diff --git a/.bestpractices.json b/.bestpractices.json index 903c55b..671f950 100644 --- a/.bestpractices.json +++ b/.bestpractices.json @@ -4,7 +4,7 @@ "project_id": 12716, "name": "ctm", - "description": "Claude Tmux Manager — survive SSH drops, reattach from your phone.", + "description": "Codex Tmux Manager — survive SSH drops, reattach from your phone.", "homepage_url": "https://github.com/RandomCodeSpace/ctm", "repo_url": "https://github.com/RandomCodeSpace/ctm", "license": "MIT", @@ -17,10 +17,9 @@ "contributing_guide": "CONTRIBUTING.md", "vulnerability_report_process": "SECURITY.md", "release_notes": "CHANGELOG.md", - "build_reproducible": "Makefile + go build -tags sqlite_fts5 ./...", + "build_reproducible": "Makefile + go build ./...", "ci_workflow": ".github/workflows/ci.yml", "release_workflow": ".github/workflows/release.yml", - "code_scanning": ".github/workflows/codeql.yml", "supply_chain_scorecard": ".github/workflows/scorecard.yml", "static_analysis_sonar": "sonar-project.properties + SonarCloud quality gate", "bestpractices_lint": ".github/workflows/bestpractices.yml", @@ -35,7 +34,7 @@ }, "description_good_status": "Met", - "description_good_justification": "README opens with: 'Claude Tmux Manager — survive SSH drops, reattach from your phone.'", + "description_good_justification": "README opens with: 'Codex Tmux Manager — survive SSH drops, reattach from your phone.'", "interact_status": "Met", "interact_justification": "GitHub Issues + Pull Requests are enabled.", @@ -44,7 +43,7 @@ "contribution_justification": "https://github.com/RandomCodeSpace/ctm/blob/main/CONTRIBUTING.md", "contribution_requirements_status": "Met", - "contribution_requirements_justification": "CONTRIBUTING.md documents PR requirements: branch naming, scoped PRs, tests required for new logic, conventional-commit subjects, all checks passing (go vet, go test -race, pnpm tsc --noEmit, pnpm vitest, SonarCloud, CodeQL, OpenSSF Scorecard). https://github.com/RandomCodeSpace/ctm/blob/main/CONTRIBUTING.md#coding-standards", + "contribution_requirements_justification": "CONTRIBUTING.md documents PR requirements: branch naming, scoped PRs, tests required for new logic, conventional-commit subjects, all checks passing (go vet, go test -race, govulncheck, SonarCloud, OpenSSF Scorecard). https://github.com/RandomCodeSpace/ctm/blob/main/CONTRIBUTING.md#coding-standards", "floss_license_status": "Met", "floss_license_justification": "MIT License.", @@ -56,7 +55,7 @@ "license_location_justification": "https://github.com/RandomCodeSpace/ctm/blob/main/LICENSE", "documentation_interface_status": "Met", - "documentation_interface_justification": "README has a Commands section listing every external interface (yolo, safe, attach, kill, list, ctm serve, etc.).", + "documentation_interface_justification": "README has a Commands section listing every external interface (yolo, yolo!, safe, attach, kill, killall, last, ls, new, pick, rename, switch, detach, forget, doctor, check, install, uninstall, version).", "sites_https_status": "Met", "sites_https_justification": "All project URLs are GitHub-hosted and use HTTPS.", @@ -122,25 +121,25 @@ "vulnerability_report_response_justification": "Formal response targets in SECURITY.md: acknowledge within 14 days, initial assessment within 30 days, fix High/Critical within 60 days, default 90-day disclosure window.", "build_status": "Met", - "build_justification": "Standard `go build -tags sqlite_fts5` builds the binary; `pnpm build` builds the embedded UI.", + "build_justification": "Standard `go build ./...` builds the binary; `make build` is the documented entry point.", "build_common_tools_status": "Met", - "build_common_tools_justification": "Build uses Go 1.24+ and pnpm — both widely available, FLOSS, and free of registration.", + "build_common_tools_justification": "Build uses Go 1.25+ and the GNU `make` driver — both widely available, FLOSS, and free of registration.", "build_floss_tools_status": "Met", - "build_floss_tools_justification": "Build toolchain is entirely FLOSS (Go, pnpm, Node.js).", + "build_floss_tools_justification": "Build toolchain is entirely FLOSS (Go, GNU make).", "test_status": "Met", - "test_justification": "918 Go tests across 27 packages, 206 UI tests across 29 files.", + "test_justification": "336 Go tests across 19 packages exercise CLI helpers, the agent registry, codex-impl spawn/discover paths, session migrations, tmux client argv builders, and the integration smoke pack.", "test_invocation_status": "Met", - "test_invocation_justification": "README documents `go test -tags sqlite_fts5 ./...` and `pnpm exec vitest run`.", + "test_invocation_justification": "CONTRIBUTING.md documents `go test ./...` and `make regression` (race + govulncheck).", "test_most_status": "Met", - "test_most_justification": "85.2% line coverage measured by SonarCloud across Go + TypeScript.", + "test_most_justification": "Line coverage tracked via SonarCloud's Go quality gate; coverage data uploaded by .github/workflows/sonar.yml on every push.", "test_continuous_integration_status": "Met", - "test_continuous_integration_justification": "GitHub Actions runs Go build/test, UI typecheck/test, SonarCloud, CodeQL, and Scorecard on every push and PR.", + "test_continuous_integration_justification": "GitHub Actions runs Go build/test (ci.yml), SonarCloud (sonar.yml), and Scorecard (scorecard.yml) on every push and PR.", "test_policy_status": "Met", "test_policy_justification": "New features must ship with tests; SonarCloud's new-code coverage gate fails PRs that drop coverage below threshold.", @@ -152,46 +151,46 @@ "tests_documented_added_justification": "test_policy is enforced in PR review and by the coverage gate.", "warnings_status": "Met", - "warnings_justification": "go vet, gopls language-server checks, ESLint with strict TypeScript rules, and SonarCloud all run on every push.", + "warnings_justification": "go vet, gopls language-server checks, and SonarCloud all run on every push.", "warnings_fixed_status": "Met", - "warnings_fixed_justification": "Warnings surfaced by gopls / ESLint / Sonar are addressed (or explicitly Accepted with a justification) before merge.", + "warnings_fixed_justification": "Warnings surfaced by gopls or Sonar are addressed (or explicitly Accepted with a justification) before merge.", "warnings_strict_status": "Met", - "warnings_strict_justification": "TypeScript strict mode enabled in tsconfig.json; SonarCloud quality gate fails the build on new BLOCKER/CRITICAL findings.", + "warnings_strict_justification": "SonarCloud quality gate fails the build on new BLOCKER/CRITICAL findings; `go vet` failures are CI-fatal.", "know_secure_design_status": "Met", - "know_secure_design_justification": "Maintainer follows OWASP Top-10 guidance; the v0.3 spec set explicitly documented threat-model decisions for auth (V27 argon2id), session token storage, and the reverse-proxy origin check.", + "know_secure_design_justification": "Maintainer follows OWASP Top-10 guidance. Threat-model decisions are documented in SECURITY.md; the v0.3 release explicitly removed all networked surface (HTTP daemon, auth, webhook delivery) so the remaining attack surface is the local CLI plus its on-disk state under `~/.config/ctm/` and `~/.codex/sessions/`.", "know_common_errors_status": "Met", "know_common_errors_justification": "Maintainer is familiar with OWASP Top-10 and CWE/SANS Top-25 patterns and applies them at PR review.", - "crypto_published_status": "Met", - "crypto_published_justification": "Auth uses argon2id (RFC 9106) and standard library crypto/rand — both published and widely reviewed.", + "crypto_published_status": "N/A", + "crypto_published_justification": "ctm performs no cryptographic operations beyond UUID generation since the v0.3 removal of the auth daemon.", - "crypto_call_status": "Met", - "crypto_call_justification": "Password hashing routes through Go's golang.org/x/crypto/argon2 package; no hand-rolled crypto.", + "crypto_call_status": "N/A", + "crypto_call_justification": "No cryptographic primitive calls remain after the auth/session-token subsystem was removed in v0.3.", "crypto_floss_status": "Met", - "crypto_floss_justification": "All crypto routines are from Go's stdlib or x/crypto — both FLOSS.", + "crypto_floss_justification": "The only crypto call left in the codebase is `crypto/rand` (Go stdlib, FLOSS) for UUID generation in internal/session.", - "crypto_keylength_status": "Met", - "crypto_keylength_justification": "Argon2id parameters meet OWASP recommendations (memory >= 19 MiB, iterations >= 2, parallelism = 1). Session tokens are 256-bit random.", + "crypto_keylength_status": "N/A", + "crypto_keylength_justification": "No keyed cryptographic operations remain.", "crypto_working_status": "Met", - "crypto_working_justification": "No known-broken algorithms (no MD5/SHA1 for integrity, no DES, no RC4).", + "crypto_working_justification": "No known-broken algorithms anywhere in the codebase (no MD5/SHA1 for integrity, no DES, no RC4).", "crypto_weaknesses_status": "Met", "crypto_weaknesses_justification": "No use of MD5, SHA1 (for integrity), DES, RC4, or ECB mode anywhere in the codebase.", "crypto_pfs_status": "N/A", - "crypto_pfs_justification": "ctm binds 127.0.0.1 only; TLS termination is the operator's reverse-proxy responsibility.", + "crypto_pfs_justification": "ctm has no network listener since v0.3; TLS / PFS are not relevant.", - "crypto_password_storage_status": "Met", - "crypto_password_storage_justification": "Passwords stored as argon2id hashes (V27 single-user auth); never logged or persisted in plaintext.", + "crypto_password_storage_status": "N/A", + "crypto_password_storage_justification": "ctm stores no user passwords since the V27 single-user auth daemon was removed in v0.3.", "crypto_random_status": "Met", - "crypto_random_justification": "All session tokens generated via crypto/rand.", + "crypto_random_justification": "Session UUIDs (the only random IDs ctm generates) come from `crypto/rand` via internal/session/uuid.go.", "delivery_mitm_status": "Met", "delivery_mitm_justification": "Releases delivered via HTTPS (GitHub Releases) with TLS-protected git fetch.", @@ -209,10 +208,10 @@ "no_leaked_credentials_justification": "SonarCloud's secret-detection rules + GitHub's secret scanning run on every push; no credentials in commit history.", "static_analysis_status": "Met", - "static_analysis_justification": "SonarCloud (Go + TypeScript) and CodeQL (security) run on every push and PR.", + "static_analysis_justification": "SonarCloud (Go) runs on every push and PR via .github/workflows/sonar.yml; `go vet` runs in CI; `govulncheck` runs as part of `make regression`.", "static_analysis_common_vulnerabilities_status": "Met", - "static_analysis_common_vulnerabilities_justification": "CodeQL covers OWASP Top-10 vulnerability families; SonarCloud's security profile covers CWE Top-25.", + "static_analysis_common_vulnerabilities_justification": "SonarCloud's security profile covers CWE Top-25; `govulncheck` reports any reachable CVE in the Go module graph.", "static_analysis_fixed_status": "Met", "static_analysis_fixed_justification": "Findings are either fixed in code or explicitly Accepted with a documented justification.", @@ -221,7 +220,7 @@ "static_analysis_often_justification": "Static analysis runs on every push and PR — well exceeding the 'before each release' bar.", "dynamic_analysis_status": "Met", - "dynamic_analysis_justification": "`go test -race ./...` runs Go's runtime data-race detector on every PR and on every release. The race detector instruments memory accesses across goroutines and panics on a detected race; for a goroutine-heavy HTTP+tmux daemon this is the realistic dynamic-analysis tool.", + "dynamic_analysis_justification": "`go test -race ./...` runs Go's runtime data-race detector on every PR and release. The race detector instruments memory accesses across goroutines and panics on a detected race; ctm uses goroutines for the post-spawn agent-session-id discovery path, so the race detector remains the realistic dynamic-analysis tool even after the daemon was removed.", "dynamic_analysis_unsafe_status": "N/A", "dynamic_analysis_unsafe_justification": "Go is memory-safe (no manual memory management; bounds-checked slices; nil-checked pointer dereferences). Race-detector coverage above provides the meaningful dynamic-safety check.", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e99ae2e..16f40a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,10 @@ -# PR + non-main-push gate. Runs the same UI build + Go build + tests -# the release pipeline depends on, so a broken main is caught before -# merge instead of after. +# PR + non-main-push gate. Runs the Go build + tests the release +# pipeline depends on, so a broken main is caught before merge instead +# of after. # -# Scope intentionally narrower than `make regression`: e2e (Playwright -# browser install + browser run), `pnpm audit`, and `govulncheck` are -# heavier and currently run via `make regression` locally — adding them -# here is a follow-up if/when they prove necessary as merge gates. +# Scope intentionally narrower than `make regression`: `govulncheck` is +# heavier and currently runs via `make regression` locally — adding it +# here is a follow-up if/when it proves necessary as a merge gate. name: CI @@ -36,27 +35,6 @@ jobs: with: go-version-file: go.mod - - name: Set up pnpm - # Pinned to commit SHA per supply-chain hygiene — third-party - # actions can be rewritten under a moving tag. Bump by re-running - # `gh api repos/pnpm/action-setup/git/refs/tags/v4`. - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - with: - version: 10.33.0 - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: ui/pnpm-lock.yaml - - - name: Build UI bundle - # Required before any Go invocation: //go:embed all:dist in - # internal/serve/assets.go errors out if internal/serve/dist/ - # is empty. - run: make ui - - name: Go vet run: go vet -tags sqlite_fts5 ./... @@ -66,14 +44,8 @@ jobs: - name: Go test # -race: Go's runtime data-race detector. Instruments memory # accesses across goroutines and panics on a detected race — - # the realistic dynamic-analysis tool for a pure-Go HTTP + - # tmux-shelling-out daemon. Satisfies the OpenSSF Best + # the realistic dynamic-analysis tool for a goroutine-heavy + # CLI that shells out to tmux. Satisfies the OpenSSF Best # Practices `dynamic_analysis` and # `dynamic_analysis_enable_assertions` criteria. run: go test -tags sqlite_fts5 -race ./... - - - name: UI typecheck - run: pnpm -C ui exec tsc --noEmit - - - name: UI test - run: pnpm -C ui test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc2d94d..c804e08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,39 +40,10 @@ jobs: # and the release build succeed against the source tree. go-version-file: go.mod - - name: Set up pnpm - # Pinned to commit SHA per supply-chain hygiene — third-party - # actions can be rewritten under a moving tag. Bump by re-running - # `gh api repos/pnpm/action-setup/git/refs/tags/v4`. - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - with: - # Pinned to ui/package.json packageManager so a bumped lockfile - # doesn't desync from the toolchain CI runs the build with. - version: 10.33.0 - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: ui/pnpm-lock.yaml - - - name: Build UI bundle - # Populates internal/serve/dist/ from ui/dist/ via rsync so the - # `//go:embed all:dist` directive in internal/serve/assets.go has - # a non-empty match. Without this, both `go test ./...` and the - # cross-compiled binary builds below fail at compile time. The - # populated dist is naturally bundled into the air-gapped source - # tarball later (the tar excludes top-level ./dist staging only, - # not internal/serve/dist). - run: make ui - - name: Run tests # sqlite_fts5: mattn/go-sqlite3 only compiles FTS5 in when this - # tag is set. internal/serve/store's schema uses - # `CREATE VIRTUAL TABLE … USING fts5(…)` — without the tag, - # OpenCostStore() fails at runtime with "no such module: fts5" - # and ~25 store/serve tests blow up. + # tag is set. Stores using `CREATE VIRTUAL TABLE … USING fts5(…)` + # fail at runtime with "no such module: fts5" without it. # -race: Go's data-race detector. Gates the release on a clean # dynamic-analysis pass per OpenSSF Best Practices # `dynamic_analysis` / `dynamic_analysis_enable_assertions`. diff --git a/.github/workflows/sonar-bulk-accept.yml b/.github/workflows/sonar-bulk-accept.yml index 9101681..a183f5b 100644 --- a/.github/workflows/sonar-bulk-accept.yml +++ b/.github/workflows/sonar-bulk-accept.yml @@ -114,166 +114,61 @@ jobs: } # ───────────────────────────────────────────────────────────── - # Bucket 1: typescript:S6759 — "Mark props as read-only" - # The codebase deliberately does not adopt Readonly; this - # is a project-wide style choice, not a per-component miss. - KEYS=$(fetch_keys "typescript:S6759") - bulk_accept "S6759 readonly-props" "${KEYS}" \ - "Project style: props interfaces are not wrapped in Readonly<>. Deliberate — accepted." - - # Bucket 2: typescript:S6819 — "Use instead of role=status" - # The role=status pattern is acceptable and used consistently - # for transient status text; is not adopted project-wide. - KEYS=$(fetch_keys "typescript:S6819") - bulk_accept "S6819 role-status" "${KEYS}" \ - "role=status is the established pattern for transient status text; not adopted. Accepted." - - # Bucket 3: typescript:S3358 — nested ternaries - # All remaining occurrences are inline JSX render expressions - # where extracting helpers would harm readability. - KEYS=$(fetch_keys "typescript:S3358") - bulk_accept "S3358 nested-ternary" "${KEYS}" \ - "Inline JSX render — extracting a helper hurts readability more than the nesting. Accepted." - - # Bucket 4: typescript:S6571 — redundant union members - # Most are deliberate "string | undefined" / "T | null" shapes - # used as explicit escape hatches at API boundaries. - KEYS=$(fetch_keys "typescript:S6571") - bulk_accept "S6571 redundant-type" "${KEYS}" \ - "Union members are intentional escape hatches at API boundaries. Accepted." - - # Bucket 5: typescript:S6754 — useState destructuring style - # The chosen form (no destructuring of the setter) is intentional - # in a couple of one-shot setters; not worth churn. - KEYS=$(fetch_keys "typescript:S6754") - bulk_accept "S6754 useState-style" "${KEYS}" \ - "Chosen form is intentional for these one-shot setters. Accepted." - - # Bucket 6: typescript:S6479 — array-index keys - # Used only where the list is statically ordered (timestamps in - # row keys, doctor checks). React reconciliation is unaffected. - KEYS=$(fetch_keys "typescript:S6479") - bulk_accept "S6479 array-index-key" "${KEYS}" \ - "Lists are append-only with stable per-row prefixes; index suffix is fine. Accepted." - - # Bucket 7: typescript:S3735 — `void` operator - # We use `void` to discard an awaited Promise result intentionally - # (fire-and-forget within useEffect / event handlers). - KEYS=$(fetch_keys "typescript:S3735") - bulk_accept "S3735 void-operator" "${KEYS}" \ - "Fire-and-forget Promise in event handler / useEffect; void is the documented escape. Accepted." - - # Bucket 8: typescript:S1874 + javascript:S1874 — use of deprecated APIs - # The deprecations flagged are in third-party libs (react-router 6→7 - # transition residue) where the migration target also fires Sonar. - KEYS=$(fetch_keys "typescript:S1874,javascript:S1874") - bulk_accept "S1874 deprecation" "${KEYS}" \ - "Deprecation is in transitional library API; migration tracked separately. Accepted." - - # Bucket 9: typescript:S7763 — re-export shorthand - # Existing shape is more grep-able for the codebase's small surface; - # the rule's preferred form is fine but not worth churn. - KEYS=$(fetch_keys "typescript:S7763") - bulk_accept "S7763 export-from" "${KEYS}" \ - "Existing form is intentional for symbol grep clarity. Accepted." - - # Bucket 10: typescript:S7718 — prefer Set#has over Array#includes - # Inputs are O(<10) — Set construction overhead exceeds savings. - KEYS=$(fetch_keys "typescript:S7718") - bulk_accept "S7718 set-has" "${KEYS}" \ - "Lookup arrays have <10 elements; Array#includes is faster. Accepted." - - # Bucket 11: typescript:S6772 — "ambiguous spacing" - # Remaining occurrences are inside / tag trees where - # the chosen form is intentional. Reliability-impact ones already fixed. - KEYS=$(fetch_keys "typescript:S6772") - bulk_accept "S6772 ambiguous-spacing" "${KEYS}" \ - "Spacing is intentional inside the affected text/code spans. Accepted." - - # Bucket 12: godre:S8205 — named struct types + # Bucket 1: godre:S8205 — named struct types # Anonymous struct types are intentional in test scaffolding and # request-decode shapes that aren't reused. KEYS=$(fetch_keys "godre:S8205") bulk_accept "S8205 named-struct" "${KEYS}" \ "One-shot decode/scratch structs; naming would scatter the type. Accepted." - # Bucket 13: godre:S8196 — interface naming + # Bucket 2: godre:S8196 — interface naming # Existing names are domain-aligned (InputSessionSource, ProjRefresher). # Renaming would touch a wide blast radius for a stylistic nit. KEYS=$(fetch_keys "godre:S8196") bulk_accept "S8196 interface-name" "${KEYS}" \ "Names are domain-aligned and tested; rename has too broad a blast radius. Accepted." - # Bucket 14: godre:S8193 — receiver naming + # Bucket 3: godre:S8193 — receiver naming # Receiver names are short and consistent within each type; # the rule's "first-letter" preference doesn't add value here. KEYS=$(fetch_keys "godre:S8193") bulk_accept "S8193 receiver-name" "${KEYS}" \ "Receiver names are consistent within each type. Accepted." - # Bucket 15: godre:S8242 — context.Context as struct field - # Used in a long-lived daemon component where ctx genuinely lives - # on the struct (cancellation propagates through the lifecycle). + # Bucket 4: godre:S8242 — context.Context as struct field + # Used in long-lived components where ctx genuinely lives on + # the struct (cancellation propagates through the lifecycle). KEYS=$(fetch_keys "godre:S8242") bulk_accept "S8242 ctx-field" "${KEYS}" \ - "Daemon-scoped ctx travels with the struct's lifecycle. Accepted." + "Lifecycle-scoped ctx travels with the struct. Accepted." - # Bucket 16: go:S107 + go:S117 — too many params / variable name - # Existing shape mirrors HTTP handler / cobra signatures. + # Bucket 5: go:S107 + go:S117 — too many params / variable name + # Existing shape mirrors cobra signatures. KEYS=$(fetch_keys "go:S107,go:S117") bulk_accept "S107/S117 signature" "${KEYS}" \ - "Signature mirrors handler / cobra contracts. Accepted." - - # Bucket 17: typescript:S6582 — optional chain - # Already fixed where applicable; remaining are intentional - # truthiness checks (e.g. `&& obj.field` where obj is required). - KEYS=$(fetch_keys "typescript:S6582") - bulk_accept "S6582 optional-chain" "${KEYS}" \ - "Remaining occurrences are intentional truthiness checks on required fields. Accepted." - - # Bucket 18: typescript:S4624 — nested template literals - # Used for compact JSX label composition; collapsing harms clarity. - KEYS=$(fetch_keys "typescript:S4624") - bulk_accept "S4624 nested-template" "${KEYS}" \ - "Compact JSX label composition; collapsing harms clarity. Accepted." - - # Bucket 19: typescript:S6822 — implicit list role (remaining only) - # Reliability-impact occurrences fixed in code; remaining list - # elements are inside scrollable card bodies where the parent - # treats them as decorative. - KEYS=$(fetch_keys "typescript:S6822") - bulk_accept "S6822 implicit-list" "${KEYS}" \ - "Remaining list elements are decorative within scrollable card bodies. Accepted." - - # Bucket 20: typescript:S1871 — duplicate case body - # The duplicate clauses document distinct semantic categories - # that happen to dispatch to the same code path. - KEYS=$(fetch_keys "typescript:S1871") - bulk_accept "S1871 duplicate-case" "${KEYS}" \ - "Cases document distinct semantic categories sharing one code path. Accepted." + "Signature mirrors cobra contracts. Accepted." - # Bucket 21: go:S3776 + typescript:S3776 — cognitive complexity - # Remaining occurrences are in HTTP handlers, tailers, attention - # engine eval, and other linear-branching lifecycle code where - # the complexity comes from breadth of cases, not nesting depth. + # Bucket 6: go:S3776 — cognitive complexity + # Remaining occurrences are in tailers, attention engine eval, + # and other linear-branching lifecycle code where the + # complexity comes from breadth of cases, not nesting depth. # Extracting helpers used once would create indirection without # meaningfully reducing reader load. Accepted as a project-wide # judgement call rather than per-function. - KEYS=$(fetch_keys "go:S3776,typescript:S3776") + KEYS=$(fetch_keys "go:S3776") bulk_accept "S3776 cognitive-complexity" "${KEYS}" \ - "Production handlers / engines: complexity is breadth of cases, not nesting. Extracting single-use helpers harms readability. Accepted." + "Production engines: complexity is breadth of cases, not nesting. Extracting single-use helpers harms readability. Accepted." # ───────────────────────────────────────────────────────────── - # Bucket 22: ALL remaining smells in *_test.go / *.test.ts(x) + # Bucket 7: ALL remaining smells in *_test.go # Test code is intentionally dense (table-driven cases, mock - # plumbing, deep ternaries to express expected outputs). The - # cognitive-complexity / readonly / etc. rules are noise here. + # plumbing). The cognitive-complexity / etc. rules are noise here. test_keys=$(curl -sSf -u "${SONAR_TOKEN}:" \ "${SONAR_HOST}/api/issues/search?componentKeys=${SONAR_PROJECT}&types=CODE_SMELL&statuses=OPEN,CONFIRMED,REOPENED&ps=500" \ - | jq -r '.issues[] | select(.component | test("(_test\\.go|\\.test\\.tsx?)$")) | .key' \ + | jq -r '.issues[] | select(.component | test("_test\\.go$")) | .key' \ | paste -sd, -) bulk_accept "test-file smells" "${test_keys}" \ - "Test code: table-driven density / mock plumbing / explicit ternaries are by design. Accepted." + "Test code: table-driven density / mock plumbing are by design. Accepted." # ───────────────────────────────────────────────────────────── # FALSE POSITIVE bucket — the rule has misfired here. diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 4e13c39..bf84c2b 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -5,10 +5,10 @@ # in-flight scans. # # Coverage is collected fresh in this workflow rather than reused from -# CI — Sonar needs both go-coverprofile and lcov in the same workspace -# pass, and the existing CI job runs `go test` without -coverprofile. -# Adding coverage there would slow every PR build; keeping it here -# isolates the cost to the Sonar job. +# CI — Sonar needs the go-coverprofile in the same workspace pass, and +# the existing CI job runs `go test` without -coverprofile. Adding +# coverage there would slow every PR build; keeping it here isolates +# the cost to the Sonar job. name: SonarCloud @@ -44,27 +44,6 @@ jobs: with: go-version-file: go.mod - - name: Set up pnpm - # Pinned to commit SHA (v4) per supply-chain hygiene — third-party - # actions can be rewritten under a moving tag. Bump by re-running - # `gh api repos/pnpm/action-setup/git/refs/tags/v4`. - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - with: - version: 10.33.0 - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: ui/pnpm-lock.yaml - - - name: Build UI bundle - # Required before any Go test run: //go:embed all:dist in - # internal/serve/assets.go errors out if internal/serve/dist/ - # is empty. Same as ci.yml. - run: make ui - - name: Go test with coverage run: | go test -tags sqlite_fts5 \ @@ -72,17 +51,6 @@ jobs: -covermode=atomic \ ./... - - name: UI install - # --ignore-scripts: refuse to execute lifecycle scripts from - # transitive deps (Sonar S6505). vitest + the React stack don't - # require any postinstall; esbuild ships per-platform binaries - # via optional deps. If a future dep needs a build step, add it - # to `pnpm.onlyBuiltDependencies` in ui/package.json instead. - run: pnpm -C ui install --frozen-lockfile --ignore-scripts - - - name: UI test with coverage - run: pnpm -C ui exec vitest run --coverage - - name: Detect SONAR_TOKEN id: token env: diff --git a/.gitignore b/.gitignore index 573cdb1..923957e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,18 +15,17 @@ coverage.* .DS_Store .superpowers/ -# UI build output (rsync'd from ui/dist for go:embed; regenerated on build) -internal/serve/dist/ - # Planning / spec docs — kept locally, not shared in repo history docs/ # Subagent worktree scratch paths (managed by the harness, not source) .claude/worktrees/ +# Local agent loop state (ralph-loop iteration snapshots, etc.) — runtime, not source +.claude/*.local.md + # Local code-intelligence cache (codeiq) — runtime DB + neo4j store, not source .codeiq/ -# Coverage reports (Go + vitest) — regenerated per-test, never committed +# Coverage reports — regenerated per-test, never committed coverage.out -ui/coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 58fcb23..27f6032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,11 @@ Each release is identified by an immutable `vX.Y.Z` git tag. Releases are cut by the [`release.yml`](.github/workflows/release.yml) workflow. On every push to `main` the workflow: -1. Builds the embedded UI (`make ui`). -2. Runs the full Go test suite under the race detector - (`go test -tags sqlite_fts5 -race ./...`). -3. Cross-compiles `linux-amd64`, `linux-arm64`, `darwin-amd64`, +1. Runs the full Go test suite under the race detector + (`go test -race ./...`). +2. Cross-compiles `linux-amd64`, `linux-arm64`, `darwin-amd64`, `darwin-arm64` binaries plus a vendored source tarball. -4. Publishes a GitHub Release with `SHA256SUMS`, conventional-commit +3. Publishes a GitHub Release with `SHA256SUMS`, conventional-commit grouped notes, and an air-gapped source archive. This in-repo file is the canonical, human-curated history. The @@ -26,7 +25,109 @@ generated notes plus the signed checksums — see ## [Unreleased] -No unreleased changes. +### Changed + +- `internal/config/config.go` schema bumped to v2. Existing + `config.json` files with `required_in_path: ["claude", …]` are + migrated to `["codex", …]` on next `ctm` invocation. User + additions to the array are preserved. +- `.bestpractices.json` refreshed for the post-daemon, codex-only + state: description swap, dropped `sqlite_fts5`/pnpm/playwright + build references, dropped non-existent CodeQL evidence, flipped + argon2id-related crypto criteria to N/A (auth subsystem removed), + reframed dynamic-analysis justification around the post-spawn + goroutine path. Maintainer still needs to click *Save (and + continue) 🤖* on bestpractices.dev project 12716 to re-ingest. + +### Fixed + +- Codex thread UUID is now discovered and stamped onto + `Session.AgentSessionID` post-spawn (5 s budget). Future reattach + uses `codex resume ` instead of falling through to + `codex resume --last`, so multi-session users land on the right + thread. + +## [0.3.0] — 2026-05-14 + +**BREAKING.** ctm is now a pure CLI. The embedded web dashboard and +the `ctm serve` HTTP daemon are gone entirely. Mobile-first SSH +reattach (`ctm last`, `ctm pick`, OSC52 clipboard, Alt-prefix keys) +remains the supported workflow. + +### Removed + +- The React web dashboard (`ui/` tree) and its build pipeline + (`make ui`, `pnpm`, `vite`, `playwright`). +- The `ctm serve` HTTP daemon (`internal/serve/`) and its + `ctm serve` subcommand. +- The `ctm auth` subcommand (single-user argon2id auth existed only + to gate the now-removed web UI). The `~/.config/ctm/auth.json` + password file is no longer read or written. +- All HTTP API endpoints (status feed, quota, session CRUD, + remote-control bridge). +- The SSE event bus and live tool-use feed used by the dashboard. +- Outbound webhook delivery and webhook signing. +- `~/.config/ctm/allowed_origins` and the Origin / CORS allow-list — + no HTTP surface remains to protect. +- The bearer-token + Origin gate documented in earlier SECURITY.md + revisions. + +### Changed + +- `sessions.json` schema is unchanged from v3; existing sessions + attach normally after upgrade. +- `release.yml` no longer builds the embedded UI; the release + artifact set is unchanged (binaries + `SHA256SUMS` + vendored + source tarball). +- SECURITY.md scope narrowed to the CLI, the on-disk state files, + and supply-chain integrity. CONTRIBUTING.md drops UI / Playwright + / `pnpm` instructions. + +## [0.2.0] — 2026-05-14 + +**BREAKING.** Anthropic's `claude` CLI is no longer supported. +OpenAI's [`codex`](https://github.com/openai/codex) CLI (0.130.0+) is +now the only supported agent. Existing `sessions.json` rows are +migrated automatically on first launch. + +### Changed + +- `sessions.json` schema bumped to **v3**. The migrator rewrites any + legacy `agent="claude"` rows to `agent="codex"` in place. The + original bytes are preserved at `sessions.json.bak.` + per the standard migration safety net. +- Session resume now uses `codex resume || codex` (with + `codex resume --last` available for the most recent session) + instead of `claude --resume … || claude --session-id …`. +- YOLO mode now launches `codex --sandbox danger-full-access` + instead of `claude --dangerously-skip-permissions`. The git + checkpoint behaviour is unchanged. +- README, SECURITY, and CONTRIBUTING rebranded: "Claude Tmux + Manager" → "Codex Tmux Manager". The short name `ctm` is unchanged. + +### Removed + +- `claude-overlay.json` (the `--settings`-layered overlay file). + Codex reads `~/.codex/config.toml` natively; ctm no longer + maintains an overlay layer. +- `claude-env.json` / `env.sh` env-prelude. Codex's own + `shell_environment_policy` covers this; users needing extra env + should export from their own shell rc. +- `ctm overlay` and all its subcommands (`init` / `edit` / `path`). +- `ctm statusline` and the 3-line context-fill / rate-limit + renderer. Codex doesn't emit equivalent telemetry, so the tmux + statusline is now just session name + cwd + activity dot. +- `ctm logs` and the PostToolUse JSONL hook tailer. Codex's hook + payload format differs and ctm no longer logs tool calls. +- `~/.claude/projects/*/.jsonl` log tailer — no codex + equivalent. + +### Added + +- `internal/agent/codex/` — the codex-specific Agent + implementation (invocation, resume, sandbox flags). Replaces the + previous claude-specific code path that lived under the same + `Agent` interface. ## [0.1.0] — 2026-04-18 onwards diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d60e69b..b0d7b1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,12 +23,11 @@ follow the process in [SECURITY.md](./SECURITY.md). 3. **Tests are required for new logic.** SonarCloud's new-code coverage gate fails PRs that drop coverage below threshold. Match the existing test style in the package you're touching - (table-driven Go tests, vitest + React Testing Library on the UI). + (table-driven Go tests). 4. **All checks must pass before merge:** `go vet`, `go build`, - `go test`, `pnpm exec tsc --noEmit`, `pnpm exec vitest run`, - SonarCloud quality gate, CodeQL, and OpenSSF Scorecard. CI runs - them automatically; locally you can run `make regression` for the - superset. + `go test`, SonarCloud quality gate, CodeQL, and OpenSSF + Scorecard. CI runs them automatically; locally you can run + `make regression` for the superset. 5. **Conventional commit subjects.** Use the prefix that matches the change kind: `feat:`, `fix:`, `refactor:`, `chore:`, `docs:`, `test:`, `ci:`, `perf:`. The release workflow groups release notes @@ -42,11 +41,9 @@ follow the process in [SECURITY.md](./SECURITY.md). | Area | Tool / convention | |---|---| | Go formatting | `gofmt -w` (run automatically by most editors) | -| Go vet | `go vet -tags sqlite_fts5 ./...` — must be clean | +| Go vet | `go vet ./...` — must be clean | | Go style | Standard Go review comments + Effective Go conventions | -| TypeScript | `pnpm exec tsc --noEmit` — strict mode is on; no `any` without justification | -| TS / React style | ESLint via the existing `ui/eslint.config.*` setup | -| Test layers | unit (pure logic), integration (DB + tmux), e2e (Playwright); see `rules/testing.md` in your local Claude config if you have one | +| Test layers | unit (pure logic) and integration (tmux + filesystem); see `rules/testing.md` in your local agent config if you have one | | File layout | Follow existing patterns. New `cmd/.go` for cobra wiring + `cmd/_runners.go` for integration-bound RunE bodies (see `cmd/yolo.go` ↔ `cmd/yolo_runners.go` for the split) | | Comments | Lead with *why* a non-obvious decision was made. Don't restate what the code does | | Dependency updates | Use `context7` MCP / vendor docs to find the latest compatible version; never guess. Permissive licenses only (MIT / Apache-2.0 / BSD) — flag GPL/AGPL for review | diff --git a/Makefile b/Makefile index 309e15c..ebf130c 100644 --- a/Makefile +++ b/Makefile @@ -1,86 +1,29 @@ -# ctm Makefile — UI build, Go build, and dev orchestration. -# -# Step 8 scope: `make ui` produces the React bundle and copies it into -# internal/serve/dist/ so Go can //go:embed it (sibling required because -# go:embed rejects parent-relative paths). - -UI_DIR := ui -UI_DIST := $(UI_DIR)/dist -EMBED_DIST := internal/serve/dist +# ctm Makefile — Go build and regression orchestration. -# V19 slice 3 requires SQLite FTS5. mattn/go-sqlite3 compiles FTS5 in -# only when the sqlite_fts5 build tag is set; applied to every go -# build / test / install invocation below. Binaries built without it -# will panic at boot on "no such module: fts5". -GO_TAGS := sqlite_fts5 - -.PHONY: ui build dev clean help e2e regression +.PHONY: build help regression help: @echo "Targets:" - @echo " ui — install + build React UI, sync to $(EMBED_DIST)" - @echo " build — make ui && go build ./..." - @echo " dev — pnpm --prefix ui dev + go run . serve in parallel" - @echo " e2e — Playwright tests against vite preview, mocked API surface" - @echo " regression — full pre-merge pack: go build/test/race/vuln + ui tsc/vitest/audit/e2e" - @echo " clean — remove $(UI_DIST) and $(EMBED_DIST)" - -ui: - @echo "==> pnpm install" - cd $(UI_DIR) && pnpm install --frozen-lockfile - @echo "==> vite build" - cd $(UI_DIR) && pnpm build - @echo "==> rsync $(UI_DIST)/ → $(EMBED_DIST)/" - mkdir -p $(EMBED_DIST) - rsync -a --delete $(UI_DIST)/ $(EMBED_DIST)/ + @echo " build — go build ./..." + @echo " regression — full pre-merge pack: go build/test/race/vuln" -build: ui +build: @echo "==> go build" - go build -trimpath -tags $(GO_TAGS) ./... - -# Dev: run pnpm dev (Vite proxies to :37778) and go run . serve in parallel. -# Trap SIGINT so Ctrl-C tears down both. -dev: - @trap 'kill 0' INT TERM EXIT; \ - pnpm --prefix $(UI_DIR) dev & \ - go run . serve & \ - wait - -clean: - rm -rf $(UI_DIST) $(EMBED_DIST) - -# Playwright E2E. Uses Chromium from ~/.cache/ms-playwright (installed via -# `pnpm exec playwright install chromium`). Mocks /api + /events at page -# level so tests don't need a running daemon or fixture DB. Run -# `pnpm --prefix ui exec playwright install chromium` once before first run. -e2e: - @echo "==> pnpm build (needed for vite preview)" - cd $(UI_DIR) && pnpm build - @echo "==> playwright test" - cd $(UI_DIR) && pnpm exec playwright test + go build -trimpath ./... # Unified regression pack. Runs everything a PR must clear before merge. -# Fails fast — first non-zero exit stops the run. Total wall time on this -# machine is ~60-90s depending on Go cache state. +# Fails fast — first non-zero exit stops the run. # # Contract: every shipped bug fix or new feature adds a test case that -# executes under one of these steps (unit / vitest / e2e). The pack grows; -# it does not get replaced. See ui/e2e/README.md. +# executes under one of these steps. The pack grows; it does not get +# replaced. regression: @echo "==> go build ./..." - go build -tags $(GO_TAGS) ./... + go build ./... @echo "==> go test ./..." - go test -tags $(GO_TAGS) ./... - @echo "==> go test -race ./internal/serve/..." - go test -race -tags $(GO_TAGS) ./internal/serve/... + go test ./... + @echo "==> go test -race ./..." + go test -race ./... @echo "==> govulncheck ./..." govulncheck ./... - @echo "==> pnpm -C ui tsc --noEmit" - pnpm -C $(UI_DIR) exec tsc --noEmit - @echo "==> pnpm -C ui test (vitest)" - pnpm -C $(UI_DIR) test - @echo "==> pnpm audit (High/Critical fails; Medium/Low reported)" - cd $(UI_DIR) && pnpm audit --audit-level=high - @echo "==> make e2e" - $(MAKE) e2e @echo "==> regression pack OK" diff --git a/README.md b/README.md index c4bd917..53d5880 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

ctm

-

Claude Tmux Manager — survive SSH drops, reattach from your phone.

+

Codex Tmux Manager — survive SSH drops, reattach from your phone.

Latest release @@ -15,8 +15,7 @@ Quickstart · Commands · Mobile · - Config · - Statusline + Config

## Quickstart @@ -28,7 +27,7 @@ Download the prebuilt binary for your platform (no Go toolchain needed): curl -LO https://github.com/RandomCodeSpace/ctm/releases/latest/download/ctm-$(curl -s https://api.github.com/repos/RandomCodeSpace/ctm/releases/latest | jq -r .tag_name)-linux-amd64.tar.gz tar xzf ctm-*-linux-amd64.tar.gz && sudo mv ctm-*/ctm /usr/local/bin/ -ctm # launches tmux + claude; drop SSH, reattach anytime +ctm # launches tmux + codex; drop SSH, reattach anytime ctm last # one-word reconnect from your phone ``` @@ -42,26 +41,22 @@ Either way, `ctm` bootstraps `~/.config/ctm/` on first run and injects shell ali ## Why ctm? -Claude Code on a remote dev box is great until your train enters a tunnel. Plain SSH + a direct `claude` invocation dies with the connection; reconnecting starts from scratch. **ctm wraps claude in tmux with mobile-first defaults** — Alt-based keybindings, OSC52 clipboard, one-keystroke session pickers, stale-session markers — so the conversation keeps running while you're underground, and reattaches from your phone with a single word. +Codex on a remote dev box is great until your train enters a tunnel. Plain SSH + a direct `codex` invocation dies with the connection; reconnecting starts from scratch. **ctm wraps codex in tmux with mobile-first defaults** — Alt-based keybindings, OSC52 clipboard, one-keystroke session pickers, stale-session markers — so the conversation keeps running while you're underground, and reattaches from your phone with a single word. ``` -Opus 4.7 (1M) ~/projects/ctm -c 49% (486.8k) w 34% h 25% -↑ 118.6k ↓ 434.8k xhigh +codex 0.130.0 ~/projects/ctm ● ``` -(Above: the 3-line statusline ctm ships. Context fill + rate limits + cumulative tokens + current `/effort`, all from one hook.) +(Above: ctm's tmux statusline — agent + cwd + activity dot. Codex doesn't expose context/rate-limit telemetry, so the line stays minimal.) ## Features - **Mobile-first workflow.** `ctm last`, `ctm pick `, `Alt-a` second prefix, OSC52 clipboard sync, stale-session markers — the entire UX assumes you're on a phone with flaky Wi-Fi and a fat thumb. -- **Persistent sessions.** tmux-backed. Claude keeps running when SSH drops; reattach from any device. -- **Resume with fallback.** `claude --resume UUID || claude --session-id UUID` — recovers cleanly when session history is missing. -- **Tool-use logging.** Built-in PostToolUse hook writes one JSONL line per tool call; `ctm logs --since 7d --tool Bash --grep pattern` queries transparently across rotated history. -- **Claude overlay.** `claude-overlay.json` applies ctm-only settings (statusline, theme, hooks) without touching your global `~/.claude/settings.json`. -- **YOLO mode.** Auto-commits a git checkpoint before bypassing permissions, so you can always roll back. -- **Preflight health checks.** Env vars, PATH, workdir, tmux session, claude process — cached for 60 s to keep mobile reconnects snappy. -- **Tight lifecycle coupling.** When claude exits, the tmux session dies. No stuck bash shells, no zombie tabs. +- **Persistent sessions.** tmux-backed. Codex keeps running when SSH drops; reattach from any device. +- **Resume with fallback.** `codex resume || codex` — recovers cleanly when the prior session can't be re-opened. Use `codex resume --last` for the most recent. +- **YOLO mode.** Auto-commits a git checkpoint before launching with `codex --sandbox danger-full-access`, so you can always roll back. +- **Preflight health checks.** Env vars, PATH, workdir, tmux session, codex process — cached for 60 s to keep mobile reconnects snappy. +- **Tight lifecycle coupling.** When codex exits, the tmux session dies. No stuck bash shells, no zombie tabs. - **Crash-safe state.** Atomic writes, flock-based locking, strict JSON decode with self-healing strip-to-.bak, `schema_version` + startup migrations on `sessions.json` / `config.json`. - **Zero non-tmux runtime deps.** Pure Go throughout. No `jq`, `pgrep`, `grep`, or `uuidgen` required. @@ -96,7 +91,7 @@ go install github.com/RandomCodeSpace/ctm@latest ### Post-install -No extra setup step is required — the first time you run any claude-launching command (`ctm`, `ctm `, `ctm new`, `ctm yolo`), ctm bootstraps `~/.config/ctm/` with sensible defaults, regenerates `tmux.conf` on every launch, and injects shell aliases into `~/.bashrc` / `~/.zshrc` if they exist. +No extra setup step is required — the first time you run any codex-launching command (`ctm`, `ctm `, `ctm new`, `ctm yolo`), ctm bootstraps `~/.config/ctm/` with sensible defaults, regenerates `tmux.conf` on every launch, and injects shell aliases into `~/.bashrc` / `~/.zshrc` if they exist. If you prefer an explicit setup step (or want the cc-session migration to run), `ctm install` still does the same work upfront. @@ -165,7 +160,7 @@ Completion is aware of subcommands, flags, and (for `ctm attach`, `ctm kill`, `c ## Requirements - tmux 3.0+ -- [Claude Code CLI](https://claude.com/claude-code) on `$PATH` +- [Codex CLI](https://github.com/openai/codex) on `$PATH` (install via `npm i -g @openai/codex` or your package manager of choice) - A terminal that speaks xterm + OSC52 (Termius, WebSSH, iTerm2, Kitty, wezterm, Windows Terminal) - Go 1.25+ — **only** if you build from source (`go install`); prebuilt binaries have no Go dependency - Linux or macOS — Windows is not supported natively; use WSL @@ -176,7 +171,7 @@ Completion is aware of subcommands, flags, and (for `ctm attach`, `ctm kill`, `c | Command | Description | |---|---| -| `ctm` | Attach to the default session (`claude`). Creates it if missing. | +| `ctm` | Attach to the default session (`codex`). Creates it if missing. | | `ctm ` | Attach to a named session, or create it. | | `ctm cc` | Shorthand for attaching to `cc`. | | `ctm new ` | Create a new session in a specific workdir. | @@ -196,7 +191,7 @@ Completion is aware of subcommands, flags, and (for `ctm attach`, `ctm kill`, `c | Command | Description | |---|---| | `ctm detach` | Detach the current tmux client. Same as `Alt-d` inside a session. | -| `ctm kill ` | Kill a tmux session and its claude process. | +| `ctm kill ` | Kill a tmux session and its codex process. | | `ctm forget ` | Remove a session from the store without killing tmux. | | `ctm rename ` | Rename a session across ctm state and tmux. | @@ -210,67 +205,6 @@ Completion is aware of subcommands, flags, and (for `ctm attach`, `ctm kill`, `c | `ctm --log-level ` | Structured diagnostic log level on stderr: `debug`\|`info`\|`warn`\|`error`. Default: `info`. Set `CTM_LOG_FORMAT=json` for NDJSON output. | | `ctm version` | Print version. | -### Claude overlay - -| Command | Description | -|---|---| -| `ctm overlay` | Show overlay status (active / missing) with paths to sidecar files. | -| `ctm overlay init` | Create a sample `~/.config/ctm/claude-overlay.json` + `statusline.sh` + `env.sh` + hooks wiring. | -| `ctm overlay edit` | Open the overlay in `$EDITOR` (creates sidecars if missing). | -| `ctm overlay path` | Print the overlay file path. | - -When the overlay file exists, ctm-spawned claude invocations get `--settings ` automatically, and `env.sh` is sourced by the shell before claude starts. Direct `claude` invocations outside ctm are untouched. - -#### Statusline - -ctm ships a 3-line statusLine renderer (`ctm statusline`) that the overlay wires into Claude Code as `statusLine.command`. Layout: - -``` -Opus 4.7 (1M) ~/projects/ctm -c 49% (486.8k) w 34% h 25% -↑ 118.6k ↓ 434.8k xhigh -``` - -- **Line 1** — model name (redundant `Claude` / `claude-` prefix stripped) and project dir (OSC 8 hyperlinked to the `origin` remote when a `.git/config` is found). -- **Line 2** — `c` context used + tokens currently consumed (input-only sum per Claude Code's formula), `w` weekly rate-limit usage, `h` 5-hour rate-limit usage. Percentages taken verbatim from the payload; parenthesised token count formatted with SI suffix (`k` / `M` / `B`). -- **Line 3** — `↑` cumulative session input tokens, `↓` cumulative session output tokens (SI-formatted). Current `/effort` level is appended dim-gray (`min`/`low`/`medium`/`high`/`xhigh`/`max`), sourced from `~/.claude/settings.json` since Claude Code's statusLine payload does not expose it. - -Sections with missing payload fields are silently skipped, and at the default `INFO` log level nothing is written to stderr. To wire it into Claude Code outside a ctm-spawned session too, set `statusLine` in `~/.claude/settings.json`: - -```json -{ - "statusLine": { "type": "command", "command": "ctm statusline" } -} -``` - -### Logs - -| Command | Description | -|---|---| -| `ctm logs` | List sessions with tool-use logs, sorted by most recent. | -| `ctm logs ` | Dump a session's formatted tool-use log. | -| `ctm logs -f` | Tail the log in real time. Handles rotation and truncation. | -| `ctm logs --raw` | Print raw JSONL lines (pipe to `jq` for scripting). | -| `ctm logs --since 7d` | Only show entries newer than this duration (`Nd` shorthand for days). | -| `ctm logs --tool Bash` | Only show entries whose `tool_name` matches (case-insensitive). | -| `ctm logs --grep '\bpassword\b'` | Only show entries whose raw JSON matches this regex. | - -Filters AND together and apply across both the active log and rotated `.gz` siblings. - -Logs are populated by a PostToolUse hook registered in the overlay. Each entry contains the full Claude Code hook payload plus a UTC timestamp. File perms 0600, session-id sanitized to prevent path traversal, concurrent writes coordinated via advisory flock. - -**Rotation & retention.** When a session's active log crosses the size cap (default 50 MiB), it is renamed to `.jsonl.`, gzipped in place to `.jsonl..gz`, and a fresh empty active log replaces it. Rotated siblings are pruned beyond the age cap (default 30 days) or count cap (default 10 files). `ctm logs ` and `ctm logs -f` read the active log **and** every rotated `.gz` sibling transparently, so history spanning rotations is a single chronological stream. Override the defaults in `config.json`: - -```json -{ - "log_max_size_mb": 100, - "log_max_age_days": 14, - "log_max_files": 5 -} -``` - -A zero value means "use the built-in default" — to effectively disable a cap, set it to a very large number. - ## Keybindings Inside any ctm tmux session: @@ -285,7 +219,7 @@ Inside any ctm tmux session: ## Mobile scroll -> **The mobile scrollback trick.** Claude Code's TUI uses alt-screen and has no built-in scroll history. To scroll back on a phone: +> **The mobile scrollback trick.** Codex's TUI uses alt-screen and has no built-in scroll history. To scroll back on a phone: > > 1. Press **`Alt-[`** (or `Ctrl-b [`) — enters tmux copy mode. > 2. Swipe / arrow keys to scroll. @@ -304,9 +238,6 @@ Inside any ctm tmux session: - `~/.config/ctm/config.json` — main config (scrollback lines, required env vars, default mode, health check timeout, yolo checkpoint toggle) - `~/.config/ctm/sessions.json` — session state (atomically written, flock-locked) - `~/.config/ctm/tmux.conf` — generated tmux config (mobile-optimized, don't edit) -- `~/.config/ctm/claude-overlay.json` — optional claude settings overlay (statusline, theme, hooks) — created on first launch -- `~/.config/ctm/env.sh` — shell env sourced before claude spawns (for early-binding env vars like `CLAUDE_CODE_NO_FLICKER`) -- `~/.config/ctm/logs/.jsonl` — per-session tool-use logs (0600) ### State file versioning diff --git a/SECURITY.md b/SECURITY.md index 9a426f6..7fb4d11 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -35,12 +35,11 @@ The faster the maintainer can reproduce the issue, the faster a fix ships. Please include: - **Affected versions** (output of `ctm version`). -- **Environment**: OS, tmux version, shell, whether ctm is reachable - from outside `127.0.0.1` (e.g. via reverse proxy). +- **Environment**: OS, tmux version, shell, codex CLI version. - **Impact**: what an attacker can do — RCE, info disclosure, privilege escalation, DoS, etc. -- **Reproducer**: smallest sequence of commands or HTTP requests - that triggers the issue. +- **Reproducer**: smallest sequence of commands that triggers the + issue. - **Suggested fix** if you have one — appreciated but not required. - **Whether you intend to seek a CVE** — the maintainer can help reserve one through GitHub's advisory flow. @@ -62,37 +61,40 @@ ping the same channel — mail can get lost. In scope: -- The `ctm` CLI and all subcommands (`yolo`, `safe`, `attach`, - `serve`, `kill`, etc.). -- The `ctm serve` HTTP daemon and its API + UI surface. -- Any session-state, log, or auth file ctm writes under - `~/.config/ctm/` or `~/.claude/`. +- The `ctm` CLI and all subcommands (`yolo`, `safe`, attach, + `kill`, `last`, `pick`, etc.). +- Any session-state file ctm writes under `~/.config/ctm/`, and the + generated `tmux.conf`. +- Lifecycle hook execution (`on_attach` / `on_new` / `on_yolo` / + `on_safe` / `on_kill`) — env-var handling and shell quoting. - Build-time supply-chain integrity (vendored deps, release artifacts). Out of scope: -- Bugs in tmux, claude, git, or any other binary ctm shells out to. -- Configuration mistakes by the operator (e.g. binding `ctm serve` - to `0.0.0.0` without an authenticating reverse proxy). +- Bugs in tmux, codex, git, or any other binary ctm shells out to. - Findings that require a pre-compromised local user account on the same machine where ctm runs (ctm trusts the user it runs as). +- YOLO mode's documented `codex --sandbox danger-full-access` + behaviour. The git checkpoint is the safety net; bypassing the + sandbox is the explicit point of the mode. ## Security architecture quick reference For background while reviewing a report: -- ctm binds **`127.0.0.1` only** by default. External exposure is - the operator's reverse-proxy responsibility. -- Single-user authentication uses **argon2id** (RFC 9106) with - parameters meeting OWASP recommendations. See V27 spec. -- Session tokens are **256-bit** values from `crypto/rand`, stored - hashed. -- Mutation endpoints require both a bearer token **and** an Origin - header allow-list (see `internal/serve/api/`). -- All git, tmux, and claude invocations resolve binaries through +- ctm has **no network listener**. It is a CLI that shells out to + tmux, git, and codex; there is no daemon, no HTTP surface, no + open port. +- All git, tmux, and codex invocations resolve binaries through `$PATH` — see `sonar-project.properties` for the documented threat model behind that. +- State files under `~/.config/ctm/` are written atomically with + `flock`-based locking and `0600` permissions where they hold + anything sensitive. +- YOLO mode auto-commits a git checkpoint before launching codex + with `--sandbox danger-full-access`, so destructive output can be + rolled back with `git reset`. ## Public disclosures to date diff --git a/cmd/attach.go b/cmd/attach.go index 5573831..83e99cf 100644 --- a/cmd/attach.go +++ b/cmd/attach.go @@ -5,11 +5,10 @@ import ( "path/filepath" "time" - "github.com/RandomCodeSpace/ctm/internal/claude" + "github.com/RandomCodeSpace/ctm/internal/agent" "github.com/RandomCodeSpace/ctm/internal/config" "github.com/RandomCodeSpace/ctm/internal/health" "github.com/RandomCodeSpace/ctm/internal/output" - "github.com/RandomCodeSpace/ctm/internal/serve/proc" "github.com/RandomCodeSpace/ctm/internal/session" "github.com/RandomCodeSpace/ctm/internal/tmux" "github.com/spf13/cobra" @@ -47,7 +46,7 @@ func init() { } func runAttach(cmd *cobra.Command, args []string) error { - name := "claude" + name := session.DefaultAgent if len(args) > 0 { name = args[0] } @@ -55,10 +54,6 @@ func runAttach(cmd *cobra.Command, args []string) error { return err } - // Bring up ctm serve in the background (no-op if already running). - // Best-effort — never blocks the attach flow. - proc.EnsureServeRunning(cmd.Context()) - out := output.Stderr() cfgPtr, err := ensureSetup() if err != nil { @@ -88,12 +83,10 @@ func createAndAttach(name, workdir, _ string, store *session.Store, tc *tmux.Cli out.Info("No session %q found — creating in %s", name, abs) sess, err := session.Yolo(session.SpawnOpts{ - Name: name, - Workdir: abs, - Tmux: tc, - Store: store, - OverlayPath: claude.OverlayPathIfExists(config.ClaudeOverlayPath()), - EnvExports: config.ClaudeEnvExports(), + Name: name, + Workdir: abs, + Tmux: tc, + Store: store, }) if err != nil { return fmt.Errorf("createAndAttach spawn: %w", err) @@ -105,8 +98,6 @@ func createAndAttach(name, workdir, _ string, store *session.Store, tc *tmux.Cli out.Success("created session %q", name) fireHook("on_new", &sess) - fireServeEvent("session_new", &sess) - fireServeEvent("session_attached", &sess) return tc.Go(name) } @@ -144,12 +135,23 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t return fmt.Errorf(errHealthCheckFmt, wdResult.Name) } - // 3. Tmux session check — if missing, recreate with --resume + a, ok := agent.For(sess.NormalizeAgent()) + if !ok { + return fmt.Errorf("session %q references unregistered agent %q", sess.Name, sess.NormalizeAgent()) + } + resumeSpec := agent.SpawnSpec{ + UUID: sess.UUID, + AgentSessionID: sess.AgentSessionID, + Mode: sess.Mode, + Resume: true, + } + + // 3. Tmux session check — if missing, recreate with resume semantics out.Debug(Verbose, "checking tmux session: %s", sess.Name) tmuxResult := health.CheckTmuxSession(tc, sess.Name) if !tmuxResult.Passed() { out.Warn("tmux session %q missing — recreating", sess.Name) - shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()), config.ClaudeEnvExports()) + shellCmd := a.BuildCommand(resumeSpec) if err := tc.NewSession(sess.Name, sess.Workdir, shellCmd); err != nil { return fmt.Errorf("recreating tmux session: %w", err) } @@ -160,20 +162,19 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t out.Warn(warnUpdateAttached, err) } fireHook("on_attach", sess) - fireServeEvent("session_attached", sess) if err := tc.Go(sess.Name); err != nil { return fmt.Errorf(errAttachingFmt, sess.Name, err) } return nil } - // 4. Claude process check — if dead, respawn with --resume - out.Debug(Verbose, "checking claude process in session: %s", sess.Name) - claudeResult := health.CheckClaudeProcess(tc, sess.Name) - if !claudeResult.Passed() { - out.Debug(Verbose, "claude not running, restarting with --resume") - out.Warn("claude process dead — respawning") - shellCmd := claude.BuildCommand(sess.UUID, sess.Mode, true, claude.OverlayPathIfExists(config.ClaudeOverlayPath()), config.ClaudeEnvExports()) + // 4. Agent process check — if dead, respawn with resume semantics + out.Debug(Verbose, "checking %s process in session: %s", a.Name(), sess.Name) + agentResult := health.CheckAgentProcess(tc, sess.Name, a.ProcessName()) + if !agentResult.Passed() { + out.Debug(Verbose, "%s not running, restarting with resume", a.Name()) + out.Warn("%s process dead — respawning", a.Name()) + shellCmd := a.BuildCommand(resumeSpec) if err := tc.RespawnPane(sess.Name, shellCmd); err != nil { return fmt.Errorf("respawning pane: %w", err) } @@ -184,7 +185,6 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t out.Warn(warnUpdateAttached, err) } fireHook("on_attach", sess) - fireServeEvent("session_attached", sess) if err := tc.Go(sess.Name); err != nil { return fmt.Errorf(errAttachingFmt, sess.Name, err) } @@ -201,7 +201,6 @@ func preflight(sess *session.Session, cfg config.Config, store *session.Store, t } fireHook("on_attach", sess) - fireServeEvent("session_attached", sess) if err := tc.Go(sess.Name); err != nil { return fmt.Errorf(errAttachingFmt, sess.Name, err) } diff --git a/cmd/auth.go b/cmd/auth.go deleted file mode 100644 index e779aa9..0000000 --- a/cmd/auth.go +++ /dev/null @@ -1,46 +0,0 @@ -package cmd - -import ( - "bufio" - "fmt" - "os" - "strings" - - "github.com/RandomCodeSpace/ctm/internal/serve/auth" - "github.com/spf13/cobra" -) - -var authCmd = &cobra.Command{ - Use: "auth", - Short: "Manage the single-user credentials (V27)", -} - -var authResetCmd = &cobra.Command{ - Use: "reset", - Short: "Delete the stored user credentials and force re-signup", - RunE: func(cmd *cobra.Command, args []string) error { - if !auth.Exists() { - fmt.Println("No credentials file present — nothing to reset.") - return nil - } - fmt.Printf("About to delete %s\nContinue? [y/N] ", auth.UserPath()) - reader := bufio.NewReader(os.Stdin) - line, _ := reader.ReadString('\n') - if strings.ToLower(strings.TrimSpace(line)) != "y" { - fmt.Println("Cancelled.") - return nil - } - if err := auth.Delete(); err != nil { - return err - } - fmt.Println("Credentials removed.") - fmt.Println("The daemon will clear active sessions on its next incoming request.") - fmt.Println("Visit the UI to sign up again.") - return nil - }, -} - -func init() { - authCmd.AddCommand(authResetCmd) - rootCmd.AddCommand(authCmd) -} diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 7af0957..fe7a820 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/RandomCodeSpace/ctm/internal/config" - "github.com/RandomCodeSpace/ctm/internal/logrotate" "github.com/RandomCodeSpace/ctm/internal/migrate" "github.com/RandomCodeSpace/ctm/internal/session" "github.com/RandomCodeSpace/ctm/internal/shell" @@ -15,23 +14,20 @@ import ( ) // ensureSetup runs the idempotent first-run bootstrap. Safe to call on -// every claude-launching command. Returns the (possibly freshly-created) -// config. Errors from non-critical steps (overlay, aliases) are swallowed -// — they must never block launching claude on a well-configured host. +// every codex-launching command. Returns the (possibly freshly-created) +// config. Errors from non-critical steps (aliases) are swallowed — they +// must never block launching codex on a well-configured host. // // Side effects (all idempotent): // - creates ~/.config/ctm/ if missing // - writes config.json with defaults if missing // - regenerates tmux.conf on every call so new defaults reach upgraded users -// - writes claude-overlay.json + claude-env.json + logs/ dir if missing +// - runs schema migrations on config.json / sessions.json // - injects shell aliases into ~/.bashrc and ~/.zshrc if markers not present func ensureSetup() (*config.Config, error) { if err := os.MkdirAll(config.Dir(), 0755); err != nil { return nil, fmt.Errorf("creating config dir: %w", err) } - // Run schema migrations before typed Load so the unmarshal sees a - // file already at the current version. Missing files are a no-op — - // the migrator never creates them. if err := runStateMigrations(); err != nil { return nil, fmt.Errorf("migrating state files: %w", err) } @@ -42,36 +38,10 @@ func ensureSetup() (*config.Config, error) { if err := tmux.GenerateConfig(config.TmuxConfPath(), cfg.ScrollbackLines); err != nil { return nil, fmt.Errorf("generating tmux config: %w", err) } - _ = ensureOverlaySidecars() _ = ensureAliases() - _ = pruneSessionLogs(cfg) return &cfg, nil } -// pruneSessionLogs walks the session log directory and applies the -// user's retention policy to every set of rotated siblings. This catches -// abandoned sessions whose active log no longer writes and therefore -// never triggers an inline MaybeRotate. Errors are swallowed — pruning -// is hygiene, not correctness, and must not block startup. -// -// The walk is bounded by the number of active sessions (typically small -// for a personal ctm user), so the cost is negligible on every run. -func pruneSessionLogs(cfg config.Config) error { - dir := filepath.Join(config.Dir(), "logs") - entries, err := os.ReadDir(dir) - if err != nil { - return nil // logs dir absent → nothing to prune - } - policy := cfg.LogPolicy() - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { - continue - } - _ = logrotate.Prune(filepath.Join(dir, e.Name()), policy) - } - return nil -} - // runStateMigrations applies the pending migration Plan for each ctm-owned // JSON state file. Missing files are a no-op. Each file that actually // migrates gets a timestamped ".bak." sibling before any write, @@ -96,29 +66,6 @@ func runStateMigrations() error { return nil } -// ensureOverlaySidecars writes claude-overlay.json, claude-env.json, and -// the per-session logs dir if any are missing. Leaves existing files -// alone — user edits to overlay/env always win. -func ensureOverlaySidecars() error { - _ = os.MkdirAll(sessionLogDir(), 0755) - _ = writeClaudeEnv(config.ClaudeEnvPath()) - - overlay := config.ClaudeOverlayPath() - if _, err := os.Stat(overlay); err == nil { - return nil - } - f, err := os.OpenFile(overlay, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) - if err != nil { - if os.IsExist(err) { - return nil - } - return err - } - defer f.Close() - _, err = f.WriteString(buildSampleOverlay(statuslineHookCommand(), logToolUseHookCommand())) - return err -} - // ensureAliases injects the ctm alias block into ~/.bashrc and ~/.zshrc // when the start marker is absent. Avoids rewriting the file on every // ctm invocation — InjectAliases is idempotent but always touches the diff --git a/cmd/bootstrap_test.go b/cmd/bootstrap_test.go index 0a5f85e..1be7a25 100644 --- a/cmd/bootstrap_test.go +++ b/cmd/bootstrap_test.go @@ -17,8 +17,8 @@ func withTempHome(t *testing.T) string { return tmp } -func TestEnsureSetupCreatesAllArtifacts(t *testing.T) { - home := withTempHome(t) +func TestEnsureSetupCreatesConfigAndTmuxConf(t *testing.T) { + withTempHome(t) cfg, err := ensureSetup() if err != nil { @@ -31,133 +31,69 @@ func TestEnsureSetupCreatesAllArtifacts(t *testing.T) { t.Errorf("expected default scrollback lines, got %d", cfg.ScrollbackLines) } - cfgDir := filepath.Join(home, ".config", "ctm") - wantFiles := []string{ - config.ConfigPath(), - config.TmuxConfPath(), - config.ClaudeOverlayPath(), - config.ClaudeEnvPath(), - } - for _, p := range wantFiles { + for _, p := range []string{config.ConfigPath(), config.TmuxConfPath()} { if _, err := os.Stat(p); err != nil { t.Errorf("expected %s to exist: %v", p, err) } } - - logs := filepath.Join(cfgDir, "logs") - if st, err := os.Stat(logs); err != nil || !st.IsDir() { - t.Errorf("expected %s to be a directory", logs) - } } -func TestEnsureSetupIdempotent(t *testing.T) { +func TestEnsureSetupIsIdempotent(t *testing.T) { withTempHome(t) + if _, err := ensureSetup(); err != nil { - t.Fatalf("first call: %v", err) + t.Fatalf("first ensureSetup: %v", err) } - overlayPath := config.ClaudeOverlayPath() - marker := []byte("// user edit — must survive bootstrap\n") - orig, err := os.ReadFile(overlayPath) + cfgPath := config.ConfigPath() + stat1, err := os.Stat(cfgPath) if err != nil { t.Fatal(err) } - edited := append(orig, marker...) - if err := os.WriteFile(overlayPath, edited, 0644); err != nil { - t.Fatal(err) - } if _, err := ensureSetup(); err != nil { - t.Fatalf("second call: %v", err) + t.Fatalf("second ensureSetup: %v", err) } - after, err := os.ReadFile(overlayPath) + + stat2, err := os.Stat(cfgPath) if err != nil { t.Fatal(err) } - if string(after) != string(edited) { - t.Errorf("ensureSetup clobbered overlay edits\nbefore: %q\nafter: %q", edited, after) + if stat1.Size() != stat2.Size() { + t.Errorf("config.json size changed across idempotent calls (%d → %d)", stat1.Size(), stat2.Size()) } } -func TestEnsureSetupAliasesIdempotent(t *testing.T) { - home := withTempHome(t) - bashrc := filepath.Join(home, ".bashrc") - // Seed a bashrc — ensureAliases only writes to existing rc files. - if err := os.WriteFile(bashrc, []byte("# existing rc\n"), 0644); err != nil { - t.Fatal(err) - } +func TestEnsureSetupWritesDefaultConfigContents(t *testing.T) { + withTempHome(t) if _, err := ensureSetup(); err != nil { t.Fatal(err) } - first, err := os.ReadFile(bashrc) - if err != nil { - t.Fatal(err) - } - if _, err := ensureSetup(); err != nil { - t.Fatal(err) - } - second, err := os.ReadFile(bashrc) - if err != nil { - t.Fatal(err) - } - if string(first) != string(second) { - t.Errorf("bashrc changed on second bootstrap:\nfirst: %q\nsecond: %q", first, second) - } - if !containsAliasMarker(string(first)) { - t.Errorf("expected alias marker injected, got:\n%s", first) - } -} -func TestEnsureOverlayCreatesWithHookCommands(t *testing.T) { - withTempHome(t) - if err := ensureOverlaySidecars(); err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(config.ClaudeOverlayPath()) + cfg, err := config.Load(config.ConfigPath()) if err != nil { - t.Fatal(err) + t.Fatalf("Load: %v", err) } - got := string(data) - for _, want := range []string{`"spinnerTipsEnabled": false`, "statusline", "log-tool-use"} { - if !contains(got, want) { - t.Errorf("overlay missing %q:\n%s", want, got) + hasCodex := false + for _, p := range cfg.RequiredInPath { + if p == "codex" { + hasCodex = true } } -} - -func TestOverlayAndEnvFilePermsAre0600(t *testing.T) { - withTempHome(t) - if err := ensureOverlaySidecars(); err != nil { - t.Fatal(err) - } - for _, path := range []string{ - config.ClaudeOverlayPath(), - config.ClaudeEnvPath(), - } { - info, err := os.Stat(path) - if err != nil { - t.Fatalf("stat %s: %v", path, err) - } - if mode := info.Mode().Perm(); mode != 0600 { - t.Errorf("%s mode = %v, want 0600", path, mode) - } + if !hasCodex { + t.Errorf("expected default RequiredInPath to include \"codex\", got %v", cfg.RequiredInPath) } -} -func contains(haystack, needle string) bool { - return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0 -} - -func containsAliasMarker(s string) bool { - return indexOf(s, "# --- ctm aliases START ---") >= 0 -} - -func indexOf(haystack, needle string) int { - for i := 0; i+len(needle) <= len(haystack); i++ { - if haystack[i:i+len(needle)] == needle { - return i + // Verify no stray overlay/env files were created — those features + // were removed when claude support was dropped. + cfgDir := filepath.Dir(config.ConfigPath()) + for _, leaked := range []string{ + filepath.Join(cfgDir, "claude-overlay.json"), + filepath.Join(cfgDir, "claude-env.json"), + } { + if _, err := os.Stat(leaked); err == nil { + t.Errorf("unexpected legacy file present: %s", leaked) } } - return -1 } diff --git a/cmd/check.go b/cmd/check.go index 04257bf..0571076 100644 --- a/cmd/check.go +++ b/cmd/check.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/spf13/cobra" + "github.com/RandomCodeSpace/ctm/internal/agent" "github.com/RandomCodeSpace/ctm/internal/config" "github.com/RandomCodeSpace/ctm/internal/health" "github.com/RandomCodeSpace/ctm/internal/output" @@ -25,7 +26,7 @@ var checkCmd = &cobra.Command{ } func runCheck(cmd *cobra.Command, args []string) error { - name := "claude" + name := session.DefaultAgent if len(args) > 0 { name = args[0] } @@ -84,11 +85,19 @@ func runCheck(cmd *cobra.Command, args []string) error { allPassed = false } - // Claude process - claudeResult := health.CheckClaudeProcess(tc, name) - printCheckResult(out, claudeResult) - if !claudeResult.Passed() { - allPassed = false + // Agent process — only meaningful when we know which agent the + // session uses. A missing session row was already reported above + // via the workdir-result branch; skip the process check in that case. + if sessErr == nil { + procName := sess.NormalizeAgent() + if a, ok := agent.For(sess.NormalizeAgent()); ok { + procName = a.ProcessName() + } + procResult := health.CheckAgentProcess(tc, name, procName) + printCheckResult(out, procResult) + if !procResult.Passed() { + allPassed = false + } } // Summary diff --git a/cmd/doctor.go b/cmd/doctor.go index 317f3f2..96a4ddb 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -41,7 +41,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { // --- Dependencies --- out.Bold("Dependencies:") - for _, dep := range []string{"tmux", "claude", "git"} { + for _, dep := range []string{"tmux", "codex", "git"} { if path, ok := doctor.LookupBinary(dep); ok { out.Success(" [OK] %-10s %s", dep, path) } else { diff --git a/cmd/hooks_dispatch.go b/cmd/hooks_dispatch.go index f5b8a49..94ee36c 100644 --- a/cmd/hooks_dispatch.go +++ b/cmd/hooks_dispatch.go @@ -1,11 +1,8 @@ package cmd import ( - "net/url" - "github.com/RandomCodeSpace/ctm/internal/config" "github.com/RandomCodeSpace/ctm/internal/hooks" - "github.com/RandomCodeSpace/ctm/internal/serve/proc" "github.com/RandomCodeSpace/ctm/internal/session" ) @@ -51,27 +48,3 @@ func fireHook(event string, sess *session.Session) { } _ = hooks.Run(event, cfg.Hooks, hctx, cfg.HookTimeout()) } - -// fireServeEvent is fireHook's sibling for the in-process ctm serve -// daemon: it POSTs the lifecycle event to /api/hooks/:event so the -// web UI's hub sees session_new / session_attached / session_killed / -// on_yolo as soon as the CLI-side action completes. Failures are -// swallowed inside proc.PostEvent — serve being down must never -// block the user's CLI flow. -func fireServeEvent(event string, sess *session.Session) { - if sess == nil { - return - } - form := url.Values{} - form.Set("name", sess.Name) - if sess.UUID != "" { - form.Set("uuid", sess.UUID) - } - if sess.Mode != "" { - form.Set("mode", sess.Mode) - } - if sess.Workdir != "" { - form.Set("workdir", sess.Workdir) - } - proc.PostEvent(event, form) -} diff --git a/cmd/install.go b/cmd/install.go index 0a6a2c1..dc78770 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -77,27 +77,7 @@ func runInstall(cmd *cobra.Command, args []string) error { } } - // 5. Migrate from cc if ~/.claude/cc-sessions exists - ccSessionsDir := filepath.Join(home, ".claude", "cc-sessions") - if _, err := os.Stat(ccSessionsDir); err == nil { - migrated, err := shell.MigrateFromCC(ccSessionsDir, config.SessionsPath()) - if err != nil { - out.Warn("Migration warning: %v", err) - } else if len(migrated) > 0 { - out.Success("Migrated %d session(s) from cc: %v", len(migrated), migrated) - } else { - out.Info("No sessions to migrate from cc.") - } - } - - // 6. Claude-side defaults are now expressed entirely in - // ~/.config/ctm/claude-overlay.json (created by step 6 of ensureSetup - // via writeOverlayFile). ctm never mutates ~/.claude.json or - // ~/.claude/settings.json — the overlay is merged in via - // `claude --settings` only when claude is launched through ctm, - // leaving direct `claude` invocations completely unaffected. - - // 7. Print summary + // 5. Print summary fmt.Println() out.Bold("ctm installed successfully!") out.Info("Run: source ~/.bashrc") diff --git a/cmd/kill.go b/cmd/kill.go index 2fc85d5..e6a501b 100644 --- a/cmd/kill.go +++ b/cmd/kill.go @@ -45,7 +45,6 @@ func runKill(cmd *cobra.Command, args []string) error { // Fire the hook BEFORE tearing down so the script can see the // session as still live (take a transcript snapshot, notify, …). fireHook("on_kill", sess) - fireServeEvent("session_killed", sess) if tc.HasSession(name) { if err := tc.KillSession(name); err != nil { @@ -79,7 +78,6 @@ func runKillAll(cmd *cobra.Command, args []string) error { // Fire on_kill for each session while they still exist on disk. for _, s := range sessions { fireHook("on_kill", s) - fireServeEvent("session_killed", s) } if err := tc.KillServer(); err != nil { diff --git a/cmd/log_tool_use.go b/cmd/log_tool_use.go deleted file mode 100644 index 76b00a3..0000000 --- a/cmd/log_tool_use.go +++ /dev/null @@ -1,165 +0,0 @@ -package cmd - -import ( - "encoding/json" - "io" - "log/slog" - "os" - "path/filepath" - "regexp" - "syscall" - "time" - - "github.com/spf13/cobra" - "github.com/RandomCodeSpace/ctm/internal/config" - "github.com/RandomCodeSpace/ctm/internal/logrotate" -) - -func init() { - rootCmd.AddCommand(logToolUseCmd) -} - -// logToolUseCmd is the PostToolUse hook target. Claude invokes it for every -// tool call and pipes the raw hook JSON on stdin. We parse it, add a -// timestamp, and append one JSONL line to ~/.config/ctm/logs/.jsonl. -// -// Hidden because it's an internal hook, not a user-facing command. Always -// exits 0 — hook failures must never block the tool pipeline. -var logToolUseCmd = &cobra.Command{ - Use: "log-tool-use", - Short: "Internal PostToolUse hook — logs tool invocations (hidden)", - Hidden: true, - RunE: runLogToolUse, - SilenceUsage: true, - SilenceErrors: true, -} - -// sessionIDSafe matches only characters we allow in a log filename. -// Claude's session IDs are UUIDs, but we sanitize defensively to prevent -// path traversal or filesystem weirdness if the hook payload is malformed. -var sessionIDSafe = regexp.MustCompile(`[^a-zA-Z0-9_-]`) - -func sanitizeSessionID(id string) string { - clean := sessionIDSafe.ReplaceAllString(id, "-") - if clean == "" || len(clean) > 128 { - return "unknown" - } - return clean -} - -// warn surfaces hook failures via stderr (claude usually captures this) -// AND appends to ~/.config/ctm/logs/.hook-errors.log so silent drops leave -// a forensic trail. Both paths are best-effort — we still return nil from -// the hook to preserve the contract that hook failures never block tools. -func warn(reason string, attrs ...slog.Attr) { - args := make([]any, 0, len(attrs)*2) - for _, a := range attrs { - args = append(args, a.Key, a.Value) - } - slog.Warn("log-tool-use: "+reason, args...) - if errLog, err := os.OpenFile(filepath.Join(config.Dir(), "logs", ".hook-errors.log"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600); err == nil { - fields := map[string]any{"ts": time.Now().UTC().Format(time.RFC3339Nano), "reason": reason} - for _, a := range attrs { - fields[a.Key] = a.Value.Any() - } - if line, mErr := json.Marshal(fields); mErr == nil { - _, _ = errLog.Write(append(line, '\n')) - } - _ = errLog.Close() - } -} - -func runLogToolUse(cmd *cobra.Command, args []string) error { - // Read all of stdin. Hook payloads are small (<100KB typically). - data, err := io.ReadAll(io.LimitReader(os.Stdin, 1<<20)) // 1 MiB cap - if err != nil { - warn("stdin read failed", slog.String("err", err.Error())) - return nil - } - if len(data) == 0 { - // Empty stdin is legitimate (hook fired with no payload — shouldn't - // happen but isn't an error from our side). Silent skip. - return nil - } - - // Parse into a generic map so we preserve all fields claude sends. - var payload map[string]interface{} - if err := json.Unmarshal(data, &payload); err != nil { - warn("payload parse failed", slog.String("err", err.Error()), slog.Int("bytes", len(data))) - return nil - } - - // Extract and sanitize session_id for the filename. - rawSessionID, _ := payload["session_id"].(string) - sessionID := "unknown" - if rawSessionID != "" { - sessionID = sanitizeSessionID(rawSessionID) - } - if sessionID == "unknown" { - // session_id missing or unsanitizable — file lands at logs/unknown.jsonl - // and the daemon won't tail it under any session name. Surface this. - warn("session_id missing or invalid", slog.String("raw", rawSessionID)) - } - - // Add a ctm-side timestamp so the log is readable even if claude - // doesn't include one in the payload. - payload["ctm_timestamp"] = time.Now().UTC().Format(time.RFC3339) - - logDir := filepath.Join(config.Dir(), "logs") - // 0700 on the dir — tool payloads can contain file paths and contents. - if err := os.MkdirAll(logDir, 0700); err != nil { - warn("mkdir logs failed", slog.String("dir", logDir), slog.String("err", err.Error())) - return nil - } - logFile := filepath.Join(logDir, sessionID+".jsonl") - - line, err := json.Marshal(payload) - if err != nil { - warn("marshal payload failed", slog.String("err", err.Error()), slog.String("session", sessionID)) - return nil - } - line = append(line, '\n') - - // 0600 on the file — same reasoning as the dir. - f, err := os.OpenFile(logFile, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) - if err != nil { - warn("open log failed", slog.String("path", logFile), slog.String("err", err.Error())) - return nil - } - - // Acquire an exclusive advisory lock before writing. O_APPEND is only - // atomic up to PIPE_BUF (4096 bytes) on Linux; tool payloads can easily - // exceed that (Read/Bash output). Without the lock, concurrent hook - // invocations can interleave bytes and corrupt the JSONL stream. - // Lock failure is non-fatal — write anyway rather than block the tool pipeline. - fd := int(f.Fd()) - lockAcquired := syscall.Flock(fd, syscall.LOCK_EX) == nil - - if _, werr := f.Write(line); werr != nil { - warn("write log failed", slog.String("path", logFile), slog.String("err", werr.Error()), slog.Int("bytes", len(line))) - } - - // Release the advisory lock and close explicitly *before* calling - // MaybeRotate: rotation takes its own sibling lock, and keeping our - // fd open across the rename would anchor writers to the rotated - // inode instead of the fresh active file. - if lockAcquired { - _ = syscall.Flock(fd, syscall.LOCK_UN) - } - _ = f.Close() - - _ = logrotate.MaybeRotate(logFile, hookRotatePolicy()) - return nil -} - -// hookRotatePolicy resolves the rotation policy to use from inside the -// hook. It loads config.json if present; on any error it falls back to -// the built-in defaults so a misconfigured file never blocks a tool -// invocation. -func hookRotatePolicy() logrotate.Policy { - cfg, err := config.Load(config.ConfigPath()) - if err != nil { - return logrotate.DefaultPolicy() - } - return cfg.LogPolicy() -} diff --git a/cmd/log_tool_use_test.go b/cmd/log_tool_use_test.go deleted file mode 100644 index 3db569c..0000000 --- a/cmd/log_tool_use_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package cmd - -import "testing" - -func TestSanitizeSessionID(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - {"valid uuid", "f6489cb4-010f-4c96-940b-188014f746f0", "f6489cb4-010f-4c96-940b-188014f746f0"}, - {"simple alnum", "abc123", "abc123"}, - {"underscore ok", "a_b_c", "a_b_c"}, - {"dash ok", "a-b-c", "a-b-c"}, - {"path traversal attempt", "../../etc/passwd", "------etc-passwd"}, - {"absolute path", "/etc/passwd", "-etc-passwd"}, - {"dot", "a.b.c", "a-b-c"}, - {"spaces", "a b c", "a-b-c"}, - {"null byte", "abc\x00def", "abc-def"}, - {"empty string", "", "unknown"}, - {"only invalid chars", "////", "----"}, - {"really long", string(make([]byte, 200)), "unknown"}, // >128 after sanitize → fallback - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := sanitizeSessionID(tt.in) - if got != tt.want { - t.Errorf("sanitizeSessionID(%q) = %q, want %q", tt.in, got, tt.want) - } - }) - } -} - -func TestSanitizeSessionIDNoEmptyResult(t *testing.T) { - // Sanity: never returns "" — either a clean id or "unknown". - inputs := []string{"", "...", "/////", string(make([]byte, 500))} - for _, in := range inputs { - got := sanitizeSessionID(in) - if got == "" { - t.Errorf("sanitizeSessionID(%q) returned empty string", in) - } - } -} diff --git a/cmd/logs.go b/cmd/logs.go deleted file mode 100644 index 220ab2c..0000000 --- a/cmd/logs.go +++ /dev/null @@ -1,443 +0,0 @@ -package cmd - -import ( - "bufio" - "encoding/json" - "fmt" - "io" - "log/slog" - "os" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "time" - - "github.com/RandomCodeSpace/ctm/internal/config" - "github.com/RandomCodeSpace/ctm/internal/logrotate" - "github.com/RandomCodeSpace/ctm/internal/output" - "github.com/spf13/cobra" -) - -func init() { - logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "Tail the log (follow new entries)") - logsCmd.Flags().BoolVar(&logsRaw, "raw", false, "Print raw JSONL lines without formatting") - logsCmd.Flags().StringVar(&logsSince, "since", "", "Only show entries newer than this duration (e.g. 7d, 24h, 30m). Days accepted via \"Nd\" suffix.") - logsCmd.Flags().StringVar(&logsTool, "tool", "", "Only show entries whose tool_name matches (case-insensitive, exact).") - logsCmd.Flags().StringVar(&logsGrep, "grep", "", "Only show entries whose raw JSON line matches this regex.") - rootCmd.AddCommand(logsCmd) -} - -var ( - logsFollow bool - logsRaw bool - logsSince string - logsTool string - logsGrep string -) - -// jsonlExt is the per-session log file suffix written by log_tool_use. -const jsonlExt = ".jsonl" - -// filterSpec is the compiled form of the logs-command filter flags. -// Zero-valued fields disable the corresponding check, so an empty -// filterSpec passes everything. -type filterSpec struct { - since time.Time // zero = no time filter - toolLow string // "" = no tool filter (lowercased) - grep *regexp.Regexp // nil = no grep filter - active bool // true if any filter is set (cheap short-circuit) -} - -// compileFilters builds a filterSpec from the current flag values. -// Returns an error if any flag is malformed. -func compileFilters() (filterSpec, error) { - var fs filterSpec - if logsSince != "" { - d, err := parseSince(logsSince) - if err != nil { - return fs, fmt.Errorf("--since: %w", err) - } - fs.since = time.Now().Add(-d) - fs.active = true - } - if logsTool != "" { - fs.toolLow = strings.ToLower(logsTool) - fs.active = true - } - if logsGrep != "" { - re, err := regexp.Compile(logsGrep) - if err != nil { - return fs, fmt.Errorf("--grep: %w", err) - } - fs.grep = re - fs.active = true - } - return fs, nil -} - -// parseSince accepts Go's time.ParseDuration format plus a "Nd" day -// shorthand ("7d" → 7*24h). Empty string is an error; use an empty -// --since flag value to disable the filter instead. -func parseSince(s string) (time.Duration, error) { - if strings.HasSuffix(s, "d") { - days, err := strconv.Atoi(strings.TrimSuffix(s, "d")) - if err != nil { - return 0, fmt.Errorf("invalid day count %q", s) - } - if days < 0 { - return 0, fmt.Errorf("--since must be non-negative, got %q", s) - } - return time.Duration(days) * 24 * time.Hour, nil - } - return time.ParseDuration(s) -} - -// match returns true if the given raw JSONL line passes every active -// filter in fs. Malformed lines (non-JSON) pass iff --grep matches -// literal bytes and no other filter is active — otherwise they fail -// (we cannot introspect tool_name / ctm_timestamp without a parse). -func (fs filterSpec) match(raw []byte) bool { - if !fs.active { - return true - } - if fs.grep != nil && !fs.grep.Match(raw) { - return false - } - // Short-circuit: if only --grep is set, we're done. - if fs.since.IsZero() && fs.toolLow == "" { - return true - } - var entry map[string]any - if err := json.Unmarshal(raw, &entry); err != nil { - return false - } - if !fs.since.IsZero() { - ts, _ := entry["ctm_timestamp"].(string) - parsed, err := time.Parse(time.RFC3339, ts) - if err != nil || parsed.Before(fs.since) { - return false - } - } - if fs.toolLow != "" { - name, _ := entry["tool_name"].(string) - if strings.ToLower(name) != fs.toolLow { - return false - } - } - return true -} - -var logsCmd = &cobra.Command{ - Use: "logs [session-id]", - Short: "View PostToolUse tool-use logs captured for ctm sessions", - Long: "Show the tool-use log written by ctm's PostToolUse hook. " + - "With no argument, lists available session logs. With a session ID, " + - "prints that session's log. Use -f to tail.", - Args: cobra.MaximumNArgs(1), - RunE: runLogs, -} - -func runLogs(cmd *cobra.Command, args []string) error { - logDir := filepath.Join(config.Dir(), "logs") - - // No arg → list available session logs. The filter flags apply - // only to the dump/tail path; listing is unaffected by them. - if len(args) == 0 { - return listSessionLogs(logDir) - } - - fs, err := compileFilters() - if err != nil { - return err - } - - // With arg → show that session's log (tailing if requested). - sessionID := sanitizeSessionID(args[0]) - logFile := filepath.Join(logDir, sessionID+jsonlExt) - if _, err := os.Stat(logFile); err != nil { - return fmt.Errorf("no log file for session %q at %s", sessionID, logFile) - } - - if logsFollow { - return tailLog(cmd, logFile, fs) - } - return dumpLog(logFile, fs) -} - -// listSessionLogs prints a table of session-id → entry count. -func listSessionLogs(logDir string) error { - out := output.Stdout() - entries, err := os.ReadDir(logDir) - if err != nil { - if os.IsNotExist(err) { - out.Dim("no logs yet at %s", logDir) - out.Dim("they populate after the first tool call in a ctm session") - return nil - } - return fmt.Errorf("reading log dir: %w", err) - } - - type row struct { - name string - size int64 - count int - mtime time.Time - } - var rows []row - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), jsonlExt) { - continue - } - info, err := e.Info() - if err != nil { - continue - } - name := strings.TrimSuffix(e.Name(), jsonlExt) - rows = append(rows, row{ - name: name, - size: info.Size(), - count: countLines(filepath.Join(logDir, e.Name())), - mtime: info.ModTime(), - }) - } - - if len(rows) == 0 { - out.Dim("no session logs yet at %s", logDir) - return nil - } - - // Sort by most recent - sort.Slice(rows, func(i, j int) bool { return rows[i].mtime.After(rows[j].mtime) }) - - fmt.Printf("%-40s %6s %-20s\n", "SESSION", "CALLS", "LAST") - for _, r := range rows { - fmt.Printf("%-40s %6d %-20s\n", - truncate(r.name, 40), - r.count, - humanDuration(time.Since(r.mtime))+" ago") - } - return nil -} - -// countLines returns the number of newline-terminated records across -// the active log and every rotated .gz sibling. Returns 0 on any error -// — this is a display helper, not critical. -func countLines(path string) int { - sources, err := logrotate.Sources(path) - if err != nil { - return 0 - } - var total int - for _, src := range sources { - total += countLinesOne(src) - } - return total -} - -// countLinesOne counts newline-terminated records in a single source -// (plain or .gz), returning 0 on any error. -func countLinesOne(path string) int { - r, err := logrotate.Open(path) - if err != nil { - return 0 - } - defer r.Close() - var count int - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) - for scanner.Scan() { - count++ - } - return count -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n-1] + "…" -} - -// dumpLog reads every JSONL line for a session — across rotated .gz -// siblings and the active log, in chronological order — applies the -// filter spec (if any), and prints each passing line formatted (or -// raw if --raw was passed). -// -// Per-source errors (e.g. a corrupt .gz, an I/O error mid-read) are -// logged at WARN level and skipped rather than aborting the rest of -// the session's history. A truncated final line in any source is -// handled naturally by bufio.Scanner, which emits the partial bytes -// as a final token. -func dumpLog(path string, fs filterSpec) error { - sources, err := logrotate.Sources(path) - if err != nil { - return err - } - for _, src := range sources { - if err := dumpOne(src, fs); err != nil { - slog.Warn("skipping unreadable log source", - "path", src, "err", err) - continue - } - } - return nil -} - -// dumpOne reads a single source (plain or .gz) line by line, drops -// anything the filter rejects, and prints each survivor via -// printFormattedEntry (or raw passthrough when --raw). -func dumpOne(path string, fs filterSpec) error { - r, err := logrotate.Open(path) - if err != nil { - return err - } - defer r.Close() - - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) - for scanner.Scan() { - line := scanner.Bytes() - if !fs.match(line) { - continue - } - if logsRaw { - fmt.Println(string(line)) - continue - } - printFormattedEntry(line) - } - return scanner.Err() -} - -// tailLog prints existing entries then follows new ones. First it -// drains every rotated .gz sibling in chronological order, then drains -// the active log, then polls every 500ms for new entries. Each line -// is filtered against fs before being printed. Handles truncation and -// mid-tail rotation by reopening when the active file shrinks below -// our read offset or disappears entirely. Exits cleanly on Ctrl-C via -// the command context. -func tailLog(cmd *cobra.Command, path string, fs filterSpec) error { - // Drain rotated siblings first — they're immutable and won't grow. - if sources, err := logrotate.Sources(path); err == nil { - for _, src := range sources { - if src == path { - continue - } - _ = dumpOne(src, fs) - } - } - - f, err := os.Open(path) - if err != nil { - return err - } - defer func() { f.Close() }() - reader := bufio.NewReader(f) - var offset int64 - - drain := func() error { - for { - line, err := reader.ReadBytes('\n') - if len(line) > 0 { - offset += int64(len(line)) - trimmed := strings.TrimRight(string(line), "\n") - if fs.match([]byte(trimmed)) { - if logsRaw { - fmt.Println(trimmed) - } else { - printFormattedEntry([]byte(trimmed)) - } - } - } - if err == io.EOF { - return nil - } - if err != nil { - return err - } - } - } - - if err := drain(); err != nil { - return err - } - - ctx := cmd.Context() - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return nil - case <-ticker.C: - } - - // Detect rotation/truncation by stat-ing the PATH (not the fd). - info, statErr := os.Stat(path) - if statErr != nil { - // File vanished. Try to reopen; if it's back, drain from 0. - if nf, openErr := os.Open(path); openErr == nil { - f.Close() - f = nf - reader = bufio.NewReader(f) - offset = 0 - _ = drain() - } - continue - } - - if info.Size() < offset { - // Truncated or rotated in place — reset. - if nf, openErr := os.Open(path); openErr == nil { - f.Close() - f = nf - reader = bufio.NewReader(f) - offset = 0 - } - } - - if err := drain(); err != nil { - return err - } - } -} - -// printFormattedEntry renders a single JSONL entry as a short line: -// -// 2026-04-12T10:23:45Z Read /path/to/file -func printFormattedEntry(raw []byte) { - var entry map[string]interface{} - if err := json.Unmarshal(raw, &entry); err != nil { - fmt.Println(string(raw)) - return - } - ts, _ := entry["ctm_timestamp"].(string) - if ts == "" { - ts = "—" - } - toolName, _ := entry["tool_name"].(string) - if toolName == "" { - toolName = "?" - } - summary := toolInputSummary(entry["tool_input"]) - fmt.Printf("%-20s %-12s %s\n", ts, toolName, summary) -} - -// toolInputSummary extracts a short human-readable hint from tool_input. -// Falls back to "—" if nothing useful is found. -func toolInputSummary(v interface{}) string { - m, ok := v.(map[string]interface{}) - if !ok { - return "—" - } - // Common keys across tools, in priority order - for _, key := range []string{"file_path", "path", "command", "pattern", "url", "prompt"} { - if val, ok := m[key].(string); ok && val != "" { - return truncate(val, 80) - } - } - return "—" -} diff --git a/cmd/logs_extra_test.go b/cmd/logs_extra_test.go deleted file mode 100644 index da397bf..0000000 --- a/cmd/logs_extra_test.go +++ /dev/null @@ -1,477 +0,0 @@ -package cmd - -import ( - stdbytes "bytes" - "context" - "fmt" - "io" - "os" - "path/filepath" - "regexp" - "strings" - "sync" - "testing" - "time" - - "github.com/spf13/cobra" -) - -// captureStdout redirects os.Stdout for the duration of fn and returns -// everything that was written. Used to assert on side-effecting helpers. -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - old := os.Stdout - r, w, err := os.Pipe() - if err != nil { - t.Fatalf("pipe: %v", err) - } - os.Stdout = w - - var ( - buf stdbytes.Buffer - wg sync.WaitGroup - ) - wg.Add(1) - go func() { - defer wg.Done() - _, _ = io.Copy(&buf, r) - }() - - fn() - - w.Close() - os.Stdout = old - wg.Wait() - return buf.String() -} - -// withFlags resets logs* flag globals around fn so tests don't leak. -func withFlags(t *testing.T, follow, raw bool, since, tool, grep string, fn func()) { - t.Helper() - pf, pr := logsFollow, logsRaw - ps, pt, pg := logsSince, logsTool, logsGrep - logsFollow = follow - logsRaw = raw - logsSince = since - logsTool = tool - logsGrep = grep - defer func() { - logsFollow, logsRaw = pf, pr - logsSince, logsTool, logsGrep = ps, pt, pg - }() - fn() -} - -// --- compileFilters --------------------------------------------------------- - -func TestCompileFilters_AllUnsetIsInactive(t *testing.T) { - withFlags(t, false, false, "", "", "", func() { - fs, err := compileFilters() - if err != nil { - t.Fatalf("err: %v", err) - } - if fs.active { - t.Errorf("expected inactive filterSpec, got active=true") - } - if fs.grep != nil || fs.toolLow != "" || !fs.since.IsZero() { - t.Errorf("expected zero spec, got %+v", fs) - } - }) -} - -func TestCompileFilters_AllSetActive(t *testing.T) { - withFlags(t, false, false, "1h", "Bash", "foo", func() { - fs, err := compileFilters() - if err != nil { - t.Fatalf("err: %v", err) - } - if !fs.active { - t.Error("expected active=true") - } - if fs.toolLow != "bash" { - t.Errorf("toolLow = %q, want bash", fs.toolLow) - } - if fs.grep == nil { - t.Error("expected grep to be compiled") - } - if fs.since.IsZero() { - t.Error("expected since to be set") - } - }) -} - -func TestCompileFilters_BadSince(t *testing.T) { - withFlags(t, false, false, "abcd", "", "", func() { - _, err := compileFilters() - if err == nil || !strings.Contains(err.Error(), "--since") { - t.Errorf("expected --since error, got %v", err) - } - }) -} - -func TestCompileFilters_BadGrep(t *testing.T) { - withFlags(t, false, false, "", "", "[invalid(", func() { - _, err := compileFilters() - if err == nil || !strings.Contains(err.Error(), "--grep") { - t.Errorf("expected --grep error, got %v", err) - } - }) -} - -// --- truncate edge ---------------------------------------------------------- - -func TestTruncate_LongerThanN(t *testing.T) { - got := truncate("abcdefghij", 5) - // returns s[:n-1] + "…" (which is 1 rune, 3 bytes). - if !strings.HasSuffix(got, "…") { - t.Errorf("truncate did not append ellipsis: %q", got) - } - if !strings.HasPrefix(got, "abcd") { - t.Errorf("truncate prefix wrong: %q", got) - } -} - -func TestTruncate_ShorterThanN(t *testing.T) { - if got := truncate("abc", 5); got != "abc" { - t.Errorf("truncate(abc,5) = %q, want abc", got) - } -} - -// --- toolInputSummary ------------------------------------------------------- - -func TestToolInputSummary_AllKeyPaths(t *testing.T) { - cases := []struct { - name string - in any - want string - }{ - {"file_path", map[string]any{"file_path": "/tmp/x"}, "/tmp/x"}, - {"path", map[string]any{"path": "/var/log"}, "/var/log"}, - {"command", map[string]any{"command": "echo hi"}, "echo hi"}, - {"pattern", map[string]any{"pattern": "foo.*"}, "foo.*"}, - {"url", map[string]any{"url": "https://x"}, "https://x"}, - {"prompt", map[string]any{"prompt": "hi there"}, "hi there"}, - {"none", map[string]any{"other": "ignored"}, "—"}, - {"non-map", "string-input", "—"}, - {"nil", nil, "—"}, - {"empty string val falls through to none", map[string]any{"file_path": ""}, "—"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := toolInputSummary(c.in); got != c.want { - t.Errorf("got %q want %q", got, c.want) - } - }) - } -} - -func TestToolInputSummary_Truncates(t *testing.T) { - long := strings.Repeat("a", 200) - got := toolInputSummary(map[string]any{"command": long}) - if !strings.HasSuffix(got, "…") { - t.Errorf("expected truncated output, got len=%d", len(got)) - } -} - -// --- printFormattedEntry ---------------------------------------------------- - -func TestPrintFormattedEntry_ValidJSON(t *testing.T) { - out := captureStdout(t, func() { - printFormattedEntry([]byte(`{"ctm_timestamp":"2026-01-02T03:04:05Z","tool_name":"Bash","tool_input":{"command":"ls"}}`)) - }) - if !strings.Contains(out, "Bash") || !strings.Contains(out, "ls") || !strings.Contains(out, "2026-01-02T03:04:05Z") { - t.Errorf("unexpected output: %q", out) - } -} - -func TestPrintFormattedEntry_MissingFieldsUseDashes(t *testing.T) { - out := captureStdout(t, func() { - printFormattedEntry([]byte(`{}`)) - }) - if !strings.Contains(out, "—") || !strings.Contains(out, "?") { - t.Errorf("expected fallback markers in %q", out) - } -} - -func TestPrintFormattedEntry_InvalidJSONPrintsRaw(t *testing.T) { - out := captureStdout(t, func() { - printFormattedEntry([]byte("not-json-at-all")) - }) - if !strings.Contains(out, "not-json-at-all") { - t.Errorf("expected raw passthrough, got %q", out) - } -} - -// --- dumpOne / dumpLog formatting paths ------------------------------------- - -func TestDumpOne_RawAndFormatted(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "sess.jsonl") - writeLines(t, path, 2) - - // Raw mode: the lines pass through verbatim. - withFlags(t, false, true, "", "", "", func() { - out := captureStdout(t, func() { - if err := dumpOne(path, filterSpec{}); err != nil { - t.Fatalf("dumpOne: %v", err) - } - }) - if strings.Count(out, "\n") < 2 { - t.Errorf("expected ≥2 lines, got %q", out) - } - if !strings.Contains(out, `"tool_name":"Read"`) { - t.Errorf("raw passthrough missing JSON: %q", out) - } - }) - - // Formatted mode: prints the table-shaped row, not raw JSON. - withFlags(t, false, false, "", "", "", func() { - out := captureStdout(t, func() { - if err := dumpOne(path, filterSpec{}); err != nil { - t.Fatalf("dumpOne formatted: %v", err) - } - }) - if !strings.Contains(out, "Read") || !strings.Contains(out, "/x") { - t.Errorf("formatted output missing fields: %q", out) - } - }) -} - -func TestDumpOne_FilterSkipsAll(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "sess.jsonl") - writeLines(t, path, 3) - - fs := filterSpec{ - grep: regexp.MustCompile("WILL_NOT_MATCH"), - active: true, - } - withFlags(t, false, true, "", "", "", func() { - out := captureStdout(t, func() { - if err := dumpOne(path, fs); err != nil { - t.Fatalf("dumpOne: %v", err) - } - }) - if out != "" { - t.Errorf("expected empty output when filter rejects all, got %q", out) - } - }) -} - -func TestDumpOne_NonexistentReturnsError(t *testing.T) { - if err := dumpOne(filepath.Join(t.TempDir(), "missing.jsonl"), filterSpec{}); err == nil { - t.Error("expected error opening nonexistent path") - } -} - -func TestDumpLog_NoSourcesIsNoOp(t *testing.T) { - // Path inside an empty directory: logrotate.Sources should return - // an empty slice and dumpLog should succeed without printing. - dir := t.TempDir() - withFlags(t, false, true, "", "", "", func() { - out := captureStdout(t, func() { - if err := dumpLog(filepath.Join(dir, "ghost.jsonl"), filterSpec{}); err != nil { - t.Errorf("dumpLog on empty dir should not error, got %v", err) - } - }) - if out != "" { - t.Errorf("expected no output, got %q", out) - } - }) -} - -// --- listSessionLogs -------------------------------------------------------- - -func TestListSessionLogs_MissingDirIsSoft(t *testing.T) { - dir := filepath.Join(t.TempDir(), "does-not-exist") - if err := listSessionLogs(dir); err != nil { - t.Errorf("missing log dir should not error, got %v", err) - } -} - -func TestListSessionLogs_EmptyDirSoftMessage(t *testing.T) { - dir := t.TempDir() // empty - if err := listSessionLogs(dir); err != nil { - t.Errorf("empty log dir should not error, got %v", err) - } -} - -func TestListSessionLogs_PrintsRows(t *testing.T) { - dir := t.TempDir() - writeLines(t, filepath.Join(dir, "session-aaa.jsonl"), 2) - writeLines(t, filepath.Join(dir, "session-bbb.jsonl"), 1) - // Touch one to be more recent so sort order is deterministic. - now := time.Now() - if err := os.Chtimes(filepath.Join(dir, "session-aaa.jsonl"), now, now); err != nil { - t.Fatalf("chtimes: %v", err) - } - if err := os.Chtimes(filepath.Join(dir, "session-bbb.jsonl"), now.Add(-time.Hour), now.Add(-time.Hour)); err != nil { - t.Fatalf("chtimes: %v", err) - } - // Non-jsonl and a directory should be ignored. - if err := os.Mkdir(filepath.Join(dir, "subdir"), 0700); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(filepath.Join(dir, "skip.txt"), []byte("ignored"), 0600); err != nil { - t.Fatalf("write: %v", err) - } - - out := captureStdout(t, func() { - if err := listSessionLogs(dir); err != nil { - t.Fatalf("listSessionLogs: %v", err) - } - }) - if !strings.Contains(out, "SESSION") || !strings.Contains(out, "CALLS") { - t.Errorf("missing header, out=%q", out) - } - if !strings.Contains(out, "session-aaa") || !strings.Contains(out, "session-bbb") { - t.Errorf("missing session names, out=%q", out) - } - if strings.Contains(out, "skip.txt") || strings.Contains(out, "subdir") { - t.Errorf("non-jsonl entries leaked: %q", out) - } - // Newest first → aaa before bbb. - if strings.Index(out, "session-aaa") > strings.Index(out, "session-bbb") { - t.Errorf("expected aaa before bbb (newer-first), got %q", out) - } -} - -// --- runLogs (covers HOME-based path resolution + missing-session error) ---- - -// pointHomeAtTempDir sets $HOME so that config.Dir() resolves under a -// tempdir we control. Returns the resolved logs/ path. -func pointHomeAtTempDir(t *testing.T) string { - t.Helper() - home := t.TempDir() - t.Setenv("HOME", home) - logs := filepath.Join(home, ".config", "ctm", "logs") - if err := os.MkdirAll(logs, 0700); err != nil { - t.Fatalf("mkdir logs: %v", err) - } - return logs -} - -func TestRunLogs_NoArgListsLogs(t *testing.T) { - logs := pointHomeAtTempDir(t) - writeLines(t, filepath.Join(logs, "abc.jsonl"), 1) - - withFlags(t, false, false, "", "", "", func() { - out := captureStdout(t, func() { - if err := runLogs(&cobra.Command{}, nil); err != nil { - t.Fatalf("runLogs: %v", err) - } - }) - if !strings.Contains(out, "abc") { - t.Errorf("expected listing to include 'abc', got %q", out) - } - }) -} - -func TestRunLogs_BadSinceReturnsError(t *testing.T) { - pointHomeAtTempDir(t) - withFlags(t, false, false, "abcd", "", "", func() { - err := runLogs(&cobra.Command{}, []string{"sess"}) - if err == nil || !strings.Contains(err.Error(), "--since") { - t.Errorf("expected --since error, got %v", err) - } - }) -} - -func TestRunLogs_MissingSessionReturnsError(t *testing.T) { - pointHomeAtTempDir(t) - withFlags(t, false, false, "", "", "", func() { - err := runLogs(&cobra.Command{}, []string{"nope"}) - if err == nil || !strings.Contains(err.Error(), "no log file") { - t.Errorf("expected no-log-file error, got %v", err) - } - }) -} - -func TestRunLogs_DumpsExistingSession(t *testing.T) { - logs := pointHomeAtTempDir(t) - writeLines(t, filepath.Join(logs, "sessX.jsonl"), 2) - - withFlags(t, false, true, "", "", "", func() { - out := captureStdout(t, func() { - if err := runLogs(&cobra.Command{}, []string{"sessX"}); err != nil { - t.Fatalf("runLogs: %v", err) - } - }) - if strings.Count(out, "\n") < 2 { - t.Errorf("expected ≥2 raw lines, got %q", out) - } - }) -} - -// --- tailLog (drives the rotation/truncation paths via context cancel) ------ - -func TestTailLog_DrainsThenExitsOnContextCancel(t *testing.T) { - logs := pointHomeAtTempDir(t) - path := filepath.Join(logs, "sessTail.jsonl") - writeLines(t, path, 2) - - ctx, cancel := context.WithCancel(context.Background()) - root := &cobra.Command{} - root.SetContext(ctx) - - // wg gates this test on the goroutine fully exiting, including - // withFlags' deferred restore of the package-level logs* globals. - // Without this, a follow-on test's withFlags read can race the - // in-flight defer write — caught by `go test -race`. - var wg sync.WaitGroup - done := make(chan error, 1) - wg.Add(1) - go func() { - defer wg.Done() - withFlags(t, true, true, "", "", "", func() { - done <- tailLog(root, path, filterSpec{}) - }) - }() - - // Let the initial drain run, then cancel. - time.Sleep(50 * time.Millisecond) - cancel() - - select { - case err := <-done: - if err != nil { - t.Errorf("tailLog returned err on cancel: %v", err) - } - case <-time.After(3 * time.Second): - t.Fatal("tailLog did not exit after cancel") - } - wg.Wait() -} - -func TestTailLog_NonexistentPathReturnsError(t *testing.T) { - ctx := context.Background() - root := &cobra.Command{} - root.SetContext(ctx) - withFlags(t, true, true, "", "", "", func() { - err := tailLog(root, filepath.Join(t.TempDir(), "missing.jsonl"), filterSpec{}) - if err == nil { - t.Error("expected error for nonexistent tail path") - } - }) -} - -// --- ensure init registered logsCmd on rootCmd (sanity / cheap coverage) ---- - -func TestLogsCmdRegistered(t *testing.T) { - if _, _, err := rootCmd.Find([]string{"logs"}); err != nil { - t.Fatalf("logs subcommand not registered: %v", err) - } -} - -// --- extra: parseSince is hit indirectly above; this guards the empty -// string error path stays an error after compileFilters drops -// through. - -func TestParseSince_EmptyIsError(t *testing.T) { - if _, err := parseSince(""); err == nil { - t.Error("expected error for empty string") - } -} - -// keep fmt import used (in case future tests are added) -var _ = fmt.Sprintf diff --git a/cmd/logs_test.go b/cmd/logs_test.go deleted file mode 100644 index b0611da..0000000 --- a/cmd/logs_test.go +++ /dev/null @@ -1,252 +0,0 @@ -package cmd - -import ( - "bytes" - "compress/gzip" - "fmt" - "os" - "path/filepath" - "regexp" - "testing" - "time" -) - -// writeLine writes n JSONL lines of a minimal tool-use shape to path. -func writeLines(t *testing.T, path string, n int) { - t.Helper() - var buf bytes.Buffer - for i := 0; i < n; i++ { - buf.WriteString(`{"tool_name":"Read","tool_input":{"file_path":"/x"}}` + "\n") - } - if err := os.WriteFile(path, buf.Bytes(), 0600); err != nil { - t.Fatalf("write: %v", err) - } -} - -// writeGzLines writes n JSONL lines gzipped to path. -func writeGzLines(t *testing.T, path string, n int) { - t.Helper() - var buf bytes.Buffer - gw := gzip.NewWriter(&buf) - for i := 0; i < n; i++ { - gw.Write([]byte(`{"tool_name":"Read","tool_input":{"file_path":"/x"}}` + "\n")) - } - if err := gw.Close(); err != nil { - t.Fatalf("gzip close: %v", err) - } - if err := os.WriteFile(path, buf.Bytes(), 0600); err != nil { - t.Fatalf("write gz: %v", err) - } -} - -func TestCountLines_SumsAcrossRotated(t *testing.T) { - dir := t.TempDir() - active := filepath.Join(dir, "sess.jsonl") - writeLines(t, active, 2) - writeGzLines(t, filepath.Join(dir, "sess.jsonl.1000.gz"), 3) - writeGzLines(t, filepath.Join(dir, "sess.jsonl.2000.gz"), 4) - - got := countLines(active) - if got != 2+3+4 { - t.Errorf("countLines = %d, want 9", got) - } -} - -func TestCountLines_IgnoresUnrelatedSiblings(t *testing.T) { - dir := t.TempDir() - active := filepath.Join(dir, "sess.jsonl") - writeLines(t, active, 2) - - // Unrelated files in the same dir must not be counted. - writeLines(t, filepath.Join(dir, "other.jsonl"), 10) - writeGzLines(t, filepath.Join(dir, "sess.jsonl.bak.gz"), 99) // non-numeric suffix - writeLines(t, filepath.Join(dir, "sess.jsonl.garbage"), 99) // not .gz - - got := countLines(active) - if got != 2 { - t.Errorf("countLines = %d, want 2 (only active)", got) - } -} - -func TestCountLines_ActiveMissingStillSumsRotated(t *testing.T) { - dir := t.TempDir() - active := filepath.Join(dir, "sess.jsonl") // never created - writeGzLines(t, filepath.Join(dir, "sess.jsonl.1.gz"), 5) - - if got := countLines(active); got != 5 { - t.Errorf("countLines = %d, want 5", got) - } -} - -// TestCountLines_TruncatedFinalLineNotPanics: an active log whose final -// line is a truncated (non-newline-terminated) JSONL record must still -// count and must not crash. bufio.Scanner emits the partial bytes as a -// final token — we count 2 here (one clean + one truncated). -func TestCountLines_TruncatedFinalLineNotPanics(t *testing.T) { - dir := t.TempDir() - active := filepath.Join(dir, "sess.jsonl") - raw := `{"tool_name":"Read","tool_input":{"file_path":"/x"}}` + "\n" + - `{"tool_name":"Bash","tool_input":{"command":"echo ` // no trailing quote + no newline - if err := os.WriteFile(active, []byte(raw), 0600); err != nil { - t.Fatalf("write: %v", err) - } - - if got := countLines(active); got != 2 { - t.Errorf("countLines = %d, want 2 (1 clean + 1 truncated partial)", got) - } -} - -// TestCountLines_CorruptGzSkipsWithoutCrash: a rotated sibling that -// claims to be .gz but isn't (empty or garbage) must be counted as -// zero lines, not crash, and not prevent other sources from counting. -func TestCountLines_CorruptGzSkipsWithoutCrash(t *testing.T) { - dir := t.TempDir() - active := filepath.Join(dir, "sess.jsonl") - writeLines(t, active, 3) - writeGzLines(t, filepath.Join(dir, "sess.jsonl.1000.gz"), 5) - // 2000.gz is NOT a gzip file despite the extension. - if err := os.WriteFile(filepath.Join(dir, "sess.jsonl.2000.gz"), []byte("definitely not gzip"), 0600); err != nil { - t.Fatalf("write corrupt: %v", err) - } - - // The good sources should still contribute; the corrupt one counts 0. - got := countLines(active) - if got != 3+5 { - t.Errorf("countLines = %d, want 8 (skip corrupt, sum others)", got) - } -} - -// TestDumpLog_CorruptSourceSkipsAndContinues: dumpLog encountering a -// corrupt .gz should log a warning and continue reading the rest, -// rather than aborting. -func TestDumpLog_CorruptSourceSkipsAndContinues(t *testing.T) { - dir := t.TempDir() - active := filepath.Join(dir, "sess.jsonl") - writeLines(t, active, 2) - // Corrupt rotated sibling claims .gz but isn't. - if err := os.WriteFile(filepath.Join(dir, "sess.jsonl.1000.gz"), []byte("garbage"), 0600); err != nil { - t.Fatalf("write corrupt: %v", err) - } - - if err := dumpLog(active, filterSpec{}); err != nil { - t.Errorf("dumpLog should not fail on corrupt .gz, got: %v", err) - } -} - -func TestParseSince(t *testing.T) { - tests := []struct { - in string - want time.Duration - wantErr bool - }{ - {"30m", 30 * time.Minute, false}, - {"24h", 24 * time.Hour, false}, - {"1h30m", 90 * time.Minute, false}, - {"7d", 7 * 24 * time.Hour, false}, - {"0d", 0, false}, - {"365d", 365 * 24 * time.Hour, false}, - {"-3d", 0, true}, - {"abcd", 0, true}, - {"d", 0, true}, - {"", 0, true}, - } - for _, tt := range tests { - t.Run(tt.in, func(t *testing.T) { - got, err := parseSince(tt.in) - if tt.wantErr { - if err == nil { - t.Errorf("parseSince(%q) expected error, got %v", tt.in, got) - } - return - } - if err != nil { - t.Errorf("parseSince(%q) err = %v", tt.in, err) - } - if got != tt.want { - t.Errorf("parseSince(%q) = %v, want %v", tt.in, got, tt.want) - } - }) - } -} - -func TestFilterSpec_MatchesByTool(t *testing.T) { - fs := filterSpec{toolLow: "bash", active: true} - cases := []struct { - name string - line string - want bool - }{ - {"match lower", `{"tool_name":"bash"}`, true}, - {"match mixed case", `{"tool_name":"Bash"}`, true}, - {"different tool", `{"tool_name":"Read"}`, false}, - {"no tool_name", `{"other":"x"}`, false}, - {"invalid json", `not json`, false}, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - got := fs.match([]byte(tt.line)) - if got != tt.want { - t.Errorf("match(%q) = %v, want %v", tt.line, got, tt.want) - } - }) - } -} - -func TestFilterSpec_MatchesByGrep(t *testing.T) { - re := regexp.MustCompile(`(?i)urgent`) - fs := filterSpec{grep: re, active: true} - if !fs.match([]byte(`{"msg":"Urgent task"}`)) { - t.Error("expected case-insensitive grep match") - } - if fs.match([]byte(`{"msg":"calm"}`)) { - t.Error("expected grep to reject non-matching line") - } -} - -func TestFilterSpec_MatchesBySince(t *testing.T) { - recent := time.Now().Add(-1 * time.Hour).Format(time.RFC3339) - old := time.Now().Add(-10 * 24 * time.Hour).Format(time.RFC3339) - - fs := filterSpec{since: time.Now().Add(-2 * time.Hour), active: true} - - if !fs.match([]byte(fmt.Sprintf(`{"ctm_timestamp":%q}`, recent))) { - t.Error("recent entry should pass 2h since filter") - } - if fs.match([]byte(fmt.Sprintf(`{"ctm_timestamp":%q}`, old))) { - t.Error("10-day-old entry should fail 2h since filter") - } - if fs.match([]byte(`{}`)) { - t.Error("missing/malformed ctm_timestamp should fail since filter") - } -} - -func TestFilterSpec_Combines(t *testing.T) { - recent := time.Now().Add(-30 * time.Minute).Format(time.RFC3339) - fs := filterSpec{ - toolLow: "bash", - since: time.Now().Add(-1 * time.Hour), - active: true, - } - // Match: recent + tool=bash. - ok := fs.match([]byte(fmt.Sprintf( - `{"ctm_timestamp":%q,"tool_name":"Bash"}`, recent))) - if !ok { - t.Error("recent Bash entry should pass combined filter") - } - // Fail: recent but wrong tool. - fail := fs.match([]byte(fmt.Sprintf( - `{"ctm_timestamp":%q,"tool_name":"Read"}`, recent))) - if fail { - t.Error("recent Read entry should fail combined (tool mismatch)") - } -} - -func TestFilterSpec_ZeroMatchesEverything(t *testing.T) { - var fs filterSpec // zero value, active=false - lines := []string{`{"tool_name":"Read"}`, `not json`, `{}`, ``} - for _, l := range lines { - if !fs.match([]byte(l)) { - t.Errorf("zero filter must accept %q", l) - } - } -} diff --git a/cmd/new.go b/cmd/new.go index 686f3fb..37aa309 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -9,7 +9,6 @@ import ( "github.com/RandomCodeSpace/ctm/internal/config" "github.com/RandomCodeSpace/ctm/internal/output" "github.com/RandomCodeSpace/ctm/internal/prompt" - "github.com/RandomCodeSpace/ctm/internal/serve/proc" "github.com/RandomCodeSpace/ctm/internal/session" "github.com/RandomCodeSpace/ctm/internal/shell" "github.com/RandomCodeSpace/ctm/internal/tmux" @@ -28,8 +27,6 @@ var newCmd = &cobra.Command{ } func runNew(cmd *cobra.Command, args []string) error { - proc.EnsureServeRunning(cmd.Context()) - out := output.Stderr() cfgPtr, err := ensureSetup() if err != nil { diff --git a/cmd/overlay.go b/cmd/overlay.go deleted file mode 100644 index ca6323b..0000000 --- a/cmd/overlay.go +++ /dev/null @@ -1,287 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - - "github.com/RandomCodeSpace/ctm/internal/config" - "github.com/RandomCodeSpace/ctm/internal/output" - "github.com/spf13/cobra" -) - -// Repeated overlay messages / format strings extracted to satisfy the -// no-duplicate-literal rule. -const ( - errCreatingConfigDirFmt = "creating config dir: %w" - dimStatusLineFmt = "statusLine: %s" - dimEnvFileFmt = "env file: %s" -) - -func init() { - overlayCmd.AddCommand(overlayInitCmd) - overlayCmd.AddCommand(overlayEditCmd) - overlayCmd.AddCommand(overlayPathCmd) - rootCmd.AddCommand(overlayCmd) -} - -var overlayCmd = &cobra.Command{ - Use: "overlay", - Short: "Manage the ctm-only claude settings overlay", - Long: "The overlay file at ~/.config/ctm/claude-overlay.json contains claude " + - "settings (statusline, theme, etc.) that apply ONLY when claude is launched " + - "via ctm. Direct `claude` invocations are unaffected.", - RunE: runOverlayStatus, -} - -var overlayInitCmd = &cobra.Command{ - Use: "init", - Short: "Create a sample overlay file with statusline + theme examples", - RunE: runOverlayInit, -} - -var overlayEditCmd = &cobra.Command{ - Use: "edit", - Short: "Open the overlay file in $EDITOR (creates it first if missing)", - RunE: runOverlayEdit, -} - -var overlayPathCmd = &cobra.Command{ - Use: "path", - Short: "Print the overlay file path", - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println(config.ClaudeOverlayPath()) - return nil - }, -} - -// sessionLogDir returns the directory where per-session tool-use logs are written. -func sessionLogDir() string { - return filepath.Join(config.Dir(), "logs") -} - -// ctmSubcommand returns the absolute path to the ctm binary plus the given -// subcommand, suitable for embedding in claude hook JSON. We resolve at -// overlay generation time so hooks keep working even if PATH changes. -// Falls back to bare "ctm " if os.Executable fails, which is rare. -func ctmSubcommand(sub string) string { - if exe, err := os.Executable(); err == nil && exe != "" { - return exe + " " + sub - } - return "ctm " + sub -} - -// logToolUseHookCommand is the PostToolUse hook target (hidden subcommand -// in cmd/log_tool_use.go). Pure Go — no jq / bash dependency. -func logToolUseHookCommand() string { return ctmSubcommand("log-tool-use") } - -// statuslineHookCommand is the statusLine.command target (hidden subcommand -// in cmd/statusline.go). Pure Go — no jq / awk / bash dependency. -func statuslineHookCommand() string { return ctmSubcommand("statusline") } - -// buildSampleOverlay returns the overlay JSON, pointing statusLine at the -// built-in `ctm statusline` renderer and PostToolUse at the logging hook. -// Both hook commands are resolved to the ctm binary at write time so they -// keep working even if PATH changes. -// -// `tui`, `viewMode`, and `remoteControlAtStartup` live here (not in -// ~/.claude/settings.json or ~/.claude.json) so ctm never mutates any -// Claude-owned config file on disk. The overlay is merged on top of -// settings.json only when claude is launched via ctm — direct `claude` -// invocations are completely unaffected by ctm's defaults. -// -// Note: env vars like CLAUDE_CODE_NO_FLICKER cannot go here — claude -// reads them too early in startup for settings.json's env key to take -// effect. They live in ~/.config/ctm/claude-env.json (see -// sampleClaudeEnvJSON) and are exported by the shell before claude -// launches via config.ClaudeEnvExports(). -func buildSampleOverlay(statuslineCmd, logHookCmd string) string { - return fmt.Sprintf(`{ - "reduceMotion": false, - "spinnerTipsEnabled": false, - "statusLine": { - "type": "command", - "command": %q - }, - "theme": "dark", - "tui": "fullscreen", - "viewMode": "focus", - "remoteControlAtStartup": true, - "env": { - "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" - }, - "hooks": { - "PostToolUse": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": %q - } - ] - } - ] - } -} -`, statuslineCmd, logHookCmd) -} - -// sampleClaudeEnvJSON is the JSON env file ctm reads at every claude -// launch and exports into the shell BEFORE exec'ing claude. Use this -// for env vars claude reads during CLI startup, which is too early for -// the overlay's `env` block to take effect. Most env vars (including -// CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) belong in claude-overlay.json's -// `env` block instead and should be put there. -// -// Pre-seeded with the two ctm-default vars: -// - CLAUDE_CODE_NO_FLICKER: flicker-free streaming markdown rendering -// - CTM_STATUSLINE_DUMP: where `ctm statusline` writes per-session -// quota dumps; `{uuid}` is substituted by -// the statusline subcommand at render time. -const sampleClaudeEnvJSON = `{ - "_comment": "ctm-managed env vars exported into the shell that spawns claude. Only affects claude processes launched via ctm; direct 'claude' calls outside ctm are unaffected. Use this for vars claude reads too early in startup for claude-overlay.json's 'env' block to take effect. For anything else, prefer the overlay's 'env' block.", - "env": { - "CLAUDE_CODE_NO_FLICKER": "1", - "CTM_STATUSLINE_DUMP": "/tmp/ctm-statusline/{uuid}.json" - } -} -` - -// writeClaudeEnv writes the default claude-env.json to path, creating -// parent dirs. Uses O_EXCL so parallel invocations don't clobber each -// other, and leaves an existing file untouched (so user edits survive). -func writeClaudeEnv(path string) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf(errCreatingConfigDirFmt, err) - } - // 0600: claude-env.json is exported by the shell that spawns claude - // and is a natural place for users to park secrets (API keys, - // tokens). Default to owner-only so a user who drops a secret in - // doesn't leak it to other users on a shared host. - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) - if err != nil { - if os.IsExist(err) { - return nil // keep user edits intact - } - return fmt.Errorf("creating claude-env.json: %w", err) - } - defer f.Close() - if _, err := f.WriteString(sampleClaudeEnvJSON); err != nil { - return fmt.Errorf("writing claude-env.json: %w", err) - } - return nil -} - -func runOverlayStatus(cmd *cobra.Command, args []string) error { - out := output.Stdout() - path := config.ClaudeOverlayPath() - if _, err := os.Stat(path); err == nil { - out.Success("overlay active: %s", path) - out.Dim(dimStatusLineFmt, statuslineHookCommand()) - out.Dim("PostToolUse: %s", logToolUseHookCommand()) - envPath := config.ClaudeEnvPath() - if _, err := os.Stat(envPath); err == nil { - out.Dim(dimEnvFileFmt, envPath) - } - } else { - out.Dim("no overlay file at %s", path) - out.Dim("create one with: ctm overlay init") - } - return nil -} - -func runOverlayInit(cmd *cobra.Command, args []string) error { - out := output.Stdout() - path := config.ClaudeOverlayPath() - envPath := config.ClaudeEnvPath() - slCmd := statuslineHookCommand() - logCmd := logToolUseHookCommand() - - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf(errCreatingConfigDirFmt, err) - } - if err := writeClaudeEnv(envPath); err != nil { - return err - } - if err := os.MkdirAll(sessionLogDir(), 0755); err != nil { - return fmt.Errorf("creating session log dir: %w", err) - } - - // O_EXCL is atomic against concurrent creators — no TOCTOU race. - // 0600: personal claude config under ~/.config/ctm/ (0700 dir); no need - // to be world- or group-readable. - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) - if err != nil { - if os.IsExist(err) { - return fmt.Errorf("overlay already exists at %s — delete it first or use `ctm overlay edit`", path) - } - return fmt.Errorf("creating overlay: %w", err) - } - defer f.Close() - - if _, err := f.WriteString(buildSampleOverlay(slCmd, logCmd)); err != nil { - return fmt.Errorf("writing overlay: %w", err) - } - - out.Success("created %s", path) - out.Dim(dimEnvFileFmt, envPath) - out.Dim(dimStatusLineFmt, slCmd) - out.Dim("PostToolUse hook: %s", logCmd) - out.Dim("session logs dir: %s (view: ctm logs)", sessionLogDir()) - out.Dim("edit with: ctm overlay edit") - out.Dim("applies to all NEW ctm sessions; restart existing ones to pick up changes") - return nil -} - -func runOverlayEdit(cmd *cobra.Command, args []string) error { - out := output.Stdout() - path := config.ClaudeOverlayPath() - envPath := config.ClaudeEnvPath() - slCmd := statuslineHookCommand() - logCmd := logToolUseHookCommand() - - // Resolve editor BEFORE touching the filesystem so a missing $EDITOR - // doesn't leave a half-created sample file behind. - editor := os.Getenv("EDITOR") - if editor == "" { - editor = "vi" - } - editorBin, err := exec.LookPath(editor) - if err != nil { - return fmt.Errorf("editor %q not found in PATH — set $EDITOR or install it; overlay path: %s", editor, path) - } - - // Create with sample if missing, atomically. - if _, err := os.Stat(path); os.IsNotExist(err) { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf(errCreatingConfigDirFmt, err) - } - if err := writeClaudeEnv(envPath); err != nil { - return err - } - _ = os.MkdirAll(sessionLogDir(), 0755) - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) - if err != nil && !os.IsExist(err) { - return fmt.Errorf("creating overlay: %w", err) - } - if err == nil { - if _, werr := f.WriteString(buildSampleOverlay(slCmd, logCmd)); werr != nil { - f.Close() - return fmt.Errorf("writing overlay: %w", werr) - } - f.Close() - out.Dim("created sample overlay at %s", path) - out.Dim(dimEnvFileFmt, envPath) - out.Dim(dimStatusLineFmt, slCmd) - out.Dim("PostToolUse hook: %s", logCmd) - } - } - - c := exec.Command(editorBin, path) - c.Stdin = os.Stdin - c.Stdout = os.Stdout - c.Stderr = os.Stderr - return c.Run() -} diff --git a/cmd/overlay_test.go b/cmd/overlay_test.go deleted file mode 100644 index efd56d1..0000000 --- a/cmd/overlay_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/RandomCodeSpace/ctm/internal/config" -) - -// withTempHome is defined in bootstrap_test.go in this package; reuse it. - -func TestSessionLogDir(t *testing.T) { - home := withTempHome(t) - got := sessionLogDir() - want := filepath.Join(home, ".config", "ctm", "logs") - if got != want { - t.Errorf("sessionLogDir() = %q, want %q", got, want) - } -} - -func TestCtmSubcommand(t *testing.T) { - // Happy path: os.Executable returns a real path during `go test`, so the - // returned string must contain the subcommand suffix. - got := ctmSubcommand("statusline") - if !strings.HasSuffix(got, " statusline") { - t.Errorf("ctmSubcommand(\"statusline\") = %q, expected suffix %q", got, " statusline") - } - if got == "" { - t.Error("ctmSubcommand returned empty string") - } -} - -func TestLogToolUseHookCommand(t *testing.T) { - got := logToolUseHookCommand() - if !strings.HasSuffix(got, " log-tool-use") { - t.Errorf("logToolUseHookCommand() = %q, expected suffix %q", got, " log-tool-use") - } -} - -func TestStatuslineHookCommand(t *testing.T) { - got := statuslineHookCommand() - if !strings.HasSuffix(got, " statusline") { - t.Errorf("statuslineHookCommand() = %q, expected suffix %q", got, " statusline") - } -} - -func TestBuildSampleOverlayContainsHookPaths(t *testing.T) { - got := buildSampleOverlay("/usr/local/bin/ctm statusline", "/usr/local/bin/ctm log-tool-use") - - wants := []string{ - `"reduceMotion": false`, - `"spinnerTipsEnabled": false`, - `"statusLine"`, - `"/usr/local/bin/ctm statusline"`, - `"/usr/local/bin/ctm log-tool-use"`, - `"theme": "dark"`, - `"tui": "fullscreen"`, - `"viewMode": "focus"`, - `"remoteControlAtStartup": true`, - `"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"`, - `"PostToolUse"`, - `"matcher": "*"`, - } - for _, want := range wants { - if !strings.Contains(got, want) { - t.Errorf("buildSampleOverlay output missing %q\n--- got ---\n%s", want, got) - } - } -} - -func TestBuildSampleOverlayEscapesPathsWithSpaces(t *testing.T) { - // %q in fmt.Sprintf is what protects us from a path containing a quote - // character — verify the JSON stays parseable. - got := buildSampleOverlay(`/path with spaces/ctm statusline`, `/another path/ctm log-tool-use`) - if !strings.Contains(got, `"/path with spaces/ctm statusline"`) { - t.Errorf("statusline path not properly quoted:\n%s", got) - } - if !strings.Contains(got, `"/another path/ctm log-tool-use"`) { - t.Errorf("log hook path not properly quoted:\n%s", got) - } -} - -func TestWriteClaudeEnvCreatesAndIsIdempotent(t *testing.T) { - tmp := t.TempDir() - path := filepath.Join(tmp, "nested", "dir", "claude-env.json") - - if err := writeClaudeEnv(path); err != nil { - t.Fatalf("first writeClaudeEnv: %v", err) - } - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("stat after first write: %v", err) - } - if mode := info.Mode().Perm(); mode != 0600 { - t.Errorf("claude-env.json perm = %v, want 0600", mode) - } - - // User edit must survive a second call (O_EXCL bails out on EEXIST). - userEdit := []byte(`{"env":{"FOO":"bar"}}`) - if err := os.WriteFile(path, userEdit, 0600); err != nil { - t.Fatalf("user edit: %v", err) - } - if err := writeClaudeEnv(path); err != nil { - t.Fatalf("second writeClaudeEnv: %v", err) - } - got, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(got) != string(userEdit) { - t.Errorf("user edit clobbered:\nwant:\n%s\ngot:\n%s", userEdit, got) - } -} - -func TestWriteClaudeEnvMkdirAllErrorPath(t *testing.T) { - // Pointing the env file at a path whose parent is a regular file forces - // MkdirAll to fail, exercising the error-return branch. - tmp := t.TempDir() - regularFile := filepath.Join(tmp, "blocker") - if err := os.WriteFile(regularFile, []byte("x"), 0600); err != nil { - t.Fatal(err) - } - target := filepath.Join(regularFile, "child", "claude-env.json") - - if err := writeClaudeEnv(target); err == nil { - t.Errorf("expected error when parent path component is a regular file") - } -} - -func TestRunOverlayStatusNoOverlay(t *testing.T) { - withTempHome(t) - if err := runOverlayStatus(nil, nil); err != nil { - t.Errorf("runOverlayStatus with no overlay returned err: %v", err) - } -} - -func TestRunOverlayStatusWithOverlay(t *testing.T) { - withTempHome(t) - // Create overlay + env file so both info-branches are walked. - if err := os.MkdirAll(config.Dir(), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(config.ClaudeOverlayPath(), []byte("{}\n"), 0600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(config.ClaudeEnvPath(), []byte("# env\n"), 0600); err != nil { - t.Fatal(err) - } - - if err := runOverlayStatus(nil, nil); err != nil { - t.Errorf("runOverlayStatus with overlay returned err: %v", err) - } -} - -func TestRunOverlayInitCreates(t *testing.T) { - withTempHome(t) - if err := runOverlayInit(nil, nil); err != nil { - t.Fatalf("runOverlayInit: %v", err) - } - - overlay := config.ClaudeOverlayPath() - data, err := os.ReadFile(overlay) - if err != nil { - t.Fatalf("reading overlay: %v", err) - } - got := string(data) - for _, want := range []string{ - `"reduceMotion"`, - `"spinnerTipsEnabled"`, - `statusline`, - `log-tool-use`, - } { - if !strings.Contains(got, want) { - t.Errorf("overlay missing %q in output:\n%s", want, got) - } - } - - info, err := os.Stat(overlay) - if err != nil { - t.Fatal(err) - } - if mode := info.Mode().Perm(); mode != 0600 { - t.Errorf("overlay mode = %v, want 0600", mode) - } - - // env file + log dir should also exist. - if _, err := os.Stat(config.ClaudeEnvPath()); err != nil { - t.Errorf("env file not created: %v", err) - } - if st, err := os.Stat(sessionLogDir()); err != nil || !st.IsDir() { - t.Errorf("session log dir not a directory: %v", err) - } -} - -func TestRunOverlayInitErrorsWhenAlreadyExists(t *testing.T) { - withTempHome(t) - if err := os.MkdirAll(config.Dir(), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(config.ClaudeOverlayPath(), []byte("{}\n"), 0600); err != nil { - t.Fatal(err) - } - - err := runOverlayInit(nil, nil) - if err == nil { - t.Fatal("runOverlayInit should error when overlay exists") - } - if !strings.Contains(err.Error(), "already exists") { - t.Errorf("error %q should mention 'already exists'", err.Error()) - } -} - -// fakeEditorPath writes a minimal POSIX shell editor that exits 0 without -// touching the file, and prepends its directory to PATH for the test. -// The returned editor name is suitable for $EDITOR. -func fakeEditorPath(t *testing.T, name string) { - t.Helper() - dir := t.TempDir() - bin := filepath.Join(dir, name) - // Use #!/bin/sh true-equivalent so the editor exits cleanly. - script := "#!/bin/sh\nexit 0\n" - if err := os.WriteFile(bin, []byte(script), 0700); err != nil { - t.Fatalf("writing fake editor: %v", err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("EDITOR", name) -} - -func TestRunOverlayEditCreatesSampleAndRunsEditor(t *testing.T) { - withTempHome(t) - fakeEditorPath(t, "fake-editor-create") - - if err := runOverlayEdit(nil, nil); err != nil { - t.Fatalf("runOverlayEdit: %v", err) - } - - // Sample overlay should be created on first edit. - data, err := os.ReadFile(config.ClaudeOverlayPath()) - if err != nil { - t.Fatalf("reading overlay: %v", err) - } - if !strings.Contains(string(data), "statusLine") { - t.Errorf("expected sample overlay content, got:\n%s", data) - } - if _, err := os.Stat(config.ClaudeEnvPath()); err != nil { - t.Errorf("expected env file, got err: %v", err) - } -} - -func TestRunOverlayEditExistingFile(t *testing.T) { - withTempHome(t) - fakeEditorPath(t, "fake-editor-existing") - - if err := os.MkdirAll(config.Dir(), 0700); err != nil { - t.Fatal(err) - } - preexisting := []byte(`{"theme":"light"}`) - if err := os.WriteFile(config.ClaudeOverlayPath(), preexisting, 0600); err != nil { - t.Fatal(err) - } - - if err := runOverlayEdit(nil, nil); err != nil { - t.Fatalf("runOverlayEdit: %v", err) - } - - // Editor exits without changes; existing content must be preserved. - got, err := os.ReadFile(config.ClaudeOverlayPath()) - if err != nil { - t.Fatal(err) - } - if string(got) != string(preexisting) { - t.Errorf("existing overlay was rewritten\nwant: %s\ngot: %s", preexisting, got) - } -} - -func TestRunOverlayEditMissingEditor(t *testing.T) { - withTempHome(t) - // Empty PATH + nonexistent editor name -> exec.LookPath fails. - t.Setenv("PATH", "") - t.Setenv("EDITOR", "definitely-not-a-real-editor-xyzzy") - - err := runOverlayEdit(nil, nil) - if err == nil { - t.Fatal("expected error when editor is missing") - } - if !strings.Contains(err.Error(), "not found in PATH") { - t.Errorf("error %q should mention editor not found in PATH", err.Error()) - } - - // Half-created sample must NOT exist (resolver runs before any FS work). - if _, statErr := os.Stat(config.ClaudeOverlayPath()); statErr == nil { - t.Error("overlay file should not have been created when editor lookup failed") - } -} - -func TestRunOverlayEditDefaultsToVi(t *testing.T) { - withTempHome(t) - // Unset $EDITOR to exercise the "EDITOR == empty -> vi" branch. With an - // empty PATH, vi resolution will fail and we get a clear error mentioning - // "vi". - t.Setenv("PATH", "") - t.Setenv("EDITOR", "") - - err := runOverlayEdit(nil, nil) - if err == nil { - t.Fatal("expected error when vi missing from empty PATH") - } - if !strings.Contains(err.Error(), `"vi"`) { - t.Errorf("expected error to name default editor vi, got: %v", err) - } -} - -func TestOverlayPathCmdRunE(t *testing.T) { - withTempHome(t) - // Exercise the inline RunE on overlayPathCmd. It calls fmt.Println and - // returns nil — no observable state beyond no-error. - if err := overlayPathCmd.RunE(overlayPathCmd, nil); err != nil { - t.Errorf("overlayPathCmd RunE returned err: %v", err) - } -} diff --git a/cmd/root.go b/cmd/root.go index c7938f0..d13e28c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -40,8 +40,8 @@ var ( var rootCmd = &cobra.Command{ Use: "ctm [session-name]", - Short: "Claude Tmux Manager — seamless session management", - Long: "ctm manages Claude Code sessions inside tmux with pre-flight health checks, persistent modes, and mobile-optimized configuration.", + Short: "Codex Tmux Manager — seamless session management", + Long: "ctm manages codex sessions inside tmux with pre-flight health checks, persistent modes, and mobile-optimized configuration.", Args: cobra.MaximumNArgs(1), // Configure slog before any subcommand runs so diagnostic lines // from pre-flight checks and state loads respect --log-level. diff --git a/cmd/serve.go b/cmd/serve.go deleted file mode 100644 index bdd22f9..0000000 --- a/cmd/serve.go +++ /dev/null @@ -1,92 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "log/slog" - "os/signal" - "syscall" - - "github.com/spf13/cobra" - - "github.com/RandomCodeSpace/ctm/internal/config" - "github.com/RandomCodeSpace/ctm/internal/serve" - "github.com/RandomCodeSpace/ctm/internal/serve/attention" -) - -var servePort int - -func init() { - rootCmd.AddCommand(serveCmd) - serveCmd.Flags().IntVar(&servePort, "port", serve.DefaultPort, - "Loopback port to bind") -} - -var serveCmd = &cobra.Command{ - Use: "serve", - Short: "Run the ctm web UI HTTP daemon on 127.0.0.1:37778", - Long: `ctm serve runs a long-lived HTTP daemon that powers the web UI. - -Normally this is auto-spawned by ctm attach / ctm new / ctm yolo and you -do not need to run it manually. Bound to loopback only. - -Single-instance: if another ctm serve already owns the port, this -command exits silently with status 0. If a non-ctm-serve process owns -the port, it exits non-zero without disturbing the foreign listener.`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx, stop := signal.NotifyContext(context.Background(), - syscall.SIGINT, syscall.SIGTERM) - defer stop() - - // Load config for Serve sub-struct (webhook URL/auth + attention - // thresholds). A failure here is non-fatal — fall back to zero - // values so the daemon still starts with built-in defaults. - cfg, cfgErr := config.Load(config.ConfigPath()) - if cfgErr != nil { - slog.Warn("serve: config load failed, using defaults", "err", cfgErr) - } - - opts := serve.Options{ - Port: servePort, - Version: Version, - WebhookURL: cfg.Serve.WebhookURL, - WebhookAuth: cfg.Serve.WebhookAuth, - AttentionThresholds: attentionThresholdsFrom(cfg.Serve.Attention), - // Thread the loaded config through so /api/doctor can - // surface required_env / required_in_path without re- - // reading from disk inside the handler. - Config: cfg, - } - // Let the config override the dump dir when set; otherwise - // serve.New falls back to /tmp/ctm-statusline. - if cfg.Serve.StatuslineDumpDir != "" { - opts.StatuslineDumpDir = cfg.Serve.StatuslineDumpDir - } - - srv, err := serve.New(opts) - if err != nil { - if errors.Is(err, serve.ErrAlreadyRunning) { - // Another ctm serve owns the port — silent success. - return nil - } - return err - } - - return srv.Run(ctx) - }, -} - -// attentionThresholdsFrom maps the config-layer thresholds (with zero -// fall-back to defaults via Resolved()) into the attention package's -// own type, keeping cmd/ as the integration seam. -func attentionThresholdsFrom(c config.AttentionThresholds) attention.Thresholds { - r := c.Resolved() - return attention.Thresholds{ - ErrorRatePct: r.ErrorRatePct, - ErrorRateWindow: r.ErrorRateWindow, - IdleMinutes: r.IdleMinutes, - QuotaPct: r.QuotaPct, - ContextPct: r.ContextPct, - YoloUncheckedMinutes: r.YoloUncheckedMinutes, - } -} diff --git a/cmd/statusline.go b/cmd/statusline.go deleted file mode 100644 index 2e4f5a0..0000000 --- a/cmd/statusline.go +++ /dev/null @@ -1,329 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "io" - "math" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/spf13/cobra" -) - -func init() { - rootCmd.AddCommand(statuslineCmd) -} - -// statuslineCmd is the target for claude's statusLine.command setting. -// Claude pipes a JSON payload on stdin every time the status redraws; -// we print a three-line display on stdout. Hidden because it's an -// internal hook, not a user-facing command. -// -// Output layout (2 lines): -// -// Line 1: · (project shown as a plain path) -// Line 2: ctx 25% w 40% h 10% (context % + rate limits) -// -// Cache_read (⚡) was dropped because its magnitude is already captured -// in the context-tokens parenthesis and Claude Code's own focus-mode -// overlay duplicates the information. Weekly / 5-hour rate limits -// share line 2 with context because they're all percentages. -var statuslineCmd = &cobra.Command{ - Use: "statusline", - Short: "Internal statusLine renderer — reads JSON on stdin (hidden)", - Hidden: true, - Args: cobra.NoArgs, - RunE: runStatusline, - SilenceUsage: true, - SilenceErrors: true, -} - -// statuslineInput is the subset of claude's statusLine payload we render. -// Pointer fields let us distinguish "field absent" from "field is 0". -type statuslineInput struct { - // SessionID is Claude's session UUID, used as the {uuid} substitution - // in CTM_STATUSLINE_DUMP so ctm serve can ingest per-session quota - // without needing tmux env plumbing. - SessionID string `json:"session_id"` - Model struct { - DisplayName string `json:"display_name"` - ID string `json:"id"` - } `json:"model"` - Workspace struct { - ProjectDir string `json:"project_dir"` - } `json:"workspace"` - Cwd string `json:"cwd"` - ContextWindow struct { - UsedPercentage *float64 `json:"used_percentage"` - TotalInputTokens *int64 `json:"total_input_tokens"` - TotalOutputTokens *int64 `json:"total_output_tokens"` - CurrentUsage struct { - InputTokens *int64 `json:"input_tokens"` - CacheCreationInputTokens *int64 `json:"cache_creation_input_tokens"` - CacheReadInputTokens *int64 `json:"cache_read_input_tokens"` - } `json:"current_usage"` - } `json:"context_window"` - RateLimits struct { - SevenDay struct { - UsedPercentage *float64 `json:"used_percentage"` - } `json:"seven_day"` - FiveHour struct { - UsedPercentage *float64 `json:"used_percentage"` - } `json:"five_hour"` - } `json:"rate_limits"` -} - -func runStatusline(cmd *cobra.Command, args []string) error { - data, err := io.ReadAll(io.LimitReader(os.Stdin, 1<<20)) - if err != nil || len(data) == 0 { - return nil - } - - // Parse early so the `{uuid}` substitution below has a session ID to - // work with. Unmarshal failures fall back silently to the legacy - // no-template behavior so a malformed payload never blocks rendering. - var in statuslineInput - parseErr := json.Unmarshal(data, &in) - - // CTM_STATUSLINE_DUMP supports a `{uuid}` template that ctm serve's - // quota ingest watches for. With the template, each redraw writes - // `/.json` (one file per session). Without the - // template (legacy behavior), it writes a single global file — - // global rate limits still work, per-session context % does not. - if dump := os.Getenv("CTM_STATUSLINE_DUMP"); dump != "" { - path := dump - if strings.Contains(path, "{uuid}") { - uuid := "unknown" - if parseErr == nil && in.SessionID != "" { - uuid = sanitizeSessionID(in.SessionID) - } - path = strings.ReplaceAll(path, "{uuid}", uuid) - } - // Ensure the parent dir exists (the templated default - // `/tmp/ctm-statusline/{uuid}.json` typically requires this). - if dir := filepath.Dir(path); dir != "" && dir != "." { - _ = os.MkdirAll(dir, 0o700) - } - _ = os.WriteFile(path, data, 0o600) - } - - if parseErr != nil { - return nil - } - rendered := renderStatusline(&in) - if rendered != "" { - fmt.Println(rendered) - } - // Diagnostic twin of CTM_STATUSLINE_DUMP: if CTM_STATUSLINE_OUT - // points at a path, write the rendered bytes there. Lets a caller - // cross-reference input-payload dump against the exact output - // string ctm produced on the same redraw. - if out := os.Getenv("CTM_STATUSLINE_OUT"); out != "" { - _ = os.WriteFile(out, []byte(rendered), 0600) - } - return nil -} - -// Okabe-Ito colorblind-safe palette, matching the original bash script. -const ( - cReset = "\x1b[0m" - cCyan = "\x1b[1;38;5;33m" // context bar + project header - cMagenta = "\x1b[1;38;5;220m" // weekly bar - cYellow = "\x1b[1;38;5;208m" // 5-hour bar - cHdrModel = "\x1b[1;97m" -) - -func renderStatusline(in *statuslineInput) string { - var lines []string - if s := buildHeader(in); s != "" { - lines = append(lines, s) - } - // Line 2: context + rate-limit percentages on one line. - mid := joinNonEmpty(buildContextLine(in), buildRateLimitLine(in)) - if mid != "" { - lines = append(lines, mid) - } - return strings.Join(lines, "\n") -} - -// joinNonEmpty joins its arguments with " " between each pair, skipping -// empty strings. Used to glue together optional statusline segments -// without leaving trailing or leading whitespace when a section was -// skipped for a missing payload field. -func joinNonEmpty(parts ...string) string { - var kept []string - for _, p := range parts { - if p != "" { - kept = append(kept, p) - } - } - return strings.Join(kept, " ") -} - -func buildHeader(in *statuslineInput) string { - model := in.Model.DisplayName - if model == "" { - model = in.Model.ID - } - project := in.Workspace.ProjectDir - if project == "" { - project = in.Cwd - } - - var parts []string - if model != "" { - parts = append(parts, formatModel(model)) - } - if project != "" { - parts = append(parts, shortenPath(project)) - } - - switch len(parts) { - case 0: - return "" - case 1: - return cHdrModel + parts[0] + cReset - default: - return cHdrModel + parts[0] + cReset + " " + cCyan + parts[1] + cReset - } -} - -// buildContextLine builds the `ctx %` segment of line 2. -// Returns "" when used_percentage is absent. -func buildContextLine(in *statuslineInput) string { - used := in.ContextWindow.UsedPercentage - if used == nil { - return "" - } - usedPct := int(math.Round(*used)) - return fmt.Sprintf("%sctx %d%%%s", cCyan, usedPct, cReset) -} - -// buildRateLimitLine renders `w %` and `h %` for weekly and -// 5-hour rate-limit usage. Percentages only — Claude Code's payload -// does not expose token counts for these buckets. Rendered on the -// same physical line as the context percentage (joined by renderStatusline). -func buildRateLimitLine(in *statuslineInput) string { - var parts []string - add := func(label rune, color string, used *float64) { - if used == nil { - return - } - usedPct := int(math.Round(*used)) - parts = append(parts, fmt.Sprintf("%s%c %d%%%s", - color, label, usedPct, cReset)) - } - add('w', cMagenta, in.RateLimits.SevenDay.UsedPercentage) - add('h', cYellow, in.RateLimits.FiveHour.UsedPercentage) - return strings.Join(parts, " ") -} - -// contextTokens returns the number of tokens currently consumed in the -// context window, computed per Claude Code's documented formula: -// -// input_tokens + cache_creation_input_tokens + cache_read_input_tokens -// -// (current_usage only, input-side only — this is the same definition -// used to derive context_window.used_percentage; output tokens do not -// count toward context). Any missing field is treated as 0; the sum is -// capped at zero so the caller can branch on >0 to decide whether to -// render. -func contextTokens(in *statuslineInput) int64 { - cu := in.ContextWindow.CurrentUsage - var total int64 - if cu.InputTokens != nil { - total += *cu.InputTokens - } - if cu.CacheCreationInputTokens != nil { - total += *cu.CacheCreationInputTokens - } - if cu.CacheReadInputTokens != nil { - total += *cu.CacheReadInputTokens - } - if total < 0 { - return 0 - } - return total -} - -// formatModel returns the model's display name with redundant words -// stripped. Two simplifications happen: -// -// 1. The "Claude " / "claude-" prefix is dropped (every model in this -// statusline is a Claude model — the word carries no signal). -// 2. The trailing " context" inside a "(… context)" marker is -// collapsed so "(1M context)" / "(200K context)" render as "(1M)" -// / "(200K)". The marker number alone is understood; the word -// just eats width. -// -// Examples: -// -// "Claude Opus 4.7 (1M context)" → "Opus 4.7 (1M)" -// "Claude Sonnet 4.5 (200K)" → "Sonnet 4.5 (200K)" -// "Claude Opus 4.7" → "Opus 4.7" -// "claude-sonnet-4-5-20250929" → "sonnet-4-5-20250929" -// "" → "" -func formatModel(name string) string { - s := strings.TrimSpace(name) - if trimmed, ok := strings.CutPrefix(s, "Claude "); ok { - s = trimmed - } else if trimmed, ok := strings.CutPrefix(s, "claude-"); ok { - s = trimmed - } - s = strings.Replace(s, " context)", ")", 1) - return s -} - -// shortenPath rewrites $HOME prefix as "~". -func shortenPath(p string) string { - home, err := os.UserHomeDir() - if err != nil || home == "" { - return p - } - if p == home { - return "~" - } - if strings.HasPrefix(p, home+string(os.PathSeparator)) { - return "~" + p[len(home):] - } - return p -} - -// fmtTokens formats n with an SI-style suffix so the statusline width -// stays bounded regardless of how chatty a session gets. Rules: -// -// - n < 1 000 → "" e.g. "500" -// - n < 1 000 000 → "k" e.g. "1.2k", "402.6k", "1k" -// - n < 1 000 000 000 → "M" e.g. "1.5M", "402.6M", "1M" -// - n ≥ 1 000 000 000 → "B" e.g. "4.2B" -// -// Negative values (shouldn't happen for token counts but are defended -// against to keep the hook crash-free) are formatted as the raw int. -// Trailing ".0" is stripped so thousands that land on a round number -// render tight ("402k" rather than "402.0k"). -func fmtTokens(n int64) string { - if n < 0 { - return strconv.FormatInt(n, 10) - } - switch { - case n < 1_000: - return strconv.FormatInt(n, 10) - case n < 1_000_000: - return humanSI(float64(n)/1_000.0, "k") - case n < 1_000_000_000: - return humanSI(float64(n)/1_000_000.0, "M") - default: - return humanSI(float64(n)/1_000_000_000.0, "B") - } -} - -// humanSI formats v with one decimal then strips a trailing ".0" so -// round-ish numbers look clean ("5k" not "5.0k", "1.5k" unchanged). -func humanSI(v float64, suffix string) string { - s := strconv.FormatFloat(v, 'f', 1, 64) - s = strings.TrimSuffix(s, ".0") - return s + suffix -} diff --git a/cmd/statusline_test.go b/cmd/statusline_test.go deleted file mode 100644 index 03d6e08..0000000 --- a/cmd/statusline_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package cmd - -import ( - "encoding/json" - "strings" - "testing" -) - -func intPtr(v int64) *int64 { return &v } -func floatPtr(v float64) *float64 { return &v } - -func TestRenderStatuslineFullPayload(t *testing.T) { - in := &statuslineInput{} - in.Model.DisplayName = "Claude Sonnet 4.5 (1M)" - in.Workspace.ProjectDir = "/tmp/ctm-statusline-fake" - in.ContextWindow.UsedPercentage = floatPtr(25) - in.ContextWindow.TotalInputTokens = intPtr(12345) - in.ContextWindow.TotalOutputTokens = intPtr(6789) - in.ContextWindow.CurrentUsage.CacheReadInputTokens = intPtr(500) - in.RateLimits.SevenDay.UsedPercentage = floatPtr(40) - in.RateLimits.FiveHour.UsedPercentage = floatPtr(10) - - out := renderStatusline(in) - lines := strings.Split(out, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %d:\n%s", len(lines), out) - } - if !strings.Contains(lines[0], "Sonnet 4.5 (1M)") { - t.Errorf("header missing full model name: %q", lines[0]) - } - if !strings.Contains(lines[0], "ctm-statusline-fake") { - t.Errorf("header missing project tail: %q", lines[0]) - } - for _, want := range []string{"ctx", "25%", "w", "40%", "h", "10%"} { - if !strings.Contains(lines[1], want) { - t.Errorf("line 2 missing %q: %q", want, lines[1]) - } - } - if strings.Contains(lines[1], "(") || strings.Contains(lines[1], ")") { - t.Errorf("line 2 should not contain token-count parens: %q", lines[1]) - } - for _, banned := range []string{"⚡", "↑", "↓"} { - if strings.Contains(out, banned) { - t.Errorf("dropped glyph %q should not appear:\n%s", banned, out) - } - } - for _, bar := range []string{"━", "─"} { - if strings.Contains(out, bar) { - t.Errorf("unexpected bar rune %q in output:\n%s", bar, out) - } - } -} - -func TestRenderStatuslineSkipsMissingFields(t *testing.T) { - in := &statuslineInput{} - in.Model.ID = "opus-4.7" - // No project, no tokens, no bars. - out := renderStatusline(in) - if out == "" { - t.Fatal("expected at least the model line") - } - if strings.Contains(out, "\n") { - t.Errorf("expected exactly one line, got:\n%s", out) - } - if !strings.Contains(out, "opus") { - t.Errorf("expected opus model, got %q", out) - } -} - -func TestFormatModel(t *testing.T) { - cases := map[string]string{ - "Claude Sonnet 4.5 (1M)": "Sonnet 4.5 (1M)", - "Claude Sonnet 4.5 (200K)": "Sonnet 4.5 (200K)", - "Claude Opus 4.7 (1M context)": "Opus 4.7 (1M)", - "Claude Sonnet 4.5 (1M context)": "Sonnet 4.5 (1M)", - "Claude Sonnet 4.5 (200K context)": "Sonnet 4.5 (200K)", - "Claude Opus 4.7": "Opus 4.7", - "claude-sonnet-4-5-20250929": "sonnet-4-5-20250929", - "claude-opus-4-7": "opus-4-7", - "Opus 4.7 (1M)": "Opus 4.7 (1M)", // no "Claude " prefix — unchanged - " Claude Sonnet 4.5 ": "Sonnet 4.5", - "": "", - } - for in, want := range cases { - if got := formatModel(in); got != want { - t.Errorf("formatModel(%q) = %q, want %q", in, got, want) - } - } -} - -func TestContextTokens_SumsCurrentUsage(t *testing.T) { - in := &statuslineInput{} - in.ContextWindow.CurrentUsage.InputTokens = intPtr(1000) - in.ContextWindow.CurrentUsage.CacheCreationInputTokens = intPtr(2000) - in.ContextWindow.CurrentUsage.CacheReadInputTokens = intPtr(437270) - if got := contextTokens(in); got != 1000+2000+437270 { - t.Errorf("contextTokens = %d, want %d", got, 1000+2000+437270) - } -} - -func TestContextTokens_NilFieldsTreatedAsZero(t *testing.T) { - in := &statuslineInput{} - // Only cache_read is present — input and cache_creation are nil. - in.ContextWindow.CurrentUsage.CacheReadInputTokens = intPtr(500) - if got := contextTokens(in); got != 500 { - t.Errorf("contextTokens with two nils = %d, want 500", got) - } -} - -func TestContextTokens_AllNilReturnsZero(t *testing.T) { - in := &statuslineInput{} - if got := contextTokens(in); got != 0 { - t.Errorf("contextTokens with empty current_usage = %d, want 0", got) - } -} - -func TestFmtTokens(t *testing.T) { - cases := map[int64]string{ - 0: "0", - 1: "1", - 500: "500", - 999: "999", - 1_000: "1k", - 1_234: "1.2k", - 12_345: "12.3k", - 100_000: "100k", - 402_620: "402.6k", - 999_999: "1000k", // just under the M cutoff - 1_000_000: "1M", - 1_500_000: "1.5M", - 402_620_000: "402.6M", - 999_999_999: "1000M", - 1_000_000_000: "1B", - 4_250_000_000: "4.2B", // round-half-to-even: 4.25 → 4.2 - } - for in, want := range cases { - if got := fmtTokens(in); got != want { - t.Errorf("fmtTokens(%d) = %q, want %q", in, got, want) - } - } -} - -func TestFmtTokens_NegativeRendersRaw(t *testing.T) { - if got := fmtTokens(-42); got != "-42" { - t.Errorf("fmtTokens(-42) = %q, want %q", got, "-42") - } -} - -func TestRunStatuslineTolerantJSON(t *testing.T) { - // Unmarshal of an empty object must not panic; output should be empty. - var in statuslineInput - if err := json.Unmarshal([]byte(`{}`), &in); err != nil { - t.Fatal(err) - } - if out := renderStatusline(&in); out != "" { - t.Errorf("expected empty output for empty payload, got %q", out) - } -} diff --git a/cmd/yolo.go b/cmd/yolo.go index 3911ea1..51ceb7f 100644 --- a/cmd/yolo.go +++ b/cmd/yolo.go @@ -21,12 +21,12 @@ import ( // shouldResumeExisting reports whether a stored session should be resumed via // preflight rather than torn down and recreated. A session is resumable iff // its recorded mode matches the requested mode — tmux liveness is irrelevant -// because preflight handles a dead tmux pane by recreating it with -// `claude --resume UUID`, preserving the session's conversation history. +// because preflight handles a dead tmux pane by recreating it with the agent's +// resume command, preserving the session's conversation history. // // Regression guard: the previous implementation also required the tmux session -// to be live, which caused `ctm yolo ` after claude exited to delete the -// stored UUID and spawn a fresh session, losing all chat history. +// to be live, which caused `ctm yolo ` after the agent exited to delete +// the stored UUID and spawn a fresh session, losing all chat history. func shouldResumeExisting(sess *session.Session, requestedMode string) bool { return sess != nil && sess.Mode == requestedMode } @@ -86,25 +86,23 @@ func eventsFor(mode string) (hookEvent, serveEvent string) { return "on_" + mode, "session_attached" } -// fireLaunchEvents fires both the user-defined shell hook and the serve-hub -// event for a launch in the given mode. Failures inside fireHook / -// fireServeEvent are already swallowed; this wrapper just composes them. +// fireLaunchEvents fires the user-defined shell hook for a launch in +// the given mode. Failures inside fireHook are already swallowed. func fireLaunchEvents(store *session.Store, name, workdir, mode string) { - hookEvent, serveEvent := eventsFor(mode) + hookEvent, _ := eventsFor(mode) intent := yoloIntent(store, name, workdir, mode) fireHook(hookEvent, intent) - fireServeEvent(serveEvent, intent) } -// resolveSimpleName returns args[0] when present, else "claude". This is the -// name-resolution rule shared by `ctm yolo!` and `ctm safe`. (`ctm yolo` has a -// richer rule that also handles 2-arg form and prompts for a path, so it -// stays inline.) +// resolveSimpleName returns args[0] when present, else session.DefaultAgent. +// This is the name-resolution rule shared by `ctm yolo!` and `ctm safe`. +// (`ctm yolo` has a richer rule that also handles 2-arg form and prompts for +// a path, so it stays inline.) func resolveSimpleName(args []string) string { if len(args) > 0 { return args[0] } - return "claude" + return session.DefaultAgent } // resolveModeTarget produces the (name, workdir) pair used by `ctm yolo!` and diff --git a/cmd/yolo_helpers_test.go b/cmd/yolo_helpers_test.go index b4846b2..a75e416 100644 --- a/cmd/yolo_helpers_test.go +++ b/cmd/yolo_helpers_test.go @@ -159,8 +159,8 @@ func TestResolveSimpleName(t *testing.T) { args []string want string }{ - {"no args → default 'claude'", nil, "claude"}, - {"empty slice → default 'claude'", []string{}, "claude"}, + {"no args → default 'codex'", nil, "codex"}, + {"empty slice → default 'codex'", []string{}, "codex"}, {"single arg → that name", []string{"my-sess"}, "my-sess"}, {"extra args ignored — first wins", []string{"first", "ignored"}, "first"}, } @@ -235,8 +235,8 @@ func TestResolveModeTargetDefaultName(t *testing.T) { if err != nil { t.Fatalf("resolveModeTarget: %v", err) } - if name != "claude" { - t.Errorf("default name = %q, want claude", name) + if name != "codex" { + t.Errorf("default name = %q, want codex", name) } } diff --git a/cmd/yolo_runners.go b/cmd/yolo_runners.go index 38cbb0d..b38be37 100644 --- a/cmd/yolo_runners.go +++ b/cmd/yolo_runners.go @@ -1,9 +1,9 @@ // Cobra wiring + RunE bodies for the yolo / yolo! / safe commands. // // Split out from yolo.go so the heavy integration paths (preflight, -// createAndAttach, gitCheckpoint, EnsureServeRunning) live in one place -// and can be excluded from the SonarCloud coverage gate. The pure -// helpers each runner composes (decideModeAction, fireLaunchEvents, +// createAndAttach, gitCheckpoint) live in one place and can be +// excluded from the SonarCloud coverage gate. The pure helpers each +// runner composes (decideModeAction, fireLaunchEvents, // resolveModeTarget, tearDownForRecreate, printBanner, etc.) all live // in yolo.go and are unit-tested there. @@ -21,7 +21,6 @@ import ( "github.com/RandomCodeSpace/ctm/internal/config" "github.com/RandomCodeSpace/ctm/internal/output" "github.com/RandomCodeSpace/ctm/internal/prompt" - "github.com/RandomCodeSpace/ctm/internal/serve/proc" "github.com/RandomCodeSpace/ctm/internal/session" "github.com/RandomCodeSpace/ctm/internal/shell" "github.com/RandomCodeSpace/ctm/internal/tmux" @@ -58,7 +57,6 @@ var safeCmd = &cobra.Command{ } func runYolo(cmd *cobra.Command, args []string) error { - proc.EnsureServeRunning(cmd.Context()) out := output.Stdout() cfgPtr, err := ensureSetup() if err != nil { @@ -117,8 +115,8 @@ func runYolo(cmd *cobra.Command, args []string) error { fireLaunchEvents(store, name, workdir, "yolo") // If session exists and mode matches → preflight. preflight handles both - // live tmux (plain reattach) and dead tmux (recreate with --resume UUID), - // so the session's claude history survives `claude` exiting on its own. + // live tmux (plain reattach) and dead tmux (recreate via the agent's resume + // command), so the session's history survives the agent exiting on its own. // Only kill/delete when the mode actually changes (safe → yolo) or when // the user forces fresh state via `ctm yolo!` / `ctm kill`. sess, getErr := store.Get(name) @@ -136,7 +134,6 @@ func runYolo(cmd *cobra.Command, args []string) error { } func runYoloBang(cmd *cobra.Command, args []string) error { - proc.EnsureServeRunning(cmd.Context()) out := output.Stdout() cfgPtr, err := ensureSetup() if err != nil { @@ -168,7 +165,6 @@ func runYoloBang(cmd *cobra.Command, args []string) error { } func runSafe(cmd *cobra.Command, args []string) error { - proc.EnsureServeRunning(cmd.Context()) out := output.Stdout() cfgPtr, err := ensureSetup() if err != nil { @@ -188,8 +184,8 @@ func runSafe(cmd *cobra.Command, args []string) error { fireLaunchEvents(store, name, workdir, "safe") // If session exists and mode matches → preflight. preflight handles both - // live tmux (plain reattach) and dead tmux (recreate with --resume UUID), - // so the session's claude history survives `claude` exiting on its own. + // live tmux (plain reattach) and dead tmux (recreate via the agent's resume + // command), so the session's history survives the agent exiting on its own. // Force-fresh escape hatches: `ctm kill ` / `ctm forget `. sess, getErr := store.Get(name) switch decideModeAction(sess, getErr, "safe") { diff --git a/cmd/yolo_test.go b/cmd/yolo_test.go index 0644ff2..954dba5 100644 --- a/cmd/yolo_test.go +++ b/cmd/yolo_test.go @@ -45,8 +45,8 @@ func TestShouldResumeExisting(t *testing.T) { }, { // Regression: previously this case required tc.HasSession(name) to - // also be true, which caused `ctm yolo ` after claude exited - // (tmux dies with claude) to drop the stored UUID and start fresh. + // also be true, which caused `ctm yolo ` after the agent exited + // (tmux dies with the agent) to drop the stored UUID and start fresh. name: "yolo session whose tmux died still resumes", sess: &session.Session{Mode: "yolo"}, requestedMode: "yolo", diff --git a/go.mod b/go.mod index 96e3809..50b838c 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,7 @@ go 1.25.0 require ( github.com/chzyer/readline v1.5.1 - github.com/fsnotify/fsnotify v1.7.0 - github.com/mattn/go-sqlite3 v1.14.42 github.com/spf13/cobra v1.10.2 - golang.org/x/crypto v0.50.0 ) require ( diff --git a/go.sum b/go.sum index 507ee16..c8e8e76 100644 --- a/go.sum +++ b/go.sum @@ -5,20 +5,14 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= -github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/integration_test.go b/integration_test.go index fe5e3a9..8c54310 100644 --- a/integration_test.go +++ b/integration_test.go @@ -266,34 +266,3 @@ func TestIntegration_SessionNameValidation(t *testing.T) { } } -func TestIntegration_MigrateFromCC(t *testing.T) { - home := t.TempDir() - - // Create fake cc-sessions dir with a file - ccSessionsDir := filepath.Join(home, ".claude", "cc-sessions") - if err := os.MkdirAll(ccSessionsDir, 0755); err != nil { - t.Fatalf("failed to create cc-sessions dir: %v", err) - } - // Write a fake session file: name is the filename (without ext), content is UUID - fakeUUID := "12345678-1234-1234-1234-123456789abc" - if err := os.WriteFile(filepath.Join(ccSessionsDir, "myproject.txt"), []byte(fakeUUID), 0644); err != nil { - t.Fatalf("failed to write fake session file: %v", err) - } - - // Create .bashrc for install - bashrc := filepath.Join(home, ".bashrc") - if err := os.WriteFile(bashrc, []byte("# existing content\n"), 0644); err != nil { - t.Fatalf("failed to create .bashrc: %v", err) - } - - // Run install — it should detect and migrate cc-sessions - out, err := ctmRun(t, home, "install") - if err != nil { - t.Fatalf("ctm install failed: %v\noutput: %s", err, out) - } - - // Verify migration message in output - if !strings.Contains(strings.ToLower(out), "migrat") { - t.Errorf("expected install output to mention migration, got:\n%s", out) - } -} diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..f1e7fda --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,87 @@ +// Package agent defines the multi-agent abstraction. Each supported +// agent CLI implements Agent and is registered via Register() in its +// package's init() — see internal/agent/codex for the current default +// (and only built-in) implementation. +// +// cmd/* consumes agents via For(sess.Agent). The Agent interface is +// the only seam between session-level code (cmd/, internal/session/) +// and per-agent specifics (CLI flags, hook event names, file layouts). +// Future agents (e.g. opencode) plug in without touching the call +// sites. +package agent + +import "time" + +// Agent is the per-CLI behavior contract. Methods return values +// rather than mutating the agent — Agent values are expected to be +// safe to use from multiple goroutines without external locking. +type Agent interface { + // Name returns the canonical short identifier (e.g. "codex"). + // Used as the on-disk Session.Agent value and as the registry + // key. + Name() string + + // Binary returns the executable name searched in PATH (e.g. + // "codex"). Implementations may honor CTM__BIN env + // var to support fake-agent fixture binaries in tests. + Binary() string + + // DefaultSessionName returns the tmux session name when the user + // runs `ctm yolo --agent ` without a positional name arg. + DefaultSessionName() string + + // ProcessName returns the comm/name string used to identify a + // child process under /proc//status for pane-PID discovery. + ProcessName() string + + // BuildCommand returns the shell command that tmux's `new-session` + // runs as the pane process. The result is embedded verbatim in + // `sh -c `, so it must be shell-safe. + BuildCommand(SpawnSpec) string + + // YOLOFlag returns the agent-CLI flag(s) appended to the spawn + // command when SpawnSpec.Mode == "yolo". Empty slice when the + // agent has no bypass flag. + YOLOFlag() []string + + // DiscoverSessionID polls the agent's on-disk state for the + // thread/session identifier created by a fresh spawn that started + // at spawnStart. Implementations pick their own polling cadence and + // budget. Returns ("", false) on timeout, missing state directory, + // or any agent whose AgentSessionID is not separable from + // SpawnSpec.UUID (e.g. claude historically used UUID directly). + // + // The returned ID is stored in Session.AgentSessionID so future + // resume can target the specific thread (codex resume ) instead + // of falling through to picker / --last. + DiscoverSessionID(spawnStart time.Time) (string, bool) +} + +// SpawnSpec is the per-spawn input passed to Agent.BuildCommand. +type SpawnSpec struct { + // UUID is ctm's own session UUID (Session.UUID). For codex it is + // unused at spawn time and only carried for observability — the + // agent-side session/thread ID lives in AgentSessionID below. + UUID string + + // AgentSessionID is the per-agent backend session/thread ID + // (Session.AgentSessionID). For codex it is the thread UUID + // discovered post-spawn; empty on first run. + AgentSessionID string + + // Mode is "safe" or "yolo". Drives YOLOFlag insertion. + Mode string + + // Resume requests resume semantics. False on first spawn; true + // on reattach where conversation continuity is desired. + Resume bool + + // OverlayPath is the resolved overlay file path or empty when + // the user has not initialized an overlay. The agent's + // BuildCommand is responsible for any TOCTOU-safe shell guard. + OverlayPath string + + // EnvExports is a pre-built `export K='V' …` shell prelude + // produced from -env.json. Empty when no env file exists. + EnvExports string +} diff --git a/internal/agent/codex/codex.go b/internal/agent/codex/codex.go new file mode 100644 index 0000000..c5d2c0c --- /dev/null +++ b/internal/agent/codex/codex.go @@ -0,0 +1,57 @@ +// Package codex provides the Agent implementation for OpenAI's `codex` CLI. +// Registration happens in init() — any binary that links this package will +// have "codex" available in the agent registry. +package codex + +import ( + "os" + "time" + + "github.com/RandomCodeSpace/ctm/internal/agent" +) + +func init() { + agent.Register(New()) +} + +// codexAgent is the zero-state Agent value for codex. All behavior is in +// methods; no per-instance state is required. +type codexAgent struct{} + +// New returns the codex Agent. Exposed (not just via init) so test code +// that needs a fresh registry can re-register after agent.Reset(). +func New() agent.Agent { return codexAgent{} } + +func (codexAgent) Name() string { return "codex" } +func (codexAgent) DefaultSessionName() string { return "codex" } +func (codexAgent) ProcessName() string { return "codex" } + +// Binary honors CTM_CODEX_BIN for fake-binary fixture overrides in +// integration tests. Production deployments leave it unset → "codex" is +// resolved through PATH at exec time. +func (codexAgent) Binary() string { + if b := os.Getenv("CTM_CODEX_BIN"); b != "" { + return b + } + return "codex" +} + +// BuildCommand delegates to the package-level BuildCommand (command.go). +// SpawnSpec.OverlayPath is unused — codex reads ~/.codex/config.toml +// natively; ctm does not maintain a parallel overlay layer for it. +func (codexAgent) BuildCommand(s agent.SpawnSpec) string { + return BuildCommand(s.AgentSessionID, s.Mode, s.Resume, s.EnvExports) +} + +// YOLOFlag is the sandbox-bypass flag codex accepts. Returned as a slice +// so cmd/* can consume it without string parsing. +func (codexAgent) YOLOFlag() []string { + return []string{"--sandbox", "danger-full-access"} +} + +// DiscoverSessionID polls ~/.codex/sessions/ for the rollout file +// created by a fresh spawn at spawnStart. See discover.go for the +// polling contract. +func (codexAgent) DiscoverSessionID(spawnStart time.Time) (string, bool) { + return DiscoverSessionID(spawnStart) +} diff --git a/internal/agent/codex/codex_test.go b/internal/agent/codex/codex_test.go new file mode 100644 index 0000000..2159cec --- /dev/null +++ b/internal/agent/codex/codex_test.go @@ -0,0 +1,117 @@ +package codex_test + +import ( + "strings" + "testing" + + "github.com/RandomCodeSpace/ctm/internal/agent" + _ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register via init +) + +func TestCodex_IsRegistered(t *testing.T) { + a, ok := agent.For("codex") + if !ok { + t.Fatal("codex not registered after blank import") + } + if a.Name() != "codex" { + t.Fatalf("Name = %q, want codex", a.Name()) + } + if a.ProcessName() != "codex" { + t.Fatalf("ProcessName = %q, want codex", a.ProcessName()) + } + if a.DefaultSessionName() != "codex" { + t.Fatalf("DefaultSessionName = %q, want codex", a.DefaultSessionName()) + } +} + +func TestCodex_Binary_DefaultsLiteral(t *testing.T) { + t.Setenv("CTM_CODEX_BIN", "") + a, _ := agent.For("codex") + if got := a.Binary(); got != "codex" { + t.Fatalf("Binary = %q, want codex", got) + } +} + +func TestCodex_Binary_HonorsEnvOverride(t *testing.T) { + t.Setenv("CTM_CODEX_BIN", "/fake/path/to/codex") + a, _ := agent.For("codex") + if got := a.Binary(); got != "/fake/path/to/codex" { + t.Fatalf("Binary = %q, want /fake/path/to/codex", got) + } +} + +func TestCodex_BuildCommand_FreshSafe(t *testing.T) { + a, _ := agent.For("codex") + got := a.BuildCommand(agent.SpawnSpec{Mode: "safe"}) + if got != "codex" { + t.Fatalf("BuildCommand = %q, want codex", got) + } +} + +func TestCodex_BuildCommand_FreshYolo(t *testing.T) { + a, _ := agent.For("codex") + got := a.BuildCommand(agent.SpawnSpec{Mode: "yolo"}) + want := "codex --sandbox danger-full-access" + if got != want { + t.Fatalf("BuildCommand = %q, want %q", got, want) + } +} + +func TestCodex_BuildCommand_ResumeKnownID(t *testing.T) { + a, _ := agent.For("codex") + got := a.BuildCommand(agent.SpawnSpec{ + AgentSessionID: "thread-uuid-1", + Mode: "safe", + Resume: true, + }) + if !strings.Contains(got, "codex resume 'thread-uuid-1'") { + t.Fatalf("expected positional resume id, got: %q", got) + } + if !strings.HasSuffix(got, "|| codex") { + t.Fatalf("expected fresh-codex fallback, got: %q", got) + } +} + +func TestCodex_BuildCommand_ResumeUnknownID(t *testing.T) { + a, _ := agent.For("codex") + got := a.BuildCommand(agent.SpawnSpec{ + Mode: "safe", + Resume: true, + }) + want := "codex resume --last || codex" + if got != want { + t.Fatalf("BuildCommand = %q, want %q", got, want) + } +} + +func TestCodex_BuildCommand_EnvExportsPrefix(t *testing.T) { + a, _ := agent.For("codex") + got := a.BuildCommand(agent.SpawnSpec{ + Mode: "safe", + EnvExports: "export FOO='bar'", + }) + want := "export FOO='bar'; codex" + if got != want { + t.Fatalf("BuildCommand = %q, want %q", got, want) + } +} + +func TestCodex_YOLOFlag(t *testing.T) { + a, _ := agent.For("codex") + flags := a.YOLOFlag() + if len(flags) != 2 || flags[0] != "--sandbox" || flags[1] != "danger-full-access" { + t.Fatalf("YOLOFlag = %v, want [--sandbox danger-full-access]", flags) + } +} + +func TestCodex_BuildCommand_ShellQuoteEmbeddedQuote(t *testing.T) { + a, _ := agent.For("codex") + got := a.BuildCommand(agent.SpawnSpec{ + AgentSessionID: `weird'id`, + Mode: "safe", + Resume: true, + }) + if !strings.Contains(got, `codex resume 'weird'\''id'`) { + t.Fatalf("expected escaped single-quote, got: %q", got) + } +} diff --git a/internal/agent/codex/command.go b/internal/agent/codex/command.go new file mode 100644 index 0000000..7ae2bd6 --- /dev/null +++ b/internal/agent/codex/command.go @@ -0,0 +1,54 @@ +package codex + +import ( + "fmt" + "strings" +) + +// shellQuote wraps s in single quotes, escaping any embedded single quotes. +// Safe for paths and IDs passed through /bin/sh -c. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// BuildCommand builds the codex CLI command string. +// +// agentSessionID is the codex thread UUID (Session.AgentSessionID). On +// resume: +// - if non-empty: `codex resume ` (positional) +// - if empty: `codex resume --last` +// +// In both resume branches the command falls back to a fresh `codex` on +// non-zero exit — better to land in a usable state than strand the user +// when the prior session can't be resumed. Crashes, auth failures, or +// Ctrl-C will also trigger the fallback; that's intentional. +// +// envExports, when non-empty, is prepended verbatim as a shell prelude. +// It comes from -env.json via the caller — for codex this lets +// the user set provider keys or PATH bits that need to be visible to +// codex's early startup, too early for codex's own config.toml shell +// policy to take effect. +func BuildCommand(agentSessionID, mode string, resume bool, envExports string) string { + var sandboxFlag string + if mode == "yolo" { + sandboxFlag = " --sandbox danger-full-access" + } + + freshCmd := "codex" + sandboxFlag + + var core string + switch { + case !resume: + core = freshCmd + case agentSessionID != "": + core = fmt.Sprintf("codex resume %s%s || %s", + shellQuote(agentSessionID), sandboxFlag, freshCmd) + default: + core = "codex resume --last" + sandboxFlag + " || " + freshCmd + } + + if envExports != "" { + return envExports + "; " + core + } + return core +} diff --git a/internal/agent/codex/discover.go b/internal/agent/codex/discover.go new file mode 100644 index 0000000..c68623b --- /dev/null +++ b/internal/agent/codex/discover.go @@ -0,0 +1,158 @@ +package codex + +import ( + "errors" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// discoverBudgetVar is the wall-clock ceiling for DiscoverSessionID. +// Codex typically writes its rollout file ~100–500ms after invocation; +// 5s gives plenty of slack on cold caches without making the post-spawn +// goroutine noticeable. var (not const) so tests can shrink it. +var discoverBudgetVar = 5 * time.Second + +// discoverPollVar is the interval between filesystem scans during +// discovery. 100ms keeps latency tight without burning CPU. +var discoverPollVar = 100 * time.Millisecond + +// rolloutFilenameRe captures the codex thread UUID from a rollout +// filename. Codex writes files as +// +// rollout-YYYY-MM-DDTHH-MM-SS-.jsonl +// +// under ~/.codex/sessions/YYYY/MM/DD/. The UUID is the trailing +// segment before .jsonl; we anchor on "rollout-" prefix + .jsonl +// suffix to avoid catching unrelated files dropped into the dir. +var rolloutFilenameRe = regexp.MustCompile(`^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-fA-F-]+)\.jsonl$`) + +// DiscoverSessionID polls ~/.codex/sessions/ for a rollout file whose +// mtime is at or after spawnStart and returns its UUID. Empty + false +// on timeout, missing sessions dir, or any I/O error along the way — +// callers fall back to `codex resume --last` semantics, which is +// strictly less precise but still correct. +// +// Implementation: scan the day-directory matching spawnStart's UTC +// date (plus the previous day if spawnStart is within the first 5 +// minutes of UTC midnight, to absorb clock skew). The file whose +// mtime is closest to spawnStart and >= spawnStart wins. +func DiscoverSessionID(spawnStart time.Time) (string, bool) { + deadline := time.Now().Add(discoverBudgetVar) + for { + id, ok := scanForRollout(spawnStart) + if ok { + return id, true + } + if time.Now().After(deadline) { + return "", false + } + time.Sleep(discoverPollVar) + } +} + +// scanForRollout walks the relevant day directories under +// ~/.codex/sessions/ and returns the UUID of the newest rollout file +// whose mtime is at or after spawnStart. Returns ("", false) when no +// match is found. +func scanForRollout(spawnStart time.Time) (string, bool) { + home, err := os.UserHomeDir() + if err != nil { + return "", false + } + root := filepath.Join(home, ".codex", "sessions") + + candidates := dayDirsFor(root, spawnStart.UTC()) + + var bestID string + var bestMtime time.Time + for _, dir := range candidates { + if id, mtime, ok := newestMatchingRollout(dir, spawnStart); ok { + if mtime.After(bestMtime) { + bestID = id + bestMtime = mtime + } + } + } + if bestID == "" { + return "", false + } + return bestID, true +} + +// dayDirsFor returns the codex-sessions day directories that could +// contain a rollout file for a spawn at t (UTC). Always includes t's +// own day; also includes the next day when t is within 5 minutes of +// UTC midnight so a clock-skewed file from the rollover doesn't get +// missed. +func dayDirsFor(root string, t time.Time) []string { + dirs := []string{dayDir(root, t)} + // Within the first 5 minutes of t's UTC day, codex may have written + // a file dated for the previous day if its clock is skewed; check + // there too. + if t.Hour() == 0 && t.Minute() < 5 { + dirs = append(dirs, dayDir(root, t.Add(-1*time.Hour))) + } + return dirs +} + +func dayDir(root string, t time.Time) string { + return filepath.Join(root, + t.Format("2006"), + t.Format("01"), + t.Format("02")) +} + +// newestMatchingRollout walks dir for rollout-named .jsonl files, +// returning the UUID and mtime of the freshest one whose mtime is at +// or after minMtime. Returns ("", _, false) when dir is missing or +// no match is found. +func newestMatchingRollout(dir string, minMtime time.Time) (string, time.Time, bool) { + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return "", time.Time{}, false + } + return "", time.Time{}, false + } + + // Sub-second precision in spawnStart vs filesystem mtime can race + // when tests run rapid-fire; round minMtime down to the second so + // a file whose mtime equals spawnStart-on-the-tick still matches. + cutoff := minMtime.Truncate(time.Second) + + var bestID string + var bestMtime time.Time + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, ".jsonl") { + continue + } + m := rolloutFilenameRe.FindStringSubmatch(name) + if len(m) != 2 { + continue + } + info, err := e.Info() + if err != nil { + continue + } + mtime := info.ModTime() + if mtime.Before(cutoff) { + continue + } + if mtime.After(bestMtime) { + bestID = m[1] + bestMtime = mtime + } + } + if bestID == "" { + return "", time.Time{}, false + } + return bestID, bestMtime, true +} diff --git a/internal/agent/codex/discover_test.go b/internal/agent/codex/discover_test.go new file mode 100644 index 0000000..72361a9 --- /dev/null +++ b/internal/agent/codex/discover_test.go @@ -0,0 +1,143 @@ +package codex + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// fakeCodexHome wires HOME to a temp dir and pre-creates the +// ~/.codex/sessions/ directory tree so the test can drop +// rollout fixtures in. Returns the day-directory path the test +// writes into. +func fakeCodexHome(t *testing.T, spawn time.Time) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + day := filepath.Join(home, ".codex", "sessions", + spawn.UTC().Format("2006"), + spawn.UTC().Format("01"), + spawn.UTC().Format("02")) + if err := os.MkdirAll(day, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + return day +} + +func writeRollout(t *testing.T, dir, name string, mtime time.Time) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(`{"type":"session_meta"}`), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := os.Chtimes(path, mtime, mtime); err != nil { + t.Fatalf("Chtimes: %v", err) + } +} + +// shrinkBudget swaps the discovery timing constants to test-friendly +// values for the duration of t. Without this, a "no match" case takes +// the full 5-second production budget. +func shrinkBudget(t *testing.T) { + t.Helper() + origBudget := discoverBudgetVar + origPoll := discoverPollVar + discoverBudgetVar = 200 * time.Millisecond + discoverPollVar = 25 * time.Millisecond + t.Cleanup(func() { + discoverBudgetVar = origBudget + discoverPollVar = origPoll + }) +} + +func TestDiscoverSessionID_FindsRolloutAfterSpawn(t *testing.T) { + shrinkBudget(t) + spawn := time.Now() + day := fakeCodexHome(t, spawn) + // File mtime = spawn + 50ms — first poll iteration should pick it up. + writeRollout(t, day, + "rollout-2026-05-14T12-00-00-019dd15a-458b-7341-80de-7d67e796f06f.jsonl", + spawn.Add(50*time.Millisecond)) + + id, ok := DiscoverSessionID(spawn) + if !ok { + t.Fatal("expected discovery to succeed") + } + want := "019dd15a-458b-7341-80de-7d67e796f06f" + if id != want { + t.Fatalf("id = %q, want %q", id, want) + } +} + +func TestDiscoverSessionID_IgnoresPreSpawnFiles(t *testing.T) { + shrinkBudget(t) + spawn := time.Now() + day := fakeCodexHome(t, spawn) + // File written WAY before spawn — must not match. + writeRollout(t, day, + "rollout-2026-05-14T11-00-00-019dd000-0000-0000-0000-000000000000.jsonl", + spawn.Add(-1*time.Hour)) + + if id, ok := DiscoverSessionID(spawn); ok { + t.Fatalf("expected timeout, got id=%q ok=%v", id, ok) + } +} + +func TestDiscoverSessionID_PicksNewestWhenMultiple(t *testing.T) { + shrinkBudget(t) + spawn := time.Now() + day := fakeCodexHome(t, spawn) + writeRollout(t, day, + "rollout-2026-05-14T12-00-00-019dd111-0000-0000-0000-000000000000.jsonl", + spawn.Add(50*time.Millisecond)) + writeRollout(t, day, + "rollout-2026-05-14T12-00-01-019dd222-0000-0000-0000-000000000000.jsonl", + spawn.Add(75*time.Millisecond)) // newest, wins + writeRollout(t, day, + "rollout-2026-05-14T12-00-02-019dd333-0000-0000-0000-000000000000.jsonl", + spawn.Add(60*time.Millisecond)) + + id, ok := DiscoverSessionID(spawn) + if !ok { + t.Fatal("expected discovery") + } + if id != "019dd222-0000-0000-0000-000000000000" { + t.Fatalf("id = %q, want newest (019dd222-…)", id) + } +} + +func TestDiscoverSessionID_TimeoutWhenSessionsDirAbsent(t *testing.T) { + shrinkBudget(t) + home := t.TempDir() + t.Setenv("HOME", home) + // No ~/.codex/sessions tree at all. + if id, ok := DiscoverSessionID(time.Now()); ok { + t.Fatalf("expected timeout on missing dir, got id=%q ok=%v", id, ok) + } +} + +func TestDiscoverSessionID_SkipsNonRolloutFiles(t *testing.T) { + shrinkBudget(t) + spawn := time.Now() + day := fakeCodexHome(t, spawn) + // A file that is fresh but doesn't match the rollout name shape. + writeRollout(t, day, "scratch.jsonl", spawn.Add(50*time.Millisecond)) + // And one with a rollout prefix but missing the UUID suffix shape. + writeRollout(t, day, "rollout-2026-05-14T12-00-00.jsonl", spawn.Add(50*time.Millisecond)) + + if id, ok := DiscoverSessionID(spawn); ok { + t.Fatalf("expected no match, got id=%q ok=%v", id, ok) + } +} + +func TestRolloutFilenameRe_HappyPath(t *testing.T) { + name := "rollout-2026-04-27T23-50-47-019dd15a-458b-7341-80de-7d67e796f06f.jsonl" + m := rolloutFilenameRe.FindStringSubmatch(name) + if len(m) != 2 { + t.Fatalf("regex did not capture: %v", m) + } + if m[1] != "019dd15a-458b-7341-80de-7d67e796f06f" { + t.Fatalf("capture = %q", m[1]) + } +} diff --git a/internal/agent/codex/process.go b/internal/agent/codex/process.go new file mode 100644 index 0000000..75ee6a1 --- /dev/null +++ b/internal/agent/codex/process.go @@ -0,0 +1,110 @@ +package codex + +import ( + "bufio" + "errors" + "fmt" + "os" + "strings" +) + +// IsCodexAlive checks if a PID exists and is not a zombie. +func IsCodexAlive(pid string) (bool, error) { + if pid == "" { + return false, errors.New("pid must not be empty") + } + + var pidInt int + if _, err := fmt.Sscanf(pid, "%d", &pidInt); err != nil || pidInt <= 0 { + return false, fmt.Errorf("invalid pid: %q", pid) + } + + statusPath := fmt.Sprintf("/proc/%s/status", pid) + f, err := os.Open(statusPath) + if err != nil { + return false, nil + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "State:") { + if strings.Contains(line, "Z (zombie)") { + return false, nil + } + return true, nil + } + } + + return true, nil +} + +// FindCodexChild finds a codex process among children of the given PID by +// walking /proc/*/status. Pure Go — no pgrep dependency. +func FindCodexChild(panePID string) string { + if panePID == "" { + return "" + } + + entries, err := os.ReadDir("/proc") + if err != nil { + return "" + } + + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if !isNumeric(name) { + continue + } + + ppid, procName, ok := readProcStatus("/proc/" + name + "/status") + if !ok { + continue + } + if ppid == panePID && procName == "codex" { + return name + } + } + + return "" +} + +func readProcStatus(path string) (ppid, procName string, ok bool) { + f, err := os.Open(path) + if err != nil { + return "", "", false + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + switch { + case strings.HasPrefix(line, "Name:"): + procName = strings.TrimSpace(strings.TrimPrefix(line, "Name:")) + case strings.HasPrefix(line, "PPid:"): + ppid = strings.TrimSpace(strings.TrimPrefix(line, "PPid:")) + } + if ppid != "" && procName != "" { + return ppid, procName, true + } + } + return ppid, procName, ppid != "" && procName != "" +} + +func isNumeric(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} + diff --git a/internal/agent/registry.go b/internal/agent/registry.go new file mode 100644 index 0000000..3a31720 --- /dev/null +++ b/internal/agent/registry.go @@ -0,0 +1,70 @@ +package agent + +import ( + "fmt" + "sort" + "sync" +) + +var ( + mu sync.RWMutex + registry = map[string]Agent{} +) + +// Register adds a to the registry under a.Name(). Panics on duplicate +// or nil. Intended to be called from package init() in each agent's +// package; not safe to call after process startup once cmd/* has +// begun reading sessions. +func Register(a Agent) { + if a == nil { + panic("agent.Register: nil Agent") + } + mu.Lock() + defer mu.Unlock() + name := a.Name() + if name == "" { + panic("agent.Register: empty Name()") + } + if _, exists := registry[name]; exists { + panic(fmt.Sprintf("agent.Register: duplicate %q", name)) + } + registry[name] = a +} + +// For looks up the agent named s. Returns ok=false if absent. +func For(s string) (Agent, bool) { + mu.RLock() + defer mu.RUnlock() + a, ok := registry[s] + return a, ok +} + +// MustFor is For with a panic on miss. Convenience for code that has +// already validated the name (e.g., during Session.Save). +func MustFor(s string) Agent { + a, ok := For(s) + if !ok { + panic(fmt.Sprintf("agent.MustFor: unknown %q", s)) + } + return a +} + +// Registered returns the sorted list of registered agent names. +func Registered() []string { + mu.RLock() + defer mu.RUnlock() + out := make([]string, 0, len(registry)) + for k := range registry { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// Reset clears the registry. Test-only — call from TestMain or in a +// test that wires up its own stubs. +func Reset() { + mu.Lock() + defer mu.Unlock() + registry = map[string]Agent{} +} diff --git a/internal/agent/registry_test.go b/internal/agent/registry_test.go new file mode 100644 index 0000000..a4a8533 --- /dev/null +++ b/internal/agent/registry_test.go @@ -0,0 +1,98 @@ +package agent_test + +import ( + "testing" + "time" + + "github.com/RandomCodeSpace/ctm/internal/agent" +) + +type stubAgent struct{ name string } + +func (s stubAgent) Name() string { return s.name } +func (s stubAgent) Binary() string { return s.name } +func (s stubAgent) DefaultSessionName() string { return s.name } +func (s stubAgent) ProcessName() string { return s.name } +func (s stubAgent) BuildCommand(agent.SpawnSpec) string { return "" } +func (s stubAgent) YOLOFlag() []string { return nil } +func (s stubAgent) DiscoverSessionID(time.Time) (string, bool) { return "", false } + +func TestRegister_ThenFor(t *testing.T) { + agent.Reset() + a := stubAgent{name: "stubA"} + agent.Register(a) + got, ok := agent.For("stubA") + if !ok { + t.Fatal("For(stubA) not found after Register") + } + if got.Name() != "stubA" { + t.Fatalf("name = %q, want stubA", got.Name()) + } +} + +func TestRegister_NilPanics(t *testing.T) { + agent.Reset() + defer func() { + if recover() == nil { + t.Fatal("expected panic on nil Register") + } + }() + agent.Register(nil) +} + +func TestRegister_EmptyNamePanics(t *testing.T) { + agent.Reset() + defer func() { + if recover() == nil { + t.Fatal("expected panic on empty Name()") + } + }() + agent.Register(stubAgent{name: ""}) +} + +func TestRegister_DuplicatePanics(t *testing.T) { + agent.Reset() + agent.Register(stubAgent{name: "dup"}) + defer func() { + if recover() == nil { + t.Fatal("expected panic on duplicate register") + } + }() + agent.Register(stubAgent{name: "dup"}) +} + +func TestRegistered_ReturnsSortedNames(t *testing.T) { + agent.Reset() + agent.Register(stubAgent{name: "z"}) + agent.Register(stubAgent{name: "a"}) + got := agent.Registered() + if len(got) != 2 || got[0] != "a" || got[1] != "z" { + t.Fatalf("Registered = %v, want [a z]", got) + } +} + +func TestFor_UnknownReturnsFalse(t *testing.T) { + agent.Reset() + if _, ok := agent.For("nope"); ok { + t.Fatal("expected For(nope)=false on empty registry") + } +} + +func TestMustFor_UnknownPanics(t *testing.T) { + agent.Reset() + defer func() { + if recover() == nil { + t.Fatal("expected panic on MustFor miss") + } + }() + _ = agent.MustFor("nope") +} + +func TestMustFor_HitReturnsAgent(t *testing.T) { + agent.Reset() + agent.Register(stubAgent{name: "ok"}) + got := agent.MustFor("ok") + if got.Name() != "ok" { + t.Fatalf("MustFor = %q, want ok", got.Name()) + } +} diff --git a/internal/claude/command.go b/internal/claude/command.go deleted file mode 100644 index 50fc9d9..0000000 --- a/internal/claude/command.go +++ /dev/null @@ -1,90 +0,0 @@ -package claude - -import ( - "fmt" - "os" - "strings" -) - -// OverlayPathIfExists returns overlayPath if the file exists and is readable, -// otherwise returns empty string. Used to gate the --settings flag. -func OverlayPathIfExists(overlayPath string) string { - if overlayPath == "" { - return "" - } - if _, err := os.Stat(overlayPath); err != nil { - return "" - } - return overlayPath -} - -// shellQuote wraps s in single quotes, escaping any embedded single quotes. -// This is safe for paths passed through /bin/sh -c. -func shellQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" -} - -// BuildCommand builds the claude CLI command string. -// If resume is true, tries --resume first, falls back to --session-id if the -// session no longer exists. Claude is the pane process — when it exits, the -// tmux session dies. -// -// If envExports is non-empty, it is prepended verbatim as a shell prelude -// — e.g. "export CLAUDE_CODE_NO_FLICKER='1' CTM_STATUSLINE_DUMP='/tmp/...'". -// The caller is responsible for loading ~/.config/ctm/claude-env.json (via -// config.ClaudeEnvExports) and producing this string. This lets ctm set -// real shell env vars that claude reads during early startup, which is -// too early for the overlay's `env` block to take effect. -// -// If overlayPath is non-empty, it is passed via --settings to layer ctm-only -// claude customizations (statusline, theme, etc.) on top of the user's -// global settings without modifying ~/.claude/settings.json. The overlay -// check is a TOCTOU-safe shell guard — `[ -r path ]` re-evaluates at -// exec time and falls back gracefully if the file vanished. -// -// NOTE: The || fallback fires on ANY non-zero exit from `claude --resume`, -// not just "session not found". A crash, auth error, or Ctrl-C will also -// trigger a fresh session with the same UUID. This is intentional — it's -// better to recover into a usable state than to leave the user stranded. -func BuildCommand(uuid, mode string, resume bool, overlayPath, envExports string) string { - var dangerFlag string - if mode == "yolo" { - dangerFlag = " --dangerously-skip-permissions" - } - - // claudeCmd returns a single claude invocation, with --settings 'path' - // only if withOverlay is true. - claudeCmd := func(sessionFlag string, withOverlay bool) string { - base := fmt.Sprintf("claude %s %s%s", sessionFlag, uuid, dangerFlag) - if withOverlay { - base += " --settings " + shellQuote(overlayPath) - } - return base - } - - // buildResume returns the resume-or-fresh fallback chain at one branch. - buildResume := func(withOverlay bool) string { - if !resume { - return claudeCmd("--session-id", withOverlay) - } - return claudeCmd("--resume", withOverlay) + " || " + claudeCmd("--session-id", withOverlay) - } - - // Core command: overlay-gated fallback chain. - var core string - if overlayPath == "" { - core = buildResume(false) - } else { - // TOCTOU-safe: shell re-checks the overlay file at exec time. - // Each branch is a complete invocation so paths with spaces stay as one arg. - core = fmt.Sprintf("if [ -r %s ]; then %s; else %s; fi", - shellQuote(overlayPath), buildResume(true), buildResume(false)) - } - - // Optional env-export prelude: prepended verbatim. Empty when the - // caller had no claude-env.json or it was empty/malformed. - if envExports != "" { - return envExports + "; " + core - } - return core -} diff --git a/internal/claude/command_test.go b/internal/claude/command_test.go deleted file mode 100644 index 5666678..0000000 --- a/internal/claude/command_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package claude - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestBuildCommandSafeNew(t *testing.T) { - cmd := BuildCommand("abc-123", "safe", false, "", "") - expected := "claude --session-id abc-123" - if cmd != expected { - t.Errorf("got: %s, want: %s", cmd, expected) - } -} - -func TestBuildCommandYoloNew(t *testing.T) { - cmd := BuildCommand("abc-123", "yolo", false, "", "") - expected := "claude --session-id abc-123 --dangerously-skip-permissions" - if cmd != expected { - t.Errorf("got: %s, want: %s", cmd, expected) - } -} - -func TestBuildCommandResume(t *testing.T) { - cmd := BuildCommand("abc-123", "safe", true, "", "") - if !strings.Contains(cmd, "--resume abc-123") { - t.Errorf("expected --resume abc-123, got: %s", cmd) - } - if !strings.Contains(cmd, "|| claude --session-id abc-123") { - t.Errorf("expected fallback to --session-id, got: %s", cmd) - } -} - -func TestBuildCommandYoloResume(t *testing.T) { - cmd := BuildCommand("abc-123", "yolo", true, "", "") - if !strings.Contains(cmd, "--resume abc-123 --dangerously-skip-permissions") { - t.Errorf("expected --resume with yolo flag, got: %s", cmd) - } - if !strings.Contains(cmd, "|| claude --session-id abc-123 --dangerously-skip-permissions") { - t.Errorf("expected fallback with yolo flag, got: %s", cmd) - } -} - -func TestBuildCommandWithOverlay(t *testing.T) { - cmd := BuildCommand("abc-123", "safe", false, "/home/u/.config/ctm/claude-overlay.json", "") - if !strings.HasPrefix(cmd, "if [ -r '/home/u/.config/ctm/claude-overlay.json' ]; then ") { - t.Errorf("expected if-test prefix, got: %s", cmd) - } - if !strings.Contains(cmd, "claude --session-id abc-123 --settings '/home/u/.config/ctm/claude-overlay.json'") { - t.Errorf("expected then-branch with settings, got: %s", cmd) - } - if !strings.Contains(cmd, "; else claude --session-id abc-123; fi") { - t.Errorf("expected else-branch without settings, got: %s", cmd) - } -} - -func TestBuildCommandWithOverlayResume(t *testing.T) { - cmd := BuildCommand("abc-123", "yolo", true, "/tmp/overlay.json", "") - if !strings.HasPrefix(cmd, "if [ -r '/tmp/overlay.json' ]; then ") { - t.Errorf("expected if-test prefix, got: %s", cmd) - } - if !strings.Contains(cmd, "claude --resume abc-123 --dangerously-skip-permissions --settings '/tmp/overlay.json' || claude --session-id abc-123 --dangerously-skip-permissions --settings '/tmp/overlay.json'") { - t.Errorf("then-branch missing or wrong: %s", cmd) - } - if !strings.Contains(cmd, "; else claude --resume abc-123 --dangerously-skip-permissions || claude --session-id abc-123 --dangerously-skip-permissions; fi") { - t.Errorf("else-branch missing or wrong: %s", cmd) - } -} - -func TestBuildCommandWithOverlayPathContainsSpaces(t *testing.T) { - cmd := BuildCommand("abc-123", "safe", false, "/home/My User/.config/ctm/claude-overlay.json", "") - if !strings.Contains(cmd, "[ -r '/home/My User/.config/ctm/claude-overlay.json' ]") { - t.Errorf("path with spaces lost quoting in test: %s", cmd) - } - if !strings.Contains(cmd, "--settings '/home/My User/.config/ctm/claude-overlay.json'") { - t.Errorf("path with spaces lost quoting in --settings: %s", cmd) - } -} - -func TestBuildCommandWithEnvExports(t *testing.T) { - cmd := BuildCommand("abc-123", "safe", false, "", `export CLAUDE_CODE_NO_FLICKER='1'`) - // Env exports are prepended verbatim, then "; " then the core. - expectedPrefix := `export CLAUDE_CODE_NO_FLICKER='1'; ` - if !strings.HasPrefix(cmd, expectedPrefix) { - t.Errorf("expected env-exports prefix, got: %s", cmd) - } - if !strings.Contains(cmd, "claude --session-id abc-123") { - t.Errorf("expected claude invocation after env exports, got: %s", cmd) - } -} - -func TestBuildCommandWithEnvExportsAndOverlay(t *testing.T) { - cmd := BuildCommand("abc-123", "yolo", true, "/o.json", `export X='1' Y='2'`) - // Env prefix appears first. - if !strings.HasPrefix(cmd, `export X='1' Y='2'; if [ -r '/o.json' ]; then `) { - t.Errorf("expected exports then overlay-if prefix, got: %s", cmd) - } - if !strings.Contains(cmd, "--settings '/o.json'") { - t.Errorf("expected --settings flag, got: %s", cmd) - } - if !strings.Contains(cmd, "--dangerously-skip-permissions") { - t.Errorf("expected yolo flag, got: %s", cmd) - } -} - -func TestBuildCommandEmptyEnvExportsHasNoPrefix(t *testing.T) { - cmd := BuildCommand("abc-123", "safe", false, "", "") - if strings.HasPrefix(cmd, "export ") { - t.Errorf("empty envExports should produce no prefix, got: %s", cmd) - } -} - -func TestShellQuote(t *testing.T) { - tests := []struct { - in, want string - }{ - {"/simple/path", "'/simple/path'"}, - {"/path with spaces", "'/path with spaces'"}, - {"/path/with'quote", `'/path/with'\''quote'`}, - } - for _, tt := range tests { - got := shellQuote(tt.in) - if got != tt.want { - t.Errorf("shellQuote(%q) = %q, want %q", tt.in, got, tt.want) - } - } -} - -func TestOverlayPathIfExists(t *testing.T) { - dir := t.TempDir() - - t.Run("empty path returns empty", func(t *testing.T) { - if got := OverlayPathIfExists(""); got != "" { - t.Errorf("got %q, want empty", got) - } - }) - - t.Run("missing file returns empty", func(t *testing.T) { - if got := OverlayPathIfExists(filepath.Join(dir, "nope.json")); got != "" { - t.Errorf("got %q, want empty", got) - } - }) - - t.Run("existing file returns path", func(t *testing.T) { - path := filepath.Join(dir, "exists.json") - if err := os.WriteFile(path, []byte("{}"), 0644); err != nil { - t.Fatal(err) - } - if got := OverlayPathIfExists(path); got != path { - t.Errorf("got %q, want %q", got, path) - } - }) -} - diff --git a/internal/claude/process.go b/internal/claude/process.go deleted file mode 100644 index 1c4012e..0000000 --- a/internal/claude/process.go +++ /dev/null @@ -1,190 +0,0 @@ -package claude - -import ( - "bufio" - "bytes" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" -) - -// IsClaudeAlive checks if a PID exists and is not a zombie. -func IsClaudeAlive(pid string) (bool, error) { - if pid == "" { - return false, errors.New("pid must not be empty") - } - - // Validate it's a parseable integer - var pidInt int - if _, err := fmt.Sscanf(pid, "%d", &pidInt); err != nil || pidInt <= 0 { - return false, fmt.Errorf("invalid pid: %q", pid) - } - - statusPath := fmt.Sprintf("/proc/%s/status", pid) - f, err := os.Open(statusPath) - if err != nil { - // File doesn't exist → process not alive - return false, nil - } - defer f.Close() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "State:") { - if strings.Contains(line, "Z (zombie)") { - return false, nil - } - return true, nil - } - } - - return true, nil -} - -// FindClaudeChild finds a claude process among children of the given PID by -// walking /proc/*/status. Pure Go — no pgrep dependency. -// -// For each PID directory under /proc, it reads /proc//status and checks -// the PPid and Name fields. Returns the first PID whose PPid == panePID and -// Name == "claude", or empty string if none found. -func FindClaudeChild(panePID string) string { - if panePID == "" { - return "" - } - - entries, err := os.ReadDir("/proc") - if err != nil { - return "" - } - - for _, e := range entries { - if !e.IsDir() { - continue - } - // Only numeric entries are PID directories. - name := e.Name() - if !isNumeric(name) { - continue - } - - ppid, procName, ok := readProcStatus("/proc/" + name + "/status") - if !ok { - continue - } - if ppid == panePID && procName == "claude" { - return name - } - } - - return "" -} - -// readProcStatus parses /proc//status and returns (PPid, Name, ok). -// ok is false on any read/parse error or if the file doesn't contain both -// fields. Returns as soon as both fields are seen to avoid reading the rest. -func readProcStatus(path string) (ppid, procName string, ok bool) { - f, err := os.Open(path) - if err != nil { - return "", "", false - } - defer f.Close() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Text() - switch { - case strings.HasPrefix(line, "Name:"): - procName = strings.TrimSpace(strings.TrimPrefix(line, "Name:")) - case strings.HasPrefix(line, "PPid:"): - ppid = strings.TrimSpace(strings.TrimPrefix(line, "PPid:")) - } - if ppid != "" && procName != "" { - return ppid, procName, true - } - } - return ppid, procName, ppid != "" && procName != "" -} - -// isNumeric reports whether s is a non-empty string of ASCII digits. -func isNumeric(s string) bool { - if s == "" { - return false - } - for _, c := range s { - if c < '0' || c > '9' { - return false - } - } - return true -} - -// SessionExists checks if a Claude session UUID has data in ~/.claude/. -// It checks ~/.claude/projects/ and ~/.claude/conversations/, returning false -// (not an error) if neither directory exists. -func SessionExists(uuid string) bool { - home, err := os.UserHomeDir() - if err != nil { - return false - } - - claudeDir := filepath.Join(home, ".claude") - - // Check each candidate directory; skip ones that don't exist. - for _, subdir := range []string{"projects", "conversations"} { - dir := filepath.Join(claudeDir, subdir) - if _, err := os.Stat(dir); os.IsNotExist(err) { - continue - } - if sessionExistsWalk(dir, uuid) { - return true - } - } - - return false -} - -// sessionExistsWalk walks dir looking for any *.json file containing uuid -// as a substring. Pure Go — no grep dependency. -// -// Uses filepath.WalkDir with an early-exit sentinel on first match. -// Conservatively returns false on any I/O error (the caller's fallback path -// handles missing session data safely). -func sessionExistsWalk(dir, uuid string) bool { - needle := []byte(uuid) - found := false - errFound := errors.New("match-found") - - err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - // Skip unreadable entries; don't fail the whole walk. - if d != nil && d.IsDir() { - return fs.SkipDir - } - return nil - } - if d.IsDir() { - return nil - } - if !strings.HasSuffix(d.Name(), ".json") { - return nil - } - // File is small (claude sessions), read whole thing. - data, err := os.ReadFile(path) - if err != nil { - return nil - } - if bytes.Contains(data, needle) { - found = true - return errFound // early exit - } - return nil - }) - - // Only errFound is expected; other errors fall through to "not found". - _ = err - return found -} diff --git a/internal/claude/process_test.go b/internal/claude/process_test.go deleted file mode 100644 index cede4c6..0000000 --- a/internal/claude/process_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package claude - -import ( - "os" - "path/filepath" - "testing" -) - -func TestIsClaudePID_InvalidPID(t *testing.T) { - alive, err := IsClaudeAlive("") - if err == nil { - t.Error("expected error for empty PID, got nil") - } - if alive { - t.Error("expected not alive for empty PID") - } -} - -func TestIsClaudePID_NonExistent(t *testing.T) { - alive, err := IsClaudeAlive("9999999") - if err != nil { - t.Errorf("expected no error for non-existent PID, got: %v", err) - } - if alive { - t.Error("expected not alive for non-existent PID") - } -} - -func TestFindClaudeChild_NoPID(t *testing.T) { - pid := FindClaudeChild("") - if pid != "" { - t.Errorf("expected empty string for empty panePID, got: %s", pid) - } -} - -func TestSessionExistsWalkFindsUUIDInJSON(t *testing.T) { - dir := t.TempDir() - uuid := "f6489cb4-010f-4c96-940b-188014f746f0" - path := filepath.Join(dir, "session.json") - content := `{"sessionId":"` + uuid + `","messages":[]}` - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatal(err) - } - if !sessionExistsWalk(dir, uuid) { - t.Errorf("expected to find uuid %s in %s", uuid, dir) - } -} - -func TestSessionExistsWalkMissing(t *testing.T) { - dir := t.TempDir() - if sessionExistsWalk(dir, "not-in-any-file") { - t.Error("expected false for missing uuid") - } -} - -func TestSessionExistsWalkIgnoresNonJSON(t *testing.T) { - dir := t.TempDir() - uuid := "abc123" - if err := os.WriteFile(filepath.Join(dir, "data.txt"), []byte(uuid), 0644); err != nil { - t.Fatal(err) - } - if sessionExistsWalk(dir, uuid) { - t.Error("expected false — UUID only in .txt file") - } -} - -func TestSessionExistsWalkRecursive(t *testing.T) { - dir := t.TempDir() - uuid := "nested-uuid-here" - nested := filepath.Join(dir, "projects", "myproject", "sessions") - if err := os.MkdirAll(nested, 0755); err != nil { - t.Fatal(err) - } - path := filepath.Join(nested, "deep.json") - if err := os.WriteFile(path, []byte(`{"id":"`+uuid+`"}`), 0644); err != nil { - t.Fatal(err) - } - if !sessionExistsWalk(dir, uuid) { - t.Errorf("expected to find uuid nested inside %s", dir) - } -} - -func TestSessionExistsWalkNonexistentDir(t *testing.T) { - if sessionExistsWalk("/nonexistent/path/xyz", "anything") { - t.Error("expected false for nonexistent dir") - } -} - -func TestIsNumeric(t *testing.T) { - tests := []struct { - in string - want bool - }{ - {"123", true}, - {"0", true}, - {"9999999", true}, - {"", false}, - {"12a", false}, - {"abc", false}, - {"-1", false}, - {"1.5", false}, - {" 1", false}, - } - for _, tt := range tests { - if got := isNumeric(tt.in); got != tt.want { - t.Errorf("isNumeric(%q) = %v, want %v", tt.in, got, tt.want) - } - } -} - -func TestReadProcStatusParsesSelf(t *testing.T) { - path := "/proc/self/status" - if _, err := os.Stat(path); err != nil { - t.Skip("/proc not available, skipping") - } - ppid, name, ok := readProcStatus(path) - if !ok { - t.Fatal("expected ok=true for /proc/self/status") - } - if name == "" { - t.Error("expected non-empty Name") - } - if !isNumeric(ppid) { - t.Errorf("expected numeric PPid, got %q", ppid) - } -} - -func TestReadProcStatusMissing(t *testing.T) { - _, _, ok := readProcStatus("/nonexistent/proc/entry") - if ok { - t.Error("expected ok=false for missing path") - } -} diff --git a/internal/claude/tui.go b/internal/claude/tui.go deleted file mode 100644 index c24ebc9..0000000 --- a/internal/claude/tui.go +++ /dev/null @@ -1,53 +0,0 @@ -package claude - -import ( - "encoding/json" - "os" - "path/filepath" -) - -// SettingsJSONPath returns the canonical path to Claude Code's user-level -// settings file (~/.claude/settings.json). Unlike ~/.claude.json (which -// stores per-user runtime state), this file holds the documented -// user-overridable configuration. -// -// ctm reads this file (e.g., for the "effortLevel" key consumed by the -// statusline renderer) but never writes to it. UI-shaping defaults -// (tui, viewMode) live in claude-overlay.json — see buildSampleOverlay -// in cmd/overlay.go — so ctm stays additive, not invasive, on the -// user's per-user Claude Code config. -func SettingsJSONPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".claude", "settings.json"), nil -} - -// ReadEffortLevel returns the current effort level stored under the -// "effortLevel" key in path (typically ~/.claude/settings.json). Values -// in the wild: "min" / "low" / "medium" / "high" / "xhigh" / "max". -// -// Returns "" when the file is missing, unreadable, unparseable, the key -// is absent, or the value is not a string — intentionally silent so -// callers (the statusline renderer) can display nothing on missing data -// without a noisy error path. -func ReadEffortLevel(path string) string { - data, err := os.ReadFile(path) - if err != nil { - return "" - } - var obj map[string]json.RawMessage - if err := json.Unmarshal(data, &obj); err != nil { - return "" - } - raw, ok := obj["effortLevel"] - if !ok { - return "" - } - var s string - if err := json.Unmarshal(raw, &s); err != nil { - return "" - } - return s -} diff --git a/internal/claude/tui_test.go b/internal/claude/tui_test.go deleted file mode 100644 index 2b82e53..0000000 --- a/internal/claude/tui_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package claude - -import ( - "os" - "path/filepath" - "testing" -) - -func TestReadEffortLevel(t *testing.T) { - tests := []struct { - name string - initial string // "" = don't create file - want string - }{ - {"missing file", "", ""}, - {"key absent", `{"theme":"dark"}`, ""}, - {"string value", `{"effortLevel":"xhigh","theme":"dark"}`, "xhigh"}, - {"empty string", `{"effortLevel":""}`, ""}, - {"wrong type (int)", `{"effortLevel":3}`, ""}, - {"wrong type (null)", `{"effortLevel":null}`, ""}, - {"malformed json", `{not json`, ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "settings.json") - if tt.initial != "" { - if err := os.WriteFile(path, []byte(tt.initial), 0644); err != nil { - t.Fatalf("setup: %v", err) - } - } - if got := ReadEffortLevel(path); got != tt.want { - t.Errorf("ReadEffortLevel = %q, want %q", got, tt.want) - } - }) - } -} diff --git a/internal/config/claude_env.go b/internal/config/claude_env.go deleted file mode 100644 index 779ffa9..0000000 --- a/internal/config/claude_env.go +++ /dev/null @@ -1,117 +0,0 @@ -package config - -import ( - "encoding/json" - "fmt" - "os" - "regexp" - "sort" - "strings" -) - -// ClaudeEnvFile is the on-disk shape of ~/.config/ctm/claude-env.json. -// -// ctm exports these vars into the shell that spawns claude. This is the -// canonical home for env vars claude reads too early in startup for the -// overlay's `env` block to apply (e.g., CLAUDE_CODE_NO_FLICKER). Most -// env vars belong in claude-overlay.json's `env` block instead. -// -// The Comment field is JSON-convention `_comment` and is preserved on -// round-trip but otherwise unused. -type ClaudeEnvFile struct { - Comment string `json:"_comment,omitempty"` - Env map[string]string `json:"env"` -} - -// envKeyRe matches POSIX-portable shell variable names: leading letter or -// underscore, then letters/digits/underscores. Anything else (spaces, -// dashes, shell metachars) would be a corrupt file and we refuse to -// emit shell from it. -var envKeyRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - -// LoadClaudeEnv reads claude-env.json from path. Returns the zero -// ClaudeEnvFile and a nil error when the file does not exist — a missing -// file is treated as "no extra env vars to export," same graceful -// degradation the previous env.sh sourcing had. -// -// Returns a non-nil error on: -// - a present but malformed JSON file (loud failure: corrupt config -// should not silently drop env vars) -// - any env key that is not a portable shell variable name -// -// On error the returned ClaudeEnvFile is the zero value. -func LoadClaudeEnv(path string) (ClaudeEnvFile, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return ClaudeEnvFile{}, nil - } - return ClaudeEnvFile{}, fmt.Errorf("reading %s: %w", path, err) - } - var f ClaudeEnvFile - if err := json.Unmarshal(data, &f); err != nil { - return ClaudeEnvFile{}, fmt.Errorf("parsing %s: %w", path, err) - } - for k := range f.Env { - if !envKeyRe.MatchString(k) { - return ClaudeEnvFile{}, fmt.Errorf("%s: invalid env key %q (must match [A-Za-z_][A-Za-z0-9_]*)", path, k) - } - } - return f, nil -} - -// ShellExports returns a single-line `export KEY1='val1' KEY2='val2'` -// clause suitable for prepending to the claude launch command. Returns -// "" when there are no entries so callers can branch on emptiness -// without producing a stray "export " in the shell. -// -// Keys are emitted alphabetically so the launch command is deterministic -// across runs (handy for diffing process trees and tests). -// -// Values are wrapped in single quotes with embedded single quotes -// escaped as '\'' — the standard POSIX-safe quoting that handles -// arbitrary characters including spaces, $, `, !, and the literal -// `{uuid}` placeholder consumed downstream by `ctm statusline`. -func (f ClaudeEnvFile) ShellExports() string { - if len(f.Env) == 0 { - return "" - } - keys := make([]string, 0, len(f.Env)) - for k := range f.Env { - keys = append(keys, k) - } - sort.Strings(keys) - var b strings.Builder - b.WriteString("export ") - for i, k := range keys { - if i > 0 { - b.WriteByte(' ') - } - b.WriteString(k) - b.WriteByte('=') - b.WriteString(shellQuoteValue(f.Env[k])) - } - return b.String() -} - -// shellQuoteValue wraps s in single quotes, escaping embedded single -// quotes as '\''. Mirrors internal/claude.shellQuote — kept local here -// to avoid an import cycle (config is leaf, claude depends on config). -func shellQuoteValue(s string) string { - return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" -} - -// ClaudeEnvExports is the one-call convenience: load the file at -// ClaudeEnvPath() and return its ShellExports. Errors are swallowed — -// returning empty string preserves the legacy env.sh behavior of -// "missing file is fine" while letting the caller stay one-liner clean. -// -// Callers that want loud failure on a malformed file should call -// LoadClaudeEnv directly. -func ClaudeEnvExports() string { - f, err := LoadClaudeEnv(ClaudeEnvPath()) - if err != nil { - return "" - } - return f.ShellExports() -} diff --git a/internal/config/claude_env_test.go b/internal/config/claude_env_test.go deleted file mode 100644 index bd280c0..0000000 --- a/internal/config/claude_env_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadClaudeEnv_MissingFileIsZeroValueNoError(t *testing.T) { - dir := t.TempDir() - got, err := LoadClaudeEnv(filepath.Join(dir, "absent.json")) - if err != nil { - t.Fatalf("missing file should be silent: %v", err) - } - if len(got.Env) != 0 { - t.Errorf("missing file should yield empty Env, got %v", got.Env) - } -} - -func TestLoadClaudeEnv_RoundTrip(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "claude-env.json") - body := `{ - "_comment": "ctm-managed env vars", - "env": { - "CLAUDE_CODE_NO_FLICKER": "1", - "CTM_STATUSLINE_DUMP": "/tmp/{uuid}.json" - } -}` - if err := os.WriteFile(path, []byte(body), 0600); err != nil { - t.Fatal(err) - } - got, err := LoadClaudeEnv(path) - if err != nil { - t.Fatalf("load: %v", err) - } - if got.Env["CLAUDE_CODE_NO_FLICKER"] != "1" { - t.Errorf("missing/wrong CLAUDE_CODE_NO_FLICKER: %v", got.Env) - } - if got.Env["CTM_STATUSLINE_DUMP"] != "/tmp/{uuid}.json" { - t.Errorf("{uuid} placeholder not preserved verbatim: %v", got.Env) - } - if got.Comment == "" { - t.Errorf("_comment lost on round-trip") - } -} - -func TestLoadClaudeEnv_MalformedJSONReturnsError(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "bad.json") - if err := os.WriteFile(path, []byte("{not json"), 0600); err != nil { - t.Fatal(err) - } - if _, err := LoadClaudeEnv(path); err == nil { - t.Errorf("expected error on malformed JSON") - } -} - -func TestLoadClaudeEnv_RejectsInvalidKey(t *testing.T) { - dir := t.TempDir() - for _, bad := range []string{ - `{"env":{"BAD KEY":"1"}}`, - `{"env":{"BAD-KEY":"1"}}`, - `{"env":{"1LEADING_DIGIT":"1"}}`, - `{"env":{"with;semicolon":"1"}}`, - } { - path := filepath.Join(dir, "bad.json") - if err := os.WriteFile(path, []byte(bad), 0600); err != nil { - t.Fatal(err) - } - if _, err := LoadClaudeEnv(path); err == nil { - t.Errorf("expected error for %s", bad) - } - } -} - -func TestShellExports_EmptyIsEmptyString(t *testing.T) { - if got := (ClaudeEnvFile{}).ShellExports(); got != "" { - t.Errorf("empty file should produce no exports, got %q", got) - } -} - -func TestShellExports_DeterministicOrder(t *testing.T) { - f := ClaudeEnvFile{Env: map[string]string{ - "ZED": "z", - "ALF": "a", - "MID": "m", - }} - got := f.ShellExports() - want := `export ALF='a' MID='m' ZED='z'` - if got != want { - t.Errorf("got %q\nwant %q", got, want) - } -} - -func TestShellExports_QuotesAwkwardValues(t *testing.T) { - f := ClaudeEnvFile{Env: map[string]string{ - "WITH_SPACE": "hello world", - "WITH_QUOTE": `it's`, - "WITH_DOLLAR": "$HOME/$PATH", - "WITH_UUID": "/tmp/{uuid}.json", - }} - got := f.ShellExports() - wants := []string{ - `WITH_SPACE='hello world'`, - `WITH_QUOTE='it'\''s'`, - `WITH_DOLLAR='$HOME/$PATH'`, - `WITH_UUID='/tmp/{uuid}.json'`, - } - for _, w := range wants { - if !contains(got, w) { - t.Errorf("expected %q in output, got: %s", w, got) - } - } -} - -func contains(haystack, needle string) bool { - return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0 -} - -func indexOf(haystack, needle string) int { - for i := 0; i+len(needle) <= len(haystack); i++ { - if haystack[i:i+len(needle)] == needle { - return i - } - } - return -1 -} diff --git a/internal/config/config.go b/internal/config/config.go index d2234cf..6bfb3d0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,12 @@ import ( // SchemaVersion is the current on-disk schema version of config.json. // Bump this and append a Step to the Plan returned by MigrationPlan() // whenever the shape of Config changes in a non-additive way. -const SchemaVersion = 1 +// +// v2: rewrite legacy required_in_path entries of "claude" to "codex" +// after the claude CLI was removed. Existing configs that customized +// the list to add extra binaries keep those additions; only the exact +// literal "claude" is swapped. +const SchemaVersion = 2 // Config holds user preferences for ctm. type Config struct { @@ -49,35 +54,6 @@ type Config struct { // HookTimeoutSec is the per-hook wall-clock ceiling. Zero → default // (5 s). Set a very large number to effectively disable the cap. HookTimeoutSec int `json:"hook_timeout_seconds"` - - // Serve holds configuration for `ctm serve` (the local UI daemon). - // Missing from old configs is fine: strict decoding tolerates absent - // keys, and zero-valued fields fall back to built-in defaults via - // their accessor helpers. - Serve ServeConfig `json:"serve"` -} - -// ServeConfig holds knobs for the `ctm serve` daemon. All fields are -// optional; zero values resolve to defaults via ServeConfig accessors. -type ServeConfig struct { - Port int `json:"port"` - BearerToken string `json:"bearer_token"` - WebhookURL string `json:"webhook_url"` - WebhookAuth string `json:"webhook_auth"` - StatuslineDumpDir string `json:"statusline_dump_dir"` - Attention AttentionThresholds `json:"attention"` -} - -// AttentionThresholds controls when `ctm serve` flags a session as -// needing user attention. Zero fields resolve to defaults via -// Resolved(). -type AttentionThresholds struct { - ErrorRatePct int `json:"error_rate_pct"` - ErrorRateWindow int `json:"error_rate_window"` - IdleMinutes int `json:"idle_minutes"` - QuotaPct int `json:"quota_pct"` - ContextPct int `json:"context_pct"` - YoloUncheckedMinutes int `json:"yolo_unchecked_minutes"` } // Default values for log rotation knobs. Exposed as constants so callers @@ -93,54 +69,6 @@ const ( // would create a cycle via cmd). const DefaultHookTimeoutSec = 5 -// Defaults for `ctm serve` attention thresholds and port. Exposed so -// callers can reference them without re-hardcoding the numeric literal. -const ( - DefaultServePort = 37778 - DefaultAttentionErrorRatePct = 20 - DefaultAttentionErrorRateWindow = 20 - DefaultAttentionIdleMinutes = 5 - DefaultAttentionQuotaPct = 85 - DefaultAttentionContextPct = 90 - DefaultAttentionYoloUncheckedMinutes = 30 -) - -// Port returns the listen port for `ctm serve`, substituting -// DefaultServePort when the configured value is non-positive so old -// configs that predate the serve block still bind the canonical port. -func (s ServeConfig) ResolvedPort() int { - if s.Port <= 0 { - return DefaultServePort - } - return s.Port -} - -// Resolved returns a copy of t with any zero-valued field replaced by -// its built-in default. Mirrors LogPolicy()/HookTimeout(): old configs -// loaded without the attention block get sane thresholds without a -// schema bump. -func (t AttentionThresholds) Resolved() AttentionThresholds { - if t.ErrorRatePct <= 0 { - t.ErrorRatePct = DefaultAttentionErrorRatePct - } - if t.ErrorRateWindow <= 0 { - t.ErrorRateWindow = DefaultAttentionErrorRateWindow - } - if t.IdleMinutes <= 0 { - t.IdleMinutes = DefaultAttentionIdleMinutes - } - if t.QuotaPct <= 0 { - t.QuotaPct = DefaultAttentionQuotaPct - } - if t.ContextPct <= 0 { - t.ContextPct = DefaultAttentionContextPct - } - if t.YoloUncheckedMinutes <= 0 { - t.YoloUncheckedMinutes = DefaultAttentionYoloUncheckedMinutes - } - return t -} - // HookTimeout returns the per-hook wall-clock ceiling. A zero // HookTimeoutSec resolves to DefaultHookTimeoutSec so old configs (and // users who never set it) still get a sensible default. @@ -156,7 +84,7 @@ func Default() Config { return Config{ SchemaVersion: SchemaVersion, RequiredEnv: []string{"PATH", "HOME"}, - RequiredInPath: []string{"claude", "node", "go", "bun"}, + RequiredInPath: []string{"codex", "node", "go", "bun"}, ScrollbackLines: 50000, HealthCheckTimeoutSec: 5, GitCheckpointBeforeYolo: true, @@ -164,17 +92,6 @@ func Default() Config { LogMaxSizeMB: DefaultLogMaxSizeMB, LogMaxAgeDays: DefaultLogMaxAgeDays, LogMaxFiles: DefaultLogMaxFiles, - Serve: ServeConfig{ - Port: DefaultServePort, - Attention: AttentionThresholds{ - ErrorRatePct: DefaultAttentionErrorRatePct, - ErrorRateWindow: DefaultAttentionErrorRateWindow, - IdleMinutes: DefaultAttentionIdleMinutes, - QuotaPct: DefaultAttentionQuotaPct, - ContextPct: DefaultAttentionContextPct, - YoloUncheckedMinutes: DefaultAttentionYoloUncheckedMinutes, - }, - }, } } @@ -226,35 +143,6 @@ func TmuxConfPath() string { return filepath.Join(Dir(), "tmux.conf") } -// AllowedOriginsPath returns the path to the extra-origins file. -// One origin per line; blank lines and `#`-prefixed lines are ignored. -// Read by the serve mutation allowlist so mobile / reverse-proxy -// hostnames persist across reloads without needing env vars. -func AllowedOriginsPath() string { - return filepath.Join(Dir(), "allowed_origins") -} - -// ClaudeOverlayPath returns the path to the optional claude settings overlay. -// When this file exists, ctm passes --settings to every claude -// invocation, layering it on top of the user's existing claude settings -// without modifying ~/.claude/settings.json. -func ClaudeOverlayPath() string { - return filepath.Join(Dir(), "claude-overlay.json") -} - -// ClaudeEnvPath returns the path to the ctm-managed JSON env file. -// When this file exists, ctm reads it at every claude-launching command -// and exports its `env` block into the shell BEFORE exec'ing claude. -// Use this for env vars claude reads too early in startup for the -// overlay's `env` block to apply (e.g., CLAUDE_CODE_NO_FLICKER). -// -// Replaces the older bash-script env.sh — JSON keeps the format -// consistent with the rest of ctm's user config (config.json, -// sessions.json, claude-overlay.json, .bestpractices.json). -func ClaudeEnvPath() string { - return filepath.Join(Dir(), "claude-env.json") -} - // Load reads Config from path. If the file does not exist it creates it // with defaults and returns those defaults. // @@ -281,9 +169,11 @@ func Load(path string) (Config, error) { return cfg, nil } -// MigrationPlan returns the migrate.Plan for config.json. Steps is empty at -// v1 because the initial migration only stamps the version — no content -// changes are required to turn an unversioned config.json into v1. +// MigrationPlan returns the migrate.Plan for config.json. +// +// - v0 → v1: stamp only (initial schema_version introduction). +// - v1 → v2: rewrite literal "claude" entries in required_in_path +// to "codex" after the claude CLI was removed. // // Callers run the returned Plan before Load so the typed unmarshal sees // a file already at the current SchemaVersion. @@ -291,8 +181,52 @@ func MigrationPlan() migrate.Plan { return migrate.Plan{ Name: "config.json", CurrentVersion: SchemaVersion, - Steps: []migrate.Step{nil}, // v0 → v1: stamp only + Steps: []migrate.Step{ + nil, // v0 → v1: stamp only + rewriteRequiredPathClaude, // v1 → v2: claude → codex in required_in_path + }, + } +} + +// rewriteRequiredPathClaude rewrites the literal string "claude" in +// obj["required_in_path"] to "codex". Idempotent — a slice that +// already contains "codex" instead of "claude" passes through +// unchanged. Non-string entries (defensive) are passed through verbatim. +// +// Custom additions ("claude", "node", "go", "bun", "rust") are +// preserved aside from the targeted swap, so users who tailored the +// list keep their tailoring. +func rewriteRequiredPathClaude(obj map[string]json.RawMessage) error { + raw, ok := obj["required_in_path"] + if !ok || len(raw) == 0 { + return nil + } + var list []json.RawMessage + if err := json.Unmarshal(raw, &list); err != nil { + // Malformed entry — leave it alone; jsonstrict will surface + // the error on the typed Load that follows. + return nil + } + changed := false + for i, entry := range list { + var s string + if err := json.Unmarshal(entry, &s); err != nil { + continue + } + if s == "claude" { + list[i] = json.RawMessage(`"codex"`) + changed = true + } + } + if !changed { + return nil + } + out, err := json.Marshal(list) + if err != nil { + return err } + obj["required_in_path"] = out + return nil } // write marshals cfg to path, creating parent directories as needed. It diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5d0ec04..cbf8150 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,11 +2,15 @@ package config import ( "encoding/json" + "fmt" "os" "path/filepath" + "reflect" "strings" "testing" "time" + + "github.com/RandomCodeSpace/ctm/internal/migrate" ) func TestDefaultConfig(t *testing.T) { @@ -16,7 +20,7 @@ func TestDefaultConfig(t *testing.T) { t.Errorf("RequiredEnv = %v, want [PATH HOME]", cfg.RequiredEnv) } - expectedInPath := []string{"claude", "node", "go", "bun"} + expectedInPath := []string{"codex", "node", "go", "bun"} if len(cfg.RequiredInPath) != len(expectedInPath) { t.Errorf("RequiredInPath len = %d, want %d", len(cfg.RequiredInPath), len(expectedInPath)) } else { @@ -142,8 +146,9 @@ func TestLoadCreatesFileWithSchemaVersion(t *testing.T) { if err := json.Unmarshal(data, &raw); err != nil { t.Fatalf("unmarshal: %v", err) } - if v := string(raw["schema_version"]); v != "1" { - t.Errorf("freshly-created config.json schema_version = %s, want 1", v) + wantSV := fmt.Sprintf("%d", SchemaVersion) + if v := string(raw["schema_version"]); v != wantSV { + t.Errorf("freshly-created config.json schema_version = %s, want %s", v, wantSV) } } @@ -162,8 +167,9 @@ func TestWriteForceStampsSchemaVersion(t *testing.T) { data, _ := os.ReadFile(path) var raw map[string]json.RawMessage _ = json.Unmarshal(data, &raw) - if v := string(raw["schema_version"]); v != "1" { - t.Errorf("write stamped schema_version = %s, want 1", v) + wantSV := fmt.Sprintf("%d", SchemaVersion) + if v := string(raw["schema_version"]); v != wantSV { + t.Errorf("write stamped schema_version = %s, want %s", v, wantSV) } } @@ -223,140 +229,6 @@ func TestLogPolicy_ExplicitValuesRespected(t *testing.T) { } } -func TestDefault_PopulatesServeDefaults(t *testing.T) { - cfg := Default() - if cfg.Serve.Port != DefaultServePort { - t.Errorf("Serve.Port = %d, want %d", cfg.Serve.Port, DefaultServePort) - } - want := AttentionThresholds{ - ErrorRatePct: DefaultAttentionErrorRatePct, - ErrorRateWindow: DefaultAttentionErrorRateWindow, - IdleMinutes: DefaultAttentionIdleMinutes, - QuotaPct: DefaultAttentionQuotaPct, - ContextPct: DefaultAttentionContextPct, - YoloUncheckedMinutes: DefaultAttentionYoloUncheckedMinutes, - } - if cfg.Serve.Attention != want { - t.Errorf("Serve.Attention = %+v, want %+v", cfg.Serve.Attention, want) - } - // Optional/user-supplied fields must stay empty by default. - if cfg.Serve.BearerToken != "" || cfg.Serve.WebhookURL != "" || - cfg.Serve.WebhookAuth != "" || cfg.Serve.StatuslineDumpDir != "" { - t.Errorf("optional serve fields populated unexpectedly: %+v", cfg.Serve) - } -} - -func TestAttentionThresholds_ResolvedFillsZeros(t *testing.T) { - got := AttentionThresholds{}.Resolved() - want := AttentionThresholds{ - ErrorRatePct: DefaultAttentionErrorRatePct, - ErrorRateWindow: DefaultAttentionErrorRateWindow, - IdleMinutes: DefaultAttentionIdleMinutes, - QuotaPct: DefaultAttentionQuotaPct, - ContextPct: DefaultAttentionContextPct, - YoloUncheckedMinutes: DefaultAttentionYoloUncheckedMinutes, - } - if got != want { - t.Errorf("Resolved() on zero = %+v, want %+v", got, want) - } -} - -func TestAttentionThresholds_ResolvedPreservesExplicit(t *testing.T) { - in := AttentionThresholds{ - ErrorRatePct: 1, - ErrorRateWindow: 2, - IdleMinutes: 3, - QuotaPct: 4, - ContextPct: 5, - YoloUncheckedMinutes: 6, - } - if got := in.Resolved(); got != in { - t.Errorf("Resolved() altered explicit values: %+v -> %+v", in, got) - } -} - -func TestServeConfig_ResolvedPortFallsBackToDefault(t *testing.T) { - if got := (ServeConfig{}).ResolvedPort(); got != DefaultServePort { - t.Errorf("ResolvedPort() zero = %d, want %d", got, DefaultServePort) - } - if got := (ServeConfig{Port: 9999}).ResolvedPort(); got != 9999 { - t.Errorf("ResolvedPort() explicit = %d, want 9999", got) - } -} - -func TestLoad_ConfigWithoutServeKeyStillParses(t *testing.T) { - // Existing v0.1 config.json files predate the serve block. Strict - // decoding tolerates missing subkeys (only unknown ones fail), so - // these must load clean with zero-valued Serve + no backup written. - dir := t.TempDir() - path := filepath.Join(dir, "config.json") - - legacy := `{ - "schema_version": 1, - "scrollback_lines": 4242, - "default_mode": "safe" -}` - if err := os.WriteFile(path, []byte(legacy), 0600); err != nil { - t.Fatalf("setup: %v", err) - } - - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load on pre-serve config: %v", err) - } - if cfg.ScrollbackLines != 4242 { - t.Errorf("ScrollbackLines = %d, want 4242", cfg.ScrollbackLines) - } - if cfg.Serve != (ServeConfig{}) { - t.Errorf("Serve should be zero-valued on legacy load, got %+v", cfg.Serve) - } - // Accessors must still return sensible defaults on a zero Serve. - if got := cfg.Serve.ResolvedPort(); got != DefaultServePort { - t.Errorf("ResolvedPort() on legacy = %d, want %d", got, DefaultServePort) - } - - // No unknown-keys backup should have been produced — the key was - // absent, not unknown. - entries, _ := os.ReadDir(dir) - for _, e := range entries { - if strings.Contains(e.Name(), ".bak.unknowns.") { - t.Errorf("unexpected unknowns backup created: %s", e.Name()) - } - } -} - -func TestLoad_ServeBlockRoundTrips(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.json") - - orig := Default() - orig.Serve = ServeConfig{ - Port: 40000, - BearerToken: "tok-abc", - WebhookURL: "https://example.invalid/hook", - WebhookAuth: "Bearer xyz", - StatuslineDumpDir: "/var/tmp/ctm-sl", - Attention: AttentionThresholds{ - ErrorRatePct: 33, - ErrorRateWindow: 50, - IdleMinutes: 7, - QuotaPct: 90, - ContextPct: 95, - YoloUncheckedMinutes: 15, - }, - } - - if err := write(path, orig); err != nil { - t.Fatalf("write: %v", err) - } - loaded, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if loaded.Serve != orig.Serve { - t.Errorf("Serve round-trip mismatch:\n got = %+v\n want = %+v", loaded.Serve, orig.Serve) - } -} func TestLoad_StripsUnknownKeysAndBacksUp(t *testing.T) { dir := t.TempDir() @@ -404,3 +276,94 @@ func TestLoad_StripsUnknownKeysAndBacksUp(t *testing.T) { t.Errorf("stripped key survived rewrite: %s", after) } } + +// TestMigration_V1ToV2_RewritesClaudeInRequiredPath verifies the +// v1→v2 step: a legacy `required_in_path` array containing "claude" +// gets that entry rewritten to "codex" while preserving every other +// element exactly. +func TestMigration_V1ToV2_RewritesClaudeInRequiredPath(t *testing.T) { + in := map[string]json.RawMessage{ + "required_in_path": json.RawMessage(`["claude","node","go","bun"]`), + } + if err := rewriteRequiredPathClaude(in); err != nil { + t.Fatalf("rewriteRequiredPathClaude: %v", err) + } + var got []string + if err := json.Unmarshal(in["required_in_path"], &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + want := []string{"codex", "node", "go", "bun"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("required_in_path = %v, want %v", got, want) + } +} + +// TestMigration_V1ToV2_PreservesUserAdditions verifies that customized +// entries beyond the original default set survive the migration: only +// the literal "claude" is rewritten. +func TestMigration_V1ToV2_PreservesUserAdditions(t *testing.T) { + in := map[string]json.RawMessage{ + "required_in_path": json.RawMessage(`["bun","claude","rust","node"]`), + } + if err := rewriteRequiredPathClaude(in); err != nil { + t.Fatalf("rewriteRequiredPathClaude: %v", err) + } + var got []string + _ = json.Unmarshal(in["required_in_path"], &got) + want := []string{"bun", "codex", "rust", "node"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("required_in_path = %v, want %v", got, want) + } +} + +// TestMigration_V1ToV2_Idempotent: running the step twice or against +// an already-migrated config is a no-op. +func TestMigration_V1ToV2_Idempotent(t *testing.T) { + in := map[string]json.RawMessage{ + "required_in_path": json.RawMessage(`["codex","node","go","bun"]`), + } + if err := rewriteRequiredPathClaude(in); err != nil { + t.Fatalf("rewriteRequiredPathClaude: %v", err) + } + var got []string + _ = json.Unmarshal(in["required_in_path"], &got) + want := []string{"codex", "node", "go", "bun"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("required_in_path = %v, want %v (no-op expected)", got, want) + } +} + +// TestMigration_V1ToV2_FullPlanRewritesLegacyConfig verifies end-to- +// end behavior via the migrate runner: a v1 config.json with +// `claude` in required_in_path lands at v2 with `codex`, no backup. +func TestMigration_V1ToV2_FullPlanRewritesLegacyConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + legacy := `{ + "schema_version": 1, + "scrollback_lines": 7777, + "default_mode": "safe", + "required_in_path": ["claude","node","go","bun"] +}` + if err := os.WriteFile(path, []byte(legacy), 0600); err != nil { + t.Fatalf("setup: %v", err) + } + if _, err := migrate.Run(path, MigrationPlan()); err != nil { + t.Fatalf("migrate.Run: %v", err) + } + raw, _ := os.ReadFile(path) + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatalf("unmarshal: %v", err) + } + var sv int + _ = json.Unmarshal(obj["schema_version"], &sv) + if sv != 2 { + t.Fatalf("schema_version = %d, want 2", sv) + } + var got []string + _ = json.Unmarshal(obj["required_in_path"], &got) + if !reflect.DeepEqual(got, []string{"codex", "node", "go", "bun"}) { + t.Fatalf("required_in_path post-migrate = %v", got) + } +} diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go index b92b2b1..cf2257e 100644 --- a/internal/doctor/doctor.go +++ b/internal/doctor/doctor.go @@ -89,7 +89,7 @@ func Run(ctx context.Context, cfg config.Config) []Check { } func checkDependencies(_ context.Context, _ config.Config) []Check { - deps := []string{"tmux", "claude", "git"} + deps := []string{"tmux", "codex", "git"} out := make([]Check, 0, len(deps)) for _, dep := range deps { c := Check{Name: "dep:" + dep} diff --git a/internal/fsutil/atomic.go b/internal/fsutil/atomic.go index 4583fa7..cb218af 100644 --- a/internal/fsutil/atomic.go +++ b/internal/fsutil/atomic.go @@ -1,7 +1,7 @@ // Package fsutil holds tiny, dependency-free filesystem helpers shared // across the codebase. Currently exports AtomicWriteFile, which replaces // the three near-identical copies that lived in -// internal/claude/jsonpatch.go, internal/migrate/migrate.go, and +// internal/migrate/migrate.go and // internal/jsonstrict/jsonstrict.go. package fsutil diff --git a/internal/health/claude_check.go b/internal/health/agent_check.go similarity index 52% rename from internal/health/claude_check.go rename to internal/health/agent_check.go index 9eb9d45..5dcbcae 100644 --- a/internal/health/claude_check.go +++ b/internal/health/agent_check.go @@ -4,80 +4,58 @@ import ( "fmt" "os" - "github.com/RandomCodeSpace/ctm/internal/claude" + "github.com/RandomCodeSpace/ctm/internal/procscan" "github.com/RandomCodeSpace/ctm/internal/tmux" ) -// CheckClaudeProcess checks that a claude child process is alive under the tmux pane. -func CheckClaudeProcess(tc *tmux.Client, sessionName string) CheckResult { +// CheckAgentProcess verifies that a child process named procName is +// alive under the tmux pane for sessionName. procName is the value the +// agent reports via Agent.ProcessName() — "codex" for the codex agent. +// The check is agent-agnostic; this package has no dependency on any +// specific agent implementation. +func CheckAgentProcess(tc *tmux.Client, sessionName, procName string) CheckResult { + checkName := procName + "_process" panePID, err := tc.PanePID(sessionName) if err != nil { return CheckResult{ - Name: "claude_process", + Name: checkName, Status: StatusFail, Message: fmt.Sprintf("could not get pane PID for session %q: %v", sessionName, err), Fix: "ensure the tmux session exists and has an active pane", } } - claudePID := claude.FindClaudeChild(panePID) - if claudePID == "" { + pid := procscan.FindChild(panePID, procName) + if pid == "" { return CheckResult{ - Name: "claude_process", + Name: checkName, Status: StatusFail, - Message: fmt.Sprintf("no claude child process found under pane PID %s", panePID), - Fix: "start claude in the tmux session", + Message: fmt.Sprintf("no %s child process found under pane PID %s", procName, panePID), + Fix: fmt.Sprintf("start %s in the tmux session", procName), } } - alive, err := claude.IsClaudeAlive(claudePID) + alive, err := procscan.IsAlive(pid) if err != nil { return CheckResult{ - Name: "claude_process", + Name: checkName, Status: StatusFail, - Message: fmt.Sprintf("error checking claude PID %s: %v", claudePID, err), + Message: fmt.Sprintf("error checking %s PID %s: %v", procName, pid, err), } } if !alive { return CheckResult{ - Name: "claude_process", + Name: checkName, Status: StatusFail, - Message: fmt.Sprintf("claude process PID %s is not alive", claudePID), - Fix: "restart claude in the tmux session", + Message: fmt.Sprintf("%s process PID %s is not alive", procName, pid), + Fix: fmt.Sprintf("restart %s in the tmux session", procName), } } return CheckResult{ - Name: "claude_process", + Name: checkName, Status: StatusPass, - Message: fmt.Sprintf("claude process PID %s is alive", claudePID), - } -} - -// CheckClaudeSession verifies that a Claude session UUID exists in ~/.claude/. -func CheckClaudeSession(uuid string) CheckResult { - if uuid == "" { - return CheckResult{ - Name: "claude_session", - Status: StatusFail, - Message: "session UUID is empty", - Fix: "provide a valid Claude session UUID", - } - } - - if claude.SessionExists(uuid) { - return CheckResult{ - Name: "claude_session", - Status: StatusPass, - Message: fmt.Sprintf("claude session %q exists", uuid), - } - } - - return CheckResult{ - Name: "claude_session", - Status: StatusFail, - Message: fmt.Sprintf("claude session %q not found in ~/.claude/", uuid), - Fix: "verify the session UUID or create a new claude session", + Message: fmt.Sprintf("%s process PID %s is alive", procName, pid), } } diff --git a/internal/health/claude_check_test.go b/internal/health/claude_check_test.go deleted file mode 100644 index 1961658..0000000 --- a/internal/health/claude_check_test.go +++ /dev/null @@ -1,262 +0,0 @@ -package health - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/RandomCodeSpace/ctm/internal/tmux" -) - -// --- CheckWorkdir ----------------------------------------------------------- - -func TestCheckWorkdir(t *testing.T) { - tmpDir := t.TempDir() - - existingDir := filepath.Join(tmpDir, "exists") - if err := os.Mkdir(existingDir, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - - existingFile := filepath.Join(tmpDir, "afile") - if err := os.WriteFile(existingFile, []byte("x"), 0o644); err != nil { - t.Fatalf("writefile: %v", err) - } - - missing := filepath.Join(tmpDir, "does-not-exist") - - tests := []struct { - name string - workdir string - wantStatus string - wantMsgSubstr string - wantFixSubstr string - }{ - { - name: "empty workdir fails (migrated session)", - workdir: "", - wantStatus: StatusFail, - wantMsgSubstr: "working directory not set", - wantFixSubstr: "ctm forget", - }, - { - name: "non-existent path fails with does-not-exist", - workdir: missing, - wantStatus: StatusFail, - wantMsgSubstr: "does not exist", - wantFixSubstr: "mkdir -p", - }, - { - name: "regular file fails with not-a-directory", - workdir: existingFile, - wantStatus: StatusFail, - wantMsgSubstr: "not a directory", - wantFixSubstr: "remove the file", - }, - { - name: "existing directory passes", - workdir: existingDir, - wantStatus: StatusPass, - wantMsgSubstr: "exists and is a directory", - }, - } - - // Permission-denied path: os.Stat returns a non-IsNotExist error when - // the parent dir is unreadable. Skipped if running as root (chmod 0 - // is bypassed for root) or if the FS doesn't enforce mode bits - // (e.g. some CI overlays). - if os.Geteuid() != 0 { - lockedParent := filepath.Join(tmpDir, "locked") - if err := os.Mkdir(lockedParent, 0o755); err != nil { - t.Fatalf("mkdir locked: %v", err) - } - target := filepath.Join(lockedParent, "child") - if err := os.Mkdir(target, 0o755); err != nil { - t.Fatalf("mkdir target: %v", err) - } - if err := os.Chmod(lockedParent, 0o000); err != nil { - t.Fatalf("chmod locked: %v", err) - } - t.Cleanup(func() { _ = os.Chmod(lockedParent, 0o755) }) - - // Verify the FS actually enforces the perm before relying on it. - if _, err := os.Stat(target); err != nil && !os.IsNotExist(err) { - t.Run("stat error other than not-exist fails", func(t *testing.T) { - got := CheckWorkdir(target) - if got.Name != "workdir" { - t.Errorf("Name = %q, want %q", got.Name, "workdir") - } - if got.Status != StatusFail { - t.Errorf("Status = %q, want %q", got.Status, StatusFail) - } - if !strings.Contains(got.Message, "error checking workdir") { - t.Errorf("Message = %q, want substring %q", got.Message, "error checking workdir") - } - }) - } - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := CheckWorkdir(tc.workdir) - if got.Name != "workdir" { - t.Errorf("Name = %q, want %q", got.Name, "workdir") - } - if got.Status != tc.wantStatus { - t.Errorf("Status = %q, want %q (msg=%q)", got.Status, tc.wantStatus, got.Message) - } - if tc.wantMsgSubstr != "" && !strings.Contains(got.Message, tc.wantMsgSubstr) { - t.Errorf("Message = %q, want substring %q", got.Message, tc.wantMsgSubstr) - } - if tc.wantFixSubstr != "" && !strings.Contains(got.Fix, tc.wantFixSubstr) { - t.Errorf("Fix = %q, want substring %q", got.Fix, tc.wantFixSubstr) - } - }) - } -} - -// --- CheckClaudeSession ----------------------------------------------------- - -// writeSessionFile creates a Claude-style session JSON file containing the uuid -// as a substring (matching the SessionExists walk pattern: *.json files). -func writeSessionFile(t *testing.T, dir, uuid string) { - t.Helper() - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir %s: %v", dir, err) - } - path := filepath.Join(dir, uuid+".json") - body := []byte(`{"sessionId":"` + uuid + `"}`) - if err := os.WriteFile(path, body, 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } -} - -func TestCheckClaudeSession(t *testing.T) { - tests := []struct { - name string - uuid string - setup func(t *testing.T, home string) - wantStatus string - wantMsgSubstr string - wantFixSubstr string - }{ - { - name: "empty uuid fails", - uuid: "", - setup: nil, - wantStatus: StatusFail, - wantMsgSubstr: "session UUID is empty", - wantFixSubstr: "valid Claude session UUID", - }, - { - name: "session present in projects dir passes", - uuid: "11111111-aaaa-bbbb-cccc-222222222222", - setup: func(t *testing.T, home string) { - writeSessionFile(t, filepath.Join(home, ".claude", "projects", "myproj"), - "11111111-aaaa-bbbb-cccc-222222222222") - }, - wantStatus: StatusPass, - wantMsgSubstr: "exists", - }, - { - name: "session present in conversations dir passes", - uuid: "33333333-dddd-eeee-ffff-444444444444", - setup: func(t *testing.T, home string) { - writeSessionFile(t, filepath.Join(home, ".claude", "conversations"), - "33333333-dddd-eeee-ffff-444444444444") - }, - wantStatus: StatusPass, - wantMsgSubstr: "exists", - }, - { - name: "uuid not found anywhere fails", - uuid: "55555555-no-such-uuid-66666666", - setup: nil, - wantStatus: StatusFail, - wantMsgSubstr: "not found", - wantFixSubstr: "verify the session UUID", - }, - { - name: "claude dir exists but uuid absent fails", - uuid: "77777777-absent-aaaaaaaa", - setup: func(t *testing.T, home string) { - // create empty projects + conversations dirs so the walk runs - // but finds no matching file - if err := os.MkdirAll(filepath.Join(home, ".claude", "projects"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(home, ".claude", "conversations"), 0o755); err != nil { - t.Fatal(err) - } - // add a decoy file with a different uuid - writeSessionFile(t, filepath.Join(home, ".claude", "projects"), - "decoy-uuid-not-the-one-we-want") - }, - wantStatus: StatusFail, - wantMsgSubstr: "not found", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - if tc.setup != nil { - tc.setup(t, home) - } - - got := CheckClaudeSession(tc.uuid) - if got.Name != "claude_session" { - t.Errorf("Name = %q, want %q", got.Name, "claude_session") - } - if got.Status != tc.wantStatus { - t.Errorf("Status = %q, want %q (msg=%q)", got.Status, tc.wantStatus, got.Message) - } - if tc.wantMsgSubstr != "" && !strings.Contains(got.Message, tc.wantMsgSubstr) { - t.Errorf("Message = %q, want substring %q", got.Message, tc.wantMsgSubstr) - } - if tc.wantFixSubstr != "" && !strings.Contains(got.Fix, tc.wantFixSubstr) { - t.Errorf("Fix = %q, want substring %q", got.Fix, tc.wantFixSubstr) - } - }) - } -} - -// --- CheckClaudeProcess ----------------------------------------------------- - -// CheckClaudeProcess shells out to tmux directly via exec.Command (the public -// PanePID method does not honour the Client's injectable execCommand hook), -// so we cannot easily stub it. We can still cover the first failure branch -// deterministically: a session name that does not exist makes -// `tmux list-panes -t ` exit non-zero, returning an error from -// PanePID. If tmux is not installed at all, exec.LookPath also fails and -// we hit the same branch. Either way the early-failure path is exercised. -// -// The remaining branches (no claude child / IsClaudeAlive error / not alive -// / alive) require a live tmux pane with a controlled child process tree -// and are out of scope for a unit test — they belong in an integration -// test with a real tmux server. -func TestCheckClaudeProcess_PanePIDError(t *testing.T) { - tc := tmux.NewClient("") - // A session name that almost certainly does not exist on any host. - bogus := "ctm-health-test-bogus-session-zzz-9f8e7d6c" - - got := CheckClaudeProcess(tc, bogus) - - if got.Name != "claude_process" { - t.Errorf("Name = %q, want %q", got.Name, "claude_process") - } - if got.Status != StatusFail { - t.Errorf("Status = %q, want %q (msg=%q)", got.Status, StatusFail, got.Message) - } - if !strings.Contains(got.Message, "could not get pane PID") { - t.Errorf("Message = %q, want substring %q", got.Message, "could not get pane PID") - } - if !strings.Contains(got.Message, bogus) { - t.Errorf("Message = %q, want session name %q included", got.Message, bogus) - } - if !strings.Contains(got.Fix, "tmux session exists") { - t.Errorf("Fix = %q, want substring %q", got.Fix, "tmux session exists") - } -} diff --git a/internal/output/format_test.go b/internal/output/format_test.go index ce3c349..80a077a 100644 --- a/internal/output/format_test.go +++ b/internal/output/format_test.go @@ -22,12 +22,12 @@ func TestSuccess(t *testing.T) { func TestError(t *testing.T) { var buf bytes.Buffer p := NewPrinter(&buf) - p.Error("cannot attach", "claude process is dead", "run 'ctm forget myproject'") + p.Error("cannot attach", "codex process is dead", "run 'ctm forget myproject'") got := buf.String() if !containsText(got, "cannot attach") { t.Errorf("expected 'cannot attach' in output, got: %q", got) } - if !containsText(got, "claude process is dead") { + if !containsText(got, "codex process is dead") { t.Errorf("expected reason in output, got: %q", got) } if !containsText(got, "ctm forget myproject") { @@ -48,9 +48,9 @@ func TestWarn(t *testing.T) { func TestInfo(t *testing.T) { var buf bytes.Buffer p := NewPrinter(&buf) - p.Info("Attaching to: %s", "claude") + p.Info("Attaching to: %s", "codex") got := buf.String() - if !containsText(got, "Attaching to: claude") { + if !containsText(got, "Attaching to: codex") { t.Errorf("expected info text, got: %q", got) } } diff --git a/internal/procscan/procscan.go b/internal/procscan/procscan.go new file mode 100644 index 0000000..8875473 --- /dev/null +++ b/internal/procscan/procscan.go @@ -0,0 +1,116 @@ +// Package procscan exposes the small set of /proc helpers ctm uses to +// detect whether the per-pane agent process is alive. It deliberately +// has no agent-specific knowledge — callers supply the comm name they +// expect (e.g. "codex", "claude" historically) and procscan walks +// /proc/*/status with no further coupling. +package procscan + +import ( + "bufio" + "errors" + "fmt" + "os" + "strings" +) + +// IsAlive reports whether the PID names a non-zombie process. Returns +// (false, nil) when the PID directory is absent — a "definitely dead" +// signal that callers handle the same as a zombie. Returns an error +// only for malformed PIDs (empty or non-numeric). +func IsAlive(pid string) (bool, error) { + if pid == "" { + return false, errors.New("pid must not be empty") + } + var pidInt int + if _, err := fmt.Sscanf(pid, "%d", &pidInt); err != nil || pidInt <= 0 { + return false, fmt.Errorf("invalid pid: %q", pid) + } + + statusPath := fmt.Sprintf("/proc/%s/status", pid) + f, err := os.Open(statusPath) + if err != nil { + return false, nil + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "State:") { + if strings.Contains(line, "Z (zombie)") { + return false, nil + } + return true, nil + } + } + return true, nil +} + +// FindChild walks /proc/*/status looking for the first PID whose +// PPid matches parentPID and whose Name matches procName. Returns an +// empty string if no match is found. +// +// Pure Go — no pgrep dependency, no shell invocations. +func FindChild(parentPID, procName string) string { + if parentPID == "" || procName == "" { + return "" + } + + entries, err := os.ReadDir("/proc") + if err != nil { + return "" + } + + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if !isNumeric(name) { + continue + } + ppid, comm, ok := readStatus("/proc/" + name + "/status") + if !ok { + continue + } + if ppid == parentPID && comm == procName { + return name + } + } + return "" +} + +func readStatus(path string) (ppid, comm string, ok bool) { + f, err := os.Open(path) + if err != nil { + return "", "", false + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + switch { + case strings.HasPrefix(line, "Name:"): + comm = strings.TrimSpace(strings.TrimPrefix(line, "Name:")) + case strings.HasPrefix(line, "PPid:"): + ppid = strings.TrimSpace(strings.TrimPrefix(line, "PPid:")) + } + if ppid != "" && comm != "" { + return ppid, comm, true + } + } + return ppid, comm, ppid != "" && comm != "" +} + +func isNumeric(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if c < '0' || c > '9' { + return false + } + } + return true +} diff --git a/internal/serve/api/auth.go b/internal/serve/api/auth.go deleted file mode 100644 index 27943f7..0000000 --- a/internal/serve/api/auth.go +++ /dev/null @@ -1,254 +0,0 @@ -package api - -// V27 — /api/auth/{status,signup,login,logout}. Spec: -// docs/superpowers/specs/2026-04-22-V27-single-user-auth-design.md - -import ( - "encoding/json" - "errors" - "io/fs" - "log/slog" - "math" - "net" - "net/http" - "regexp" - "strconv" - "strings" - - "github.com/RandomCodeSpace/ctm/internal/serve/auth" -) - -var authUsernameRe = regexp.MustCompile(`^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$`) - -const authUsernameMax = 254 - -const authPasswordMin = 8 -const authBodyMax = 1024 - -const ( - authMsgPostOnly = "POST only" - authLogLoginReject = "auth login reject" -) - -// HTTP header / value constants shared across handlers in this package. -const ( - headerContentType = "Content-Type" - headerCacheControl = "Cache-Control" - contentTypeJSON = "application/json" - cacheControlNoStore = "no-store" -) - -type authCredsBody struct { - Username string `json:"username"` - Password string `json:"password"` -} - -// AuthStatus returns GET /api/auth/status. Never 401s — reports -// registered+authenticated as booleans so the UI can route. -func AuthStatus(store *auth.Store) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - w.Header().Set("Allow", http.MethodGet) - writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET only") - return - } - resp := struct { - Registered bool `json:"registered"` - Authenticated bool `json:"authenticated"` - }{ - Registered: auth.Exists(), - } - if tok := bearerToken(r); tok != "" { - if _, ok := store.Lookup(tok); ok { - resp.Authenticated = true - } - } - w.Header().Set(headerContentType, contentTypeJSON) - _ = json.NewEncoder(w).Encode(resp) - } -} - -// AuthSignup returns POST /api/auth/signup. Refuses if user.json -// already exists; otherwise creates it, issues a session token, -// returns 201. -func AuthSignup(store *auth.Store) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.Header().Set("Allow", http.MethodPost) - writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", authMsgPostOnly) - return - } - if auth.Exists() { - slog.Info("auth signup reject", "reason", "already_registered") - writeInputErr(w, http.StatusConflict, "already_registered", - "this instance already has a user; log in instead") - return - } - var body authCredsBody - if err := decodeAuthBody(r, w, &body); err != nil { - return - } - if err := validateCreds(body); err != nil { - slog.Info("auth signup reject", "reason", err.Error()) - writeInputErr(w, http.StatusBadRequest, "invalid_body", err.Error()) - return - } - enc, err := auth.Hash(body.Password) - if err != nil { - slog.Error("auth signup hash error", "err", err.Error()) - writeInputErr(w, http.StatusInternalServerError, "hash_failed", err.Error()) - return - } - if err := auth.Save(auth.User{Username: body.Username, Password: enc}); err != nil { - slog.Error("auth signup save error", "err", err.Error()) - writeInputErr(w, http.StatusInternalServerError, "save_failed", err.Error()) - return - } - tok, err := store.Create(body.Username) - if err != nil { - writeInputErr(w, http.StatusInternalServerError, "session_failed", err.Error()) - return - } - slog.Info("auth signup ok", "username", body.Username) - w.Header().Set(headerContentType, contentTypeJSON) - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(map[string]string{ - "token": tok, - "username": body.Username, - }) - } -} - -// AuthLogin returns POST /api/auth/login. The limiter protects the -// argon2id verify path from brute-force/DoS; a successful login -// resets the IP's window so legitimate users aren't locked out -// after a typo. -func AuthLogin(store *auth.Store, limiter *auth.Limiter) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.Header().Set("Allow", http.MethodPost) - writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", authMsgPostOnly) - return - } - ip := clientIP(r) - if ok, retryAfter := limiter.Allow(ip); !ok { - secs := int(math.Ceil(retryAfter.Seconds())) - if secs < 1 { - secs = 1 - } - w.Header().Set("Retry-After", strconv.Itoa(secs)) - slog.Info(authLogLoginReject, "reason", "rate_limited", "ip", ip) - writeInputErr(w, http.StatusTooManyRequests, "rate_limited", - "too many login attempts; try again later") - return - } - var body authCredsBody - if err := decodeAuthBody(r, w, &body); err != nil { - return - } - u, err := auth.Load() - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - slog.Info(authLogLoginReject, "reason", "not_registered") - writeInputErr(w, http.StatusNotFound, "not_registered", - "no user exists yet; sign up first") - return - } - slog.Error("auth login load error", "err", err.Error()) - writeInputErr(w, http.StatusInternalServerError, "load_failed", err.Error()) - return - } - if u.Username != body.Username || !auth.Verify(u.Password, body.Password) { - slog.Info(authLogLoginReject, "reason", "invalid_credentials", "attempted_username", body.Username) - writeInputErr(w, http.StatusUnauthorized, "invalid_credentials", - "username or password does not match") - return - } - limiter.Reset(ip) - tok, err := store.Create(u.Username) - if err != nil { - writeInputErr(w, http.StatusInternalServerError, "session_failed", err.Error()) - return - } - slog.Info("auth login ok", "username", u.Username) - w.Header().Set(headerContentType, contentTypeJSON) - _ = json.NewEncoder(w).Encode(map[string]string{ - "token": tok, - "username": u.Username, - }) - } -} - -// clientIP returns the host portion of r.RemoteAddr. We deliberately -// do NOT honour X-Forwarded-For: behind the reverse proxy the real -// source IP should reach us via RemoteAddr, and trusting XFF blindly -// would let any client spoof the rate-limit key. -func clientIP(r *http.Request) string { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr - } - return host -} - -// AuthLogout returns POST /api/auth/logout. -func AuthLogout(store *auth.Store) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.Header().Set("Allow", http.MethodPost) - writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", authMsgPostOnly) - return - } - tok := bearerToken(r) - if tok == "" { - writeInputErr(w, http.StatusUnauthorized, "missing_token", "") - return - } - user, ok := store.Lookup(tok) - if !ok { - writeInputErr(w, http.StatusUnauthorized, "invalid_token", "") - return - } - store.Revoke(tok) - slog.Info("auth logout", "username", user) - w.WriteHeader(http.StatusNoContent) - } -} - -// ---------- helpers -------------------------------------------------------- - -func bearerToken(r *http.Request) string { - h := r.Header.Get("Authorization") - const prefix = "Bearer " - if !strings.HasPrefix(h, prefix) { - return "" - } - return strings.TrimSpace(h[len(prefix):]) -} - -// BearerFromRequest is the exported twin of bearerToken, used by -// internal/serve/server.go's authHF middleware. -func BearerFromRequest(r *http.Request) string { return bearerToken(r) } - -func decodeAuthBody(r *http.Request, w http.ResponseWriter, out *authCredsBody) error { - r.Body = http.MaxBytesReader(w, r.Body, authBodyMax) - dec := json.NewDecoder(r.Body) - if err := dec.Decode(out); err != nil { - writeInputErr(w, http.StatusBadRequest, "invalid_body", err.Error()) - return err - } - return nil -} - -func validateCreds(b authCredsBody) error { - if len(b.Username) > authUsernameMax || !authUsernameRe.MatchString(b.Username) { - return errors.New("username must be a valid email address") - } - if len(b.Password) < authPasswordMin { - return errors.New("password must be at least 8 characters") - } - if strings.TrimSpace(b.Password) == "" { - return errors.New("password cannot be whitespace only") - } - return nil -} diff --git a/internal/serve/api/auth_test.go b/internal/serve/api/auth_test.go deleted file mode 100644 index a037dfc..0000000 --- a/internal/serve/api/auth_test.go +++ /dev/null @@ -1,303 +0,0 @@ -package api_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/RandomCodeSpace/ctm/internal/serve/api" - "github.com/RandomCodeSpace/ctm/internal/serve/auth" -) - -// testLimiter returns a generously-sized limiter so tests that aren't -// specifically exercising rate-limiting are never throttled. -func testLimiter() *auth.Limiter { - return auth.NewLimiter(1000, time.Second) -} - -// ---------- helpers -------------------------------------------------------- - -func authTempHome(t *testing.T) string { - t.Helper() - home := t.TempDir() - old := os.Getenv("HOME") - t.Cleanup(func() { _ = os.Setenv("HOME", old) }) - _ = os.Setenv("HOME", home) - return home -} - -func authJSONReq(t *testing.T, method, path string, body any) *http.Request { - t.Helper() - b, err := json.Marshal(body) - if err != nil { - t.Fatalf("marshal: %v", err) - } - r := httptest.NewRequest(method, path, bytes.NewReader(b)) - r.Header.Set("Content-Type", "application/json") - return r -} - -// ---------- status --------------------------------------------------------- - -func TestStatus_Unregistered(t *testing.T) { - authTempHome(t) - store := auth.NewStore() - h := api.AuthStatus(store) - rec := httptest.NewRecorder() - h(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - var body struct{ Registered, Authenticated bool } - _ = json.NewDecoder(rec.Body).Decode(&body) - if body.Registered || body.Authenticated { - t.Fatalf("got %+v, want both false", body) - } -} - -func TestStatus_RegisteredButAnonymous(t *testing.T) { - authTempHome(t) - enc, _ := auth.Hash("pw") - _ = auth.Save(auth.User{Username: "alice@example.com", Password: enc}) - store := auth.NewStore() - h := api.AuthStatus(store) - rec := httptest.NewRecorder() - h(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)) - var body struct{ Registered, Authenticated bool } - _ = json.NewDecoder(rec.Body).Decode(&body) - if !body.Registered || body.Authenticated { - t.Fatalf("got %+v, want registered=true authenticated=false", body) - } -} - -func TestStatus_Authenticated(t *testing.T) { - authTempHome(t) - enc, _ := auth.Hash("pw") - _ = auth.Save(auth.User{Username: "alice@example.com", Password: enc}) - store := auth.NewStore() - tok, _ := store.Create("alice@example.com") - h := api.AuthStatus(store) - req := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil) - req.Header.Set("Authorization", "Bearer "+tok) - rec := httptest.NewRecorder() - h(rec, req) - var body struct{ Registered, Authenticated bool } - _ = json.NewDecoder(rec.Body).Decode(&body) - if !body.Registered || !body.Authenticated { - t.Fatalf("got %+v, want both true", body) - } -} - -// ---------- signup --------------------------------------------------------- - -func TestSignup_HappyPath(t *testing.T) { - authTempHome(t) - store := auth.NewStore() - h := api.AuthSignup(store) - rec := httptest.NewRecorder() - h(rec, authJSONReq(t, http.MethodPost, "/api/auth/signup", - map[string]string{"username": "alice@example.com", "password": "password123"})) - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d (%s), want 201", rec.Code, rec.Body.String()) - } - var body struct{ Token, Username string } - _ = json.NewDecoder(rec.Body).Decode(&body) - if body.Token == "" || body.Username != "alice@example.com" { - t.Fatalf("body = %+v", body) - } - if _, ok := store.Lookup(body.Token); !ok { - t.Fatal("token not in session store") - } - if !auth.Exists() { - t.Fatal("user.json was not created") - } -} - -func TestSignup_AlreadyRegistered(t *testing.T) { - authTempHome(t) - enc, _ := auth.Hash("pw") - _ = auth.Save(auth.User{Username: "bob@example.com", Password: enc}) - store := auth.NewStore() - h := api.AuthSignup(store) - rec := httptest.NewRecorder() - h(rec, authJSONReq(t, http.MethodPost, "/api/auth/signup", - map[string]string{"username": "alice@example.com", "password": "password123"})) - if rec.Code != http.StatusConflict { - t.Fatalf("status = %d, want 409", rec.Code) - } - if !strings.Contains(rec.Body.String(), "already_registered") { - t.Fatalf("body = %q", rec.Body.String()) - } -} - -func TestSignup_BadBody(t *testing.T) { - authTempHome(t) - store := auth.NewStore() - h := api.AuthSignup(store) - cases := []map[string]string{ - {}, - {"username": "ab", "password": "password123"}, - {"username": "alice@example.com", "password": "short"}, - {"username": "has space", "password": "password123"}, - {"username": "alice@example.com", "password": " "}, - } - for i, c := range cases { - rec := httptest.NewRecorder() - h(rec, authJSONReq(t, http.MethodPost, "/api/auth/signup", c)) - if rec.Code != http.StatusBadRequest { - t.Fatalf("case %d status = %d, want 400 (body=%s)", i, rec.Code, rec.Body.String()) - } - } -} - -// ---------- login ---------------------------------------------------------- - -func TestLogin_HappyPath(t *testing.T) { - authTempHome(t) - enc, _ := auth.Hash("password123") - _ = auth.Save(auth.User{Username: "alice@example.com", Password: enc}) - store := auth.NewStore() - h := api.AuthLogin(store, testLimiter()) - rec := httptest.NewRecorder() - h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login", - map[string]string{"username": "alice@example.com", "password": "password123"})) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d (%s)", rec.Code, rec.Body.String()) - } - var body struct{ Token, Username string } - _ = json.NewDecoder(rec.Body).Decode(&body) - if body.Token == "" || body.Username != "alice@example.com" { - t.Fatalf("body = %+v", body) - } -} - -func TestLogin_BadPassword(t *testing.T) { - authTempHome(t) - enc, _ := auth.Hash("password123") - _ = auth.Save(auth.User{Username: "alice@example.com", Password: enc}) - store := auth.NewStore() - h := api.AuthLogin(store, testLimiter()) - rec := httptest.NewRecorder() - h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login", - map[string]string{"username": "alice@example.com", "password": "wrong"})) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("status = %d, want 401", rec.Code) - } - if !strings.Contains(rec.Body.String(), "invalid_credentials") { - t.Fatalf("body = %q", rec.Body.String()) - } -} - -func TestLogin_UnknownUsername(t *testing.T) { - authTempHome(t) - enc, _ := auth.Hash("password123") - _ = auth.Save(auth.User{Username: "alice@example.com", Password: enc}) - store := auth.NewStore() - h := api.AuthLogin(store, testLimiter()) - rec := httptest.NewRecorder() - h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login", - map[string]string{"username": "mallory@example.com", "password": "password123"})) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("status = %d, want 401", rec.Code) - } -} - -func TestLogin_NotRegistered(t *testing.T) { - authTempHome(t) - store := auth.NewStore() - h := api.AuthLogin(store, testLimiter()) - rec := httptest.NewRecorder() - h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login", - map[string]string{"username": "alice@example.com", "password": "password123"})) - if rec.Code != http.StatusNotFound { - t.Fatalf("status = %d, want 404", rec.Code) - } - if !strings.Contains(rec.Body.String(), "not_registered") { - t.Fatalf("body = %q", rec.Body.String()) - } -} - -func TestLogin_RateLimited(t *testing.T) { - authTempHome(t) - enc, _ := auth.Hash("password123") - _ = auth.Save(auth.User{Username: "alice@example.com", Password: enc}) - store := auth.NewStore() - - // Injected clock stays fixed so all 6 attempts fall in the window. - clock := func() time.Time { return time.Unix(1_700_000_000, 0) } - lim := auth.NewLimiterWithClock(5, 60*time.Second, clock) - h := api.AuthLogin(store, lim) - - // 5 bad attempts — all should reach the handler and return 401. - for i := 0; i < 5; i++ { - rec := httptest.NewRecorder() - h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login", - map[string]string{"username": "alice@example.com", "password": "wrong"})) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("attempt %d status = %d, want 401 (%s)", i+1, rec.Code, rec.Body.String()) - } - } - - // 6th attempt must be rate-limited. - rec := httptest.NewRecorder() - h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login", - map[string]string{"username": "alice@example.com", "password": "wrong"})) - if rec.Code != http.StatusTooManyRequests { - t.Fatalf("6th attempt status = %d, want 429", rec.Code) - } - if ra := rec.Result().Header.Get("Retry-After"); ra == "" { - t.Fatal("Retry-After header missing on 429") - } - if !strings.Contains(rec.Body.String(), "rate_limited") { - t.Fatalf("body = %q, want rate_limited code", rec.Body.String()) - } -} - -// ---------- logout --------------------------------------------------------- - -func TestLogout_RevokesToken(t *testing.T) { - authTempHome(t) - enc, _ := auth.Hash("pw") - _ = auth.Save(auth.User{Username: "alice@example.com", Password: enc}) - store := auth.NewStore() - tok, _ := store.Create("alice@example.com") - h := api.AuthLogout(store) - - req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) - req.Header.Set("Authorization", "Bearer "+tok) - rec := httptest.NewRecorder() - h(rec, req) - if rec.Code != http.StatusNoContent { - t.Fatalf("status = %d (%s)", rec.Code, rec.Body.String()) - } - if _, ok := store.Lookup(tok); ok { - t.Fatal("token still present after logout") - } -} - -func TestLogout_NoToken(t *testing.T) { - authTempHome(t) - store := auth.NewStore() - h := api.AuthLogout(store) - rec := httptest.NewRecorder() - h(rec, httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("status = %d, want 401", rec.Code) - } -} - -// ---------- file-based paths sanity check --------------------------------- - -func TestUserPath_UsesHome(t *testing.T) { - home := authTempHome(t) - want := filepath.Join(home, ".config", "ctm", "user.json") - if got := auth.UserPath(); got != want { - t.Fatalf("UserPath = %q, want %q", got, want) - } -} diff --git a/internal/serve/api/bootstrap.go b/internal/serve/api/bootstrap.go deleted file mode 100644 index 5fe9d22..0000000 --- a/internal/serve/api/bootstrap.go +++ /dev/null @@ -1,47 +0,0 @@ -// Package api implements the JSON HTTP handlers mounted under /api by -// internal/serve.Server. Handlers are pure http.HandlerFunc factories -// — auth wrapping happens at server boot, not inside the handler. -package api - -import ( - "encoding/json" - "net/http" -) - -// Bootstrap returns the GET /api/bootstrap handler. The response shape -// is the contract consumed by ui/ on the auth-paste screen: enough -// metadata for the SPA to decide whether to render webhook UI without -// leaking server internals. -// -// Response: {"version":..., "port":..., "has_webhook":...}. -// -// 405 on any non-GET method. -func Bootstrap(version string, port int, hasWebhook bool) http.HandlerFunc { - body := struct { - Version string `json:"version"` - Port int `json:"port"` - HasWebhook bool `json:"has_webhook"` - }{ - Version: version, - Port: port, - HasWebhook: hasWebhook, - } - encoded, err := json.Marshal(body) - if err != nil { - // inputs are primitives — this is unreachable in practice - panic("api.Bootstrap: marshal: " + err.Error()) - } - - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - w.Header().Set("Allow", http.MethodGet) - w.Header().Set("Cache-Control", "no-store") - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusOK) - _, _ = w.Write(encoded) - } -} diff --git a/internal/serve/api/bootstrap_test.go b/internal/serve/api/bootstrap_test.go deleted file mode 100644 index 3a77f2e..0000000 --- a/internal/serve/api/bootstrap_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package api - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" -) - -func TestBootstrap_ResponseShape(t *testing.T) { - h := Bootstrap("v1.2.3", 37778, true) - req := httptest.NewRequest(http.MethodGet, "/api/bootstrap", nil) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - if got := rec.Header().Get("Content-Type"); got != "application/json" { - t.Errorf("Content-Type = %q", got) - } - if got := rec.Header().Get("Cache-Control"); got != "no-store" { - t.Errorf("Cache-Control = %q", got) - } - - body, _ := io.ReadAll(rec.Body) - var got struct { - Version string `json:"version"` - Port int `json:"port"` - HasWebhook bool `json:"has_webhook"` - Extra any `json:"extra,omitempty"` - } - if err := json.Unmarshal(body, &got); err != nil { - t.Fatalf("unmarshal: %v (body=%s)", err, body) - } - if got.Version != "v1.2.3" || got.Port != 37778 || got.HasWebhook != true { - t.Errorf("body = %+v", got) - } - - // Round-trip the keys to catch accidental renames. - var raw map[string]any - if err := json.Unmarshal(body, &raw); err != nil { - t.Fatal(err) - } - for _, key := range []string{"version", "port", "has_webhook"} { - if _, ok := raw[key]; !ok { - t.Errorf("missing key %q in response: %s", key, body) - } - } - if len(raw) != 3 { - t.Errorf("unexpected extra keys: %v", raw) - } -} - -func TestBootstrap_HasWebhookFalse(t *testing.T) { - h := Bootstrap("dev", 1, false) - req := httptest.NewRequest(http.MethodGet, "/api/bootstrap", nil) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - - var got struct { - HasWebhook bool `json:"has_webhook"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatal(err) - } - if got.HasWebhook { - t.Errorf("has_webhook = true, want false") - } -} - -func TestBootstrap_MethodNotAllowed(t *testing.T) { - h := Bootstrap("dev", 37778, false) - for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} { - t.Run(m, func(t *testing.T) { - req := httptest.NewRequest(m, "/api/bootstrap", nil) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - - if rec.Code != http.StatusMethodNotAllowed { - t.Errorf("status = %d, want 405", rec.Code) - } - if got := rec.Header().Get("Allow"); got != http.MethodGet { - t.Errorf("Allow = %q, want GET", got) - } - }) - } -} diff --git a/internal/serve/api/checkpoints.go b/internal/serve/api/checkpoints.go deleted file mode 100644 index 6d568f2..0000000 --- a/internal/serve/api/checkpoints.go +++ /dev/null @@ -1,189 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "os" - "path/filepath" - "strconv" - "sync" - "time" - - "github.com/RandomCodeSpace/ctm/internal/serve/git" -) - -// checkpointsCacheTTL is the per-session window during which repeated -// GETs return the previously rendered list without re-running git. -// The 5 s figure mirrors the spec's "/checkpoints (5 s server cache)". -const checkpointsCacheTTL = 5 * time.Second - -// checkpointsLister is the seam tests inject; production code passes -// git.List. Kept package-private — callers wire through Checkpoints(). -var checkpointsLister = git.List - -type checkpointsCacheEntry struct { - at time.Time - value []git.Checkpoint - limit int -} - -// CheckpointsCache wraps the per-session 5 s TTL cache used by the -// /checkpoints handler so other callers — notably the revert -// SHA-allowlist check — can reuse the same cached list rather than -// each spinning up its own `git log` subprocess. -// -// The zero value is ready to use; NewCheckpointsCache exists for -// symmetry with the rest of the api package. -type CheckpointsCache struct { - mu sync.RWMutex - entries map[string]checkpointsCacheEntry - listFn func(workdir string, limit int) ([]git.Checkpoint, error) -} - -// NewCheckpointsCache constructs a fresh cache. Lister selection is -// deferred to call time so tests can swap the package-level -// `checkpointsLister` after construction and still see the change. -func NewCheckpointsCache() *CheckpointsCache { - return &CheckpointsCache{} -} - -// Get returns the cached checkpoint list for (name, limit) when fresh, -// otherwise re-runs the underlying lister against workdir, caches, and -// returns. Errors propagate without poisoning the cache. -func (c *CheckpointsCache) Get(workdir, name string, limit int) ([]git.Checkpoint, error) { - if cached, ok := c.lookup(name, limit); ok { - return cached, nil - } - listFn := c.listFn - if listFn == nil { - listFn = checkpointsLister - } - fresh, err := listFn(workdir, limit) - if err != nil { - return nil, err - } - c.store(name, limit, fresh) - return fresh, nil -} - -// IsCheckpoint reports whether sha is one of the *full* commit SHAs -// returned for (workdir, name) by Get(workdir, name, 200). Comparison -// is exact-match only — abbreviated SHAs are intentionally rejected -// because prefix matching would expand the allowlist's blast radius. -func (c *CheckpointsCache) IsCheckpoint(workdir, name, sha string) bool { - if sha == "" { - return false - } - cps, err := c.Get(workdir, name, 200) - if err != nil { - return false - } - for _, cp := range cps { - if cp.SHA == sha { - return true - } - } - return false -} - -func (c *CheckpointsCache) lookup(name string, limit int) ([]git.Checkpoint, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - e, ok := c.entries[name] - if !ok || e.limit != limit || time.Since(e.at) > checkpointsCacheTTL { - return nil, false - } - return e.value, true -} - -func (c *CheckpointsCache) store(name string, limit int, value []git.Checkpoint) { - c.mu.Lock() - defer c.mu.Unlock() - if c.entries == nil { - c.entries = make(map[string]checkpointsCacheEntry) - } - c.entries[name] = checkpointsCacheEntry{ - at: time.Now(), - value: value, - limit: limit, - } -} - -// Checkpoints returns the GET handler for -// /api/sessions/{name}/checkpoints. resolveWorkdir lets the handler -// stay decoupled from the sessions package: it returns the workdir -// for `name` and false when the session is unknown. cache is shared -// with the revert handler's SHA-allowlist check (see server.go). -func Checkpoints(resolveWorkdir func(name string) (string, bool), cache *CheckpointsCache) http.HandlerFunc { - if cache == nil { - cache = NewCheckpointsCache() - } - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodHead { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - name := r.PathValue("name") - if name == "" { - http.NotFound(w, r) - return - } - - workdir, ok := resolveWorkdir(name) - if !ok { - http.NotFound(w, r) - return - } - - limit := 50 - if raw := r.URL.Query().Get("limit"); raw != "" { - if n, err := strconv.Atoi(raw); err == nil && n > 0 { - limit = n - } - } - - gitDir := isGitWorkdir(workdir) - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") - - if !gitDir { - _ = json.NewEncoder(w).Encode(checkpointsResp{ - GitWorkdir: false, - Checkpoints: []git.Checkpoint{}, - }) - return - } - - list, err := cache.Get(workdir, name, limit) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "git_failed"}) - return - } - if list == nil { - list = []git.Checkpoint{} - } - - _ = json.NewEncoder(w).Encode(checkpointsResp{ - GitWorkdir: true, - Checkpoints: list, - }) - } -} - -type checkpointsResp struct { - GitWorkdir bool `json:"git_workdir"` - Checkpoints []git.Checkpoint `json:"checkpoints"` -} - -// isGitWorkdir reports whether workdir contains a `.git` entry (dir -// or file — worktrees use a file). No stat = no git repo = no -// checkpoints possible. -func isGitWorkdir(workdir string) bool { - if workdir == "" { - return false - } - _, err := os.Stat(filepath.Join(workdir, ".git")) - return err == nil -} diff --git a/internal/serve/api/checkpoints_test.go b/internal/serve/api/checkpoints_test.go deleted file mode 100644 index cce0a1d..0000000 --- a/internal/serve/api/checkpoints_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "sync/atomic" - "testing" - "time" - - "github.com/RandomCodeSpace/ctm/internal/serve/git" -) - -// gitWorkdir returns a tempdir with a .git directory so the handler's -// isGitWorkdir check passes — otherwise the handler short-circuits -// before calling the lister. -func gitWorkdir(t *testing.T) string { - t.Helper() - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o700); err != nil { - t.Fatal(err) - } - return dir -} - -func TestCheckpoints_404OnUnknownSession(t *testing.T) { - h := Checkpoints(func(name string) (string, bool) { return "", false }, nil) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/sessions/missing/checkpoints", nil) - h(rec, req) - if rec.Code != http.StatusNotFound { - t.Errorf("status = %d, want 404", rec.Code) - } -} - -func TestCheckpoints_405OnPost(t *testing.T) { - h := Checkpoints(func(name string) (string, bool) { return "/tmp", true }, nil) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/sessions/x/checkpoints", nil) - h(rec, req) - if rec.Code != http.StatusMethodNotAllowed { - t.Errorf("status = %d, want 405", rec.Code) - } -} - -func TestCheckpoints_CacheHitWithin5s(t *testing.T) { - prev := checkpointsLister - t.Cleanup(func() { checkpointsLister = prev }) - - var calls int32 - want := []git.Checkpoint{{SHA: "abc", Subject: "checkpoint: pre-yolo x", TS: "2026-04-20T10:00:00Z", Ago: "2m"}} - checkpointsLister = func(workdir string, limit int) ([]git.Checkpoint, error) { - atomic.AddInt32(&calls, 1) - return want, nil - } - - wd := gitWorkdir(t) - h := Checkpoints(func(name string) (string, bool) { return wd, true }, nil) - - for i := 0; i < 5; i++ { - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/sessions/sess/checkpoints", nil) - req.SetPathValue("name", "sess") - h(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("call %d: status = %d", i, rec.Code) - } - var got checkpointsResp - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatalf("decode: %v", err) - } - if !got.GitWorkdir { - t.Fatalf("call %d: git_workdir = false, want true", i) - } - if len(got.Checkpoints) != 1 || got.Checkpoints[0].SHA != "abc" { - t.Errorf("call %d: payload = %+v", i, got) - } - } - if c := atomic.LoadInt32(&calls); c != 1 { - t.Errorf("lister called %d times, want 1 (cached)", c) - } -} - -func TestCheckpoints_CacheKeyedOnLimit(t *testing.T) { - prev := checkpointsLister - t.Cleanup(func() { checkpointsLister = prev }) - - var calls int32 - checkpointsLister = func(workdir string, limit int) ([]git.Checkpoint, error) { - atomic.AddInt32(&calls, 1) - return nil, nil - } - wd := gitWorkdir(t) - h := Checkpoints(func(name string) (string, bool) { return wd, true }, nil) - - for _, q := range []string{"", "?limit=10", "?limit=10", "?limit=20"} { - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/sessions/s/checkpoints"+q, nil) - req.SetPathValue("name", "s") - h(rec, req) - } - // Three distinct cache keys: default(50), 10, 20. Second "?limit=10" hits cache. - if c := atomic.LoadInt32(&calls); c != 3 { - t.Errorf("lister calls = %d, want 3", c) - } -} - -func TestCheckpoints_NilListEncodedAsEmptyArray(t *testing.T) { - prev := checkpointsLister - t.Cleanup(func() { checkpointsLister = prev }) - checkpointsLister = func(workdir string, limit int) ([]git.Checkpoint, error) { - return nil, nil - } - wd := gitWorkdir(t) - h := Checkpoints(func(name string) (string, bool) { return wd, true }, nil) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/sessions/s/checkpoints", nil) - req.SetPathValue("name", "s") - h(rec, req) - var got checkpointsResp - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatalf("decode: %v", err) - } - if !got.GitWorkdir { - t.Fatalf("git_workdir = false, want true") - } - if got.Checkpoints == nil || len(got.Checkpoints) != 0 { - t.Errorf("checkpoints = %+v, want empty non-nil slice", got.Checkpoints) - } -} - -func TestCheckpoints_NotGitWorkdir(t *testing.T) { - // No .git dir — handler must short-circuit and return - // git_workdir:false without calling the lister. - prev := checkpointsLister - t.Cleanup(func() { checkpointsLister = prev }) - var calls int32 - checkpointsLister = func(workdir string, limit int) ([]git.Checkpoint, error) { - atomic.AddInt32(&calls, 1) - return nil, nil - } - - wd := t.TempDir() // no .git subdir - h := Checkpoints(func(name string) (string, bool) { return wd, true }, nil) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/sessions/s/checkpoints", nil) - req.SetPathValue("name", "s") - h(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - var got checkpointsResp - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.GitWorkdir { - t.Errorf("git_workdir = true, want false for non-git workdir") - } - if len(got.Checkpoints) != 0 { - t.Errorf("checkpoints = %+v, want empty", got.Checkpoints) - } - if c := atomic.LoadInt32(&calls); c != 0 { - t.Errorf("lister called %d times for non-git workdir, want 0", c) - } -} - -func TestCheckpointsCache_IsCheckpointFullSHAOnly(t *testing.T) { - prev := checkpointsLister - t.Cleanup(func() { checkpointsLister = prev }) - - const fullSHA = "3e17c87aabbccddee0011223344556677889900" - checkpointsLister = func(workdir string, limit int) ([]git.Checkpoint, error) { - return []git.Checkpoint{{SHA: fullSHA, Subject: "checkpoint: pre-yolo 2026"}}, nil - } - - cache := NewCheckpointsCache() - if !cache.IsCheckpoint("/wd", "name", fullSHA) { - t.Error("full SHA must be allowed") - } - if cache.IsCheckpoint("/wd", "name", fullSHA[:7]) { - t.Error("7-char abbreviated SHA must be rejected") - } - if cache.IsCheckpoint("/wd", "name", fullSHA[:12]) { - t.Error("12-char abbreviated SHA must be rejected") - } - if cache.IsCheckpoint("/wd", "name", "") { - t.Error("empty SHA must be rejected") - } -} - -func TestCheckpoints_CacheExpiresAfterTTL(t *testing.T) { - prev := checkpointsLister - t.Cleanup(func() { checkpointsLister = prev }) - - var calls int32 - checkpointsLister = func(workdir string, limit int) ([]git.Checkpoint, error) { - atomic.AddInt32(&calls, 1) - return nil, nil - } - wd := gitWorkdir(t) - h := Checkpoints(func(name string) (string, bool) { return wd, true }, nil) - - rec1 := httptest.NewRecorder() - r1 := httptest.NewRequest(http.MethodGet, "/api/sessions/s/checkpoints", nil) - r1.SetPathValue("name", "s") - h(rec1, r1) - rec2 := httptest.NewRecorder() - r2 := httptest.NewRequest(http.MethodGet, "/api/sessions/s/checkpoints", nil) - r2.SetPathValue("name", "s") - h(rec2, r2) - if c := atomic.LoadInt32(&calls); c != 1 { - t.Fatalf("cache miss within TTL: calls = %d", c) - } - _ = time.Now() -} diff --git a/internal/serve/api/config_get.go b/internal/serve/api/config_get.go deleted file mode 100644 index 547bde3..0000000 --- a/internal/serve/api/config_get.go +++ /dev/null @@ -1,99 +0,0 @@ -package api - -// Coordinator wiring (do NOT edit server.go here — this comment is the -// integration contract). In server.go's registerRoutes, after the -// existing mux.Handle lines, add: -// -// mux.Handle("GET /api/config", authHF(api.ConfigGet(config.ConfigPath()))) -// mux.Handle("PATCH /api/config", authHF(api.ConfigUpdate(config.ConfigPath(), s.Shutdown))) -// -// The second line assumes a Server.Shutdown(reason string) exists that -// cancels the daemon's root context. If it does not yet, wrap the -// Run(ctx) caller's cancel() via a thin closure at the call site. - -import ( - "encoding/json" - "net/http" - - "github.com/RandomCodeSpace/ctm/internal/config" -) - -// ConfigGet returns the GET /api/config handler. The response is the -// same allowlisted shape that PATCH /api/config accepts, so the UI can -// seed the settings form with current values without parsing the full -// on-disk config. -// -// Response body: -// -// { -// "webhook_url": "...", -// "webhook_auth": "...", -// "attention": { -// "error_rate_pct": 20, -// "error_rate_window": 20, -// "idle_minutes": 5, -// "quota_pct": 85, -// "context_pct": 90, -// "yolo_unchecked_minutes": 30 -// } -// } -// -// Thresholds are always returned in their Resolved() form so the UI -// form shows the defaults the daemon is actually using rather than -// zeroes for fields the user has never set. -// -// 405 on any non-GET method. 500 on config-load failure (rare — Load -// auto-creates the file on first boot). -func ConfigGet(cfgPath string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - w.Header().Set("Allow", http.MethodGet) - w.Header().Set("Cache-Control", "no-store") - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - cfg, err := config.Load(cfgPath) - if err != nil { - writeJSONError(w, http.StatusInternalServerError, "load_config") - return - } - - att := cfg.Serve.Attention.Resolved() - body := configPayload{ - WebhookURL: cfg.Serve.WebhookURL, - WebhookAuth: cfg.Serve.WebhookAuth, - Attention: &attentionPayload{ - ErrorRatePct: att.ErrorRatePct, - ErrorRateWindow: att.ErrorRateWindow, - IdleMinutes: att.IdleMinutes, - QuotaPct: att.QuotaPct, - ContextPct: att.ContextPct, - YoloUncheckedMinutes: att.YoloUncheckedMinutes, - }, - } - - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") - _ = json.NewEncoder(w).Encode(body) - } -} - -// configPayload is the wire shape for both GET and PATCH /api/config. -// On GET every field is populated; on PATCH clients may omit fields to -// leave them unchanged. `omitempty` on Attention lets partial patches -// skip the attention block entirely. -type configPayload struct { - WebhookURL string `json:"webhook_url"` - WebhookAuth string `json:"webhook_auth"` - Attention *attentionPayload `json:"attention,omitempty"` -} - -type attentionPayload struct { - ErrorRatePct int `json:"error_rate_pct"` - ErrorRateWindow int `json:"error_rate_window"` - IdleMinutes int `json:"idle_minutes"` - QuotaPct int `json:"quota_pct"` - ContextPct int `json:"context_pct"` - YoloUncheckedMinutes int `json:"yolo_unchecked_minutes"` -} diff --git a/internal/serve/api/config_update.go b/internal/serve/api/config_update.go deleted file mode 100644 index 4a89702..0000000 --- a/internal/serve/api/config_update.go +++ /dev/null @@ -1,236 +0,0 @@ -package api - -// Coordinator wiring (do NOT edit server.go here — this comment is the -// integration contract). In server.go's registerRoutes, after the -// existing mux.Handle lines, add: -// -// mux.Handle("GET /api/config", authHF(api.ConfigGet(config.ConfigPath()))) -// mux.Handle("PATCH /api/config", authHF(api.ConfigUpdate(config.ConfigPath(), s.Shutdown))) -// -// s.Shutdown(reason string) must cancel the daemon's root context so -// Run(ctx) returns cleanly; callers (ctm attach / new / yolo) then -// respawn via proc.EnsureServeRunning on the next user action. - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "path/filepath" - "time" - - "github.com/RandomCodeSpace/ctm/internal/config" -) - -// Validation bounds for the six attention thresholds. Keep these in -// sync with config.AttentionThresholds doc. -const ( - maxPct = 100 - maxMinutes = 1440 -) - -// shutdownDelay is how long we wait after responding 202 before -// cancelling the daemon's root context. A small delay lets the HTTP -// response body flush to the client; otherwise the SPA would race the -// shutdown and see a truncated connection before it can show the -// "daemon restarting" banner. -const shutdownDelay = 1 * time.Second - -// ConfigUpdate returns the PATCH /api/config handler. -// -// Body shape (all top-level keys optional; unknown keys → 400): -// -// { -// "webhook_url": "https://...", -// "webhook_auth": "Bearer ...", -// "attention": { -// "error_rate_pct": 20, -// "error_rate_window": 20, -// "idle_minutes": 5, -// "quota_pct": 85, -// "context_pct": 90, -// "yolo_unchecked_minutes": 30 -// } -// } -// -// On success, responds 202 Accepted with {"status":"restarting"} and -// schedules shutdown(reason) to run after shutdownDelay so the response -// flushes first. The caller (proc.EnsureServeRunning at the next -// attach/new/yolo) respawns the daemon. -// -// Contract: -// - 405 on non-PATCH. -// - 400 on invalid JSON, unknown top-level keys, or out-of-range values. -// - 500 if the on-disk config cannot be loaded or atomically replaced. -// - 202 with {"status":"restarting"} on success. -// -// Write is atomic: marshal → WriteFile(.tmp) → Rename. A crash -// mid-write leaves the previous config intact. -func ConfigUpdate(cfgPath string, shutdown func(reason string)) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPatch { - w.Header().Set("Allow", http.MethodPatch) - w.Header().Set("Cache-Control", "no-store") - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - // Decode with DisallowUnknownFields so typos like "webhookUrl" - // or dropped experimental keys surface as 400 instead of being - // silently ignored. The allowlist is the wire contract. - dec := json.NewDecoder(r.Body) - dec.DisallowUnknownFields() - var req configPayload - if err := dec.Decode(&req); err != nil { - writeJSONError(w, http.StatusBadRequest, sanitizeDecodeErr(err)) - return - } - - // Range-check thresholds before touching disk. - if req.Attention != nil { - if msg, ok := validateAttention(req.Attention); !ok { - writeJSONError(w, http.StatusBadRequest, msg) - return - } - } - - cfg, err := config.Load(cfgPath) - if err != nil { - writeJSONError(w, http.StatusInternalServerError, "load_config") - return - } - - // Deep-merge: only fields present in the patch body overwrite - // existing config state. String fields are overwritten - // verbatim (including empty strings, which lets users clear - // webhook_url); attention thresholds merge field-by-field so a - // partial attention block preserves the untouched thresholds. - cfg.Serve.WebhookURL = req.WebhookURL - cfg.Serve.WebhookAuth = req.WebhookAuth - if req.Attention != nil { - cfg.Serve.Attention = config.AttentionThresholds{ - ErrorRatePct: req.Attention.ErrorRatePct, - ErrorRateWindow: req.Attention.ErrorRateWindow, - IdleMinutes: req.Attention.IdleMinutes, - QuotaPct: req.Attention.QuotaPct, - ContextPct: req.Attention.ContextPct, - YoloUncheckedMinutes: req.Attention.YoloUncheckedMinutes, - } - } - - if err := writeAtomic(cfgPath, cfg); err != nil { - writeJSONError(w, http.StatusInternalServerError, "write_config") - return - } - - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusAccepted) - _ = json.NewEncoder(w).Encode(map[string]string{"status": "restarting"}) - - // Flush before cancelling: the response goroutine must return - // so http.Server can write the body out before BaseContext is - // cancelled. A tiny delay plus a background goroutine avoids - // coupling the restart to the response writer's lifetime. - if shutdown != nil { - go func() { - time.Sleep(shutdownDelay) - shutdown("config change") - }() - } - } -} - -// validateAttention enforces the documented ranges: percentages in -// (0, 100], minute windows in (0, 1440]. Zero is rejected because the -// config-layer Resolved() treats 0 as "use default", and the UI should -// not be able to implicitly reset a threshold by POSTing 0 — it must -// send the default value explicitly. -func validateAttention(a *attentionPayload) (string, bool) { - checks := []struct { - name string - val int - max int - }{ - {"error_rate_pct", a.ErrorRatePct, maxPct}, - {"quota_pct", a.QuotaPct, maxPct}, - {"context_pct", a.ContextPct, maxPct}, - {"error_rate_window", a.ErrorRateWindow, maxMinutes}, - {"idle_minutes", a.IdleMinutes, maxMinutes}, - {"yolo_unchecked_minutes", a.YoloUncheckedMinutes, maxMinutes}, - } - for _, c := range checks { - if c.val <= 0 { - return fmt.Sprintf("%s must be > 0", c.name), false - } - if c.val > c.max { - return fmt.Sprintf("%s must be <= %d", c.name, c.max), false - } - } - return "", true -} - -// writeAtomic marshals cfg to JSON and swaps it into place via a -// sibling .tmp file + rename. The rename is atomic on POSIX when src -// and dst live on the same filesystem (always true here — both in -// ~/.config/ctm/). -func writeAtomic(path string, cfg config.Config) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - // Stamp the current schema version so the file round-trips cleanly - // through the migrator on subsequent loads. Mirrors config.write(). - cfg.SchemaVersion = config.SchemaVersion - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0600); err != nil { - return err - } - if err := os.Rename(tmp, path); err != nil { - _ = os.Remove(tmp) - return err - } - return nil -} - -// sanitizeDecodeErr maps json.Decoder errors to short, stable tokens. -// Unknown-field errors are the load-bearing 400 signal so the UI can -// render a helpful message; everything else collapses to -// "invalid_request" to avoid leaking parser internals. -func sanitizeDecodeErr(err error) string { - if err == nil { - return "" - } - msg := err.Error() - const marker = "json: unknown field " - if idx := indexOf(msg, marker); idx >= 0 { - // e.g. `json: unknown field "foo"` → `unknown key: foo` - return "unknown key: " + trimQuotes(msg[idx+len(marker):]) - } - return "invalid_request" -} - -// indexOf is a tiny strings.Index shim to keep the import list lean -// (one-file handler — we don't need strings elsewhere here). -func indexOf(s, substr string) int { - n := len(substr) - for i := 0; i+n <= len(s); i++ { - if s[i:i+n] == substr { - return i - } - } - return -1 -} - -// trimQuotes strips leading/trailing ASCII double-quotes from s. -// json's "unknown field" error format is `"field"` with the quotes -// embedded in the message, so we peel them for a cleaner wire token. -func trimQuotes(s string) string { - if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { - return s[1 : len(s)-1] - } - return s -} diff --git a/internal/serve/api/config_update_test.go b/internal/serve/api/config_update_test.go deleted file mode 100644 index b467882..0000000 --- a/internal/serve/api/config_update_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "runtime" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/RandomCodeSpace/ctm/internal/config" -) - -// validBody is a happy-path patch body that covers every allowlisted -// key. Reused across tests so a schema change breaks every case at -// once instead of letting old bodies silently pass. -func validBody() string { - return `{ - "webhook_url": "https://hooks.example/ctm", - "webhook_auth": "Bearer xyz", - "attention": { - "error_rate_pct": 25, - "error_rate_window": 40, - "idle_minutes": 10, - "quota_pct": 90, - "context_pct": 95, - "yolo_unchecked_minutes": 45 - } - }` -} - -// seedConfig writes a default config.json to tmpDir and returns the -// full path. Load() auto-creates on missing, but seeding gives us a -// stable starting state so assertions can compare against known values. -func seedConfig(t *testing.T, dir string) string { - t.Helper() - cfgPath := filepath.Join(dir, "config.json") - def := config.Default() - // Give it a distinct webhook_url we can check was overwritten. - def.Serve.WebhookURL = "https://old.example" - data, err := json.MarshalIndent(def, "", " ") - if err != nil { - t.Fatalf("marshal seed: %v", err) - } - if err := os.WriteFile(cfgPath, data, 0600); err != nil { - t.Fatalf("write seed: %v", err) - } - return cfgPath -} - -func TestConfigUpdate_HappyPath(t *testing.T) { - dir := t.TempDir() - cfgPath := seedConfig(t, dir) - - var shutdownCalls atomic.Int32 - var shutdownReason atomic.Value - shutdown := func(reason string) { - shutdownCalls.Add(1) - shutdownReason.Store(reason) - } - - h := ConfigUpdate(cfgPath, shutdown) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPatch, "/api/config", strings.NewReader(validBody())) - h(rec, req) - - if rec.Code != http.StatusAccepted { - t.Fatalf("status = %d, want 202; body=%s", rec.Code, rec.Body.String()) - } - var resp map[string]string - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode resp: %v", err) - } - if resp["status"] != "restarting" { - t.Errorf("status field = %q", resp["status"]) - } - - // Config was written atomically. - got, err := config.Load(cfgPath) - if err != nil { - t.Fatalf("reload: %v", err) - } - if got.Serve.WebhookURL != "https://hooks.example/ctm" { - t.Errorf("webhook_url = %q", got.Serve.WebhookURL) - } - if got.Serve.WebhookAuth != "Bearer xyz" { - t.Errorf("webhook_auth = %q", got.Serve.WebhookAuth) - } - if got.Serve.Attention.QuotaPct != 90 { - t.Errorf("quota_pct = %d", got.Serve.Attention.QuotaPct) - } - if got.Serve.Attention.YoloUncheckedMinutes != 45 { - t.Errorf("yolo_unchecked_minutes = %d", got.Serve.Attention.YoloUncheckedMinutes) - } - - // shutdown() runs in a goroutine after a 1s delay. Poll up to 3s so - // slow CI workers don't flake; fail fast if it never fires. - deadline := time.Now().Add(3 * time.Second) - for shutdownCalls.Load() == 0 && time.Now().Before(deadline) { - time.Sleep(20 * time.Millisecond) - } - if shutdownCalls.Load() != 1 { - t.Errorf("shutdown called %d times, want 1", shutdownCalls.Load()) - } - if got, _ := shutdownReason.Load().(string); got != "config change" { - t.Errorf("shutdown reason = %q", got) - } - - // Tmp sibling must not leak on success. - if _, err := os.Stat(cfgPath + ".tmp"); !os.IsNotExist(err) { - t.Errorf("tmp file left behind: err=%v", err) - } -} - -func TestConfigUpdate_UnknownKey(t *testing.T) { - dir := t.TempDir() - cfgPath := seedConfig(t, dir) - h := ConfigUpdate(cfgPath, func(string) {}) - body := `{"bogus_key": 1}` - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPatch, "/api/config", strings.NewReader(body)) - h(rec, req) - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) - } - var resp map[string]string - _ = json.Unmarshal(rec.Body.Bytes(), &resp) - if !strings.Contains(resp["error"], "unknown key") { - t.Errorf("error = %q, want contains 'unknown key'", resp["error"]) - } -} - -func TestConfigUpdate_InvalidRange(t *testing.T) { - dir := t.TempDir() - cfgPath := seedConfig(t, dir) - h := ConfigUpdate(cfgPath, func(string) {}) - - cases := []struct { - name string - body string - want string // substring the error message must contain - }{ - { - "pct over 100", - `{"attention":{"error_rate_pct":150,"error_rate_window":10,"idle_minutes":5,"quota_pct":80,"context_pct":90,"yolo_unchecked_minutes":10}}`, - "error_rate_pct", - }, - { - "minutes over 1440", - `{"attention":{"error_rate_pct":10,"error_rate_window":10,"idle_minutes":9999,"quota_pct":80,"context_pct":90,"yolo_unchecked_minutes":10}}`, - "idle_minutes", - }, - { - "zero rejected", - `{"attention":{"error_rate_pct":10,"error_rate_window":10,"idle_minutes":5,"quota_pct":0,"context_pct":90,"yolo_unchecked_minutes":10}}`, - "quota_pct", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPatch, "/api/config", strings.NewReader(tc.body)) - h(rec, req) - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) - } - var resp map[string]string - _ = json.Unmarshal(rec.Body.Bytes(), &resp) - if !strings.Contains(resp["error"], tc.want) { - t.Errorf("error = %q, want contains %q", resp["error"], tc.want) - } - }) - } -} - -func TestConfigUpdate_MethodNotAllowed(t *testing.T) { - dir := t.TempDir() - cfgPath := seedConfig(t, dir) - h := ConfigUpdate(cfgPath, func(string) {}) - for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete} { - t.Run(m, func(t *testing.T) { - rec := httptest.NewRecorder() - req := httptest.NewRequest(m, "/api/config", strings.NewReader(validBody())) - h(rec, req) - if rec.Code != http.StatusMethodNotAllowed { - t.Errorf("status = %d, want 405", rec.Code) - } - if got := rec.Header().Get("Allow"); got != http.MethodPatch { - t.Errorf("Allow = %q, want PATCH", got) - } - }) - } -} - -func TestConfigUpdate_WriteFailure(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("chmod-based read-only dir test is POSIX-only") - } - if os.Geteuid() == 0 { - t.Skip("running as root bypasses directory permissions") - } - dir := t.TempDir() - cfgPath := seedConfig(t, dir) - // Make the parent directory read-only so the atomic rename's - // WriteFile on the .tmp sibling fails. Restore in cleanup so the - // test runner can tear the temp dir down. - if err := os.Chmod(dir, 0500); err != nil { - t.Fatalf("chmod dir: %v", err) - } - t.Cleanup(func() { _ = os.Chmod(dir, 0700) }) - - var shutdownCalls atomic.Int32 - h := ConfigUpdate(cfgPath, func(string) { shutdownCalls.Add(1) }) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPatch, "/api/config", strings.NewReader(validBody())) - h(rec, req) - - if rec.Code != http.StatusInternalServerError { - t.Fatalf("status = %d, want 500; body=%s", rec.Code, rec.Body.String()) - } - // The daemon must NOT restart when the write failed — otherwise - // users lose the running daemon state for a change that was never - // persisted. - time.Sleep(50 * time.Millisecond) - if shutdownCalls.Load() != 0 { - t.Errorf("shutdown called on write failure (count=%d); must not restart", shutdownCalls.Load()) - } -} - -func TestConfigUpdate_InvalidJSON(t *testing.T) { - dir := t.TempDir() - cfgPath := seedConfig(t, dir) - h := ConfigUpdate(cfgPath, func(string) {}) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPatch, "/api/config", strings.NewReader("{not json")) - h(rec, req) - if rec.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", rec.Code) - } -} - -func TestConfigGet_HappyPath(t *testing.T) { - dir := t.TempDir() - cfgPath := seedConfig(t, dir) - - h := ConfigGet(cfgPath) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/config", nil) - h(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) - } - var got struct { - WebhookURL string `json:"webhook_url"` - WebhookAuth string `json:"webhook_auth"` - Attention struct { - ErrorRatePct int `json:"error_rate_pct"` - QuotaPct int `json:"quota_pct"` - YoloUncheckedMinutes int `json:"yolo_unchecked_minutes"` - } `json:"attention"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got.WebhookURL != "https://old.example" { - t.Errorf("webhook_url = %q", got.WebhookURL) - } - // Resolved() fills zeros with defaults. - if got.Attention.ErrorRatePct == 0 { - t.Errorf("error_rate_pct = 0, want resolved default") - } - if got.Attention.QuotaPct == 0 { - t.Errorf("quota_pct = 0, want resolved default") - } -} - -func TestConfigGet_MethodNotAllowed(t *testing.T) { - dir := t.TempDir() - cfgPath := seedConfig(t, dir) - h := ConfigGet(cfgPath) - for _, m := range []string{http.MethodPost, http.MethodPatch, http.MethodDelete} { - t.Run(m, func(t *testing.T) { - rec := httptest.NewRecorder() - req := httptest.NewRequest(m, "/api/config", nil) - h(rec, req) - if rec.Code != http.StatusMethodNotAllowed { - t.Errorf("status = %d, want 405", rec.Code) - } - }) - } -} diff --git a/internal/serve/api/cost.go b/internal/serve/api/cost.go deleted file mode 100644 index e6ad27f..0000000 --- a/internal/serve/api/cost.go +++ /dev/null @@ -1,149 +0,0 @@ -// Package api — V13 /api/cost handler. -// -// Returns a window of cost_points plus totals so the dashboard can -// render a cumulative cost chart that survives daemon restarts. -// -// Required server.go wiring (coordinator owns this): -// -// mux.Handle("GET /api/cost", authHF(api.Cost(s.cost))) -// -// s.cost must satisfy api.CostSource (see below). The production -// implementation is store.CostStore — use a direct assignment; the -// interfaces match by duck-typing (Range + Totals). -package api - -import ( - "encoding/json" - "net/http" - "time" -) - -// CostSource is the subset of store.CostStore the Cost handler depends -// on. Accepting an interface keeps this package decoupled from the -// store package (mirrors the QuotaSource / LogsUsage patterns) and -// lets tests swap in a fake without opening a SQLite file. -type CostSource interface { - Range(session string, since, until time.Time) ([]CostPoint, error) - Totals(since time.Time) (CostTotals, error) -} - -// CostPoint mirrors store.Point in wire form. Exported so the -// server.go adapter can type-assert cleanly. -type CostPoint struct { - TS time.Time - Session string - InputTokens int64 - OutputTokens int64 - CacheTokens int64 - CostUSDMicros int64 -} - -// CostTotals mirrors store.Totals for the same decoupling reason. -type CostTotals struct { - InputTokens int64 - OutputTokens int64 - CacheTokens int64 - CostUSDMicros int64 -} - -// windowDurations maps the accepted ?window= values to a since-cutoff. -// Unknown values are rejected with 400 so the UI can't silently show -// an empty chart because of a typo. -var windowDurations = map[string]time.Duration{ - "hour": time.Hour, - "day": 24 * time.Hour, - "week": 7 * 24 * time.Hour, -} - -type costPointJSON struct { - TS string `json:"ts"` - Session string `json:"session"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CacheTokens int64 `json:"cache_tokens"` - CostUSDMicros int64 `json:"cost_usd_micros"` -} - -type costTotalsJSON struct { - Input int64 `json:"input"` - Output int64 `json:"output"` - Cache int64 `json:"cache"` - CostUSDMicros int64 `json:"cost_usd_micros"` -} - -type costResponse struct { - Window string `json:"window"` - Points []costPointJSON `json:"points"` - Totals costTotalsJSON `json:"totals"` -} - -// Cost returns the GET /api/cost handler. -// -// ?session= — optional; omitted = aggregate across all sessions -// ?window=hour|day|week — default day; unknown = 400 -func Cost(src CostSource) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - w.Header().Set("Allow", http.MethodGet) - w.Header().Set("Cache-Control", "no-store") - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("Content-Type", "application/json") - - window := r.URL.Query().Get("window") - if window == "" { - window = "day" - } - dur, ok := windowDurations[window] - if !ok { - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]any{ - "error": "unknown_window", - "hint": "window must be one of hour|day|week", - }) - return - } - - session := r.URL.Query().Get("session") - - now := time.Now().UTC() - since := now.Add(-dur) - - points, err := src.Range(session, since, now) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "range_failed"}) - return - } - totals, err := src.Totals(since) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "totals_failed"}) - return - } - - out := costResponse{ - Window: window, - Points: make([]costPointJSON, 0, len(points)), - Totals: costTotalsJSON{ - Input: totals.InputTokens, - Output: totals.OutputTokens, - Cache: totals.CacheTokens, - CostUSDMicros: totals.CostUSDMicros, - }, - } - for _, p := range points { - out.Points = append(out.Points, costPointJSON{ - TS: p.TS.UTC().Format(time.RFC3339Nano), - Session: p.Session, - InputTokens: p.InputTokens, - OutputTokens: p.OutputTokens, - CacheTokens: p.CacheTokens, - CostUSDMicros: p.CostUSDMicros, - }) - } - _ = json.NewEncoder(w).Encode(out) - } -} diff --git a/internal/serve/api/cost_test.go b/internal/serve/api/cost_test.go deleted file mode 100644 index ea676b6..0000000 --- a/internal/serve/api/cost_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -// fakeCostSource is an in-memory CostSource for handler tests. -type fakeCostSource struct { - points []CostPoint - totals CostTotals - err error -} - -func (f fakeCostSource) Range(session string, since, until time.Time) ([]CostPoint, error) { - if f.err != nil { - return nil, f.err - } - out := make([]CostPoint, 0, len(f.points)) - for _, p := range f.points { - if session != "" && p.Session != session { - continue - } - if p.TS.Before(since) || p.TS.After(until) { - continue - } - out = append(out, p) - } - return out, nil -} - -func (f fakeCostSource) Totals(since time.Time) (CostTotals, error) { - if f.err != nil { - return CostTotals{}, f.err - } - return f.totals, nil -} - -func TestCost_HappyPath(t *testing.T) { - now := time.Now().UTC() - src := fakeCostSource{ - points: []CostPoint{ - {TS: now.Add(-10 * time.Minute), Session: "alpha", InputTokens: 100, OutputTokens: 50, CacheTokens: 10, CostUSDMicros: 1200}, - {TS: now.Add(-5 * time.Minute), Session: "alpha", InputTokens: 200, OutputTokens: 100, CacheTokens: 20, CostUSDMicros: 2400}, - }, - totals: CostTotals{InputTokens: 200, OutputTokens: 100, CacheTokens: 20, CostUSDMicros: 2400}, - } - - h := Cost(src) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/cost?window=hour&session=alpha", nil) - h(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - var body costResponse - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { - t.Fatalf("decode: %v", err) - } - if body.Window != "hour" { - t.Errorf("window = %q, want hour", body.Window) - } - if len(body.Points) != 2 { - t.Fatalf("points len = %d, want 2", len(body.Points)) - } - if body.Points[0].Session != "alpha" || body.Points[0].InputTokens != 100 { - t.Errorf("points[0] = %+v", body.Points[0]) - } - if body.Totals.Input != 200 || body.Totals.CostUSDMicros != 2400 { - t.Errorf("totals = %+v", body.Totals) - } -} - -func TestCost_UnknownWindow(t *testing.T) { - h := Cost(fakeCostSource{}) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/cost?window=forever", nil) - h(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", rec.Code) - } - var body map[string]any - _ = json.Unmarshal(rec.Body.Bytes(), &body) - if body["error"] != "unknown_window" { - t.Errorf("error = %v, want unknown_window", body["error"]) - } -} - -func TestCost_DefaultWindowIsDay(t *testing.T) { - h := Cost(fakeCostSource{}) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/cost", nil) - h(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - var body costResponse - _ = json.Unmarshal(rec.Body.Bytes(), &body) - if body.Window != "day" { - t.Errorf("window = %q, want day", body.Window) - } -} - -func TestCost_MissingSessionAggregatesAcross(t *testing.T) { - now := time.Now().UTC() - src := fakeCostSource{ - points: []CostPoint{ - {TS: now.Add(-1 * time.Minute), Session: "alpha", InputTokens: 10}, - {TS: now.Add(-1 * time.Minute), Session: "beta", InputTokens: 20}, - }, - } - h := Cost(src) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/cost?window=day", nil) - h(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - var body costResponse - _ = json.Unmarshal(rec.Body.Bytes(), &body) - if len(body.Points) != 2 { - t.Fatalf("points len = %d, want 2 (both sessions)", len(body.Points)) - } -} - -func TestCost_EmptyStore(t *testing.T) { - h := Cost(fakeCostSource{}) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/cost?window=day", nil) - h(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - var body costResponse - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { - t.Fatalf("decode: %v", err) - } - if len(body.Points) != 0 { - t.Errorf("points = %+v, want []", body.Points) - } - if body.Totals.Input != 0 || body.Totals.CostUSDMicros != 0 { - t.Errorf("totals = %+v, want zero", body.Totals) - } - // Shape check: points is explicitly [], not null. JS code path - // assumes an array; null would break .map() without extra guards. - if !strings.Contains(rec.Body.String(), `"points":[]`) { - t.Errorf("body missing points:[] literal — got %s", rec.Body.String()) - } -} - -func TestCost_405OnPost(t *testing.T) { - h := Cost(fakeCostSource{}) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/cost", nil) - h(rec, req) - - if rec.Code != http.StatusMethodNotAllowed { - t.Fatalf("status = %d, want 405", rec.Code) - } - if got := rec.Header().Get("Allow"); !strings.Contains(got, "GET") { - t.Errorf("Allow header = %q, want GET", got) - } -} diff --git a/internal/serve/api/create.go b/internal/serve/api/create.go deleted file mode 100644 index c9adc5e..0000000 --- a/internal/serve/api/create.go +++ /dev/null @@ -1,138 +0,0 @@ -package api - -// V26 — POST /api/sessions. Creates a detached yolo-mode claude -// session. Spec: docs/superpowers/specs/2026-04-22-V26-create-session-design.md - -import ( - "encoding/json" - "errors" - "io/fs" - "net/http" - "os" - "path/filepath" - "regexp" - "strings" - - "github.com/RandomCodeSpace/ctm/internal/session" -) - -// CreateSpawner is the thin seam into session.Yolo. Keeping it -// behind an interface lets create_test.go exercise the handler -// without spawning real tmux + claude. -type CreateSpawner interface { - Spawn(name, workdir string) (session.Session, error) - // SendInitialPrompt fires a one-shot prompt into the new session - // after claude has had time to boot. Fire-and-forget; errors are - // logged but don't fail the create response. - SendInitialPrompt(name, text string) -} - -// CreateLookPath is the seam used for the "claude on PATH" check. -// exec.LookPath satisfies it in production. -type CreateLookPath interface { - LookPath(file string) (string, error) -} - -type createReqBody struct { - Workdir string `json:"workdir"` - Name string `json:"name,omitempty"` - InitialPrompt string `json:"initial_prompt,omitempty"` -} - -var createNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`) - -// CreateSession returns POST /api/sessions. -func CreateSession(src InputSessionSource, sp CreateSpawner, lp CreateLookPath) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.Header().Set("Allow", http.MethodPost) - writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only") - return - } - - var body createReqBody - r.Body = http.MaxBytesReader(w, r.Body, 4096) - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeInputErr(w, http.StatusBadRequest, "invalid_body", err.Error()) - return - } - body.Workdir = strings.TrimSpace(body.Workdir) - body.Name = strings.TrimSpace(body.Name) - if body.Workdir == "" { - writeInputErr(w, http.StatusBadRequest, "invalid_body", "workdir required") - return - } - - if !filepath.IsAbs(body.Workdir) { - writeInputErr(w, http.StatusBadRequest, "workdir_not_absolute", - "workdir must be an absolute path") - return - } - info, err := os.Stat(body.Workdir) - if err != nil { - if !errors.Is(err, fs.ErrNotExist) { - writeInputErr(w, http.StatusBadRequest, "bad_workdir", - "workdir stat: "+err.Error()) - return - } - // Auto-create the workdir so users can spawn sessions for - // directories that don't exist yet (fresh project scratchpad). - if mkErr := os.MkdirAll(body.Workdir, 0o755); mkErr != nil { - writeInputErr(w, http.StatusBadRequest, "bad_workdir", - "workdir mkdir: "+mkErr.Error()) - return - } - info, err = os.Stat(body.Workdir) - if err != nil { - writeInputErr(w, http.StatusInternalServerError, "bad_workdir", - "workdir stat after mkdir: "+err.Error()) - return - } - } - if !info.IsDir() { - writeInputErr(w, http.StatusBadRequest, "workdir_not_dir", - "workdir is not a directory") - return - } - if _, err := lp.LookPath("claude"); err != nil { - writeInputErr(w, http.StatusServiceUnavailable, "no_claude", - "claude CLI not found on PATH") - return - } - - name := body.Name - if name == "" { - name = filepath.Base(strings.TrimRight(body.Workdir, "/")) - } - if !createNameRe.MatchString(name) { - writeInputErr(w, http.StatusBadRequest, "bad_name", - "name must match ^[A-Za-z0-9._-]{1,64}$") - return - } - - if existing, ok := src.Get(name); ok { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusConflict) - _ = json.NewEncoder(w).Encode(map[string]any{ - "error": "name_exists", - "message": "a session named '" + name + "' already exists", - "session": existing, - }) - return - } - - sess, err := sp.Spawn(name, body.Workdir) - if err != nil { - writeInputErr(w, http.StatusInternalServerError, "spawn_failed", err.Error()) - return - } - - if prompt := strings.TrimRight(body.InitialPrompt, " \t\n\r"); prompt != "" { - sp.SendInitialPrompt(sess.Name, prompt) - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(sess) - } -} diff --git a/internal/serve/api/create_test.go b/internal/serve/api/create_test.go deleted file mode 100644 index 8431da4..0000000 --- a/internal/serve/api/create_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package api_test - -import ( - "bytes" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/RandomCodeSpace/ctm/internal/serve/api" - "github.com/RandomCodeSpace/ctm/internal/session" -) - -// ---------- fakes ---------------------------------------------------------- - -type fakeCreateProj struct{ sess map[string]session.Session } - -func (f *fakeCreateProj) Get(name string) (session.Session, bool) { - s, ok := f.sess[name] - return s, ok -} - -// TmuxAlive on this fake satisfies InputSessionSource (the handler -// reuses that interface). Not exercised by CreateSession flow itself, -// but required by the interface contract. -func (f *fakeCreateProj) TmuxAlive(name string) bool { return true } - -type fakeCreateSpawner struct { - returnSess session.Session - err error - calledWith struct{ name, workdir string } - called int - initialCalls int - initialArgs struct{ name, text string } -} - -func (f *fakeCreateSpawner) Spawn(name, workdir string) (session.Session, error) { - f.called++ - f.calledWith.name = name - f.calledWith.workdir = workdir - if f.err != nil { - return session.Session{}, f.err - } - s := f.returnSess - s.Name = name - s.Workdir = workdir - return s, nil -} - -func (f *fakeCreateSpawner) SendInitialPrompt(name, text string) { - f.initialCalls++ - f.initialArgs.name = name - f.initialArgs.text = text -} - -type fakeLookPath struct{ ok bool } - -func (f fakeLookPath) LookPath(file string) (string, error) { - if f.ok { - return "/usr/bin/" + file, nil - } - return "", errors.New("not found") -} - -// ---------- helpers -------------------------------------------------------- - -func createReq(t *testing.T, body any) *http.Request { - t.Helper() - b, err := json.Marshal(body) - if err != nil { - t.Fatalf("marshal: %v", err) - } - r := httptest.NewRequest(http.MethodPost, "/api/sessions", bytes.NewReader(b)) - r.Header.Set("Content-Type", "application/json") - return r -} - -func tempDir(t *testing.T) string { - t.Helper() - return t.TempDir() -} - -// ---------- tests ---------------------------------------------------------- - -func TestCreate_HappyPath(t *testing.T) { - dir := tempDir(t) - proj := &fakeCreateProj{sess: map[string]session.Session{}} - spawn := &fakeCreateSpawner{returnSess: session.Session{UUID: "u", Mode: "yolo"}} - h := api.CreateSession(proj, spawn, fakeLookPath{ok: true}) - - rec := httptest.NewRecorder() - h(rec, createReq(t, map[string]string{"workdir": dir})) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d (%s), want 201", rec.Code, rec.Body.String()) - } - var got session.Session - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatalf("decode body: %v", err) - } - if got.Name != filepath.Base(dir) { - t.Fatalf("name = %q, want %q", got.Name, filepath.Base(dir)) - } - if got.Workdir != dir { - t.Fatalf("workdir = %q, want %q", got.Workdir, dir) - } - if got.Mode != "yolo" { - t.Fatalf("mode = %q, want yolo", got.Mode) - } - if spawn.initialCalls != 0 { - t.Fatalf("SendInitialPrompt called %d times without initial_prompt, want 0", spawn.initialCalls) - } -} - -func TestCreate_InitialPrompt_Fires(t *testing.T) { - dir := tempDir(t) - proj := &fakeCreateProj{sess: map[string]session.Session{}} - spawn := &fakeCreateSpawner{returnSess: session.Session{UUID: "u", Mode: "yolo"}} - h := api.CreateSession(proj, spawn, fakeLookPath{ok: true}) - - rec := httptest.NewRecorder() - h(rec, createReq(t, map[string]string{"workdir": dir, "initial_prompt": "review the diff"})) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d (%s), want 201", rec.Code, rec.Body.String()) - } - if spawn.initialCalls != 1 { - t.Fatalf("SendInitialPrompt called %d times, want 1", spawn.initialCalls) - } - if spawn.initialArgs.text != "review the diff" { - t.Fatalf("prompt text = %q, want %q", spawn.initialArgs.text, "review the diff") - } - if spawn.initialArgs.name != filepath.Base(dir) { - t.Fatalf("prompt name = %q, want %q", spawn.initialArgs.name, filepath.Base(dir)) - } -} - -func TestCreate_InitialPrompt_Empty_Skips(t *testing.T) { - dir := tempDir(t) - spawn := &fakeCreateSpawner{returnSess: session.Session{UUID: "u", Mode: "yolo"}} - h := api.CreateSession(&fakeCreateProj{sess: map[string]session.Session{}}, spawn, fakeLookPath{ok: true}) - - rec := httptest.NewRecorder() - h(rec, createReq(t, map[string]string{"workdir": dir, "initial_prompt": ""})) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, want 201", rec.Code) - } - if spawn.initialCalls != 0 { - t.Fatalf("SendInitialPrompt called %d times for empty prompt, want 0", spawn.initialCalls) - } -} - -func TestCreate_InitialPrompt_WhitespaceOnly_Skips(t *testing.T) { - dir := tempDir(t) - spawn := &fakeCreateSpawner{returnSess: session.Session{UUID: "u", Mode: "yolo"}} - h := api.CreateSession(&fakeCreateProj{sess: map[string]session.Session{}}, spawn, fakeLookPath{ok: true}) - - rec := httptest.NewRecorder() - h(rec, createReq(t, map[string]string{"workdir": dir, "initial_prompt": " \n\t "})) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, want 201", rec.Code) - } - if spawn.initialCalls != 0 { - t.Fatalf("SendInitialPrompt called %d times for whitespace prompt, want 0", spawn.initialCalls) - } -} - -func TestCreate_NameOverride(t *testing.T) { - dir := tempDir(t) - proj := &fakeCreateProj{sess: map[string]session.Session{}} - spawn := &fakeCreateSpawner{returnSess: session.Session{UUID: "u", Mode: "yolo"}} - h := api.CreateSession(proj, spawn, fakeLookPath{ok: true}) - - rec := httptest.NewRecorder() - h(rec, createReq(t, map[string]string{"workdir": dir, "name": "explicit"})) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d (%s)", rec.Code, rec.Body.String()) - } - var got session.Session - _ = json.NewDecoder(rec.Body).Decode(&got) - if got.Name != "explicit" { - t.Fatalf("name = %q, want explicit", got.Name) - } -} - -func TestCreate_RelativeWorkdir(t *testing.T) { - h := api.CreateSession(&fakeCreateProj{}, &fakeCreateSpawner{}, fakeLookPath{ok: true}) - rec := httptest.NewRecorder() - h(rec, createReq(t, map[string]string{"workdir": "relative/path"})) - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", rec.Code) - } - if !strings.Contains(rec.Body.String(), "workdir_not_absolute") { - t.Fatalf("body = %q", rec.Body.String()) - } -} - -func TestCreate_UncreatableWorkdir(t *testing.T) { - // `/definitely/…` — MkdirAll can't create /definitely from a - // non-root test process, so this exercises the mkdir-failed branch. - h := api.CreateSession(&fakeCreateProj{}, &fakeCreateSpawner{}, fakeLookPath{ok: true}) - rec := httptest.NewRecorder() - h(rec, createReq(t, map[string]string{"workdir": "/definitely/not/here/xyz"})) - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", rec.Code) - } - if !strings.Contains(rec.Body.String(), "bad_workdir") { - t.Fatalf("body = %q", rec.Body.String()) - } -} - -func TestCreate_AutoCreatesMissingWorkdir(t *testing.T) { - parent := tempDir(t) - newDir := filepath.Join(parent, "newly-created") - proj := &fakeCreateProj{sess: map[string]session.Session{}} - spawn := &fakeCreateSpawner{returnSess: session.Session{UUID: "u", Mode: "yolo"}} - h := api.CreateSession(proj, spawn, fakeLookPath{ok: true}) - rec := httptest.NewRecorder() - h(rec, createReq(t, map[string]string{"workdir": newDir})) - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d (%s), want 201", rec.Code, rec.Body.String()) - } - if info, err := os.Stat(newDir); err != nil || !info.IsDir() { - t.Fatalf("workdir not auto-created: err=%v", err) - } -} - -func TestCreate_FileInsteadOfDir(t *testing.T) { - dir := tempDir(t) - f := filepath.Join(dir, "file") - _ = os.WriteFile(f, []byte("x"), 0o600) - h := api.CreateSession(&fakeCreateProj{}, &fakeCreateSpawner{}, fakeLookPath{ok: true}) - rec := httptest.NewRecorder() - h(rec, createReq(t, map[string]string{"workdir": f})) - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", rec.Code) - } - if !strings.Contains(rec.Body.String(), "workdir_not_dir") { - t.Fatalf("body = %q", rec.Body.String()) - } -} - -func TestCreate_NoClaude(t *testing.T) { - dir := tempDir(t) - h := api.CreateSession(&fakeCreateProj{}, &fakeCreateSpawner{}, fakeLookPath{ok: false}) - rec := httptest.NewRecorder() - h(rec, createReq(t, map[string]string{"workdir": dir})) - if rec.Code != http.StatusServiceUnavailable { - t.Fatalf("status = %d, want 503", rec.Code) - } - if !strings.Contains(rec.Body.String(), "no_claude") { - t.Fatalf("body = %q", rec.Body.String()) - } -} - -func TestCreate_Collision(t *testing.T) { - dir := tempDir(t) - proj := &fakeCreateProj{sess: map[string]session.Session{ - filepath.Base(dir): {Name: filepath.Base(dir), Mode: "yolo"}, - }} - spawn := &fakeCreateSpawner{} - h := api.CreateSession(proj, spawn, fakeLookPath{ok: true}) - - rec := httptest.NewRecorder() - h(rec, createReq(t, map[string]string{"workdir": dir})) - if rec.Code != http.StatusConflict { - t.Fatalf("status = %d, want 409", rec.Code) - } - var body struct { - Error string `json:"error"` - Session session.Session `json:"session"` - } - if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { - t.Fatalf("decode: %v", err) - } - if body.Error != "name_exists" { - t.Fatalf("error = %q", body.Error) - } - if body.Session.Name != filepath.Base(dir) { - t.Fatalf("existing session not surfaced: %+v", body.Session) - } - if spawn.called != 0 { - t.Fatalf("Spawn should NOT be called on collision, was called %d times", spawn.called) - } -} - -func TestCreate_EmptyBody(t *testing.T) { - h := api.CreateSession(&fakeCreateProj{}, &fakeCreateSpawner{}, fakeLookPath{ok: true}) - rec := httptest.NewRecorder() - h(rec, createReq(t, map[string]string{})) - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", rec.Code) - } -} - -func TestCreate_BadName(t *testing.T) { - dir := tempDir(t) - h := api.CreateSession(&fakeCreateProj{}, &fakeCreateSpawner{}, fakeLookPath{ok: true}) - rec := httptest.NewRecorder() - h(rec, createReq(t, map[string]string{"workdir": dir, "name": "has space"})) - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", rec.Code) - } - if !strings.Contains(rec.Body.String(), "bad_name") { - t.Fatalf("body = %q", rec.Body.String()) - } -} diff --git a/internal/serve/api/diff.go b/internal/serve/api/diff.go deleted file mode 100644 index 3640e46..0000000 --- a/internal/serve/api/diff.go +++ /dev/null @@ -1,99 +0,0 @@ -package api - -import ( - "log/slog" - "net/http" - - "github.com/RandomCodeSpace/ctm/internal/serve/git" -) - -// diffFn is the seam tests inject; production code wires git.DiffAt. -// Kept package-private — callers wire through Diff(). -var diffFn = git.DiffAt - -// Diff returns the GET handler for -// /api/sessions/{name}/checkpoints/{sha}/diff. -// -// The handler chains two guards that together prevent arbitrary -// `git show` exposure: -// -// 1. resolveWorkdir maps a session name to its workdir (false → 404). -// 2. cache.IsCheckpoint confirms sha is one of the *full* commit -// SHAs currently listed under the session's checkpoints. A SHA -// that doesn't appear — including abbreviated forms — yields 404. -// -// On success the unified diff is streamed as text/plain so the UI can -// render it in a
 without JSON envelope overhead.
-func Diff(resolveWorkdir func(name string) (string, bool), cache *CheckpointsCache) http.HandlerFunc {
-	if cache == nil {
-		cache = NewCheckpointsCache()
-	}
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodGet && r.Method != http.MethodHead {
-			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-			return
-		}
-
-		name := r.PathValue("name")
-		sha := r.PathValue("sha")
-		if name == "" {
-			http.NotFound(w, r)
-			return
-		}
-		// Cheap-first: reject obviously malformed SHA before doing any
-		// lookup work. `git show` accepts abbreviated SHAs but the
-		// checkpoint allowlist rejects them, so in practice every
-		// legitimate caller sends a 40-char hex string.
-		if !isFullSHA(sha) {
-			http.Error(w, "invalid_sha", http.StatusBadRequest)
-			return
-		}
-
-		workdir, ok := resolveWorkdir(name)
-		if !ok {
-			http.NotFound(w, r)
-			return
-		}
-
-		// Full-SHA allowlist — same cache the /checkpoints handler and
-		// the revert handler consult, so a cached list produced in the
-		// last 5 s covers this call too.
-		if !cache.IsCheckpoint(workdir, name, sha) {
-			http.NotFound(w, r)
-			return
-		}
-
-		out, err := diffFn(workdir, sha)
-		if err != nil {
-			slog.Error("checkpoint diff failed",
-				"session", name,
-				"sha", sha,
-				"err", err)
-			http.Error(w, "git_failed", http.StatusInternalServerError)
-			return
-		}
-
-		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
-		w.Header().Set("Cache-Control", "no-store")
-		_, _ = w.Write([]byte(out))
-	}
-}
-
-// isFullSHA reports whether s is exactly 40 lowercase-hex characters —
-// the canonical git SHA-1 form. Any shorter or mixed-case input is
-// rejected outright to keep the allowlist's blast radius minimal.
-func isFullSHA(s string) bool {
-	if len(s) != 40 {
-		return false
-	}
-	for i := 0; i < len(s); i++ {
-		c := s[i]
-		switch {
-		case c >= '0' && c <= '9':
-		case c >= 'a' && c <= 'f':
-		default:
-			return false
-		}
-	}
-	return true
-}
diff --git a/internal/serve/api/diff_test.go b/internal/serve/api/diff_test.go
deleted file mode 100644
index 578a56c..0000000
--- a/internal/serve/api/diff_test.go
+++ /dev/null
@@ -1,183 +0,0 @@
-package api
-
-import (
-	"io"
-	"net/http"
-	"net/http/httptest"
-	"strings"
-	"testing"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/git"
-)
-
-const diffTestSHA = "3e17c87aabbccddee0011223344556677889900a"
-
-// installDiffStubs wires the package-level seams (`checkpointsLister`
-// and `diffFn`) to deterministic in-memory doubles for a single test.
-// The prior values are restored via t.Cleanup.
-func installDiffStubs(
-	t *testing.T,
-	lister func(workdir string, limit int) ([]git.Checkpoint, error),
-	diff func(workdir, sha string) (string, error),
-) {
-	t.Helper()
-	prevL := checkpointsLister
-	prevD := diffFn
-	t.Cleanup(func() {
-		checkpointsLister = prevL
-		diffFn = prevD
-	})
-	if lister != nil {
-		checkpointsLister = lister
-	}
-	if diff != nil {
-		diffFn = diff
-	}
-}
-
-func newDiffRequest(t *testing.T, name, sha string) *http.Request {
-	t.Helper()
-	req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+name+"/checkpoints/"+sha+"/diff", nil)
-	req.SetPathValue("name", name)
-	req.SetPathValue("sha", sha)
-	return req
-}
-
-func TestDiff_HappyPathReturnsPlainText(t *testing.T) {
-	wantBody := "commit 3e17c87\ndiff --git a/x b/x\n+added line\n"
-	installDiffStubs(t,
-		func(workdir string, limit int) ([]git.Checkpoint, error) {
-			return []git.Checkpoint{{SHA: diffTestSHA, Subject: "checkpoint: pre-yolo"}}, nil
-		},
-		func(workdir, sha string) (string, error) {
-			if sha != diffTestSHA {
-				t.Errorf("diffFn called with sha = %q, want %q", sha, diffTestSHA)
-			}
-			return wantBody, nil
-		},
-	)
-
-	cache := NewCheckpointsCache()
-	h := Diff(func(name string) (string, bool) { return "/wd", true }, cache)
-
-	rec := httptest.NewRecorder()
-	h(rec, newDiffRequest(t, "sess", diffTestSHA))
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
-		t.Errorf("content-type = %q, want text/plain prefix", ct)
-	}
-	body, _ := io.ReadAll(rec.Body)
-	if string(body) != wantBody {
-		t.Errorf("body = %q, want %q", string(body), wantBody)
-	}
-}
-
-func TestDiff_MalformedSHAReturns400(t *testing.T) {
-	// No stubs needed — the 400 path short-circuits before lookup.
-	cache := NewCheckpointsCache()
-	h := Diff(func(name string) (string, bool) { return "/wd", true }, cache)
-
-	cases := []string{
-		"",
-		"deadbeef",                                 // too short
-		"DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", // uppercase hex
-		"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", // non-hex
-		diffTestSHA + "a",                          // too long
-	}
-	for _, sha := range cases {
-		rec := httptest.NewRecorder()
-		h(rec, newDiffRequest(t, "sess", sha))
-		if rec.Code != http.StatusBadRequest {
-			t.Errorf("sha=%q: status = %d, want 400", sha, rec.Code)
-		}
-	}
-}
-
-func TestDiff_NonCheckpointSHAReturns404(t *testing.T) {
-	// Lister returns a *different* SHA, so the requested one is not
-	// in the allowlist and the handler must refuse.
-	otherSHA := "0011223344556677889900aabbccddeeff001122"
-	installDiffStubs(t,
-		func(workdir string, limit int) ([]git.Checkpoint, error) {
-			return []git.Checkpoint{{SHA: otherSHA, Subject: "checkpoint: x"}}, nil
-		},
-		func(workdir, sha string) (string, error) {
-			t.Fatalf("diffFn must not be called for non-checkpoint SHA")
-			return "", nil
-		},
-	)
-
-	cache := NewCheckpointsCache()
-	h := Diff(func(name string) (string, bool) { return "/wd", true }, cache)
-
-	rec := httptest.NewRecorder()
-	h(rec, newDiffRequest(t, "sess", diffTestSHA))
-	if rec.Code != http.StatusNotFound {
-		t.Errorf("status = %d, want 404", rec.Code)
-	}
-}
-
-func TestDiff_UnknownSessionReturns404(t *testing.T) {
-	// resolveWorkdir returns false → handler must 404 before touching
-	// the cache or the diff seam.
-	installDiffStubs(t,
-		func(workdir string, limit int) ([]git.Checkpoint, error) {
-			t.Fatalf("lister must not be called when session is unknown")
-			return nil, nil
-		},
-		func(workdir, sha string) (string, error) {
-			t.Fatalf("diffFn must not be called when session is unknown")
-			return "", nil
-		},
-	)
-
-	cache := NewCheckpointsCache()
-	h := Diff(func(name string) (string, bool) { return "", false }, cache)
-
-	rec := httptest.NewRecorder()
-	h(rec, newDiffRequest(t, "missing", diffTestSHA))
-	if rec.Code != http.StatusNotFound {
-		t.Errorf("status = %d, want 404", rec.Code)
-	}
-}
-
-func TestDiff_405OnPost(t *testing.T) {
-	cache := NewCheckpointsCache()
-	h := Diff(func(name string) (string, bool) { return "/wd", true }, cache)
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodPost, "/api/sessions/x/checkpoints/"+diffTestSHA+"/diff", nil)
-	req.SetPathValue("name", "x")
-	req.SetPathValue("sha", diffTestSHA)
-	h(rec, req)
-	if rec.Code != http.StatusMethodNotAllowed {
-		t.Errorf("status = %d, want 405", rec.Code)
-	}
-}
-
-func TestDiff_GitErrorReturns500(t *testing.T) {
-	installDiffStubs(t,
-		func(workdir string, limit int) ([]git.Checkpoint, error) {
-			return []git.Checkpoint{{SHA: diffTestSHA, Subject: "checkpoint: ok"}}, nil
-		},
-		func(workdir, sha string) (string, error) {
-			return "", &diffError{msg: "git show exploded"}
-		},
-	)
-
-	cache := NewCheckpointsCache()
-	h := Diff(func(name string) (string, bool) { return "/wd", true }, cache)
-
-	rec := httptest.NewRecorder()
-	h(rec, newDiffRequest(t, "sess", diffTestSHA))
-	if rec.Code != http.StatusInternalServerError {
-		t.Errorf("status = %d, want 500", rec.Code)
-	}
-}
-
-// diffError is a minimal error type for the 500-path test.
-type diffError struct{ msg string }
-
-func (e *diffError) Error() string { return e.msg }
diff --git a/internal/serve/api/doctor.go b/internal/serve/api/doctor.go
deleted file mode 100644
index 831a7ac..0000000
--- a/internal/serve/api/doctor.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package api
-
-import (
-	"context"
-	"encoding/json"
-	"net/http"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/config"
-	"github.com/RandomCodeSpace/ctm/internal/doctor"
-)
-
-// DoctorRunner is the seam the handler uses to produce a []doctor.Check.
-// Tests stub it to force arbitrary status rows; production injects a
-// function that calls doctor.Run(ctx, cfg) with the daemon's Config.
-type DoctorRunner func(ctx context.Context) []doctor.Check
-
-// DoctorDeadline caps how long the runner may spend. The CLI doctor
-// shells out to tmux and checks tmux session liveness; 5 s is ample
-// for that without letting a pathological box hang the HTTP response.
-const DoctorDeadline = 5 * time.Second
-
-// Doctor returns the GET /api/doctor handler.
-//
-// Response shape (wire contract):
-//
-//	{
-//	  "checks": [
-//	    {"name": "dep:tmux", "status": "ok", "message": "/usr/bin/tmux"},
-//	    {"name": "env:PATH", "status": "ok", "message": "set"},
-//	    {"name": "serve:token", "status": "warn", "message": "...", "remediation": "..."}
-//	  ]
-//	}
-//
-// Auth wrapping happens at server boot (see server.go).
-func Doctor(run DoctorRunner) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodGet {
-			w.Header().Set("Allow", http.MethodGet)
-			w.Header().Set("Cache-Control", "no-store")
-			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-			return
-		}
-
-		ctx, cancel := context.WithTimeout(r.Context(), DoctorDeadline)
-		defer cancel()
-
-		checks := run(ctx)
-		if checks == nil {
-			// Always emit an array so the UI can render an empty state
-			// without tripping over a JSON null.
-			checks = []doctor.Check{}
-		}
-
-		w.Header().Set("Content-Type", "application/json")
-		w.Header().Set("Cache-Control", "no-store")
-		w.WriteHeader(http.StatusOK)
-		_ = json.NewEncoder(w).Encode(struct {
-			Checks []doctor.Check `json:"checks"`
-		}{Checks: checks})
-	}
-}
-
-// DefaultDoctorRunner is the production adapter: calls doctor.Run with
-// the live Config under ctx. Kept out of Doctor() so tests can inject
-// a deterministic stub via DoctorRunner.
-func DefaultDoctorRunner(cfg config.Config) DoctorRunner {
-	return func(ctx context.Context) []doctor.Check {
-		return doctor.Run(ctx, cfg)
-	}
-}
diff --git a/internal/serve/api/doctor_test.go b/internal/serve/api/doctor_test.go
deleted file mode 100644
index cffcf29..0000000
--- a/internal/serve/api/doctor_test.go
+++ /dev/null
@@ -1,137 +0,0 @@
-package api
-
-import (
-	"context"
-	"encoding/json"
-	"io"
-	"net/http"
-	"net/http/httptest"
-	"testing"
-
-	"github.com/RandomCodeSpace/ctm/internal/doctor"
-	"github.com/RandomCodeSpace/ctm/internal/serve/auth"
-)
-
-func TestDoctor_ResponseShape(t *testing.T) {
-	stub := func(_ context.Context) []doctor.Check {
-		return []doctor.Check{
-			{Name: "dep:tmux", Status: doctor.StatusOK, Message: "/usr/bin/tmux"},
-			{Name: "env:PATH", Status: doctor.StatusWarn, Message: "short", Remediation: "set PATH"},
-			{Name: "serve:token", Status: doctor.StatusErr, Message: "missing", Remediation: "run ctm doctor"},
-		}
-	}
-	h := Doctor(stub)
-	req := httptest.NewRequest(http.MethodGet, "/api/doctor", nil)
-	rec := httptest.NewRecorder()
-	h.ServeHTTP(rec, req)
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	if got := rec.Header().Get("Content-Type"); got != "application/json" {
-		t.Errorf("Content-Type = %q", got)
-	}
-	if got := rec.Header().Get("Cache-Control"); got != "no-store" {
-		t.Errorf("Cache-Control = %q", got)
-	}
-
-	var got struct {
-		Checks []doctor.Check `json:"checks"`
-	}
-	body, _ := io.ReadAll(rec.Body)
-	if err := json.Unmarshal(body, &got); err != nil {
-		t.Fatalf("unmarshal: %v (body=%s)", err, body)
-	}
-	if len(got.Checks) != 3 {
-		t.Fatalf("len(checks) = %d, want 3 (body=%s)", len(got.Checks), body)
-	}
-	if got.Checks[0].Status != doctor.StatusOK ||
-		got.Checks[1].Status != doctor.StatusWarn ||
-		got.Checks[2].Status != doctor.StatusErr {
-		t.Errorf("status ordering mismatch: %+v", got.Checks)
-	}
-
-	// Remediation should only appear on rows that set it.
-	var raw struct {
-		Checks []map[string]any `json:"checks"`
-	}
-	_ = json.Unmarshal(body, &raw)
-	if _, ok := raw.Checks[0]["remediation"]; ok {
-		t.Errorf("remediation should be omitted on ok row: %s", body)
-	}
-	if raw.Checks[1]["remediation"] != "set PATH" {
-		t.Errorf("remediation wrong on warn row: %v", raw.Checks[1])
-	}
-}
-
-func TestDoctor_NilChecksBecomesEmptyArray(t *testing.T) {
-	h := Doctor(func(_ context.Context) []doctor.Check { return nil })
-	req := httptest.NewRequest(http.MethodGet, "/api/doctor", nil)
-	rec := httptest.NewRecorder()
-	h.ServeHTTP(rec, req)
-
-	body, _ := io.ReadAll(rec.Body)
-	// Must be {"checks":[]} not {"checks":null} — UI relies on this.
-	var raw map[string]any
-	if err := json.Unmarshal(body, &raw); err != nil {
-		t.Fatal(err)
-	}
-	arr, ok := raw["checks"].([]any)
-	if !ok {
-		t.Fatalf("checks is not an array: %s", body)
-	}
-	if len(arr) != 0 {
-		t.Errorf("want empty array, got %v", arr)
-	}
-}
-
-func TestDoctor_MethodNotAllowed(t *testing.T) {
-	h := Doctor(func(_ context.Context) []doctor.Check { return nil })
-	for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
-		req := httptest.NewRequest(m, "/api/doctor", nil)
-		rec := httptest.NewRecorder()
-		h.ServeHTTP(rec, req)
-		if rec.Code != http.StatusMethodNotAllowed {
-			t.Errorf("%s: status = %d, want 405", m, rec.Code)
-		}
-		if got := rec.Header().Get("Allow"); got != http.MethodGet {
-			t.Errorf("%s: Allow = %q, want GET", m, got)
-		}
-	}
-}
-
-// TestDoctor_AuthWrapped exercises the server-level auth requirement:
-// the handler itself doesn't check auth (that's the server's job), but
-// when mounted behind auth.Required the combined stack must reject
-// unauthenticated requests before reaching the runner.
-func TestDoctor_AuthWrapped(t *testing.T) {
-	var called bool
-	run := func(_ context.Context) []doctor.Check {
-		called = true
-		return nil
-	}
-	h := auth.Required("secret-token", Doctor(run))
-
-	// No Authorization header.
-	req := httptest.NewRequest(http.MethodGet, "/api/doctor", nil)
-	rec := httptest.NewRecorder()
-	h.ServeHTTP(rec, req)
-	if rec.Code != http.StatusUnauthorized {
-		t.Errorf("no-auth: status = %d, want 401", rec.Code)
-	}
-	if called {
-		t.Error("runner was called despite missing auth")
-	}
-
-	// Correct bearer.
-	req = httptest.NewRequest(http.MethodGet, "/api/doctor", nil)
-	req.Header.Set("Authorization", "Bearer secret-token")
-	rec = httptest.NewRecorder()
-	h.ServeHTTP(rec, req)
-	if rec.Code != http.StatusOK {
-		t.Errorf("authed: status = %d, want 200", rec.Code)
-	}
-	if !called {
-		t.Error("runner was not called despite valid auth")
-	}
-}
diff --git a/internal/serve/api/feed.go b/internal/serve/api/feed.go
deleted file mode 100644
index da35f84..0000000
--- a/internal/serve/api/feed.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"strconv"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-// FeedSource is the subset of events.Hub that the Feed handler needs.
-// Accepting an interface keeps the api package decoupled from the hub
-// for tests and avoids a circular import if events ever grows to
-// depend on api.
-type FeedSource interface {
-	Snapshot(filter string) []events.Event
-}
-
-const (
-	// defaultFeedLimit caps the REST seed so a freshly-connected
-	// browser doesn't render 500 stale rows at once.
-	defaultFeedLimit = 200
-	// maxFeedLimit bounds caller-supplied ?limit to keep a single
-	// request from paying for the full ring (ring.cap is 500).
-	maxFeedLimit = 500
-)
-
-// Feed returns the GET /api/feed handler (filter == "" → global ring)
-// or, when filter is non-empty, GET /api/sessions/{filter}/feed.
-//
-// Emits ONLY `tool_call` events. Other event types live in the same
-// ring (quota_update, attention_*, session lifecycle) but the feed is
-// a human-readable tool-call transcript — filtering here keeps the
-// contract narrow.
-//
-// Response shape: array of the same payload the SSE tool_call event
-// carries, newest-first ordering so the client does not have to
-// reverse when it appends new live events.
-//
-//	[
-//	  {"session":"ctm","tool":"Edit","input":"...","summary":"...",
-//	   "is_error":false,"ts":"2026-04-21T14:33:09Z"},
-//	  ...
-//	]
-//
-// Returns 200 with an empty array when the ring is empty — lets the
-// client distinguish "no history yet" from an error.
-func Feed(src FeedSource, filter string) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodGet {
-			w.Header().Set("Allow", http.MethodGet)
-			w.Header().Set("Cache-Control", "no-store")
-			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-			return
-		}
-
-		// Per-session filter comes from the path variable when
-		// mounted at /api/sessions/{name}/feed; otherwise "".
-		sessionFilter := filter
-		if sessionFilter == "" {
-			sessionFilter = r.PathValue("name")
-		}
-
-		limit := defaultFeedLimit
-		if q := r.URL.Query().Get("limit"); q != "" {
-			if n, err := strconv.Atoi(q); err == nil && n > 0 {
-				if n > maxFeedLimit {
-					n = maxFeedLimit
-				}
-				limit = n
-			}
-		}
-
-		all := src.Snapshot(sessionFilter)
-
-		// Walk newest → oldest, keep only tool_call payloads up to
-		// limit. Reverse order up front because the UI renders
-		// newest-first; the ring stores chronological.
-		out := make([]json.RawMessage, 0, limit)
-		for i := len(all) - 1; i >= 0 && len(out) < limit; i-- {
-			ev := all[i]
-			if ev.Type != "tool_call" {
-				continue
-			}
-			out = append(out, ev.Payload)
-		}
-
-		w.Header().Set("Content-Type", "application/json")
-		w.Header().Set("Cache-Control", "no-store")
-		w.WriteHeader(http.StatusOK)
-		_ = json.NewEncoder(w).Encode(out)
-	}
-}
diff --git a/internal/serve/api/feed_history.go b/internal/serve/api/feed_history.go
deleted file mode 100644
index e3ef49c..0000000
--- a/internal/serve/api/feed_history.go
+++ /dev/null
@@ -1,502 +0,0 @@
-// Package api — /api/sessions/{name}/feed/history (V6).
-//
-// Historical scroll past the in-memory 500-slot ring buffer. The hub's
-// per-session ring is a cache; the on-disk JSONL log is the source of
-// truth. This handler reads that log in reverse so a UI that has
-// scrolled to the oldest ring entry can fetch older events on demand.
-//
-// Mount (wired in server.go alongside the other /feed routes):
-//
-//	mux.Handle("GET /api/sessions/{name}/feed/history",
-//	    authHF(api.FeedHistory(s.logDir, logsUUIDResolver{proj: s.proj})))
-//
-// Shape (one response row per line):
-//
-//	{
-//	  "events": [
-//	    {"id":"-0", "session":"alpha", "type":"tool_call",
-//	     "ts":"2026-04-21T14:33:09Z",
-//	     "payload":{session,tool,input,summary,is_error,ts}},
-//	    ...
-//	  ],
-//	  "has_more": true
-//	}
-//
-// Contract notes:
-//   - `before` query param is REQUIRED. The cursor is the opaque
-//     `-` ID the hub assigns at Publish time; the
-//     client echoes back the oldest visible row's id.
-//   - Derived IDs use seq=0: JSONL lines don't carry hub sequence
-//     numbers, but monotonicity within this cursor window is preserved
-//     because rows come out of a single file in append-order.
-//   - Results are strictly less than `before` and returned newest-first
-//     so the UI can append directly below the ring view.
-//   - `has_more` is true when the backwards scan hit `limit` before
-//     exhausting the file — the UI shows the "Load older" button again.
-//   - Only `tool_call`-shaped lines are emitted (lines without a
-//     `tool_name` field are dropped). The on-disk log only contains
-//     tool_call payloads today, so this is effectively a schema guard.
-package api
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"io"
-	"net/http"
-	"os"
-	"path/filepath"
-	"strconv"
-	"strings"
-	"time"
-)
-
-const (
-	defaultFeedHistoryLimit = 100
-	maxFeedHistoryLimit     = 500
-	// reverseChunkSize is how many bytes we pull off the tail on each
-	// seek step when walking the file backwards. 64 KB matches the
-	// tailer's forward buffered-reader size and is big enough that a
-	// typical 200-byte JSONL line doesn't thrash across many reads.
-	reverseChunkSize = 64 << 10
-	// historyInputMax mirrors the tailer's inputSummaryMax so summaries
-	// look identical whether sourced live (SSE) or from history.
-	historyInputMax = 200
-	// jsonlExt is the per-session claude history file suffix.
-	jsonlExt = ".jsonl"
-)
-
-// feedHistoryEvent mirrors events.Event but lives here for JSON shape
-// control. Kept separate from the hub type so the wire contract is
-// documented in a single place and doesn't accidentally drift with
-// internal hub refactors.
-type feedHistoryEvent struct {
-	ID      string          `json:"id"`
-	Session string          `json:"session"`
-	Type    string          `json:"type"`
-	TS      string          `json:"ts"`
-	Payload json.RawMessage `json:"payload"`
-}
-
-type feedHistoryResponse struct {
-	Events  []feedHistoryEvent `json:"events"`
-	HasMore bool               `json:"has_more"`
-}
-
-// toolCallLinePayload is the on-the-wire payload nested inside an
-// Event.Payload for a tool_call event. Matches ingest.ToolCallPayload
-// — duplicated here so the history handler can construct the same
-// shape without taking an import dependency on internal/serve/ingest.
-type toolCallLinePayload struct {
-	Session string `json:"session"`
-	Tool    string `json:"tool"`
-	Input   string `json:"input,omitempty"`
-	Summary string `json:"summary,omitempty"`
-	IsError bool   `json:"is_error"`
-	TS      string `json:"ts"`
-}
-
-// FeedHistory returns the GET /api/sessions/{name}/feed/history handler.
-// Reads the session's .jsonl (uuid resolved via `resolver`) in
-// reverse and returns up to `limit` tool_call events strictly older
-// than `before`.
-func FeedHistory(logDir string, resolver UUIDNameResolver) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodGet {
-			w.Header().Set("Allow", http.MethodGet)
-			w.Header().Set("Cache-Control", "no-store")
-			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-			return
-		}
-
-		w.Header().Set("Content-Type", "application/json")
-		w.Header().Set("Cache-Control", "no-store")
-
-		name := r.PathValue("name")
-		if name == "" {
-			writeJSON(w, http.StatusBadRequest, errorBody{Error: "session name required"})
-			return
-		}
-
-		before := r.URL.Query().Get("before")
-		if before == "" {
-			writeJSON(w, http.StatusBadRequest, errorBody{Error: "before cursor required", Name: name})
-			return
-		}
-
-		limit := defaultFeedHistoryLimit
-		if q := r.URL.Query().Get("limit"); q != "" {
-			if n, err := strconv.Atoi(q); err == nil && n > 0 {
-				if n > maxFeedHistoryLimit {
-					n = maxFeedHistoryLimit
-				}
-				limit = n
-			}
-		}
-
-		// Resolve session name → log UUID. The logs_usage resolver
-		// maps UUID → name; we need the reverse. Walk logDir asking
-		// the resolver for each UUID — bounded by the existing
-		// max-files gate (10_000) and only invoked on explicit user
-		// click, so no cache is necessary.
-		uuid, ok := resolveNameToUUID(resolver, logDir, name)
-		if !ok {
-			writeJSON(w, http.StatusNotFound, errorBody{Error: "session not found", Name: name})
-			return
-		}
-
-		path := filepath.Join(logDir, uuid+jsonlExt)
-		events, hasMore, err := readJSONLReverse(path, name, before, limit)
-		if err != nil {
-			if errors.Is(err, os.ErrNotExist) {
-				// Log for the session doesn't exist yet (fresh session,
-				// no tool calls recorded). Return an empty 200 rather
-				// than 404 — the session is known to the projection.
-				writeJSON(w, http.StatusOK, feedHistoryResponse{
-					Events:  []feedHistoryEvent{},
-					HasMore: false,
-				})
-				return
-			}
-			writeJSON(w, http.StatusInternalServerError, errorBody{Error: "read failed", Name: name})
-			return
-		}
-		writeJSON(w, http.StatusOK, feedHistoryResponse{
-			Events:  events,
-			HasMore: hasMore,
-		})
-	}
-}
-
-// nameToUUIDResolver is the optional direct name→uuid lookup. When a
-// UUIDNameResolver also implements this, resolveNameToUUID consults it
-// first so the authoritative sessions.json mapping wins over the log-
-// directory scan. Without this, sessions that had an older claude
-// session_id (a dead log file still sitting in logDir) would race with
-// the live one and could shadow it when filenames sort before the live
-// UUID. See resolveNameToUUID below.
-type nameToUUIDResolver interface {
-	ResolveName(name string) (uuid string, ok bool)
-}
-
-// resolveNameToUUID returns the log UUID for a human session name.
-//
-// Order of resolution:
-//  1. If resolver implements nameToUUIDResolver (production: the
-//     projection-backed logsUUIDResolver), use that directly. This is
-//     the authoritative path and handles the multi-historical-log-
-//     file case where a session has cycled through several claude
-//     session_ids.
-//  2. Fallback: scan logDir for *.jsonl files and reverse-map each
-//     via ResolveUUID. Preserves behaviour for orphan UUIDs whose
-//     session isn't in the projection (tests, migration, manual
-//     overrides).
-func resolveNameToUUID(resolver UUIDNameResolver, logDir, name string) (string, bool) {
-	if resolver == nil {
-		return "", false
-	}
-	if nr, ok := resolver.(nameToUUIDResolver); ok {
-		if uuid, ok := nr.ResolveName(name); ok {
-			return uuid, true
-		}
-	}
-	entries, err := os.ReadDir(logDir)
-	if err != nil {
-		return "", false
-	}
-	for _, e := range entries {
-		if e.IsDir() {
-			continue
-		}
-		fn := e.Name()
-		if !strings.HasSuffix(fn, jsonlExt) {
-			continue
-		}
-		uuid := strings.TrimSuffix(fn, jsonlExt)
-		if got, ok := resolver.ResolveUUID(uuid); ok && got == name {
-			return uuid, true
-		}
-	}
-	return "", false
-}
-
-// readJSONLReverse seeks to EOF and reads `reverseChunkSize` bytes at
-// a time going backwards, splitting on '\n' and parsing each complete
-// line as a tool_call. Returns up to `limit` events strictly less
-// than `before`, newest-first. hasMore is true when the scan stopped
-// because it hit `limit` (i.e. older rows may still exist below the
-// returned window).
-func readJSONLReverse(path, sessionName, before string, limit int) ([]feedHistoryEvent, bool, error) {
-	fh, err := os.Open(path)
-	if err != nil {
-		return nil, false, err
-	}
-	defer fh.Close()
-
-	info, err := fh.Stat()
-	if err != nil {
-		return nil, false, err
-	}
-	size := info.Size()
-
-	out := make([]feedHistoryEvent, 0, limit)
-	// `tail` is the partial leading fragment carried over from the
-	// previous (newer) chunk — its bytes belong to a line whose '\n'
-	// terminator hasn't been seen yet. Gets reset every time we find
-	// a newline that splits a chunk cleanly.
-	var tail []byte
-	offset := size
-	// hasMore is flipped true as soon as we would have appended the
-	// (limit+1)-th event. Keeps the signal precise: we definitively
-	// skipped at least one older eligible row, and the caller should
-	// show the "Load older" button again.
-	hasMore := false
-
-	for offset > 0 && len(out) < limit {
-		readSize := int64(reverseChunkSize)
-		if readSize > offset {
-			readSize = offset
-		}
-		offset -= readSize
-		buf := make([]byte, readSize)
-		if _, err := fh.ReadAt(buf, offset); err != nil && !errors.Is(err, io.EOF) {
-			return nil, false, err
-		}
-
-		// Combine this chunk with any partial head carried over. The
-		// partial head originally sat in the newer chunk but was
-		// missing its leading '\n'; now that we've pulled the older
-		// bytes, we can stitch them together.
-		combined := make([]byte, 0, len(buf)+len(tail))
-		combined = append(combined, buf...)
-		combined = append(combined, tail...)
-
-		// If we're not at file-start, the first byte of `combined` is
-		// mid-line (the line's start lies further back, in an older
-		// chunk we haven't read yet). Stash everything up to the first
-		// '\n' as the new tail; emit lines strictly after that first
-		// '\n'. At file-start we emit from byte 0.
-		var emitFrom int
-		if offset > 0 {
-			nl := bytes.IndexByte(combined, '\n')
-			if nl < 0 {
-				// No newline in this chunk — entire chunk is a partial
-				// line. Keep walking backwards with the accumulated
-				// fragment so the caller can see it once we reach a
-				// '\n' (or the start of file).
-				tail = combined
-				continue
-			}
-			tail = combined[:nl]
-			emitFrom = nl + 1
-		} else {
-			tail = nil
-			emitFrom = 0
-		}
-
-		// Split emittable region on '\n' and walk newest → oldest.
-		region := combined[emitFrom:]
-		lines := bytes.Split(region, []byte{'\n'})
-		for i := len(lines) - 1; i >= 0; i-- {
-			line := bytes.TrimRight(lines[i], "\r")
-			if len(line) == 0 {
-				continue
-			}
-			ev, ok := synthEvent(sessionName, line)
-			if !ok {
-				continue
-			}
-			if !idLessThanExt(ev.ID, before) {
-				continue
-			}
-			if len(out) >= limit {
-				// We have an (limit+1)-th eligible row in hand — the
-				// caller needs to know older content exists so it can
-				// render "Load older" again. Bail out of both loops.
-				hasMore = true
-				break
-			}
-			out = append(out, ev)
-		}
-		if hasMore {
-			break
-		}
-	}
-
-	// At offset == 0 the residual `tail` holds the file's first line
-	// (or is empty if the file begins with '\n'). Emit if there's
-	// still budget; otherwise consult it for the has_more signal.
-	if !hasMore && len(tail) > 0 {
-		line := bytes.TrimRight(tail, "\r")
-		if len(line) > 0 {
-			if ev, ok := synthEvent(sessionName, line); ok && idLessThanExt(ev.ID, before) {
-				if len(out) < limit {
-					out = append(out, ev)
-				} else {
-					hasMore = true
-				}
-			}
-		}
-	}
-
-	return out, hasMore, nil
-}
-
-// synthEvent parses one raw JSONL hook line and synthesises an Event
-// envelope equivalent to what the hub would emit live. The derived id
-// (-0) uses the line's `ctm_timestamp` for monotonicity
-// within the cursor window.
-func synthEvent(sessionName string, line []byte) (feedHistoryEvent, bool) {
-	var raw map[string]any
-	if err := json.Unmarshal(line, &raw); err != nil {
-		return feedHistoryEvent{}, false
-	}
-
-	tool, _ := raw["tool_name"].(string)
-	if tool == "" {
-		// Not a tool_call line (e.g. future event types sharing the
-		// log would be filtered out here).
-		return feedHistoryEvent{}, false
-	}
-
-	ts := extractTS(raw)
-	nanos := ts.UnixNano()
-
-	p := toolCallLinePayload{
-		Session: sessionName,
-		Tool:    tool,
-		Input:   summariseHistoryInput(raw, tool),
-		Summary: summariseHistoryResponse(raw),
-		IsError: nestedBool(raw, "tool_response", "is_error"),
-		TS:      ts.Format(time.RFC3339),
-	}
-	body, err := json.Marshal(p)
-	if err != nil {
-		return feedHistoryEvent{}, false
-	}
-	return feedHistoryEvent{
-		ID:      strconv.FormatInt(nanos, 10) + "-0",
-		Session: sessionName,
-		Type:    "tool_call",
-		TS:      p.TS,
-		Payload: body,
-	}, true
-}
-
-// extractTS prefers the `ctm_timestamp` field (RFC3339) already written
-// by cmd/log-tool-use; falls back to zero time (id becomes 0-0) when
-// absent. Equivalent to tailer_parse.parseTimestamp minus the now()
-// fallback — for history, now() would produce a monotonically
-// increasing id that breaks cursor ordering.
-func extractTS(raw map[string]any) time.Time {
-	if s, ok := raw["ctm_timestamp"].(string); ok {
-		if t, err := time.Parse(time.RFC3339, s); err == nil {
-			return t.UTC()
-		}
-		if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
-			return t.UTC()
-		}
-	}
-	return time.Time{}
-}
-
-func nestedBool(m map[string]any, path ...string) bool {
-	cur := any(m)
-	for _, k := range path {
-		mp, ok := cur.(map[string]any)
-		if !ok {
-			return false
-		}
-		cur, ok = mp[k]
-		if !ok {
-			return false
-		}
-	}
-	b, _ := cur.(bool)
-	return b
-}
-
-// summariseHistoryInput mirrors tailer_parse.summariseInput — kept as
-// a duplicate here to avoid importing internal/serve/ingest (would add
-// a new direction to the dep graph).
-func summariseHistoryInput(raw map[string]any, tool string) string {
-	in, ok := raw["tool_input"].(map[string]any)
-	if !ok {
-		return ""
-	}
-	if v, ok := truncateToolInputField(tool, in); ok {
-		return v
-	}
-	body, err := json.Marshal(in)
-	if err != nil {
-		return ""
-	}
-	return truncateHistory(string(body))
-}
-
-// summariseHistoryResponse mirrors tailer_parse.summariseResponse.
-func summariseHistoryResponse(raw map[string]any) string {
-	resp, ok := raw["tool_response"]
-	if !ok {
-		return ""
-	}
-	switch r := resp.(type) {
-	case string:
-		return truncateHistory(r)
-	case map[string]any:
-		if v, ok := r["output"].(string); ok {
-			if i := strings.IndexByte(v, '\n'); i >= 0 {
-				v = v[:i]
-			}
-			return truncateHistory(v)
-		}
-		if isErr, _ := r["is_error"].(bool); isErr {
-			if msg, ok := r["error"].(string); ok {
-				return truncateHistory(msg)
-			}
-			return "error"
-		}
-		keys := make([]string, 0, len(r))
-		for k := range r {
-			keys = append(keys, k)
-		}
-		if len(keys) == 0 {
-			return ""
-		}
-		return truncateHistory("[" + strings.Join(keys, " ") + "]")
-	}
-	return ""
-}
-
-func truncateHistory(s string) string {
-	s = strings.TrimSpace(s)
-	if len(s) <= historyInputMax {
-		return s
-	}
-	return s[:historyInputMax-3] + "…"
-}
-
-// idLessThanExt is a duplicate of events.idLessThan, inlined here so
-// the api package stays event-agnostic (importing events would couple
-// the http layer to the in-process pub-sub). If the hub's id scheme
-// ever changes, both copies need to move — flagged in the hub.go
-// comment already.
-func idLessThanExt(a, b string) bool {
-	an, as := splitIDExt(a)
-	bn, bs := splitIDExt(b)
-	if an != bn {
-		return an < bn
-	}
-	return as < bs
-}
-
-func splitIDExt(id string) (int64, uint64) {
-	for i := 0; i < len(id); i++ {
-		if id[i] == '-' {
-			ns, _ := strconv.ParseInt(id[:i], 10, 64)
-			seq, _ := strconv.ParseUint(id[i+1:], 10, 64)
-			return ns, seq
-		}
-	}
-	return 0, 0
-}
diff --git a/internal/serve/api/feed_history_extra_test.go b/internal/serve/api/feed_history_extra_test.go
deleted file mode 100644
index 7a78866..0000000
--- a/internal/serve/api/feed_history_extra_test.go
+++ /dev/null
@@ -1,323 +0,0 @@
-package api
-
-import (
-	"strconv"
-	"testing"
-	"time"
-)
-
-// TestExtractTS_RFC3339 covers the RFC3339 (seconds-precision) branch.
-func TestExtractTS_RFC3339(t *testing.T) {
-	want := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
-	got := extractTS(map[string]any{
-		"ctm_timestamp": want.Format(time.RFC3339),
-	})
-	if !got.Equal(want) {
-		t.Errorf("extractTS RFC3339 = %v, want %v", got, want)
-	}
-}
-
-// TestExtractTS_RFC3339Nano covers the nano-precision fallback branch.
-func TestExtractTS_RFC3339Nano(t *testing.T) {
-	want := time.Date(2026, 4, 21, 12, 0, 0, 123456789, time.UTC)
-	got := extractTS(map[string]any{
-		"ctm_timestamp": want.Format(time.RFC3339Nano),
-	})
-	if !got.Equal(want) {
-		t.Errorf("extractTS RFC3339Nano = %v, want %v", got, want)
-	}
-}
-
-// TestExtractTS_Missing covers the "no ctm_timestamp" → zero time branch.
-func TestExtractTS_Missing(t *testing.T) {
-	got := extractTS(map[string]any{"other_field": "abc"})
-	if !got.IsZero() {
-		t.Errorf("extractTS missing = %v, want zero", got)
-	}
-}
-
-// TestExtractTS_WrongType covers the type-assertion-failure branch
-// (ctm_timestamp present but not a string).
-func TestExtractTS_WrongType(t *testing.T) {
-	got := extractTS(map[string]any{"ctm_timestamp": 12345})
-	if !got.IsZero() {
-		t.Errorf("extractTS wrong-type = %v, want zero", got)
-	}
-}
-
-// TestExtractTS_BadFormat covers the "string but unparseable" branch:
-// neither RFC3339 nor RFC3339Nano accepts → zero time.
-func TestExtractTS_BadFormat(t *testing.T) {
-	got := extractTS(map[string]any{"ctm_timestamp": "not-a-timestamp"})
-	if !got.IsZero() {
-		t.Errorf("extractTS bad-format = %v, want zero", got)
-	}
-}
-
-// TestNestedBool covers all branches of nestedBool: missing top key,
-// non-map intermediate, missing leaf, leaf-not-bool, leaf-true.
-func TestNestedBool(t *testing.T) {
-	m := map[string]any{
-		"a": map[string]any{
-			"b": map[string]any{
-				"isit": true,
-			},
-			"scalar": "x",
-		},
-	}
-
-	if !nestedBool(m, "a", "b", "isit") {
-		t.Errorf("nestedBool(a.b.isit) = false, want true")
-	}
-	// missing top key
-	if nestedBool(m, "missing", "x") {
-		t.Errorf("nestedBool(missing.x) = true, want false")
-	}
-	// intermediate is not a map
-	if nestedBool(m, "a", "scalar", "deeper") {
-		t.Errorf("nestedBool(a.scalar.deeper) = true, want false")
-	}
-	// leaf missing
-	if nestedBool(m, "a", "b", "missing") {
-		t.Errorf("nestedBool(a.b.missing) = true, want false")
-	}
-	// leaf present but wrong type
-	m2 := map[string]any{"flag": "true-as-string"}
-	if nestedBool(m2, "flag") {
-		t.Errorf("nestedBool wrong-type = true, want false")
-	}
-	// no path: returns whether root coerces to bool — root is map → false
-	if nestedBool(m) {
-		t.Errorf("nestedBool empty path on map = true, want false")
-	}
-}
-
-// TestSummariseHistoryInput_NoToolInput exercises the "tool_input
-// missing or wrong type" early return.
-func TestSummariseHistoryInput_NoToolInput(t *testing.T) {
-	if got := summariseHistoryInput(map[string]any{}, "Bash"); got != "" {
-		t.Errorf("summariseHistoryInput no input = %q, want \"\"", got)
-	}
-	if got := summariseHistoryInput(map[string]any{"tool_input": "not-a-map"}, "Bash"); got != "" {
-		t.Errorf("summariseHistoryInput wrong-type = %q, want \"\"", got)
-	}
-}
-
-// TestSummariseHistoryInput_KnownToolPath returns the well-known
-// primary input field via truncateToolInputField.
-func TestSummariseHistoryInput_KnownToolPath(t *testing.T) {
-	raw := map[string]any{
-		"tool_input": map[string]any{
-			"command": "echo hello",
-		},
-	}
-	if got := summariseHistoryInput(raw, "Bash"); got != "echo hello" {
-		t.Errorf("summariseHistoryInput Bash = %q, want \"echo hello\"", got)
-	}
-}
-
-// TestSummariseHistoryInput_FallbackJSON exercises the json.Marshal
-// fallback when the tool isn't well-known.
-func TestSummariseHistoryInput_FallbackJSON(t *testing.T) {
-	raw := map[string]any{
-		"tool_input": map[string]any{
-			"foo": "bar",
-		},
-	}
-	got := summariseHistoryInput(raw, "UnknownTool")
-	// Marshaled JSON should round-trip back something containing the key.
-	if got == "" {
-		t.Errorf("summariseHistoryInput fallback = \"\", want non-empty JSON")
-	}
-}
-
-// TestSummariseHistoryResponse covers each switch arm of the response
-// summariser: missing, string, map.output, map.is_error+error,
-// map.is_error+no-error, map with arbitrary keys, empty map, and the
-// "wrong type" default-fall-through.
-func TestSummariseHistoryResponse(t *testing.T) {
-	t.Run("missing key", func(t *testing.T) {
-		if got := summariseHistoryResponse(map[string]any{}); got != "" {
-			t.Errorf("missing = %q, want \"\"", got)
-		}
-	})
-	t.Run("string response", func(t *testing.T) {
-		raw := map[string]any{"tool_response": "ok"}
-		if got := summariseHistoryResponse(raw); got != "ok" {
-			t.Errorf("string = %q, want \"ok\"", got)
-		}
-	})
-	t.Run("string response truncated", func(t *testing.T) {
-		long := make([]byte, historyInputMax+50)
-		for i := range long {
-			long[i] = 'x'
-		}
-		raw := map[string]any{"tool_response": string(long)}
-		got := summariseHistoryResponse(raw)
-		if len(got) == 0 || len(got) > historyInputMax {
-			t.Errorf("string truncated len=%d, want <= %d and > 0", len(got), historyInputMax)
-		}
-	})
-	t.Run("map output single line", func(t *testing.T) {
-		raw := map[string]any{"tool_response": map[string]any{"output": "hello"}}
-		if got := summariseHistoryResponse(raw); got != "hello" {
-			t.Errorf("map.output single-line = %q, want \"hello\"", got)
-		}
-	})
-	t.Run("map output multi-line takes first line", func(t *testing.T) {
-		raw := map[string]any{"tool_response": map[string]any{"output": "first\nsecond\nthird"}}
-		if got := summariseHistoryResponse(raw); got != "first" {
-			t.Errorf("map.output multiline = %q, want \"first\"", got)
-		}
-	})
-	t.Run("map is_error with message", func(t *testing.T) {
-		raw := map[string]any{
-			"tool_response": map[string]any{
-				"is_error": true,
-				"error":    "boom",
-			},
-		}
-		if got := summariseHistoryResponse(raw); got != "boom" {
-			t.Errorf("map is_error+error = %q, want \"boom\"", got)
-		}
-	})
-	t.Run("map is_error with no message", func(t *testing.T) {
-		raw := map[string]any{
-			"tool_response": map[string]any{
-				"is_error": true,
-			},
-		}
-		if got := summariseHistoryResponse(raw); got != "error" {
-			t.Errorf("map is_error+no-error = %q, want \"error\"", got)
-		}
-	})
-	t.Run("map empty falls through to keys empty", func(t *testing.T) {
-		raw := map[string]any{"tool_response": map[string]any{}}
-		if got := summariseHistoryResponse(raw); got != "" {
-			t.Errorf("empty map = %q, want \"\"", got)
-		}
-	})
-	t.Run("map arbitrary keys → bracketed list", func(t *testing.T) {
-		raw := map[string]any{
-			"tool_response": map[string]any{
-				"foo": "x",
-				"bar": "y",
-			},
-		}
-		got := summariseHistoryResponse(raw)
-		// Map iteration order is random, but the wrapper format is
-		// stable: starts with "[" and ends with "]".
-		if len(got) < 2 || got[0] != '[' || got[len(got)-1] != ']' {
-			t.Errorf("arbitrary keys = %q, want bracketed list", got)
-		}
-	})
-	t.Run("unsupported response type", func(t *testing.T) {
-		raw := map[string]any{"tool_response": 42}
-		if got := summariseHistoryResponse(raw); got != "" {
-			t.Errorf("unsupported = %q, want \"\"", got)
-		}
-	})
-}
-
-// TestTruncateHistory covers the trim-and-truncate helper directly.
-func TestTruncateHistory(t *testing.T) {
-	if got := truncateHistory("  hello  "); got != "hello" {
-		t.Errorf("trim only = %q, want \"hello\"", got)
-	}
-	short := "abcdef"
-	if got := truncateHistory(short); got != "abcdef" {
-		t.Errorf("short pass-through = %q, want %q", got, short)
-	}
-	long := make([]byte, historyInputMax+10)
-	for i := range long {
-		long[i] = 'x'
-	}
-	got := truncateHistory(string(long))
-	if len(got) != historyInputMax {
-		t.Errorf("truncated len = %d, want %d", len(got), historyInputMax)
-	}
-}
-
-// TestSplitIDExt and TestIDLessThanExt exercise the cursor-id parser
-// and comparator end-to-end including malformed inputs.
-func TestSplitIDExt(t *testing.T) {
-	cases := []struct {
-		id      string
-		wantNS  int64
-		wantSeq uint64
-	}{
-		{"1700000000-3", 1700000000, 3},
-		{"42-0", 42, 0},
-		{"", 0, 0},                   // no '-' → zeroes
-		{"not-a-cursor", 0, 0},       // first segment unparseable, but '-' found
-		{"123-notnum", 123, 0},       // seq unparseable
-	}
-	for _, c := range cases {
-		ns, seq := splitIDExt(c.id)
-		if ns != c.wantNS || seq != c.wantSeq {
-			t.Errorf("splitIDExt(%q) = (%d, %d), want (%d, %d)",
-				c.id, ns, seq, c.wantNS, c.wantSeq)
-		}
-	}
-}
-
-func TestIDLessThanExt(t *testing.T) {
-	// older nano → less.
-	if !idLessThanExt("100-0", "200-0") {
-		t.Error("100-0 < 200-0 should be true")
-	}
-	// equal nano → seq decides.
-	if !idLessThanExt("100-0", "100-1") {
-		t.Error("100-0 < 100-1 should be true")
-	}
-	if idLessThanExt("200-0", "100-9") {
-		t.Error("200-0 < 100-9 should be false")
-	}
-	// equal ids → not less.
-	if idLessThanExt("100-1", "100-1") {
-		t.Error("equal ids should not be less")
-	}
-}
-
-// TestSynthEvent_BadJSON covers synthEvent's "json.Unmarshal failed"
-// branch (returns ok=false).
-func TestSynthEvent_BadJSON(t *testing.T) {
-	if _, ok := synthEvent("alpha", []byte("not-json")); ok {
-		t.Error("synthEvent should return false on invalid JSON")
-	}
-}
-
-// TestSynthEvent_NoToolName covers the "tool_name missing" early return.
-func TestSynthEvent_NoToolName(t *testing.T) {
-	line := []byte(`{"foo":"bar"}`)
-	if _, ok := synthEvent("alpha", line); ok {
-		t.Error("synthEvent should return false when tool_name is missing")
-	}
-}
-
-// TestSynthEvent_Happy verifies the synthesised envelope: id is
-// derived from ctm_timestamp, type is tool_call, payload contains the
-// session+tool.
-func TestSynthEvent_Happy(t *testing.T) {
-	ts := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
-	line := []byte(`{
-		"tool_name":"Bash",
-		"tool_input":{"command":"echo hi"},
-		"tool_response":{"output":"hi","is_error":false},
-		"ctm_timestamp":"` + ts.Format(time.RFC3339) + `"
-	}`)
-	ev, ok := synthEvent("alpha", line)
-	if !ok {
-		t.Fatal("synthEvent returned false on valid line")
-	}
-	if ev.Session != "alpha" {
-		t.Errorf("Session = %q, want alpha", ev.Session)
-	}
-	if ev.Type != "tool_call" {
-		t.Errorf("Type = %q, want tool_call", ev.Type)
-	}
-	wantID := strconv.FormatInt(ts.UnixNano(), 10) + "-0"
-	if ev.ID != wantID {
-		t.Errorf("ID = %q, want %q", ev.ID, wantID)
-	}
-}
diff --git a/internal/serve/api/feed_history_test.go b/internal/serve/api/feed_history_test.go
deleted file mode 100644
index 728c6e0..0000000
--- a/internal/serve/api/feed_history_test.go
+++ /dev/null
@@ -1,353 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"fmt"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"path/filepath"
-	"strconv"
-	"strings"
-	"testing"
-	"time"
-)
-
-// newHistoryRequest constructs a GET /api/sessions/{name}/feed/history
-// test request with the Go 1.22 ServeMux path-value populated so the
-// handler's r.PathValue("name") call resolves.
-func newHistoryRequest(name, query string) *http.Request {
-	req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+name+"/feed/history?"+query, nil)
-	req.SetPathValue("name", name)
-	return req
-}
-
-// writeJSONLFixture writes n tool_call hook-payload lines to /.jsonl
-// with monotonically increasing ctm_timestamps starting at `base`.
-// Returns the slice of timestamps (nanos) written so tests can derive
-// the expected ids.
-func writeJSONLFixture(t *testing.T, dir, uuid string, n int, base time.Time) []int64 {
-	t.Helper()
-	path := filepath.Join(dir, uuid+".jsonl")
-	fh, err := os.Create(path)
-	if err != nil {
-		t.Fatalf("create: %v", err)
-	}
-	defer fh.Close()
-	nanos := make([]int64, 0, n)
-	for i := 0; i < n; i++ {
-		ts := base.Add(time.Duration(i) * time.Second)
-		// Force UTC + seconds precision for cursor determinism.
-		tsStr := ts.UTC().Format(time.RFC3339)
-		parsed, _ := time.Parse(time.RFC3339, tsStr)
-		nanos = append(nanos, parsed.UnixNano())
-		line := map[string]any{
-			"tool_name": "Bash",
-			"tool_input": map[string]any{
-				"command": fmt.Sprintf("echo %d", i),
-			},
-			"tool_response": map[string]any{
-				"output":   fmt.Sprintf("output-%d", i),
-				"is_error": false,
-			},
-			"ctm_timestamp": tsStr,
-		}
-		enc, _ := json.Marshal(line)
-		if _, err := fh.Write(append(enc, '\n')); err != nil {
-			t.Fatalf("write: %v", err)
-		}
-	}
-	return nanos
-}
-
-// historyResolver is a fake UUIDNameResolver pinned to a single mapping
-// so tests can drive name→uuid resolution deterministically.
-type historyResolver struct{ uuid, name string }
-
-func (h historyResolver) ResolveUUID(u string) (string, bool) {
-	if u == h.uuid {
-		return h.name, true
-	}
-	return "", false
-}
-
-// projectionResolver implements both ResolveUUID (workdir-fallback
-// semantics: every uuid reverse-maps to `name`) and ResolveName (the
-// authoritative direct lookup). Used by TestResolveNameToUUID_Prefers
-// ProjectionOverLexicalScan to reproduce the codeiq-style bug where
-// a lexically-earlier dead log file shadowed the live one.
-type projectionResolver struct {
-	liveUUID string
-	name     string
-}
-
-func (p projectionResolver) ResolveUUID(u string) (string, bool) { return p.name, true }
-func (p projectionResolver) ResolveName(n string) (string, bool) {
-	if n == p.name {
-		return p.liveUUID, true
-	}
-	return "", false
-}
-
-func TestResolveNameToUUID_PrefersProjectionOverLexicalScan(t *testing.T) {
-	// Two log files under logDir. deadUUID sorts lexically before
-	// liveUUID; both reverse-map to "codeiq" via the workdir fallback
-	// (projectionResolver.ResolveUUID returns "codeiq" for any input).
-	// Without the direct-name lookup, resolveNameToUUID would return
-	// deadUUID and callers (Subagents, Teams, FeedHistory) would open
-	// the wrong file.
-	const (
-		deadUUID = "11111111-0000-0000-0000-000000000000"
-		liveUUID = "99999999-0000-0000-0000-000000000000"
-	)
-	dir := t.TempDir()
-	for _, u := range []string{deadUUID, liveUUID} {
-		if err := os.WriteFile(filepath.Join(dir, u+".jsonl"), []byte{}, 0o600); err != nil {
-			t.Fatalf("create %s: %v", u, err)
-		}
-	}
-
-	got, ok := resolveNameToUUID(projectionResolver{liveUUID: liveUUID, name: "codeiq"}, dir, "codeiq")
-	if !ok {
-		t.Fatalf("resolveNameToUUID: ok=false, want true")
-	}
-	if got != liveUUID {
-		t.Errorf("resolveNameToUUID = %q, want %q (projection/live uuid, not the lexically-earlier dead file)", got, liveUUID)
-	}
-}
-
-func TestResolveNameToUUID_FallsBackToScanWhenNoDirectLookup(t *testing.T) {
-	// historyResolver only implements ResolveUUID — no direct name
-	// lookup — so the scan path must still work for orphan UUIDs /
-	// legacy callers.
-	const uuid = "aaaaaaaa-0000-0000-0000-000000000001"
-	dir := t.TempDir()
-	if err := os.WriteFile(filepath.Join(dir, uuid+".jsonl"), []byte{}, 0o600); err != nil {
-		t.Fatalf("create: %v", err)
-	}
-	got, ok := resolveNameToUUID(historyResolver{uuid: uuid, name: "alpha"}, dir, "alpha")
-	if !ok {
-		t.Fatalf("resolveNameToUUID: ok=false, want true")
-	}
-	if got != uuid {
-		t.Errorf("resolveNameToUUID = %q, want %q", got, uuid)
-	}
-}
-
-func TestFeedHistory_BeforeInMiddleReturnsOlder(t *testing.T) {
-	dir := t.TempDir()
-	const uuid = "aaaaaaaa-0000-0000-0000-000000000001"
-	base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
-	nanos := writeJSONLFixture(t, dir, uuid, 50, base)
-
-	h := FeedHistory(dir, historyResolver{uuid: uuid, name: "alpha"})
-
-	// Cursor = id of the 30th event (0-indexed → index 30). Expect
-	// events 0..29 returned, newest-first (29 down to 0).
-	cursor := strconv.FormatInt(nanos[30], 10) + "-0"
-	rec := httptest.NewRecorder()
-	req := newHistoryRequest("alpha", "before="+cursor+"&limit=100")
-	h(rec, req)
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200 (body=%s)", rec.Code, rec.Body.String())
-	}
-	var body feedHistoryResponse
-	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if len(body.Events) != 30 {
-		t.Fatalf("events = %d, want 30", len(body.Events))
-	}
-	// Newest-first: first returned must be event 29.
-	wantFirst := strconv.FormatInt(nanos[29], 10) + "-0"
-	if body.Events[0].ID != wantFirst {
-		t.Errorf("events[0].id = %q, want %q", body.Events[0].ID, wantFirst)
-	}
-	wantLast := strconv.FormatInt(nanos[0], 10) + "-0"
-	if body.Events[len(body.Events)-1].ID != wantLast {
-		t.Errorf("events[last].id = %q, want %q", body.Events[len(body.Events)-1].ID, wantLast)
-	}
-	if body.HasMore {
-		t.Errorf("has_more = true, want false (30 < limit 100)")
-	}
-	// Shape sanity: payload is a tool_call envelope with a command field.
-	var payload map[string]any
-	if err := json.Unmarshal(body.Events[0].Payload, &payload); err != nil {
-		t.Fatalf("payload decode: %v", err)
-	}
-	if payload["tool"] != "Bash" {
-		t.Errorf("payload.tool = %v, want Bash", payload["tool"])
-	}
-	if _, ok := payload["input"].(string); !ok {
-		t.Errorf("payload.input missing or wrong type")
-	}
-}
-
-func TestFeedHistory_BeforeOlderThanAllReturnsEmpty(t *testing.T) {
-	dir := t.TempDir()
-	const uuid = "bbbbbbbb-0000-0000-0000-000000000002"
-	base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
-	_ = writeJSONLFixture(t, dir, uuid, 10, base)
-
-	h := FeedHistory(dir, historyResolver{uuid: uuid, name: "beta"})
-
-	// Cursor older than any fixture timestamp → nothing to return.
-	old := base.Add(-1 * time.Hour).UnixNano()
-	cursor := strconv.FormatInt(old, 10) + "-0"
-	rec := httptest.NewRecorder()
-	req := newHistoryRequest("beta", "before="+cursor)
-	h(rec, req)
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var body feedHistoryResponse
-	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if len(body.Events) != 0 {
-		t.Errorf("events = %d, want 0", len(body.Events))
-	}
-	if body.HasMore {
-		t.Errorf("has_more = true, want false")
-	}
-}
-
-func TestFeedHistory_LimitAppliedAndHasMoreTrue(t *testing.T) {
-	dir := t.TempDir()
-	const uuid = "cccccccc-0000-0000-0000-000000000003"
-	base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
-	nanos := writeJSONLFixture(t, dir, uuid, 50, base)
-
-	h := FeedHistory(dir, historyResolver{uuid: uuid, name: "gamma"})
-
-	// before = id of the newest event so EVERYTHING older is in play;
-	// limit=10 forces the scan to stop early.
-	cursor := strconv.FormatInt(nanos[49], 10) + "-0"
-	rec := httptest.NewRecorder()
-	req := newHistoryRequest("gamma", "before="+cursor+"&limit=10")
-	h(rec, req)
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var body feedHistoryResponse
-	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if len(body.Events) != 10 {
-		t.Fatalf("events = %d, want 10", len(body.Events))
-	}
-	if !body.HasMore {
-		t.Errorf("has_more = false, want true (39 older rows remain)")
-	}
-	// Events newest-first: first == event 48 (one below the cursor).
-	wantFirst := strconv.FormatInt(nanos[48], 10) + "-0"
-	if body.Events[0].ID != wantFirst {
-		t.Errorf("events[0].id = %q, want %q", body.Events[0].ID, wantFirst)
-	}
-}
-
-func TestFeedHistory_MissingBefore400(t *testing.T) {
-	dir := t.TempDir()
-	const uuid = "dddddddd-0000-0000-0000-000000000004"
-	writeJSONLFixture(t, dir, uuid, 3, time.Now())
-
-	h := FeedHistory(dir, historyResolver{uuid: uuid, name: "delta"})
-	rec := httptest.NewRecorder()
-	req := newHistoryRequest("delta", "")
-	h(rec, req)
-
-	if rec.Code != http.StatusBadRequest {
-		t.Fatalf("status = %d, want 400", rec.Code)
-	}
-	if !strings.Contains(rec.Body.String(), "before") {
-		t.Errorf("body = %q, want mention of before cursor", rec.Body.String())
-	}
-}
-
-func TestFeedHistory_UnknownSession404(t *testing.T) {
-	dir := t.TempDir()
-	// No fixture file → resolver never matches.
-	h := FeedHistory(dir, historyResolver{uuid: "x", name: "exists"})
-	rec := httptest.NewRecorder()
-	req := newHistoryRequest("ghost", "before=1-0")
-	h(rec, req)
-
-	if rec.Code != http.StatusNotFound {
-		t.Fatalf("status = %d, want 404", rec.Code)
-	}
-}
-
-func TestFeedHistory_NonGET405(t *testing.T) {
-	dir := t.TempDir()
-	h := FeedHistory(dir, historyResolver{uuid: "x", name: "exists"})
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodPost, "/api/sessions/exists/feed/history?before=1-0", nil)
-	req.SetPathValue("name", "exists")
-	h(rec, req)
-	if rec.Code != http.StatusMethodNotAllowed {
-		t.Fatalf("status = %d, want 405", rec.Code)
-	}
-	if !strings.Contains(rec.Header().Get("Allow"), "GET") {
-		t.Errorf("Allow = %q, want GET", rec.Header().Get("Allow"))
-	}
-}
-
-// TestFeedHistory_SpansReverseChunkBoundary ensures the reverse reader
-// correctly stitches lines that straddle a 64 KB chunk boundary. We do
-// this by padding each line's command field so the total file is well
-// over reverseChunkSize.
-func TestFeedHistory_SpansReverseChunkBoundary(t *testing.T) {
-	dir := t.TempDir()
-	const uuid = "eeeeeeee-0000-0000-0000-000000000005"
-	path := filepath.Join(dir, uuid+".jsonl")
-	fh, err := os.Create(path)
-	if err != nil {
-		t.Fatalf("create: %v", err)
-	}
-	base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
-	pad := strings.Repeat("x", 2000)
-	nanos := make([]int64, 0, 100)
-	for i := 0; i < 100; i++ {
-		ts := base.Add(time.Duration(i) * time.Second)
-		tsStr := ts.UTC().Format(time.RFC3339)
-		parsed, _ := time.Parse(time.RFC3339, tsStr)
-		nanos = append(nanos, parsed.UnixNano())
-		line := map[string]any{
-			"tool_name":     "Bash",
-			"tool_input":    map[string]any{"command": pad + "-" + strconv.Itoa(i)},
-			"ctm_timestamp": tsStr,
-		}
-		enc, _ := json.Marshal(line)
-		fh.Write(append(enc, '\n'))
-	}
-	fh.Close()
-
-	h := FeedHistory(dir, historyResolver{uuid: uuid, name: "epsilon"})
-	cursor := strconv.FormatInt(nanos[99], 10) + "-0"
-	rec := httptest.NewRecorder()
-	req := newHistoryRequest("epsilon", "before="+cursor+"&limit=500")
-	h(rec, req)
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var body feedHistoryResponse
-	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	// Expect events 0..98 = 99 rows.
-	if len(body.Events) != 99 {
-		t.Fatalf("events = %d, want 99 (chunk-boundary stitching broken?)", len(body.Events))
-	}
-	wantFirst := strconv.FormatInt(nanos[98], 10) + "-0"
-	if body.Events[0].ID != wantFirst {
-		t.Errorf("events[0].id = %q, want %q", body.Events[0].ID, wantFirst)
-	}
-	wantLast := strconv.FormatInt(nanos[0], 10) + "-0"
-	if body.Events[len(body.Events)-1].ID != wantLast {
-		t.Errorf("events[last].id = %q, want %q", body.Events[len(body.Events)-1].ID, wantLast)
-	}
-}
diff --git a/internal/serve/api/feed_test.go b/internal/serve/api/feed_test.go
deleted file mode 100644
index 0b84900..0000000
--- a/internal/serve/api/feed_test.go
+++ /dev/null
@@ -1,201 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"testing"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-// fakeFeedSource is a minimal FeedSource backed by a static slice so we
-// can exercise Feed without standing up the real hub.
-type fakeFeedSource struct{ events []events.Event }
-
-func (f fakeFeedSource) Snapshot(filter string) []events.Event {
-	if filter == "" {
-		return f.events
-	}
-	out := make([]events.Event, 0, len(f.events))
-	for _, ev := range f.events {
-		if ev.Session == filter {
-			out = append(out, ev)
-		}
-	}
-	return out
-}
-
-func mustJSON(t *testing.T, v any) json.RawMessage {
-	t.Helper()
-	b, err := json.Marshal(v)
-	if err != nil {
-		t.Fatalf("marshal: %v", err)
-	}
-	return b
-}
-
-func TestFeed_FiltersToToolCallsOnlyAndReverses(t *testing.T) {
-	src := fakeFeedSource{events: []events.Event{
-		{Type: "tool_call", Session: "alpha", Payload: mustJSON(t, map[string]any{"n": 1})},
-		{Type: "quota_update", Session: "", Payload: mustJSON(t, map[string]any{"n": 2})},
-		{Type: "tool_call", Session: "alpha", Payload: mustJSON(t, map[string]any{"n": 3})},
-		{Type: "attention_raised", Session: "alpha", Payload: mustJSON(t, map[string]any{"n": 4})},
-		{Type: "tool_call", Session: "alpha", Payload: mustJSON(t, map[string]any{"n": 5})},
-	}}
-	h := Feed(src, "")
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodGet, "/api/feed", nil))
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	if got := rec.Header().Get("Content-Type"); got != "application/json" {
-		t.Errorf("Content-Type = %q, want application/json", got)
-	}
-
-	var got []map[string]any
-	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if len(got) != 3 {
-		t.Fatalf("got %d items, want 3 tool_calls only: %+v", len(got), got)
-	}
-	// Newest-first: original chronological order [1,3,5] reverses to [5,3,1].
-	wantNs := []float64{5, 3, 1}
-	for i, want := range wantNs {
-		if got[i]["n"] != want {
-			t.Errorf("item %d n = %v, want %v", i, got[i]["n"], want)
-		}
-	}
-}
-
-func TestFeed_EmptyRingReturnsEmptyArray(t *testing.T) {
-	h := Feed(fakeFeedSource{}, "")
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodGet, "/api/feed", nil))
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	// Body must be a JSON array literal "[]" (not "null") so the SPA
-	// can distinguish empty-but-known from "fetch failed".
-	body := rec.Body.String()
-	if body != "[]\n" && body != "[]" {
-		t.Errorf("body = %q, want %q", body, "[]")
-	}
-}
-
-func TestFeed_LimitClampedToMax(t *testing.T) {
-	// Build 600 tool_calls; expect at most maxFeedLimit (500) returned.
-	in := make([]events.Event, 0, 600)
-	for i := 0; i < 600; i++ {
-		in = append(in, events.Event{
-			Type:    "tool_call",
-			Session: "alpha",
-			Payload: mustJSON(t, map[string]any{"n": i}),
-		})
-	}
-	h := Feed(fakeFeedSource{events: in}, "")
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodGet, "/api/feed?limit=99999", nil))
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var got []map[string]any
-	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if len(got) != maxFeedLimit {
-		t.Errorf("len(got) = %d, want %d (clamped)", len(got), maxFeedLimit)
-	}
-}
-
-func TestFeed_LimitHonouredWhenSmall(t *testing.T) {
-	in := make([]events.Event, 0, 10)
-	for i := 0; i < 10; i++ {
-		in = append(in, events.Event{
-			Type:    "tool_call",
-			Session: "alpha",
-			Payload: mustJSON(t, map[string]any{"n": i}),
-		})
-	}
-	h := Feed(fakeFeedSource{events: in}, "")
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodGet, "/api/feed?limit=3", nil))
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var got []map[string]any
-	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if len(got) != 3 {
-		t.Errorf("len(got) = %d, want 3", len(got))
-	}
-	// Newest-first.
-	if got[0]["n"] != float64(9) {
-		t.Errorf("first item n = %v, want 9", got[0]["n"])
-	}
-}
-
-func TestFeed_InvalidLimitFallsBackToDefault(t *testing.T) {
-	// 250 tool_calls; ?limit=garbage → defaultFeedLimit (200).
-	in := make([]events.Event, 0, 250)
-	for i := 0; i < 250; i++ {
-		in = append(in, events.Event{
-			Type:    "tool_call",
-			Session: "alpha",
-			Payload: mustJSON(t, map[string]any{"n": i}),
-		})
-	}
-	h := Feed(fakeFeedSource{events: in}, "")
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodGet, "/api/feed?limit=banana", nil))
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var got []map[string]any
-	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if len(got) != defaultFeedLimit {
-		t.Errorf("len(got) = %d, want default %d", len(got), defaultFeedLimit)
-	}
-}
-
-func TestFeed_PerSessionFilterFromConstructor(t *testing.T) {
-	src := fakeFeedSource{events: []events.Event{
-		{Type: "tool_call", Session: "alpha", Payload: mustJSON(t, map[string]any{"who": "alpha"})},
-		{Type: "tool_call", Session: "beta", Payload: mustJSON(t, map[string]any{"who": "beta"})},
-	}}
-	h := Feed(src, "alpha")
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodGet, "/api/sessions/alpha/feed", nil))
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var got []map[string]any
-	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if len(got) != 1 || got[0]["who"] != "alpha" {
-		t.Errorf("got %+v, want only alpha", got)
-	}
-}
-
-func TestFeed_MethodNotAllowed(t *testing.T) {
-	h := Feed(fakeFeedSource{}, "")
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodPost, "/api/feed", nil))
-	if rec.Code != http.StatusMethodNotAllowed {
-		t.Errorf("status = %d, want 405", rec.Code)
-	}
-	if got := rec.Header().Get("Allow"); got != http.MethodGet {
-		t.Errorf("Allow = %q, want GET", got)
-	}
-}
diff --git a/internal/serve/api/handler_helpers.go b/internal/serve/api/handler_helpers.go
deleted file mode 100644
index 9cb0fb6..0000000
--- a/internal/serve/api/handler_helpers.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package api
-
-import (
-	"net/http"
-)
-
-// Mutation handlers share these short error messages — extracted to
-// satisfy the "no duplicated literal" rule and keep the wire shape
-// stable across kill / forget / rename / attach-url responses.
-const (
-	errMsgMissingSessionName = "missing session name"
-	errMsgSessionNotFound    = "session not found"
-)
-
-// requireSessionPreamble runs the boilerplate every /api/sessions/{name}/...
-// JSON GET handler needs: enforce GET/HEAD only, set the standard
-// Content-Type + Cache-Control headers, extract the {name} path param, and
-// reject an empty name. Returns the session name on success; on failure it
-// has already written the error response and the caller should return.
-//
-// Sonar previously flagged subagents.go, teams.go, etc. as duplicating this
-// 16-line block — lifting it removes the copy-paste while keeping each
-// handler's mode-specific logic local.
-func requireSessionPreamble(w http.ResponseWriter, r *http.Request) (name string, ok bool) {
-	if r.Method != http.MethodGet && r.Method != http.MethodHead {
-		w.Header().Set("Allow", "GET, HEAD")
-		w.Header().Set(headerCacheControl, cacheControlNoStore)
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-		return "", false
-	}
-
-	w.Header().Set(headerContentType, contentTypeJSON)
-	w.Header().Set(headerCacheControl, cacheControlNoStore)
-
-	name = r.PathValue("name")
-	if name == "" {
-		writeJSON(w, http.StatusBadRequest, errorBody{Error: "session name required"})
-		return "", false
-	}
-	return name, true
-}
-
-// truncateToolInputField returns the well-known "primary input" string for
-// the named tool — Bash → command, Edit/Write/etc. → file_path, Glob/Grep
-// → pattern, WebFetch → url, Task → description. Returns ok=false for tools
-// the switch doesn't know about so the caller can pick its own fallback
-// (the feed-history summariser JSON-marshals the whole input map; the
-// subagent label scans description/prompt/query).
-//
-// Both shortestSubagentInputLabel (subagents.go) and summariseHistoryInput
-// (feed_history.go) used to inline the same get-closure + switch — Sonar
-// reported the duplicate, this consolidates the per-tool routing.
-func truncateToolInputField(tool string, in map[string]any) (string, bool) {
-	get := func(k string) string {
-		if v, ok := in[k].(string); ok {
-			return v
-		}
-		return ""
-	}
-	switch tool {
-	case "Bash":
-		return truncateHistory(get("command")), true
-	case "Edit", "Write", "Read", "MultiEdit", "NotebookEdit":
-		return truncateHistory(get("file_path")), true
-	case "Glob", "Grep":
-		return truncateHistory(get("pattern")), true
-	case "WebFetch":
-		return truncateHistory(get("url")), true
-	case "Task":
-		return truncateHistory(get("description")), true
-	}
-	return "", false
-}
diff --git a/internal/serve/api/health.go b/internal/serve/api/health.go
deleted file mode 100644
index 029cb6b..0000000
--- a/internal/serve/api/health.go
+++ /dev/null
@@ -1,77 +0,0 @@
-// Package api holds the v0.1 HTTP handlers for ctm serve. Only the
-// liveness endpoints (/healthz, /health) live here in step 1 of the
-// design spec; sessions, hooks, revert, and bootstrap arrive in
-// subsequent steps.
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"time"
-)
-
-type healthzResponse struct {
-	Status        string  `json:"status"`
-	UptimeSeconds float64 `json:"uptime_seconds"`
-}
-
-type healthResponse struct {
-	Status        string            `json:"status"`
-	Version       string            `json:"version"`
-	UptimeSeconds float64           `json:"uptime_seconds"`
-	Components    map[string]string `json:"components"`
-	Hub           any               `json:"hub,omitempty"`
-}
-
-// Healthz returns the unauthenticated liveness endpoint. The headerName
-// (typically "X-Ctm-Serve") is set to version on every response so the
-// single-instance guard can identify a sibling daemon portably without
-// /proc//cmdline.
-func Healthz(version, headerName string, startedAt time.Time) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodGet && r.Method != http.MethodHead {
-			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-			return
-		}
-		w.Header().Set(headerName, version)
-		w.Header().Set("Content-Type", "application/json")
-		w.Header().Set("Cache-Control", "no-store")
-		_ = json.NewEncoder(w).Encode(healthzResponse{
-			Status:        "ok",
-			UptimeSeconds: time.Since(startedAt).Seconds(),
-		})
-	}
-}
-
-// HealthHubStats lets the daemon inject live hub statistics into the
-// /health response without health.go importing the events package
-// (cycle). server.go wires it via Health().
-type HealthHubStats interface {
-	Stats() any
-}
-
-// Health returns the rich, component-level health endpoint. Surfaces
-// hub stats (subscriber count, publish/drop totals, ring sizes) so
-// "is anyone subscribed to /events/all?" is observable from outside.
-func Health(version, headerName string, startedAt time.Time, hub HealthHubStats) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodGet && r.Method != http.MethodHead {
-			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-			return
-		}
-		w.Header().Set(headerName, version)
-		w.Header().Set("Content-Type", "application/json")
-		w.Header().Set("Cache-Control", "no-store")
-		var hubStats any
-		if hub != nil {
-			hubStats = hub.Stats()
-		}
-		_ = json.NewEncoder(w).Encode(healthResponse{
-			Status:        "ok",
-			Version:       version,
-			UptimeSeconds: time.Since(startedAt).Seconds(),
-			Components:    map[string]string{"http": "ok"},
-			Hub:           hubStats,
-		})
-	}
-}
diff --git a/internal/serve/api/health_test.go b/internal/serve/api/health_test.go
deleted file mode 100644
index 0ffe32f..0000000
--- a/internal/serve/api/health_test.go
+++ /dev/null
@@ -1,137 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"testing"
-	"time"
-)
-
-type fakeHubStats struct{ payload any }
-
-func (f fakeHubStats) Stats() any { return f.payload }
-
-func TestHealthz_HappyPath(t *testing.T) {
-	const hdr = "X-Ctm-Serve"
-	const ver = "0.3.7"
-	started := time.Now().Add(-2 * time.Second)
-	h := Healthz(ver, hdr, started)
-
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	if got := rec.Header().Get(hdr); got != ver {
-		t.Errorf("%s header = %q, want %q", hdr, got, ver)
-	}
-	if got := rec.Header().Get("Content-Type"); got != "application/json" {
-		t.Errorf("Content-Type = %q, want application/json", got)
-	}
-	if got := rec.Header().Get("Cache-Control"); got != "no-store" {
-		t.Errorf("Cache-Control = %q, want no-store", got)
-	}
-
-	var body struct {
-		Status        string  `json:"status"`
-		UptimeSeconds float64 `json:"uptime_seconds"`
-	}
-	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body.Status != "ok" {
-		t.Errorf("status = %q, want ok", body.Status)
-	}
-	if body.UptimeSeconds < 1.5 {
-		t.Errorf("uptime = %.2fs, want at least ~2s", body.UptimeSeconds)
-	}
-}
-
-func TestHealthz_HEADReturnsHeadersWithoutBody(t *testing.T) {
-	const hdr = "X-Ctm-Serve"
-	h := Healthz("0.3.7", hdr, time.Now())
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodHead, "/healthz", nil))
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	if rec.Header().Get(hdr) == "" {
-		t.Errorf("expected header %q to be set on HEAD", hdr)
-	}
-}
-
-func TestHealthz_MethodNotAllowed(t *testing.T) {
-	h := Healthz("0.3.7", "X-Ctm-Serve", time.Now())
-	for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
-		rec := httptest.NewRecorder()
-		h(rec, httptest.NewRequest(m, "/healthz", nil))
-		if rec.Code != http.StatusMethodNotAllowed {
-			t.Errorf("%s status = %d, want 405", m, rec.Code)
-		}
-	}
-}
-
-func TestHealth_HappyPathWithHubStats(t *testing.T) {
-	const hdr = "X-Ctm-Serve"
-	const ver = "0.3.7"
-	started := time.Now().Add(-1 * time.Second)
-	stats := fakeHubStats{payload: map[string]any{"published": float64(42), "dropped": float64(0)}}
-	h := Health(ver, hdr, started, stats)
-
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	if got := rec.Header().Get(hdr); got != ver {
-		t.Errorf("version header = %q, want %q", got, ver)
-	}
-
-	var body struct {
-		Status        string            `json:"status"`
-		Version       string            `json:"version"`
-		UptimeSeconds float64           `json:"uptime_seconds"`
-		Components    map[string]string `json:"components"`
-		Hub           map[string]any    `json:"hub"`
-	}
-	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body.Status != "ok" || body.Version != ver {
-		t.Errorf("status/version = (%q,%q), want (ok,%q)", body.Status, body.Version, ver)
-	}
-	if got := body.Components["http"]; got != "ok" {
-		t.Errorf("components[http] = %q, want ok", got)
-	}
-	if got, _ := body.Hub["published"].(float64); got != 42 {
-		t.Errorf("hub.published = %v, want 42", body.Hub["published"])
-	}
-}
-
-func TestHealth_NilHubOmitsHubField(t *testing.T) {
-	h := Health("0.3.7", "X-Ctm-Serve", time.Now(), nil)
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var body map[string]any
-	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if _, present := body["hub"]; present {
-		t.Errorf("expected 'hub' to be omitted when nil, got %v", body["hub"])
-	}
-}
-
-func TestHealth_MethodNotAllowed(t *testing.T) {
-	h := Health("0.3.7", "X-Ctm-Serve", time.Now(), nil)
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
-	if rec.Code != http.StatusMethodNotAllowed {
-		t.Errorf("status = %d, want 405", rec.Code)
-	}
-}
diff --git a/internal/serve/api/hooks.go b/internal/serve/api/hooks.go
deleted file mode 100644
index b23211a..0000000
--- a/internal/serve/api/hooks.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-	"github.com/RandomCodeSpace/ctm/internal/serve/ingest"
-)
-
-// hookEvents enumerates the lifecycle event names that ctm's session-
-// originating CLI commands POST to `/api/hooks/:event`. Anything else
-// returns 404 to keep the surface area explicit.
-var hookEvents = map[string]struct{}{
-	"session_new":      {},
-	"session_attached": {},
-	"session_killed":   {},
-	"on_yolo":          {},
-}
-
-// Hooks returns the handler for `POST /api/hooks/:event`. It accepts
-// form-encoded payloads from `proc.PostEvent` (the in-process helper
-// added in Step 7) co-located with each `fireHook` call site.
-//
-// The handler:
-//
-//   - Spawns a tailer on `session_new` (if `name` and `uuid` are present).
-//   - Stops the tailer on `session_killed`.
-//   - Republishes every event onto the hub so SSE clients see the
-//     lifecycle in their feed.
-func Hooks(mgr *ingest.TailerManager, hub *events.Hub) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodPost {
-			w.Header().Set("Allow", "POST")
-			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-			return
-		}
-		event := r.PathValue("event")
-		if _, ok := hookEvents[event]; !ok {
-			http.NotFound(w, r)
-			return
-		}
-		if err := r.ParseForm(); err != nil {
-			http.Error(w, "bad form payload", http.StatusBadRequest)
-			return
-		}
-
-		name := r.PostForm.Get("name")
-		uuid := r.PostForm.Get("uuid")
-		mode := r.PostForm.Get("mode")
-		workdir := r.PostForm.Get("workdir")
-
-		switch event {
-		case "session_new":
-			if name != "" && uuid != "" {
-				mgr.Start(r.Context(), name, uuid)
-			}
-		case "session_killed":
-			if name != "" {
-				mgr.Stop(name)
-			}
-		}
-
-		body := map[string]any{}
-		if name != "" {
-			body["name"] = name
-		}
-		if uuid != "" {
-			body["uuid"] = uuid
-		}
-		if mode != "" {
-			body["mode"] = mode
-		}
-		if workdir != "" {
-			body["workdir"] = workdir
-		}
-		if event == "session_attached" {
-			body["at"] = time.Now().UTC().Format(time.RFC3339)
-		}
-		payload, _ := json.Marshal(body)
-
-		hub.Publish(events.Event{
-			Type:    event,
-			Session: name,
-			Payload: payload,
-		})
-
-		w.WriteHeader(http.StatusNoContent)
-	}
-}
diff --git a/internal/serve/api/hooks_test.go b/internal/serve/api/hooks_test.go
deleted file mode 100644
index 95714e3..0000000
--- a/internal/serve/api/hooks_test.go
+++ /dev/null
@@ -1,148 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"net/url"
-	"strings"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-	"github.com/RandomCodeSpace/ctm/internal/serve/ingest"
-)
-
-const hooksTestUUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
-
-func newHookServer(t *testing.T, hub *events.Hub) (*httptest.Server, *ingest.TailerManager) {
-	t.Helper()
-	mgr := ingest.NewTailerManager(t.TempDir(), hub)
-	mux := http.NewServeMux()
-	mux.HandleFunc("POST /api/hooks/{event}", Hooks(mgr, hub))
-	srv := httptest.NewServer(mux)
-	t.Cleanup(srv.Close)
-	t.Cleanup(mgr.StopAll)
-	return srv, mgr
-}
-
-func postHook(t *testing.T, srv *httptest.Server, event string, form url.Values) *http.Response {
-	t.Helper()
-	resp, err := http.PostForm(srv.URL+"/api/hooks/"+event, form)
-	if err != nil {
-		t.Fatalf("post: %v", err)
-	}
-	return resp
-}
-
-func TestHooks_SessionNewSpawnsTailerAndPublishes(t *testing.T) {
-	hub := events.NewHub(0)
-	sub, _ := hub.Subscribe("", "")
-	defer sub.Close()
-
-	srv, mgr := newHookServer(t, hub)
-
-	resp := postHook(t, srv, "session_new", url.Values{
-		"name":    {"alpha"},
-		"uuid":    {hooksTestUUID},
-		"mode":    {"yolo"},
-		"workdir": {"/tmp/work"},
-	})
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusNoContent {
-		t.Fatalf("status = %d, want 204", resp.StatusCode)
-	}
-
-	select {
-	case ev := <-sub.Events():
-		if ev.Type != "session_new" {
-			t.Errorf("ev.Type = %q, want session_new", ev.Type)
-		}
-		if ev.Session != "alpha" {
-			t.Errorf("ev.Session = %q, want alpha", ev.Session)
-		}
-		var body map[string]any
-		_ = json.Unmarshal(ev.Payload, &body)
-		if body["mode"] != "yolo" || body["workdir"] != "/tmp/work" || body["uuid"] != hooksTestUUID {
-			t.Errorf("payload = %v, want mode=yolo workdir=/tmp/work uuid=%s", body, hooksTestUUID)
-		}
-	case <-time.After(time.Second):
-		t.Fatal("no session_new event published")
-	}
-
-	if got := mgr.Active(); len(got) != 1 || got[0] != "alpha" {
-		t.Errorf("Active = %v, want [alpha]", got)
-	}
-}
-
-func TestHooks_SessionKilledStopsTailer(t *testing.T) {
-	hub := events.NewHub(0)
-	srv, mgr := newHookServer(t, hub)
-
-	postHook(t, srv, "session_new", url.Values{
-		"name": {"beta"},
-		"uuid": {hooksTestUUID},
-	}).Body.Close()
-	if got := mgr.Active(); len(got) != 1 {
-		t.Fatalf("expected 1 tailer, got %v", got)
-	}
-
-	resp := postHook(t, srv, "session_killed", url.Values{"name": {"beta"}})
-	resp.Body.Close()
-
-	if got := mgr.Active(); len(got) != 0 {
-		t.Errorf("Active after kill = %v, want []", got)
-	}
-}
-
-func TestHooks_UnknownEventReturns404(t *testing.T) {
-	hub := events.NewHub(0)
-	srv, _ := newHookServer(t, hub)
-
-	resp := postHook(t, srv, "session_oops", url.Values{"name": {"x"}})
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusNotFound {
-		t.Errorf("status = %d, want 404", resp.StatusCode)
-	}
-}
-
-func TestHooks_GetReturns405(t *testing.T) {
-	hub := events.NewHub(0)
-	srv, _ := newHookServer(t, hub)
-	resp, err := http.Get(srv.URL + "/api/hooks/session_new")
-	if err != nil {
-		t.Fatalf("get: %v", err)
-	}
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusMethodNotAllowed {
-		t.Errorf("status = %d, want 405", resp.StatusCode)
-	}
-	if got := resp.Header.Get("Allow"); !strings.Contains(got, "POST") {
-		t.Errorf("Allow = %q, want contains POST", got)
-	}
-}
-
-func TestHooks_AttachedStampsTimestamp(t *testing.T) {
-	hub := events.NewHub(0)
-	sub, _ := hub.Subscribe("", "")
-	defer sub.Close()
-	srv, _ := newHookServer(t, hub)
-
-	resp := postHook(t, srv, "session_attached", url.Values{"name": {"gamma"}})
-	resp.Body.Close()
-
-	select {
-	case ev := <-sub.Events():
-		var body map[string]any
-		_ = json.Unmarshal(ev.Payload, &body)
-		at, ok := body["at"].(string)
-		if !ok || at == "" {
-			t.Errorf("missing `at` timestamp; payload = %v", body)
-		}
-		if _, err := time.Parse(time.RFC3339, at); err != nil {
-			t.Errorf("at = %q, not RFC3339: %v", at, err)
-		}
-	case <-time.After(time.Second):
-		t.Fatal("no session_attached event")
-	}
-}
diff --git a/internal/serve/api/input.go b/internal/serve/api/input.go
deleted file mode 100644
index 6b5b783..0000000
--- a/internal/serve/api/input.go
+++ /dev/null
@@ -1,179 +0,0 @@
-package api
-
-// V25 — session input: POST /api/sessions/{name}/input
-// Spec: docs/superpowers/specs/2026-04-22-V25-session-input-design.md
-
-import (
-	"encoding/json"
-	"errors"
-	"fmt"
-	"log/slog"
-	"net/http"
-	"strings"
-
-	"github.com/RandomCodeSpace/ctm/internal/session"
-)
-
-// InputSessionSource is the narrow slice of the sessions projection
-// the Input handler needs. Get returns the snapshot; TmuxAlive is
-// sourced from the attention engine / live reconcile layer (see
-// internal/serve/attention/engine.go SessionSource for prior art).
-type InputSessionSource interface {
-	Get(name string) (session.Session, bool)
-	TmuxAlive(name string) bool
-}
-
-// InputTmux is the narrow slice of *tmux.Client the Input handler needs.
-type InputTmux interface {
-	SendKeys(target, keys string) error
-	SendEnter(target string) error
-}
-
-type inputReq struct {
-	Text   string `json:"text,omitempty"`
-	Preset string `json:"preset,omitempty"`
-}
-
-const inputTextMax = 256
-
-// inputLogReject is the slog message used for every reject branch in
-// the Input handler so structured-log consumers can grep one literal.
-const inputLogReject = "input reject"
-
-var (
-	errInputBothFields = errors.New("invalid_body")
-	errInputEmpty      = errors.New("invalid_body")
-	errInputText       = errors.New("invalid_text")
-	errInputPreset     = errors.New("invalid_preset")
-)
-
-// Input returns POST /api/sessions/{name}/input. Gated on
-// mode=yolo + tmux_alive; refuses otherwise with a structured error.
-func Input(src InputSessionSource, tmux InputTmux) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		name := r.PathValue("name")
-		slog.Info("input request", "session", name, "origin", r.Header.Get("Origin"), "ua", r.Header.Get("User-Agent"))
-		if r.Method != http.MethodPost {
-			slog.Info(inputLogReject, "session", name, "reason", "method_not_allowed")
-			w.Header().Set("Allow", http.MethodPost)
-			writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only")
-			return
-		}
-		if name == "" {
-			slog.Info(inputLogReject, "reason", "missing_name")
-			writeInputErr(w, http.StatusBadRequest, "invalid_body", "missing session name")
-			return
-		}
-
-		sess, ok := src.Get(name)
-		if !ok {
-			slog.Info(inputLogReject, "session", name, "reason", "session_not_found")
-			writeInputErr(w, http.StatusNotFound, "session_not_found", "no session named "+name)
-			return
-		}
-		if sess.Mode != "yolo" {
-			slog.Info(inputLogReject, "session", name, "reason", "not_yolo", "mode", sess.Mode)
-			writeInputErr(w, http.StatusForbidden, "not_yolo",
-				"input is only available on yolo-mode sessions")
-			return
-		}
-		if !src.TmuxAlive(name) {
-			slog.Info(inputLogReject, "session", name, "reason", "tmux_dead")
-			writeInputErr(w, http.StatusConflict, "tmux_dead",
-				"session tmux has exited")
-			return
-		}
-
-		var body inputReq
-		r.Body = http.MaxBytesReader(w, r.Body, 1024)
-		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
-			slog.Info(inputLogReject, "session", name, "reason", "invalid_body", "err", err.Error())
-			writeInputErr(w, http.StatusBadRequest, "invalid_body", err.Error())
-			return
-		}
-
-		keys, herr := expandInput(body)
-		if herr != nil {
-			slog.Info(inputLogReject, "session", name, "reason", herr.Error())
-			writeInputErr(w, http.StatusBadRequest, herr.Error(), herr.Error())
-			return
-		}
-
-		target := fmt.Sprintf("%s:0.0", sess.Name)
-		// `keys` always ends with "\n" by construction (see expandInput).
-		// We split: literal text via -l, then a real tmux Enter key so
-		// claude's TUI treats it as submit rather than "add newline".
-		literal := strings.TrimRight(keys, "\n")
-		if literal != "" {
-			if err := tmux.SendKeys(target, literal); err != nil {
-				slog.Error("input send_failed", "session", name, "err", err.Error())
-				writeInputErr(w, http.StatusInternalServerError, "send_failed", err.Error())
-				return
-			}
-		}
-		if err := tmux.SendEnter(target); err != nil {
-			slog.Error("input send_failed", "session", name, "err", err.Error())
-			writeInputErr(w, http.StatusInternalServerError, "send_failed", err.Error())
-			return
-		}
-		slog.Info("input ok", "session", name, "preset", body.Preset, "text_len", len(body.Text))
-		w.WriteHeader(http.StatusNoContent)
-	}
-}
-
-// expandInput validates the body and returns the bytes to send.
-// A trailing \n is always appended — the handler enforces
-// "one line per POST" as a hard invariant.
-func expandInput(b inputReq) (string, error) {
-	hasText := b.Text != ""
-	hasPreset := b.Preset != ""
-	if hasText && hasPreset {
-		return "", errInputBothFields
-	}
-	if !hasText && !hasPreset {
-		return "", errInputEmpty
-	}
-	if hasPreset {
-		switch b.Preset {
-		case "yes":
-			return "Approve\n", nil
-		case "no":
-			return "Deny\n", nil
-		case "continue":
-			return "\n", nil
-		case "follow":
-			return "Follow recommended\n", nil
-		default:
-			return "", errInputPreset
-		}
-	}
-	if len(b.Text) > inputTextMax {
-		return "", errInputText
-	}
-	if strings.TrimSpace(b.Text) == "" {
-		return "", errInputText
-	}
-	if strings.ContainsAny(b.Text, "\n\r") {
-		return "", errInputText
-	}
-	for _, r := range b.Text {
-		if r == '\t' {
-			continue
-		}
-		if r < 0x20 || r == 0x7f {
-			return "", errInputText
-		}
-	}
-	return b.Text + "\n", nil
-}
-
-// writeInputErr writes a structured JSON error: {"error":"", "message":"..."}.
-// Named distinctly to avoid collision with writeJSONError in revert.go.
-func writeInputErr(w http.ResponseWriter, status int, code, message string) {
-	w.Header().Set("Content-Type", "application/json")
-	w.WriteHeader(status)
-	_ = json.NewEncoder(w).Encode(map[string]string{
-		"error":   code,
-		"message": message,
-	})
-}
diff --git a/internal/serve/api/input_test.go b/internal/serve/api/input_test.go
deleted file mode 100644
index 5f7c5c6..0000000
--- a/internal/serve/api/input_test.go
+++ /dev/null
@@ -1,254 +0,0 @@
-package api_test
-
-import (
-	"bytes"
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"strings"
-	"testing"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/api"
-	"github.com/RandomCodeSpace/ctm/internal/session"
-)
-
-// ---------- fakes ----------------------------------------------------------
-
-type fakeInputProj struct {
-	sess      map[string]session.Session
-	tmuxAlive map[string]bool
-}
-
-func (f *fakeInputProj) Get(name string) (session.Session, bool) {
-	s, ok := f.sess[name]
-	return s, ok
-}
-
-func (f *fakeInputProj) TmuxAlive(name string) bool {
-	return f.tmuxAlive[name]
-}
-
-type fakeInputTmux struct {
-	lastTarget  string
-	lastKeys    string
-	sendCalls   int
-	enterCalls  int
-	err         error
-}
-
-func (f *fakeInputTmux) SendKeys(target, keys string) error {
-	f.lastTarget = target
-	f.lastKeys = keys
-	f.sendCalls++
-	return f.err
-}
-
-func (f *fakeInputTmux) SendEnter(target string) error {
-	f.lastTarget = target
-	f.enterCalls++
-	return f.err
-}
-
-// ---------- helpers --------------------------------------------------------
-
-func inputReq(t *testing.T, name string, body any) *http.Request {
-	t.Helper()
-	b, err := json.Marshal(body)
-	if err != nil {
-		t.Fatalf("marshal: %v", err)
-	}
-	r := httptest.NewRequest(http.MethodPost, "/api/sessions/"+name+"/input", bytes.NewReader(b))
-	r.SetPathValue("name", name)
-	r.Header.Set("Content-Type", "application/json")
-	return r
-}
-
-func newYoloProj() *fakeInputProj {
-	return &fakeInputProj{
-		sess: map[string]session.Session{
-			"alpha": {Name: "alpha", UUID: "u-1", Mode: "yolo"},
-			"safe":  {Name: "safe", UUID: "u-2", Mode: "safe"},
-			"dead":  {Name: "dead", UUID: "u-3", Mode: "yolo"},
-		},
-		tmuxAlive: map[string]bool{
-			"alpha": true,
-			"safe":  true,
-			"dead":  false,
-		},
-	}
-}
-
-// ---------- tests ----------------------------------------------------------
-
-func TestInput_Preset_Yes(t *testing.T) {
-	tmux := &fakeInputTmux{}
-	h := api.Input(newYoloProj(), tmux)
-	rec := httptest.NewRecorder()
-	h(rec, inputReq(t, "alpha", map[string]string{"preset": "yes"}))
-
-	if rec.Code != http.StatusNoContent {
-		t.Fatalf("status = %d, want 204", rec.Code)
-	}
-	if tmux.lastTarget != "alpha:0.0" {
-		t.Fatalf("tmux target = %q, want %q", tmux.lastTarget, "alpha:0.0")
-	}
-	if tmux.lastKeys != "Approve" {
-		t.Fatalf("tmux literal = %q, want %q", tmux.lastKeys, "Approve")
-	}
-	if tmux.enterCalls != 1 {
-		t.Fatalf("SendEnter called %d times, want 1", tmux.enterCalls)
-	}
-}
-
-func TestInput_Preset_No(t *testing.T) {
-	tmux := &fakeInputTmux{}
-	h := api.Input(newYoloProj(), tmux)
-	rec := httptest.NewRecorder()
-	h(rec, inputReq(t, "alpha", map[string]string{"preset": "no"}))
-	if rec.Code != http.StatusNoContent {
-		t.Fatalf("status = %d, want 204", rec.Code)
-	}
-	if tmux.lastKeys != "Deny" || tmux.enterCalls != 1 {
-		t.Fatalf("tmux literal=%q enters=%d, want %q + 1", tmux.lastKeys, tmux.enterCalls, "Deny")
-	}
-}
-
-func TestInput_Preset_Continue(t *testing.T) {
-	tmux := &fakeInputTmux{}
-	h := api.Input(newYoloProj(), tmux)
-	rec := httptest.NewRecorder()
-	h(rec, inputReq(t, "alpha", map[string]string{"preset": "continue"}))
-	if rec.Code != http.StatusNoContent {
-		t.Fatalf("status = %d, want 204", rec.Code)
-	}
-	if tmux.sendCalls != 0 {
-		t.Fatalf("SendKeys called %d times for 'continue', want 0 (Enter-only)", tmux.sendCalls)
-	}
-	if tmux.enterCalls != 1 {
-		t.Fatalf("SendEnter called %d times, want 1", tmux.enterCalls)
-	}
-}
-
-func TestInput_FreeText(t *testing.T) {
-	tmux := &fakeInputTmux{}
-	h := api.Input(newYoloProj(), tmux)
-	rec := httptest.NewRecorder()
-	h(rec, inputReq(t, "alpha", map[string]string{"text": "approve"}))
-	if rec.Code != http.StatusNoContent {
-		t.Fatalf("status = %d, want 204", rec.Code)
-	}
-	if tmux.lastKeys != "approve" || tmux.enterCalls != 1 {
-		t.Fatalf("tmux literal=%q enters=%d, want %q + 1", tmux.lastKeys, tmux.enterCalls, "approve")
-	}
-}
-
-func TestInput_NotYolo(t *testing.T) {
-	tmux := &fakeInputTmux{}
-	h := api.Input(newYoloProj(), tmux)
-	rec := httptest.NewRecorder()
-	h(rec, inputReq(t, "safe", map[string]string{"preset": "yes"}))
-
-	if rec.Code != http.StatusForbidden {
-		t.Fatalf("status = %d, want 403", rec.Code)
-	}
-	if !strings.Contains(rec.Body.String(), "not_yolo") {
-		t.Fatalf("body = %q, want substring %q", rec.Body.String(), "not_yolo")
-	}
-	if tmux.lastTarget != "" {
-		t.Fatalf("tmux was called with target %q — expected no call", tmux.lastTarget)
-	}
-}
-
-func TestInput_TmuxDead(t *testing.T) {
-	tmux := &fakeInputTmux{}
-	h := api.Input(newYoloProj(), tmux)
-	rec := httptest.NewRecorder()
-	h(rec, inputReq(t, "dead", map[string]string{"preset": "yes"}))
-	if rec.Code != http.StatusConflict {
-		t.Fatalf("status = %d, want 409", rec.Code)
-	}
-	if !strings.Contains(rec.Body.String(), "tmux_dead") {
-		t.Fatalf("body = %q, want substring %q", rec.Body.String(), "tmux_dead")
-	}
-}
-
-func TestInput_NotFound(t *testing.T) {
-	tmux := &fakeInputTmux{}
-	h := api.Input(newYoloProj(), tmux)
-	rec := httptest.NewRecorder()
-	h(rec, inputReq(t, "nope", map[string]string{"preset": "yes"}))
-	if rec.Code != http.StatusNotFound {
-		t.Fatalf("status = %d, want 404", rec.Code)
-	}
-}
-
-func TestInput_BothTextAndPreset(t *testing.T) {
-	tmux := &fakeInputTmux{}
-	h := api.Input(newYoloProj(), tmux)
-	rec := httptest.NewRecorder()
-	h(rec, inputReq(t, "alpha", map[string]string{"text": "hi", "preset": "yes"}))
-	if rec.Code != http.StatusBadRequest {
-		t.Fatalf("status = %d, want 400", rec.Code)
-	}
-	if !strings.Contains(rec.Body.String(), "invalid_body") {
-		t.Fatalf("body = %q, want substring %q", rec.Body.String(), "invalid_body")
-	}
-}
-
-func TestInput_TextTooLong(t *testing.T) {
-	tmux := &fakeInputTmux{}
-	h := api.Input(newYoloProj(), tmux)
-	rec := httptest.NewRecorder()
-	h(rec, inputReq(t, "alpha", map[string]string{"text": strings.Repeat("x", 257)}))
-	if rec.Code != http.StatusBadRequest {
-		t.Fatalf("status = %d, want 400", rec.Code)
-	}
-	if !strings.Contains(rec.Body.String(), "invalid_text") {
-		t.Fatalf("body = %q, want substring %q", rec.Body.String(), "invalid_text")
-	}
-}
-
-func TestInput_TextContainsNewline(t *testing.T) {
-	tmux := &fakeInputTmux{}
-	h := api.Input(newYoloProj(), tmux)
-	rec := httptest.NewRecorder()
-	h(rec, inputReq(t, "alpha", map[string]string{"text": "line1\nline2"}))
-	if rec.Code != http.StatusBadRequest {
-		t.Fatalf("status = %d, want 400", rec.Code)
-	}
-}
-
-func TestInput_UnknownPreset(t *testing.T) {
-	tmux := &fakeInputTmux{}
-	h := api.Input(newYoloProj(), tmux)
-	rec := httptest.NewRecorder()
-	h(rec, inputReq(t, "alpha", map[string]string{"preset": "maybe"}))
-	if rec.Code != http.StatusBadRequest {
-		t.Fatalf("status = %d, want 400", rec.Code)
-	}
-	if !strings.Contains(rec.Body.String(), "invalid_preset") {
-		t.Fatalf("body = %q, want substring %q", rec.Body.String(), "invalid_preset")
-	}
-}
-
-func TestInput_EmptyBody(t *testing.T) {
-	tmux := &fakeInputTmux{}
-	h := api.Input(newYoloProj(), tmux)
-	rec := httptest.NewRecorder()
-	h(rec, inputReq(t, "alpha", map[string]string{}))
-	if rec.Code != http.StatusBadRequest {
-		t.Fatalf("status = %d, want 400", rec.Code)
-	}
-}
-
-func TestInput_WrongMethod(t *testing.T) {
-	tmux := &fakeInputTmux{}
-	h := api.Input(newYoloProj(), tmux)
-	rec := httptest.NewRecorder()
-	r := httptest.NewRequest(http.MethodGet, "/api/sessions/alpha/input", nil)
-	r.SetPathValue("name", "alpha")
-	h(rec, r)
-	if rec.Code != http.StatusMethodNotAllowed {
-		t.Fatalf("status = %d, want 405", rec.Code)
-	}
-}
diff --git a/internal/serve/api/logs_usage.go b/internal/serve/api/logs_usage.go
deleted file mode 100644
index bebec35..0000000
--- a/internal/serve/api/logs_usage.go
+++ /dev/null
@@ -1,171 +0,0 @@
-// Package api — /api/logs/usage surfaces disk usage of the JSONL
-// tailer directory so users can notice when it's time to prune.
-//
-// Walk is bounded (maxFilesLimit) to keep the handler cheap even on
-// very old installs that have accumulated thousands of transcripts.
-// Resolution of uuid → session name reuses the same "claudeDirToName"
-// fallback already used for orphan UUID adoption in serve.Server.Run
-// (see server.go for background).
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-	"time"
-)
-
-// maxFilesLimit caps how many *.jsonl entries logsUsage will stat in a
-// single request. Past this bound we return 507 Insufficient Storage
-// with a hint so the UI can surface "too many log files — prune" rather
-// than hang on an uncached dir walk.
-const maxFilesLimit = 10_000
-
-// UUIDNameResolver returns the human session name for a given log UUID.
-// Implementations should encapsulate the uuidToName + claudeDirToName
-// fallback lookup so the handler stays decoupled from ingest internals.
-//
-// ok=false signals "unknown UUID"; the handler falls back to a
-// "uuid:" placeholder identical to the orphan-adoption path.
-type UUIDNameResolver interface {
-	ResolveUUID(uuid string) (name string, ok bool)
-}
-
-// logsUsageFile is a single *.jsonl entry in the response.
-type logsUsageFile struct {
-	UUID    string `json:"uuid"`
-	Session string `json:"session"`
-	Bytes   int64  `json:"bytes"`
-	// Mtime is RFC3339 in UTC for easy JS Date parsing. Empty string
-	// when stat returned a zero time (shouldn't happen on a real fs).
-	Mtime string `json:"mtime"`
-}
-
-// logsUsageResponse is the shape returned by GET /api/logs/usage.
-type logsUsageResponse struct {
-	Dir        string          `json:"dir"`
-	TotalBytes int64           `json:"total_bytes"`
-	Files      []logsUsageFile `json:"files"`
-}
-
-// LogsUsage returns the GET /api/logs/usage handler. logDir is the
-// absolute path of the directory the tailer watches (normally
-// ~/.config/ctm/logs). resolver maps log UUID → human session name;
-// pass nil to disable resolution (every row falls back to the
-// "uuid:" placeholder).
-func LogsUsage(logDir string, resolver UUIDNameResolver) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodGet && r.Method != http.MethodHead {
-			w.Header().Set("Allow", "GET, HEAD")
-			w.Header().Set("Cache-Control", "no-store")
-			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-			return
-		}
-
-		w.Header().Set("Content-Type", "application/json")
-		w.Header().Set("Cache-Control", "no-store")
-
-		entries, err := os.ReadDir(logDir)
-		if err != nil {
-			if os.IsNotExist(err) {
-				// Directory doesn't exist yet (fresh install). Return
-				// an empty but well-formed response rather than 5xx so
-				// the UI can render "no logs yet" instead of an error.
-				_ = json.NewEncoder(w).Encode(logsUsageResponse{
-					Dir:   logDir,
-					Files: []logsUsageFile{},
-				})
-				return
-			}
-			w.WriteHeader(http.StatusInternalServerError)
-			_ = json.NewEncoder(w).Encode(map[string]string{
-				"error": "readdir_failed",
-			})
-			return
-		}
-
-		// Pre-count the *.jsonl entries so we can bail early with 507
-		// without stat()-ing anything. A non-jsonl file in logDir is
-		// unexpected but not fatal; ignore it entirely.
-		jsonls := 0
-		for _, e := range entries {
-			if !e.IsDir() && strings.HasSuffix(e.Name(), jsonlExt) {
-				jsonls++
-			}
-		}
-		if jsonls > maxFilesLimit {
-			w.WriteHeader(http.StatusInsufficientStorage)
-			_ = json.NewEncoder(w).Encode(map[string]any{
-				"error":     "too_many_log_files",
-				"count":     jsonls,
-				"limit":     maxFilesLimit,
-				"hint":      "prune old *.jsonl files in " + logDir,
-				"directory": logDir,
-			})
-			return
-		}
-
-		files := make([]logsUsageFile, 0, jsonls)
-		var total int64
-		for _, e := range entries {
-			if e.IsDir() || !strings.HasSuffix(e.Name(), jsonlExt) {
-				continue
-			}
-			uuid := strings.TrimSuffix(e.Name(), jsonlExt)
-			full := filepath.Join(logDir, e.Name())
-			info, err := os.Stat(full)
-			if err != nil {
-				// Race: the file was removed between ReadDir and Stat,
-				// or we lost permission. Skip it silently rather than
-				// failing the whole response.
-				continue
-			}
-			name := resolveSessionName(resolver, uuid)
-			mtime := ""
-			if t := info.ModTime(); !t.IsZero() {
-				mtime = t.UTC().Format(time.RFC3339)
-			}
-			files = append(files, logsUsageFile{
-				UUID:    uuid,
-				Session: name,
-				Bytes:   info.Size(),
-				Mtime:   mtime,
-			})
-			total += info.Size()
-		}
-
-		// Sort by bytes desc, then UUID asc for determinism on ties.
-		sort.Slice(files, func(i, j int) bool {
-			if files[i].Bytes != files[j].Bytes {
-				return files[i].Bytes > files[j].Bytes
-			}
-			return files[i].UUID < files[j].UUID
-		})
-
-		_ = json.NewEncoder(w).Encode(logsUsageResponse{
-			Dir:        logDir,
-			TotalBytes: total,
-			Files:      files,
-		})
-	}
-}
-
-// resolveSessionName asks the resolver for a human name; on miss or
-// nil resolver it falls back to the same "uuid:" placeholder
-// that the orphan-adoption path in serve.Server.Run uses, so the UI
-// sees a consistent identifier for unmapped UUIDs.
-func resolveSessionName(resolver UUIDNameResolver, uuid string) string {
-	if resolver != nil {
-		if n, ok := resolver.ResolveUUID(uuid); ok && n != "" {
-			return n
-		}
-	}
-	short := uuid
-	if len(short) > 8 {
-		short = short[:8]
-	}
-	return "uuid:" + short
-}
diff --git a/internal/serve/api/logs_usage_test.go b/internal/serve/api/logs_usage_test.go
deleted file mode 100644
index a834ca0..0000000
--- a/internal/serve/api/logs_usage_test.go
+++ /dev/null
@@ -1,215 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"fmt"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-)
-
-// fakeResolver implements UUIDNameResolver with an in-memory map.
-type fakeResolver struct{ m map[string]string }
-
-func (f fakeResolver) ResolveUUID(uuid string) (string, bool) {
-	n, ok := f.m[uuid]
-	return n, ok
-}
-
-// seedLogs writes len(sizes) *.jsonl files of exactly the requested
-// byte length into dir. Returns the UUID slice in the same order.
-func seedLogs(t *testing.T, dir string, sizes map[string]int) {
-	t.Helper()
-	if err := os.MkdirAll(dir, 0o755); err != nil {
-		t.Fatalf("mkdir: %v", err)
-	}
-	for uuid, n := range sizes {
-		path := filepath.Join(dir, uuid+".jsonl")
-		if err := os.WriteFile(path, []byte(strings.Repeat("x", n)), 0o644); err != nil {
-			t.Fatalf("write %s: %v", path, err)
-		}
-	}
-}
-
-func TestLogsUsage_405OnPost(t *testing.T) {
-	h := LogsUsage(t.TempDir(), nil)
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodPost, "/api/logs/usage", nil)
-	h(rec, req)
-	if rec.Code != http.StatusMethodNotAllowed {
-		t.Errorf("status = %d, want 405", rec.Code)
-	}
-	if got := rec.Header().Get("Allow"); !strings.Contains(got, "GET") {
-		t.Errorf("Allow header = %q, want GET", got)
-	}
-}
-
-func TestLogsUsage_MissingDirReturnsEmpty(t *testing.T) {
-	// Point at a dir that doesn't exist — serve startup creates it on
-	// demand, but /api/logs/usage must not 5xx before that happens.
-	missing := filepath.Join(t.TempDir(), "does-not-exist")
-	h := LogsUsage(missing, nil)
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodGet, "/api/logs/usage", nil)
-	h(rec, req)
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var body logsUsageResponse
-	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body.Dir != missing {
-		t.Errorf("dir = %q, want %q", body.Dir, missing)
-	}
-	if body.TotalBytes != 0 {
-		t.Errorf("total_bytes = %d, want 0", body.TotalBytes)
-	}
-	if body.Files == nil {
-		t.Error("files = nil, want empty slice (JSON [] not null)")
-	}
-	if len(body.Files) != 0 {
-		t.Errorf("files = %+v, want empty", body.Files)
-	}
-}
-
-func TestLogsUsage_JSONShapeAndTotals(t *testing.T) {
-	dir := t.TempDir()
-	sizes := map[string]int{
-		"aaaaaaaa-0000-0000-0000-000000000001": 100,
-		"bbbbbbbb-0000-0000-0000-000000000002": 250,
-		"cccccccc-0000-0000-0000-000000000003": 50,
-	}
-	seedLogs(t, dir, sizes)
-	// A non-jsonl file must be ignored entirely (no stat, no row).
-	if err := os.WriteFile(filepath.Join(dir, "README"), []byte("ignore me"), 0o644); err != nil {
-		t.Fatalf("seed README: %v", err)
-	}
-	// A subdir must also be ignored.
-	if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
-		t.Fatalf("mkdir sub: %v", err)
-	}
-
-	resolver := fakeResolver{m: map[string]string{
-		"aaaaaaaa-0000-0000-0000-000000000001": "alpha",
-		"bbbbbbbb-0000-0000-0000-000000000002": "beta",
-		// cccccccc... intentionally unresolved → uuid: fallback.
-	}}
-	h := LogsUsage(dir, resolver)
-
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodGet, "/api/logs/usage", nil)
-	h(rec, req)
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	if got := rec.Header().Get("Content-Type"); got != "application/json" {
-		t.Errorf("Content-Type = %q", got)
-	}
-
-	var body logsUsageResponse
-	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body.Dir != dir {
-		t.Errorf("dir = %q, want %q", body.Dir, dir)
-	}
-	if body.TotalBytes != 400 {
-		t.Errorf("total_bytes = %d, want 400 (100+250+50)", body.TotalBytes)
-	}
-	if len(body.Files) != 3 {
-		t.Fatalf("files count = %d, want 3: %+v", len(body.Files), body.Files)
-	}
-
-	// Files must be sorted by bytes desc: beta(250), alpha(100), ccc(50).
-	if body.Files[0].Session != "beta" || body.Files[0].Bytes != 250 {
-		t.Errorf("files[0] = %+v, want {session:beta bytes:250}", body.Files[0])
-	}
-	if body.Files[1].Session != "alpha" || body.Files[1].Bytes != 100 {
-		t.Errorf("files[1] = %+v, want {session:alpha bytes:100}", body.Files[1])
-	}
-	if !strings.HasPrefix(body.Files[2].Session, "uuid:") {
-		t.Errorf("files[2].session = %q, want uuid: fallback", body.Files[2].Session)
-	}
-	if body.Files[2].Bytes != 50 {
-		t.Errorf("files[2].bytes = %d, want 50", body.Files[2].Bytes)
-	}
-
-	// UUID propagated through verbatim.
-	for _, f := range body.Files {
-		if _, ok := sizes[f.UUID]; !ok {
-			t.Errorf("unexpected uuid %q", f.UUID)
-		}
-		if f.Mtime == "" {
-			t.Errorf("file %s mtime empty", f.UUID)
-		}
-	}
-
-	// Shape check: top-level keys exactly {dir, total_bytes, files}.
-	var raw map[string]any
-	if err := json.Unmarshal(rec.Body.Bytes(), &raw); err == nil {
-		for _, key := range []string{"dir", "total_bytes", "files"} {
-			if _, ok := raw[key]; !ok {
-				t.Errorf("missing top-level key %q", key)
-			}
-		}
-	}
-}
-
-func TestLogsUsage_NilResolverAlwaysFallsBack(t *testing.T) {
-	dir := t.TempDir()
-	seedLogs(t, dir, map[string]int{
-		"12345678-aaaa-bbbb-cccc-000000000000": 10,
-	})
-	h := LogsUsage(dir, nil)
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodGet, "/api/logs/usage", nil)
-	h(rec, req)
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var body logsUsageResponse
-	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if len(body.Files) != 1 {
-		t.Fatalf("files = %+v", body.Files)
-	}
-	if body.Files[0].Session != "uuid:12345678" {
-		t.Errorf("session = %q, want uuid:12345678", body.Files[0].Session)
-	}
-}
-
-func TestLogsUsage_507OverLimit(t *testing.T) {
-	dir := t.TempDir()
-	// Seed just over the bound. Use tiny files — we only care about the
-	// count gate, not byte totals.
-	sizes := make(map[string]int, maxFilesLimit+1)
-	for i := 0; i <= maxFilesLimit; i++ {
-		sizes[fmt.Sprintf("uuid-%08d", i)] = 1
-	}
-	seedLogs(t, dir, sizes)
-
-	h := LogsUsage(dir, nil)
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodGet, "/api/logs/usage", nil)
-	h(rec, req)
-
-	if rec.Code != http.StatusInsufficientStorage {
-		t.Fatalf("status = %d, want 507", rec.Code)
-	}
-	var body map[string]any
-	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body["error"] != "too_many_log_files" {
-		t.Errorf("error = %v, want too_many_log_files", body["error"])
-	}
-	if _, ok := body["hint"]; !ok {
-		t.Errorf("missing hint field in 507 body")
-	}
-}
diff --git a/internal/serve/api/mutations.go b/internal/serve/api/mutations.go
deleted file mode 100644
index 36f86a1..0000000
--- a/internal/serve/api/mutations.go
+++ /dev/null
@@ -1,276 +0,0 @@
-package api
-
-// V23 — mutation endpoints: kill / forget / rename / attach-deeplink.
-// Design rationale and threat model live in docs/v02/V23-mutation-auth.md
-// (auth recipe A+B+D: bearer + type-to-confirm + Origin check).
-//
-// Wiring (central, server.go) — wrap each POST with
-// api.RequireOriginFunc(allowed, …) in addition to authHF(…):
-//
-//   allowed := api.DefaultAllowedOrigins(s.opts.Port)
-//   mux.Handle("POST /api/sessions/{name}/kill",
-//       authHF(api.RequireOriginFunc(allowed, api.Kill(s.sessionStore, s.tmuxClient, s.proj))))
-//   mux.Handle("POST /api/sessions/{name}/forget",
-//       authHF(api.RequireOriginFunc(allowed, api.Forget(s.sessionStore, s.proj))))
-//   mux.Handle("POST /api/sessions/{name}/rename",
-//       authHF(api.RequireOriginFunc(allowed, api.Rename(s.sessionStore, s.tmuxClient, s.proj))))
-//   mux.Handle("GET /api/sessions/{name}/attach-url",
-//       authHF(api.RequireOriginFunc(allowed, api.AttachURL())))
-
-import (
-	"encoding/json"
-	"errors"
-	"net/http"
-	"net/url"
-
-	"github.com/RandomCodeSpace/ctm/internal/session"
-)
-
-// SessionStore is the narrow slice of *session.Store the mutation
-// handlers need. A package-local interface keeps the api package
-// decoupled from the concrete store and makes the handlers trivially
-// faked in tests.
-type SessionStore interface {
-	Get(name string) (*session.Session, error)
-	Delete(name string) error
-	Rename(oldName, newName string) error
-}
-
-// TmuxMutator is the narrow slice of *tmux.Client that kill / rename
-// need. Mirrors the same decoupling pattern used by TmuxPaneCapturer.
-type TmuxMutator interface {
-	KillSession(name string) error
-	RenameSession(oldName, newName string) error
-}
-
-// ProjRefresher triggers a projection reload after state mutations so
-// /api/sessions reflects the new truth without waiting for the next
-// polling tick.
-type ProjRefresher interface {
-	Reload()
-}
-
-// ----- kill -----------------------------------------------------------------
-
-type killReq struct {
-	Confirm string `json:"confirm"`
-}
-
-// Kill returns POST /api/sessions/{name}/kill. Body must be
-// `{"confirm":""}` matching the path param (B in the
-// design doc). Runs tmux kill-session and triggers a projection
-// refresh. Returns 200 + the updated session JSON.
-func Kill(store SessionStore, tmuxClient TmuxMutator, proj ProjRefresher) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodPost {
-			methodNotAllowed(w, http.MethodPost)
-			return
-		}
-		name := r.PathValue("name")
-		if name == "" {
-			http.Error(w, errMsgMissingSessionName, http.StatusBadRequest)
-			return
-		}
-
-		var body killReq
-		if err := decodeJSON(r, &body); err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		if body.Confirm != name {
-			http.Error(w, "confirm must match session name", http.StatusBadRequest)
-			return
-		}
-
-		sess, err := store.Get(name)
-		if err != nil {
-			http.Error(w, errMsgSessionNotFound, http.StatusNotFound)
-			return
-		}
-
-		// tmux may already be down for the session — that's fine and
-		// still counts as "killed". Surface other errors as 500 so the
-		// UI doesn't silently lie about state.
-		if err := tmuxClient.KillSession(name); err != nil && !isAlreadyGone(err) {
-			http.Error(w, "tmux kill-session: "+err.Error(), http.StatusInternalServerError)
-			return
-		}
-
-		proj.Reload()
-		writeJSON(w, http.StatusOK, sess)
-	}
-}
-
-// ----- forget ---------------------------------------------------------------
-
-type forgetReq struct {
-	Confirm string `json:"confirm"`
-}
-
-// Forget returns POST /api/sessions/{name}/forget. Removes the
-// session from sessions.json but keeps the JSONL log so the user can
-// still search history. Body must be `{"confirm":""}` (B).
-func Forget(store SessionStore, proj ProjRefresher) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodPost {
-			methodNotAllowed(w, http.MethodPost)
-			return
-		}
-		name := r.PathValue("name")
-		if name == "" {
-			http.Error(w, errMsgMissingSessionName, http.StatusBadRequest)
-			return
-		}
-
-		var body forgetReq
-		if err := decodeJSON(r, &body); err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		if body.Confirm != name {
-			http.Error(w, "confirm must match session name", http.StatusBadRequest)
-			return
-		}
-
-		sess, err := store.Get(name)
-		if err != nil {
-			http.Error(w, errMsgSessionNotFound, http.StatusNotFound)
-			return
-		}
-		if err := store.Delete(name); err != nil {
-			http.Error(w, "sessions.json write: "+err.Error(), http.StatusInternalServerError)
-			return
-		}
-
-		proj.Reload()
-		writeJSON(w, http.StatusOK, sess)
-	}
-}
-
-// ----- rename ---------------------------------------------------------------
-
-type renameReq struct {
-	To string `json:"to"`
-}
-
-// Rename returns POST /api/sessions/{name}/rename. Body:
-// `{"to":""}`. Does not require type-to-confirm — rename
-// is recoverable. Performs the tmux rename-session first so a name
-// collision fails loudly before sessions.json gets touched.
-func Rename(store SessionStore, tmuxClient TmuxMutator, proj ProjRefresher) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodPost {
-			methodNotAllowed(w, http.MethodPost)
-			return
-		}
-		name := r.PathValue("name")
-		if name == "" {
-			http.Error(w, errMsgMissingSessionName, http.StatusBadRequest)
-			return
-		}
-
-		var body renameReq
-		if err := decodeJSON(r, &body); err != nil {
-			http.Error(w, err.Error(), http.StatusBadRequest)
-			return
-		}
-		if err := session.ValidateName(body.To); err != nil {
-			http.Error(w, "invalid new name: "+err.Error(), http.StatusBadRequest)
-			return
-		}
-		if body.To == name {
-			http.Error(w, "new name matches current", http.StatusBadRequest)
-			return
-		}
-
-		if _, err := store.Get(name); err != nil {
-			http.Error(w, errMsgSessionNotFound, http.StatusNotFound)
-			return
-		}
-
-		if err := tmuxClient.RenameSession(name, body.To); err != nil && !isAlreadyGone(err) {
-			http.Error(w, "tmux rename-session: "+err.Error(), http.StatusInternalServerError)
-			return
-		}
-		if err := store.Rename(name, body.To); err != nil {
-			http.Error(w, "sessions.json write: "+err.Error(), http.StatusInternalServerError)
-			return
-		}
-
-		proj.Reload()
-		// Re-fetch by new name so we return the post-rename session.
-		sess, err := store.Get(body.To)
-		if err != nil {
-			http.Error(w, "post-rename lookup: "+err.Error(), http.StatusInternalServerError)
-			return
-		}
-		writeJSON(w, http.StatusOK, sess)
-	}
-}
-
-// ----- attach-deeplink ------------------------------------------------------
-
-// AttachURL returns GET /api/sessions/{name}/attach-url. Produces a
-// `ctm://attach?name=…` URL the OS can hand off to the ctm CLI's
-// attach handler. Read-only aside from formatting the URL, but still
-// gated by Origin so a rogue page can't enumerate session names by
-// probing this endpoint.
-func AttachURL() http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodGet {
-			methodNotAllowed(w, http.MethodGet)
-			return
-		}
-		name := r.PathValue("name")
-		if name == "" {
-			http.Error(w, errMsgMissingSessionName, http.StatusBadRequest)
-			return
-		}
-		q := url.Values{}
-		q.Set("name", name)
-		writeJSON(w, http.StatusOK, map[string]string{
-			"url": "ctm://attach?" + q.Encode(),
-		})
-	}
-}
-
-// ----- shared helpers -------------------------------------------------------
-
-func methodNotAllowed(w http.ResponseWriter, allow string) {
-	w.Header().Set("Allow", allow)
-	http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-}
-
-func decodeJSON(r *http.Request, dst any) error {
-	dec := json.NewDecoder(r.Body)
-	dec.DisallowUnknownFields()
-	if err := dec.Decode(dst); err != nil {
-		return errors.New("invalid JSON: " + err.Error())
-	}
-	return nil
-}
-
-// isAlreadyGone returns true when tmux reports that the target
-// session doesn't exist. tmux's CLI prints "can't find session" in
-// that case; os/exec wraps it in a *exec.ExitError whose Stderr we
-// could parse, but the message is already surfaced via err.Error().
-// We accept any error containing either literal since tmux versions
-// differ slightly.
-func isAlreadyGone(err error) bool {
-	msg := err.Error()
-	return contains(msg, "can't find session") ||
-		contains(msg, "session not found") ||
-		contains(msg, "no server running")
-}
-
-func contains(s, sub string) bool {
-	if len(sub) > len(s) {
-		return false
-	}
-	for i := 0; i+len(sub) <= len(s); i++ {
-		if s[i:i+len(sub)] == sub {
-			return true
-		}
-	}
-	return false
-}
diff --git a/internal/serve/api/mutations_test.go b/internal/serve/api/mutations_test.go
deleted file mode 100644
index af79802..0000000
--- a/internal/serve/api/mutations_test.go
+++ /dev/null
@@ -1,375 +0,0 @@
-package api
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"net/http"
-	"net/http/httptest"
-	"strings"
-	"testing"
-
-	"github.com/RandomCodeSpace/ctm/internal/session"
-)
-
-// fakeStore is an in-memory SessionStore for tests.
-type fakeStore struct {
-	sessions    map[string]*session.Session
-	deleteErr   error
-	renameErr   error
-	deleteCalls int
-	renameCalls int
-}
-
-func newFakeStore(seed map[string]*session.Session) *fakeStore {
-	if seed == nil {
-		seed = map[string]*session.Session{}
-	}
-	return &fakeStore{sessions: seed}
-}
-
-func (f *fakeStore) Get(name string) (*session.Session, error) {
-	s, ok := f.sessions[name]
-	if !ok {
-		return nil, errors.New("not found")
-	}
-	return s, nil
-}
-func (f *fakeStore) Delete(name string) error {
-	f.deleteCalls++
-	if f.deleteErr != nil {
-		return f.deleteErr
-	}
-	delete(f.sessions, name)
-	return nil
-}
-func (f *fakeStore) Rename(old, newName string) error {
-	f.renameCalls++
-	if f.renameErr != nil {
-		return f.renameErr
-	}
-	s, ok := f.sessions[old]
-	if !ok {
-		return errors.New("not found")
-	}
-	s.Name = newName
-	delete(f.sessions, old)
-	f.sessions[newName] = s
-	return nil
-}
-
-// fakeTmux is an in-memory TmuxMutator.
-type fakeTmux struct {
-	killErr     error
-	renameErr   error
-	killCalls   []string
-	renameCalls [][2]string
-}
-
-func (f *fakeTmux) KillSession(name string) error {
-	f.killCalls = append(f.killCalls, name)
-	return f.killErr
-}
-func (f *fakeTmux) RenameSession(oldName, newName string) error {
-	f.renameCalls = append(f.renameCalls, [2]string{oldName, newName})
-	return f.renameErr
-}
-
-type fakeRefresher struct{ calls int }
-
-func (f *fakeRefresher) Reload() { f.calls++ }
-
-func makeRequest(method, path, name, body string) *http.Request {
-	r := httptest.NewRequest(method, path, strings.NewReader(body))
-	r.SetPathValue("name", name)
-	return r
-}
-
-// ---------- kill ------------------------------------------------------------
-
-func TestKill_HappyPath(t *testing.T) {
-	store := newFakeStore(map[string]*session.Session{
-		"alpha": {Name: "alpha", Workdir: "/tmp", Mode: "safe"},
-	})
-	tmuxC := &fakeTmux{}
-	proj := &fakeRefresher{}
-	h := Kill(store, tmuxC, proj)
-
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/alpha/kill", "alpha", `{"confirm":"alpha"}`))
-
-	if rr.Code != http.StatusOK {
-		t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
-	}
-	if len(tmuxC.killCalls) != 1 || tmuxC.killCalls[0] != "alpha" {
-		t.Errorf("kill calls=%v", tmuxC.killCalls)
-	}
-	if proj.calls != 1 {
-		t.Errorf("projection reload calls=%d want 1", proj.calls)
-	}
-	var got session.Session
-	_ = json.Unmarshal(rr.Body.Bytes(), &got)
-	if got.Name != "alpha" {
-		t.Errorf("returned session name=%q want alpha", got.Name)
-	}
-}
-
-func TestKill_ConfirmMismatch400(t *testing.T) {
-	store := newFakeStore(map[string]*session.Session{"alpha": {Name: "alpha"}})
-	h := Kill(store, &fakeTmux{}, &fakeRefresher{})
-
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/alpha/kill", "alpha", `{"confirm":"beta"}`))
-
-	if rr.Code != http.StatusBadRequest {
-		t.Errorf("status=%d want 400", rr.Code)
-	}
-}
-
-func TestKill_MissingSession404(t *testing.T) {
-	h := Kill(newFakeStore(nil), &fakeTmux{}, &fakeRefresher{})
-
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/ghost/kill", "ghost", `{"confirm":"ghost"}`))
-
-	if rr.Code != http.StatusNotFound {
-		t.Errorf("status=%d want 404", rr.Code)
-	}
-}
-
-func TestKill_AlreadyGoneStillSucceeds(t *testing.T) {
-	store := newFakeStore(map[string]*session.Session{"alpha": {Name: "alpha"}})
-	tmuxC := &fakeTmux{killErr: errors.New("can't find session: alpha")}
-	proj := &fakeRefresher{}
-	h := Kill(store, tmuxC, proj)
-
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/alpha/kill", "alpha", `{"confirm":"alpha"}`))
-
-	if rr.Code != http.StatusOK {
-		t.Errorf("status=%d want 200 (tmux already gone should be idempotent)", rr.Code)
-	}
-	if proj.calls != 1 {
-		t.Errorf("reload should still fire, got %d", proj.calls)
-	}
-}
-
-func TestKill_405OnGet(t *testing.T) {
-	h := Kill(newFakeStore(nil), &fakeTmux{}, &fakeRefresher{})
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodGet, "/api/sessions/alpha/kill", "alpha", ""))
-	if rr.Code != http.StatusMethodNotAllowed {
-		t.Errorf("status=%d want 405", rr.Code)
-	}
-	if rr.Header().Get("Allow") != http.MethodPost {
-		t.Errorf("Allow=%q want POST", rr.Header().Get("Allow"))
-	}
-}
-
-func TestKill_UnknownJSONField400(t *testing.T) {
-	store := newFakeStore(map[string]*session.Session{"alpha": {Name: "alpha"}})
-	h := Kill(store, &fakeTmux{}, &fakeRefresher{})
-
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/alpha/kill", "alpha", `{"confirm":"alpha","extra":true}`))
-
-	if rr.Code != http.StatusBadRequest {
-		t.Errorf("status=%d want 400", rr.Code)
-	}
-}
-
-// ---------- forget ----------------------------------------------------------
-
-func TestForget_HappyPath(t *testing.T) {
-	store := newFakeStore(map[string]*session.Session{"alpha": {Name: "alpha"}})
-	proj := &fakeRefresher{}
-	h := Forget(store, proj)
-
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/alpha/forget", "alpha", `{"confirm":"alpha"}`))
-
-	if rr.Code != http.StatusOK {
-		t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
-	}
-	if store.deleteCalls != 1 {
-		t.Errorf("delete calls=%d want 1", store.deleteCalls)
-	}
-	if _, ok := store.sessions["alpha"]; ok {
-		t.Errorf("alpha should be gone from store")
-	}
-	if proj.calls != 1 {
-		t.Errorf("reload calls=%d", proj.calls)
-	}
-}
-
-func TestForget_ConfirmMismatch400(t *testing.T) {
-	store := newFakeStore(map[string]*session.Session{"alpha": {Name: "alpha"}})
-	h := Forget(store, &fakeRefresher{})
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/alpha/forget", "alpha", `{"confirm":"wrong"}`))
-	if rr.Code != http.StatusBadRequest {
-		t.Errorf("status=%d want 400", rr.Code)
-	}
-	if store.deleteCalls != 0 {
-		t.Errorf("delete should not fire on confirm mismatch")
-	}
-}
-
-func TestForget_MissingSession404(t *testing.T) {
-	h := Forget(newFakeStore(nil), &fakeRefresher{})
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/ghost/forget", "ghost", `{"confirm":"ghost"}`))
-	if rr.Code != http.StatusNotFound {
-		t.Errorf("status=%d want 404", rr.Code)
-	}
-}
-
-// ---------- rename ----------------------------------------------------------
-
-func TestRename_HappyPath(t *testing.T) {
-	store := newFakeStore(map[string]*session.Session{"alpha": {Name: "alpha", Mode: "safe"}})
-	tmuxC := &fakeTmux{}
-	proj := &fakeRefresher{}
-	h := Rename(store, tmuxC, proj)
-
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/alpha/rename", "alpha", `{"to":"beta"}`))
-
-	if rr.Code != http.StatusOK {
-		t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
-	}
-	if _, ok := store.sessions["beta"]; !ok {
-		t.Errorf("beta missing from store")
-	}
-	if _, ok := store.sessions["alpha"]; ok {
-		t.Errorf("alpha should be gone")
-	}
-	if len(tmuxC.renameCalls) != 1 || tmuxC.renameCalls[0] != [2]string{"alpha", "beta"} {
-		t.Errorf("tmux rename calls=%v", tmuxC.renameCalls)
-	}
-}
-
-func TestRename_InvalidNewName400(t *testing.T) {
-	store := newFakeStore(map[string]*session.Session{"alpha": {Name: "alpha"}})
-	h := Rename(store, &fakeTmux{}, &fakeRefresher{})
-
-	// SanitizeName would reject this; ValidateName enforces the rule.
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/alpha/rename", "alpha", `{"to":"bad/name"}`))
-
-	if rr.Code != http.StatusBadRequest {
-		t.Errorf("status=%d want 400", rr.Code)
-	}
-}
-
-func TestRename_SameName400(t *testing.T) {
-	store := newFakeStore(map[string]*session.Session{"alpha": {Name: "alpha"}})
-	h := Rename(store, &fakeTmux{}, &fakeRefresher{})
-
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/alpha/rename", "alpha", `{"to":"alpha"}`))
-
-	if rr.Code != http.StatusBadRequest {
-		t.Errorf("status=%d want 400", rr.Code)
-	}
-}
-
-func TestRename_MissingSession404(t *testing.T) {
-	h := Rename(newFakeStore(nil), &fakeTmux{}, &fakeRefresher{})
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/ghost/rename", "ghost", `{"to":"zeta"}`))
-	if rr.Code != http.StatusNotFound {
-		t.Errorf("status=%d want 404", rr.Code)
-	}
-}
-
-func TestRename_TmuxFailureDoesNotTouchStore(t *testing.T) {
-	store := newFakeStore(map[string]*session.Session{"alpha": {Name: "alpha"}})
-	tmuxC := &fakeTmux{renameErr: errors.New("session_name already exists: beta")}
-	h := Rename(store, tmuxC, &fakeRefresher{})
-
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/alpha/rename", "alpha", `{"to":"beta"}`))
-
-	if rr.Code != http.StatusInternalServerError {
-		t.Errorf("status=%d want 500", rr.Code)
-	}
-	if store.renameCalls != 0 {
-		t.Errorf("store should not be touched when tmux fails")
-	}
-}
-
-// ---------- attach-url ------------------------------------------------------
-
-func TestAttachURL_ReturnsCtmDeeplink(t *testing.T) {
-	h := AttachURL()
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodGet, "/api/sessions/alpha/attach-url", "alpha", ""))
-
-	if rr.Code != http.StatusOK {
-		t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
-	}
-	var got map[string]string
-	if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if !strings.HasPrefix(got["url"], "ctm://attach?") {
-		t.Errorf("url=%q want ctm://attach? prefix", got["url"])
-	}
-	if !strings.Contains(got["url"], "name=alpha") {
-		t.Errorf("url=%q missing name=alpha", got["url"])
-	}
-}
-
-func TestAttachURL_PercentEncodesName(t *testing.T) {
-	h := AttachURL()
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodGet, "/api/sessions/my+session/attach-url", "my session", ""))
-
-	if rr.Code != http.StatusOK {
-		t.Fatalf("status=%d", rr.Code)
-	}
-	var got map[string]string
-	_ = json.Unmarshal(rr.Body.Bytes(), &got)
-	// url.Values.Encode() produces + for space.
-	if !strings.Contains(got["url"], "name=my+session") {
-		t.Errorf("url=%q want name=my+session", got["url"])
-	}
-}
-
-func TestAttachURL_405OnPost(t *testing.T) {
-	h := AttachURL()
-	rr := httptest.NewRecorder()
-	h(rr, makeRequest(http.MethodPost, "/api/sessions/alpha/attach-url", "alpha", ""))
-	if rr.Code != http.StatusMethodNotAllowed {
-		t.Errorf("status=%d want 405", rr.Code)
-	}
-}
-
-// ---------- misc -----------------------------------------------------------
-
-func TestIsAlreadyGone_MatchesKnownPhrases(t *testing.T) {
-	for _, s := range []string{
-		"can't find session: alpha",
-		"session not found",
-		"no server running on /tmp/tmux-1000/default",
-	} {
-		if !isAlreadyGone(errors.New(s)) {
-			t.Errorf("isAlreadyGone(%q)=false want true", s)
-		}
-	}
-	if isAlreadyGone(errors.New("random tmux hiccup")) {
-		t.Errorf("unexpected match on unrelated error")
-	}
-}
-
-// guard that the mutations package compiles against bytes.Buffer (a
-// previous refactor shadowed strings.NewReader in one spot — keep a
-// belt-and-braces sanity check so we notice regressions).
-func TestBytesImportAvailable(t *testing.T) {
-	var buf bytes.Buffer
-	buf.WriteString("x")
-	if buf.String() != "x" {
-		t.Fatal("bytes import broken")
-	}
-}
diff --git a/internal/serve/api/origin.go b/internal/serve/api/origin.go
deleted file mode 100644
index 8105fba..0000000
--- a/internal/serve/api/origin.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package api
-
-// V23 — origin allowlist helper. See docs/v02/V23-mutation-auth.md.
-//
-// Mutation endpoints (kill / forget / rename / attach-deeplink) layer
-// an Origin-header check on top of the existing bearer-token auth.
-// Even though the daemon binds 127.0.0.1 and browsers require
-// explicit CORS opt-in, a cross-origin POST from a tab the user has
-// open to a hostile page would otherwise succeed by piggy-backing on
-// the token stored in localStorage. Requiring Origin to match one of
-// the known loopback URLs defeats that vector at zero cost to
-// legitimate callers.
-
-import (
-	"log/slog"
-	"net/http"
-	"strings"
-)
-
-// DefaultAllowedOrigins is the baseline loopback allowlist. Future
-// work may load a user-configurable extra list from
-// config.Serve.AllowedOrigins; for v0.2 we hard-code the two loopback
-// spellings the UI actually produces.
-func DefaultAllowedOrigins(port int) []string {
-	return []string{
-		originFor("http://127.0.0.1", port),
-		originFor("http://localhost", port),
-	}
-}
-
-// originFor formats a scheme+host+port origin tuple without a
-// trailing slash — matching the exact shape browsers send in the
-// Origin request header (RFC 6454 §6.2).
-func originFor(schemeHost string, port int) string {
-	if port == 80 && strings.HasPrefix(schemeHost, "http://") {
-		return schemeHost
-	}
-	if port == 443 && strings.HasPrefix(schemeHost, "https://") {
-		return schemeHost
-	}
-	return schemeHost + ":" + itoa(port)
-}
-
-// itoa avoids importing strconv into this tiny file. Port is always
-// a positive int; negative/zero should never reach us.
-func itoa(n int) string {
-	if n == 0 {
-		return "0"
-	}
-	var buf [10]byte
-	i := len(buf)
-	for n > 0 {
-		i--
-		buf[i] = byte('0' + n%10)
-		n /= 10
-	}
-	return string(buf[i:])
-}
-
-// RequireOrigin wraps h with a check that the Origin request header
-// matches one of allowed. An empty Origin is rejected — same-origin
-// fetches set it reliably in modern browsers, and non-browser callers
-// (curl, ctm CLI) can set it explicitly. Missing/mismatched → 403.
-func RequireOrigin(allowed []string, h http.Handler) http.Handler {
-	set := make(map[string]struct{}, len(allowed))
-	for _, o := range allowed {
-		set[o] = struct{}{}
-	}
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		origin := r.Header.Get("Origin")
-		if origin == "" {
-			slog.Info("origin rejected", "path", r.URL.Path, "reason", "missing")
-			http.Error(w, "missing Origin", http.StatusForbidden)
-			return
-		}
-		if _, ok := set[origin]; !ok {
-			slog.Info("origin rejected", "path", r.URL.Path, "origin", origin, "reason", "not_in_allowlist")
-			http.Error(w, "disallowed Origin", http.StatusForbidden)
-			return
-		}
-		h.ServeHTTP(w, r)
-	})
-}
-
-// RequireOriginFunc is the HandlerFunc-flavoured variant — convenient
-// when wrapping the return of another HandlerFunc inline.
-func RequireOriginFunc(allowed []string, h http.HandlerFunc) http.HandlerFunc {
-	wrapped := RequireOrigin(allowed, h)
-	return func(w http.ResponseWriter, r *http.Request) {
-		wrapped.ServeHTTP(w, r)
-	}
-}
diff --git a/internal/serve/api/origin_test.go b/internal/serve/api/origin_test.go
deleted file mode 100644
index 028ab0b..0000000
--- a/internal/serve/api/origin_test.go
+++ /dev/null
@@ -1,106 +0,0 @@
-package api
-
-import (
-	"net/http"
-	"net/http/httptest"
-	"strings"
-	"testing"
-)
-
-func TestDefaultAllowedOrigins_BuildsLoopbackPair(t *testing.T) {
-	got := DefaultAllowedOrigins(37778)
-	want := []string{
-		"http://127.0.0.1:37778",
-		"http://localhost:37778",
-	}
-	if len(got) != len(want) {
-		t.Fatalf("len=%d want %d (%v)", len(got), len(want), got)
-	}
-	for i := range want {
-		if got[i] != want[i] {
-			t.Errorf("[%d] %q want %q", i, got[i], want[i])
-		}
-	}
-}
-
-func TestOriginFor_OmitsDefaultPorts(t *testing.T) {
-	if got := originFor("http://example.com", 80); got != "http://example.com" {
-		t.Errorf("http:80 -> %q want http://example.com", got)
-	}
-	if got := originFor("https://example.com", 443); got != "https://example.com" {
-		t.Errorf("https:443 -> %q want https://example.com", got)
-	}
-	if got := originFor("http://127.0.0.1", 37778); got != "http://127.0.0.1:37778" {
-		t.Errorf("loopback -> %q", got)
-	}
-}
-
-func TestRequireOrigin_AllowsMatchingOrigin(t *testing.T) {
-	allowed := DefaultAllowedOrigins(37778)
-	inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
-		w.WriteHeader(http.StatusNoContent)
-	})
-	h := RequireOrigin(allowed, inner)
-
-	req := httptest.NewRequest(http.MethodPost, "/kill", nil)
-	req.Header.Set("Origin", "http://127.0.0.1:37778")
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, req)
-
-	if rr.Code != http.StatusNoContent {
-		t.Errorf("status=%d want 204", rr.Code)
-	}
-}
-
-func TestRequireOrigin_RejectsMismatchedOrigin(t *testing.T) {
-	allowed := DefaultAllowedOrigins(37778)
-	h := RequireOrigin(allowed, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
-		w.WriteHeader(http.StatusNoContent)
-	}))
-
-	req := httptest.NewRequest(http.MethodPost, "/kill", nil)
-	req.Header.Set("Origin", "https://evil.example.com")
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, req)
-
-	if rr.Code != http.StatusForbidden {
-		t.Errorf("status=%d want 403", rr.Code)
-	}
-	if !strings.Contains(rr.Body.String(), "disallowed") {
-		t.Errorf("body=%q want 'disallowed'", rr.Body.String())
-	}
-}
-
-func TestRequireOrigin_RejectsMissingOrigin(t *testing.T) {
-	h := RequireOrigin(DefaultAllowedOrigins(37778), http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
-		w.WriteHeader(http.StatusNoContent)
-	}))
-
-	req := httptest.NewRequest(http.MethodPost, "/kill", nil)
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, req)
-
-	if rr.Code != http.StatusForbidden {
-		t.Errorf("status=%d want 403", rr.Code)
-	}
-}
-
-func TestRequireOriginFunc_WrapsHandlerFunc(t *testing.T) {
-	called := false
-	wrapped := RequireOriginFunc(DefaultAllowedOrigins(37778), func(w http.ResponseWriter, _ *http.Request) {
-		called = true
-		w.WriteHeader(http.StatusTeapot)
-	})
-
-	req := httptest.NewRequest(http.MethodPost, "/kill", nil)
-	req.Header.Set("Origin", "http://localhost:37778")
-	rr := httptest.NewRecorder()
-	wrapped(rr, req)
-
-	if rr.Code != http.StatusTeapot {
-		t.Errorf("status=%d want 418", rr.Code)
-	}
-	if !called {
-		t.Error("inner handler not invoked")
-	}
-}
diff --git a/internal/serve/api/pane.go b/internal/serve/api/pane.go
deleted file mode 100644
index ea14ffb..0000000
--- a/internal/serve/api/pane.go
+++ /dev/null
@@ -1,162 +0,0 @@
-// Package api — pane.go: V24 live tmux pane capture SSE stream.
-//
-// Route wiring (owned by coordinator in server.go — do NOT edit here):
-//
-//	mux.Handle("GET /events/session/{name}/pane", authHF(api.PaneStream(s.tmux)))
-package api
-
-import (
-	"encoding/json"
-	"io"
-	"net/http"
-	"strconv"
-	"time"
-)
-
-// paneTick is the capture cadence. 1 Hz matches the design brief —
-// a shell prompt feels live without hammering tmux / the browser.
-const paneTick = 1 * time.Second
-
-// Default and upper bound for the scrollback window captured above
-// the visible pane area. Detached tmux sessions often collapse to a
-// small geometry (e.g. 55×28); without scrollback the viewer shows
-// only the last ~28 rows no matter how much output has been
-// produced. 500 lines is a generous debugging window; 10 000 caps a
-// pathological `?history=` query from shipping megabytes per tick.
-const (
-	defaultPaneScrollback = 500
-	maxPaneScrollback     = 10_000
-)
-
-// TmuxPaneCapturer is the narrow slice of *tmux.Client this handler
-// needs. A package-local interface keeps the api package decoupled
-// from internal/tmux (which would otherwise pull os/exec into every
-// api test binary) and makes the handler trivially faked.
-type TmuxPaneCapturer interface {
-	// CapturePaneHistory returns the raw output of
-	//   tmux capture-pane -e -p -J -t  -S -
-	// scrollback lines above the visible pane, with -e preserving
-	// SGR, -p writing to stdout, and -J joining wrapped lines.
-	CapturePaneHistory(name string, scrollback int) (string, error)
-}
-
-// PaneStream returns a GET /events/session/{name}/pane handler that
-// streams a live capture of the named tmux pane as SSE.
-//
-// Behaviour:
-//   - Emits one `event: pane` frame per tick (1 Hz) whose `data` is a
-//     JSON-encoded string containing the raw capture (escape sequences
-//     preserved).
-//   - Debounces identical payloads — a tick whose capture matches the
-//     last emitted payload is skipped. Keeps the stream quiet when the
-//     pane is idle.
-//   - Emits a single initial frame on connect so the UI has something
-//     to render immediately (no 1s blank state).
-//   - Exits cleanly when the client disconnects (r.Context().Done())
-//     or when the pane disappears (CapturePaneHistory returns an
-//     error twice in a row — we tolerate one transient miss).
-//   - `?history=` query param overrides the default scrollback
-//     window. Clamped to [0, maxPaneScrollback]. 0 = visible-only.
-func PaneStream(tmux TmuxPaneCapturer) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		name := r.PathValue("name")
-		if name == "" {
-			http.Error(w, "missing session name", http.StatusBadRequest)
-			return
-		}
-
-		scrollback := defaultPaneScrollback
-		if raw := r.URL.Query().Get("history"); raw != "" {
-			if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
-				if n > maxPaneScrollback {
-					n = maxPaneScrollback
-				}
-				scrollback = n
-			}
-		}
-
-		flusher, ok := w.(http.Flusher)
-		if !ok {
-			http.Error(w, "streaming unsupported", http.StatusInternalServerError)
-			return
-		}
-
-		hdr := w.Header()
-		hdr.Set("Content-Type", "text/event-stream")
-		hdr.Set("Cache-Control", "no-store")
-		hdr.Set("Connection", "keep-alive")
-		hdr.Set("X-Accel-Buffering", "no")
-		w.WriteHeader(http.StatusOK)
-
-		// Initial comment so fetch-event-source's onopen fires without
-		// waiting for the first real frame — matches events/sse.go.
-		if _, err := io.WriteString(w, ": ok\n\n"); err != nil {
-			return
-		}
-		flusher.Flush()
-
-		ctx := r.Context()
-		ticker := time.NewTicker(paneTick)
-		defer ticker.Stop()
-
-		var last string
-		var hadOne bool
-		var consecutiveErrs int
-
-		emit := func(payload string) bool {
-			if hadOne && payload == last {
-				return true // debounce
-			}
-			b, err := json.Marshal(payload)
-			if err != nil {
-				return false
-			}
-			if _, err := io.WriteString(w, "event: pane\ndata: "); err != nil {
-				return false
-			}
-			if _, err := w.Write(b); err != nil {
-				return false
-			}
-			if _, err := io.WriteString(w, "\n\n"); err != nil {
-				return false
-			}
-			flusher.Flush()
-			last = payload
-			hadOne = true
-			return true
-		}
-
-		// Initial capture + emission — so the UI has a first frame
-		// without waiting 1s.
-		if out, err := tmux.CapturePaneHistory(name, scrollback); err == nil {
-			if !emit(out) {
-				return
-			}
-		} else {
-			consecutiveErrs++
-		}
-
-		for {
-			select {
-			case <-ctx.Done():
-				return
-			case <-ticker.C:
-				out, err := tmux.CapturePaneHistory(name, scrollback)
-				if err != nil {
-					consecutiveErrs++
-					if consecutiveErrs >= 2 {
-						// Pane is gone — signal end politely.
-						_, _ = io.WriteString(w, "event: pane_end\ndata: \"\"\n\n")
-						flusher.Flush()
-						return
-					}
-					continue
-				}
-				consecutiveErrs = 0
-				if !emit(out) {
-					return
-				}
-			}
-		}
-	}
-}
diff --git a/internal/serve/api/pane_test.go b/internal/serve/api/pane_test.go
deleted file mode 100644
index f253f5b..0000000
--- a/internal/serve/api/pane_test.go
+++ /dev/null
@@ -1,270 +0,0 @@
-package api
-
-import (
-	"bufio"
-	"context"
-	"errors"
-	"io"
-	"net/http"
-	"net/http/httptest"
-	"strings"
-	"sync"
-	"sync/atomic"
-	"testing"
-	"time"
-)
-
-// fakeCapturer returns scripted outputs on successive calls. Once the
-// scripted slice is exhausted, the last entry is repeated so the loop
-// has a stable "idle" value to debounce against. lastScrollback
-// records the most recent CapturePaneHistory argument so tests can
-// assert the handler threaded `?history=` through correctly.
-type fakeCapturer struct {
-	mu             sync.Mutex
-	outputs        []string
-	errs           []error
-	calls          int32
-	lastScrollback int
-}
-
-func (f *fakeCapturer) CapturePaneHistory(_ string, scrollback int) (string, error) {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	f.lastScrollback = scrollback
-	i := int(atomic.AddInt32(&f.calls, 1)) - 1
-	var out string
-	var err error
-	if i < len(f.outputs) {
-		out = f.outputs[i]
-	} else if len(f.outputs) > 0 {
-		out = f.outputs[len(f.outputs)-1]
-	}
-	if i < len(f.errs) {
-		err = f.errs[i]
-	}
-	return out, err
-}
-
-// readPaneFrames scans an SSE body for `event: pane` frames and
-// returns their JSON-encoded data lines in order. Stops when the
-// reader errors (body closed) or after `want` frames.
-func readPaneFrames(t *testing.T, body io.Reader, want int, timeout time.Duration) []string {
-	t.Helper()
-	r := bufio.NewReader(body)
-	got := make([]string, 0, want)
-	done := make(chan struct{})
-	var mu sync.Mutex
-	go func() {
-		defer close(done)
-		var eventType string
-		for {
-			line, err := r.ReadString('\n')
-			if err != nil {
-				return
-			}
-			line = strings.TrimRight(line, "\r\n")
-			switch {
-			case strings.HasPrefix(line, "event: "):
-				eventType = strings.TrimPrefix(line, "event: ")
-			case strings.HasPrefix(line, "data: "):
-				if eventType == "pane" {
-					mu.Lock()
-					got = append(got, strings.TrimPrefix(line, "data: "))
-					n := len(got)
-					mu.Unlock()
-					if n >= want {
-						return
-					}
-				}
-			case line == "":
-				eventType = ""
-			}
-		}
-	}()
-	select {
-	case <-done:
-	case <-time.After(timeout):
-	}
-	mu.Lock()
-	defer mu.Unlock()
-	return append([]string(nil), got...)
-}
-
-func TestPaneStream_EmitsFramesWithCorrectFormat(t *testing.T) {
-	fake := &fakeCapturer{outputs: []string{"alpha\n", "bravo\n", "charlie\n"}}
-
-	h := PaneStream(fake)
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		r.SetPathValue("name", "alpha")
-		h(w, r)
-	}))
-	defer srv.Close()
-
-	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
-	defer cancel()
-	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		t.Fatalf("GET: %v", err)
-	}
-	defer resp.Body.Close()
-
-	if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
-		t.Errorf("content-type = %q, want text/event-stream", ct)
-	}
-
-	frames := readPaneFrames(t, resp.Body, 3, 2800*time.Millisecond)
-	if len(frames) < 3 {
-		t.Fatalf("frames = %d (%v), want >=3", len(frames), frames)
-	}
-	wants := []string{`"alpha\n"`, `"bravo\n"`, `"charlie\n"`}
-	for i, w := range wants {
-		if frames[i] != w {
-			t.Errorf("frame[%d] = %q, want %q", i, frames[i], w)
-		}
-	}
-}
-
-func TestPaneStream_DebouncesIdenticalPayloads(t *testing.T) {
-	fake := &fakeCapturer{outputs: []string{"same\n"}}
-
-	h := PaneStream(fake)
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		r.SetPathValue("name", "alpha")
-		h(w, r)
-	}))
-	defer srv.Close()
-
-	ctx, cancel := context.WithTimeout(context.Background(), 2500*time.Millisecond)
-	defer cancel()
-	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		t.Fatalf("GET: %v", err)
-	}
-	defer resp.Body.Close()
-
-	frames := readPaneFrames(t, resp.Body, 3, 2300*time.Millisecond)
-	if len(frames) != 1 {
-		t.Errorf("frames = %d (%v), want 1 (identical payloads debounced)", len(frames), frames)
-	}
-}
-
-func TestPaneStream_ExitsOnClientDisconnect(t *testing.T) {
-	fake := &fakeCapturer{outputs: []string{"one\n"}}
-
-	done := make(chan struct{})
-	h := PaneStream(fake)
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		r.SetPathValue("name", "alpha")
-		h(w, r)
-		close(done)
-	}))
-	defer srv.Close()
-
-	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
-	defer cancel()
-	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		t.Fatalf("GET: %v", err)
-	}
-
-	_ = readPaneFrames(t, resp.Body, 1, 1500*time.Millisecond)
-	resp.Body.Close()
-	cancel()
-
-	select {
-	case <-done:
-	case <-time.After(3 * time.Second):
-		t.Fatal("handler did not exit within 3s of client disconnect")
-	}
-}
-
-func TestPaneStream_ErrorTwiceEndsStream(t *testing.T) {
-	fake := &fakeCapturer{
-		outputs: []string{"ok\n", "", ""},
-		errs:    []error{nil, errors.New("gone"), errors.New("gone")},
-	}
-	h := PaneStream(fake)
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		r.SetPathValue("name", "alpha")
-		h(w, r)
-	}))
-	defer srv.Close()
-
-	ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
-	defer cancel()
-	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		t.Fatalf("GET: %v", err)
-	}
-	defer resp.Body.Close()
-
-	body, _ := io.ReadAll(resp.Body)
-	if !strings.Contains(string(body), "event: pane\ndata: \"ok\\n\"") {
-		t.Errorf("expected initial pane frame, got:\n%s", string(body))
-	}
-	if !strings.Contains(string(body), "event: pane_end") {
-		t.Errorf("expected pane_end frame after consecutive errors, got:\n%s", string(body))
-	}
-}
-
-func TestPaneStream_DefaultScrollbackAndOverride(t *testing.T) {
-	cases := []struct {
-		name    string
-		query   string
-		want    int
-	}{
-		{name: "default when unset", query: "", want: defaultPaneScrollback},
-		{name: "honours ?history=250", query: "history=250", want: 250},
-		{name: "caps at maxPaneScrollback", query: "history=99999", want: maxPaneScrollback},
-		{name: "zero disables scrollback", query: "history=0", want: 0},
-		{name: "negative falls back to default", query: "history=-5", want: defaultPaneScrollback},
-		{name: "garbage falls back to default", query: "history=notanumber", want: defaultPaneScrollback},
-	}
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			fake := &fakeCapturer{outputs: []string{"x\n"}}
-			h := PaneStream(fake)
-			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-				r.SetPathValue("name", "alpha")
-				h(w, r)
-			}))
-			defer srv.Close()
-
-			ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond)
-			defer cancel()
-			url := srv.URL
-			if tc.query != "" {
-				url += "?" + tc.query
-			}
-			req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
-			resp, err := http.DefaultClient.Do(req)
-			if err != nil {
-				t.Fatalf("GET: %v", err)
-			}
-			defer resp.Body.Close()
-			_ = readPaneFrames(t, resp.Body, 1, 900*time.Millisecond)
-
-			fake.mu.Lock()
-			got := fake.lastScrollback
-			fake.mu.Unlock()
-			if got != tc.want {
-				t.Errorf("lastScrollback = %d, want %d", got, tc.want)
-			}
-		})
-	}
-}
-
-func TestPaneStream_MissingNameReturns400(t *testing.T) {
-	fake := &fakeCapturer{outputs: []string{"x"}}
-	h := PaneStream(fake)
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodGet, "/events/session//pane", nil)
-	h(rec, req)
-	if rec.Code != http.StatusBadRequest {
-		t.Errorf("status = %d, want 400", rec.Code)
-	}
-}
diff --git a/internal/serve/api/quota.go b/internal/serve/api/quota.go
deleted file mode 100644
index 4448795..0000000
--- a/internal/serve/api/quota.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"time"
-)
-
-// QuotaSource is the subset of ingest.QuotaIngester the Quota handler
-// depends on. Accepting an interface lets tests inject a fake without
-// touching fsnotify.
-type QuotaSource interface {
-	// Snapshot returns WeeklyPct, FiveHourPct, WeeklyResetsAt,
-	// FiveHourResetAt, Known — matching ingest.GlobalSnapshot field
-	// order via a struct return.
-	Snapshot() QuotaSnapshot
-}
-
-// QuotaSnapshot mirrors ingest.GlobalSnapshot so the api package
-// doesn't need to import internal/serve/ingest (mirrors the pattern
-// used by hubStatsAdapter in server.go). Exported because the
-// quotaSourceAdapter in server.go converts between the ingest
-// snapshot and this shape at the package boundary.
-type QuotaSnapshot struct {
-	WeeklyPct       int
-	FiveHourPct     int
-	WeeklyResetsAt  time.Time
-	FiveHourResetAt time.Time
-	Known           bool
-}
-
-// Quota returns the GET /api/quota handler. Shape mirrors the SSE
-// `quota_update` global payload exactly so the SPA can feed the same
-// TanStack cache key from both sources:
-//
-//	{"weekly_pct":46,"five_hr_pct":3,
-//	 "weekly_resets_at":"2026-04-22T13:00:00Z",
-//	 "five_hr_resets_at":"2026-04-21T18:00:00Z"}
-//
-// If no statusline dump has populated rate limits yet, responds 204
-// No Content so the client leaves the cache null and renders "—"
-// placeholders — matches spec §5 ("bars render before first event").
-func Quota(src QuotaSource) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodGet {
-			w.Header().Set("Allow", http.MethodGet)
-			w.Header().Set("Cache-Control", "no-store")
-			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-			return
-		}
-		w.Header().Set("Cache-Control", "no-store")
-
-		snap := src.Snapshot()
-		if !snap.Known {
-			w.WriteHeader(http.StatusNoContent)
-			return
-		}
-
-		body := struct {
-			WeeklyPct      int    `json:"weekly_pct"`
-			FiveHrPct      int    `json:"five_hr_pct"`
-			WeeklyResetsAt string `json:"weekly_resets_at"`
-			FiveHrResetsAt string `json:"five_hr_resets_at"`
-		}{
-			WeeklyPct:      snap.WeeklyPct,
-			FiveHrPct:      snap.FiveHourPct,
-			WeeklyResetsAt: rfc3339OrEmpty(snap.WeeklyResetsAt),
-			FiveHrResetsAt: rfc3339OrEmpty(snap.FiveHourResetAt),
-		}
-		w.Header().Set("Content-Type", "application/json")
-		w.WriteHeader(http.StatusOK)
-		_ = json.NewEncoder(w).Encode(body)
-	}
-}
-
-func rfc3339OrEmpty(t time.Time) string {
-	if t.IsZero() {
-		return ""
-	}
-	return t.UTC().Format(time.RFC3339)
-}
diff --git a/internal/serve/api/quota_test.go b/internal/serve/api/quota_test.go
deleted file mode 100644
index df2b5ce..0000000
--- a/internal/serve/api/quota_test.go
+++ /dev/null
@@ -1,134 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"strings"
-	"testing"
-	"time"
-)
-
-// fakeQuotaSrc is a tiny in-memory QuotaSource so the Quota handler can
-// be exercised without touching ingest.
-type fakeQuotaSrc struct{ snap QuotaSnapshot }
-
-func (f fakeQuotaSrc) Snapshot() QuotaSnapshot { return f.snap }
-
-func TestQuota_HappyPath(t *testing.T) {
-	weeklyReset := time.Date(2026, 4, 22, 13, 0, 0, 0, time.UTC)
-	fiveReset := time.Date(2026, 4, 21, 18, 0, 0, 0, time.UTC)
-	src := fakeQuotaSrc{snap: QuotaSnapshot{
-		WeeklyPct:       46,
-		FiveHourPct:     3,
-		WeeklyResetsAt:  weeklyReset,
-		FiveHourResetAt: fiveReset,
-		Known:           true,
-	}}
-	h := Quota(src)
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodGet, "/api/quota", nil))
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	if got := rec.Header().Get("Cache-Control"); got != "no-store" {
-		t.Errorf("Cache-Control = %q, want no-store", got)
-	}
-	if got := rec.Header().Get("Content-Type"); got != "application/json" {
-		t.Errorf("Content-Type = %q, want application/json", got)
-	}
-
-	var body struct {
-		WeeklyPct      int    `json:"weekly_pct"`
-		FiveHrPct      int    `json:"five_hr_pct"`
-		WeeklyResetsAt string `json:"weekly_resets_at"`
-		FiveHrResetsAt string `json:"five_hr_resets_at"`
-	}
-	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body.WeeklyPct != 46 || body.FiveHrPct != 3 {
-		t.Errorf("body pcts = (%d,%d), want (46,3)", body.WeeklyPct, body.FiveHrPct)
-	}
-	if body.WeeklyResetsAt != weeklyReset.Format(time.RFC3339) {
-		t.Errorf("weekly_resets_at = %q, want %q", body.WeeklyResetsAt, weeklyReset.Format(time.RFC3339))
-	}
-	if body.FiveHrResetsAt != fiveReset.Format(time.RFC3339) {
-		t.Errorf("five_hr_resets_at = %q, want %q", body.FiveHrResetsAt, fiveReset.Format(time.RFC3339))
-	}
-}
-
-func TestQuota_UnknownReturns204(t *testing.T) {
-	src := fakeQuotaSrc{snap: QuotaSnapshot{Known: false}}
-	h := Quota(src)
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodGet, "/api/quota", nil))
-
-	if rec.Code != http.StatusNoContent {
-		t.Fatalf("status = %d, want 204", rec.Code)
-	}
-	if rec.Body.Len() != 0 {
-		t.Errorf("body should be empty, got %q", rec.Body.String())
-	}
-}
-
-func TestQuota_MethodNotAllowed(t *testing.T) {
-	h := Quota(fakeQuotaSrc{snap: QuotaSnapshot{Known: true}})
-	for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
-		rec := httptest.NewRecorder()
-		h(rec, httptest.NewRequest(m, "/api/quota", strings.NewReader("")))
-		if rec.Code != http.StatusMethodNotAllowed {
-			t.Errorf("%s status = %d, want 405", m, rec.Code)
-		}
-		if got := rec.Header().Get("Allow"); got != http.MethodGet {
-			t.Errorf("%s Allow = %q, want GET", m, got)
-		}
-	}
-}
-
-func TestQuota_ZeroResetTimesEmitEmptyStrings(t *testing.T) {
-	src := fakeQuotaSrc{snap: QuotaSnapshot{
-		WeeklyPct:   12,
-		FiveHourPct: 7,
-		Known:       true,
-		// Reset times left as zero — handler must serialize as "".
-	}}
-	h := Quota(src)
-	rec := httptest.NewRecorder()
-	h(rec, httptest.NewRequest(http.MethodGet, "/api/quota", nil))
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var body map[string]any
-	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if got, _ := body["weekly_resets_at"].(string); got != "" {
-		t.Errorf("weekly_resets_at = %q, want empty string", got)
-	}
-	if got, _ := body["five_hr_resets_at"].(string); got != "" {
-		t.Errorf("five_hr_resets_at = %q, want empty string", got)
-	}
-}
-
-func TestRfc3339OrEmpty(t *testing.T) {
-	if got := rfc3339OrEmpty(time.Time{}); got != "" {
-		t.Errorf("rfc3339OrEmpty(zero) = %q, want empty", got)
-	}
-	// Non-UTC input must be normalized to UTC in the output.
-	loc, err := time.LoadLocation("America/New_York")
-	if err != nil {
-		t.Fatalf("LoadLocation: %v", err)
-	}
-	in := time.Date(2026, 1, 2, 3, 4, 5, 0, loc)
-	got := rfc3339OrEmpty(in)
-	want := in.UTC().Format(time.RFC3339)
-	if got != want {
-		t.Errorf("rfc3339OrEmpty(NY time) = %q, want %q", got, want)
-	}
-	if !strings.HasSuffix(got, "Z") {
-		t.Errorf("rfc3339OrEmpty output %q should end with Z (UTC)", got)
-	}
-}
diff --git a/internal/serve/api/revert.go b/internal/serve/api/revert.go
deleted file mode 100644
index 3e47e3b..0000000
--- a/internal/serve/api/revert.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"errors"
-	"net/http"
-	"strings"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/git"
-)
-
-// revertFn is the seam tests inject; production wires git.Revert.
-var revertFn = git.Revert
-
-type revertRequest struct {
-	SHA        string `json:"sha"`
-	StashFirst bool   `json:"stash_first"`
-}
-
-// Revert returns the POST handler for /api/sessions/{name}/revert.
-//
-// resolveWorkdir maps a session name to its workdir (false → 404).
-// allowedSHA reports whether `sha` appears in the corresponding
-// `/checkpoints` listing for `name` — the sole guard preventing a
-// caller from `git reset --hard` to an arbitrary SHA.
-func Revert(
-	resolveWorkdir func(name string) (string, bool),
-	allowedSHA func(name, sha string) bool,
-) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodPost {
-			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-			return
-		}
-
-		name := r.PathValue("name")
-		if name == "" {
-			http.NotFound(w, r)
-			return
-		}
-
-		workdir, ok := resolveWorkdir(name)
-		if !ok {
-			http.NotFound(w, r)
-			return
-		}
-
-		dec := json.NewDecoder(r.Body)
-		dec.DisallowUnknownFields()
-		var req revertRequest
-		if err := dec.Decode(&req); err != nil {
-			writeJSONError(w, http.StatusBadRequest, "invalid_request")
-			return
-		}
-		req.SHA = strings.TrimSpace(req.SHA)
-		if req.SHA == "" {
-			writeJSONError(w, http.StatusBadRequest, "missing_sha")
-			return
-		}
-
-		if !allowedSHA(name, req.SHA) {
-			writeJSONError(w, http.StatusUnprocessableEntity, "sha_not_a_checkpoint")
-			return
-		}
-
-		result, err := revertFn(workdir, req.SHA, req.StashFirst)
-		if err != nil {
-			var dirty *git.DirtyError
-			if errors.As(err, &dirty) {
-				w.Header().Set(headerContentType, contentTypeJSON)
-				w.Header().Set(headerCacheControl, cacheControlNoStore)
-				w.WriteHeader(http.StatusConflict)
-				_ = json.NewEncoder(w).Encode(map[string]any{
-					"error":       "dirty_workdir",
-					"dirty_files": dirty.Files,
-				})
-				return
-			}
-			// If the stash succeeded but the subsequent reset failed,
-			// `result.StashedAs` carries the stash SHA the user will
-			// need to recover with `git stash pop `. Surface it
-			// so the UI can show a recovery hint instead of orphaning
-			// the user's uncommitted work.
-			body := map[string]any{"error": sanitiseErr(err)}
-			if result.StashedAs != "" {
-				body["stashed_as"] = result.StashedAs
-			}
-			w.Header().Set(headerContentType, contentTypeJSON)
-			w.Header().Set(headerCacheControl, cacheControlNoStore)
-			w.WriteHeader(http.StatusInternalServerError)
-			_ = json.NewEncoder(w).Encode(body)
-			return
-		}
-
-		w.Header().Set(headerContentType, contentTypeJSON)
-		w.Header().Set(headerCacheControl, cacheControlNoStore)
-		_ = json.NewEncoder(w).Encode(result)
-	}
-}
-
-func writeJSONError(w http.ResponseWriter, status int, msg string) {
-	w.Header().Set(headerContentType, contentTypeJSON)
-	w.Header().Set(headerCacheControl, cacheControlNoStore)
-	w.WriteHeader(status)
-	_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
-}
-
-// sanitiseErr collapses an error to a short, log-style token so we
-// don't leak filesystem paths or git internals over the wire.
-func sanitiseErr(err error) string {
-	s := err.Error()
-	// Keep the leading "verb" (e.g. "git reset --hard ") and drop
-	// anything past the first colon — that's where stderr starts.
-	if i := strings.Index(s, ":"); i > 0 {
-		return s[:i]
-	}
-	return s
-}
diff --git a/internal/serve/api/revert_test.go b/internal/serve/api/revert_test.go
deleted file mode 100644
index 31f33af..0000000
--- a/internal/serve/api/revert_test.go
+++ /dev/null
@@ -1,181 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"strings"
-	"testing"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/git"
-)
-
-func TestRevert_405OnGet(t *testing.T) {
-	h := Revert(
-		func(name string) (string, bool) { return "/wd", true },
-		func(name, sha string) bool { return true },
-	)
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodGet, "/api/sessions/s/revert", nil)
-	req.SetPathValue("name", "s")
-	h(rec, req)
-	if rec.Code != http.StatusMethodNotAllowed {
-		t.Errorf("status = %d, want 405", rec.Code)
-	}
-}
-
-func TestRevert_404OnUnknownSession(t *testing.T) {
-	h := Revert(
-		func(name string) (string, bool) { return "", false },
-		func(name, sha string) bool { return true },
-	)
-	rec := httptest.NewRecorder()
-	body := strings.NewReader(`{"sha":"deadbeef"}`)
-	req := httptest.NewRequest(http.MethodPost, "/api/sessions/s/revert", body)
-	req.SetPathValue("name", "s")
-	h(rec, req)
-	if rec.Code != http.StatusNotFound {
-		t.Errorf("status = %d, want 404", rec.Code)
-	}
-}
-
-func TestRevert_400OnUnknownField(t *testing.T) {
-	h := Revert(
-		func(name string) (string, bool) { return "/wd", true },
-		func(name, sha string) bool { return true },
-	)
-	rec := httptest.NewRecorder()
-	body := strings.NewReader(`{"sha":"a","danger":true}`)
-	req := httptest.NewRequest(http.MethodPost, "/api/sessions/s/revert", body)
-	req.SetPathValue("name", "s")
-	h(rec, req)
-	if rec.Code != http.StatusBadRequest {
-		t.Errorf("status = %d, want 400", rec.Code)
-	}
-}
-
-func TestRevert_400OnEmptyBody(t *testing.T) {
-	h := Revert(
-		func(name string) (string, bool) { return "/wd", true },
-		func(name, sha string) bool { return true },
-	)
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodPost, "/api/sessions/s/revert", strings.NewReader(""))
-	req.SetPathValue("name", "s")
-	h(rec, req)
-	if rec.Code != http.StatusBadRequest {
-		t.Errorf("status = %d, want 400", rec.Code)
-	}
-}
-
-func TestRevert_422OnDisallowedSHA(t *testing.T) {
-	h := Revert(
-		func(name string) (string, bool) { return "/wd", true },
-		func(name, sha string) bool { return false },
-	)
-	rec := httptest.NewRecorder()
-	body := strings.NewReader(`{"sha":"deadbeef"}`)
-	req := httptest.NewRequest(http.MethodPost, "/api/sessions/s/revert", body)
-	req.SetPathValue("name", "s")
-	h(rec, req)
-	if rec.Code != http.StatusUnprocessableEntity {
-		t.Fatalf("status = %d, want 422", rec.Code)
-	}
-	var got map[string]string
-	_ = json.NewDecoder(rec.Body).Decode(&got)
-	if got["error"] != "sha_not_a_checkpoint" {
-		t.Errorf("error = %q, want sha_not_a_checkpoint", got["error"])
-	}
-}
-
-func TestRevert_409OnDirtyWorkdir(t *testing.T) {
-	prev := revertFn
-	t.Cleanup(func() { revertFn = prev })
-	revertFn = func(workdir, sha string, stashFirst bool) (git.RevertResult, error) {
-		return git.RevertResult{}, &git.DirtyError{Files: []string{"README", "main.go"}}
-	}
-
-	h := Revert(
-		func(name string) (string, bool) { return "/wd", true },
-		func(name, sha string) bool { return true },
-	)
-	rec := httptest.NewRecorder()
-	body := strings.NewReader(`{"sha":"abc"}`)
-	req := httptest.NewRequest(http.MethodPost, "/api/sessions/s/revert", body)
-	req.SetPathValue("name", "s")
-	h(rec, req)
-	if rec.Code != http.StatusConflict {
-		t.Fatalf("status = %d, want 409", rec.Code)
-	}
-	var got struct {
-		Error      string   `json:"error"`
-		DirtyFiles []string `json:"dirty_files"`
-	}
-	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if got.Error != "dirty_workdir" {
-		t.Errorf("error = %q, want dirty_workdir", got.Error)
-	}
-	if len(got.DirtyFiles) != 2 || got.DirtyFiles[0] != "README" {
-		t.Errorf("dirty_files = %v", got.DirtyFiles)
-	}
-}
-
-func TestRevert_200OnSuccess(t *testing.T) {
-	prev := revertFn
-	t.Cleanup(func() { revertFn = prev })
-	revertFn = func(workdir, sha string, stashFirst bool) (git.RevertResult, error) {
-		return git.RevertResult{OK: true, RevertedTo: sha, StashedAs: "stashSHA"}, nil
-	}
-
-	h := Revert(
-		func(name string) (string, bool) { return "/wd", true },
-		func(name, sha string) bool { return true },
-	)
-	rec := httptest.NewRecorder()
-	body := strings.NewReader(`{"sha":"abc","stash_first":true}`)
-	req := httptest.NewRequest(http.MethodPost, "/api/sessions/s/revert", body)
-	req.SetPathValue("name", "s")
-	h(rec, req)
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var got git.RevertResult
-	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if !got.OK || got.RevertedTo != "abc" || got.StashedAs != "stashSHA" {
-		t.Errorf("result = %+v", got)
-	}
-}
-
-func TestRevert_500OnGenericError(t *testing.T) {
-	prev := revertFn
-	t.Cleanup(func() { revertFn = prev })
-	revertFn = func(workdir, sha string, stashFirst bool) (git.RevertResult, error) {
-		return git.RevertResult{}, errStub("git reset --hard abc: exit 128: bad object")
-	}
-	h := Revert(
-		func(name string) (string, bool) { return "/wd", true },
-		func(name, sha string) bool { return true },
-	)
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodPost, "/api/sessions/s/revert", strings.NewReader(`{"sha":"abc"}`))
-	req.SetPathValue("name", "s")
-	h(rec, req)
-	if rec.Code != http.StatusInternalServerError {
-		t.Fatalf("status = %d, want 500", rec.Code)
-	}
-	var got map[string]string
-	_ = json.NewDecoder(rec.Body).Decode(&got)
-	// sanitiseErr drops everything past the first colon — should not
-	// leak "bad object" or the SHA-bearing path.
-	if strings.Contains(got["error"], "bad object") {
-		t.Errorf("error leaked stderr: %q", got["error"])
-	}
-}
-
-type errStub string
-
-func (e errStub) Error() string { return string(e) }
diff --git a/internal/serve/api/sessions.go b/internal/serve/api/sessions.go
deleted file mode 100644
index 8ceb1da..0000000
--- a/internal/serve/api/sessions.go
+++ /dev/null
@@ -1,182 +0,0 @@
-// Package api hosts the HTTP handlers for ctm serve. Each file in this
-// package owns one resource family; this file owns /api/sessions and
-// /api/sessions/{name}.
-//
-// Wiring lives in internal/serve/server.go (route registration is the
-// caller's responsibility). Handlers here return enriched Session views
-// per spec §6: fields the projection cannot yet populate
-// (last_tool_call_at, context_pct, attention) are sourced through the
-// SessionEnricher interface and OMITTED from the JSON when the
-// enricher reports no value. Later steps in the ctm-serve plan will
-// supply real enricher implementations.
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/ingest"
-	"github.com/RandomCodeSpace/ctm/internal/session"
-)
-
-// Attention mirrors the spec §6 Session.attention sub-object. Values
-// come from a future attention engine; for now the enricher always
-// reports "no value" and the field is omitted.
-type Attention struct {
-	State   string    `json:"state"`
-	Since   time.Time `json:"since"`
-	Details string    `json:"details,omitempty"`
-}
-
-// SessionEnricher supplies the per-session fields that the sessions
-// projection cannot derive on its own. Implementations return ok=false
-// to signal "no value yet"; handlers omit those fields from the JSON.
-//
-// Stable interface — later steps plug in real implementations
-// (tool-call tailer, statusline-dump quota ingest, attention engine)
-// without changing this signature.
-type SessionEnricher interface {
-	LastToolCallAt(name string) (time.Time, bool)
-	ContextPct(name string) (int, bool)
-	Attention(name string) (Attention, bool)
-	// Tokens returns the live per-session token breakdown from the
-	// last statusline dump's current_usage. ok=false means no dump
-	// has been ingested yet for this session.
-	Tokens(name string) (TokenUsage, bool)
-}
-
-// TokenUsage mirrors the statusline dump's `context_window.current_usage`
-// payload the ingester captures per session. All three fields are
-// current-turn counts, not cumulative session totals.
-type TokenUsage struct {
-	InputTokens  int `json:"input_tokens"`
-	OutputTokens int `json:"output_tokens"`
-	// CacheTokens is creation + read from the statusline dump; the UI
-	// doesn't distinguish, so we collapse them at ingest time.
-	CacheTokens int `json:"cache_tokens"`
-}
-
-// NoopEnricher reports no values for every field. Useful as the default
-// while the underlying ingestors are still being built.
-type NoopEnricher struct{}
-
-func (NoopEnricher) LastToolCallAt(string) (time.Time, bool) { return time.Time{}, false }
-func (NoopEnricher) ContextPct(string) (int, bool)           { return 0, false }
-func (NoopEnricher) Attention(string) (Attention, bool)      { return Attention{}, false }
-func (NoopEnricher) Tokens(string) (TokenUsage, bool)        { return TokenUsage{}, false }
-
-// sessionView is the spec §6 enriched-view JSON shape. Pointer types
-// for the optional enriched fields so we can omit them entirely when
-// the enricher has nothing to report (omitempty on a pointer drops the
-// key; on a value type it would keep "context_pct":0 — wrong).
-type sessionView struct {
-	Name             string     `json:"name"`
-	UUID             string     `json:"uuid"`
-	Mode             string     `json:"mode"`
-	Workdir          string     `json:"workdir"`
-	CreatedAt        time.Time  `json:"created_at"`
-	LastAttachedAt   *time.Time `json:"last_attached_at,omitempty"`
-	IsActive         bool       `json:"is_active"`
-	TmuxAlive        bool       `json:"tmux_alive"`
-	LastToolCallAt   *time.Time  `json:"last_tool_call_at,omitempty"`
-	ContextPct       *int        `json:"context_pct,omitempty"`
-	Attention        *Attention  `json:"attention,omitempty"`
-	Tokens           *TokenUsage `json:"tokens,omitempty"`
-}
-
-// buildView projects a session.Session + enrichment + tmux liveness
-// into the spec §6 sessionView shape.
-func buildView(s session.Session, alive bool, e SessionEnricher) sessionView {
-	v := sessionView{
-		Name:      s.Name,
-		UUID:      s.UUID,
-		Mode:      s.Mode,
-		Workdir:   s.Workdir,
-		CreatedAt: s.CreatedAt,
-		// is_active in v0.1: present in our books AND tmux confirms it.
-		// Reconciliation against tmux happens elsewhere; for the read
-		// model, "is_active" simply tracks tmux liveness.
-		IsActive:  alive,
-		TmuxAlive: alive,
-	}
-	if !s.LastAttachedAt.IsZero() {
-		t := s.LastAttachedAt
-		v.LastAttachedAt = &t
-	}
-	if t, ok := e.LastToolCallAt(s.Name); ok {
-		v.LastToolCallAt = &t
-	}
-	if pct, ok := e.ContextPct(s.Name); ok {
-		v.ContextPct = &pct
-	}
-	if att, ok := e.Attention(s.Name); ok {
-		a := att
-		v.Attention = &a
-	}
-	if t, ok := e.Tokens(s.Name); ok {
-		tu := t
-		v.Tokens = &tu
-	}
-	return v
-}
-
-// List returns GET /api/sessions — the full sessionView slice.
-func List(p *ingest.Projection, e SessionEnricher) http.HandlerFunc {
-	if e == nil {
-		e = NoopEnricher{}
-	}
-	return func(w http.ResponseWriter, r *http.Request) {
-		all := p.All()
-		out := make([]sessionView, 0, len(all))
-		for _, s := range all {
-			out = append(out, buildView(s, p.TmuxAlive(s.Name), e))
-		}
-		writeJSON(w, http.StatusOK, out)
-	}
-}
-
-// Get returns GET /api/sessions/{name} — a single sessionView, or 404.
-// Uses the Go 1.22+ http.ServeMux path-pattern variable {name}.
-func Get(p *ingest.Projection, e SessionEnricher) http.HandlerFunc {
-	if e == nil {
-		e = NoopEnricher{}
-	}
-	return func(w http.ResponseWriter, r *http.Request) {
-		name := r.PathValue("name")
-		s, ok := p.Get(name)
-		if !ok {
-			writeJSON(w, http.StatusNotFound, errorBody{Error: "session not found", Name: name})
-			return
-		}
-		writeJSON(w, http.StatusOK, buildView(s, p.TmuxAlive(name), e))
-	}
-}
-
-// errorBody is the small JSON shape for 4xx responses from this file.
-// Kept local to avoid premature shared-types ceremony — the wider error
-// model lives in spec §7 and will be unified in a later step.
-type errorBody struct {
-	Error string `json:"error"`
-	Name  string `json:"name,omitempty"`
-}
-
-// writeJSON writes v as JSON with the conventions used by the rest of
-// internal/serve/api: application/json, no-store cache header, and a
-// trailing newline. Errors during marshal degrade to a 500 with the
-// error string — these handlers serialize plain structs, so marshal
-// failure is effectively impossible in practice.
-func writeJSON(w http.ResponseWriter, status int, v any) {
-	body, err := json.Marshal(v)
-	if err != nil {
-		w.Header().Set("Content-Type", "application/json")
-		w.Header().Set("Cache-Control", "no-store")
-		w.WriteHeader(http.StatusInternalServerError)
-		_, _ = w.Write([]byte(`{"error":"marshal failed"}` + "\n"))
-		return
-	}
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("Cache-Control", "no-store")
-	w.WriteHeader(status)
-	_, _ = w.Write(append(body, '\n'))
-}
diff --git a/internal/serve/api/sessions_test.go b/internal/serve/api/sessions_test.go
deleted file mode 100644
index 19dd3fa..0000000
--- a/internal/serve/api/sessions_test.go
+++ /dev/null
@@ -1,250 +0,0 @@
-package api_test
-
-import (
-	"context"
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"path/filepath"
-	"sync"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/api"
-	"github.com/RandomCodeSpace/ctm/internal/serve/ingest"
-	"github.com/RandomCodeSpace/ctm/internal/session"
-)
-
-type fakeTmux struct {
-	mu    sync.Mutex
-	alive map[string]bool
-}
-
-func (f *fakeTmux) HasSession(name string) bool {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	return f.alive[name]
-}
-
-func writeSessions(t *testing.T, path string, sessions ...*session.Session) {
-	t.Helper()
-	m := make(map[string]*session.Session, len(sessions))
-	for _, s := range sessions {
-		m[s.Name] = s
-	}
-	body := map[string]any{
-		"schema_version": session.SchemaVersion,
-		"sessions":       m,
-	}
-	data, err := json.Marshal(body)
-	if err != nil {
-		t.Fatalf("marshal: %v", err)
-	}
-	if err := os.WriteFile(path, data, 0600); err != nil {
-		t.Fatalf("write: %v", err)
-	}
-}
-
-// boot constructs a projection bound to a temp sessions.json prefilled
-// with sessions, runs it until ready, and returns it plus the fake tmux
-// (so tests can flip alive state).
-func boot(t *testing.T, alive map[string]bool, sessions ...*session.Session) (*ingest.Projection, *fakeTmux) {
-	t.Helper()
-	dir := t.TempDir()
-	path := filepath.Join(dir, "sessions.json")
-	writeSessions(t, path, sessions...)
-
-	tx := &fakeTmux{alive: alive}
-	p := ingest.New(path, tx)
-	ctx, cancel := context.WithCancel(context.Background())
-	t.Cleanup(cancel)
-	go func() { _ = p.Run(ctx) }()
-
-	deadline := time.Now().Add(2 * time.Second)
-	for time.Now().Before(deadline) {
-		if len(p.All()) == len(sessions) {
-			return p, tx
-		}
-		time.Sleep(20 * time.Millisecond)
-	}
-	t.Fatalf("projection never reached %d sessions; All=%v", len(sessions), p.All())
-	return nil, nil
-}
-
-func TestList_OmitsUnpopulatedFields(t *testing.T) {
-	s := session.New("alpha", "/work/alpha", "safe")
-	p, _ := boot(t, map[string]bool{"alpha": true}, s)
-
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil)
-	api.List(p, nil)(rec, req)
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	if got := rec.Header().Get("Content-Type"); got != "application/json" {
-		t.Errorf("Content-Type = %q, want application/json", got)
-	}
-	if got := rec.Header().Get("Cache-Control"); got != "no-store" {
-		t.Errorf("Cache-Control = %q, want no-store", got)
-	}
-
-	var out []map[string]any
-	if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if len(out) != 1 {
-		t.Fatalf("expected 1 session in list, got %d (%v)", len(out), out)
-	}
-	got := out[0]
-	if got["name"] != "alpha" {
-		t.Errorf("name = %v", got["name"])
-	}
-	if got["tmux_alive"] != true {
-		t.Errorf("tmux_alive = %v, want true", got["tmux_alive"])
-	}
-	if got["is_active"] != true {
-		t.Errorf("is_active = %v, want true", got["is_active"])
-	}
-	for _, key := range []string{"last_tool_call_at", "context_pct", "attention"} {
-		if _, present := got[key]; present {
-			t.Errorf("%q must be omitted when enricher reports no value; got %v", key, got[key])
-		}
-	}
-	// last_attached_at is omitempty when zero — newly minted session has zero LastAttachedAt.
-	if _, present := got["last_attached_at"]; present {
-		t.Errorf("last_attached_at must be omitted for never-attached session; got %v", got["last_attached_at"])
-	}
-}
-
-func TestGet_UnknownReturns404(t *testing.T) {
-	s := session.New("alpha", "/work/alpha", "safe")
-	p, _ := boot(t, map[string]bool{"alpha": true}, s)
-
-	mux := http.NewServeMux()
-	mux.Handle("GET /api/sessions/{name}", api.Get(p, nil))
-
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodGet, "/api/sessions/ghost", nil)
-	mux.ServeHTTP(rec, req)
-
-	if rec.Code != http.StatusNotFound {
-		t.Fatalf("status = %d, want 404", rec.Code)
-	}
-	var body map[string]any
-	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body["error"] == nil {
-		t.Errorf("expected error field in 404 body, got %v", body)
-	}
-}
-
-func TestGet_KnownReflectsTmuxAlive(t *testing.T) {
-	s := session.New("alpha", "/work/alpha", "yolo")
-	p, _ := boot(t, map[string]bool{"alpha": false}, s) // tmux says dead
-
-	mux := http.NewServeMux()
-	mux.Handle("GET /api/sessions/{name}", api.Get(p, nil))
-
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodGet, "/api/sessions/alpha", nil)
-	mux.ServeHTTP(rec, req)
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var body map[string]any
-	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body["name"] != "alpha" {
-		t.Errorf("name = %v, want alpha", body["name"])
-	}
-	if body["tmux_alive"] != false {
-		t.Errorf("tmux_alive = %v, want false", body["tmux_alive"])
-	}
-	if body["is_active"] != false {
-		t.Errorf("is_active = %v, want false", body["is_active"])
-	}
-	if body["mode"] != "yolo" {
-		t.Errorf("mode = %v, want yolo", body["mode"])
-	}
-	if body["uuid"] != s.UUID {
-		t.Errorf("uuid = %v, want %v", body["uuid"], s.UUID)
-	}
-	for _, key := range []string{"last_tool_call_at", "context_pct", "attention"} {
-		if _, present := body[key]; present {
-			t.Errorf("%q must be omitted by NoopEnricher; got %v", key, body[key])
-		}
-	}
-}
-
-// stubEnricher returns canned values to verify the wiring path when a
-// real enricher is plugged in by a later step.
-type stubEnricher struct{}
-
-func (stubEnricher) LastToolCallAt(string) (time.Time, bool) {
-	return time.Date(2026, 4, 20, 15, 30, 42, 0, time.UTC), true
-}
-func (stubEnricher) ContextPct(string) (int, bool) { return 49, true }
-func (stubEnricher) Attention(string) (api.Attention, bool) {
-	return api.Attention{
-		State:   "error_burst",
-		Since:   time.Date(2026, 4, 20, 15, 29, 10, 0, time.UTC),
-		Details: "6 errors in last 20 calls",
-	}, true
-}
-func (stubEnricher) Tokens(string) (api.TokenUsage, bool) {
-	return api.TokenUsage{InputTokens: 17, OutputTokens: 42, CacheTokens: 8192}, true
-}
-
-func TestGet_EnricherFieldsPropagate(t *testing.T) {
-	s := session.New("alpha", "/work/alpha", "yolo")
-	p, _ := boot(t, map[string]bool{"alpha": true}, s)
-
-	mux := http.NewServeMux()
-	mux.Handle("GET /api/sessions/{name}", api.Get(p, stubEnricher{}))
-
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodGet, "/api/sessions/alpha", nil)
-	mux.ServeHTTP(rec, req)
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	var body map[string]any
-	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body["last_tool_call_at"] == nil {
-		t.Errorf("last_tool_call_at missing; body=%v", body)
-	}
-	if pct, ok := body["context_pct"].(float64); !ok || pct != 49 {
-		t.Errorf("context_pct = %v, want 49", body["context_pct"])
-	}
-	att, ok := body["attention"].(map[string]any)
-	if !ok {
-		t.Fatalf("attention missing or wrong type: %v", body["attention"])
-	}
-	if att["state"] != "error_burst" {
-		t.Errorf("attention.state = %v", att["state"])
-	}
-	if att["details"] != "6 errors in last 20 calls" {
-		t.Errorf("attention.details = %v", att["details"])
-	}
-	tokens, ok := body["tokens"].(map[string]any)
-	if !ok {
-		t.Fatalf("tokens missing or wrong type: %v", body["tokens"])
-	}
-	if in, _ := tokens["input_tokens"].(float64); in != 17 {
-		t.Errorf("tokens.input_tokens = %v, want 17", tokens["input_tokens"])
-	}
-	if out, _ := tokens["output_tokens"].(float64); out != 42 {
-		t.Errorf("tokens.output_tokens = %v, want 42", tokens["output_tokens"])
-	}
-	if c, _ := tokens["cache_tokens"].(float64); c != 8192 {
-		t.Errorf("tokens.cache_tokens = %v, want 8192", tokens["cache_tokens"])
-	}
-}
diff --git a/internal/serve/api/subagents.go b/internal/serve/api/subagents.go
deleted file mode 100644
index 5be5121..0000000
--- a/internal/serve/api/subagents.go
+++ /dev/null
@@ -1,319 +0,0 @@
-// Package api — V15: /api/sessions/{name}/subagents.
-//
-// Returns the forest of subagents for a session, computed by replaying
-// the session's JSONL log and grouping tool_call rows by their
-// top-level `agent_id` field. Each unique (session, agent_id) pair
-// produces one tree node; the `parent_id` field is reserved for a
-// future Claude Code schema change (the current JSONL shape doesn't
-// carry a parent pointer, so every node is a root today).
-//
-// Mount (wired in server.go alongside the other /api/sessions/{name}
-// routes — the coordinator pastes these lines into registerRoutes):
-//
-//	mux.Handle("GET /api/sessions/{name}/subagents",
-//	    authHF(api.Subagents(s.logDir, logsUUIDResolver{proj: s.proj})))
-//
-// Shape:
-//
-//	{
-//	  "subagents": [
-//	    {
-//	      "id": "ada78973e092dae52",
-//	      "parent_id": null,
-//	      "type": "Explore",
-//	      "description": "cat README.md",
-//	      "started_at": "2026-04-21T12:00:00Z",
-//	      "stopped_at": "2026-04-21T12:02:10Z",
-//	      "tool_calls": 7,
-//	      "status": "completed"
-//	    },
-//	    ...
-//	  ]
-//	}
-//
-// Newest root first; children newest-first (see orderNodes below —
-// trees are flat today so this is a simple top-level sort). Cap at
-// maxSubagentsPerSession (500).
-//
-// `since` query param (RFC3339) — when present, rows with
-// `started_at <= since` are elided server-side to reduce the payload
-// on re-fetch after an SSE wake-up.
-//
-// Completion status is inferred: a subagent is "running" when its
-// last observed tool_call is within runningGrace (5 s) of now AND
-// no tool_response error bit is set; "failed" when any of its tool
-// calls returned an error; otherwise "completed".
-package api
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"io"
-	"net/http"
-	"os"
-	"path/filepath"
-	"sort"
-	"time"
-)
-
-// maxSubagentsPerSession caps the response at the same 500 as the
-// hub's per-session ring — keeps the response predictable on long
-// transcripts.
-const maxSubagentsPerSession = 500
-
-// runningGrace is the staleness window past the most recent tool_call
-// before we flip a subagent's status from "running" to "completed".
-// Matches the attention engine's "stalled" trigger ballpark so live
-// agents and "just-finished" agents don't bounce between the two.
-const runningGrace = 5 * time.Second
-
-// SubagentNode is a single row in the /subagents response. ParentID
-// is a pointer so JSON emits `null` when absent (rather than the zero
-// string "").
-type SubagentNode struct {
-	ID          string     `json:"id"`
-	ParentID    *string    `json:"parent_id"`
-	Type        string     `json:"type"`
-	Description string     `json:"description"`
-	StartedAt   time.Time  `json:"started_at"`
-	StoppedAt   *time.Time `json:"stopped_at,omitempty"`
-	ToolCalls   int        `json:"tool_calls"`
-	Status      string     `json:"status"`
-}
-
-type subagentsResponse struct {
-	Subagents []SubagentNode `json:"subagents"`
-}
-
-// Subagents returns the GET handler for /api/sessions/{name}/subagents.
-// logDir is the JSONL tailer directory; resolver maps session name →
-// log UUID via the same `claudeDirToName` fallback used elsewhere in
-// the api package.
-func Subagents(logDir string, resolver UUIDNameResolver) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		name, ok := requireSessionPreamble(w, r)
-		if !ok {
-			return
-		}
-
-		var since time.Time
-		if raw := r.URL.Query().Get("since"); raw != "" {
-			t, err := time.Parse(time.RFC3339, raw)
-			if err != nil {
-				writeJSON(w, http.StatusBadRequest, errorBody{Error: "since must be RFC3339", Name: name})
-				return
-			}
-			since = t.UTC()
-		}
-
-		uuid, ok := resolveNameToUUID(resolver, logDir, name)
-		if !ok {
-			writeJSON(w, http.StatusNotFound, errorBody{Error: "session not found", Name: name})
-			return
-		}
-
-		path := filepath.Join(logDir, uuid+".jsonl")
-		nodes, err := buildSubagentForest(path, time.Now().UTC(), since)
-		if err != nil {
-			if errors.Is(err, os.ErrNotExist) {
-				writeJSON(w, http.StatusOK, subagentsResponse{Subagents: []SubagentNode{}})
-				return
-			}
-			writeJSON(w, http.StatusInternalServerError, errorBody{Error: "read failed", Name: name})
-			return
-		}
-
-		if len(nodes) > maxSubagentsPerSession {
-			nodes = nodes[:maxSubagentsPerSession]
-		}
-
-		writeJSON(w, http.StatusOK, subagentsResponse{Subagents: nodes})
-	}
-}
-
-// buildSubagentForest replays the JSONL at path, grouping by
-// agent_id. `now` drives the running/completed cutoff;
-// `since` (zero-value = include all) filters out any subagent whose
-// started_at is <= since, which is how the `since=` query param
-// trims re-fetch payloads.
-//
-// Exported for reuse by teams.go.
-func buildSubagentForest(path string, now, since time.Time) ([]SubagentNode, error) {
-	return buildSubagentForestFromReader(path, nil, now, since)
-}
-
-// buildSubagentForestFromReader is the test seam. When openOverride
-// is non-nil it returns the fixture reader instead of opening `path`;
-// production passes nil and we fall through to os.Open.
-func buildSubagentForestFromReader(
-	path string,
-	openOverride func(string) (io.ReadCloser, error),
-	now, since time.Time,
-) ([]SubagentNode, error) {
-	var (
-		rc  io.ReadCloser
-		err error
-	)
-	if openOverride != nil {
-		rc, err = openOverride(path)
-	} else {
-		rc, err = os.Open(path)
-	}
-	if err != nil {
-		return nil, err
-	}
-	defer rc.Close()
-
-	all, err := io.ReadAll(rc)
-	if err != nil {
-		return nil, err
-	}
-
-	// Group by agent_id. A map preserves O(n) replay; the ordering
-	// step at the end re-sorts by started_at descending so the forest
-	// is newest-first.
-	type accumulator struct {
-		node     SubagentNode
-		anyError bool
-		lastTS   time.Time
-	}
-	byID := make(map[string]*accumulator)
-	order := make([]string, 0)
-
-	for _, rawLine := range bytes.Split(all, []byte{'\n'}) {
-		line := bytes.TrimRight(rawLine, "\r")
-		if len(line) == 0 {
-			continue
-		}
-		meta, ok := parseSubagentLine(line)
-		if !ok {
-			continue
-		}
-		// De-duplicate the agent_id → tree-node bookkeeping.
-		acc, exists := byID[meta.AgentID]
-		if !exists {
-			acc = &accumulator{
-				node: SubagentNode{
-					ID:          meta.AgentID,
-					Type:        meta.AgentType,
-					Description: meta.Input,
-					StartedAt:   meta.TS,
-				},
-			}
-			byID[meta.AgentID] = acc
-			order = append(order, meta.AgentID)
-		}
-		acc.node.ToolCalls++
-		if meta.IsError {
-			acc.anyError = true
-		}
-		// Keep the highest ts as the "stopped_at" candidate; track
-		// it in a local so we can later decide running vs completed.
-		if meta.TS.After(acc.lastTS) {
-			acc.lastTS = meta.TS
-		}
-		// If a later row has a better description, keep the first
-		// non-empty one for stability — users get jumpy when row
-		// descriptions swap mid-stream.
-		if acc.node.Description == "" && meta.Input != "" {
-			acc.node.Description = meta.Input
-		}
-	}
-
-	out := make([]SubagentNode, 0, len(byID))
-	for _, id := range order {
-		acc := byID[id]
-		node := acc.node
-		if !acc.lastTS.IsZero() {
-			last := acc.lastTS
-			if now.Sub(last) > runningGrace {
-				// Subagent went quiet — treat last-seen ts as the
-				// stopped_at marker.
-				ls := last
-				node.StoppedAt = &ls
-			}
-		}
-		switch {
-		case acc.anyError:
-			node.Status = "failed"
-		case node.StoppedAt == nil:
-			node.Status = "running"
-		default:
-			node.Status = "completed"
-		}
-		if !since.IsZero() && !node.StartedAt.After(since) {
-			continue
-		}
-		out = append(out, node)
-	}
-
-	// Newest root first. Deterministic tie-break on id so tests that
-	// stamp the same timestamp on multiple subagents don't flake.
-	sort.Slice(out, func(i, j int) bool {
-		if !out[i].StartedAt.Equal(out[j].StartedAt) {
-			return out[i].StartedAt.After(out[j].StartedAt)
-		}
-		return out[i].ID > out[j].ID
-	})
-	return out, nil
-}
-
-// parseSubagentLine is a local duplicate of ingest.parseSubagentMeta
-// kept here so the api package does not import internal/serve/ingest
-// (would add a new direction to the dep graph). The shape is trivial;
-// both copies stay in sync via the tests.
-type subagentLineMeta struct {
-	AgentID   string
-	AgentType string
-	Input     string
-	IsError   bool
-	TS        time.Time
-}
-
-func parseSubagentLine(line []byte) (subagentLineMeta, bool) {
-	var raw map[string]any
-	if err := json.Unmarshal(line, &raw); err != nil {
-		return subagentLineMeta{}, false
-	}
-	agentID, _ := raw["agent_id"].(string)
-	if agentID == "" {
-		return subagentLineMeta{}, false
-	}
-	agentType, _ := raw["agent_type"].(string)
-	tool, _ := raw["tool_name"].(string)
-	input := ""
-	if in, ok := raw["tool_input"].(map[string]any); ok {
-		input = shortestSubagentInputLabel(tool, in)
-	}
-	ts := extractTS(raw)
-	if ts.IsZero() {
-		// Fall back to zero — handler re-fetches clamp this to
-		// "unknown" and still produce a stable id ordering via
-		// agent_id lexical sort when timestamps collide.
-	}
-	return subagentLineMeta{
-		AgentID:   agentID,
-		AgentType: agentType,
-		Input:     input,
-		IsError:   nestedBool(raw, "tool_response", "is_error"),
-		TS:        ts,
-	}, true
-}
-
-// shortestSubagentInputLabel picks the most human-readable single
-// field from a tool_input map for the subagent row's `description`.
-// Mirrors the feed-row summariser's per-tool conventions so a
-// subagent expanded in the UI matches the feed rows shown below it.
-func shortestSubagentInputLabel(tool string, in map[string]any) string {
-	if v, ok := truncateToolInputField(tool, in); ok {
-		return v
-	}
-	// Fallback: any "description"-ish key.
-	for _, k := range []string{"description", "prompt", "query"} {
-		if v, ok := in[k].(string); ok && v != "" {
-			return truncateHistory(v)
-		}
-	}
-	return ""
-}
diff --git a/internal/serve/api/subagents_test.go b/internal/serve/api/subagents_test.go
deleted file mode 100644
index 4a295ca..0000000
--- a/internal/serve/api/subagents_test.go
+++ /dev/null
@@ -1,361 +0,0 @@
-package api_test
-
-import (
-	"encoding/json"
-	"fmt"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/api"
-)
-
-// subagentResolver is a minimal UUIDNameResolver pinned to one
-// (uuid, name) mapping so tests drive the lookup deterministically.
-type subagentResolver struct{ uuid, name string }
-
-func (s subagentResolver) ResolveUUID(u string) (string, bool) {
-	if u == s.uuid {
-		return s.name, true
-	}
-	return "", false
-}
-
-// writeSubagentFixture appends JSONL rows to /.jsonl. Each
-// row has a top-level agent_id / agent_type if `agentID` is non-empty.
-// ctm_timestamp is taken from `ts`.
-func writeSubagentFixture(t *testing.T, path, agentID, agentType string, ts time.Time, tool, cmd string, isError bool) {
-	t.Helper()
-	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
-	if err != nil {
-		t.Fatalf("open: %v", err)
-	}
-	defer f.Close()
-	row := map[string]any{
-		"tool_name": tool,
-		"tool_input": map[string]any{
-			"command": cmd,
-		},
-		"tool_response": map[string]any{
-			"is_error": isError,
-			"output":   "ok",
-		},
-		"ctm_timestamp": ts.UTC().Format(time.RFC3339),
-	}
-	if agentID != "" {
-		row["agent_id"] = agentID
-		row["agent_type"] = agentType
-	}
-	body, _ := json.Marshal(row)
-	if _, err := f.Write(append(body, '\n')); err != nil {
-		t.Fatalf("write: %v", err)
-	}
-}
-
-func newSubagentRequest(name, query string) *http.Request {
-	url := "/api/sessions/" + name + "/subagents"
-	if query != "" {
-		url += "?" + query
-	}
-	req := httptest.NewRequest(http.MethodGet, url, nil)
-	req.SetPathValue("name", name)
-	return req
-}
-
-func TestSubagents_ReplaysForestNewestFirst(t *testing.T) {
-	dir := t.TempDir()
-	uuid := "aaaaaaaa-0000-0000-0000-000000000010"
-	path := filepath.Join(dir, uuid+".jsonl")
-
-	base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
-	// Three subagents with distinct agent_ids, interleaved tool calls.
-	writeSubagentFixture(t, path, "agent-a", "Explore", base, "Bash", "echo a1", false)
-	writeSubagentFixture(t, path, "agent-b", "Task", base.Add(10*time.Second), "Bash", "echo b1", false)
-	writeSubagentFixture(t, path, "agent-a", "Explore", base.Add(20*time.Second), "Read", "/tmp/x", false)
-	writeSubagentFixture(t, path, "agent-c", "Explore", base.Add(30*time.Second), "Bash", "echo c1", false)
-	writeSubagentFixture(t, path, "agent-b", "Task", base.Add(40*time.Second), "Bash", "echo b2", false)
-	// A no-agent row should not corrupt counts.
-	writeSubagentFixture(t, path, "", "", base.Add(50*time.Second), "Bash", "echo ignored", false)
-
-	h := api.Subagents(dir, subagentResolver{uuid: uuid, name: "alpha"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newSubagentRequest("alpha", ""))
-
-	if rr.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
-	}
-
-	var got struct {
-		Subagents []api.SubagentNode `json:"subagents"`
-	}
-	if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
-		t.Fatalf("unmarshal: %v", err)
-	}
-	if len(got.Subagents) != 3 {
-		t.Fatalf("len = %d, want 3; got=%+v", len(got.Subagents), got.Subagents)
-	}
-	// Newest start first → c (t+30s), b (t+10s), a (t+0s).
-	wantIDs := []string{"agent-c", "agent-b", "agent-a"}
-	for i, want := range wantIDs {
-		if got.Subagents[i].ID != want {
-			t.Errorf("Subagents[%d].ID = %q, want %q", i, got.Subagents[i].ID, want)
-		}
-	}
-	// Per-agent tool_call counts.
-	byID := map[string]api.SubagentNode{}
-	for _, n := range got.Subagents {
-		byID[n.ID] = n
-	}
-	if byID["agent-a"].ToolCalls != 2 {
-		t.Errorf("agent-a ToolCalls = %d, want 2", byID["agent-a"].ToolCalls)
-	}
-	if byID["agent-b"].ToolCalls != 2 {
-		t.Errorf("agent-b ToolCalls = %d, want 2", byID["agent-b"].ToolCalls)
-	}
-	if byID["agent-c"].ToolCalls != 1 {
-		t.Errorf("agent-c ToolCalls = %d, want 1", byID["agent-c"].ToolCalls)
-	}
-	// parent_id always null today.
-	for _, n := range got.Subagents {
-		if n.ParentID != nil {
-			t.Errorf("%s ParentID = %v, want nil", n.ID, *n.ParentID)
-		}
-	}
-}
-
-func TestSubagents_DuplicateAgentIDCoalesces(t *testing.T) {
-	dir := t.TempDir()
-	uuid := "aaaaaaaa-0000-0000-0000-000000000011"
-	path := filepath.Join(dir, uuid+".jsonl")
-
-	base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
-	// Same agent_id across 5 rows — should produce exactly one node
-	// with tool_calls=5 and the earliest ts as started_at.
-	for i := 0; i < 5; i++ {
-		writeSubagentFixture(t, path, "same-agent", "Explore",
-			base.Add(time.Duration(i)*time.Second), "Bash",
-			"echo "+strings.Repeat("a", i+1), false)
-	}
-
-	h := api.Subagents(dir, subagentResolver{uuid: uuid, name: "alpha"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newSubagentRequest("alpha", ""))
-
-	var got struct {
-		Subagents []api.SubagentNode `json:"subagents"`
-	}
-	_ = json.Unmarshal(rr.Body.Bytes(), &got)
-	if len(got.Subagents) != 1 {
-		t.Fatalf("len = %d, want 1", len(got.Subagents))
-	}
-	n := got.Subagents[0]
-	if n.ToolCalls != 5 {
-		t.Errorf("ToolCalls = %d, want 5", n.ToolCalls)
-	}
-	if !n.StartedAt.Equal(base) {
-		t.Errorf("StartedAt = %v, want %v", n.StartedAt, base)
-	}
-}
-
-func TestSubagents_StatusFailedWhenAnyToolErrored(t *testing.T) {
-	dir := t.TempDir()
-	uuid := "aaaaaaaa-0000-0000-0000-000000000012"
-	path := filepath.Join(dir, uuid+".jsonl")
-
-	base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
-	writeSubagentFixture(t, path, "agent-a", "Explore", base, "Bash", "ok", false)
-	writeSubagentFixture(t, path, "agent-a", "Explore", base.Add(1*time.Second), "Bash", "boom", true)
-
-	h := api.Subagents(dir, subagentResolver{uuid: uuid, name: "alpha"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newSubagentRequest("alpha", ""))
-
-	var got struct {
-		Subagents []api.SubagentNode `json:"subagents"`
-	}
-	_ = json.Unmarshal(rr.Body.Bytes(), &got)
-	if len(got.Subagents) != 1 {
-		t.Fatalf("len = %d, want 1", len(got.Subagents))
-	}
-	if got.Subagents[0].Status != "failed" {
-		t.Errorf("Status = %q, want failed", got.Subagents[0].Status)
-	}
-}
-
-func TestSubagents_SinceFiltersOlder(t *testing.T) {
-	dir := t.TempDir()
-	uuid := "aaaaaaaa-0000-0000-0000-000000000013"
-	path := filepath.Join(dir, uuid+".jsonl")
-
-	base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
-	writeSubagentFixture(t, path, "old-agent", "Explore", base, "Bash", "old", false)
-	writeSubagentFixture(t, path, "new-agent", "Explore", base.Add(1*time.Hour), "Bash", "new", false)
-
-	since := base.Add(30 * time.Minute).Format(time.RFC3339)
-	h := api.Subagents(dir, subagentResolver{uuid: uuid, name: "alpha"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newSubagentRequest("alpha", "since="+since))
-
-	var got struct {
-		Subagents []api.SubagentNode `json:"subagents"`
-	}
-	_ = json.Unmarshal(rr.Body.Bytes(), &got)
-	if len(got.Subagents) != 1 || got.Subagents[0].ID != "new-agent" {
-		t.Errorf("got = %+v, want [new-agent]", got.Subagents)
-	}
-}
-
-func TestSubagents_404UnknownSession(t *testing.T) {
-	dir := t.TempDir()
-	h := api.Subagents(dir, subagentResolver{uuid: "no-match", name: "other"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newSubagentRequest("ghost", ""))
-	if rr.Code != http.StatusNotFound {
-		t.Errorf("status = %d, want 404", rr.Code)
-	}
-}
-
-func TestSubagents_405NonGET(t *testing.T) {
-	dir := t.TempDir()
-	h := api.Subagents(dir, subagentResolver{uuid: "", name: ""})
-	req := httptest.NewRequest(http.MethodPost, "/api/sessions/alpha/subagents", nil)
-	req.SetPathValue("name", "alpha")
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, req)
-	if rr.Code != http.StatusMethodNotAllowed {
-		t.Errorf("status = %d, want 405", rr.Code)
-	}
-}
-
-func TestSubagents_OrphanParentIsRoot(t *testing.T) {
-	// Today the JSONL doesn't carry parent_id, so every node is a
-	// root. This test pins the contract: even if a hypothetical
-	// parent reference showed up as a free-form "parent_id" field
-	// (which parseSubagentLine doesn't consume today), the node
-	// must still appear in the output as a root.
-	dir := t.TempDir()
-	uuid := "aaaaaaaa-0000-0000-0000-000000000014"
-	path := filepath.Join(dir, uuid+".jsonl")
-	// Hand-craft a row with an (unused-today) parent_id field.
-	f, _ := os.Create(path)
-	line := map[string]any{
-		"agent_id":      "orphan",
-		"agent_type":    "Explore",
-		"parent_id":     "does-not-exist",
-		"tool_name":     "Bash",
-		"tool_input":    map[string]any{"command": "echo hi"},
-		"tool_response": map[string]any{"is_error": false},
-		"ctm_timestamp": "2026-04-21T12:00:00Z",
-	}
-	body, _ := json.Marshal(line)
-	_, _ = f.Write(append(body, '\n'))
-	_ = f.Close()
-
-	h := api.Subagents(dir, subagentResolver{uuid: uuid, name: "alpha"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newSubagentRequest("alpha", ""))
-	var got struct {
-		Subagents []api.SubagentNode `json:"subagents"`
-	}
-	_ = json.Unmarshal(rr.Body.Bytes(), &got)
-	if len(got.Subagents) != 1 {
-		t.Fatalf("len = %d", len(got.Subagents))
-	}
-	if got.Subagents[0].ParentID != nil {
-		t.Errorf("ParentID = %v, want nil (orphan promoted to root)", *got.Subagents[0].ParentID)
-	}
-}
-
-// TestSubagents_RunningWhenRecent uses a fixture dated "a few ms ago"
-// so runningGrace hasn't elapsed — the node should report status
-// "running" with no stopped_at.
-func TestSubagents_RunningWhenRecent(t *testing.T) {
-	dir := t.TempDir()
-	uuid := "aaaaaaaa-0000-0000-0000-000000000015"
-	path := filepath.Join(dir, uuid+".jsonl")
-
-	// Stamp "now" via the fixture so the test doesn't flake on a
-	// slow CI box. buildSubagentForest uses time.Now() directly —
-	// we approximate by writing a ts one second in the future.
-	fmtTS := time.Now().UTC().Add(1 * time.Second)
-	writeSubagentFixture(t, path, "fresh", "Explore", fmtTS, "Bash", "echo live", false)
-
-	h := api.Subagents(dir, subagentResolver{uuid: uuid, name: "alpha"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newSubagentRequest("alpha", ""))
-	var got struct {
-		Subagents []api.SubagentNode `json:"subagents"`
-	}
-	_ = json.Unmarshal(rr.Body.Bytes(), &got)
-	if len(got.Subagents) != 1 {
-		t.Fatalf("len = %d; body=%s", len(got.Subagents), rr.Body.String())
-	}
-	if got.Subagents[0].Status != "running" {
-		t.Errorf("Status = %q, want running", got.Subagents[0].Status)
-	}
-	if got.Subagents[0].StoppedAt != nil {
-		t.Errorf("StoppedAt = %v, want nil", *got.Subagents[0].StoppedAt)
-	}
-}
-
-// Sanity check: the response is a well-formed JSON object even when
-// the log file is empty (fresh session whose JSONL has been opened by
-// the tailer but not yet written to). The file must exist for the
-// session-name → UUID resolver to find it — that's the same contract
-// as feed_history. When the file doesn't exist at all the resolver
-// returns 404.
-func TestSubagents_EmptyLogOK(t *testing.T) {
-	dir := t.TempDir()
-	uuid := "aaaaaaaa-0000-0000-0000-000000000016"
-	// Touch an empty log so the resolver can find it.
-	path := filepath.Join(dir, uuid+".jsonl")
-	_ = os.WriteFile(path, []byte{}, 0o600)
-
-	h := api.Subagents(dir, subagentResolver{uuid: uuid, name: "alpha"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newSubagentRequest("alpha", ""))
-	if rr.Code != http.StatusOK {
-		t.Errorf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
-	}
-	var got struct {
-		Subagents []api.SubagentNode `json:"subagents"`
-	}
-	if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
-		t.Fatalf("unmarshal: %v", err)
-	}
-	if len(got.Subagents) != 0 {
-		t.Errorf("len = %d, want 0", len(got.Subagents))
-	}
-}
-
-// Nested-tree smoke: even if a child arrives before a grand-child
-// the agent_id grouping keeps both as roots today. Locks the shape
-// so a future parent_id implementation can change it deliberately.
-func TestSubagents_NestedFamilyStaysFlat(t *testing.T) {
-	dir := t.TempDir()
-	uuid := "aaaaaaaa-0000-0000-0000-000000000017"
-	path := filepath.Join(dir, uuid+".jsonl")
-	base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
-	for i, id := range []string{"root", "child-1", "child-2", "grandchild"} {
-		writeSubagentFixture(t, path, id, "Explore", base.Add(time.Duration(i)*time.Second), "Bash", fmt.Sprintf("step %d", i), false)
-	}
-	h := api.Subagents(dir, subagentResolver{uuid: uuid, name: "alpha"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newSubagentRequest("alpha", ""))
-	var got struct {
-		Subagents []api.SubagentNode `json:"subagents"`
-	}
-	_ = json.Unmarshal(rr.Body.Bytes(), &got)
-	if len(got.Subagents) != 4 {
-		t.Fatalf("len = %d, want 4", len(got.Subagents))
-	}
-	for _, n := range got.Subagents {
-		if n.ParentID != nil {
-			t.Errorf("%s ParentID = %v, want nil", n.ID, *n.ParentID)
-		}
-	}
-}
diff --git a/internal/serve/api/teams.go b/internal/serve/api/teams.go
deleted file mode 100644
index c9cf73c..0000000
--- a/internal/serve/api/teams.go
+++ /dev/null
@@ -1,234 +0,0 @@
-// Package api — V16: /api/sessions/{name}/teams.
-//
-// A "team" is a group of subagents dispatched within a tight time
-// window (teamWindow). The Claude Code JSONL doesn't carry an
-// explicit `team_name` or `team_spawn` row today, so the team shape
-// is inferred from the same replay as V15: any pair of subagents
-// whose `started_at` timestamps fall inside teamWindow get merged
-// into a single team. When the schema grows a dedicated team_name
-// field, extend parseSubagentLine to surface it and switch the group
-// key here.
-//
-// Mount (coordinator pastes into registerRoutes in server.go):
-//
-//	mux.Handle("GET /api/sessions/{name}/teams",
-//	    authHF(api.Teams(s.logDir, logsUUIDResolver{proj: s.proj})))
-//
-// Shape:
-//
-//	{
-//	  "teams": [
-//	    {
-//	      "id": "team-",
-//	      "name": "Explore · 3 agents",
-//	      "dispatched_at": "2026-04-21T12:00:00Z",
-//	      "status": "completed",
-//	      "summary": null,
-//	      "members": [
-//	        {"subagent_id":"abc","description":"...","status":"completed"},
-//	        ...
-//	      ]
-//	    }
-//	  ]
-//	}
-//
-// Status roll-up:
-//   - any member running → team is "running"
-//   - any member failed  → team is "failed"
-//   - else                → team is "completed"
-//
-// Summary is currently always null (no `team_summary` event exists in
-// the JSONL yet); the field stays in the contract so the UI can
-// render a blockquote when one lands later.
-package api
-
-import (
-	"errors"
-	"fmt"
-	"net/http"
-	"os"
-	"path/filepath"
-	"sort"
-	"time"
-)
-
-// teamWindow is the maximum delta between two subagent start times
-// before we consider them to be part of separate teams. 2s matches
-// the rough cadence of a parallel dispatch from the Task/Agent tool —
-// tests can override via the exported TeamWindowForTest setter below.
-const teamWindow = 2 * time.Second
-
-// maxTeamsPerSession caps the response at the same 500-ish ceiling as
-// V15, for the same reason (predictable payload on long transcripts).
-const maxTeamsPerSession = 500
-
-// TeamMember mirrors a single row in team.members.
-type TeamMember struct {
-	SubagentID  string `json:"subagent_id"`
-	Description string `json:"description"`
-	Status      string `json:"status"`
-}
-
-// Team is a single row in the /teams response.
-type Team struct {
-	ID           string       `json:"id"`
-	Name         string       `json:"name"`
-	DispatchedAt time.Time    `json:"dispatched_at"`
-	Status       string       `json:"status"`
-	Summary      *string      `json:"summary,omitempty"`
-	Members      []TeamMember `json:"members"`
-}
-
-type teamsResponse struct {
-	Teams []Team `json:"teams"`
-}
-
-// Teams returns GET /api/sessions/{name}/teams.
-func Teams(logDir string, resolver UUIDNameResolver) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		name, ok := requireSessionPreamble(w, r)
-		if !ok {
-			return
-		}
-
-		uuid, ok := resolveNameToUUID(resolver, logDir, name)
-		if !ok {
-			writeJSON(w, http.StatusNotFound, errorBody{Error: "session not found", Name: name})
-			return
-		}
-
-		path := filepath.Join(logDir, uuid+".jsonl")
-		teams, err := buildTeamsFromForest(path, time.Now().UTC())
-		if err != nil {
-			if errors.Is(err, os.ErrNotExist) {
-				writeJSON(w, http.StatusOK, teamsResponse{Teams: []Team{}})
-				return
-			}
-			writeJSON(w, http.StatusInternalServerError, errorBody{Error: "read failed", Name: name})
-			return
-		}
-
-		if len(teams) > maxTeamsPerSession {
-			teams = teams[:maxTeamsPerSession]
-		}
-		writeJSON(w, http.StatusOK, teamsResponse{Teams: teams})
-	}
-}
-
-// buildTeamsFromForest computes the team list for a session by first
-// walking the JSONL to produce the subagent forest, then bucketing
-// subagents into teams whose start times cluster within teamWindow.
-//
-// `now` drives the underlying forest builder's running/completed
-// cutoff — propagating it here keeps teams.status roll-up consistent
-// with /subagents at the same instant.
-//
-// Public via the Teams handler only; kept unexported to leave room
-// for evolving the team shape without breaking callers.
-func buildTeamsFromForest(path string, now time.Time) ([]Team, error) {
-	nodes, err := buildSubagentForest(path, now, time.Time{})
-	if err != nil {
-		return nil, err
-	}
-	if len(nodes) == 0 {
-		return []Team{}, nil
-	}
-
-	// Sort by started_at ASC so we can sweep left→right and open a
-	// new team whenever the delta exceeds teamWindow. (The forest
-	// builder returns newest-first; we reverse for sweeping and
-	// reverse the final teams slice to preserve newest-first output.)
-	asc := make([]SubagentNode, len(nodes))
-	copy(asc, nodes)
-	sort.Slice(asc, func(i, j int) bool {
-		if !asc[i].StartedAt.Equal(asc[j].StartedAt) {
-			return asc[i].StartedAt.Before(asc[j].StartedAt)
-		}
-		return asc[i].ID < asc[j].ID
-	})
-
-	type bucket struct {
-		first time.Time
-		last  time.Time
-		ids   []SubagentNode
-	}
-	var buckets []*bucket
-	for _, n := range asc {
-		if len(buckets) == 0 {
-			buckets = append(buckets, &bucket{
-				first: n.StartedAt,
-				last:  n.StartedAt,
-				ids:   []SubagentNode{n},
-			})
-			continue
-		}
-		cur := buckets[len(buckets)-1]
-		// Gap relative to the cluster's last start, so a rolling
-		// burst of dispatches (e.g. 4 agents at t, t+1s, t+2s, t+3s)
-		// still collapses into a single team.
-		if n.StartedAt.Sub(cur.last) <= teamWindow {
-			cur.ids = append(cur.ids, n)
-			cur.last = n.StartedAt
-			continue
-		}
-		buckets = append(buckets, &bucket{
-			first: n.StartedAt,
-			last:  n.StartedAt,
-			ids:   []SubagentNode{n},
-		})
-	}
-
-	out := make([]Team, 0, len(buckets))
-	for _, b := range buckets {
-		if len(b.ids) == 0 {
-			continue
-		}
-		members := make([]TeamMember, 0, len(b.ids))
-		runningCount, failedCount := 0, 0
-		// Capture the dominant agent_type for the team's display name
-		// (first member wins — same stability reasoning as
-		// SubagentNode.Description).
-		primaryType := b.ids[0].Type
-		for _, n := range b.ids {
-			if n.Status == "running" {
-				runningCount++
-			}
-			if n.Status == "failed" {
-				failedCount++
-			}
-			members = append(members, TeamMember{
-				SubagentID:  n.ID,
-				Description: n.Description,
-				Status:      n.Status,
-			})
-		}
-		status := "completed"
-		switch {
-		case runningCount > 0:
-			status = "running"
-		case failedCount > 0:
-			status = "failed"
-		}
-		name := fmt.Sprintf("%s · %d agents", primaryType, len(b.ids))
-		if primaryType == "" {
-			name = fmt.Sprintf("%d agents", len(b.ids))
-		}
-		out = append(out, Team{
-			ID:           "team-" + b.ids[0].ID,
-			Name:         name,
-			DispatchedAt: b.first,
-			Status:       status,
-			Summary:      nil,
-			Members:      members,
-		})
-	}
-
-	// Newest dispatch first.
-	sort.Slice(out, func(i, j int) bool {
-		if !out[i].DispatchedAt.Equal(out[j].DispatchedAt) {
-			return out[i].DispatchedAt.After(out[j].DispatchedAt)
-		}
-		return out[i].ID > out[j].ID
-	})
-	return out, nil
-}
diff --git a/internal/serve/api/teams_test.go b/internal/serve/api/teams_test.go
deleted file mode 100644
index 02e44aa..0000000
--- a/internal/serve/api/teams_test.go
+++ /dev/null
@@ -1,163 +0,0 @@
-package api_test
-
-import (
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"path/filepath"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/api"
-)
-
-func newTeamsRequest(name string) *http.Request {
-	req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+name+"/teams", nil)
-	req.SetPathValue("name", name)
-	return req
-}
-
-// TestTeams_WindowGroupsNearStarts pins the 2 s dispatch-window
-// heuristic: three subagents whose starts fall inside the window
-// collapse into one team; a later-arriving agent opens a second.
-func TestTeams_WindowGroupsNearStarts(t *testing.T) {
-	dir := t.TempDir()
-	uuid := "bbbbbbbb-0000-0000-0000-000000000001"
-	path := filepath.Join(dir, uuid+".jsonl")
-	base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
-
-	// Team 1 — agents a, b, c dispatched within 2 s.
-	writeSubagentFixture(t, path, "a", "Explore", base, "Bash", "a", false)
-	writeSubagentFixture(t, path, "b", "Explore", base.Add(500*time.Millisecond), "Bash", "b", false)
-	writeSubagentFixture(t, path, "c", "Explore", base.Add(1500*time.Millisecond), "Bash", "c", false)
-	// Team 2 — agent d dispatched 10 s later (way beyond window).
-	writeSubagentFixture(t, path, "d", "Task", base.Add(15*time.Second), "Bash", "d", false)
-
-	h := api.Teams(dir, subagentResolver{uuid: uuid, name: "alpha"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newTeamsRequest("alpha"))
-
-	if rr.Code != http.StatusOK {
-		t.Fatalf("status = %d; body=%s", rr.Code, rr.Body.String())
-	}
-	var got struct {
-		Teams []api.Team `json:"teams"`
-	}
-	if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
-		t.Fatalf("unmarshal: %v", err)
-	}
-	if len(got.Teams) != 2 {
-		t.Fatalf("len = %d, want 2; teams=%+v", len(got.Teams), got.Teams)
-	}
-	// Newest dispatch first → team d is [0], cluster abc is [1].
-	if len(got.Teams[0].Members) != 1 || got.Teams[0].Members[0].SubagentID != "d" {
-		t.Errorf("team[0] members = %+v, want [{d}]", got.Teams[0].Members)
-	}
-	if len(got.Teams[1].Members) != 3 {
-		t.Errorf("team[1] len = %d, want 3; members=%+v", len(got.Teams[1].Members), got.Teams[1].Members)
-	}
-}
-
-// TestTeams_StatusAggregation: mixed member statuses roll up per spec.
-func TestTeams_StatusAggregation(t *testing.T) {
-	dir := t.TempDir()
-	uuid := "bbbbbbbb-0000-0000-0000-000000000002"
-	path := filepath.Join(dir, uuid+".jsonl")
-	base := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC)
-
-	// Team 1: one completed, one failed.
-	writeSubagentFixture(t, path, "ok", "Explore", base, "Bash", "ok", false)
-	writeSubagentFixture(t, path, "err", "Explore", base.Add(500*time.Millisecond), "Bash", "bad", true)
-	// Team 2: one completed only — should be "completed".
-	writeSubagentFixture(t, path, "clean", "Task", base.Add(30*time.Second), "Bash", "fine", false)
-
-	h := api.Teams(dir, subagentResolver{uuid: uuid, name: "alpha"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newTeamsRequest("alpha"))
-	var got struct {
-		Teams []api.Team `json:"teams"`
-	}
-	_ = json.Unmarshal(rr.Body.Bytes(), &got)
-	if len(got.Teams) != 2 {
-		t.Fatalf("len = %d", len(got.Teams))
-	}
-	// Newest-first: team "clean" first, mixed team second.
-	if got.Teams[0].Status != "completed" {
-		t.Errorf("Teams[0].Status = %q, want completed", got.Teams[0].Status)
-	}
-	if got.Teams[1].Status != "failed" {
-		t.Errorf("Teams[1].Status = %q, want failed (one member failed)", got.Teams[1].Status)
-	}
-}
-
-// TestTeams_RunningMemberMakesTeamRunning: even with no failures, any
-// currently-running member overrides "completed".
-func TestTeams_RunningMemberMakesTeamRunning(t *testing.T) {
-	dir := t.TempDir()
-	uuid := "bbbbbbbb-0000-0000-0000-000000000003"
-	path := filepath.Join(dir, uuid+".jsonl")
-
-	// Old (completed) + fresh (running) in the same dispatch window.
-	now := time.Now().UTC()
-	writeSubagentFixture(t, path, "oldie", "Explore", now.Add(-1*time.Hour), "Bash", "done", false)
-	writeSubagentFixture(t, path, "live", "Explore", now.Add(1*time.Second), "Bash", "still going", false)
-
-	h := api.Teams(dir, subagentResolver{uuid: uuid, name: "alpha"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newTeamsRequest("alpha"))
-	var got struct {
-		Teams []api.Team `json:"teams"`
-	}
-	_ = json.Unmarshal(rr.Body.Bytes(), &got)
-	// Two teams — the hour gap keeps them apart.
-	if len(got.Teams) != 2 {
-		t.Fatalf("len = %d, want 2", len(got.Teams))
-	}
-	// Newest team contains "live".
-	if got.Teams[0].Status != "running" {
-		t.Errorf("newest Status = %q, want running", got.Teams[0].Status)
-	}
-}
-
-func TestTeams_404UnknownSession(t *testing.T) {
-	dir := t.TempDir()
-	h := api.Teams(dir, subagentResolver{uuid: "no-match", name: "other"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newTeamsRequest("ghost"))
-	if rr.Code != http.StatusNotFound {
-		t.Errorf("status = %d, want 404", rr.Code)
-	}
-}
-
-func TestTeams_405NonGET(t *testing.T) {
-	dir := t.TempDir()
-	h := api.Teams(dir, subagentResolver{uuid: "", name: ""})
-	req := httptest.NewRequest(http.MethodPost, "/api/sessions/alpha/teams", nil)
-	req.SetPathValue("name", "alpha")
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, req)
-	if rr.Code != http.StatusMethodNotAllowed {
-		t.Errorf("status = %d, want 405", rr.Code)
-	}
-}
-
-func TestTeams_EmptyLogReturnsEmptyArray(t *testing.T) {
-	dir := t.TempDir()
-	uuid := "bbbbbbbb-0000-0000-0000-000000000099"
-	// Touch an empty log file so the resolver finds it.
-	_ = os.WriteFile(filepath.Join(dir, uuid+".jsonl"), []byte{}, 0o600)
-	h := api.Teams(dir, subagentResolver{uuid: uuid, name: "alpha"})
-	rr := httptest.NewRecorder()
-	h.ServeHTTP(rr, newTeamsRequest("alpha"))
-	if rr.Code != http.StatusOK {
-		t.Errorf("status = %d", rr.Code)
-	}
-	var got struct {
-		Teams []api.Team `json:"teams"`
-	}
-	_ = json.Unmarshal(rr.Body.Bytes(), &got)
-	if len(got.Teams) != 0 {
-		t.Errorf("len = %d, want 0", len(got.Teams))
-	}
-}
diff --git a/internal/serve/api/tool_call_detail.go b/internal/serve/api/tool_call_detail.go
deleted file mode 100644
index 6bcdac5..0000000
--- a/internal/serve/api/tool_call_detail.go
+++ /dev/null
@@ -1,497 +0,0 @@
-// Package api — /api/sessions/{name}/tool_calls/{id}/detail surfaces
-// the full tool-call input (and, for Edit/MultiEdit/Write, a unified
-// diff) on-demand so the Feed tab can expand any row without paying
-// for a richer hub Event payload at ingest time.
-//
-// Wiring (paste into internal/serve/server.go registerRoutes, next to
-// the other /api/sessions/{name}/... handlers):
-//
-//     mux.Handle(
-//         "GET /api/sessions/{name}/tool_calls/{id}/detail",
-//         authHF(api.ToolCallDetail(api.NewJSONLLogReader(s.logDir, s.proj))),
-//     )
-//
-// `s.logDir` is the absolute path to ~/.config/ctm/logs (same value
-// already passed to `api.LogsUsage` on line 463). `s.proj` is the
-// ingest.Projection used to map human session name → Claude UUID.
-package api
-
-import (
-	"bufio"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"net/http"
-	"os"
-	"path/filepath"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/ingest"
-)
-
-// jsonlScanCap bounds how many bytes we'll read from a single session
-// JSONL file while hunting for a matching ts. 5 MB at ~1 KB/line is
-// ~5k tool calls — two orders of magnitude above the hub ring cap of
-// 500, so any ID still referenced by the UI is comfortably reachable.
-const jsonlScanCap = 5 << 20
-
-// tsMatchTolerance is how far apart the RFC3339 `ts` inside the JSONL
-// line and the unix-second prefix of the hub Event.ID may be while
-// still matching. Tailer uses time.Now().UTC() when the hook payload
-// omits ctm_timestamp, but hub.Publish also stamps its own monotonic
-// clock — the two sources can disagree by a few hundred ms under
-// load. ±1 s covers that safely without matching adjacent events.
-const tsMatchTolerance = time.Second
-
-// ErrDetailNotFound is the sentinel the LogReader returns when the
-// requested id does not correspond to any line in the session's JSONL
-// (either the file is missing, the id is too old to still be on disk,
-// or the scan hit the 5 MB cap without a match).
-var ErrDetailNotFound = errors.New("tool call detail not found")
-
-// Detail is the JSON response shape for GET
-// /api/sessions/{name}/tool_calls/{id}/detail.
-//
-// InputJSON is always the raw `tool_input` sub-object re-encoded as
-// compact JSON so the UI can render it as a code block without having
-// to re-marshal. Diff is only populated when Tool ∈ {Edit,MultiEdit,
-// Write}; empty otherwise.
-type Detail struct {
-	Tool           string `json:"tool"`
-	InputJSON      string `json:"input_json"`
-	OutputExcerpt  string `json:"output_excerpt"`
-	TS             string `json:"ts"`
-	IsError        bool   `json:"is_error"`
-	Diff           string `json:"diff,omitempty"`
-}
-
-// LogReader is the seam the handler talks to. Production wires
-// JSONLLogReader; tests pass a fake. Keeping this narrow means the
-// handler test doesn't need to touch the filesystem.
-type LogReader interface {
-	// ReadDetail returns the Detail for (sessionName, id). On a
-	// clean miss it must return ErrDetailNotFound so the handler can
-	// emit a 404 without logging a 5xx.
-	ReadDetail(sessionName, id string) (Detail, error)
-}
-
-// ToolCallDetail returns the handler for
-// GET /api/sessions/{name}/tool_calls/{id}/detail.
-//
-// reader is the seam described on LogReader. Responses are always
-// application/json. Errors map as:
-//
-//   - 405 on non-GET
-//   - 400 on empty name / id
-//   - 404 on ErrDetailNotFound
-//   - 500 on any other reader error
-func ToolCallDetail(reader LogReader) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if r.Method != http.MethodGet {
-			w.Header().Set("Allow", http.MethodGet)
-			w.Header().Set("Cache-Control", "no-store")
-			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-			return
-		}
-
-		name := r.PathValue("name")
-		id := r.PathValue("id")
-		if name == "" || id == "" {
-			http.Error(w, "bad request", http.StatusBadRequest)
-			return
-		}
-
-		detail, err := reader.ReadDetail(name, id)
-		if err != nil {
-			if errors.Is(err, ErrDetailNotFound) {
-				http.NotFound(w, r)
-				return
-			}
-			slog.Error("tool call detail lookup failed",
-				"session", name, "id", id, "err", err)
-			http.Error(w, "internal error", http.StatusInternalServerError)
-			return
-		}
-
-		w.Header().Set("Content-Type", "application/json")
-		w.Header().Set("Cache-Control", "no-store")
-		w.WriteHeader(http.StatusOK)
-		_ = json.NewEncoder(w).Encode(detail)
-	}
-}
-
-// UUIDResolver maps a human session name to its Claude session UUID.
-// Narrower than ingest.Projection so the reader stays testable.
-type UUIDResolver interface {
-	ResolveName(sessionName string) (uuid string, ok bool)
-}
-
-// projUUIDAdapter bridges ingest.Projection to UUIDResolver; used by
-// the production wiring in server.go.
-type projUUIDAdapter struct{ proj *ingest.Projection }
-
-func (a projUUIDAdapter) ResolveName(name string) (string, bool) {
-	s, ok := a.proj.Get(name)
-	if !ok {
-		return "", false
-	}
-	return s.UUID, s.UUID != ""
-}
-
-// JSONLLogReader is the production LogReader. It maps session name →
-// Claude UUID, scans ~/.config/ctm/logs/.jsonl from end-to-start
-// matching on the hub Event.ID's nanosecond prefix against each
-// line's `ctm_timestamp` (or, lacking that, by falling back to the
-// newest line within the scan cap).
-type JSONLLogReader struct {
-	LogDir   string
-	Resolver UUIDResolver
-}
-
-// NewJSONLLogReader wires a production reader against the tailer log
-// directory and the ingest projection.
-func NewJSONLLogReader(logDir string, proj *ingest.Projection) *JSONLLogReader {
-	return &JSONLLogReader{
-		LogDir:   logDir,
-		Resolver: projUUIDAdapter{proj: proj},
-	}
-}
-
-// ReadDetail implements LogReader. Errors other than ErrDetailNotFound
-// are I/O-level and surface as 500.
-func (r *JSONLLogReader) ReadDetail(sessionName, id string) (Detail, error) {
-	if r == nil || r.Resolver == nil {
-		return Detail{}, ErrDetailNotFound
-	}
-	uuid, ok := r.Resolver.ResolveName(sessionName)
-	if !ok || uuid == "" {
-		return Detail{}, ErrDetailNotFound
-	}
-	path := filepath.Join(r.LogDir, uuid+".jsonl")
-
-	targetSec, hasTarget := idNanoSec(id)
-
-	data, err := readTail(path, jsonlScanCap)
-	if err != nil {
-		if errors.Is(err, os.ErrNotExist) {
-			return Detail{}, ErrDetailNotFound
-		}
-		return Detail{}, err
-	}
-
-	// Walk end → start. Each line is a hook payload with the same
-	// shape the tailer parses.
-	lines := splitLinesReverse(data)
-	for _, line := range lines {
-		if len(line) == 0 {
-			continue
-		}
-		var raw map[string]any
-		if err := json.Unmarshal(line, &raw); err != nil {
-			continue
-		}
-		if hasTarget {
-			lineSec, ok := rawTimestampSec(raw)
-			if !ok {
-				continue
-			}
-			if absDiff(lineSec, targetSec) > int64(tsMatchTolerance/time.Second) {
-				continue
-			}
-		}
-		return buildDetail(raw), nil
-	}
-
-	return Detail{}, ErrDetailNotFound
-}
-
-// idNanoSec extracts the unix-second prefix of a hub Event.ID of the
-// form "-". Returns ok=false for malformed input.
-func idNanoSec(id string) (int64, bool) {
-	left, _, ok := strings.Cut(id, "-")
-	if !ok {
-		return 0, false
-	}
-	ns, err := strconv.ParseInt(left, 10, 64)
-	if err != nil {
-		return 0, false
-	}
-	return ns / int64(time.Second), true
-}
-
-// rawTimestampSec parses a JSONL row's `ctm_timestamp` into a unix
-// second, matching the ingest.parseTimestamp contract. Missing or
-// malformed stamps fall back to the hook's top-level `ts` RFC3339
-// string if present (some older payloads).
-func rawTimestampSec(raw map[string]any) (int64, bool) {
-	candidates := []string{"ctm_timestamp", "ts"}
-	for _, k := range candidates {
-		if s, ok := raw[k].(string); ok {
-			if t, err := time.Parse(time.RFC3339, s); err == nil {
-				return t.Unix(), true
-			}
-		}
-	}
-	return 0, false
-}
-
-func absDiff(a, b int64) int64 {
-	if a > b {
-		return a - b
-	}
-	return b - a
-}
-
-// buildDetail converts a raw hook payload into a Detail. Missing
-// fields degrade gracefully per the tailer's lenient-ingest contract.
-func buildDetail(raw map[string]any) Detail {
-	tool := stringFromMap(raw, "tool_name")
-	isErr := false
-	if tr, ok := raw["tool_response"].(map[string]any); ok {
-		if b, ok := tr["is_error"].(bool); ok {
-			isErr = b
-		}
-	}
-	inputJSON := ""
-	if in, ok := raw["tool_input"].(map[string]any); ok {
-		if b, err := json.Marshal(in); err == nil {
-			inputJSON = string(b)
-		}
-	}
-	output := ""
-	if tr, ok := raw["tool_response"].(map[string]any); ok {
-		if s, ok := tr["output"].(string); ok {
-			output = truncateExcerpt(s, 4096)
-		} else if s, ok := tr["error"].(string); ok {
-			output = truncateExcerpt(s, 4096)
-		}
-	}
-	ts := stringFromMap(raw, "ctm_timestamp")
-	if ts == "" {
-		ts = stringFromMap(raw, "ts")
-	}
-
-	d := Detail{
-		Tool:          tool,
-		InputJSON:     inputJSON,
-		OutputExcerpt: output,
-		TS:            ts,
-		IsError:       isErr,
-	}
-	if diff := renderDiff(tool, raw); diff != "" {
-		d.Diff = diff
-	}
-	return d
-}
-
-// renderDiff builds a unified-diff snippet for Edit/MultiEdit/Write.
-// Returns "" for any other tool or when the payload is too malformed
-// to diff (rather than faking an empty hunk).
-func renderDiff(tool string, raw map[string]any) string {
-	in, ok := raw["tool_input"].(map[string]any)
-	if !ok {
-		return ""
-	}
-	switch tool {
-	case "Edit":
-		return renderEditDiff(in)
-	case "MultiEdit":
-		return renderMultiEditDiff(in)
-	case "Write":
-		return renderWriteDiff(in)
-	default:
-		return ""
-	}
-}
-
-func renderEditDiff(in map[string]any) string {
-	path := stringFromMap(in, "file_path")
-	oldS := stringFromMap(in, "old_string")
-	newS := stringFromMap(in, "new_string")
-	if path == "" {
-		return ""
-	}
-	var b strings.Builder
-	writeDiffHeader(&b, path)
-	writeHunk(&b, oldS, newS)
-	return b.String()
-}
-
-func renderMultiEditDiff(in map[string]any) string {
-	path := stringFromMap(in, "file_path")
-	edits, ok := in["edits"].([]any)
-	if path == "" || !ok || len(edits) == 0 {
-		return ""
-	}
-	var b strings.Builder
-	writeDiffHeader(&b, path)
-	for _, e := range edits {
-		em, ok := e.(map[string]any)
-		if !ok {
-			continue
-		}
-		writeHunk(&b, stringFromMap(em, "old_string"), stringFromMap(em, "new_string"))
-	}
-	return b.String()
-}
-
-func renderWriteDiff(in map[string]any) string {
-	path := stringFromMap(in, "file_path")
-	content := stringFromMap(in, "content")
-	if path == "" {
-		return ""
-	}
-	var b strings.Builder
-	writeDiffHeader(&b, path)
-	// Write replaces the entire file — render as a single all-added
-	// hunk. The "old" side is empty; line counts reflect that.
-	newLines := splitForDiff(content)
-	fmt.Fprintf(&b, "@@ -0,0 +1,%d @@\n", len(newLines))
-	for _, l := range newLines {
-		b.WriteString("+")
-		b.WriteString(l)
-		b.WriteString("\n")
-	}
-	return b.String()
-}
-
-func writeDiffHeader(b *strings.Builder, path string) {
-	fmt.Fprintf(b, "--- a/%s\n", path)
-	fmt.Fprintf(b, "+++ b/%s\n", path)
-}
-
-// writeHunk emits a minimal `@@ ... @@` block for a single Edit-style
-// old/new pair. Line numbers start at 1 because we don't track where
-// in the file the hunk lives — the Edit tool's old_string match is
-// unique within the file, so the UI presents this purely as a
-// before/after snippet rather than a locatable patch.
-func writeHunk(b *strings.Builder, oldS, newS string) {
-	oldLines := splitForDiff(oldS)
-	newLines := splitForDiff(newS)
-	oldCount := len(oldLines)
-	newCount := len(newLines)
-	if oldS == "" {
-		oldCount = 0
-	}
-	if newS == "" {
-		newCount = 0
-	}
-	oldStart := 1
-	if oldCount == 0 {
-		oldStart = 0
-	}
-	newStart := 1
-	if newCount == 0 {
-		newStart = 0
-	}
-	fmt.Fprintf(b, "@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount)
-	if oldS != "" {
-		for _, l := range oldLines {
-			b.WriteString("-")
-			b.WriteString(l)
-			b.WriteString("\n")
-		}
-	}
-	if newS != "" {
-		for _, l := range newLines {
-			b.WriteString("+")
-			b.WriteString(l)
-			b.WriteString("\n")
-		}
-	}
-}
-
-// splitForDiff splits on "\n" without dropping a trailing empty line
-// that represents a real final newline in the source — callers only
-// render it when the original string was non-empty so this is safe.
-func splitForDiff(s string) []string {
-	if s == "" {
-		return nil
-	}
-	out := strings.Split(s, "\n")
-	// If the string ends with '\n', Split produces a trailing "" —
-	// drop it so we don't emit a spurious "+" line.
-	if len(out) > 0 && out[len(out)-1] == "" {
-		out = out[:len(out)-1]
-	}
-	return out
-}
-
-// stringFromMap is a local helper — we can't import ingest's
-// unexported stringField. Keeping the code duplicated here is cheaper
-// than exporting ingest internals just for this.
-func stringFromMap(m map[string]any, key string) string {
-	if v, ok := m[key].(string); ok {
-		return v
-	}
-	return ""
-}
-
-func truncateExcerpt(s string, max int) string {
-	if len(s) <= max {
-		return s
-	}
-	return s[:max-1] + "…"
-}
-
-// readTail reads the last `cap` bytes of the file at path. Returns
-// the full content when the file is smaller than cap. os.ErrNotExist
-// is propagated so the caller can emit a 404.
-func readTail(path string, cap int) ([]byte, error) {
-	f, err := os.Open(path)
-	if err != nil {
-		return nil, err
-	}
-	defer f.Close()
-
-	st, err := f.Stat()
-	if err != nil {
-		return nil, err
-	}
-	size := st.Size()
-	readFrom := int64(0)
-	if size > int64(cap) {
-		readFrom = size - int64(cap)
-	}
-	if _, err := f.Seek(readFrom, io.SeekStart); err != nil {
-		return nil, err
-	}
-	// If we skipped a partial line at the tail-start boundary, drop
-	// the first partial line so we don't hand bufio a malformed row.
-	br := bufio.NewReader(f)
-	if readFrom > 0 {
-		if _, err := br.ReadBytes('\n'); err != nil && !errors.Is(err, io.EOF) {
-			return nil, err
-		}
-	}
-	data, err := io.ReadAll(br)
-	if err != nil && !errors.Is(err, io.EOF) {
-		return nil, err
-	}
-	return data, nil
-}
-
-// splitLinesReverse splits `data` on "\n" and returns the lines in
-// reverse (newest-first) order. Empty trailing line (from a terminal
-// "\n") is dropped.
-func splitLinesReverse(data []byte) [][]byte {
-	// Walk backwards so we don't allocate a forward slice first.
-	out := make([][]byte, 0, 32)
-	end := len(data)
-	for i := len(data) - 1; i >= 0; i-- {
-		if data[i] == '\n' {
-			if i+1 < end {
-				out = append(out, data[i+1:end])
-			}
-			end = i
-		}
-	}
-	if end > 0 {
-		out = append(out, data[:end])
-	}
-	return out
-}
diff --git a/internal/serve/api/tool_call_detail_resolver_test.go b/internal/serve/api/tool_call_detail_resolver_test.go
deleted file mode 100644
index 7cb6cb7..0000000
--- a/internal/serve/api/tool_call_detail_resolver_test.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"errors"
-	"os"
-	"path/filepath"
-	"testing"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/ingest"
-)
-
-// writeSessionsFile creates a minimal sessions.json file at path
-// containing the given (name, uuid) pairs so a Projection can resolve
-// them via Get(). Format mirrors internal/session/state.go diskShape.
-func writeSessionsFile(t *testing.T, path string, entries map[string]string) {
-	t.Helper()
-	type sess struct {
-		Name string `json:"name"`
-		UUID string `json:"uuid"`
-		Mode string `json:"mode"`
-	}
-	body := map[string]any{
-		"schema_version": 1,
-		"sessions":       map[string]sess{},
-	}
-	sessions := body["sessions"].(map[string]sess)
-	for name, uuid := range entries {
-		sessions[name] = sess{Name: name, UUID: uuid, Mode: "ask"}
-	}
-	data, err := json.Marshal(body)
-	if err != nil {
-		t.Fatalf("marshal: %v", err)
-	}
-	if err := os.WriteFile(path, data, 0o600); err != nil {
-		t.Fatalf("write sessions.json: %v", err)
-	}
-}
-
-// TestNewJSONLLogReader_HappyPathResolvesViaProjection covers
-// NewJSONLLogReader and its embedded projUUIDAdapter.ResolveName: the
-// adapter must return the real UUID for known sessions, and surface
-// ErrDetailNotFound for unknown ones.
-func TestNewJSONLLogReader_ResolvesViaProjection(t *testing.T) {
-	dir := t.TempDir()
-	logDir := filepath.Join(dir, "logs")
-	if err := os.MkdirAll(logDir, 0o755); err != nil {
-		t.Fatalf("mkdir logs: %v", err)
-	}
-	sessionsPath := filepath.Join(dir, "sessions.json")
-	writeSessionsFile(t, sessionsPath, map[string]string{
-		"alpha": "11112222-3333-4444-5555-666677778888",
-	})
-
-	proj := ingest.New(sessionsPath, nil)
-	proj.Reload()
-
-	r := NewJSONLLogReader(logDir, proj)
-	if r == nil {
-		t.Fatalf("NewJSONLLogReader returned nil")
-	}
-	if r.LogDir != logDir {
-		t.Errorf("LogDir = %q, want %q", r.LogDir, logDir)
-	}
-	if r.Resolver == nil {
-		t.Fatalf("Resolver is nil")
-	}
-
-	// Known session: ResolveName returns the configured UUID.
-	uuid, ok := r.Resolver.ResolveName("alpha")
-	if !ok || uuid != "11112222-3333-4444-5555-666677778888" {
-		t.Errorf("ResolveName(alpha) = (%q, %v), want (uuid, true)", uuid, ok)
-	}
-
-	// Unknown session: ResolveName reports !ok.
-	if _, ok := r.Resolver.ResolveName("ghost"); ok {
-		t.Errorf("ResolveName(ghost) = ok, want !ok")
-	}
-
-	// ReadDetail for an unknown session must return ErrDetailNotFound
-	// without ever touching the filesystem.
-	if _, err := r.ReadDetail("ghost", "anything-0"); !errors.Is(err, ErrDetailNotFound) {
-		t.Errorf("ReadDetail(ghost) err = %v, want ErrDetailNotFound", err)
-	}
-
-	// ReadDetail for a known session whose JSONL file does not exist
-	// should also return ErrDetailNotFound (os.ErrNotExist mapped).
-	if _, err := r.ReadDetail("alpha", "anything-0"); !errors.Is(err, ErrDetailNotFound) {
-		t.Errorf("ReadDetail(alpha, missing file) err = %v, want ErrDetailNotFound", err)
-	}
-}
-
-// TestNewJSONLLogReader_NilReceiverSafe — paranoia for the early
-// "r == nil || r.Resolver == nil" guard in ReadDetail.
-func TestJSONLLogReader_NilReceiverReturnsNotFound(t *testing.T) {
-	var r *JSONLLogReader
-	if _, err := r.ReadDetail("anything", "id-0"); !errors.Is(err, ErrDetailNotFound) {
-		t.Errorf("nil receiver err = %v, want ErrDetailNotFound", err)
-	}
-}
-
-// TestProjUUIDAdapter_EmptyUUIDIsNotResolvable — the adapter's "uuid != ''"
-// guard ensures sessions with no Claude UUID surface as not-resolvable
-// rather than returning an empty string that would later look up the
-// wrong file.
-func TestProjUUIDAdapter_EmptyUUIDNotResolvable(t *testing.T) {
-	dir := t.TempDir()
-	sessionsPath := filepath.Join(dir, "sessions.json")
-	// alpha has no UUID.
-	writeSessionsFile(t, sessionsPath, map[string]string{"alpha": ""})
-
-	proj := ingest.New(sessionsPath, nil)
-	proj.Reload()
-
-	r := NewJSONLLogReader(dir, proj)
-	uuid, ok := r.Resolver.ResolveName("alpha")
-	if ok || uuid != "" {
-		t.Errorf("ResolveName(alpha, blank uuid) = (%q, %v), want (\"\", false)", uuid, ok)
-	}
-}
diff --git a/internal/serve/api/tool_call_detail_test.go b/internal/serve/api/tool_call_detail_test.go
deleted file mode 100644
index 92a7ee7..0000000
--- a/internal/serve/api/tool_call_detail_test.go
+++ /dev/null
@@ -1,339 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"path/filepath"
-	"strings"
-	"testing"
-	"time"
-)
-
-// fakeLogReader is the testing seam for ToolCallDetail. Returns canned
-// Details keyed on (session, id); any key not present reports
-// ErrDetailNotFound so the handler's 404 path can be exercised.
-type fakeLogReader struct {
-	items map[string]Detail
-	err   error
-}
-
-func (f fakeLogReader) ReadDetail(session, id string) (Detail, error) {
-	if f.err != nil {
-		return Detail{}, f.err
-	}
-	if d, ok := f.items[session+"|"+id]; ok {
-		return d, nil
-	}
-	return Detail{}, ErrDetailNotFound
-}
-
-func newDetailRequest(t *testing.T, session, id string) *http.Request {
-	t.Helper()
-	req := httptest.NewRequest(
-		http.MethodGet,
-		"/api/sessions/"+session+"/tool_calls/"+id+"/detail",
-		nil,
-	)
-	req.SetPathValue("name", session)
-	req.SetPathValue("id", id)
-	return req
-}
-
-func TestToolCallDetail_HappyPathReturnsJSON(t *testing.T) {
-	want := Detail{
-		Tool:          "Edit",
-		InputJSON:     `{"file_path":"/tmp/a.go","old_string":"foo","new_string":"bar"}`,
-		OutputExcerpt: "ok",
-		TS:            "2026-04-21T16:28:00Z",
-		IsError:       false,
-		Diff:          "--- a/tmp/a.go\n+++ b/tmp/a.go\n@@ -1,1 +1,1 @@\n-foo\n+bar\n",
-	}
-	h := ToolCallDetail(fakeLogReader{
-		items: map[string]Detail{"alpha|17771234-0": want},
-	})
-	rec := httptest.NewRecorder()
-	h(rec, newDetailRequest(t, "alpha", "17771234-0"))
-
-	if rec.Code != http.StatusOK {
-		t.Fatalf("status = %d, want 200", rec.Code)
-	}
-	if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
-		t.Errorf("Content-Type = %q, want application/json", ct)
-	}
-	var got Detail
-	if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if got != want {
-		t.Errorf("body mismatch\n got=%+v\nwant=%+v", got, want)
-	}
-}
-
-func TestToolCallDetail_NotFoundReturns404(t *testing.T) {
-	h := ToolCallDetail(fakeLogReader{items: map[string]Detail{}})
-	rec := httptest.NewRecorder()
-	h(rec, newDetailRequest(t, "alpha", "doesnotexist-0"))
-	if rec.Code != http.StatusNotFound {
-		t.Errorf("status = %d, want 404", rec.Code)
-	}
-}
-
-func TestToolCallDetail_MethodNotGet(t *testing.T) {
-	h := ToolCallDetail(fakeLogReader{})
-	for _, method := range []string{
-		http.MethodPost,
-		http.MethodPut,
-		http.MethodDelete,
-		http.MethodPatch,
-	} {
-		rec := httptest.NewRecorder()
-		req := httptest.NewRequest(
-			method,
-			"/api/sessions/alpha/tool_calls/1-0/detail",
-			nil,
-		)
-		req.SetPathValue("name", "alpha")
-		req.SetPathValue("id", "1-0")
-		h(rec, req)
-		if rec.Code != http.StatusMethodNotAllowed {
-			t.Errorf("%s: status = %d, want 405", method, rec.Code)
-		}
-		if got := rec.Header().Get("Allow"); got != http.MethodGet {
-			t.Errorf("%s: Allow = %q, want GET", method, got)
-		}
-	}
-}
-
-func TestToolCallDetail_EmptyPathVarsReturn400(t *testing.T) {
-	h := ToolCallDetail(fakeLogReader{})
-	rec := httptest.NewRecorder()
-	req := httptest.NewRequest(http.MethodGet, "/api/sessions//tool_calls//detail", nil)
-	req.SetPathValue("name", "")
-	req.SetPathValue("id", "")
-	h(rec, req)
-	if rec.Code != http.StatusBadRequest {
-		t.Errorf("status = %d, want 400", rec.Code)
-	}
-}
-
-// renderDiff unit tests — exercise the three tool shapes. These hit
-// the rendering directly rather than going through the HTTP layer so
-// failures pinpoint the diff builder.
-
-func TestRenderDiff_EditEmitsReplaceHunk(t *testing.T) {
-	raw := map[string]any{
-		"tool_name": "Edit",
-		"tool_input": map[string]any{
-			"file_path":  "/tmp/a.go",
-			"old_string": "package foo\n\nfunc Bar() {}",
-			"new_string": "package foo\n\nfunc Baz() {}",
-		},
-	}
-	got := renderDiff("Edit", raw)
-	mustContain(t, got, "--- a//tmp/a.go")
-	mustContain(t, got, "+++ b//tmp/a.go")
-	mustContain(t, got, "@@ -1,3 +1,3 @@")
-	mustContain(t, got, "-func Bar() {}")
-	mustContain(t, got, "+func Baz() {}")
-}
-
-func TestRenderDiff_WriteEmitsAllAddedHunk(t *testing.T) {
-	raw := map[string]any{
-		"tool_name": "Write",
-		"tool_input": map[string]any{
-			"file_path": "/tmp/new.txt",
-			"content":   "hello\nworld\n",
-		},
-	}
-	got := renderDiff("Write", raw)
-	mustContain(t, got, "@@ -0,0 +1,2 @@")
-	mustContain(t, got, "+hello")
-	mustContain(t, got, "+world")
-	if strings.Contains(got, "-") {
-		// "-" would indicate a removed-line leak into an all-added
-		// hunk. Check a position-aware pattern: "\n-" at the start
-		// of a line is the problem shape.
-		if strings.Contains(got, "\n-") {
-			t.Errorf("Write diff must contain no removed lines:\n%s", got)
-		}
-	}
-}
-
-func TestRenderDiff_MultiEditEmitsMultipleHunks(t *testing.T) {
-	raw := map[string]any{
-		"tool_name": "MultiEdit",
-		"tool_input": map[string]any{
-			"file_path": "/tmp/multi.go",
-			"edits": []any{
-				map[string]any{
-					"old_string": "alpha",
-					"new_string": "ALPHA",
-				},
-				map[string]any{
-					"old_string": "beta",
-					"new_string": "BETA",
-				},
-			},
-		},
-	}
-	got := renderDiff("MultiEdit", raw)
-	hunks := strings.Count(got, "@@ -")
-	if hunks != 2 {
-		t.Errorf("hunk count = %d, want 2\n%s", hunks, got)
-	}
-	for _, token := range []string{"-alpha", "+ALPHA", "-beta", "+BETA"} {
-		mustContain(t, got, token)
-	}
-}
-
-func TestRenderDiff_NonDiffToolReturnsEmpty(t *testing.T) {
-	raw := map[string]any{
-		"tool_name":  "Bash",
-		"tool_input": map[string]any{"command": "ls"},
-	}
-	if got := renderDiff("Bash", raw); got != "" {
-		t.Errorf("Bash diff should be empty, got:\n%s", got)
-	}
-	if got := renderDiff("Read", raw); got != "" {
-		t.Errorf("Read diff should be empty, got:\n%s", got)
-	}
-}
-
-// JSONLLogReader integration — drives the real scan path against a
-// hand-authored tailer-shaped JSONL file. Kept lean: one session, one
-// matching line, one distractor line.
-
-func TestJSONLLogReader_FindsMatchByTS(t *testing.T) {
-	dir := t.TempDir()
-	uuid := "11111111-2222-3333-4444-555555555555"
-	path := filepath.Join(dir, uuid+".jsonl")
-
-	ts := time.Date(2026, 4, 21, 16, 28, 0, 0, time.UTC)
-	// Distractor: older line that should NOT match because its ts is
-	// >1 s away from the target.
-	older := map[string]any{
-		"tool_name":     "Bash",
-		"tool_input":    map[string]any{"command": "ls"},
-		"ctm_timestamp": ts.Add(-10 * time.Second).Format(time.RFC3339),
-	}
-	// Target: Edit call right at the target second.
-	target := map[string]any{
-		"tool_name": "Edit",
-		"tool_input": map[string]any{
-			"file_path":  "/tmp/x.go",
-			"old_string": "foo",
-			"new_string": "bar",
-		},
-		"tool_response": map[string]any{
-			"output":   "ok",
-			"is_error": false,
-		},
-		"ctm_timestamp": ts.Format(time.RFC3339),
-	}
-	writeJSONLLine(t, path, older)
-	writeJSONLLine(t, path, target)
-
-	reader := &JSONLLogReader{
-		LogDir:   dir,
-		Resolver: staticResolver{"alpha": uuid},
-	}
-	// id = "-"; nanos ÷ 1e9 == ts.Unix().
-	id := toID(ts)
-	d, err := reader.ReadDetail("alpha", id)
-	if err != nil {
-		t.Fatalf("ReadDetail: %v", err)
-	}
-	if d.Tool != "Edit" {
-		t.Errorf("Tool = %q, want Edit", d.Tool)
-	}
-	if !strings.Contains(d.Diff, "-foo") || !strings.Contains(d.Diff, "+bar") {
-		t.Errorf("Diff missing replace content:\n%s", d.Diff)
-	}
-	if d.TS != ts.Format(time.RFC3339) {
-		t.Errorf("TS = %q, want %q", d.TS, ts.Format(time.RFC3339))
-	}
-}
-
-func TestJSONLLogReader_MissingFileIsNotFound(t *testing.T) {
-	dir := t.TempDir()
-	reader := &JSONLLogReader{
-		LogDir: dir,
-		Resolver: staticResolver{
-			"alpha": "99999999-9999-9999-9999-999999999999",
-		},
-	}
-	_, err := reader.ReadDetail("alpha", toID(time.Now()))
-	if err != ErrDetailNotFound {
-		t.Errorf("err = %v, want ErrDetailNotFound", err)
-	}
-}
-
-func TestJSONLLogReader_UnknownSessionIsNotFound(t *testing.T) {
-	reader := &JSONLLogReader{
-		LogDir:   t.TempDir(),
-		Resolver: staticResolver{},
-	}
-	_, err := reader.ReadDetail("nope", toID(time.Now()))
-	if err != ErrDetailNotFound {
-		t.Errorf("err = %v, want ErrDetailNotFound", err)
-	}
-}
-
-// --- helpers ---------------------------------------------------------
-
-type staticResolver map[string]string
-
-func (s staticResolver) ResolveName(name string) (string, bool) {
-	u, ok := s[name]
-	return u, ok
-}
-
-func writeJSONLLine(t *testing.T, path string, payload map[string]any) {
-	t.Helper()
-	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
-	if err != nil {
-		t.Fatalf("open: %v", err)
-	}
-	defer f.Close()
-	body, err := json.Marshal(payload)
-	if err != nil {
-		t.Fatalf("marshal: %v", err)
-	}
-	if _, err := f.Write(append(body, '\n')); err != nil {
-		t.Fatalf("write: %v", err)
-	}
-}
-
-// toID mirrors the hub's ID format "-" for a given
-// wall clock. Seq is always 0 here — we only match on the nanosecond
-// prefix.
-func toID(t time.Time) string {
-	return timeNanoString(t) + "-0"
-}
-
-func timeNanoString(t time.Time) string {
-	return strings0Pad(t.UnixNano())
-}
-
-// strings0Pad formats an int64 as decimal without thousand separators.
-// Named instead of calling strconv directly so the helper file stays
-// import-lean — the production path already imports strconv.
-func strings0Pad(n int64) string {
-	// Local FormatInt avoids a second strconv import line here.
-	return fmtInt(n)
-}
-
-func fmtInt(n int64) string {
-	// Simple wrapper around strconv.FormatInt via json.
-	b, _ := json.Marshal(n)
-	return string(b)
-}
-
-func mustContain(t *testing.T, haystack, needle string) {
-	t.Helper()
-	if !strings.Contains(haystack, needle) {
-		t.Errorf("missing %q in:\n%s", needle, haystack)
-	}
-}
diff --git a/internal/serve/assets.go b/internal/serve/assets.go
deleted file mode 100644
index 4735974..0000000
--- a/internal/serve/assets.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package serve
-
-import (
-	"embed"
-	"io/fs"
-	"net/http"
-	"strings"
-)
-
-// dist holds the built React UI, rsync'd from `ui/dist/` by `make ui`.
-// Committed contents survive a fresh clone; the directory itself is in
-// .gitignore so source tarballs don't carry stale artifacts.
-//
-//go:embed all:dist
-var dist embed.FS
-
-// distFS is the rooted view of the embed (i.e. dropping the leading
-// "dist/" prefix) so HTTP serves /index.html etc. directly.
-func distFS() fs.FS {
-	sub, err := fs.Sub(dist, "dist")
-	if err != nil {
-		// Compile-time invariant: the dist directory exists because
-		// `make ui` ran before `go build`. Panic loudly if not.
-		panic("serve: dist embed missing — run `make ui` before `go build`: " + err.Error())
-	}
-	return sub
-}
-
-// assetHandler serves the embedded React UI from the root. /api/* and
-// /events/* return 404 here so future handlers can claim those
-// prefixes cleanly via mux.Handle without HTML leaking through.
-//
-// SPA routing: any path that isn't a static asset and isn't /api /
-// /events falls back to index.html so client-side routes (e.g. /s/:name)
-// resolve correctly on a hard refresh or deep link.
-func assetHandler() http.Handler {
-	root := distFS()
-	files := http.FileServer(http.FS(root))
-	indexHTML, _ := fs.ReadFile(root, "index.html")
-
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		path := r.URL.Path
-		if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/events/") {
-			http.NotFound(w, r)
-			return
-		}
-		// Try the static FS first; on miss, serve index.html so the
-		// React router can take over.
-		if path != "/" {
-			rel := strings.TrimPrefix(path, "/")
-			if _, err := fs.Stat(root, rel); err == nil {
-				files.ServeHTTP(w, r)
-				return
-			}
-		}
-		w.Header().Set("Content-Type", "text/html; charset=utf-8")
-		w.Header().Set("Cache-Control", "no-store")
-		_, _ = w.Write(indexHTML)
-	})
-}
diff --git a/internal/serve/attention/engine.go b/internal/serve/attention/engine.go
deleted file mode 100644
index 5eca760..0000000
--- a/internal/serve/attention/engine.go
+++ /dev/null
@@ -1,550 +0,0 @@
-// Package attention implements the v0.1 attention engine: the seven
-// locked triggers (A–G) from docs/superpowers/specs/2026-04-20-ctm-serve-
-// ui-v0.1-design.md §4 "Attention engine". The engine subscribes to the
-// hub's global stream, maintains per-session state, and publishes
-// `attention_raised` / `attention_cleared` events on state transitions.
-package attention
-
-import (
-	"context"
-	"encoding/json"
-	"log/slog"
-	"sync"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-// State values are the canonical alert identifiers consumed by the UI
-// (see spec §6 Session.attention.state). Empty string means "clear".
-const (
-	StateClear           = ""
-	StateLastErrorCall   = "last_error_call"
-	StateErrorBurst      = "error_burst"
-	StateStuck           = "stuck"
-	StateTmuxDead        = "tmux_dead"
-	StateQuotaHigh       = "quota_high"
-	StateContextImminent = "context_imminent"
-	StateYoloUnchecked   = "yolo_unchecked"
-)
-
-// Thresholds wires the seven spec-mandated defaults through to the
-// engine. Server-side config maps onto these fields.
-type Thresholds struct {
-	ErrorRatePct         int
-	ErrorRateWindow      int
-	IdleMinutes          int
-	QuotaPct             int
-	ContextPct           int
-	YoloUncheckedMinutes int
-}
-
-// Defaults returns the thresholds documented in spec §1 "Attention
-// triggers". Any field left zero by a caller is NOT backfilled here —
-// callers pass `Defaults()` and then override as needed.
-func Defaults() Thresholds {
-	return Thresholds{
-		ErrorRatePct:         20,
-		ErrorRateWindow:      20,
-		IdleMinutes:          5,
-		QuotaPct:             85,
-		ContextPct:           90,
-		YoloUncheckedMinutes: 30,
-	}
-}
-
-// Snapshot is the point-in-time per-session view surfaced via the
-// SessionEnricher into /api/sessions. An empty State means "no alert".
-type Snapshot struct {
-	State   string
-	Since   time.Time
-	Details string
-}
-
-// QuotaSource is the read side of ingest.QuotaIngester the engine needs.
-// Percentages are float64 to match the underlying accessor; the engine
-// compares against the configured int threshold after a plain cast.
-type QuotaSource interface {
-	WeeklyPct() (float64, bool)
-	FiveHourPct() (float64, bool)
-	ContextPct(session string) (int, bool)
-}
-
-// SessionSource supplies per-session metadata for triggers C/D/G.
-type SessionSource interface {
-	Names() []string
-	Mode(name string) string
-	TmuxAlive(name string) bool
-	LastCheckpointAt(name string) (time.Time, bool)
-}
-
-// tickInterval is how often time-based triggers (C, G) are re-evaluated
-// in the absence of events. Short enough to feel live, long enough to
-// avoid waking up every session on every heartbeat.
-const tickInterval = 30 * time.Second
-
-// Engine is the attention evaluator. A single instance runs per serve
-// process, subscribed to the hub's global stream.
-type Engine struct {
-	hub      *events.Hub
-	quota    QuotaSource
-	sessions SessionSource
-	thr      Thresholds
-	now      func() time.Time
-
-	// bootTime is the clock at NewEngine. Trigger C (stuck) uses
-	// max(st.lastCall, bootTime) as its reference so sessions whose
-	// only tool_call history came from a hub-ring replay don't
-	// instantly trip stuck with an ancient timestamp. A session that
-	// truly goes idle after boot will still fire after IdleMinutes.
-	bootTime time.Time
-
-	mu       sync.Mutex
-	sessions_state map[string]*sessionState
-}
-
-// sessionState is the rolling per-session working set. All reads and
-// writes go through Engine.mu; Snapshot returns a copy.
-type sessionState struct {
-	current  Snapshot
-	errWin   []bool    // rolling window of is_error, newest last
-	lastCall time.Time // timestamp of most recent tool_call seen
-	yoloAt   time.Time // when "yolo" mode was first observed
-}
-
-// NewEngine constructs an Engine. clock == nil uses time.Now.
-func NewEngine(hub *events.Hub, quota QuotaSource, sessions SessionSource, thr Thresholds, clock func() time.Time) *Engine {
-	if clock == nil {
-		clock = time.Now
-	}
-	return &Engine{
-		hub:            hub,
-		quota:          quota,
-		sessions:       sessions,
-		thr:            thr,
-		now:            clock,
-		bootTime:       clock(),
-		sessions_state: make(map[string]*sessionState),
-	}
-}
-
-// Run blocks until ctx is cancelled. It drives the engine from two
-// sources: hub events (reactive) and a 30-second tick (idle/yolo).
-func (e *Engine) Run(ctx context.Context) error {
-	if e.hub == nil {
-		<-ctx.Done()
-		return nil
-	}
-	sub, replay := e.hub.Subscribe("", "")
-	defer sub.Close()
-
-	// Replay the hub ring so a late-starting engine recovers state.
-	for _, ev := range replay {
-		e.handleEvent(ev)
-	}
-	e.evaluateAll()
-
-	t := time.NewTicker(tickInterval)
-	defer t.Stop()
-
-	for {
-		select {
-		case <-ctx.Done():
-			return nil
-		case ev, ok := <-sub.Events():
-			if !ok {
-				return nil
-			}
-			// Ignore events we publish ourselves to avoid feedback.
-			if ev.Type == "attention_raised" || ev.Type == "attention_cleared" {
-				continue
-			}
-			e.handleEvent(ev)
-			e.evaluateAll()
-		case <-t.C:
-			e.evaluateAll()
-		}
-	}
-}
-
-// LastToolCallAt returns the timestamp of the most recent tool_call
-// event observed for this session (from live stream or hub replay).
-// Exposed so the sessions API can surface real activity instead of
-// just last_attached_at — used as the primary list sort key.
-func (e *Engine) LastToolCallAt(name string) (time.Time, bool) {
-	e.mu.Lock()
-	defer e.mu.Unlock()
-	st, ok := e.sessions_state[name]
-	if !ok || st.lastCall.IsZero() {
-		return time.Time{}, false
-	}
-	return st.lastCall, true
-}
-
-// Snapshot returns the current per-session snapshot. Returns ok=false
-// when no alert is active (StateClear).
-func (e *Engine) Snapshot(name string) (Snapshot, bool) {
-	e.mu.Lock()
-	defer e.mu.Unlock()
-	st, ok := e.sessions_state[name]
-	if !ok || st.current.State == StateClear {
-		return Snapshot{}, false
-	}
-	// Copy to decouple from internal mutation.
-	return st.current, true
-}
-
-// --- event ingestion ------------------------------------------------------
-
-// toolCallPayload mirrors ingest.ToolCallPayload without importing the
-// ingest package (keeps the dependency direction clean: attention does
-// not depend on ingest).
-type toolCallPayload struct {
-	Session string    `json:"session"`
-	IsError bool      `json:"is_error"`
-	TS      time.Time `json:"ts"`
-}
-
-// sessionLifecyclePayload decodes the name out of session_new/killed/on_yolo.
-type sessionLifecyclePayload struct {
-	Name    string `json:"name"`
-	Session string `json:"session"`
-	Mode    string `json:"mode"`
-}
-
-func (e *Engine) handleEvent(ev events.Event) {
-	switch ev.Type {
-	case "tool_call":
-		var p toolCallPayload
-		if err := json.Unmarshal(ev.Payload, &p); err != nil {
-			return
-		}
-		name := p.Session
-		if name == "" {
-			name = ev.Session
-		}
-		if name == "" {
-			return
-		}
-		ts := p.TS
-		if ts.IsZero() {
-			ts = e.now()
-		}
-		e.recordToolCall(name, p.IsError, ts)
-	case "session_killed":
-		name := sessionNameFromPayload(ev)
-		if name != "" {
-			e.markTmuxDead(name)
-		}
-	case "on_yolo":
-		name := sessionNameFromPayload(ev)
-		if name != "" {
-			e.markYolo(name)
-		}
-	}
-}
-
-func sessionNameFromPayload(ev events.Event) string {
-	if ev.Session != "" {
-		return ev.Session
-	}
-	var p sessionLifecyclePayload
-	if err := json.Unmarshal(ev.Payload, &p); err != nil {
-		return ""
-	}
-	if p.Name != "" {
-		return p.Name
-	}
-	return p.Session
-}
-
-func (e *Engine) recordToolCall(name string, isError bool, ts time.Time) {
-	e.mu.Lock()
-	defer e.mu.Unlock()
-	st := e.stateLocked(name)
-	st.lastCall = ts
-	win := e.thr.ErrorRateWindow
-	if win <= 0 {
-		win = 1
-	}
-	st.errWin = append(st.errWin, isError)
-	if len(st.errWin) > win {
-		st.errWin = st.errWin[len(st.errWin)-win:]
-	}
-}
-
-func (e *Engine) markTmuxDead(name string) {
-	e.mu.Lock()
-	defer e.mu.Unlock()
-	// Engine caches the "dead" signal on state; evaluate() will confirm
-	// it against SessionSource.TmuxAlive too on the next tick.
-	st := e.stateLocked(name)
-	// No-op field — tmux_dead is driven from SessionSource.TmuxAlive.
-	// This hook is here so we can force-eval on the event.
-	_ = st
-}
-
-func (e *Engine) markYolo(name string) {
-	e.mu.Lock()
-	defer e.mu.Unlock()
-	st := e.stateLocked(name)
-	if st.yoloAt.IsZero() {
-		st.yoloAt = e.now()
-	}
-}
-
-func (e *Engine) stateLocked(name string) *sessionState {
-	st, ok := e.sessions_state[name]
-	if !ok {
-		st = &sessionState{}
-		e.sessions_state[name] = st
-	}
-	return st
-}
-
-// --- evaluation -----------------------------------------------------------
-
-// evaluateAll re-computes every known session's state and publishes
-// transitions. Called after every event and on every tick.
-func (e *Engine) evaluateAll() {
-	names := e.sessions.Names()
-	seen := make(map[string]struct{}, len(names))
-	for _, n := range names {
-		seen[n] = struct{}{}
-	}
-
-	// Include any sessions the engine is tracking that SessionSource
-	// hasn't listed (e.g. a tool_call arrived for a just-forgotten
-	// session). They'll resolve to StateClear if no trigger fires.
-	e.mu.Lock()
-	for n := range e.sessions_state {
-		if _, ok := seen[n]; !ok {
-			names = append(names, n)
-			seen[n] = struct{}{}
-		}
-	}
-	e.mu.Unlock()
-
-	for _, name := range names {
-		e.evaluateOne(name)
-	}
-}
-
-func (e *Engine) evaluateOne(name string) {
-	// Observe YOLO mode and remember entry time for trigger G. We do
-	// this outside the evaluation lock so SessionSource implementations
-	// can hold their own mutexes without risking reentry.
-	mode := e.sessions.Mode(name)
-	alive := e.sessions.TmuxAlive(name)
-	lastCP, hasCP := e.sessions.LastCheckpointAt(name)
-	weeklyPct, hasWeekly := e.quota.WeeklyPct()
-	fiveHrPct, hasFive := e.quota.FiveHourPct()
-	ctxPct, hasCtx := e.quota.ContextPct(name)
-	now := e.now()
-
-	e.mu.Lock()
-	st := e.stateLocked(name)
-
-	// Track yolo entry once we first observe it.
-	if mode == "yolo" {
-		if st.yoloAt.IsZero() {
-			st.yoloAt = now
-		}
-	} else {
-		st.yoloAt = time.Time{}
-	}
-
-	next := e.pickState(st, mode, alive, lastCP, hasCP, weeklyPct, hasWeekly, fiveHrPct, hasFive, ctxPct, hasCtx, now)
-
-	prev := st.current
-	var raise, clear bool
-	switch {
-	case prev.State == next.State && prev.State == StateClear:
-		// nothing
-	case prev.State == next.State:
-		// Keep the original Since; refresh Details if changed.
-		if prev.Details != next.Details {
-			st.current.Details = next.Details
-		}
-	case prev.State == StateClear:
-		st.current = next
-		raise = true
-	case next.State == StateClear:
-		st.current = Snapshot{}
-		clear = true
-	default:
-		// alert → different alert: clear old, raise new.
-		st.current = next
-		clear = true
-		raise = true
-	}
-
-	// Capture what we need for publishing, drop the lock before I/O.
-	publishClear := clear
-	publishRaise := raise
-	prevForClear := prev
-	newForRaise := st.current
-	e.mu.Unlock()
-
-	if publishClear {
-		e.publishCleared(name, prevForClear)
-	}
-	if publishRaise {
-		e.publishRaised(name, newForRaise)
-	}
-}
-
-// pickState runs the 7 trigger checks and returns the single highest-
-// priority alert. Precedence (most urgent first):
-//
-//	D tmux_dead > A last_error_call > B error_burst > E quota_high >
-//	F context_imminent > G yolo_unchecked > C stuck.
-//
-// The spec is silent on ordering; this matches the severity sort the
-// UI list uses (dead/last-error dominate; stuck is the softest signal).
-func (e *Engine) pickState(
-	st *sessionState,
-	mode string,
-	alive bool,
-	lastCP time.Time, hasCP bool,
-	weeklyPct float64, hasWeekly bool,
-	fiveHrPct float64, hasFive bool,
-	ctxPct int, hasCtx bool,
-	now time.Time,
-) Snapshot {
-	// D · tmux_dead — highest priority: the session is gone.
-	if !alive {
-		return Snapshot{State: StateTmuxDead, Since: e.sinceOr(st, StateTmuxDead, now), Details: "tmux session no longer exists"}
-	}
-
-	// A · last_error_call — most recent tool_call was an error.
-	if n := len(st.errWin); n > 0 && st.errWin[n-1] {
-		return Snapshot{State: StateLastErrorCall, Since: e.sinceOr(st, StateLastErrorCall, now), Details: "last tool call returned is_error=true"}
-	}
-
-	// B · error_burst — rate over rolling window.
-	if n := len(st.errWin); n > 0 && e.thr.ErrorRateWindow > 0 && n >= e.thr.ErrorRateWindow {
-		errs := 0
-		for _, b := range st.errWin {
-			if b {
-				errs++
-			}
-		}
-		if errs*100 >= e.thr.ErrorRatePct*n {
-			return Snapshot{
-				State:   StateErrorBurst,
-				Since:   e.sinceOr(st, StateErrorBurst, now),
-				Details: formatErrorBurst(errs, n),
-			}
-		}
-	}
-
-	// E · quota_high — global weekly OR 5-hour ≥ threshold.
-	if (hasWeekly && weeklyPct >= float64(e.thr.QuotaPct)) ||
-		(hasFive && fiveHrPct >= float64(e.thr.QuotaPct)) {
-		return Snapshot{
-			State:   StateQuotaHigh,
-			Since:   e.sinceOr(st, StateQuotaHigh, now),
-			Details: formatQuota(weeklyPct, hasWeekly, fiveHrPct, hasFive),
-		}
-	}
-
-	// F · context_imminent — per-session context window.
-	if hasCtx && ctxPct >= e.thr.ContextPct {
-		return Snapshot{
-			State:   StateContextImminent,
-			Since:   e.sinceOr(st, StateContextImminent, now),
-			Details: formatCtx(ctxPct),
-		}
-	}
-
-	// G · yolo_unchecked — yolo mode active > threshold without checkpoint.
-	if mode == "yolo" && e.thr.YoloUncheckedMinutes > 0 && !st.yoloAt.IsZero() {
-		yoloFor := now.Sub(st.yoloAt)
-		tooLong := yoloFor > time.Duration(e.thr.YoloUncheckedMinutes)*time.Minute
-		noCP := !hasCP || now.Sub(lastCP) > time.Duration(e.thr.YoloUncheckedMinutes)*time.Minute
-		if tooLong && noCP {
-			return Snapshot{
-				State:   StateYoloUnchecked,
-				Since:   e.sinceOr(st, StateYoloUnchecked, now),
-				Details: "yolo mode without recent checkpoint",
-			}
-		}
-	}
-
-	// C · stuck — idle > threshold while tmux alive. Reference clamps
-	// to bootTime so an ancient tool_call recovered from the hub ring
-	// on daemon restart can't pre-age the signal; the session has to
-	// go idle for IdleMinutes *since we started watching*.
-	if e.thr.IdleMinutes > 0 && !st.lastCall.IsZero() {
-		ref := st.lastCall
-		if ref.Before(e.bootTime) {
-			ref = e.bootTime
-		}
-		if now.Sub(ref) > time.Duration(e.thr.IdleMinutes)*time.Minute {
-			return Snapshot{
-				State:   StateStuck,
-				Since:   e.sinceOr(st, StateStuck, now),
-				Details: "no tool call in > idle threshold",
-			}
-		}
-	}
-
-	return Snapshot{}
-}
-
-// sinceOr keeps the original "since" timestamp when an alert persists
-// across evaluations; otherwise returns now.
-func (e *Engine) sinceOr(st *sessionState, state string, now time.Time) time.Time {
-	if st.current.State == state && !st.current.Since.IsZero() {
-		return st.current.Since
-	}
-	return now
-}
-
-// --- publishing -----------------------------------------------------------
-
-type raisedPayload struct {
-	Session     string    `json:"session"`
-	State       string    `json:"state"`
-	Since       time.Time `json:"since"`
-	Details     string    `json:"details,omitempty"`
-	TriggerRule string    `json:"trigger_rule"`
-}
-
-type clearedPayload struct {
-	Session string `json:"session"`
-	State   string `json:"state,omitempty"`
-}
-
-func (e *Engine) publishRaised(name string, s Snapshot) {
-	payload, err := json.Marshal(raisedPayload{
-		Session:     name,
-		State:       s.State,
-		Since:       s.Since,
-		Details:     s.Details,
-		TriggerRule: s.State,
-	})
-	if err != nil {
-		slog.Warn("attention publish raise marshal", "err", err, "session", name)
-		return
-	}
-	e.hub.Publish(events.Event{
-		Type:    "attention_raised",
-		Session: name,
-		Payload: payload,
-	})
-}
-
-func (e *Engine) publishCleared(name string, prev Snapshot) {
-	payload, err := json.Marshal(clearedPayload{Session: name, State: prev.State})
-	if err != nil {
-		slog.Warn("attention publish clear marshal", "err", err, "session", name)
-		return
-	}
-	e.hub.Publish(events.Event{
-		Type:    "attention_cleared",
-		Session: name,
-		Payload: payload,
-	})
-}
diff --git a/internal/serve/attention/engine_more_test.go b/internal/serve/attention/engine_more_test.go
deleted file mode 100644
index 502a5b9..0000000
--- a/internal/serve/attention/engine_more_test.go
+++ /dev/null
@@ -1,219 +0,0 @@
-package attention
-
-import (
-	"encoding/json"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-// TestLastToolCallAt_RecordedFromToolCall verifies the per-session
-// lastCall timestamp is exposed via the public LastToolCallAt accessor.
-func TestLastToolCallAt_RecordedFromToolCall(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	q := newFakeQuota()
-	s := newFakeSessions("alpha")
-	eng := newEngineAt(&now, hub, q, s, Defaults())
-
-	// Unknown session → ok=false.
-	if _, ok := eng.LastToolCallAt("ghost"); ok {
-		t.Fatalf("LastToolCallAt(ghost) = ok, want !ok")
-	}
-
-	// Tracked session with no calls yet → ok=false.
-	eng.handleEvent(events.Event{
-		Type:    "tool_call",
-		Session: "alpha",
-		Payload: mustPayload(t, map[string]any{
-			"session":  "alpha",
-			"is_error": false,
-			"ts":       now,
-		}),
-	})
-
-	got, ok := eng.LastToolCallAt("alpha")
-	if !ok {
-		t.Fatalf("LastToolCallAt after tool_call = !ok, want ok")
-	}
-	if !got.Equal(now) {
-		t.Errorf("LastToolCallAt = %v, want %v", got, now)
-	}
-
-	// A second tool_call with a later ts replaces the recorded value.
-	later := now.Add(2 * time.Minute)
-	eng.handleEvent(events.Event{
-		Type:    "tool_call",
-		Session: "alpha",
-		Payload: mustPayload(t, map[string]any{
-			"session":  "alpha",
-			"is_error": false,
-			"ts":       later,
-		}),
-	})
-	got2, _ := eng.LastToolCallAt("alpha")
-	if !got2.Equal(later) {
-		t.Errorf("after second call: %v, want %v", got2, later)
-	}
-}
-
-// TestSessionNameFromPayload_Fallbacks covers the three branches of
-// sessionNameFromPayload: explicit ev.Session, payload "name", and the
-// final return path. We exercise it through handleEvent so the test
-// only relies on the public surface.
-func TestHandleEvent_OnYolo_NameFromPayload(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	q := newFakeQuota()
-	s := newFakeSessions() // no preregistered names
-	thr := Defaults()
-	thr.YoloUncheckedMinutes = 30
-	eng := newEngineAt(&now, hub, q, s, thr)
-
-	// Event has empty ev.Session — name must come from payload.name.
-	payload, _ := json.Marshal(map[string]any{"name": "alpha"})
-	eng.handleEvent(events.Event{Type: "on_yolo", Payload: payload})
-
-	// markYolo only sets state; G must NOT trip on a fresh entry.
-	// But the yoloAt timestamp is now == clock; advancing the clock
-	// past the threshold without a checkpoint and re-evaluating
-	// triggers G.
-	s.names = append(s.names, "alpha")
-	s.alive["alpha"] = true
-	s.modes["alpha"] = "yolo"
-
-	// 31 minutes later → trip.
-	now = now.Add(31 * time.Minute)
-	eng.evaluateAll()
-	snap, ok := eng.Snapshot("alpha")
-	if !ok || snap.State != StateYoloUnchecked {
-		t.Fatalf("want yolo_unchecked after threshold elapsed, got %+v ok=%v", snap, ok)
-	}
-}
-
-// TestHandleEvent_OnYolo_EmptyNamesIgnored ensures markYolo is not
-// called when the event has neither ev.Session nor a payload name.
-func TestHandleEvent_OnYolo_NoNameIgnored(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	eng := newEngineAt(&now, hub, newFakeQuota(), newFakeSessions(), Defaults())
-
-	eng.handleEvent(events.Event{Type: "on_yolo", Payload: []byte(`{}`)})
-	// No session ever added — Snapshot of unknown name is ok=false.
-	if _, ok := eng.Snapshot(""); ok {
-		t.Fatalf("expected no state created for nameless event")
-	}
-}
-
-// TestHandleEvent_OnYolo_BadJSONIgnored makes sure malformed payloads
-// don't panic or create stray state.
-func TestHandleEvent_OnYolo_BadJSONIgnored(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	eng := newEngineAt(&now, hub, newFakeQuota(), newFakeSessions(), Defaults())
-
-	eng.handleEvent(events.Event{Type: "on_yolo", Payload: []byte(`{not json`)})
-	if _, ok := eng.Snapshot("anything"); ok {
-		t.Fatalf("expected no state created for bad payload")
-	}
-}
-
-// TestHandleEvent_SessionKilled_EvSessionWins exercises markTmuxDead
-// via the explicit ev.Session path. The dead state shows up only on
-// next evaluateAll because markTmuxDead is a no-op for state and
-// SessionSource.TmuxAlive is the source of truth.
-func TestHandleEvent_SessionKilled_TriggersDead(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	s := newFakeSessions("alpha")
-	eng := newEngineAt(&now, hub, newFakeQuota(), s, Defaults())
-
-	// Mark the session dead in the SessionSource and dispatch the event.
-	s.setAlive("alpha", false)
-	eng.handleEvent(events.Event{Type: "session_killed", Session: "alpha", Payload: []byte(`{}`)})
-	eng.evaluateAll()
-	snap, ok := eng.Snapshot("alpha")
-	if !ok || snap.State != StateTmuxDead {
-		t.Fatalf("want tmux_dead, got %+v ok=%v", snap, ok)
-	}
-}
-
-// TestHandleEvent_ToolCall_IgnoresUnknownSession covers the early
-// return when neither ev.Session nor payload.session is set.
-func TestHandleEvent_ToolCall_NoSessionIgnored(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	eng := newEngineAt(&now, hub, newFakeQuota(), newFakeSessions(), Defaults())
-
-	payload, _ := json.Marshal(map[string]any{"is_error": true})
-	eng.handleEvent(events.Event{Type: "tool_call", Payload: payload})
-
-	// Nothing should have been created.
-	if _, ok := eng.LastToolCallAt(""); ok {
-		t.Fatalf("expected no tracked session for nameless tool_call")
-	}
-}
-
-// TestHandleEvent_ToolCall_BadJSONIgnored covers the json.Unmarshal
-// error branch of handleEvent.
-func TestHandleEvent_ToolCall_BadJSONIgnored(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	eng := newEngineAt(&now, hub, newFakeQuota(), newFakeSessions("alpha"), Defaults())
-
-	eng.handleEvent(events.Event{Type: "tool_call", Session: "alpha", Payload: []byte(`{garbage`)})
-	if _, ok := eng.LastToolCallAt("alpha"); ok {
-		t.Fatalf("expected no tracking from malformed payload")
-	}
-}
-
-// TestHandleEvent_ToolCall_ZeroTSFallsBackToClock checks the
-// "ts.IsZero() → e.now()" branch of handleEvent.
-func TestHandleEvent_ToolCall_ZeroTSFallsBackToClock(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	eng := newEngineAt(&now, hub, newFakeQuota(), newFakeSessions("alpha"), Defaults())
-
-	// Payload with no "ts" field → recordToolCall must use e.now().
-	payload, _ := json.Marshal(map[string]any{"session": "alpha", "is_error": false})
-	eng.handleEvent(events.Event{Type: "tool_call", Session: "alpha", Payload: payload})
-
-	got, ok := eng.LastToolCallAt("alpha")
-	if !ok {
-		t.Fatalf("LastToolCallAt(alpha) = !ok")
-	}
-	if !got.Equal(now) {
-		t.Errorf("LastToolCallAt = %v, want fallback to now=%v", got, now)
-	}
-}
-
-// TestMarkYolo_IdempotentOnRepeatedEvents ensures that re-firing
-// on_yolo for the same session does NOT reset yoloAt — only the first
-// observation is recorded.
-func TestMarkYolo_IdempotentOnRepeatedEvents(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	s := newFakeSessions("alpha")
-	thr := Defaults()
-	thr.YoloUncheckedMinutes = 30
-	eng := newEngineAt(&now, hub, newFakeQuota(), s, thr)
-
-	s.setMode("alpha", "yolo")
-
-	// First event sets yoloAt at t=now.
-	eng.handleEvent(events.Event{Type: "on_yolo", Session: "alpha", Payload: []byte(`{}`)})
-
-	// Advance clock and fire on_yolo again — yoloAt must not bump forward,
-	// otherwise the threshold check below would not fire.
-	now = now.Add(20 * time.Minute)
-	eng.handleEvent(events.Event{Type: "on_yolo", Session: "alpha", Payload: []byte(`{}`)})
-
-	// Total elapsed = 31 min → G fires.
-	now = now.Add(11 * time.Minute)
-	eng.evaluateAll()
-	snap, ok := eng.Snapshot("alpha")
-	if !ok || snap.State != StateYoloUnchecked {
-		t.Fatalf("want yolo_unchecked (idempotent yoloAt), got %+v ok=%v", snap, ok)
-	}
-}
diff --git a/internal/serve/attention/engine_test.go b/internal/serve/attention/engine_test.go
deleted file mode 100644
index b752efc..0000000
--- a/internal/serve/attention/engine_test.go
+++ /dev/null
@@ -1,552 +0,0 @@
-package attention
-
-import (
-	"context"
-	"encoding/json"
-	"sync"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-// --- fakes ---------------------------------------------------------------
-
-type fakeQuota struct {
-	mu          sync.Mutex
-	weekly      float64
-	weeklyOK    bool
-	fiveHr      float64
-	fiveHrOK    bool
-	ctx         map[string]int
-}
-
-func newFakeQuota() *fakeQuota { return &fakeQuota{ctx: make(map[string]int)} }
-
-func (f *fakeQuota) WeeklyPct() (float64, bool) {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	return f.weekly, f.weeklyOK
-}
-
-func (f *fakeQuota) FiveHourPct() (float64, bool) {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	return f.fiveHr, f.fiveHrOK
-}
-
-func (f *fakeQuota) ContextPct(name string) (int, bool) {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	v, ok := f.ctx[name]
-	return v, ok
-}
-
-func (f *fakeQuota) setWeekly(p float64) {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	f.weekly = p
-	f.weeklyOK = true
-}
-
-func (f *fakeQuota) setFiveHr(p float64) {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	f.fiveHr = p
-	f.fiveHrOK = true
-}
-
-func (f *fakeQuota) setCtx(name string, p int) {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	f.ctx[name] = p
-}
-
-type fakeSessions struct {
-	mu    sync.Mutex
-	names []string
-	modes map[string]string
-	alive map[string]bool
-	cp    map[string]time.Time
-}
-
-func newFakeSessions(names ...string) *fakeSessions {
-	f := &fakeSessions{
-		names: append([]string{}, names...),
-		modes: make(map[string]string),
-		alive: make(map[string]bool),
-		cp:    make(map[string]time.Time),
-	}
-	for _, n := range names {
-		f.alive[n] = true
-	}
-	return f
-}
-
-func (f *fakeSessions) Names() []string {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	out := make([]string, len(f.names))
-	copy(out, f.names)
-	return out
-}
-
-func (f *fakeSessions) Mode(name string) string {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	return f.modes[name]
-}
-
-func (f *fakeSessions) TmuxAlive(name string) bool {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	return f.alive[name]
-}
-
-func (f *fakeSessions) LastCheckpointAt(name string) (time.Time, bool) {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	t, ok := f.cp[name]
-	return t, ok
-}
-
-func (f *fakeSessions) setMode(name, mode string) {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	f.modes[name] = mode
-}
-
-func (f *fakeSessions) setAlive(name string, alive bool) {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	f.alive[name] = alive
-}
-
-func (f *fakeSessions) setCheckpoint(name string, t time.Time) {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	f.cp[name] = t
-}
-
-// --- helpers --------------------------------------------------------------
-
-func mustPayload(t *testing.T, v any) json.RawMessage {
-	t.Helper()
-	b, err := json.Marshal(v)
-	if err != nil {
-		t.Fatalf("marshal: %v", err)
-	}
-	return b
-}
-
-func toolCallEv(t *testing.T, session string, isErr bool, ts time.Time) events.Event {
-	return events.Event{
-		Type:    "tool_call",
-		Session: session,
-		Payload: mustPayload(t, map[string]any{
-			"session":  session,
-			"is_error": isErr,
-			"ts":       ts,
-		}),
-	}
-}
-
-// newEngineAt returns an engine whose clock is controlled by a pointer.
-func newEngineAt(now *time.Time, hub *events.Hub, q QuotaSource, s SessionSource, thr Thresholds) *Engine {
-	return NewEngine(hub, q, s, thr, func() time.Time { return *now })
-}
-
-// --- trigger tests --------------------------------------------------------
-
-func TestTriggerA_LastErrorCall(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	q := newFakeQuota()
-	s := newFakeSessions("alpha")
-	eng := newEngineAt(&now, hub, q, s, Defaults())
-
-	eng.handleEvent(toolCallEv(t, "alpha", true, now))
-	eng.evaluateAll()
-
-	snap, ok := eng.Snapshot("alpha")
-	if !ok || snap.State != StateLastErrorCall {
-		t.Fatalf("want last_error_call, got %+v ok=%v", snap, ok)
-	}
-
-	// Next non-error tool call clears.
-	eng.handleEvent(toolCallEv(t, "alpha", false, now))
-	eng.evaluateAll()
-	if _, ok := eng.Snapshot("alpha"); ok {
-		t.Fatalf("expected clear after non-error call")
-	}
-}
-
-func TestTriggerB_ErrorBurst(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	q := newFakeQuota()
-	s := newFakeSessions("alpha")
-	thr := Defaults()
-	thr.ErrorRateWindow = 10
-	thr.ErrorRatePct = 30
-	eng := newEngineAt(&now, hub, q, s, thr)
-
-	// 10 calls, 4 errors (40%) — but ends on a non-error so A is clear.
-	pattern := []bool{true, false, true, false, true, false, true, false, false, false}
-	for _, isErr := range pattern {
-		eng.handleEvent(toolCallEv(t, "alpha", isErr, now))
-	}
-	eng.evaluateAll()
-
-	snap, ok := eng.Snapshot("alpha")
-	if !ok || snap.State != StateErrorBurst {
-		t.Fatalf("want error_burst, got %+v ok=%v", snap, ok)
-	}
-
-	// Drop error share by feeding 10 clean calls.
-	for i := 0; i < 10; i++ {
-		eng.handleEvent(toolCallEv(t, "alpha", false, now))
-	}
-	eng.evaluateAll()
-	if _, ok := eng.Snapshot("alpha"); ok {
-		t.Fatalf("expected clear after clean window")
-	}
-}
-
-func TestTriggerC_Stuck(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	q := newFakeQuota()
-	s := newFakeSessions("alpha")
-	thr := Defaults()
-	thr.IdleMinutes = 5
-	eng := newEngineAt(&now, hub, q, s, thr)
-
-	// Seed one call 6 minutes ago.
-	eng.handleEvent(toolCallEv(t, "alpha", false, now))
-	now = now.Add(6 * time.Minute)
-	eng.evaluateAll()
-
-	snap, ok := eng.Snapshot("alpha")
-	if !ok || snap.State != StateStuck {
-		t.Fatalf("want stuck, got %+v ok=%v", snap, ok)
-	}
-
-	// A new tool call resets the idle clock.
-	eng.handleEvent(toolCallEv(t, "alpha", false, now))
-	eng.evaluateAll()
-	if _, ok := eng.Snapshot("alpha"); ok {
-		t.Fatalf("expected clear after fresh tool_call")
-	}
-}
-
-// Regression: daemon restart replays old tool_calls from the hub ring
-// with timestamps > IdleMinutes ago. The previous implementation used
-// those timestamps directly and pre-tripped stuck on boot. Reference
-// now clamps to bootTime, so stuck can only fire on idle *since boot*.
-func TestTriggerC_Stuck_BootReplayDoesNotInstantlyFire(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	q := newFakeQuota()
-	s := newFakeSessions("alpha")
-	thr := Defaults()
-	thr.IdleMinutes = 5
-	eng := newEngineAt(&now, hub, q, s, thr)
-
-	// Simulate ring replay: a tool_call from 10 minutes before boot.
-	ancient := now.Add(-10 * time.Minute)
-	eng.handleEvent(toolCallEv(t, "alpha", false, ancient))
-
-	// Immediately at boot: must NOT fire stuck.
-	eng.evaluateAll()
-	if snap, ok := eng.Snapshot("alpha"); ok {
-		t.Fatalf("stuck fired on replay at boot; snap=%+v", snap)
-	}
-
-	// 4 min after boot: still under the 5 min threshold since boot.
-	now = now.Add(4 * time.Minute)
-	eng.evaluateAll()
-	if snap, ok := eng.Snapshot("alpha"); ok {
-		t.Fatalf("stuck fired at 4 min since boot; snap=%+v", snap)
-	}
-
-	// 6 min since boot with no fresh tool_call → stuck fires now.
-	now = now.Add(2 * time.Minute)
-	eng.evaluateAll()
-	if snap, ok := eng.Snapshot("alpha"); !ok || snap.State != StateStuck {
-		t.Fatalf("want stuck at 6 min since boot; got %+v ok=%v", snap, ok)
-	}
-}
-
-func TestTriggerD_TmuxDead(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	q := newFakeQuota()
-	s := newFakeSessions("alpha")
-	eng := newEngineAt(&now, hub, q, s, Defaults())
-
-	s.setAlive("alpha", false)
-	eng.evaluateAll()
-
-	snap, ok := eng.Snapshot("alpha")
-	if !ok || snap.State != StateTmuxDead {
-		t.Fatalf("want tmux_dead, got %+v ok=%v", snap, ok)
-	}
-
-	// Revived (e.g. tmux session restarted) → clears.
-	s.setAlive("alpha", true)
-	eng.evaluateAll()
-	if _, ok := eng.Snapshot("alpha"); ok {
-		t.Fatalf("expected clear after tmux revived")
-	}
-}
-
-func TestTriggerE_QuotaHigh(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	q := newFakeQuota()
-	s := newFakeSessions("alpha")
-	thr := Defaults()
-	thr.QuotaPct = 85
-	eng := newEngineAt(&now, hub, q, s, thr)
-
-	q.setWeekly(90)
-	q.setFiveHr(10)
-	eng.evaluateAll()
-
-	snap, ok := eng.Snapshot("alpha")
-	if !ok || snap.State != StateQuotaHigh {
-		t.Fatalf("want quota_high (weekly), got %+v ok=%v", snap, ok)
-	}
-
-	// Drop weekly; 5h still low → clears.
-	q.setWeekly(50)
-	eng.evaluateAll()
-	if _, ok := eng.Snapshot("alpha"); ok {
-		t.Fatalf("expected clear after quota drop")
-	}
-
-	// 5-hour-side also fires independently.
-	q.setFiveHr(86)
-	eng.evaluateAll()
-	if snap, ok := eng.Snapshot("alpha"); !ok || snap.State != StateQuotaHigh {
-		t.Fatalf("want quota_high (5h), got %+v ok=%v", snap, ok)
-	}
-}
-
-func TestTriggerF_ContextImminent(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	q := newFakeQuota()
-	s := newFakeSessions("alpha")
-	thr := Defaults()
-	thr.ContextPct = 90
-	eng := newEngineAt(&now, hub, q, s, thr)
-
-	q.setCtx("alpha", 92)
-	eng.evaluateAll()
-
-	snap, ok := eng.Snapshot("alpha")
-	if !ok || snap.State != StateContextImminent {
-		t.Fatalf("want context_imminent, got %+v ok=%v", snap, ok)
-	}
-
-	q.setCtx("alpha", 50)
-	eng.evaluateAll()
-	if _, ok := eng.Snapshot("alpha"); ok {
-		t.Fatalf("expected clear after context drop")
-	}
-}
-
-func TestTriggerG_YoloUnchecked(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	q := newFakeQuota()
-	s := newFakeSessions("alpha")
-	thr := Defaults()
-	thr.YoloUncheckedMinutes = 30
-	eng := newEngineAt(&now, hub, q, s, thr)
-
-	s.setMode("alpha", "yolo")
-	// Initial tick: yoloAt recorded; not tripped (brand new).
-	eng.evaluateAll()
-	if _, ok := eng.Snapshot("alpha"); ok {
-		t.Fatalf("G must not trip on fresh yolo")
-	}
-
-	// 31 minutes later, still no checkpoint → fires.
-	now = now.Add(31 * time.Minute)
-	eng.evaluateAll()
-	snap, ok := eng.Snapshot("alpha")
-	if !ok || snap.State != StateYoloUnchecked {
-		t.Fatalf("want yolo_unchecked, got %+v ok=%v", snap, ok)
-	}
-
-	// Recent checkpoint clears.
-	s.setCheckpoint("alpha", now.Add(-1*time.Minute))
-	eng.evaluateAll()
-	if _, ok := eng.Snapshot("alpha"); ok {
-		t.Fatalf("expected clear after fresh checkpoint")
-	}
-}
-
-func TestPrecedence_TmuxDeadBeatsErrorBurst(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	q := newFakeQuota()
-	s := newFakeSessions("alpha")
-	thr := Defaults()
-	thr.ErrorRateWindow = 4
-	thr.ErrorRatePct = 50
-	eng := newEngineAt(&now, hub, q, s, thr)
-
-	// Fill window with errors (A fires) and kill tmux — D wins.
-	for i := 0; i < 4; i++ {
-		eng.handleEvent(toolCallEv(t, "alpha", true, now))
-	}
-	s.setAlive("alpha", false)
-	eng.evaluateAll()
-
-	snap, ok := eng.Snapshot("alpha")
-	if !ok || snap.State != StateTmuxDead {
-		t.Fatalf("want tmux_dead, got %+v ok=%v", snap, ok)
-	}
-}
-
-func TestSnapshotFalseWhenClear(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	eng := newEngineAt(&now, hub, newFakeQuota(), newFakeSessions("alpha"), Defaults())
-
-	if snap, ok := eng.Snapshot("alpha"); ok {
-		t.Fatalf("want (zero, false), got (%+v, %v)", snap, ok)
-	}
-	// Untracked names also return false.
-	if _, ok := eng.Snapshot("ghost"); ok {
-		t.Fatalf("want false for unknown session")
-	}
-}
-
-func TestPublishesRaisedAndCleared(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	sub, _ := hub.Subscribe("", "")
-	defer sub.Close()
-
-	q := newFakeQuota()
-	s := newFakeSessions("alpha")
-	eng := newEngineAt(&now, hub, q, s, Defaults())
-
-	// Raise via trigger A.
-	eng.handleEvent(toolCallEv(t, "alpha", true, now))
-	eng.evaluateAll()
-
-	raised := waitForType(t, sub, "attention_raised", 1*time.Second)
-	if raised.Session != "alpha" {
-		t.Fatalf("raised session = %q", raised.Session)
-	}
-	var rp raisedPayload
-	if err := json.Unmarshal(raised.Payload, &rp); err != nil {
-		t.Fatalf("unmarshal: %v", err)
-	}
-	if rp.State != StateLastErrorCall {
-		t.Fatalf("raised state = %q", rp.State)
-	}
-
-	// Clear via non-error call.
-	eng.handleEvent(toolCallEv(t, "alpha", false, now))
-	eng.evaluateAll()
-	cleared := waitForType(t, sub, "attention_cleared", 1*time.Second)
-	if cleared.Session != "alpha" {
-		t.Fatalf("cleared session = %q", cleared.Session)
-	}
-}
-
-func TestIdempotentNoRepublish(t *testing.T) {
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(50)
-	sub, _ := hub.Subscribe("", "")
-	defer sub.Close()
-
-	q := newFakeQuota()
-	s := newFakeSessions("alpha")
-	eng := newEngineAt(&now, hub, q, s, Defaults())
-
-	q.setWeekly(95)
-	eng.evaluateAll()
-	_ = waitForType(t, sub, "attention_raised", 1*time.Second)
-
-	// Same state should not re-publish.
-	eng.evaluateAll()
-	eng.evaluateAll()
-	select {
-	case ev := <-sub.Events():
-		t.Fatalf("unexpected republish: %+v", ev)
-	case <-time.After(50 * time.Millisecond):
-	}
-}
-
-func TestRunReturnsOnContextCancel(t *testing.T) {
-	hub := events.NewHub(50)
-	eng := NewEngine(hub, newFakeQuota(), newFakeSessions("alpha"), Defaults(), nil)
-
-	ctx, cancel := context.WithCancel(context.Background())
-	done := make(chan error, 1)
-	go func() { done <- eng.Run(ctx) }()
-
-	cancel()
-	select {
-	case err := <-done:
-		if err != nil {
-			t.Fatalf("Run returned error: %v", err)
-		}
-	case <-time.After(50 * time.Millisecond):
-		t.Fatal("Run did not return within 50ms of ctx cancel")
-	}
-}
-
-func TestDropSafe_StateStillUpdates(t *testing.T) {
-	// A slow subscriber gets events dropped by the hub; engine state
-	// must still reflect the alert because evaluation mutates state
-	// BEFORE publish, and publish is fire-and-forget.
-	now := time.Unix(1_700_000_000, 0).UTC()
-	hub := events.NewHub(2) // tiny ring — easy to overflow sub channel
-	// Subscribe but never read: force drops.
-	sub, _ := hub.Subscribe("", "")
-	defer sub.Close()
-
-	q := newFakeQuota()
-	s := newFakeSessions("alpha")
-	eng := newEngineAt(&now, hub, q, s, Defaults())
-
-	// Push many alerts without draining sub.
-	for i := 0; i < 500; i++ {
-		eng.handleEvent(toolCallEv(t, "alpha", true, now))
-		eng.evaluateAll()
-	}
-
-	snap, ok := eng.Snapshot("alpha")
-	if !ok || snap.State != StateLastErrorCall {
-		t.Fatalf("state lost under drop pressure: %+v ok=%v", snap, ok)
-	}
-}
-
-// waitForType drains sub until a matching event arrives or timeout expires.
-// Ignores synthetic hub-replay entries that don't match.
-func waitForType(t *testing.T, sub *events.Sub, typ string, timeout time.Duration) events.Event {
-	t.Helper()
-	deadline := time.After(timeout)
-	for {
-		select {
-		case ev, ok := <-sub.Events():
-			if !ok {
-				t.Fatalf("sub closed waiting for %q", typ)
-			}
-			if ev.Type == typ {
-				return ev
-			}
-		case <-deadline:
-			t.Fatalf("timeout waiting for %q", typ)
-		}
-	}
-}
diff --git a/internal/serve/attention/format.go b/internal/serve/attention/format.go
deleted file mode 100644
index ee15c9f..0000000
--- a/internal/serve/attention/format.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package attention
-
-import (
-	"fmt"
-	"math"
-)
-
-func formatErrorBurst(errors, total int) string {
-	return fmt.Sprintf("%d errors in last %d calls", errors, total)
-}
-
-func formatQuota(weekly float64, hasWeekly bool, fiveHr float64, hasFive bool) string {
-	switch {
-	case hasWeekly && hasFive:
-		return fmt.Sprintf("weekly %d%% / 5h %d%%", roundPct(weekly), roundPct(fiveHr))
-	case hasWeekly:
-		return fmt.Sprintf("weekly %d%%", roundPct(weekly))
-	case hasFive:
-		return fmt.Sprintf("5h %d%%", roundPct(fiveHr))
-	}
-	return "quota threshold reached"
-}
-
-func formatCtx(pct int) string {
-	return fmt.Sprintf("context window %d%%", pct)
-}
-
-func roundPct(p float64) int {
-	return int(math.Round(p))
-}
diff --git a/internal/serve/auth/context.go b/internal/serve/auth/context.go
deleted file mode 100644
index 6706097..0000000
--- a/internal/serve/auth/context.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package auth
-
-import "context"
-
-type ctxKey struct{}
-
-// WithUser returns a context carrying the authenticated username.
-func WithUser(ctx context.Context, user string) context.Context {
-	return context.WithValue(ctx, ctxKey{}, user)
-}
-
-// UserFrom returns the authenticated username from ctx, or "" if
-// there is none.
-func UserFrom(ctx context.Context) string {
-	s, _ := ctx.Value(ctxKey{}).(string)
-	return s
-}
diff --git a/internal/serve/auth/context_test.go b/internal/serve/auth/context_test.go
deleted file mode 100644
index b55002d..0000000
--- a/internal/serve/auth/context_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package auth
-
-import (
-	"context"
-	"testing"
-)
-
-func TestUserFrom_EmptyContext(t *testing.T) {
-	if got := UserFrom(context.Background()); got != "" {
-		t.Errorf("UserFrom(empty) = %q, want empty string", got)
-	}
-}
-
-func TestWithUser_RoundTrip(t *testing.T) {
-	ctx := WithUser(context.Background(), "alice@example.com")
-	if got := UserFrom(ctx); got != "alice@example.com" {
-		t.Errorf("UserFrom = %q, want alice@example.com", got)
-	}
-}
-
-func TestWithUser_OverwritesPrevious(t *testing.T) {
-	ctx := WithUser(context.Background(), "first")
-	ctx = WithUser(ctx, "second")
-	if got := UserFrom(ctx); got != "second" {
-		t.Errorf("UserFrom = %q, want second", got)
-	}
-}
-
-func TestUserFrom_ForeignKeyValueIgnored(t *testing.T) {
-	type otherKey struct{}
-	ctx := context.WithValue(context.Background(), otherKey{}, "shadow")
-	if got := UserFrom(ctx); got != "" {
-		t.Errorf("UserFrom(foreign key) = %q, want empty", got)
-	}
-}
-
-func TestWithUser_PreservesParentValues(t *testing.T) {
-	type parentKey struct{}
-	parent := context.WithValue(context.Background(), parentKey{}, "kept")
-	ctx := WithUser(parent, "bob")
-
-	if got := UserFrom(ctx); got != "bob" {
-		t.Errorf("UserFrom = %q, want bob", got)
-	}
-	if got, _ := ctx.Value(parentKey{}).(string); got != "kept" {
-		t.Errorf("parent value = %q, want kept", got)
-	}
-}
diff --git a/internal/serve/auth/middleware.go b/internal/serve/auth/middleware.go
deleted file mode 100644
index 250e3ee..0000000
--- a/internal/serve/auth/middleware.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package auth
-
-import (
-	"crypto/subtle"
-	"log/slog"
-	"net/http"
-	"strings"
-)
-
-// bearerPrefix is matched case-insensitively per RFC 7235 §2.1.
-const bearerPrefix = "bearer "
-
-// Required returns an HTTP middleware that enforces a static bearer
-// token. Requests without an `Authorization: Bearer ` header
-// matching the expected token (constant-time compared) are rejected
-// with 401 + a small JSON body and the standard `WWW-Authenticate`
-// challenge header.
-//
-// An empty `token` argument is a programming error (Required is wired
-// at server boot, not per-request) and panics rather than silently
-// disabling auth.
-func Required(token string, next http.Handler) http.Handler {
-	if token == "" {
-		panic("auth.Required: empty token (auth would be disabled)")
-	}
-	expected := []byte(token)
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		header := r.Header.Get("Authorization")
-		if header == "" {
-			slog.Warn("auth deny: no Authorization header",
-				"path", r.URL.Path, "remote", r.RemoteAddr,
-				"origin", r.Header.Get("Origin"), "referer", r.Header.Get("Referer"))
-			deny(w)
-			return
-		}
-		if len(header) < len(bearerPrefix) || !strings.EqualFold(header[:len(bearerPrefix)], bearerPrefix) {
-			slog.Warn("auth deny: malformed Authorization scheme",
-				"path", r.URL.Path, "scheme_prefix", safePrefix(header, 8))
-			deny(w)
-			return
-		}
-		got := strings.TrimSpace(header[len(bearerPrefix):])
-		if subtle.ConstantTimeCompare([]byte(got), expected) != 1 {
-			slog.Warn("auth deny: token mismatch",
-				"path", r.URL.Path,
-				"got_len", len(got), "want_len", len(expected),
-				"got_fp", fingerprint(got), "want_fp", fingerprint(string(expected)))
-			deny(w)
-			return
-		}
-		next.ServeHTTP(w, r)
-	})
-}
-
-// fingerprint returns the first 4 + last 4 chars of a token joined by "…".
-// Safe to log: with 43 url-safe-b64 chars (~256 bits of entropy), exposing 8
-// chars reveals <50 bits and still lets us compare "is this the same token"
-// across log lines / between localStorage and disk.
-func fingerprint(s string) string {
-	if len(s) <= 9 {
-		return "(short)"
-	}
-	return s[:4] + "…" + s[len(s)-4:]
-}
-
-// safePrefix returns up to n chars of s; used to log the scheme portion of
-// a malformed Authorization header without risking secret exposure.
-func safePrefix(s string, n int) string {
-	if len(s) < n {
-		return s
-	}
-	return s[:n]
-}
-
-func deny(w http.ResponseWriter) {
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("Cache-Control", "no-store")
-	w.Header().Set("WWW-Authenticate", `Bearer realm="ctm-serve"`)
-	w.WriteHeader(http.StatusUnauthorized)
-	_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
-}
diff --git a/internal/serve/auth/middleware_test.go b/internal/serve/auth/middleware_test.go
deleted file mode 100644
index dfac09e..0000000
--- a/internal/serve/auth/middleware_test.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package auth
-
-import (
-	"io"
-	"net/http"
-	"net/http/httptest"
-	"strings"
-	"testing"
-)
-
-func okHandler(called *bool) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		*called = true
-		w.WriteHeader(http.StatusOK)
-		_, _ = w.Write([]byte("ok"))
-	})
-}
-
-func TestRequired_PanicsOnEmptyToken(t *testing.T) {
-	defer func() {
-		if r := recover(); r == nil {
-			t.Fatal("expected panic for empty token, got none")
-		}
-	}()
-	_ = Required("", http.NotFoundHandler())
-}
-
-func TestRequired_TableDriven(t *testing.T) {
-	const tok = "secret-token-abc"
-
-	cases := []struct {
-		name       string
-		header     string
-		wantStatus int
-		wantNext   bool
-	}{
-		{name: "missing header", header: "", wantStatus: http.StatusUnauthorized, wantNext: false},
-		{name: "wrong scheme", header: "Basic Zm9vOmJhcg==", wantStatus: http.StatusUnauthorized, wantNext: false},
-		{name: "wrong token", header: "Bearer not-the-token", wantStatus: http.StatusUnauthorized, wantNext: false},
-		{name: "empty bearer value", header: "Bearer ", wantStatus: http.StatusUnauthorized, wantNext: false},
-		{name: "case-insensitive scheme", header: "bEaReR " + tok, wantStatus: http.StatusOK, wantNext: true},
-		{name: "trims whitespace after scheme", header: "Bearer   " + tok + "  ", wantStatus: http.StatusOK, wantNext: true},
-		{name: "happy path", header: "Bearer " + tok, wantStatus: http.StatusOK, wantNext: true},
-	}
-
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			called := false
-			h := Required(tok, okHandler(&called))
-
-			req := httptest.NewRequest(http.MethodGet, "/anything", nil)
-			if tc.header != "" {
-				req.Header.Set("Authorization", tc.header)
-			}
-			rec := httptest.NewRecorder()
-			h.ServeHTTP(rec, req)
-
-			if rec.Code != tc.wantStatus {
-				t.Errorf("status = %d, want %d", rec.Code, tc.wantStatus)
-			}
-			if called != tc.wantNext {
-				t.Errorf("next invoked = %v, want %v", called, tc.wantNext)
-			}
-			if !tc.wantNext {
-				// 401 invariants
-				if got := rec.Header().Get("WWW-Authenticate"); got != `Bearer realm="ctm-serve"` {
-					t.Errorf("WWW-Authenticate = %q", got)
-				}
-				if got := rec.Header().Get("Content-Type"); got != "application/json" {
-					t.Errorf("Content-Type = %q", got)
-				}
-				if got := rec.Header().Get("Cache-Control"); got != "no-store" {
-					t.Errorf("Cache-Control = %q", got)
-				}
-				body, _ := io.ReadAll(rec.Body)
-				if !strings.Contains(string(body), `"unauthorized"`) {
-					t.Errorf("body = %q", body)
-				}
-			}
-		})
-	}
-}
diff --git a/internal/serve/auth/password.go b/internal/serve/auth/password.go
deleted file mode 100644
index e12b19a..0000000
--- a/internal/serve/auth/password.go
+++ /dev/null
@@ -1,84 +0,0 @@
-// Package auth owns the ctm serve password hashing, user credentials
-// file, and in-memory session store (V27 single-user auth).
-package auth
-
-import (
-	"crypto/rand"
-	"crypto/subtle"
-	"encoding/base64"
-	"fmt"
-
-	"golang.org/x/crypto/argon2"
-)
-
-// Params holds the argon2id cost parameters. Defaults follow
-// current OWASP guidance for modest-power servers.
-type Params struct {
-	M       uint32 `json:"m"`
-	T       uint32 `json:"t"`
-	P       uint8  `json:"p"`
-	SaltLen uint32 `json:"salt_len"`
-	HashLen uint32 `json:"hash_len"`
-}
-
-// DefaultParams is the canonical set of argon2id params used by Hash.
-// Stored inside Encoded so a future bump does not invalidate old hashes.
-var DefaultParams = Params{
-	M:       64 * 1024, // 64 MiB
-	T:       3,
-	P:       2,
-	SaltLen: 16,
-	HashLen: 32,
-}
-
-// Encoded is the on-disk representation of a hashed password.
-// Everything needed to verify a password against this hash is
-// contained here; no external key material is required.
-type Encoded struct {
-	Algo    string `json:"algo"`
-	Params  Params `json:"params"`
-	SaltB64 string `json:"salt_b64"`
-	HashB64 string `json:"hash_b64"`
-}
-
-// Hash returns an Encoded value derived from password using
-// DefaultParams and a fresh random salt. Each call produces a
-// different salt — hashing the same password twice yields two
-// distinct Encoded values.
-func Hash(password string) (Encoded, error) {
-	p := DefaultParams
-	salt := make([]byte, p.SaltLen)
-	if _, err := rand.Read(salt); err != nil {
-		return Encoded{}, fmt.Errorf("auth: rand: %w", err)
-	}
-	h := argon2.IDKey([]byte(password), salt, p.T, p.M, p.P, p.HashLen)
-	return Encoded{
-		Algo:    "argon2id",
-		Params:  p,
-		SaltB64: base64.StdEncoding.EncodeToString(salt),
-		HashB64: base64.StdEncoding.EncodeToString(h),
-	}, nil
-}
-
-// Verify returns true iff password matches enc. Runs in constant
-// time with respect to the hash comparison; salt/base64 decode
-// errors are treated as a non-match (no panic). An empty password
-// always returns false.
-func Verify(enc Encoded, password string) bool {
-	if password == "" {
-		return false
-	}
-	if enc.Algo != "argon2id" || enc.Params.M == 0 {
-		return false
-	}
-	salt, err := base64.StdEncoding.DecodeString(enc.SaltB64)
-	if err != nil {
-		return false
-	}
-	want, err := base64.StdEncoding.DecodeString(enc.HashB64)
-	if err != nil {
-		return false
-	}
-	got := argon2.IDKey([]byte(password), salt, enc.Params.T, enc.Params.M, enc.Params.P, enc.Params.HashLen)
-	return subtle.ConstantTimeCompare(got, want) == 1
-}
diff --git a/internal/serve/auth/password_test.go b/internal/serve/auth/password_test.go
deleted file mode 100644
index 8b18a08..0000000
--- a/internal/serve/auth/password_test.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package auth
-
-import (
-	"strings"
-	"testing"
-)
-
-func TestHash_VerifyRoundTrip(t *testing.T) {
-	enc, err := Hash("correct horse battery staple")
-	if err != nil {
-		t.Fatalf("Hash: %v", err)
-	}
-	if enc.Algo != "argon2id" {
-		t.Fatalf("algo = %q, want argon2id", enc.Algo)
-	}
-	if !Verify(enc, "correct horse battery staple") {
-		t.Fatal("Verify returned false for correct password")
-	}
-	if Verify(enc, "wrong password") {
-		t.Fatal("Verify returned true for wrong password")
-	}
-}
-
-func TestHash_Unique(t *testing.T) {
-	a, err := Hash("same")
-	if err != nil {
-		t.Fatal(err)
-	}
-	b, err := Hash("same")
-	if err != nil {
-		t.Fatal(err)
-	}
-	if a.SaltB64 == b.SaltB64 {
-		t.Fatal("two hashes of the same password share a salt")
-	}
-	if a.HashB64 == b.HashB64 {
-		t.Fatal("two hashes of the same password produce identical bytes")
-	}
-}
-
-func TestVerify_RejectsEmpty(t *testing.T) {
-	enc, err := Hash("real")
-	if err != nil {
-		t.Fatal(err)
-	}
-	if Verify(enc, "") {
-		t.Fatal("Verify accepted empty password")
-	}
-}
-
-func TestVerify_MalformedEncoded_ReturnsFalse(t *testing.T) {
-	// Empty struct should not crash, should return false.
-	if Verify(Encoded{}, "anything") {
-		t.Fatal("Verify on empty Encoded returned true")
-	}
-	bad := Encoded{Algo: "argon2id", SaltB64: "!!!not-base64!!!", HashB64: "aGVsbG8="}
-	if Verify(bad, "anything") {
-		t.Fatal("Verify on malformed salt returned true")
-	}
-}
-
-func TestHash_ContainsExpectedFields(t *testing.T) {
-	enc, err := Hash("x")
-	if err != nil {
-		t.Fatal(err)
-	}
-	if strings.TrimSpace(enc.SaltB64) == "" || strings.TrimSpace(enc.HashB64) == "" {
-		t.Fatal("Hash produced empty salt or hash")
-	}
-	if enc.Params.M == 0 || enc.Params.T == 0 || enc.Params.P == 0 {
-		t.Fatalf("Hash params zero: %+v", enc.Params)
-	}
-}
diff --git a/internal/serve/auth/ratelimit.go b/internal/serve/auth/ratelimit.go
deleted file mode 100644
index 14573e2..0000000
--- a/internal/serve/auth/ratelimit.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package auth
-
-// Per-IP sliding-window rate limiter for /api/auth/login.
-// Rationale: argon2id is deliberately CPU-heavy, so unbounded login
-// attempts are a DoS vector. Lazy eviction keeps memory bounded to
-// active IPs; successful logins should Reset() to avoid locking out
-// legitimate users after typos.
-
-import (
-	"sync"
-	"time"
-)
-
-// Limiter tracks recent timestamps per IP and allows at most max
-// attempts within window. Safe for concurrent use.
-type Limiter struct {
-	max    int
-	window time.Duration
-	now    func() time.Time
-
-	mu   sync.Mutex
-	hits map[string][]time.Time
-}
-
-// NewLimiter returns a Limiter using the wall clock.
-func NewLimiter(max int, window time.Duration) *Limiter {
-	return NewLimiterWithClock(max, window, time.Now)
-}
-
-// NewLimiterWithClock returns a Limiter with an injectable clock.
-// The clock must be monotonic-ish within the window (tests inject
-// a closure that advances deterministically).
-func NewLimiterWithClock(max int, window time.Duration, now func() time.Time) *Limiter {
-	if now == nil {
-		now = time.Now
-	}
-	return &Limiter{
-		max:    max,
-		window: window,
-		now:    now,
-		hits:   make(map[string][]time.Time),
-	}
-}
-
-// Allow records an attempt for ip and reports whether it is within
-// budget. If denied, retryAfter is the duration until the oldest
-// in-window attempt ages out.
-func (l *Limiter) Allow(ip string) (bool, time.Duration) {
-	l.mu.Lock()
-	defer l.mu.Unlock()
-
-	now := l.now()
-	cutoff := now.Add(-l.window)
-
-	// Lazy-evict expired timestamps for this IP.
-	hits := l.hits[ip]
-	kept := hits[:0]
-	for _, t := range hits {
-		if t.After(cutoff) {
-			kept = append(kept, t)
-		}
-	}
-	if len(kept) >= l.max {
-		// Reject without recording; retry-after = time until oldest
-		// kept attempt leaves the window.
-		retry := kept[0].Add(l.window).Sub(now)
-		if retry < 0 {
-			retry = 0
-		}
-		l.hits[ip] = kept
-		return false, retry
-	}
-	kept = append(kept, now)
-	l.hits[ip] = kept
-	return true, 0
-}
-
-// Reset clears all recorded attempts for ip. Called after a
-// successful login to avoid locking out a legitimate user who
-// mistyped a few times first.
-func (l *Limiter) Reset(ip string) {
-	l.mu.Lock()
-	defer l.mu.Unlock()
-	delete(l.hits, ip)
-}
diff --git a/internal/serve/auth/ratelimit_test.go b/internal/serve/auth/ratelimit_test.go
deleted file mode 100644
index aeb1fcf..0000000
--- a/internal/serve/auth/ratelimit_test.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package auth_test
-
-import (
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/auth"
-)
-
-// newClock returns a clock closure and a setter so tests can advance it.
-func newClock(start time.Time) (func() time.Time, func(time.Duration)) {
-	now := start
-	return func() time.Time { return now }, func(d time.Duration) { now = now.Add(d) }
-}
-
-func TestLimiter_AllowsUpToMax(t *testing.T) {
-	clk, _ := newClock(time.Unix(1_700_000_000, 0))
-	lim := auth.NewLimiterWithClock(5, 60*time.Second, clk)
-	for i := 0; i < 5; i++ {
-		ok, ra := lim.Allow("1.2.3.4")
-		if !ok {
-			t.Fatalf("attempt %d denied, want allowed", i+1)
-		}
-		if ra != 0 {
-			t.Fatalf("attempt %d retryAfter = %v, want 0", i+1, ra)
-		}
-	}
-	ok, ra := lim.Allow("1.2.3.4")
-	if ok {
-		t.Fatalf("6th attempt allowed, want denied")
-	}
-	if ra <= 0 {
-		t.Fatalf("retryAfter = %v, want > 0", ra)
-	}
-}
-
-func TestLimiter_WindowRollOff(t *testing.T) {
-	clk, advance := newClock(time.Unix(1_700_000_000, 0))
-	lim := auth.NewLimiterWithClock(5, 60*time.Second, clk)
-	for i := 0; i < 5; i++ {
-		if ok, _ := lim.Allow("1.2.3.4"); !ok {
-			t.Fatalf("attempt %d denied", i+1)
-		}
-	}
-	// 6th within window: denied
-	if ok, _ := lim.Allow("1.2.3.4"); ok {
-		t.Fatal("6th within window allowed, want denied")
-	}
-	// Advance past window; all prior attempts should age out.
-	advance(61 * time.Second)
-	ok, ra := lim.Allow("1.2.3.4")
-	if !ok {
-		t.Fatalf("post-window attempt denied, retryAfter=%v", ra)
-	}
-}
-
-func TestLimiter_Reset(t *testing.T) {
-	clk, _ := newClock(time.Unix(1_700_000_000, 0))
-	lim := auth.NewLimiterWithClock(5, 60*time.Second, clk)
-	for i := 0; i < 5; i++ {
-		lim.Allow("1.2.3.4")
-	}
-	if ok, _ := lim.Allow("1.2.3.4"); ok {
-		t.Fatal("6th attempt allowed before reset")
-	}
-	lim.Reset("1.2.3.4")
-	for i := 0; i < 5; i++ {
-		if ok, _ := lim.Allow("1.2.3.4"); !ok {
-			t.Fatalf("attempt %d after reset denied", i+1)
-		}
-	}
-}
-
-func TestLimiter_IndependentIPs(t *testing.T) {
-	clk, _ := newClock(time.Unix(1_700_000_000, 0))
-	lim := auth.NewLimiterWithClock(5, 60*time.Second, clk)
-	for i := 0; i < 5; i++ {
-		lim.Allow("1.1.1.1")
-	}
-	if ok, _ := lim.Allow("1.1.1.1"); ok {
-		t.Fatal("1.1.1.1 6th allowed")
-	}
-	// Different IP still has full budget.
-	for i := 0; i < 5; i++ {
-		if ok, _ := lim.Allow("2.2.2.2"); !ok {
-			t.Fatalf("2.2.2.2 attempt %d denied", i+1)
-		}
-	}
-}
-
-func TestLimiter_DefaultConstructorUsesWallClock(t *testing.T) {
-	lim := auth.NewLimiter(2, time.Minute)
-	if ok, _ := lim.Allow("x"); !ok {
-		t.Fatal("first allow denied")
-	}
-	if ok, _ := lim.Allow("x"); !ok {
-		t.Fatal("second allow denied")
-	}
-	if ok, _ := lim.Allow("x"); ok {
-		t.Fatal("third allow should be denied")
-	}
-}
diff --git a/internal/serve/auth/sessions.go b/internal/serve/auth/sessions.go
deleted file mode 100644
index 8cf307d..0000000
--- a/internal/serve/auth/sessions.go
+++ /dev/null
@@ -1,160 +0,0 @@
-package auth
-
-import (
-	"crypto/rand"
-	"encoding/base64"
-	"fmt"
-	"os"
-	"sync"
-	"sync/atomic"
-	"time"
-)
-
-// DefaultSessionTTL bounds how long an in-memory session token remains
-// valid. Caps the blast radius of a leaked token on a shared machine.
-const DefaultSessionTTL = 30 * 24 * time.Hour
-
-// sessionEntry is the internal value type for the sessions map.
-type sessionEntry struct {
-	username  string
-	createdAt time.Time
-}
-
-// Store is a goroutine-safe in-memory map of session tokens to
-// usernames. The zero value is unusable; callers must use NewStore.
-// Single-user assumption: the map contains 0..N entries for a
-// single username (one per device).
-type Store struct {
-	mu      sync.RWMutex
-	entries map[string]sessionEntry
-
-	ttl time.Duration
-	now func() time.Time
-
-	staleWindow time.Duration
-	lastCheck   atomic.Int64
-	userPresent atomic.Bool
-	everPresent atomic.Bool // true once we've seen user.json exist
-}
-
-// NewStore constructs an empty session store with the default TTL.
-func NewStore() *Store {
-	return NewStoreWithTTL(DefaultSessionTTL)
-}
-
-// NewStoreWithTTL constructs an empty session store with a custom TTL.
-// Exposed for tests; production code should use NewStore.
-func NewStoreWithTTL(ttl time.Duration) *Store {
-	return &Store{
-		entries:     make(map[string]sessionEntry),
-		ttl:         ttl,
-		now:         time.Now,
-		staleWindow: time.Second,
-	}
-}
-
-// Create issues a new random 32-byte session token for username and
-// returns it. Token format: base64.URL-encoded.
-func (s *Store) Create(username string) (string, error) {
-	raw := make([]byte, 32)
-	if _, err := rand.Read(raw); err != nil {
-		return "", fmt.Errorf("auth: rand: %w", err)
-	}
-	tok := base64.RawURLEncoding.EncodeToString(raw)
-	s.mu.Lock()
-	s.entries[tok] = sessionEntry{username: username, createdAt: s.now()}
-	s.mu.Unlock()
-	return tok, nil
-}
-
-// Lookup returns the username bound to tok, or ("", false) if the
-// token is unknown. If user.json has been deleted since last check,
-// the entire store is wiped before reporting false. Expired entries
-// (older than the store's TTL) are evicted lazily and reported as
-// not-found.
-func (s *Store) Lookup(tok string) (string, bool) {
-	if s.userFileGone() {
-		s.Wipe()
-		return "", false
-	}
-	s.mu.RLock()
-	entry, ok := s.entries[tok]
-	s.mu.RUnlock()
-	if !ok {
-		return "", false
-	}
-	if s.now().Sub(entry.createdAt) > s.ttl {
-		s.mu.Lock()
-		// Re-check under write lock to avoid racing a concurrent refresh
-		// (Revoke/Wipe/Create) that may have touched this key.
-		if cur, stillThere := s.entries[tok]; stillThere && s.now().Sub(cur.createdAt) > s.ttl {
-			delete(s.entries, tok)
-		}
-		s.mu.Unlock()
-		return "", false
-	}
-	return entry.username, true
-}
-
-// Revoke removes the given token. No-op if it doesn't exist.
-func (s *Store) Revoke(tok string) {
-	s.mu.Lock()
-	delete(s.entries, tok)
-	s.mu.Unlock()
-}
-
-// Wipe drops every session.
-func (s *Store) Wipe() {
-	s.mu.Lock()
-	s.entries = make(map[string]sessionEntry)
-	s.mu.Unlock()
-}
-
-// Seed inserts a pre-known token → username mapping. Intended only for
-// test seams where the caller injects a fixed token via Options.Token.
-func (s *Store) Seed(token, username string) {
-	s.mu.Lock()
-	s.entries[token] = sessionEntry{username: username, createdAt: s.now()}
-	s.mu.Unlock()
-}
-
-// SetStaleWindowForTest lets tests force an immediate restat.
-func (s *Store) SetStaleWindowForTest(d time.Duration) {
-	s.staleWindow = d
-	s.lastCheck.Store(0)
-}
-
-// SetClockForTest injects a fake clock for TTL tests. Not safe for
-// concurrent use with live traffic; tests only.
-func (s *Store) SetClockForTest(now func() time.Time) {
-	s.mu.Lock()
-	s.now = now
-	s.mu.Unlock()
-}
-
-// EntryCountForTest returns the number of map entries. Test-only
-// accessor used to assert lazy eviction.
-func (s *Store) EntryCountForTest() int {
-	s.mu.RLock()
-	n := len(s.entries)
-	s.mu.RUnlock()
-	return n
-}
-
-func (s *Store) userFileGone() bool {
-	now := time.Now().UnixNano()
-	last := s.lastCheck.Load()
-	if last != 0 && time.Duration(now-last) < s.staleWindow {
-		// Only "gone" if we previously saw it present and it's now absent.
-		return s.everPresent.Load() && !s.userPresent.Load()
-	}
-	_, err := os.Stat(UserPath())
-	present := err == nil
-	if present {
-		s.everPresent.Store(true)
-	}
-	s.userPresent.Store(present)
-	s.lastCheck.Store(now)
-	// Only "gone" if we previously saw it present and it's now absent.
-	return s.everPresent.Load() && !present
-}
diff --git a/internal/serve/auth/sessions_test.go b/internal/serve/auth/sessions_test.go
deleted file mode 100644
index 2cf27ed..0000000
--- a/internal/serve/auth/sessions_test.go
+++ /dev/null
@@ -1,165 +0,0 @@
-package auth_test
-
-import (
-	"os"
-	"path/filepath"
-	"sync"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/auth"
-)
-
-func TestStore_CreateLookup(t *testing.T) {
-	withTempHome(t)
-	s := auth.NewStore()
-	token, err := s.Create("alice")
-	if err != nil {
-		t.Fatal(err)
-	}
-	if token == "" {
-		t.Fatal("empty token")
-	}
-	user, ok := s.Lookup(token)
-	if !ok || user != "alice" {
-		t.Fatalf("Lookup = (%q, %v), want (\"alice\", true)", user, ok)
-	}
-}
-
-func TestStore_LookupUnknown(t *testing.T) {
-	withTempHome(t)
-	s := auth.NewStore()
-	if _, ok := s.Lookup("nope"); ok {
-		t.Fatal("Lookup of unknown token returned ok=true")
-	}
-}
-
-func TestStore_Revoke(t *testing.T) {
-	withTempHome(t)
-	s := auth.NewStore()
-	tok, _ := s.Create("alice")
-	s.Revoke(tok)
-	if _, ok := s.Lookup(tok); ok {
-		t.Fatal("Lookup after Revoke returned ok=true")
-	}
-}
-
-func TestStore_Wipe(t *testing.T) {
-	withTempHome(t)
-	s := auth.NewStore()
-	t1, _ := s.Create("alice")
-	t2, _ := s.Create("alice")
-	s.Wipe()
-	if _, ok := s.Lookup(t1); ok {
-		t.Fatal("token t1 still present after Wipe")
-	}
-	if _, ok := s.Lookup(t2); ok {
-		t.Fatal("token t2 still present after Wipe")
-	}
-}
-
-func TestStore_WipesWhenUserFileGone(t *testing.T) {
-	home := withTempHome(t)
-	if err := os.MkdirAll(filepath.Join(home, ".config", "ctm"), 0o700); err != nil {
-		t.Fatal(err)
-	}
-	if err := os.WriteFile(filepath.Join(home, ".config", "ctm", "user.json"), []byte("{}"), 0o600); err != nil {
-		t.Fatal(err)
-	}
-	s := auth.NewStore()
-	tok, _ := s.Create("alice")
-	s.SetStaleWindowForTest(0)
-	if _, ok := s.Lookup(tok); !ok {
-		t.Fatal("unexpected: lookup failed with file present")
-	}
-	_ = os.Remove(filepath.Join(home, ".config", "ctm", "user.json"))
-	if _, ok := s.Lookup(tok); ok {
-		t.Fatal("Lookup succeeded after user.json deleted")
-	}
-}
-
-func TestLookup_ExpiredReturnsFalse(t *testing.T) {
-	withTempHome(t)
-	s := auth.NewStoreWithTTL(time.Second)
-	base := time.Unix(1_700_000_000, 0)
-	s.SetClockForTest(func() time.Time { return base })
-	tok, err := s.Create("alice")
-	if err != nil {
-		t.Fatal(err)
-	}
-	// Advance past the TTL.
-	s.SetClockForTest(func() time.Time { return base.Add(2 * time.Second) })
-	if user, ok := s.Lookup(tok); ok {
-		t.Fatalf("Lookup after TTL expiry = (%q, true), want (\"\", false)", user)
-	}
-}
-
-func TestLookup_WithinTTLReturnsTrue(t *testing.T) {
-	withTempHome(t)
-	s := auth.NewStoreWithTTL(time.Minute)
-	base := time.Unix(1_700_000_000, 0)
-	s.SetClockForTest(func() time.Time { return base })
-	tok, err := s.Create("alice")
-	if err != nil {
-		t.Fatal(err)
-	}
-	// Advance by less than the TTL.
-	s.SetClockForTest(func() time.Time { return base.Add(30 * time.Second) })
-	user, ok := s.Lookup(tok)
-	if !ok || user != "alice" {
-		t.Fatalf("Lookup within TTL = (%q, %v), want (\"alice\", true)", user, ok)
-	}
-}
-
-func TestExpiredTokenIsEvicted(t *testing.T) {
-	withTempHome(t)
-	s := auth.NewStoreWithTTL(time.Second)
-	base := time.Unix(1_700_000_000, 0)
-	s.SetClockForTest(func() time.Time { return base })
-	tok, err := s.Create("alice")
-	if err != nil {
-		t.Fatal(err)
-	}
-	if got := s.EntryCountForTest(); got != 1 {
-		t.Fatalf("pre-expiry entry count = %d, want 1", got)
-	}
-	s.SetClockForTest(func() time.Time { return base.Add(2 * time.Second) })
-	if _, ok := s.Lookup(tok); ok {
-		t.Fatal("expired Lookup returned ok=true")
-	}
-	if got := s.EntryCountForTest(); got != 0 {
-		t.Fatalf("post-expiry entry count = %d, want 0 (expected lazy eviction)", got)
-	}
-	// Second lookup should still report false.
-	if _, ok := s.Lookup(tok); ok {
-		t.Fatal("second Lookup of expired token returned ok=true")
-	}
-}
-
-func TestStore_Concurrent(t *testing.T) {
-	withTempHome(t)
-	s := auth.NewStore()
-	var wg sync.WaitGroup
-	for i := 0; i < 50; i++ {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			tok, err := s.Create("alice")
-			if err != nil {
-				t.Error(err)
-				return
-			}
-			if _, ok := s.Lookup(tok); !ok {
-				t.Error("created token not found on immediate lookup")
-			}
-			s.Revoke(tok)
-		}()
-	}
-	done := make(chan struct{})
-	go func() { wg.Wait(); close(done) }()
-	select {
-	case <-done:
-	case <-time.After(5 * time.Second):
-		t.Fatal("concurrent ops did not finish in 5s — possible deadlock")
-	}
-}
diff --git a/internal/serve/auth/user.go b/internal/serve/auth/user.go
deleted file mode 100644
index b1638f6..0000000
--- a/internal/serve/auth/user.go
+++ /dev/null
@@ -1,110 +0,0 @@
-package auth
-
-import (
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io/fs"
-	"os"
-	"path/filepath"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/config"
-)
-
-// User is the single-user record persisted at userPath().
-type User struct {
-	Username  string    `json:"username"`
-	Password  Encoded   `json:"-"`
-	CreatedAt time.Time `json:"created_at"`
-}
-
-// userPersisted is the on-disk shape — Encoded fields flattened
-// into the top level for readability.
-type userPersisted struct {
-	Username  string    `json:"username"`
-	Algo      string    `json:"algo"`
-	Params    Params    `json:"params"`
-	SaltB64   string    `json:"salt_b64"`
-	HashB64   string    `json:"hash_b64"`
-	CreatedAt time.Time `json:"created_at"`
-}
-
-// UserPath returns the absolute path to user.json.
-func UserPath() string {
-	return filepath.Join(config.Dir(), "user.json")
-}
-
-// Exists reports whether user.json is present.
-func Exists() bool {
-	_, err := os.Stat(UserPath())
-	return err == nil
-}
-
-// Save writes u to UserPath() atomically (tmp-file + rename) with
-// 0600 perms, creating the config directory if needed.
-func Save(u User) error {
-	if err := os.MkdirAll(config.Dir(), 0o700); err != nil {
-		return fmt.Errorf("auth: mkdir: %w", err)
-	}
-	if u.CreatedAt.IsZero() {
-		u.CreatedAt = time.Now().UTC()
-	}
-	persisted := userPersisted{
-		Username:  u.Username,
-		Algo:      u.Password.Algo,
-		Params:    u.Password.Params,
-		SaltB64:   u.Password.SaltB64,
-		HashB64:   u.Password.HashB64,
-		CreatedAt: u.CreatedAt,
-	}
-	blob, err := json.MarshalIndent(persisted, "", "  ")
-	if err != nil {
-		return fmt.Errorf("auth: marshal: %w", err)
-	}
-	tmp := UserPath() + ".tmp"
-	if err := os.WriteFile(tmp, blob, 0o600); err != nil {
-		return fmt.Errorf("auth: write tmp: %w", err)
-	}
-	if err := os.Rename(tmp, UserPath()); err != nil {
-		return fmt.Errorf("auth: rename: %w", err)
-	}
-	return nil
-}
-
-// Load reads + parses user.json. Returns fs.ErrNotExist if the file
-// is absent.
-func Load() (User, error) {
-	blob, err := os.ReadFile(UserPath())
-	if err != nil {
-		return User{}, err
-	}
-	var p userPersisted
-	if err := json.Unmarshal(blob, &p); err != nil {
-		return User{}, fmt.Errorf("auth: unmarshal: %w", err)
-	}
-	return User{
-		Username: p.Username,
-		Password: Encoded{
-			Algo:    p.Algo,
-			Params:  p.Params,
-			SaltB64: p.SaltB64,
-			HashB64: p.HashB64,
-		},
-		CreatedAt: p.CreatedAt,
-	}, nil
-}
-
-// Delete removes user.json. Returns nil if the file was already
-// absent (idempotent by design so ctm auth reset can be run twice
-// without confusion).
-func Delete() error {
-	err := os.Remove(UserPath())
-	if err == nil {
-		return nil
-	}
-	if errors.Is(err, fs.ErrNotExist) {
-		return nil
-	}
-	return fmt.Errorf("auth: delete: %w", err)
-}
diff --git a/internal/serve/auth/user_test.go b/internal/serve/auth/user_test.go
deleted file mode 100644
index 98d2094..0000000
--- a/internal/serve/auth/user_test.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package auth_test
-
-import (
-	"os"
-	"path/filepath"
-	"testing"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/auth"
-)
-
-func withTempHome(t *testing.T) string {
-	t.Helper()
-	home := t.TempDir()
-	old := os.Getenv("HOME")
-	t.Cleanup(func() { _ = os.Setenv("HOME", old) })
-	_ = os.Setenv("HOME", home)
-	return home
-}
-
-func TestUser_Exists_False_WhenNoFile(t *testing.T) {
-	withTempHome(t)
-	if auth.Exists() {
-		t.Fatal("Exists() = true, want false on a fresh home")
-	}
-}
-
-func TestUser_Save_Load_RoundTrip(t *testing.T) {
-	home := withTempHome(t)
-	enc, err := auth.Hash("pw")
-	if err != nil {
-		t.Fatal(err)
-	}
-	u := auth.User{Username: "alice", Password: enc}
-	if err := auth.Save(u); err != nil {
-		t.Fatalf("Save: %v", err)
-	}
-	if !auth.Exists() {
-		t.Fatal("Exists() = false after Save")
-	}
-	got, err := auth.Load()
-	if err != nil {
-		t.Fatalf("Load: %v", err)
-	}
-	if got.Username != "alice" {
-		t.Fatalf("username = %q, want %q", got.Username, "alice")
-	}
-	if !auth.Verify(got.Password, "pw") {
-		t.Fatal("Verify(stored, \"pw\") = false, want true")
-	}
-	// File is inside ~/.config/ctm/ and is 0600.
-	p := filepath.Join(home, ".config", "ctm", "user.json")
-	info, err := os.Stat(p)
-	if err != nil {
-		t.Fatalf("stat user.json: %v", err)
-	}
-	if info.Mode().Perm() != 0o600 {
-		t.Fatalf("perm = %o, want 0600", info.Mode().Perm())
-	}
-}
-
-func TestUser_Delete(t *testing.T) {
-	withTempHome(t)
-	enc, _ := auth.Hash("pw")
-	if err := auth.Save(auth.User{Username: "alice", Password: enc}); err != nil {
-		t.Fatal(err)
-	}
-	if err := auth.Delete(); err != nil {
-		t.Fatalf("Delete: %v", err)
-	}
-	if auth.Exists() {
-		t.Fatal("Exists() = true after Delete")
-	}
-	if err := auth.Delete(); err != nil {
-		t.Fatalf("second Delete should be a no-op, got %v", err)
-	}
-}
diff --git a/internal/serve/events/event.go b/internal/serve/events/event.go
deleted file mode 100644
index 5977d2a..0000000
--- a/internal/serve/events/event.go
+++ /dev/null
@@ -1,29 +0,0 @@
-// Package events implements the in-memory pub/sub hub and SSE handler
-// that fan tool-call, quota, and lifecycle events out to the UI.
-//
-// All routing is in-process: publishers (tailers, hook handlers, attention
-// engine, quota ingest) call Hub.Publish; subscribers (SSE handlers, the
-// attention engine) call Hub.Subscribe. The hub never blocks publishers —
-// slow consumers drop events and increment a per-sub counter.
-package events
-
-import (
-	"encoding/json"
-	"time"
-)
-
-// Event is a single message routed through the hub. ID is assigned by
-// the hub at Publish time if empty: "-" where seq is a
-// per-second monotonic counter so bursts within the same nanosecond
-// remain unique and ordered.
-type Event struct {
-	ID      string          `json:"id"`
-	Type    string          `json:"type"`
-	Session string          `json:"session,omitempty"`
-	Payload json.RawMessage `json:"payload"`
-
-	ts time.Time
-}
-
-// globalRing is the ring-buffer key used for events without a session.
-const globalRing = ""
diff --git a/internal/serve/events/hub.go b/internal/serve/events/hub.go
deleted file mode 100644
index fc59739..0000000
--- a/internal/serve/events/hub.go
+++ /dev/null
@@ -1,363 +0,0 @@
-package events
-
-import (
-	"log/slog"
-	"strconv"
-	"strings"
-	"sync"
-	"sync/atomic"
-	"time"
-)
-
-const (
-	defaultRingSize = 500
-	// subChanBuffer sized to absorb boot bursts without dropping: the
-	// quota ingester's catch-up scan + the tailer's initial JSONL
-	// replay can fan out dozens of events in a few ms before an
-	// internal subscriber's consumer loop gets scheduled. 1024 covers
-	// that comfortably at ~200 KB per subscriber worst-case, and still
-	// gives ordinary browser SSE tabs plenty of headroom.
-	subChanBuffer   = 1024
-	dropLogInterval = time.Minute
-)
-
-// ring is a fixed-capacity FIFO of events keyed insertion-order. Oldest
-// entry is at index head; size grows to cap then wraps.
-type ring struct {
-	buf  []Event
-	head int
-	size int
-	cap  int
-}
-
-func newRing(cap int) *ring { return &ring{buf: make([]Event, cap), cap: cap} }
-
-func (r *ring) push(e Event) {
-	if r.cap == 0 {
-		return
-	}
-	idx := (r.head + r.size) % r.cap
-	r.buf[idx] = e
-	if r.size < r.cap {
-		r.size++
-	} else {
-		r.head = (r.head + 1) % r.cap
-	}
-}
-
-// snapshot returns events in chronological order.
-func (r *ring) snapshot() []Event {
-	out := make([]Event, r.size)
-	for i := 0; i < r.size; i++ {
-		out[i] = r.buf[(r.head+i)%r.cap]
-	}
-	return out
-}
-
-// oldestID returns the ID of the oldest entry or "" if empty.
-func (r *ring) oldestID() string {
-	if r.size == 0 {
-		return ""
-	}
-	return r.buf[r.head].ID
-}
-
-// Sub is a hub subscription. Consumers read events off Events(); the hub
-// drops events for this sub when its channel is full.
-type Sub struct {
-	ch       chan Event
-	filter   string
-	closed   atomic.Bool
-	dropped  atomic.Uint64
-	hub      *Hub
-	lastWarn atomic.Int64 // unix-nano of most recent drop WARN
-
-	closeOnce sync.Once
-}
-
-// Events returns the receive channel. The channel is closed when Close
-// is called or the hub is shut down.
-func (s *Sub) Events() <-chan Event { return s.ch }
-
-// Dropped returns the number of events that were dropped for this sub
-// because its channel was full when the publisher tried to enqueue.
-func (s *Sub) Dropped() uint64 { return s.dropped.Load() }
-
-// Close removes the sub from the hub and closes its channel. Idempotent.
-func (s *Sub) Close() {
-	s.closeOnce.Do(func() {
-		s.closed.Store(true)
-		if s.hub != nil {
-			s.hub.removeSub(s)
-		}
-		close(s.ch)
-	})
-}
-
-// Stats describes hub state for /health.
-type Stats struct {
-	Published   uint64         `json:"published"`
-	Dropped     uint64         `json:"dropped"`
-	Subscribers int            `json:"subscribers"`
-	RingSizes   map[string]int `json:"ring_sizes"`
-}
-
-// Hub is the in-process pub/sub fan-out plus per-session ring buffer.
-type Hub struct {
-	mu       sync.RWMutex
-	subs     map[*Sub]struct{}
-	rings    map[string]*ring
-	ringSize int
-
-	published atomic.Uint64
-
-	// idSeq counts events emitted within the current second, used as the
-	// monotonic suffix on Event.ID. Reset when the unix-second changes.
-	idMu     sync.Mutex
-	idLastNs int64
-	idSeq    uint64
-}
-
-// NewHub returns a hub with the given per-ring capacity. ringSize <= 0
-// uses the default (500).
-func NewHub(ringSize int) *Hub {
-	if ringSize <= 0 {
-		ringSize = defaultRingSize
-	}
-	return &Hub{
-		subs:     make(map[*Sub]struct{}),
-		rings:    make(map[string]*ring),
-		ringSize: ringSize,
-	}
-}
-
-// nextID assigns a monotonically-increasing "-" id.
-// Within the same nanosecond the seq increments; otherwise it resets.
-func (h *Hub) nextID(now time.Time) string {
-	h.idMu.Lock()
-	defer h.idMu.Unlock()
-	ns := now.UnixNano()
-	if ns <= h.idLastNs {
-		// Same instant or clock didn't tick; bump seq and reuse last ns
-		// so monotonicity holds even under coarse clocks.
-		h.idSeq++
-		ns = h.idLastNs
-	} else {
-		h.idLastNs = ns
-		h.idSeq = 0
-	}
-	return strconv.FormatInt(ns, 10) + "-" + strconv.FormatUint(h.idSeq, 10)
-}
-
-// Publish appends e to the appropriate rings and fans out to subscribers.
-// Never blocks: a full subscriber channel causes the event to be dropped
-// for that sub (drop counter incremented; WARN logged at most once/min).
-func (h *Hub) Publish(e Event) {
-	now := time.Now()
-	if e.ts.IsZero() {
-		e.ts = now
-	}
-	if e.ID == "" {
-		e.ID = h.nextID(now)
-	}
-
-	h.mu.Lock()
-	h.appendRing(globalRing, e)
-	if e.Session != "" {
-		h.appendRing(e.Session, e)
-	}
-	// Snapshot subs while holding the lock; deliver outside.
-	subs := make([]*Sub, 0, len(h.subs))
-	for s := range h.subs {
-		subs = append(subs, s)
-	}
-	h.mu.Unlock()
-
-	h.published.Add(1)
-
-	for _, s := range subs {
-		if s.closed.Load() {
-			continue
-		}
-		if s.filter != "" && s.filter != e.Session {
-			continue
-		}
-		select {
-		case s.ch <- e:
-		default:
-			n := s.dropped.Add(1)
-			h.maybeWarnDrop(s, n)
-		}
-	}
-}
-
-func (h *Hub) appendRing(key string, e Event) {
-	r := h.rings[key]
-	if r == nil {
-		r = newRing(h.ringSize)
-		h.rings[key] = r
-	}
-	r.push(e)
-}
-
-func (h *Hub) maybeWarnDrop(s *Sub, total uint64) {
-	now := time.Now().UnixNano()
-	prev := s.lastWarn.Load()
-	if prev != 0 && now-prev < int64(dropLogInterval) {
-		return
-	}
-	if !s.lastWarn.CompareAndSwap(prev, now) {
-		return
-	}
-	slog.Warn("hub subscriber dropping events",
-		"filter", s.filter,
-		"dropped_total", total)
-}
-
-// Subscribe registers a new subscriber and returns it together with the
-// replay slice from the appropriate ring.
-//
-// filter == "" subscribes to the global stream (every event); a non-empty
-// filter restricts delivery to events whose Session matches.
-//
-// since is a Last-Event-ID value: the replay slice contains buffered
-// events strictly after that ID, in chronological order. since == ""
-// returns an empty slice (start live). If since predates the ring's
-// oldest entry the entire ring snapshot is returned and Lost reports
-// true so the caller can emit a "lost" marker to the SSE client.
-func (h *Hub) Subscribe(filter, since string) (*Sub, []Event) {
-	sub, ev, _ := h.subscribe(filter, since)
-	return sub, ev
-}
-
-// subscribe is the internal variant exposing the lost-gap flag.
-func (h *Hub) subscribe(filter, since string) (*Sub, []Event, bool) {
-	s := &Sub{
-		ch:     make(chan Event, subChanBuffer),
-		filter: filter,
-		hub:    h,
-	}
-
-	h.mu.Lock()
-	defer h.mu.Unlock()
-
-	h.subs[s] = struct{}{}
-
-	key := globalRing
-	if filter != "" {
-		key = filter
-	}
-	r := h.rings[key]
-	if r == nil {
-		return s, nil, false
-	}
-
-	snap := r.snapshot()
-	if since == "" {
-		// Fresh connect with no resume cursor: seed the subscriber
-		// with the full ring snapshot so UI state (feed scrollback,
-		// quota bars, session cards) survives a page reload instead
-		// of staring at "waiting for first event…" until the next
-		// publish. Not treated as a gap — the client never had a
-		// cursor in the first place, so there's nothing "lost".
-		return s, snap, false
-	}
-	if idLessThan(since, r.oldestID()) {
-		// Gap unfillable — caller should emit a "lost" marker.
-		return s, snap, true
-	}
-	// Find first event with ID > since.
-	idx := -1
-	for i, e := range snap {
-		if idLessThan(since, e.ID) {
-			idx = i
-			break
-		}
-	}
-	if idx == -1 {
-		return s, nil, false
-	}
-	out := make([]Event, len(snap)-idx)
-	copy(out, snap[idx:])
-	return s, out, false
-}
-
-// removeSub is invoked by Sub.Close to detach from the hub.
-func (h *Hub) removeSub(s *Sub) {
-	h.mu.Lock()
-	delete(h.subs, s)
-	remaining := len(h.subs)
-	h.mu.Unlock()
-	// Debug-level: tab closes, navigations, and fetch-event-source
-	// reconnects all produce unsubscribes. Watching these at Info is
-	// too chatty for normal operation.
-	slog.Debug("hub unsubscribe",
-		"filter", s.filter, "dropped", s.dropped.Load(),
-		"subscribers_after", remaining)
-}
-
-// Snapshot returns a chronological copy of the ring for filter
-// (empty string = global ring). Caller owns the returned slice.
-// Used by REST seed endpoints (/api/feed, /api/sessions/{name}/feed)
-// so first paint renders historical events immediately — hub.Subscribe
-// returns no replay on empty Last-Event-ID, and we don't want fresh
-// browser tabs to stare at an empty list until the next publish.
-func (h *Hub) Snapshot(filter string) []Event {
-	key := globalRing
-	if filter != "" {
-		key = filter
-	}
-	h.mu.RLock()
-	defer h.mu.RUnlock()
-	r := h.rings[key]
-	if r == nil {
-		return nil
-	}
-	return r.snapshot()
-}
-
-// Stats returns a point-in-time snapshot of hub counters and ring sizes.
-func (h *Hub) Stats() Stats {
-	h.mu.RLock()
-	defer h.mu.RUnlock()
-	rings := make(map[string]int, len(h.rings))
-	var dropped uint64
-	for k, r := range h.rings {
-		rings[k] = r.size
-	}
-	for s := range h.subs {
-		dropped += s.dropped.Load()
-	}
-	return Stats{
-		Published:   h.published.Load(),
-		Dropped:     dropped,
-		Subscribers: len(h.subs),
-		RingSizes:   rings,
-	}
-}
-
-// idLessThan reports whether a < b under the "-" ID
-// ordering. Falls back to lexicographic for malformed IDs (since the
-// numeric prefixes are zero-padded by FormatInt of 19-digit nanos and
-// the seq is small, lexical ordering matches numeric ordering for
-// equal-length prefixes; we split to be safe).
-//
-// Exported for sse.go test reuse.
-func idLessThan(a, b string) bool {
-	an, as := splitID(a)
-	bn, bs := splitID(b)
-	if an != bn {
-		return an < bn
-	}
-	return as < bs
-}
-
-func splitID(id string) (int64, uint64) {
-	left, right, ok := strings.Cut(id, "-")
-	if !ok {
-		return 0, 0
-	}
-	ns, _ := strconv.ParseInt(left, 10, 64)
-	seq, _ := strconv.ParseUint(right, 10, 64)
-	return ns, seq
-}
diff --git a/internal/serve/events/hub_test.go b/internal/serve/events/hub_test.go
deleted file mode 100644
index c57bb6d..0000000
--- a/internal/serve/events/hub_test.go
+++ /dev/null
@@ -1,274 +0,0 @@
-package events
-
-import (
-	"encoding/json"
-	"sync"
-	"testing"
-	"time"
-)
-
-func mustPayload(t *testing.T, v any) json.RawMessage {
-	t.Helper()
-	b, err := json.Marshal(v)
-	if err != nil {
-		t.Fatalf("marshal: %v", err)
-	}
-	return b
-}
-
-func TestHub_BasicFanout(t *testing.T) {
-	t.Parallel()
-	h := NewHub(0)
-	subs := make([]*Sub, 3)
-	for i := range subs {
-		s, _ := h.Subscribe("", "")
-		subs[i] = s
-	}
-	defer func() {
-		for _, s := range subs {
-			s.Close()
-		}
-	}()
-
-	h.Publish(Event{Type: "tool_call", Payload: mustPayload(t, map[string]string{"k": "v"})})
-
-	for i, s := range subs {
-		select {
-		case e := <-s.Events():
-			if e.Type != "tool_call" {
-				t.Fatalf("sub %d: got type %q", i, e.Type)
-			}
-			if e.ID == "" {
-				t.Fatalf("sub %d: id not assigned", i)
-			}
-		case <-time.After(time.Second):
-			t.Fatalf("sub %d: timed out waiting for event", i)
-		}
-	}
-}
-
-func TestHub_FilterBySession(t *testing.T) {
-	t.Parallel()
-	h := NewHub(0)
-	all, _ := h.Subscribe("", "")
-	defer all.Close()
-	alpha, _ := h.Subscribe("alpha", "")
-	defer alpha.Close()
-
-	h.Publish(Event{Type: "tool_call", Session: "alpha", Payload: []byte(`{}`)})
-	h.Publish(Event{Type: "tool_call", Session: "beta", Payload: []byte(`{}`)})
-
-	// alpha-filtered sub should see only the alpha event.
-	select {
-	case e := <-alpha.Events():
-		if e.Session != "alpha" {
-			t.Fatalf("alpha sub got session %q", e.Session)
-		}
-	case <-time.After(time.Second):
-		t.Fatal("alpha sub timed out")
-	}
-	select {
-	case e := <-alpha.Events():
-		t.Fatalf("alpha sub got unexpected event: %+v", e)
-	case <-time.After(50 * time.Millisecond):
-	}
-
-	// global sub should see both.
-	for i := 0; i < 2; i++ {
-		select {
-		case <-all.Events():
-		case <-time.After(time.Second):
-			t.Fatalf("global sub timed out on event %d", i)
-		}
-	}
-}
-
-func TestHub_RingReplay(t *testing.T) {
-	t.Parallel()
-	h := NewHub(50)
-	const n = 20
-
-	// Publish n events with no subscribers; they accumulate in the ring.
-	ids := make([]string, n)
-	for i := 0; i < n; i++ {
-		e := Event{Type: "tool_call", Payload: []byte(`{}`)}
-		h.Publish(e)
-		// Read back the assigned ID via the global ring snapshot.
-		ids[i] = h.rings[globalRing].snapshot()[i].ID
-	}
-
-	// Subscribe with since = ids[k]; expect n-1-k events replayed.
-	const k = 7
-	sub, replay := h.Subscribe("", ids[k])
-	defer sub.Close()
-
-	want := n - 1 - k
-	if len(replay) != want {
-		t.Fatalf("replay len = %d, want %d", len(replay), want)
-	}
-	for i, e := range replay {
-		if e.ID != ids[k+1+i] {
-			t.Fatalf("replay[%d] id = %q, want %q", i, e.ID, ids[k+1+i])
-		}
-	}
-}
-
-func TestHub_RingReplayGapMarker(t *testing.T) {
-	t.Parallel()
-	// Tiny ring: 5 entries. Publish 10. Subscribe with a very old since.
-	h := NewHub(5)
-	for i := 0; i < 10; i++ {
-		h.Publish(Event{Type: "x", Payload: []byte(`{}`)})
-	}
-	// since = "0-0" predates everything; replay should be the full ring.
-	sub, replay := h.Subscribe("", "0-0")
-	defer sub.Close()
-	if len(replay) != 5 {
-		t.Fatalf("expected full ring (5) on unfillable gap, got %d", len(replay))
-	}
-}
-
-func TestHub_SlowConsumerDrop(t *testing.T) {
-	t.Parallel()
-	h := NewHub(0)
-	slow, _ := h.Subscribe("", "")
-	defer slow.Close()
-
-	// Anchor to the buffer size so this keeps testing "overflow"
-	// behaviour if subChanBuffer changes in the future.
-	n := subChanBuffer + 500
-	done := make(chan struct{})
-	go func() {
-		defer close(done)
-		for i := 0; i < n; i++ {
-			h.Publish(Event{Type: "x", Payload: []byte(`{}`)})
-		}
-	}()
-
-	select {
-	case <-done:
-	case <-time.After(time.Second):
-		t.Fatalf("publisher blocked on slow consumer")
-	}
-
-	// Drain what slow can hold (subChanBuffer) and verify the rest were dropped.
-	delivered := 0
-drain:
-	for {
-		select {
-		case <-slow.Events():
-			delivered++
-		default:
-			break drain
-		}
-	}
-	if delivered > subChanBuffer {
-		t.Fatalf("delivered %d > channel buffer %d", delivered, subChanBuffer)
-	}
-	if slow.Dropped() == 0 {
-		t.Fatal("expected non-zero dropped counter")
-	}
-	if uint64(delivered)+slow.Dropped() != uint64(n) {
-		t.Fatalf("delivered(%d) + dropped(%d) != %d", delivered, slow.Dropped(), n)
-	}
-	t.Logf("slow consumer: published=%d delivered=%d dropped=%d", n, delivered, slow.Dropped())
-}
-
-func TestSub_CloseIdempotent(t *testing.T) {
-	t.Parallel()
-	h := NewHub(0)
-	s, _ := h.Subscribe("", "")
-	s.Close()
-	// second close must not panic or double-close the channel.
-	s.Close()
-	// reading from a closed channel must return zero+!ok.
-	if _, ok := <-s.Events(); ok {
-		t.Fatal("expected channel to be closed")
-	}
-}
-
-func TestHub_IDMonotonicAndUnique(t *testing.T) {
-	t.Parallel()
-	h := NewHub(2000)
-	const n = 1000
-	for i := 0; i < n; i++ {
-		h.Publish(Event{Type: "x", Payload: []byte(`{}`)})
-	}
-	snap := h.rings[globalRing].snapshot()
-	if len(snap) != n {
-		t.Fatalf("ring size %d, want %d", len(snap), n)
-	}
-	seen := make(map[string]struct{}, n)
-	for i, e := range snap {
-		if _, dup := seen[e.ID]; dup {
-			t.Fatalf("duplicate id at %d: %q", i, e.ID)
-		}
-		seen[e.ID] = struct{}{}
-		if i > 0 && !idLessThan(snap[i-1].ID, e.ID) {
-			t.Fatalf("id non-monotonic at %d: prev=%q cur=%q",
-				i, snap[i-1].ID, e.ID)
-		}
-	}
-}
-
-func TestHub_StatsAccessor(t *testing.T) {
-	t.Parallel()
-	h := NewHub(10)
-	a, _ := h.Subscribe("", "")
-	defer a.Close()
-	b, _ := h.Subscribe("alpha", "")
-	defer b.Close()
-
-	h.Publish(Event{Type: "x", Session: "alpha", Payload: []byte(`{}`)})
-	h.Publish(Event{Type: "x", Payload: []byte(`{}`)})
-	// Drain so subs can keep up (avoids racy drop counters).
-	<-a.Events()
-	<-a.Events()
-	<-b.Events()
-
-	st := h.Stats()
-	if st.Published != 2 {
-		t.Fatalf("published = %d, want 2", st.Published)
-	}
-	if st.Subscribers != 2 {
-		t.Fatalf("subscribers = %d, want 2", st.Subscribers)
-	}
-	if st.RingSizes[globalRing] != 2 {
-		t.Fatalf("global ring = %d, want 2", st.RingSizes[globalRing])
-	}
-	if st.RingSizes["alpha"] != 1 {
-		t.Fatalf("alpha ring = %d, want 1", st.RingSizes["alpha"])
-	}
-}
-
-func TestHub_ConcurrentPublishSubscribe(t *testing.T) {
-	t.Parallel()
-	h := NewHub(0)
-	var wg sync.WaitGroup
-	for i := 0; i < 8; i++ {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			for j := 0; j < 50; j++ {
-				h.Publish(Event{Type: "x", Payload: []byte(`{}`)})
-			}
-		}()
-	}
-	for i := 0; i < 4; i++ {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
-			s, _ := h.Subscribe("", "")
-			defer s.Close()
-			deadline := time.After(500 * time.Millisecond)
-			for {
-				select {
-				case <-s.Events():
-				case <-deadline:
-					return
-				}
-			}
-		}()
-	}
-	wg.Wait()
-}
diff --git a/internal/serve/events/snapshot_test.go b/internal/serve/events/snapshot_test.go
deleted file mode 100644
index 6d25a62..0000000
--- a/internal/serve/events/snapshot_test.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package events
-
-import (
-	"testing"
-)
-
-func TestSnapshot_EmptyHubReturnsNil(t *testing.T) {
-	t.Parallel()
-	h := NewHub(0)
-	if got := h.Snapshot(""); got != nil {
-		t.Errorf("Snapshot on empty hub = %+v, want nil", got)
-	}
-	if got := h.Snapshot("alpha"); got != nil {
-		t.Errorf("Snapshot for unknown filter = %+v, want nil", got)
-	}
-}
-
-func TestSnapshot_GlobalReturnsChronologicalCopy(t *testing.T) {
-	t.Parallel()
-	h := NewHub(0)
-	for _, payload := range [][]byte{[]byte(`{"n":1}`), []byte(`{"n":2}`), []byte(`{"n":3}`)} {
-		h.Publish(Event{Type: "tool_call", Session: "alpha", Payload: payload})
-	}
-
-	got := h.Snapshot("")
-	if len(got) != 3 {
-		t.Fatalf("len = %d, want 3", len(got))
-	}
-	for i, want := range [][]byte{[]byte(`{"n":1}`), []byte(`{"n":2}`), []byte(`{"n":3}`)} {
-		if string(got[i].Payload) != string(want) {
-			t.Errorf("got[%d].Payload = %q, want %q", i, got[i].Payload, want)
-		}
-	}
-
-	// Mutating the returned slice must not affect future Snapshot calls.
-	got[0].Type = "mutated"
-	again := h.Snapshot("")
-	if again[0].Type == "mutated" {
-		t.Errorf("Snapshot returned aliased slice; mutation leaked back")
-	}
-}
-
-func TestSnapshot_FilterReturnsOnlySessionEvents(t *testing.T) {
-	t.Parallel()
-	h := NewHub(0)
-	h.Publish(Event{Type: "tool_call", Session: "alpha", Payload: []byte(`{"a":1}`)})
-	h.Publish(Event{Type: "tool_call", Session: "beta", Payload: []byte(`{"b":1}`)})
-	h.Publish(Event{Type: "tool_call", Session: "alpha", Payload: []byte(`{"a":2}`)})
-
-	alpha := h.Snapshot("alpha")
-	if len(alpha) != 2 {
-		t.Fatalf("alpha snapshot len = %d, want 2", len(alpha))
-	}
-	for i, ev := range alpha {
-		if ev.Session != "alpha" {
-			t.Errorf("alpha[%d].Session = %q, want alpha", i, ev.Session)
-		}
-	}
-
-	beta := h.Snapshot("beta")
-	if len(beta) != 1 || beta[0].Session != "beta" {
-		t.Errorf("beta snapshot = %+v, want one beta event", beta)
-	}
-
-	global := h.Snapshot("")
-	if len(global) != 3 {
-		t.Errorf("global snapshot len = %d, want 3", len(global))
-	}
-}
-
-func TestSnapshot_RingWrapKeepsNewest(t *testing.T) {
-	t.Parallel()
-	const ringSize = 4
-	h := NewHub(ringSize)
-
-	// Publish 6 events on the same session — ring should retain the last 4.
-	for i := 0; i < 6; i++ {
-		h.Publish(Event{
-			Type:    "tool_call",
-			Session: "alpha",
-			Payload: []byte(`{"n":` + string(rune('0'+i)) + `}`),
-		})
-	}
-
-	got := h.Snapshot("alpha")
-	if len(got) != ringSize {
-		t.Fatalf("len = %d, want %d", len(got), ringSize)
-	}
-	// Oldest retained should correspond to n=2 (events 0,1 fell off).
-	wantFirst := `{"n":2}`
-	if string(got[0].Payload) != wantFirst {
-		t.Errorf("got[0].Payload = %q, want %q", got[0].Payload, wantFirst)
-	}
-	wantLast := `{"n":5}`
-	if string(got[ringSize-1].Payload) != wantLast {
-		t.Errorf("got[last].Payload = %q, want %q", got[ringSize-1].Payload, wantLast)
-	}
-}
diff --git a/internal/serve/events/sse.go b/internal/serve/events/sse.go
deleted file mode 100644
index 4125095..0000000
--- a/internal/serve/events/sse.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package events
-
-import (
-	"io"
-	"net/http"
-	"strings"
-	"time"
-)
-
-const (
-	sseKeepalive = 15 * time.Second
-)
-
-// Handler returns an http.HandlerFunc that streams hub events as an SSE
-// response. filter == "" streams the global feed; otherwise only events
-// whose Session matches filter are delivered.
-//
-// Honours the Last-Event-ID request header for ring replay. Sends a
-// keepalive comment every sseKeepalive so corporate proxies don't time
-// the stream out. Exits when the client disconnects.
-func Handler(h *Hub, filter string) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		flusher, ok := w.(http.Flusher)
-		if !ok {
-			http.Error(w, "streaming unsupported", http.StatusInternalServerError)
-			return
-		}
-
-		hdr := w.Header()
-		hdr.Set("Content-Type", "text/event-stream")
-		hdr.Set("Cache-Control", "no-store")
-		hdr.Set("Connection", "keep-alive")
-		hdr.Set("X-Accel-Buffering", "no")
-		w.WriteHeader(http.StatusOK)
-		flusher.Flush()
-
-		// Initial comment frame: gives proxies (Caddy, nginx, CF) and the
-		// browser an immediate body byte so the response is committed and
-		// EventSource.onopen fires without waiting for the first 15s
-		// keepalive. Without this, fetch-event-source can sit idle and
-		// some intermediaries hold headers until first chunk.
-		if _, err := io.WriteString(w, ": ok\n\n"); err != nil {
-			return
-		}
-		flusher.Flush()
-
-		since := r.Header.Get("Last-Event-ID")
-		sub, replay, lost := h.subscribe(filter, since)
-		defer sub.Close()
-
-		if lost {
-			if _, err := io.WriteString(w, ": lost\n\n"); err != nil {
-				return
-			}
-			flusher.Flush()
-		}
-
-		for _, e := range replay {
-			if err := writeEvent(w, flusher, e); err != nil {
-				return
-			}
-		}
-
-		ticker := time.NewTicker(sseKeepalive)
-		defer ticker.Stop()
-
-		ctx := r.Context()
-		for {
-			select {
-			case <-ctx.Done():
-				return
-			case e, open := <-sub.Events():
-				if !open {
-					return
-				}
-				if err := writeEvent(w, flusher, e); err != nil {
-					return
-				}
-			case <-ticker.C:
-				if _, err := io.WriteString(w, ": keepalive\n\n"); err != nil {
-					return
-				}
-				flusher.Flush()
-			}
-		}
-	}
-}
-
-// writeEvent serialises one Event to an SSE frame.
-//
-// Multi-line payloads must be split on newline per the SSE spec — each
-// data line is prefixed with "data: ".
-func writeEvent(w io.Writer, f http.Flusher, e Event) error {
-	var b strings.Builder
-	if e.Type != "" {
-		b.WriteString("event: ")
-		b.WriteString(e.Type)
-		b.WriteByte('\n')
-	}
-	if e.ID != "" {
-		b.WriteString("id: ")
-		b.WriteString(e.ID)
-		b.WriteByte('\n')
-	}
-	payload := string(e.Payload)
-	if payload == "" {
-		b.WriteString("data: \n")
-	} else {
-		for line := range strings.SplitSeq(payload, "\n") {
-			b.WriteString("data: ")
-			b.WriteString(line)
-			b.WriteByte('\n')
-		}
-	}
-	b.WriteByte('\n')
-
-	if _, err := io.WriteString(w, b.String()); err != nil {
-		return err
-	}
-	f.Flush()
-	return nil
-}
diff --git a/internal/serve/events/sse_test.go b/internal/serve/events/sse_test.go
deleted file mode 100644
index a6e49be..0000000
--- a/internal/serve/events/sse_test.go
+++ /dev/null
@@ -1,180 +0,0 @@
-package events
-
-import (
-	"bufio"
-	"context"
-	"net/http"
-	"net/http/httptest"
-	"strings"
-	"testing"
-	"time"
-)
-
-// readSSEFrames parses raw SSE bytes off a reader into frame maps until
-// `want` frames have been collected or the deadline expires.
-func readSSEFrames(t *testing.T, r *bufio.Reader, want int, deadline time.Duration) []map[string]string {
-	t.Helper()
-	type result struct {
-		frame map[string]string
-		err   error
-	}
-
-	frames := make([]map[string]string, 0, want)
-	cur := map[string]string{}
-	dataLines := []string{}
-
-	timer := time.NewTimer(deadline)
-	defer timer.Stop()
-	ch := make(chan result, 1)
-
-	readOne := func() {
-		line, err := r.ReadString('\n')
-		ch <- result{frame: map[string]string{"_line": line}, err: err}
-	}
-
-	go readOne()
-	for len(frames) < want {
-		select {
-		case res := <-ch:
-			if res.err != nil {
-				t.Fatalf("sse read: %v", res.err)
-			}
-			line := strings.TrimRight(res.frame["_line"], "\n")
-			if line == "" {
-				// Frame boundary. Only dispatch if a data line was seen
-				// (per SSE spec a comment-only block dispatches nothing).
-				if len(dataLines) > 0 {
-					cur["data"] = strings.Join(dataLines, "\n")
-					frames = append(frames, cur)
-				}
-				cur = map[string]string{}
-				dataLines = nil
-			} else if strings.HasPrefix(line, ":") {
-				cur["_comment"] = strings.TrimSpace(line[1:])
-			} else if i := strings.IndexByte(line, ':'); i > 0 {
-				key := line[:i]
-				val := strings.TrimPrefix(line[i+1:], " ")
-				if key == "data" {
-					dataLines = append(dataLines, val)
-				} else {
-					cur[key] = val
-				}
-			}
-			if len(frames) < want {
-				go readOne()
-			}
-		case <-timer.C:
-			t.Fatalf("timed out reading %d sse frames (got %d)", want, len(frames))
-		}
-	}
-	return frames
-}
-
-func newSSEServer(t *testing.T, h *Hub, filter string) *httptest.Server {
-	t.Helper()
-	mux := http.NewServeMux()
-	mux.HandleFunc("/events", Handler(h, filter))
-	return httptest.NewServer(mux)
-}
-
-func TestSSE_Handler_Basic(t *testing.T) {
-	t.Parallel()
-	h := NewHub(0)
-	ts := newSSEServer(t, h, "")
-	defer ts.Close()
-
-	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
-	defer cancel()
-	req, _ := http.NewRequestWithContext(ctx, "GET", ts.URL+"/events", nil)
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer resp.Body.Close()
-
-	if got := resp.Header.Get("Content-Type"); got != "text/event-stream" {
-		t.Fatalf("Content-Type=%q", got)
-	}
-	if got := resp.Header.Get("Cache-Control"); got != "no-store" {
-		t.Fatalf("Cache-Control=%q", got)
-	}
-	if got := resp.Header.Get("X-Accel-Buffering"); got != "no" {
-		t.Fatalf("X-Accel-Buffering=%q", got)
-	}
-
-	// Give handler a tick to register its sub before we publish.
-	time.Sleep(20 * time.Millisecond)
-	h.Publish(Event{Type: "tool_call", Payload: []byte(`{"k":"v"}`)})
-	h.Publish(Event{Type: "quota_update", Payload: []byte(`{"weekly_pct":34}`)})
-
-	br := bufio.NewReader(resp.Body)
-	frames := readSSEFrames(t, br, 2, 2*time.Second)
-
-	if frames[0]["event"] != "tool_call" {
-		t.Fatalf("frame 0 event=%q", frames[0]["event"])
-	}
-	if frames[0]["data"] != `{"k":"v"}` {
-		t.Fatalf("frame 0 data=%q", frames[0]["data"])
-	}
-	if frames[0]["id"] == "" {
-		t.Fatal("frame 0 missing id")
-	}
-	if frames[1]["event"] != "quota_update" {
-		t.Fatalf("frame 1 event=%q", frames[1]["event"])
-	}
-}
-
-func TestSSE_LastEventIDResume(t *testing.T) {
-	t.Parallel()
-	h := NewHub(50)
-
-	// Pre-publish a batch and capture IDs.
-	for i := 0; i < 5; i++ {
-		h.Publish(Event{Type: "x", Payload: []byte(`{}`)})
-	}
-	snap := h.rings[globalRing].snapshot()
-	kthID := snap[2].ID
-
-	ts := newSSEServer(t, h, "")
-	defer ts.Close()
-
-	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
-	defer cancel()
-	req, _ := http.NewRequestWithContext(ctx, "GET", ts.URL+"/events", nil)
-	req.Header.Set("Last-Event-ID", kthID)
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer resp.Body.Close()
-
-	br := bufio.NewReader(resp.Body)
-	// Expect 2 replayed frames (ids 3 and 4).
-	frames := readSSEFrames(t, br, 2, 2*time.Second)
-	if frames[0]["id"] != snap[3].ID {
-		t.Fatalf("resume frame 0 id=%q want %q", frames[0]["id"], snap[3].ID)
-	}
-	if frames[1]["id"] != snap[4].ID {
-		t.Fatalf("resume frame 1 id=%q want %q", frames[1]["id"], snap[4].ID)
-	}
-}
-
-func TestSSE_MultilineDataSplit(t *testing.T) {
-	t.Parallel()
-	var sb strings.Builder
-	tw := &testFlusher{w: &sb}
-	e := Event{Type: "x", ID: "1-0", Payload: []byte("line1\nline2\nline3")}
-	if err := writeEvent(tw, tw, e); err != nil {
-		t.Fatal(err)
-	}
-	got := sb.String()
-	want := "event: x\nid: 1-0\ndata: line1\ndata: line2\ndata: line3\n\n"
-	if got != want {
-		t.Fatalf("frame mismatch:\ngot  %q\nwant %q", got, want)
-	}
-}
-
-type testFlusher struct{ w *strings.Builder }
-
-func (t *testFlusher) Write(p []byte) (int, error) { return t.w.Write(p) }
-func (t *testFlusher) Flush()                      {}
diff --git a/internal/serve/git/checkpoints.go b/internal/serve/git/checkpoints.go
deleted file mode 100644
index 0555081..0000000
--- a/internal/serve/git/checkpoints.go
+++ /dev/null
@@ -1,159 +0,0 @@
-// Package git provides git-backed primitives for ctm serve: listing
-// the YOLO checkpoint commits in a session's workdir and reverting to
-// one of them. All operations shell out to the system `git` binary
-// with a bounded timeout.
-package git
-
-import (
-	"bufio"
-	"context"
-	"fmt"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"strings"
-	"time"
-)
-
-// gitTimeout caps every shell-out to git. 10s matches the contract in
-// the implementation plan and is generous for any non-pathological
-// workdir.
-const gitTimeout = 10 * time.Second
-
-// maxLimit caps the number of checkpoints returned by List even if the
-// caller asks for more. Mirrors the upper bound in the spec's
-// `?limit=` query semantics.
-const maxLimit = 200
-
-// Checkpoint is the JSON view of a single YOLO checkpoint commit. Tags
-// match §6 "Checkpoint JSON" exactly.
-type Checkpoint struct {
-	SHA     string `json:"sha"`
-	Subject string `json:"subject"`
-	TS      string `json:"ts"`
-	Ago     string `json:"ago"`
-}
-
-// List returns up to limit checkpoints from workdir, newest first.
-// limit is capped at maxLimit. A missing workdir or one without a
-// `.git` directory yields (nil, nil) so the caller can render an
-// empty list without surfacing an error to the user.
-func List(workdir string, limit int) ([]Checkpoint, error) {
-	if !hasGitDir(workdir) {
-		return nil, nil
-	}
-	if limit <= 0 {
-		limit = 50
-	}
-	if limit > maxLimit {
-		limit = maxLimit
-	}
-
-	out, err := runGit(workdir,
-		"log",
-		"--grep=checkpoint",
-		"--pretty=format:%H%x09%s%x09%cI",
-		fmt.Sprintf("-n%d", limit),
-	)
-	if err != nil {
-		return nil, err
-	}
-
-	now := time.Now()
-	var checkpoints []Checkpoint
-	scanner := bufio.NewScanner(strings.NewReader(out))
-	scanner.Buffer(make([]byte, 64*1024), 1024*1024)
-	for scanner.Scan() {
-		line := scanner.Text()
-		if line == "" {
-			continue
-		}
-		parts := strings.SplitN(line, "\t", 3)
-		if len(parts) != 3 {
-			continue
-		}
-		sha, subject, isoTS := parts[0], parts[1], parts[2]
-		// `--grep=checkpoint` is a loose substring match; restrict to
-		// commits whose subject actually starts with "checkpoint:".
-		if !strings.HasPrefix(subject, "checkpoint:") {
-			continue
-		}
-		ts, terr := time.Parse(time.RFC3339, isoTS)
-		if terr != nil {
-			// Skip rather than fail the whole list — a single corrupt
-			// commit shouldn't blank the UI.
-			continue
-		}
-		checkpoints = append(checkpoints, Checkpoint{
-			SHA:     sha,
-			Subject: subject,
-			TS:      ts.UTC().Format(time.RFC3339),
-			Ago:     humaniseAgo(now.Sub(ts)),
-		})
-	}
-	return checkpoints, nil
-}
-
-// hasGitDir reports whether workdir is a non-empty path containing a
-// `.git` entry (file or directory — covers both standard repos and
-// linked worktrees).
-func hasGitDir(workdir string) bool {
-	if workdir == "" {
-		return false
-	}
-	info, err := os.Stat(workdir)
-	if err != nil || !info.IsDir() {
-		return false
-	}
-	if _, err := os.Stat(filepath.Join(workdir, ".git")); err != nil {
-		return false
-	}
-	return true
-}
-
-// runGit executes `git -C workdir ` with the package-level
-// timeout. Returns stdout on success; on failure, returns an error
-// that includes stderr for diagnosis.
-func runGit(workdir string, args ...string) (string, error) {
-	ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
-	defer cancel()
-	full := append([]string{"-C", workdir}, args...)
-	cmd := exec.CommandContext(ctx, "git", full...)
-	var stderr strings.Builder
-	cmd.Stderr = &stderr
-	out, err := cmd.Output()
-	if err != nil {
-		msg := strings.TrimSpace(stderr.String())
-		if msg == "" {
-			return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
-		}
-		return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, msg)
-	}
-	return string(out), nil
-}
-
-// humaniseAgo renders a duration as a short human string in the same
-// style as GitHub timestamps: "12s", "5m", "3h", "2d", "4w", "9mo",
-// "2y". Negative durations clamp to "0s".
-func humaniseAgo(d time.Duration) string {
-	if d < 0 {
-		return "0s"
-	}
-	s := int64(d.Seconds())
-	switch {
-	case s < 60:
-		return fmt.Sprintf("%ds", s)
-	case s < 3600:
-		return fmt.Sprintf("%dm", s/60)
-	case s < 86400:
-		return fmt.Sprintf("%dh", s/3600)
-	case s < 7*86400:
-		return fmt.Sprintf("%dd", s/86400)
-	case s < 30*86400:
-		return fmt.Sprintf("%dw", s/(7*86400))
-	case s < 365*86400:
-		return fmt.Sprintf("%dmo", s/(30*86400))
-	default:
-		return fmt.Sprintf("%dy", s/(365*86400))
-	}
-}
diff --git a/internal/serve/git/checkpoints_test.go b/internal/serve/git/checkpoints_test.go
deleted file mode 100644
index a234530..0000000
--- a/internal/serve/git/checkpoints_test.go
+++ /dev/null
@@ -1,159 +0,0 @@
-package git
-
-import (
-	"os"
-	"os/exec"
-	"path/filepath"
-	"strconv"
-	"strings"
-	"testing"
-	"time"
-)
-
-func TestList_ReturnsCheckpointsAndIgnoresOthers(t *testing.T) {
-	requireGit(t)
-	dir := newRepo(t)
-
-	// Two checkpoints + one regular commit.
-	commit(t, dir, "checkpoint: pre-yolo 2026-04-20T10:00:00")
-	commit(t, dir, "feat: regular work")
-	commit(t, dir, "checkpoint: pre-yolo 2026-04-20T11:00:00")
-
-	got, err := List(dir, 50)
-	if err != nil {
-		t.Fatalf("List: %v", err)
-	}
-	if len(got) != 2 {
-		t.Fatalf("want 2 checkpoints, got %d: %+v", len(got), got)
-	}
-	for _, c := range got {
-		if !strings.HasPrefix(c.Subject, "checkpoint:") {
-			t.Errorf("subject not a checkpoint: %q", c.Subject)
-		}
-		if c.SHA == "" {
-			t.Error("empty SHA")
-		}
-		if _, perr := time.Parse(time.RFC3339, c.TS); perr != nil {
-			t.Errorf("TS not RFC3339: %q (%v)", c.TS, perr)
-		}
-		if c.Ago == "" {
-			t.Error("empty Ago")
-		}
-	}
-}
-
-func TestList_RespectsLimit(t *testing.T) {
-	requireGit(t)
-	dir := newRepo(t)
-	for i := 0; i < 5; i++ {
-		commit(t, dir, "checkpoint: pre-yolo n="+itoa(i))
-	}
-	got, err := List(dir, 3)
-	if err != nil {
-		t.Fatalf("List: %v", err)
-	}
-	if len(got) != 3 {
-		t.Fatalf("want 3, got %d", len(got))
-	}
-}
-
-func TestList_CapsAtMaxLimit(t *testing.T) {
-	requireGit(t)
-	dir := newRepo(t)
-	commit(t, dir, "checkpoint: only one")
-
-	// Asking for more than maxLimit should not error.
-	got, err := List(dir, 10_000)
-	if err != nil {
-		t.Fatalf("List: %v", err)
-	}
-	if len(got) != 1 {
-		t.Fatalf("want 1, got %d", len(got))
-	}
-}
-
-func TestList_MissingWorkdirReturnsEmpty(t *testing.T) {
-	got, err := List("/nonexistent/path/should/not/exist", 50)
-	if err != nil {
-		t.Fatalf("want nil error, got %v", err)
-	}
-	if got != nil {
-		t.Fatalf("want nil list, got %+v", got)
-	}
-}
-
-func TestList_NoGitDirReturnsEmpty(t *testing.T) {
-	dir := t.TempDir() // not a git repo
-	got, err := List(dir, 50)
-	if err != nil {
-		t.Fatalf("want nil error, got %v", err)
-	}
-	if got != nil {
-		t.Fatalf("want nil list, got %+v", got)
-	}
-}
-
-func TestHumaniseAgo(t *testing.T) {
-	cases := []struct {
-		d    time.Duration
-		want string
-	}{
-		{0, "0s"},
-		{30 * time.Second, "30s"},
-		{2 * time.Minute, "2m"},
-		{3 * time.Hour, "3h"},
-		{2 * 24 * time.Hour, "2d"},
-		{2 * 7 * 24 * time.Hour, "2w"},
-		{60 * 24 * time.Hour, "2mo"},
-		{2 * 365 * 24 * time.Hour, "2y"},
-		{-1 * time.Second, "0s"},
-	}
-	for _, c := range cases {
-		if got := humaniseAgo(c.d); got != c.want {
-			t.Errorf("humaniseAgo(%v) = %q, want %q", c.d, got, c.want)
-		}
-	}
-}
-
-// Helpers ---------------------------------------------------------
-
-func requireGit(t *testing.T) {
-	t.Helper()
-	if _, err := exec.LookPath("git"); err != nil {
-		t.Skip("git not on PATH; skipping")
-	}
-}
-
-func newRepo(t *testing.T) string {
-	t.Helper()
-	dir := t.TempDir()
-	run(t, dir, "git", "init", "-q", "-b", "main")
-	run(t, dir, "git", "config", "user.email", "test@example.com")
-	run(t, dir, "git", "config", "user.name", "Test")
-	run(t, dir, "git", "config", "commit.gpgsign", "false")
-	// Seed with a regular commit so HEAD exists.
-	if err := os.WriteFile(filepath.Join(dir, "README"), []byte("hello\n"), 0o644); err != nil {
-		t.Fatalf("seed file: %v", err)
-	}
-	run(t, dir, "git", "add", "-A")
-	run(t, dir, "git", "commit", "-q", "-m", "initial")
-	return dir
-}
-
-func commit(t *testing.T, dir, msg string) {
-	t.Helper()
-	run(t, dir, "git", "commit", "-q", "--allow-empty", "-m", msg)
-}
-
-func run(t *testing.T, dir string, name string, args ...string) {
-	t.Helper()
-	cmd := exec.Command(name, args...)
-	cmd.Dir = dir
-	if out, err := cmd.CombinedOutput(); err != nil {
-		t.Fatalf("%s %v: %v\n%s", name, args, err, out)
-	}
-}
-
-func itoa(i int) string {
-	return strconv.Itoa(i)
-}
diff --git a/internal/serve/git/diff.go b/internal/serve/git/diff.go
deleted file mode 100644
index 230ed8e..0000000
--- a/internal/serve/git/diff.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package git
-
-import (
-	"context"
-	"fmt"
-	"os/exec"
-	"strings"
-	"time"
-)
-
-// diffTimeout caps every `git show` shell-out. 5 s per V18 spec —
-// tighter than the 10 s gitTimeout shared by List/Revert because the
-// diff endpoint is called interactively from the UI and we'd rather
-// surface a fast error than hang the sheet.
-const diffTimeout = 5 * time.Second
-
-// DiffAt returns the unified diff (patch + commit header) for sha in
-// workdir, produced by `git -C  show --unified=3 `.
-//
-// A missing workdir or one without a `.git` entry returns an error —
-// unlike List, which falls through to (nil, nil). The diff handler's
-// 404 semantics are driven by the SHA-allowlist check upstream, so a
-// bad workdir here is unexpected and should surface, not silently
-// produce an empty string.
-//
-// The caller is expected to have already validated sha through
-// api.CheckpointsCache.IsCheckpoint — this function shells out
-// without re-validation. Do not call with arbitrary user input.
-func DiffAt(workdir, sha string) (string, error) {
-	if !hasGitDir(workdir) {
-		return "", fmt.Errorf("workdir %q is not a git repository", workdir)
-	}
-	if strings.TrimSpace(sha) == "" {
-		return "", fmt.Errorf("sha must not be empty")
-	}
-
-	ctx, cancel := context.WithTimeout(context.Background(), diffTimeout)
-	defer cancel()
-	cmd := exec.CommandContext(ctx, "git", "-C", workdir, "show", "--unified=3", sha)
-	var stderr strings.Builder
-	cmd.Stderr = &stderr
-	out, err := cmd.Output()
-	if err != nil {
-		msg := strings.TrimSpace(stderr.String())
-		if msg == "" {
-			return "", fmt.Errorf("git show %s: %w", sha, err)
-		}
-		return "", fmt.Errorf("git show %s: %w: %s", sha, err, msg)
-	}
-	return string(out), nil
-}
diff --git a/internal/serve/git/revert.go b/internal/serve/git/revert.go
deleted file mode 100644
index 395ccee..0000000
--- a/internal/serve/git/revert.go
+++ /dev/null
@@ -1,114 +0,0 @@
-package git
-
-import (
-	"bufio"
-	"fmt"
-	"strings"
-)
-
-// RevertResult is the JSON payload returned by a successful revert.
-// `StashedAs` is omitted from the output when the caller did not ask
-// for (or did not need) a pre-revert stash.
-type RevertResult struct {
-	OK         bool   `json:"ok"`
-	RevertedTo string `json:"reverted_to"`
-	StashedAs  string `json:"stashed_as,omitempty"`
-}
-
-// DirtyError is returned by Revert when the workdir has uncommitted
-// changes and the caller did not opt in to `stashFirst`. Files holds
-// the relative paths reported by `git status --porcelain`.
-type DirtyError struct {
-	Files []string
-}
-
-// Error implements error.
-func (e *DirtyError) Error() string {
-	if e == nil {
-		return "workdir is dirty"
-	}
-	return fmt.Sprintf("workdir is dirty (%d file(s))", len(e.Files))
-}
-
-// Revert resets workdir's HEAD to sha (`git reset --hard `).
-//
-// If the workdir is dirty:
-//   - !stashFirst → returns *DirtyError (no side effects).
-//   - stashFirst  → `git stash push -u -m "ctm-revert pre-"`,
-//     captures the stash commit SHA into RevertResult.StashedAs, then
-//     proceeds with the reset. If reset itself fails the stash is left
-//     in place for manual recovery — we do not auto-pop.
-//
-// SECURITY CONTRACT: this function does NOT validate that sha refers
-// to a known checkpoint. The caller (the HTTP handler in api/revert.go)
-// is responsible for enforcing the SHA-allowlist guarantee from the
-// spec — callers that bypass that allowlist allow arbitrary
-// `git reset --hard` against the repo. Do not call Revert from any
-// path that has not first cross-checked sha against the matching
-// `/checkpoints` response.
-func Revert(workdir, sha string, stashFirst bool) (RevertResult, error) {
-	var res RevertResult
-
-	if !hasGitDir(workdir) {
-		return res, fmt.Errorf("workdir %q is not a git repository", workdir)
-	}
-	if strings.TrimSpace(sha) == "" {
-		return res, fmt.Errorf("sha must not be empty")
-	}
-
-	status, err := runGit(workdir, "status", "--porcelain")
-	if err != nil {
-		return res, fmt.Errorf("git status: %w", err)
-	}
-	dirty := parseDirtyFiles(status)
-	if len(dirty) > 0 {
-		if !stashFirst {
-			return res, &DirtyError{Files: dirty}
-		}
-		stashMsg := fmt.Sprintf("ctm-revert pre-%s", sha)
-		if _, err := runGit(workdir, "stash", "push", "-u", "-m", stashMsg); err != nil {
-			return res, fmt.Errorf("git stash: %w", err)
-		}
-		stashSHA, err := runGit(workdir, "rev-parse", "stash@{0}")
-		if err != nil {
-			return res, fmt.Errorf("git rev-parse stash@{0}: %w", err)
-		}
-		res.StashedAs = strings.TrimSpace(stashSHA)
-	}
-
-	if _, err := runGit(workdir, "reset", "--hard", sha); err != nil {
-		// Surface the underlying git error verbatim. The stash, if any,
-		// is intentionally not popped — the user can `git stash pop`.
-		return res, fmt.Errorf("git reset --hard %s: %w", sha, err)
-	}
-
-	res.OK = true
-	res.RevertedTo = sha
-	return res, nil
-}
-
-// parseDirtyFiles extracts the path portion of every line in
-// `git status --porcelain` output. Porcelain v1 format is "XY path"
-// (with two status columns and a single space separator). Lines that
-// don't match the expected shape are skipped silently.
-func parseDirtyFiles(porcelain string) []string {
-	var files []string
-	scanner := bufio.NewScanner(strings.NewReader(porcelain))
-	for scanner.Scan() {
-		line := scanner.Text()
-		if len(line) < 4 {
-			continue
-		}
-		// Skip the two status columns and the separating space.
-		path := strings.TrimSpace(line[3:])
-		if path == "" {
-			continue
-		}
-		// Renames are reported as "old -> new"; surface the new path.
-		if idx := strings.Index(path, " -> "); idx >= 0 {
-			path = path[idx+len(" -> "):]
-		}
-		files = append(files, path)
-	}
-	return files
-}
diff --git a/internal/serve/git/revert_test.go b/internal/serve/git/revert_test.go
deleted file mode 100644
index 5169d9e..0000000
--- a/internal/serve/git/revert_test.go
+++ /dev/null
@@ -1,135 +0,0 @@
-package git
-
-import (
-	"errors"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"strings"
-	"testing"
-)
-
-func TestRevert_DirtyWithoutStash_ReturnsDirtyError(t *testing.T) {
-	requireGit(t)
-	dir := newRepo(t)
-	commit(t, dir, "checkpoint: pre-yolo first")
-	target := headSHA(t, dir)
-	commit(t, dir, "feat: add stuff")
-
-	// Make workdir dirty.
-	if err := os.WriteFile(filepath.Join(dir, "README"), []byte("dirty\n"), 0o644); err != nil {
-		t.Fatalf("dirty: %v", err)
-	}
-
-	_, err := Revert(dir, target, false)
-	if err == nil {
-		t.Fatal("want error, got nil")
-	}
-	var de *DirtyError
-	if !errors.As(err, &de) {
-		t.Fatalf("want *DirtyError, got %T: %v", err, err)
-	}
-	if len(de.Files) == 0 {
-		t.Fatal("DirtyError.Files empty")
-	}
-	found := false
-	for _, f := range de.Files {
-		if f == "README" {
-			found = true
-			break
-		}
-	}
-	if !found {
-		t.Errorf("README not in dirty files: %v", de.Files)
-	}
-}
-
-func TestRevert_DirtyWithStash_Succeeds(t *testing.T) {
-	requireGit(t)
-	dir := newRepo(t)
-	commit(t, dir, "checkpoint: pre-yolo first")
-	target := headSHA(t, dir)
-	commit(t, dir, "feat: add stuff")
-
-	if err := os.WriteFile(filepath.Join(dir, "README"), []byte("dirty\n"), 0o644); err != nil {
-		t.Fatalf("dirty: %v", err)
-	}
-
-	res, err := Revert(dir, target, true)
-	if err != nil {
-		t.Fatalf("Revert: %v", err)
-	}
-	if !res.OK {
-		t.Error("OK = false")
-	}
-	if res.RevertedTo != target {
-		t.Errorf("RevertedTo = %q, want %q", res.RevertedTo, target)
-	}
-	if res.StashedAs == "" {
-		t.Error("StashedAs empty after stash")
-	}
-
-	stashList := mustOut(t, dir, "git", "stash", "list")
-	if !strings.Contains(stashList, "ctm-revert pre-") {
-		t.Errorf("stash entry missing in list:\n%s", stashList)
-	}
-
-	if got := headSHA(t, dir); got != target {
-		t.Errorf("HEAD = %s, want %s", got, target)
-	}
-}
-
-func TestRevert_CleanWorkdir_Succeeds(t *testing.T) {
-	requireGit(t)
-	dir := newRepo(t)
-	commit(t, dir, "checkpoint: pre-yolo first")
-	target := headSHA(t, dir)
-	commit(t, dir, "feat: add stuff")
-
-	res, err := Revert(dir, target, false)
-	if err != nil {
-		t.Fatalf("Revert: %v", err)
-	}
-	if !res.OK || res.RevertedTo != target {
-		t.Errorf("unexpected result: %+v", res)
-	}
-	if res.StashedAs != "" {
-		t.Errorf("StashedAs unexpectedly set: %q", res.StashedAs)
-	}
-	if got := headSHA(t, dir); got != target {
-		t.Errorf("HEAD = %s, want %s", got, target)
-	}
-}
-
-func TestRevert_NoGitDir_Errors(t *testing.T) {
-	requireGit(t)
-	dir := t.TempDir()
-	_, err := Revert(dir, "deadbeef", false)
-	if err == nil {
-		t.Fatal("want error for non-repo workdir")
-	}
-}
-
-func TestRevert_EmptySHA_Errors(t *testing.T) {
-	requireGit(t)
-	dir := newRepo(t)
-	if _, err := Revert(dir, "", false); err == nil {
-		t.Fatal("want error for empty sha")
-	}
-}
-
-func headSHA(t *testing.T, dir string) string {
-	t.Helper()
-	return strings.TrimSpace(mustOut(t, dir, "git", "rev-parse", "HEAD"))
-}
-
-func mustOut(t *testing.T, dir, name string, args ...string) string {
-	t.Helper()
-	cmd := exec.Command(name, args...)
-	cmd.Dir = dir
-	out, err := cmd.Output()
-	if err != nil {
-		t.Fatalf("%s %v: %v", name, args, err)
-	}
-	return string(out)
-}
diff --git a/internal/serve/ingest/export_test.go b/internal/serve/ingest/export_test.go
deleted file mode 100644
index 90d0ef3..0000000
--- a/internal/serve/ingest/export_test.go
+++ /dev/null
@@ -1,11 +0,0 @@
-package ingest
-
-import "time"
-
-// SetClockForTest replaces the projection's now() seam. Test-only:
-// declared in an _test.go file so it is not part of the public API.
-// Visible to external _test packages (e.g. ingest_test) so they can
-// fast-forward the tmux liveness TTL without sleeping.
-func SetClockForTest(p *Projection, now func() time.Time) {
-	p.now = now
-}
diff --git a/internal/serve/ingest/quota.go b/internal/serve/ingest/quota.go
deleted file mode 100644
index f8f90cb..0000000
--- a/internal/serve/ingest/quota.go
+++ /dev/null
@@ -1,416 +0,0 @@
-package ingest
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-	"log/slog"
-	"os"
-	"path/filepath"
-	"strings"
-	"sync"
-	"time"
-
-	"github.com/fsnotify/fsnotify"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-// QuotaIngester watches the directory where `cmd statusline` drops
-// per-session JSON snapshots (path templated by `{uuid}` in
-// CTM_STATUSLINE_DUMP) and republishes the aggregated quota state on
-// the hub.
-//
-// Two flavours of `quota_update` event are emitted:
-//
-//   - Global (`Session == ""`) carrying weekly + 5-hour rate limit
-//     percentages. Re-emitted whenever any dump file changes — these
-//     fields are not session-scoped, but they're easiest to refresh
-//     from the same payload.
-//   - Per-session (`Session == `) carrying the per-session
-//     `context_pct` derived from the `context_window.used_percentage`
-//     field. UUID → session name resolution goes through the
-//     Projection.
-//
-// QuotaIngester also implements the surface a `SessionEnricher` adapter
-// in server.go needs to populate the `context_pct` field on the
-// /api/sessions view.
-type QuotaIngester struct {
-	dir  string
-	proj *Projection
-	hub  *events.Hub
-
-	mu       sync.RWMutex
-	perSess  map[string]sessionQuota // keyed on human session name
-	global   globalQuota
-	hasGlbl  bool
-	hasAnySS bool
-}
-
-type sessionQuota struct {
-	contextPct int
-	// inputTokens / outputTokens are the session-cumulative
-	// total_{input,output}_tokens from the statusline payload —
-	// matches what the physical statusline renders with ↑ and ↓.
-	// Grows monotonically through the session.
-	inputTokens  int
-	outputTokens int
-	// cacheTokens is the sum of cache_creation_input_tokens +
-	// cache_read_input_tokens from current_usage. No session total
-	// is published for cache, so this moves with each turn.
-	cacheTokens int
-	at          time.Time
-}
-
-// SessionTokenSnapshot is the live per-session token view exposed to
-// the REST /api/sessions handler. Zero values indicate "unknown" —
-// callers gate on the bool from PerSessionSnapshot.
-type SessionTokenSnapshot struct {
-	ContextPct   int
-	InputTokens  int
-	OutputTokens int
-	CacheTokens  int
-}
-
-// globalQuota tracks the most recent rate-limit snapshot. Each field
-// has a `has*` companion so a partial statusline dump (e.g. a context-
-// only update mid-turn) doesn't clobber known-good values from a prior
-// full dump with zeros.
-type globalQuota struct {
-	weeklyPct         float64
-	fiveHrPct         float64
-	weeklyResetsAt    time.Time
-	fiveHrResetsAt    time.Time
-	hasWeeklyPct      bool
-	hasFiveHrPct      bool
-	hasWeeklyResetsAt bool
-	hasFiveHrResetsAt bool
-	at                time.Time
-}
-
-// NewQuotaIngester constructs an ingester rooted at dir. proj is used
-// to resolve UUID → session name; pass nil and per-session events will
-// not be published (global rate limits still are).
-func NewQuotaIngester(dir string, proj *Projection, hub *events.Hub) *QuotaIngester {
-	return &QuotaIngester{
-		dir:     dir,
-		proj:    proj,
-		hub:     hub,
-		perSess: make(map[string]sessionQuota),
-	}
-}
-
-// Run blocks until ctx is cancelled. Re-scans every file in dir on
-// startup so a freshly-spawned serve picks up state Claude wrote
-// minutes earlier; then watches for file events.
-func (q *QuotaIngester) Run(ctx context.Context) error {
-	if err := os.MkdirAll(q.dir, 0o700); err != nil {
-		return err
-	}
-	w, err := fsnotify.NewWatcher()
-	if err != nil {
-		return err
-	}
-	defer w.Close()
-	if err := w.Add(q.dir); err != nil {
-		return err
-	}
-
-	// Initial sweep: pick up any dump files that already exist.
-	entries, err := os.ReadDir(q.dir)
-	if err != nil && !errors.Is(err, os.ErrNotExist) {
-		return err
-	}
-	for _, e := range entries {
-		if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
-			continue
-		}
-		q.ingest(filepath.Join(q.dir, e.Name()))
-	}
-
-	for {
-		select {
-		case <-ctx.Done():
-			return nil
-		case ev, ok := <-w.Events:
-			if !ok {
-				return nil
-			}
-			if !strings.HasSuffix(ev.Name, ".json") {
-				continue
-			}
-			if ev.Op&(fsnotify.Write|fsnotify.Create) != 0 {
-				q.ingest(ev.Name)
-			}
-		case err, ok := <-w.Errors:
-			if !ok {
-				return nil
-			}
-			slog.Warn("quota ingest fsnotify error", "err", err)
-		}
-	}
-}
-
-// ingest reads one dump file, derives the per-session UUID from the
-// filename (or the payload's session_id field as fallback), updates
-// in-memory state, and publishes events on changes.
-func (q *QuotaIngester) ingest(path string) {
-	data, err := os.ReadFile(path)
-	if err != nil {
-		// File may have been removed between fsnotify and read; ignore.
-		return
-	}
-	if len(data) == 0 {
-		return
-	}
-	var raw struct {
-		SessionID     string `json:"session_id"`
-		ContextWindow struct {
-			UsedPercentage    *float64 `json:"used_percentage"`
-			TotalInputTokens  *int     `json:"total_input_tokens"`
-			TotalOutputTokens *int     `json:"total_output_tokens"`
-			CurrentUsage      struct {
-				CacheCreationInputTokens *int `json:"cache_creation_input_tokens"`
-				CacheReadInputTokens     *int `json:"cache_read_input_tokens"`
-			} `json:"current_usage"`
-		} `json:"context_window"`
-		RateLimits struct {
-			SevenDay struct {
-				UsedPercentage *float64 `json:"used_percentage"`
-				ResetsAt       *int64   `json:"resets_at"`
-			} `json:"seven_day"`
-			FiveHour struct {
-				UsedPercentage *float64 `json:"used_percentage"`
-				ResetsAt       *int64   `json:"resets_at"`
-			} `json:"five_hour"`
-		} `json:"rate_limits"`
-	}
-	if err := json.Unmarshal(data, &raw); err != nil {
-		return
-	}
-
-	uuid := raw.SessionID
-	if uuid == "" {
-		// Fall back to filename stem (the {uuid} template wrote it).
-		stem := strings.TrimSuffix(filepath.Base(path), ".json")
-		uuid = stem
-	}
-
-	q.mu.Lock()
-	prevGlobal := q.global
-	hadGlobal := q.hasGlbl
-	// Merge incoming fields onto prevGlobal — only overwrite fields
-	// the payload actually carried. Partial dumps must not clobber
-	// known-good rate-limit state with zeros.
-	gl := prevGlobal
-	gl.at = time.Now().UTC()
-	if p := raw.RateLimits.SevenDay.UsedPercentage; p != nil {
-		gl.weeklyPct = *p
-		gl.hasWeeklyPct = true
-	}
-	if p := raw.RateLimits.FiveHour.UsedPercentage; p != nil {
-		gl.fiveHrPct = *p
-		gl.hasFiveHrPct = true
-	}
-	if u := raw.RateLimits.SevenDay.ResetsAt; u != nil {
-		gl.weeklyResetsAt = time.Unix(*u, 0).UTC()
-		gl.hasWeeklyResetsAt = true
-	}
-	if u := raw.RateLimits.FiveHour.ResetsAt; u != nil {
-		gl.fiveHrResetsAt = time.Unix(*u, 0).UTC()
-		gl.hasFiveHrResetsAt = true
-	}
-
-	q.global = gl
-	// hasGlbl flips true the first time ANY rate-limit field is seen;
-	// stays true thereafter so accessors keep returning the cached
-	// value across context-only dumps.
-	if gl.hasWeeklyPct || gl.hasFiveHrPct {
-		q.hasGlbl = true
-	}
-	// Resolve UUID → session name for per-session publish.
-	var sessionName string
-	if q.proj != nil {
-		for _, s := range q.proj.All() {
-			if s.UUID == uuid {
-				sessionName = s.Name
-				break
-			}
-		}
-	}
-	var perSessChanged bool
-	if sessionName != "" {
-		ctxPct := 0
-		if p := raw.ContextWindow.UsedPercentage; p != nil {
-			ctxPct = int(*p + 0.5)
-		}
-		// Use the session-cumulative totals (what the statusline renders
-		// with ↑ and ↓). The current_usage per-turn counts are tiny
-		// (often <100) and never reach the "k" threshold, which reads
-		// as broken in the UI. Cache has no cumulative total in the
-		// payload, so it stays live (creation + read per turn).
-		in := derefInt(raw.ContextWindow.TotalInputTokens)
-		out := derefInt(raw.ContextWindow.TotalOutputTokens)
-		cache := derefInt(raw.ContextWindow.CurrentUsage.CacheCreationInputTokens) +
-			derefInt(raw.ContextWindow.CurrentUsage.CacheReadInputTokens)
-		prev, had := q.perSess[sessionName]
-		if !had ||
-			prev.contextPct != ctxPct ||
-			prev.inputTokens != in ||
-			prev.outputTokens != out ||
-			prev.cacheTokens != cache {
-			perSessChanged = true
-		}
-		q.perSess[sessionName] = sessionQuota{
-			contextPct:   ctxPct,
-			inputTokens:  in,
-			outputTokens: out,
-			cacheTokens:  cache,
-			at:           time.Now().UTC(),
-		}
-		q.hasAnySS = true
-	}
-	q.mu.Unlock()
-
-	// Only publish when we actually have rate-limit data AND something
-	// changed. A context-only dump with no rate_limits leaves
-	// hasWeeklyPct/hasFiveHrPct false and must NOT publish a fake
-	// quota_update with zeros (would flicker the UI bars).
-	rateLimitChanged := (gl.hasWeeklyPct && prevGlobal.weeklyPct != gl.weeklyPct) ||
-		(gl.hasFiveHrPct && prevGlobal.fiveHrPct != gl.fiveHrPct)
-	firstFullDump := !hadGlobal && (gl.hasWeeklyPct || gl.hasFiveHrPct)
-	if firstFullDump || rateLimitChanged {
-		q.publishGlobal(gl)
-	}
-	if perSessChanged && sessionName != "" {
-		q.publishSession(sessionName)
-	}
-}
-
-func (q *QuotaIngester) publishGlobal(g globalQuota) {
-	body, _ := json.Marshal(map[string]any{
-		"weekly_pct":        roundPct(g.weeklyPct),
-		"five_hr_pct":       roundPct(g.fiveHrPct),
-		"weekly_resets_at":  rfc3339OrEmpty(g.weeklyResetsAt),
-		"five_hr_resets_at": rfc3339OrEmpty(g.fiveHrResetsAt),
-	})
-	slog.Info("quota publish global",
-		"weekly_pct", roundPct(g.weeklyPct), "five_hr_pct", roundPct(g.fiveHrPct))
-	q.hub.Publish(events.Event{Type: "quota_update", Payload: body})
-}
-
-func (q *QuotaIngester) publishSession(name string) {
-	q.mu.RLock()
-	s := q.perSess[name]
-	q.mu.RUnlock()
-	body, _ := json.Marshal(map[string]any{
-		"session":       name,
-		"context_pct":   s.contextPct,
-		"input_tokens":  s.inputTokens,
-		"output_tokens": s.outputTokens,
-		"cache_tokens":  s.cacheTokens,
-	})
-	// Debug-level: fires on every statusline dump where any token
-	// field changed, which during active sessions is multiple times a
-	// second. Global-quota publishes stay at Info — they only move
-	// when rate-limit buckets actually shift.
-	slog.Debug("quota publish session",
-		"session", name, "context_pct", s.contextPct,
-		"input", s.inputTokens, "output", s.outputTokens, "cache", s.cacheTokens)
-	q.hub.Publish(events.Event{Type: "quota_update", Session: name, Payload: body})
-}
-
-// PerSessionSnapshot returns the live token view for a session, or
-// (_, false) if no statusline dump has been ingested for it yet.
-// Called by the api.SessionEnricher adapter so REST /api/sessions
-// renders token counts on first paint.
-func (q *QuotaIngester) PerSessionSnapshot(name string) (SessionTokenSnapshot, bool) {
-	q.mu.RLock()
-	defer q.mu.RUnlock()
-	s, ok := q.perSess[name]
-	if !ok {
-		return SessionTokenSnapshot{}, false
-	}
-	return SessionTokenSnapshot{
-		ContextPct:   s.contextPct,
-		InputTokens:  s.inputTokens,
-		OutputTokens: s.outputTokens,
-		CacheTokens:  s.cacheTokens,
-	}, true
-}
-
-func derefInt(p *int) int {
-	if p == nil {
-		return 0
-	}
-	return *p
-}
-
-// ContextPct returns the latest context_window.used_percentage seen
-// for the named session, rounded to a whole percent.
-func (q *QuotaIngester) ContextPct(name string) (int, bool) {
-	q.mu.RLock()
-	defer q.mu.RUnlock()
-	s, ok := q.perSess[name]
-	if !ok {
-		return 0, false
-	}
-	return s.contextPct, true
-}
-
-// WeeklyPct returns the latest 7-day rate limit percentage, if known.
-func (q *QuotaIngester) WeeklyPct() (float64, bool) {
-	q.mu.RLock()
-	defer q.mu.RUnlock()
-	if !q.hasGlbl {
-		return 0, false
-	}
-	return q.global.weeklyPct, true
-}
-
-// FiveHourPct returns the latest 5-hour rate limit percentage, if known.
-func (q *QuotaIngester) FiveHourPct() (float64, bool) {
-	q.mu.RLock()
-	defer q.mu.RUnlock()
-	if !q.hasGlbl {
-		return 0, false
-	}
-	return q.global.fiveHrPct, true
-}
-
-// GlobalSnapshot is the point-in-time rate-limit view exposed to the
-// REST /api/quota handler. Zero values indicate "unknown" (no dump has
-// populated that field yet); callers should gate on Known before
-// trusting the percentages.
-type GlobalSnapshot struct {
-	WeeklyPct       int
-	FiveHourPct     int
-	WeeklyResetsAt  time.Time
-	FiveHourResetAt time.Time
-	Known           bool
-}
-
-// Snapshot returns the current global rate-limit state under a single
-// read lock so the REST response is consistent (vs. calling
-// WeeklyPct/FiveHourPct separately and risking a torn read between a
-// concurrent ingest).
-func (q *QuotaIngester) Snapshot() GlobalSnapshot {
-	q.mu.RLock()
-	defer q.mu.RUnlock()
-	return GlobalSnapshot{
-		WeeklyPct:       roundPct(q.global.weeklyPct),
-		FiveHourPct:     roundPct(q.global.fiveHrPct),
-		WeeklyResetsAt:  q.global.weeklyResetsAt,
-		FiveHourResetAt: q.global.fiveHrResetsAt,
-		Known:           q.hasGlbl,
-	}
-}
-
-func roundPct(f float64) int { return int(f + 0.5) }
-
-func rfc3339OrEmpty(t time.Time) string {
-	if t.IsZero() {
-		return ""
-	}
-	return t.UTC().Format(time.RFC3339)
-}
diff --git a/internal/serve/ingest/quota_test.go b/internal/serve/ingest/quota_test.go
deleted file mode 100644
index 5618ba4..0000000
--- a/internal/serve/ingest/quota_test.go
+++ /dev/null
@@ -1,181 +0,0 @@
-package ingest
-
-import (
-	"context"
-	"encoding/json"
-	"os"
-	"path/filepath"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-	"github.com/RandomCodeSpace/ctm/internal/session"
-)
-
-const quotaTestUUID = "ffffffff-1111-2222-3333-444444444444"
-
-// newFakeProjection builds a Projection populated with a static slice
-// without spinning up its polling Run goroutine. We're in the same
-// package, so we can poke the unexported fields directly.
-func newFakeProjection(sess ...session.Session) *Projection {
-	p := New("/dev/null", nil)
-	p.mu.Lock()
-	p.sessions = append([]session.Session{}, sess...)
-	for _, s := range sess {
-		p.byName[s.Name] = s
-	}
-	p.mu.Unlock()
-	return p
-}
-
-func writePayload(t *testing.T, path string, body map[string]any) {
-	t.Helper()
-	data, err := json.Marshal(body)
-	if err != nil {
-		t.Fatalf("marshal: %v", err)
-	}
-	if err := os.WriteFile(path, data, 0o600); err != nil {
-		t.Fatalf("write: %v", err)
-	}
-}
-
-func TestQuotaIngester_GlobalEventOnInitialSweep(t *testing.T) {
-	dir := t.TempDir()
-	hub := events.NewHub(0)
-	sub, _ := hub.Subscribe("", "")
-	defer sub.Close()
-
-	pct1 := 34.0
-	pct2 := 21.0
-	writePayload(t, filepath.Join(dir, quotaTestUUID+".json"), map[string]any{
-		"session_id": quotaTestUUID,
-		"rate_limits": map[string]any{
-			"seven_day": map[string]any{"used_percentage": pct1},
-			"five_hour": map[string]any{"used_percentage": pct2},
-		},
-	})
-
-	q := NewQuotaIngester(dir, nil, hub)
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	go func() { _ = q.Run(ctx) }()
-
-	select {
-	case ev := <-sub.Events():
-		if ev.Type != "quota_update" {
-			t.Errorf("ev.Type = %q, want quota_update", ev.Type)
-		}
-		var body map[string]any
-		_ = json.Unmarshal(ev.Payload, &body)
-		if body["weekly_pct"] != float64(34) {
-			t.Errorf("weekly_pct = %v, want 34", body["weekly_pct"])
-		}
-		if body["five_hr_pct"] != float64(21) {
-			t.Errorf("five_hr_pct = %v, want 21", body["five_hr_pct"])
-		}
-	case <-time.After(2 * time.Second):
-		t.Fatal("no quota_update event from initial sweep")
-	}
-}
-
-func TestQuotaIngester_PerSessionContextEvent(t *testing.T) {
-	dir := t.TempDir()
-	hub := events.NewHub(0)
-
-	proj := newFakeProjection(session.Session{
-		Name: "alpha",
-		UUID: quotaTestUUID,
-	})
-
-	subAlpha, _ := hub.Subscribe("alpha", "")
-	defer subAlpha.Close()
-
-	q := NewQuotaIngester(dir, proj, hub)
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	go func() { _ = q.Run(ctx) }()
-	time.Sleep(50 * time.Millisecond)
-
-	ctxPct := 49.4
-	writePayload(t, filepath.Join(dir, quotaTestUUID+".json"), map[string]any{
-		"session_id": quotaTestUUID,
-		"context_window": map[string]any{
-			"used_percentage": ctxPct,
-		},
-	})
-
-	select {
-	case ev := <-subAlpha.Events():
-		if ev.Type != "quota_update" || ev.Session != "alpha" {
-			t.Errorf("ev = %+v, want type=quota_update session=alpha", ev)
-		}
-		var body map[string]any
-		_ = json.Unmarshal(ev.Payload, &body)
-		if body["context_pct"] != float64(49) {
-			t.Errorf("context_pct = %v, want 49 (rounded)", body["context_pct"])
-		}
-	case <-time.After(2 * time.Second):
-		t.Fatal("no per-session quota event")
-	}
-
-	if got, ok := q.ContextPct("alpha"); !ok || got != 49 {
-		t.Errorf("ContextPct(alpha) = %d,%v want 49,true", got, ok)
-	}
-}
-
-func TestQuotaIngester_NoChangeNoRepublish(t *testing.T) {
-	dir := t.TempDir()
-	hub := events.NewHub(0)
-	sub, _ := hub.Subscribe("", "")
-	defer sub.Close()
-
-	pct := 12.0
-	path := filepath.Join(dir, quotaTestUUID+".json")
-	writePayload(t, path, map[string]any{
-		"rate_limits": map[string]any{
-			"seven_day": map[string]any{"used_percentage": pct},
-			"five_hour": map[string]any{"used_percentage": pct},
-		},
-	})
-
-	q := NewQuotaIngester(dir, nil, hub)
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	go func() { _ = q.Run(ctx) }()
-
-	// Drain the initial event.
-	select {
-	case <-sub.Events():
-	case <-time.After(2 * time.Second):
-		t.Fatal("no initial event")
-	}
-
-	// Re-write identical contents — should NOT re-publish.
-	writePayload(t, path, map[string]any{
-		"rate_limits": map[string]any{
-			"seven_day": map[string]any{"used_percentage": pct},
-			"five_hour": map[string]any{"used_percentage": pct},
-		},
-	})
-
-	select {
-	case ev := <-sub.Events():
-		t.Errorf("unexpected republish: %+v", ev)
-	case <-time.After(300 * time.Millisecond):
-		// good — no event
-	}
-}
-
-func TestQuotaIngester_AccessorsReturnFalseUntilSeen(t *testing.T) {
-	q := NewQuotaIngester(t.TempDir(), nil, events.NewHub(0))
-	if _, ok := q.WeeklyPct(); ok {
-		t.Error("WeeklyPct should be false before first ingest")
-	}
-	if _, ok := q.FiveHourPct(); ok {
-		t.Error("FiveHourPct should be false before first ingest")
-	}
-	if _, ok := q.ContextPct("x"); ok {
-		t.Error("ContextPct(x) should be false before first ingest")
-	}
-}
-
diff --git a/internal/serve/ingest/sessions_proj.go b/internal/serve/ingest/sessions_proj.go
deleted file mode 100644
index d61d03d..0000000
--- a/internal/serve/ingest/sessions_proj.go
+++ /dev/null
@@ -1,209 +0,0 @@
-// Package ingest builds the in-memory projections that the serve API
-// reads from. The sessions projection mirrors ~/.config/ctm/sessions.json
-// (decoded leniently — serve is a consumer; the CLI owns strictness via
-// internal/jsonstrict) and exposes thread-safe accessors plus a tmux
-// liveness probe with a short TTL cache.
-//
-// Polling note: this initial implementation re-reads sessions.json on a
-// 1 s ticker whenever the file's mtime changes. Step 5 of the
-// ctm-serve plan will swap the polling loop for an fsnotify watcher.
-// The public API of Projection (New / Run / All / Get / TmuxAlive) will
-// not change when that swap happens.
-package ingest
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-	"log/slog"
-	"os"
-	"sync"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/session"
-)
-
-// TmuxClient is the narrow surface of *tmux.Client that the projection
-// depends on. Defined here so tests can supply a fake without spinning
-// up tmux.
-type TmuxClient interface {
-	HasSession(name string) bool
-}
-
-// tmuxAliveTTL is how long a positive or negative tmux liveness result
-// stays cached before the next probe. Trades some staleness for not
-// fork/exec-ing tmux on every API request.
-const tmuxAliveTTL = 5 * time.Second
-
-// pollInterval is how often Run wakes up to check the file's mtime.
-// Cheap (one stat call) and bounded — replaced by fsnotify in step 5.
-const pollInterval = 1 * time.Second
-
-type tmuxCacheEntry struct {
-	alive    bool
-	cachedAt time.Time
-}
-
-// Projection is an RWMutex-guarded in-memory snapshot of sessions.json
-// plus a tiny TTL cache for tmux liveness probes.
-type Projection struct {
-	path string
-	tmux TmuxClient
-
-	// now is a clock-injection seam used by tests to fast-forward the
-	// tmux liveness TTL without sleeping. Defaults to time.Now.
-	now func() time.Time
-
-	mu       sync.RWMutex
-	sessions []session.Session
-	byName   map[string]session.Session
-	mtime    time.Time
-
-	tmuxMu    sync.RWMutex
-	tmuxCache map[string]tmuxCacheEntry
-}
-
-// New constructs a Projection bound to path and the given tmux client.
-// Run must be called to populate the snapshot and keep it fresh.
-func New(path string, tmux TmuxClient) *Projection {
-	return &Projection{
-		path:      path,
-		tmux:      tmux,
-		now:       time.Now,
-		byName:    make(map[string]session.Session),
-		tmuxCache: make(map[string]tmuxCacheEntry),
-	}
-}
-
-// Reload synchronously re-reads sessions.json. Call once at startup
-// before iterating All() so the snapshot is populated before any
-// caller (e.g. serve's tailer-spawn loop) reads from it; otherwise
-// they race with Run's first refresh and see an empty list.
-func (p *Projection) Reload() {
-	p.refresh()
-}
-
-// Run loads the initial snapshot and then polls path's mtime every
-// pollInterval, re-reading on change. Returns nil when ctx is cancelled.
-func (p *Projection) Run(ctx context.Context) error {
-	p.refresh() // best-effort initial load; missing file is fine.
-
-	t := time.NewTicker(pollInterval)
-	defer t.Stop()
-	for {
-		select {
-		case <-ctx.Done():
-			return nil
-		case <-t.C:
-			p.refresh()
-		}
-	}
-}
-
-// refresh stats the file; if mtime changed (or first load), it
-// re-decodes leniently and atomically swaps in the new snapshot.
-func (p *Projection) refresh() {
-	info, err := os.Stat(p.path)
-	if err != nil {
-		if errors.Is(err, os.ErrNotExist) {
-			// Empty out the projection so callers see no sessions
-			// rather than stale ones if the file disappears.
-			p.mu.Lock()
-			if len(p.sessions) > 0 || len(p.byName) > 0 {
-				p.sessions = nil
-				p.byName = make(map[string]session.Session)
-				p.mtime = time.Time{}
-			}
-			p.mu.Unlock()
-			return
-		}
-		slog.Warn("sessions projection: stat failed", "path", p.path, "err", err)
-		return
-	}
-
-	p.mu.RLock()
-	prev := p.mtime
-	p.mu.RUnlock()
-	if !info.ModTime().After(prev) && !prev.IsZero() {
-		return
-	}
-
-	data, err := os.ReadFile(p.path)
-	if err != nil {
-		slog.Warn("sessions projection: read failed", "path", p.path, "err", err)
-		return
-	}
-
-	// Lenient decode: tolerate unknown fields and schema drift. This is
-	// the deliberate counterpart to the CLI's jsonstrict load — serve
-	// must keep working even if sessions.json gains new fields ahead of
-	// a serve rebuild.
-	var d diskShape
-	if err := json.Unmarshal(data, &d); err != nil {
-		slog.Warn("sessions projection: lenient decode failed", "path", p.path, "err", err)
-		return
-	}
-
-	list := make([]session.Session, 0, len(d.Sessions))
-	idx := make(map[string]session.Session, len(d.Sessions))
-	for _, s := range d.Sessions {
-		if s == nil {
-			continue
-		}
-		list = append(list, *s)
-		idx[s.Name] = *s
-	}
-
-	p.mu.Lock()
-	p.sessions = list
-	p.byName = idx
-	p.mtime = info.ModTime()
-	p.mu.Unlock()
-}
-
-// diskShape mirrors session.diskData but is lenient: no jsonstrict, and
-// any unknown top-level fields are ignored by encoding/json by default.
-type diskShape struct {
-	SchemaVersion int                          `json:"schema_version"`
-	Sessions      map[string]*session.Session  `json:"sessions"`
-}
-
-// All returns a defensive copy of the current snapshot. Callers may
-// mutate the returned slice without affecting the projection.
-func (p *Projection) All() []session.Session {
-	p.mu.RLock()
-	defer p.mu.RUnlock()
-	out := make([]session.Session, len(p.sessions))
-	copy(out, p.sessions)
-	return out
-}
-
-// Get returns the session with the given name, if known.
-func (p *Projection) Get(name string) (session.Session, bool) {
-	p.mu.RLock()
-	defer p.mu.RUnlock()
-	s, ok := p.byName[name]
-	return s, ok
-}
-
-// TmuxAlive reports whether tmux currently has a session matching name.
-// Results are cached for tmuxAliveTTL to avoid fork/exec on every API
-// request.
-func (p *Projection) TmuxAlive(name string) bool {
-	now := p.now()
-
-	p.tmuxMu.RLock()
-	entry, ok := p.tmuxCache[name]
-	p.tmuxMu.RUnlock()
-	if ok && now.Sub(entry.cachedAt) < tmuxAliveTTL {
-		return entry.alive
-	}
-
-	alive := p.tmux.HasSession(name)
-
-	p.tmuxMu.Lock()
-	p.tmuxCache[name] = tmuxCacheEntry{alive: alive, cachedAt: now}
-	p.tmuxMu.Unlock()
-
-	return alive
-}
diff --git a/internal/serve/ingest/sessions_proj_test.go b/internal/serve/ingest/sessions_proj_test.go
deleted file mode 100644
index 7d5ca23..0000000
--- a/internal/serve/ingest/sessions_proj_test.go
+++ /dev/null
@@ -1,239 +0,0 @@
-package ingest_test
-
-import (
-	"context"
-	"encoding/json"
-	"os"
-	"path/filepath"
-	"sync"
-	"sync/atomic"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/ingest"
-	"github.com/RandomCodeSpace/ctm/internal/session"
-)
-
-// fakeTmux records call counts per session name and returns a canned
-// alive value. Safe for concurrent use.
-type fakeTmux struct {
-	mu    sync.Mutex
-	calls map[string]int
-	alive map[string]bool
-}
-
-func newFakeTmux() *fakeTmux {
-	return &fakeTmux{calls: map[string]int{}, alive: map[string]bool{}}
-}
-
-func (f *fakeTmux) HasSession(name string) bool {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	f.calls[name]++
-	return f.alive[name]
-}
-
-func (f *fakeTmux) callCount(name string) int {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	return f.calls[name]
-}
-
-func (f *fakeTmux) setAlive(name string, alive bool) {
-	f.mu.Lock()
-	defer f.mu.Unlock()
-	f.alive[name] = alive
-}
-
-// writeSessionsFile serializes sessions in the same shape as
-// session.Store.save (schema_version + sessions map keyed by name) but
-// without going through the locked Store API.
-func writeSessionsFile(t *testing.T, path string, sessions ...*session.Session) {
-	t.Helper()
-	m := make(map[string]*session.Session, len(sessions))
-	for _, s := range sessions {
-		m[s.Name] = s
-	}
-	body := map[string]any{
-		"schema_version": session.SchemaVersion,
-		"sessions":       m,
-	}
-	data, err := json.Marshal(body)
-	if err != nil {
-		t.Fatalf("marshal sessions fixture: %v", err)
-	}
-	if err := os.WriteFile(path, data, 0600); err != nil {
-		t.Fatalf("write sessions fixture: %v", err)
-	}
-}
-
-func TestProjection_InitialLoad(t *testing.T) {
-	dir := t.TempDir()
-	path := filepath.Join(dir, "sessions.json")
-	s := session.New("alpha", "/work/alpha", "safe")
-	writeSessionsFile(t, path, s)
-
-	p := ingest.New(path, newFakeTmux())
-	ctx, cancel := context.WithCancel(context.Background())
-	t.Cleanup(cancel)
-
-	done := make(chan struct{})
-	go func() { _ = p.Run(ctx); close(done) }()
-
-	if !waitFor(t, 2*time.Second, func() bool {
-		_, ok := p.Get("alpha")
-		return ok
-	}) {
-		t.Fatal("projection never saw alpha after initial load")
-	}
-
-	all := p.All()
-	if len(all) != 1 || all[0].Name != "alpha" {
-		t.Fatalf("All() = %+v, want one [alpha]", all)
-	}
-	if all[0].UUID != s.UUID {
-		t.Errorf("UUID round-trip mismatch: got %q want %q", all[0].UUID, s.UUID)
-	}
-
-	cancel()
-	select {
-	case <-done:
-	case <-time.After(2 * time.Second):
-		t.Fatal("Run did not return after cancel")
-	}
-}
-
-func TestProjection_PicksUpRewrite(t *testing.T) {
-	dir := t.TempDir()
-	path := filepath.Join(dir, "sessions.json")
-	writeSessionsFile(t, path, session.New("alpha", "/work/alpha", "safe"))
-
-	// Force the first file's mtime into the past so the second write's
-	// mtime is guaranteed to be strictly greater on filesystems with
-	// 1 s mtime granularity.
-	past := time.Now().Add(-2 * time.Second)
-	if err := os.Chtimes(path, past, past); err != nil {
-		t.Fatalf("chtimes: %v", err)
-	}
-
-	p := ingest.New(path, newFakeTmux())
-	ctx, cancel := context.WithCancel(context.Background())
-	t.Cleanup(cancel)
-	go func() { _ = p.Run(ctx) }()
-
-	if !waitFor(t, 2*time.Second, func() bool {
-		_, ok := p.Get("alpha")
-		return ok
-	}) {
-		t.Fatal("projection never saw alpha")
-	}
-
-	// Second write — add beta, drop alpha.
-	writeSessionsFile(t, path, session.New("beta", "/work/beta", "yolo"))
-
-	if !waitFor(t, 3*time.Second, func() bool {
-		_, gotBeta := p.Get("beta")
-		_, gotAlpha := p.Get("alpha")
-		return gotBeta && !gotAlpha
-	}) {
-		t.Fatalf("projection did not re-read after rewrite; All=%v", p.All())
-	}
-}
-
-func TestProjection_LenientDecode_UnknownFields(t *testing.T) {
-	// Unknown top-level fields and unknown per-session fields must not
-	// break the projection (serve is a consumer; CLI owns strictness).
-	dir := t.TempDir()
-	path := filepath.Join(dir, "sessions.json")
-
-	body := []byte(`{
-		"schema_version": 1,
-		"future_top_level_field": "ignored",
-		"sessions": {
-			"alpha": {
-				"name": "alpha",
-				"uuid": "u-alpha",
-				"mode": "safe",
-				"workdir": "/work/alpha",
-				"created_at": "2026-04-20T12:34:56Z",
-				"future_per_session_field": {"x": 1}
-			}
-		}
-	}`)
-	if err := os.WriteFile(path, body, 0600); err != nil {
-		t.Fatalf("write: %v", err)
-	}
-
-	p := ingest.New(path, newFakeTmux())
-	ctx, cancel := context.WithCancel(context.Background())
-	t.Cleanup(cancel)
-	go func() { _ = p.Run(ctx) }()
-
-	if !waitFor(t, 2*time.Second, func() bool {
-		s, ok := p.Get("alpha")
-		return ok && s.UUID == "u-alpha"
-	}) {
-		t.Fatalf("lenient decode failed; All=%v", p.All())
-	}
-}
-
-func TestProjection_TmuxAliveTTLCache(t *testing.T) {
-	dir := t.TempDir()
-	path := filepath.Join(dir, "sessions.json")
-	writeSessionsFile(t, path, session.New("alpha", "/work/alpha", "safe"))
-
-	tx := newFakeTmux()
-	tx.setAlive("alpha", true)
-
-	p := ingest.New(path, tx)
-
-	// Inject a controllable clock. Use atomic.Pointer so the test can
-	// fast-forward without racing with TmuxAlive's reads.
-	var clock atomic.Pointer[time.Time]
-	now := time.Unix(1_700_000_000, 0)
-	clock.Store(&now)
-	ingest.SetClockForTest(p, func() time.Time { return *clock.Load() })
-
-	// First call → upstream probe.
-	if !p.TmuxAlive("alpha") {
-		t.Fatal("expected alpha to be alive on first probe")
-	}
-	if got := tx.callCount("alpha"); got != 1 {
-		t.Fatalf("expected 1 upstream call, got %d", got)
-	}
-
-	// Second call within TTL → cached, no new probe.
-	advance := now.Add(2 * time.Second)
-	clock.Store(&advance)
-	if !p.TmuxAlive("alpha") {
-		t.Fatal("expected cached alive=true")
-	}
-	if got := tx.callCount("alpha"); got != 1 {
-		t.Fatalf("expected still 1 upstream call after cache hit, got %d", got)
-	}
-
-	// Fast-forward beyond TTL → fresh probe.
-	advance = now.Add(6 * time.Second)
-	clock.Store(&advance)
-	if !p.TmuxAlive("alpha") {
-		t.Fatal("expected alive on refresh")
-	}
-	if got := tx.callCount("alpha"); got != 2 {
-		t.Fatalf("expected 2 upstream calls after TTL expiry, got %d", got)
-	}
-}
-
-// waitFor polls cond up to d, sleeping briefly between checks. Returns
-// true if cond became true. Used to bridge between Run's polling and
-// the test's synchronous assertions without flaky fixed sleeps.
-func waitFor(t *testing.T, d time.Duration, cond func() bool) bool {
-	t.Helper()
-	deadline := time.Now().Add(d)
-	for time.Now().Before(deadline) {
-		if cond() {
-			return true
-		}
-		time.Sleep(20 * time.Millisecond)
-	}
-	return cond()
-}
diff --git a/internal/serve/ingest/subagent.go b/internal/serve/ingest/subagent.go
deleted file mode 100644
index e70b0e4..0000000
--- a/internal/serve/ingest/subagent.go
+++ /dev/null
@@ -1,113 +0,0 @@
-// Package ingest — subagent event shapes (V15) + team aggregation
-// primitives (V16).
-//
-// The Claude Code JSONL transcripts do not carry dedicated
-// `subagent_start` / `subagent_stop` / `team_spawn` rows. Instead,
-// each tool_call row optionally carries top-level `agent_id` +
-// `agent_type` fields (appended by the PostToolUse hook when the tool
-// call was dispatched via the Agent/Task tool). V15 treats the first
-// occurrence of a given (session, agent_id) pair as the subagent's
-// start and the last occurrence as its stop.
-//
-// To keep the ingest path additive — existing tool_call parsing must
-// continue to work untouched per the V15/V16 brief — subagent
-// metadata is surfaced three ways:
-//
-//  1. Tailer emits a sibling `subagent_start` hub event the first
-//     time a given agent_id is seen on a session. The payload carries
-//     enough context for the UI to wake up and refetch
-//     /api/sessions/{name}/subagents (it does NOT try to be a
-//     self-contained tree — computing the full tree from a stream of
-//     live events would duplicate the replay logic and drift).
-//  2. The /subagents REST handler (api.Subagents) replays the session
-//     JSONL and groups rows by agent_id to produce the forest.
-//  3. The /teams REST handler (api.Teams) groups subagents that start
-//     within a bounded time window (see teamWindow below) into a
-//     single "team" — the closest primitive the real-world JSONL
-//     supports to the V16 brief's "agent_team" concept.
-//
-// Future work: if Claude Code starts emitting explicit
-// `subagent_start` / `subagent_stop` rows (different `hook_event_name`
-// or a dedicated field), update parseSubagentMeta to surface the
-// lifecycle directly rather than infer it from first/last tool_call.
-package ingest
-
-import (
-	"encoding/json"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-// SubagentMeta is the minimal envelope a single JSONL tool_call row
-// contributes to the subagent forest. All fields are best-effort;
-// ok=false from parseSubagentMeta signals "this row does not belong
-// to any subagent".
-type SubagentMeta struct {
-	AgentID   string    `json:"agent_id"`
-	AgentType string    `json:"agent_type"`
-	Tool      string    `json:"tool"`
-	Input     string    `json:"input,omitempty"`
-	IsError   bool      `json:"is_error"`
-	TS        time.Time `json:"ts"`
-}
-
-// SubagentStartPayload is the hub-event payload emitted once per
-// session+agent_id when the tailer first observes a new subagent. The
-// UI treats it purely as a wake-up signal — the full tree is fetched
-// via the REST endpoint.
-type SubagentStartPayload struct {
-	Session   string    `json:"session"`
-	AgentID   string    `json:"agent_id"`
-	AgentType string    `json:"agent_type"`
-	TS        time.Time `json:"ts"`
-}
-
-// parseSubagentMeta reads just the subagent-relevant fields out of a
-// raw JSONL line. Returns ok=false for any row that does not carry a
-// non-empty `agent_id`.
-//
-// Duplicated field-pulling rather than reusing tailer_parse helpers
-// so that (a) this path is cheap to run on every scanned line, and
-// (b) future changes to the feed-row summariser can't accidentally
-// change the subagent tree's `description` column.
-func parseSubagentMeta(line []byte) (SubagentMeta, bool) {
-	var raw map[string]any
-	if err := json.Unmarshal(line, &raw); err != nil {
-		return SubagentMeta{}, false
-	}
-	agentID, _ := raw["agent_id"].(string)
-	if agentID == "" {
-		return SubagentMeta{}, false
-	}
-	agentType, _ := raw["agent_type"].(string)
-	tool, _ := raw["tool_name"].(string)
-	return SubagentMeta{
-		AgentID:   agentID,
-		AgentType: agentType,
-		Tool:      tool,
-		Input:     summariseInput(raw),
-		IsError:   boolAt(raw, "tool_response", "is_error"),
-		TS:        parseTimestamp(raw),
-	}, true
-}
-
-// buildSubagentStartEvent wraps the meta in a hub-ready Event. Kept
-// separate from parseSubagentMeta so tests can exercise the two
-// halves independently.
-func buildSubagentStartEvent(sessionName string, meta SubagentMeta) (events.Event, error) {
-	body, err := json.Marshal(SubagentStartPayload{
-		Session:   sessionName,
-		AgentID:   meta.AgentID,
-		AgentType: meta.AgentType,
-		TS:        meta.TS,
-	})
-	if err != nil {
-		return events.Event{}, err
-	}
-	return events.Event{
-		Type:    "subagent_start",
-		Session: sessionName,
-		Payload: body,
-	}, nil
-}
diff --git a/internal/serve/ingest/tailer.go b/internal/serve/ingest/tailer.go
deleted file mode 100644
index ed44658..0000000
--- a/internal/serve/ingest/tailer.go
+++ /dev/null
@@ -1,240 +0,0 @@
-package ingest
-
-import (
-	"bufio"
-	"context"
-	"encoding/json"
-	"errors"
-	"io"
-	"log/slog"
-	"os"
-	"path/filepath"
-	"time"
-
-	"github.com/fsnotify/fsnotify"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-// Tailer watches the JSONL log file for a single Claude session and
-// publishes `tool_call` events to the hub for each appended line.
-//
-// Per the design spec (§4 Tailers), log files are keyed on Claude's
-// session UUID, not the human session name. The human name is stamped
-// into outgoing events so the UI can route by a stable, user-friendly
-// key while the file on disk follows Claude's identifier.
-type Tailer struct {
-	SessionName string
-	SessionUUID string
-	LogPath     string
-	Hub         *events.Hub
-}
-
-// NewTailer constructs a tailer for the given session. The log file is
-// assumed to live at `/.jsonl`.
-func NewTailer(sessionName, sessionUUID, logDir string, hub *events.Hub) *Tailer {
-	return &Tailer{
-		SessionName: sessionName,
-		SessionUUID: sessionUUID,
-		LogPath:     filepath.Join(logDir, sessionUUID+".jsonl"),
-		Hub:         hub,
-	}
-}
-
-// Run blocks until ctx is cancelled or a fatal fsnotify error occurs.
-// On startup it scans the file to EOF (if it already exists), then
-// waits for WRITE / CREATE / RENAME / REMOVE events on the parent
-// directory and reacts per spec §7 "Error handling per layer":
-//
-//   - WRITE: re-scan from last offset (not just "tail new bytes") —
-//     fsnotify can coalesce writes, so always catch up to EOF.
-//   - RENAME / REMOVE: close fd; wait for CREATE to reopen.
-//   - CREATE (after rotation or first appearance): reopen at offset 0.
-//
-// Parse errors on individual lines are logged and skipped — never fatal.
-func (t *Tailer) Run(ctx context.Context) error {
-	w, err := fsnotify.NewWatcher()
-	if err != nil {
-		return err
-	}
-	defer w.Close()
-
-	parent := filepath.Dir(t.LogPath)
-	if err := os.MkdirAll(parent, 0o700); err != nil {
-		return err
-	}
-	if err := w.Add(parent); err != nil {
-		return err
-	}
-
-	var (
-		fh     *os.File
-		offset int64
-	)
-
-	// subagentSeen tracks which agent_ids we've already fired
-	// `subagent_start` for on this tailer — used to keep the SSE
-	// signal idempotent when the tailer re-scans the same byte range
-	// after a transient fsnotify hiccup.
-	subagentSeen := make(map[string]bool)
-
-	openAndScan := func() {
-		f, err := os.Open(t.LogPath)
-		if err != nil {
-			if !errors.Is(err, os.ErrNotExist) {
-				slog.Warn("tailer open failed",
-					"session", t.SessionName, "path", t.LogPath, "err", err)
-			}
-			return
-		}
-		fh = f
-		offset = 0
-		scan(fh, &offset, t.SessionName, t.Hub, subagentSeen)
-	}
-
-	closeFile := func() {
-		if fh != nil {
-			_ = fh.Close()
-			fh = nil
-			offset = 0
-		}
-	}
-
-	openAndScan() // initial catch-up
-	defer closeFile()
-
-	for {
-		select {
-		case <-ctx.Done():
-			return nil
-		case ev, ok := <-w.Events:
-			if !ok {
-				return nil
-			}
-			if ev.Name != t.LogPath {
-				continue
-			}
-			switch {
-			case ev.Op&fsnotify.Write == fsnotify.Write:
-				if fh == nil {
-					openAndScan()
-				} else {
-					scan(fh, &offset, t.SessionName, t.Hub, subagentSeen)
-				}
-			case ev.Op&fsnotify.Create == fsnotify.Create:
-				closeFile()
-				openAndScan()
-			case ev.Op&fsnotify.Rename == fsnotify.Rename,
-				ev.Op&fsnotify.Remove == fsnotify.Remove:
-				closeFile()
-			}
-		case err, ok := <-w.Errors:
-			if !ok {
-				return nil
-			}
-			slog.Warn("tailer fsnotify error", "session", t.SessionName, "err", err)
-		}
-	}
-}
-
-// scan reads from *offset to EOF, parses each JSONL line, and publishes
-// a tool_call event per line. Advances *offset by bytes consumed.
-//
-// subagentSeen tracks already-announced agent_ids: whenever we see a
-// tool_call whose raw line carries an `agent_id` we haven't observed
-// yet for this session, we emit a sibling `subagent_start` event so
-// the UI's Subagents tab and Teams tab can wake up and refetch. Stop
-// events are not emitted live — completion is inferred server-side
-// from "no tool calls for N seconds" when the JSONL is replayed (see
-// api.Subagents).
-func scan(fh *os.File, offset *int64, sessionName string, hub *events.Hub, subagentSeen map[string]bool) {
-	if _, err := fh.Seek(*offset, io.SeekStart); err != nil {
-		slog.Warn("tailer seek failed", "session", sessionName, "err", err)
-		return
-	}
-	br := bufio.NewReaderSize(fh, 64<<10)
-	for {
-		line, err := br.ReadBytes('\n')
-		if len(line) > 0 {
-			*offset += int64(len(line))
-			trimmed := line
-			if trimmed[len(trimmed)-1] == '\n' {
-				trimmed = trimmed[:len(trimmed)-1]
-			}
-			if len(trimmed) == 0 {
-				continue
-			}
-			ev, perr := parseToolCallLine(sessionName, trimmed)
-			if perr != nil {
-				slog.Debug("tailer skipped malformed line",
-					"session", sessionName, "err", perr)
-				continue
-			}
-			hub.Publish(ev)
-
-			// Best-effort subagent_start detection. parseSubagentMeta
-			// decodes just the agent_id/agent_type/ctm_timestamp
-			// fields from the same raw line; if we've already seen
-			// this agent_id we skip the notification. Parse errors
-			// here are non-fatal — the upstream tool_call event was
-			// already published.
-			if subagentSeen != nil {
-				if meta, ok := parseSubagentMeta(trimmed); ok {
-					if !subagentSeen[meta.AgentID] {
-						subagentSeen[meta.AgentID] = true
-						if startEv, serr := buildSubagentStartEvent(sessionName, meta); serr == nil {
-							hub.Publish(startEv)
-						}
-					}
-				}
-			}
-		}
-		if err != nil {
-			if !errors.Is(err, io.EOF) {
-				slog.Warn("tailer read error", "session", sessionName, "err", err)
-			}
-			return
-		}
-	}
-}
-
-// ToolCallPayload is the JSON envelope published on the hub for each
-// tool invocation, matching §6 of the design spec exactly.
-type ToolCallPayload struct {
-	Session string    `json:"session"`
-	Tool    string    `json:"tool"`
-	Input   string    `json:"input,omitempty"`
-	Summary string    `json:"summary,omitempty"`
-	IsError bool      `json:"is_error"`
-	TS      time.Time `json:"ts"`
-}
-
-// parseToolCallLine turns a raw hook-payload JSON line into a hub Event.
-//
-// Hook payloads from `cmd/log-tool-use` are permissive (`map[string]any`),
-// so we tolerate missing/renamed fields rather than fail. Input and
-// Summary strings are best-effort summaries used by the UI's row
-// rendering; later steps can refine per-tool formatting.
-func parseToolCallLine(sessionName string, line []byte) (events.Event, error) {
-	var raw map[string]any
-	if err := json.Unmarshal(line, &raw); err != nil {
-		return events.Event{}, err
-	}
-	p := ToolCallPayload{
-		Session: sessionName,
-		Tool:    stringField(raw, "tool_name"),
-		IsError: boolAt(raw, "tool_response", "is_error"),
-		Input:   summariseInput(raw),
-		Summary: summariseResponse(raw),
-		TS:      parseTimestamp(raw),
-	}
-	body, err := json.Marshal(p)
-	if err != nil {
-		return events.Event{}, err
-	}
-	return events.Event{
-		Type:    "tool_call",
-		Session: sessionName,
-		Payload: body,
-	}, nil
-}
diff --git a/internal/serve/ingest/tailer_manager.go b/internal/serve/ingest/tailer_manager.go
deleted file mode 100644
index 0f60c5c..0000000
--- a/internal/serve/ingest/tailer_manager.go
+++ /dev/null
@@ -1,111 +0,0 @@
-package ingest
-
-import (
-	"context"
-	"sync"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-// TailerManager owns the lifecycle of one Tailer goroutine per active
-// session. Hooks (`session_new` → Start, `session_killed` → Stop) and
-// the startup reconciliation sweep both call into this manager.
-type TailerManager struct {
-	logDir string
-	hub    *events.Hub
-
-	mu      sync.Mutex
-	tailers map[string]*handle // keyed on human session name
-}
-
-type handle struct {
-	uuid   string
-	cancel context.CancelFunc
-	done   chan struct{}
-}
-
-// NewTailerManager constructs a manager rooted at logDir. logDir is
-// typically `~/.config/ctm/logs/`; tests pass a t.TempDir().
-func NewTailerManager(logDir string, hub *events.Hub) *TailerManager {
-	return &TailerManager{
-		logDir:  logDir,
-		hub:     hub,
-		tailers: make(map[string]*handle),
-	}
-}
-
-// Start spawns a tailer for (name, uuid) if one isn't already running
-// for that name. Re-calling with the same name and the same uuid is a
-// no-op; calling with a different uuid implicitly stops the prior
-// tailer first (rare — happens on uuid drift after recreation).
-func (m *TailerManager) Start(ctx context.Context, name, uuid string) {
-	if name == "" || uuid == "" {
-		return
-	}
-	m.mu.Lock()
-	defer m.mu.Unlock()
-
-	if h, ok := m.tailers[name]; ok {
-		if h.uuid == uuid {
-			return
-		}
-		m.stopLocked(name, h)
-	}
-
-	tCtx, cancel := context.WithCancel(ctx)
-	h := &handle{uuid: uuid, cancel: cancel, done: make(chan struct{})}
-	m.tailers[name] = h
-
-	t := NewTailer(name, uuid, m.logDir, m.hub)
-	go func() {
-		defer close(h.done)
-		_ = t.Run(tCtx)
-	}()
-}
-
-// Stop terminates the tailer for the named session, if any. Blocks
-// until the goroutine exits (ensures no late publishes after shutdown).
-func (m *TailerManager) Stop(name string) {
-	m.mu.Lock()
-	h, ok := m.tailers[name]
-	if !ok {
-		m.mu.Unlock()
-		return
-	}
-	m.stopLocked(name, h)
-	m.mu.Unlock()
-}
-
-// StopAll terminates every running tailer. Used during graceful
-// shutdown of the serve daemon.
-func (m *TailerManager) StopAll() {
-	m.mu.Lock()
-	names := make([]string, 0, len(m.tailers))
-	for name := range m.tailers {
-		names = append(names, name)
-	}
-	for _, name := range names {
-		if h, ok := m.tailers[name]; ok {
-			m.stopLocked(name, h)
-		}
-	}
-	m.mu.Unlock()
-}
-
-// Active reports the names of currently-running tailers (test helper /
-// debug aid).
-func (m *TailerManager) Active() []string {
-	m.mu.Lock()
-	defer m.mu.Unlock()
-	names := make([]string, 0, len(m.tailers))
-	for name := range m.tailers {
-		names = append(names, name)
-	}
-	return names
-}
-
-func (m *TailerManager) stopLocked(name string, h *handle) {
-	delete(m.tailers, name)
-	h.cancel()
-	<-h.done
-}
diff --git a/internal/serve/ingest/tailer_parse.go b/internal/serve/ingest/tailer_parse.go
deleted file mode 100644
index fa5796d..0000000
--- a/internal/serve/ingest/tailer_parse.go
+++ /dev/null
@@ -1,138 +0,0 @@
-package ingest
-
-import (
-	"encoding/json"
-	"fmt"
-	"strings"
-	"time"
-)
-
-// inputSummaryMax bounds the length of best-effort string summaries
-// emitted for the UI feed. Longer payloads are visible in detail
-// expansions (later UI step), not the row.
-const inputSummaryMax = 200
-
-// stringField returns m[key] as a string, or "" if missing / not a
-// string. JSON unmarshal stores everything as `any`, so this is the
-// cheapest accessor that survives schema drift.
-func stringField(m map[string]any, key string) string {
-	if v, ok := m[key].(string); ok {
-		return v
-	}
-	return ""
-}
-
-// boolAt walks a nested path of map keys and returns the leaf bool.
-// Missing intermediate keys, non-map values, or non-bool leaves all
-// yield false — matching the spec's lenient ingest contract.
-func boolAt(m map[string]any, path ...string) bool {
-	cur := any(m)
-	for _, k := range path {
-		mp, ok := cur.(map[string]any)
-		if !ok {
-			return false
-		}
-		cur, ok = mp[k]
-		if !ok {
-			return false
-		}
-	}
-	if b, ok := cur.(bool); ok {
-		return b
-	}
-	return false
-}
-
-// summariseInput produces a short, human-readable summary of a hook
-// payload's `tool_input` for the feed row. Per-tool conventions favour
-// the field most users associate with the tool (Bash → command, Edit
-// → file_path, etc.); unknown tools fall back to a JSON-encoded prefix.
-func summariseInput(raw map[string]any) string {
-	tool := stringField(raw, "tool_name")
-	in, ok := raw["tool_input"].(map[string]any)
-	if !ok {
-		return ""
-	}
-	switch tool {
-	case "Bash":
-		return truncate(stringField(in, "command"))
-	case "Edit", "Write", "Read", "MultiEdit", "NotebookEdit":
-		return truncate(stringField(in, "file_path"))
-	case "Glob":
-		return truncate(stringField(in, "pattern"))
-	case "Grep":
-		return truncate(stringField(in, "pattern"))
-	case "WebFetch":
-		return truncate(stringField(in, "url"))
-	case "Task":
-		return truncate(stringField(in, "description"))
-	}
-	body, err := json.Marshal(in)
-	if err != nil {
-		return ""
-	}
-	return truncate(string(body))
-}
-
-// summariseResponse extracts a one-line summary from `tool_response`.
-// Bash gets the exit-code line; for tools where the response is a
-// string we truncate it; everything else returns "".
-func summariseResponse(raw map[string]any) string {
-	resp, ok := raw["tool_response"]
-	if !ok {
-		return ""
-	}
-	switch r := resp.(type) {
-	case string:
-		return truncate(r)
-	case map[string]any:
-		if v, ok := r["output"].(string); ok {
-			return truncate(firstLine(v))
-		}
-		if isErr, _ := r["is_error"].(bool); isErr {
-			if msg, ok := r["error"].(string); ok {
-				return truncate(msg)
-			}
-			return "error"
-		}
-		// Fall through to a generic key list so the UI shows *something*.
-		keys := make([]string, 0, len(r))
-		for k := range r {
-			keys = append(keys, k)
-		}
-		if len(keys) == 0 {
-			return ""
-		}
-		return truncate(fmt.Sprintf("%v", keys))
-	}
-	return ""
-}
-
-// parseTimestamp prefers ctm's appended `ctm_timestamp` (always
-// RFC3339, written by `cmd/log-tool-use`); falls back to time.Now()
-// when absent or unparseable.
-func parseTimestamp(raw map[string]any) time.Time {
-	if s, ok := raw["ctm_timestamp"].(string); ok {
-		if t, err := time.Parse(time.RFC3339, s); err == nil {
-			return t
-		}
-	}
-	return time.Now().UTC()
-}
-
-func truncate(s string) string {
-	s = strings.TrimSpace(s)
-	if len(s) <= inputSummaryMax {
-		return s
-	}
-	// "…" is 3 bytes (U+2026), so trim to inputSummaryMax-3 to keep the
-	// total byte length at exactly inputSummaryMax.
-	return s[:inputSummaryMax-3] + "…"
-}
-
-func firstLine(s string) string {
-	if before, _, ok := strings.Cut(s, "\n"); ok {
-		return before
-	}
-	return s
-}
diff --git a/internal/serve/ingest/tailer_test.go b/internal/serve/ingest/tailer_test.go
deleted file mode 100644
index 720fd51..0000000
--- a/internal/serve/ingest/tailer_test.go
+++ /dev/null
@@ -1,266 +0,0 @@
-package ingest
-
-import (
-	"context"
-	"encoding/json"
-	"os"
-	"path/filepath"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-const tailerTestUUID = "11111111-2222-3333-4444-555555555555"
-
-func writeLine(t *testing.T, path string, payload map[string]any) {
-	t.Helper()
-	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
-	if err != nil {
-		t.Fatalf("open: %v", err)
-	}
-	defer f.Close()
-	body, err := json.Marshal(payload)
-	if err != nil {
-		t.Fatalf("marshal: %v", err)
-	}
-	if _, err := f.Write(append(body, '\n')); err != nil {
-		t.Fatalf("write: %v", err)
-	}
-}
-
-func awaitEvent(t *testing.T, sub *events.Sub, want time.Duration) (events.Event, bool) {
-	t.Helper()
-	select {
-	case e, ok := <-sub.Events():
-		return e, ok
-	case <-time.After(want):
-		return events.Event{}, false
-	}
-}
-
-func TestTailer_PublishesAppendedLines(t *testing.T) {
-	dir := t.TempDir()
-	hub := events.NewHub(0)
-	sub, _ := hub.Subscribe("alpha", "")
-	defer sub.Close()
-
-	tail := NewTailer("alpha", tailerTestUUID, dir, hub)
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	go func() { _ = tail.Run(ctx) }()
-
-	// Give the watcher a beat to register.
-	time.Sleep(50 * time.Millisecond)
-
-	logPath := filepath.Join(dir, tailerTestUUID+".jsonl")
-	writeLine(t, logPath, map[string]any{
-		"tool_name": "Bash",
-		"tool_input": map[string]any{
-			"command": "go test ./...",
-		},
-		"tool_response": map[string]any{
-			"output":   "ok\nPASS\n",
-			"is_error": false,
-		},
-		"ctm_timestamp": "2026-04-20T15:30:42Z",
-	})
-
-	ev, ok := awaitEvent(t, sub, 2*time.Second)
-	if !ok {
-		t.Fatal("no tool_call event published in 2s")
-	}
-	if ev.Type != "tool_call" {
-		t.Errorf("ev.Type = %q, want tool_call", ev.Type)
-	}
-	if ev.Session != "alpha" {
-		t.Errorf("ev.Session = %q, want alpha", ev.Session)
-	}
-	var p ToolCallPayload
-	if err := json.Unmarshal(ev.Payload, &p); err != nil {
-		t.Fatalf("unmarshal payload: %v", err)
-	}
-	if p.Tool != "Bash" {
-		t.Errorf("Tool = %q, want Bash", p.Tool)
-	}
-	if p.Input != "go test ./..." {
-		t.Errorf("Input = %q, want %q", p.Input, "go test ./...")
-	}
-	if p.IsError {
-		t.Error("IsError = true, want false")
-	}
-	if !p.TS.Equal(time.Date(2026, 4, 20, 15, 30, 42, 0, time.UTC)) {
-		t.Errorf("TS = %v, want 2026-04-20T15:30:42Z", p.TS)
-	}
-}
-
-func TestTailer_HandlesPreexistingFileOnStartup(t *testing.T) {
-	dir := t.TempDir()
-	logPath := filepath.Join(dir, tailerTestUUID+".jsonl")
-	// Pre-seed with two lines BEFORE starting the tailer.
-	for _, cmd := range []string{"ls", "pwd"} {
-		writeLine(t, logPath, map[string]any{
-			"tool_name":  "Bash",
-			"tool_input": map[string]any{"command": cmd},
-		})
-	}
-
-	hub := events.NewHub(0)
-	sub, _ := hub.Subscribe("a", "")
-	defer sub.Close()
-
-	tail := NewTailer("a", tailerTestUUID, dir, hub)
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	go func() { _ = tail.Run(ctx) }()
-
-	// Both pre-existing lines should be drained.
-	for i, want := range []string{"ls", "pwd"} {
-		ev, ok := awaitEvent(t, sub, 2*time.Second)
-		if !ok {
-			t.Fatalf("event %d: timeout", i)
-		}
-		var p ToolCallPayload
-		_ = json.Unmarshal(ev.Payload, &p)
-		if p.Input != want {
-			t.Errorf("event %d: Input = %q, want %q", i, p.Input, want)
-		}
-	}
-}
-
-func TestTailer_SkipsMalformedLines(t *testing.T) {
-	dir := t.TempDir()
-	logPath := filepath.Join(dir, tailerTestUUID+".jsonl")
-
-	hub := events.NewHub(0)
-	sub, _ := hub.Subscribe("a", "")
-	defer sub.Close()
-
-	tail := NewTailer("a", tailerTestUUID, dir, hub)
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	go func() { _ = tail.Run(ctx) }()
-	time.Sleep(50 * time.Millisecond)
-
-	// Mixed write: one valid + one corrupt + one valid.
-	f, err := os.OpenFile(logPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600)
-	if err != nil {
-		t.Fatalf("open: %v", err)
-	}
-	body, _ := json.Marshal(map[string]any{
-		"tool_name":  "Bash",
-		"tool_input": map[string]any{"command": "first"},
-	})
-	if _, err := f.Write(append(body, '\n')); err != nil {
-		t.Fatalf("write 1: %v", err)
-	}
-	if _, err := f.Write([]byte("not-json{\n")); err != nil {
-		t.Fatalf("write corrupt: %v", err)
-	}
-	body2, _ := json.Marshal(map[string]any{
-		"tool_name":  "Bash",
-		"tool_input": map[string]any{"command": "second"},
-	})
-	if _, err := f.Write(append(body2, '\n')); err != nil {
-		t.Fatalf("write 2: %v", err)
-	}
-	_ = f.Close()
-
-	for _, want := range []string{"first", "second"} {
-		ev, ok := awaitEvent(t, sub, 2*time.Second)
-		if !ok {
-			t.Fatalf("missing event for %q", want)
-		}
-		var p ToolCallPayload
-		_ = json.Unmarshal(ev.Payload, &p)
-		if p.Input != want {
-			t.Errorf("got %q, want %q", p.Input, want)
-		}
-	}
-}
-
-func TestTailerManager_StartIdempotentAndStop(t *testing.T) {
-	dir := t.TempDir()
-	hub := events.NewHub(0)
-	mgr := NewTailerManager(dir, hub)
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-
-	mgr.Start(ctx, "alpha", tailerTestUUID)
-	mgr.Start(ctx, "alpha", tailerTestUUID) // idempotent
-	if got := mgr.Active(); len(got) != 1 || got[0] != "alpha" {
-		t.Errorf("Active = %v, want [alpha]", got)
-	}
-
-	mgr.Stop("alpha")
-	if got := mgr.Active(); len(got) != 0 {
-		t.Errorf("Active after Stop = %v, want []", got)
-	}
-}
-
-func TestTailerManager_StopAll(t *testing.T) {
-	dir := t.TempDir()
-	hub := events.NewHub(0)
-	mgr := NewTailerManager(dir, hub)
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-
-	mgr.Start(ctx, "a", "uuid-a")
-	mgr.Start(ctx, "b", "uuid-b")
-	mgr.Start(ctx, "c", "uuid-c")
-	if got := len(mgr.Active()); got != 3 {
-		t.Fatalf("Active count = %d, want 3", got)
-	}
-	mgr.StopAll()
-	if got := len(mgr.Active()); got != 0 {
-		t.Errorf("Active after StopAll = %d, want 0", got)
-	}
-}
-
-func TestSummariseInput(t *testing.T) {
-	tests := []struct {
-		tool string
-		in   map[string]any
-		want string
-	}{
-		{"Bash", map[string]any{"command": "ls -la"}, "ls -la"},
-		{"Edit", map[string]any{"file_path": "/tmp/foo.go"}, "/tmp/foo.go"},
-		{"Read", map[string]any{"file_path": "/tmp/bar.txt"}, "/tmp/bar.txt"},
-		{"Glob", map[string]any{"pattern": "*.go"}, "*.go"},
-	}
-	for _, tt := range tests {
-		raw := map[string]any{"tool_name": tt.tool, "tool_input": tt.in}
-		got := summariseInput(raw)
-		if got != tt.want {
-			t.Errorf("summariseInput(%s) = %q, want %q", tt.tool, got, tt.want)
-		}
-	}
-}
-
-func TestTruncate_LongStringClipped(t *testing.T) {
-	long := make([]byte, 500)
-	for i := range long {
-		long[i] = 'a'
-	}
-	got := truncate(string(long))
-	if len(got) != inputSummaryMax {
-		t.Errorf("len = %d, want %d", len(got), inputSummaryMax)
-	}
-	if got[len(got)-1] != []byte("…")[2] {
-		// Last byte is the third byte of the U+2026 encoding; just
-		// assert the truncation marker is present.
-		if !endsWithEllipsis(got) {
-			t.Errorf("missing ellipsis: %q", got[len(got)-3:])
-		}
-	}
-}
-
-func endsWithEllipsis(s string) bool {
-	const e = "…"
-	if len(s) < len(e) {
-		return false
-	}
-	return s[len(s)-len(e):] == e
-}
diff --git a/internal/serve/pane_ready.go b/internal/serve/pane_ready.go
deleted file mode 100644
index 1b5ac09..0000000
--- a/internal/serve/pane_ready.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package serve
-
-import (
-	"context"
-	"strings"
-	"time"
-)
-
-// paneCapturer is the minimal surface waitForPaneReady needs — a
-// single CapturePane call that returns the current visible pane
-// contents (ANSI-preserving, same shape as *tmux.Client.CapturePane).
-type paneCapturer interface {
-	CapturePane(target string) (string, error)
-}
-
-// waitOpts carries the tunables + test seams for waitForPaneReady.
-// Zero values pick sensible production defaults.
-type waitOpts struct {
-	interval time.Duration
-	timeout  time.Duration
-	now      func() time.Time
-	sleep    func(time.Duration)
-}
-
-// defaults for waitOpts — chosen so SendInitialPrompt has the same
-// worst-case ceiling as a corporate-slow cold start, while a fast
-// machine returns in hundreds of ms instead of the old fixed 8s.
-const (
-	defaultPaneReadyInterval = 200 * time.Millisecond
-	defaultPaneReadyTimeout  = 15 * time.Second
-)
-
-// waitForPaneReady polls the named tmux target until two consecutive
-// captures are byte-identical AND non-empty (trimmed), signalling the
-// TUI has rendered and stopped churning. Returns nil on readiness,
-// context.DeadlineExceeded on timeout. Capture errors do not abort —
-// they reset the "previous" snapshot so we keep polling until the
-// budget runs out. The helper is deliberately dependency-light so
-// callers can inject fakes in tests.
-func waitForPaneReady(ctx context.Context, tmux paneCapturer, target string, opts waitOpts) error {
-	if opts.interval <= 0 {
-		opts.interval = defaultPaneReadyInterval
-	}
-	if opts.timeout <= 0 {
-		opts.timeout = defaultPaneReadyTimeout
-	}
-	if opts.now == nil {
-		opts.now = time.Now
-	}
-	if opts.sleep == nil {
-		opts.sleep = time.Sleep
-	}
-
-	deadline := opts.now().Add(opts.timeout)
-	var prev string
-	havePrev := false
-
-	for {
-		if err := ctx.Err(); err != nil {
-			return err
-		}
-
-		cur, err := tmux.CapturePane(target)
-		if err != nil {
-			// Transient capture failure (pane not yet attached, tmux
-			// racing). Drop any prior snapshot so we don't falsely
-			// match across an error, then keep polling.
-			havePrev = false
-			prev = ""
-		} else if havePrev && cur == prev && len(strings.TrimSpace(cur)) > 0 {
-			return nil
-		} else {
-			prev = cur
-			havePrev = true
-		}
-
-		if !opts.now().Before(deadline) {
-			return context.DeadlineExceeded
-		}
-		opts.sleep(opts.interval)
-		if !opts.now().Before(deadline) {
-			return context.DeadlineExceeded
-		}
-	}
-}
diff --git a/internal/serve/pane_ready_test.go b/internal/serve/pane_ready_test.go
deleted file mode 100644
index ea43034..0000000
--- a/internal/serve/pane_ready_test.go
+++ /dev/null
@@ -1,134 +0,0 @@
-package serve
-
-import (
-	"context"
-	"errors"
-	"testing"
-	"time"
-)
-
-// scriptedCapturer returns successive strings from a scripted slice.
-// Once the slice is exhausted it keeps returning the final entry
-// (mirrors a pane that has stabilised).
-type scriptedCapturer struct {
-	outputs []string
-	calls   int
-	err     error
-}
-
-func (s *scriptedCapturer) CapturePane(_ string) (string, error) {
-	idx := s.calls
-	s.calls++
-	if s.err != nil {
-		return "", s.err
-	}
-	if idx >= len(s.outputs) {
-		return s.outputs[len(s.outputs)-1], nil
-	}
-	return s.outputs[idx], nil
-}
-
-// churnCapturer returns a never-stable stream of distinct strings so
-// the helper can never see two consecutive identical captures.
-type churnCapturer struct {
-	calls int
-}
-
-func (c *churnCapturer) CapturePane(_ string) (string, error) {
-	c.calls++
-	// Each call returns a unique string.
-	return time.Unix(int64(c.calls), 0).String(), nil
-}
-
-// fakeClock produces a monotonically advancing time driven by sleep
-// calls, so tests don't actually block.
-type fakeClock struct {
-	now time.Time
-}
-
-func (f *fakeClock) Now() time.Time { return f.now }
-
-func (f *fakeClock) Sleep(d time.Duration) { f.now = f.now.Add(d) }
-
-func newFakeClock() *fakeClock {
-	return &fakeClock{now: time.Unix(1_700_000_000, 0)}
-}
-
-func TestWaitForPaneReady_ReadyQuickly(t *testing.T) {
-	clock := newFakeClock()
-	cap := &scriptedCapturer{outputs: []string{"claude> ", "claude> "}}
-
-	err := waitForPaneReady(context.Background(), cap, "sess", waitOpts{
-		interval: 200 * time.Millisecond,
-		timeout:  15 * time.Second,
-		now:      clock.Now,
-		sleep:    clock.Sleep,
-	})
-	if err != nil {
-		t.Fatalf("expected nil error, got %v", err)
-	}
-	if cap.calls != 2 {
-		t.Fatalf("expected 2 capture calls, got %d", cap.calls)
-	}
-}
-
-func TestWaitForPaneReady_StabilizesAfterChurn(t *testing.T) {
-	clock := newFakeClock()
-	cap := &scriptedCapturer{outputs: []string{
-		"booting",
-		"booting...",
-		"claude> ",
-		"claude> ",
-	}}
-
-	err := waitForPaneReady(context.Background(), cap, "sess", waitOpts{
-		interval: 200 * time.Millisecond,
-		timeout:  15 * time.Second,
-		now:      clock.Now,
-		sleep:    clock.Sleep,
-	})
-	if err != nil {
-		t.Fatalf("expected nil, got %v", err)
-	}
-	// First pair of identical non-empty captures arrives at call 4.
-	if cap.calls != 4 {
-		t.Fatalf("expected exactly 4 capture calls, got %d", cap.calls)
-	}
-}
-
-func TestWaitForPaneReady_EmptyNeverReady(t *testing.T) {
-	clock := newFakeClock()
-	cap := &scriptedCapturer{outputs: []string{"", "", ""}}
-
-	err := waitForPaneReady(context.Background(), cap, "sess", waitOpts{
-		interval: 200 * time.Millisecond,
-		timeout:  1 * time.Second,
-		now:      clock.Now,
-		sleep:    clock.Sleep,
-	})
-	if !errors.Is(err, context.DeadlineExceeded) {
-		t.Fatalf("expected DeadlineExceeded, got %v", err)
-	}
-	// Must have made at least a handful of attempts across the budget.
-	if cap.calls < 3 {
-		t.Fatalf("expected multiple capture attempts, got %d", cap.calls)
-	}
-}
-
-func TestWaitForPaneReady_Timeout(t *testing.T) {
-	clock := newFakeClock()
-	cap := &churnCapturer{}
-
-	err := waitForPaneReady(context.Background(), cap, "sess", waitOpts{
-		interval: 200 * time.Millisecond,
-		timeout:  1 * time.Second,
-		now:      clock.Now,
-		sleep:    clock.Sleep,
-	})
-	if !errors.Is(err, context.DeadlineExceeded) {
-		t.Fatalf("expected DeadlineExceeded, got %v", err)
-	}
-	if cap.calls < 3 {
-		t.Fatalf("expected multiple capture attempts during churn, got %d", cap.calls)
-	}
-}
diff --git a/internal/serve/proc/spawn.go b/internal/serve/proc/spawn.go
deleted file mode 100644
index 69cc389..0000000
--- a/internal/serve/proc/spawn.go
+++ /dev/null
@@ -1,137 +0,0 @@
-// Package proc provides the CLI-side glue between session-creating
-// commands (`ctm attach`, `ctm new`, `ctm yolo`, etc.) and the local
-// `ctm serve` daemon: a fire-and-forget spawner that ensures serve is
-// up, and a tiny HTTP client that POSTs lifecycle events to its
-// /api/hooks/:event endpoint.
-//
-// Both helpers are best-effort. Serve is observability — failures
-// here log at WARN/DEBUG and never block the user-visible CLI flow.
-package proc
-
-import (
-	"context"
-	"io"
-	"log/slog"
-	"net/http"
-	"net/url"
-	"os"
-	"os/exec"
-	"strings"
-	"syscall"
-	"time"
-)
-
-const (
-	// serveAddr is the loopback address `ctm serve` binds. Mirrors
-	// `internal/serve.DefaultPort`; not imported to avoid the larger
-	// dep cone (proc must stay light — it's hot-pathed by the CLI).
-	serveAddr = "127.0.0.1:37778"
-
-	probeTimeout = 200 * time.Millisecond
-	spawnDeadline = 2 * time.Second
-	postTimeout  = 1 * time.Second
-)
-
-// EnsureServeRunning probes /healthz; if no `ctm serve` listens, it
-// spawns one as a detached child via setsid and waits up to 2 s for
-// readiness, then returns. The caller never blocks past 2 s — if
-// serve isn't up by then, subsequent PostEvent calls degrade silently.
-func EnsureServeRunning(ctx context.Context) {
-	if probeServe() {
-		return
-	}
-	if err := spawnDetached(); err != nil {
-		slog.Warn("failed to spawn ctm serve", "err", err)
-		return
-	}
-	deadline := time.Now().Add(spawnDeadline)
-	for time.Now().Before(deadline) {
-		if probeServe() {
-			return
-		}
-		select {
-		case <-ctx.Done():
-			return
-		case <-time.After(75 * time.Millisecond):
-		}
-	}
-	slog.Debug("ctm serve not ready within readiness window; continuing without it")
-}
-
-// PostEvent fires a hook event to the local serve daemon. event must
-// match one of the names whitelisted in `internal/serve/api.Hooks`
-// (session_new / session_attached / session_killed / on_yolo). The
-// form is sent as `application/x-www-form-urlencoded`. No bearer token
-// is needed — /api/hooks/* is unauthed (daemon binds 127.0.0.1 only).
-func PostEvent(event string, form url.Values) {
-	body := strings.NewReader(form.Encode())
-	req, err := http.NewRequest(http.MethodPost,
-		"http://"+serveAddr+"/api/hooks/"+event, body)
-	if err != nil {
-		return
-	}
-	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-
-	client := &http.Client{Timeout: postTimeout}
-	resp, err := client.Do(req)
-	if err != nil {
-		slog.Debug("PostEvent failed", "event", event, "err", err)
-		return
-	}
-	defer func() {
-		_, _ = io.Copy(io.Discard, resp.Body)
-		_ = resp.Body.Close()
-	}()
-	if resp.StatusCode >= 400 {
-		slog.Debug("PostEvent non-2xx", "event", event, "status", resp.StatusCode)
-	}
-}
-
-// serveVersionHeader mirrors internal/serve.ServeVersionHeader.
-// Inlined here to avoid importing the larger internal/serve package
-// (proc must stay light — it's hot-pathed by every CLI invocation).
-const serveVersionHeader = "X-Ctm-Serve"
-
-// probeServe verifies that the listener on serveAddr is a real ctm
-// serve daemon, NOT just any process returning 200 on /healthz. The
-// X-Ctm-Serve header check defends against a local-uid impostor
-// binding 127.0.0.1:37778 before the real daemon starts.
-func probeServe() bool {
-	client := &http.Client{Timeout: probeTimeout}
-	resp, err := client.Get("http://" + serveAddr + "/healthz")
-	if err != nil {
-		return false
-	}
-	defer func() {
-		_, _ = io.Copy(io.Discard, resp.Body)
-		_ = resp.Body.Close()
-	}()
-	if resp.StatusCode != http.StatusOK {
-		return false
-	}
-	return resp.Header.Get(serveVersionHeader) != ""
-}
-
-// spawnDetached launches `ctm serve` as a detached child. stdout/stderr
-// are routed to the same descriptors the parent had — `serve.log`
-// rotation is the daemon's job (handled inside `cmd serve` if/when
-// wired). For now nil descriptors mean inherited (typically /dev/tty
-// or a pipe); a future polish pass can swap for a logrotate sink.
-func spawnDetached() error {
-	bin, err := os.Executable()
-	if err != nil {
-		return err
-	}
-	cmd := exec.Command(bin, "serve")
-	cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
-	// Detach stdio so the parent's terminal isn't disturbed by serve's
-	// slog output. /dev/null on POSIX.
-	devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
-	if err == nil {
-		cmd.Stdin = devNull
-		cmd.Stdout = devNull
-		cmd.Stderr = devNull
-	}
-	return cmd.Start()
-}
-
diff --git a/internal/serve/server.go b/internal/serve/server.go
deleted file mode 100644
index 7c6c56e..0000000
--- a/internal/serve/server.go
+++ /dev/null
@@ -1,1079 +0,0 @@
-// Package serve runs the ctm web UI HTTP daemon (`ctm serve`).
-//
-// v0.1 scope: auth-gated REST + SSE endpoints on loopback:37778,
-// backed by an in-memory hub, a read-through sessions projection
-// over ~/.config/ctm/sessions.json, and per-workdir git checkpoint
-// / revert handlers.
-package serve
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"log/slog"
-	"net"
-	"net/http"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/config"
-	"github.com/RandomCodeSpace/ctm/internal/serve/api"
-	"github.com/RandomCodeSpace/ctm/internal/serve/attention"
-	"github.com/RandomCodeSpace/ctm/internal/serve/auth"
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-	"github.com/RandomCodeSpace/ctm/internal/serve/ingest"
-	"github.com/RandomCodeSpace/ctm/internal/serve/store"
-	"github.com/RandomCodeSpace/ctm/internal/serve/webhook"
-	"github.com/RandomCodeSpace/ctm/internal/session"
-	"github.com/RandomCodeSpace/ctm/internal/tmux"
-)
-
-const (
-	// DefaultPort is the loopback port ctm serve binds by default.
-	DefaultPort = 37778
-
-	// ServeVersionHeader is set on /healthz responses so a second
-	// process can identify a live sibling daemon portably (no /proc).
-	ServeVersionHeader = "X-Ctm-Serve"
-
-	probeTimeout  = 200 * time.Millisecond
-	shutdownGrace = 10 * time.Second
-
-	// jsonlExt is the per-session claude history file suffix.
-	jsonlExt = ".jsonl"
-)
-
-// ErrAlreadyRunning is returned by New when another `ctm serve` already
-// owns the port (detected via the X-Ctm-Serve header on /healthz).
-// Callers should treat it as silent success.
-var ErrAlreadyRunning = errors.New("ctm serve already running on this port")
-
-// Options configures a Server.
-type Options struct {
-	Port    int
-	Version string
-
-	// Token, if non-empty, is pre-seeded into the in-memory session store
-	// at startup so tests can authenticate without going through signup/
-	// login. Production code leaves this empty.
-	Token string
-
-	// SessionsPath overrides the path serve watches for the sessions
-	// projection. Empty means config.SessionsPath(). Primarily a test
-	// seam.
-	SessionsPath string
-
-	// TmuxConfPath overrides the tmux client's conf path. Empty means
-	// config.TmuxConfPath(). Primarily a test seam.
-	TmuxConfPath string
-
-	// LogDir overrides the directory the JSONL tailer manager watches.
-	// Empty means filepath.Join(config.Dir(), "logs"). Test seam.
-	LogDir string
-
-	// StatuslineDumpDir overrides the directory the quota ingester
-	// watches for `cmd statusline` per-session JSON dumps. Empty
-	// means /tmp/ctm-statusline (per design spec §4 default).
-	StatuslineDumpDir string
-
-	// WebhookURL enables the webhook dispatcher. Empty → disabled.
-	// HasWebhook in /api/bootstrap is derived from this.
-	WebhookURL string
-
-	// WebhookAuth, if non-empty, is sent verbatim in the Authorization
-	// header on each POST (e.g. "Bearer abc123").
-	WebhookAuth string
-
-	// AttentionThresholds overrides the built-in defaults for the
-	// attention engine's seven triggers. A zero-valued Thresholds falls
-	// back to attention.Defaults().
-	AttentionThresholds attention.Thresholds
-
-	// Config is the already-loaded user config, threaded through so
-	// the /api/doctor handler can report on required_env /
-	// required_in_path without re-reading from disk. Zero value is
-	// safe — the doctor runner treats unset fields as "not
-	// configured" rather than failing.
-	Config config.Config
-}
-
-// Server is the ctm serve HTTP daemon.
-type Server struct {
-	opts      Options
-	listener  net.Listener
-	http      *http.Server
-	startedAt time.Time
-
-	// requestCtx is the parent context every incoming HTTP request
-	// inherits via http.Server.BaseContext. Cancelling it on shutdown
-	// kicks long-lived SSE handlers out of their `<-r.Context().Done()`
-	// select so http.Shutdown's grace deadline can complete cleanly.
-	requestCtx    context.Context
-	requestCancel context.CancelFunc
-
-	// runCancel, set by Run, cancels the root context driving all
-	// background goroutines. Shutdown() triggers it so in-process
-	// callers (e.g. PATCH /api/config) can bring the daemon down
-	// without a signal.
-	runCancel context.CancelFunc
-
-	sessions     *auth.Store
-	hub          *events.Hub
-	proj         *ingest.Projection
-	tailers      *ingest.TailerManager
-	quota        *ingest.QuotaIngester
-	cpCache      *api.CheckpointsCache
-	attention    *attention.Engine
-	webhook      *webhook.Dispatcher
-	tmuxClient   *tmux.Client
-	sessionStore *session.Store
-	cost         store.CostStore
-	logDir       string
-}
-
-// Shutdown cancels the daemon's root context so Run(ctx) returns and
-// callers that want to respawn (e.g. config save → restart) can do so
-// via proc.EnsureServeRunning. Safe to call more than once; no-op if
-// Run hasn't started or has already returned.
-func (s *Server) Shutdown(reason string) {
-	slog.Info("ctm serve shutdown requested", "reason", reason)
-	if s.runCancel != nil {
-		s.runCancel()
-	}
-}
-
-// New binds the listener, loads the bearer token, constructs the hub
-// and sessions projection, and wires routes. See DefaultPort, single-
-// instance guard semantics in the package doc.
-func New(opts Options) (*Server, error) {
-	if opts.Port == 0 {
-		opts.Port = DefaultPort
-	}
-
-	sessions := auth.NewStore()
-	if opts.Token != "" {
-		// Test seam: pre-seed the session store so tests can authenticate
-		// without going through the signup/login flow.
-		sessions.Seed(opts.Token, "test")
-	}
-
-	addr := fmt.Sprintf("127.0.0.1:%d", opts.Port)
-	ln, err := net.Listen("tcp", addr)
-	if err != nil {
-		if isAddrInUse(err) {
-			if probeIsCtmServe(addr) {
-				return nil, ErrAlreadyRunning
-			}
-			return nil, fmt.Errorf("port %d in use by a non-ctm-serve listener; refusing to bind", opts.Port)
-		}
-		return nil, fmt.Errorf("bind %s: %w", addr, err)
-	}
-
-	sessionsPath := opts.SessionsPath
-	if sessionsPath == "" {
-		sessionsPath = config.SessionsPath()
-	}
-	tmuxConf := opts.TmuxConfPath
-	if tmuxConf == "" {
-		tmuxConf = config.TmuxConfPath()
-	}
-	logDir := opts.LogDir
-	if logDir == "" {
-		logDir = filepath.Join(config.Dir(), "logs")
-	}
-	dumpDir := opts.StatuslineDumpDir
-	if dumpDir == "" {
-		dumpDir = "/tmp/ctm-statusline"
-	}
-
-	hub := events.NewHub(0)
-	tmuxClient := tmux.NewClient(tmuxConf)
-	sessionStore := session.NewStore(sessionsPath)
-
-	// V13 cost store: persists per-session token/cost history so the
-	// dashboard chart survives daemon restarts. WAL mode + batched tx
-	// inserts keep the write path off the hub's hot loop.
-	costDB, err := store.OpenCostStore(filepath.Join(config.Dir(), "ctm.db"))
-	if err != nil {
-		_ = ln.Close()
-		return nil, fmt.Errorf("open cost db: %w", err)
-	}
-
-	proj := ingest.New(sessionsPath, tmuxClient)
-	quota := ingest.NewQuotaIngester(dumpDir, proj, hub)
-	cpCache := api.NewCheckpointsCache()
-
-	// Attention engine: subscribes to hub, evaluates the seven triggers,
-	// re-publishes attention_raised/_cleared on transitions. Wired into
-	// quotaEnricher.Attention so /api/sessions surfaces the current
-	// alert. Runs for the full daemon lifetime.
-	thr := opts.AttentionThresholds
-	if thr == (attention.Thresholds{}) {
-		thr = attention.Defaults()
-	}
-	attEngine := attention.NewEngine(
-		hub,
-		quota,
-		sessionSourceAdapter{proj: proj, cpCache: cpCache},
-		thr,
-		nil,
-	)
-
-	// Webhook dispatcher: POSTs attention_raised events to a user-
-	// configured URL with 3× exponential retry and 60 s debounce per
-	// (session, alert). URL empty → Run returns immediately without
-	// subscribing (dispatcher disabled).
-	disp := webhook.NewDispatcher(
-		hub,
-		sessionResolverAdapter{proj: proj},
-		webhook.Config{
-			URL:        opts.WebhookURL,
-			AuthHeader: opts.WebhookAuth,
-			UIBaseURL:  fmt.Sprintf("http://127.0.0.1:%d", opts.Port),
-		},
-		nil,
-	)
-
-	reqCtx, reqCancel := context.WithCancel(context.Background())
-	s := &Server{
-		opts:          opts,
-		listener:      ln,
-		startedAt:     time.Now(),
-		requestCtx:    reqCtx,
-		requestCancel: reqCancel,
-		sessions:      sessions,
-		hub:           hub,
-		proj:          proj,
-		tailers:       ingest.NewTailerManager(logDir, hub),
-		quota:         quota,
-		cpCache:       cpCache,
-		attention:     attEngine,
-		webhook:       disp,
-		tmuxClient:    tmuxClient,
-		sessionStore:  sessionStore,
-		cost:          costDB,
-		logDir:        logDir,
-	}
-
-	mux := http.NewServeMux()
-	s.registerRoutes(mux)
-
-	s.http = &http.Server{
-		Handler:           mux,
-		ReadHeaderTimeout: 5 * time.Second,
-		BaseContext:       func(net.Listener) context.Context { return reqCtx },
-	}
-	return s, nil
-}
-
-// Addr returns the bound listener address.
-func (s *Server) Addr() string { return s.listener.Addr().String() }
-
-// Hub returns the server's event hub. Exposed for tests and, in later
-// steps, for ingest layers that publish events from the same process.
-func (s *Server) Hub() *events.Hub { return s.hub }
-
-// Run blocks until ctx is cancelled, then gracefully shuts down.
-// Returns nil on clean shutdown; propagates non-ErrServerClosed errors.
-func (s *Server) Run(ctx context.Context) error {
-	// Wrap the caller's ctx so Shutdown() can trigger the same
-	// cascading-cancel path that a parent SIGINT would.
-	ctx, runCancel := context.WithCancel(ctx)
-	s.runCancel = runCancel
-	defer runCancel()
-
-	// Synchronous initial projection load before the polling goroutine
-	// starts; the tailer-spawn loop below depends on All() being
-	// populated, otherwise it sees an empty list and no tailers fire
-	// for sessions that were already running when serve booted.
-	s.proj.Reload()
-
-	projCtx, projCancel := context.WithCancel(ctx)
-	defer projCancel()
-	projDone := make(chan error, 1)
-	go func() { projDone <- s.proj.Run(projCtx) }()
-
-	quotaCtx, quotaCancel := context.WithCancel(ctx)
-	defer quotaCancel()
-	quotaDone := make(chan error, 1)
-	go func() { quotaDone <- s.quota.Run(quotaCtx) }()
-
-	attCtx, attCancel := context.WithCancel(ctx)
-	defer attCancel()
-	attDone := make(chan error, 1)
-	go func() { attDone <- s.attention.Run(attCtx) }()
-
-	whCtx, whCancel := context.WithCancel(ctx)
-	defer whCancel()
-	whDone := make(chan error, 1)
-	go func() { whDone <- s.webhook.Run(whCtx) }()
-
-	// V13 cost subscriber: writes per-session token/cost rows every
-	// time the quota ingester publishes a fresh triple. The goroutine
-	// exits when costCtx cancels; we wait on costDone in the shutdown
-	// sequence so the final batch lands before the DB closes.
-	costCtx, costCancel := context.WithCancel(ctx)
-	defer costCancel()
-	costDone := make(chan struct{})
-	go func() {
-		defer close(costDone)
-		store.SubscribeQuotaWriter(costCtx, s.hub, s.cost, nil)
-	}()
-
-	// V19 slice 3 FTS subscriber: indexes every tool_call payload into
-	// the SQLite FTS5 table. OpenCostStore wipes the index on boot, so
-	// the tailer's offset-0 replay repopulates it cleanly.
-	ftsCtx, ftsCancel := context.WithCancel(ctx)
-	defer ftsCancel()
-	ftsDone := make(chan struct{})
-	go func() {
-		defer close(ftsDone)
-		if idx, ok := s.cost.(store.ToolCallIndexer); ok {
-			store.SubscribeToolCallWriter(ftsCtx, s.hub, idx, nil)
-		}
-	}()
-
-	// Tailer adoption: scan the JSONL log directory and spawn a tailer
-	// for every UUID we find a log file for. The log files are the
-	// ground truth (claude writes them via the log-tool-use hook
-	// regardless of what sessions.json says); the sessions projection
-	// is just metadata. Resolving UUID → human session name from the
-	// projection is best-effort — when no match exists we use a
-	// "uuid:" placeholder so the UI still surfaces the activity
-	// rather than silently dropping it.
-	tailerCtx, tailerCancel := context.WithCancel(ctx)
-	defer tailerCancel()
-	uuidToName, claudeDirToName := buildSessionMaps(s.proj.All())
-	claudeProjectsRoot := ""
-	if home, err := os.UserHomeDir(); err == nil {
-		claudeProjectsRoot = filepath.Join(home, ".claude", "projects")
-	}
-	// Each tmux session can accumulate many jsonls in s.logDir (one per
-	// claude conversation in that workdir over time). The tailer manager
-	// keys on session NAME, so calling Start() for the same name with
-	// different UUIDs replaces the prior tailer — meaning the last UUID
-	// iterated wins. os.ReadDir returns entries alphabetically, which
-	// means the order is unrelated to recency, so we'd often end up
-	// glued to a stale UUID while claude writes to a fresh one. Group
-	// log files by resolved session name and pick the freshest per name.
-	type tailCand struct {
-		uuid  string
-		mtime time.Time
-	}
-	freshest := make(map[string]tailCand) // session name → freshest log
-	orphanUUIDs := make([]string, 0)
-	adoptedViaWorkdir := 0
-	if entries, err := os.ReadDir(s.logDir); err == nil {
-		for _, e := range entries {
-			if e.IsDir() || !strings.HasSuffix(e.Name(), jsonlExt) {
-				continue
-			}
-			uuid := strings.TrimSuffix(e.Name(), jsonlExt)
-			name, viaFallback, ok := resolveLogUUIDToName(uuid, uuidToName, claudeDirToName, claudeProjectsRoot)
-			if !ok {
-				orphanUUIDs = append(orphanUUIDs, uuid)
-				continue
-			}
-			if viaFallback {
-				adoptedViaWorkdir++
-			}
-			info, infoErr := e.Info()
-			if infoErr != nil {
-				continue
-			}
-			cand := tailCand{uuid: uuid, mtime: info.ModTime()}
-			if existing, found := freshest[name]; !found || cand.mtime.After(existing.mtime) {
-				freshest[name] = cand
-			}
-		}
-	}
-	for name, cand := range freshest {
-		s.tailers.Start(tailerCtx, name, cand.uuid)
-	}
-	for _, uuid := range orphanUUIDs {
-		short := uuid
-		if len(short) > 8 {
-			short = short[:8]
-		}
-		s.tailers.Start(tailerCtx, "uuid:"+short, uuid)
-	}
-	slog.Info("ctm serve tailers started",
-		"sessions_in_projection", len(s.proj.All()),
-		"tailers_started", len(freshest)+len(orphanUUIDs),
-		"adopted_via_workdir", adoptedViaWorkdir,
-		"orphan_uuids", len(orphanUUIDs))
-
-	// Periodic rescan: claude rotates to a new UUID jsonl whenever a new
-	// conversation starts inside an existing tmux session, but the tailer
-	// manager only reads the projection at startup. Without this loop,
-	// the daemon stays glued to the previous UUID's file (which claude
-	// no longer writes to) and the UI shows stale activity. Re-running
-	// the freshest-per-name selection every 30s and calling Start() —
-	// which is a no-op when the UUID hasn't changed — keeps tailers in
-	// sync with whatever conversation claude is writing to right now.
-	go func() {
-		ticker := time.NewTicker(30 * time.Second)
-		defer ticker.Stop()
-		for {
-			select {
-			case <-tailerCtx.Done():
-				return
-			case <-ticker.C:
-				s.rescanTailers(tailerCtx, claudeProjectsRoot)
-			}
-		}
-	}()
-
-	serveDone := make(chan error, 1)
-	go func() { serveDone <- s.http.Serve(s.listener) }()
-
-	slog.Info("ctm serve started",
-		"addr", s.listener.Addr().String(),
-		"version", s.opts.Version)
-
-	select {
-	case err := <-serveDone:
-		projCancel()
-		<-projDone
-		quotaCancel()
-		<-quotaDone
-		attCancel()
-		<-attDone
-		whCancel()
-		<-whDone
-		costCancel()
-		<-costDone
-		ftsCancel()
-		<-ftsDone
-		_ = s.cost.Close()
-		s.tailers.StopAll()
-		if errors.Is(err, http.ErrServerClosed) {
-			return nil
-		}
-		return err
-	case <-ctx.Done():
-		slog.Info("ctm serve shutting down")
-		// Cancel BaseContext first so SSE handlers' r.Context().Done()
-		// fires; otherwise their long-lived goroutines keep the
-		// connection in StateActive and Shutdown blocks until the
-		// grace deadline.
-		s.requestCancel()
-		shutCtx, cancel := context.WithTimeout(context.Background(), shutdownGrace)
-		defer cancel()
-		shutErr := s.http.Shutdown(shutCtx)
-		projCancel()
-		<-projDone
-		quotaCancel()
-		<-quotaDone
-		attCancel()
-		<-attDone
-		whCancel()
-		<-whDone
-		costCancel()
-		<-costDone
-		ftsCancel()
-		<-ftsDone
-		_ = s.cost.Close()
-		s.tailers.StopAll()
-		err := <-serveDone
-		if shutErr != nil {
-			return shutErr
-		}
-		if errors.Is(err, http.ErrServerClosed) {
-			return nil
-		}
-		return err
-	}
-}
-
-// rescanTailers re-runs the freshest-per-name adoption pass against
-// s.logDir, calling Start() for each mapped session. TailerManager.Start
-// is idempotent when the UUID hasn't changed and rotates cleanly when
-// it has, so calling it on every tick is safe even if nothing's moved.
-// Orphan UUIDs are not (re-)registered here — they only get tailers at
-// startup; a new claude conversation's UUID becomes mappable as soon as
-// the projection picks up the session_new hook event.
-func (s *Server) rescanTailers(ctx context.Context, claudeProjectsRoot string) {
-	uuidToName, claudeDirToName := buildSessionMaps(s.proj.All())
-	type tailCand struct {
-		uuid  string
-		mtime time.Time
-	}
-	freshest := make(map[string]tailCand)
-	entries, err := os.ReadDir(s.logDir)
-	if err != nil {
-		return
-	}
-	for _, e := range entries {
-		if e.IsDir() || !strings.HasSuffix(e.Name(), jsonlExt) {
-			continue
-		}
-		uuid := strings.TrimSuffix(e.Name(), jsonlExt)
-		name, _, ok := resolveLogUUIDToName(uuid, uuidToName, claudeDirToName, claudeProjectsRoot)
-		if !ok {
-			continue
-		}
-		info, infoErr := e.Info()
-		if infoErr != nil {
-			continue
-		}
-		cand := tailCand{uuid: uuid, mtime: info.ModTime()}
-		if existing, found := freshest[name]; !found || cand.mtime.After(existing.mtime) {
-			freshest[name] = cand
-		}
-	}
-	for name, cand := range freshest {
-		s.tailers.Start(ctx, name, cand.uuid)
-	}
-}
-
-// buildSessionMaps walks a sessions snapshot and returns:
-//   - uuidToName: claude session_id (UUID) → session.Name
-//   - claudeDirToName: Claude's projects-directory encoding of the
-//     workdir (`/home/dev/projects/ctm` → `-home-dev-projects-ctm`) →
-//     session.Name. Used as a fallback so orphan UUIDs from prior claude
-//     sessions in the same workdir still get routed to the right ring.
-//
-// Both maps are pre-sized from the input slice. Sessions with empty
-// UUID or empty Workdir are skipped from the corresponding map.
-func buildSessionMaps(sessions []session.Session) (uuidToName, claudeDirToName map[string]string) {
-	uuidToName = make(map[string]string, len(sessions))
-	claudeDirToName = make(map[string]string, len(sessions))
-	for _, sess := range sessions {
-		if sess.UUID != "" {
-			uuidToName[sess.UUID] = sess.Name
-		}
-		if sess.Workdir != "" {
-			claudeDirToName[strings.ReplaceAll(sess.Workdir, "/", "-")] = sess.Name
-		}
-	}
-	return uuidToName, claudeDirToName
-}
-
-// resolveLogUUIDToName maps a JSONL log file's UUID (filename minus
-// .jsonl) to a managed session.Name. Lookup order:
-//
-//  1. Direct match in uuidToName.
-//  2. Fallback: glob `~/.claude/projects/*/.jsonl` (parameterised
-//     via claudeProjectsRoot for testability) — if exactly one match
-//     exists, the parent directory name is looked up in claudeDirToName.
-//
-// viaFallback reports whether the resolution required the
-// claude-projects fallback (used by Server.Run to count
-// `adopted_via_workdir` for the structured boot log). When ok is false
-// the caller treats the UUID as orphan.
-func resolveLogUUIDToName(uuid string, uuidToName, claudeDirToName map[string]string, claudeProjectsRoot string) (name string, viaFallback bool, ok bool) {
-	if name, found := uuidToName[uuid]; found {
-		return name, false, true
-	}
-	if claudeProjectsRoot == "" {
-		return "", false, false
-	}
-	matches, _ := filepath.Glob(filepath.Join(claudeProjectsRoot, "*", uuid+jsonlExt))
-	if len(matches) != 1 {
-		return "", false, false
-	}
-	mapped, found := claudeDirToName[filepath.Base(filepath.Dir(matches[0]))]
-	if !found {
-		return "", false, false
-	}
-	return mapped, true, true
-}
-
-func (s *Server) registerRoutes(mux *http.ServeMux) {
-	// authHF wraps h so that every request carries a valid session
-	// token (V27). Existing mux.Handle(..., authHF(h)) callsites
-	// don't need changes.
-	authHF := func(h http.HandlerFunc) http.Handler {
-		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			tok := api.BearerFromRequest(r)
-			if tok == "" {
-				writeJSONAuthErr(w, http.StatusUnauthorized, "missing_token")
-				return
-			}
-			user, ok := s.sessions.Lookup(tok)
-			if !ok {
-				writeJSONAuthErr(w, http.StatusUnauthorized, "invalid_token")
-				return
-			}
-			r = r.WithContext(auth.WithUser(r.Context(), user))
-			h(w, r)
-		})
-	}
-
-	// Unauthenticated: liveness only. Registered without a method
-	// prefix so the handler's own method check (returns 405 for non-
-	// GET/HEAD) sees the request rather than letting non-GET fall
-	// through to the / catch-all and accidentally serve 200 HTML.
-	mux.HandleFunc("/healthz", api.Healthz(s.opts.Version, ServeVersionHeader, s.startedAt))
-
-	// Authenticated JSON endpoints.
-	mux.Handle("GET /health", authHF(api.Health(s.opts.Version, ServeVersionHeader, s.startedAt, hubStatsAdapter{s.hub})))
-	mux.Handle("GET /api/bootstrap", authHF(api.Bootstrap(s.opts.Version, s.opts.Port, s.opts.WebhookURL != "")))
-	// Diagnostics (V20) — mirrors `ctm doctor` CLI over JSON. Handler
-	// enforces its own 5 s timeout on the runner so a pathological box
-	// can't hang the response.
-	mux.Handle("GET /api/doctor", authHF(api.Doctor(api.DefaultDoctorRunner(s.opts.Config))))
-
-	enricher := quotaEnricher{quota: s.quota, attention: s.attention}
-	mux.Handle("GET /api/sessions", authHF(api.List(s.proj, enricher)))
-	mux.Handle("GET /api/sessions/{name}", authHF(api.Get(s.proj, enricher)))
-	// Quota REST fallback so global rate-limit bars render on first
-	// paint without waiting for the next SSE-delivered value change
-	// (hub.Subscribe returns no replay when Last-Event-ID is empty).
-	mux.Handle("GET /api/quota", authHF(api.Quota(quotaSourceAdapter{s.quota})))
-
-	resolveWorkdir := func(name string) (string, bool) {
-		sess, ok := s.proj.Get(name)
-		if !ok {
-			return "", false
-		}
-		return sess.Workdir, true
-	}
-	// Shared 5 s checkpoint cache: the /checkpoints handler and the
-	// revert SHA-allowlist check both read through the same cache,
-	// preventing a rapid-revert client from spinning up unbounded
-	// `git log` subprocesses. Also consumed by the attention engine
-	// (trigger G yolo_unchecked).
-	allowedSHA := func(name, sha string) bool {
-		wd, ok := resolveWorkdir(name)
-		if !ok {
-			return false
-		}
-		// Full SHA equality only; abbreviated SHAs are intentionally
-		// rejected (see api.CheckpointsCache.IsCheckpoint comment).
-		return s.cpCache.IsCheckpoint(wd, name, sha)
-	}
-	mux.Handle("GET /api/sessions/{name}/checkpoints", authHF(api.Checkpoints(resolveWorkdir, s.cpCache)))
-	// V18 standalone diff viewer: unified diff for a single checkpoint
-	// SHA, guarded by the same 5 s cache + full-SHA allowlist as
-	// /revert. Response is text/plain so the UI can render it in a
-	// 
 without JSON-envelope overhead.
-	mux.Handle("GET /api/sessions/{name}/checkpoints/{sha}/diff", authHF(api.Diff(resolveWorkdir, s.cpCache)))
-	mux.Handle("POST /api/sessions/{name}/revert", authHF(api.Revert(resolveWorkdir, allowedSHA)))
-	// Feed REST seed — parallel to /api/quota. Global ring and per-
-	// session variant; both emit tool_call payloads only. See
-	// api/feed.go for shape.
-	mux.Handle("GET /api/feed", authHF(api.Feed(s.hub, "")))
-	mux.Handle("GET /api/sessions/{name}/feed", authHF(api.Feed(s.hub, "")))
-
-	// Hook intake from `proc.PostEvent`; spawns / stops tailers as a
-	// side-effect of session_new / session_killed. No auth wrapper —
-	// the daemon binds 127.0.0.1 only, so callers are implicitly
-	// local-uid peers; the X-Ctm-Serve probe in proc.probeServe is the
-	// anti-impostor signal and is sufficient for this fire-and-forget path.
-	mux.Handle("POST /api/hooks/{event}", api.Hooks(s.tailers, s.hub))
-
-	// V21 log disk usage. Walks the JSONL log dir and reports bytes per
-	// session + total so users can notice when it's time to prune.
-	// Read-only — no deletion verbs on this endpoint.
-	mux.Handle("GET /api/logs/usage", authHF(api.LogsUsage(s.logDir, logsUUIDResolver{proj: s.proj})))
-
-	// V19 slice 3 (v0.3): FTS5-backed full-text search over indexed
-	// tool_call content. Rebuilt on each boot from the tailer's
-	// V13 cumulative cost chart. Pulls from the SQLite cost store; adapter
-	// below copies store.CostPoint → api.CostPoint to keep the api package
-	// free of the store dependency.
-	mux.Handle("GET /api/cost", authHF(api.Cost(costSourceAdapter{s.cost})))
-
-	// V15/V16 — subagent tree + agent teams. Both replay the session's
-	// JSONL to infer lifecycle (parseSubagentMeta → start; last-tool-
-	// call-ts → stop/running; is_error → failed). Teams are a 2 s
-	// dispatch-window heuristic until Claude Code emits explicit
-	// team events.
-	mux.Handle("GET /api/sessions/{name}/subagents",
-		authHF(api.Subagents(s.logDir, logsUUIDResolver{proj: s.proj})))
-	mux.Handle("GET /api/sessions/{name}/teams",
-		authHF(api.Teams(s.logDir, logsUUIDResolver{proj: s.proj})))
-
-	// V6 historical feed scroll. Returns tool_call rows older than a
-	// cursor by reading backwards over the session's JSONL log, so the
-	// UI's Load-older button can walk past the 500-slot hub ring.
-	mux.Handle(
-		"GET /api/sessions/{name}/feed/history",
-		authHF(api.FeedHistory(s.logDir, logsUUIDResolver{proj: s.proj})),
-	)
-
-	// V9 inline Edit/Write diff viewer — looks up a single tool_call
-	// row by its hub event ID and returns a rendered unified diff when
-	// the tool is Edit/MultiEdit/Write.
-	mux.Handle(
-		"GET /api/sessions/{name}/tool_calls/{id}/detail",
-		authHF(api.ToolCallDetail(api.NewJSONLLogReader(s.logDir, s.proj))),
-	)
-
-	// V24 live tmux pane capture. 1 Hz SSE of `tmux capture-pane -e -p`
-	// output; frames are debounced on identical payloads so idle panes
-	// stay quiet. The handler exits when the client disconnects.
-	mux.Handle("GET /events/session/{name}/pane", authHF(api.PaneStream(s.tmuxClient)))
-
-	// Settings drawer (webhook URL + attention thresholds). GET returns
-	// the current config; PATCH applies a subset and triggers a daemon
-	// restart so the new config takes effect on the next user action.
-	mux.Handle("GET /api/config", authHF(api.ConfigGet(config.ConfigPath())))
-	mux.Handle("PATCH /api/config", authHF(api.ConfigUpdate(config.ConfigPath(), s.Shutdown)))
-
-	// V23 mutation endpoints: bearer + Origin-allowlist + type-to-confirm
-	// (for destructive ones) per docs/v02/V23-mutation-auth.md (A+B+D).
-	// Extra origins for reverse-proxy / tunnel deployments are sourced
-	// from (a) CTM_ALLOWED_ORIGINS env var (comma-separated, useful for
-	// one-off tests) and (b) ~/.config/ctm/allowed_origins file (one per
-	// line, blank/`#` lines ignored — persists across reloads). The
-	// loopback pair from DefaultAllowedOrigins is always included.
-	allowedOrigins := api.DefaultAllowedOrigins(s.opts.Port)
-	if extra := os.Getenv("CTM_ALLOWED_ORIGINS"); extra != "" {
-		for _, o := range strings.Split(extra, ",") {
-			if o = strings.TrimSpace(o); o != "" {
-				allowedOrigins = append(allowedOrigins, o)
-			}
-		}
-	}
-	if raw, err := os.ReadFile(config.AllowedOriginsPath()); err == nil {
-		for _, line := range strings.Split(string(raw), "\n") {
-			line = strings.TrimSpace(line)
-			if line == "" || strings.HasPrefix(line, "#") {
-				continue
-			}
-			allowedOrigins = append(allowedOrigins, line)
-		}
-	}
-	// V27 auth routes. /api/auth/status is intentionally unauthenticated
-	// so the UI can probe it from any context. Signup/login are Origin-
-	// gated to prevent CSRF. Logout requires a valid session.
-	mux.Handle("GET /api/auth/status", api.AuthStatus(s.sessions))
-	mux.Handle("POST /api/auth/signup", api.RequireOriginFunc(allowedOrigins, api.AuthSignup(s.sessions)))
-	loginLimiter := auth.NewLimiter(5, 60*time.Second)
-	mux.Handle("POST /api/auth/login", api.RequireOriginFunc(allowedOrigins, api.AuthLogin(s.sessions, loginLimiter)))
-	mux.Handle("POST /api/auth/logout", authHF(api.AuthLogout(s.sessions)))
-
-	mux.Handle("POST /api/sessions/{name}/kill",
-		authHF(api.RequireOriginFunc(allowedOrigins, api.Kill(s.sessionStore, s.tmuxClient, s.proj))))
-	mux.Handle("POST /api/sessions/{name}/forget",
-		authHF(api.RequireOriginFunc(allowedOrigins, api.Forget(s.sessionStore, s.proj))))
-	mux.Handle("POST /api/sessions/{name}/rename",
-		authHF(api.RequireOriginFunc(allowedOrigins, api.Rename(s.sessionStore, s.tmuxClient, s.proj))))
-	mux.Handle("GET /api/sessions/{name}/attach-url",
-		authHF(api.RequireOriginFunc(allowedOrigins, api.AttachURL())))
-	mux.Handle("POST /api/sessions/{name}/input",
-		authHF(api.RequireOriginFunc(allowedOrigins,
-			api.Input(inputSessionSource{proj: s.proj}, s.tmuxClient))))
-	mux.Handle("POST /api/sessions",
-		authHF(api.RequireOriginFunc(allowedOrigins,
-			api.CreateSession(
-				inputSessionSource{proj: s.proj},
-				createSpawner{store: s.sessionStore, tmux: s.tmuxClient},
-				execLookPath{}))))
-
-	// Debug: hub counters + subscriber count. Gated on auth; useful
-	// from curl to check whether publishes are flowing and whether
-	// the browser is actually subscribed.
-	mux.Handle("GET /debug/hub", authHF(func(w http.ResponseWriter, _ *http.Request) {
-		w.Header().Set("Content-Type", "application/json")
-		w.Header().Set("Cache-Control", "no-store")
-		_ = json.NewEncoder(w).Encode(s.hub.Stats())
-	}))
-
-	// SSE.
-	mux.Handle("GET /events/all", authHF(events.Handler(s.hub, "")))
-	mux.Handle("GET /events/session/{name}", authHF(func(w http.ResponseWriter, r *http.Request) {
-		events.Handler(s.hub, r.PathValue("name"))(w, r)
-	}))
-
-	// Placeholder UI at / ; returns 404 for unknown /api/ and /events/
-	// paths so future routes can claim those prefixes cleanly.
-	mux.Handle("/", assetHandler())
-}
-
-func writeJSONAuthErr(w http.ResponseWriter, status int, code string) {
-	w.Header().Set("Content-Type", "application/json")
-	w.WriteHeader(status)
-	_ = json.NewEncoder(w).Encode(map[string]string{"error": code})
-}
-
-// hubStatsAdapter exposes the events.Hub's Stats() to api.Health
-// without forcing api/health.go to import internal/serve/events.
-type hubStatsAdapter struct{ hub *events.Hub }
-
-func (a hubStatsAdapter) Stats() any { return a.hub.Stats() }
-
-// quotaEnricher adapts the QuotaIngester (in `internal/serve/ingest`)
-// and the attention engine to the api.SessionEnricher interface so
-// per-session `context_pct`, tokens, and current attention alert show
-// up on /api/sessions responses without coupling the api package to
-// ingest or attention internals.
-type quotaEnricher struct {
-	quota     *ingest.QuotaIngester
-	attention *attention.Engine
-}
-
-func (e quotaEnricher) ContextPct(name string) (int, bool) {
-	if e.quota == nil {
-		return 0, false
-	}
-	return e.quota.ContextPct(name)
-}
-
-func (e quotaEnricher) LastToolCallAt(name string) (time.Time, bool) {
-	if e.attention == nil {
-		return time.Time{}, false
-	}
-	return e.attention.LastToolCallAt(name)
-}
-
-func (e quotaEnricher) Attention(name string) (api.Attention, bool) {
-	if e.attention == nil {
-		return api.Attention{}, false
-	}
-	snap, ok := e.attention.Snapshot(name)
-	if !ok {
-		return api.Attention{}, false
-	}
-	return api.Attention{
-		State:   snap.State,
-		Since:   snap.Since,
-		Details: snap.Details,
-	}, true
-}
-
-func (e quotaEnricher) Tokens(name string) (api.TokenUsage, bool) {
-	if e.quota == nil {
-		return api.TokenUsage{}, false
-	}
-	s, ok := e.quota.PerSessionSnapshot(name)
-	if !ok {
-		return api.TokenUsage{}, false
-	}
-	return api.TokenUsage{
-		InputTokens:  s.InputTokens,
-		OutputTokens: s.OutputTokens,
-		CacheTokens:  s.CacheTokens,
-	}, true
-}
-
-// sessionSourceAdapter satisfies attention.SessionSource by reading
-// through the live projection and the checkpoint cache. Projection
-// already owns its own tmux client for TmuxAlive; checkpoint freshness
-// (for trigger G) comes from a bounded-limit cache Get that avoids
-// unbounded `git log` calls per tick.
-type sessionSourceAdapter struct {
-	proj    *ingest.Projection
-	cpCache *api.CheckpointsCache
-}
-
-func (a sessionSourceAdapter) Names() []string {
-	all := a.proj.All()
-	out := make([]string, 0, len(all))
-	for _, s := range all {
-		out = append(out, s.Name)
-	}
-	return out
-}
-
-func (a sessionSourceAdapter) Mode(name string) string {
-	s, ok := a.proj.Get(name)
-	if !ok {
-		return ""
-	}
-	return s.Mode
-}
-
-func (a sessionSourceAdapter) TmuxAlive(name string) bool {
-	return a.proj.TmuxAlive(name)
-}
-
-func (a sessionSourceAdapter) LastCheckpointAt(name string) (time.Time, bool) {
-	s, ok := a.proj.Get(name)
-	if !ok || s.Workdir == "" {
-		return time.Time{}, false
-	}
-	cps, err := a.cpCache.Get(s.Workdir, name, 1)
-	if err != nil || len(cps) == 0 {
-		return time.Time{}, false
-	}
-	t, perr := time.Parse(time.RFC3339, cps[0].TS)
-	if perr != nil {
-		return time.Time{}, false
-	}
-	return t, true
-}
-
-// sessionResolverAdapter satisfies webhook.SessionResolver so webhook
-// payloads carry session_uuid / workdir / mode alongside the alert.
-type sessionResolverAdapter struct{ proj *ingest.Projection }
-
-func (a sessionResolverAdapter) Resolve(name string) (uuid, workdir, mode string, ok bool) {
-	s, found := a.proj.Get(name)
-	if !found {
-		return "", "", "", false
-	}
-	return s.UUID, s.Workdir, s.Mode, true
-}
-
-// quotaSourceAdapter wraps the ingester's GlobalSnapshot return into
-// the api-package's private quotaSnapshot, keeping the ingest →
-// api dependency one-way (api stays ignorant of ingest internals).
-type quotaSourceAdapter struct{ quota *ingest.QuotaIngester }
-
-func (a quotaSourceAdapter) Snapshot() api.QuotaSnapshot {
-	if a.quota == nil {
-		return api.QuotaSnapshot{}
-	}
-	s := a.quota.Snapshot()
-	return api.QuotaSnapshot{
-		WeeklyPct:       s.WeeklyPct,
-		FiveHourPct:     s.FiveHourPct,
-		WeeklyResetsAt:  s.WeeklyResetsAt,
-		FiveHourResetAt: s.FiveHourResetAt,
-		Known:           s.Known,
-	}
-}
-
-// logsUUIDResolver maps a log-file UUID to a human session name for
-// /api/logs/usage. It mirrors the orphan-adoption lookup done in
-// Server.Run: direct UUID match first, then the claudeDirToName fall-
-// back via ~/.claude/projects/*/.jsonl so transcripts from
-// previous claude sessions in the same workdir still surface with
-// their tmux session name.
-// costSourceAdapter implements api.CostSource on top of store.CostStore.
-// The structs have identical field shapes so the conversion is direct.
-type costSourceAdapter struct{ s store.CostStore }
-
-func (a costSourceAdapter) Range(session string, since, until time.Time) ([]api.CostPoint, error) {
-	pts, err := a.s.Range(session, since, until)
-	if err != nil {
-		return nil, err
-	}
-	out := make([]api.CostPoint, len(pts))
-	for i, p := range pts {
-		out[i] = api.CostPoint(p)
-	}
-	return out, nil
-}
-
-func (a costSourceAdapter) Totals(since time.Time) (api.CostTotals, error) {
-	t, err := a.s.Totals(since)
-	if err != nil {
-		return api.CostTotals{}, err
-	}
-	return api.CostTotals(t), nil
-}
-
-// createSpawner adapts session.Yolo to api.CreateSpawner.
-type createSpawner struct {
-	store *session.Store
-	tmux  *tmux.Client
-}
-
-func (c createSpawner) Spawn(name, workdir string) (session.Session, error) {
-	return session.Yolo(session.SpawnOpts{
-		Name:    name,
-		Workdir: workdir,
-		Tmux:    c.tmux,
-		Store:   c.store,
-	})
-}
-
-// SendInitialPrompt fires `text` into the new session's pane after
-// claude has rendered its input prompt. Runs in a goroutine —
-// fire-and-forget; errors are logged, not returned.
-//
-// Readiness is detected by polling `tmux capture-pane` until two
-// consecutive captures are byte-identical and non-empty, bounded
-// by a 15s ceiling. This replaces the old fixed 8s sleep, which
-// was both too short on slow cold-starts and wasted latency on
-// fast machines. On timeout we still attempt the send — better to
-// race claude's input handler than silently drop the prompt.
-func (c createSpawner) SendInitialPrompt(name, text string) {
-	go func() {
-		target := name + ":0.0"
-		start := time.Now()
-		err := waitForPaneReady(context.Background(), c.tmux, target, waitOpts{})
-		if err != nil {
-			slog.Warn("initial prompt: pane readiness timed out; sending anyway", "session", name)
-		} else {
-			slog.Info("initial prompt: pane ready", "after_ms", time.Since(start).Milliseconds())
-		}
-		if err := c.tmux.SendKeys(target, text); err != nil {
-			slog.Warn("initial prompt send failed", "session", name, "err", err.Error())
-			return
-		}
-		// Small gap between keystrokes and Enter so claude has time
-		// to register the final character before the submit.
-		time.Sleep(300 * time.Millisecond)
-		if err := c.tmux.SendEnter(target); err != nil {
-			slog.Warn("initial prompt enter failed", "session", name, "err", err.Error())
-		}
-	}()
-}
-
-// execLookPath is a tiny adapter so api.CreateLookPath can be
-// satisfied by the free function os/exec.LookPath.
-type execLookPath struct{}
-
-func (execLookPath) LookPath(file string) (string, error) { return exec.LookPath(file) }
-
-// inputSessionSource adapts *ingest.Projection to api.InputSessionSource.
-// Both Get and TmuxAlive are implemented directly on *ingest.Projection.
-type inputSessionSource struct{ proj *ingest.Projection }
-
-func (a inputSessionSource) Get(name string) (session.Session, bool) {
-	return a.proj.Get(name)
-}
-
-func (a inputSessionSource) TmuxAlive(name string) bool {
-	return a.proj.TmuxAlive(name)
-}
-
-type logsUUIDResolver struct{ proj *ingest.Projection }
-
-// ResolveName maps a human session name to the log UUID recorded in
-// the sessions projection (sessions.json). This is the authoritative
-// path — log-directory scans are a fallback for orphan UUIDs and can
-// pick a stale historical transcript if a session has cycled through
-// several claude session_ids.
-func (r logsUUIDResolver) ResolveName(name string) (string, bool) {
-	if r.proj == nil || name == "" {
-		return "", false
-	}
-	s, ok := r.proj.Get(name)
-	if !ok || s.UUID == "" {
-		return "", false
-	}
-	return s.UUID, true
-}
-
-func (r logsUUIDResolver) ResolveUUID(uuid string) (string, bool) {
-	if r.proj == nil || uuid == "" {
-		return "", false
-	}
-	// Build the direct + workdir maps on every call. Projection.All()
-	// is RWMutex-guarded and copies defensively; the caller (handler)
-	// only fires on user-triggered refresh (TanStack 30 s staleTime),
-	// so the cost is negligible and we avoid stale caching for
-	// sessions that have been renamed.
-	all := r.proj.All()
-	for _, sess := range all {
-		if sess.UUID == uuid {
-			return sess.Name, true
-		}
-	}
-	home, err := os.UserHomeDir()
-	if err != nil {
-		return "", false
-	}
-	matches, _ := filepath.Glob(filepath.Join(home, ".claude", "projects", "*", uuid+jsonlExt))
-	if len(matches) != 1 {
-		return "", false
-	}
-	dirName := filepath.Base(filepath.Dir(matches[0]))
-	for _, sess := range all {
-		if sess.Workdir != "" && strings.ReplaceAll(sess.Workdir, "/", "-") == dirName {
-			return sess.Name, true
-		}
-	}
-	return "", false
-}
diff --git a/internal/serve/server_extra_test.go b/internal/serve/server_extra_test.go
deleted file mode 100644
index 6c3151b..0000000
--- a/internal/serve/server_extra_test.go
+++ /dev/null
@@ -1,952 +0,0 @@
-package serve
-
-import (
-	"context"
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"strconv"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/api"
-	"github.com/RandomCodeSpace/ctm/internal/serve/attention"
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-	"github.com/RandomCodeSpace/ctm/internal/serve/ingest"
-	"github.com/RandomCodeSpace/ctm/internal/serve/store"
-	"github.com/RandomCodeSpace/ctm/internal/session"
-)
-
-// fakeTmuxClient is a minimal HasSession stub to satisfy ingest.TmuxClient
-// without spinning up tmux. Tests that don't care about liveness use
-// this and just inspect the projection.
-type fakeTmuxClient struct {
-	alive map[string]bool
-}
-
-func (f *fakeTmuxClient) HasSession(name string) bool {
-	if f == nil {
-		return false
-	}
-	return f.alive[name]
-}
-
-// writeSessionsJSON writes the disk-shape ingest.Projection expects so
-// Reload picks the entries up. Mirrors writeSessionsFile in
-// internal/serve/ingest/sessions_proj_test.go.
-func writeSessionsJSON(t *testing.T, path string, sessions ...*session.Session) {
-	t.Helper()
-	m := make(map[string]*session.Session, len(sessions))
-	for _, s := range sessions {
-		m[s.Name] = s
-	}
-	body := map[string]any{
-		"schema_version": session.SchemaVersion,
-		"sessions":       m,
-	}
-	data, err := json.Marshal(body)
-	if err != nil {
-		t.Fatalf("marshal sessions fixture: %v", err)
-	}
-	if err := os.WriteFile(path, data, 0o600); err != nil {
-		t.Fatalf("write sessions fixture: %v", err)
-	}
-}
-
-// ---- Pure helper coverage --------------------------------------------------
-
-func TestBuildSessionMaps(t *testing.T) {
-	sessions := []session.Session{
-		{Name: "alpha", UUID: "u-alpha", Workdir: "/home/dev/projects/alpha"},
-		{Name: "beta", UUID: "u-beta", Workdir: ""},   // skipped from claudeDir
-		{Name: "gamma", UUID: "", Workdir: "/srv/g"}, // skipped from uuidToName
-	}
-	uuidToName, claudeDirToName := buildSessionMaps(sessions)
-
-	if got := uuidToName["u-alpha"]; got != "alpha" {
-		t.Errorf("uuidToName[u-alpha] = %q, want alpha", got)
-	}
-	if got := uuidToName["u-beta"]; got != "beta" {
-		t.Errorf("uuidToName[u-beta] = %q, want beta", got)
-	}
-	if _, ok := uuidToName[""]; ok {
-		t.Errorf("empty UUID should be skipped, got entry")
-	}
-	if got := claudeDirToName["-home-dev-projects-alpha"]; got != "alpha" {
-		t.Errorf("claudeDirToName[-home-dev-projects-alpha] = %q, want alpha", got)
-	}
-	if got := claudeDirToName["-srv-g"]; got != "gamma" {
-		t.Errorf("claudeDirToName[-srv-g] = %q, want gamma", got)
-	}
-	if _, ok := claudeDirToName[""]; ok {
-		t.Errorf("empty workdir should be skipped, got entry")
-	}
-}
-
-func TestBuildSessionMaps_Empty(t *testing.T) {
-	uuidToName, claudeDirToName := buildSessionMaps(nil)
-	if len(uuidToName) != 0 || len(claudeDirToName) != 0 {
-		t.Errorf("expected empty maps, got %v / %v", uuidToName, claudeDirToName)
-	}
-}
-
-func TestResolveLogUUIDToName_Direct(t *testing.T) {
-	uuidToName := map[string]string{"u-1": "alpha"}
-	name, viaFallback, ok := resolveLogUUIDToName("u-1", uuidToName, nil, "")
-	if !ok || name != "alpha" || viaFallback {
-		t.Errorf("got (%q, %v, %v), want (alpha, false, true)", name, viaFallback, ok)
-	}
-}
-
-func TestResolveLogUUIDToName_NoFallbackRoot(t *testing.T) {
-	// uuid not in direct map; claudeProjectsRoot empty → no fallback.
-	name, viaFallback, ok := resolveLogUUIDToName("orphan", nil, nil, "")
-	if ok || name != "" || viaFallback {
-		t.Errorf("got (%q, %v, %v), want (\"\", false, false)", name, viaFallback, ok)
-	}
-}
-
-func TestResolveLogUUIDToName_FallbackHit(t *testing.T) {
-	root := t.TempDir()
-	// Lay down ~/.claude/projects//.jsonl
-	encoded := "-home-dev-projects-alpha"
-	if err := os.MkdirAll(filepath.Join(root, encoded), 0o755); err != nil {
-		t.Fatalf("mkdir: %v", err)
-	}
-	if err := os.WriteFile(filepath.Join(root, encoded, "orphan-uuid.jsonl"), []byte("{}\n"), 0o600); err != nil {
-		t.Fatalf("write transcript: %v", err)
-	}
-	claudeDirToName := map[string]string{encoded: "alpha"}
-
-	name, viaFallback, ok := resolveLogUUIDToName("orphan-uuid", nil, claudeDirToName, root)
-	if !ok || name != "alpha" || !viaFallback {
-		t.Errorf("got (%q, %v, %v), want (alpha, true, true)", name, viaFallback, ok)
-	}
-}
-
-func TestResolveLogUUIDToName_FallbackNoMatchInDirMap(t *testing.T) {
-	root := t.TempDir()
-	encoded := "-some-unknown-dir"
-	if err := os.MkdirAll(filepath.Join(root, encoded), 0o755); err != nil {
-		t.Fatalf("mkdir: %v", err)
-	}
-	if err := os.WriteFile(filepath.Join(root, encoded, "abc.jsonl"), []byte("{}"), 0o600); err != nil {
-		t.Fatalf("write: %v", err)
-	}
-	// claudeDirToName has a different key → fallback fails.
-	claudeDirToName := map[string]string{"-other": "x"}
-	_, _, ok := resolveLogUUIDToName("abc", nil, claudeDirToName, root)
-	if ok {
-		t.Errorf("expected ok=false when claudeDirToName has no match")
-	}
-}
-
-func TestResolveLogUUIDToName_FallbackMultipleMatchesRejected(t *testing.T) {
-	// When the same UUID exists under two encoded dirs, the resolver
-	// must reject the ambiguity (len(matches) != 1) and return ok=false.
-	root := t.TempDir()
-	for _, dir := range []string{"-a", "-b"} {
-		if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
-			t.Fatalf("mkdir: %v", err)
-		}
-		if err := os.WriteFile(filepath.Join(root, dir, "dup.jsonl"), []byte("{}"), 0o600); err != nil {
-			t.Fatalf("write: %v", err)
-		}
-	}
-	claudeDirToName := map[string]string{"-a": "x", "-b": "y"}
-	_, _, ok := resolveLogUUIDToName("dup", nil, claudeDirToName, root)
-	if ok {
-		t.Errorf("ambiguous fallback should not resolve, ok=true")
-	}
-}
-
-// ---- Adapter coverage ------------------------------------------------------
-
-func TestExecLookPath(t *testing.T) {
-	// `go` will exist in PATH in any environment that built this test.
-	want, err := exec.LookPath("go")
-	if err != nil {
-		t.Skipf("go not in PATH: %v", err)
-	}
-	got, err := execLookPath{}.LookPath("go")
-	if err != nil {
-		t.Fatalf("execLookPath.LookPath: %v", err)
-	}
-	if got != want {
-		t.Errorf("LookPath(go) = %q, want %q", got, want)
-	}
-	if _, err := (execLookPath{}).LookPath("definitely-not-a-real-binary-xyz123"); err == nil {
-		t.Errorf("expected error for missing binary")
-	}
-}
-
-func TestHubStatsAdapter(t *testing.T) {
-	hub := events.NewHub(0)
-	a := hubStatsAdapter{hub: hub}
-	stats := a.Stats()
-	if stats == nil {
-		t.Fatalf("Stats() returned nil")
-	}
-}
-
-func TestQuotaEnricher_NilGuards(t *testing.T) {
-	// All accessors must safely return zero+false when their backing
-	// component is nil, since /api/sessions paints "unknown" lanes
-	// from those bools rather than crashing.
-	e := quotaEnricher{}
-	if pct, ok := e.ContextPct("any"); ok || pct != 0 {
-		t.Errorf("ContextPct nil quota: got (%d, %v)", pct, ok)
-	}
-	if ts, ok := e.LastToolCallAt("any"); ok || !ts.IsZero() {
-		t.Errorf("LastToolCallAt nil attention: got (%v, %v)", ts, ok)
-	}
-	if a, ok := e.Attention("any"); ok || (a != api.Attention{}) {
-		t.Errorf("Attention nil attention: got (%+v, %v)", a, ok)
-	}
-	if u, ok := e.Tokens("any"); ok || (u != api.TokenUsage{}) {
-		t.Errorf("Tokens nil quota: got (%+v, %v)", u, ok)
-	}
-}
-
-func TestQuotaEnricher_WithRealBackends(t *testing.T) {
-	// Wire up a real QuotaIngester + attention engine so the live code
-	// paths execute. Neither component requires Run() to answer the
-	// snapshot / per-session lookups we hit here — they just return
-	// false because we never published.
-	hub := events.NewHub(0)
-	q := ingest.NewQuotaIngester(t.TempDir(), nil, hub)
-	att := attention.NewEngine(hub, q, fakeAttSrc{}, attention.Defaults(), nil)
-
-	e := quotaEnricher{quota: q, attention: att}
-	// Empty engine: every Snapshot/PerSession/ContextPct should be (zero, false).
-	if pct, ok := e.ContextPct("nope"); ok || pct != 0 {
-		t.Errorf("ContextPct: got (%d, %v) want (0, false)", pct, ok)
-	}
-	if ts, ok := e.LastToolCallAt("nope"); ok || !ts.IsZero() {
-		t.Errorf("LastToolCallAt: got (%v, %v)", ts, ok)
-	}
-	if a, ok := e.Attention("nope"); ok || (a != api.Attention{}) {
-		t.Errorf("Attention: got (%+v, %v)", a, ok)
-	}
-	if u, ok := e.Tokens("nope"); ok || (u != api.TokenUsage{}) {
-		t.Errorf("Tokens: got (%+v, %v)", u, ok)
-	}
-}
-
-// fakeAttSrc satisfies attention.SessionSource for tests that only need
-// to construct an engine, not exercise its triggers.
-type fakeAttSrc struct{}
-
-func (fakeAttSrc) Names() []string                                { return nil }
-func (fakeAttSrc) Mode(string) string                             { return "" }
-func (fakeAttSrc) TmuxAlive(string) bool                          { return false }
-func (fakeAttSrc) LastCheckpointAt(string) (time.Time, bool)      { return time.Time{}, false }
-
-func TestSessionSourceAdapter(t *testing.T) {
-	dir := t.TempDir()
-	path := filepath.Join(dir, "sessions.json")
-	now := time.Now()
-	s1 := &session.Session{Name: "alpha", Mode: "yolo", Workdir: "/srv/a", CreatedAt: now}
-	s2 := &session.Session{Name: "beta", Mode: "safe", Workdir: "", CreatedAt: now}
-	writeSessionsJSON(t, path, s1, s2)
-
-	proj := ingest.New(path, &fakeTmuxClient{alive: map[string]bool{"alpha": true}})
-	proj.Reload()
-
-	cp := api.NewCheckpointsCache()
-	a := sessionSourceAdapter{proj: proj, cpCache: cp}
-
-	names := a.Names()
-	if len(names) != 2 {
-		t.Errorf("Names() len = %d, want 2", len(names))
-	}
-	// Names map order is stable from sessions_proj's slice, but the
-	// disk decode happens via map[string]*session.Session, so order
-	// isn't guaranteed. Just check membership.
-	have := map[string]bool{}
-	for _, n := range names {
-		have[n] = true
-	}
-	if !have["alpha"] || !have["beta"] {
-		t.Errorf("Names() = %v, want both alpha + beta", names)
-	}
-	if got := a.Mode("alpha"); got != "yolo" {
-		t.Errorf("Mode(alpha) = %q, want yolo", got)
-	}
-	if got := a.Mode("missing"); got != "" {
-		t.Errorf("Mode(missing) = %q, want \"\"", got)
-	}
-	if !a.TmuxAlive("alpha") {
-		t.Errorf("TmuxAlive(alpha) = false, want true")
-	}
-	if a.TmuxAlive("beta") {
-		t.Errorf("TmuxAlive(beta) = true, want false")
-	}
-	// LastCheckpointAt: workdir empty → false.
-	if ts, ok := a.LastCheckpointAt("beta"); ok || !ts.IsZero() {
-		t.Errorf("LastCheckpointAt(beta empty workdir) = (%v, %v), want zero+false", ts, ok)
-	}
-	// LastCheckpointAt for missing session → false.
-	if _, ok := a.LastCheckpointAt("missing"); ok {
-		t.Errorf("LastCheckpointAt(missing) ok=true, want false")
-	}
-	// LastCheckpointAt for valid workdir but no git repo → cache returns
-	// err, adapter returns (zero, false). Workdir is fine because it's a
-	// non-existent path; CheckpointsCache.Get returns an error which the
-	// adapter swallows.
-	if _, ok := a.LastCheckpointAt("alpha"); ok {
-		t.Errorf("LastCheckpointAt(alpha non-git workdir) ok=true, want false")
-	}
-}
-
-func TestSessionResolverAdapter(t *testing.T) {
-	dir := t.TempDir()
-	path := filepath.Join(dir, "sessions.json")
-	s1 := &session.Session{Name: "alpha", UUID: "u-alpha", Mode: "yolo", Workdir: "/srv/a"}
-	writeSessionsJSON(t, path, s1)
-
-	proj := ingest.New(path, &fakeTmuxClient{})
-	proj.Reload()
-
-	a := sessionResolverAdapter{proj: proj}
-	uuid, wd, mode, ok := a.Resolve("alpha")
-	if !ok || uuid != "u-alpha" || wd != "/srv/a" || mode != "yolo" {
-		t.Errorf("Resolve(alpha) = (%q, %q, %q, %v), want (u-alpha, /srv/a, yolo, true)", uuid, wd, mode, ok)
-	}
-	if _, _, _, ok := a.Resolve("missing"); ok {
-		t.Errorf("Resolve(missing) ok=true, want false")
-	}
-}
-
-func TestQuotaSourceAdapter(t *testing.T) {
-	// Empty ingester → Snapshot returns Known=false.
-	q := ingest.NewQuotaIngester(t.TempDir(), nil, events.NewHub(0))
-	a := quotaSourceAdapter{quota: q}
-	snap := a.Snapshot()
-	if snap.Known {
-		t.Errorf("Known = true on empty ingester, want false")
-	}
-	// Nil-quota path.
-	if got := (quotaSourceAdapter{}).Snapshot(); got != (api.QuotaSnapshot{}) {
-		t.Errorf("nil quota Snapshot = %+v, want zero", got)
-	}
-}
-
-func TestCostSourceAdapter(t *testing.T) {
-	dbPath := filepath.Join(t.TempDir(), "ctm.db")
-	cs, err := store.OpenCostStore(dbPath)
-	if err != nil {
-		t.Fatalf("OpenCostStore: %v", err)
-	}
-	t.Cleanup(func() { _ = cs.Close() })
-
-	now := time.Now().UTC().Truncate(time.Second)
-	if err := cs.Insert([]store.Point{
-		{TS: now, Session: "alpha", InputTokens: 10, OutputTokens: 20, CacheTokens: 5, CostUSDMicros: 1234},
-	}); err != nil {
-		t.Fatalf("Insert: %v", err)
-	}
-
-	a := costSourceAdapter{s: cs}
-
-	// Range round-trip.
-	pts, err := a.Range("alpha", now.Add(-time.Hour), now.Add(time.Hour))
-	if err != nil {
-		t.Fatalf("Range: %v", err)
-	}
-	if len(pts) != 1 {
-		t.Fatalf("Range len = %d, want 1", len(pts))
-	}
-	if pts[0].Session != "alpha" || pts[0].InputTokens != 10 || pts[0].OutputTokens != 20 {
-		t.Errorf("Range[0] = %+v", pts[0])
-	}
-
-	// Range with a foreign session → empty slice.
-	emptyPts, err := a.Range("nobody", now.Add(-time.Hour), now.Add(time.Hour))
-	if err != nil {
-		t.Fatalf("Range nobody: %v", err)
-	}
-	if len(emptyPts) != 0 {
-		t.Errorf("Range nobody len = %d, want 0", len(emptyPts))
-	}
-
-	totals, err := a.Totals(now.Add(-time.Hour))
-	if err != nil {
-		t.Fatalf("Totals: %v", err)
-	}
-	if totals.InputTokens != 10 || totals.OutputTokens != 20 || totals.CacheTokens != 5 || totals.CostUSDMicros != 1234 {
-		t.Errorf("Totals = %+v", totals)
-	}
-}
-
-func TestInputSessionSource(t *testing.T) {
-	dir := t.TempDir()
-	path := filepath.Join(dir, "sessions.json")
-	s1 := &session.Session{Name: "alpha", UUID: "u-alpha", Workdir: "/srv/a"}
-	writeSessionsJSON(t, path, s1)
-
-	proj := ingest.New(path, &fakeTmuxClient{alive: map[string]bool{"alpha": true}})
-	proj.Reload()
-
-	a := inputSessionSource{proj: proj}
-	got, ok := a.Get("alpha")
-	if !ok || got.Name != "alpha" {
-		t.Errorf("Get(alpha) = (%+v, %v)", got, ok)
-	}
-	if _, ok := a.Get("missing"); ok {
-		t.Errorf("Get(missing) ok=true, want false")
-	}
-	if !a.TmuxAlive("alpha") {
-		t.Errorf("TmuxAlive(alpha) = false, want true")
-	}
-	if a.TmuxAlive("missing") {
-		t.Errorf("TmuxAlive(missing) = true, want false")
-	}
-}
-
-func TestLogsUUIDResolver_ResolveName(t *testing.T) {
-	dir := t.TempDir()
-	path := filepath.Join(dir, "sessions.json")
-	s1 := &session.Session{Name: "alpha", UUID: "u-alpha", Workdir: "/srv/a"}
-	s2 := &session.Session{Name: "beta", UUID: "", Workdir: "/srv/b"}
-	writeSessionsJSON(t, path, s1, s2)
-
-	proj := ingest.New(path, &fakeTmuxClient{})
-	proj.Reload()
-
-	r := logsUUIDResolver{proj: proj}
-	if uuid, ok := r.ResolveName("alpha"); !ok || uuid != "u-alpha" {
-		t.Errorf("ResolveName(alpha) = (%q, %v), want (u-alpha, true)", uuid, ok)
-	}
-	if _, ok := r.ResolveName("beta"); ok {
-		t.Errorf("ResolveName(beta empty UUID) ok=true, want false")
-	}
-	if _, ok := r.ResolveName("missing"); ok {
-		t.Errorf("ResolveName(missing) ok=true, want false")
-	}
-	if _, ok := r.ResolveName(""); ok {
-		t.Errorf("ResolveName(empty) ok=true, want false")
-	}
-	// Nil-projection guard.
-	if _, ok := (logsUUIDResolver{}).ResolveName("anything"); ok {
-		t.Errorf("ResolveName(nil proj) ok=true, want false")
-	}
-}
-
-func TestLogsUUIDResolver_ResolveUUID_Direct(t *testing.T) {
-	dir := t.TempDir()
-	path := filepath.Join(dir, "sessions.json")
-	s1 := &session.Session{Name: "alpha", UUID: "u-alpha", Workdir: "/srv/a"}
-	writeSessionsJSON(t, path, s1)
-
-	proj := ingest.New(path, &fakeTmuxClient{})
-	proj.Reload()
-
-	r := logsUUIDResolver{proj: proj}
-	if name, ok := r.ResolveUUID("u-alpha"); !ok || name != "alpha" {
-		t.Errorf("ResolveUUID(u-alpha) = (%q, %v), want (alpha, true)", name, ok)
-	}
-	// Empty input.
-	if _, ok := r.ResolveUUID(""); ok {
-		t.Errorf("ResolveUUID(\"\") ok=true, want false")
-	}
-	if _, ok := (logsUUIDResolver{}).ResolveUUID("anything"); ok {
-		t.Errorf("ResolveUUID(nil proj) ok=true, want false")
-	}
-}
-
-// TestLogsUUIDResolver_ResolveUUID_FallbackMiss exercises the
-// claude-projects fallback path when the UUID isn't in the projection.
-// We can't intercept os.UserHomeDir here, but since the random UUID
-// almost certainly doesn't exist under any user's
-// ~/.claude/projects/*/*.jsonl, ResolveUUID should fall through to
-// "len(matches) != 1" and return false. This still hits the fallback
-// branch (UserHomeDir + Glob).
-func TestLogsUUIDResolver_ResolveUUID_FallbackMiss(t *testing.T) {
-	dir := t.TempDir()
-	path := filepath.Join(dir, "sessions.json")
-	writeSessionsJSON(t, path) // empty
-
-	proj := ingest.New(path, &fakeTmuxClient{})
-	proj.Reload()
-
-	r := logsUUIDResolver{proj: proj}
-	bogus := "this-uuid-does-not-exist-anywhere-7c4e1a2b3d4f"
-	if name, ok := r.ResolveUUID(bogus); ok {
-		t.Errorf("ResolveUUID(bogus) = (%q, true), want false", name)
-	}
-}
-
-// ---- Server lifecycle helpers ---------------------------------------------
-
-func TestServerAddrAndHub(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "vAddr", port))
-
-	// Build a fresh handle by taking a separate Server reference so we
-	// can call Addr/Hub directly. Easier: just call New() directly with
-	// a *different* port (Addr/Hub don't require Run).
-	port2 := pickFreePort(t)
-	srv, err := New(Options{
-		Port:              port2,
-		Version:           "vAddr2",
-		Token:             testToken,
-		SessionsPath:      filepath.Join(t.TempDir(), "sessions.json"),
-		TmuxConfPath:      filepath.Join(t.TempDir(), "tmux.conf"),
-		LogDir:            filepath.Join(t.TempDir(), "logs"),
-		StatuslineDumpDir: filepath.Join(t.TempDir(), "statusline"),
-	})
-	if err != nil {
-		t.Fatalf("New: %v", err)
-	}
-	t.Cleanup(func() { _ = srv.listener.Close(); _ = srv.cost.Close() })
-
-	addr := srv.Addr()
-	if addr == "" {
-		t.Errorf("Addr() = \"\"")
-	}
-	if got, want := addr, "127.0.0.1:"+strconv.Itoa(port2); got != want {
-		t.Errorf("Addr() = %q, want %q", got, want)
-	}
-	if srv.Hub() == nil {
-		t.Errorf("Hub() = nil")
-	}
-}
-
-func TestServerShutdownIdempotent(t *testing.T) {
-	// Shutdown is safe to call before Run starts (no-op) and more than
-	// once. Both code paths are exercised.
-	port := pickFreePort(t)
-	srv, err := New(Options{
-		Port:              port,
-		Version:           "vShutdown",
-		Token:             testToken,
-		SessionsPath:      filepath.Join(t.TempDir(), "sessions.json"),
-		TmuxConfPath:      filepath.Join(t.TempDir(), "tmux.conf"),
-		LogDir:            filepath.Join(t.TempDir(), "logs"),
-		StatuslineDumpDir: filepath.Join(t.TempDir(), "statusline"),
-	})
-	if err != nil {
-		t.Fatalf("New: %v", err)
-	}
-	defer func() { _ = srv.listener.Close(); _ = srv.cost.Close() }()
-
-	// Pre-Run: runCancel is nil → no-op.
-	srv.Shutdown("test pre-run")
-}
-
-func TestServerShutdownTriggersRunReturn(t *testing.T) {
-	// Bring up a Server, call Shutdown(), confirm Run returns. This
-	// covers Server.Shutdown's runCancel path AND the ctx.Done branch
-	// of Run.
-	port := pickFreePort(t)
-	srv, err := New(Options{
-		Port:              port,
-		Version:           "vShutdownRun",
-		Token:             testToken,
-		SessionsPath:      filepath.Join(t.TempDir(), "sessions.json"),
-		TmuxConfPath:      filepath.Join(t.TempDir(), "tmux.conf"),
-		LogDir:            filepath.Join(t.TempDir(), "logs"),
-		StatuslineDumpDir: filepath.Join(t.TempDir(), "statusline"),
-	})
-	if err != nil {
-		t.Fatalf("New: %v", err)
-	}
-
-	done := make(chan error, 1)
-	go func() { done <- srv.Run(context.Background()) }()
-
-	// Wait for healthz before shutting down so Run is fully initialised.
-	deadline := time.Now().Add(2 * time.Second)
-	addr := "http://127.0.0.1:" + strconv.Itoa(port)
-	for {
-		resp, err := http.Get(addr + "/healthz")
-		if err == nil {
-			_ = resp.Body.Close()
-			if resp.StatusCode == http.StatusOK {
-				break
-			}
-		}
-		if time.Now().After(deadline) {
-			t.Fatal("healthz never became ready")
-		}
-		time.Sleep(5 * time.Millisecond)
-	}
-
-	srv.Shutdown("test")
-	select {
-	case err := <-done:
-		if err != nil {
-			t.Errorf("Run() = %v, want nil", err)
-		}
-	case <-time.After(15 * time.Second):
-		t.Fatal("Run did not return within 15s after Shutdown")
-	}
-}
-
-// ---- Run-path coverage: orphan + adopted UUID adoption --------------------
-
-// TestRunAdoptsUUIDsFromLogDir seeds a sessions.json with a known UUID
-// and writes .jsonl into the log dir; on Run, the tailer manager
-// must pick it up and the Active() set must contain the session name.
-// This exercises the loop body in Run lines ~384-414 (now extracted).
-func TestRunAdoptsUUIDsFromLogDir(t *testing.T) {
-	tmpDir := t.TempDir()
-	logDir := filepath.Join(tmpDir, "logs")
-	if err := os.MkdirAll(logDir, 0o755); err != nil {
-		t.Fatalf("mkdir logs: %v", err)
-	}
-	sessionsPath := filepath.Join(tmpDir, "sessions.json")
-	s := &session.Session{Name: "myrun", UUID: "uuid-known", Workdir: "/srv/myrun"}
-	writeSessionsJSON(t, sessionsPath, s)
-	// Direct-match log file.
-	if err := os.WriteFile(filepath.Join(logDir, "uuid-known.jsonl"), []byte("{}\n"), 0o600); err != nil {
-		t.Fatalf("write log: %v", err)
-	}
-	// Orphan log file (no projection match, no claude-projects fallback).
-	if err := os.WriteFile(filepath.Join(logDir, "orphan-12345678abcd.jsonl"), []byte("{}\n"), 0o600); err != nil {
-		t.Fatalf("write orphan: %v", err)
-	}
-
-	port := pickFreePort(t)
-	srv, err := New(Options{
-		Port:              port,
-		Version:           "vRunAdopt",
-		Token:             testToken,
-		SessionsPath:      sessionsPath,
-		TmuxConfPath:      filepath.Join(tmpDir, "tmux.conf"),
-		LogDir:            logDir,
-		StatuslineDumpDir: filepath.Join(tmpDir, "statusline"),
-	})
-	if err != nil {
-		t.Fatalf("New: %v", err)
-	}
-
-	ctx, cancel := context.WithCancel(context.Background())
-	done := make(chan error, 1)
-	go func() { done <- srv.Run(ctx) }()
-
-	// Wait until tailers are registered.
-	deadline := time.Now().Add(2 * time.Second)
-	for {
-		active := srv.tailers.Active()
-		hasMyrun := false
-		hasOrphan := false
-		for _, n := range active {
-			if n == "myrun" {
-				hasMyrun = true
-			}
-			if len(n) >= len("uuid:") && n[:5] == "uuid:" {
-				hasOrphan = true
-			}
-		}
-		if hasMyrun && hasOrphan {
-			break
-		}
-		if time.Now().After(deadline) {
-			t.Fatalf("tailers Active = %v; want both myrun + uuid:* prefix", active)
-		}
-		time.Sleep(10 * time.Millisecond)
-	}
-
-	cancel()
-	select {
-	case <-done:
-	case <-time.After(15 * time.Second):
-		t.Fatal("Run did not return within 15s")
-	}
-}
-
-// TestRescanTailersReadsLogDir exercises rescanTailers directly: with
-// a populated projection and a fresh log file, it should call
-// tailers.Start for the matching session name. We use a local Server
-// (not via Run) and just invoke rescanTailers in-band.
-func TestRescanTailersReadsLogDir(t *testing.T) {
-	tmpDir := t.TempDir()
-	logDir := filepath.Join(tmpDir, "logs")
-	if err := os.MkdirAll(logDir, 0o755); err != nil {
-		t.Fatalf("mkdir logs: %v", err)
-	}
-	sessionsPath := filepath.Join(tmpDir, "sessions.json")
-	s := &session.Session{Name: "rescan", UUID: "uuid-rescan", Workdir: "/srv/rescan"}
-	writeSessionsJSON(t, sessionsPath, s)
-	if err := os.WriteFile(filepath.Join(logDir, "uuid-rescan.jsonl"), []byte("{}\n"), 0o600); err != nil {
-		t.Fatalf("write log: %v", err)
-	}
-	// Orphan: not in projection → silently skipped (rescanTailers does
-	// NOT register orphan UUIDs, only the boot pass does).
-	if err := os.WriteFile(filepath.Join(logDir, "orphan-rescan-7c4e.jsonl"), []byte("{}\n"), 0o600); err != nil {
-		t.Fatalf("write orphan: %v", err)
-	}
-	// Non-jsonl file: should be skipped.
-	if err := os.WriteFile(filepath.Join(logDir, "garbage.txt"), []byte("nope"), 0o600); err != nil {
-		t.Fatalf("write garbage: %v", err)
-	}
-	// Subdir: should be skipped.
-	if err := os.MkdirAll(filepath.Join(logDir, "subdir"), 0o755); err != nil {
-		t.Fatalf("mkdir subdir: %v", err)
-	}
-
-	port := pickFreePort(t)
-	srv, err := New(Options{
-		Port:              port,
-		Version:           "vRescan",
-		Token:             testToken,
-		SessionsPath:      sessionsPath,
-		TmuxConfPath:      filepath.Join(tmpDir, "tmux.conf"),
-		LogDir:            logDir,
-		StatuslineDumpDir: filepath.Join(tmpDir, "statusline"),
-	})
-	if err != nil {
-		t.Fatalf("New: %v", err)
-	}
-	defer func() { _ = srv.listener.Close(); _ = srv.cost.Close() }()
-
-	srv.proj.Reload() // populate projection so resolveLogUUIDToName matches.
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	srv.rescanTailers(ctx, "")
-
-	active := srv.tailers.Active()
-	found := false
-	for _, n := range active {
-		if n == "rescan" {
-			found = true
-		}
-		if len(n) >= 5 && n[:5] == "uuid:" {
-			t.Errorf("rescanTailers should not register orphan: %q", n)
-		}
-	}
-	if !found {
-		t.Errorf("rescanTailers did not start tailer for 'rescan'; active=%v", active)
-	}
-
-	// Now exercise the early-return when ReadDir fails (logDir missing).
-	_ = os.RemoveAll(logDir)
-	srv.rescanTailers(ctx, "") // must not panic
-}
-
-// ---- registerRoutes coverage: hit a few unauthenticated/auth paths --------
-
-// TestServeMuxAuthStatusUnauthenticated covers the AuthStatus route,
-// which is registered without authHF and not currently exercised.
-func TestServeMuxAuthStatusUnauthenticated(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "vAuth", port))
-
-	resp, err := http.Get("http://127.0.0.1:" + strconv.Itoa(port) + "/api/auth/status")
-	if err != nil {
-		t.Fatalf("get auth/status: %v", err)
-	}
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusOK {
-		t.Errorf("status = %d, want 200", resp.StatusCode)
-	}
-}
-
-func TestServeMuxDoctorAuthed(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "vDoctor", port))
-
-	resp := authedGet(t, "http://127.0.0.1:"+strconv.Itoa(port)+"/api/doctor")
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusOK {
-		t.Errorf("status = %d, want 200", resp.StatusCode)
-	}
-}
-
-func TestServeMuxQuotaAuthed(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "vQuota", port))
-
-	resp := authedGet(t, "http://127.0.0.1:"+strconv.Itoa(port)+"/api/quota")
-	defer resp.Body.Close()
-	// Empty quota → 204 No Content; populated → 200. We just verify the
-	// route is reachable and returns a non-error status.
-	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
-		t.Errorf("status = %d, want 200/204", resp.StatusCode)
-	}
-}
-
-func TestServeMuxFeedAuthed(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "vFeed", port))
-
-	resp := authedGet(t, "http://127.0.0.1:"+strconv.Itoa(port)+"/api/feed")
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusOK {
-		t.Errorf("status = %d, want 200", resp.StatusCode)
-	}
-}
-
-func TestServeMuxLogsUsageAuthed(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "vLogs", port))
-
-	resp := authedGet(t, "http://127.0.0.1:"+strconv.Itoa(port)+"/api/logs/usage")
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusOK {
-		t.Errorf("status = %d, want 200", resp.StatusCode)
-	}
-}
-
-func TestServeMuxDebugHubAuthed(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "vDbg", port))
-
-	resp := authedGet(t, "http://127.0.0.1:"+strconv.Itoa(port)+"/debug/hub")
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusOK {
-		t.Errorf("status = %d, want 200", resp.StatusCode)
-	}
-	if got := resp.Header.Get("Content-Type"); got != "application/json" {
-		t.Errorf("Content-Type = %q, want application/json", got)
-	}
-}
-
-func TestServeMuxCostAuthed(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "vCost", port))
-
-	resp := authedGet(t, "http://127.0.0.1:"+strconv.Itoa(port)+"/api/cost")
-	defer resp.Body.Close()
-	// Cost handler returns 200 with empty data when no points exist.
-	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest {
-		t.Errorf("status = %d, want 200/400", resp.StatusCode)
-	}
-}
-
-func TestAuthRejectsBadToken(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "vBad", port))
-
-	req, _ := http.NewRequest(http.MethodGet, "http://127.0.0.1:"+strconv.Itoa(port)+"/health", nil)
-	req.Header.Set("Authorization", "Bearer not-the-right-token")
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		t.Fatalf("do: %v", err)
-	}
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusUnauthorized {
-		t.Errorf("status = %d, want 401", resp.StatusCode)
-	}
-	var body map[string]string
-	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body["error"] != "invalid_token" {
-		t.Errorf("error = %q, want invalid_token", body["error"])
-	}
-}
-
-// TestWriteJSONAuthErrShape exercises the helper directly to lock in
-// its response shape (status code + Content-Type + JSON body).
-func TestWriteJSONAuthErrShape(t *testing.T) {
-	rr := httptest.NewRecorder()
-	writeJSONAuthErr(rr, http.StatusForbidden, "no_perm")
-	if rr.Code != http.StatusForbidden {
-		t.Errorf("status = %d, want 403", rr.Code)
-	}
-	if got := rr.Header().Get("Content-Type"); got != "application/json" {
-		t.Errorf("Content-Type = %q, want application/json", got)
-	}
-	var body map[string]string
-	if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body["error"] != "no_perm" {
-		t.Errorf("error = %q, want no_perm", body["error"])
-	}
-}
-
-// TestNewWithCustomThresholds covers the AttentionThresholds branch in
-// New that picks attention.Defaults() when the option is zero-valued.
-func TestNewWithCustomThresholds(t *testing.T) {
-	port := pickFreePort(t)
-	thr := attention.Defaults()
-	thr.QuotaPct = 42
-	srv, err := New(Options{
-		Port:                port,
-		Version:             "vThr",
-		Token:               testToken,
-		SessionsPath:        filepath.Join(t.TempDir(), "sessions.json"),
-		TmuxConfPath:        filepath.Join(t.TempDir(), "tmux.conf"),
-		LogDir:              filepath.Join(t.TempDir(), "logs"),
-		StatuslineDumpDir:   filepath.Join(t.TempDir(), "statusline"),
-		AttentionThresholds: thr,
-	})
-	if err != nil {
-		t.Fatalf("New: %v", err)
-	}
-	t.Cleanup(func() { _ = srv.listener.Close(); _ = srv.cost.Close() })
-	if srv.attention == nil {
-		t.Errorf("attention engine not wired")
-	}
-}
-
-// TestRegisterRoutesCustomMux exercises the mux registration path on a
-// brand-new server without going through Run, ensuring registerRoutes
-// is exercised twice in the same process.
-func TestRegisterRoutesCustomMux(t *testing.T) {
-	// Use the constructed Server so resolveWorkdir / allowedOrigins /
-	// quota adapters all wire up the same as production.
-	port := pickFreePort(t)
-	srv, err := New(Options{
-		Port:              port,
-		Version:           "vMux",
-		Token:             testToken,
-		SessionsPath:      filepath.Join(t.TempDir(), "sessions.json"),
-		TmuxConfPath:      filepath.Join(t.TempDir(), "tmux.conf"),
-		LogDir:            filepath.Join(t.TempDir(), "logs"),
-		StatuslineDumpDir: filepath.Join(t.TempDir(), "statusline"),
-	})
-	if err != nil {
-		t.Fatalf("New: %v", err)
-	}
-	t.Cleanup(func() { _ = srv.listener.Close(); _ = srv.cost.Close() })
-
-	mux := http.NewServeMux()
-	srv.registerRoutes(mux)
-	rr := httptest.NewRecorder()
-	req, _ := http.NewRequest(http.MethodGet, "/healthz", nil)
-	mux.ServeHTTP(rr, req)
-	if rr.Code != http.StatusOK {
-		t.Errorf("/healthz on local mux = %d, want 200", rr.Code)
-	}
-}
-
-// TestRegisterRoutesAllowedOriginsEnv covers the CTM_ALLOWED_ORIGINS env
-// var branch in registerRoutes (lines 715-721).
-func TestRegisterRoutesAllowedOriginsEnv(t *testing.T) {
-	t.Setenv("CTM_ALLOWED_ORIGINS", "https://dev.example.com,, https://other.example.com ")
-
-	port := pickFreePort(t)
-	srv, err := New(Options{
-		Port:              port,
-		Version:           "vEnv",
-		Token:             testToken,
-		SessionsPath:      filepath.Join(t.TempDir(), "sessions.json"),
-		TmuxConfPath:      filepath.Join(t.TempDir(), "tmux.conf"),
-		LogDir:            filepath.Join(t.TempDir(), "logs"),
-		StatuslineDumpDir: filepath.Join(t.TempDir(), "statusline"),
-	})
-	if err != nil {
-		t.Fatalf("New: %v", err)
-	}
-	t.Cleanup(func() { _ = srv.listener.Close(); _ = srv.cost.Close() })
-	// The env-var fork is exercised at registerRoutes time inside New.
-	// We can't observe the resulting allowedOrigins slice externally,
-	// but a successful New + healthz on a local mux confirms the path.
-	mux := http.NewServeMux()
-	srv.registerRoutes(mux)
-	rr := httptest.NewRecorder()
-	req, _ := http.NewRequest(http.MethodGet, "/healthz", nil)
-	mux.ServeHTTP(rr, req)
-	if rr.Code != http.StatusOK {
-		t.Errorf("/healthz = %d, want 200", rr.Code)
-	}
-}
diff --git a/internal/serve/server_more_test.go b/internal/serve/server_more_test.go
deleted file mode 100644
index 8541e5a..0000000
--- a/internal/serve/server_more_test.go
+++ /dev/null
@@ -1,392 +0,0 @@
-package serve
-
-import (
-	"errors"
-	"net"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"strconv"
-	"strings"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/api"
-	"github.com/RandomCodeSpace/ctm/internal/serve/ingest"
-	"github.com/RandomCodeSpace/ctm/internal/serve/store"
-	"github.com/RandomCodeSpace/ctm/internal/session"
-)
-
-// fakeCostStore is a tiny store.CostStore stub whose Range/Totals
-// return a fixed error. Used to drive costSourceAdapter's err
-// branches. Insert/Close are no-ops for the few tests that touch
-// them (none here, but they're satisfied by interface).
-type fakeCostStore struct {
-	rangeErr  error
-	totalsErr error
-	rangePts  []store.Point
-	totals    store.Totals
-}
-
-func (f *fakeCostStore) Insert([]store.Point) error { return nil }
-func (f *fakeCostStore) Range(session string, since, until time.Time) ([]store.Point, error) {
-	if f.rangeErr != nil {
-		return nil, f.rangeErr
-	}
-	return f.rangePts, nil
-}
-func (f *fakeCostStore) Totals(since time.Time) (store.Totals, error) {
-	if f.totalsErr != nil {
-		return store.Totals{}, f.totalsErr
-	}
-	return f.totals, nil
-}
-func (f *fakeCostStore) Close() error { return nil }
-
-// TestCostSourceAdapter_RangeError covers the "Range returns err"
-// fast-fail branch in costSourceAdapter.Range.
-func TestCostSourceAdapter_RangeError(t *testing.T) {
-	wantErr := errors.New("boom")
-	a := costSourceAdapter{s: &fakeCostStore{rangeErr: wantErr}}
-	pts, err := a.Range("alpha", time.Now().Add(-time.Hour), time.Now())
-	if err == nil || !errors.Is(err, wantErr) {
-		t.Errorf("Range err = %v, want %v", err, wantErr)
-	}
-	if pts != nil {
-		t.Errorf("Range pts = %v, want nil", pts)
-	}
-}
-
-// TestCostSourceAdapter_TotalsError covers the "Totals returns err"
-// fast-fail branch.
-func TestCostSourceAdapter_TotalsError(t *testing.T) {
-	wantErr := errors.New("boom")
-	a := costSourceAdapter{s: &fakeCostStore{totalsErr: wantErr}}
-	got, err := a.Totals(time.Now().Add(-time.Hour))
-	if err == nil || !errors.Is(err, wantErr) {
-		t.Errorf("Totals err = %v, want %v", err, wantErr)
-	}
-	if got != (api.CostTotals{}) {
-		t.Errorf("Totals = %+v, want zero", got)
-	}
-}
-
-// TestLogsUUIDResolver_NilProjOrEmptyArgs covers the early-return
-// guards on both Resolve methods.
-func TestLogsUUIDResolver_NilProjOrEmptyArgs(t *testing.T) {
-	// nil projection.
-	r := logsUUIDResolver{proj: nil}
-	if _, ok := r.ResolveName("alpha"); ok {
-		t.Errorf("ResolveName(nil proj) should return false")
-	}
-	if _, ok := r.ResolveUUID("uuid"); ok {
-		t.Errorf("ResolveUUID(nil proj) should return false")
-	}
-
-	// empty projection but non-empty proj — empty arg short-circuits.
-	dir := t.TempDir()
-	path := filepath.Join(dir, "sessions.json")
-	writeSessionsJSON(t, path)
-	proj := ingest.New(path, &fakeTmuxClient{})
-	proj.Reload()
-	r2 := logsUUIDResolver{proj: proj}
-	if _, ok := r2.ResolveName(""); ok {
-		t.Errorf("ResolveName(\"\") should return false")
-	}
-	if _, ok := r2.ResolveUUID(""); ok {
-		t.Errorf("ResolveUUID(\"\") should return false")
-	}
-}
-
-// TestLogsUUIDResolver_ResolveName_NoUUIDStored covers the
-// "session exists but UUID empty" branch.
-func TestLogsUUIDResolver_ResolveName_NoUUIDStored(t *testing.T) {
-	dir := t.TempDir()
-	path := filepath.Join(dir, "sessions.json")
-	// Session has no UUID set.
-	writeSessionsJSON(t, path, &session.Session{Name: "alpha", Mode: "safe"})
-	proj := ingest.New(path, &fakeTmuxClient{})
-	proj.Reload()
-
-	r := logsUUIDResolver{proj: proj}
-	if _, ok := r.ResolveName("alpha"); ok {
-		t.Errorf("ResolveName for empty-UUID session should return false")
-	}
-}
-
-// TestLogsUUIDResolver_ResolveUUID_WorkdirFallbackHit covers the
-// production workdir-derived fallback: when the requested uuid isn't
-// in the projection's direct uuid map, ResolveUUID globs
-// ~/.claude/projects//.jsonl, derives the session from the
-// dirname (slashes → dashes), and returns its name.
-func TestLogsUUIDResolver_ResolveUUID_WorkdirFallbackHit(t *testing.T) {
-	// Sandbox HOME so UserHomeDir + Glob hit our fake tree only.
-	homeDir := t.TempDir()
-	t.Setenv("HOME", homeDir)
-	// On Linux UserHomeDir consults $HOME, on macOS too; this is enough
-	// for CI and dev. Skip if UserHomeDir surprises us.
-	if got, err := os.UserHomeDir(); err != nil || got != homeDir {
-		t.Skipf("UserHomeDir didn't honour HOME override: got=%q err=%v", got, err)
-	}
-
-	// Real workdir for the session.
-	workdir := "/srv/projects/codeiq"
-	// Claude projects layout: ~/.claude/projects//.jsonl
-	dirName := strings.ReplaceAll(workdir, "/", "-")
-	projDir := filepath.Join(homeDir, ".claude", "projects", dirName)
-	if err := os.MkdirAll(projDir, 0o755); err != nil {
-		t.Fatalf("mkdir: %v", err)
-	}
-	const uuid = "ffffffff-0000-0000-0000-000000000001"
-	if err := os.WriteFile(filepath.Join(projDir, uuid+".jsonl"), []byte{}, 0o600); err != nil {
-		t.Fatalf("write jsonl: %v", err)
-	}
-
-	// Projection has the session WITHOUT this uuid (e.g. session
-	// rotated to a fresh claude session_id but the old transcript
-	// still exists on disk). The direct loop misses, the workdir
-	// fallback should still resolve to "codeiq" via the dirName.
-	dir := t.TempDir()
-	sessionsPath := filepath.Join(dir, "sessions.json")
-	writeSessionsJSON(t, sessionsPath, &session.Session{
-		Name:    "codeiq",
-		UUID:    "current-uuid-different",
-		Workdir: workdir,
-	})
-	proj := ingest.New(sessionsPath, &fakeTmuxClient{})
-	proj.Reload()
-
-	r := logsUUIDResolver{proj: proj}
-	got, ok := r.ResolveUUID(uuid)
-	if !ok {
-		t.Fatalf("ResolveUUID(orphan uuid via workdir fallback) returned false")
-	}
-	if got != "codeiq" {
-		t.Errorf("ResolveUUID = %q, want codeiq", got)
-	}
-}
-
-// TestLogsUUIDResolver_ResolveUUID_FallbackHitButNoSessionMatch covers
-// the case where the glob matches a single file but no projection
-// session has a workdir whose dashed form equals the dirname → false.
-func TestLogsUUIDResolver_ResolveUUID_FallbackHitButNoSessionMatch(t *testing.T) {
-	homeDir := t.TempDir()
-	t.Setenv("HOME", homeDir)
-	if got, err := os.UserHomeDir(); err != nil || got != homeDir {
-		t.Skipf("UserHomeDir didn't honour HOME override: got=%q err=%v", got, err)
-	}
-
-	dirName := "-srv-projects-codeiq"
-	projDir := filepath.Join(homeDir, ".claude", "projects", dirName)
-	if err := os.MkdirAll(projDir, 0o755); err != nil {
-		t.Fatalf("mkdir: %v", err)
-	}
-	const uuid = "fffffffe-0000-0000-0000-000000000001"
-	if err := os.WriteFile(filepath.Join(projDir, uuid+".jsonl"), []byte{}, 0o600); err != nil {
-		t.Fatalf("write jsonl: %v", err)
-	}
-
-	// Projection has a session but its workdir doesn't match the dashed
-	// form ("/totally/different").
-	dir := t.TempDir()
-	sessionsPath := filepath.Join(dir, "sessions.json")
-	writeSessionsJSON(t, sessionsPath, &session.Session{
-		Name:    "alpha",
-		UUID:    "u-alpha",
-		Workdir: "/totally/different",
-	})
-	proj := ingest.New(sessionsPath, &fakeTmuxClient{})
-	proj.Reload()
-
-	r := logsUUIDResolver{proj: proj}
-	if got, ok := r.ResolveUUID(uuid); ok {
-		t.Errorf("ResolveUUID = (%q, true), want false (no workdir match)", got)
-	}
-}
-
-// TestSessionSourceAdapter_LastCheckpointAt_RealRepo covers the
-// "cps[0].TS parses cleanly → return non-zero time" success branch
-// of LastCheckpointAt. Requires git in PATH; skipped if absent.
-func TestSessionSourceAdapter_LastCheckpointAt_RealRepo(t *testing.T) {
-	if _, err := exec.LookPath("git"); err != nil {
-		t.Skipf("git not in PATH: %v", err)
-	}
-
-	// Build a real git repo with one checkpoint commit so
-	// CheckpointsCache.Get returns a populated slice with an RFC3339 TS.
-	repoDir := t.TempDir()
-	gitInit(t, repoDir)
-	if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("hi\n"), 0o644); err != nil {
-		t.Fatalf("write file: %v", err)
-	}
-	gitRun(t, repoDir, "add", ".")
-	gitRun(t, repoDir, "commit", "-m", "checkpoint: pre-yolo 2026-04-21T12:00:00")
-
-	// Wire a projection with this workdir and a session source adapter.
-	sessionsPath := filepath.Join(t.TempDir(), "sessions.json")
-	writeSessionsJSON(t, sessionsPath, &session.Session{
-		Name: "alpha", UUID: "u-alpha", Workdir: repoDir,
-	})
-	proj := ingest.New(sessionsPath, &fakeTmuxClient{})
-	proj.Reload()
-
-	a := sessionSourceAdapter{proj: proj, cpCache: api.NewCheckpointsCache()}
-	ts, ok := a.LastCheckpointAt("alpha")
-	if !ok {
-		t.Fatalf("LastCheckpointAt returned ok=false on real checkpointed repo")
-	}
-	if ts.IsZero() {
-		t.Errorf("LastCheckpointAt ts is zero, want non-zero")
-	}
-}
-
-// gitInit / gitRun are minimal shell-out helpers for the repo fixture.
-// They mirror the helpers in internal/serve/git/checkpoints_test.go.
-func gitInit(t *testing.T, dir string) {
-	t.Helper()
-	gitRun(t, dir, "init", "-q")
-	gitRun(t, dir, "config", "user.email", "test@example.com")
-	gitRun(t, dir, "config", "user.name", "Test")
-	// Some hosts default to gpgsign=true; force off so commits don't
-	// hang waiting for a signing agent we don't have.
-	gitRun(t, dir, "config", "commit.gpgsign", "false")
-}
-
-// TestNew_DefaultsAllUnset covers the four `if path == ""` branches in
-// New() that fall through to config.SessionsPath / TmuxConfPath /
-// Dir()/logs / /tmp/ctm-statusline. Sandboxes HOME so the cost db is
-// written into a temp tree rather than the dev's real ~/.config/ctm.
-func TestNew_DefaultsAllUnset(t *testing.T) {
-	homeDir := t.TempDir()
-	t.Setenv("HOME", homeDir)
-	if got, err := os.UserHomeDir(); err != nil || got != homeDir {
-		t.Skipf("UserHomeDir didn't honour HOME override: got=%q err=%v", got, err)
-	}
-	// config.Dir uses UserHomeDir → ~/.config/ctm. Pre-create so
-	// OpenCostStore doesn't trip on the parent.
-	if err := os.MkdirAll(filepath.Join(homeDir, ".config", "ctm"), 0o755); err != nil {
-		t.Fatalf("mkdir config dir: %v", err)
-	}
-
-	port := pickFreePort(t)
-	srv, err := New(Options{
-		Port:    port,
-		Version: "vDef",
-		Token:   testToken,
-		// Intentionally leave SessionsPath / TmuxConfPath / LogDir /
-		// StatuslineDumpDir empty — the empty-path branches inside
-		// New() should fall through to config defaults.
-	})
-	if err != nil {
-		t.Fatalf("New defaults: %v", err)
-	}
-	t.Cleanup(func() { _ = srv.listener.Close(); _ = srv.cost.Close() })
-
-	if !strings.HasPrefix(srv.logDir, homeDir) {
-		t.Errorf("logDir = %q, want under %q", srv.logDir, homeDir)
-	}
-}
-
-// TestNew_PortInUseByNonCtm covers the "port bound by foreign listener"
-// branch (lines 170-176). We bind a vanilla TCP listener first, then
-// call New() against the same port — it should fail with the bind
-// error wrapped, NOT ErrAlreadyRunning (probeIsCtmServe sees a non-ctm
-// listener).
-func TestNew_PortInUseByNonCtm(t *testing.T) {
-	port := pickFreePort(t)
-	// Bind a TCP listener that doesn't speak ctm-serve's healthz.
-	ln, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port))
-	if err != nil {
-		t.Fatalf("bind decoy: %v", err)
-	}
-	t.Cleanup(func() { _ = ln.Close() })
-
-	_, err = New(Options{
-		Port:              port,
-		Version:           "vClash",
-		Token:             testToken,
-		SessionsPath:      filepath.Join(t.TempDir(), "sessions.json"),
-		TmuxConfPath:      filepath.Join(t.TempDir(), "tmux.conf"),
-		LogDir:            filepath.Join(t.TempDir(), "logs"),
-		StatuslineDumpDir: filepath.Join(t.TempDir(), "statusline"),
-	})
-	if err == nil {
-		t.Fatal("New on busy port returned nil error, want bind failure")
-	}
-	if errors.Is(err, ErrAlreadyRunning) {
-		t.Errorf("err = ErrAlreadyRunning; want non-ctm bind-failure wrap. got=%v", err)
-	}
-}
-
-// TestEventsSessionRoute exercises the GET /events/session/{name}
-// closure registered in registerRoutes — the line counted at 779. We
-// can't keep the SSE connection open for long, but a 200 + initial
-// retry frame is enough to record coverage on the route.
-func TestEventsSessionRoute(t *testing.T) {
-	port := pickFreePort(t)
-	srv, err := New(Options{
-		Port:              port,
-		Version:           "vSSE",
-		Token:             testToken,
-		SessionsPath:      filepath.Join(t.TempDir(), "sessions.json"),
-		TmuxConfPath:      filepath.Join(t.TempDir(), "tmux.conf"),
-		LogDir:            filepath.Join(t.TempDir(), "logs"),
-		StatuslineDumpDir: filepath.Join(t.TempDir(), "statusline"),
-	})
-	if err != nil {
-		t.Fatalf("New: %v", err)
-	}
-	t.Cleanup(func() { _ = srv.listener.Close(); _ = srv.cost.Close() })
-
-	mux := http.NewServeMux()
-	srv.registerRoutes(mux)
-
-	// httptest.NewRecorder doesn't support Flush detection, but for SSE
-	// we just want the handler entered and response headers written.
-	// Use a real test server so the underlying Hijack/Flush works.
-	ts := httptest.NewServer(mux)
-	t.Cleanup(ts.Close)
-
-	req, err := http.NewRequest(http.MethodGet, ts.URL+"/events/session/alpha", nil)
-	if err != nil {
-		t.Fatalf("NewRequest: %v", err)
-	}
-	req.Header.Set("Authorization", "Bearer "+testToken)
-
-	// Use a client with an aggressive timeout so the long-lived SSE
-	// loop returns control to the test promptly.
-	client := &http.Client{Timeout: 200 * time.Millisecond}
-	resp, err := client.Do(req)
-	if err != nil {
-		// A timeout-on-read is fine — the route was hit. Surface only
-		// non-timeout failures.
-		if !strings.Contains(err.Error(), "Timeout") &&
-			!strings.Contains(err.Error(), "deadline exceeded") &&
-			!strings.Contains(err.Error(), "context canceled") {
-			t.Fatalf("Do: %v", err)
-		}
-		return
-	}
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusOK {
-		t.Errorf("status = %d, want 200", resp.StatusCode)
-	}
-}
-
-func gitRun(t *testing.T, dir string, args ...string) {
-	t.Helper()
-	cmd := exec.Command("git", args...)
-	cmd.Dir = dir
-	// Force a fully self-contained env so user-level git config (eg.
-	// commit signing, hooks) can't leak in from the dev's machine.
-	cmd.Env = append(os.Environ(),
-		"GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com",
-		"GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com",
-		"GIT_CONFIG_GLOBAL=/dev/null",
-	)
-	if out, err := cmd.CombinedOutput(); err != nil {
-		t.Fatalf("git %v: %v\n%s", args, err, out)
-	}
-}
diff --git a/internal/serve/server_test.go b/internal/serve/server_test.go
deleted file mode 100644
index 672cdf0..0000000
--- a/internal/serve/server_test.go
+++ /dev/null
@@ -1,322 +0,0 @@
-package serve
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-	"io"
-	"net"
-	"net/http"
-	"path/filepath"
-	"strconv"
-	"strings"
-	"testing"
-	"time"
-)
-
-// testToken is the bearer token injected into every Server spun up by
-// startServer. Tests that exercise authenticated endpoints prepend
-// `Authorization: Bearer ` on their requests.
-const testToken = "test-bearer-token"
-
-// authedGet issues a GET with the test bearer header set.
-func authedGet(t *testing.T, url string) *http.Response {
-	t.Helper()
-	req, err := http.NewRequest(http.MethodGet, url, nil)
-	if err != nil {
-		t.Fatalf("new request: %v", err)
-	}
-	req.Header.Set("Authorization", "Bearer "+testToken)
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		t.Fatalf("do %s: %v", url, err)
-	}
-	return resp
-}
-
-// pickFreePort asks the kernel for a free loopback port, then releases
-// it so the Server under test can bind. A race between release and
-// re-bind is theoretically possible but vanishingly rare on a single
-// test box; acceptable for unit tests.
-func pickFreePort(t *testing.T) int {
-	t.Helper()
-	l, err := net.Listen("tcp", "127.0.0.1:0")
-	if err != nil {
-		t.Fatalf("listen: %v", err)
-	}
-	port := l.Addr().(*net.TCPAddr).Port
-	if err := l.Close(); err != nil {
-		t.Fatalf("close: %v", err)
-	}
-	return port
-}
-
-// startServer brings up a Server on the given port and waits until
-// /healthz is live. Returns a stop function that cancels Run and
-// blocks until the server goroutine exits.
-func startServer(t *testing.T, version string, port int) func() {
-	t.Helper()
-	// Isolate the projection away from the user's real ~/.config/ctm.
-	sessionsPath := filepath.Join(t.TempDir(), "sessions.json")
-	srv, err := New(Options{
-		Port:              port,
-		Version:           version,
-		Token:             testToken,
-		SessionsPath:      sessionsPath,
-		TmuxConfPath:      filepath.Join(t.TempDir(), "tmux.conf"),
-		LogDir:            filepath.Join(t.TempDir(), "logs"),
-		StatuslineDumpDir: filepath.Join(t.TempDir(), "statusline"),
-	})
-	if err != nil {
-		t.Fatalf("New: %v", err)
-	}
-	ctx, cancel := context.WithCancel(context.Background())
-	done := make(chan struct{})
-	go func() {
-		_ = srv.Run(ctx)
-		close(done)
-	}()
-
-	stop := func() {
-		cancel()
-		select {
-		case <-done:
-		case <-time.After(5 * time.Second):
-			t.Errorf("server did not shut down within 5s")
-		}
-	}
-
-	addr := "http://127.0.0.1:" + strconv.Itoa(port)
-	deadline := time.Now().Add(2 * time.Second)
-	for {
-		resp, err := http.Get(addr + "/healthz")
-		if err == nil {
-			_, _ = io.Copy(io.Discard, resp.Body)
-			_ = resp.Body.Close()
-			if resp.StatusCode == http.StatusOK {
-				return stop
-			}
-		}
-		if time.Now().After(deadline) {
-			stop()
-			t.Fatal("server did not become healthy in 2s")
-		}
-		time.Sleep(5 * time.Millisecond)
-	}
-}
-
-func TestHealthzReportsVersionHeader(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "test-v1", port))
-
-	resp, err := http.Get("http://127.0.0.1:" + strconv.Itoa(port) + "/healthz")
-	if err != nil {
-		t.Fatalf("get /healthz: %v", err)
-	}
-	defer resp.Body.Close()
-
-	if got := resp.Header.Get(ServeVersionHeader); got != "test-v1" {
-		t.Errorf("X-Ctm-Serve = %q, want %q", got, "test-v1")
-	}
-	if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "application/json") {
-		t.Errorf("Content-Type = %q, want application/json", got)
-	}
-
-	var body struct {
-		Status        string  `json:"status"`
-		UptimeSeconds float64 `json:"uptime_seconds"`
-	}
-	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body.Status != "ok" {
-		t.Errorf("status = %q, want ok", body.Status)
-	}
-	if body.UptimeSeconds < 0 {
-		t.Errorf("uptime_seconds = %v, want >= 0", body.UptimeSeconds)
-	}
-}
-
-func TestHealthIncludesComponents(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "vX", port))
-
-	resp := authedGet(t, "http://127.0.0.1:"+strconv.Itoa(port)+"/health")
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		t.Fatalf("status = %d, want 200", resp.StatusCode)
-	}
-
-	var body struct {
-		Status     string            `json:"status"`
-		Version    string            `json:"version"`
-		Components map[string]string `json:"components"`
-	}
-	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body.Version != "vX" {
-		t.Errorf("version = %q, want vX", body.Version)
-	}
-	if body.Components["http"] != "ok" {
-		t.Errorf("components.http = %q, want ok", body.Components["http"])
-	}
-}
-
-func TestAuthRejectsUnauthenticated(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "vA", port))
-
-	// /healthz is public; every other documented endpoint requires the
-	// bearer token.
-	authed := []string{
-		"/health",
-		"/api/bootstrap",
-		"/api/sessions",
-		"/events/all",
-	}
-	for _, path := range authed {
-		resp, err := http.Get("http://127.0.0.1:" + strconv.Itoa(port) + path)
-		if err != nil {
-			t.Fatalf("get %s: %v", path, err)
-		}
-		_ = resp.Body.Close()
-		if resp.StatusCode != http.StatusUnauthorized {
-			t.Errorf("GET %s without token: status = %d, want 401", path, resp.StatusCode)
-		}
-	}
-}
-
-func TestSessionsListEmptyByDefault(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "v1", port))
-
-	resp := authedGet(t, "http://127.0.0.1:"+strconv.Itoa(port)+"/api/sessions")
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusOK {
-		t.Fatalf("status = %d, want 200", resp.StatusCode)
-	}
-	body, _ := io.ReadAll(resp.Body)
-	// Empty sessions.json → empty JSON array.
-	if got := strings.TrimSpace(string(body)); got != "[]" {
-		t.Errorf("body = %q, want '[]'", got)
-	}
-}
-
-func TestBootstrapReflectsServerVersion(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "vBoot", port))
-
-	resp := authedGet(t, "http://127.0.0.1:"+strconv.Itoa(port)+"/api/bootstrap")
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusOK {
-		t.Fatalf("status = %d, want 200", resp.StatusCode)
-	}
-	var body struct {
-		Version    string `json:"version"`
-		Port       int    `json:"port"`
-		HasWebhook bool   `json:"has_webhook"`
-	}
-	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
-		t.Fatalf("decode: %v", err)
-	}
-	if body.Version != "vBoot" || body.Port != port {
-		t.Errorf("body = %+v, want version=vBoot port=%d", body, port)
-	}
-}
-
-func TestSingleInstanceGuardDetectsSibling(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "first", port))
-
-	_, err := New(Options{Port: port, Version: "second", Token: testToken})
-	if !errors.Is(err, ErrAlreadyRunning) {
-		t.Errorf("err = %v, want ErrAlreadyRunning", err)
-	}
-}
-
-func TestSingleInstanceGuardRefusesForeignListener(t *testing.T) {
-	l, err := net.Listen("tcp", "127.0.0.1:0")
-	if err != nil {
-		t.Fatalf("listen: %v", err)
-	}
-	defer l.Close()
-	port := l.Addr().(*net.TCPAddr).Port
-
-	_, err = New(Options{Port: port, Version: "x", Token: testToken})
-	if err == nil {
-		t.Fatal("New succeeded on foreign-occupied port; want error")
-	}
-	if errors.Is(err, ErrAlreadyRunning) {
-		t.Errorf("err = %v, want non-ErrAlreadyRunning", err)
-	}
-	if !strings.Contains(err.Error(), "non-ctm-serve") {
-		t.Errorf("err = %q, want mention of non-ctm-serve", err.Error())
-	}
-}
-
-func TestRootServesEmbeddedIndex(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "v1", port))
-
-	resp, err := http.Get("http://127.0.0.1:" + strconv.Itoa(port) + "/")
-	if err != nil {
-		t.Fatalf("get /: %v", err)
-	}
-	defer resp.Body.Close()
-	body, _ := io.ReadAll(resp.Body)
-	// Vite builds emit `` (lowercase). Anything HTML-ish
-	// proves the embed is wired; aesthetic checks happen in the UI.
-	if !strings.Contains(strings.ToLower(string(body)), "") {
-		t.Errorf("body missing : %q", string(body)[:min(200, len(body))])
-	}
-	if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
-		t.Errorf("Content-Type = %q, want text/html", got)
-	}
-}
-
-func TestUnknownAPIReturns404(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "v1", port))
-
-	resp, err := http.Get("http://127.0.0.1:" + strconv.Itoa(port) + "/api/does-not-exist")
-	if err != nil {
-		t.Fatalf("get /api: %v", err)
-	}
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusNotFound {
-		t.Errorf("status = %d, want 404", resp.StatusCode)
-	}
-}
-
-func TestUnknownEventsReturns404(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "v1", port))
-
-	// /events/all and /events/session/{name} are registered; anything
-	// else under /events/ should hit the placeholder catch-all and 404.
-	resp, err := http.Get("http://127.0.0.1:" + strconv.Itoa(port) + "/events/does-not-exist")
-	if err != nil {
-		t.Fatalf("get /events: %v", err)
-	}
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusNotFound {
-		t.Errorf("status = %d, want 404", resp.StatusCode)
-	}
-}
-
-func TestHealthzRejectsPost(t *testing.T) {
-	port := pickFreePort(t)
-	t.Cleanup(startServer(t, "v1", port))
-
-	resp, err := http.Post("http://127.0.0.1:"+strconv.Itoa(port)+"/healthz",
-		"text/plain", strings.NewReader("nope"))
-	if err != nil {
-		t.Fatalf("post /healthz: %v", err)
-	}
-	defer resp.Body.Close()
-	if resp.StatusCode != http.StatusMethodNotAllowed {
-		t.Errorf("status = %d, want 405", resp.StatusCode)
-	}
-}
diff --git a/internal/serve/sockerr.go b/internal/serve/sockerr.go
deleted file mode 100644
index b88166a..0000000
--- a/internal/serve/sockerr.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package serve
-
-import (
-	"errors"
-	"io"
-	"net/http"
-	"syscall"
-)
-
-// isAddrInUse reports whether err is the kernel's "address already in
-// use" condition. Works on Linux + macOS via errno unwrapping; nothing
-// platform-specific.
-func isAddrInUse(err error) bool {
-	return errors.Is(err, syscall.EADDRINUSE)
-}
-
-// probeIsCtmServe issues a short-timeout GET /healthz against addr and
-// returns true when the response carries the X-Ctm-Serve header. Used
-// by the single-instance guard after a failed bind so we can
-// distinguish "another ctm serve is up" (silent success) from "some
-// other process owns the port" (refuse).
-func probeIsCtmServe(addr string) bool {
-	client := &http.Client{Timeout: probeTimeout}
-	resp, err := client.Get("http://" + addr + "/healthz")
-	if err != nil {
-		return false
-	}
-	defer func() {
-		_, _ = io.Copy(io.Discard, resp.Body)
-		_ = resp.Body.Close()
-	}()
-	if resp.StatusCode != http.StatusOK {
-		return false
-	}
-	return resp.Header.Get(ServeVersionHeader) != ""
-}
diff --git a/internal/serve/store/cost_store.go b/internal/serve/store/cost_store.go
deleted file mode 100644
index c60f742..0000000
--- a/internal/serve/store/cost_store.go
+++ /dev/null
@@ -1,340 +0,0 @@
-// Package store persists per-session token/cost history so the
-// dashboard can render a cumulative-cost chart (V13) that survives
-// daemon restarts.
-//
-// The store is a thin wrapper over SQLite (github.com/mattn/go-sqlite3,
-// CGo — picked for raw retrieval speed; see project_v13_storage_decision).
-// One table (cost_points) holds append-only samples; a compound index
-// on (session, ts) keeps Range queries cheap regardless of how many
-// sessions have landed rows.
-//
-// # Required server.go wiring (coordinator owns this)
-//
-// Open the DB after `hub := events.NewHub(0)`:
-//
-//	costDB, err := store.OpenCostStore(filepath.Join(config.Dir(), "ctm.db"))
-//	if err != nil { return nil, fmt.Errorf("open cost db: %w", err) }
-//
-// Attach it to the Server struct (field `cost store.CostStore`) and
-// close it in Run's shutdown path:
-//
-//	defer costDB.Close()
-//
-// Subscribe a goroutine to the hub that writes `quota_update` events
-// carrying `session` + token triples into the store — see
-// store.SubscribeQuotaWriter for the helper.
-//
-// Mount the handler in registerRoutes:
-//
-//	mux.Handle("GET /api/cost", authHF(api.Cost(s.cost)))
-package store
-
-import (
-	"database/sql"
-	"errors"
-	"fmt"
-	"net/url"
-	"os"
-	"path/filepath"
-	"sync"
-	"time"
-
-	// Blank import registers the "sqlite3" driver with database/sql so
-	// sql.Open("sqlite3", …) below can resolve it. Build is gated on the
-	// `sqlite_fts5` build tag (see Makefile / sonar-project.properties).
-	_ "github.com/mattn/go-sqlite3"
-)
-
-// Point is a single persisted cost sample.
-type Point struct {
-	TS            time.Time
-	Session       string
-	InputTokens   int64
-	OutputTokens  int64
-	CacheTokens   int64
-	CostUSDMicros int64 // USD * 1_000_000
-}
-
-// Totals is the aggregate returned by CostStore.Totals for a window.
-type Totals struct {
-	InputTokens   int64
-	OutputTokens  int64
-	CacheTokens   int64
-	CostUSDMicros int64
-}
-
-// CostStore is the persistence seam. Handlers depend on the interface
-// so tests can swap in an in-memory fake.
-type CostStore interface {
-	// Insert appends a batch of points in a single transaction. A nil
-	// or empty slice is a no-op (returns nil).
-	Insert(points []Point) error
-
-	// Range returns every point for session (or all sessions if
-	// session == "") with ts ∈ [since, until], sorted oldest-first.
-	Range(session string, since, until time.Time) ([]Point, error)
-
-	// Totals aggregates all points with ts >= since across every
-	// session. The caller picks the time window; the store has no
-	// opinion about what "total" means.
-	Totals(since time.Time) (Totals, error)
-
-	// Close releases the underlying DB handle. Idempotent.
-	Close() error
-}
-
-// sqliteCostStore is the production CostStore backed by github.com/mattn/go-sqlite3.
-type sqliteCostStore struct {
-	db     *sql.DB
-	closed bool
-	mu     sync.Mutex
-}
-
-// OpenCostStore opens (or creates) the SQLite DB at path and applies
-// the V13 schema. Callers should Close() on shutdown.
-//
-// WAL + NORMAL sync is used for write throughput; busy_timeout=5000ms
-// keeps the handler-side Writer from erroring under light contention
-// with the quota-subscriber goroutine.
-func OpenCostStore(path string) (CostStore, error) {
-	// Ensure the parent directory exists. Production opens the DB at
-	// ~/.config/ctm/ctm.db; on a fresh install (or in CI runners
-	// without a pre-existing config dir) the parent might not exist
-	// yet, and mattn/go-sqlite3 surfaces that as
-	// "unable to open database file". Cheap and idempotent — no-op
-	// for ":memory:" (filepath.Dir returns "."). Errors here are
-	// non-fatal: if mkdir fails we let sql.Open surface the real
-	// problem.
-	if path != ":memory:" {
-		_ = os.MkdirAll(filepath.Dir(path), 0o700)
-	}
-
-	// DSN tuning: ?_busy_timeout=5000 waits out brief writer locks;
-	// ?_journal=WAL enables concurrent readers; ?_sync=NORMAL pairs
-	// with WAL for an acceptable durability-vs-speed trade.
-	v := url.Values{}
-	v.Set("_busy_timeout", "5000")
-	v.Set("_journal", "WAL")
-	v.Set("_sync", "NORMAL")
-	dsn := fmt.Sprintf("file:%s?%s", path, v.Encode())
-
-	db, err := sql.Open("sqlite3", dsn)
-	if err != nil {
-		return nil, fmt.Errorf("sql.Open: %w", err)
-	}
-	// SQLite doesn't benefit from a large pool; a single writer avoids
-	// "database is locked" churn under WAL.
-	db.SetMaxOpenConns(1)
-	db.SetMaxIdleConns(1)
-
-	if err := applySchema(db); err != nil {
-		_ = db.Close()
-		return nil, fmt.Errorf("apply schema: %w", err)
-	}
-	return &sqliteCostStore{db: db}, nil
-}
-
-// errCostStoreClosed is returned by every method after Close — the
-// `db == nil` guard collapses to one sentinel for callers and removes
-// the duplicated literal Sonar previously flagged.
-const errCostStoreClosedMsg = "cost store closed"
-
-const schemaSQL = `
-CREATE TABLE IF NOT EXISTS cost_points(
-  ts              INTEGER NOT NULL, -- unix millis
-  session         TEXT NOT NULL,
-  input_tokens    INTEGER NOT NULL,
-  output_tokens   INTEGER NOT NULL,
-  cache_tokens    INTEGER NOT NULL,
-  cost_usd_micros INTEGER NOT NULL
-);
-CREATE INDEX IF NOT EXISTS cost_points_session_ts ON cost_points(session, ts);
-CREATE TABLE IF NOT EXISTS schema_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
-INSERT OR IGNORE INTO schema_meta(key, value) VALUES('version', '2');
-
--- V19 slice 3 (v0.3): FTS5 index over tool_call payloads.
--- Trigram tokenizer so queries like "needle" match inside
--- tokens like "has-needle-row" without needing explicit wildcards.
--- Wiped on every boot; the tailer's replay (offset starts at 0)
--- repopulates it via the tool_call hub subscriber.
-CREATE VIRTUAL TABLE IF NOT EXISTS tool_calls_fts USING fts5(
-  session, ts UNINDEXED, tool UNINDEXED, content,
-  tokenize = 'trigram'
-);
-`
-
-func applySchema(db *sql.DB) error {
-	// PRAGMAs must run outside a transaction.
-	for _, pragma := range []string{
-		"PRAGMA journal_mode=WAL;",
-		"PRAGMA synchronous=NORMAL;",
-	} {
-		if _, err := db.Exec(pragma); err != nil {
-			return fmt.Errorf("%s: %w", pragma, err)
-		}
-	}
-	if _, err := db.Exec(schemaSQL); err != nil {
-		return fmt.Errorf("schema DDL: %w", err)
-	}
-	// V19 slice 3: the FTS index is rebuilt on each boot by the
-	// tailer's offset-0 replay feeding the tool_call subscriber, so
-	// we start from an empty table to avoid accumulating duplicates
-	// across restarts.
-	if _, err := db.Exec("DELETE FROM tool_calls_fts;"); err != nil {
-		return fmt.Errorf("wipe fts: %w", err)
-	}
-	return nil
-}
-
-func (s *sqliteCostStore) Insert(points []Point) error {
-	if len(points) == 0 {
-		return nil
-	}
-	s.mu.Lock()
-	closed := s.closed
-	s.mu.Unlock()
-	if closed {
-		return errors.New(errCostStoreClosedMsg)
-	}
-	tx, err := s.db.Begin()
-	if err != nil {
-		return fmt.Errorf("begin: %w", err)
-	}
-	// Rollback on any error path — safe to call after Commit.
-	defer func() { _ = tx.Rollback() }()
-
-	stmt, err := tx.Prepare(`
-INSERT INTO cost_points(ts, session, input_tokens, output_tokens, cache_tokens, cost_usd_micros)
-VALUES(?, ?, ?, ?, ?, ?)`)
-	if err != nil {
-		return fmt.Errorf("prepare: %w", err)
-	}
-	defer func() { _ = stmt.Close() }()
-
-	for _, p := range points {
-		if _, err := stmt.Exec(
-			p.TS.UnixMilli(),
-			p.Session,
-			p.InputTokens,
-			p.OutputTokens,
-			p.CacheTokens,
-			p.CostUSDMicros,
-		); err != nil {
-			return fmt.Errorf("exec: %w", err)
-		}
-	}
-	if err := tx.Commit(); err != nil {
-		return fmt.Errorf("commit: %w", err)
-	}
-	return nil
-}
-
-func (s *sqliteCostStore) Range(session string, since, until time.Time) ([]Point, error) {
-	s.mu.Lock()
-	closed := s.closed
-	s.mu.Unlock()
-	if closed {
-		return nil, errors.New(errCostStoreClosedMsg)
-	}
-	sinceMs := since.UnixMilli()
-	untilMs := until.UnixMilli()
-
-	var (
-		rows *sql.Rows
-		err  error
-	)
-	if session == "" {
-		rows, err = s.db.Query(`
-SELECT ts, session, input_tokens, output_tokens, cache_tokens, cost_usd_micros
-FROM cost_points
-WHERE ts >= ? AND ts <= ?
-ORDER BY ts ASC`, sinceMs, untilMs)
-	} else {
-		rows, err = s.db.Query(`
-SELECT ts, session, input_tokens, output_tokens, cache_tokens, cost_usd_micros
-FROM cost_points
-WHERE session = ? AND ts >= ? AND ts <= ?
-ORDER BY ts ASC`, session, sinceMs, untilMs)
-	}
-	if err != nil {
-		return nil, fmt.Errorf("query: %w", err)
-	}
-	defer func() { _ = rows.Close() }()
-
-	out := make([]Point, 0, 64)
-	for rows.Next() {
-		var (
-			tsMs int64
-			p    Point
-		)
-		if err := rows.Scan(
-			&tsMs,
-			&p.Session,
-			&p.InputTokens,
-			&p.OutputTokens,
-			&p.CacheTokens,
-			&p.CostUSDMicros,
-		); err != nil {
-			return nil, fmt.Errorf("scan: %w", err)
-		}
-		p.TS = time.UnixMilli(tsMs).UTC()
-		out = append(out, p)
-	}
-	if err := rows.Err(); err != nil {
-		return nil, fmt.Errorf("rows.Err: %w", err)
-	}
-	return out, nil
-}
-
-func (s *sqliteCostStore) Totals(since time.Time) (Totals, error) {
-	s.mu.Lock()
-	closed := s.closed
-	s.mu.Unlock()
-	if closed {
-		return Totals{}, errors.New(errCostStoreClosedMsg)
-	}
-	// Cost is an append-only cumulative-delta series: every row is a
-	// token snapshot at a point in time. The totals view the handler
-	// wants is "latest counts per session, summed". Use a correlated
-	// MAX(ts) sub-select to avoid pulling every row into memory.
-	row := s.db.QueryRow(`
-WITH latest AS (
-  SELECT cp.session,
-         cp.input_tokens,
-         cp.output_tokens,
-         cp.cache_tokens,
-         cp.cost_usd_micros
-  FROM cost_points cp
-  JOIN (
-    SELECT session, MAX(ts) AS max_ts
-    FROM cost_points
-    WHERE ts >= ?
-    GROUP BY session
-  ) m ON m.session = cp.session AND m.max_ts = cp.ts
-)
-SELECT
-  COALESCE(SUM(input_tokens),   0),
-  COALESCE(SUM(output_tokens),  0),
-  COALESCE(SUM(cache_tokens),   0),
-  COALESCE(SUM(cost_usd_micros), 0)
-FROM latest`, since.UnixMilli())
-
-	var t Totals
-	if err := row.Scan(&t.InputTokens, &t.OutputTokens, &t.CacheTokens, &t.CostUSDMicros); err != nil {
-		return Totals{}, fmt.Errorf("scan: %w", err)
-	}
-	return t, nil
-}
-
-func (s *sqliteCostStore) Close() error {
-	s.mu.Lock()
-	defer s.mu.Unlock()
-	if s.closed {
-		return nil
-	}
-	s.closed = true
-	if s.db != nil {
-		return s.db.Close()
-	}
-	return nil
-}
diff --git a/internal/serve/store/cost_store_test.go b/internal/serve/store/cost_store_test.go
deleted file mode 100644
index ee8fd63..0000000
--- a/internal/serve/store/cost_store_test.go
+++ /dev/null
@@ -1,311 +0,0 @@
-package store
-
-import (
-	"context"
-	"encoding/json"
-	"path/filepath"
-	"sync"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-func openTest(t *testing.T) CostStore {
-	t.Helper()
-	s, err := OpenCostStore(filepath.Join(t.TempDir(), "ctm.db"))
-	if err != nil {
-		t.Fatalf("OpenCostStore: %v", err)
-	}
-	t.Cleanup(func() { _ = s.Close() })
-	return s
-}
-
-func TestCostStore_InsertRangeRoundTrip(t *testing.T) {
-	t.Parallel()
-	s := openTest(t)
-
-	base := time.Now().UTC().Truncate(time.Millisecond)
-	pts := []Point{
-		{TS: base.Add(-2 * time.Minute), Session: "alpha", InputTokens: 100, OutputTokens: 50, CacheTokens: 10, CostUSDMicros: 1234},
-		{TS: base.Add(-1 * time.Minute), Session: "alpha", InputTokens: 200, OutputTokens: 80, CacheTokens: 20, CostUSDMicros: 2468},
-		{TS: base, Session: "beta", InputTokens: 50, OutputTokens: 25, CacheTokens: 5, CostUSDMicros: 500},
-	}
-	if err := s.Insert(pts); err != nil {
-		t.Fatalf("Insert: %v", err)
-	}
-
-	got, err := s.Range("", base.Add(-5*time.Minute), base.Add(time.Minute))
-	if err != nil {
-		t.Fatalf("Range all: %v", err)
-	}
-	if len(got) != 3 {
-		t.Fatalf("len(all) = %d, want 3", len(got))
-	}
-	for i := 1; i < len(got); i++ {
-		if got[i].TS.Before(got[i-1].TS) {
-			t.Errorf("Range not sorted asc: %v before %v", got[i].TS, got[i-1].TS)
-		}
-	}
-}
-
-func TestCostStore_RangeSessionFilter(t *testing.T) {
-	t.Parallel()
-	s := openTest(t)
-
-	base := time.Now().UTC().Truncate(time.Millisecond)
-	_ = s.Insert([]Point{
-		{TS: base, Session: "alpha", InputTokens: 100},
-		{TS: base, Session: "beta", InputTokens: 200},
-	})
-
-	alpha, err := s.Range("alpha", base.Add(-time.Hour), base.Add(time.Hour))
-	if err != nil {
-		t.Fatalf("Range alpha: %v", err)
-	}
-	if len(alpha) != 1 || alpha[0].Session != "alpha" {
-		t.Fatalf("alpha = %+v, want one alpha row", alpha)
-	}
-}
-
-func TestCostStore_RangeTimeBounded(t *testing.T) {
-	t.Parallel()
-	s := openTest(t)
-
-	now := time.Now().UTC().Truncate(time.Millisecond)
-	_ = s.Insert([]Point{
-		{TS: now.Add(-2 * time.Hour), Session: "alpha", InputTokens: 10},
-		{TS: now, Session: "alpha", InputTokens: 20},
-	})
-
-	// Only the recent row falls within a 1h window.
-	got, err := s.Range("alpha", now.Add(-time.Hour), now.Add(time.Second))
-	if err != nil {
-		t.Fatalf("Range: %v", err)
-	}
-	if len(got) != 1 {
-		t.Fatalf("len = %d, want 1", len(got))
-	}
-	if got[0].InputTokens != 20 {
-		t.Errorf("got[0].InputTokens = %d, want 20", got[0].InputTokens)
-	}
-}
-
-func TestCostStore_RangeEmpty(t *testing.T) {
-	t.Parallel()
-	s := openTest(t)
-	got, err := s.Range("nobody", time.Now().Add(-time.Hour), time.Now())
-	if err != nil {
-		t.Fatalf("Range empty: %v", err)
-	}
-	if len(got) != 0 {
-		t.Fatalf("len = %d, want 0", len(got))
-	}
-}
-
-func TestCostStore_Totals(t *testing.T) {
-	t.Parallel()
-	s := openTest(t)
-
-	now := time.Now().UTC().Truncate(time.Millisecond)
-	// Two sessions, two samples each. Totals picks the latest per
-	// session and sums across sessions — matches the handler shape.
-	_ = s.Insert([]Point{
-		{TS: now.Add(-time.Minute), Session: "a", InputTokens: 10, OutputTokens: 5, CacheTokens: 1, CostUSDMicros: 100},
-		{TS: now, Session: "a", InputTokens: 20, OutputTokens: 10, CacheTokens: 2, CostUSDMicros: 200},
-		{TS: now, Session: "b", InputTokens: 50, OutputTokens: 25, CacheTokens: 5, CostUSDMicros: 500},
-	})
-
-	tot, err := s.Totals(now.Add(-time.Hour))
-	if err != nil {
-		t.Fatalf("Totals: %v", err)
-	}
-	// Latest-per-session: a=20/10/2/200, b=50/25/5/500 → sum.
-	if tot.InputTokens != 70 {
-		t.Errorf("InputTokens = %d, want 70", tot.InputTokens)
-	}
-	if tot.OutputTokens != 35 {
-		t.Errorf("OutputTokens = %d, want 35", tot.OutputTokens)
-	}
-	if tot.CacheTokens != 7 {
-		t.Errorf("CacheTokens = %d, want 7", tot.CacheTokens)
-	}
-	if tot.CostUSDMicros != 700 {
-		t.Errorf("CostUSDMicros = %d, want 700", tot.CostUSDMicros)
-	}
-}
-
-func TestCostStore_BatchInsertTxSemantics(t *testing.T) {
-	t.Parallel()
-	// Correctness check: a 500-row batch lands atomically. Not a
-	// benchmark — just verifies the BEGIN/COMMIT wrap works.
-	s := openTest(t)
-
-	batch := make([]Point, 500)
-	base := time.Now().UTC().Truncate(time.Millisecond)
-	for i := range batch {
-		batch[i] = Point{
-			TS:          base.Add(time.Duration(i) * time.Millisecond),
-			Session:     "bulk",
-			InputTokens: int64(i),
-		}
-	}
-	if err := s.Insert(batch); err != nil {
-		t.Fatalf("Insert batch: %v", err)
-	}
-	got, err := s.Range("bulk", base.Add(-time.Second), base.Add(time.Hour))
-	if err != nil {
-		t.Fatalf("Range: %v", err)
-	}
-	if len(got) != 500 {
-		t.Fatalf("len = %d, want 500", len(got))
-	}
-}
-
-func TestCostStore_ConcurrentWrites(t *testing.T) {
-	t.Parallel()
-	s := openTest(t)
-
-	const writers = 4
-	const perWriter = 50
-	base := time.Now().UTC().Truncate(time.Millisecond)
-
-	var wg sync.WaitGroup
-	for w := 0; w < writers; w++ {
-		wg.Add(1)
-		go func(w int) {
-			defer wg.Done()
-			for i := 0; i < perWriter; i++ {
-				_ = s.Insert([]Point{{
-					TS:          base.Add(time.Duration(w*perWriter+i) * time.Millisecond),
-					Session:     "shared",
-					InputTokens: int64(w*100 + i),
-				}})
-			}
-		}(w)
-	}
-	wg.Wait()
-
-	got, err := s.Range("shared", base.Add(-time.Second), base.Add(time.Hour))
-	if err != nil {
-		t.Fatalf("Range: %v", err)
-	}
-	if len(got) != writers*perWriter {
-		t.Fatalf("len = %d, want %d", len(got), writers*perWriter)
-	}
-}
-
-func TestCostStore_CloseIdempotent(t *testing.T) {
-	t.Parallel()
-	s, err := OpenCostStore(filepath.Join(t.TempDir(), "ctm.db"))
-	if err != nil {
-		t.Fatalf("Open: %v", err)
-	}
-	if err := s.Close(); err != nil {
-		t.Fatalf("first Close: %v", err)
-	}
-	// Second call must not panic or error.
-	if err := s.Close(); err != nil {
-		t.Errorf("second Close: %v", err)
-	}
-	// Operations after Close return an error, not a panic.
-	if err := s.Insert([]Point{{Session: "x", TS: time.Now()}}); err == nil {
-		t.Errorf("Insert after Close: want error, got nil")
-	}
-}
-
-func TestSubscribeQuotaWriter_PersistsPerSessionUpdates(t *testing.T) {
-	t.Parallel()
-	s := openTest(t)
-	hub := events.NewHub(0)
-
-	ctx, cancel := context.WithCancel(context.Background())
-	done := make(chan struct{})
-	ready := make(chan struct{})
-	go func() {
-		defer close(done)
-		SubscribeQuotaWriter(ctx, hub, s, ready)
-	}()
-	<-ready
-
-	payload, _ := json.Marshal(map[string]any{
-		"session":       "alpha",
-		"input_tokens":  1000,
-		"output_tokens": 500,
-		"cache_tokens":  100,
-	})
-	hub.Publish(events.Event{Type: "quota_update", Session: "alpha", Payload: payload})
-
-	// Poll for the write to land (async subscriber).
-	deadline := time.Now().Add(2 * time.Second)
-	for time.Now().Before(deadline) {
-		pts, err := s.Range("alpha", time.Now().Add(-time.Hour), time.Now().Add(time.Hour))
-		if err == nil && len(pts) == 1 {
-			if pts[0].InputTokens != 1000 {
-				t.Errorf("InputTokens = %d, want 1000", pts[0].InputTokens)
-			}
-			if pts[0].CostUSDMicros == 0 {
-				t.Errorf("cost not computed")
-			}
-			cancel()
-			<-done
-			return
-		}
-		time.Sleep(10 * time.Millisecond)
-	}
-	cancel()
-	<-done
-	t.Fatalf("no point persisted within deadline")
-}
-
-func TestSubscribeQuotaWriter_IgnoresGlobalAndEmpty(t *testing.T) {
-	t.Parallel()
-	s := openTest(t)
-	hub := events.NewHub(0)
-
-	ctx, cancel := context.WithCancel(context.Background())
-	done := make(chan struct{})
-	ready := make(chan struct{})
-	go func() {
-		defer close(done)
-		SubscribeQuotaWriter(ctx, hub, s, ready)
-	}()
-	<-ready
-
-	// Global rate-limit update: no session, no tokens. Must be ignored.
-	gbl, _ := json.Marshal(map[string]any{"weekly_pct": 10})
-	hub.Publish(events.Event{Type: "quota_update", Payload: gbl})
-
-	// Empty per-session — all zeros, should also be ignored (noise).
-	zero, _ := json.Marshal(map[string]any{"session": "a", "input_tokens": 0})
-	hub.Publish(events.Event{Type: "quota_update", Session: "a", Payload: zero})
-
-	// Let the goroutine process.
-	time.Sleep(100 * time.Millisecond)
-	cancel()
-	<-done
-
-	pts, _ := s.Range("", time.Now().Add(-time.Hour), time.Now().Add(time.Hour))
-	if len(pts) != 0 {
-		t.Errorf("got %d points, want 0", len(pts))
-	}
-}
-
-func TestComputeCostMicros(t *testing.T) {
-	t.Parallel()
-	// 1M input tokens → $3 → 3_000_000 micros.
-	got := ComputeCostMicros(1_000_000, 0, 0)
-	if got != 3_000_000 {
-		t.Errorf("input 1M: got %d, want 3_000_000", got)
-	}
-	// 1M output → $15 → 15_000_000 micros.
-	got = ComputeCostMicros(0, 1_000_000, 0)
-	if got != 15_000_000 {
-		t.Errorf("output 1M: got %d, want 15_000_000", got)
-	}
-	// 1M cache → $0.30 → 300_000 micros.
-	got = ComputeCostMicros(0, 0, 1_000_000)
-	if got != 300_000 {
-		t.Errorf("cache 1M: got %d, want 300_000", got)
-	}
-}
diff --git a/internal/serve/store/search_store.go b/internal/serve/store/search_store.go
deleted file mode 100644
index 1b7deed..0000000
--- a/internal/serve/store/search_store.go
+++ /dev/null
@@ -1,183 +0,0 @@
-package store
-
-// Package-level docs in cost_store.go. This file adds the V19 slice 3
-// FTS5-backed full-text search layer, sharing the same SQLite handle
-// already opened by OpenCostStore.
-
-import (
-	"database/sql"
-	"errors"
-	"fmt"
-	"strings"
-	"time"
-)
-
-// SearchMatch mirrors api.SearchMatch in wire form. Exported so the
-// server.go adapter can type-assert cleanly (shared field shape keeps
-// the api package free of the store dependency).
-type SearchMatch struct {
-	Session string
-	TS      time.Time
-	Tool    string
-	Snippet string
-}
-
-// SearchStore is the persistence seam for the FTS5 index. sqliteCostStore
-// implements both CostStore and SearchStore so a single *sql.DB handle
-// backs every V13/V19 write path.
-type SearchStore interface {
-	// IndexToolCall appends one searchable row. Idempotency is the
-	// caller's problem — OpenCostStore wipes the FTS table on boot so
-	// the tailer's offset-0 replay repopulates it fresh.
-	IndexToolCall(session, tool, content string, ts time.Time) error
-
-	// SearchFTS returns at most limit matches for q, optionally filtered
-	// by session. The boolean return reports truncation.
-	SearchFTS(q, sessionFilter string, limit int) ([]SearchMatch, bool, error)
-}
-
-// IndexToolCall writes one row to the FTS virtual table. Empty content
-// is skipped — nothing to search.
-func (s *sqliteCostStore) IndexToolCall(session, tool, content string, ts time.Time) error {
-	content = strings.TrimSpace(content)
-	if content == "" {
-		return nil
-	}
-
-	s.mu.Lock()
-	closed := s.closed
-	s.mu.Unlock()
-	if closed {
-		return errors.New("search store closed")
-	}
-
-	_, err := s.db.Exec(
-		`INSERT INTO tool_calls_fts(session, ts, tool, content) VALUES(?, ?, ?, ?)`,
-		session, ts.UnixMilli(), tool, content,
-	)
-	if err != nil {
-		return fmt.Errorf("fts insert: %w", err)
-	}
-	return nil
-}
-
-// SearchFTS runs an FTS5 MATCH query. q is wrapped in double quotes so
-// shell-level punctuation (slashes, dots, underscores in file paths)
-// is treated as a literal phrase under the trigram tokenizer. Limit is
-// clamped to >=1 by the caller; we over-fetch by one row to detect
-// truncation.
-func (s *sqliteCostStore) SearchFTS(q, sessionFilter string, limit int) ([]SearchMatch, bool, error) {
-	s.mu.Lock()
-	closed := s.closed
-	s.mu.Unlock()
-	if closed {
-		return nil, false, errors.New("search store closed")
-	}
-	if limit < 1 {
-		limit = 1
-	}
-
-	// Trigram tokenizer expects a phrase expression; the double-quoted
-	// form matches a literal substring of length >= 3. The caller
-	// enforces the minimum length at the API boundary.
-	phrase := fts5QuotePhrase(q)
-
-	var (
-		rows *sql.Rows
-		err  error
-	)
-	if sessionFilter == "" {
-		rows, err = s.db.Query(`
-SELECT session, ts, tool, content
-FROM tool_calls_fts
-WHERE tool_calls_fts MATCH ?
-ORDER BY rowid DESC
-LIMIT ?`, phrase, limit+1)
-	} else {
-		rows, err = s.db.Query(`
-SELECT session, ts, tool, content
-FROM tool_calls_fts
-WHERE tool_calls_fts MATCH ? AND session = ?
-ORDER BY rowid DESC
-LIMIT ?`, phrase, sessionFilter, limit+1)
-	}
-	if err != nil {
-		return nil, false, fmt.Errorf("fts query: %w", err)
-	}
-	defer func() { _ = rows.Close() }()
-
-	out := make([]SearchMatch, 0, limit)
-	truncated := false
-	for rows.Next() {
-		if len(out) >= limit {
-			truncated = true
-			break
-		}
-		var (
-			session, tool, content string
-			tsMs                   int64
-		)
-		if err := rows.Scan(&session, &tsMs, &tool, &content); err != nil {
-			return nil, false, fmt.Errorf("fts scan: %w", err)
-		}
-		out = append(out, SearchMatch{
-			Session: session,
-			TS:      time.UnixMilli(tsMs).UTC(),
-			Tool:    tool,
-			Snippet: snippet(content, q),
-		})
-	}
-	if err := rows.Err(); err != nil {
-		return nil, false, fmt.Errorf("fts rows: %w", err)
-	}
-	return out, truncated, nil
-}
-
-// fts5QuotePhrase escapes the caller's query so FTS5 treats it as a
-// literal phrase: any internal double-quote is doubled per the FTS5
-// quoting rules, and the whole string is wrapped in double quotes.
-// This is intentionally narrow — we do not support the full MATCH
-// grammar (AND/OR/NEAR) in slice 3; power users can add the v0.4
-// advanced-query-syntax mode later.
-func fts5QuotePhrase(q string) string {
-	var b strings.Builder
-	b.Grow(len(q) + 2)
-	b.WriteByte('"')
-	for _, r := range q {
-		if r == '"' {
-			b.WriteString(`""`)
-			continue
-		}
-		b.WriteRune(r)
-	}
-	b.WriteByte('"')
-	return b.String()
-}
-
-// snippet extracts a 60-char window around the first case-insensitive
-// occurrence of q in content. Mirrors the slice-1 snippet shape so UI
-// code stays unchanged.
-func snippet(content, q string) string {
-	const half = 30
-	lc := strings.ToLower(content)
-	lq := strings.ToLower(q)
-	idx := strings.Index(lc, lq)
-	if idx < 0 {
-		// Shouldn't happen — FTS MATCH said there was a hit. Fall
-		// back to a head-of-content prefix so we still return
-		// something intelligible.
-		if len(content) > 2*half+len(q) {
-			return content[:2*half+len(q)]
-		}
-		return content
-	}
-	start := idx - half
-	if start < 0 {
-		start = 0
-	}
-	end := idx + len(q) + half
-	if end > len(content) {
-		end = len(content)
-	}
-	return content[start:end]
-}
diff --git a/internal/serve/store/search_store_test.go b/internal/serve/store/search_store_test.go
deleted file mode 100644
index 416b96e..0000000
--- a/internal/serve/store/search_store_test.go
+++ /dev/null
@@ -1,236 +0,0 @@
-package store
-
-import (
-	"context"
-	"encoding/json"
-	"path/filepath"
-	"strings"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-func newTestStore(t *testing.T) *sqliteCostStore {
-	t.Helper()
-	path := filepath.Join(t.TempDir(), "ctm.db")
-	s, err := OpenCostStore(path)
-	if err != nil {
-		t.Fatalf("open: %v", err)
-	}
-	t.Cleanup(func() { _ = s.Close() })
-	return s.(*sqliteCostStore)
-}
-
-func TestSearchFTS_FindsLiteralSubstring(t *testing.T) {
-	s := newTestStore(t)
-	ts := time.Date(2026, 4, 21, 16, 28, 0, 0, time.UTC)
-
-	must := func(err error) {
-		t.Helper()
-		if err != nil {
-			t.Fatal(err)
-		}
-	}
-	must(s.IndexToolCall("alpha", "Bash", "echo has-needle-row here", ts))
-	must(s.IndexToolCall("alpha", "Read", "/tmp/foo.txt", ts.Add(time.Second)))
-	must(s.IndexToolCall("beta", "Grep", "pattern=needle", ts.Add(2*time.Second)))
-
-	hits, truncated, err := s.SearchFTS("needle", "", 100)
-	if err != nil {
-		t.Fatalf("search: %v", err)
-	}
-	if truncated {
-		t.Errorf("unexpected truncated=true")
-	}
-	if len(hits) != 2 {
-		t.Fatalf("hits=%d want 2 (got %+v)", len(hits), hits)
-	}
-	for _, h := range hits {
-		if !strings.Contains(strings.ToLower(h.Snippet), "needle") {
-			t.Errorf("snippet missing query: %q", h.Snippet)
-		}
-	}
-}
-
-func TestSearchFTS_SessionFilter(t *testing.T) {
-	s := newTestStore(t)
-	ts := time.Now().UTC()
-
-	_ = s.IndexToolCall("alpha", "Bash", "needle-in-alpha", ts)
-	_ = s.IndexToolCall("beta", "Bash", "needle-in-beta", ts)
-
-	hits, _, err := s.SearchFTS("needle", "beta", 100)
-	if err != nil {
-		t.Fatalf("search: %v", err)
-	}
-	if len(hits) != 1 {
-		t.Fatalf("hits=%d want 1", len(hits))
-	}
-	if hits[0].Session != "beta" {
-		t.Errorf("session=%q want beta", hits[0].Session)
-	}
-}
-
-func TestSearchFTS_Truncation(t *testing.T) {
-	s := newTestStore(t)
-	base := time.Now().UTC()
-	for i := 0; i < 5; i++ {
-		_ = s.IndexToolCall("alpha", "Bash", "row-needle-"+string(rune('a'+i)), base.Add(time.Duration(i)*time.Second))
-	}
-
-	hits, truncated, err := s.SearchFTS("needle", "", 3)
-	if err != nil {
-		t.Fatalf("search: %v", err)
-	}
-	if !truncated {
-		t.Errorf("truncated=false want true")
-	}
-	if len(hits) != 3 {
-		t.Errorf("hits=%d want 3", len(hits))
-	}
-}
-
-func TestSearchFTS_PathLikeTokens(t *testing.T) {
-	s := newTestStore(t)
-	_ = s.IndexToolCall("alpha", "Edit", "src/live.ts updated", time.Now().UTC())
-
-	// Trigram tokenizer handles substring queries even across
-	// slashes — this is the property that motivated the choice.
-	hits, _, err := s.SearchFTS("live.ts", "", 10)
-	if err != nil {
-		t.Fatalf("search: %v", err)
-	}
-	if len(hits) != 1 {
-		t.Fatalf("hits=%d want 1 (%+v)", len(hits), hits)
-	}
-}
-
-func TestSearchFTS_EmptyContentSkipped(t *testing.T) {
-	s := newTestStore(t)
-	// Empty content → nothing indexed; whitespace-only too.
-	if err := s.IndexToolCall("alpha", "Bash", "", time.Now().UTC()); err != nil {
-		t.Errorf("empty content should not error, got %v", err)
-	}
-	if err := s.IndexToolCall("alpha", "Bash", "   ", time.Now().UTC()); err != nil {
-		t.Errorf("whitespace content should not error, got %v", err)
-	}
-	hits, _, _ := s.SearchFTS("alpha", "", 10)
-	if len(hits) != 0 {
-		t.Errorf("empty-content inserts leaked into FTS: %+v", hits)
-	}
-}
-
-func TestSearchFTS_ClosedStoreErrors(t *testing.T) {
-	s := newTestStore(t)
-	_ = s.Close()
-	if _, _, err := s.SearchFTS("needle", "", 10); err == nil {
-		t.Error("expected error after Close()")
-	}
-	if err := s.IndexToolCall("alpha", "Bash", "x", time.Now().UTC()); err == nil {
-		t.Error("expected error after Close()")
-	}
-}
-
-// ---- subscriber --------------------------------------------------------
-
-func TestSubscribeToolCallWriter_IndexesPublishedEvents(t *testing.T) {
-	s := newTestStore(t)
-	hub := events.NewHub(0)
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-
-	ready := make(chan struct{})
-	done := make(chan struct{})
-	go func() {
-		defer close(done)
-		SubscribeToolCallWriter(ctx, hub, s, ready)
-	}()
-	<-ready
-
-	payload, _ := json.Marshal(map[string]any{
-		"session":  "alpha",
-		"tool":     "Bash",
-		"input":    "echo haystack-needle-here",
-		"summary":  "ok",
-		"is_error": false,
-		"ts":       time.Now().UTC().Format(time.RFC3339Nano),
-	})
-	hub.Publish(events.Event{Type: "tool_call", Session: "alpha", Payload: payload})
-
-	// Poll briefly for the async insert.
-	deadline := time.Now().Add(2 * time.Second)
-	for time.Now().Before(deadline) {
-		hits, _, _ := s.SearchFTS("needle", "", 10)
-		if len(hits) == 1 && hits[0].Session == "alpha" {
-			cancel()
-			<-done
-			return
-		}
-		time.Sleep(20 * time.Millisecond)
-	}
-	cancel()
-	<-done
-	t.Fatal("subscriber did not index the published event")
-}
-
-func TestSubscribeToolCallWriter_IgnoresNonToolCallEvents(t *testing.T) {
-	s := newTestStore(t)
-	hub := events.NewHub(0)
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-
-	ready := make(chan struct{})
-	done := make(chan struct{})
-	go func() {
-		defer close(done)
-		SubscribeToolCallWriter(ctx, hub, s, ready)
-	}()
-	<-ready
-
-	hub.Publish(events.Event{
-		Type:    "quota_update",
-		Session: "alpha",
-		Payload: []byte(`{"session":"alpha","input_tokens":1}`),
-	})
-
-	// Give the subscriber a chance to (not) process it.
-	time.Sleep(100 * time.Millisecond)
-
-	hits, _, _ := s.SearchFTS("needle", "", 10)
-	if len(hits) != 0 {
-		t.Errorf("quota_update leaked into FTS: %+v", hits)
-	}
-	cancel()
-	<-done
-}
-
-func TestWipeFTSOnBoot(t *testing.T) {
-	path := filepath.Join(t.TempDir(), "ctm.db")
-	s1, err := OpenCostStore(path)
-	if err != nil {
-		t.Fatal(err)
-	}
-	ss1 := s1.(*sqliteCostStore)
-	_ = ss1.IndexToolCall("alpha", "Bash", "needle-one", time.Now().UTC())
-	hits, _, _ := ss1.SearchFTS("needle", "", 10)
-	if len(hits) != 1 {
-		t.Fatalf("pre-close hits=%d want 1", len(hits))
-	}
-	_ = s1.Close()
-
-	// Re-open — OpenCostStore should wipe the FTS table so the
-	// tailer's replay can rebuild it fresh without duplicates.
-	s2, err := OpenCostStore(path)
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer func() { _ = s2.Close() }()
-	ss2 := s2.(*sqliteCostStore)
-	hits, _, _ = ss2.SearchFTS("needle", "", 10)
-	if len(hits) != 0 {
-		t.Errorf("boot wipe missed: %d rows survived restart", len(hits))
-	}
-}
diff --git a/internal/serve/store/subscriber.go b/internal/serve/store/subscriber.go
deleted file mode 100644
index 0581bfd..0000000
--- a/internal/serve/store/subscriber.go
+++ /dev/null
@@ -1,121 +0,0 @@
-// Package store — quota_update → cost_points subscriber.
-//
-// Wired in server.go (coordinator-owned):
-//
-//	costDone := make(chan struct{})
-//	go func() {
-//	    defer close(costDone)
-//	    store.SubscribeQuotaWriter(runCtx, hub, costDB)
-//	}()
-//
-// No cancellation channel is returned; the goroutine exits when
-// SubscribeQuotaWriter's ctx is cancelled (wired off the daemon's
-// root ctx so shutdown draining matches the attention/webhook
-// pattern in server.go).
-package store
-
-import (
-	"context"
-	"encoding/json"
-	"log/slog"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-// Per-million-token input/output/cache prices used to compute
-// cost_usd_micros. Values track Claude Sonnet 4.5 public pricing
-// (input $3/Mt, output $15/Mt, cache read $0.30/Mt). We store integers
-// (USD * 1_000_000) rather than floats so SUM() stays exact across
-// the seven-day window.
-const (
-	PriceInputPerMillionMicros  int64 = 3_000_000
-	PriceOutputPerMillionMicros int64 = 15_000_000
-	PriceCachePerMillionMicros  int64 = 300_000
-)
-
-// ComputeCostMicros returns USD * 1e6 for the given token triple.
-// Exported for tests and so handlers never have to redo the math.
-func ComputeCostMicros(input, output, cache int64) int64 {
-	// micros = tokens * priceMicrosPerMillion / 1_000_000
-	return (input*PriceInputPerMillionMicros +
-		output*PriceOutputPerMillionMicros +
-		cache*PriceCachePerMillionMicros) / 1_000_000
-}
-
-// quotaUpdatePayload matches the JSON that QuotaIngester.publishSession
-// writes. Only per-session payloads (Session != "") carry token fields;
-// global rate-limit updates are ignored by the writer.
-type quotaUpdatePayload struct {
-	Session      string `json:"session"`
-	InputTokens  int64  `json:"input_tokens"`
-	OutputTokens int64  `json:"output_tokens"`
-	CacheTokens  int64  `json:"cache_tokens"`
-}
-
-// SubscribeQuotaWriter subscribes to the hub's quota_update stream and
-// persists each per-session update as a cost_points row. Blocks until
-// ctx is cancelled or the subscription is closed by the hub.
-//
-// If ready is non-nil, it is closed as soon as the hub subscription is
-// registered — callers that need to race-free publish between the
-// "start this goroutine" and "publish the first event" lines should
-// wait on ready first. Nil is fine for production, where events
-// arrive asynchronously from the quota ingester well after startup.
-//
-// Write errors are logged and swallowed — a failed persistence must
-// not take down the daemon, and the next update will carry the same
-// cumulative token counts so no data is lost permanently.
-func SubscribeQuotaWriter(ctx context.Context, hub *events.Hub, store CostStore, ready chan<- struct{}) {
-	if hub == nil || store == nil {
-		if ready != nil {
-			close(ready)
-		}
-		return
-	}
-	sub, _ := hub.Subscribe("", "")
-	defer sub.Close()
-	if ready != nil {
-		close(ready)
-	}
-
-	for {
-		select {
-		case <-ctx.Done():
-			return
-		case ev, ok := <-sub.Events():
-			if !ok {
-				return
-			}
-			if ev.Type != "quota_update" {
-				continue
-			}
-			if ev.Session == "" {
-				// Global rate-limit update — no token counts to persist.
-				continue
-			}
-			var p quotaUpdatePayload
-			if err := json.Unmarshal(ev.Payload, &p); err != nil {
-				slog.Debug("cost store: bad quota_update payload", "err", err)
-				continue
-			}
-			// Guard against obviously-empty payloads. If all three token
-			// counters are zero there's nothing useful to chart and
-			// writing it just adds noise to Totals().
-			if p.InputTokens == 0 && p.OutputTokens == 0 && p.CacheTokens == 0 {
-				continue
-			}
-			cost := ComputeCostMicros(p.InputTokens, p.OutputTokens, p.CacheTokens)
-			if err := store.Insert([]Point{{
-				TS:            time.Now().UTC(),
-				Session:       p.Session,
-				InputTokens:   p.InputTokens,
-				OutputTokens:  p.OutputTokens,
-				CacheTokens:   p.CacheTokens,
-				CostUSDMicros: cost,
-			}}); err != nil {
-				slog.Warn("cost store insert failed", "session", p.Session, "err", err)
-			}
-		}
-	}
-}
diff --git a/internal/serve/store/tool_call_subscriber.go b/internal/serve/store/tool_call_subscriber.go
deleted file mode 100644
index ed0a2f8..0000000
--- a/internal/serve/store/tool_call_subscriber.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package store
-
-// V19 slice 3 — stream tool_call events from the hub into the FTS index.
-//
-// The tailer replays each session's JSONL from offset 0 on boot, so
-// this subscriber indexes both historical and live rows without
-// needing a separate backfill loop. OpenCostStore wipes the FTS table
-// on each boot, which keeps dedup trivial: every restart rebuilds a
-// clean index from the incoming event stream.
-//
-// Wired in server.go alongside the cost subscriber:
-//
-//	toolCallDone := make(chan struct{})
-//	go func() {
-//	    defer close(toolCallDone)
-//	    store.SubscribeToolCallWriter(runCtx, hub, costDB, nil)
-//	}()
-
-import (
-	"context"
-	"encoding/json"
-	"log/slog"
-	"strings"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-// ToolCallIndexer is satisfied by sqliteCostStore. Kept narrow so
-// tests can swap in a spy.
-type ToolCallIndexer interface {
-	IndexToolCall(session, tool, content string, ts time.Time) error
-}
-
-// toolCallPayload mirrors ingest.ToolCallPayload; duplicated here to
-// avoid a store → ingest dependency.
-type toolCallPayload struct {
-	Session string    `json:"session"`
-	Tool    string    `json:"tool"`
-	Input   string    `json:"input,omitempty"`
-	Summary string    `json:"summary,omitempty"`
-	IsError bool      `json:"is_error"`
-	TS      time.Time `json:"ts"`
-}
-
-// SubscribeToolCallWriter subscribes to every tool_call event and
-// writes a searchable row to the FTS index. Returns when ctx is
-// cancelled or the subscription channel closes. `ready`, if non-nil,
-// is closed once the subscription attaches — tests wait on it to
-// avoid racing the hub.
-func SubscribeToolCallWriter(
-	ctx context.Context,
-	hub *events.Hub,
-	idx ToolCallIndexer,
-	ready chan<- struct{},
-) {
-	if hub == nil || idx == nil {
-		if ready != nil {
-			close(ready)
-		}
-		return
-	}
-	sub, _ := hub.Subscribe("", "")
-	defer sub.Close()
-	if ready != nil {
-		close(ready)
-	}
-
-	for {
-		select {
-		case <-ctx.Done():
-			return
-		case ev, ok := <-sub.Events():
-			if !ok {
-				return
-			}
-			if ev.Type != "tool_call" {
-				continue
-			}
-			var p toolCallPayload
-			if err := json.Unmarshal(ev.Payload, &p); err != nil {
-				slog.Debug("fts subscriber: malformed payload", "err", err)
-				continue
-			}
-			content := joinNonEmpty(p.Input, p.Summary)
-			if content == "" {
-				continue
-			}
-			session := p.Session
-			if session == "" {
-				session = ev.Session
-			}
-			ts := p.TS
-			if ts.IsZero() {
-				ts = time.Now().UTC()
-			}
-			if err := idx.IndexToolCall(session, p.Tool, content, ts); err != nil {
-				slog.Warn("fts subscriber: index write failed",
-					"session", session, "tool", p.Tool, "err", err)
-			}
-		}
-	}
-}
-
-func joinNonEmpty(parts ...string) string {
-	var b strings.Builder
-	for _, p := range parts {
-		p = strings.TrimSpace(p)
-		if p == "" {
-			continue
-		}
-		if b.Len() > 0 {
-			b.WriteByte(' ')
-		}
-		b.WriteString(p)
-	}
-	return b.String()
-}
diff --git a/internal/serve/webhook/dispatcher.go b/internal/serve/webhook/dispatcher.go
deleted file mode 100644
index 1f3b9b1..0000000
--- a/internal/serve/webhook/dispatcher.go
+++ /dev/null
@@ -1,290 +0,0 @@
-// Package webhook dispatches attention_raised events to a user-configured
-// HTTP endpoint. Retries with exponential backoff and debounces duplicate
-// (session, alert) pairs within a configurable window to prevent flapping.
-package webhook
-
-import (
-	"bytes"
-	"context"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"net/http"
-	"sync"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-const (
-	defaultTimeout     = 10 * time.Second
-	defaultDebounce    = 60 * time.Second
-	attentionRaisedKey = "attention_raised"
-	maxInflight        = 8
-)
-
-var defaultRetryDelays = []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
-
-// Config configures the dispatcher. Zero-value fields fall back to
-// sensible defaults; an empty URL disables dispatch entirely.
-type Config struct {
-	URL         string
-	AuthHeader  string
-	UIBaseURL   string
-	Timeout     time.Duration
-	DebounceFor time.Duration
-	// RetryDelays lets tests inject short delays. Nil → {1s, 2s, 4s}.
-	RetryDelays []time.Duration
-}
-
-// SessionResolver returns metadata for a session name. Zero values are
-// acceptable when unknown — the dispatcher emits whatever is provided.
-type SessionResolver interface {
-	Resolve(name string) (uuid string, workdir string, mode string, ok bool)
-}
-
-// Payload matches spec §6 exactly. Time is serialized as RFC 3339.
-type Payload struct {
-	Alert       string    `json:"alert"`
-	Session     string    `json:"session"`
-	SessionUUID string    `json:"session_uuid,omitempty"`
-	Workdir     string    `json:"workdir,omitempty"`
-	Mode        string    `json:"mode,omitempty"`
-	Details     string    `json:"details,omitempty"`
-	TS          time.Time `json:"ts"`
-	UIURL       string    `json:"ui_url,omitempty"`
-}
-
-// attentionPayload mirrors the JSON shape the attention engine publishes
-// on the hub under Event.Payload for attention_raised.
-type attentionPayload struct {
-	State   string `json:"state"`
-	Details string `json:"details"`
-	// TS is optional; the attention engine may or may not set it.
-	TS time.Time `json:"ts"`
-}
-
-// Dispatcher subscribes to the hub and dispatches attention_raised
-// events via HTTP POST, retrying on failure and debouncing duplicates.
-type Dispatcher struct {
-	hub      *events.Hub
-	resolver SessionResolver
-	cfg      Config
-	client   *http.Client
-
-	mu          sync.Mutex
-	lastSent    map[string]time.Time
-	debounceFor time.Duration
-	retryDelays []time.Duration
-
-	sem chan struct{}
-	wg  sync.WaitGroup
-}
-
-// NewDispatcher constructs a Dispatcher. httpClient may be nil; a client
-// with the configured per-attempt Timeout is built on demand.
-func NewDispatcher(hub *events.Hub, resolver SessionResolver, cfg Config, httpClient *http.Client) *Dispatcher {
-	timeout := cfg.Timeout
-	if timeout <= 0 {
-		timeout = defaultTimeout
-	}
-	if httpClient == nil {
-		httpClient = &http.Client{Timeout: timeout}
-	}
-	debounce := cfg.DebounceFor
-	if debounce <= 0 {
-		debounce = defaultDebounce
-	}
-	delays := cfg.RetryDelays
-	if delays == nil {
-		delays = defaultRetryDelays
-	}
-	return &Dispatcher{
-		hub:         hub,
-		resolver:    resolver,
-		cfg:         cfg,
-		client:      httpClient,
-		lastSent:    make(map[string]time.Time),
-		debounceFor: debounce,
-		retryDelays: delays,
-		sem:         make(chan struct{}, maxInflight),
-	}
-}
-
-// Run subscribes to the hub's global stream, dispatching attention_raised
-// events until ctx is done. When cfg.URL is empty, it logs and returns
-// immediately. Active POST goroutines honour ctx cancellation; Run waits
-// for them before returning.
-func (d *Dispatcher) Run(ctx context.Context) error {
-	if d.cfg.URL == "" {
-		slog.Debug("webhook dispatcher disabled")
-		return nil
-	}
-
-	sub, _ := d.hub.Subscribe("", "")
-	defer sub.Close()
-
-	slog.Info("webhook dispatcher started", "url", d.cfg.URL)
-
-	for {
-		select {
-		case <-ctx.Done():
-			d.wg.Wait()
-			return nil
-		case e, ok := <-sub.Events():
-			if !ok {
-				d.wg.Wait()
-				return nil
-			}
-			if e.Type != attentionRaisedKey {
-				continue
-			}
-			d.handle(ctx, e)
-		}
-	}
-}
-
-func (d *Dispatcher) handle(ctx context.Context, e events.Event) {
-	var ap attentionPayload
-	if len(e.Payload) > 0 {
-		_ = json.Unmarshal(e.Payload, &ap)
-	}
-	alert := ap.State
-	if alert == "" {
-		// No alert label → nothing actionable to send.
-		slog.Debug("webhook: attention_raised missing state, skipping", "session", e.Session)
-		return
-	}
-
-	key := e.Session + "|" + alert
-	now := time.Now()
-
-	d.mu.Lock()
-	last, hadPrev := d.lastSent[key]
-	if hadPrev && now.Sub(last) < d.debounceFor {
-		d.mu.Unlock()
-		slog.Debug("webhook: debounced", "session", e.Session, "alert", alert)
-		return
-	}
-	d.lastSent[key] = now
-	d.mu.Unlock()
-
-	ts := ap.TS
-	if ts.IsZero() {
-		ts = now
-	}
-
-	p := Payload{
-		Alert:   alert,
-		Session: e.Session,
-		Details: ap.Details,
-		TS:      ts,
-	}
-	if d.resolver != nil {
-		if uuid, workdir, mode, ok := d.resolver.Resolve(e.Session); ok {
-			p.SessionUUID = uuid
-			p.Workdir = workdir
-			p.Mode = mode
-		}
-	}
-	if d.cfg.UIBaseURL != "" && e.Session != "" {
-		p.UIURL = d.cfg.UIBaseURL + "/s/" + e.Session
-	}
-
-	// Bounded concurrency: if the pool is full, wait or abort on ctx.
-	select {
-	case d.sem <- struct{}{}:
-	case <-ctx.Done():
-		return
-	}
-
-	d.wg.Add(1)
-	go func() {
-		defer d.wg.Done()
-		defer func() { <-d.sem }()
-		d.send(ctx, p)
-	}()
-}
-
-func (d *Dispatcher) send(ctx context.Context, p Payload) {
-	body, err := json.Marshal(p)
-	if err != nil {
-		slog.Warn("webhook: marshal failed", "err", err, "session", p.Session, "alert", p.Alert)
-		return
-	}
-
-	var lastErr error
-	// attempts = initial try + len(retryDelays). After attempt i (0-indexed),
-	// if it failed and i < len(retryDelays), sleep retryDelays[i] then retry.
-	for i := 0; i <= len(d.retryDelays); i++ {
-		if ctx.Err() != nil {
-			return
-		}
-		lastErr = d.postOnce(ctx, body)
-		if lastErr == nil {
-			if i > 0 {
-				slog.Info("webhook: delivered after retry", "session", p.Session, "alert", p.Alert, "attempts", i+1)
-			} else {
-				slog.Info("webhook: delivered", "session", p.Session, "alert", p.Alert)
-			}
-			return
-		}
-		if i == len(d.retryDelays) {
-			break
-		}
-		delay := d.retryDelays[i]
-		t := time.NewTimer(delay)
-		select {
-		case <-ctx.Done():
-			t.Stop()
-			return
-		case <-t.C:
-		}
-	}
-	slog.Warn("webhook: delivery failed after retries",
-		"session", p.Session,
-		"alert", p.Alert,
-		"attempts", len(d.retryDelays)+1,
-		"err", lastErr)
-}
-
-func (d *Dispatcher) postOnce(ctx context.Context, body []byte) error {
-	// Per-attempt timeout: Config.Timeout. The http.Client.Timeout covers
-	// this when client is constructed by us; but when a caller injects
-	// their own client we still want a bound, so use a per-request ctx.
-	timeout := d.cfg.Timeout
-	if timeout <= 0 {
-		timeout = defaultTimeout
-	}
-	reqCtx, cancel := context.WithTimeout(ctx, timeout)
-	defer cancel()
-
-	req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, d.cfg.URL, bytes.NewReader(body))
-	if err != nil {
-		return err
-	}
-	req.Header.Set("Content-Type", "application/json")
-	if d.cfg.AuthHeader != "" {
-		req.Header.Set("Authorization", d.cfg.AuthHeader)
-	}
-
-	resp, err := d.client.Do(req)
-	if err != nil {
-		return err
-	}
-	defer func() {
-		_, _ = io.Copy(io.Discard, resp.Body)
-		_ = resp.Body.Close()
-	}()
-
-	if resp.StatusCode >= 200 && resp.StatusCode < 300 {
-		return nil
-	}
-	return fmt.Errorf("webhook: non-2xx status %d", resp.StatusCode)
-}
-
-// ErrDisabled is kept for potential future callers that want to check
-// whether a dispatcher was disabled. Currently Run just returns nil.
-var ErrDisabled = errors.New("webhook dispatcher disabled")
diff --git a/internal/serve/webhook/dispatcher_test.go b/internal/serve/webhook/dispatcher_test.go
deleted file mode 100644
index ecd426e..0000000
--- a/internal/serve/webhook/dispatcher_test.go
+++ /dev/null
@@ -1,341 +0,0 @@
-package webhook
-
-import (
-	"context"
-	"encoding/json"
-	"io"
-	"net/http"
-	"net/http/httptest"
-	"runtime"
-	"sync"
-	"sync/atomic"
-	"testing"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/serve/events"
-)
-
-type stubResolver struct {
-	uuid, workdir, mode string
-	ok                  bool
-}
-
-func (s stubResolver) Resolve(string) (string, string, string, bool) {
-	return s.uuid, s.workdir, s.mode, s.ok
-}
-
-// fastCfg returns a Config with tiny retry delays / debounce for tests.
-func fastCfg(url string) Config {
-	return Config{
-		URL:         url,
-		UIBaseURL:   "http://localhost:37778",
-		Timeout:     2 * time.Second,
-		DebounceFor: 50 * time.Millisecond,
-		RetryDelays: []time.Duration{1 * time.Millisecond, 1 * time.Millisecond, 1 * time.Millisecond},
-	}
-}
-
-// publishAttention publishes an attention_raised event onto the hub and
-// returns immediately. Callers synchronize via the test server callback.
-func publishAttention(t *testing.T, hub *events.Hub, session, state, details string) {
-	t.Helper()
-	payload, err := json.Marshal(map[string]any{
-		"state":   state,
-		"details": details,
-	})
-	if err != nil {
-		t.Fatalf("marshal: %v", err)
-	}
-	hub.Publish(events.Event{
-		Type:    "attention_raised",
-		Session: session,
-		Payload: payload,
-	})
-}
-
-// waitFor spins until cond() returns true or the timeout elapses.
-func waitFor(t *testing.T, timeout time.Duration, cond func() bool) {
-	t.Helper()
-	deadline := time.Now().Add(timeout)
-	for time.Now().Before(deadline) {
-		if cond() {
-			return
-		}
-		time.Sleep(2 * time.Millisecond)
-	}
-	t.Fatalf("condition not met within %v", timeout)
-}
-
-func TestDispatcher_EmptyURL_ReturnsImmediately(t *testing.T) {
-	hub := events.NewHub(0)
-	d := NewDispatcher(hub, nil, Config{}, nil)
-	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
-	defer cancel()
-	if err := d.Run(ctx); err != nil {
-		t.Fatalf("expected nil err, got %v", err)
-	}
-}
-
-func TestDispatcher_PostsCorrectPayload(t *testing.T) {
-	var (
-		mu      sync.Mutex
-		gotBody []byte
-		gotAuth string
-	)
-	done := make(chan struct{})
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		body, _ := io.ReadAll(r.Body)
-		mu.Lock()
-		gotBody = body
-		gotAuth = r.Header.Get("Authorization")
-		mu.Unlock()
-		if ct := r.Header.Get("Content-Type"); ct != "application/json" {
-			t.Errorf("Content-Type = %q, want application/json", ct)
-		}
-		w.WriteHeader(http.StatusNoContent)
-		select {
-		case <-done:
-		default:
-			close(done)
-		}
-	}))
-	defer srv.Close()
-
-	hub := events.NewHub(0)
-	cfg := fastCfg(srv.URL)
-	cfg.AuthHeader = "Bearer secret-xyz"
-
-	d := NewDispatcher(hub, stubResolver{uuid: "uuid-1", workdir: "/tmp/work", mode: "yolo", ok: true}, cfg, nil)
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	runDone := make(chan struct{})
-	go func() { _ = d.Run(ctx); close(runDone) }()
-
-	// Let the subscription register before publishing.
-	time.Sleep(10 * time.Millisecond)
-	publishAttention(t, hub, "sess-a", "error_burst", "3/5 errors")
-
-	select {
-	case <-done:
-	case <-time.After(2 * time.Second):
-		t.Fatal("server did not receive request")
-	}
-
-	mu.Lock()
-	body := gotBody
-	auth := gotAuth
-	mu.Unlock()
-
-	var p Payload
-	if err := json.Unmarshal(body, &p); err != nil {
-		t.Fatalf("unmarshal: %v  body=%s", err, string(body))
-	}
-	if p.Alert != "error_burst" {
-		t.Errorf("Alert = %q, want error_burst", p.Alert)
-	}
-	if p.Session != "sess-a" {
-		t.Errorf("Session = %q, want sess-a", p.Session)
-	}
-	if p.SessionUUID != "uuid-1" {
-		t.Errorf("SessionUUID = %q, want uuid-1", p.SessionUUID)
-	}
-	if p.Workdir != "/tmp/work" {
-		t.Errorf("Workdir = %q, want /tmp/work", p.Workdir)
-	}
-	if p.Mode != "yolo" {
-		t.Errorf("Mode = %q, want yolo", p.Mode)
-	}
-	if p.Details != "3/5 errors" {
-		t.Errorf("Details = %q, want 3/5 errors", p.Details)
-	}
-	if p.UIURL != "http://localhost:37778/s/sess-a" {
-		t.Errorf("UIURL = %q", p.UIURL)
-	}
-	if p.TS.IsZero() {
-		t.Error("TS should be set")
-	}
-	if auth != "Bearer secret-xyz" {
-		t.Errorf("Authorization = %q", auth)
-	}
-
-	cancel()
-	<-runDone
-}
-
-func TestDispatcher_RetriesOn500ThenSucceeds(t *testing.T) {
-	var calls atomic.Int32
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		n := calls.Add(1)
-		if n < 3 {
-			w.WriteHeader(http.StatusInternalServerError)
-			return
-		}
-		w.WriteHeader(http.StatusOK)
-	}))
-	defer srv.Close()
-
-	hub := events.NewHub(0)
-	d := NewDispatcher(hub, nil, fastCfg(srv.URL), nil)
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	runDone := make(chan struct{})
-	go func() { _ = d.Run(ctx); close(runDone) }()
-
-	time.Sleep(10 * time.Millisecond)
-	publishAttention(t, hub, "sess-b", "stuck", "")
-
-	waitFor(t, 2*time.Second, func() bool { return calls.Load() >= 3 })
-	// Give the dispatcher a moment to record "success".
-	time.Sleep(20 * time.Millisecond)
-	if got := calls.Load(); got != 3 {
-		t.Errorf("expected 3 calls, got %d", got)
-	}
-
-	cancel()
-	<-runDone
-}
-
-func TestDispatcher_StopsAfterMaxRetries(t *testing.T) {
-	var calls atomic.Int32
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		calls.Add(1)
-		w.WriteHeader(http.StatusBadGateway)
-	}))
-	defer srv.Close()
-
-	hub := events.NewHub(0)
-	d := NewDispatcher(hub, nil, fastCfg(srv.URL), nil)
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	runDone := make(chan struct{})
-	go func() { _ = d.Run(ctx); close(runDone) }()
-
-	time.Sleep(10 * time.Millisecond)
-	publishAttention(t, hub, "sess-c", "tmux_dead", "")
-
-	// 1 initial + 3 retries = 4 attempts.
-	waitFor(t, 2*time.Second, func() bool { return calls.Load() >= 4 })
-	time.Sleep(30 * time.Millisecond)
-	if got := calls.Load(); got != 4 {
-		t.Errorf("expected 4 attempts, got %d", got)
-	}
-
-	cancel()
-	<-runDone
-}
-
-func TestDispatcher_Debounce(t *testing.T) {
-	var calls atomic.Int32
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		calls.Add(1)
-		w.WriteHeader(http.StatusOK)
-	}))
-	defer srv.Close()
-
-	hub := events.NewHub(0)
-	cfg := fastCfg(srv.URL)
-	cfg.DebounceFor = 80 * time.Millisecond
-	d := NewDispatcher(hub, nil, cfg, nil)
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	runDone := make(chan struct{})
-	go func() { _ = d.Run(ctx); close(runDone) }()
-
-	time.Sleep(10 * time.Millisecond)
-	publishAttention(t, hub, "sess-d", "error_burst", "")
-	waitFor(t, 1*time.Second, func() bool { return calls.Load() == 1 })
-
-	// Second event within the debounce window: dropped.
-	publishAttention(t, hub, "sess-d", "error_burst", "")
-	time.Sleep(30 * time.Millisecond)
-	if got := calls.Load(); got != 1 {
-		t.Errorf("expected 1 call (2nd debounced), got %d", got)
-	}
-
-	// Different alert: should go through.
-	publishAttention(t, hub, "sess-d", "stuck", "")
-	waitFor(t, 1*time.Second, func() bool { return calls.Load() == 2 })
-
-	// After debounce window passes, the same (session, alert) fires again.
-	time.Sleep(100 * time.Millisecond)
-	publishAttention(t, hub, "sess-d", "error_burst", "")
-	waitFor(t, 1*time.Second, func() bool { return calls.Load() == 3 })
-
-	cancel()
-	<-runDone
-}
-
-func TestDispatcher_IgnoresOtherEventTypes(t *testing.T) {
-	var calls atomic.Int32
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		calls.Add(1)
-		w.WriteHeader(http.StatusOK)
-	}))
-	defer srv.Close()
-
-	hub := events.NewHub(0)
-	d := NewDispatcher(hub, nil, fastCfg(srv.URL), nil)
-
-	ctx, cancel := context.WithCancel(context.Background())
-	defer cancel()
-	runDone := make(chan struct{})
-	go func() { _ = d.Run(ctx); close(runDone) }()
-
-	time.Sleep(10 * time.Millisecond)
-	hub.Publish(events.Event{Type: "tool_call", Session: "x", Payload: json.RawMessage(`{}`)})
-	hub.Publish(events.Event{Type: "quota_update", Payload: json.RawMessage(`{}`)})
-	hub.Publish(events.Event{Type: "attention_cleared", Session: "x", Payload: json.RawMessage(`{}`)})
-	time.Sleep(50 * time.Millisecond)
-
-	if got := calls.Load(); got != 0 {
-		t.Errorf("expected 0 calls, got %d", got)
-	}
-
-	cancel()
-	<-runDone
-}
-
-func TestDispatcher_CtxCancel_NoGoroutineLeak(t *testing.T) {
-	// Block indefinitely on the server so a POST is in-flight when we cancel.
-	release := make(chan struct{})
-	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		select {
-		case <-release:
-		case <-r.Context().Done():
-		}
-		w.WriteHeader(http.StatusOK)
-	}))
-	defer srv.Close()
-	defer close(release)
-
-	hub := events.NewHub(0)
-	cfg := fastCfg(srv.URL)
-	cfg.Timeout = 5 * time.Second
-	d := NewDispatcher(hub, nil, cfg, nil)
-
-	baseline := runtime.NumGoroutine()
-
-	ctx, cancel := context.WithCancel(context.Background())
-	runDone := make(chan struct{})
-	go func() { _ = d.Run(ctx); close(runDone) }()
-
-	time.Sleep(10 * time.Millisecond)
-	publishAttention(t, hub, "sess-e", "error_burst", "")
-	time.Sleep(20 * time.Millisecond)
-
-	cancel()
-	select {
-	case <-runDone:
-	case <-time.After(2 * time.Second):
-		t.Fatal("Run did not return after ctx cancel")
-	}
-
-	// Allow Go runtime to reap goroutines.
-	waitFor(t, 2*time.Second, func() bool {
-		return runtime.NumGoroutine() <= baseline+1
-	})
-}
diff --git a/internal/session/spawn.go b/internal/session/spawn.go
index 0c5f3c1..84bc7dc 100644
--- a/internal/session/spawn.go
+++ b/internal/session/spawn.go
@@ -1,16 +1,17 @@
 package session
 
-// V26 — reusable yolo-spawn. Lifted from cmd/yolo.go's createAndAttach
-// so the HTTP /api/sessions create endpoint shares a code path with
-// the CLI. Attach is intentionally NOT part of this function — the
-// daemon never attaches; the CLI wraps this + Attach.
+// Reusable yolo-spawn shared by the CLI yolo path and any future
+// programmatic spawner. Attach is intentionally NOT part of this
+// function — the caller wraps this + tmux attach.
 
 import (
 	"fmt"
+	"log/slog"
 	"os"
 	"path/filepath"
+	"time"
 
-	"github.com/RandomCodeSpace/ctm/internal/claude"
+	"github.com/RandomCodeSpace/ctm/internal/agent"
 )
 
 // TmuxSpawner is the narrow slice of *tmux.Client Yolo needs.
@@ -20,6 +21,14 @@ type TmuxSpawner interface {
 	KillSession(name string) error
 }
 
+// AgentSessionStamper persists a discovered agent-side session/thread
+// identifier onto the named session row. *Store satisfies this via
+// UpdateAgentSessionID. The Saver/Stamper split keeps tests honest —
+// fakes can satisfy Saver alone and skip the discovery write path.
+type AgentSessionStamper interface {
+	UpdateAgentSessionID(name, id string) error
+}
+
 // Saver is the narrow slice of *Store Yolo needs.
 type Saver interface {
 	Save(sess *Session) error
@@ -28,28 +37,43 @@ type Saver interface {
 // SpawnOpts bundles the tmux client and store so Yolo can be driven
 // from either CLI or daemon without depending on config globals.
 //
-// EnvExports is a pre-built shell-export prelude (e.g.
-// "export CLAUDE_CODE_NO_FLICKER='1' CTM_STATUSLINE_DUMP='/tmp/...'")
-// produced by config.ClaudeEnvExports(). Empty when claude-env.json
-// is absent or has no entries.
+// Agent selects the registered agent.Agent driving the pane. Empty is
+// normalized to DefaultAgent (codex) by the call below.
+//
+// EnvExports is a pre-built shell-export prelude produced by the
+// caller (e.g. config.CodexEnvExports). Empty when no env file is
+// present.
+//
+// OverlayPath is currently unused by the codex agent; the field is
+// retained as part of agent.SpawnSpec so a future agent that wants a
+// settings overlay can wire it through.
 type SpawnOpts struct {
 	Name        string
+	Agent       string
 	Workdir     string
 	Tmux        TmuxSpawner
 	Store       Saver
 	OverlayPath string
 	EnvExports  string
+
+	// OnDiscoveryComplete fires when the background DiscoverSessionID
+	// goroutine returns (success, timeout, or no stamper). Optional —
+	// production callers leave it nil. Tests pass a chan-close func to
+	// synchronize on completion before asserting on AgentSessionID.
+	OnDiscoveryComplete func()
 }
 
-// Yolo creates a detached tmux session, launches claude in yolo mode,
-// and persists the session state. Returns the populated Session.
+// Yolo creates a detached tmux session, launches the configured agent
+// in yolo mode, and persists the session state. Returns the populated
+// Session.
 //
 // Preconditions:
 //   - Workdir must be absolute
 //   - Workdir must exist and be a directory
+//   - Agent (or DefaultAgent on empty) must be registered
 //
-// Postconditions on success: tmux session exists detached, claude is
-// launched inside it, Store.Save has been called with mode="yolo".
+// Postconditions on success: tmux session exists detached, the agent
+// is launched inside it, Store.Save has been called with mode="yolo".
 // On error before Save: NO session state is persisted.
 func Yolo(opts SpawnOpts) (Session, error) {
 	if !filepath.IsAbs(opts.Workdir) {
@@ -63,9 +87,25 @@ func Yolo(opts SpawnOpts) (Session, error) {
 		return Session{}, fmt.Errorf("workdir is not a directory: %q", opts.Workdir)
 	}
 
+	agentName := opts.Agent
+	if agentName == "" || agentName == "claude" {
+		agentName = DefaultAgent
+	}
+	a, ok := agent.For(agentName)
+	if !ok {
+		return Session{}, fmt.Errorf("unknown agent %q (registered: %v)", agentName, agent.Registered())
+	}
+
 	uid := newUUIDv4()
-	shellCmd := claude.BuildCommand(uid, "yolo", false, opts.OverlayPath, opts.EnvExports)
+	shellCmd := a.BuildCommand(agent.SpawnSpec{
+		UUID:        uid,
+		Mode:        "yolo",
+		Resume:      false,
+		OverlayPath: opts.OverlayPath,
+		EnvExports:  opts.EnvExports,
+	})
 
+	spawnStart := time.Now()
 	if err := opts.Tmux.NewSession(opts.Name, opts.Workdir, shellCmd); err != nil {
 		return Session{}, fmt.Errorf("tmux new-session: %w", err)
 	}
@@ -75,6 +115,7 @@ func Yolo(opts SpawnOpts) (Session, error) {
 		UUID:    uid,
 		Mode:    "yolo",
 		Workdir: opts.Workdir,
+		Agent:   agentName,
 	}
 	if err := opts.Store.Save(&sess); err != nil {
 		// Best-effort cleanup of the orphan tmux session we just created.
@@ -82,5 +123,44 @@ func Yolo(opts SpawnOpts) (Session, error) {
 		_ = opts.Tmux.KillSession(opts.Name)
 		return Session{}, fmt.Errorf("session save: %w", err)
 	}
+
+	// Fire-and-forget: discover the agent's backend session/thread ID
+	// in the background so the user's interactive attach isn't blocked.
+	// On success we stamp it onto the store row so future reattach
+	// uses `codex resume ` instead of `--last`. On timeout / no
+	// stamper / no discovery, the row stays as-is.
+	stamper, hasStamper := opts.Store.(AgentSessionStamper)
+	if hasStamper {
+		go discoverAndStamp(a, opts.Name, spawnStart, stamper, opts.OnDiscoveryComplete)
+	} else if opts.OnDiscoveryComplete != nil {
+		// No stamper means no discovery happened — fire the callback
+		// immediately so tests waiting on it don't deadlock.
+		opts.OnDiscoveryComplete()
+	}
 	return sess, nil
 }
+
+// discoverAndStamp runs the agent's DiscoverSessionID and persists the
+// result via stamper. Errors are logged at debug level — the discovery
+// is opportunistic, never load-bearing. onComplete (if non-nil) is
+// invoked after the stamp attempt regardless of outcome.
+func discoverAndStamp(a agent.Agent, name string, spawnStart time.Time, stamper AgentSessionStamper, onComplete func()) {
+	defer func() {
+		if onComplete != nil {
+			onComplete()
+		}
+	}()
+	id, ok := a.DiscoverSessionID(spawnStart)
+	if !ok {
+		slog.Debug("agent session id discovery timed out",
+			"session", name, "agent", a.Name())
+		return
+	}
+	if err := stamper.UpdateAgentSessionID(name, id); err != nil {
+		slog.Debug("could not stamp agent session id",
+			"session", name, "agent", a.Name(), "id", id, "err", err)
+		return
+	}
+	slog.Debug("stamped agent session id",
+		"session", name, "agent", a.Name(), "id", id)
+}
diff --git a/internal/session/spawn_discovery_test.go b/internal/session/spawn_discovery_test.go
new file mode 100644
index 0000000..e0c69c4
--- /dev/null
+++ b/internal/session/spawn_discovery_test.go
@@ -0,0 +1,133 @@
+package session_test
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	_ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex
+	"github.com/RandomCodeSpace/ctm/internal/session"
+)
+
+// realStoreFakeTmux composes a real *session.Store (so the discovery
+// path can stamp via UpdateAgentSessionID) with a fake tmux that
+// records but does not exec. Returns the store + temp dir for HOME.
+func realStoreFakeTmux(t *testing.T) (*session.Store, *fakeTmux, string) {
+	t.Helper()
+	home := t.TempDir()
+	t.Setenv("HOME", home)
+	storePath := filepath.Join(home, "sessions.json")
+	return session.NewStore(storePath), &fakeTmux{}, home
+}
+
+// dropRolloutAfter creates a codex-shaped rollout file in
+// ~/.codex/sessions// after delay. Returns the UUID that ends
+// up in the filename so the test can assert against it.
+func dropRolloutAfter(t *testing.T, home string, delay time.Duration) string {
+	t.Helper()
+	uuid := "019dd200-1111-7000-8000-aaaabbbbcccc"
+	now := time.Now().UTC()
+	day := filepath.Join(home, ".codex", "sessions",
+		now.Format("2006"), now.Format("01"), now.Format("02"))
+	if err := os.MkdirAll(day, 0755); err != nil {
+		t.Fatalf("mkdir codex day-dir: %v", err)
+	}
+	go func() {
+		time.Sleep(delay)
+		name := "rollout-" + now.Format("2006-01-02T15-04-05") + "-" + uuid + ".jsonl"
+		path := filepath.Join(day, name)
+		_ = os.WriteFile(path, []byte(`{"type":"session_meta"}`), 0644)
+	}()
+	return uuid
+}
+
+// TestYolo_DiscoveryStampsAgentSessionID verifies the full end-to-end
+// flow: session.Yolo spawns, fires the discovery goroutine, the
+// goroutine finds the (simulated) codex rollout file, and stamps the
+// session row's AgentSessionID. Uses OnDiscoveryComplete to
+// synchronize without depending on flaky time-based sleeps.
+func TestYolo_DiscoveryStampsAgentSessionID(t *testing.T) {
+	store, tmux, home := realStoreFakeTmux(t)
+
+	// Set up: simulate codex writing its rollout file ~50ms after spawn.
+	wantUUID := dropRolloutAfter(t, home, 50*time.Millisecond)
+
+	done := make(chan struct{})
+	wd := t.TempDir() // any real dir works as workdir
+
+	sess, err := session.Yolo(session.SpawnOpts{
+		Name:                "discsess",
+		Workdir:             wd,
+		Tmux:                tmux,
+		Store:               store,
+		OnDiscoveryComplete: func() { close(done) },
+	})
+	if err != nil {
+		t.Fatalf("Yolo: %v", err)
+	}
+	if sess.AgentSessionID != "" {
+		t.Errorf("Yolo returned AgentSessionID=%q before discovery; expected empty (stamp happens async)", sess.AgentSessionID)
+	}
+
+	select {
+	case <-done:
+	case <-time.After(6 * time.Second):
+		t.Fatal("discovery goroutine did not complete within 6s")
+	}
+
+	// After discovery completes, store row must carry the UUID.
+	got, err := store.Get("discsess")
+	if err != nil {
+		t.Fatalf("Get: %v", err)
+	}
+	if got.AgentSessionID != wantUUID {
+		t.Fatalf("AgentSessionID = %q, want %q", got.AgentSessionID, wantUUID)
+	}
+	if tmux.newCalled != 1 {
+		t.Errorf("tmux.NewSession called %d times, want 1", tmux.newCalled)
+	}
+}
+
+// TestYolo_DiscoveryTimeoutLeavesStoreRowEmpty verifies the
+// codex-down/no-rollout path: discovery times out, OnDiscoveryComplete
+// still fires, and the store row's AgentSessionID stays empty so
+// `codex resume --last` semantics keep working.
+func TestYolo_DiscoveryTimeoutLeavesStoreRowEmpty(t *testing.T) {
+	// Shrink the discovery budget so the test doesn't take 5s.
+	t.Setenv("HOME", t.TempDir())
+	// Note: we deliberately don't create ~/.codex/sessions/ so
+	// scanForRollout finds nothing each poll.
+	store := session.NewStore(filepath.Join(os.Getenv("HOME"), "sessions.json"))
+	tmux := &fakeTmux{}
+
+	// We rely on the test budget being short enough to finish within
+	// the test's wait. The production constant is 5s; for this test
+	// the timeout will happen naturally before our 6s wait.
+	done := make(chan struct{})
+	wd := t.TempDir()
+
+	if _, err := session.Yolo(session.SpawnOpts{
+		Name:                "timeoutsess",
+		Workdir:             wd,
+		Tmux:                tmux,
+		Store:               store,
+		OnDiscoveryComplete: func() { close(done) },
+	}); err != nil {
+		t.Fatalf("Yolo: %v", err)
+	}
+
+	select {
+	case <-done:
+	case <-time.After(7 * time.Second):
+		t.Fatal("discovery goroutine did not complete within 7s")
+	}
+
+	got, err := store.Get("timeoutsess")
+	if err != nil {
+		t.Fatalf("Get: %v", err)
+	}
+	if got.AgentSessionID != "" {
+		t.Fatalf("expected empty AgentSessionID on timeout, got %q", got.AgentSessionID)
+	}
+}
diff --git a/internal/session/spawn_test.go b/internal/session/spawn_test.go
index f143b5f..0a87bf2 100644
--- a/internal/session/spawn_test.go
+++ b/internal/session/spawn_test.go
@@ -6,6 +6,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	_ "github.com/RandomCodeSpace/ctm/internal/agent/codex" // register codex via init
 	"github.com/RandomCodeSpace/ctm/internal/session"
 )
 
diff --git a/internal/session/state.go b/internal/session/state.go
index 10e213b..0d3c5a9 100644
--- a/internal/session/state.go
+++ b/internal/session/state.go
@@ -18,7 +18,17 @@ import (
 // SchemaVersion is the current on-disk schema version of sessions.json.
 // Bump this and append a Step to the Plan returned by MigrationPlan()
 // whenever the shape of diskData or Session changes in a non-additive way.
-const SchemaVersion = 1
+//
+// v3: codex is the only supported agent. Any v2 rows with agent="claude"
+// are migrated to agent="codex" — the claude implementation has been
+// removed and continuing to reference it would fail at agent.For lookup.
+const SchemaVersion = 3
+
+// DefaultAgent is the registry key used when a session row has no agent
+// field set. Exposed as a constant so cmd/* code branching on the
+// default doesn't drift from the migration / Save / NormalizeAgent
+// codepaths.
+const DefaultAgent = "codex"
 
 // errFmtNotFound is the consistent shape returned by Get/Set/Delete/etc.
 // when a session name is unknown. Callers that distinguish "not found"
@@ -26,17 +36,87 @@ const SchemaVersion = 1
 // sentinel would be a behaviour change.
 const errFmtNotFound = "session %q not found"
 
-// MigrationPlan returns the migrate.Plan for sessions.json. Steps is empty
-// at v1 because the initial migration only stamps the version — no content
-// changes are required to turn an unversioned sessions.json into v1.
+// MigrationPlan returns the migrate.Plan for sessions.json.
+//
+//   - v0 → v1: stamp only (initial schema_version introduction).
+//   - v1 → v2: backfill agent="claude" on rows missing the field
+//     (historical — claude was the only agent at the time).
+//   - v2 → v3: rewrite any agent="claude" rows to agent="codex" after
+//     claude support was removed.
 func MigrationPlan() migrate.Plan {
 	return migrate.Plan{
 		Name:           "sessions.json",
 		CurrentVersion: SchemaVersion,
-		Steps:          []migrate.Step{nil}, // v0 → v1: stamp only
+		Steps: []migrate.Step{
+			nil,                 // v0 → v1: stamp only
+			stampAgentClaude,    // v1 → v2: backfill agent
+			rewriteClaudeToCodex, // v2 → v3: claude → codex
+		},
 	}
 }
 
+// stampAgentClaude walks obj["sessions"] and sets agent="claude" on
+// rows missing the field. Idempotent — rows that already have an
+// agent value are left untouched.
+//
+// obj["sessions"] is the JSON map keyed by session name, so the
+// values are themselves objects. The step decodes lazily to keep
+// per-row diffs minimal.
+//
+// Historical: at v2 claude was the only supported agent. The follow-on
+// v2→v3 step (rewriteClaudeToCodex) rewrites the value once claude
+// was removed.
+func stampAgentClaude(obj map[string]json.RawMessage) error {
+	raw, ok := obj["sessions"]
+	if !ok || len(raw) == 0 {
+		return nil
+	}
+	var byName map[string]map[string]json.RawMessage
+	if err := json.Unmarshal(raw, &byName); err != nil {
+		return fmt.Errorf("stampAgentClaude: parse sessions: %w", err)
+	}
+	for _, row := range byName {
+		if _, present := row["agent"]; !present {
+			row["agent"] = json.RawMessage(`"claude"`)
+		}
+	}
+	out, err := json.Marshal(byName)
+	if err != nil {
+		return fmt.Errorf("stampAgentClaude: marshal sessions: %w", err)
+	}
+	obj["sessions"] = out
+	return nil
+}
+
+// rewriteClaudeToCodex rewrites any agent="claude" row to agent="codex"
+// during the v2 → v3 migration. The claude Agent implementation was
+// removed; leaving the value as "claude" would surface as an
+// agent.For miss at session resume time.
+//
+// Idempotent — rows already at "codex" or any other (future) agent
+// pass through unchanged.
+func rewriteClaudeToCodex(obj map[string]json.RawMessage) error {
+	raw, ok := obj["sessions"]
+	if !ok || len(raw) == 0 {
+		return nil
+	}
+	var byName map[string]map[string]json.RawMessage
+	if err := json.Unmarshal(raw, &byName); err != nil {
+		return fmt.Errorf("rewriteClaudeToCodex: parse sessions: %w", err)
+	}
+	for _, row := range byName {
+		if a, present := row["agent"]; present && string(a) == `"claude"` {
+			row["agent"] = json.RawMessage(`"codex"`)
+		}
+	}
+	out, err := json.Marshal(byName)
+	if err != nil {
+		return fmt.Errorf("rewriteClaudeToCodex: marshal sessions: %w", err)
+	}
+	obj["sessions"] = out
+	return nil
+}
+
 var nameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,99}$`)
 var sanitizeRe = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
 
@@ -64,6 +144,32 @@ type Session struct {
 	LastAttachedAt   time.Time `json:"last_attached_at,omitempty"`
 	LastHealthStatus string    `json:"last_health_status,omitempty"`
 	LastHealthAt     time.Time `json:"last_health_at,omitempty"`
+
+	// Agent identifies the CLI driving this session. Set at creation
+	// and never mutated. Empty value on read = legacy → normalized to
+	// "claude" by Save and NormalizeAgent. Migration v1→v2 backfills
+	// the on-disk value.
+	Agent string `json:"agent,omitempty"`
+
+	// AgentSessionID is the agent-backend session/thread identifier.
+	// For claude this equals UUID (`claude --session-id `). For
+	// codex it is the thread UUID discovered post-spawn from the
+	// rollout file; empty until the first discovery succeeds.
+	AgentSessionID string `json:"agent_session_id,omitempty"`
+}
+
+// NormalizeAgent returns DefaultAgent ("codex") when s.Agent is empty,
+// else s.Agent verbatim. Cheap idempotent guard used by read paths
+// that handle pre-migration in-memory values without touching disk.
+//
+// Legacy "claude" values that escaped the v2→v3 migration are also
+// remapped to "codex" so a stale in-memory Session never surfaces as
+// an agent.For miss at the call site.
+func (s *Session) NormalizeAgent() string {
+	if s.Agent == "" || s.Agent == "claude" {
+		return DefaultAgent
+	}
+	return s.Agent
 }
 
 // ValidateName returns an error if name is not a valid session name.
@@ -207,8 +313,15 @@ func (s *Store) backupLocked() (string, error) {
 	return backupPath, nil
 }
 
-// Save adds or updates a session.
+// Save adds or updates a session. Empty sess.Agent is normalized to
+// DefaultAgent ("codex"). Legacy "claude" values are also rewritten —
+// the claude implementation was removed and a stray "claude" row
+// would fail at spawn-time agent.For lookup.
 func (s *Store) Save(sess *Session) error {
+	if sess.Agent == "" || sess.Agent == "claude" {
+		sess.Agent = DefaultAgent
+	}
+
 	lf, err := s.lock()
 	if err != nil {
 		return err
@@ -367,6 +480,32 @@ func (s *Store) UpdateHealth(name, status string) error {
 	return s.save(d)
 }
 
+// UpdateAgentSessionID stamps the agent-backend thread/session
+// identifier on the named session. Idempotent — supplying the same id
+// twice is a no-op on disk apart from the rewrite. Returns the
+// "not found" error if name has no store entry.
+func (s *Store) UpdateAgentSessionID(name, id string) error {
+	lf, err := s.lock()
+	if err != nil {
+		return err
+	}
+	defer s.unlock(lf)
+
+	d, err := s.load()
+	if err != nil {
+		return err
+	}
+	sess, ok := d.Sessions[name]
+	if !ok {
+		return fmt.Errorf(errFmtNotFound, name)
+	}
+	if sess.AgentSessionID == id {
+		return nil
+	}
+	sess.AgentSessionID = id
+	return s.save(d)
+}
+
 // UpdateAttached updates the last attached timestamp of a session.
 func (s *Store) UpdateAttached(name string) error {
 	lf, err := s.lock()
diff --git a/internal/session/state_migration_test.go b/internal/session/state_migration_test.go
new file mode 100644
index 0000000..4319531
--- /dev/null
+++ b/internal/session/state_migration_test.go
@@ -0,0 +1,189 @@
+package session_test
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/RandomCodeSpace/ctm/internal/migrate"
+	"github.com/RandomCodeSpace/ctm/internal/session"
+)
+
+// TestMigration_V1ToV3_RewritesAllToCodex verifies the full v1 → v3
+// migration path: legacy rows missing `agent` get stamped (v1→v2) and
+// every row ends at "codex" (v2→v3 rewrite of legacy "claude" values).
+func TestMigration_V1ToV3_RewritesAllToCodex(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "sessions.json")
+	v1 := `{
+  "schema_version": 1,
+  "sessions": {
+    "old1": {"name":"old1","uuid":"u-1","mode":"yolo","workdir":"/tmp"},
+    "old2": {"name":"old2","uuid":"u-2","mode":"safe","workdir":"/tmp","agent":"claude"}
+  }
+}`
+	if err := os.WriteFile(path, []byte(v1), 0600); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := migrate.Run(path, session.MigrationPlan()); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+	raw, _ := os.ReadFile(path)
+	var got map[string]json.RawMessage
+	if err := json.Unmarshal(raw, &got); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	var sv int
+	_ = json.Unmarshal(got["schema_version"], &sv)
+	if sv != 3 {
+		t.Fatalf("schema_version = %d, want 3", sv)
+	}
+	var sessions map[string]map[string]any
+	if err := json.Unmarshal(got["sessions"], &sessions); err != nil {
+		t.Fatalf("sessions unmarshal: %v", err)
+	}
+	for name, row := range sessions {
+		if row["agent"] != "codex" {
+			t.Fatalf("session[%s].agent = %v, want \"codex\"", name, row["agent"])
+		}
+	}
+}
+
+// TestMigration_V2ToV3_RewritesClaudeRowsOnly verifies isolated v2→v3
+// behavior: only agent="claude" rows are rewritten; non-claude agents
+// (e.g. a future "opencode") are left alone.
+func TestMigration_V2ToV3_RewritesClaudeRowsOnly(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "sessions.json")
+	v2 := `{
+  "schema_version": 2,
+  "sessions": {
+    "legacy":  {"name":"legacy","uuid":"u-1","mode":"yolo","workdir":"/tmp","agent":"claude"},
+    "other":   {"name":"other","uuid":"u-2","mode":"safe","workdir":"/tmp","agent":"opencode"}
+  }
+}`
+	if err := os.WriteFile(path, []byte(v2), 0600); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := migrate.Run(path, session.MigrationPlan()); err != nil {
+		t.Fatalf("migrate: %v", err)
+	}
+	raw, _ := os.ReadFile(path)
+	var got map[string]json.RawMessage
+	if err := json.Unmarshal(raw, &got); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	var sessions map[string]map[string]any
+	if err := json.Unmarshal(got["sessions"], &sessions); err != nil {
+		t.Fatalf("sessions unmarshal: %v", err)
+	}
+	if sessions["legacy"]["agent"] != "codex" {
+		t.Fatalf("legacy.agent = %v, want \"codex\"", sessions["legacy"]["agent"])
+	}
+	if sessions["other"]["agent"] != "opencode" {
+		t.Fatalf("other.agent = %v, want \"opencode\" (untouched)", sessions["other"]["agent"])
+	}
+}
+
+// TestMigration_V1ToV2_Idempotent verifies the step is safe to re-run.
+// The migrate runner short-circuits when already at CurrentVersion, but
+// the underlying step must also be a no-op if called against an
+// already-backfilled object.
+func TestMigration_V1ToV2_Idempotent(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "sessions.json")
+	v1 := `{
+  "schema_version": 1,
+  "sessions": {
+    "s": {"name":"s","uuid":"u","mode":"yolo","workdir":"/tmp"}
+  }
+}`
+	if err := os.WriteFile(path, []byte(v1), 0600); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := migrate.Run(path, session.MigrationPlan()); err != nil {
+		t.Fatalf("first migrate: %v", err)
+	}
+	first, _ := os.ReadFile(path)
+	if _, err := migrate.Run(path, session.MigrationPlan()); err != nil {
+		t.Fatalf("second migrate: %v", err)
+	}
+	second, _ := os.ReadFile(path)
+	if string(first) != string(second) {
+		t.Fatalf("second migrate altered the file\nfirst:  %s\nsecond: %s", first, second)
+	}
+}
+
+// TestSession_AgentFieldRoundTrip verifies that the Agent and
+// AgentSessionID fields survive a Save / Get cycle for non-default
+// agents. (The default agent path is covered by
+// TestSession_EmptyAgentDefaultsCodexOnSave.)
+func TestSession_AgentFieldRoundTrip(t *testing.T) {
+	dir := t.TempDir()
+	store := session.NewStore(filepath.Join(dir, "sessions.json"))
+	in := &session.Session{
+		Name:           "foo",
+		UUID:           "u-foo",
+		Mode:           "yolo",
+		Workdir:        "/tmp",
+		Agent:          "codex",
+		AgentSessionID: "thread-foo",
+	}
+	if err := store.Save(in); err != nil {
+		t.Fatalf("save: %v", err)
+	}
+	out, err := store.Get("foo")
+	if err != nil {
+		t.Fatalf("get: %v", err)
+	}
+	if out.Agent != "codex" {
+		t.Fatalf("Agent = %q, want codex", out.Agent)
+	}
+	if out.AgentSessionID != "thread-foo" {
+		t.Fatalf("AgentSessionID = %q, want thread-foo", out.AgentSessionID)
+	}
+}
+
+// TestSession_EmptyAgentDefaultsCodexOnSave verifies the read-side
+// guard: Save sets s.Agent = "codex" when empty.
+func TestSession_EmptyAgentDefaultsCodexOnSave(t *testing.T) {
+	dir := t.TempDir()
+	store := session.NewStore(filepath.Join(dir, "sessions.json"))
+	in := &session.Session{
+		Name:    "bar",
+		UUID:    "u-bar",
+		Mode:    "safe",
+		Workdir: "/tmp",
+		// Agent intentionally empty
+	}
+	if err := store.Save(in); err != nil {
+		t.Fatalf("save: %v", err)
+	}
+	out, _ := store.Get("bar")
+	if out.Agent != "codex" {
+		t.Fatalf("empty Agent should default to \"codex\" on Save, got %q", out.Agent)
+	}
+}
+
+// TestSession_LegacyClaudeRewrittenOnSave verifies that an in-memory
+// Session with Agent="claude" (e.g. read from a v2 file by code that
+// skipped the migration runner) is rewritten to "codex" by Save.
+func TestSession_LegacyClaudeRewrittenOnSave(t *testing.T) {
+	dir := t.TempDir()
+	store := session.NewStore(filepath.Join(dir, "sessions.json"))
+	in := &session.Session{
+		Name:    "legacy",
+		UUID:    "u-legacy",
+		Mode:    "safe",
+		Workdir: "/tmp",
+		Agent:   "claude",
+	}
+	if err := store.Save(in); err != nil {
+		t.Fatalf("save: %v", err)
+	}
+	out, _ := store.Get("legacy")
+	if out.Agent != "codex" {
+		t.Fatalf("legacy claude row should be rewritten to codex on Save, got %q", out.Agent)
+	}
+}
diff --git a/internal/session/state_test.go b/internal/session/state_test.go
index 8d88dd3..b3dc038 100644
--- a/internal/session/state_test.go
+++ b/internal/session/state_test.go
@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"os"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"testing"
 
@@ -288,8 +289,9 @@ func TestSaveStampsSchemaVersion(t *testing.T) {
 	if err := json.Unmarshal(data, &raw); err != nil {
 		t.Fatalf("unmarshal: %v", err)
 	}
-	if v := string(raw["schema_version"]); v != "1" {
-		t.Errorf("sessions.json schema_version = %s, want 1", v)
+	want := strconv.Itoa(session.SchemaVersion)
+	if v := string(raw["schema_version"]); v != want {
+		t.Errorf("sessions.json schema_version = %s, want %s", v, want)
 	}
 }
 
@@ -303,6 +305,30 @@ func TestMigrationPlan_MatchesSchemaVersion(t *testing.T) {
 	}
 }
 
+// TestNormalizeAgent_DefaultsCodex covers the read-side helper. Empty
+// values default to "codex" (the post-claude-removal default); legacy
+// "claude" values are also remapped to "codex" so a stale Session
+// never surfaces as an agent.For miss at the call site. Other agent
+// names pass through verbatim.
+func TestNormalizeAgent_DefaultsCodex(t *testing.T) {
+	s := &session.Session{}
+	if got := s.NormalizeAgent(); got != "codex" {
+		t.Fatalf("NormalizeAgent on zero-value = %q, want codex", got)
+	}
+	s.Agent = "claude"
+	if got := s.NormalizeAgent(); got != "codex" {
+		t.Fatalf("NormalizeAgent(claude) = %q, want codex (legacy remap)", got)
+	}
+	s.Agent = "codex"
+	if got := s.NormalizeAgent(); got != "codex" {
+		t.Fatalf("NormalizeAgent(codex) = %q, want codex", got)
+	}
+	s.Agent = "opencode"
+	if got := s.NormalizeAgent(); got != "opencode" {
+		t.Fatalf("NormalizeAgent(opencode) = %q, want opencode (forward-compat)", got)
+	}
+}
+
 func TestSavePermIs0600(t *testing.T) {
 	dir := t.TempDir()
 	path := filepath.Join(dir, "sessions.json")
diff --git a/internal/shell/migrate.go b/internal/shell/migrate.go
deleted file mode 100644
index 07f871e..0000000
--- a/internal/shell/migrate.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package shell
-
-import (
-	"fmt"
-	"os"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/RandomCodeSpace/ctm/internal/session"
-)
-
-// MigrateFromCC reads sessions from old ~/.claude/cc-sessions/ directory
-// and imports into ctm session store. Returns migrated names.
-func MigrateFromCC(ccSessionsDir, sessionsPath string) ([]string, error) {
-	entries, err := os.ReadDir(ccSessionsDir)
-	if os.IsNotExist(err) {
-		return nil, nil
-	}
-	if err != nil {
-		return nil, fmt.Errorf("read cc sessions dir: %w", err)
-	}
-
-	store := session.NewStore(sessionsPath)
-
-	var migrated []string
-	for _, entry := range entries {
-		if entry.IsDir() {
-			continue
-		}
-
-		name := entry.Name()
-		// Strip extension for session name
-		sessionName := strings.TrimSuffix(name, filepath.Ext(name))
-		if sessionName == "" {
-			continue
-		}
-
-		// Read UUID from file
-		data, err := os.ReadFile(filepath.Join(ccSessionsDir, name))
-		if err != nil {
-			continue
-		}
-		uuid := strings.TrimSpace(string(data))
-		if uuid == "" {
-			continue
-		}
-
-		// Skip if already in store
-		if _, err := store.Get(sessionName); err == nil {
-			continue
-		}
-
-		sess := &session.Session{
-			Name:      sessionName,
-			UUID:      uuid,
-			Mode:      "safe",
-			Workdir:   "",
-			CreatedAt: time.Now().UTC(),
-		}
-
-		if err := store.Save(sess); err != nil {
-			return migrated, fmt.Errorf("save session %q: %w", sessionName, err)
-		}
-		migrated = append(migrated, sessionName)
-	}
-
-	return migrated, nil
-}
diff --git a/internal/shell/migrate_test.go b/internal/shell/migrate_test.go
deleted file mode 100644
index e2f744b..0000000
--- a/internal/shell/migrate_test.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package shell
-
-import (
-	"os"
-	"path/filepath"
-	"testing"
-)
-
-func TestMigrateFromCC_NoDir(t *testing.T) {
-	sessionsPath := filepath.Join(t.TempDir(), "sessions.json")
-	migrated, err := MigrateFromCC("/tmp/ctm-nonexistent-xyz", sessionsPath)
-	if err != nil {
-		t.Fatalf("expected no error for missing dir, got: %v", err)
-	}
-	if len(migrated) != 0 {
-		t.Errorf("expected 0 migrated, got %d", len(migrated))
-	}
-}
-
-func TestMigrateFromCC_WithSessions(t *testing.T) {
-	ccDir := t.TempDir()
-	sessionsPath := filepath.Join(t.TempDir(), "sessions.json")
-
-	// Create two session files with UUIDs
-	sessions := map[string]string{
-		"myproject":  "550e8400-e29b-41d4-a716-446655440000",
-		"worksprint": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
-	}
-	for name, uuid := range sessions {
-		if err := os.WriteFile(filepath.Join(ccDir, name), []byte(uuid+"\n"), 0644); err != nil {
-			t.Fatalf("write session file: %v", err)
-		}
-	}
-
-	migrated, err := MigrateFromCC(ccDir, sessionsPath)
-	if err != nil {
-		t.Fatalf("MigrateFromCC: %v", err)
-	}
-	if len(migrated) != 2 {
-		t.Errorf("expected 2 migrated, got %d: %v", len(migrated), migrated)
-	}
-}
-
-func TestMigrateFromCC_Idempotent(t *testing.T) {
-	ccDir := t.TempDir()
-	sessionsPath := filepath.Join(t.TempDir(), "sessions.json")
-
-	uuid := "550e8400-e29b-41d4-a716-446655440000"
-	if err := os.WriteFile(filepath.Join(ccDir, "myproject"), []byte(uuid), 0644); err != nil {
-		t.Fatalf("write session file: %v", err)
-	}
-
-	first, err := MigrateFromCC(ccDir, sessionsPath)
-	if err != nil {
-		t.Fatalf("first MigrateFromCC: %v", err)
-	}
-	if len(first) != 1 {
-		t.Errorf("expected 1 migrated on first run, got %d", len(first))
-	}
-
-	second, err := MigrateFromCC(ccDir, sessionsPath)
-	if err != nil {
-		t.Fatalf("second MigrateFromCC: %v", err)
-	}
-	if len(second) != 0 {
-		t.Errorf("expected 0 migrated on second run (idempotent), got %d", len(second))
-	}
-}
diff --git a/internal/tmux/client.go b/internal/tmux/client.go
index 65126e1..0f00815 100644
--- a/internal/tmux/client.go
+++ b/internal/tmux/client.go
@@ -250,7 +250,7 @@ func (c *Client) SendKeys(target, keys string) error {
 
 // SendEnter sends the tmux "Enter" key (without -l) to the given
 // target. A literal `\n` via SendKeys is the LF character, which
-// TUIs like claude interpret as "insert newline", not "submit".
+// TUIs like codex interpret as "insert newline", not "submit".
 // Sending the Enter keybind triggers the real submit path.
 func (c *Client) SendEnter(target string) error {
 	if target == "" {
diff --git a/internal/tmux/client_test.go b/internal/tmux/client_test.go
index 2f819c5..7e8dbbb 100644
--- a/internal/tmux/client_test.go
+++ b/internal/tmux/client_test.go
@@ -8,16 +8,16 @@ import (
 )
 
 func TestBuildNewSessionArgs(t *testing.T) {
-	args := buildNewSessionArgs("myproject", "/home/dev/projects", "/tmp/ctm.conf", "claude --session-id abc")
-	expected := []string{"-f", "/tmp/ctm.conf", "new-session", "-d", "-s", "myproject", "-c", "/home/dev/projects", "claude --session-id abc"}
+	args := buildNewSessionArgs("myproject", "/home/dev/projects", "/tmp/ctm.conf", "codex resume abc")
+	expected := []string{"-f", "/tmp/ctm.conf", "new-session", "-d", "-s", "myproject", "-c", "/home/dev/projects", "codex resume abc"}
 	if !reflect.DeepEqual(args, expected) {
 		t.Errorf("got %v, want %v", args, expected)
 	}
 }
 
 func TestBuildNewSessionArgsEmptyConfPath(t *testing.T) {
-	args := buildNewSessionArgs("myproject", "/home/dev/projects", "", "claude --session-id abc")
-	expected := []string{"new-session", "-d", "-s", "myproject", "-c", "/home/dev/projects", "claude --session-id abc"}
+	args := buildNewSessionArgs("myproject", "/home/dev/projects", "", "codex resume abc")
+	expected := []string{"new-session", "-d", "-s", "myproject", "-c", "/home/dev/projects", "codex resume abc"}
 	if !reflect.DeepEqual(args, expected) {
 		t.Errorf("got %v, want %v", args, expected)
 	}
@@ -57,7 +57,7 @@ func TestBuildSwitchArgs(t *testing.T) {
 }
 
 func TestBuildRespawnPaneArgs(t *testing.T) {
-	shellCmd := "claude --resume abc-123 || claude --session-id abc-123"
+	shellCmd := "codex resume abc-123 || codex"
 	args := buildRespawnPaneArgs("myproject", shellCmd)
 	expected := []string{"respawn-pane", "-t", "myproject", "-k", "/bin/sh", "-c", shellCmd}
 	if !reflect.DeepEqual(args, expected) {
@@ -67,7 +67,7 @@ func TestBuildRespawnPaneArgs(t *testing.T) {
 
 func TestBuildRespawnPaneArgsShellCmdIsSingleArg(t *testing.T) {
 	// Verify the || fallback is passed as one argument to /bin/sh -c, not split
-	shellCmd := "claude --resume xyz || claude --session-id xyz"
+	shellCmd := "codex resume xyz || codex"
 	args := buildRespawnPaneArgs("sess", shellCmd)
 	// args[6] should be the entire shellCmd as one string
 	if args[6] != shellCmd {
diff --git a/internal/tmux/config.go b/internal/tmux/config.go
index 4849580..b14079d 100644
--- a/internal/tmux/config.go
+++ b/internal/tmux/config.go
@@ -37,7 +37,7 @@ set -g allow-rename off
 set -sg escape-time 10
 set -g monitor-activity on
 set -g visual-activity off
-# Focus events: lets apps inside tmux (claude, vim) see focus-in/out events,
+# Focus events: lets apps inside tmux (codex, vim) see focus-in/out events,
 # which improves redraw behavior and avoids stale cursor state on reattach.
 set -g focus-events on
 # OSC52: sync tmux copy-mode selections to system clipboard.
diff --git a/main.go b/main.go
index 8fbb9d2..a006d8b 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,14 @@
 package main
 
-import "github.com/RandomCodeSpace/ctm/cmd"
+import (
+	"github.com/RandomCodeSpace/ctm/cmd"
+
+	// Side-effect import: codex.init() registers the codex Agent with the
+	// internal/agent registry. Without this blank import the ctm binary
+	// would link with no agents registered and any attach / yolo / check
+	// would fail at agent.For lookup.
+	_ "github.com/RandomCodeSpace/ctm/internal/agent/codex"
+)
 
 func main() {
 	cmd.Execute()
diff --git a/sonar-project.properties b/sonar-project.properties
index 55f4e3c..84330a4 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -12,28 +12,22 @@ sonar.projectName=ctm
 
 # ── Sources ────────────────────────────────────────────────────────────
 # Scan everything under the repo root. Generated artifacts, vendored
-# code, the embedded UI dist, and agent worktree scratch never enter
-# the analysis — they're either machine-written or out of scope.
+# code, and agent worktree scratch never enter the analysis — they're
+# either machine-written or out of scope.
 sonar.sources=.
 sonar.exclusions=\
     **/dist/**,\
-    **/node_modules/**,\
     **/vendor/**,\
     **/_attic/**,\
     **/.claude/**,\
     **/.codeiq/**,\
-    internal/serve/dist/**,\
-    ui/coverage/**,\
-    ui/e2e/**,\
-    ui/playwright-report/**,\
-    ui/test-results/**,\
     coverage.out,\
     docs/**
 
 # ── Issue suppressions ─────────────────────────────────────────────────
 # go:S4036 — "Make sure the PATH variable only contains fixed,
 # unwriteable directories." ctm is a CLI orchestrator that intentionally
-# resolves user-installed tools (git, tmux, claude, gh) via $PATH on
+# resolves user-installed tools (git, tmux, codex, gh) via $PATH on
 # whatever box it's running on. Hardcoded absolute paths aren't viable
 # across macOS / Linux / Homebrew / system installs. The risk model is
 # the user's own shell, not a service account on a server, so the rule
@@ -45,29 +39,20 @@ sonar.issue.ignore.multicriteria.path.resourceKey=**/*.go
 
 # ── Tests ──────────────────────────────────────────────────────────────
 # Sonar separates "test code" from "production code" so coverage and
-# duplication metrics target the right files. Playwright e2e specs
-# stay out of the JS test set — they don't generate the unit-style
-# coverage Sonar expects.
+# duplication metrics target the right files.
 sonar.tests=.
-sonar.test.inclusions=\
-    **/*_test.go,\
-    ui/src/**/*.test.ts,\
-    ui/src/**/*.test.tsx
-sonar.test.exclusions=ui/e2e/**
+sonar.test.inclusions=**/*_test.go
 
 # ── Coverage report paths ──────────────────────────────────────────────
 # Go: `go test -coverprofile=coverage.out ./...` writes to repo root.
-# JS/TS: vitest with provider:v8 writes lcov to ui/coverage/lcov.info
-# (configured in ui/vitest.config.ts).
 sonar.go.coverage.reportPaths=coverage.out
-sonar.javascript.lcov.reportPaths=ui/coverage/lcov.info
 
 # ── Coverage exclusions ────────────────────────────────────────────────
 # Files in this list are excluded from coverage measurement entirely —
 # they exist on the integration boundary (tmux, shell-outs, real
 # install paths, hook stdin readers, cobra RunE bodies that delegate
-# to those paths) and have no meaningful unit-testable surface in jsdom
-# / sandboxed CI. Where useful, the testable helpers a file delegates
+# to those paths) and have no meaningful unit-testable surface in
+# sandboxed CI. Where useful, the testable helpers a file delegates
 # to live in a separate file in the same package and ARE covered (see
 # cmd/yolo.go ↔ cmd/yolo_runners.go split for the pattern).
 #
@@ -77,40 +62,29 @@ sonar.javascript.lcov.reportPaths=ui/coverage/lcov.info
 #   reachable on Linux as the file's owner. Success / rename-onto-dir
 #   / missing-parent paths ARE tested in atomic_test.go.
 # - cmd/yolo_runners.go: cobra wiring + RunE bodies for yolo / yolo! /
-#   safe. Shells out to tmux + git + claude. Pure helpers in cmd/yolo.go
+#   safe. Shells out to tmux + git + codex. Pure helpers in cmd/yolo.go
 #   are unit-tested.
-# - cmd/attach.go, cmd/install.go, cmd/check.go, cmd/log_tool_use.go,
-#   cmd/auth.go, cmd/forget.go, cmd/kill.go, cmd/last.go, cmd/list.go,
-#   cmd/new.go, cmd/pick.go, cmd/serve.go, cmd/setup.go,
-#   cmd/statusline.go, cmd/uninstall.go: cobra RunE bodies that depend
-#   on a live tmux server, an installed claude binary, or interactive
-#   prompts. The decisions inside (e.g., shouldResumeExisting,
-#   decideModeAction) live in helper files that are covered.
+# - cmd/attach.go, cmd/install.go, cmd/check.go, cmd/forget.go,
+#   cmd/kill.go, cmd/last.go, cmd/list.go, cmd/new.go, cmd/pick.go:
+#   cobra RunE bodies that depend on a live tmux server, an installed
+#   codex binary, or interactive prompts. The decisions inside (e.g.,
+#   shouldResumeExisting, decideModeAction) live in helper files that
+#   are covered.
 # - internal/tmux/client.go: every method shells out via exec.Command
 #   to tmux. No mockable seam without a tmux integration harness.
-# - internal/serve/proc/proc.go: spawns the ctm serve daemon over a
-#   2-second blocking deadline. Excluded for the same reason
-#   EnsureServeRunning is not callable from tests.
 sonar.coverage.exclusions=\
     internal/fsutil/atomic.go,\
     cmd/yolo_runners.go,\
     cmd/attach.go,\
     cmd/install.go,\
-    cmd/log_tool_use.go,\
     cmd/check.go,\
-    cmd/auth.go,\
     cmd/forget.go,\
     cmd/kill.go,\
     cmd/last.go,\
     cmd/list.go,\
     cmd/new.go,\
     cmd/pick.go,\
-    cmd/serve.go,\
-    cmd/setup.go,\
-    cmd/statusline.go,\
-    cmd/uninstall.go,\
-    internal/tmux/client.go,\
-    internal/serve/proc/proc.go
+    internal/tmux/client.go
 
 # ── General ────────────────────────────────────────────────────────────
 sonar.sourceEncoding=UTF-8
diff --git a/ui/.gitignore b/ui/.gitignore
deleted file mode 100644
index 7287948..0000000
--- a/ui/.gitignore
+++ /dev/null
@@ -1,14 +0,0 @@
-node_modules/
-dist/
-.vite/
-*.log
-.DS_Store
-coverage/
-
-# tsc incremental build cache
-*.tsbuildinfo
-
-# Playwright E2E artifacts
-test-results/
-playwright-report/
-.playwright/
diff --git a/ui/.nvmrc b/ui/.nvmrc
deleted file mode 100644
index 2bd5a0a..0000000
--- a/ui/.nvmrc
+++ /dev/null
@@ -1 +0,0 @@
-22
diff --git a/ui/components.json b/ui/components.json
deleted file mode 100644
index 13e1db0..0000000
--- a/ui/components.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
-  "$schema": "https://ui.shadcn.com/schema.json",
-  "style": "new-york",
-  "rsc": false,
-  "tsx": true,
-  "tailwind": {
-    "config": "",
-    "css": "src/index.css",
-    "baseColor": "neutral",
-    "cssVariables": true,
-    "prefix": ""
-  },
-  "aliases": {
-    "components": "@/components",
-    "utils": "@/lib/utils",
-    "ui": "@/components/ui",
-    "lib": "@/lib",
-    "hooks": "@/hooks"
-  },
-  "iconLibrary": "lucide"
-}
diff --git a/ui/e2e/README.md b/ui/e2e/README.md
deleted file mode 100644
index 1f9f053..0000000
--- a/ui/e2e/README.md
+++ /dev/null
@@ -1,56 +0,0 @@
-# Playwright E2E — ctm serve UI
-
-Fast, deterministic browser tests for the React SPA. Mocks `/api/**` and
-`/events/**` per-page so the suite never needs a running daemon or fixture
-DB, and runs against a production vite-preview bundle so CSS and asset
-resolution match what ships to users.
-
-## Running
-
-```sh
-make e2e                       # from repo root — rebuilds bundle + runs suite
-pnpm exec playwright test      # from ui/ — assumes dist/ is already built
-make regression                # runs this suite plus Go/vitest/audit
-```
-
-One-time setup on a fresh clone:
-```sh
-pnpm --prefix ui install
-pnpm --prefix ui exec playwright install chromium
-```
-
-## Growing the pack
-
-**Every shipped bug fix or feature adds a test here that would have caught
-it.** The pack is append-only — do not replace existing specs when you
-extend them. Name new spec files after the feature or regression ID
-(`quota-strip.spec.ts`, `auth.spec.ts`, …) and group related cases inside
-a single `test.describe` block.
-
-## Mocks
-
-`e2e/fixtures/mocks.ts` exposes `installMocks(page, overrides?)` and
-`authenticate(page)`. Defaults return a single happy-path session, a
-quota snapshot with future reset times, an empty feed, and a held-open
-SSE stream. Override per-case:
-
-```ts
-await installMocks(page, {
-  sessions: [{ ...alpha, is_active: false }],
-  authenticated: false,  // forces /api/bootstrap → 401
-});
-```
-
-Shapes mirror the real API (`/api/sessions` → `Session[]`, `/api/feed` →
-`ToolCallRow[]`) — if you change either contract in `internal/serve/api`,
-update the fixture too.
-
-## Debugging
-
-```sh
-pnpm exec playwright test --debug             # launch inspector
-pnpm exec playwright test auth.spec.ts --ui   # interactive UI mode
-playwright show-trace test-results//trace.zip
-```
-
-Traces are retained only on failure (see `playwright.config.ts`).
diff --git a/ui/e2e/auth.spec.ts b/ui/e2e/auth.spec.ts
deleted file mode 100644
index 539287d..0000000
--- a/ui/e2e/auth.spec.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { test, expect, type Route } from "@playwright/test";
-import { installMocks } from "./fixtures/mocks";
-
-test.describe("Auth (V27)", () => {
-  test("unregistered → signup → dashboard", async ({ page }) => {
-    await installMocks(page, { authRegistered: false, authAuthenticated: false });
-    await page.goto("/");
-    await expect(
-      page.getByRole("heading", { name: /create your ctm account/i }),
-    ).toBeVisible();
-
-    await page.getByLabel(/email/i).fill("alice@example.com");
-    await page.getByLabel(/^password$/i).fill("password123");
-    await page.getByLabel(/confirm/i).fill("password123");
-    await page.getByRole("button", { name: /create account/i }).click();
-
-    await installMocks(page, { authRegistered: true, authAuthenticated: true });
-    await page.reload();
-    await expect(
-      page.getByRole("button", { name: /new session/i }),
-    ).toBeVisible();
-  });
-
-  test("registered + unauthenticated → login → dashboard", async ({ page }) => {
-    await installMocks(page, { authRegistered: true, authAuthenticated: false });
-    await page.goto("/");
-    await expect(
-      page.getByRole("heading", { name: /log in to ctm/i }),
-    ).toBeVisible();
-
-    await page.getByLabel(/email/i).fill("alice@example.com");
-    await page.getByLabel(/^password$/i).fill("password123");
-    await page.getByRole("button", { name: /log in/i }).click();
-
-    await installMocks(page, { authRegistered: true, authAuthenticated: true });
-    await page.reload();
-    await expect(
-      page.getByRole("button", { name: /new session/i }),
-    ).toBeVisible();
-  });
-
-  test("login 401 surfaces error", async ({ page }) => {
-    await installMocks(page, { authRegistered: true, authAuthenticated: false });
-    await page.route("**/api/auth/login", (r: Route) =>
-      r.fulfill({
-        status: 401,
-        contentType: "application/json",
-        body: JSON.stringify({ error: "invalid_credentials", message: "nope" }),
-      }),
-    );
-    await page.goto("/");
-    await page.getByLabel(/email/i).fill("alice@example.com");
-    await page.getByLabel(/^password$/i).fill("wrong");
-    await page.getByRole("button", { name: /log in/i }).click();
-    await expect(page.getByRole("alert")).toBeVisible();
-  });
-
-  test("signup 409 offers 'log in instead'", async ({ page }) => {
-    await installMocks(page, { authRegistered: false, authAuthenticated: false });
-    await page.route("**/api/auth/signup", (r: Route) =>
-      r.fulfill({
-        status: 409,
-        contentType: "application/json",
-        body: JSON.stringify({ error: "already_registered", message: "exists" }),
-      }),
-    );
-    await page.goto("/");
-    await page.getByLabel(/email/i).fill("alice@example.com");
-    await page.getByLabel(/^password$/i).fill("password123");
-    await page.getByLabel(/confirm/i).fill("password123");
-    await page.getByRole("button", { name: /create account/i }).click();
-    await expect(
-      page.getByRole("button", { name: /log in instead/i }),
-    ).toBeVisible();
-    await page.getByRole("button", { name: /log in instead/i }).click();
-    await expect(
-      page.getByRole("heading", { name: /log in to ctm/i }),
-    ).toBeVisible();
-  });
-
-  test("logout from settings returns to login without reload", async ({ page }) => {
-    await installMocks(page, { authRegistered: true, authAuthenticated: true });
-    await page.goto("/");
-    await page.getByRole("button", { name: /open settings/i }).click();
-
-    // Swap mocks BEFORE clicking logout so any subsequent auth-status
-    // refetch returns authenticated:false. The UI must transition
-    // without a page.reload() — that's the core UX contract.
-    await installMocks(page, { authRegistered: true, authAuthenticated: false });
-
-    await page.getByRole("button", { name: /log out/i }).click();
-    await expect(
-      page.getByRole("heading", { name: /log in to ctm/i }),
-    ).toBeVisible();
-  });
-});
diff --git a/ui/e2e/bash-filter.spec.ts b/ui/e2e/bash-filter.spec.ts
deleted file mode 100644
index ef331f6..0000000
--- a/ui/e2e/bash-filter.spec.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import { test, expect, type Route } from "@playwright/test";
-import { authenticate, installMocks } from "./fixtures/mocks";
-
-/**
- * V10 — Feed tab "All | Bash" filter.
- *
- * The SPA populates its feed cache from SSE (/events/all), not from
- * /api/feed (the REST endpoint is kept only for scripting / external
- * consumers). So we mock /events/all with a stream that emits a mix of
- * Bash, Edit, and Read tool_call events, then assert the filter toggle
- * shows them all initially and collapses to Bash-only on click.
- *
- * We also fulfill /api/feed for good measure so any consumer that still
- * reads it gets the same dataset.
- */
-
-const toolCalls = [
-  {
-    type: "tool_call",
-    session: "alpha",
-    tool: "Bash",
-    input: "ls -la /tmp",
-    summary: "total 0",
-    is_error: false,
-    ts: "2026-04-21T16:28:00Z",
-  },
-  {
-    type: "tool_call",
-    session: "alpha",
-    tool: "Edit",
-    input: "src/foo.ts",
-    summary: "1 line changed",
-    is_error: false,
-    ts: "2026-04-21T16:28:05Z",
-  },
-  {
-    type: "tool_call",
-    session: "alpha",
-    tool: "Bash",
-    input: "echo hi && false",
-    summary: "hi",
-    is_error: true,
-    exit_code: 1,
-    ts: "2026-04-21T16:28:10Z",
-  },
-  {
-    type: "tool_call",
-    session: "alpha",
-    tool: "Read",
-    input: "/etc/hosts",
-    summary: "127.0.0.1 localhost",
-    is_error: false,
-    ts: "2026-04-21T16:28:15Z",
-  },
-];
-
-function sseBody(
-  events: Array<{ type: string } & Record>,
-): string {
-  return (
-    events
-      .map((e) => {
-        // Fan the event out as its named SSE type so SseProvider's
-        // switch(ev.type) dispatches it as a tool_call. The `type`
-        // field is also embedded in the JSON payload for parity
-        // with how the Go hub encodes events.
-        return `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`;
-      })
-      .join("") + ": keepalive\n\n"
-  );
-}
-
-test.describe("Feed tab — Bash filter (V10)", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page, { feed: toolCalls });
-
-    // Override the default no-op /events route with a stream that
-    // ships the seeded tool_call events — ONCE. Subsequent reconnect
-    // attempts (fetch-event-source auto-retries when the response
-    // body ends) land on the fall-through no-op stream so we don't
-    // double-append into the feed cache.
-    await page.route(
-      "**/events/**",
-      (route: Route) => {
-        return route.fulfill({
-          status: 200,
-          contentType: "text/event-stream",
-          headers: {
-            "cache-control": "no-cache",
-            connection: "keep-alive",
-          },
-          body: sseBody(toolCalls),
-        });
-      },
-      { times: 1 },
-    );
-  });
-
-  test("shows all tool calls under All and only Bash under Bash", async ({
-    page,
-  }) => {
-    await page.goto("/s/alpha");
-
-    const feed = page.getByRole("region", { name: /feed for alpha/i });
-    await expect(feed).toBeVisible();
-
-    // All: expect all three tool names to appear somewhere in the feed.
-    await expect(feed.getByText("Bash").first()).toBeVisible();
-    await expect(feed.getByText("Edit").first()).toBeVisible();
-    await expect(feed.getByText("Read").first()).toBeVisible();
-
-    // Click Bash filter.
-    await page.getByRole("tab", { name: /^bash$/i }).click();
-
-    // Now the compact BashOnlyRow strip is the renderer: rows expose
-    // a data-testid="bash-row". Expect exactly 2 (two Bash events).
-    await expect(page.locator('[data-testid="bash-row"]')).toHaveCount(2);
-
-    // Neither Edit nor Read command text is visible.
-    await expect(feed.getByText("src/foo.ts")).toHaveCount(0);
-    await expect(feed.getByText("/etc/hosts")).toHaveCount(0);
-
-    // One ok chip (ls -la) and one err chip (echo hi && false).
-    await expect(
-      page.locator('[data-testid="bash-chip"][data-status="ok"]'),
-    ).toHaveCount(1);
-    await expect(
-      page.locator('[data-testid="bash-chip"][data-status="err"]'),
-    ).toHaveCount(1);
-  });
-
-  test("persists the Bash selection across reload via sessionStorage", async ({
-    page,
-  }) => {
-    await page.goto("/s/alpha");
-    await page.getByRole("tab", { name: /^bash$/i }).click();
-    await expect(
-      page.getByRole("tab", { name: /^bash$/i }),
-    ).toHaveAttribute("aria-selected", "true");
-
-    await page.reload();
-
-    // On reload, the Bash filter should still be active.
-    await expect(
-      page.getByRole("tab", { name: /^bash$/i }),
-    ).toHaveAttribute("aria-selected", "true");
-  });
-});
diff --git a/ui/e2e/checkpoint-diff.spec.ts b/ui/e2e/checkpoint-diff.spec.ts
deleted file mode 100644
index 2caa66f..0000000
--- a/ui/e2e/checkpoint-diff.spec.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { test, expect, type Route } from "@playwright/test";
-import { authenticate, installMocks } from "./fixtures/mocks";
-
-const FULL_SHA = "abcdef1234567890abcdef1234567890abcdef12";
-
-const SAMPLE_DIFF = [
-  "commit abcdef1234567890abcdef1234567890abcdef12",
-  "Author: ctm",
-  "",
-  "diff --git a/foo.go b/foo.go",
-  "--- a/foo.go",
-  "+++ b/foo.go",
-  "@@ -1,3 +1,3 @@",
-  " context line",
-  "-removed line",
-  "+added line",
-].join("\n");
-
-test.describe("Checkpoint diff viewer", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page);
-
-    // Checkpoint list: one commit; the "View diff" button's rendered
-    // SHA will be derived from this row's `short_sha`.
-    await page.route("**/api/sessions/alpha/checkpoints**", (route: Route) => {
-      // Diff endpoint also matches this glob (path includes /checkpoints//diff).
-      // Fall through to the specific diff route when the path is deeper.
-      const url = new URL(route.request().url());
-      if (!url.pathname.endsWith("/checkpoints")) return route.fallback();
-      return route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify({
-          git_workdir: true,
-          checkpoints: [
-            {
-              sha: FULL_SHA,
-              short_sha: FULL_SHA.slice(0, 7),
-              subject: "checkpoint: pre-yolo 2026-04-21T12:00:00",
-              author: "ctm",
-              ts: new Date(Date.now() - 60_000).toISOString(),
-            },
-          ],
-        }),
-      });
-    });
-
-    // Diff endpoint. Note the path order differs from /checkpoints so
-    // this route won't collide with the list mock above.
-    await page.route(
-      `**/api/sessions/alpha/checkpoints/${FULL_SHA}/diff`,
-      (route: Route) =>
-        route.fulfill({
-          status: 200,
-          contentType: "text/plain",
-          body: SAMPLE_DIFF,
-        }),
-    );
-  });
-
-  test("View diff button opens the sheet with coloured lines", async ({
-    page,
-  }) => {
-    await page.goto("/s/alpha/checkpoints");
-
-    // Row is present, then click the sibling "View diff" action.
-    const viewDiff = page.getByRole("button", { name: /view diff/i });
-    await expect(viewDiff).toBeVisible();
-    await viewDiff.click();
-
-    // Sheet surfaces.
-    await expect(
-      page.getByRole("heading", { name: /checkpoint diff/i }),
-    ).toBeVisible();
-
-    // Added line → emerald.
-    const added = page.getByText("+added line");
-    await expect(added).toBeVisible();
-    await expect(added).toHaveClass(/text-emerald-400/);
-
-    // Removed line → alert-ember.
-    const removed = page.getByText("-removed line");
-    await expect(removed).toBeVisible();
-    await expect(removed).toHaveClass(/text-alert-ember/);
-
-    // Hunk header → fg-dim.
-    const hunk = page.getByText("@@ -1,3 +1,3 @@");
-    await expect(hunk).toBeVisible();
-    await expect(hunk).toHaveClass(/text-fg-dim/);
-  });
-});
diff --git a/ui/e2e/cost-chart.spec.ts b/ui/e2e/cost-chart.spec.ts
deleted file mode 100644
index 9df2e7a..0000000
--- a/ui/e2e/cost-chart.spec.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import { test, expect, type Route } from "@playwright/test";
-import { authenticate, installMocks, defaultSession } from "./fixtures/mocks";
-
-function makeCostFixture(window: "hour" | "day" | "week" = "day") {
-  const now = Date.now();
-  const points = Array.from({ length: 10 }).map((_, i) => {
-    const ts = new Date(now - (10 - i) * 60_000).toISOString();
-    return {
-      ts,
-      session: "alpha",
-      input_tokens: 1000 * (i + 1),
-      output_tokens: 500 * (i + 1),
-      cache_tokens: 100 * (i + 1),
-      cost_usd_micros: 12_000 * (i + 1),
-    };
-  });
-  return {
-    window,
-    points,
-    totals: {
-      input: 10_000,
-      output: 5_000,
-      cache: 1_000,
-      cost_usd_micros: 120_000,
-    },
-  };
-}
-
-test.describe("Cumulative cost chart (V13)", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page);
-
-    await page.route("**/api/sessions/**", (route: Route) => {
-      const url = new URL(route.request().url());
-      if (
-        url.pathname.endsWith("/feed") ||
-        url.pathname === "/api/sessions"
-      ) {
-        return route.fallback();
-      }
-      return route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify(defaultSession),
-      });
-    });
-
-    // Mock /api/cost with a 10-point fixture; honour the window query
-    // param so the Hour/Day/Week pill test can assert the client
-    // actually re-fetched.
-    await page.route("**/api/cost**", (route: Route) => {
-      const url = new URL(route.request().url());
-      const window = (url.searchParams.get("window") ?? "day") as
-        | "hour"
-        | "day"
-        | "week";
-      return route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify(makeCostFixture(window)),
-      });
-    });
-  });
-
-  test("renders polyline, legend total, and switches windows", async ({
-    page,
-  }) => {
-    await page.goto("/s/alpha/meta");
-
-    // Scope to the Meta tabpanel — the Dashboard card also has
-    // aria-label="Cumulative cost" on desktop viewports.
-    const region = page
-      .getByRole("tabpanel", { name: /meta/i })
-      .getByRole("region", { name: /cumulative cost/i });
-    await expect(region).toBeVisible();
-
-    // Polyline visible with a points attribute.
-    const poly = region.locator('[data-testid="cost-polyline"]');
-    await expect(poly).toBeVisible();
-    const pointsAttr = await poly.getAttribute("points");
-    expect(pointsAttr && pointsAttr.length).toBeTruthy();
-
-    // Legend: 120_000 micros = $0.1200.
-    await expect(region.getByText("$0.1200")).toBeVisible();
-    // Token sum: input(10k) + output(5k) = 15k → "15k tokens".
-    await expect(region.getByText(/15k tokens/i)).toBeVisible();
-
-    // Window pill switch: click "Hour", verify aria-selected moves.
-    await region.getByRole("tab", { name: /hour/i }).click();
-    await expect(region.getByRole("tab", { name: /hour/i })).toHaveAttribute(
-      "aria-selected",
-      "true",
-    );
-    await expect(region.getByRole("tab", { name: /day/i })).toHaveAttribute(
-      "aria-selected",
-      "false",
-    );
-  });
-});
diff --git a/ui/e2e/create-session.spec.ts b/ui/e2e/create-session.spec.ts
deleted file mode 100644
index 02c017c..0000000
--- a/ui/e2e/create-session.spec.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { test, expect, type Route } from "@playwright/test";
-import { authenticate, installMocks, defaultSession } from "./fixtures/mocks";
-
-test.describe("Create session (V26)", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page, {
-      sessions: [
-        { ...defaultSession, name: "ctm", workdir: "/home/dev/projects/ctm" },
-        { ...defaultSession, name: "docsiq", workdir: "/home/dev/projects/docsiq" },
-      ],
-    });
-  });
-
-  test("opens modal, recents pre-fills workdir, Create navigates", async ({
-    page,
-  }) => {
-    const postedBodies: string[] = [];
-    await page.route("**/api/sessions", (route: Route) => {
-      if (route.request().method() !== "POST") return route.fallback();
-      postedBodies.push(route.request().postData() ?? "");
-      return route.fulfill({
-        status: 201,
-        contentType: "application/json",
-        body: JSON.stringify({
-          name: "ctm",
-          uuid: "u",
-          mode: "yolo",
-          workdir: "/home/dev/projects/ctm",
-          created_at: new Date().toISOString(),
-          is_active: true,
-          tmux_alive: true,
-        }),
-      });
-    });
-
-    await page.goto("/");
-    await page.getByRole("button", { name: /new session/i }).click();
-
-    const workdir = page.getByRole("textbox", { name: /workdir/i });
-    await expect(workdir).toHaveValue(/\/home\/dev\/projects\//);
-
-    await page.getByRole("button", { name: /create/i }).click();
-    await expect(page).toHaveURL(/\/s\/ctm$/);
-    expect(JSON.parse(postedBodies[0])).toEqual({
-      workdir: "/home/dev/projects/ctm",
-    });
-  });
-
-  test("collision surfaces rename / go-to-existing", async ({ page }) => {
-    await page.route("**/api/sessions", (route: Route) => {
-      if (route.request().method() !== "POST") return route.fallback();
-      return route.fulfill({
-        status: 409,
-        contentType: "application/json",
-        body: JSON.stringify({
-          error: "name_exists",
-          message: "exists",
-          session: {
-            name: "ctm",
-            uuid: "u",
-            mode: "yolo",
-            workdir: "/home/dev/projects/ctm",
-          },
-        }),
-      });
-    });
-
-    await page.goto("/");
-    await page.getByRole("button", { name: /new session/i }).click();
-    await page.getByRole("button", { name: /create/i }).click();
-
-    await expect(
-      page.getByRole("button", { name: /go to existing/i }),
-    ).toBeVisible();
-    await page.getByRole("button", { name: /go to existing/i }).click();
-    await expect(page).toHaveURL(/\/s\/ctm$/);
-  });
-
-  test("fills initial prompt textarea and posts initial_prompt", async ({
-    page,
-  }) => {
-    const postedBodies: string[] = [];
-    await page.route("**/api/sessions", (route: Route) => {
-      if (route.request().method() !== "POST") return route.fallback();
-      postedBodies.push(route.request().postData() ?? "");
-      return route.fulfill({
-        status: 201,
-        contentType: "application/json",
-        body: JSON.stringify({
-          name: "ctm",
-          uuid: "u",
-          mode: "yolo",
-          workdir: "/home/dev/projects/ctm",
-          created_at: new Date().toISOString(),
-          is_active: true,
-          tmux_alive: true,
-        }),
-      });
-    });
-
-    await page.goto("/");
-    await page.getByRole("button", { name: /new session/i }).click();
-    await page
-      .getByRole("textbox", { name: /initial prompt/i })
-      .fill("review the diff");
-    await page.getByRole("button", { name: /create/i }).click();
-
-    await expect(page).toHaveURL(/\/s\/ctm$/);
-    expect(JSON.parse(postedBodies[0])).toEqual({
-      workdir: "/home/dev/projects/ctm",
-      initial_prompt: "review the diff",
-    });
-  });
-});
diff --git a/ui/e2e/dashboard.spec.ts b/ui/e2e/dashboard.spec.ts
deleted file mode 100644
index eadd63f..0000000
--- a/ui/e2e/dashboard.spec.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { test, expect } from "@playwright/test";
-import { authenticate, installMocks } from "./fixtures/mocks";
-
-test.describe("Dashboard", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page);
-  });
-
-  test("renders the seeded session card", async ({ page }) => {
-    await page.goto("/");
-    // Desktop auto-navigates to the top session so "alpha" appears in
-    // both the list card and detail heading — assert on the link card.
-    await expect(
-      page.getByRole("link", { name: /alpha/i }).first(),
-    ).toBeVisible();
-    await expect(page.getByText(/safe/i).first()).toBeVisible();
-  });
-
-  test("shows last tool call time on the card", async ({ page }) => {
-    // Seeded tool call is at 16:28; attached at 16:00. Card should carry
-    // both time readings (⏵ for tool call, plain for attached).
-    await page.goto("/");
-    const card = page.getByRole("link", { name: /alpha/i }).first();
-    await expect(card).toBeVisible();
-    // ⏵ glyph rendered for the tool-call badge.
-    await expect(card.locator('time[aria-label="last tool call"]')).toBeVisible();
-  });
-
-  test("renders the per-session context bar on the card", async ({ page }) => {
-    // Seeded session has context_pct: 42 → bar should be visible.
-    await page.goto("/");
-    const card = page.getByRole("link", { name: /alpha/i }).first();
-    await expect(card).toBeVisible();
-    const bar = card.getByRole("progressbar", { name: /context window/i });
-    await expect(bar).toBeVisible();
-    await expect(bar).toHaveAttribute("aria-valuenow", "42");
-  });
-
-  test("context bar turns ember at >=90%", async ({ page }) => {
-    await installMocks(page, {
-      sessions: [
-        {
-          name: "hot",
-          uuid: "00000000-0000-0000-0000-0000000000aa",
-          mode: "yolo",
-          workdir: "/home/dev/projects/ctm",
-          created_at: "2026-04-21T10:00:00Z",
-          last_attached_at: "2026-04-21T11:00:00Z",
-          last_tool_call_at: new Date(Date.now() - 5_000).toISOString(),
-          is_active: true,
-          tmux_alive: true,
-          context_pct: 95,
-        },
-      ],
-    });
-    await page.goto("/");
-    const card = page.getByRole("link", { name: /hot/i }).first();
-    const bar = card.getByRole("progressbar", { name: /context window/i });
-    await expect(bar).toBeVisible();
-    await expect(bar).toHaveAttribute("aria-valuenow", "95");
-    // The coloured fill is the first child; verify ember class + width.
-    const fill = bar.locator("> div").first();
-    await expect(fill).toHaveClass(/bg-alert-ember/);
-    await expect(fill).toHaveAttribute("style", /width:\s*95%/);
-  });
-
-  test("renders the tool-frequency sparkline when the feed has events", async ({
-    page,
-  }) => {
-    // Seed the SSE stream with 6 tool_call events spread across 10 min.
-    // The sparkline component reads from the SseProvider-populated feed
-    // cache, so we route /events/all to deliver synthetic events once.
-    const now = Date.now();
-    const lines: string[] = [];
-    for (let i = 0; i < 6; i++) {
-      const ts = new Date(now - i * 60_000).toISOString();
-      const id = `${now - i * 60_000}000000-0`;
-      const data = JSON.stringify({
-        session: "alpha",
-        tool: "Bash",
-        input: "",
-        is_error: false,
-        ts,
-      });
-      lines.push(`id: ${id}\nevent: tool_call\ndata: ${data}\n\n`);
-    }
-    await page.route(
-      "**/events/all",
-      (route) =>
-        route.fulfill({
-          status: 200,
-          contentType: "text/event-stream",
-          body: ": ok\n\n" + lines.join(""),
-        }),
-      { times: 1 },
-    );
-    await page.goto("/");
-    const card = page.getByRole("link", { name: /alpha/i }).first();
-    const svg = card.locator(
-      'svg[aria-label*="tool call frequency" i]',
-    );
-    await expect(svg).toBeVisible();
-  });
-
-  test("renders the stale chip when tool call is older than 30 min", async ({
-    page,
-  }) => {
-    const oldTC = new Date(Date.now() - 45 * 60_000).toISOString();
-    await installMocks(page, {
-      sessions: [
-        {
-          name: "dormant",
-          uuid: "00000000-0000-0000-0000-00000000dead",
-          mode: "safe",
-          workdir: "/home/dev/projects/ctm",
-          created_at: "2026-04-21T10:00:00Z",
-          last_attached_at: "2026-04-21T11:00:00Z",
-          last_tool_call_at: oldTC,
-          is_active: true,
-          tmux_alive: true,
-        },
-      ],
-    });
-    await page.goto("/");
-    const card = page.getByRole("link", { name: /dormant/i }).first();
-    await expect(card.getByLabel("stale session")).toBeVisible();
-  });
-});
diff --git a/ui/e2e/doctor.spec.ts b/ui/e2e/doctor.spec.ts
deleted file mode 100644
index 0a227c2..0000000
--- a/ui/e2e/doctor.spec.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { test, expect } from "@playwright/test";
-import { authenticate, installMocks } from "./fixtures/mocks";
-
-/**
- * V20 doctor panel. Mocks /api/doctor at page level (the shared
- * fixtures don't include it yet; adding it inline here keeps the
- * fixtures file untouched for now — promote if a second spec needs it).
- */
-test.describe("DoctorPanel", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page);
-    await page.route("**/api/doctor", (route) =>
-      route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify({
-          checks: [
-            { name: "dep:tmux", status: "ok", message: "/usr/bin/tmux" },
-            {
-              name: "env:PATH",
-              status: "warn",
-              message: "short",
-              remediation: "export PATH=...",
-            },
-            {
-              name: "serve:token",
-              status: "err",
-              message: "missing",
-              remediation: "run ctm doctor",
-            },
-          ],
-        }),
-      }),
-    );
-  });
-
-  test("navigates to /doctor from the dashboard top bar and renders rows", async ({
-    page,
-  }) => {
-    await page.goto("/");
-
-    // Dashboard → Doctor via the Stethoscope link in the top bar.
-    await page
-      .getByRole("link", { name: /open doctor diagnostics/i })
-      .click();
-    await expect(page).toHaveURL(/\/doctor$/);
-
-    // One row per check.
-    await expect(page.getByText("dep:tmux")).toBeVisible();
-    await expect(page.getByText("env:PATH")).toBeVisible();
-    await expect(page.getByText("serve:token")).toBeVisible();
-
-    // Colour-coded dots present with the expected class hooks.
-    const okDot = page.getByLabel("check ok");
-    const warnDot = page.getByLabel("check warn");
-    const errDot = page.getByLabel("check err");
-    await expect(okDot).toBeVisible();
-    await expect(warnDot).toBeVisible();
-    await expect(errDot).toBeVisible();
-
-    // Classnames carry the status tokens.
-    await expect(okDot).toHaveClass(/bg-live-dot/);
-    await expect(warnDot).toHaveClass(/bg-accent-gold/);
-    await expect(errDot).toHaveClass(/bg-alert-ember/);
-  });
-
-  test("expanding a row reveals its remediation", async ({ page }) => {
-    await page.goto("/doctor");
-
-    await expect(page.getByText("env:PATH")).toBeVisible();
-    // Remediation hidden by default.
-    await expect(
-      page.getByText("export PATH=...", { exact: false }),
-    ).toHaveCount(0);
-
-    // Click the env:PATH row to expand.
-    await page.getByRole("button", { name: /env:PATH.*warn/i }).click();
-    await expect(
-      page.getByText("export PATH=...", { exact: false }),
-    ).toBeVisible();
-  });
-
-  test("re-run button triggers a refetch", async ({ page }) => {
-    let hits = 0;
-    await page.unroute("**/api/doctor");
-    await page.route("**/api/doctor", (route) => {
-      hits += 1;
-      return route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify({
-          checks: [
-            { name: "dep:tmux", status: "ok", message: "/usr/bin/tmux" },
-          ],
-        }),
-      });
-    });
-
-    await page.goto("/doctor");
-    await expect(page.getByText("dep:tmux")).toBeVisible();
-    const initial = hits;
-
-    await page.getByRole("button", { name: /re-run checks/i }).click();
-    await expect.poll(() => hits).toBeGreaterThan(initial);
-  });
-});
diff --git a/ui/e2e/feed-history.spec.ts b/ui/e2e/feed-history.spec.ts
deleted file mode 100644
index 796468a..0000000
--- a/ui/e2e/feed-history.spec.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-import { test, expect, type Route } from "@playwright/test";
-import { authenticate, installMocks } from "./fixtures/mocks";
-
-/**
- * V6 — "Load older" button fetches from /api/sessions/{name}/feed/history
- * when the user wants to scroll past the 500-slot ring buffer.
- *
- * We seed the live feed (via SSE, as bash-filter.spec does) with two
- * recent tool calls, then mock the history endpoint to return 5
- * older tool calls. Clicking "Load older" must append them below the
- * live rows and hide the button when has_more is false.
- */
-
-const liveEvents = [
-  {
-    type: "tool_call",
-    session: "alpha",
-    tool: "Bash",
-    input: "live-command-a",
-    summary: "ok",
-    is_error: false,
-    ts: "2026-04-21T16:30:00Z",
-  },
-  {
-    type: "tool_call",
-    session: "alpha",
-    tool: "Edit",
-    input: "src/live.ts",
-    summary: "1 line changed",
-    is_error: false,
-    ts: "2026-04-21T16:30:05Z",
-  },
-];
-
-const historyPayload = {
-  events: Array.from({ length: 5 }).map((_, i) => ({
-    id: `${100 + i}-0`,
-    session: "alpha",
-    type: "tool_call",
-    ts: `2026-04-21T14:00:0${i}Z`,
-    payload: {
-      session: "alpha",
-      tool: "Read",
-      input: `older-file-${i}.txt`,
-      summary: "read 10 lines",
-      is_error: false,
-      ts: `2026-04-21T14:00:0${i}Z`,
-    },
-  })),
-  has_more: false,
-};
-
-function sseBody(
-  events: Array<{ type: string } & Record>,
-): string {
-  return (
-    events
-      .map((e) => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`)
-      .join("") + ": keepalive\n\n"
-  );
-}
-
-test.describe("Feed history — Load older (V6)", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page);
-
-    // Feed the live SSE stream once; subsequent reconnects drop through
-    // to the installMocks no-op stream.
-    await page.route(
-      "**/events/**",
-      (route: Route) =>
-        route.fulfill({
-          status: 200,
-          contentType: "text/event-stream",
-          headers: {
-            "cache-control": "no-cache",
-            connection: "keep-alive",
-          },
-          body: sseBody(liveEvents),
-        }),
-      { times: 1 },
-    );
-
-    // History endpoint — match the full feed/history path so the
-    // broader /api/sessions/** mock in installMocks doesn't swallow it.
-    await page.route("**/api/sessions/alpha/feed/history**", (route: Route) =>
-      route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify(historyPayload),
-      }),
-    );
-  });
-
-  test("clicking Load older appends historical rows and hides the button", async ({
-    page,
-  }) => {
-    await page.goto("/s/alpha");
-
-    const feed = page.getByRole("region", { name: /feed for alpha/i });
-    await expect(feed).toBeVisible();
-
-    // Wait for the button to appear; its visibility depends on the
-    // feed cache being populated (rows.length > 0). Checking the
-    // button directly avoids racing against a transient empty-cache
-    // render under parallel-worker load.
-    const button = page.getByRole("button", { name: /load older/i });
-    await expect(button).toHaveCount(1, { timeout: 10_000 });
-
-    // Live rows are present before click.
-    await expect(feed.getByText("live-command-a")).toBeVisible();
-    await expect(feed.getByText("src/live.ts")).toBeVisible();
-    await expect(feed.getByText("older-file-0.txt")).toHaveCount(0);
-
-    await button.scrollIntoViewIfNeeded();
-    await button.click();
-
-    // All five historical entries appended.
-    for (let i = 0; i < 5; i++) {
-      await expect(feed.getByText(`older-file-${i}.txt`)).toBeVisible();
-    }
-
-    // has_more: false in the mocked response → button hidden.
-    await expect(
-      page.getByRole("button", { name: /load older/i }),
-    ).toHaveCount(0);
-  });
-});
diff --git a/ui/e2e/fixtures/mocks.ts b/ui/e2e/fixtures/mocks.ts
deleted file mode 100644
index 809ad95..0000000
--- a/ui/e2e/fixtures/mocks.ts
+++ /dev/null
@@ -1,212 +0,0 @@
-import type { Page, Route } from "@playwright/test";
-
-/**
- * Default mock surface: one happy-path session, one quota snapshot,
- * no attention alerts, empty feed. Tests override per-case by calling
- * `installMocks(page, { overrides })` or by adding their own route()
- * handlers after.
- */
-export interface MockOverrides {
-  bootstrap?: unknown;
-  sessions?: unknown;
-  quota?: unknown;
-  feed?: unknown;
-  /** When false, /api/bootstrap returns 401 so the paste screen renders. */
-  authenticated?: boolean;
-  /** Controls /api/auth/status `registered` field (default true). */
-  authRegistered?: boolean;
-  /** Controls /api/auth/status `authenticated` field (default true). */
-  authAuthenticated?: boolean;
-}
-
-const defaultSession = {
-  name: "alpha",
-  uuid: "00000000-0000-0000-0000-000000000001",
-  mode: "safe",
-  workdir: "/home/dev/projects/ctm",
-  created_at: "2026-04-21T15:00:00Z",
-  last_attached_at: "2026-04-21T16:00:00Z",
-  last_tool_call_at: "2026-04-21T16:28:00Z",
-  is_active: true,
-  tmux_alive: true,
-  context_pct: 42,
-  tokens: { input_tokens: 12_340, output_tokens: 2_100, cache_tokens: 55_000 },
-};
-
-const defaultQuota = {
-  weekly_pct: 48,
-  five_hr_pct: 24,
-  // A reset 3 hours into the future — verifies the relativeFuture helper.
-  weekly_resets_at: new Date(Date.now() + 3 * 3600_000).toISOString(),
-  five_hr_resets_at: new Date(Date.now() + 45 * 60_000).toISOString(),
-};
-
-export async function installMocks(
-  page: Page,
-  overrides: MockOverrides = {},
-): Promise {
-  const authed = overrides.authenticated !== false;
-
-  // Catch-all for unmocked /api/** endpoints. Registered first so the
-  // specific mocks below override it (Playwright matches the most
-  // recently registered route). Returns a safe empty payload shaped by
-  // route — crucially NOT 401 — because any 401 bubbles up as
-  // UnauthorizedError and triggers AuthProvider.signOut(), which clears
-  // the bearer token and boots the test back to the paste screen. That
-  // masks every subsequent assertion and makes the suite extremely
-  // brittle to new endpoints (e.g. V13's /api/cost was silently dropping
-  // all tests into the paste screen until this catch-all was added).
-  await page.route("**/api/**", (route: Route) => {
-    const url = route.request().url();
-    const path = new URL(url).pathname;
-    let body = "{}";
-    if (path.startsWith("/api/cost")) {
-      body = JSON.stringify({
-        window: "day",
-        points: [],
-        totals: { input: 0, output: 0, cache: 0, cost_usd_micros: 0 },
-      });
-    } else if (path.endsWith("/subagents")) {
-      body = JSON.stringify({ subagents: [] });
-    } else if (path.endsWith("/teams")) {
-      body = JSON.stringify({ teams: [] });
-    } else if (path.endsWith("/checkpoints")) {
-      body = JSON.stringify({ git_workdir: true, checkpoints: [] });
-    } else if (path.endsWith("/feed/history")) {
-      body = JSON.stringify({ events: [], has_more: false });
-    } else if (path === "/api/logs/usage") {
-      body = JSON.stringify({ dir: "", total_bytes: 0, files: [] });
-    } else if (path === "/api/doctor") {
-      body = JSON.stringify({ checks: [], ok: true });
-    } else if (path === "/api/config") {
-      body = "{}";
-    } else if (/\/api\/sessions\/[^/]+\/input$/.test(path)) {
-      // V25 session-input default: 204 No Content. Tests override with
-      // page.route(...) when they want an error case.
-      return route.fulfill({ status: 204 });
-    } else if (path === "/api/auth/status") {
-      return route.fulfill({
-        status: 200,
-        contentType: "application/json",
-        body: JSON.stringify({
-          registered: overrides.authRegistered ?? true,
-          authenticated: overrides.authAuthenticated ?? true,
-        }),
-      });
-    } else if (path === "/api/auth/signup" && route.request().method() === "POST") {
-      return route.fulfill({
-        status: 201,
-        contentType: "application/json",
-        body: JSON.stringify({ token: "test-token", username: "alice" }),
-      });
-    } else if (path === "/api/auth/login" && route.request().method() === "POST") {
-      return route.fulfill({
-        status: 200,
-        contentType: "application/json",
-        body: JSON.stringify({ token: "test-token", username: "alice" }),
-      });
-    } else if (path === "/api/auth/logout" && route.request().method() === "POST") {
-      return route.fulfill({ status: 204 });
-    }
-    return route.fulfill({ contentType: "application/json", body });
-  });
-
-  await page.route("**/api/bootstrap", (route: Route) => {
-    if (!authed) return route.fulfill({ status: 401 });
-    return route.fulfill({
-      contentType: "application/json",
-      body: JSON.stringify(
-        overrides.bootstrap ?? {
-          version: "e2e-test",
-          port: 37778,
-          has_webhook: false,
-        },
-      ),
-    });
-  });
-
-  await page.route("**/api/sessions**", (route: Route) => {
-    const path = new URL(route.request().url()).pathname;
-    // Differentiate the list endpoint from the per-session detail
-    // endpoint. Both share the `/api/sessions` prefix so a single
-    // glob-mock would otherwise return the list payload for
-    // `/api/sessions/alpha` — leaving useSessionDetail with an array
-    // where it expects a Session object, and crashing MetaList /
-    // CostChart on undefined dates. Nested paths like
-    // `/api/sessions/alpha/checkpoints` fall through to the
-    // shape-aware catch-all above.
-    if (path === "/api/sessions" && route.request().method() === "POST") {
-      // V26 create-session default: 201 with a synthesized session.
-      // Tests override via page.route to exercise 409 / other error cases.
-      return route.fulfill({
-        status: 201,
-        contentType: "application/json",
-        body: JSON.stringify({
-          name: "auto",
-          uuid: "u-new",
-          mode: "yolo",
-          workdir: "/tmp/auto",
-          created_at: new Date().toISOString(),
-          last_attached_at: null,
-          last_tool_call_at: null,
-          is_active: true,
-          tmux_alive: true,
-          context_pct: null,
-          tokens: { input_tokens: 0, output_tokens: 0, cache_tokens: 0 },
-          attention: null,
-        }),
-      });
-    }
-    if (path === "/api/sessions") {
-      return route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify(overrides.sessions ?? [defaultSession]),
-      });
-    }
-    const m = path.match(/^\/api\/sessions\/([^/]+)$/);
-    if (m) {
-      const name = decodeURIComponent(m[1]);
-      const sessions = (overrides.sessions ?? [defaultSession]) as Array<{
-        name: string;
-      }>;
-      const found = sessions.find((s) => s.name === name) ?? defaultSession;
-      return route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify(found),
-      });
-    }
-    return route.fallback();
-  });
-
-  await page.route("**/api/quota", (route: Route) => {
-    return route.fulfill({
-      contentType: "application/json",
-      body: JSON.stringify(overrides.quota ?? defaultQuota),
-    });
-  });
-
-  await page.route("**/api/feed**", (route: Route) => {
-    return route.fulfill({
-      contentType: "application/json",
-      body: JSON.stringify(overrides.feed ?? []),
-    });
-  });
-
-  // Keep SSE calls quiet — route with a held-open no-op stream.
-  await page.route("**/events/**", (route: Route) => {
-    return route.fulfill({
-      status: 200,
-      contentType: "text/event-stream",
-      body: ": ok\n\n",
-    });
-  });
-}
-
-/** Store an auth token in localStorage so the SPA skips the paste screen. */
-export async function authenticate(page: Page): Promise {
-  await page.addInitScript(() => {
-    window.localStorage.setItem("ctm.token", "e2e-test-token");
-  });
-}
-
-export { defaultSession, defaultQuota };
diff --git a/ui/e2e/logs-usage.spec.ts b/ui/e2e/logs-usage.spec.ts
deleted file mode 100644
index 9a02bdc..0000000
--- a/ui/e2e/logs-usage.spec.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { test, expect, type Route } from "@playwright/test";
-import { authenticate, installMocks, defaultSession } from "./fixtures/mocks";
-
-const LOGS_USAGE_PAYLOAD = {
-  dir: "/home/dev/.config/ctm/logs",
-  total_bytes: 1024 * 1024 * 3, // 3 MB
-  files: [
-    {
-      uuid: "00000000-0000-0000-0000-000000000001",
-      session: "alpha",
-      bytes: 1024 * 1024 * 2, // 2 MB
-      mtime: new Date(Date.now() - 5 * 60_000).toISOString(),
-    },
-    {
-      uuid: "22222222-0000-0000-0000-000000000002",
-      session: "beta",
-      bytes: 1024 * 512, // 512 KB
-      mtime: new Date(Date.now() - 30 * 60_000).toISOString(),
-    },
-    {
-      uuid: "33333333-0000-0000-0000-000000000003",
-      session: "uuid:33333333",
-      bytes: 1024 * 512, // 512 KB
-      mtime: new Date(Date.now() - 2 * 3600_000).toISOString(),
-    },
-  ],
-};
-
-test.describe("Log disk usage (Meta tab)", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page);
-
-    // Per-session Get endpoint — dashboard sessions list already covered
-    // by installMocks, but SessionDetail fires its own /api/sessions/{name}.
-    await page.route("**/api/sessions/**", (route: Route) => {
-      const url = new URL(route.request().url());
-      // Skip routes already handled by installMocks (list + feed).
-      if (
-        url.pathname.endsWith("/feed") ||
-        url.pathname === "/api/sessions"
-      ) {
-        return route.fallback();
-      }
-      return route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify(defaultSession),
-      });
-    });
-
-    await page.route("**/api/logs/usage", (route: Route) => {
-      return route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify(LOGS_USAGE_PAYLOAD),
-      });
-    });
-  });
-
-  test("renders total + per-session rows on the Meta tab", async ({ page }) => {
-    await page.goto("/s/alpha/meta");
-
-    const region = page.getByRole("region", { name: /log disk usage/i });
-    await expect(region).toBeVisible();
-
-    // Total in the header — 3 MB.
-    await expect(region).toContainText("3 MB");
-
-    // Per-session rows visible with humanised sizes.
-    await expect(region.getByText("alpha")).toBeVisible();
-    await expect(region.getByText("beta")).toBeVisible();
-    await expect(region.getByText("uuid:33333333")).toBeVisible();
-    await expect(region.getByText("2 MB")).toBeVisible();
-    // Two files at 512 KB — use locator count rather than toBeVisible
-    // because both render identically.
-    await expect(region.getByText("512 KB")).toHaveCount(2);
-
-    // Dir path surfaces in the footer.
-    await expect(region.getByText("/home/dev/.config/ctm/logs")).toBeVisible();
-  });
-});
diff --git a/ui/e2e/mobile-tabs.spec.ts b/ui/e2e/mobile-tabs.spec.ts
deleted file mode 100644
index fdaf7a9..0000000
--- a/ui/e2e/mobile-tabs.spec.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { test, expect } from "@playwright/test";
-import { authenticate, installMocks } from "./fixtures/mocks";
-
-/**
- * Regression guard: at narrow viewports the SessionDetail tab row holds
- * 6 tabs (Feed, Checkpoints, Subagents, Teams, Pane, Meta) which can't
- * all fit at ~390px. The list must remain horizontally scrollable so
- * Pane and Meta stay reachable — without this guard a future tab add,
- * a CSS refactor, or a `overflow-hidden` slip would silently clip the
- * tail off screen.
- */
-test.use({
-  viewport: { width: 390, height: 844 },
-  isMobile: true,
-  hasTouch: true,
-});
-
-test.describe("SessionDetail tabs (mobile)", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page);
-  });
-
-  test("all six tabs are reachable via horizontal scroll", async ({ page }) => {
-    await page.goto("/s/alpha");
-
-    // Scope to the primary SessionDetail tablist — the CostChart on
-    // the Meta tab also renders a role=tablist (hour/day/week pills)
-    // that would otherwise inflate the count.
-    const tablist = page
-      .getByRole("tablist")
-      .filter({ has: page.getByRole("tab", { name: /feed/i }) })
-      .first();
-    const tabs = tablist.getByRole("tab");
-    await expect(tabs).toHaveCount(6);
-
-    // The last tab (Meta) starts clipped off the right edge but must
-    // become clickable after the tablist scrolls it into view. Radix
-    // handles keyboard arrow navigation; we drive it via click on the
-    // scrolled-into-view element to mirror the touch-scroll flow.
-    const metaTab = tablist.getByRole("tab", { name: /meta/i });
-    await metaTab.scrollIntoViewIfNeeded();
-    await metaTab.click();
-
-    await expect(metaTab).toHaveAttribute("aria-selected", "true");
-  });
-});
diff --git a/ui/e2e/pane.spec.ts b/ui/e2e/pane.spec.ts
deleted file mode 100644
index 81e6dd5..0000000
--- a/ui/e2e/pane.spec.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { test, expect, type Route } from "@playwright/test";
-import { authenticate, installMocks } from "./fixtures/mocks";
-
-/**
- * V24 — Pane tab (live tmux capture over SSE).
- *
- * Mocks /events/session/alpha/pane to emit two scripted `pane`
- * frames; navigates to /s/alpha, clicks the Pane tab, and asserts
- * the first frame renders, then the second replaces it.
- *
- * Note: the JSON-encoded data on the wire is a quoted string (the
- * server does `json.Marshal(capture)`), so the SSE `data:` line is
- * `"frame one"` (with literal quotes) — the hook parses it back to
- * the raw capture before rendering.
- */
-
-function paneFrame(text: string): string {
-  return `event: pane\ndata: ${JSON.stringify(text)}\n\n`;
-}
-
-test.describe("Pane tab — live capture (V24)", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page);
-
-    // Precise route for the pane SSE — installMocks catches
-    // /events/** with a no-op; this more specific handler must
-    // win. Playwright's routing matches LIFO (last registered first).
-    await page.route("**/events/session/*/pane", (route: Route) => {
-      const body =
-        paneFrame("first frame\n$ ") +
-        paneFrame("second frame\n$ ls\n");
-      return route.fulfill({
-        status: 200,
-        contentType: "text/event-stream",
-        headers: {
-          "cache-control": "no-cache",
-          connection: "keep-alive",
-        },
-        body,
-      });
-    });
-  });
-
-  test("renders first frame then replaces with second", async ({ page }) => {
-    await page.goto("/s/alpha");
-
-    // Click the Pane tab trigger.
-    await page.getByRole("tab", { name: /^pane$/i }).click();
-
-    const pane = page.getByTestId("pane-view");
-    await expect(pane).toBeVisible();
-
-    // Second frame is the last payload emitted; React state will
-    // settle on it. Poll for it.
-    await expect(pane).toContainText("second frame");
-    await expect(pane).toContainText("$ ls");
-  });
-
-  test("pane region has an accessible label and live indicator", async ({
-    page,
-  }) => {
-    await page.goto("/s/alpha");
-    await page.getByRole("tab", { name: /^pane$/i }).click();
-
-    await expect(
-      page.getByRole("region", { name: /live pane for alpha/i }),
-    ).toBeVisible();
-    await expect(page.getByTestId("pane-live-dot")).toBeVisible();
-  });
-});
diff --git a/ui/e2e/quota-strip.spec.ts b/ui/e2e/quota-strip.spec.ts
deleted file mode 100644
index 754d49a..0000000
--- a/ui/e2e/quota-strip.spec.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { test, expect } from "@playwright/test";
-import { authenticate, installMocks } from "./fixtures/mocks";
-
-test.describe("QuotaStrip", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page);
-  });
-
-  test("shows future reset time, not 0 sec (regression 4e9694f)", async ({
-    page,
-  }) => {
-    // Default mock has 5h reset 45 minutes out, weekly 3 hours out.
-    // Before the relativeFuture fix, both rendered "0 sec".
-    await page.goto("/");
-
-    const strip = page.getByRole("region", { name: /rate limit usage/i });
-    await expect(strip).toBeVisible();
-
-    const text = await strip.innerText();
-    expect(text).not.toContain("resets in 0 sec");
-    expect(text).toMatch(/resets in \d+ (min|hr|day)/);
-  });
-
-  test("renders percentage values", async ({ page }) => {
-    await page.goto("/");
-    const strip = page.getByRole("region", { name: /rate limit usage/i });
-    await expect(strip).toContainText("24%"); // 5h
-    await expect(strip).toContainText("48%"); // weekly
-  });
-});
diff --git a/ui/e2e/session-input.spec.ts b/ui/e2e/session-input.spec.ts
deleted file mode 100644
index 9d0dc98..0000000
--- a/ui/e2e/session-input.spec.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { test, expect, type Route } from "@playwright/test";
-import { authenticate, installMocks, defaultSession } from "./fixtures/mocks";
-
-/**
- * V25 Session Input — e2e coverage:
- *  - Bar is visible on a yolo-mode session and hidden on safe
- *  - Approve button POSTs { preset: "yes" } to the correct URL
- *  - Server 409 tmux_dead surfaces inline via role=status
- */
-test.describe("SessionInputBar", () => {
-  const yolo = { ...defaultSession, name: "alpha", mode: "yolo" as const };
-  const safe = { ...defaultSession, name: "safe", mode: "safe" as const };
-
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page, { sessions: [yolo, safe] });
-  });
-
-  test("shows the bar on yolo sessions and hides it on safe", async ({
-    page,
-  }) => {
-    await page.goto("/s/alpha");
-    await expect(
-      page.getByRole("button", { name: /approve/i }),
-    ).toBeVisible();
-
-    await page.goto("/s/safe");
-    await expect(
-      page.getByRole("button", { name: /approve/i }),
-    ).toHaveCount(0);
-  });
-
-  test("Approve POSTs preset=yes to the right URL", async ({ page }) => {
-    const posted: Array<{ url: string; body: string }> = [];
-    await page.route("**/api/sessions/alpha/input", (route: Route) => {
-      posted.push({
-        url: route.request().url(),
-        body: route.request().postData() ?? "",
-      });
-      return route.fulfill({ status: 204 });
-    });
-
-    await page.goto("/s/alpha");
-    await page.getByRole("button", { name: /approve/i }).click();
-
-    await expect.poll(() => posted.length).toBeGreaterThan(0);
-    expect(posted[0].url).toMatch(/\/api\/sessions\/alpha\/input$/);
-    expect(JSON.parse(posted[0].body)).toEqual({ preset: "yes" });
-  });
-
-  test("surfaces tmux_dead error inline", async ({ page }) => {
-    await page.route("**/api/sessions/alpha/input", (route: Route) =>
-      route.fulfill({
-        status: 409,
-        contentType: "application/json",
-        body: JSON.stringify({
-          error: "tmux_dead",
-          message: "session tmux has exited",
-        }),
-      }),
-    );
-
-    await page.goto("/s/alpha");
-    await page.getByRole("button", { name: /approve/i }).click();
-
-    await expect(
-      page.getByRole("status").filter({ hasText: /tmux|could not/i }),
-    ).toBeVisible();
-  });
-});
diff --git a/ui/e2e/settings.spec.ts b/ui/e2e/settings.spec.ts
deleted file mode 100644
index eece5eb..0000000
--- a/ui/e2e/settings.spec.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-import { test, expect } from "@playwright/test";
-import { authenticate, installMocks } from "./fixtures/mocks";
-
-/**
- * V0.2 Settings drawer — gear icon in the Dashboard top bar opens a
- * right-side drawer that seeds from GET /api/config and PATCHes edits
- * back. The daemon responds 202 then restarts itself ~1s later; we
- * don't simulate the restart here (the ConnectionBanner has its own
- * test coverage), we only assert the PATCH round-trip.
- *
- * Both handlers are mocked inline — the shared fixture file is
- * deliberately not touched until a second spec needs /api/config.
- */
-
-const seededConfig = {
-  webhook_url: "https://old.example",
-  webhook_auth: "Bearer old",
-  attention: {
-    error_rate_pct: 20,
-    error_rate_window: 30,
-    idle_minutes: 5,
-    quota_pct: 85,
-    context_pct: 90,
-    yolo_unchecked_minutes: 30,
-  },
-};
-
-test.describe("SettingsDrawer", () => {
-  test("opens from the gear icon, edits a threshold, and PATCHes back", async ({
-    page,
-  }) => {
-    await authenticate(page);
-    await installMocks(page);
-
-    // Record PATCH body so the assertion can confirm the shape.
-    let patchBody: unknown = null;
-
-    await page.route("**/api/config", (route) => {
-      const req = route.request();
-      if (req.method() === "PATCH") {
-        patchBody = req.postDataJSON();
-        return route.fulfill({
-          status: 202,
-          contentType: "application/json",
-          body: JSON.stringify({ status: "restarting" }),
-        });
-      }
-      return route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify(seededConfig),
-      });
-    });
-
-    await page.goto("/");
-
-    // Open the drawer from the gear button in the top bar.
-    await page.getByRole("button", { name: /open settings/i }).click();
-
-    // Form seeds from GET /api/config.
-    const urlInput = page.getByLabel(/^webhook url$/i);
-    await expect(urlInput).toHaveValue("https://old.example");
-    const quotaInput = page.getByLabel(/^quota %$/i);
-    await expect(quotaInput).toHaveValue("85");
-
-    // Edit the idle minutes threshold.
-    const idleInput = page.getByLabel(/^idle minutes$/i);
-    await idleInput.fill("12");
-
-    // Save.
-    await page.getByRole("button", { name: /save & restart/i }).click();
-
-    // Restarting banner surfaces.
-    await expect(page.getByText(/daemon restarting/i)).toBeVisible();
-
-    // PATCH body shape matches the contract.
-    expect(patchBody).not.toBeNull();
-    const body = patchBody as {
-      webhook_url: string;
-      webhook_auth: string;
-      attention: { idle_minutes: number; quota_pct: number };
-    };
-    expect(body.webhook_url).toBe("https://old.example");
-    expect(body.webhook_auth).toBe("Bearer old");
-    expect(body.attention.idle_minutes).toBe(12);
-    expect(body.attention.quota_pct).toBe(85);
-  });
-
-  test("locally disables submit on out-of-range value", async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page);
-    await page.route("**/api/config", (route) =>
-      route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify(seededConfig),
-      }),
-    );
-
-    await page.goto("/");
-    await page.getByRole("button", { name: /open settings/i }).click();
-
-    const quotaInput = page.getByLabel(/^quota %$/i);
-    await quotaInput.fill("150");
-
-    const submit = page.getByRole("button", { name: /save & restart/i });
-    await expect(submit).toBeDisabled();
-    await expect(page.getByText(/quota % must be <= 100/i)).toBeVisible();
-  });
-
-  test("close button dismisses the drawer", async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page);
-    await page.route("**/api/config", (route) =>
-      route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify(seededConfig),
-      }),
-    );
-
-    await page.goto("/");
-    await page.getByRole("button", { name: /open settings/i }).click();
-    await expect(page.getByLabel(/^webhook url$/i)).toBeVisible();
-
-    // Radix Sheet auto-injects an sr-only X button also named "Close".
-    // Scope to the footer's data-slot to target our explicit footer
-    // button without a strict-mode collision.
-    await page
-      .locator('[data-slot="sheet-footer"]')
-      .getByRole("button", { name: /^close$/i })
-      .click();
-    await expect(page.getByLabel(/^webhook url$/i)).toBeHidden();
-  });
-});
diff --git a/ui/e2e/subagents.spec.ts b/ui/e2e/subagents.spec.ts
deleted file mode 100644
index f38cd6f..0000000
--- a/ui/e2e/subagents.spec.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { test, expect, type Route } from "@playwright/test";
-import { authenticate, installMocks, defaultSession } from "./fixtures/mocks";
-
-const SUBAGENTS_PAYLOAD = {
-  subagents: [
-    {
-      id: "agent-3",
-      parent_id: null,
-      type: "Explore",
-      description: "scan the repo",
-      started_at: "2026-04-21T12:05:00Z",
-      tool_calls: 3,
-      status: "running",
-    },
-    {
-      id: "agent-2",
-      parent_id: null,
-      type: "Task",
-      description: "refactor auth",
-      started_at: "2026-04-21T12:04:00Z",
-      stopped_at: "2026-04-21T12:04:30Z",
-      tool_calls: 2,
-      status: "completed",
-    },
-    {
-      id: "agent-1",
-      parent_id: null,
-      type: "Explore",
-      description: "read spec",
-      started_at: "2026-04-21T12:03:00Z",
-      stopped_at: "2026-04-21T12:03:20Z",
-      tool_calls: 5,
-      status: "failed",
-    },
-  ],
-};
-
-test.describe("Subagents tab (V15)", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page);
-
-    // Per-session GET — dashboard covers the list; SessionDetail hits
-    // /api/sessions/{name} on mount, so we stub it to the default row.
-    await page.route("**/api/sessions/**", (route: Route) => {
-      const url = new URL(route.request().url());
-      if (
-        url.pathname.endsWith("/feed") ||
-        url.pathname === "/api/sessions" ||
-        url.pathname.endsWith("/subagents") ||
-        url.pathname.endsWith("/teams") ||
-        url.pathname.endsWith("/checkpoints") ||
-        url.pathname.endsWith("/feed/history")
-      ) {
-        return route.fallback();
-      }
-      return route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify(defaultSession),
-      });
-    });
-
-    await page.route(
-      "**/api/sessions/*/subagents*",
-      (route: Route) => {
-        return route.fulfill({
-          contentType: "application/json",
-          body: JSON.stringify(SUBAGENTS_PAYLOAD),
-        });
-      },
-    );
-  });
-
-  test("renders all three subagent rows and expands one", async ({ page }) => {
-    await page.goto("/s/alpha/subagents");
-
-    const region = page.getByRole("region", { name: /subagent tree/i });
-    await expect(region).toBeVisible();
-
-    // All three subagent rows visible.
-    await expect(
-      page.getByTestId("subagent-row-agent-1"),
-    ).toBeVisible();
-    await expect(
-      page.getByTestId("subagent-row-agent-2"),
-    ).toBeVisible();
-    await expect(
-      page.getByTestId("subagent-row-agent-3"),
-    ).toBeVisible();
-
-    // Descriptions surface in the rows.
-    await expect(region).toContainText("scan the repo");
-    await expect(region).toContainText("refactor auth");
-    await expect(region).toContainText("read spec");
-
-    // Click to expand agent-3 reveals its tool-call counter.
-    await page.getByTestId("subagent-row-agent-3").click();
-    const detail = page.getByTestId("subagent-detail-agent-3");
-    await expect(detail).toBeVisible();
-    await expect(detail).toContainText("3"); // tool_calls
-  });
-});
diff --git a/ui/e2e/teams.spec.ts b/ui/e2e/teams.spec.ts
deleted file mode 100644
index bcb2f0a..0000000
--- a/ui/e2e/teams.spec.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import { test, expect, type Route } from "@playwright/test";
-import { authenticate, installMocks, defaultSession } from "./fixtures/mocks";
-
-const TEAMS_PAYLOAD = {
-  teams: [
-    {
-      id: "team-live",
-      name: "Explore · 3 agents",
-      dispatched_at: "2026-04-21T12:05:00Z",
-      status: "running",
-      members: [
-        {
-          subagent_id: "agent-a1",
-          description: "scan repo",
-          status: "running",
-        },
-        {
-          subagent_id: "agent-a2",
-          description: "read spec",
-          status: "completed",
-        },
-        {
-          subagent_id: "agent-a3",
-          description: "tests",
-          status: "completed",
-        },
-      ],
-    },
-    {
-      id: "team-done",
-      name: "Task · 2 agents",
-      dispatched_at: "2026-04-21T11:50:00Z",
-      status: "completed",
-      summary: "Plan approved; three follow-ups logged.",
-      members: [
-        {
-          subagent_id: "agent-b1",
-          description: "planner",
-          status: "completed",
-        },
-        {
-          subagent_id: "agent-b2",
-          description: "writer",
-          status: "completed",
-        },
-      ],
-    },
-  ],
-};
-
-test.describe("Teams tab (V16)", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page);
-
-    await page.route("**/api/sessions/**", (route: Route) => {
-      const url = new URL(route.request().url());
-      if (
-        url.pathname.endsWith("/feed") ||
-        url.pathname === "/api/sessions" ||
-        url.pathname.endsWith("/subagents") ||
-        url.pathname.endsWith("/teams") ||
-        url.pathname.endsWith("/checkpoints") ||
-        url.pathname.endsWith("/feed/history")
-      ) {
-        return route.fallback();
-      }
-      return route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify(defaultSession),
-      });
-    });
-
-    await page.route("**/api/sessions/*/teams*", (route: Route) => {
-      return route.fulfill({
-        contentType: "application/json",
-        body: JSON.stringify(TEAMS_PAYLOAD),
-      });
-    });
-  });
-
-  test("renders two teams with distinct status chips and expands to reveal members", async ({
-    page,
-  }) => {
-    await page.goto("/s/alpha/teams");
-
-    const region = page.getByRole("region", { name: /agent teams/i });
-    await expect(region).toBeVisible();
-
-    await expect(page.getByTestId("team-card-team-live")).toBeVisible();
-    await expect(page.getByTestId("team-card-team-done")).toBeVisible();
-
-    // Status chips colour-coded via testid on the chip element.
-    await expect(page.getByTestId("team-status-running")).toBeVisible();
-    await expect(page.getByTestId("team-status-completed")).toBeVisible();
-
-    // Expand the running team and verify members appear.
-    await page
-      .getByTestId("team-card-team-live")
-      .getByRole("button")
-      .first()
-      .click();
-    await expect(
-      page.getByTestId("team-member-agent-a1"),
-    ).toBeVisible();
-    await expect(
-      page.getByTestId("team-member-agent-a2"),
-    ).toBeVisible();
-    await expect(
-      page.getByTestId("team-member-agent-a3"),
-    ).toBeVisible();
-  });
-});
diff --git a/ui/e2e/tool-call-detail.spec.ts b/ui/e2e/tool-call-detail.spec.ts
deleted file mode 100644
index ba11008..0000000
--- a/ui/e2e/tool-call-detail.spec.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { test, expect, type Route } from "@playwright/test";
-import { authenticate, installMocks } from "./fixtures/mocks";
-
-/**
- * V9 — expandable inline diff viewer on Edit/Write/MultiEdit feed
- * rows.
- *
- * The SPA hydrates its feed from SSE (/events/all), stamping each
- * hub Event.ID onto the resulting ToolCallRow in the cache. When the
- * user clicks the expand chevron, ToolCallRow fires
- * /api/sessions/:name/tool_calls/:id/detail and renders the returned
- * unified diff with +/-/@@  colouring.
- *
- * We seed one Edit call, mock the detail endpoint once, and assert
- * the diff surfaces with the correct colour tokens.
- */
-
-const EVENT_ID = "17771234000000000-0";
-
-const EDIT_CALL = {
-  type: "tool_call",
-  session: "alpha",
-  tool: "Edit",
-  input: "src/foo.ts",
-  summary: "1 line changed",
-  is_error: false,
-  ts: "2026-04-21T16:28:05Z",
-};
-
-const SAMPLE_DETAIL = {
-  tool: "Edit",
-  input_json: JSON.stringify({
-    file_path: "src/foo.ts",
-    old_string: "foo",
-    new_string: "bar",
-  }),
-  output_excerpt: "",
-  ts: EDIT_CALL.ts,
-  is_error: false,
-  diff: [
-    "--- a/src/foo.ts",
-    "+++ b/src/foo.ts",
-    "@@ -1,1 +1,1 @@",
-    "-foo",
-    "+bar",
-  ].join("\n"),
-};
-
-function sseBody(): string {
-  // `id:` on the SSE message line is what fetch-event-source surfaces
-  // as EventSourceMessage.id — SseProvider stamps that onto the
-  // ToolCallRow in cache so ToolCallRow can request detail by id.
-  return (
-    `event: tool_call\nid: ${EVENT_ID}\ndata: ${JSON.stringify(EDIT_CALL)}\n\n` +
-    `: keepalive\n\n`
-  );
-}
-
-test.describe("Feed row — inline diff detail (V9)", () => {
-  test.beforeEach(async ({ page }) => {
-    await authenticate(page);
-    await installMocks(page, { feed: [EDIT_CALL] });
-
-    // Serve the seeded Edit event over SSE exactly once; further
-    // reconnects fall through to the no-op stream from installMocks
-    // so the feed cache doesn't double-append.
-    await page.route(
-      "**/events/**",
-      (route: Route) => {
-        return route.fulfill({
-          status: 200,
-          contentType: "text/event-stream",
-          headers: {
-            "cache-control": "no-cache",
-            connection: "keep-alive",
-          },
-          body: sseBody(),
-        });
-      },
-      { times: 1 },
-    );
-
-    // Detail endpoint — fulfilled once per test, asserting the id
-    // round-trips intact.
-    await page.route(
-      `**/api/sessions/alpha/tool_calls/${EVENT_ID}/detail`,
-      (route: Route) =>
-        route.fulfill({
-          status: 200,
-          contentType: "application/json",
-          body: JSON.stringify(SAMPLE_DETAIL),
-        }),
-    );
-  });
-
-  test("expanding an Edit row shows a coloured unified diff", async ({
-    page,
-  }) => {
-    await page.goto("/s/alpha");
-
-    const feed = page.getByRole("region", { name: /feed for alpha/i });
-    await expect(feed).toBeVisible();
-
-    // Wait for the Edit row to surface via SSE.
-    const editRow = feed.locator("article", { hasText: "Edit" }).first();
-    await expect(editRow).toBeVisible();
-
-    // Expand control is present for Edit/MultiEdit/Write only.
-    const expand = editRow.getByTestId("tool-expand");
-    await expect(expand).toBeVisible();
-    await expect(expand).toHaveAttribute("aria-expanded", "false");
-
-    await expand.click();
-    await expect(expand).toHaveAttribute("aria-expanded", "true");
-
-    // Diff renders.
-    const diff = editRow.getByTestId("tool-diff");
-    await expect(diff).toBeVisible();
-
-    // Added line → emerald.
-    const added = diff.getByText("+bar");
-    await expect(added).toBeVisible();
-    await expect(added).toHaveClass(/text-emerald-400/);
-
-    // Removed line → alert-ember.
-    const removed = diff.getByText("-foo");
-    await expect(removed).toBeVisible();
-    await expect(removed).toHaveClass(/text-alert-ember/);
-
-    // Hunk header → fg-dim.
-    const hunk = diff.getByText("@@ -1,1 +1,1 @@");
-    await expect(hunk).toBeVisible();
-    await expect(hunk).toHaveClass(/text-fg-dim/);
-  });
-});
diff --git a/ui/eslint.config.js b/ui/eslint.config.js
deleted file mode 100644
index 7bc6266..0000000
--- a/ui/eslint.config.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import js from "@eslint/js";
-import globals from "globals";
-import reactHooks from "eslint-plugin-react-hooks";
-import reactRefresh from "eslint-plugin-react-refresh";
-import tseslint from "typescript-eslint";
-
-export default tseslint.config(
-  { ignores: ["dist", "node_modules"] },
-  {
-    extends: [js.configs.recommended, ...tseslint.configs.recommended],
-    files: ["**/*.{ts,tsx}"],
-    languageOptions: {
-      ecmaVersion: 2022,
-      globals: globals.browser,
-    },
-    plugins: {
-      "react-hooks": reactHooks,
-      "react-refresh": reactRefresh,
-    },
-    rules: {
-      ...reactHooks.configs.recommended.rules,
-      "react-refresh/only-export-components": [
-        "warn",
-        { allowConstantExport: true },
-      ],
-    },
-  },
-);
diff --git a/ui/index.html b/ui/index.html
deleted file mode 100644
index 770ba0e..0000000
--- a/ui/index.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-  
-    
-    
-    
-    
-    ctm
-  
-  
-    
- - - diff --git a/ui/package.json b/ui/package.json deleted file mode 100644 index d1bf993..0000000 --- a/ui/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "ctm-ui", - "private": true, - "version": "0.1.0", - "type": "module", - "packageManager": "pnpm@10.33.0", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview", - "lint": "eslint .", - "typecheck": "tsc -b --noEmit", - "test": "vitest run --passWithNoTests" - }, - "dependencies": { - "@microsoft/fetch-event-source": "^2.0.1", - "@ossrandom/design-system": "^0.2.1", - "@tanstack/react-query": "^5.99.2", - "clsx": "^2.1.1", - "lucide-react": "^0.544.0", - "react": "^19.2.5", - "react-dom": "^19.2.5", - "react-router": "^7.14.1", - "tailwind-merge": "^3.5.0" - }, - "devDependencies": { - "@eslint/js": "10.0.1", - "@playwright/test": "^1.59.1", - "@tailwindcss/vite": "^4.2.2", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "14.6.1", - "@types/node": "^24.0.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.2.0", - "@vitest/coverage-v8": "^3.2.4", - "eslint": "^9.0.0", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.12", - "globals": "^15.14.0", - "jsdom": "^25.0.0", - "msw": "^2.13.4", - "tailwindcss": "^4.2.2", - "typescript": "~5.6.3", - "typescript-eslint": "^8.59.0", - "vite": "^7.0.0", - "vitest": "^3.2.4" - } -} diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts deleted file mode 100644 index e4721fb..0000000 --- a/ui/playwright.config.ts +++ /dev/null @@ -1,46 +0,0 @@ -/// -import { defineConfig, devices } from "@playwright/test"; - -/** - * Playwright E2E config. Drives the React SPA against a mocked /api + /events - * surface via `page.route` so tests stay fast and deterministic; backed by the - * vite preview server (built bundle, not dev server) so CSS/asset resolution - * matches production. - */ -export default defineConfig({ - testDir: "./e2e", - outputDir: "./test-results", - timeout: 15_000, - expect: { timeout: 3_000 }, - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, - reporter: process.env.CI ? "github" : "list", - - use: { - baseURL: "http://127.0.0.1:4173", - trace: "retain-on-failure", - screenshot: "only-on-failure", - }, - - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], - - // webServer assumes `dist/` is already built. `make e2e` runs - // `pnpm build` first; running `playwright test` directly also works - // if you've recently built. Vite preview serves the static bundle — - // no dev-server HMR noise — matching what ships to users. - webServer: { - command: - "pnpm exec vite preview --port 4173 --strictPort --host 127.0.0.1", - url: "http://127.0.0.1:4173", - reuseExistingServer: !process.env.CI, - timeout: 30_000, - stdout: "ignore", - stderr: "pipe", - }, -}); diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml deleted file mode 100644 index e7eac08..0000000 --- a/ui/pnpm-lock.yaml +++ /dev/null @@ -1,4154 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@microsoft/fetch-event-source': - specifier: ^2.0.1 - version: 2.0.1 - '@ossrandom/design-system': - specifier: ^0.2.1 - version: 0.2.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/react-query': - specifier: ^5.99.2 - version: 5.99.2(react@19.2.5) - clsx: - specifier: ^2.1.1 - version: 2.1.1 - lucide-react: - specifier: ^0.544.0 - version: 0.544.0(react@19.2.5) - react: - specifier: ^19.2.5 - version: 19.2.5 - react-dom: - specifier: ^19.2.5 - version: 19.2.5(react@19.2.5) - react-router: - specifier: ^7.14.1 - version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - tailwind-merge: - specifier: ^3.5.0 - version: 3.5.0 - devDependencies: - '@eslint/js': - specifier: 10.0.1 - version: 10.0.1(eslint@9.39.4(jiti@2.6.1)) - '@playwright/test': - specifier: ^1.59.1 - version: 1.59.1 - '@tailwindcss/vite': - specifier: ^4.2.2 - version: 4.2.2(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) - '@testing-library/jest-dom': - specifier: ^6.9.1 - version: 6.9.1 - '@testing-library/react': - specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@testing-library/user-event': - specifier: 14.6.1 - version: 14.6.1(@testing-library/dom@10.4.1) - '@types/node': - specifier: ^24.0.0 - version: 24.12.2 - '@types/react': - specifier: ^19.2.14 - version: 19.2.14 - '@types/react-dom': - specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.14) - '@vitejs/plugin-react': - specifier: ^5.2.0 - version: 5.2.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) - '@vitest/coverage-v8': - specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.12.2)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.13.4(@types/node@24.12.2)(typescript@5.6.3))) - eslint: - specifier: ^9.0.0 - version: 9.39.4(jiti@2.6.1) - eslint-plugin-react-hooks: - specifier: ^5.2.0 - version: 5.2.0(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-react-refresh: - specifier: ^0.4.12 - version: 0.4.26(eslint@9.39.4(jiti@2.6.1)) - globals: - specifier: ^15.14.0 - version: 15.15.0 - jsdom: - specifier: ^25.0.0 - version: 25.0.1 - msw: - specifier: ^2.13.4 - version: 2.13.4(@types/node@24.12.2)(typescript@5.6.3) - tailwindcss: - specifier: ^4.2.2 - version: 4.2.2 - typescript: - specifier: ~5.6.3 - version: 5.6.3 - typescript-eslint: - specifier: ^8.59.0 - version: 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3) - vite: - specifier: ^7.0.0 - version: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) - vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.13.4(@types/node@24.12.2)(typescript@5.6.3)) - -packages: - - '@adobe/css-tools@4.4.4': - resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} - - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - - '@asamuzakjp/css-color@3.2.0': - resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@bcoe/v8-coverage@1.0.2': - resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} - engines: {node: '>=18'} - - '@csstools/color-helpers@5.1.0': - resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} - engines: {node: '>=18'} - - '@csstools/css-calc@2.1.4': - resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-color-parser@3.1.0': - resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.5 - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-parser-algorithms@3.0.5': - resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} - engines: {node: '>=18'} - peerDependencies: - '@csstools/css-tokenizer': ^3.0.4 - - '@csstools/css-tokenizer@3.0.4': - resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} - engines: {node: '>=18'} - - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@10.0.1': - resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: ^10.0.0 - peerDependenciesMeta: - eslint: - optional: true - - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} - - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@inquirer/ansi@2.0.5': - resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - - '@inquirer/confirm@6.0.12': - resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/core@11.1.9': - resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/figures@2.0.5': - resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - - '@inquirer/type@4.0.5': - resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@istanbuljs/schema@0.1.6': - resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} - engines: {node: '>=8'} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@microsoft/fetch-event-source@2.0.1': - resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} - - '@mswjs/interceptors@0.41.4': - resolution: {integrity: sha512-3B9EinUkrdOUGYzHRzRWSXunQ4YFGboJnyLNRwEJWEde+j8fNhPUHvrN1E3g1DU/iS/s8JQrMNVe+S7AHHVs0w==} - engines: {node: '>=18'} - - '@open-draft/deferred-promise@2.2.0': - resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - - '@open-draft/deferred-promise@3.0.0': - resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} - - '@open-draft/logger@0.3.0': - resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} - - '@open-draft/until@2.1.0': - resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - - '@ossrandom/design-system@0.2.1': - resolution: {integrity: sha512-eMKLkPvdMMocxedQR8YWZYAiHADarfD4QYSLJYagTyRKa05m73CvetncjtnJDI1nAE/Lh7+BGllJwTaFqmmYjg==} - engines: {node: '>=18.18'} - peerDependencies: - react: '>=18' - react-dom: '>=18' - - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - - '@playwright/test@1.59.1': - resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} - engines: {node: '>=18'} - hasBin: true - - '@rolldown/pluginutils@1.0.0-rc.3': - resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - - '@rollup/rollup-android-arm-eabi@4.60.2': - resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.60.2': - resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.60.2': - resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.60.2': - resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.60.2': - resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.60.2': - resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': - resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.60.2': - resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.60.2': - resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.60.2': - resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.60.2': - resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.60.2': - resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.60.2': - resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.60.2': - resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.60.2': - resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.60.2': - resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.60.2': - resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.60.2': - resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.60.2': - resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.60.2': - resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.60.2': - resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.60.2': - resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.60.2': - resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.60.2': - resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.60.2': - resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==} - cpu: [x64] - os: [win32] - - '@tailwindcss/node@4.2.2': - resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} - - '@tailwindcss/oxide-android-arm64@4.2.2': - resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.2.2': - resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.2.2': - resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.2.2': - resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.2.2': - resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} - engines: {node: '>= 20'} - - '@tailwindcss/vite@4.2.2': - resolution: {integrity: sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 || ^8 - - '@tanstack/query-core@5.99.2': - resolution: {integrity: sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==} - - '@tanstack/react-query@5.99.2': - resolution: {integrity: sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==} - peerDependencies: - react: ^18 || ^19 - - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} - engines: {node: '>=18'} - - '@testing-library/jest-dom@6.9.1': - resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} - engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - - '@testing-library/react@16.3.2': - resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} - engines: {node: '>=18'} - peerDependencies: - '@testing-library/dom': ^10.0.0 - '@types/react': ^18.0.0 || ^19.0.0 - '@types/react-dom': ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@testing-library/user-event@14.6.1': - resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} - engines: {node: '>=12', npm: '>=6'} - peerDependencies: - '@testing-library/dom': '>=7.21.4' - - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/node@24.12.2': - resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - - '@types/set-cookie-parser@2.4.10': - resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} - - '@types/statuses@2.0.6': - resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} - - '@typescript-eslint/eslint-plugin@8.59.0': - resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.59.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/parser@8.59.0': - resolution: {integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/project-service@8.59.0': - resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/scope-manager@8.59.0': - resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.59.0': - resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/type-utils@8.59.0': - resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/types@8.59.0': - resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.59.0': - resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.59.0': - resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/visitor-keys@8.59.0': - resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@vitejs/plugin-react@5.2.0': - resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - - '@vitest/coverage-v8@3.2.4': - resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} - peerDependencies: - '@vitest/browser': 3.2.4 - vitest: 3.2.4 - peerDependenciesMeta: - '@vitest/browser': - optional: true - - '@vitest/expect@3.2.4': - resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - - '@vitest/mocker@3.2.4': - resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} - peerDependencies: - msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@3.2.4': - resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - - '@vitest/runner@3.2.4': - resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} - - '@vitest/snapshot@3.2.4': - resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} - - '@vitest/spy@3.2.4': - resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - - '@vitest/utils@3.2.4': - resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} - - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - ast-v8-to-istanbul@0.3.12: - resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - baseline-browser-mapping@2.10.20: - resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==} - engines: {node: '>=6.0.0'} - hasBin: true - - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - - brace-expansion@2.1.0: - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} - engines: {node: 18 || 20 || >=22} - - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - caniuse-lite@1.0.30001788: - resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} - - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} - engines: {node: '>=18'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - check-error@2.1.3: - resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} - engines: {node: '>= 16'} - - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - css.escape@1.5.1: - resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} - - cssstyle@4.6.0: - resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} - engines: {node: '>=18'} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decimal.js@10.6.0: - resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - - dom-accessibility-api@0.6.3: - resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - - electron-to-chromium@1.5.340: - resolution: {integrity: sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - enhanced-resolve@5.20.1: - resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} - engines: {node: '>=10.13.0'} - - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} - engines: {node: '>=0.12'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-plugin-react-hooks@5.2.0: - resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} - engines: {node: '>=10'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - - eslint-plugin-react-refresh@0.4.26: - resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} - peerDependencies: - eslint: '>=8.40' - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fast-string-truncated-width@3.0.3: - resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} - - fast-string-width@3.0.2: - resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - - fast-wrap-ansi@0.2.0: - resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - globals@15.15.0: - resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} - engines: {node: '>=18'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - graphql@16.13.2: - resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} - engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.3: - resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} - engines: {node: '>= 0.4'} - - headers-polyfill@5.0.1: - resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} - - html-encoding-sniffer@4.0.0: - resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} - engines: {node: '>=18'} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - - http-proxy-agent@7.0.2: - resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} - engines: {node: '>= 14'} - - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-node-process@1.2.0: - resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} - - is-potential-custom-element-name@1.0.1: - resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-lib-source-maps@5.0.6: - resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} - engines: {node: '>=10'} - - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - - js-tokens@10.0.0: - resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - jsdom@25.0.1: - resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} - engines: {node: '>=18'} - peerDependencies: - canvas: ^2.11.2 - peerDependenciesMeta: - canvas: - optional: true - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lucide-react@0.544.0: - resolution: {integrity: sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - magicast@0.3.5: - resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - min-indent@1.0.1: - resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} - engines: {node: '>=4'} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - msw@2.13.4: - resolution: {integrity: sha512-fPlKBeFe+8rpcyR3umUmmHuNwu6gc6T3STvkgEa9WDX/HEgal9wDeflpCUAIRtmvaLZM2igfI5y1bZ9G5J26KA==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - typescript: '>= 4.8.x' - peerDependenciesMeta: - typescript: - optional: true - - mute-stream@3.0.0: - resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} - engines: {node: ^20.17.0 || >=22.9.0} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - node-releases@2.0.37: - resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} - - nwsapi@2.2.23: - resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - outvariant@1.4.3: - resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - playwright-core@1.59.1: - resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.59.1: - resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} - engines: {node: '>=18'} - hasBin: true - - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - react-dom@19.2.5: - resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} - peerDependencies: - react: ^19.2.5 - - react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - - react-refresh@0.18.0: - resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} - engines: {node: '>=0.10.0'} - - react-router@7.14.1: - resolution: {integrity: sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==} - engines: {node: '>=20.0.0'} - peerDependencies: - react: '>=18' - react-dom: '>=18' - peerDependenciesMeta: - react-dom: - optional: true - - react@19.2.5: - resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} - engines: {node: '>=0.10.0'} - - redent@3.0.0: - resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} - engines: {node: '>=8'} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - rettime@0.11.8: - resolution: {integrity: sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==} - - rollup@4.60.2: - resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - rrweb-cssom@0.7.1: - resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} - - rrweb-cssom@0.8.0: - resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - saxes@6.0.0: - resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} - engines: {node: '>=v12.22.7'} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - - set-cookie-parser@3.1.0: - resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - - strict-event-emitter@0.5.1: - resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - - strip-indent@3.0.0: - resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} - engines: {node: '>=8'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - strip-literal@3.1.0: - resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - symbol-tree@3.2.4: - resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - - tagged-tag@1.0.0: - resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} - engines: {node: '>=20'} - - tailwind-merge@3.5.0: - resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - - tailwindcss@4.2.2: - resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} - - tapable@2.3.2: - resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} - engines: {node: '>=6'} - - test-exclude@7.0.2: - resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} - engines: {node: '>=18'} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@2.0.0: - resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} - engines: {node: '>=14.0.0'} - - tinyspy@4.0.4: - resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} - engines: {node: '>=14.0.0'} - - tldts-core@6.1.86: - resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} - - tldts-core@7.0.28: - resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} - - tldts@6.1.86: - resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} - hasBin: true - - tldts@7.0.28: - resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} - hasBin: true - - tough-cookie@5.1.2: - resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} - engines: {node: '>=16'} - - tough-cookie@6.0.1: - resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} - engines: {node: '>=16'} - - tr46@5.1.1: - resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} - engines: {node: '>=18'} - - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - type-fest@5.6.0: - resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} - engines: {node: '>=20'} - - typescript-eslint@8.59.0: - resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - typescript@5.6.3: - resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - - until-async@3.0.2: - resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - vite-node@3.2.4: - resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - - vite@7.3.2: - resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/debug': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - w3c-xmlserializer@5.0.0: - resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} - engines: {node: '>=18'} - - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} - - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - - whatwg-url@14.2.0: - resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} - engines: {node: '>=18'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - -snapshots: - - '@adobe/css-tools@4.4.4': {} - - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@asamuzakjp/css-color@3.2.0': - dependencies: - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - lru-cache: 10.4.3 - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.28.6': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.29.2': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/runtime@7.29.2': {} - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@bcoe/v8-coverage@1.0.2': {} - - '@csstools/color-helpers@5.1.0': {} - - '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/color-helpers': 5.1.0 - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': - dependencies: - '@csstools/css-tokenizer': 3.0.4 - - '@csstools/css-tokenizer@3.0.4': {} - - '@esbuild/aix-ppc64@0.27.7': - optional: true - - '@esbuild/android-arm64@0.27.7': - optional: true - - '@esbuild/android-arm@0.27.7': - optional: true - - '@esbuild/android-x64@0.27.7': - optional: true - - '@esbuild/darwin-arm64@0.27.7': - optional: true - - '@esbuild/darwin-x64@0.27.7': - optional: true - - '@esbuild/freebsd-arm64@0.27.7': - optional: true - - '@esbuild/freebsd-x64@0.27.7': - optional: true - - '@esbuild/linux-arm64@0.27.7': - optional: true - - '@esbuild/linux-arm@0.27.7': - optional: true - - '@esbuild/linux-ia32@0.27.7': - optional: true - - '@esbuild/linux-loong64@0.27.7': - optional: true - - '@esbuild/linux-mips64el@0.27.7': - optional: true - - '@esbuild/linux-ppc64@0.27.7': - optional: true - - '@esbuild/linux-riscv64@0.27.7': - optional: true - - '@esbuild/linux-s390x@0.27.7': - optional: true - - '@esbuild/linux-x64@0.27.7': - optional: true - - '@esbuild/netbsd-arm64@0.27.7': - optional: true - - '@esbuild/netbsd-x64@0.27.7': - optional: true - - '@esbuild/openbsd-arm64@0.27.7': - optional: true - - '@esbuild/openbsd-x64@0.27.7': - optional: true - - '@esbuild/openharmony-arm64@0.27.7': - optional: true - - '@esbuild/sunos-x64@0.27.7': - optional: true - - '@esbuild/win32-arm64@0.27.7': - optional: true - - '@esbuild/win32-ia32@0.27.7': - optional: true - - '@esbuild/win32-x64@0.27.7': - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': - dependencies: - eslint: 9.39.4(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.21.2': - dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3 - minimatch: 3.1.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 - - '@eslint/core@0.17.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.5': - dependencies: - ajv: 6.14.0 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@10.0.1(eslint@9.39.4(jiti@2.6.1))': - optionalDependencies: - eslint: 9.39.4(jiti@2.6.1) - - '@eslint/js@9.39.4': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 - - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 - - '@humanfs/types@0.15.0': {} - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@inquirer/ansi@2.0.5': {} - - '@inquirer/confirm@6.0.12(@types/node@24.12.2)': - dependencies: - '@inquirer/core': 11.1.9(@types/node@24.12.2) - '@inquirer/type': 4.0.5(@types/node@24.12.2) - optionalDependencies: - '@types/node': 24.12.2 - - '@inquirer/core@11.1.9(@types/node@24.12.2)': - dependencies: - '@inquirer/ansi': 2.0.5 - '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@24.12.2) - cli-width: 4.1.0 - fast-wrap-ansi: 0.2.0 - mute-stream: 3.0.0 - signal-exit: 4.1.0 - optionalDependencies: - '@types/node': 24.12.2 - - '@inquirer/figures@2.0.5': {} - - '@inquirer/type@4.0.5(@types/node@24.12.2)': - optionalDependencies: - '@types/node': 24.12.2 - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@istanbuljs/schema@0.1.6': {} - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@microsoft/fetch-event-source@2.0.1': {} - - '@mswjs/interceptors@0.41.4': - dependencies: - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/logger': 0.3.0 - '@open-draft/until': 2.1.0 - is-node-process: 1.2.0 - outvariant: 1.4.3 - strict-event-emitter: 0.5.1 - - '@open-draft/deferred-promise@2.2.0': {} - - '@open-draft/deferred-promise@3.0.0': {} - - '@open-draft/logger@0.3.0': - dependencies: - is-node-process: 1.2.0 - outvariant: 1.4.3 - - '@open-draft/until@2.1.0': {} - - '@ossrandom/design-system@0.2.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - - '@pkgjs/parseargs@0.11.0': - optional: true - - '@playwright/test@1.59.1': - dependencies: - playwright: 1.59.1 - - '@rolldown/pluginutils@1.0.0-rc.3': {} - - '@rollup/rollup-android-arm-eabi@4.60.2': - optional: true - - '@rollup/rollup-android-arm64@4.60.2': - optional: true - - '@rollup/rollup-darwin-arm64@4.60.2': - optional: true - - '@rollup/rollup-darwin-x64@4.60.2': - optional: true - - '@rollup/rollup-freebsd-arm64@4.60.2': - optional: true - - '@rollup/rollup-freebsd-x64@4.60.2': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.60.2': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.60.2': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.60.2': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.60.2': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.60.2': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.60.2': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.60.2': - optional: true - - '@rollup/rollup-linux-x64-musl@4.60.2': - optional: true - - '@rollup/rollup-openbsd-x64@4.60.2': - optional: true - - '@rollup/rollup-openharmony-arm64@4.60.2': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.60.2': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.60.2': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.60.2': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.60.2': - optional: true - - '@tailwindcss/node@4.2.2': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.1 - jiti: 2.6.1 - lightningcss: 1.32.0 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.2.2 - - '@tailwindcss/oxide-android-arm64@4.2.2': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.2.2': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.2.2': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - optional: true - - '@tailwindcss/oxide@4.2.2': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-x64': 4.2.2 - '@tailwindcss/oxide-freebsd-x64': 4.2.2 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-x64-musl': 4.2.2 - '@tailwindcss/oxide-wasm32-wasi': 4.2.2 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - - '@tailwindcss/vite@4.2.2(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))': - dependencies: - '@tailwindcss/node': 4.2.2 - '@tailwindcss/oxide': 4.2.2 - tailwindcss: 4.2.2 - vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) - - '@tanstack/query-core@5.99.2': {} - - '@tanstack/react-query@5.99.2(react@19.2.5)': - dependencies: - '@tanstack/query-core': 5.99.2 - react: 19.2.5 - - '@testing-library/dom@10.4.1': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - picocolors: 1.1.1 - pretty-format: 27.5.1 - - '@testing-library/jest-dom@6.9.1': - dependencies: - '@adobe/css-tools': 4.4.4 - aria-query: 5.3.2 - css.escape: 1.5.1 - dom-accessibility-api: 0.6.3 - picocolors: 1.1.1 - redent: 3.0.0 - - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@babel/runtime': 7.29.2 - '@testing-library/dom': 10.4.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - optionalDependencies: - '@types/react': 19.2.14 - '@types/react-dom': 19.2.3(@types/react@19.2.14) - - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': - dependencies: - '@testing-library/dom': 10.4.1 - - '@types/aria-query@5.0.4': {} - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.0 - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - - '@types/estree@1.0.8': {} - - '@types/json-schema@7.0.15': {} - - '@types/node@24.12.2': - dependencies: - undici-types: 7.16.0 - - '@types/react-dom@19.2.3(@types/react@19.2.14)': - dependencies: - '@types/react': 19.2.14 - - '@types/react@19.2.14': - dependencies: - csstype: 3.2.3 - - '@types/set-cookie-parser@2.4.10': - dependencies: - '@types/node': 24.12.2 - - '@types/statuses@2.0.6': {} - - '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3) - '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/type-utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3) - '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3) - '@typescript-eslint/visitor-keys': 8.59.0 - eslint: 9.39.4(jiti@2.6.1) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.6.3) - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.6.3) - '@typescript-eslint/visitor-keys': 8.59.0 - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.59.0(typescript@5.6.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.6.3) - '@typescript-eslint/types': 8.59.0 - debug: 4.4.3 - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.59.0': - dependencies: - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/visitor-keys': 8.59.0 - - '@typescript-eslint/tsconfig-utils@8.59.0(typescript@5.6.3)': - dependencies: - typescript: 5.6.3 - - '@typescript-eslint/type-utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3)': - dependencies: - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.6.3) - '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3) - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@5.6.3) - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.59.0': {} - - '@typescript-eslint/typescript-estree@8.59.0(typescript@5.6.3)': - dependencies: - '@typescript-eslint/project-service': 8.59.0(typescript@5.6.3) - '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.6.3) - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/visitor-keys': 8.59.0 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.7.4 - tinyglobby: 0.2.16 - ts-api-utils: 2.5.0(typescript@5.6.3) - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/types': 8.59.0 - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.6.3) - eslint: 9.39.4(jiti@2.6.1) - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.59.0': - dependencies: - '@typescript-eslint/types': 8.59.0 - eslint-visitor-keys: 5.0.1 - - '@vitejs/plugin-react@5.2.0(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-rc.3 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) - transitivePeerDependencies: - - supports-color - - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.12.2)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.13.4(@types/node@24.12.2)(typescript@5.6.3)))': - dependencies: - '@ampproject/remapping': 2.3.0 - '@bcoe/v8-coverage': 1.0.2 - ast-v8-to-istanbul: 0.3.12 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 - istanbul-reports: 3.2.0 - magic-string: 0.30.21 - magicast: 0.3.5 - std-env: 3.10.0 - test-exclude: 7.0.2 - tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.13.4(@types/node@24.12.2)(typescript@5.6.3)) - transitivePeerDependencies: - - supports-color - - '@vitest/expect@3.2.4': - dependencies: - '@types/chai': 5.2.3 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - tinyrainbow: 2.0.0 - - '@vitest/mocker@3.2.4(msw@2.13.4(@types/node@24.12.2)(typescript@5.6.3))(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - msw: 2.13.4(@types/node@24.12.2)(typescript@5.6.3) - vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) - - '@vitest/pretty-format@3.2.4': - dependencies: - tinyrainbow: 2.0.0 - - '@vitest/runner@3.2.4': - dependencies: - '@vitest/utils': 3.2.4 - pathe: 2.0.3 - strip-literal: 3.1.0 - - '@vitest/snapshot@3.2.4': - dependencies: - '@vitest/pretty-format': 3.2.4 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@3.2.4': - dependencies: - tinyspy: 4.0.4 - - '@vitest/utils@3.2.4': - dependencies: - '@vitest/pretty-format': 3.2.4 - loupe: 3.2.1 - tinyrainbow: 2.0.0 - - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - agent-base@7.1.4: {} - - ajv@6.14.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@5.2.0: {} - - ansi-styles@6.2.3: {} - - argparse@2.0.1: {} - - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 - - aria-query@5.3.2: {} - - assertion-error@2.0.1: {} - - ast-v8-to-istanbul@0.3.12: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - estree-walker: 3.0.3 - js-tokens: 10.0.0 - - asynckit@0.4.0: {} - - balanced-match@1.0.2: {} - - balanced-match@4.0.4: {} - - baseline-browser-mapping@2.10.20: {} - - brace-expansion@1.1.14: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.1.0: - dependencies: - balanced-match: 1.0.2 - - brace-expansion@5.0.5: - dependencies: - balanced-match: 4.0.4 - - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.20 - caniuse-lite: 1.0.30001788 - electron-to-chromium: 1.5.340 - node-releases: 2.0.37 - update-browserslist-db: 1.2.3(browserslist@4.28.2) - - cac@6.7.14: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - callsites@3.1.0: {} - - caniuse-lite@1.0.30001788: {} - - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.3 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - check-error@2.1.3: {} - - cli-width@4.1.0: {} - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - clsx@2.1.1: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - concat-map@0.0.1: {} - - convert-source-map@2.0.0: {} - - cookie@1.1.1: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - css.escape@1.5.1: {} - - cssstyle@4.6.0: - dependencies: - '@asamuzakjp/css-color': 3.2.0 - rrweb-cssom: 0.8.0 - - csstype@3.2.3: {} - - data-urls@5.0.0: - dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decimal.js@10.6.0: {} - - deep-eql@5.0.2: {} - - deep-is@0.1.4: {} - - delayed-stream@1.0.0: {} - - dequal@2.0.3: {} - - detect-libc@2.1.2: {} - - dom-accessibility-api@0.5.16: {} - - dom-accessibility-api@0.6.3: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - eastasianwidth@0.2.0: {} - - electron-to-chromium@1.5.340: {} - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - - enhanced-resolve@5.20.1: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.2 - - entities@6.0.1: {} - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-module-lexer@1.7.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.3 - - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-plugin-react-hooks@5.2.0(eslint@9.39.4(jiti@2.6.1)): - dependencies: - eslint: 9.39.4(jiti@2.6.1) - - eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@2.6.1)): - dependencies: - eslint: 9.39.4(jiti@2.6.1) - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@9.39.4(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 4.2.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - esutils@2.0.3: {} - - expect-type@1.3.0: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fast-string-truncated-width@3.0.3: {} - - fast-string-width@3.0.2: - dependencies: - fast-string-truncated-width: 3.0.3 - - fast-wrap-ansi@0.2.0: - dependencies: - fast-string-width: 3.0.2 - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.3 - mime-types: 2.1.35 - - fsevents@2.3.2: - optional: true - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - gensync@1.0.0-beta.2: {} - - get-caller-file@2.0.5: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.3 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - - globals@14.0.0: {} - - globals@15.15.0: {} - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - graphql@16.13.2: {} - - has-flag@4.0.0: {} - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.3: - dependencies: - function-bind: 1.1.2 - - headers-polyfill@5.0.1: - dependencies: - '@types/set-cookie-parser': 2.4.10 - set-cookie-parser: 3.1.0 - - html-encoding-sniffer@4.0.0: - dependencies: - whatwg-encoding: 3.1.1 - - html-escaper@2.0.2: {} - - http-proxy-agent@7.0.2: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - - ignore@5.3.2: {} - - ignore@7.0.5: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - indent-string@4.0.0: {} - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-node-process@1.2.0: {} - - is-potential-custom-element-name@1.0.1: {} - - isexe@2.0.0: {} - - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-lib-source-maps@5.0.6: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 - istanbul-lib-coverage: 3.2.2 - transitivePeerDependencies: - - supports-color - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - - jiti@2.6.1: {} - - js-tokens@10.0.0: {} - - js-tokens@4.0.0: {} - - js-tokens@9.0.1: {} - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - jsdom@25.0.1: - dependencies: - cssstyle: 4.6.0 - data-urls: 5.0.0 - decimal.js: 10.6.0 - form-data: 4.0.5 - html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.23 - parse5: 7.3.0 - rrweb-cssom: 0.7.1 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 5.1.2 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - ws: 8.20.0 - xml-name-validator: 5.0.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - jsesc@3.1.0: {} - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@2.2.3: {} - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.merge@4.6.2: {} - - loupe@3.2.1: {} - - lru-cache@10.4.3: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lucide-react@0.544.0(react@19.2.5): - dependencies: - react: 19.2.5 - - lz-string@1.5.0: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - magicast@0.3.5: - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - source-map-js: 1.2.1 - - make-dir@4.0.0: - dependencies: - semver: 7.7.4 - - math-intrinsics@1.1.0: {} - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - min-indent@1.0.1: {} - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.5 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.14 - - minimatch@9.0.9: - dependencies: - brace-expansion: 2.1.0 - - minipass@7.1.3: {} - - ms@2.1.3: {} - - msw@2.13.4(@types/node@24.12.2)(typescript@5.6.3): - dependencies: - '@inquirer/confirm': 6.0.12(@types/node@24.12.2) - '@mswjs/interceptors': 0.41.4 - '@open-draft/deferred-promise': 3.0.0 - '@types/statuses': 2.0.6 - cookie: 1.1.1 - graphql: 16.13.2 - headers-polyfill: 5.0.1 - is-node-process: 1.2.0 - outvariant: 1.4.3 - path-to-regexp: 6.3.0 - picocolors: 1.1.1 - rettime: 0.11.8 - statuses: 2.0.2 - strict-event-emitter: 0.5.1 - tough-cookie: 6.0.1 - type-fest: 5.6.0 - until-async: 3.0.2 - yargs: 17.7.2 - optionalDependencies: - typescript: 5.6.3 - transitivePeerDependencies: - - '@types/node' - - mute-stream@3.0.0: {} - - nanoid@3.3.11: {} - - natural-compare@1.4.0: {} - - node-releases@2.0.37: {} - - nwsapi@2.2.23: {} - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - outvariant@1.4.3: {} - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - package-json-from-dist@1.0.1: {} - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - parse5@7.3.0: - dependencies: - entities: 6.0.1 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.3 - - path-to-regexp@6.3.0: {} - - pathe@2.0.3: {} - - pathval@2.0.1: {} - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - playwright-core@1.59.1: {} - - playwright@1.59.1: - dependencies: - playwright-core: 1.59.1 - optionalDependencies: - fsevents: 2.3.2 - - postcss@8.5.10: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - pretty-format@27.5.1: - dependencies: - ansi-regex: 5.0.1 - ansi-styles: 5.2.0 - react-is: 17.0.2 - - punycode@2.3.1: {} - - react-dom@19.2.5(react@19.2.5): - dependencies: - react: 19.2.5 - scheduler: 0.27.0 - - react-is@17.0.2: {} - - react-refresh@0.18.0: {} - - react-router@7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - cookie: 1.1.1 - react: 19.2.5 - set-cookie-parser: 2.7.2 - optionalDependencies: - react-dom: 19.2.5(react@19.2.5) - - react@19.2.5: {} - - redent@3.0.0: - dependencies: - indent-string: 4.0.0 - strip-indent: 3.0.0 - - require-directory@2.1.1: {} - - resolve-from@4.0.0: {} - - rettime@0.11.8: {} - - rollup@4.60.2: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.2 - '@rollup/rollup-android-arm64': 4.60.2 - '@rollup/rollup-darwin-arm64': 4.60.2 - '@rollup/rollup-darwin-x64': 4.60.2 - '@rollup/rollup-freebsd-arm64': 4.60.2 - '@rollup/rollup-freebsd-x64': 4.60.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.2 - '@rollup/rollup-linux-arm-musleabihf': 4.60.2 - '@rollup/rollup-linux-arm64-gnu': 4.60.2 - '@rollup/rollup-linux-arm64-musl': 4.60.2 - '@rollup/rollup-linux-loong64-gnu': 4.60.2 - '@rollup/rollup-linux-loong64-musl': 4.60.2 - '@rollup/rollup-linux-ppc64-gnu': 4.60.2 - '@rollup/rollup-linux-ppc64-musl': 4.60.2 - '@rollup/rollup-linux-riscv64-gnu': 4.60.2 - '@rollup/rollup-linux-riscv64-musl': 4.60.2 - '@rollup/rollup-linux-s390x-gnu': 4.60.2 - '@rollup/rollup-linux-x64-gnu': 4.60.2 - '@rollup/rollup-linux-x64-musl': 4.60.2 - '@rollup/rollup-openbsd-x64': 4.60.2 - '@rollup/rollup-openharmony-arm64': 4.60.2 - '@rollup/rollup-win32-arm64-msvc': 4.60.2 - '@rollup/rollup-win32-ia32-msvc': 4.60.2 - '@rollup/rollup-win32-x64-gnu': 4.60.2 - '@rollup/rollup-win32-x64-msvc': 4.60.2 - fsevents: 2.3.3 - - rrweb-cssom@0.7.1: {} - - rrweb-cssom@0.8.0: {} - - safer-buffer@2.1.2: {} - - saxes@6.0.0: - dependencies: - xmlchars: 2.2.0 - - scheduler@0.27.0: {} - - semver@6.3.1: {} - - semver@7.7.4: {} - - set-cookie-parser@2.7.2: {} - - set-cookie-parser@3.1.0: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - siginfo@2.0.0: {} - - signal-exit@4.1.0: {} - - source-map-js@1.2.1: {} - - stackback@0.0.2: {} - - statuses@2.0.2: {} - - std-env@3.10.0: {} - - strict-event-emitter@0.5.1: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - - strip-indent@3.0.0: - dependencies: - min-indent: 1.0.1 - - strip-json-comments@3.1.1: {} - - strip-literal@3.1.0: - dependencies: - js-tokens: 9.0.1 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - symbol-tree@3.2.4: {} - - tagged-tag@1.0.0: {} - - tailwind-merge@3.5.0: {} - - tailwindcss@4.2.2: {} - - tapable@2.3.2: {} - - test-exclude@7.0.2: - dependencies: - '@istanbuljs/schema': 0.1.6 - glob: 10.5.0 - minimatch: 10.2.5 - - tinybench@2.9.0: {} - - tinyexec@0.3.2: {} - - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tinypool@1.1.1: {} - - tinyrainbow@2.0.0: {} - - tinyspy@4.0.4: {} - - tldts-core@6.1.86: {} - - tldts-core@7.0.28: {} - - tldts@6.1.86: - dependencies: - tldts-core: 6.1.86 - - tldts@7.0.28: - dependencies: - tldts-core: 7.0.28 - - tough-cookie@5.1.2: - dependencies: - tldts: 6.1.86 - - tough-cookie@6.0.1: - dependencies: - tldts: 7.0.28 - - tr46@5.1.1: - dependencies: - punycode: 2.3.1 - - ts-api-utils@2.5.0(typescript@5.6.3): - dependencies: - typescript: 5.6.3 - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - type-fest@5.6.0: - dependencies: - tagged-tag: 1.0.0 - - typescript-eslint@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3) - '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3) - '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.6.3) - '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3) - eslint: 9.39.4(jiti@2.6.1) - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color - - typescript@5.6.3: {} - - undici-types@7.16.0: {} - - until-async@3.0.2: {} - - update-browserslist-db@1.2.3(browserslist@4.28.2): - dependencies: - browserslist: 4.28.2 - escalade: 3.2.0 - picocolors: 1.1.1 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - vite-node@3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0): - dependencies: - esbuild: 0.27.7 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.10 - rollup: 4.60.2 - tinyglobby: 0.2.16 - optionalDependencies: - '@types/node': 24.12.2 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.32.0 - - vitest@3.2.4(@types/node@24.12.2)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.13.4(@types/node@24.12.2)(typescript@5.6.3)): - dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.13.4(@types/node@24.12.2)(typescript@5.6.3))(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.3.0 - magic-string: 0.30.21 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.16 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) - vite-node: 3.2.4(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.12.2 - jsdom: 25.0.1 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - w3c-xmlserializer@5.0.0: - dependencies: - xml-name-validator: 5.0.0 - - webidl-conversions@7.0.0: {} - - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - - whatwg-mimetype@4.0.0: {} - - whatwg-url@14.2.0: - dependencies: - tr46: 5.1.1 - webidl-conversions: 7.0.0 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - word-wrap@1.2.5: {} - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 - - ws@8.20.0: {} - - xml-name-validator@5.0.0: {} - - xmlchars@2.2.0: {} - - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yocto-queue@0.1.0: {} diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg deleted file mode 100644 index 453649f..0000000 --- a/ui/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ -c diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx deleted file mode 100644 index 6645cae..0000000 --- a/ui/src/App.test.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import type { ReactNode } from "react"; -import { TOKEN_KEY } from "@/lib/api"; - -/* - * App.test.tsx — integration test of the top-level shell. - * - * App.tsx wires the global providers (Theme -> design-system bridge -> - * QueryClient -> Auth -> Sse -> AuthGate) and a `createBrowserRouter`. - * For tests we: - * - * 1. drive route resolution through `window.history.pushState` rather - * than a MemoryRouter — RouterProvider takes a router instance, so - * we have to use the real (browser) history API the createBrowserRouter - * reads from. jsdom implements it. - * 2. mock the heavy children (Dashboard, DoctorPanel, FeedFullscreen, - * SessionDetail) — those have their own dedicated suites and pull in - * SSE/network on mount, which would explode in this shell-level test. - * 3. mock SseProvider so we don't actually open EventSource / use - * fetch-event-source. We expose a fake `useSseStatus` so - * ConnectionBanner can read the connected flag the same way it - * does in production. - * 4. stub `globalThis.fetch` for /api/auth/status so AuthGate resolves - * either to (unauthenticated) or to - * (authenticated). All other endpoints return 404 — the route - * stubs never call them. - */ - -vi.mock("@/components/SseProvider", () => { - let connected = true; - return { - SseProvider: ({ children }: { children: ReactNode }) => ( -
{children}
- ), - useSseStatus: () => ({ connected }), - /** Test-only escape hatch — flip the SSE banner state. */ - __setSseConnected: (v: boolean) => { - connected = v; - }, - }; -}); - -vi.mock("@/routes/Dashboard", () => ({ - Dashboard: () =>
dashboard
, -})); - -vi.mock("@/routes/DoctorPanel", () => ({ - DoctorPanel: () =>
doctor
, -})); - -vi.mock("@/routes/FeedFullscreen", () => ({ - FeedFullscreen: () =>
feed-fs
, -})); - -// LoginForm renders a real
— much lighter than a stub here, but -// we lean on a stub so we don't need to wire useLogin's mutation path. -vi.mock("@/routes/LoginForm", () => ({ - LoginForm: ({ onSwitchToSignup }: { onSwitchToSignup?: () => void }) => ( -
- login - {onSwitchToSignup && ( - - )} -
- ), -})); - -vi.mock("@/routes/SignupForm", () => ({ - SignupForm: ({ onSwitchToLogin }: { onSwitchToLogin?: () => void }) => ( -
- signup - {onSwitchToLogin && ( - - )} -
- ), -})); - -// design-system pulls in real CSS in App.tsx — the bridge component -// only needs ToastRegion + ThemeProvider. Stub them so jsdom doesn't -// have to parse design-system styles. -vi.mock("@ossrandom/design-system", () => ({ - ThemeProvider: ({ - mode, - children, - }: { - mode: "light" | "dark"; - children: ReactNode; - }) => ( -
- {children} -
- ), - ToastRegion: () =>
, -})); - -// design-system styles import — vite/vitest treats `.css` as opaque -// modules in node, but the bare-specifier resolution needs a stub so -// the dynamic import doesn't try to walk into the package. -vi.mock("@ossrandom/design-system/styles.css", () => ({})); - -// Lazy import so the vi.mock factories above are hoisted before the -// module under test pulls them in. -async function loadApp() { - const mod = await import("@/App"); - return mod.App; -} - -interface AuthStatus { - registered: boolean; - authenticated: boolean; -} - -interface FetchState { - authStatus?: AuthStatus | "error"; -} - -function buildFetchStub(state: FetchState = {}): typeof globalThis.fetch { - return vi.fn(async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : input.toString(); - if (url.includes("/api/auth/status")) { - if (state.authStatus === "error") { - return new Response("boom", { status: 500 }); - } - const body: AuthStatus = state.authStatus ?? { - registered: true, - authenticated: true, - }; - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - return new Response("not found", { status: 404 }); - }) as unknown as typeof globalThis.fetch; -} - -/** - * createBrowserRouter reads from window.history. Reset the URL to a - * fresh path before each test so route assertions are deterministic. - */ -function navigateTo(path: string) { - window.history.pushState({}, "", path); -} - -describe("App", () => { - let originalFetch: typeof globalThis.fetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - localStorage.setItem(TOKEN_KEY, "test-token"); - - // jsdom doesn't implement matchMedia. ThemeProvider uses it to - // resolve "system" preference into light/dark. - if (!window.matchMedia) { - Object.defineProperty(window, "matchMedia", { - writable: true, - configurable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - }), - }); - } - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - localStorage.clear(); - // Walk back to "/" so the next test's createBrowserRouter - // initialises at a known path. - window.history.pushState({}, "", "/"); - vi.restoreAllMocks(); - // vi.resetModules so each test re-imports App with a fresh - // createBrowserRouter (its `router` constant is module-level). - vi.resetModules(); - }); - - it("renders Dashboard at /", async () => { - globalThis.fetch = buildFetchStub(); - navigateTo("/"); - const App = await loadApp(); - render(); - expect(await screen.findByTestId("dashboard-stub")).toBeInTheDocument(); - // Provider tree wired around it. - expect(screen.getByTestId("ds-theme")).toHaveAttribute("data-mode", "dark"); - expect(screen.getByTestId("toast-region")).toBeInTheDocument(); - expect(screen.getByTestId("sse-provider-stub")).toBeInTheDocument(); - }); - - it("renders Dashboard at /s/:name (session route)", async () => { - globalThis.fetch = buildFetchStub(); - navigateTo("/s/alpha"); - const App = await loadApp(); - render(); - expect(await screen.findByTestId("dashboard-stub")).toBeInTheDocument(); - }); - - it("renders Dashboard at the /s/:name/* tab variants", async () => { - globalThis.fetch = buildFetchStub(); - for (const path of [ - "/s/alpha/feed", - "/s/alpha/checkpoints", - "/s/alpha/pane", - "/s/alpha/subagents", - "/s/alpha/teams", - "/s/alpha/meta", - ]) { - navigateTo(path); - const App = await loadApp(); - const { unmount } = render(); - expect(await screen.findByTestId("dashboard-stub")).toBeInTheDocument(); - unmount(); - vi.resetModules(); - } - }); - - it("renders FeedFullscreen at /feed", async () => { - globalThis.fetch = buildFetchStub(); - navigateTo("/feed"); - const App = await loadApp(); - render(); - expect( - await screen.findByTestId("feed-fullscreen-stub"), - ).toBeInTheDocument(); - }); - - it("renders DoctorPanel at /doctor", async () => { - globalThis.fetch = buildFetchStub(); - navigateTo("/doctor"); - const App = await loadApp(); - render(); - expect(await screen.findByTestId("doctor-stub")).toBeInTheDocument(); - }); - - it("matches the catchall route for unknown URLs without throwing", async () => { - globalThis.fetch = buildFetchStub(); - navigateTo("/this/does/not/exist"); - const App = await loadApp(); - // The router's `*` route uses . We don't - // assert on the URL change because react-router v7 + jsdom don't - // settle the redirect reliably inside a test render. We DO assert - // the App didn't crash and the providers still mounted — i.e., the - // catch-all branch in the routes array is exercised without an - // unhandled error from a missing match. - expect(() => render()).not.toThrow(); - await waitFor(() => { - expect(screen.getByTestId("sse-provider-stub")).toBeInTheDocument(); - }); - }); - - it("renders LoginForm when the daemon reports registered & unauthenticated", async () => { - globalThis.fetch = buildFetchStub({ - authStatus: { registered: true, authenticated: false }, - }); - navigateTo("/"); - const App = await loadApp(); - render(); - expect(await screen.findByTestId("login-stub")).toBeInTheDocument(); - // Dashboard is gated out. - expect(screen.queryByTestId("dashboard-stub")).not.toBeInTheDocument(); - }); - - it("renders SignupForm when the daemon reports no registered user", async () => { - globalThis.fetch = buildFetchStub({ - authStatus: { registered: false, authenticated: false }, - }); - navigateTo("/"); - const App = await loadApp(); - render(); - expect(await screen.findByTestId("signup-stub")).toBeInTheDocument(); - expect(screen.queryByTestId("dashboard-stub")).not.toBeInTheDocument(); - }); - - it("AuthGate user can switch from signup to login via the override", async () => { - globalThis.fetch = buildFetchStub({ - authStatus: { registered: false, authenticated: false }, - }); - navigateTo("/"); - const App = await loadApp(); - render(); - - expect(await screen.findByTestId("signup-stub")).toBeInTheDocument(); - const user = userEvent.setup(); - await user.click( - screen.getByRole("button", { name: /switch-to-login/i }), - ); - expect(await screen.findByTestId("login-stub")).toBeInTheDocument(); - }); - - it("AuthGate shows a daemon-error banner when /api/auth/status fails", async () => { - globalThis.fetch = buildFetchStub({ authStatus: "error" }); - navigateTo("/"); - const App = await loadApp(); - render(); - expect( - await screen.findByText(/could not reach the daemon/i), - ).toBeInTheDocument(); - expect(screen.queryByTestId("dashboard-stub")).not.toBeInTheDocument(); - }); - - it("ConnectionBanner is hidden when SSE is connected", async () => { - globalThis.fetch = buildFetchStub(); - navigateTo("/"); - const App = await loadApp(); - render(); - await screen.findByTestId("dashboard-stub"); - expect( - screen.queryByText(/connection lost/i), - ).not.toBeInTheDocument(); - }); - - it("ConnectionBanner surfaces when SSE drops", async () => { - const sse = await import("@/components/SseProvider"); - (sse as unknown as { __setSseConnected: (v: boolean) => void }).__setSseConnected( - false, - ); - globalThis.fetch = buildFetchStub(); - navigateTo("/"); - const App = await loadApp(); - render(); - await screen.findByTestId("dashboard-stub"); - expect(screen.getByText(/connection lost/i)).toBeInTheDocument(); - // Reset so other tests see a connected SSE. - (sse as unknown as { __setSseConnected: (v: boolean) => void }).__setSseConnected( - true, - ); - }); -}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx deleted file mode 100644 index 084e933..0000000 --- a/ui/src/App.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Navigate, RouterProvider, createBrowserRouter } from "react-router"; -import { useMemo, type ReactNode } from "react"; -import { - ThemeProvider as DSThemeProvider, - ToastRegion, -} from "@ossrandom/design-system"; -import "@ossrandom/design-system/styles.css"; -import { ThemeProvider, useTheme } from "@/hooks/useTheme"; -import { AuthProvider } from "@/components/AuthProvider"; -import { SseProvider } from "@/components/SseProvider"; -import { Dashboard } from "@/routes/Dashboard"; -import { DoctorPanel } from "@/routes/DoctorPanel"; -import { FeedFullscreen } from "@/routes/FeedFullscreen"; -import { ConnectionBanner } from "@/components/ConnectionBanner"; -import { AuthGate } from "@/routes/AuthGate"; - -// Bridges ctm's useTheme (system/light/dark cycle, localStorage-backed) -// to the design-system's ThemeProvider, which only takes a resolved -// "light"|"dark" mode. Keeps a single source of truth — ctm owns the -// preference, the design-system owns the data-theme attribute writes -// and component-level token resolution. -function DesignSystemBridge({ children }: { children: ReactNode }) { - const { resolved } = useTheme(); - return ( - - {children} - - - ); -} - -/* - * Routing intent: in two-pane mode (>=768px) the Dashboard owns both - * panels, so /, /s/:name, /s/:name/checkpoints, /s/:name/meta all - * resolve to . The right pane reads useParams() to swap - * between the empty placeholder and . The list never - * unmounts. On mobile (<768px), Dashboard hides the right pane entirely - * and the list takes over — when a session is selected, Dashboard hides - * the list and shows the detail (responsive layout, single Dashboard - * route stays mounted). - * - * This keeps URL semantics (deep-link, browser back) consistent across - * widths, and matches spec §3 (Desktop scaling: Two-pane). - */ -const router = createBrowserRouter([ - { path: "/", element: }, - { path: "/s/:name", element: }, - { path: "/s/:name/feed", element: }, - { path: "/s/:name/checkpoints", element: }, - { path: "/s/:name/pane", element: }, - { path: "/s/:name/subagents", element: }, - { path: "/s/:name/teams", element: }, - { path: "/s/:name/meta", element: }, - { path: "/feed", element: }, - { path: "/doctor", element: }, - { path: "*", element: }, -]); - -export function App() { - const queryClient = useMemo( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - refetchOnWindowFocus: false, - retry: (failureCount, err) => { - // Bail on auth errors — AuthProvider handles redirect. - if (err instanceof Error && err.name === "UnauthorizedError") { - return false; - } - return failureCount < 2; - }, - }, - }, - }), - [], - ); - - return ( - - - - - - - - - - - - - - - ); -} diff --git a/ui/src/components/AgentTeamsPanel.test.tsx b/ui/src/components/AgentTeamsPanel.test.tsx deleted file mode 100644 index 4ecaa14..0000000 --- a/ui/src/components/AgentTeamsPanel.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { AgentTeamsPanel } from "@/components/AgentTeamsPanel"; -import type { Team } from "@/hooks/useTeams"; - -function renderWithQuery(ui: React.ReactNode) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render({ui}); -} - -function mockTeams(teams: Team[]) { - return vi.fn(async () => - new Response(JSON.stringify({ teams }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); -} - -const twoTeams: Team[] = [ - { - id: "team-fresh", - name: "Explore · 3 agents", - dispatched_at: "2026-04-21T12:05:00Z", - status: "running", - members: [ - { subagent_id: "agent-a1", description: "scan repo", status: "running" }, - { subagent_id: "agent-a2", description: "read spec", status: "completed" }, - { subagent_id: "agent-a3", description: "test", status: "completed" }, - ], - }, - { - id: "team-old", - name: "Task · 2 agents", - dispatched_at: "2026-04-21T11:50:00Z", - status: "completed", - summary: "Plan approved; 3 follow-ups logged.", - members: [ - { subagent_id: "agent-b1", description: "planner", status: "completed" }, - { subagent_id: "agent-b2", description: "writer", status: "completed" }, - ], - }, -]; - -describe("AgentTeamsPanel", () => { - let originalFetch: typeof globalThis.fetch; - beforeEach(() => { - originalFetch = globalThis.fetch; - }); - afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - it("renders a card per team with a status chip", async () => { - globalThis.fetch = mockTeams(twoTeams); - renderWithQuery(); - - await waitFor(() => - expect(screen.getByTestId("team-card-team-fresh")).toBeInTheDocument(), - ); - expect(screen.getByTestId("team-card-team-old")).toBeInTheDocument(); - - // Status chips carry data-testids for running/completed. - expect(screen.getByTestId("team-status-running")).toBeInTheDocument(); - expect(screen.getByTestId("team-status-completed")).toBeInTheDocument(); - }); - - it("expanding a team reveals its members", async () => { - globalThis.fetch = mockTeams(twoTeams); - const user = userEvent.setup(); - renderWithQuery(); - - const freshCard = await screen.findByTestId("team-card-team-fresh"); - // Before expand: member rows not yet in DOM. - expect(screen.queryByTestId("team-member-agent-a1")).toBeNull(); - await user.click(freshCard.querySelector("button") as HTMLElement); - await waitFor(() => - expect(screen.getByTestId("team-member-agent-a1")).toBeInTheDocument(), - ); - expect(screen.getByTestId("team-member-agent-a2")).toBeInTheDocument(); - expect(screen.getByTestId("team-member-agent-a3")).toBeInTheDocument(); - }); - - it("teams with no summary hide the blockquote", async () => { - globalThis.fetch = mockTeams(twoTeams); - const user = userEvent.setup(); - renderWithQuery(); - - const fresh = await screen.findByTestId("team-card-team-fresh"); - await user.click(fresh.querySelector("button") as HTMLElement); - await waitFor(() => - expect(screen.getByTestId("team-member-agent-a1")).toBeInTheDocument(), - ); - // No blockquote for team without summary. - const detail = screen.getByTestId("team-detail-team-fresh"); - expect(detail.querySelector("blockquote")).toBeNull(); - - // The second team (team-old) has a summary — expanding shows it. - const old = screen.getByTestId("team-card-team-old"); - await user.click(old.querySelector("button") as HTMLElement); - await waitFor(() => - expect( - screen.getByTestId("team-detail-team-old").querySelector("blockquote"), - ).not.toBeNull(), - ); - expect( - screen.getByTestId("team-detail-team-old").querySelector("blockquote")! - .textContent, - ).toMatch(/follow-ups logged/); - }); - - it("renders empty state when no teams exist", async () => { - globalThis.fetch = mockTeams([]); - renderWithQuery(); - await waitFor(() => - expect( - screen.getByText(/no teams for this session/i), - ).toBeInTheDocument(), - ); - }); -}); diff --git a/ui/src/components/AgentTeamsPanel.tsx b/ui/src/components/AgentTeamsPanel.tsx deleted file mode 100644 index a0272b8..0000000 --- a/ui/src/components/AgentTeamsPanel.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { useState } from "react"; -import { ChevronRight } from "lucide-react"; -import { Skeleton } from "@ossrandom/design-system"; -import { useTeams, type Team, type TeamMember } from "@/hooks/useTeams"; -import { relativeTime } from "@/lib/format"; -import { cn } from "@/lib/utils"; - -/** - * V16 — Agent teams panel. - * - * Renders each team as a collapsible card. Header carries the team - * name + member count + status chip; expanding reveals the member - * list and any lead-agent summary (once the backend starts surfacing - * one — it's null today and the blockquote collapses cleanly). - * - * Refetches on `team_spawn` / `team_settled` SSE events via the - * shared queryKey invalidation in SseProvider. - */ -export function AgentTeamsPanel({ sessionName }: { sessionName: string }) { - const { data, isLoading, isError, error } = useTeams(sessionName); - const teams = data?.teams ?? []; - - return ( -
- {isLoading && ( -
- - -
- )} - - {isError && ( -

- Could not load teams - {error instanceof Error ? `: ${error.message}` : ""} -

- )} - - {!isLoading && !isError && teams.length === 0 && ( -

- No teams for this session. -

- )} - -
    - {teams.map((team) => ( -
  • - -
  • - ))} -
-
- ); -} - -function TeamCard({ team }: { team: Team }) { - const [open, setOpen] = useState(false); - return ( -
- - {open && ( -
- {team.summary && ( -
- {team.summary} -
- )} -
    - {team.members.map((m) => ( - - ))} -
-
- )} -
- ); -} - -function TeamMemberRow({ member }: { member: TeamMember }) { - return ( -
  • - - - {member.subagent_id.slice(0, 10)} - - - {member.description || } - -
  • - ); -} - -function StatusChip({ status }: { status: Team["status"] }) { - const label = - status === "running" - ? "Running" - : status === "failed" - ? "Failed" - : "Completed"; - return ( - - - {label} - - ); -} - -function StatusDot({ status }: { status: Team["status"] }) { - return ( - - ); -} diff --git a/ui/src/components/AttentionLabel.tsx b/ui/src/components/AttentionLabel.tsx deleted file mode 100644 index 7b6e91e..0000000 --- a/ui/src/components/AttentionLabel.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { Attention } from "@/hooks/useSessions"; -import { cn } from "@/lib/utils"; - -const HUMAN: Record = { - error_burst: "Error burst", - stuck: "Idle", - stalled: "Stalled", - quota_low: "Quota low", - quota_high: "Quota high", - context_high: "Context high", - context_imminent: "Context full", - permission_request: "Permission", - long_session: "Long session", - tmux_dead: "Tmux dead", - yolo_unchecked: "Unchecked", - last_error_call: "Last call errored", -}; - -function humanize(state: string): string { - return HUMAN[state] ?? state.replaceAll("_", " "); -} - -interface AttentionLabelProps { - attention: Attention; - className?: string; -} - -/** - * Small uppercase ember-red label rendered below the metadata row when - * attention.state is non-clear. Multiple labels stack via parent layout. - * Spec §3 (Attention treatment, locked: B Halftone). - */ -export function AttentionLabel({ attention, className }: AttentionLabelProps) { - if (attention.state === "clear") return null; - return ( -
    - {humanize(attention.state)} - {attention.details && ( - - {attention.details} - - )} -
    - ); -} diff --git a/ui/src/components/AuthProvider.tsx b/ui/src/components/AuthProvider.tsx deleted file mode 100644 index dd93aca..0000000 --- a/ui/src/components/AuthProvider.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, - type ReactNode, -} from "react"; -import { QueryCache, useQueryClient } from "@tanstack/react-query"; -import { - TOKEN_KEY, - UnauthorizedError, - clearToken, - getToken, - setToken, -} from "@/lib/api"; - -interface AuthCtx { - token: string | null; - setTokenAndPersist: (t: string) => void; - signOut: () => void; -} - -const Ctx = createContext(null); - -/** - * Holds the bearer token. If absent on first paint, short-circuits the rest of - * the app — AuthGate renders . Mid-session 401s (from REST or SSE) - * clear the token so AuthGate re-renders into the login screen. - */ -export function AuthProvider({ children }: { children: ReactNode }) { - const queryClient = useQueryClient(); - const [token, setTokenState] = useState(() => getToken()); - - const setTokenAndPersist = useCallback((t: string) => { - setToken(t); - setTokenState(t); - }, []); - - const signOut = useCallback(() => { - clearToken(); - setTokenState(null); - }, []); - - // Listen to other tabs clearing the token. - useEffect(() => { - const onStorage = (e: StorageEvent) => { - if (e.key === TOKEN_KEY) setTokenState(e.newValue); - }; - globalThis.addEventListener("storage", onStorage); - return () => globalThis.removeEventListener("storage", onStorage); - }, []); - - // Subscribe to TanStack Query failures — 401s from any query trigger sign-out. - useEffect(() => { - const cache: QueryCache = queryClient.getQueryCache(); - const unsub = cache.subscribe((event) => { - if (event.type === "updated" && event.action.type === "error") { - const err = event.action.error; - if (err instanceof UnauthorizedError) signOut(); - } - }); - return unsub; - }, [queryClient, signOut]); - - const value = useMemo( - () => ({ token, setTokenAndPersist, signOut }), - [token, setTokenAndPersist, signOut], - ); - - return {children}; -} - -export function useAuth(): AuthCtx { - const v = useContext(Ctx); - if (!v) throw new Error("useAuth must be used inside "); - return v; -} diff --git a/ui/src/components/BashOnlyRow.test.tsx b/ui/src/components/BashOnlyRow.test.tsx deleted file mode 100644 index 255f145..0000000 --- a/ui/src/components/BashOnlyRow.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { BashOnlyRow } from "./BashOnlyRow"; -import type { ToolCallRow } from "@/hooks/useFeed"; - -function makeRow(overrides: Partial = {}): ToolCallRow { - return { - session: "alpha", - tool: "Bash", - input: "ls -la /tmp", - summary: "total 0\ndrwxrwxrwt 1 root root 4096 Apr 21 16:00 .", - is_error: false, - ts: "2026-04-21T16:28:00Z", - ...overrides, - }; -} - -describe("BashOnlyRow", () => { - it("renders the command text and an 'ok' chip on success", () => { - render(); - expect(screen.getByText(/ls -la \/tmp/)).toBeInTheDocument(); - const chip = screen.getByTestId("bash-chip"); - expect(chip).toHaveAttribute("data-status", "ok"); - expect(chip).toHaveTextContent(/^ok$/i); - }); - - it("treats exit_code 0 as success even when field is present", () => { - render(); - const chip = screen.getByTestId("bash-chip"); - expect(chip).toHaveAttribute("data-status", "ok"); - expect(chip).toHaveTextContent(/^ok$/i); - }); - - it("renders an 'err ' chip when exit_code is non-zero", () => { - render( - , - ); - const chip = screen.getByTestId("bash-chip"); - expect(chip).toHaveAttribute("data-status", "err"); - expect(chip).toHaveTextContent(/err\s*127/i); - }); - - it("renders a bare 'err' chip when is_error is true without exit_code", () => { - render(); - const chip = screen.getByTestId("bash-chip"); - expect(chip).toHaveAttribute("data-status", "err"); - expect(chip).toHaveTextContent(/^err$/i); - }); - - it("expands on click to show the full command and output", async () => { - const user = userEvent.setup(); - const long = - "echo " + "abcdefghij".repeat(20); // > 120 chars so it truncates - render( - , - ); - - // Collapsed: no expanded blocks. - expect(screen.queryByTestId("bash-expanded-cmd")).toBeNull(); - expect(screen.queryByTestId("bash-expanded-output")).toBeNull(); - - await user.click(screen.getByRole("button")); - - expect(screen.getByTestId("bash-expanded-cmd")).toHaveTextContent(long); - const output = screen.getByTestId("bash-expanded-output"); - expect(output).toHaveTextContent("line1"); - expect(output).toHaveTextContent("line2"); - expect(output).toHaveTextContent("line3"); - }); -}); diff --git a/ui/src/components/BashOnlyRow.tsx b/ui/src/components/BashOnlyRow.tsx deleted file mode 100644 index 2fbe8c5..0000000 --- a/ui/src/components/BashOnlyRow.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useState } from "react"; -import type { ToolCallRow as ToolCallRowType } from "@/hooks/useFeed"; -import { stripAnsi } from "@/lib/format"; -import { cn } from "@/lib/utils"; - -interface BashOnlyRowProps { - row: ToolCallRowType; -} - -const CMD_MAX = 120; -const OUTPUT_LINES = 8; - -function truncate(s: string, max: number): string { - if (s.length <= max) return s; - return s.slice(0, max - 1) + "…"; -} - -/** - * Compact one-liner for the Feed-tab "Bash" filter (V10). - * - * - Mono command on the left (single-line, truncated ~120 chars). - * - Right-aligned status chip: `ok` (sage) on success, `err ` (ember) - * on failure. Success = exit_code === 0 OR (exit_code undefined AND - * !is_error). Anything else is an error. - * - Click toggles a small expansion with the full command + first - * OUTPUT_LINES lines of stripped-ANSI output. - */ -export function BashOnlyRow({ row }: BashOnlyRowProps) { - const [open, setOpen] = useState(false); - - const cmdFull = stripAnsi(row.input ?? "").replaceAll(/\s+/g, " ").trim(); - const cmdLine = truncate(cmdFull, CMD_MAX); - - const hasExit = typeof row.exit_code === "number"; - const isError = row.is_error || (hasExit && row.exit_code !== 0); - const chipLabel = isError - ? `err${hasExit ? ` ${row.exit_code}` : ""}` - : "ok"; - - const outputLines = row.summary - ? stripAnsi(row.summary).split(/\r?\n/).slice(0, OUTPUT_LINES) - : []; - - return ( -
    - - {open && ( -
    -
    -            {cmdFull}
    -          
    - {outputLines.length > 0 && ( -
    -              {outputLines.join("\n")}
    -            
    - )} -
    - )} -
    - ); -} diff --git a/ui/src/components/CheckpointRow.tsx b/ui/src/components/CheckpointRow.tsx deleted file mode 100644 index 9d0eb55..0000000 --- a/ui/src/components/CheckpointRow.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type { Checkpoint } from "@/hooks/useCheckpoints"; -import { relativeTime } from "@/lib/format"; -import { cn } from "@/lib/utils"; - -interface CheckpointRowProps { - checkpoint: Checkpoint; - onSelect: (cp: Checkpoint) => void; - /** V18: opens the DiffSheet for this checkpoint. */ - onViewDiff?: (cp: Checkpoint) => void; - selected?: boolean; -} - -function shortSha(cp: Checkpoint): string { - return cp.short_sha && cp.short_sha.length > 0 ? cp.short_sha : cp.sha.slice(0, 7); -} - -/** - * V17 row — clickable checkpoint surface; opens the RevertSheet on click. - * Layout: short SHA | subject | relative time | [View diff]. - * - * V18 adds a sibling `View diff` button whose onClick stops propagation - * so the row's primary "open revert sheet" affordance is preserved. - */ -export function CheckpointRow({ - checkpoint, - onSelect, - onViewDiff, - selected, -}: CheckpointRowProps) { - return ( -
    - - {onViewDiff && ( - - )} -
    - ); -} diff --git a/ui/src/components/ConnectionBanner.tsx b/ui/src/components/ConnectionBanner.tsx deleted file mode 100644 index cb1d178..0000000 --- a/ui/src/components/ConnectionBanner.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useSseStatus } from "@/components/SseProvider"; - -/** - * 1-line ember-red strip across viewport top, persistent until SSE - * reconnects. Spec §5 attention surfacing. - */ -export function ConnectionBanner() { - const { connected } = useSseStatus(); - if (connected) return null; - return ( -
    - Connection lost — retrying -
    - ); -} diff --git a/ui/src/components/CostChart.test.tsx b/ui/src/components/CostChart.test.tsx deleted file mode 100644 index 14e5f2d..0000000 --- a/ui/src/components/CostChart.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { CostChart } from "@/components/CostChart"; -import type { CostResponse } from "@/hooks/useCost"; - -function renderWithQuery(ui: React.ReactNode) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render({ui}); -} - -function makeResponse(window: "hour" | "day" | "week" = "day"): CostResponse { - const now = Date.now(); - return { - window, - points: [ - { - ts: new Date(now - 10 * 60_000).toISOString(), - session: "alpha", - input_tokens: 1000, - output_tokens: 500, - cache_tokens: 100, - cost_usd_micros: 12_000, - }, - { - ts: new Date(now - 5 * 60_000).toISOString(), - session: "alpha", - input_tokens: 2000, - output_tokens: 1000, - cache_tokens: 200, - cost_usd_micros: 24_000, - }, - ], - totals: { - input: 2000, - output: 1000, - cache: 200, - cost_usd_micros: 24_000, - }, - }; -} - -describe("CostChart", () => { - let originalFetch: typeof globalThis.fetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - it("renders a skeleton while loading", async () => { - // Pending forever — exercises the loading path. - globalThis.fetch = vi.fn( - () => new Promise(() => {}), - ) as unknown as typeof globalThis.fetch; - - const { container } = renderWithQuery(); - // design-system Skeleton renders with class `rcs-skeleton`. - await waitFor(() => { - expect(container.querySelector(".rcs-skeleton")).toBeTruthy(); - }); - }); - - it("renders a polyline when data is present", async () => { - globalThis.fetch = vi.fn( - async () => - new Response(JSON.stringify(makeResponse()), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ) as unknown as typeof globalThis.fetch; - - renderWithQuery(); - - const region = await screen.findByRole("region", { - name: /cumulative cost/i, - }); - // Polyline is present and has a non-empty points attribute. - const poly = await waitFor(() => { - const node = region.querySelector( - '[data-testid="cost-polyline"]', - ) as SVGPolylineElement | null; - expect(node).not.toBeNull(); - return node!; - }); - expect(poly.getAttribute("points")).toBeTruthy(); - expect(poly.getAttribute("points")!.length).toBeGreaterThan(0); - - // Totals row renders. - expect(within(region).getByText(/\$0\.0240/)).toBeInTheDocument(); - expect(within(region).getByText(/3k tokens/i)).toBeInTheDocument(); - // Cache ratio: cache=200, input=2000 → 200/2200 ≈ 9%. - expect(within(region).getByText(/cache hit 9%/i)).toBeInTheDocument(); - }); - - it("renders empty state when points=[]", async () => { - globalThis.fetch = vi.fn( - async () => - new Response( - JSON.stringify({ - window: "day", - points: [], - totals: { input: 0, output: 0, cache: 0, cost_usd_micros: 0 }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) as unknown as typeof globalThis.fetch; - - renderWithQuery(); - - await waitFor(() => { - expect( - screen.getByText(/no cost data yet/i), - ).toBeInTheDocument(); - }); - }); - - it("window pill click re-fires fetch with the new window", async () => { - const fetchMock = vi.fn(async (url: RequestInfo | URL) => { - const href = typeof url === "string" ? url : url.toString(); - const win = new URL(href, "http://x").searchParams.get("window") ?? "day"; - return new Response(JSON.stringify(makeResponse(win as "hour" | "day" | "week")), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }); - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; - - renderWithQuery(); - - // Wait for initial load (window=day). - await screen.findByRole("region", { name: /cumulative cost/i }); - await waitFor(() => { - expect(fetchMock).toHaveBeenCalled(); - }); - const initialCalls = fetchMock.mock.calls.length; - const user = userEvent.setup(); - await user.click(screen.getByRole("tab", { name: /hour/i })); - - await waitFor(() => { - expect(fetchMock.mock.calls.length).toBeGreaterThan(initialCalls); - }); - const lastCall = fetchMock.mock.calls.at(-1)!; - const lastUrl = - typeof lastCall[0] === "string" ? lastCall[0] : String(lastCall[0]); - expect(lastUrl).toContain("window=hour"); - }); -}); diff --git a/ui/src/components/CostChart.tsx b/ui/src/components/CostChart.tsx deleted file mode 100644 index f6216d2..0000000 --- a/ui/src/components/CostChart.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { useMemo, useState } from "react"; -import { Skeleton } from "@ossrandom/design-system"; -import { useCost, type CostPoint, type CostWindow } from "@/hooks/useCost"; -import { cn } from "@/lib/utils"; - -const WIDTH = 600; -const HEIGHT = 120; -const PADDING = { top: 8, right: 8, bottom: 8, left: 8 }; -const INNER_W = WIDTH - PADDING.left - PADDING.right; -const INNER_H = HEIGHT - PADDING.top - PADDING.bottom; - -interface Props { - sessionName?: string; - className?: string; -} - -const WINDOW_LABELS: Record = { - hour: "Hour", - day: "Day", - week: "Week", -}; - -const WINDOWS: CostWindow[] = ["hour", "day", "week"]; - -/** - * V13 cumulative cost chart. - * - * Renders a hand-rolled SVG polyline of cumulative USD across the - * selected window. No d3, no recharts — the line is a - * built from points.reduce(sum, cost_usd_micros) so the bundle stays - * lean (pattern matches ToolFrequencySparkline). - * - * When no session is given, the chart aggregates across every - * persisted session (daemon-wide cost-over-time). - */ -export function CostChart({ sessionName, className }: Props) { - const [window, setWindow] = useState("day"); - const { data, isLoading, isError, error } = useCost(sessionName, window); - - const { polyline, totalUSD, tokenSum, cacheRatio } = useMemo(() => { - const empty = { polyline: "", totalUSD: 0, tokenSum: 0, cacheRatio: 0 }; - if (!data || data.points.length === 0) return empty; - - const points = data.points; - // Cumulative running-sum over cost_usd_micros so the line always - // rises monotonically — reads as "cumulative spend" at a glance. - const series: { ts: number; cum: number }[] = []; - let cum = 0; - for (const p of points) { - cum += p.cost_usd_micros; - series.push({ ts: Date.parse(p.ts), cum }); - } - const firstTs = series[0].ts; - const lastTs = series.at(-1)!.ts; - const spanMs = Math.max(1, lastTs - firstTs); - const peak = series.at(-1)!.cum || 1; - - const pts = series.map((s) => { - const x = PADDING.left + ((s.ts - firstTs) / spanMs) * INNER_W; - const y = PADDING.top + INNER_H - (s.cum / peak) * INNER_H; - return `${x.toFixed(1)},${y.toFixed(1)}`; - }); - - const totals = data.totals; - const totalUSDval = totals.cost_usd_micros / 1_000_000; - const tokenSumVal = totals.input + totals.output; - // Cache hit ratio = cache / (input + cache). 0 when both are zero - // so the legend never renders NaN. - const denom = totals.input + totals.cache; - const cacheRatioVal = denom > 0 ? totals.cache / denom : 0; - - return { - polyline: pts.join(" "), - totalUSD: totalUSDval, - tokenSum: tokenSumVal, - cacheRatio: cacheRatioVal, - }; - }, [data]); - - const hasPoints = Boolean(data && data.points.length > 0); - const ariaLabel = `Cumulative cost over ${WINDOW_LABELS[window].toLowerCase()}, $${totalUSD.toFixed(4)}`; - - return ( -
    -
    -

    - Cumulative cost -

    -
    - {WINDOWS.map((w) => ( - - ))} -
    -
    - - {isLoading && ( -
    - -
    - )} - - {isError && ( -

    - Could not load cost data - {error instanceof Error ? `: ${error.message}` : ""} -

    - )} - - {!isLoading && !isError && !hasPoints && ( -

    - No cost data yet — run a session to start tracking. -

    - )} - - {!isLoading && !isError && hasPoints && ( - <> -
    - - - -
    -
    - - {formatUSD(totalUSD)} - - - {compactNumber(tokenSum)} tokens - - - cache hit {(cacheRatio * 100).toFixed(0)}% - -
    - - )} -
    - ); -} - -/** - * Format USD with 4 decimals — cumulative cost for a typical dev - * session is fractions of a cent in the first few minutes and reads - * as "$0.00" otherwise. 4 decimals keeps small amounts legible - * without drifting into micro-cent noise. - */ -function formatUSD(value: number): string { - if (!Number.isFinite(value)) return "$0.0000"; - return `$${value.toFixed(4)}`; -} - -/** Local copy to avoid pulling compactNumber cross-module for a chart. */ -function compactNumber(n: number): string { - const abs = Math.abs(n); - if (abs >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`; - if (abs >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`; - if (abs >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}k`; - return String(n); -} - -export type { CostPoint }; diff --git a/ui/src/components/DiffSheet.test.tsx b/ui/src/components/DiffSheet.test.tsx deleted file mode 100644 index 18a02a3..0000000 --- a/ui/src/components/DiffSheet.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { DiffSheet, classifyLine } from "@/components/DiffSheet"; -import type { Checkpoint } from "@/hooks/useCheckpoints"; - -const FULL_SHA = "abcdef1234567890abcdef1234567890abcdef12"; - -function makeCheckpoint(overrides: Partial = {}): Checkpoint { - return { - sha: FULL_SHA, - short_sha: FULL_SHA.slice(0, 7), - subject: "checkpoint: pre-yolo 2026-04-20T12:00:00", - author: "ctm", - ts: new Date(Date.now() - 10_000).toISOString(), - ...overrides, - }; -} - -function renderWithQuery(ui: React.ReactNode) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render( - {ui}, - ); -} - -// Representative diff snippet covering every branch of classifyLine. -const SAMPLE_DIFF = [ - "commit abcdef1234567890abcdef1234567890abcdef12", - "Author: ctm", - "", - "diff --git a/foo.go b/foo.go", - "index 111..222 100644", - "--- a/foo.go", - "+++ b/foo.go", - "@@ -1,3 +1,3 @@", - " context line", - "-removed line", - "+added line", -].join("\n"); - -describe("DiffSheet", () => { - let originalFetch: typeof globalThis.fetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it("classifies +/- /@@/plain lines into the expected colour buckets", () => { - expect(classifyLine("+added")).toBe("text-emerald-400"); - expect(classifyLine("-removed")).toBe("text-alert-ember"); - expect(classifyLine("@@ -1,3 +1,3 @@")).toBe("text-fg-dim"); - expect(classifyLine(" context")).toBe("text-fg"); - expect(classifyLine("commit abc")).toBe("text-fg"); - }); - - it("fetches the diff and renders every line with the right colour", async () => { - const fetchMock = vi.fn(async (url: RequestInfo | URL) => { - expect(String(url)).toContain( - `/api/sessions/alpha/checkpoints/${FULL_SHA}/diff`, - ); - return new Response(SAMPLE_DIFF, { - status: 200, - headers: { "content-type": "text/plain" }, - }); - }); - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; - - renderWithQuery( - {}} - />, - ); - - await waitFor(() => { - expect(fetchMock).toHaveBeenCalled(); - }); - - const pre = await screen.findByTestId("diff-pre"); - expect(pre).toBeInTheDocument(); - - // Added line gets emerald. - await waitFor(() => { - const added = screen.getByText("+added line"); - expect(added.className).toContain("text-emerald-400"); - }); - // Removed line gets alert-ember. - const removed = screen.getByText("-removed line"); - expect(removed.className).toContain("text-alert-ember"); - // Hunk header gets fg-dim. - const hunk = screen.getByText("@@ -1,3 +1,3 @@"); - expect(hunk.className).toContain("text-fg-dim"); - // Plain commit-header line stays on default fg. - const commit = screen.getByText( - "commit abcdef1234567890abcdef1234567890abcdef12", - ); - expect(commit.className).toContain("text-fg"); - expect(commit.className).not.toContain("text-emerald-400"); - expect(commit.className).not.toContain("text-alert-ember"); - }); - - it("does not fetch when no checkpoint is provided", () => { - const fetchMock = vi.fn(); - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; - renderWithQuery( - {}} - />, - ); - expect(fetchMock).not.toHaveBeenCalled(); - expect(screen.queryByText(/checkpoint diff/i)).toBeNull(); - }); - - it("surfaces a fetch error in an alert region", async () => { - const fetchMock = vi.fn(async () => { - return new Response("boom", { - status: 500, - headers: { "content-type": "text/plain" }, - }); - }); - globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; - - renderWithQuery( - {}} - />, - ); - - await waitFor(() => { - expect(screen.getByRole("alert")).toBeInTheDocument(); - }); - expect(screen.getByRole("alert")).toHaveTextContent(/could not load diff/i); - }); -}); diff --git a/ui/src/components/DiffSheet.tsx b/ui/src/components/DiffSheet.tsx deleted file mode 100644 index 5e1a2f8..0000000 --- a/ui/src/components/DiffSheet.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useMemo } from "react"; -import { Loader2 } from "lucide-react"; -import { Drawer, Button } from "@ossrandom/design-system"; -import type { Checkpoint } from "@/hooks/useCheckpoints"; -import { useCheckpointDiff } from "@/hooks/useCheckpoints"; -import { relativeTime } from "@/lib/format"; -import { cn } from "@/lib/utils"; -import { classifyLine } from "@/lib/diff"; - -interface DiffSheetProps { - sessionName: string; - checkpoint: Checkpoint | null; - onClose: () => void; -} - -/** - * V18 standalone diff viewer. - * - * Fetches `/api/sessions/:name/checkpoints/:sha/diff` (unified `git - * show` output, text/plain) and renders it in a scrollable monospace - *
    . Colouring is the minimal set that preserves legibility
    - * without a full syntax-highlighter dependency:
    - *
    - *   +...   emerald  (added line)
    - *   -...   alert-ember  (removed line)
    - *   @@...  fg-dim  (hunk header)
    - *   else   fg
    - *
    - * Slide-out chrome, focus trap, ESC-to-close come from the design-system
    - * Drawer component — same primitive used by RevertSheet for consistency.
    - */
    -export function DiffSheet({ sessionName, checkpoint, onClose }: DiffSheetProps) {
    -  const open = checkpoint !== null;
    -  const {
    -    data: diff,
    -    isLoading,
    -    isError,
    -    error,
    -  } = useCheckpointDiff(sessionName, checkpoint?.sha);
    -
    -  const lines = useMemo(() => (diff ? diff.split("\n") : []), [diff]);
    -
    -  return (
    -    
    -          Unified diff from git show.
    -        
    -      }
    -      footer={
    -        
    -      }
    -    >
    -      {checkpoint && (
    -        <>
    -          
    -

    - {checkpoint.short_sha || checkpoint.sha.slice(0, 7)} -

    -

    {checkpoint.subject}

    -

    - -

    -
    - -
    - {isLoading && ( -

    - - Loading diff… -

    - )} - {isError && ( -

    - Could not load diff - {error instanceof Error ? `: ${error.message}` : ""} -

    - )} - {!isLoading && !isError && diff !== undefined && ( -
    -                {lines.map((line, i) => (
    -                  
    -                ))}
    -              
    - )} -
    - - )} -
    - ); -} - -interface DiffLineProps { - line: string; -} - -function DiffLine({ line }: DiffLineProps) { - const cls = classifyLine(line); - return ( -
    {line === "" ? " " : line}
    - ); -} - -export { classifyLine }; diff --git a/ui/src/components/FeedStream.test.tsx b/ui/src/components/FeedStream.test.tsx deleted file mode 100644 index a871672..0000000 --- a/ui/src/components/FeedStream.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { FeedStream } from "@/components/FeedStream"; -import type { ToolCallRow } from "@/hooks/useFeed"; -import type { FeedHistoryResponse } from "@/hooks/useFeedHistory"; - -/** Render FeedStream with a pre-seeded feed cache for sessionName. */ -function renderWithCache( - ui: React.ReactNode, - { - sessionName, - feed, - }: { sessionName: string; feed: ToolCallRow[] }, -) { - const qc = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - qc.setQueryData(["feed", sessionName], feed); - return render({ui}); -} - -const liveRow: ToolCallRow = { - session: "alpha", - tool: "Bash", - input: "live command", - summary: "ok", - is_error: false, - ts: "2026-04-21T16:30:00Z", -}; - -function makeHistoryResponse( - count: number, - has_more: boolean, -): FeedHistoryResponse { - const events = Array.from({ length: count }).map((_, i) => ({ - id: `${i}-0`, - session: "alpha", - type: "tool_call", - ts: `2026-04-21T16:00:0${i}Z`, - payload: { - session: "alpha", - tool: "Edit", - input: `historical-file-${i}.ts`, - summary: "diff applied", - is_error: false, - ts: `2026-04-21T16:00:0${i}Z`, - } as ToolCallRow, - })); - return { events, has_more }; -} - -describe("FeedStream — Load older (V6)", () => { - it("appends returned rows below the ring view on click", async () => { - const onLoadOlder = vi.fn< - (beforeId: string) => Promise - >(async () => makeHistoryResponse(3, true)); - - renderWithCache( - , - { sessionName: "alpha", feed: [liveRow] }, - ); - - // Live row is visible. - expect(screen.getByText("live command")).toBeInTheDocument(); - - const button = screen.getByRole("button", { name: /load older/i }); - await userEvent.click(button); - - // Fetcher received the live row's cursor (nanos of 2026-04-21T16:30Z, - // any suffix "-0"). We only verify it was called with a non-empty - // string, not the exact ms precision. - expect(onLoadOlder).toHaveBeenCalledTimes(1); - expect(onLoadOlder.mock.calls.length).toBe(1); - const cursor = onLoadOlder.mock.calls[0]![0]; - expect(cursor).toMatch(/^\d+-\d+$/); - - // All three historical rows rendered (newest-first below live). - await waitFor(() => { - expect(screen.getByText("historical-file-0.ts")).toBeInTheDocument(); - }); - expect(screen.getByText("historical-file-1.ts")).toBeInTheDocument(); - expect(screen.getByText("historical-file-2.ts")).toBeInTheDocument(); - - // has_more=true → button still rendered for another click. - expect( - screen.getByRole("button", { name: /load older/i }), - ).toBeInTheDocument(); - }); - - it("hides the Load older button when has_more is false", async () => { - const onLoadOlder = vi.fn< - (beforeId: string) => Promise - >(async () => makeHistoryResponse(2, false)); - - renderWithCache( - , - { sessionName: "alpha", feed: [liveRow] }, - ); - - await userEvent.click(screen.getByRole("button", { name: /load older/i })); - - // Rows landed… - await waitFor(() => { - expect(screen.getByText("historical-file-0.ts")).toBeInTheDocument(); - }); - // …and the button is gone because the backend reported exhaustion. - expect( - screen.queryByRole("button", { name: /load older/i }), - ).not.toBeInTheDocument(); - }); - - it("omits the Load older button entirely when onLoadOlder is absent", () => { - renderWithCache(, { - sessionName: "alpha", - feed: [liveRow], - }); - expect( - screen.queryByRole("button", { name: /load older/i }), - ).not.toBeInTheDocument(); - }); -}); diff --git a/ui/src/components/FeedStream.tsx b/ui/src/components/FeedStream.tsx deleted file mode 100644 index 87ae775..0000000 --- a/ui/src/components/FeedStream.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { useCallback, useMemo, useState } from "react"; -import { useFeed, type ToolCallRow as ToolCallRowType } from "@/hooks/useFeed"; -import { ToolCallRow } from "@/components/ToolCallRow"; -import { BashOnlyRow } from "@/components/BashOnlyRow"; -import type { FeedHistoryResponse } from "@/hooks/useFeedHistory"; -import { cn } from "@/lib/utils"; - -interface FeedStreamProps { - /** When undefined, renders the cross-session feed (V5). */ - sessionName?: string; - className?: string; - /** Cap rows displayed (cache is capped at 500 by SseProvider). */ - limit?: number; - /** - * V10 — when true, filter to Bash-only tool calls and swap the renderer - * for the compact strip. Default false preserves the - * existing editorial feed used everywhere else. - */ - bashOnly?: boolean; - /** - * V6 — when provided, renders a "Load older" button at the bottom of - * the list. Clicked with the oldest visible row's cursor; returned - * events are appended below the ring view. Omitted in the - * cross-session feed (no per-session cursor semantics). - * - * The fetcher is injected rather than called directly so tests can - * stub without mocking `fetch`. Production callers pass - * `fetchFeedHistory` from `@/hooks/useFeedHistory`. - */ - onLoadOlder?: (beforeId: string) => Promise; -} - -/** - * Derive the hub-style cursor id for a cache row. The feed cache - * stores only payloads (no event id), so we reconstruct the id from - * the payload timestamp in the same shape the backend uses: - * `-0`. Monotonicity within the cursor window is preserved - * because history results come out of a single JSONL file in - * append-order. - */ -function cursorFromRow(row: ToolCallRowType): string { - const ms = Date.parse(row.ts); - if (Number.isFinite(ms)) { - const nanos = BigInt(ms) * 1_000_000n; - return `${nanos.toString()}-0`; - } - return "0-0"; -} - -/** - * Read-only live feed of tool calls. Reads from the TanStack Query cache - * populated by SseProvider on `tool_call` events. No own subscription — - * SseProvider owns the EventSource. - * - * Newest first; reverses the cache (which is append-order) for display. - */ -export function FeedStream({ - sessionName, - className, - limit = 500, - bashOnly = false, - onLoadOlder, -}: FeedStreamProps) { - const { data } = useFeed(sessionName); - - // Historical rows appended below the ring view. Kept in local state - // rather than the TanStack cache so live SSE ticks never have to - // re-sort / de-dup against older rows (see useFeedHistory docs). - // Append-order matches the backend's newest-first response, so each - // successive "Load older" batch lands beneath the previous one. - const [historical, setHistorical] = useState([]); - const [hasMore, setHasMore] = useState(true); - const [loading, setLoading] = useState(false); - const [loadError, setLoadError] = useState(null); - - const liveRows = useMemo(() => { - const source = data ?? []; - const filtered = bashOnly - ? source.filter((r) => r.tool === "Bash") - : source; - const slice = filtered.slice(-limit); - return slice.slice().reverse(); - }, [data, limit, bashOnly]); - - const displayHistorical = useMemo(() => { - if (!bashOnly) return historical; - return historical.filter((r) => r.tool === "Bash"); - }, [historical, bashOnly]); - - const rows = useMemo( - () => [...liveRows, ...displayHistorical], - [liveRows, displayHistorical], - ); - - const handleLoadOlder = useCallback(async () => { - if (!onLoadOlder || loading) return; - // Cursor = oldest currently-visible row (live tail or last - // historical batch). When the feed is empty (e.g. fresh tab, SSE - // hasn't delivered yet), fall back to "now" so the first click - // still asks the server for anything older than the present - // moment — it's a best-effort upper bound. - const oldest = rows.at(-1); - const cursor = oldest - ? cursorFromRow(oldest) - : `${BigInt(Date.now()) * 1_000_000n}-0`; - setLoading(true); - setLoadError(null); - try { - const resp = await onLoadOlder(cursor); - const newRows = resp.events.map((e) => e.payload); - if (newRows.length > 0) { - setHistorical((prev) => [...prev, ...newRows]); - } - setHasMore(resp.has_more); - } catch (err) { - setLoadError(err instanceof Error ? err.message : "failed"); - } finally { - setLoading(false); - } - }, [onLoadOlder, loading, rows]); - - const emptyMessage = bashOnly - ? "No Bash commands yet." - : "Waiting for the first tool call…"; - - // Render "Load older" whenever a fetcher is provided and the server - // hasn't reported exhaustion. We intentionally do NOT gate on - // rows.length > 0: a fresh tab with an empty ring still wants to - // pull historical rows from disk if the user is curious about older - // activity. The backend handler returns [] + has_more:false when - // nothing older is available, which flips the button off naturally. - const showLoadOlder = Boolean(onLoadOlder) && hasMore; - - return ( -
    - {rows.length === 0 ? ( -

    - {emptyMessage} -

    - ) : ( -
      - {rows.map((row, i) => ( -
    1. - {bashOnly ? ( - - ) : ( - - )} -
    2. - ))} -
    - )} - {showLoadOlder && ( -
    - -
    - )} - {loadError && ( -

    - Could not load older events: {loadError} -

    - )} -
    - ); -} diff --git a/ui/src/components/HealthDot.tsx b/ui/src/components/HealthDot.tsx deleted file mode 100644 index 4f7a2e8..0000000 --- a/ui/src/components/HealthDot.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { cn } from "@/lib/utils"; - -const RECENT_MS = 60_000; - -export type HealthState = "live" | "idle" | "dead"; - -/** - * Compute health state from a Session-shaped record. - * - live: tmux alive AND last tool call within 60s - * - idle: tmux alive but no recent tool calls - * - dead: tmux not alive (record persisted, process gone) - */ -export function healthState(input: { - tmux_alive?: boolean; - last_tool_call_at?: string; - now?: Date; -}): HealthState { - if (!input.tmux_alive) return "dead"; - if (!input.last_tool_call_at) return "idle"; - const now = (input.now ?? new Date()).getTime(); - const last = new Date(input.last_tool_call_at).getTime(); - if (Number.isNaN(last)) return "idle"; - return now - last <= RECENT_MS ? "live" : "idle"; -} - -const COLOR: Record = { - live: "bg-live-dot", - idle: "bg-fg-dim", - dead: "bg-fg-muted opacity-50", -}; - -const LABEL: Record = { - live: "live", - idle: "idle", - dead: "stopped", -}; - -interface HealthDotProps { - state: HealthState; - className?: string; -} - -/** - * Tiny status dot — green when live, dim when idle, muted when stored-but-dead. - * Spec §3 design tokens: --live-dot, --fg-dim, --fg-muted. - */ -export function HealthDot({ state, className }: HealthDotProps) { - return ( - - ); -} diff --git a/ui/src/components/LogDiskUsage.test.tsx b/ui/src/components/LogDiskUsage.test.tsx deleted file mode 100644 index 44437d4..0000000 --- a/ui/src/components/LogDiskUsage.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor, within } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { LogDiskUsage } from "@/components/LogDiskUsage"; -import { humanBytes } from "@/lib/format"; -import type { LogsUsage } from "@/hooks/useLogsUsage"; - -function renderWithQuery(ui: React.ReactNode) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return render({ui}); -} - -function mockResponse(body: LogsUsage, status = 200) { - return vi.fn(async () => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }), - ); -} - -describe("humanBytes", () => { - it("renders B / KB / MB / GB with binary unit boundaries", () => { - expect(humanBytes(0)).toBe("0 B"); - expect(humanBytes(1023)).toBe("1023 B"); - expect(humanBytes(1024)).toBe("1 KB"); - expect(humanBytes(1024 * 1024)).toBe("1 MB"); - expect(humanBytes(1024 * 1024 * 1024)).toBe("1 GB"); - // Non-integer scaled value keeps one decimal. - expect(humanBytes(1024 + 512)).toBe("1.5 KB"); - expect(humanBytes(1024 * 1024 + 512 * 1024)).toBe("1.5 MB"); - }); - - it("handles invalid input without throwing", () => { - expect(humanBytes(NaN)).toBe("—"); - expect(humanBytes(Infinity)).toBe("—"); - }); -}); - -describe("LogDiskUsage", () => { - let originalFetch: typeof globalThis.fetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - it("renders total bytes and per-session rows from the hook", async () => { - const payload: LogsUsage = { - dir: "/home/dev/.config/ctm/logs", - total_bytes: 1_048_576 + 1024, // 1 MB + 1 KB - files: [ - { - uuid: "aaaa", - session: "alpha", - bytes: 1_048_576, - mtime: new Date(Date.now() - 120_000).toISOString(), - }, - { - uuid: "bbbb", - session: "beta", - bytes: 1024, - mtime: new Date(Date.now() - 300_000).toISOString(), - }, - ], - }; - globalThis.fetch = mockResponse( - payload, - ) as unknown as typeof globalThis.fetch; - - renderWithQuery(); - - // Total is rendered in the header. - await waitFor(() => { - const region = screen.getByRole("region", { name: /log disk usage/i }); - expect(within(region).getByText("1 MB", { exact: false })).toBeTruthy(); - }); - - const region = screen.getByRole("region", { name: /log disk usage/i }); - // Both session names rendered. - expect(within(region).getByText("alpha")).toBeInTheDocument(); - expect(within(region).getByText("beta")).toBeInTheDocument(); - // Per-row sizes humanised. "1 MB" is in the header too, so assert - // on beta's 1 KB row which is unique to the body. - expect(within(region).getByText("1 KB")).toBeInTheDocument(); - // Dir surfaced in the footer. - expect( - within(region).getByText("/home/dev/.config/ctm/logs"), - ).toBeInTheDocument(); - }); - - it("renders the empty state when no log files exist", async () => { - globalThis.fetch = mockResponse({ - dir: "/fresh/install", - total_bytes: 0, - files: [], - }) as unknown as typeof globalThis.fetch; - - renderWithQuery(); - - await waitFor(() => { - expect( - screen.getByText(/no log files in \/fresh\/install/i), - ).toBeInTheDocument(); - }); - }); - - it("surfaces an error when the endpoint fails", async () => { - globalThis.fetch = vi.fn(async () => - new Response("boom", { status: 500 }), - ) as unknown as typeof globalThis.fetch; - - renderWithQuery(); - - await waitFor(() => { - expect( - screen.getByRole("alert", { name: undefined }), - ).toHaveTextContent(/could not load log usage/i); - }); - }); -}); diff --git a/ui/src/components/LogDiskUsage.tsx b/ui/src/components/LogDiskUsage.tsx deleted file mode 100644 index cd30192..0000000 --- a/ui/src/components/LogDiskUsage.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { Skeleton } from "@ossrandom/design-system"; -import { useLogsUsage } from "@/hooks/useLogsUsage"; -import { humanBytes, relativeTime } from "@/lib/format"; - -/** - * V21 log disk usage card. Mounted below the Meta tab fields on - * SessionDetail so users notice when it's time to prune - * ~/.config/ctm/logs. - * - * Read-only by design — no delete button. If users want to prune they - * do it with `rm` / a housekeeping script; exposing deletion from the - * UI would be a second-axis auth decision we don't want to make in v1. - */ -export function LogDiskUsage() { - const { data, isLoading, isError, error } = useLogsUsage(); - - return ( -
    -
    -

    - Log disk usage -

    - {data && ( - - {humanBytes(data.total_bytes)} - - )} -
    - - {isLoading && ( -
    - - - -
    - )} - - {isError && ( -

    - Could not load log usage - {error instanceof Error ? `: ${error.message}` : ""} -

    - )} - - {data && !isLoading && !isError && ( - <> - {data.files.length === 0 ? ( -

    - No log files in {data.dir} -

    - ) : ( - - - - - - - - - - - {data.files.map((f) => ( - - - - - - ))} - -
    - Per-session log file sizes, sorted by bytes descending -
    - Session - - Size - - Updated -
    - - {f.session} - - - {humanBytes(f.bytes)} - - {f.mtime ? ( - - ) : ( - "—" - )} -
    - )} -
    - - {data.dir} - -
    - - )} -
    - ); -} diff --git a/ui/src/components/NewSessionModal.test.tsx b/ui/src/components/NewSessionModal.test.tsx deleted file mode 100644 index 822c00f..0000000 --- a/ui/src/components/NewSessionModal.test.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { MemoryRouter } from "react-router"; -import { NewSessionModal } from "@/components/NewSessionModal"; - -const navigateMock = vi.fn(); -vi.mock("react-router", async () => { - const actual = - await vi.importActual("react-router"); - return { ...actual, useNavigate: () => navigateMock }; -}); - -function wrap(ui: React.ReactNode) { - const qc = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - return ( - - {ui} - - ); -} - -function stubFetchSequence(responses: Array<{ status: number; body: unknown }>) { - let i = 0; - const mock = vi.fn(async () => { - const r = responses[i++] ?? responses[responses.length - 1]; - return new Response( - r.body === undefined ? "" : JSON.stringify(r.body), - { - status: r.status, - headers: { "content-type": "application/json" }, - }, - ); - }); - globalThis.fetch = mock as unknown as typeof globalThis.fetch; - return mock; -} - -describe("NewSessionModal", () => { - let originalFetch: typeof globalThis.fetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - navigateMock.mockReset(); - }); - afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - it("renders workdir input and Create button disabled initially", () => { - render( - wrap(), - ); - const input = screen.getByRole("textbox", { name: /workdir/i }); - const create = screen.getByRole("button", { name: /create/i }); - expect(input).toBeInTheDocument(); - expect(create).toBeDisabled(); - }); - - it("pre-fills the top recent and enables Create", () => { - render( - wrap( - , - ), - ); - const input = screen.getByRole("textbox", { name: /workdir/i }) as HTMLInputElement; - expect(input.value).toBe("/home/dev/projects/ctm"); - expect(screen.getByRole("button", { name: /create/i })).toBeEnabled(); - }); - - it("tapping a recent replaces the input value", async () => { - render( - wrap( - , - ), - ); - await userEvent.click(screen.getByRole("button", { name: "/b" })); - const input = screen.getByRole("textbox", { name: /workdir/i }) as HTMLInputElement; - expect(input.value).toBe("/b"); - }); - - it("submits and navigates on 201", async () => { - const onClose = vi.fn(); - stubFetchSequence([ - { status: 201, body: { name: "ctm", uuid: "u", mode: "yolo", workdir: "/ctm" } }, - ]); - render( - wrap(), - ); - await userEvent.click(screen.getByRole("button", { name: /create/i })); - await waitFor(() => expect(navigateMock).toHaveBeenCalledWith("/s/ctm")); - expect(onClose).toHaveBeenCalled(); - }); - - it("on 409 collision, surfaces the rename / go-to-existing panel", async () => { - stubFetchSequence([ - { - status: 409, - body: { - error: "name_exists", - message: "exists", - session: { name: "ctm", uuid: "u", mode: "yolo", workdir: "/ctm" }, - }, - }, - ]); - render( - wrap(), - ); - await userEvent.click(screen.getByRole("button", { name: /create/i })); - expect( - await screen.findByRole("button", { name: /go to existing/i }), - ).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /rename/i })).toBeInTheDocument(); - }); - - it("rename flow re-submits with a new name", async () => { - const fetchMock = stubFetchSequence([ - { - status: 409, - body: { - error: "name_exists", - message: "exists", - session: { name: "ctm", uuid: "u", mode: "yolo", workdir: "/ctm" }, - }, - }, - { status: 201, body: { name: "ctm-2", uuid: "u2", mode: "yolo", workdir: "/ctm" } }, - ]); - render( - wrap(), - ); - await userEvent.click(screen.getByRole("button", { name: /create/i })); - await userEvent.click(await screen.findByRole("button", { name: /rename/i })); - - const nameInput = screen.getByRole("textbox", { name: /new name/i }) as HTMLInputElement; - expect(nameInput.value).toBe("ctm-2"); - await userEvent.click(screen.getByRole("button", { name: /create/i })); - - await waitFor(() => expect(navigateMock).toHaveBeenCalledWith("/s/ctm-2")); - const call1 = fetchMock.mock.calls[1] as unknown as [string, RequestInit]; - expect(JSON.parse(String(call1[1]?.body))).toEqual({ - workdir: "/ctm", - name: "ctm-2", - }); - }); - - it("includes initial_prompt in the body when the textarea is filled", async () => { - const fetchMock = stubFetchSequence([ - { status: 201, body: { name: "ctm", uuid: "u", mode: "yolo", workdir: "/ctm" } }, - ]); - render( - wrap(), - ); - const textarea = screen.getByRole("textbox", { name: /initial prompt/i }); - await userEvent.type(textarea, "review the diff"); - await userEvent.click(screen.getByRole("button", { name: /create/i })); - - await waitFor(() => expect(fetchMock).toHaveBeenCalled()); - const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; - expect(JSON.parse(String(call[1]?.body))).toEqual({ - workdir: "/ctm", - initial_prompt: "review the diff", - }); - }); - - it("omits initial_prompt when the textarea is empty", async () => { - const fetchMock = stubFetchSequence([ - { status: 201, body: { name: "ctm", uuid: "u", mode: "yolo", workdir: "/ctm" } }, - ]); - render( - wrap(), - ); - await userEvent.click(screen.getByRole("button", { name: /create/i })); - - await waitFor(() => expect(fetchMock).toHaveBeenCalled()); - const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; - const body = JSON.parse(String(call[1]?.body)); - expect(body).not.toHaveProperty("initial_prompt"); - }); - - it("trims whitespace-only initial prompt and omits the field", async () => { - const fetchMock = stubFetchSequence([ - { status: 201, body: { name: "ctm", uuid: "u", mode: "yolo", workdir: "/ctm" } }, - ]); - render( - wrap(), - ); - const textarea = screen.getByRole("textbox", { name: /initial prompt/i }); - await userEvent.type(textarea, " "); - await userEvent.click(screen.getByRole("button", { name: /create/i })); - - await waitFor(() => expect(fetchMock).toHaveBeenCalled()); - const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; - const body = JSON.parse(String(call[1]?.body)); - expect(body).not.toHaveProperty("initial_prompt"); - }); -}); diff --git a/ui/src/components/NewSessionModal.tsx b/ui/src/components/NewSessionModal.tsx deleted file mode 100644 index 4bc706d..0000000 --- a/ui/src/components/NewSessionModal.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { useEffect, useState } from "react"; -import { useNavigate } from "react-router"; -import { Modal, Input, Textarea, Button } from "@ossrandom/design-system"; -import { ApiError } from "@/lib/api"; -import { - isConflict, - useCreateSession, - type CreateConflict, -} from "@/hooks/useCreateSession"; - -interface NewSessionModalProps { - open: boolean; - onClose: () => void; - recents: string[]; -} - -/** - * V26 — create a fresh yolo-mode claude session from the browser. - * Flow: - * default state: workdir input (pre-filled with recents[0]) + Create - * on 201: navigate("/s/") + onClose() - * on 409: swap to collision state with Rename / Go-to-existing - * on 4xx/5xx: inline error message - */ -export function NewSessionModal({ open, onClose, recents }: NewSessionModalProps) { - const navigate = useNavigate(); - const create = useCreateSession(); - const [workdir, setWorkdir] = useState(recents[0] ?? ""); - const [name, setName] = useState(""); - const [initialPrompt, setInitialPrompt] = useState(""); - const [collision, setCollision] = useState(null); - const [renaming, setRenaming] = useState(false); - const [errMsg, setErrMsg] = useState(null); - - useEffect(() => { - if (!open) return; - setWorkdir(recents[0] ?? ""); - setName(""); - setInitialPrompt(""); - setCollision(null); - setRenaming(false); - setErrMsg(null); - create.reset(); - }, [open]); // eslint-disable-line react-hooks/exhaustive-deps - - async function handleSubmit() { - setErrMsg(null); - try { - const trimmedPrompt = initialPrompt.trim(); - const sess = await create.mutateAsync({ - workdir, - ...(renaming && name ? { name } : {}), - ...(trimmedPrompt ? { initial_prompt: trimmedPrompt } : {}), - }); - navigate(`/s/${encodeURIComponent(sess.name)}`); - onClose(); - } catch (e) { - if (isConflict(e)) { - setCollision(e.body); - setName(suggestRename(e.body.session.name)); - return; - } - setErrMsg(serverMessage(e) ?? "Could not create session"); - } - } - - const canSubmit = workdir.trim().length > 0 && !create.isPending; - - return ( - { - navigate(`/s/${encodeURIComponent(collision.session.name)}`); - onClose(); - }} - onRename={() => setRenaming(true)} - onSubmit={handleSubmit} - /> - ) : ( - <> - - - - ) - } - > - {collision ? ( - - ) : ( - { - e.preventDefault(); - if (canSubmit) handleSubmit(); - }} - > -
    Workdir
    - setWorkdir(v)} - placeholder="/home/dev/projects/…" - autoFocus - size="sm" - aria-label="Workdir" - /> - - {recents.length > 0 && ( -
    -
    Recents
    -
      - {recents.map((r) => ( -
    • - -
    • - ))} -
    -
    - )} - -
    - Initial prompt(optional — sent after boot) -
    -