diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 51eba13..8fcbeac 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -5,9 +5,11 @@ labels: bug --- **Environment** -- OS: +- OS: - abtop version: -- Claude Code version: +- Affected agent: +- Agent version: +- Agent data home, if customized: - Terminal: **Describe the bug** @@ -26,3 +28,6 @@ labels: bug **Screenshots** + +**Diagnostic output** + diff --git a/AGENTS.md b/AGENTS.md index d1a254c..058f182 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ AI agent monitor for your terminal. Like btop++, but for AI coding agents. -Supports Claude Code, Codex CLI, and OpenCode sessions. +Supports Claude Code, Codex CLI, OpenCode, Grok, and Kimi Code sessions. ## Language Policy @@ -18,17 +18,45 @@ English is mandatory for all project-facing work and communication. ``` src/ -├── main.rs # Entry, terminal setup, event loop, --setup flag +├── main.rs # Thin binary entry that delegates to abtop::run +├── lib.rs # CLI dispatch, terminal setup/event loop, admin flags ├── app.rs # App state, tick logic, key handling, summary generation -├── setup.rs # StatusLine hook installation (abtop --setup) -├── ui/ -│ └── mod.rs # All panels in single file: header, context, quota, -│ # tokens, projects, ports, sessions, footer +├── config.rs # Platform config loading and persisted UI preferences +├── demo.rs # Deterministic demo sessions and metrics +├── host_info.rs # Host CPU/MEM and aggregate agent metrics +├── herdr.rs # Bounded content-free Herdr CLI protocol client +├── locale.rs # Centralized English UI strings +├── snapshot.rs # Stable JSON-friendly snapshot DTOs +├── theme.rs # Built-in theme definitions and lookup +├── setup.rs # Claude StatusLine installation only +├── codex_compat.rs # 0.6-only direct native compatibility trampoline +├── codex_hooks/ # Native Codex plugin setup and content-free hook state +│ ├── mod.rs # Internal plugin/state facade and silent ingest entry +│ ├── plugin.rs # Isolated local marketplace/plugin bundle management +│ ├── migration.rs # Exact legacy shell-wrapper marker cleanup +│ ├── ingest.rs # Bounded hook parsing and native-process correlation +│ └── state.rs # Private content-free lifecycle reducer/state store +├── jump/ # cmux, tmux, and macOS iTerm2 terminal focus adapters +├── ui/ # Responsive layout plus one module per panel/overlay +│ ├── mod.rs # Desktop/narrow allocation and mouse hit-testing +│ ├── context.rs # Token rate and per-session context gauges +│ ├── quota.rs # Adaptive provider account-quota cards and windows +│ ├── tokens.rs # Token totals and selected-session history +│ ├── projects.rs # Per-project git state +│ ├── ports.rs # Child listeners and orphan ports +│ ├── sessions.rs # Session table, detail, timeline, and file audit +│ ├── mcp.rs # Codex MCP server inventory and activity +│ └── {header,footer,help,config,view_menu}.rs ├── collector/ │ ├── mod.rs # MultiCollector orchestration, orphan port detection │ ├── claude.rs # Claude Code: session discovery, transcript parsing -│ ├── codex.rs # Codex CLI: session discovery via ps+lsof, JSONL parsing +│ ├── codexbar.rs # Optional bounded multi-provider quotas via CodexBar +│ ├── codex.rs # Hook lifecycle evidence + local rollout metrics +│ ├── herdr.rs # Exact Herdr session/pane/PID status correlation │ ├── opencode.rs # OpenCode: session discovery via ps + SQLite DB parsing +│ ├── grok.rs # Grok: active registry + session JSON/JSONL parsing +│ ├── kimi.rs # Kimi Code: session index + wire JSONL parsing +│ ├── mcp.rs # Codex mcp-server process/rollout discovery │ ├── process.rs # Child process tree (ps) + open ports (lsof) + git stats │ └── rate_limit.rs # Rate limit file reading (~/.claude/abtop-rate-limits.json) └── model/ @@ -39,52 +67,68 @@ src/ ## Layout -``` -┌─ ¹context (token rate sparkline + per-session context bars) ─────────┐ -│ ▁▃▅▇█▇▅▃▁▃▅▇██ S1 abtop ████████ 82% │ -│ token rate (200pt history) S2 prediction █████████91%⚠ │ -│ S3 api-server ███ 22% │ -└──────────────────────────────────────────────────────────────────────┘ -┌─ ²quota ─────┐┌─ ³tokens ───┐┌─ projects ───┐┌─ ⁴ports ──────────┐ -│ CLAUDE ││ Total 1.2M ││ abtop ││ PORT SESSION CMD │ -│ 5h ████ 35% ││ Input 402k ││ main +3 ~18 ││ :3000 api-srv node│ -│ resets 2h ││ Output 89k ││ ││ :8080 predict crgo│ -│ 7d ██ 12% ││ Cache 710k ││ prediction ││ │ -│ ││ ▁▃▅▇█▇▅▃▁▃▅││ feat/x +1~2 ││ ORPHAN PORTS │ -│ CODEX ││ Turns: 48 ││ ││ :4000 old-prj node│ -│ 5h █ 9% ││ Avg: 25k/t ││ api-server ││ │ -│ 7d ██ 14% ││ ││ main ✓clean ││ │ -└──────────────┘└─────────────┘└──────────────┘└────────────────────┘ -┌─ ⁵sessions ─────────────────────────────────────────────────────────┐ -│ ►*CC 7336 abtop ● Work opus 82% 1.2M 48 Edit src/pay.rs │ -│ >CD 8840 pred ◌ Wait sonn 91% 340k 12 waiting │ -│ ─────────────────────────────────────────────────────────────────── │ -│ SESSION 7336 · /Users/graykode/abtop │ -│ Stripe payment integration... │ -│ └─ Edit src/pay.rs │ -│ CHILDREN: 7401 cargo build │ -│ SUBAGENTS: explore-data ✓12k · run-tests ●8k │ -│ MEM 4f · 12/200 │ v2.1.86 · 47m │ -└──────────────────────────────────────────────────────────────────────┘ -``` +The seven numbered panels are Context, Quota, Tokens, Projects, Ports, Sessions, +and MCP. Every panel can be toggled with `1`–`7`, persisted in platform config, +and hidden independently. Quota combines native Claude/Codex data with every provider +enabled through the optional CodexBar integration. -Panel rendering priority (top to bottom): -1. **Sessions** — always visible, gets priority allocation (min 5 rows, ideal = 2/session + 7) -2. **Mid-tier** (quota, tokens, projects, ports) — split equally, shown if space allows -3. **Context** — only renders when sessions have ideal height AND surplus >= 5 rows -4. **Header** (1 row) + **Footer** (1 row) — always present +The supported minimum terminal is 60×18. Widths 60–99 use the tabbed Work / Usage / +System layout; widths of 100 or more use the desktop layout. Narrow sections divide +height evenly unless the active section is maximized. Desktop allocation reserves the +one-row header/footer, gives Sessions a five-row minimum and first claim toward its +ideal height, then assigns the mid-tier row; Context appears only when the requested +Sessions height is satisfied and at least five surplus rows remain. At 120×40 and above +the full desktop layout is normally visible. -Panel descriptions: -- **¹context**: Left = token rate braille sparkline (200-point history). Right = per-session context % bars with yellow/red warning. -- **²quota**: Claude + Codex rate limit gauges side-by-side (5h and 7d windows with reset countdown). Quota is intentionally limited to Claude and Codex; do not add an OpenCode row unless OpenCode exposes a reliable account-level provider rate-limit source. -- **³tokens**: Total token breakdown (in/out/cache) + per-turn sparkline for selected session. -- **projects** (always visible): Per-project git branch + added/modified file counts. -- **⁴ports**: Agent-spawned open ports + orphan ports (from dead sessions). Conflict detection. -- **⁵sessions**: Full-width panel below mid row. Session list table (top) + selected session detail (bottom), separated by divider. +The Sessions panel contains the list and selected-session detail, including evidence, +children, subagents, chat, timeline, and file audit. The MCP panel shows detected Codex +`mcp-server` processes and recent rollout activity. MCP-owned rollouts are suppressed +from Sessions by default; `M` changes suppression for the current run only. ## Data Sources -All read-only from local filesystem + `ps` + `lsof`. No API calls, no auth. +Collectors are read-only over local files plus local process and port metadata +(`ps`/`lsof` on Unix, native equivalents on Windows). The opt-in CodexBar quota +integration invokes its normal configured usage command and honors every enabled +provider plus CodexBar's automatic local, web, OAuth, or API source selection. abtop +never reads or stores credentials, identities, credits, or raw provider text. The +explicit setup commands write only their documented Claude or Codex integration, and +the installed Codex helper writes only its private, bounded lifecycle state. + +### Optional CodexBar account quota + +`codexbar_quota_fallback = true` is the retained compatibility key for the +disabled-by-default **CodexBar quotas** setting. While enabled, poll the normal +configured CodexBar usage command every 60 seconds with a 15-second timeout and +independent 1 MiB output bounds. Do not force `--provider all`: the returned providers +must reflect CodexBar's enabled-provider configuration and automatic local, web, OAuth, +or API source selection. + +Parse successful and failed providers independently. Retain every bounded primary, +secondary, tertiary, and extra quota window from successful rows; convert failed rows +to fixed sanitized per-provider diagnostics without retaining raw messages. A valid +poll atomically replaces the previous CodexBar provider set so disabled providers do +not remain as ghosts. A transport failure may preserve previous values as stale, while +an explicit provider failure clears that provider's old CodexBar windows. + +Fresh native Claude and Codex windows win overlapping slots. CodexBar fills missing or +stale native slots and contributes all non-overlapping windows, with provenance exposed +as native, CodexBar, or mixed. Render provider cards in a stable Claude, Codex, Grok, +Kimi, then alphabetical order; use an adaptive grid, retain unavailable provider cards, +and report explicit overflow rather than silently clipping data. `--once` and JSON +snapshots include the complete bounded provider/window set. + +Built-in `primary`/`secondary`/`tertiary` positions may shift when a provider omits a +shorter window. Before matching by slot ID, pair built-in native and CodexBar windows +one-to-one when both their nonempty durations and reset timestamps match exactly. Use +the CodexBar built-in ID in the combined view while retaining the winning values, +label, timestamp, and provenance. Apply the structural mapping only when all resulting +built-in IDs remain unique; otherwise fall back to one-to-one ID matching. Never +structurally collapse custom/extra windows, incomplete metadata, or fuzzy timestamps. + +CodexBar collection must never retain credentials, account email or organization, +credits, pace summaries, dashboard data, raw error text, or arbitrary provider fields. +Persist no CodexBar result to native quota caches. ### 1. Claude Code session discovery: process + config-root mapping @@ -147,13 +191,283 @@ Key line types: - **Partial line handling**: new bytes may end mid-JSON-line. Buffer incomplete lines until next read. - **File rotation**: if file shrinks (session restart), reset offset to 0 and re-scan. -### 3. Codex CLI sessions: `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` +### 3. Codex CLI sessions: native plugin hooks + local rollouts -Discovery strategy: -1. Find running `codex` processes via `ps` -2. Map PID → open `rollout-*.jsonl` file via `lsof` -3. Parse JSONL for `session_meta`, `token_count` (includes rate_limits), `agent_message` events -4. Detect finished sessions: scan today's directory for JSONL < 5 min old not owned by running process +On macOS and Linux, `abtop --setup-codex` installs an isolated local Codex plugin in the +active `${CODEX_HOME:-~/.codex}`. Windows keeps read-only process/rollout metrics but +reports Codex lifecycle as unmanaged `Unknown`; setup fails before mutation there. +The integration must never alias, wrap, replace, or proxy the native +`codex` command. Plain `codex`, `codex resume`, `codex fork`, `codex --yolo`, and every +other native argument retain their normal behavior. + +Plugin layout: + +```text +$CODEX_HOME/abtop/marketplace/ +├── .agents/plugins/marketplace.json +└── plugins/abtop/ + ├── .codex-plugin/plugin.json + ├── hooks/hooks.json + └── scripts/abtop-codex-hook.{sh,cmd} +``` + +Setup and migration rules: + +1. Resolve and retain the exact lexical `codex` entry selected from `PATH` whose + invocation proves a stable `codex-cli` release at least 0.145.0; do not canonicalize + away argv-sensitive shims. +2. Safely remove only legacy shell blocks delimited by + `# >>> abtop managed codex >>>` and `# <<< abtop managed codex <<<`. A missing block + is success. Malformed, unmatched, or duplicate markers fail closed without changing + the file. Preserve unrelated profile content, aliases, and functions. Apply the same + exact-marker policy to zsh, bash, and fish. Windows setup exits before migration; + successful Windows uninstall may provide exact manual PowerShell cleanup guidance. + On macOS/Linux, lock and revalidate every target, use atomic replacements, and perform + a lost-update-safe rollback if migration cannot complete. Serialize Unix scans and + edits with the stable, content-free mode-`0600` + `~/.abtop-codex-migration.lock`. Status inspection may create this file; retain its + inode so concurrent processes cannot lock different replacements. The non-Unix lock + is a no-op. When zsh is the active shell, setup, uninstall, and status inspection + may run strictly framed, output-bounded login and non-login probes so an unexported + custom `ZDOTDIR` is included. Those probes evaluate normal zsh startup files but + persist none of their content. +3. Serialize setup and uninstall with the stable + `$CODEX_HOME/.abtop-codex-plugin.lock`. On Unix require a same-owner regular file with + mode `0600`, revalidate its inode while held, and retain it after uninstall so + concurrent administrative processes cannot lock different replacements. Treat + `$CODEX_HOME/abtop/.setup.lock` only as legacy source-bundle debris that may be removed + during verified owned-source cleanup; it is never the current lock. +4. Require stable `codex-cli` 0.145.0 or newer, exact `hooks stable true` and + `plugins stable true` feature rows, and all supported 11 uppercase + `ManagedHooksRequirements.properties` in generated + `v2/ConfigRequirementsReadResponse.json`; additional event properties are compatible + and must not expand the installed abtop hook set. + The short-lived `codex app-server generate-json-schema` preflight is schema inspection + only; it is never a relay, supervisor, monitoring transport, or persistent daemon. + Give every native administrative command null stdin, a 15-second overall timeout, and + independent 1 MiB stdout/stderr caps. On Unix use a separate process group, + nonblocking pipe drains, group termination on timeout or a pipe retained for 100 ms + after leader exit, and bounded reap. On Windows use a kill-on-close Job Object when + available plus direct-child fallback; portable reader channels must never be joined + indefinitely. Bracket every mutating invocation with the exact selected executable + identity digest. + Generate the marketplace/plugin bundle with private permissions. Its version and + hook command incorporate the hook schema revision and helper identity digest. +5. Use native `codex plugin marketplace add --json` and + `codex plugin add abtop@abtop-local --json`, then verify that the plugin is installed + and enabled. Never write trusted hashes or bypass hook trust. +6. Tell the user to restart plain native Codex and approve only the 11 hooks attributed + to `abtop@abtop-local`. Rerun setup after every Codex CLI update so the version, + features, and generated schema are checked again. A changed abtop executable path or + byte content changes the helper identity; require a fresh review whenever Codex + presents the changed identity. Only sessions started after that restart may contribute + hook-refined status from the currently installed and reviewed helper identity. + +`abtop --uninstall-codex` applies the same exact-marker cleanup and preserves the +content-free plugin-data directory. It always invokes removal of the reserved +`abtop@abtop-local` plugin ID first, even if the marketplace is missing, malformed, or +conflicting, and verifies that registration is absent before marketplace/source cleanup. +It may remove marketplace registration/source only after proving the exact owned local +source, then verifies marketplace and plugin absence again before deleting source; +otherwise preserve them and return manual-recovery guidance. It retains the stable root +setup lock and the plugin-data tree. Unlike +setup/status compatibility, uninstall accepts any exact stable `X.Y.Z` Codex semver as a +recovery path after a native Codex downgrade or upgrade. +`abtop --codex-integration-status` audits compatibility, source and cached bundles, +attestation, declaration, installation, base trust/enablement, and legacy cleanup. The +base audit is diagnostic installation evidence, not proof of a live thread's effective +in-memory hook engine: profiles, SessionFlags, project/config-lock layers, managed +policy, and live reload can differ. It +exits 0 only when healthy and 1 when not ready or inspection fails; malformed singleton +administration invocations exit 2. A healthy base audit never overrides a per-process +profile, trust bypass, or command-line/config hook override; that process remains +`Unknown`. `abtop --setup` remains Claude-only. Existing global Codex hooks, `notify`, +OpenTelemetry, plugins, and unrelated configuration must remain unchanged. + +The plugin installs these 11 matcherless hooks, each synchronous, silent, and limited +to one second: + +- `PreToolUse` +- `PermissionRequest` +- `PostToolUse` +- `PreCompact` +- `PostCompact` +- `SessionStart` +- `SessionEnd` +- `UserPromptSubmit` +- `SubagentStart` +- `SubagentStop` +- `Stop` + +Do not install `PostToolUseFailure`; events outside abtop's required 11-event contract +are ignored even when a newer Codex release advertises them. The launcher creates the no-clobber fault marker and invokes the hidden +abtop ingest command; the ingest helper drains JSON input. Both absorb all errors, and +after its bounded dispatch attempt the launcher always exits 0 without stdout or stderr, +regardless of helper success, failure, or absence, so a failed monitor cannot deny a +Codex action. + +Release 0.6 retains hidden `abtop codex -- ...` only as a no-relay compatibility +trampoline for already-loaded legacy wrapper functions. It requires +`ABTOP_MANAGED_CODEX_BINARY`, invokes that exact captured binary with exact arguments +and standard streams, and preserves its exit behavior. Unix process replacement also +preserves native signal behavior. It has no fallback, argument allowlist, manifest, or +monitoring role. Remove it in 0.7 and never present it as the normal launch path. + +Hook ingest and state rules: + +1. Stream at most 4 MiB through serde without materializing the raw payload. Accept at + most 256 root fields, 512-byte lifecycle IDs, a 16 KiB cwd, and other bounded + allowlisted scalars. Skip all other values with `IgnoredAny` and attempt to drain to + EOF after malformed JSON. Prompt text, tool input/output, last-assistant text, raw + argv/commands, environment/authentication, transcript paths, and arbitrary provider + text must never enter state. +2. Store state only below `$CODEX_HOME/plugins/data/abtop-abtop-local`, using private + mode-0700 directories and mode-0600 files on Unix, same-owner validation, no-follow + opens, symlink-escape rejection, locks, and same-directory atomic replacements. +3. Treat `session_id` as the shared root identity on root and descendant hooks. Treat + `agent_id` only as a child-agent identity and fold child lifecycle into the shared + root state; never create a separate session from it. +4. Bound all state and retain at most 128 content-free samples. Persist only + schema/helper/install identities, session/turn/tool/subagent IDs, canonical event + kind and tool class, cwd when correlation requires it, timestamps, exact PID/start + incarnations, and faults/open sets. +5. Before starting abtop, the POSIX launcher must first use `mktemp` to exclusively + create an empty private mode-`0600` + `launch--pending.<16-alphanumeric-nonce>` marker in the embedded fault + directory. Only if unique allocation fails may it use the 16 fixed no-clobber + `launch--abtopv1.pending` names; persistent `overflow.json` is the exhaustion + fallback. The helper anchors the directory, adopts only that exact marker/inode, and + enriches it to bounded content-free JSON with a fresh random 128-bit per-adoption + commit ID before attestation, ancestor resolution, parsing, or folding. A + missing/rejected token independently attempts a generic `hook-.json` marker. + Commit the marker basename plus commit ID with the folded state before unlinking that + same marker. Preserve valid commit proofs across clean startup/resume/clear boundaries; + reject legacy basename-only proofs so fixed-name reuse cannot impersonate another + invocation. + An empty/malformed marker is global `Unknown`; a timeout, failed launch, crash, + malformed event, changed config/helper, or unsafe association must never preserve a + stale positive row. Bound fault/state/temp files. Before deleting generation state, + persist the writer's first GC-side confirmation that the exact process incarnation is + gone. That observation is only a deletion grace anchor and never authorizes `Done`. + Deletion requires a later writer pass strictly more than 30 seconds afterward and a + fresh exact-incarnation gone check. Capacity pressure starts this sequence only after a + terminal generation is at least 30 seconds old or a crashed nonterminal generation is + strictly older than 24 hours; normal cleanup runs on a later `SessionEnd` and requires + the strict 24-hour gate for either. After payload drain, every later ingest may reclaim + strictly older-than-24-hour state/fault temporary files, malformed or abandoned + fixed-slot markers, and ordinary fault markers only from a complete validated state + snapshot that remains unchanged across out-of-lock process-death probes, with every + affected process incarnation confirmed gone. Collector reads never clean state, + `overflow.json` is permanent and monotonic, and a clean `SessionStart` never deletes + failure evidence. +6. Because hook input has no PID, identify the nearest eligible native Codex ancestor + and double-read its process incarnation. Exclude app-server/daemon, `mcp-server`, + Desktop, and remote-control hosts. An npm wrapper can support discovery but the state + must bind to its native Codex child. Shared-daemon hooks and PID ambiguity are + `Unknown` and inactionable. Never start or attach to a daemon. +7. Keep action PID/session ownership separate from lifecycle evidence. Unknown ownership + disables kill and PID-based terminal jump even when a lifecycle edge is otherwise + useful. Independently, one unique exact Herdr native provider/session reference may + authorize focus only; it never authorizes a PID action. + +Supported Codex releases expose no thread/PID/generation-bound attestation of the effective hook +engine after profile, project/config-lock, managed/cloud, command-line, per-thread, and +live-reload layers are applied. Base installation integrity, trust/enablement, individual +hook events, and rollout correlation are not live-coverage proof. Production must keep +`effective_hook_engine_attested = false`. Exact supported lifecycle shapes may project +as non-actionable `Thinking / Heuristic`, `Executing / Heuristic`, or +`Idle / Heuristic`; incomplete or ambiguous shapes remain `Unknown / Unavailable`. + +Exit proof requires exact installation, process, session, and event correlation. Require +the matched process-owned root rollout to report a stable `cli_version` at least 0.145.0, +and require every discovered descendant rollout to report the same version. Missing, +older, malformed, child-only, or descendant-mismatched version metadata is insufficient. Then +apply this matrix: + +- A previously observed exact live PID/start ↔ supported-version rollout-tree binding, + followed by that same process incarnation changing from live to gone, creates a + bounded, content-free, non-actionable in-memory tombstone + → `Done / Heuristic` through 30 seconds from the collector's transition observation. + Source-state disappearance or an unavailable scan may preserve that tombstone, but a + collector whose first observation is already gone, numeric-PID reuse without exact + incarnation continuity, or an unavailable scan cannot create one. Once an exact + process scan confirms it gone, omit a hook-only generation without that observed + transition instead of retaining PID-zero `Unknown` or fabricating `Done`. +- A root open tool, a child open tool, `PermissionRequest`, or a + `request_user_input` candidate → `Unknown / Unavailable`. `PreToolUse` runs before a + separately configurable permission edge, so an open hook/rollout call cannot prove + that execution began. +- An exact supported root turn with no open tool and exact-terminal descendants → + non-actionable `Thinking / Heuristic` with `HookTurnOpen`. +- A complete exact active direct-child model set with no open child tool → + non-actionable `Executing / Heuristic` with `HookSubagentActive`. +- An exact supported stop plus matching rollout completion and a clean tree → + non-actionable `Idle / Heuristic` with `HookTurnComplete`. +- Every `SessionStart` source is generation evidence only and is never sufficient for + `Idle`. +- Rollout `stream_error` or `error`, a `task_complete` carrying a terminal error, any + unparseable open descriptor, or a nonterminal non-selected root tree invalidates + rollout lifecycle and cannot seed new exit proof. A direct-child + active/terminal/provisional mismatch or a child `PreToolUse` without its exact close is + also live `Unknown`, without treating incomplete child lifecycle as work or rest. +- `Stop` alone, hosted/uncovered/background tools, missing/out-of-order events, failure + without an exact close, incomplete/aborted/stale/duplicate/mismatched child lifecycle, + unsupported active/non-direct child lifecycle, config drift, and PID/session ambiguity + → `Unknown / Unavailable`. Exact-terminal nested descendants remain internal + correlation evidence only and never authorize public live-status promotion. + +Codex queues startup, resume, and clear `SessionStart` hooks into the next turn before +`UserPromptSubmit`; those sources reset the generation. Compact `SessionStart` follows +`PostCompact` inside the current turn and preserves active work. No source is `Idle` +proof. A newly opened empty composer can have no evidence and remains `Unknown`. + +`Stop` and `SubagentStop` are provisional: another hook can block them, and the same +actor can continue within the turn. Later matching activity reopens the actor. A child +stop closes internally only after exact matching child `task_complete`; later active +child model work reopens the candidate. Only a complete exact active direct-child set +can produce non-actionable heuristic `Executing`. + +Supported Codex releases do not expose sufficient prompt-display and resolution lifecycle to label +approvals or questions safely. Therefore abtop deliberately reports those states as +`Unknown`, never guesses `Waiting`, and never leaves a stale `Executing` label. Hook data +does not create live Codex `Error` or `RateLimited`; account rate limits remain quota +metadata. Never infer live status from elapsed time, CPU use, transcript/rollout mtime, +token activity, or uncorrelated child-process activity. + +Codex-specific content-free `StatusReason` values include +`HookIntegrationUnverified`, `HookConfigChanged`, `HookEventGap`, +`HookStateMalformed`, `HookInteractionResolutionUnavailable`, `HookToolOpen`, +`HookSubagentActive`, `HookTurnOpen`, and `HookTurnComplete`; generic failure/transition +paths can also use `OwnershipUnconfirmed` and `ProcessExited`. Hook evidence always uses +`connection_generation = 0`. + +For Codex sessions hosted by Herdr, abtop may supplement those rules with Herdr's +private terminal detector. Require two unchanged bounded `agent list` snapshots around +`pane process-info`, one exact `herdr:codex` session-ID match, the exact native Codex +PID, and an unchanged process incarnation. `blocked` produces non-actionable +`Waiting / Heuristic`; `idle` produces non-actionable `Idle / Heuristic`; `working` +produces `Thinking` or `Executing` only when the exact Codex lifecycle candidate refines +it, otherwise generic non-actionable `Working / Heuristic` with +`HerdrWorkingUnrefined`. Never ingest terminal titles or screen content. Missing Herdr, +timeouts, oversized or malformed output, duplicate matches, failed snapshot bracketing, +pane movement, and PID reuse remain `Unknown` and clear continuity. Herdr reasons are `HerdrScreenBlocked`, +`HerdrScreenWorking`, `HerdrScreenIdle`, and `HerdrWorkingUnrefined`; Herdr evidence +also uses `connection_generation = 0`. + +Codex CLI processes without a valid current plugin state, Codex Desktop, and other +hosted sessions remain discoverable through shared process/open-file evidence and local +rollouts. They cannot supply hook-refined `Thinking` or `Executing`; one uniquely and +exactly correlated Herdr terminal may still supply `Waiting`, `Idle`, or generic +`Working`. Otherwise their live status is `Unknown`. + +Rollout files live at +`${CODEX_HOME:-~/.codex}/sessions/YYYY/MM/DD/rollout-*.jsonl`. Parsed rollouts are +cached only after descriptor-bracketed validation of the canonical path, file identity, +length, modification time, and platform change/creation time. The bounded cache +invalidates on append, replacement, same-length rewrite, or pathname/descriptor drift so +a process holding many dormant subagent descriptors does not force a full rescan every +tick. Build the `parent_thread_id` graph and aggregate descendants into the selected root +row instead of showing subagents as duplicate top-level sessions. Rate limits extracted from `token_count` events: ```json @@ -167,17 +481,85 @@ Rate limits extracted from `token_count` events: } ``` +- Rollout tool/request records remain useful for metrics and display metadata, but never + determine Codex status without a matching hook lifecycle edge. +- A previously observed exact live PID/start ↔ supported-version rollout-tree binding, + followed by that same incarnation changing from live to gone, produces heuristic `Done` + through 30 seconds from the collector's transition observation. A bounded, + content-free, non-actionable in-memory tombstone preserves the row if source state + disappears. An unavailable scan may retain existing proof but cannot create it; a + collector whose first observation is already gone or numeric-PID reuse without exact + incarnation continuity never produces `Done`. Once the exact incarnation is confirmed + gone, omit an unproven hook-only generation instead of displaying PID-zero `Unknown`. + Independently, writer-side GC persists + its first exact-incarnation gone confirmation and may delete state only on a later pass + strictly more than 30 seconds afterward after a fresh gone check. Capacity pressure + begins that sequence only once terminal state is at least 30 seconds old or crashed + nonterminal state is strictly older than 24 hours; normal cleanup runs on a later + `SessionEnd` and requires the strict 24-hour gate for either. Collector reads never + perform cleanup. +- Historical rollout files must never create PID-zero `Done` rows. + ### 4. OpenCode sessions: `~/.local/share/opencode/opencode.db` - Discover running `opencode` processes via shared `ps` data. - Read recent sessions from OpenCode's SQLite DB through `sqlite3 -readonly -json`. +- Read only the latest message/tool-part lifecycle on fast ticks; never select raw prompt, tool input, or tool output data for status detection. - Match live PIDs to DB sessions by process cwd. OpenCode does not expose a PID/session mapping, so when multiple DB rows share one cwd, only live PIDs should be assigned and older rows should not be shown as live duplicates. -- OpenCode contributes session/token/project/port data, but not quota data. Quota remains Claude + Codex only. +- OpenCode contributes session/token/project/port data, but no native quota data. + Independently, display any provider returned by the enabled CodexBar integration. + +### 5. Grok sessions: `${GROK_HOME:-~/.grok}/sessions/` + +Discovery strategy: +1. Find running `grok`, `agent`, `xai-grok-pager`, and managed Grok platform binaries via shared `ps` data. Exclude leader PIDs using Grok's `leader*.lock` files; command text alone is insufficient because some platforms flatten argv boundaries. +2. Read `${GROK_HOME:-~/.grok}/active_sessions.json`, whose entries contain `session_id`, `pid`, `cwd`, and `opened_at`. +3. Verify the PID still belongs to Grok and that its process start predates `opened_at` before trusting the registry entry. This guards against stale entries and PID reuse. +4. Read the session's `summary.json`, `signals.json`, append-only `updates.jsonl`, optional append-only `events.jsonl`, and `plan_mode.json`. +5. Headless mode is visible when Grok registers it in the same active-session registry (for example with `GROK_TRACK_HEADLESS`). Unregistered processes are not guessed from cwd alone; leader/ACP hosts that do not represent a user-owned session are excluded. + +Important behavior: +- `summary.json` supplies session identity, cwd, timestamps, current model, and title metadata. +- `signals.json` is preferred for current context tokens/window and turn count. +- `updates.jsonl` supplies durable token usage, tool lifecycle, status, and subagent events. Tail it incrementally and tolerate both wrapped ACP records and xAI-specific events. +- `events.jsonl` pairs permission requests and resolutions. An unmatched request from the current registry-open interval sets `awaiting_input`; malformed or unavailable event logs must not hide an otherwise valid session. +- An unresolved canonical `ask_user_question` tool or `plan_mode.json` with `awaiting_plan_approval = true` also marks the session as waiting for user input. +- A Grok TUI process can own multiple sessions. Show every registered session, but attribute process memory, children, and ports only to its most recently active row so aggregate values are not double-counted. +- Killing any row backed by a shared PID kills the process and therefore all sessions it owns. The confirmation message must state how many sessions will be affected. +- The Grok session collector contributes no native quota data. Grok account quota may + come from the explicitly enabled CodexBar integration. + +### 6. Kimi Code sessions: `${KIMI_CODE_HOME:-~/.kimi-code}/sessions/` + +This collector targets the current [Kimi Code](https://github.com/MoonshotAI/kimi-code) CLI. The retired `MoonshotAI/kimi-cli` format under `~/.kimi` is out of scope. + +Discovery strategy: +1. Find running `kimi-code`, `kimi`, and the Kimi Code Node wrapper via shared `ps` data; exclude plugin-runner, ACP, and web host modes when their arguments are visible. +2. Fold append-only `${KIMI_CODE_HOME:-~/.kimi-code}/session_index.jsonl`, including deletion tombstones. +3. Validate that every indexed session path stays below the configured `sessions/` root, contains no symlink escape, and has a basename matching the session ID. +4. Read per-session `state.json` plus `agents/main/wire.jsonl`. Accept current v1/v2 state timestamp and cwd field variants. +5. Prefer an explicit session ID from the process command. Otherwise correlate within the process's own root by cwd and recent activity, keeping assignments stable between polls and following a newer post-start activity edge when one PID switches sessions in place. + +Kimi Code has no authoritative PID/session registry and rewrites its process title to `kimi-code`, which can hide both session flags and host subcommands. A unique root+cwd mapping becomes actionable only after an explicit match or session activity at/after the process start; idle old resumes remain `Unknown`. If multiple live Kimi processes share one root+cwd, pair rows deterministically but keep ownership `Unknown`. Unknown rows cannot be killed or terminal-jumped from abtop. Use separate worktrees when authoritative live ownership is important. + +Wire behavior: +- Sum only `usage.record` events for lifetime tokens; `step.end.usage` duplicates those totals. +- Derive current tools and status from interaction, turn, step, tool-call, tool-result, and validated task-lifecycle records. Every unresolved `AskUserQuestion`, whether foreground or background, and every validated running `question` task, including detached tasks, sets `awaiting_input` and reports `Waiting`. Exact resolution, cancellation, tool completion, or a terminal task snapshot clears the corresponding wait. +- Resolve context limits from the active model alias in Kimi Code's TOML configuration. If no reliable limit is available, keep `context_window = 0` and display `—` instead of guessing. +- Build subagents from state topology, task lifecycle records, and child agent wires. +- The Kimi session collector contributes no native quota data. Kimi account quota or a + sanitized unavailable state may come from the explicitly enabled CodexBar integration. -### 5. Subagents: `~/.claude/projects/{path}/{sessionId}/subagents/` +Custom Grok and Kimi roots are discovered from `GROK_HOME` / `KIMI_CODE_HOME` inherited by abtop. abtop also makes a best-effort platform-specific read of each candidate process environment; launch abtop with the same environment as the agent when the operating system does not expose it. + +### 7. Subagents: provider-local sources + +Claude Code stores subagents under `~/.claude/projects/{path}/{sessionId}/subagents/`: - `agent-{hash}.jsonl` — same JSONL format as main transcript - `agent-{hash}.meta.json` — `{ "agentType": "general-purpose", "description": "..." }` -### 6. Process tree: `ps` + `lsof` +Grok emits subagent lifecycle updates and may persist child sessions below a parent's `subagents/` directory. Kimi Code stores each child agent wire under `agents/{agentId}/wire.jsonl` and records its topology in `state.json`. + +### 8. Process tree: `ps` + `lsof` ```bash ps -eo pid,ppid,rss,%cpu,command # All processes lsof -i -P -n -sTCP:LISTEN # Open ports @@ -185,20 +567,22 @@ lsof -i -P -n -sTCP:LISTEN # Open ports - Build parent→children map from ppid - Map listening PID → parent agent PID → session -### 7. Git status per project +### 9. Git status per project ```bash git -C {cwd} status --porcelain # added/modified file counts ``` -### 8. Memory status +### 10. Memory status - Path: `~/.claude/projects/{encoded-path}/memory/` - Count files in directory + lines in `MEMORY.md` -### 9. Rate limit (Claude Code) +### 11. Rate limit (Claude Code) NOT in transcript JSONL. Collected via StatusLine mechanism. `abtop --setup` automates this: creates a script at `~/.claude/abtop-statusline.sh` that writes rate limit JSON to `~/.claude/abtop-rate-limits.json`, and registers it in `~/.claude/settings.json`. +This command is Claude-only. It does not inspect, preflight, or modify Codex. Use the +separate explicit `abtop --setup-codex` command for Codex plugin integration. File format read by abtop: ```json @@ -214,83 +598,185 @@ File format read by abtop: - Account-level metric, shared across all sessions. - Show "—" when not configured or data unavailable. -### 10. Other files +### 12. Other files - `~/.claude/stats-cache.json` — daily aggregates. Only updated on `/stats`, NOT real-time. - `~/.claude/history.jsonl` — prompt history with sessionId. ## Session Status Detection ``` -● Working = PID alive + transcript mtime < 30s ago -◌ Waiting = PID alive + transcript mtime > 30s ago -✗ Error = PID alive + last assistant has error content -✓ Done = PID dead (detected via kill(pid, 0) failure) +◉ Think = model turn is open and no tool is currently running +● Exec = trustworthy provider-specific lifecycle evidence proves active work +◐ Work = exact terminal evidence proves activity but cannot classify its kind +◌ Wait = an exact unresolved interaction requires user action +○ Idle = live session has no current model, tool, task, or interaction work +? Unknown = trustworthy current lifecycle proof is insufficient +⏳ Rate = provider reports an active account-level rate limit +✗ Error = provider reports a live fatal session/turn failure +✓ Done = verified process has exited ``` -**Done detection**: session files are deleted on normal exit, but may linger briefly or survive crashes. When PID is dead but file exists, show as Done and clean up on next tick. - -**PID reuse risk**: verify PID is still the expected agent process (Claude, Codex, or OpenCode) by checking `ps -p {pid} -o command=`. Don't trust PID alone. +For Codex, an open root or child tool is deliberately `Unknown`, not `Exec`, because the +supported hook contract cannot prove whether permission is still pending. A stable, +exactly correlated Herdr `working` state can independently confirm activity and refine +an exact root tool to `Exec`; when exact Herdr activity cannot be refined safely it +reports generic non-actionable `Working` instead. Without that terminal evidence the open +tool remains `Unknown`. Exact active direct-child model work can produce non-actionable +heuristic `Exec`. + +After ownership/liveness and protocol validation, exact status precedence is `Waiting` > +`RateLimited` > `Error` > `Executing` > `Thinking` > `Working` > `Idle`. `Working` is +only the generic fallback for exact activity that cannot be refined to `Thinking` or +`Executing`. A real user-action wait wins when background work continues at the same time. `Unknown` covers any case without +sufficient trustworthy lifecycle proof, including ownership ambiguity, stale evidence, +disconnects, malformed protocol state, and failed validation. Never infer `Waiting` from +elapsed time, transcript mtime, or low CPU usage. + +Every row carries `StatusEvidence`: authority (`Provider`, `Heuristic`, or `Unavailable`), +a machine-readable reason, observation/status-since timestamps, connection generation, +consecutive matching count, and a bounded content-free sample history. Codex hook +evidence always uses connection generation zero and is at most `Heuristic`; unsafe or +insufficient Codex evidence is `Unavailable`. The selected-session detail and `--once` expose current +authority, reason, observation time/freshness, connection generation, consecutive +matching count, and the statuses of the latest five samples. JSON snapshots include the +same current fields and the latest five complete content-free samples for audit. + +**Done detection**: Codex requires this collector instance to have previously observed an +exact live PID/start ↔ supported-version rollout-tree binding, followed by that same +process incarnation changing from live to gone. It retains a bounded, content-free, +non-actionable in-memory tombstone through 30 seconds from that observation, including if +source state later disappears. An unavailable scan may preserve an existing tombstone but +cannot create one; a collector whose first observation is already gone or numeric-PID +reuse without exact incarnation continuity never fabricates `Done`. Once an exact process +scan confirms it gone, omit a hook-only generation without that observed transition +instead of retaining PID-zero `Unknown` or relabeling it `Done`. Historical Codex rollouts +never become PID-zero Done rows. Other providers use their documented process or registry +evidence and may disappear immediately after verified exit. + +**PID reuse risk**: verify the exact process incarnation and provider-native live argv +before jumping or killing. For Grok registry entries, also compare process start time +with `opened_at`. Never trust PID alone. Current task (2nd line under each session): -- Working → last `tool_use` name + first arg (e.g. `Edit src/main.rs`) +- Thinking → "thinking" +- Executing → provider-safe work preview. Codex reaches this only from an exact active + direct-child shape or Herdr-visible work refined by exact tool/child evidence. +- Working → "working"; Codex uses it only for exact, stable, unrefined Herdr activity. - Waiting → "waiting for user input" -- Error → last error message (truncated) -- Done → "finished {duration} ago" - -**Known limitations** (all heuristic): -- Cannot distinguish model-thinking vs tool-executing vs rate-limit-waiting vs permission-prompt -- "Waiting" may be wrong if a long-running tool (cargo build, npm test) is running -- Status is best-effort, not authoritative +- Idle → "idle" +- Error → canonical provider failure label; never raw provider error content +- Unknown → insufficient-evidence warning; killing is disabled +- Done → "finished" + +**Known limitations** (all best-effort): +- Explicit provider interaction signals take precedence, but not every provider persists every kind of wait. +- Codex hook evidence is never provider-authoritative. A missing, stale, unsafe, + malformed, ambiguous, or uncorrelated live hook generation becomes `Unknown`. A + process-confirmed-gone hook-only generation without exact transition proof is omitted. +- Plain Codex may use a local or remote shared app-server daemon. Hooks then run below + that shared host rather than the client TUI, so they cannot be bound to its PID and + remain `Unknown` and inactionable. +- Supported Codex approvals and questions are deliberately `Unknown`, not `Waiting`, because + exact prompt-display/resolution lifecycle is unavailable. +- Supported Codex releases expose no thread/PID/generation-bound attestation of the effective hook + engine. Base hook trust and enablement are installation diagnostics only. Exact + supported root-turn, direct-child, and completed-turn shapes may provide + non-actionable heuristic `Thinking`, `Executing`, and `Idle`. Root and child open tools + remain `Unknown` unless stable, exactly correlated Herdr work independently confirms + activity; exact Herdr work that cannot be refined reports generic non-actionable + `Working / Heuristic`. Only an independently exact supported process/session `Live → Gone` + transition can report non-actionable heuristic `Done` for 30 seconds. +- Herdr status is optional and local. Require stable bounded snapshots, exact + `herdr:codex` session identity, exact pane PID, and unchanged process incarnation. + Failed bracketing, duplicates, malformed output, pane movement, PID reuse, and timeouts + remain `Unknown`. Never retain stale Herdr state or ingest terminal titles or screen content. +- Never infer Codex status from rollout ordering/content alone, token activity, mtime, + CPU use, child processes, or open tool records. Those sources provide metrics and + correlation metadata only. +- OpenCode does not persist live permission prompts in SQLite, so abtop cannot label those waits authoritatively. +- Kimi v1 does not persist ordinary tool-approval prompts. Those remain `Executing`; abtop does not guess from elapsed time or process inactivity. +- A long-running Codex root tool without a provider execution-start signal remains + `Unknown`; never turn an open call into `Executing` merely because it persists. +- Provider status is best-effort unless its evidence explicitly says `Provider`; Codex + exact hook/rollout and Herdr terminal displays are capped at `Heuristic`; incomplete + evidence is `Unavailable`. +- Kimi Code does not provide an authoritative PID/session map. Same-cwd ambiguity is reported as `Unknown` ownership and disables killing. +- One Grok process may own several sessions. Process resources are attributed once, and killing that PID stops all of its sessions. +- Grok and Kimi context percentages are unavailable when their local signals/configuration do not expose a reliable window size. ## Session Summary Generation Each session gets a one-line summary title generated via `claude --print`: - Spawned as background process with 10s timeout -- Rejects generic/empty output; falls back to sanitized first prompt (28 chars) -- Cached to `~/.cache/abtop/summaries.json` (persists across runs) +- Sends at most 200 characters each from the first user and first assistant text +- Rejects generic/empty output; falls back to a sanitized 80-character excerpt +- Caches only generated titles or sanitized fallbacks in the platform cache directory + (`${XDG_CACHE_HOME:-~/.cache}/abtop`, `~/Library/Caches/abtop`, or + `%LOCALAPPDATA%\abtop`) - Max 3 concurrent summary jobs, max 2 retries per session +- `App::tick_no_summaries()` and `abtop --json` never spawn summary jobs ## Context Window Calculation -Not provided in data files. Derive: -- **Window size**: hardcode by model name - - `claude-opus-4-6` → 200,000 (default) - - `claude-opus-4-6[1m]` → 1,000,000 - - `claude-sonnet-4-6` → 200,000 - - `claude-haiku-4-5` → 200,000 -- **Current usage**: last `assistant` line's `input_tokens + cache_read_input_tokens`. `cache_creation_input_tokens` is intentionally excluded — on compaction turns the same tokens can be reported as both `cache_creation` *and* `cache_read`, and summing all three double-counts (#54). Matches Claude Code's own statusline and the Codex collector. -- **Percentage**: current_usage / window_size * 100 -- **Warning**: yellow at 80%, red at 90%, ⚠ icon at 90%+ +Context sources are provider-specific: + +- Claude uses 200,000 by default and 1,000,000 when the transcript/configured model + contains `[1m]` or observed context exceeds 200,000. Current usage is normally + `input_tokens + cache_read_input_tokens`; for a fresh session with zero cache read and + nonzero cache creation, use `input_tokens + cache_creation_input_tokens`. This avoids + double counting compaction turns while representing the initial cache correctly. +- Codex uses the rollout's explicit `model_context_window` and last token-usage record. +- OpenCode estimates 200,000, or 1,000,000 for a `[1m]` model. +- Grok uses local signal fields and Kimi uses its resolved local model configuration. + Unknown provider limits remain unavailable rather than guessed. + +Context percentage is current usage divided by the validated window. The Context panel +shows `!` at 75% and `⚠` at 90%; bar color follows the theme gradient. ## Orphan Port Detection Tracks child processes that have open ports. When a parent session dies but the child process remains alive and listening: - Added to `orphan_ports` list automatically - Displayed in ports panel under "ORPHAN PORTS" section -- Can be killed via `X` (Shift+X) with safety checks (fresh port scan + PID command verification before SIGKILL) +- Can be killed via `X` (Shift+X) only after a fresh port scan, exact PID/command + verification, and the platform termination path ## Key Bindings | Key | Action | -|-----|--------| -| `↑`/`↓` or `k`/`j` | Select session in list | -| `Enter` | Jump to session terminal (cmux / tmux / iTerm2) | -| `x` | Kill selected session (SIGKILL) | -| `X` | Kill all orphan ports | -| `q` | Quit | -| `r` | Force refresh | +| --- | ------ | +| `↑`/`↓`, `k`/`j` | Select the previous or next visible session | +| `Enter` | Jump by exact Herdr session identity, or by actionable PID after fresh validation | +| `/` | Enter filter mode; `Backspace` edits, `Enter` keeps, `Esc` clears | +| `x` twice within two seconds | Kill the same freshly revalidated session/process incarnation | +| `X` | Freshly rescan and kill validated orphan-port owners | +| `r`, `q` | Force refresh or quit | +| `t`, `T`, `l`/`L`, `f`/`F` | Cycle theme, toggle tree, timeline, or file audit | +| `1`–`7`, `M` | Toggle/persist panels or toggle runtime MCP-session suppression | +| `c`, `v`, `?` | Open config, view menu, or help | + +In the narrow layout, arrows/Tab cycle visible tabs, `w`/`u`/`s` select Work / +Usage / System, `+` or `=` maximizes the active section, and `-` restores the split. +Demo mode uses the default panel set and default theme unless `--theme` is explicit; it +ignores persisted hidden-agent, custom Claude-root, visibility, and theme settings. It +disables keyboard `r`, `x`, `X`, and `Enter`; mouse orphan cleanup remains live when +`--demo --mouse` is explicitly used. ## Tech Stack - **Rust** (2021 edition) - **ratatui** + **crossterm** for TUI - **serde** + **serde_json** for JSON/JSONL parsing +- **toml** for native Codex configuration and plugin inspection - **chrono** for timestamp formatting - **dirs** for home directory resolution +- **tempfile**, **sha2**, and **getrandom** for bounded private integration state +- Platform support through **libc**, **proc_pidinfo**, **sysinfo**, and **windows-sys** - **Polling intervals** (staggered to avoid freezes): - Session scan + transcript tail: every 2s - Process tree (ps): every 2s - - Port scan (lsof) + git status + rate limits: every 10s (5 ticks) + - Port scan + git status: every 10s, with immediate port invalidation when the PID set changes + - Rate-limit files: first tick and then approximately every 10s ## Commit Convention @@ -305,12 +791,32 @@ Types: `feat`, `fix`, `refactor`, `docs`, `chore` cargo build # Build cargo run # Run TUI cargo run -- --once # Print snapshot and exit -cargo run -- --setup # Install StatusLine hook for rate limit collection +cargo run -- --json # Print a summary-free JSON snapshot +cargo run -- --demo # Run deterministic, non-keyboard-destructive demo data +cargo run -- --mouse # Opt in to mouse capture and click/wheel targets +cargo run -- --theme btop # Launch-only theme override +cargo run -- --setup # Install the Claude quota hook only +cargo run -- --setup-codex # Install/repair the local Codex plugin (macOS/Linux) +cargo run -- --uninstall-codex # Remove the local abtop Codex plugin integration +cargo run -- --codex-integration-status # Audit the Codex plugin integration +codex # Native Codex command; never wrapped by abtop +codex resume # Native resume +codex fork # Native fork +codex --yolo # Native flags remain untouched cargo run -- --exit-on-jump # Quit after Enter-jumping to a session terminal (for popup overlays) +cargo fmt --all -- --check # Formatting verification cargo test # Tests -cargo clippy # Lint +cargo clippy --all-targets -- -D warnings # Strict lint +cargo build --release # Release build +scripts/agent-tui-e2e.sh --suite codexbar --abtop "$PWD/target/release/abtop" --codex-home "${CODEX_HOME:-$HOME/.codex}" # Isolated quota E2E +scripts/agent-tui-e2e.sh --suite codex-status --abtop "$PWD/target/release/abtop" --codex-home "${CODEX_HOME:-$HOME/.codex}" --workspace "$PWD" # Authenticated pre-trusted Codex/Herdr E2E +cargo publish --dry-run --allow-dirty # Package validation before the PR is committed ``` +Release 0.6's hidden `cargo run -- codex -- ...` path exists only so an already-loaded +legacy abtop wrapper can delegate to its captured native Codex binary. Do not use or +document it as a monitoring or launch workflow; remove it in 0.7. + ## Release Process 1. Pick the target semver version and update both `Cargo.toml` and `Cargo.lock`. @@ -344,7 +850,7 @@ cargo clippy # Lint **Do NOT push the tag before the version bump is on `main`.** **Do NOT reuse a release tag after a failed publish; bump to a new patch version instead.** -## Non-Goals (v0.1) +## Non-Goals - Gemini/Cursor support - Cost estimation @@ -353,7 +859,8 @@ cargo clippy # Lint ## Terminal Jump (`Enter`) -`Enter` focuses the terminal running the selected session's agent process. +`Enter` first attempts to focus an exact Herdr native provider/session reference, then +falls back to the terminal running the selected session's actionable agent process. The logic lives in `src/jump/` as a registry of `TerminalJumper` adapters (one file per backend). `jumpers()` is the single ordered source of truth; `resolve()` walks it and the first applicable adapter wins. @@ -364,37 +871,109 @@ Each adapter returns a three-way `JumpAttempt`: - `Failed(msg)` — this backend owns the process but the focus command errored; stop and surface `": "` in the status line. -Order (most specific first), mutually exclusive by controlling tty: - -1. **cmux** (`jump/cmux.rs`) — reads `CMUX_WORKSPACE_ID` (a UUID cmux exports - into every surface, inherited by the agent) from the process environment via - `ps eww`, then `cmux select-workspace --workspace `. -2. **tmux** (`jump/tmux.rs`) — only when abtop itself runs inside tmux (`$TMUX`). +Order (most specific first): + +1. **Herdr** (`jump/herdr.rs`) — when abtop runs inside Herdr, first matches the + selected provider and native session ID against one unique `agent_session` from + `agent list`. This focus-only proof works regardless of lifecycle status. If no + exact reference exists, the legacy path validates the selected actionable PID + against pane process inventory. The inherited pane ID remains that fallback's fast + path, and only the current Herdr server is searched. +2. **cmux** (`jump/cmux.rs`) — reads `CMUX_WORKSPACE_ID` plus optional panel, + bundled-CLI, and socket variables from the target process via `ps eww`; clears + unrelated inherited `CMUX_*` values, restores the target socket context, and runs + `cmux workspace select `. On macOS, a broken cmux socket attempts a bounded + AppleScript workspace/terminal focus fallback before reporting failure. +3. **tmux** (`jump/tmux.rs`) — only when abtop itself runs inside tmux (`$TMUX`). Maps PID → pane via `tmux list-panes -a -F '#{pane_pid} #{session_name}:#{window_index}.#{pane_index}'` + process-tree descent, then `switch-client` / `select-window` / `select-pane`. PID in no pane → `NotApplicable` (lets another backend try). -3. **iTerm2** (`jump/iterm2.rs`) — resolves the PID's controlling tty (`ps -o tty=`), +4. **iTerm2** (`jump/iterm2.rs`) — resolves the PID's controlling tty (`ps -o tty=`), then AppleScript selects the session whose `tty` matches and brings its window/app to the front. First call triggers a one-time macOS Automation permission prompt; until granted, `osascript` exits non-zero → `Failed`. -Parsing/registry logic is unit-tested in `jump/mod.rs`; the thin `ps`/`osascript`/ -`tmux` I/O wrappers are verified manually. +Herdr parsing and process matching, registry behavior, cmux command planning, socket +failure, and AppleScript fallback logic are unit-tested; the thin live CLI and platform +I/O wrappers are verified manually. ## Privacy -abtop reads transcripts, prompts, tool inputs, and memory files. These may contain secrets. -- **`--once` output**: redact file contents from tool_use inputs. Show tool name + file path only, not content. -- **TUI mode**: show tool name + first arg (file path), never show file contents or prompt text in session list. -- **No network**: abtop never sends data anywhere. All local reads. -- **Exception**: summary generation calls `claude --print` locally (no network by abtop itself, but claude may use its API). +abtop reads local session databases, registries, transcripts, prompts, tool inputs, and +memory files for all supported providers. These may contain secrets. + +- **`--once` output**: redact file contents from tool inputs. Show only the provider, + tool name, safe path/location previews, child command previews, and bounded redacted + summaries; never file contents or raw tool arguments/output. +- **TUI mode**: show the tool name, a safe path/location preview, and a bounded redacted + summary. The summary may fall back to the first prompt; never show file contents or raw + tool input/output in the session list. The selected-session detail may render the + collected, bounded `initial_prompt` field. +- **JSON snapshots**: include bounded, redacted chat and task metadata derived from local + records. Treat snapshots as private data because project context can remain after + redaction. +- **Codex plugin hooks**: stream at most 4 MiB without retaining the raw payload; + materialize only bounded lifecycle fields and discard every prompt, message, tool + input/output, last-assistant text, raw argv/command, transcript path, + environment/authentication value, and arbitrary provider string. State may contain + only schema/helper/install identities, session/turn/tool/subagent IDs, canonical + event/tool classes, cwd when required, timestamps, exact PID/start incarnations, + faults/open sets, and at most 128 content-free samples. State is not metadata-free; + treat cwd and stable IDs as private. +- **Codex state storage**: keep it only below + `$CODEX_HOME/plugins/data/abtop-abtop-local`, with mode-0700 directories and mode-0600 + files on Unix, same-owner and no-symlink validation, locks, and atomic same-directory + replacement. The POSIX launcher first precreates a unique private + `launch--pending.<16-alphanumeric-nonce>` marker before abtop starts; 16 + fixed no-clobber names are fallback only. Ingest adopts/enriches the exact inode with a + fresh random 128-bit commit ID and commits basename plus ID before exact removal. Valid + commit proofs survive clean generation boundaries; legacy basename-only proofs fail + closed. Before + deleting generation state, a writer persists its first GC-side exact-incarnation gone + confirmation; this is only a deletion grace anchor and never `Done` proof. Removal + requires a later pass strictly more than 30 seconds afterward plus a fresh gone check. + Capacity pressure begins that sequence only after terminal state is at least 30 seconds + old or crashed nonterminal state is strictly older than 24 hours; normal cleanup runs + on a later `SessionEnd` and requires the strict 24-hour gate for either. The collector + separately retains already-proven `Done` as a bounded, content-free, non-actionable + 30-second in-memory tombstone, but omits a process-confirmed-gone hook-only generation + when it never observed the exact transition. After draining its payload, every later ingest may + reclaim strictly stale temporary/fixed-slot/ordinary-fault artifacts only from a + complete, revalidated state snapshot after out-of-lock death probes. Collector reads + never remove state; persistent overflow remains permanent, monotonic, and fail-closed. +- **Codex setup isolation**: `abtop --setup-codex` may create an absent absolute + `CODEX_HOME`; write the isolated local marketplace/plugin source bundle and retained + private `$CODEX_HOME/.abtop-codex-plugin.lock`; create the content-free plugin-data + attestation, state, and fault tree; + cause Codex to write the installed cache and native plugin registration; remove exact + legacy profile blocks; and, on macOS/Linux, create the stable content-free migration + lock documented above. Setup and uninstall revalidate the stable root lock while held, + and uninstall retains it so contenders cannot lock different inodes. A source-local + `abtop/.setup.lock` is legacy cleanup only. Generated launchers embed only the exact + abtop executable and private data paths, never provider content. It never edits global + hooks, `notify`, OpenTelemetry, unrelated plugins, Claude configuration, `PATH`, or + the Codex executable. Migration removes only exact legacy abtop marker blocks; it + preserves arbitrary aliases, functions, and other profile content. Never bypass hook + trust or write trusted hashes on the user's behalf. +- **No implicit network**: collectors never send provider session data anywhere and do + not require API keys. `codexbar_quota_fallback = true` is an explicit exception: the + bounded CodexBar subprocess may use its configured automatic local, authenticated + web, OAuth, or API sources for all enabled providers. Retain only bounded provider + IDs, quota-window IDs/labels, percentages, window lengths, reset timestamps, + provenance, freshness, and fixed sanitized diagnostics. Never retain credentials, + account identity, credits, pace summaries, dashboard data, raw errors, or arbitrary + provider text. +- **Summary exception**: normal TUI ticks and `--once` pass up to 200 characters each + from the first user and first assistant text to local `claude --print`, which may call + Anthropic. The cache stores only generated summaries or sanitized 80-character + fallbacks. `App::tick_no_summaries()` and `abtop --json` do not launch summary jobs. ## Gotchas - **Transcript size**: 1KB–18MB. On first load, full scan for totals. After that, track file offset and read only new bytes. Buffer partial lines. - **Session file deletion**: files disappear when Claude exits. Handle `NotFound` between scan and read. - **stats-cache.json is stale**: only updated on `/stats` command. Don't use for live data. -- **Context window not in data**: must hardcode per model. Will break if Anthropic/OpenAI add new models. +- **Context window sources vary**: Claude/Codex use known model limits, Grok reads local signals, and Kimi resolves its local model configuration. Unknown limits must remain unavailable rather than guessed. - **Rate limit is account-level**: shared across all sessions. Don't show per-session. - **Path encoding**: `/Users/foo/bar` → `-Users-foo-bar`. Used for transcript directory names. - **Path encoding collision**: `-Users-foo-bar-baz` could be `/Users/foo/bar-baz` or `/Users/foo-bar/baz`. Use session JSON's `cwd` as source of truth. @@ -402,8 +981,61 @@ abtop reads transcripts, prompts, tool inputs, and memory files. These may conta - **Child process tree**: `pgrep -P` only gets direct children. Build full tree from `ps -eo ppid`. - **Port detection race**: a port can close between lsof and display. Show stale data gracefully. - **Subagent directory may not exist**: only created when Agent tool is used. Check existence before scanning. -- **Undocumented internals**: all data sources are Claude Code/Codex implementation details, not stable APIs. Schema may change without notice. Defensive parsing with `serde(default)` everywhere. -- **Terminal size**: minimum 80x24. Panels degrade gracefully when small (context panel hidden first). +- **Undocumented internals**: all five providers' local data sources are implementation details, not stable APIs. Schemas may change without notice. Parse defensively, ignore unknown records, and bound untrusted strings/collections. +- **Codex hook compatibility**: setup supports stable `codex-cli` 0.145.0 or newer and must + validate exact `hooks stable true` / `plugins stable true` feature rows plus all + supported 11 uppercase `ManagedHooksRequirements` properties in generated + `v2/ConfigRequirementsReadResponse.json`. Runtime evidence additionally requires the + exact source bundle, Codex's installed cached copy, attestation, and base hook state. + This is not an effective live-thread hook-engine attestation. Profiles, SessionFlags, + project/config-lock layers, managed/cloud policy, per-thread state, and live reload can + differ. Supported Codex releases cannot attest those effective layers, so exact live lifecycle + displays remain non-actionable heuristics and incomplete shapes remain `Unknown`; only + independently exact transition-bound `Done` reports process exit. It must not + interfere with native Codex launch. Secure setup/runtime are macOS/Linux-only; Windows + Codex remains unmanaged `Unknown`. `abtop --setup` is Claude-only. +- **Codex trust review**: setup never approves hooks or bypasses trust. A schema/helper + identity change must alter the declared command and cause Codex to request review + again. A changed helper path or byte content changes that identity; tell the user to + rerun setup and restart Codex after every abtop update or replacement, and approve only + `abtop@abtop-local` whenever Codex presents a fresh review. Hook refinement is accepted + only from sessions started with that current installed and reviewed helper identity. +- **Legacy wrapper migration**: remove only one structurally valid pair of the exact + abtop markers. Missing markers are success; duplicates or unmatched markers fail + closed. An already-loaded legacy function can persist until the shell exits, so 0.6's + hidden trampoline must directly delegate all native arguments, streams, and exit + behavior with no relay or allowlist. Remove that trampoline in 0.7. +- **Codex state validation**: accept only private, supported-schema hook state with a + matching helper/install identity and exact PID/start incarnation. Before helper exec, + the POSIX launcher first creates a unique empty private marker and falls back to 16 + fixed no-clobber names only if unique allocation fails. The helper anchors, adopts, and + enriches the exact marker with a fresh random 128-bit commit ID before validation, + commits basename plus ID with the fold, then removes only that inode. Preserve valid + commit proofs across clean generation boundaries and reject legacy basename-only + proofs. Missing/rejected tokens use a generic marker; exhaustion leaves persistent + overflow. A failed launch, timeout, crash, malformed + event, stale/unsafe state, or incomplete lifecycle poisons affected state instead of + leaving stale positive evidence. The launcher must remain silent and always exit zero + after its bounded dispatch attempt even when the helper is absent or fails. Cleanup must obey confirmed-process and + retention/capacity rules. Treat `session_id` as the shared root and `agent_id` as a + child identity. Never fall back to time, CPU, mtime, or uncorrelated child activity. +- **Codex status ceiling**: exact supported hook/rollout shapes may provide + non-actionable heuristic `Thinking`, direct-child `Executing`, and `Idle`; incomplete, + approval/question, and uncorrelated open-tool shapes remain `Unknown`. Stable exact + Herdr terminal evidence may provide non-actionable `Waiting`, `Idle`, refined + `Thinking`/`Executing`, or generic `Working / Heuristic` with + `HerdrWorkingUnrefined` when exact activity cannot be refined. Failed Herdr correlation + remains `Unknown`. Only an independently exact supported process/session + `Live → Gone` transition may produce non-actionable `Done / Heuristic` for 30 + seconds; once confirmed gone, an unproven hook-only generation is omitted instead of + shown as PID-zero `Unknown` or fabricated `Done`. +- **Grok shared PID**: a single Grok TUI can own multiple session rows. Attribute memory, children, and ports once; a kill affects every session on that PID. +- **Kimi same-cwd ambiguity**: several live Kimi processes sharing one root+cwd cannot be mapped authoritatively. Keep those rows `Unknown` and unkillable. +- **Kimi process title**: current Kimi Code replaces argv with the bare `kimi-code` title. Host-mode exclusions are best-effort when original arguments survive; ownership still requires explicit or post-start activity evidence. +- **Custom Grok/Kimi homes**: process-environment inspection is best-effort and platform-dependent. Launch abtop with the same `GROK_HOME` / `KIMI_CODE_HOME` when inspection is unavailable. +- **Legacy Kimi CLI**: `~/.kimi` from the retired `MoonshotAI/kimi-cli` project is not scanned. Only current Kimi Code data under `~/.kimi-code` is supported. +- **Terminal size**: minimum 60×18. Widths below 100 use the tabbed narrow layout; + desktop height pressure prioritizes Sessions and omits Context first. - **PID reuse in port cache**: invalidate cached ports when the set of tracked PIDs changes. - **Rate limit staleness**: reject rate limit data older than 10 minutes. - **`/clear` + multi-PID same cwd**: after `/clear`, Claude Code mints a new `sessionId` + `.jsonl` without rewriting `sessions/{PID}.json`. abtop overrides the stale sid by picking the newest transcript in the project dir, but this heuristic can't disambiguate ownership when two live `claude` PIDs share a cwd — so the override is disabled in that case and both sessions keep their original sid until exit. Use separate worktrees if live tracking is needed on both simultaneously. diff --git a/CLAUDE.md b/CLAUDE.md index 01bcaa2..eadc107 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,3 @@ # abtop -Claude-specific instructions are kept in [AGENTS.md](./AGENTS.md). +@AGENTS.md diff --git a/Cargo.lock b/Cargo.lock index bd38712..fd90016 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,19 +4,23 @@ version = 4 [[package]] name = "abtop" -version = "0.5.3" +version = "0.6.0" dependencies = [ "chrono", "crossterm", "dirs", + "getrandom 0.3.4", "libc", "proc_pidinfo", "ratatui", "serde", "serde_json", + "sha2", "sysinfo", "tempfile", + "toml", "unicode-width 0.2.0", + "windows-sys 0.61.2", ] [[package]] @@ -36,9 +40,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "autocfg" @@ -52,6 +56,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -123,6 +136,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -135,9 +157,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -173,6 +195,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.23.0" @@ -207,6 +239,16 @@ dependencies = [ "syn", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -268,6 +310,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -279,6 +331,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -287,7 +351,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -418,9 +482,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" @@ -581,6 +645,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -741,6 +811,26 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -876,6 +966,51 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -917,6 +1052,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1236,6 +1377,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 5723665..f7d703e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "abtop" -version = "0.5.3" +version = "0.6.0" edition = "2021" rust-version = "1.88" description = "AI agent monitor for your terminal" @@ -11,7 +11,7 @@ keywords = ["tui", "monitor", "claude", "ai", "agent"] categories = ["command-line-utilities"] readme = "README.md" authors = ["Tae Hwan Jung "] -exclude = ["assets/*", "assets/**/*"] +exclude = ["assets/*", "assets/**/*", ".omo", ".omo/**/*"] [dependencies] ratatui = "0.29" @@ -22,14 +22,18 @@ dirs = "6" chrono = { version = "0.4", features = ["serde"] } tempfile = "3" unicode-width = "0.2" +toml = "1.1.4" +getrandom = { version = "0.3", features = ["std"] } +sha2 = "0.10" [target.'cfg(target_vendor = "apple")'.dependencies] proc_pidinfo = "0.1" [target.'cfg(target_os = "windows")'.dependencies] sysinfo = "0.32" +windows-sys = { version = "0.61", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_SystemServices", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } -[target.'cfg(target_os = "linux")'.dependencies] +[target.'cfg(unix)'.dependencies] libc = "0.2" # The profile that 'dist' will build with diff --git a/README.md b/README.md index 68116b7..d23d5df 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ **Like [btop](https://github.com/aristocratos/btop), but for your AI coding agents.** -See every Claude Code, Codex CLI, and OpenCode session at a glance — token usage, context window %, rate limits, child processes, open ports, and more. -Claude Code, Codex CLI, and OpenCode sessions are discovered from local process/file state, so multiple active profiles are supported across macOS, Linux, and Windows. +See Claude Code, Codex CLI, OpenCode, Grok, and Kimi Code sessions at a glance — token +usage, context window %, rate limits, child processes, open ports, and more. +Sessions are discovered from local process and file state across macOS, Linux, and Windows. ![demo](https://raw.githubusercontent.com/graykode/abtop/main/assets/demo.gif) @@ -14,56 +15,413 @@ Claude Code, Codex CLI, and OpenCode sessions are discovered from local process/ - Agent spawned a server and forgot to kill it? Orphan port detection. - Context window filling up? Per-session % bars with warnings. -All read-only. No API keys. No auth. +## Contents + +- [Install](#install) +- [Quick Start](#quick-start) +- [Command Reference](#command-reference) +- [Codex Hook Integration](#codex-hook-integration) +- [Interface](#interface) +- [Supported Agents](#supported-agents) +- [Status and Evidence](#status-and-evidence) +- [Themes](#themes) +- [Configuration](#configuration) +- [Key Bindings](#key-bindings) +- [Library / JSON snapshot](#library--json-snapshot) +- [Privacy](#privacy) + +Collection reads local state and needs no API keys. The disabled-by-default CodexBar +quota integration may use CodexBar's configured automatic local, web, OAuth, or API +sources when explicitly enabled. +The normal TUI and `--once` can invoke the installed Claude CLI to generate session +titles; see [Privacy](#privacy) for the exact data boundary. Optional Codex hook +integration records only bounded, content-free lifecycle metadata. It never participates +in the interactive Codex launch path: plain `codex ...` remains native. Setup invokes +bounded native compatibility-preflight and plugin-administration commands. Legacy zsh +inspection can also run bounded login and non-login probes so an unexported `ZDOTDIR` +is not missed. The native plugin integration and normal launch path never wrap, replace, +alias, or proxy Codex, and abtop never inspects provider credentials. A narrowly scoped +0.6 compatibility trampoline for an already-loaded retired wrapper is documented below. ## Install ### macOS / Linux -> [!IMPORTANT] -> On Linux, ensure `sqlite3` is installed to enable monitoring for OpenCode sessions. - ```bash curl --proto '=https' --tlsv1.2 -LsSf https://github.com/graykode/abtop/releases/latest/download/abtop-installer.sh | sh ``` ### Cargo +Building or installing from source requires Rust 1.88 or newer. + ```bash cargo install abtop ``` ### Windows -Native support — no WSL required. Uses `sysinfo` for process info and host CPU/MEM metrics, and `netstat -ano` for listening ports. Windows has no load average, so LOAD is reported as 0. OpenCode session discovery additionally requires the `sqlite3` CLI (`winget install SQLite.SQLite`); without it abtop prints a one-time warning to stderr. +Native support — no WSL required. Uses `sysinfo` for process info and host CPU/MEM +metrics, and `netstat -ano` for listening ports. Windows has no load average, so LOAD is +reported as 0. OpenCode session discovery additionally requires the `sqlite3` CLI +(`winget install SQLite.SQLite`); without it abtop prints a one-time warning to stderr. ```powershell powershell -c "irm https://github.com/graykode/abtop/releases/latest/download/abtop-installer.ps1 | iex" ``` -Or `cargo install abtop` from any terminal with Git in PATH. Claude Code config is resolved automatically from `%USERPROFILE%\.claude`. +Or run `cargo install abtop` from a terminal with the Rust toolchain available. Claude +Code config is resolved automatically from `%USERPROFILE%\.claude`. ### Other -Pre-built binaries for all platforms are available on the [GitHub Releases](https://github.com/graykode/abtop/releases) page. +Pre-built binaries for all platforms are available on the +[GitHub Releases](https://github.com/graykode/abtop/releases) page. + +### Optional dependencies + +The base monitor starts without provider credentials or account setup. Extra local +programs enable the following features: + +| Feature | Requirement | +| ------- | ----------- | +| OpenCode sessions | A `sqlite3` CLI on `PATH` with `-readonly` and `-json` support | +| Claude account quota | `bash` and `python3` in Claude Code's hook environment | +| Generated session titles | An installed `claude` CLI; the normal TUI and `--once` may invoke `claude --print` | +| Codex lifecycle audit and exit correlation (macOS/Linux) | Stable native `codex-cli` 0.145.0 or newer with enabled `hooks` and `plugins` features and all 11 required generated hook events | +| Optional CodexBar quotas | A `codexbar` CLI on `PATH`; collection remains disabled until explicitly enabled | +| Project dirty-file counts | `git` on `PATH` | +| Full Unix process, open-file, port, and MCP discovery | `ps` and `lsof` | +| `abtop --update` | `curl` and `sh` | + +Missing optional dependencies degrade only their corresponding feature. For example, +abtop can still monitor non-OpenCode sessions without `sqlite3`. + +## Quick Start + +Launch the monitor immediately after installation: + +```bash +abtop +``` + +No setup is required for ordinary local session discovery. Two optional setup commands +enable data that providers do not otherwise expose reliably. + +### Claude account quota + +```bash +abtop --setup +``` + +This installs a Claude Code StatusLine hook in the active config root: +`CLAUDE_CONFIG_DIR` when it is valid UTF-8 and names an existing directory, otherwise +`~/.claude`. It writes `abtop-statusline.sh` and registers it in `settings.json`; the +hook later produces `abtop-rate-limits.json` when a Claude response supplies +`rate_limits`. If `statusLine.command` is already a different nonempty string, setup +exits instead of replacing that setting. Restart any running Claude Code sessions after +setup. Quota appears after the next response that includes quota data and remains +unavailable when the provider does not supply it. + +`abtop --setup` is Claude-only. It does not inspect or change Codex configuration. + +### Optional CodexBar quotas + +Enable **CodexBar quotas** in the `c` configuration overlay to show quota data for all +providers enabled in CodexBar, or set the existing compatibility key: + +```toml +codexbar_quota_fallback = true +``` + +This opt-in integration runs CodexBar's normal configured usage command with bounded +JSON output and a timeout. It honors CodexBar's enabled providers and automatic source +selection, which may use existing local, authenticated web, OAuth, or API access. abtop +does not select accounts or inspect credentials. It retains only bounded quota-window +metadata and sanitized diagnostics; account email, organization, credits, pace +summaries, dashboard data, raw errors, and arbitrary provider text are discarded. + +Every returned primary, secondary, tertiary, and extra quota window is eligible for +display. Fresh native Claude and Codex windows win for overlapping slots; CodexBar +fills missing or stale native windows and contributes every non-overlapping window. +When a plan shifts a standard window between built-in slot names, exact duration and +reset metadata prevents that same limit from appearing twice; custom windows remain +distinct. +One provider failure does not discard successful providers, and a sanitized unavailable +card identifies an enabled provider whose CodexBar source failed. + +The Quota panel uses an adaptive provider grid, marks CodexBar-only data with `·CB` +and mixed native/CodexBar data with `·MIX`, and reports explicit overflow when all +windows cannot fit. `--once` and `--json` retain the complete bounded provider/window +set. The configuration overlay reports `off`, `checking`, `active`, `partial`, or +`unavailable`; `abtop --json` exposes provider state, provenance, freshness, and fixed +sanitized error categories under `codexbar_quota`. Poll failures are non-fatal, and +CodexBar values remain in memory rather than being written into native quota caches. + +### Codex lifecycle audit and exit correlation + +The integration records content-free lifecycle boundaries for auditing and exact +process-exit correlation. Supported Codex releases cannot attest the effective hook engine of a +live thread. Exact supported hook/rollout shapes may nevertheless provide conservative, +non-actionable heuristic `Thinking`, `Executing`, or `Idle`; exactly correlated Herdr +terminal state may additionally provide `Waiting`, `Idle`, refined work, or generic +`Working` when activity is exact but its kind cannot be refined safely. + +Native hook integration is currently supported on macOS and Linux. Windows still gets +Codex process, rollout, token, context, and quota metadata, but its live lifecycle status +remains `Unknown`; secure hook setup fails before changing Codex configuration there. + +Install abtop's isolated local Codex plugin for the current abtop executable: + +```bash +abtop --setup-codex +``` -## Usage +Setup uses the native `codex plugin` commands to register and enable +`abtop@abtop-local` in the current `${CODEX_HOME:-~/.codex}`. It does not edit `PATH`, +define a shell function or alias, replace the Codex executable, or change command-line +arguments. Existing global hooks, `notify`, plugins, and OpenTelemetry configuration +remain untouched. Normal Codex commands keep their native behavior: ```bash -abtop # Launch TUI -abtop --once # Print snapshot and exit -abtop --json # Print one JSON snapshot and exit (for scripts/tools) -abtop --setup # Install rate limit collection hook -abtop --theme dracula # Launch with a specific theme -abtop --mouse # Enable mouse click/scroll navigation +codex +codex resume +codex fork +codex --yolo ``` -Recommended terminal size: **120x40** or larger. Minimum 80x24 — panels hide gracefully when small. -Mouse capture is off by default so terminal drag selection and copy keep working. Launch with `--mouse` if you prefer click targets and wheel navigation. +Setup supports stable `codex-cli` 0.145.0 and newer releases. It also requires the native feature list to +contain the exact `hooks stable true` and `plugins stable true` rows, and verifies that +the uppercase `ManagedHooksRequirements.properties` in generated +`v2/ConfigRequirementsReadResponse.json` contain all 11 required events. Additional +events are allowed; older releases or releases missing a required capability fail closed; +if plugin installation cannot complete, setup attempts a lost-update-safe rollback of +legacy profile edits and preserves any concurrent editor save rather than overwriting it. +Run setup again after updating Codex so the selected release's features and generated +schema are checked before starting a fresh native Codex session. + +Restart Codex after setup. If Codex asks for a trust review, approve only the 11 hooks +attributed to `abtop@abtop-local`; setup never writes trusted hook hashes itself. A +successful setup can therefore exit 0 while review is still required, whereas +`abtop --codex-integration-status` remains exit 1 / `not ready` until the base config +trusts and enables all 11 exact hooks. Run +`abtop --codex-integration-status` to audit the installation. See +[Codex Hook Integration](#codex-hook-integration) for its status and privacy limits. +After replacing or updating the abtop binary, run `abtop --setup-codex` again: the exact +helper digest is part of the hook identity, so old integration state deliberately becomes +unavailable for hook-refined status until the new plugin copy is installed, reviewed, and +loaded by a fresh Codex session. An independently exact Herdr terminal may still report +generic `Working`, `Waiting`, or `Idle` during that interval. + +## Command Reference + +| Command | Behavior | +| ------- | -------- | +| `abtop` | Launch the interactive monitor. | +| `abtop --once` | Print one human-readable snapshot. It may wait up to 30 seconds for missing titles generated by `claude --print`. | +| `abtop --json` | Print one machine-readable JSON snapshot without spawning summary jobs. | +| `abtop --theme ` | Override the theme for this launch. Use the TUI to persist a choice. | +| `abtop --mouse` | Enable mouse click and wheel handling; mouse capture is otherwise off. | +| `abtop --demo` | Show deterministic demo data with default panels and theme (unless `--theme` is supplied); persisted discovery/visibility settings are ignored, as are keyboard `r`, `x`, `X`, and `Enter` actions. | +| `abtop --exit-on-jump` | Exit abtop after a successful `Enter` terminal jump. | +| `abtop --setup` | Install the Claude quota hook. It does not change Codex configuration. | +| `abtop --setup-codex` | On macOS/Linux, remove exact legacy abtop Codex wrapper blocks, then install or repair the isolated `abtop@abtop-local` Codex plugin. | +| `abtop --uninstall-codex` | Remove abtop's local Codex plugin integration and exact retired wrapper blocks, preserve its content-free audit data, and leave unrelated configuration unchanged. | +| `abtop --codex-integration-status` | Audit native compatibility, the local plugin source and installed cache, declaration, helper, base trust/enablement, and retired-wrapper cleanup. It reports not ready on unsupported platforms. | +| `abtop --version`, `abtop -V` | Print the installed abtop version. | +| `abtop --update` | Download and run the latest release shell installer using `curl` and `sh`. | + +`--once` can wait for title generation before printing, while `--json` performs a single +summary-free collection pass and is the safer interface for scripts and local tools. + +The three Codex administration commands are exact singleton invocations. They return +exit code 0 on success, exit code 1 when setup/uninstall fails or integration status is +not ready, and exit code 2 for invalid command-line usage. + +The repository also includes a fail-closed agent-tui integration harness: + +```bash +scripts/agent-tui-e2e.sh \ + --suite codexbar \ + --abtop "$PWD/target/release/abtop" +``` + +The deterministic `codexbar` suite uses an empty isolated Codex home, a fixed mixed +multi-provider response (including a sanitized provider failure), private agent-tui +state, and a Quota-only abtop configuration. The +`codex-status` suite additionally requires a healthy, already reviewed hook integration +and an already trusted Git workspace; it starts two bounded authenticated Codex turns in an +exact disposable Herdr session and validates `Idle → Exec → Idle` plus Enter-to-focus. +It never approves hooks or touches Herdr's default session. Controlled artifacts are +retained with private permissions at the path printed on exit. + +Integration status audits the exact bundle and trust/enablement recorded in base +`$CODEX_HOME/config.toml`; it is not an attestation of a live thread's in-memory hook +engine. Profiles, command-line or per-thread overrides, project/config-lock layers, +managed/cloud policy, and live reload can differ. Supported Codex releases expose no +thread/PID/generation-bound proof of the effective hook engine, so a healthy setup or +status result is installation readiness only. Exact supported lifecycle shapes may +display non-actionable heuristic `Think`, `Exec`, or `Idle`, but do not authorize PID +actions. Herdr terminal evidence is independently correlated and does not upgrade the +plugin audit. Only the validated process-exit transition described below reports +heuristic `Done`. + +## Codex Hook Integration + +On macOS and Linux, `abtop --setup-codex` creates a private local marketplace and plugin +below the active `${CODEX_HOME:-~/.codex}`: + +```text +$CODEX_HOME/abtop/marketplace/ +├── .agents/plugins/marketplace.json +└── plugins/abtop/ + ├── .codex-plugin/plugin.json + ├── hooks/hooks.json + └── scripts/abtop-codex-hook.{sh,cmd} +``` + +Before committing the integration, setup requires stable `codex-cli` 0.145.0 or newer, +exact `hooks stable true` and `plugins stable true` feature rows, and all events from +the required 11-event contract in +`v2/ConfigRequirementsReadResponse.json`'s uppercase +`ManagedHooksRequirements.properties`. It then registers the marketplace and plugin by +invoking the exact lexical `codex` entry selected from `PATH` whose execution proved that +version and contract. This preserves argv-sensitive shims such as mise rather than +silently substituting their canonical target. Setup verifies that the plugin is installed +and enabled. Runtime +state is stored in `$CODEX_HOME/plugins/data/abtop-abtop-local`. Re-running setup repairs +or updates the bundle. A changed abtop executable path or byte content changes the helper +digest; even a byte-identical replacement must be followed by setup, so run setup again +after every abtop update or replacement and restart Codex. The plugin version and hook command +include the schema revision and helper identity digest, causing Codex to request a new +trust review instead of silently reusing an old approval. Hook evidence is accepted only +when the private source bundle, Codex's installed cached copy, install attestation, and +base trust/enablement state match the current declaration exactly. Those checks establish +installation integrity, not complete effective per-thread hook coverage. + +Setup and uninstall serialize owned plugin mutations with the stable +`$CODEX_HOME/.abtop-codex-plugin.lock`. On Unix it is a same-owner regular file with +mode `0600`, is revalidated while held, and is retained after uninstall so concurrent +administrative processes cannot lock different replacement inodes. A source-local +`$CODEX_HOME/abtop/.setup.lock` belongs only to older installations and is removed as +legacy source-bundle debris; it is not the current lock. + +Compatibility preflight briefly invokes `codex app-server generate-json-schema` to +inspect that local release's schema. This subprocess is not a relay, supervisor, +monitoring transport, or persistent daemon, and abtop never attaches to a Codex daemon. +Every native Codex administrative command receives null stdin, has a 15-second overall +timeout, and captures at most 1 MiB from each of stdout and stderr. On Unix it runs in +its own process group with nonblocking pipe drains; abtop terminates that group on +timeout or when descendants retain an output pipe for 100 ms after the leader exits, +then reaps only for a bounded interval. On Windows the portable path uses bounded +reader channels and a kill-on-close Job Object when assignment succeeds, with direct +child termination as a fallback. Mutating plugin commands are bracketed by checks of +the exact selected Codex executable identity and fail if it changes. + +The plugin subscribes, without matchers, to the same 11-event set validated for every +supported Codex release: + +| Session and turn | Tools and interaction | Subagents and compaction | +| ---------------- | --------------------- | ------------------------ | +| `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `Stop` | `PreToolUse`, `PermissionRequest`, `PostToolUse` | `SubagentStart`, `SubagentStop`, `PreCompact`, `PostCompact` | + +Each hook is synchronous, silent, and limited to one second, so it can delay the +corresponding Codex edge by at most that configured timeout. The helper parses at most +4 MiB as a stream, materializes only bounded lifecycle fields, discards every other JSON +value, and attempts to drain malformed input. The launcher and helper absorb all errors +and produce no stdout or stderr. After its bounded dispatch attempt, the launcher always +exits with code zero regardless of helper success, failure, or absence. Monitoring +therefore fails open for Codex itself: an unavailable or outdated abtop reduces +monitoring confidence but cannot deny or alter the agent action. + +Setup does not add `PostToolUseFailure`; additional events advertised by newer Codex +releases do not expand abtop's installed hook set automatically. Setup also leaves existing user hooks, `notify`, OpenTelemetry, and +other plugins unchanged. Do not bypass hook trust globally. After setup, restart plain +native Codex and review only the 11 hooks shown for `abtop@abtop-local`. Only sessions +started after that restart use the currently installed and reviewed helper identity; +older live sessions retain untrusted integration state and cannot supply hook-refined +status. + +No shell integration is installed. Setup removes only the exact legacy blocks delimited +by `# >>> abtop managed codex >>>` and `# <<< abtop managed codex <<<`; missing blocks +are already clean, while malformed or duplicate markers fail closed without editing the +file. Replacements are locked, revalidated, atomic, and rolled back safely if migration +cannot complete on macOS/Linux. Unrelated aliases, functions, and profile contents are +preserved. +Zsh, bash, and fish are migrated automatically when applicable. To discover an +unexported custom `ZDOTDIR`, setup, uninstall, and integration-status inspection can run +strictly framed, output-bounded login and non-login zsh probes; zsh evaluates its normal +startup files, but abtop persists none of their content. Windows setup is unsupported +before migration; Windows uninstall can instead return exact manual PowerShell cleanup +guidance. If an old wrapper function is still loaded in the current shell, start a fresh +shell after setup. On macOS/Linux, migration and integration-status inspection share the +stable private mode-`0600` lock `~/.abtop-codex-migration.lock`. The file is created on +the first inspection and intentionally retained so concurrent processes never lock +different replacement inodes; it contains no provider or shell-profile content. The +non-Unix lock implementation is a no-op. + +Uninstall applies the same exact-marker migration and preserves the bounded content-free +state in `$CODEX_HOME/plugins/data/abtop-abtop-local` for audit. As a recovery rule it +always asks native Codex to remove the reserved `abtop@abtop-local` plugin ID first, +including when the marketplace record is missing, malformed, or conflicting; do not use +that reserved ID for an unrelated plugin. It removes the marketplace registration and +source bundle only after proving they point to abtop's exact local source, otherwise it +preserves them and exits with manual-recovery guidance. Other profile content and Codex +configuration remain unchanged. Unlike setup and healthy-status validation, uninstall +accepts any exact stable `X.Y.Z` Codex semver so a downgrade or upgrade cannot strand the +integration without a recovery path. + +Release 0.6 retains `abtop codex -- ...` only as a hidden compatibility trampoline for +an already-loaded legacy wrapper. It requires that wrapper's exact captured Codex path +and directly delegates arguments, standard streams, and exit status to the native +executable; on Unix, process replacement also preserves native signal behavior. It is +not a monitoring launcher, has no argument allowlist, and is +scheduled for removal in 0.7. New scripts and documentation must always invoke +`codex ...` directly. + +## Interface + +The numbered panels can be toggled with `1`–`7`; their visibility is persisted. +For orientation, press `?` for help, `/` to filter sessions, `c` for configuration, +`Enter` to jump through exact Herdr session identity or actionable PID ownership, and +`x` twice to confirm a session kill. See +[Key Bindings](#key-bindings) for the complete controls. + +| Panel | Contents | +| ----- | -------- | +| **1 Context** | Token-rate history and per-session context-window gauges when reliable window data exists. | +| **2 Quota** | Account-level quota windows and reset times from native Claude/Codex sources plus every provider enabled through the optional CodexBar integration. | +| **3 Tokens** | Input, output, cache, turn, and selected-session token history. | +| **4 Projects** | Project branch and dirty-file counts collected through `git`. | +| **5 Ports** | Agent child listeners, conflicts, and ports orphaned after their parent session exits. | +| **6 Sessions** | Session list, current status/task, selected-session evidence, children, subagents, timeline, and file audit. | +| **7 MCP** | Detected Codex `mcp-server` processes, profiles, rollout counts, and recent activity. A rollout updated within 30 minutes counts as active. | + +MCP-owned rollouts are suppressed from Sessions by default to prevent duplicate or +ghost rows. `M` changes that behavior for the current run only. + +The supported minimum terminal size is **60x18**; smaller terminals show a size warning +when the pane is tall enough to render it. +Widths from 60 through 99 use a tabbed **Work / Usage / System** layout, while widths of +100 or more use the desktop layout. In desktop mode, Sessions receive priority when +height is constrained and the Context panel is omitted first; narrow-mode sections +split the available height equally unless one is maximized. **120x40** or larger remains +recommended. + +Mouse capture is off by default so drag selection and copy continue to work. Launch with +`--mouse` to enable panel, tab, session, zoom, and orphan-port click targets plus wheel +navigation. ### Terminal Jump -Press `Enter` to focus the terminal running the selected agent. abtop supports cmux, tmux, and iTerm2 on macOS. +Press `Enter` to focus the selected agent terminal. abtop supports +Herdr 0.7.0 or newer, cmux, tmux, and iTerm2 on macOS. When abtop runs inside +Herdr, it can jump to agents in any pane, tab, or workspace in the same Herdr +session; no additional setup is required. Unsupported environments do nothing. iTerm2 +can request macOS Automation permission on first use. Herdr first matches one exact +native provider/session reference, so an exact row can be focused even when its +lifecycle status is `Unknown` or `Done`. When no exact reference exists, all backends +retain the existing actionable PID and fresh process-incarnation requirements. ```bash tmux new -s work @@ -73,26 +431,310 @@ tmux new -s work # → Enter on a session in abtop jumps to its pane ``` +The same flow works inside Herdr: run abtop in one pane, run agents elsewhere +in the same session, then press `Enter` on the selected abtop row. + ## Supported Agents -| Feature | Claude Code | Codex CLI | OpenCode | -| ----------------- | :---------: | :-------: | :------: | -| Session Discovery | ✅ | ✅ | ✅ | -| Token Tracking | ✅ | ✅ | ✅ | -| Context Window % | ✅ | ✅ | ❌ | -| Status Detection | ✅ | ✅ | ✅ | -| Current Task | ✅ | ✅ | ❌ | -| Rate Limit | ✅ | ✅ | ❌ | -| Git Status | ✅ | ✅ | ✅ | -| Children / Ports | ✅ | ✅ | ✅ | -| Subagents | ✅ | ❌ | ❌ | -| Memory Status | ✅ | ❌ | ❌ | - -OpenCode support reads the local SQLite database at `~/.local/share/opencode/opencode.db` (also the default location on Windows; `%LOCALAPPDATA%\opencode` and `%APPDATA%\opencode` are probed as fallbacks) and requires `sqlite3` in `PATH` (on Windows: `winget install SQLite.SQLite`). +✅ means available, ⚠ means conditional or deliberately limited, and ❌ means +unavailable. Provider-specific caveats and evidence authorities are explained in +[Status and Evidence](#status-and-evidence). + +| Feature | Claude Code | Codex CLI | OpenCode | Grok | Kimi Code | +| ------- | :---------: | :-------: | :------: | :--: | :-------: | +| Session discovery | ✅ | ✅ | ✅ | ✅ | ✅ | +| Token tracking | ✅ | ✅ | ✅ | ✅ | ✅ | +| Context window % | ✅ | ✅ | ⚠ estimated | ⚠ local data | ⚠ local config | +| Status detection | ⚠ mixed evidence | ⚠ conservative heuristics | ⚠ limited | ✅ | ⚠ mixed evidence | +| Current task | ✅ | ⚠ generic/refined | ⚠ generic | ✅ | ✅ | +| Account quota | ✅ | ✅ | ❌ | ⚠ CodexBar | ⚠ CodexBar | +| Git status | ✅ | ✅ | ✅ | ✅ | ✅ | +| Children / ports | ✅ | ✅ | ✅ | ✅ | ✅ | +| Subagents | ✅ | ✅ | ❌ | ✅ | ✅ | +| Memory status | ✅ | ❌ | ❌ | ❌ | ❌ | + +## Status and Evidence + +Status answers what the agent is doing; evidence answers how confidently abtop can prove +it. UI labels and their JSON enum values are: + +| UI | JSON | Meaning | +| -- | ---- | ------- | +| `◉ Think` | `Thinking` | A model turn is open and no tool is currently running. | +| `● Exec` | `Executing` | A tool, task, subagent, background terminal, or verified active child is working. | +| `◐ Work` | `Working` | Exact terminal evidence proves activity, but its kind cannot be classified safely. | +| `◌ Wait` | `Waiting` | An explicit unresolved approval or question requires user action. It wins over concurrent background work. | +| `○ Idle` | `Idle` | The live session has no active model, tool, task, or interaction work. | +| `? Unknown` | `Unknown` | Ownership or lifecycle proof is missing, stale, malformed, disconnected, contradictory, or otherwise insufficient. | +| `⏳ Rate` | `RateLimited` | The provider reports a current rate-limit block. A quota percentage alone never sets this lifecycle status. | +| `✗ Error` | `Error` | The provider reports a current fatal session or turn failure. Raw provider error content is not used as the task label. | +| `✓ Done` | `Done` | A verified process exit has been observed. | + +After ownership and lifecycle validation, precedence is `Waiting` > `RateLimited` > +`Error` > `Executing` > `Thinking` > `Working` > `Idle`. `Working` is used only when +exact activity cannot be refined to `Thinking` or `Executing`. abtop never turns elapsed +time, low CPU, or an old transcript timestamp into `Wait`. `Unknown` is the fail-closed +result when that precedence cannot be applied safely; `Done` is terminal exit evidence. + +Each row also carries one of these evidence authorities: + +| Authority | Meaning | +| --------- | ------- | +| `Provider` | Exact provider lifecycle data. Codex hook evidence is never promoted to this authority. | +| `Heuristic` | A local-file, process, or exactly correlated terminal inference that is useful but not provider-authoritative. | +| `Unavailable` | No sufficiently reliable current source exists. | + +The evidence record includes a machine-readable reason, observation and status-since +timestamps, connection generation, consecutive matching count, and bounded sample +history. `observed_at_ms` is the time of the newest evidence sample; its displayed +freshness is the age of that observation. `status_since_ms` is when the current status +began. `connection_generation` identifies a protocol connection generation; zero is +used for hook and other non-protocol evidence and is displayed as `—`. Consecutive matching +counts samples with the same status, authority, and connection generation. `--once` +prints the current evidence plus the latest five status samples. The selected-session +detail shows the same line when terminal width permits; `--json` always includes the +complete current fields and latest five content-free samples. Fields ending in `_ms` +use Unix epoch milliseconds. + +Process actions fail closed too. `Working`, `Unknown`, and `Done` rows, `Unavailable` evidence, a +missing exact process identity, or a failed fresh PID/provider revalidation disables +kill and PID-based terminal jump. An exact Herdr native session reference is separate +focus-only authority and never enables kill. Kimi is stricter for PID actions: even a +non-`Unknown` heuristic row is non-actionable; its process actions require `Provider` +authority. The first `x` records +an exact-session confirmation and a matching second press within two seconds performs +the kill. A Grok confirmation reports how many logical sessions share the target PID. + +Codex `Done` is transition-bound. This collector instance must first observe an exact +live PID/start ↔ supported-version rollout-tree binding, then observe that same process +incarnation become gone. It anchors the 30-second window at that transition and retains +a bounded, content-free, non-actionable in-memory tombstone through the exact boundary, +even if the source state later disappears. A temporarily unavailable hook-state scan +may preserve an existing tombstone but cannot create one; a fresh collector that first +sees an already-gone process, or sees only a reused numeric PID, cannot fabricate +`Done`. Once an exact process scan confirms it is gone, a hook-only generation without +that observed transition is omitted instead of remaining as a PID-zero `Unknown` row or +being relabeled `Done`. Other providers can disappear immediately after verified exit. +Historical Codex rollouts never create PID-zero `Done` rows. + +### Claude Code + +Claude discovery maps live processes to local config roots and transcripts. Recognized +native registry states and durable transcript, tool, and subagent lifecycle can provide +`Provider` evidence; activity inferred only from descendant processes is `Heuristic`. +An unorderable native/transcript disagreement, or incomplete subagent lifecycle that +would otherwise look idle, fails closed to `Unknown`. Account quota requires the +[Claude setup](#claude-account-quota) hook. + +Quota values older than ten minutes remain visible but are dimmed and omit their reset +countdown. The panel has one Claude provider card: if several Claude roots contain quota +files, the newest complete valid native sample is used as one account; equally dated or +undated samples preserve discovery order, with `~/.claude` checked before the environment +and additional roots. When CodexBar quotas are enabled, fresh native Claude windows retain +precedence over overlapping CodexBar windows. + +### Codex CLI + +Codex combines the content-free events from [Codex Hook +Integration](#codex-hook-integration) with local rollout data. Hooks establish lifecycle +edges; rollouts supply session identity, project/model metadata, tokens, context, quota, +summaries, and subagent relationships. Rollout ordering, mtime, token activity, CPU use, +child activity, and incomplete tool records never establish live status by themselves. + +Supported Codex releases expose no thread/PID/generation-bound attestation of the effective hook +engine after profile, project/config-lock, managed/cloud, command-line, per-thread, and +live-reload layers are applied. Base trust, enablement, bundle integrity, individual hook +events, and rollout correlation therefore cannot prove complete live coverage. Exact, +supported process/session/hook/rollout shapes may still supply conservative +`Heuristic` display states, but every live Codex row remains non-actionable. + +When Codex runs inside Herdr, abtop can supplement those shapes with Herdr's private +terminal detector. It accepts only a stable `herdr:codex` session-ID match bracketed +around `pane process-info`, with the exact native Codex PID and process incarnation. +Herdr `blocked` reports `Waiting`, Herdr `idle` reports `Idle`, and Herdr `working` +reports `Thinking` or `Executing` only when exact Codex lifecycle evidence classifies +the work. Otherwise the same exact, stable activity reports generic +`Working / Heuristic` with reason `HerdrWorkingUnrefined`. Missing Herdr, failed snapshot +bracketing, duplicate matches, pane movement, PID reuse, malformed output, and timeouts +remain `Unknown`; terminal titles and screen content are never ingested. + +The independent exit proof still requires exact process-incarnation, session, +configuration, and event correlation. The matched process-owned root rollout must report +a stable `cli_version` of 0.145.0 or newer, and every discovered descendant rollout must +report that same version. Missing, older, malformed, child-only, or descendant-mismatched version metadata +cannot seed exit proof. abtop applies this deliberately strict matrix: + +| Evidence | Codex status | +| -------- | ------------ | +| A previously observed exact live PID/start ↔ supported-version rollout-tree binding, followed by that same process incarnation changing from live to gone | `Done / Heuristic` for 30 seconds | +| Exact supported root turn with no tool or active child | `Thinking / Heuristic`, non-actionable | +| Exact supported active direct-child model work with a complete child set | `Executing / Heuristic`, non-actionable | +| Exact supported stop plus matching rollout completion and a clean tree | `Idle / Heuristic`, non-actionable | +| Stable Herdr `blocked` or `idle` with exact session/PID/incarnation correlation | `Waiting / Heuristic` or `Idle / Heuristic`, non-actionable | +| Stable Herdr `working` plus exact root-turn, root-tool, or direct-child evidence | `Thinking / Heuristic` or `Executing / Heuristic`, non-actionable | +| Stable Herdr `working` with exact session/PID/incarnation correlation but no safe lifecycle refinement | `Working / Heuristic` with `HerdrWorkingUnrefined`, non-actionable | +| A root `PreToolUse`/open rollout tool, any child with an open tool, `PermissionRequest`, or a `request_user_input` candidate | `Unknown / Unavailable`; Codex may be executing or waiting for approval | +| Any `SessionStart`, including startup, resume, clear, and compact | Generation evidence only; never sufficient for `Idle` | +| Rollout `stream_error`/`error`, failed `task_complete`, an unparseable open descriptor, or a nonterminal extra root tree | `Unknown / Unavailable`; invalid rollout lifecycle cannot seed new exit proof | +| A child `PreToolUse`/open tool or any direct-child active/terminal/provisional mismatch | `Unknown / Unavailable`; incomplete child lifecycle never proves live work or rest | +| Missing/different root or descendant `cli_version`; delayed, aborted, stale, duplicate, mismatched, or unsupported active/non-direct child lifecycle; missing/out-of-order hooks; uncovered or hosted tools; a relevant process descendant when root inactivity is required; malformed/stale state; configuration drift; or ambiguous ownership | `Unknown / Unavailable` | +| A hook-only generation whose exact process incarnation is confirmed gone without this collector having observed the required live-to-gone transition | Omitted; never retained as PID-zero `Unknown` and never fabricated as `Done` | + +Codex queues startup, resume, and clear `SessionStart` hooks into the next turn, +immediately before `UserPromptSubmit`; those sources reset to a clean generation. A +compact `SessionStart` follows `PostCompact` inside the current turn and preserves active +work. Every source proves a lifecycle boundary, not the absence of work, and none can +become `Idle` on its own. A newly opened empty composer may emit no hook evidence and +remains `Unknown`. + +`Stop` and `SubagentStop` are provisional candidates: another hook may block a stop, and +the same root or child can continue in the same turn. Later matching activity reopens +that actor. A provisional child stop closes only when the exact child rollout later +reaches `task_complete`; continued child model work is promoted only from a complete, +exact direct-child set. Neither `SessionStart` nor `Stop` alone proves `Idle`. + +Supported Codex releases do not expose enough prompt-display and resolution lifecycle to +distinguish approval/question waits safely. Therefore those states are intentionally +`Unknown`, never guessed as `Wait`, `Exec`, or `Idle`: observing a +`PermissionRequest` or question candidate does not prove when the displayed interaction +is resolved. Hook data also does not produce live `Error` or `RateLimited`; Codex +rate-limit records remain quota metadata only. A root open tool also remains `Unknown`: +Codex can run `PreToolUse` before a separately configurable `PermissionRequest`, so an +open hook/rollout call does not prove that execution has begun. abtop never substitutes +elapsed time, low CPU, or file mtime for a missing event. + +Codex uses one root `session_id` for the root and every descendant hook. A child +`agent_id` identifies subagent lifecycle within that shared root; abtop folds those +records into one root state and never treats `agent_id` as a separate Codex session. + +Before abtop starts, the POSIX launcher first uses `mktemp` to exclusively create an +empty mode-`0600` marker named +`launch--pending.<16-alphanumeric-nonce>` in the embedded private fault +directory. Only if that unique allocation fails does it try the 16 fixed no-clobber +`launch--abtopv1.pending` fallback names; exhaustion leaves the persistent +`overflow.json` sentinel. The helper anchors the directory, adopts only the exact +marker/inode, and enriches it with bounded content-free identity plus a fresh random +128-bit per-adoption commit ID before attestation, ancestor resolution, parsing, and +folding. A missing or rejected token also attempts an independent `hook-.json` +marker. A successful fold records both the marker basename and commit ID in state before +removing that same marker, closing the fold/delete crash window. Valid commit proofs +survive clean startup, resume, and clear boundaries; a reused fixed fallback basename +therefore cannot impersonate another invocation. Legacy basename-only proofs fail +closed. A timeout, crash, +malformed input, or helper that never starts therefore leaves evidence that poisons a +live affected generation to `Unknown` instead of preserving stale work. Once its exact +process incarnation is confirmed gone, the collector omits that unproven generation as +described above. Fault artifacts are bounded. Before generation state can be deleted, +the writer persists its first GC-side +confirmation that the exact process incarnation is gone. That maintenance observation +does not authorize `Done`; it preserves an observation opportunity for the collector. +Deletion requires a later writer pass strictly more than 30 seconds afterward plus a +fresh exact-incarnation gone check. Capacity pressure starts this sequence only after a +terminal generation is at least 30 seconds old or a crashed nonterminal generation is +strictly older than 24 hours; normal cleanup runs on a later `SessionEnd` and requires +the strict 24-hour gate for either. Once the collector has independently observed the +required supported live-to-gone transition, its bounded, content-free, +non-actionable in-memory tombstone preserves the 30-second `Done` row even if generation +state disappears. After draining its payload, every later ingest may also reclaim stale +state/fault temporary files, malformed or abandoned fixed-slot markers, and ordinary +fault markers only when they are strictly older than 24 hours, the complete validated +state snapshot is unchanged across out-of-lock process probes, and every affected +process incarnation is confirmed gone. Collector reads never delete artifacts, +`overflow.json` remains permanent and monotonic, and a clean `SessionStart` does not +delete failure evidence. + +JSON and detail views make the distinction auditable with content-free reasons such as +`HookToolOpen`, `HookSubagentActive`, `HookTurnOpen`, `HookTurnComplete`, +`HookInteractionResolutionUnavailable`, `HookEventGap`, `HookConfigChanged`, +`HookStateMalformed`, `HookIntegrationUnverified`, `HerdrScreenBlocked`, +`HerdrScreenWorking`, `HerdrScreenIdle`, and `HerdrWorkingUnrefined`. Hook and Herdr +evidence always use `connection_generation = 0`. + +The helper binds each hook event to the nearest eligible native Codex ancestor and its +exact process start identity. Shared daemon hooks, app-server/MCP/Desktop/remote-control +hosts, PID ambiguity, and session/action ownership ambiguity fail closed. An unknown or +inactionable row cannot be killed or terminal-jumped. Codex without the abtop plugin +remains discoverable for rollout metrics; an exactly correlated Herdr terminal can +still provide `Waiting`, `Idle`, or generic `Working`; only `Thinking` and `Executing` +require exact current-helper lifecycle refinement. When plain Codex uses a local or +remote shared app-server daemon, its hooks +run below that shared host instead of the client TUI, so uncorrelated status remains +`Unknown`. + +Windows uses that unmanaged behavior too: rollout and process metadata remain available, +but secure native-hook state is currently macOS/Linux-only and lifecycle evidence stays +`Unknown / Unavailable`. + +### OpenCode + +OpenCode reads the local SQLite database at +`${XDG_DATA_HOME:-~/.local/share}/opencode/opencode.db`; on Windows it also probes +`%LOCALAPPDATA%\opencode` and `%APPDATA%\opencode`. It requires a `sqlite3` CLI on +`PATH` that supports both `-readonly` and `-json`. Discovery considers the 20 most +recently updated database sessions. +An explicit `--session`/`-s` process argument or a unique one-process/one-row cwd group +can confirm lifecycle ownership; ambiguous same-cwd groups become `Unknown`. Process +kill and terminal jump remain disabled because OpenCode has no durable actionable +PID/session registry. + +Persisted pending/running `question` and tool rows are not authoritative current +lifecycle: SQLite may expose them before execution or retain them after memory state has +moved on. abtop therefore never promotes those rows to `Wait` or `Exec`. A fresh +incomplete assistant record can provide heuristic `Think`, a completed assistant record +heuristic `Idle`, and a current failure heuristic `Error`. Live permission waits exist +only in process memory and are not observable from the database. The displayed current +task is therefore a generic status label rather than a persisted tool name. Context +percentage uses an estimated 200,000-token window, or 1,000,000 when the model name +contains `[1m]`. + +### Grok + +Grok reads `active_sessions.json` and per-session `summary.json`, `signals.json`, +`updates.jsonl`, optional `events.jsonl`, and `plan_mode.json` under +`${GROK_HOME:-~/.grok}`. Permission requests, structured questions, and plan approvals +become `Wait` when their unresolved local lifecycle records are present. Grok can also +emit lifecycle `RateLimited`, even though it exposes no account-level quota gauge. + +One Grok process can own multiple registered sessions. abtop shows every logical +session, attributes memory, children, and ports only once, and warns that killing the +shared PID stops all of them. Positive lifecycle signals remain usable, but a quiescent +row cannot be proven individually idle while the PID is shared, so provider `Idle` is +downgraded to `Unknown / OwnershipUnconfirmed`. Headless sessions must be registered, +for example with `GROK_TRACK_HEADLESS`; abtop does not guess them from cwd alone. + +### Kimi Code + +Kimi support targets the current [Kimi Code](https://github.com/MoonshotAI/kimi-code) +CLI and its persisted wire protocol 1.4. It reads `session_index.jsonl`, per-session +`state.json`, and agent `wire.jsonl` under `${KIMI_CODE_HOME:-~/.kimi-code}`. Unsupported, +malformed, incomplete, or stale wire state fails closed to `Unknown`. Retired +`MoonshotAI/kimi-cli` data under `~/.kimi` is not scanned. + +Every unresolved `AskUserQuestion`, whether foreground or background, and every +validated running `question` task, including detached tasks, becomes `Wait` until an +exact resolution, cancellation, tool completion, or terminal task snapshot clears it. +Wire protocol 1.4 does not persist ordinary tool-approval prompts, so those remain +`Exec` rather than being guessed from elapsed time or inactivity. A long-running tool +without an explicit interaction record likewise remains `Exec`. + +Kimi has no authoritative PID/session registry and can rewrite its process title to +bare `kimi-code`. An explicit visible session association can provide `Provider` +ownership; cwd plus post-start activity can provide useful heuristic display status but +never authorizes kill or jump. Idle old resumes, ambiguous same-root/cwd groups, and +bare-title host ambiguity remain `Unknown` and non-actionable. Visible plugin-runner, +ACP, web, and server host modes are excluded instead of shown as sessions. + +For custom Grok or Kimi homes, launch abtop with the same `GROK_HOME` or +`KIMI_CODE_HOME` environment as the agent. abtop also attempts a platform-specific read +of candidate process environments, but operating-system permissions can prevent that +fallback. Grok and Kimi context percentages appear only when their signals or model +configuration provide a reliable window. Their account quota is unavailable from the +native session collectors, but can appear when each provider is enabled and succeeds +in the optional CodexBar integration. ## Themes -12 built-in themes, including 4 colorblind-friendly options (`high-contrast`, `protanopia`, `deuteranopia`, `tritanopia`). Press `t` to cycle at runtime, or launch with `--theme `. Your choice is saved to `~/.config/abtop/config.toml`. +12 built-in themes, including 4 colorblind-friendly options (`high-contrast`, `protanopia`, `deuteranopia`, `tritanopia`). Press `t` to cycle and persist a theme, or use `--theme ` for a launch-only override. The config overlay can also change and persist the theme. | btop (default) | dracula | catppuccin | |:-:|:-:|:-:| @@ -120,49 +762,95 @@ Light themes (`light` — Solarized cream, `white` — GitHub-style pure white) ## Configuration -`~/.config/abtop/config.toml` supports: +abtop uses the platform config directory returned by the operating system: + +| Platform | Config file | +| -------- | ----------- | +| Linux | `${XDG_CONFIG_HOME:-~/.config}/abtop/config.toml` | +| macOS | `~/Library/Application Support/abtop/config.toml` | +| Windows | `%APPDATA%\abtop\config.toml` | + +Configuration is loaded at launch. Theme, panel-visibility, and CodexBar quota +changes made in the TUI are written back immediately; unrelated and unknown lines are preserved. The `M` +MCP-session suppression toggle is runtime-only and is not stored. + +Supported keys are: ```toml theme = "btop" + +# Optional quotas for all providers enabled in CodexBar. +# The legacy key name is retained for configuration compatibility. +codexbar_quota_fallback = false + # Hide specific agent CLIs from the TUI (case-insensitive). -# Useful if you only use one agent and want a cleaner view. -hidden_agents = ["codex"] +# Supported IDs: claude, codex, opencode, grok, kimi. +hidden_agents = [] + # Additional Claude Code profile roots to scan. # abtop also auto-discovers ~/.claude and ~/.claude-* roots that contain # both sessions/ and projects/. -claude_config_dirs = ["~/.claude-personal", "~/.claude-work-team"] -# UI language. Omit or leave empty to auto-detect from LANG. -language = "zh" +claude_config_dirs = [] + +# Panel visibility. Every key defaults to true. +show_context = true +show_quota = true +show_tokens = true +show_projects = true +show_ports = true +show_sessions = true +show_mcp = true ``` -### Supported Languages +For example, use `hidden_agents = ["codex", "grok"]` to hide those session collectors +or `claude_config_dirs = ["~/.claude-personal", "~/.claude-work-team"]` to add profile +roots. Account-level quota collection is independent of session visibility, so an +explicitly enabled CodexBar integration remains active when any corresponding session +provider is hidden. -| Code | Language | -| ---- | ------------------- | -| `en` | English (default) | -| `zh` | Simplified Chinese | - -When `language` is unset, abtop auto-detects from `LANG` — any value starting with `zh` switches to Simplified Chinese, otherwise English. +Codex hook integration is intentionally separate from this platform config. It lives +under the active `CODEX_HOME` and is managed with `--setup-codex`, +`--uninstall-codex`, and `--codex-integration-status`. ## Key Bindings -| Key | Action | -| ------------------ | ------------------------------------ | -| `↑`/`↓` or `k`/`j` | Select session | -| `Enter` | Jump to session terminal | -| `x` | Kill selected session | -| `X` | Kill all orphan ports | -| `t` | Cycle theme | -| `1`–`5` | Toggle panel visibility | -| `Esc` | Open/close config page | -| `q` | Quit | -| `r` | Force refresh | +| Key | Action | +| --- | ------ | +| `↑`/`↓`, `k`/`j` | Select the previous or next visible session. | +| `Enter` | Jump by exact Herdr session identity, or by actionable PID after fresh validation. | +| `/` | Enter session-filter input mode. Type to filter, use `Backspace` to delete, `Enter` to keep the filter and leave input mode, or `Esc` to clear it. | +| `x` | Request a kill confirmation; press `x` again within two seconds to kill the same freshly validated session/process incarnation. | +| `X` | Freshly rescan and validate, then kill all processes still owning displayed orphan ports. | +| `r` | Force refresh. Disabled in demo mode. | +| `q` | Quit, or close the config overlay while it is open. | +| `t` | Cycle and persist the theme. | +| `T` | Toggle the subagent tree view. | +| `l` / `L` | Toggle the selected-session timeline. | +| `f` / `F` | Toggle the selected-session file audit. | +| `1`–`7` | Toggle and persist Context, Quota, Tokens, Projects, Ports, Sessions, or MCP visibility. | +| `M` | Toggle suppression of `mcp-server`-owned rollouts in the Sessions panel for this run. | +| `c` | Open/close configuration. Inside it, select with `↑`/`↓` or `k`/`j`, change with `Enter`/Space, and close with `Esc`, `q`, or `c`. | +| `v` | Open/close the view menu. | +| `Esc` | Close the view menu, or clear a retained nonempty session filter outside filter-input mode. | +| `?` | Show keybinding help; any key closes it. | + +In the narrow tabbed layout: + +| Key | Action | +| --- | ------ | +| `←`/`→`, `Shift+Tab`/`Tab` | Cycle visible Work, Usage, and System tabs. | +| `w`, `u`, `s` | Select the Work, Usage, or System tab directly. | +| `+` / `=` | Maximize the active section. | +| `-` | Restore the split sections. | + +In demo mode, the keyboard actions `r`, `x`, `X`, and `Enter` are disabled. Do not treat +`--demo --mouse` as a destructive-action safety boundary: the mouse orphan-port cleanup +target is still active. ## Library / JSON snapshot -abtop is also a library crate, so local tools can reuse its data-collection -layer in-process — no re-scanning, no subprocesses — and serialize the same -state the TUI renders. +abtop is also a library crate, so local tools can reuse its collection and +state APIs in-process and serialize the same state the TUI renders. ```bash abtop --json # one-shot JSON snapshot for scripts @@ -171,15 +859,16 @@ abtop --json # one-shot JSON snapshot for scripts For long-running consumers, build an `App`, refresh it with `App::tick_no_summaries()` (which never spawns `claude --print`, so it doesn't touch your Claude quota), and call `App::to_snapshot(interval_ms)` to get a -JSON-serializable [`Snapshot`]: +JSON-serializable `Snapshot`: ```rust,no_run use abtop::app::App; use abtop::{config, theme::Theme}; let cfg = config::load_config(); -let mut app = App::new_with_config_and_claude_dirs( +let mut app = App::new_with_config_and_claude_dirs_and_codexbar( Theme::default(), &cfg.hidden_agents, cfg.panels, &cfg.claude_config_dirs, + cfg.codexbar_quota_fallback, ); app.tick_no_summaries(); let json = serde_json::to_string(&app.to_snapshot(2_000)).unwrap(); @@ -191,9 +880,133 @@ is a reference consumer: a local-first web dashboard built on exactly this API. ## Privacy -abtop reads local files and local process/open-file metadata only. No API keys, no auth. In the TUI and `--once` output, tool names and file paths are shown, but file contents and prompt text are never displayed. Session summaries are generated via `claude --print`, which makes its own API call — this is the only indirect network usage. - -The JSON snapshot includes richer local dashboard data, including `summary`, `chat_messages`, working directories, config roots, tool-call previews, child process commands, token counts, and port metadata. Chat text is bounded and redacted by the collectors, but it is still derived from local transcripts and may contain sensitive project context. Treat JSON snapshots as local/private data and avoid writing them to shared logs or exposing them on a network without your own access controls. +abtop collectors read local files and local process/open-file metadata, including the +Claude, Codex, OpenCode, Grok, and Kimi Code session stores. They need no provider API +keys and do not send collected session records to provider APIs. Normal collection +starts no Codex relay or daemon, attaches to no Codex daemon, and uses no provider API +or transport credential. The disabled-by-default CodexBar integration is the explicit +exception: its short-lived, bounded command uses CodexBar's enabled providers and +automatic sources, which may access existing local, authenticated web, OAuth, or API +sessions. abtop never reads or stores those credentials. It retains only bounded quota +windows, provenance, freshness, and fixed sanitized diagnostics—never account identity, +email, organization, credits, pace summaries, dashboard data, raw errors, or arbitrary +provider text. + +Native Codex can independently choose a shared local or remote app-server daemon; those +sessions deliberately remain `Unknown`. Codex otherwise continues to run directly with +the caller's executable, arguments, standard streams, and environment; abtop does not +inspect or persist provider credentials. + +The Codex hook helper uses a 4 MiB streaming JSON parser and never materializes the raw +payload as one buffer. It accepts at most 256 root fields, 512 bytes per lifecycle ID, +16 KiB of cwd, and the other small allowlisted lifecycle scalars; every unrecognized or +sensitive value is skipped directly by the deserializer. It attempts to drain to EOF even +after malformed JSON, subject to the hard stream cap. Prompt text, tool input and output, +the last assistant message, raw commands and arguments, environment and authentication +data, transcript paths, and arbitrary provider text never enter state. Private state +contains only schema/helper/install identities, session/turn/tool/subagent identifiers, +canonical event and tool classes, cwd when needed for correlation, timestamps, exact +PID/start incarnations, lifecycle faults/open sets, and at most 128 content-free samples. + +Codex plugin data directories use mode `0700` and files use mode `0600` on Unix. State +writes reject symlinks and ownership mismatches, lock updates, and atomically replace a +same-directory file. Malformed input, event gaps, changed hook/helper identities, +unsafe paths, stale state, or ambiguous native-process ancestry become sticky failure +evidence and keep an affected live generation `Unknown` unless exact Herdr evidence +provides one of its independent states. The launch marker is created before the helper starts, adopted and enriched +before validation, and removed only after its basename and fresh random 128-bit +per-adoption commit ID are durably committed by a successful fold. The POSIX launcher +normally allocates a unique `launch--pending.<16-alphanumeric-nonce>` marker; +16 no-clobber fixed names are a bounded fallback only. Valid commit proofs survive clean +generation boundaries, so even reuse of a fallback basename cannot hide a different +failed invocation. A helper timeout, crash, rejected record, or failed launch therefore +cannot leave stale positive evidence. Missing-token fallback markers, bounded ordinary +faults, and persistent overflow prevent unbounded artifacts while preserving fail-closed +evidence. Before deleting generation state, a +writer persists its first GC-side exact-incarnation gone confirmation; that timestamp is +only a deletion grace anchor and never authorizes `Done`. Removal requires a later pass +strictly more than 30 seconds afterward and a fresh exact-incarnation gone check. +Capacity pressure starts that sequence only after a terminal generation is at least 30 +seconds old or a crashed nonterminal generation is strictly older than 24 hours; normal +cleanup runs on a later `SessionEnd` and requires the strict 24-hour gate for either. +The collector separately requires an already observed exact supported live-to-gone +transition and retains a bounded, content-free, non-actionable 30-second in-memory +tombstone, so proven `Done` survives later source-state disappearance. A collector whose +first observation is already gone, numeric-PID reuse without exact incarnation +continuity, or an unavailable scan cannot create that proof. Once the exact incarnation +is confirmed gone, an unproven hook-only generation is omitted instead of displayed as +PID-zero `Unknown`. Once its payload is drained, +each later ingest can reclaim strictly +older-than-24-hour temporary files, malformed or abandoned fixed-slot markers, and +ordinary faults only from a complete validated state snapshot that remains unchanged +across out-of-lock process-death probes, with every affected incarnation confirmed +gone. Collector reads never remove artifacts and `overflow.json` remains permanent and +monotonic. These guarantees are a content boundary, not a claim that lifecycle metadata +such as cwd and stable IDs is nonsensitive. + +`abtop --setup-codex` can create the absolute `CODEX_HOME` when it is absent, then writes +the isolated marketplace/plugin source bundle, the retained private +`$CODEX_HOME/.abtop-codex-plugin.lock`, the content-free `installation.json` +attestation and `states/faults` tree under plugin data, Codex's installed plugin cache and +native marketplace/plugin registration, and the content-free stable migration lock +described above on macOS/Linux. Generated launchers contain the exact absolute abtop +executable and private plugin-data paths, but no provider content. It never +modifies global `hooks.json`, +`notify`, OpenTelemetry, unrelated plugins, Claude configuration, `PATH`, or the Codex +executable. During migration it may remove exact legacy abtop wrapper marker blocks +from shell startup files; it does not remove arbitrary aliases or functions. Uninstall +uses the same exact-marker rule, unconditionally removes and then verifies absence of the +reserved plugin ID, verifies marketplace absence before deleting the owned source tree, +and preserves both the retained root setup lock and the content-free plugin-data tree and +attestation. It removes a legacy source-local `abtop/.setup.lock` only as part of verified +owned-source cleanup. +The helper identity in the declared hook command deliberately retriggers Codex trust +review after meaningful updates. + +Codex hook setup and secure state collection are currently available only on macOS and +Linux. Windows setup fails before mutation; ordinary Windows Codex collection remains +read-only and reports lifecycle status as `Unknown`. + +`abtop --setup` writes `abtop-statusline.sh` inside the active Claude config root and +registers the script in that root's `settings.json`. When StatusLine input contains +`rate_limits`, the hook extracts only quota percentages and reset timestamps through +local `python3`, adds the fixed source `claude` and current local update time, and writes +`abtop-rate-limits.json`. It persists no prompt or message content. abtop does not add a +network request to that hook. + +abtop uses the operating system's cache directory: + +| Platform | abtop cache directory | +| -------- | --------------------- | +| Linux | `${XDG_CACHE_HOME:-~/.cache}/abtop/` | +| macOS | `~/Library/Caches/abtop/` | +| Windows | `%LOCALAPPDATA%\abtop\` | + +The directory can contain `summaries.json` and `codex-rate-limits.json`. The latter +stores only the last locally observed native Codex account quota windows and timestamps. +CodexBar values for every provider remain in memory and are never written to this cache. +Codex hook lifecycle state is kept separately at +`${CODEX_HOME:-~/.codex}/plugins/data/abtop-abtop-local`; it contains only the bounded +content-free fields described above. + +The normal TUI and `--once` generate missing session titles by passing up to 200 +characters from the first user text and up to 200 characters from the first assistant +text to the locally installed `claude --print`. That CLI may call Anthropic. The summary +cache's `summaries.json` stores only the derived title or sanitized 80-character fallback +for each session, not the full source records; those cached values can still contain +sensitive project context. `App::tick_no_summaries()` does not launch summary jobs or +pass excerpts to `claude --print`; neither does `abtop --json`. + +The TUI and `--once` do not deliberately render full local files or raw tool results, +but they do show safe path previews, bounded tool-argument and child-command previews, +and bounded session summaries. The selected-session detail can also render the +collected `initial_prompt` field and a bounded, redacted tail of recent user and +assistant chat messages. Prompts or chat can themselves contain pasted file content. +The JSON snapshot includes richer local dashboard data such as `summary`, +bounded/redacted `chat_messages`, working directories, config roots, tool-call previews, +child process commands, token counts, and port metadata. All of these outputs and caches +can reveal project context; treat them as private data and do not expose them through +shared logs or a network without your own access controls. ## Acknowledgements diff --git a/assets/agent-state-demo.gif b/assets/agent-state-demo.gif deleted file mode 100644 index 126e1a0..0000000 Binary files a/assets/agent-state-demo.gif and /dev/null differ diff --git a/assets/config-demo.gif b/assets/config-demo.gif deleted file mode 100644 index b1e5f70..0000000 Binary files a/assets/config-demo.gif and /dev/null differ diff --git a/assets/context-gauge-demo.gif b/assets/context-gauge-demo.gif deleted file mode 100644 index 4c6e436..0000000 Binary files a/assets/context-gauge-demo.gif and /dev/null differ diff --git a/assets/demo.gif b/assets/demo.gif index a5366a6..1fed930 100644 Binary files a/assets/demo.gif and b/assets/demo.gif differ diff --git a/assets/demo.mp4 b/assets/demo.mp4 index 5ddd664..67ed426 100644 Binary files a/assets/demo.mp4 and b/assets/demo.mp4 differ diff --git a/assets/demo.tape b/assets/demo.tape index 98d24a0..1d17ace 100644 --- a/assets/demo.tape +++ b/assets/demo.tape @@ -1,4 +1,5 @@ -Output demo.gif +Output assets/demo.gif +Output assets/demo.mp4 Set Shell "bash" Set FontSize 24 @@ -9,29 +10,25 @@ Set Theme "Catppuccin Mocha" Set Framerate 30 Hide -Type "../target/debug/abtop --demo" +Type "unset NO_COLOR; ./target/release/abtop --demo" Enter Sleep 1s Show -# Initial state: session 1 selected. Timeline shows the live WebSearch row -# animating — a tool that's currently running. +# Initial state: session 1 selected with its chat and lifecycle evidence visible. Sleep 3s -# Navigate down — session 2 has no tool calls, so the timeline slot is empty; -# session 3 repopulates it and also shows a live "Think" row (the model is -# generating its next reply, no tool_use yet). +# Navigate through the demo sessions to show provider and status differences. Type "j" Sleep 1.2s Type "j" Sleep 3.5s -# Press L to enter focus mode — detail area grows and the timeline takes the -# full width, so the Think row is easy to see growing. +# Press L to replace chat with the selected session's tool timeline. Type "l" Sleep 4s -# Press L again to return to the default side-by-side layout. +# Press L again to return to chat. Type "l" Sleep 1.5s diff --git a/assets/regenerate.sh b/assets/regenerate.sh new file mode 100755 index 0000000..c9d61f6 --- /dev/null +++ b/assets/regenerate.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +asset_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$asset_dir/.." && pwd) +cd "$repo_root" + +if ! command -v vhs >/dev/null 2>&1; then + echo "VHS 0.11.0 is required (macOS: brew install vhs)." >&2 + exit 1 +fi + +if ! command -v ffmpeg >/dev/null 2>&1; then + echo "ffmpeg is required to extract the theme screenshots." >&2 + exit 1 +fi + +case "$(vhs --version)" in + *0.11.0*) ;; + *) + echo "Expected VHS 0.11.0; found: $(vhs --version)" >&2 + exit 1 + ;; +esac + +cargo build --release +vhs assets/demo.tape + +for tape in assets/themes/*.tape; do + recording=${tape%.tape}.preview.gif + screenshot=${tape%.tape}.png + if [ -e "$recording" ]; then + unlink "$recording" + fi + if [ -e "$screenshot" ]; then + unlink "$screenshot" + fi + vhs "$tape" + ffmpeg -loglevel error -y -sseof -0.1 -i "$recording" -frames:v 1 "$screenshot" + unlink "$recording" +done diff --git a/assets/themes/btop.png b/assets/themes/btop.png index d881ae8..e45024b 100644 Binary files a/assets/themes/btop.png and b/assets/themes/btop.png differ diff --git a/assets/themes/btop.tape b/assets/themes/btop.tape index 9479064..7878e05 100644 --- a/assets/themes/btop.tape +++ b/assets/themes/btop.tape @@ -1,4 +1,4 @@ -Output assets/themes/btop.gif +Output assets/themes/btop.preview.gif Set Shell "bash" Set FontSize 24 @@ -8,7 +8,7 @@ Set Padding 10 Set Framerate 1 Hide -Type "./target/release/abtop --demo --theme btop" +Type "unset NO_COLOR; ./target/release/abtop --demo --theme btop" Enter Sleep 3s Show diff --git a/assets/themes/catppuccin.png b/assets/themes/catppuccin.png index d23badc..dbb47da 100644 Binary files a/assets/themes/catppuccin.png and b/assets/themes/catppuccin.png differ diff --git a/assets/themes/catppuccin.tape b/assets/themes/catppuccin.tape index 62dade8..4ac7596 100644 --- a/assets/themes/catppuccin.tape +++ b/assets/themes/catppuccin.tape @@ -1,4 +1,4 @@ -Output assets/themes/catppuccin.gif +Output assets/themes/catppuccin.preview.gif Set Shell "bash" Set FontSize 24 @@ -8,7 +8,7 @@ Set Padding 10 Set Framerate 1 Hide -Type "./target/release/abtop --demo --theme catppuccin" +Type "unset NO_COLOR; ./target/release/abtop --demo --theme catppuccin" Enter Sleep 3s Show diff --git a/assets/themes/deuteranopia.png b/assets/themes/deuteranopia.png index 35a0767..f25c286 100644 Binary files a/assets/themes/deuteranopia.png and b/assets/themes/deuteranopia.png differ diff --git a/assets/themes/deuteranopia.tape b/assets/themes/deuteranopia.tape index 32d4819..0f078b2 100644 --- a/assets/themes/deuteranopia.tape +++ b/assets/themes/deuteranopia.tape @@ -1,4 +1,4 @@ -Output assets/themes/deuteranopia.png +Output assets/themes/deuteranopia.preview.gif Set Shell "bash" Set FontSize 24 @@ -8,7 +8,7 @@ Set Padding 10 Set Framerate 1 Hide -Type "./target/release/abtop --demo --theme deuteranopia" +Type "unset NO_COLOR; ./target/release/abtop --demo --theme deuteranopia" Enter Sleep 3s Show diff --git a/assets/themes/dracula.png b/assets/themes/dracula.png index 208f7b5..15e6d95 100644 Binary files a/assets/themes/dracula.png and b/assets/themes/dracula.png differ diff --git a/assets/themes/dracula.tape b/assets/themes/dracula.tape index c19d7c2..8d39b19 100644 --- a/assets/themes/dracula.tape +++ b/assets/themes/dracula.tape @@ -1,4 +1,4 @@ -Output assets/themes/dracula.gif +Output assets/themes/dracula.preview.gif Set Shell "bash" Set FontSize 24 @@ -8,7 +8,7 @@ Set Padding 10 Set Framerate 1 Hide -Type "./target/release/abtop --demo --theme dracula" +Type "unset NO_COLOR; ./target/release/abtop --demo --theme dracula" Enter Sleep 3s Show diff --git a/assets/themes/gruvbox.png b/assets/themes/gruvbox.png index f940252..542f64a 100644 Binary files a/assets/themes/gruvbox.png and b/assets/themes/gruvbox.png differ diff --git a/assets/themes/gruvbox.tape b/assets/themes/gruvbox.tape index 80fea39..dd25054 100644 --- a/assets/themes/gruvbox.tape +++ b/assets/themes/gruvbox.tape @@ -1,4 +1,4 @@ -Output assets/themes/gruvbox.gif +Output assets/themes/gruvbox.preview.gif Set Shell "bash" Set FontSize 24 @@ -8,7 +8,7 @@ Set Padding 10 Set Framerate 1 Hide -Type "./target/release/abtop --demo --theme gruvbox" +Type "unset NO_COLOR; ./target/release/abtop --demo --theme gruvbox" Enter Sleep 3s Show diff --git a/assets/themes/high-contrast.png b/assets/themes/high-contrast.png index e7e61b1..f9e9c7c 100644 Binary files a/assets/themes/high-contrast.png and b/assets/themes/high-contrast.png differ diff --git a/assets/themes/high-contrast.tape b/assets/themes/high-contrast.tape index da17d0b..186db1d 100644 --- a/assets/themes/high-contrast.tape +++ b/assets/themes/high-contrast.tape @@ -1,4 +1,4 @@ -Output assets/themes/high-contrast.png +Output assets/themes/high-contrast.preview.gif Set Shell "bash" Set FontSize 24 @@ -8,7 +8,7 @@ Set Padding 10 Set Framerate 1 Hide -Type "./target/release/abtop --demo --theme high-contrast" +Type "unset NO_COLOR; ./target/release/abtop --demo --theme high-contrast" Enter Sleep 3s Show diff --git a/assets/themes/light.png b/assets/themes/light.png index 8904ce1..fd0a789 100644 Binary files a/assets/themes/light.png and b/assets/themes/light.png differ diff --git a/assets/themes/light.tape b/assets/themes/light.tape index d8e41ab..2202279 100644 --- a/assets/themes/light.tape +++ b/assets/themes/light.tape @@ -1,4 +1,4 @@ -Output assets/themes/light.gif +Output assets/themes/light.preview.gif Set Shell "bash" Set FontSize 24 @@ -8,7 +8,7 @@ Set Padding 10 Set Framerate 1 Hide -Type "./target/release/abtop --demo --theme light" +Type "unset NO_COLOR; ./target/release/abtop --demo --theme light" Enter Sleep 3s Show diff --git a/assets/themes/nord.png b/assets/themes/nord.png index d10dbbc..77a683c 100644 Binary files a/assets/themes/nord.png and b/assets/themes/nord.png differ diff --git a/assets/themes/nord.tape b/assets/themes/nord.tape index bed8b10..b16eec7 100644 --- a/assets/themes/nord.tape +++ b/assets/themes/nord.tape @@ -1,4 +1,4 @@ -Output assets/themes/nord.gif +Output assets/themes/nord.preview.gif Set Shell "bash" Set FontSize 24 @@ -8,7 +8,7 @@ Set Padding 10 Set Framerate 1 Hide -Type "./target/release/abtop --demo --theme nord" +Type "unset NO_COLOR; ./target/release/abtop --demo --theme nord" Enter Sleep 3s Show diff --git a/assets/themes/protanopia.png b/assets/themes/protanopia.png index 5487b5b..ef15eff 100644 Binary files a/assets/themes/protanopia.png and b/assets/themes/protanopia.png differ diff --git a/assets/themes/protanopia.tape b/assets/themes/protanopia.tape index ce6146f..50b63d0 100644 --- a/assets/themes/protanopia.tape +++ b/assets/themes/protanopia.tape @@ -1,4 +1,4 @@ -Output assets/themes/protanopia.png +Output assets/themes/protanopia.preview.gif Set Shell "bash" Set FontSize 24 @@ -8,7 +8,7 @@ Set Padding 10 Set Framerate 1 Hide -Type "./target/release/abtop --demo --theme protanopia" +Type "unset NO_COLOR; ./target/release/abtop --demo --theme protanopia" Enter Sleep 3s Show diff --git a/assets/themes/tokyo-night.png b/assets/themes/tokyo-night.png index 4cb461b..9a20b17 100644 Binary files a/assets/themes/tokyo-night.png and b/assets/themes/tokyo-night.png differ diff --git a/assets/themes/tokyo-night.tape b/assets/themes/tokyo-night.tape index 5f1fc78..ccf702c 100644 --- a/assets/themes/tokyo-night.tape +++ b/assets/themes/tokyo-night.tape @@ -1,4 +1,4 @@ -Output assets/themes/tokyo-night.gif +Output assets/themes/tokyo-night.preview.gif Set Shell "bash" Set FontSize 24 @@ -8,7 +8,7 @@ Set Padding 10 Set Framerate 1 Hide -Type "./target/release/abtop --demo --theme tokyo-night" +Type "unset NO_COLOR; ./target/release/abtop --demo --theme tokyo-night" Enter Sleep 3s Show diff --git a/assets/themes/tritanopia.png b/assets/themes/tritanopia.png index 32b146c..25de13f 100644 Binary files a/assets/themes/tritanopia.png and b/assets/themes/tritanopia.png differ diff --git a/assets/themes/tritanopia.tape b/assets/themes/tritanopia.tape index fa18f99..12581d0 100644 --- a/assets/themes/tritanopia.tape +++ b/assets/themes/tritanopia.tape @@ -1,4 +1,4 @@ -Output assets/themes/tritanopia.png +Output assets/themes/tritanopia.preview.gif Set Shell "bash" Set FontSize 24 @@ -8,7 +8,7 @@ Set Padding 10 Set Framerate 1 Hide -Type "./target/release/abtop --demo --theme tritanopia" +Type "unset NO_COLOR; ./target/release/abtop --demo --theme tritanopia" Enter Sleep 3s Show diff --git a/assets/themes/white.png b/assets/themes/white.png index 84973ea..7821fa4 100644 Binary files a/assets/themes/white.png and b/assets/themes/white.png differ diff --git a/assets/themes/white.tape b/assets/themes/white.tape index a88e840..f354f18 100644 --- a/assets/themes/white.tape +++ b/assets/themes/white.tape @@ -1,4 +1,4 @@ -Output assets/themes/white.gif +Output assets/themes/white.preview.gif Set Shell "bash" Set FontSize 24 @@ -8,7 +8,7 @@ Set Padding 10 Set Framerate 1 Hide -Type "./target/release/abtop --demo --theme white" +Type "unset NO_COLOR; ./target/release/abtop --demo --theme white" Enter Sleep 3s Show diff --git a/assets/timeline-demo.gif b/assets/timeline-demo.gif deleted file mode 100644 index a601e87..0000000 Binary files a/assets/timeline-demo.gif and /dev/null differ diff --git a/scripts/agent-tui-e2e.sh b/scripts/agent-tui-e2e.sh new file mode 100755 index 0000000..a537c35 --- /dev/null +++ b/scripts/agent-tui-e2e.sh @@ -0,0 +1,1582 @@ +#!/usr/bin/env bash +# Manual, authenticated end-to-end checks for abtop's terminal integrations. +# +# The harness deliberately uses a private agent-tui daemon and an exact named +# Herdr session. It never issues a global agent-tui cleanup and never attaches +# to, focuses, stops, or deletes Herdr's default session. + +set -Eeuo pipefail +umask 077 + +readonly SCRIPT_NAME="${0##*/}" +readonly CONFIG_LABEL="CodexBar quotas" +readonly CODEXBAR_IDENTITY_SENTINEL="ABTOP_E2E_PRIVATE_IDENTITY_DETAIL" +readonly CODEXBAR_CREDITS_SENTINEL="ABTOP_E2E_PRIVATE_CREDITS_DETAIL" +readonly CODEXBAR_PACE_SENTINEL="ABTOP_E2E_PRIVATE_PACE_DETAIL" +readonly CODEXBAR_ERROR_SENTINEL="ABTOP_E2E_PRIVATE_RAW_ERROR_DETAIL" + +SUITE="" +ABTOP_BIN="" +CODEX_HOME_PATH="" +ARTIFACTS_PARENT="" +WORKSPACE_PATH="" +AGENT_TUI_BIN="" +RUN_ROOT="" +RUNTIME_ROOT="" +DAEMON_STARTED=0 +HERDR_MAY_EXIST=0 +HERDR_SOCKET="" +HERDR_NAME="" +HERDR_TUI_SESSION="" +ACTIVE_ATUI_SESSIONS=() +LAST_ATUI_SESSION="" + +usage() { + cat <&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1" +} + +version_at_least() { + local actual="$1" + local required="$2" + local actual_major actual_minor actual_patch required_major required_minor required_patch + IFS=. read -r actual_major actual_minor actual_patch <<<"$actual" + IFS=. read -r required_major required_minor required_patch <<<"$required" + actual_patch="${actual_patch%%[^0-9]*}" + required_patch="${required_patch%%[^0-9]*}" + (( actual_major > required_major )) && return 0 + (( actual_major < required_major )) && return 1 + (( actual_minor > required_minor )) && return 0 + (( actual_minor < required_minor )) && return 1 + (( actual_patch >= required_patch )) +} + +parse_args() { + while (($# > 0)); do + case "$1" in + --suite) + (($# >= 2)) || fail "--suite requires a value" + SUITE="$2" + shift 2 + ;; + --abtop) + (($# >= 2)) || fail "--abtop requires a value" + ABTOP_BIN="$2" + shift 2 + ;; + --codex-home) + (($# >= 2)) || fail "--codex-home requires a value" + CODEX_HOME_PATH="$2" + shift 2 + ;; + --artifacts) + (($# >= 2)) || fail "--artifacts requires a value" + ARTIFACTS_PARENT="$2" + shift 2 + ;; + --workspace) + (($# >= 2)) || fail "--workspace requires a value" + WORKSPACE_PATH="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac + done + + case "$SUITE" in + codex-status|codexbar|all) ;; + *) fail "--suite must be codex-status, codexbar, or all" ;; + esac + [[ "$ABTOP_BIN" == /* ]] || fail "--abtop must be an absolute path" + [[ -f "$ABTOP_BIN" && -x "$ABTOP_BIN" ]] || fail "--abtop is not an executable file: $ABTOP_BIN" + if [[ "$SUITE" == "codex-status" || "$SUITE" == "all" ]]; then + [[ "$CODEX_HOME_PATH" == /* ]] || fail "--codex-home must be an absolute path" + [[ -d "$CODEX_HOME_PATH" ]] || fail "--codex-home is not a directory: $CODEX_HOME_PATH" + fi + if [[ -z "$WORKSPACE_PATH" ]]; then + WORKSPACE_PATH="$(cd "$(dirname "$0")/.." && pwd -P)" + fi + [[ "$WORKSPACE_PATH" == /* ]] || fail "--workspace must be an absolute path" + [[ -d "$WORKSPACE_PATH" ]] || fail "--workspace is not a directory: $WORKSPACE_PATH" +} + +register_atui_session() { + ACTIVE_ATUI_SESSIONS+=("$1") +} + +atui() { + local session_id="$1" + shift + "$AGENT_TUI_BIN" --session "$session_id" --json "$@" +} + +kill_atui_session() { + local session_id="$1" + set +e + atui "$session_id" kill --yes >/dev/null 2>&1 + set -e +} + +cleanup() { + local status=$? + local session_id + local runtime_entry + trap - EXIT HUP INT TERM + set +e + + for session_id in "${ACTIVE_ATUI_SESSIONS[@]}"; do + atui "$session_id" kill --yes >/dev/null 2>&1 + done + + if ((HERDR_MAY_EXIST)); then + herdr session stop "$HERDR_NAME" --json >/dev/null 2>&1 + herdr session delete "$HERDR_NAME" --json >/dev/null 2>&1 + fi + + if ((DAEMON_STARTED)); then + "$AGENT_TUI_BIN" --json daemon stop --yes >/dev/null 2>&1 + fi + + if [[ -n "$RUN_ROOT" ]]; then + if [[ -n "$RUNTIME_ROOT" && -f "$RUNTIME_ROOT/agent-tui.log" ]]; then + cp "$RUNTIME_ROOT/agent-tui.log" "$RUN_ROOT/agent-tui.log" + fi + if [[ -n "$RUNTIME_ROOT" && -f "$RUNTIME_ROOT/sessions.jsonl" ]]; then + cp "$RUNTIME_ROOT/sessions.jsonl" "$RUN_ROOT/agent-tui-sessions.jsonl" + fi + if [[ -n "$RUNTIME_ROOT" && -f "$RUNTIME_ROOT/a.log" ]]; then + cp "$RUNTIME_ROOT/a.log" "$RUN_ROOT/agent-tui-daemon.log" + fi + printf '%s\n' "$status" >"$RUN_ROOT/exit-code" + log "artifacts retained at $RUN_ROOT" + fi + + case "$RUNTIME_ROOT" in + /tmp/abtop-atui.*|/private/tmp/abtop-atui.*) + for runtime_entry in \ + a.sock a.lock a.log sessions.jsonl sessions.lock \ + agent-tui.log ui.json ws.json; do + if [[ -e "$RUNTIME_ROOT/$runtime_entry" || -L "$RUNTIME_ROOT/$runtime_entry" ]]; then + rm -f -- "$RUNTIME_ROOT/$runtime_entry" + fi + done + rmdir "$RUNTIME_ROOT" >/dev/null 2>&1 || true + ;; + esac + exit "$status" +} + +prepare_runtime() { + local run_id version_output version + require_command jq + require_command mktemp + require_command agent-tui + AGENT_TUI_BIN="$(command -v agent-tui)" + + version_output="$($AGENT_TUI_BIN --version 2>&1)" + version="$(printf '%s\n' "$version_output" | sed -E -n 's/.*[^0-9]([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' | head -n 1)" + [[ -n "$version" ]] || fail "could not parse agent-tui version from: $version_output" + version_at_least "$version" "1.1.0" || fail "agent-tui 1.1.0 or newer is required (found $version)" + + run_id="abtop-e2e-$(date +%s)-$$" + if [[ -n "$ARTIFACTS_PARENT" ]]; then + mkdir -p "$ARTIFACTS_PARENT" + ARTIFACTS_PARENT="$(cd "$ARTIFACTS_PARENT" && pwd -P)" + RUN_ROOT="$ARTIFACTS_PARENT/$run_id" + mkdir "$RUN_ROOT" + else + RUN_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/abtop-agent-tui-artifacts.XXXXXX")" + fi + + # Keep the Unix socket path short enough for macOS. All agent-tui state is + # still private and exact; only final evidence is copied to RUN_ROOT. + RUNTIME_ROOT="$(mktemp -d "/tmp/abtop-atui.XXXXXX")" + export AGENT_TUI_SOCKET="$RUNTIME_ROOT/a.sock" + export AGENT_TUI_SESSION_STORE="$RUNTIME_ROOT/sessions.jsonl" + export AGENT_TUI_UI_STATE="$RUNTIME_ROOT/ui.json" + export AGENT_TUI_WS_STATE="$RUNTIME_ROOT/ws.json" + export AGENT_TUI_LOG="$RUNTIME_ROOT/agent-tui.log" + export AGENT_TUI_LOG_FORMAT=json + export AGENT_TUI_LOG_STREAM=stderr + export AGENT_TUI_TRANSPORT=unix + export AGENT_TUI_WS_DISABLED=1 + export AGENT_TUI_NO_INPUT=1 + export NO_COLOR=1 + + printf '%s\n' "$version_output" >"$RUN_ROOT/agent-tui-version.txt" + "$ABTOP_BIN" --version >"$RUN_ROOT/abtop-version.txt" + "$AGENT_TUI_BIN" --json daemon start >"$RUN_ROOT/agent-tui-daemon-start.json" + DAEMON_STARTED=1 + "$AGENT_TUI_BIN" --json daemon status >"$RUN_ROOT/agent-tui-daemon-status.json" +} + +run_atui_session() { + local output_file="$1" + shift + LAST_ATUI_SESSION="" + "$AGENT_TUI_BIN" --json run "$@" >"$output_file" + [[ -s "$output_file" ]] || fail "agent-tui returned empty run output" + LAST_ATUI_SESSION="$(jq -er '.session_id | select(type == "string" and length > 0)' "$output_file")" + register_atui_session "$LAST_ATUI_SESSION" +} + +capture_screen() { + local session_id="$1" + local basename="$2" + atui "$session_id" screenshot --strip-ansi >"$RUN_ROOT/$basename.json" + jq -er '.screenshot' "$RUN_ROOT/$basename.json" >"$RUN_ROOT/$basename.txt" +} + +wait_screen_regex() { + local session_id="$1" + local regex="$2" + local timeout_seconds="$3" + local evidence_name="$4" + local deadline now poll poll_name + deadline=$(( $(date +%s) + timeout_seconds )) + poll=0 + while :; do + poll=$((poll + 1)) + printf -v poll_name '%s-poll-%03d' "$evidence_name" "$poll" + capture_screen "$session_id" "$poll_name" + if grep -E -q "$regex" "$RUN_ROOT/$poll_name.txt"; then + cp "$RUN_ROOT/$poll_name.json" "$RUN_ROOT/$evidence_name.json" + cp "$RUN_ROOT/$poll_name.txt" "$RUN_ROOT/$evidence_name.txt" + return 0 + fi + now=$(date +%s) + ((now < deadline)) || return 1 + sleep 1 + done +} + +wait_codex_table_row_count() { + local session_id="$1" + local expected_count="$2" + local timeout_seconds="$3" + local evidence_name="$4" + local deadline now poll poll_name actual_count consecutive_matches required_matches + deadline=$(( $(date +%s) + timeout_seconds )) + poll=0 + consecutive_matches=0 + required_matches=1 + if [[ "$expected_count" == 0 ]]; then + required_matches=2 + fi + while :; do + poll=$((poll + 1)) + printf -v poll_name '%s-poll-%03d' "$evidence_name" "$poll" + if capture_screen "$session_id" "$poll_name" \ + && [[ -s "$RUN_ROOT/$poll_name.txt" ]] \ + && grep -E -q 'abtop v[0-9]' "$RUN_ROOT/$poll_name.txt" \ + && grep -F -q '⁶sessions' "$RUN_ROOT/$poll_name.txt" \ + && grep -E -q \ + 'AI[[:space:]]+Pid[[:space:]]+Project' \ + "$RUN_ROOT/$poll_name.txt"; then + actual_count="$(grep -E -c \ + '[[:space:]]>?CD[[:space:]]+[0-9]+[[:space:]]' \ + "$RUN_ROOT/$poll_name.txt" || true)" + if [[ "$actual_count" == "$expected_count" ]]; then + consecutive_matches=$((consecutive_matches + 1)) + if ((consecutive_matches >= required_matches)); then + cp "$RUN_ROOT/$poll_name.json" "$RUN_ROOT/$evidence_name.json" + cp "$RUN_ROOT/$poll_name.txt" "$RUN_ROOT/$evidence_name.txt" + return 0 + fi + else + consecutive_matches=0 + fi + else + consecutive_matches=0 + fi + now=$(date +%s) + ((now < deadline)) || return 1 + sleep 1 + done +} + +assert_screen_regex() { + local basename="$1" + local regex="$2" + grep -E -q "$regex" "$RUN_ROOT/$basename.txt" \ + || fail "screen $basename did not match: $regex" +} + +assert_screen_absent() { + local basename="$1" + local literal="$2" + if grep -F -q "$literal" "$RUN_ROOT/$basename.txt"; then + fail "screen $basename exposed or retained forbidden text: $literal" + fi +} + +assert_codexbar_private_data_absent() { + local path="$1" + local sentinel + for sentinel in \ + "$CODEXBAR_IDENTITY_SENTINEL" \ + "$CODEXBAR_CREDITS_SENTINEL" \ + "$CODEXBAR_PACE_SENTINEL" \ + "$CODEXBAR_ERROR_SENTINEL"; do + if grep -F -q "$sentinel" "$path"; then + fail "CodexBar output exposed private provider data in $path" + fi + done +} + +open_codexbar_config() { + local session_id="$1" + local evidence_prefix="$2" + atui "$session_id" press c >/dev/null + wait_screen_regex "$session_id" "$CONFIG_LABEL" 10 "$evidence_prefix-open" \ + || fail "CodexBar config row did not appear" + atui "$session_id" press \ + ArrowDown ArrowDown ArrowDown ArrowDown ArrowDown ArrowDown ArrowDown ArrowDown >/dev/null + capture_screen "$session_id" "$evidence_prefix-selected" + assert_screen_regex "$evidence_prefix-selected" ">[[:space:]]+$CONFIG_LABEL" +} + +write_fake_codexbar() { + local fake_bin="$1" + mkdir -p "$fake_bin" + cat >"$fake_bin/codexbar" <<'FAKE_CODEXBAR' +#!/bin/sh +set -eu +: "${ABTOP_E2E_CODEXBAR_MODE_FILE:?}" +: "${ABTOP_E2E_CODEXBAR_CALLS_FILE:?}" +printf '%s\n' "$*" >>"$ABTOP_E2E_CODEXBAR_CALLS_FILE" +if [ "$*" != "usage --format json --json-only --no-color" ]; then + printf '%s\n' "unexpected CodexBar arguments" >&2 + exit 64 +fi +mode=$(sed -n '1p' "$ABTOP_E2E_CODEXBAR_MODE_FILE") +case "$mode" in + partial) + # Cross an epoch-second boundary so headless paths prove they take + # their freshness clock after the blocking initial poll completes. + sleep 2 + printf '%s\n' '[{"provider":"claude","source":"claude","usage":{"primary":{"usedPercent":28.0,"windowMinutes":300,"resetsAt":"2099-01-01T05:00:00Z"},"secondary":{"usedPercent":6.0,"windowMinutes":10080,"resetsAt":"2099-01-08T00:00:00Z"},"tertiary":{"usedPercent":11.0,"windowMinutes":43200,"resetsAt":"2099-01-31T00:00:00Z"},"extraRateWindows":[{"id":"claude-weekly-scoped-fable","title":"Fable only","window":{"usedPercent":0.0,"windowMinutes":10080,"resetsAt":"2099-01-08T08:00:00Z"}}],"identity":{"providerID":"claude","accountEmail":"ABTOP_E2E_PRIVATE_IDENTITY_DETAIL"}},"pace":{"primary":{"summary":"ABTOP_E2E_PRIVATE_PACE_DETAIL"}}},{"provider":"codex","source":"oauth","usage":{"primary":null,"secondary":{"usedPercent":48.0,"windowMinutes":10080,"resetsAt":"2099-01-08T09:00:00Z"},"tertiary":null,"extraRateWindows":[{"id":"codex-spark-weekly","title":"Codex Spark Weekly","window":{"usedPercent":0.0,"windowMinutes":10080,"resetsAt":"2099-01-09T00:00:00Z"}}],"identity":{"providerID":"codex"}},"credits":{"remaining":0,"events":[{"detail":"ABTOP_E2E_PRIVATE_CREDITS_DETAIL"}]}},{"provider":"kimi","source":"web","error":{"kind":"provider","code":1,"message":"ABTOP_E2E_PRIVATE_RAW_ERROR_DETAIL"}},{"provider":"grok","source":"grok-web","usage":{"primary":{"usedPercent":18.0,"resetsAt":"2099-01-04T06:36:08Z"},"secondary":null,"tertiary":null,"extraRateWindows":[],"identity":{"providerID":"grok"}}}]' + printf '%s\n' "ABTOP_E2E_PRIVATE_RAW_ERROR_DETAIL" >&2 + exit 42 + ;; + error) + printf '%s\n' "ABTOP_E2E_PRIVATE_RAW_ERROR_DETAIL" >&2 + exit 42 + ;; + *) + printf '%s\n' "unknown fake CodexBar mode" >&2 + exit 65 + ;; +esac +FAKE_CODEXBAR + chmod 700 "$fake_bin/codexbar" +} + +start_codexbar_abtop() { + local evidence_name="$1" + local home="$2" + local isolated_codex_home="$3" + local fake_bin="$4" + local mode_file="$5" + local calls_file="$6" + local session_id + run_atui_session "$RUN_ROOT/$evidence_name-run.json" \ + --cols 160 --rows 48 --cwd "$RUN_ROOT" -- \ + env \ + "HOME=$home" \ + "XDG_CONFIG_HOME=$home/.config" \ + "CODEX_HOME=$isolated_codex_home" \ + "PATH=$fake_bin:$PATH" \ + "ABTOP_E2E_CODEXBAR_MODE_FILE=$mode_file" \ + "ABTOP_E2E_CODEXBAR_CALLS_FILE=$calls_file" \ + "$ABTOP_BIN" + session_id="$LAST_ATUI_SESSION" + wait_screen_regex "$session_id" "abtop" 15 "$evidence_name-ready" \ + || fail "abtop did not render in agent-tui session $session_id" +} + +find_isolated_config() { + local home="$1" + local config_file + case "$(uname -s)" in + Darwin) + config_file="$home/Library/Application Support/abtop/config.toml" + ;; + Linux) + config_file="$home/.config/abtop/config.toml" + ;; + *) + fail "agent-tui E2E tests support only macOS and Linux" + ;; + esac + [[ -f "$config_file" ]] || fail "abtop did not persist an isolated config file" + printf '%s\n' "$config_file" +} + +seed_isolated_codexbar_config() { + local home="$1" + local config_dir + case "$(uname -s)" in + Darwin) + config_dir="$home/Library/Application Support/abtop" + ;; + Linux) + config_dir="$home/.config/abtop" + ;; + *) + fail "agent-tui E2E tests support only macOS and Linux" + ;; + esac + mkdir -p "$config_dir" + cat >"$config_dir/config.toml" <<'ISOLATED_CONFIG' +codexbar_quota_fallback = false +hidden_agents = ["claude", "codex", "opencode", "grok", "kimi"] +show_context = false +show_quota = true +show_tokens = false +show_projects = false +show_ports = false +show_sessions = false +show_mcp = false +ISOLATED_CONFIG +} + +run_codexbar_suite() { + local suite_root home isolated_codex_home fake_bin mode_file calls_file + local initial_id persisted_id error_id off_id config_file calls_before calls_after + suite_root="$RUN_ROOT/codexbar" + home="$suite_root/home" + isolated_codex_home="$suite_root/empty-codex-home" + fake_bin="$suite_root/fake-bin" + mode_file="$suite_root/mode" + calls_file="$suite_root/calls.log" + mkdir -p "$home" "$isolated_codex_home" + seed_isolated_codexbar_config "$home" + : >"$calls_file" + printf '%s\n' partial >"$mode_file" + write_fake_codexbar "$fake_bin" + + log "CodexBar: asserting initial off state and interactive enable" + start_codexbar_abtop "codexbar-initial" "$home" "$isolated_codex_home" "$fake_bin" "$mode_file" "$calls_file" + initial_id="$LAST_ATUI_SESSION" + open_codexbar_config "$initial_id" "codexbar-initial" + assert_screen_regex "codexbar-initial-selected" "${CONFIG_LABEL}[[:space:]]+off" + atui "$initial_id" press Enter >/dev/null + wait_screen_regex "$initial_id" "${CONFIG_LABEL}[[:space:]]+partial([[:space:]]|$)" 20 "codexbar-partial-config" \ + || fail "CodexBar quotas did not reach partial state" + assert_codexbar_private_data_absent "$RUN_ROOT/codexbar-partial-config.txt" + atui "$initial_id" press Esc >/dev/null + wait_screen_regex "$initial_id" "KIMI·CB" 10 "codexbar-partial-quota" \ + || fail "quota panel did not render the unavailable Kimi provider" + assert_screen_regex "codexbar-partial-quota" "unavailable" + assert_screen_regex "codexbar-partial-quota" "CLAUDE·CB" + assert_screen_regex "codexbar-partial-quota" "CODEX·CB" + assert_screen_regex "codexbar-partial-quota" "GROK·CB" + assert_screen_regex "codexbar-partial-quota" "Tertiary" + assert_screen_regex "codexbar-partial-quota" "Fable only" + assert_screen_regex "codexbar-partial-quota" "Codex Spark Week" + assert_codexbar_private_data_absent "$RUN_ROOT/codexbar-partial-quota.txt" + config_file="$(find_isolated_config "$home")" + grep -E -q '^codexbar_quota_fallback[[:space:]]*=[[:space:]]*true$' "$config_file" \ + || fail "CodexBar enabled setting was not persisted" + grep -F -q 'usage --format json --json-only --no-color' "$calls_file" \ + || fail "fake CodexBar did not receive the exact bounded command" + kill_atui_session "$initial_id" + + log "CodexBar: asserting enabled-state persistence and JSON diagnostics" + start_codexbar_abtop "codexbar-persisted" "$home" "$isolated_codex_home" "$fake_bin" "$mode_file" "$calls_file" + persisted_id="$LAST_ATUI_SESSION" + wait_screen_regex "$persisted_id" "KIMI·CB" 20 "codexbar-persisted-quota" \ + || fail "persisted CodexBar setting did not restore provider quotas" + assert_screen_regex "codexbar-persisted-quota" "unavailable" + assert_screen_regex "codexbar-persisted-quota" "CLAUDE·CB" + assert_screen_regex "codexbar-persisted-quota" "CODEX·CB" + assert_screen_regex "codexbar-persisted-quota" "GROK·CB" + assert_codexbar_private_data_absent "$RUN_ROOT/codexbar-persisted-quota.txt" + open_codexbar_config "$persisted_id" "codexbar-persisted" + wait_screen_regex "$persisted_id" "${CONFIG_LABEL}[[:space:]]+partial([[:space:]]|$)" 10 "codexbar-persisted-config" \ + || fail "persisted config did not show partial CodexBar state" + assert_codexbar_private_data_absent "$RUN_ROOT/codexbar-persisted-config.txt" + env \ + "HOME=$home" \ + "XDG_CONFIG_HOME=$home/.config" \ + "CODEX_HOME=$isolated_codex_home" \ + "PATH=$fake_bin:$PATH" \ + "ABTOP_E2E_CODEXBAR_MODE_FILE=$mode_file" \ + "ABTOP_E2E_CODEXBAR_CALLS_FILE=$calls_file" \ + "$ABTOP_BIN" --json \ + >"$RUN_ROOT/codexbar-partial-snapshot.json" \ + 2>"$RUN_ROOT/codexbar-partial-snapshot.stderr.txt" + jq -e ' + .codexbar_quota.enabled == true and + .codexbar_quota.state == "partial" and + .codexbar_quota.provenance == "codexbar" and + .codexbar_quota.stale == false and + [.codexbar_quota.providers[].provider] == ["claude", "codex", "grok", "kimi"] and + all(.codexbar_quota.providers[0:3][]; + .state == "active" and + .provenance == "codexbar" and + .stale == false and + .error == null + ) and + .codexbar_quota.providers[3].provider == "kimi" and + .codexbar_quota.providers[3].state == "unavailable" and + .codexbar_quota.providers[3].provenance == null and + .codexbar_quota.providers[3].stale == false and + .codexbar_quota.providers[3].error == "provider_error" and + any(.rate_limits[]; + .source == "claude" and + (.windows | length) == 4 and + any(.windows[]; + .id == "tertiary" and + .used_pct == 11 and + .window_minutes == 43200 and + .provenance == "codexbar" + ) and + any(.windows[]; + .id == "claude-weekly-scoped-fable" and + .label == "Fable only" and + .used_pct == 0 and + .window_minutes == 10080 and + .provenance == "codexbar" + ) + ) and + any(.rate_limits[]; + .source == "codex" and + any(.windows[]; + .id == "codex-spark-weekly" and + .label == "Codex Spark Weekly" and + .used_pct == 0 and + .window_minutes == 10080 and + .provenance == "codexbar" + ) + ) and + any(.rate_limits[]; + .source == "grok" and + any(.windows[]; + .id == "primary" and + .used_pct == 18 and + .resets_at != null and + .window_minutes == null and + .provenance == "codexbar" + ) + ) and + all(.rate_limits[].windows[]; + (.id | type) == "string" and + (.label | type) == "string" + ) + ' "$RUN_ROOT/codexbar-partial-snapshot.json" >/dev/null \ + || fail "partial CodexBar JSON snapshot did not match the provider/window contract" + assert_codexbar_private_data_absent "$RUN_ROOT/codexbar-partial-snapshot.json" + assert_codexbar_private_data_absent "$RUN_ROOT/codexbar-partial-snapshot.stderr.txt" + + env \ + "HOME=$home" \ + "XDG_CONFIG_HOME=$home/.config" \ + "CODEX_HOME=$isolated_codex_home" \ + "PATH=$fake_bin:$PATH" \ + "ABTOP_E2E_CODEXBAR_MODE_FILE=$mode_file" \ + "ABTOP_E2E_CODEXBAR_CALLS_FILE=$calls_file" \ + "$ABTOP_BIN" --once \ + >"$RUN_ROOT/codexbar-partial-once.txt" \ + 2>"$RUN_ROOT/codexbar-partial-once.stderr.txt" + grep -E -q '^quota:$' "$RUN_ROOT/codexbar-partial-once.txt" \ + || fail "--once omitted the quota section" + grep -E -q '^ claude:' "$RUN_ROOT/codexbar-partial-once.txt" \ + || fail "--once omitted Claude CodexBar quota" + grep -E -q '^ codex:' "$RUN_ROOT/codexbar-partial-once.txt" \ + || fail "--once omitted Codex CodexBar quota" + grep -E -q '^ grok:' "$RUN_ROOT/codexbar-partial-once.txt" \ + || fail "--once omitted Grok CodexBar quota" + grep -E -q '^ kimi: unavailable$' "$RUN_ROOT/codexbar-partial-once.txt" \ + || fail "--once omitted the sanitized Kimi unavailable state" + grep -F -q 'Fable only' "$RUN_ROOT/codexbar-partial-once.txt" \ + || fail "--once omitted a Claude extra quota window" + grep -F -q 'Tertiary' "$RUN_ROOT/codexbar-partial-once.txt" \ + || fail "--once omitted a Claude tertiary quota window" + grep -F -q 'Codex Spark Weekly' "$RUN_ROOT/codexbar-partial-once.txt" \ + || fail "--once omitted a Codex extra quota window" + assert_codexbar_private_data_absent "$RUN_ROOT/codexbar-partial-once.txt" + assert_codexbar_private_data_absent "$RUN_ROOT/codexbar-partial-once.stderr.txt" + kill_atui_session "$persisted_id" + + log "CodexBar: asserting sanitized unavailable state" + printf '%s\n' error >"$mode_file" + start_codexbar_abtop "codexbar-error" "$home" "$isolated_codex_home" "$fake_bin" "$mode_file" "$calls_file" + error_id="$LAST_ATUI_SESSION" + open_codexbar_config "$error_id" "codexbar-error" + wait_screen_regex "$error_id" "${CONFIG_LABEL}[[:space:]]+unavailable([[:space:]]|$)" 20 "codexbar-unavailable-config" \ + || fail "CodexBar process failure was not surfaced as unavailable" + assert_codexbar_private_data_absent "$RUN_ROOT/codexbar-unavailable-config.txt" + env \ + "HOME=$home" \ + "XDG_CONFIG_HOME=$home/.config" \ + "CODEX_HOME=$isolated_codex_home" \ + "PATH=$fake_bin:$PATH" \ + "ABTOP_E2E_CODEXBAR_MODE_FILE=$mode_file" \ + "ABTOP_E2E_CODEXBAR_CALLS_FILE=$calls_file" \ + "$ABTOP_BIN" --json \ + >"$RUN_ROOT/codexbar-error-snapshot.json" \ + 2>"$RUN_ROOT/codexbar-error-snapshot.stderr.txt" + jq -e ' + .codexbar_quota.enabled == true and + .codexbar_quota.state == "unavailable" and + .codexbar_quota.provenance == null and + .codexbar_quota.error == "process_failed" + ' "$RUN_ROOT/codexbar-error-snapshot.json" >/dev/null \ + || fail "CodexBar error JSON snapshot was not sanitized and actionable" + assert_codexbar_private_data_absent "$RUN_ROOT/codexbar-error-snapshot.json" + assert_codexbar_private_data_absent "$RUN_ROOT/codexbar-error-snapshot.stderr.txt" + + log "CodexBar: asserting interactive disable and disabled persistence" + # open_codexbar_config leaves the exact CodexBar row selected. + atui "$error_id" press Enter >/dev/null + wait_screen_regex "$error_id" "${CONFIG_LABEL}[[:space:]]+off([[:space:]]|$)" 10 "codexbar-disabled-config" \ + || fail "CodexBar setting did not switch off" + grep -E -q '^codexbar_quota_fallback[[:space:]]*=[[:space:]]*false$' "$config_file" \ + || fail "CodexBar disabled setting was not persisted" + kill_atui_session "$error_id" + + calls_before="$(wc -l <"$calls_file" | tr -d ' ')" + start_codexbar_abtop "codexbar-off" "$home" "$isolated_codex_home" "$fake_bin" "$mode_file" "$calls_file" + off_id="$LAST_ATUI_SESSION" + open_codexbar_config "$off_id" "codexbar-off-persisted" + assert_screen_regex "codexbar-off-persisted-selected" "${CONFIG_LABEL}[[:space:]]+off" + calls_after="$(wc -l <"$calls_file" | tr -d ' ')" + [[ "$calls_after" == "$calls_before" ]] \ + || fail "disabled persisted setting still invoked CodexBar" + kill_atui_session "$off_id" + log "CodexBar suite passed" +} + +herdr_socket() { + HERDR_SOCKET_PATH="$HERDR_SOCKET" herdr "$@" +} + +wait_for_named_herdr() { + local deadline sessions_json matches + deadline=$(( $(date +%s) + 20 )) + while :; do + sessions_json="$(herdr session list --json)" + matches="$(printf '%s' "$sessions_json" | jq --arg name "$HERDR_NAME" '[.sessions[] | select(.name == $name and .running == true)] | length')" + if [[ "$matches" == 1 ]]; then + HERDR_SOCKET="$(printf '%s' "$sessions_json" | jq -er --arg name "$HERDR_NAME" '.sessions[] | select(.name == $name and .running == true) | .socket_path')" + [[ "$HERDR_SOCKET" == /* ]] || fail "named Herdr session returned a non-absolute socket" + printf '%s\n' "$sessions_json" >"$RUN_ROOT/herdr-session-list.json" + return 0 + fi + (($(date +%s) < deadline)) || return 1 + sleep 1 + done +} + +filtered_agent_list() { + local output_file="$1" + herdr_socket agent list | jq '{ + result: { + agents: ([.result.agents[] | + { + terminal_id, agent, name, agent_status, + agent_session, workspace_id, tab_id, pane_id, focused, + state_change_seq, revision + } + + if has("screen_detection_skipped") then + {screen_detection_skipped} + else + {} + end + ] | sort_by([.terminal_id, .pane_id, (.agent_session.value // "")])) + } + }' >"$output_file" +} + +filtered_process_info() { + local pane_id="$1" + local output_file="$2" + herdr_socket pane process-info --pane "$pane_id" | jq '{ + result: { + process_info: { + pane_id: .result.process_info.pane_id, + shell_pid: (.result.process_info.shell_pid // null), + foreground_process_group_id: + (.result.process_info.foreground_process_group_id // null), + foreground_processes: + ([.result.process_info.foreground_processes[] | {pid, name}] + | sort_by(.pid)) + } + } + }' >"$output_file" +} + +wait_for_exact_codex_session() { + local pane_id="$1" + local agent_target="$2" + local output_file="$3" + local timeout_seconds="$4" + local deadline matches + deadline=$(( $(date +%s) + timeout_seconds )) + while :; do + filtered_agent_list "$output_file" + matches="$(jq --arg pane "$pane_id" --arg target "$agent_target" '[ + .result.agents[] | + select( + .pane_id == $pane and + .agent == "codex" and + .name == $target and + .agent_session.source == "herdr:codex" and + .agent_session.agent == "codex" and + .agent_session.kind == "id" and + (.agent_session.value | type == "string" and length > 0 and length <= 512) + ) + ] | length' "$output_file")" + [[ "$matches" == 1 ]] && return 0 + (($(date +%s) < deadline)) || return 1 + sleep 1 + done +} + +wait_agent_focused() { + local target="$1" + local deadline + deadline=$(( $(date +%s) + 10 )) + while :; do + if herdr_socket agent get "$target" 2>/dev/null \ + | jq -e '.result.agent.focused == true' >/dev/null; then + return 0 + fi + (($(date +%s) < deadline)) || return 1 + sleep 1 + done +} + +exact_codex_agent_pair_is_unchanged() { + local before_file="$1" + local after_file="$2" + local pane_id="$3" + local session_id="$4" + local expected_status="$5" + local agent_target="$6" + + jq -e \ + --slurpfile before "$before_file" \ + --arg pane "$pane_id" \ + --arg sid "$session_id" \ + --arg expected "$expected_status" \ + --arg target "$agent_target" ' + def exact_target($document): + [$document.result.agents[] | + select( + .pane_id == $pane and + .agent == "codex" and + .name == $target and + .agent_session.source == "herdr:codex" and + .agent_session.agent == "codex" and + .agent_session.kind == "id" and + .agent_session.value == $sid + )]; + def screen_detection_used: + ((has("screen_detection_skipped") | not) or + .screen_detection_skipped == false); + exact_target($before[0]) as $first | + exact_target(.) as $second | + ($first | length) == 1 and + ($second | length) == 1 and + $first[0].agent_status == $expected and + $second[0].agent_status == $expected and + ($first[0] | screen_detection_used) and + ($second[0] | screen_detection_used) and + ($first[0].terminal_id | type == "string" and length > 0) and + ($first[0].workspace_id | type == "string" and length > 0) and + ($first[0].tab_id | type == "string" and length > 0) and + ($first[0].state_change_seq | type == "number") and + ($first[0].revision | type == "number") and + $first[0] == $second[0] + ' "$after_file" >/dev/null +} + +codex_agent_explain_matches() { + local explain_file="$1" + local expected_status="$2" + + jq -e --arg expected "$expected_status" ' + def false_or_absent($field): + ((has($field) | not) or .[$field] == false); + .agent == "codex" and + .state == $expected and + (.manifest_source | type == "string" and length > 0) and + (.manifest_version | type == "string" and length > 0) and + (.matched_rule | type == "object") and + .matched_rule.state == $expected and + (.matched_rule.id | type == "string" and length > 0) and + (.matched_rule.region | type == "string" and length > 0) and + false_or_absent("screen_detection_skipped") and + ((has("skipped_update_reason") | not) or + .skipped_update_reason == null) and + ((has("skip_state_update") | not) or .skip_state_update == false) + ' "$explain_file" >/dev/null +} + +start_codex_agent() { + local agent_target="$1" + local pane_id="$2" + local output_file="$3" + local error_prefix="$4" + shift 4 + local start_output start_status start_deadline start_attempts + local start_timeout_ms start_remaining_seconds + + start_deadline=$(( $(date +%s) + 20 )) + start_attempts=0 + while :; do + start_attempts=$((start_attempts + 1)) + start_remaining_seconds=$((start_deadline - $(date +%s))) + if ((start_remaining_seconds <= 3)); then + printf '%s\n' \ + "Codex start deadline expired before attempt $start_attempts" \ + >"$RUN_ROOT/$error_prefix.txt" + herdr_socket pane process-info --pane "$pane_id" \ + >"$RUN_ROOT/$error_prefix-process-info.json" 2>&1 || true + capture_screen "$HERDR_TUI_SESSION" "$error_prefix-screen" || true + return 1 + fi + start_timeout_ms=$((start_remaining_seconds * 1000)) + # Herdr requires more than 3000 ms and one successful launch may need + # its entire readiness window. Busy-shell retries return immediately; + # every other attempt is bounded by the remaining 20-second deadline. + set +e + start_output="$(herdr_socket agent start "$agent_target" \ + --kind codex --pane "$pane_id" --timeout "$start_timeout_ms" -- \ + "$@" 2>&1)" + start_status=$? + set -e + if ((start_status == 0)); then + if printf '%s\n' "$start_output" | jq -e \ + --arg name "$agent_target" --arg pane "$pane_id" ' + .id == "cli:agent:start" and + .result.type == "agent_started" and + .result.agent.agent == "codex" and + .result.agent.name == $name and + .result.agent.pane_id == $pane and + .result.agent.interactive_ready == true + ' >/dev/null 2>&1; then + printf '%s\n' "$start_output" >"$output_file" + return 0 + fi + printf '%s\n' "$start_output" >"$RUN_ROOT/$error_prefix.txt" + herdr_socket pane process-info --pane "$pane_id" \ + >"$RUN_ROOT/$error_prefix-process-info.json" 2>&1 || true + capture_screen "$HERDR_TUI_SESSION" "$error_prefix-screen" || true + return 1 + fi + + if ! printf '%s\n' "$start_output" \ + | jq -e '.error.code == "agent_pane_busy"' >/dev/null 2>&1 \ + || (($(date +%s) >= start_deadline)); then + printf '%s\n' "$start_output" >"$RUN_ROOT/$error_prefix.txt" + herdr_socket pane process-info --pane "$pane_id" \ + >"$RUN_ROOT/$error_prefix-process-info.json" 2>&1 || true + capture_screen "$HERDR_TUI_SESSION" "$error_prefix-screen" || true + return 1 + fi + sleep 1 + done +} + +wait_for_pid_exit() { + local pid="$1" + local timeout_seconds="$2" + local deadline + deadline=$(( $(date +%s) + timeout_seconds )) + while kill -0 "$pid" 2>/dev/null; do + (($(date +%s) < deadline)) || return 1 + sleep 1 + done +} + +wait_for_pane_process_absent() { + local pane_id="$1" + local process_name="$2" + local timeout_seconds="$3" + local deadline process_file + deadline=$(( $(date +%s) + timeout_seconds )) + process_file="$RUN_ROOT/pane-process-absence-$pane_id.json" + while :; do + if filtered_process_info "$pane_id" "$process_file" \ + && jq -e --arg name "$process_name" ' + all(.result.process_info.foreground_processes[]; + .name != $name) + ' "$process_file" >/dev/null; then + return 0 + fi + (($(date +%s) < deadline)) || return 1 + sleep 1 + done +} + +wait_for_pane_process_present() { + local pane_id="$1" + local process_name="$2" + local timeout_seconds="$3" + local deadline process_file + deadline=$(( $(date +%s) + timeout_seconds )) + process_file="$RUN_ROOT/pane-process-presence-$pane_id.json" + while :; do + if filtered_process_info "$pane_id" "$process_file" \ + && jq -e --arg name "$process_name" ' + any(.result.process_info.foreground_processes[]; + .name == $name) + ' "$process_file" >/dev/null; then + return 0 + fi + (($(date +%s) < deadline)) || return 1 + sleep 1 + done +} + +wait_for_fresh_snapshot_without_session() { + local session_id="$1" + local status_home="$2" + local timeout_seconds="$3" + local evidence_name="$4" + local deadline attempt snapshot_file + deadline=$(( $(date +%s) + timeout_seconds )) + attempt=0 + while :; do + attempt=$((attempt + 1)) + printf -v snapshot_file '%s/%s-poll-%03d.json' \ + "$RUN_ROOT" "$evidence_name" "$attempt" + env \ + "HOME=$status_home" \ + "XDG_CONFIG_HOME=$status_home/.config" \ + "CODEX_HOME=$CODEX_HOME_PATH" \ + HERDR_ENV=1 \ + "HERDR_SOCKET_PATH=$HERDR_SOCKET" \ + "$ABTOP_BIN" --json >"$snapshot_file" + if jq -e --arg sid "$session_id" ' + all(.sessions[]; .session_id != $sid) + ' "$snapshot_file" >/dev/null; then + cp "$snapshot_file" "$RUN_ROOT/$evidence_name.json" + return 0 + fi + (($(date +%s) < deadline)) || return 1 + sleep 1 + done +} + +capture_codex_status_phase() { + local phase="$1" + local expected_status="$2" + local expected_reason="$3" + local expected_herdr_status="$4" + local status_home="$5" + local root_pane="$6" + local codex_agent_target="$7" + local codex_session_id="$8" + local timeout_seconds="$9" + local screen_evidence_name="${10}" + local deadline attempt now poll_base before_process_file after_process_file + local before_explain_file after_explain_file before_snapshot_file + local after_snapshot_file after_screen_file explain_file process_file + local snapshot_file screen_file native_pid codex_display_id ui_label ui_regex + + case "$expected_status" in + Idle) ui_label="Idle" ;; + Executing) ui_label="Exec" ;; + Working) ui_label="Work" ;; + *) return 1 ;; + esac + # The table deliberately renders the first UUID group, while the selected + # session detail and filter retain the complete native UUID. + codex_display_id="${codex_session_id%%-*}" + ui_regex="($codex_display_id.*$ui_label|$ui_label.*$codex_display_id)" + deadline=$(( $(date +%s) + timeout_seconds )) + attempt=0 + + while :; do + attempt=$((attempt + 1)) + printf -v poll_base 'codex-%s-poll-%03d' "$phase" "$attempt" + before_process_file="$RUN_ROOT/$poll_base-herdr-before-process.json" + after_process_file="$RUN_ROOT/$poll_base-herdr-after-process.json" + before_explain_file="$RUN_ROOT/$poll_base-herdr-before-explain.json" + after_explain_file="$RUN_ROOT/$poll_base-herdr-after-explain.json" + before_snapshot_file="$RUN_ROOT/$poll_base-herdr-before-snapshot.json" + after_snapshot_file="$RUN_ROOT/$poll_base-herdr-after-snapshot.json" + after_screen_file="$RUN_ROOT/$poll_base-herdr-after-screen.json" + explain_file="$RUN_ROOT/$poll_base-herdr-explain.json" + process_file="$RUN_ROOT/$poll_base-process-info.json" + snapshot_file="$RUN_ROOT/$poll_base-snapshot.json" + screen_file="$RUN_ROOT/$poll_base-screen.txt" + + # Each provider read is independently bracketed by one unchanged exact + # Herdr row. A changing revision, identity, pane, session, or state + # invalidates the attempt instead of being normalized away. + if filtered_agent_list "$before_process_file" \ + && filtered_process_info "$root_pane" "$process_file" \ + && filtered_agent_list "$after_process_file" \ + && exact_codex_agent_pair_is_unchanged \ + "$before_process_file" "$after_process_file" \ + "$root_pane" "$codex_session_id" "$expected_herdr_status" \ + "$codex_agent_target" \ + && filtered_agent_list "$before_explain_file" \ + && herdr_socket agent explain "$codex_agent_target" --json \ + >"$explain_file" \ + && filtered_agent_list "$after_explain_file" \ + && exact_codex_agent_pair_is_unchanged \ + "$before_explain_file" "$after_explain_file" \ + "$root_pane" "$codex_session_id" "$expected_herdr_status" \ + "$codex_agent_target" \ + && codex_agent_explain_matches \ + "$explain_file" "$expected_herdr_status" \ + && filtered_agent_list "$before_snapshot_file" \ + && env \ + "HOME=$status_home" \ + "XDG_CONFIG_HOME=$status_home/.config" \ + "CODEX_HOME=$CODEX_HOME_PATH" \ + HERDR_ENV=1 \ + "HERDR_SOCKET_PATH=$HERDR_SOCKET" \ + "$ABTOP_BIN" --json >"$snapshot_file" \ + && filtered_agent_list "$after_snapshot_file" \ + && exact_codex_agent_pair_is_unchanged \ + "$before_snapshot_file" "$after_snapshot_file" \ + "$root_pane" "$codex_session_id" "$expected_herdr_status" \ + "$codex_agent_target" \ + && capture_screen "$HERDR_TUI_SESSION" "$poll_base-screen" \ + && filtered_agent_list "$after_screen_file" \ + && exact_codex_agent_pair_is_unchanged \ + "$after_snapshot_file" "$after_screen_file" \ + "$root_pane" "$codex_session_id" "$expected_herdr_status" \ + "$codex_agent_target" \ + && exact_codex_agent_pair_is_unchanged \ + "$before_process_file" "$after_screen_file" \ + "$root_pane" "$codex_session_id" "$expected_herdr_status" \ + "$codex_agent_target"; then + native_pid="$(jq -er ' + [.result.process_info.foreground_processes[] | + select( + .name == "codex" and + (.pid | type == "number" and . > 0) + )] + | if length == 1 then .[0].pid + else error("expected one native Codex process") end + ' "$process_file" 2>/dev/null || true)" + + if [[ -n "$native_pid" ]] \ + && jq -e --arg pane "$root_pane" --argjson pid "$native_pid" ' + .result.process_info.pane_id == $pane and + ([.result.process_info.foreground_processes[] | + select( + .pid == $pid and + .name == "codex" + )] | length) == 1 + ' "$process_file" >/dev/null \ + && jq -e \ + --arg sid "$codex_session_id" \ + --argjson pid "$native_pid" \ + --arg status "$expected_status" \ + --arg reason "$expected_reason" ' + [.sessions[] | + select(.agent_cli == "codex" and .session_id == $sid)] as $rows | + ($rows | length) == 1 and + $rows[0].pid == $pid and + $rows[0].status == $status and + $rows[0].status_evidence.authority == "Heuristic" and + $rows[0].status_evidence.reason == $reason and + $rows[0].status_evidence.connection_generation == 0 and + $rows[0].status_evidence.observations[-1].status == $status and + $rows[0].status_evidence.observations[-1].reason == $reason + ' "$snapshot_file" >/dev/null \ + && grep -E -q "$ui_regex" "$screen_file" \ + && grep -F -q "$codex_session_id" "$screen_file"; then + if jq -n \ + --arg pane_id "$root_pane" \ + --arg session_id "$codex_session_id" \ + --argjson native_codex_pid "$native_pid" \ + '{pane_id: $pane_id, + session_id: $session_id, + native_codex_pid: $native_codex_pid}' \ + >"$RUN_ROOT/codex-$phase-correlation.json" \ + && cp "$before_process_file" \ + "$RUN_ROOT/codex-$phase-herdr-agent-before-process.json" \ + && cp "$after_process_file" \ + "$RUN_ROOT/codex-$phase-herdr-agent-after-process.json" \ + && cp "$before_explain_file" \ + "$RUN_ROOT/codex-$phase-herdr-agent-before-explain.json" \ + && cp "$after_explain_file" \ + "$RUN_ROOT/codex-$phase-herdr-agent-after-explain.json" \ + && cp "$before_snapshot_file" \ + "$RUN_ROOT/codex-$phase-herdr-agent-before-snapshot.json" \ + && cp "$after_snapshot_file" \ + "$RUN_ROOT/codex-$phase-herdr-agent-after-snapshot.json" \ + && cp "$after_screen_file" \ + "$RUN_ROOT/codex-$phase-herdr-agent-after-screen.json" \ + && cp "$after_screen_file" \ + "$RUN_ROOT/codex-$phase-herdr-agent.json" \ + && cp "$explain_file" \ + "$RUN_ROOT/codex-$phase-herdr-explain.json" \ + && cp "$process_file" \ + "$RUN_ROOT/codex-$phase-process-info.json" \ + && cp "$snapshot_file" \ + "$RUN_ROOT/codex-$phase-snapshot.json" \ + && cp "$RUN_ROOT/$poll_base-screen.json" \ + "$RUN_ROOT/$screen_evidence_name.json" \ + && cp "$screen_file" \ + "$RUN_ROOT/$screen_evidence_name.txt"; then + return 0 + fi + return 1 + fi + fi + + now=$(date +%s) + ((now < deadline)) || return 1 + sleep 1 + done +} + +run_codex_status_suite() { + local suite_root status_home session_list existing_count root_pane split_json abtop_pane + local codex_agent_target codex_session_id hook_native_pid + local abtop_command abtop_run_output current_pane filter_snapshot_file + local negative_filter_id negative_filter_last negative_filter_replacement + local no_hooks_split_json no_hooks_pane no_hooks_agent_target + local no_hooks_session_id no_hooks_native_pid no_hooks_focus_pane + require_command herdr + require_command codex + require_command git + + suite_root="$RUN_ROOT/codex-status" + status_home="$suite_root/home" + mkdir -p "$status_home" + git -C "$WORKSPACE_PATH" rev-parse --show-toplevel >"$RUN_ROOT/codex-workspace-root.txt" \ + || fail "--workspace must be inside an existing trusted Git workspace" + + if ! env CODEX_HOME="$CODEX_HOME_PATH" "$ABTOP_BIN" --codex-integration-status \ + >"$RUN_ROOT/codex-integration-status.txt" 2>&1; then + fail "Codex integration is not already healthy; review only the 11 abtop hooks, then rerun" + fi + codex --version >"$RUN_ROOT/codex-version.txt" + herdr --version >"$RUN_ROOT/herdr-version.txt" + + HERDR_NAME="abtop-e2e-$(date +%s)-$$" + session_list="$(herdr session list --json)" + existing_count="$(printf '%s' "$session_list" | jq --arg name "$HERDR_NAME" '[.sessions[] | select(.name == $name)] | length')" + [[ "$existing_count" == 0 ]] || fail "refusing to reuse existing Herdr session $HERDR_NAME" + + log "Codex status: creating exact named Herdr session $HERDR_NAME" + # agent-tui creates a separate virtual terminal. Do not let an outer Herdr + # pane's inherited runtime identity misclassify that PTY as a nested launch. + run_atui_session "$RUN_ROOT/herdr-agent-tui-run.json" \ + --cols 160 --rows 48 --cwd "$WORKSPACE_PATH" -- \ + env \ + -u HERDR_ENV \ + -u HERDR_SOCKET_PATH \ + -u HERDR_CLIENT_SOCKET_PATH \ + -u HERDR_SESSION \ + -u HERDR_PANE_ID \ + -u HERDR_TAB_ID \ + -u HERDR_WORKSPACE_ID \ + -u HERDR_STARTUP_CWD \ + "CODEX_HOME=$CODEX_HOME_PATH" \ + herdr --session "$HERDR_NAME" + HERDR_TUI_SESSION="$LAST_ATUI_SESSION" + HERDR_MAY_EXIST=1 + if ! wait_for_named_herdr; then + herdr session list --json >"$RUN_ROOT/herdr-session-list-failed.json" 2>&1 || true + capture_screen "$HERDR_TUI_SESSION" "herdr-session-start-failed" || true + fail "named Herdr session did not become available" + fi + + herdr_socket pane list >"$RUN_ROOT/herdr-panes-initial.json" + root_pane="$(jq -er '.result.panes | if length == 1 then .[0].pane_id else error("expected exactly one initial pane") end' "$RUN_ROOT/herdr-panes-initial.json")" + split_json="$(herdr_socket pane split "$root_pane" --direction down --ratio 0.5 --cwd "$WORKSPACE_PATH" --no-focus)" + printf '%s\n' "$split_json" >"$RUN_ROOT/herdr-pane-split.json" + abtop_pane="$(printf '%s' "$split_json" | jq -er '.result.pane.pane_id')" + [[ "$abtop_pane" != "$root_pane" ]] || fail "Herdr split reused the root pane" + + log "Codex status: starting authenticated Codex with read-only sandbox" + codex_agent_target="abtop-e2e-codex" + start_codex_agent \ + "$codex_agent_target" "$root_pane" \ + "$RUN_ROOT/codex-agent-start.txt" "codex-agent-start-error" \ + --sandbox read-only --ask-for-approval never --no-alt-screen \ + -C "$WORKSPACE_PATH" \ + || fail "Codex did not reach ready state; no hook or directory trust was changed" + + # Codex queues its startup SessionStart hook into the first turn. Run one + # bounded no-tool turn so Herdr can publish the exact native session ID. + log "Codex status: establishing exact native session identity" + herdr_socket agent prompt "$codex_agent_target" \ + 'Reply with READY. Do not run any tools.' \ + --wait --until idle --until "done" --timeout 120000 \ + >"$RUN_ROOT/herdr-codex-identity-prompt.json" + herdr_socket agent focus "$codex_agent_target" \ + >"$RUN_ROOT/herdr-focus-codex-after-identity.json" + herdr_socket agent wait "$codex_agent_target" --until idle --timeout 10000 \ + >"$RUN_ROOT/herdr-wait-identity-idle.json" + wait_for_exact_codex_session \ + "$root_pane" "$codex_agent_target" \ + "$RUN_ROOT/herdr-agents-ready.json" 20 \ + || fail "Herdr did not publish one exact native Codex session reference" + codex_session_id="$(jq -er --arg pane "$root_pane" ' + [.result.agents[] | + select( + .pane_id == $pane and + .agent == "codex" and + .name == "abtop-e2e-codex" and + .agent_session.source == "herdr:codex" and + .agent_session.agent == "codex" and + .agent_session.kind == "id" + )] + | if length == 1 then .[0].agent_session.value else error("expected one exact Codex session") end + ' "$RUN_ROOT/herdr-agents-ready.json")" + [[ "$codex_session_id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] \ + || fail "Herdr returned a non-UUID Codex session reference" + printf -v abtop_command \ + 'env HOME=%q XDG_CONFIG_HOME=%q CODEX_HOME=%q HERDR_SOCKET_PATH=%q %q --exit-on-jump' \ + "$status_home" "$status_home/.config" "$CODEX_HOME_PATH" "$HERDR_SOCKET" "$ABTOP_BIN" + abtop_run_output="$(herdr_socket pane run "$abtop_pane" "$abtop_command")" + printf '%s\n' "$abtop_run_output" >"$RUN_ROOT/herdr-abtop-pane-run.json" + wait_screen_regex "$HERDR_TUI_SESSION" "abtop v[0-9]" 20 "codex-status-abtop-ready" \ + || fail "abtop did not render before the UI input phase" + + # Focus the exact abtop pane, observe it, then filter with the complete + # native Codex UUID. The footer truncates the displayed filter, so result + # uniqueness is proved with a tail-sensitive negative filter, exact JSON + # identity, and navigation invariance. + herdr_socket pane focus --direction down --pane "$root_pane" \ + >"$RUN_ROOT/herdr-focus-abtop.json" + current_pane="$(jq -er '.result.focus.focused_pane_id' "$RUN_ROOT/herdr-focus-abtop.json")" + [[ "$current_pane" == "$abtop_pane" ]] || fail "Herdr did not focus the exact abtop pane" + # One raw write makes the filter text and CR indivisible. A literal LF is + # Ctrl+J in a raw terminal and must never be used as Enter. + herdr_socket pane send-text "$abtop_pane" $'/'"$codex_session_id"$'\r' \ + >"$RUN_ROOT/herdr-filter-abtop.json" + wait_screen_regex \ + "$HERDR_TUI_SESSION" \ + "SESSION \(►$codex_session_id" \ + 20 \ + "codex-status-filtered" \ + || fail "abtop did not select the exact full-UUID Codex filter result" + wait_codex_table_row_count \ + "$HERDR_TUI_SESSION" 1 10 "codex-status-filtered-one-row" \ + || fail "the filtered Sessions table did not render exactly one Codex row" + filter_snapshot_file="$RUN_ROOT/codex-filter-snapshot.json" + env \ + "HOME=$status_home" \ + "XDG_CONFIG_HOME=$status_home/.config" \ + "CODEX_HOME=$CODEX_HOME_PATH" \ + HERDR_ENV=1 \ + "HERDR_SOCKET_PATH=$HERDR_SOCKET" \ + "$ABTOP_BIN" --json >"$filter_snapshot_file" + jq -e --arg sid "$codex_session_id" ' + [.sessions[] | select(.agent_cli == "codex" and .session_id == $sid)] | + length == 1 + ' "$filter_snapshot_file" >/dev/null \ + || fail "the full Codex UUID did not identify exactly one JSON session" + + # Change only a non-rendered UUID-tail nibble. If the TUI consumed merely + # the displayed first group, this negative filter would incorrectly retain + # the Codex row and the zero-row assertion would fail. + negative_filter_last="${codex_session_id: -1}" + negative_filter_replacement="0" + if [[ "$negative_filter_last" == "0" ]]; then + negative_filter_replacement="1" + fi + negative_filter_id="${codex_session_id%?}$negative_filter_replacement" + printf '%s\n' "$negative_filter_id" \ + >"$RUN_ROOT/codex-filter-tail-negative-value.txt" + herdr_socket pane send-keys "$abtop_pane" esc \ + >"$RUN_ROOT/herdr-filter-clear-before-negative.json" + herdr_socket pane send-text "$abtop_pane" $'/'"$negative_filter_id"$'\r' \ + >"$RUN_ROOT/herdr-filter-tail-negative.json" + wait_codex_table_row_count \ + "$HERDR_TUI_SESSION" 0 10 "codex-status-filter-tail-negative" \ + || fail "abtop ignored the non-rendered suffix of its full-UUID filter" + + herdr_socket pane send-keys "$abtop_pane" esc \ + >"$RUN_ROOT/herdr-filter-clear-before-restore.json" + herdr_socket pane send-text "$abtop_pane" $'/'"$codex_session_id"$'\r' \ + >"$RUN_ROOT/herdr-filter-restore.json" + wait_codex_table_row_count \ + "$HERDR_TUI_SESSION" 1 10 "codex-status-filter-restored" \ + || fail "abtop did not restore the one-row full-UUID filter" + wait_screen_regex \ + "$HERDR_TUI_SESSION" "SESSION \(►$codex_session_id" 10 \ + "codex-status-filter-restored-session" \ + || fail "abtop did not restore the exact full-UUID Codex selection" + herdr_socket pane send-keys "$abtop_pane" down \ + >"$RUN_ROOT/herdr-filter-down.json" + wait_screen_regex \ + "$HERDR_TUI_SESSION" "SESSION \(►$codex_session_id" 3 \ + "codex-status-filter-down" \ + || fail "Down changed the only full-UUID Codex filter result" + herdr_socket pane send-keys "$abtop_pane" up \ + >"$RUN_ROOT/herdr-filter-up.json" + wait_screen_regex \ + "$HERDR_TUI_SESSION" "SESSION \(►$codex_session_id" 3 \ + "codex-status-one-match" \ + || fail "Up changed the only full-UUID Codex filter result" + + log "Codex status: asserting idle, live root tool execution, and idle completion" + herdr_socket agent wait "$codex_agent_target" --until idle --timeout 60000 \ + >"$RUN_ROOT/herdr-wait-initial-idle.json" + capture_codex_status_phase \ + initial-idle Idle HerdrScreenIdle idle \ + "$status_home" "$root_pane" "$codex_agent_target" "$codex_session_id" \ + 20 codex-status-idle \ + || fail "abtop did not project one bracketed exact idle state" + herdr_socket agent prompt "$codex_agent_target" \ + 'Run the shell command sleep 30. After it finishes, reply briefly.' \ + >"$RUN_ROOT/herdr-codex-tool-prompt.json" + herdr_socket agent wait "$codex_agent_target" --until working --timeout 30000 \ + >"$RUN_ROOT/herdr-wait-working.json" + capture_codex_status_phase \ + executing Executing HerdrScreenWorking working \ + "$status_home" "$root_pane" "$codex_agent_target" "$codex_session_id" \ + 60 codex-status-executing \ + || fail "abtop did not show one bracketed Exec phase for the exact root tool" + herdr_socket agent wait "$codex_agent_target" --until idle --timeout 120000 \ + >"$RUN_ROOT/herdr-wait-final-idle.json" + capture_codex_status_phase \ + final-idle Idle HerdrScreenIdle idle \ + "$status_home" "$root_pane" "$codex_agent_target" "$codex_session_id" \ + 30 codex-status-complete \ + || fail "abtop did not return the exact Codex session to a bracketed Idle phase" + + log "Codex status: asserting Enter focuses the exact Codex pane" + herdr_socket pane focus --direction down --pane "$root_pane" \ + >"$RUN_ROOT/herdr-refocus-abtop.json" + herdr_socket pane list >"$RUN_ROOT/herdr-panes-before-enter.json" + herdr_socket agent get "$root_pane" >"$RUN_ROOT/herdr-agent-before-enter.json" + jq -e --arg pane "$abtop_pane" ' + [.result.panes[] | select(.focused == true and .pane_id == $pane)] | length == 1 + ' "$RUN_ROOT/herdr-panes-before-enter.json" >/dev/null \ + || fail "the exact abtop pane was not focused before Enter" + capture_screen "$HERDR_TUI_SESSION" "codex-focus-before-enter" + assert_screen_absent "codex-focus-before-enter" "agent list returned invalid JSON" + atui "$HERDR_TUI_SESSION" press Enter >"$RUN_ROOT/agent-tui-abtop-enter.json" + wait_agent_focused "$root_pane" || fail "Enter did not focus the exact Codex pane" + herdr_socket pane list >"$RUN_ROOT/herdr-panes-after-enter.json" + capture_screen "$HERDR_TUI_SESSION" "codex-focus-after-enter" + assert_screen_absent "codex-focus-after-enter" "agent list returned invalid JSON" + herdr_socket agent get "$root_pane" >"$RUN_ROOT/herdr-focused-codex.json" + jq -e --arg pane "$root_pane" ' + [.result.panes[] | select(.focused == true and .pane_id == $pane)] | length == 1 + ' "$RUN_ROOT/herdr-panes-after-enter.json" >/dev/null \ + || fail "Herdr pane state did not move focus to the exact Codex pane" + jq -e \ + --arg pane "$root_pane" \ + --arg sid "$codex_session_id" ' + .result.agent.focused == true and + .result.agent.pane_id == $pane and + .result.agent.agent == "codex" and + .result.agent.name == "abtop-e2e-codex" and + .result.agent.agent_session.source == "herdr:codex" and + .result.agent.agent_session.agent == "codex" and + .result.agent.agent_session.kind == "id" and + .result.agent.agent_session.value == $sid + ' "$RUN_ROOT/herdr-focused-codex.json" >/dev/null \ + || fail "Herdr did not confirm exact Codex focus" + + hook_native_pid="$(jq -er '.native_codex_pid' \ + "$RUN_ROOT/codex-final-idle-correlation.json")" + + log "Codex status: asserting exact Herdr Working without abtop hooks" + no_hooks_agent_target="abtop-e2e-codex-no-hooks" + no_hooks_split_json="$(herdr_socket pane split "$root_pane" \ + --direction right --ratio 0.5 --cwd "$WORKSPACE_PATH" --no-focus)" + printf '%s\n' "$no_hooks_split_json" \ + >"$RUN_ROOT/herdr-no-hooks-pane-split.json" + no_hooks_pane="$(printf '%s' "$no_hooks_split_json" \ + | jq -er '.result.pane.pane_id')" + [[ "$no_hooks_pane" != "$root_pane" && "$no_hooks_pane" != "$abtop_pane" ]] \ + || fail "Herdr reused a pane for the no-hooks Codex session" + start_codex_agent \ + "$no_hooks_agent_target" "$no_hooks_pane" \ + "$RUN_ROOT/codex-no-hooks-agent-start.txt" \ + "codex-no-hooks-agent-start-error" \ + --config 'plugins."abtop@abtop-local".enabled=false' \ + --sandbox read-only --ask-for-approval never \ + --no-alt-screen -C "$WORKSPACE_PATH" \ + || fail "Codex with invocation-scoped disabled abtop plugin did not reach ready state" + + herdr_socket agent prompt "$no_hooks_agent_target" \ + 'Reply with READY. Do not run any tools.' \ + --wait --until idle --until "done" --timeout 120000 \ + >"$RUN_ROOT/herdr-codex-no-hooks-identity-prompt.json" + herdr_socket agent wait "$no_hooks_agent_target" --until idle --timeout 10000 \ + >"$RUN_ROOT/herdr-wait-no-hooks-identity-idle.json" + wait_for_exact_codex_session \ + "$no_hooks_pane" "$no_hooks_agent_target" \ + "$RUN_ROOT/herdr-no-hooks-agents-ready.json" 20 \ + || fail "Herdr did not publish the no-hooks Codex session identity" + no_hooks_session_id="$(jq -er \ + --arg pane "$no_hooks_pane" --arg target "$no_hooks_agent_target" ' + [.result.agents[] | + select( + .pane_id == $pane and + .agent == "codex" and + .name == $target and + .agent_session.source == "herdr:codex" and + .agent_session.agent == "codex" and + .agent_session.kind == "id" + )] + | if length == 1 then .[0].agent_session.value + else error("expected one exact no-hooks Codex session") end + ' "$RUN_ROOT/herdr-no-hooks-agents-ready.json")" + [[ "$no_hooks_session_id" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] \ + || fail "Herdr returned a non-UUID no-hooks Codex session reference" + + wait_for_pane_process_absent "$abtop_pane" abtop 10 \ + || fail "the abtop pane did not return to its shell after exact focus" + if ! abtop_run_output="$(herdr_socket pane run \ + "$abtop_pane" "$abtop_command")"; then + fail "the abtop pane did not return to its shell after exact focus" + fi + printf '%s\n' "$abtop_run_output" \ + >"$RUN_ROOT/herdr-no-hooks-abtop-pane-run.json" + wait_for_pane_process_present "$abtop_pane" abtop 10 \ + || fail "abtop did not become the foreground process for its second run" + wait_screen_regex "$HERDR_TUI_SESSION" "abtop v[0-9]" 20 \ + "codex-no-hooks-abtop-ready" \ + || fail "abtop did not restart for the no-hooks status phase" + herdr_socket pane send-text \ + "$abtop_pane" $'/'"$no_hooks_session_id"$'\r' \ + >"$RUN_ROOT/herdr-no-hooks-filter-abtop.json" + wait_codex_table_row_count \ + "$HERDR_TUI_SESSION" 1 10 "codex-no-hooks-filtered-one-row" \ + || fail "the no-hooks full-UUID filter did not select exactly one row" + wait_screen_regex \ + "$HERDR_TUI_SESSION" "SESSION \(►$no_hooks_session_id" 10 \ + "codex-no-hooks-filtered-session" \ + || fail "abtop did not select the exact no-hooks Codex session" + + herdr_socket agent prompt "$no_hooks_agent_target" \ + 'Run the shell command sleep 30. After it finishes, reply briefly.' \ + >"$RUN_ROOT/herdr-codex-no-hooks-tool-prompt.json" + herdr_socket agent wait "$no_hooks_agent_target" \ + --until working --timeout 30000 \ + >"$RUN_ROOT/herdr-wait-no-hooks-working.json" + capture_codex_status_phase \ + no-hooks-working Working HerdrWorkingUnrefined working \ + "$status_home" "$no_hooks_pane" "$no_hooks_agent_target" \ + "$no_hooks_session_id" 60 codex-status-no-hooks-working \ + || fail "abtop did not show bracketed Work for exact Herdr activity without hooks" + no_hooks_native_pid="$(jq -er '.native_codex_pid' \ + "$RUN_ROOT/codex-no-hooks-working-correlation.json")" + + log "Codex status: asserting Working remains non-PID-actionable" + herdr_socket pane focus --direction down --pane "$no_hooks_pane" \ + >"$RUN_ROOT/herdr-focus-abtop-before-no-hooks-kill.json" + no_hooks_focus_pane="$(jq -er '.result.focus.focused_pane_id' \ + "$RUN_ROOT/herdr-focus-abtop-before-no-hooks-kill.json")" + [[ "$no_hooks_focus_pane" == "$abtop_pane" ]] \ + || fail "Herdr did not focus the exact abtop pane for the no-hooks row" + atui "$HERDR_TUI_SESSION" press x \ + >"$RUN_ROOT/agent-tui-abtop-no-hooks-kill-attempt.json" + capture_screen "$HERDR_TUI_SESSION" "codex-no-hooks-after-kill-attempt" + assert_screen_absent \ + "codex-no-hooks-after-kill-attempt" "Press x again to kill" + kill -0 "$no_hooks_native_pid" 2>/dev/null \ + || fail "a non-actionable Working row terminated its native Codex process" + + log "Codex status: asserting Enter semantically focuses Working" + assert_screen_absent \ + "codex-no-hooks-after-kill-attempt" "agent list returned invalid JSON" + atui "$HERDR_TUI_SESSION" press Enter \ + >"$RUN_ROOT/agent-tui-abtop-no-hooks-enter.json" + wait_agent_focused "$no_hooks_pane" \ + || fail "Enter did not focus the exact no-hooks Codex pane" + herdr_socket agent get "$no_hooks_pane" \ + >"$RUN_ROOT/herdr-focused-no-hooks-codex.json" + jq -e \ + --arg pane "$no_hooks_pane" \ + --arg target "$no_hooks_agent_target" \ + --arg sid "$no_hooks_session_id" ' + .result.agent.focused == true and + .result.agent.pane_id == $pane and + .result.agent.agent == "codex" and + .result.agent.name == $target and + .result.agent.agent_session.source == "herdr:codex" and + .result.agent.agent_session.agent == "codex" and + .result.agent.agent_session.kind == "id" and + .result.agent.agent_session.value == $sid + ' "$RUN_ROOT/herdr-focused-no-hooks-codex.json" >/dev/null \ + || fail "Herdr did not confirm semantic focus for Working" + + log "Codex status: asserting fresh collection suppresses a disposed hook session" + herdr_socket pane close "$no_hooks_pane" \ + >"$RUN_ROOT/herdr-no-hooks-pane-close.json" + herdr_socket pane close "$root_pane" \ + >"$RUN_ROOT/herdr-hook-pane-close.json" + wait_for_pid_exit "$no_hooks_native_pid" 20 \ + || fail "the exact no-hooks Codex process survived its pane disposal" + wait_for_pid_exit "$hook_native_pid" 20 \ + || fail "the exact hook-backed Codex process survived its pane disposal" + wait_for_fresh_snapshot_without_session \ + "$codex_session_id" "$status_home" 30 \ + "codex-disposed-hook-session-snapshot" \ + || fail "fresh JSON retained the disposed hook session as a ghost row" + + log "Codex status suite passed" + log "Note: model-only Think, dynamic approval/Wait, and 30-second Done are retained as manual review cases because Herdr exposes no deterministic synthetic lifecycle injector for them." +} + +main() { + parse_args "$@" + trap cleanup EXIT HUP INT TERM + prepare_runtime + case "$SUITE" in + codexbar) + run_codexbar_suite + ;; + codex-status) + run_codex_status_suite + ;; + all) + run_codexbar_suite + run_codex_status_suite + ;; + esac + log "requested suite completed successfully" +} + +main "$@" diff --git a/src/app.rs b/src/app.rs index c9de3cc..3f76a70 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,18 @@ +use crate::collector::codexbar::{ + canonical_provider_id, CodexBarProviderSnapshot, CodexBarQuotaPoller, CodexBarQuotaStatus, + CodexBarSnapshot, +}; use crate::collector::{read_rate_limits, McpServer, MultiCollector}; use crate::host_info::{AgentAggregate, HostMetrics, HostSampler}; -use crate::model::{AgentSession, OrphanPort, RateLimitInfo, SessionStatus}; +use crate::model::{ + AgentSession, OrphanPort, RateLimitInfo, RateLimitProvenance, RateLimitWindow, SessionStatus, + StatusAuthority, StatusEvidence, StatusObservation, StatusReason, MAX_STATUS_OBSERVATIONS, +}; use crate::theme::Theme; use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; use std::sync::mpsc; -use std::time::Instant; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; /// Maximum data points kept for the live token-rate graph. const GRAPH_HISTORY_LEN: usize = 200; @@ -13,14 +20,530 @@ const GRAPH_HISTORY_LEN: usize = 200; const MAX_SUMMARY_JOBS: usize = 3; /// Max summary attempts per session before giving up. const MAX_SUMMARY_RETRIES: u32 = 2; +/// Native quota samples at or below this age take precedence over CodexBar. +const QUOTA_FRESH_SECS: u64 = 600; + +#[derive(Clone)] +struct QuotaWindowCandidate { + window: RateLimitWindow, + updated_at: Option, + force_stale: bool, +} + +#[derive(Default)] +struct ProviderQuotaCandidates { + native: Vec, + codexbar: Vec, + codexbar_failed: bool, +} + +/// Merge native quota samples with one atomic CodexBar snapshot. +/// +/// Matching fresh native windows win, while CodexBar contributes missing and +/// provider-specific windows. When neither matching sample is fresh, the newer +/// timestamp wins. Each provider keeps one whole newest valid native sample so +/// separate accounts can never be folded into a fictional quota; ties and +/// unknown timestamps preserve discovery order. Provider failures suppress only +/// that provider's CodexBar values and never hide a native sample. +fn merge_rate_limits( + native: &[RateLimitInfo], + codexbar: Option<&CodexBarSnapshot>, + now_secs: u64, + codexbar_observed_at: Option, + codexbar_transport_stale: bool, +) -> Vec { + let mut providers: HashMap = HashMap::new(); + + for info in native { + let Some(provider) = canonical_provider_id(&info.source) else { + continue; + }; + let windows = normalized_native_windows(info); + if windows.is_empty() { + continue; + } + let entry = providers.entry(provider).or_default(); + let updated_at = valid_sample_timestamp(info.updated_at, now_secs); + if entry + .native + .first() + .is_some_and(|selected| !sample_is_newer(updated_at, selected.updated_at)) + { + continue; + } + entry.native = windows + .into_iter() + .map(|window| QuotaWindowCandidate { + window, + updated_at, + force_stale: false, + }) + .collect(); + } + + if let Some(snapshot) = codexbar { + for provider_snapshot in &snapshot.providers { + let Some(provider) = canonical_provider_id(&provider_snapshot.provider) else { + continue; + }; + let entry = providers.entry(provider).or_default(); + if provider_snapshot.error.is_some() { + entry.codexbar.clear(); + entry.codexbar_failed = true; + continue; + } + if entry.codexbar_failed { + continue; + } + for window in &provider_snapshot.windows { + let Some(window) = RateLimitWindow::try_new( + window.id.clone(), + window.label.clone(), + window.used_pct, + window.resets_at, + window.window_minutes, + RateLimitProvenance::CodexBar, + ) else { + continue; + }; + upsert_candidate( + &mut entry.codexbar, + QuotaWindowCandidate { + window, + updated_at: codexbar_sample_timestamp( + provider_snapshot.updated_at, + codexbar_observed_at, + now_secs, + codexbar_transport_stale, + ), + force_stale: codexbar_transport_stale, + }, + ); + } + } + } + + let mut merged = providers + .into_iter() + .filter_map(|(provider, candidates)| { + merge_provider_rate_limit(provider, candidates, now_secs) + }) + .collect::>(); + merged.sort_by(|left, right| provider_cmp(&left.source, &right.source)); + merged +} + +fn normalized_native_windows(info: &RateLimitInfo) -> Vec { + let mut windows = Vec::with_capacity(info.windows.len().saturating_add(2)); + for window in &info.windows { + if windows + .iter() + .any(|existing: &RateLimitWindow| existing.id.eq_ignore_ascii_case(&window.id)) + { + continue; + } + if let Some(window) = RateLimitWindow::try_new( + window.id.clone(), + window.label.clone(), + window.used_pct, + window.resets_at, + window.window_minutes, + RateLimitProvenance::Native, + ) { + windows.push(window); + } + } + + // Exact source-slot identities are authoritative. Legacy short/long fields + // are only a compatibility fallback; mixing both would duplicate cases + // such as a free-plan `primary` window projected into the historical long + // slot. + if !info.windows.is_empty() { + return windows; + } + + if let Some(used_pct) = info.five_hour_pct { + if !windows + .iter() + .any(|window| window.id.eq_ignore_ascii_case("primary")) + { + if let Some(window) = RateLimitWindow::try_new( + "primary", + native_window_label(info.five_hour_window_minutes, "5h"), + used_pct, + info.five_hour_resets_at, + info.five_hour_window_minutes, + RateLimitProvenance::Native, + ) { + windows.push(window); + } + } + } + if let Some(used_pct) = info.seven_day_pct { + if !windows + .iter() + .any(|window| window.id.eq_ignore_ascii_case("secondary")) + { + if let Some(window) = RateLimitWindow::try_new( + "secondary", + native_window_label(info.seven_day_window_minutes, "7d"), + used_pct, + info.seven_day_resets_at, + info.seven_day_window_minutes, + RateLimitProvenance::Native, + ) { + windows.push(window); + } + } + } + + windows +} + +fn native_window_label(window_minutes: Option, fallback: &str) -> String { + match window_minutes { + None | Some(0) => fallback.to_string(), + Some(minutes) if minutes % (24 * 60) == 0 => format!("{}d", minutes / (24 * 60)), + Some(minutes) if minutes % 60 == 0 => format!("{}h", minutes / 60), + Some(minutes) => format!("{minutes}m"), + } +} + +fn valid_sample_timestamp(updated_at: Option, now_secs: u64) -> Option { + updated_at.filter(|updated_at| *updated_at <= now_secs) +} + +fn codexbar_sample_timestamp( + provider_updated_at: Option, + observed_at: Option, + now_secs: u64, + transport_stale: bool, +) -> Option { + match provider_updated_at { + Some(updated_at) => valid_sample_timestamp(Some(updated_at), now_secs), + None if !transport_stale => valid_sample_timestamp(observed_at, now_secs), + None => None, + } +} + +fn upsert_candidate(candidates: &mut Vec, incoming: QuotaWindowCandidate) { + if let Some(existing) = candidates.iter_mut().find(|candidate| { + candidate + .window + .id + .eq_ignore_ascii_case(&incoming.window.id) + }) { + if sample_is_newer(incoming.updated_at, existing.updated_at) { + *existing = incoming; + } + } else { + candidates.push(incoming); + } +} + +fn merge_provider_rate_limit( + provider: String, + candidates: ProviderQuotaCandidates, + now_secs: u64, +) -> Option { + let mut selected = Vec::with_capacity( + candidates + .native + .len() + .saturating_add(candidates.codexbar.len()), + ); + + let mut codexbar_matched = vec![false; candidates.codexbar.len()]; + let mut native_matches = vec![None; candidates.native.len()]; + + // Built-in slot positions can shift when a plan omits its short window. + // Pair exact semantic fingerprints first so a native weekly `primary` + // cannot consume CodexBar's unrelated 5h `primary` by name alone. + for (native_index, native) in candidates.native.iter().enumerate() { + if let Some(codexbar_index) = candidates + .codexbar + .iter() + .enumerate() + .find(|(index, codexbar)| { + !codexbar_matched[*index] + && quota_windows_structurally_alias(&native.window, &codexbar.window) + }) + .map(|(index, _)| index) + { + native_matches[native_index] = Some((codexbar_index, true)); + codexbar_matched[codexbar_index] = true; + } + } + + if !structural_mapping_has_unique_builtin_ids( + &candidates.native, + &candidates.codexbar, + &native_matches, + &codexbar_matched, + ) { + native_matches.fill(None); + codexbar_matched.fill(false); + } + + // When exact reset metadata is unavailable or crosses a reset boundary, + // retain the stable source-owned ID as the conservative fallback. + for (native_index, native) in candidates.native.iter().enumerate() { + if native_matches[native_index].is_some() { + continue; + } + if let Some(codexbar_index) = candidates + .codexbar + .iter() + .enumerate() + .find(|(index, codexbar)| { + !codexbar_matched[*index] + && native.window.id.eq_ignore_ascii_case(&codexbar.window.id) + }) + .map(|(index, _)| index) + { + native_matches[native_index] = Some((codexbar_index, false)); + codexbar_matched[codexbar_index] = true; + } + } + + for (native_index, native) in candidates.native.iter().enumerate() { + let candidate = native_matches[native_index] + .map(|(index, structural)| { + let codexbar = &candidates.codexbar[index]; + let mut selected = preferred_candidate(native, codexbar, now_secs).clone(); + if structural { + // CodexBar's built-in ID is the normalized combined-view + // slot; the winning values and provenance remain native. + selected.window.id.clone_from(&codexbar.window.id); + } + selected + }) + .unwrap_or_else(|| native.clone()); + selected.push(candidate); + } + for (index, codexbar) in candidates.codexbar.iter().enumerate() { + if !codexbar_matched[index] { + selected.push(codexbar.clone()); + } + } + + if selected.is_empty() { + return None; + } + selected.sort_by_key(|candidate| window_sort_rank(&candidate.window.id)); + // A provider-level timestamp must describe every displayed window. The + // oldest selected sample is conservative; any unknown timestamp makes the + // combined provider timestamp unknown as well. + let updated_at = selected.iter().try_fold(u64::MAX, |oldest, candidate| { + if candidate.force_stale { + None + } else { + candidate + .updated_at + .map(|updated_at| oldest.min(updated_at)) + } + }); + let windows = selected + .into_iter() + .map(|candidate| candidate.window) + .collect::>(); + let primary = windows + .iter() + .find(|window| window.id.eq_ignore_ascii_case("primary")); + let secondary = windows + .iter() + .find(|window| window.id.eq_ignore_ascii_case("secondary")); + + Some(RateLimitInfo { + source: provider, + five_hour_pct: primary.map(|window| window.used_pct), + five_hour_resets_at: primary.and_then(|window| window.resets_at), + five_hour_window_minutes: primary.and_then(|window| window.window_minutes), + seven_day_pct: secondary.map(|window| window.used_pct), + seven_day_resets_at: secondary.and_then(|window| window.resets_at), + seven_day_window_minutes: secondary.and_then(|window| window.window_minutes), + updated_at, + windows, + }) +} + +fn structural_mapping_has_unique_builtin_ids( + native: &[QuotaWindowCandidate], + codexbar: &[QuotaWindowCandidate], + native_matches: &[Option<(usize, bool)>], + codexbar_matched: &[bool], +) -> bool { + let mut ids = HashSet::new(); + for (index, native) in native.iter().enumerate() { + let id = native_matches[index] + .filter(|(_, structural)| *structural) + .map(|(codexbar_index, _)| codexbar[codexbar_index].window.id.as_str()) + .unwrap_or(native.window.id.as_str()); + if is_builtin_quota_slot(id) && !ids.insert(id.to_ascii_lowercase()) { + return false; + } + } + for (index, candidate) in codexbar.iter().enumerate() { + if !codexbar_matched[index] + && is_builtin_quota_slot(&candidate.window.id) + && !ids.insert(candidate.window.id.to_ascii_lowercase()) + { + return false; + } + } + true +} + +fn quota_windows_structurally_alias(left: &RateLimitWindow, right: &RateLimitWindow) -> bool { + // A provider can expose a long-only plan window in its native `primary` + // slot while CodexBar normalizes the same standard window to `secondary`. + // Treat only an exact built-in fingerprint as an alias; provider-specific + // extra windows remain distinct even when they share a duration. + is_builtin_quota_slot(&left.id) + && is_builtin_quota_slot(&right.id) + && left.window_minutes.is_some() + && left.window_minutes == right.window_minutes + && left.resets_at.is_some() + && left.resets_at == right.resets_at +} + +fn is_builtin_quota_slot(id: &str) -> bool { + ["primary", "secondary", "tertiary"] + .iter() + .any(|slot| id.eq_ignore_ascii_case(slot)) +} + +fn preferred_candidate<'a>( + native: &'a QuotaWindowCandidate, + codexbar: &'a QuotaWindowCandidate, + now_secs: u64, +) -> &'a QuotaWindowCandidate { + if candidate_is_fresh(native, now_secs) { + native + } else if candidate_is_fresh(codexbar, now_secs) + || sample_is_newer(codexbar.updated_at, native.updated_at) + { + codexbar + } else { + native + } +} + +fn candidate_is_fresh(candidate: &QuotaWindowCandidate, now_secs: u64) -> bool { + !candidate.force_stale + && candidate + .updated_at + .is_some_and(|updated_at| now_secs.saturating_sub(updated_at) <= QUOTA_FRESH_SECS) +} + +fn sample_is_newer(candidate: Option, current: Option) -> bool { + match (candidate, current) { + (Some(candidate), Some(current)) => candidate > current, + (Some(_), None) => true, + (None, _) => false, + } +} + +fn provider_cmp(left: &str, right: &str) -> std::cmp::Ordering { + provider_sort_rank(left) + .cmp(&provider_sort_rank(right)) + .then_with(|| left.cmp(right)) +} + +fn provider_sort_rank(provider: &str) -> u8 { + match provider { + "claude" => 0, + "codex" => 1, + "grok" => 2, + "kimi" => 3, + _ => 4, + } +} + +fn window_sort_rank(window_id: &str) -> u8 { + if window_id.eq_ignore_ascii_case("primary") { + 0 + } else if window_id.eq_ignore_ascii_case("secondary") { + 1 + } else if window_id.eq_ignore_ascii_case("tertiary") { + 2 + } else { + 3 + } +} + +/// Cross-poll identity for status evidence. The opaque incarnation marker is +/// only valid together with its PID, so both are part of the key. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct StatusEvidenceKey { + provider: String, + session_id: String, + pid: u32, + process_incarnation: String, +} + +/// Identity captured between the first and second `x` presses. Unlike a list +/// index, this remains safe when a refresh reorders the sessions. +struct KillConfirmation { + provider: String, + session_id: String, + pid: u32, + process_incarnation: String, + grok_session_ids: Vec, + requested_at: Instant, +} + +impl KillConfirmation { + fn for_session( + session: &AgentSession, + sessions: &[AgentSession], + process_incarnation: String, + ) -> Self { + let mut grok_session_ids = if session.agent_cli == "grok" { + sessions + .iter() + .filter(|other| other.agent_cli == "grok" && other.pid == session.pid) + .map(|other| other.session_id.clone()) + .collect() + } else { + Vec::new() + }; + grok_session_ids.sort(); + Self { + provider: session.agent_cli.to_string(), + session_id: session.session_id.clone(), + pid: session.pid, + process_incarnation, + grok_session_ids, + requested_at: Instant::now(), + } + } + + fn matches(&self, session: &AgentSession, sessions: &[AgentSession]) -> bool { + let mut current_grok_session_ids = if self.provider == "grok" { + sessions + .iter() + .filter(|other| other.agent_cli == "grok" && other.pid == self.pid) + .map(|other| other.session_id.clone()) + .collect() + } else { + Vec::new() + }; + current_grok_session_ids.sort(); + self.provider == session.agent_cli + && self.session_id == session.session_id + && self.pid == session.pid + && session.action_process_incarnation.as_deref() + == Some(self.process_incarnation.as_str()) + && self.grok_session_ids == current_grok_session_ids + } +} /// Produce a terminal-safe fallback summary from a raw prompt. fn sanitize_fallback(prompt: &str, max_len: usize) -> String { - prompt - .chars() - .filter(|c| !c.is_control() || *c == ' ') - .take(max_len) - .collect() + let safe = crate::collector::sanitize_terminal_text(prompt); + let redacted = crate::collector::redact_secrets(&safe); + redacted.chars().take(max_len).collect() } /// Outcome of an Enter-key jump attempt. Distinct from `Option` so @@ -90,12 +613,21 @@ pub struct App { pub should_quit: bool, /// Token rate per tick (delta). Ring buffer for the braille graph. pub token_rates: VecDeque, - /// Account-level rate limits (Claude, Codex, etc.) + /// Account-level rate limits from native sources and enabled CodexBar providers. pub rate_limits: Vec, + /// Native provider quota before the optional CodexBar data is merged. + native_rate_limits: Vec, /// Per-session previous token totals, keyed by (agent_cli, session_id). prev_tokens: HashMap<(String, String), u64>, + /// Cross-poll status ledger keyed by exact logical and process identity. + /// Sessions without a queryable process incarnation deliberately do not + /// inherit evidence from an earlier poll. + status_evidence_ledger: HashMap, /// Rate limit poll counter (read every 5 ticks = 10s) rate_limit_counter: u32, + codexbar_quota_poller: CodexBarQuotaPoller, + codexbar_quota_status: CodexBarQuotaStatus, + codexbar_provider_snapshots: Vec, collector: MultiCollector, /// Cached LLM-generated summaries, keyed by session_id. pub summaries: HashMap, @@ -111,8 +643,8 @@ pub struct App { pub orphan_ports: Vec, /// Transient status message shown in the footer (auto-clears after 3s). pub status_msg: Option<(String, Instant)>, - /// Kill confirmation: (selected_index, timestamp). Expires after 2s. - kill_confirm: Option<(usize, Instant)>, + /// Stable process/session identity captured for the two-press kill guard. + kill_confirm: Option, pub theme: Theme, pub show_context: bool, pub show_quota: bool, @@ -121,6 +653,7 @@ pub struct App { pub show_ports: bool, pub show_sessions: bool, pub show_mcp: bool, + pub codexbar_quota_fallback: bool, pub narrow_tab: NarrowTab, pub active_narrow_section: Option, pub maximized_narrow_section: Option, @@ -166,20 +699,43 @@ impl App { hidden_agents: &[String], panels: crate::config::PanelVisibility, claude_config_dirs: &[PathBuf], + ) -> Self { + Self::new_with_config_and_claude_dirs_and_codexbar( + theme, + hidden_agents, + panels, + claude_config_dirs, + false, + ) + } + + pub fn new_with_config_and_claude_dirs_and_codexbar( + theme: Theme, + hidden_agents: &[String], + panels: crate::config::PanelVisibility, + claude_config_dirs: &[PathBuf], + codexbar_quota_fallback: bool, ) -> Self { let (tx, rx) = mpsc::channel(); let summaries = load_summary_cache(); let mut collector = MultiCollector::with_hidden_and_claude_config_dirs(hidden_agents, claude_config_dirs); collector.set_mcp_suppress(true); + let codexbar_quota_poller = CodexBarQuotaPoller::new(codexbar_quota_fallback); + let codexbar_quota_status = codexbar_quota_poller.status(); Self { sessions: Vec::new(), selected: 0, should_quit: false, token_rates: VecDeque::with_capacity(GRAPH_HISTORY_LEN), rate_limits: Vec::new(), + native_rate_limits: Vec::new(), prev_tokens: HashMap::new(), + status_evidence_ledger: HashMap::new(), rate_limit_counter: 5, + codexbar_quota_poller, + codexbar_quota_status, + codexbar_provider_snapshots: Vec::new(), collector, summaries, pending_summaries: HashSet::new(), @@ -197,6 +753,7 @@ impl App { show_ports: panels.ports, show_sessions: panels.sessions, show_mcp: panels.mcp, + codexbar_quota_fallback, narrow_tab: NarrowTab::Work, active_narrow_section: Some(NarrowSection::Sessions), maximized_narrow_section: None, @@ -288,7 +845,7 @@ impl App { } pub fn config_item_count(&self) -> usize { - 8 // theme + 7 panel toggles + 9 // theme + 7 panel toggles + CodexBar quota fallback } pub fn config_select_next(&mut self) { @@ -314,12 +871,28 @@ impl App { 5 => self.show_ports = !self.show_ports, 6 => self.show_sessions = !self.show_sessions, 7 => self.show_mcp = !self.show_mcp, + 8 => { + self.toggle_codexbar_quota_fallback(); + return; + } _ => return, } self.persist_panel_visibility(); self.clamp_narrow_tab(); } + fn toggle_codexbar_quota_fallback(&mut self) { + self.codexbar_quota_fallback = !self.codexbar_quota_fallback; + self.codexbar_quota_poller + .set_enabled(self.codexbar_quota_fallback); + self.refresh_codexbar_quota(); + if let Err(error) = + crate::config::save_codexbar_quota_fallback(self.codexbar_quota_fallback) + { + self.set_status(format!("CodexBar quota setting save failed: {error}")); + } + } + pub fn narrow_tab_visible(&self, tab: NarrowTab) -> bool { match tab { NarrowTab::Work => self.show_sessions || self.show_projects, @@ -504,10 +1077,21 @@ impl App { /// `tick` additionally calls [`App::drain_and_retry_summaries`], which /// shells out to `claude --print` to generate session titles. Headless /// consumers (e.g. the web snapshot API) call this variant so they never - /// spawn subprocesses or consume the user's Claude quota. + /// invoke that summary path or consume the user's Claude quota. When the + /// explicit CodexBar fallback is enabled, this variant may still start its + /// bounded Codex CLI quota subprocess. pub fn tick_no_summaries(&mut self) { self.collector.set_mcp_suppress(self.mcp_suppress_sessions); - self.sessions = self.collector.collect(); + let mut sessions = self.collector.collect(); + self.status_evidence_ledger = reconcile_status_evidence( + &mut sessions, + &self.status_evidence_ledger, + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + ); + self.sessions = sessions; self.orphan_ports = self.collector.orphan_ports.clone(); self.mcp_servers = self.collector.mcp_servers.clone(); self.host_metrics = self.host_sampler.sample(); @@ -536,17 +1120,67 @@ impl App { } // Poll rate limits: first tick immediately, then every 5 ticks ≈ 10s - if self.rate_limits.is_empty() || self.rate_limit_counter >= 5 { + if self.native_rate_limits.is_empty() || self.rate_limit_counter >= 5 { self.rate_limit_counter = 0; let extra_dirs = self.collector.all_config_dirs(); - self.rate_limits = read_rate_limits(&extra_dirs); + self.native_rate_limits = read_rate_limits(&extra_dirs); // Merge live rate limits from agent collectors (e.g. Codex JSONL parsing) - self.rate_limits.extend(self.collector.agent_rate_limits()); + self.native_rate_limits + .extend(self.collector.agent_rate_limits()); } else { self.rate_limit_counter += 1; } + self.refresh_codexbar_quota(); + + // Quota percentages are display-only. A lifecycle can become + // RateLimited only when the provider reports an active block. + } + + /// Wait for the first enabled CodexBar quota poll without changing session + /// state. Used only by one-shot output modes; normal TUI ticks never block. + pub fn wait_for_initial_codexbar_quota(&mut self, timeout: std::time::Duration) { + self.refresh_codexbar_quota_with_wait(timeout); + } + + pub(crate) fn codexbar_quota_status(&self) -> &CodexBarQuotaStatus { + &self.codexbar_quota_status + } + + pub(crate) fn codexbar_provider_snapshots(&self) -> &[CodexBarProviderSnapshot] { + &self.codexbar_provider_snapshots + } + + fn refresh_codexbar_quota(&mut self) { + self.refresh_codexbar_quota_with_wait(std::time::Duration::ZERO); + } - promote_waiting_to_rate_limited(&mut self.sessions, &self.rate_limits); + fn refresh_codexbar_quota_with_wait(&mut self, timeout: std::time::Duration) { + let codexbar = if timeout.is_zero() { + self.codexbar_quota_poller.update() + } else { + self.codexbar_quota_poller.wait_for_initial(timeout) + }; + // One-shot modes may block here while CodexBar fetches providers. Take + // the merge clock only after that wait so timestamps produced by the + // completed poll are not misclassified as future data. + let now_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + self.codexbar_quota_status = self.codexbar_quota_poller.status(); + self.codexbar_provider_snapshots = codexbar + .as_ref() + .map(|snapshot| snapshot.providers.clone()) + .unwrap_or_default(); + self.codexbar_provider_snapshots + .sort_by(|left, right| provider_cmp(&left.provider, &right.provider)); + self.rate_limits = merge_rate_limits( + &self.native_rate_limits, + codexbar.as_ref(), + now_secs, + self.codexbar_quota_status.last_checked_at, + self.codexbar_quota_status.error.is_some(), + ); } /// Drain completed summary results and spawn retries. Does NOT recollect @@ -638,7 +1272,8 @@ impl App { } fn session_matches(s: &AgentSession, query: &str) -> bool { - s.project_name.to_lowercase().contains(query) + s.agent_cli.to_lowercase().contains(query) + || s.project_name.to_lowercase().contains(query) || s.model.to_lowercase().contains(query) || s.session_id.to_lowercase().contains(query) || s.initial_prompt.to_lowercase().contains(query) @@ -712,44 +1347,74 @@ impl App { return; } let session = &self.sessions[self.selected]; - if matches!(session.status, SessionStatus::Done | SessionStatus::Unknown) { + if !session_process_is_actionable(session) { return; } - // Check if we have a pending confirmation for this exact session - if let Some((idx, ts)) = self.kill_confirm.take() { - if idx == self.selected && ts.elapsed().as_secs() < 2 { - // Confirmed — verify PID still runs a killable agent before killing - let pid = session.pid; - let verified = std::process::Command::new("ps") - .args(["-p", &pid.to_string(), "-o", "command="]) - .output() - .ok() - .map(|output| { - let cmd = String::from_utf8_lossy(&output.stdout).trim().to_string(); - is_killable_agent_command(&cmd) - }) - .unwrap_or(false); + // Confirm only the exact logical session and process incarnation that + // received the first press. Revalidate from fresh OS state immediately + // before signaling so list churn and PID reuse cannot redirect a kill. + if let Some(confirm) = self.kill_confirm.take() { + if confirm.matches(session, &self.sessions) + && confirm.requested_at.elapsed().as_secs() < 2 + { + let pid = confirm.pid; + let verified = freshly_validate_action_process( + &confirm.provider, + pid, + Some(&confirm.process_incarnation), + ); if !verified { - self.set_status(format!("PID {} is no longer a known agent process", pid)); + self.set_status(format!( + "PID {} is no longer the selected agent process", + pid + )); + return; + } + if let Err(message) = terminate_process(pid) { + self.set_status(message); return; } - let _ = std::process::Command::new("kill") - .args(["-9", &pid.to_string()]) - .output(); self.tick(); return; } } - // First press — ask for confirmation + // First press — anchor the confirmation to the exact incarnation that + // produced the displayed row, then verify it is still current before + // offering a destructive second press. + let Some(expected_incarnation) = session.action_process_incarnation.clone() else { + self.set_status(format!("Cannot safely verify PID {}", session.pid)); + return; + }; + if !freshly_validate_action_process( + session.agent_cli, + session.pid, + Some(&expected_incarnation), + ) { + self.set_status(format!("Cannot safely verify PID {}", session.pid)); + return; + } let name = self .summaries .get(&session.session_id) .cloned() .unwrap_or_else(|| format!("PID {}", session.pid)); - self.kill_confirm = Some((self.selected, Instant::now())); - self.set_status(format!("Press x again to kill: {}", name)); + let affected_grok_sessions = self + .sessions + .iter() + .filter(|other| other.agent_cli == "grok" && other.pid == session.pid) + .count(); + let message = kill_confirmation_message( + session.agent_cli, + session.pid, + &name, + affected_grok_sessions, + ); + let confirmation = + KillConfirmation::for_session(session, &self.sessions, expected_incarnation); + self.kill_confirm = Some(confirmation); + self.set_status(message); } /// Kill all orphan port processes (Shift+X). @@ -760,6 +1425,9 @@ impl App { // Fresh port scan right now — don't rely on cached data let fresh_ports = get_listening_ports(); + let fresh_processes = crate::collector::process::get_process_info(); + let mut failures = Vec::new(); + let mut killed = 0usize; for orphan in &self.orphan_ports { // 1. Verify PID still listens on the expected port @@ -767,23 +1435,32 @@ impl App { .get(&orphan.pid) .is_some_and(|ports| ports.contains(&orphan.port)); if !still_listening { + failures.push(format!( + "Skipped PID {}: port {} is no longer listening", + orphan.pid, orphan.port + )); continue; } - // 2. Verify PID still runs the expected command (full match, not substring) - if let Ok(output) = std::process::Command::new("ps") - .args(["-p", &orphan.pid.to_string(), "-o", "command="]) - .output() - { - let current_cmd = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if current_cmd == orphan.command { - let _ = std::process::Command::new("kill") - .args([&orphan.pid.to_string()]) - .output(); - } + // 2. Verify PID still runs the exact expected command before using + // the platform-native hard-kill path. + let same_command = fresh_processes + .get(&orphan.pid) + .is_some_and(|process| process.command == orphan.command); + if !same_command { + failures.push(format!( + "Skipped PID {}: process identity changed", + orphan.pid + )); + continue; + } + match terminate_process(orphan.pid) { + Ok(()) => killed += 1, + Err(error) => failures.push(error), } } // Re-collect to reflect changes self.tick(); + self.set_status(orphan_kill_status(killed, &failures)); } pub fn quit(&mut self) { @@ -791,15 +1468,44 @@ impl App { } /// Jump to the terminal running the selected session's agent process. - /// Delegates to the terminal-jumper registry (cmux / tmux / iTerm2); - /// see [`crate::jump`]. No-op when nothing is selected or no backend - /// recognizes the process. + /// Delegates to the terminal-jumper registry (Herdr / cmux / tmux / iTerm2); + /// see [`crate::jump`]. The cached process incarnation and provider command + /// are revalidated from fresh OS state immediately before resolving the + /// terminal, so PID reuse cannot redirect the action. No-op when nothing is + /// selected or no backend recognizes the process. pub fn jump_to_session(&mut self) -> JumpOutcome { + self.jump_to_session_with(crate::jump::run_herdr_session_jump, crate::jump::run_jump) + } + + fn jump_to_session_with( + &mut self, + semantic_jump: impl FnOnce(&str, &str) -> JumpOutcome, + process_jump: impl FnOnce(u32) -> JumpOutcome, + ) -> JumpOutcome { if self.sessions.is_empty() { return JumpOutcome::NoOp; } - let target_pid = self.sessions[self.selected].pid; - crate::jump::run_jump(target_pid) + let selected = &self.sessions[self.selected]; + match semantic_jump(selected.agent_cli, &selected.session_id) { + JumpOutcome::NoOp => {} + outcome => return outcome, + } + if !session_process_is_actionable(selected) { + return JumpOutcome::NoOp; + } + + if !freshly_validate_action_process( + selected.agent_cli, + selected.pid, + selected.action_process_incarnation.as_deref(), + ) { + return JumpOutcome::Failed(format!( + "PID {} is no longer the selected agent process", + selected.pid + )); + } + let target_pid = selected.pid; + process_jump(target_pid) } /// Get the display summary for a session: LLM summary > "..." if pending > raw prompt > "—" @@ -840,6 +1546,24 @@ impl App { } } +/// Return whether a row carries enough process ownership proof for a PID action. +/// +/// Current Kimi Code rewrites every launch mode's process title to bare +/// `kimi-code`. A unique cwd/session activity match can still support a useful +/// lifecycle display, but it cannot distinguish the interactive TUI from web, +/// ACP, or plugin hosts. Keep those heuristic rows visible while preventing +/// kill and terminal-jump actions from targeting the wrong host process. +fn session_process_is_actionable(session: &AgentSession) -> bool { + !matches!( + session.status, + SessionStatus::Working | SessionStatus::Unknown | SessionStatus::Done + ) && session.pid > 0 + && session.action_process_incarnation.is_some() + && session.status_evidence.authority != StatusAuthority::Unavailable + && (session.agent_cli != "kimi" + || session.status_evidence.authority == StatusAuthority::Provider) +} + /// Call `claude --print` via stdin pipe to summarize a prompt. /// Returns `None` on timeout so the caller can retry later. fn generate_summary(prompt: &str, assistant_text: &str) -> Option { @@ -896,9 +1620,7 @@ fn generate_summary(prompt: &str, assistant_text: &str) -> Option { Ok(r) => r, Err(_) => { // Timeout or disconnected — kill the child so the helper thread can exit. - let _ = std::process::Command::new("kill") - .args(["-9", &child_pid.to_string()]) - .status(); + let _ = terminate_process(child_pid); return None; } }; @@ -922,7 +1644,10 @@ fn generate_summary(prompt: &str, assistant_text: &str) -> Option { { Some(fallback) } else { - Some(raw.trim_matches('"').trim_matches('\'').to_string()) + Some(sanitize_fallback( + raw.trim_matches('"').trim_matches('\''), + 80, + )) } } _ => Some(fallback), @@ -946,10 +1671,19 @@ fn load_summary_cache() -> HashMap { Ok(content) => { let mut cache: HashMap = serde_json::from_str(&content).unwrap_or_default(); - // Purge polluted or old truncated-fallback entries so they regenerate + // Purge polluted or old truncated-fallback entries so they regenerate, + // and re-sanitize legacy cache values before rendering them. let before = cache.len(); cache.retain(|_, v| !v.contains("You are a conversation tit") && !v.ends_with('…')); - if cache.len() < before { + let mut sanitized = false; + for value in cache.values_mut() { + let safe = sanitize_fallback(value, 80); + if *value != safe { + *value = safe; + sanitized = true; + } + } + if cache.len() < before || sanitized { // Persist cleaned cache let _ = std::fs::create_dir_all(cache_dir()); let _ = std::fs::write(&path, serde_json::to_string(&cache).unwrap_or_default()); @@ -971,57 +1705,938 @@ fn save_summary_cache(summaries: &HashMap) { } } -/// Threshold above which a rate-limited bucket is surfaced as RateLimited -/// in the session list. 90% leaves enough headroom to catch near-saturation -/// before the account actually blocks. -const RATE_LIMITED_PCT: f64 = 90.0; +fn status_evidence_key(session: &AgentSession) -> Option { + if session.session_id.is_empty() || session.pid == 0 { + return None; + } + let process_incarnation = session.action_process_incarnation.clone()?; + Some(StatusEvidenceKey { + provider: session.agent_cli.to_string(), + session_id: session.session_id.clone(), + pid: session.pid, + process_incarnation, + }) +} -/// Promote Waiting sessions to RateLimited when a rate limit from the SAME -/// agent CLI is over `RATE_LIMITED_PCT`. Matching on source avoids a -/// Claude-only saturation freezing Codex sessions and vice versa. -fn promote_waiting_to_rate_limited(sessions: &mut [AgentSession], rate_limits: &[RateLimitInfo]) { - if rate_limits.is_empty() { +/// Preserve evidence between collector rebuilds only for an exact logical +/// session and process incarnation, and add one bounded sample for collectors +/// that do not yet supply their own lifecycle provenance. Rows without a +/// collector-bound action identity deliberately never inherit history. +fn reconcile_status_evidence( + sessions: &mut [AgentSession], + previous: &HashMap, + observed_at_ms: u64, +) -> HashMap { + let mut current = HashMap::new(); + for session in sessions { + session.enforce_status_contract(); + let key = status_evidence_key(session); + let old = key.as_ref().and_then(|key| previous.get(key)); + + if session.status_evidence.has_sample() { + if let Some(old) = old { + prepend_status_history(&mut session.status_evidence, old); + } + } else { + let mut evidence = old.cloned().unwrap_or_default(); + let (authority, reason) = if session.status == SessionStatus::Unknown { + ( + StatusAuthority::Unavailable, + StatusReason::OwnershipUnconfirmed, + ) + } else { + (StatusAuthority::Heuristic, StatusReason::CollectorInference) + }; + evidence.observe(StatusObservation::new( + session.status, + authority, + reason, + observed_at_ms, + 0, + )); + session.status_evidence = evidence; + } + + if let Some(key) = key { + current.insert(key, session.status_evidence.clone()); + } + } + current +} + +fn prepend_status_history(current: &mut StatusEvidence, previous: &StatusEvidence) { + if previous.observations.is_empty() { return; } - for s in sessions.iter_mut() { - if s.status != SessionStatus::Waiting { - continue; + + // Most collectors publish one fresh source-qualified sample per poll and + // rely on App to retain the cross-poll ledger. Preserve the duration and + // matching count for that incremental shape. Provider-local state can + // already contain a multi-sample ledger; trust its summary and only + // prepend observations that aged out of the bounded source file. + let incremental = current.observations.len() == 1; + let incoming = current.observations.last().cloned(); + let previous_latest = previous.observations.last(); + if incremental { + if let (Some(incoming), Some(previous_latest)) = (incoming.as_ref(), previous_latest) { + let same_projection = incoming.status == previous_latest.status + && incoming.authority == previous_latest.authority + && incoming.connection_generation == previous_latest.connection_generation; + if same_projection && incoming.observed_at_ms > previous.observed_at_ms { + // A one-sample ledger normally gets `status_since_ms` from + // `observe`, making it equal to `observed_at_ms`. A strictly + // earlier, newer Provider value is therefore an explicit + // lifecycle timestamp and must win over App's older history. + // Equality is conservatively treated as the synthetic default + // because the model does not carry an explicit/source bit. + let newer_explicit_provider_since = incoming.authority == StatusAuthority::Provider + && current.status_since_ms > previous.status_since_ms + && current.status_since_ms < incoming.observed_at_ms; + if !newer_explicit_provider_since { + current.status_since_ms = if previous.status_since_ms > 0 { + previous.status_since_ms + } else { + incoming.observed_at_ms + }; + } + current.consecutive_matching = previous + .consecutive_matching + .saturating_add(current.consecutive_matching.max(1)); + } else if same_projection && incoming == previous_latest { + current.status_since_ms = previous.status_since_ms; + current.consecutive_matching = current + .consecutive_matching + .max(previous.consecutive_matching); + } } - let over = rate_limits.iter().any(|rl| { - rl.source == s.agent_cli - && (rl.five_hour_pct.unwrap_or(0.0) > RATE_LIMITED_PCT - || rl.seven_day_pct.unwrap_or(0.0) > RATE_LIMITED_PCT) - }); - if over { - s.status = SessionStatus::RateLimited; + } + + let mut merged = previous.observations.clone(); + for observation in ¤t.observations { + if !merged.contains(observation) { + merged.push(observation.clone()); } } + merged.sort_by_key(|observation| observation.observed_at_ms); + if merged.len() > MAX_STATUS_OBSERVATIONS { + let excess = merged.len() - MAX_STATUS_OBSERVATIONS; + merged.drain(..excess); + } + current.observations = merged; } fn is_supported_agent_command(cmd: &str) -> bool { crate::collector::process::cmd_has_binary(cmd, "claude") || crate::collector::process::cmd_has_binary(cmd, "codex") || crate::collector::process::cmd_has_binary(cmd, "opencode") + || is_grok_agent_command(cmd) + || is_kimi_agent_command(cmd) } fn is_killable_agent_command(cmd: &str) -> bool { - is_supported_agent_command(cmd) - && !(crate::collector::process::cmd_has_binary(cmd, "codex") && cmd.contains(" app-server")) + ["claude", "codex", "opencode", "grok", "kimi"] + .iter() + .any(|provider| is_killable_agent_command_for_provider(provider, cmd)) +} + +fn is_killable_agent_command_for_provider(provider: &str, cmd: &str) -> bool { + match provider { + "claude" => crate::collector::process::cmd_has_binary(cmd, "claude"), + "codex" => { + crate::collector::process::cmd_has_binary(cmd, "codex") + && !crate::collector::process::command_tokens(cmd) + .windows(2) + .any(|pair| { + pair[0].eq_ignore_ascii_case("codex") + && pair[1].eq_ignore_ascii_case("app-server") + }) + && !cmd.contains(" app-server") + } + "opencode" => crate::collector::process::cmd_has_binary(cmd, "opencode"), + "grok" => is_grok_agent_command(cmd), + "kimi" => is_kimi_agent_command(cmd), + _ => false, + } +} + +fn is_grok_agent_command(cmd: &str) -> bool { + crate::collector::grok::is_grok_process(cmd) +} + +fn is_kimi_agent_command(cmd: &str) -> bool { + crate::collector::kimi::is_kimi_process(cmd) +} + +fn process_incarnation_matches(expected: Option<&str>, current: Option<&str>) -> bool { + matches!((expected, current), (Some(expected), Some(current)) if expected == current) +} + +fn exact_action_argv_is_valid(provider: &str, tokens: &[String]) -> bool { + match provider { + "claude" => crate::collector::process::tokens_have_binary(tokens, "claude"), + "codex" => { + crate::collector::process::tokens_have_binary(tokens, "codex") + && !tokens + .iter() + .skip(1) + .any(|token| token.eq_ignore_ascii_case("app-server")) + } + "opencode" => crate::collector::process::tokens_have_binary(tokens, "opencode"), + "grok" => crate::collector::grok::is_grok_process_tokens(tokens), + "kimi" => crate::collector::kimi::is_kimi_process_tokens(tokens), + _ => false, + } +} + +/// Check one command observation bracketed by two exact process-incarnation +/// reads. This prevents a command sampled from one process from being accepted +/// together with a reused PID belonging to another process. +fn action_process_observation_is_valid( + provider: &str, + expected_incarnation: Option<&str>, + before_incarnation: Option<&str>, + after_incarnation: Option<&str>, + command: Option<&str>, + grok_leader: bool, +) -> bool { + process_incarnation_matches(expected_incarnation, before_incarnation) + && process_incarnation_matches(expected_incarnation, after_incarnation) + && command.is_some_and(|cmd| { + is_supported_agent_command(cmd) + && is_killable_agent_command(cmd) + && is_killable_agent_command_for_provider(provider, cmd) + }) + && !(provider == "grok" && grok_leader) +} + +/// Revalidate a cached action target from fresh OS state immediately before a +/// kill or terminal jump. Unknown identity, PID reuse, provider drift, and Grok +/// leader processes all fail closed. +fn freshly_validate_action_process( + provider: &str, + pid: u32, + expected_incarnation: Option<&str>, +) -> bool { + let Some(expected_incarnation) = expected_incarnation else { + return false; + }; + let before_incarnation = crate::collector::process::get_process_incarnation(pid); + let process_tokens = crate::collector::process::get_process_tokens(pid); + let command = crate::collector::process::get_process_info() + .remove(&pid) + .map(|process| process.command); + let grok_leader = provider == "grok" && crate::collector::grok::is_grok_leader_pid(pid); + let after_incarnation = crate::collector::process::get_process_incarnation(pid); + + process_tokens + .as_deref() + .is_some_and(|tokens| exact_action_argv_is_valid(provider, tokens)) + && action_process_observation_is_valid( + provider, + Some(expected_incarnation), + before_incarnation.as_deref(), + after_incarnation.as_deref(), + command.as_deref(), + grok_leader, + ) +} + +fn terminate_process(pid: u32) -> Result<(), String> { + #[cfg(target_os = "windows")] + let result = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .output(); + + #[cfg(not(target_os = "windows"))] + let result = std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .output(); + + match result { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(termination_failure_message( + pid, + &String::from_utf8_lossy(&output.stderr), + &String::from_utf8_lossy(&output.stdout), + &output.status.to_string(), + )), + Err(error) => Err(termination_failure_message(pid, &error.to_string(), "", "")), + } +} + +fn termination_failure_message(pid: u32, stderr: &str, stdout: &str, status: &str) -> String { + let raw_detail = [stderr, stdout, status] + .into_iter() + .map(str::trim) + .find(|detail| !detail.is_empty()) + .unwrap_or("unknown error"); + let detail = crate::collector::sanitize_terminal_text(raw_detail) + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(160) + .collect::(); + format!("Failed to kill PID {}: {}", pid, detail) +} + +fn orphan_kill_status(killed: usize, failures: &[String]) -> String { + match (killed, failures.first()) { + (0, None) => "No orphan processes needed killing".to_string(), + (killed, None) => format!("Killed {} orphan process(es)", killed), + (0, Some(first)) if failures.len() == 1 => first.clone(), + (0, Some(first)) => format!("{} orphan failures: {}", failures.len(), first), + (killed, Some(first)) => format!( + "Killed {}; {} orphan failure(s): {}", + killed, + failures.len(), + first + ), + } +} + +fn kill_confirmation_message(provider: &str, pid: u32, name: &str, affected: usize) -> String { + if provider == "grok" && affected > 1 { + format!( + "Press x again to kill PID {}; affects {} Grok sessions", + pid, affected + ) + } else { + format!("Press x again to kill: {}", name) + } } #[cfg(test)] mod tests { use super::*; + fn native_quota( + provider: &str, + updated_at: u64, + primary_pct: Option, + secondary_pct: Option, + ) -> RateLimitInfo { + RateLimitInfo { + source: provider.to_string(), + five_hour_pct: primary_pct, + five_hour_resets_at: primary_pct.map(|_| updated_at + 300), + five_hour_window_minutes: primary_pct.map(|_| 300), + seven_day_pct: secondary_pct, + seven_day_resets_at: secondary_pct.map(|_| updated_at + 10_080), + seven_day_window_minutes: secondary_pct.map(|_| 10_080), + updated_at: Some(updated_at), + windows: Vec::new(), + } + } + + fn codexbar_window(id: &str, used_pct: f64) -> crate::collector::codexbar::CodexBarWindow { + crate::collector::codexbar::CodexBarWindow { + id: id.to_string(), + label: id.to_string(), + used_pct, + resets_at: Some(20_000), + window_minutes: Some(300), + } + } + + fn codexbar_provider( + provider: &str, + updated_at: u64, + windows: Vec, + ) -> CodexBarProviderSnapshot { + CodexBarProviderSnapshot { + provider: provider.to_string(), + windows, + updated_at: Some(updated_at), + error: None, + } + } + + #[test] + fn quota_merge_keeps_fresh_native_overlaps_and_codexbar_only_windows() { + let now = 10_000; + let native = native_quota("codex", now - 10, Some(12.0), Some(34.0)); + let snapshot = CodexBarSnapshot { + providers: vec![codexbar_provider( + "codex", + now, + vec![ + codexbar_window("primary", 91.0), + codexbar_window("secondary", 92.0), + codexbar_window("tertiary", 93.0), + codexbar_window("codex-spark-weekly", 94.0), + ], + )], + }; + + let merged = merge_rate_limits(&[native], Some(&snapshot), now, None, false); + assert_eq!(merged.len(), 1); + let codex = &merged[0]; + assert_eq!(codex.five_hour_pct, Some(12.0)); + assert_eq!(codex.seven_day_pct, Some(34.0)); + assert_eq!(codex.updated_at, Some(now - 10)); + assert_eq!(codex.windows.len(), 4); + assert_eq!( + codex + .windows + .iter() + .map(|window| (window.id.as_str(), window.used_pct, window.provenance)) + .collect::>(), + vec![ + ("primary", 12.0, RateLimitProvenance::Native), + ("secondary", 34.0, RateLimitProvenance::Native), + ("tertiary", 93.0, RateLimitProvenance::CodexBar), + ("codex-spark-weekly", 94.0, RateLimitProvenance::CodexBar,), + ] + ); + } + + #[test] + fn quota_merge_selects_the_newest_whole_native_provider_sample() { + let now = 10_000; + let first_account = native_quota("claude", now - 20, Some(11.0), None); + let second_account = native_quota("claude", now - 10, None, Some(92.0)); + + let merged = merge_rate_limits(&[first_account, second_account], None, now, None, false); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].five_hour_pct, None); + assert_eq!(merged[0].seven_day_pct, Some(92.0)); + assert_eq!(merged[0].windows.len(), 1); + assert_eq!(merged[0].updated_at, Some(now - 10)); + } + + #[test] + fn quota_merge_preserves_discovery_order_for_equal_native_timestamps() { + let now = 10_000; + let first_account = native_quota("claude", now - 10, Some(11.0), None); + let second_account = native_quota("claude", now - 10, None, Some(92.0)); + + let merged = merge_rate_limits(&[first_account, second_account], None, now, None, false); + + assert_eq!(merged[0].five_hour_pct, Some(11.0)); + assert_eq!(merged[0].seven_day_pct, None); + assert_eq!(merged[0].windows.len(), 1); + } + + #[test] + fn exact_native_primary_does_not_duplicate_its_legacy_long_projection() { + let now = 10_000; + let mut free_plan = native_quota("codex", now, None, Some(48.0)); + free_plan.windows = vec![RateLimitWindow::try_new( + "primary", + "30d", + 48.0, + Some(now + 43_200), + Some(43_200), + RateLimitProvenance::Native, + ) + .unwrap()]; + + let merged = merge_rate_limits(&[free_plan], None, now, None, false); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].windows.len(), 1); + assert_eq!(merged[0].windows[0].id, "primary"); + assert_eq!(merged[0].five_hour_pct, Some(48.0)); + assert_eq!(merged[0].seven_day_pct, None); + } + + #[test] + fn codex_shifted_builtin_slots_merge_by_exact_window_fingerprint() { + let now = 10_000; + let reset = 20_000; + let mut native = native_quota("codex", now - 10, None, None); + native.windows = vec![RateLimitWindow::try_new( + "primary", + "7d", + 55.0, + Some(reset), + Some(10_080), + RateLimitProvenance::Native, + ) + .unwrap()]; + let mut shifted = codexbar_window("secondary", 56.0); + shifted.resets_at = Some(reset); + shifted.window_minutes = Some(10_080); + let snapshot = CodexBarSnapshot { + providers: vec![codexbar_provider("codex", now, vec![shifted])], + }; + + let merged = merge_rate_limits( + std::slice::from_ref(&native), + Some(&snapshot), + now, + None, + false, + ); + assert_eq!(merged[0].windows.len(), 1); + assert_eq!(merged[0].windows[0].id, "secondary"); + assert_eq!(merged[0].windows[0].used_pct, 55.0); + assert_eq!(merged[0].windows[0].provenance, RateLimitProvenance::Native); + assert_eq!(merged[0].five_hour_pct, None); + assert_eq!(merged[0].seven_day_pct, Some(55.0)); + + native.updated_at = Some(now - QUOTA_FRESH_SECS - 1); + let merged = merge_rate_limits(&[native], Some(&snapshot), now, None, false); + assert_eq!(merged[0].windows.len(), 1); + assert_eq!(merged[0].windows[0].id, "secondary"); + assert_eq!(merged[0].windows[0].used_pct, 56.0); + assert_eq!( + merged[0].windows[0].provenance, + RateLimitProvenance::CodexBar + ); + } + + #[test] + fn structural_slot_matching_precedes_positional_id_fallback() { + let now = 10_000; + let mut native = native_quota("codex", now - 10, None, None); + native.windows = vec![RateLimitWindow::try_new( + "primary", + "7d", + 55.0, + Some(30_000), + Some(10_080), + RateLimitProvenance::Native, + ) + .unwrap()]; + let mut five_hour = codexbar_window("primary", 12.0); + five_hour.resets_at = Some(20_000); + five_hour.window_minutes = Some(300); + let mut weekly = codexbar_window("secondary", 56.0); + weekly.resets_at = Some(30_000); + weekly.window_minutes = Some(10_080); + let snapshot = CodexBarSnapshot { + providers: vec![codexbar_provider("codex", now, vec![five_hour, weekly])], + }; + + let merged = merge_rate_limits(&[native], Some(&snapshot), now, None, false); + assert_eq!(merged[0].windows.len(), 2); + assert_eq!( + merged[0] + .windows + .iter() + .map(|window| { (window.id.as_str(), window.used_pct, window.provenance,) }) + .collect::>(), + vec![ + ("primary", 12.0, RateLimitProvenance::CodexBar), + ("secondary", 55.0, RateLimitProvenance::Native), + ] + ); + assert_eq!(merged[0].five_hour_pct, Some(12.0)); + assert_eq!(merged[0].seven_day_pct, Some(55.0)); + } + + #[test] + fn structural_aliasing_never_collapses_custom_or_incomplete_windows() { + let now = 10_000; + let mut native = native_quota("codex", now, None, None); + native.windows = vec![ + RateLimitWindow::try_new( + "primary", + "7d", + 55.0, + Some(30_000), + Some(10_080), + RateLimitProvenance::Native, + ) + .unwrap(), + RateLimitWindow::try_new( + "secondary", + "Unknown", + 20.0, + None, + None, + RateLimitProvenance::Native, + ) + .unwrap(), + ]; + let mut custom = codexbar_window("scoped-weekly", 56.0); + custom.resets_at = Some(30_000); + custom.window_minutes = Some(10_080); + let mut different_reset = codexbar_window("tertiary", 57.0); + different_reset.resets_at = Some(30_001); + different_reset.window_minutes = Some(10_080); + let snapshot = CodexBarSnapshot { + providers: vec![codexbar_provider( + "codex", + now, + vec![custom, different_reset], + )], + }; + + let merged = merge_rate_limits(&[native], Some(&snapshot), now, None, false); + assert_eq!(merged[0].windows.len(), 4); + + let mut incomplete_native = native_quota("codex", now, None, None); + incomplete_native.windows = vec![RateLimitWindow::try_new( + "primary", + "Primary", + 20.0, + None, + None, + RateLimitProvenance::Native, + ) + .unwrap()]; + let mut incomplete_codexbar = codexbar_window("secondary", 21.0); + incomplete_codexbar.resets_at = None; + incomplete_codexbar.window_minutes = None; + let snapshot = CodexBarSnapshot { + providers: vec![codexbar_provider("codex", now, vec![incomplete_codexbar])], + }; + let merged = merge_rate_limits(&[incomplete_native], Some(&snapshot), now, None, false); + assert_eq!(merged[0].windows.len(), 2); + } + + #[test] + fn structural_alias_matches_are_consumed_one_to_one() { + let now = 10_000; + let mut native = native_quota("codex", now, None, None); + native.windows = ["primary", "secondary"] + .into_iter() + .map(|id| { + RateLimitWindow::try_new( + id, + id, + 10.0, + Some(30_000), + Some(10_080), + RateLimitProvenance::Native, + ) + .unwrap() + }) + .collect(); + let mut codexbar = codexbar_window("tertiary", 99.0); + codexbar.resets_at = Some(30_000); + codexbar.window_minutes = Some(10_080); + let snapshot = CodexBarSnapshot { + providers: vec![codexbar_provider("codex", now, vec![codexbar])], + }; + + let merged = merge_rate_limits(&[native], Some(&snapshot), now, None, false); + assert_eq!(merged[0].windows.len(), 2); + assert_eq!( + merged[0] + .windows + .iter() + .filter(|window| window.used_pct == 99.0) + .count(), + 0 + ); + } + + #[test] + fn ambiguous_structural_slot_mapping_falls_back_to_unique_ids() { + let now = 10_000; + let mut native = native_quota("codex", now, None, None); + native.windows = vec![ + RateLimitWindow::try_new( + "primary", + "Weekly", + 55.0, + Some(30_000), + Some(10_080), + RateLimitProvenance::Native, + ) + .unwrap(), + RateLimitWindow::try_new( + "secondary", + "Other", + 22.0, + Some(40_000), + Some(43_200), + RateLimitProvenance::Native, + ) + .unwrap(), + ]; + let mut shifted = codexbar_window("secondary", 56.0); + shifted.resets_at = Some(30_000); + shifted.window_minutes = Some(10_080); + let snapshot = CodexBarSnapshot { + providers: vec![codexbar_provider("codex", now, vec![shifted])], + }; + + let merged = merge_rate_limits(&[native], Some(&snapshot), now, None, false); + assert_eq!(merged[0].windows.len(), 2); + assert_eq!( + merged[0] + .windows + .iter() + .map(|window| (window.id.as_str(), window.used_pct)) + .collect::>(), + vec![("primary", 55.0), ("secondary", 22.0)] + ); + } + + #[test] + fn provider_timestamp_is_the_oldest_complete_selected_window_timestamp() { + let now = 10_000; + let stale_native = native_quota("codex", now - QUOTA_FRESH_SECS - 10, Some(12.0), None); + let fresh_extra = CodexBarSnapshot { + providers: vec![codexbar_provider( + "codex", + now, + vec![codexbar_window("codex-spark-weekly", 1.0)], + )], + }; + + let merged = merge_rate_limits(&[stale_native], Some(&fresh_extra), now, None, false); + assert_eq!(merged[0].updated_at, Some(now - QUOTA_FRESH_SECS - 10)); + + let mut unknown_native = native_quota("codex", now, Some(12.0), None); + unknown_native.updated_at = None; + let merged = merge_rate_limits(&[unknown_native], Some(&fresh_extra), now, None, false); + assert_eq!(merged[0].updated_at, None); + } + + #[test] + fn future_native_timestamp_is_unavailable_for_merge_decisions() { + let now = 10_000; + let future_native = native_quota("codex", now + 3_600, Some(12.0), None); + let stale_codexbar = CodexBarSnapshot { + providers: vec![codexbar_provider( + "codex", + now - QUOTA_FRESH_SECS - 1, + vec![codexbar_window("primary", 91.0)], + )], + }; + + let merged = merge_rate_limits( + std::slice::from_ref(&future_native), + Some(&stale_codexbar), + now, + None, + false, + ); + assert_eq!(merged[0].five_hour_pct, Some(91.0)); + assert_eq!( + merged[0].windows[0].provenance, + RateLimitProvenance::CodexBar + ); + + let native_only = merge_rate_limits(&[future_native], None, now, None, false); + assert_eq!(native_only[0].updated_at, None); + } + + #[test] + fn future_codexbar_timestamp_is_unavailable_for_merge_decisions() { + let now = 10_000; + let stale_native = native_quota("codex", now - QUOTA_FRESH_SECS - 1, Some(12.0), None); + let future_codexbar = CodexBarSnapshot { + providers: vec![codexbar_provider( + "codex", + now + 3_600, + vec![codexbar_window("primary", 91.0)], + )], + }; + + let merged = merge_rate_limits( + &[stale_native], + Some(&future_codexbar), + now, + Some(now), + false, + ); + assert_eq!(merged[0].five_hour_pct, Some(12.0)); + assert_eq!(merged[0].updated_at, Some(now - QUOTA_FRESH_SECS - 1)); + + let codexbar_only = merge_rate_limits(&[], Some(&future_codexbar), now, Some(now), false); + assert_eq!(codexbar_only[0].updated_at, None); + } + + #[test] + fn successful_poll_observation_dates_codexbar_rows_without_provider_timestamp() { + let now = 10_000; + let stale_native = native_quota("grok", now - QUOTA_FRESH_SECS - 1, Some(12.0), None); + let mut provider = codexbar_provider("grok", now, vec![codexbar_window("primary", 91.0)]); + provider.updated_at = None; + let snapshot = CodexBarSnapshot { + providers: vec![provider], + }; + + let merged = merge_rate_limits(&[stale_native], Some(&snapshot), now, Some(now - 1), false); + + assert_eq!(merged[0].five_hour_pct, Some(91.0)); + assert_eq!(merged[0].updated_at, Some(now - 1)); + assert_eq!( + merged[0].windows[0].provenance, + RateLimitProvenance::CodexBar + ); + } + + #[test] + fn transport_failed_codexbar_cache_is_immediately_stale() { + let now = 10_000; + let cached = CodexBarSnapshot { + providers: vec![codexbar_provider( + "grok", + now, + vec![codexbar_window("primary", 18.0)], + )], + }; + + let merged = merge_rate_limits(&[], Some(&cached), now, Some(now), true); + + assert_eq!(merged[0].five_hour_pct, Some(18.0)); + assert_eq!( + merged[0].windows[0].provenance, + RateLimitProvenance::CodexBar + ); + assert_eq!(merged[0].updated_at, None); + } + + #[test] + fn quota_merge_uses_fresh_codexbar_for_a_stale_native_overlap() { + let now = 10_000; + let native = native_quota("codex", now - QUOTA_FRESH_SECS - 1, Some(12.0), None); + let snapshot = CodexBarSnapshot { + providers: vec![codexbar_provider( + "codex", + now - 1, + vec![codexbar_window("primary", 91.0)], + )], + }; + + let merged = merge_rate_limits(&[native], Some(&snapshot), now, None, false); + assert_eq!(merged[0].five_hour_pct, Some(91.0)); + assert_eq!( + merged[0].windows[0].provenance, + RateLimitProvenance::CodexBar + ); + } + + #[test] + fn quota_merge_uses_the_newer_sample_when_both_overlaps_are_stale() { + let now = 10_000; + let native = native_quota("codex", now - QUOTA_FRESH_SECS - 20, Some(12.0), None); + let snapshot = CodexBarSnapshot { + providers: vec![codexbar_provider( + "codex", + now - QUOTA_FRESH_SECS - 10, + vec![codexbar_window("primary", 91.0)], + )], + }; + + let merged = merge_rate_limits(&[native], Some(&snapshot), now, None, false); + assert_eq!(merged[0].five_hour_pct, Some(91.0)); + + let newer_native = native_quota("codex", now - QUOTA_FRESH_SECS - 5, Some(23.0), None); + let older_codexbar = CodexBarSnapshot { + providers: vec![codexbar_provider( + "codex", + now - QUOTA_FRESH_SECS - 15, + vec![codexbar_window("primary", 82.0)], + )], + }; + let merged = merge_rate_limits(&[newer_native], Some(&older_codexbar), now, None, false); + assert_eq!(merged[0].five_hour_pct, Some(23.0)); + assert_eq!(merged[0].windows[0].provenance, RateLimitProvenance::Native); + } + + #[test] + fn codexbar_provider_error_does_not_hide_native_quota() { + let now = 10_000; + let snapshot = CodexBarSnapshot { + providers: vec![ + CodexBarProviderSnapshot { + provider: "codex".to_string(), + windows: Vec::new(), + updated_at: None, + error: Some(crate::collector::codexbar::CodexBarProviderError::Unavailable), + }, + CodexBarProviderSnapshot { + provider: "kimi".to_string(), + windows: Vec::new(), + updated_at: None, + error: Some(crate::collector::codexbar::CodexBarProviderError::Unavailable), + }, + ], + }; + + let merged = merge_rate_limits( + &[native_quota("codex", now, Some(12.0), None)], + Some(&snapshot), + now, + None, + false, + ); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].source, "codex"); + assert_eq!(merged[0].five_hour_pct, Some(12.0)); + } + + #[test] + fn quota_providers_have_a_stable_preferred_then_lexical_order() { + let now = 10_000; + let providers = ["zeta", "kimi", "grok", "codex", "claude", "alpha"] + .into_iter() + .map(|provider| codexbar_provider(provider, now, vec![codexbar_window("primary", 1.0)])) + .collect(); + let merged = + merge_rate_limits(&[], Some(&CodexBarSnapshot { providers }), now, None, false); + + assert_eq!( + merged + .iter() + .map(|rate_limit| rate_limit.source.as_str()) + .collect::>(), + vec!["claude", "codex", "grok", "kimi", "alpha", "zeta"] + ); + } + + #[test] + fn quota_provider_ids_are_strict_content_free_ascii_identifiers() { + assert_eq!( + canonical_provider_id("Claude-Code_1.2"), + Some("claude-code_1.2".into()) + ); + for invalid in [ + "private@example.com", + " claude", + "claude ", + "claude\n", + "grok\u{202e}", + ] { + assert_eq!(canonical_provider_id(invalid), None, "{invalid:?}"); + } + } + + #[test] + fn session_visibility_does_not_disable_codexbar_quota() { + for hidden in [ + vec!["codex".to_string()], + vec!["grok".to_string(), "kimi".to_string()], + vec!["codex".to_string(), "grok".to_string(), "kimi".to_string()], + ] { + let app = App::new_with_config_and_claude_dirs_and_codexbar( + Theme::default(), + &hidden, + crate::config::PanelVisibility::default(), + &[], + true, + ); + + assert!(app.codexbar_quota_fallback, "{hidden:?}"); + assert_eq!( + app.codexbar_quota_status().state, + crate::collector::codexbar::CodexBarQuotaState::Unavailable, + "{hidden:?}" + ); + } + } + fn waiting_session(cli: &'static str) -> AgentSession { AgentSession { agent_cli: cli, pid: 1, + action_process_incarnation: Some("process-1".to_string()), session_id: String::new(), cwd: String::new(), project_name: String::new(), started_at: 0, status: SessionStatus::Waiting, + status_evidence: StatusEvidence::default(), model: String::new(), effort: String::new(), context_percent: 0.0, @@ -1047,6 +2662,7 @@ mod tests { chat_messages: vec![], tool_calls: vec![], pending_since_ms: 0, + awaiting_input: false, thinking_since_ms: 0, file_accesses: vec![], config_root: String::new(), @@ -1055,45 +2671,245 @@ mod tests { } } - fn rate_limit(source: &str, pct: f64) -> RateLimitInfo { - RateLimitInfo { - source: source.to_string(), - five_hour_pct: Some(pct), - five_hour_resets_at: None, - five_hour_window_minutes: Some(300), - seven_day_pct: None, - seven_day_resets_at: None, - seven_day_window_minutes: None, - updated_at: None, + fn idle_session(cli: &'static str) -> AgentSession { + let mut session = waiting_session(cli); + session.status = SessionStatus::Idle; + session + } + + #[test] + fn reconciliation_enforces_waiting_contract_and_samples_heuristics() { + let mut waiting = waiting_session("claude"); + waiting.awaiting_input = false; + let mut idle = idle_session("codex"); + idle.awaiting_input = true; + let mut sessions = vec![waiting, idle]; + + reconcile_status_evidence(&mut sessions, &HashMap::new(), 100); + + assert!(sessions[0].awaiting_input); + assert!(!sessions[1].awaiting_input); + for session in sessions { + assert_eq!( + session.status_evidence.authority, + StatusAuthority::Heuristic + ); + assert_eq!( + session.status_evidence.reason, + StatusReason::CollectorInference + ); + assert_eq!(session.status_evidence.observations.len(), 1); } } #[test] - fn test_rate_limited_promotion_is_per_agent_cli() { - // Claude is saturated, Codex is not. Only the Claude session should - // be promoted. - let mut sessions = vec![waiting_session("claude"), waiting_session("codex")]; - let limits = vec![rate_limit("claude", 95.0)]; - promote_waiting_to_rate_limited(&mut sessions, &limits); - assert_eq!(sessions[0].status, SessionStatus::RateLimited); - assert_eq!(sessions[1].status, SessionStatus::Waiting); + fn reconciliation_marks_unknown_evidence_unavailable() { + let mut unknown = waiting_session("kimi"); + unknown.status = SessionStatus::Unknown; + let mut sessions = vec![unknown]; + + reconcile_status_evidence(&mut sessions, &HashMap::new(), 100); + + assert_eq!( + sessions[0].status_evidence.authority, + StatusAuthority::Unavailable + ); + assert_eq!( + sessions[0].status_evidence.reason, + StatusReason::OwnershipUnconfirmed + ); + assert!(!sessions[0].awaiting_input); } #[test] - fn test_rate_limited_promotion_ignores_below_threshold() { - let mut sessions = vec![waiting_session("claude")]; - let limits = vec![rate_limit("claude", 89.9)]; - promote_waiting_to_rate_limited(&mut sessions, &limits); - assert_eq!(sessions[0].status, SessionStatus::Waiting); + fn reconciliation_preserves_history_across_ticks() { + let mut first = vec![idle_session("codex")]; + first[0].session_id = "stable".into(); + first[0].action_process_incarnation = Some("process-a".into()); + let previous = reconcile_status_evidence(&mut first, &HashMap::new(), 100); + + let mut second = vec![idle_session("codex")]; + second[0].session_id = "stable".into(); + second[0].action_process_incarnation = Some("process-a".into()); + reconcile_status_evidence(&mut second, &previous, 200); + + assert_eq!(second[0].status_evidence.observations.len(), 2); + assert_eq!(second[0].status_evidence.consecutive_matching, 2); + assert_eq!(second[0].status_evidence.status_since_ms, 100); + } + + #[test] + fn reconciliation_accumulates_source_qualified_incremental_samples() { + let mut first = idle_session("claude"); + first.session_id = "provider-state".into(); + first.status_evidence.observe(StatusObservation::new( + SessionStatus::Idle, + StatusAuthority::Provider, + StatusReason::ProviderIdle, + 100, + 0, + )); + first.action_process_incarnation = Some("process-a".into()); + let previous = + reconcile_status_evidence(std::slice::from_mut(&mut first), &HashMap::new(), 100); + + let mut second = idle_session("claude"); + second.session_id = "provider-state".into(); + second.status_evidence.observe(StatusObservation::new( + SessionStatus::Idle, + StatusAuthority::Provider, + StatusReason::ProviderIdle, + 200, + 0, + )); + second.action_process_incarnation = Some("process-a".into()); + let duplicate_previous = + reconcile_status_evidence(std::slice::from_mut(&mut second), &previous, 200); + + assert_eq!(second.status_evidence.observations.len(), 2); + assert_eq!(second.status_evidence.consecutive_matching, 2); + assert_eq!(second.status_evidence.status_since_ms, 100); + + let mut duplicate = idle_session("claude"); + duplicate.session_id = "provider-state".into(); + duplicate.status_evidence.observe(StatusObservation::new( + SessionStatus::Idle, + StatusAuthority::Provider, + StatusReason::ProviderIdle, + 200, + 0, + )); + duplicate.action_process_incarnation = Some("process-a".into()); + reconcile_status_evidence( + std::slice::from_mut(&mut duplicate), + &duplicate_previous, + 250, + ); + assert_eq!(duplicate.status_evidence.consecutive_matching, 2); + assert_eq!(duplicate.status_evidence.status_since_ms, 100); + assert_eq!(duplicate.status_evidence.observations.len(), 2); + } + + #[test] + fn reconciliation_never_inherits_across_process_incarnations() { + let mut first = idle_session("codex"); + first.session_id = "reused".into(); + first.action_process_incarnation = Some("process-a".into()); + let previous = + reconcile_status_evidence(std::slice::from_mut(&mut first), &HashMap::new(), 100); + + let mut reused = idle_session("codex"); + reused.session_id = "reused".into(); + reused.action_process_incarnation = Some("process-b".into()); + let current = reconcile_status_evidence(std::slice::from_mut(&mut reused), &previous, 200); + + assert_eq!(reused.status_evidence.observations.len(), 1); + assert_eq!(reused.status_evidence.consecutive_matching, 1); + assert_eq!(reused.status_evidence.status_since_ms, 200); + assert_eq!(current.len(), 1); + assert!(current + .keys() + .all(|key| key.process_incarnation == "process-b")); + } + + #[test] + fn evidence_key_uses_the_collector_bound_incarnation_after_pid_reuse() { + let mut session = idle_session("codex"); + session.pid = 42; + session.session_id = "logical-a".into(); + session.action_process_incarnation = Some("process-a".into()); + + // Process A produced this row and then exited. Even if a fresh OS + // lookup would now observe process B at the same PID, status history + // stays keyed to the collector-bound A identity. The next collector + // pass may publish B as a different key, but App must not synthesize it. + let post_collection_pid_owner = "process-b"; + let key = status_evidence_key(&session).expect("row has an exact identity"); + + assert_eq!(key.process_incarnation, "process-a"); + assert_ne!(key.process_incarnation, post_collection_pid_owner); } #[test] - fn test_rate_limited_promotion_skips_non_waiting_sessions() { - let mut sessions = vec![waiting_session("claude")]; - sessions[0].status = SessionStatus::Thinking; - let limits = vec![rate_limit("claude", 99.0)]; - promote_waiting_to_rate_limited(&mut sessions, &limits); - assert_eq!(sessions[0].status, SessionStatus::Thinking); + fn reconciliation_requires_provider_session_and_pid_to_match() { + let mut first = idle_session("codex"); + first.session_id = "logical-a".into(); + let previous = + reconcile_status_evidence(std::slice::from_mut(&mut first), &HashMap::new(), 100); + + let mut changed_provider = idle_session("claude"); + changed_provider.session_id = "logical-a".into(); + let mut changed_session = idle_session("codex"); + changed_session.session_id = "logical-b".into(); + let mut changed_pid = idle_session("codex"); + changed_pid.session_id = "logical-a".into(); + changed_pid.pid = 2; + let mut sessions = vec![changed_provider, changed_session, changed_pid]; + + reconcile_status_evidence(&mut sessions, &previous, 200); + + assert!(sessions.iter().all(|session| { + session.status_evidence.observations.len() == 1 + && session.status_evidence.status_since_ms == 200 + && session.status_evidence.consecutive_matching == 1 + })); + } + + #[test] + fn reconciliation_without_exact_incarnation_fails_closed() { + let mut first = idle_session("codex"); + first.session_id = "unqueryable".into(); + first.action_process_incarnation = None; + let first_ledger = + reconcile_status_evidence(std::slice::from_mut(&mut first), &HashMap::new(), 100); + assert!(first_ledger.is_empty()); + + let mut second = idle_session("codex"); + second.session_id = "unqueryable".into(); + second.action_process_incarnation = None; + reconcile_status_evidence(std::slice::from_mut(&mut second), &first_ledger, 200); + + assert_eq!(second.status_evidence.observations.len(), 1); + assert_eq!(second.status_evidence.status_since_ms, 200); + + let mut unidentified = idle_session("codex"); + let unidentified_ledger = reconcile_status_evidence( + std::slice::from_mut(&mut unidentified), + &HashMap::new(), + 300, + ); + assert!(unidentified_ledger.is_empty()); + } + + #[test] + fn reconciliation_preserves_newer_explicit_provider_status_since() { + let mut first = idle_session("claude"); + first.session_id = "provider-transition".into(); + first.status_evidence.observe(StatusObservation::new( + SessionStatus::Idle, + StatusAuthority::Provider, + StatusReason::ProviderIdle, + 100, + 7, + )); + let previous = + reconcile_status_evidence(std::slice::from_mut(&mut first), &HashMap::new(), 100); + + let mut current = idle_session("claude"); + current.session_id = "provider-transition".into(); + current.status_evidence.observe(StatusObservation::new( + SessionStatus::Idle, + StatusAuthority::Provider, + StatusReason::ProviderIdle, + 300, + 7, + )); + current.status_evidence.status_since_ms = 250; + reconcile_status_evidence(std::slice::from_mut(&mut current), &previous, 300); + + assert_eq!(current.status_evidence.status_since_ms, 250); + assert_eq!(current.status_evidence.consecutive_matching, 2); + assert_eq!(current.status_evidence.observations.len(), 2); } #[test] @@ -1101,6 +2917,12 @@ mod tests { assert!(is_supported_agent_command("/usr/local/bin/claude")); assert!(is_supported_agent_command("codex --resume abc")); assert!(is_supported_agent_command("/opt/homebrew/bin/opencode")); + assert!(is_supported_agent_command("/usr/local/bin/grok")); + assert!(is_supported_agent_command("xai-grok-pager --resume abc")); + assert!(is_supported_agent_command("kimi-code")); + assert!(is_supported_agent_command( + "node /opt/node_modules/@moonshot-ai/kimi-code/dist/main.mjs" + )); assert!(!is_supported_agent_command("node server.js")); } @@ -1112,4 +2934,412 @@ mod tests { "/Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled" )); } + + #[test] + fn grok_command_distinguishes_host_modes_from_prompt_text() { + assert!(is_grok_agent_command("grok")); + assert!(is_grok_agent_command("/usr/local/bin/grok -p hello")); + assert!(is_grok_agent_command("~/.grok/bin/grok-1.2.3 -p hello")); + assert!(is_grok_agent_command( + "\"/Applications/Grok Build/grok\" -p hello" + )); + assert!(is_grok_agent_command("xai-grok-pager --resume abc")); + assert!(is_grok_agent_command("grok -p agent leader")); + assert!(is_grok_agent_command("grok fix agent leader handling")); + assert!(!is_grok_agent_command("grok agent leader")); + assert!(!is_grok_agent_command( + "/usr/local/bin/grok --debug agent --model grok-code leader" + )); + assert!(!is_grok_agent_command( + "cat /Users/test/.grok/bin/grok-1.2.3" + )); + assert!(!is_grok_agent_command("\"/tmp/not grok\"")); + assert!(!is_killable_agent_command_for_provider( + "grok", + "grok agent leader" + )); + } + + #[test] + fn kimi_command_rejects_non_session_hosts_and_plugin_helper() { + assert!(is_kimi_agent_command("kimi")); + assert!(is_kimi_agent_command("kimi-code --session abc")); + assert!(is_kimi_agent_command("kimi -p build web UI")); + assert!(is_kimi_agent_command("kimi \"--prompt=build web UI\"")); + assert!(is_kimi_agent_command("kimi -S web")); + assert!(is_kimi_agent_command( + "node /opt/node_modules/@moonshot-ai/kimi-code/dist/main.mjs" + )); + assert!(!is_kimi_agent_command("kimi web")); + assert!(!is_kimi_agent_command("kimi --verbose web")); + assert!(!is_kimi_agent_command("kimi acp")); + assert!(!is_kimi_agent_command("kimi server")); + assert!(!is_kimi_agent_command("kimi vis session-id")); + assert!(!is_kimi_agent_command("kimi __plugin_run_node")); + assert!(!is_kimi_agent_command( + "node /opt/node_modules/@moonshot-ai/kimi-code/dist/main.mjs web" + )); + assert!(!is_kimi_agent_command( + "cat /opt/node_modules/@moonshot-ai/kimi-code/dist/main.mjs" + )); + assert!(!is_kimi_agent_command( + "node server.js --help-text=@moonshot-ai/kimi-code/dist/main.mjs" + )); + assert!(!is_kimi_agent_command("\"/tmp/not kimi\"")); + } + + #[test] + fn kill_validation_requires_the_selected_provider() { + assert!(is_killable_agent_command_for_provider( + "grok", + "grok --resume abc" + )); + assert!(!is_killable_agent_command_for_provider( + "claude", + "grok --resume abc" + )); + assert!(!is_killable_agent_command_for_provider("kimi", "kimi web")); + } + + #[test] + fn session_filter_matches_provider_name() { + let session = waiting_session("grok"); + assert!(App::session_matches(&session, "grok")); + assert!(!App::session_matches(&session, "kimi")); + } + + #[test] + fn session_filter_matches_idle_status() { + let session = idle_session("codex"); + assert!(App::session_matches(&session, "idle")); + assert!(!App::session_matches(&session, "waiting")); + } + + #[test] + fn shared_grok_process_confirmation_warns_about_all_sessions() { + assert_eq!( + kill_confirmation_message("grok", 42, "session", 3), + "Press x again to kill PID 42; affects 3 Grok sessions" + ); + assert_eq!( + kill_confirmation_message("kimi", 42, "session", 1), + "Press x again to kill: session" + ); + } + + #[test] + fn process_incarnation_requires_two_matching_known_exact_identities() { + assert!(process_incarnation_matches( + Some("linux:boot-id:10"), + Some("linux:boot-id:10") + )); + assert!(!process_incarnation_matches( + Some("linux:boot-id:10"), + Some("linux:boot-id:11") + )); + assert!(!process_incarnation_matches(Some("linux:boot-id:10"), None)); + assert!(!process_incarnation_matches(None, Some("linux:boot-id:10"))); + assert!(!process_incarnation_matches(None, None)); + } + + #[test] + fn pid_action_validation_brackets_the_command_with_exact_identity() { + assert!(action_process_observation_is_valid( + "claude", + Some("process-a"), + Some("process-a"), + Some("process-a"), + Some("/usr/local/bin/claude"), + false, + )); + assert!(!action_process_observation_is_valid( + "claude", + None, + Some("process-a"), + Some("process-a"), + Some("/usr/local/bin/claude"), + false, + )); + assert!(!action_process_observation_is_valid( + "claude", + Some("process-a"), + Some("process-a"), + Some("process-b"), + Some("/usr/local/bin/claude"), + false, + )); + assert!(!action_process_observation_is_valid( + "claude", + Some("process-a"), + Some("process-a"), + Some("process-a"), + Some("/usr/local/bin/grok"), + false, + )); + assert!(!action_process_observation_is_valid( + "grok", + Some("process-a"), + Some("process-a"), + Some("process-a"), + Some("/usr/local/bin/grok"), + true, + )); + } + + #[test] + fn action_validation_requires_exact_provider_argv_and_session_role() { + assert!(exact_action_argv_is_valid( + "claude", + &["/usr/local/bin/claude".to_string()], + )); + assert!(!exact_action_argv_is_valid( + "claude", + &["/usr/local/bin/codex".to_string()], + )); + assert!(!exact_action_argv_is_valid( + "codex", + &["/usr/local/bin/codex".to_string(), "app-server".to_string(),], + )); + assert!(!exact_action_argv_is_valid( + "grok", + &[ + "/usr/local/bin/grok".to_string(), + "agent".to_string(), + "leader".to_string(), + ], + )); + assert!(!exact_action_argv_is_valid( + "kimi", + &["/usr/local/bin/kimi-code".to_string(), "web".to_string()], + )); + } + + #[test] + fn row_bound_action_anchor_rejects_reuse_before_app_resample() { + let mut session = waiting_session("codex"); + session.pid = 42; + session.session_id = "logical-a".into(); + session.action_process_incarnation = Some("process-a".into()); + + // Process A produced the row, then exited. A post-collection PID + // resample would now see a different Codex process B at PID 42. + let post_collection_resample = "process-b"; + let confirmation = KillConfirmation::for_session( + &session, + std::slice::from_ref(&session), + session.action_process_incarnation.clone().unwrap(), + ); + assert_eq!(confirmation.process_incarnation, "process-a"); + assert!(!action_process_observation_is_valid( + "codex", + Some(&confirmation.process_incarnation), + Some(post_collection_resample), + Some(post_collection_resample), + Some("/usr/local/bin/codex"), + false, + )); + } + + #[test] + fn fallback_summaries_remove_terminal_controls_and_secrets() { + let summary = sanitize_fallback("\u{202e}fix sk-ant-secret now\n", 80); + assert!(!summary.contains('\u{202e}')); + assert!(!summary.contains("sk-ant-secret")); + assert!(summary.contains("[REDACTED]")); + } + + #[test] + fn grok_confirmation_revalidates_the_shared_session_set() { + let mut first = waiting_session("grok"); + first.pid = 42; + first.session_id = "first".into(); + first.action_process_incarnation = Some("linux:boot-id:10".into()); + let mut second = waiting_session("grok"); + second.pid = 42; + second.session_id = "second".into(); + second.action_process_incarnation = Some("linux:boot-id:10".into()); + let confirmation = KillConfirmation { + provider: "grok".into(), + session_id: "first".into(), + pid: 42, + process_incarnation: "linux:boot-id:10".into(), + grok_session_ids: vec!["first".into(), "second".into()], + requested_at: Instant::now(), + }; + assert!(confirmation.matches(&first, &[second.clone(), first.clone()])); + assert!(!confirmation.matches(&second, &[first.clone(), second.clone()])); + + let mut wrong_provider = first.clone(); + wrong_provider.agent_cli = "kimi"; + assert!(!confirmation.matches(&wrong_provider, &[first.clone(), second.clone()])); + + let mut wrong_pid = first.clone(); + wrong_pid.pid = 43; + assert!(!confirmation.matches(&wrong_pid, &[first.clone(), second.clone()])); + + let mut wrong_incarnation = first.clone(); + wrong_incarnation.action_process_incarnation = Some("linux:boot-id:11".into()); + assert!(!confirmation.matches(&wrong_incarnation, &[first.clone(), second.clone()])); + + let mut added = waiting_session("grok"); + added.pid = 42; + added.session_id = "added".into(); + assert!(!confirmation.matches(&first, &[first.clone(), second, added])); + assert!(!confirmation.matches(&first, &[first.clone()])); + } + + #[test] + fn termination_failures_use_safe_nonempty_details() { + assert_eq!( + termination_failure_message(42, "permission denied\n", "", "exit status: 1"), + "Failed to kill PID 42: permission denied" + ); + assert_eq!( + termination_failure_message(42, "", "taskkill failed\n", "exit status: 1"), + "Failed to kill PID 42: taskkill failed" + ); + let escaped = termination_failure_message(42, "\u{1b}[31mnope", "", ""); + assert!(!escaped.contains('\u{1b}')); + } + + #[test] + fn orphan_kill_status_reports_successes_and_failures() { + assert_eq!( + orphan_kill_status(0, &[]), + "No orphan processes needed killing" + ); + assert_eq!(orphan_kill_status(2, &[]), "Killed 2 orphan process(es)"); + assert_eq!( + orphan_kill_status(0, &["identity changed".into()]), + "identity changed" + ); + assert_eq!( + orphan_kill_status(1, &["permission denied".into()]), + "Killed 1; 1 orphan failure(s): permission denied" + ); + } + + #[test] + fn exact_semantic_focus_precedes_the_non_actionable_status_guard() { + for (provider, status) in [ + ("codex", SessionStatus::Working), + ("kimi", SessionStatus::Unknown), + ("kimi", SessionStatus::Done), + ] { + let mut app = App::new_with_config( + Theme::default(), + &[], + crate::config::PanelVisibility::default(), + ); + let mut session = waiting_session(provider); + session.status = status; + session.session_id = "session-1".to_string(); + app.sessions.push(session); + + assert_eq!( + app.jump_to_session_with( + |actual_provider, session_id| { + assert_eq!(actual_provider, provider); + assert_eq!(session_id, "session-1"); + JumpOutcome::Jumped + }, + |_| panic!("PID fallback must not run after semantic focus") + ), + JumpOutcome::Jumped + ); + } + } + + #[test] + fn unknown_row_without_semantic_identity_cannot_use_pid_fallback() { + let mut app = App::new_with_config( + Theme::default(), + &[], + crate::config::PanelVisibility::default(), + ); + let mut session = waiting_session("kimi"); + session.status = SessionStatus::Unknown; + app.sessions.push(session); + + assert_eq!( + app.jump_to_session_with( + |_, _| JumpOutcome::NoOp, + |_| panic!("non-actionable row must not reach PID fallback") + ), + JumpOutcome::NoOp + ); + } + + #[test] + fn working_row_is_never_pid_actionable() { + let mut session = waiting_session("codex"); + session.status = SessionStatus::Working; + session.status_evidence.authority = StatusAuthority::Provider; + + assert!(!session_process_is_actionable(&session)); + } + + #[test] + fn actionable_jump_without_a_polled_incarnation_fails_closed() { + let mut app = App::new_with_config( + Theme::default(), + &[], + crate::config::PanelVisibility::default(), + ); + let mut session = waiting_session("claude"); + session.status_evidence.authority = StatusAuthority::Provider; + app.sessions.push(session); + + assert!(matches!( + app.jump_to_session_with( + |_, _| JumpOutcome::NoOp, + |_| panic!("failed process validation must stop before PID jump") + ), + JumpOutcome::Failed(message) + if message == "PID 1 is no longer the selected agent process" + )); + } + + #[test] + fn kimi_pid_actions_require_provider_ownership() { + let mut session = waiting_session("kimi"); + session.status_evidence.authority = StatusAuthority::Heuristic; + assert!(!session_process_is_actionable(&session)); + + session.status_evidence.authority = StatusAuthority::Unavailable; + assert!(!session_process_is_actionable(&session)); + + session.status_evidence.authority = StatusAuthority::Provider; + assert!(session_process_is_actionable(&session)); + + session.status = SessionStatus::Unknown; + assert!(!session_process_is_actionable(&session)); + } + + #[test] + fn every_provider_action_requires_a_row_bound_process_anchor() { + for provider in ["claude", "codex", "opencode", "grok", "kimi"] { + let mut session = waiting_session(provider); + session.status_evidence.authority = StatusAuthority::Provider; + assert!(session_process_is_actionable(&session), "{provider}"); + + session.status_evidence.authority = StatusAuthority::Heuristic; + assert_eq!( + session_process_is_actionable(&session), + provider != "kimi", + "{provider}" + ); + + session.status_evidence.authority = StatusAuthority::Unavailable; + assert!(!session_process_is_actionable(&session), "{provider}"); + + session.status_evidence.authority = StatusAuthority::Provider; + session.action_process_incarnation = None; + assert!(!session_process_is_actionable(&session), "{provider}"); + } + + let mut zero_pid = waiting_session("codex"); + zero_pid.pid = 0; + assert!(!session_process_is_actionable(&zero_pid)); + } } diff --git a/src/codex_compat.rs b/src/codex_compat.rs new file mode 100644 index 0000000..4951d20 --- /dev/null +++ b/src/codex_compat.rs @@ -0,0 +1,66 @@ +//! Temporary 0.6 compatibility for shell functions installed by pre-plugin builds. +//! +//! This is intentionally not an integration mechanism. It performs no relay, +//! status collection, argument filtering, or binary discovery. A legacy shell +//! function already loaded in the current shell may still call +//! `abtop codex -- ...`; in that case we replace this process with the exact +//! executable that the function captured in `ABTOP_MANAGED_CODEX_BINARY`. + +use std::ffi::OsString; +use std::io; +use std::path::PathBuf; + +const LEGACY_CODEX_BINARY_ENV: &str = "ABTOP_MANAGED_CODEX_BINARY"; + +fn captured_binary() -> io::Result { + let binary = std::env::var_os(LEGACY_CODEX_BINARY_ENV).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "legacy Codex compatibility requires ABTOP_MANAGED_CODEX_BINARY", + ) + })?; + let path = PathBuf::from(binary); + if !path.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "ABTOP_MANAGED_CODEX_BINARY must be an absolute path", + )); + } + Ok(path) +} + +/// Hand control directly to the native command captured by an already-loaded +/// legacy shell function. This entry point is scheduled for removal in 0.7. +pub(crate) fn run(args: Vec) -> io::Result { + let binary = captured_binary()?; + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + + let error = std::process::Command::new(binary) + .args(args) + .env_remove(LEGACY_CODEX_BINARY_ENV) + .exec(); + Err(error) + } + + #[cfg(not(unix))] + { + let status = std::process::Command::new(binary) + .args(args) + .env_remove(LEGACY_CODEX_BINARY_ENV) + .status()?; + Ok(status.code().unwrap_or(1)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compatibility_environment_name_stays_private_and_explicit() { + assert_eq!(LEGACY_CODEX_BINARY_ENV, "ABTOP_MANAGED_CODEX_BINARY"); + } +} diff --git a/src/codex_hooks/ingest.rs b/src/codex_hooks/ingest.rs new file mode 100644 index 0000000..7a1e5ac --- /dev/null +++ b/src/codex_hooks/ingest.rs @@ -0,0 +1,1253 @@ +//! Silent Codex hook ingestion. +//! +//! The parser materializes only allowlisted lifecycle fields. Prompt text, +//! tool inputs/outputs, transcript paths, assistant messages, and every +//! unknown value are drained with `IgnoredAny` and never enter persisted state. + +use super::plugin::{self, PluginPaths, HOOK_SCHEMA_REVISION}; +use super::state::{ + unix_now_ms, HookEvent, HookEventKind, HookProcessIdentity, HookStateIngress, HookStateStore, + HookToolClass, IntegrationIdentity, SessionStartSource, +}; +use crate::collector::process; +use serde::de::{self, Deserializer, IgnoredAny, MapAccess, Visitor}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +const MAX_HOOK_DECLARATION_BYTES: usize = 256 * 1024; +const MAX_HOOK_STREAM_BYTES: usize = 4 * 1024 * 1024; +const MAX_LIFECYCLE_ID_BYTES: usize = 512; +const MAX_CWD_BYTES: usize = 16 * 1024; +const MAX_EVENT_NAME_BYTES: usize = 64; +const MAX_SOURCE_BYTES: usize = 16; +const MAX_ROOT_FIELDS: usize = 256; +const MAX_ANCESTORS: usize = 64; + +pub(crate) fn run_from_environment(args: Vec) -> io::Result<()> { + let plugin_data = plugin_data_from_environment()?; + let observed_at_ms = unix_now_ms(); + let ingress = HookStateStore::prepare(&plugin_data)?; + let ingest_guard = match std::env::var_os("ABTOP_CODEX_HOOK_FAULT_TOKEN") { + Some(token) => match ingress.adopt_launcher_marker(token.as_os_str(), observed_at_ms) { + Ok(guard) => guard, + Err(error) => { + // Keep the launcher's untrusted/unusable marker in place and + // independently establish a generic fail-closed marker. + let _ = ingress.begin_ingest(observed_at_ms); + return Err(error); + } + }, + None => ingress.begin_ingest(observed_at_ms)?, + }; + let ingest_marker_id = ingest_guard.marker_id()?.to_owned(); + + let helper_digest = parse_private_args(&args)?; + let parsed = parse_and_drain_hook_input_outcome(io::stdin().lock())?; + ingress.reclaim_stale_artifacts_after_drain(observed_at_ms, Some(&ingest_marker_id))?; + let parsed = parsed?; + let (store, process, integration) = + attest_hook_environment(&plugin_data, &ingress, &helper_digest)?; + let event = parsed.into_event(process, integration, observed_at_ms, ingest_marker_id)?; + store.fold(event)?; + ingest_guard.succeed()?; + Ok(()) +} + +fn attest_hook_environment( + plugin_data: &Path, + ingress: &HookStateIngress, + helper_digest: &str, +) -> io::Result<(HookStateStore, HookProcessIdentity, IntegrationIdentity)> { + let codex_home = codex_home_from_plugin_data(plugin_data)?; + let paths = PluginPaths::new(&codex_home)?; + let canonical_data = fs::canonicalize(plugin_data)?; + if canonical_data != fs::canonicalize(&paths.plugin_data_root)? { + return Err(invalid_data( + "PLUGIN_DATA does not identify abtop's plugin data", + )); + } + + let attestation = plugin::read_installation_attestation(&codex_home)? + .ok_or_else(|| invalid_data("missing installation attestation"))?; + if attestation.hook_schema_revision != HOOK_SCHEMA_REVISION + || attestation.helper_digest != helper_digest + { + return Err(invalid_data("hook helper identity changed")); + } + let current_exe = std::env::current_exe()?; + if plugin::helper_digest(¤t_exe)? != helper_digest { + return Err(invalid_data( + "running helper does not match its installation", + )); + } + validate_cached_plugin( + &codex_home, + &attestation.plugin_version, + &attestation.hooks_digest, + )?; + let runtime = plugin::runtime_hook_config(&codex_home, ¤t_exe)?; + let integration = IntegrationIdentity { + hook_schema_revision: attestation.hook_schema_revision, + helper_digest: attestation.helper_digest, + installation_id: attestation.installation_id, + config_digest: runtime.config_digest, + complete_hook_set: runtime.complete_hook_set, + }; + let store = ingress.bind(integration.clone())?; + let process = resolve_nearest_codex_ancestor(&codex_home)?; + Ok((store, process, integration)) +} + +fn parse_private_args(args: &[OsString]) -> io::Result { + if args.len() != 4 + || args[0] != OsStr::new("--schema-revision") + || args[1] != OsStr::new(HOOK_SCHEMA_REVISION) + || args[2] != OsStr::new("--helper-digest") + { + return Err(invalid_data("invalid private hook arguments")); + } + let digest = args[3] + .to_str() + .ok_or_else(|| invalid_data("invalid helper digest encoding"))?; + if digest.len() != 71 + || !digest.starts_with("sha256:") + || !digest[7..].bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(invalid_data("invalid helper digest")); + } + Ok(digest.to_string()) +} + +fn plugin_data_from_environment() -> io::Result { + let value = std::env::var_os("PLUGIN_DATA") + .or_else(|| std::env::var_os("CLAUDE_PLUGIN_DATA")) + .ok_or_else(|| invalid_data("missing PLUGIN_DATA"))?; + let path = PathBuf::from(value); + if !path.is_absolute() + || path.file_name().and_then(|name| name.to_str()) != Some("abtop-abtop-local") + { + return Err(invalid_data("invalid PLUGIN_DATA")); + } + Ok(path) +} + +fn codex_home_from_plugin_data(plugin_data: &Path) -> io::Result { + let data = plugin_data + .parent() + .ok_or_else(|| invalid_data("PLUGIN_DATA has no data parent"))?; + if data.file_name().and_then(|name| name.to_str()) != Some("data") { + return Err(invalid_data("PLUGIN_DATA is outside plugins/data")); + } + let plugins = data + .parent() + .ok_or_else(|| invalid_data("PLUGIN_DATA has no plugins parent"))?; + if plugins.file_name().and_then(|name| name.to_str()) != Some("plugins") { + return Err(invalid_data("PLUGIN_DATA is outside the plugins root")); + } + plugins + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| invalid_data("PLUGIN_DATA has no CODEX_HOME parent")) +} + +fn validate_cached_plugin( + codex_home: &Path, + version: &str, + expected_hooks_digest: &str, +) -> io::Result<()> { + if version.is_empty() || version == "." || version == ".." || version.contains(['/', '\\']) { + return Err(invalid_data("invalid installed plugin version")); + } + let expected_root = codex_home + .join("plugins/cache/abtop-local/abtop") + .join(version); + let plugin_root = std::env::var_os("PLUGIN_ROOT") + .or_else(|| std::env::var_os("CLAUDE_PLUGIN_ROOT")) + .map(PathBuf::from) + .ok_or_else(|| invalid_data("missing PLUGIN_ROOT"))?; + if !plugin_root.is_absolute() + || fs::canonicalize(&plugin_root)? != fs::canonicalize(&expected_root)? + { + return Err(invalid_data( + "hook did not originate from the installed abtop cache", + )); + } + let hooks = read_regular_bounded( + &plugin_root.join("hooks/hooks.json"), + MAX_HOOK_DECLARATION_BYTES, + )?; + if sha256(&hooks) != expected_hooks_digest { + return Err(invalid_data("installed hook declaration changed")); + } + Ok(()) +} + +fn read_regular_bounded(path: &Path, maximum: usize) -> io::Result> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > maximum as u64 { + return Err(invalid_data("unsafe installed hook declaration")); + } + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let file = options.open(path)?; + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(maximum as u64 + 1).read_to_end(&mut bytes)?; + if bytes.len() > maximum { + return Err(invalid_data("oversized installed hook declaration")); + } + Ok(bytes) +} + +#[derive(Debug, Default)] +struct ParsedHookInput { + session_id: Option, + cwd: Option, + event: Option, + turn_id: Option, + tool_name: Option, + tool_use_id: Option, + agent_id: Option, + source: Option, + stop_hook_active: Option, +} + +impl ParsedHookInput { + fn into_event( + self, + process: HookProcessIdentity, + integration: IntegrationIdentity, + observed_at_ms: u64, + ingest_marker_id: String, + ) -> io::Result { + let session_id = self + .session_id + .ok_or_else(|| invalid_data("hook input has no session_id"))?; + let cwd = self + .cwd + .ok_or_else(|| invalid_data("hook input has no cwd"))?; + let event_name = self + .event + .ok_or_else(|| invalid_data("hook input has no hook_event_name"))?; + let kind = parse_event_kind(&event_name)?; + let tool_class = match kind { + HookEventKind::PreToolUse | HookEventKind::PostToolUse => { + Some(if self.tool_name.as_deref() == Some("request_user_input") { + HookToolClass::RequestUserInput + } else { + HookToolClass::Ordinary + }) + } + _ => None, + }; + let session_start_source = match (kind, self.source.as_deref()) { + (HookEventKind::SessionStart, Some("startup")) => Some(SessionStartSource::Startup), + (HookEventKind::SessionStart, Some("resume")) => Some(SessionStartSource::Resume), + (HookEventKind::SessionStart, Some("clear")) => Some(SessionStartSource::Clear), + (HookEventKind::SessionStart, Some("compact")) => Some(SessionStartSource::Compact), + (HookEventKind::SessionStart, _) => { + return Err(invalid_data("SessionStart has an invalid source")); + } + (_, Some(_)) => return Err(invalid_data("unexpected hook source field")), + _ => None, + }; + if self + .agent_id + .as_deref() + .is_some_and(|agent_id| agent_id == session_id.as_str()) + { + return Err(invalid_data( + "hook agent_id aliases the shared root session", + )); + } + require_shape( + kind, + self.turn_id.as_deref(), + self.tool_name.as_deref(), + self.tool_use_id.as_deref(), + self.agent_id.as_deref(), + self.stop_hook_active, + )?; + Ok(HookEvent { + kind, + session_id, + cwd, + turn_id: self.turn_id, + tool_use_id: self.tool_use_id, + tool_class, + agent_id: self.agent_id, + session_start_source, + observed_at_ms, + process, + integration, + stop_hook_active: self.stop_hook_active, + ingest_marker_id, + }) + } +} + +/// Parse one hook object without retaining its raw JSON, then drain the input. +/// +/// Draining happens even after malformed JSON so Codex never blocks while its +/// hook writer is still holding the other end of the pipe. The hard byte cap +/// bounds adversarial or broken producers; ordinary large ignored fields do +/// not allocate an aggregate input buffer and are accepted up to that cap. +#[cfg(all(test, unix))] +fn parse_before_preflight(reader: R, preflight: F) -> io::Result<(ParsedHookInput, T)> +where + R: Read, + F: FnOnce() -> io::Result, +{ + // This ordering is part of the hook protocol. Codex writes the complete + // JSON object before it can wait for us to exit; hashing executables or + // probing process ancestry first can leave both processes blocked on a + // full stdin pipe until Codex's one-second hook timeout fires. + let parsed = parse_and_drain_hook_input_outcome(reader)??; + let verified = preflight()?; + Ok((parsed, verified)) +} + +#[cfg(test)] +fn parse_and_drain_hook_input(reader: R) -> io::Result { + parse_and_drain_hook_input_outcome(reader)? +} + +/// Return the parse result only after the bounded input reached EOF. +/// +/// Keeping the parse error nested lets the ingest path perform content-free +/// maintenance after a malformed but fully drained payload, without running +/// maintenance when an oversized or broken producer prevented a complete +/// drain. +fn parse_and_drain_hook_input_outcome( + reader: R, +) -> io::Result> { + let mut reader = BoundedHookReader::new(reader, MAX_HOOK_STREAM_BYTES); + let parsed = { + let mut deserializer = serde_json::Deserializer::from_reader(&mut reader); + ParsedHookInput::deserialize(&mut deserializer) + .and_then(|parsed| deserializer.end().map(|()| parsed)) + .map_err(|error| invalid_data(format!("invalid hook JSON: {error}"))) + }; + + let drain_result = reader.drain_to_eof(); + if reader.exceeded { + return Err(invalid_data("hook input exceeds its streaming bound")); + } + drain_result?; + Ok(parsed) +} + +struct BoundedHookReader { + inner: R, + consumed: usize, + maximum: usize, + exceeded: bool, +} + +impl BoundedHookReader { + fn new(inner: R, maximum: usize) -> Self { + Self { + inner, + consumed: 0, + maximum, + exceeded: false, + } + } +} + +impl BoundedHookReader { + fn read_bounded(&mut self, buffer: &mut [u8]) -> io::Result { + if self.exceeded { + return Err(invalid_data("hook input exceeds its streaming bound")); + } + if buffer.is_empty() { + return Ok(0); + } + + // Permit one probe byte beyond the bound so an input of exactly the + // maximum size can still be distinguished from an oversized stream. + let remaining_with_probe = self.maximum.saturating_add(1).saturating_sub(self.consumed); + if remaining_with_probe == 0 { + self.exceeded = true; + return Err(invalid_data("hook input exceeds its streaming bound")); + } + let maximum_read = buffer.len().min(remaining_with_probe); + let read = loop { + match self.inner.read(&mut buffer[..maximum_read]) { + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + result => break result?, + } + }; + self.consumed = self.consumed.saturating_add(read); + if self.consumed > self.maximum { + self.exceeded = true; + return Err(invalid_data("hook input exceeds its streaming bound")); + } + Ok(read) + } + + fn drain_to_eof(&mut self) -> io::Result<()> { + if self.exceeded { + return Err(invalid_data("hook input exceeds its streaming bound")); + } + let mut buffer = [0_u8; 8192]; + loop { + let read = self.read_bounded(&mut buffer)?; + if read == 0 { + return Ok(()); + } + } + } +} + +impl Read for BoundedHookReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + self.read_bounded(buffer) + } +} + +impl<'de> serde::Deserialize<'de> for ParsedHookInput { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_map(ParsedHookVisitor) + } +} + +struct ParsedHookVisitor; + +#[derive(Clone, Copy)] +enum HookField { + SessionId, + Cwd, + Event, + TurnId, + ToolName, + ToolUseId, + AgentId, + Source, + StopHookActive, + Other, +} + +impl HookField { + fn bit(self) -> Option { + match self { + Self::SessionId => Some(1 << 0), + Self::Cwd => Some(1 << 1), + Self::Event => Some(1 << 2), + Self::TurnId => Some(1 << 3), + Self::ToolName => Some(1 << 4), + Self::ToolUseId => Some(1 << 5), + Self::AgentId => Some(1 << 6), + Self::Source => Some(1 << 7), + Self::StopHookActive => Some(1 << 8), + Self::Other => None, + } + } +} + +impl<'de> Deserialize<'de> for HookField { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_identifier(HookFieldVisitor) + } +} + +struct HookFieldVisitor; + +impl Visitor<'_> for HookFieldVisitor { + type Value = HookField; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a hook object field") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + Ok(match value { + "session_id" => HookField::SessionId, + "cwd" => HookField::Cwd, + "hook_event_name" => HookField::Event, + "turn_id" => HookField::TurnId, + "tool_name" => HookField::ToolName, + "tool_use_id" => HookField::ToolUseId, + "agent_id" => HookField::AgentId, + "source" => HookField::Source, + "stop_hook_active" => HookField::StopHookActive, + _ => HookField::Other, + }) + } +} + +struct BoundedString(String); + +impl<'de, const MAXIMUM: usize> Deserialize<'de> for BoundedString { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(BoundedStringVisitor::) + } +} + +struct BoundedStringVisitor; + +impl Visitor<'_> for BoundedStringVisitor { + type Value = BoundedString; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "a string no longer than {MAXIMUM} bytes") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + if value.len() > MAXIMUM { + return Err(E::custom("allowlisted hook field is oversized")); + } + Ok(BoundedString(value.to_owned())) + } +} + +impl<'de> Visitor<'de> for ParsedHookVisitor { + type Value = ParsedHookInput; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("one Codex hook object") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut parsed = ParsedHookInput::default(); + let mut seen = 0_u16; + let mut field_count = 0_usize; + while let Some(field) = map.next_key::()? { + field_count = field_count.saturating_add(1); + if field_count > MAX_ROOT_FIELDS { + return Err(de::Error::custom("hook object has too many fields")); + } + if let Some(bit) = field.bit() { + if seen & bit != 0 { + return Err(de::Error::custom("duplicate critical hook field")); + } + seen |= bit; + } + match field { + HookField::SessionId => { + parsed.session_id = + Some(map.next_value::>()?.0); + } + HookField::Cwd => { + parsed.cwd = Some(map.next_value::>()?.0); + } + HookField::Event => { + parsed.event = Some(map.next_value::>()?.0); + } + HookField::TurnId => { + parsed.turn_id = + Some(map.next_value::>()?.0); + } + HookField::ToolName => { + parsed.tool_name = + Some(map.next_value::>()?.0); + } + HookField::ToolUseId => { + parsed.tool_use_id = + Some(map.next_value::>()?.0); + } + HookField::AgentId => { + parsed.agent_id = + Some(map.next_value::>()?.0); + } + HookField::Source => { + parsed.source = Some(map.next_value::>()?.0); + } + HookField::StopHookActive => { + parsed.stop_hook_active = Some(map.next_value::()?); + } + HookField::Other => { + map.next_value::()?; + } + } + } + Ok(parsed) + } +} + +fn parse_event_kind(value: &str) -> io::Result { + match value { + "PreToolUse" => Ok(HookEventKind::PreToolUse), + "PermissionRequest" => Ok(HookEventKind::PermissionRequest), + "PostToolUse" => Ok(HookEventKind::PostToolUse), + "PreCompact" => Ok(HookEventKind::PreCompact), + "PostCompact" => Ok(HookEventKind::PostCompact), + "SessionStart" => Ok(HookEventKind::SessionStart), + "SessionEnd" => Ok(HookEventKind::SessionEnd), + "UserPromptSubmit" => Ok(HookEventKind::UserPromptSubmit), + "SubagentStart" => Ok(HookEventKind::SubagentStart), + "SubagentStop" => Ok(HookEventKind::SubagentStop), + "Stop" => Ok(HookEventKind::Stop), + _ => Err(invalid_data("unsupported hook event")), + } +} + +fn require_shape( + kind: HookEventKind, + turn_id: Option<&str>, + tool_name: Option<&str>, + tool_use_id: Option<&str>, + agent_id: Option<&str>, + stop_hook_active: Option, +) -> io::Result<()> { + let needs_turn = matches!( + kind, + HookEventKind::PreToolUse + | HookEventKind::PermissionRequest + | HookEventKind::PostToolUse + | HookEventKind::PreCompact + | HookEventKind::PostCompact + | HookEventKind::UserPromptSubmit + | HookEventKind::SubagentStart + | HookEventKind::SubagentStop + | HookEventKind::Stop + ); + if needs_turn && turn_id.is_none_or(str::is_empty) { + return Err(invalid_data("hook event has no turn_id")); + } + if matches!(kind, HookEventKind::PreToolUse | HookEventKind::PostToolUse) + && tool_use_id.is_none_or(str::is_empty) + { + return Err(invalid_data("tool hook has no tool_use_id")); + } + if matches!( + kind, + HookEventKind::PreToolUse | HookEventKind::PermissionRequest | HookEventKind::PostToolUse + ) && tool_name.is_none_or(str::is_empty) + { + return Err(invalid_data("tool hook has no tool_name")); + } + if matches!( + kind, + HookEventKind::SubagentStart | HookEventKind::SubagentStop + ) && agent_id.is_none_or(str::is_empty) + { + return Err(invalid_data("subagent hook has no agent_id")); + } + if matches!( + kind, + HookEventKind::SessionStart | HookEventKind::SessionEnd | HookEventKind::Stop + ) && agent_id.is_some() + { + return Err(invalid_data("root hook unexpectedly names a subagent")); + } + if matches!(kind, HookEventKind::Stop | HookEventKind::SubagentStop) { + if stop_hook_active.is_none() { + return Err(invalid_data("stop hook has no stop_hook_active flag")); + } + } else if stop_hook_active.is_some() { + return Err(invalid_data("unexpected stop_hook_active field")); + } + Ok(()) +} + +fn resolve_nearest_codex_ancestor(codex_home: &Path) -> io::Result { + let processes = process::get_process_info(); + let mut current = std::process::id(); + let mut visited = std::collections::BTreeSet::new(); + for _ in 0..MAX_ANCESTORS { + if !visited.insert(current) { + break; + } + let parent = processes + .get(¤t) + .map(|info| info.ppid) + .or_else(|| (current == std::process::id()).then(current_parent_pid)) + .filter(|pid| *pid > 1) + .ok_or_else(|| invalid_data("cannot resolve Codex hook ancestry"))?; + current = parent; + let before = process::get_process_incarnation(current) + .ok_or_else(|| invalid_data("cannot identify hook ancestor incarnation"))?; + let executable = process::get_process_executable(current) + .ok_or_else(|| invalid_data("cannot identify hook ancestor executable"))?; + let argv = process::get_process_argv(current) + .ok_or_else(|| invalid_data("cannot identify hook ancestor arguments"))?; + let after = process::get_process_incarnation(current) + .ok_or_else(|| invalid_data("hook ancestor disappeared during inspection"))?; + if before != after { + return Err(invalid_data("hook ancestor incarnation changed")); + } + if !is_native_codex_executable(&executable) { + continue; + } + if excluded_codex_host(&executable, &argv) { + return Err(invalid_data("hook belongs to an excluded Codex host")); + } + if let Some(root) = process::read_process_env_var(current, "CODEX_HOME") { + let root = PathBuf::from(root); + if !root.is_absolute() + || fs::canonicalize(root).ok().as_deref() + != fs::canonicalize(codex_home).ok().as_deref() + { + return Err(invalid_data("hook process uses another CODEX_HOME")); + } + } + let started_at_ms = process::get_process_started_at_ms(current) + .ok_or_else(|| invalid_data("cannot identify hook ancestor start time"))?; + return Ok(HookProcessIdentity { + pid: current, + started_at_ms, + incarnation: before, + shared_host: false, + launch_config_ambiguous: launch_configuration_ambiguous(&argv), + }); + } + Err(invalid_data("no eligible native Codex ancestor")) +} + +#[cfg(unix)] +fn current_parent_pid() -> u32 { + // SAFETY: getppid has no preconditions or side effects. + u32::try_from(unsafe { libc::getppid() }).unwrap_or(0) +} + +#[cfg(not(unix))] +fn current_parent_pid() -> u32 { + 0 +} + +fn is_native_codex_executable(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + let name = name.to_ascii_lowercase(); + (name == "codex" || name == "codex.exe" || name.starts_with("codex-")) + && name != "codex-app-server" + && name != "codex-app-server.exe" + && name != "codex-code-mode-host" + && name != "codex-code-mode-host.exe" +} + +fn excluded_codex_host(executable: &Path, argv: &[OsString]) -> bool { + let name = executable + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if name.contains("app-server") || name.contains("code-mode-host") { + return true; + } + argv.iter().skip(1).any(|argument| { + matches!( + argument.to_str(), + Some( + "app-server" + | "daemon" + | "mcp-server" + | "remote-control" + | "exec-server" + | "codex-code-mode-host" + ) + ) + }) +} + +fn launch_configuration_ambiguous(argv: &[OsString]) -> bool { + let mut iter = argv.iter().skip(1).peekable(); + while let Some(argument) = iter.next() { + let Some(argument) = argument.to_str() else { + return true; + }; + if argument == "--dangerously-bypass-hook-trust" + || argument == "--profile" + || argument.starts_with("--profile=") + || argument == "-p" + || (argument.starts_with("-p") && argument.len() > 2) + { + return true; + } + + if matches!(argument, "--enable" | "--disable") { + let Some(feature) = iter.next().and_then(|value| value.to_str()) else { + return true; + }; + if feature_override_affects_hooks(feature) { + return true; + } + continue; + } + if let Some(feature) = argument + .strip_prefix("--enable=") + .or_else(|| argument.strip_prefix("--disable=")) + { + if feature_override_affects_hooks(feature) { + return true; + } + continue; + } + + if matches!(argument, "-c" | "--config") { + let Some(value) = iter.next().and_then(|value| value.to_str()) else { + return true; + }; + if config_override_affects_hooks(value) { + return true; + } + continue; + } + if let Some(value) = argument.strip_prefix("--config=") { + if config_override_affects_hooks(value) { + return true; + } + continue; + } + if let Some(value) = argument + .strip_prefix("-c=") + .or_else(|| argument.strip_prefix("-c")) + { + if config_override_affects_hooks(value) { + return true; + } + } + } + false +} + +fn feature_override_affects_hooks(value: &str) -> bool { + value + .split(',') + .map(str::trim) + .any(|feature| matches!(feature, "hooks" | "plugins" | "codex_hooks")) +} + +fn config_override_affects_hooks(value: &str) -> bool { + let key = value.split_once('=').map_or(value, |(key, _)| key).trim(); + matches!( + key, + "hooks" + | "plugins" + | "features.hooks" + | "features.plugins" + | "features.codex_hooks" + | "debug.config_lockfile" + | "requirements" + ) || key.starts_with("hooks.") + || key.starts_with("plugins.") + || key.starts_with("debug.config_lockfile.") + || key.starts_with("requirements.") +} + +fn sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut value = String::with_capacity(71); + value.push_str("sha256:"); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(value, "{byte:02x}"); + } + value +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::io::{Cursor, Repeat, Take}; + use std::rc::Rc; + + #[cfg(unix)] + #[test] + fn input_larger_than_a_pipe_is_drained_before_slow_preflight() { + use std::io::Write; + use std::os::unix::net::UnixStream; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + use std::thread; + use std::time::Duration; + + let (reader, mut writer) = UnixStream::pair().expect("create hook input socket"); + let writer_finished = Arc::new(AtomicBool::new(false)); + let writer_finished_in_thread = Arc::clone(&writer_finished); + let body = "x".repeat(3 * 1024 * 1024); + let input = format!( + "{{\"prompt\":\"{body}\",\"session_id\":\"session-a\",\"cwd\":\"/tmp/project\",\"hook_event_name\":\"UserPromptSubmit\",\"turn_id\":\"turn-a\"}}" + ); + assert!(input.len() < MAX_HOOK_STREAM_BYTES); + let writer_thread = thread::spawn(move || { + writer + .write_all(input.as_bytes()) + .expect("write complete oversized-pipe hook input"); + writer_finished_in_thread.store(true, Ordering::Release); + writer + .shutdown(std::net::Shutdown::Write) + .expect("close hook input writer"); + }); + + let (parsed, preflight_value) = parse_before_preflight(reader, || { + // A preflight-first implementation reaches this closure while the + // producer is still blocked on the full socket buffer. + if !writer_finished.load(Ordering::Acquire) { + return Err(invalid_data("preflight ran before stdin was drained")); + } + thread::sleep(Duration::from_millis(25)); + Ok(42) + }) + .expect("drain hook input before expensive preflight"); + writer_thread.join().expect("hook input writer completes"); + + assert_eq!(preflight_value, 42); + assert_eq!(parsed.session_id.as_deref(), Some("session-a")); + assert_eq!(parsed.event.as_deref(), Some("UserPromptSubmit")); + } + + #[test] + fn parser_discards_every_sensitive_field() { + let input = br#"{ + "session_id":"session-a", + "cwd":"/tmp/project", + "hook_event_name":"PreToolUse", + "turn_id":"turn-a", + "tool_use_id":"call-a", + "tool_name":"Bash", + "prompt":"ABTOP_SECRET_PROMPT", + "tool_input":{"command":"ABTOP_SECRET_COMMAND"}, + "tool_response":{"output":"ABTOP_SECRET_OUTPUT"}, + "transcript_path":"/tmp/ABTOP_SECRET_TRANSCRIPT", + "last_assistant_message":"ABTOP_SECRET_MESSAGE" + }"#; + let parsed = parse_and_drain_hook_input(input.as_slice()).expect("parse hook"); + let debug = format!( + "{} {:?} {:?}", + parsed.session_id.unwrap(), + parsed.turn_id, + parsed.tool_use_id + ); + assert!(!debug.contains("ABTOP_SECRET")); + } + + #[test] + fn large_sensitive_value_is_streamed_and_not_retained() { + const LARGE_IGNORED_BYTES: u64 = 1024 * 1024; + let prefix = Cursor::new(br#"{"prompt":"ABTOP_SECRET_BEGIN_"#.as_slice()); + let body: Take = io::repeat(b'x').take(LARGE_IGNORED_BYTES); + let suffix = Cursor::new( + br#"_ABTOP_SECRET_END","session_id":"session-a","cwd":"/tmp/project","hook_event_name":"UserPromptSubmit","turn_id":"turn-a"}"#.as_slice(), + ); + let consumed = Rc::new(Cell::new(0)); + let reader = CountingReader::new(prefix.chain(body).chain(suffix), consumed.clone()); + + let parsed = parse_and_drain_hook_input(reader).expect("stream large ignored value"); + let retained_bytes = parsed.session_id.as_deref().map_or(0, str::len) + + parsed.cwd.as_deref().map_or(0, str::len) + + parsed.event.as_deref().map_or(0, str::len) + + parsed.turn_id.as_deref().map_or(0, str::len) + + parsed.tool_name.as_deref().map_or(0, str::len) + + parsed.tool_use_id.as_deref().map_or(0, str::len) + + parsed.agent_id.as_deref().map_or(0, str::len) + + parsed.source.as_deref().map_or(0, str::len); + assert!(retained_bytes < 128); + assert!(consumed.get() > LARGE_IGNORED_BYTES as usize); + assert_eq!(parsed.session_id.as_deref(), Some("session-a")); + assert_eq!(parsed.event.as_deref(), Some("UserPromptSubmit")); + + let temp = tempfile::tempdir().expect("temporary plugin root"); + // macOS exposes `/var` through a symlink. Production passes the + // canonical plugin-data path, and the secure store correctly rejects + // every symlink component, so make the fixture mirror production. + let temp_root = fs::canonicalize(temp.path()).expect("canonical temporary root"); + let plugin_data = temp_root.join("plugins/data").join("abtop-abtop-local"); + fs::create_dir_all(&plugin_data).expect("create plugin data"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&plugin_data, fs::Permissions::from_mode(0o700)) + .expect("private plugin data"); + } + let ingress = HookStateStore::prepare(&plugin_data).expect("prepare hook state"); + let guard = ingress.begin_ingest(10).expect("create ingest marker"); + let marker_id = guard.marker_id().expect("marker ID").to_owned(); + let event = parsed + .into_event(process_identity(), identity(), 10, marker_id) + .expect("reduce allowlisted lifecycle fields"); + ingress + .bind(identity()) + .expect("bind hook state") + .fold(event) + .expect("persist content-free state"); + guard.succeed().expect("commit ingest marker"); + assert_tree_excludes(&plugin_data, "ABTOP_SECRET"); + } + + #[test] + fn malformed_input_is_drained_before_it_fails_closed() { + let input = br#"{"session_id":false THIS_IS_MALFORMED_AND_MUST_BE_DRAINED"#; + let mut reader = Cursor::new(input.as_slice()); + assert!(parse_and_drain_hook_input(&mut reader).is_err()); + assert_eq!(reader.position(), input.len() as u64); + } + + #[test] + fn oversized_stream_fails_closed_without_an_aggregate_buffer() { + let prefix = Cursor::new(br#"{"ignored":""#.as_slice()); + let body = io::repeat(b'x').take(MAX_HOOK_STREAM_BYTES as u64 + 1); + let consumed = Rc::new(Cell::new(0)); + let reader = CountingReader::new(prefix.chain(body), consumed.clone()); + + let error = parse_and_drain_hook_input(reader).expect_err("oversized stream"); + assert!(error.to_string().contains("streaming bound")); + assert_eq!(consumed.get(), MAX_HOOK_STREAM_BYTES + 1); + } + + #[test] + fn allowlisted_scalars_are_bounded_and_input_is_still_drained() { + let oversized_id = "x".repeat(MAX_LIFECYCLE_ID_BYTES + 1); + let input = format!( + "{{\"session_id\":\"{oversized_id}\",\"ignored\":\"ABTOP_SECRET_AFTER_ERROR\"}}" + ); + let mut reader = Cursor::new(input.as_bytes()); + + assert!(parse_and_drain_hook_input(&mut reader).is_err()); + assert_eq!(reader.position(), input.len() as u64); + } + + #[test] + fn excessive_root_fields_fail_closed_and_are_drained() { + let mut input = String::from("{"); + for index in 0..=MAX_ROOT_FIELDS { + if index != 0 { + input.push(','); + } + use std::fmt::Write as _; + write!(input, "\"ignored_{index}\":null").expect("build hook object"); + } + input.push_str(",\"tail\":\"ABTOP_SECRET_AFTER_FIELD_LIMIT\"}"); + let mut reader = Cursor::new(input.as_bytes()); + + assert!(parse_and_drain_hook_input(&mut reader).is_err()); + assert_eq!(reader.position(), input.len() as u64); + } + + #[test] + fn duplicate_and_trailing_objects_fail_closed() { + assert!( + parse_and_drain_hook_input(br#"{"session_id":"a","session_id":"b"}"#.as_slice()) + .is_err() + ); + assert!(parse_and_drain_hook_input(br#"{} {}"#.as_slice()).is_err()); + } + + #[test] + fn all_eleven_event_names_are_exact() { + for name in plugin::HOOK_EVENTS { + assert!(parse_event_kind(name).is_ok(), "{name}"); + } + assert!(parse_event_kind("PostToolUseFailure").is_err()); + assert!(parse_event_kind("pre_tool_use").is_err()); + } + + #[test] + fn session_start_source_and_tool_shape_are_exact() { + let start = parse_and_drain_hook_input( + br#"{"session_id":"session-a","cwd":"/tmp/project","hook_event_name":"SessionStart","source":"compact"}"#.as_slice(), + ) + .unwrap() + .into_event(process_identity(), identity(), 10, test_marker_id()) + .unwrap(); + assert_eq!( + start.session_start_source, + Some(SessionStartSource::Compact) + ); + + let bad_source = parse_and_drain_hook_input( + br#"{"session_id":"session-a","cwd":"/tmp/project","hook_event_name":"SessionEnd","source":"startup"}"#.as_slice(), + ) + .unwrap(); + assert!(bad_source + .into_event(process_identity(), identity(), 10, test_marker_id()) + .is_err()); + + let missing_tool_name = parse_and_drain_hook_input( + br#"{"session_id":"session-a","cwd":"/tmp/project","hook_event_name":"PreToolUse","turn_id":"turn-a","tool_use_id":"call-a"}"#.as_slice(), + ) + .unwrap(); + assert!(missing_tool_name + .into_event(process_identity(), identity(), 10, test_marker_id()) + .is_err()); + } + + #[test] + fn stop_hook_active_is_exact_and_limited_to_stop_events() { + let stop = parse_and_drain_hook_input( + br#"{"session_id":"session-a","cwd":"/tmp/project","hook_event_name":"Stop","turn_id":"turn-a","stop_hook_active":true}"#.as_slice(), + ) + .unwrap() + .into_event(process_identity(), identity(), 10, test_marker_id()) + .unwrap(); + assert_eq!(stop.stop_hook_active, Some(true)); + + let subagent_stop = parse_and_drain_hook_input( + br#"{"session_id":"session-a","cwd":"/tmp/project","hook_event_name":"SubagentStop","turn_id":"turn-a","agent_id":"agent-a","stop_hook_active":false}"#.as_slice(), + ) + .unwrap() + .into_event(process_identity(), identity(), 10, test_marker_id()) + .unwrap(); + assert_eq!(subagent_stop.stop_hook_active, Some(false)); + + for invalid in [ + br#"{"session_id":"session-a","cwd":"/tmp/project","hook_event_name":"Stop","turn_id":"turn-a"}"#.as_slice(), + br#"{"session_id":"session-a","cwd":"/tmp/project","hook_event_name":"UserPromptSubmit","turn_id":"turn-a","stop_hook_active":false}"#.as_slice(), + br#"{"session_id":"session-a","cwd":"/tmp/project","hook_event_name":"Stop","turn_id":"turn-a","stop_hook_active":"true"}"#.as_slice(), + ] { + let result = parse_and_drain_hook_input(invalid).and_then(|parsed| { + parsed.into_event(process_identity(), identity(), 10, test_marker_id()) + }); + assert!(result.is_err()); + } + } + + #[test] + fn child_agent_identity_must_not_alias_the_shared_session() { + let parsed = parse_and_drain_hook_input( + br#"{"session_id":"root-a","cwd":"/tmp/project","hook_event_name":"SubagentStart","turn_id":"turn-a","agent_id":"root-a"}"#.as_slice(), + ) + .unwrap(); + assert!(parsed + .into_event(process_identity(), identity(), 10, test_marker_id()) + .is_err()); + } + + #[test] + fn native_binary_and_host_filters_are_conservative() { + assert!(is_native_codex_executable(Path::new("/opt/codex"))); + assert!(is_native_codex_executable(Path::new( + "/opt/codex-aarch64-apple-darwin" + ))); + assert!(!is_native_codex_executable(Path::new( + "/opt/codex-code-mode-host" + ))); + assert!(excluded_codex_host( + Path::new("/opt/codex"), + &[OsString::from("codex"), OsString::from("app-server")] + )); + } + + #[test] + fn trust_bypass_and_hook_overrides_are_ambiguous() { + assert!(launch_configuration_ambiguous(&[ + OsString::from("codex"), + OsString::from("--dangerously-bypass-hook-trust") + ])); + assert!(launch_configuration_ambiguous(&[ + OsString::from("codex"), + OsString::from("-c"), + OsString::from("hooks.state={}") + ])); + for arguments in [ + vec!["codex", "-p", "profile-a"], + vec!["codex", "-pprofile-a"], + vec!["codex", "-p=profile-a"], + vec!["codex", "--enable", "hooks"], + vec!["codex", "--disable=plugins"], + vec!["codex", "--enable=codex_hooks"], + vec!["codex", "-cfeatures.hooks=false"], + vec!["codex", "-c=features.plugins=true"], + vec![ + "codex", + "--config=debug.config_lockfile.load_path=/tmp/lock.toml", + ], + vec!["codex", "-c", "requirements.allowed_hooks=[]"], + ] { + let arguments = arguments + .into_iter() + .map(OsString::from) + .collect::>(); + assert!(launch_configuration_ambiguous(&arguments), "{arguments:?}"); + } + assert!(!launch_configuration_ambiguous(&[ + OsString::from("codex"), + OsString::from("-c"), + OsString::from("model=plugins-v2") + ])); + assert!(!launch_configuration_ambiguous(&[ + OsString::from("codex"), + OsString::from("--yolo") + ])); + } + + fn identity() -> IntegrationIdentity { + IntegrationIdentity { + hook_schema_revision: "1".into(), + helper_digest: format!("sha256:{}", "1".repeat(64)), + installation_id: "a".repeat(32), + config_digest: format!("sha256:{}", "2".repeat(64)), + complete_hook_set: true, + } + } + + fn process_identity() -> HookProcessIdentity { + HookProcessIdentity { + pid: std::process::id(), + started_at_ms: 1, + incarnation: "test-incarnation".into(), + shared_host: false, + launch_config_ambiguous: false, + } + } + + fn test_marker_id() -> String { + format!("hook-{}.json", "a".repeat(32)) + } + + struct CountingReader { + inner: R, + consumed: Rc>, + } + + impl CountingReader { + fn new(inner: R, consumed: Rc>) -> Self { + Self { inner, consumed } + } + } + + impl Read for CountingReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let read = self.inner.read(buffer)?; + self.consumed.set(self.consumed.get().saturating_add(read)); + Ok(read) + } + } + + fn assert_tree_excludes(path: &Path, needle: &str) { + for entry in fs::read_dir(path).expect("read persisted state tree") { + let entry = entry.expect("read state entry"); + let file_type = entry.file_type().expect("state entry type"); + if file_type.is_dir() { + assert_tree_excludes(&entry.path(), needle); + } else if file_type.is_file() { + let bytes = fs::read(entry.path()).expect("read persisted state file"); + assert!(!String::from_utf8_lossy(&bytes).contains(needle)); + } + } + } +} diff --git a/src/codex_hooks/migration.rs b/src/codex_hooks/migration.rs new file mode 100644 index 0000000..7473847 --- /dev/null +++ b/src/codex_hooks/migration.rs @@ -0,0 +1,2006 @@ +//! Removal of the retired shell-function based Codex integration. +//! +//! This module recognizes only abtop's exact legacy marker lines. It never +//! searches for, edits, or removes an unrelated `codex` alias or function. + +use std::collections::BTreeMap; +use std::fs; +use std::fs::File; +#[cfg(unix)] +use std::fs::OpenOptions; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +#[cfg(unix)] +use std::process::{Command, Stdio}; +#[cfg(unix)] +use std::thread; +#[cfg(not(unix))] +use std::time::SystemTime; +#[cfg(unix)] +use std::time::{Duration, Instant}; + +pub const LEGACY_START_MARKER: &str = "# >>> abtop managed codex >>>"; +pub const LEGACY_END_MARKER: &str = "# <<< abtop managed codex <<<"; +const LEGACY_MARKER_NAMESPACE: &str = "abtop managed codex"; +const MAX_PROFILE_BYTES: u64 = 4 * 1024 * 1024; +#[cfg(unix)] +const LEGACY_HOME_LOCK_FILE: &str = ".abtop-codex-migration.lock"; +#[cfg(unix)] +const ZDOTDIR_PROBE_TIMEOUT: Duration = Duration::from_secs(3); +#[cfg(unix)] +const ZDOTDIR_PROBE_OUTPUT_LIMIT: usize = 4096; +#[cfg(unix)] +const ZDOTDIR_PROBE_BEGIN: &[u8] = b"\x1eABTOP_ZDOTDIR_V1_BEGIN\x1f"; +#[cfg(unix)] +const ZDOTDIR_PROBE_END: &[u8] = b"\x1eABTOP_ZDOTDIR_V1_END\x1f"; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct MigrationReport { + pub scanned_files: Vec, + pub changed_files: Vec, + pub powershell_guidance: Option, +} + +#[derive(Debug)] +struct PreparedEdit { + requested_path: PathBuf, + path: PathBuf, + original: Vec, + updated: Vec, + permissions: fs::Permissions, + original_identity: FileIdentity, + installed_identity: Option, + committed: bool, +} + +/// A multi-file legacy cleanup transaction. +/// +/// Call [`commit`](Self::commit) only after the replacement Codex integration +/// has installed successfully. Calling [`rollback`](Self::rollback), or +/// dropping an unfinished transaction, restores every profile that this +/// transaction changed, provided no concurrent editor changed it afterwards. +#[derive(Debug)] +pub struct LegacyCleanupTransaction { + home: PathBuf, + lock: LegacyHomeLock, + report: MigrationReport, + edits: Vec, + finished: bool, +} + +impl LegacyCleanupTransaction { + pub fn begin() -> io::Result { + let home = dirs::home_dir().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "cannot determine the current user's home directory", + ) + })?; + Self::begin_at(&home) + } + + pub fn begin_at(home: &Path) -> io::Result { + let home = canonical_home(home)?; + // One stable lock serializes the complete legacy migration, including + // no-op scans. Keeping the inode on disk prevents an unlink/recreate + // window in which two abtop processes could lock different files. + let lock = LegacyHomeLock::acquire(&home, LockMode::Exclusive)?; + let candidates = legacy_profile_candidates(&home)?; + let mut edits = Vec::new(); + let mut report = MigrationReport { + powershell_guidance: powershell_guidance(), + ..MigrationReport::default() + }; + + // Resolve, validate, and prepare every candidate before changing any + // file. Canonical targets deduplicate profile aliases and symlinks. + let mut targets = BTreeMap::::new(); + for requested in candidates { + let Some(target) = validated_existing_profile(&requested, &home)? else { + continue; + }; + targets.entry(target).or_insert(requested); + } + + for (target, requested) in targets { + let metadata = fs::metadata(&target)?; + let original = read_profile_bounded(&target, &metadata)?; + let text = std::str::from_utf8(&original).map_err(|_| { + invalid_data(format!( + "legacy shell file {} is not valid UTF-8", + requested.display() + )) + })?; + let Some(updated) = remove_marked_block(text)? else { + report.scanned_files.push(requested); + continue; + }; + report.scanned_files.push(requested.clone()); + revalidate_requested_target(&requested, &target, &home)?; + let current = fs::symlink_metadata(&target)?; + if !same_file_identity(&metadata, ¤t) { + return Err(invalid_data(format!( + "legacy shell file {} changed while cleanup was prepared", + requested.display() + ))); + } + edits.push(PreparedEdit { + requested_path: requested, + path: target, + original, + updated: updated.into_bytes(), + permissions: metadata.permissions(), + original_identity: FileIdentity::from_metadata(&metadata), + installed_identity: None, + committed: false, + }); + } + lock.revalidate()?; + + let mut transaction = Self { + home, + lock, + report, + edits, + finished: false, + }; + if let Err(error) = transaction.apply() { + let rollback = transaction.rollback_internal(); + transaction.finished = true; + return match rollback { + Ok(()) => Err(error), + Err(rollback_error) => Err(io::Error::new( + error.kind(), + format!( + "{error}; additionally failed to roll back legacy shell cleanup: {rollback_error}" + ), + )), + }; + } + Ok(transaction) + } + + #[allow(dead_code)] + pub fn report(&self) -> &MigrationReport { + &self.report + } + + pub fn commit(mut self) -> MigrationReport { + self.finished = true; + self.report.clone() + } + + pub fn rollback(&mut self) -> io::Result<()> { + let result = self.rollback_internal(); + if result.is_ok() { + self.finished = true; + } + result + } + + fn apply(&mut self) -> io::Result<()> { + for edit in &mut self.edits { + self.lock.revalidate()?; + revalidate_requested_target(&edit.requested_path, &edit.path, &self.home)?; + let metadata = fs::symlink_metadata(&edit.path)?; + if !edit.original_identity.matches(&metadata) { + return Err(invalid_data(format!( + "legacy shell file {} was replaced during cleanup", + edit.path.display() + ))); + } + let current = read_profile_bounded(&edit.path, &metadata)?; + if current != edit.original { + return Err(invalid_data(format!( + "legacy shell file {} changed during cleanup; retry", + edit.path.display() + ))); + } + revalidate_requested_target(&edit.requested_path, &edit.path, &self.home)?; + if !edit + .original_identity + .matches(&fs::symlink_metadata(&edit.path)?) + { + return Err(invalid_data(format!( + "legacy shell file {} was replaced immediately before cleanup", + edit.path.display() + ))); + } + let PreparedEdit { + requested_path, + path, + original, + updated, + permissions, + original_identity, + installed_identity, + committed, + } = edit; + atomic_replace( + AtomicReplacement { + requested_path, + path, + home: &self.home, + bytes: updated, + permissions, + expected_identity: original_identity, + expected_bytes: original, + lock: &self.lock, + }, + |identity| { + // There must be no fallible operation between the rename + // and recording that rollback now owns the replacement. + *installed_identity = identity; + *committed = installed_identity.is_some(); + }, + )?; + self.report.changed_files.push(edit.requested_path.clone()); + } + Ok(()) + } + + fn rollback_internal(&mut self) -> io::Result<()> { + let mut errors = Vec::new(); + for edit in self.edits.iter_mut().rev() { + if !edit.committed { + continue; + } + if let Err(error) = self.lock.revalidate() { + errors.push(format!( + "legacy cleanup lock changed before rollback: {error}" + )); + break; + } + let installed_metadata = match fs::symlink_metadata(&edit.path) { + Ok(metadata) + if edit + .installed_identity + .as_ref() + .is_some_and(|identity| identity.matches(&metadata)) => + { + metadata + } + _ => { + errors.push(format!( + "{} was replaced after cleanup; refusing to overwrite it", + edit.path.display() + )); + continue; + } + }; + let current = match read_profile_bounded(&edit.path, &installed_metadata) { + Ok(current) => current, + Err(error) => { + errors.push(format!("{}: {error}", edit.path.display())); + continue; + } + }; + if current != edit.updated { + errors.push(format!( + "{} changed after cleanup; refusing to overwrite it", + edit.path.display() + )); + continue; + } + if !edit.installed_identity.as_ref().is_some_and(|identity| { + fs::symlink_metadata(&edit.path) + .map(|metadata| identity.matches(&metadata)) + .unwrap_or(false) + }) { + errors.push(format!( + "{} was replaced during rollback; refusing to overwrite it", + edit.path.display() + )); + continue; + } + let expected_identity = edit + .installed_identity + .clone() + .expect("a committed edit has an installed identity"); + let PreparedEdit { + requested_path, + path, + original, + updated, + permissions, + installed_identity, + committed, + .. + } = edit; + if let Err(error) = atomic_replace( + AtomicReplacement { + requested_path, + path, + home: &self.home, + bytes: original, + permissions, + expected_identity: &expected_identity, + expected_bytes: updated, + lock: &self.lock, + }, + |_| { + // As soon as the restoring rename succeeds, this edit is + // no longer eligible for another rollback. + *committed = false; + *installed_identity = None; + }, + ) { + errors.push(format!("{}: {error}", edit.path.display())); + } + } + if errors.is_empty() { + self.report.changed_files.clear(); + Ok(()) + } else { + Err(invalid_data(errors.join("; "))) + } + } +} + +impl Drop for LegacyCleanupTransaction { + fn drop(&mut self) { + if !self.finished { + let _ = self.rollback_internal(); + } + } +} + +/// Remove one exact legacy block from `content`. +/// +/// `Ok(None)` means no legacy marker is present. Duplicate, unmatched, +/// reordered, or look-alike marker lines fail closed. +pub fn remove_marked_block(content: &str) -> io::Result> { + let locations = marker_locations(content)?; + match (locations.starts.as_slice(), locations.ends.as_slice()) { + ([], []) => Ok(None), + ([start], [end]) if start.body_start < end.body_start => { + let mut output = String::with_capacity( + content + .len() + .saturating_sub(end.full_end.saturating_sub(start.body_start)), + ); + output.push_str(&content[..start.body_start]); + output.push_str(&content[end.full_end..]); + Ok(Some(output)) + } + ([_], [_]) => Err(invalid_data( + "legacy managed Codex profile markers are out of order", + )), + _ => Err(invalid_data( + "legacy shell file contains duplicate or unmatched managed Codex markers", + )), + } +} + +/// Inspect known legacy shell files without modifying them. +pub fn inspect_legacy_shell_integration() -> io::Result> { + let home = dirs::home_dir().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "cannot determine the current user's home directory", + ) + })?; + inspect_legacy_shell_integration_at(&home) +} + +pub fn inspect_legacy_shell_integration_at(home: &Path) -> io::Result> { + let home = canonical_home(home)?; + let lock = LegacyHomeLock::acquire(&home, LockMode::Shared)?; + let mut found = Vec::new(); + let mut seen = BTreeMap::::new(); + for candidate in legacy_profile_candidates(&home)? { + let Some(target) = validated_existing_profile(&candidate, &home)? else { + continue; + }; + if seen.insert(target.clone(), ()).is_some() { + continue; + } + let metadata = fs::metadata(&target)?; + let bytes = read_profile_bounded(&target, &metadata)?; + let text = std::str::from_utf8(&bytes).map_err(|_| { + invalid_data(format!( + "legacy shell file {} is not valid UTF-8", + candidate.display() + )) + })?; + if remove_marked_block(text)?.is_some() { + found.push(candidate); + } + } + lock.revalidate()?; + Ok(found) +} + +fn legacy_profile_candidates(home: &Path) -> io::Result> { + let mut paths = vec![ + home.join(".zshrc"), + home.join(".config/zsh/.zshrc"), + home.join(".bashrc"), + home.join(".bash_profile"), + home.join(".bash_login"), + home.join(".profile"), + home.join(".config/abtop/codex-shell.bash"), + home.join(".config/fish/config.fish"), + ]; + + let is_current_home = dirs::home_dir() + .and_then(|current| fs::canonicalize(current).ok()) + .as_deref() + == Some(home); + if is_current_home { + if let Some(zdotdir) = std::env::var_os("ZDOTDIR") { + paths.push(checked_root(&PathBuf::from(zdotdir), home, "ZDOTDIR")?.join(".zshrc")); + } + #[cfg(unix)] + if let Some(zdotdir) = probe_current_zdotdir(home)? { + paths.push(zdotdir.join(".zshrc")); + } + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + paths.push( + checked_root(&PathBuf::from(xdg), home, "XDG_CONFIG_HOME")? + .join("fish/config.fish"), + ); + } + } + + paths.sort(); + paths.dedup(); + Ok(paths) +} + +#[cfg(unix)] +fn probe_current_zdotdir(home: &Path) -> io::Result> { + let Some(shell) = std::env::var_os("SHELL").map(PathBuf::from) else { + return Ok(None); + }; + probe_zdotdir_with_shell(&shell, home) +} + +#[cfg(unix)] +fn probe_zdotdir_with_shell(shell: &Path, home: &Path) -> io::Result> { + probe_zdotdir_with_shell_timeout(shell, home, ZDOTDIR_PROBE_TIMEOUT) +} + +#[cfg(unix)] +fn probe_zdotdir_with_shell_timeout( + shell: &Path, + home: &Path, + timeout: Duration, +) -> io::Result> { + if !shell.is_absolute() + || !shell + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "zsh" || name.starts_with("zsh-")) + { + return Ok(None); + } + let shell = normalize_absolute(shell)?; + let metadata = fs::metadata(&shell)?; + if !metadata.is_file() { + return Err(invalid_data("SHELL does not name a regular zsh executable")); + } + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o111 == 0 { + return Err(invalid_data("SHELL does not name an executable zsh")); + } + + let login = run_zdotdir_probe_with_timeout(&shell, true, timeout)?; + let non_login = run_zdotdir_probe_with_timeout(&shell, false, timeout)?; + if login != non_login { + return Err(invalid_data( + "login and non-login zsh resolved different ZDOTDIR values; refusing incomplete legacy cleanup", + )); + } + let Some(root) = login else { + return Ok(None); + }; + checked_root(&root, home, "probed ZDOTDIR").map(Some) +} + +#[cfg(unix)] +fn run_zdotdir_probe_with_timeout( + shell: &Path, + login: bool, + timeout: Duration, +) -> io::Result> { + use std::os::unix::process::CommandExt; + + let command = + "printf '\\036ABTOP_ZDOTDIR_V1_BEGIN\\037%s\\036ABTOP_ZDOTDIR_V1_END\\037' \"${ZDOTDIR-}\""; + let mut process = Command::new(shell); + if login { + process.arg("-l"); + } + let mut child = process + .arg("-c") + .arg(command) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .process_group(0) + .spawn()?; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("missing zsh probe stdout"))?; + let mut stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("missing zsh probe stderr"))?; + if let Err(error) = + set_probe_pipe_nonblocking(&stdout).and_then(|()| set_probe_pipe_nonblocking(&stderr)) + { + terminate_probe_group(&mut child); + return Err(error); + } + let mut stdout_bytes = Vec::new(); + let mut stderr_bytes = Vec::new(); + let mut stdout_overflow = false; + let mut stderr_overflow = false; + let deadline = Instant::now() + timeout; + let status = loop { + let poll = drain_probe_pipe(&mut stdout, &mut stdout_bytes, &mut stdout_overflow) + .and_then(|()| drain_probe_pipe(&mut stderr, &mut stderr_bytes, &mut stderr_overflow)) + .and_then(|()| child.try_wait()); + let status = match poll { + Ok(status) => status, + Err(error) => { + terminate_probe_group(&mut child); + return Err(error); + } + }; + if let Some(status) = status { + // Drain bytes already present in the kernel pipes, then close our + // descriptors. A background process inherited from shell startup + // must not keep this bounded probe waiting after zsh has exited. + drain_probe_pipe(&mut stdout, &mut stdout_bytes, &mut stdout_overflow)?; + drain_probe_pipe(&mut stderr, &mut stderr_bytes, &mut stderr_overflow)?; + break status; + } + if Instant::now() >= deadline { + terminate_probe_group(&mut child); + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "zsh ZDOTDIR probe timed out", + )); + } + thread::sleep(Duration::from_millis(10)); + }; + if !status.success() || stdout_overflow || stderr_overflow || !stderr_bytes.is_empty() { + return Err(invalid_data( + "zsh ZDOTDIR probe did not produce one bounded silent result", + )); + } + parse_zdotdir_probe(&stdout_bytes) +} + +#[cfg(unix)] +fn set_probe_pipe_nonblocking(pipe: &impl std::os::fd::AsRawFd) -> io::Result<()> { + let flags = unsafe { libc::fcntl(pipe.as_raw_fd(), libc::F_GETFL) }; + if flags < 0 { + return Err(io::Error::last_os_error()); + } + if unsafe { libc::fcntl(pipe.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(unix)] +fn drain_probe_pipe( + pipe: &mut impl Read, + output: &mut Vec, + overflow: &mut bool, +) -> io::Result<()> { + let mut buffer = [0_u8; 1024]; + let mut drained = 0_usize; + while drained < 64 * 1024 { + let read = match pipe.read(&mut buffer) { + Ok(read) => read, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(()), + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + }; + if read == 0 { + return Ok(()); + } + drained += read; + let remaining = ZDOTDIR_PROBE_OUTPUT_LIMIT.saturating_sub(output.len()); + let keep = remaining.min(read); + output.extend_from_slice(&buffer[..keep]); + *overflow |= keep != read; + } + Ok(()) +} + +#[cfg(unix)] +fn terminate_probe_group(child: &mut std::process::Child) { + if let Ok(pid) = i32::try_from(child.id()) { + let _ = unsafe { libc::kill(-pid, libc::SIGKILL) }; + } + let _ = child.wait(); +} + +#[cfg(unix)] +fn parse_zdotdir_probe(output: &[u8]) -> io::Result> { + use std::os::unix::ffi::OsStringExt; + + let Some(value) = output + .strip_prefix(ZDOTDIR_PROBE_BEGIN) + .and_then(|value| value.strip_suffix(ZDOTDIR_PROBE_END)) + else { + return Err(invalid_data( + "zsh ZDOTDIR probe output was not exactly framed", + )); + }; + if value.is_empty() { + return Ok(None); + } + if value.iter().any(|byte| byte.is_ascii_control()) { + return Err(invalid_data( + "zsh ZDOTDIR probe returned a path containing control bytes", + )); + } + Ok(Some(PathBuf::from(std::ffi::OsString::from_vec( + value.to_vec(), + )))) +} + +fn checked_root(root: &Path, home: &Path, label: &str) -> io::Result { + if !root.is_absolute() { + return Err(invalid_data(format!("{label} must be an absolute path"))); + } + let normalized = normalize_absolute(root)?; + if !normalized.starts_with(home) { + return Err(invalid_data(format!( + "{label} must remain inside the home directory" + ))); + } + Ok(normalized) +} + +fn canonical_home(home: &Path) -> io::Result { + let home = fs::canonicalize(home)?; + if !home.is_absolute() { + return Err(invalid_data("home directory is not absolute")); + } + Ok(home) +} + +fn validated_existing_profile(requested: &Path, home: &Path) -> io::Result> { + match fs::symlink_metadata(requested) { + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + } + let target = fs::canonicalize(requested)?; + if !target.starts_with(home) { + return Err(invalid_data(format!( + "legacy shell file {} resolves outside the home directory", + requested.display() + ))); + } + let metadata = fs::metadata(&target)?; + if !metadata.is_file() { + return Err(invalid_data(format!( + "legacy shell path {} is not a regular file", + requested.display() + ))); + } + if metadata.len() > MAX_PROFILE_BYTES { + return Err(invalid_data(format!( + "legacy shell file {} exceeds 4 MiB", + requested.display() + ))); + } + validate_same_owner(&metadata, requested)?; + validate_single_link(&metadata, requested)?; + let parent = target + .parent() + .ok_or_else(|| invalid_data("legacy shell file has no parent directory"))?; + let parent_metadata = fs::metadata(parent)?; + if !parent_metadata.is_dir() { + return Err(invalid_data("legacy shell parent is not a directory")); + } + validate_same_owner(&parent_metadata, parent)?; + validate_not_other_writable(&parent_metadata, parent)?; + Ok(Some(target)) +} + +fn revalidate_requested_target(requested: &Path, expected: &Path, home: &Path) -> io::Result<()> { + let current = fs::canonicalize(requested).map_err(|error| { + io::Error::new( + error.kind(), + format!( + "cannot revalidate legacy shell path {}: {error}", + requested.display() + ), + ) + })?; + if current != expected || !current.starts_with(home) { + return Err(invalid_data(format!( + "legacy shell path {} changed its target during cleanup", + requested.display() + ))); + } + Ok(()) +} + +fn read_profile_bounded(path: &Path, expected: &fs::Metadata) -> io::Result> { + if !expected.is_file() { + return Err(invalid_data(format!( + "legacy shell path {} is not a regular file", + path.display() + ))); + } + if expected.len() > MAX_PROFILE_BYTES { + return Err(invalid_data(format!( + "legacy shell file {} exceeds 4 MiB", + path.display() + ))); + } + validate_same_owner(expected, path)?; + validate_single_link(expected, path)?; + + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path)? + }; + #[cfg(not(unix))] + let mut file = File::open(path)?; + + let opened = file.metadata()?; + if !opened.is_file() || !same_file_snapshot(expected, &opened) { + return Err(invalid_data(format!( + "legacy shell file {} changed before it could be inspected", + path.display() + ))); + } + validate_same_owner(&opened, path)?; + validate_single_link(&opened, path)?; + + let mut bytes = Vec::with_capacity(opened.len().min(MAX_PROFILE_BYTES) as usize); + { + let mut limited = (&mut file).take(MAX_PROFILE_BYTES + 1); + limited.read_to_end(&mut bytes)?; + } + if bytes.len() as u64 > MAX_PROFILE_BYTES { + return Err(invalid_data(format!( + "legacy shell file {} exceeds 4 MiB", + path.display() + ))); + } + + let descriptor_after = file.metadata()?; + let path_after = fs::symlink_metadata(path)?; + if !same_file_snapshot(&opened, &descriptor_after) + || !same_file_snapshot(&descriptor_after, &path_after) + || descriptor_after.len() != bytes.len() as u64 + { + return Err(invalid_data(format!( + "legacy shell file {} changed while it was inspected", + path.display() + ))); + } + validate_same_owner(&descriptor_after, path)?; + validate_single_link(&descriptor_after, path)?; + Ok(bytes) +} + +struct AtomicReplacement<'a> { + requested_path: &'a Path, + path: &'a Path, + home: &'a Path, + bytes: &'a [u8], + permissions: &'a fs::Permissions, + expected_identity: &'a FileIdentity, + expected_bytes: &'a [u8], + lock: &'a LegacyHomeLock, +} + +fn atomic_replace(replacement: AtomicReplacement<'_>, on_state: F) -> io::Result<()> +where + F: FnMut(Option), +{ + atomic_replace_with_before_exchange(replacement, on_state, || {}) +} + +fn atomic_replace_with_before_exchange( + replacement: AtomicReplacement<'_>, + mut on_state: F, + before_exchange: B, +) -> io::Result<()> +where + F: FnMut(Option), + B: FnOnce(), +{ + let AtomicReplacement { + requested_path, + path, + home, + bytes, + permissions, + expected_identity, + expected_bytes, + lock, + } = replacement; + let parent = path + .parent() + .ok_or_else(|| invalid_data("legacy shell file has no parent directory"))?; + let canonical_parent = fs::canonicalize(parent)?; + if canonical_parent != parent || !canonical_parent.starts_with(home) { + return Err(invalid_data(format!( + "legacy shell parent {} changed during cleanup", + parent.display() + ))); + } + let parent_metadata = fs::symlink_metadata(parent)?; + if !parent_metadata.is_dir() { + return Err(invalid_data("legacy shell parent is not a directory")); + } + validate_same_owner(&parent_metadata, parent)?; + validate_not_other_writable(&parent_metadata, parent)?; + let parent_identity = FileIdentity::from_metadata(&parent_metadata); + #[cfg(unix)] + let parent_handle = { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(parent)? + }; + #[cfg(unix)] + if !parent_identity.matches(&parent_handle.metadata()?) { + return Err(invalid_data(format!( + "legacy shell parent {} changed while it was opened", + parent.display() + ))); + } + + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary + .as_file_mut() + .set_permissions(permissions.clone())?; + temporary.write_all(bytes)?; + temporary.as_file_mut().sync_all()?; + + // Temp-file preparation may take time. Revalidate the requested alias, + // canonical leaf, parent, identity, and bytes together immediately before + // the replacement. Any observed concurrent edit fails closed. + revalidate_requested_target(requested_path, path, home)?; + let current_parent = fs::symlink_metadata(parent)?; + if !parent_identity.matches(¤t_parent) || !current_parent.is_dir() { + return Err(invalid_data(format!( + "legacy shell parent {} was replaced during cleanup", + parent.display() + ))); + } + validate_same_owner(¤t_parent, parent)?; + validate_not_other_writable(¤t_parent, parent)?; + let current_metadata = fs::symlink_metadata(path)?; + if !current_metadata.is_file() || !expected_identity.matches(¤t_metadata) { + return Err(invalid_data(format!( + "legacy shell file {} was replaced immediately before cleanup", + path.display() + ))); + } + validate_same_owner(¤t_metadata, path)?; + validate_single_link(¤t_metadata, path)?; + let current_bytes = read_profile_bounded(path, ¤t_metadata)?; + if current_bytes != expected_bytes { + return Err(invalid_data(format!( + "legacy shell file {} changed immediately before cleanup", + path.display() + ))); + } + revalidate_requested_target(requested_path, path, home)?; + if !expected_identity.matches(&fs::symlink_metadata(path)?) + || !parent_identity.matches(&fs::symlink_metadata(parent)?) + { + return Err(invalid_data( + "legacy shell file or parent changed at the replacement boundary", + )); + } + lock.revalidate()?; + + let replacement_metadata = temporary.as_file().metadata()?; + if !replacement_metadata.is_file() + || replacement_metadata.len() != bytes.len() as u64 + || !same_file_snapshot(&replacement_metadata, &temporary.as_file().metadata()?) + { + return Err(invalid_data( + "legacy shell temporary replacement changed before installation", + )); + } + validate_same_owner(&replacement_metadata, temporary.path())?; + validate_single_link(&replacement_metadata, temporary.path())?; + let replacement_identity = FileIdentity::from_metadata(&replacement_metadata); + + #[cfg(unix)] + { + let temporary_name = temporary + .path() + .file_name() + .ok_or_else(|| invalid_data("legacy shell temporary replacement has no file name"))?; + let target_name = path + .file_name() + .ok_or_else(|| invalid_data("legacy shell file has no file name"))?; + if !relative_regular_file_matches(&parent_handle, temporary_name, &replacement_metadata)? { + return Err(invalid_data( + "legacy shell temporary replacement escaped its pinned parent", + )); + } + if !relative_regular_file_matches(&parent_handle, target_name, ¤t_metadata)? { + return Err(invalid_data( + "legacy shell target changed at the handle-relative rename boundary", + )); + } + + use std::ffi::CString; + use std::os::fd::AsRawFd; + use std::os::unix::ffi::OsStrExt; + let temporary_name = CString::new(temporary_name.as_bytes()) + .map_err(|_| invalid_data("legacy shell temporary file name contains NUL"))?; + let target_name = CString::new(target_name.as_bytes()) + .map_err(|_| invalid_data("legacy shell file name contains NUL"))?; + + // Cleanup is disabled before the handle-relative exchange so a + // path-based TempPath drop can never unlink an attacker-created name. + // The exchange makes the replaced inode available under the temporary + // name, allowing an exact post-syscall compare-and-swap check. + temporary.disable_cleanup(true); + before_exchange(); + if let Err(exchange_error) = + atomic_exchange_at(&parent_handle, &temporary_name, &target_name) + { + let cleanup_failed = + unsafe { libc::unlinkat(parent_handle.as_raw_fd(), temporary_name.as_ptr(), 0) } + != 0; + if cleanup_failed { + let cleanup_error = io::Error::last_os_error(); + return Err(io::Error::new( + exchange_error.kind(), + format!( + "{exchange_error}; additionally failed to remove the pinned temporary file: {cleanup_error}" + ), + )); + } + return Err(exchange_error); + } + // This callback is deliberately the first operation after exchange. + // It contains no I/O and records which inode rollback now owns. + on_state(Some(replacement_identity.clone())); + + let swapped_metadata = + relative_regular_file_metadata_cstr(&parent_handle, &temporary_name)?; + let swapped_matches = expected_identity.matches(&swapped_metadata) + && read_profile_bounded(temporary.path(), &swapped_metadata)? == expected_bytes; + if !swapped_matches { + let target_metadata = + relative_regular_file_metadata_cstr(&parent_handle, &target_name)?; + if !replacement_identity.matches(&target_metadata) { + return Err(invalid_data(format!( + "legacy shell file {} changed after a conflicting atomic exchange; both filesystem entries were preserved", + path.display() + ))); + } + atomic_exchange_at(&parent_handle, &temporary_name, &target_name)?; + let restored_temporary = + relative_regular_file_metadata_cstr(&parent_handle, &temporary_name)?; + if !replacement_identity.matches(&restored_temporary) { + return Err(invalid_data(format!( + "legacy shell file {} changed while a conflicting edit was restored; both filesystem entries were preserved", + path.display() + ))); + } + if unsafe { libc::unlinkat(parent_handle.as_raw_fd(), temporary_name.as_ptr(), 0) } != 0 + { + return Err(io::Error::last_os_error()); + } + on_state(None); + return Err(invalid_data(format!( + "legacy shell file {} was concurrently replaced at the atomic exchange boundary; the concurrent bytes were preserved", + path.display() + ))); + } + if unsafe { libc::unlinkat(parent_handle.as_raw_fd(), temporary_name.as_ptr(), 0) } != 0 { + return Err(io::Error::last_os_error()); + } + } + + #[cfg(not(unix))] + { + before_exchange(); + temporary.persist(path).map_err(|error| error.error)?; + // This callback is deliberately the first operation after rename. + // It contains no I/O and records which inode rollback now owns. + on_state(Some(replacement_identity.clone())); + } + + let installed = fs::symlink_metadata(path)?; + if !installed.is_file() || !replacement_identity.matches(&installed) { + return Err(invalid_data(format!( + "legacy shell file {} changed immediately after cleanup", + path.display() + ))); + } + #[cfg(unix)] + { + let relative_installed = relative_regular_file_metadata( + &parent_handle, + path.file_name() + .ok_or_else(|| invalid_data("legacy shell file has no file name"))?, + )?; + if !replacement_identity.matches(&relative_installed) + || relative_installed.len() != bytes.len() as u64 + || !same_file_snapshot(&installed, &relative_installed) + { + return Err(invalid_data( + "legacy shell replacement changed inside its pinned parent", + )); + } + parent_handle.sync_all()?; + } + Ok(()) +} + +#[cfg(unix)] +fn relative_regular_file_matches( + parent: &File, + name: &std::ffi::OsStr, + expected: &fs::Metadata, +) -> io::Result { + Ok(same_file_snapshot( + expected, + &relative_regular_file_metadata(parent, name)?, + )) +} + +#[cfg(unix)] +fn relative_regular_file_metadata( + parent: &File, + name: &std::ffi::OsStr, +) -> io::Result { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let name = CString::new(name.as_bytes()) + .map_err(|_| invalid_data("legacy shell relative file name contains NUL"))?; + relative_regular_file_metadata_cstr(parent, &name) +} + +#[cfg(unix)] +fn relative_regular_file_metadata_cstr( + parent: &File, + name: &std::ffi::CStr, +) -> io::Result { + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::fs::MetadataExt; + + let descriptor = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK, + ) + }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + let file = unsafe { File::from_raw_fd(descriptor) }; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.nlink() != 1 { + return Err(invalid_data( + "legacy shell relative entry is not a single-link regular file", + )); + } + Ok(metadata) +} + +#[cfg(target_os = "macos")] +fn atomic_exchange_at( + parent: &File, + left: &std::ffi::CStr, + right: &std::ffi::CStr, +) -> io::Result<()> { + use std::os::fd::AsRawFd; + + if unsafe { + libc::renameatx_np( + parent.as_raw_fd(), + left.as_ptr(), + parent.as_raw_fd(), + right.as_ptr(), + libc::RENAME_SWAP, + ) + } != 0 + { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn atomic_exchange_at( + parent: &File, + left: &std::ffi::CStr, + right: &std::ffi::CStr, +) -> io::Result<()> { + use std::os::fd::AsRawFd; + + let result = unsafe { + libc::syscall( + libc::SYS_renameat2, + parent.as_raw_fd(), + left.as_ptr(), + parent.as_raw_fd(), + right.as_ptr(), + libc::RENAME_EXCHANGE, + ) + }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))] +fn atomic_exchange_at( + _parent: &File, + _left: &std::ffi::CStr, + _right: &std::ffi::CStr, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "atomic legacy-profile exchange is supported only on macOS and Linux", + )) +} + +#[derive(Debug, Clone, Copy)] +enum LockMode { + Shared, + Exclusive, +} + +#[cfg(unix)] +#[derive(Debug)] +struct LegacyHomeLock { + file: File, + path: PathBuf, + identity: FileIdentity, + home_path: PathBuf, + home_identity: FileIdentity, +} + +#[cfg(unix)] +impl LegacyHomeLock { + fn acquire(home: &Path, mode: LockMode) -> io::Result { + use std::os::fd::AsRawFd; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let home_metadata = fs::symlink_metadata(home)?; + if !home_metadata.is_dir() { + return Err(invalid_data("legacy migration home is not a directory")); + } + validate_same_owner(&home_metadata, home)?; + validate_not_other_writable(&home_metadata, home)?; + let home_identity = FileIdentity::from_metadata(&home_metadata); + let path = home.join(LEGACY_HOME_LOCK_FILE); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(invalid_data("legacy cleanup lock is not a regular file")); + } + validate_same_owner(&metadata, &path)?; + validate_single_link(&metadata, &path)?; + if metadata.permissions().mode() & 0o777 != 0o600 { + return Err(invalid_data(format!( + "legacy cleanup lock {} must have mode 0600", + path.display() + ))); + } + let operation = match mode { + LockMode::Shared => libc::LOCK_SH, + LockMode::Exclusive => libc::LOCK_EX, + } | libc::LOCK_NB; + if unsafe { libc::flock(file.as_raw_fd(), operation) } != 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::WouldBlock { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + format!("legacy migration is locked by another operation: {error}"), + )); + } + return Err(error); + } + + let lock = Self { + identity: FileIdentity::from_metadata(&metadata), + file, + path, + home_path: home.to_path_buf(), + home_identity, + }; + lock.revalidate()?; + Ok(lock) + } + + fn revalidate(&self) -> io::Result<()> { + let home_metadata = fs::symlink_metadata(&self.home_path)?; + if !home_metadata.is_dir() + || !self.home_identity.matches(&home_metadata) + || fs::canonicalize(&self.home_path)? != self.home_path + { + return Err(invalid_data( + "legacy migration home changed while its lock was held", + )); + } + validate_same_owner(&home_metadata, &self.home_path)?; + validate_not_other_writable(&home_metadata, &self.home_path)?; + let descriptor_metadata = self.file.metadata()?; + if !descriptor_metadata.is_file() + || !self.identity.matches(&descriptor_metadata) + || !self.identity.matches(&fs::symlink_metadata(&self.path)?) + { + return Err(invalid_data(format!( + "legacy cleanup lock {} was replaced", + self.path.display() + ))); + } + validate_same_owner(&descriptor_metadata, &self.path)?; + validate_single_link(&descriptor_metadata, &self.path)?; + use std::os::unix::fs::PermissionsExt; + if descriptor_metadata.permissions().mode() & 0o777 != 0o600 { + return Err(invalid_data(format!( + "legacy cleanup lock {} must have mode 0600", + self.path.display() + ))); + } + Ok(()) + } +} + +#[cfg(unix)] +impl Drop for LegacyHomeLock { + fn drop(&mut self) { + use std::os::fd::AsRawFd; + let _ = unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) }; + } +} + +#[cfg(not(unix))] +#[derive(Debug)] +struct LegacyHomeLock; + +#[cfg(not(unix))] +impl LegacyHomeLock { + fn acquire(_home: &Path, _mode: LockMode) -> io::Result { + Ok(Self) + } + + fn revalidate(&self) -> io::Result<()> { + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct FileIdentity { + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, + #[cfg(not(unix))] + length: u64, + #[cfg(not(unix))] + modified: Option, +} + +impl FileIdentity { + fn from_metadata(metadata: &fs::Metadata) -> Self { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + Self { + device: metadata.dev(), + inode: metadata.ino(), + } + } + #[cfg(not(unix))] + { + Self { + length: metadata.len(), + modified: metadata.modified().ok(), + } + } + } + + fn matches(&self, metadata: &fs::Metadata) -> bool { + self == &Self::from_metadata(metadata) + } +} + +#[cfg(unix)] +fn validate_same_owner(metadata: &fs::Metadata, path: &Path) -> io::Result<()> { + use std::os::unix::fs::MetadataExt; + if metadata.uid() != unsafe { libc::geteuid() } { + return Err(invalid_data(format!( + "legacy shell file {} is not owned by the current user", + path.display() + ))); + } + Ok(()) +} + +#[cfg(unix)] +fn validate_single_link(metadata: &fs::Metadata, path: &Path) -> io::Result<()> { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(invalid_data(format!( + "legacy shell file {} has multiple hard links", + path.display() + ))); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_single_link(_metadata: &fs::Metadata, _path: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn validate_not_other_writable(metadata: &fs::Metadata, path: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o022 != 0 { + return Err(invalid_data(format!( + "legacy shell directory {} is writable by another user", + path.display() + ))); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_not_other_writable(_metadata: &fs::Metadata, _path: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(not(unix))] +fn validate_same_owner(_metadata: &fs::Metadata, _path: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn same_file_identity(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(unix)] +fn same_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + same_file_identity(left, right) + && left.len() == right.len() + && left.mode() == right.mode() + && left.mtime() == right.mtime() + && left.mtime_nsec() == right.mtime_nsec() + && left.ctime() == right.ctime() + && left.ctime_nsec() == right.ctime_nsec() +} + +#[cfg(not(unix))] +fn same_file_identity(left: &fs::Metadata, right: &fs::Metadata) -> bool { + left.len() == right.len() && left.modified().ok() == right.modified().ok() +} + +#[cfg(not(unix))] +fn same_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { + same_file_identity(left, right) +} + +fn normalize_absolute(path: &Path) -> io::Result { + use std::path::Component; + if !path.is_absolute() { + return Err(invalid_data("path must be absolute")); + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::RootDir | Component::Prefix(_) | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + return Err(invalid_data("path escapes the filesystem root")); + } + } + } + } + Ok(normalized) +} + +#[derive(Default)] +struct MarkerLocations { + starts: Vec, + ends: Vec, +} + +#[derive(Clone, Copy)] +struct LineLocation { + body_start: usize, + full_end: usize, +} + +fn marker_locations(content: &str) -> io::Result { + let mut result = MarkerLocations::default(); + let mut offset = 0; + while offset < content.len() { + let relative_end = content[offset..].find('\n'); + let full_end = relative_end + .map(|position| offset + position + 1) + .unwrap_or(content.len()); + let mut body_end = relative_end + .map(|position| offset + position) + .unwrap_or(content.len()); + if body_end > offset && content.as_bytes()[body_end - 1] == b'\r' { + body_end -= 1; + } + let body = &content[offset..body_end]; + let location = LineLocation { + body_start: offset, + full_end, + }; + if body == LEGACY_START_MARKER { + result.starts.push(location); + } else if body == LEGACY_END_MARKER { + result.ends.push(location); + } else if body.contains(LEGACY_MARKER_NAMESPACE) { + return Err(invalid_data( + "legacy shell file contains a malformed managed Codex marker", + )); + } + offset = full_end; + } + Ok(result) +} + +fn powershell_guidance() -> Option { + #[cfg(windows)] + { + Some(format!( + "Open $PROFILE.CurrentUserCurrentHost and remove exactly one block from `{LEGACY_START_MARKER}` through `{LEGACY_END_MARKER}`. Stop without editing if either marker is missing, duplicated, malformed, or out of order. Do not remove any unrelated Codex alias or function." + )) + } + #[cfg(not(windows))] + { + None + } +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn removes_only_the_exact_marker_block() { + let input = format!( + "before\n{LEGACY_START_MARKER}\nfunction codex {{ old; }}\n{LEGACY_END_MARKER}\nafter\n" + ); + assert_eq!( + remove_marked_block(&input).unwrap(), + Some("before\nafter\n".to_string()) + ); + } + + #[test] + fn no_marker_is_a_successful_noop() { + assert_eq!(remove_marked_block("alias codex=custom\n").unwrap(), None); + } + + #[test] + fn duplicate_unmatched_and_lookalike_markers_fail_closed() { + let duplicate = format!( + "{LEGACY_START_MARKER}\n{LEGACY_END_MARKER}\n{LEGACY_START_MARKER}\n{LEGACY_END_MARKER}\n" + ); + assert!(remove_marked_block(&duplicate).is_err()); + assert!(remove_marked_block(LEGACY_START_MARKER).is_err()); + assert!(remove_marked_block("# >>> abtop managed codex >>> trailing\n").is_err()); + } + + #[test] + fn preserves_crlf_and_handles_a_final_marker_without_newline() { + let crlf = + format!("before\r\n{LEGACY_START_MARKER}\r\nold\r\n{LEGACY_END_MARKER}\r\nafter\r\n"); + assert_eq!( + remove_marked_block(&crlf).unwrap(), + Some("before\r\nafter\r\n".to_string()) + ); + let no_final_newline = format!("before\n{LEGACY_START_MARKER}\nold\n{LEGACY_END_MARKER}"); + assert_eq!( + remove_marked_block(&no_final_newline).unwrap(), + Some("before\n".to_string()) + ); + } + + #[test] + fn reversed_markers_fail_closed() { + let reversed = format!("{LEGACY_END_MARKER}\nold\n{LEGACY_START_MARKER}\n"); + assert!(remove_marked_block(&reversed).is_err()); + } + + #[test] + fn cleanup_transaction_can_roll_back_exact_bytes() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join(".zshrc"); + let original = format!("keep\r\n{LEGACY_START_MARKER}\r\nold\r\n{LEGACY_END_MARKER}\r\n"); + fs::write(&profile, &original).unwrap(); + + let mut transaction = LegacyCleanupTransaction::begin_at(temp.path()).unwrap(); + assert_eq!(fs::read_to_string(&profile).unwrap(), "keep\r\n"); + transaction.rollback().unwrap(); + assert_eq!(fs::read_to_string(&profile).unwrap(), original); + } + + #[test] + fn rollback_refuses_to_overwrite_a_later_profile_edit() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join(".zshrc"); + let original = format!("keep\n{LEGACY_START_MARKER}\nold\n{LEGACY_END_MARKER}\n"); + fs::write(&profile, original).unwrap(); + + let mut transaction = LegacyCleanupTransaction::begin_at(temp.path()).unwrap(); + fs::write(&profile, "external edit\n").unwrap(); + + assert!(transaction.rollback().is_err()); + assert_eq!(fs::read_to_string(&profile).unwrap(), "external edit\n"); + } + + #[test] + #[cfg(unix)] + fn apply_preserves_a_concurrent_atomic_save_at_the_exchange_boundary() { + let temp = tempfile::tempdir().unwrap(); + let home = fs::canonicalize(temp.path()).unwrap(); + let profile = home.join("profile"); + fs::write(&profile, b"before").unwrap(); + let metadata = fs::symlink_metadata(&profile).unwrap(); + let identity = FileIdentity::from_metadata(&metadata); + let permissions = metadata.permissions(); + let lock = LegacyHomeLock::acquire(&home, LockMode::Exclusive).unwrap(); + let mut states = Vec::new(); + + let error = atomic_replace_with_before_exchange( + AtomicReplacement { + requested_path: &profile, + path: &profile, + home: &home, + bytes: b"installed", + permissions: &permissions, + expected_identity: &identity, + expected_bytes: b"before", + lock: &lock, + }, + |state| states.push(state), + || { + let saved = home.join("profile.concurrent-save"); + fs::write(&saved, b"concurrent apply edit").unwrap(); + fs::rename(saved, &profile).unwrap(); + }, + ) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error + .to_string() + .contains("concurrent bytes were preserved")); + assert_eq!(states.len(), 2); + assert!(states[0].is_some()); + assert!(states[1].is_none()); + assert_eq!(fs::read(&profile).unwrap(), b"concurrent apply edit"); + assert!(!home.join("profile.concurrent-save").exists()); + } + + #[test] + #[cfg(unix)] + fn rollback_preserves_a_concurrent_atomic_save_at_the_exchange_boundary() { + let temp = tempfile::tempdir().unwrap(); + let home = fs::canonicalize(temp.path()).unwrap(); + let profile = home.join("profile"); + fs::write(&profile, b"installed").unwrap(); + let metadata = fs::symlink_metadata(&profile).unwrap(); + let identity = FileIdentity::from_metadata(&metadata); + let permissions = metadata.permissions(); + let lock = LegacyHomeLock::acquire(&home, LockMode::Exclusive).unwrap(); + let mut states = Vec::new(); + + let error = atomic_replace_with_before_exchange( + AtomicReplacement { + requested_path: &profile, + path: &profile, + home: &home, + bytes: b"before", + permissions: &permissions, + expected_identity: &identity, + expected_bytes: b"installed", + lock: &lock, + }, + |state| states.push(state), + || { + let saved = home.join("profile.concurrent-save"); + fs::write(&saved, b"concurrent rollback edit").unwrap(); + fs::rename(saved, &profile).unwrap(); + }, + ) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error + .to_string() + .contains("concurrent bytes were preserved")); + assert_eq!(states.len(), 2); + assert!(states[0].is_some()); + assert!(states[1].is_none()); + assert_eq!(fs::read(&profile).unwrap(), b"concurrent rollback edit"); + assert!(!home.join("profile.concurrent-save").exists()); + } + + #[test] + fn oversized_profile_is_rejected_before_unbounded_allocation() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join(".zshrc"); + let file = File::create(&profile).unwrap(); + file.set_len(MAX_PROFILE_BYTES + 1).unwrap(); + + let error = LegacyCleanupTransaction::begin_at(temp.path()).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + + #[test] + #[cfg(unix)] + fn bounded_profile_reader_refuses_a_symlink_leaf() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("target"); + let link = temp.path().join("link"); + fs::write(&target, b"unchanged").unwrap(); + symlink("target", &link).unwrap(); + let followed_metadata = fs::metadata(&link).unwrap(); + + assert!(read_profile_bounded(&link, &followed_metadata).is_err()); + assert_eq!(fs::read(&target).unwrap(), b"unchanged"); + assert!(fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink()); + } + + #[test] + #[cfg(unix)] + fn cleanup_preserves_an_owned_internal_profile_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("real-zshrc"); + let profile = temp.path().join(".zshrc"); + let original = format!("keep\n{LEGACY_START_MARKER}\nold\n{LEGACY_END_MARKER}\n"); + fs::write(&target, original).unwrap(); + symlink("real-zshrc", &profile).unwrap(); + + let transaction = LegacyCleanupTransaction::begin_at(temp.path()).unwrap(); + assert!(fs::symlink_metadata(&profile) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(fs::read_to_string(&target).unwrap(), "keep\n"); + let _ = transaction.commit(); + assert!(fs::symlink_metadata(&profile) + .unwrap() + .file_type() + .is_symlink()); + } + + #[test] + #[cfg(unix)] + fn no_marker_transaction_retains_one_stable_home_lock() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let temp = tempfile::tempdir().unwrap(); + let lock_path = temp.path().join(LEGACY_HOME_LOCK_FILE); + + let transaction = LegacyCleanupTransaction::begin_at(temp.path()).unwrap(); + let first = fs::symlink_metadata(&lock_path).unwrap(); + assert!(first.is_file()); + assert_eq!(first.permissions().mode() & 0o777, 0o600); + let first_identity = (first.dev(), first.ino()); + let _ = transaction.commit(); + + assert!(lock_path.exists()); + let second_transaction = LegacyCleanupTransaction::begin_at(temp.path()).unwrap(); + let second = fs::symlink_metadata(&lock_path).unwrap(); + assert_eq!(first_identity, (second.dev(), second.ino())); + let _ = second_transaction.commit(); + assert!(lock_path.exists()); + } + + #[test] + #[cfg(unix)] + fn exclusive_transaction_blocks_cleanup_before_profile_scanning() { + let temp = tempfile::tempdir().unwrap(); + let transaction = LegacyCleanupTransaction::begin_at(temp.path()).unwrap(); + fs::write( + temp.path().join(".zshrc"), + format!("{LEGACY_START_MARKER}\nmissing end\n"), + ) + .unwrap(); + + let error = LegacyCleanupTransaction::begin_at(temp.path()).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::WouldBlock); + + let _ = transaction.commit(); + } + + #[test] + #[cfg(unix)] + fn inspection_uses_the_same_shared_home_lock() { + let temp = tempfile::tempdir().unwrap(); + let transaction = LegacyCleanupTransaction::begin_at(temp.path()).unwrap(); + + let error = inspect_legacy_shell_integration_at(temp.path()).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::WouldBlock); + + let _ = transaction.commit(); + assert!(inspect_legacy_shell_integration_at(temp.path()) + .unwrap() + .is_empty()); + } + + #[test] + #[cfg(unix)] + fn unsafe_lock_mode_is_rejected_without_repairing_it() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let lock_path = temp.path().join(LEGACY_HOME_LOCK_FILE); + fs::write(&lock_path, b"").unwrap(); + fs::set_permissions(&lock_path, fs::Permissions::from_mode(0o644)).unwrap(); + + let error = LegacyCleanupTransaction::begin_at(temp.path()).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert_eq!( + fs::symlink_metadata(&lock_path) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o644 + ); + } + + #[test] + #[cfg(unix)] + fn symlink_lock_is_rejected_without_touching_its_target() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("unrelated"); + let lock_path = temp.path().join(LEGACY_HOME_LOCK_FILE); + fs::write(&target, b"keep").unwrap(); + symlink("unrelated", &lock_path).unwrap(); + + assert!(LegacyCleanupTransaction::begin_at(temp.path()).is_err()); + assert_eq!(fs::read(&target).unwrap(), b"keep"); + assert!(fs::symlink_metadata(&lock_path) + .unwrap() + .file_type() + .is_symlink()); + } + + #[test] + #[cfg(unix)] + fn hard_linked_and_nonregular_locks_are_rejected() { + use std::os::unix::fs::PermissionsExt; + + let linked_home = tempfile::tempdir().unwrap(); + let linked_target = linked_home.path().join("other-lock-name"); + let linked_lock = linked_home.path().join(LEGACY_HOME_LOCK_FILE); + fs::write(&linked_target, b"").unwrap(); + fs::set_permissions(&linked_target, fs::Permissions::from_mode(0o600)).unwrap(); + fs::hard_link(&linked_target, &linked_lock).unwrap(); + assert!(LegacyCleanupTransaction::begin_at(linked_home.path()).is_err()); + + let directory_home = tempfile::tempdir().unwrap(); + fs::create_dir(directory_home.path().join(LEGACY_HOME_LOCK_FILE)).unwrap(); + assert!(LegacyCleanupTransaction::begin_at(directory_home.path()).is_err()); + } + + #[test] + #[cfg(unix)] + fn other_writable_legacy_home_is_rejected() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o777)).unwrap(); + let error = LegacyCleanupTransaction::begin_at(temp.path()).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(!temp.path().join(LEGACY_HOME_LOCK_FILE).exists()); + } + + #[test] + #[cfg(unix)] + fn atomic_replace_records_the_rename_before_later_validation_failure() { + let temp = tempfile::tempdir().unwrap(); + let home = fs::canonicalize(temp.path()).unwrap(); + let profile = home.join("profile"); + fs::write(&profile, b"before").unwrap(); + let metadata = fs::symlink_metadata(&profile).unwrap(); + let identity = FileIdentity::from_metadata(&metadata); + let permissions = metadata.permissions(); + let lock = LegacyHomeLock::acquire(&home, LockMode::Exclusive).unwrap(); + let mut renamed = false; + + let error = atomic_replace( + AtomicReplacement { + requested_path: &profile, + path: &profile, + home: &home, + bytes: b"after", + permissions: &permissions, + expected_identity: &identity, + expected_bytes: b"before", + lock: &lock, + }, + |_| { + renamed = true; + fs::remove_file(&profile).unwrap(); + fs::write(&profile, b"external").unwrap(); + }, + ) + .unwrap_err(); + + assert!(renamed); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert_eq!(fs::read(&profile).unwrap(), b"external"); + } + + #[test] + #[cfg(unix)] + fn zdotdir_probe_accepts_only_one_exact_frame() { + assert_eq!( + parse_zdotdir_probe(b"\x1eABTOP_ZDOTDIR_V1_BEGIN\x1f\x1eABTOP_ZDOTDIR_V1_END\x1f") + .unwrap(), + None + ); + assert_eq!( + parse_zdotdir_probe( + b"\x1eABTOP_ZDOTDIR_V1_BEGIN\x1f/private/home/.config/zsh\x1eABTOP_ZDOTDIR_V1_END\x1f" + ) + .unwrap(), + Some(PathBuf::from("/private/home/.config/zsh")) + ); + for malformed in [ + b"/private/home/.config/zsh".as_slice(), + b"prefix\x1eABTOP_ZDOTDIR_V1_BEGIN\x1f/path\x1eABTOP_ZDOTDIR_V1_END\x1f", + b"\x1eABTOP_ZDOTDIR_V1_BEGIN\x1f/path\nother\x1eABTOP_ZDOTDIR_V1_END\x1f", + ] { + assert!(parse_zdotdir_probe(malformed).is_err()); + } + } + + #[test] + #[cfg(unix)] + fn zdotdir_probe_finds_a_value_set_only_by_zsh_startup() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let home = fs::canonicalize(temp.path()).unwrap(); + let root = home.join("startup-only-zdotdir"); + fs::create_dir(&root).unwrap(); + let shell = home.join("zsh"); + let quoted_root = root.to_string_lossy().replace('\'', "'\"'\"'"); + fs::write( + &shell, + format!( + "#!/bin/sh\nZDOTDIR='{quoted_root}'\nprintf '\\036ABTOP_ZDOTDIR_V1_BEGIN\\037%s\\036ABTOP_ZDOTDIR_V1_END\\037' \"$ZDOTDIR\"\n" + ), + ) + .unwrap(); + fs::set_permissions(&shell, fs::Permissions::from_mode(0o700)).unwrap(); + + assert_eq!( + probe_zdotdir_with_shell_timeout(&shell, &home, Duration::from_secs(15)).unwrap(), + Some(root) + ); + } + + #[test] + #[cfg(unix)] + fn zdotdir_probe_does_not_wait_for_inherited_background_pipes() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let shell = temp.path().join("zsh"); + let pid_file = temp.path().join("background.pid"); + let quoted_pid_file = pid_file.to_string_lossy().replace('\'', "'\"'\"'"); + fs::write( + &shell, + format!( + "#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > '{quoted_pid_file}'\nprintf '\\036ABTOP_ZDOTDIR_V1_BEGIN\\037%s\\036ABTOP_ZDOTDIR_V1_END\\037' '/private/tmp/zsh'\n" + ), + ) + .unwrap(); + fs::set_permissions(&shell, fs::Permissions::from_mode(0o700)).unwrap(); + + let started = Instant::now(); + assert_eq!( + run_zdotdir_probe_with_timeout(&shell, false, Duration::from_secs(15)).unwrap(), + Some(PathBuf::from("/private/tmp/zsh")) + ); + assert!(started.elapsed() < Duration::from_secs(20)); + + let background_pid = fs::read_to_string(pid_file) + .unwrap() + .parse::() + .unwrap(); + let _ = unsafe { libc::kill(background_pid, libc::SIGKILL) }; + } + + #[test] + #[cfg(unix)] + fn handle_relative_replacement_leaves_no_temporary_name() { + let temp = tempfile::tempdir().unwrap(); + let home = fs::canonicalize(temp.path()).unwrap(); + let profile = home.join("profile"); + fs::write(&profile, b"before").unwrap(); + let metadata = fs::symlink_metadata(&profile).unwrap(); + let identity = FileIdentity::from_metadata(&metadata); + let permissions = metadata.permissions(); + let lock = LegacyHomeLock::acquire(&home, LockMode::Exclusive).unwrap(); + let mut renamed = false; + + atomic_replace( + AtomicReplacement { + requested_path: &profile, + path: &profile, + home: &home, + bytes: b"after", + permissions: &permissions, + expected_identity: &identity, + expected_bytes: b"before", + lock: &lock, + }, + |_| renamed = true, + ) + .unwrap(); + + assert!(renamed); + assert_eq!(fs::read(&profile).unwrap(), b"after"); + let mut names = fs::read_dir(&home) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + names.sort(); + assert_eq!( + names, + vec![ + std::ffi::OsString::from(LEGACY_HOME_LOCK_FILE), + std::ffi::OsString::from("profile"), + ] + ); + } +} diff --git a/src/codex_hooks/mod.rs b/src/codex_hooks/mod.rs new file mode 100644 index 0000000..ecccf77 --- /dev/null +++ b/src/codex_hooks/mod.rs @@ -0,0 +1,22 @@ +//! Native Codex hook integration. +//! +//! Public setup commands install an isolated local plugin. Runtime hook input +//! is consumed by a private, silent entry point and reduced to content-free +//! state for the Codex collector. + +pub(crate) mod ingest; +pub(crate) mod migration; +pub(crate) mod plugin; +pub(crate) mod state; + +use std::ffi::OsString; + +/// Consume one hook invocation without ever affecting Codex. +/// +/// The generated launcher already redirects both output streams. This second +/// fail-open boundary is intentional: malformed, stale, or untrusted input is +/// ignored and represented by unavailable evidence instead of delaying the +/// provider. +pub(crate) fn ingest_silently(args: Vec) { + let _ = ingest::run_from_environment(args); +} diff --git a/src/codex_hooks/plugin.rs b/src/codex_hooks/plugin.rs new file mode 100644 index 0000000..286d237 --- /dev/null +++ b/src/codex_hooks/plugin.rs @@ -0,0 +1,6608 @@ +//! Installation and inspection of abtop's local Codex lifecycle-hook plugin. +//! +//! The integration never replaces the `codex` command. It installs an +//! isolated local marketplace through the native Codex plugin CLI and keeps +//! its own source bundle under the active `CODEX_HOME`. + +use super::migration::{self, LegacyCleanupTransaction, MigrationReport}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +#[cfg(not(unix))] +use std::sync::mpsc; +#[cfg(not(unix))] +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +pub const PLUGIN_NAME: &str = "abtop"; +pub const MARKETPLACE_NAME: &str = "abtop-local"; +pub const PLUGIN_ID: &str = "abtop@abtop-local"; +pub const HOOK_SCHEMA_REVISION: &str = "1"; +pub(crate) const MIN_SUPPORTED_CODEX_VERSION: &str = "0.145.0"; +const MIN_SUPPORTED_CODEX_VERSION_COMPONENTS: (u64, u64, u64) = (0, 145, 0); +pub const INSTALL_ATTESTATION_FILE: &str = "installation.json"; +pub(crate) const HOOK_FAULT_TOKEN_ENV: &str = "ABTOP_CODEX_HOOK_FAULT_TOKEN"; +pub(crate) const HOOK_STATE_DIR_NAME: &str = "states"; +pub(crate) const HOOK_FAULT_DIR_NAME: &str = "faults"; +pub const HOOK_EVENTS: [&str; 11] = [ + "PreToolUse", + "PermissionRequest", + "PostToolUse", + "PreCompact", + "PostCompact", + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "SubagentStart", + "SubagentStop", + "Stop", +]; + +const HELPER_IDENTITY_REVISION: &str = "abtop-codex-hook-helper-v1"; +const MARKETPLACE_MANIFEST_RELATIVE: &str = ".agents/plugins/marketplace.json"; +const PLUGIN_MANIFEST_RELATIVE: &str = "plugins/abtop/.codex-plugin/plugin.json"; +const HOOKS_RELATIVE: &str = "plugins/abtop/hooks/hooks.json"; +const POSIX_LAUNCHER_RELATIVE: &str = "plugins/abtop/scripts/abtop-codex-hook.sh"; +const WINDOWS_LAUNCHER_RELATIVE: &str = "plugins/abtop/scripts/abtop-codex-hook.cmd"; +const SETUP_LOCK_FILE: &str = ".abtop-codex-plugin.lock"; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(15); +const COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(10); +const INHERITED_PIPE_GRACE: Duration = Duration::from_millis(100); +const MAX_COMMAND_OUTPUT: usize = 1024 * 1024; +const MAX_MANAGED_FILE: u64 = 4 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginPaths { + pub codex_home: PathBuf, + pub marketplace_root: PathBuf, + pub marketplace_manifest: PathBuf, + pub plugin_root: PathBuf, + pub plugin_manifest: PathBuf, + pub hooks_manifest: PathBuf, + pub posix_launcher: PathBuf, + pub windows_launcher: PathBuf, + pub plugin_data_root: PathBuf, + pub install_attestation: PathBuf, +} + +impl PluginPaths { + pub fn new(codex_home: &Path) -> io::Result { + let codex_home = normalize_absolute(codex_home)?; + let marketplace_root = codex_home.join("abtop/marketplace"); + let plugin_root = marketplace_root.join("plugins/abtop"); + let plugin_data_root = codex_home.join("plugins/data/abtop-abtop-local"); + Ok(Self { + marketplace_manifest: marketplace_root.join(MARKETPLACE_MANIFEST_RELATIVE), + plugin_manifest: marketplace_root.join(PLUGIN_MANIFEST_RELATIVE), + hooks_manifest: marketplace_root.join(HOOKS_RELATIVE), + posix_launcher: marketplace_root.join(POSIX_LAUNCHER_RELATIVE), + windows_launcher: marketplace_root.join(WINDOWS_LAUNCHER_RELATIVE), + install_attestation: plugin_data_root.join(INSTALL_ATTESTATION_FILE), + codex_home, + marketplace_root, + plugin_root, + plugin_data_root, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InstallationAttestation { + pub schema_version: u32, + pub hook_schema_revision: String, + pub helper_digest: String, + pub installation_id: String, + pub plugin_id: String, + pub plugin_version: String, + pub hooks_digest: String, + pub hook_events: Vec, + pub installed_at_unix_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetupReport { + pub paths: PluginPaths, + pub codex_binary: PathBuf, + pub abtop_binary: PathBuf, + pub helper_digest: String, + pub plugin_version: String, + pub hook_schema_revision: &'static str, + pub hook_count: usize, + pub base_config_trusted_hooks: usize, + pub base_config_enabled_hooks: usize, + pub review_required: bool, + pub legacy_cleanup: MigrationReport, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UninstallReport { + pub paths: PluginPaths, + pub codex_binary: PathBuf, + pub plugin_removed: bool, + pub marketplace_removed: bool, + pub source_files_removed: Vec, + pub preserved_data_root: PathBuf, + pub legacy_cleanup: MigrationReport, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IntegrationStatus { + pub paths: PluginPaths, + pub codex_binary: Option, + pub helper_digest: Option, + pub hook_schema_revision: &'static str, + pub hook_count: usize, + pub marketplace_registered: bool, + pub plugin_installed: bool, + pub plugin_enabled: bool, + pub installed_version: Option, + pub bundle_valid: bool, + pub attestation_valid: bool, + /// Trust state from the base `$CODEX_HOME/config.toml` only. Per-process + /// CLI overrides and higher-precedence managed layers can differ. + pub base_config_trusted_hooks: usize, + /// Enabled state from the base config, where an absent `enabled` field is + /// Codex's default-enabled state. This does not imply that a hook is trusted. + pub base_config_enabled_hooks: usize, + pub base_config_state_entries: usize, + pub legacy_marker_files: Vec, + /// False when the legacy-shell inspection could not be completed safely. + /// An empty marker list is meaningful only while this flag is true. + pub legacy_inspection_valid: bool, + pub healthy: bool, + pub details: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimeHookConfig { + pub config_digest: String, + pub complete_hook_set: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RenderedBundle { + helper_digest: String, + plugin_version: String, + marketplace_manifest: Vec, + plugin_manifest: Vec, + hooks_manifest: Vec, + posix_launcher: Vec, + windows_launcher: Vec, + hooks_digest: String, + hook_commands: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct HookCommandIdentity { + event: &'static str, + event_key: &'static str, + command: String, + command_windows: String, +} + +#[derive(Debug)] +struct CommandOutput { + status: ExitStatus, + stdout: Vec, + stderr: Vec, + overflowed: bool, +} + +#[derive(Debug, Default)] +struct CliState { + marketplace_registered: bool, + marketplace_conflict: Option, + marketplace_malformed: bool, + plugin_configured: bool, + plugin_config_malformed: bool, + plugin_installed: bool, + plugin_enabled: bool, + installed_version: Option, +} + +#[derive(Debug, Default)] +struct BaseHookState { + trusted: usize, + enabled: usize, + entries: usize, +} + +#[derive(Debug)] +struct PreparedInstall { + codex_home: PathBuf, + codex_binary: PathBuf, + codex_binary_digest: String, + abtop_binary: PathBuf, + paths: PluginPaths, + bundle: RenderedBundle, +} + +#[cfg(unix)] +#[derive(Debug)] +struct SetupLock { + file: File, + codex_home_directory: File, + lock_metadata: fs::Metadata, + codex_home: PathBuf, +} + +#[cfg(unix)] +impl SetupLock { + fn acquire(codex_home: &Path) -> io::Result { + use std::os::fd::AsRawFd; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let codex_home_directory = open_unix_directory(codex_home, false)?; + let codex_home_metadata = codex_home_directory.metadata()?; + let lock_path = codex_home.join(SETUP_LOCK_FILE); + let file = openat_create_unix( + &codex_home_directory, + std::ffi::OsStr::new(SETUP_LOCK_FILE), + libc::O_RDWR | libc::O_CREAT | libc::O_NOFOLLOW | libc::O_CLOEXEC, + 0o600, + )?; + let metadata = file.metadata()?; + if !metadata.is_file() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.permissions().mode() & 0o077 != 0 + { + return Err(invalid_data(format!( + "unsafe Codex integration setup lock {}", + lock_path.display() + ))); + } + validate_single_link(&metadata, &lock_path)?; + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "another abtop Codex integration setup is running", + )); + } + let current_home = open_unix_directory(codex_home, false)?; + if !same_file_metadata(&codex_home_metadata, ¤t_home.metadata()?) { + return Err(invalid_data( + "CODEX_HOME changed while the integration lock was acquired", + )); + } + let current_lock = openat_unix( + ¤t_home, + std::ffi::OsStr::new(SETUP_LOCK_FILE), + libc::O_RDWR | libc::O_NOFOLLOW | libc::O_CLOEXEC, + )? + .ok_or_else(|| invalid_data("Codex integration setup lock disappeared"))?; + if !same_file_metadata(&metadata, ¤t_lock.metadata()?) { + return Err(invalid_data( + "Codex integration setup lock changed while it was acquired", + )); + } + Ok(Self { + file, + codex_home_directory, + lock_metadata: metadata, + codex_home: codex_home.to_path_buf(), + }) + } + + fn revalidate(&self) -> io::Result<()> { + let current_home = open_unix_directory(&self.codex_home, false)?; + if !same_file_metadata( + &self.codex_home_directory.metadata()?, + ¤t_home.metadata()?, + ) { + return Err(invalid_data( + "CODEX_HOME changed while the integration lock was held", + )); + } + let lock_path = self.codex_home.join(SETUP_LOCK_FILE); + let current_lock = openat_unix( + ¤t_home, + std::ffi::OsStr::new(SETUP_LOCK_FILE), + libc::O_RDWR | libc::O_NOFOLLOW | libc::O_CLOEXEC, + )? + .ok_or_else(|| invalid_data("Codex integration setup lock disappeared"))?; + let metadata = current_lock.metadata()?; + if !same_file_metadata(&self.lock_metadata, &metadata) { + return Err(invalid_data(format!( + "Codex integration setup lock {} was replaced", + lock_path.display() + ))); + } + validate_owned_regular_file(&lock_path, &metadata, true)?; + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o777 != 0o600 { + return Err(invalid_data(format!( + "Codex integration setup lock {} must have mode 0600", + lock_path.display() + ))); + } + Ok(()) + } +} + +#[cfg(unix)] +impl Drop for SetupLock { + fn drop(&mut self) { + use std::os::fd::AsRawFd; + let _ = unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) }; + } +} + +#[cfg(not(unix))] +#[derive(Debug)] +struct SetupLock { + file: File, + metadata: fs::Metadata, + path: PathBuf, +} + +#[cfg(not(unix))] +impl SetupLock { + fn acquire(codex_home: &Path) -> io::Result { + let lock = codex_home.join(SETUP_LOCK_FILE); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + // Permit read-only inspection while denying another writer or a + // delete/replace of the stable lock for this transaction. + options.share_mode(windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ); + } + let file = options.open(&lock)?; + let metadata = file.metadata()?; + let path_metadata = fs::symlink_metadata(&lock)?; + if path_metadata.file_type().is_symlink() + || !metadata.is_file() + || !same_file_metadata(&metadata, &path_metadata) + { + return Err(invalid_data(format!( + "unsafe Codex integration setup lock {}", + lock.display() + ))); + } + Ok(Self { + file, + metadata, + path: lock, + }) + } + + fn revalidate(&self) -> io::Result<()> { + let descriptor = self.file.metadata()?; + let path = fs::symlink_metadata(&self.path)?; + if path.file_type().is_symlink() + || !descriptor.is_file() + || !same_file_metadata(&self.metadata, &descriptor) + || !same_file_metadata(&self.metadata, &path) + { + return Err(invalid_data(format!( + "Codex integration setup lock {} changed while it was held", + self.path.display() + ))); + } + Ok(()) + } +} + +pub fn setup() -> io::Result { + let codex_home = current_codex_home()?; + let codex_binary = resolve_codex_binary()?; + let abtop_binary = current_abtop_binary()?; + setup_with(&codex_home, &codex_binary, &abtop_binary) +} + +/// Install using explicit paths. This is public to make setup behavior +/// auditable and testable; all three paths must be absolute. +pub fn setup_with( + codex_home: &Path, + codex_binary: &Path, + abtop_binary: &Path, +) -> io::Result { + let home = dirs::home_dir().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "cannot determine the current user's home directory", + ) + })?; + setup_with_home(codex_home, codex_binary, abtop_binary, &home) +} + +pub fn setup_with_home( + codex_home: &Path, + codex_binary: &Path, + abtop_binary: &Path, + legacy_home: &Path, +) -> io::Result { + // Complete every read-only compatibility, helper, source-tree, and base + // registration check before the legacy transaction temporarily edits a + // shell profile. Later checks are repeated under the setup lock to close + // the gap before mutation. + let prepared = prepare_install(codex_home, codex_binary, abtop_binary)?; + let mut legacy = LegacyCleanupTransaction::begin_at(legacy_home)?; + match install_prepared(prepared) { + Ok(mut report) => { + report.legacy_cleanup = legacy.commit(); + Ok(report) + } + Err(error) => match legacy.rollback() { + Ok(()) => Err(error), + Err(rollback_error) => Err(io::Error::new( + error.kind(), + format!( + "{error}; additionally failed to restore the legacy shell integration: {rollback_error}" + ), + )), + }, + } +} + +#[cfg(all(test, unix))] +fn install_after_legacy_cleanup( + codex_home: &Path, + codex_binary: &Path, + abtop_binary: &Path, +) -> io::Result { + install_prepared(prepare_install(codex_home, codex_binary, abtop_binary)?) +} + +fn prepare_install( + codex_home: &Path, + codex_binary: &Path, + abtop_binary: &Path, +) -> io::Result { + ensure_hook_state_platform_supported()?; + let codex_home = prepare_codex_home(codex_home)?; + let (codex_binary, codex_binary_digest) = + capture_codex_binary_compatibility(codex_binary, &codex_home)?; + let abtop_binary = validate_abtop_binary(abtop_binary)?; + let paths = PluginPaths::new(&codex_home)?; + let bundle = render_bundle(&abtop_binary, &paths.plugin_data_root)?; + audit_owned_source_tree(&paths, false)?; + let prior = inspect_config_state(&paths, None)?; + validate_setup_registration(&prior)?; + Ok(PreparedInstall { + codex_home, + codex_binary, + codex_binary_digest, + abtop_binary, + paths, + bundle, + }) +} + +fn install_prepared(prepared: PreparedInstall) -> io::Result { + let PreparedInstall { + codex_home, + codex_binary, + codex_binary_digest, + abtop_binary, + paths, + bundle, + } = prepared; + let _setup_lock = SetupLock::acquire(&paths.codex_home)?; + + let revalidated_codex = validate_codex_binary_compatibility(&codex_binary, &codex_home)?; + if revalidated_codex != codex_binary + || executable_path_digest(&revalidated_codex)? != codex_binary_digest + { + return Err(invalid_data( + "the native Codex executable changed after compatibility preflight", + )); + } + let revalidated_bundle = render_bundle(&abtop_binary, &paths.plugin_data_root)?; + if revalidated_bundle != bundle { + return Err(invalid_data( + "the abtop helper changed after integration preflight", + )); + } + + _setup_lock.revalidate()?; + audit_owned_source_tree(&paths, false)?; + let prior = inspect_config_state(&paths, None)?; + validate_setup_registration(&prior)?; + write_bundle(&paths, &bundle)?; + write_attestation(&paths, &bundle)?; + if !bundle_matches_disk(&paths, &bundle)? || !attestation_matches(&paths, &bundle)? { + return Err(invalid_data( + "the abtop plugin source bundle did not validate after it was written", + )); + } + + _setup_lock.revalidate()?; + let marketplace_added = !prior.marketplace_registered; + if marketplace_added { + let marketplace_add = run_mutating_codex( + &codex_binary, + &codex_binary_digest, + &codex_home, + &[ + OsString::from("plugin"), + OsString::from("marketplace"), + OsString::from("add"), + paths.marketplace_root.as_os_str().to_owned(), + OsString::from("--json"), + ], + ) + .and_then(|output| { + require_success(&output, "adding the abtop local marketplace")?; + require_json_object(&output.stdout, "marketplace add") + }); + if let Err(error) = marketplace_add { + let cleanup = remove_marketplace_if_owned( + &paths, + &codex_binary, + &codex_binary_digest, + &codex_home, + ) + .err() + .into_iter() + .collect(); + return Err(with_cleanup_errors(error, cleanup)); + } + } + + let plugin_add = run_mutating_codex( + &codex_binary, + &codex_binary_digest, + &codex_home, + &[ + OsString::from("plugin"), + OsString::from("add"), + OsString::from(PLUGIN_ID), + OsString::from("--json"), + ], + ) + .and_then(|output| { + require_success(&output, "installing the abtop plugin")?; + require_json_object(&output.stdout, "plugin add") + }); + if let Err(error) = plugin_add { + let mut cleanup = Vec::new(); + if !prior.plugin_configured { + if let Err(cleanup_error) = + remove_plugin_cli(&codex_binary, &codex_binary_digest, &codex_home) + { + cleanup.push(cleanup_error); + } + } + if marketplace_added { + if let Err(cleanup_error) = remove_marketplace_if_owned( + &paths, + &codex_binary, + &codex_binary_digest, + &codex_home, + ) { + cleanup.push(cleanup_error); + } + } + return Err(with_cleanup_errors(error, cleanup)); + } + + let verified = inspect_config_state(&paths, Some(&bundle))?; + if !verified.marketplace_registered + || !verified.plugin_installed + || !verified.plugin_enabled + || verified.installed_version.as_deref() != Some(bundle.plugin_version.as_str()) + { + let error = invalid_data( + "Codex did not install the exact validated abtop plugin payload and enable it after setup", + ); + let mut cleanup = Vec::new(); + if !prior.plugin_configured { + if let Err(cleanup_error) = + remove_plugin_cli(&codex_binary, &codex_binary_digest, &codex_home) + { + cleanup.push(cleanup_error); + } + } + if marketplace_added { + if let Err(cleanup_error) = remove_marketplace_if_owned( + &paths, + &codex_binary, + &codex_binary_digest, + &codex_home, + ) { + cleanup.push(cleanup_error); + } + } + return Err(with_cleanup_errors(error, cleanup)); + } + + let base = inspect_base_hook_state(&paths, &bundle)?; + Ok(SetupReport { + paths, + codex_binary, + abtop_binary, + helper_digest: bundle.helper_digest, + plugin_version: bundle.plugin_version, + hook_schema_revision: HOOK_SCHEMA_REVISION, + hook_count: HOOK_EVENTS.len(), + base_config_trusted_hooks: base.trusted, + base_config_enabled_hooks: base.enabled, + review_required: base.trusted != HOOK_EVENTS.len() || base.enabled != HOOK_EVENTS.len(), + legacy_cleanup: MigrationReport::default(), + }) +} + +fn validate_setup_registration(prior: &CliState) -> io::Result<()> { + if let Some(conflict) = prior.marketplace_conflict.as_ref() { + return Err(invalid_data(format!( + "Codex marketplace `{MARKETPLACE_NAME}` is already registered from {}, not abtop's isolated marketplace; remove or rename that conflicting marketplace first", + conflict.display() + ))); + } + if prior.marketplace_malformed || prior.plugin_config_malformed { + return Err(invalid_data( + "the existing abtop Codex registration is malformed; run `abtop --uninstall-codex` for guarded recovery, and repair the named abtop entry in CODEX_HOME/config.toml manually if ownership cannot be proved", + )); + } + Ok(()) +} + +pub fn status() -> io::Result { + let codex_home = current_codex_home()?; + let paths = PluginPaths::new(&normalize_existing_or_lexical(&codex_home)?)?; + let abtop_binary = current_abtop_binary().ok(); + let bundle = abtop_binary + .as_ref() + .and_then(|path| render_bundle(path, &paths.plugin_data_root).ok()); + let (codex_binary, compatibility_error) = match resolve_codex_binary() + .and_then(|binary| validate_codex_binary_compatibility(&binary, &paths.codex_home)) + { + Ok(binary) => (Some(binary), None), + Err(error) => (None, Some(error.to_string())), + }; + status_with_parts(paths, codex_binary, compatibility_error, bundle) +} + +#[allow(dead_code)] +pub fn status_with( + codex_home: &Path, + codex_binary: &Path, + abtop_binary: &Path, +) -> io::Result { + let paths = PluginPaths::new(&normalize_existing_or_lexical(codex_home)?)?; + let (codex_binary, compatibility_error) = + match validate_codex_binary_compatibility(codex_binary, &paths.codex_home) { + Ok(binary) => (Some(binary), None), + Err(error) => (None, Some(error.to_string())), + }; + let bundle = render_bundle(abtop_binary, &paths.plugin_data_root).ok(); + status_with_parts(paths, codex_binary, compatibility_error, bundle) +} + +fn status_with_parts( + paths: PluginPaths, + codex_binary: Option, + compatibility_error: Option, + bundle: Option, +) -> io::Result { + let mut details = Vec::new(); + let expected_version = bundle.as_ref().map(|bundle| bundle.plugin_version.clone()); + let (legacy_marker_files, legacy_inspection_valid) = + match migration::inspect_legacy_shell_integration() { + Ok(files) => (files, true), + Err(error) => { + details.push(format!("legacy shell inspection failed: {error}")); + (Vec::new(), false) + } + }; + if !legacy_marker_files.is_empty() { + details.push("the retired managed-Codex shell marker is still present".to_string()); + } + + let config = match read_base_config(&paths) { + Ok(config) => Some(config), + Err(error) => { + details.push(format!("base Codex config inspection failed: {error}")); + None + } + }; + + let (bundle_valid, attestation_valid, helper) = match &bundle { + Some(bundle) => { + let bundle_valid = bundle_matches_disk(&paths, bundle).unwrap_or_else(|error| { + details.push(format!("plugin bundle validation failed: {error}")); + false + }); + let attestation_valid = attestation_matches(&paths, bundle).unwrap_or_else(|error| { + details.push(format!("plugin attestation validation failed: {error}")); + false + }); + ( + bundle_valid, + attestation_valid, + Some(bundle.helper_digest.clone()), + ) + } + None => { + details.push("the current abtop helper identity could not be validated".to_string()); + (false, false, None) + } + }; + + let base = match (config.as_ref(), bundle.as_ref()) { + (Some(config), Some(bundle)) => inspect_base_hook_state_from_config(config, bundle), + _ => BaseHookState::default(), + }; + let base_runtime_config_safe = config.as_ref().is_some_and(base_runtime_hook_config_safe); + if config.as_ref().is_some_and(base_config_lock_selected) { + details.push( + "the base Codex config selects a config lock, so complete runtime hook coverage cannot be established" + .to_string(), + ); + } + if config + .as_ref() + .is_some_and(|config| !base_hook_features_enabled(config)) + { + details.push("the base Codex config disables a required hook/plugin feature".to_string()); + } + let cli = match config.as_ref() { + Some(config) => inspect_status_config_state(&paths, bundle.as_ref(), config, &mut details), + None => CliState::default(), + }; + if let Some(error) = compatibility_error { + details.push(format!( + "native Codex compatibility preflight failed: {error}" + )); + } + if cli.marketplace_conflict.is_some() { + details.push("the abtop-local marketplace name points at another source".to_string()); + } else if cli.marketplace_malformed { + details.push("the abtop-local marketplace registration is malformed".to_string()); + } else if !cli.marketplace_registered { + details.push("the abtop-local marketplace is not registered".to_string()); + } + if cli.plugin_config_malformed { + details.push("the abtop plugin registration is malformed".to_string()); + } + if !cli.plugin_installed { + details.push("the abtop plugin is not installed".to_string()); + } else if cli.installed_version.as_ref() != expected_version.as_ref() { + details.push(format!( + "installed plugin version {} does not match the current helper version {}", + cli.installed_version.as_deref().unwrap_or(""), + expected_version.as_deref().unwrap_or("") + )); + } + if base.trusted != HOOK_EVENTS.len() { + details.push(format!( + "base config trusts {}/{} abtop hooks; a new native Codex session must review the remainder", + base.trusted, + HOOK_EVENTS.len() + )); + } + if base.enabled != HOOK_EVENTS.len() { + details.push(format!( + "base config enables {}/{} abtop hooks", + base.enabled, + HOOK_EVENTS.len() + )); + } + + let healthy = cli.marketplace_registered + && cli.plugin_installed + && cli.plugin_enabled + && cli.installed_version.as_ref() == expected_version.as_ref() + && bundle_valid + && attestation_valid + && base_runtime_config_safe + && base.trusted == HOOK_EVENTS.len() + && base.enabled == HOOK_EVENTS.len() + && legacy_marker_files.is_empty() + && legacy_inspection_valid + && codex_binary.is_some(); + Ok(IntegrationStatus { + paths, + codex_binary, + helper_digest: helper, + hook_schema_revision: HOOK_SCHEMA_REVISION, + hook_count: HOOK_EVENTS.len(), + marketplace_registered: cli.marketplace_registered, + plugin_installed: cli.plugin_installed, + plugin_enabled: cli.plugin_enabled, + installed_version: cli.installed_version, + bundle_valid, + attestation_valid, + base_config_trusted_hooks: base.trusted, + base_config_enabled_hooks: base.enabled, + base_config_state_entries: base.entries, + legacy_marker_files, + legacy_inspection_valid, + healthy, + details, + }) +} + +pub fn uninstall() -> io::Result { + let codex_home = current_codex_home()?; + let codex_binary = resolve_codex_binary()?; + uninstall_with(&codex_home, &codex_binary) +} + +pub fn uninstall_with(codex_home: &Path, codex_binary: &Path) -> io::Result { + let mut legacy = LegacyCleanupTransaction::begin()?; + let result = uninstall_after_legacy_cleanup(codex_home, codex_binary); + match result { + Ok(mut report) => { + report.legacy_cleanup = legacy.commit(); + Ok(report) + } + Err(error) => match legacy.rollback() { + Ok(()) => Err(error), + Err(rollback_error) => Err(io::Error::new( + error.kind(), + format!( + "{error}; additionally failed to restore the legacy shell integration: {rollback_error}" + ), + )), + }, + } +} + +fn uninstall_after_legacy_cleanup( + codex_home: &Path, + codex_binary: &Path, +) -> io::Result { + let codex_home = normalize_existing_or_lexical(codex_home)?; + let paths = PluginPaths::new(&codex_home)?; + let setup_lock = SetupLock::acquire(&paths.codex_home)?; + // Uninstall deliberately accepts future native Codex versions: the + // idempotent remove command is the recovery path when an upgrade makes the + // currently supported hook contract unavailable. + let (codex_binary, codex_binary_digest) = + capture_codex_binary_identity(codex_binary, &codex_home)?; + setup_lock.revalidate()?; + + // Codex documents plugin removal as idempotent, and it remains usable when + // the marketplace source or snapshot is already gone. Always issue it so a + // partially broken prior installation can recover without a global list. + remove_plugin_cli(&codex_binary, &codex_binary_digest, &codex_home)?; + ensure_plugin_absent(&paths)?; + let plugin_removed = true; + + // Source safety must never prevent the unconditional reserved-plugin + // removal above. It gates only marketplace/source cleanup. + audit_owned_source_tree(&paths, false)?; + let marketplace_removed = + remove_marketplace_if_owned(&paths, &codex_binary, &codex_binary_digest, &codex_home)?; + // Re-audit after invoking Codex and immediately before path-based leaf + // deletion. Any substituted ancestor, symlink, or unexpected capability + // fails closed and is preserved for manual inspection. + ensure_plugin_absent(&paths)?; + ensure_marketplace_absent(&paths)?; + setup_lock.revalidate()?; + audit_owned_source_tree(&paths, false)?; + let source_files_removed = remove_owned_bundle_files(&paths)?; + ensure_plugin_absent(&paths)?; + ensure_marketplace_absent(&paths)?; + setup_lock.revalidate()?; + Ok(UninstallReport { + preserved_data_root: paths.plugin_data_root.clone(), + paths, + codex_binary, + plugin_removed, + marketplace_removed, + source_files_removed, + legacy_cleanup: MigrationReport::default(), + }) +} + +/// Return a digest that binds the hook schema, helper interface revision, +/// exact abtop executable path, and executable contents. +pub fn helper_digest(abtop_binary: &Path) -> io::Result { + let binary = validate_abtop_binary(abtop_binary)?; + let path = binary.to_str().ok_or_else(|| { + invalid_data("the abtop executable path must be valid UTF-8 for hook installation") + })?; + validate_embedded_text(path, "abtop executable path")?; + let executable_digest = hash_file(&binary)?; + let mut hasher = Sha256::new(); + hasher.update(HELPER_IDENTITY_REVISION.as_bytes()); + hasher.update([0]); + hasher.update(HOOK_SCHEMA_REVISION.as_bytes()); + hasher.update([0]); + hasher.update(path.as_bytes()); + hasher.update([0]); + hasher.update(executable_digest.as_bytes()); + Ok(format!("sha256:{}", hex(&hasher.finalize()))) +} + +/// Read the content-free installation identity without invoking Codex or +/// hashing the helper executable. Runtime ingestion and collection use this +/// as the stable generation boundary; full helper/bundle verification remains +/// the responsibility of [`status`] and setup. +pub fn read_installation_attestation( + codex_home: &Path, +) -> io::Result> { + let paths = PluginPaths::new(&normalize_existing_or_lexical(codex_home)?)?; + let Some(bytes) = read_installation_attestation_bytes(&paths)? else { + return Ok(None); + }; + let attestation: InstallationAttestation = serde_json::from_slice(&bytes) + .map_err(|error| invalid_data(format!("invalid installation attestation: {error}")))?; + if valid_attestation_shape(&attestation) { + Ok(Some(attestation)) + } else { + Err(invalid_data( + "installation attestation has an invalid identity shape", + )) + } +} + +#[allow(dead_code)] +pub fn read_current_installation_attestation() -> io::Result> { + read_installation_attestation(¤t_codex_home()?) +} + +/// Validate the static, base-config portion of runtime hook identity without +/// invoking Codex or mutating its configuration. +/// +/// In the supported Codex hook contract, individual hook state is merged only +/// from the base/selected User layer and SessionFlags. This function validates the unprofiled base +/// layer only; process/runtime correlation must separately reject profiles, +/// relevant session flags, config locks, and any lifecycle without fresh hook +/// evidence. It is not a proof of the complete effective config stack. +pub(crate) fn runtime_hook_config( + codex_home: &Path, + abtop_binary: &Path, +) -> io::Result { + ensure_hook_state_platform_supported()?; + let paths = PluginPaths::new(&normalize_existing_or_lexical(codex_home)?)?; + let bundle = render_bundle(abtop_binary, &paths.plugin_data_root)?; + let (config_bytes, config) = read_base_config_snapshot(&paths)?; + let mut identity = Sha256::new(); + identity.update(&config_bytes); + identity.update([0]); + identity.update(bundle.hooks_digest.as_bytes()); + identity.update([0]); + identity.update(bundle.plugin_version.as_bytes()); + let base = inspect_base_hook_state_from_config(&config, &bundle); + Ok(RuntimeHookConfig { + config_digest: format!("sha256:{}", hex(&identity.finalize())), + complete_hook_set: bundle_matches_disk(&paths, &bundle)? + && cached_bundle_matches_disk(&paths, &bundle)? + && attestation_matches(&paths, &bundle)? + && base_runtime_hook_config_safe(&config) + && base.trusted == HOOK_EVENTS.len() + && base.enabled == HOOK_EVENTS.len(), + }) +} + +fn base_runtime_hook_config_safe(config: &toml::Value) -> bool { + base_plugin_enabled(config) + && base_hook_features_enabled(config) + && !base_config_lock_selected(config) +} + +fn base_plugin_enabled(config: &toml::Value) -> bool { + config + .get("plugins") + .and_then(toml::Value::as_table) + .and_then(|plugins| plugins.get(PLUGIN_ID)) + .and_then(toml::Value::as_table) + .is_some_and(|plugin| plugin.get("enabled").and_then(toml::Value::as_bool) != Some(false)) +} + +fn base_hook_features_enabled(config: &toml::Value) -> bool { + let Some(features) = config.get("features") else { + return true; + }; + let Some(features) = features.as_table() else { + return false; + }; + ["hooks", "codex_hooks", "plugins"] + .iter() + .all(|name| features.get(*name).and_then(toml::Value::as_bool) != Some(false)) +} + +fn base_config_lock_selected(config: &toml::Value) -> bool { + config + .get("debug") + .and_then(|debug| debug.get("config_lockfile")) + .and_then(|lock| lock.get("load_path")) + .is_some() +} + +fn valid_attestation_shape(attestation: &InstallationAttestation) -> bool { + attestation.schema_version == 1 + && attestation.hook_schema_revision == HOOK_SCHEMA_REVISION + && valid_sha256_digest(&attestation.helper_digest) + && attestation.installation_id.len() == 32 + && attestation + .installation_id + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + && attestation.plugin_id == PLUGIN_ID + && !attestation.plugin_version.is_empty() + && valid_sha256_digest(&attestation.hooks_digest) + && attestation.hook_events + == HOOK_EVENTS + .iter() + .map(|event| (*event).to_string()) + .collect::>() +} + +fn valid_sha256_digest(value: &str) -> bool { + value.len() == "sha256:".len() + 64 + && value.starts_with("sha256:") + && value["sha256:".len()..] + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) +} + +fn render_bundle(abtop_binary: &Path, plugin_data_root: &Path) -> io::Result { + let abtop_binary = validate_abtop_binary(abtop_binary)?; + let plugin_data_root = normalize_absolute(plugin_data_root)?; + let helper_digest = helper_digest(&abtop_binary)?; + let suffix = helper_digest + .strip_prefix("sha256:") + .unwrap_or(&helper_digest) + .chars() + .take(12) + .collect::(); + let plugin_version = format!("{}+codex.{suffix}", env!("CARGO_PKG_VERSION")); + let command = format!( + "\"$PLUGIN_ROOT/scripts/abtop-codex-hook.sh\" --schema-revision {HOOK_SCHEMA_REVISION} --helper-digest {helper_digest} >/dev/null 2>&1 || :; exit 0" + ); + let command_windows = format!( + "cmd.exe /D /C call \"%PLUGIN_ROOT%\\scripts\\abtop-codex-hook.cmd\" --schema-revision {HOOK_SCHEMA_REVISION} --helper-digest {helper_digest} >nul 2>nul || exit /b 0" + ); + let hook_commands = HOOK_EVENTS + .iter() + .map(|event| HookCommandIdentity { + event, + event_key: hook_event_key(event), + command: command.clone(), + command_windows: command_windows.clone(), + }) + .collect::>(); + + let mut hooks = serde_json::Map::new(); + for identity in &hook_commands { + hooks.insert( + identity.event.to_string(), + json!([{ + "hooks": [{ + "type": "command", + "command": identity.command, + "commandWindows": identity.command_windows, + "timeout": 1 + }] + }]), + ); + } + let hooks_manifest = pretty_json(&json!({ + "description": "Content-free lifecycle signals for the local abtop agent monitor.", + "hooks": hooks + }))?; + let hooks_digest = hash_bytes(&hooks_manifest); + + let marketplace_manifest = pretty_json(&json!({ + "name": MARKETPLACE_NAME, + "interface": { "displayName": "abtop Local" }, + "plugins": [{ + "name": PLUGIN_NAME, + "source": { "source": "local", "path": "./plugins/abtop" }, + "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, + "category": "Productivity" + }] + }))?; + let plugin_manifest = pretty_json(&json!({ + "name": PLUGIN_NAME, + "version": plugin_version, + "description": "Reports content-free Codex lifecycle events to the local abtop monitor.", + "author": { "name": "abtop contributors" }, + "homepage": "https://github.com/graykode/abtop", + "repository": "https://github.com/graykode/abtop", + "license": "MIT", + "keywords": ["monitoring", "codex", "terminal"], + "interface": { + "displayName": "abtop", + "shortDescription": "Local lifecycle signals for the abtop monitor.", + "longDescription": "Reports content-free Codex lifecycle events to the local abtop terminal monitor.", + "developerName": "abtop contributors", + "category": "Productivity", + "capabilities": [], + "defaultPrompt": ["Show the current abtop monitoring status."] + } + }))?; + + let binary = abtop_binary.to_str().ok_or_else(|| { + invalid_data("the abtop executable path must be valid UTF-8 for hook installation") + })?; + let launcher_nonce = helper_digest + .strip_prefix("sha256:") + .unwrap_or(&helper_digest) + .chars() + .take(16) + .collect::(); + let fault_directory = plugin_data_root + .join(HOOK_STATE_DIR_NAME) + .join(HOOK_FAULT_DIR_NAME); + let fault_directory_text = fault_directory.to_str().ok_or_else(|| { + invalid_data("the Codex hook fault directory must be valid UTF-8 for hook installation") + })?; + validate_embedded_text(fault_directory_text, "Codex hook fault directory")?; + let quoted_fault_directory = quote_posix(fault_directory_text); + let quoted_binary = quote_posix(binary); + let posix_launcher = format!( + "#!/bin/sh\n# Generated by abtop. Do not add output or provider content.\n[ \"${{1-}}\" = '--schema-revision' ] || exit 0\n[ \"${{2-}}\" = '{HOOK_SCHEMA_REVISION}' ] || exit 0\n[ \"${{3-}}\" = '--helper-digest' ] || exit 0\n[ \"${{4-}}\" = '{helper_digest}' ] || exit 0\nunset {HOOK_FAULT_TOKEN_ENV}\nabtop_fault_dir={quoted_fault_directory}\nabtop_fault_path=\nif abtop_fault_path=$(umask 077; mktemp \"$abtop_fault_dir/launch-$$-pending.XXXXXXXXXXXXXXXX\" 2>/dev/null); then\n abtop_fault_token=${{abtop_fault_path##*/}}\n abtop_fault_nonce=${{abtop_fault_token#launch-$$-pending.}}\n case \"$abtop_fault_path\" in\n \"$abtop_fault_dir/launch-$$-pending.\"*)\n case \"$abtop_fault_nonce\" in\n ''|*[!A-Za-z0-9]*) ;;\n *)\n {HOOK_FAULT_TOKEN_ENV}=$abtop_fault_token\n export {HOOK_FAULT_TOKEN_ENV}\n ;;\n esac\n ;;\n esac\nfi\nif [ -z \"${{{HOOK_FAULT_TOKEN_ENV}-}}\" ]; then\n abtop_fault_slot=0\n while [ \"$abtop_fault_slot\" -lt 16 ]; do\n abtop_fault_token=launch-$abtop_fault_slot-abtopv1.pending\n if (umask 077; set -C; : > \"$abtop_fault_dir/$abtop_fault_token\") 2>/dev/null; then\n {HOOK_FAULT_TOKEN_ENV}=$abtop_fault_token\n export {HOOK_FAULT_TOKEN_ENV}\n break\n fi\n abtop_fault_slot=$((abtop_fault_slot + 1))\n done\nfi\nif [ -z \"${{{HOOK_FAULT_TOKEN_ENV}-}}\" ]; then\n (umask 077; set -C; : > \"$abtop_fault_dir/overflow.json\") 2>/dev/null || :\nfi\n{quoted_binary} --codex-hook-ingest --schema-revision '{HOOK_SCHEMA_REVISION}' --helper-digest '{helper_digest}' >/dev/null 2>&1 || :\nexit 0\n" + ) + .into_bytes(); + let windows_binary = quote_cmd_path(binary)?; + let windows_fault_directory = escape_cmd_set_value(fault_directory_text)?; + let windows_launcher = format!( + "@echo off\r\nrem Generated by abtop. Do not add output or provider content.\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\nif not \"%~1\"==\"--schema-revision\" exit /b 0\r\nif not \"%~2\"==\"{HOOK_SCHEMA_REVISION}\" exit /b 0\r\nif not \"%~3\"==\"--helper-digest\" exit /b 0\r\nif not \"%~4\"==\"{helper_digest}\" exit /b 0\r\nset \"{HOOK_FAULT_TOKEN_ENV}=\"\r\nset \"abtop_fault_dir={windows_fault_directory}\"\r\ncall :abtop_create_fault_marker\r\n{windows_binary} --codex-hook-ingest --schema-revision {HOOK_SCHEMA_REVISION} --helper-digest {helper_digest} >nul 2>nul\r\nexit /b 0\r\n\r\n:abtop_create_fault_marker\r\nif not exist \"%abtop_fault_dir%\\.\" exit /b 0\r\nset /a abtop_fault_attempt=0 >nul 2>nul\r\n:abtop_create_fault_marker_retry\r\nset \"abtop_fault_token=launch-%RANDOM%-{launcher_nonce}%RANDOM%%RANDOM%.pending\"\r\n\"%SystemRoot%\\System32\\fsutil.exe\" file createnew \"%abtop_fault_dir%\\%abtop_fault_token%\" 0 >nul 2>nul\r\nif errorlevel 1 goto abtop_create_fault_marker_failed\r\nset \"{HOOK_FAULT_TOKEN_ENV}=%abtop_fault_token%\"\r\nexit /b 0\r\n:abtop_create_fault_marker_failed\r\nset /a abtop_fault_attempt+=1 >nul 2>nul\r\nif %abtop_fault_attempt% LSS 16 goto abtop_create_fault_marker_retry\r\nexit /b 0\r\n" + ) + .into_bytes(); + + Ok(RenderedBundle { + helper_digest, + plugin_version, + marketplace_manifest, + plugin_manifest, + hooks_manifest, + posix_launcher, + windows_launcher, + hooks_digest, + hook_commands, + }) +} + +fn write_bundle(paths: &PluginPaths, bundle: &RenderedBundle) -> io::Result<()> { + #[cfg(unix)] + { + write_bundle_unix(paths, bundle) + } + #[cfg(not(unix))] + { + write_bundle_portable(paths, bundle) + } +} + +#[cfg(unix)] +fn write_bundle_unix(paths: &PluginPaths, bundle: &RenderedBundle) -> io::Result<()> { + let home = open_unix_directory(&paths.codex_home, false)?; + let source_root_path = paths.codex_home.join("abtop"); + let source_root = + ensure_managed_directory_at(&home, "abtop", &source_root_path, true, Some(0o700))?; + let marketplace_path = source_root_path.join("marketplace"); + let marketplace = ensure_managed_directory_at( + &source_root, + "marketplace", + &marketplace_path, + true, + Some(0o700), + )?; + let agents_path = marketplace_path.join(".agents"); + let agents = + ensure_managed_directory_at(&marketplace, ".agents", &agents_path, true, Some(0o700))?; + let agents_plugins_path = agents_path.join("plugins"); + let agents_plugins = + ensure_managed_directory_at(&agents, "plugins", &agents_plugins_path, true, Some(0o700))?; + let source_plugins_path = marketplace_path.join("plugins"); + let source_plugins = ensure_managed_directory_at( + &marketplace, + "plugins", + &source_plugins_path, + true, + Some(0o700), + )?; + let plugin_root_path = source_plugins_path.join(PLUGIN_NAME); + let plugin_root = ensure_managed_directory_at( + &source_plugins, + PLUGIN_NAME, + &plugin_root_path, + true, + Some(0o700), + )?; + let manifest_path = plugin_root_path.join(".codex-plugin"); + let manifest_directory = ensure_managed_directory_at( + &plugin_root, + ".codex-plugin", + &manifest_path, + true, + Some(0o700), + )?; + let hooks_path = plugin_root_path.join("hooks"); + let hooks_directory = + ensure_managed_directory_at(&plugin_root, "hooks", &hooks_path, true, Some(0o700))?; + let scripts_path = plugin_root_path.join("scripts"); + let scripts_directory = + ensure_managed_directory_at(&plugin_root, "scripts", &scripts_path, true, Some(0o700))?; + + let plugins_path = paths.codex_home.join("plugins"); + let plugins = ensure_managed_directory_at(&home, "plugins", &plugins_path, false, None)?; + let data_path = plugins_path.join("data"); + let data = ensure_managed_directory_at(&plugins, "data", &data_path, false, None)?; + let data_root = ensure_managed_directory_at( + &data, + "abtop-abtop-local", + &paths.plugin_data_root, + true, + Some(0o700), + )?; + let states_path = paths.plugin_data_root.join(HOOK_STATE_DIR_NAME); + let states = ensure_managed_directory_at( + &data_root, + HOOK_STATE_DIR_NAME, + &states_path, + true, + Some(0o700), + )?; + let faults_path = states_path.join(HOOK_FAULT_DIR_NAME); + ensure_managed_directory_at( + &states, + HOOK_FAULT_DIR_NAME, + &faults_path, + true, + Some(0o700), + )?; + + atomic_write_private_at( + &agents_plugins, + "marketplace.json", + &paths.marketplace_manifest, + &bundle.marketplace_manifest, + false, + )?; + atomic_write_private_at( + &manifest_directory, + "plugin.json", + &paths.plugin_manifest, + &bundle.plugin_manifest, + false, + )?; + atomic_write_private_at( + &hooks_directory, + "hooks.json", + &paths.hooks_manifest, + &bundle.hooks_manifest, + false, + )?; + atomic_write_private_at( + &scripts_directory, + "abtop-codex-hook.sh", + &paths.posix_launcher, + &bundle.posix_launcher, + true, + )?; + atomic_write_private_at( + &scripts_directory, + "abtop-codex-hook.cmd", + &paths.windows_launcher, + &bundle.windows_launcher, + false, + )?; + if !bundle_matches_disk_unix(paths, bundle)? { + return Err(invalid_data( + "the live managed plugin source tree did not match the payload after writing", + )); + } + Ok(()) +} + +#[cfg(not(unix))] +fn write_bundle_portable(paths: &PluginPaths, bundle: &RenderedBundle) -> io::Result<()> { + // Keep this explicit rather than using create_dir_all: every component is + // checked for ownership and symlink substitution before the next one is + // created. + for directory in [ + paths.codex_home.join("abtop"), + paths.marketplace_root.clone(), + paths.marketplace_root.join(".agents"), + paths.marketplace_manifest.parent().unwrap().to_path_buf(), + paths.marketplace_root.join("plugins"), + paths.plugin_root.clone(), + paths.plugin_manifest.parent().unwrap().to_path_buf(), + paths.hooks_manifest.parent().unwrap().to_path_buf(), + paths.posix_launcher.parent().unwrap().to_path_buf(), + ] { + ensure_private_dir(&directory)?; + } + ensure_private_data_ancestry(paths)?; + ensure_private_dir(&paths.plugin_data_root)?; + ensure_private_dir(&paths.plugin_data_root.join(HOOK_STATE_DIR_NAME))?; + ensure_private_dir( + &paths + .plugin_data_root + .join(HOOK_STATE_DIR_NAME) + .join(HOOK_FAULT_DIR_NAME), + )?; + + atomic_write_private( + &paths.marketplace_manifest, + &bundle.marketplace_manifest, + false, + )?; + atomic_write_private(&paths.plugin_manifest, &bundle.plugin_manifest, false)?; + atomic_write_private(&paths.hooks_manifest, &bundle.hooks_manifest, false)?; + atomic_write_private(&paths.posix_launcher, &bundle.posix_launcher, true)?; + atomic_write_private(&paths.windows_launcher, &bundle.windows_launcher, false)?; + Ok(()) +} + +fn write_attestation(paths: &PluginPaths, bundle: &RenderedBundle) -> io::Result<()> { + #[cfg(unix)] + { + write_attestation_unix(paths, bundle) + } + #[cfg(not(unix))] + { + let prior = read_private_regular_file(&paths.install_attestation)?; + let bytes = render_attestation_bytes(prior, bundle)?; + atomic_write_private(&paths.install_attestation, &bytes, false) + } +} + +fn render_attestation_bytes( + prior: Option>, + bundle: &RenderedBundle, +) -> io::Result> { + let prior = prior + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .filter(|attestation| attestation_identity_matches(attestation, bundle)); + let (installation_id, installed_at_unix_ms) = match prior { + Some(attestation) if !attestation.installation_id.is_empty() => ( + attestation.installation_id, + attestation.installed_at_unix_ms, + ), + _ => { + let mut random = [0_u8; 16]; + getrandom::fill(&mut random).map_err(io::Error::other)?; + let installed_at_unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX); + (hex(&random), installed_at_unix_ms) + } + }; + let attestation = InstallationAttestation { + schema_version: 1, + hook_schema_revision: HOOK_SCHEMA_REVISION.to_string(), + helper_digest: bundle.helper_digest.clone(), + installation_id, + plugin_id: PLUGIN_ID.to_string(), + plugin_version: bundle.plugin_version.clone(), + hooks_digest: bundle.hooks_digest.clone(), + hook_events: HOOK_EVENTS + .iter() + .map(|event| (*event).to_string()) + .collect(), + installed_at_unix_ms, + }; + pretty_json(&serde_json::to_value(attestation).map_err(io::Error::other)?) +} + +#[cfg(unix)] +fn write_attestation_unix(paths: &PluginPaths, bundle: &RenderedBundle) -> io::Result<()> { + let home = open_unix_directory(&paths.codex_home, false)?; + let home_metadata = home.metadata()?; + let plugins_path = paths.codex_home.join("plugins"); + let plugins = ensure_managed_directory_at(&home, "plugins", &plugins_path, false, None)?; + let data_path = plugins_path.join("data"); + let data = ensure_managed_directory_at(&plugins, "data", &data_path, false, None)?; + let data_root = ensure_managed_directory_at( + &data, + "abtop-abtop-local", + &paths.plugin_data_root, + true, + Some(0o700), + )?; + let prior = read_private_regular_file_at( + &data_root, + INSTALL_ATTESTATION_FILE, + &paths.install_attestation, + )?; + let bytes = render_attestation_bytes(prior, bundle)?; + atomic_write_private_at( + &data_root, + INSTALL_ATTESTATION_FILE, + &paths.install_attestation, + &bytes, + false, + )?; + let pinned_attestation = open_matching_managed_file_at( + &data_root, + INSTALL_ATTESTATION_FILE, + &paths.install_attestation, + &bytes, + false, + true, + )? + .ok_or_else(|| invalid_data("installation attestation disappeared after writing"))?; + + let rebound_home = open_unix_directory(&paths.codex_home, false)?; + if !same_file_metadata(&home_metadata, &rebound_home.metadata()?) { + return Err(invalid_data( + "CODEX_HOME changed while the installation attestation was written", + )); + } + let rebound_plugins = + reopen_same_directory_at(&rebound_home, "plugins", &plugins, &plugins_path, false)?; + let rebound_data = + reopen_same_directory_at(&rebound_plugins, "data", &data, &data_path, false)?; + let rebound_data_root = reopen_same_directory_at( + &rebound_data, + "abtop-abtop-local", + &data_root, + &paths.plugin_data_root, + true, + )?; + reopen_same_file_at( + &rebound_data_root, + INSTALL_ATTESTATION_FILE, + &pinned_attestation, + &paths.install_attestation, + &bytes, + false, + true, + )?; + Ok(()) +} + +fn inspect_config_state( + paths: &PluginPaths, + bundle: Option<&RenderedBundle>, +) -> io::Result { + let config = read_base_config(paths)?; + inspect_config_state_from_config(paths, bundle, &config) +} + +fn inspect_config_state_from_config( + paths: &PluginPaths, + bundle: Option<&RenderedBundle>, + config: &toml::Value, +) -> io::Result { + let mut state = inspect_registration_state_from_config(paths, config); + + if let Some(bundle) = bundle { + let cache_valid = cached_bundle_matches_disk(paths, bundle)?; + if cache_version_path(paths, &bundle.plugin_version).exists() { + state.installed_version = Some(bundle.plugin_version.clone()); + } + state.plugin_installed = state.plugin_configured && cache_valid; + } + Ok(state) +} + +fn inspect_registration_state_from_config(paths: &PluginPaths, config: &toml::Value) -> CliState { + let mut state = CliState::default(); + + if let Some(registration) = config + .get("marketplaces") + .and_then(|marketplaces| marketplaces.get(MARKETPLACE_NAME)) + { + match registration.as_table() { + Some(table) => { + let source_type = table.get("source_type").and_then(toml::Value::as_str); + let source = table.get("source").and_then(toml::Value::as_str); + match (source_type, source) { + (Some("local"), Some(source)) => { + let source_path = PathBuf::from(source); + if !source_path.is_absolute() { + state.marketplace_malformed = true; + } else if paths_equal(&source_path, &paths.marketplace_root) { + state.marketplace_registered = true; + } else { + state.marketplace_conflict = Some(source_path); + } + } + (Some(source_type), Some(source)) if source_type != "local" => { + state.marketplace_conflict = + Some(PathBuf::from(format!("<{source_type} source: {source}>"))); + } + _ => state.marketplace_malformed = true, + } + } + None => state.marketplace_malformed = true, + } + } + + if let Some(plugin) = config + .get("plugins") + .and_then(|plugins| plugins.get(PLUGIN_ID)) + { + state.plugin_configured = true; + match plugin.as_table() { + Some(table) => match table.get("enabled") { + Some(value) => match value.as_bool() { + Some(enabled) => state.plugin_enabled = enabled, + None => state.plugin_config_malformed = true, + }, + None => state.plugin_enabled = true, + }, + None => state.plugin_config_malformed = true, + } + } + + state +} + +fn inspect_status_config_state( + paths: &PluginPaths, + bundle: Option<&RenderedBundle>, + config: &toml::Value, + details: &mut Vec, +) -> CliState { + match inspect_config_state_from_config(paths, bundle, config) { + Ok(state) => state, + Err(error) => { + let mut state = inspect_registration_state_from_config(paths, config); + let stale_version = bundle.and_then(|bundle| { + attested_cached_plugin_version(paths, &bundle.plugin_version).unwrap_or_else( + |stale_error| { + details.push(format!( + "installed plugin version inspection failed: {stale_error}" + )); + None + }, + ) + }); + if let Some(version) = stale_version { + state.plugin_installed = state.plugin_configured; + state.installed_version = Some(version); + } else { + details.push(format!( + "Codex plugin installation inspection failed: {error}" + )); + } + state + } + } +} + +fn read_base_config(paths: &PluginPaths) -> io::Result { + read_base_config_snapshot(paths).map(|(_bytes, config)| config) +} + +fn read_base_config_snapshot(paths: &PluginPaths) -> io::Result<(Vec, toml::Value)> { + let config_path = paths.codex_home.join("config.toml"); + let metadata = match fs::symlink_metadata(&config_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok((Vec::new(), toml::Value::Table(Default::default()))); + } + Err(error) => return Err(error), + }; + validate_owned_regular_file(&config_path, &metadata, false)?; + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = options.open(&config_path)?; + let opened = file.metadata()?; + if !same_file_content_snapshot(&metadata, &opened) { + return Err(invalid_data(format!( + "{} changed while it was opened", + config_path.display() + ))); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + (&mut file) + .take(MAX_MANAGED_FILE + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_MANAGED_FILE { + return Err(invalid_data(format!( + "{} exceeds the 4 MiB inspection limit", + config_path.display() + ))); + } + let descriptor_after = file.metadata()?; + let after = fs::symlink_metadata(&config_path)?; + if bytes.len() as u64 != metadata.len() + || !same_file_content_snapshot(&metadata, &descriptor_after) + || !same_file_content_snapshot(&metadata, &after) + { + return Err(invalid_data(format!( + "{} changed while it was inspected", + config_path.display() + ))); + } + let text = std::str::from_utf8(&bytes) + .map_err(|_| invalid_data(format!("{} is not valid UTF-8", config_path.display())))?; + let config = if text.trim().is_empty() { + toml::Value::Table(Default::default()) + } else { + toml::from_str(text).map_err(|error| { + invalid_data(format!( + "{} contains invalid TOML: {error}", + config_path.display() + )) + })? + }; + Ok((bytes, config)) +} + +fn inspect_base_hook_state( + paths: &PluginPaths, + bundle: &RenderedBundle, +) -> io::Result { + let config = read_base_config(paths)?; + Ok(inspect_base_hook_state_from_config(&config, bundle)) +} + +fn inspect_base_hook_state_from_config( + config: &toml::Value, + bundle: &RenderedBundle, +) -> BaseHookState { + let states = config + .get("hooks") + .and_then(|hooks| hooks.get("state")) + .and_then(toml::Value::as_table); + let mut result = BaseHookState::default(); + for identity in &bundle.hook_commands { + let key = format!("{PLUGIN_ID}:hooks/hooks.json:{}:0:0", identity.event_key); + let state = states.and_then(|states| states.get(&key)); + if state.is_some() { + result.entries += 1; + } + let enabled = state + .and_then(|state| state.get("enabled")) + .and_then(toml::Value::as_bool) + != Some(false); + if enabled { + result.enabled += 1; + } + let trusted = state + .and_then(|state| state.get("trusted_hash")) + .and_then(toml::Value::as_str); + if trusted == Some(expected_trust_hash(identity).as_str()) { + result.trusted += 1; + } + } + result +} + +fn expected_trust_hash(identity: &HookCommandIdentity) -> String { + // Mirrors the supported Codex contract's normalized command-hook identity: + // no matcher, one synchronous command handler, an explicit one-second timeout, and + // only the command selected for the current platform. Discovery clears + // `commandWindows` before hashing the normalized handler. + #[cfg(windows)] + let command = &identity.command_windows; + #[cfg(not(windows))] + let command = &identity.command; + let value = json!({ + "event_name": identity.event_key, + "hooks": [{ + "async": false, + "command": command, + "timeout": 1, + "type": "command" + }] + }); + let canonical = canonical_json(&value); + hash_bytes(&serde_json::to_vec(&canonical).unwrap_or_default()) +} + +fn canonical_json(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut keys = map.keys().collect::>(); + keys.sort(); + let mut sorted = serde_json::Map::new(); + for key in keys { + sorted.insert(key.clone(), canonical_json(&map[key])); + } + Value::Object(sorted) + } + Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()), + _ => value.clone(), + } +} + +fn audit_owned_source_tree(paths: &PluginPaths, require_complete: bool) -> io::Result { + let root = paths.codex_home.join("abtop"); + let expected_directories = [ + "marketplace", + "marketplace/.agents", + "marketplace/.agents/plugins", + "marketplace/plugins", + "marketplace/plugins/abtop", + "marketplace/plugins/abtop/.codex-plugin", + "marketplace/plugins/abtop/hooks", + "marketplace/plugins/abtop/scripts", + ] + .into_iter() + .map(PathBuf::from) + .collect::>(); + let expected_files = [ + "marketplace/.agents/plugins/marketplace.json", + "marketplace/plugins/abtop/.codex-plugin/plugin.json", + "marketplace/plugins/abtop/hooks/hooks.json", + "marketplace/plugins/abtop/scripts/abtop-codex-hook.sh", + "marketplace/plugins/abtop/scripts/abtop-codex-hook.cmd", + ] + .into_iter() + .map(PathBuf::from) + .collect::>(); + + let root_metadata = match fs::symlink_metadata(&root) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(!require_complete), + Err(error) => return Err(error), + }; + validate_owned_directory(&root, &root_metadata, true)?; + + let mut found_directories = BTreeSet::new(); + let mut found_files = BTreeSet::new(); + let mut pending = vec![root.clone()]; + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(&directory)? { + let entry = entry?; + let path = entry.path(); + let relative = path.strip_prefix(&root).map_err(|_| { + invalid_data(format!( + "managed plugin path {} escaped its source root", + path.display() + )) + })?; + let metadata = fs::symlink_metadata(&path)?; + if relative == Path::new(".setup.lock") { + validate_owned_regular_file(&path, &metadata, true)?; + continue; + } + if expected_directories.contains(relative) { + validate_owned_directory(&path, &metadata, true)?; + found_directories.insert(relative.to_path_buf()); + pending.push(path); + } else if expected_files.contains(relative) { + validate_owned_regular_file(&path, &metadata, true)?; + found_files.insert(relative.to_path_buf()); + } else { + return Err(invalid_data(format!( + "unexpected file or capability in the managed plugin source tree: {}", + path.display() + ))); + } + } + } + + Ok(!require_complete + || (found_directories == expected_directories && found_files == expected_files)) +} + +fn cache_version_path(paths: &PluginPaths, version: &str) -> PathBuf { + paths + .codex_home + .join("plugins/cache") + .join(MARKETPLACE_NAME) + .join(PLUGIN_NAME) + .join(version) +} + +fn attested_cached_plugin_version( + paths: &PluginPaths, + current_version: &str, +) -> io::Result> { + let Some(attestation) = read_installation_attestation(&paths.codex_home)? else { + return Ok(None); + }; + if attestation.plugin_version == current_version { + return Ok(None); + } + if !valid_attested_cache_version(&attestation.plugin_version) { + return Err(invalid_data( + "the attested plugin version is not a safe abtop cache version", + )); + } + if cached_attested_plugin_payload_exists(paths, &attestation.plugin_version)? { + Ok(Some(attestation.plugin_version)) + } else { + Ok(None) + } +} + +fn valid_attested_cache_version(version: &str) -> bool { + let Some((base, suffix)) = version.rsplit_once("+codex.") else { + return false; + }; + !base.is_empty() + && base.len() <= 96 + && base + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) + && suffix.len() == 12 + && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn cached_attested_plugin_payload_exists(paths: &PluginPaths, version: &str) -> io::Result { + #[cfg(unix)] + { + cached_attested_plugin_payload_exists_unix(paths, version) + } + #[cfg(not(unix))] + { + cached_attested_plugin_payload_exists_portable(paths, version) + } +} + +#[cfg(not(unix))] +fn cached_attested_plugin_payload_exists_portable( + paths: &PluginPaths, + version: &str, +) -> io::Result { + cached_plugin_payload_matches_disk_portable(paths, version, None) +} + +#[cfg(unix)] +fn cached_attested_plugin_payload_exists_unix( + paths: &PluginPaths, + installed_version: &str, +) -> io::Result { + cached_plugin_payload_matches_disk_unix(paths, installed_version, None) +} + +fn cached_bundle_matches_disk(paths: &PluginPaths, bundle: &RenderedBundle) -> io::Result { + #[cfg(unix)] + { + cached_bundle_matches_disk_unix(paths, bundle) + } + #[cfg(not(unix))] + { + cached_bundle_matches_disk_portable(paths, bundle) + } +} + +#[cfg(not(unix))] +fn cached_bundle_matches_disk_portable( + paths: &PluginPaths, + bundle: &RenderedBundle, +) -> io::Result { + cached_plugin_payload_matches_disk_portable(paths, &bundle.plugin_version, Some(bundle)) +} + +#[cfg(not(unix))] +fn cached_plugin_payload_matches_disk_portable( + paths: &PluginPaths, + plugin_version: &str, + expected_bundle: Option<&RenderedBundle>, +) -> io::Result { + let cache_plugin_root = paths + .codex_home + .join("plugins/cache") + .join(MARKETPLACE_NAME) + .join(PLUGIN_NAME); + for directory in [ + paths.codex_home.join("plugins"), + paths.codex_home.join("plugins/cache"), + paths + .codex_home + .join("plugins/cache") + .join(MARKETPLACE_NAME), + cache_plugin_root.clone(), + ] { + let metadata = match fs::symlink_metadata(&directory) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + validate_owned_directory(&directory, &metadata, false)?; + } + + let version_root = cache_version_path(paths, plugin_version); + let entries = fs::read_dir(&cache_plugin_root)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>()?; + if entries.len() != 1 || entries.first() != Some(&version_root) { + if entries.is_empty() { + return Ok(false); + } + return Err(invalid_data(format!( + "the cached `{PLUGIN_ID}` payload does not contain exactly the current version" + ))); + } + + let expected_directories = [".codex-plugin", "hooks", "scripts"] + .into_iter() + .map(PathBuf::from) + .collect::>(); + let expected_files = [ + ".codex-plugin/plugin.json", + "hooks/hooks.json", + "scripts/abtop-codex-hook.sh", + "scripts/abtop-codex-hook.cmd", + ] + .into_iter() + .map(PathBuf::from) + .collect::>(); + let root_metadata = fs::symlink_metadata(&version_root)?; + validate_owned_directory(&version_root, &root_metadata, false)?; + let mut found_directories = BTreeSet::new(); + let mut found_files = BTreeSet::new(); + let mut pending = vec![version_root.clone()]; + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(&directory)? { + let entry = entry?; + let path = entry.path(); + let relative = path.strip_prefix(&version_root).map_err(|_| { + invalid_data(format!( + "cached plugin path {} escaped its root", + path.display() + )) + })?; + let metadata = fs::symlink_metadata(&path)?; + if expected_directories.contains(relative) { + validate_owned_directory(&path, &metadata, false)?; + found_directories.insert(relative.to_path_buf()); + pending.push(path); + } else if expected_files.contains(relative) { + validate_owned_regular_file(&path, &metadata, false)?; + found_files.insert(relative.to_path_buf()); + } else { + return Err(invalid_data(format!( + "unexpected file or capability in the cached plugin payload: {}", + path.display() + ))); + } + } + } + if found_directories != expected_directories || found_files != expected_files { + return Ok(false); + } + + for (path, expected, executable) in [ + ( + version_root.join(".codex-plugin/plugin.json"), + expected_bundle.map(|bundle| bundle.plugin_manifest.as_slice()), + false, + ), + ( + version_root.join("hooks/hooks.json"), + expected_bundle.map(|bundle| bundle.hooks_manifest.as_slice()), + false, + ), + ( + version_root.join("scripts/abtop-codex-hook.sh"), + expected_bundle.map(|bundle| bundle.posix_launcher.as_slice()), + true, + ), + ( + version_root.join("scripts/abtop-codex-hook.cmd"), + expected_bundle.map(|bundle| bundle.windows_launcher.as_slice()), + false, + ), + ] { + let valid = match expected { + Some(bytes) => private_regular_file_matches(&path, bytes, executable)?, + None => private_regular_mode_matches(&path, executable)?, + }; + if !valid { + return Ok(false); + } + } + Ok(true) +} + +#[cfg(unix)] +fn cached_bundle_matches_disk_unix( + paths: &PluginPaths, + bundle: &RenderedBundle, +) -> io::Result { + cached_plugin_payload_matches_disk_unix(paths, &bundle.plugin_version, Some(bundle)) +} + +#[cfg(unix)] +fn cached_plugin_payload_matches_disk_unix( + paths: &PluginPaths, + plugin_version: &str, + expected_bundle: Option<&RenderedBundle>, +) -> io::Result { + let home = open_unix_directory(&paths.codex_home, false)?; + let home_metadata = home.metadata()?; + let plugins_path = paths.codex_home.join("plugins"); + let Some(plugins) = open_managed_directory_at(&home, "plugins", &plugins_path, false)? else { + return Ok(false); + }; + let cache_path = plugins_path.join("cache"); + let Some(cache) = open_managed_directory_at(&plugins, "cache", &cache_path, false)? else { + return Ok(false); + }; + let marketplace_path = cache_path.join(MARKETPLACE_NAME); + let Some(marketplace) = + open_managed_directory_at(&cache, MARKETPLACE_NAME, &marketplace_path, false)? + else { + return Ok(false); + }; + let plugin_path = marketplace_path.join(PLUGIN_NAME); + let Some(plugin) = open_managed_directory_at(&marketplace, PLUGIN_NAME, &plugin_path, false)? + else { + return Ok(false); + }; + + let expected_versions = BTreeSet::from([OsString::from(plugin_version)]); + if !unix_directory_has_exact_names( + &plugin, + &expected_versions, + &plugin_path, + "cached plugin versions", + )? { + return Ok(false); + } + let version_path = plugin_path.join(plugin_version); + let Some(version) = open_managed_directory_at(&plugin, plugin_version, &version_path, false)? + else { + return Ok(false); + }; + let version_names = BTreeSet::from([ + OsString::from(".codex-plugin"), + OsString::from("hooks"), + OsString::from("scripts"), + ]); + if !unix_directory_has_exact_names( + &version, + &version_names, + &version_path, + "cached plugin root", + )? { + return Ok(false); + } + + let manifest_path = version_path.join(".codex-plugin"); + let hooks_path = version_path.join("hooks"); + let scripts_path = version_path.join("scripts"); + let Some(manifest_directory) = + open_managed_directory_at(&version, ".codex-plugin", &manifest_path, false)? + else { + return Ok(false); + }; + let Some(hooks_directory) = open_managed_directory_at(&version, "hooks", &hooks_path, false)? + else { + return Ok(false); + }; + let Some(scripts_directory) = + open_managed_directory_at(&version, "scripts", &scripts_path, false)? + else { + return Ok(false); + }; + if !unix_directory_has_exact_names( + &manifest_directory, + &BTreeSet::from([OsString::from("plugin.json")]), + &manifest_path, + "cached plugin manifest directory", + )? || !unix_directory_has_exact_names( + &hooks_directory, + &BTreeSet::from([OsString::from("hooks.json")]), + &hooks_path, + "cached hooks directory", + )? || !unix_directory_has_exact_names( + &scripts_directory, + &BTreeSet::from([ + OsString::from("abtop-codex-hook.cmd"), + OsString::from("abtop-codex-hook.sh"), + ]), + &scripts_path, + "cached scripts directory", + )? { + return Ok(false); + } + + let plugin_manifest_path = manifest_path.join("plugin.json"); + let hooks_file_path = hooks_path.join("hooks.json"); + let posix_path = scripts_path.join("abtop-codex-hook.sh"); + let windows_path = scripts_path.join("abtop-codex-hook.cmd"); + let Some(plugin_manifest) = open_cached_payload_file_at( + &manifest_directory, + "plugin.json", + &plugin_manifest_path, + expected_bundle.map(|bundle| bundle.plugin_manifest.as_slice()), + false, + )? + else { + return Ok(false); + }; + let Some(hooks_file) = open_cached_payload_file_at( + &hooks_directory, + "hooks.json", + &hooks_file_path, + expected_bundle.map(|bundle| bundle.hooks_manifest.as_slice()), + false, + )? + else { + return Ok(false); + }; + let Some(posix_launcher) = open_cached_payload_file_at( + &scripts_directory, + "abtop-codex-hook.sh", + &posix_path, + expected_bundle.map(|bundle| bundle.posix_launcher.as_slice()), + true, + )? + else { + return Ok(false); + }; + let Some(windows_launcher) = open_cached_payload_file_at( + &scripts_directory, + "abtop-codex-hook.cmd", + &windows_path, + expected_bundle.map(|bundle| bundle.windows_launcher.as_slice()), + false, + )? + else { + return Ok(false); + }; + + // Rebuild the complete path from the current CODEX_HOME namespace. A + // pinned descriptor can continue to describe a detached old tree after a + // concurrent rename, so scanning only that descriptor is insufficient. + let rebound_home = open_unix_directory(&paths.codex_home, false)?; + if !same_file_metadata(&home_metadata, &rebound_home.metadata()?) { + return Err(invalid_data("CODEX_HOME changed during cache validation")); + } + let rebound_plugins = + reopen_same_directory_at(&rebound_home, "plugins", &plugins, &plugins_path, false)?; + let rebound_cache = + reopen_same_directory_at(&rebound_plugins, "cache", &cache, &cache_path, false)?; + let rebound_marketplace = reopen_same_directory_at( + &rebound_cache, + MARKETPLACE_NAME, + &marketplace, + &marketplace_path, + false, + )?; + let rebound_plugin = reopen_same_directory_at( + &rebound_marketplace, + PLUGIN_NAME, + &plugin, + &plugin_path, + false, + )?; + let rebound_version = reopen_same_directory_at( + &rebound_plugin, + plugin_version, + &version, + &version_path, + false, + )?; + let rebound_manifest = reopen_same_directory_at( + &rebound_version, + ".codex-plugin", + &manifest_directory, + &manifest_path, + false, + )?; + let rebound_hooks = reopen_same_directory_at( + &rebound_version, + "hooks", + &hooks_directory, + &hooks_path, + false, + )?; + let rebound_scripts = reopen_same_directory_at( + &rebound_version, + "scripts", + &scripts_directory, + &scripts_path, + false, + )?; + reopen_same_cached_payload_file_at( + &rebound_manifest, + "plugin.json", + &plugin_manifest, + &plugin_manifest_path, + expected_bundle.map(|bundle| bundle.plugin_manifest.as_slice()), + false, + )?; + reopen_same_cached_payload_file_at( + &rebound_hooks, + "hooks.json", + &hooks_file, + &hooks_file_path, + expected_bundle.map(|bundle| bundle.hooks_manifest.as_slice()), + false, + )?; + reopen_same_cached_payload_file_at( + &rebound_scripts, + "abtop-codex-hook.sh", + &posix_launcher, + &posix_path, + expected_bundle.map(|bundle| bundle.posix_launcher.as_slice()), + true, + )?; + reopen_same_cached_payload_file_at( + &rebound_scripts, + "abtop-codex-hook.cmd", + &windows_launcher, + &windows_path, + expected_bundle.map(|bundle| bundle.windows_launcher.as_slice()), + false, + )?; + Ok(unix_directory_has_exact_names( + &rebound_plugin, + &expected_versions, + &plugin_path, + "cached plugin versions", + )? && unix_directory_has_exact_names( + &rebound_version, + &version_names, + &version_path, + "cached plugin root", + )?) +} + +#[cfg(unix)] +fn open_managed_directory_at( + parent: &File, + name: &str, + path: &Path, + private: bool, +) -> io::Result> { + let Some(directory) = openat_unix( + parent, + std::ffi::OsStr::new(name), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + )? + else { + return Ok(None); + }; + validate_owned_directory(path, &directory.metadata()?, private)?; + Ok(Some(directory)) +} + +#[cfg(unix)] +fn reopen_same_directory_at( + parent: &File, + name: &str, + pinned: &File, + path: &Path, + private: bool, +) -> io::Result { + let current = open_managed_directory_at(parent, name, path, private)?.ok_or_else(|| { + invalid_data(format!( + "managed plugin directory {} disappeared during closing validation", + path.display() + )) + })?; + if !same_file_metadata(&pinned.metadata()?, ¤t.metadata()?) { + return Err(invalid_data(format!( + "managed plugin directory {} was replaced during validation", + path.display() + ))); + } + Ok(current) +} + +#[cfg(unix)] +fn unix_directory_has_exact_names( + directory: &File, + expected: &BTreeSet, + path: &Path, + label: &str, +) -> io::Result { + let actual = unix_directory_names(directory)?; + if &actual == expected { + return Ok(true); + } + if actual.is_subset(expected) { + return Ok(false); + } + Err(invalid_data(format!( + "unexpected file or capability in {label} {}", + path.display() + ))) +} + +#[cfg(unix)] +fn unix_directory_has_required_and_optional_names( + directory: &File, + required: &BTreeSet, + optional: &BTreeSet, + path: &Path, + label: &str, +) -> io::Result { + let actual = unix_directory_names(directory)?; + let allowed = required.union(optional).cloned().collect::>(); + if !actual.is_subset(&allowed) { + return Err(invalid_data(format!( + "unexpected file or capability in {label} {}", + path.display() + ))); + } + Ok(required.is_subset(&actual)) +} + +#[cfg(unix)] +fn unix_directory_names(directory: &File) -> io::Result> { + use std::os::fd::AsRawFd; + use std::os::unix::ffi::OsStringExt; + + // A duplicated directory descriptor shares its seek position with the + // original open file description. Opening `.` relative to the pinned + // directory gives each scan an independent cursor, so a closing + // verification cannot silently observe EOF from the first scan. + let dot = std::ffi::CString::new(".").expect("static path has no NUL"); + let descriptor = unsafe { + libc::openat( + directory.as_raw_fd(), + dot.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + let stream = unsafe { libc::fdopendir(descriptor) }; + if stream.is_null() { + let error = io::Error::last_os_error(); + let _ = unsafe { libc::close(descriptor) }; + return Err(error); + } + let mut names = BTreeSet::new(); + loop { + let entry = unsafe { libc::readdir(stream) }; + if entry.is_null() { + break; + } + let bytes = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); + if bytes == b"." || bytes == b".." { + continue; + } + if names.len() >= 64 { + let _ = unsafe { libc::closedir(stream) }; + return Err(invalid_data("cached plugin directory has too many entries")); + } + names.insert(OsString::from_vec(bytes.to_vec())); + } + if unsafe { libc::closedir(stream) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(names) +} + +#[cfg(unix)] +fn open_managed_cache_file_at( + parent: &File, + name: &str, + path: &Path, + executable: bool, +) -> io::Result> { + let flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK; + let Some(file) = openat_unix(parent, std::ffi::OsStr::new(name), flags)? else { + return Ok(None); + }; + let metadata = file.metadata()?; + validate_owned_regular_file(path, &metadata, false)?; + use std::os::unix::fs::PermissionsExt; + let expected_mode = if executable { 0o700 } else { 0o600 }; + if metadata.permissions().mode() & 0o777 != expected_mode { + return Ok(None); + } + Ok(Some(file)) +} + +#[cfg(unix)] +fn reopen_same_cache_file_at( + parent: &File, + name: &str, + pinned: &File, + path: &Path, + executable: bool, +) -> io::Result { + let current = open_managed_cache_file_at(parent, name, path, executable)?.ok_or_else(|| { + invalid_data(format!( + "cached plugin file {} disappeared or changed during closing validation", + path.display() + )) + })?; + if !same_file_content_snapshot(&pinned.metadata()?, ¤t.metadata()?) { + return Err(invalid_data(format!( + "cached plugin file {} was replaced during validation", + path.display() + ))); + } + Ok(current) +} + +#[cfg(unix)] +fn open_matching_managed_file_at( + parent: &File, + name: &str, + path: &Path, + expected: &[u8], + executable: bool, + private: bool, +) -> io::Result> { + let flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK; + let Some(mut file) = openat_unix(parent, std::ffi::OsStr::new(name), flags)? else { + return Ok(None); + }; + let metadata = file.metadata()?; + validate_owned_regular_file(path, &metadata, private)?; + use std::os::unix::fs::PermissionsExt; + let expected_mode = if executable { 0o700 } else { 0o600 }; + if metadata.permissions().mode() & 0o777 != expected_mode { + return Ok(None); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + (&mut file) + .take(MAX_MANAGED_FILE + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_MANAGED_FILE + || bytes.len() as u64 != metadata.len() + || !same_file_content_snapshot(&metadata, &file.metadata()?) + { + return Err(invalid_data(format!( + "cached plugin file {} changed while it was read", + path.display() + ))); + } + let current = openat_unix(parent, std::ffi::OsStr::new(name), flags)?.ok_or_else(|| { + invalid_data(format!( + "cached plugin file {} disappeared after it was read", + path.display() + )) + })?; + if !same_file_content_snapshot(&metadata, ¤t.metadata()?) { + return Err(invalid_data(format!( + "cached plugin file {} was replaced while it was read", + path.display() + ))); + } + if bytes != expected { + return Ok(None); + } + Ok(Some(current)) +} + +#[cfg(unix)] +fn reopen_same_file_at( + parent: &File, + name: &str, + pinned: &File, + path: &Path, + expected: &[u8], + executable: bool, + private: bool, +) -> io::Result { + let current = open_matching_managed_file_at(parent, name, path, expected, executable, private)? + .ok_or_else(|| { + invalid_data(format!( + "managed plugin file {} disappeared or changed during closing validation", + path.display() + )) + })?; + if !same_file_content_snapshot(&pinned.metadata()?, ¤t.metadata()?) { + return Err(invalid_data(format!( + "managed plugin file {} was replaced during validation", + path.display() + ))); + } + Ok(current) +} + +#[cfg(unix)] +fn open_cached_payload_file_at( + parent: &File, + name: &str, + path: &Path, + expected: Option<&[u8]>, + executable: bool, +) -> io::Result> { + match expected { + Some(bytes) => open_matching_managed_file_at(parent, name, path, bytes, executable, false), + None => open_managed_cache_file_at(parent, name, path, executable), + } +} + +#[cfg(unix)] +fn reopen_same_cached_payload_file_at( + parent: &File, + name: &str, + pinned: &File, + path: &Path, + expected: Option<&[u8]>, + executable: bool, +) -> io::Result { + match expected { + Some(bytes) => reopen_same_file_at(parent, name, pinned, path, bytes, executable, false), + None => reopen_same_cache_file_at(parent, name, pinned, path, executable), + } +} + +#[cfg(unix)] +fn read_private_regular_file_at( + parent: &File, + name: &str, + path: &Path, +) -> io::Result>> { + let flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK; + let Some(mut file) = openat_unix(parent, std::ffi::OsStr::new(name), flags)? else { + return Ok(None); + }; + let metadata = file.metadata()?; + validate_owned_regular_file(path, &metadata, true)?; + let mut bytes = Vec::with_capacity(metadata.len() as usize); + (&mut file) + .take(MAX_MANAGED_FILE + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_MANAGED_FILE + || bytes.len() as u64 != metadata.len() + || !same_file_content_snapshot(&metadata, &file.metadata()?) + { + return Err(invalid_data(format!( + "managed plugin file {} changed while it was read", + path.display() + ))); + } + let current = openat_unix(parent, std::ffi::OsStr::new(name), flags)?.ok_or_else(|| { + invalid_data(format!( + "managed plugin file {} disappeared after it was read", + path.display() + )) + })?; + if !same_file_content_snapshot(&metadata, ¤t.metadata()?) { + return Err(invalid_data(format!( + "managed plugin file {} was replaced while it was read", + path.display() + ))); + } + Ok(Some(bytes)) +} + +fn validate_owned_directory(path: &Path, metadata: &fs::Metadata, private: bool) -> io::Result<()> { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(invalid_data(format!( + "managed plugin path {} is not a safe directory", + path.display() + ))); + } + validate_same_owner(metadata, path)?; + if private { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return Err(invalid_data(format!( + "managed plugin directory {} is accessible by another user", + path.display() + ))); + } + } + } else { + validate_not_other_writable(metadata, path)?; + } + Ok(()) +} + +fn validate_owned_regular_file( + path: &Path, + metadata: &fs::Metadata, + private: bool, +) -> io::Result<()> { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(invalid_data(format!( + "managed plugin path {} is not a safe regular file", + path.display() + ))); + } + if metadata.len() > MAX_MANAGED_FILE { + return Err(invalid_data(format!( + "managed plugin file {} is oversized", + path.display() + ))); + } + validate_same_owner(metadata, path)?; + validate_single_link(metadata, path)?; + if private { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return Err(invalid_data(format!( + "managed plugin file {} is accessible by another user", + path.display() + ))); + } + } + } else { + validate_not_other_writable(metadata, path)?; + } + Ok(()) +} + +fn bundle_matches_disk(paths: &PluginPaths, bundle: &RenderedBundle) -> io::Result { + #[cfg(unix)] + { + bundle_matches_disk_unix(paths, bundle) + } + #[cfg(not(unix))] + { + bundle_matches_disk_portable(paths, bundle) + } +} + +#[cfg(unix)] +fn bundle_matches_disk_unix(paths: &PluginPaths, bundle: &RenderedBundle) -> io::Result { + let home = open_unix_directory(&paths.codex_home, false)?; + let home_metadata = home.metadata()?; + let source_root_path = paths.codex_home.join("abtop"); + let Some(source_root) = open_managed_directory_at(&home, "abtop", &source_root_path, true)? + else { + return Ok(false); + }; + let root_required = BTreeSet::from([OsString::from("marketplace")]); + let root_optional = BTreeSet::from([OsString::from(".setup.lock")]); + if !unix_directory_has_required_and_optional_names( + &source_root, + &root_required, + &root_optional, + &source_root_path, + "managed source root", + )? { + return Ok(false); + } + + let marketplace_path = source_root_path.join("marketplace"); + let Some(marketplace) = + open_managed_directory_at(&source_root, "marketplace", &marketplace_path, true)? + else { + return Ok(false); + }; + let marketplace_names = BTreeSet::from([OsString::from(".agents"), OsString::from("plugins")]); + if !unix_directory_has_exact_names( + &marketplace, + &marketplace_names, + &marketplace_path, + "managed marketplace root", + )? { + return Ok(false); + } + + let agents_path = marketplace_path.join(".agents"); + let Some(agents) = open_managed_directory_at(&marketplace, ".agents", &agents_path, true)? + else { + return Ok(false); + }; + let source_plugins_path = marketplace_path.join("plugins"); + let Some(source_plugins) = + open_managed_directory_at(&marketplace, "plugins", &source_plugins_path, true)? + else { + return Ok(false); + }; + if !unix_directory_has_exact_names( + &agents, + &BTreeSet::from([OsString::from("plugins")]), + &agents_path, + "managed marketplace metadata root", + )? || !unix_directory_has_exact_names( + &source_plugins, + &BTreeSet::from([OsString::from(PLUGIN_NAME)]), + &source_plugins_path, + "managed plugin source parent", + )? { + return Ok(false); + } + + let agents_plugins_path = agents_path.join("plugins"); + let Some(agents_plugins) = + open_managed_directory_at(&agents, "plugins", &agents_plugins_path, true)? + else { + return Ok(false); + }; + let plugin_root_path = source_plugins_path.join(PLUGIN_NAME); + let Some(plugin_root) = + open_managed_directory_at(&source_plugins, PLUGIN_NAME, &plugin_root_path, true)? + else { + return Ok(false); + }; + let plugin_root_names = BTreeSet::from([ + OsString::from(".codex-plugin"), + OsString::from("hooks"), + OsString::from("scripts"), + ]); + if !unix_directory_has_exact_names( + &agents_plugins, + &BTreeSet::from([OsString::from("marketplace.json")]), + &agents_plugins_path, + "managed marketplace manifest directory", + )? || !unix_directory_has_exact_names( + &plugin_root, + &plugin_root_names, + &plugin_root_path, + "managed plugin source root", + )? { + return Ok(false); + } + + let manifest_path = plugin_root_path.join(".codex-plugin"); + let hooks_path = plugin_root_path.join("hooks"); + let scripts_path = plugin_root_path.join("scripts"); + let Some(manifest_directory) = + open_managed_directory_at(&plugin_root, ".codex-plugin", &manifest_path, true)? + else { + return Ok(false); + }; + let Some(hooks_directory) = + open_managed_directory_at(&plugin_root, "hooks", &hooks_path, true)? + else { + return Ok(false); + }; + let Some(scripts_directory) = + open_managed_directory_at(&plugin_root, "scripts", &scripts_path, true)? + else { + return Ok(false); + }; + if !unix_directory_has_exact_names( + &manifest_directory, + &BTreeSet::from([OsString::from("plugin.json")]), + &manifest_path, + "managed plugin manifest directory", + )? || !unix_directory_has_exact_names( + &hooks_directory, + &BTreeSet::from([OsString::from("hooks.json")]), + &hooks_path, + "managed hooks directory", + )? || !unix_directory_has_exact_names( + &scripts_directory, + &BTreeSet::from([ + OsString::from("abtop-codex-hook.cmd"), + OsString::from("abtop-codex-hook.sh"), + ]), + &scripts_path, + "managed scripts directory", + )? { + return Ok(false); + } + + let marketplace_manifest_path = agents_plugins_path.join("marketplace.json"); + let plugin_manifest_path = manifest_path.join("plugin.json"); + let hooks_file_path = hooks_path.join("hooks.json"); + let posix_path = scripts_path.join("abtop-codex-hook.sh"); + let windows_path = scripts_path.join("abtop-codex-hook.cmd"); + let Some(marketplace_manifest) = open_matching_managed_file_at( + &agents_plugins, + "marketplace.json", + &marketplace_manifest_path, + &bundle.marketplace_manifest, + false, + true, + )? + else { + return Ok(false); + }; + let Some(plugin_manifest) = open_matching_managed_file_at( + &manifest_directory, + "plugin.json", + &plugin_manifest_path, + &bundle.plugin_manifest, + false, + true, + )? + else { + return Ok(false); + }; + let Some(hooks_file) = open_matching_managed_file_at( + &hooks_directory, + "hooks.json", + &hooks_file_path, + &bundle.hooks_manifest, + false, + true, + )? + else { + return Ok(false); + }; + let Some(posix_launcher) = open_matching_managed_file_at( + &scripts_directory, + "abtop-codex-hook.sh", + &posix_path, + &bundle.posix_launcher, + true, + true, + )? + else { + return Ok(false); + }; + let Some(windows_launcher) = open_matching_managed_file_at( + &scripts_directory, + "abtop-codex-hook.cmd", + &windows_path, + &bundle.windows_launcher, + false, + true, + )? + else { + return Ok(false); + }; + + let rebound_home = open_unix_directory(&paths.codex_home, false)?; + if !same_file_metadata(&home_metadata, &rebound_home.metadata()?) { + return Err(invalid_data("CODEX_HOME changed during source validation")); + } + let rebound_source_root = reopen_same_directory_at( + &rebound_home, + "abtop", + &source_root, + &source_root_path, + true, + )?; + let rebound_marketplace = reopen_same_directory_at( + &rebound_source_root, + "marketplace", + &marketplace, + &marketplace_path, + true, + )?; + let rebound_agents = + reopen_same_directory_at(&rebound_marketplace, ".agents", &agents, &agents_path, true)?; + let rebound_source_plugins = reopen_same_directory_at( + &rebound_marketplace, + "plugins", + &source_plugins, + &source_plugins_path, + true, + )?; + let rebound_agents_plugins = reopen_same_directory_at( + &rebound_agents, + "plugins", + &agents_plugins, + &agents_plugins_path, + true, + )?; + let rebound_plugin_root = reopen_same_directory_at( + &rebound_source_plugins, + PLUGIN_NAME, + &plugin_root, + &plugin_root_path, + true, + )?; + let rebound_manifest = reopen_same_directory_at( + &rebound_plugin_root, + ".codex-plugin", + &manifest_directory, + &manifest_path, + true, + )?; + let rebound_hooks = reopen_same_directory_at( + &rebound_plugin_root, + "hooks", + &hooks_directory, + &hooks_path, + true, + )?; + let rebound_scripts = reopen_same_directory_at( + &rebound_plugin_root, + "scripts", + &scripts_directory, + &scripts_path, + true, + )?; + reopen_same_file_at( + &rebound_agents_plugins, + "marketplace.json", + &marketplace_manifest, + &marketplace_manifest_path, + &bundle.marketplace_manifest, + false, + true, + )?; + reopen_same_file_at( + &rebound_manifest, + "plugin.json", + &plugin_manifest, + &plugin_manifest_path, + &bundle.plugin_manifest, + false, + true, + )?; + reopen_same_file_at( + &rebound_hooks, + "hooks.json", + &hooks_file, + &hooks_file_path, + &bundle.hooks_manifest, + false, + true, + )?; + reopen_same_file_at( + &rebound_scripts, + "abtop-codex-hook.sh", + &posix_launcher, + &posix_path, + &bundle.posix_launcher, + true, + true, + )?; + reopen_same_file_at( + &rebound_scripts, + "abtop-codex-hook.cmd", + &windows_launcher, + &windows_path, + &bundle.windows_launcher, + false, + true, + )?; + Ok(unix_directory_has_required_and_optional_names( + &rebound_source_root, + &root_required, + &root_optional, + &source_root_path, + "managed source root", + )? && unix_directory_has_exact_names( + &rebound_marketplace, + &marketplace_names, + &marketplace_path, + "managed marketplace root", + )? && unix_directory_has_exact_names( + &rebound_plugin_root, + &plugin_root_names, + &plugin_root_path, + "managed plugin source root", + )? && private_runtime_state_tree_valid(paths)?) +} + +#[cfg(not(unix))] +fn bundle_matches_disk_portable(paths: &PluginPaths, bundle: &RenderedBundle) -> io::Result { + if !audit_owned_source_tree(paths, true)? { + return Ok(false); + } + if !private_runtime_state_tree_valid(paths)? { + return Ok(false); + } + let expected = [ + ( + &paths.marketplace_manifest, + bundle.marketplace_manifest.as_slice(), + false, + ), + ( + &paths.plugin_manifest, + bundle.plugin_manifest.as_slice(), + false, + ), + ( + &paths.hooks_manifest, + bundle.hooks_manifest.as_slice(), + false, + ), + ( + &paths.posix_launcher, + bundle.posix_launcher.as_slice(), + true, + ), + ( + &paths.windows_launcher, + bundle.windows_launcher.as_slice(), + false, + ), + ]; + for (path, bytes, executable) in expected { + if !private_regular_file_matches(path, bytes, executable)? { + return Ok(false); + } + } + Ok(true) +} + +fn private_runtime_state_tree_valid(paths: &PluginPaths) -> io::Result { + #[cfg(unix)] + { + private_runtime_state_tree_valid_unix(paths) + } + #[cfg(not(unix))] + { + private_runtime_state_tree_valid_portable(paths) + } +} + +#[cfg(unix)] +fn private_runtime_state_tree_valid_unix(paths: &PluginPaths) -> io::Result { + use std::os::unix::fs::PermissionsExt; + + let home = open_unix_directory(&paths.codex_home, false)?; + let home_metadata = home.metadata()?; + let plugins_path = paths.codex_home.join("plugins"); + let Some(plugins) = open_managed_directory_at(&home, "plugins", &plugins_path, false)? else { + return Ok(false); + }; + let data_path = plugins_path.join("data"); + let Some(data) = open_managed_directory_at(&plugins, "data", &data_path, false)? else { + return Ok(false); + }; + let Some(data_root) = + open_managed_directory_at(&data, "abtop-abtop-local", &paths.plugin_data_root, true)? + else { + return Ok(false); + }; + let states_path = paths.plugin_data_root.join(HOOK_STATE_DIR_NAME); + let Some(states) = + open_managed_directory_at(&data_root, HOOK_STATE_DIR_NAME, &states_path, true)? + else { + return Ok(false); + }; + let faults_path = states_path.join(HOOK_FAULT_DIR_NAME); + let Some(faults) = open_managed_directory_at(&states, HOOK_FAULT_DIR_NAME, &faults_path, true)? + else { + return Ok(false); + }; + for directory in [&data_root, &states, &faults] { + if directory.metadata()?.permissions().mode() & 0o777 != 0o700 { + return Ok(false); + } + } + + let rebound_home = open_unix_directory(&paths.codex_home, false)?; + if !same_file_metadata(&home_metadata, &rebound_home.metadata()?) { + return Err(invalid_data( + "CODEX_HOME changed during runtime-state validation", + )); + } + let rebound_plugins = + reopen_same_directory_at(&rebound_home, "plugins", &plugins, &plugins_path, false)?; + let rebound_data = + reopen_same_directory_at(&rebound_plugins, "data", &data, &data_path, false)?; + let rebound_data_root = reopen_same_directory_at( + &rebound_data, + "abtop-abtop-local", + &data_root, + &paths.plugin_data_root, + true, + )?; + let rebound_states = reopen_same_directory_at( + &rebound_data_root, + HOOK_STATE_DIR_NAME, + &states, + &states_path, + true, + )?; + let rebound_faults = reopen_same_directory_at( + &rebound_states, + HOOK_FAULT_DIR_NAME, + &faults, + &faults_path, + true, + )?; + let exact_modes = [&rebound_data_root, &rebound_states, &rebound_faults] + .into_iter() + .all(|directory| { + directory + .metadata() + .is_ok_and(|metadata| metadata.permissions().mode() & 0o777 == 0o700) + }); + Ok(exact_modes) +} + +#[cfg(not(unix))] +fn private_runtime_state_tree_valid_portable(paths: &PluginPaths) -> io::Result { + for directory in [ + paths.plugin_data_root.clone(), + paths.plugin_data_root.join(HOOK_STATE_DIR_NAME), + paths + .plugin_data_root + .join(HOOK_STATE_DIR_NAME) + .join(HOOK_FAULT_DIR_NAME), + ] { + if !private_directory_valid(&directory)? { + return Ok(false); + } + } + Ok(true) +} + +#[cfg(not(unix))] +fn private_directory_valid(path: &Path) -> io::Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Ok(false); + } + validate_same_owner(&metadata, path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return Ok(false); + } + } + Ok(true) +} + +#[cfg(not(unix))] +fn plugin_data_hierarchy_valid(paths: &PluginPaths) -> io::Result { + for directory in [ + paths.codex_home.join("plugins"), + paths.codex_home.join("plugins/data"), + ] { + let metadata = match fs::symlink_metadata(&directory) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Ok(false); + } + validate_same_owner(&metadata, &directory)?; + validate_not_other_writable(&metadata, &directory)?; + } + private_directory_valid(&paths.plugin_data_root) +} + +fn read_installation_attestation_bytes(paths: &PluginPaths) -> io::Result>> { + #[cfg(unix)] + { + read_installation_attestation_bytes_unix(paths) + } + #[cfg(not(unix))] + { + if !plugin_data_hierarchy_valid(paths)? { + return Ok(None); + } + if fs::symlink_metadata(&paths.install_attestation).is_ok() + && !private_regular_mode_matches(&paths.install_attestation, false)? + { + return Err(invalid_data( + "installation attestation does not have the exact private file mode", + )); + } + read_private_regular_file(&paths.install_attestation) + } +} + +#[cfg(unix)] +fn read_installation_attestation_bytes_unix(paths: &PluginPaths) -> io::Result>> { + let home = open_unix_directory(&paths.codex_home, false)?; + let home_metadata = home.metadata()?; + let plugins_path = paths.codex_home.join("plugins"); + let Some(plugins) = open_managed_directory_at(&home, "plugins", &plugins_path, false)? else { + return Ok(None); + }; + let data_path = plugins_path.join("data"); + let Some(data) = open_managed_directory_at(&plugins, "data", &data_path, false)? else { + return Ok(None); + }; + let Some(data_root) = + open_managed_directory_at(&data, "abtop-abtop-local", &paths.plugin_data_root, true)? + else { + return Ok(None); + }; + let Some(bytes) = read_private_regular_file_at( + &data_root, + INSTALL_ATTESTATION_FILE, + &paths.install_attestation, + )? + else { + return Ok(None); + }; + let pinned = open_matching_managed_file_at( + &data_root, + INSTALL_ATTESTATION_FILE, + &paths.install_attestation, + &bytes, + false, + true, + )? + .ok_or_else(|| invalid_data("installation attestation does not have the exact private mode"))?; + + let rebound_home = open_unix_directory(&paths.codex_home, false)?; + if !same_file_metadata(&home_metadata, &rebound_home.metadata()?) { + return Err(invalid_data( + "CODEX_HOME changed during attestation validation", + )); + } + let rebound_plugins = + reopen_same_directory_at(&rebound_home, "plugins", &plugins, &plugins_path, false)?; + let rebound_data = + reopen_same_directory_at(&rebound_plugins, "data", &data, &data_path, false)?; + let rebound_data_root = reopen_same_directory_at( + &rebound_data, + "abtop-abtop-local", + &data_root, + &paths.plugin_data_root, + true, + )?; + reopen_same_file_at( + &rebound_data_root, + INSTALL_ATTESTATION_FILE, + &pinned, + &paths.install_attestation, + &bytes, + false, + true, + )?; + Ok(Some(bytes)) +} + +fn attestation_matches(paths: &PluginPaths, bundle: &RenderedBundle) -> io::Result { + let Some(bytes) = read_installation_attestation_bytes(paths)? else { + return Ok(false); + }; + let attestation: InstallationAttestation = serde_json::from_slice(&bytes) + .map_err(|error| invalid_data(format!("invalid installation attestation: {error}")))?; + Ok(valid_attestation_shape(&attestation) && attestation_identity_matches(&attestation, bundle)) +} + +fn attestation_identity_matches( + attestation: &InstallationAttestation, + bundle: &RenderedBundle, +) -> bool { + attestation.schema_version == 1 + && attestation.hook_schema_revision == HOOK_SCHEMA_REVISION + && attestation.helper_digest == bundle.helper_digest + && attestation.plugin_id == PLUGIN_ID + && attestation.plugin_version == bundle.plugin_version + && attestation.hooks_digest == bundle.hooks_digest + && attestation.hook_events + == HOOK_EVENTS + .iter() + .map(|event| (*event).to_string()) + .collect::>() +} + +fn remove_marketplace_if_owned( + paths: &PluginPaths, + codex_binary: &Path, + codex_binary_digest: &str, + codex_home: &Path, +) -> io::Result { + ensure_plugin_absent(paths)?; + let current = inspect_config_state(paths, None)?; + if let Some(conflict) = current.marketplace_conflict { + return Err(invalid_data(format!( + "refusing to remove marketplace `{MARKETPLACE_NAME}` because it now points at {}", + conflict.display() + ))); + } + if current.marketplace_malformed { + return Err(invalid_data(format!( + "cannot prove ownership of malformed `marketplaces.{MARKETPLACE_NAME}`; the plugin was removed, but the marketplace entry and abtop source bundle were preserved for manual recovery" + ))); + } + if !current.marketplace_registered { + return Ok(false); + } + + remove_marketplace_cli(codex_binary, codex_binary_digest, codex_home)?; + ensure_marketplace_absent(paths)?; + Ok(true) +} + +fn ensure_plugin_absent(paths: &PluginPaths) -> io::Result<()> { + let current = inspect_config_state(paths, None)?; + if current.plugin_configured || current.plugin_config_malformed { + return Err(invalid_data(format!( + "reserved plugin `{PLUGIN_ID}` remains configured after native removal; preserving its marketplace and source bundle" + ))); + } + let cache_root = paths + .codex_home + .join("plugins/cache") + .join(MARKETPLACE_NAME) + .join(PLUGIN_NAME); + match fs::symlink_metadata(&cache_root) { + Ok(_) => Err(invalid_data(format!( + "reserved plugin cache {} remains after native removal; preserving its marketplace and source bundle", + cache_root.display() + ))), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn ensure_marketplace_absent(paths: &PluginPaths) -> io::Result<()> { + let current = inspect_config_state(paths, None)?; + if let Some(conflict) = current.marketplace_conflict { + return Err(invalid_data(format!( + "marketplace `{MARKETPLACE_NAME}` was concurrently registered from {}; preserving the abtop source bundle", + conflict.display() + ))); + } + if current.marketplace_malformed { + return Err(invalid_data(format!( + "marketplace `{MARKETPLACE_NAME}` became malformed during cleanup; preserving the abtop source bundle" + ))); + } + if current.marketplace_registered { + return Err(invalid_data(format!( + "marketplace `{MARKETPLACE_NAME}` was concurrently re-registered; preserving the abtop source bundle" + ))); + } + Ok(()) +} + +fn remove_marketplace_cli( + codex_binary: &Path, + codex_binary_digest: &str, + codex_home: &Path, +) -> io::Result<()> { + let output = run_mutating_codex( + codex_binary, + codex_binary_digest, + codex_home, + &[ + OsString::from("plugin"), + OsString::from("marketplace"), + OsString::from("remove"), + OsString::from(MARKETPLACE_NAME), + OsString::from("--json"), + ], + )?; + require_success(&output, "removing the abtop local marketplace")?; + require_json_object(&output.stdout, "marketplace remove") +} + +fn remove_plugin_cli( + codex_binary: &Path, + codex_binary_digest: &str, + codex_home: &Path, +) -> io::Result<()> { + let output = run_mutating_codex( + codex_binary, + codex_binary_digest, + codex_home, + &[ + OsString::from("plugin"), + OsString::from("remove"), + OsString::from(PLUGIN_ID), + OsString::from("--json"), + ], + )?; + require_success(&output, "removing the abtop plugin")?; + require_json_object(&output.stdout, "plugin remove") +} + +fn remove_owned_bundle_files(paths: &PluginPaths) -> io::Result> { + #[cfg(unix)] + { + remove_owned_bundle_files_unix(paths) + } + #[cfg(not(unix))] + { + remove_owned_bundle_files_portable(paths) + } +} + +#[cfg(unix)] +#[derive(Debug)] +struct OwnedSourceTreeGuard { + codex_home_directory: File, + root_directory: File, + root_metadata: fs::Metadata, + root_path: PathBuf, +} + +#[cfg(unix)] +impl OwnedSourceTreeGuard { + fn open(paths: &PluginPaths) -> io::Result { + let codex_home_directory = open_unix_directory(&paths.codex_home, false)?; + let root_path = paths.codex_home.join("abtop"); + let root_directory = openat_unix( + &codex_home_directory, + std::ffi::OsStr::new("abtop"), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + )? + .ok_or_else(|| invalid_data("managed abtop source root disappeared during uninstall"))?; + let root_metadata = root_directory.metadata()?; + validate_owned_directory(&root_path, &root_metadata, true)?; + Ok(Self { + codex_home_directory, + root_directory, + root_metadata, + root_path, + }) + } + + fn parent_and_leaf( + &self, + relative: &Path, + ) -> io::Result> { + use std::os::unix::ffi::OsStrExt; + + let mut components = relative.components().collect::>(); + let leaf = components + .pop() + .ok_or_else(|| invalid_data("managed source relative path is empty"))?; + let Component::Normal(leaf) = leaf else { + return Err(invalid_data("managed source relative path is unsafe")); + }; + let leaf = std::ffi::CString::new(leaf.as_bytes()) + .map_err(|_| invalid_data("managed source path contains NUL"))?; + let mut directory = self.root_directory.try_clone()?; + let mut display = self.root_path.clone(); + for component in components { + let Component::Normal(name) = component else { + return Err(invalid_data("managed source relative path is unsafe")); + }; + display.push(name); + let Some(next) = openat_unix( + &directory, + name, + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + )? + else { + return Ok(None); + }; + validate_owned_directory(&display, &next.metadata()?, true)?; + directory = next; + } + Ok(Some((directory, leaf, display))) + } + + fn remove_file(&self, relative: &Path) -> io::Result { + use std::os::fd::AsRawFd; + + let Some((parent, leaf, mut display)) = self.parent_and_leaf(relative)? else { + return Ok(false); + }; + display.push( + relative + .file_name() + .ok_or_else(|| invalid_data("managed source relative path has no file name"))?, + ); + let flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK; + let Some(first) = openat_unix_cstr(&parent, &leaf, flags)? else { + return Ok(false); + }; + let first_metadata = first.metadata()?; + validate_owned_regular_file(&display, &first_metadata, true)?; + let second = openat_unix_cstr(&parent, &leaf, flags)?.ok_or_else(|| { + invalid_data(format!( + "managed source file {} disappeared before deletion", + display.display() + )) + })?; + if !same_file_content_snapshot(&first_metadata, &second.metadata()?) { + return Err(invalid_data(format!( + "managed source file {} changed before deletion", + display.display() + ))); + } + if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(true) + } + + fn remove_directory(&self, relative: &Path) -> io::Result { + use std::os::fd::AsRawFd; + + let Some((parent, leaf, mut display)) = self.parent_and_leaf(relative)? else { + return Ok(false); + }; + display.push( + relative + .file_name() + .ok_or_else(|| invalid_data("managed source relative path has no file name"))?, + ); + let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC; + let Some(first) = openat_unix_cstr(&parent, &leaf, flags)? else { + return Ok(false); + }; + let first_metadata = first.metadata()?; + validate_owned_directory(&display, &first_metadata, true)?; + let second = openat_unix_cstr(&parent, &leaf, flags)?.ok_or_else(|| { + invalid_data(format!( + "managed source directory {} disappeared before deletion", + display.display() + )) + })?; + if !same_file_content_snapshot(&first_metadata, &second.metadata()?) { + return Err(invalid_data(format!( + "managed source directory {} changed before deletion", + display.display() + ))); + } + if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), libc::AT_REMOVEDIR) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(true) + } + + fn remove_root(self) -> io::Result<()> { + use std::os::fd::AsRawFd; + + let name = std::ffi::CString::new("abtop").expect("static path has no NUL"); + let current = openat_unix_cstr( + &self.codex_home_directory, + &name, + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + )? + .ok_or_else(|| invalid_data("managed abtop source root disappeared before deletion"))?; + let current_metadata = current.metadata()?; + if !same_file_metadata(&self.root_metadata, ¤t_metadata) { + return Err(invalid_data( + "managed abtop source root changed before deletion", + )); + } + validate_owned_directory(&self.root_path, ¤t_metadata, true)?; + if unsafe { + libc::unlinkat( + self.codex_home_directory.as_raw_fd(), + name.as_ptr(), + libc::AT_REMOVEDIR, + ) + } != 0 + { + return Err(io::Error::last_os_error()); + } + Ok(()) + } +} + +#[cfg(unix)] +fn open_unix_directory(path: &Path, private: bool) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path)?; + validate_owned_directory(path, &file.metadata()?, private)?; + Ok(file) +} + +#[cfg(unix)] +fn openat_unix( + parent: &File, + name: &std::ffi::OsStr, + flags: libc::c_int, +) -> io::Result> { + use std::os::unix::ffi::OsStrExt; + + let name = std::ffi::CString::new(name.as_bytes()) + .map_err(|_| invalid_data("managed source path contains NUL"))?; + openat_unix_cstr(parent, &name, flags) +} + +#[cfg(unix)] +fn openat_unix_cstr( + parent: &File, + name: &std::ffi::CStr, + flags: libc::c_int, +) -> io::Result> { + use std::os::fd::{AsRawFd, FromRawFd}; + + let descriptor = unsafe { libc::openat(parent.as_raw_fd(), name.as_ptr(), flags) }; + if descriptor < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::NotFound { + return Ok(None); + } + return Err(error); + } + Ok(Some(unsafe { File::from_raw_fd(descriptor) })) +} + +#[cfg(unix)] +fn openat_create_unix( + parent: &File, + name: &std::ffi::OsStr, + flags: libc::c_int, + mode: libc::mode_t, +) -> io::Result { + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + + let name = std::ffi::CString::new(name.as_bytes()) + .map_err(|_| invalid_data("managed source path contains NUL"))?; + let descriptor = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + flags, + libc::c_uint::from(mode), + ) + }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + Ok(unsafe { File::from_raw_fd(descriptor) }) +} + +#[cfg(unix)] +fn ensure_managed_directory_at( + parent: &File, + name: &str, + path: &Path, + private: bool, + exact_mode: Option, +) -> io::Result { + use std::os::fd::AsRawFd; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::PermissionsExt; + + let name_os = std::ffi::OsStr::new(name); + let mut directory = open_managed_directory_at(parent, name, path, private)?; + if directory.is_none() { + let name_c = std::ffi::CString::new(name_os.as_bytes()) + .map_err(|_| invalid_data("managed source path contains NUL"))?; + let mode = exact_mode.unwrap_or(0o700); + if unsafe { libc::mkdirat(parent.as_raw_fd(), name_c.as_ptr(), mode) } != 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::AlreadyExists { + return Err(error); + } + } + directory = open_managed_directory_at(parent, name, path, private)?; + } + let directory = directory.ok_or_else(|| { + invalid_data(format!( + "managed plugin directory {} could not be created", + path.display() + )) + })?; + if let Some(mode) = exact_mode { + if unsafe { libc::fchmod(directory.as_raw_fd(), mode) } != 0 { + return Err(io::Error::last_os_error()); + } + if u64::from(directory.metadata()?.permissions().mode() & 0o777) != u64::from(mode) { + return Err(invalid_data(format!( + "managed plugin directory {} does not have the exact private mode", + path.display() + ))); + } + } + validate_owned_directory(path, &directory.metadata()?, private)?; + Ok(directory) +} + +#[cfg(unix)] +fn remove_owned_bundle_files_unix(paths: &PluginPaths) -> io::Result> { + audit_owned_source_tree(paths, false)?; + match fs::symlink_metadata(paths.codex_home.join("abtop")) { + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error), + Ok(_) => {} + } + let guard = OwnedSourceTreeGuard::open(paths)?; + let files = [ + ( + Path::new("marketplace/plugins/abtop/scripts/abtop-codex-hook.cmd"), + paths.windows_launcher.clone(), + ), + ( + Path::new("marketplace/plugins/abtop/scripts/abtop-codex-hook.sh"), + paths.posix_launcher.clone(), + ), + ( + Path::new("marketplace/plugins/abtop/hooks/hooks.json"), + paths.hooks_manifest.clone(), + ), + ( + Path::new("marketplace/plugins/abtop/.codex-plugin/plugin.json"), + paths.plugin_manifest.clone(), + ), + ( + Path::new("marketplace/.agents/plugins/marketplace.json"), + paths.marketplace_manifest.clone(), + ), + ]; + let mut removed = Vec::new(); + for (relative, reported) in files { + if guard.remove_file(relative)? { + removed.push(reported); + } + } + // Pre-stable-lock installations left this source-local lock behind. It is + // never used by the current process; the live retained lock is anchored + // directly below CODEX_HOME. + let _ = guard.remove_file(Path::new(".setup.lock"))?; + for relative in [ + "marketplace/plugins/abtop/scripts", + "marketplace/plugins/abtop/hooks", + "marketplace/plugins/abtop/.codex-plugin", + "marketplace/plugins/abtop", + "marketplace/.agents/plugins", + "marketplace/.agents", + "marketplace/plugins", + "marketplace", + ] { + let _ = guard.remove_directory(Path::new(relative))?; + } + guard.remove_root()?; + Ok(removed) +} + +#[cfg(not(unix))] +fn remove_owned_bundle_files_portable(paths: &PluginPaths) -> io::Result> { + audit_owned_source_tree(paths, false)?; + match fs::symlink_metadata(paths.codex_home.join("abtop")) { + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error), + Ok(_) => {} + } + let mut removed = Vec::new(); + for path in [ + &paths.windows_launcher, + &paths.posix_launcher, + &paths.hooks_manifest, + &paths.plugin_manifest, + &paths.marketplace_manifest, + ] { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err(invalid_data(format!( + "refusing to remove unsafe managed plugin path {}", + path.display() + ))); + } + Ok(_) => { + validate_source_ancestor_chain(paths, path)?; + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(invalid_data(format!( + "refusing to remove substituted managed plugin path {}", + path.display() + ))); + } + validate_same_owner(&metadata, path)?; + validate_single_link(&metadata, path)?; + fs::remove_file(path)?; + removed.push(path.clone()); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + let legacy_setup_lock = paths.codex_home.join("abtop/.setup.lock"); + match fs::symlink_metadata(&legacy_setup_lock) { + Ok(metadata) => { + validate_owned_regular_file(&legacy_setup_lock, &metadata, true)?; + fs::remove_file(&legacy_setup_lock)?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let directories = vec![ + paths.posix_launcher.parent().unwrap().to_path_buf(), + paths.hooks_manifest.parent().unwrap().to_path_buf(), + paths.plugin_manifest.parent().unwrap().to_path_buf(), + paths.plugin_root.clone(), + paths.marketplace_manifest.parent().unwrap().to_path_buf(), + paths.marketplace_root.join(".agents"), + paths.marketplace_root.join("plugins"), + paths.marketplace_root.clone(), + ]; + for directory in directories { + match fs::symlink_metadata(&directory) { + Ok(_) => { + validate_source_ancestor_chain(paths, &directory)?; + let metadata = fs::symlink_metadata(&directory)?; + validate_owned_directory(&directory, &metadata, true)?; + fs::remove_dir(directory)?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + Ok(removed) +} + +#[cfg(not(unix))] +fn validate_source_ancestor_chain(paths: &PluginPaths, leaf: &Path) -> io::Result<()> { + let root = paths.codex_home.join("abtop"); + if !leaf.starts_with(&root) { + return Err(invalid_data(format!( + "managed plugin path {} is outside the abtop source root", + leaf.display() + ))); + } + let mut current = root; + let parent = leaf + .parent() + .ok_or_else(|| invalid_data("managed plugin path has no parent directory"))?; + validate_owned_directory(¤t, &fs::symlink_metadata(¤t)?, true)?; + let relative_parent = parent + .strip_prefix(¤t) + .map_err(|_| invalid_data("managed plugin parent escaped its source root"))?; + for component in relative_parent.components() { + current.push(component.as_os_str()); + let metadata = fs::symlink_metadata(¤t)?; + validate_owned_directory(¤t, &metadata, true)?; + } + Ok(()) +} + +fn current_codex_home() -> io::Result { + if let Some(value) = std::env::var_os("CODEX_HOME") { + let path = PathBuf::from(value); + if !path.is_absolute() { + return Err(invalid_data("CODEX_HOME must be an absolute path")); + } + return Ok(path); + } + dirs::home_dir() + .map(|home| home.join(".codex")) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "cannot determine CODEX_HOME")) +} + +fn current_abtop_binary() -> io::Result { + let binary = std::env::current_exe()?; + validate_abtop_binary(&binary) +} + +fn resolve_codex_binary() -> io::Result { + let path = std::env::var_os("PATH") + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "PATH is not set"))?; + let cwd = std::env::current_dir()?; + #[cfg(windows)] + let names = windows_codex_executable_names(std::env::var_os("PATHEXT").as_deref()); + #[cfg(not(windows))] + let names = vec![OsString::from("codex")]; + resolve_codex_binary_in_path(&path, &cwd, &names) +} + +fn resolve_codex_binary_in_path( + path: &std::ffi::OsStr, + cwd: &Path, + names: &[OsString], +) -> io::Result { + for root in std::env::split_paths(&path) { + let root = if root.as_os_str().is_empty() { + cwd.to_path_buf() + } else if root.is_absolute() { + root + } else { + cwd.join(root) + }; + for name in names { + let candidate = root.join(name); + if executable_target(&candidate) { + return normalize_absolute(&candidate); + } + } + } + Err(io::Error::new( + io::ErrorKind::NotFound, + "cannot find a native Codex executable on PATH", + )) +} + +#[cfg(any(windows, test))] +fn windows_codex_executable_names(pathext: Option<&std::ffi::OsStr>) -> Vec { + let configured = pathext.and_then(std::ffi::OsStr::to_str); + windows_codex_executable_names_from_text(configured) +} + +#[cfg(any(windows, test))] +fn windows_codex_executable_names_from_text(pathext: Option<&str>) -> Vec { + const DEFAULT_PATHEXT: &str = ".COM;.EXE;.BAT;.CMD"; + + let configured = pathext + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_PATHEXT); + let mut seen = BTreeSet::new(); + let mut names = Vec::new(); + for extension in configured.split(';') { + if extension.len() < 2 + || !extension.starts_with('.') + || extension + .bytes() + .any(|byte| matches!(byte, b'/' | b'\\' | b':' | b'\0')) + { + continue; + } + let comparison = extension.to_ascii_uppercase(); + if seen.insert(comparison) { + names.push(OsString::from(format!("codex{extension}"))); + } + } + if names.is_empty() && configured != DEFAULT_PATHEXT { + return windows_codex_executable_names_from_text(Some(DEFAULT_PATHEXT)); + } + names +} + +fn prepare_codex_home(path: &Path) -> io::Result { + let path = normalize_absolute(path)?; + if !path.exists() { + let parent = path + .parent() + .ok_or_else(|| invalid_data("CODEX_HOME has no parent directory"))?; + let parent = fs::canonicalize(parent)?; + let parent_metadata = fs::metadata(&parent)?; + if !parent_metadata.is_dir() { + return Err(invalid_data("CODEX_HOME parent is not a directory")); + } + validate_same_owner(&parent_metadata, &parent)?; + validate_not_other_writable(&parent_metadata, &parent)?; + fs::create_dir(&path)?; + #[cfg(unix)] + fs::set_permissions(&path, unix_permissions(0o700))?; + } + let canonical = fs::canonicalize(&path)?; + let metadata = fs::metadata(&canonical)?; + if !metadata.is_dir() { + return Err(invalid_data("CODEX_HOME is not a directory")); + } + validate_same_owner(&metadata, &canonical)?; + Ok(canonical) +} + +fn normalize_existing_or_lexical(path: &Path) -> io::Result { + let path = normalize_absolute(path)?; + match fs::canonicalize(&path) { + Ok(path) => Ok(path), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(path), + Err(error) => Err(error), + } +} + +fn validate_codex_binary_compatibility(path: &Path, codex_home: &Path) -> io::Result { + ensure_hook_state_platform_supported()?; + let (path, version) = inspect_codex_binary_identity(path, codex_home)?; + validate_supported_codex_release(&version)?; + + let features = run_codex( + &path, + codex_home, + &[OsString::from("features"), OsString::from("list")], + )?; + require_success(&features, "checking native Codex hook features")?; + validate_required_feature_rows(&features.stdout)?; + validate_generated_hook_schema(&path, codex_home)?; + Ok(path) +} + +fn ensure_hook_state_platform_supported() -> io::Result<()> { + if super::state::hook_state_platform_supported() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "native Codex hook state is supported only on macOS and Linux", + )) + } +} + +fn validate_supported_codex_release(bytes: &[u8]) -> io::Result<()> { + let (major, minor, patch) = parse_codex_cli_version(bytes)?; + if (major, minor, patch) < MIN_SUPPORTED_CODEX_VERSION_COMPONENTS { + return Err(invalid_data(format!( + "Codex hook integration requires stable codex-cli {MIN_SUPPORTED_CODEX_VERSION} or newer, but the selected executable reports {major}.{minor}.{patch}" + ))); + } + Ok(()) +} + +pub(crate) fn codex_version_is_supported(version: &str) -> bool { + parse_stable_semver(version) + .is_some_and(|parsed| parsed >= MIN_SUPPORTED_CODEX_VERSION_COMPONENTS) +} + +pub(crate) fn codex_version_has_verified_code_mode_shape(version: &str) -> bool { + // Codex 0.145.0 and 0.146.0 create nested Code Mode hook IDs as + // `exec-` while the outer rollout records `exec` / `wait` under a + // provider `call_*` ID. Future releases stay fail-closed until that + // cross-surface shape is reviewed again. + matches!(parse_stable_semver(version), Some((0, 145 | 146, 0))) +} + +fn validate_codex_binary_identity(path: &Path, codex_home: &Path) -> io::Result { + inspect_codex_binary_identity(path, codex_home).map(|(path, _version)| path) +} + +fn capture_codex_binary_identity(path: &Path, codex_home: &Path) -> io::Result<(PathBuf, String)> { + let path = validate_executable_path(path, "Codex executable")?; + let before = executable_path_digest(&path)?; + let validated = validate_codex_binary_identity(&path, codex_home)?; + let after = executable_path_digest(&validated)?; + if validated != path || before != after { + return Err(invalid_data( + "the native Codex executable changed during identity preflight", + )); + } + Ok((validated, after)) +} + +fn capture_codex_binary_compatibility( + path: &Path, + codex_home: &Path, +) -> io::Result<(PathBuf, String)> { + let path = validate_executable_path(path, "Codex executable")?; + let before = executable_path_digest(&path)?; + let validated = validate_codex_binary_compatibility(&path, codex_home)?; + let after = executable_path_digest(&validated)?; + if validated != path || before != after { + return Err(invalid_data( + "the native Codex executable changed during compatibility preflight", + )); + } + Ok((validated, after)) +} + +fn inspect_codex_binary_identity(path: &Path, codex_home: &Path) -> io::Result<(PathBuf, Vec)> { + let path = validate_executable_path(path, "Codex executable")?; + let output = run_codex(&path, codex_home, &[OsString::from("--version")])?; + require_success(&output, "checking the native Codex version")?; + parse_codex_cli_version(&output.stdout)?; + Ok((path, output.stdout)) +} + +fn parse_codex_cli_version(bytes: &[u8]) -> io::Result<(u64, u64, u64)> { + let text = std::str::from_utf8(bytes) + .map_err(|_| invalid_data("native Codex returned a non-UTF-8 version"))?; + let line = text.strip_suffix('\n').unwrap_or(text); + let line = line.strip_suffix('\r').unwrap_or(line); + let version = line.strip_prefix("codex-cli ").ok_or_else(|| { + invalid_data("the selected executable did not report an exact native `codex-cli` version") + })?; + parse_stable_semver(version).ok_or_else(|| { + invalid_data(format!( + "native Codex reported an unsupported semantic version `{version}`" + )) + }) +} + +fn parse_stable_semver(version: &str) -> Option<(u64, u64, u64)> { + if version.is_empty() + || version + .bytes() + .any(|byte| !byte.is_ascii_digit() && byte != b'.') + { + return None; + } + let components = version.split('.').collect::>(); + if components.len() != 3 + || components.iter().any(|component| { + component.is_empty() || component.len() > 1 && component.starts_with('0') + }) + { + return None; + } + let parsed = components + .into_iter() + .map(str::parse::) + .collect::, _>>() + .ok()?; + Some((parsed[0], parsed[1], parsed[2])) +} + +fn validate_required_feature_rows(bytes: &[u8]) -> io::Result<()> { + let text = std::str::from_utf8(bytes) + .map_err(|_| invalid_data("native Codex returned a non-UTF-8 feature list"))?; + let mut found = BTreeSet::new(); + for line in text.lines() { + let fields = line.split_whitespace().collect::>(); + let Some(name @ ("hooks" | "plugins")) = fields.first().copied() else { + continue; + }; + if fields.len() != 3 || fields[1] != "stable" || fields[2] != "true" { + return Err(invalid_data(format!( + "native Codex feature `{name}` must be reported exactly as stable and enabled" + ))); + } + if !found.insert(name) { + return Err(invalid_data(format!( + "native Codex reported duplicate `{name}` feature rows" + ))); + } + } + if found != BTreeSet::from(["hooks", "plugins"]) { + return Err(invalid_data( + "native Codex did not report both stable enabled `hooks` and `plugins` features", + )); + } + Ok(()) +} + +fn validate_generated_hook_schema(codex_binary: &Path, codex_home: &Path) -> io::Result<()> { + let output_root = tempfile::Builder::new() + .prefix("abtop-codex-schema-") + .tempdir()?; + let output = run_codex( + codex_binary, + codex_home, + &[ + OsString::from("app-server"), + OsString::from("generate-json-schema"), + OsString::from("--out"), + output_root.path().as_os_str().to_owned(), + ], + )?; + require_success(&output, "generating the native Codex compatibility schema")?; + let schema_path = output_root + .path() + .join("v2/ConfigRequirementsReadResponse.json"); + let schema = read_bounded_generated_schema(&schema_path)?; + validate_hook_schema_bytes(&schema) +} + +fn validate_hook_schema_bytes(schema: &[u8]) -> io::Result<()> { + let schema: Value = serde_json::from_slice(schema).map_err(|error| { + invalid_data(format!( + "native Codex generated an invalid hook compatibility schema: {error}" + )) + })?; + let properties = schema + .get("definitions") + .and_then(|definitions| definitions.get("ManagedHooksRequirements")) + .and_then(|requirements| requirements.get("properties")) + .and_then(Value::as_object) + .ok_or_else(|| { + invalid_data("native Codex compatibility schema omitted managed hook event properties") + })?; + let actual = properties + .keys() + .filter(|name| name.as_bytes().first().is_some_and(u8::is_ascii_uppercase)) + .map(String::as_str) + .collect::>(); + let expected = HOOK_EVENTS.into_iter().collect::>(); + let missing = expected.difference(&actual).copied().collect::>(); + if !missing.is_empty() { + return Err(invalid_data(format!( + "native Codex managed-hook event set is incompatible: missing required events {}", + missing.join(", ") + ))); + } + Ok(()) +} + +fn read_bounded_generated_schema(path: &Path) -> io::Result> { + let metadata = fs::symlink_metadata(path).map_err(|error| { + io::Error::new( + error.kind(), + format!( + "cannot inspect generated Codex compatibility schema {}: {error}", + path.display() + ), + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > MAX_MANAGED_FILE + { + return Err(invalid_data(format!( + "generated Codex compatibility schema {} is unsafe or oversized", + path.display() + ))); + } + validate_same_owner(&metadata, path)?; + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = options.open(path)?; + if !same_file_metadata(&metadata, &file.metadata()?) { + return Err(invalid_data( + "generated Codex compatibility schema changed while it was opened", + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + (&mut file) + .take(MAX_MANAGED_FILE + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_MANAGED_FILE + || !same_file_metadata(&metadata, &fs::symlink_metadata(path)?) + { + return Err(invalid_data( + "generated Codex compatibility schema changed or exceeded its safety bound", + )); + } + Ok(bytes) +} + +fn validate_abtop_binary(path: &Path) -> io::Result { + let path = validate_executable_path(path, "abtop executable")?; + let canonical = fs::canonicalize(&path)?; + validate_executable_path(&canonical, "abtop executable") +} + +fn validate_executable_path(path: &Path, label: &str) -> io::Result { + if !path.is_absolute() { + return Err(invalid_data(format!("{label} path must be absolute"))); + } + let path = normalize_absolute(path)?; + let metadata = fs::metadata(&path).map_err(|error| { + io::Error::new(error.kind(), format!("cannot inspect {label}: {error}")) + })?; + if !metadata.is_file() { + return Err(invalid_data(format!("{label} is not a regular file"))); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o111 == 0 { + return Err(invalid_data(format!("{label} is not executable"))); + } + } + Ok(path) +} + +fn executable_target(path: &Path) -> bool { + let Ok(metadata) = fs::metadata(path) else { + return false; + }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +#[cfg(not(unix))] +fn ensure_private_data_ancestry(paths: &PluginPaths) -> io::Result<()> { + let plugins = paths.codex_home.join("plugins"); + let data = plugins.join("data"); + for directory in [&plugins, &data] { + match fs::symlink_metadata(directory) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(invalid_data(format!( + "unsafe Codex plugin data ancestor {}", + directory.display() + ))); + } + validate_same_owner(&metadata, directory)?; + validate_not_other_writable(&metadata, directory)?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir(directory)?; + #[cfg(unix)] + fs::set_permissions(directory, unix_permissions(0o700))?; + } + Err(error) => return Err(error), + } + } + Ok(()) +} + +#[cfg(any(not(unix), test))] +fn ensure_private_dir(path: &Path) -> io::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(invalid_data(format!( + "managed plugin path {} is not a safe directory", + path.display() + ))); + } + validate_same_owner(&metadata, path)?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir(path)?; + } + Err(error) => return Err(error), + } + #[cfg(unix)] + fs::set_permissions(path, unix_permissions(0o700))?; + Ok(()) +} + +#[cfg(any(not(unix), test))] +fn atomic_write_private(path: &Path, bytes: &[u8], executable: bool) -> io::Result<()> { + #[cfg(not(unix))] + let _ = executable; + if let Ok(metadata) = fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(invalid_data(format!( + "managed plugin path {} is not a regular file", + path.display() + ))); + } + validate_same_owner(&metadata, path)?; + } + let parent = path + .parent() + .ok_or_else(|| invalid_data("managed plugin file has no parent directory"))?; + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + #[cfg(unix)] + temporary + .as_file_mut() + .set_permissions(unix_permissions(if executable { 0o700 } else { 0o600 }))?; + temporary.write_all(bytes)?; + temporary.as_file_mut().sync_all()?; + temporary.persist(path).map_err(|error| error.error)?; + #[cfg(unix)] + File::open(parent)?.sync_all()?; + Ok(()) +} + +#[cfg(unix)] +fn atomic_write_private_at( + parent: &File, + name: &str, + path: &Path, + bytes: &[u8], + executable: bool, +) -> io::Result<()> { + use std::os::fd::AsRawFd; + use std::os::unix::fs::PermissionsExt; + + let target_name = std::ffi::CString::new(name.as_bytes()) + .map_err(|_| invalid_data("managed plugin file name contains NUL"))?; + let flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK; + let prior = openat_unix_cstr(parent, &target_name, flags)?; + if let Some(prior) = prior.as_ref() { + validate_owned_regular_file(path, &prior.metadata()?, true)?; + } + + let mode = if executable { 0o700 } else { 0o600 }; + let (temporary_name, mut temporary) = { + let mut created = None; + for _ in 0..32 { + let mut random = [0_u8; 16]; + getrandom::fill(&mut random).map_err(io::Error::other)?; + let name = format!(".abtop-write-{}", hex(&random)); + match openat_create_unix( + parent, + std::ffi::OsStr::new(&name), + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC, + mode, + ) { + Ok(file) => { + created = Some((name, file)); + break; + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + created.ok_or_else(|| { + io::Error::new( + io::ErrorKind::AlreadyExists, + "could not allocate a private managed-plugin temporary file", + ) + })? + }; + let temporary_name_c = std::ffi::CString::new(temporary_name.as_bytes()) + .map_err(|_| invalid_data("managed plugin temporary file name contains NUL"))?; + let mut renamed = false; + let outcome = (|| { + if unsafe { libc::fchmod(temporary.as_raw_fd(), mode) } != 0 { + return Err(io::Error::last_os_error()); + } + let temporary_metadata = temporary.metadata()?; + validate_owned_regular_file(path, &temporary_metadata, true)?; + if u64::from(temporary_metadata.permissions().mode() & 0o777) != u64::from(mode) { + return Err(invalid_data( + "managed plugin temporary file has an unexpected mode", + )); + } + temporary.write_all(bytes)?; + temporary.sync_all()?; + let prepared_metadata = temporary.metadata()?; + if prepared_metadata.len() != bytes.len() as u64 + || !same_file_content_snapshot(&prepared_metadata, &temporary.metadata()?) + { + return Err(invalid_data( + "managed plugin temporary file changed before installation", + )); + } + + let current = openat_unix_cstr(parent, &target_name, flags)?; + match (prior.as_ref(), current.as_ref()) { + (None, None) => {} + (Some(prior), Some(current)) + if same_file_content_snapshot(&prior.metadata()?, ¤t.metadata()?) => {} + _ => { + return Err(invalid_data(format!( + "managed plugin file {} changed at the replacement boundary", + path.display() + ))); + } + } + if unsafe { + libc::renameat( + parent.as_raw_fd(), + temporary_name_c.as_ptr(), + parent.as_raw_fd(), + target_name.as_ptr(), + ) + } != 0 + { + return Err(io::Error::last_os_error()); + } + renamed = true; + let installed = open_matching_managed_file_at(parent, name, path, bytes, executable, true)? + .ok_or_else(|| { + invalid_data(format!( + "managed plugin file {} did not validate after installation", + path.display() + )) + })?; + if !same_file_metadata(&prepared_metadata, &installed.metadata()?) { + return Err(invalid_data(format!( + "managed plugin file {} was replaced immediately after installation", + path.display() + ))); + } + parent.sync_all()?; + Ok(()) + })(); + if !renamed && unsafe { libc::unlinkat(parent.as_raw_fd(), temporary_name_c.as_ptr(), 0) } != 0 + { + let cleanup_error = io::Error::last_os_error(); + if outcome.is_ok() { + return Err(cleanup_error); + } + } + outcome +} + +#[cfg(not(unix))] +fn private_regular_file_matches( + path: &Path, + expected: &[u8], + _executable: bool, +) -> io::Result { + let Some(bytes) = read_private_regular_file(path)? else { + return Ok(false); + }; + if bytes != expected { + return Ok(false); + } + Ok(true) +} + +#[cfg(not(unix))] +fn private_regular_mode_matches(path: &Path, _executable: bool) -> io::Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + validate_owned_regular_file(path, &metadata, true)?; + Ok(true) +} + +#[cfg(not(unix))] +fn read_private_regular_file(path: &Path) -> io::Result>> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(invalid_data(format!( + "managed plugin path {} is not a regular file", + path.display() + ))); + } + if metadata.len() > MAX_MANAGED_FILE { + return Err(invalid_data(format!( + "managed plugin file {} is oversized", + path.display() + ))); + } + validate_same_owner(&metadata, path)?; + validate_single_link(&metadata, path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return Err(invalid_data(format!( + "managed plugin file {} is accessible by another user", + path.display() + ))); + } + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = options.open(path)?; + let opened = file.metadata()?; + if !same_file_metadata(&metadata, &opened) { + return Err(invalid_data(format!( + "managed plugin file {} changed while it was opened", + path.display() + ))); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + (&mut file) + .take(MAX_MANAGED_FILE + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_MANAGED_FILE { + return Err(invalid_data("managed plugin file is oversized")); + } + let after = fs::symlink_metadata(path)?; + if !same_file_metadata(&metadata, &after) { + return Err(invalid_data(format!( + "managed plugin file {} changed while it was inspected", + path.display() + ))); + } + Ok(Some(bytes)) +} + +fn executable_path_digest(path: &Path) -> io::Result { + let lexical_before = fs::symlink_metadata(path)?; + let canonical = fs::canonicalize(path)?; + validate_executable_path(&canonical, "native executable target")?; + let target_digest = hash_file(&canonical)?; + let lexical_after = fs::symlink_metadata(path)?; + let canonical_after = fs::canonicalize(path)?; + if !same_file_content_snapshot(&lexical_before, &lexical_after) || canonical_after != canonical + { + return Err(invalid_data( + "native executable path changed while its identity was inspected", + )); + } + let mut digest = Sha256::new(); + digest.update(path.as_os_str().as_encoded_bytes()); + digest.update([0]); + digest.update(canonical.as_os_str().as_encoded_bytes()); + digest.update([0]); + digest.update(target_digest.as_bytes()); + Ok(format!("sha256:{}", hex(&digest.finalize()))) +} + +fn hash_file(path: &Path) -> io::Result { + let path_metadata = fs::symlink_metadata(path)?; + if path_metadata.file_type().is_symlink() || !path_metadata.is_file() { + return Err(invalid_data( + "helper or native executable path is not a direct regular file", + )); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let mut file = options.open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() || !same_file_content_snapshot(&path_metadata, &metadata) { + return Err(invalid_data( + "helper or native executable changed while it was opened", + )); + } + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + let descriptor_after = file.metadata()?; + let path_after = fs::symlink_metadata(path)?; + if !same_file_content_snapshot(&metadata, &descriptor_after) + || !same_file_content_snapshot(&metadata, &path_after) + { + return Err(invalid_data( + "helper or native executable changed while it was hashed", + )); + } + Ok(format!("sha256:{}", hex(&hasher.finalize()))) +} + +fn run_mutating_codex( + binary: &Path, + expected_digest: &str, + codex_home: &Path, + args: &[OsString], +) -> io::Result { + ensure_codex_executable_identity(binary, expected_digest)?; + let result = run_codex(binary, codex_home, args); + let closing_identity = ensure_codex_executable_identity(binary, expected_digest); + match (result, closing_identity) { + (_, Err(identity_error)) => Err(invalid_data(format!( + "the native Codex executable changed during a mutating plugin command: {identity_error}" + ))), + (result, Ok(())) => result, + } +} + +fn ensure_codex_executable_identity(binary: &Path, expected_digest: &str) -> io::Result<()> { + let observed = executable_path_digest(binary)?; + if observed == expected_digest { + Ok(()) + } else { + Err(invalid_data( + "the selected lexical Codex executable path, target, or contents changed", + )) + } +} + +fn run_codex(binary: &Path, codex_home: &Path, args: &[OsString]) -> io::Result { + run_codex_with_timeout(binary, codex_home, args, COMMAND_TIMEOUT) +} + +#[cfg(unix)] +fn run_codex_with_timeout( + binary: &Path, + codex_home: &Path, + args: &[OsString], + timeout: Duration, +) -> io::Result { + use std::os::unix::process::CommandExt; + + let mut command = Command::new(binary); + command + .args(args) + .env("CODEX_HOME", codex_home) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .process_group(0); + let mut child = command.spawn()?; + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("missing stdout pipe"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("missing stderr pipe"))?; + if let Err(error) = set_nonblocking(&stdout).and_then(|()| set_nonblocking(&stderr)) { + terminate_unix_process_group(&mut child); + reap_child_bounded(child, Instant::now() + INHERITED_PIPE_GRACE); + return Err(error); + } + + let mut stdout = stdout; + let mut stderr = stderr; + let mut stdout_bytes = Vec::new(); + let mut stderr_bytes = Vec::new(); + let mut stdout_overflow = false; + let mut stderr_overflow = false; + let mut stdout_closed = false; + let mut stderr_closed = false; + let mut leader_exited = false; + let mut leader_exited_at = None; + let mut descendants_terminated_at = None; + let deadline = Instant::now() + timeout; + + loop { + if !stdout_closed { + let drained = + drain_nonblocking_pipe(&mut stdout, &mut stdout_bytes, &mut stdout_overflow); + match drained { + Ok(closed) => stdout_closed = closed, + Err(error) => { + terminate_unix_process_group(&mut child); + reap_child_bounded(child, Instant::now() + INHERITED_PIPE_GRACE); + return Err(error); + } + } + } + if !stderr_closed { + let drained = + drain_nonblocking_pipe(&mut stderr, &mut stderr_bytes, &mut stderr_overflow); + match drained { + Ok(closed) => stderr_closed = closed, + Err(error) => { + terminate_unix_process_group(&mut child); + reap_child_bounded(child, Instant::now() + INHERITED_PIPE_GRACE); + return Err(error); + } + } + } + if !leader_exited { + match child_exited_without_reaping(child.id()) { + Ok(exited) => { + leader_exited = exited; + if exited { + leader_exited_at = Some(Instant::now()); + } + } + Err(error) => { + terminate_unix_process_group(&mut child); + reap_child_bounded(child, Instant::now() + INHERITED_PIPE_GRACE); + return Err(error); + } + } + } + if leader_exited && stdout_closed && stderr_closed { + // `waitid(..., WNOWAIT)` deliberately kept the leader as a zombie + // until all group cleanup was complete. Its PID/PGID therefore + // could not be reused by an unrelated process before a group kill. + let status = child.wait()?; + return Ok(CommandOutput { + status, + stdout: stdout_bytes, + stderr: stderr_bytes, + overflowed: stdout_overflow || stderr_overflow, + }); + } + + let now = Instant::now(); + if !leader_exited && now >= deadline { + terminate_unix_process_group(&mut child); + reap_child_bounded(child, now + INHERITED_PIPE_GRACE); + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "native Codex plugin command timed out", + )); + } + if leader_exited_at.is_some_and(|exited| now.duration_since(exited) >= INHERITED_PIPE_GRACE) + && descendants_terminated_at.is_none() + { + // A descendant inherited one of the pipes after the native command + // exited. Kill the isolated command process group so pipe readers + // can never block setup or uninstall indefinitely. + terminate_unix_process_group(&mut child); + descendants_terminated_at = Some(now); + } + if descendants_terminated_at + .is_some_and(|terminated| now.duration_since(terminated) >= INHERITED_PIPE_GRACE) + { + reap_child_bounded(child, now + INHERITED_PIPE_GRACE); + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "native Codex plugin command left inherited output pipes open", + )); + } + std::thread::sleep(COMMAND_POLL_INTERVAL); + } +} + +#[cfg(unix)] +fn child_exited_without_reaping(pid: u32) -> io::Result { + let mut information = std::mem::MaybeUninit::::zeroed(); + let result = unsafe { + libc::waitid( + libc::P_PID, + pid as libc::id_t, + information.as_mut_ptr(), + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + let information = unsafe { information.assume_init() }; + Ok(unsafe { information.si_pid() } != 0) +} + +#[cfg(unix)] +fn set_nonblocking(pipe: &impl std::os::fd::AsRawFd) -> io::Result<()> { + let descriptor = pipe.as_raw_fd(); + let flags = unsafe { libc::fcntl(descriptor, libc::F_GETFL) }; + if flags < 0 { + return Err(io::Error::last_os_error()); + } + if unsafe { libc::fcntl(descriptor, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(unix)] +fn drain_nonblocking_pipe( + pipe: &mut impl Read, + output: &mut Vec, + overflow: &mut bool, +) -> io::Result { + const DRAIN_BUDGET: usize = 64 * 1024; + + let mut drained = 0; + let mut buffer = [0_u8; 8192]; + while drained < DRAIN_BUDGET { + match pipe.read(&mut buffer) { + Ok(0) => return Ok(true), + Ok(read) => { + drained += read; + let remaining = MAX_COMMAND_OUTPUT.saturating_sub(output.len()); + let keep = remaining.min(read); + output.extend_from_slice(&buffer[..keep]); + *overflow |= keep != read; + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(false), + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } + Ok(false) +} + +#[cfg(unix)] +fn terminate_unix_process_group(child: &mut Child) { + let process_group = -(child.id() as libc::pid_t); + let result = unsafe { libc::kill(process_group, libc::SIGKILL) }; + if result != 0 && io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) { + let _ = child.kill(); + } else { + // `kill(-pgid, ...)` can report ESRCH when the leader is the only + // process and has already exited. The direct kill is a harmless + // fallback for the remaining leader incarnation. + let _ = child.kill(); + } +} + +#[cfg(unix)] +fn reap_child_bounded(mut child: Child, deadline: Instant) { + loop { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(COMMAND_POLL_INTERVAL); + } + Ok(None) | Err(_) => break, + } + } + // Reaping is detached only after SIGKILL and a bounded grace period. The + // administrative call remains bounded even on a broken platform wait. + let _ = std::thread::Builder::new() + .name("abtop-codex-reaper".to_string()) + .spawn(move || { + let _ = child.wait(); + }); +} + +#[cfg(not(unix))] +fn run_codex_with_timeout( + binary: &Path, + codex_home: &Path, + args: &[OsString], + timeout: Duration, +) -> io::Result { + let mut child = Command::new(binary) + .args(args) + .env("CODEX_HOME", codex_home) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + #[cfg(windows)] + let job = WindowsCommandJob::assign(&child); + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("missing stdout pipe"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("missing stderr pipe"))?; + let (stdout_sender, stdout_receiver) = mpsc::sync_channel(1); + let (stderr_sender, stderr_receiver) = mpsc::sync_channel(1); + thread::spawn(move || { + let _ = stdout_sender.send(read_command_pipe(stdout)); + }); + thread::spawn(move || { + let _ = stderr_sender.send(read_command_pipe(stderr)); + }); + + let mut status = None; + let mut stdout = None; + let mut stderr = None; + let mut leader_exited_at = None; + let deadline = Instant::now() + timeout; + loop { + if status.is_none() { + status = child.try_wait()?; + if status.is_some() { + leader_exited_at = Some(Instant::now()); + } + } + if stdout.is_none() { + match stdout_receiver.try_recv() { + Ok(value) => stdout = Some(value?), + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + return Err(io::Error::other("Codex stdout reader failed")); + } + } + } + if stderr.is_none() { + match stderr_receiver.try_recv() { + Ok(value) => stderr = Some(value?), + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + return Err(io::Error::other("Codex stderr reader failed")); + } + } + } + if status.is_some() && stdout.is_some() && stderr.is_some() { + return match (status, stdout, stderr) { + ( + Some(status), + Some((stdout, stdout_overflow)), + Some((stderr, stderr_overflow)), + ) => Ok(CommandOutput { + status, + stdout, + stderr, + overflowed: stdout_overflow || stderr_overflow, + }), + _ => Err(io::Error::other( + "Codex command result changed while being collected", + )), + }; + } + + let now = Instant::now(); + let inherited_pipe_timeout = leader_exited_at + .is_some_and(|exited| now.duration_since(exited) >= INHERITED_PIPE_GRACE); + if now >= deadline || inherited_pipe_timeout { + #[cfg(windows)] + if let Some(job) = job.as_ref() { + job.terminate(); + } + let _ = child.kill(); + return Err(io::Error::new( + io::ErrorKind::TimedOut, + if inherited_pipe_timeout { + "native Codex plugin command left inherited output pipes open" + } else { + "native Codex plugin command timed out" + }, + )); + } + thread::sleep(COMMAND_POLL_INTERVAL); + } +} + +#[cfg(windows)] +#[derive(Debug)] +struct WindowsCommandJob { + handle: windows_sys::Win32::Foundation::HANDLE, +} + +#[cfg(windows)] +impl WindowsCommandJob { + fn assign(child: &Child) -> Option { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + + let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if handle.is_null() { + return None; + } + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let configured = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + (&raw const limits).cast(), + std::mem::size_of::() as u32, + ) + } != 0; + let assigned = configured + && unsafe { AssignProcessToJobObject(handle, child.as_raw_handle() as _) } != 0; + if !assigned { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + return None; + } + Some(Self { handle }) + } + + fn terminate(&self) { + unsafe { + windows_sys::Win32::System::JobObjects::TerminateJobObject(self.handle, 1); + } + } +} + +#[cfg(windows)] +impl Drop for WindowsCommandJob { + fn drop(&mut self) { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(self.handle); + } + } +} + +#[cfg(not(unix))] +fn read_command_pipe(mut pipe: impl Read) -> io::Result<(Vec, bool)> { + let mut output = Vec::new(); + let mut buffer = [0_u8; 8192]; + let mut overflow = false; + loop { + let read = pipe.read(&mut buffer)?; + if read == 0 { + break; + } + let remaining = MAX_COMMAND_OUTPUT.saturating_sub(output.len()); + let keep = remaining.min(read); + output.extend_from_slice(&buffer[..keep]); + overflow |= keep != read; + } + Ok((output, overflow)) +} + +fn require_success(output: &CommandOutput, action: &str) -> io::Result<()> { + if output.status.success() && !output.overflowed { + Ok(()) + } else { + Err(command_failure(action, output)) + } +} + +fn command_failure(action: &str, output: &CommandOutput) -> io::Error { + let detail = sanitize_output(if output.stderr.is_empty() { + &output.stdout + } else { + &output.stderr + }); + let suffix = if output.overflowed { + "output exceeded the 1 MiB safety limit".to_string() + } else if detail.is_empty() { + format!("exit status {}", output.status) + } else { + detail + }; + io::Error::other(format!("failed while {action}: {suffix}")) +} + +fn with_cleanup_errors(primary: io::Error, cleanup: Vec) -> io::Error { + if cleanup.is_empty() { + return primary; + } + let details = cleanup + .into_iter() + .map(|error| error.to_string()) + .collect::>() + .join("; "); + io::Error::new( + primary.kind(), + format!("{primary}; cleanup was incomplete: {details}"), + ) +} + +fn sanitize_output(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes) + .chars() + .filter(|character| !character.is_control() || matches!(character, '\n' | '\t')) + .take(2048) + .collect::() + .trim() + .to_string() +} + +fn parse_json(bytes: &[u8], label: &str) -> io::Result { + serde_json::from_slice(bytes) + .map_err(|error| invalid_data(format!("Codex {label} returned invalid JSON: {error}"))) +} + +fn require_json_object(bytes: &[u8], label: &str) -> io::Result<()> { + if parse_json(bytes, label)?.is_object() { + Ok(()) + } else { + Err(invalid_data(format!( + "Codex {label} did not return a JSON object" + ))) + } +} + +fn paths_equal(left: &Path, right: &Path) -> bool { + let left = fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf()); + let right = fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf()); + left == right +} + +#[cfg(unix)] +fn same_file_metadata(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(unix)] +fn same_file_content_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + same_file_metadata(left, right) + && left.len() == right.len() + && left.mode() == right.mode() + && left.mtime() == right.mtime() + && left.mtime_nsec() == right.mtime_nsec() + && left.ctime() == right.ctime() + && left.ctime_nsec() == right.ctime_nsec() +} + +#[cfg(not(unix))] +fn same_file_metadata(left: &fs::Metadata, right: &fs::Metadata) -> bool { + left.len() == right.len() && left.modified().ok() == right.modified().ok() +} + +#[cfg(not(unix))] +fn same_file_content_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool { + same_file_metadata(left, right) +} + +fn pretty_json(value: &Value) -> io::Result> { + let mut bytes = serde_json::to_vec_pretty(value).map_err(io::Error::other)?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn hash_bytes(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("sha256:{}", hex(&hasher.finalize())) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn quote_posix(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn quote_cmd_path(value: &str) -> io::Result { + validate_embedded_text(value, "abtop executable path")?; + if value.contains('"') { + return Err(invalid_data( + "the abtop executable path cannot contain a double quote on Windows", + )); + } + Ok(format!("\"{}\"", value.replace('%', "%%"))) +} + +fn escape_cmd_set_value(value: &str) -> io::Result { + validate_embedded_text(value, "Codex hook fault directory")?; + if value.contains('"') { + return Err(invalid_data( + "the Codex hook fault directory cannot contain a double quote on Windows", + )); + } + Ok(value.replace('%', "%%")) +} + +fn validate_embedded_text(value: &str, label: &str) -> io::Result<()> { + if value + .chars() + .any(|character| character == '\0' || matches!(character, '\r' | '\n')) + { + return Err(invalid_data(format!( + "{label} contains an unsafe character" + ))); + } + Ok(()) +} + +fn hook_event_key(event: &str) -> &'static str { + match event { + "PreToolUse" => "pre_tool_use", + "PermissionRequest" => "permission_request", + "PostToolUse" => "post_tool_use", + "PreCompact" => "pre_compact", + "PostCompact" => "post_compact", + "SessionStart" => "session_start", + "SessionEnd" => "session_end", + "UserPromptSubmit" => "user_prompt_submit", + "SubagentStart" => "subagent_start", + "SubagentStop" => "subagent_stop", + "Stop" => "stop", + _ => unreachable!("HOOK_EVENTS contains only known events"), + } +} + +fn normalize_absolute(path: &Path) -> io::Result { + if !path.is_absolute() { + return Err(invalid_data("path must be absolute")); + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + return Err(invalid_data("path escapes the filesystem root")); + } + } + } + } + Ok(normalized) +} + +#[cfg(unix)] +fn unix_permissions(mode: u32) -> fs::Permissions { + use std::os::unix::fs::PermissionsExt; + fs::Permissions::from_mode(mode) +} + +#[cfg(unix)] +fn validate_same_owner(metadata: &fs::Metadata, path: &Path) -> io::Result<()> { + use std::os::unix::fs::MetadataExt; + if metadata.uid() != unsafe { libc::geteuid() } { + return Err(invalid_data(format!( + "{} is not owned by the current user", + path.display() + ))); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_same_owner(_metadata: &fs::Metadata, _path: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn validate_single_link(metadata: &fs::Metadata, path: &Path) -> io::Result<()> { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() != 1 { + return Err(invalid_data(format!( + "managed plugin file {} has multiple hard links", + path.display() + ))); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_single_link(_metadata: &fs::Metadata, _path: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn validate_not_other_writable(metadata: &fs::Metadata, path: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o022 != 0 { + return Err(invalid_data(format!( + "{} is writable by another user", + path.display() + ))); + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_not_other_writable(_metadata: &fs::Metadata, _path: &Path) -> io::Result<()> { + Ok(()) +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + fn executable_file(path: &Path, contents: &[u8]) { + use std::os::unix::fs::PermissionsExt; + fs::write(path, contents).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + + #[cfg(unix)] + fn fixture_bundle() -> (tempfile::TempDir, PluginPaths, RenderedBundle) { + let temp = tempfile::tempdir().unwrap(); + let codex_home = temp.path().join("codex"); + fs::create_dir(&codex_home).unwrap(); + let codex_home = fs::canonicalize(codex_home).unwrap(); + let helper = temp.path().join("abtop"); + executable_file(&helper, b"helper bytes"); + let paths = PluginPaths::new(&codex_home).unwrap(); + let bundle = render_bundle(&helper, &paths.plugin_data_root).unwrap(); + (temp, paths, bundle) + } + + #[cfg(unix)] + fn rendered_test_bundle(helper: &Path) -> RenderedBundle { + render_bundle(helper, &helper.parent().unwrap().join("abtop-abtop-local")).unwrap() + } + + #[test] + #[cfg(unix)] + fn codex_runner_kills_a_descendant_that_inherits_output_pipes() { + let temp = tempfile::tempdir().unwrap(); + let codex_home = temp.path().join("codex-home"); + fs::create_dir(&codex_home).unwrap(); + let binary = temp.path().join("codex-fake"); + executable_file( + &binary, + b"#!/bin/sh\nsleep 30 &\nprintf '%s\\n' \"$!\" > \"$CODEX_HOME/descendant.pid\"\nprintf '{}\\n'\nexit 0\n", + ); + + let started = Instant::now(); + let output = + run_codex_with_timeout(&binary, &codex_home, &[], Duration::from_secs(15)).unwrap(); + assert!(output.status.success()); + assert_eq!(output.stdout, b"{}\n"); + assert!(started.elapsed() < Duration::from_secs(20)); + + let pid = fs::read_to_string(codex_home.join("descendant.pid")) + .unwrap() + .trim() + .parse::() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + while unsafe { libc::kill(pid, 0) } == 0 && Instant::now() < deadline { + std::thread::sleep(COMMAND_POLL_INTERVAL); + } + assert_ne!(unsafe { libc::kill(pid, 0) }, 0); + } + + #[test] + #[cfg(unix)] + fn codex_runner_timeout_kills_the_whole_process_group_without_blocking() { + let temp = tempfile::tempdir().unwrap(); + let codex_home = temp.path().join("codex-home"); + fs::create_dir(&codex_home).unwrap(); + let binary = temp.path().join("codex-fake"); + executable_file( + &binary, + b"#!/bin/sh\nprintf '%s\\n' \"$$\" > \"$CODEX_HOME/leader.pid\"\nsleep 30 &\nprintf '%s\\n' \"$!\" > \"$CODEX_HOME/descendant.pid\"\nwait\n", + ); + + let started = Instant::now(); + let error = + run_codex_with_timeout(&binary, &codex_home, &[], Duration::from_secs(15)).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert!(started.elapsed() < Duration::from_secs(20)); + for name in ["leader.pid", "descendant.pid"] { + let pid = fs::read_to_string(codex_home.join(name)) + .unwrap_or_else(|error| { + panic!("timeout fixture did not record {name} before termination: {error}") + }) + .trim() + .parse::() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + while unsafe { libc::kill(pid, 0) } == 0 && Instant::now() < deadline { + std::thread::sleep(COMMAND_POLL_INTERVAL); + } + assert_ne!(unsafe { libc::kill(pid, 0) }, 0, "{name} survived"); + } + } + + #[test] + fn windows_pathext_order_includes_com_and_deduplicates_case_insensitively() { + let names = windows_codex_executable_names_from_text(Some(".COM;.EXE;.com;.CMD")); + assert_eq!( + names, + vec![ + OsString::from("codex.COM"), + OsString::from("codex.EXE"), + OsString::from("codex.CMD") + ] + ); + assert_eq!( + windows_codex_executable_names(None).first(), + Some(&OsString::from("codex.COM")) + ); + } + + #[test] + #[cfg(unix)] + fn path_resolution_preserves_pathext_precedence_and_the_lexical_entry() { + let temp = tempfile::tempdir().unwrap(); + let path_root = temp.path().join("path-entry"); + fs::create_dir(&path_root).unwrap(); + executable_file(&path_root.join("codex.COM"), b"com"); + executable_file(&path_root.join("codex.EXE"), b"exe"); + let names = windows_codex_executable_names_from_text(Some(".COM;.EXE")); + let path = std::env::join_paths([&path_root]).unwrap(); + let resolved = resolve_codex_binary_in_path(&path, temp.path(), &names).unwrap(); + assert_eq!(resolved, path_root.join("codex.COM")); + } + + #[test] + #[cfg(unix)] + fn stable_setup_lock_is_private_and_reuses_one_inode() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let temp = tempfile::tempdir().unwrap(); + let codex_home = temp.path().join("codex-home"); + fs::create_dir(&codex_home).unwrap(); + let first = SetupLock::acquire(&codex_home).unwrap(); + first.revalidate().unwrap(); + let lock_path = codex_home.join(SETUP_LOCK_FILE); + let first_metadata = fs::symlink_metadata(&lock_path).unwrap(); + assert_eq!(first_metadata.permissions().mode() & 0o777, 0o600); + drop(first); + let second = SetupLock::acquire(&codex_home).unwrap(); + second.revalidate().unwrap(); + let second_metadata = fs::symlink_metadata(lock_path).unwrap(); + assert_eq!(first_metadata.dev(), second_metadata.dev()); + assert_eq!(first_metadata.ino(), second_metadata.ino()); + } + + #[test] + #[cfg(unix)] + fn identity_preflight_rejects_an_atomic_executable_replacement() { + let temp = tempfile::tempdir().unwrap(); + let codex_home = temp.path().join("codex-home"); + fs::create_dir(&codex_home).unwrap(); + let binary = temp.path().join("codex-fake"); + let replacement = codex_home.join("replacement"); + executable_file( + &binary, + b"#!/bin/sh\nif [ \"$1\" = '--version' ]; then mv \"$CODEX_HOME/replacement\" \"$0\"; printf 'codex-cli 0.146.0\\n'; exit 0; fi\nexit 47\n", + ); + executable_file(&replacement, b"#!/bin/sh\nprintf 'codex-cli 0.146.0\\n'\n"); + + let error = capture_codex_binary_identity(&binary, &codex_home).unwrap_err(); + assert!(error + .to_string() + .contains("changed during identity preflight")); + } + + #[test] + #[cfg(unix)] + fn mutating_command_rejects_an_atomic_executable_replacement() { + let temp = tempfile::tempdir().unwrap(); + let codex_home = temp.path().join("codex-home"); + fs::create_dir(&codex_home).unwrap(); + let binary = temp.path().join("codex-fake"); + let replacement = codex_home.join("replacement"); + executable_file( + &binary, + b"#!/bin/sh\nmv \"$CODEX_HOME/replacement\" \"$0\"\nprintf '{}\\n'\n", + ); + executable_file(&replacement, b"#!/bin/sh\nprintf '{}\\n'\n"); + let digest = executable_path_digest(&binary).unwrap(); + + let error = run_mutating_codex(&binary, &digest, &codex_home, &[]).unwrap_err(); + assert!(error + .to_string() + .contains("changed during a mutating plugin command")); + } + + #[cfg(unix)] + fn write_cached_bundle(paths: &PluginPaths, bundle: &RenderedBundle) { + let version_root = cache_version_path(paths, &bundle.plugin_version); + for directory in [ + version_root.clone(), + version_root.join(".codex-plugin"), + version_root.join("hooks"), + version_root.join("scripts"), + ] { + fs::create_dir_all(directory).unwrap(); + } + atomic_write_private( + &version_root.join(".codex-plugin/plugin.json"), + &bundle.plugin_manifest, + false, + ) + .unwrap(); + atomic_write_private( + &version_root.join("hooks/hooks.json"), + &bundle.hooks_manifest, + false, + ) + .unwrap(); + atomic_write_private( + &version_root.join("scripts/abtop-codex-hook.sh"), + &bundle.posix_launcher, + true, + ) + .unwrap(); + atomic_write_private( + &version_root.join("scripts/abtop-codex-hook.cmd"), + &bundle.windows_launcher, + false, + ) + .unwrap(); + } + + #[cfg(unix)] + fn write_trusted_base_hook_config(paths: &PluginPaths, bundle: &RenderedBundle) { + let mut states = toml::map::Map::new(); + for identity in &bundle.hook_commands { + let key = format!("{PLUGIN_ID}:hooks/hooks.json:{}:0:0", identity.event_key); + let mut state = toml::map::Map::new(); + state.insert("enabled".to_string(), toml::Value::Boolean(true)); + state.insert( + "trusted_hash".to_string(), + toml::Value::String(expected_trust_hash(identity)), + ); + states.insert(key, toml::Value::Table(state)); + } + let mut hooks = toml::map::Map::new(); + hooks.insert("state".to_string(), toml::Value::Table(states)); + let mut root = toml::map::Map::new(); + root.insert("hooks".to_string(), toml::Value::Table(hooks)); + let mut plugin = toml::map::Map::new(); + plugin.insert("enabled".to_string(), toml::Value::Boolean(true)); + let mut plugins = toml::map::Map::new(); + plugins.insert(PLUGIN_ID.to_string(), toml::Value::Table(plugin)); + root.insert("plugins".to_string(), toml::Value::Table(plugins)); + let config = toml::to_string(&toml::Value::Table(root)).unwrap(); + atomic_write_private( + &paths.codex_home.join("config.toml"), + config.as_bytes(), + false, + ) + .unwrap(); + } + + #[test] + #[cfg(unix)] + fn bundle_declares_all_supported_hooks_without_matchers() { + let temp = tempfile::tempdir().unwrap(); + let helper = temp.path().join("abtop"); + executable_file(&helper, b"helper bytes"); + let bundle = rendered_test_bundle(&helper); + let value: Value = serde_json::from_slice(&bundle.hooks_manifest).unwrap(); + let hooks = value["hooks"].as_object().unwrap(); + assert_eq!(hooks.len(), HOOK_EVENTS.len()); + for event in HOOK_EVENTS { + let group = &hooks[event][0]; + assert!(group.get("matcher").is_none()); + let handler = &group["hooks"][0]; + assert_eq!(handler["timeout"], 1); + assert!(handler.get("async").is_none()); + assert!(handler["command"] + .as_str() + .unwrap() + .contains(HOOK_SCHEMA_REVISION)); + assert!(handler["command"] + .as_str() + .unwrap() + .contains(&bundle.helper_digest)); + let command = handler["command"].as_str().unwrap(); + assert!(command.starts_with("\"$PLUGIN_ROOT/scripts/abtop-codex-hook.sh\" ")); + assert!(command.ends_with(">/dev/null 2>&1 || :; exit 0")); + let command_windows = handler["commandWindows"].as_str().unwrap(); + assert!(command_windows.starts_with("cmd.exe /D /C call ")); + assert!(command_windows.ends_with(">nul 2>nul || exit /b 0")); + } + assert!(hooks.get("PostToolUseFailure").is_none()); + } + + #[test] + #[cfg(unix)] + fn posix_hook_command_invokes_the_exact_launcher_argv_silently() { + let temp = tempfile::tempdir().unwrap(); + let helper = temp.path().join("abtop-helper"); + executable_file(&helper, b"helper bytes"); + let bundle = rendered_test_bundle(&helper); + let manifest: Value = serde_json::from_slice(&bundle.hooks_manifest).unwrap(); + let command = manifest["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + + let plugin_root = temp.path().join("plugin root"); + fs::create_dir_all(plugin_root.join("scripts")).unwrap(); + let launcher = plugin_root.join("scripts/abtop-codex-hook.sh"); + executable_file( + &launcher, + b"#!/bin/sh\nprintf '%s\\n' \"$#\" \"$@\" > \"$ABTOP_TEST_ARGV\"\nprintf 'sensitive stdout\\n'\nprintf 'sensitive stderr\\n' >&2\nexit 37\n", + ); + let captured = temp.path().join("argv.txt"); + + let output = Command::new("sh") + .args(["-c", command]) + .env("PLUGIN_ROOT", &plugin_root) + .env("ABTOP_TEST_ARGV", &captured) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + assert_eq!( + fs::read_to_string(captured).unwrap(), + format!( + "4\n--schema-revision\n{HOOK_SCHEMA_REVISION}\n--helper-digest\n{}\n", + bundle.helper_digest + ) + ); + } + + #[test] + #[cfg(unix)] + fn posix_hook_command_absorbs_missing_and_unexecutable_launchers() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let helper = temp.path().join("abtop-helper"); + executable_file(&helper, b"helper bytes"); + let bundle = rendered_test_bundle(&helper); + let manifest: Value = serde_json::from_slice(&bundle.hooks_manifest).unwrap(); + let command = manifest["hooks"]["PostToolUse"][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + + let missing_root = temp.path().join("missing plugin root"); + let missing = Command::new("sh") + .args(["-c", command]) + .env("PLUGIN_ROOT", &missing_root) + .output() + .unwrap(); + assert!(missing.status.success()); + assert!(missing.stdout.is_empty()); + assert!(missing.stderr.is_empty()); + + let unexecutable_root = temp.path().join("unexecutable plugin root"); + fs::create_dir_all(unexecutable_root.join("scripts")).unwrap(); + let launcher = unexecutable_root.join("scripts/abtop-codex-hook.sh"); + fs::write(&launcher, b"#!/bin/sh\nexit 91\n").unwrap(); + fs::set_permissions(&launcher, fs::Permissions::from_mode(0o600)).unwrap(); + let unexecutable = Command::new("sh") + .args(["-c", command]) + .env("PLUGIN_ROOT", &unexecutable_root) + .output() + .unwrap(); + assert!(unexecutable.status.success()); + assert!(unexecutable.stdout.is_empty()); + assert!(unexecutable.stderr.is_empty()); + } + + #[test] + #[cfg(unix)] + fn manifest_omits_the_unsupported_hooks_field() { + let temp = tempfile::tempdir().unwrap(); + let helper = temp.path().join("abtop"); + executable_file(&helper, b"helper bytes"); + let bundle = rendered_test_bundle(&helper); + let manifest: Value = serde_json::from_slice(&bundle.plugin_manifest).unwrap(); + assert_eq!(manifest["name"], PLUGIN_NAME); + assert!(manifest.get("hooks").is_none()); + assert!(manifest["version"].as_str().unwrap().contains("+codex.")); + } + + #[test] + #[cfg(unix)] + fn launcher_is_silent_and_invokes_the_exact_helper() { + let temp = tempfile::tempdir().unwrap(); + let helper = temp.path().join("abtop executable"); + executable_file(&helper, b"helper bytes"); + let bundle = rendered_test_bundle(&helper); + let script = String::from_utf8(bundle.posix_launcher).unwrap(); + assert!(script.contains("'/")); + assert!(!script.contains("\nexec '/")); + assert!(script.contains("--codex-hook-ingest")); + assert!(script.contains(">/dev/null 2>&1")); + assert!(script.contains("set -C")); + assert!(script.contains("mktemp \"$abtop_fault_dir/launch-$$-pending.XXXXXXXXXXXXXXXX\"")); + assert!(script.contains(HOOK_FAULT_TOKEN_ENV)); + assert!(script.contains("/states/faults")); + assert!(script.contains("launch-$abtop_fault_slot-abtopv1.pending")); + assert!(script.contains("overflow.json")); + let expected_fault_directory = helper + .parent() + .unwrap() + .join("abtop-abtop-local/states/faults"); + assert!(script.contains(expected_fault_directory.to_string_lossy().as_ref())); + assert!(!script.contains("${PLUGIN_DATA")); + assert!(!script.contains("echo ")); + } + + #[test] + #[cfg(unix)] + fn posix_launcher_absorbs_helper_failure_and_output() { + let temp = tempfile::tempdir().unwrap(); + let helper = temp.path().join("abtop-helper"); + executable_file( + &helper, + b"#!/bin/sh\nprintf 'sensitive stdout\\n'\nprintf 'sensitive stderr\\n' >&2\nexit 37\n", + ); + let bundle = rendered_test_bundle(&helper); + let launcher = temp.path().join("launcher.sh"); + executable_file(&launcher, &bundle.posix_launcher); + fs::create_dir_all(temp.path().join("abtop-abtop-local/states/faults")).unwrap(); + + let output = Command::new(&launcher) + .args([ + "--schema-revision", + HOOK_SCHEMA_REVISION, + "--helper-digest", + &bundle.helper_digest, + ]) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + } + + #[test] + #[cfg(unix)] + fn posix_launcher_marks_the_embedded_private_directory_without_provider_env() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let helper = temp.path().join("abtop-helper"); + executable_file( + &helper, + format!( + "#!/bin/sh\nprintf '%s\\n' \"${{{HOOK_FAULT_TOKEN_ENV}-}}\" > \"$ABTOP_TEST_TOKEN_OUTPUT\"\ntest -f \"$ABTOP_TEST_FAULT_DIR/${{{HOOK_FAULT_TOKEN_ENV}}}\"\n" + ) + .as_bytes(), + ); + let bundle = rendered_test_bundle(&helper); + let launcher = temp.path().join("launcher.sh"); + fs::write(&launcher, &bundle.posix_launcher).unwrap(); + fs::set_permissions(&launcher, fs::Permissions::from_mode(0o700)).unwrap(); + let plugin_data = temp.path().join("abtop-abtop-local"); + let faults = plugin_data + .join(HOOK_STATE_DIR_NAME) + .join(HOOK_FAULT_DIR_NAME); + fs::create_dir_all(&faults).unwrap(); + fs::set_permissions(&plugin_data, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions( + plugin_data.join(HOOK_STATE_DIR_NAME), + fs::Permissions::from_mode(0o700), + ) + .unwrap(); + fs::set_permissions(&faults, fs::Permissions::from_mode(0o700)).unwrap(); + let output = temp.path().join("token.txt"); + + let status = Command::new(&launcher) + .args([ + "--schema-revision", + HOOK_SCHEMA_REVISION, + "--helper-digest", + &bundle.helper_digest, + ]) + .env_remove("PLUGIN_DATA") + .env_remove("CLAUDE_PLUGIN_DATA") + .env("ABTOP_TEST_FAULT_DIR", &faults) + .env("ABTOP_TEST_TOKEN_OUTPUT", &output) + .status() + .unwrap(); + assert!(status.success()); + let token = fs::read_to_string(&output).unwrap(); + let token = token.trim(); + let body = token.strip_prefix("launch-").unwrap(); + let (pid, nonce) = body.split_once("-pending.").unwrap(); + assert!(!pid.is_empty() && pid.bytes().all(|byte| byte.is_ascii_digit())); + assert_eq!(nonce.len(), 16); + assert!(nonce.bytes().all(|byte| byte.is_ascii_alphanumeric())); + let marker = faults.join(token); + let metadata = fs::symlink_metadata(marker).unwrap(); + assert!(metadata.is_file()); + assert_eq!(metadata.len(), 0); + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + } + + #[test] + #[cfg(unix)] + fn posix_launcher_never_forwards_an_inherited_fault_token() { + let temp = tempfile::tempdir().unwrap(); + let helper = temp.path().join("abtop-helper"); + executable_file( + &helper, + format!( + "#!/bin/sh\nprintf '%s\\n' \"${{{HOOK_FAULT_TOKEN_ENV}-}}\" > \"$ABTOP_TEST_TOKEN_OUTPUT\"\n" + ) + .as_bytes(), + ); + let bundle = rendered_test_bundle(&helper); + let launcher = temp.path().join("launcher.sh"); + executable_file(&launcher, &bundle.posix_launcher); + let plugin_data = temp.path().join("abtop-abtop-local"); + fs::create_dir(&plugin_data).unwrap(); + let output = temp.path().join("token.txt"); + + let status = Command::new(&launcher) + .args([ + "--schema-revision", + HOOK_SCHEMA_REVISION, + "--helper-digest", + &bundle.helper_digest, + ]) + .env("PLUGIN_DATA", &plugin_data) + .env(HOOK_FAULT_TOKEN_ENV, "launch-1-inherited.pending") + .env("ABTOP_TEST_TOKEN_OUTPUT", &output) + .status() + .unwrap(); + assert!(status.success()); + assert_eq!(fs::read_to_string(output).unwrap(), "\n"); + } + + #[test] + #[cfg(unix)] + fn posix_launcher_never_truncates_colliding_pending_markers() { + let temp = tempfile::tempdir().unwrap(); + let helper = temp.path().join("abtop-helper"); + executable_file( + &helper, + format!( + "#!/bin/sh\nprintf '%s\\n' \"${{{HOOK_FAULT_TOKEN_ENV}-}}\" > \"$ABTOP_TEST_TOKEN_OUTPUT\"\n" + ) + .as_bytes(), + ); + let bundle = rendered_test_bundle(&helper); + let launcher = temp.path().join("launcher.sh"); + executable_file(&launcher, &bundle.posix_launcher); + let plugin_data = temp.path().join("abtop-abtop-local"); + let faults = plugin_data + .join(HOOK_STATE_DIR_NAME) + .join(HOOK_FAULT_DIR_NAME); + fs::create_dir_all(&faults).unwrap(); + let output = temp.path().join("token.txt"); + let wrapper = format!( + "mktemp() {{ return 1; }}\ni=0\nwhile [ \"$i\" -lt 16 ]; do\n printf sentinel > \"$PLUGIN_DATA/{HOOK_STATE_DIR_NAME}/{HOOK_FAULT_DIR_NAME}/launch-$i-abtopv1.pending\"\n i=$((i + 1))\ndone\nset -- --schema-revision '{HOOK_SCHEMA_REVISION}' --helper-digest '{}'\n. \"$ABTOP_TEST_LAUNCHER\"\n", + bundle.helper_digest + ); + + let status = Command::new("sh") + .args(["-c", &wrapper]) + .env("PLUGIN_DATA", &plugin_data) + .env("ABTOP_TEST_LAUNCHER", &launcher) + .env("ABTOP_TEST_TOKEN_OUTPUT", &output) + .status() + .unwrap(); + assert!(status.success()); + assert_eq!(fs::read_to_string(output).unwrap(), "\n"); + let entries = fs::read_dir(faults) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(entries.len(), 17); + for entry in entries { + if entry.file_name() == "overflow.json" { + assert!(fs::read(entry.path()).unwrap().is_empty()); + } else { + assert_eq!(fs::read(entry.path()).unwrap(), b"sentinel"); + } + } + } + + #[test] + #[cfg(unix)] + fn setup_rejects_codex_older_than_the_minimum_before_writing_the_bundle() { + let temp = tempfile::tempdir().unwrap(); + let codex_home = temp.path().join("codex"); + fs::create_dir(&codex_home).unwrap(); + let codex = temp.path().join("codex-old"); + executable_file(&codex, b"#!/bin/sh\nprintf 'codex-cli 0.144.0\\n'\n"); + let helper = temp.path().join("abtop"); + executable_file(&helper, b"helper bytes"); + + let error = install_after_legacy_cleanup(&codex_home, &codex, &helper).unwrap_err(); + assert!(error + .to_string() + .contains("requires stable codex-cli 0.145.0 or newer")); + assert!(!codex_home.join("abtop").exists()); + } + + #[test] + #[cfg(unix)] + fn setup_preflight_rejects_incompatible_codex_before_legacy_profile_edits() { + let temp = tempfile::tempdir().unwrap(); + let codex_home = temp.path().join("codex"); + fs::create_dir(&codex_home).unwrap(); + let legacy_home = temp.path().join("legacy-home"); + fs::create_dir(&legacy_home).unwrap(); + let profile = legacy_home.join(".zshrc"); + let original = format!( + "before\n{}\nfunction codex() {{ :; }}\n{}\nafter\n", + migration::LEGACY_START_MARKER, + migration::LEGACY_END_MARKER + ); + fs::write(&profile, &original).unwrap(); + let codex = temp.path().join("codex-old"); + executable_file(&codex, b"#!/bin/sh\nprintf 'codex-cli 0.144.0\\n'\n"); + let helper = temp.path().join("abtop"); + executable_file(&helper, b"helper bytes"); + + let error = setup_with_home(&codex_home, &codex, &helper, &legacy_home).unwrap_err(); + assert!(error + .to_string() + .contains("requires stable codex-cli 0.145.0 or newer")); + assert_eq!(fs::read_to_string(profile).unwrap(), original); + assert!(!legacy_home.join(".abtop-codex-migration.lock").exists()); + } + + #[test] + #[cfg(unix)] + fn setup_preflight_invokes_a_multicall_codex_symlink_by_its_lexical_path() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let codex_home = temp.path().join("codex-home"); + fs::create_dir(&codex_home).unwrap(); + let mut properties = serde_json::Map::new(); + for event in HOOK_EVENTS { + properties.insert(event.to_string(), json!({"type": "array"})); + } + properties.insert("FutureLifecycleEvent".to_string(), json!({"type": "array"})); + let schema = serde_json::to_string(&json!({ + "definitions": { + "ManagedHooksRequirements": { "properties": properties } + } + })) + .unwrap(); + let multicall_target = temp.path().join("mise"); + executable_file( + &multicall_target, + format!( + r##"#!/bin/sh +if [ "$(basename "$0")" != "codex" ]; then + exit 91 +fi +if [ "$1" = "--version" ]; then + printf 'codex-cli 0.147.0\n' + exit 0 +fi +if [ "$1" = "features" ] && [ "$2" = "list" ]; then + printf 'hooks stable true\nplugins stable true\n' + exit 0 +fi +if [ "$1" = "app-server" ] && [ "$2" = "generate-json-schema" ] && [ "$3" = "--out" ]; then + mkdir -p "$4/v2" + printf '%s\n' {} > "$4/v2/ConfigRequirementsReadResponse.json" + exit 0 +fi +exit 92 +"##, + quote_posix(&schema) + ) + .as_bytes(), + ); + let lexical_codex = temp.path().join("codex"); + symlink(&multicall_target, &lexical_codex).unwrap(); + let helper = temp.path().join("abtop"); + executable_file(&helper, b"helper bytes"); + + let prepared = prepare_install(&codex_home, &lexical_codex, &helper).unwrap(); + + assert_eq!(prepared.codex_binary, lexical_codex); + assert_ne!( + prepared.codex_binary, + fs::canonicalize(&prepared.codex_binary).unwrap() + ); + } + + #[test] + #[cfg(not(unix))] + fn unsupported_platform_fails_before_setup_mutation() { + let temp = tempfile::tempdir().unwrap(); + let codex_home = temp.path().join("codex"); + let error = prepare_install( + &codex_home, + &temp.path().join("codex.exe"), + &temp.path().join("abtop.exe"), + ) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + assert!(!codex_home.exists()); + } + + #[test] + fn expected_hook_keys_are_stable() { + let keys = HOOK_EVENTS.map(hook_event_key); + assert_eq!(keys[0], "pre_tool_use"); + assert_eq!(keys[10], "stop"); + } + + #[test] + fn base_runtime_config_rejects_disabled_features_plugins_and_config_locks() { + let safe: toml::Value = + toml::from_str(&format!("[plugins.\"{PLUGIN_ID}\"]\nenabled = true\n")).unwrap(); + assert!(base_runtime_hook_config_safe(&safe)); + + for unsafe_config in [ + format!( + "[plugins.\"{PLUGIN_ID}\"]\nenabled = false\n" + ), + format!( + "[plugins.\"{PLUGIN_ID}\"]\nenabled = true\n[features]\nhooks = false\n" + ), + format!( + "[plugins.\"{PLUGIN_ID}\"]\nenabled = true\n[features]\nplugins = false\n" + ), + format!( + "[plugins.\"{PLUGIN_ID}\"]\nenabled = true\n[debug.config_lockfile]\nload_path = \"/private/lock.toml\"\n" + ), + ] { + let config: toml::Value = toml::from_str(&unsafe_config).unwrap(); + assert!(!base_runtime_hook_config_safe(&config)); + } + } + + #[test] + fn codex_version_gate_accepts_the_minimum_and_newer_stable_releases() { + assert_eq!( + parse_codex_cli_version(b"codex-cli 0.145.0\n").unwrap(), + (0, 145, 0) + ); + assert_eq!( + parse_codex_cli_version(b"codex-cli 0.146.0\n").unwrap(), + (0, 146, 0) + ); + assert_eq!( + parse_codex_cli_version(b"codex-cli 0.146.27").unwrap(), + (0, 146, 27) + ); + for supported in [ + b"codex-cli 0.145.0\n".as_slice(), + b"codex-cli 0.145.9\n", + b"codex-cli 0.146.27\n", + b"codex-cli 0.147.0\n", + b"codex-cli 1.0.0\n", + ] { + validate_supported_codex_release(supported).unwrap(); + } + for unsupported in [b"codex-cli 0.144.0\n".as_slice(), b"codex-cli 0.144.999\n"] { + assert!(validate_supported_codex_release(unsupported).is_err()); + } + assert!(codex_version_is_supported("0.145.0")); + assert!(codex_version_is_supported("0.146.1")); + assert!(!codex_version_is_supported("0.144.999")); + assert!(!codex_version_is_supported("0.145.0-beta.1")); + assert!(codex_version_has_verified_code_mode_shape("0.145.0")); + assert!(codex_version_has_verified_code_mode_shape("0.146.0")); + assert!(!codex_version_has_verified_code_mode_shape("0.145.1")); + assert!(!codex_version_has_verified_code_mode_shape("0.146.27")); + assert!(!codex_version_has_verified_code_mode_shape("0.144.999")); + assert!(!codex_version_has_verified_code_mode_shape("0.147.0")); + assert!(!codex_version_has_verified_code_mode_shape("1.0.0")); + for invalid in [ + b"codex 0.146.0\n".as_slice(), + b"codex-cli 0.146.0-beta\n", + b"codex-cli 0.146\n", + b"codex-cli 0.146.00\n", + b"codex-cli 0.146.0\ntrailing\n", + ] { + assert!(parse_codex_cli_version(invalid).is_err()); + } + } + + #[test] + fn feature_preflight_requires_unique_stable_enabled_rows() { + validate_required_feature_rows( + b"foo experimental false\nhooks stable true\nplugins stable true\n", + ) + .unwrap(); + for invalid in [ + b"hooks stable true\n".as_slice(), + b"hooks experimental true\nplugins stable true\n", + b"hooks stable false\nplugins stable true\n", + b"hooks stable true extra\nplugins stable true\n", + b"hooks stable true\nhooks stable true\nplugins stable true\n", + ] { + assert!(validate_required_feature_rows(invalid).is_err()); + } + } + + #[test] + fn generated_schema_must_advertise_every_required_event() { + let mut properties = serde_json::Map::new(); + for event in HOOK_EVENTS { + properties.insert(event.to_string(), json!({"type": "array"})); + } + properties.insert("managedDir".to_string(), json!({"type": "string"})); + properties.insert("windowsManagedDir".to_string(), json!({"type": "string"})); + let schema = json!({ + "definitions": { + "ManagedHooksRequirements": { "properties": properties } + } + }); + let exact = serde_json::to_vec(&schema).unwrap(); + validate_hook_schema_bytes(&exact).unwrap(); + + let mut extra = schema.clone(); + extra["definitions"]["ManagedHooksRequirements"]["properties"]["PostToolUseFailure"] = + json!({"type": "array"}); + validate_hook_schema_bytes(&serde_json::to_vec(&extra).unwrap()).unwrap(); + + let mut missing = schema; + missing["definitions"]["ManagedHooksRequirements"]["properties"] + .as_object_mut() + .unwrap() + .remove("SessionEnd"); + assert!(validate_hook_schema_bytes(&serde_json::to_vec(&missing).unwrap()).is_err()); + } + + #[test] + fn canonical_hash_does_not_depend_on_object_insertion_order() { + let left = json!({"b": 1, "a": {"d": 2, "c": 3}}); + let right = json!({"a": {"c": 3, "d": 2}, "b": 1}); + assert_eq!( + hash_bytes(&serde_json::to_vec(&canonical_json(&left)).unwrap()), + hash_bytes(&serde_json::to_vec(&canonical_json(&right)).unwrap()) + ); + } + + #[test] + #[cfg(not(windows))] + fn trust_hash_matches_the_codex_normalized_identity_fixture() { + let identity = HookCommandIdentity { + event: "PreToolUse", + event_key: "pre_tool_use", + command: "echo fixed".to_string(), + command_windows: "cmd.exe /D /C echo fixed".to_string(), + }; + assert_eq!( + expected_trust_hash(&identity), + "sha256:418976fc656aa7eb68bea8445e29221077b6687c785ed66fbdbcd4c83cb934d7" + ); + } + + #[test] + #[cfg(unix)] + fn source_tree_is_closed_and_rejects_unexpected_capabilities() { + use std::os::unix::fs::PermissionsExt; + + let (_temp, paths, bundle) = fixture_bundle(); + write_bundle(&paths, &bundle).unwrap(); + assert!(audit_owned_source_tree(&paths, true).unwrap()); + for directory in [ + paths.plugin_data_root.clone(), + paths.plugin_data_root.join(HOOK_STATE_DIR_NAME), + paths + .plugin_data_root + .join(HOOK_STATE_DIR_NAME) + .join(HOOK_FAULT_DIR_NAME), + ] { + let metadata = fs::symlink_metadata(directory).unwrap(); + assert!(metadata.is_dir()); + assert_eq!(metadata.permissions().mode() & 0o777, 0o700); + } + assert!(private_runtime_state_tree_valid(&paths).unwrap()); + + let unexpected = paths.plugin_root.join("commands"); + fs::create_dir(&unexpected).unwrap(); + assert!(audit_owned_source_tree(&paths, true).is_err()); + assert!(remove_owned_bundle_files(&paths).is_err()); + assert!(unexpected.exists()); + } + + #[test] + #[cfg(unix)] + fn source_tree_rejects_a_symlinked_ancestor_without_touching_its_target() { + use std::os::unix::fs::symlink; + + let (_temp, paths, _bundle) = fixture_bundle(); + ensure_private_dir(&paths.codex_home.join("abtop")).unwrap(); + let outside = paths.codex_home.join("outside"); + fs::create_dir(&outside).unwrap(); + let sentinel = outside.join("sentinel"); + fs::write(&sentinel, b"keep").unwrap(); + symlink(&outside, &paths.marketplace_root).unwrap(); + + assert!(audit_owned_source_tree(&paths, false).is_err()); + assert!(remove_owned_bundle_files(&paths).is_err()); + assert_eq!(fs::read(&sentinel).unwrap(), b"keep"); + } + + #[test] + #[cfg(unix)] + fn handle_relative_unlink_rejects_an_ancestor_swapped_to_a_symlink() { + use std::os::unix::fs::symlink; + + let (_temp, paths, bundle) = fixture_bundle(); + write_bundle(&paths, &bundle).unwrap(); + let guard = OwnedSourceTreeGuard::open(&paths).unwrap(); + let scripts = paths.plugin_root.join("scripts"); + let displaced = paths.plugin_root.join("scripts-displaced"); + fs::rename(&scripts, &displaced).unwrap(); + let outside = paths.codex_home.join("outside-scripts"); + fs::create_dir(&outside).unwrap(); + let sentinel = outside.join("abtop-codex-hook.sh"); + fs::write(&sentinel, b"outside sentinel").unwrap(); + symlink(&outside, &scripts).unwrap(); + + assert!(guard + .remove_file(Path::new( + "marketplace/plugins/abtop/scripts/abtop-codex-hook.sh" + )) + .is_err()); + assert_eq!(fs::read(&sentinel).unwrap(), b"outside sentinel"); + assert!(displaced.join("abtop-codex-hook.sh").exists()); + } + + #[test] + #[cfg(unix)] + fn cached_payload_must_be_the_exact_current_closed_bundle() { + let (_temp, paths, bundle) = fixture_bundle(); + write_cached_bundle(&paths, &bundle); + assert!(cached_bundle_matches_disk(&paths, &bundle).unwrap()); + + let version_root = cache_version_path(&paths, &bundle.plugin_version); + let unexpected = version_root.join("commands.md"); + atomic_write_private(&unexpected, b"unexpected", false).unwrap(); + assert!(cached_bundle_matches_disk(&paths, &bundle).is_err()); + fs::remove_file(&unexpected).unwrap(); + + use std::os::unix::fs::PermissionsExt; + let hooks = version_root.join("hooks/hooks.json"); + fs::set_permissions(&hooks, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(!cached_bundle_matches_disk(&paths, &bundle).unwrap()); + fs::set_permissions(&hooks, fs::Permissions::from_mode(0o600)).unwrap(); + + atomic_write_private(&hooks, b"{}\n", false).unwrap(); + assert!(!cached_bundle_matches_disk(&paths, &bundle).unwrap()); + } + + #[test] + #[cfg(unix)] + fn status_preserves_registration_and_attested_install_across_helper_version_drift() { + let (temp, paths, installed_bundle) = fixture_bundle(); + write_attestation(&paths, &installed_bundle).unwrap(); + write_cached_bundle(&paths, &installed_bundle); + + let current_helper = temp.path().join("abtop-current"); + executable_file(¤t_helper, b"new helper bytes"); + let current_bundle = render_bundle(¤t_helper, &paths.plugin_data_root).unwrap(); + assert_ne!( + installed_bundle.plugin_version, + current_bundle.plugin_version + ); + + let config_text = format!( + "[marketplaces.{MARKETPLACE_NAME}]\nsource_type = \"local\"\nsource = {:?}\n\n[plugins.\"{PLUGIN_ID}\"]\nenabled = true\n", + paths.marketplace_root.to_string_lossy() + ); + let config = toml::from_str::(&config_text).unwrap(); + + let exact_error = + inspect_config_state_from_config(&paths, Some(¤t_bundle), &config).unwrap_err(); + assert!(exact_error + .to_string() + .contains("unexpected file or capability in cached plugin versions")); + + let mut details = Vec::new(); + let state = + inspect_status_config_state(&paths, Some(¤t_bundle), &config, &mut details); + assert!(state.marketplace_registered); + assert!(state.plugin_configured); + assert!(state.plugin_installed); + assert!(state.plugin_enabled); + assert_eq!( + state.installed_version, + Some(installed_bundle.plugin_version.clone()) + ); + assert!(details.is_empty()); + } + + #[test] + #[cfg(unix)] + fn status_does_not_promote_a_malformed_stale_cache_payload() { + let (temp, paths, installed_bundle) = fixture_bundle(); + write_attestation(&paths, &installed_bundle).unwrap(); + write_cached_bundle(&paths, &installed_bundle); + atomic_write_private( + &cache_version_path(&paths, &installed_bundle.plugin_version).join("unexpected"), + b"unexpected", + false, + ) + .unwrap(); + + let current_helper = temp.path().join("abtop-current"); + executable_file(¤t_helper, b"new helper bytes"); + let current_bundle = render_bundle(¤t_helper, &paths.plugin_data_root).unwrap(); + let config_text = format!( + "[marketplaces.{MARKETPLACE_NAME}]\nsource_type = \"local\"\nsource = {:?}\n\n[plugins.\"{PLUGIN_ID}\"]\nenabled = true\n", + paths.marketplace_root.to_string_lossy() + ); + let config = toml::from_str::(&config_text).unwrap(); + + let mut details = Vec::new(); + let state = + inspect_status_config_state(&paths, Some(¤t_bundle), &config, &mut details); + assert!(state.marketplace_registered); + assert!(state.plugin_configured); + assert!(state.plugin_enabled); + assert!(!state.plugin_installed); + assert_eq!(state.installed_version, None); + assert!(details + .iter() + .any(|detail| detail.contains("installed plugin version inspection failed"))); + assert!(details + .iter() + .any(|detail| detail.contains("Codex plugin installation inspection failed"))); + } + + #[test] + #[cfg(unix)] + fn runtime_hook_config_requires_the_exact_cached_bundle() { + let (temp, paths, bundle) = fixture_bundle(); + write_bundle(&paths, &bundle).unwrap(); + write_attestation(&paths, &bundle).unwrap(); + write_cached_bundle(&paths, &bundle); + write_trusted_base_hook_config(&paths, &bundle); + + let helper = temp.path().join("abtop"); + assert!(bundle_matches_disk(&paths, &bundle).unwrap()); + assert!(cached_bundle_matches_disk(&paths, &bundle).unwrap()); + assert!(attestation_matches(&paths, &bundle).unwrap()); + let config = read_base_config(&paths).unwrap(); + assert!(base_runtime_hook_config_safe(&config)); + let base = inspect_base_hook_state_from_config(&config, &bundle); + assert_eq!(base.trusted, HOOK_EVENTS.len()); + assert_eq!(base.enabled, HOOK_EVENTS.len()); + let valid = runtime_hook_config(&paths.codex_home, &helper).unwrap(); + assert!(valid.complete_hook_set); + + let cached_hooks = + cache_version_path(&paths, &bundle.plugin_version).join("hooks/hooks.json"); + atomic_write_private(&cached_hooks, b"{}\n", false).unwrap(); + let tampered = runtime_hook_config(&paths.codex_home, &helper).unwrap(); + assert!(!tampered.complete_hook_set); + assert_eq!(tampered.config_digest, valid.config_digest); + } + + #[test] + #[cfg(unix)] + fn static_config_inspection_ignores_unrelated_marketplace_snapshots() { + let (_temp, paths, bundle) = fixture_bundle(); + write_cached_bundle(&paths, &bundle); + let unrelated = paths.codex_home.join(".tmp/marketplaces/broken.json"); + fs::create_dir_all(unrelated.parent().unwrap()).unwrap(); + fs::write(&unrelated, b"not json").unwrap(); + let config = format!( + "[marketplaces.{MARKETPLACE_NAME}]\nsource_type = \"local\"\nsource = {:?}\n\n[plugins.\"{PLUGIN_ID}\"]\nenabled = true\n", + paths.marketplace_root.to_string_lossy() + ); + atomic_write_private( + &paths.codex_home.join("config.toml"), + config.as_bytes(), + false, + ) + .unwrap(); + + let state = inspect_config_state(&paths, Some(&bundle)).unwrap(); + assert!(state.marketplace_registered); + assert!(state.plugin_configured); + assert!(state.plugin_enabled); + assert!(state.plugin_installed); + assert_eq!(state.installed_version, Some(bundle.plugin_version.clone())); + + let conflict = format!( + "[marketplaces.{MARKETPLACE_NAME}]\nsource_type = \"local\"\nsource = \"/different/source\"\n" + ); + atomic_write_private( + &paths.codex_home.join("config.toml"), + conflict.as_bytes(), + false, + ) + .unwrap(); + let state = inspect_config_state(&paths, None).unwrap(); + assert!(!state.marketplace_registered); + assert_eq!( + state.marketplace_conflict, + Some(PathBuf::from("/different/source")) + ); + } + + #[test] + #[cfg(unix)] + fn uninstall_removes_the_plugin_but_preserves_an_unowned_malformed_marketplace() { + let temp = tempfile::tempdir().unwrap(); + let codex_home = temp.path().join("codex"); + fs::create_dir(&codex_home).unwrap(); + fs::create_dir(codex_home.join("abtop")).unwrap(); + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(codex_home.join("abtop"), fs::Permissions::from_mode(0o700)).unwrap(); + atomic_write_private( + &codex_home.join("config.toml"), + b"[marketplaces.abtop-local]\nsource_type = \"local\"\n", + false, + ) + .unwrap(); + let codex = temp.path().join("codex-fake"); + executable_file( + &codex, + br##"#!/bin/sh +if [ "$1" = "--version" ]; then + printf 'codex-cli 0.147.0\n' + exit 0 +fi +printf '%s\n' "$*" >> "$CODEX_HOME/calls.log" +if [ "$1" = "plugin" ] && [ "$2" = "remove" ] && [ "$3" = "abtop@abtop-local" ]; then + printf '{}\n' + exit 0 +fi +exit 47 +"##, + ); + + let error = uninstall_after_legacy_cleanup(&codex_home, &codex).unwrap_err(); + assert!(error + .to_string() + .contains("cannot prove ownership of malformed")); + let calls = fs::read_to_string(codex_home.join("calls.log")).unwrap(); + assert_eq!(calls, "plugin remove abtop@abtop-local --json\n"); + assert!(!calls.contains("list")); + assert!(codex_home.join("abtop").exists()); + } + + #[test] + #[cfg(unix)] + fn uninstall_recovers_from_a_malformed_owned_marketplace_snapshot() { + let (temp, paths, bundle) = fixture_bundle(); + write_bundle(&paths, &bundle).unwrap(); + atomic_write_private(&paths.marketplace_manifest, b"malformed\n", false).unwrap(); + let config = format!( + "[marketplaces.{MARKETPLACE_NAME}]\nsource_type = \"local\"\nsource = {:?}\n\n[plugins.\"{PLUGIN_ID}\"]\nenabled = true\n", + paths.marketplace_root.to_string_lossy() + ); + atomic_write_private( + &paths.codex_home.join("config.toml"), + config.as_bytes(), + false, + ) + .unwrap(); + let codex = temp.path().join("codex-fake"); + executable_file( + &codex, + br##"#!/bin/sh +if [ "$1" = "--version" ]; then + printf 'codex-cli 0.146.0\n' + exit 0 +fi +printf '%s\n' "$*" >> "$CODEX_HOME/calls.log" +if [ "$1" = "plugin" ] && [ "$2" = "remove" ]; then + sed '/^\[plugins\./,$d' "$CODEX_HOME/config.toml" > "$CODEX_HOME/config.toml.next" + mv "$CODEX_HOME/config.toml.next" "$CODEX_HOME/config.toml" + printf '{}\n' + exit 0 +fi +if [ "$1" = "plugin" ] && [ "$2" = "marketplace" ] && [ "$3" = "remove" ]; then + : > "$CODEX_HOME/config.toml" + printf '{}\n' + exit 0 +fi +exit 47 +"##, + ); + + let report = uninstall_after_legacy_cleanup(&paths.codex_home, &codex).unwrap(); + assert!(report.plugin_removed); + assert!(report.marketplace_removed); + assert!(!paths.marketplace_root.exists()); + assert!(paths.plugin_data_root.exists()); + let calls = fs::read_to_string(paths.codex_home.join("calls.log")).unwrap(); + assert!(calls.contains("plugin remove abtop@abtop-local --json")); + assert!(calls.contains("plugin marketplace remove abtop-local --json")); + assert!(!calls.contains("list")); + } + + #[test] + #[cfg(unix)] + fn successful_uninstall_retains_the_same_stable_lock_inode() { + use std::os::unix::fs::MetadataExt; + + let (temp, paths, bundle) = fixture_bundle(); + write_bundle(&paths, &bundle).unwrap(); + let config = format!( + "[marketplaces.{MARKETPLACE_NAME}]\nsource_type = \"local\"\nsource = {:?}\n\n[plugins.\"{PLUGIN_ID}\"]\nenabled = true\n", + paths.marketplace_root.to_string_lossy() + ); + atomic_write_private( + &paths.codex_home.join("config.toml"), + config.as_bytes(), + false, + ) + .unwrap(); + let codex = temp.path().join("codex-fake"); + executable_file( + &codex, + br##"#!/bin/sh +if [ "$1" = "--version" ]; then + printf 'codex-cli 0.146.0\n' + exit 0 +fi +if [ "$1" = "plugin" ] && [ "$2" = "remove" ]; then + sed '/^\[plugins\./,$d' "$CODEX_HOME/config.toml" > "$CODEX_HOME/config.toml.next" + mv "$CODEX_HOME/config.toml.next" "$CODEX_HOME/config.toml" + printf '{}\n' + exit 0 +fi +if [ "$1" = "plugin" ] && [ "$2" = "marketplace" ] && [ "$3" = "remove" ]; then + : > "$CODEX_HOME/config.toml" + printf '{}\n' + exit 0 +fi +exit 47 +"##, + ); + + uninstall_after_legacy_cleanup(&paths.codex_home, &codex).unwrap(); + let lock_path = paths.codex_home.join(SETUP_LOCK_FILE); + let first = fs::symlink_metadata(&lock_path).unwrap(); + uninstall_after_legacy_cleanup(&paths.codex_home, &codex).unwrap(); + let second = fs::symlink_metadata(&lock_path).unwrap(); + assert_eq!(first.dev(), second.dev()); + assert_eq!(first.ino(), second.ino()); + } + + #[test] + #[cfg(unix)] + fn uninstall_preserves_source_when_native_plugin_remove_is_a_noop() { + let (temp, paths, bundle) = fixture_bundle(); + write_bundle(&paths, &bundle).unwrap(); + atomic_write_private( + &paths.codex_home.join("config.toml"), + format!("[plugins.\"{PLUGIN_ID}\"]\nenabled = true\n").as_bytes(), + false, + ) + .unwrap(); + let codex = temp.path().join("codex-fake"); + executable_file( + &codex, + br##"#!/bin/sh +if [ "$1" = "--version" ]; then printf 'codex-cli 0.146.0\n'; exit 0; fi +printf '%s\n' "$*" >> "$CODEX_HOME/calls.log" +printf '{}\n' +"##, + ); + + let error = uninstall_after_legacy_cleanup(&paths.codex_home, &codex).unwrap_err(); + assert!(error.to_string().contains("remains configured")); + assert!(paths.marketplace_root.exists()); + assert_eq!( + fs::read_to_string(paths.codex_home.join("calls.log")).unwrap(), + "plugin remove abtop@abtop-local --json\n" + ); + } + + #[test] + #[cfg(unix)] + fn uninstall_removes_reserved_plugin_before_rejecting_unsafe_source() { + use std::os::unix::fs::symlink; + + let (temp, paths, bundle) = fixture_bundle(); + write_bundle(&paths, &bundle).unwrap(); + symlink(temp.path(), paths.codex_home.join("abtop/unexpected-link")).unwrap(); + atomic_write_private( + &paths.codex_home.join("config.toml"), + format!("[plugins.\"{PLUGIN_ID}\"]\nenabled = true\n").as_bytes(), + false, + ) + .unwrap(); + let codex = temp.path().join("codex-fake"); + executable_file( + &codex, + br##"#!/bin/sh +if [ "$1" = "--version" ]; then printf 'codex-cli 0.146.0\n'; exit 0; fi +printf '%s\n' "$*" >> "$CODEX_HOME/calls.log" +: > "$CODEX_HOME/config.toml" +printf '{}\n' +"##, + ); + + let error = uninstall_after_legacy_cleanup(&paths.codex_home, &codex).unwrap_err(); + assert!(error.to_string().contains("unexpected file or capability")); + assert!(paths.marketplace_root.exists()); + assert_eq!( + fs::read_to_string(paths.codex_home.join("calls.log")).unwrap(), + "plugin remove abtop@abtop-local --json\n" + ); + } + + #[test] + #[cfg(unix)] + fn uninstall_preserves_source_when_native_cache_remains() { + let (temp, paths, bundle) = fixture_bundle(); + write_bundle(&paths, &bundle).unwrap(); + atomic_write_private( + &paths.codex_home.join("config.toml"), + format!("[plugins.\"{PLUGIN_ID}\"]\nenabled = true\n").as_bytes(), + false, + ) + .unwrap(); + fs::create_dir_all( + paths + .codex_home + .join("plugins/cache") + .join(MARKETPLACE_NAME) + .join(PLUGIN_NAME) + .join("retained"), + ) + .unwrap(); + let codex = temp.path().join("codex-fake"); + executable_file( + &codex, + br##"#!/bin/sh +if [ "$1" = "--version" ]; then printf 'codex-cli 0.146.0\n'; exit 0; fi +: > "$CODEX_HOME/config.toml" +printf '{}\n' +"##, + ); + + let error = uninstall_after_legacy_cleanup(&paths.codex_home, &codex).unwrap_err(); + assert!(error.to_string().contains("plugin cache")); + assert!(paths.marketplace_root.exists()); + } +} diff --git a/src/codex_hooks/state.rs b/src/codex_hooks/state.rs new file mode 100644 index 0000000..d498b95 --- /dev/null +++ b/src/codex_hooks/state.rs @@ -0,0 +1,6498 @@ +//! Private, content-free Codex hook state. +//! +//! Hook payloads are reduced before this module sees them. The state format +//! intentionally contains only lifecycle identifiers, canonical event/tool +//! classes, timestamps, an exact process incarnation, and bounded faults. + +use crate::collector::process; +use crate::model::{SessionStatus, StatusReason}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +#[cfg(unix)] +use std::ffi::CString; +use std::ffi::{OsStr, OsString}; +#[cfg(not(unix))] +use std::fs::OpenOptions; +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +pub const HOOK_STATE_SCHEMA_VERSION: u32 = 1; +pub const MAX_STATE_SAMPLES: usize = 128; +const MAX_OPEN_ITEMS: usize = 256; +const MAX_COMPLETED_ITEMS: usize = 256; +const MAX_STATE_FILES: usize = 256; +const MAX_FAULT_FILES: usize = 128; +const MAX_FAULT_RECOVERY_FILES: usize = 512; +const MAX_TEMP_FILES: usize = 8; +const MAX_DIRECTORY_ENTRIES: usize = MAX_STATE_FILES + 16; +const MAX_FAULT_DIRECTORY_ENTRIES: usize = MAX_FAULT_FILES + 8; +const MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES: usize = MAX_FAULT_RECOVERY_FILES + MAX_TEMP_FILES + 8; +const MAX_FAULT_COMPACTION_BATCH_FILES: usize = 64; +const MAX_FAULT_COMPACTION_SCAN_ENTRIES: usize = MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES; +const MAX_STATE_BYTES: u64 = 1024 * 1024; +const MAX_FAULT_BYTES: u64 = 32 * 1024; +const MAX_ID_BYTES: usize = 512; +const MAX_CWD_BYTES: usize = 16 * 1024; +const STATE_DIR_NAME: &str = "states"; +const FAULT_DIR_NAME: &str = "faults"; +const FAULT_PREFIX: &str = "hook-"; +const FAULT_OVERFLOW_NAME: &str = "overflow.json"; +const FAULT_COMPACTION_TEMP_NAME: &str = ".tmp-6162746f70636f6d70616374696f6e31"; +const LAUNCH_FAULT_PREFIX: &str = "launch-"; +const LAUNCH_FAULT_SUFFIX: &str = ".pending"; +const LAUNCH_UNIQUE_SEPARATOR: &str = "-pending."; +const LAUNCH_UNIQUE_NONCE_LEN: usize = 16; +const LAUNCH_FAULT_SLOT_COUNT: u8 = 16; +const LAUNCH_FAULT_SLOT_NONCE: &str = "abtopv1"; +const INGEST_COMMIT_ID_HEX_BYTES: usize = 16; +const INGEST_COMMIT_ID_LEN: usize = INGEST_COMMIT_ID_HEX_BYTES * 2; +const INGEST_COMMIT_PROOF_SEPARATOR: char = ':'; +const TERMINAL_RETENTION_MS: u64 = 24 * 60 * 60 * 1000; +const MIN_TERMINAL_AGE_BEFORE_PRESSURE_MS: u64 = 30 * 1000; +const PROCESS_DEATH_OBSERVATION_GRACE_MS: u64 = 30 * 1000; + +pub const fn hook_state_platform_supported() -> bool { + cfg!(any(target_os = "linux", target_os = "macos")) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IntegrationIdentity { + pub hook_schema_revision: String, + pub helper_digest: String, + pub installation_id: String, + pub config_digest: String, + pub complete_hook_set: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HookProcessIdentity { + pub pid: u32, + pub started_at_ms: u64, + pub incarnation: String, + pub shared_host: bool, + pub launch_config_ambiguous: bool, +} + +impl HookProcessIdentity { + pub fn matches_live_process(&self) -> bool { + self.pid != 0 + && !self.incarnation.is_empty() + && process::get_process_incarnation(self.pid).as_deref() + == Some(self.incarnation.as_str()) + } + + pub fn confirmed_gone(&self) -> bool { + if self.pid == 0 || self.incarnation.is_empty() { + return false; + } + if let Some(current) = process::get_process_incarnation(self.pid) { + return current != self.incarnation; + } + #[cfg(unix)] + { + let Ok(pid) = libc::pid_t::try_from(self.pid) else { + return false; + }; + // SAFETY: signal zero performs no mutation and only queries the + // exact numeric PID. ESRCH proves that incarnation is gone. + let result = unsafe { libc::kill(pid, 0) }; + result == -1 && io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) + } + #[cfg(not(unix))] + { + false + } + } + + pub fn actionable(&self) -> bool { + !self.shared_host && !self.launch_config_ambiguous && self.matches_live_process() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookEventKind { + PreToolUse, + PermissionRequest, + PostToolUse, + PreCompact, + PostCompact, + SessionStart, + SessionEnd, + UserPromptSubmit, + SubagentStart, + SubagentStop, + Stop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HookToolClass { + Ordinary, + RequestUserInput, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionStartSource { + Startup, + Resume, + Clear, + Compact, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookEvent { + pub kind: HookEventKind, + pub session_id: String, + pub cwd: String, + pub turn_id: Option, + pub tool_use_id: Option, + pub tool_class: Option, + pub agent_id: Option, + pub session_start_source: Option, + /// Exact Codex Stop/SubagentStop recursion guard. Codex sets this when + /// another Stop hook previously blocked the same turn from stopping. + pub stop_hook_active: Option, + /// Opaque proof binding the durable marker basename to this exact marker + /// adoption. It is generated by abtop and contains no provider content. + pub ingest_marker_id: String, + pub observed_at_ms: u64, + pub process: HookProcessIdentity, + pub integration: IntegrationIdentity, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HookStateSample { + pub event: HookEventKind, + pub observed_at_ms: u64, + #[serde(default)] + pub status: SessionStatus, + #[serde(default)] + pub reason: StatusReason, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HookSessionState { + pub schema_version: u32, + pub integration: IntegrationIdentity, + pub generation_id: String, + pub session_id: String, + pub cwd: String, + pub process: HookProcessIdentity, + pub created_at_ms: u64, + pub updated_at_ms: u64, + pub ended_at_ms: u64, + /// First writer-side confirmation that this generation's exact recorded + /// process incarnation is gone. This maintenance timestamp is + /// never accepted from hook/provider input and may be later than + /// `updated_at_ms`. + #[serde(default)] + pub first_confirmed_gone_at_ms: u64, + pub last_event: HookEventKind, + #[serde(default)] + pub session_start_source: Option, + #[serde(default)] + pub last_root_event: Option, + #[serde(default)] + pub last_root_boundary_at_ms: u64, + pub active_turn_id: Option, + #[serde(default)] + pub prompt_observed_at_ms: u64, + pub stop_turn_id: Option, + #[serde(default)] + pub stop_hook_active: Option, + #[serde(default)] + pub stop_observed_at_ms: u64, + pub prompt_accepted: bool, + pub open_tools: BTreeMap, + #[serde(default)] + pub tool_opened_at_ms: BTreeMap, + pub closed_tools: BTreeSet, + /// Oldest-to-newest insertion order for the bounded root-tool replay + /// cache. Legacy schema-v1 states omit this metadata and are normalized + /// deterministically before the next fold. + #[serde(default)] + pub completed_tool_order: Vec, + /// Exact ordinary child-tool ownership. Child tools cannot be projected + /// as execution because Codex does not attest their approval coverage. + #[serde(default)] + pub open_child_tools: BTreeMap, + #[serde(default)] + pub child_tool_opened_at_ms: BTreeMap, + #[serde(default)] + pub closed_child_tools: BTreeMap, + /// Oldest-to-newest insertion order for the bounded child-tool replay + /// cache. + #[serde(default)] + pub completed_child_tool_order: Vec, + pub open_subagents: BTreeSet, + #[serde(default)] + pub subagent_opened_at_ms: BTreeMap, + #[serde(default)] + pub provisional_stopped_subagents: BTreeSet, + #[serde(default)] + pub subagent_stopped_at_ms: BTreeMap, + /// Replay cache reserved for descendants with independently proven exact + /// completion. `SubagentStop` itself is provisional and deliberately + /// remains in the open/provisional collections instead of entering this + /// cache. + pub closed_subagents: BTreeSet, + /// Oldest-to-newest insertion order for the bounded subagent replay + /// cache. + #[serde(default)] + pub completed_subagent_order: Vec, + pub open_questions: BTreeSet, + #[serde(default)] + pub question_opened_at_ms: BTreeMap, + pub closed_questions: BTreeSet, + /// Oldest-to-newest insertion order for the bounded question replay + /// cache. + #[serde(default)] + pub completed_question_order: Vec, + #[serde(default)] + pub question_agents: BTreeMap, + pub permission_ambiguity: bool, + #[serde(default)] + pub permission_observed_at_ms: u64, + #[serde(default)] + pub child_permission_ambiguities: BTreeSet, + #[serde(default)] + pub child_permission_observed_at_ms: BTreeMap, + pub compaction_open: bool, + pub sticky_fault: Option, + /// At least one completed lifecycle ID was evicted from a replay cache. + /// This is audit metadata only; an unmatched later close still fails + /// closed through the normal event-gap path. + #[serde(default)] + pub completed_history_truncated: bool, + #[serde(default)] + pub completed_ingests: Vec, + pub samples: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookProjection { + Unknown(StatusReason), + TurnOpen, + ToolOpen(BTreeSet), + SubagentOpen { + active: BTreeSet, + provisional: BTreeSet, + root: HookRootProjection, + }, + TurnStopped, + Ended, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookRootProjection { + Unknown(StatusReason), + TurnOpen, + ToolOpen(BTreeSet), + TurnStopped, + Ended, +} + +impl HookSessionState { + fn child_tool_owner(&self, tool_id: &str) -> Option<&str> { + self.open_child_tools.get(tool_id).map(String::as_str) + } + + fn has_open_child_tools(&self) -> bool { + self.open_child_tools + .keys() + .any(|tool_id| self.child_tool_owner(tool_id).is_some()) + } + + pub fn interaction_ambiguous(&self) -> bool { + self.permission_ambiguity + || !self.child_permission_ambiguities.is_empty() + || !self.open_questions.is_empty() + } + + pub fn projection(&self) -> HookProjection { + if !self.integration.complete_hook_set { + return HookProjection::Unknown(StatusReason::HookIntegrationUnverified); + } + if self.process.shared_host || self.process.launch_config_ambiguous { + return HookProjection::Unknown(StatusReason::OwnershipUnconfirmed); + } + if let Some(reason) = self.sticky_fault { + return HookProjection::Unknown(reason); + } + if self.interaction_ambiguous() { + return HookProjection::Unknown(StatusReason::HookInteractionResolutionUnavailable); + } + if self.has_open_child_tools() { + // The supported Codex hook contract does not attest whether an + // ordinary child tool can surface an approval. Exact open-tool evidence therefore blocks + // the tool-free child-model projection and stays Unknown. + return HookProjection::Unknown(StatusReason::HookToolOpen); + } + if !self.open_subagents.is_empty() { + let active = self + .open_subagents + .difference(&self.provisional_stopped_subagents) + .cloned() + .collect(); + return HookProjection::SubagentOpen { + active, + provisional: self.provisional_stopped_subagents.clone(), + root: self.root_projection(), + }; + } + match self.root_projection() { + HookRootProjection::Unknown(reason) => HookProjection::Unknown(reason), + HookRootProjection::TurnOpen => HookProjection::TurnOpen, + HookRootProjection::ToolOpen(tools) => HookProjection::ToolOpen(tools), + HookRootProjection::TurnStopped => HookProjection::TurnStopped, + HookRootProjection::Ended => HookProjection::Ended, + } + } + + fn root_projection(&self) -> HookRootProjection { + if self.ended_at_ms != 0 || self.last_root_event == Some(HookEventKind::SessionEnd) { + return HookRootProjection::Ended; + } + if !self.open_tools.is_empty() { + return HookRootProjection::ToolOpen(self.open_tools.keys().cloned().collect()); + } + match self.last_root_event { + // Supported Codex releases queue every SessionStart source and run + // the hook from inside the next turn, immediately before UserPromptSubmit. + // It therefore proves a clean generation boundary, not Idle. + Some(HookEventKind::SessionStart) + if self.session_start_source == Some(SessionStartSource::Compact) + && self.active_turn_id.is_some() + && self.prompt_accepted => + { + HookRootProjection::TurnOpen + } + Some(HookEventKind::SessionStart) => { + HookRootProjection::Unknown(StatusReason::HookEventGap) + } + Some(HookEventKind::Stop) if self.stop_turn_id.is_some() => { + HookRootProjection::TurnStopped + } + _ if self.active_turn_id.is_some() && self.prompt_accepted => { + HookRootProjection::TurnOpen + } + _ => HookRootProjection::Unknown(StatusReason::HookEventGap), + } + } + + fn new(event: &HookEvent) -> Self { + let generation_id = state_key(event); + Self { + schema_version: HOOK_STATE_SCHEMA_VERSION, + integration: event.integration.clone(), + generation_id, + session_id: event.session_id.clone(), + cwd: event.cwd.clone(), + process: event.process.clone(), + created_at_ms: event.observed_at_ms, + updated_at_ms: event.observed_at_ms, + ended_at_ms: 0, + first_confirmed_gone_at_ms: 0, + last_event: event.kind, + session_start_source: event.session_start_source, + last_root_event: event.agent_id.is_none().then_some(event.kind), + last_root_boundary_at_ms: if is_root_boundary_event(event) { + event.observed_at_ms + } else { + 0 + }, + active_turn_id: None, + prompt_observed_at_ms: 0, + stop_turn_id: None, + stop_hook_active: None, + stop_observed_at_ms: 0, + prompt_accepted: false, + open_tools: BTreeMap::new(), + tool_opened_at_ms: BTreeMap::new(), + closed_tools: BTreeSet::new(), + completed_tool_order: Vec::new(), + open_child_tools: BTreeMap::new(), + child_tool_opened_at_ms: BTreeMap::new(), + closed_child_tools: BTreeMap::new(), + completed_child_tool_order: Vec::new(), + open_subagents: BTreeSet::new(), + subagent_opened_at_ms: BTreeMap::new(), + provisional_stopped_subagents: BTreeSet::new(), + subagent_stopped_at_ms: BTreeMap::new(), + closed_subagents: BTreeSet::new(), + completed_subagent_order: Vec::new(), + open_questions: BTreeSet::new(), + question_opened_at_ms: BTreeMap::new(), + closed_questions: BTreeSet::new(), + completed_question_order: Vec::new(), + question_agents: BTreeMap::new(), + permission_ambiguity: false, + permission_observed_at_ms: 0, + child_permission_ambiguities: BTreeSet::new(), + child_permission_observed_at_ms: BTreeMap::new(), + compaction_open: false, + sticky_fault: None, + completed_history_truncated: false, + completed_ingests: Vec::new(), + samples: Vec::new(), + } + } + + fn normalize_completed_history(&mut self) { + // Schema v1 originally persisted only the membership collections. + // An empty order next to a non-empty cache is therefore a valid + // legacy shape. Use its stable key order as the initial FIFO order; + // every later write persists the explicit insertion order. + if self.completed_tool_order.is_empty() && !self.closed_tools.is_empty() { + self.completed_tool_order + .extend(self.closed_tools.iter().cloned()); + } + if self.completed_child_tool_order.is_empty() && !self.closed_child_tools.is_empty() { + self.completed_child_tool_order + .extend(self.closed_child_tools.keys().cloned()); + } + if self.completed_subagent_order.is_empty() && !self.closed_subagents.is_empty() { + self.completed_subagent_order + .extend(self.closed_subagents.iter().cloned()); + } + if self.completed_question_order.is_empty() && !self.closed_questions.is_empty() { + self.completed_question_order + .extend(self.closed_questions.iter().cloned()); + } + } + + fn compact_completed_history(&mut self) { + let valid_orders = + completed_order_matches(&self.completed_tool_order, self.closed_tools.iter()) + && completed_order_matches( + &self.completed_child_tool_order, + self.closed_child_tools.keys(), + ) + && completed_order_matches( + &self.completed_subagent_order, + self.closed_subagents.iter(), + ) + && completed_order_matches( + &self.completed_question_order, + self.closed_questions.iter(), + ); + if !valid_orders { + self.sticky_fault = Some(StatusReason::HookStateMalformed); + return; + } + + let root_excess = self + .completed_tool_order + .len() + .saturating_sub(MAX_COMPLETED_ITEMS); + for tool_id in self.completed_tool_order.drain(..root_excess) { + self.closed_tools.remove(&tool_id); + self.completed_history_truncated = true; + } + + let child_excess = self + .completed_child_tool_order + .len() + .saturating_sub(MAX_COMPLETED_ITEMS); + for tool_id in self.completed_child_tool_order.drain(..child_excess) { + self.closed_child_tools.remove(&tool_id); + self.completed_history_truncated = true; + } + + let subagent_excess = self + .completed_subagent_order + .len() + .saturating_sub(MAX_COMPLETED_ITEMS); + for agent_id in self.completed_subagent_order.drain(..subagent_excess) { + self.closed_subagents.remove(&agent_id); + self.completed_history_truncated = true; + } + + let question_excess = self + .completed_question_order + .len() + .saturating_sub(MAX_COMPLETED_ITEMS); + for tool_id in self.completed_question_order.drain(..question_excess) { + self.closed_questions.remove(&tool_id); + self.completed_history_truncated = true; + } + } + + fn apply(&mut self, event: &HookEvent) { + self.normalize_completed_history(); + if self.integration != event.integration { + self.sticky_fault = Some(StatusReason::HookConfigChanged); + } + if self.process != event.process || self.session_id != event.session_id { + self.sticky_fault = Some(StatusReason::OwnershipUnconfirmed); + } + if !event.cwd.is_empty() && self.cwd != event.cwd { + self.sticky_fault = Some(StatusReason::OwnershipUnconfirmed); + } + let root_boundary = is_root_boundary_event(event); + let root_boundary_regressed = + root_boundary && event.observed_at_ms < self.last_root_boundary_at_ms; + if root_boundary_regressed { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + if event.agent_id.is_some() + && matches!( + event.kind, + HookEventKind::PreToolUse + | HookEventKind::PermissionRequest + | HookEventKind::PostToolUse + | HookEventKind::PreCompact + | HookEventKind::PostCompact + | HookEventKind::UserPromptSubmit + | HookEventKind::SubagentStart + | HookEventKind::SubagentStop + ) + && self.prompt_observed_at_ms != 0 + && event.observed_at_ms < self.prompt_observed_at_ms + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + if event.agent_id.is_none() + && matches!( + event.kind, + HookEventKind::PreToolUse + | HookEventKind::PermissionRequest + | HookEventKind::PostToolUse + ) + && self.prompt_observed_at_ms != 0 + && event.observed_at_ms < self.prompt_observed_at_ms + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + + // Stop is provisional: another provider hook can block it and Codex + // then continues the same turn. Any exact later non-Stop lifecycle + // edge proves that continuation and invalidates the old candidate. + // UserPromptSubmit handles this itself so it can distinguish a steer + // from the first prompt of a genuinely new turn. + if self.stop_turn_id.is_some() + && event.agent_id.is_none() + && !matches!( + event.kind, + HookEventKind::Stop + | HookEventKind::UserPromptSubmit + | HookEventKind::SessionEnd + | HookEventKind::SessionStart + ) + { + if event.observed_at_ms < self.stop_observed_at_ms { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + self.stop_turn_id = None; + self.stop_hook_active = None; + self.stop_observed_at_ms = 0; + } + if let Some(agent) = event.agent_id.as_deref() { + if event.kind != HookEventKind::SubagentStart + && self + .subagent_opened_at_ms + .get(agent) + .is_some_and(|opened_at| event.observed_at_ms < *opened_at) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + if event.kind != HookEventKind::SubagentStop { + let delayed_before_stop = self + .subagent_stopped_at_ms + .get(agent) + .is_some_and(|stopped_at| event.observed_at_ms < *stopped_at); + if delayed_before_stop { + self.sticky_fault = Some(StatusReason::HookEventGap); + } else { + self.provisional_stopped_subagents.remove(agent); + self.subagent_stopped_at_ms.remove(agent); + } + } + } + + match event.kind { + HookEventKind::SessionStart => { + if event.session_start_source == Some(SessionStartSource::Compact) { + // Compact is queued and dispatched inside the current + // turn after PostCompact. It is not a new generation and + // must preserve root and descendant work state. + if event.agent_id.is_some() + || self.last_root_event != Some(HookEventKind::PostCompact) + || self.active_turn_id.is_none() + || self.compaction_open + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + self.session_start_source = event.session_start_source; + } else { + // startup/resume/clear is an exact root generation + // boundary. Opening an empty TUI emits no event; this + // boundary is queued until the first turn and is not Idle. + if event.agent_id.is_some() + || !matches!( + event.session_start_source, + Some( + SessionStartSource::Startup + | SessionStartSource::Resume + | SessionStartSource::Clear + ) + ) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } else if !root_boundary_regressed { + self.integration = event.integration.clone(); + self.cwd = event.cwd.clone(); + self.process = event.process.clone(); + self.created_at_ms = event.observed_at_ms; + self.ended_at_ms = 0; + self.first_confirmed_gone_at_ms = 0; + self.active_turn_id = None; + self.prompt_observed_at_ms = 0; + self.stop_turn_id = None; + self.stop_hook_active = None; + self.stop_observed_at_ms = 0; + self.prompt_accepted = false; + self.open_tools.clear(); + self.tool_opened_at_ms.clear(); + self.closed_tools.clear(); + self.completed_tool_order.clear(); + self.open_child_tools.clear(); + self.child_tool_opened_at_ms.clear(); + self.closed_child_tools.clear(); + self.completed_child_tool_order.clear(); + self.open_subagents.clear(); + self.subagent_opened_at_ms.clear(); + self.provisional_stopped_subagents.clear(); + self.subagent_stopped_at_ms.clear(); + self.closed_subagents.clear(); + self.completed_subagent_order.clear(); + self.open_questions.clear(); + self.question_opened_at_ms.clear(); + self.closed_questions.clear(); + self.completed_question_order.clear(); + self.question_agents.clear(); + self.permission_ambiguity = false; + self.permission_observed_at_ms = 0; + self.child_permission_ambiguities.clear(); + self.child_permission_observed_at_ms.clear(); + self.compaction_open = false; + self.sticky_fault = None; + self.completed_history_truncated = false; + // A marker may have been durably committed immediately + // before this boundary and then survived a crash before + // unlink. Retain every valid exact commit proof so the + // boundary cannot turn that success into a false gap. + // Legacy basename-only entries are not commit proofs + // and are recoverable only at this exact clean edge. + self.completed_ingests + .retain(|proof| valid_ingest_commit_proof(proof)); + self.samples.clear(); + self.session_start_source = event.session_start_source; + } + } + } + HookEventKind::UserPromptSubmit => { + if let Some(agent) = event.agent_id.as_deref() { + if !self.open_subagents.contains(agent) { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + self.finish_sample(event); + return; + } + let turn = required_id(event.turn_id.as_deref()); + let same_turn = turn.is_some() && self.active_turn_id.as_deref() == turn; + let may_start_after_stop = turn.is_some() + && self.active_turn_id.is_some() + && self.active_turn_id.as_deref() != turn + && self.stop_turn_id == self.active_turn_id + && self.last_root_event == Some(HookEventKind::Stop) + && self.open_tools.is_empty() + && !self.compaction_open; + let first_turn = turn.is_some() && self.active_turn_id.is_none(); + if !(same_turn || may_start_after_stop || first_turn) { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + if first_turn || may_start_after_stop { + self.active_turn_id = turn.map(ToOwned::to_owned); + clear_root_questions(self); + self.closed_tools.clear(); + self.completed_tool_order.clear(); + self.closed_child_tools.clear(); + self.completed_child_tool_order.clear(); + self.permission_ambiguity = false; + self.permission_observed_at_ms = 0; + } + // A repeated/steered prompt in the same turn must not clear + // open work, closed-edge history, or interaction ambiguity. + self.stop_turn_id = None; + self.stop_hook_active = None; + self.stop_observed_at_ms = 0; + self.prompt_accepted = turn.is_some(); + self.prompt_observed_at_ms = self.prompt_observed_at_ms.max(event.observed_at_ms); + } + HookEventKind::PreToolUse => { + let child = event.agent_id.as_deref(); + if child.is_some_and(|agent| !self.open_subagents.contains(agent)) + || (child.is_none() && !turn_matches(self, event.turn_id.as_deref())) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + let Some(tool_id) = required_id(event.tool_use_id.as_deref()) else { + self.sticky_fault = Some(StatusReason::HookEventGap); + self.finish_sample(event); + return; + }; + match event.tool_class { + Some(HookToolClass::RequestUserInput) => { + if self.open_tools.contains_key(tool_id) + || self.closed_tools.contains(tool_id) + || self.open_child_tools.contains_key(tool_id) + || self.closed_child_tools.contains_key(tool_id) + || self.closed_questions.contains(tool_id) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } else if self.open_questions.contains(tool_id) { + let same_actor = match (child, self.question_agents.get(tool_id)) { + (None, None) => true, + (Some(agent), Some(recorded)) => recorded == agent, + _ => false, + }; + if !same_actor { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + } else { + self.open_questions.insert(tool_id.to_string()); + self.question_opened_at_ms + .insert(tool_id.to_string(), event.observed_at_ms); + if let Some(agent) = child { + self.question_agents + .insert(tool_id.to_string(), agent.to_string()); + } + } + } + Some(HookToolClass::Ordinary) => { + if let Some(agent) = child { + let conflicts_with_root = self.open_tools.contains_key(tool_id) + || self.closed_tools.contains(tool_id); + let owner_conflict = self + .open_child_tools + .get(tool_id) + .is_some_and(|recorded| recorded != agent); + if conflicts_with_root + || owner_conflict + || self.closed_child_tools.contains_key(tool_id) + || self.open_questions.contains(tool_id) + || self.closed_questions.contains(tool_id) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } else { + // Exact duplicate child PreToolUse is + // idempotent only for the same child owner. + self.open_child_tools + .entry(tool_id.to_string()) + .or_insert_with(|| agent.to_string()); + self.child_tool_opened_at_ms + .entry(tool_id.to_string()) + .and_modify(|at| *at = (*at).min(event.observed_at_ms)) + .or_insert(event.observed_at_ms); + } + } else if self.closed_tools.contains(tool_id) + || self.open_questions.contains(tool_id) + || self.closed_questions.contains(tool_id) + || self.open_child_tools.contains_key(tool_id) + || self.closed_child_tools.contains_key(tool_id) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } else { + // An exact duplicate root PreToolUse is an + // idempotent retry. + self.open_tools + .entry(tool_id.to_string()) + .or_insert(HookToolClass::Ordinary); + self.tool_opened_at_ms + .entry(tool_id.to_string()) + .and_modify(|at| *at = (*at).min(event.observed_at_ms)) + .or_insert(event.observed_at_ms); + } + } + None => self.sticky_fault = Some(StatusReason::HookEventGap), + } + } + HookEventKind::PermissionRequest => { + if let Some(agent) = event.agent_id.as_deref() { + if !self.open_subagents.contains(agent) { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + self.child_permission_ambiguities.insert(agent.to_string()); + self.child_permission_observed_at_ms + .entry(agent.to_string()) + .and_modify(|at| *at = (*at).min(event.observed_at_ms)) + .or_insert(event.observed_at_ms); + } else if !turn_matches(self, event.turn_id.as_deref()) { + self.sticky_fault = Some(StatusReason::HookEventGap); + self.permission_ambiguity = true; + self.permission_observed_at_ms = + min_nonzero(self.permission_observed_at_ms, event.observed_at_ms); + } else { + self.permission_ambiguity = true; + self.permission_observed_at_ms = + min_nonzero(self.permission_observed_at_ms, event.observed_at_ms); + } + } + HookEventKind::PostToolUse => { + let child = event.agent_id.as_deref(); + if child.is_some_and(|agent| !self.open_subagents.contains(agent)) + || (child.is_none() && !turn_matches(self, event.turn_id.as_deref())) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + let Some(tool_id) = required_id(event.tool_use_id.as_deref()) else { + self.sticky_fault = Some(StatusReason::HookEventGap); + self.finish_sample(event); + return; + }; + match event.tool_class { + Some(HookToolClass::RequestUserInput) => { + let actor_matches = match (child, self.question_agents.get(tool_id)) { + (None, None) => true, + (Some(agent), Some(recorded)) => recorded == agent, + _ => false, + }; + if self + .question_opened_at_ms + .get(tool_id) + .is_some_and(|opened_at| event.observed_at_ms < *opened_at) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + if actor_matches && self.open_questions.remove(tool_id) { + self.question_agents.remove(tool_id); + self.question_opened_at_ms.remove(tool_id); + if self.closed_questions.insert(tool_id.to_string()) { + self.completed_question_order.push(tool_id.to_string()); + } + } else if !self.closed_questions.contains(tool_id) { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + } + Some(HookToolClass::Ordinary) => { + if let Some(agent) = child { + if self.open_tools.contains_key(tool_id) + || self.closed_tools.contains(tool_id) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } else if self + .open_child_tools + .get(tool_id) + .is_some_and(|recorded| recorded == agent) + { + if self + .child_tool_opened_at_ms + .get(tool_id) + .is_some_and(|opened_at| event.observed_at_ms < *opened_at) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + self.open_child_tools.remove(tool_id); + self.child_tool_opened_at_ms.remove(tool_id); + if self + .closed_child_tools + .insert(tool_id.to_string(), agent.to_string()) + .is_none() + { + self.completed_child_tool_order.push(tool_id.to_string()); + } + } else if self + .closed_child_tools + .get(tool_id) + .is_none_or(|recorded| recorded != agent) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + } else if self.open_child_tools.contains_key(tool_id) + || self.closed_child_tools.contains_key(tool_id) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } else if self.open_tools.remove(tool_id).is_some() { + if self + .tool_opened_at_ms + .get(tool_id) + .is_some_and(|opened_at| event.observed_at_ms < *opened_at) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + self.tool_opened_at_ms.remove(tool_id); + if self.closed_tools.insert(tool_id.to_string()) { + self.completed_tool_order.push(tool_id.to_string()); + } + } else if !self.closed_tools.contains(tool_id) { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + } + None => self.sticky_fault = Some(StatusReason::HookEventGap), + } + } + HookEventKind::SubagentStart => { + // All descendants share the root session_id. agent_id is the + // exact child thread identity and the start/stop pair is the + // only complete descendant-work boundary exposed to hooks. + let root_turn_active = self.active_turn_id.is_some() + && self.prompt_accepted + && self.prompt_observed_at_ms != 0 + && self.stop_turn_id.is_none() + && self.ended_at_ms == 0; + match ( + required_id(event.agent_id.as_deref()), + required_id(event.turn_id.as_deref()), + ) { + (Some(agent), Some(_)) + if root_turn_active + && event.observed_at_ms >= self.prompt_observed_at_ms + && !self.closed_subagents.contains(agent) + && !self.provisional_stopped_subagents.contains(agent) => + { + self.open_subagents.insert(agent.to_string()); + self.subagent_opened_at_ms + .entry(agent.to_string()) + .and_modify(|at| *at = (*at).min(event.observed_at_ms)) + .or_insert(event.observed_at_ms); + } + _ => self.sticky_fault = Some(StatusReason::HookEventGap), + } + } + HookEventKind::SubagentStop => match required_id(event.agent_id.as_deref()) { + Some(agent) if self.open_subagents.contains(agent) => { + if self + .open_child_tools + .values() + .any(|recorded| recorded == agent) + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + self.provisional_stopped_subagents.insert(agent.to_string()); + self.subagent_stopped_at_ms + .entry(agent.to_string()) + .and_modify(|at| *at = (*at).max(event.observed_at_ms)) + .or_insert(event.observed_at_ms); + self.child_permission_ambiguities.remove(agent); + self.child_permission_observed_at_ms.remove(agent); + clear_agent_questions(self, agent); + } + _ => self.sticky_fault = Some(StatusReason::HookEventGap), + }, + HookEventKind::PreCompact => { + if let Some(agent) = event.agent_id.as_deref() { + if !self.open_subagents.contains(agent) { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + self.finish_sample(event); + return; + } + if self.compaction_open || !turn_matches(self, event.turn_id.as_deref()) { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + self.compaction_open = true; + } + HookEventKind::PostCompact => { + if let Some(agent) = event.agent_id.as_deref() { + if !self.open_subagents.contains(agent) { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + self.finish_sample(event); + return; + } + if !self.compaction_open || !turn_matches(self, event.turn_id.as_deref()) { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + self.compaction_open = false; + } + HookEventKind::Stop => { + let turn = required_id(event.turn_id.as_deref()); + if turn.is_none() + || self.active_turn_id.as_deref() != turn + || !self.open_tools.is_empty() + || self.compaction_open + || event.stop_hook_active.is_none() + { + self.sticky_fault = Some(StatusReason::HookEventGap); + } + self.stop_turn_id = turn.map(ToOwned::to_owned); + self.stop_hook_active = event.stop_hook_active; + self.stop_observed_at_ms = event.observed_at_ms; + // Stop is the first exact later turn boundary available for + // PermissionRequest and cancelled question ambiguity. + self.permission_ambiguity = false; + self.permission_observed_at_ms = 0; + clear_root_questions(self); + } + HookEventKind::SessionEnd => { + self.ended_at_ms = event.observed_at_ms; + self.active_turn_id = None; + self.prompt_observed_at_ms = 0; + self.stop_turn_id = None; + self.stop_hook_active = None; + self.stop_observed_at_ms = 0; + self.prompt_accepted = false; + self.open_tools.clear(); + self.tool_opened_at_ms.clear(); + self.closed_tools.clear(); + self.completed_tool_order.clear(); + self.open_child_tools.clear(); + self.child_tool_opened_at_ms.clear(); + self.closed_child_tools.clear(); + self.completed_child_tool_order.clear(); + self.open_subagents.clear(); + self.subagent_opened_at_ms.clear(); + self.provisional_stopped_subagents.clear(); + self.subagent_stopped_at_ms.clear(); + self.closed_subagents.clear(); + self.completed_subagent_order.clear(); + self.open_questions.clear(); + self.question_opened_at_ms.clear(); + self.closed_questions.clear(); + self.completed_question_order.clear(); + self.question_agents.clear(); + self.permission_ambiguity = false; + self.permission_observed_at_ms = 0; + self.child_permission_ambiguities.clear(); + self.child_permission_observed_at_ms.clear(); + self.compaction_open = false; + self.completed_history_truncated = false; + } + } + self.finish_sample(event); + } + + fn finish_sample(&mut self, event: &HookEvent) { + self.last_event = event.kind; + if event.agent_id.is_none() { + self.last_root_event = Some(event.kind); + } + if is_root_boundary_event(event) { + self.last_root_boundary_at_ms = self.last_root_boundary_at_ms.max(event.observed_at_ms); + } + if !self.completed_ingests.contains(&event.ingest_marker_id) { + self.completed_ingests.push(event.ingest_marker_id.clone()); + } + if self.completed_ingests.len() > MAX_STATE_SAMPLES { + let remove = self.completed_ingests.len() - MAX_STATE_SAMPLES; + self.completed_ingests.drain(..remove); + } + self.updated_at_ms = self.updated_at_ms.max(event.observed_at_ms); + self.compact_completed_history(); + // Historical hook candidates are not independently sufficient for a + // public status. The collector promotes only the current candidate + // after exact rollout/process correlation, so persisted history stays + // conservatively Unknown. + let reason = match self.projection() { + HookProjection::Unknown(reason) => reason, + HookProjection::TurnOpen => StatusReason::HookTurnOpen, + HookProjection::ToolOpen(_) | HookProjection::SubagentOpen { .. } => { + StatusReason::HookToolOpen + } + HookProjection::TurnStopped => StatusReason::HookTurnComplete, + HookProjection::Ended => StatusReason::OwnershipUnconfirmed, + }; + self.samples.push(HookStateSample { + event: event.kind, + observed_at_ms: event.observed_at_ms, + status: SessionStatus::Unknown, + reason, + }); + self.samples.sort_by_key(|sample| sample.observed_at_ms); + if self.samples.len() > MAX_STATE_SAMPLES { + let remove = self.samples.len() - MAX_STATE_SAMPLES; + self.samples.drain(..remove); + } + if self.open_tools.len() > MAX_OPEN_ITEMS + || self.tool_opened_at_ms.len() > MAX_OPEN_ITEMS + || self.open_child_tools.len() > MAX_OPEN_ITEMS + || self.child_tool_opened_at_ms.len() > MAX_OPEN_ITEMS + || self.open_subagents.len() > MAX_OPEN_ITEMS + || self.subagent_opened_at_ms.len() > MAX_OPEN_ITEMS + || self.provisional_stopped_subagents.len() > MAX_OPEN_ITEMS + || self.subagent_stopped_at_ms.len() > MAX_OPEN_ITEMS + || self.open_questions.len() > MAX_OPEN_ITEMS + || self.question_opened_at_ms.len() > MAX_OPEN_ITEMS + || self.question_agents.len() > MAX_OPEN_ITEMS + || self.child_permission_ambiguities.len() > MAX_OPEN_ITEMS + || self.child_permission_observed_at_ms.len() > MAX_OPEN_ITEMS + { + self.sticky_fault = Some(StatusReason::HookStateMalformed); + while self.open_tools.len() > MAX_OPEN_ITEMS { + if let Some(key) = self.open_tools.keys().next_back().cloned() { + self.open_tools.remove(&key); + self.tool_opened_at_ms.remove(&key); + } + } + while self.open_child_tools.len() > MAX_OPEN_ITEMS { + if let Some(key) = self.open_child_tools.keys().next_back().cloned() { + self.open_child_tools.remove(&key); + self.child_tool_opened_at_ms.remove(&key); + } + } + while self.open_subagents.len() > MAX_OPEN_ITEMS { + if let Some(key) = self.open_subagents.iter().next_back().cloned() { + self.open_subagents.remove(&key); + self.subagent_opened_at_ms.remove(&key); + self.provisional_stopped_subagents.remove(&key); + self.subagent_stopped_at_ms.remove(&key); + let owned_tools = self + .open_child_tools + .iter() + .filter_map(|(tool, owner)| (owner == &key).then_some(tool.clone())) + .collect::>(); + for tool in owned_tools { + self.open_child_tools.remove(&tool); + self.child_tool_opened_at_ms.remove(&tool); + } + self.closed_child_tools.retain(|_, owner| owner != &key); + self.completed_child_tool_order + .retain(|tool| self.closed_child_tools.contains_key(tool)); + } + } + while self.open_questions.len() > MAX_OPEN_ITEMS { + if let Some(key) = self.open_questions.iter().next_back().cloned() { + self.open_questions.remove(&key); + self.question_opened_at_ms.remove(&key); + self.question_agents.remove(&key); + } + } + while self.child_permission_ambiguities.len() > MAX_OPEN_ITEMS { + if let Some(key) = self + .child_permission_ambiguities + .iter() + .next_back() + .cloned() + { + self.child_permission_ambiguities.remove(&key); + self.child_permission_observed_at_ms.remove(&key); + } + } + } + } +} + +fn completed_order_matches<'a>( + order: &'a [String], + members: impl Iterator, +) -> bool { + let ordered = order.iter().collect::>(); + let members = members.collect::>(); + ordered.len() == order.len() && ordered == members +} + +fn persisted_completed_order_matches<'a>( + order: &'a [String], + members: impl Iterator, +) -> bool { + // Empty order metadata is the only legacy schema-v1 exception. Partial, + // duplicate, or conflicting metadata is malformed rather than repaired. + order.is_empty() || completed_order_matches(order, members) +} + +#[derive(Debug, Default)] +pub struct HookStateScan { + pub states: Vec, + pub rejected: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct HookIngestFault { + schema_version: u32, + #[serde(default)] + integration: Option, + observed_at_ms: u64, + /// Random per-adoption identity. Legacy records deserialize with an empty + /// value and fail closed instead of inheriting a basename-only commit. + #[serde(default)] + commit_id: String, +} + +fn new_ingest_commit_id() -> io::Result { + let mut random = [0_u8; INGEST_COMMIT_ID_HEX_BYTES]; + getrandom::fill(&mut random).map_err(io::Error::other)?; + Ok(hex(&random)) +} + +fn valid_ingest_commit_id(value: &str) -> bool { + value.len() == INGEST_COMMIT_ID_LEN && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn ingest_commit_proof(marker_name: &str, commit_id: &str) -> io::Result { + if (!valid_fault_filename(marker_name) && !valid_launcher_fault_filename(marker_name)) + || !valid_ingest_commit_id(commit_id) + { + return Err(invalid_data("invalid hook ingest commit proof")); + } + Ok(format!( + "{marker_name}{INGEST_COMMIT_PROOF_SEPARATOR}{commit_id}" + )) +} + +fn parse_ingest_commit_proof(value: &str) -> Option<(&str, &str)> { + let (marker_name, commit_id) = value.split_once(INGEST_COMMIT_PROOF_SEPARATOR)?; + ((!marker_name.contains(INGEST_COMMIT_PROOF_SEPARATOR)) + && (valid_fault_filename(marker_name) || valid_launcher_fault_filename(marker_name)) + && valid_ingest_commit_id(commit_id)) + .then_some((marker_name, commit_id)) +} + +fn valid_ingest_commit_proof(value: &str) -> bool { + parse_ingest_commit_proof(value).is_some() +} + +fn valid_ingest_fault(fault: &HookIngestFault) -> bool { + fault.schema_version == HOOK_STATE_SCHEMA_VERSION + && fault.observed_at_ms != 0 + && valid_ingest_commit_id(&fault.commit_id) + && fault + .integration + .as_ref() + .is_none_or(|identity| validate_identity(identity).is_ok()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConfirmedGoneGcDecision { + Keep, + PersistFirstConfirmation, + Remove, +} + +fn confirmed_gone_gc_decision( + state: &mut HookSessionState, + now_ms: u64, +) -> ConfirmedGoneGcDecision { + if now_ms < state.updated_at_ms || !state.process.confirmed_gone() { + return ConfirmedGoneGcDecision::Keep; + } + if state.first_confirmed_gone_at_ms == 0 { + state.first_confirmed_gone_at_ms = now_ms; + return ConfirmedGoneGcDecision::PersistFirstConfirmation; + } + if now_ms.saturating_sub(state.first_confirmed_gone_at_ms) > PROCESS_DEATH_OBSERVATION_GRACE_MS + { + // `confirmed_gone` above is a fresh exact-incarnation check for this + // deletion attempt. A reused numeric PID cannot satisfy Live again. + ConfirmedGoneGcDecision::Remove + } else { + ConfirmedGoneGcDecision::Keep + } +} + +/// A durable marker created before parsing/folding one hook invocation. +/// Dropping an armed guard deliberately leaves the marker behind. +pub struct HookIngestGuard { + fault_dir: Arc, + name: OsString, + commit_proof: String, + file: File, + armed: bool, + remove_on_success: bool, +} + +impl HookIngestGuard { + pub fn marker_id(&self) -> io::Result<&str> { + Ok(&self.commit_proof) + } + + pub fn succeed(mut self) -> io::Result<()> { + if self.remove_on_success { + self.fault_dir.remove_if_same(&self.name, &self.file)?; + } + self.armed = false; + Ok(()) + } +} + +fn ingest_guard( + fault_dir: &Arc, + name: OsString, + file: File, + commit_id: &str, + remove_on_success: bool, +) -> io::Result { + let marker_name = name + .to_str() + .ok_or_else(|| invalid_data("invalid hook marker encoding"))?; + Ok(HookIngestGuard { + fault_dir: Arc::clone(fault_dir), + commit_proof: ingest_commit_proof(marker_name, commit_id)?, + name, + file, + armed: true, + remove_on_success, + }) +} + +impl Drop for HookIngestGuard { + fn drop(&mut self) { + // An armed marker is the fail-closed result. It must not be removed + // from Drop because unwinding/errors are exactly what it records. + let _ = self.armed; + } +} + +/// A safely anchored store root used before attestation and process/input +/// preflight. Its generic marker deliberately contains no provider content. +pub struct HookStateIngress { + state_dir: Arc, + fault_dir: Arc, +} + +pub struct HookStateStore { + state_dir: Arc, + fault_dir: Arc, + expected: IntegrationIdentity, +} + +impl HookStateStore { + /// Resolve and anchor the private state directories before any expensive + /// hook preflight. On Unix every later operation is relative to retained + /// directory descriptors, so replacing an ancestor cannot redirect I/O. + pub fn prepare(plugin_data: &Path) -> io::Result { + if !hook_state_platform_supported() { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "secure Codex hook state is unsupported on this platform", + )); + } + validate_plugin_data_path(plugin_data)?; + let plugin_dir = SecureDirectory::open_path(plugin_data, true)?; + let state_dir = Arc::new(plugin_dir.open_or_create_child(STATE_DIR_NAME)?); + let fault_dir = Arc::new(state_dir.open_or_create_child(FAULT_DIR_NAME)?); + Ok(HookStateIngress { + state_dir, + fault_dir, + }) + } + + #[cfg(all(test, unix))] + pub fn new(plugin_data: &Path, expected: IntegrationIdentity) -> io::Result { + Self::prepare(plugin_data)?.bind(expected) + } + + /// Open the collector side without creating directories or repairing + /// permissions. Missing or unsafe state must remain a read-only failure. + pub fn open_existing(plugin_data: &Path, expected: IntegrationIdentity) -> io::Result { + if !hook_state_platform_supported() { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "secure Codex hook state is unsupported on this platform", + )); + } + validate_plugin_data_path(plugin_data)?; + validate_identity(&expected)?; + let plugin_dir = SecureDirectory::open_path(plugin_data, false)?; + let state_dir = Arc::new(plugin_dir.open_existing_child(STATE_DIR_NAME)?); + let fault_dir = Arc::new(state_dir.open_existing_child(FAULT_DIR_NAME)?); + Ok(Self { + state_dir, + fault_dir, + expected, + }) + } + + pub fn fold(&self, event: HookEvent) -> io::Result { + validate_event(&event)?; + if event.integration != self.expected { + return Err(invalid_data("hook integration identity changed")); + } + let name = OsString::from(format!("state-{}.json", state_key(&event))); + let state = { + let _lock = StateLock::acquire(&self.state_dir)?; + let mut state = match read_state_file(&self.state_dir, &name)? { + Some(state) => state, + None => { + self.ensure_state_capacity(event.observed_at_ms)?; + let mut state = HookSessionState::new(&event); + if event.kind != HookEventKind::SessionStart { + state.sticky_fault = Some(StatusReason::HookEventGap); + } + state + } + }; + state.apply(&event); + validate_state(&state)?; + write_state_file(&self.state_dir, &name, &state)?; + state + }; + // Terminal retention is intentionally outside the hot fold lock. + // Files are immutable terminal generations and removal verifies the + // exact opened inode before unlinking. + if event.kind == HookEventKind::SessionEnd { + self.cleanup_terminal_states(event.observed_at_ms)?; + } + Ok(state) + } + + #[cfg(all(test, unix))] + pub fn begin_ingest(&self, observed_at_ms: u64) -> io::Result { + self.begin_marker(observed_at_ms, Some(self.expected.clone())) + } + + pub fn read_all(&self, now_ms: u64) -> io::Result { + self.read_all_with_between_snapshot(now_ms, || {}) + } + + fn read_all_with_between_snapshot( + &self, + now_ms: u64, + mut between_snapshot_halves: F, + ) -> io::Result + where + F: FnMut(), + { + // State replacements and fault-ledger compaction are serialized by + // the same retained lock. Keep the shared collector lock through both + // halves so commit proofs and their markers come from one coherent + // snapshot. Any temporary file left after acquisition is abandoned + // evidence and still fails closed. + if let Some(_lock) = StateLock::acquire_existing(&self.state_dir)? { + return self.read_all_snapshot(now_ms, &mut between_snapshot_halves); + } + + // A freshly prepared store has no lock yet. Snapshot it without + // creating one, then double-check: if the first writer appeared at + // any point, wait for it and rerun the entire state-plus-fault scan. + let unlocked = self.read_all_snapshot(now_ms, &mut between_snapshot_halves)?; + if let Some(_lock) = StateLock::acquire_existing(&self.state_dir)? { + return self.read_all_snapshot(now_ms, &mut between_snapshot_halves); + } + Ok(unlocked) + } + + fn read_all_snapshot( + &self, + now_ms: u64, + between_snapshot_halves: &mut F, + ) -> io::Result + where + F: FnMut(), + { + let (mut scan, mut poison_all) = self.read_state_directory_snapshot(now_ms)?; + between_snapshot_halves(); + let mut latest_failure_ms = 0_u64; + + // The launcher creates its marker before the helper can take the + // state lock. A broken older helper can therefore leave more than the + // steady-state fault budget behind. Read a larger, still-bounded + // recovery window so a complete ledger of valid records remains + // reducible instead of becoming malformed solely because it exceeds + // the writer's soft cap. + let (faults, faults_truncated) = self + .fault_dir + .list_names(MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES)?; + if faults_truncated { + scan.rejected += 1; + poison_all = true; + } + if faults + .iter() + .filter(|name| { + name.to_str().is_some_and(|name| { + valid_fault_filename(name) || valid_launcher_fault_filename(name) + }) + }) + .count() + > MAX_FAULT_RECOVERY_FILES + { + scan.rejected += 1; + poison_all = true; + } + for name in faults { + let Some(name) = name.to_str() else { + scan.rejected += 1; + poison_all = true; + continue; + }; + if valid_temporary_filename(name) { + scan.rejected += 1; + poison_all = true; + continue; + } + if !valid_fault_filename(name) && !valid_launcher_fault_filename(name) { + scan.rejected += 1; + poison_all = true; + continue; + } + match read_fault_file(&self.fault_dir, OsStr::new(name)) { + Ok(fault) => { + if !valid_ingest_fault(&fault) { + scan.rejected += 1; + poison_all = true; + continue; + } + if fault.integration.as_ref().is_some_and(|integration| { + integration.installation_id != self.expected.installation_id + }) { + // A structurally valid older integration cannot poison + // or exhaust the current installation. + continue; + } + if fault.observed_at_ms > now_ms.saturating_add(60_000) { + scan.rejected += 1; + poison_all = true; + continue; + } + let Ok(commit_proof) = ingest_commit_proof(name, &fault.commit_id) else { + scan.rejected += 1; + poison_all = true; + continue; + }; + let committed = name != FAULT_OVERFLOW_NAME + && scan.states.iter().any(|state| { + state.integration == self.expected + && state + .completed_ingests + .iter() + .any(|proof| proof == &commit_proof) + && state.updated_at_ms >= fault.observed_at_ms + }); + if committed { + continue; + } + match fault.integration.as_ref() { + None => { + latest_failure_ms = latest_failure_ms.max(fault.observed_at_ms); + } + Some(integration) if integration == &self.expected => { + latest_failure_ms = latest_failure_ms.max(fault.observed_at_ms); + } + Some(_) => { + scan.rejected += 1; + poison_all = true; + } + } + } + Err(_) => { + // Empty launcher markers, partial writes, future times, + // and schema mismatches are durable global uncertainty. + scan.rejected += 1; + poison_all = true; + } + } + } + + for state in &mut scan.states { + if poison_all { + state.sticky_fault = Some(StatusReason::HookStateMalformed); + } else if latest_failure_ms > 0 && state.created_at_ms <= latest_failure_ms { + state.sticky_fault = Some(StatusReason::HookEventGap); + } + } + Ok(scan) + } + + fn read_state_directory_snapshot(&self, now_ms: u64) -> io::Result<(HookStateScan, bool)> { + let mut scan = HookStateScan::default(); + let mut poison_all = false; + let mut saw_lock = false; + let mut saw_state_file = false; + let (entries, truncated) = self.state_dir.list_names(MAX_DIRECTORY_ENTRIES)?; + if truncated { + scan.rejected += 1; + poison_all = true; + } + for name in entries { + let Some(name) = name.to_str() else { + scan.rejected += 1; + poison_all = true; + continue; + }; + if name == ".lock" { + saw_lock = true; + let lock_is_unsafe = match self + .state_dir + .open_private_read(OsStr::new(name)) + .and_then(|file| file.metadata()) + { + Ok(metadata) => metadata.len() != 0, + Err(_) => true, + }; + if lock_is_unsafe { + scan.rejected += 1; + poison_all = true; + } + continue; + } + if name == FAULT_DIR_NAME { + continue; + } + if valid_temporary_filename(name) { + // Synchronization is attempted before and after an initially + // lock-free snapshot, so a conforming atomic writer cannot + // leave this file in the returned snapshot. Count an + // abandoned artifact so it cannot bypass the directory bound + // or preserve stale positive evidence. + scan.rejected += 1; + poison_all = true; + continue; + } + if !valid_state_filename(name) { + scan.rejected += 1; + poison_all = true; + continue; + } + saw_state_file = true; + match read_state_file(&self.state_dir, OsStr::new(name)) { + Ok(Some(mut state)) => { + if validate_state(&state).is_err() { + scan.rejected += 1; + poison_all = true; + continue; + } + let expected_name = format!("state-{}.json", state.generation_id); + if name != expected_name { + scan.rejected += 1; + poison_all = true; + continue; + } + if state.integration.installation_id != self.expected.installation_id { + // A prior installation is retained for bounded audit + // and GC, but is not malformed evidence for the + // current integration and cannot produce a live row. + continue; + } + if state.integration != self.expected { + state.sticky_fault = Some(StatusReason::HookConfigChanged); + } + if state.updated_at_ms > now_ms.saturating_add(60_000) { + state.sticky_fault = Some(StatusReason::HookStateMalformed); + } + if state.first_confirmed_gone_at_ms > now_ms.saturating_add(60_000) { + state.sticky_fault = Some(StatusReason::HookStateMalformed); + } + scan.states.push(state); + } + _ => { + scan.rejected += 1; + poison_all = true; + } + } + } + if scan.states.len() > MAX_STATE_FILES { + scan.rejected += 1; + scan.states.truncate(MAX_STATE_FILES); + poison_all = true; + } + if saw_state_file && !saw_lock { + scan.rejected += 1; + poison_all = true; + } + Ok((scan, poison_all)) + } + + #[cfg(all(test, unix))] + fn state_path(&self, key: &str) -> PathBuf { + self.state_dir.path.join(format!("state-{key}.json")) + } + + fn cleanup_terminal_states(&self, now_ms: u64) -> io::Result<()> { + let (entries, _) = self.state_dir.list_names(MAX_DIRECTORY_ENTRIES)?; + let mut candidates = Vec::new(); + for name in entries { + let Some(name_str) = name.to_str() else { + continue; + }; + if !valid_state_filename(name_str) { + continue; + } + let Some(state) = read_state_file(&self.state_dir, &name)? else { + continue; + }; + if validate_state(&state).is_err() { + // Malformed evidence is never garbage-collected as if it + // were a trustworthy terminal generation. + continue; + } + let retention_anchor = if state.ended_at_ms != 0 { + state.ended_at_ms + } else { + state.updated_at_ms + }; + if now_ms.saturating_sub(retention_anchor) > TERMINAL_RETENTION_MS + && state.process.confirmed_gone() + { + candidates.push((name, state)); + } + } + + // Slow process probes above do not hold the writer lock. Revalidate + // both the exact state snapshot and exact process incarnation before + // persisting a first-death observation or deleting anything. + let _lock = StateLock::acquire(&self.state_dir)?; + for (name, snapshot) in candidates { + let Some((mut current, file)) = read_state_file_with_file(&self.state_dir, &name)? + else { + continue; + }; + if current != snapshot || validate_state(¤t).is_err() { + continue; + } + match confirmed_gone_gc_decision(&mut current, now_ms) { + ConfirmedGoneGcDecision::Keep => {} + ConfirmedGoneGcDecision::PersistFirstConfirmation => { + validate_state(¤t)?; + write_state_file(&self.state_dir, &name, ¤t)?; + } + ConfirmedGoneGcDecision::Remove => { + let _ = self.state_dir.remove_if_same(&name, &file); + } + } + } + Ok(()) + } + + fn ensure_state_capacity(&self, now_ms: u64) -> io::Result<()> { + let (entries, truncated) = self.state_dir.list_names(MAX_DIRECTORY_ENTRIES)?; + let mut state_names = entries + .into_iter() + .filter(|name| name.to_str().is_some_and(valid_state_filename)) + .collect::>(); + if !truncated && state_names.len() < MAX_STATE_FILES { + return Ok(()); + } + + // Pressure never anchors the observation opportunity to SessionEnd: + // a process can remain live long after that hook. The first exact + // death confirmation is persisted, and removal needs a later pass + // strictly beyond its grace interval. + state_names.sort(); + for name in &state_names { + let Ok(Some((mut state, file))) = read_state_file_with_file(&self.state_dir, name) + else { + continue; + }; + if validate_state(&state).is_err() { + continue; + } + let terminal_reclaimable = state.ended_at_ms != 0 + && now_ms.saturating_sub(state.ended_at_ms) >= MIN_TERMINAL_AGE_BEFORE_PRESSURE_MS; + let crashed_reclaimable = state.ended_at_ms == 0 + && now_ms.saturating_sub(state.updated_at_ms) > TERMINAL_RETENTION_MS; + if terminal_reclaimable || crashed_reclaimable { + match confirmed_gone_gc_decision(&mut state, now_ms) { + ConfirmedGoneGcDecision::Keep => {} + ConfirmedGoneGcDecision::PersistFirstConfirmation => { + validate_state(&state)?; + write_state_file(&self.state_dir, name, &state)?; + } + ConfirmedGoneGcDecision::Remove => { + let _ = self.state_dir.remove_if_same(name, &file); + } + } + } + let (remaining, still_truncated) = self.state_dir.list_names(MAX_DIRECTORY_ENTRIES)?; + let count = remaining + .iter() + .filter(|candidate| candidate.to_str().is_some_and(valid_state_filename)) + .count(); + if !still_truncated && count < MAX_STATE_FILES { + return Ok(()); + } + } + + self.record_overflow_fault(now_ms, Some(self.expected.clone()))?; + Err(invalid_data("hook-state generation capacity exhausted")) + } + + #[cfg(all(test, unix))] + fn begin_marker( + &self, + observed_at_ms: u64, + integration: Option, + ) -> io::Result { + begin_marker( + &self.state_dir, + &self.fault_dir, + observed_at_ms, + integration, + ) + } + + fn record_overflow_fault( + &self, + observed_at_ms: u64, + integration: Option, + ) -> io::Result<()> { + record_overflow_fault(&self.fault_dir, observed_at_ms, integration).map(|_| ()) + } +} + +impl HookStateIngress { + pub fn bind(&self, expected: IntegrationIdentity) -> io::Result { + validate_identity(&expected)?; + Ok(HookStateStore { + state_dir: Arc::clone(&self.state_dir), + fault_dir: Arc::clone(&self.fault_dir), + expected, + }) + } + + pub fn begin_ingest(&self, observed_at_ms: u64) -> io::Result { + begin_marker(&self.state_dir, &self.fault_dir, observed_at_ms, None) + } + + /// Reclaim crash artifacts after the hook payload has been drained. + /// + /// This intentionally performs process-incarnation probes and therefore + /// must not run on the latency-critical path before stdin drain or durable + /// marker adoption. Collector reads never call this mutation. + pub fn reclaim_stale_artifacts_after_drain( + &self, + now_ms: u64, + protected_marker_id: Option<&str>, + ) -> io::Result<()> { + if now_ms == 0 { + return Err(invalid_data("invalid hook-ingest cleanup time")); + } + // Fault-ledger compaction must not depend on the slower out-of-lock + // process probes below. Active parallel hooks can legitimately change + // generation state during those probes; tying compaction to their + // snapshot equality would let valid launcher faults grow forever. + let mut compaction_error = None; + if fault_compaction_may_be_needed(&self.fault_dir, now_ms, protected_marker_id)? { + if let Some(_lock) = StateLock::try_acquire(&self.state_dir)? { + if let Some(states) = generations_for_artifact_reclamation(&self.state_dir)? { + if let Err(error) = compact_adopted_generic_launcher_faults( + &self.fault_dir, + &states, + now_ms, + protected_marker_id, + ) { + // Keep the current ingest fail-closed, but do not let a + // stale compaction artifact prevent its own separately + // authorized 24-hour cleanup. A later ingest can retry + // compaction after that exact artifact is reclaimed. + compaction_error = Some(error); + } + } + } + } + let stale_reclamation = (|| -> io::Result<()> { + if !stale_artifact_reclamation_may_be_needed( + &self.state_dir, + &self.fault_dir, + now_ms, + protected_marker_id, + )? { + return Ok(()); + } + let Some(states_before_probe) = generations_for_artifact_reclamation(&self.state_dir)? + else { + return Ok(()); + }; + // Process inspection is deliberately outside the global state lock: + // another concurrent hook must still be able to adopt its durable + // marker and drain stdin within Codex's one-second timeout. + let process_cache = process_death_snapshot(&states_before_probe); + let _lock = StateLock::acquire(&self.state_dir)?; + let Some(states_after_probe) = generations_for_artifact_reclamation(&self.state_dir)? + else { + return Ok(()); + }; + if states_before_probe != states_after_probe { + // Never apply process observations to a state set that changed + // while those observations were collected. + return Ok(()); + } + reclaim_stale_ingest_artifacts( + &self.state_dir, + &self.fault_dir, + &states_after_probe, + now_ms, + &process_cache, + )?; + reclaim_expired_faults(&self.fault_dir, &states_after_probe, now_ms, &process_cache) + })(); + if let Some(error) = compaction_error { + return Err(error); + } + stale_reclamation + } + + /// Adopt only the unique launcher token grammar (or its bounded fixed-slot + /// fallback) below the already anchored faults directory. An arbitrary + /// path is never accepted. + pub fn adopt_launcher_marker( + &self, + token: &OsStr, + observed_at_ms: u64, + ) -> io::Result { + let Some(name) = token.to_str() else { + return Err(invalid_data("invalid launcher fault token encoding")); + }; + if !valid_launcher_fault_filename(name) { + return Err(invalid_data("invalid launcher fault token")); + } + if observed_at_ms == 0 { + return Err(invalid_data("invalid hook-ingest fault time")); + } + let _lock = StateLock::acquire(&self.state_dir)?; + let mut file = self.fault_dir.open_private_rw_existing(token)?; + if file.metadata()?.len() != 0 { + return Err(invalid_data("launcher fault marker was already adopted")); + } + lock_marker(&file)?; + let commit_id = new_ingest_commit_id()?; + let fault = HookIngestFault { + schema_version: HOOK_STATE_SCHEMA_VERSION, + integration: None, + observed_at_ms, + commit_id: commit_id.clone(), + }; + let bytes = encode_fault(&fault)?; + use std::io::{Seek, SeekFrom}; + file.seek(SeekFrom::Start(0))?; + file.write_all(&bytes)?; + file.set_len(bytes.len() as u64)?; + file.sync_all()?; + self.fault_dir.sync()?; + ingest_guard( + &self.fault_dir, + token.to_os_string(), + file, + &commit_id, + true, + ) + } +} + +fn fault_compaction_may_be_needed( + fault_dir: &SecureDirectory, + now_ms: u64, + protected_marker_id: Option<&str>, +) -> io::Result { + let protected_name = protected_marker_id + .map(|proof| { + parse_ingest_commit_proof(proof) + .map(|(name, _)| name) + .ok_or_else(|| invalid_data("invalid protected hook marker identity")) + }) + .transpose()?; + if protected_name == Some(FAULT_OVERFLOW_NAME) { + return Ok(false); + } + let (names, _) = fault_dir.list_names(MAX_FAULT_COMPACTION_SCAN_ENTRIES)?; + for name in names { + let Some(name_str) = name.to_str() else { + continue; + }; + if !valid_launcher_fault_filename(name_str) || protected_name == Some(name_str) { + continue; + } + let Ok((file, bytes)) = fault_dir.read_private_bounded(&name, MAX_FAULT_BYTES) else { + continue; + }; + let Ok(fault) = serde_json::from_slice::(&bytes) else { + continue; + }; + if valid_ingest_fault(&fault) + && fault.integration.is_none() + && fault.observed_at_ms <= now_ms.saturating_add(60_000) + && try_lock_marker(&file)? + { + return Ok(true); + } + } + Ok(false) +} + +fn stale_artifact_reclamation_may_be_needed( + state_dir: &SecureDirectory, + fault_dir: &SecureDirectory, + now_ms: u64, + protected_marker_id: Option<&str>, +) -> io::Result { + let protected_name = protected_marker_id + .and_then(parse_ingest_commit_proof) + .map(|(name, _)| name); + let (state_names, state_truncated) = state_dir.list_names(MAX_DIRECTORY_ENTRIES)?; + if state_truncated { + return Ok(false); + } + for name in state_names { + if !name.to_str().is_some_and(valid_temporary_filename) { + continue; + } + if let Ok(file) = state_dir.open_private_read(&name) { + if stale_artifact_cutoff(&file, now_ms).is_some() { + return Ok(true); + } + } + } + + let (fault_names, _) = fault_dir.list_names(MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES)?; + // Inspect the retained recovery window even when more names exist. A + // truncated directory alone does not justify the expensive state/death + // probes: fresh markers are not reclaimable and already keep reads + // fail-closed. Once any marker in this bounded window becomes eligible, + // batched reclamation makes room for later entries on subsequent hooks. + for name in fault_names { + let Some(name_str) = name.to_str() else { + continue; + }; + if protected_name == Some(name_str) || name_str == FAULT_OVERFLOW_NAME { + continue; + } + let eligible = valid_temporary_filename(name_str) + || valid_launcher_fault_filename(name_str) + || valid_fault_filename(name_str); + if !eligible { + continue; + } + if let Ok(file) = fault_dir.open_private_read(&name) { + if stale_artifact_cutoff(&file, now_ms).is_some() { + return Ok(true); + } + } + } + Ok(false) +} + +/// Losslessly collapse completed or failed adopted launcher markers. +/// +/// The launcher itself does not participate in `StateLock`, so this function +/// operates only on exact inodes from one complete bounded snapshot. Locked, +/// malformed, empty, scoped, future-dated, and newly-created markers are left +/// untouched. Uncommitted generic failures are represented by the permanent +/// monotonic overflow record before their exact inodes are removed. +fn compact_adopted_generic_launcher_faults( + fault_dir: &Arc, + states: &[HookSessionState], + now_ms: u64, + protected_marker_id: Option<&str>, +) -> io::Result<()> { + compact_adopted_generic_launcher_faults_with( + fault_dir, + states, + now_ms, + protected_marker_id, + || {}, + ) +} + +fn compact_adopted_generic_launcher_faults_with( + fault_dir: &Arc, + states: &[HookSessionState], + now_ms: u64, + protected_marker_id: Option<&str>, + before_exclusive_overflow_create: F, +) -> io::Result<()> +where + F: FnOnce(), +{ + let protected_name = match protected_marker_id { + Some(proof) => Some( + parse_ingest_commit_proof(proof) + .ok_or_else(|| invalid_data("invalid protected hook marker identity"))? + .0, + ), + None => None, + }; + if protected_name == Some(FAULT_OVERFLOW_NAME) { + return Ok(()); + } + + // Process a fixed batch even when more names exist. Every selected inode + // is independently validated and summarized, while unseen entries remain + // fail-closed for the reader and become eligible on later passes as the + // directory shrinks. + let (names, _) = fault_dir.list_names(MAX_FAULT_COMPACTION_SCAN_ENTRIES)?; + + let mut candidates = Vec::new(); + for name in names { + if candidates.len() >= MAX_FAULT_COMPACTION_BATCH_FILES { + break; + } + let Some(name_str) = name.to_str() else { + continue; + }; + if !valid_launcher_fault_filename(name_str) { + continue; + } + let Ok((file, bytes)) = fault_dir.read_private_bounded(&name, MAX_FAULT_BYTES) else { + continue; + }; + let Ok(fault) = serde_json::from_slice::(&bytes) else { + continue; + }; + if !valid_ingest_fault(&fault) + || fault.integration.is_some() + || fault.observed_at_ms > now_ms.saturating_add(60_000) + { + continue; + } + let Ok(commit_proof) = ingest_commit_proof(name_str, &fault.commit_id) else { + continue; + }; + if protected_marker_id == Some(commit_proof.as_str()) || !try_lock_marker(&file)? { + continue; + } + let committed = states.iter().any(|state| { + state.updated_at_ms >= fault.observed_at_ms + && state + .completed_ingests + .iter() + .any(|proof| proof == &commit_proof) + }); + candidates.push((name, file, fault.observed_at_ms, committed)); + } + + let latest_uncommitted = candidates + .iter() + .filter_map(|(_, _, observed_at_ms, committed)| (!committed).then_some(*observed_at_ms)) + .max() + .unwrap_or_default(); + if latest_uncommitted != 0 + && !persist_compaction_overflow_with( + fault_dir, + latest_uncommitted, + now_ms, + before_exclusive_overflow_create, + )? + { + return Ok(()); + } + + let mut removed_any = false; + for (name, file, _, _) in candidates { + // A concurrent launcher can add a new marker, but it cannot make this + // exact opened inode match a replacement at the same name. + if fault_dir.remove_if_same_without_sync(&name, &file).is_ok() { + removed_any = true; + } + } + if removed_any { + // The overflow record or exact state commit proofs are already durable. + // One directory sync now commits the exact-inode batch without putting + // one fsync on the one-second hook path for every old marker. + fault_dir.sync()?; + } + Ok(()) +} + +fn persist_compaction_overflow_with( + fault_dir: &Arc, + observed_at_ms: u64, + now_ms: u64, + before_exclusive_create: F, +) -> io::Result +where + F: FnOnce(), +{ + let existing = + match fault_dir.read_private_bounded(OsStr::new(FAULT_OVERFLOW_NAME), MAX_FAULT_BYTES) { + Ok((file, bytes)) => { + let Ok(fault) = serde_json::from_slice::(&bytes) else { + return Ok(false); + }; + if !valid_ingest_fault(&fault) + || fault.observed_at_ms > now_ms.saturating_add(60_000) + || !try_lock_marker(&file)? + { + return Ok(false); + } + Some((file, fault)) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(_) => return Ok(false), + }; + + let commit_id = new_ingest_commit_id()?; + let fault = HookIngestFault { + schema_version: HOOK_STATE_SCHEMA_VERSION, + integration: None, + observed_at_ms: existing + .as_ref() + .map(|(_, fault)| fault.observed_at_ms) + .unwrap_or_default() + .max(observed_at_ms), + commit_id, + }; + let bytes = encode_fault(&fault)?; + + if let Some((file, old)) = existing { + if old.integration.is_none() && old.observed_at_ms >= observed_at_ms { + let Ok(current) = fault_dir.open_private_read(OsStr::new(FAULT_OVERFLOW_NAME)) else { + return Ok(false); + }; + if !same_file(¤t.metadata()?, &file.metadata()?) { + return Ok(false); + } + return Ok(true); + } + fault_dir.atomic_replace_if_same_with_temporary( + OsStr::new(FAULT_OVERFLOW_NAME), + &file, + OsStr::new(FAULT_COMPACTION_TEMP_NAME), + &bytes, + )?; + return Ok(true); + } + + // The launcher does not take StateLock and can create an empty overflow + // marker after our absence check. O_EXCL makes that exact race observable: + // the concurrent marker wins, remains fail-closed, and no source marker is + // deleted by this pass. + before_exclusive_create(); + match fault_dir.create_private_new(OsStr::new(FAULT_OVERFLOW_NAME), &bytes) { + Ok(file) => { + drop(file); + Ok(true) + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(false), + Err(error) => Err(error), + } +} + +fn begin_marker( + state_dir: &Arc, + fault_dir: &Arc, + observed_at_ms: u64, + integration: Option, +) -> io::Result { + if observed_at_ms == 0 { + return Err(invalid_data("invalid hook-ingest fault time")); + } + if let Some(identity) = integration.as_ref() { + validate_identity(identity)?; + } + let _lock = StateLock::acquire(state_dir)?; + let (entries, truncated) = fault_dir.list_names(MAX_FAULT_DIRECTORY_ENTRIES)?; + let overflow_present = entries + .iter() + .any(|name| name == OsStr::new(FAULT_OVERFLOW_NAME)); + let overflow_invalid = overflow_present + && match read_fault_file(fault_dir, OsStr::new(FAULT_OVERFLOW_NAME)) { + Ok(fault) => !valid_ingest_fault(&fault), + Err(_) => true, + }; + if overflow_invalid { + let commit_id = record_overflow_fault(fault_dir, observed_at_ms, integration)?; + let file = fault_dir.open_private_rw_existing(OsStr::new(FAULT_OVERFLOW_NAME))?; + lock_marker(&file)?; + return ingest_guard( + fault_dir, + OsString::from(FAULT_OVERFLOW_NAME), + file, + &commit_id, + false, + ); + } + let count = entries + .iter() + .filter(|name| { + name.to_str().is_some_and(|name| { + valid_fault_filename(name) || valid_launcher_fault_filename(name) + }) + }) + .count(); + if truncated || count >= MAX_FAULT_FILES { + let commit_id = record_overflow_fault(fault_dir, observed_at_ms, integration)?; + let file = fault_dir.open_private_rw_existing(OsStr::new(FAULT_OVERFLOW_NAME))?; + lock_marker(&file)?; + return ingest_guard( + fault_dir, + OsString::from(FAULT_OVERFLOW_NAME), + file, + &commit_id, + false, + ); + } + let commit_id = new_ingest_commit_id()?; + let fault = HookIngestFault { + schema_version: HOOK_STATE_SCHEMA_VERSION, + integration, + observed_at_ms, + commit_id: commit_id.clone(), + }; + let bytes = encode_fault(&fault)?; + for _ in 0..8 { + let mut random = [0_u8; 16]; + getrandom::fill(&mut random).map_err(io::Error::other)?; + let name = OsString::from(format!("{FAULT_PREFIX}{}.json", hex(&random))); + match fault_dir.create_private_new(&name, &bytes) { + Ok(file) => { + lock_marker(&file)?; + return ingest_guard(fault_dir, name, file, &commit_id, true); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + record_overflow_fault(fault_dir, observed_at_ms, fault.integration)?; + Err(invalid_data("cannot allocate a unique hook fault marker")) +} + +/// Return a complete, validated generation set for conservative artifact GC. +/// A malformed/truncated directory makes reclamation ineligible rather than +/// letting cleanup erase evidence it cannot associate with a dead process. +fn generations_for_artifact_reclamation( + state_dir: &SecureDirectory, +) -> io::Result>> { + let (names, truncated) = state_dir.list_names(MAX_DIRECTORY_ENTRIES)?; + if truncated { + return Ok(None); + } + + let mut states = Vec::new(); + let mut saw_lock = false; + let mut saw_state = false; + for name in names { + let Some(name_str) = name.to_str() else { + return Ok(None); + }; + if name_str == ".lock" { + saw_lock = true; + let Ok(file) = state_dir.open_private_read(&name) else { + return Ok(None); + }; + if file.metadata()?.len() != 0 { + return Ok(None); + } + continue; + } + if name_str == FAULT_DIR_NAME || valid_temporary_filename(name_str) { + continue; + } + if !valid_state_filename(name_str) { + return Ok(None); + } + saw_state = true; + let Some(state) = read_state_file(state_dir, &name)? else { + return Ok(None); + }; + if validate_state(&state).is_err() + || name_str != format!("state-{}.json", state.generation_id) + { + return Ok(None); + } + states.push(state); + } + if saw_state && !saw_lock { + return Ok(None); + } + states.sort_by(|left, right| left.generation_id.cmp(&right.generation_id)); + Ok(Some(states)) +} + +fn process_death_snapshot(states: &[HookSessionState]) -> BTreeMap<(u32, String), bool> { + let mut process_cache = BTreeMap::new(); + for state in states { + let key = (state.process.pid, state.process.incarnation.clone()); + process_cache + .entry(key) + .or_insert_with(|| state.process.confirmed_gone()); + } + process_cache +} + +fn stale_artifact_cutoff(file: &File, now_ms: u64) -> Option { + let modified_at_ms = file + .metadata() + .ok()? + .modified() + .ok() + .and_then(system_time_ms)?; + (modified_at_ms != 0 && now_ms.saturating_sub(modified_at_ms) > TERMINAL_RETENTION_MS) + .then_some(modified_at_ms) +} + +fn affected_generations_confirmed_gone( + states: &[HookSessionState], + cutoff_ms: u64, + process_cache: &BTreeMap<(u32, String), bool>, +) -> bool { + states + .iter() + .filter(|state| state.created_at_ms <= cutoff_ms) + .all(|state| { + let key = (state.process.pid, state.process.incarnation.clone()); + process_cache.get(&key).copied().unwrap_or(false) + }) +} + +fn reclaim_stale_temporary_files( + directory: &SecureDirectory, + listing_bound: usize, + removal_limit: usize, + states: &[HookSessionState], + now_ms: u64, + process_cache: &BTreeMap<(u32, String), bool>, +) -> io::Result<()> { + let (names, _) = directory.list_names(listing_bound)?; + let mut removed = 0; + for name in names { + if removed >= removal_limit { + break; + } + let Some(name_str) = name.to_str() else { + continue; + }; + if !valid_temporary_filename(name_str) { + continue; + } + let Ok(file) = directory.open_private_read(&name) else { + continue; + }; + let Some(cutoff_ms) = stale_artifact_cutoff(&file, now_ms) else { + continue; + }; + if affected_generations_confirmed_gone(states, cutoff_ms, process_cache) + && directory.remove_if_same_without_sync(&name, &file).is_ok() + { + removed += 1; + } + } + if removed != 0 { + directory.sync()?; + } + Ok(()) +} + +fn reclaim_stale_launcher_markers( + fault_dir: &SecureDirectory, + states: &[HookSessionState], + now_ms: u64, + process_cache: &BTreeMap<(u32, String), bool>, +) -> io::Result<()> { + let (names, _) = fault_dir.list_names(MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES)?; + let mut removed = 0; + for name in names { + if removed >= MAX_FAULT_COMPACTION_BATCH_FILES { + break; + } + let Some(name_str) = name.to_str() else { + continue; + }; + if !valid_launcher_fault_filename(name_str) { + continue; + } + let Ok((file, bytes)) = fault_dir.read_private_bounded(&name, MAX_FAULT_BYTES) else { + continue; + }; + let Some(mut cutoff_ms) = stale_artifact_cutoff(&file, now_ms) else { + continue; + }; + + match serde_json::from_slice::(&bytes) { + Ok(fault) if valid_ingest_fault(&fault) => { + // A reused fixed fallback slot or theoretically colliding + // unique basename has a new inode/mtime. Require both its + // durable record and current inode to have crossed the 24h + // eligibility window before considering it abandoned. + if now_ms.saturating_sub(fault.observed_at_ms) <= TERMINAL_RETENTION_MS { + continue; + } + cutoff_ms = cutoff_ms.max(fault.observed_at_ms); + } + _ => { + // Launchers necessarily leave an empty marker if the helper + // never starts, and can leave a partial/legacy marker if it + // dies while adopting it. The inode mtime is the only safe + // content-free age for those malformed launcher records. + } + } + + if affected_generations_confirmed_gone(states, cutoff_ms, process_cache) + && try_lock_marker(&file)? + && fault_dir.remove_if_same_without_sync(&name, &file).is_ok() + { + removed += 1; + } + } + if removed != 0 { + fault_dir.sync()?; + } + Ok(()) +} + +fn reclaim_stale_ingest_artifacts( + state_dir: &SecureDirectory, + fault_dir: &SecureDirectory, + states: &[HookSessionState], + now_ms: u64, + process_cache: &BTreeMap<(u32, String), bool>, +) -> io::Result<()> { + reclaim_stale_temporary_files( + state_dir, + MAX_DIRECTORY_ENTRIES, + MAX_TEMP_FILES, + states, + now_ms, + process_cache, + )?; + reclaim_stale_temporary_files( + fault_dir, + MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES, + MAX_FAULT_COMPACTION_BATCH_FILES, + states, + now_ms, + process_cache, + )?; + reclaim_stale_launcher_markers(fault_dir, states, now_ms, process_cache) +} + +fn reclaim_expired_faults( + fault_dir: &SecureDirectory, + states: &[HookSessionState], + now_ms: u64, + process_cache: &BTreeMap<(u32, String), bool>, +) -> io::Result<()> { + let (fault_names, _) = fault_dir.list_names(MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES)?; + let mut removed = 0; + for name in fault_names { + if removed >= MAX_FAULT_COMPACTION_BATCH_FILES { + break; + } + let Some(name_str) = name.to_str() else { + continue; + }; + if valid_launcher_fault_filename(name_str) { + // Launcher records have inode-age and fixed-slot reuse rules that + // are stricter than ordinary hook faults. They are handled by + // reclaim_stale_launcher_markers and never by this path. + continue; + } + if !valid_fault_filename(name_str) || name_str == FAULT_OVERFLOW_NAME { + continue; + } + let Ok((file, bytes)) = fault_dir.read_private_bounded(&name, MAX_FAULT_BYTES) else { + continue; + }; + let Ok(fault) = serde_json::from_slice::(&bytes) else { + continue; + }; + if fault.schema_version != HOOK_STATE_SCHEMA_VERSION + || fault.observed_at_ms == 0 + || fault + .integration + .as_ref() + .is_some_and(|integration| validate_identity(integration).is_err()) + || now_ms.saturating_sub(fault.observed_at_ms) <= TERMINAL_RETENTION_MS + { + continue; + } + let still_relevant = states.iter().any(|state| { + let integration_matches = fault.integration.as_ref().is_none_or(|integration| { + integration.installation_id == state.integration.installation_id + }); + let process_key = (state.process.pid, state.process.incarnation.clone()); + integration_matches + && state.created_at_ms <= fault.observed_at_ms + && !process_cache.get(&process_key).copied().unwrap_or(false) + }); + if !still_relevant + && try_lock_marker(&file)? + && fault_dir.remove_if_same_without_sync(&name, &file).is_ok() + { + removed += 1; + } + } + if removed != 0 { + fault_dir.sync()?; + } + Ok(()) +} + +fn system_time_ms(value: SystemTime) -> Option { + value + .duration_since(UNIX_EPOCH) + .ok()? + .as_millis() + .try_into() + .ok() +} + +fn encode_fault(fault: &HookIngestFault) -> io::Result> { + if !valid_ingest_fault(fault) { + return Err(invalid_data("invalid hook fault shape")); + } + let bytes = serde_json::to_vec(fault) + .map_err(|error| invalid_data(format!("cannot encode hook fault: {error}")))?; + if bytes.len() as u64 > MAX_FAULT_BYTES { + return Err(invalid_data("hook fault exceeds its storage bound")); + } + Ok(bytes) +} + +fn record_overflow_fault( + fault_dir: &Arc, + observed_at_ms: u64, + integration: Option, +) -> io::Result { + let old = read_fault_file(fault_dir, OsStr::new(FAULT_OVERFLOW_NAME)) + .ok() + .filter(valid_ingest_fault); + let commit_id = new_ingest_commit_id()?; + let fault = HookIngestFault { + schema_version: HOOK_STATE_SCHEMA_VERSION, + integration: match old.as_ref() { + // Once an overflow covers every integration, a later scoped + // failure must never narrow it. Doing so could make an older + // uncommitted launcher failure disappear for another generation. + Some(HookIngestFault { + integration: None, .. + }) => None, + Some(HookIngestFault { + integration: Some(old_identity), + .. + }) if integration.as_ref() == Some(old_identity) => Some(old_identity.clone()), + Some(_) => None, + None => integration, + }, + observed_at_ms: old + .map(|fault| fault.observed_at_ms) + .unwrap_or_default() + .max(observed_at_ms), + commit_id: commit_id.clone(), + }; + fault_dir.atomic_replace_with_listing_bound( + OsStr::new(FAULT_OVERFLOW_NAME), + &encode_fault(&fault)?, + MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES, + )?; + Ok(commit_id) +} + +fn is_root_boundary_event(event: &HookEvent) -> bool { + event.agent_id.is_none() + && matches!( + event.kind, + HookEventKind::SessionStart + | HookEventKind::UserPromptSubmit + | HookEventKind::PreCompact + | HookEventKind::PostCompact + | HookEventKind::Stop + | HookEventKind::SessionEnd + ) +} + +fn turn_matches(state: &HookSessionState, turn: Option<&str>) -> bool { + required_id(turn).is_some() && state.active_turn_id.as_deref() == turn +} + +fn clear_root_questions(state: &mut HookSessionState) { + let root_questions = state + .open_questions + .iter() + .filter(|tool_id| !state.question_agents.contains_key(*tool_id)) + .cloned() + .collect::>(); + for tool_id in root_questions { + state.open_questions.remove(&tool_id); + state.question_opened_at_ms.remove(&tool_id); + } + state.closed_questions.clear(); + state.completed_question_order.clear(); +} + +fn clear_agent_questions(state: &mut HookSessionState, agent: &str) { + let agent_questions = state + .question_agents + .iter() + .filter_map(|(tool_id, owner)| { + (owner == agent && state.open_questions.contains(tool_id)).then_some(tool_id.clone()) + }) + .collect::>(); + for tool_id in agent_questions { + state.question_agents.remove(&tool_id); + state.open_questions.remove(&tool_id); + state.question_opened_at_ms.remove(&tool_id); + } +} + +fn required_id(value: Option<&str>) -> Option<&str> { + value.filter(|value| !value.is_empty() && value.len() <= MAX_ID_BYTES) +} + +fn min_nonzero(current: u64, candidate: u64) -> u64 { + if current == 0 { + candidate + } else { + current.min(candidate) + } +} + +fn validate_event(event: &HookEvent) -> io::Result<()> { + validate_identity(&event.integration)?; + validate_id(&event.session_id, "session ID")?; + if event.cwd.is_empty() + || event.cwd.len() > MAX_CWD_BYTES + || !Path::new(&event.cwd).is_absolute() + { + return Err(invalid_data("invalid hook cwd")); + } + for (value, label) in [ + (event.turn_id.as_deref(), "turn ID"), + (event.tool_use_id.as_deref(), "tool-use ID"), + (event.agent_id.as_deref(), "agent ID"), + ] { + if let Some(value) = value { + validate_id(value, label)?; + } + } + if event.observed_at_ms == 0 + || event.process.pid == 0 + || event.process.started_at_ms == 0 + || event.process.started_at_ms > event.observed_at_ms.saturating_add(60_000) + { + return Err(invalid_data( + "hook event has no exact time/process identity", + )); + } + validate_id(&event.process.incarnation, "process incarnation")?; + if matches!( + event.kind, + HookEventKind::Stop | HookEventKind::SubagentStop + ) != event.stop_hook_active.is_some() + { + return Err(invalid_data("invalid stop_hook_active lifecycle shape")); + } + if !valid_ingest_commit_proof(&event.ingest_marker_id) { + return Err(invalid_data("invalid hook ingest commit proof")); + } + Ok(()) +} + +fn validate_identity(identity: &IntegrationIdentity) -> io::Result<()> { + validate_id(&identity.hook_schema_revision, "hook schema revision")?; + validate_digest(&identity.helper_digest)?; + validate_id(&identity.installation_id, "installation ID")?; + validate_digest(&identity.config_digest)?; + Ok(()) +} + +fn validate_state(state: &HookSessionState) -> io::Result<()> { + let invalid_optional_time = + |value: u64| value != 0 && (value < state.created_at_ms || value > state.updated_at_ms); + let expected_generation = state_key_parts( + &state.integration.installation_id, + &state.session_id, + state.process.pid, + &state.process.incarnation, + ); + if state.schema_version != HOOK_STATE_SCHEMA_VERSION + || state.generation_id.len() != 64 + || !state + .generation_id + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + || state.generation_id != expected_generation + || state.created_at_ms == 0 + || state.updated_at_ms < state.created_at_ms + || state.process.pid == 0 + || state.process.started_at_ms == 0 + || state.process.started_at_ms > state.created_at_ms.saturating_add(60_000) + || state.last_root_event.is_none() + || invalid_optional_time(state.last_root_boundary_at_ms) + || state.last_root_boundary_at_ms > state.updated_at_ms + || invalid_optional_time(state.ended_at_ms) + || (state.first_confirmed_gone_at_ms != 0 + && state.first_confirmed_gone_at_ms < state.updated_at_ms) + || invalid_optional_time(state.prompt_observed_at_ms) + || invalid_optional_time(state.stop_observed_at_ms) + || invalid_optional_time(state.permission_observed_at_ms) + || (state.stop_turn_id.is_some() != state.stop_hook_active.is_some()) + || state + .stop_turn_id + .as_ref() + .is_some_and(|turn| state.active_turn_id.as_ref() != Some(turn)) + || state.samples.len() > MAX_STATE_SAMPLES + || state.open_tools.len() > MAX_OPEN_ITEMS + || state.tool_opened_at_ms.len() > MAX_OPEN_ITEMS + || state.open_child_tools.len() > MAX_OPEN_ITEMS + || state.child_tool_opened_at_ms.len() > MAX_OPEN_ITEMS + || state.open_subagents.len() > MAX_OPEN_ITEMS + || state.subagent_opened_at_ms.len() > MAX_OPEN_ITEMS + || state.provisional_stopped_subagents.len() > MAX_OPEN_ITEMS + || state.subagent_stopped_at_ms.len() > MAX_OPEN_ITEMS + || state.open_questions.len() > MAX_OPEN_ITEMS + || state.question_opened_at_ms.len() > MAX_OPEN_ITEMS + || state.question_agents.len() > MAX_OPEN_ITEMS + || state.child_permission_ambiguities.len() > MAX_OPEN_ITEMS + || state.child_permission_observed_at_ms.len() > MAX_OPEN_ITEMS + || state.closed_tools.len() > MAX_COMPLETED_ITEMS + || state.completed_tool_order.len() > MAX_COMPLETED_ITEMS + || state.closed_child_tools.len() > MAX_COMPLETED_ITEMS + || state.completed_child_tool_order.len() > MAX_COMPLETED_ITEMS + || state.closed_subagents.len() > MAX_COMPLETED_ITEMS + || state.completed_subagent_order.len() > MAX_COMPLETED_ITEMS + || state.closed_questions.len() > MAX_COMPLETED_ITEMS + || state.completed_question_order.len() > MAX_COMPLETED_ITEMS + || !persisted_completed_order_matches( + &state.completed_tool_order, + state.closed_tools.iter(), + ) + || !persisted_completed_order_matches( + &state.completed_child_tool_order, + state.closed_child_tools.keys(), + ) + || !persisted_completed_order_matches( + &state.completed_subagent_order, + state.closed_subagents.iter(), + ) + || !persisted_completed_order_matches( + &state.completed_question_order, + state.closed_questions.iter(), + ) + || (state.prompt_accepted != (state.prompt_observed_at_ms != 0)) + || (state.permission_ambiguity != (state.permission_observed_at_ms != 0)) + || (state.stop_turn_id.is_some() != (state.stop_observed_at_ms != 0)) + || state.samples.is_empty() + || !state.tool_opened_at_ms.keys().eq(state.open_tools.keys()) + || !state + .child_tool_opened_at_ms + .keys() + .eq(state.open_child_tools.keys()) + || !state + .subagent_opened_at_ms + .keys() + .eq(state.open_subagents.iter()) + || !state + .subagent_stopped_at_ms + .keys() + .eq(state.provisional_stopped_subagents.iter()) + || !state + .question_opened_at_ms + .keys() + .eq(state.open_questions.iter()) + || !state + .child_permission_observed_at_ms + .keys() + .eq(state.child_permission_ambiguities.iter()) + || state + .open_tools + .values() + .any(|class| *class != HookToolClass::Ordinary) + || state + .open_child_tools + .values() + .chain(state.closed_child_tools.values()) + .any(|agent| !state.open_subagents.contains(agent)) + || state + .question_agents + .keys() + .any(|tool_id| !state.open_questions.contains(tool_id)) + || state + .question_agents + .values() + .any(|agent| !state.open_subagents.contains(agent)) + || state + .child_permission_ambiguities + .iter() + .any(|agent| !state.open_subagents.contains(agent)) + || state + .provisional_stopped_subagents + .iter() + .any(|agent| !state.open_subagents.contains(agent)) + || state.open_tools.keys().any(|key| { + state.closed_tools.contains(key) + || state.open_questions.contains(key) + || state.closed_questions.contains(key) + }) + || state + .closed_tools + .iter() + .any(|key| state.open_questions.contains(key) || state.closed_questions.contains(key)) + || state.open_child_tools.keys().any(|key| { + state.closed_child_tools.contains_key(key) + || state.open_tools.contains_key(key) + || state.closed_tools.contains(key) + || state.open_questions.contains(key) + || state.closed_questions.contains(key) + }) + || state.closed_child_tools.keys().any(|key| { + state.open_tools.contains_key(key) + || state.closed_tools.contains(key) + || state.open_questions.contains(key) + || state.closed_questions.contains(key) + }) + || state + .open_subagents + .iter() + .any(|key| state.closed_subagents.contains(key)) + || state + .open_questions + .iter() + .any(|key| state.closed_questions.contains(key)) + || state + .tool_opened_at_ms + .values() + .chain(state.child_tool_opened_at_ms.values()) + .chain(state.subagent_opened_at_ms.values()) + .chain(state.subagent_stopped_at_ms.values()) + .chain(state.question_opened_at_ms.values()) + .chain(state.child_permission_observed_at_ms.values()) + .any(|at| *at < state.created_at_ms || *at > state.updated_at_ms) + || state.samples.iter().any(|sample| { + sample.observed_at_ms < state.created_at_ms + || sample.observed_at_ms > state.updated_at_ms + || sample.status != SessionStatus::Unknown + }) + || state + .samples + .windows(2) + .any(|samples| samples[0].observed_at_ms > samples[1].observed_at_ms) + { + return Err(invalid_data("invalid hook state shape")); + } + validate_identity(&state.integration)?; + validate_id(&state.session_id, "session ID")?; + validate_id(&state.process.incarnation, "process incarnation")?; + for (value, label) in [ + (state.stop_turn_id.as_deref(), "stop turn ID"), + (state.active_turn_id.as_deref(), "active turn ID"), + ] { + if let Some(value) = value { + validate_id(value, label)?; + } + } + if state.completed_ingests.is_empty() + || state.completed_ingests.len() > MAX_STATE_SAMPLES + || state + .completed_ingests + .iter() + .collect::>() + .len() + != state.completed_ingests.len() + || state + .completed_ingests + .iter() + .any(|proof| !valid_ingest_commit_proof(proof)) + { + return Err(invalid_data("invalid completed hook marker identity")); + } + if state.cwd.is_empty() + || state.cwd.len() > MAX_CWD_BYTES + || !Path::new(&state.cwd).is_absolute() + { + return Err(invalid_data("invalid hook state cwd")); + } + for key in state + .open_tools + .keys() + .chain(state.open_child_tools.keys()) + .chain(state.open_child_tools.values()) + .chain(state.closed_child_tools.keys()) + .chain(state.closed_child_tools.values()) + .chain(state.open_subagents.iter()) + .chain(state.provisional_stopped_subagents.iter()) + .chain(state.open_questions.iter()) + .chain(state.closed_tools.iter()) + .chain(state.closed_subagents.iter()) + .chain(state.closed_questions.iter()) + .chain(state.question_agents.values()) + .chain(state.child_permission_ambiguities.iter()) + { + validate_id(key, "open lifecycle ID")?; + } + Ok(()) +} + +fn validate_id(value: &str, label: &str) -> io::Result<()> { + if value.is_empty() || value.len() > MAX_ID_BYTES || value.chars().any(|ch| ch.is_control()) { + Err(invalid_data(format!("invalid {label}"))) + } else { + Ok(()) + } +} + +fn validate_digest(value: &str) -> io::Result<()> { + if value.len() == 71 + && value.starts_with("sha256:") + && value[7..].bytes().all(|byte| byte.is_ascii_hexdigit()) + { + Ok(()) + } else { + Err(invalid_data("invalid SHA-256 identity")) + } +} + +fn state_key(event: &HookEvent) -> String { + state_key_parts( + &event.integration.installation_id, + &event.session_id, + event.process.pid, + &event.process.incarnation, + ) +} + +fn state_key_parts(installation_id: &str, session_id: &str, pid: u32, incarnation: &str) -> String { + let mut hash = Sha256::new(); + for value in [ + installation_id.as_bytes(), + session_id.as_bytes(), + pid.to_string().as_bytes(), + incarnation.as_bytes(), + ] { + hash.update(value); + hash.update([0]); + } + hex(&hash.finalize()) +} + +fn valid_state_filename(name: &str) -> bool { + name.len() == "state-.json".len() + 64 + && name.starts_with("state-") + && name.ends_with(".json") + && name[6..name.len() - 5] + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) +} + +fn valid_fault_filename(name: &str) -> bool { + name == FAULT_OVERFLOW_NAME + || (name.len() == FAULT_PREFIX.len() + 32 + ".json".len() + && name.starts_with(FAULT_PREFIX) + && name.ends_with(".json") + && name[FAULT_PREFIX.len()..name.len() - ".json".len()] + .bytes() + .all(|byte| byte.is_ascii_hexdigit())) +} + +fn valid_launcher_fault_filename(name: &str) -> bool { + if fixed_launcher_slot(name).is_some() { + return true; + } + let Some(body) = name.strip_prefix(LAUNCH_FAULT_PREFIX) else { + return false; + }; + let Some((pid, nonce)) = body.split_once(LAUNCH_UNIQUE_SEPARATOR) else { + return false; + }; + let Ok(parsed_pid) = pid.parse::() else { + return false; + }; + parsed_pid != 0 + && parsed_pid.to_string() == pid + && nonce.len() == LAUNCH_UNIQUE_NONCE_LEN + && nonce.bytes().all(|byte| byte.is_ascii_alphanumeric()) +} + +fn fixed_launcher_slot(name: &str) -> Option { + let body = name + .strip_prefix(LAUNCH_FAULT_PREFIX)? + .strip_suffix(LAUNCH_FAULT_SUFFIX)?; + let (slot_text, nonce) = body.split_once('-')?; + if nonce != LAUNCH_FAULT_SLOT_NONCE { + return None; + } + let slot = slot_text.parse::().ok()?; + (slot < LAUNCH_FAULT_SLOT_COUNT && slot.to_string() == slot_text).then_some(slot) +} + +fn valid_temporary_filename(name: &str) -> bool { + name.len() == ".tmp-".len() + 32 + && name.starts_with(".tmp-") + && name[".tmp-".len()..] + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) +} + +fn validate_plugin_data_path(path: &Path) -> io::Result<()> { + if !path.is_absolute() + || path.file_name().and_then(|name| name.to_str()) != Some("abtop-abtop-local") + || path + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + != Some("data") + || path + .parent() + .and_then(Path::parent) + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + != Some("plugins") + { + return Err(invalid_data("invalid abtop Codex plugin-data root")); + } + Ok(()) +} + +fn read_fault_file(dir: &SecureDirectory, name: &OsStr) -> io::Result { + let (_, bytes) = dir.read_private_bounded(name, MAX_FAULT_BYTES)?; + serde_json::from_slice(&bytes) + .map_err(|error| invalid_data(format!("invalid hook fault JSON: {error}"))) +} + +fn read_state_file(dir: &SecureDirectory, name: &OsStr) -> io::Result> { + Ok(read_state_file_with_file(dir, name)?.map(|(state, _)| state)) +} + +fn read_state_file_with_file( + dir: &SecureDirectory, + name: &OsStr, +) -> io::Result> { + let (file, bytes) = match dir.read_private_bounded(name, MAX_STATE_BYTES) { + Ok(result) => result, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + let state = serde_json::from_slice(&bytes) + .map_err(|error| invalid_data(format!("invalid hook state JSON: {error}")))?; + Ok(Some((state, file))) +} + +fn write_state_file( + dir: &SecureDirectory, + name: &OsStr, + state: &HookSessionState, +) -> io::Result<()> { + let bytes = encode_state(state)?; + dir.atomic_replace(name, &bytes) +} + +fn encode_state(state: &HookSessionState) -> io::Result> { + let bytes = serde_json::to_vec(state) + .map_err(|error| invalid_data(format!("cannot encode hook state: {error}")))?; + if bytes.len() as u64 > MAX_STATE_BYTES { + return Err(invalid_data("hook state exceeds its storage bound")); + } + Ok(bytes) +} + +struct SecureDirectory { + path: PathBuf, + #[cfg(unix)] + file: File, +} + +impl SecureDirectory { + fn open_path(path: &Path, allow_fix_mode: bool) -> io::Result { + #[cfg(unix)] + { + use std::os::fd::FromRawFd; + use std::os::unix::ffi::OsStrExt; + let mut components = path.components(); + if components.next() != Some(std::path::Component::RootDir) { + return Err(invalid_data("private directory path is not absolute")); + } + let root = CString::new("/").expect("root has no NUL"); + // SAFETY: root is a valid NUL-terminated path and the returned fd + // is immediately owned by File. + let root_fd = unsafe { + libc::open( + root.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if root_fd < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: open returned a new owned descriptor. + let mut file = unsafe { File::from_raw_fd(root_fd) }; + for component in components { + let std::path::Component::Normal(name) = component else { + return Err(invalid_data("unsafe private directory component")); + }; + let name = CString::new(name.as_bytes()) + .map_err(|_| invalid_data("private directory component contains NUL"))?; + file = openat_directory(&file, &name)?; + } + validate_private_directory_file(path, &file, allow_fix_mode)?; + Ok(Self { + path: path.to_path_buf(), + file, + }) + } + #[cfg(not(unix))] + { + validate_private_directory(path, allow_fix_mode)?; + Ok(Self { + path: path.to_path_buf(), + }) + } + } + + fn open_or_create_child(&self, name: &str) -> io::Result { + validate_component_name(OsStr::new(name))?; + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + let name_c = os_cstring(OsStr::new(name))?; + // SAFETY: descriptor/name are valid; mkdirat is contained by the + // retained parent descriptor. + let result = unsafe { libc::mkdirat(self.file.as_raw_fd(), name_c.as_ptr(), 0o700) }; + if result != 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::AlreadyExists { + return Err(error); + } + } + let file = openat_directory(&self.file, &name_c)?; + let path = self.path.join(name); + validate_private_directory_file(&path, &file, false)?; + self.sync()?; + Ok(Self { path, file }) + } + #[cfg(not(unix))] + { + let path = self.path.join(name); + ensure_private_directory(&path)?; + Ok(Self { path }) + } + } + + fn open_existing_child(&self, name: &str) -> io::Result { + validate_component_name(OsStr::new(name))?; + #[cfg(unix)] + { + let name_c = os_cstring(OsStr::new(name))?; + let file = openat_directory(&self.file, &name_c)?; + let path = self.path.join(name); + validate_private_directory_file(&path, &file, false)?; + Ok(Self { path, file }) + } + #[cfg(not(unix))] + { + let path = self.path.join(name); + validate_private_directory(&path, false)?; + Ok(Self { path }) + } + } + + fn list_names(&self, maximum: usize) -> io::Result<(Vec, bool)> { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + use std::os::unix::ffi::OsStringExt; + let dot = CString::new(".").expect("dot has no NUL"); + // SAFETY: openat on `.` creates a new open file description with + // an independent directory cursor; fdopendir consumes it below. + let listing_fd = unsafe { + libc::openat( + self.file.as_raw_fd(), + dot.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if listing_fd < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: listing_fd is a valid directory descriptor. + let directory = unsafe { libc::fdopendir(listing_fd) }; + if directory.is_null() { + // SAFETY: fdopendir failed and did not consume the descriptor. + unsafe { libc::close(listing_fd) }; + return Err(io::Error::last_os_error()); + } + let mut names = Vec::new(); + let mut truncated = false; + loop { + clear_errno(); + // SAFETY: directory remains valid until closedir. + let entry = unsafe { libc::readdir(directory) }; + if entry.is_null() { + let error = io::Error::last_os_error(); + // SAFETY: directory is live and uniquely owned here. + unsafe { libc::closedir(directory) }; + if error.raw_os_error().unwrap_or(0) != 0 { + return Err(error); + } + break; + } + // SAFETY: readdir returns a live dirent with a NUL-terminated + // d_name for the duration of this iteration. + let bytes = unsafe { + std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) + .to_bytes() + .to_vec() + }; + if bytes == b"." || bytes == b".." { + continue; + } + if names.len() >= maximum { + truncated = true; + // The caller needs only bounded evidence plus the fact + // that more entries exist. Do not walk an arbitrarily + // large launcher-created directory synchronously. + // SAFETY: directory is live and uniquely owned here. + unsafe { libc::closedir(directory) }; + break; + } + names.push(OsString::from_vec(bytes)); + } + Ok((names, truncated)) + } + #[cfg(not(unix))] + { + let mut names = Vec::new(); + let mut truncated = false; + for entry in fs::read_dir(&self.path)? { + let entry = entry?; + if names.len() >= maximum { + truncated = true; + break; + } else { + names.push(entry.file_name()); + } + } + Ok((names, truncated)) + } + } + + fn read_private_bounded(&self, name: &OsStr, maximum: u64) -> io::Result<(File, Vec)> { + let file = self.open_private_read(name)?; + let metadata = file.metadata()?; + validate_private_regular_metadata(&self.path.join(name), &metadata)?; + if metadata.len() > maximum { + return Err(invalid_data("oversized private hook file")); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + (&file).take(maximum + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > maximum { + return Err(invalid_data("oversized private hook file")); + } + Ok((file, bytes)) + } + + fn open_private_read(&self, name: &OsStr) -> io::Result { + validate_component_name(name)?; + #[cfg(unix)] + { + openat_regular(&self.file, name, libc::O_RDONLY, 0) + } + #[cfg(not(unix))] + { + let path = self.path.join(name); + let file = OpenOptions::new().read(true).open(&path)?; + validate_private_regular_metadata(&path, &file.metadata()?)?; + Ok(file) + } + } + + fn open_private_rw_existing(&self, name: &OsStr) -> io::Result { + validate_component_name(name)?; + #[cfg(unix)] + { + openat_regular(&self.file, name, libc::O_RDWR, 0) + } + #[cfg(not(unix))] + { + let path = self.path.join(name); + let file = OpenOptions::new().read(true).write(true).open(&path)?; + validate_private_regular_metadata(&path, &file.metadata()?)?; + Ok(file) + } + } + + fn create_private_new(&self, name: &OsStr, bytes: &[u8]) -> io::Result { + validate_component_name(name)?; + if bytes.len() as u64 > MAX_STATE_BYTES { + return Err(invalid_data("private hook file exceeds storage bound")); + } + #[cfg(unix)] + let mut file = openat_regular( + &self.file, + name, + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL, + 0o600, + )?; + #[cfg(not(unix))] + let mut file = { + let path = self.path.join(name); + let file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&path)?; + validate_private_regular_metadata(&path, &file.metadata()?)?; + file + }; + file.write_all(bytes)?; + file.sync_all()?; + self.sync()?; + Ok(file) + } + + fn atomic_replace(&self, name: &OsStr, bytes: &[u8]) -> io::Result<()> { + self.atomic_replace_with_listing_bound(name, bytes, MAX_DIRECTORY_ENTRIES) + } + + fn atomic_replace_with_listing_bound( + &self, + name: &OsStr, + bytes: &[u8], + listing_bound: usize, + ) -> io::Result<()> { + validate_component_name(name)?; + if bytes.len() as u64 > MAX_STATE_BYTES { + return Err(invalid_data("private hook file exceeds storage bound")); + } + match self.open_private_read(name) { + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let (entries, truncated) = self.list_names(listing_bound)?; + let temporary_count = entries + .iter() + .filter(|entry| entry.to_str().is_some_and(valid_temporary_filename)) + .count(); + if truncated || temporary_count >= MAX_TEMP_FILES { + return Err(invalid_data( + "private hook temporary-file capacity exhausted", + )); + } + let mut random = [0_u8; 16]; + getrandom::fill(&mut random).map_err(io::Error::other)?; + let temporary_name = OsString::from(format!(".tmp-{}", hex(&random))); + let temporary = self.create_private_new(&temporary_name, bytes)?; + drop(temporary); + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + let old = os_cstring(&temporary_name)?; + let new = os_cstring(name)?; + // SAFETY: both names are validated single components and both + // descriptors are the same retained private directory. + let result = unsafe { + libc::renameat( + self.file.as_raw_fd(), + old.as_ptr(), + self.file.as_raw_fd(), + new.as_ptr(), + ) + }; + if result != 0 { + let error = io::Error::last_os_error(); + let _ = self.remove(&temporary_name); + return Err(error); + } + } + #[cfg(not(unix))] + { + let temporary_path = self.path.join(&temporary_name); + if let Err(error) = fs::rename(&temporary_path, self.path.join(name)) { + let _ = fs::remove_file(temporary_path); + return Err(error); + } + } + self.sync() + } + + fn atomic_replace_if_same_with_temporary( + &self, + name: &OsStr, + expected: &File, + temporary_name: &OsStr, + bytes: &[u8], + ) -> io::Result<()> { + validate_component_name(name)?; + validate_component_name(temporary_name)?; + if !temporary_name + .to_str() + .is_some_and(valid_temporary_filename) + { + return Err(invalid_data("invalid private hook temporary filename")); + } + if bytes.len() as u64 > MAX_STATE_BYTES { + return Err(invalid_data("private hook file exceeds storage bound")); + } + let current = self.open_private_read(name)?; + if !same_file(¤t.metadata()?, &expected.metadata()?) { + return Err(invalid_data("private hook file changed before replacement")); + } + drop(current); + + let temporary = self.create_private_new(temporary_name, bytes)?; + drop(temporary); + let current = match self.open_private_read(name) { + Ok(current) => current, + Err(error) => { + let _ = self.remove(temporary_name); + return Err(error); + } + }; + if !same_file(¤t.metadata()?, &expected.metadata()?) { + drop(current); + let _ = self.remove(temporary_name); + return Err(invalid_data("private hook file changed before replacement")); + } + drop(current); + + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + let old = os_cstring(temporary_name)?; + let new = os_cstring(name)?; + // SAFETY: both names are validated single components and both + // descriptors are the same retained private directory. The exact + // destination inode was revalidated immediately above. + let result = unsafe { + libc::renameat( + self.file.as_raw_fd(), + old.as_ptr(), + self.file.as_raw_fd(), + new.as_ptr(), + ) + }; + if result != 0 { + let error = io::Error::last_os_error(); + let _ = self.remove(temporary_name); + return Err(error); + } + } + #[cfg(not(unix))] + { + let temporary_path = self.path.join(temporary_name); + if let Err(error) = fs::rename(&temporary_path, self.path.join(name)) { + let _ = fs::remove_file(temporary_path); + return Err(error); + } + } + self.sync() + } + + fn remove_if_same(&self, name: &OsStr, expected: &File) -> io::Result<()> { + self.remove_if_same_without_sync(name, expected)?; + self.sync() + } + + fn remove_if_same_without_sync(&self, name: &OsStr, expected: &File) -> io::Result<()> { + let current = self.open_private_read(name)?; + if !same_file(¤t.metadata()?, &expected.metadata()?) { + return Err(invalid_data("private hook file changed before removal")); + } + drop(current); + self.remove_without_sync(name) + } + + fn remove(&self, name: &OsStr) -> io::Result<()> { + self.remove_without_sync(name)?; + self.sync() + } + + fn remove_without_sync(&self, name: &OsStr) -> io::Result<()> { + validate_component_name(name)?; + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + let name = os_cstring(name)?; + // SAFETY: name is a validated component and dirfd is retained. + let result = unsafe { libc::unlinkat(self.file.as_raw_fd(), name.as_ptr(), 0) }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + } + #[cfg(not(unix))] + fs::remove_file(self.path.join(name))?; + Ok(()) + } + + fn sync(&self) -> io::Result<()> { + #[cfg(unix)] + self.file.sync_all()?; + #[cfg(not(unix))] + File::open(&self.path)?.sync_all()?; + Ok(()) + } +} + +#[cfg(unix)] +fn openat_directory(parent: &File, name: &CString) -> io::Result { + use std::os::fd::{AsRawFd, FromRawFd}; + // SAFETY: parent/name are valid and returned fd is immediately owned. + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + Err(io::Error::last_os_error()) + } else { + // SAFETY: openat returned a new owned descriptor. + Ok(unsafe { File::from_raw_fd(fd) }) + } +} + +#[cfg(unix)] +fn openat_regular(parent: &File, name: &OsStr, flags: libc::c_int, mode: u32) -> io::Result { + use std::os::fd::{AsRawFd, FromRawFd}; + let name = os_cstring(name)?; + // SAFETY: parent/name are valid and returned fd is immediately owned. + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + flags | libc::O_CLOEXEC | libc::O_NOFOLLOW, + mode as libc::c_uint, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: openat returned a new owned descriptor. + let file = unsafe { File::from_raw_fd(fd) }; + validate_private_regular_metadata( + Path::new(name.to_str().unwrap_or("")), + &file.metadata()?, + )?; + Ok(file) +} + +#[cfg(unix)] +fn os_cstring(name: &OsStr) -> io::Result { + use std::os::unix::ffi::OsStrExt; + CString::new(name.as_bytes()).map_err(|_| invalid_data("private filename contains NUL")) +} + +fn validate_component_name(name: &OsStr) -> io::Result<()> { + if name.is_empty() || name == OsStr::new(".") || name == OsStr::new("..") { + return Err(invalid_data("invalid private filename")); + } + let path = Path::new(name); + if path.components().count() != 1 + || !matches!( + path.components().next(), + Some(std::path::Component::Normal(_)) + ) + { + return Err(invalid_data("private filename is not one component")); + } + Ok(()) +} + +#[cfg(unix)] +fn validate_private_directory_file( + path: &Path, + file: &File, + allow_fix_mode: bool, +) -> io::Result<()> { + use std::os::fd::AsRawFd; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let mut metadata = file.metadata()?; + if !metadata.is_dir() || metadata.uid() != unsafe { libc::geteuid() } { + return Err(invalid_data(format!("unsafe directory {}", path.display()))); + } + if metadata.permissions().mode() & 0o077 != 0 { + if !allow_fix_mode { + return Err(invalid_data(format!( + "directory {} is not private", + path.display() + ))); + } + // SAFETY: descriptor is retained and mode contains only permission bits. + if unsafe { libc::fchmod(file.as_raw_fd(), 0o700) } != 0 { + return Err(io::Error::last_os_error()); + } + metadata = file.metadata()?; + if metadata.permissions().mode() & 0o077 != 0 { + return Err(invalid_data(format!( + "directory {} is not private", + path.display() + ))); + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn validate_private_directory(path: &Path, _allow_fix_mode: bool) -> io::Result<()> { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(invalid_data(format!("unsafe directory {}", path.display()))); + } + Ok(()) +} + +#[cfg(not(unix))] +fn ensure_private_directory(path: &Path) -> io::Result<()> { + match fs::symlink_metadata(path) { + Ok(_) => validate_private_directory(path, false), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir(path)?; + validate_private_directory(path, false) + } + Err(error) => Err(error), + } +} + +fn validate_private_regular_metadata(path: &Path, metadata: &fs::Metadata) -> io::Result<()> { + if !metadata.is_file() { + return Err(invalid_data(format!( + "unsafe state file {}", + path.display() + ))); + } + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + // SAFETY: geteuid has no preconditions or side effects. + let expected = unsafe { libc::geteuid() }; + if metadata.uid() != expected + || metadata.nlink() != 1 + || metadata.permissions().mode() & 0o077 != 0 + { + return Err(invalid_data(format!( + "unsafe state file {}", + path.display() + ))); + } + } + Ok(()) +} + +fn same_file(left: &fs::Metadata, right: &fs::Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + left.dev() == right.dev() && left.ino() == right.ino() + } + #[cfg(not(unix))] + { + left.len() == right.len() && left.modified().ok() == right.modified().ok() + } +} + +#[cfg(unix)] +fn clear_errno() { + #[cfg(any(target_os = "linux", target_os = "android"))] + unsafe { + *libc::__errno_location() = 0; + } + #[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "dragonfly" + ))] + unsafe { + *libc::__error() = 0; + } +} + +struct StateLock { + #[cfg(unix)] + file: File, + #[cfg(not(unix))] + _file: File, + #[cfg(not(unix))] + path: PathBuf, +} + +impl StateLock { + /// Acquire the retained lock without creating it. + /// + /// Collector reads use this path so they can synchronize with an active + /// atomic replacement while remaining strictly read-only. Reopening the + /// name after locking verifies that the descriptor still names the + /// retained lock inode rather than an unlinked predecessor. + fn acquire_existing(directory: &SecureDirectory) -> io::Result> { + #[cfg(unix)] + { + let file = match directory.open_private_read(OsStr::new(".lock")) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + lock_shared_with_budget(&file)?; + let current = directory.open_private_read(OsStr::new(".lock"))?; + if !same_file(&file.metadata()?, ¤t.metadata()?) { + return Err(invalid_data("hook-state lock changed while acquiring it")); + } + Ok(Some(Self { file })) + } + #[cfg(not(unix))] + { + let _ = directory; + Ok(None) + } + } + + fn try_acquire(directory: &SecureDirectory) -> io::Result> { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + let file = match directory.open_private_rw_existing(OsStr::new(".lock")) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + match directory.create_private_new(OsStr::new(".lock"), &[]) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + directory.open_private_rw_existing(OsStr::new(".lock"))? + } + Err(error) => return Err(error), + } + } + Err(error) => return Err(error), + }; + // SAFETY: descriptor is live for the returned guard's lifetime. + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result == 0 { + return Ok(Some(Self { file })); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EWOULDBLOCK) + || error.raw_os_error() == Some(libc::EAGAIN) + { + return Ok(None); + } + Err(error) + } + #[cfg(not(unix))] + { + let path = directory.path.join(".lock"); + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => Ok(Some(Self { _file: file, path })), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(None), + Err(error) => Err(error), + } + } + } + + fn acquire(directory: &SecureDirectory) -> io::Result { + #[cfg(unix)] + { + let file = match directory.open_private_rw_existing(OsStr::new(".lock")) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + match directory.create_private_new(OsStr::new(".lock"), &[]) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + directory.open_private_rw_existing(OsStr::new(".lock"))? + } + Err(error) => return Err(error), + } + } + Err(error) => return Err(error), + }; + lock_with_budget(&file)?; + Ok(Self { file }) + } + #[cfg(not(unix))] + { + let path = directory.path.join(".lock"); + for _ in 0..160 { + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => return Ok(Self { _file: file, path }), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + thread::sleep(Duration::from_millis(5)); + } + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::WouldBlock, + "hook-state lock is busy", + )) + } + } +} + +#[cfg(unix)] +fn lock_with_budget(file: &File) -> io::Result<()> { + lock_with_budget_operation(file, libc::LOCK_EX) +} + +#[cfg(unix)] +fn lock_shared_with_budget(file: &File) -> io::Result<()> { + lock_with_budget_operation(file, libc::LOCK_SH) +} + +#[cfg(unix)] +fn lock_with_budget_operation(file: &File, operation: libc::c_int) -> io::Result<()> { + use std::os::fd::AsRawFd; + for _ in 0..160 { + // SAFETY: descriptor is live for the duration of the caller's guard. + let result = unsafe { libc::flock(file.as_raw_fd(), operation | libc::LOCK_NB) }; + if result == 0 { + return Ok(()); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::EWOULDBLOCK) + && error.raw_os_error() != Some(libc::EAGAIN) + { + return Err(error); + } + thread::sleep(Duration::from_millis(5)); + } + Err(io::Error::new( + io::ErrorKind::WouldBlock, + "hook-state lock is busy", + )) +} + +fn lock_marker(file: &File) -> io::Result<()> { + #[cfg(unix)] + lock_with_budget(file)?; + #[cfg(not(unix))] + let _ = file; + Ok(()) +} + +fn try_lock_marker(file: &File) -> io::Result { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // SAFETY: descriptor remains live through the guarded removal. The + // acquired lock is released automatically when that descriptor drops. + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result == 0 { + return Ok(true); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EWOULDBLOCK) + || error.raw_os_error() == Some(libc::EAGAIN) + { + return Ok(false); + } + Err(error) + } + #[cfg(not(unix))] + { + let _ = file; + Ok(false) + } +} + +impl Drop for StateLock { + fn drop(&mut self) { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // SAFETY: this guard owns the live descriptor. + let _ = unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) }; + } + #[cfg(not(unix))] + { + let _ = fs::remove_file(&self.path); + } + } +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +pub fn unix_now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn identity() -> IntegrationIdentity { + IntegrationIdentity { + hook_schema_revision: "1".into(), + helper_digest: format!("sha256:{}", "1".repeat(64)), + installation_id: "a".repeat(32), + config_digest: format!("sha256:{}", "2".repeat(64)), + complete_hook_set: true, + } + } + + fn process_identity() -> HookProcessIdentity { + HookProcessIdentity { + pid: std::process::id(), + started_at_ms: 1, + incarnation: process::get_process_incarnation(std::process::id()) + .unwrap_or_else(|| "test-incarnation".into()), + shared_host: false, + launch_config_ambiguous: false, + } + } + + fn test_commit_proof(marker_name: &str) -> String { + ingest_commit_proof(marker_name, &"c".repeat(INGEST_COMMIT_ID_LEN)).unwrap() + } + + #[cfg(unix)] + fn marker_name_from_proof(proof: &str) -> &str { + parse_ingest_commit_proof(proof).unwrap().0 + } + + fn event(kind: HookEventKind, at: u64) -> HookEvent { + HookEvent { + kind, + session_id: "session-a".into(), + cwd: "/tmp/project".into(), + turn_id: None, + tool_use_id: None, + tool_class: None, + agent_id: None, + session_start_source: (kind == HookEventKind::SessionStart) + .then_some(SessionStartSource::Startup), + stop_hook_active: matches!(kind, HookEventKind::Stop | HookEventKind::SubagentStop) + .then_some(false), + ingest_marker_id: test_commit_proof(&format!("{FAULT_PREFIX}{}.json", "0".repeat(32))), + observed_at_ms: at, + process: process_identity(), + integration: identity(), + } + } + + #[cfg(unix)] + fn private_plugin_data(temp: &tempfile::TempDir) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let root = fs::canonicalize(temp.path()).unwrap(); + let plugin_data = root.join("plugins/data/abtop-abtop-local"); + fs::create_dir_all(&plugin_data).unwrap(); + fs::set_permissions(&plugin_data, fs::Permissions::from_mode(0o700)).unwrap(); + plugin_data + } + + #[cfg(unix)] + fn set_modified_ms(path: &Path, at_ms: u64) { + let file = fs::OpenOptions::new().write(true).open(path).unwrap(); + file.set_modified(UNIX_EPOCH + Duration::from_millis(at_ms)) + .unwrap(); + } + + #[cfg(unix)] + fn gone_process_identity() -> HookProcessIdentity { + HookProcessIdentity { + pid: 2_000_000_000, + started_at_ms: 1, + incarnation: "gone-incarnation".into(), + shared_host: false, + launch_config_ambiguous: false, + } + } + + #[cfg(unix)] + fn launcher_marker_name(index: u64) -> OsString { + OsString::from(format!( + "launch-{}-pending.{:016x}", + 100_000_u64 + index, + index + )) + } + + #[cfg(unix)] + fn write_adopted_generic_launcher_fault( + fault_dir: &SecureDirectory, + index: u64, + observed_at_ms: u64, + ) -> OsString { + let name = launcher_marker_name(index); + let fault = HookIngestFault { + schema_version: HOOK_STATE_SCHEMA_VERSION, + integration: None, + observed_at_ms, + commit_id: format!("{:032x}", index + 1), + }; + fault_dir + .create_private_new(&name, &encode_fault(&fault).unwrap()) + .unwrap(); + name + } + + #[test] + fn lifecycle_projection_is_strict() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + assert_eq!( + state.projection(), + HookProjection::Unknown(StatusReason::HookEventGap) + ); + + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + state.apply(&prompt); + assert_eq!(state.projection(), HookProjection::TurnOpen); + assert_eq!(state.prompt_observed_at_ms, 20); + + let mut pre = event(HookEventKind::PreToolUse, 30); + pre.turn_id = Some("turn-a".into()); + pre.tool_use_id = Some("call-a".into()); + pre.tool_class = Some(HookToolClass::Ordinary); + state.apply(&pre); + assert!(matches!(state.projection(), HookProjection::ToolOpen(_))); + assert_eq!(state.tool_opened_at_ms.get("call-a"), Some(&30)); + + let mut permission = event(HookEventKind::PermissionRequest, 40); + permission.turn_id = Some("turn-a".into()); + state.apply(&permission); + assert_eq!(state.permission_observed_at_ms, 40); + assert_eq!( + state.projection(), + HookProjection::Unknown(StatusReason::HookInteractionResolutionUnavailable) + ); + } + + #[test] + fn missing_tool_close_is_a_sticky_gap() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + state.apply(&prompt); + let mut stop = event(HookEventKind::Stop, 30); + stop.turn_id = Some("turn-b".into()); + state.apply(&stop); + assert_eq!( + state.projection(), + HookProjection::Unknown(StatusReason::HookEventGap) + ); + } + + #[test] + fn exact_duplicate_tool_edges_are_idempotent() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + state.apply(&prompt); + + let mut pre = event(HookEventKind::PreToolUse, 30); + pre.turn_id = Some("turn-a".into()); + pre.tool_use_id = Some("call-a".into()); + pre.tool_class = Some(HookToolClass::Ordinary); + state.apply(&pre); + state.apply(&pre); + assert!(matches!(state.projection(), HookProjection::ToolOpen(_))); + + let mut post = event(HookEventKind::PostToolUse, 40); + post.turn_id = Some("turn-a".into()); + post.tool_use_id = Some("call-a".into()); + post.tool_class = Some(HookToolClass::Ordinary); + state.apply(&post); + state.apply(&post); + assert_eq!(state.projection(), HookProjection::TurnOpen); + } + + #[test] + fn same_turn_steer_preserves_open_work() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + state.apply(&prompt); + let mut pre = event(HookEventKind::PreToolUse, 30); + pre.turn_id = Some("turn-a".into()); + pre.tool_use_id = Some("call-a".into()); + pre.tool_class = Some(HookToolClass::Ordinary); + state.apply(&pre); + + let mut steer = event(HookEventKind::UserPromptSubmit, 40); + steer.turn_id = Some("turn-a".into()); + state.apply(&steer); + assert_eq!( + state.open_tools.keys().cloned().collect::>(), + BTreeSet::from(["call-a".to_string()]) + ); + assert_eq!(state.sticky_fault, None); + assert!(matches!(state.projection(), HookProjection::ToolOpen(_))); + } + + #[test] + fn stop_is_provisional_and_same_turn_work_reopens_it() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + state.apply(&prompt); + + let mut stop = event(HookEventKind::Stop, 30); + stop.turn_id = Some("turn-a".into()); + state.apply(&stop); + assert_eq!(state.active_turn_id.as_deref(), Some("turn-a")); + assert_eq!(state.projection(), HookProjection::TurnStopped); + + let mut pre = event(HookEventKind::PreToolUse, 40); + pre.turn_id = Some("turn-a".into()); + pre.tool_use_id = Some("call-a".into()); + pre.tool_class = Some(HookToolClass::Ordinary); + state.apply(&pre); + assert_eq!(state.stop_turn_id, None); + assert!(matches!(state.projection(), HookProjection::ToolOpen(_))); + + let mut post = event(HookEventKind::PostToolUse, 50); + post.turn_id = Some("turn-a".into()); + post.tool_use_id = Some("call-a".into()); + post.tool_class = Some(HookToolClass::Ordinary); + state.apply(&post); + let mut repeated_stop = event(HookEventKind::Stop, 60); + repeated_stop.turn_id = Some("turn-a".into()); + repeated_stop.stop_hook_active = Some(true); + state.apply(&repeated_stop); + state.apply(&repeated_stop); + assert_eq!(state.projection(), HookProjection::TurnStopped); + + let mut next_prompt = event(HookEventKind::UserPromptSubmit, 70); + next_prompt.turn_id = Some("turn-b".into()); + state.apply(&next_prompt); + assert_eq!(state.active_turn_id.as_deref(), Some("turn-b")); + assert_eq!(state.stop_turn_id, None); + assert_eq!(state.sticky_fault, None); + assert_eq!(state.projection(), HookProjection::TurnOpen); + } + + #[test] + fn distinct_tool_edges_can_arrive_in_reverse_timestamp_order() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + state.apply(&prompt); + + let mut later_pre = event(HookEventKind::PreToolUse, 40); + later_pre.turn_id = Some("turn-a".into()); + later_pre.tool_use_id = Some("call-b".into()); + later_pre.tool_class = Some(HookToolClass::Ordinary); + state.apply(&later_pre); + let mut earlier_pre = event(HookEventKind::PreToolUse, 30); + earlier_pre.turn_id = Some("turn-a".into()); + earlier_pre.tool_use_id = Some("call-a".into()); + earlier_pre.tool_class = Some(HookToolClass::Ordinary); + state.apply(&earlier_pre); + + let mut later_post = event(HookEventKind::PostToolUse, 60); + later_post.turn_id = Some("turn-a".into()); + later_post.tool_use_id = Some("call-b".into()); + later_post.tool_class = Some(HookToolClass::Ordinary); + state.apply(&later_post); + let mut earlier_post = event(HookEventKind::PostToolUse, 50); + earlier_post.turn_id = Some("turn-a".into()); + earlier_post.tool_use_id = Some("call-a".into()); + earlier_post.tool_class = Some(HookToolClass::Ordinary); + state.apply(&earlier_post); + + assert_eq!(state.sticky_fault, None); + assert_eq!(state.updated_at_ms, 60); + assert_eq!(state.projection(), HookProjection::TurnOpen); + } + + #[test] + fn one_tool_cannot_close_before_its_own_open_edge() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + state.apply(&prompt); + + let mut pre = event(HookEventKind::PreToolUse, 40); + pre.turn_id = Some("turn-a".into()); + pre.tool_use_id = Some("call-a".into()); + pre.tool_class = Some(HookToolClass::Ordinary); + state.apply(&pre); + let mut post = event(HookEventKind::PostToolUse, 30); + post.turn_id = Some("turn-a".into()); + post.tool_use_id = Some("call-a".into()); + post.tool_class = Some(HookToolClass::Ordinary); + state.apply(&post); + + assert_eq!(state.sticky_fault, Some(StatusReason::HookEventGap)); + assert_eq!( + state.projection(), + HookProjection::Unknown(StatusReason::HookEventGap) + ); + } + + #[test] + fn persisted_state_is_bound_to_its_generation_and_time_envelope() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + assert!(validate_state(&state).is_ok()); + + let mut wrong_generation = state.clone(); + wrong_generation.process.pid = wrong_generation.process.pid.saturating_add(1); + assert!(validate_state(&wrong_generation).is_err()); + + let mut future_edge = state; + future_edge.last_root_boundary_at_ms = future_edge.updated_at_ms.saturating_add(1); + assert!(validate_state(&future_edge).is_err()); + } + + #[test] + fn persisted_turn_and_child_tool_ids_use_the_full_id_contract() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + state.apply(&prompt); + + let mut invalid_active = state.clone(); + invalid_active.active_turn_id = Some("turn\ncontrol".into()); + let error = validate_state(&invalid_active).unwrap_err(); + assert!(error.to_string().contains("active turn ID")); + + let oversized = "x".repeat(MAX_ID_BYTES + 1); + let mut invalid_stop = state.clone(); + invalid_stop.active_turn_id = Some(oversized.clone()); + invalid_stop.stop_turn_id = Some(oversized); + invalid_stop.stop_hook_active = Some(false); + invalid_stop.stop_observed_at_ms = 20; + let error = validate_state(&invalid_stop).unwrap_err(); + assert!(error.to_string().contains("stop turn ID")); + + let mut invalid_child = state; + invalid_child.open_subagents.insert("child-a".into()); + invalid_child + .subagent_opened_at_ms + .insert("child-a".into(), 20); + invalid_child + .open_child_tools + .insert("call-a".into(), "child\ncontrol".into()); + invalid_child + .child_tool_opened_at_ms + .insert("call-a".into(), 20); + assert!(validate_state(&invalid_child).is_err()); + } + + #[test] + fn permission_remains_unknown_until_the_root_turn_ends() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + state.apply(&prompt); + let mut permission = event(HookEventKind::PermissionRequest, 30); + permission.turn_id = Some("turn-a".into()); + state.apply(&permission); + assert_eq!( + state.projection(), + HookProjection::Unknown(StatusReason::HookInteractionResolutionUnavailable) + ); + + let mut pre = event(HookEventKind::PreToolUse, 40); + pre.turn_id = Some("turn-a".into()); + pre.tool_use_id = Some("call-a".into()); + pre.tool_class = Some(HookToolClass::Ordinary); + state.apply(&pre); + assert_eq!( + state.projection(), + HookProjection::Unknown(StatusReason::HookInteractionResolutionUnavailable) + ); + + let mut post = event(HookEventKind::PostToolUse, 50); + post.turn_id = Some("turn-a".into()); + post.tool_use_id = Some("call-a".into()); + post.tool_class = Some(HookToolClass::Ordinary); + state.apply(&post); + let mut stop = event(HookEventKind::Stop, 60); + stop.turn_id = Some("turn-a".into()); + state.apply(&stop); + assert_eq!(state.projection(), HookProjection::TurnStopped); + } + + #[test] + fn compact_session_start_is_not_idle_proof() { + let mut start = event(HookEventKind::SessionStart, 10); + start.session_start_source = Some(SessionStartSource::Compact); + let mut state = HookSessionState::new(&start); + state.apply(&start); + assert_eq!( + state.projection(), + HookProjection::Unknown(StatusReason::HookEventGap) + ); + + let start = event(HookEventKind::SessionStart, 20); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 30); + prompt.turn_id = Some("turn-a".into()); + state.apply(&prompt); + let mut pre_compact = event(HookEventKind::PreCompact, 40); + pre_compact.turn_id = Some("turn-a".into()); + state.apply(&pre_compact); + let mut post_compact = event(HookEventKind::PostCompact, 50); + post_compact.turn_id = Some("turn-a".into()); + state.apply(&post_compact); + let mut compact_start = event(HookEventKind::SessionStart, 60); + compact_start.session_start_source = Some(SessionStartSource::Compact); + state.apply(&compact_start); + assert_eq!(state.active_turn_id.as_deref(), Some("turn-a")); + assert_eq!(state.projection(), HookProjection::TurnOpen); + } + + #[test] + fn child_hooks_may_interleave_with_root_compact_boundary() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + state.apply(&prompt); + let mut pre = event(HookEventKind::PreCompact, 30); + pre.turn_id = Some("turn-a".into()); + state.apply(&pre); + let mut post = event(HookEventKind::PostCompact, 40); + post.turn_id = Some("turn-a".into()); + state.apply(&post); + + let mut child_start = event(HookEventKind::SubagentStart, 45); + child_start.agent_id = Some("child-a".into()); + child_start.turn_id = Some("child-turn".into()); + state.apply(&child_start); + let mut child_prompt = event(HookEventKind::UserPromptSubmit, 46); + child_prompt.agent_id = Some("child-a".into()); + child_prompt.turn_id = Some("child-turn".into()); + state.apply(&child_prompt); + + let mut compact_start = event(HookEventKind::SessionStart, 50); + compact_start.session_start_source = Some(SessionStartSource::Compact); + state.apply(&compact_start); + assert_eq!(state.sticky_fault, None); + assert_eq!(state.active_turn_id.as_deref(), Some("turn-a")); + assert_eq!( + state.projection(), + HookProjection::SubagentOpen { + active: BTreeSet::from(["child-a".to_string()]), + provisional: BTreeSet::new(), + root: HookRootProjection::TurnOpen, + } + ); + } + + #[test] + fn subagent_events_fold_into_the_shared_root_state() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut root_prompt = event(HookEventKind::UserPromptSubmit, 20); + root_prompt.turn_id = Some("turn-root".into()); + state.apply(&root_prompt); + + let mut child_start = event(HookEventKind::SubagentStart, 30); + child_start.agent_id = Some("child-a".into()); + child_start.turn_id = Some("turn-child".into()); + state.apply(&child_start); + assert_eq!( + state.projection(), + HookProjection::SubagentOpen { + active: BTreeSet::from(["child-a".to_string()]), + provisional: BTreeSet::new(), + root: HookRootProjection::TurnOpen, + } + ); + + let mut prompt = event(HookEventKind::UserPromptSubmit, 40); + prompt.agent_id = Some("child-a".into()); + prompt.turn_id = Some("turn-child".into()); + state.apply(&prompt); + assert_eq!( + state.projection(), + HookProjection::SubagentOpen { + active: BTreeSet::from(["child-a".to_string()]), + provisional: BTreeSet::new(), + root: HookRootProjection::TurnOpen, + } + ); + + let mut question = event(HookEventKind::PreToolUse, 50); + question.agent_id = Some("child-a".into()); + question.turn_id = Some("turn-child".into()); + question.tool_use_id = Some("question-a".into()); + question.tool_class = Some(HookToolClass::RequestUserInput); + state.apply(&question); + assert_eq!( + state.projection(), + HookProjection::Unknown(StatusReason::HookInteractionResolutionUnavailable) + ); + + let mut stop = event(HookEventKind::SubagentStop, 60); + stop.agent_id = Some("child-a".into()); + stop.turn_id = Some("turn-child".into()); + state.apply(&stop); + assert_eq!(state.subagent_opened_at_ms.get("child-a"), Some(&30)); + assert_eq!(state.subagent_stopped_at_ms.get("child-a"), Some(&60)); + assert_eq!( + state.projection(), + HookProjection::SubagentOpen { + active: BTreeSet::new(), + provisional: BTreeSet::from(["child-a".to_string()]), + root: HookRootProjection::TurnOpen, + } + ); + let mut repeated_stop = stop.clone(); + repeated_stop.observed_at_ms = 61; + repeated_stop.stop_hook_active = Some(true); + state.apply(&repeated_stop); + assert_eq!(state.sticky_fault, None); + + let mut continuation = event(HookEventKind::UserPromptSubmit, 70); + continuation.agent_id = Some("child-a".into()); + continuation.turn_id = Some("turn-child".into()); + state.apply(&continuation); + assert!(!state.subagent_stopped_at_ms.contains_key("child-a")); + assert_eq!( + state.projection(), + HookProjection::SubagentOpen { + active: BTreeSet::from(["child-a".to_string()]), + provisional: BTreeSet::new(), + root: HookRootProjection::TurnOpen, + } + ); + } + + #[test] + fn subagent_start_requires_an_active_root_prompt_and_child_turn() { + let start = event(HookEventKind::SessionStart, 10); + let mut without_prompt = HookSessionState::new(&start); + without_prompt.apply(&start); + let mut child = event(HookEventKind::SubagentStart, 20); + child.agent_id = Some("child-a".into()); + child.turn_id = Some("child-turn".into()); + without_prompt.apply(&child); + assert_eq!( + without_prompt.sticky_fault, + Some(StatusReason::HookEventGap) + ); + assert!(without_prompt.open_subagents.is_empty()); + + let mut missing_child_turn = HookSessionState::new(&start); + missing_child_turn.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("root-turn".into()); + missing_child_turn.apply(&prompt); + let mut child = event(HookEventKind::SubagentStart, 30); + child.agent_id = Some("child-a".into()); + missing_child_turn.apply(&child); + assert_eq!( + missing_child_turn.sticky_fault, + Some(StatusReason::HookEventGap) + ); + assert!(missing_child_turn.open_subagents.is_empty()); + + let mut after_stop = HookSessionState::new(&start); + after_stop.apply(&start); + after_stop.apply(&prompt); + let mut stop = event(HookEventKind::Stop, 30); + stop.turn_id = Some("root-turn".into()); + after_stop.apply(&stop); + let mut child = event(HookEventKind::SubagentStart, 40); + child.agent_id = Some("child-a".into()); + child.turn_id = Some("child-turn".into()); + after_stop.apply(&child); + assert_eq!(after_stop.sticky_fault, Some(StatusReason::HookEventGap)); + assert!(after_stop.open_subagents.is_empty()); + } + + #[test] + fn multiple_child_tools_are_exact_and_block_tool_free_execution() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("root-turn".into()); + state.apply(&prompt); + let mut child = event(HookEventKind::SubagentStart, 30); + child.agent_id = Some("child-a".into()); + child.turn_id = Some("child-turn".into()); + state.apply(&child); + + for (tool_id, at) in [("child-call-a", 40), ("child-call-b", 41)] { + let mut pre = event(HookEventKind::PreToolUse, at); + pre.agent_id = Some("child-a".into()); + pre.turn_id = Some("child-turn".into()); + pre.tool_use_id = Some(tool_id.into()); + pre.tool_class = Some(HookToolClass::Ordinary); + state.apply(&pre); + } + assert_eq!(state.open_child_tools.len(), 2); + assert_eq!( + state.projection(), + HookProjection::Unknown(StatusReason::HookToolOpen) + ); + assert!(validate_state(&state).is_ok()); + + let mut first_post = event(HookEventKind::PostToolUse, 50); + first_post.agent_id = Some("child-a".into()); + first_post.turn_id = Some("child-turn".into()); + first_post.tool_use_id = Some("child-call-a".into()); + first_post.tool_class = Some(HookToolClass::Ordinary); + state.apply(&first_post); + assert_eq!(state.open_child_tools.len(), 1); + assert_eq!( + state.projection(), + HookProjection::Unknown(StatusReason::HookToolOpen) + ); + + let mut second_post = first_post.clone(); + second_post.observed_at_ms = 51; + second_post.tool_use_id = Some("child-call-b".into()); + state.apply(&second_post); + assert!(state.open_child_tools.is_empty()); + assert_eq!(state.closed_child_tools.len(), 2); + assert_eq!( + state.projection(), + HookProjection::SubagentOpen { + active: BTreeSet::from(["child-a".to_string()]), + provisional: BTreeSet::new(), + root: HookRootProjection::TurnOpen, + } + ); + assert!(validate_state(&state).is_ok()); + } + + #[test] + fn child_stop_preserves_the_root_stop_fallback() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-root".into()); + state.apply(&prompt); + let mut child_start = event(HookEventKind::SubagentStart, 30); + child_start.agent_id = Some("child-a".into()); + child_start.turn_id = Some("turn-child".into()); + state.apply(&child_start); + let mut root_stop = event(HookEventKind::Stop, 40); + root_stop.turn_id = Some("turn-root".into()); + state.apply(&root_stop); + let mut child_stop = event(HookEventKind::SubagentStop, 50); + child_stop.agent_id = Some("child-a".into()); + child_stop.turn_id = Some("turn-child".into()); + state.apply(&child_stop); + + assert_eq!(state.stop_turn_id.as_deref(), Some("turn-root")); + assert_eq!( + state.projection(), + HookProjection::SubagentOpen { + active: BTreeSet::new(), + provisional: BTreeSet::from(["child-a".to_string()]), + root: HookRootProjection::TurnStopped, + } + ); + } + + #[test] + fn completed_root_tool_history_compacts_fifo_without_poisoning() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-root".into()); + state.apply(&prompt); + + let total = MAX_COMPLETED_ITEMS + 24; + for index in 0..total { + let tool_id = format!("root-call-{index:04}"); + let at = 30 + (index as u64 * 2); + let mut pre = event(HookEventKind::PreToolUse, at); + pre.turn_id = Some("turn-root".into()); + pre.tool_use_id = Some(tool_id.clone()); + pre.tool_class = Some(HookToolClass::Ordinary); + state.apply(&pre); + + let mut post = event(HookEventKind::PostToolUse, at + 1); + post.turn_id = Some("turn-root".into()); + post.tool_use_id = Some(tool_id); + post.tool_class = Some(HookToolClass::Ordinary); + state.apply(&post); + } + + assert_eq!(state.sticky_fault, None); + assert!(state.completed_history_truncated); + assert_eq!(state.closed_tools.len(), MAX_COMPLETED_ITEMS); + assert_eq!(state.completed_tool_order.len(), MAX_COMPLETED_ITEMS); + assert_eq!( + state.completed_tool_order.first().map(String::as_str), + Some("root-call-0024") + ); + assert_eq!( + state.completed_tool_order.last().map(String::as_str), + Some("root-call-0279") + ); + assert!(!state.closed_tools.contains("root-call-0000")); + assert_eq!(state.projection(), HookProjection::TurnOpen); + assert!(validate_state(&state).is_ok()); + let persisted = serde_json::to_vec(&state).unwrap(); + assert!(persisted.len() < MAX_STATE_BYTES as usize); + let restored: HookSessionState = serde_json::from_slice(&persisted).unwrap(); + assert_eq!(restored, state); + assert!(validate_state(&restored).is_ok()); + + let retained_order = state.completed_tool_order.clone(); + let mut duplicate_close = event(HookEventKind::PostToolUse, 1_000); + duplicate_close.turn_id = Some("turn-root".into()); + duplicate_close.tool_use_id = Some("root-call-0279".into()); + duplicate_close.tool_class = Some(HookToolClass::Ordinary); + state.apply(&duplicate_close); + assert_eq!(state.sticky_fault, None); + assert_eq!(state.completed_tool_order, retained_order); + + let mut evicted_replay = state.clone(); + let mut evicted_close = duplicate_close.clone(); + evicted_close.observed_at_ms = 1_001; + evicted_close.tool_use_id = Some("root-call-0000".into()); + evicted_replay.apply(&evicted_close); + assert_eq!( + evicted_replay.sticky_fault, + Some(StatusReason::HookEventGap) + ); + + let mut retained_replay = state; + let mut duplicate_open = event(HookEventKind::PreToolUse, 1_002); + duplicate_open.turn_id = Some("turn-root".into()); + duplicate_open.tool_use_id = Some("root-call-0279".into()); + duplicate_open.tool_class = Some(HookToolClass::Ordinary); + retained_replay.apply(&duplicate_open); + assert_eq!( + retained_replay.sticky_fault, + Some(StatusReason::HookEventGap) + ); + } + + #[test] + fn completed_child_tool_history_compacts_fifo_and_preserves_ownership() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-root".into()); + state.apply(&prompt); + let mut child = event(HookEventKind::SubagentStart, 30); + child.agent_id = Some("child-a".into()); + child.turn_id = Some("turn-child-a".into()); + state.apply(&child); + + let total = MAX_COMPLETED_ITEMS + 24; + for index in 0..total { + let tool_id = format!("child-call-{index:04}"); + let at = 40 + (index as u64 * 2); + let mut pre = event(HookEventKind::PreToolUse, at); + pre.agent_id = Some("child-a".into()); + pre.turn_id = Some("turn-child-a".into()); + pre.tool_use_id = Some(tool_id.clone()); + pre.tool_class = Some(HookToolClass::Ordinary); + state.apply(&pre); + + let mut post = event(HookEventKind::PostToolUse, at + 1); + post.agent_id = Some("child-a".into()); + post.turn_id = Some("turn-child-a".into()); + post.tool_use_id = Some(tool_id); + post.tool_class = Some(HookToolClass::Ordinary); + state.apply(&post); + } + + assert_eq!(state.sticky_fault, None); + assert!(state.completed_history_truncated); + assert_eq!(state.closed_child_tools.len(), MAX_COMPLETED_ITEMS); + assert_eq!( + state.completed_child_tool_order.first().map(String::as_str), + Some("child-call-0024") + ); + assert_eq!( + state + .closed_child_tools + .get("child-call-0279") + .map(String::as_str), + Some("child-a") + ); + assert!(validate_state(&state).is_ok()); + assert!(serde_json::to_vec(&state).unwrap().len() < MAX_STATE_BYTES as usize); + + let retained_order = state.completed_child_tool_order.clone(); + let mut duplicate_close = event(HookEventKind::PostToolUse, 1_000); + duplicate_close.agent_id = Some("child-a".into()); + duplicate_close.turn_id = Some("turn-child-a".into()); + duplicate_close.tool_use_id = Some("child-call-0279".into()); + duplicate_close.tool_class = Some(HookToolClass::Ordinary); + state.apply(&duplicate_close); + assert_eq!(state.sticky_fault, None); + assert_eq!(state.completed_child_tool_order, retained_order); + + let mut child_b = event(HookEventKind::SubagentStart, 1_001); + child_b.agent_id = Some("child-b".into()); + child_b.turn_id = Some("turn-child-b".into()); + state.apply(&child_b); + let mut wrong_owner = duplicate_close; + wrong_owner.observed_at_ms = 1_002; + wrong_owner.agent_id = Some("child-b".into()); + wrong_owner.turn_id = Some("turn-child-b".into()); + state.apply(&wrong_owner); + assert_eq!(state.sticky_fault, Some(StatusReason::HookEventGap)); + } + + #[test] + fn completed_subagent_and_question_histories_compact_fifo() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + + for index in 0..(MAX_COMPLETED_ITEMS + 8) { + let agent_id = format!("closed-child-{index:04}"); + state.closed_subagents.insert(agent_id.clone()); + state.completed_subagent_order.push(agent_id); + let question_id = format!("closed-question-{index:04}"); + state.closed_questions.insert(question_id.clone()); + state.completed_question_order.push(question_id); + } + state.compact_completed_history(); + + assert_eq!(state.sticky_fault, None); + assert!(state.completed_history_truncated); + assert_eq!(state.closed_subagents.len(), MAX_COMPLETED_ITEMS); + assert_eq!(state.closed_questions.len(), MAX_COMPLETED_ITEMS); + assert_eq!( + state.completed_subagent_order.first().map(String::as_str), + Some("closed-child-0008") + ); + assert_eq!( + state.completed_question_order.first().map(String::as_str), + Some("closed-question-0008") + ); + assert!(validate_state(&state).is_ok()); + } + + #[test] + fn legacy_completed_history_without_order_metadata_is_normalized_on_fold() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-root".into()); + state.apply(&prompt); + let mut pre = event(HookEventKind::PreToolUse, 30); + pre.turn_id = Some("turn-root".into()); + pre.tool_use_id = Some("root-call-a".into()); + pre.tool_class = Some(HookToolClass::Ordinary); + state.apply(&pre); + let mut post = event(HookEventKind::PostToolUse, 40); + post.turn_id = Some("turn-root".into()); + post.tool_use_id = Some("root-call-a".into()); + post.tool_class = Some(HookToolClass::Ordinary); + state.apply(&post); + + let mut value = serde_json::to_value(&state).unwrap(); + let object = value.as_object_mut().unwrap(); + for field in [ + "completed_tool_order", + "completed_child_tool_order", + "completed_subagent_order", + "completed_question_order", + "completed_history_truncated", + ] { + object.remove(field); + } + let mut legacy: HookSessionState = serde_json::from_value(value).unwrap(); + assert!(legacy.completed_tool_order.is_empty()); + assert!(validate_state(&legacy).is_ok()); + + let mut next_pre = event(HookEventKind::PreToolUse, 50); + next_pre.turn_id = Some("turn-root".into()); + next_pre.tool_use_id = Some("root-call-b".into()); + next_pre.tool_class = Some(HookToolClass::Ordinary); + legacy.apply(&next_pre); + let mut next_post = event(HookEventKind::PostToolUse, 60); + next_post.turn_id = Some("turn-root".into()); + next_post.tool_use_id = Some("root-call-b".into()); + next_post.tool_class = Some(HookToolClass::Ordinary); + legacy.apply(&next_post); + + assert_eq!( + legacy.completed_tool_order, + ["root-call-a".to_string(), "root-call-b".to_string()] + ); + assert_eq!(legacy.schema_version, HOOK_STATE_SCHEMA_VERSION); + assert!(validate_state(&legacy).is_ok()); + } + + #[test] + fn unresolved_tool_capacity_still_fails_closed() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-root".into()); + state.apply(&prompt); + + for index in 0..=MAX_OPEN_ITEMS { + let mut pre = event(HookEventKind::PreToolUse, 30 + index as u64); + pre.turn_id = Some("turn-root".into()); + pre.tool_use_id = Some(format!("open-call-{index:04}")); + pre.tool_class = Some(HookToolClass::Ordinary); + state.apply(&pre); + } + + assert_eq!(state.open_tools.len(), MAX_OPEN_ITEMS); + assert_eq!(state.sticky_fault, Some(StatusReason::HookStateMalformed)); + assert_eq!( + state.projection(), + HookProjection::Unknown(StatusReason::HookStateMalformed) + ); + assert!(validate_state(&state).is_ok()); + } + + #[test] + fn encoding_rejects_structurally_bounded_state_above_storage_limit() { + fn maximal_id(category: &str, index: usize) -> String { + let prefix = format!("{category}-{index:04}-"); + format!("{prefix}{}", "x".repeat(MAX_ID_BYTES - prefix.len())) + } + + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + let owner = maximal_id("owner", 0); + state.open_subagents.insert(owner.clone()); + state.subagent_opened_at_ms.insert(owner.clone(), 10); + + for index in 0..MAX_COMPLETED_ITEMS { + let root_tool = maximal_id("root", index); + state.closed_tools.insert(root_tool.clone()); + state.completed_tool_order.push(root_tool); + + let child_tool = maximal_id("child-tool", index); + state + .closed_child_tools + .insert(child_tool.clone(), owner.clone()); + state.completed_child_tool_order.push(child_tool); + + let subagent = maximal_id("closed-agent", index); + state.closed_subagents.insert(subagent.clone()); + state.completed_subagent_order.push(subagent); + + let question = maximal_id("question", index); + state.closed_questions.insert(question.clone()); + state.completed_question_order.push(question); + } + + assert!(serde_json::to_vec(&state).unwrap().len() > MAX_STATE_BYTES as usize); + assert!(validate_state(&state).is_ok()); + let error = encode_state(&state).unwrap_err(); + assert!(error.to_string().contains("storage bound")); + } + + #[test] + fn sample_history_is_bounded() { + let start = event(HookEventKind::SessionStart, 1); + let mut state = HookSessionState::new(&start); + state.apply(&start); + for at in 2..300 { + let mut prompt = event(HookEventKind::UserPromptSubmit, at); + prompt.turn_id = Some(format!("turn-{at}")); + state.apply(&prompt); + } + assert_eq!(state.samples.len(), MAX_STATE_SAMPLES); + } + + #[test] + fn state_filenames_are_strict() { + assert!(valid_state_filename(&format!( + "state-{}.json", + "a".repeat(64) + ))); + assert!(!valid_state_filename("state-../secret.json")); + assert!(!valid_state_filename(&format!( + "state-{}.jsonl", + "a".repeat(64) + ))); + assert!(valid_launcher_fault_filename( + "launch-12345-pending.AbCdEf0123456789" + )); + assert!(valid_launcher_fault_filename("launch-0-abtopv1.pending")); + assert!(!valid_launcher_fault_filename( + "launch-12345-AbCdEf0123456789.pending" + )); + assert!(!valid_launcher_fault_filename( + "launch-0-pending.AbCdEf0123456789" + )); + assert!(!valid_launcher_fault_filename( + "launch-012-pending.AbCdEf0123456789" + )); + let marker = format!("{FAULT_PREFIX}{}.json", "a".repeat(32)); + assert!(valid_ingest_commit_proof(&test_commit_proof(&marker))); + assert!(!valid_ingest_commit_proof(&marker)); + } + + #[cfg(unix)] + #[test] + fn store_round_trip_is_private_and_rejects_symlinks() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + let start = event(HookEventKind::SessionStart, 10); + let state = store.fold(start).unwrap(); + + let path = store.state_path(&state.generation_id); + let metadata = fs::symlink_metadata(&path).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + let scan = store.read_all(10).unwrap(); + assert_eq!(scan.rejected, 0); + assert_eq!(scan.states, vec![state]); + + fs::remove_file(&path).unwrap(); + let target = temp.path().join("outside.json"); + fs::write(&target, b"{}").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + symlink(&target, &path).unwrap(); + let scan = store.read_all(10).unwrap(); + assert_eq!(scan.rejected, 1); + assert!(scan.states.is_empty()); + } + + #[cfg(unix)] + #[test] + fn in_flight_atomic_state_temp_does_not_poison_a_read() { + use std::sync::mpsc::{self, RecvTimeoutError}; + + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + let state = store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let reader = ingress.bind(identity()).unwrap(); + + let write_lock = StateLock::acquire(&store.state_dir).unwrap(); + let temporary_name = OsStr::new(".tmp-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + drop( + store + .state_dir + .create_private_new(temporary_name, &encode_state(&state).unwrap()) + .unwrap(), + ); + + let (started_tx, started_rx) = mpsc::channel(); + let (result_tx, result_rx) = mpsc::channel(); + let reader_thread = thread::spawn(move || { + started_tx.send(()).unwrap(); + result_tx.send(reader.read_all(20)).unwrap(); + }); + started_rx.recv().unwrap(); + + // An unsynchronized reader returns the poisoned snapshot while the + // writer is paused here. A synchronized reader waits for the atomic + // replacement window to close and then takes a fresh snapshot. + let early = match result_rx.recv_timeout(Duration::from_millis(100)) { + Ok(result) => Some(result), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => panic!("state reader disconnected"), + }; + store.state_dir.remove(temporary_name).unwrap(); + drop(write_lock); + let scan = early + .unwrap_or_else(|| { + result_rx + .recv_timeout(Duration::from_secs(2)) + .expect("state reader remained blocked") + }) + .unwrap(); + reader_thread.join().unwrap(); + + assert_eq!(scan.rejected, 0); + assert_eq!(scan.states, vec![state]); + assert_eq!(scan.states[0].sticky_fault, None); + } + + #[cfg(unix)] + #[test] + fn combined_read_lock_blocks_state_writer_between_snapshot_halves() { + use std::cell::Cell; + + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + let state = store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let state_name = OsString::from(format!("state-{}.json", state.generation_id)); + let mut replacement = state.clone(); + replacement.updated_at_ms = 15; + replacement.sticky_fault = Some(StatusReason::HookStateMalformed); + let writer_interposed = Cell::new(false); + + let scan = store + .read_all_with_between_snapshot(20, || { + if let Some(_lock) = StateLock::try_acquire(&store.state_dir).unwrap() { + writer_interposed.set(true); + write_state_file(&store.state_dir, &state_name, &replacement).unwrap(); + } + }) + .unwrap(); + + assert!(!writer_interposed.get()); + assert_eq!(scan.rejected, 0); + assert_eq!(scan.states, vec![state]); + } + + #[cfg(unix)] + #[test] + fn combined_read_lock_blocks_fault_compaction_temp_between_snapshot_halves() { + use std::cell::RefCell; + + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + let state = store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let compaction_lock = RefCell::new(None); + let fault = HookIngestFault { + schema_version: HOOK_STATE_SCHEMA_VERSION, + integration: None, + observed_at_ms: 15, + commit_id: "a".repeat(INGEST_COMMIT_ID_LEN), + }; + let fault_bytes = encode_fault(&fault).unwrap(); + let temporary_name = OsStr::new(FAULT_COMPACTION_TEMP_NAME); + + let scan = store + .read_all_with_between_snapshot(20, || { + if let Some(lock) = StateLock::try_acquire(&store.state_dir).unwrap() { + drop( + store + .fault_dir + .create_private_new(temporary_name, &fault_bytes) + .unwrap(), + ); + compaction_lock.replace(Some(lock)); + } + }) + .unwrap(); + + let compactor_interposed = compaction_lock.borrow().is_some(); + if compactor_interposed { + store.fault_dir.remove(temporary_name).unwrap(); + drop(compaction_lock.borrow_mut().take()); + } + assert!(!compactor_interposed); + assert_eq!(scan.rejected, 0); + assert_eq!(scan.states, vec![state]); + } + + #[cfg(unix)] + #[test] + fn first_writer_reruns_the_entire_combined_snapshot() { + use std::cell::Cell; + + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + let writer_started = Cell::new(false); + let snapshot_count = Cell::new(0_u8); + + let scan = store + .read_all_with_between_snapshot(30, || { + snapshot_count.set(snapshot_count.get() + 1); + if !writer_started.replace(true) { + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + drop(store.begin_ingest(20).unwrap()); + } + }) + .unwrap(); + + assert_eq!(snapshot_count.get(), 2); + assert_eq!(scan.rejected, 0); + assert_eq!(scan.states.len(), 1); + assert_eq!( + scan.states[0].sticky_fault, + Some(StatusReason::HookEventGap) + ); + } + + #[cfg(unix)] + #[test] + fn failed_ingest_poison_persists_until_a_clean_generation() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + + let failed = store.begin_ingest(20).unwrap(); + drop(failed); + let poisoned = store.read_all(30).unwrap(); + assert_eq!(poisoned.states.len(), 1); + assert_eq!( + poisoned.states[0].sticky_fault, + Some(StatusReason::HookEventGap) + ); + + store.fold(event(HookEventKind::SessionStart, 40)).unwrap(); + let recovered = store.read_all(50).unwrap(); + assert_eq!(recovered.states.len(), 1); + assert_eq!(recovered.states[0].sticky_fault, None); + + let successful = store.begin_ingest(60).unwrap(); + successful.succeed().unwrap(); + let still_clean = store.read_all(70).unwrap(); + assert_eq!(still_clean.states[0].sticky_fault, None); + } + + #[cfg(unix)] + #[test] + fn valid_over_soft_cap_fault_ledger_is_read_through_the_recovery_window() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + let mut first = event(HookEventKind::SessionStart, 10); + first.session_id = "session-a".into(); + store.fold(first).unwrap(); + let mut second = event(HookEventKind::SessionStart, 11); + second.session_id = "session-b".into(); + store.fold(second).unwrap(); + + for index in 0..148 { + write_adopted_generic_launcher_fault(&store.fault_dir, index, 20 + index); + } + + let mut clean = event(HookEventKind::SessionStart, 1_000); + clean.session_id = "session-a".into(); + store.fold(clean).unwrap(); + let scan = store.read_all(1_100).unwrap(); + assert_eq!(scan.rejected, 0); + let first = scan + .states + .iter() + .find(|state| state.session_id == "session-a") + .unwrap(); + let second = scan + .states + .iter() + .find(|state| state.session_id == "session-b") + .unwrap(); + assert_eq!(first.created_at_ms, 1_000); + assert_eq!(first.sticky_fault, None); + assert_eq!(second.sticky_fault, Some(StatusReason::HookEventGap)); + } + + #[cfg(unix)] + #[test] + fn over_hard_cap_ledger_is_fail_closed_then_recovers_in_bounded_batches() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + for index in 0..600 { + write_adopted_generic_launcher_fault(&store.fault_dir, index, 20 + index); + } + + let before = store.read_all(1_000).unwrap(); + assert!(before.rejected >= 1); + assert_eq!( + before.states[0].sticky_fault, + Some(StatusReason::HookStateMalformed) + ); + + ingress + .reclaim_stale_artifacts_after_drain(1_000, None) + .unwrap(); + assert!(store.read_all(1_000).unwrap().rejected >= 1); + ingress + .reclaim_stale_artifacts_after_drain(1_000, None) + .unwrap(); + let after = store.read_all(1_000).unwrap(); + assert_eq!(after.rejected, 0); + assert_eq!( + after.states[0].sticky_fault, + Some(StatusReason::HookEventGap) + ); + } + + #[cfg(unix)] + #[test] + fn hard_cap_recovery_preserves_the_current_protected_marker() { + use std::os::unix::fs::MetadataExt; + + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + for index in 0..MAX_FAULT_RECOVERY_FILES as u64 { + write_adopted_generic_launcher_fault(&store.fault_dir, index, 20 + index); + } + let protected_name = launcher_marker_name(2_000); + drop( + store + .fault_dir + .create_private_new(&protected_name, &[]) + .unwrap(), + ); + let protected = ingress.adopt_launcher_marker(&protected_name, 900).unwrap(); + let protected_proof = protected.marker_id().unwrap().to_string(); + let path = store.fault_dir.path.join(&protected_name); + let before = fs::metadata(&path).unwrap(); + + assert!(store.read_all(1_000).unwrap().rejected >= 1); + ingress + .reclaim_stale_artifacts_after_drain(1_000, Some(&protected_proof)) + .unwrap(); + let after = store.read_all(1_000).unwrap(); + assert_eq!(after.rejected, 0); + let current = fs::metadata(&path).unwrap(); + assert_eq!((before.dev(), before.ino()), (current.dev(), current.ino())); + protected.succeed().unwrap(); + } + + #[cfg(unix)] + #[test] + fn compaction_persists_overflow_before_removing_exact_launcher_inodes() { + use std::os::unix::fs::MetadataExt; + + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + + // Exceed both the 128-file soft cap and the old generic atomic-write + // listing bound so overflow replacement exercises its recovery bound. + for index in 0..300 { + write_adopted_generic_launcher_fault(&store.fault_dir, index, 20 + index); + } + + let protected_name = launcher_marker_name(1_000); + drop( + store + .fault_dir + .create_private_new(&protected_name, &[]) + .unwrap(), + ); + let protected = ingress + .adopt_launcher_marker(&protected_name, 1_000) + .unwrap(); + let protected_proof = protected.marker_id().unwrap().to_string(); + let protected_path = store.fault_dir.path.join(&protected_name); + let before = fs::metadata(&protected_path).unwrap(); + + for _ in 0..8 { + ingress + .reclaim_stale_artifacts_after_drain(1_100, Some(&protected_proof)) + .unwrap(); + } + + let overflow = read_fault_file(&store.fault_dir, OsStr::new(FAULT_OVERFLOW_NAME)).unwrap(); + assert_eq!(overflow.integration, None); + assert_eq!(overflow.observed_at_ms, 319); + let after = fs::metadata(&protected_path).unwrap(); + assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino())); + let (names, truncated) = store + .fault_dir + .list_names(MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES) + .unwrap(); + assert!(!truncated); + assert_eq!( + names + .iter() + .filter(|name| { + name.to_str().is_some_and(|name| { + valid_fault_filename(name) || valid_launcher_fault_filename(name) + }) + }) + .count(), + 2 + ); + let scan = store.read_all(1_100).unwrap(); + assert_eq!(scan.rejected, 0); + assert_eq!( + scan.states[0].sticky_fault, + Some(StatusReason::HookEventGap) + ); + protected.succeed().unwrap(); + } + + #[cfg(unix)] + #[test] + fn compaction_drops_committed_marker_without_promoting_it_to_failure() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let unresolved_name = write_adopted_generic_launcher_fault(&store.fault_dir, 0, 20); + + let committed_name = launcher_marker_name(1); + drop( + store + .fault_dir + .create_private_new(&committed_name, &[]) + .unwrap(), + ); + let committed_guard = ingress.adopt_launcher_marker(&committed_name, 30).unwrap(); + let committed_proof = committed_guard.marker_id().unwrap().to_string(); + let mut prompt = event(HookEventKind::UserPromptSubmit, 30); + prompt.turn_id = Some("turn-a".into()); + prompt.ingest_marker_id = committed_proof.clone(); + store.fold(prompt).unwrap(); + drop(committed_guard); + + ingress + .reclaim_stale_artifacts_after_drain(40, None) + .unwrap(); + + let overflow = read_fault_file(&store.fault_dir, OsStr::new(FAULT_OVERFLOW_NAME)).unwrap(); + assert_eq!(overflow.observed_at_ms, 20); + assert!(!store.fault_dir.path.join(unresolved_name).exists()); + assert!(!store.fault_dir.path.join(committed_name).exists()); + let state = store.read_all(40).unwrap().states.remove(0); + assert!(state.completed_ingests.contains(&committed_proof)); + } + + #[cfg(unix)] + #[test] + fn concurrent_empty_overflow_creation_aborts_compaction_without_deletion() { + use std::cell::Cell; + use std::os::unix::fs::MetadataExt; + + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let marker = write_adopted_generic_launcher_fault(&store.fault_dir, 0, 20); + let states = generations_for_artifact_reclamation(&store.state_dir) + .unwrap() + .unwrap(); + let created_inode = Cell::new(None); + + let _lock = StateLock::acquire(&store.state_dir).unwrap(); + compact_adopted_generic_launcher_faults_with(&store.fault_dir, &states, 30, None, || { + drop( + store + .fault_dir + .create_private_new(OsStr::new(FAULT_OVERFLOW_NAME), &[]) + .unwrap(), + ); + let metadata = fs::metadata(store.fault_dir.path.join(FAULT_OVERFLOW_NAME)).unwrap(); + created_inode.set(Some((metadata.dev(), metadata.ino()))); + }) + .unwrap(); + drop(_lock); + + assert!(store.fault_dir.path.join(marker).exists()); + let overflow_metadata = + fs::metadata(store.fault_dir.path.join(FAULT_OVERFLOW_NAME)).unwrap(); + assert_eq!(overflow_metadata.len(), 0); + assert_eq!( + created_inode.get(), + Some((overflow_metadata.dev(), overflow_metadata.ino())) + ); + let scan = store.read_all(30).unwrap(); + assert!(scan.rejected >= 1); + assert_eq!( + scan.states[0].sticky_fault, + Some(StatusReason::HookStateMalformed) + ); + } + + #[cfg(unix)] + #[test] + fn reserved_compaction_temp_blocks_replacement_without_deleting_sources() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + let mut start = event(HookEventKind::SessionStart, 10); + start.process = gone_process_identity(); + store.fold(start).unwrap(); + store.record_overflow_fault(15, None).unwrap(); + let source = write_adopted_generic_launcher_fault(&store.fault_dir, 0, 20); + let source_path = store.fault_dir.path.join(&source); + let temporary_path = store.fault_dir.path.join(FAULT_COMPACTION_TEMP_NAME); + drop( + store + .fault_dir + .create_private_new(OsStr::new(FAULT_COMPACTION_TEMP_NAME), b"stale") + .unwrap(), + ); + let recovery_at_ms = 2_000 + TERMINAL_RETENTION_MS + 1; + set_modified_ms(&temporary_path, 2_000); + set_modified_ms(&source_path, recovery_at_ms - 1_000); + + assert!(ingress + .reclaim_stale_artifacts_after_drain(30, None) + .is_err()); + assert!(source_path.exists()); + assert!(temporary_path.exists()); + let scan = store.read_all(30).unwrap(); + assert!(scan.rejected >= 1); + assert_eq!( + scan.states[0].sticky_fault, + Some(StatusReason::HookStateMalformed) + ); + + // The failed compaction remains fail-closed, but it must not starve + // independently authorized stale-temp recovery forever. + assert!(ingress + .reclaim_stale_artifacts_after_drain(recovery_at_ms, None) + .is_err()); + assert!(!temporary_path.exists()); + assert!(source_path.exists()); + + ingress + .reclaim_stale_artifacts_after_drain(recovery_at_ms + 1, None) + .unwrap(); + assert!(!source_path.exists()); + let overflow = read_fault_file(&store.fault_dir, OsStr::new(FAULT_OVERFLOW_NAME)).unwrap(); + assert_eq!(overflow.observed_at_ms, 20); + } + + #[cfg(unix)] + #[test] + fn contended_state_lock_skips_compaction_without_mutation() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let source = write_adopted_generic_launcher_fault(&store.fault_dir, 0, 20); + let source_path = store.fault_dir.path.join(&source); + let before = fs::metadata(&source_path).unwrap(); + let _held = StateLock::acquire(&store.state_dir).unwrap(); + + ingress + .reclaim_stale_artifacts_after_drain(30, None) + .unwrap(); + + let after = fs::metadata(&source_path).unwrap(); + assert!(same_file(&before, &after)); + assert!(!store.fault_dir.path.join(FAULT_OVERFLOW_NAME).exists()); + } + + #[cfg(unix)] + #[test] + fn compaction_scans_past_uncompactable_entries_but_stops_at_its_batch() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + + for index in 0..80 { + let name = launcher_marker_name(index); + drop(store.fault_dir.create_private_new(&name, &[]).unwrap()); + } + let valid = (100..170) + .map(|index| write_adopted_generic_launcher_fault(&store.fault_dir, index, 20 + index)) + .collect::>(); + + ingress + .reclaim_stale_artifacts_after_drain(300, None) + .unwrap(); + + let remaining_valid = valid + .iter() + .filter(|name| store.fault_dir.path.join(name).exists()) + .count(); + assert_eq!(remaining_valid, 70 - MAX_FAULT_COMPACTION_BATCH_FILES); + assert!(store.fault_dir.path.join(FAULT_OVERFLOW_NAME).exists()); + assert!((0..80).all(|index| { + store + .fault_dir + .path + .join(launcher_marker_name(index)) + .exists() + })); + } + + #[cfg(unix)] + #[test] + fn current_marker_and_overflow_take_the_fast_no_compaction_path() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + store.record_overflow_fault(10, None).unwrap(); + let current_name = launcher_marker_name(1); + drop( + store + .fault_dir + .create_private_new(¤t_name, &[]) + .unwrap(), + ); + let current = ingress.adopt_launcher_marker(¤t_name, 20).unwrap(); + let proof = current.marker_id().unwrap(); + for index in 10..20 { + let name = launcher_marker_name(index); + drop(store.fault_dir.create_private_new(&name, &[]).unwrap()); + } + + assert!(!fault_compaction_may_be_needed(&store.fault_dir, 30, Some(proof)).unwrap()); + assert!(!stale_artifact_reclamation_may_be_needed( + &store.state_dir, + &store.fault_dir, + 30, + Some(proof), + ) + .unwrap()); + current.succeed().unwrap(); + } + + #[cfg(unix)] + #[test] + fn one_session_boundary_never_erases_another_sessions_failure() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + let mut first = event(HookEventKind::SessionStart, 10); + first.session_id = "session-a".into(); + store.fold(first).unwrap(); + let mut second = event(HookEventKind::SessionStart, 11); + second.session_id = "session-b".into(); + store.fold(second).unwrap(); + + let failed = store.begin_ingest(20).unwrap(); + let marker = marker_name_from_proof(failed.marker_id().unwrap()).to_string(); + drop(failed); + let mut clean_first = event(HookEventKind::SessionStart, 40); + clean_first.session_id = "session-a".into(); + store.fold(clean_first).unwrap(); + + assert!(store.fault_dir.path.join(marker).exists()); + let scan = store.read_all(50).unwrap(); + let first = scan + .states + .iter() + .find(|state| state.session_id == "session-a") + .unwrap(); + let second = scan + .states + .iter() + .find(|state| state.session_id == "session-b") + .unwrap(); + assert_eq!(first.sticky_fault, None); + assert_eq!(second.sticky_fault, Some(StatusReason::HookEventGap)); + } + + #[cfg(unix)] + #[test] + fn concurrent_inflight_marker_survives_a_clean_boundary() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let guard = store.begin_ingest(20).unwrap(); + let marker = marker_name_from_proof(guard.marker_id().unwrap()).to_string(); + store.fold(event(HookEventKind::SessionStart, 30)).unwrap(); + assert!(store.fault_dir.path.join(&marker).exists()); + drop(guard); + assert!(store.fault_dir.path.join(marker).exists()); + } + + #[cfg(unix)] + #[test] + fn committed_marker_is_ignored_after_crash_before_unlink() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let guard = store.begin_ingest(20).unwrap(); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + prompt.ingest_marker_id = guard.marker_id().unwrap().to_string(); + store.fold(prompt).unwrap(); + drop(guard); + + let scan = store.read_all(30).unwrap(); + assert_eq!(scan.rejected, 0); + assert_eq!(scan.states.len(), 1); + assert_eq!(scan.states[0].sticky_fault, None); + assert_eq!(scan.states[0].projection(), HookProjection::TurnOpen); + } + + #[cfg(unix)] + #[test] + fn reused_launcher_basename_cannot_inherit_an_old_commit() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let token = OsStr::new("launch-0-abtopv1.pending"); + + drop(store.fault_dir.create_private_new(token, &[]).unwrap()); + let first_guard = ingress.adopt_launcher_marker(token, 20).unwrap(); + let first_proof = first_guard.marker_id().unwrap().to_string(); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + prompt.ingest_marker_id = first_proof.clone(); + store.fold(prompt).unwrap(); + first_guard.succeed().unwrap(); + + // Reuse the fixed fallback basename for an invocation that fails + // before folding. A later unrelated successful update must not let + // the old basename-only proof hide this new failure. + drop(store.fault_dir.create_private_new(token, &[]).unwrap()); + let failed_guard = ingress.adopt_launcher_marker(token, 30).unwrap(); + let failed_proof = failed_guard.marker_id().unwrap().to_string(); + assert_ne!(first_proof, failed_proof); + drop(failed_guard); + + let later_guard = store.begin_ingest(40).unwrap(); + let mut later = event(HookEventKind::UserPromptSubmit, 40); + later.turn_id = Some("turn-a".into()); + later.ingest_marker_id = later_guard.marker_id().unwrap().to_string(); + store.fold(later).unwrap(); + later_guard.succeed().unwrap(); + + let scan = store.read_all(50).unwrap(); + assert_eq!(scan.rejected, 0); + assert_eq!( + scan.states[0].sticky_fault, + Some(StatusReason::HookEventGap) + ); + assert!(scan.states[0].completed_ingests.contains(&first_proof)); + assert!(!scan.states[0].completed_ingests.contains(&failed_proof)); + } + + #[cfg(unix)] + #[test] + fn clean_boundary_retains_commit_proof_for_other_live_generations() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + let mut first_start = event(HookEventKind::SessionStart, 10); + first_start.session_id = "session-a".into(); + store.fold(first_start).unwrap(); + let mut second_start = event(HookEventKind::SessionStart, 11); + second_start.session_id = "session-b".into(); + store.fold(second_start).unwrap(); + + let guard = store.begin_ingest(20).unwrap(); + let committed_proof = guard.marker_id().unwrap().to_string(); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.session_id = "session-a".into(); + prompt.turn_id = Some("turn-a".into()); + prompt.ingest_marker_id = committed_proof.clone(); + store.fold(prompt).unwrap(); + + let mut boundary = event(HookEventKind::SessionStart, 30); + boundary.session_id = "session-a".into(); + let boundary_state = store.fold(boundary).unwrap(); + assert!(boundary_state.completed_ingests.contains(&committed_proof)); + drop(guard); + + let scan = store.read_all(40).unwrap(); + assert_eq!(scan.rejected, 0); + assert!(scan.states.iter().all(|state| state.sticky_fault.is_none())); + } + + #[cfg(unix)] + #[test] + fn legacy_fault_without_commit_id_fails_closed() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let name = OsString::from(format!("{FAULT_PREFIX}{}.json", "9".repeat(32))); + let legacy = serde_json::json!({ + "schema_version": HOOK_STATE_SCHEMA_VERSION, + "integration": identity(), + "observed_at_ms": 20 + }); + let path = store.fault_dir.path.join(&name); + fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + + let scan = store.read_all(30).unwrap(); + assert_eq!(scan.rejected, 1); + assert_eq!( + scan.states[0].sticky_fault, + Some(StatusReason::HookStateMalformed) + ); + } + + #[cfg(unix)] + #[test] + fn parallel_committed_markers_remain_independently_auditable() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let first_guard = store.begin_ingest(20).unwrap(); + let second_guard = store.begin_ingest(30).unwrap(); + + let mut first = event(HookEventKind::UserPromptSubmit, 20); + first.turn_id = Some("turn-a".into()); + first.ingest_marker_id = first_guard.marker_id().unwrap().to_string(); + store.fold(first).unwrap(); + let mut second = event(HookEventKind::UserPromptSubmit, 30); + second.turn_id = Some("turn-a".into()); + second.ingest_marker_id = second_guard.marker_id().unwrap().to_string(); + store.fold(second).unwrap(); + drop(first_guard); + drop(second_guard); + + let scan = store.read_all(40).unwrap(); + assert_eq!(scan.rejected, 0); + assert_eq!(scan.states[0].sticky_fault, None); + assert_eq!(scan.states[0].completed_ingests.len(), 3); + } + + #[cfg(unix)] + #[test] + fn fresh_fixed_slot_marker_is_global_unknown_until_adopted() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let token = "launch-0-abtopv1.pending"; + let marker_path = store.fault_dir.path.join(token); + fs::write(&marker_path, []).unwrap(); + fs::set_permissions(&marker_path, fs::Permissions::from_mode(0o600)).unwrap(); + let orphaned = store.read_all(15).unwrap(); + assert_eq!(orphaned.rejected, 1); + assert_eq!( + orphaned.states[0].sticky_fault, + Some(StatusReason::HookStateMalformed) + ); + + let guard = ingress + .adopt_launcher_marker(OsStr::new(token), 20) + .unwrap(); + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + prompt.ingest_marker_id = guard.marker_id().unwrap().to_string(); + store.fold(prompt).unwrap(); + drop(guard); + let committed = store.read_all(30).unwrap(); + assert_eq!(committed.rejected, 0); + assert_eq!(committed.states[0].sticky_fault, None); + } + + #[cfg(unix)] + #[test] + fn stale_fixed_slot_recovers_only_after_dead_generation_and_strict_grace() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + let mut start = event(HookEventKind::SessionStart, 1_000); + start.process = gone_process_identity(); + store.fold(start).unwrap(); + + let token = OsStr::new("launch-0-abtopv1.pending"); + let marker_path = store.fault_dir.path.join(token); + drop(store.fault_dir.create_private_new(token, &[]).unwrap()); + set_modified_ms(&marker_path, 2_000); + let marker_time = fs::metadata(&marker_path) + .unwrap() + .modified() + .ok() + .and_then(system_time_ms) + .unwrap(); + + let poisoned = store.read_all(marker_time).unwrap(); + assert_eq!(poisoned.rejected, 1); + assert_eq!( + poisoned.states[0].sticky_fault, + Some(StatusReason::HookStateMalformed) + ); + + ingress + .reclaim_stale_artifacts_after_drain(marker_time + TERMINAL_RETENTION_MS, None) + .unwrap(); + assert!( + marker_path.exists(), + "the 24h boundary is still fail-closed" + ); + ingress + .reclaim_stale_artifacts_after_drain(marker_time + TERMINAL_RETENTION_MS + 1, None) + .unwrap(); + assert!(!marker_path.exists()); + let recovered = store + .read_all(marker_time + TERMINAL_RETENTION_MS + 1) + .unwrap(); + assert_eq!(recovered.rejected, 0); + assert_eq!(recovered.states[0].sticky_fault, None); + + // A later launcher can now create a new inode in the freed fixed slot + // and its own helper can adopt that fresh marker. + drop(store.fault_dir.create_private_new(token, &[]).unwrap()); + let guard = ingress + .adopt_launcher_marker(token, marker_time + TERMINAL_RETENTION_MS + 2) + .unwrap(); + assert_eq!( + marker_name_from_proof(guard.marker_id().unwrap()), + token.to_str().unwrap() + ); + guard.succeed().unwrap(); + assert!(!marker_path.exists()); + } + + #[cfg(unix)] + #[test] + fn truncated_stale_launcher_directory_reclaims_in_bounded_batches() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + let mut start = event(HookEventKind::SessionStart, 1_000); + start.process = gone_process_identity(); + store.fold(start).unwrap(); + + for index in 0..=MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES as u64 { + let name = launcher_marker_name(index); + let path = store.fault_dir.path.join(&name); + drop(store.fault_dir.create_private_new(&name, &[]).unwrap()); + set_modified_ms(&path, 2_000); + } + let now_ms = 2_000 + TERMINAL_RETENTION_MS + 1; + assert!( + store + .fault_dir + .list_names(MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES) + .unwrap() + .1 + ); + + ingress + .reclaim_stale_artifacts_after_drain(now_ms, None) + .unwrap(); + assert_eq!( + fs::read_dir(&store.fault_dir.path).unwrap().count(), + MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES + 1 - MAX_FAULT_COMPACTION_BATCH_FILES + ); + for _ in 0..8 { + ingress + .reclaim_stale_artifacts_after_drain(now_ms, None) + .unwrap(); + } + + let (remaining, truncated) = store + .fault_dir + .list_names(MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES) + .unwrap(); + assert!(!truncated); + assert!(remaining.is_empty()); + } + + #[cfg(unix)] + #[test] + fn fresh_soft_cap_prefix_does_not_hide_a_stale_launcher_tail() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + let mut start = event(HookEventKind::SessionStart, 1_000); + start.process = gone_process_identity(); + store.fold(start).unwrap(); + let now_ms = TERMINAL_RETENTION_MS + 10_000; + + for index in 0..(MAX_FAULT_DIRECTORY_ENTRIES + 32) as u64 { + let name = launcher_marker_name(index); + let path = store.fault_dir.path.join(&name); + drop(store.fault_dir.create_private_new(&name, &[]).unwrap()); + set_modified_ms(&path, now_ms - 1_000); + } + + let (soft_names, soft_truncated) = store + .fault_dir + .list_names(MAX_FAULT_DIRECTORY_ENTRIES) + .unwrap(); + assert!(soft_truncated); + let soft_names = soft_names.into_iter().collect::>(); + let (recovery_names, recovery_truncated) = store + .fault_dir + .list_names(MAX_FAULT_RECOVERY_DIRECTORY_ENTRIES) + .unwrap(); + assert!(!recovery_truncated); + let stale_name = recovery_names + .into_iter() + .find(|name| !soft_names.contains(name)) + .expect("the widened scan must expose a marker outside the soft prefix"); + let stale_path = store.fault_dir.path.join(&stale_name); + set_modified_ms(&stale_path, 2_000); + + ingress + .reclaim_stale_artifacts_after_drain(now_ms, None) + .unwrap(); + + assert!(!stale_path.exists()); + for name in soft_names { + assert!(store.fault_dir.path.join(name).exists()); + } + } + + #[cfg(unix)] + #[test] + fn stale_artifacts_remain_poisoning_while_an_affected_generation_is_live() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + store + .fold(event(HookEventKind::SessionStart, 1_000)) + .unwrap(); + + let token = OsStr::new("launch-1-abtopv1.pending"); + let marker_path = store.fault_dir.path.join(token); + drop(store.fault_dir.create_private_new(token, &[]).unwrap()); + set_modified_ms(&marker_path, 2_000); + let marker_time = fs::metadata(&marker_path) + .unwrap() + .modified() + .ok() + .and_then(system_time_ms) + .unwrap(); + + ingress + .reclaim_stale_artifacts_after_drain(marker_time + TERMINAL_RETENTION_MS + 1, None) + .unwrap(); + assert!(marker_path.exists()); + let scan = store + .read_all(marker_time + TERMINAL_RETENTION_MS + 1) + .unwrap(); + assert_eq!(scan.rejected, 1); + assert_eq!( + scan.states[0].sticky_fault, + Some(StatusReason::HookStateMalformed) + ); + } + + #[cfg(unix)] + #[test] + fn stale_atomic_temps_recover_after_dead_generation_and_strict_grace() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + let mut start = event(HookEventKind::SessionStart, 1_000); + start.process = gone_process_identity(); + store.fold(start).unwrap(); + + let state_temp = OsStr::new(".tmp-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let fault_temp = OsStr::new(".tmp-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + let state_temp_path = store.state_dir.path.join(state_temp); + let fault_temp_path = store.fault_dir.path.join(fault_temp); + drop( + store + .state_dir + .create_private_new(state_temp, b"partial") + .unwrap(), + ); + drop( + store + .fault_dir + .create_private_new(fault_temp, b"partial") + .unwrap(), + ); + set_modified_ms(&state_temp_path, 2_000); + set_modified_ms(&fault_temp_path, 2_000); + let artifact_time = fs::metadata(&state_temp_path) + .unwrap() + .modified() + .ok() + .and_then(system_time_ms) + .unwrap(); + + let poisoned = store.read_all(artifact_time).unwrap(); + assert_eq!(poisoned.rejected, 2); + assert_eq!( + poisoned.states[0].sticky_fault, + Some(StatusReason::HookStateMalformed) + ); + + ingress + .reclaim_stale_artifacts_after_drain(artifact_time + TERMINAL_RETENTION_MS, None) + .unwrap(); + assert!(state_temp_path.exists()); + assert!(fault_temp_path.exists()); + ingress + .reclaim_stale_artifacts_after_drain(artifact_time + TERMINAL_RETENTION_MS + 1, None) + .unwrap(); + assert!(!state_temp_path.exists()); + assert!(!fault_temp_path.exists()); + let recovered = store + .read_all(artifact_time + TERMINAL_RETENTION_MS + 1) + .unwrap(); + assert_eq!(recovered.rejected, 0); + assert_eq!(recovered.states[0].sticky_fault, None); + } + + #[cfg(unix)] + #[test] + fn overflow_sentinel_is_never_reclaimed() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let ingress = HookStateStore::prepare(&plugin_data).unwrap(); + let store = ingress.bind(identity()).unwrap(); + store.record_overflow_fault(10, None).unwrap(); + + ingress + .reclaim_stale_artifacts_after_drain(TERMINAL_RETENTION_MS + 100, None) + .unwrap(); + let overflow = read_fault_file(&store.fault_dir, OsStr::new(FAULT_OVERFLOW_NAME)).unwrap(); + assert_eq!(overflow.observed_at_ms, 10); + assert_eq!(overflow.integration, None); + } + + #[cfg(unix)] + #[test] + fn overflow_timestamp_is_monotonic_and_malformed_markers_are_rejected() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 1)).unwrap(); + for at in 2..(MAX_FAULT_FILES as u64 + 2) { + drop(store.begin_ingest(at).unwrap()); + } + drop(store.begin_ingest(1_000).unwrap()); + drop(store.begin_ingest(900).unwrap()); + let overflow = read_fault_file(&store.fault_dir, OsStr::new(FAULT_OVERFLOW_NAME)).unwrap(); + assert_eq!(overflow.observed_at_ms, 1_000); + + let malformed_name = format!("{FAULT_PREFIX}{}.json", "f".repeat(32)); + let malformed_path = store.fault_dir.path.join(malformed_name); + fs::write(&malformed_path, b"{").unwrap(); + fs::set_permissions(&malformed_path, fs::Permissions::from_mode(0o600)).unwrap(); + let scan = store.read_all(1_100).unwrap(); + assert!(scan.rejected >= 1); + assert_eq!( + scan.states[0].sticky_fault, + Some(StatusReason::HookStateMalformed) + ); + } + + #[cfg(unix)] + #[test] + fn generic_overflow_can_never_be_narrowed_to_one_integration() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + + store.record_overflow_fault(10, None).unwrap(); + store.record_overflow_fault(20, Some(identity())).unwrap(); + + let overflow = read_fault_file(&store.fault_dir, OsStr::new(FAULT_OVERFLOW_NAME)).unwrap(); + assert_eq!(overflow.observed_at_ms, 20); + assert_eq!(overflow.integration, None); + } + + #[cfg(unix)] + #[test] + fn same_installation_mismatched_fault_is_rejected_globally() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let mut changed = identity(); + changed.config_digest = format!("sha256:{}", "9".repeat(64)); + let fault = HookIngestFault { + schema_version: HOOK_STATE_SCHEMA_VERSION, + integration: Some(changed), + observed_at_ms: 20, + commit_id: "d".repeat(INGEST_COMMIT_ID_LEN), + }; + let name = OsString::from(format!("{FAULT_PREFIX}{}.json", "e".repeat(32))); + store + .fault_dir + .create_private_new(&name, &encode_fault(&fault).unwrap()) + .unwrap(); + + let scan = store.read_all(30).unwrap(); + assert_eq!(scan.rejected, 1); + assert_eq!( + scan.states[0].sticky_fault, + Some(StatusReason::HookStateMalformed) + ); + } + + #[cfg(unix)] + #[test] + fn empty_launcher_overflow_is_refreshed_without_becoming_success() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let overflow_path = store.fault_dir.path.join(FAULT_OVERFLOW_NAME); + fs::write(&overflow_path, []).unwrap(); + fs::set_permissions(&overflow_path, fs::Permissions::from_mode(0o600)).unwrap(); + + let guard = store.begin_ingest(20).unwrap(); + assert_eq!( + marker_name_from_proof(guard.marker_id().unwrap()), + FAULT_OVERFLOW_NAME + ); + drop(guard); + let fault = read_fault_file(&store.fault_dir, OsStr::new(FAULT_OVERFLOW_NAME)).unwrap(); + assert_eq!(fault.observed_at_ms, 20); + let scan = store.read_all(30).unwrap(); + assert_eq!(scan.rejected, 0); + assert_eq!( + scan.states[0].sticky_fault, + Some(StatusReason::HookEventGap) + ); + } + + #[cfg(unix)] + #[test] + fn retained_directory_descriptors_defeat_ancestor_replacement() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let temp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(temp.path()).unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + let original_plugins = root.join("plugins"); + let retained_plugins = root.join("plugins-retained"); + fs::rename(&original_plugins, &retained_plugins).unwrap(); + + let replacement = root.join("replacement"); + fs::create_dir(&replacement).unwrap(); + fs::set_permissions(&replacement, fs::Permissions::from_mode(0o700)).unwrap(); + symlink(&replacement, &original_plugins).unwrap(); + + let state = store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let retained_state = retained_plugins + .join("data/abtop-abtop-local/states") + .join(format!("state-{}.json", state.generation_id)); + assert!(retained_state.exists()); + assert!(!replacement.join("data").exists()); + assert_eq!(store.read_all(20).unwrap().states.len(), 1); + } + + #[cfg(unix)] + #[test] + fn collector_open_existing_never_creates_state_directories() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + assert!(HookStateStore::open_existing(&plugin_data, identity()).is_err()); + assert!(!plugin_data.join(STATE_DIR_NAME).exists()); + + HookStateStore::prepare(&plugin_data).unwrap(); + let store = HookStateStore::open_existing(&plugin_data, identity()).unwrap(); + assert!(store.read_all(10).unwrap().states.is_empty()); + assert!(!store.state_dir.path.join(".lock").exists()); + } + + #[cfg(unix)] + #[test] + fn prior_installation_state_is_retained_but_not_current_evidence() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let mut old_identity = identity(); + old_identity.installation_id = "b".repeat(32); + let old_store = HookStateStore::new(&plugin_data, old_identity.clone()).unwrap(); + let mut old_start = event(HookEventKind::SessionStart, 10); + old_start.integration = old_identity; + old_store.fold(old_start).unwrap(); + drop(old_store.begin_ingest(15).unwrap()); + + let current = HookStateStore::open_existing(&plugin_data, identity()).unwrap(); + let scan = current.read_all(20).unwrap(); + assert_eq!(scan.rejected, 0); + assert!(scan.states.is_empty()); + assert_eq!( + current + .state_dir + .list_names(MAX_DIRECTORY_ENTRIES) + .unwrap() + .0 + .iter() + .filter(|name| name.to_str().is_some_and(valid_state_filename)) + .count(), + 1 + ); + } + + #[cfg(unix)] + #[test] + fn normal_gc_starts_grace_at_first_confirmed_death_and_uses_strict_boundary() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + + let mut start = event(HookEventKind::SessionStart, 1); + start.process = gone_process_identity(); + let started = store.fold(start).unwrap(); + let mut end = event(HookEventKind::SessionEnd, 2); + end.process = gone_process_identity(); + store.fold(end).unwrap(); + let name = OsString::from(format!("state-{}.json", started.generation_id)); + + // SessionEnd can precede process exit by an arbitrary interval. The + // retention age must not substitute for the first exact Gone poll. + let first_confirmation = 2 + TERMINAL_RETENTION_MS + 1; + store.cleanup_terminal_states(first_confirmation).unwrap(); + let stamped = read_state_file(&store.state_dir, &name).unwrap().unwrap(); + assert_eq!(stamped.first_confirmed_gone_at_ms, first_confirmation); + + store + .cleanup_terminal_states(first_confirmation + PROCESS_DEATH_OBSERVATION_GRACE_MS) + .unwrap(); + let at_boundary = read_state_file(&store.state_dir, &name).unwrap().unwrap(); + assert_eq!(at_boundary.first_confirmed_gone_at_ms, first_confirmation); + + store + .cleanup_terminal_states(first_confirmation + PROCESS_DEATH_OBSERVATION_GRACE_MS + 1) + .unwrap(); + assert!(read_state_file(&store.state_dir, &name).unwrap().is_none()); + } + + #[cfg(unix)] + #[test] + fn crashed_generations_need_persisted_death_grace_under_capacity_pressure() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + + let mut crashed = event(HookEventKind::SessionStart, 1); + crashed.session_id = "crashed".into(); + crashed.process = HookProcessIdentity { + pid: 2_000_000_000, + started_at_ms: 1, + incarnation: "gone-incarnation".into(), + shared_host: false, + launch_config_ambiguous: false, + }; + store.fold(crashed).unwrap(); + for index in 1..MAX_STATE_FILES { + let mut live = event(HookEventKind::SessionStart, index as u64 + 2); + live.session_id = format!("live-{index}"); + store.fold(live).unwrap(); + } + + let after_retention = TERMINAL_RETENTION_MS + 10; + let mut replacement = event(HookEventKind::SessionStart, after_retention); + replacement.session_id = "replacement".into(); + assert!(store.fold(replacement.clone()).is_err()); + let scan = store.read_all(after_retention).unwrap(); + assert_eq!(scan.rejected, 0); + assert_eq!(scan.states.len(), MAX_STATE_FILES); + let crashed = scan + .states + .iter() + .find(|state| state.session_id == "crashed") + .unwrap(); + assert_eq!(crashed.ended_at_ms, 0); + assert_eq!(crashed.first_confirmed_gone_at_ms, after_retention); + + replacement.observed_at_ms = after_retention + PROCESS_DEATH_OBSERVATION_GRACE_MS; + assert!(store.fold(replacement.clone()).is_err()); + let at_boundary = store + .read_all(replacement.observed_at_ms) + .unwrap() + .states + .into_iter() + .find(|state| state.session_id == "crashed") + .unwrap(); + assert_eq!(at_boundary.first_confirmed_gone_at_ms, after_retention); + + replacement.observed_at_ms += 1; + store.fold(replacement.clone()).unwrap(); + let after_grace = store.read_all(replacement.observed_at_ms).unwrap(); + assert_eq!(after_grace.states.len(), MAX_STATE_FILES); + assert!(after_grace + .states + .iter() + .all(|state| state.session_id != "crashed")); + assert!(after_grace + .states + .iter() + .any(|state| state.session_id == "replacement")); + + let mut overflow = event(HookEventKind::SessionStart, replacement.observed_at_ms + 1); + overflow.session_id = "overflow".into(); + assert!(store.fold(overflow).is_err()); + assert!(store.fault_dir.path.join(FAULT_OVERFLOW_NAME).exists()); + } + + #[test] + fn pid_reuse_is_exact_gone_evidence_and_still_observes_grace() { + let mut reused = process_identity(); + reused.incarnation = "retired-test-incarnation".into(); + assert!(!reused.matches_live_process()); + assert!(reused.confirmed_gone()); + + let mut start = event(HookEventKind::SessionStart, 10); + start.process = reused; + let mut state = HookSessionState::new(&start); + state.apply(&start); + + let first_confirmation = TERMINAL_RETENTION_MS + 20; + assert_eq!( + confirmed_gone_gc_decision(&mut state, first_confirmation), + ConfirmedGoneGcDecision::PersistFirstConfirmation + ); + assert_eq!( + confirmed_gone_gc_decision( + &mut state, + first_confirmation + PROCESS_DEATH_OBSERVATION_GRACE_MS + ), + ConfirmedGoneGcDecision::Keep + ); + assert_eq!( + confirmed_gone_gc_decision( + &mut state, + first_confirmation + PROCESS_DEATH_OBSERVATION_GRACE_MS + 1 + ), + ConfirmedGoneGcDecision::Remove + ); + } + + #[test] + fn only_a_clean_generation_boundary_resets_death_confirmation() { + let start = event(HookEventKind::SessionStart, 10); + let mut state = HookSessionState::new(&start); + state.apply(&start); + state.first_confirmed_gone_at_ms = 40; + + let mut prompt = event(HookEventKind::UserPromptSubmit, 20); + prompt.turn_id = Some("turn-a".into()); + state.apply(&prompt); + assert_eq!(state.first_confirmed_gone_at_ms, 40); + + let resume = HookEvent { + session_start_source: Some(SessionStartSource::Resume), + ..event(HookEventKind::SessionStart, 50) + }; + state.apply(&resume); + assert_eq!(state.first_confirmed_gone_at_ms, 0); + } + + #[cfg(unix)] + #[test] + fn future_death_confirmation_fails_closed_on_read() { + let temp = tempfile::tempdir().unwrap(); + let plugin_data = private_plugin_data(&temp); + let store = HookStateStore::new(&plugin_data, identity()).unwrap(); + let state = store.fold(event(HookEventKind::SessionStart, 10)).unwrap(); + let name = OsString::from(format!("state-{}.json", state.generation_id)); + let mut future = state; + let read_at = 100; + future.first_confirmed_gone_at_ms = read_at + 60_001; + write_state_file(&store.state_dir, &name, &future).unwrap(); + + let scan = store.read_all(read_at).unwrap(); + assert_eq!(scan.states.len(), 1); + assert_eq!( + scan.states[0].sticky_fault, + Some(StatusReason::HookStateMalformed) + ); + } +} diff --git a/src/collector/claude.rs b/src/collector/claude.rs index 8f08db5..e3e4b37 100644 --- a/src/collector/claude.rs +++ b/src/collector/claude.rs @@ -1,7 +1,8 @@ use super::process::{self, ProcInfo}; use crate::model::{ AgentSession, ChatMessage, ChatRole, ChildProcess, FileAccess, FileOp, SessionFile, - SessionStatus, SubAgent, MAX_CHAT_MESSAGES, MAX_FILE_ACCESSES, + SessionStatus, StatusAuthority, StatusEvidence, StatusObservation, StatusReason, SubAgent, + MAX_CHAT_MESSAGES, MAX_FILE_ACCESSES, }; use serde_json::Value; use std::collections::HashMap; @@ -46,6 +47,394 @@ struct ProcessOpenPaths { paths: Vec, } +/// Claude Code's native per-session status, written to `sessions/{pid}.json`. +/// Keep this list exact: unknown values must fall back to transcript/process +/// lifecycle signals instead of silently acquiring new semantics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ClaudeNativeStatus { + Busy, + Shell, + Idle, + Waiting, +} + +/// `procStart` has `ps -o lstart=` shape and second precision, but carries no +/// timezone. Claude installations have emitted both UTC-looking and local +/// values, so preserve every plausible epoch and disambiguate it against the +/// numeric `startedAt` plus the live OS timestamp. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum ClaudeRecordedProcessStart { + #[default] + Missing, + Parsed { + utc_ms: u64, + local_ms: Option, + alternate_local_ms: Option, + }, + Invalid, +} + +const PROCESS_START_TOLERANCE_MS: u64 = 2_000; +const REGISTRY_PROCESS_START_MAX_SKEW_MS: u64 = 5 * 60 * 1_000; + +fn parse_claude_process_start(value: Option<&Value>) -> ClaudeRecordedProcessStart { + use chrono::{Local, LocalResult, NaiveDateTime, TimeZone, Utc}; + + let Some(value) = value else { + return ClaudeRecordedProcessStart::Missing; + }; + let Some(raw) = value.as_str().filter(|raw| !raw.trim().is_empty()) else { + return ClaudeRecordedProcessStart::Invalid; + }; + let Ok(naive) = NaiveDateTime::parse_from_str(raw.trim(), "%a %b %e %H:%M:%S %Y") else { + return ClaudeRecordedProcessStart::Invalid; + }; + + let as_local_ms = |timestamp: chrono::DateTime| { + let millis = timestamp.timestamp_millis(); + u64::try_from(millis).ok() + }; + let Ok(utc_ms) = u64::try_from(Utc.from_utc_datetime(&naive).timestamp_millis()) else { + return ClaudeRecordedProcessStart::Invalid; + }; + let (local_ms, alternate_local_ms) = match Local.from_local_datetime(&naive) { + LocalResult::Single(timestamp) => (as_local_ms(timestamp), None), + LocalResult::Ambiguous(first, second) => (as_local_ms(first), as_local_ms(second)), + LocalResult::None => (None, None), + }; + ClaudeRecordedProcessStart::Parsed { + utc_ms, + local_ms, + alternate_local_ms, + } +} + +/// Verify that a registry row still belongs to this exact OS process rather +/// than a later Claude process that reused the PID. A present `procStart` is +/// authoritative and fails closed when it cannot be parsed or compared. Old +/// Claude versions without `procStart` can only be trusted when the exact live +/// OS start is available and predates the registry row. PID-only compatibility +/// would let a later Claude process inherit a stale legacy registry file. +fn claude_process_incarnation_matches( + recorded: ClaudeRecordedProcessStart, + registry_started_at_ms: u64, + live_started_at_ms: Option, +) -> bool { + match recorded { + ClaudeRecordedProcessStart::Parsed { + utc_ms, + local_ms, + alternate_local_ms, + } => live_started_at_ms.is_some_and(|live_ms| { + [Some(utc_ms), local_ms, alternate_local_ms] + .into_iter() + .flatten() + .any(|candidate_ms| { + candidate_ms.abs_diff(live_ms) <= PROCESS_START_TOLERANCE_MS + && registry_started_at_ms > 0 + && candidate_ms.abs_diff(registry_started_at_ms) + <= REGISTRY_PROCESS_START_MAX_SKEW_MS + }) + }), + ClaudeRecordedProcessStart::Invalid => false, + ClaudeRecordedProcessStart::Missing => live_started_at_ms.is_some_and(|live_ms| { + live_ms > 0 + && registry_started_at_ms > 0 + && live_ms <= registry_started_at_ms.saturating_add(PROCESS_START_TOLERANCE_MS) + }), + } +} + +fn claude_action_process_observation_is_exact( + expected_incarnation: &str, + current_incarnation: Option<&str>, + tokens: &[String], +) -> bool { + current_incarnation == Some(expected_incarnation) + && process::tokens_have_binary(tokens, "claude") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ClaudeLifecycleArbitration { + Native(ClaudeNativeStatus), + Transcript, + Conflict, +} + +fn native_status_compatible_with_transcript( + native_status: ClaudeNativeStatus, + transcript_status: SessionStatus, +) -> bool { + match native_status { + ClaudeNativeStatus::Waiting => transcript_status == SessionStatus::Waiting, + ClaudeNativeStatus::Idle => transcript_status == SessionStatus::Idle, + ClaudeNativeStatus::Busy => matches!( + transcript_status, + SessionStatus::Thinking | SessionStatus::Executing + ), + ClaudeNativeStatus::Shell => transcript_status == SessionStatus::Executing, + } +} + +fn durable_claude_transcript_status( + lifecycle: ClaudeTranscriptLifecycle, + pending_decision: bool, +) -> Option { + match lifecycle { + ClaudeTranscriptLifecycle::ModelOpen => Some(SessionStatus::Thinking), + ClaudeTranscriptLifecycle::ToolOpen if pending_decision => Some(SessionStatus::Waiting), + ClaudeTranscriptLifecycle::ToolOpen => Some(SessionStatus::Executing), + ClaudeTranscriptLifecycle::Terminal => Some(SessionStatus::Idle), + ClaudeTranscriptLifecycle::Unknown => None, + } +} + +/// Claude's native registry and append-only transcript are separate writes and +/// either can lag. Comparable timestamps select the newer exact lifecycle for +/// every native state. If the states conflict but cannot be ordered, neither +/// source is safe enough and the caller must report Unknown. +fn arbitrate_claude_native_status( + native_status: Option, + native_transition_at_ms: u64, + transcript_status: Option, + transcript_lifecycle_observed: bool, + transcript_lifecycle_at_ms: u64, + now_ms: u64, +) -> ClaudeLifecycleArbitration { + let Some(native_status) = native_status else { + return ClaudeLifecycleArbitration::Transcript; + }; + let native_timestamp_valid = native_transition_at_ms > 0 && native_transition_at_ms <= now_ms; + let transcript_timestamp_valid = + transcript_lifecycle_at_ms > 0 && transcript_lifecycle_at_ms <= now_ms; + let Some(transcript_status) = transcript_status else { + if !transcript_lifecycle_observed { + return ClaudeLifecycleArbitration::Native(native_status); + } + return if native_timestamp_valid + && transcript_timestamp_valid + && native_transition_at_ms > transcript_lifecycle_at_ms + { + ClaudeLifecycleArbitration::Native(native_status) + } else { + ClaudeLifecycleArbitration::Conflict + }; + }; + + if native_timestamp_valid && transcript_timestamp_valid { + if transcript_lifecycle_at_ms > native_transition_at_ms { + ClaudeLifecycleArbitration::Transcript + } else if native_transition_at_ms > transcript_lifecycle_at_ms + || native_status_compatible_with_transcript(native_status, transcript_status) + { + ClaudeLifecycleArbitration::Native(native_status) + } else { + ClaudeLifecycleArbitration::Conflict + } + } else if native_status_compatible_with_transcript(native_status, transcript_status) { + ClaudeLifecycleArbitration::Native(native_status) + } else { + ClaudeLifecycleArbitration::Conflict + } +} + +#[derive(Debug, Default)] +struct ClaudeRegistryState { + status: Option, + status_failure: Option, + process_start: ClaudeRecordedProcessStart, + /// Generic provider metadata. It is parsed and bounded, but deliberately + /// not displayed because it is undocumented and may contain private text. + #[allow(dead_code)] + waiting_for: Option, + /// Valid, non-future status transition time, used only as a duration + /// fallback. A native state never expires merely because this is old. + transition_at_ms: u64, +} + +impl ClaudeRegistryState { + fn parse(value: &Value) -> Self { + const MAX_SANE_EPOCH_MS: u64 = 4_000_000_000_000_000; + + let raw_status = value.get("status"); + let status = raw_status + .and_then(Value::as_str) + .and_then(|status| match status { + "busy" => Some(ClaudeNativeStatus::Busy), + "shell" => Some(ClaudeNativeStatus::Shell), + "idle" => Some(ClaudeNativeStatus::Idle), + "waiting" => Some(ClaudeNativeStatus::Waiting), + _ => None, + }); + let status_failure = match raw_status { + Some(Value::String(_)) if status.is_some() => None, + Some(Value::String(_)) if status.is_none() => Some(StatusReason::ProtocolUnknown), + Some(_) => Some(StatusReason::ProtocolMalformed), + None => None, + }; + + let process_start = parse_claude_process_start(value.get("procStart")); + + let waiting_for = value + .get("waitingFor") + .and_then(Value::as_str) + .filter(|reason| !reason.trim().is_empty()) + .map(|reason| truncate(reason.trim(), 256)); + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let sane_timestamp = |field: &str| { + value + .get(field) + .and_then(Value::as_u64) + .filter(|timestamp| *timestamp <= MAX_SANE_EPOCH_MS && *timestamp <= now_ms) + }; + let transition_at_ms = sane_timestamp("statusUpdatedAt") + .or_else(|| sane_timestamp("updatedAt")) + .unwrap_or(0); + + Self { + status, + status_failure, + process_start, + waiting_for, + transition_at_ms, + } + } +} + +fn classify_claude_status( + native_status: Option, + pending_decision: bool, + pending_tool: bool, + has_active_descendant: bool, + has_working_subagent: bool, + model_generating: bool, + transcript_terminal: bool, +) -> SessionStatus { + let executing = pending_tool || has_active_descendant || has_working_subagent; + + match native_status { + // A native interaction is authoritative and takes precedence over + // concurrent tools or background work: the user can unblock it now. + Some(ClaudeNativeStatus::Waiting) => SessionStatus::Waiting, + // `shell` means Claude has returned to its prompt while a shell task + // remains active. The session is still doing work. + Some(ClaudeNativeStatus::Shell) => SessionStatus::Executing, + // Native idle closes stale parent transcript/process heuristics, but + // Claude can return the parent prompt while an asynchronous subagent + // continues in its own exact transcript lifecycle. + Some(ClaudeNativeStatus::Idle) if has_working_subagent => SessionStatus::Executing, + Some(ClaudeNativeStatus::Idle) => SessionStatus::Idle, + // Busy is always active. Prefer Executing when we can identify the + // active work; otherwise Claude is generating a model response. + Some(ClaudeNativeStatus::Busy) => { + if executing { + SessionStatus::Executing + } else { + SessionStatus::Thinking + } + } + None => { + if pending_decision { + SessionStatus::Waiting + } else if executing { + SessionStatus::Executing + } else if model_generating { + SessionStatus::Thinking + } else if transcript_terminal { + SessionStatus::Idle + } else { + SessionStatus::Unknown + } + } + } +} + +fn unix_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +#[allow(clippy::too_many_arguments)] +fn claude_status_evidence( + status: SessionStatus, + native_status: Option, + pending_decision_reason: Option, + pending_tool: bool, + has_active_descendant: bool, + has_working_subagent: bool, + model_generating: bool, + transcript_terminal: bool, + native_status_failure: Option, + source_since_ms: u64, +) -> StatusEvidence { + let (authority, reason) = match native_status { + Some(ClaudeNativeStatus::Waiting) => ( + StatusAuthority::Provider, + StatusReason::ProviderWaitingUserInput, + ), + Some(ClaudeNativeStatus::Shell) => { + (StatusAuthority::Provider, StatusReason::ProviderExecuting) + } + Some(ClaudeNativeStatus::Idle) if has_working_subagent => { + (StatusAuthority::Provider, StatusReason::ProviderExecuting) + } + Some(ClaudeNativeStatus::Idle) => (StatusAuthority::Provider, StatusReason::ProviderIdle), + Some(ClaudeNativeStatus::Busy) if pending_tool || has_working_subagent => { + (StatusAuthority::Provider, StatusReason::ProviderExecuting) + } + Some(ClaudeNativeStatus::Busy) if has_active_descendant => ( + StatusAuthority::Heuristic, + StatusReason::BackgroundTerminalActive, + ), + Some(ClaudeNativeStatus::Busy) => { + (StatusAuthority::Provider, StatusReason::ProviderThinking) + } + None => { + if let Some(reason) = pending_decision_reason { + (StatusAuthority::Provider, reason) + } else if pending_tool || has_working_subagent { + (StatusAuthority::Provider, StatusReason::ProviderExecuting) + } else if has_active_descendant { + ( + StatusAuthority::Heuristic, + StatusReason::BackgroundTerminalActive, + ) + } else if model_generating { + (StatusAuthority::Provider, StatusReason::ProviderThinking) + } else if transcript_terminal { + (StatusAuthority::Provider, StatusReason::ProviderIdle) + } else { + ( + StatusAuthority::Unavailable, + native_status_failure.unwrap_or(StatusReason::Unavailable), + ) + } + } + }; + + let observed_at_ms = unix_now_ms(); + let mut evidence = StatusEvidence::default(); + evidence.observe(StatusObservation::new( + status, + authority, + reason, + observed_at_ms, + 0, + )); + if source_since_ms > 0 && source_since_ms <= observed_at_ms { + evidence.status_since_ms = source_since_ms; + } + evidence +} + pub struct ClaudeCollector { /// All known config directories to scan for sessions. config_dirs: Vec, @@ -56,6 +445,14 @@ pub struct ClaudeCollector { transcript_cache: HashMap, } +#[derive(Default)] +struct ClaudeSubagentCollection { + agents: Vec, + /// At least one reachable child descriptor could not be enumerated or + /// parsed, so quiescence cannot be proven. + incomplete: bool, +} + impl ClaudeCollector { #[cfg(test)] pub fn new() -> Self { @@ -304,8 +701,84 @@ impl ClaudeCollector { ctx: &DiscoveryContext, ) -> Option { let content = fs::read_to_string(path).ok()?; - let mut sf: SessionFile = serde_json::from_str(&content).ok()?; + let session_value: Value = serde_json::from_str(&content).ok()?; + let registry_state = ClaudeRegistryState::parse(&session_value); + let mut sf: SessionFile = serde_json::from_value(session_value).ok()?; sf.sanitize(); + // Retain the exact incarnation observed before the registry/process + // ownership checks below. If this process exits at any later point, + // action-time validation compares against this old identity and fails + // closed instead of anchoring to a reused PID. + let action_process_incarnation_candidate = process::get_process_incarnation(sf.pid); + let action_process_tokens = process::get_process_tokens(sf.pid); + + // `build_discovery_context` verifies Claude's recorded `procStart` + // against the live OS process before a row can acquire an actionable + // PID. A mismatch means a stale registry file survived PID reuse. + if !ctx + .session_incarnation_valid + .get(path) + .copied() + .unwrap_or(false) + { + return None; + } + + let proc = process_info.get(&sf.pid)?; + if !process::cmd_has_binary(&proc.command, "claude") { + return None; + } + // Re-check the current file value after the discovery pass. This + // closes the narrow race where a registry file is replaced between + // context construction and loading. + if registry_state.process_start != ClaudeRecordedProcessStart::Missing + && !claude_process_incarnation_matches( + registry_state.process_start, + sf.started_at, + process::get_process_started_at_ms(sf.pid), + ) + { + return None; + } + let action_live_started_at_ms = process::get_process_started_at_ms(sf.pid); + let action_binding_confirmed = match registry_state.process_start { + ClaudeRecordedProcessStart::Parsed { .. } => claude_process_incarnation_matches( + registry_state.process_start, + sf.started_at, + action_live_started_at_ms, + ), + // Compatibility rows without `procStart` remain visible when the + // OS start time is unavailable, but that is not enough ownership + // proof for a destructive or terminal-focus action. + ClaudeRecordedProcessStart::Missing => { + action_live_started_at_ms.is_some() + && claude_process_incarnation_matches( + registry_state.process_start, + sf.started_at, + action_live_started_at_ms, + ) + } + ClaudeRecordedProcessStart::Invalid => false, + }; + let action_process_incarnation_after = process::get_process_incarnation(sf.pid); + let exact_process_observation = action_process_incarnation_candidate + .as_deref() + .zip(action_process_tokens.as_deref()) + .is_some_and(|(before, tokens)| { + claude_action_process_observation_is_exact( + before, + action_process_incarnation_after.as_deref(), + tokens, + ) + }); + let action_process_incarnation = (action_binding_confirmed && exact_process_observation) + .then_some(action_process_incarnation_candidate) + .flatten(); + // Skip sessions whose PID is a descendant of abtop itself — those are + // the `claude --print` summary children spawned by `generate_summary`. + if process::is_descendant_of(sf.pid, ctx.self_pid, process_info) { + return None; + } // Resolve the project dir that actually holds this session's // transcripts. For worktree sessions the on-disk dir does not match @@ -339,27 +812,11 @@ impl ClaudeCollector { } } - let proc_cmd = process_info.get(&sf.pid).map(|p| p.command.as_str()); - let pid_alive = proc_cmd - .map(|c| process::cmd_has_binary(c, "claude")) - .unwrap_or(false); - - // Skip sessions whose PID is a descendant of abtop itself — - // those are the `claude --print` summary children spawned by - // `generate_summary` in app.rs. User-launched non-interactive - // sessions (`claude --print` in another shell) are NOT filtered. - // Only checked while the process is alive (ppid visible); dead - // sessions are cleaned up when the session file disappears. - if process::is_descendant_of(sf.pid, ctx.self_pid, process_info) { - return None; - } - let project_name = process::last_path_segment(&sf.cwd) .unwrap_or("?") .to_string(); - let proc = process_info.get(&sf.pid); - let mem_mb = proc.map(|p| p.rss_kb / 1024).unwrap_or(0); + let mem_mb = proc.rss_kb / 1024; // Use the already-resolved project_dir so a post-/clear sid lookup // lands in the same (possibly worktree) directory as the original. @@ -385,19 +842,33 @@ impl ClaudeCollector { cached.as_ref().map(|c| c.new_offset).unwrap_or(0) }; - let (initial_context_tokens, initial_cache_read) = if from_offset > 0 { + let ( + initial_context_tokens, + initial_cache_read, + initial_lifecycle, + initial_lifecycle_at_ms, + ) = if from_offset > 0 { cached .as_ref() - .map(|c| (c.last_context_tokens, c.prev_cache_read)) - .unwrap_or((0, 0)) + .map(|c| { + ( + c.last_context_tokens, + c.prev_cache_read, + c.lifecycle, + c.lifecycle_at_ms, + ) + }) + .unwrap_or((0, 0, ClaudeTranscriptLifecycle::Unknown, 0)) } else { - (0, 0) + (0, 0, ClaudeTranscriptLifecycle::Unknown, 0) }; let delta = parse_transcript_with_previous( tp, from_offset, initial_context_tokens, initial_cache_read, + initial_lifecycle, + initial_lifecycle_at_ms, ); if let Some(mut prev) = cached { @@ -458,6 +929,10 @@ impl ClaudeCollector { prev.last_assistant_ts_ms = delta.last_assistant_ts_ms; prev.last_user_ts_ms = delta.last_user_ts_ms; } + if delta.saw_lifecycle_record { + prev.lifecycle = delta.lifecycle; + prev.lifecycle_at_ms = delta.lifecycle_at_ms; + } if prev.initial_prompt.is_empty() && !delta.initial_prompt.is_empty() { prev.initial_prompt = delta.initial_prompt; } @@ -505,6 +980,9 @@ impl ClaudeCollector { last_assistant_ts_ms: 0, last_user_ts_ms: 0, saw_turn: false, + saw_lifecycle_record: false, + lifecycle: ClaudeTranscriptLifecycle::Unknown, + lifecycle_at_ms: 0, file_accesses: Vec::new(), }; let cached = self @@ -532,10 +1010,6 @@ impl ClaudeCollector { let tool_calls = cached.tool_calls.clone(); let file_accesses = cached.file_accesses.clone(); - if !pid_alive { - return None; - } - // Derive the project directory from the transcript path (handles worktree sessions), // falling back to the encoded cwd. let project_dir = transcript_path @@ -546,62 +1020,141 @@ impl ClaudeCollector { // Collect subagents before deriving the parent status so asynchronous // Agent work keeps the parent active after its tool_result has returned. let subagents_dir = project_dir.join(&sf.session_id).join("subagents"); - let subagents = Self::collect_subagents(&subagents_dir); + let subagent_collection = Self::collect_subagents(&subagents_dir); + let subagents = subagent_collection.agents; let has_working_subagent = subagents.iter().any(|agent| agent.status == "working"); - - // Status is best-effort. Signals we trust: - // 1. Active descendant CPU → tool is running. - // 2. current_task non-empty → latest assistant turn left a - // tool_use unanswered. Catches I/O-bound tools (Read, Edit) - // whose descendants stay under 5% CPU, so the CPU heuristic - // alone would flicker to Waiting while the tool runs. - // 3. A working subagent → an async Agent call is still running even - // though its tool_result has already returned to the parent. - // 4. last_user_ts_ms > 0 → trailing transcript line is a real - // user prompt with no assistant reply yet, so the model is - // generating. tool_result wrappers are skipped at the - // parser level so this only fires for actual prompts. - // - // We drop the mtime freshness gate intentionally: Claude Code - // writes the assistant turn atomically when it lands, so during - // a long streamed reply the file isn't touched and mtime would - // go stale. Without the gate the status now matches the live - // "Think" row in the timeline (both keyed off last_user_ts_ms), - // and an idle session can't get stuck Thinking because the - // tool_result skip means last_user only flips on real prompts. - let status = { - let has_active_descendant = - process::has_active_descendant(sf.pid, children_map, process_info, 5.0); - // Non-empty current_task = latest assistant turn left a tool_use - // unanswered. Catches fast tools (`Bash rm ...`) that finish - // between CPU samples, so has_active_descendant alone misses them. - let pending_tool = !cached.current_task.is_empty(); - let model_generating = cached.last_user_ts_ms > 0; - if has_active_descendant || pending_tool || has_working_subagent { - SessionStatus::Executing - } else if model_generating { - SessionStatus::Thinking - } else { - SessionStatus::Waiting + let has_unknown_subagent = subagent_collection.incomplete + || subagents.iter().any(|agent| agent.status == "unknown"); + + // `current_task` is a cached display label. It is only an exact open + // tool while the durable transcript lifecycle remains `ToolOpen`; a + // subsequent user/tool_result record changes that lifecycle even when + // either record omitted a timestamp. + let pending_tool = !cached.current_task.is_empty() + && cached.lifecycle == ClaudeTranscriptLifecycle::ToolOpen; + let decision_tool = cached + .current_task + .split_once(' ') + .map_or(cached.current_task.as_str(), |(tool, _)| tool); + let pending_decision_reason = if pending_tool { + match decision_tool { + "ExitPlanMode" => Some(StatusReason::ProviderWaitingApproval), + "AskUserQuestion" => Some(StatusReason::ProviderWaitingUserInput), + _ => None, } + } else { + None + }; + let pending_decision = pending_decision_reason.is_some(); + let has_active_descendant = + process::has_active_descendant(sf.pid, children_map, process_info, 5.0); + let model_generating = cached.last_user_ts_ms > 0; + let arbitration = arbitrate_claude_native_status( + registry_state.status, + registry_state.transition_at_ms, + durable_claude_transcript_status(cached.lifecycle, pending_decision), + cached.saw_lifecycle_record, + cached.lifecycle_at_ms, + unix_now_ms(), + ); + let effective_native_status = match arbitration { + ClaudeLifecycleArbitration::Native(status) => Some(status), + ClaudeLifecycleArbitration::Transcript | ClaudeLifecycleArbitration::Conflict => None, + }; + let mut status = if arbitration == ClaudeLifecycleArbitration::Conflict { + SessionStatus::Unknown + } else { + classify_claude_status( + effective_native_status, + pending_decision, + pending_tool, + has_active_descendant, + has_working_subagent, + model_generating, + cached.lifecycle == ClaudeTranscriptLifecycle::Terminal, + ) + }; + let unknown_subagent_blocks_idle = has_unknown_subagent && status == SessionStatus::Idle; + if unknown_subagent_blocks_idle { + status = SessionStatus::Unknown; + } + let awaiting_input = matches!(status, SessionStatus::Waiting); + let evidence_since_ms = if matches!(arbitration, ClaudeLifecycleArbitration::Native(_)) { + registry_state.transition_at_ms + } else if pending_tool + || model_generating + || cached.lifecycle == ClaudeTranscriptLifecycle::Terminal + { + cached.lifecycle_at_ms + } else { + 0 + }; + let status_evidence = if arbitration == ClaudeLifecycleArbitration::Conflict + || unknown_subagent_blocks_idle + { + let observed_at_ms = unix_now_ms(); + let mut evidence = StatusEvidence::default(); + evidence.observe(StatusObservation::new( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::ProtocolUnknown, + observed_at_ms, + 0, + )); + evidence + } else { + claude_status_evidence( + status, + effective_native_status, + pending_decision_reason, + pending_tool, + has_active_descendant, + has_working_subagent, + model_generating, + cached.lifecycle == ClaudeTranscriptLifecycle::Terminal, + registry_state.status_failure, + evidence_since_ms, + ) }; let configured_model = read_configured_model(&sf.cwd); - let context_window = crate::collector::context_window_for_model(&model, &configured_model, max_context_tokens); + let context_window = crate::collector::context_window_for_model( + &model, + &configured_model, + max_context_tokens, + ); let context_percent = if context_window > 0 { (last_context_tokens as f64 / context_window as f64) * 100.0 } else { 0.0 }; - let current_tasks = if !current_task.is_empty() { - vec![current_task] - } else if !pid_alive { - vec!["finished".to_string()] - } else if matches!(status, SessionStatus::Waiting) { - vec!["waiting for input".to_string()] + let current_tasks = match &status { + SessionStatus::Waiting => vec!["waiting for user input".to_string()], + SessionStatus::Executing if pending_tool => vec![current_task], + SessionStatus::Executing => vec!["executing".to_string()], + SessionStatus::Working => vec!["working".to_string()], + SessionStatus::Thinking => vec!["thinking".to_string()], + SessionStatus::Idle => vec!["idle".to_string()], + SessionStatus::Unknown => vec!["unknown".to_string()], + SessionStatus::RateLimited => vec!["rate limited".to_string()], + SessionStatus::Error => vec!["error".to_string()], + SessionStatus::Done => vec!["finished".to_string()], + }; + + let pending_since_ms = if matches!(status, SessionStatus::Executing) && pending_tool { + cached.last_assistant_ts_ms + } else { + 0 + }; + let thinking_since_ms = if matches!(status, SessionStatus::Thinking) { + if cached.last_user_ts_ms > 0 { + cached.last_user_ts_ms + } else { + registry_state.transition_at_ms + } } else { - vec!["thinking...".to_string()] + 0 }; let mut children = Vec::new(); @@ -643,11 +1196,13 @@ impl ClaudeCollector { Some(AgentSession { agent_cli: "claude", pid: sf.pid, + action_process_incarnation, session_id: sf.session_id, cwd: sf.cwd, project_name, started_at: sf.started_at, status, + status_evidence, model, effort, context_percent, @@ -674,49 +1229,74 @@ impl ClaudeCollector { first_assistant_text, chat_messages, tool_calls, - pending_since_ms: cached.last_assistant_ts_ms, - thinking_since_ms: cached.last_user_ts_ms, + pending_since_ms, + awaiting_input, + thinking_since_ms, file_accesses, config_root: super::abbrev_path(&config.base_dir()), }) } - fn collect_subagents(subagents_dir: &Path) -> Vec { - let mut subagents = Vec::new(); + fn collect_subagents(subagents_dir: &Path) -> ClaudeSubagentCollection { + let mut collection = ClaudeSubagentCollection::default(); let entries = match fs::read_dir(subagents_dir) { Ok(e) => e, - Err(_) => return subagents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return collection, + Err(_) => { + collection.incomplete = true; + return collection; + } }; // Collect meta files and their corresponding jsonl files let mut meta_files: Vec = Vec::new(); - for entry in entries.flatten() { - if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(true) { + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + collection.incomplete = true; + continue; + } + }; + let path = entry.path(); + let is_meta = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".meta.json")); + if !is_meta { continue; } - let path = entry.path(); - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - if name.ends_with(".meta.json") { - meta_files.push(path); - } + if entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(true) { + collection.incomplete = true; + continue; } + meta_files.push(path); } for meta_path in meta_files { let meta_name = match meta_path.file_name().and_then(|n| n.to_str()) { Some(n) => n.to_string(), - None => continue, + None => { + collection.incomplete = true; + continue; + } }; // Parse meta JSON let meta_content = match fs::read_to_string(&meta_path) { Ok(c) => c, - Err(_) => continue, + Err(_) => { + collection.incomplete = true; + continue; + } }; let meta_val: Value = match serde_json::from_str(&meta_content) { Ok(v) => v, - Err(_) => continue, + Err(_) => { + collection.incomplete = true; + continue; + } }; let description = meta_val @@ -730,46 +1310,40 @@ impl ClaudeCollector { let jsonl_path = meta_path.with_file_name(&jsonl_name); let mut tokens = 0u64; - let mut last_activity = std::time::UNIX_EPOCH; - - if jsonl_path.exists() { - // Get file mtime for status - if let Ok(metadata) = fs::metadata(&jsonl_path) { - if let Ok(mtime) = metadata.modified() { - last_activity = mtime; - } - } + let mut lifecycle = ClaudeTranscriptLifecycle::Unknown; - // Parse jsonl for token totals + if jsonl_path.exists() && !is_symlink(&jsonl_path) { + // The transcript is already scanned for token totals, so fold + // its exact final lifecycle at the same time. File age is not + // a lifecycle signal: a fresh `end_turn` is complete, while an + // old unresolved tool/model turn is still active. let transcript = parse_transcript(&jsonl_path, 0); tokens = transcript.total_input + transcript.total_output + transcript.total_cache_read + transcript.total_cache_create; + lifecycle = transcript.lifecycle; } - let status = { - let since = std::time::SystemTime::now() - .duration_since(last_activity) - .unwrap_or_default(); - if since.as_secs() < 30 { - "working".to_string() - } else { - "done".to_string() + let status = match lifecycle { + ClaudeTranscriptLifecycle::ModelOpen | ClaudeTranscriptLifecycle::ToolOpen => { + "working" } + ClaudeTranscriptLifecycle::Terminal => "done", + ClaudeTranscriptLifecycle::Unknown => "unknown", }; // Use description as name, shorten if needed let name = truncate(&description, 30); - subagents.push(SubAgent { + collection.agents.push(SubAgent { name, - status, + status: status.to_string(), tokens, }); } - subagents + collection } fn collect_memory_status(memory_dir: &Path) -> (u32, u32) { @@ -1034,27 +1608,47 @@ struct DiscoveryContext { /// abtop's own PID, threaded through so `load_session` can self-filter /// without growing an extra arg. Set by `build_discovery_context`. self_pid: u32, + /// Per-registry-file process-incarnation result. This is path-scoped + /// because custom Claude roots can contain different stale rows for the + /// same numeric PID. + session_incarnation_valid: HashMap, } fn build_discovery_context( session_paths: &[(PathBuf, ConfigDir)], process_info: &HashMap, self_pid: u32, +) -> DiscoveryContext { + build_discovery_context_with_start_lookup( + session_paths, + process_info, + self_pid, + process::get_process_started_at_ms, + ) +} + +fn build_discovery_context_with_start_lookup( + session_paths: &[(PathBuf, ConfigDir)], + process_info: &HashMap, + self_pid: u32, + mut live_start_for_pid: impl FnMut(u32) -> Option, ) -> DiscoveryContext { let mut claimed_sids_by_pid: HashMap = HashMap::new(); let mut pids_per_cwd: HashMap = HashMap::new(); + let mut session_incarnation_valid: HashMap = HashMap::new(); let mut seen_pids: std::collections::HashSet = std::collections::HashSet::new(); for (path, _) in session_paths { let Ok(content) = fs::read_to_string(path) else { continue; }; - let Ok(mut sf) = serde_json::from_str::(&content) else { + let Ok(value) = serde_json::from_str::(&content) else { continue; }; - sf.sanitize(); - if !seen_pids.insert(sf.pid) { + let registry_state = ClaudeRegistryState::parse(&value); + let Ok(mut sf) = serde_json::from_value::(value) else { continue; - } + }; + sf.sanitize(); // Only count PIDs that are alive AND actually claude AND not // descended from abtop itself. Stale `sessions/{PID}.json` files // (crashed sessions) and abtop's own `claude --print` summary @@ -1070,6 +1664,19 @@ fn build_discovery_context( if process::is_descendant_of(sf.pid, self_pid, process_info) { continue; } + // Always resolve the exact OS process start. Legacy rows without a + // provider `procStart` are not exempt: if the OS observation is + // unavailable, PID ownership cannot be established safely. + let live_started_at_ms = live_start_for_pid(sf.pid); + let incarnation_valid = claude_process_incarnation_matches( + registry_state.process_start, + sf.started_at, + live_started_at_ms, + ); + session_incarnation_valid.insert(path.clone(), incarnation_valid); + if !incarnation_valid || !seen_pids.insert(sf.pid) { + continue; + } *pids_per_cwd.entry(sf.cwd.clone()).or_insert(0) += 1; claimed_sids_by_pid.insert(sf.pid, sf.session_id); } @@ -1077,6 +1684,7 @@ fn build_discovery_context( claimed_sids_by_pid, pids_per_cwd, self_pid, + session_incarnation_valid, } } @@ -1240,7 +1848,8 @@ struct TranscriptResult { chat_messages: Vec, /// Tool call timeline extracted from transcript. tool_calls: Vec, - /// Timestamp of the last assistant turn (epoch ms), used to compute tool duration. + /// Timestamp of the unresolved assistant tool turn (epoch ms). A non-empty + /// `current_task` is only live while this marker remains nonzero. last_assistant_ts_ms: u64, /// Timestamp (epoch ms) of the most recent `user` line that has not been /// followed by an assistant turn. Zero when the latest entry was an @@ -1251,10 +1860,29 @@ struct TranscriptResult { /// must not overwrite cached state - otherwise a no-new-data tick /// would clear the live pending/thinking markers. saw_turn: bool, + /// This parse observed a record (or malformed tail) that can change the + /// current lifecycle. Unlike `saw_turn`, this remains true for explicit + /// Unknown so incremental parsing does not retain stale terminal state. + saw_lifecycle_record: bool, + /// Exact lifecycle of the newest durable transcript turn. This is used + /// for subagents, which do not have their own native session registry. + lifecycle: ClaudeTranscriptLifecycle, + /// Provider timestamp of the durable record that established `lifecycle`. + /// Zero means the sources cannot be ordered safely. + lifecycle_at_ms: u64, /// File access audit log extracted from tool_use entries. file_accesses: Vec, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum ClaudeTranscriptLifecycle { + #[default] + Unknown, + ModelOpen, + ToolOpen, + Terminal, +} + /// Check if a path is a symlink without following it. /// Defaults to true on error (fail-closed: skip if we can't verify). fn is_symlink(path: &Path) -> bool { @@ -1290,7 +1918,14 @@ fn file_identity(path: &Path) -> (u64, u64) { } fn parse_transcript(path: &Path, from_offset: u64) -> TranscriptResult { - parse_transcript_with_previous(path, from_offset, 0, 0) + parse_transcript_with_previous( + path, + from_offset, + 0, + 0, + ClaudeTranscriptLifecycle::Unknown, + 0, + ) } fn parse_transcript_with_previous( @@ -1298,6 +1933,8 @@ fn parse_transcript_with_previous( from_offset: u64, initial_context_tokens: u64, initial_cache_read: u64, + initial_lifecycle: ClaudeTranscriptLifecycle, + initial_lifecycle_at_ms: u64, ) -> TranscriptResult { let identity = file_identity(path); let mut result = TranscriptResult { @@ -1326,6 +1963,9 @@ fn parse_transcript_with_previous( last_assistant_ts_ms: 0, last_user_ts_ms: 0, saw_turn: false, + saw_lifecycle_record: false, + lifecycle: initial_lifecycle, + lifecycle_at_ms: initial_lifecycle_at_ms, file_accesses: Vec::new(), }; @@ -1346,6 +1986,10 @@ fn parse_transcript_with_previous( } else { from_offset }; + if effective_offset == 0 { + result.lifecycle = ClaudeTranscriptLifecycle::Unknown; + result.lifecycle_at_ms = 0; + } let from_offset = effective_offset; let mut prev_context_tokens = if from_offset > 0 { initial_context_tokens @@ -1388,6 +2032,9 @@ fn parse_transcript_with_previous( // Cap hit without a newline — malformed/hostile line. Skip // to end of file; we'll re-evaluate when file_identity changes. if line_buf.len() > MAX_LINE_BYTES && !line_buf.ends_with('\n') { + result.lifecycle = ClaudeTranscriptLifecycle::Unknown; + result.lifecycle_at_ms = 0; + result.saw_lifecycle_record = true; bytes_read = file_len; break; } @@ -1405,6 +2052,13 @@ fn parse_transcript_with_previous( let val = match serde_json::from_str::(line) { Ok(v) => v, Err(_) => { + // The newest durable/partial record is not a lifecycle + // signal we can interpret. Subagents must become + // Unknown rather than inheriting an older terminal or + // active state from file age. + result.lifecycle = ClaudeTranscriptLifecycle::Unknown; + result.lifecycle_at_ms = 0; + result.saw_lifecycle_record = true; if has_newline { // Complete line but invalid JSON — skip it bytes_read += n as u64; @@ -1428,6 +2082,9 @@ fn parse_transcript_with_previous( match val.get("type").and_then(|t| t.as_str()) { Some("assistant") => { + result.lifecycle = ClaudeTranscriptLifecycle::Unknown; + result.lifecycle_at_ms = entry_ts_ms; + result.saw_lifecycle_record = true; result.turn_count += 1; // Clear previous task on each new turn so stale tasks // don't persist when latest turn has no tool_use @@ -1585,30 +2242,60 @@ fn parse_transcript_with_previous( if entry_ts_ms > 0 && has_tool_use { result.last_assistant_ts_ms = entry_ts_ms; } + result.lifecycle = + match msg.get("stop_reason").and_then(Value::as_str) { + Some("tool_use") => ClaudeTranscriptLifecycle::ToolOpen, + Some("end_turn") => ClaudeTranscriptLifecycle::Terminal, + Some(_) => ClaudeTranscriptLifecycle::Unknown, + None if has_tool_use => ClaudeTranscriptLifecycle::ToolOpen, + // A JSON assistant record without a stop reason may + // be a streaming/partial record. Even when it follows + // an exact open model turn, it is not proof that the + // model reached a quiescent terminal state. + None => ClaudeTranscriptLifecycle::Unknown, + }; + result.lifecycle_at_ms = entry_ts_ms; // Any assistant turn closes the prior "thinking" window. result.last_user_ts_ms = 0; result.saw_turn = true; } } Some("user") => { - // Compute tool call duration: time from assistant turn to this user turn - if entry_ts_ms > 0 && result.last_assistant_ts_ms > 0 { - let duration = - entry_ts_ms.saturating_sub(result.last_assistant_ts_ms); - // Distribute duration across tool calls from that assistant turn - // (approximation: divide equally among pending zero-duration calls) - let pending: Vec = result - .tool_calls - .iter() - .enumerate() - .rev() - .take_while(|(_, tc)| tc.duration_ms == 0) - .map(|(i, _)| i) - .collect(); - if !pending.is_empty() { - let per_call = duration / pending.len() as u64; - for idx in pending { - result.tool_calls[idx].duration_ms = per_call; + // User-role records have two independent meanings: + // whether they are real prompts, and whether they + // carry lifecycle evidence. Tool-result wrappers + // are synthetic prompts but still close an open + // tool lifecycle. Pure local/meta command records + // do neither, so they must not compete with the + // native registry during status arbitration. + let tool_result = is_tool_result_user_msg(&val); + let synthetic = is_synthetic_user_msg(&val); + if !synthetic || tool_result { + result.saw_lifecycle_record = true; + } + // Any following user record closes the preceding + // tool turn, including a tool_result without a + // usable timestamp. The timestamp is optional for + // duration accounting, not for lifecycle closure. + if result.last_assistant_ts_ms > 0 { + if entry_ts_ms > 0 { + let duration = + entry_ts_ms.saturating_sub(result.last_assistant_ts_ms); + // Distribute duration across tool calls from that assistant turn + // (approximation: divide equally among pending zero-duration calls) + let pending: Vec = result + .tool_calls + .iter() + .enumerate() + .rev() + .take_while(|(_, tc)| tc.duration_ms == 0) + .map(|(i, _)| i) + .collect(); + if !pending.is_empty() { + let per_call = duration / pending.len() as u64; + for idx in pending { + result.tool_calls[idx].duration_ms = per_call; + } } } result.last_assistant_ts_ms = 0; @@ -1626,7 +2313,17 @@ fn parse_transcript_with_previous( // the session in Thinking forever (e.g. /plugin // update flushes 3 user-role lines and no // assistant reply ever arrives to clear them). - let synthetic = is_synthetic_user_msg(&val); + if !synthetic { + result.lifecycle = ClaudeTranscriptLifecycle::ModelOpen; + result.lifecycle_at_ms = entry_ts_ms; + } else if tool_result { + // A tool result closes the preceding tool call, but the + // provider normally resumes model generation afterwards. + // Without a native status or a later explicit end_turn, + // quiescence is unproven and must fail closed. + result.lifecycle = ClaudeTranscriptLifecycle::Unknown; + result.lifecycle_at_ms = entry_ts_ms; + } if entry_ts_ms > 0 && !synthetic { result.last_user_ts_ms = entry_ts_ms; } @@ -1698,17 +2395,18 @@ fn parse_transcript_with_previous( /// invoke the model, so treating them as a prompt would leave /// `last_user_ts_ms` stuck and pin the session in Thinking forever. fn is_synthetic_user_msg(entry: &Value) -> bool { - if entry.get("isMeta").and_then(|v| v.as_bool()).unwrap_or(false) { + if entry + .get("isMeta") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { return true; } - let Some(message) = entry.get("message") else { return false }; + let Some(message) = entry.get("message") else { + return false; + }; match message.get("content") { - Some(Value::Array(arr)) => { - !arr.is_empty() - && arr.iter().all(|block| { - block.get("type").and_then(|t| t.as_str()) == Some("tool_result") - }) - } + Some(Value::Array(_)) => is_tool_result_user_msg(entry), Some(Value::String(s)) => { let t = s.trim_start(); t.starts_with("") @@ -1723,6 +2421,19 @@ fn is_synthetic_user_msg(entry: &Value) -> bool { } } +fn is_tool_result_user_msg(entry: &Value) -> bool { + entry + .get("message") + .and_then(|message| message.get("content")) + .and_then(Value::as_array) + .is_some_and(|blocks| { + !blocks.is_empty() + && blocks + .iter() + .all(|block| block.get("type").and_then(Value::as_str) == Some("tool_result")) + }) +} + fn push_chat_message(messages: &mut Vec, role: ChatRole, text: String) { if text.is_empty() { return; @@ -2026,48 +2737,583 @@ mod tests { use super::*; use std::io::Write; - fn write_lines(file: &mut tempfile::NamedTempFile, lines: &[&str]) { - for line in lines { - writeln!(file, "{}", line).unwrap(); - } - file.flush().unwrap(); - } - - fn write_session_file(path: &Path, pid: u32, session_id: &str, cwd: &Path) { - // Serialize via serde_json so Windows backslash paths are escaped - // correctly instead of producing invalid JSON. - std::fs::write( - path, - serde_json::json!({ - "pid": pid, - "sessionId": session_id, - "cwd": cwd.to_str().unwrap(), - "startedAt": 1774715116826u64, + /// Most collector unit tests use synthetic PIDs. Inject a plausible exact + /// process start so they exercise session parsing without weakening the + /// production lookup. Tests covering unavailable/reused incarnations call + /// `super::build_discovery_context` or the injected helper explicitly. + fn build_discovery_context( + session_paths: &[(PathBuf, ConfigDir)], + process_info: &HashMap, + self_pid: u32, + ) -> DiscoveryContext { + let recorded_starts: HashMap = session_paths + .iter() + .filter_map(|(path, _)| { + let value: Value = serde_json::from_str(&fs::read_to_string(path).ok()?).ok()?; + let session: SessionFile = serde_json::from_value(value.clone()).ok()?; + let state = ClaudeRegistryState::parse(&value); + let live_start = match state.process_start { + ClaudeRecordedProcessStart::Parsed { utc_ms, .. } => utc_ms, + ClaudeRecordedProcessStart::Missing => session.started_at.saturating_sub(1), + ClaudeRecordedProcessStart::Invalid => session.started_at.saturating_sub(1), + }; + Some((session.pid, live_start.max(1))) }) - .to_string(), + .collect(); + super::build_discovery_context_with_start_lookup( + session_paths, + process_info, + self_pid, + |pid| recorded_starts.get(&pid).copied(), ) - .unwrap(); } - fn write_transcript(projects: &Path, cwd: &Path, session_id: &str, prompt: &str) -> PathBuf { - let transcript_dir = projects.join(encode_cwd_path(cwd.to_str().unwrap())); - std::fs::create_dir_all(&transcript_dir).unwrap(); - let transcript = transcript_dir.join(format!("{}.jsonl", session_id)); - std::fs::write( - &transcript, - format!( - r#"{{"type":"user","timestamp":"2026-03-28T15:00:00Z","message":{{"role":"user","content":"{}"}}}} -{{"type":"assistant","timestamp":"2026-03-28T15:00:05Z","message":{{"model":"claude-sonnet-4-6","usage":{{"input_tokens":12,"output_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0}},"content":[{{"type":"text","text":"done"}}]}}}} -"#, - prompt - ), - ) - .unwrap(); - transcript + #[test] + fn test_registry_state_parses_only_valid_native_values() { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let old_transition = now_ms.saturating_sub(86_400_000); + let long_reason = "x".repeat(400); + let state = ClaudeRegistryState::parse(&serde_json::json!({ + "status": "waiting", + "waitingFor": long_reason, + "updatedAt": now_ms, + "statusUpdatedAt": old_transition, + })); + + assert_eq!(state.status, Some(ClaudeNativeStatus::Waiting)); + assert_eq!(state.status_failure, None); + assert_eq!(state.transition_at_ms, old_transition); + assert_eq!(state.waiting_for.as_ref().unwrap().chars().count(), 256); + + let future = now_ms.saturating_add(60_000); + let fallback = ClaudeRegistryState::parse(&serde_json::json!({ + "status": "Busy", + "waitingFor": 42, + "updatedAt": old_transition, + "statusUpdatedAt": future, + })); + assert_eq!(fallback.status, None, "native status matching is exact"); + assert_eq!(fallback.status_failure, Some(StatusReason::ProtocolUnknown)); + assert_eq!(fallback.waiting_for, None); + assert_eq!(fallback.transition_at_ms, old_transition); + + let malformed = ClaudeRegistryState::parse(&serde_json::json!({ + "status": ["idle"], + "updatedAt": -1, + "statusUpdatedAt": "now", + })); + assert_eq!(malformed.status, None); + assert_eq!( + malformed.status_failure, + Some(StatusReason::ProtocolMalformed) + ); + assert_eq!(malformed.transition_at_ms, 0); } - fn make_proc_info(pid: u32, command: &str) -> HashMap { - let mut process_info = HashMap::new(); + #[test] + fn test_proc_start_verifies_exact_process_incarnation() { + let parsed = parse_claude_process_start(Some(&Value::String( + "Sat Aug 1 17:22:17 2026".to_string(), + ))); + let recorded_ms = match parsed { + ClaudeRecordedProcessStart::Parsed { utc_ms, .. } => utc_ms, + other => panic!("expected parsed procStart, got {other:?}"), + }; + assert_eq!(recorded_ms, 1_785_604_937_000); + + assert!(claude_process_incarnation_matches( + parsed, + recorded_ms.saturating_add(1_000), + Some(recorded_ms.saturating_add(1_500)), + )); + assert!( + !claude_process_incarnation_matches( + parsed, + recorded_ms.saturating_add(1_000), + Some(recorded_ms.saturating_add(PROCESS_START_TOLERANCE_MS + 1)), + ), + "a later same-binary process must not inherit a stale registry row", + ); + assert!( + !claude_process_incarnation_matches(parsed, recorded_ms.saturating_add(1_000), None,), + "a present procStart must fail closed when the OS start is unavailable", + ); + } + + #[test] + fn test_action_binding_rejects_pid_reuse_around_exact_argv() { + let direct = vec!["/usr/local/bin/claude".to_string()]; + let wrapped = vec![ + "/usr/local/bin/node".to_string(), + "/opt/bin/claude".to_string(), + ]; + + assert!(claude_action_process_observation_is_exact( + "process-a", + Some("process-a"), + &direct, + )); + assert!(claude_action_process_observation_is_exact( + "process-a", + Some("process-a"), + &wrapped, + )); + assert!(!claude_action_process_observation_is_exact( + "process-a", + Some("process-b"), + &direct, + )); + assert!(!claude_action_process_observation_is_exact( + "process-a", + Some("process-a"), + &["/usr/local/bin/codex".to_string()], + )); + } + + #[test] + fn test_proc_start_malformed_fails_closed_and_legacy_ordering_is_bounded() { + for malformed in [ + Value::String("not a process start".to_string()), + Value::Null, + ] { + let recorded = parse_claude_process_start(Some(&malformed)); + assert_eq!(recorded, ClaudeRecordedProcessStart::Invalid); + assert!(!claude_process_incarnation_matches( + recorded, + 10_000, + Some(9_000), + )); + } + + assert!(claude_process_incarnation_matches( + ClaudeRecordedProcessStart::Missing, + 10_000, + Some(9_000), + )); + assert!(!claude_process_incarnation_matches( + ClaudeRecordedProcessStart::Missing, + 10_000, + Some(10_000 + PROCESS_START_TOLERANCE_MS + 1), + )); + assert!( + !claude_process_incarnation_matches(ClaudeRecordedProcessStart::Missing, 10_000, None,), + "legacy rows must fail closed when the exact OS start is unavailable" + ); + } + + #[test] + fn test_discovery_rejects_proc_start_when_live_incarnation_cannot_be_verified() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join(".claude"); + let sessions_dir = profile.join("sessions"); + let projects_dir = profile.join("projects"); + let cwd = temp.path().join("repo"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + std::fs::create_dir_all(&projects_dir).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let pid = u32::MAX - 17; + let sid = "unverifiable-proc-start"; + let session_path = sessions_dir.join(format!("{pid}.json")); + std::fs::write( + &session_path, + serde_json::json!({ + "pid": pid, + "sessionId": sid, + "cwd": cwd, + "startedAt": 1_785_604_938_015u64, + "procStart": "Sat Aug 1 17:22:17 2026", + }) + .to_string(), + ) + .unwrap(); + + let config = ConfigDir::new(profile); + let session_paths = vec![(session_path, config.clone())]; + let process_info = make_proc_info(pid, "claude"); + let ctx = super::build_discovery_context(&session_paths, &process_info, 0); + assert_eq!( + ctx.session_incarnation_valid.values().copied().next(), + Some(false) + ); + + let mut collector = ClaudeCollector::new(); + let loaded = collector.load_session_paths( + &session_paths, + &process_info, + &HashMap::new(), + &HashMap::new(), + &ctx, + ); + assert!(loaded.is_empty()); + } + + #[test] + fn test_claude_status_precedence_matrix() { + let classify = |native, decision, tool, child, subagent, model| { + classify_claude_status(native, decision, tool, child, subagent, model, false) + }; + + assert_eq!( + classify( + Some(ClaudeNativeStatus::Waiting), + true, + true, + true, + true, + true + ), + SessionStatus::Waiting, + "native waiting must win over concurrent work", + ); + assert_eq!( + classify( + Some(ClaudeNativeStatus::Shell), + false, + false, + false, + false, + false + ), + SessionStatus::Executing, + ); + assert_eq!( + classify( + Some(ClaudeNativeStatus::Idle), + true, + true, + true, + false, + true + ), + SessionStatus::Idle, + "native idle must close stale fallback signals", + ); + assert_eq!( + classify( + Some(ClaudeNativeStatus::Idle), + false, + false, + false, + true, + false + ), + SessionStatus::Executing, + "an exact asynchronous subagent remains active at the parent prompt", + ); + assert_eq!( + classify( + Some(ClaudeNativeStatus::Busy), + false, + false, + false, + false, + false + ), + SessionStatus::Thinking, + ); + for (tool, child, subagent) in [ + (true, false, false), + (false, true, false), + (false, false, true), + ] { + assert_eq!( + classify( + Some(ClaudeNativeStatus::Busy), + false, + tool, + child, + subagent, + false + ), + SessionStatus::Executing, + ); + } + + assert_eq!( + classify(None, true, true, true, true, true), + SessionStatus::Waiting, + "an exact fallback decision must win over ordinary work", + ); + assert_eq!( + classify(None, false, true, false, false, false), + SessionStatus::Executing, + ); + assert_eq!( + classify(None, false, false, false, false, true), + SessionStatus::Thinking, + ); + assert_eq!( + classify(None, false, false, false, false, false), + SessionStatus::Unknown, + "missing native and transcript lifecycle evidence must fail closed", + ); + assert_eq!( + classify_claude_status(None, false, false, false, false, false, true), + SessionStatus::Idle, + "an exact terminal transcript is sufficient provider idle evidence", + ); + } + + #[test] + fn newer_transcript_lifecycle_supersedes_every_stale_native_state() { + let now_ms = 1_000; + + let arbitration = arbitrate_claude_native_status( + Some(ClaudeNativeStatus::Idle), + 100, + Some(SessionStatus::Waiting), + true, + 200, + now_ms, + ); + assert_eq!(arbitration, ClaudeLifecycleArbitration::Transcript); + assert_eq!( + classify_claude_status(None, true, true, false, false, false, false), + SessionStatus::Waiting, + "a newer durable question must not be hidden by stale native idle", + ); + + for native in [ + ClaudeNativeStatus::Waiting, + ClaudeNativeStatus::Busy, + ClaudeNativeStatus::Shell, + ] { + assert_eq!( + arbitrate_claude_native_status( + Some(native), + 300, + Some(SessionStatus::Idle), + true, + 400, + now_ms, + ), + ClaudeLifecycleArbitration::Transcript, + "native={native:?}", + ); + } + } + + #[test] + fn unorderable_conflicting_claude_lifecycles_are_unknown() { + for transcript_at_ms in [0, 1_001] { + assert_eq!( + arbitrate_claude_native_status( + Some(ClaudeNativeStatus::Waiting), + 100, + Some(SessionStatus::Idle), + true, + transcript_at_ms, + 1_000, + ), + ClaudeLifecycleArbitration::Conflict, + "timestamp={transcript_at_ms}", + ); + } + assert_eq!( + arbitrate_claude_native_status( + Some(ClaudeNativeStatus::Waiting), + 0, + Some(SessionStatus::Waiting), + true, + 0, + 1_000, + ), + ClaudeLifecycleArbitration::Native(ClaudeNativeStatus::Waiting), + "compatible unorderable states do not conflict", + ); + assert_eq!( + arbitrate_claude_native_status( + Some(ClaudeNativeStatus::Waiting), + 100, + None, + false, + 200, + 1_000, + ), + ClaudeLifecycleArbitration::Native(ClaudeNativeStatus::Waiting), + "an unknown transcript lifecycle is not competing evidence", + ); + assert_eq!( + arbitrate_claude_native_status( + Some(ClaudeNativeStatus::Waiting), + 200, + Some(SessionStatus::Idle), + true, + 100, + 1_000, + ), + ClaudeLifecycleArbitration::Native(ClaudeNativeStatus::Waiting), + "the newer exact native state wins", + ); + assert_eq!( + arbitrate_claude_native_status( + Some(ClaudeNativeStatus::Idle), + 100, + None, + true, + 200, + 1_000, + ), + ClaudeLifecycleArbitration::Conflict, + "a newer explicit Unknown transcript record must not inherit native idle", + ); + assert_eq!( + arbitrate_claude_native_status( + Some(ClaudeNativeStatus::Busy), + 100, + Some(SessionStatus::Idle), + true, + 100, + 1_000, + ), + ClaudeLifecycleArbitration::Conflict, + "equal timestamps do not order conflicting states", + ); + } + + #[test] + fn test_claude_status_evidence_distinguishes_provider_and_fallback_sources() { + let native_wait = claude_status_evidence( + SessionStatus::Waiting, + Some(ClaudeNativeStatus::Waiting), + None, + false, + true, + true, + false, + false, + None, + 123, + ); + assert_eq!(native_wait.authority, StatusAuthority::Provider); + assert_eq!(native_wait.reason, StatusReason::ProviderWaitingUserInput); + assert_eq!(native_wait.status_since_ms, 123); + + let plan_approval = claude_status_evidence( + SessionStatus::Waiting, + None, + Some(StatusReason::ProviderWaitingApproval), + true, + true, + false, + false, + false, + None, + 456, + ); + assert_eq!(plan_approval.authority, StatusAuthority::Provider); + assert_eq!(plan_approval.reason, StatusReason::ProviderWaitingApproval); + + let process_child = claude_status_evidence( + SessionStatus::Executing, + None, + None, + false, + true, + false, + false, + false, + None, + 0, + ); + assert_eq!(process_child.authority, StatusAuthority::Heuristic); + assert_eq!(process_child.reason, StatusReason::BackgroundTerminalActive); + + let exact_subagent = claude_status_evidence( + SessionStatus::Executing, + None, + None, + false, + false, + true, + false, + false, + None, + 0, + ); + assert_eq!(exact_subagent.authority, StatusAuthority::Provider); + assert_eq!(exact_subagent.reason, StatusReason::ProviderExecuting); + + let terminal = claude_status_evidence( + SessionStatus::Idle, + None, + None, + false, + false, + false, + false, + true, + None, + 0, + ); + assert_eq!(terminal.authority, StatusAuthority::Provider); + assert_eq!(terminal.reason, StatusReason::ProviderIdle); + + let unavailable_lifecycle = claude_status_evidence( + SessionStatus::Unknown, + None, + None, + false, + false, + false, + false, + false, + None, + 0, + ); + assert_eq!( + unavailable_lifecycle.authority, + StatusAuthority::Unavailable + ); + assert_eq!(unavailable_lifecycle.reason, StatusReason::Unavailable); + } + + fn write_lines(file: &mut tempfile::NamedTempFile, lines: &[&str]) { + for line in lines { + writeln!(file, "{}", line).unwrap(); + } + file.flush().unwrap(); + } + + fn write_session_file(path: &Path, pid: u32, session_id: &str, cwd: &Path) { + // Serialize via serde_json so Windows backslash paths are escaped + // correctly instead of producing invalid JSON. + std::fs::write( + path, + serde_json::json!({ + "pid": pid, + "sessionId": session_id, + "cwd": cwd.to_str().unwrap(), + "startedAt": 1774715116826u64, + }) + .to_string(), + ) + .unwrap(); + } + + fn write_transcript(projects: &Path, cwd: &Path, session_id: &str, prompt: &str) -> PathBuf { + let transcript_dir = projects.join(encode_cwd_path(cwd.to_str().unwrap())); + std::fs::create_dir_all(&transcript_dir).unwrap(); + let transcript = transcript_dir.join(format!("{}.jsonl", session_id)); + std::fs::write( + &transcript, + format!( + r#"{{"type":"user","timestamp":"2026-03-28T15:00:00Z","message":{{"role":"user","content":"{}"}}}} +{{"type":"assistant","timestamp":"2026-03-28T15:00:05Z","message":{{"model":"claude-sonnet-4-6","stop_reason":"end_turn","usage":{{"input_tokens":12,"output_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0}},"content":[{{"type":"text","text":"done"}}]}}}} +"#, + prompt + ), + ) + .unwrap(); + transcript + } + + fn make_proc_info(pid: u32, command: &str) -> HashMap { + let mut process_info = HashMap::new(); process_info.insert( pid, ProcInfo { @@ -2146,6 +3392,62 @@ mod tests { assert!(result.new_offset > 0, "non-turn lines still advance offset"); } + #[test] + fn test_parse_transcript_pure_local_user_records_are_not_lifecycle_evidence() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","isMeta":true,"message":{"role":"user","content":"Caveat"}}"#, + r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","message":{"role":"user","content":"/plugin\nupdate foo"}}"#, + r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","message":{"role":"user","content":"Updated foo"}}"#, + ], + ); + + let result = parse_transcript(file.path(), 0); + + assert!(result.saw_turn); + assert!( + !result.saw_lifecycle_record, + "pure local/meta records must not compete with native lifecycle evidence", + ); + assert_eq!(result.lifecycle, ClaudeTranscriptLifecycle::Unknown); + assert_eq!(result.lifecycle_at_ms, 0); + } + + #[test] + fn test_parse_transcript_pure_local_delta_preserves_terminal_lifecycle() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + r#"{"type":"assistant","timestamp":"2026-03-28T15:00:00Z","message":{"model":"claude-sonnet-4-6","stop_reason":"end_turn","content":[{"type":"text","text":"done"}]}}"#, + ], + ); + let previous_offset = std::fs::metadata(file.path()).unwrap().len(); + write_lines( + &mut file, + &[ + r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","message":{"role":"user","content":"/plugin"}}"#, + r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","message":{"role":"user","content":"Updated"}}"#, + ], + ); + let terminal_at_ms = 1_774_710_000_000; + + let result = parse_transcript_with_previous( + file.path(), + previous_offset, + 0, + 0, + ClaudeTranscriptLifecycle::Terminal, + terminal_at_ms, + ); + + assert!(!result.saw_lifecycle_record); + assert_eq!(result.lifecycle, ClaudeTranscriptLifecycle::Terminal); + assert_eq!(result.lifecycle_at_ms, terminal_at_ms); + } + #[test] fn test_parse_transcript_tool_result_does_not_open_thinking_window() { // Regression: status used to flicker Think ↔ Wait during tool @@ -2169,6 +3471,10 @@ mod tests { let result = parse_transcript(file.path(), 0); assert!(result.saw_turn); + assert!( + result.saw_lifecycle_record, + "a tool_result must remain exact lifecycle evidence", + ); // After the tool_result line, last_user_ts_ms must STILL be 0 // — the assistant turn at 15:00:01 cleared it, and the // tool_result wrapper at 15:00:02 must not re-open the window. @@ -2176,6 +3482,36 @@ mod tests { result.last_user_ts_ms, 0, "tool_result user-role line must not reopen the thinking window", ); + assert_eq!( + result.last_assistant_ts_ms, 0, + "tool_result must close the exact pending-tool marker", + ); + assert_eq!( + result.current_task, "Bash ls", + "the cached display label may remain after the tool is closed", + ); + assert_eq!( + result.lifecycle, + ClaudeTranscriptLifecycle::Unknown, + "tool_result closes execution but does not prove the resumed model turn is quiescent", + ); + } + + #[test] + fn test_parse_transcript_assistant_without_stop_reason_is_not_terminal() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + r#"{"type":"user","timestamp":"2026-03-28T15:00:00Z","message":{"role":"user","content":"hi"}}"#, + r#"{"type":"assistant","timestamp":"2026-03-28T15:00:01Z","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"partial"}]}}"#, + ], + ); + + let result = parse_transcript(file.path(), 0); + + assert_eq!(result.last_user_ts_ms, 0); + assert_eq!(result.lifecycle, ClaudeTranscriptLifecycle::Unknown); } #[test] @@ -2216,6 +3552,10 @@ mod tests { let result = parse_transcript(file.path(), 0); assert!(result.saw_turn); + assert!( + result.saw_lifecycle_record, + "a real prompt must remain exact lifecycle evidence", + ); assert!(result.last_user_ts_ms > 0); assert_eq!(result.last_assistant_ts_ms, 0); } @@ -2276,15 +3616,18 @@ mod tests { // forever (Think generating reply). All three line shapes must be // treated as synthetic. let mut file = tempfile::NamedTempFile::new().unwrap(); - write_lines(&mut file, &[ - // Real prompt + assistant reply to seed a clean Wait state. - r#"{"type":"user","timestamp":"2026-03-28T15:00:00Z","message":{"role":"user","content":"hi"}}"#, - r#"{"type":"assistant","timestamp":"2026-03-28T15:00:01Z","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"hello"}]}}"#, - // The three user-role lines `/plugin update` writes: - r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","isMeta":true,"message":{"role":"user","content":"Caveat: ..."}}"#, - r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","message":{"role":"user","content":"/plugin\nupdate foo"}}"#, - r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","message":{"role":"user","content":"Updated foo"}}"#, - ]); + write_lines( + &mut file, + &[ + // Real prompt + assistant reply to seed a clean Wait state. + r#"{"type":"user","timestamp":"2026-03-28T15:00:00Z","message":{"role":"user","content":"hi"}}"#, + r#"{"type":"assistant","timestamp":"2026-03-28T15:00:01Z","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"hello"}]}}"#, + // The three user-role lines `/plugin update` writes: + r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","isMeta":true,"message":{"role":"user","content":"Caveat: ..."}}"#, + r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","message":{"role":"user","content":"/plugin\nupdate foo"}}"#, + r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","message":{"role":"user","content":"Updated foo"}}"#, + ], + ); let result = parse_transcript(file.path(), 0); @@ -2302,11 +3645,14 @@ mod tests { // `!ls` and friends serialize as / user-role // lines with no assistant reply. Same failure mode as /plugin. let mut file = tempfile::NamedTempFile::new().unwrap(); - write_lines(&mut file, &[ - r#"{"type":"assistant","timestamp":"2026-03-28T15:00:00Z","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"ok"}]}}"#, - r#"{"type":"user","timestamp":"2026-03-28T15:00:05Z","message":{"role":"user","content":"ls"}}"#, - r#"{"type":"user","timestamp":"2026-03-28T15:00:05Z","message":{"role":"user","content":"a\nb"}}"#, - ]); + write_lines( + &mut file, + &[ + r#"{"type":"assistant","timestamp":"2026-03-28T15:00:00Z","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"ok"}]}}"#, + r#"{"type":"user","timestamp":"2026-03-28T15:00:05Z","message":{"role":"user","content":"ls"}}"#, + r#"{"type":"user","timestamp":"2026-03-28T15:00:05Z","message":{"role":"user","content":"a\nb"}}"#, + ], + ); let result = parse_transcript(file.path(), 0); @@ -3088,7 +4434,10 @@ n/Users/bob/.claude-alt/projects/-Users-bob-project/session.jsonl #[test] fn test_context_window_for_model() { // Base model with low token usage → 200K - assert_eq!(crate::collector::context_window_for_model("claude-opus-4-6", "", 50_000), 200_000); + assert_eq!( + crate::collector::context_window_for_model("claude-opus-4-6", "", 50_000), + 200_000 + ); // Explicit [1m] suffix in transcript model → 1M regardless of token count assert_eq!( crate::collector::context_window_for_model("claude-opus-4-6[1m]", "", 0), @@ -3103,7 +4452,10 @@ n/Users/bob/.claude-alt/projects/-Users-bob-project/session.jsonl crate::collector::context_window_for_model("claude-sonnet-4-6", "", 100_000), 200_000 ); - assert_eq!(crate::collector::context_window_for_model("unknown-model", "", 0), 200_000); + assert_eq!( + crate::collector::context_window_for_model("unknown-model", "", 0), + 200_000 + ); // Token usage exceeds 200K → must be 1M window assert_eq!( crate::collector::context_window_for_model("claude-opus-4-6", "", 250_000), @@ -3406,14 +4758,14 @@ n/Users/bob/.claude-alt/projects/-Users-bob-project/session.jsonl } #[test] - fn test_load_session_stale_transcript_is_waiting_even_when_cpu_busy() { + fn test_load_session_terminal_turn_is_idle_even_when_main_cpu_is_busy() { // Regression: lifetime `%cpu` from ps doesn't tell us whether the // agent is doing work *right now* — long-running sessions can // average over 1% even when fully idle. Status must drive off // recent transcript activity, not lifetime CPU. Here the // transcript timestamps are months stale and there is no active // descendant; even with cpu_pct=42 the session must read as - // Waiting. + // Idle. let temp = tempfile::tempdir().unwrap(); let profile = temp.path().join(".claude"); let sessions_dir = profile.join("sessions"); @@ -3455,8 +4807,57 @@ n/Users/bob/.claude-alt/projects/-Users-bob-project/session.jsonl assert_eq!(sessions.len(), 1); assert_eq!( sessions[0].status, - SessionStatus::Waiting, - "stale transcript + idle descendants must be Waiting regardless of lifetime cpu_pct", + SessionStatus::Idle, + "a terminal turn with idle descendants must be Idle regardless of lifetime cpu_pct", + ); + assert_eq!(sessions[0].current_tasks, ["idle"]); + assert_eq!(sessions[0].pending_since_ms, 0); + assert_eq!(sessions[0].thinking_since_ms, 0); + } + + #[test] + fn test_load_session_streaming_assistant_without_stop_reason_is_unknown() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join(".claude"); + let sessions_dir = profile.join("sessions"); + let projects = profile.join("projects"); + let cwd = temp.path().join("repo"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let pid = 19_102; + let sid = "streaming-assistant"; + let session_path = sessions_dir.join(format!("{pid}.json")); + write_session_file(&session_path, pid, sid, &cwd); + let transcript_dir = projects.join(encode_cwd_path(cwd.to_str().unwrap())); + std::fs::create_dir_all(&transcript_dir).unwrap(); + std::fs::write( + transcript_dir.join(format!("{sid}.jsonl")), + r#"{"type":"user","timestamp":"2026-03-28T15:00:00Z","message":{"role":"user","content":"go"}} +{"type":"assistant","timestamp":"2026-03-28T15:00:01Z","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"partial"}]}} +"#, + ) + .unwrap(); + + let config = ConfigDir::new(profile); + let process_info = make_proc_info(pid, "claude"); + let session_paths = vec![(session_path, config.clone())]; + let ctx = build_discovery_context(&session_paths, &process_info, 0); + let mut collector = ClaudeCollector::new(); + let sessions = collector.load_session_paths( + &session_paths, + &process_info, + &HashMap::new(), + &HashMap::new(), + &ctx, + ); + + assert_eq!(sessions[0].status, SessionStatus::Unknown); + assert_eq!(sessions[0].current_tasks, ["unknown"]); + assert_eq!( + sessions[0].status_evidence.authority, + StatusAuthority::Unavailable, ); } @@ -3571,6 +4972,705 @@ n/Users/bob/.claude-alt/projects/-Users-bob-project/session.jsonl SessionStatus::Executing, "pending tool_use must read as Executing even with idle descendants", ); + assert!( + !sessions[0].awaiting_input, + "a normal pending tool (Bash) must NOT be flagged as awaiting_input", + ); + } + + #[test] + fn test_load_session_incremental_tool_result_closes_execution() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join(".claude"); + let sessions_dir = profile.join("sessions"); + let projects = profile.join("projects"); + let cwd = temp.path().join("repo"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let pid = 9110; + let sid = "incremental-tool-result"; + let session_path = sessions_dir.join(format!("{pid}.json")); + write_session_file(&session_path, pid, sid, &cwd); + let transcript_dir = projects.join(encode_cwd_path(cwd.to_str().unwrap())); + std::fs::create_dir_all(&transcript_dir).unwrap(); + let transcript_path = transcript_dir.join(format!("{sid}.jsonl")); + std::fs::write( + &transcript_path, + r#"{"type":"assistant","timestamp":"2026-03-28T15:00:05Z","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"tool_use","name":"Bash","id":"t1","input":{"command":"sleep 1"}}]}} +"#, + ) + .unwrap(); + + let config = ConfigDir::new(profile.clone()); + let process_info = make_proc_info(pid, "claude"); + let session_paths = vec![(session_path, config.clone())]; + let ctx = build_discovery_context(&session_paths, &process_info, 0); + let mut collector = ClaudeCollector::new(); + collector.config_dirs = vec![config]; + + let first = collector.load_session_paths( + &session_paths, + &process_info, + &HashMap::new(), + &HashMap::new(), + &ctx, + ); + assert_eq!(first[0].status, SessionStatus::Executing); + assert!(first[0].pending_since_ms > 0); + + let mut transcript = std::fs::OpenOptions::new() + .append(true) + .open(&transcript_path) + .unwrap(); + writeln!( + transcript, + r#"{{"type":"user","timestamp":"2026-03-28T15:00:06Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1","content":"done"}}]}}}}"#, + ) + .unwrap(); + transcript.flush().unwrap(); + + let second = collector.load_session_paths( + &session_paths, + &process_info, + &HashMap::new(), + &HashMap::new(), + &ctx, + ); + assert_eq!(second[0].status, SessionStatus::Unknown); + assert_eq!(second[0].current_tasks, ["unknown"]); + assert_eq!(second[0].pending_since_ms, 0); + assert_eq!(second[0].thinking_since_ms, 0); + assert_eq!( + second[0].status_evidence.authority, + StatusAuthority::Unavailable, + ); + } + + #[test] + fn incremental_partial_tail_invalidates_cached_terminal_lifecycle() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join(".claude"); + let sessions_dir = profile.join("sessions"); + let projects = profile.join("projects"); + let cwd = temp.path().join("repo"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let pid = 19_110; + let sid = "incremental-partial-tail"; + let session_path = sessions_dir.join(format!("{pid}.json")); + write_session_file(&session_path, pid, sid, &cwd); + let transcript_dir = projects.join(encode_cwd_path(cwd.to_str().unwrap())); + std::fs::create_dir_all(&transcript_dir).unwrap(); + let transcript_path = transcript_dir.join(format!("{sid}.jsonl")); + std::fs::write( + &transcript_path, + r#"{"type":"assistant","timestamp":"2026-03-28T15:00:05Z","message":{"model":"claude-sonnet-4-6","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"done"}]}} +"#, + ) + .unwrap(); + + let config = ConfigDir::new(profile); + let process_info = make_proc_info(pid, "claude"); + let session_paths = vec![(session_path, config)]; + let ctx = build_discovery_context(&session_paths, &process_info, 0); + let mut collector = ClaudeCollector::new(); + let first = collector.load_session_paths( + &session_paths, + &process_info, + &HashMap::new(), + &HashMap::new(), + &ctx, + ); + assert_eq!(first[0].status, SessionStatus::Idle); + + let mut transcript = std::fs::OpenOptions::new() + .append(true) + .open(&transcript_path) + .unwrap(); + write!(transcript, r#"{{"type":"assistant""#).unwrap(); + transcript.flush().unwrap(); + + let second = collector.load_session_paths( + &session_paths, + &process_info, + &HashMap::new(), + &HashMap::new(), + &ctx, + ); + assert_eq!(second[0].status, SessionStatus::Unknown); + assert_eq!( + second[0].status_evidence.authority, + StatusAuthority::Unavailable + ); + } + + #[test] + fn native_idle_remains_provider_idle_with_synthetic_only_transcript() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join(".claude"); + let sessions_dir = profile.join("sessions"); + let projects = profile.join("projects"); + let cwd = temp.path().join("repo"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let pid = 19_111; + let sid = "native-idle-synthetic-only"; + let session_path = sessions_dir.join(format!("{pid}.json")); + std::fs::write( + &session_path, + serde_json::json!({ + "pid": pid, + "sessionId": sid, + "cwd": cwd.to_str().unwrap(), + "startedAt": 1_774_715_116_826u64, + "status": "idle", + // Deliberately older than the synthetic transcript records. + // Before the regression fix those records became competing + // explicit-Unknown evidence and hid this native idle state. + "statusUpdatedAt": 1u64, + }) + .to_string(), + ) + .unwrap(); + let transcript_dir = projects.join(encode_cwd_path(cwd.to_str().unwrap())); + std::fs::create_dir_all(&transcript_dir).unwrap(); + std::fs::write( + transcript_dir.join(format!("{sid}.jsonl")), + concat!( + r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","isMeta":true,"message":{"role":"user","content":"Caveat"}} +"#, + r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","message":{"role":"user","content":"/plugin"}} +"#, + r#"{"type":"user","timestamp":"2026-03-28T15:01:00Z","message":{"role":"user","content":"Updated"}} +"#, + ), + ) + .unwrap(); + + let config = ConfigDir::new(profile); + let process_info = make_proc_info(pid, "claude"); + let session_paths = vec![(session_path, config)]; + let ctx = build_discovery_context(&session_paths, &process_info, 0); + let mut collector = ClaudeCollector::new(); + let sessions = collector.load_session_paths( + &session_paths, + &process_info, + &HashMap::new(), + &HashMap::new(), + &ctx, + ); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].status, SessionStatus::Idle); + assert_eq!( + sessions[0].status_evidence.authority, + StatusAuthority::Provider + ); + assert_eq!( + sessions[0].status_evidence.reason, + StatusReason::ProviderIdle + ); + } + + #[test] + fn test_load_session_native_idle_overrides_stale_active_signals() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join(".claude"); + let sessions_dir = profile.join("sessions"); + let projects = profile.join("projects"); + let cwd = temp.path().join("repo"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let pid = 9111; + let child_pid = 9112; + let sid = "native-idle"; + let session_path = sessions_dir.join(format!("{pid}.json")); + std::fs::write( + &session_path, + serde_json::json!({ + "pid": pid, + "sessionId": sid, + "cwd": cwd.to_str().unwrap(), + "startedAt": 1774715116826u64, + "status": "idle", + "statusUpdatedAt": 1_774_715_200_000u64, + }) + .to_string(), + ) + .unwrap(); + let transcript_dir = projects.join(encode_cwd_path(cwd.to_str().unwrap())); + std::fs::create_dir_all(&transcript_dir).unwrap(); + std::fs::write( + transcript_dir.join(format!("{sid}.jsonl")), + r#"{"type":"assistant","timestamp":"2026-03-28T15:00:05Z","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"tool_use","name":"Bash","id":"t1","input":{"command":"old"}}]}} +"#, + ) + .unwrap(); + + let config = ConfigDir::new(profile.clone()); + let mut process_info = make_proc_info(pid, "claude"); + process_info.insert( + child_pid, + ProcInfo { + pid: child_pid, + ppid: pid, + rss_kb: 1024, + cpu_pct: 100.0, + command: "old-child".to_string(), + }, + ); + let children_map = HashMap::from([(pid, vec![child_pid])]); + let session_paths = vec![(session_path, config.clone())]; + let ctx = build_discovery_context(&session_paths, &process_info, 0); + let mut collector = ClaudeCollector::new(); + collector.config_dirs = vec![config]; + let sessions = collector.load_session_paths( + &session_paths, + &process_info, + &children_map, + &HashMap::new(), + &ctx, + ); + + assert_eq!(sessions[0].status, SessionStatus::Idle); + assert_eq!(sessions[0].current_tasks, ["idle"]); + assert_eq!(sessions[0].pending_since_ms, 0); + assert!(!sessions[0].awaiting_input); + assert_eq!( + sessions[0].status_evidence.authority, + StatusAuthority::Provider + ); + assert_eq!( + sessions[0].status_evidence.reason, + StatusReason::ProviderIdle + ); + } + + #[test] + fn test_load_session_tool_name_matching_is_exact() { + // A trailing, unanswered ExitPlanMode / AskUserQuestion tool_use means the + // agent is blocked on MY decision (plan approval / question) — not idle, + // and distinct from ordinary tool execution. awaiting_input must flag it. + for (tool, expected) in [ + ("ExitPlanMode", true), + ("AskUserQuestion", true), + ("ExitPlanModeFoo", false), + ("AskUserQuestionnaire", false), + ] { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join(".claude"); + let sessions_dir = profile.join("sessions"); + let projects = profile.join("projects"); + let cwd = temp.path().join("repo"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let pid = 9104; + let sid = "pending-decision"; + let session_path = sessions_dir.join(format!("{}.json", pid)); + write_session_file(&session_path, pid, sid, &cwd); + + // Trailing assistant decision tool_use with no following tool_result. + let transcript_dir = projects.join(encode_cwd_path(cwd.to_str().unwrap())); + std::fs::create_dir_all(&transcript_dir).unwrap(); + let transcript = transcript_dir.join(format!("{}.jsonl", sid)); + std::fs::write( + &transcript, + format!( + r#"{{"type":"user","timestamp":"2026-03-28T15:00:00Z","message":{{"role":"user","content":"go"}}}} +{{"type":"assistant","timestamp":"2026-03-28T15:00:05Z","message":{{"model":"claude-sonnet-4-6","usage":{{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"content":[{{"type":"tool_use","name":"{}","id":"t1","input":{{}}}}]}}}} +"#, + tool + ), + ) + .unwrap(); + + let config = ConfigDir::new(profile.clone()); + let process_info = make_proc_info(pid, "claude"); + let mut collector = ClaudeCollector::new(); + collector.config_dirs = vec![config.clone()]; + + let session_paths = vec![(session_path, config)]; + let ctx = build_discovery_context(&session_paths, &process_info, 0); + let sessions = collector.load_session_paths( + &session_paths, + &process_info, + &HashMap::new(), + &HashMap::new(), + &ctx, + ); + + let expected_status = if expected { + SessionStatus::Waiting + } else { + SessionStatus::Executing + }; + assert_eq!(sessions.len(), 1, "{tool}"); + assert_eq!(sessions[0].status, expected_status, "{tool}"); + assert_eq!(sessions[0].awaiting_input, expected, "{tool}"); + assert_eq!( + sessions[0].status_evidence.authority, + StatusAuthority::Provider, + "{tool}" + ); + let expected_reason = match tool { + "ExitPlanMode" => StatusReason::ProviderWaitingApproval, + "AskUserQuestion" => StatusReason::ProviderWaitingUserInput, + _ => StatusReason::ProviderExecuting, + }; + assert_eq!( + sessions[0].status_evidence.reason, expected_reason, + "{tool}" + ); + if expected { + assert_eq!(sessions[0].current_tasks, ["waiting for user input"]); + assert_eq!(sessions[0].pending_since_ms, 0); + } else { + assert!(sessions[0].pending_since_ms > 0); + } + } + } + + #[test] + fn test_load_session_claude_waiting_status_is_awaiting_input() { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join(".claude"); + let sessions_dir = profile.join("sessions"); + let projects = profile.join("projects"); + let cwd = temp.path().join("repo"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let pid = 9105; + let sid = "claude-waiting"; + // Session file carries Claude's own status:"waiting". + let session_path = sessions_dir.join(format!("{}.json", pid)); + std::fs::write( + &session_path, + format!( + r#"{{"pid":{},"sessionId":"{}","cwd":"{}","startedAt":1774715116826,"status":"waiting","statusUpdatedAt":1774715200000}}"#, + pid, + sid, + cwd.display() + ), + ) + .unwrap(); + + // Transcript ends at a normal assistant end_turn — no pending/decision tool. + let transcript_dir = projects.join(encode_cwd_path(cwd.to_str().unwrap())); + std::fs::create_dir_all(&transcript_dir).unwrap(); + let transcript = transcript_dir.join(format!("{}.jsonl", sid)); + std::fs::write( + &transcript, + r#"{"type":"user","timestamp":"2026-03-28T15:00:00Z","message":{"role":"user","content":"hi"}} +{"type":"assistant","timestamp":"2026-03-28T15:00:05Z","message":{"model":"claude-sonnet-4-6","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"done"}]}} +"#, + ) + .unwrap(); + + let config = ConfigDir::new(profile.clone()); + let process_info = make_proc_info(pid, "claude"); + let mut collector = ClaudeCollector::new(); + collector.config_dirs = vec![config.clone()]; + + let session_paths = vec![(session_path, config)]; + let ctx = build_discovery_context(&session_paths, &process_info, 0); + let sessions = collector.load_session_paths( + &session_paths, + &process_info, + &HashMap::new(), + &HashMap::new(), + &ctx, + ); + + assert_eq!(sessions.len(), 1); + assert!( + sessions[0].action_process_incarnation.is_none(), + "a synthetic PID without an exact live incarnation must not authorize actions" + ); + assert_eq!(sessions[0].status, SessionStatus::Waiting); + assert!( + sessions[0].awaiting_input, + "Claude status:\"waiting\" must set awaiting_input even at a normal end_turn", + ); + assert_eq!(sessions[0].current_tasks, ["waiting for user input"]); + assert_eq!(sessions[0].pending_since_ms, 0); + assert_eq!(sessions[0].thinking_since_ms, 0); + assert_eq!( + sessions[0].status_evidence.authority, + StatusAuthority::Provider + ); + assert_eq!( + sessions[0].status_evidence.reason, + StatusReason::ProviderWaitingUserInput + ); + } + + #[test] + fn test_load_session_native_nonwaiting_statuses_and_fallback() { + for (native, expected_status, expected_task, authority, reason) in [ + ( + None, + SessionStatus::Unknown, + "unknown", + StatusAuthority::Unavailable, + StatusReason::Unavailable, + ), + ( + Some("busy"), + SessionStatus::Thinking, + "thinking", + StatusAuthority::Provider, + StatusReason::ProviderThinking, + ), + ( + Some("shell"), + SessionStatus::Executing, + "executing", + StatusAuthority::Provider, + StatusReason::ProviderExecuting, + ), + ( + Some("idle"), + SessionStatus::Idle, + "idle", + StatusAuthority::Provider, + StatusReason::ProviderIdle, + ), + ( + Some("unexpected"), + SessionStatus::Unknown, + "unknown", + StatusAuthority::Unavailable, + StatusReason::ProtocolUnknown, + ), + ] { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join(".claude"); + let sessions_dir = profile.join("sessions"); + let projects = profile.join("projects"); + let cwd = temp.path().join("repo"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let pid = 9106; + let sid = "nonwaiting-status"; + let session_path = sessions_dir.join(format!("{}.json", pid)); + let status_field = native.map_or(String::new(), |value| { + format!(r#","status":"{value}","statusUpdatedAt":1774715200000"#) + }); + std::fs::write( + &session_path, + format!( + r#"{{"pid":{pid},"sessionId":"{sid}","cwd":"{}","startedAt":1774715116826{status_field}}}"#, + cwd.display() + ), + ) + .unwrap(); + + let transcript_dir = projects.join(encode_cwd_path(cwd.to_str().unwrap())); + std::fs::create_dir_all(&transcript_dir).unwrap(); + std::fs::write( + transcript_dir.join(format!("{sid}.jsonl")), + r#"{"type":"assistant","timestamp":"2026-03-28T15:00:05Z","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"done"}]}} +"#, + ) + .unwrap(); + + let config = ConfigDir::new(profile.clone()); + let process_info = make_proc_info(pid, "claude"); + let mut collector = ClaudeCollector::new(); + collector.config_dirs = vec![config.clone()]; + let session_paths = vec![(session_path, config)]; + let ctx = build_discovery_context(&session_paths, &process_info, 0); + let sessions = collector.load_session_paths( + &session_paths, + &process_info, + &HashMap::new(), + &HashMap::new(), + &ctx, + ); + + assert_eq!(sessions.len(), 1, "{native:?}"); + assert_eq!(sessions[0].status, expected_status, "{native:?}"); + assert_eq!(sessions[0].current_tasks, [expected_task], "{native:?}"); + assert!(!sessions[0].awaiting_input, "{native:?}"); + assert_eq!(sessions[0].pending_since_ms, 0, "{native:?}"); + assert_eq!( + sessions[0].status_evidence.authority, authority, + "{native:?}" + ); + assert_eq!(sessions[0].status_evidence.reason, reason, "{native:?}"); + } + } + + fn load_parent_with_subagent( + subagent_transcript: &str, + subagent_mtime_offset_secs: i64, + native_status: Option<&str>, + ) -> AgentSession { + let temp = tempfile::tempdir().unwrap(); + let profile = temp.path().join(".claude"); + let sessions_dir = profile.join("sessions"); + let projects = profile.join("projects"); + let cwd = temp.path().join("repo"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + std::fs::create_dir_all(&projects).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let pid = 19_104; + let sid = "subagent-lifecycle"; + let session_path = sessions_dir.join(format!("{pid}.json")); + if let Some(native_status) = native_status { + std::fs::write( + &session_path, + serde_json::json!({ + "pid": pid, + "sessionId": sid, + "cwd": cwd, + "startedAt": 1_774_715_116_826u64, + "status": native_status, + }) + .to_string(), + ) + .unwrap(); + } else { + write_session_file(&session_path, pid, sid, &cwd); + } + + let project_dir = projects.join(encode_cwd_path(cwd.to_str().unwrap())); + std::fs::create_dir_all(&project_dir).unwrap(); + std::fs::write( + project_dir.join(format!("{sid}.jsonl")), + r#"{"type":"assistant","timestamp":"2026-03-28T15:00:05Z","message":{"model":"claude-sonnet-4-6","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"parent idle"}]}} +"#, + ) + .unwrap(); + + let subagents_dir = project_dir.join(sid).join("subagents"); + std::fs::create_dir_all(&subagents_dir).unwrap(); + std::fs::write( + subagents_dir.join("agent-child.meta.json"), + r#"{"agentType":"general-purpose","description":"child"}"#, + ) + .unwrap(); + let child_path = subagents_dir.join("agent-child.jsonl"); + std::fs::write(&child_path, subagent_transcript).unwrap(); + set_mtime(&child_path, subagent_mtime_offset_secs); + + let config = ConfigDir::new(profile); + let session_paths = vec![(session_path, config.clone())]; + let process_info = make_proc_info(pid, "claude"); + let ctx = build_discovery_context(&session_paths, &process_info, 0); + let mut collector = ClaudeCollector::new(); + collector + .load_session_paths( + &session_paths, + &process_info, + &HashMap::new(), + &HashMap::new(), + &ctx, + ) + .into_iter() + .next() + .unwrap() + } + + #[test] + fn test_old_exact_open_subagent_keeps_parent_executing() { + let session = load_parent_with_subagent( + r#"{"type":"assistant","timestamp":"2026-03-28T15:00:06Z","message":{"model":"claude-sonnet-4-6","stop_reason":"tool_use","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"tool_use","name":"Bash","id":"t1","input":{"command":"sleep 999"}}]}} +"#, + -3_600, + None, + ); + + assert_eq!(session.subagents[0].status, "working"); + assert_eq!(session.status, SessionStatus::Executing); + assert_eq!(session.status_evidence.authority, StatusAuthority::Provider); + assert_eq!( + session.status_evidence.reason, + StatusReason::ProviderExecuting + ); + } + + #[test] + fn test_fresh_completed_subagent_does_not_force_parent_execution() { + let session = load_parent_with_subagent( + r#"{"type":"assistant","timestamp":"2026-03-28T15:00:06Z","message":{"model":"claude-sonnet-4-6","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},"content":[{"type":"text","text":"done"}]}} +"#, + 0, + None, + ); + + assert_eq!(session.subagents[0].status, "done"); + assert_eq!(session.status, SessionStatus::Idle); + assert_eq!(session.status_evidence.authority, StatusAuthority::Provider); + assert_eq!(session.status_evidence.reason, StatusReason::ProviderIdle); + } + + #[test] + fn test_partial_subagent_tail_prevents_false_parent_idle() { + let session = load_parent_with_subagent( + concat!( + r#"{"type":"user","timestamp":"2026-03-28T15:00:06Z","message":{"role":"user","content":"work"}} +"#, + r#"{"type":"assistant""#, + ), + 0, + None, + ); + + assert_eq!(session.subagents[0].status, "unknown"); + assert_eq!(session.status, SessionStatus::Unknown); + assert_eq!( + session.status_evidence.authority, + StatusAuthority::Unavailable + ); + assert_eq!( + session.status_evidence.reason, + StatusReason::ProtocolUnknown + ); + } + + #[test] + fn malformed_subagent_metadata_marks_collection_incomplete() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("agent-child.meta.json"), "{broken").unwrap(); + + let collection = ClaudeCollector::collect_subagents(temp.path()); + + assert!(collection.agents.is_empty()); + assert!(collection.incomplete); + } + + #[test] + fn test_native_idle_parent_preserves_exact_async_subagent_execution() { + let session = load_parent_with_subagent( + r#"{"type":"user","timestamp":"2026-03-28T15:00:06Z","message":{"role":"user","content":"work"}} +"#, + -3_600, + Some("idle"), + ); + + assert_eq!(session.subagents[0].status, "working"); + assert_eq!(session.status, SessionStatus::Executing); + assert_eq!(session.status_evidence.authority, StatusAuthority::Provider); + assert_eq!( + session.status_evidence.reason, + StatusReason::ProviderExecuting + ); } #[test] diff --git a/src/collector/codex.rs b/src/collector/codex.rs index 43a4bfc..befff69 100644 --- a/src/collector/codex.rs +++ b/src/collector/codex.rs @@ -1,12 +1,23 @@ +use super::herdr::{HerdrObservation, HerdrStatus, HerdrStatusResolver, HerdrTarget}; use super::process::{self, ProcInfo}; +use crate::codex_hooks::{ + plugin::{self, PluginPaths}, + state::{ + HookProjection, HookRootProjection, HookSessionState, HookStateStore, HookToolClass, + IntegrationIdentity, + }, +}; use crate::model::{ - AgentSession, ChatMessage, ChatRole, ChildProcess, RateLimitInfo, SessionStatus, ToolCall, - MAX_CHAT_MESSAGES, + AgentSession, ChatMessage, ChatRole, ChildProcess, RateLimitInfo, RateLimitProvenance, + RateLimitWindow, SessionStatus, StatusAuthority, StatusEvidence, StatusObservation, + StatusReason, SubAgent, ToolCall, MAX_CHAT_MESSAGES, }; +use serde::Deserialize; use serde_json::Value; +use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::fs; -use std::io::{BufRead, BufReader, Read}; +use std::io::{self, BufRead, BufReader, Read}; use std::path::{Path, PathBuf}; #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] use std::process::Command; @@ -24,12 +35,833 @@ use std::time::{Duration, Instant}; /// - `session_meta`: session ID, cwd, cli_version, model_provider, git info /// - `event_msg` subtypes: task_started, user_message, token_count, agent_message, task_complete /// - `response_item`: assistant messages (commentary/final), function_call, function_call_output +/// - Open `request_user_input` records: metrics only; never authoritative Waiting /// - `turn_context`: model, cwd, effort, context window size pub struct CodexCollector { sessions_dir: PathBuf, /// Latest rate limit info parsed from Codex JSONL token_count events. pub last_rate_limit: Option, desktop_recent_scanner: DesktopRecentRolloutScanner, + parse_cache: RefCell, + rollout_lifecycle: RefCell>, + hook_process_states: RefCell>, + hook_exit_observations: RefCell>, + hook_process_rollout_bindings: RefCell>, + hook_live_session_snapshots: RefCell>, + hook_done_tombstones: RefCell>, + herdr_status_resolver: RefCell, + herdr_working_continuity: RefCell>, +} + +const MAX_CODEX_PARSE_CACHE_ENTRIES: usize = 256; +const MAX_CODEX_DONE_TOMBSTONES: usize = 128; +const MAX_OPEN_ROLLOUT_CALLS: usize = 256; +const MAX_TRACKED_ROLLOUT_CALL_IDS: usize = 4096; +const MAX_COPIED_ROLLOUT_SESSION_META: usize = 64; +const MAX_ROLLOUT_LIFECYCLE_ID_BYTES: usize = 512; +const MAX_ROLLOUT_TOOL_NAME_BYTES: usize = 256; +const MAX_TRACKED_CODE_MODE_CELLS: usize = 128; +const MAX_CODE_MODE_STATUS_FRAME_BYTES: usize = 256; +/// Native account quota windows longer than one year are malformed rather +/// than useful display data. Bounding the duration also keeps derived labels +/// predictably small and content-free. +const MAX_NATIVE_RATE_LIMIT_WINDOW_MINUTES: u64 = 365 * 24 * 60; +#[derive(Clone, PartialEq, Eq)] +struct RolloutFingerprint { + len: u64, + #[cfg(unix)] + dev: u64, + #[cfg(unix)] + ino: u64, + #[cfg(unix)] + mtime_sec: i64, + #[cfg(unix)] + mtime_nsec: i64, + #[cfg(unix)] + ctime_sec: i64, + #[cfg(unix)] + ctime_nsec: i64, + #[cfg(target_os = "windows")] + volume_serial: u64, + #[cfg(target_os = "windows")] + file_id: [u8; 16], + #[cfg(target_os = "windows")] + creation_time: i64, + #[cfg(target_os = "windows")] + last_write_time: i64, + #[cfg(target_os = "windows")] + change_time: i64, + #[cfg(not(any(unix, target_os = "windows")))] + modified: std::time::SystemTime, +} + +impl RolloutFingerprint { + fn read(file: &fs::File) -> io::Result { + let metadata = file.metadata()?; + if !metadata.file_type().is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Codex rollout is not a regular file", + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + Ok(Self { + len: metadata.len(), + dev: metadata.dev(), + ino: metadata.ino(), + mtime_sec: metadata.mtime(), + mtime_nsec: metadata.mtime_nsec(), + ctime_sec: metadata.ctime(), + ctime_nsec: metadata.ctime_nsec(), + }) + } + #[cfg(target_os = "windows")] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileBasicInfo, FileIdInfo, GetFileInformationByHandleEx, FILE_BASIC_INFO, + FILE_ID_INFO, + }; + + let mut basic = FILE_BASIC_INFO::default(); + let mut identity = FILE_ID_INFO::default(); + // SAFETY: both buffers have the exact Win32 structures and remain + // valid for the duration of the synchronous calls. The File owns + // the queried handle. + let basic_ok = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileBasicInfo, + (&raw mut basic).cast(), + std::mem::size_of::() as u32, + ) + }; + // SAFETY: same argument as above for FILE_ID_INFO. + let identity_ok = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileIdInfo, + (&raw mut identity).cast(), + std::mem::size_of::() as u32, + ) + }; + if basic_ok == 0 || identity_ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Self { + len: metadata.len(), + volume_serial: identity.VolumeSerialNumber, + file_id: identity.FileId.Identifier, + creation_time: basic.CreationTime, + last_write_time: basic.LastWriteTime, + change_time: basic.ChangeTime, + }) + } + #[cfg(not(any(unix, target_os = "windows")))] + Ok(Self { + len: metadata.len(), + modified: metadata.modified()?, + }) + } +} + +fn open_rollout_file(path: &Path) -> io::Result { + #[cfg(unix)] + let file = { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path)? + }; + #[cfg(not(unix))] + let file = fs::OpenOptions::new().read(true).open(path)?; + if !file.metadata()?.file_type().is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Codex rollout is not a regular file", + )); + } + Ok(file) +} + +fn rollout_path_matches_fingerprint( + path: &Path, + canonical_path: &Path, + expected: &RolloutFingerprint, +) -> bool { + let Ok(before_path) = fs::canonicalize(path) else { + return false; + }; + if before_path != canonical_path { + return false; + } + let Ok(file) = open_rollout_file(path) else { + return false; + }; + let Ok(observed) = RolloutFingerprint::read(&file) else { + return false; + }; + let Ok(after_path) = fs::canonicalize(path) else { + return false; + }; + observed == *expected && after_path == canonical_path +} + +struct CachedCodexParse { + fingerprint: RolloutFingerprint, + result: CodexJSONLResult, + last_used: u64, +} + +#[derive(Default)] +struct CodexParseCache { + entries: HashMap, + clock: u64, +} + +#[derive(Clone)] +struct RolloutLifecycle { + root_cli_version: String, + turn_active: bool, + task_complete: bool, + lifecycle_valid: bool, + active_turn_id: Option, + completed_turn_id: Option, + turn_started_at_ms: u64, + latest_lifecycle_at_ms: u64, + task_completed_at_ms: u64, + open_tool_ids: HashSet, + open_tool_started_at_ms: HashMap, + open_tool_classes: HashMap, + completed_tool_calls: HashMap, + nested_code_mode_end_at_ms: HashMap, + live_code_mode_cells: usize, + code_mode_correlation_ambiguous: bool, + descendants: Vec, + relevant_process_descendant: bool, +} + +#[derive(Clone)] +struct DescendantRolloutLifecycle { + session_id: String, + cli_version: String, + direct_child: bool, + lifecycle_valid: bool, + turn_active: bool, + task_complete: bool, + active_turn_id: Option, + completed_turn_id: Option, + turn_started_at_ms: u64, + latest_lifecycle_at_ms: u64, + task_completed_at_ms: u64, + open_tool_ids: HashSet, + open_tool_started_at_ms: HashMap, + open_tool_classes: HashMap, + live_code_mode_cells: usize, + code_mode_correlation_ambiguous: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RolloutToolClass { + Ordinary, + RequestUserInput, + CodeModeExec { + exec_started_at_ms: u64, + }, + CodeModeWait { + exec_started_at_ms: u64, + exec_yielded_at_ms: u64, + }, + CodeModeUncorrelatable, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CompletedRolloutCall { + started_at_ms: u64, + completed_at_ms: u64, + class: RolloutToolClass, + code_mode_terminal: bool, +} + +impl Default for RolloutLifecycle { + fn default() -> Self { + Self { + root_cli_version: String::new(), + turn_active: false, + task_complete: false, + lifecycle_valid: true, + active_turn_id: None, + completed_turn_id: None, + turn_started_at_ms: 0, + latest_lifecycle_at_ms: 0, + task_completed_at_ms: 0, + open_tool_ids: HashSet::new(), + open_tool_started_at_ms: HashMap::new(), + open_tool_classes: HashMap::new(), + completed_tool_calls: HashMap::new(), + nested_code_mode_end_at_ms: HashMap::new(), + live_code_mode_cells: 0, + code_mode_correlation_ambiguous: false, + descendants: Vec::new(), + relevant_process_descendant: false, + } + } +} + +impl DescendantRolloutLifecycle { + fn has_exact_active_shape(&self, now_ms: u64) -> bool { + self.lifecycle_valid + && self.turn_active + && !self.task_complete + && self.active_turn_id.is_some() + && self.completed_turn_id.is_none() + && self.turn_started_at_ms > 0 + && self.turn_started_at_ms <= self.latest_lifecycle_at_ms + && self.latest_lifecycle_at_ms <= now_ms + && self.task_completed_at_ms == 0 + && self.open_tool_started_at_ms.len() == self.open_tool_ids.len() + && self.open_tool_started_at_ms.iter().all(|(id, timestamp)| { + self.open_tool_ids.contains(id) + && *timestamp >= self.turn_started_at_ms + && *timestamp <= self.latest_lifecycle_at_ms + }) + && self.open_tool_classes.len() == self.open_tool_ids.len() + && self + .open_tool_classes + .keys() + .all(|id| self.open_tool_ids.contains(id)) + } + + fn is_exact_active(&self, now_ms: u64) -> bool { + self.has_exact_active_shape(now_ms) + && self.open_tool_ids.is_empty() + && self.live_code_mode_cells == 0 + && !self.code_mode_correlation_ambiguous + } + + fn is_exact_active_with_open_tool(&self, now_ms: u64) -> bool { + self.has_exact_active_shape(now_ms) + && !self.open_tool_ids.is_empty() + && self.live_code_mode_cells == 0 + && !self.code_mode_correlation_ambiguous + } + + fn is_exact_terminal(&self, now_ms: u64) -> bool { + self.lifecycle_valid + && self.task_complete + && !self.turn_active + && self.active_turn_id.is_none() + && self.completed_turn_id.is_some() + && self.turn_started_at_ms > 0 + && self.turn_started_at_ms <= self.latest_lifecycle_at_ms + && self.latest_lifecycle_at_ms <= self.task_completed_at_ms + && self.task_completed_at_ms <= now_ms + && self.open_tool_ids.is_empty() + && self.open_tool_started_at_ms.is_empty() + && self.open_tool_classes.is_empty() + && self.live_code_mode_cells == 0 + && !self.code_mode_correlation_ambiguous + } +} + +impl RolloutLifecycle { + fn has_compatible_release_tree(&self) -> bool { + // Only the selected root attests the process release. Descendant + // metadata cannot supply a missing root version, and disagreement + // makes the selected lifecycle tree internally inconsistent. + plugin::codex_version_is_supported(&self.root_cli_version) + && self + .descendants + .iter() + .all(|child| child.cli_version == self.root_cli_version) + } + + fn root_is_exact_active(&self, now_ms: u64) -> bool { + self.lifecycle_valid + && self.turn_active + && !self.task_complete + && self.active_turn_id.is_some() + && self.completed_turn_id.is_none() + && self.turn_started_at_ms > 0 + && self.turn_started_at_ms <= self.latest_lifecycle_at_ms + && self.latest_lifecycle_at_ms <= now_ms + && self.task_completed_at_ms == 0 + && self.open_tool_started_at_ms.len() == self.open_tool_ids.len() + && self.open_tool_started_at_ms.iter().all(|(id, timestamp)| { + self.open_tool_ids.contains(id) + && *timestamp >= self.turn_started_at_ms + && *timestamp <= self.latest_lifecycle_at_ms + }) + && self.open_tool_classes.len() == self.open_tool_ids.len() + && self + .open_tool_classes + .keys() + .all(|id| self.open_tool_ids.contains(id)) + && self.completed_tool_calls.len() <= MAX_TRACKED_ROLLOUT_CALL_IDS + && self.completed_tool_calls.iter().all(|(id, completion)| { + !self.open_tool_ids.contains(id) + && completed_rollout_call_is_exact( + id, + completion, + self.turn_started_at_ms, + self.latest_lifecycle_at_ms, + ) + }) + && self.nested_code_mode_end_at_ms.len() <= MAX_TRACKED_ROLLOUT_CALL_IDS + && self + .nested_code_mode_end_at_ms + .iter() + .all(|(id, timestamp)| { + code_mode_nested_call_id_is_exact(id) + && *timestamp >= self.turn_started_at_ms + && *timestamp <= self.latest_lifecycle_at_ms + }) + } + + fn descendants_are_exact_terminal(&self, now_ms: u64) -> bool { + self.descendants + .iter() + .all(|child| child.is_exact_terminal(now_ms)) + } + + fn direct_child(&self, session_id: &str) -> Option<&DescendantRolloutLifecycle> { + self.descendants + .iter() + .find(|child| child.direct_child && child.session_id == session_id) + } + + /// Return the complete active direct-child rollout set only when every + /// descriptor in the selected rollout tree is exact. Nested children are + /// deliberately unsupported for hook promotion because a flat root hook + /// set cannot prove their parentage. + fn exact_direct_child_sets(&self, now_ms: u64) -> Option<(HashSet, HashSet)> { + if !self.lifecycle_valid || self.descendants.iter().any(|child| !child.direct_child) { + return None; + } + let mut active = HashSet::new(); + let mut terminal = HashSet::new(); + for child in &self.descendants { + if child.is_exact_active(now_ms) { + if !active.insert(child.session_id.clone()) { + return None; + } + } else if child.is_exact_terminal(now_ms) { + if !terminal.insert(child.session_id.clone()) { + return None; + } + } else { + return None; + } + } + Some((active, terminal)) + } +} + +#[derive(Clone, Debug)] +enum HookCandidate { + Unknown(StatusReason), + TurnOpen, + ToolOpen(HashSet), + SubagentOpen { + active: HashSet, + provisional: HashSet, + root: HookRootCandidate, + }, + TurnStopped, + Ended, +} + +#[derive(Clone, Debug)] +enum HookRootCandidate { + Unknown(StatusReason), + TurnOpen, + ToolOpen(HashSet), + TurnStopped, + Ended, +} + +impl From for HookRootCandidate { + fn from(projection: HookRootProjection) -> Self { + match projection { + HookRootProjection::Unknown(reason) => Self::Unknown(reason), + HookRootProjection::TurnOpen => Self::TurnOpen, + HookRootProjection::ToolOpen(ids) => { + Self::ToolOpen(ids.into_iter().collect::>()) + } + HookRootProjection::TurnStopped => Self::TurnStopped, + HookRootProjection::Ended => Self::Ended, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HookProcessState { + Live, + Gone, + Unverified, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct HookProcessRolloutBinding { + session_id: String, + supported_release: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +struct HookDoneKey { + session_id: String, + generation_id: String, + pid: u32, + process_incarnation: String, +} + +#[derive(Clone)] +struct HookDoneTombstone { + exit_observed_at_ms: u64, + snapshot: HookSessionSnapshot, +} + +#[derive(Clone)] +struct HookSessionSnapshot { + cwd: String, + project_name: String, + started_at: u64, + model: String, + effort: String, + context_percent: f64, + total_input_tokens: u64, + total_output_tokens: u64, + total_cache_read: u64, + total_cache_create: u64, + turn_count: u32, + version: String, + git_added: u32, + git_modified: u32, + token_history: Vec, + context_history: Vec, + compaction_count: u32, + context_window: u64, + mem_file_count: u32, + mem_line_count: u32, + config_root: String, +} + +impl HookSessionSnapshot { + fn capture(session: &AgentSession) -> Self { + Self { + cwd: session.cwd.clone(), + project_name: session.project_name.clone(), + started_at: session.started_at, + model: session.model.clone(), + effort: session.effort.clone(), + context_percent: session.context_percent, + total_input_tokens: session.total_input_tokens, + total_output_tokens: session.total_output_tokens, + total_cache_read: session.total_cache_read, + total_cache_create: session.total_cache_create, + turn_count: session.turn_count, + version: session.version.clone(), + git_added: session.git_added, + git_modified: session.git_modified, + token_history: session.token_history.clone(), + context_history: session.context_history.clone(), + compaction_count: session.compaction_count, + context_window: session.context_window, + mem_file_count: session.mem_file_count, + mem_line_count: session.mem_line_count, + config_root: session.config_root.clone(), + } + } + + fn done_session(&self, key: &HookDoneKey, exit_observed_at_ms: u64) -> AgentSession { + AgentSession { + agent_cli: "codex", + pid: key.pid, + action_process_incarnation: None, + session_id: key.session_id.clone(), + cwd: self.cwd.clone(), + project_name: self.project_name.clone(), + started_at: self.started_at, + status: SessionStatus::Done, + status_evidence: status_evidence( + SessionStatus::Done, + StatusAuthority::Heuristic, + StatusReason::ProcessExited, + exit_observed_at_ms, + 0, + ), + model: self.model.clone(), + effort: self.effort.clone(), + context_percent: self.context_percent, + total_input_tokens: self.total_input_tokens, + total_output_tokens: self.total_output_tokens, + total_cache_read: self.total_cache_read, + total_cache_create: self.total_cache_create, + turn_count: self.turn_count, + current_tasks: vec!["finished".to_string()], + mem_mb: 0, + version: self.version.clone(), + git_branch: String::new(), + git_added: self.git_added, + git_modified: self.git_modified, + token_history: self.token_history.clone(), + context_history: self.context_history.clone(), + compaction_count: self.compaction_count, + context_window: self.context_window, + subagents: Vec::new(), + mem_file_count: self.mem_file_count, + mem_line_count: self.mem_line_count, + children: Vec::new(), + initial_prompt: String::new(), + first_assistant_text: String::new(), + chat_messages: Vec::new(), + tool_calls: Vec::new(), + pending_since_ms: 0, + awaiting_input: false, + thinking_since_ms: 0, + file_accesses: Vec::new(), + config_root: self.config_root.clone(), + } + } +} + +#[derive(Clone, Debug)] +struct HookCollectorRecord { + generation_id: String, + session_id: String, + cwd: String, + started_at_ms: u64, + observed_at_ms: u64, + status_since_ms: u64, + ended_at_ms: u64, + exit_observed_at_ms: u64, + /// The last exact live root binding used a compatible Codex release. + exit_supported_rollout_correlated: bool, + pid: u32, + process_incarnation: Option, + process_state: HookProcessState, + native_process_verified: bool, + /// Current process ownership and the matched root rollout both attest the audited release. + supported_release_attested: bool, + /// Exact, thread-bound proof that this live Codex process actually loaded + /// and enabled abtop's complete hook engine. Supported Codex releases expose no such + /// proof, so production state conversion always leaves this false. It is + /// an actionability boundary; exact lifecycle shapes may still support a + /// conservative, non-actionable heuristic display. + effective_hook_engine_attested: bool, + actionable: bool, + owns_resources: bool, + local_config_ambiguous: bool, + interaction_ambiguous: bool, + subagent_set_complete: bool, + turn_id: Option, + prompt_observed_at_ms: u64, + stop_observed_at_ms: u64, + tool_opened_at_ms: HashMap, + tool_classes: HashMap, + subagent_opened_at_ms: HashMap, + subagent_stopped_at_ms: HashMap, + candidate: HookCandidate, + observations: Vec, +} + +fn hook_record_is_active_generation(record: &HookCollectorRecord) -> bool { + !matches!(record.candidate, HookCandidate::Ended) + && record.ended_at_ms == 0 + && record.process_state == HookProcessState::Live +} + +fn hook_candidate_allows_exit_transition(candidate: &HookCandidate) -> bool { + match candidate { + HookCandidate::Unknown( + StatusReason::HookInteractionResolutionUnavailable | StatusReason::HookToolOpen, + ) => true, + HookCandidate::Unknown(_) => false, + HookCandidate::TurnOpen + | HookCandidate::ToolOpen(_) + | HookCandidate::SubagentOpen { .. } + | HookCandidate::TurnStopped + | HookCandidate::Ended => true, + } +} + +fn hook_record_process_key(record: &HookCollectorRecord) -> Option<(u32, String)> { + (record.pid != 0) + .then(|| { + record + .process_incarnation + .as_ref() + .filter(|incarnation| !incarnation.is_empty()) + .map(|incarnation| (record.pid, incarnation.clone())) + }) + .flatten() +} + +fn hook_done_key(record: &HookCollectorRecord) -> Option { + let (_, process_incarnation) = hook_record_process_key(record)?; + (!record.session_id.is_empty() && !record.generation_id.is_empty()).then(|| HookDoneKey { + session_id: record.session_id.clone(), + generation_id: record.generation_id.clone(), + pid: record.pid, + process_incarnation, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CurrentGenerationSelection { + Absent, + Unique(usize), + Ambiguous, +} + +fn classify_current_generation( + candidates: impl IntoIterator, +) -> CurrentGenerationSelection { + let mut candidates = candidates.into_iter(); + let Some(first) = candidates.next() else { + return CurrentGenerationSelection::Absent; + }; + if candidates.next().is_some() { + CurrentGenerationSelection::Ambiguous + } else { + CurrentGenerationSelection::Unique(first) + } +} + +fn agreed_current_generation( + rollout: CurrentGenerationSelection, + herdr: CurrentGenerationSelection, +) -> Option { + use CurrentGenerationSelection::{Absent, Ambiguous, Unique}; + + match (rollout, herdr) { + (Unique(left), Unique(right)) if left == right => Some(left), + (Unique(index), Absent) | (Absent, Unique(index)) => Some(index), + (Absent, Absent) | (Ambiguous, _) | (_, Ambiguous) | (Unique(_), Unique(_)) => None, + } +} + +/// Select the one current thread hosted by an exact native Codex process. +/// +/// Codex can switch threads in place without ending the prior hook generation, +/// leaving several live-looking records on one PID/incarnation. A unique +/// process-owned rollout or exact Herdr identity may choose the display record; +/// disagreement remains fail-closed. This selection is status-only: it never +/// grants process actions or resource ownership. +fn reconcile_current_hook_generations( + records: &mut Vec, + sessions: &mut Vec, + rollout_lifecycle: &HashMap, + herdr_observations: &HashMap<(String, u32), HerdrObservation>, + process_info: &HashMap, + eligible_pids: &HashSet, +) { + let mut groups = HashMap::<(u32, String), Vec>::new(); + for (index, record) in records.iter().enumerate() { + if !hook_record_is_active_generation(record) { + continue; + } + if let Some(key) = hook_record_process_key(record) { + groups.entry(key).or_default().push(index); + } + } + + let mut suppressed = HashSet::new(); + for indices in groups.values().filter(|indices| indices.len() > 1) { + let rollout_selection = + classify_current_generation(indices.iter().copied().filter(|index| { + let record = &records[*index]; + if !record.native_process_verified || !eligible_pids.contains(&record.pid) { + return false; + } + let mut matching = sessions.iter().filter(|session| { + session.session_id == record.session_id + && session.pid == record.pid + && session.cwd == record.cwd + && process_info.contains_key(&session.pid) + && rollout_lifecycle + .get(&record.session_id) + .is_some_and(|lifecycle| { + lifecycle.lifecycle_valid + && session.version == lifecycle.root_cli_version + }) + }); + matching.next().is_some() && matching.next().is_none() + })); + let herdr_selection = + classify_current_generation(indices.iter().copied().filter(|index| { + let record = &records[*index]; + record.native_process_verified + && eligible_pids.contains(&record.pid) + && herdr_observations.contains_key(&(record.session_id.clone(), record.pid)) + })); + let Some(selected) = agreed_current_generation(rollout_selection, herdr_selection) else { + continue; + }; + + // Multiple persisted records still claim this process. The selected + // record can describe status, but Herdr/current-thread selection never + // resolves destructive-action or resource ownership. + records[selected].actionable = false; + records[selected].owns_resources = false; + suppressed.extend(indices.iter().copied().filter(|index| *index != selected)); + } + + if suppressed.is_empty() { + return; + } + let suppressed_claims = suppressed + .iter() + .map(|index| { + let record = &records[*index]; + (record.session_id.clone(), record.pid, record.cwd.clone()) + }) + .collect::>(); + sessions.retain(|session| { + !suppressed_claims.iter().any(|(session_id, pid, cwd)| { + session.session_id == *session_id + && session.cwd == *cwd + && (session.pid == 0 || session.pid == *pid) + }) + }); + let mut index = 0_usize; + records.retain(|_| { + let keep = !suppressed.contains(&index); + index += 1; + keep + }); +} + +fn cwd_has_unattested_codex_config(cwd: &str, codex_home: &Path) -> bool { + let cwd = Path::new(cwd); + if !cwd.is_absolute() { + return true; + } + let Ok(canonical_cwd) = fs::canonicalize(cwd) else { + return true; + }; + let Ok(canonical_codex_home) = fs::canonicalize(codex_home) else { + return true; + }; + for ancestor in canonical_cwd.ancestors() { + let project_config = ancestor.join(".codex"); + let is_attested_base = + fs::canonicalize(&project_config).is_ok_and(|path| path == canonical_codex_home); + for name in ["config.toml", ".config.lock.toml", "config.lock.toml"] { + if name == "config.toml" && is_attested_base { + continue; + } + match fs::symlink_metadata(project_config.join(name)) { + Ok(_) => return true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return true, + } + } + } + false } #[derive(Clone, Copy)] @@ -40,6 +872,12 @@ struct CodexProcessContext { unknown_process_owner: bool, } +struct CodexCliSessionGroupLoad { + session: Option, + rate_limit: Option, + owned_paths: Vec, +} + struct DesktopRecentRolloutScanResult { rollouts: Vec, } @@ -105,71 +943,1348 @@ impl DesktopRecentRolloutScanner { } } -impl CodexCollector { - pub fn new() -> Self { - let home = dirs::home_dir().unwrap_or_default(); - Self { - sessions_dir: home.join(".codex").join("sessions"), - last_rate_limit: None, - desktop_recent_scanner: DesktopRecentRolloutScanner::new(), - } - } +fn unix_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} - fn collect_sessions(&mut self, shared: &super::SharedProcessData) -> Vec { - if !self.sessions_dir.exists() { - self.last_rate_limit = None; - return vec![]; - } +fn status_evidence( + status: SessionStatus, + authority: StatusAuthority, + reason: StatusReason, + observed_at_ms: u64, + connection_generation: u64, +) -> StatusEvidence { + let mut evidence = StatusEvidence::default(); + evidence.observe(StatusObservation::new( + status, + authority, + reason, + observed_at_ms, + connection_generation, + )); + evidence +} - // Reset live rate limit each pass — only keep it if a current session provides one - self.last_rate_limit = None; +fn timestamp_is_recent_past(now_ms: u64, timestamp_ms: u64, maximum_age_ms: u64) -> bool { + timestamp_ms != 0 && timestamp_ms <= now_ms && now_ms - timestamp_ms <= maximum_age_ms +} - // Step 1: Find running codex processes from shared ps data (no extra ps call). - // When MCP suppression is on, exclude `codex mcp-server` PIDs — those - // are surfaced through the MCP servers panel instead. See issue #95. - let codex_pids = - Self::find_codex_pids_from_shared(&shared.process_info, &shared.mcp_server_pids); - let just_pids: Vec = codex_pids.iter().map(|(p, _)| *p).collect(); - let pid_to_jsonl = Self::map_pid_to_jsonl(&just_pids, &self.sessions_dir); - let pid_is_exec: HashMap = codex_pids.into_iter().collect(); +fn is_native_codex_executable(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + let name = name.to_ascii_lowercase(); + (name == "codex" || name == "codex.exe" || name.starts_with("codex-")) + && name != "codex-app-server" + && name != "codex-app-server.exe" + && name != "codex-code-mode-host" + && name != "codex-code-mode-host.exe" +} - let mut sessions = Vec::new(); - let mut seen_jsonl = std::collections::HashSet::new(); +/// Bind hook state to one exact native Codex process observation. Incarnation +/// reads bracket argv/executable classification so PID reuse fails closed. +pub(super) fn native_codex_process_is_exact(pid: u32, expected_incarnation: &str) -> bool { + let Some(before) = process::get_process_incarnation(pid) else { + return false; + }; + if before != expected_incarnation { + return false; + } + let Some(executable) = process::get_process_executable(pid) else { + return false; + }; + let Some(argv) = process::get_process_argv(pid) else { + return false; + }; + let Some(after) = process::get_process_incarnation(pid) else { + return false; + }; + if after != expected_incarnation || !is_native_codex_executable(&executable) { + return false; + } + !argv.iter().skip(1).any(|argument| { + matches!( + argument.to_str(), + Some( + "app-server" + | "daemon" + | "mcp-server" + | "remote-control" + | "exec-server" + | "codex-code-mode-host" + ) + ) + }) +} - // Active sessions: running codex processes with open JSONL files - for (pid, jsonl_path) in &pid_to_jsonl { - let is_exec = pid_is_exec.get(pid).copied().unwrap_or(false); - if let Some((session, rl)) = self.load_session_with_rate_limit( - CodexProcessContext { - pid: Some(*pid), - is_exec, - owns_process_tree: true, - unknown_process_owner: false, - }, - jsonl_path, - &shared.process_info, - &shared.children_map, - &shared.ports, - ) { - seen_jsonl.insert(jsonl_path.clone()); - if let Some(new_rl) = rl { - let newer = self - .last_rate_limit - .as_ref() - .is_none_or(|old| new_rl.updated_at > old.updated_at); - if newer { - super::rate_limit::write_codex_cache(&new_rl); - self.last_rate_limit = Some(new_rl); - } - } - sessions.push(session); +fn hook_record_from_state(state: HookSessionState) -> HookCollectorRecord { + let projection = state.projection(); + let prompt_observed_at_ms = state.prompt_observed_at_ms; + let stop_observed_at_ms = state.stop_observed_at_ms; + let tool_opened_at_ms = state + .tool_opened_at_ms + .iter() + .map(|(id, timestamp)| (id.clone(), *timestamp)) + .collect::>(); + let tool_classes = state + .open_tools + .iter() + .map(|(id, class)| (id.clone(), *class)) + .collect::>(); + let subagent_opened_at_ms = state + .subagent_opened_at_ms + .iter() + .map(|(id, timestamp)| (id.clone(), *timestamp)) + .collect::>(); + let subagent_stopped_at_ms = state + .subagent_stopped_at_ms + .iter() + .map(|(id, timestamp)| (id.clone(), *timestamp)) + .collect::>(); + let turn_id = match &projection { + HookProjection::TurnOpen | HookProjection::ToolOpen(_) => state.active_turn_id.clone(), + HookProjection::SubagentOpen { root, .. } => match root { + HookRootProjection::TurnStopped => state.stop_turn_id.clone(), + HookRootProjection::TurnOpen | HookRootProjection::ToolOpen(_) => { + state.active_turn_id.clone() } + HookRootProjection::Unknown(_) | HookRootProjection::Ended => None, + }, + HookProjection::TurnStopped => state.stop_turn_id.clone(), + HookProjection::Unknown(_) | HookProjection::Ended => None, + }; + let candidate = match projection { + HookProjection::Unknown(reason) => HookCandidate::Unknown(reason), + HookProjection::TurnOpen => HookCandidate::TurnOpen, + HookProjection::ToolOpen(ids) => { + HookCandidate::ToolOpen(ids.into_iter().collect::>()) } + HookProjection::SubagentOpen { + active, + provisional, + root, + } => HookCandidate::SubagentOpen { + active: active.into_iter().collect::>(), + provisional: provisional.into_iter().collect::>(), + root: root.into(), + }, + HookProjection::TurnStopped => HookCandidate::TurnStopped, + HookProjection::Ended => HookCandidate::Ended, + }; + let status_since_ms = match &candidate { + HookCandidate::TurnOpen => prompt_observed_at_ms, + HookCandidate::ToolOpen(ids) => ids + .iter() + .filter_map(|id| tool_opened_at_ms.get(id).copied()) + .filter(|timestamp| *timestamp > 0) + .min() + .unwrap_or(0), + HookCandidate::SubagentOpen { + active, + provisional, + .. + } => active + .iter() + .filter_map(|id| subagent_opened_at_ms.get(id).copied()) + .chain( + provisional + .iter() + .filter_map(|id| subagent_stopped_at_ms.get(id).copied()), + ) + .filter(|timestamp| *timestamp > 0) + .min() + .unwrap_or(0), + HookCandidate::TurnStopped => stop_observed_at_ms, + HookCandidate::Unknown(_) | HookCandidate::Ended => state.updated_at_ms, + }; + let process_state = if state.process.matches_live_process() { + HookProcessState::Live + } else if state.process.confirmed_gone() { + HookProcessState::Gone + } else { + HookProcessState::Unverified + }; + let native_process_verified = process_state == HookProcessState::Live + && native_codex_process_is_exact(state.process.pid, &state.process.incarnation); + let process_shape_valid = state.process.started_at_ms > 0 + && state.process.started_at_ms <= state.created_at_ms + && state.created_at_ms <= state.updated_at_ms; + let candidate = if process_shape_valid { + candidate + } else { + HookCandidate::Unknown(StatusReason::HookStateMalformed) + }; + let actionable = native_process_verified && state.process.actionable(); + let interaction_ambiguous = state.interaction_ambiguous(); + let subagent_set_complete = state.integration.complete_hook_set; + let observations = state + .samples + .iter() + .map(|sample| { + // Persisted hook samples are lifecycle candidates. Only the + // current projection can be correlated with rollout/process + // evidence, so historical samples remain conservative. + StatusObservation::new( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + sample.reason, + sample.observed_at_ms, + 0, + ) + }) + .collect(); + + HookCollectorRecord { + generation_id: state.generation_id, + session_id: state.session_id, + cwd: state.cwd, + started_at_ms: state.created_at_ms, + observed_at_ms: state.updated_at_ms, + status_since_ms, + ended_at_ms: state.ended_at_ms, + exit_observed_at_ms: 0, + exit_supported_rollout_correlated: false, + pid: state.process.pid, + process_incarnation: Some(state.process.incarnation), + process_state, + native_process_verified, + supported_release_attested: false, + effective_hook_engine_attested: false, + actionable, + owns_resources: actionable, + local_config_ambiguous: false, + interaction_ambiguous, + subagent_set_complete, + turn_id, + prompt_observed_at_ms, + stop_observed_at_ms, + tool_opened_at_ms, + tool_classes, + subagent_opened_at_ms, + subagent_stopped_at_ms, + candidate, + observations, + } +} - let desktop_pids = Self::find_codex_desktop_pids_from_shared( - &shared.process_info, - &shared.mcp_server_pids, - ); +fn safe_rollout_task_preview(session: &AgentSession) -> Option { + let task = session + .current_tasks + .last() + .map(String::as_str) + .map(str::trim) + .filter(|task| { + !task.is_empty() + && !matches!( + *task, + "unknown" + | "idle" + | "thinking" + | "executing" + | "finished" + | "waiting for user input" + | "rate limited" + | "error" + | "authoritative status unavailable" + ) + })?; + // Tool names originate in provider data and are not bounded by the + // rollout schema. Bound work before allocating sanitized copies, but keep + // enough lookahead to redact a credential that begins near the 160-char + // display boundary. + let scan_bounded = task.chars().take(512).collect::(); + let terminal_safe = super::sanitize_terminal_text(&scan_bounded); + let known_redacted = super::redact_secrets(&terminal_safe); + let redacted = redact_generic_sk_token(&known_redacted); + let bounded = redacted.chars().take(160).collect::(); + (!bounded.trim().is_empty()).then_some(bounded) +} + +/// Redact legacy/unknown `sk-...` credentials not covered by the provider- +/// specific prefixes in the shared redactor. Require a lexical boundary and +/// a substantial token body so ordinary substrings such as `task-sketch`, +/// `sketch`, and short identifiers remain visible. +fn redact_generic_sk_token(input: &str) -> String { + const MIN_SECRET_BODY_CHARS: usize = 8; + + let mut output = String::with_capacity(input.len()); + let mut emitted = 0; + let mut scan = 0; + while let Some(relative) = input[scan..].find("sk-") { + let start = scan + relative; + let boundary = input[..start].chars().next_back().is_none_or(|character| { + !character.is_ascii_alphanumeric() && !matches!(character, '_' | '-') + }); + let body_start = start + "sk-".len(); + let body_end = input[body_start..] + .char_indices() + .find_map(|(offset, character)| { + (!character.is_ascii_alphanumeric() && !matches!(character, '_' | '-')) + .then_some(body_start + offset) + }) + .unwrap_or(input.len()); + let body = &input[body_start..body_end]; + let looks_secret = boundary + && body.chars().count() >= MIN_SECRET_BODY_CHARS + && body.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '_' | '-') + }); + + if looks_secret { + output.push_str(&input[emitted..start]); + output.push_str("[REDACTED]"); + emitted = body_end; + scan = body_end; + } else { + scan = body_start; + } + } + output.push_str(&input[emitted..]); + output +} + +fn collect_resource_children( + root_pid: u32, + process_info: &HashMap, + children_map: &HashMap>, + ports: &HashMap>, +) -> Vec { + let mut children = Vec::new(); + let mut stack = children_map.get(&root_pid).cloned().unwrap_or_default(); + let mut visited = HashSet::new(); + while let Some(pid) = stack.pop() { + if !visited.insert(pid) { + continue; + } + if let Some(process) = process_info.get(&pid) { + children.push(ChildProcess { + pid, + command: process.command.clone(), + mem_kb: process.rss_kb, + port: ports.get(&pid).and_then(|values| values.first().copied()), + }); + } + if let Some(descendants) = children_map.get(&pid) { + stack.extend(descendants); + } + } + children.sort_by_key(|child| child.pid); + children +} + +fn has_relevant_codex_process_descendant( + root_pid: u32, + process_info: &HashMap, + children_map: &HashMap>, + mcp_server_pids: &HashSet, +) -> bool { + let mut stack = children_map.get(&root_pid).cloned().unwrap_or_default(); + let mut visited = HashSet::new(); + while let Some(pid) = stack.pop() { + if !visited.insert(pid) { + continue; + } + if mcp_server_pids.contains(&pid) { + // Shared discovery has already proven this exact root as an MCP + // host. Its complete subtree is provider infrastructure. + continue; + } + let Some(child) = process_info.get(&pid) else { + // A process edge without a contemporaneous process snapshot cannot + // corroborate inactivity. + return true; + }; + if process::cmd_has_binary(&child.command, "codex-code-mode-host") { + if let Some(descendants) = children_map.get(&pid) { + stack.extend(descendants); + } + continue; + } + return true; + } + false +} + +fn mark_codex_status_unavailable(session: &mut AgentSession, now_ms: u64, reason: StatusReason) { + session.status = SessionStatus::Unknown; + session.action_process_incarnation = None; + session.status_evidence = status_evidence( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + reason, + now_ms, + 0, + ); + session.current_tasks = vec!["status evidence unavailable".to_string()]; + session.pending_since_ms = 0; + session.thinking_since_ms = 0; + session.awaiting_input = false; + session.enforce_status_contract(); +} + +fn hook_edge_timestamp_is_valid(record: &HookCollectorRecord, timestamp_ms: u64) -> bool { + timestamp_ms >= record.started_at_ms + && timestamp_ms <= record.observed_at_ms + && timestamp_ms > 0 +} + +fn hook_id_timestamps_are_exact( + record: &HookCollectorRecord, + ids: &HashSet, + timestamps: &HashMap, +) -> bool { + timestamps.len() == ids.len() + && ids.iter().all(|id| { + timestamps + .get(id) + .is_some_and(|timestamp| hook_edge_timestamp_is_valid(record, *timestamp)) + }) +} + +fn hook_open_tools_are_exact_ordinary( + ids: &HashSet, + classes: &HashMap, +) -> bool { + classes.len() == ids.len() + && ids.iter().all(|id| { + classes + .get(id) + .is_some_and(|class| *class == HookToolClass::Ordinary) + }) +} + +fn rollout_open_tools_are_exact_ordinary( + ids: &HashSet, + classes: &HashMap, +) -> bool { + classes.len() == ids.len() + && ids.iter().all(|id| { + classes + .get(id) + .is_some_and(|class| *class == RolloutToolClass::Ordinary) + }) +} + +fn code_mode_nested_call_id_is_exact(id: &str) -> bool { + let Some(uuid) = id.strip_prefix("exec-") else { + return false; + }; + let bytes = uuid.as_bytes(); + if bytes.len() != 36 + || ![8, 13, 18, 23] + .into_iter() + .all(|index| bytes[index] == b'-') + || bytes[14] != b'4' + || !matches!(bytes[19], b'8' | b'9' | b'a' | b'b') + { + return false; + } + bytes.iter().enumerate().all(|(index, byte)| { + matches!(index, 8 | 13 | 18 | 23) || byte.is_ascii_digit() || matches!(byte, b'a'..=b'f') + }) +} + +fn uuid_v7_timestamp_ms(id: &str) -> Option { + let bytes = id.as_bytes(); + if bytes.len() != 36 + || ![8, 13, 18, 23] + .into_iter() + .all(|index| bytes[index] == b'-') + || bytes[14] != b'7' + || !matches!(bytes[19], b'8' | b'9' | b'a' | b'b') + || !bytes.iter().enumerate().all(|(index, byte)| { + matches!(index, 8 | 13 | 18 | 23) + || byte.is_ascii_digit() + || matches!(byte, b'a'..=b'f') + }) + { + return None; + } + + let timestamp_hex = [&id[..8], &id[9..13]].concat(); + u64::from_str_radix(×tamp_hex, 16).ok() +} + +fn rollout_call_id_is_exact(id: &str) -> bool { + let Some(suffix) = id.strip_prefix("call_") else { + return false; + }; + !suffix.is_empty() + && suffix.len() <= 128 + && suffix.bytes().all(|byte| byte.is_ascii_alphanumeric()) +} + +fn completed_rollout_call_is_exact( + call_id: &str, + completion: &CompletedRolloutCall, + turn_started_at_ms: u64, + latest_lifecycle_at_ms: u64, +) -> bool { + rollout_call_id_is_exact(call_id) + && turn_started_at_ms <= completion.started_at_ms + && completion.started_at_ms <= completion.completed_at_ms + && completion.completed_at_ms <= latest_lifecycle_at_ms + && match completion.class { + RolloutToolClass::CodeModeExec { exec_started_at_ms } => { + exec_started_at_ms == completion.started_at_ms + } + RolloutToolClass::Ordinary + | RolloutToolClass::RequestUserInput + | RolloutToolClass::CodeModeWait { .. } + | RolloutToolClass::CodeModeUncorrelatable => !completion.code_mode_terminal, + } +} + +fn code_mode_open_tool_shape_is_exact( + hook_ids: &HashSet, + hook_classes: &HashMap, + hook_opened_at_ms: &HashMap, + state: &RolloutLifecycle, +) -> bool { + if !plugin::codex_version_has_verified_code_mode_shape(&state.root_cli_version) + || hook_ids.len() != 1 + || state.open_tool_ids.len() != 1 + || !hook_open_tools_are_exact_ordinary(hook_ids, hook_classes) + || state.open_tool_classes.len() != 1 + || state.code_mode_correlation_ambiguous + { + return false; + } + let Some(hook_id) = hook_ids.iter().next() else { + return false; + }; + let Some(rollout_id) = state.open_tool_ids.iter().next() else { + return false; + }; + let Some(rollout_started_at_ms) = state.open_tool_started_at_ms.get(rollout_id).copied() else { + return false; + }; + let Some(class) = state.open_tool_classes.get(rollout_id) else { + return false; + }; + let Some(hook_opened_at_ms) = hook_opened_at_ms.get(hook_id).copied() else { + return false; + }; + if !code_mode_nested_call_id_is_exact(hook_id) || !rollout_call_id_is_exact(rollout_id) { + return false; + } + match class { + RolloutToolClass::CodeModeExec { exec_started_at_ms } => { + state.live_code_mode_cells == 0 + && *exec_started_at_ms == rollout_started_at_ms + && state.turn_started_at_ms <= *exec_started_at_ms + && *exec_started_at_ms <= hook_opened_at_ms + } + RolloutToolClass::CodeModeWait { + exec_started_at_ms, + exec_yielded_at_ms, + } => { + state.live_code_mode_cells == 1 + && *exec_started_at_ms <= *exec_yielded_at_ms + && *exec_yielded_at_ms <= rollout_started_at_ms + && *exec_started_at_ms <= hook_opened_at_ms + && hook_opened_at_ms <= *exec_yielded_at_ms + } + RolloutToolClass::Ordinary + | RolloutToolClass::RequestUserInput + | RolloutToolClass::CodeModeUncorrelatable => false, + } +} + +fn exactly_completed_root_hook_ids( + record: &HookCollectorRecord, + hook_ids: &HashSet, + state: &RolloutLifecycle, + now_ms: u64, +) -> Option> { + if !hook_id_timestamps_are_exact(record, hook_ids, &record.tool_opened_at_ms) + || !hook_open_tools_are_exact_ordinary(hook_ids, &record.tool_classes) + || !state.root_is_exact_active(now_ms) + || state.active_turn_id != record.turn_id + || record.turn_id.is_none() + || state.code_mode_correlation_ambiguous + { + return None; + } + + let mut nested_end_timestamps = state + .nested_code_mode_end_at_ms + .values() + .copied() + .collect::>(); + nested_end_timestamps.sort_unstable(); + let completions_with_nested_identity = state + .completed_tool_calls + .iter() + .filter_map(|(outer_id, call)| { + let first = + nested_end_timestamps.partition_point(|timestamp| *timestamp < call.started_at_ms); + nested_end_timestamps + .get(first) + .is_some_and(|timestamp| *timestamp <= call.completed_at_ms) + .then_some(outer_id.as_str()) + }) + .collect::>(); + + let mut completed = HashSet::new(); + let mut nested_candidates = Vec::<(String, String)>::new(); + for hook_id in hook_ids { + let hook_opened_at_ms = record.tool_opened_at_ms.get(hook_id).copied()?; + if rollout_call_id_is_exact(hook_id) { + if state.completed_tool_calls.get(hook_id).is_some_and(|call| { + call.class == RolloutToolClass::Ordinary + && !state.open_tool_ids.contains(hook_id) + && call.started_at_ms <= hook_opened_at_ms + && hook_opened_at_ms <= call.completed_at_ms + && call.completed_at_ms <= record.observed_at_ms + }) { + completed.insert(hook_id.clone()); + } + continue; + } + if !plugin::codex_version_has_verified_code_mode_shape(&state.root_cli_version) + || !code_mode_nested_call_id_is_exact(hook_id) + { + continue; + } + let mut matching_outer_calls = + state + .completed_tool_calls + .iter() + .filter_map(|(outer_id, call)| { + matches!( + call.class, + RolloutToolClass::CodeModeExec { exec_started_at_ms } + if exec_started_at_ms == call.started_at_ms + ) + .then_some((outer_id, call)) + .filter(|(outer_id, call)| { + let nested_identity_observed = + completions_with_nested_identity.contains(outer_id.as_str()); + let nested_identity_matches = !nested_identity_observed + || state.nested_code_mode_end_at_ms.get(hook_id).is_some_and( + |ended_at_ms| { + call.started_at_ms <= *ended_at_ms + && hook_opened_at_ms <= *ended_at_ms + && *ended_at_ms <= call.completed_at_ms + }, + ); + call.code_mode_terminal + && !state.open_tool_ids.contains(*outer_id) + && call.started_at_ms <= hook_opened_at_ms + && hook_opened_at_ms <= call.completed_at_ms + && call.completed_at_ms <= record.observed_at_ms + && nested_identity_matches + }) + }); + let Some((outer_id, _)) = matching_outer_calls.next() else { + continue; + }; + if matching_outer_calls.next().is_none() { + nested_candidates.push((hook_id.clone(), outer_id.clone())); + } + } + + let mut outer_counts = HashMap::::new(); + for (_, outer_id) in &nested_candidates { + *outer_counts.entry(outer_id.clone()).or_default() += 1; + } + completed.extend( + nested_candidates + .into_iter() + .filter_map(|(hook_id, outer_id)| { + (outer_counts.get(&outer_id) == Some(&1)).then_some(hook_id) + }), + ); + Some(completed) +} + +fn reconciled_herdr_hook_record( + record: &HookCollectorRecord, + state: &RolloutLifecycle, + now_ms: u64, +) -> Option { + let root_hook_ids = match &record.candidate { + HookCandidate::ToolOpen(ids) => ids, + HookCandidate::SubagentOpen { + root: HookRootCandidate::ToolOpen(ids), + .. + } => ids, + _ => return Some(record.clone()), + }; + let completed = exactly_completed_root_hook_ids(record, root_hook_ids, state, now_ms)?; + if completed.is_empty() { + return Some(record.clone()); + } + let remaining = root_hook_ids + .difference(&completed) + .cloned() + .collect::>(); + let mut reconciled = record.clone(); + reconciled + .tool_opened_at_ms + .retain(|id, _| remaining.contains(id)); + reconciled + .tool_classes + .retain(|id, _| remaining.contains(id)); + reconciled.candidate = match &record.candidate { + HookCandidate::ToolOpen(_) if remaining.is_empty() => HookCandidate::TurnOpen, + HookCandidate::ToolOpen(_) => HookCandidate::ToolOpen(remaining.clone()), + HookCandidate::SubagentOpen { + active, + provisional, + root: HookRootCandidate::ToolOpen(_), + } => HookCandidate::SubagentOpen { + active: active.clone(), + provisional: provisional.clone(), + root: if remaining.is_empty() { + HookRootCandidate::TurnOpen + } else { + HookRootCandidate::ToolOpen(remaining.clone()) + }, + }, + _ => return Some(record.clone()), + }; + if matches!(reconciled.candidate, HookCandidate::TurnOpen) { + reconciled.status_since_ms = reconciled.prompt_observed_at_ms; + } else if matches!(reconciled.candidate, HookCandidate::ToolOpen(_)) { + reconciled.status_since_ms = remaining + .iter() + .filter_map(|id| reconciled.tool_opened_at_ms.get(id).copied()) + .min() + .unwrap_or(0); + } + Some(reconciled) +} + +fn subagent_root_tool_refinement_is_exact( + record: &HookCollectorRecord, + state: &RolloutLifecycle, + now_ms: u64, +) -> bool { + let HookCandidate::SubagentOpen { + active, + provisional, + root: HookRootCandidate::ToolOpen(_), + } = &record.candidate + else { + return matches!(record.candidate, HookCandidate::ToolOpen(_)); + }; + if !record.subagent_set_complete + || !active.is_empty() + || provisional.is_empty() + || !active.is_disjoint(provisional) + || !hook_id_timestamps_are_exact(record, provisional, &record.subagent_opened_at_ms) + || !hook_id_timestamps_are_exact(record, provisional, &record.subagent_stopped_at_ms) + || provisional.iter().any(|id| { + record + .subagent_opened_at_ms + .get(id) + .zip(record.subagent_stopped_at_ms.get(id)) + .is_none_or(|(opened, stopped)| stopped < opened) + }) + || !state.descendants_are_exact_terminal(now_ms) + { + return false; + } + let terminal_ids = state + .descendants + .iter() + .map(|child| child.session_id.clone()) + .collect::>(); + terminal_ids == *provisional + && state.descendants.iter().all(|child| { + record + .subagent_stopped_at_ms + .get(&child.session_id) + .is_some_and(|stopped| child.task_completed_at_ms >= *stopped) + }) +} + +fn project_hook_root_status( + candidate: &HookRootCandidate, + record: &HookCollectorRecord, + rollout: Option<&RolloutLifecycle>, + now_ms: u64, +) -> (SessionStatus, StatusAuthority, StatusReason) { + let unavailable = |reason| (SessionStatus::Unknown, StatusAuthority::Unavailable, reason); + match candidate { + HookRootCandidate::Unknown(reason) => unavailable(*reason), + HookRootCandidate::Ended => unavailable(StatusReason::HookEventGap), + HookRootCandidate::TurnOpen => { + if rollout.is_some_and(|state| { + hook_edge_timestamp_is_valid(record, record.prompt_observed_at_ms) + && state.root_is_exact_active(now_ms) + && state.active_turn_id == record.turn_id + && record.turn_id.is_some() + && state.open_tool_ids.is_empty() + && state.live_code_mode_cells == 0 + && !state.code_mode_correlation_ambiguous + && state.descendants_are_exact_terminal(now_ms) + && !state.relevant_process_descendant + }) { + ( + SessionStatus::Thinking, + StatusAuthority::Heuristic, + StatusReason::HookTurnOpen, + ) + } else { + unavailable(StatusReason::HookEventGap) + } + } + HookRootCandidate::ToolOpen(hook_ids) => { + let _edge_shape_valid = + hook_id_timestamps_are_exact(record, hook_ids, &record.tool_opened_at_ms); + let _ = (rollout, now_ms); + // Codex does not attest the process-effective PermissionRequest + // coverage. The same open PreToolUse/rollout call can therefore + // mean either execution or a selectively unobserved approval. + unavailable(StatusReason::HookInteractionResolutionUnavailable) + } + HookRootCandidate::TurnStopped => { + if rollout.is_some_and(|state| { + hook_edge_timestamp_is_valid(record, record.stop_observed_at_ms) + && state.lifecycle_valid + && state.task_complete + && state.completed_turn_id == record.turn_id + && record.turn_id.is_some() + && state.turn_started_at_ms > 0 + && state.turn_started_at_ms <= state.latest_lifecycle_at_ms + && state.latest_lifecycle_at_ms <= state.task_completed_at_ms + && state.task_completed_at_ms <= now_ms + && state.task_completed_at_ms >= record.stop_observed_at_ms + && !state.turn_active + && state.active_turn_id.is_none() + && state.open_tool_ids.is_empty() + && state.open_tool_started_at_ms.is_empty() + && state.open_tool_classes.is_empty() + && state.live_code_mode_cells == 0 + && !state.code_mode_correlation_ambiguous + && state.descendants_are_exact_terminal(now_ms) + && !state.relevant_process_descendant + }) { + ( + SessionStatus::Idle, + StatusAuthority::Heuristic, + StatusReason::HookTurnComplete, + ) + } else { + unavailable(StatusReason::HookEventGap) + } + } + } +} + +fn project_hook_status( + record: &HookCollectorRecord, + rollout: Option<&RolloutLifecycle>, + now_ms: u64, +) -> (SessionStatus, StatusAuthority, StatusReason) { + let unavailable = |reason| (SessionStatus::Unknown, StatusAuthority::Unavailable, reason); + if record.session_id.is_empty() + || record.observed_at_ms == 0 + || record.observed_at_ms > now_ms + || record.started_at_ms == 0 + || record.started_at_ms > record.observed_at_ms + || record.status_since_ms < record.started_at_ms + || record.status_since_ms > record.observed_at_ms + || record.observations.iter().any(|sample| { + sample.observed_at_ms == 0 || sample.observed_at_ms > record.observed_at_ms + }) + || record + .observations + .windows(2) + .any(|samples| samples[0].observed_at_ms > samples[1].observed_at_ms) + { + return unavailable(StatusReason::HookStateMalformed); + } + if record.process_state == HookProcessState::Live + && (record.pid == 0 + || record + .process_incarnation + .as_deref() + .is_none_or(str::is_empty) + || !record.native_process_verified) + { + return unavailable(StatusReason::OwnershipUnconfirmed); + } + if record.local_config_ambiguous { + return unavailable(StatusReason::HookConfigChanged); + } + if matches!(record.candidate, HookCandidate::Ended) { + return if record.pid != 0 + && record + .process_incarnation + .as_deref() + .is_some_and(|incarnation| !incarnation.is_empty()) + && record.process_state == HookProcessState::Gone + && record.exit_supported_rollout_correlated + && record.exit_observed_at_ms >= record.started_at_ms + && timestamp_is_recent_past(now_ms, record.exit_observed_at_ms, 30_000) + { + ( + SessionStatus::Done, + StatusAuthority::Heuristic, + StatusReason::ProcessExited, + ) + } else { + unavailable(StatusReason::OwnershipUnconfirmed) + }; + } + if !matches!(record.candidate, HookCandidate::Unknown(_)) + && (!record.supported_release_attested + || rollout.is_none_or(|state| !state.has_compatible_release_tree())) + { + return unavailable(StatusReason::HookIntegrationUnverified); + } + if record.interaction_ambiguous { + return unavailable(StatusReason::HookInteractionResolutionUnavailable); + } + + match &record.candidate { + HookCandidate::Unknown(reason) => unavailable(*reason), + HookCandidate::Ended => unreachable!("Ended is handled before live precedence"), + _ if record.process_state != HookProcessState::Live => { + unavailable(StatusReason::OwnershipUnconfirmed) + } + HookCandidate::TurnOpen => { + project_hook_root_status(&HookRootCandidate::TurnOpen, record, rollout, now_ms) + } + HookCandidate::ToolOpen(hook_ids) => project_hook_root_status( + &HookRootCandidate::ToolOpen(hook_ids.clone()), + record, + rollout, + now_ms, + ), + HookCandidate::SubagentOpen { + active, + provisional, + root, + } => { + match root { + HookRootCandidate::Unknown(reason) => return unavailable(*reason), + HookRootCandidate::Ended => return unavailable(StatusReason::HookEventGap), + HookRootCandidate::TurnOpen + | HookRootCandidate::ToolOpen(_) + | HookRootCandidate::TurnStopped => {} + } + if matches!(root, HookRootCandidate::ToolOpen(_)) + || rollout.is_some_and(|state| { + !state.open_tool_ids.is_empty() + || !state.open_tool_started_at_ms.is_empty() + || !state.open_tool_classes.is_empty() + || state.live_code_mode_cells > 0 + || state.code_mode_correlation_ambiguous + }) + { + // Root interaction ambiguity has higher precedence than + // exact background child work. A rollout call can also be + // present when the corresponding PreToolUse edge was missed. + return unavailable(StatusReason::HookInteractionResolutionUnavailable); + } + if !record.subagent_set_complete + || (active.is_empty() && provisional.is_empty()) + || !active.is_disjoint(provisional) + { + return unavailable(StatusReason::HookEventGap); + } + let tracked_ids = active + .iter() + .chain(provisional.iter()) + .cloned() + .collect::>(); + if !hook_id_timestamps_are_exact(record, &tracked_ids, &record.subagent_opened_at_ms) + || !hook_id_timestamps_are_exact( + record, + provisional, + &record.subagent_stopped_at_ms, + ) + || provisional.iter().any(|id| { + record + .subagent_opened_at_ms + .get(id) + .zip(record.subagent_stopped_at_ms.get(id)) + .is_none_or(|(opened, stopped)| stopped < opened) + }) + { + return unavailable(StatusReason::HookEventGap); + } + if rollout.is_some_and(|state| { + state.descendants.iter().any(|child| { + child.direct_child + && tracked_ids.contains(&child.session_id) + && child.is_exact_active_with_open_tool(now_ms) + }) + }) { + return unavailable(StatusReason::HookInteractionResolutionUnavailable); + } + let Some((rollout_active, rollout_terminal)) = + rollout.and_then(|state| state.exact_direct_child_sets(now_ms)) + else { + return unavailable(StatusReason::HookEventGap); + }; + if active.iter().any(|id| !rollout_active.contains(id)) + || provisional + .iter() + .any(|id| !rollout_active.contains(id) && !rollout_terminal.contains(id)) + { + return unavailable(StatusReason::HookEventGap); + } + let state = rollout.expect("exact child sets require rollout state"); + if active.iter().any(|id| { + state + .direct_child(id) + .zip(record.subagent_opened_at_ms.get(id)) + .is_none_or(|(child, opened)| child.latest_lifecycle_at_ms < *opened) + }) || provisional.iter().any(|id| { + state + .direct_child(id) + .zip(record.subagent_stopped_at_ms.get(id)) + .is_none_or(|(child, stopped)| { + if rollout_active.contains(id) { + child.latest_lifecycle_at_ms < *stopped + } else { + child.task_completed_at_ms < *stopped + } + }) + }) { + return unavailable(StatusReason::HookEventGap); + } + let expected_active = active + .iter() + .chain(provisional.iter().filter(|id| rollout_active.contains(*id))) + .cloned() + .collect::>(); + let expected_terminal = provisional + .iter() + .filter(|id| rollout_terminal.contains(*id)) + .cloned() + .collect::>(); + if rollout_active != expected_active || rollout_terminal != expected_terminal { + return unavailable(StatusReason::HookEventGap); + } + if !rollout_active.is_empty() { + ( + SessionStatus::Executing, + StatusAuthority::Heuristic, + StatusReason::HookSubagentActive, + ) + } else { + project_hook_root_status(root, record, rollout, now_ms) + } + } + HookCandidate::TurnStopped => { + project_hook_root_status(&HookRootCandidate::TurnStopped, record, rollout, now_ms) + } + } +} + +fn hook_record_has_exact_public_done(record: &HookCollectorRecord, now_ms: u64) -> bool { + matches!( + project_hook_status(record, None, now_ms), + ( + SessionStatus::Done, + StatusAuthority::Heuristic, + StatusReason::ProcessExited + ) + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct HerdrWorkingProjection { + status: SessionStatus, + status_since_ms: u64, + consecutive_matching: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct HerdrWorkingContinuityKey { + session_id: String, + pid: u32, + executing: bool, + status_since_ms: u64, +} + +fn project_herdr_working_status( + record: &HookCollectorRecord, + rollout: Option<&RolloutLifecycle>, + now_ms: u64, +) -> Option { + let (status, authority, reason) = project_hook_status(record, rollout, now_ms); + if authority == StatusAuthority::Heuristic + && matches!(status, SessionStatus::Thinking | SessionStatus::Executing) + { + return Some(HerdrWorkingProjection { + status, + status_since_ms: record.status_since_ms, + consecutive_matching: 0, + }); + } + if status != SessionStatus::Unknown + || authority != StatusAuthority::Unavailable + || reason != StatusReason::HookInteractionResolutionUnavailable + { + return None; + } + let state = rollout?; + let reconciled = reconciled_herdr_hook_record(record, state, now_ms)?; + let (status, authority, reason) = project_hook_status(&reconciled, Some(state), now_ms); + if authority == StatusAuthority::Heuristic + && matches!(status, SessionStatus::Thinking | SessionStatus::Executing) + { + return Some(HerdrWorkingProjection { + status, + status_since_ms: reconciled.status_since_ms, + consecutive_matching: 0, + }); + } + if status != SessionStatus::Unknown + || authority != StatusAuthority::Unavailable + || reason != StatusReason::HookInteractionResolutionUnavailable + { + return None; + } + let hook_ids = match &reconciled.candidate { + HookCandidate::ToolOpen(ids) => ids, + HookCandidate::SubagentOpen { + root: HookRootCandidate::ToolOpen(ids), + .. + } => ids, + _ => return None, + }; + let direct_tool_shape = !hook_ids.is_empty() + && state.open_tool_ids == *hook_ids + && state.live_code_mode_cells == 0 + && !state.code_mode_correlation_ambiguous + && hook_open_tools_are_exact_ordinary(hook_ids, &reconciled.tool_classes) + && rollout_open_tools_are_exact_ordinary(&state.open_tool_ids, &state.open_tool_classes); + let code_mode_tool_shape = code_mode_open_tool_shape_is_exact( + hook_ids, + &reconciled.tool_classes, + &reconciled.tool_opened_at_ms, + state, + ); + if reconciled.process_state != HookProcessState::Live + || !reconciled.native_process_verified + || reconciled.local_config_ambiguous + || reconciled.interaction_ambiguous + || !reconciled.supported_release_attested + || !state.has_compatible_release_tree() + || !state.root_is_exact_active(now_ms) + || state.active_turn_id != reconciled.turn_id + || reconciled.turn_id.is_none() + || !subagent_root_tool_refinement_is_exact(&reconciled, state, now_ms) + // Direct tools retain exact shared IDs. In audited Code Mode + // releases, Codex intentionally wraps one nested `exec-` hook + // inside one outer rollout `exec` / linked `wait` call with a separate + // `call_*` ID. No generic cardinality or timestamp guess is accepted. + || (!direct_tool_shape && !code_mode_tool_shape) + || !hook_id_timestamps_are_exact( + &reconciled, + hook_ids, + &reconciled.tool_opened_at_ms, + ) + || !state.descendants_are_exact_terminal(now_ms) + { + return None; + } + let rollout_since_ms = state + .open_tool_started_at_ms + .values() + .copied() + .min() + .unwrap_or(0); + Some(HerdrWorkingProjection { + status: SessionStatus::Executing, + // The later source-local edge starts the currently corroborated + // execution interval. This also resets continuity when a long-running + // `exec` is followed by a distinct provider `wait` call. + status_since_ms: reconciled.status_since_ms.max(rollout_since_ms), + consecutive_matching: 0, + }) +} + +fn apply_herdr_observation( + session: &mut AgentSession, + observation: HerdrObservation, + working_projection: Option, + rollout_preview: Option<&String>, +) { + if session.status == SessionStatus::Done + || session.status_evidence.authority == StatusAuthority::Provider + { + return; + } + let (status, authority, reason, status_since_ms, consecutive_matching) = + match observation.status { + HerdrStatus::Blocked => ( + SessionStatus::Waiting, + StatusAuthority::Heuristic, + StatusReason::HerdrScreenBlocked, + observation.status_since_ms, + observation.consecutive_matching, + ), + HerdrStatus::Idle => ( + SessionStatus::Idle, + StatusAuthority::Heuristic, + StatusReason::HerdrScreenIdle, + observation.status_since_ms, + observation.consecutive_matching, + ), + HerdrStatus::Working => match working_projection { + Some(projection) => ( + projection.status, + StatusAuthority::Heuristic, + StatusReason::HerdrScreenWorking, + projection.status_since_ms, + projection.consecutive_matching.max(1), + ), + None => ( + SessionStatus::Working, + StatusAuthority::Heuristic, + StatusReason::HerdrWorkingUnrefined, + observation.status_since_ms, + observation.consecutive_matching, + ), + }, + }; + session.status_evidence.observe(StatusObservation::new( + status, + authority, + reason, + observation.observed_at_ms, + 0, + )); + session.status_evidence.status_since_ms = status_since_ms; + session.status_evidence.consecutive_matching = consecutive_matching; + session.status = status; + session.action_process_incarnation = None; + session.current_tasks = vec![match status { + SessionStatus::Executing => rollout_preview + .cloned() + .unwrap_or_else(|| "executing".to_string()), + SessionStatus::Working => "working".to_string(), + SessionStatus::Thinking => "thinking".to_string(), + SessionStatus::Waiting => "waiting for user input".to_string(), + SessionStatus::Idle => "idle".to_string(), + SessionStatus::Unknown => "status evidence unavailable".to_string(), + _ => unreachable!("Herdr projects only live Codex states"), + }]; + session.pending_since_ms = if status == SessionStatus::Executing { + status_since_ms + } else { + 0 + }; + session.thinking_since_ms = if status == SessionStatus::Thinking { + status_since_ms + } else { + 0 + }; + session.enforce_status_contract(); +} + +fn hook_task_label(status: SessionStatus, rollout_preview: Option) -> String { + match status { + SessionStatus::Thinking => "thinking".to_string(), + SessionStatus::Executing => rollout_preview.unwrap_or_else(|| "executing".to_string()), + SessionStatus::Working => "working".to_string(), + SessionStatus::Idle => "idle".to_string(), + SessionStatus::Done => "finished".to_string(), + SessionStatus::Unknown => "status evidence unavailable".to_string(), + // Supported Codex hook evidence never emits these live states. + SessionStatus::Waiting => "status evidence unavailable".to_string(), + SessionStatus::RateLimited => "status evidence unavailable".to_string(), + SessionStatus::Error => "status evidence unavailable".to_string(), + } +} + +impl CodexCollector { + pub fn new() -> Self { + let default_home = dirs::home_dir().unwrap_or_default().join(".codex"); + let codex_home = std::env::var_os("CODEX_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or(default_home); + Self { + sessions_dir: codex_home.join("sessions"), + last_rate_limit: None, + desktop_recent_scanner: DesktopRecentRolloutScanner::new(), + parse_cache: RefCell::new(CodexParseCache::default()), + rollout_lifecycle: RefCell::new(HashMap::new()), + hook_process_states: RefCell::new(HashMap::new()), + hook_exit_observations: RefCell::new(HashMap::new()), + hook_process_rollout_bindings: RefCell::new(HashMap::new()), + hook_live_session_snapshots: RefCell::new(HashMap::new()), + hook_done_tombstones: RefCell::new(HashMap::new()), + herdr_status_resolver: RefCell::new(HerdrStatusResolver::default()), + herdr_working_continuity: RefCell::new(HashMap::new()), + } + } + + fn collect_sessions(&mut self, shared: &super::SharedProcessData) -> Vec { + let now_ms = unix_now_ms(); + self.rollout_lifecycle.borrow_mut().clear(); + + // Reset live rate limit each pass — only keep it if a current session provides one. + self.last_rate_limit = None; + if !self.sessions_dir.exists() { + return self.finalize_hook_sessions(Vec::new(), shared, now_ms); + } + + // Step 1: Find running codex processes from shared ps data (no extra ps call). + // When MCP suppression is on, exclude `codex mcp-server` PIDs — those + // are surfaced through the MCP servers panel instead. See issue #95. + let codex_pids = + Self::find_codex_pids_from_shared(&shared.process_info, &shared.mcp_server_pids); + let just_pids: Vec = codex_pids.iter().map(|(p, _)| *p).collect(); + let pid_to_jsonl = Self::map_pid_to_jsonl(&just_pids, &self.sessions_dir); + let pid_is_exec: HashMap = codex_pids.into_iter().collect(); + + let mut sessions = Vec::new(); + let mut seen_jsonl = std::collections::HashSet::new(); + + // Active sessions: running codex processes with open JSONL files + for (pid, jsonl_paths) in &pid_to_jsonl { + let is_exec = pid_is_exec.get(pid).copied().unwrap_or(false); + let loaded = self.load_cli_session_group( + CodexProcessContext { + pid: Some(*pid), + is_exec, + owns_process_tree: true, + unknown_process_owner: false, + }, + jsonl_paths, + &shared.process_info, + &shared.children_map, + &shared.ports, + &shared.mcp_server_pids, + ); + seen_jsonl.extend(loaded.owned_paths); + if let Some(session) = loaded.session { + if let Some(new_rl) = loaded.rate_limit { + let newer = self + .last_rate_limit + .as_ref() + .is_none_or(|old| new_rl.updated_at > old.updated_at); + if newer { + super::rate_limit::write_codex_cache(&new_rl); + self.last_rate_limit = Some(new_rl); + } + } + sessions.push(session); + } + } + + let desktop_pids = Self::find_codex_desktop_pids_from_shared( + &shared.process_info, + &shared.mcp_server_pids, + ); if !desktop_pids.is_empty() { let desktop_pid_to_rollouts: HashMap> = desktop_pids .iter() @@ -207,9 +2322,7 @@ impl CodexCollector { Self::sort_rollouts_by_mtime_desc(&mut desktop_rollout_paths); for path in desktop_rollout_paths { - let pid = desktop_pid_for_path - .get(&path) - .copied(); + let pid = desktop_pid_for_path.get(&path).copied(); let process_ctx = CodexProcessContext { pid, is_exec: false, @@ -222,6 +2335,7 @@ impl CodexCollector { &shared.process_info, &shared.children_map, &shared.ports, + &shared.mcp_server_pids, ) { seen_jsonl.insert(path); if let Some(new_rl) = rl { @@ -257,6 +2371,7 @@ impl CodexCollector { &shared.process_info, &shared.children_map, &shared.ports, + &shared.mcp_server_pids, ) { seen_jsonl.insert(path); if let Some(new_rl) = rl { @@ -274,8 +2389,8 @@ impl CodexCollector { } } - // Recently finished sessions: scan today's JSONL files not owned by any running process. - // This ensures Codex sessions transition to Done instead of vanishing. + // Recently modified unowned rollouts can enrich an exact hook exit + // tombstone with metrics. By themselves they never create Done rows. if let Some(recent_dir) = Self::today_session_dir(&self.sessions_dir) { if let Ok(entries) = fs::read_dir(&recent_dir) { for entry in entries.flatten() { @@ -320,6 +2435,7 @@ impl CodexCollector { &shared.process_info, &shared.children_map, &shared.ports, + &shared.mcp_server_pids, ) { if let Some(new_rl) = rl { let newer = self @@ -337,15 +2453,979 @@ impl CodexCollector { } } - sessions.sort_by_key(|s| std::cmp::Reverse(s.started_at)); - sessions + self.finalize_hook_sessions(sessions, shared, now_ms) } - /// Get today's session directory path: ~/.codex/sessions/YYYY/MM/DD - fn today_session_dir(sessions_dir: &Path) -> Option { - let now = chrono::Local::now(); - let dir = sessions_dir - .join(now.format("%Y").to_string()) + /// Overlay content-free Codex hook state onto rollout-derived metrics. + /// Direct CLI and Desktop rows remain Unknown when no validated hook state matches. + fn finalize_hook_sessions( + &self, + sessions: Vec, + shared: &super::SharedProcessData, + now_ms: u64, + ) -> Vec { + match self.read_hook_records(now_ms) { + Some(records) => { + self.finalize_hook_records_with_scan(sessions, records, shared, now_ms, true) + } + None => { + self.finalize_hook_records_with_scan(sessions, Vec::new(), shared, now_ms, false) + } + } + } + + /// Open only an already-existing private hook-state tree through the + /// store's non-mutating collector path. + fn read_hook_records(&self, now_ms: u64) -> Option> { + let codex_home = self.sessions_dir.parent()?; + let paths = PluginPaths::new(codex_home).ok()?; + let attestation = plugin::read_installation_attestation(codex_home).ok()??; + let current_exe = std::env::current_exe().ok()?; + let runtime = plugin::runtime_hook_config(codex_home, ¤t_exe).ok()?; + let expected = IntegrationIdentity { + hook_schema_revision: attestation.hook_schema_revision, + helper_digest: attestation.helper_digest, + installation_id: attestation.installation_id, + config_digest: runtime.config_digest, + complete_hook_set: runtime.complete_hook_set, + }; + let store = HookStateStore::open_existing(&paths.plugin_data_root, expected).ok()?; + let scan = store.read_all(now_ms).ok()?; + let mut records = scan + .states + .into_iter() + .map(hook_record_from_state) + .collect::>(); + for record in &mut records { + record.local_config_ambiguous = + cwd_has_unattested_codex_config(&record.cwd, codex_home); + } + if scan.rejected > 0 { + for record in &mut records { + record.candidate = HookCandidate::Unknown(StatusReason::HookStateMalformed); + record.actionable = false; + record.owns_resources = false; + } + } + Some(records) + } + + fn observe_hook_process_transitions( + &self, + records: &mut [HookCollectorRecord], + now_ms: u64, + scan_available: bool, + ) { + let seen = records + .iter() + .filter_map(hook_done_key) + .collect::>(); + if scan_available { + self.hook_process_states + .borrow_mut() + .retain(|key, _| seen.contains(key)); + self.hook_exit_observations + .borrow_mut() + .retain(|key, _| seen.contains(key)); + self.hook_process_rollout_bindings + .borrow_mut() + .retain(|key, _| seen.contains(key)); + } + + for record in records { + let Some(key) = hook_done_key(record) else { + continue; + }; + let exit_transition_eligible = hook_candidate_allows_exit_transition(&record.candidate) + && !record.local_config_ambiguous; + let observed_process_state = if record.process_state == HookProcessState::Live + && !record.native_process_verified + { + HookProcessState::Unverified + } else { + record.process_state + }; + let previous = self + .hook_process_states + .borrow_mut() + .insert(key.clone(), observed_process_state); + match observed_process_state { + HookProcessState::Live => { + self.hook_exit_observations.borrow_mut().remove(&key); + } + HookProcessState::Gone + if previous == Some(HookProcessState::Live) && exit_transition_eligible => + { + self.hook_exit_observations + .borrow_mut() + .entry(key.clone()) + .or_insert(now_ms); + } + HookProcessState::Gone | HookProcessState::Unverified => {} + } + if !exit_transition_eligible { + self.hook_exit_observations.borrow_mut().remove(&key); + self.hook_process_rollout_bindings.borrow_mut().remove(&key); + self.hook_done_tombstones.borrow_mut().remove(&key); + continue; + } + if let Some(exited_at_ms) = self.hook_exit_observations.borrow().get(&key).copied() { + record.exit_observed_at_ms = exited_at_ms; + record.observed_at_ms = exited_at_ms; + record.status_since_ms = exited_at_ms; + let binding = self + .hook_process_rollout_bindings + .borrow() + .get(&key) + .cloned(); + let binding_matches = binding + .as_ref() + .is_some_and(|binding| binding.session_id == record.session_id); + record.exit_supported_rollout_correlated = binding_matches + && binding + .as_ref() + .is_some_and(|binding| binding.supported_release); + record.candidate = match binding { + Some(binding) + if binding.session_id == record.session_id && binding.supported_release => + { + HookCandidate::Ended + } + Some(binding) if binding.session_id == record.session_id => { + HookCandidate::Unknown(StatusReason::HookIntegrationUnverified) + } + _ => HookCandidate::Unknown(StatusReason::OwnershipUnconfirmed), + }; + } + } + } + + fn trim_live_hook_snapshots(&self) { + let mut snapshots = self.hook_live_session_snapshots.borrow_mut(); + if snapshots.len() <= MAX_CODEX_DONE_TOMBSTONES { + return; + } + let mut oldest = snapshots + .iter() + .map(|(key, snapshot)| (snapshot.started_at, key.clone())) + .collect::>(); + oldest.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); + for (_, key) in oldest + .into_iter() + .take(snapshots.len() - MAX_CODEX_DONE_TOMBSTONES) + { + snapshots.remove(&key); + } + } + + fn prune_hook_done_tombstones(&self, now_ms: u64) { + let mut tombstones = self.hook_done_tombstones.borrow_mut(); + tombstones.retain(|key, tombstone| { + key.pid != 0 + && !key.process_incarnation.is_empty() + && timestamp_is_recent_past(now_ms, tombstone.exit_observed_at_ms, 30_000) + }); + if tombstones.len() <= MAX_CODEX_DONE_TOMBSTONES { + return; + } + let mut oldest = tombstones + .iter() + .map(|(key, tombstone)| (tombstone.exit_observed_at_ms, key.clone())) + .collect::>(); + oldest.sort(); + for (_, key) in oldest + .into_iter() + .take(tombstones.len() - MAX_CODEX_DONE_TOMBSTONES) + { + tombstones.remove(&key); + } + } + + fn remember_hook_done_tombstone( + &self, + record: &HookCollectorRecord, + current_session: &AgentSession, + now_ms: u64, + ) { + if !matches!(record.candidate, HookCandidate::Ended) + || record.process_state != HookProcessState::Gone + || !record.exit_supported_rollout_correlated + || !timestamp_is_recent_past(now_ms, record.exit_observed_at_ms, 30_000) + { + return; + } + let Some(key) = hook_done_key(record) else { + return; + }; + let mut snapshot = self + .hook_live_session_snapshots + .borrow() + .get(&key) + .cloned() + .unwrap_or_else(|| HookSessionSnapshot::capture(current_session)); + snapshot.cwd = record.cwd.clone(); + snapshot.project_name = process::last_path_segment(&record.cwd) + .unwrap_or("?") + .to_string(); + snapshot.started_at = record.started_at_ms; + self.hook_done_tombstones.borrow_mut().insert( + key, + HookDoneTombstone { + exit_observed_at_ms: record.exit_observed_at_ms, + snapshot, + }, + ); + self.prune_hook_done_tombstones(now_ms); + } + + #[cfg(test)] + fn finalize_hook_records( + &self, + sessions: Vec, + records: Vec, + shared: &super::SharedProcessData, + now_ms: u64, + ) -> Vec { + self.finalize_hook_records_with_scan(sessions, records, shared, now_ms, true) + } + + fn finalize_hook_records_with_scan( + &self, + sessions: Vec, + records: Vec, + shared: &super::SharedProcessData, + now_ms: u64, + hook_scan_available: bool, + ) -> Vec { + self.finalize_hook_records_with_scan_and_herdr( + sessions, + records, + shared, + now_ms, + hook_scan_available, + None, + ) + } + + fn reconcile_herdr_working_continuity( + &self, + projections: &mut HashMap<(String, u32), HerdrWorkingProjection>, + observations: &HashMap<(String, u32), HerdrObservation>, + ) { + let mut continuity = self.herdr_working_continuity.borrow_mut(); + let mut current = HashSet::new(); + for (target, projection) in projections { + let Some(observation) = observations + .get(target) + .filter(|observation| observation.status == HerdrStatus::Working) + else { + continue; + }; + let phase_since_ms = projection.status_since_ms; + let status_since_ms = observation.status_since_ms.max(phase_since_ms); + let key = HerdrWorkingContinuityKey { + session_id: target.0.clone(), + pid: target.1, + executing: projection.status == SessionStatus::Executing, + status_since_ms, + }; + let initial_count = if phase_since_ms <= observation.status_since_ms { + observation.consecutive_matching.max(1) + } else { + 1 + }; + let consecutive_matching = continuity + .entry(key.clone()) + .and_modify(|count| *count = count.saturating_add(1).max(initial_count)) + .or_insert(initial_count); + projection.status_since_ms = status_since_ms; + projection.consecutive_matching = *consecutive_matching; + current.insert(key); + } + continuity.retain(|key, _| current.contains(key)); + } + + #[cfg(test)] + fn finalize_hook_records_with_herdr( + &self, + sessions: Vec, + records: Vec, + shared: &super::SharedProcessData, + now_ms: u64, + herdr_observations: HashMap<(String, u32), HerdrObservation>, + ) -> Vec { + self.finalize_hook_records_with_scan_and_herdr( + sessions, + records, + shared, + now_ms, + true, + Some(herdr_observations), + ) + } + + fn finalize_hook_records_with_scan_and_herdr( + &self, + mut sessions: Vec, + mut records: Vec, + shared: &super::SharedProcessData, + now_ms: u64, + hook_scan_available: bool, + herdr_override: Option>, + ) -> Vec { + let eligible_pids = + Self::find_codex_pids_from_shared(&shared.process_info, &shared.mcp_server_pids) + .into_iter() + .map(|(pid, _)| pid) + .collect::>(); + let herdr_observations = herdr_override.unwrap_or_else(|| { + let mut targets = Vec::new(); + let mut seen = HashSet::<(String, u32, String)>::new(); + let mut push_target = |session_id: String, pid: u32, incarnation: String| { + if seen.insert((session_id.clone(), pid, incarnation.clone())) { + targets.push(HerdrTarget { + session_id, + pid, + expected_incarnation: Some(incarnation), + }); + } + }; + + for record in &records { + if !hook_record_is_active_generation(record) + || !record.native_process_verified + || !eligible_pids.contains(&record.pid) + { + continue; + } + if let Some(incarnation) = record + .process_incarnation + .as_ref() + .filter(|incarnation| !incarnation.is_empty()) + { + push_target(record.session_id.clone(), record.pid, incarnation.clone()); + } + } + for session in &sessions { + if session.pid == 0 + || session.status == SessionStatus::Done + || !eligible_pids.contains(&session.pid) + || !shared.process_info.contains_key(&session.pid) + { + continue; + } + let Some(incarnation) = process::get_process_incarnation(session.pid) else { + continue; + }; + if native_codex_process_is_exact(session.pid, &incarnation) { + push_target(session.session_id.clone(), session.pid, incarnation); + } + } + self.herdr_status_resolver + .borrow_mut() + .resolve(&targets, now_ms) + }); + { + let rollout_lifecycle = self.rollout_lifecycle.borrow(); + reconcile_current_hook_generations( + &mut records, + &mut sessions, + &rollout_lifecycle, + &herdr_observations, + &shared.process_info, + &eligible_pids, + ); + } + self.observe_hook_process_transitions(&mut records, now_ms, hook_scan_available); + self.prune_hook_done_tombstones(now_ms); + + // Persisted hook state can outlive the exact Codex process for hours. + // A collector that first observes that incarnation as already gone + // must neither fabricate a Done transition nor keep a PID=0 Unknown + // placeholder in the live Sessions list. Observe transitions first so + // an exact Live->Gone edge can still become the bounded Done row, then + // suppress only generations that have no public live or exit state. + let publicly_retained_record_ids = records + .iter() + .filter(|record| { + record.process_state != HookProcessState::Gone + || hook_record_has_exact_public_done(record, now_ms) + }) + .map(|record| record.session_id.clone()) + .collect::>(); + let suppressed_gone_session_ids = records + .iter() + .filter(|record| { + record.process_state == HookProcessState::Gone + && !hook_record_has_exact_public_done(record, now_ms) + && !publicly_retained_record_ids.contains(&record.session_id) + }) + .map(|record| record.session_id.clone()) + .collect::>(); + + let rollout_only_done_ids = sessions + .iter() + .filter(|session| session.status == SessionStatus::Done && session.pid == 0) + .map(|session| session.session_id.clone()) + .collect::>(); + let mut remaining = sessions.into_iter().map(Some).collect::>(); + let rollout_previews = remaining + .iter() + .flatten() + .filter_map(|session| { + safe_rollout_task_preview(session) + .map(|preview| (session.session_id.clone(), preview)) + }) + .collect::>(); + let mut herdr_working_status = HashMap::<(String, u32), HerdrWorkingProjection>::new(); + for session in remaining.iter_mut().flatten() { + mark_codex_status_unavailable(session, now_ms, StatusReason::HookIntegrationUnverified); + } + + records.retain(|record| { + !record.session_id.is_empty() + && (record.process_state != HookProcessState::Gone + || hook_record_has_exact_public_done(record, now_ms)) + && if matches!(record.candidate, HookCandidate::Ended) { + match record.process_state { + HookProcessState::Gone => { + timestamp_is_recent_past(now_ms, record.exit_observed_at_ms, 30_000) + } + HookProcessState::Live | HookProcessState::Unverified => { + timestamp_is_recent_past(now_ms, record.ended_at_ms, 30_000) + } + } + } else { + record.ended_at_ms == 0 + } + }); + let active_session_ids = records + .iter() + .filter(|record| hook_record_is_active_generation(record)) + .map(|record| record.session_id.clone()) + .collect::>(); + let active_process_keys = records + .iter() + .filter(|record| hook_record_is_active_generation(record)) + .filter_map(hook_record_process_key) + .collect::>(); + records.retain(|record| { + !matches!(record.candidate, HookCandidate::Ended) + || (!active_session_ids.contains(&record.session_id) + && hook_record_process_key(record) + .is_none_or(|key| !active_process_keys.contains(&key))) + }); + + // One exact process incarnation can accumulate several terminal hook + // generations (resume/clear/session replacement). Keep only the + // newest Gone generation, and likewise one newest Gone generation per + // session ID. Generation recency is creation time; exit observation + // time is shared and can be overwritten by transition detection. + let mut gone_session_max = HashMap::::new(); + let mut gone_process_max = HashMap::<(u32, String), (u64, usize)>::new(); + for record in records + .iter() + .filter(|record| record.process_state == HookProcessState::Gone) + { + let entry = gone_session_max + .entry(record.session_id.clone()) + .or_insert((record.started_at_ms, 0)); + if record.started_at_ms > entry.0 { + *entry = (record.started_at_ms, 1); + } else if record.started_at_ms == entry.0 { + entry.1 += 1; + } + if let Some(key) = hook_record_process_key(record) { + let entry = gone_process_max + .entry(key) + .or_insert((record.started_at_ms, 0)); + if record.started_at_ms > entry.0 { + *entry = (record.started_at_ms, 1); + } else if record.started_at_ms == entry.0 { + entry.1 += 1; + } + } + } + records.sort_by(|left, right| { + right + .started_at_ms + .cmp(&left.started_at_ms) + .then_with(|| left.generation_id.cmp(&right.generation_id)) + .then_with(|| left.session_id.cmp(&right.session_id)) + }); + let mut seen_gone_sessions = HashSet::new(); + let mut seen_gone_processes = HashSet::new(); + records.retain_mut(|record| { + if record.process_state != HookProcessState::Gone { + return true; + } + let process_key = hook_record_process_key(record); + if seen_gone_sessions.contains(&record.session_id) + || process_key + .as_ref() + .is_some_and(|key| seen_gone_processes.contains(key)) + { + return false; + } + let tied_session = + gone_session_max + .get(&record.session_id) + .is_some_and(|(started_at_ms, count)| { + *started_at_ms == record.started_at_ms && *count > 1 + }); + let tied_process = process_key.as_ref().is_some_and(|key| { + gone_process_max + .get(key) + .is_some_and(|(started_at_ms, count)| { + *started_at_ms == record.started_at_ms && *count > 1 + }) + }); + if tied_session || tied_process { + record.candidate = HookCandidate::Unknown(StatusReason::OwnershipUnconfirmed); + record.actionable = false; + record.owns_resources = false; + } + seen_gone_sessions.insert(record.session_id.clone()); + if let Some(key) = process_key { + seen_gone_processes.insert(key); + } + true + }); + records.sort_by(|left, right| { + let rank = |record: &HookCollectorRecord| { + if hook_record_is_active_generation(record) { + 0 + } else if !matches!(record.candidate, HookCandidate::Ended) { + 1 + } else { + 2 + } + }; + rank(left) + .cmp(&rank(right)) + .then_with(|| right.observed_at_ms.cmp(&left.observed_at_ms)) + .then_with(|| right.started_at_ms.cmp(&left.started_at_ms)) + .then_with(|| left.generation_id.cmp(&right.generation_id)) + .then_with(|| left.session_id.cmp(&right.session_id)) + }); + + let mut session_counts = HashMap::::new(); + let mut pid_counts = HashMap::::new(); + for record in &records { + if !hook_record_is_active_generation(record) { + continue; + } + *session_counts.entry(record.session_id.clone()).or_default() += 1; + if record.pid != 0 { + *pid_counts.entry(record.pid).or_default() += 1; + } + } + + let mut emitted_sessions = HashSet::new(); + let mut retained_live_snapshot_keys = HashSet::new(); + let mut result = Vec::new(); + for mut record in records { + if !emitted_sessions.insert(record.session_id.clone()) { + continue; + } + + let matching_rollouts = remaining + .iter() + .enumerate() + .filter_map(|(index, slot)| { + slot.as_ref() + .is_some_and(|session| session.session_id == record.session_id) + .then_some(index) + }) + .collect::>(); + let selected = matching_rollouts.first().copied(); + let selected_is_live = matching_rollouts.iter().any(|index| { + remaining[*index].as_ref().is_some_and(|session| { + session.pid != 0 && shared.process_info.contains_key(&session.pid) + }) + }); + let rollout_binding_conflict = record.process_state == HookProcessState::Live + && matching_rollouts.iter().any(|index| { + remaining[*index].as_ref().is_some_and(|session| { + (session.pid != 0 && session.pid != record.pid) || session.cwd != record.cwd + }) + }); + let rollout_pid_session_conflict = record.process_state == HookProcessState::Live + && remaining.iter().flatten().any(|session| { + session.pid == record.pid && session.session_id != record.session_id + }); + + // A recently ended generation must not relabel a resumed live + // rollout with the same thread ID as Done. + if matches!(record.candidate, HookCandidate::Ended) && selected_is_live { + continue; + } + + let active_generation = hook_record_is_active_generation(&record); + let ownership_conflict = rollout_binding_conflict + || rollout_pid_session_conflict + || (active_generation + && (session_counts + .get(&record.session_id) + .is_some_and(|count| *count > 1) + || (record.pid != 0 + && pid_counts.get(&record.pid).is_some_and(|count| *count > 1)))); + let process_visible = record.pid != 0 + && record.process_state == HookProcessState::Live + && record.native_process_verified + && eligible_pids.contains(&record.pid); + if ownership_conflict + || (record.process_state == HookProcessState::Live && !process_visible) + { + record.candidate = HookCandidate::Unknown(StatusReason::OwnershipUnconfirmed); + record.actionable = false; + record.owns_resources = false; + } + + let rollout = self + .rollout_lifecycle + .borrow() + .get(&record.session_id) + .cloned(); + let pidless_rollout_recovered = matching_rollouts.len() == 1 + && selected.is_some_and(|index| { + remaining[index].as_ref().is_some_and(|session| { + session.pid == 0 + && session.cwd == record.cwd + && process_visible + && !rollout_pid_session_conflict + }) + }); + let rollout_binding_exact = matching_rollouts.len() == 1 + && selected.is_some_and(|index| { + remaining[index].as_ref().is_some_and(|session| { + (session.pid == record.pid || pidless_rollout_recovered) + && session.cwd == record.cwd + && shared.process_info.contains_key(&record.pid) + && rollout.as_ref().is_some_and(|lifecycle| { + session.version == lifecycle.root_cli_version + }) + }) + }) + && rollout + .as_ref() + .is_some_and(|lifecycle| lifecycle.lifecycle_valid); + let supported_release = rollout_binding_exact + && rollout + .as_ref() + .is_some_and(RolloutLifecycle::has_compatible_release_tree); + record.supported_release_attested = active_generation + && process_visible + && !ownership_conflict + && !record.local_config_ambiguous + && supported_release; + if pidless_rollout_recovered { + // The hook/process binding can recover display status for an + // otherwise unowned rollout, but that recovery is not action + // or resource-ownership proof. + record.actionable = false; + record.owns_resources = false; + } + if active_generation + && (!record.supported_release_attested || !record.effective_hook_engine_attested) + { + record.actionable = false; + } + if active_generation { + if let Some(done_key) = hook_done_key(&record) { + let mut bindings = self.hook_process_rollout_bindings.borrow_mut(); + bindings.remove(&done_key); + if process_visible + && !ownership_conflict + && !pidless_rollout_recovered + && !record.local_config_ambiguous + && rollout_binding_exact + { + bindings.insert( + done_key, + HookProcessRolloutBinding { + session_id: record.session_id.clone(), + supported_release, + }, + ); + } + } + } + if active_generation && process_visible && !ownership_conflict { + let key = (record.session_id.clone(), record.pid); + if let Some(status) = + project_herdr_working_status(&record, rollout.as_ref(), now_ms) + { + herdr_working_status.insert(key, status); + } + } + let rollout_preview = rollout_previews.get(&record.session_id).cloned(); + let mut session = selected + .and_then(|index| remaining[index].take()) + .unwrap_or_else(|| self.hook_placeholder(&record)); + self.apply_hook_record( + &mut session, + &record, + rollout.as_ref(), + rollout_preview, + shared, + now_ms, + process_visible && !ownership_conflict, + ); + if active_generation { + if let Some(key) = hook_done_key(&record) { + if record.supported_release_attested { + self.hook_live_session_snapshots + .borrow_mut() + .insert(key.clone(), HookSessionSnapshot::capture(&session)); + retained_live_snapshot_keys.insert(key); + self.trim_live_hook_snapshots(); + } else { + self.hook_live_session_snapshots.borrow_mut().remove(&key); + } + } + } else if session.status == SessionStatus::Done { + self.remember_hook_done_tombstone(&record, &session, now_ms); + if let Some(key) = hook_done_key(&record) { + if let Some(tombstone) = self.hook_done_tombstones.borrow().get(&key) { + session = tombstone + .snapshot + .done_session(&key, tombstone.exit_observed_at_ms); + } + } + } + + // Duplicate rollout rows for one exact hook session are metadata + // aliases, not independent sessions. + for slot in &mut remaining { + if slot + .as_ref() + .is_some_and(|candidate| candidate.session_id == record.session_id) + { + *slot = None; + } + } + result.push(session); + } + + if hook_scan_available { + self.hook_live_session_snapshots + .borrow_mut() + .retain(|key, _| retained_live_snapshot_keys.contains(key)); + } + + let live_rollout_session_ids = remaining + .iter() + .flatten() + .filter(|session| session.pid != 0 && shared.process_info.contains_key(&session.pid)) + .map(|session| session.session_id.clone()) + .collect::>(); + self.hook_done_tombstones.borrow_mut().retain(|key, _| { + !active_session_ids.contains(&key.session_id) + && !active_process_keys.contains(&(key.pid, key.process_incarnation.clone())) + && !live_rollout_session_ids.contains(&key.session_id) + }); + let mut retained_done = self + .hook_done_tombstones + .borrow() + .iter() + .map(|(key, tombstone)| { + ( + tombstone.exit_observed_at_ms, + key.clone(), + tombstone + .snapshot + .done_session(key, tombstone.exit_observed_at_ms), + ) + }) + .collect::>(); + retained_done + .sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); + for (_, key, session) in retained_done { + if emitted_sessions.contains(&key.session_id) { + continue; + } + for slot in &mut remaining { + if slot + .as_ref() + .is_some_and(|candidate| candidate.session_id == key.session_id) + { + *slot = None; + } + } + emitted_sessions.insert(key.session_id); + result.push(session); + } + + result.extend( + remaining + .into_iter() + .flatten() + // Rollout completion is never process-exit proof. A matching + // exact hook tombstone above may still retain its metrics. + .filter(|session| !rollout_only_done_ids.contains(&session.session_id)) + // A matching unowned rollout is only metadata for a hook + // generation whose exact process is already gone. Preserve + // an independently live rollout with the same session ID. + .filter(|session| { + session.pid != 0 || !suppressed_gone_session_ids.contains(&session.session_id) + }), + ); + self.reconcile_herdr_working_continuity(&mut herdr_working_status, &herdr_observations); + for session in &mut result { + let key = (session.session_id.clone(), session.pid); + let Some(observation) = herdr_observations.get(&key).copied() else { + continue; + }; + apply_herdr_observation( + session, + observation, + herdr_working_status.get(&key).copied(), + rollout_previews.get(&session.session_id), + ); + } + result.sort_by_key(|session| std::cmp::Reverse(session.started_at)); + result + } + + fn hook_placeholder(&self, record: &HookCollectorRecord) -> AgentSession { + AgentSession { + agent_cli: "codex", + pid: 0, + action_process_incarnation: None, + session_id: record.session_id.clone(), + cwd: record.cwd.clone(), + project_name: process::last_path_segment(&record.cwd) + .unwrap_or("?") + .to_string(), + started_at: record.started_at_ms, + status: SessionStatus::Unknown, + status_evidence: StatusEvidence::default(), + model: String::new(), + effort: String::new(), + context_percent: 0.0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_read: 0, + total_cache_create: 0, + turn_count: 0, + current_tasks: Vec::new(), + mem_mb: 0, + version: String::new(), + git_branch: String::new(), + git_added: 0, + git_modified: 0, + token_history: Vec::new(), + context_history: Vec::new(), + compaction_count: 0, + context_window: 0, + subagents: Vec::new(), + mem_file_count: 0, + mem_line_count: 0, + children: Vec::new(), + initial_prompt: String::new(), + first_assistant_text: String::new(), + chat_messages: Vec::new(), + tool_calls: Vec::new(), + pending_since_ms: 0, + awaiting_input: false, + thinking_since_ms: 0, + file_accesses: Vec::new(), + config_root: super::abbrev_path(self.sessions_dir.parent().unwrap_or(Path::new("."))), + } + } + + #[allow(clippy::too_many_arguments)] + fn apply_hook_record( + &self, + session: &mut AgentSession, + record: &HookCollectorRecord, + rollout: Option<&RolloutLifecycle>, + rollout_preview: Option, + shared: &super::SharedProcessData, + now_ms: u64, + process_visible: bool, + ) { + let (status, authority, reason) = project_hook_status(record, rollout, now_ms); + let observed_at_ms = record.observed_at_ms.min(now_ms).max(1); + let mut evidence = StatusEvidence::default(); + for sample in record.observations.iter().rev().take(127).rev() { + evidence.observe(StatusObservation::new( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + sample.reason, + sample.observed_at_ms.min(now_ms), + 0, + )); + } + evidence.observe(StatusObservation::new( + status, + authority, + reason, + observed_at_ms, + 0, + )); + if record.status_since_ms > 0 && record.status_since_ms <= observed_at_ms { + evidence.status_since_ms = record.status_since_ms; + } + + session.status = status; + session.status_evidence = evidence; + session.awaiting_input = false; + session.current_tasks = vec![hook_task_label(status, rollout_preview)]; + session.pending_since_ms = if status == SessionStatus::Executing { + session.status_evidence.status_since_ms + } else { + 0 + }; + session.thinking_since_ms = if status == SessionStatus::Thinking { + session.status_evidence.status_since_ms + } else { + 0 + }; + + if process_visible && record.process_state == HookProcessState::Live { + session.pid = record.pid; + session.action_process_incarnation = record + .actionable + .then_some(status) + .filter(|status| { + matches!( + status, + SessionStatus::Idle | SessionStatus::Thinking | SessionStatus::Executing + ) + }) + .and(record.process_incarnation.clone()); + if record.owns_resources { + session.mem_mb = shared + .process_info + .get(&record.pid) + .map(|process| process.rss_kb / 1024) + .unwrap_or(0); + session.children = collect_resource_children( + record.pid, + &shared.process_info, + &shared.children_map, + &shared.ports, + ); + } else { + session.mem_mb = 0; + session.children.clear(); + } + } else { + session.pid = 0; + session.action_process_incarnation = None; + session.mem_mb = 0; + session.children.clear(); + } + session.enforce_status_contract(); + } + + /// Get today's session directory path: ~/.codex/sessions/YYYY/MM/DD + fn today_session_dir(sessions_dir: &Path) -> Option { + let now = chrono::Local::now(); + let dir = sessions_dir + .join(now.format("%Y").to_string()) .join(now.format("%m").to_string()) .join(now.format("%d").to_string()); if dir.exists() { @@ -535,60 +3615,248 @@ impl CodexCollector { process_info: &HashMap, children_map: &HashMap>, ports: &HashMap>, + mcp_server_pids: &HashSet, ) -> Option<(AgentSession, Option)> { - let result = parse_codex_jsonl(jsonl_path)?; - - let proc = process_ctx.pid.and_then(|p| process_info.get(&p)); - let mem_mb = if process_ctx.owns_process_tree { - proc.map(|p| p.rss_kb / 1024).unwrap_or(0) - } else { - 0 - }; - let display_pid = process_ctx.pid.unwrap_or(0); - - let project_name = process::last_path_segment(&result.cwd) - .unwrap_or("?") - .to_string(); + let result = self.parse_rollout_cached(jsonl_path)?; + self.build_session_with_rate_limit( + process_ctx, + result, + &[], + process_info, + children_map, + ports, + mcp_server_pids, + ) + } - // Status detection - // Note: Codex interactive sessions emit task_complete after every turn, - // so task_complete alone does NOT mean the session is finished when PID is alive. - // However, for exec (one-shot) sessions, task_complete means truly done. - let pid_alive = proc.is_some(); - // Mirrors Claude: trust the trailing-event-is-user signal alone. - // Codex tool outputs flow through response_item, not user_message, - // so model_generating only flips on real prompts. - let status = if process_ctx.unknown_process_owner { - SessionStatus::Unknown - } else if !pid_alive || (process_ctx.is_exec && result.task_complete) { + /// Load the current root rollout for one CLI PID and aggregate any child + /// rollouts held open by that process. Codex keeps subagent rollout file + /// descriptors open alongside the root, so selecting the last `lsof` path + /// would otherwise surface an arbitrary child as the interactive session. + fn load_cli_session_group( + &self, + process_ctx: CodexProcessContext, + jsonl_paths: &[PathBuf], + process_info: &HashMap, + children_map: &HashMap>, + ports: &HashMap>, + mcp_server_pids: &HashSet, + ) -> CodexCliSessionGroupLoad { + let mut owned_paths = jsonl_paths.to_vec(); + owned_paths.sort(); + owned_paths.dedup(); + let mut every_descriptor_parsed = true; + let parsed = jsonl_paths + .iter() + .filter_map(|path| match self.parse_rollout_cached(path) { + Some(result) => Some((path.clone(), result)), + None => { + every_descriptor_parsed = false; + None + } + }) + .collect(); + let Some(mut group) = select_codex_rollout_group(parsed) else { + return CodexCliSessionGroupLoad { + session: None, + rate_limit: None, + owned_paths, + }; + }; + // Every path came from the live process's open descriptor set. Dropping + // one unparseable descriptor could hide another active root or child, + // so the selected tree remains useful only for metrics. + group.lifecycle_valid &= every_descriptor_parsed; + let mut root = group.root; + root.lifecycle_valid &= group.lifecycle_valid; + let Some((session, rate_limit)) = self.build_session_with_rate_limit( + process_ctx, + root, + &group.children, + process_info, + children_map, + ports, + mcp_server_pids, + ) else { + return CodexCliSessionGroupLoad { + session: None, + rate_limit: None, + owned_paths, + }; + }; + CodexCliSessionGroupLoad { + session: Some(session), + rate_limit, + owned_paths, + } + } + + fn parse_rollout_cached(&self, path: &Path) -> Option { + let cache_clock = { + let mut cache = self.parse_cache.borrow_mut(); + cache.clock = cache.clock.saturating_add(1); + cache.clock + }; + let mut unstable_result = None; + for _ in 0..2 { + let canonical_path = fs::canonicalize(path).ok()?; + let file = open_rollout_file(path).ok()?; + let before = RolloutFingerprint::read(&file).ok()?; + + let cached = self + .parse_cache + .borrow() + .entries + .get(&canonical_path) + .filter(|entry| entry.fingerprint == before) + .map(|entry| entry.result.clone()); + if let Some(result) = cached { + let descriptor_after = RolloutFingerprint::read(&file).ok()?; + if descriptor_after == before + && rollout_path_matches_fingerprint(path, &canonical_path, &before) + { + if let Some(entry) = self + .parse_cache + .borrow_mut() + .entries + .get_mut(&canonical_path) + { + entry.last_used = cache_clock; + } + return Some(result); + } + continue; + } + + let mut result = parse_codex_open_file(&file)?; + let descriptor_after = RolloutFingerprint::read(&file).ok()?; + if descriptor_after == before + && rollout_path_matches_fingerprint(path, &canonical_path, &before) + { + let mut cache = self.parse_cache.borrow_mut(); + cache.entries.insert( + canonical_path, + CachedCodexParse { + fingerprint: before, + result: result.clone(), + last_used: cache_clock, + }, + ); + while cache.entries.len() > MAX_CODEX_PARSE_CACHE_ENTRIES { + let Some(oldest) = cache + .entries + .iter() + .min_by_key(|(_, entry)| entry.last_used) + .map(|(path, _)| path.clone()) + else { + break; + }; + cache.entries.remove(&oldest); + } + return Some(result); + } + + // Preserve metadata visibility for this tick, but ensure an + // unstable read cannot corroborate any positive hook status. + result.lifecycle_valid = false; + unstable_result = Some(result); + } + unstable_result + } + + #[allow(clippy::too_many_arguments)] + fn build_session_with_rate_limit( + &self, + process_ctx: CodexProcessContext, + result: CodexJSONLResult, + related_rollouts: &[CodexJSONLResult], + process_info: &HashMap, + children_map: &HashMap>, + ports: &HashMap>, + mcp_server_pids: &HashSet, + ) -> Option<(AgentSession, Option)> { + let proc = process_ctx.pid.and_then(|p| process_info.get(&p)); + let mem_mb = if process_ctx.owns_process_tree { + proc.map(|p| p.rss_kb / 1024).unwrap_or(0) + } else { + 0 + }; + let display_pid = process_ctx.pid.unwrap_or(0); + + let project_name = process::last_path_segment(&result.cwd) + .unwrap_or("?") + .to_string(); + + // Build a provisional rollout lifecycle for correlation and display + // metadata. No status computed in this block leaves the collector + // directly: finalize_hook_sessions replaces it with validated hook + // evidence or Unknown. + // + // Codex interactive sessions emit task_complete after every turn, so + // task_complete alone is not process-exit evidence. + let pid_alive = proc.is_some(); + // Codex emits exact task boundaries for each interactive turn. An + // assistant commentary message does not end the turn because tool calls + // or more model output may follow it. + let related_activity = related_rollouts.iter().any(|child| { + child.turn_active || child.pending_since_ms > 0 || child.awaiting_input_since_ms > 0 + }); + let session_done = !pid_alive + || (process_ctx.is_exec + && result.task_complete + && result.pending_since_ms == 0 + && !related_activity); + let related_awaiting_input = related_rollouts + .iter() + .any(|child| child.awaiting_input_since_ms > 0); + let awaiting_input = !process_ctx.unknown_process_owner + && !session_done + && (result.awaiting_input_since_ms > 0 || related_awaiting_input); + let status = if process_ctx.unknown_process_owner { + SessionStatus::Unknown + } else if session_done { SessionStatus::Done + } else if awaiting_input { + SessionStatus::Waiting } else { let has_active_child = process_ctx.owns_process_tree && process_ctx.pid.is_some_and(|p| { process::has_active_descendant(p, children_map, process_info, 5.0) }); - if has_active_child || result.pending_since_ms > 0 { + let has_active_subagent = related_rollouts + .iter() + .any(|child| child.turn_active || child.pending_since_ms > 0); + if has_active_child || result.pending_since_ms > 0 || has_active_subagent { SessionStatus::Executing - } else if result.model_generating { + } else if result.turn_active { SessionStatus::Thinking } else { - SessionStatus::Waiting + SessionStatus::Idle } }; + let active_subagent = related_rollouts + .iter() + .filter(|child| child.turn_active || child.pending_since_ms > 0) + .max_by_key(|child| child.last_activity); + // Current task from last tool use // For exec (one-shot) sessions, task_complete means truly finished. // For interactive sessions, task_complete fires after every turn — ignore it. - let current_tasks = if !result.current_task.is_empty() { + let current_tasks = if awaiting_input { + vec!["waiting for user input".to_string()] + } else if !result.current_task.is_empty() { vec![result.current_task] + } else if let Some(child) = active_subagent { + vec![format!("subagent {}", child.subagent_display_name())] } else if matches!(status, SessionStatus::Unknown) { vec!["unknown".to_string()] } else if !pid_alive || (process_ctx.is_exec && result.task_complete) { vec!["finished".to_string()] - } else if matches!(status, SessionStatus::Waiting) { - vec!["waiting for input".to_string()] + } else if matches!(status, SessionStatus::Idle) { + vec!["idle".to_string()] } else { - vec!["thinking...".to_string()] + vec!["thinking".to_string()] }; // Context window percentage from token usage @@ -625,17 +3893,106 @@ impl CodexCollector { // Git stats: populated by MultiCollector on slow ticks let (git_added, git_modified) = (0, 0); - let rate_limit = result.rate_limit.clone(); + let rate_limit = std::iter::once(result.rate_limit.as_ref()) + .chain( + related_rollouts + .iter() + .map(|child| child.rate_limit.as_ref()), + ) + .flatten() + .max_by_key(|info| info.updated_at) + .cloned(); + + let subagents = related_rollouts + .iter() + .map(|child| SubAgent { + name: child.subagent_display_name(), + status: if child.turn_active || child.pending_since_ms > 0 { + "working".to_string() + } else { + "done".to_string() + }, + tokens: child + .total_input + .saturating_add(child.total_output) + .saturating_add(child.total_cache_read), + }) + .collect(); + let pending_since_ms = std::iter::once(result.pending_since_ms) + .chain(related_rollouts.iter().map(|child| child.pending_since_ms)) + .filter(|started| *started > 0) + .min() + .unwrap_or(0); + + self.rollout_lifecycle.borrow_mut().insert( + result.session_id.clone(), + RolloutLifecycle { + root_cli_version: result.version.clone(), + turn_active: result.turn_active, + task_complete: result.task_complete, + lifecycle_valid: result.lifecycle_valid, + active_turn_id: result.active_turn_id.clone(), + completed_turn_id: result.completed_turn_id.clone(), + turn_started_at_ms: result.turn_started_at_ms, + latest_lifecycle_at_ms: result.latest_lifecycle_at_ms, + task_completed_at_ms: result.task_completed_at_ms, + // Ordinary root tools correlate only with root hook IDs. + // Child work is promoted through a complete SubagentStart / + // SubagentStop hook set after exact child-to-root mapping. + open_tool_ids: result.open_tool_ids.clone(), + open_tool_started_at_ms: result.open_tool_started_at_ms.clone(), + open_tool_classes: result.open_tool_classes.clone(), + completed_tool_calls: result.completed_tool_calls.clone(), + nested_code_mode_end_at_ms: result.nested_code_mode_end_at_ms.clone(), + live_code_mode_cells: result.live_code_mode_cells, + code_mode_correlation_ambiguous: result.code_mode_correlation_ambiguous, + descendants: related_rollouts + .iter() + .map(|child| DescendantRolloutLifecycle { + session_id: child.session_id.clone(), + cli_version: child.version.clone(), + direct_child: child.parent_thread_id.as_deref() + == Some(result.session_id.as_str()), + lifecycle_valid: child.lifecycle_valid, + turn_active: child.turn_active, + task_complete: child.task_complete, + active_turn_id: child.active_turn_id.clone(), + completed_turn_id: child.completed_turn_id.clone(), + turn_started_at_ms: child.turn_started_at_ms, + latest_lifecycle_at_ms: child.latest_lifecycle_at_ms, + task_completed_at_ms: child.task_completed_at_ms, + open_tool_ids: child.open_tool_ids.clone(), + open_tool_started_at_ms: child.open_tool_started_at_ms.clone(), + open_tool_classes: child.open_tool_classes.clone(), + live_code_mode_cells: child.live_code_mode_cells, + code_mode_correlation_ambiguous: child.code_mode_correlation_ambiguous, + }) + .collect(), + relevant_process_descendant: process_ctx.owns_process_tree + && process_ctx.pid.is_some_and(|pid| { + has_relevant_codex_process_descendant( + pid, + process_info, + children_map, + mcp_server_pids, + ) + }), + }, + ); Some(( AgentSession { agent_cli: "codex", pid: display_pid, + // Rollout/process association is useful for metadata, but only + // validated hook ownership can supply an action target. + action_process_incarnation: None, session_id: result.session_id, cwd: result.cwd, project_name, started_at: result.started_at, status, + status_evidence: Default::default(), model: result.model, effort: result.effort, context_percent, @@ -654,7 +4011,7 @@ impl CodexCollector { context_history: vec![], compaction_count: 0, context_window: result.context_window, - subagents: vec![], + subagents, mem_file_count: 0, mem_line_count: 0, children, @@ -662,7 +4019,8 @@ impl CodexCollector { first_assistant_text: String::new(), chat_messages: result.chat_messages, tool_calls: result.tool_calls, - pending_since_ms: result.pending_since_ms, + pending_since_ms, + awaiting_input, thinking_since_ms: result.thinking_since_ms, file_accesses: vec![], config_root: super::abbrev_path( @@ -689,9 +4047,17 @@ impl CodexCollector { continue; } let cmd = &info.command; - let is_exec = cmd.contains(" exec"); - let is_codex = process::cmd_has_binary(cmd, "codex"); - if is_codex && !cmd.contains(" app-server") && !cmd.contains("grep") { + let tokens = process::command_tokens(cmd); + let is_exec = tokens.iter().any(|token| token == "exec"); + let is_host = tokens.iter().any(|token| { + matches!( + token.as_str(), + "app-server" | "daemon" | "mcp-server" | "remote-control" + ) + }); + let is_codex = process::cmd_has_binary(cmd, "codex") + || process::cmd_has_binary(&cmd.replace('\\', "/"), "codex"); + if is_codex && !is_host && !cmd.contains("grep") { pids.push((*pid, is_exec)); } } @@ -740,14 +4106,15 @@ impl CodexCollector { pids } - /// Map codex PIDs to their open rollout-*.jsonl files. + /// Map Codex PIDs to every open rollout-*.jsonl file. A single CLI process + /// holds both its root rollout and spawned subagent rollouts open. /// /// On Linux, scans /proc/{pid}/fd symlinks directly (no process spawn). /// On Windows, scans ~/.codex/sessions/YYYY/MM/DD/ for recently modified /// JSONL files and assigns them to discovered PIDs, since Windows has no /// equivalent of lsof for enumerating open file descriptors. /// Falls back to lsof on macOS/other platforms. - fn map_pid_to_jsonl(pids: &[u32], sessions_dir: &Path) -> HashMap { + fn map_pid_to_jsonl(pids: &[u32], sessions_dir: &Path) -> HashMap> { // sessions_dir is consumed only by the windows arm below. #[cfg(not(target_os = "windows"))] let _ = sessions_dir; @@ -766,10 +4133,13 @@ impl CodexCollector { .and_then(|n| n.to_str()) .is_some_and(|n| n.starts_with("rollout-") && n.ends_with(".jsonl")); if is_rollout { - map.insert(pid, target); - break; + map.entry(pid).or_insert_with(Vec::new).push(target); } } + if let Some(paths) = map.get_mut(&pid) { + paths.sort(); + paths.dedup(); + } } map } @@ -806,7 +4176,7 @@ impl CodexCollector { // Assign candidates to PIDs (most recent file → first PID) for (i, &pid_u32) in pids.iter().enumerate() { if i < candidates.len() { - map.insert(pid_u32, candidates[i].0.clone()); + map.insert(pid_u32, vec![candidates[i].0.clone()]); } } @@ -832,12 +4202,18 @@ impl CodexCollector { } else if let Some(name) = line.strip_prefix('n') { if let Some(pid) = current_pid { if name.contains("rollout-") && name.ends_with(".jsonl") { - map.insert(pid, PathBuf::from(name)); + map.entry(pid) + .or_insert_with(Vec::new) + .push(PathBuf::from(name)); } } } } } + for paths in map.values_mut() { + paths.sort(); + paths.dedup(); + } map } } @@ -862,8 +4238,13 @@ impl super::AgentCollector for CodexCollector { } /// Parsed result from a Codex rollout JSONL file. +#[derive(Clone)] struct CodexJSONLResult { session_id: String, + /// Parent thread when this rollout belongs to a spawned Codex subagent. + parent_thread_id: Option, + /// Provider-assigned subagent nickname or path, kept bounded for display. + subagent_name: String, cwd: String, originator: String, started_at: u64, @@ -877,11 +4258,16 @@ struct CodexJSONLResult { turn_count: u32, current_task: String, task_complete: bool, - /// True iff the latest event in the rollout is a `user_message` with - /// no `agent_message` after it — i.e. the model has been prompted - /// but has not yet replied. Combined with recent rollout mtime this - /// gates the Thinking status. Mirrors Claude's `last_user_ts_ms > 0`. - model_generating: bool, + lifecycle_valid: bool, + active_turn_id: Option, + completed_turn_id: Option, + turn_started_at_ms: u64, + latest_lifecycle_at_ms: u64, + task_completed_at_ms: u64, + /// Exact interactive-turn lifecycle from task_started through + /// task_complete/turn_aborted. Unlike assistant messages, commentary does + /// not close the turn because tools may follow it. + turn_active: bool, last_activity: std::time::SystemTime, initial_prompt: String, chat_messages: Vec, @@ -898,6 +4284,22 @@ struct CodexJSONLResult { tool_calls: Vec, /// Earliest start timestamp among currently open tool calls. pending_since_ms: u64, + /// Exact provider call IDs that are still open at the rollout tail. + open_tool_ids: HashSet, + /// Provider observation time for every exact currently open call ID. + open_tool_started_at_ms: HashMap, + /// Content-free interaction class for every exact currently open call ID. + open_tool_classes: HashMap, + /// Exact canonical completions from the currently selected root turn. + completed_tool_calls: HashMap, + /// Exact nested Code Mode end identities exposed by the selected root turn. + nested_code_mode_end_at_ms: HashMap, + /// Bounded yielded Code Mode cells that remain live beyond an outer call. + live_code_mode_cells: usize, + /// Multiple/cross-turn Code Mode shapes with no exact hook-call bijection. + code_mode_correlation_ambiguous: bool, + /// Earliest start timestamp among open `request_user_input` calls. + awaiting_input_since_ms: u64, /// Timestamp of the latest user prompt not yet followed by assistant output. thinking_since_ms: u64, } @@ -906,37 +4308,311 @@ impl CodexJSONLResult { fn is_codex_desktop(&self) -> bool { self.originator == "Codex Desktop" } + + fn subagent_display_name(&self) -> String { + if !self.subagent_name.is_empty() { + return self.subagent_name.clone(); + } + self.session_id.chars().take(12).collect() + } + + fn is_exact_terminal_lifecycle(&self) -> bool { + self.lifecycle_valid + && self.task_complete + && !self.turn_active + && self.active_turn_id.is_none() + && self.completed_turn_id.is_some() + && self.turn_started_at_ms > 0 + && self.turn_started_at_ms <= self.latest_lifecycle_at_ms + && self.latest_lifecycle_at_ms <= self.task_completed_at_ms + && self.task_completed_at_ms > 0 + && self.open_tool_ids.is_empty() + && self.open_tool_started_at_ms.is_empty() + && self.open_tool_classes.is_empty() + && self.live_code_mode_cells == 0 + && !self.code_mode_correlation_ambiguous + } +} + +struct CodexRolloutGroup { + root: CodexJSONLResult, + children: Vec, + lifecycle_valid: bool, +} + +/// Select the most recently active root tree from all rollout descriptors held +/// by one Codex CLI process. Subagent metadata provides an exact parent thread +/// ID. If a parent file is no longer open, that subtree is treated as its own +/// candidate rather than being attached to an unrelated root. +fn select_codex_rollout_group( + candidates: Vec<(PathBuf, CodexJSONLResult)>, +) -> Option { + let mut parsed = Vec::new(); + let mut seen_paths = HashSet::new(); + for (path, result) in candidates { + if !seen_paths.insert(path.clone()) { + continue; + } + parsed.push((path, result)); + } + if parsed.is_empty() { + return None; + } + + let mut lifecycle_valid = true; + let mut id_to_index = HashMap::new(); + let mut parent_by_id = HashMap::>::new(); + for (idx, (_, result)) in parsed.iter().enumerate() { + if result.session_id.is_empty() { + lifecycle_valid = false; + continue; + } + if id_to_index.insert(result.session_id.clone(), idx).is_some() { + lifecycle_valid = false; + } + if let Some(previous_parent) = + parent_by_id.insert(result.session_id.clone(), result.parent_thread_id.clone()) + { + if previous_parent != result.parent_thread_id { + lifecycle_valid = false; + } + } + } + + // Validate every resolvable parent chain independently. A parent whose + // descriptor is not currently open remains a legitimate detached root, + // but cycles and self-parenting make the whole process association + // ambiguous and must never be split into apparently valid trees. + for start in 0..parsed.len() { + let mut current = start; + let mut visited = HashSet::new(); + while visited.insert(current) { + let Some(parent_id) = parsed[current].1.parent_thread_id.as_ref() else { + break; + }; + let Some(parent) = id_to_index.get(parent_id).copied() else { + break; + }; + current = parent; + } + if visited.contains(¤t) + && parsed[current] + .1 + .parent_thread_id + .as_ref() + .and_then(|parent| id_to_index.get(parent)) + .is_some() + { + lifecycle_valid = false; + } + } + + fn root_for( + start: usize, + parsed: &[(PathBuf, CodexJSONLResult)], + id_to_index: &HashMap, + ) -> usize { + let mut current = start; + let mut visited = HashSet::new(); + while visited.insert(current) { + let Some(parent_id) = parsed[current].1.parent_thread_id.as_ref() else { + return current; + }; + let Some(parent) = id_to_index.get(parent_id).copied() else { + return current; + }; + current = parent; + } + // Malformed cyclic metadata: fail closed to the originally selected + // rollout instead of merging unrelated records. + start + } + + let mut trees: HashMap> = HashMap::new(); + for idx in 0..parsed.len() { + trees + .entry(root_for(idx, &parsed, &id_to_index)) + .or_default() + .push(idx); + } + + let (&selected_root, selected_indices) = + trees + .iter() + .max_by(|(root_a, members_a), (root_b, members_b)| { + let activity_a = members_a + .iter() + .map(|idx| parsed[*idx].1.last_activity) + .max() + .unwrap_or(std::time::UNIX_EPOCH); + let activity_b = members_b + .iter() + .map(|idx| parsed[*idx].1.last_activity) + .max() + .unwrap_or(std::time::UNIX_EPOCH); + activity_a + .cmp(&activity_b) + .then_with(|| parsed[**root_a].0.cmp(&parsed[**root_b].0)) + })?; + + // One native CLI can retain descriptors for several root threads. A + // non-selected tree is harmless only when every one of its descriptors is + // exact-terminal; otherwise choosing the newest root could hide concurrent + // or malformed work owned by the same process. + lifecycle_valid &= trees.iter().all(|(root, members)| { + *root == selected_root + || members + .iter() + .all(|idx| parsed[*idx].1.is_exact_terminal_lifecycle()) + }); + + let root = parsed[selected_root].1.clone(); + let mut child_indices: Vec = selected_indices + .iter() + .copied() + .filter(|idx| *idx != selected_root) + .collect(); + child_indices.sort_by_key(|idx| parsed[*idx].1.started_at); + let children = child_indices + .iter() + .map(|idx| parsed[*idx].1.clone()) + .collect(); + Some(CodexRolloutGroup { + root, + children, + lifecycle_valid, + }) } -fn event_timestamp_ms(val: &Value) -> Option { +fn parse_rollout_timestamp_ms(raw: &str, now_ms: u64) -> Option { + chrono::DateTime::parse_from_rfc3339(raw) + .ok() + .and_then(|dt| u64::try_from(dt.timestamp_millis()).ok()) + .filter(|timestamp_ms| *timestamp_ms > 0 && *timestamp_ms <= now_ms) +} + +fn event_timestamp_ms(val: &Value, now_ms: u64) -> Option { val["timestamp"] .as_str() - .and_then(|ts| chrono::DateTime::parse_from_rfc3339(ts).ok()) - .and_then(|dt| u64::try_from(dt.timestamp_millis()).ok()) + .and_then(|timestamp| parse_rollout_timestamp_ms(timestamp, now_ms)) +} + +fn advance_rollout_lifecycle(result: &mut CodexJSONLResult, timestamp_ms: u64) { + if timestamp_ms == 0 || timestamp_ms < result.latest_lifecycle_at_ms { + result.lifecycle_valid = false; + return; + } + result.latest_lifecycle_at_ms = timestamp_ms; } -fn value_to_tool_arg(value: &Value) -> Option { - if let Some(s) = value.as_str() { - return Some(s.to_string()); +#[derive(Clone, Debug, PartialEq, Eq)] +struct CopiedRolloutMeta { + parent_thread_id: Option, + cli_version: String, +} + +fn copied_rollout_epoch_thresholds( + session_id: &str, + parent_thread_id: &str, + cli_version: &str, + replayed_meta: &HashMap, +) -> Option> { + if cli_version != "0.146.0" { + return None; } - if let Some(items) = value.as_array() { - let parts: Vec<&str> = items.iter().filter_map(|item| item.as_str()).collect(); - if parts.is_empty() { + let child_timestamp_ms = uuid_v7_timestamp_ms(session_id)?; + let mut current = parent_thread_id.to_string(); + let mut visited = HashSet::new(); + let mut thresholds = Vec::new(); + + loop { + if !visited.insert(current.clone()) { + return None; + } + let meta = replayed_meta.get(¤t)?; + if meta.cli_version != cli_version { + return None; + } + let current_timestamp_ms = uuid_v7_timestamp_ms(¤t)?; + if current_timestamp_ms >= child_timestamp_ms { return None; } - if parts.len() >= 3 && parts[0] == "bash" && parts[1] == "-lc" { - return Some(parts[2].to_string()); + let Some(parent_thread_id) = meta.parent_thread_id.as_ref() else { + break; + }; + if uuid_v7_timestamp_ms(parent_thread_id)? >= current_timestamp_ms { + return None; } - return Some(parts.join(" ")); + // Only a replayed subagent (never the root metadata) authorizes an + // inherited copied-epoch boundary. + thresholds.push(current_timestamp_ms); + current = parent_thread_id.clone(); + } + + if visited.len() != replayed_meta.len() { + return None; + } + thresholds.reverse(); + if !thresholds.windows(2).all(|pair| pair[0] < pair[1]) { + return None; } - if value.is_number() || value.is_boolean() { - return Some(value.to_string()); + thresholds.push(child_timestamp_ms); + Some(thresholds) +} + +fn copied_prefix_record_has_irreversible_failure(val: &Value) -> bool { + match val["type"].as_str() { + Some("error") => true, + Some("event_msg") => match val["payload"]["type"].as_str() { + Some("stream_error" | "error") => true, + Some("task_complete") => !val["payload"]["error"].is_null(), + _ => false, + }, + _ => false, } - None +} + +fn reset_copied_subagent_rollout_epoch(result: &mut CodexJSONLResult, boundary_ms: u64) { + // Full-history subagents persist a copied parent prefix before their own + // first turn. Keep the child file identity, but discard inherited metrics + // and lifecycle state once the exact child-local delimiter is observed. + result.model = String::from("-"); + result.effort.clear(); + result.context_window = 0; + result.turn_count = 0; + result.current_task.clear(); + result.task_complete = false; + result.active_turn_id = None; + result.completed_turn_id = None; + result.turn_started_at_ms = 0; + result.latest_lifecycle_at_ms = 0; + result.task_completed_at_ms = 0; + result.turn_active = false; + result.last_activity = std::time::UNIX_EPOCH + std::time::Duration::from_millis(boundary_ms); + result.initial_prompt.clear(); + result.chat_messages.clear(); + result.total_input = 0; + result.total_output = 0; + result.total_cache_read = 0; + result.last_context_tokens = 0; + result.token_history.clear(); + result.rate_limit = None; + result.tool_calls.clear(); + result.pending_since_ms = 0; + result.open_tool_ids.clear(); + result.open_tool_started_at_ms.clear(); + result.open_tool_classes.clear(); + result.completed_tool_calls.clear(); + result.nested_code_mode_end_at_ms.clear(); + result.live_code_mode_cells = 0; + result.code_mode_correlation_ambiguous = false; + result.awaiting_input_since_ms = 0; + result.thinking_since_ms = 0; } fn sanitize_tool_arg(arg: &str) -> String { - let redacted = super::redact_secrets(arg); + let terminal_safe = super::sanitize_terminal_text(arg); + let redacted = super::redact_secrets(&terminal_safe); redacted.chars().take(120).collect() } @@ -975,18 +4651,14 @@ fn parse_codex_tool_arg(arguments: &str) -> String { } } - for key in ["cmd", "command", "chars", "target", "session_id"] { - if let Some(raw) = value_to_tool_arg(&value[key]) { - return sanitize_tool_arg(&raw); - } + // Commands, stdin, prompts, request bodies, and arbitrary tool arguments + // can contain source or secrets. Only provider process/session identifiers + // are safe enough to preview beyond an allowlisted path. + if let Some(raw) = value["session_id"].as_str() { + return sanitize_tool_arg(raw); } - - if let Some(obj) = value.as_object() { - for val in obj.values() { - if let Some(raw) = value_to_tool_arg(val) { - return sanitize_tool_arg(&raw); - } - } + if let Some(raw) = value["session_id"].as_u64() { + return raw.to_string(); } String::new() @@ -996,9 +4668,166 @@ fn parse_codex_tool_session_id(arguments: &str) -> Option { let value = serde_json::from_str::(arguments).ok()?; let raw = &value["session_id"]; if let Some(s) = raw.as_str() { - return Some(s.to_string()); + return bounded_rollout_session_id(s); + } + raw.as_u64() + .map(|n| n.to_string()) + .and_then(|id| bounded_rollout_session_id(&id)) +} + +fn bounded_rollout_session_id(raw: &str) -> Option { + (!raw.is_empty() + && raw.len() <= 128 + && raw + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))) + .then(|| raw.to_string()) +} + +fn bounded_rollout_lifecycle_id(raw: &str) -> Option { + (!raw.is_empty() + && raw.len() <= MAX_ROLLOUT_LIFECYCLE_ID_BYTES + && raw.bytes().all(|byte| byte.is_ascii_graphic())) + .then(|| raw.to_string()) +} + +fn bounded_rollout_tool_name(raw: &str) -> Option<&str> { + (!raw.is_empty() + && raw.len() <= MAX_ROLLOUT_TOOL_NAME_BYTES + && raw + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'))) + .then_some(raw) +} + +fn known_rollout_end_matches_tool( + event_type: &str, + namespace: Option<&str>, + tool_name: &str, +) -> bool { + match event_type { + "exec_command_end" => namespace.is_none() && tool_name == "exec_command", + "patch_apply_end" => namespace.is_none() && tool_name == "apply_patch", + "web_search_end" => namespace == Some("web") && tool_name == "run", + "image_generation_end" => namespace == Some("image_gen") && tool_name == "imagegen", + "mcp_tool_call_end" => namespace.is_some_and(|namespace| namespace.starts_with("mcp__")), + _ => false, + } +} + +fn bounded_code_mode_cell_id(raw: &str) -> Option { + fn canonical_positive_decimal(raw: &str) -> Option { + let value = raw.parse::().ok().filter(|value| *value > 0)?; + (value.to_string() == raw).then_some(value) + } + + if raw.is_empty() || raw.len() > 128 { + return None; + } + if canonical_positive_decimal(raw).is_some() { + return Some(raw.to_string()); + } + let (generation, cell) = raw.strip_prefix('g')?.split_once(':')?; + (canonical_positive_decimal(generation).is_some_and(|generation| generation >= 2) + && canonical_positive_decimal(cell).is_some()) + .then(|| raw.to_string()) +} + +fn default_code_mode_wait_yield_time_ms() -> u64 { + 10_000 +} + +#[derive(Debug, Deserialize)] +struct CodeModeWaitArgs { + cell_id: String, + #[serde( + rename = "yield_time_ms", + default = "default_code_mode_wait_yield_time_ms" + )] + _yield_time_ms: u64, + #[serde(rename = "max_tokens", default)] + _max_tokens: Option, + #[serde(default)] + terminate: bool, +} + +#[derive(Debug, PartialEq, Eq)] +struct ParsedCodeModeWaitArgs { + cell_id: String, + terminate: bool, +} + +fn parse_code_mode_wait_args(arguments: &str) -> Option { + let CodeModeWaitArgs { + cell_id, terminate, .. + } = serde_json::from_str(arguments).ok()?; + Some(ParsedCodeModeWaitArgs { + cell_id: bounded_code_mode_cell_id(&cell_id)?, + terminate, + }) +} + +#[derive(Debug, PartialEq, Eq)] +enum CodeModeOutputState { + Running(String), + Terminal, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct YieldedCodeModeCell { + turn_id: String, + origin_call_id: String, + exec_started_at_ms: u64, + exec_yielded_at_ms: u64, +} + +fn code_mode_output_text(output: &Value) -> Option<&str> { + // Rollouts preserve a single text-only status item as a string, while a + // status plus runtime output is represented as a content-item array. + if let Some(text) = output.as_str() { + return Some(text); + } + let first = output.as_array()?.first()?.as_object()?; + if first.len() != 2 { + return None; + } + (first.get("type").and_then(Value::as_str) == Some("input_text")) + .then(|| first.get("text")?.as_str()) + .flatten() +} + +fn code_mode_output_state(output: &Value) -> Option { + const RUNNING_PREFIX: &str = "Script running with cell ID "; + let frame = code_mode_output_text(output)?; + if frame.len() > MAX_CODE_MODE_STATUS_FRAME_BYTES { + return None; + } + let mut lines = frame.split('\n'); + let status = lines.next()?; + let wall_time = lines + .next()? + .strip_prefix("Wall time ")? + .strip_suffix(" seconds")?; + let (whole_seconds, fractional_seconds) = wall_time.split_once('.')?; + if whole_seconds.is_empty() + || !whole_seconds.bytes().all(|byte| byte.is_ascii_digit()) + || (whole_seconds.len() > 1 && whole_seconds.starts_with('0')) + || fractional_seconds.len() != 1 + || !fractional_seconds.bytes().all(|byte| byte.is_ascii_digit()) + || lines.next()? != "Output:" + || !lines.next()?.is_empty() + || lines.next().is_some() + { + return None; + } + if let Some(raw_cell_id) = status.strip_prefix(RUNNING_PREFIX) { + return bounded_code_mode_cell_id(raw_cell_id).map(CodeModeOutputState::Running); } - raw.as_u64().map(|n| n.to_string()) + matches!( + status, + "Script completed" | "Script failed" | "Script terminated" + ) + .then_some(CodeModeOutputState::Terminal) } fn running_process_session_id(output: &str) -> Option { @@ -1007,14 +4836,7 @@ fn running_process_session_id(output: &str) -> Option { .lines() .find_map(|line| line.trim_start().strip_prefix(marker))?; let id = after.split_whitespace().next()?; - if id.is_empty() { - None - } else { - Some( - id.trim_matches(|c: char| !c.is_ascii_alphanumeric()) - .to_string(), - ) - } + bounded_rollout_session_id(id.trim_matches(|c: char| !c.is_ascii_alphanumeric())) } fn output_reports_process_exit(output: &str) -> bool { @@ -1023,22 +4845,146 @@ fn output_reports_process_exit(output: &str) -> bool { .any(|line| line.trim_start().starts_with("Process exited")) } +#[derive(Clone, Debug)] +struct OpenRolloutCall { + started_at_ms: u64, + name: String, + custom: bool, + class: RolloutToolClass, + tool_index: Option, + write_stdin_target: Option, + code_mode_wait_target: Option, + code_mode_wait_terminate: bool, + known_end_at_ms: Option, +} + +#[derive(Clone, Debug)] +struct SeenRolloutCall { + namespace: Option, + name: String, +} + fn close_codex_tool_call( call_id: &str, end_ms: u64, tool_calls: &mut [ToolCall], - call_indices: &HashMap, - call_starts: &mut HashMap, + open_calls: &mut HashMap, pending_tasks: &mut Vec<(String, String)>, -) { - if let Some(start_ms) = call_starts.remove(call_id) { - if let Some(idx) = call_indices.get(call_id).copied() { - if let Some(tool_call) = tool_calls.get_mut(idx) { - tool_call.duration_ms = end_ms.saturating_sub(start_ms); - } +) -> Option { + let call = open_calls.remove(call_id)?; + if let Some(idx) = call.tool_index { + if let Some(tool_call) = tool_calls.get_mut(idx) { + tool_call.duration_ms = call + .known_end_at_ms + .unwrap_or(end_ms) + .saturating_sub(call.started_at_ms); } } pending_tasks.retain(|(id, _)| id != call_id); + Some(call) +} + +fn remember_completed_rollout_call( + completed: &mut HashMap, + call_id: &str, + call: &OpenRolloutCall, + completed_at_ms: u64, + code_mode_terminal: bool, +) { + if !rollout_call_id_is_exact(call_id) + || call.started_at_ms == 0 + || completed_at_ms < call.started_at_ms + || completed.len() >= MAX_TRACKED_ROLLOUT_CALL_IDS + || completed.contains_key(call_id) + { + return; + } + completed.insert( + call_id.to_string(), + CompletedRolloutCall { + started_at_ms: call.started_at_ms, + completed_at_ms, + class: call.class, + code_mode_terminal, + }, + ); +} + +fn complete_yielded_code_mode_exec( + completed: &mut HashMap, + open_calls: &HashMap, + provenance: &YieldedCodeModeCell, + wait_call: &OpenRolloutCall, + active_turn_id: Option<&str>, + completed_at_ms: u64, +) -> bool { + if active_turn_id != Some(provenance.turn_id.as_str()) + || !rollout_call_id_is_exact(&provenance.origin_call_id) + || open_calls.contains_key(&provenance.origin_call_id) + || provenance.exec_started_at_ms == 0 + || provenance.exec_yielded_at_ms < provenance.exec_started_at_ms + || wait_call.started_at_ms < provenance.exec_yielded_at_ms + || completed_at_ms < wait_call.started_at_ms + || wait_call.code_mode_wait_terminate + { + return false; + } + let RolloutToolClass::CodeModeWait { + exec_started_at_ms, + exec_yielded_at_ms, + } = wait_call.class + else { + return false; + }; + if exec_started_at_ms != provenance.exec_started_at_ms + || exec_yielded_at_ms != provenance.exec_yielded_at_ms + { + return false; + } + + let Some(origin) = completed.get_mut(&provenance.origin_call_id) else { + return false; + }; + if origin.started_at_ms != provenance.exec_started_at_ms + || origin.completed_at_ms < origin.started_at_ms + || origin.completed_at_ms > provenance.exec_yielded_at_ms + || origin.code_mode_terminal + || origin.class + != (RolloutToolClass::CodeModeExec { + exec_started_at_ms: provenance.exec_started_at_ms, + }) + { + return false; + } + + origin.completed_at_ms = completed_at_ms; + origin.code_mode_terminal = true; + true +} + +fn close_codex_turn_calls( + end_ms: u64, + tool_calls: &mut [ToolCall], + open_calls: &mut HashMap, + pending_tasks: &mut Vec<(String, String)>, + running_exec_by_session: &HashMap, +) { + let background_execs: HashSet<&str> = running_exec_by_session + .values() + .map(String::as_str) + .collect(); + let call_ids: Vec = open_calls + .keys() + .filter(|call_id| !background_execs.contains(call_id.as_str())) + .cloned() + .collect(); + for call_id in call_ids { + close_codex_tool_call(&call_id, end_ms, tool_calls, open_calls, pending_tasks); + } +} + +fn codex_tool_waits_for_user(name: &str) -> bool { + name == "request_user_input" } /// Parse a Codex rollout-*.jsonl file. @@ -1050,14 +4996,21 @@ fn close_codex_tool_call( /// - event_msg.user_message: user prompt /// - event_msg.agent_message: turn count /// - event_msg.task_complete: session done -/// - response_item (function_call): current tool use +/// - response_item (function_call): current tool use and user-input waits /// - turn_context: model, effort fn parse_codex_jsonl(path: &Path) -> Option { - let file = fs::File::open(path).ok()?; + let file = open_rollout_file(path).ok()?; + parse_codex_open_file(&file) +} + +fn parse_codex_open_file(file: &fs::File) -> Option { let mut reader = BufReader::new(file); + let parse_now_ms = unix_now_ms(); let mut result = CodexJSONLResult { session_id: String::new(), + parent_thread_id: None, + subagent_name: String::new(), cwd: String::new(), originator: String::new(), started_at: 0, @@ -1069,7 +5022,13 @@ fn parse_codex_jsonl(path: &Path) -> Option { turn_count: 0, current_task: String::new(), task_complete: false, - model_generating: false, + lifecycle_valid: true, + active_turn_id: None, + completed_turn_id: None, + turn_started_at_ms: 0, + latest_lifecycle_at_ms: 0, + task_completed_at_ms: 0, + turn_active: false, last_activity: std::time::UNIX_EPOCH, initial_prompt: String::new(), chat_messages: Vec::new(), @@ -1081,14 +5040,30 @@ fn parse_codex_jsonl(path: &Path) -> Option { rate_limit: None, tool_calls: Vec::new(), pending_since_ms: 0, + open_tool_ids: HashSet::new(), + open_tool_started_at_ms: HashMap::new(), + open_tool_classes: HashMap::new(), + completed_tool_calls: HashMap::new(), + nested_code_mode_end_at_ms: HashMap::new(), + live_code_mode_cells: 0, + code_mode_correlation_ambiguous: false, + awaiting_input_since_ms: 0, thinking_since_ms: 0, }; - let mut call_indices: HashMap = HashMap::new(); - let mut call_starts: HashMap = HashMap::new(); - let mut call_names: HashMap = HashMap::new(); - let mut write_stdin_targets: HashMap = HashMap::new(); + let mut open_calls: HashMap = HashMap::new(); + let mut seen_calls: HashMap = HashMap::new(); + let mut seen_known_end_ids: HashSet = HashSet::new(); let mut running_exec_by_session: HashMap = HashMap::new(); + let mut yielded_code_mode_cells: HashMap = HashMap::new(); + let mut code_mode_correlation_ambiguous = false; let mut pending_tasks: Vec<(String, String)> = Vec::new(); + let mut fork_metadata_attested = false; + let mut saw_exact_inherited_parent_meta = false; + let mut replayed_meta: HashMap = HashMap::new(); + let mut copied_epoch_thresholds: Option> = None; + let mut copied_epoch_index = 0_usize; + let mut copied_epoch_chain_initialized = false; + let mut previous_record_was_thread_settings_applied = false; // Match Claude transcript cap: a malformed/hostile line beyond this size // aborts the scan to prevent OOM. take(MAX+1) physically bounds the read. @@ -1103,10 +5078,14 @@ fn parse_codex_jsonl(path: &Path) -> Option { { Ok(0) => break, Ok(_) => {} - Err(_) => break, + Err(_) => { + result.lifecycle_valid = false; + break; + } } // Cap hit without a newline — skip this file's remainder. if line_buf.len() > MAX_LINE_BYTES && !line_buf.ends_with('\n') { + result.lifecycle_valid = false; break; } let line = line_buf.trim(); @@ -1116,26 +5095,170 @@ fn parse_codex_jsonl(path: &Path) -> Option { let val: Value = match serde_json::from_str(line) { Ok(v) => v, - Err(_) => continue, // partial line at EOF or malformed + // A final incomplete append is normal while Codex is writing, but + // its hidden event could be a tool, error, child, or turn edge. + // Fail this poll closed; an appended completion changes the file + // fingerprint and reparses the full record on the next poll. + Err(_) if !line_buf.ends_with('\n') => { + result.lifecycle_valid = false; + break; + } + Err(_) => { + result.lifecycle_valid = false; + continue; + } }; - // Update last_activity from timestamp - if let Some(ts_str) = val["timestamp"].as_str() { - if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts_str) { - let sys_time = std::time::UNIX_EPOCH - + std::time::Duration::from_millis(dt.timestamp_millis() as u64); - if sys_time > result.last_activity { - result.last_activity = sys_time; - } + // Provider timestamps participate in lifecycle proof. A pre-epoch or + // future timestamp cannot be used to order a current observation. + let record_timestamp_ms = val["timestamp"] + .as_str() + .and_then(|timestamp| parse_rollout_timestamp_ms(timestamp, parse_now_ms)); + if !val["timestamp"].is_null() && record_timestamp_ms.is_none() { + result.lifecycle_valid = false; + } + if let Some(timestamp_ms) = record_timestamp_ms { + let sys_time = std::time::UNIX_EPOCH + std::time::Duration::from_millis(timestamp_ms); + if sys_time > result.last_activity { + result.last_activity = sys_time; } } + let follows_thread_settings_applied = previous_record_was_thread_settings_applied; + previous_record_was_thread_settings_applied = val["type"].as_str() == Some("event_msg") + && val["payload"]["type"].as_str() == Some("thread_settings_applied"); + + let direct_fork_prefix_pending = fork_metadata_attested + && replayed_meta.is_empty() + && copied_epoch_index == 0 + && val["type"].as_str() != Some("session_meta"); + if direct_fork_prefix_pending { + let local_epoch_candidate = if val["type"].as_str() == Some("event_msg") + && val["payload"]["type"].as_str() == Some("task_started") + { + let child_timestamp_ms = uuid_v7_timestamp_ms(&result.session_id); + let turn_timestamp_ms = val["payload"]["turn_id"] + .as_str() + .and_then(bounded_rollout_lifecycle_id) + .as_deref() + .and_then(uuid_v7_timestamp_ms); + match (child_timestamp_ms, turn_timestamp_ms) { + (Some(child_timestamp_ms), Some(turn_timestamp_ms)) => { + turn_timestamp_ms >= child_timestamp_ms + } + _ => { + result.lifecycle_valid = false; + false + } + } + } else { + false + }; + if !local_epoch_candidate { + if copied_prefix_record_has_irreversible_failure(&val) { + result.lifecycle_valid = false; + } + continue; + } + } match val["type"].as_str() { Some("session_meta") => { + // A forked subagent rollout starts with its own metadata, then + // may replay parent/root session_meta records as inherited + // history. The first valid identity belongs to this file; + // replacing it with a later parent collapses the rollout graph + // and hides active child work from the root session. + if !result.session_id.is_empty() { + let payload = &val["payload"]; + let replayed_id = payload["id"] + .as_str() + .and_then(bounded_rollout_lifecycle_id); + let raw_parent_thread_id = payload["parent_thread_id"].as_str().or_else(|| { + payload["source"]["subagent"]["thread_spawn"]["parent_thread_id"].as_str() + }); + let replayed_parent_thread_id = + raw_parent_thread_id.and_then(bounded_rollout_lifecycle_id); + let replayed_version = payload["cli_version"].as_str().filter(|version| { + !version.is_empty() + && version.len() <= 64 + && version.bytes().all(|byte| byte.is_ascii_graphic()) + }); + if copied_epoch_chain_initialized + || replayed_meta.len() >= MAX_COPIED_ROLLOUT_SESSION_META + || replayed_id.is_none() + || replayed_version.is_none() + || (raw_parent_thread_id.is_some() && replayed_parent_thread_id.is_none()) + { + result.lifecycle_valid = false; + continue; + } + let replayed_id = replayed_id.unwrap_or_default(); + if result.parent_thread_id.as_deref() == Some(replayed_id.as_str()) { + saw_exact_inherited_parent_meta = true; + } + if replayed_meta + .insert( + replayed_id, + CopiedRolloutMeta { + parent_thread_id: replayed_parent_thread_id, + cli_version: replayed_version.unwrap_or_default().to_string(), + }, + ) + .is_some() + { + result.lifecycle_valid = false; + } + continue; + } let payload = &val["payload"]; if let Some(id) = payload["id"].as_str() { result.session_id = id.to_string(); } + let direct_parent_value = &payload["parent_thread_id"]; + let raw_direct_parent_id = direct_parent_value.as_str(); + let direct_parent_id = raw_direct_parent_id.and_then(bounded_rollout_lifecycle_id); + let forked_from_value = &payload["forked_from_id"]; + let raw_forked_from_id = forked_from_value.as_str(); + let forked_from_id = raw_forked_from_id.and_then(bounded_rollout_lifecycle_id); + let source_parent_value = + &payload["source"]["subagent"]["thread_spawn"]["parent_thread_id"]; + let raw_source_parent_id = source_parent_value.as_str(); + let source_parent_id = raw_source_parent_id.and_then(bounded_rollout_lifecycle_id); + let parent_thread_id = direct_parent_id + .clone() + .or_else(|| source_parent_id.clone()); + if (!direct_parent_value.is_null() && raw_direct_parent_id.is_none()) + || (!forked_from_value.is_null() && raw_forked_from_id.is_none()) + || (!source_parent_value.is_null() && raw_source_parent_id.is_none()) + || (raw_direct_parent_id.is_some() && direct_parent_id.is_none()) + || (raw_forked_from_id.is_some() && forked_from_id.is_none()) + || (raw_source_parent_id.is_some() && source_parent_id.is_none()) + || (direct_parent_id.is_some() + && source_parent_id.is_some() + && direct_parent_id != source_parent_id) + { + result.lifecycle_valid = false; + } + fork_metadata_attested = direct_parent_id.is_some() + && forked_from_id == direct_parent_id + && source_parent_id == direct_parent_id; + if !forked_from_value.is_null() && !fork_metadata_attested { + result.lifecycle_valid = false; + } + result.parent_thread_id = parent_thread_id; + if let Some(name) = payload["agent_nickname"] + .as_str() + .or_else(|| { + payload["source"]["subagent"]["thread_spawn"]["agent_nickname"].as_str() + }) + .or_else(|| payload["agent_path"].as_str()) + .or_else(|| { + payload["source"]["subagent"]["thread_spawn"]["agent_path"].as_str() + }) + { + let short = process::last_path_segment(name).unwrap_or(name); + result.subagent_name = sanitize_tool_arg(short).chars().take(60).collect(); + } if let Some(cwd) = payload["cwd"].as_str() { result.cwd = cwd.to_string(); } @@ -1146,9 +5269,12 @@ fn parse_codex_jsonl(path: &Path) -> Option { result.version = ver.to_string(); } // started_at from timestamp - if let Some(ts) = payload["timestamp"].as_str() { - if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) { - result.started_at = dt.timestamp_millis() as u64; + if let Some(timestamp) = payload["timestamp"].as_str() { + if let Some(timestamp_ms) = parse_rollout_timestamp_ms(timestamp, parse_now_ms) + { + result.started_at = timestamp_ms; + } else { + result.lifecycle_valid = false; } } // Git branch @@ -1161,13 +5287,127 @@ fn parse_codex_jsonl(path: &Path) -> Option { let payload = &val["payload"]; match payload["type"].as_str() { Some("task_started") => { - if let Some(cw) = payload["model_context_window"].as_u64() { - result.context_window = cw; + let boundary_ms = event_timestamp_ms(&val, parse_now_ms).unwrap_or(0); + let turn_id = payload["turn_id"] + .as_str() + .and_then(bounded_rollout_lifecycle_id); + let copied_history_attested = + saw_exact_inherited_parent_meta || fork_metadata_attested; + if copied_history_attested && !copied_epoch_chain_initialized { + copied_epoch_chain_initialized = true; + copied_epoch_thresholds = + result + .parent_thread_id + .as_deref() + .and_then(|parent_thread_id| { + if replayed_meta.is_empty() && fork_metadata_attested { + if result.version != "0.146.0" { + return None; + } + let child_timestamp_ms = + uuid_v7_timestamp_ms(&result.session_id)?; + let parent_timestamp_ms = + uuid_v7_timestamp_ms(parent_thread_id)?; + return (parent_timestamp_ms < child_timestamp_ms) + .then_some(vec![child_timestamp_ms]); + } + if saw_exact_inherited_parent_meta { + copied_rollout_epoch_thresholds( + &result.session_id, + parent_thread_id, + &result.version, + &replayed_meta, + ) + } else { + None + } + }); + if copied_epoch_thresholds.is_none() { + result.lifecycle_valid = false; + } } - } - Some("user_message") => { - result.model_generating = true; - result.thinking_since_ms = event_timestamp_ms(&val).unwrap_or(0); + let turn_timestamp_ms = turn_id.as_deref().and_then(uuid_v7_timestamp_ms); + let copied_epoch_candidate = copied_epoch_thresholds + .as_ref() + .and_then(|thresholds| { + thresholds + .get(copied_epoch_index) + .copied() + .map(|threshold_ms| (thresholds, threshold_ms)) + }) + .zip(turn_timestamp_ms) + .filter(|((_, threshold_ms), turn_timestamp_ms)| { + *turn_timestamp_ms >= *threshold_ms + }); + if let Some(((thresholds, threshold_ms), turn_timestamp_ms)) = + copied_epoch_candidate + { + let upper_threshold_ms = + thresholds.get(copied_epoch_index + 1).copied(); + // Consume the first candidate for this exact fork + // threshold even when it is malformed. A later + // delimiter can therefore never repair invalidity. + copied_epoch_index += 1; + let exact_copied_epoch_boundary = result.lifecycle_valid + && follows_thread_settings_applied + && boundary_ms > 0 + && turn_timestamp_ms > threshold_ms + && turn_timestamp_ms <= boundary_ms + && upper_threshold_ms + .is_none_or(|upper_ms| turn_timestamp_ms < upper_ms); + if exact_copied_epoch_boundary { + reset_copied_subagent_rollout_epoch(&mut result, boundary_ms); + open_calls.clear(); + seen_calls.clear(); + seen_known_end_ids.clear(); + running_exec_by_session.clear(); + yielded_code_mode_cells.clear(); + code_mode_correlation_ambiguous = false; + pending_tasks.clear(); + } else { + result.lifecycle_valid = false; + } + } + close_codex_turn_calls( + boundary_ms, + &mut result.tool_calls, + &mut open_calls, + &mut pending_tasks, + &running_exec_by_session, + ); + seen_calls.retain(|call_id, _| open_calls.contains_key(call_id)); + result.completed_tool_calls.clear(); + result.nested_code_mode_end_at_ms.clear(); + if result.turn_active { + result.lifecycle_valid = false; + } + advance_rollout_lifecycle(&mut result, boundary_ms); + result.task_complete = false; + result.lifecycle_valid &= boundary_ms > 0 && turn_id.is_some(); + result.active_turn_id = turn_id; + result.completed_turn_id = None; + result.turn_started_at_ms = boundary_ms; + result.task_completed_at_ms = 0; + result.turn_active = true; + result.thinking_since_ms = boundary_ms; + if yielded_code_mode_cells.values().any(|cell| { + result.active_turn_id.as_deref() != Some(cell.turn_id.as_str()) + }) { + code_mode_correlation_ambiguous = true; + } + if let Some(cw) = payload["model_context_window"].as_u64() { + result.context_window = cw; + } + } + Some("user_message") => { + let boundary_ms = event_timestamp_ms(&val, parse_now_ms).unwrap_or(0); + advance_rollout_lifecycle(&mut result, boundary_ms); + result.task_complete = false; + result.turn_active = true; + if result.active_turn_id.is_none() { + result.lifecycle_valid = false; + } + result.thinking_since_ms = boundary_ms; if let Some(msg) = payload["message"].as_str() { if result.initial_prompt.is_empty() { let truncated: String = msg.chars().take(120).collect(); @@ -1213,9 +5453,10 @@ fn parse_codex_jsonl(path: &Path) -> Option { if let Some(cw) = info["model_context_window"].as_u64() { result.context_window = cw; } - // Rate limits: assign to short/long slots based on window_minutes. - // Plus plans: primary=5h(300min), secondary=7d(10080min). - // Free plans: primary can be a longer window, such as 30d(43200min). + // Preserve Codex's exact primary/secondary identities, + // then populate the legacy short/long compatibility + // fields based on duration. A free-plan primary can be + // a longer window such as 30d (43200 minutes). let rl = &payload["rate_limits"]; if rl.is_object() && is_account_level_codex_rate_limit(rl) { let event_secs = val["timestamp"] @@ -1232,26 +5473,30 @@ fn parse_codex_jsonl(path: &Path) -> Option { if !w.is_object() { continue; } - let mins = w["window_minutes"].as_u64().unwrap_or(0); - let pct = w["used_percent"].as_f64(); - let resets = w["resets_at"].as_u64(); + let Some(window) = native_codex_rate_limit_window(slot, w) else { + continue; + }; + let mins = window.window_minutes.unwrap_or(0); if mins <= 300 { - info.five_hour_pct = pct; - info.five_hour_resets_at = resets; - info.five_hour_window_minutes = Some(mins); + info.five_hour_pct = Some(window.used_pct); + info.five_hour_resets_at = window.resets_at; + info.five_hour_window_minutes = window.window_minutes; } else { - info.seven_day_pct = pct; - info.seven_day_resets_at = resets; - info.seven_day_window_minutes = Some(mins); + info.seven_day_pct = Some(window.used_pct); + info.seven_day_resets_at = window.resets_at; + info.seven_day_window_minutes = window.window_minutes; } + info.windows.push(window); + } + if !info.windows.is_empty() { + result.rate_limit = Some(info); } - result.rate_limit = Some(info); } } Some("agent_message") => { result.turn_count += 1; - result.model_generating = false; - result.thinking_since_ms = 0; + // Agent messages can be progress commentary followed by + // tools. Only the exact turn boundary closes thinking. if let Some(msg) = payload["message"].as_str() { push_chat_message( &mut result.chat_messages, @@ -1261,37 +5506,281 @@ fn parse_codex_jsonl(path: &Path) -> Option { } } Some("task_complete") => { - result.task_complete = true; - result.model_generating = false; + let boundary_ms = event_timestamp_ms(&val, parse_now_ms).unwrap_or(0); + let completed_turn_id = payload["turn_id"] + .as_str() + .and_then(bounded_rollout_lifecycle_id); + let exact_boundary = payload["error"].is_null() + && boundary_ms > 0 + && completed_turn_id.is_some() + && completed_turn_id == result.active_turn_id + && result.turn_started_at_ms > 0 + && result.turn_started_at_ms <= result.latest_lifecycle_at_ms + && result.latest_lifecycle_at_ms <= boundary_ms + && open_calls.is_empty(); + close_codex_turn_calls( + boundary_ms, + &mut result.tool_calls, + &mut open_calls, + &mut pending_tasks, + &running_exec_by_session, + ); + result.lifecycle_valid &= exact_boundary; + advance_rollout_lifecycle(&mut result, boundary_ms); + result.task_complete = result.lifecycle_valid && exact_boundary; + result.completed_turn_id = completed_turn_id; + result.task_completed_at_ms = boundary_ms; + result.active_turn_id = None; + result.turn_active = false; result.thinking_since_ms = 0; } - Some(event_type) if event_type.ends_with("_end") => { - if let Some(call_id) = payload["call_id"].as_str() { - let end_ms = event_timestamp_ms(&val).unwrap_or(0); - close_codex_tool_call( - call_id, - end_ms, - &mut result.tool_calls, - &call_indices, - &mut call_starts, - &mut pending_tasks, - ); + Some("turn_aborted") => { + let boundary_ms = event_timestamp_ms(&val, parse_now_ms).unwrap_or(0); + let aborted_turn_id = payload["turn_id"] + .as_str() + .and_then(bounded_rollout_lifecycle_id); + let exact_boundary = boundary_ms > 0 + && aborted_turn_id.is_some() + && aborted_turn_id == result.active_turn_id + && result.turn_active + && !result.task_complete + && result.completed_turn_id.is_none() + && result.turn_started_at_ms > 0 + && result.turn_started_at_ms <= result.latest_lifecycle_at_ms + && result.latest_lifecycle_at_ms <= boundary_ms; + close_codex_turn_calls( + boundary_ms, + &mut result.tool_calls, + &mut open_calls, + &mut pending_tasks, + &running_exec_by_session, + ); + // A yielded Code Mode cell is session-scoped and can + // continue after the turn task is aborted. Preserve + // its exact provenance and require a later matching + // wait/termination edge before lifecycle promotion. + code_mode_correlation_ambiguous = !yielded_code_mode_cells.is_empty(); + // An exact abort makes this turn unavailable, but it is + // also a complete boundary: a later exact task_started + // begins a new lifecycle epoch in the same rollout. + // Structural ambiguity remains sticky because `&=` can + // never restore a lifecycle invalidated earlier. + result.lifecycle_valid &= exact_boundary; + advance_rollout_lifecycle(&mut result, boundary_ms); + result.task_complete = false; + result.active_turn_id = None; + result.completed_turn_id = None; + result.task_completed_at_ms = 0; + result.turn_active = false; + result.thinking_since_ms = 0; + } + Some("stream_error" | "error") => { + // A transport/model stream failure can interrupt any + // point in the turn and has no exact recovery boundary + // in the rollout. Keep metrics, but permanently revoke + // lifecycle promotion for this parsed generation. + result.lifecycle_valid = false; + result.task_complete = false; + yielded_code_mode_cells.clear(); + code_mode_correlation_ambiguous = false; + } + Some( + event_type @ ("exec_command_end" + | "image_generation_end" + | "mcp_tool_call_end" + | "patch_apply_end" + | "web_search_end"), + ) => { + let end_ms = event_timestamp_ms(&val, parse_now_ms).unwrap_or(0); + advance_rollout_lifecycle(&mut result, end_ms); + let call_id = payload["call_id"] + .as_str() + .and_then(bounded_rollout_lifecycle_id); + let unique_end = call_id.as_ref().is_some_and(|call_id| { + if seen_known_end_ids.contains(call_id) + || seen_known_end_ids.len() >= MAX_TRACKED_ROLLOUT_CALL_IDS + { + false + } else { + seen_known_end_ids.insert(call_id.clone()); + true + } + }); + let seen_call = + call_id.as_ref().and_then(|call_id| seen_calls.get(call_id)); + let direct_call = seen_call.is_some_and(|call| { + known_rollout_end_matches_tool( + event_type, + call.namespace.as_deref(), + &call.name, + ) + }); + let direct_open_timestamp_valid = call_id.as_ref().is_some_and(|call_id| { + open_calls + .get(call_id) + .is_some_and(|call| end_ms >= call.started_at_ms) + }); + let nested_code_mode_started_at_ms = if seen_call.is_none() { + let mut candidates = open_calls.values().filter_map(|call| { + matches!( + call.class, + RolloutToolClass::CodeModeExec { .. } + | RolloutToolClass::CodeModeWait { .. } + | RolloutToolClass::CodeModeUncorrelatable + ) + .then_some(call.started_at_ms) + }); + let first = candidates.next(); + if candidates.next().is_none() { + first + } else { + None + } + } else { + None + }; + let inherited_copied_prefix = copied_epoch_thresholds + .as_ref() + .is_some_and(|thresholds| copied_epoch_index < thresholds.len()); + let nested_code_mode_call = call_id.as_ref().is_some_and(|call_id| { + plugin::codex_version_has_verified_code_mode_shape(&result.version) + && code_mode_nested_call_id_is_exact(call_id) + && (nested_code_mode_started_at_ms + .is_some_and(|started_at_ms| end_ms >= started_at_ms) + || (inherited_copied_prefix + && result.turn_started_at_ms > 0 + && end_ms >= result.turn_started_at_ms)) + }); + // These provider events precede the canonical response + // output for direct tools, so they validate correlation + // but do not normally close the top-level descriptor. + // Code Mode additionally emits them for one exact + // nested exec-UUIDv4 call with no rollout open record. + let event_shape_valid = end_ms > 0 + && result.turn_active + && result.active_turn_id.is_some() + && unique_end + && ((direct_call && direct_open_timestamp_valid) + || nested_code_mode_call); + result.lifecycle_valid &= event_shape_valid; + + if event_shape_valid && nested_code_mode_call { + if let Some(call_id) = call_id.as_ref() { + if result.nested_code_mode_end_at_ms.len() + >= MAX_TRACKED_ROLLOUT_CALL_IDS + { + result.lifecycle_valid = false; + } else { + result + .nested_code_mode_end_at_ms + .insert(call_id.clone(), end_ms); + } + } + } + + if event_shape_valid && direct_call { + if let Some(call_id) = call_id.as_deref() { + let mut direct_tool_timing = None; + if let Some(call) = open_calls.get_mut(call_id) { + if call.known_end_at_ms.is_some() { + result.lifecycle_valid = false; + } else { + call.known_end_at_ms = Some(end_ms); + direct_tool_timing = + Some((call.tool_index, call.started_at_ms)); + } + } + if let Some((Some(tool_index), started_at_ms)) = direct_tool_timing + { + if let Some(tool_call) = result.tool_calls.get_mut(tool_index) { + tool_call.duration_ms = + end_ms.saturating_sub(started_at_ms); + } + } + let background_sessions = running_exec_by_session + .iter() + .filter_map(|(session_id, exec_call_id)| { + (exec_call_id == call_id).then_some(session_id.clone()) + }) + .collect::>(); + if !background_sessions.is_empty() { + for session_id in background_sessions { + running_exec_by_session.remove(&session_id); + } + if let Some(closed) = close_codex_tool_call( + call_id, + end_ms, + &mut result.tool_calls, + &mut open_calls, + &mut pending_tasks, + ) { + remember_completed_rollout_call( + &mut result.completed_tool_calls, + call_id, + &closed, + end_ms, + false, + ); + } + } + } } } + Some(event_type) if event_type.ends_with("_end") => { + result.lifecycle_valid = false; + } _ => {} } } Some("response_item") => { let payload = &val["payload"]; - // Track current tool use - if payload["type"].as_str() == Some("function_call") { - if let Some(name) = payload["name"].as_str() { + let item_type = payload["type"].as_str(); + // Codex uses function_call for built-in tools and + // custom_tool_call for freeform tools such as the current + // `exec` implementation. Both remain open until their matching + // output record arrives. + if matches!(item_type, Some("function_call" | "custom_tool_call")) { + if result.active_turn_id.is_none() { + result.lifecycle_valid = false; + } + if let Some(raw_name) = payload["name"].as_str() { + let Some(name) = bounded_rollout_tool_name(raw_name) else { + result.lifecycle_valid = false; + continue; + }; + let namespace = match &payload["namespace"] { + Value::Null => None, + Value::String(raw_namespace) => { + let Some(namespace) = bounded_rollout_tool_name(raw_namespace) + else { + result.lifecycle_valid = false; + continue; + }; + Some(namespace.to_string()) + } + _ => { + result.lifecycle_valid = false; + continue; + } + }; // Extract first arg (typically file path or command) - let arg = payload["arguments"] - .as_str() - .map(parse_codex_tool_arg) - .unwrap_or_default(); + let raw_input = if item_type == Some("function_call") { + &payload["arguments"] + } else { + &payload["input"] + }; + // custom_tool_call input is freeform and may contain + // source, prompts, or secrets. Never surface it; only + // structured built-in function calls get a safe preview. + let arg = if item_type == Some("function_call") { + raw_input + .as_str() + .map(parse_codex_tool_arg) + .unwrap_or_default() + } else { + String::new() + }; let task = if arg.is_empty() { name.to_string() @@ -1299,76 +5788,351 @@ fn parse_codex_jsonl(path: &Path) -> Option { format!("{} {}", name, arg) }; - result.model_generating = false; + result.task_complete = false; + result.turn_active = true; result.thinking_since_ms = 0; if let Some(call_id) = payload["call_id"].as_str() { - let start_ms = event_timestamp_ms(&val).unwrap_or(0); - call_names.insert(call_id.to_string(), name.to_string()); - if name == "write_stdin" { - if let Some(session_id) = payload["arguments"] - .as_str() - .and_then(parse_codex_tool_session_id) - { - write_stdin_targets.insert(call_id.to_string(), session_id); + let start_ms = event_timestamp_ms(&val, parse_now_ms).unwrap_or(0); + advance_rollout_lifecycle(&mut result, start_ms); + if bounded_rollout_lifecycle_id(call_id).is_none() + || start_ms == 0 + || open_calls.contains_key(call_id) + || seen_calls.contains_key(call_id) + || open_calls.len() >= MAX_OPEN_ROLLOUT_CALLS + || seen_calls.len() >= MAX_TRACKED_ROLLOUT_CALL_IDS + { + result.lifecycle_valid = false; + } else { + seen_calls.insert( + call_id.to_string(), + SeenRolloutCall { + namespace: namespace.clone(), + name: name.to_string(), + }, + ); + let is_code_mode_wait = + item_type == Some("function_call") && name == "wait"; + let wait_args = is_code_mode_wait + .then(|| raw_input.as_str().and_then(parse_code_mode_wait_args)) + .flatten(); + if is_code_mode_wait && wait_args.is_none() { + result.lifecycle_valid = false; } - } - call_starts.insert(call_id.to_string(), start_ms); - pending_tasks.retain(|(id, _)| id != call_id); - pending_tasks.push((call_id.to_string(), task)); - if result.tool_calls.len() < 500 { - let idx = result.tool_calls.len(); - result.tool_calls.push(ToolCall { - name: name.to_string(), - arg, - duration_ms: 0, + let wait_cell_id = + wait_args.as_ref().map(|args| args.cell_id.clone()); + let wait_terminate = + wait_args.as_ref().is_some_and(|args| args.terminate); + let code_mode_call_open = open_calls.values().any(|call| { + matches!( + call.class, + RolloutToolClass::CodeModeExec { .. } + | RolloutToolClass::CodeModeWait { .. } + | RolloutToolClass::CodeModeUncorrelatable + ) + }); + let known_wait_cell = wait_cell_id + .as_ref() + .and_then(|cell_id| yielded_code_mode_cells.get(cell_id)) + .cloned(); + let exact_wait_cell = known_wait_cell.as_ref().filter(|cell| { + !code_mode_correlation_ambiguous + && yielded_code_mode_cells.len() == 1 + && !code_mode_call_open + && result.active_turn_id.as_deref() + == Some(cell.turn_id.as_str()) + }); + let opens_code_mode_exec = + item_type == Some("custom_tool_call") && name == "exec"; + let introduces_ambiguity = (opens_code_mode_exec + && (code_mode_call_open + || !yielded_code_mode_cells.is_empty() + || code_mode_correlation_ambiguous)) + || (is_code_mode_wait + && (known_wait_cell.is_none() + || exact_wait_cell.is_none())); + if introduces_ambiguity { + code_mode_correlation_ambiguous = true; + for call in open_calls.values_mut() { + if matches!( + call.class, + RolloutToolClass::CodeModeExec { .. } + | RolloutToolClass::CodeModeWait { .. } + ) { + call.class = RolloutToolClass::CodeModeUncorrelatable; + } + } + } + let exact_wait_cell = wait_cell_id.as_ref().and_then(|cell_id| { + (!code_mode_correlation_ambiguous + && yielded_code_mode_cells.len() == 1 + && !code_mode_call_open) + .then(|| yielded_code_mode_cells.get(cell_id)) + .flatten() + .filter(|cell| { + result.active_turn_id.as_deref() + == Some(cell.turn_id.as_str()) + }) }); - call_indices.insert(call_id.to_string(), idx); + let class = match (item_type, name, exact_wait_cell) { + (Some("custom_tool_call"), "exec", _) => { + if code_mode_correlation_ambiguous { + RolloutToolClass::CodeModeUncorrelatable + } else { + RolloutToolClass::CodeModeExec { + exec_started_at_ms: start_ms, + } + } + } + (Some("function_call"), "wait", _) if wait_terminate => { + RolloutToolClass::CodeModeUncorrelatable + } + (Some("function_call"), "wait", Some(cell)) => { + RolloutToolClass::CodeModeWait { + exec_started_at_ms: cell.exec_started_at_ms, + exec_yielded_at_ms: cell.exec_yielded_at_ms, + } + } + (Some("function_call"), "wait", _) => { + RolloutToolClass::CodeModeUncorrelatable + } + (_, "request_user_input", _) => { + RolloutToolClass::RequestUserInput + } + _ => RolloutToolClass::Ordinary, + }; + let code_mode_wait_target = wait_cell_id; + let write_stdin_target = (name == "write_stdin") + .then(|| { + raw_input.as_str().and_then(parse_codex_tool_session_id) + }) + .flatten(); + let tool_index = if result.tool_calls.len() < 500 { + let idx = result.tool_calls.len(); + result.tool_calls.push(ToolCall { + name: name.to_string(), + arg, + duration_ms: 0, + }); + Some(idx) + } else { + None + }; + open_calls.insert( + call_id.to_string(), + OpenRolloutCall { + started_at_ms: start_ms, + name: name.to_string(), + custom: item_type == Some("custom_tool_call"), + class, + tool_index, + write_stdin_target, + code_mode_wait_target, + code_mode_wait_terminate: wait_terminate, + known_end_at_ms: None, + }, + ); + pending_tasks.retain(|(id, _)| id != call_id); + pending_tasks.push((call_id.to_string(), task)); + if pending_tasks.len() > MAX_OPEN_ROLLOUT_CALLS { + result.lifecycle_valid = false; + pending_tasks.truncate(MAX_OPEN_ROLLOUT_CALLS); + } } + } else { + result.lifecycle_valid = false; } + } else { + result.lifecycle_valid = false; } - } else if payload["type"].as_str() == Some("function_call_output") { + } else if matches!( + item_type, + Some("function_call_output" | "custom_tool_call_output") + ) { if let Some(call_id) = payload["call_id"].as_str() { - let end_ms = event_timestamp_ms(&val).unwrap_or(0); - let output = payload["output"].as_str().unwrap_or_default(); - match call_names.get(call_id).map(String::as_str) { - Some("exec_command") => { + let Some(call) = open_calls.get(call_id).cloned() else { + result.lifecycle_valid = false; + continue; + }; + let end_ms = event_timestamp_ms(&val, parse_now_ms).unwrap_or(0); + advance_rollout_lifecycle(&mut result, end_ms); + if end_ms == 0 + || call + .known_end_at_ms + .is_some_and(|known_end_at_ms| end_ms < known_end_at_ms) + { + result.lifecycle_valid = false; + } + let custom_output = item_type == Some("custom_tool_call_output"); + if call.custom != custom_output { + result.lifecycle_valid = false; + close_codex_tool_call( + call_id, + end_ms, + &mut result.tool_calls, + &mut open_calls, + &mut pending_tasks, + ); + continue; + } + let output_value = &payload["output"]; + let output = output_value.as_str().unwrap_or_default(); + let mut code_mode_terminal = false; + match call.name.as_str() { + "exec" if item_type == Some("custom_tool_call_output") => { + match code_mode_output_state(output_value) { + Some(CodeModeOutputState::Running(cell_id)) => { + if yielded_code_mode_cells.contains_key(&cell_id) + || result.active_turn_id.is_none() + || yielded_code_mode_cells.len() + >= MAX_TRACKED_CODE_MODE_CELLS + { + result.lifecycle_valid = false; + } else { + if !yielded_code_mode_cells.is_empty() + || matches!( + call.class, + RolloutToolClass::CodeModeUncorrelatable + ) + { + code_mode_correlation_ambiguous = true; + } + yielded_code_mode_cells.insert( + cell_id, + YieldedCodeModeCell { + turn_id: result + .active_turn_id + .clone() + .unwrap_or_default(), + origin_call_id: call_id.to_string(), + exec_started_at_ms: call.started_at_ms, + exec_yielded_at_ms: end_ms, + }, + ); + } + } + Some(CodeModeOutputState::Terminal) => { + code_mode_terminal = true; + } + None => result.lifecycle_valid = false, + } + close_codex_tool_call( + call_id, + end_ms, + &mut result.tool_calls, + &mut open_calls, + &mut pending_tasks, + ); + } + "wait" => { + close_codex_tool_call( + call_id, + end_ms, + &mut result.tool_calls, + &mut open_calls, + &mut pending_tasks, + ); + if let Some(target) = call.code_mode_wait_target.as_ref() { + let unique_yielded_cell = yielded_code_mode_cells.len() == 1; + let provenance = yielded_code_mode_cells.remove(target); + match ( + provenance, + code_mode_output_state(output_value), + call.code_mode_wait_terminate, + ) { + ( + Some(mut provenance), + Some(CodeModeOutputState::Running(cell_id)), + false, + ) if cell_id == *target => { + provenance.exec_yielded_at_ms = end_ms; + yielded_code_mode_cells + .insert(target.clone(), provenance); + } + ( + Some(provenance), + Some(CodeModeOutputState::Terminal), + _, + ) => { + if matches!( + call.class, + RolloutToolClass::CodeModeWait { .. } + ) && (!unique_yielded_cell + || code_mode_correlation_ambiguous + || !complete_yielded_code_mode_exec( + &mut result.completed_tool_calls, + &open_calls, + &provenance, + &call, + result.active_turn_id.as_deref(), + end_ms, + )) + { + result.lifecycle_valid = false; + } + } + _ => result.lifecycle_valid = false, + } + } + } + "exec_command" => { if let Some(session_id) = running_process_session_id(output) { - running_exec_by_session.insert(session_id, call_id.to_string()); + if session_id.len() > MAX_ROLLOUT_LIFECYCLE_ID_BYTES + || (!running_exec_by_session.contains_key(&session_id) + && running_exec_by_session.len() + >= MAX_OPEN_ROLLOUT_CALLS) + { + result.lifecycle_valid = false; + close_codex_tool_call( + call_id, + end_ms, + &mut result.tool_calls, + &mut open_calls, + &mut pending_tasks, + ); + } else { + running_exec_by_session + .insert(session_id, call_id.to_string()); + } } else { close_codex_tool_call( call_id, end_ms, &mut result.tool_calls, - &call_indices, - &mut call_starts, + &mut open_calls, &mut pending_tasks, ); } } - Some("write_stdin") => { + "write_stdin" => { close_codex_tool_call( call_id, end_ms, &mut result.tool_calls, - &call_indices, - &mut call_starts, + &mut open_calls, &mut pending_tasks, ); if output_reports_process_exit(output) { if let Some(exec_call_id) = - write_stdin_targets.get(call_id).and_then(|session_id| { + call.write_stdin_target.as_ref().and_then(|session_id| { running_exec_by_session.remove(session_id) }) { - close_codex_tool_call( + if let Some(closed) = close_codex_tool_call( &exec_call_id, end_ms, &mut result.tool_calls, - &call_indices, - &mut call_starts, + &mut open_calls, &mut pending_tasks, - ); + ) { + remember_completed_rollout_call( + &mut result.completed_tool_calls, + &exec_call_id, + &closed, + end_ms, + false, + ); + } } } } @@ -1377,13 +6141,39 @@ fn parse_codex_jsonl(path: &Path) -> Option { call_id, end_ms, &mut result.tool_calls, - &call_indices, - &mut call_starts, + &mut open_calls, &mut pending_tasks, ); } } + if result.lifecycle_valid && !open_calls.contains_key(call_id) { + remember_completed_rollout_call( + &mut result.completed_tool_calls, + call_id, + &call, + end_ms, + code_mode_terminal, + ); + } + if yielded_code_mode_cells.is_empty() + && !open_calls.values().any(|open| { + matches!( + open.class, + RolloutToolClass::CodeModeExec { .. } + | RolloutToolClass::CodeModeWait { .. } + | RolloutToolClass::CodeModeUncorrelatable + ) + }) + { + code_mode_correlation_ambiguous = false; + } + } else { + result.lifecycle_valid = false; } + } else if item_type.is_some_and(|kind| kind.contains("call")) { + // A future/hosted tool lifecycle is not safely correlated + // until this parser understands its exact open/close IDs. + result.lifecycle_valid = false; } } @@ -1401,6 +6191,16 @@ fn parse_codex_jsonl(path: &Path) -> Option { } } + Some("error") => { + // Top-level provider errors likewise have no exact lifecycle + // resolution edge. Never retain a stale active or completed + // positive projection after observing one. + result.lifecycle_valid = false; + result.task_complete = false; + yielded_code_mode_cells.clear(); + code_mode_correlation_ambiguous = false; + } + _ => {} } } @@ -1409,18 +6209,98 @@ fn parse_codex_jsonl(path: &Path) -> Option { return None; } + if !replayed_meta.is_empty() && !saw_exact_inherited_parent_meta { + result.lifecycle_valid = false; + } + if (saw_exact_inherited_parent_meta || fork_metadata_attested) + && (!copied_epoch_chain_initialized + || copied_epoch_thresholds.is_none() + || copied_epoch_thresholds + .as_ref() + .is_some_and(|thresholds| copied_epoch_index != thresholds.len())) + { + // A copied prefix without every exact ancestor/current fork boundary + // can only describe inherited state, never this child rollout's live + // lifecycle. + result.lifecycle_valid = false; + } + result.current_task = pending_tasks .last() .map(|(_, task)| task.clone()) .unwrap_or_default(); - result.pending_since_ms = call_starts.values().copied().min().unwrap_or(0); - if !result.model_generating { + result.pending_since_ms = open_calls + .values() + .map(|call| call.started_at_ms) + .min() + .unwrap_or(0); + result.open_tool_ids = open_calls.keys().cloned().collect(); + result.open_tool_started_at_ms = open_calls + .iter() + .map(|(call_id, call)| (call_id.clone(), call.started_at_ms)) + .collect(); + result.open_tool_classes = open_calls + .iter() + .map(|(call_id, call)| (call_id.clone(), call.class)) + .collect(); + result.live_code_mode_cells = yielded_code_mode_cells.len(); + result.code_mode_correlation_ambiguous = code_mode_correlation_ambiguous; + result.awaiting_input_since_ms = open_calls + .values() + .filter_map(|call| codex_tool_waits_for_user(&call.name).then_some(call.started_at_ms)) + .min() + .unwrap_or(0); + if !result.turn_active || result.pending_since_ms > 0 { result.thinking_since_ms = 0; + } else if result.thinking_since_ms == 0 { + result.thinking_since_ms = result + .last_activity + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX); } Some(result) } +fn native_codex_rate_limit_window(slot: &str, value: &Value) -> Option { + let fallback_label = match slot { + "primary" => "Primary", + "secondary" => "Secondary", + _ => return None, + }; + let used_pct = value["used_percent"].as_f64()?; + if !used_pct.is_finite() || !(0.0..=100.0).contains(&used_pct) { + return None; + } + let window_minutes = value["window_minutes"].as_u64(); + if window_minutes + .is_some_and(|minutes| minutes == 0 || minutes > MAX_NATIVE_RATE_LIMIT_WINDOW_MINUTES) + { + return None; + } + let label = native_rate_limit_window_label(window_minutes, fallback_label); + RateLimitWindow::try_new( + slot, + label, + used_pct, + value["resets_at"].as_u64(), + window_minutes, + RateLimitProvenance::Native, + ) +} + +fn native_rate_limit_window_label(window_minutes: Option, fallback: &str) -> String { + match window_minutes { + Some(minutes) if minutes % (24 * 60) == 0 => format!("{}d", minutes / (24 * 60)), + Some(minutes) if minutes % 60 == 0 => format!("{}h", minutes / 60), + Some(minutes) => format!("{minutes}m"), + None => fallback.to_string(), + } +} + fn is_account_level_codex_rate_limit(rate_limits: &Value) -> bool { matches!(rate_limits["limit_id"].as_str(), Some("codex") | None) } @@ -1428,6 +6308,8 @@ fn is_account_level_codex_rate_limit(rate_limits: &Value) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::codex_hooks::state::{HookEventKind, HookProcessIdentity}; + use std::collections::{BTreeMap, BTreeSet}; use std::fs::File; use std::io::Write; use std::time::{Duration, SystemTime}; @@ -1470,6 +6352,296 @@ mod tests { } } + fn finalize_unintegrated( + collector: &CodexCollector, + session: AgentSession, + process_info: HashMap, + ) -> AgentSession { + let shared = super::super::SharedProcessData { + children_map: process::get_children_map(&process_info), + process_info, + ports: HashMap::new(), + slow_tick: false, + mcp_server_pids: HashSet::new(), + mcp_owned_rollouts: HashSet::new(), + mcp_suppress: true, + desktop_rollout_fd_map: HashMap::new(), + }; + collector + .finalize_hook_records(vec![session], Vec::new(), &shared, unix_now_ms()) + .into_iter() + .next() + .unwrap() + } + + fn hook_record(candidate: HookCandidate, now_ms: u64) -> HookCollectorRecord { + let edge_ms = now_ms.saturating_sub(1_000); + let mut record = HookCollectorRecord { + generation_id: "test-generation".to_string(), + session_id: "hook-session".to_string(), + cwd: "/home/user/project".to_string(), + started_at_ms: now_ms.saturating_sub(10_000), + observed_at_ms: now_ms, + status_since_ms: edge_ms, + ended_at_ms: 0, + exit_observed_at_ms: 0, + exit_supported_rollout_correlated: false, + pid: 42, + process_incarnation: Some("test:codex:42".to_string()), + process_state: HookProcessState::Live, + native_process_verified: true, + supported_release_attested: true, + effective_hook_engine_attested: true, + actionable: true, + owns_resources: true, + local_config_ambiguous: false, + interaction_ambiguous: false, + subagent_set_complete: true, + turn_id: Some("turn-1".to_string()), + prompt_observed_at_ms: edge_ms, + stop_observed_at_ms: edge_ms, + tool_opened_at_ms: HashMap::new(), + tool_classes: HashMap::new(), + subagent_opened_at_ms: HashMap::new(), + subagent_stopped_at_ms: HashMap::new(), + candidate, + observations: Vec::new(), + }; + match &record.candidate { + HookCandidate::ToolOpen(ids) => { + record.tool_opened_at_ms = ids.iter().map(|id| (id.clone(), edge_ms)).collect(); + record.tool_classes = ids + .iter() + .map(|id| (id.clone(), HookToolClass::Ordinary)) + .collect(); + } + HookCandidate::SubagentOpen { + active, + provisional, + root, + } => { + record.subagent_opened_at_ms = active + .iter() + .chain(provisional.iter()) + .map(|id| (id.clone(), edge_ms)) + .collect(); + record.subagent_stopped_at_ms = + provisional.iter().map(|id| (id.clone(), edge_ms)).collect(); + if let HookRootCandidate::ToolOpen(ids) = root { + record.tool_opened_at_ms = ids.iter().map(|id| (id.clone(), edge_ms)).collect(); + record.tool_classes = ids + .iter() + .map(|id| (id.clone(), HookToolClass::Ordinary)) + .collect(); + } + } + HookCandidate::Unknown(_) + | HookCandidate::TurnOpen + | HookCandidate::TurnStopped + | HookCandidate::Ended => {} + } + record + } + + fn production_turn_open_hook_state(now_ms: u64) -> HookSessionState { + let edge_ms = now_ms.saturating_sub(1_000); + HookSessionState { + schema_version: 1, + integration: IntegrationIdentity { + hook_schema_revision: "test-schema".to_string(), + helper_digest: "test-helper".to_string(), + installation_id: "test-installation".to_string(), + config_digest: "test-config".to_string(), + complete_hook_set: true, + }, + generation_id: "test-generation".to_string(), + session_id: "hook-session".to_string(), + cwd: "/home/user/project".to_string(), + process: HookProcessIdentity { + pid: 42, + started_at_ms: now_ms.saturating_sub(20_000), + incarnation: "test:codex:42".to_string(), + shared_host: false, + launch_config_ambiguous: false, + }, + created_at_ms: now_ms.saturating_sub(10_000), + updated_at_ms: now_ms, + ended_at_ms: 0, + first_confirmed_gone_at_ms: 0, + last_event: HookEventKind::UserPromptSubmit, + session_start_source: None, + last_root_event: Some(HookEventKind::UserPromptSubmit), + last_root_boundary_at_ms: edge_ms, + active_turn_id: Some("turn-1".to_string()), + prompt_observed_at_ms: edge_ms, + stop_turn_id: None, + stop_hook_active: None, + stop_observed_at_ms: 0, + prompt_accepted: true, + open_tools: BTreeMap::new(), + tool_opened_at_ms: BTreeMap::new(), + closed_tools: BTreeSet::new(), + completed_tool_order: Vec::new(), + open_child_tools: BTreeMap::new(), + child_tool_opened_at_ms: BTreeMap::new(), + closed_child_tools: BTreeMap::new(), + completed_child_tool_order: Vec::new(), + open_subagents: BTreeSet::new(), + subagent_opened_at_ms: BTreeMap::new(), + provisional_stopped_subagents: BTreeSet::new(), + subagent_stopped_at_ms: BTreeMap::new(), + closed_subagents: BTreeSet::new(), + completed_subagent_order: Vec::new(), + open_questions: BTreeSet::new(), + question_opened_at_ms: BTreeMap::new(), + closed_questions: BTreeSet::new(), + completed_question_order: Vec::new(), + question_agents: BTreeMap::new(), + permission_ambiguity: false, + permission_observed_at_ms: 0, + child_permission_ambiguities: BTreeSet::new(), + child_permission_observed_at_ms: BTreeMap::new(), + compaction_open: false, + sticky_fault: None, + completed_history_truncated: false, + completed_ingests: Vec::new(), + samples: Vec::new(), + } + } + + fn active_root_rollout(now_ms: u64) -> RolloutLifecycle { + RolloutLifecycle { + root_cli_version: plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(), + turn_active: true, + active_turn_id: Some("turn-1".to_string()), + turn_started_at_ms: now_ms.saturating_sub(2_000), + latest_lifecycle_at_ms: now_ms.saturating_sub(500), + ..Default::default() + } + } + + fn completed_root_rollout(now_ms: u64) -> RolloutLifecycle { + RolloutLifecycle { + root_cli_version: plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(), + task_complete: true, + completed_turn_id: Some("turn-1".to_string()), + turn_started_at_ms: now_ms.saturating_sub(2_000), + latest_lifecycle_at_ms: now_ms.saturating_sub(500), + task_completed_at_ms: now_ms.saturating_sub(500), + ..Default::default() + } + } + + fn active_child_rollout( + session_id: &str, + direct_child: bool, + now_ms: u64, + ) -> DescendantRolloutLifecycle { + DescendantRolloutLifecycle { + session_id: session_id.to_string(), + cli_version: plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(), + direct_child, + lifecycle_valid: true, + turn_active: true, + task_complete: false, + active_turn_id: Some("child-turn".to_string()), + completed_turn_id: None, + turn_started_at_ms: now_ms.saturating_sub(900), + latest_lifecycle_at_ms: now_ms.saturating_sub(100), + task_completed_at_ms: 0, + open_tool_ids: HashSet::new(), + open_tool_started_at_ms: HashMap::new(), + open_tool_classes: HashMap::new(), + live_code_mode_cells: 0, + code_mode_correlation_ambiguous: false, + } + } + + fn terminal_child_rollout( + session_id: &str, + direct_child: bool, + now_ms: u64, + ) -> DescendantRolloutLifecycle { + DescendantRolloutLifecycle { + session_id: session_id.to_string(), + cli_version: plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(), + direct_child, + lifecycle_valid: true, + turn_active: false, + task_complete: true, + active_turn_id: None, + completed_turn_id: Some("child-turn".to_string()), + turn_started_at_ms: now_ms.saturating_sub(900), + latest_lifecycle_at_ms: now_ms.saturating_sub(100), + task_completed_at_ms: now_ms.saturating_sub(100), + open_tool_ids: HashSet::new(), + open_tool_started_at_ms: HashMap::new(), + open_tool_classes: HashMap::new(), + live_code_mode_cells: 0, + code_mode_correlation_ambiguous: false, + } + } + + fn hook_shared() -> super::super::SharedProcessData { + let process_info = HashMap::from([(42, proc_info(42, 1, "/usr/local/bin/codex"))]); + super::super::SharedProcessData { + children_map: process::get_children_map(&process_info), + process_info, + ports: HashMap::new(), + slow_tick: false, + mcp_server_pids: HashSet::new(), + mcp_owned_rollouts: HashSet::new(), + mcp_suppress: true, + desktop_rollout_fd_map: HashMap::new(), + } + } + + fn prepared_live_hook( + collector: &CodexCollector, + now_ms: u64, + session_id: &str, + generation_id: &str, + process_incarnation: &str, + cli_version: &str, + ) -> (HookCollectorRecord, AgentSession) { + let mut record = hook_record(HookCandidate::TurnOpen, now_ms); + record.session_id = session_id.to_string(); + record.generation_id = generation_id.to_string(); + record.process_incarnation = Some(process_incarnation.to_string()); + let mut rollout = active_root_rollout(now_ms); + rollout.root_cli_version = cli_version.to_string(); + collector + .rollout_lifecycle + .borrow_mut() + .insert(session_id.to_string(), rollout); + let mut session = collector.hook_placeholder(&record); + session.pid = record.pid; + session.version = cli_version.to_string(); + session.model = "gpt-test".to_string(); + session.effort = "high".to_string(); + session.context_percent = 42.0; + session.total_input_tokens = 123; + session.total_output_tokens = 45; + session.total_cache_read = 67; + session.turn_count = 9; + session.git_branch = "sensitive-branch-name".to_string(); + session.git_added = 3; + session.git_modified = 4; + session.token_history = vec![10, 20, 30]; + session.initial_prompt = "sensitive prompt".to_string(); + session.first_assistant_text = "sensitive response".to_string(); + session.chat_messages.push(ChatMessage { + role: ChatRole::User, + text: "sensitive chat".to_string(), + }); + session.tool_calls.push(ToolCall { + name: "Read".to_string(), + arg: "sensitive/path".to_string(), + duration_ms: 1, + }); + (record, session) + } + fn write_jsonl(path: &Path, lines: &[&str]) { let mut file = File::create(path).unwrap(); for line in lines { @@ -1478,6 +6650,14 @@ mod tests { file.flush().unwrap(); } + fn append_jsonl(path: &Path, lines: &[&str]) { + let mut file = std::fs::OpenOptions::new().append(true).open(path).unwrap(); + for line in lines { + writeln!(file, "{}", line).unwrap(); + } + file.flush().unwrap(); + } + fn set_modified(path: &Path, when: SystemTime) { // Open with write access: on Windows, setting timestamps through a // read-only handle fails with PermissionDenied. @@ -1485,7 +6665,6 @@ mod tests { file.set_modified(when).unwrap(); } - #[cfg(windows)] #[test] fn find_codex_pids_windows_keeps_real_child_over_wrappers() { let mut process_info = HashMap::new(); @@ -1689,7 +6868,7 @@ mod tests { } #[test] - fn desktop_rollout_selection_loads_active_session_with_host_pid() { + fn desktop_rollout_metrics_do_not_create_live_status_or_action_owner() { let temp = tempfile::tempdir().unwrap(); let active = temp.path().join("rollout-active.jsonl"); let stale = temp.path().join("rollout-stale.jsonl"); @@ -1729,28 +6908,33 @@ mod tests { &process_info, &children_map, &ports, + &HashSet::new(), ) .map(|(session, _)| session) }) .collect(); assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].pid, 99); - assert_eq!(sessions[0].session_id, "desktop-123"); - assert_eq!(sessions[0].agent_cli, "codex"); - assert_eq!(sessions[0].status, SessionStatus::Waiting); - assert_eq!(sessions[0].mem_mb, 0); - assert!(sessions[0].children.is_empty()); + let session = finalize_unintegrated( + &collector, + sessions.into_iter().next().unwrap(), + process_info, + ); + assert_eq!(session.pid, 99); + assert_eq!(session.session_id, "desktop-123"); + assert_eq!(session.agent_cli, "codex"); + assert_eq!(session.status, SessionStatus::Unknown); + assert!(session.action_process_incarnation.is_none()); + assert_eq!(session.mem_mb, 0); + assert!(session.children.is_empty()); } #[test] fn desktop_filesystem_only_rollout_is_unknown_without_fd_owner() { let sessions = tempfile::tempdir().unwrap(); - let today = sessions.path().join( - chrono::Local::now() - .format("%Y/%m/%d") - .to_string(), - ); + let today = sessions + .path() + .join(chrono::Local::now().format("%Y/%m/%d").to_string()); fs::create_dir_all(&today).unwrap(); let active = today.join("rollout-active.jsonl"); write_jsonl(&active, &[DESKTOP_SESSION_META]); @@ -1759,6 +6943,15 @@ mod tests { sessions_dir: sessions.path().to_path_buf(), last_rate_limit: None, desktop_recent_scanner: DesktopRecentRolloutScanner::new(), + parse_cache: RefCell::new(CodexParseCache::default()), + rollout_lifecycle: RefCell::new(HashMap::new()), + hook_process_states: RefCell::new(HashMap::new()), + hook_exit_observations: RefCell::new(HashMap::new()), + hook_process_rollout_bindings: RefCell::new(HashMap::new()), + hook_live_session_snapshots: RefCell::new(HashMap::new()), + hook_done_tombstones: RefCell::new(HashMap::new()), + herdr_status_resolver: RefCell::new(HerdrStatusResolver::default()), + herdr_working_continuity: RefCell::new(HashMap::new()), }; let mut shared = super::super::SharedProcessData { process_info: HashMap::new(), @@ -1785,7 +6978,10 @@ mod tests { assert_eq!(sessions[0].pid, 0); assert_eq!(sessions[0].session_id, "desktop-123"); assert_eq!(sessions[0].status, SessionStatus::Unknown); - assert_eq!(sessions[0].current_tasks, vec!["unknown".to_string()]); + assert_eq!( + sessions[0].current_tasks, + vec!["status evidence unavailable".to_string()] + ); } #[test] @@ -1799,6 +6995,22 @@ mod tests { assert_eq!(result.git_branch, "feature/x"); } + #[test] + fn root_cli_version_is_not_replaced_by_replayed_session_metadata() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + r#"{"type":"session_meta","timestamp":"2026-03-28T15:00:00Z","payload":{"id":"root","cwd":"/home/user/project","cli_version":"0.145.0","timestamp":"2026-03-28T15:00:00Z"}}"#, + r#"{"type":"session_meta","timestamp":"2026-03-28T15:01:00Z","payload":{"id":"stale-parent","cwd":"/home/user/other","cli_version":"0.146.0","timestamp":"2026-03-28T15:01:00Z"}}"#, + ], + ); + let result = parse_codex_jsonl(file.path()).unwrap(); + assert_eq!(result.session_id, "root"); + assert_eq!(result.cwd, "/home/user/project"); + assert_eq!(result.version, "0.145.0"); + } + #[test] fn test_parse_codex_token_count() { let mut file = tempfile::NamedTempFile::new().unwrap(); @@ -1851,6 +7063,15 @@ mod tests { assert_eq!(rl.five_hour_window_minutes, Some(300)); assert_eq!(rl.seven_day_pct, Some(14.0)); assert_eq!(rl.seven_day_window_minutes, Some(10_080)); + assert_eq!(rl.windows.len(), 2); + assert_eq!(rl.windows[0].id, "primary"); + assert_eq!(rl.windows[0].label, "5h"); + assert_eq!(rl.windows[0].used_pct, 9.0); + assert_eq!(rl.windows[0].provenance, RateLimitProvenance::Native); + assert_eq!(rl.windows[1].id, "secondary"); + assert_eq!(rl.windows[1].label, "7d"); + assert_eq!(rl.windows[1].used_pct, 14.0); + assert_eq!(rl.windows[1].provenance, RateLimitProvenance::Native); } #[test] @@ -1868,6 +7089,26 @@ mod tests { assert_eq!(rl.five_hour_pct, None); assert_eq!(rl.seven_day_pct, Some(48.0)); assert_eq!(rl.seven_day_window_minutes, Some(43_200)); + assert_eq!(rl.windows.len(), 1); + assert_eq!(rl.windows[0].id, "primary"); + assert_eq!(rl.windows[0].label, "30d"); + assert_eq!(rl.windows[0].used_pct, 48.0); + assert_eq!(rl.windows[0].window_minutes, Some(43_200)); + assert_eq!(rl.windows[0].provenance, RateLimitProvenance::Native); + } + + #[test] + fn test_parse_codex_rate_limit_rejects_unbounded_native_duration() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-06-17T15:01:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1,"output_tokens":1},"last_token_usage":{"input_tokens":1,"output_tokens":1}},"rate_limits":{"limit_id":"codex","primary":{"used_percent":48.0,"window_minutes":525601,"resets_at":1780000000},"secondary":null,"plan_type":"free"}}}"#, + ], + ); + let result = parse_codex_jsonl(file.path()).unwrap(); + assert!(result.rate_limit.is_none()); } #[test] @@ -1917,15 +7158,15 @@ mod tests { ], ); let result = parse_codex_jsonl(file.path()).unwrap(); - // Bad line skipped, agent_message still counted + // Metrics continue after a bad line, but lifecycle promotion fails closed. assert_eq!(result.turn_count, 1); + assert!(!result.lifecycle_valid); } #[test] - fn test_parse_codex_model_generating_after_user_message() { - // Latest event is a user_message → the model has not replied yet. - // Combined with recent rollout mtime this drives the Thinking - // status branch in CodexCollector::collect_sessions. + fn test_parse_codex_turn_active_after_user_message() { + // Older rollouts may omit task_started, so a user message remains + // useful activity metadata but cannot prove an exact current turn. let mut file = tempfile::NamedTempFile::new().unwrap(); write_lines( &mut file, @@ -1936,17 +7177,14 @@ mod tests { ], ); let result = parse_codex_jsonl(file.path()).unwrap(); - assert!( - result.model_generating, - "trailing user_message must mark model as generating" - ); + assert!(result.turn_active); + assert!(!result.lifecycle_valid); } #[test] - fn test_parse_codex_model_generating_cleared_by_agent_message() { - // user_message followed by agent_message → reply landed, the - // session is idle. Without the reset Thinking would misfire on - // every just-finished turn while mtime is still fresh. + fn test_parse_codex_agent_message_does_not_close_turn() { + // Commentary/final text can be followed by tools. Only task_complete + // or turn_aborted closes a Codex turn. let mut file = tempfile::NamedTempFile::new().unwrap(); write_lines( &mut file, @@ -1957,10 +7195,7 @@ mod tests { ], ); let result = parse_codex_jsonl(file.path()).unwrap(); - assert!( - !result.model_generating, - "agent_message must close the thinking window" - ); + assert!(result.turn_active); } #[test] @@ -2018,7 +7253,7 @@ mod tests { } #[test] - fn test_codex_pending_function_call_marks_session_executing_and_timeline() { + fn rollout_open_call_needs_hook_correlation_before_executing() { let mut file = tempfile::NamedTempFile::new().unwrap(); write_lines( &mut file, @@ -2029,6 +7264,9 @@ mod tests { r#"{"type":"response_item","timestamp":"2026-03-28T15:01:06Z","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}","call_id":"call_1"}}"#, ], ); + let parsed = parse_codex_jsonl(file.path()).unwrap(); + assert_eq!(parsed.open_tool_ids, HashSet::from(["call_1".to_string()])); + assert!(parsed.pending_since_ms > 0); let collector = CodexCollector::new(); let mut process_info = HashMap::new(); @@ -2050,66 +7288,177 @@ mod tests { &process_info, &HashMap::new(), &HashMap::new(), + &HashSet::new(), ) .unwrap(); + let session = finalize_unintegrated(&collector, session, process_info); - assert_eq!(session.status, SessionStatus::Executing); + assert_eq!(session.status, SessionStatus::Unknown); assert_eq!( - session.current_tasks, - vec!["exec_command cargo test".to_string()] + session.status_evidence.authority, + StatusAuthority::Unavailable ); assert_eq!(session.tool_calls.len(), 1); assert_eq!(session.tool_calls[0].name, "exec_command"); - assert_eq!(session.tool_calls[0].arg, "cargo test"); + assert!(session.tool_calls[0].arg.is_empty()); assert_eq!(session.tool_calls[0].duration_ms, 0); - assert!(session.pending_since_ms > 0); + assert_eq!(session.pending_since_ms, 0); + assert!(!session.awaiting_input); assert_eq!(session.thinking_since_ms, 0); } #[test] - fn test_codex_exec_command_end_closes_task_and_records_duration() { + fn rollout_request_user_input_never_promotes_live_status() { let mut file = tempfile::NamedTempFile::new().unwrap(); write_lines( &mut file, &[ SESSION_META, - r#"{"type":"response_item","timestamp":"2026-03-28T15:01:06Z","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}","call_id":"call_1"}}"#, - r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:09Z","payload":{"type":"exec_command_end","call_id":"call_1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"user_message","message":"ask me first"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:06Z","payload":{"type":"function_call","name":"request_user_input","arguments":"{\"questions\":[{\"question\":\"Choose a mode\"}]}","call_id":"call_question"}}"#, ], ); let collector = CodexCollector::new(); let mut process_info = HashMap::new(); - process_info.insert( - 42, - ProcInfo { - pid: 42, - ppid: 1, - rss_kb: 1024, - cpu_pct: 0.0, - command: "codex".to_string(), - }, - ); + process_info.insert(42, proc_info(42, 1, "codex")); + let mut child = proc_info(43, 42, "cargo test"); + child.cpu_pct = 99.0; + process_info.insert(43, child); + let children_map = HashMap::from([(42, vec![43])]); let (session, _) = collector .load_session_with_rate_limit( owned_process(42), file.path(), &process_info, + &children_map, &HashMap::new(), - &HashMap::new(), + &HashSet::new(), ) .unwrap(); + let session = finalize_unintegrated(&collector, session, process_info); - assert_eq!(session.status, SessionStatus::Waiting); - assert_eq!(session.current_tasks, vec!["waiting for input".to_string()]); + assert_eq!(session.status, SessionStatus::Unknown); + assert!(!session.awaiting_input); + assert_eq!( + session.status_evidence.authority, + StatusAuthority::Unavailable + ); assert_eq!(session.tool_calls.len(), 1); - assert_eq!(session.tool_calls[0].duration_ms, 3_000); + assert_eq!(session.tool_calls[0].name, "request_user_input"); + assert_eq!(session.tool_calls[0].duration_ms, 0); assert_eq!(session.pending_since_ms, 0); } #[test] - fn test_codex_exec_command_output_closes_task_without_end_event() { + fn rollout_request_user_input_resolution_remains_metadata_only() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:06Z","payload":{"type":"function_call","name":"request_user_input","arguments":"{\"questions\":[]}","call_id":"call_question"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:09Z","payload":{"type":"function_call_output","call_id":"call_question","output":"{\"answers\":{}}"}}"#, + ], + ); + + let collector = CodexCollector::new(); + let process_info = HashMap::from([(42, proc_info(42, 1, "codex"))]); + let (session, _) = collector + .load_session_with_rate_limit( + owned_process(42), + file.path(), + &process_info, + &HashMap::new(), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + let session = finalize_unintegrated(&collector, session, process_info); + + assert_eq!(session.status, SessionStatus::Unknown); + assert!(!session.awaiting_input); + assert_eq!(session.pending_since_ms, 0); + assert_eq!(session.tool_calls[0].duration_ms, 3_000); + } + + #[test] + fn test_codex_turn_boundaries_clear_stale_user_input_waits() { + let boundaries = [ + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:09Z","payload":{"type":"task_complete"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:09Z","payload":{"type":"turn_aborted"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:09Z","payload":{"type":"task_started","model_context_window":200000}}"#, + ]; + + for boundary in boundaries { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:06Z","payload":{"type":"function_call","name":"request_user_input","arguments":"{\"questions\":[]}","call_id":"call_question"}}"#, + boundary, + ], + ); + + let result = parse_codex_jsonl(file.path()).unwrap(); + assert_eq!(result.awaiting_input_since_ms, 0); + assert_eq!(result.pending_since_ms, 0); + assert!(result.current_task.is_empty()); + assert_eq!(result.tool_calls[0].duration_ms, 3_000); + } + } + + #[test] + fn rollout_exec_end_records_duration_but_stays_open_until_output() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:05Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:06Z","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}","call_id":"call_1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:09Z","payload":{"type":"exec_command_end","call_id":"call_1"}}"#, + ], + ); + let parsed = parse_codex_jsonl(file.path()).unwrap(); + assert_eq!(parsed.open_tool_ids, HashSet::from(["call_1".to_string()])); + assert!(parsed.pending_since_ms > 0); + + let collector = CodexCollector::new(); + let mut process_info = HashMap::new(); + process_info.insert( + 42, + ProcInfo { + pid: 42, + ppid: 1, + rss_kb: 1024, + cpu_pct: 0.0, + command: "codex".to_string(), + }, + ); + + let (session, _) = collector + .load_session_with_rate_limit( + owned_process(42), + file.path(), + &process_info, + &HashMap::new(), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + let session = finalize_unintegrated(&collector, session, process_info); + + assert_eq!(session.status, SessionStatus::Unknown); + assert_eq!(session.tool_calls.len(), 1); + assert_eq!(session.tool_calls[0].duration_ms, 3_000); + assert_eq!(session.pending_since_ms, 0); + } + + #[test] + fn rollout_exec_output_records_duration_but_not_live_status() { let mut file = tempfile::NamedTempFile::new().unwrap(); write_lines( &mut file, @@ -2140,18 +7489,19 @@ mod tests { &process_info, &HashMap::new(), &HashMap::new(), + &HashSet::new(), ) .unwrap(); + let session = finalize_unintegrated(&collector, session, process_info); - assert_eq!(session.status, SessionStatus::Waiting); - assert_eq!(session.current_tasks, vec!["waiting for input".to_string()]); + assert_eq!(session.status, SessionStatus::Unknown); assert_eq!(session.tool_calls.len(), 1); assert_eq!(session.tool_calls[0].duration_ms, 3_000); assert_eq!(session.pending_since_ms, 0); } #[test] - fn test_codex_running_exec_closes_when_write_stdin_reports_exit() { + fn rollout_background_exec_closure_is_metadata_only() { let mut file = tempfile::NamedTempFile::new().unwrap(); write_lines( &mut file, @@ -2184,11 +7534,12 @@ mod tests { &process_info, &HashMap::new(), &HashMap::new(), + &HashSet::new(), ) .unwrap(); + let session = finalize_unintegrated(&collector, session, process_info); - assert_eq!(session.status, SessionStatus::Waiting); - assert_eq!(session.current_tasks, vec!["waiting for input".to_string()]); + assert_eq!(session.status, SessionStatus::Unknown); assert_eq!(session.tool_calls.len(), 2); assert_eq!(session.tool_calls[0].name, "exec_command"); assert_eq!(session.tool_calls[0].duration_ms, 6_000); @@ -2197,6 +7548,5071 @@ mod tests { assert_eq!(session.pending_since_ms, 0); } + #[test] + fn exact_exec_end_closes_a_background_process_after_its_running_output() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"exec_command","arguments":"{}","call_id":"call_1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"function_call_output","call_id":"call_1","output":"Process running with session ID 12345"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"exec_command_end","call_id":"call_1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"task_complete","turn_id":"turn-1"}}"#, + ], + ); + + let parsed = parse_codex_jsonl(file.path()).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.is_exact_terminal_lifecycle()); + assert!(parsed.open_tool_ids.is_empty()); + assert_eq!(parsed.tool_calls[0].duration_ms, 2_000); + assert_eq!( + parsed + .completed_tool_calls + .get("call_1") + .map(|call| call.class), + Some(RolloutToolClass::Ordinary) + ); + } + + #[test] + fn test_codex_turn_boundaries_preserve_running_exec_sessions() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:06Z","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}","call_id":"call_1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:07Z","payload":{"type":"function_call_output","call_id":"call_1","output":"Chunk ID: abc\nWall time: 1.0000 seconds\nProcess running with session ID 12345\nOutput:\ncompiling"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:08Z","payload":{"type":"task_complete"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:09Z","payload":{"type":"task_started","model_context_window":200000}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:10Z","payload":{"type":"function_call","name":"write_stdin","arguments":"{\"session_id\":12345,\"chars\":\"\"}","call_id":"call_2"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:12Z","payload":{"type":"function_call_output","call_id":"call_2","output":"Chunk ID: abc\nWall time: 0.0000 seconds\nProcess exited with code 0\nOutput:\nok"}}"#, + ], + ); + + let result = parse_codex_jsonl(file.path()).unwrap(); + assert_eq!(result.pending_since_ms, 0); + assert_eq!(result.tool_calls.len(), 2); + assert_eq!(result.tool_calls[0].duration_ms, 6_000); + assert_eq!(result.tool_calls[1].duration_ms, 2_000); + } + + #[test] + fn rollout_background_exec_alone_stays_unknown() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}","call_id":"call_1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"function_call_output","call_id":"call_1","output":"Process running with session ID 12345"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"task_complete"}}"#, + ], + ); + + let collector = CodexCollector::new(); + let process_info = HashMap::from([(42, proc_info(42, 1, "codex exec"))]); + let mut process_ctx = owned_process(42); + process_ctx.is_exec = true; + let (session, _) = collector + .load_session_with_rate_limit( + process_ctx, + file.path(), + &process_info, + &HashMap::new(), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + let session = finalize_unintegrated(&collector, session, process_info); + + assert_eq!(session.status, SessionStatus::Unknown); + assert_eq!(session.pending_since_ms, 0); + } + + #[test] + fn rollout_task_complete_alone_stays_unknown() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","model_context_window":200000}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"agent_message","message":"Done."}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"task_complete"}}"#, + ], + ); + + let collector = CodexCollector::new(); + let process_info = HashMap::from([(42, proc_info(42, 1, "codex"))]); + let (session, _) = collector + .load_session_with_rate_limit( + owned_process(42), + file.path(), + &process_info, + &HashMap::new(), + &HashMap::new(), + &HashSet::new(), + ) + .unwrap(); + let session = finalize_unintegrated(&collector, session, process_info); + + assert_eq!(session.status, SessionStatus::Unknown); + assert!(!session.awaiting_input); + assert_eq!(session.pending_since_ms, 0); + assert_eq!(session.thinking_since_ms, 0); + } + + #[test] + fn test_codex_parse_cache_invalidates_on_append_and_stays_bounded() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("rollout-first.jsonl"); + write_jsonl(&first, &[SESSION_META]); + let collector = CodexCollector::new(); + + assert!(!collector.parse_rollout_cached(&first).unwrap().turn_active); + assert!(!collector.parse_rollout_cached(&first).unwrap().turn_active); + { + let cache = collector.parse_cache.borrow(); + assert_eq!(cache.entries.len(), 1); + assert_eq!(cache.clock, 2); + } + + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&first) + .unwrap(); + file.write_all( + br#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started"}} +"#, + ) + .unwrap(); + file.flush().unwrap(); + assert!(collector.parse_rollout_cached(&first).unwrap().turn_active); + + for index in 0..MAX_CODEX_PARSE_CACHE_ENTRIES + 4 { + let path = temp.path().join(format!("rollout-{index}.jsonl")); + write_jsonl(&path, &[SESSION_META]); + collector.parse_rollout_cached(&path).unwrap(); + } + assert_eq!( + collector.parse_cache.borrow().entries.len(), + MAX_CODEX_PARSE_CACHE_ENTRIES + ); + } + + #[test] + fn parse_cache_invalidates_same_length_rewrite_with_restored_mtime() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("rollout-rewritten.jsonl"); + write_jsonl(&path, &[SESSION_META]); + let original_modified = fs::metadata(&path).unwrap().modified().unwrap(); + let collector = CodexCollector::new(); + assert_eq!( + collector.parse_rollout_cached(&path).unwrap().session_id, + "sess-123" + ); + + let replacement = SESSION_META.replace("sess-123", "sess-456"); + assert_eq!(replacement.len(), SESSION_META.len()); + write_jsonl(&path, &[&replacement]); + set_modified(&path, original_modified); + assert_eq!( + collector.parse_rollout_cached(&path).unwrap().session_id, + "sess-456" + ); + } + + #[test] + fn parse_cache_invalidates_equal_metadata_atomic_replacement() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("rollout-replaced.jsonl"); + let replacement_path = temp.path().join("replacement.jsonl"); + write_jsonl(&path, &[SESSION_META]); + let original_modified = fs::metadata(&path).unwrap().modified().unwrap(); + let collector = CodexCollector::new(); + assert_eq!( + collector.parse_rollout_cached(&path).unwrap().session_id, + "sess-123" + ); + + let replacement = SESSION_META.replace("sess-123", "sess-789"); + write_jsonl(&replacement_path, &[&replacement]); + set_modified(&replacement_path, original_modified); + fs::rename(&replacement_path, &path).unwrap(); + assert_eq!( + collector.parse_rollout_cached(&path).unwrap().session_id, + "sess-789" + ); + } + + #[test] + fn global_codex_config_is_attested_but_project_config_and_lock_are_not() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let codex_home = home.join(".codex"); + let clean_project = home.join("clean"); + let configured_project = home.join("configured"); + let locked_project = home.join("locked"); + fs::create_dir_all(&codex_home).unwrap(); + fs::create_dir_all(&clean_project).unwrap(); + fs::create_dir_all(configured_project.join(".codex")).unwrap(); + fs::create_dir_all(locked_project.join(".codex")).unwrap(); + write_jsonl(&codex_home.join("config.toml"), &["[features]"]); + write_jsonl(&configured_project.join(".codex/config.toml"), &["[hooks]"]); + write_jsonl( + &locked_project.join(".codex/.config.lock.toml"), + &["version = 1"], + ); + + assert!(!cwd_has_unattested_codex_config( + clean_project.to_str().unwrap(), + &codex_home, + )); + assert!(cwd_has_unattested_codex_config( + configured_project.to_str().unwrap(), + &codex_home, + )); + assert!(cwd_has_unattested_codex_config( + locked_project.to_str().unwrap(), + &codex_home, + )); + } + + #[test] + fn test_codex_custom_tool_call_lifecycle_is_exact_and_private() { + let mut open = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut open, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"do not expose this raw freeform input","status":"completed","call_id":"custom_1"}}"#, + ], + ); + let result = parse_codex_jsonl(open.path()).unwrap(); + assert_eq!(result.pending_since_ms, 1_774_710_061_000); + assert_eq!(result.current_task, "exec"); + assert_eq!(result.tool_calls.len(), 1); + assert!(result.tool_calls[0].arg.is_empty()); + assert_eq!( + result.open_tool_classes, + HashMap::from([( + "custom_1".to_string(), + RolloutToolClass::CodeModeExec { + exec_started_at_ms: 1_774_710_061_000, + }, + )]) + ); + + write_lines( + &mut open, + &[ + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"custom_tool_call_output","call_id":"custom_1","output":[{"type":"input_text","text":"Script completed\nWall time 3.0 seconds\nOutput:\n"}]}}"#, + ], + ); + let result = parse_codex_jsonl(open.path()).unwrap(); + assert!(result.lifecycle_valid); + assert_eq!(result.pending_since_ms, 0); + assert!(result.current_task.is_empty()); + assert!(result.open_tool_classes.is_empty()); + assert_eq!(result.tool_calls[0].duration_ms, 3_000); + assert!(result.turn_active); + } + + #[test] + fn code_mode_wait_class_requires_an_exact_yielded_cell_link() { + let mut linked = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut linked, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private code","call_id":"call_exec"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"custom_tool_call_output","call_id":"call_exec","output":[{"type":"input_text","text":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}]}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\",\"yield_time_ms\":10000}","call_id":"call_wait"}}"#, + ], + ); + let open = parse_codex_jsonl(linked.path()).unwrap(); + assert_eq!(open.open_tool_ids, HashSet::from(["call_wait".to_string()])); + assert!(open + .completed_tool_calls + .get("call_exec") + .is_some_and( + |call| !call.code_mode_terminal && call.completed_at_ms == 1_774_710_062_000 + )); + assert_eq!( + open.open_tool_classes, + HashMap::from([( + "call_wait".to_string(), + RolloutToolClass::CodeModeWait { + exec_started_at_ms: 1_774_710_061_000, + exec_yielded_at_ms: 1_774_710_062_000, + }, + )]) + ); + + write_lines( + &mut linked, + &[ + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"function_call_output","call_id":"call_wait","output":[{"type":"input_text","text":"Script completed\nWall time 3.0 seconds\nOutput:\n"}]}}"#, + ], + ); + let closed = parse_codex_jsonl(linked.path()).unwrap(); + assert!(closed.open_tool_ids.is_empty()); + assert!(closed.open_tool_classes.is_empty()); + assert!(closed + .completed_tool_calls + .get("call_exec") + .is_some_and(|call| call.code_mode_terminal + && call.started_at_ms == 1_774_710_061_000 + && call.completed_at_ms == 1_774_710_064_000)); + assert!(closed + .completed_tool_calls + .get("call_wait") + .is_some_and(|call| !call.code_mode_terminal + && matches!(call.class, RolloutToolClass::CodeModeWait { .. }))); + + let mut unlinked = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut unlinked, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"2\"}","call_id":"call_wait"}}"#, + ], + ); + let unlinked = parse_codex_jsonl(unlinked.path()).unwrap(); + assert_eq!( + unlinked.open_tool_classes, + HashMap::from([( + "call_wait".to_string(), + RolloutToolClass::CodeModeUncorrelatable, + )]) + ); + assert!(unlinked.code_mode_correlation_ambiguous); + } + + #[test] + fn linked_terminal_wait_completes_only_the_exact_origin_exec() { + let temp = tempfile::tempdir().unwrap(); + let linked = temp.path().join("rollout-linked-waits.jsonl"); + write_jsonl( + &linked, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_exec"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"custom_tool_call_output","call_id":"call_exec","output":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\"}","call_id":"call_wait_1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"function_call_output","call_id":"call_wait_1","output":"Script running with cell ID 1\nWall time 3.0 seconds\nOutput:\n"}}"#, + ], + ); + let running = parse_codex_jsonl(&linked).unwrap(); + assert!(running.lifecycle_valid); + assert_eq!(running.live_code_mode_cells, 1); + assert!(running + .completed_tool_calls + .get("call_exec") + .is_some_and( + |call| !call.code_mode_terminal && call.completed_at_ms == 1_774_710_062_000 + )); + + append_jsonl( + &linked, + &[ + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:05Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\"}","call_id":"call_wait_2"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:06Z","payload":{"type":"function_call_output","call_id":"call_wait_2","output":"Script completed\nWall time 5.0 seconds\nOutput:\n"}}"#, + ], + ); + let terminal = parse_codex_jsonl(&linked).unwrap(); + assert!(terminal.lifecycle_valid); + assert_eq!(terminal.live_code_mode_cells, 0); + assert!(terminal + .completed_tool_calls + .get("call_exec") + .is_some_and( + |call| call.code_mode_terminal && call.completed_at_ms == 1_774_710_066_000 + )); + + let terminated = temp.path().join("rollout-terminated-wait.jsonl"); + write_jsonl( + &terminated, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_exec"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"custom_tool_call_output","call_id":"call_exec","output":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\",\"terminate\":true}","call_id":"call_wait"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"function_call_output","call_id":"call_wait","output":"Script terminated\nWall time 3.0 seconds\nOutput:\n"}}"#, + ], + ); + let terminated = parse_codex_jsonl(&terminated).unwrap(); + assert!(terminated.lifecycle_valid); + assert!(terminated + .completed_tool_calls + .get("call_exec") + .is_some_and(|call| !call.code_mode_terminal)); + } + + #[test] + fn linked_terminal_wait_reconciles_the_stale_hook_before_current_code_mode_work() { + let rollout_path = tempfile::NamedTempFile::new().unwrap(); + write_jsonl( + rollout_path.path(), + &[ + r#"{"type":"session_meta","timestamp":"2026-03-28T15:00:00Z","payload":{"id":"sess-123","cwd":"/home/user/project","cli_version":"0.146.0","timestamp":"2026-03-28T15:00:00Z"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_stale"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"custom_tool_call_output","call_id":"call_stale","output":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\"}","call_id":"call_wait"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"function_call_output","call_id":"call_wait","output":"Script completed\nWall time 3.0 seconds\nOutput:\n"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:05Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_current"}}"#, + ], + ); + let parsed = parse_codex_jsonl(rollout_path.path()).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed + .completed_tool_calls + .get("call_stale") + .is_some_and(|call| call.code_mode_terminal)); + + let now_ms = 1_774_710_066_000; + let stale_hook = "exec-01234567-89ab-4def-8abc-0123456789ab".to_string(); + let current_hook = "exec-11111111-2222-4333-8444-555555555555".to_string(); + let mut record = hook_record( + HookCandidate::ToolOpen(HashSet::from([stale_hook.clone(), current_hook.clone()])), + now_ms, + ); + record + .tool_opened_at_ms + .insert(stale_hook, 1_774_710_061_050); + record + .tool_opened_at_ms + .insert(current_hook, 1_774_710_065_050); + + let rollout = RolloutLifecycle { + root_cli_version: parsed.version, + turn_active: parsed.turn_active, + task_complete: parsed.task_complete, + lifecycle_valid: parsed.lifecycle_valid, + active_turn_id: parsed.active_turn_id, + completed_turn_id: parsed.completed_turn_id, + turn_started_at_ms: parsed.turn_started_at_ms, + latest_lifecycle_at_ms: parsed.latest_lifecycle_at_ms, + task_completed_at_ms: parsed.task_completed_at_ms, + open_tool_ids: parsed.open_tool_ids, + open_tool_started_at_ms: parsed.open_tool_started_at_ms, + open_tool_classes: parsed.open_tool_classes, + completed_tool_calls: parsed.completed_tool_calls, + nested_code_mode_end_at_ms: parsed.nested_code_mode_end_at_ms, + live_code_mode_cells: parsed.live_code_mode_cells, + code_mode_correlation_ambiguous: parsed.code_mode_correlation_ambiguous, + descendants: Vec::new(), + relevant_process_descendant: false, + }; + + assert_eq!( + project_hook_status(&record, Some(&rollout), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookInteractionResolutionUnavailable, + ), + "rollout completion remains non-authoritative without exact Herdr working evidence" + ); + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + Some(HerdrWorkingProjection { + status: SessionStatus::Executing, + status_since_ms: 1_774_710_065_050, + consecutive_matching: 0, + }), + "the linked terminal wait must close only the stale nested hook and retain current work" + ); + + let collector = CodexCollector::new(); + let binding_key = hook_done_key(&record).unwrap(); + collector + .rollout_lifecycle + .borrow_mut() + .insert(record.session_id.clone(), rollout); + let mut pidless = collector.hook_placeholder(&record); + pidless.version = "0.146.0".to_string(); + let sessions = collector.finalize_hook_records_with_herdr( + vec![pidless], + vec![record.clone()], + &hook_shared(), + now_ms, + HashMap::from([( + (record.session_id.clone(), record.pid), + HerdrObservation { + status: HerdrStatus::Working, + observed_at_ms: now_ms, + status_since_ms: now_ms - 1_000, + consecutive_matching: 2, + }, + )]), + ); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].status, SessionStatus::Executing); + assert_eq!( + sessions[0].status_evidence.authority, + StatusAuthority::Heuristic + ); + assert_eq!( + sessions[0].status_evidence.reason, + StatusReason::HerdrScreenWorking + ); + assert!(sessions[0].action_process_incarnation.is_none()); + assert!(!collector + .hook_process_rollout_bindings + .borrow() + .contains_key(&binding_key)); + assert!(!collector + .hook_done_tombstones + .borrow() + .contains_key(&binding_key)); + } + + #[test] + fn code_mode_cell_ids_and_status_headers_match_the_audited_provider_shape() { + assert_eq!(bounded_code_mode_cell_id("1").as_deref(), Some("1")); + assert_eq!(bounded_code_mode_cell_id("g2:1").as_deref(), Some("g2:1")); + for invalid in ["", "0", "01", "g1:1", "g2:0", "cell1", "g2:01"] { + assert_eq!(bounded_code_mode_cell_id(invalid), None, "{invalid}"); + } + assert_eq!( + code_mode_output_state(&serde_json::json!([{ + "type": "input_text", + "text": "Script running with cell ID g2:1\nWall time 1.0 seconds\nOutput:\n", + }])), + Some(CodeModeOutputState::Running("g2:1".to_string())) + ); + assert_eq!( + code_mode_output_state(&serde_json::json!( + "Script running with cell ID 1\nWall time 0.0 seconds\nOutput:\n" + )), + Some(CodeModeOutputState::Running("1".to_string())) + ); + assert_eq!( + code_mode_output_state(&serde_json::json!( + "Script completed\nWall time 0.0 seconds\nOutput:\n" + )), + Some(CodeModeOutputState::Terminal) + ); + for terminal in ["Script completed", "Script failed", "Script terminated"] { + assert_eq!( + code_mode_output_state(&serde_json::json!([{ + "type": "input_text", + "text": format!("{terminal}\nWall time 1.0 seconds\nOutput:\n"), + }, { + "type": "input_text", + "text": "subsequent provider output is not part of the status frame", + }])), + Some(CodeModeOutputState::Terminal) + ); + } + for invalid in [ + serde_json::json!([{ + "type": "input_text", + "text": "Script completed", + }]), + serde_json::json!([{ + "type": "input_text", + "text": "Script completed\nWall time 1.00 seconds\nOutput:\n", + }]), + serde_json::json!([{ + "type": "input_text", + "text": "Script completed\nWall time 01.0 seconds\nOutput:\n", + }]), + serde_json::json!([{ + "type": "input_text", + "text": "Script completed\nWall time 1.0 seconds\nOutput:\nforged", + }]), + serde_json::json!([{ + "type": "input_text", + "text": "Script paused\nWall time 1.0 seconds\nOutput:\n", + }]), + serde_json::json!([{ + "type": "input_text", + "text": "Script completed\nWall time 1.0 seconds\nOutput:\n", + "extra": true, + }]), + ] { + assert_eq!(code_mode_output_state(&invalid), None, "{invalid}"); + } + } + + #[test] + fn code_mode_wait_arguments_match_the_audited_provider_deserializer() { + for (raw, cell_id, terminate) in [ + (r#"{"cell_id":"1"}"#, "1", false), + ( + r#"{"cell_id":"g2:1","yield_time_ms":0,"max_tokens":0,"terminate":false}"#, + "g2:1", + false, + ), + ( + r#"{"cell_id":"1","max_tokens":null,"extra":{"ignored":true}}"#, + "1", + false, + ), + (r#"{"cell_id":"1","terminate":true}"#, "1", true), + ] { + assert_eq!( + parse_code_mode_wait_args(raw), + Some(ParsedCodeModeWaitArgs { + cell_id: cell_id.to_string(), + terminate, + }), + "{raw}" + ); + } + + for raw in [ + r#"{}"#, + r#"{"cell_id":"1","cell_id":"2"}"#, + r#"{"cell_id":1}"#, + r#"{"cell_id":"01"}"#, + r#"{"cell_id":"1","yield_time_ms":"10"}"#, + r#"{"cell_id":"1","yield_time_ms":null}"#, + r#"{"cell_id":"1","max_tokens":-1}"#, + r#"{"cell_id":"1","terminate":0}"#, + r#"{"cell_id":"1","terminate":null}"#, + ] { + assert_eq!(parse_code_mode_wait_args(raw), None, "{raw}"); + } + } + + #[test] + fn terminating_code_mode_wait_never_projects_as_a_resumptive_wait() { + let mut terminal = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut terminal, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_exec"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"custom_tool_call_output","call_id":"call_exec","output":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\",\"terminate\":true}","call_id":"call_wait"}}"#, + ], + ); + let open = parse_codex_jsonl(terminal.path()).unwrap(); + assert!(open.lifecycle_valid); + assert_eq!(open.live_code_mode_cells, 1); + assert_eq!( + open.open_tool_classes.get("call_wait"), + Some(&RolloutToolClass::CodeModeUncorrelatable) + ); + + write_lines( + &mut terminal, + &[ + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"function_call_output","call_id":"call_wait","output":[{"type":"input_text","text":"Script terminated\nWall time 1.0 seconds\nOutput:\n"}]}}"#, + ], + ); + let closed = parse_codex_jsonl(terminal.path()).unwrap(); + assert!(closed.lifecycle_valid); + assert_eq!(closed.live_code_mode_cells, 0); + assert!(closed.open_tool_classes.is_empty()); + + let mut impossible_running = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut impossible_running, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_exec"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"custom_tool_call_output","call_id":"call_exec","output":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\",\"terminate\":true}","call_id":"call_wait"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"function_call_output","call_id":"call_wait","output":[{"type":"input_text","text":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}]}}"#, + ], + ); + let rejected = parse_codex_jsonl(impossible_running.path()).unwrap(); + assert!(!rejected.lifecycle_valid); + assert!(!rejected.is_exact_terminal_lifecycle()); + } + + #[test] + fn rollout_open_call_metadata_is_bounded_and_closed_calls_do_not_accumulate() { + let mut overflow = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut overflow, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + ], + ); + for index in 0..=MAX_OPEN_ROLLOUT_CALLS { + writeln!( + overflow, + "{}", + serde_json::json!({ + "type": "response_item", + "timestamp": "2026-03-28T15:01:01Z", + "payload": { + "type": "function_call", + "name": "read_file", + "arguments": "{}", + "call_id": format!("call_{index}"), + } + }) + ) + .unwrap(); + } + overflow.flush().unwrap(); + let parsed = parse_codex_jsonl(overflow.path()).unwrap(); + assert!(!parsed.lifecycle_valid); + assert_eq!(parsed.open_tool_ids.len(), MAX_OPEN_ROLLOUT_CALLS); + assert_eq!(parsed.open_tool_started_at_ms.len(), MAX_OPEN_ROLLOUT_CALLS); + assert_eq!(parsed.open_tool_classes.len(), MAX_OPEN_ROLLOUT_CALLS); + + let mut sequential = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut sequential, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + ], + ); + for index in 0..(MAX_OPEN_ROLLOUT_CALLS + 64) { + let call_id = format!("call_{index}"); + writeln!( + sequential, + "{}", + serde_json::json!({ + "type": "response_item", + "timestamp": "2026-03-28T15:01:01Z", + "payload": { + "type": "function_call", + "name": "read_file", + "arguments": "{}", + "call_id": call_id, + } + }) + ) + .unwrap(); + writeln!( + sequential, + "{}", + serde_json::json!({ + "type": "response_item", + "timestamp": "2026-03-28T15:01:01Z", + "payload": { + "type": "function_call_output", + "call_id": call_id, + "output": "ok", + } + }) + ) + .unwrap(); + } + sequential.flush().unwrap(); + let parsed = parse_codex_jsonl(sequential.path()).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.open_tool_ids.is_empty()); + assert!(parsed.open_tool_started_at_ms.is_empty()); + assert!(parsed.open_tool_classes.is_empty()); + + write_lines( + &mut sequential, + &[ + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"read_file","arguments":"{}","call_id":"call_0"}}"#, + ], + ); + let duplicate = parse_codex_jsonl(sequential.path()).unwrap(); + assert!(!duplicate.lifecycle_valid); + assert!(duplicate.open_tool_ids.is_empty()); + } + + #[test] + fn rollout_call_history_resets_at_each_clean_turn_boundary() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines(&mut file, &[SESSION_META]); + for index in 0..=MAX_TRACKED_ROLLOUT_CALL_IDS { + let turn_id = format!("turn-{index}"); + let call_id = format!("call_{index}"); + for value in [ + serde_json::json!({ + "type": "event_msg", + "timestamp": "2026-03-28T15:01:01Z", + "payload": {"type": "task_started", "turn_id": turn_id}, + }), + serde_json::json!({ + "type": "response_item", + "timestamp": "2026-03-28T15:01:01Z", + "payload": { + "type": "function_call", + "name": "read_file", + "arguments": "{}", + "call_id": call_id, + }, + }), + serde_json::json!({ + "type": "response_item", + "timestamp": "2026-03-28T15:01:01Z", + "payload": { + "type": "function_call_output", + "call_id": call_id, + "output": "ok", + }, + }), + serde_json::json!({ + "type": "event_msg", + "timestamp": "2026-03-28T15:01:01Z", + "payload": {"type": "task_complete", "turn_id": turn_id}, + }), + ] { + writeln!(file, "{value}").unwrap(); + } + } + file.flush().unwrap(); + + let parsed = parse_codex_jsonl(file.path()).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.task_complete); + assert_eq!( + parsed.completed_turn_id.as_deref(), + Some(format!("turn-{MAX_TRACKED_ROLLOUT_CALL_IDS}").as_str()) + ); + assert!(parsed.open_tool_ids.is_empty()); + } + + #[test] + fn rollout_lifecycle_ids_and_tool_names_are_bounded_before_retention() { + let mut oversized_turn = tempfile::NamedTempFile::new().unwrap(); + write_lines(&mut oversized_turn, &[SESSION_META]); + writeln!( + oversized_turn, + "{}", + serde_json::json!({ + "type": "event_msg", + "timestamp": "2026-03-28T15:01:00Z", + "payload": { + "type": "task_started", + "turn_id": "x".repeat(MAX_ROLLOUT_LIFECYCLE_ID_BYTES + 1), + }, + }) + ) + .unwrap(); + for index in 1..=MAX_TRACKED_CODE_MODE_CELLS { + let call_id = format!("call_exec_{index}"); + writeln!( + oversized_turn, + "{}", + serde_json::json!({ + "type": "response_item", + "timestamp": "2026-03-28T15:01:01Z", + "payload": { + "type": "custom_tool_call", + "name": "exec", + "input": "private", + "call_id": call_id, + }, + }) + ) + .unwrap(); + writeln!( + oversized_turn, + "{}", + serde_json::json!({ + "type": "response_item", + "timestamp": "2026-03-28T15:01:01Z", + "payload": { + "type": "custom_tool_call_output", + "call_id": call_id, + "output": [{ + "type": "input_text", + "text": format!( + "Script running with cell ID {index}\nWall time 1.0 seconds\nOutput:\n" + ), + }], + }, + }) + ) + .unwrap(); + } + oversized_turn.flush().unwrap(); + let parsed = parse_codex_jsonl(oversized_turn.path()).unwrap(); + assert!(!parsed.lifecycle_valid); + assert!(parsed.active_turn_id.is_none()); + assert_eq!(parsed.live_code_mode_cells, 0); + + let mut unsafe_names = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut unsafe_names, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + ], + ); + for (index, name) in [ + "read\u{1b}[31m".to_string(), + "x".repeat(MAX_ROLLOUT_TOOL_NAME_BYTES + 1), + ] + .into_iter() + .enumerate() + { + writeln!( + unsafe_names, + "{}", + serde_json::json!({ + "type": "response_item", + "timestamp": "2026-03-28T15:01:01Z", + "payload": { + "type": "function_call", + "name": name, + "arguments": "{}", + "call_id": format!("call_{index}"), + }, + }) + ) + .unwrap(); + } + unsafe_names.flush().unwrap(); + let parsed = parse_codex_jsonl(unsafe_names.path()).unwrap(); + assert!(!parsed.lifecycle_valid); + assert!(parsed.current_task.is_empty()); + assert!(parsed.tool_calls.is_empty()); + assert!(parsed.open_tool_ids.is_empty()); + } + + #[test] + fn multiple_or_cross_turn_code_mode_cells_remain_valid_but_uncorrelatable() { + let mut multiple = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut multiple, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_exec_1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"custom_tool_call_output","call_id":"call_exec_1","output":[{"type":"input_text","text":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}]}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_exec_2"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"custom_tool_call_output","call_id":"call_exec_2","output":[{"type":"input_text","text":"Script running with cell ID 2\nWall time 1.0 seconds\nOutput:\n"}]}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:05Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\"}","call_id":"call_wait"}}"#, + ], + ); + let parsed = parse_codex_jsonl(multiple.path()).unwrap(); + assert!(parsed.lifecycle_valid); + assert_eq!(parsed.live_code_mode_cells, 2); + assert!(parsed.code_mode_correlation_ambiguous); + assert_eq!( + parsed.open_tool_classes.get("call_wait"), + Some(&RolloutToolClass::CodeModeUncorrelatable) + ); + + let mut cross_turn = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut cross_turn, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_exec"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"custom_tool_call_output","call_id":"call_exec","output":[{"type":"input_text","text":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}]}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"task_complete","turn_id":"turn-1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"task_started","turn_id":"turn-2"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:05Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\"}","call_id":"call_wait"}}"#, + ], + ); + let parsed = parse_codex_jsonl(cross_turn.path()).unwrap(); + assert!(parsed.lifecycle_valid); + assert_eq!(parsed.live_code_mode_cells, 1); + assert!(parsed.code_mode_correlation_ambiguous); + assert_eq!( + parsed.open_tool_classes.get("call_wait"), + Some(&RolloutToolClass::CodeModeUncorrelatable) + ); + } + + #[test] + fn concurrent_code_mode_waits_and_unknown_outputs_fail_closed() { + let mut concurrent = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut concurrent, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_exec"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"custom_tool_call_output","call_id":"call_exec","output":[{"type":"input_text","text":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}]}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\"}","call_id":"call_wait_1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\"}","call_id":"call_wait_2"}}"#, + ], + ); + let parsed = parse_codex_jsonl(concurrent.path()).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.code_mode_correlation_ambiguous); + assert!(parsed + .open_tool_classes + .values() + .all(|class| { *class == RolloutToolClass::CodeModeUncorrelatable })); + + write_lines( + &mut concurrent, + &[ + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:05Z","payload":{"type":"function_call_output","call_id":"call_wait_2","output":[{"type":"input_text","text":"Script completed\nWall time 1.0 seconds\nOutput:\n"}]}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:06Z","payload":{"type":"function_call_output","call_id":"call_wait_1","output":[{"type":"input_text","text":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}]}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:07Z","payload":{"type":"task_complete","turn_id":"turn-1"}}"#, + ], + ); + let raced = parse_codex_jsonl(concurrent.path()).unwrap(); + assert!(!raced.lifecycle_valid); + assert!(!raced.is_exact_terminal_lifecycle()); + + let mut resolved_ambiguity = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut resolved_ambiguity, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_exec_1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"custom_tool_call_output","call_id":"call_exec_1","output":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_exec_2"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"custom_tool_call_output","call_id":"call_exec_2","output":"Script running with cell ID 2\nWall time 1.0 seconds\nOutput:\n"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:05Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\"}","call_id":"call_wait_1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:06Z","payload":{"type":"function_call_output","call_id":"call_wait_1","output":[{"type":"input_text","text":"Script completed\nWall time 1.0 seconds\nOutput:\n"}]}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:07Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"2\"}","call_id":"call_wait_2"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:08Z","payload":{"type":"function_call_output","call_id":"call_wait_2","output":[{"type":"input_text","text":"Script completed\nWall time 1.0 seconds\nOutput:\n"}]}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:09Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_exec_3"}}"#, + ], + ); + let recovered = parse_codex_jsonl(resolved_ambiguity.path()).unwrap(); + assert!(recovered.lifecycle_valid); + assert_eq!(recovered.live_code_mode_cells, 0); + assert!(!recovered.code_mode_correlation_ambiguous); + assert!(matches!( + recovered.open_tool_classes.get("call_exec_3"), + Some(RolloutToolClass::CodeModeExec { .. }) + )); + + let mut unknown_output = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut unknown_output, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_exec"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"custom_tool_call_output","call_id":"call_exec","output":[{"type":"input_text","text":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}]}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\"}","call_id":"call_wait"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"function_call_output","call_id":"call_wait","output":"Script paused\nOutput:\n"}}"#, + ], + ); + let parsed = parse_codex_jsonl(unknown_output.path()).unwrap(); + assert!(!parsed.lifecycle_valid); + assert_eq!(parsed.live_code_mode_cells, 0); + assert!(parsed.open_tool_ids.is_empty()); + } + + #[test] + fn code_mode_cell_provenance_is_bounded_and_blocks_terminal_promotion() { + let mut overflow = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut overflow, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + ], + ); + for index in 1..=(MAX_TRACKED_CODE_MODE_CELLS + 1) { + let call_id = format!("call_exec_{index}"); + writeln!( + overflow, + "{}", + serde_json::json!({ + "type": "response_item", + "timestamp": "2026-03-28T15:01:01Z", + "payload": { + "type": "custom_tool_call", + "name": "exec", + "input": "private", + "call_id": call_id, + } + }) + ) + .unwrap(); + writeln!( + overflow, + "{}", + serde_json::json!({ + "type": "response_item", + "timestamp": "2026-03-28T15:01:01Z", + "payload": { + "type": "custom_tool_call_output", + "call_id": call_id, + "output": [{ + "type": "input_text", + "text": format!( + "Script running with cell ID {index}\nWall time 1.0 seconds\nOutput:\n" + ), + }], + } + }) + ) + .unwrap(); + } + overflow.flush().unwrap(); + let parsed = parse_codex_jsonl(overflow.path()).unwrap(); + assert!(!parsed.lifecycle_valid); + assert_eq!(parsed.live_code_mode_cells, MAX_TRACKED_CODE_MODE_CELLS); + + let mut live_at_completion = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut live_at_completion, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_exec"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"custom_tool_call_output","call_id":"call_exec","output":[{"type":"input_text","text":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}]}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"task_complete","turn_id":"turn-1"}}"#, + ], + ); + let parsed = parse_codex_jsonl(live_at_completion.path()).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.task_complete); + assert_eq!(parsed.live_code_mode_cells, 1); + assert!(!parsed.is_exact_terminal_lifecycle()); + } + + #[test] + fn rollout_group_aggregates_subagent_metadata_but_not_status() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("rollout-root.jsonl"); + let child = temp.path().join("rollout-child.jsonl"); + write_jsonl( + &root, + &[ + r#"{"type":"session_meta","timestamp":"2026-03-28T15:00:00Z","payload":{"id":"root","cwd":"/home/user/project","timestamp":"2026-03-28T15:00:00Z"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:00:02Z","payload":{"type":"task_complete"}}"#, + ], + ); + write_jsonl( + &child, + &[ + r#"{"type":"session_meta","timestamp":"2026-03-28T15:01:00Z","payload":{"id":"child","parent_thread_id":"root","agent_nickname":"reviewer","cwd":"/home/user/project","timestamp":"2026-03-28T15:01:00Z"}}"#, + r#"{"type":"session_meta","timestamp":"2026-03-28T15:00:00Z","payload":{"id":"root","cwd":"/home/user/project","timestamp":"2026-03-28T15:00:00Z"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"task_started"}}"#, + ], + ); + + let collector = CodexCollector::new(); + let process_info = HashMap::from([(42, proc_info(42, 1, "codex"))]); + let loaded = collector.load_cli_session_group( + owned_process(42), + &[child.clone(), root.clone()], + &process_info, + &HashMap::new(), + &HashMap::new(), + &HashSet::new(), + ); + let session = loaded.session.unwrap(); + let session = finalize_unintegrated(&collector, session, process_info); + + assert_eq!(session.session_id, "root"); + assert_eq!(session.status, SessionStatus::Unknown); + assert_eq!(session.subagents.len(), 1); + assert_eq!(session.subagents[0].name, "reviewer"); + assert_eq!(session.subagents[0].status, "working"); + assert_eq!( + HashSet::from_iter(loaded.owned_paths), + HashSet::from([root, child]) + ); + } + + #[test] + fn rollout_group_rejects_duplicate_cycle_and_conflicting_parent_graphs() { + let temp = tempfile::tempdir().unwrap(); + let make = |name: &str, id: &str, parent: Option<&str>| { + let path = temp.path().join(name); + let parent_field = parent + .map(|parent| format!(",\"parent_thread_id\":\"{parent}\"")) + .unwrap_or_default(); + let line = format!( + r#"{{"type":"session_meta","timestamp":"2026-03-28T15:00:00Z","payload":{{"id":"{id}"{parent_field},"cwd":"/tmp","timestamp":"2026-03-28T15:00:00Z"}}}}"# + ); + write_jsonl(&path, &[&line]); + (path.clone(), parse_codex_jsonl(&path).unwrap()) + }; + + let duplicate = select_codex_rollout_group(vec![ + make("duplicate-a.jsonl", "same", None), + make("duplicate-b.jsonl", "same", None), + ]) + .unwrap(); + assert!(!duplicate.lifecycle_valid); + + let cycle = select_codex_rollout_group(vec![ + make("cycle-a.jsonl", "a", Some("b")), + make("cycle-b.jsonl", "b", Some("a")), + ]) + .unwrap(); + assert!(!cycle.lifecycle_valid); + + let conflicting = select_codex_rollout_group(vec![ + make("parent-a.jsonl", "parent-a", None), + make("parent-b.jsonl", "parent-b", None), + make("child-a.jsonl", "child", Some("parent-a")), + make("child-b.jsonl", "child", Some("parent-b")), + ]) + .unwrap(); + assert!(!conflicting.lifecycle_valid); + } + + #[test] + fn unparseable_open_descriptor_invalidates_the_selected_rollout_group() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("rollout-root.jsonl"); + let unparseable = temp.path().join("rollout-unparseable.jsonl"); + write_jsonl( + &root, + &[ + r#"{"type":"session_meta","timestamp":"2026-03-28T15:00:00Z","payload":{"id":"root","cwd":"/home/user/project","cli_version":"0.146.0","timestamp":"2026-03-28T15:00:00Z"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:00:01Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + ], + ); + write_jsonl(&unparseable, &["not-json"]); + + let collector = CodexCollector::new(); + let process_info = HashMap::from([(42, proc_info(42, 1, "codex"))]); + let loaded = collector.load_cli_session_group( + owned_process(42), + &[root.clone(), unparseable.clone()], + &process_info, + &HashMap::new(), + &HashMap::new(), + &HashSet::new(), + ); + let session = loaded.session.unwrap(); + assert_eq!( + HashSet::from_iter(loaded.owned_paths), + HashSet::from([root.clone(), unparseable.clone()]), + "every descriptor owned by the live PID must remain suppressed from fallback scans" + ); + let lifecycle = collector + .rollout_lifecycle + .borrow() + .get("root") + .cloned() + .unwrap(); + assert!(!lifecycle.lifecycle_valid); + let now_ms = unix_now_ms(); + let mut record = hook_record(HookCandidate::TurnOpen, now_ms); + record.session_id = "root".to_string(); + let public = collector.finalize_hook_records( + vec![session], + vec![record.clone()], + &hook_shared(), + now_ms, + ); + assert_eq!(public[0].status, SessionStatus::Unknown); + assert_eq!( + public[0].status_evidence.reason, + StatusReason::HookIntegrationUnverified + ); + assert_eq!( + project_hook_status(&record, Some(&lifecycle), now_ms,), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookEventGap, + ) + ); + + let all_unparseable = collector.load_cli_session_group( + owned_process(42), + std::slice::from_ref(&unparseable), + &process_info, + &HashMap::new(), + &HashMap::new(), + &HashSet::new(), + ); + assert!(all_unparseable.session.is_none()); + assert_eq!(all_unparseable.owned_paths, vec![unparseable]); + } + + #[test] + fn multiple_rollout_roots_require_every_nonselected_tree_to_be_exact_terminal() { + let temp = tempfile::tempdir().unwrap(); + let selected = temp.path().join("rollout-selected.jsonl"); + let terminal = temp.path().join("rollout-terminal.jsonl"); + let active = temp.path().join("rollout-active.jsonl"); + write_jsonl( + &selected, + &[ + r#"{"type":"session_meta","timestamp":"2026-03-28T15:03:00Z","payload":{"id":"selected","cwd":"/tmp","cli_version":"0.146.0","timestamp":"2026-03-28T15:03:00Z"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:03:01Z","payload":{"type":"task_started","turn_id":"selected-turn"}}"#, + ], + ); + write_jsonl( + &terminal, + &[ + r#"{"type":"session_meta","timestamp":"2026-03-28T15:00:00Z","payload":{"id":"terminal","cwd":"/tmp","cli_version":"0.146.0","timestamp":"2026-03-28T15:00:00Z"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:00:01Z","payload":{"type":"task_started","turn_id":"terminal-turn"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:00:02Z","payload":{"type":"task_complete","turn_id":"terminal-turn"}}"#, + ], + ); + write_jsonl( + &active, + &[ + r#"{"type":"session_meta","timestamp":"2026-03-28T15:01:00Z","payload":{"id":"active","cwd":"/tmp","cli_version":"0.146.0","timestamp":"2026-03-28T15:01:00Z"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"task_started","turn_id":"active-turn"}}"#, + ], + ); + + let parse = |path: &Path| (path.to_path_buf(), parse_codex_jsonl(path).unwrap()); + let safe = select_codex_rollout_group(vec![parse(&selected), parse(&terminal)]).unwrap(); + assert_eq!(safe.root.session_id, "selected"); + assert!(safe.lifecycle_valid); + + let ambiguous = select_codex_rollout_group(vec![parse(&selected), parse(&active)]).unwrap(); + assert_eq!(ambiguous.root.session_id, "selected"); + assert!(!ambiguous.lifecycle_valid); + + let collector = CodexCollector::new(); + let process_info = HashMap::from([(42, proc_info(42, 1, "codex"))]); + let loaded = collector.load_cli_session_group( + owned_process(42), + &[selected, active], + &process_info, + &HashMap::new(), + &HashMap::new(), + &HashSet::new(), + ); + let session = loaded.session.unwrap(); + let now_ms = unix_now_ms(); + let mut record = hook_record(HookCandidate::TurnOpen, now_ms); + record.session_id = "selected".to_string(); + record.cwd = "/tmp".to_string(); + record.turn_id = Some("selected-turn".to_string()); + let public = + collector.finalize_hook_records(vec![session], vec![record], &hook_shared(), now_ms); + assert_eq!(public[0].status, SessionStatus::Unknown); + assert_eq!( + public[0].status_evidence.reason, + StatusReason::HookIntegrationUnverified + ); + } + + #[test] + fn child_rollout_user_input_never_promotes_live_status() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("rollout-root.jsonl"); + let child = temp.path().join("rollout-child.jsonl"); + write_jsonl( + &root, + &[ + r#"{"type":"session_meta","timestamp":"2026-03-28T15:00:00Z","payload":{"id":"root","cwd":"/home/user/project","timestamp":"2026-03-28T15:00:00Z"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:00:01Z","payload":{"type":"task_started"}}"#, + ], + ); + write_jsonl( + &child, + &[ + r#"{"type":"session_meta","timestamp":"2026-03-28T15:01:00Z","payload":{"id":"child","parent_thread_id":"root","agent_nickname":"reviewer","cwd":"/home/user/project","timestamp":"2026-03-28T15:01:00Z"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"request_user_input","input":"{\"questions\":[]}","call_id":"question_1"}}"#, + ], + ); + + let collector = CodexCollector::new(); + let process_info = HashMap::from([(42, proc_info(42, 1, "codex"))]); + let loaded = collector.load_cli_session_group( + owned_process(42), + &[root, child], + &process_info, + &HashMap::new(), + &HashMap::new(), + &HashSet::new(), + ); + let session = loaded.session.unwrap(); + let session = finalize_unintegrated(&collector, session, process_info); + + assert_eq!(session.session_id, "root"); + assert_eq!(session.status, SessionStatus::Unknown); + assert!(!session.awaiting_input); + } + + #[test] + fn root_tool_open_is_unknown_without_effective_permission_attestation() { + let now_ms = 100_000; + let tool = hook_record( + HookCandidate::ToolOpen(HashSet::from(["call-1".to_string()])), + now_ms, + ); + let mismatch = RolloutLifecycle { + root_cli_version: plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(), + turn_active: true, + active_turn_id: Some("turn-1".to_string()), + open_tool_ids: HashSet::from(["call-2".to_string()]), + ..Default::default() + }; + assert_eq!( + project_hook_status(&tool, Some(&mismatch), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookInteractionResolutionUnavailable, + ) + ); + + let mut matched = active_root_rollout(now_ms); + matched.open_tool_ids = HashSet::from(["call-1".to_string()]); + matched.open_tool_started_at_ms = + HashMap::from([("call-1".to_string(), now_ms.saturating_sub(1_500))]); + assert_eq!( + project_hook_status(&tool, Some(&matched), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookInteractionResolutionUnavailable, + ) + ); + + let thinking = hook_record(HookCandidate::TurnOpen, now_ms); + assert_eq!( + project_hook_status(&thinking, Some(&matched), now_ms).0, + SessionStatus::Unknown, + "an unresolved rollout tool prevents Thinking" + ); + assert_eq!( + project_hook_status(&thinking, Some(&active_root_rollout(now_ms)), now_ms,), + ( + SessionStatus::Thinking, + StatusAuthority::Heuristic, + StatusReason::HookTurnOpen, + ) + ); + } + + #[test] + fn hook_interaction_ambiguity_is_unknown_even_with_background_work() { + let now_ms = 100_000; + let mut record = hook_record( + HookCandidate::ToolOpen(HashSet::from(["call-1".to_string()])), + now_ms, + ); + record.interaction_ambiguous = true; + let rollout = RolloutLifecycle { + root_cli_version: plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(), + turn_active: true, + open_tool_ids: HashSet::from(["call-1".to_string()]), + ..Default::default() + }; + assert_eq!( + project_hook_status(&record, Some(&rollout), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookInteractionResolutionUnavailable, + ) + ); + } + + #[test] + fn hook_projection_precedence_table_is_fail_closed() { + let now_ms = 100_000; + let complete = completed_root_rollout(now_ms); + let cases = [ + ( + HookCandidate::Unknown(StatusReason::HookConfigChanged), + Some(complete.clone()), + SessionStatus::Unknown, + StatusAuthority::Unavailable, + ), + ( + HookCandidate::SubagentOpen { + active: HashSet::from(["child-1".to_string()]), + provisional: HashSet::new(), + root: HookRootCandidate::TurnOpen, + }, + None, + SessionStatus::Unknown, + StatusAuthority::Unavailable, + ), + ( + HookCandidate::TurnOpen, + None, + SessionStatus::Unknown, + StatusAuthority::Unavailable, + ), + ( + HookCandidate::ToolOpen(HashSet::from(["missing".to_string()])), + Some(complete.clone()), + SessionStatus::Unknown, + StatusAuthority::Unavailable, + ), + ( + HookCandidate::TurnStopped, + Some(complete), + SessionStatus::Idle, + StatusAuthority::Heuristic, + ), + ]; + + for (candidate, rollout, expected_status, expected_authority) in cases { + let record = hook_record(candidate, now_ms); + let (status, authority, _) = project_hook_status(&record, rollout.as_ref(), now_ms); + assert_eq!(status, expected_status); + assert_eq!(authority, expected_authority); + assert!(!matches!( + status, + SessionStatus::Waiting | SessionStatus::Error | SessionStatus::RateLimited + )); + } + } + + #[test] + fn hook_session_start_is_unknown_and_stop_idle_requires_rollout_completion() { + let now_ms = 100_000; + assert_eq!( + project_hook_status( + &hook_record(HookCandidate::Unknown(StatusReason::HookEventGap), now_ms), + None, + now_ms, + ), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookEventGap, + ) + ); + + let stopped = hook_record(HookCandidate::TurnStopped, now_ms); + assert_eq!( + project_hook_status(&stopped, None, now_ms).0, + SessionStatus::Unknown + ); + let complete = completed_root_rollout(now_ms); + assert_eq!( + project_hook_status(&stopped, Some(&complete), now_ms).0, + SessionStatus::Idle + ); + } + + #[test] + fn aborted_or_stale_rollout_boundary_cannot_promote_stop_to_idle() { + let now_ms = 100_000; + let stopped = hook_record(HookCandidate::TurnStopped, now_ms); + let aborted = RolloutLifecycle { + lifecycle_valid: false, + completed_turn_id: Some("turn-1".to_string()), + task_completed_at_ms: 99_999, + ..Default::default() + }; + assert_eq!( + project_hook_status(&stopped, Some(&aborted), now_ms).0, + SessionStatus::Unknown + ); + + let stale = RolloutLifecycle { + task_complete: true, + completed_turn_id: Some("older-turn".to_string()), + task_completed_at_ms: 99_999, + ..Default::default() + }; + assert_eq!( + project_hook_status(&stopped, Some(&stale), now_ms).0, + SessionStatus::Unknown + ); + } + + #[test] + fn idle_completion_must_follow_the_exact_stop_and_current_turn() { + let now_ms = 100_000; + let stopped = hook_record(HookCandidate::TurnStopped, now_ms); + let mut before_stop = completed_root_rollout(now_ms); + before_stop.latest_lifecycle_at_ms = now_ms.saturating_sub(1_500); + before_stop.task_completed_at_ms = now_ms.saturating_sub(1_500); + assert_eq!( + project_hook_status(&stopped, Some(&before_stop), now_ms).0, + SessionStatus::Unknown + ); + + let mut before_turn = completed_root_rollout(now_ms); + before_turn.turn_started_at_ms = before_turn.task_completed_at_ms.saturating_add(1); + assert_eq!( + project_hook_status(&stopped, Some(&before_turn), now_ms).0, + SessionStatus::Unknown + ); + + let mut future = completed_root_rollout(now_ms); + future.latest_lifecycle_at_ms = now_ms.saturating_add(1); + future.task_completed_at_ms = now_ms.saturating_add(1); + assert_eq!( + project_hook_status(&stopped, Some(&future), now_ms).0, + SessionStatus::Unknown + ); + } + + #[test] + fn live_background_process_blocks_thinking_and_idle() { + let now_ms = 100_000; + let mut active = active_root_rollout(now_ms); + active.relevant_process_descendant = true; + assert_eq!( + project_hook_status( + &hook_record(HookCandidate::TurnOpen, now_ms), + Some(&active), + now_ms, + ) + .0, + SessionStatus::Unknown + ); + + let mut complete = completed_root_rollout(now_ms); + complete.relevant_process_descendant = true; + assert_eq!( + project_hook_status( + &hook_record(HookCandidate::TurnStopped, now_ms), + Some(&complete), + now_ms, + ) + .0, + SessionStatus::Unknown + ); + } + + #[test] + fn process_descendant_filter_traverses_code_host_and_skips_proven_mcp_subtree() { + let process_info = HashMap::from([ + (2, proc_info(2, 1, "codex-code-mode-host")), + (3, proc_info(3, 2, "sleep 300")), + ]); + let with_background = HashMap::from([(1, vec![2]), (2, vec![3])]); + assert!(has_relevant_codex_process_descendant( + 1, + &process_info, + &with_background, + &HashSet::new(), + )); + assert!(!has_relevant_codex_process_descendant( + 1, + &process_info, + &HashMap::from([(1, vec![2])]), + &HashSet::new(), + )); + assert!(!has_relevant_codex_process_descendant( + 1, + &process_info, + &with_background, + &HashSet::from([2]), + )); + } + + #[test] + fn malformed_and_uncovered_rollout_tools_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let malformed = temp.path().join("rollout-malformed.jsonl"); + write_jsonl( + &malformed, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + "not-json", + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"exec_command","arguments":"{}","call_id":"call-1"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&malformed).unwrap(); + assert!(!parsed.lifecycle_valid); + + let hosted = temp.path().join("rollout-hosted.jsonl"); + write_jsonl( + &hosted, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"hosted_search_call","id":"call-1","status":"in_progress"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&hosted).unwrap(); + assert!(!parsed.lifecycle_valid); + } + + #[test] + fn known_codex_end_events_require_exact_correlation_and_canonical_output() { + const AUDITED_META: &str = r#"{"type":"session_meta","timestamp":"2026-03-28T15:00:00Z","payload":{"id":"sess-audited","cwd":"/work","cli_version":"0.146.0","timestamp":"2026-03-28T15:00:00Z"}}"#; + let temp = tempfile::tempdir().unwrap(); + let half_closed = temp.path().join("rollout-known-end-half-closed.jsonl"); + write_jsonl( + &half_closed, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"exec_command","arguments":"{}","call_id":"call_1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"exec_command_end","call_id":"call_1"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&half_closed).unwrap(); + assert!(parsed.lifecycle_valid); + assert_eq!(parsed.open_tool_ids, HashSet::from(["call_1".to_string()])); + assert_eq!(parsed.tool_calls[0].duration_ms, 1_000); + + let completed = temp.path().join("rollout-known-end-completed.jsonl"); + write_jsonl( + &completed, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"exec_command","arguments":"{}","call_id":"call_1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"exec_command_end","call_id":"call_1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"function_call_output","call_id":"call_1","output":"ok"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&completed).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.open_tool_ids.is_empty()); + assert_eq!(parsed.tool_calls[0].duration_ms, 1_000); + let completed = parsed.completed_tool_calls.get("call_1").unwrap(); + assert_eq!(completed.class, RolloutToolClass::Ordinary); + assert_eq!(completed.completed_at_ms - completed.started_at_ms, 2_000); + assert!(!completed.code_mode_terminal); + + let output_before_end = temp.path().join("rollout-output-before-known-end.jsonl"); + write_jsonl( + &output_before_end, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"exec_command","arguments":"{}","call_id":"call_1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"function_call_output","call_id":"call_1","output":"ok"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"exec_command_end","call_id":"call_1"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&output_before_end).unwrap(); + assert!(!parsed.lifecycle_valid); + assert!(parsed.open_tool_ids.is_empty()); + + let nested = temp.path().join("rollout-known-nested-end.jsonl"); + write_jsonl( + &nested, + &[ + AUDITED_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call_outer"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"web_search_end","call_id":"exec-01234567-89ab-4def-8abc-0123456789ab"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"custom_tool_call_output","call_id":"call_outer","output":"Script completed\nWall time 2.0 seconds\nOutput:\n"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&nested).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.open_tool_ids.is_empty()); + assert!(parsed + .completed_tool_calls + .get("call_outer") + .is_some_and(|call| call.code_mode_terminal)); + assert_eq!( + parsed.nested_code_mode_end_at_ms, + HashMap::from([( + "exec-01234567-89ab-4def-8abc-0123456789ab".to_string(), + 1_774_710_062_000, + )]) + ); + + let unknown = temp.path().join("rollout-unknown-end.jsonl"); + write_jsonl( + &unknown, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"exec_command","arguments":"{}","call_id":"call-1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"future_tool_end","call_id":"call-1"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&unknown).unwrap(); + assert!(!parsed.lifecycle_valid); + assert_eq!(parsed.open_tool_ids, HashSet::from(["call-1".to_string()])); + + for (name, end) in [ + ( + "mismatched-kind", + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"patch_apply_end","call_id":"call-1"}}"#, + ), + ( + "unmatched-id", + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"exec_command_end","call_id":"unmatched"}}"#, + ), + ] { + let path = temp.path().join(format!("rollout-{name}.jsonl")); + write_jsonl( + &path, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"exec_command","arguments":"{}","call_id":"call-1"}}"#, + end, + ], + ); + let parsed = parse_codex_jsonl(&path).unwrap(); + assert!(!parsed.lifecycle_valid, "{name} must fail closed"); + assert_eq!(parsed.open_tool_ids, HashSet::from(["call-1".to_string()])); + } + + let duplicate = temp.path().join("rollout-duplicate-known-end.jsonl"); + write_jsonl( + &duplicate, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"exec_command","arguments":"{}","call_id":"call-1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"exec_command_end","call_id":"call-1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"exec_command_end","call_id":"call-1"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&duplicate).unwrap(); + assert!(!parsed.lifecycle_valid); + assert_eq!(parsed.open_tool_ids, HashSet::from(["call-1".to_string()])); + } + + #[test] + fn known_codex_end_kinds_match_the_audited_direct_tool_namespaces() { + let temp = tempfile::tempdir().unwrap(); + let cases = [ + ( + "exec-command", + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"exec_command","arguments":"{}","call_id":"call-1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"exec_command_end","call_id":"call-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"function_call_output","call_id":"call-1","output":"ok"}}"#, + ), + ( + "apply-patch", + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"apply_patch","input":"private","call_id":"call-1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"patch_apply_end","call_id":"call-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"custom_tool_call_output","call_id":"call-1","output":"ok"}}"#, + ), + ( + "web-run", + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","namespace":"web","name":"run","arguments":"{}","call_id":"call-1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"web_search_end","call_id":"call-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"function_call_output","call_id":"call-1","output":"ok"}}"#, + ), + ( + "image-generation", + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","namespace":"image_gen","name":"imagegen","arguments":"{}","call_id":"call-1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"image_generation_end","call_id":"call-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"function_call_output","call_id":"call-1","output":"ok"}}"#, + ), + ( + "mcp-tool", + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","namespace":"mcp__server","name":"tool","arguments":"{}","call_id":"call-1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"mcp_tool_call_end","call_id":"call-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"function_call_output","call_id":"call-1","output":"ok"}}"#, + ), + ]; + + for (name, call, end, output) in cases { + let path = temp.path().join(format!("rollout-{name}.jsonl")); + write_jsonl( + &path, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + call, + end, + output, + ], + ); + let parsed = parse_codex_jsonl(&path).unwrap(); + assert!(parsed.lifecycle_valid, "{name} should remain exact"); + assert!( + parsed.open_tool_ids.is_empty(), + "{name} should close on output" + ); + assert_eq!(parsed.tool_calls[0].duration_ms, 1_000); + } + } + + #[test] + fn future_and_pre_epoch_rollout_timestamps_are_invalid() { + let temp = tempfile::tempdir().unwrap(); + let future = temp.path().join("rollout-future.jsonl"); + write_jsonl( + &future, + &[ + r#"{"type":"session_meta","timestamp":"2999-01-01T00:00:00Z","payload":{"id":"future","cwd":"/tmp","timestamp":"2999-01-01T00:00:00Z"}}"#, + ], + ); + assert!(!parse_codex_jsonl(&future).unwrap().lifecycle_valid); + + let pre_epoch = temp.path().join("rollout-pre-epoch.jsonl"); + write_jsonl( + &pre_epoch, + &[ + r#"{"type":"session_meta","timestamp":"1960-01-01T00:00:00Z","payload":{"id":"past","cwd":"/tmp","timestamp":"1960-01-01T00:00:00Z"}}"#, + ], + ); + assert!(!parse_codex_jsonl(&pre_epoch).unwrap().lifecycle_valid); + } + + #[test] + fn rollout_error_records_permanently_invalidate_positive_lifecycle() { + let temp = tempfile::tempdir().unwrap(); + let cases = [ + ( + "stream-error", + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"stream_error","message":"must remain private"}}"#, + ), + ( + "event-error", + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"error","message":"must remain private"}}"#, + ), + ( + "top-level-error", + r#"{"type":"error","timestamp":"2026-03-28T15:01:01Z","message":"must remain private"}"#, + ), + ]; + for (name, error_record) in cases { + let path = temp.path().join(format!("rollout-{name}.jsonl")); + write_jsonl( + &path, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + error_record, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"task_complete","turn_id":"turn-1"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&path).unwrap(); + assert!(!parsed.lifecycle_valid, "{name} must fail closed"); + assert!(!parsed.task_complete, "{name} cannot retain completion"); + assert!(!parsed.current_task.contains("must remain private")); + assert!(parsed + .chat_messages + .iter() + .all(|message| !message.text.contains("must remain private"))); + } + + let parsed = parse_codex_jsonl(&temp.path().join("rollout-stream-error.jsonl")).unwrap(); + let rollout = RolloutLifecycle { + root_cli_version: plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(), + turn_active: parsed.turn_active, + task_complete: parsed.task_complete, + lifecycle_valid: parsed.lifecycle_valid, + active_turn_id: parsed.active_turn_id, + completed_turn_id: parsed.completed_turn_id, + turn_started_at_ms: parsed.turn_started_at_ms, + latest_lifecycle_at_ms: parsed.latest_lifecycle_at_ms, + task_completed_at_ms: parsed.task_completed_at_ms, + open_tool_ids: parsed.open_tool_ids, + open_tool_started_at_ms: parsed.open_tool_started_at_ms, + ..Default::default() + }; + let now_ms = unix_now_ms(); + assert_eq!( + project_hook_status( + &hook_record(HookCandidate::TurnOpen, now_ms), + Some(&rollout), + now_ms, + ), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookEventGap, + ), + "an errored active rollout cannot remain Thinking" + ); + + let failed_completion = temp.path().join("rollout-failed-completion.jsonl"); + write_jsonl( + &failed_completion, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:02:00Z","payload":{"type":"task_started","turn_id":"turn-2"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:02:01Z","payload":{"type":"task_complete","turn_id":"turn-2","error":{"message":"must remain private"}}}"#, + ], + ); + let parsed = parse_codex_jsonl(&failed_completion).unwrap(); + assert!(!parsed.lifecycle_valid); + assert!(!parsed.task_complete); + assert!(parsed + .chat_messages + .iter() + .all(|message| !message.text.contains("must remain private"))); + } + + #[test] + fn incomplete_rollout_tail_fails_closed_and_recovers_after_append() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + ], + ); + file.write_all( + br#"{"type":"event_msg","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"agent_message""#, + ) + .unwrap(); + file.flush().unwrap(); + + let partial = parse_codex_jsonl(file.path()).unwrap(); + assert!(!partial.lifecycle_valid); + let now_ms = unix_now_ms(); + let mut rollout = active_root_rollout(now_ms); + rollout.lifecycle_valid = partial.lifecycle_valid; + assert_eq!( + project_hook_status( + &hook_record(HookCandidate::TurnOpen, now_ms), + Some(&rollout), + now_ms, + ), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookEventGap, + ) + ); + + file.write_all(br#", "message":"ok"}}"#).unwrap(); + file.write_all(b"\n").unwrap(); + file.flush().unwrap(); + let complete = parse_codex_jsonl(file.path()).unwrap(); + assert!(complete.lifecycle_valid); + assert!(complete.turn_active); + } + + #[test] + fn rollout_lifecycle_preserves_exact_active_and_completed_turn_ids() { + let temp = tempfile::tempdir().unwrap(); + let active = temp.path().join("rollout-active.jsonl"); + write_jsonl( + &active, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"exec_command","arguments":"{}","call_id":"call-1"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&active).unwrap(); + assert!(parsed.lifecycle_valid); + assert_eq!(parsed.active_turn_id.as_deref(), Some("turn-1")); + assert_eq!(parsed.open_tool_ids, HashSet::from(["call-1".to_string()])); + + let completed = temp.path().join("rollout-completed.jsonl"); + write_jsonl( + &completed, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"apply_patch","arguments":"{}","call_id":"call-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"function_call_output","call_id":"call-1","output":"ok"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"task_complete","turn_id":"turn-1"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&completed).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.task_complete); + assert_eq!(parsed.completed_turn_id.as_deref(), Some("turn-1")); + assert!(parsed.active_turn_id.is_none()); + + let aborted = temp.path().join("rollout-aborted.jsonl"); + write_jsonl( + &aborted, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"turn_aborted","turn_id":"turn-1"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&aborted).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(!parsed.turn_active); + assert!(!parsed.task_complete); + assert!(parsed.active_turn_id.is_none()); + assert!(parsed.completed_turn_id.is_none()); + } + + #[test] + fn exact_abort_allows_only_a_later_clean_turn_to_recover() { + let temp = tempfile::tempdir().unwrap(); + let recovered = temp.path().join("rollout-abort-recovered.jsonl"); + write_jsonl( + &recovered, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"function_call","name":"exec_command","arguments":"{}","call_id":"call-1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"turn_aborted","turn_id":"turn-1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"task_started","turn_id":"turn-2"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"custom_tool_call","name":"exec","input":"const result = await tools.exec_command({cmd: \"cargo test\"});\ntext(result.output);","call_id":"call-2"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&recovered).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.turn_active); + assert_eq!(parsed.active_turn_id.as_deref(), Some("turn-2")); + assert_eq!(parsed.open_tool_ids, HashSet::from(["call-2".to_string()])); + assert!(matches!( + parsed.open_tool_classes.get("call-2"), + Some(RolloutToolClass::CodeModeExec { .. }) + )); + + for (name, fault) in [ + ( + "mismatched-abort", + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"turn_aborted","turn_id":"other-turn"}}"#, + ), + ( + "stream-error", + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"stream_error"}}"#, + ), + ] { + let path = temp.path().join(format!("rollout-{name}.jsonl")); + write_jsonl( + &path, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + fault, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"task_started","turn_id":"turn-2"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&path).unwrap(); + assert!(!parsed.lifecycle_valid, "{name} must remain fail-closed"); + } + } + + #[test] + fn abort_preserves_yielded_code_mode_cells_until_exact_termination() { + let temp = tempfile::tempdir().unwrap(); + let unresolved = temp.path().join("rollout-aborted-yielded-cell.jsonl"); + let prefix = [ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started","turn_id":"turn-1"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:01Z","payload":{"type":"custom_tool_call","name":"exec","input":"private","call_id":"call-exec"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:02Z","payload":{"type":"custom_tool_call_output","call_id":"call-exec","output":"Script running with cell ID 1\nWall time 1.0 seconds\nOutput:\n"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:03Z","payload":{"type":"turn_aborted","turn_id":"turn-1"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:04Z","payload":{"type":"task_started","turn_id":"turn-2"}}"#, + ]; + write_jsonl(&unresolved, &prefix); + + let parsed = parse_codex_jsonl(&unresolved).unwrap(); + assert!(parsed.lifecycle_valid); + assert_eq!(parsed.live_code_mode_cells, 1); + assert!(parsed.code_mode_correlation_ambiguous); + assert!(!parsed.is_exact_terminal_lifecycle()); + + let completed_without_cell = temp.path().join("rollout-aborted-live-cell-complete.jsonl"); + let mut lines = prefix.to_vec(); + lines.push( + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:05Z","payload":{"type":"task_complete","turn_id":"turn-2"}}"#, + ); + write_jsonl(&completed_without_cell, &lines); + let parsed = parse_codex_jsonl(&completed_without_cell).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.task_complete); + assert_eq!(parsed.live_code_mode_cells, 1); + assert!(parsed.code_mode_correlation_ambiguous); + assert!(!parsed.is_exact_terminal_lifecycle()); + + let terminated = temp.path().join("rollout-aborted-cell-terminated.jsonl"); + let mut lines = prefix.to_vec(); + lines.extend([ + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:05Z","payload":{"type":"function_call","name":"wait","arguments":"{\"cell_id\":\"1\",\"terminate\":true}","call_id":"call-wait"}}"#, + r#"{"type":"response_item","timestamp":"2026-03-28T15:01:06Z","payload":{"type":"function_call_output","call_id":"call-wait","output":"Script terminated\nWall time 1.0 seconds\nOutput:\n"}}"#, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:07Z","payload":{"type":"task_complete","turn_id":"turn-2"}}"#, + ]); + write_jsonl(&terminated, &lines); + let parsed = parse_codex_jsonl(&terminated).unwrap(); + assert!(parsed.lifecycle_valid); + assert_eq!(parsed.live_code_mode_cells, 0); + assert!(!parsed.code_mode_correlation_ambiguous); + assert!(parsed.is_exact_terminal_lifecycle()); + } + + #[test] + fn copied_subagent_history_starts_a_distinct_exact_lifecycle_epoch() { + const CHILD_ID: &str = "019fc46e-53c0-7861-95ad-b379c660fb69"; + const PARENT_ID: &str = "019fc2c5-df6e-78f0-8773-9ab7a5fb6d84"; + const CHILD_META: &str = r#"{"type":"session_meta","timestamp":"2026-08-02T21:43:12.695Z","payload":{"id":"019fc46e-53c0-7861-95ad-b379c660fb69","parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T21:43:12.695Z"}}"#; + const PARENT_META: &str = r#"{"type":"session_meta","timestamp":"2026-08-02T21:43:12.695Z","payload":{"id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T13:59:35.564Z"}}"#; + + let temp = tempfile::tempdir().unwrap(); + let copied = temp.path().join("rollout-copied-child.jsonl"); + write_jsonl( + &copied, + &[ + CHILD_META, + PARENT_META, + r#"{"type":"event_msg","timestamp":"2026-08-02T21:43:12.700Z","payload":{"type":"task_started","turn_id":"019fc46d-6979-7711-86f3-823997623d47"}}"#, + r#"{"type":"response_item","timestamp":"2026-08-02T21:43:12.710Z","payload":{"type":"function_call","name":"exec_command","arguments":"{}","call_id":"parent-call"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T21:43:12.730Z","payload":{"type":"thread_settings_applied"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T21:43:12.736Z","payload":{"type":"task_started","turn_id":"019fc46e-545f-73f3-a201-88b6a34e78f0"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T21:45:17.200Z","payload":{"type":"turn_aborted","turn_id":"019fc46e-545f-73f3-a201-88b6a34e78f0"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T21:45:35.627Z","payload":{"type":"task_started","turn_id":"019fc470-828a-79a0-9903-d5f02a8c437d"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T21:45:40.000Z","payload":{"type":"agent_message","message":"done"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T21:45:57.017Z","payload":{"type":"task_complete","turn_id":"019fc470-828a-79a0-9903-d5f02a8c437d"}}"#, + ], + ); + + let parsed = parse_codex_jsonl(&copied).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.task_complete); + assert!(!parsed.turn_active); + assert_eq!(parsed.session_id, CHILD_ID); + assert_eq!(parsed.parent_thread_id.as_deref(), Some(PARENT_ID)); + assert_eq!( + parsed.completed_turn_id.as_deref(), + Some("019fc470-828a-79a0-9903-d5f02a8c437d") + ); + assert_eq!(parsed.turn_count, 1); + assert!(parsed.open_tool_ids.is_empty()); + assert!(parsed.tool_calls.is_empty()); + + let legacy = temp.path().join("rollout-copied-child-0.145.jsonl"); + write_jsonl( + &legacy, + &[ + r#"{"type":"session_meta","timestamp":"2026-08-02T21:43:12.695Z","payload":{"id":"019fc46e-53c0-7861-95ad-b379c660fb69","parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","cwd":"/work","cli_version":"0.145.0","timestamp":"2026-08-02T21:43:12.695Z"}}"#, + r#"{"type":"session_meta","timestamp":"2026-08-02T21:43:12.695Z","payload":{"id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","cwd":"/work","cli_version":"0.145.0","timestamp":"2026-08-02T13:59:35.564Z"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T21:43:12.700Z","payload":{"type":"task_started","turn_id":"019fc46d-6979-7711-86f3-823997623d47"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T21:43:12.736Z","payload":{"type":"task_started","turn_id":"019fc46e-545f-73f3-a201-88b6a34e78f0"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T21:45:17.200Z","payload":{"type":"task_complete","turn_id":"019fc46e-545f-73f3-a201-88b6a34e78f0"}}"#, + ], + ); + let parsed = parse_codex_jsonl(&legacy).unwrap(); + assert!(!parsed.lifecycle_valid); + assert!(!parsed.is_exact_terminal_lifecycle()); + } + + #[test] + fn copied_rollout_metadata_is_bounded_and_excess_fails_closed() { + fn uuid_v7_at(timestamp_ms: u64, serial: usize) -> String { + format!( + "{:08x}-{:04x}-7000-8000-{:012x}", + timestamp_ms >> 16, + timestamp_ms & 0xffff, + serial + ) + } + + fn rfc3339(timestamp_ms: u64) -> String { + chrono::DateTime::::from_timestamp_millis(timestamp_ms as i64) + .unwrap() + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true) + } + + fn write_copied_chain(path: &Path, replayed_count: usize) { + let base_ms = 1_774_710_000_000_u64; + let replayed_ids = (0..replayed_count) + .map(|index| uuid_v7_at(base_ms + index as u64 * 1_000, index)) + .collect::>(); + let child_timestamp_ms = base_ms + replayed_count as u64 * 1_000; + let child_id = uuid_v7_at(child_timestamp_ms, replayed_count); + let child_timestamp = rfc3339(child_timestamp_ms); + let mut file = File::create(path).unwrap(); + writeln!( + file, + "{}", + serde_json::json!({ + "type": "session_meta", + "timestamp": child_timestamp, + "payload": { + "id": child_id, + "parent_thread_id": replayed_ids.last().unwrap(), + "cwd": "/work", + "cli_version": "0.146.0", + "timestamp": child_timestamp, + } + }) + ) + .unwrap(); + + for (index, replayed_id) in replayed_ids.iter().enumerate() { + let metadata_timestamp = rfc3339(base_ms + index as u64 * 1_000); + let mut payload = serde_json::json!({ + "id": replayed_id, + "cwd": "/work", + "cli_version": "0.146.0", + "timestamp": metadata_timestamp, + }); + if index > 0 { + payload["parent_thread_id"] = Value::String(replayed_ids[index - 1].clone()); + } + writeln!( + file, + "{}", + serde_json::json!({ + "type": "session_meta", + "timestamp": metadata_timestamp, + "payload": payload, + }) + ) + .unwrap(); + } + + let epoch_thresholds = (1..replayed_count) + .map(|index| base_ms + index as u64 * 1_000) + .chain(std::iter::once(child_timestamp_ms)); + for (index, threshold_ms) in epoch_thresholds.enumerate() { + let turn_id = uuid_v7_at(threshold_ms + 100, replayed_count + index + 1); + writeln!( + file, + "{}", + serde_json::json!({ + "type": "event_msg", + "timestamp": rfc3339(threshold_ms + 50), + "payload": { "type": "thread_settings_applied" }, + }) + ) + .unwrap(); + writeln!( + file, + "{}", + serde_json::json!({ + "type": "event_msg", + "timestamp": rfc3339(threshold_ms + 150), + "payload": { "type": "task_started", "turn_id": turn_id }, + }) + ) + .unwrap(); + writeln!( + file, + "{}", + serde_json::json!({ + "type": "event_msg", + "timestamp": rfc3339(threshold_ms + 200), + "payload": { "type": "task_complete", "turn_id": turn_id }, + }) + ) + .unwrap(); + } + file.flush().unwrap(); + } + + let temp = tempfile::tempdir().unwrap(); + let at_limit = temp.path().join("rollout-copied-meta-at-limit.jsonl"); + write_copied_chain(&at_limit, MAX_COPIED_ROLLOUT_SESSION_META); + let parsed = parse_codex_jsonl(&at_limit).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.is_exact_terminal_lifecycle()); + + let over_limit = temp.path().join("rollout-copied-meta-over-limit.jsonl"); + write_copied_chain(&over_limit, MAX_COPIED_ROLLOUT_SESSION_META + 1); + let parsed = parse_codex_jsonl(&over_limit).unwrap(); + assert!(!parsed.lifecycle_valid); + assert!(!parsed.is_exact_terminal_lifecycle()); + } + + #[test] + fn attested_fork_metadata_delimits_copied_history_without_replayed_parent_meta() { + let temp = tempfile::tempdir().unwrap(); + let copied = temp.path().join("rollout-attested-fork-child.jsonl"); + write_jsonl( + &copied, + &[ + r#"{"type":"session_meta","timestamp":"2026-08-02T22:02:12.914Z","payload":{"id":"019fc47f-b832-75b1-9427-5f024c502791","parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","forked_from_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","source":{"subagent":{"thread_spawn":{"parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84"}}},"cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T22:02:12.914Z"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T22:02:12.920Z","payload":{"type":"task_started","turn_id":"019fc46d-6979-7711-86f3-823997623d47"}}"#, + r#"{"type":"response_item","timestamp":"2026-08-02T22:02:12.925Z","payload":{"type":"function_call","name":"exec_command","arguments":"{}","call_id":"parent-call"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T22:02:12.930Z","payload":{"type":"thread_settings_applied"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T22:02:12.935Z","payload":{"type":"task_started","turn_id":"019fc47f-b8b7-7210-99b2-d8b4d8bc86b7"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T22:02:12.950Z","payload":{"type":"agent_message","message":"child done"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T22:02:13.000Z","payload":{"type":"task_complete","turn_id":"019fc47f-b8b7-7210-99b2-d8b4d8bc86b7"}}"#, + ], + ); + + let parsed = parse_codex_jsonl(&copied).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.is_exact_terminal_lifecycle()); + assert_eq!( + parsed.completed_turn_id.as_deref(), + Some("019fc47f-b8b7-7210-99b2-d8b4d8bc86b7") + ); + assert_eq!(parsed.turn_count, 1); + assert!(parsed.tool_calls.is_empty()); + + let conflicting = temp.path().join("rollout-conflicting-fork-child.jsonl"); + write_jsonl( + &conflicting, + &[ + r#"{"type":"session_meta","timestamp":"2026-08-02T22:02:12.914Z","payload":{"id":"019fc47f-b832-75b1-9427-5f024c502791","parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","forked_from_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","source":{"subagent":{"thread_spawn":{"parent_thread_id":"019fc2c0-0000-7000-8000-000000000000"}}},"cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T22:02:12.914Z"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T22:02:12.930Z","payload":{"type":"thread_settings_applied"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T22:02:12.935Z","payload":{"type":"task_started","turn_id":"019fc47f-b8b7-7210-99b2-d8b4d8bc86b7"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T22:02:13.000Z","payload":{"type":"task_complete","turn_id":"019fc47f-b8b7-7210-99b2-d8b4d8bc86b7"}}"#, + ], + ); + assert!(!parse_codex_jsonl(&conflicting).unwrap().lifecycle_valid); + } + + #[test] + fn direct_fork_epoch_attestation_requires_the_complete_exact_0146_shape() { + const SETTINGS: &str = r#"{"type":"event_msg","timestamp":"2026-08-02T22:02:12.930Z","payload":{"type":"thread_settings_applied"}}"#; + const START: &str = r#"{"type":"event_msg","timestamp":"2026-08-02T22:02:12.935Z","payload":{"type":"task_started","turn_id":"019fc47f-b8b7-7210-99b2-d8b4d8bc86b7"}}"#; + const COMPLETE: &str = r#"{"type":"event_msg","timestamp":"2026-08-02T22:02:13.000Z","payload":{"type":"task_complete","turn_id":"019fc47f-b8b7-7210-99b2-d8b4d8bc86b7"}}"#; + let cases = [ + ( + "missing-direct-parent", + r#"{"type":"session_meta","timestamp":"2026-08-02T22:02:12.914Z","payload":{"id":"019fc47f-b832-75b1-9427-5f024c502791","forked_from_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","source":{"subagent":{"thread_spawn":{"parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84"}}},"cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T22:02:12.914Z"}}"#, + ), + ( + "missing-source-parent", + r#"{"type":"session_meta","timestamp":"2026-08-02T22:02:12.914Z","payload":{"id":"019fc47f-b832-75b1-9427-5f024c502791","parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","forked_from_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T22:02:12.914Z"}}"#, + ), + ( + "mismatched-fork-parent", + r#"{"type":"session_meta","timestamp":"2026-08-02T22:02:12.914Z","payload":{"id":"019fc47f-b832-75b1-9427-5f024c502791","parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","forked_from_id":"019fc2c0-0000-7000-8000-000000000000","source":{"subagent":{"thread_spawn":{"parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84"}}},"cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T22:02:12.914Z"}}"#, + ), + ( + "non-string-direct-parent", + r#"{"type":"session_meta","timestamp":"2026-08-02T22:02:12.914Z","payload":{"id":"019fc47f-b832-75b1-9427-5f024c502791","parent_thread_id":42,"forked_from_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","source":{"subagent":{"thread_spawn":{"parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84"}}},"cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T22:02:12.914Z"}}"#, + ), + ( + "unreviewed-patch-release", + r#"{"type":"session_meta","timestamp":"2026-08-02T22:02:12.914Z","payload":{"id":"019fc47f-b832-75b1-9427-5f024c502791","parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","forked_from_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","source":{"subagent":{"thread_spawn":{"parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84"}}},"cwd":"/work","cli_version":"0.146.1","timestamp":"2026-08-02T22:02:12.914Z"}}"#, + ), + ( + "parent-not-older-than-child", + r#"{"type":"session_meta","timestamp":"2026-08-02T22:02:12.914Z","payload":{"id":"019fc47f-b832-75b1-9427-5f024c502791","parent_thread_id":"019fc480-0000-7000-8000-000000000000","forked_from_id":"019fc480-0000-7000-8000-000000000000","source":{"subagent":{"thread_spawn":{"parent_thread_id":"019fc480-0000-7000-8000-000000000000"}}},"cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T22:02:12.914Z"}}"#, + ), + ( + "malformed-child-uuid", + r#"{"type":"session_meta","timestamp":"2026-08-02T22:02:12.914Z","payload":{"id":"not-a-uuid","parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","forked_from_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","source":{"subagent":{"thread_spawn":{"parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84"}}},"cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T22:02:12.914Z"}}"#, + ), + ]; + + let temp = tempfile::tempdir().unwrap(); + for (name, metadata) in cases { + let path = temp.path().join(format!("rollout-{name}.jsonl")); + write_jsonl(&path, &[metadata, SETTINGS, START, COMPLETE]); + assert!( + !parse_codex_jsonl(&path).unwrap().lifecycle_valid, + "{name} must remain fail-closed" + ); + } + + let interrupted = temp.path().join("rollout-interrupted-copied-prefix.jsonl"); + write_jsonl( + &interrupted, + &[ + r#"{"type":"session_meta","timestamp":"2026-08-02T22:02:12.914Z","payload":{"id":"019fc47f-b832-75b1-9427-5f024c502791","parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","forked_from_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","source":{"subagent":{"thread_spawn":{"parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84"}}},"cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T22:02:12.914Z"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T22:02:12.920Z","payload":{"type":"stream_error"}}"#, + SETTINGS, + START, + COMPLETE, + ], + ); + assert!(!parse_codex_jsonl(&interrupted).unwrap().lifecycle_valid); + + let incomplete = temp.path().join("rollout-incomplete-direct-fork.jsonl"); + write_jsonl( + &incomplete, + &[ + r#"{"type":"session_meta","timestamp":"2026-08-02T22:02:12.914Z","payload":{"id":"019fc47f-b832-75b1-9427-5f024c502791","parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","forked_from_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","source":{"subagent":{"thread_spawn":{"parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84"}}},"cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T22:02:12.914Z"}}"#, + SETTINGS, + ], + ); + assert!(!parse_codex_jsonl(&incomplete).unwrap().lifecycle_valid); + } + + #[test] + fn copied_subagent_epoch_recovery_requires_the_exact_provider_delimiter() { + const CHILD_META: &str = r#"{"type":"session_meta","timestamp":"2026-08-02T21:43:12.695Z","payload":{"id":"019fc46e-53c0-7861-95ad-b379c660fb69","parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T21:43:12.695Z"}}"#; + const PARENT_META: &str = r#"{"type":"session_meta","timestamp":"2026-08-02T21:43:12.695Z","payload":{"id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T13:59:35.564Z"}}"#; + const PARENT_START: &str = r#"{"type":"event_msg","timestamp":"2026-08-02T21:43:12.700Z","payload":{"type":"task_started","turn_id":"019fc46d-6979-7711-86f3-823997623d47"}}"#; + const SETTINGS: &str = r#"{"type":"event_msg","timestamp":"2026-08-02T21:43:12.730Z","payload":{"type":"thread_settings_applied"}}"#; + const CHILD_START: &str = r#"{"type":"event_msg","timestamp":"2026-08-02T21:43:12.736Z","payload":{"type":"task_started","turn_id":"019fc46e-545f-73f3-a201-88b6a34e78f0"}}"#; + + let temp = tempfile::tempdir().unwrap(); + let cases: [(&str, &[&str]); 8] = [ + ( + "missing-parent-meta", + &[CHILD_META, PARENT_START, SETTINGS, CHILD_START], + ), + ( + "non-adjacent-delimiter", + &[ + CHILD_META, + PARENT_META, + PARENT_START, + SETTINGS, + r#"{"type":"event_msg","timestamp":"2026-08-02T21:43:12.732Z","payload":{"type":"token_count"}}"#, + CHILD_START, + ], + ), + ( + "missing-0.146-delimiter", + &[CHILD_META, PARENT_META, PARENT_START, CHILD_START], + ), + ( + "malformed-copied-stream", + &[ + CHILD_META, + PARENT_META, + "NOT JSON", + PARENT_START, + SETTINGS, + CHILD_START, + ], + ), + ( + "missing-child-local-task", + &[CHILD_META, PARENT_META, PARENT_START, SETTINGS], + ), + ( + "failed-first-candidate-cannot-recover", + &[ + CHILD_META, + PARENT_META, + PARENT_START, + CHILD_START, + SETTINGS, + r#"{"type":"event_msg","timestamp":"2026-08-02T21:45:35.627Z","payload":{"type":"task_started","turn_id":"019fc470-828a-79a0-9903-d5f02a8c437d"}}"#, + ], + ), + ( + "equal-uuidv7-threshold", + &[ + CHILD_META, + PARENT_META, + PARENT_START, + SETTINGS, + r#"{"type":"event_msg","timestamp":"2026-08-02T21:43:12.736Z","payload":{"type":"task_started","turn_id":"019fc46e-53c0-7abc-8def-0123456789ab"}}"#, + ], + ), + ( + "incomplete-ancestor-chain", + &[ + CHILD_META, + r#"{"type":"session_meta","timestamp":"2026-08-02T21:43:12.695Z","payload":{"id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","parent_thread_id":"019fc2c0-0000-7000-8000-000000000000","cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T13:59:35.564Z"}}"#, + PARENT_START, + SETTINGS, + CHILD_START, + ], + ), + ]; + + for (name, lines) in cases { + let path = temp.path().join(format!("rollout-{name}.jsonl")); + write_jsonl(&path, lines); + let parsed = parse_codex_jsonl(&path).unwrap(); + assert!(!parsed.lifecycle_valid, "{name} must remain fail-closed"); + } + } + + #[test] + fn nested_copied_subagent_epochs_follow_the_exact_metadata_chain_once() { + let temp = tempfile::tempdir().unwrap(); + let nested = temp.path().join("rollout-nested-copied-child.jsonl"); + write_jsonl( + &nested, + &[ + r#"{"type":"session_meta","timestamp":"2026-08-02T18:04:28.102Z","payload":{"id":"019fc3a6-0fd2-77c1-8a24-2a0330b1ab2e","parent_thread_id":"019fc3a5-ecd7-75a3-80c9-f278fa72e5c6","cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T18:04:28.007Z"}}"#, + r#"{"type":"session_meta","timestamp":"2026-08-02T18:04:28.102Z","payload":{"id":"019fc3a5-ecd7-75a3-80c9-f278fa72e5c6","parent_thread_id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T18:04:19.051Z"}}"#, + r#"{"type":"session_meta","timestamp":"2026-08-02T18:04:28.102Z","payload":{"id":"019fc2c5-df6e-78f0-8773-9ab7a5fb6d84","cwd":"/work","cli_version":"0.146.0","timestamp":"2026-08-02T13:59:35.564Z"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T18:04:28.106Z","payload":{"type":"task_started","turn_id":"019fc3a3-fd5f-7b51-9657-5240320e5f8d"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T18:04:28.115Z","payload":{"type":"thread_settings_applied"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T18:04:28.116Z","payload":{"type":"task_started","turn_id":"019fc3a5-ed5f-7081-bcb2-f12e3e6d791c"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T18:04:28.117Z","payload":{"type":"task_complete","turn_id":"019fc3a5-ed5f-7081-bcb2-f12e3e6d791c"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T18:04:28.117Z","payload":{"type":"thread_settings_applied"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T18:04:28.118Z","payload":{"type":"task_started","turn_id":"019fc3a5-f000-7660-8b33-6d31a98fa13b"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T18:04:28.119Z","payload":{"type":"task_complete","turn_id":"019fc3a5-f000-7660-8b33-6d31a98fa13b"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T18:04:28.119Z","payload":{"type":"thread_settings_applied"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T18:04:28.120Z","payload":{"type":"task_started","turn_id":"019fc3a6-1058-7800-a69d-82ec56600245"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T18:04:29.000Z","payload":{"type":"agent_message","message":"child done"}}"#, + r#"{"type":"event_msg","timestamp":"2026-08-02T18:08:29.254Z","payload":{"type":"task_complete","turn_id":"019fc3a6-1058-7800-a69d-82ec56600245"}}"#, + ], + ); + + let parsed = parse_codex_jsonl(&nested).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.is_exact_terminal_lifecycle()); + assert_eq!( + parsed.completed_turn_id.as_deref(), + Some("019fc3a6-1058-7800-a69d-82ec56600245") + ); + assert_eq!(parsed.turn_count, 1); + } + + #[test] + fn root_subagent_set_executes_and_interaction_ambiguity_wins() { + let now_ms = 100_000; + let mut root = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::from(["child-1".to_string()]), + provisional: HashSet::new(), + root: HookRootCandidate::TurnOpen, + }, + now_ms, + ); + let mut rollout = active_root_rollout(now_ms); + rollout + .descendants + .push(active_child_rollout("child-1", true, now_ms)); + assert_eq!( + project_hook_status(&root, Some(&rollout), now_ms), + ( + SessionStatus::Executing, + StatusAuthority::Heuristic, + StatusReason::HookSubagentActive, + ) + ); + + root.interaction_ambiguous = true; + assert_eq!( + project_hook_status(&root, Some(&rollout), now_ms).0, + SessionStatus::Unknown + ); + } + + #[test] + fn positive_hook_status_accepts_compatible_stable_root_cli_versions() { + let now_ms = 100_000; + let record = hook_record(HookCandidate::TurnOpen, now_ms); + for supported in ["0.145.0", "0.146.1", "1.0.0"] { + let mut rollout = active_root_rollout(now_ms); + rollout.root_cli_version = supported.to_string(); + assert_eq!( + project_hook_status(&record, Some(&rollout), now_ms), + ( + SessionStatus::Thinking, + StatusAuthority::Heuristic, + StatusReason::HookTurnOpen, + ), + "compatible root cli_version {supported:?} must retain lifecycle evidence" + ); + } + for unsupported in ["0.144.999", "0.145.0-beta.1", ""] { + let mut rollout = active_root_rollout(now_ms); + rollout.root_cli_version = unsupported.to_string(); + assert_eq!( + project_hook_status(&record, Some(&rollout), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookIntegrationUnverified, + ), + "unsupported root cli_version {unsupported:?} must fail closed" + ); + } + + let mut unattested = record; + unattested.supported_release_attested = false; + assert_eq!( + project_hook_status(&unattested, Some(&active_root_rollout(now_ms)), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookIntegrationUnverified, + ), + "rollout metadata cannot replace exact process/root correlation" + ); + } + + #[test] + fn production_hook_conversion_is_heuristic_unactionable_and_preserves_exact_done_proof() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let shared = hook_shared(); + let mut record = hook_record_from_state(production_turn_open_hook_state(now_ms)); + assert!(matches!(record.candidate, HookCandidate::TurnOpen)); + assert!( + !record.effective_hook_engine_attested, + "supported Codex releases cannot attest the effective hook engine for one live thread" + ); + + // The OS probes are isolated from conversion in this unit test. Make + // every other ownership input exact so the live row can be displayed + // heuristically while action ownership remains unavailable. + record.process_state = HookProcessState::Live; + record.native_process_verified = true; + record.actionable = true; + record.owns_resources = true; + let (_, session) = prepared_live_hook( + &collector, + now_ms, + "hook-session", + "test-generation", + "test:codex:42", + "0.147.3", + ); + let key = hook_done_key(&record).unwrap(); + let live = + collector.finalize_hook_records(vec![session], vec![record.clone()], &shared, now_ms); + assert_eq!(live.len(), 1); + assert_eq!(live[0].status, SessionStatus::Thinking); + assert_eq!( + live[0].status_evidence.authority, + StatusAuthority::Heuristic + ); + assert_eq!(live[0].status_evidence.reason, StatusReason::HookTurnOpen); + assert_eq!(live[0].version, "0.147.3"); + assert!(live[0].action_process_incarnation.is_none()); + assert!(collector + .hook_process_rollout_bindings + .borrow() + .contains_key(&key)); + assert!(collector + .hook_live_session_snapshots + .borrow() + .contains_key(&key)); + + record.process_state = HookProcessState::Gone; + record.native_process_verified = false; + let done = collector.finalize_hook_records(Vec::new(), vec![record], &shared, 101_000); + assert_eq!(done.len(), 1); + assert_eq!(done[0].status, SessionStatus::Done); + assert_eq!( + done[0].status_evidence.authority, + StatusAuthority::Heuristic + ); + assert_eq!(done[0].status_evidence.reason, StatusReason::ProcessExited); + assert_eq!(done[0].version, "0.147.3"); + assert!(done[0].action_process_incarnation.is_none()); + } + + #[test] + fn herdr_working_refines_unique_tool_across_codex_id_namespaces() { + let now_ms = 100_000; + let hook_id = "exec-f842a710-1234-4abc-8def-000000000000".to_string(); + let rollout_id = "call_qOSAD01CGeN305j2BKf2D6vV".to_string(); + let mut record = hook_record( + HookCandidate::ToolOpen(HashSet::from([hook_id.clone()])), + now_ms, + ); + record.effective_hook_engine_attested = false; + let mut rollout = active_root_rollout(now_ms); + rollout.open_tool_ids = HashSet::from([rollout_id.clone()]); + rollout + .open_tool_started_at_ms + .insert(rollout_id.clone(), now_ms - 1_042); + rollout.open_tool_classes.insert( + rollout_id, + RolloutToolClass::CodeModeExec { + exec_started_at_ms: now_ms - 1_042, + }, + ); + + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + Some(HerdrWorkingProjection { + status: SessionStatus::Executing, + status_since_ms: now_ms - 1_000, + consecutive_matching: 0, + }) + ); + + record + .tool_opened_at_ms + .insert(hook_id.clone(), now_ms - 1_043); + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + None, + "a sole stale hook from before the outer exec cannot refine current work" + ); + record.tool_opened_at_ms.insert(hook_id, now_ms - 1_000); + + record.interaction_ambiguous = true; + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + None, + "a permission edge keeps visible work unclassified" + ); + + record.interaction_ambiguous = false; + record.observed_at_ms = now_ms + 1; + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + None, + "Herdr working must not bypass malformed hook state" + ); + } + + #[test] + fn herdr_working_refines_a_later_unique_wait_without_reusing_ids() { + let now_ms = 100_000; + let hook_id = "exec-f842a710-1234-4abc-8def-000000000000".to_string(); + let rollout_id = "call_wait".to_string(); + let mut record = hook_record( + HookCandidate::ToolOpen(HashSet::from([hook_id.clone()])), + now_ms, + ); + record.status_since_ms = now_ms - 1_500; + record + .tool_opened_at_ms + .insert(hook_id.clone(), now_ms - 1_500); + + let mut rollout = active_root_rollout(now_ms); + rollout.open_tool_ids = HashSet::from([rollout_id.clone()]); + rollout + .open_tool_started_at_ms + .insert(rollout_id.clone(), now_ms - 500); + rollout.open_tool_classes.insert( + rollout_id, + RolloutToolClass::CodeModeWait { + exec_started_at_ms: now_ms - 1_500, + exec_yielded_at_ms: now_ms - 1_000, + }, + ); + rollout.live_code_mode_cells = 1; + rollout.latest_lifecycle_at_ms = now_ms - 100; + + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + Some(HerdrWorkingProjection { + status: SessionStatus::Executing, + status_since_ms: now_ms - 500, + consecutive_matching: 0, + }) + ); + + record + .tool_opened_at_ms + .insert(hook_id.clone(), now_ms - 1_501); + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + None, + "a sole stale hook from before the yielded exec cannot corroborate its later wait" + ); + + record.tool_opened_at_ms.insert(hook_id, now_ms - 999); + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + None, + "a hook opened after the yield cannot be reused as the origin of its wait" + ); + } + + #[test] + fn herdr_working_reconciles_only_exact_canonical_root_completions() { + let now_ms = 100_000; + let stale_id = "call_Stale".to_string(); + let current_id = "call_Current".to_string(); + let mut record = hook_record( + HookCandidate::ToolOpen(HashSet::from([stale_id.clone(), current_id.clone()])), + now_ms, + ); + record + .tool_opened_at_ms + .insert(stale_id.clone(), now_ms - 1_000); + record + .tool_opened_at_ms + .insert(current_id.clone(), now_ms - 300); + + let mut rollout = active_root_rollout(now_ms); + rollout.latest_lifecycle_at_ms = now_ms - 100; + rollout.open_tool_ids = HashSet::from([current_id.clone()]); + rollout + .open_tool_started_at_ms + .insert(current_id.clone(), now_ms - 250); + rollout + .open_tool_classes + .insert(current_id, RolloutToolClass::Ordinary); + rollout.completed_tool_calls.insert( + stale_id, + CompletedRolloutCall { + started_at_ms: now_ms - 1_100, + completed_at_ms: now_ms - 900, + class: RolloutToolClass::Ordinary, + code_mode_terminal: false, + }, + ); + + assert_eq!( + project_hook_status(&record, Some(&rollout), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookInteractionResolutionUnavailable, + ), + "rollout completion must not weaken the general hook projection" + ); + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + Some(HerdrWorkingProjection { + status: SessionStatus::Executing, + status_since_ms: now_ms - 250, + consecutive_matching: 0, + }) + ); + + let completion = rollout.completed_tool_calls.get_mut("call_Stale").unwrap(); + completion.started_at_ms = now_ms - 950; + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + None, + "a provider completion that starts after the hook edge cannot close it" + ); + } + + #[test] + fn herdr_working_reconciles_one_terminal_code_mode_interval_without_guessing() { + let now_ms = 100_000; + let stale_hook = "exec-01234567-89ab-4def-8abc-0123456789ab".to_string(); + let current_hook = "exec-11111111-2222-4333-8444-555555555555".to_string(); + let current_outer = "call_Current".to_string(); + let mut record = hook_record( + HookCandidate::ToolOpen(HashSet::from([stale_hook.clone(), current_hook.clone()])), + now_ms, + ); + record + .tool_opened_at_ms + .insert(stale_hook.clone(), now_ms - 1_000); + record.tool_opened_at_ms.insert(current_hook, now_ms - 200); + + let mut rollout = active_root_rollout(now_ms); + rollout.latest_lifecycle_at_ms = now_ms - 100; + rollout.open_tool_ids = HashSet::from([current_outer.clone()]); + rollout + .open_tool_started_at_ms + .insert(current_outer.clone(), now_ms - 250); + rollout.open_tool_classes.insert( + current_outer, + RolloutToolClass::CodeModeExec { + exec_started_at_ms: now_ms - 250, + }, + ); + rollout.completed_tool_calls.insert( + "call_Completed".to_string(), + CompletedRolloutCall { + started_at_ms: now_ms - 1_100, + completed_at_ms: now_ms - 900, + class: RolloutToolClass::CodeModeExec { + exec_started_at_ms: now_ms - 1_100, + }, + code_mode_terminal: true, + }, + ); + + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + Some(HerdrWorkingProjection { + status: SessionStatus::Executing, + status_since_ms: now_ms - 200, + consecutive_matching: 0, + }) + ); + + rollout + .completed_tool_calls + .get_mut("call_Completed") + .unwrap() + .code_mode_terminal = false; + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + None, + "a yielded Code Mode cell is not a terminal nested-tool completion" + ); + + rollout + .completed_tool_calls + .get_mut("call_Completed") + .unwrap() + .code_mode_terminal = true; + rollout.nested_code_mode_end_at_ms.insert( + "exec-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee".to_string(), + now_ms - 950, + ); + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + None, + "an exposed nested end identity cannot close a different stale hook" + ); + + rollout.nested_code_mode_end_at_ms.clear(); + rollout + .nested_code_mode_end_at_ms + .insert(stale_hook, now_ms - 950); + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + Some(HerdrWorkingProjection { + status: SessionStatus::Executing, + status_since_ms: now_ms - 200, + consecutive_matching: 0, + }), + "an exact nested end identity may reconcile only its matching stale hook" + ); + rollout.nested_code_mode_end_at_ms.clear(); + rollout.completed_tool_calls.insert( + "call_Overlap".to_string(), + CompletedRolloutCall { + started_at_ms: now_ms - 1_050, + completed_at_ms: now_ms - 850, + class: RolloutToolClass::CodeModeExec { + exec_started_at_ms: now_ms - 1_050, + }, + code_mode_terminal: true, + }, + ); + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + None, + "overlapping Code Mode intervals have no exact hook bijection" + ); + + rollout.completed_tool_calls.remove("call_Overlap"); + rollout.root_cli_version = "0.147.0".to_string(); + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + None, + "an unaudited Code Mode release cannot reuse older correlation rules" + ); + } + + #[test] + fn herdr_completion_reconciliation_preserves_exact_subagent_projection() { + let now_ms = 100_000; + let stale_id = "call_Stale".to_string(); + let mut active = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::from(["child-1".to_string()]), + provisional: HashSet::new(), + root: HookRootCandidate::ToolOpen(HashSet::from([stale_id.clone()])), + }, + now_ms, + ); + active + .tool_opened_at_ms + .insert(stale_id.clone(), now_ms - 1_000); + let mut rollout = active_root_rollout(now_ms); + rollout.completed_tool_calls.insert( + stale_id.clone(), + CompletedRolloutCall { + started_at_ms: now_ms - 1_100, + completed_at_ms: now_ms - 900, + class: RolloutToolClass::Ordinary, + code_mode_terminal: false, + }, + ); + rollout + .descendants + .push(active_child_rollout("child-1", true, now_ms)); + assert_eq!( + project_herdr_working_status(&active, Some(&rollout), now_ms), + Some(HerdrWorkingProjection { + status: SessionStatus::Executing, + status_since_ms: now_ms - 1_000, + consecutive_matching: 0, + }) + ); + + let mut stopped = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::new(), + provisional: HashSet::from(["child-1".to_string()]), + root: HookRootCandidate::ToolOpen(HashSet::from([stale_id.clone()])), + }, + now_ms, + ); + stopped.tool_opened_at_ms.insert(stale_id, now_ms - 1_000); + rollout.descendants = vec![terminal_child_rollout("child-1", true, now_ms)]; + assert_eq!( + project_herdr_working_status(&stopped, Some(&rollout), now_ms), + Some(HerdrWorkingProjection { + status: SessionStatus::Thinking, + status_since_ms: now_ms - 1_000, + consecutive_matching: 0, + }) + ); + + let current_id = "call_Current".to_string(); + let terminal_tool = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::new(), + provisional: HashSet::from(["child-1".to_string()]), + root: HookRootCandidate::ToolOpen(HashSet::from([ + "call_Stale".to_string(), + current_id.clone(), + ])), + }, + now_ms, + ); + rollout.latest_lifecycle_at_ms = now_ms - 100; + rollout.open_tool_ids = HashSet::from([current_id.clone()]); + rollout + .open_tool_started_at_ms + .insert(current_id.clone(), now_ms - 250); + rollout + .open_tool_classes + .insert(current_id, RolloutToolClass::Ordinary); + assert_eq!( + project_herdr_working_status(&terminal_tool, Some(&rollout), now_ms), + Some(HerdrWorkingProjection { + status: SessionStatus::Executing, + status_since_ms: now_ms - 250, + consecutive_matching: 0, + }) + ); + + rollout.descendants.clear(); + assert_eq!( + project_herdr_working_status(&terminal_tool, Some(&rollout), now_ms), + None, + "missing provisional descendant completion remains unavailable" + ); + + stopped + .tool_classes + .insert("unexpected".to_string(), HookToolClass::Ordinary); + assert_eq!( + project_herdr_working_status(&stopped, Some(&rollout), now_ms), + None, + "malformed hook maps remain unavailable" + ); + } + + #[test] + fn herdr_working_rejects_interaction_and_parallel_tool_ambiguity() { + let now_ms = 100_000; + let hook_id = "exec-f842a710-1234-4abc-8def-000000000000".to_string(); + let record = hook_record(HookCandidate::ToolOpen(HashSet::from([hook_id])), now_ms); + let rollout_id = "call_exec".to_string(); + let mut rollout = active_root_rollout(now_ms); + rollout.open_tool_ids = HashSet::from([rollout_id.clone()]); + rollout + .open_tool_started_at_ms + .insert(rollout_id.clone(), now_ms - 900); + rollout + .open_tool_classes + .insert(rollout_id.clone(), RolloutToolClass::RequestUserInput); + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + None, + "an open interaction is never execution" + ); + + rollout.open_tool_classes.insert( + rollout_id, + RolloutToolClass::CodeModeExec { + exec_started_at_ms: now_ms - 900, + }, + ); + rollout.open_tool_ids.insert("call_parallel".to_string()); + rollout + .open_tool_started_at_ms + .insert("call_parallel".to_string(), now_ms - 800); + rollout + .open_tool_classes + .insert("call_parallel".to_string(), RolloutToolClass::Ordinary); + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + None, + "parallel provider calls have no cross-namespace bijection" + ); + + rollout.open_tool_ids.remove("call_parallel"); + rollout.open_tool_started_at_ms.remove("call_parallel"); + rollout.open_tool_classes.remove("call_parallel"); + rollout.open_tool_classes.clear(); + assert_eq!( + project_herdr_working_status(&record, Some(&rollout), now_ms), + None, + "missing content-free classes fail closed" + ); + } + + #[test] + fn herdr_working_keeps_direct_ids_and_rejects_unreviewed_code_mode_shapes() { + let now_ms = 100_000; + let direct_id = "call_direct".to_string(); + let direct = hook_record( + HookCandidate::ToolOpen(HashSet::from([direct_id.clone()])), + now_ms, + ); + let mut rollout = active_root_rollout(now_ms); + rollout.open_tool_ids = HashSet::from([direct_id.clone()]); + rollout + .open_tool_started_at_ms + .insert(direct_id.clone(), now_ms - 900); + rollout + .open_tool_classes + .insert(direct_id, RolloutToolClass::Ordinary); + assert!(project_herdr_working_status(&direct, Some(&rollout), now_ms).is_some()); + + let code_mode = hook_record( + HookCandidate::ToolOpen(HashSet::from([ + "exec-f842a710-1234-4abc-8def-000000000000".to_string() + ])), + now_ms, + ); + rollout.root_cli_version = "0.147.0".to_string(); + rollout.open_tool_ids = HashSet::from(["call_outer".to_string()]); + rollout.open_tool_started_at_ms = HashMap::from([("call_outer".to_string(), now_ms - 900)]); + rollout.open_tool_classes = HashMap::from([( + "call_outer".to_string(), + RolloutToolClass::CodeModeExec { + exec_started_at_ms: now_ms - 900, + }, + )]); + assert_eq!( + project_herdr_working_status(&code_mode, Some(&rollout), now_ms), + None, + "future Code Mode shapes require an explicit source audit" + ); + + rollout.root_cli_version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let malformed = hook_record( + HookCandidate::ToolOpen(HashSet::from(["exec-not-a-uuid".to_string()])), + now_ms, + ); + assert_eq!( + project_herdr_working_status(&malformed, Some(&rollout), now_ms), + None, + "only the exact nested Code Mode UUID shape crosses ID namespaces" + ); + } + + #[test] + fn hook_conversion_preserves_content_free_open_tool_classes() { + let now_ms = 100_000; + let mut state = production_turn_open_hook_state(now_ms); + state + .open_tools + .insert("exec-f842".to_string(), HookToolClass::Ordinary); + state + .tool_opened_at_ms + .insert("exec-f842".to_string(), now_ms - 500); + state.last_event = HookEventKind::PreToolUse; + state.updated_at_ms = now_ms; + + let record = hook_record_from_state(state); + assert_eq!( + record.tool_classes, + HashMap::from([("exec-f842".to_string(), HookToolClass::Ordinary)]) + ); + } + + #[test] + fn herdr_overlay_maps_attention_idle_and_coarse_work_without_actions() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let record = hook_record(HookCandidate::TurnOpen, now_ms); + let observation = |status| HerdrObservation { + status, + observed_at_ms: now_ms, + status_since_ms: now_ms - 5_000, + consecutive_matching: 3, + }; + + let mut blocked = collector.hook_placeholder(&record); + blocked.pid = record.pid; + apply_herdr_observation(&mut blocked, observation(HerdrStatus::Blocked), None, None); + assert_eq!(blocked.status, SessionStatus::Waiting); + assert_eq!( + blocked.status_evidence.reason, + StatusReason::HerdrScreenBlocked + ); + assert!(blocked.awaiting_input); + assert!(blocked.action_process_incarnation.is_none()); + + let mut idle = collector.hook_placeholder(&record); + idle.pid = record.pid; + apply_herdr_observation(&mut idle, observation(HerdrStatus::Idle), None, None); + assert_eq!(idle.status, SessionStatus::Idle); + assert_eq!(idle.status_evidence.status_since_ms, now_ms - 5_000); + assert_eq!(idle.status_evidence.consecutive_matching, 3); + assert!(!idle.awaiting_input); + assert!(idle.action_process_incarnation.is_none()); + + let mut unrefined = collector.hook_placeholder(&record); + unrefined.pid = record.pid; + apply_herdr_observation( + &mut unrefined, + observation(HerdrStatus::Working), + None, + None, + ); + assert_eq!(unrefined.status, SessionStatus::Working); + assert_eq!( + unrefined.status_evidence.authority, + StatusAuthority::Heuristic + ); + assert_eq!( + unrefined.status_evidence.reason, + StatusReason::HerdrWorkingUnrefined + ); + assert!(unrefined.action_process_incarnation.is_none()); + } + + #[test] + fn herdr_selects_the_current_same_process_generation_and_tracks_its_phase() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let mut current = hook_record( + HookCandidate::ToolOpen(HashSet::from([ + "exec-f842a710-1234-4abc-8def-000000000000".to_string() + ])), + now_ms, + ); + current.session_id = "current-session".to_string(); + current.generation_id = "current-generation".to_string(); + let mut stale = hook_record(HookCandidate::TurnOpen, now_ms); + stale.session_id = "stale-session".to_string(); + stale.generation_id = "stale-generation".to_string(); + stale.started_at_ms = now_ms.saturating_sub(20_000); + let stale_key = hook_done_key(&stale).unwrap(); + let current_key = hook_done_key(¤t).unwrap(); + + let mut rollout = active_root_rollout(now_ms); + rollout.open_tool_ids = HashSet::from(["call_qOS".to_string()]); + rollout + .open_tool_started_at_ms + .insert("call_qOS".to_string(), now_ms - 1_042); + rollout.open_tool_classes.insert( + "call_qOS".to_string(), + RolloutToolClass::CodeModeExec { + exec_started_at_ms: now_ms - 1_042, + }, + ); + collector + .rollout_lifecycle + .borrow_mut() + .insert(current.session_id.clone(), rollout); + let mut session = collector.hook_placeholder(¤t); + session.pid = 0; + session.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let mut stale_session = collector.hook_placeholder(&stale); + stale_session.pid = 0; + stale_session.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let working = HerdrObservation { + status: HerdrStatus::Working, + observed_at_ms: now_ms, + status_since_ms: now_ms - 2_000, + consecutive_matching: 2, + }; + let observations = HashMap::from([((current.session_id.clone(), current.pid), working)]); + + let executing = collector.finalize_hook_records_with_herdr( + vec![stale_session, session], + vec![stale.clone(), current.clone()], + &hook_shared(), + now_ms, + observations.clone(), + ); + assert_eq!(executing.len(), 1); + assert_eq!(executing[0].session_id, "current-session"); + assert_eq!(executing[0].pid, 42); + assert_eq!(executing[0].status, SessionStatus::Executing); + assert_eq!( + executing[0].status_evidence.authority, + StatusAuthority::Heuristic + ); + assert_eq!( + executing[0].status_evidence.reason, + StatusReason::HerdrScreenWorking + ); + assert_eq!(executing[0].status_evidence.status_since_ms, now_ms - 1_000); + assert_eq!(executing[0].status_evidence.consecutive_matching, 1); + assert_eq!(executing[0].pending_since_ms, now_ms - 1_000); + assert!(executing[0].action_process_incarnation.is_none()); + assert!(!collector + .hook_process_states + .borrow() + .contains_key(&stale_key)); + assert!(!collector + .hook_process_rollout_bindings + .borrow() + .contains_key(¤t_key)); + + let mut stale_gone = stale.clone(); + let mut thinking = hook_record(HookCandidate::TurnOpen, now_ms + 1_000); + thinking.session_id = "current-session".to_string(); + thinking.generation_id = "current-generation".to_string(); + let mut stale_next = stale; + stale_next.observed_at_ms = now_ms + 1_000; + stale_next.status_since_ms = now_ms; + let mut thinking_continued = thinking.clone(); + let mut stale_continued = stale_next.clone(); + collector.rollout_lifecycle.borrow_mut().insert( + thinking.session_id.clone(), + active_root_rollout(now_ms + 1_000), + ); + let mut session = collector.hook_placeholder(&thinking); + session.pid = 0; + session.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let mut stale_session = collector.hook_placeholder(&stale_next); + stale_session.pid = 0; + stale_session.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let observations = HashMap::from([( + (thinking.session_id.clone(), thinking.pid), + HerdrObservation { + observed_at_ms: now_ms + 1_000, + ..working + }, + )]); + let thinking = collector.finalize_hook_records_with_herdr( + vec![stale_session, session], + vec![stale_next, thinking], + &hook_shared(), + now_ms + 1_000, + observations, + ); + assert_eq!(thinking.len(), 1); + assert_eq!(thinking[0].status, SessionStatus::Thinking); + assert_eq!( + thinking[0].status_evidence.reason, + StatusReason::HerdrScreenWorking + ); + assert_eq!(thinking[0].status_evidence.status_since_ms, now_ms); + assert_eq!(thinking[0].status_evidence.consecutive_matching, 1); + assert_eq!(thinking[0].thinking_since_ms, now_ms); + assert!(thinking[0].action_process_incarnation.is_none()); + + thinking_continued.observed_at_ms = now_ms + 2_000; + stale_continued.observed_at_ms = now_ms + 2_000; + collector.rollout_lifecycle.borrow_mut().insert( + thinking_continued.session_id.clone(), + active_root_rollout(now_ms + 2_000), + ); + let mut session = collector.hook_placeholder(&thinking_continued); + session.pid = 0; + session.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let mut stale_session = collector.hook_placeholder(&stale_continued); + stale_session.pid = 0; + stale_session.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let observations = HashMap::from([( + ( + thinking_continued.session_id.clone(), + thinking_continued.pid, + ), + HerdrObservation { + observed_at_ms: now_ms + 2_000, + consecutive_matching: 4, + ..working + }, + )]); + let continued = collector.finalize_hook_records_with_herdr( + vec![stale_session, session], + vec![stale_continued, thinking_continued], + &hook_shared(), + now_ms + 2_000, + observations, + ); + assert_eq!(continued.len(), 1); + assert_eq!(continued[0].status, SessionStatus::Thinking); + assert_eq!(continued[0].status_evidence.status_since_ms, now_ms); + assert_eq!(continued[0].status_evidence.consecutive_matching, 2); + + stale_gone.candidate = HookCandidate::Ended; + stale_gone.process_state = HookProcessState::Gone; + stale_gone.native_process_verified = false; + stale_gone.observed_at_ms = now_ms + 3_000; + stale_gone.ended_at_ms = now_ms + 2_500; + stale_gone.exit_observed_at_ms = now_ms + 2_500; + stale_gone.exit_supported_rollout_correlated = false; + let exited = collector.finalize_hook_records_with_herdr( + Vec::new(), + vec![stale_gone], + &hook_shared(), + now_ms + 3_000, + HashMap::new(), + ); + assert!(exited.is_empty()); + assert!(!collector + .hook_exit_observations + .borrow() + .contains_key(&stale_key)); + assert!(!collector + .hook_done_tombstones + .borrow() + .contains_key(&stale_key)); + } + + #[test] + fn herdr_working_preserves_fault_evidence_while_reporting_coarse_work() { + let now_ms = 100_000; + for (index, reason) in [StatusReason::HookStateMalformed, StatusReason::HookEventGap] + .into_iter() + .enumerate() + { + let collector = CodexCollector::new(); + let tool_ids = HashSet::from(["tool-1".to_string()]); + let mut record = hook_record(HookCandidate::Unknown(reason), now_ms); + record.session_id = format!("unavailable-session-{index}"); + record.generation_id = format!("unavailable-generation-{index}"); + + let mut rollout = active_root_rollout(now_ms); + rollout.open_tool_ids = tool_ids; + rollout + .open_tool_started_at_ms + .insert("tool-1".to_string(), now_ms - 900); + collector + .rollout_lifecycle + .borrow_mut() + .insert(record.session_id.clone(), rollout); + + let mut session = collector.hook_placeholder(&record); + session.pid = record.pid; + session.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let observations = HashMap::from([( + (record.session_id.clone(), record.pid), + HerdrObservation { + status: HerdrStatus::Working, + observed_at_ms: now_ms, + status_since_ms: now_ms - 2_000, + consecutive_matching: 2, + }, + )]); + + let sessions = collector.finalize_hook_records_with_herdr( + vec![session], + vec![record], + &hook_shared(), + now_ms, + observations, + ); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].status, SessionStatus::Working); + assert_eq!( + sessions[0].status_evidence.authority, + StatusAuthority::Heuristic + ); + assert_eq!( + sessions[0].status_evidence.reason, + StatusReason::HerdrWorkingUnrefined + ); + assert!(sessions[0] + .status_evidence + .observations + .iter() + .any(|observation| observation.reason == reason)); + assert!(sessions[0].action_process_incarnation.is_none()); + } + } + + #[test] + fn herdr_working_reports_coarse_activity_without_a_current_hook_record() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let record = hook_record(HookCandidate::TurnOpen, now_ms); + let mut rollout_only = collector.hook_placeholder(&record); + rollout_only.pid = record.pid; + rollout_only.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let observations = HashMap::from([( + (record.session_id.clone(), record.pid), + HerdrObservation { + status: HerdrStatus::Working, + observed_at_ms: now_ms, + status_since_ms: now_ms - 2_000, + consecutive_matching: 2, + }, + )]); + + let sessions = collector.finalize_hook_records_with_herdr( + vec![rollout_only], + Vec::new(), + &hook_shared(), + now_ms, + observations, + ); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].status, SessionStatus::Working); + assert_eq!( + sessions[0].status_evidence.authority, + StatusAuthority::Heuristic + ); + assert_eq!( + sessions[0].status_evidence.reason, + StatusReason::HerdrWorkingUnrefined + ); + assert!(sessions[0] + .status_evidence + .observations + .iter() + .any(|sample| { + sample.reason == StatusReason::HookIntegrationUnverified + && sample.authority == StatusAuthority::Unavailable + })); + assert_eq!(sessions[0].current_tasks, vec!["working"]); + assert!(sessions[0].action_process_incarnation.is_none()); + } + + #[test] + fn pidless_rollout_recovers_herdr_status_without_actions_or_exit_proof() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let tool_ids = HashSet::from(["tool-1".to_string()]); + let record = hook_record(HookCandidate::ToolOpen(tool_ids.clone()), now_ms); + let binding_key = hook_done_key(&record).unwrap(); + let mut rollout = active_root_rollout(now_ms); + rollout.open_tool_ids = tool_ids; + rollout + .open_tool_started_at_ms + .insert("tool-1".to_string(), now_ms - 900); + rollout + .open_tool_classes + .insert("tool-1".to_string(), RolloutToolClass::Ordinary); + collector + .rollout_lifecycle + .borrow_mut() + .insert(record.session_id.clone(), rollout); + let mut pidless = collector.hook_placeholder(&record); + pidless.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let observations = HashMap::from([( + (record.session_id.clone(), record.pid), + HerdrObservation { + status: HerdrStatus::Working, + observed_at_ms: now_ms, + status_since_ms: now_ms - 2_000, + consecutive_matching: 2, + }, + )]); + + let sessions = collector.finalize_hook_records_with_herdr( + vec![pidless], + vec![record], + &hook_shared(), + now_ms, + observations, + ); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].pid, 42); + assert_eq!(sessions[0].status, SessionStatus::Executing); + assert!(sessions[0].action_process_incarnation.is_none()); + assert!(!collector + .hook_process_rollout_bindings + .borrow() + .contains_key(&binding_key)); + } + + #[test] + fn current_generation_suppression_is_scoped_to_the_stale_process_claim() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let mut current = hook_record(HookCandidate::TurnOpen, now_ms); + current.session_id = "current-session".to_string(); + current.generation_id = "current-generation".to_string(); + let mut stale = hook_record(HookCandidate::TurnOpen, now_ms); + stale.session_id = "reused-session-id".to_string(); + stale.generation_id = "stale-generation".to_string(); + + let current_session = collector.hook_placeholder(¤t); + let stale_pidless = collector.hook_placeholder(&stale); + let mut independent = collector.hook_placeholder(&stale); + independent.pid = 99; + let mut sessions = vec![stale_pidless, independent, current_session]; + let mut records = vec![stale, current.clone()]; + let observations = HashMap::from([( + (current.session_id.clone(), current.pid), + HerdrObservation { + status: HerdrStatus::Working, + observed_at_ms: now_ms, + status_since_ms: now_ms, + consecutive_matching: 1, + }, + )]); + let shared = hook_shared(); + + reconcile_current_hook_generations( + &mut records, + &mut sessions, + &HashMap::new(), + &observations, + &shared.process_info, + &HashSet::from([current.pid]), + ); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].session_id, "current-session"); + assert!(sessions + .iter() + .any(|session| session.session_id == "current-session" && session.pid == 0)); + assert!(sessions + .iter() + .any(|session| session.session_id == "reused-session-id" && session.pid == 99)); + assert!(!sessions + .iter() + .any(|session| session.session_id == "reused-session-id" && session.pid == 0)); + } + + #[test] + fn rollout_selects_only_the_current_generation_and_only_it_can_transition_to_done() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let mut current = hook_record(HookCandidate::TurnOpen, now_ms); + current.session_id = "current-session".to_string(); + current.generation_id = "current-generation".to_string(); + current.effective_hook_engine_attested = false; + let mut stale = hook_record(HookCandidate::TurnOpen, now_ms); + stale.session_id = "stale-session".to_string(); + stale.generation_id = "stale-generation".to_string(); + stale.started_at_ms = now_ms - 20_000; + stale.effective_hook_engine_attested = false; + let current_key = hook_done_key(¤t).unwrap(); + let stale_key = hook_done_key(&stale).unwrap(); + + collector + .rollout_lifecycle + .borrow_mut() + .insert(current.session_id.clone(), active_root_rollout(now_ms)); + let mut current_session = collector.hook_placeholder(¤t); + current_session.pid = current.pid; + current_session.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + current_session.mem_mb = 777; + current_session.children.push(ChildProcess { + pid: 77, + command: "codex-child".to_string(), + mem_kb: 1_024, + port: None, + }); + let stale_session = collector.hook_placeholder(&stale); + + let live = collector.finalize_hook_records_with_herdr( + vec![stale_session, current_session], + vec![stale.clone(), current.clone()], + &hook_shared(), + now_ms, + HashMap::new(), + ); + assert_eq!(live.len(), 1); + assert_eq!(live[0].session_id, "current-session"); + assert_eq!(live[0].status, SessionStatus::Thinking); + assert_eq!(live[0].status_evidence.reason, StatusReason::HookTurnOpen); + assert!(live[0].action_process_incarnation.is_none()); + assert_eq!(live[0].mem_mb, 0); + assert!(live[0].children.is_empty()); + assert!(collector + .hook_process_rollout_bindings + .borrow() + .contains_key(¤t_key)); + assert!(!collector + .hook_process_rollout_bindings + .borrow() + .contains_key(&stale_key)); + + let mut current_gone = current; + current_gone.process_state = HookProcessState::Gone; + current_gone.native_process_verified = false; + current_gone.observed_at_ms = now_ms + 1_000; + let mut stale_gone = stale; + stale_gone.process_state = HookProcessState::Gone; + stale_gone.native_process_verified = false; + stale_gone.observed_at_ms = now_ms + 1_000; + let exited = collector.finalize_hook_records_with_herdr( + Vec::new(), + vec![stale_gone, current_gone], + &hook_shared(), + now_ms + 1_000, + HashMap::new(), + ); + let current = exited + .iter() + .find(|session| session.session_id == "current-session") + .expect("the selected generation should retain its exact exit row"); + assert_eq!(current.status, SessionStatus::Done); + assert_eq!(current.status_evidence.reason, StatusReason::ProcessExited); + assert!(exited.iter().all(|session| { + session.session_id != "stale-session" || session.status != SessionStatus::Done + })); + assert!(!collector + .hook_done_tombstones + .borrow() + .contains_key(&stale_key)); + } + + #[test] + fn disagreeing_rollout_and_herdr_current_sessions_remain_unknown() { + let now_ms = 100_000; + for status in [ + HerdrStatus::Working, + HerdrStatus::Blocked, + HerdrStatus::Idle, + ] { + let collector = CodexCollector::new(); + let mut rollout_selected = hook_record(HookCandidate::TurnOpen, now_ms); + rollout_selected.session_id = "rollout-session".to_string(); + rollout_selected.generation_id = "rollout-generation".to_string(); + let mut herdr_selected = hook_record(HookCandidate::TurnOpen, now_ms); + herdr_selected.session_id = "herdr-session".to_string(); + herdr_selected.generation_id = "herdr-generation".to_string(); + collector.rollout_lifecycle.borrow_mut().insert( + rollout_selected.session_id.clone(), + active_root_rollout(now_ms), + ); + let mut session = collector.hook_placeholder(&rollout_selected); + session.pid = rollout_selected.pid; + session.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let observations = HashMap::from([( + (herdr_selected.session_id.clone(), herdr_selected.pid), + HerdrObservation { + status, + observed_at_ms: now_ms, + status_since_ms: now_ms - 2_000, + consecutive_matching: 2, + }, + )]); + + let sessions = collector.finalize_hook_records_with_herdr( + vec![session], + vec![rollout_selected, herdr_selected], + &hook_shared(), + now_ms, + observations, + ); + assert_eq!(sessions.len(), 2); + assert!(sessions.iter().all(|session| { + session.status == SessionStatus::Unknown + && session.status_evidence.reason == StatusReason::OwnershipUnconfirmed + && session.action_process_incarnation.is_none() + })); + } + } + + #[test] + fn child_version_cannot_supply_or_contradict_the_root_release() { + let now_ms = 100_000; + let record = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::from(["child-1".to_string()]), + provisional: HashSet::new(), + root: HookRootCandidate::TurnOpen, + }, + now_ms, + ); + let mut child = active_child_rollout("child-1", true, now_ms); + + let mut missing_root = active_root_rollout(now_ms); + missing_root.root_cli_version.clear(); + missing_root.descendants.push(child.clone()); + assert_eq!( + project_hook_status(&record, Some(&missing_root), now_ms).2, + StatusReason::HookIntegrationUnverified, + "a supported child must not supply missing root version evidence" + ); + + child.cli_version = "0.146.1".to_string(); + let mut mismatched_child = active_root_rollout(now_ms); + mismatched_child.descendants.push(child); + assert_eq!( + project_hook_status(&record, Some(&mismatched_child), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookIntegrationUnverified, + ), + "a child from a different release invalidates child lifecycle proof" + ); + } + + #[test] + fn subagent_exec_requires_the_exact_active_direct_child_set() { + let now_ms = 100_000; + let record = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::from(["child-1".to_string()]), + provisional: HashSet::new(), + root: HookRootCandidate::TurnOpen, + }, + now_ms, + ); + + let mut exact = active_root_rollout(now_ms); + exact + .descendants + .push(active_child_rollout("child-1", true, now_ms)); + assert_eq!( + project_hook_status(&record, Some(&exact), now_ms).0, + SessionStatus::Executing + ); + assert_eq!( + project_hook_status(&record, None, now_ms).0, + SessionStatus::Unknown, + "a hook child without a rollout is not execution proof" + ); + + let mut mismatched = active_root_rollout(now_ms); + mismatched + .descendants + .push(active_child_rollout("child-2", true, now_ms)); + assert_eq!( + project_hook_status(&record, Some(&mismatched), now_ms).0, + SessionStatus::Unknown + ); + + let mut extra = exact.clone(); + extra + .descendants + .push(active_child_rollout("child-2", true, now_ms)); + assert_eq!( + project_hook_status(&record, Some(&extra), now_ms).0, + SessionStatus::Unknown + ); + + let mut uncovered_terminal = exact.clone(); + uncovered_terminal + .descendants + .push(terminal_child_rollout("child-2", true, now_ms)); + assert_eq!( + project_hook_status(&record, Some(&uncovered_terminal), now_ms).0, + SessionStatus::Unknown, + "an uncovered terminal direct child invalidates the complete hook set" + ); + + let mut nested = active_root_rollout(now_ms); + nested + .descendants + .push(active_child_rollout("child-1", false, now_ms)); + assert_eq!( + project_hook_status(&record, Some(&nested), now_ms).0, + SessionStatus::Unknown + ); + + let mut direct_with_terminal_nested = exact.clone(); + direct_with_terminal_nested + .descendants + .push(terminal_child_rollout("nested-terminal", false, now_ms)); + assert_eq!( + project_hook_status(&record, Some(&direct_with_terminal_nested), now_ms).0, + SessionStatus::Unknown, + "a flat hook child set cannot prove a non-direct subagent execution tree" + ); + + let mut terminal = active_root_rollout(now_ms); + terminal + .descendants + .push(terminal_child_rollout("child-1", true, now_ms)); + assert_eq!( + project_hook_status(&record, Some(&terminal), now_ms).0, + SessionStatus::Unknown, + "a terminal rollout cannot satisfy an active hook child" + ); + } + + #[test] + fn active_child_cannot_hide_an_unknown_or_ended_root_candidate() { + let now_ms = 100_000; + let mut rollout = active_root_rollout(now_ms); + rollout + .descendants + .push(active_child_rollout("child-1", true, now_ms)); + + let unknown = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::from(["child-1".to_string()]), + provisional: HashSet::new(), + root: HookRootCandidate::Unknown(StatusReason::HookConfigChanged), + }, + now_ms, + ); + assert_eq!( + project_hook_status(&unknown, Some(&rollout), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookConfigChanged, + ) + ); + + let ended = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::from(["child-1".to_string()]), + provisional: HashSet::new(), + root: HookRootCandidate::Ended, + }, + now_ms, + ); + assert_eq!( + project_hook_status(&ended, Some(&rollout), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookEventGap, + ) + ); + } + + #[test] + fn exact_terminal_nested_descendants_allow_root_thinking_and_idle() { + let now_ms = 100_000; + let mut thinking_rollout = active_root_rollout(now_ms); + thinking_rollout + .descendants + .push(terminal_child_rollout("nested-terminal", false, now_ms)); + assert_eq!( + project_hook_status( + &hook_record(HookCandidate::TurnOpen, now_ms), + Some(&thinking_rollout), + now_ms, + ), + ( + SessionStatus::Thinking, + StatusAuthority::Heuristic, + StatusReason::HookTurnOpen, + ) + ); + + let mut idle_rollout = completed_root_rollout(now_ms); + idle_rollout + .descendants + .push(terminal_child_rollout("nested-terminal", false, now_ms)); + assert_eq!( + project_hook_status( + &hook_record(HookCandidate::TurnStopped, now_ms), + Some(&idle_rollout), + now_ms, + ), + ( + SessionStatus::Idle, + StatusAuthority::Heuristic, + StatusReason::HookTurnComplete, + ) + ); + } + + #[test] + fn provisional_subagent_stop_needs_exact_later_child_evidence() { + let now_ms = 100_000; + let record = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::new(), + provisional: HashSet::from(["child-1".to_string()]), + root: HookRootCandidate::TurnStopped, + }, + now_ms, + ); + + let mut continued = completed_root_rollout(now_ms); + continued + .descendants + .push(active_child_rollout("child-1", true, now_ms)); + assert_eq!( + project_hook_status(&record, Some(&continued), now_ms).0, + SessionStatus::Executing, + "same-child activity after provisional SubagentStop remains work" + ); + + let mut closed = completed_root_rollout(now_ms); + closed + .descendants + .push(terminal_child_rollout("child-1", true, now_ms)); + assert_eq!( + project_hook_status(&record, Some(&closed), now_ms).0, + SessionStatus::Idle + ); + + let mut aborted = completed_root_rollout(now_ms); + let mut child = terminal_child_rollout("child-1", true, now_ms); + child.lifecycle_valid = false; + child.task_complete = false; + aborted.descendants.push(child); + assert_eq!( + project_hook_status(&record, Some(&aborted), now_ms).0, + SessionStatus::Unknown + ); + } + + #[test] + fn root_open_tool_keeps_provisional_child_work_unknown() { + let now_ms = 100_000; + let record = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::new(), + provisional: HashSet::from(["child-1".to_string()]), + root: HookRootCandidate::ToolOpen(HashSet::from(["close-agent-call".to_string()])), + }, + now_ms, + ); + let mut continuing = active_root_rollout(now_ms); + continuing + .descendants + .push(active_child_rollout("child-1", true, now_ms)); + assert_eq!( + project_hook_status(&record, Some(&continuing), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookInteractionResolutionUnavailable, + ), + "a possibly blocked root interaction wins over active child work" + ); + + let mut incomplete = record.clone(); + incomplete.subagent_set_complete = false; + assert_eq!( + project_hook_status(&incomplete, Some(&continuing), now_ms).2, + StatusReason::HookInteractionResolutionUnavailable, + "root tool ambiguity wins before incomplete child-set projection" + ); + + let mut terminal = active_root_rollout(now_ms); + terminal + .descendants + .push(terminal_child_rollout("child-1", true, now_ms)); + assert_eq!( + project_hook_status(&record, Some(&terminal), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookInteractionResolutionUnavailable, + ), + "the root close_agent tool remains approval-ambiguous" + ); + } + + #[test] + fn root_rollout_open_tool_keeps_active_child_work_unknown() { + let now_ms = 100_000; + let record = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::from(["child-1".to_string()]), + provisional: HashSet::new(), + root: HookRootCandidate::TurnOpen, + }, + now_ms, + ); + let mut rollout = active_root_rollout(now_ms); + rollout + .descendants + .push(active_child_rollout("child-1", true, now_ms)); + rollout.open_tool_ids = HashSet::from(["root-call".to_string()]); + rollout.open_tool_started_at_ms = + HashMap::from([("root-call".to_string(), now_ms.saturating_sub(500))]); + assert_eq!( + project_hook_status(&record, Some(&rollout), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookInteractionResolutionUnavailable, + ), + "an unhooked root rollout call may still be waiting for approval" + ); + + rollout.open_tool_ids.clear(); + assert_eq!( + project_hook_status(&record, Some(&rollout), now_ms).2, + StatusReason::HookInteractionResolutionUnavailable, + "orphaned root call timestamps cannot be hidden by child execution" + ); + + let stopped_record = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::new(), + provisional: HashSet::from(["child-1".to_string()]), + root: HookRootCandidate::TurnStopped, + }, + now_ms, + ); + let mut stopped_rollout = completed_root_rollout(now_ms); + stopped_rollout + .descendants + .push(active_child_rollout("child-1", true, now_ms)); + stopped_rollout.open_tool_ids = HashSet::from(["root-call".to_string()]); + stopped_rollout.open_tool_started_at_ms = + HashMap::from([("root-call".to_string(), now_ms.saturating_sub(500))]); + assert_eq!( + project_hook_status(&stopped_record, Some(&stopped_rollout), now_ms).2, + StatusReason::HookInteractionResolutionUnavailable, + "a provisional root stop cannot override an open root rollout call" + ); + } + + #[test] + fn child_open_tool_is_unknown_because_approval_coverage_is_unobservable() { + let now_ms = 100_000; + let record = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::from(["child-1".to_string()]), + provisional: HashSet::new(), + root: HookRootCandidate::TurnOpen, + }, + now_ms, + ); + let mut rollout = active_root_rollout(now_ms); + let mut child = active_child_rollout("child-1", true, now_ms); + child.open_tool_ids = HashSet::from(["child-call".to_string()]); + child.open_tool_started_at_ms = + HashMap::from([("child-call".to_string(), now_ms.saturating_sub(500))]); + child.open_tool_classes = + HashMap::from([("child-call".to_string(), RolloutToolClass::Ordinary)]); + rollout.descendants.push(child); + assert_eq!( + project_hook_status(&record, Some(&rollout), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookInteractionResolutionUnavailable, + ) + ); + } + + #[test] + fn production_child_pre_tool_ambiguity_cannot_promote_execution() { + let now_ms = 100_000; + let mut state = production_turn_open_hook_state(now_ms); + state.open_subagents.insert("child-1".to_string()); + state + .subagent_opened_at_ms + .insert("child-1".to_string(), now_ms.saturating_sub(800)); + state + .open_child_tools + .insert("child-call".to_string(), "child-1".to_string()); + state + .child_tool_opened_at_ms + .insert("child-call".to_string(), now_ms.saturating_sub(500)); + let mut record = hook_record_from_state(state); + assert!(matches!( + record.candidate, + HookCandidate::Unknown(StatusReason::HookToolOpen) + )); + + // Even a hypothetical future effective-engine attestation cannot turn + // the explicit child interaction ambiguity into execution. + record.process_state = HookProcessState::Live; + record.native_process_verified = true; + record.supported_release_attested = true; + record.effective_hook_engine_attested = true; + let mut rollout = active_root_rollout(now_ms); + rollout + .descendants + .push(active_child_rollout("child-1", true, now_ms)); + assert_eq!( + project_hook_status(&record, Some(&rollout), now_ms), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookToolOpen, + ) + ); + + // The state is still exact process/session evidence. It must stay + // Unknown while live without preventing a later exact process-exit + // tombstone for the same incarnation. + record.effective_hook_engine_attested = false; + let collector = CodexCollector::new(); + collector + .rollout_lifecycle + .borrow_mut() + .insert(record.session_id.clone(), rollout); + let mut session = collector.hook_placeholder(&record); + session.pid = record.pid; + session.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let shared = hook_shared(); + let live = + collector.finalize_hook_records(vec![session], vec![record.clone()], &shared, now_ms); + assert_eq!(live[0].status, SessionStatus::Unknown); + assert_eq!(live[0].status_evidence.reason, StatusReason::HookToolOpen); + + record.process_state = HookProcessState::Gone; + record.native_process_verified = false; + let done = collector.finalize_hook_records(Vec::new(), vec![record], &shared, 101_000); + assert_eq!(done[0].status, SessionStatus::Done); + assert_eq!(done[0].status_evidence.reason, StatusReason::ProcessExited); + } + + #[test] + fn malformed_child_prevents_root_idle() { + let now_ms = 100_000; + let stopped = hook_record(HookCandidate::TurnStopped, now_ms); + let mut rollout = completed_root_rollout(now_ms); + let mut child = terminal_child_rollout("child-1", true, now_ms); + child.lifecycle_valid = false; + rollout.descendants.push(child); + assert_eq!( + project_hook_status(&stopped, Some(&rollout), now_ms).0, + SessionStatus::Unknown + ); + } + + #[test] + fn live_to_gone_transition_is_required_for_done() { + let collector = CodexCollector::new(); + let mut live = vec![hook_record(HookCandidate::TurnOpen, 100_000)]; + collector.observe_hook_process_transitions(&mut live, 100_000, true); + assert_eq!(live[0].exit_observed_at_ms, 0); + let live_key = hook_done_key(&live[0]).unwrap(); + collector.hook_process_rollout_bindings.borrow_mut().insert( + live_key, + HookProcessRolloutBinding { + session_id: "hook-session".to_string(), + supported_release: true, + }, + ); + + let mut gone = live.clone(); + gone[0].process_state = HookProcessState::Gone; + gone[0].native_process_verified = false; + collector.observe_hook_process_transitions(&mut gone, 101_000, true); + assert!(matches!(gone[0].candidate, HookCandidate::Ended)); + assert_eq!(gone[0].exit_observed_at_ms, 101_000); + + let fresh_collector = CodexCollector::new(); + let mut already_gone = vec![gone[0].clone()]; + already_gone[0].exit_observed_at_ms = 0; + already_gone[0].candidate = HookCandidate::Ended; + fresh_collector.observe_hook_process_transitions(&mut already_gone, 101_000, true); + assert_eq!(already_gone[0].exit_observed_at_ms, 0); + } + + #[test] + fn unsupported_release_binding_cannot_become_done() { + let collector = CodexCollector::new(); + let mut live = vec![hook_record(HookCandidate::TurnOpen, 100_000)]; + collector.observe_hook_process_transitions(&mut live, 100_000, true); + let live_key = hook_done_key(&live[0]).unwrap(); + collector.hook_process_rollout_bindings.borrow_mut().insert( + live_key, + HookProcessRolloutBinding { + session_id: "hook-session".to_string(), + supported_release: false, + }, + ); + + let mut gone = live; + gone[0].process_state = HookProcessState::Gone; + gone[0].native_process_verified = false; + collector.observe_hook_process_transitions(&mut gone, 101_000, true); + assert!(!gone[0].exit_supported_rollout_correlated); + assert!(matches!( + gone[0].candidate, + HookCandidate::Unknown(StatusReason::HookIntegrationUnverified) + )); + assert_eq!( + project_hook_status(&gone[0], None, 101_000), + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::HookIntegrationUnverified, + ) + ); + } + + #[test] + fn done_tombstone_survives_source_disappearance_for_the_full_window() { + let collector = CodexCollector::new(); + let shared = hook_shared(); + let (record, session) = prepared_live_hook( + &collector, + 100_000, + "hook-session", + "generation-one", + "test:codex:42", + plugin::MIN_SUPPORTED_CODEX_VERSION, + ); + let key = hook_done_key(&record).unwrap(); + let live = + collector.finalize_hook_records(vec![session], vec![record.clone()], &shared, 100_000); + assert_eq!(live[0].status, SessionStatus::Thinking); + assert!(collector + .hook_live_session_snapshots + .borrow() + .contains_key(&key)); + + let mut gone = record; + gone.process_state = HookProcessState::Gone; + gone.native_process_verified = false; + let first_done = collector.finalize_hook_records(Vec::new(), vec![gone], &shared, 101_000); + assert_eq!(first_done.len(), 1); + let done = &first_done[0]; + assert_eq!(done.status, SessionStatus::Done); + assert_eq!( + done.pid, 42, + "a tombstone always retains its exact nonzero PID" + ); + assert!(done.action_process_incarnation.is_none()); + assert_eq!(done.total_input_tokens, 123); + assert_eq!(done.total_output_tokens, 45); + assert_eq!(done.total_cache_read, 67); + assert_eq!(done.turn_count, 9); + assert_eq!(done.model, "gpt-test"); + assert_eq!(done.effort, "high"); + assert_eq!(done.current_tasks, vec!["finished"]); + assert!(done.initial_prompt.is_empty()); + assert!(done.first_assistant_text.is_empty()); + assert!(done.chat_messages.is_empty()); + assert!(done.tool_calls.is_empty()); + assert!(done.children.is_empty()); + assert!(done.subagents.is_empty()); + assert!(done.git_branch.is_empty()); + assert_eq!( + done.status_evidence.observed_at_ms, 101_000, + "the exit observation is immutable across the retention window" + ); + assert!(collector.hook_done_tombstones.borrow().contains_key(&key)); + + let vanished = collector.finalize_hook_records(Vec::new(), Vec::new(), &shared, 101_001); + assert_eq!(vanished.len(), 1); + assert_eq!(vanished[0].status, SessionStatus::Done); + assert_eq!(vanished[0].pid, 42); + assert!(vanished[0].action_process_incarnation.is_none()); + + let boundary = collector.finalize_hook_records(Vec::new(), Vec::new(), &shared, 131_000); + assert_eq!( + boundary.len(), + 1, + "Done is visible through exactly 30 seconds" + ); + assert_eq!(boundary[0].status, SessionStatus::Done); + + let expired = collector.finalize_hook_records(Vec::new(), Vec::new(), &shared, 131_001); + assert!(expired.is_empty()); + assert!(collector.hook_done_tombstones.borrow().is_empty()); + } + + #[test] + fn unavailable_scan_preserves_proof_but_never_promotes_live_status() { + let collector = CodexCollector::new(); + let shared = hook_shared(); + let (record, session) = prepared_live_hook( + &collector, + 100_000, + "hook-session", + "generation-one", + "test:codex:42", + plugin::MIN_SUPPORTED_CODEX_VERSION, + ); + let key = hook_done_key(&record).unwrap(); + let fallback_session = session.clone(); + let live = + collector.finalize_hook_records(vec![session], vec![record.clone()], &shared, 100_000); + assert_eq!(live[0].status, SessionStatus::Thinking); + + let unavailable = collector.finalize_hook_records_with_scan( + vec![fallback_session], + Vec::new(), + &shared, + 100_500, + false, + ); + assert_eq!(unavailable[0].status, SessionStatus::Unknown); + assert!(collector.hook_process_states.borrow().contains_key(&key)); + assert!(collector + .hook_process_rollout_bindings + .borrow() + .contains_key(&key)); + assert!(collector + .hook_live_session_snapshots + .borrow() + .contains_key(&key)); + + let mut gone = record; + gone.process_state = HookProcessState::Gone; + gone.native_process_verified = false; + let done = collector.finalize_hook_records(Vec::new(), vec![gone], &shared, 101_000); + assert_eq!(done.len(), 1); + assert_eq!(done[0].status, SessionStatus::Done); + } + + #[test] + fn fresh_already_gone_or_sticky_fault_never_creates_a_tombstone() { + let shared = hook_shared(); + let fresh_collector = CodexCollector::new(); + let mut already_gone = hook_record(HookCandidate::TurnOpen, 100_000); + already_gone.process_state = HookProcessState::Gone; + already_gone.native_process_verified = false; + let mut pidless_alias = fresh_collector.hook_placeholder(&already_gone); + pidless_alias.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let fresh = fresh_collector.finalize_hook_records( + vec![pidless_alias], + vec![already_gone.clone()], + &shared, + 100_000, + ); + assert!(fresh.is_empty()); + assert!(fresh_collector.hook_done_tombstones.borrow().is_empty()); + assert!(fresh_collector + .finalize_hook_records(Vec::new(), vec![already_gone], &shared, 100_001) + .is_empty()); + + for reason in [ + StatusReason::HookStateMalformed, + StatusReason::HookConfigChanged, + StatusReason::OwnershipUnconfirmed, + StatusReason::HookEventGap, + StatusReason::HookIntegrationUnverified, + ] { + let collector = CodexCollector::new(); + let (record, session) = prepared_live_hook( + &collector, + 100_000, + "hook-session", + "generation-one", + "test:codex:42", + plugin::MIN_SUPPORTED_CODEX_VERSION, + ); + collector.finalize_hook_records(vec![session], vec![record.clone()], &shared, 100_000); + let mut faulty_gone = record; + faulty_gone.process_state = HookProcessState::Gone; + faulty_gone.native_process_verified = false; + faulty_gone.candidate = HookCandidate::Unknown(reason); + let faulty = + collector.finalize_hook_records(Vec::new(), vec![faulty_gone], &shared, 101_000); + assert!(faulty.is_empty(), "gone {reason:?} rows must be omitted"); + assert!(collector.hook_done_tombstones.borrow().is_empty()); + } + } + + #[test] + fn suppressed_gone_hook_record_preserves_an_independently_live_rollout() { + let mut gone = hook_record(HookCandidate::TurnOpen, 100_000); + gone.process_state = HookProcessState::Gone; + gone.native_process_verified = false; + + let collector = CodexCollector::new(); + let mut live_rollout = collector.hook_placeholder(&gone); + live_rollout.pid = 99; + live_rollout.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let process_info = HashMap::from([ + (42, proc_info(42, 1, "/usr/local/bin/codex")), + (99, proc_info(99, 1, "/usr/local/bin/codex")), + ]); + let shared = super::super::SharedProcessData { + children_map: process::get_children_map(&process_info), + process_info, + ports: HashMap::new(), + slow_tick: false, + mcp_server_pids: HashSet::new(), + mcp_owned_rollouts: HashSet::new(), + mcp_suppress: true, + desktop_rollout_fd_map: HashMap::new(), + }; + + let sessions = + collector.finalize_hook_records(vec![live_rollout], vec![gone], &shared, 100_000); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].pid, 99); + assert_eq!(sessions[0].status, SessionStatus::Unknown); + assert_eq!( + sessions[0].status_evidence.reason, + StatusReason::HookIntegrationUnverified + ); + } + + #[test] + fn interaction_resolution_unknown_can_still_become_exact_done() { + let collector = CodexCollector::new(); + let shared = hook_shared(); + let (mut record, session) = prepared_live_hook( + &collector, + 100_000, + "hook-session", + "generation-one", + "test:codex:42", + plugin::MIN_SUPPORTED_CODEX_VERSION, + ); + record.candidate = + HookCandidate::Unknown(StatusReason::HookInteractionResolutionUnavailable); + record.effective_hook_engine_attested = false; + let live = + collector.finalize_hook_records(vec![session], vec![record.clone()], &shared, 100_000); + assert_eq!(live.len(), 1); + assert_eq!(live[0].status, SessionStatus::Unknown); + assert_eq!( + live[0].status_evidence.reason, + StatusReason::HookInteractionResolutionUnavailable + ); + + record.process_state = HookProcessState::Gone; + record.native_process_verified = false; + let done = collector.finalize_hook_records(Vec::new(), vec![record], &shared, 101_000); + assert_eq!(done.len(), 1); + assert_eq!(done[0].status, SessionStatus::Done); + assert_eq!(done[0].status_evidence.reason, StatusReason::ProcessExited); + assert_eq!(done[0].pid, 42); + assert!(done[0].action_process_incarnation.is_none()); + assert_eq!(collector.hook_done_tombstones.borrow().len(), 1); + } + + #[test] + fn unsupported_release_never_creates_or_replays_a_done_tombstone() { + let collector = CodexCollector::new(); + let shared = hook_shared(); + let (record, session) = prepared_live_hook( + &collector, + 100_000, + "hook-session", + "generation-one", + "test:codex:42", + "0.144.0", + ); + let live = + collector.finalize_hook_records(vec![session], vec![record.clone()], &shared, 100_000); + assert_eq!(live[0].status, SessionStatus::Unknown); + + let mut gone = record; + gone.process_state = HookProcessState::Gone; + gone.native_process_verified = false; + let gone = collector.finalize_hook_records(Vec::new(), vec![gone], &shared, 101_000); + assert!(gone.is_empty()); + assert!(collector.hook_done_tombstones.borrow().is_empty()); + assert!(collector + .finalize_hook_records(Vec::new(), Vec::new(), &shared, 101_001) + .is_empty()); + } + + #[test] + fn done_tombstone_cache_is_bounded_and_prunes_the_oldest_exit() { + let collector = CodexCollector::new(); + let now_ms = 100_000; + let template_record = hook_record(HookCandidate::TurnOpen, now_ms); + let template_session = collector.hook_placeholder(&template_record); + let snapshot = HookSessionSnapshot::capture(&template_session); + for index in 0..=MAX_CODEX_DONE_TOMBSTONES { + let key = HookDoneKey { + session_id: format!("session-{index}"), + generation_id: format!("generation-{index}"), + pid: u32::try_from(index + 1).unwrap(), + process_incarnation: format!("incarnation-{index}"), + }; + collector.hook_done_tombstones.borrow_mut().insert( + key, + HookDoneTombstone { + exit_observed_at_ms: 90_000 + u64::try_from(index).unwrap(), + snapshot: snapshot.clone(), + }, + ); + } + + collector.prune_hook_done_tombstones(now_ms); + let tombstones = collector.hook_done_tombstones.borrow(); + assert_eq!(tombstones.len(), MAX_CODEX_DONE_TOMBSTONES); + assert!(!tombstones.keys().any(|key| key.session_id == "session-0")); + assert!(tombstones + .keys() + .any(|key| key.session_id == format!("session-{MAX_CODEX_DONE_TOMBSTONES}"))); + } + + #[test] + fn generation_rotation_cannot_inherit_prior_live_or_done_proof() { + let collector = CodexCollector::new(); + let shared = hook_shared(); + let (old_record, old_session) = prepared_live_hook( + &collector, + 100_000, + "hook-session", + "generation-old", + "test:codex:42", + plugin::MIN_SUPPORTED_CODEX_VERSION, + ); + let old_key = hook_done_key(&old_record).unwrap(); + collector.finalize_hook_records( + vec![old_session], + vec![old_record.clone()], + &shared, + 100_000, + ); + + let (new_record, new_session) = prepared_live_hook( + &collector, + 100_500, + "hook-session", + "generation-new", + "test:codex:42", + plugin::MIN_SUPPORTED_CODEX_VERSION, + ); + let new_key = hook_done_key(&new_record).unwrap(); + let new_live = collector.finalize_hook_records( + vec![new_session], + vec![new_record.clone()], + &shared, + 100_500, + ); + assert_eq!(new_live[0].status, SessionStatus::Thinking); + assert!(!collector + .hook_process_states + .borrow() + .contains_key(&old_key)); + assert!(collector + .hook_process_states + .borrow() + .contains_key(&new_key)); + + let mut stale_old_gone = old_record; + stale_old_gone.process_state = HookProcessState::Gone; + stale_old_gone.native_process_verified = false; + let (_, refreshed_new_session) = prepared_live_hook( + &collector, + 101_000, + "hook-session", + "generation-new", + "test:codex:42", + plugin::MIN_SUPPORTED_CODEX_VERSION, + ); + let sessions = collector.finalize_hook_records( + vec![refreshed_new_session], + vec![stale_old_gone, new_record], + &shared, + 101_000, + ); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].status, SessionStatus::Thinking); + assert!(collector.hook_done_tombstones.borrow().is_empty()); + } + + #[test] + fn pid_reuse_with_a_new_incarnation_cannot_inherit_done_proof() { + let collector = CodexCollector::new(); + let shared = hook_shared(); + let (old_record, old_session) = prepared_live_hook( + &collector, + 100_000, + "old-session", + "old-generation", + "old-incarnation", + plugin::MIN_SUPPORTED_CODEX_VERSION, + ); + let old_key = hook_done_key(&old_record).unwrap(); + collector.finalize_hook_records( + vec![old_session], + vec![old_record.clone()], + &shared, + 100_000, + ); + + let (new_record, new_session) = prepared_live_hook( + &collector, + 100_500, + "new-session", + "new-generation", + "new-incarnation", + plugin::MIN_SUPPORTED_CODEX_VERSION, + ); + let new_key = hook_done_key(&new_record).unwrap(); + collector.finalize_hook_records( + vec![new_session], + vec![new_record.clone()], + &shared, + 100_500, + ); + assert!(!collector + .hook_process_states + .borrow() + .contains_key(&old_key)); + assert!(collector + .hook_process_states + .borrow() + .contains_key(&new_key)); + + let mut stale_old_gone = old_record; + stale_old_gone.process_state = HookProcessState::Gone; + stale_old_gone.native_process_verified = false; + let (_, refreshed_new_session) = prepared_live_hook( + &collector, + 101_000, + "new-session", + "new-generation", + "new-incarnation", + plugin::MIN_SUPPORTED_CODEX_VERSION, + ); + let sessions = collector.finalize_hook_records( + vec![refreshed_new_session], + vec![stale_old_gone, new_record], + &shared, + 101_000, + ); + assert!(sessions + .iter() + .any(|session| session.session_id == "new-session" + && session.status == SessionStatus::Thinking)); + assert!(sessions + .iter() + .all(|session| session.status != SessionStatus::Done)); + assert!(collector.hook_done_tombstones.borrow().is_empty()); + } + + #[test] + fn live_hook_rollout_binding_mismatch_is_unknown_and_unactionable() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let record = hook_record(HookCandidate::TurnOpen, now_ms); + let mut wrong = collector.hook_placeholder(&record); + wrong.pid = 99; + wrong.cwd = "/home/user/other".to_string(); + let sessions = + collector.finalize_hook_records(vec![wrong], vec![record], &hook_shared(), now_ms); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].status, SessionStatus::Unknown); + assert_eq!( + sessions[0].status_evidence.reason, + StatusReason::OwnershipUnconfirmed + ); + assert_eq!(sessions[0].pid, 0); + assert!(sessions[0].action_process_incarnation.is_none()); + } + + #[test] + fn live_rollout_with_same_pid_and_different_session_blocks_promotion() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let record = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::from(["child-1".to_string()]), + provisional: HashSet::new(), + root: HookRootCandidate::TurnOpen, + }, + now_ms, + ); + let mut conflicting = collector.hook_placeholder(&record); + conflicting.session_id = "other-session".to_string(); + conflicting.pid = record.pid; + let sessions = collector.finalize_hook_records( + vec![conflicting], + vec![record], + &hook_shared(), + now_ms, + ); + let hook = sessions + .iter() + .find(|session| session.session_id == "hook-session") + .unwrap(); + assert_eq!(hook.status, SessionStatus::Unknown); + assert_eq!( + hook.status_evidence.reason, + StatusReason::OwnershipUnconfirmed + ); + assert!(hook.action_process_incarnation.is_none()); + } + + #[test] + fn hook_done_requires_confirmed_exit_and_expires_after_thirty_seconds() { + let now_ms = 100_000; + let mut ended = hook_record(HookCandidate::Ended, now_ms); + ended.ended_at_ms = 90_000; + ended.process_state = HookProcessState::Unverified; + assert_eq!( + project_hook_status(&ended, None, now_ms).0, + SessionStatus::Unknown + ); + ended.process_state = HookProcessState::Gone; + ended.exit_observed_at_ms = 90_000; + assert_eq!( + project_hook_status(&ended, None, now_ms).0, + SessionStatus::Unknown, + "process exit without the last exact rollout binding is not Done proof" + ); + ended.exit_supported_rollout_correlated = true; + assert_eq!( + project_hook_status(&ended, None, now_ms), + ( + SessionStatus::Done, + StatusAuthority::Heuristic, + StatusReason::ProcessExited, + ) + ); + assert_eq!( + project_hook_status(&ended, None, 120_001).0, + SessionStatus::Unknown + ); + ended.process_incarnation = Some(String::new()); + assert_eq!( + project_hook_status(&ended, None, now_ms).0, + SessionStatus::Unknown + ); + } + + #[test] + fn hook_overlay_is_never_provider_authoritative_or_actionable_when_ambiguous() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let mut rollout = active_root_rollout(now_ms); + rollout + .descendants + .push(active_child_rollout("child-1", true, now_ms)); + collector + .rollout_lifecycle + .borrow_mut() + .insert("hook-session".to_string(), rollout); + let mut record = hook_record( + HookCandidate::SubagentOpen { + active: HashSet::from(["child-1".to_string()]), + provisional: HashSet::new(), + root: HookRootCandidate::TurnOpen, + }, + now_ms, + ); + record.observations.push(StatusObservation::new( + SessionStatus::Executing, + StatusAuthority::Heuristic, + StatusReason::HookSubagentActive, + now_ms.saturating_sub(500), + 0, + )); + let mut session = collector.hook_placeholder(&record); + session.pid = 42; + session.version = plugin::MIN_SUPPORTED_CODEX_VERSION.to_string(); + let sessions = collector.finalize_hook_records( + vec![session], + vec![record.clone()], + &hook_shared(), + now_ms, + ); + assert_eq!(sessions[0].status, SessionStatus::Executing); + assert_eq!( + sessions[0].status_evidence.authority, + StatusAuthority::Heuristic + ); + assert_eq!(sessions[0].status_evidence.connection_generation, 0); + assert_eq!( + sessions[0].status_evidence.observations[0].status, + SessionStatus::Unknown, + "uncorrelated historical candidates must stay Unknown" + ); + assert_eq!( + sessions[0].action_process_incarnation.as_deref(), + Some("test:codex:42") + ); + + record.interaction_ambiguous = true; + record.actionable = false; + let sessions = + collector.finalize_hook_records(Vec::new(), vec![record], &hook_shared(), now_ms); + assert_eq!(sessions[0].status, SessionStatus::Unknown); + assert_eq!( + sessions[0].status_evidence.authority, + StatusAuthority::Unavailable + ); + assert!(sessions[0].action_process_incarnation.is_none()); + assert!(!sessions[0].awaiting_input); + } + + #[test] + fn unsupported_process_owned_root_rollout_is_unknown_and_unactionable() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let mut rollout = active_root_rollout(now_ms); + rollout.root_cli_version = "0.144.0".to_string(); + collector + .rollout_lifecycle + .borrow_mut() + .insert("hook-session".to_string(), rollout); + let record = hook_record(HookCandidate::TurnOpen, now_ms); + let binding_key = hook_done_key(&record).unwrap(); + let mut session = collector.hook_placeholder(&record); + session.pid = 42; + session.version = "0.144.0".to_string(); + + let sessions = + collector.finalize_hook_records(vec![session], vec![record], &hook_shared(), now_ms); + assert_eq!(sessions[0].status, SessionStatus::Unknown); + assert_eq!( + sessions[0].status_evidence.reason, + StatusReason::HookIntegrationUnverified + ); + assert!(sessions[0].action_process_incarnation.is_none()); + assert_eq!( + collector + .hook_process_rollout_bindings + .borrow() + .get(&binding_key), + Some(&HookProcessRolloutBinding { + session_id: "hook-session".to_string(), + supported_release: false, + }) + ); + } + + #[test] + fn duplicate_hook_ownership_collapses_to_one_unknown_unactionable_row() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let first = hook_record(HookCandidate::TurnOpen, now_ms); + let mut second = first.clone(); + second.observed_at_ms = now_ms.saturating_sub(1); + + let sessions = collector.finalize_hook_records( + Vec::new(), + vec![first, second], + &hook_shared(), + now_ms, + ); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].status, SessionStatus::Unknown); + assert_eq!( + sessions[0].status_evidence.reason, + StatusReason::OwnershipUnconfirmed + ); + assert!(sessions[0].action_process_incarnation.is_none()); + } + + #[test] + fn live_generation_suppresses_ended_generation_for_the_same_process_identity() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let live = hook_record(HookCandidate::Unknown(StatusReason::HookEventGap), now_ms); + let mut ended = live.clone(); + ended.generation_id = "older-generation".to_string(); + ended.session_id = "older-session".to_string(); + ended.started_at_ms = now_ms.saturating_sub(20_000); + ended.ended_at_ms = now_ms.saturating_sub(500); + ended.candidate = HookCandidate::Ended; + + let sessions = + collector.finalize_hook_records(Vec::new(), vec![ended, live], &hook_shared(), now_ms); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session_id, "hook-session"); + assert_ne!( + sessions[0].status_evidence.reason, + StatusReason::OwnershipUnconfirmed + ); + } + + #[test] + fn multiple_active_generations_for_one_pid_remain_unknown() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let first = hook_record(HookCandidate::TurnOpen, now_ms); + let mut second = first.clone(); + second.generation_id = "second-generation".to_string(); + second.session_id = "second-session".to_string(); + + let sessions = collector.finalize_hook_records( + Vec::new(), + vec![first, second], + &hook_shared(), + now_ms, + ); + assert_eq!(sessions.len(), 2); + assert!(sessions.iter().all(|session| { + session.status == SessionStatus::Unknown + && session.status_evidence.reason == StatusReason::OwnershipUnconfirmed + && session.action_process_incarnation.is_none() + })); + } + + #[test] + fn gone_process_generations_collapse_to_the_newest_session() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let mut older = hook_record(HookCandidate::Ended, now_ms); + older.generation_id = "older-generation".to_string(); + older.session_id = "older-session".to_string(); + older.started_at_ms = 80_000; + older.ended_at_ms = 90_000; + older.exit_observed_at_ms = 99_000; + older.exit_supported_rollout_correlated = true; + older.process_state = HookProcessState::Gone; + older.native_process_verified = false; + + let mut newer = older.clone(); + newer.generation_id = "newer-generation".to_string(); + newer.session_id = "newer-session".to_string(); + newer.started_at_ms = 90_000; + newer.ended_at_ms = 95_000; + + let sessions = + collector.finalize_hook_records(Vec::new(), vec![older, newer], &hook_shared(), now_ms); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session_id, "newer-session"); + assert_eq!(sessions[0].status, SessionStatus::Done); + } + + #[test] + fn pid_reuse_does_not_suppress_a_different_ended_incarnation() { + let now_ms = 100_000; + let collector = CodexCollector::new(); + let live = hook_record(HookCandidate::Unknown(StatusReason::HookEventGap), now_ms); + let mut ended = live.clone(); + ended.generation_id = "ended-generation".to_string(); + ended.session_id = "ended-session".to_string(); + ended.started_at_ms = 80_000; + ended.ended_at_ms = 90_000; + ended.exit_observed_at_ms = 99_000; + ended.exit_supported_rollout_correlated = true; + ended.process_incarnation = Some("test:codex:old-42".to_string()); + ended.process_state = HookProcessState::Gone; + ended.native_process_verified = false; + ended.candidate = HookCandidate::Ended; + + let sessions = + collector.finalize_hook_records(Vec::new(), vec![ended, live], &hook_shared(), now_ms); + assert_eq!(sessions.len(), 2); + assert!(sessions.iter().any(|session| { + session.session_id == "ended-session" && session.status == SessionStatus::Done + })); + } + #[test] fn test_parse_codex_empty_returns_none() { let file = tempfile::NamedTempFile::new().unwrap(); diff --git a/src/collector/codexbar.rs b/src/collector/codexbar.rs new file mode 100644 index 0000000..a481399 --- /dev/null +++ b/src/collector/codexbar.rs @@ -0,0 +1,2057 @@ +//! Optional, bounded CodexBar quota provider. +//! +//! The poller asks CodexBar for its configured providers and retains only +//! canonical provider IDs plus bounded quota-window metadata. Account identity, +//! credits, pace, dashboard data, raw provider failures, and unknown fields are +//! discarded before a snapshot enters application state. + +use crate::model::{RateLimitProvenance, RateLimitWindow}; +#[cfg(test)] +use crate::model::{MAX_RATE_LIMIT_WINDOW_ID_BYTES, MAX_RATE_LIMIT_WINDOW_LABEL_BYTES}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::io::Read; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const POLL_INTERVAL: Duration = Duration::from_secs(60); +const COMMAND_TIMEOUT: Duration = Duration::from_secs(15); +const OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_millis(100); +const CHILD_REAP_TIMEOUT: Duration = Duration::from_millis(100); +#[cfg(windows)] +const READER_CANCEL_TIMEOUT: Duration = Duration::from_millis(100); +const MAX_OUTPUT: usize = 1024 * 1024; +const MAX_PROVIDERS: usize = 64; +const MAX_WINDOWS_PER_PROVIDER: usize = 32; +const MAX_PROVIDER_ID_BYTES: usize = 64; +const CODEXBAR_ARGS: [&str; 5] = ["usage", "--format", "json", "--json-only", "--no-color"]; + +/// Content-free state of the optional CodexBar quota poller. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CodexBarQuotaState { + Off, + Checking, + Available, + Partial, + Unavailable, +} + +/// Sanitized failure category. Provider output and OS error text are never retained. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CodexBarPollError { + NotRunnable, + TimedOut, + OutputTooLarge, + ProcessFailed, + InvalidResponse, + UnsupportedResponse, + Cancelled, + InternalError, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub(crate) struct CodexBarQuotaStatus { + pub(crate) state: CodexBarQuotaState, + pub(crate) last_checked_at: Option, + pub(crate) error: Option, +} + +/// Sanitized failure for one provider row. CodexBar's raw error is discarded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CodexBarProviderError { + Unavailable, + InvalidResponse, + DuplicateProvider, + TooManyWindows, +} + +/// One bounded quota window returned by CodexBar. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct CodexBarWindow { + pub(crate) id: String, + pub(crate) label: String, + pub(crate) used_pct: f64, + pub(crate) resets_at: Option, + pub(crate) window_minutes: Option, +} + +/// Sanitized result for one configured CodexBar provider. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct CodexBarProviderSnapshot { + pub(crate) provider: String, + pub(crate) windows: Vec, + pub(crate) updated_at: Option, + pub(crate) error: Option, +} + +impl CodexBarProviderSnapshot { + fn failure(provider: String, error: CodexBarProviderError) -> Self { + Self { + provider, + windows: Vec::new(), + updated_at: None, + error: Some(error), + } + } + + pub(crate) fn is_available(&self) -> bool { + self.error.is_none() && !self.windows.is_empty() + } +} + +/// One atomic CodexBar poll. Response order is retained; consumers may sort. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct CodexBarSnapshot { + pub(crate) providers: Vec, +} + +impl CodexBarSnapshot { + fn state(&self) -> CodexBarQuotaState { + let available = self + .providers + .iter() + .filter(|provider| provider.is_available()) + .count(); + let unavailable = self.providers.len().saturating_sub(available); + match (available, unavailable) { + (0, _) => CodexBarQuotaState::Unavailable, + (_, 0) => CodexBarQuotaState::Available, + _ => CodexBarQuotaState::Partial, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawCodexBarWindow { + used_percent: f64, + #[serde(default)] + window_minutes: Option, + #[serde(default)] + resets_at: Option, +} + +#[derive(Debug, Deserialize)] +struct RawExtraWindow { + id: String, + #[serde(default)] + title: Option, + window: RawCodexBarWindow, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TypedCodexBarUsage { + #[serde(default)] + primary: Option, + #[serde(default)] + secondary: Option, + #[serde(default)] + tertiary: Option, + #[serde(default)] + extra_rate_windows: Option>, + #[serde(default)] + updated_at: Option, +} + +struct PollResult { + generation: u64, + result: Result, +} + +struct CommandOutput { + stdout: Vec, + success: bool, +} + +#[derive(Debug)] +struct ChildProcessSlot { + generation: u64, + pid: Option, + #[cfg(windows)] + job: Option>, + #[cfg(windows)] + process: Option>, +} + +impl ChildProcessSlot { + fn new(generation: u64) -> Self { + Self { + generation, + pid: None, + #[cfg(windows)] + job: None, + #[cfg(windows)] + process: None, + } + } +} + +#[derive(Debug, Clone)] +struct ChildProcessHandle { + pid: u32, + #[cfg(windows)] + job: Option>, + #[cfg(windows)] + process: Option>, +} + +impl ChildProcessHandle { + fn for_child(child: &Child) -> Self { + Self { + pid: child.id(), + #[cfg(windows)] + job: WindowsCommandJob::assign(child).map(Arc::new), + #[cfg(windows)] + process: WindowsProcessHandle::duplicate(child).map(Arc::new), + } + } + + fn terminate(&self) { + #[cfg(windows)] + { + if let Some(job) = &self.job { + job.terminate(); + } + if let Some(process) = &self.process { + process.terminate(); + } + } + #[cfg(not(windows))] + terminate_pid(self.pid); + } + + fn can_register(&self) -> bool { + #[cfg(windows)] + { + self.process.is_some() + } + #[cfg(not(windows))] + { + true + } + } +} + +struct ChildProcessRegistration { + slot: Arc>, + generation: u64, + pid: u32, +} + +impl ChildProcessRegistration { + fn claim( + slot: &Arc>, + generation: u64, + child: &ChildProcessHandle, + ) -> Option { + if !claim_child_process(slot, generation, child) { + return None; + } + Some(Self { + slot: slot.clone(), + generation, + pid: child.pid, + }) + } +} + +impl Drop for ChildProcessRegistration { + fn drop(&mut self) { + release_child_process(&self.slot, self.generation, self.pid); + } +} + +/// Runs one nonblocking child observation while holding the registration lock. +/// +/// A successful result means the observation reaped the direct child. Removing +/// the matching slot before releasing the lock prevents a concurrent setting +/// toggle from signalling a newly reused numeric PID. The caller's independent +/// Windows Job handle remains available for exact descendant cleanup. +fn observe_registered_child( + registration: &mut Option, + observe: impl FnOnce() -> Result, E>, +) -> Result, E> { + let Some(current) = registration.as_ref() else { + return observe(); + }; + let slot = current.slot.clone(); + let generation = current.generation; + let pid = current.pid; + let (result, _released) = { + let mut slot = child_slot_guard(&slot); + let result = observe()?; + let released = if result.is_some() && slot.generation == generation && slot.pid == Some(pid) + { + take_slot_child(&mut slot) + } else { + None + }; + (result, released) + }; + if result.is_some() { + // The locked path already removed the exact registration. Dropping the + // guard afterward is intentionally a harmless second validation. + drop(registration.take()); + } + Ok(result) +} + +#[cfg(unix)] +fn observe_unreaped_unix_child( + registration: &mut Option, + process: &ChildProcessHandle, +) -> Result { + let Some(current) = registration.as_ref() else { + return child_exited_without_reaping(process.pid); + }; + let slot = current.slot.clone(); + let generation = current.generation; + let pid = current.pid; + let (exited, _released) = { + let mut slot = child_slot_guard(&slot); + let exited = child_exited_without_reaping(pid)?; + let released = if exited && slot.generation == generation && slot.pid == Some(pid) { + // The zombie leader still reserves the PGID. Signal and remove the + // numeric registration under the same lock so a toggle can never + // carry this PGID past the worker's later reap. + process.terminate(); + take_slot_child(&mut slot) + } else { + None + }; + (exited, released) + }; + if exited { + drop(registration.take()); + } + Ok(exited) +} + +#[cfg(unix)] +fn signal_and_release_registered_unix_child( + registration: &mut Option, + process: &ChildProcessHandle, +) { + let Some(current) = registration.as_ref() else { + // No shared numeric identity was published, so only this worker can + // transition the unreaped child and its process group. + process.terminate(); + return; + }; + let slot = current.slot.clone(); + let generation = current.generation; + let pid = current.pid; + let _released = { + let mut slot = child_slot_guard(&slot); + if slot.generation == generation && slot.pid == Some(pid) { + process.terminate(); + take_slot_child(&mut slot) + } else { + // A generation rotation already removed and signalled it while + // holding this same lock. + None + } + }; + drop(registration.take()); +} + +fn child_slot_guard(slot: &Mutex) -> std::sync::MutexGuard<'_, ChildProcessSlot> { + slot.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn take_slot_child(slot: &mut ChildProcessSlot) -> Option { + let pid = slot.pid.take(); + #[cfg(windows)] + let job = slot.job.take(); + #[cfg(windows)] + let process = slot.process.take(); + pid.map(|pid| ChildProcessHandle { + pid, + #[cfg(windows)] + job, + #[cfg(windows)] + process, + }) +} + +fn child_generation_matches(slot: &Mutex, generation: u64) -> bool { + child_slot_guard(slot).generation == generation +} + +fn claim_child_process( + slot: &Mutex, + generation: u64, + child: &ChildProcessHandle, +) -> bool { + if child.pid == 0 || !child.can_register() { + return false; + } + let mut slot = child_slot_guard(slot); + if slot.generation != generation || slot.pid.is_some() { + return false; + } + slot.pid = Some(child.pid); + #[cfg(windows)] + { + slot.job = child.job.clone(); + slot.process = child.process.clone(); + } + true +} + +fn release_child_process(slot: &Mutex, generation: u64, pid: u32) -> bool { + let released = { + let mut slot = child_slot_guard(slot); + if slot.generation == generation && slot.pid == Some(pid) { + take_slot_child(&mut slot) + } else { + None + } + }; + // On Windows this can close the final kill-on-close job handle. Keep that + // operation outside the slot lock. + released.is_some() +} + +fn rotate_child_generation( + slot: &Mutex, + generation: u64, +) -> Option { + let mut slot = child_slot_guard(slot); + let child = take_slot_child(&mut slot); + #[cfg(unix)] + if let Some(child) = &child { + // The worker cannot reap while this lock is held. Signal the process + // group before releasing its numeric identity. + child.terminate(); + } + slot.generation = generation; + #[cfg(unix)] + { + None + } + #[cfg(not(unix))] + { + child + } +} + +pub(crate) struct CodexBarQuotaPoller { + enabled: bool, + generation: u64, + cached: Option, + in_flight: Option, + last_started: Option, + last_checked_at: Option, + last_error: Option, + child_process: Arc>, + tx: mpsc::Sender, + rx: mpsc::Receiver, +} + +impl CodexBarQuotaPoller { + pub(crate) fn new(enabled: bool) -> Self { + let (tx, rx) = mpsc::channel(); + Self { + enabled, + generation: 0, + cached: None, + in_flight: None, + last_started: None, + last_checked_at: None, + last_error: None, + child_process: Arc::new(Mutex::new(ChildProcessSlot::new(0))), + tx, + rx, + } + } + + pub(crate) fn set_enabled(&mut self, enabled: bool) { + if self.enabled == enabled { + return; + } + self.enabled = enabled; + self.generation = self.generation.wrapping_add(1); + let child = rotate_child_generation(&self.child_process, self.generation); + self.cached = None; + self.last_started = None; + self.last_checked_at = None; + self.last_error = None; + self.in_flight = None; + if let Some(child) = child { + child.terminate(); + } + } + + pub(crate) fn status(&self) -> CodexBarQuotaStatus { + let state = if !self.enabled { + CodexBarQuotaState::Off + } else if self.in_flight.is_some() { + CodexBarQuotaState::Checking + } else { + let cached_state = self + .cached + .as_ref() + .map(CodexBarSnapshot::state) + .unwrap_or(CodexBarQuotaState::Unavailable); + if self.last_error.is_some() + && matches!( + cached_state, + CodexBarQuotaState::Available | CodexBarQuotaState::Partial + ) + { + CodexBarQuotaState::Partial + } else { + cached_state + } + }; + CodexBarQuotaStatus { + state, + last_checked_at: self.last_checked_at, + error: self.last_error, + } + } + + pub(crate) fn update(&mut self) -> Option { + self.poll_completed(); + if self.should_start() { + self.start(); + } + self.cached.clone() + } + + pub(crate) fn wait_for_initial(&mut self, timeout: Duration) -> Option { + self.update(); + if self.cached.is_some() || self.in_flight.is_none() { + return self.cached.clone(); + } + let deadline = Instant::now() + timeout; + while self.in_flight.is_some() { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + break; + }; + match self + .rx + .recv_timeout(remaining.min(Duration::from_millis(100))) + { + Ok(result) => self.apply_result(result), + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => { + self.in_flight = None; + self.last_checked_at = Some(now_secs()); + self.last_error = Some(CodexBarPollError::InternalError); + break; + } + } + } + self.cached.clone() + } + + fn poll_completed(&mut self) { + loop { + match self.rx.try_recv() { + Ok(result) => self.apply_result(result), + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + if self.in_flight.take().is_some() { + self.last_checked_at = Some(now_secs()); + self.last_error = Some(CodexBarPollError::InternalError); + } + break; + } + } + } + } + + fn apply_result(&mut self, result: PollResult) { + if self.in_flight == Some(result.generation) { + self.in_flight = None; + } + if self.enabled && result.generation == self.generation { + self.last_checked_at = Some(now_secs()); + match result.result { + Ok(snapshot) => { + // A structurally valid poll atomically replaces the complete + // configured-provider set. Provider failures therefore clear + // old values for that provider instead of leaving ghosts. + self.cached = Some(snapshot); + self.last_error = None; + } + Err(error) => { + // Transport and envelope failures retain the prior bounded + // snapshot as stale data, while exposing only the category. + self.last_error = Some(error); + } + } + } + } + + fn should_start(&self) -> bool { + self.enabled + && self.in_flight.is_none() + && self + .last_started + .is_none_or(|started| started.elapsed() >= POLL_INTERVAL) + } + + fn start(&mut self) { + let generation = self.generation; + self.in_flight = Some(generation); + self.last_started = Some(Instant::now()); + let tx = self.tx.clone(); + let child_process = self.child_process.clone(); + std::thread::spawn(move || { + let result = run_codexbar(&child_process, generation).and_then(parse_command_output); + let _ = tx.send(PollResult { generation, result }); + }); + } +} + +impl Drop for CodexBarQuotaPoller { + fn drop(&mut self) { + self.generation = self.generation.wrapping_add(1); + let child = rotate_child_generation(&self.child_process, self.generation); + if let Some(child) = child { + child.terminate(); + } + } +} + +fn parse_command_output(output: CommandOutput) -> Result { + match parse_codexbar(&output.stdout) { + Ok(snapshot) if output.success || !snapshot.providers.is_empty() => Ok(snapshot), + Ok(_) => Err(CodexBarPollError::ProcessFailed), + Err(_) if !output.success => Err(CodexBarPollError::ProcessFailed), + Err(error) => Err(error), + } +} + +fn parse_codexbar(bytes: &[u8]) -> Result { + if bytes.len() > MAX_OUTPUT { + return Err(CodexBarPollError::OutputTooLarge); + } + let envelope: Value = + serde_json::from_slice(bytes).map_err(|_| CodexBarPollError::InvalidResponse)?; + let rows = match envelope { + Value::Array(rows) => rows, + Value::Object(_) => vec![envelope], + _ => return Err(CodexBarPollError::UnsupportedResponse), + }; + if rows.len() > MAX_PROVIDERS { + return Err(CodexBarPollError::UnsupportedResponse); + } + let response_was_empty = rows.is_empty(); + + let mut providers = Vec::with_capacity(rows.len()); + let mut indexes = HashMap::::new(); + for row in rows { + let Some(provider) = row + .get("provider") + .and_then(Value::as_str) + .and_then(canonical_provider_id) + else { + // Without a safe stable provider identity there is no row that can + // be exposed or atomically cleared. Other valid providers survive. + continue; + }; + + if let Some(index) = indexes.get(&provider).copied() { + providers[index] = CodexBarProviderSnapshot::failure( + provider, + CodexBarProviderError::DuplicateProvider, + ); + continue; + } + + let snapshot = parse_provider_row(provider.clone(), &row); + indexes.insert(provider, providers.len()); + providers.push(snapshot); + } + + if !providers.is_empty() || response_was_empty { + Ok(CodexBarSnapshot { providers }) + } else { + Err(CodexBarPollError::InvalidResponse) + } +} + +pub(crate) fn canonical_provider_id(raw: &str) -> Option { + if raw.is_empty() + || raw.len() > MAX_PROVIDER_ID_BYTES + || !raw + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return None; + } + Some(raw.to_ascii_lowercase()) +} + +fn parse_provider_row(provider: String, row: &Value) -> CodexBarProviderSnapshot { + let has_error = row.get("error").is_some_and(|error| !error.is_null()); + let usage = row.get("usage").filter(|usage| !usage.is_null()); + if has_error { + return CodexBarProviderSnapshot::failure( + provider, + if usage.is_some() { + CodexBarProviderError::InvalidResponse + } else { + CodexBarProviderError::Unavailable + }, + ); + } + let Some(usage) = usage else { + return CodexBarProviderSnapshot::failure(provider, CodexBarProviderError::InvalidResponse); + }; + let Ok(usage) = serde_json::from_value::(usage.clone()) else { + return CodexBarProviderSnapshot::failure(provider, CodexBarProviderError::InvalidResponse); + }; + + let extra_windows = usage.extra_rate_windows.unwrap_or_default(); + let built_in_count = [ + usage.primary.as_ref(), + usage.secondary.as_ref(), + usage.tertiary.as_ref(), + ] + .into_iter() + .flatten() + .count(); + if built_in_count.saturating_add(extra_windows.len()) > MAX_WINDOWS_PER_PROVIDER { + return CodexBarProviderSnapshot::failure(provider, CodexBarProviderError::TooManyWindows); + } + + let updated_at = match usage.updated_at.as_deref().map(parse_timestamp).transpose() { + Ok(updated_at) => updated_at, + Err(()) => { + return CodexBarProviderSnapshot::failure( + provider, + CodexBarProviderError::InvalidResponse, + ); + } + }; + let mut windows = Vec::with_capacity(built_in_count + extra_windows.len()); + let mut window_ids = HashSet::with_capacity(windows.capacity()); + for (id, label, raw) in [ + ("primary", "Primary", usage.primary.as_ref()), + ("secondary", "Secondary", usage.secondary.as_ref()), + ("tertiary", "Tertiary", usage.tertiary.as_ref()), + ] { + let Some(raw) = raw else { continue }; + let Ok(window) = parse_window(id, label, raw) else { + return CodexBarProviderSnapshot::failure( + provider, + CodexBarProviderError::InvalidResponse, + ); + }; + window_ids.insert(window.id.to_ascii_lowercase()); + windows.push(window); + } + for extra in extra_windows { + let label = extra.title.as_deref().unwrap_or(&extra.id); + let Ok(window) = parse_window(&extra.id, label, &extra.window) else { + return CodexBarProviderSnapshot::failure( + provider, + CodexBarProviderError::InvalidResponse, + ); + }; + if !window_ids.insert(window.id.to_ascii_lowercase()) { + return CodexBarProviderSnapshot::failure( + provider, + CodexBarProviderError::InvalidResponse, + ); + } + windows.push(window); + } + if windows.is_empty() { + return CodexBarProviderSnapshot::failure(provider, CodexBarProviderError::InvalidResponse); + } + + CodexBarProviderSnapshot { + provider, + windows, + updated_at, + error: None, + } +} + +fn parse_window(id: &str, label: &str, raw: &RawCodexBarWindow) -> Result { + let resets_at = raw.resets_at.as_deref().map(parse_timestamp).transpose()?; + let validated = RateLimitWindow::try_new( + id, + label, + raw.used_percent, + resets_at, + raw.window_minutes, + RateLimitProvenance::CodexBar, + ) + .ok_or(())?; + Ok(CodexBarWindow { + id: validated.id, + label: validated.label, + used_pct: validated.used_pct, + resets_at: validated.resets_at, + window_minutes: validated.window_minutes, + }) +} + +fn parse_timestamp(raw: &str) -> Result { + let timestamp = chrono::DateTime::parse_from_rfc3339(raw) + .map_err(|_| ())? + .timestamp(); + u64::try_from(timestamp).map_err(|_| ()) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn run_codexbar( + child_slot: &Arc>, + generation: u64, +) -> Result { + if !child_generation_matches(child_slot, generation) { + return Err(CodexBarPollError::Cancelled); + } + let mut command = Command::new("codexbar"); + command + .args(CODEXBAR_ARGS) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + + let mut child = command + .spawn() + .map_err(|_| CodexBarPollError::NotRunnable)?; + let child_process = ChildProcessHandle::for_child(&child); + let Some(registration) = + ChildProcessRegistration::claim(child_slot, generation, &child_process) + else { + terminate_child_bounded( + child, + &child_process, + &mut None, + bounded_cleanup_deadline(Instant::now() + COMMAND_TIMEOUT), + ); + return Err(CodexBarPollError::Cancelled); + }; + let mut registration = Some(registration); + let Some(stdout) = child.stdout.take() else { + terminate_child_bounded( + child, + &child_process, + &mut registration, + bounded_cleanup_deadline(Instant::now() + COMMAND_TIMEOUT), + ); + return Err(CodexBarPollError::InternalError); + }; + let Some(stderr) = child.stderr.take() else { + terminate_child_bounded( + child, + &child_process, + &mut registration, + bounded_cleanup_deadline(Instant::now() + COMMAND_TIMEOUT), + ); + return Err(CodexBarPollError::InternalError); + }; + let (tx, rx) = mpsc::channel(); + let overall_deadline = Instant::now() + COMMAND_TIMEOUT; + #[cfg(windows)] + let deadline = overall_deadline - READER_CANCEL_TIMEOUT; + #[cfg(not(windows))] + let deadline = overall_deadline; + let mut readers = BoundedReaderTasks::new( + [ + spawn_command_pipe_reader(stdout, 0, tx.clone()), + spawn_command_pipe_reader(stderr, 1, tx), + ], + overall_deadline, + ); + let status = loop { + #[cfg(unix)] + match observe_unreaped_unix_child(&mut registration, &child_process) { + Ok(true) => { + // The helper killed the dedicated group and removed its slot + // under one lock while the zombie leader reserved the PGID. + match child.wait() { + Ok(status) => break status, + Err(_) => return Err(CodexBarPollError::InternalError), + } + } + Ok(false) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + continue; + } + Ok(false) => { + terminate_child_bounded(child, &child_process, &mut registration, deadline); + return Err(CodexBarPollError::TimedOut); + } + Err(_) => { + terminate_child_bounded( + child, + &child_process, + &mut registration, + bounded_cleanup_deadline(deadline), + ); + return Err(CodexBarPollError::InternalError); + } + } + #[cfg(not(unix))] + match observe_registered_child(&mut registration, || child.try_wait()) { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) => { + terminate_child_bounded(child, &child_process, &mut registration, deadline); + return Err(CodexBarPollError::TimedOut); + } + Err(_) => { + terminate_child_bounded( + child, + &child_process, + &mut registration, + bounded_cleanup_deadline(deadline), + ); + return Err(CodexBarPollError::InternalError); + } + } + }; + let drain_deadline = deadline.min(Instant::now() + OUTPUT_DRAIN_TIMEOUT); + let mut streams: [Option<(Vec, bool, bool)>; 2] = [None, None]; + while streams.iter().any(Option::is_none) { + let Some(remaining) = drain_deadline.checked_duration_since(Instant::now()) else { + terminate_after_leader_reaped(&child_process); + return Err(CodexBarPollError::InternalError); + }; + match rx.recv_timeout(remaining) { + Ok((slot, bytes, truncated, read_failed)) if slot < streams.len() => { + if streams[slot].is_some() || !readers.finish(slot) { + terminate_after_leader_reaped(&child_process); + return Err(CodexBarPollError::InternalError); + } + streams[slot] = Some((bytes, truncated, read_failed)); + } + Ok(_) => { + terminate_after_leader_reaped(&child_process); + return Err(CodexBarPollError::InternalError); + } + Err(_) => { + terminate_after_leader_reaped(&child_process); + return Err(CodexBarPollError::InternalError); + } + } + } + let (stdout, stdout_truncated, stdout_read_failed) = streams[0].take().unwrap_or_default(); + let (_stderr, stderr_truncated, stderr_read_failed) = streams[1].take().unwrap_or_default(); + if stdout_truncated || stderr_truncated { + terminate_after_leader_reaped(&child_process); + return Err(CodexBarPollError::OutputTooLarge); + } + if stdout_read_failed || stderr_read_failed { + terminate_after_leader_reaped(&child_process); + return Err(CodexBarPollError::InternalError); + } + // Windows job identity remains valid after leader reap. Terminate it even + // when both pipes closed so a detached descendant cannot outlive the poll. + // Unix already killed the group while the zombie leader anchored its PGID. + terminate_after_leader_reaped(&child_process); + drop(registration); + // A provider-specific failure can make CodexBar exit nonzero while stdout + // still contains a valid mixed snapshot. Preserve only the exit predicate; + // parsing decides whether the bounded stdout is usable. Stderr is discarded. + Ok(CommandOutput { + stdout, + success: status.success(), + }) +} + +struct BoundedReaderTask { + cancelled: Arc, + handle: Option>, +} + +impl BoundedReaderTask { + fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + } +} + +struct BoundedReaderTasks { + tasks: [Option; 2], + #[cfg(windows)] + overall_deadline: Instant, +} + +impl BoundedReaderTasks { + fn new(tasks: [BoundedReaderTask; 2], overall_deadline: Instant) -> Self { + #[cfg(not(windows))] + let _ = overall_deadline; + Self { + tasks: tasks.map(Some), + #[cfg(windows)] + overall_deadline, + } + } + + fn finish(&mut self, slot: usize) -> bool { + let Some(task) = self.tasks.get_mut(slot).and_then(Option::take) else { + return false; + }; + drop(task.handle); + true + } +} + +impl Drop for BoundedReaderTasks { + fn drop(&mut self) { + #[cfg(windows)] + { + let deadline = self + .overall_deadline + .min(Instant::now() + READER_CANCEL_TIMEOUT); + loop { + let mut pending = false; + for task in self.tasks.iter().flatten() { + if task + .handle + .as_ref() + .is_some_and(|handle| !handle.is_finished()) + { + pending = true; + // Windows readers never issue an unproven blocking + // read. Reassert cancellation until the polling thread + // observes it or the overall command bound expires. + task.cancel(); + } + } + if !pending || Instant::now() >= deadline { + break; + } + std::thread::sleep( + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_millis(1)), + ); + } + } + #[cfg(not(windows))] + for task in self.tasks.iter().flatten() { + task.cancel(); + } + } +} + +#[cfg(not(windows))] +fn spawn_command_pipe_reader( + reader: impl Read + Send + 'static, + slot: usize, + tx: mpsc::Sender<(usize, Vec, bool, bool)>, +) -> BoundedReaderTask { + spawn_bounded_reader(reader, slot, tx) +} + +#[cfg(windows)] +#[link(name = "kernel32")] +extern "system" { + #[link_name = "PeekNamedPipe"] + fn peek_named_pipe( + named_pipe: windows_sys::Win32::Foundation::HANDLE, + buffer: *mut core::ffi::c_void, + buffer_size: u32, + bytes_read: *mut u32, + total_bytes_available: *mut u32, + bytes_left_this_message: *mut u32, + ) -> i32; +} + +#[cfg(windows)] +fn spawn_command_pipe_reader( + mut reader: R, + slot: usize, + tx: mpsc::Sender<(usize, Vec, bool, bool)>, +) -> BoundedReaderTask +where + R: Read + Send + std::os::windows::io::AsRawHandle + 'static, +{ + use windows_sys::Win32::Foundation::{ + GetLastError, ERROR_BROKEN_PIPE, ERROR_PIPE_NOT_CONNECTED, + }; + + let cancelled = Arc::new(AtomicBool::new(false)); + let reader_cancelled = cancelled.clone(); + let handle = std::thread::spawn(move || { + let pipe = reader.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE; + let mut output = Vec::new(); + let mut truncated = false; + let mut read_failed = false; + let mut buffer = [0_u8; 8192]; + loop { + if reader_cancelled.load(Ordering::Acquire) { + break; + } + let mut available = 0_u32; + let peeked = unsafe { + peek_named_pipe( + pipe, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut available, + std::ptr::null_mut(), + ) + } != 0; + if !peeked { + if reader_cancelled.load(Ordering::Acquire) { + break; + } + let error = unsafe { GetLastError() }; + if !matches!(error, ERROR_BROKEN_PIPE | ERROR_PIPE_NOT_CONNECTED) { + read_failed = true; + } + break; + } + if available == 0 { + std::thread::sleep(Duration::from_millis(1)); + continue; + } + + let remaining = MAX_OUTPUT.saturating_sub(output.len()); + if remaining == 0 { + // Peek already proved that bytes exist beyond the retained cap. + truncated = true; + break; + } + let requested = (available as usize).min(buffer.len()).min(remaining); + match reader.read(&mut buffer[..requested]) { + Ok(0) => break, + Ok(read) => { + output.extend_from_slice(&buffer[..read]); + if output.len() == MAX_OUTPUT && available as usize > read { + truncated = true; + break; + } + } + Err(_) if reader_cancelled.load(Ordering::Acquire) => break, + Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => break, + Err(_) => { + read_failed = true; + break; + } + } + } + let _ = tx.send((slot, output, truncated, read_failed)); + }); + BoundedReaderTask { + cancelled, + handle: Some(handle), + } +} + +#[cfg(any(not(windows), test))] +fn spawn_bounded_reader( + mut reader: impl Read + Send + 'static, + slot: usize, + tx: mpsc::Sender<(usize, Vec, bool, bool)>, +) -> BoundedReaderTask { + let cancelled = Arc::new(AtomicBool::new(false)); + let reader_cancelled = cancelled.clone(); + let handle = std::thread::spawn(move || { + let mut output = Vec::new(); + let mut truncated = false; + let mut read_failed = false; + let mut buffer = [0_u8; 8192]; + loop { + if reader_cancelled.load(Ordering::Acquire) { + break; + } + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(read) => { + let remaining = MAX_OUTPUT.saturating_sub(output.len()); + let retained = remaining.min(read); + output.extend_from_slice(&buffer[..retained]); + if retained < read { + // Once overflow is proven, close the read end instead + // of draining attacker-controlled output forever. + truncated = true; + break; + } + } + Err(_) if reader_cancelled.load(Ordering::Acquire) => break, + Err(_) => { + read_failed = true; + break; + } + } + } + let _ = tx.send((slot, output, truncated, read_failed)); + }); + BoundedReaderTask { + cancelled, + handle: Some(handle), + } +} + +fn bounded_cleanup_deadline(command_deadline: Instant) -> Instant { + command_deadline.min(Instant::now() + CHILD_REAP_TIMEOUT) +} + +fn terminate_child_bounded( + mut child: Child, + process: &ChildProcessHandle, + registration: &mut Option, + deadline: Instant, +) { + #[cfg(not(unix))] + if let Ok(Some(_)) = observe_registered_child(registration, || child.try_wait()) { + terminate_after_leader_reaped(process); + return; + } + + #[cfg(unix)] + signal_and_release_registered_unix_child(registration, process); + #[cfg(windows)] + if let Some(job) = &process.job { + job.terminate(); + } + #[cfg(not(any(unix, windows)))] + process.terminate(); + // `Child::kill` uses the exact owned process handle on Windows and the + // still-unreaped child identity elsewhere. + let _ = child.kill(); + loop { + let observed = observe_registered_child(registration, || child.try_wait()); + match observed { + Ok(Some(_)) => { + drop(registration.take()); + terminate_after_leader_reaped(process); + return; + } + Ok(None) if Instant::now() < deadline => { + std::thread::sleep( + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_millis(1)), + ); + } + Ok(None) | Err(_) => break, + } + } + + // The termination request has already been issued. Stop exposing the + // numeric fallback before handing the exact Child handle to a detached + // reaper, so the caller never blocks beyond its absolute deadline. + drop(registration.take()); + let _ = std::thread::Builder::new() + .name("abtop-codexbar-reaper".to_string()) + .spawn(move || { + let _ = child.wait(); + }); +} + +#[cfg(unix)] +fn child_exited_without_reaping(pid: u32) -> Result { + let mut information = std::mem::MaybeUninit::::zeroed(); + let result = unsafe { + libc::waitid( + libc::P_PID, + pid as libc::id_t, + information.as_mut_ptr(), + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result != 0 { + return Err(CodexBarPollError::InternalError); + } + let information = unsafe { information.assume_init() }; + Ok(unsafe { information.si_pid() } != 0) +} + +#[cfg(unix)] +fn terminate_after_leader_reaped(_: &ChildProcessHandle) {} + +#[cfg(windows)] +fn terminate_after_leader_reaped(process: &ChildProcessHandle) { + // A Job Object remains an exact process-tree handle after the leader is + // reaped. The direct-PID fallback does not: that numeric PID may already + // have been reused, so it must never be signalled from this path. + if let Some(job) = &process.job { + job.terminate(); + } +} + +#[cfg(not(any(unix, windows)))] +fn terminate_after_leader_reaped(_: &ChildProcessHandle) {} + +#[cfg(windows)] +#[derive(Debug)] +struct WindowsProcessHandle { + handle: std::os::windows::io::OwnedHandle, +} + +#[cfg(windows)] +impl WindowsProcessHandle { + fn duplicate(child: &Child) -> Option { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::Foundation::{DuplicateHandle, DUPLICATE_SAME_ACCESS}; + use windows_sys::Win32::System::Threading::GetCurrentProcess; + + let current_process = unsafe { GetCurrentProcess() }; + let mut duplicate = std::ptr::null_mut(); + let duplicated = unsafe { + DuplicateHandle( + current_process, + child.as_raw_handle() as _, + current_process, + &mut duplicate, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) + } != 0; + if !duplicated || duplicate.is_null() { + return None; + } + Some(Self { + // The native child handle includes PROCESS_TERMINATE and + // SYNCHRONIZE. The duplicate remains tied to that exact process + // object even after the numeric PID becomes reusable. + handle: unsafe { OwnedHandle::from_raw_handle(duplicate as _) }, + }) + } + + fn terminate(&self) { + use std::os::windows::io::AsRawHandle; + + unsafe { + windows_sys::Win32::System::Threading::TerminateProcess( + self.handle.as_raw_handle() as _, + 1, + ); + } + } +} + +#[cfg(windows)] +#[derive(Debug)] +struct WindowsCommandJob { + handle: std::os::windows::io::OwnedHandle, +} + +#[cfg(windows)] +impl WindowsCommandJob { + fn assign(child: &Child) -> Option { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }; + + let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if handle.is_null() { + return None; + } + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let configured = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + (&raw const limits).cast(), + std::mem::size_of::() as u32, + ) + } != 0; + let assigned = configured + && unsafe { AssignProcessToJobObject(handle, child.as_raw_handle() as _) } != 0; + if !assigned { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + return None; + } + Some(Self { + handle: unsafe { OwnedHandle::from_raw_handle(handle as _) }, + }) + } + + fn terminate(&self) { + use std::os::windows::io::AsRawHandle; + unsafe { + windows_sys::Win32::System::JobObjects::TerminateJobObject( + self.handle.as_raw_handle() as _, + 1, + ); + } + } +} + +#[cfg(not(windows))] +fn terminate_pid(pid: u32) { + if pid == 0 { + return; + } + #[cfg(unix)] + unsafe { + libc::kill(-(pid as i32), libc::SIGKILL); + } +} + +#[cfg(test)] +mod multi_provider_tests { + use super::*; + + fn mixed_response() -> Vec { + serde_json::to_vec(&serde_json::json!([ + { + "provider": "claude", + "source": "claude", + "usage": { + "primary": { + "usedPercent": 28.0, + "windowMinutes": 300, + "resetsAt": "2026-08-03T12:59:00Z" + }, + "secondary": { + "usedPercent": 6.0, + "windowMinutes": 10080, + "resetsAt": "2026-08-10T07:59:00Z" + }, + "tertiary": { + "usedPercent": 2.0 + }, + "extraRateWindows": [{ + "title": "Fable only", + "window": { + "usedPercent": 0.0, + "windowMinutes": 10080, + "resetsAt": "2026-08-10T07:59:00Z" + }, + "id": "claude-weekly-scoped-fable" + }], + "updatedAt": "2026-08-03T11:12:34Z", + "identity": { + "accountEmail": "private@example.com", + "accountOrganization": "private-org" + } + }, + "credits": {"remaining": 123}, + "pace": {"primary": {"summary": "private pace"}} + }, + { + "provider": "codex", + "source": "oauth", + "usage": { + "primary": null, + "secondary": { + "usedPercent": 48.0, + "windowMinutes": 10080, + "resetsAt": "2026-08-09T09:22:51Z" + }, + "updatedAt": "2026-08-03T11:12:35Z", + "accountEmail": "private@example.com" + } + }, + { + "provider": "kimi", + "source": "web", + "error": { + "kind": "provider", + "code": 1, + "message": "raw private failure" + } + }, + { + "provider": "grok", + "source": "grok-web", + "usage": { + "primary": { + "usedPercent": 18.0, + "resetsAt": "2026-08-06T06:36:08Z" + }, + "updatedAt": "2026-08-03T11:12:38Z", + "identity": {"accountEmail": "private@example.com"} + } + } + ])) + .unwrap() + } + + fn success(provider: &str, used_percent: f64) -> Value { + serde_json::json!({ + "provider": provider, + "source": "any-configured-source", + "usage": { + "primary": {"usedPercent": used_percent}, + "updatedAt": "2026-08-03T11:12:38Z" + } + }) + } + + fn snapshot(provider: &str) -> CodexBarSnapshot { + CodexBarSnapshot { + providers: vec![CodexBarProviderSnapshot { + provider: provider.to_string(), + windows: vec![CodexBarWindow { + id: "primary".to_string(), + label: "Primary".to_string(), + used_pct: 12.0, + resets_at: None, + window_minutes: None, + }], + updated_at: Some(1), + error: None, + }], + } + } + + #[cfg(not(windows))] + fn child_handle(pid: u32) -> ChildProcessHandle { + ChildProcessHandle { + pid, + #[cfg(windows)] + job: None, + #[cfg(windows)] + process: None, + } + } + + #[test] + fn parser_keeps_all_valid_providers_and_sanitizes_provider_errors() { + let parsed = parse_codexbar(&mixed_response()).expect("mixed response"); + assert_eq!( + parsed + .providers + .iter() + .map(|provider| provider.provider.as_str()) + .collect::>(), + ["claude", "codex", "kimi", "grok"] + ); + assert_eq!(parsed.state(), CodexBarQuotaState::Partial); + + let claude = &parsed.providers[0]; + assert_eq!(claude.windows.len(), 4); + assert_eq!(claude.windows[0].label, "Primary"); + assert_eq!(claude.windows[2].label, "Tertiary"); + assert_eq!(claude.windows[3].id, "claude-weekly-scoped-fable"); + assert_eq!(claude.windows[3].label, "Fable only"); + + let codex = &parsed.providers[1]; + assert_eq!(codex.windows.len(), 1); + assert_eq!(codex.windows[0].id, "secondary"); + + let kimi = &parsed.providers[2]; + assert_eq!(kimi.error, Some(CodexBarProviderError::Unavailable)); + assert!(kimi.windows.is_empty()); + + let grok = &parsed.providers[3]; + assert_eq!(grok.windows[0].window_minutes, None); + assert_eq!(grok.windows[0].used_pct, 18.0); + + let debug = format!("{parsed:?}"); + for private in [ + "private@example.com", + "private-org", + "private pace", + "raw private failure", + "oauth", + "grok-web", + ] { + assert!( + !debug.contains(private), + "retained private field: {private}" + ); + } + } + + #[test] + fn parser_accepts_singleton_and_empty_configured_provider_sets() { + let singleton = serde_json::to_vec(&success("GROK", 18.0)).unwrap(); + let parsed = parse_codexbar(&singleton).expect("singleton"); + assert_eq!(parsed.providers[0].provider, "grok"); + assert_eq!(parsed.state(), CodexBarQuotaState::Available); + + let empty = parse_codexbar(b"[]").expect("empty configured set"); + assert!(empty.providers.is_empty()); + assert_eq!(empty.state(), CodexBarQuotaState::Unavailable); + } + + #[test] + fn malformed_provider_is_isolated_from_valid_providers() { + let response = serde_json::to_vec(&serde_json::json!([ + success("claude", 25.0), + { + "provider": "grok", + "usage": {"primary": {"usedPercent": "not-a-number"}} + }, + {"provider": "kimi", "error": {"message": "private"}} + ])) + .unwrap(); + let parsed = parse_codexbar(&response).expect("partially valid response"); + assert!(parsed.providers[0].is_available()); + assert_eq!( + parsed.providers[1].error, + Some(CodexBarProviderError::InvalidResponse) + ); + assert_eq!( + parsed.providers[2].error, + Some(CodexBarProviderError::Unavailable) + ); + } + + #[test] + fn duplicate_provider_is_a_sanitized_provider_failure() { + let response = + serde_json::to_vec(&vec![success("Claude", 1.0), success("claude", 2.0)]).unwrap(); + let parsed = parse_codexbar(&response).expect("duplicate is isolated"); + assert_eq!(parsed.providers.len(), 1); + assert_eq!(parsed.providers[0].provider, "claude"); + assert_eq!( + parsed.providers[0].error, + Some(CodexBarProviderError::DuplicateProvider) + ); + assert!(parsed.providers[0].windows.is_empty()); + } + + #[test] + fn provider_and_window_bounds_fail_closed() { + let providers = (0..=MAX_PROVIDERS) + .map(|index| success(&format!("provider-{index}"), 1.0)) + .collect::>(); + assert_eq!( + parse_codexbar(&serde_json::to_vec(&providers).unwrap()).unwrap_err(), + CodexBarPollError::UnsupportedResponse + ); + + let extra = (0..MAX_WINDOWS_PER_PROVIDER) + .map(|index| { + serde_json::json!({ + "id": format!("extra-{index}"), + "title": format!("Extra {index}"), + "window": {"usedPercent": 1.0} + }) + }) + .collect::>(); + let response = serde_json::to_vec(&serde_json::json!([{ + "provider": "claude", + "usage": { + "primary": {"usedPercent": 1.0}, + "extraRateWindows": extra + } + }])) + .unwrap(); + let parsed = parse_codexbar(&response).expect("provider failure snapshot"); + assert_eq!( + parsed.providers[0].error, + Some(CodexBarProviderError::TooManyWindows) + ); + + assert_eq!( + parse_codexbar(&vec![b'x'; MAX_OUTPUT + 1]).unwrap_err(), + CodexBarPollError::OutputTooLarge + ); + } + + #[test] + fn provider_window_ids_and_labels_are_bounded_without_truncation() { + let invalid_provider = + serde_json::to_vec(&success(&"p".repeat(MAX_PROVIDER_ID_BYTES + 1), 1.0)).unwrap(); + assert_eq!( + parse_codexbar(&invalid_provider).unwrap_err(), + CodexBarPollError::InvalidResponse + ); + + for (id, title) in [ + ( + "i".repeat(MAX_RATE_LIMIT_WINDOW_ID_BYTES + 1), + "Extra".to_string(), + ), + ( + "extra".to_string(), + "l".repeat(MAX_RATE_LIMIT_WINDOW_LABEL_BYTES + 1), + ), + ("extra\nunsafe".to_string(), "Extra".to_string()), + ("extra".to_string(), "Extra\u{1b}".to_string()), + ("extra\u{202e}".to_string(), "Extra".to_string()), + ("extra".to_string(), "Extra\u{2066}".to_string()), + ] { + let response = serde_json::to_vec(&serde_json::json!([{ + "provider": "claude", + "usage": { + "extraRateWindows": [{ + "id": id, + "title": title, + "window": {"usedPercent": 1.0} + }] + } + }])) + .unwrap(); + let parsed = parse_codexbar(&response).expect("bounded provider failure"); + assert_eq!( + parsed.providers[0].error, + Some(CodexBarProviderError::InvalidResponse) + ); + } + } + + #[test] + fn duplicate_window_ids_are_rejected_case_insensitively() { + for extra_id in ["PRIMARY", "weekly", "WEEKLY"] { + let extra_windows = if extra_id.eq_ignore_ascii_case("primary") { + vec![serde_json::json!({ + "id": extra_id, + "window": {"usedPercent": 2.0} + })] + } else { + vec![ + serde_json::json!({ + "id": "weekly", + "window": {"usedPercent": 2.0} + }), + serde_json::json!({ + "id": extra_id, + "window": {"usedPercent": 3.0} + }), + ] + }; + let response = serde_json::to_vec(&serde_json::json!([{ + "provider": "claude", + "usage": { + "primary": {"usedPercent": 1.0}, + "extraRateWindows": extra_windows + } + }])) + .unwrap(); + let parsed = parse_codexbar(&response).expect("duplicate is provider-local"); + assert_eq!( + parsed.providers[0].error, + Some(CodexBarProviderError::InvalidResponse) + ); + } + } + + #[test] + fn extra_window_title_falls_back_to_its_stable_id() { + let response = serde_json::to_vec(&serde_json::json!([{ + "provider": "claude", + "usage": { + "extraRateWindows": [{ + "id": "scoped-weekly", + "window": {"usedPercent": 3.0} + }] + } + }])) + .unwrap(); + let parsed = parse_codexbar(&response).expect("extra window without a title"); + assert_eq!(parsed.providers[0].windows[0].id, "scoped-weekly"); + assert_eq!(parsed.providers[0].windows[0].label, "scoped-weekly"); + } + + #[test] + fn malformed_window_metadata_is_a_provider_failure() { + for window in [ + serde_json::json!({"usedPercent": -1.0}), + serde_json::json!({"usedPercent": 101.0}), + serde_json::json!({"usedPercent": 1.0, "windowMinutes": 0}), + serde_json::json!({"usedPercent": 1.0, "resetsAt": "not-a-date"}), + ] { + let response = serde_json::to_vec(&serde_json::json!([{ + "provider": "grok", + "usage": {"primary": window} + }])) + .unwrap(); + let parsed = parse_codexbar(&response).expect("provider failure snapshot"); + assert_eq!( + parsed.providers[0].error, + Some(CodexBarProviderError::InvalidResponse) + ); + } + } + + #[test] + fn valid_stdout_wins_even_when_process_exits_nonzero() { + let parsed = parse_command_output(CommandOutput { + stdout: mixed_response(), + success: false, + }) + .expect("mixed provider failure remains a valid snapshot"); + assert_eq!(parsed.providers.len(), 4); + + assert_eq!( + parse_command_output(CommandOutput { + stdout: b"[]".to_vec(), + success: false, + }) + .unwrap_err(), + CodexBarPollError::ProcessFailed + ); + + assert_eq!( + parse_command_output(CommandOutput { + stdout: b"private shell failure".to_vec(), + success: false, + }) + .unwrap_err(), + CodexBarPollError::ProcessFailed + ); + } + + #[test] + fn poller_atomically_replaces_valid_snapshots_and_retains_on_transport_failure() { + let mut poller = CodexBarQuotaPoller::new(true); + poller.apply_result(PollResult { + generation: 0, + result: Ok(snapshot("claude")), + }); + assert_eq!(poller.status().state, CodexBarQuotaState::Available); + + poller.apply_result(PollResult { + generation: 0, + result: Ok(CodexBarSnapshot { + providers: vec![CodexBarProviderSnapshot::failure( + "kimi".to_string(), + CodexBarProviderError::Unavailable, + )], + }), + }); + assert_eq!( + poller.cached.as_ref().unwrap().providers[0].provider, + "kimi" + ); + assert_eq!(poller.status().state, CodexBarQuotaState::Unavailable); + + poller.apply_result(PollResult { + generation: 0, + result: Ok(snapshot("grok")), + }); + poller.apply_result(PollResult { + generation: 0, + result: Err(CodexBarPollError::TimedOut), + }); + assert_eq!( + poller.cached.as_ref().unwrap().providers[0].provider, + "grok" + ); + assert_eq!(poller.status().state, CodexBarQuotaState::Partial); + assert_eq!(poller.status().error, Some(CodexBarPollError::TimedOut)); + } + + #[test] + fn poller_is_opt_in_periodic_and_generation_safe() { + let mut poller = CodexBarQuotaPoller::new(false); + assert!(poller.update().is_none()); + assert!(!poller.should_start()); + assert_eq!(poller.status().state, CodexBarQuotaState::Off); + + poller.set_enabled(true); + assert!(poller.should_start()); + poller.in_flight = Some(1); + assert!(!poller.should_start()); + assert_eq!(poller.status().state, CodexBarQuotaState::Checking); + + poller.set_enabled(false); + poller.apply_result(PollResult { + generation: 1, + result: Ok(snapshot("claude")), + }); + assert!(poller.cached.is_none()); + } + + #[cfg(not(windows))] + #[test] + fn child_registration_rejects_old_generations_and_releases_exact_pid_only() { + let slot = Mutex::new(ChildProcessSlot::new(7)); + assert!(claim_child_process(&slot, 7, &child_handle(101))); + assert!(!claim_child_process(&slot, 7, &child_handle(102))); + assert!(!release_child_process(&slot, 8, 101)); + assert!(release_child_process(&slot, 7, 101)); + child_slot_guard(&slot).generation = 8; + assert!(claim_child_process(&slot, 8, &child_handle(202))); + assert!(!claim_child_process(&slot, 7, &child_handle(303))); + assert!(!release_child_process(&slot, 7, 101)); + assert!(release_child_process(&slot, 8, 202)); + } + + #[test] + fn reaped_observation_removes_the_registered_identity_before_returning() { + let slot = Arc::new(Mutex::new(ChildProcessSlot::new(7))); + child_slot_guard(&slot).pid = Some(101); + let mut registration = Some(ChildProcessRegistration { + slot: slot.clone(), + generation: 7, + pid: 101, + }); + + let observed = + observe_registered_child(&mut registration, || Ok::<_, ()>(Some("exited"))).unwrap(); + + assert_eq!(observed, Some("exited")); + assert!(registration.is_none()); + assert_eq!(child_slot_guard(&slot).pid, None); + } + + #[test] + fn output_reader_is_independently_bounded() { + let (tx, rx) = mpsc::channel(); + let _task = spawn_bounded_reader(std::io::Cursor::new(vec![b'x'; MAX_OUTPUT + 1]), 4, tx); + let (slot, bytes, truncated, read_failed) = + rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(slot, 4); + assert_eq!(bytes.len(), MAX_OUTPUT); + assert!(truncated); + assert!(!read_failed); + } + + #[test] + fn output_reader_stops_reading_as_soon_as_the_cap_is_exceeded() { + struct EndlessReader(Arc); + + impl Read for EndlessReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + self.0.fetch_add(1, Ordering::Relaxed); + buffer.fill(b'x'); + Ok(buffer.len()) + } + } + + let reads = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (tx, rx) = mpsc::channel(); + let _task = spawn_bounded_reader(EndlessReader(reads.clone()), 0, tx); + let (_, bytes, truncated, read_failed) = rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(bytes.len(), MAX_OUTPUT); + assert!(truncated); + assert!(!read_failed); + let completed_reads = reads.load(Ordering::Relaxed); + std::thread::sleep(Duration::from_millis(10)); + assert_eq!(reads.load(Ordering::Relaxed), completed_reads); + } + + #[cfg(unix)] + #[test] + fn termination_and_reaping_never_block_past_the_cleanup_bound() { + use std::os::unix::process::CommandExt; + + let mut command = Command::new("/bin/sh"); + command + .args(["-c", "sleep 5"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .process_group(0); + let child = command.spawn().unwrap(); + let process = ChildProcessHandle::for_child(&child); + let slot = Arc::new(Mutex::new(ChildProcessSlot::new(3))); + let mut registration = ChildProcessRegistration::claim(&slot, 3, &process); + assert!(registration.is_some()); + + let started = Instant::now(); + terminate_child_bounded(child, &process, &mut registration, Instant::now()); + + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(registration.is_none()); + assert_eq!(child_slot_guard(&slot).pid, None); + } + + #[cfg(windows)] + #[test] + fn windows_registration_retains_an_exact_process_handle() { + let child = Command::new("cmd") + .args(["/C", "ping -n 6 127.0.0.1 >NUL"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let process = ChildProcessHandle::for_child(&child); + assert!(process.process.is_some()); + let slot = Arc::new(Mutex::new(ChildProcessSlot::new(4))); + let mut registration = ChildProcessRegistration::claim(&slot, 4, &process); + assert!(registration.is_some()); + let rotated = rotate_child_generation(&slot, 5).expect("exact registered process"); + assert!(rotated.process.is_some()); + rotated.terminate(); + terminate_child_bounded( + child, + &process, + &mut registration, + Instant::now() + CHILD_REAP_TIMEOUT, + ); + } + + #[cfg(windows)] + #[test] + fn windows_reader_cancels_without_waiting_for_pipe_eof() { + let mut child = Command::new("cmd") + .args(["/C", "ping -n 6 127.0.0.1 >NUL"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let process = ChildProcessHandle::for_child(&child); + let stdout = child.stdout.take().unwrap(); + let (tx, _rx) = mpsc::channel(); + let mut task = spawn_command_pipe_reader(stdout, 0, tx); + + std::thread::sleep(Duration::from_millis(10)); + task.cancel(); + let deadline = Instant::now() + READER_CANCEL_TIMEOUT; + while task + .handle + .as_ref() + .is_some_and(|handle| !handle.is_finished()) + && Instant::now() < deadline + { + std::thread::sleep(Duration::from_millis(1)); + } + let handle = task.handle.take().unwrap(); + assert!(handle.is_finished()); + handle.join().unwrap(); + terminate_child_bounded( + child, + &process, + &mut None, + Instant::now() + CHILD_REAP_TIMEOUT, + ); + } + + #[test] + fn invocation_uses_codexbar_configured_providers_without_source_overrides() { + assert_eq!( + CODEXBAR_ARGS, + ["usage", "--format", "json", "--json-only", "--no-color"] + ); + } + + #[test] + fn error_enums_have_stable_sanitized_wire_values() { + assert_eq!( + serde_json::to_string(&CodexBarProviderError::Unavailable).unwrap(), + "\"unavailable\"" + ); + assert_eq!( + serde_json::to_string(&CodexBarProviderError::DuplicateProvider).unwrap(), + "\"duplicate_provider\"" + ); + assert_eq!( + serde_json::to_string(&CodexBarQuotaState::Partial).unwrap(), + "\"partial\"" + ); + } +} diff --git a/src/collector/grok.rs b/src/collector/grok.rs new file mode 100644 index 0000000..b5c7724 --- /dev/null +++ b/src/collector/grok.rs @@ -0,0 +1,3970 @@ +//! Collector for xAI Grok Build sessions (`~/.grok`). + +use super::{ + abbrev_path, process, redact_secrets, sanitize_terminal_text, AgentCollector, SharedProcessData, +}; +use crate::model::{ + AgentSession, ChatMessage, ChatRole, ChildProcess, FileAccess, FileOp, SessionStatus, + StatusAuthority, StatusEvidence, StatusObservation, StatusReason, SubAgent, ToolCall, + MAX_CHAT_MESSAGES, MAX_FILE_ACCESSES, +}; +use serde_json::Value; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs::{self, File}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +const MAX_REGISTRY_BYTES: u64 = 4 * 1024 * 1024; +const MAX_JSON_BYTES: u64 = 4 * 1024 * 1024; +const MAX_UPDATE_LINE_BYTES: usize = 2 * 1024 * 1024; +const MAX_UPDATE_READ_BYTES: u64 = 32 * 1024 * 1024; +const MAX_EVENT_LINE_BYTES: usize = 64 * 1024; +const MAX_EVENT_READ_BYTES: u64 = 32 * 1024 * 1024; +const MAX_SESSION_GROUPS: usize = 2_000; +const MAX_UPDATE_CACHES: usize = 128; +const MAX_EVENT_CACHES: usize = 128; +const MAX_PENDING_PERMISSION_NAMES: usize = 256; +const MAX_PENDING_PERMISSIONS_PER_NAME: usize = 64; +const MAX_TOOL_CALLS: usize = 500; +const MAX_LEADER_LOCK_BYTES: u64 = 32; +const FILE_PREFIX_BYTES: u64 = 256; + +pub struct GrokCollector { + roots: Vec, + session_dirs: HashMap<(PathBuf, String), PathBuf>, + updates: HashMap, + events: HashMap, +} + +impl GrokCollector { + pub fn new() -> Self { + Self { + roots: default_roots(), + session_dirs: HashMap::new(), + updates: HashMap::new(), + events: HashMap::new(), + } + } + + fn refresh_roots(&mut self, shared: &SharedProcessData) { + let mut roots = default_roots(); + for (&pid, info) in &shared.process_info { + if !is_grok_process(&info.command) { + continue; + } + if let Some(root) = process_grok_root(pid) { + roots.push(root); + } + } + roots.sort(); + roots.dedup(); + self.roots = roots; + } + + fn find_session_dir(&mut self, root: &Path, session_id: &str) -> Option { + let key = (root.to_path_buf(), session_id.to_string()); + if let Some(path) = self.session_dirs.get(&key) { + if valid_session_dir(root, path, session_id) { + return Some(path.clone()); + } + self.session_dirs.remove(&key); + } + + let sessions_root = root.join("sessions"); + if is_symlink(&sessions_root) { + return None; + } + let groups = fs::read_dir(&sessions_root).ok()?; + for group in groups.flatten().take(MAX_SESSION_GROUPS) { + let group_path = group.path(); + if is_symlink(&group_path) || !group_path.is_dir() { + continue; + } + let candidate = group_path.join(session_id); + if valid_session_dir(root, &candidate, session_id) { + self.session_dirs.insert(key, candidate.clone()); + return Some(candidate); + } + } + None + } + + fn parse_updates(&mut self, path: &Path) -> UpdateState { + if is_symlink(path) { + self.updates.remove(path); + return UpdateState { + lifecycle_failure: Some(StatusReason::ProtocolMalformed), + ..UpdateState::default() + }; + } + let cache = self.updates.entry(path.to_path_buf()).or_default(); + let availability = cache.refresh(path); + let mut state = cache.state.clone(); + if let EventAvailability::Failed(reason) = availability { + state.lifecycle_failure = Some(reason); + } + state + } + + fn parse_events(&mut self, path: &Path) -> (EventState, EventAvailability) { + if is_symlink(path) { + return ( + EventState::default(), + EventAvailability::Failed(StatusReason::ProtocolMalformed), + ); + } + let cache = self.events.entry(path.to_path_buf()).or_default(); + let availability = cache.refresh(path); + (cache.state.clone(), availability) + } + + fn build_row( + &mut self, + active: &ActiveSession, + owns_process_resources: bool, + shared: &SharedProcessData, + ) -> Option { + let state = self.parse_updates(&active.dir.join("updates.jsonl")); + let (events, event_availability) = self.parse_events(&active.dir.join("events.jsonl")); + let (awaiting_plan_approval, plan_availability) = + read_awaiting_plan_approval(&active.dir.join("plan_mode.json")); + let signals = read_signals(&active.dir.join("signals.json")); + let context_window = signals.context_window.unwrap_or(0); + let context_tokens = signals + .context_tokens + .or(state.meta_context_tokens) + .unwrap_or(0); + let context_percent = if context_window > 0 { + signals + .context_percent + .unwrap_or(context_tokens as f64 * 100.0 / context_window as f64) + } else { + 0.0 + }; + let lifecycle = grok_status_decision( + &state, + &events, + event_availability, + plan_availability, + active.opened_at, + awaiting_plan_approval, + ); + let status = lifecycle.status; + let awaiting_input = lifecycle.awaiting_input; + let current_tasks = grok_current_tasks(&state, status, active.opened_at); + let pending_since_ms = match &status { + SessionStatus::Waiting | SessionStatus::Executing => lifecycle.status_since_ms, + _ => 0, + }; + let thinking_since_ms = if status == SessionStatus::Thinking { + lifecycle.status_since_ms + } else { + 0 + }; + let proc_info = shared.process_info.get(&active.pid); + let children = if owns_process_resources { + collect_children(active.pid, shared) + } else { + Vec::new() + }; + + Some(AgentSession { + agent_cli: "grok", + pid: active.pid, + action_process_incarnation: Some(active.action_process_incarnation.clone()), + session_id: active.meta.id.clone(), + cwd: active.meta.cwd.clone(), + project_name: process::last_path_segment(&active.meta.cwd) + .unwrap_or("?") + .to_string(), + started_at: active.meta.created_at, + status, + status_evidence: evidence_for(lifecycle), + model: if active.meta.model.is_empty() { + state.model.clone() + } else { + active.meta.model.clone() + }, + effort: active.meta.effort.clone(), + context_percent, + total_input_tokens: state.total_input, + total_output_tokens: state.total_output, + total_cache_read: state.total_cache_read, + total_cache_create: state.total_cache_create, + turn_count: signals.turn_count.unwrap_or(state.turn_count), + current_tasks, + mem_mb: if owns_process_resources { + proc_info.map_or(0, |info| info.rss_kb / 1024) + } else { + 0 + }, + version: String::new(), + git_branch: String::new(), + git_added: 0, + git_modified: 0, + token_history: state.token_history.clone(), + context_history: state.context_history.clone(), + compaction_count: signals.compaction_count.unwrap_or(state.compaction_count), + context_window, + subagents: state + .subagents + .values() + .map(SubagentState::to_model) + .collect(), + mem_file_count: 0, + mem_line_count: 0, + children, + initial_prompt: active.meta.title.clone(), + first_assistant_text: state.first_assistant_text.clone(), + chat_messages: state.chat_messages.clone(), + tool_calls: state.tool_calls.clone(), + pending_since_ms, + awaiting_input, + thinking_since_ms, + file_accesses: state.file_accesses.clone(), + config_root: abbrev_path(&active.root), + }) + } +} + +fn grok_current_tasks(state: &UpdateState, status: SessionStatus, opened_at: u64) -> Vec { + let mut tasks = match status { + SessionStatus::Waiting => vec!["waiting for user input".to_string()], + SessionStatus::Executing => state.current_work_labels(opened_at), + SessionStatus::Thinking => vec!["thinking".to_string()], + SessionStatus::Error => vec!["error".to_string()], + SessionStatus::Idle => vec!["idle".to_string()], + SessionStatus::Unknown => vec!["status evidence unavailable".to_string()], + _ => Vec::new(), + }; + if status == SessionStatus::Executing && tasks.is_empty() { + tasks.push("executing".to_string()); + } + tasks +} + +#[cfg(test)] +fn grok_session_status( + state: &UpdateState, + events: &EventState, + opened_at: u64, + awaiting_plan_approval: bool, +) -> (SessionStatus, bool) { + let decision = grok_status_decision( + state, + events, + EventAvailability::Available, + EventAvailability::Missing, + opened_at, + awaiting_plan_approval, + ); + (decision.status, decision.awaiting_input) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct GrokStatusDecision { + status: SessionStatus, + awaiting_input: bool, + authority: StatusAuthority, + reason: StatusReason, + status_since_ms: u64, +} + +impl GrokStatusDecision { + fn provider(status: SessionStatus, reason: StatusReason, status_since_ms: u64) -> Self { + Self { + status, + awaiting_input: status == SessionStatus::Waiting, + authority: StatusAuthority::Provider, + reason, + status_since_ms, + } + } + + fn unavailable(reason: StatusReason) -> Self { + Self { + status: SessionStatus::Unknown, + awaiting_input: false, + authority: StatusAuthority::Unavailable, + reason, + status_since_ms: 0, + } + } +} + +fn grok_status_decision( + state: &UpdateState, + events: &EventState, + event_availability: EventAvailability, + plan_availability: EventAvailability, + opened_at: u64, + awaiting_plan_approval: bool, +) -> GrokStatusDecision { + if let Some(reason) = state.lifecycle_failure { + return GrokStatusDecision::unavailable(reason); + } + if let EventAvailability::Failed(reason) = plan_availability { + return GrokStatusDecision::unavailable(reason); + } + if let EventAvailability::Failed(reason) = event_availability { + return GrokStatusDecision::unavailable(reason); + } + + if awaiting_plan_approval { + return GrokStatusDecision::provider( + SessionStatus::Waiting, + StatusReason::ProviderWaitingApproval, + opened_at, + ); + } + + if let Some(since) = state.waiting_since(opened_at) { + return GrokStatusDecision::provider( + SessionStatus::Waiting, + StatusReason::ProviderWaitingUserInput, + since, + ); + } + + match event_availability { + EventAvailability::Available => { + if let Some(since) = events.waiting_since(opened_at) { + return GrokStatusDecision::provider( + SessionStatus::Waiting, + StatusReason::ProviderWaitingApproval, + since, + ); + } + } + EventAvailability::Missing => {} + EventAvailability::Failed(_) => unreachable!("failed event evidence was rejected above"), + } + + if state.rate_limited_since > 0 && state.rate_limited_since >= opened_at { + return GrokStatusDecision::provider( + SessionStatus::RateLimited, + StatusReason::ProviderRateLimit, + state.rate_limited_since, + ); + } + + if state.fatal_error_since > 0 && state.fatal_error_since >= opened_at { + return GrokStatusDecision::provider( + SessionStatus::Error, + StatusReason::ProviderError, + state.fatal_error_since, + ); + } + + let event_executing = (event_availability == EventAvailability::Available) + .then(|| events.executing_since(opened_at)) + .flatten(); + if let Some(since) = min_nonzero(state.executing_since(opened_at), event_executing) { + return GrokStatusDecision::provider( + SessionStatus::Executing, + StatusReason::ProviderExecuting, + since, + ); + } + + let event_thinking = (event_availability == EventAvailability::Available) + .then(|| events.thinking_since(opened_at)) + .flatten(); + if let Some(since) = min_nonzero(state.thinking_since(opened_at), event_thinking) { + return GrokStatusDecision::provider( + SessionStatus::Thinking, + StatusReason::ProviderThinking, + since, + ); + } + + GrokStatusDecision::provider(SessionStatus::Idle, StatusReason::ProviderIdle, 0) +} + +fn evidence_for(decision: GrokStatusDecision) -> StatusEvidence { + let observed_at_ms = current_time_ms(); + let mut evidence = StatusEvidence::default(); + evidence.observe(StatusObservation::new( + decision.status, + decision.authority, + decision.reason, + observed_at_ms, + 0, + )); + if decision.status_since_ms > 0 { + evidence.status_since_ms = decision.status_since_ms; + } + evidence +} + +impl Default for GrokCollector { + fn default() -> Self { + Self::new() + } +} + +impl AgentCollector for GrokCollector { + fn collect(&mut self, shared: &SharedProcessData) -> Vec { + if shared.slow_tick || self.roots.is_empty() { + self.refresh_roots(shared); + } + + let mut active = Vec::new(); + for root in self.roots.clone() { + let leader_pids = read_leader_pids(&root); + for entry in read_active_registry(&root) { + let Some(info) = shared.process_info.get(&entry.pid) else { + continue; + }; + if !is_grok_process(&info.command) || leader_pids.contains(&entry.pid) { + continue; + } + let Some(action_process_incarnation) = process::get_process_incarnation(entry.pid) + else { + continue; + }; + let Some(action_process_tokens) = process::get_process_tokens(entry.pid) else { + continue; + }; + if !is_grok_process_tokens(&action_process_tokens) { + continue; + } + let Some(process_started_at) = process::get_process_started_at_ms(entry.pid) else { + continue; + }; + if entry.opened_at == 0 || process_started_at > entry.opened_at { + continue; + } + let Some(dir) = self.find_session_dir(&root, &entry.session_id) else { + continue; + }; + let Some(meta) = read_summary(&dir.join("summary.json"), &entry) else { + continue; + }; + if is_hidden_grok_session(&meta) { + continue; + } + let current_incarnation = process::get_process_incarnation(entry.pid); + if !grok_process_observation_is_exact( + &action_process_incarnation, + current_incarnation.as_deref(), + &action_process_tokens, + ) { + continue; + } + active.push(ActiveSession { + root: root.clone(), + dir, + pid: entry.pid, + opened_at: entry.opened_at, + action_process_incarnation, + meta, + }); + } + } + + let mut built = Vec::new(); + let mut keep_updates = HashSet::new(); + let mut keep_events = HashSet::new(); + for session in &active { + keep_updates.insert(session.dir.join("updates.jsonl")); + keep_events.insert(session.dir.join("events.jsonl")); + if let Some(row) = self.build_row(session, false, shared) { + built.push((session.clone(), row)); + } + } + self.updates.retain(|path, _| keep_updates.contains(path)); + if self.updates.len() > MAX_UPDATE_CACHES { + self.updates.clear(); + } + self.events.retain(|path, _| keep_events.contains(path)); + if self.events.len() > MAX_EVENT_CACHES { + self.events.clear(); + } + + // Pick a resource owner only from rows that were built successfully. + // A transiently unreadable newest session must not strip process + // memory/children from every other row sharing the same Grok PID. + let successful: Vec<_> = built.iter().map(|(session, _)| session.clone()).collect(); + let resource_owner = resource_owner_indices(&successful); + // Status ambiguity applies to every validated registry entry, even if + // one row could not be built during this poll. Dropping an unreadable + // sibling from this count could make a stale survivor look exactly + // Idle merely because the competing row failed to render. + let session_counts = active.iter().fold(HashMap::new(), |mut counts, session| { + *counts.entry(session.pid).or_insert(0usize) += 1; + counts + }); + for (index, (session, row)) in built.iter_mut().enumerate() { + if resource_owner.get(&session.pid) == Some(&index) { + row.mem_mb = shared + .process_info + .get(&session.pid) + .map_or(0, |info| info.rss_kb / 1024); + row.children = collect_children(session.pid, shared); + } + + let logical_sessions = session_counts.get(&session.pid).copied().unwrap_or(1); + if let Some(override_status) = shared_pid_idle_uncertainty( + row.status, + row.status_evidence.authority, + logical_sessions, + ) { + apply_status_override(row, override_status); + } + } + built.into_iter().map(|(_, row)| row).collect() + } +} + +fn resource_owner_indices(active: &[ActiveSession]) -> HashMap { + let mut resource_owner = HashMap::::new(); + for (index, session) in active.iter().enumerate() { + resource_owner + .entry(session.pid) + .and_modify(|current| { + if active[*current].meta.updated_at < session.meta.updated_at { + *current = index; + } + }) + .or_insert(index); + } + resource_owner +} + +fn shared_pid_idle_uncertainty( + lifecycle_status: SessionStatus, + lifecycle_authority: StatusAuthority, + logical_sessions: usize, +) -> Option { + // Grok's active-session registry is best-effort: unregister may be skipped + // under lock contention. When one process owns multiple logical rows, the + // absence of lifecycle work cannot prove that any particular row is still + // open. Preserve positive provider lifecycle evidence, but do not claim a + // provider-authoritative Idle state for an ownership-ambiguous row. + (logical_sessions > 1 + && lifecycle_status == SessionStatus::Idle + && lifecycle_authority == StatusAuthority::Provider) + .then(|| GrokStatusDecision::unavailable(StatusReason::OwnershipUnconfirmed)) +} + +fn apply_status_override(row: &mut AgentSession, decision: GrokStatusDecision) { + row.status = decision.status; + row.status_evidence = evidence_for(decision); + row.awaiting_input = decision.awaiting_input; + row.pending_since_ms = 0; + row.thinking_since_ms = 0; + row.current_tasks = vec!["shared process ownership is ambiguous".to_string()]; +} + +#[derive(Clone)] +struct ActiveSession { + root: PathBuf, + dir: PathBuf, + pid: u32, + opened_at: u64, + /// Exact identity of the process validated against this registry entry. + action_process_incarnation: String, + meta: SessionMeta, +} + +#[derive(Clone)] +struct RegistryEntry { + session_id: String, + pid: u32, + cwd: String, + opened_at: u64, +} + +#[derive(Clone, Default)] +struct SessionMeta { + id: String, + cwd: String, + title: String, + model: String, + effort: String, + session_kind: String, + hidden: Option, + created_at: u64, + updated_at: u64, +} + +#[derive(Clone, Default)] +struct Signals { + context_tokens: Option, + context_window: Option, + context_percent: Option, + turn_count: Option, + compaction_count: Option, +} + +#[derive(Default)] +struct UpdateCache { + offset: u64, + prefix: Vec, + partial: Vec, + dropping_long_line: bool, + integrity_failure: Option, + state: UpdateState, +} + +impl UpdateCache { + fn refresh(&mut self, path: &Path) -> EventAvailability { + let Ok(meta) = fs::metadata(path) else { + return EventAvailability::Failed(StatusReason::Unavailable); + }; + if !meta.is_file() { + return EventAvailability::Failed(StatusReason::ProtocolMalformed); + } + let Ok(mut file) = File::open(path) else { + return EventAvailability::Failed(StatusReason::Unavailable); + }; + let mut prefix = Vec::new(); + if file + .by_ref() + .take(meta.len().min(FILE_PREFIX_BYTES)) + .read_to_end(&mut prefix) + .is_err() + { + return EventAvailability::Failed(StatusReason::Unavailable); + } + let replaced = !self.prefix.is_empty() + && (prefix.len() < self.prefix.len() || !prefix.starts_with(&self.prefix)); + if meta.len() < self.offset || replaced { + *self = Self::default(); + } + self.prefix = prefix; + if meta.len() == self.offset { + return self.availability(meta.len()); + } + if file.seek(SeekFrom::Start(self.offset)).is_err() { + return EventAvailability::Failed(StatusReason::Unavailable); + } + let mut bytes = Vec::new(); + if file + .take(MAX_UPDATE_READ_BYTES) + .read_to_end(&mut bytes) + .is_err() + { + return EventAvailability::Failed(StatusReason::Unavailable); + } + self.offset = self.offset.saturating_add(bytes.len() as u64); + self.consume(&bytes); + self.availability(meta.len()) + } + + fn consume(&mut self, bytes: &[u8]) { + for &byte in bytes { + if self.dropping_long_line { + if byte == b'\n' { + self.dropping_long_line = false; + } + continue; + } + if byte == b'\n' { + match std::str::from_utf8(&self.partial).map(str::trim) { + Ok("") => {} + Ok(line) => match serde_json::from_str::(line) { + Ok(value) => match validate_update_record(&value) { + Ok(()) => self.state.apply(&value), + Err(reason) => self.integrity_failure = Some(reason), + }, + Err(_) => self.integrity_failure = Some(StatusReason::ProtocolMalformed), + }, + Err(_) => self.integrity_failure = Some(StatusReason::ProtocolMalformed), + } + self.partial.clear(); + } else if self.partial.len() < MAX_UPDATE_LINE_BYTES { + self.partial.push(byte); + } else { + self.partial.clear(); + self.dropping_long_line = true; + self.integrity_failure = Some(StatusReason::ProtocolMalformed); + } + } + } + + fn availability(&self, file_len: u64) -> EventAvailability { + if let Some(reason) = self.integrity_failure { + EventAvailability::Failed(reason) + } else if self.offset < file_len || !self.partial.is_empty() || self.dropping_long_line { + EventAvailability::Failed(StatusReason::Stale) + } else { + EventAvailability::Available + } + } +} + +#[derive(Default)] +struct EventCache { + offset: u64, + prefix: Vec, + partial: Vec, + dropping_long_line: bool, + state: EventState, + seen_source: bool, + failure_reason: Option, +} + +impl EventCache { + fn refresh(&mut self, path: &Path) -> EventAvailability { + let meta = match fs::metadata(path) { + Ok(meta) => meta, + Err(error) if error.kind() == std::io::ErrorKind::NotFound && !self.seen_source => { + return EventAvailability::Missing; + } + Err(_) => return self.fail(StatusReason::Unavailable), + }; + self.seen_source = true; + if !meta.is_file() { + return self.fail(StatusReason::ProtocolMalformed); + } + let Ok(mut file) = File::open(path) else { + return self.fail(StatusReason::Unavailable); + }; + let mut prefix = Vec::new(); + if file + .by_ref() + .take(meta.len().min(FILE_PREFIX_BYTES)) + .read_to_end(&mut prefix) + .is_err() + { + return self.fail(StatusReason::Unavailable); + } + let replaced = !self.prefix.is_empty() + && (prefix.len() < self.prefix.len() || !prefix.starts_with(&self.prefix)); + if meta.len() < self.offset || replaced { + *self = Self::default(); + self.seen_source = true; + } + self.prefix = prefix; + if meta.len() == self.offset { + return self.availability(meta.len()); + } + if file.seek(SeekFrom::Start(self.offset)).is_err() { + return self.fail(StatusReason::Unavailable); + } + let mut bytes = Vec::new(); + if file + .take(MAX_EVENT_READ_BYTES) + .read_to_end(&mut bytes) + .is_err() + { + return self.fail(StatusReason::Unavailable); + } + self.offset = self.offset.saturating_add(bytes.len() as u64); + self.consume(&bytes); + self.availability(meta.len()) + } + + fn consume(&mut self, bytes: &[u8]) { + for &byte in bytes { + if self.dropping_long_line { + if byte == b'\n' { + self.dropping_long_line = false; + } + continue; + } + if byte == b'\n' { + match std::str::from_utf8(&self.partial).map(str::trim) { + Ok("") => {} + Ok(line) => match serde_json::from_str::(line) { + Ok(value) => match validate_event_record(&value) { + Ok(()) => self.state.apply(&value), + Err(reason) => self.failure_reason = Some(reason), + }, + Err(_) => self.failure_reason = Some(StatusReason::ProtocolMalformed), + }, + Err(_) => self.failure_reason = Some(StatusReason::ProtocolMalformed), + } + self.partial.clear(); + } else if self.partial.len() < MAX_EVENT_LINE_BYTES { + self.partial.push(byte); + } else { + self.partial.clear(); + self.dropping_long_line = true; + self.failure_reason = Some(StatusReason::ProtocolMalformed); + } + } + } + + fn availability(&self, file_len: u64) -> EventAvailability { + if let Some(reason) = self.failure_reason { + EventAvailability::Failed(reason) + } else if self.offset < file_len || !self.partial.is_empty() || self.dropping_long_line { + EventAvailability::Failed(StatusReason::Stale) + } else { + EventAvailability::Available + } + } + + fn fail(&mut self, reason: StatusReason) -> EventAvailability { + if reason == StatusReason::ProtocolMalformed { + self.failure_reason = Some(reason); + } + EventAvailability::Failed(reason) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EventAvailability { + Available, + Missing, + Failed(StatusReason), +} + +fn validate_update_record(record: &Value) -> Result<(), StatusReason> { + let params_value = record.get("params").unwrap_or(record); + let Some(params) = params_value.as_object() else { + return Err(StatusReason::ProtocolMalformed); + }; + let update_value = params.get("update").unwrap_or(params_value); + let Some(update) = update_value.as_object() else { + return Err(StatusReason::ProtocolMalformed); + }; + let kind = update + .get("sessionUpdate") + .or_else(|| update.get("type")) + .or_else(|| update.get("kind")) + .and_then(Value::as_str) + .unwrap_or(""); + if kind.is_empty() { + return Err(StatusReason::ProtocolMalformed); + } + if is_lifecycle_update_kind(kind) && event_time_ms(record, params_value) == 0 { + return Err(StatusReason::ProtocolMalformed); + } + match kind { + "tool_call" | "tool_call_update" + if string_field(update_value, &["toolCallId", "tool_call_id", "id"]).is_empty() => + { + Err(StatusReason::ProtocolMalformed) + } + "turn_completed" + if string_field( + update_value, + &["prompt_id", "turn_id", "promptId", "turnId", "id"], + ) + .is_empty() + || string_field(update_value, &["stop_reason", "stopReason", "reason"]) + .is_empty() => + { + Err(StatusReason::ProtocolMalformed) + } + _ => Ok(()), + } +} + +fn is_lifecycle_update_kind(kind: &str) -> bool { + matches!( + kind, + "user_message_chunk" + | "agent_message_chunk" + | "agent_thought_chunk" + | "tool_call" + | "tool_call_update" + | "response_started" + | "response_completed" + | "turn_completed" + | "rewind_marker" + | "subagent_spawned" + | "subagent_progress" + | "subagent_finished" + | "task_backgrounded" + | "task_completed" + | "monitor_event" + | "retry_state" + | "auto_compact_failed" + | "auto_recovery_exhausted" + ) +} + +fn validate_event_record(event: &Value) -> Result<(), StatusReason> { + let Some(event_object) = event.as_object() else { + return Err(StatusReason::ProtocolMalformed); + }; + let kind = event_object + .get("type") + .or_else(|| event.get("event_type")) + .and_then(Value::as_str) + .unwrap_or(""); + if kind.is_empty() { + return Err(StatusReason::ProtocolMalformed); + } + if is_lifecycle_event_kind(kind) + && parse_time( + event_object + .get("ts") + .or_else(|| event.get("timestamp")) + .unwrap_or(&Value::Null), + ) == 0 + { + return Err(StatusReason::ProtocolMalformed); + } + match kind { + "permission_requested" + | "permission_resolved" + | "tool_started" + | "tool_call_started" + | "tool_completed" + | "tool_call_completed" + if event_tool_name(event).is_none() => + { + Err(StatusReason::ProtocolMalformed) + } + "phase_changed" + if !matches!( + event_object.get("phase").and_then(Value::as_str), + Some( + "waiting_for_model" + | "streaming_text" + | "streaming_reasoning" + | "sampling" + | "tool_execution" + | "permission_prompt" + | "idle" + ) + ) => + { + Err(StatusReason::ProtocolUnknown) + } + _ => Ok(()), + } +} + +fn is_lifecycle_event_kind(kind: &str) -> bool { + matches!( + kind, + "turn_started" + | "turn_ended" + | "phase_changed" + | "tool_started" + | "tool_call_started" + | "tool_completed" + | "tool_call_completed" + | "permission_requested" + | "permission_resolved" + | "first_token" + | "loop_started" + ) +} + +#[derive(Clone, Default)] +struct EventState { + pending_permissions: HashMap>, + pending_tools: HashMap>, + active_turn_started_at: u64, + active_turn_last_activity_at: u64, + phase: EventPhase, + phase_since: u64, +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum EventPhase { + #[default] + None, + Thinking, + Executing, + Waiting, +} + +impl EventState { + fn apply(&mut self, event: &Value) { + let timestamp = parse_time( + event + .get("ts") + .or_else(|| event.get("timestamp")) + .unwrap_or(&Value::Null), + ); + match event + .get("type") + .or_else(|| event.get("event_type")) + .and_then(Value::as_str) + .unwrap_or("") + { + "turn_started" => { + self.pending_permissions.clear(); + self.pending_tools.clear(); + self.active_turn_started_at = timestamp; + self.active_turn_last_activity_at = timestamp; + self.phase = EventPhase::Thinking; + self.phase_since = timestamp; + } + "turn_ended" => { + self.pending_permissions.clear(); + self.pending_tools.clear(); + self.active_turn_started_at = 0; + self.active_turn_last_activity_at = 0; + self.phase = EventPhase::None; + self.phase_since = 0; + } + "phase_changed" => { + self.phase = match event.get("phase").and_then(Value::as_str).unwrap_or("") { + "waiting_for_model" | "streaming_text" | "streaming_reasoning" | "sampling" => { + EventPhase::Thinking + } + "tool_execution" => EventPhase::Executing, + "permission_prompt" => EventPhase::Waiting, + "idle" => EventPhase::None, + _ => return, + }; + if self.phase == EventPhase::None { + self.phase_since = 0; + self.active_turn_started_at = 0; + self.active_turn_last_activity_at = 0; + } else { + self.phase_since = timestamp; + self.active_turn_last_activity_at = timestamp; + if self.active_turn_started_at == 0 { + self.active_turn_started_at = timestamp; + } + } + } + "tool_started" | "tool_call_started" => { + let Some(tool_name) = event_tool_name(event) else { + return; + }; + if !self.pending_tools.contains_key(&tool_name) + && self.pending_tools.len() >= MAX_PENDING_PERMISSION_NAMES + { + return; + } + let pending = self.pending_tools.entry(tool_name).or_default(); + if pending.len() < MAX_PENDING_PERMISSIONS_PER_NAME { + pending.push_back(timestamp); + } + self.phase = EventPhase::Executing; + self.phase_since = timestamp; + self.active_turn_last_activity_at = timestamp; + } + "tool_completed" | "tool_call_completed" => { + let Some(tool_name) = event_tool_name(event) else { + return; + }; + remove_oldest_named_event(&mut self.pending_tools, &tool_name); + self.active_turn_last_activity_at = timestamp; + } + "permission_requested" => { + let Some(tool_name) = event_tool_name(event) else { + return; + }; + if timestamp == 0 { + return; + } + if !self.pending_permissions.contains_key(&tool_name) + && self.pending_permissions.len() >= MAX_PENDING_PERMISSION_NAMES + { + return; + } + let pending = self.pending_permissions.entry(tool_name).or_default(); + if pending.len() < MAX_PENDING_PERMISSIONS_PER_NAME { + pending.push_back(timestamp); + } + self.phase = EventPhase::Waiting; + self.phase_since = timestamp; + self.active_turn_last_activity_at = timestamp; + } + "permission_resolved" => { + let Some(tool_name) = event_tool_name(event) else { + return; + }; + remove_oldest_named_event(&mut self.pending_permissions, &tool_name); + self.phase = EventPhase::Executing; + self.phase_since = timestamp; + self.active_turn_last_activity_at = timestamp; + } + "first_token" | "loop_started" => self.active_turn_last_activity_at = timestamp, + _ => {} + } + } + + fn waiting_since(&self, opened_at: u64) -> Option { + let permission = self + .pending_permissions + .values() + .flatten() + .copied() + .filter(|timestamp| *timestamp >= opened_at) + .min(); + let phase = (self.phase == EventPhase::Waiting && self.phase_since >= opened_at) + .then_some(self.phase_since); + min_nonzero(permission, phase) + } + + fn executing_since(&self, opened_at: u64) -> Option { + let tool = self + .pending_tools + .values() + .flatten() + .copied() + .filter(|timestamp| *timestamp >= opened_at) + .min(); + let phase = (self.phase == EventPhase::Executing && self.phase_since >= opened_at) + .then_some(self.phase_since); + min_nonzero(tool, phase) + } + + fn thinking_since(&self, opened_at: u64) -> Option { + let phase = (self.phase == EventPhase::Thinking && self.phase_since >= opened_at) + .then_some(self.phase_since); + let active_turn = observed_since( + self.active_turn_started_at, + self.active_turn_last_activity_at, + opened_at, + ) + .then_some(if self.active_turn_started_at >= opened_at { + self.active_turn_started_at + } else { + self.active_turn_last_activity_at + }); + min_nonzero(phase, active_turn) + } +} + +fn remove_oldest_named_event(events: &mut HashMap>, name: &str) { + let should_remove = events.get_mut(name).is_some_and(|pending| { + pending.pop_front(); + pending.is_empty() + }); + if should_remove { + events.remove(name); + } +} + +fn event_tool_name(event: &Value) -> Option { + let tool_name = event.get("tool_name")?.as_str()?.trim(); + if tool_name.is_empty() { + return None; + } + Some( + tool_name + .chars() + .take(256) + .collect::() + .to_ascii_lowercase(), + ) +} + +#[derive(Clone, Default)] +struct UpdateState { + model: String, + total_input: u64, + total_output: u64, + total_cache_read: u64, + total_cache_create: u64, + turn_count: u32, + active_turn: bool, + turn_started_at: u64, + turn_last_activity_at: u64, + pending_tools: HashMap, + background_tasks: HashMap, + last_error: Option, + fatal_error_since: u64, + rate_limited_since: u64, + lifecycle_failure: Option, + token_history: Vec, + context_history: Vec, + compaction_count: u32, + meta_context_tokens: Option, + pending_response_usage: Usage, + seen_event_ids: HashSet, + seen_turn_ids: HashSet, + seen_tool_ids: HashSet, + subagents: HashMap, + first_assistant_text: String, + chat_messages: Vec, + tool_calls: Vec, + tool_indices: HashMap, + file_accesses: Vec, +} + +#[derive(Clone, Copy, Default)] +struct Usage { + input: u64, + output: u64, + cache_read: u64, + cache_create: u64, +} + +impl Usage { + fn total(self) -> u64 { + self.input + self.output + self.cache_read + self.cache_create + } + + fn add(&mut self, other: Self) { + self.input = self.input.saturating_add(other.input); + self.output = self.output.saturating_add(other.output); + self.cache_read = self.cache_read.saturating_add(other.cache_read); + self.cache_create = self.cache_create.saturating_add(other.cache_create); + } +} + +#[derive(Clone)] +struct PendingTool { + name: String, + arg: String, + started_at: u64, + last_update_at: u64, + waits_for_user: bool, +} + +impl PendingTool { + fn label(&self) -> String { + if self.arg.is_empty() { + self.name.clone() + } else { + format!("{} {}", self.name, self.arg) + } + } + + fn observed_since(&self, opened_at: u64) -> bool { + observed_since(self.started_at, self.last_update_at, opened_at) + } + + fn current_since(&self, opened_at: u64) -> u64 { + if self.started_at >= opened_at { + self.started_at + } else if self.last_update_at >= opened_at { + self.last_update_at + } else { + 0 + } + } +} + +#[derive(Clone)] +struct BackgroundTask { + label: String, + started_at: u64, + last_update_at: u64, +} + +impl BackgroundTask { + fn observed_since(&self, opened_at: u64) -> bool { + observed_since(self.started_at, self.last_update_at, opened_at) + } + + fn current_since(&self, opened_at: u64) -> u64 { + if self.started_at >= opened_at { + self.started_at + } else if self.last_update_at >= opened_at { + self.last_update_at + } else { + 0 + } + } +} + +#[derive(Clone, Default)] +struct SubagentState { + name: String, + status: String, + tokens: u64, + started_at: u64, + last_update_at: u64, +} + +impl SubagentState { + fn to_model(&self) -> SubAgent { + SubAgent { + name: self.name.clone(), + status: self.status.clone(), + tokens: self.tokens, + } + } + + fn is_running_since(&self, opened_at: u64) -> bool { + matches!( + self.status.to_ascii_lowercase().as_str(), + "working" | "running" | "in_progress" | "pending" | "starting" + ) && observed_since(self.started_at, self.last_update_at, opened_at) + } + + fn current_since(&self, opened_at: u64) -> u64 { + if self.started_at >= opened_at { + self.started_at + } else if self.last_update_at >= opened_at { + self.last_update_at + } else { + 0 + } + } + + fn label(&self) -> String { + if self.name.is_empty() { + "Subagent".to_string() + } else { + format!("Subagent {}", self.name) + } + } +} + +impl UpdateState { + fn waiting_since(&self, opened_at: u64) -> Option { + self.pending_tools + .values() + .filter(|tool| tool.waits_for_user) + .map(|tool| tool.current_since(opened_at)) + .filter(|timestamp| *timestamp > 0) + .min() + } + + fn executing_since(&self, opened_at: u64) -> Option { + let tool = self + .pending_tools + .values() + .filter(|tool| !tool.waits_for_user && tool.observed_since(opened_at)) + .map(|tool| tool.current_since(opened_at)) + .filter(|timestamp| *timestamp > 0) + .min(); + let task = self + .background_tasks + .values() + .filter(|task| task.observed_since(opened_at)) + .map(|task| task.current_since(opened_at)) + .filter(|timestamp| *timestamp > 0) + .min(); + let subagent = self + .subagents + .values() + .filter(|subagent| subagent.is_running_since(opened_at)) + .map(|subagent| subagent.current_since(opened_at)) + .filter(|timestamp| *timestamp > 0) + .min(); + min_nonzero(min_nonzero(tool, task), subagent) + } + + fn thinking_since(&self, opened_at: u64) -> Option { + (self.active_turn + && observed_since(self.turn_started_at, self.turn_last_activity_at, opened_at)) + .then_some(if self.turn_started_at >= opened_at { + self.turn_started_at + } else { + self.turn_last_activity_at + }) + } + + fn current_work_labels(&self, opened_at: u64) -> Vec { + let mut labels = Vec::new(); + labels.extend( + self.pending_tools + .values() + .filter(|tool| !tool.waits_for_user && tool.observed_since(opened_at)) + .map(|tool| (tool.current_since(opened_at), tool.label())), + ); + labels.extend( + self.background_tasks + .values() + .filter(|task| task.observed_since(opened_at)) + .map(|task| (task.current_since(opened_at), task.label.clone())), + ); + labels.extend( + self.subagents + .values() + .filter(|subagent| subagent.is_running_since(opened_at)) + .map(|subagent| (subagent.current_since(opened_at), subagent.label())), + ); + labels.sort_by_key(|(timestamp, label)| (*timestamp, label.clone())); + labels.into_iter().map(|(_, label)| label).collect() + } + + fn apply(&mut self, record: &Value) { + let params = record.get("params").unwrap_or(record); + if let Some(total) = params + .get("_meta") + .and_then(|meta| meta.get("totalTokens")) + .and_then(Value::as_u64) + { + self.meta_context_tokens = Some(total); + } + if let Some(event_id) = params + .get("_meta") + .and_then(|meta| meta.get("eventId")) + .and_then(Value::as_str) + { + if !self.seen_event_ids.insert(event_id.to_string()) { + return; + } + } + let update = params.get("update").unwrap_or(params); + let kind = update + .get("sessionUpdate") + .or_else(|| update.get("type")) + .or_else(|| update.get("kind")) + .and_then(Value::as_str) + .unwrap_or(""); + let timestamp = event_time_ms(record, params); + + match kind { + "user_message_chunk" => { + self.last_error = None; + self.mark_turn_activity(timestamp); + push_chat( + &mut self.chat_messages, + ChatRole::User, + clean_text(&event_text(update), 500), + ); + } + "agent_message_chunk" => { + self.mark_turn_activity(timestamp); + let text = clean_text(&event_text(update), 500); + if self.first_assistant_text.is_empty() { + self.first_assistant_text = text.clone(); + } + push_chat(&mut self.chat_messages, ChatRole::Assistant, text); + } + "agent_thought_chunk" => self.mark_turn_activity(timestamp), + "tool_call" => self.apply_tool_call(update, timestamp), + "tool_call_update" => self.apply_tool_update(update, timestamp), + "response_started" => { + self.last_error = None; + self.mark_turn_activity(timestamp); + if let Some(model) = update.get("model").and_then(Value::as_str) { + self.model = clean_text(model, 120); + } + } + "response_completed" => { + self.mark_turn_activity(timestamp); + self.pending_response_usage + .add(parse_response_usage(update.get("usage").unwrap_or(update))); + // Older Grok builds included the model on this event. + if let Some(model) = update.get("model").and_then(Value::as_str) { + self.model = clean_text(model, 120); + } + } + "model_changed" => { + if let Some(model) = update + .get("model_id") + .or_else(|| update.get("modelId")) + .and_then(Value::as_str) + { + self.model = clean_text(model, 120); + } + } + "turn_completed" => self.apply_turn_completed(update, timestamp), + "rewind_marker" => self.reset_logical_branch( + u32::try_from(u64_field( + update, + &["target_prompt_index", "targetPromptIndex"], + )) + .unwrap_or(u32::MAX), + ), + "auto_compact_completed" | "context_compacted" => { + self.compaction_count = self.compaction_count.saturating_add(1); + let tokens_after = u64_field(update, &["tokens_after", "tokensAfter"]); + if tokens_after > 0 { + self.context_history.push(tokens_after); + trim_history(&mut self.context_history); + } + } + "subagent_spawned" | "subagent_progress" | "subagent_finished" => { + self.apply_subagent(kind, update, timestamp) + } + "task_backgrounded" => self.apply_task_backgrounded(update, timestamp), + "task_completed" => self.apply_task_completed(update), + "monitor_event" => self.apply_task_progress(update, timestamp), + "retry_state" | "auto_compact_failed" | "auto_recovery_exhausted" => { + let retry_kind = string_field(update, &["type", "status"]); + if matches!(retry_kind.as_str(), "failed" | "exhausted") + || matches!(kind, "auto_compact_failed" | "auto_recovery_exhausted") + { + self.last_error = Some(clean_text( + update + .get("error") + .and_then(value_text) + .or_else(|| update.get("message").and_then(value_text)) + .or_else(|| update.get("reason").and_then(value_text)) + .unwrap_or("Grok turn failed"), + 160, + )); + let exhausted_rate_limit = kind == "retry_state" + && retry_kind == "exhausted" + && bool_field(update, &["isRateLimited", "is_rate_limited"]); + if exhausted_rate_limit { + self.rate_limited_since = timestamp; + self.fatal_error_since = 0; + } else if kind == "auto_recovery_exhausted" + || (kind == "retry_state" + && matches!(retry_kind.as_str(), "failed" | "exhausted")) + { + self.fatal_error_since = timestamp; + self.rate_limited_since = 0; + } + } + } + _ => {} + } + } + + fn mark_turn_activity(&mut self, timestamp: u64) { + self.last_error = None; + self.fatal_error_since = 0; + self.rate_limited_since = 0; + self.active_turn = true; + if self.turn_started_at == 0 { + self.turn_started_at = timestamp; + } + self.turn_last_activity_at = timestamp; + } + + fn apply_tool_call(&mut self, update: &Value, timestamp: u64) { + let id = string_field(update, &["toolCallId", "tool_call_id", "id"]); + if id.is_empty() { + return; + } + self.seen_tool_ids.insert(id.clone()); + self.mark_turn_activity(timestamp); + let (kind, identifier) = tool_identity(update); + let name = safe_tool_display_name(&kind, &identifier); + let arg = safe_tool_location(update); + let waits_for_user = tool_waits_for_user(update, &kind, &identifier); + let status = string_field(update, &["status", "state"]); + let terminal = matches!(status.as_str(), "completed" | "failed" | "cancelled"); + if terminal { + if let Some(pending) = self.pending_tools.remove(&id) { + if let Some(&index) = self.tool_indices.get(&id) { + if let Some(tool) = self.tool_calls.get_mut(index) { + tool.duration_ms = timestamp.saturating_sub(pending.started_at); + } + } + } + } else { + self.pending_tools.insert( + id.clone(), + PendingTool { + name: name.clone(), + arg: arg.clone(), + started_at: timestamp, + last_update_at: timestamp, + waits_for_user, + }, + ); + } + if !self.tool_indices.contains_key(&id) && self.tool_calls.len() < MAX_TOOL_CALLS { + self.tool_indices.insert(id, self.tool_calls.len()); + self.tool_calls.push(ToolCall { + name: name.clone(), + arg: arg.clone(), + duration_ms: 0, + }); + } + if !arg.is_empty() && self.file_accesses.len() < MAX_FILE_ACCESSES { + if let Some(operation) = file_op(&kind) { + self.file_accesses.push(FileAccess { + path: arg, + operation, + turn_index: self.turn_count, + }); + } + } + if status == "failed" { + self.last_error = Some("Grok tool failed".to_string()); + } + } + + fn apply_tool_update(&mut self, update: &Value, timestamp: u64) { + let id = string_field(update, &["toolCallId", "tool_call_id", "id"]); + if !self.seen_tool_ids.contains(&id) { + // Grok registers every tool with a canonical ToolCall before + // emitting any ToolCallUpdate. Without that opener we cannot + // reconstruct either an active or terminal lifecycle exactly. + self.lifecycle_failure = Some(StatusReason::ProtocolMalformed); + return; + } + let status = string_field(update, &["status", "state"]); + if !matches!(status.as_str(), "completed" | "failed" | "cancelled") { + // Background processes keep publishing output updates under the + // original tool-call ID after that foreground call has completed. + // Those records may enrich an exact still-open call, but they must + // not reopen a model turn once its terminal lifecycle was observed. + if !self.pending_tools.contains_key(&id) { + if let Some(&index) = self.tool_indices.get(&id) { + // A known completed call can still receive delayed display + // metadata. Preserve it without changing lifecycle state. + let (kind, identifier) = tool_identity(update); + let arg = safe_tool_location(update); + if let Some(tool) = self.tool_calls.get_mut(index) { + if !kind.is_empty() || !identifier.is_empty() { + tool.name = safe_tool_display_name(&kind, &identifier); + } + if !arg.is_empty() { + tool.arg = arg; + } + } + } + return; + } + self.mark_turn_activity(timestamp); + let (kind, identifier) = tool_identity(update); + let waits_for_user = tool_waits_for_user(update, &kind, &identifier); + let arg = safe_tool_location(update); + let enriched = self.pending_tools.get_mut(&id).map(|pending| { + if !kind.is_empty() || !identifier.is_empty() { + pending.name = safe_tool_display_name(&kind, &identifier); + } + if !arg.is_empty() { + pending.arg = arg; + } + pending.last_update_at = timestamp; + pending.waits_for_user |= waits_for_user; + (pending.name.clone(), pending.arg.clone()) + }); + if let Some((name, arg)) = enriched { + if let Some(&index) = self.tool_indices.get(&id) { + if let Some(tool) = self.tool_calls.get_mut(index) { + tool.name = name; + tool.arg = arg; + } + } + } + return; + } + if let Some(pending) = self.pending_tools.remove(&id) { + if let Some(&index) = self.tool_indices.get(&id) { + if let Some(tool) = self.tool_calls.get_mut(index) { + tool.duration_ms = timestamp.saturating_sub(pending.started_at); + } + } + } + if status == "failed" { + self.last_error = Some(clean_text( + update + .get("error") + .and_then(value_text) + .unwrap_or("Grok tool failed"), + 160, + )); + } + } + + fn apply_task_backgrounded(&mut self, update: &Value, timestamp: u64) { + self.fatal_error_since = 0; + self.rate_limited_since = 0; + let id = id_field(update, &["task_id", "taskId", "id"]); + if id.is_empty() { + return; + } + let monitor = string_field(update, &["monitor_description", "monitorDescription"]); + let description = string_field(update, &["description"]); + let label = if !monitor.trim().is_empty() { + format!("Monitor {}", clean_text(&monitor, 120)) + } else if !description.trim().is_empty() { + format!("Background {}", clean_text(&description, 120)) + } else { + "Background task".to_string() + }; + self.background_tasks.insert( + id, + BackgroundTask { + label, + started_at: timestamp, + last_update_at: timestamp, + }, + ); + } + + fn apply_task_progress(&mut self, update: &Value, timestamp: u64) { + let id = id_field(update, &["task_id", "taskId", "id"]); + if let Some(task) = self.background_tasks.get_mut(&id) { + task.last_update_at = timestamp; + } + } + + fn apply_task_completed(&mut self, update: &Value) { + let snapshot = update + .get("task_snapshot") + .or_else(|| update.get("taskSnapshot")) + .unwrap_or(update); + let id = id_field(snapshot, &["task_id", "taskId", "id"]); + if id.is_empty() { + return; + } + self.background_tasks.remove(&id); + let exit_code = snapshot + .get("exit_code") + .or_else(|| snapshot.get("exitCode")) + .and_then(Value::as_i64); + if let Some(exit_code) = exit_code.filter(|code| *code != 0) { + self.last_error = Some(format!("Background task failed (exit {exit_code})")); + } + } + + fn apply_turn_completed(&mut self, update: &Value, timestamp: u64) { + let turn_id = string_field( + update, + &["prompt_id", "turn_id", "promptId", "turnId", "id"], + ); + if !turn_id.is_empty() && !self.seen_turn_ids.insert(turn_id) { + self.pending_response_usage = Usage::default(); + return; + } + let usage_value = update.get("usage").unwrap_or(&Value::Null); + let mut usage = parse_turn_usage(usage_value); + if usage.total() == 0 { + usage = self.pending_response_usage; + } + self.pending_response_usage = Usage::default(); + self.total_input = self.total_input.saturating_add(usage.input); + self.total_output = self.total_output.saturating_add(usage.output); + self.total_cache_read = self.total_cache_read.saturating_add(usage.cache_read); + self.total_cache_create = self.total_cache_create.saturating_add(usage.cache_create); + self.token_history.push(usage.total()); + trim_history(&mut self.token_history); + let context = inclusive_input_tokens(usage_value); + if context > 0 { + self.context_history.push(context); + trim_history(&mut self.context_history); + } + self.turn_count = self.turn_count.saturating_add(1); + self.active_turn = false; + self.turn_started_at = 0; + self.turn_last_activity_at = 0; + self.pending_tools.clear(); + let stop_reason = string_field(update, &["stop_reason", "stopReason", "reason"]); + self.last_error = None; + self.fatal_error_since = 0; + self.rate_limited_since = 0; + if stop_reason == "error" || stop_reason == "failed" { + self.last_error = Some(clean_text( + update + .get("error") + .and_then(value_text) + .unwrap_or("Grok turn failed"), + 160, + )); + self.fatal_error_since = timestamp; + } else if stop_reason == "rate_limit" || stop_reason == "rate_limited" { + self.rate_limited_since = timestamp; + } + } + + fn apply_subagent(&mut self, kind: &str, update: &Value, timestamp: u64) { + let id = string_field( + update, + &[ + "subagent_id", + "child_session_id", + "sessionId", + "childSessionId", + "subagentId", + "id", + ], + ); + if id.is_empty() { + return; + } + let incoming_name = string_field( + update, + &[ + "description", + "subagent_type", + "agent_type", + "agentType", + "name", + ], + ); + let status = match kind { + "subagent_spawned" => "working".to_string(), + "subagent_finished" => { + let terminal = string_field(update, &["status", "result"]); + if terminal.is_empty() { + "completed".to_string() + } else { + terminal + } + } + _ => string_field(update, &["status"]), + }; + let tokens = u64_field(update, &["tokens_used", "tokens", "totalTokens"]); + let previous = self.subagents.get(&id).cloned().unwrap_or_default(); + self.subagents.insert( + id.clone(), + SubagentState { + name: if incoming_name.is_empty() { + if previous.name.is_empty() { + clean_text(&id, 80) + } else { + previous.name.clone() + } + } else { + clean_text(&incoming_name, 80) + }, + status: clean_text( + if status.is_empty() { + if previous.status.is_empty() { + "working" + } else { + &previous.status + } + } else { + &status + }, + 40, + ), + tokens: previous.tokens.max(tokens), + started_at: if previous.started_at == 0 { + timestamp + } else { + previous.started_at + }, + last_update_at: timestamp, + }, + ); + if kind == "subagent_finished" && matches!(status.as_str(), "failed" | "error") { + self.last_error = Some(clean_text( + update + .get("error") + .and_then(value_text) + .unwrap_or("Grok subagent failed"), + 160, + )); + } + } + + fn reset_logical_branch(&mut self, target_prompt_index: u32) { + // Rewinding changes the logical conversation, but it does not refund + // tokens already spent. Keep lifetime usage and history intact while + // clearing transient state from the abandoned branch. + self.turn_count = self.turn_count.min(target_prompt_index); + self.active_turn = false; + self.turn_started_at = 0; + self.turn_last_activity_at = 0; + self.pending_tools.clear(); + self.background_tasks.clear(); + self.pending_response_usage = Usage::default(); + self.chat_messages.clear(); + self.tool_calls.clear(); + self.tool_indices.clear(); + self.file_accesses + .retain(|access| access.turn_index < target_prompt_index); + self.subagents.clear(); + self.last_error = None; + self.fatal_error_since = 0; + self.rate_limited_since = 0; + if target_prompt_index == 0 { + self.first_assistant_text.clear(); + } + } +} + +fn default_roots() -> Vec { + let mut roots = Vec::new(); + if let Some(home) = dirs::home_dir() { + roots.push(home.join(".grok")); + } + if let Some(root) = std::env::var_os("GROK_HOME").map(PathBuf::from) { + let root = if root.is_absolute() { + root + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from("/")) + .join(root) + }; + roots.push(root); + } + roots.sort(); + roots.dedup(); + roots +} + +fn process_grok_root(pid: u32) -> Option { + let configured = process::read_process_env_var(pid, "GROK_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("GROK_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + })?; + if configured.is_absolute() { + Some(configured) + } else { + process::get_process_cwd(pid).map(|cwd| PathBuf::from(cwd).join(configured)) + } +} + +fn read_leader_pids(root: &Path) -> HashSet { + let Ok(entries) = fs::read_dir(root) else { + return HashSet::new(); + }; + entries + .flatten() + .take(1_000) + .filter_map(|entry| { + let path = entry.path(); + let name = path.file_name()?.to_str()?; + if !name.starts_with("leader") || !name.ends_with(".lock") || is_symlink(&path) { + return None; + } + let meta = fs::metadata(&path).ok()?; + if !meta.is_file() || meta.len() > MAX_LEADER_LOCK_BYTES { + return None; + } + fs::read_to_string(path).ok()?.trim().parse().ok() + }) + .filter(|pid| *pid > 0) + .collect() +} + +pub(crate) fn is_grok_leader_pid(pid: u32) -> bool { + let mut roots = default_roots(); + if let Some(root) = process_grok_root(pid) { + roots.push(root); + } + roots.sort(); + roots.dedup(); + roots + .iter() + .any(|root| read_leader_pids(root).contains(&pid)) +} + +fn read_active_registry(root: &Path) -> Vec { + let path = root.join("active_sessions.json"); + if is_symlink(&path) { + return Vec::new(); + } + let Ok(meta) = fs::metadata(&path) else { + return Vec::new(); + }; + if !meta.is_file() || meta.len() > MAX_REGISTRY_BYTES { + return Vec::new(); + } + let Ok(value) = fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .ok_or(()) + else { + return Vec::new(); + }; + value + .as_array() + .into_iter() + .flat_map(|entries| entries.iter()) + .filter_map(|entry| { + let session_id = entry.get("session_id")?.as_str()?; + let pid = u32::try_from(entry.get("pid")?.as_u64()?).ok()?; + if session_id.is_empty() || session_id.len() > 256 || pid == 0 { + return None; + } + Some(RegistryEntry { + session_id: session_id.to_string(), + pid, + cwd: entry + .get("cwd") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + opened_at: parse_time(entry.get("opened_at").unwrap_or(&Value::Null)), + }) + }) + .collect() +} + +fn read_summary(path: &Path, registry: &RegistryEntry) -> Option { + let value = read_json_file(path, MAX_JSON_BYTES)?; + let info = value.get("info").unwrap_or(&Value::Null); + let id = info + .get("id") + .and_then(Value::as_str) + .unwrap_or(®istry.session_id); + if id != registry.session_id { + return None; + } + let cwd = info + .get("cwd") + .and_then(Value::as_str) + .filter(|cwd| !cwd.is_empty()) + .unwrap_or(®istry.cwd); + if cwd.is_empty() { + return None; + } + let title = value + .get("generated_title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|title| !title.is_empty()) + .or_else(|| value.get("session_summary").and_then(Value::as_str)) + .unwrap_or(""); + let created_at = parse_time(value.get("created_at").unwrap_or(&Value::Null)); + let last_active_at = parse_time(value.get("last_active_at").unwrap_or(&Value::Null)); + let updated_at = if last_active_at > 0 { + last_active_at + } else { + parse_time(value.get("updated_at").unwrap_or(&Value::Null)) + } + .max(created_at); + Some(SessionMeta { + id: id.to_string(), + cwd: cwd.to_string(), + title: clean_text(title, 120), + model: clean_text( + value + .get("current_model_id") + .and_then(Value::as_str) + .unwrap_or(""), + 120, + ), + effort: clean_text( + value + .get("reasoning_effort") + .and_then(Value::as_str) + .unwrap_or(""), + 40, + ), + session_kind: value + .get("session_kind") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + hidden: value.get("hidden").and_then(Value::as_bool), + created_at, + updated_at, + }) +} + +fn read_signals(path: &Path) -> Signals { + let Some(value) = read_json_file(path, MAX_JSON_BYTES) else { + return Signals::default(); + }; + Signals { + context_tokens: optional_u64_field(&value, &["contextTokensUsed", "context_tokens_used"]), + context_window: optional_u64_field( + &value, + &["contextWindowTokens", "context_window_tokens"], + ), + context_percent: optional_f64_field( + &value, + &["contextWindowUsage", "context_window_usage"], + ), + turn_count: optional_u64_field(&value, &["turnCount", "turn_count"]) + .map(|count| u32::try_from(count).unwrap_or(u32::MAX)), + compaction_count: optional_u64_field(&value, &["compactionCount", "compaction_count"]) + .map(|count| u32::try_from(count).unwrap_or(u32::MAX)), + } +} + +fn read_awaiting_plan_approval(path: &Path) -> (bool, EventAvailability) { + if is_symlink(path) { + return ( + false, + EventAvailability::Failed(StatusReason::ProtocolMalformed), + ); + } + let meta = match fs::metadata(path) { + Ok(meta) => meta, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return (false, EventAvailability::Missing) + } + Err(_) => return (false, EventAvailability::Failed(StatusReason::Unavailable)), + }; + if !meta.is_file() || meta.len() > MAX_JSON_BYTES { + return ( + false, + EventAvailability::Failed(StatusReason::ProtocolMalformed), + ); + } + let mut file = match File::open(path) { + Ok(file) => file, + Err(_) => return (false, EventAvailability::Failed(StatusReason::Unavailable)), + }; + let mut bytes = Vec::new(); + if file + .by_ref() + .take(MAX_JSON_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .is_err() + { + return (false, EventAvailability::Failed(StatusReason::Unavailable)); + } + if bytes.len() as u64 > MAX_JSON_BYTES { + return ( + false, + EventAvailability::Failed(StatusReason::ProtocolMalformed), + ); + } + let value = match serde_json::from_slice::(&bytes) { + Ok(value) => value, + Err(_) => { + return ( + false, + EventAvailability::Failed(StatusReason::ProtocolMalformed), + ) + } + }; + match value.get("awaiting_plan_approval").and_then(Value::as_bool) { + Some(awaiting) => (awaiting, EventAvailability::Available), + None => ( + false, + EventAvailability::Failed(StatusReason::ProtocolMalformed), + ), + } +} + +fn read_json_file(path: &Path, max_bytes: u64) -> Option { + if is_symlink(path) { + return None; + } + let meta = fs::metadata(path).ok()?; + if !meta.is_file() || meta.len() > max_bytes { + return None; + } + serde_json::from_slice(&fs::read(path).ok()?).ok() +} + +fn valid_session_dir(root: &Path, path: &Path, session_id: &str) -> bool { + let sessions_root = root.join("sessions"); + path.is_dir() + && path.starts_with(&sessions_root) + && path.file_name().is_some_and(|name| name == session_id) + && !has_symlink_component(&sessions_root, path) +} + +pub(crate) fn is_grok_process(command: &str) -> bool { + let tokens = process::command_tokens(command); + is_grok_process_tokens(&tokens) +} + +pub(crate) fn is_grok_process_tokens(tokens: &[String]) -> bool { + let recognized = tokens.first().is_some_and(|token| { + process::token_has_binary(token, "grok") + || process::token_has_binary(token, "xai-grok-pager") + || process::token_has_binary(token, "agent") + || is_versioned_grok_binary(token) + }); + recognized && !is_grok_leader_command(tokens) +} + +fn grok_process_observation_is_exact( + expected_incarnation: &str, + current_incarnation: Option<&str>, + tokens: &[String], +) -> bool { + current_incarnation == Some(expected_incarnation) && is_grok_process_tokens(tokens) +} + +fn is_grok_leader_command(tokens: &[String]) -> bool { + let Some(agent_index) = grok_agent_command_index(tokens) else { + return false; + }; + grok_agent_subcommand(tokens, agent_index + 1) + .is_some_and(|command| command.eq_ignore_ascii_case("leader")) +} + +/// Return the index of the root `agent` command. Only the first positional can +/// select a command: once ordinary prompt text is seen, later words such as +/// `agent leader` belong to that prompt and must not hide a user session. +fn grok_agent_command_index(tokens: &[String]) -> Option { + let mut index = 1; + while let Some(token) = tokens.get(index) { + if token == "--" { + return None; + } + + let option = option_name(token); + if is_grok_prompt_option(option) { + // These options consume opaque user input. Process listings on + // some platforms flatten an argv value containing spaces, so no + // later word can safely be interpreted as a host command. + return None; + } + if is_inline_option(token) || is_grok_root_flag(option) { + index += 1; + continue; + } + if is_grok_root_value_option(option) { + index += 2; + continue; + } + if token.starts_with('-') { + // Unknown options will be rejected by Grok. Treat them as flags so + // an arbitrary following value does not gain executable identity. + index += 1; + continue; + } + + return token.eq_ignore_ascii_case("agent").then_some(index); + } + None +} + +fn grok_agent_subcommand(tokens: &[String], mut index: usize) -> Option<&str> { + while let Some(token) = tokens.get(index) { + if token == "--" { + return tokens.get(index + 1).map(String::as_str); + } + let option = option_name(token); + if is_inline_option(token) || is_grok_agent_flag(option) { + index += 1; + continue; + } + if is_grok_agent_value_option(option) { + index += 2; + continue; + } + if token.starts_with('-') { + index += 1; + continue; + } + return Some(token); + } + None +} + +fn option_name(value: &str) -> &str { + value.split_once('=').map_or(value, |(name, _)| name) +} + +fn is_inline_option(value: &str) -> bool { + value.starts_with('-') && value.contains('=') +} + +fn is_grok_prompt_option(value: &str) -> bool { + matches!( + value, + "-p" | "--single" + | "--prompt-file" + | "--prompt-json" + | "--json-schema" + | "--system-prompt" + | "--system-prompt-override" + ) +} + +fn is_grok_root_value_option(value: &str) -> bool { + matches!( + value, + "--agent" + | "--agents" + | "--allow" + | "--allowedTools" + | "--cwd" + | "--debug-file" + | "--deny" + | "--disallowed-tools" + | "--disallowedTools" + | "--leader-socket" + | "-m" + | "--model" + | "--max-turns" + | "--output-format" + | "--permission-mode" + | "-r" + | "--resume" + | "--reasoning-effort" + | "--effort" + | "--rules" + | "-s" + | "--session-id" + | "--sandbox" + | "--tools" + | "-w" + | "--worktree" + | "--worktree-ref" + | "--ref" + ) +} + +fn is_grok_root_flag(value: &str) -> bool { + matches!( + value, + "--always-approve" + | "-c" + | "--continue" + | "--debug" + | "--disable-web-search" + | "--experimental-memory" + | "--fork-session" + | "--fullscreen" + | "-h" + | "--help" + | "--include-partial-messages" + | "--minimal" + | "--no-alt-screen" + | "--no-memory" + | "--no-plan" + | "--no-subagents" + | "--oauth" + | "--restore-code" + | "-v" + | "--version" + | "--verbatim" + ) +} + +fn is_grok_agent_value_option(value: &str) -> bool { + matches!( + value, + "-m" | "--model" + | "--reasoning-effort" + | "--effort" + | "--agent-profile" + | "--plugin-dir" + | "--grok-ws-origin" + | "--grok-ws-url" + | "--cli-chat-proxy-base-url" + | "--xai-api-base-url" + | "--debug-file" + | "--leader-socket" + ) +} + +fn is_grok_agent_flag(value: &str) -> bool { + matches!( + value, + "--reauth" + | "----reauthenticate" + | "--always-approve" + | "--leader" + | "--no-leader" + | "--debug" + | "-h" + | "--help" + ) +} + +fn is_hidden_grok_session(meta: &SessionMeta) -> bool { + meta.hidden.unwrap_or(false) || is_subagent_session_kind(&meta.session_kind) +} + +fn is_subagent_session_kind(kind: &str) -> bool { + kind.trim_start() + .get(.."subagent".len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("subagent")) +} + +fn is_versioned_grok_binary(token: &str) -> bool { + let normalized = token.trim_matches(['\'', '"']).replace('\\', "/"); + let base = normalized + .rsplit('/') + .next() + .unwrap_or(&normalized) + .to_ascii_lowercase(); + let base = base.strip_suffix(".exe").unwrap_or(&base); + let Some(suffix) = base.strip_prefix("grok-") else { + return false; + }; + let mut parts = suffix.split('-'); + let Some(first) = parts.next() else { + return false; + }; + let platform = if valid_grok_version(first) { + parts.next() + } else { + Some(first) + }; + match platform { + None => true, + Some("macos" | "linux" | "windows") => { + parts + .next() + .is_some_and(|arch| matches!(arch, "aarch64" | "arm64" | "x64" | "x86_64")) + && parts.next().is_none() + } + _ => false, + } +} + +fn valid_grok_version(value: &str) -> bool { + !value.is_empty() + && value + .split('.') + .all(|component| !component.is_empty() && component.chars().all(|c| c.is_ascii_digit())) +} + +fn parse_turn_usage(value: &Value) -> Usage { + let inclusive_input = inclusive_input_tokens(value); + let cache_read = u64_field( + value, + &[ + "cachedReadTokens", + "cache_read_input_tokens", + "cached_read_tokens", + ], + ); + let cache_create = u64_field( + value, + &[ + "cacheCreationTokens", + "cache_creation_input_tokens", + "cache_create_tokens", + ], + ); + Usage { + input: inclusive_input.saturating_sub(cache_read.saturating_add(cache_create)), + output: u64_field(value, &["outputTokens", "output_tokens"]), + cache_read, + cache_create, + } +} + +fn parse_response_usage(value: &Value) -> Usage { + Usage { + input: u64_field(value, &["input_tokens", "inputTokens"]), + output: u64_field(value, &["output_tokens", "outputTokens"]), + cache_read: u64_field(value, &["cache_read_input_tokens", "cachedReadTokens"]), + cache_create: u64_field( + value, + &["cache_creation_input_tokens", "cacheCreationTokens"], + ), + } +} + +fn inclusive_input_tokens(value: &Value) -> u64 { + u64_field(value, &["inputTokens", "input_tokens"]) +} + +fn event_time_ms(record: &Value, params: &Value) -> u64 { + let from_meta = params + .get("_meta") + .and_then(|meta| meta.get("agentTimestampMs")) + .and_then(Value::as_u64); + from_meta.unwrap_or_else(|| { + let value = record.get("timestamp").unwrap_or(&Value::Null); + parse_time(value) + }) +} + +fn event_text(value: &Value) -> String { + value + .get("content") + .or_else(|| value.get("text")) + .and_then(value_text) + .unwrap_or("") + .to_string() +} + +fn tool_identity(value: &Value) -> (String, String) { + let metadata = value.get("_meta").and_then(|meta| meta.get("x.ai/tool")); + let mut kind = metadata + .map(|tool| string_field(tool, &["kind", "toolKind", "tool_kind"])) + .unwrap_or_default(); + if kind.is_empty() { + kind = string_field(value, &["kind", "toolKind", "tool_kind"]); + } + let mut identifier = metadata + .map(|tool| string_field(tool, &["name", "toolName", "tool_name"])) + .unwrap_or_default(); + if identifier.is_empty() { + identifier = string_field(value, &["name", "toolName", "tool_name"]); + } + (clean_text(&kind, 120), clean_text(&identifier, 120)) +} + +fn tool_waits_for_user(_value: &Value, kind: &str, identifier: &str) -> bool { + if kind.eq_ignore_ascii_case("ask_user") || identifier.eq_ignore_ascii_case("ask_user_question") + { + return true; + } + false +} + +fn safe_tool_display_name(kind: &str, identifier: &str) -> String { + let by_kind = safe_tool_name(kind); + if by_kind != "Tool" { + return by_kind; + } + match identifier.to_ascii_lowercase().as_str() { + "ask_user_question" => "Ask User", + "run_terminal_command" | "run_terminal_cmd" => "Execute", + "read_file" => "Read", + "write_file" => "Write", + "edit_file" | "apply_patch" => "Edit", + "list_dir" | "search" => "Search", + "web_fetch" => "Fetch", + _ => "Tool", + } + .to_string() +} + +fn safe_tool_location(value: &Value) -> String { + let path = value + .get("locations") + .and_then(Value::as_array) + .and_then(|locations| locations.first()) + .and_then(|location| location.get("path")) + .and_then(Value::as_str) + .or_else(|| value.get("path").and_then(Value::as_str)) + .unwrap_or(""); + clean_text(path, 160) +} + +fn safe_tool_name(kind: &str) -> String { + match kind { + "read" => "Read", + "edit" => "Edit", + "write" => "Write", + "move" => "Move", + "delete" => "Delete", + "search" => "Search", + "fetch" => "Fetch", + "execute" => "Execute", + "think" => "Think", + _ => "Tool", + } + .to_string() +} + +fn file_op(kind: &str) -> Option { + match kind { + "read" | "search" | "fetch" => Some(FileOp::Read), + "edit" | "move" | "delete" => Some(FileOp::Edit), + "write" => Some(FileOp::Write), + _ => None, + } +} + +fn collect_children(pid: u32, shared: &SharedProcessData) -> Vec { + let mut out = Vec::new(); + let mut stack = shared.children_map.get(&pid).cloned().unwrap_or_default(); + let mut visited = HashSet::new(); + while let Some(child_pid) = stack.pop() { + if !visited.insert(child_pid) { + continue; + } + if let Some(info) = shared.process_info.get(&child_pid) { + out.push(ChildProcess { + pid: child_pid, + command: info.command.clone(), + mem_kb: info.rss_kb, + port: shared + .ports + .get(&child_pid) + .and_then(|ports| ports.first().copied()), + }); + } + if let Some(children) = shared.children_map.get(&child_pid) { + stack.extend(children); + } + } + out +} + +fn string_field(value: &Value, keys: &[&str]) -> String { + keys.iter() + .find_map(|key| value.get(*key).and_then(Value::as_str)) + .unwrap_or("") + .to_string() +} + +fn bool_field(value: &Value, keys: &[&str]) -> bool { + keys.iter() + .find_map(|key| value.get(*key).and_then(Value::as_bool)) + .unwrap_or(false) +} + +fn id_field(value: &Value, keys: &[&str]) -> String { + keys.iter() + .find_map(|key| { + let value = value.get(*key)?; + value + .as_str() + .map(ToString::to_string) + .or_else(|| value.as_u64().map(|number| number.to_string())) + .or_else(|| value.as_i64().map(|number| number.to_string())) + }) + .unwrap_or_default() +} + +fn observed_since(started_at: u64, last_activity_at: u64, opened_at: u64) -> bool { + (started_at > 0 && started_at >= opened_at) + || (last_activity_at > 0 && last_activity_at >= opened_at) +} + +fn min_nonzero(left: Option, right: Option) -> Option { + match ( + left.filter(|value| *value > 0), + right.filter(|value| *value > 0), + ) { + (Some(left), Some(right)) => Some(left.min(right)), + (Some(value), None) | (None, Some(value)) => Some(value), + (None, None) => None, + } +} + +fn u64_field(value: &Value, keys: &[&str]) -> u64 { + optional_u64_field(value, keys).unwrap_or(0) +} + +fn optional_u64_field(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key).and_then(Value::as_u64)) +} + +fn optional_f64_field(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key).and_then(Value::as_f64)) +} + +fn value_text(value: &Value) -> Option<&str> { + value + .as_str() + .or_else(|| value.get("text").and_then(Value::as_str)) + .or_else(|| value.get("message").and_then(Value::as_str)) +} + +fn parse_time(value: &Value) -> u64 { + if let Some(raw) = value.as_u64() { + return if raw < 10_000_000_000 { + raw * 1_000 + } else { + raw + }; + } + value + .as_f64() + .map(|raw| { + if raw < 10_000_000_000.0 { + (raw * 1_000.0) as u64 + } else { + raw as u64 + } + }) + .or_else(|| { + value + .as_str() + .and_then(|raw| chrono::DateTime::parse_from_rfc3339(raw).ok()) + .and_then(|time| u64::try_from(time.timestamp_millis()).ok()) + }) + .unwrap_or(0) +} + +fn current_time_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn clean_text(text: &str, max_chars: usize) -> String { + let sanitized = sanitize_terminal_text(text); + let redacted = redact_secrets(&sanitized); + redacted + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(max_chars) + .collect() +} + +fn push_chat(messages: &mut Vec, role: ChatRole, text: String) { + if text.is_empty() { + return; + } + messages.push(ChatMessage { role, text }); + if messages.len() > MAX_CHAT_MESSAGES { + messages.remove(0); + } +} + +fn trim_history(history: &mut Vec) { + const MAX_HISTORY: usize = 10_000; + if history.len() > MAX_HISTORY { + history.drain(..history.len() - MAX_HISTORY); + } +} + +fn is_symlink(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok_and(|meta| meta.file_type().is_symlink()) +} + +fn has_symlink_component(root: &Path, path: &Path) -> bool { + let Ok(relative) = path.strip_prefix(root) else { + return true; + }; + let mut current = root.to_path_buf(); + if is_symlink(¤t) { + return true; + } + for component in relative.components() { + current.push(component); + if is_symlink(¤t) { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn recognizes_user_grok_processes_and_excludes_leader_hosts() { + assert!(is_grok_process("/usr/local/bin/grok")); + assert!(is_grok_process("/Users/test/.local/bin/agent")); + assert!(is_grok_process("xai-grok-pager --resume abc")); + assert!(is_grok_process("~/.grok/bin/grok-1.2.3 -p hi")); + assert!(is_grok_process("~/.grok/downloads/grok-macos-aarch64")); + assert!(is_grok_process("GROK-0.2.118-WINDOWS-X64.EXE")); + assert!(!is_grok_process("grok agent leader")); + assert!(!is_grok_process("grok --debug agent --model code leader")); + assert!(!is_grok_process( + "grok agent --plugin-dir /tmp/company-plugin --debug leader" + )); + assert!(is_grok_process("grok -p agent leader")); + assert!(is_grok_process("grok --single=agent leader")); + assert!(is_grok_process("grok fix agent leader handling")); + assert!(is_grok_process("grok \"fix agent leader handling\"")); + assert!(!is_grok_process("cat ~/.grok/bin/grok-1.2.3")); + assert!(!is_grok_process("node /tmp/grok-1-not-the-cli.js")); + assert!(!is_grok_process("node server.js")); + } + + #[test] + fn exact_registry_binding_rejects_pid_reuse_and_leader_role() { + let session_tokens = vec![ + "/usr/local/bin/grok".to_string(), + "--resume".to_string(), + "session-a".to_string(), + ]; + let leader_tokens = vec![ + "/usr/local/bin/grok".to_string(), + "agent".to_string(), + "leader".to_string(), + ]; + + assert!(grok_process_observation_is_exact( + "process-a", + Some("process-a"), + &session_tokens, + )); + assert!(!grok_process_observation_is_exact( + "process-a", + Some("process-b"), + &session_tokens, + )); + assert!(!grok_process_observation_is_exact( + "process-a", + Some("process-a"), + &leader_tokens, + )); + } + + #[test] + fn subagent_session_kinds_are_always_hidden() { + let mut meta = SessionMeta { + session_kind: "interactive".to_string(), + hidden: Some(false), + ..SessionMeta::default() + }; + assert!(!is_hidden_grok_session(&meta)); + + meta.session_kind = "subagent".to_string(); + assert!(is_hidden_grok_session(&meta)); + meta.session_kind = "subagent_worker".to_string(); + assert!(is_hidden_grok_session(&meta)); + meta.session_kind = "SubAgentFork".to_string(); + assert!(is_hidden_grok_session(&meta)); + + meta.session_kind = "interactive".to_string(); + meta.hidden = Some(true); + assert!(is_hidden_grok_session(&meta)); + } + + #[test] + fn leader_lock_files_identify_host_pids() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("leader.lock"), "42").unwrap(); + fs::write(root.path().join("leader-custom.lock"), "43\n").unwrap(); + fs::write(root.path().join("leader.sock"), "44").unwrap(); + fs::write(root.path().join("not-leader.lock"), "45").unwrap(); + + let pids = read_leader_pids(root.path()); + assert_eq!(pids, HashSet::from([42, 43])); + } + + #[test] + fn event_state_pairs_permissions_and_clears_at_turn_boundaries() { + let mut state = EventState::default(); + state.apply(&serde_json::json!({ + "type":"permission_requested", + "tool_name":"run_terminal_command", + "ts":"2026-08-01T08:00:00Z" + })); + let first = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + assert_eq!(state.waiting_since(first), Some(first)); + assert_eq!(state.waiting_since(first + 1), None); + + state.apply(&serde_json::json!({ + "type":"permission_requested", + "tool_name":"run_terminal_command", + "ts":"2026-08-01T08:00:01Z" + })); + state.apply(&serde_json::json!({ + "type":"permission_resolved", + "tool_name":"run_terminal_command", + "ts":"2026-08-01T08:00:02Z" + })); + assert!(state.waiting_since(first + 1).is_some()); + state.apply(&serde_json::json!({ + "type":"permission_resolved", + "tool_name":"run_terminal_command", + "ts":"2026-08-01T08:00:03Z" + })); + assert_eq!(state.waiting_since(first), None); + + state.apply(&serde_json::json!({ + "type":"permission_requested", + "tool_name":"edit_file", + "ts":"2026-08-01T08:00:02Z" + })); + state.apply(&serde_json::json!({ + "type":"turn_ended", + "ts":"2026-08-01T08:00:04Z" + })); + assert_eq!(state.waiting_since(first), None); + state.apply(&serde_json::json!({ + "type":"permission_requested", + "tool_name":"edit_file", + "ts":"2026-08-01T08:00:03Z" + })); + state.apply(&serde_json::json!({ + "type":"turn_started", + "ts":"2026-08-01T08:00:05Z" + })); + assert_eq!(state.waiting_since(first), None); + } + + #[test] + fn event_source_failure_does_not_drop_a_pending_permission_wait() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + fs::write( + &path, + "{\"type\":\"permission_requested\",\"tool_name\":\"shell\",\"ts\":\"2026-08-01T08:00:01Z\"}\n", + ) + .unwrap(); + let mut cache = EventCache::default(); + assert_eq!(cache.refresh(&path), EventAvailability::Available); + let waiting = grok_status_decision( + &UpdateState::default(), + &cache.state, + EventAvailability::Available, + EventAvailability::Missing, + opened_at, + false, + ); + assert_eq!(waiting.status, SessionStatus::Waiting); + assert_eq!(waiting.authority, StatusAuthority::Provider); + assert_eq!(waiting.reason, StatusReason::ProviderWaitingApproval); + + let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap(); + file.write_all(b"{not-json}\n").unwrap(); + file.flush().unwrap(); + let failed = cache.refresh(&path); + assert_eq!( + failed, + EventAvailability::Failed(StatusReason::ProtocolMalformed) + ); + let unavailable = grok_status_decision( + &UpdateState::default(), + &cache.state, + failed, + EventAvailability::Missing, + opened_at, + false, + ); + assert_eq!(unavailable.status, SessionStatus::Unknown); + assert_eq!(unavailable.authority, StatusAuthority::Unavailable); + assert_eq!(unavailable.reason, StatusReason::ProtocolMalformed); + + file.write_all( + b"{\"type\":\"permission_resolved\",\"tool_name\":\"shell\",\"ts\":\"2026-08-01T08:00:02Z\"}\n", + ) + .unwrap(); + file.flush().unwrap(); + assert_eq!( + cache.refresh(&path), + EventAvailability::Failed(StatusReason::ProtocolMalformed), + "a skipped malformed lifecycle record permanently prevents exact reconstruction" + ); + let resolved = grok_status_decision( + &UpdateState::default(), + &cache.state, + EventAvailability::Failed(StatusReason::ProtocolMalformed), + EventAvailability::Missing, + opened_at, + false, + ); + assert_eq!(resolved.status, SessionStatus::Unknown); + assert_eq!(resolved.authority, StatusAuthority::Unavailable); + assert_eq!(resolved.reason, StatusReason::ProtocolMalformed); + } + + #[test] + fn invalid_event_timestamps_make_lifecycle_status_unknown() { + let dir = tempfile::tempdir().unwrap(); + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + for (index, invalid_resolution) in [ + "{\"type\":\"permission_resolved\",\"tool_name\":\"shell\"}\n", + "{\"type\":\"permission_resolved\",\"tool_name\":\"shell\",\"ts\":\"not-a-time\"}\n", + ] + .into_iter() + .enumerate() + { + let path = dir.path().join(format!("events-{index}.jsonl")); + fs::write( + &path, + "{\"type\":\"permission_requested\",\"tool_name\":\"shell\",\"ts\":\"2026-08-01T08:00:01Z\"}\n", + ) + .unwrap(); + let mut cache = EventCache::default(); + assert_eq!(cache.refresh(&path), EventAvailability::Available); + + let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap(); + file.write_all(invalid_resolution.as_bytes()).unwrap(); + file.flush().unwrap(); + let availability = cache.refresh(&path); + assert_eq!( + availability, + EventAvailability::Failed(StatusReason::ProtocolMalformed) + ); + assert!(cache.state.waiting_since(opened_at).is_some()); + + let decision = grok_status_decision( + &UpdateState::default(), + &cache.state, + availability, + EventAvailability::Missing, + opened_at, + false, + ); + assert_eq!(decision.status, SessionStatus::Unknown); + assert_eq!(decision.authority, StatusAuthority::Unavailable); + assert_eq!(decision.reason, StatusReason::ProtocolMalformed); + } + } + + #[test] + fn missing_optional_events_preserve_provider_idle_but_stale_waits_do_not_leak() { + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + let mut stale_events = EventState::default(); + stale_events.apply(&serde_json::json!({ + "type":"permission_requested", + "tool_name":"shell", + "ts":"2026-08-01T07:59:59Z" + })); + let stale = grok_status_decision( + &UpdateState::default(), + &stale_events, + EventAvailability::Available, + EventAvailability::Missing, + opened_at, + false, + ); + assert_eq!(stale.status, SessionStatus::Idle); + assert_eq!(stale.authority, StatusAuthority::Provider); + assert_eq!(stale.reason, StatusReason::ProviderIdle); + + let missing = grok_status_decision( + &UpdateState::default(), + &EventState::default(), + EventAvailability::Missing, + EventAvailability::Missing, + opened_at, + false, + ); + assert_eq!(missing.status, SessionStatus::Idle); + assert_eq!(missing.authority, StatusAuthority::Provider); + assert_eq!(missing.reason, StatusReason::ProviderIdle); + } + + #[test] + fn interaction_waits_override_execution_without_aging_ordinary_tools() { + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + let mut updates = UpdateState::default(); + updates.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:01Z", + "params":{"update":{ + "sessionUpdate":"tool_call", + "toolCallId":"terminal", + "_meta":{"x.ai/tool":{"kind":"execute","name":"run_terminal_command"}} + }} + })); + let mut events = EventState::default(); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Executing, false) + ); + + events.apply(&serde_json::json!({ + "type":"permission_requested", + "tool_name":"run_terminal_command", + "ts":"2026-08-01T08:00:02Z" + })); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Waiting, true) + ); + events.apply(&serde_json::json!({ + "type":"permission_resolved", + "tool_name":"run_terminal_command", + "ts":"2026-08-01T08:00:03Z" + })); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Executing, false) + ); + assert_eq!( + grok_session_status(&updates, &events, opened_at, true), + (SessionStatus::Waiting, true) + ); + } + + #[test] + fn exact_lifecycle_distinguishes_thinking_executing_waiting_and_idle() { + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + let mut updates = UpdateState::default(); + let events = EventState::default(); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Idle, false) + ); + + updates.apply(&serde_json::json!({ + "timestamp":"2026-08-01T07:59:59Z", + "params":{"update":{"sessionUpdate":"response_started"}} + })); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Idle, false), + "pre-open response state must not leak into the current registry interval" + ); + + updates.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:01Z", + "params":{"update":{"sessionUpdate":"agent_thought_chunk","content":"work"}} + })); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Thinking, false) + ); + + updates.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:02Z", + "params":{"update":{ + "sessionUpdate":"tool_call", + "toolCallId":"read", + "kind":"read", + "locations":[{"path":"/tmp/input"}] + }} + })); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Executing, false) + ); + + updates.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:03Z", + "params":{"update":{ + "sessionUpdate":"tool_call", + "toolCallId":"question", + "_meta":{"x.ai/tool":{"kind":"ask_user","name":"ask_user_question"}} + }} + })); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Waiting, true), + "an actionable wait must win over simultaneous work" + ); + + updates.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:04Z", + "params":{"update":{ + "sessionUpdate":"tool_call_update", + "toolCallId":"question", + "status":"completed" + }} + })); + updates.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:05Z", + "params":{"update":{ + "sessionUpdate":"tool_call_update", + "toolCallId":"read", + "status":"completed" + }} + })); + updates.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:06Z", + "params":{"update":{ + "sessionUpdate":"turn_completed", + "prompt_id":"turn-1", + "usage":{} + }} + })); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Idle, false) + ); + } + + #[test] + fn event_phases_are_exact_and_scoped_to_the_registry_interval() { + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + let updates = UpdateState::default(); + let mut events = EventState::default(); + events.apply(&serde_json::json!({ + "type":"phase_changed", + "phase":"streaming_reasoning", + "ts":"2026-08-01T07:59:59Z" + })); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Idle, false) + ); + + events.apply(&serde_json::json!({ + "type":"phase_changed", + "phase":"waiting_for_model", + "ts":"2026-08-01T08:00:01Z" + })); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Thinking, false) + ); + events.apply(&serde_json::json!({ + "type":"phase_changed", + "phase":"tool_execution", + "ts":"2026-08-01T08:00:02Z" + })); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Executing, false) + ); + events.apply(&serde_json::json!({ + "type":"phase_changed", + "phase":"permission_prompt", + "ts":"2026-08-01T08:00:03Z" + })); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Waiting, true) + ); + events.apply(&serde_json::json!({ + "type":"turn_ended", + "ts":"2026-08-01T08:00:04Z" + })); + assert_eq!( + grok_session_status(&updates, &events, opened_at, false), + (SessionStatus::Idle, false) + ); + } + + #[test] + fn background_tasks_and_subagents_keep_quiescent_parent_executing() { + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + let mut state = UpdateState::default(); + state.apply(&serde_json::json!({ + "params":{ + "_meta":{"agentTimestampMs":1785571201000_u64}, + "update":{ + "sessionUpdate":"task_backgrounded", + "task_id":42, + "description":"run checks" + } + } + })); + assert_eq!( + grok_session_status(&state, &EventState::default(), opened_at, false), + (SessionStatus::Executing, false) + ); + assert_eq!( + state.current_work_labels(opened_at), + vec!["Background run checks"] + ); + + state.apply(&serde_json::json!({ + "params":{"update":{ + "sessionUpdate":"task_completed", + "task_snapshot":{"task_id":42,"exit_code":1} + }} + })); + assert_eq!( + grok_session_status(&state, &EventState::default(), opened_at, false), + (SessionStatus::Idle, false) + ); + assert_eq!( + state.last_error.as_deref(), + Some("Background task failed (exit 1)") + ); + + state.apply(&serde_json::json!({ + "params":{ + "_meta":{"agentTimestampMs":1785571202000_u64}, + "update":{ + "sessionUpdate":"subagent_spawned", + "subagent_id":"sub-1", + "description":"inspect schema" + } + } + })); + assert_eq!( + grok_session_status(&state, &EventState::default(), opened_at, false), + (SessionStatus::Executing, false) + ); + assert_eq!( + state.current_work_labels(opened_at), + vec!["Subagent inspect schema"] + ); + state.apply(&serde_json::json!({ + "params":{ + "_meta":{"agentTimestampMs":1785571203000_u64}, + "update":{ + "sessionUpdate":"subagent_finished", + "subagent_id":"sub-1", + "status":"failed", + "error":"worker\nfailed" + } + } + })); + assert_eq!( + grok_session_status(&state, &EventState::default(), opened_at, false), + (SessionStatus::Idle, false) + ); + assert_eq!(state.last_error.as_deref(), Some("workerfailed")); + } + + #[test] + fn late_background_output_does_not_reopen_a_completed_turn() { + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + let mut state = UpdateState::default(); + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:01Z", + "params":{"update":{ + "sessionUpdate":"tool_call", + "toolCallId":"background-shell", + "_meta":{"x.ai/tool":{"kind":"execute","name":"run_terminal_command"}} + }} + })); + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:02Z", + "params":{"update":{ + "sessionUpdate":"task_backgrounded", + "task_id":"task-1", + "description":"run checks" + }} + })); + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:03Z", + "params":{"update":{ + "sessionUpdate":"tool_call_update", + "toolCallId":"background-shell", + "status":"completed" + }} + })); + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:04Z", + "params":{"update":{ + "sessionUpdate":"turn_completed", + "prompt_id":"turn-1", + "stop_reason":"end_turn" + }} + })); + assert!(!state.active_turn); + assert_eq!( + grok_session_status(&state, &EventState::default(), opened_at, false), + (SessionStatus::Executing, false), + "the background task itself remains exact execution evidence" + ); + + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:05Z", + "params":{"update":{ + "sessionUpdate":"tool_call_update", + "toolCallId":"background-shell", + "status":"in_progress", + "locations":[{"path":"/tmp/background.log"}], + "rawOutput":{"output":"still running"} + }} + })); + assert!( + !state.active_turn, + "late background output must not reopen completed foreground lifecycle" + ); + assert!(state.pending_tools.is_empty()); + assert_eq!(state.lifecycle_failure, None); + assert_eq!(state.tool_calls[0].arg, "/tmp/background.log"); + assert_eq!( + grok_session_status(&state, &EventState::default(), opened_at, false), + (SessionStatus::Executing, false) + ); + + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:06Z", + "params":{"update":{ + "sessionUpdate":"task_completed", + "task_snapshot":{"task_id":"task-1","exit_code":0} + }} + })); + assert_eq!( + grok_session_status(&state, &EventState::default(), opened_at, false), + (SessionStatus::Idle, false), + "the session is idle after the only background task terminates" + ); + } + + #[test] + fn never_seen_nonterminal_tool_update_fails_closed() { + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + let mut state = UpdateState::default(); + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:01Z", + "params":{"update":{ + "sessionUpdate":"tool_call_update", + "toolCallId":"missing-opener", + "status":"in_progress", + "rawOutput":{"output":"working"} + }} + })); + + assert!(!state.active_turn); + assert!(state.pending_tools.is_empty()); + assert_eq!( + state.lifecycle_failure, + Some(StatusReason::ProtocolMalformed) + ); + let decision = grok_status_decision( + &state, + &EventState::default(), + EventAvailability::Available, + EventAvailability::Missing, + opened_at, + false, + ); + assert_eq!(decision.status, SessionStatus::Unknown); + assert_eq!(decision.authority, StatusAuthority::Unavailable); + assert_eq!(decision.reason, StatusReason::ProtocolMalformed); + } + + #[test] + fn never_seen_terminal_tool_updates_fail_closed() { + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + for status in ["completed", "failed", "cancelled"] { + let mut state = UpdateState::default(); + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:01Z", + "params":{"update":{ + "sessionUpdate":"tool_call_update", + "toolCallId":"missing-opener", + "status":status + }} + })); + + assert!(!state.active_turn, "status={status}"); + assert!(state.pending_tools.is_empty(), "status={status}"); + assert_eq!( + state.lifecycle_failure, + Some(StatusReason::ProtocolMalformed), + "status={status}" + ); + let decision = grok_status_decision( + &state, + &EventState::default(), + EventAvailability::Available, + EventAvailability::Missing, + opened_at, + false, + ); + assert_eq!(decision.status, SessionStatus::Unknown, "status={status}"); + assert_eq!( + decision.authority, + StatusAuthority::Unavailable, + "status={status}" + ); + assert_eq!( + decision.reason, + StatusReason::ProtocolMalformed, + "status={status}" + ); + } + } + + #[test] + fn ask_user_tool_metadata_waits_until_terminal_completion() { + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + let mut state = UpdateState::default(); + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:01Z", + "params":{"update":{ + "sessionUpdate":"tool_call", + "toolCallId":"question", + "_meta":{"x.ai/tool":{"kind":"ask_user","name":"ask_user_question"}}, + "rawInput":{"questions":[]} + }} + })); + assert!(state.pending_tools["question"].waits_for_user); + assert_eq!(state.pending_tools["question"].name, "Ask User"); + assert_eq!( + grok_session_status(&state, &EventState::default(), opened_at, false), + (SessionStatus::Waiting, true) + ); + assert_eq!( + grok_session_status(&state, &EventState::default(), opened_at + 2_000, false), + (SessionStatus::Idle, false) + ); + + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:02Z", + "params":{"update":{ + "sessionUpdate":"tool_call_update", + "toolCallId":"question", + "status":"completed" + }} + })); + assert!(state.pending_tools.is_empty()); + assert_eq!( + grok_session_status(&state, &EventState::default(), opened_at, false), + (SessionStatus::Thinking, false), + "resolving the foreground question returns control to the still-open model turn" + ); + + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:03Z", + "params":{"update":{ + "sessionUpdate":"tool_call", + "toolCallId":"compat-question", + "rawInput":{"questions":[]} + }} + })); + assert!( + !state.pending_tools["compat-question"].waits_for_user, + "payload shape without the canonical ask-user identity is not exact wait evidence" + ); + } + + #[test] + fn nonterminal_tool_update_can_enrich_ask_user_identity() { + let mut state = UpdateState::default(); + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:00Z", + "params":{"update":{ + "sessionUpdate":"tool_call", + "toolCallId":"question" + }} + })); + assert!(!state.pending_tools["question"].waits_for_user); + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:01Z", + "params":{"update":{ + "sessionUpdate":"tool_call_update", + "toolCallId":"question", + "_meta":{"x.ai/tool":{"kind":"ask_user","name":"ask_user_question"}} + }} + })); + assert!(state.pending_tools["question"].waits_for_user); + assert_eq!(state.tool_calls[0].name, "Ask User"); + } + + #[test] + fn event_cache_buffers_partial_lines_and_resets_on_rewrite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("events.jsonl"); + let mut file = File::create(&path).unwrap(); + file.write_all(br#"{"type":"permission_requested","tool_name":"bash""#) + .unwrap(); + file.flush().unwrap(); + let mut cache = EventCache::default(); + assert_eq!( + cache.refresh(&path), + EventAvailability::Failed(StatusReason::Stale) + ); + assert!(cache.state.pending_permissions.is_empty()); + file.write_all(br#", "ts":"2026-08-01T08:00:00Z"}"#) + .unwrap(); + file.write_all(b"\nnot-json\n").unwrap(); + file.flush().unwrap(); + assert_eq!( + cache.refresh(&path), + EventAvailability::Failed(StatusReason::ProtocolMalformed) + ); + assert!(!cache.state.pending_permissions.is_empty()); + drop(file); + + fs::write( + &path, + "{\"type\":\"turn_started\",\"ts\":\"2026-08-01T08:00:01Z\"}\n", + ) + .unwrap(); + assert_eq!(cache.refresh(&path), EventAvailability::Available); + assert!(cache.state.pending_permissions.is_empty()); + } + + #[test] + fn plan_mode_file_exposes_pending_approval() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("plan_mode.json"); + fs::write(&path, r#"{"state":"Active","awaiting_plan_approval":true}"#).unwrap(); + assert_eq!( + read_awaiting_plan_approval(&path), + (true, EventAvailability::Available) + ); + fs::write( + &path, + r#"{"state":"Active","awaiting_plan_approval":false}"#, + ) + .unwrap(); + assert_eq!( + read_awaiting_plan_approval(&path), + (false, EventAvailability::Available) + ); + fs::write(&path, "not json").unwrap(); + assert_eq!( + read_awaiting_plan_approval(&path), + ( + false, + EventAvailability::Failed(StatusReason::ProtocolMalformed) + ) + ); + } + + #[test] + fn turn_usage_is_disjoint_and_reasoning_is_not_added() { + let usage = serde_json::json!({ + "inputTokens": 100, + "outputTokens": 20, + "cachedReadTokens": 30, + "cacheCreationTokens": 10, + "reasoningTokens": 7 + }); + let parsed = parse_turn_usage(&usage); + assert_eq!( + ( + parsed.input, + parsed.output, + parsed.cache_read, + parsed.cache_create + ), + (60, 20, 30, 10) + ); + assert_eq!(parsed.total(), 120); + } + + #[test] + fn update_state_tracks_turn_tools_and_deduplicates_event_ids() { + let mut state = UpdateState::default(); + let call = serde_json::json!({"params":{"_meta":{"eventId":"1","agentTimestampMs":10,"totalTokens":11},"update":{"sessionUpdate":"tool_call","toolCallId":"t","title":"Run `cat /tmp/a`","kind":"read","status":"in_progress","locations":[{"path":"/tmp/a"}]}}}); + state.apply(&call); + state.apply(&call); + assert_eq!(state.pending_tools.len(), 1); + assert_eq!(state.pending_tools["t"].name, "Read"); + assert_eq!(state.meta_context_tokens, Some(11)); + let done = serde_json::json!({"params":{"_meta":{"eventId":"2","agentTimestampMs":20},"update":{"sessionUpdate":"tool_call_update","toolCallId":"t","status":"completed"}}}); + state.apply(&done); + assert!(state.pending_tools.is_empty()); + let turn = serde_json::json!({"params":{"_meta":{"eventId":"3","agentTimestampMs":30},"update":{"sessionUpdate":"turn_completed","prompt_id":"p","stop_reason":"end_turn","usage":{"inputTokens":12,"outputTokens":3,"cachedReadTokens":2}}}}); + state.apply(&turn); + state.apply(&turn); + state.apply(&serde_json::json!({"params":{"_meta":{"eventId":"4","agentTimestampMs":40},"update":{"sessionUpdate":"turn_completed","prompt_id":"p","stop_reason":"end_turn","usage":{"inputTokens":99}}}})); + assert_eq!( + ( + state.total_input, + state.total_output, + state.total_cache_read + ), + (10, 3, 2) + ); + assert_eq!(state.turn_count, 1); + } + + #[test] + fn current_xai_shapes_update_model_subagents_retries_and_rewinds() { + let mut state = UpdateState::default(); + state.apply(&serde_json::json!({"params":{"update":{"sessionUpdate":"response_started","model":"grok-code"}}})); + assert_eq!(state.model, "grok-code"); + state.apply(&serde_json::json!({"params":{"update":{"sessionUpdate":"subagent_spawned","subagent_id":"sub-1","child_session_id":"child-1","subagent_type":"explore","description":"Inspect schema"}}})); + state.apply(&serde_json::json!({"params":{"update":{"sessionUpdate":"subagent_progress","subagent_id":"sub-1","child_session_id":"child-1","tokens_used":42}}})); + assert_eq!(state.subagents["sub-1"].name, "Inspect schema"); + assert_eq!(state.subagents["sub-1"].tokens, 42); + state.apply(&serde_json::json!({"params":{"update":{"sessionUpdate":"retry_state","type":"failed","error_type":"server","message":"request failed"}}})); + assert_eq!(state.last_error.as_deref(), Some("request failed")); + + state.total_input = 100; + state.turn_count = 4; + state.apply(&serde_json::json!({"params":{"update":{"sessionUpdate":"rewind_marker","target_prompt_index":2}}})); + assert_eq!(state.total_input, 100); + assert_eq!(state.turn_count, 2); + assert!(state.subagents.is_empty()); + } + + #[test] + fn terminal_provider_failures_and_rate_limits_are_not_idle() { + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + let mut failed = UpdateState::default(); + failed.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:01Z", + "params":{"update":{ + "sessionUpdate":"turn_completed", + "prompt_id":"failed-turn", + "stop_reason":"error", + "agent_result":"sensitive provider detail" + }} + })); + let decision = grok_status_decision( + &failed, + &EventState::default(), + EventAvailability::Missing, + EventAvailability::Missing, + opened_at, + false, + ); + assert_eq!(decision.status, SessionStatus::Error); + assert_eq!(decision.authority, StatusAuthority::Provider); + assert_eq!(decision.reason, StatusReason::ProviderError); + assert_eq!( + grok_current_tasks(&failed, decision.status, opened_at), + vec!["error".to_string()] + ); + assert!(!grok_current_tasks(&failed, decision.status, opened_at) + .iter() + .any(|task| task.contains("sensitive"))); + + let mut limited = UpdateState::default(); + limited.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:02Z", + "params":{"update":{ + "sessionUpdate":"turn_completed", + "prompt_id":"limited-turn", + "stop_reason":"rate_limit" + }} + })); + let decision = grok_status_decision( + &limited, + &EventState::default(), + EventAvailability::Missing, + EventAvailability::Missing, + opened_at, + false, + ); + assert_eq!(decision.status, SessionStatus::RateLimited); + assert_eq!(decision.reason, StatusReason::ProviderRateLimit); + + let mut exhausted_retry = UpdateState::default(); + exhausted_retry.apply(&serde_json::json!({ + "params":{ + "_meta":{"agentTimestampMs":1785571203000_u64}, + "update":{ + "sessionUpdate":"retry_state", + "type":"exhausted", + "isRateLimited":true, + "message":"provider detail" + } + } + })); + let decision = grok_status_decision( + &exhausted_retry, + &EventState::default(), + EventAvailability::Missing, + EventAvailability::Missing, + opened_at, + false, + ); + assert_eq!(decision.status, SessionStatus::RateLimited); + assert_eq!(decision.authority, StatusAuthority::Provider); + assert_eq!(decision.reason, StatusReason::ProviderRateLimit); + } + + #[test] + fn exact_wait_precedes_a_simultaneous_terminal_error() { + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + let mut state = UpdateState::default(); + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:01Z", + "params":{"update":{ + "sessionUpdate":"tool_call", + "toolCallId":"question", + "_meta":{"x.ai/tool":{"kind":"ask_user","name":"ask_user_question"}} + }} + })); + state.apply(&serde_json::json!({ + "timestamp":"2026-08-01T08:00:02Z", + "params":{"update":{ + "sessionUpdate":"auto_recovery_exhausted", + "error":"sensitive failure" + }} + })); + let decision = grok_status_decision( + &state, + &EventState::default(), + EventAvailability::Missing, + EventAvailability::Missing, + opened_at, + false, + ); + assert_eq!(decision.status, SessionStatus::Waiting); + assert_eq!(decision.reason, StatusReason::ProviderWaitingUserInput); + } + + #[test] + fn malformed_update_records_make_cached_status_unavailable_until_rewrite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("updates.jsonl"); + fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-08-01T08:00:01Z\",\"params\":{\"update\":{", + "\"sessionUpdate\":\"tool_call\",\"toolCallId\":\"question\",", + "\"_meta\":{\"x.ai/tool\":{\"kind\":\"ask_user\",", + "\"name\":\"ask_user_question\"}}}}}\n", + "not-json\n", + "{\"timestamp\":\"2026-08-01T08:00:02Z\",\"params\":{\"update\":{", + "\"sessionUpdate\":\"tool_call_update\",\"toolCallId\":\"question\",", + "\"status\":\"completed\"}}}\n" + ), + ) + .unwrap(); + let mut cache = UpdateCache::default(); + assert_eq!( + cache.refresh(&path), + EventAvailability::Failed(StatusReason::ProtocolMalformed) + ); + assert!(cache.state.pending_tools.is_empty()); + + fs::write( + &path, + "{\"timestamp\":\"2026-08-01T08:00:03Z\",\"params\":{\"update\":{\"sessionUpdate\":\"turn_completed\",\"prompt_id\":\"done\",\"stop_reason\":\"end_turn\"}}}\n", + ) + .unwrap(); + assert_eq!(cache.refresh(&path), EventAvailability::Available); + } + + #[test] + fn invalid_update_timestamps_make_lifecycle_status_unknown() { + let dir = tempfile::tempdir().unwrap(); + let opened_at = parse_time(&serde_json::json!("2026-08-01T08:00:00Z")); + for (index, invalid_completion) in [ + "{\"params\":{\"update\":{\"sessionUpdate\":\"tool_call_update\",\"toolCallId\":\"question\",\"status\":\"completed\"}}}\n", + "{\"timestamp\":\"not-a-time\",\"params\":{\"update\":{\"sessionUpdate\":\"tool_call_update\",\"toolCallId\":\"question\",\"status\":\"completed\"}}}\n", + ] + .into_iter() + .enumerate() + { + let path = dir.path().join(format!("updates-{index}.jsonl")); + fs::write( + &path, + concat!( + "{\"timestamp\":\"2026-08-01T08:00:01Z\",\"params\":{\"update\":{", + "\"sessionUpdate\":\"tool_call\",\"toolCallId\":\"question\",", + "\"_meta\":{\"x.ai/tool\":{\"kind\":\"ask_user\",", + "\"name\":\"ask_user_question\"}}}}}\n" + ), + ) + .unwrap(); + let mut cache = UpdateCache::default(); + assert_eq!(cache.refresh(&path), EventAvailability::Available); + + let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap(); + file.write_all(invalid_completion.as_bytes()).unwrap(); + file.flush().unwrap(); + let availability = cache.refresh(&path); + assert_eq!( + availability, + EventAvailability::Failed(StatusReason::ProtocolMalformed) + ); + assert!(cache.state.waiting_since(opened_at).is_some()); + + let mut state = cache.state.clone(); + if let EventAvailability::Failed(reason) = availability { + state.lifecycle_failure = Some(reason); + } + let decision = grok_status_decision( + &state, + &EventState::default(), + EventAvailability::Missing, + EventAvailability::Missing, + opened_at, + false, + ); + assert_eq!(decision.status, SessionStatus::Unknown); + assert_eq!(decision.authority, StatusAuthority::Unavailable); + assert_eq!(decision.reason, StatusReason::ProtocolMalformed); + } + } + + #[test] + fn oversized_update_record_is_protocol_malformed() { + let mut cache = UpdateCache::default(); + cache.consume(&vec![b'x'; MAX_UPDATE_LINE_BYTES + 1]); + cache.consume(b"\n"); + assert_eq!( + cache.availability(cache.offset), + EventAvailability::Failed(StatusReason::ProtocolMalformed) + ); + } + + #[test] + fn successful_turn_clears_old_errors_and_terminal_full_tool_calls() { + let mut state = UpdateState::default(); + state.apply(&serde_json::json!({"params":{"_meta":{"agentTimestampMs":10},"update":{"sessionUpdate":"tool_call","toolCallId":"tool-1","kind":"read","status":"in_progress","locations":[{"path":"/tmp/a"}]}}})); + assert!(state.pending_tools.contains_key("tool-1")); + state.apply(&serde_json::json!({"params":{"_meta":{"agentTimestampMs":20},"update":{"sessionUpdate":"tool_call","toolCallId":"tool-1","kind":"read","status":"failed","locations":[{"path":"/tmp/a"}]}}})); + assert!(state.pending_tools.is_empty()); + assert_eq!(state.tool_calls.len(), 1); + assert_eq!(state.tool_calls[0].duration_ms, 10); + assert!(state.last_error.is_some()); + + state.apply(&serde_json::json!({"params":{"update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"try again"}}}})); + assert!(state.last_error.is_none()); + state.apply(&serde_json::json!({"params":{"update":{"sessionUpdate":"turn_completed","prompt_id":"successful","stop_reason":"end_turn","usage":{"inputTokens":2}}}})); + assert!(state.last_error.is_none()); + assert!(!state.active_turn); + } + + #[test] + fn incremental_cache_buffers_partial_and_detects_rewrite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("updates.jsonl"); + let mut file = File::create(&path).unwrap(); + file.write_all( + br#"{"timestamp":"2026-08-01T08:00:01Z","params":{"update":{"sessionUpdate":"turn_completed""#, + ) + .unwrap(); + file.flush().unwrap(); + let mut cache = UpdateCache::default(); + assert_eq!( + cache.refresh(&path), + EventAvailability::Failed(StatusReason::Stale) + ); + assert_eq!(cache.state.turn_count, 0); + file.write_all( + br#", "prompt_id":"turn-1","stop_reason":"end_turn","usage":{"inputTokens":2}}}}"#, + ) + .unwrap(); + file.write_all(b"\n").unwrap(); + file.flush().unwrap(); + let complete = fs::read_to_string(&path).unwrap(); + let complete_value = serde_json::from_str::(complete.trim()).unwrap(); + let mut direct = UpdateState::default(); + direct.apply(&complete_value); + assert_eq!(direct.turn_count, 1, "{complete}"); + assert_eq!(cache.refresh(&path), EventAvailability::Available); + assert_eq!(cache.state.turn_count, 1); + fs::write(&path, "{\"timestamp\":\"2026-08-01T08:00:02Z\",\"params\":{\"update\":{\"sessionUpdate\":\"turn_completed\",\"prompt_id\":\"turn-2\",\"stop_reason\":\"end_turn\",\"usage\":{\"inputTokens\":5}}}}\n").unwrap(); + assert_eq!(cache.refresh(&path), EventAvailability::Available); + assert_eq!(cache.state.turn_count, 1); + assert_eq!(cache.state.total_input, 5); + } + + #[test] + fn signals_prefer_exact_context_fields() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("signals.json"); + fs::write(&path, r#"{"contextTokensUsed":42,"contextWindowTokens":100,"contextWindowUsage":42.0,"turnCount":3,"compactionCount":2}"#).unwrap(); + let signals = read_signals(&path); + assert_eq!(signals.context_tokens, Some(42)); + assert_eq!(signals.context_window, Some(100)); + assert_eq!(signals.context_percent, Some(42.0)); + assert_eq!(signals.turn_count, Some(3)); + assert_eq!(signals.compaction_count, Some(2)); + } + + #[test] + fn registry_and_summary_use_authoritative_session_metadata() { + let root = tempfile::tempdir().unwrap(); + fs::write( + root.path().join("active_sessions.json"), + r#"[{"session_id":"session-a","pid":42,"cwd":"/stale","opened_at":"2026-08-01T08:00:00Z"}]"#, + ) + .unwrap(); + let entries = read_active_registry(root.path()); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].session_id, "session-a"); + assert_eq!(entries[0].pid, 42); + assert!(entries[0].opened_at > 0); + + let summary_path = root.path().join("summary.json"); + fs::write( + &summary_path, + r#"{"info":{"id":"session-a","cwd":"/authoritative"},"created_at":"2026-08-01T08:00:00Z","updated_at":"2026-08-01T08:01:00Z","last_active_at":null,"current_model_id":"grok-code","generated_title":" ","session_summary":"Fix parser","hidden":false}"#, + ) + .unwrap(); + let summary = read_summary(&summary_path, &entries[0]).unwrap(); + assert_eq!(summary.cwd, "/authoritative"); + assert_eq!(summary.model, "grok-code"); + assert_eq!(summary.title, "Fix parser"); + assert_eq!(summary.hidden, Some(false)); + assert!(summary.updated_at > summary.created_at); + } + + #[test] + fn shared_pid_assigns_process_resources_to_latest_session_only() { + let make = |id: &str, updated_at: u64| ActiveSession { + root: PathBuf::from("/tmp/.grok"), + dir: PathBuf::from(format!("/tmp/.grok/sessions/project/{id}")), + pid: 42, + opened_at: 1, + action_process_incarnation: "process-42".to_string(), + meta: SessionMeta { + id: id.to_string(), + updated_at, + ..SessionMeta::default() + }, + }; + let sessions = vec![make("older", 10), make("newer", 20)]; + let owners = resource_owner_indices(&sessions); + assert_eq!(owners.get(&42), Some(&1)); + } + + #[test] + fn unavailable_lifecycle_is_never_promoted_to_execution() { + for logical_sessions in [1, 2] { + assert!(shared_pid_idle_uncertainty( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + logical_sessions, + ) + .is_none()); + } + + let unavailable = GrokStatusDecision::unavailable(StatusReason::ProtocolMalformed); + let evidence = evidence_for(unavailable); + assert_eq!(unavailable.status, SessionStatus::Unknown); + assert_eq!(evidence.authority, StatusAuthority::Unavailable); + assert_eq!(evidence.reason, StatusReason::ProtocolMalformed); + } + + #[test] + fn shared_pid_preserves_positive_provider_lifecycle() { + for status in [ + SessionStatus::Waiting, + SessionStatus::Executing, + SessionStatus::Thinking, + SessionStatus::RateLimited, + SessionStatus::Error, + ] { + assert!(shared_pid_idle_uncertainty(status, StatusAuthority::Provider, 2).is_none()); + } + assert!(shared_pid_idle_uncertainty( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + 2, + ) + .is_none()); + + let waiting = GrokStatusDecision::provider( + SessionStatus::Waiting, + StatusReason::ProviderWaitingApproval, + 42, + ); + let evidence = evidence_for(waiting); + assert_eq!(evidence.authority, StatusAuthority::Provider); + assert_eq!(evidence.reason, StatusReason::ProviderWaitingApproval); + assert_eq!(evidence.status_since_ms, 42); + } + + #[test] + fn shared_pid_provider_idle_becomes_unknown_when_row_liveness_is_ambiguous() { + assert!( + shared_pid_idle_uncertainty(SessionStatus::Idle, StatusAuthority::Provider, 1,) + .is_none() + ); + + let decision = + shared_pid_idle_uncertainty(SessionStatus::Idle, StatusAuthority::Provider, 2).unwrap(); + assert_eq!(decision.status, SessionStatus::Unknown); + assert_eq!(decision.authority, StatusAuthority::Unavailable); + assert_eq!(decision.reason, StatusReason::OwnershipUnconfirmed); + } +} diff --git a/src/collector/herdr.rs b/src/collector/herdr.rs new file mode 100644 index 0000000..5b666f3 --- /dev/null +++ b/src/collector/herdr.rs @@ -0,0 +1,1296 @@ +//! Supplemental, content-free Codex status from Herdr-owned terminals. + +use super::process; +use crate::herdr::{self, StatusAgentPane, HERDR_ENV, HERDR_SOCKET_PATH}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::time::{Duration, Instant}; + +const COMMAND_TIMEOUT: Duration = Duration::from_millis(500); +const TOTAL_TIMEOUT: Duration = Duration::from_secs(1); +const MAX_SOCKETS: usize = 8; +const MAX_AGENT_ROWS: usize = 256; +const MAX_PANE_PROBES: usize = 32; +const MAX_SOCKET_BYTES: usize = 4096; +const MAX_ID_BYTES: usize = 512; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum HerdrStatus { + Blocked, + Working, + Idle, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HerdrTarget { + pub session_id: String, + pub pid: u32, + pub expected_incarnation: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct HerdrObservation { + pub status: HerdrStatus, + pub observed_at_ms: u64, + pub status_since_ms: u64, + pub consecutive_matching: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct TargetKey { + session_id: String, + pid: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ContinuityKey { + socket_path: String, + terminal_id: String, + pane_id: String, + session_id: String, + pid: u32, + process_incarnation: String, + status: HerdrStatus, + state_change_seq: u64, +} + +#[derive(Debug, Clone, Copy)] +struct Continuity { + first_observed_at_ms: u64, + consecutive_matching: u32, +} + +#[derive(Debug, Clone)] +struct PreparedTarget { + key: TargetKey, + incarnation: String, + sockets: Vec, +} + +#[derive(Debug, Clone)] +struct ValidatedMatch { + socket_path: String, + agent: StatusAgentPane, + target: PreparedTarget, + status: HerdrStatus, +} + +fn agent_snapshot_identities_are_unique(agents: &[StatusAgentPane]) -> bool { + let mut terminal_ids = HashSet::new(); + let mut pane_ids = HashSet::new(); + agents.iter().all(|agent| { + terminal_ids.insert(agent.terminal_id.as_str()) && pane_ids.insert(agent.pane_id.as_str()) + }) +} + +fn globally_unique_match_keys( + matches: &HashMap>, +) -> HashSet { + let mut session_match_counts = HashMap::::new(); + let mut process_match_counts = HashMap::<(u32, String), usize>::new(); + let mut terminal_match_counts = HashMap::<(String, String), usize>::new(); + let mut pane_match_counts = HashMap::<(String, String), usize>::new(); + for candidates in matches.values() { + for candidate in candidates { + *session_match_counts + .entry(candidate.target.key.session_id.clone()) + .or_default() += 1; + *process_match_counts + .entry(( + candidate.target.key.pid, + candidate.target.incarnation.clone(), + )) + .or_default() += 1; + *terminal_match_counts + .entry(( + candidate.socket_path.clone(), + candidate.agent.terminal_id.clone(), + )) + .or_default() += 1; + *pane_match_counts + .entry(( + candidate.socket_path.clone(), + candidate.agent.pane_id.clone(), + )) + .or_default() += 1; + } + } + + matches + .iter() + .filter_map(|(target, candidates)| { + let [candidate] = candidates.as_slice() else { + return None; + }; + (session_match_counts.get(&target.session_id) == Some(&1) + && process_match_counts.get(&( + candidate.target.key.pid, + candidate.target.incarnation.clone(), + )) == Some(&1) + && terminal_match_counts.get(&( + candidate.socket_path.clone(), + candidate.agent.terminal_id.clone(), + )) == Some(&1) + && pane_match_counts.get(&( + candidate.socket_path.clone(), + candidate.agent.pane_id.clone(), + )) == Some(&1)) + .then(|| target.clone()) + }) + .collect() +} + +trait ResolverProbe { + fn current_socket(&mut self) -> Option; + fn process_incarnation(&mut self, pid: u32) -> Option; + fn native_codex_process_is_exact(&mut self, pid: u32, incarnation: &str) -> bool; + fn process_env_var(&mut self, pid: u32, name: &str) -> Option; + fn run( + &mut self, + socket_path: &str, + args: &[&str], + deadline: Instant, + ) -> Option; +} + +struct SystemProbe; + +impl ResolverProbe for SystemProbe { + fn current_socket(&mut self) -> Option { + current_socket() + } + + fn process_incarnation(&mut self, pid: u32) -> Option { + process::get_process_incarnation(pid) + } + + fn native_codex_process_is_exact(&mut self, pid: u32, incarnation: &str) -> bool { + super::codex::native_codex_process_is_exact(pid, incarnation) + } + + fn process_env_var(&mut self, pid: u32, name: &str) -> Option { + process::read_process_env_var(pid, name) + } + + fn run( + &mut self, + socket_path: &str, + args: &[&str], + deadline: Instant, + ) -> Option { + run(socket_path, args, deadline) + } +} + +#[derive(Default)] +pub(crate) struct HerdrStatusResolver { + continuity: HashMap, + #[cfg(test)] + test_probe: Option>, +} + +impl HerdrStatusResolver { + pub(crate) fn resolve( + &mut self, + targets: &[HerdrTarget], + now_ms: u64, + ) -> HashMap<(String, u32), HerdrObservation> { + #[cfg(test)] + if let Some(mut probe) = self.test_probe.take() { + let observations = self.resolve_with_probe(targets, now_ms, probe.as_mut()); + self.test_probe = Some(probe); + return observations; + } + + self.resolve_with_probe(targets, now_ms, &mut SystemProbe) + } + + fn resolve_with_probe( + &mut self, + targets: &[HerdrTarget], + now_ms: u64, + probe: &mut P, + ) -> HashMap<(String, u32), HerdrObservation> { + let current_socket = probe.current_socket(); + let mut prepared = Vec::new(); + let mut sockets = HashSet::new(); + let mut invalid_session_ids = HashSet::new(); + for target in targets { + let Some(candidate) = prepare_target(target, current_socket.as_deref(), probe) else { + if !target.session_id.is_empty() && target.session_id.len() <= MAX_ID_BYTES { + invalid_session_ids.insert(target.session_id.clone()); + } + continue; + }; + sockets.extend(candidate.sockets.iter().cloned()); + prepared.push(candidate); + } + if sockets.is_empty() || sockets.len() > MAX_SOCKETS { + self.continuity.clear(); + return HashMap::new(); + } + + let deadline = Instant::now() + TOTAL_TIMEOUT; + let mut matches = HashMap::>::new(); + let mut invalid_targets = HashSet::::new(); + let mut probes = 0_usize; + let mut sockets = sockets.into_iter().collect::>(); + sockets.sort(); + for socket_path in sockets { + let socket_targets = prepared + .iter() + .filter(|target| target.sockets.contains(&socket_path)) + .collect::>(); + let Some(first) = list_agents(probe, &socket_path, deadline) else { + invalid_targets.extend(socket_targets.iter().map(|target| target.key.clone())); + continue; + }; + if first.len() > MAX_AGENT_ROWS { + invalid_targets.extend(socket_targets.iter().map(|target| target.key.clone())); + continue; + } + if !agent_snapshot_identities_are_unique(&first) { + invalid_targets.extend(socket_targets.iter().map(|target| target.key.clone())); + continue; + } + + let mut candidates = Vec::new(); + for target in socket_targets { + let matching = first + .iter() + .filter(|agent| agent_matches_target(agent, target)) + .collect::>(); + match matching.as_slice() { + [] => {} + [agent] => candidates.push(((*target).clone(), (*agent).clone())), + _ => { + invalid_targets.insert(target.key.clone()); + } + } + } + if probes.saturating_add(candidates.len()) > MAX_PANE_PROBES { + self.continuity.clear(); + return HashMap::new(); + } + + let mut owned = Vec::new(); + for (target, agent) in candidates { + probes += 1; + let Some(info) = process_info(probe, &socket_path, &agent.pane_id, deadline) else { + invalid_targets.insert(target.key.clone()); + continue; + }; + if !process_info_owns_pid(&info, &agent.pane_id, target.key.pid) { + invalid_targets.insert(target.key.clone()); + continue; + } + owned.push((target, agent)); + } + if owned.is_empty() { + continue; + } + + let Some(second) = list_agents(probe, &socket_path, deadline) else { + invalid_targets.extend(owned.iter().map(|(target, _)| target.key.clone())); + continue; + }; + if second.len() > MAX_AGENT_ROWS || !agent_snapshots_unchanged(&first, &second) { + invalid_targets.extend(owned.iter().map(|(target, _)| target.key.clone())); + continue; + } + for (target, agent) in owned { + if probe.process_incarnation(target.key.pid).as_deref() + != Some(target.incarnation.as_str()) + || !probe.native_codex_process_is_exact(target.key.pid, &target.incarnation) + { + invalid_targets.insert(target.key.clone()); + continue; + } + let Some(status) = parse_status(&agent.agent_status) else { + invalid_targets.insert(target.key.clone()); + continue; + }; + matches + .entry(target.key.clone()) + .or_default() + .push(ValidatedMatch { + socket_path: socket_path.clone(), + agent, + target, + status, + }); + } + } + invalid_session_ids.extend( + invalid_targets + .iter() + .map(|target| target.session_id.clone()), + ); + matches.retain(|key, _| !invalid_session_ids.contains(&key.session_id)); + + let unique_targets = globally_unique_match_keys(&matches); + + let mut current_keys = HashSet::new(); + let mut observations = HashMap::new(); + for (target_key, candidates) in matches { + let [candidate] = candidates.as_slice() else { + continue; + }; + if !unique_targets.contains(&target_key) { + continue; + } + let continuity_key = ContinuityKey { + socket_path: candidate.socket_path.clone(), + terminal_id: candidate.agent.terminal_id.clone(), + pane_id: candidate.agent.pane_id.clone(), + session_id: candidate.target.key.session_id.clone(), + pid: candidate.target.key.pid, + process_incarnation: candidate.target.incarnation.clone(), + status: candidate.status, + state_change_seq: candidate.agent.state_change_seq, + }; + current_keys.insert(continuity_key.clone()); + let continuity = self + .continuity + .entry(continuity_key) + .and_modify(|entry| { + entry.consecutive_matching = + entry.consecutive_matching.saturating_add(1).max(1); + }) + .or_insert(Continuity { + first_observed_at_ms: now_ms, + consecutive_matching: 1, + }); + observations.insert( + (target_key.session_id, target_key.pid), + HerdrObservation { + status: candidate.status, + observed_at_ms: now_ms, + status_since_ms: continuity.first_observed_at_ms, + consecutive_matching: continuity.consecutive_matching, + }, + ); + } + self.continuity.retain(|key, _| current_keys.contains(key)); + observations + } + + #[cfg(test)] + fn set_test_probe(&mut self, probe: impl ResolverProbe + 'static) { + self.test_probe = Some(Box::new(probe)); + } +} + +fn prepare_target( + target: &HerdrTarget, + current_socket: Option<&str>, + probe: &mut P, +) -> Option { + if target.pid == 0 || target.session_id.is_empty() || target.session_id.len() > MAX_ID_BYTES { + return None; + } + let before = probe.process_incarnation(target.pid)?; + if target + .expected_incarnation + .as_deref() + .is_some_and(|expected| expected != before) + { + return None; + } + let target_is_herdr = probe.process_env_var(target.pid, HERDR_ENV).as_deref() == Some("1"); + let target_socket = target_is_herdr + .then(|| probe.process_env_var(target.pid, HERDR_SOCKET_PATH)) + .flatten() + .filter(|socket| valid_socket(socket)); + if probe.process_incarnation(target.pid).as_deref() != Some(before.as_str()) { + return None; + } + let mut sockets = Vec::new(); + if let Some(socket) = target_socket { + sockets.push(socket); + } + if let Some(socket) = current_socket.filter(|socket| valid_socket(socket)) { + if !sockets.iter().any(|candidate| candidate == socket) { + sockets.push(socket.to_string()); + } + } + (!sockets.is_empty()).then(|| PreparedTarget { + key: TargetKey { + session_id: target.session_id.clone(), + pid: target.pid, + }, + incarnation: before, + sockets, + }) +} + +fn current_socket() -> Option { + (std::env::var(HERDR_ENV).ok().as_deref() == Some("1")) + .then(|| std::env::var(HERDR_SOCKET_PATH).ok()) + .flatten() + .filter(|socket| valid_socket(socket)) +} + +fn valid_socket(socket: &str) -> bool { + !socket.is_empty() + && socket.len() <= MAX_SOCKET_BYTES + && !socket + .bytes() + .any(|byte| byte == 0 || byte == b'\n' || byte == b'\r') + && Path::new(socket).is_absolute() +} + +fn agent_matches_target(agent: &StatusAgentPane, target: &PreparedTarget) -> bool { + if agent.screen_detection_skipped + || agent.terminal_id.is_empty() + || agent.terminal_id.len() > MAX_ID_BYTES + || agent.pane_id.is_empty() + || agent.pane_id.len() > MAX_ID_BYTES + || agent.agent.as_deref() != Some("codex") + { + return false; + } + agent.agent_session.as_ref().is_some_and(|session| { + session.source == "herdr:codex" + && session.agent == "codex" + && session.kind == "id" + && session.value == target.key.session_id + && session.source.len() <= MAX_ID_BYTES + && session.agent.len() <= MAX_ID_BYTES + && session.kind.len() <= MAX_ID_BYTES + && session.value.len() <= MAX_ID_BYTES + }) +} + +fn process_info_owns_pid(info: &herdr::ProcessInfo, pane_id: &str, pid: u32) -> bool { + info.pane_id == pane_id + && info.foreground_processes.len() <= MAX_AGENT_ROWS + && info + .foreground_processes + .iter() + .any(|process| process.pid == pid) +} + +fn agent_snapshots_unchanged(first: &[StatusAgentPane], second: &[StatusAgentPane]) -> bool { + if first.len() != second.len() { + return false; + } + let mut first = first.to_vec(); + let mut second = second.to_vec(); + first.sort_unstable(); + second.sort_unstable(); + first == second +} + +fn parse_status(status: &str) -> Option { + match status { + "blocked" => Some(HerdrStatus::Blocked), + "working" => Some(HerdrStatus::Working), + "idle" => Some(HerdrStatus::Idle), + _ => None, + } +} + +fn list_agents( + probe: &mut P, + socket_path: &str, + deadline: Instant, +) -> Option> { + let output = probe.run(socket_path, &["agent", "list"], deadline)?; + output + .success + .then(|| herdr::parse_status_agent_list(&output.stdout).ok()) + .flatten() +} + +fn process_info( + probe: &mut P, + socket_path: &str, + pane_id: &str, + deadline: Instant, +) -> Option { + let output = probe.run( + socket_path, + &["pane", "process-info", "--pane", pane_id], + deadline, + )?; + output + .success + .then(|| herdr::parse_process_info(&output.stdout).ok()) + .flatten() +} + +fn run(socket_path: &str, args: &[&str], deadline: Instant) -> Option { + let remaining = deadline.checked_duration_since(Instant::now())?; + if remaining.is_zero() { + return None; + } + let timeout = remaining.min(COMMAND_TIMEOUT); + let args = args + .iter() + .map(|arg| (*arg).to_string()) + .collect::>(); + herdr::run_herdr(socket_path, &args, timeout).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + + const TEST_PID: u32 = 42; + const TEST_SESSION: &str = "session-1"; + #[cfg(not(windows))] + const TEST_SOCKET: &str = "/tmp/herdr.sock"; + #[cfg(windows)] + const TEST_SOCKET: &str = r"C:\herdr.sock"; + const TEST_INCARNATION: &str = "incarnation-1"; + + fn named_test_socket(name: &str) -> String { + if cfg!(windows) { + format!(r"C:\{name}.sock") + } else { + format!("/tmp/{name}.sock") + } + } + + struct FakeProbe { + current_socket: Option, + target_socket: Option, + fallback_incarnation: Option, + incarnation_script: VecDeque>, + native_exact: bool, + commands: VecDeque<(String, Vec, Option)>, + } + + impl FakeProbe { + fn new(commands: Vec<(Vec, Option)>) -> Self { + Self::new_on( + commands + .into_iter() + .map(|(args, output)| (TEST_SOCKET.to_string(), args, output)) + .collect(), + ) + } + + fn new_on(commands: Vec<(String, Vec, Option)>) -> Self { + Self { + current_socket: None, + target_socket: Some(TEST_SOCKET.to_string()), + fallback_incarnation: Some(TEST_INCARNATION.to_string()), + incarnation_script: VecDeque::new(), + native_exact: true, + commands: commands.into(), + } + } + + fn stable(agent: &StatusAgentPane) -> Self { + Self::new(vec![ + expected( + &["agent", "list"], + Some(agent_list_output(std::slice::from_ref(agent))), + ), + expected( + &["pane", "process-info", "--pane", &agent.pane_id], + Some(process_info_output(&agent.pane_id, &[TEST_PID])), + ), + expected( + &["agent", "list"], + Some(agent_list_output(std::slice::from_ref(agent))), + ), + ]) + } + } + + impl ResolverProbe for FakeProbe { + fn current_socket(&mut self) -> Option { + self.current_socket.clone() + } + + fn process_incarnation(&mut self, pid: u32) -> Option { + assert_eq!(pid, TEST_PID); + self.incarnation_script + .pop_front() + .unwrap_or_else(|| self.fallback_incarnation.clone()) + } + + fn native_codex_process_is_exact(&mut self, pid: u32, incarnation: &str) -> bool { + assert_eq!(pid, TEST_PID); + assert_eq!(incarnation, TEST_INCARNATION); + self.native_exact + } + + fn process_env_var(&mut self, pid: u32, name: &str) -> Option { + assert_eq!(pid, TEST_PID); + match name { + HERDR_ENV if self.target_socket.is_some() => Some("1".to_string()), + HERDR_SOCKET_PATH => self.target_socket.clone(), + _ => None, + } + } + + fn run( + &mut self, + socket_path: &str, + args: &[&str], + _deadline: Instant, + ) -> Option { + let (expected_socket, expected_args, output) = + self.commands.pop_front().expect("unexpected Herdr command"); + assert_eq!(socket_path, expected_socket); + assert_eq!( + args.iter() + .map(|arg| (*arg).to_string()) + .collect::>(), + expected_args + ); + output + } + } + + fn expected( + args: &[&str], + output: Option, + ) -> (Vec, Option) { + (args.iter().map(|arg| (*arg).to_string()).collect(), output) + } + + fn expected_on( + socket: &str, + args: &[&str], + output: Option, + ) -> (String, Vec, Option) { + ( + socket.to_string(), + args.iter().map(|arg| (*arg).to_string()).collect(), + output, + ) + } + + fn output(stdout: Vec) -> herdr::CommandOutput { + herdr::CommandOutput { + success: true, + status: "exit status: 0".to_string(), + stdout, + stderr: Vec::new(), + } + } + + fn agent_list_output(agents: &[StatusAgentPane]) -> herdr::CommandOutput { + let agents = agents + .iter() + .map(|agent| { + let session = agent.agent_session.as_ref().map(|session| { + serde_json::json!({ + "source": session.source, + "agent": session.agent, + "kind": session.kind, + "value": session.value, + }) + }); + let mut row = serde_json::json!({ + "terminal_id": agent.terminal_id, + "agent": agent.agent, + "agent_status": agent.agent_status, + "agent_session": session, + "pane_id": agent.pane_id, + "state_change_seq": agent.state_change_seq, + "revision": agent.revision, + }); + if agent.screen_detection_skipped { + row["screen_detection_skipped"] = serde_json::Value::Bool(true); + } + row + }) + .collect::>(); + output( + serde_json::json!({"result": {"agents": agents}}) + .to_string() + .into_bytes(), + ) + } + + fn process_info_output(pane_id: &str, pids: &[u32]) -> herdr::CommandOutput { + let processes = pids + .iter() + .map(|pid| serde_json::json!({"pid": pid})) + .collect::>(); + output( + serde_json::json!({ + "result": { + "process_info": { + "pane_id": pane_id, + "foreground_processes": processes, + } + } + }) + .to_string() + .into_bytes(), + ) + } + + fn target() -> HerdrTarget { + HerdrTarget { + session_id: TEST_SESSION.to_string(), + pid: TEST_PID, + expected_incarnation: Some(TEST_INCARNATION.to_string()), + } + } + + fn resolve_stable( + resolver: &mut HerdrStatusResolver, + agent: &StatusAgentPane, + now_ms: u64, + ) -> HerdrObservation { + let observations = resolve_with_fake(resolver, now_ms, FakeProbe::stable(agent)); + *observations + .get(&(TEST_SESSION.to_string(), TEST_PID)) + .expect("stable exact match should resolve") + } + + fn resolve_with_fake( + resolver: &mut HerdrStatusResolver, + now_ms: u64, + probe: FakeProbe, + ) -> HashMap<(String, u32), HerdrObservation> { + resolver.set_test_probe(probe); + resolver.resolve(&[target()], now_ms) + } + + fn assert_continuity_restarts( + resolver: &mut HerdrStatusResolver, + agent: &StatusAgentPane, + now_ms: u64, + ) { + let observation = resolve_stable(resolver, agent, now_ms); + assert_eq!(observation.status_since_ms, now_ms); + assert_eq!(observation.consecutive_matching, 1); + } + + fn pane(status: &str, session_id: &str) -> StatusAgentPane { + StatusAgentPane { + terminal_id: "terminal-1".to_string(), + agent: Some("codex".to_string()), + agent_status: status.to_string(), + screen_detection_skipped: false, + agent_session: Some(crate::herdr::AgentSessionInfo { + source: "herdr:codex".to_string(), + agent: "codex".to_string(), + kind: "id".to_string(), + value: session_id.to_string(), + }), + pane_id: "pane-1".to_string(), + state_change_seq: 7, + revision: 9, + } + } + + #[test] + fn resolve_accepts_a_stable_exact_match_and_tracks_continuity() { + let mut resolver = HerdrStatusResolver::default(); + let agent = pane("working", TEST_SESSION); + + let first = resolve_stable(&mut resolver, &agent, 100); + assert_eq!( + first, + HerdrObservation { + status: HerdrStatus::Working, + observed_at_ms: 100, + status_since_ms: 100, + consecutive_matching: 1, + } + ); + + let second = resolve_stable(&mut resolver, &agent, 125); + assert_eq!(second.observed_at_ms, 125); + assert_eq!(second.status_since_ms, 100); + assert_eq!(second.consecutive_matching, 2); + } + + #[test] + fn resolve_rejects_a_changed_snapshot_and_clears_continuity() { + let mut resolver = HerdrStatusResolver::default(); + let first = pane("working", TEST_SESSION); + resolve_stable(&mut resolver, &first, 100); + + let mut changed = first.clone(); + changed.state_change_seq += 1; + let probe = FakeProbe::new(vec![ + expected( + &["agent", "list"], + Some(agent_list_output(std::slice::from_ref(&first))), + ), + expected( + &["pane", "process-info", "--pane", &first.pane_id], + Some(process_info_output(&first.pane_id, &[TEST_PID])), + ), + expected( + &["agent", "list"], + Some(agent_list_output(std::slice::from_ref(&changed))), + ), + ]); + assert!(resolve_with_fake(&mut resolver, 125, probe).is_empty()); + + assert_continuity_restarts(&mut resolver, &changed, 150); + } + + #[test] + fn resolve_requires_the_entire_agent_snapshot_to_remain_unchanged() { + let mut resolver = HerdrStatusResolver::default(); + let agent = pane("working", TEST_SESSION); + resolve_stable(&mut resolver, &agent, 100); + + let mut unrelated = pane("idle", "another-session"); + unrelated.pane_id = "pane-2".to_string(); + unrelated.terminal_id = "terminal-2".to_string(); + let mut changed_unrelated = unrelated.clone(); + changed_unrelated.state_change_seq += 1; + let probe = FakeProbe::new(vec![ + expected( + &["agent", "list"], + Some(agent_list_output(&[agent.clone(), unrelated])), + ), + expected( + &["pane", "process-info", "--pane", &agent.pane_id], + Some(process_info_output(&agent.pane_id, &[TEST_PID])), + ), + expected( + &["agent", "list"], + Some(agent_list_output(&[agent.clone(), changed_unrelated])), + ), + ]); + assert!(resolve_with_fake(&mut resolver, 125, probe).is_empty()); + + assert_continuity_restarts(&mut resolver, &agent, 150); + } + + #[test] + fn resolve_accepts_a_semantically_unchanged_reordered_snapshot() { + let mut resolver = HerdrStatusResolver::default(); + let agent = pane("working", TEST_SESSION); + let mut unrelated = pane("idle", "another-session"); + unrelated.pane_id = "pane-2".to_string(); + unrelated.terminal_id = "terminal-2".to_string(); + let probe = FakeProbe::new(vec![ + expected( + &["agent", "list"], + Some(agent_list_output(&[agent.clone(), unrelated.clone()])), + ), + expected( + &["pane", "process-info", "--pane", &agent.pane_id], + Some(process_info_output(&agent.pane_id, &[TEST_PID])), + ), + expected( + &["agent", "list"], + Some(agent_list_output(&[unrelated, agent.clone()])), + ), + ]); + + let observations = resolve_with_fake(&mut resolver, 100, probe); + assert_eq!( + observations + .get(&(TEST_SESSION.to_string(), TEST_PID)) + .map(|observation| observation.status), + Some(HerdrStatus::Working) + ); + } + + #[test] + fn resolve_rejects_duplicate_session_claims_and_clears_continuity() { + let mut resolver = HerdrStatusResolver::default(); + let agent = pane("working", TEST_SESSION); + resolve_stable(&mut resolver, &agent, 100); + + let probe = FakeProbe::new(vec![expected( + &["agent", "list"], + Some(agent_list_output(&[agent.clone(), agent.clone()])), + )]); + assert!(resolve_with_fake(&mut resolver, 125, probe).is_empty()); + + assert_continuity_restarts(&mut resolver, &agent, 150); + } + + #[test] + fn resolve_rejects_target_and_unrelated_duplicate_before_probing_and_clears_continuity() { + struct DuplicateClaimProbe { + incarnations: HashMap, + list_output: Option, + } + + impl ResolverProbe for DuplicateClaimProbe { + fn current_socket(&mut self) -> Option { + None + } + + fn process_incarnation(&mut self, pid: u32) -> Option { + self.incarnations.get(&pid).cloned() + } + + fn native_codex_process_is_exact(&mut self, _: u32, _: &str) -> bool { + panic!("ambiguous pane claims must be rejected before process validation") + } + + fn process_env_var(&mut self, _: u32, name: &str) -> Option { + match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some(TEST_SOCKET.to_string()), + _ => None, + } + } + + fn run( + &mut self, + socket_path: &str, + args: &[&str], + _: Instant, + ) -> Option { + assert_eq!(socket_path, TEST_SOCKET); + assert_eq!(args, ["agent", "list"]); + self.list_output.take() + } + } + + let mut resolver = HerdrStatusResolver::default(); + let first = pane("working", TEST_SESSION); + resolve_stable(&mut resolver, &first, 100); + + let mut unrelated = pane("idle", "unrelated-session"); + unrelated.agent = Some("claude".to_string()); + unrelated.agent_session = Some(crate::herdr::AgentSessionInfo { + source: "herdr:claude".to_string(), + agent: "claude".to_string(), + kind: "id".to_string(), + value: "unrelated-session".to_string(), + }); + unrelated.terminal_id = "terminal-unrelated".to_string(); + resolver.set_test_probe(DuplicateClaimProbe { + incarnations: HashMap::from([(TEST_PID, TEST_INCARNATION.to_string())]), + list_output: Some(agent_list_output(&[first.clone(), unrelated])), + }); + assert!(resolver.resolve(&[target()], 125).is_empty()); + + assert_continuity_restarts(&mut resolver, &first, 150); + } + + #[test] + fn resolve_rejects_unrelated_terminal_duplicate_before_probing_and_clears_continuity() { + let mut resolver = HerdrStatusResolver::default(); + let first = pane("working", TEST_SESSION); + resolve_stable(&mut resolver, &first, 100); + + let mut unrelated = pane("idle", "unrelated-session"); + unrelated.agent = Some("claude".to_string()); + unrelated.agent_session = Some(crate::herdr::AgentSessionInfo { + source: "herdr:claude".to_string(), + agent: "claude".to_string(), + kind: "id".to_string(), + value: "unrelated-session".to_string(), + }); + unrelated.pane_id = "pane-unrelated".to_string(); + assert_eq!(unrelated.terminal_id, first.terminal_id); + + let probe = FakeProbe::new(vec![expected( + &["agent", "list"], + Some(agent_list_output(&[first.clone(), unrelated])), + )]); + assert!(resolve_with_fake(&mut resolver, 125, probe).is_empty()); + + assert_continuity_restarts(&mut resolver, &first, 150); + } + + #[test] + fn resolve_rejects_timeout_and_malformed_probes_and_clears_continuity() { + let mut resolver = HerdrStatusResolver::default(); + let agent = pane("working", TEST_SESSION); + resolve_stable(&mut resolver, &agent, 100); + + let timeout = FakeProbe::new(vec![expected(&["agent", "list"], None)]); + assert!(resolve_with_fake(&mut resolver, 125, timeout).is_empty()); + assert_continuity_restarts(&mut resolver, &agent, 150); + + let malformed_output = output(b"{not-json".to_vec()); + let malformed = FakeProbe::new(vec![expected(&["agent", "list"], Some(malformed_output))]); + assert!(resolve_with_fake(&mut resolver, 175, malformed).is_empty()); + assert_continuity_restarts(&mut resolver, &agent, 200); + } + + #[test] + fn resolve_rejects_partial_success_across_candidate_sockets() { + let mut resolver = HerdrStatusResolver::default(); + let agent = pane("working", TEST_SESSION); + resolve_stable(&mut resolver, &agent, 100); + let current_socket = named_test_socket("current-herdr"); + + let mut probe = FakeProbe::new_on(vec![ + expected_on(¤t_socket, &["agent", "list"], None), + expected_on( + TEST_SOCKET, + &["agent", "list"], + Some(agent_list_output(std::slice::from_ref(&agent))), + ), + expected_on( + TEST_SOCKET, + &["pane", "process-info", "--pane", &agent.pane_id], + Some(process_info_output(&agent.pane_id, &[TEST_PID])), + ), + expected_on( + TEST_SOCKET, + &["agent", "list"], + Some(agent_list_output(std::slice::from_ref(&agent))), + ), + ]); + probe.current_socket = Some(current_socket); + assert!(resolve_with_fake(&mut resolver, 125, probe).is_empty()); + + assert_continuity_restarts(&mut resolver, &agent, 150); + } + + #[test] + fn resolve_rejects_missing_exact_pid_and_clears_continuity() { + let mut resolver = HerdrStatusResolver::default(); + let agent = pane("working", TEST_SESSION); + resolve_stable(&mut resolver, &agent, 100); + + let probe = FakeProbe::new(vec![ + expected( + &["agent", "list"], + Some(agent_list_output(std::slice::from_ref(&agent))), + ), + expected( + &["pane", "process-info", "--pane", &agent.pane_id], + Some(process_info_output(&agent.pane_id, &[TEST_PID + 1])), + ), + ]); + assert!(resolve_with_fake(&mut resolver, 125, probe).is_empty()); + + assert_continuity_restarts(&mut resolver, &agent, 150); + } + + #[test] + fn resolve_rejects_pid_incarnation_change_and_clears_continuity() { + let mut resolver = HerdrStatusResolver::default(); + let agent = pane("working", TEST_SESSION); + resolve_stable(&mut resolver, &agent, 100); + + let mut probe = FakeProbe::stable(&agent); + probe.incarnation_script = VecDeque::from([ + Some(TEST_INCARNATION.to_string()), + Some(TEST_INCARNATION.to_string()), + Some("incarnation-2".to_string()), + ]); + assert!(resolve_with_fake(&mut resolver, 125, probe).is_empty()); + + assert_continuity_restarts(&mut resolver, &agent, 150); + } + + #[test] + fn resolve_rechecks_the_native_codex_process_and_clears_continuity() { + let mut resolver = HerdrStatusResolver::default(); + let agent = pane("working", TEST_SESSION); + resolve_stable(&mut resolver, &agent, 100); + + let mut probe = FakeProbe::stable(&agent); + probe.native_exact = false; + assert!(resolve_with_fake(&mut resolver, 125, probe).is_empty()); + + assert_continuity_restarts(&mut resolver, &agent, 150); + } + + #[test] + fn matching_requires_exact_codex_session_identity() { + let target = PreparedTarget { + key: TargetKey { + session_id: "session-1".to_string(), + pid: 42, + }, + incarnation: "incarnation".to_string(), + sockets: vec![TEST_SOCKET.to_string()], + }; + assert!(agent_matches_target(&pane("blocked", "session-1"), &target)); + assert!(!agent_matches_target( + &pane("blocked", "session-2"), + &target + )); + + let mut skipped = pane("blocked", "session-1"); + skipped.screen_detection_skipped = true; + assert!(!agent_matches_target(&skipped, &target)); + } + + #[test] + fn only_screen_states_with_useful_semantics_are_accepted() { + assert_eq!(parse_status("blocked"), Some(HerdrStatus::Blocked)); + assert_eq!(parse_status("working"), Some(HerdrStatus::Working)); + assert_eq!(parse_status("idle"), Some(HerdrStatus::Idle)); + assert_eq!(parse_status("done"), None); + assert_eq!(parse_status("unknown"), None); + } + + #[test] + fn duplicate_session_claims_and_changed_snapshots_are_detectable() { + let target = PreparedTarget { + key: TargetKey { + session_id: "session-1".to_string(), + pid: 42, + }, + incarnation: "incarnation".to_string(), + sockets: vec![TEST_SOCKET.to_string()], + }; + let first = pane("working", "session-1"); + assert_eq!( + std::slice::from_ref(&first) + .iter() + .filter(|agent| agent_matches_target(agent, &target)) + .count(), + 1 + ); + assert_eq!( + [first.clone(), first.clone()] + .iter() + .filter(|agent| agent_matches_target(agent, &target)) + .count(), + 2 + ); + + let mut changed = first.clone(); + changed.state_change_seq += 1; + assert_ne!(vec![changed], vec![first.clone()]); + assert_eq!(vec![first.clone()], vec![first]); + } + + #[test] + fn validated_matches_are_unique_by_session_process_terminal_and_pane_globally() { + let candidate = |session_id: &str, pid: u32, incarnation: &str, pane_id: &str| { + let mut agent = pane("working", session_id); + agent.pane_id = pane_id.to_string(); + agent.terminal_id = format!("terminal-{pane_id}"); + ValidatedMatch { + socket_path: named_test_socket(pane_id), + agent, + target: PreparedTarget { + key: TargetKey { + session_id: session_id.to_string(), + pid, + }, + incarnation: incarnation.to_string(), + sockets: Vec::new(), + }, + status: HerdrStatus::Working, + } + }; + + let one = candidate("session-1", 42, "incarnation-1", "pane-1"); + let one_key = one.target.key.clone(); + let unique = HashMap::from([(one_key.clone(), vec![one])]); + assert_eq!( + globally_unique_match_keys(&unique), + HashSet::from([one_key]) + ); + + let first = candidate("session-1", 42, "incarnation-1", "pane-1"); + let second = candidate("session-1", 43, "incarnation-2", "pane-2"); + let duplicate_session = HashMap::from([ + (first.target.key.clone(), vec![first]), + (second.target.key.clone(), vec![second]), + ]); + assert!(globally_unique_match_keys(&duplicate_session).is_empty()); + + let first = candidate("session-1", 42, "incarnation-1", "pane-1"); + let second = candidate("session-2", 42, "incarnation-1", "pane-2"); + let duplicate_process = HashMap::from([ + (first.target.key.clone(), vec![first]), + (second.target.key.clone(), vec![second]), + ]); + assert!(globally_unique_match_keys(&duplicate_process).is_empty()); + + let first = candidate("session-1", 42, "incarnation-1", "pane-1"); + let mut second = candidate("session-2", 43, "incarnation-2", "pane-1"); + second.agent.terminal_id = "terminal-pane-1-alias".to_string(); + let duplicate_pane = HashMap::from([ + (first.target.key.clone(), vec![first]), + (second.target.key.clone(), vec![second]), + ]); + assert!(globally_unique_match_keys(&duplicate_pane).is_empty()); + + let mut first = candidate("session-1", 42, "incarnation-1", "pane-1"); + let mut second = candidate("session-2", 43, "incarnation-2", "pane-2"); + first.agent.terminal_id = "terminal-shared".to_string(); + second.agent.terminal_id = "terminal-shared".to_string(); + second.socket_path.clone_from(&first.socket_path); + let duplicate_terminal = HashMap::from([ + (first.target.key.clone(), vec![first]), + (second.target.key.clone(), vec![second]), + ]); + assert!(globally_unique_match_keys(&duplicate_terminal).is_empty()); + + let mut first = candidate("session-1", 42, "incarnation-1", "pane-1"); + let mut second = candidate("session-2", 43, "incarnation-2", "pane-1"); + first.socket_path = named_test_socket("server-1"); + second.socket_path = named_test_socket("server-2"); + let first_key = first.target.key.clone(); + let second_key = second.target.key.clone(); + let separate_socket_namespaces = HashMap::from([ + (first_key.clone(), vec![first]), + (second_key.clone(), vec![second]), + ]); + assert_eq!( + globally_unique_match_keys(&separate_socket_namespaces), + HashSet::from([first_key, second_key]) + ); + } + + #[test] + fn complete_agent_snapshot_requires_unique_terminal_and_pane_identities() { + let mut first_agent = pane("working", "session-1"); + let mut second_agent = pane("working", "session-2"); + first_agent.pane_id = "pane-shared".to_string(); + second_agent.pane_id = "pane-shared".to_string(); + first_agent.terminal_id = "terminal-1".to_string(); + second_agent.terminal_id = "terminal-2".to_string(); + assert!(!agent_snapshot_identities_are_unique(&[ + first_agent, + second_agent + ])); + + let mut first_agent = pane("working", "session-1"); + let mut second_agent = pane("working", "session-2"); + first_agent.pane_id = "pane-1".to_string(); + second_agent.pane_id = "pane-2".to_string(); + first_agent.terminal_id = "terminal-shared".to_string(); + second_agent.terminal_id = "terminal-shared".to_string(); + assert!(!agent_snapshot_identities_are_unique(&[ + first_agent, + second_agent + ])); + + let first_agent = pane("working", "session-1"); + let mut second_agent = pane("working", "session-2"); + second_agent.pane_id = "pane-2".to_string(); + second_agent.terminal_id = "terminal-2".to_string(); + assert!(agent_snapshot_identities_are_unique(&[ + first_agent, + second_agent + ])); + } + + #[test] + fn pane_ownership_requires_the_requested_pane_and_exact_pid() { + let info = herdr::ProcessInfo { + pane_id: "pane-1".to_string(), + foreground_processes: vec![crate::herdr::ProcessPid { pid: 42 }], + }; + assert!(process_info_owns_pid(&info, "pane-1", 42)); + assert!(!process_info_owns_pid(&info, "pane-2", 42)); + assert!(!process_info_owns_pid(&info, "pane-1", 43)); + } + + #[test] + fn socket_paths_are_bounded_absolute_paths() { + assert!(valid_socket(TEST_SOCKET)); + assert!(!valid_socket("relative.sock")); + assert!(!valid_socket(&format!("{TEST_SOCKET}\nother"))); + assert!(!valid_socket(&named_test_socket( + &"x".repeat(MAX_SOCKET_BYTES) + ))); + } +} diff --git a/src/collector/kimi.rs b/src/collector/kimi.rs new file mode 100644 index 0000000..dcfc8b3 --- /dev/null +++ b/src/collector/kimi.rs @@ -0,0 +1,4821 @@ +//! Collector for current Moonshot Kimi Code sessions (`~/.kimi-code`). + +use super::{abbrev_path, process, redact_secrets, sanitize_terminal_text, AgentCollector}; +use crate::model::{ + AgentSession, ChatMessage, ChatRole, ChildProcess, FileAccess, FileOp, SessionStatus, + StatusAuthority, StatusEvidence, StatusObservation, StatusReason, SubAgent, ToolCall, + MAX_CHAT_MESSAGES, MAX_FILE_ACCESSES, +}; +use serde_json::Value; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs::{self, File}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +const MAX_INDEX_BYTES: u64 = 16 * 1024 * 1024; +const MAX_STATE_BYTES: u64 = 2 * 1024 * 1024; +const MAX_WIRE_LINE_BYTES: usize = 2 * 1024 * 1024; +const MAX_WIRE_READ_BYTES: u64 = 32 * 1024 * 1024; +const MAX_SESSIONS: usize = 500; +const MAX_WIRE_CACHES: usize = 128; +const MAX_TOOL_CALLS: usize = 500; +const MAX_HISTORY_POINTS: usize = 10_000; +const WIRE_PREFIX_BYTES: u64 = 256; +const MAX_TASK_FILES: usize = 512; +const MAX_TASK_BYTES: u64 = 256 * 1024; +/// v1.4 persists foreground starts but not every fatal completion. Treat a +/// quiet non-wait foreground record as current only for this bounded lease. +const V1_NON_WAIT_FOREGROUND_LEASE_MS: u64 = 10 * 60 * 1_000; + +pub struct KimiCollector { + roots: Vec, + sessions: Vec, + wires: HashMap, + assignments: HashMap, +} + +impl KimiCollector { + pub fn new() -> Self { + Self { + roots: default_roots(), + sessions: Vec::new(), + wires: HashMap::new(), + assignments: HashMap::new(), + } + } + + fn collect_sessions(&mut self, shared: &super::SharedProcessData) -> Vec { + let mut processes: Vec = shared + .process_info + .iter() + .filter(|(_, info)| is_kimi_process(&info.command)) + .filter_map(|(&pid, _)| { + let incarnation = process::get_process_incarnation(pid)?; + let tokens = process::get_process_tokens(pid)?; + if !is_kimi_process_tokens(&tokens) { + return None; + } + let cwd = process::get_process_cwd(pid)?; + let root = process_root(pid, &cwd)?; + let started_at = process::get_process_started_at_ms(pid); + let current_incarnation = process::get_process_incarnation(pid); + if !kimi_process_observation_is_exact( + &incarnation, + current_incarnation.as_deref(), + &tokens, + ) { + return None; + } + Some(KimiProcess { + pid, + cwd, + root, + explicit_session: explicit_session_id_from_tokens(&tokens), + bare_title: is_bare_kimi_tokens(&tokens), + started_at, + incarnation, + }) + }) + .collect(); + + if shared.slow_tick || self.sessions.is_empty() { + self.refresh_roots(&processes); + self.sessions = self + .roots + .iter() + .flat_map(|root| read_session_index(root)) + .collect(); + self.sessions + .sort_by_key(|s| std::cmp::Reverse(s.updated_at)); + self.sessions.truncate(MAX_SESSIONS); + } + + let live_incarnations: HashMap = processes + .iter() + .map(|process| (process.pid, process.incarnation.as_str())) + .collect(); + self.assignments.retain(|pid, assignment| { + live_incarnations.get(pid).copied() == Some(assignment.process_incarnation.as_str()) + && self + .sessions + .iter() + .any(|session| session.dir == assignment.dir) + }); + + let cwd_counts = processes.iter().fold( + HashMap::<(PathBuf, String), usize>::new(), + |mut counts, process| { + *counts + .entry((process.root.clone(), normalize_path(&process.cwd))) + .or_default() += 1; + counts + }, + ); + // Preserve explicit and prior assignments before pairing newcomers. + // HashMap iteration order must not reshuffle same-cwd rows between + // polls; ambiguous groups remain Unknown, but their display should be + // deterministic and sticky. + processes.sort_by_key(|process| { + let priority = if process.explicit_session.is_some() { + 0 + } else if self.assignments.contains_key(&process.pid) { + 1 + } else { + 2 + }; + (priority, process.pid) + }); + let mut claimed = HashSet::new(); + let mut output = Vec::new(); + + for proc_ctx in processes { + let cwd_key = normalize_path(&proc_ctx.cwd); + let candidates: Vec<&KimiSession> = self + .sessions + .iter() + .filter(|session| session_matches_process(session, &proc_ctx)) + .collect(); + let shared_cwd = cwd_counts + .get(&(proc_ctx.root.clone(), cwd_key)) + .copied() + .unwrap_or(0) + > 1; + let selected = select_session( + &proc_ctx, + &candidates, + self.assignments.get(&proc_ctx.pid), + &claimed, + !shared_cwd, + ); + let Some(session) = selected.cloned() else { + continue; + }; + claimed.insert(session.dir.clone()); + let previous_assignment = self.assignments.get(&proc_ctx.pid).cloned(); + let pairing_authority = + pairing_authority(&proc_ctx, &session, previous_assignment.as_ref()); + let pairing_confirmed = pairing_authority != StatusAuthority::Unavailable; + let activity_boundary_ms = proc_ctx + .started_at + .or_else(|| { + previous_assignment + .as_ref() + .filter(|assignment| assignment.dir == session.dir) + .filter(|assignment| assignment.process_incarnation == proc_ctx.incarnation) + .map(|assignment| assignment.activity_boundary_ms) + }) + .unwrap_or_else(current_time_ms); + self.assignments.insert( + proc_ctx.pid, + KimiAssignment { + dir: session.dir.clone(), + confirmed: pairing_confirmed, + authority: pairing_authority, + activity_boundary_ms, + process_incarnation: proc_ctx.incarnation.clone(), + }, + ); + let ownership_unknown = shared_cwd || !pairing_confirmed; + let action_process_incarnation = (!shared_cwd + && pairing_authority == StatusAuthority::Provider) + .then(|| proc_ctx.incarnation.clone()); + if let Some(row) = self.build_session( + &session, + proc_ctx.pid, + activity_boundary_ms, + KimiRowOwnership { + ambiguous: ownership_unknown, + authority: pairing_authority, + action_process_incarnation, + }, + shared, + ) { + output.push(row); + } + } + + let keep: HashSet = output + .iter() + .filter_map(|s| self.assignments.get(&s.pid)) + .flat_map(|assignment| { + let dir = &assignment.dir; + let mut paths = vec![dir.join("agents/main/wire.jsonl")]; + paths.extend( + self.sessions + .iter() + .find(|s| &s.dir == dir) + .into_iter() + .flat_map(|s| s.agents.iter()) + .filter(|a| a.kind == "sub") + .map(|a| dir.join("agents").join(&a.id).join("wire.jsonl")), + ); + paths + }) + .collect(); + self.wires.retain(|path, _| keep.contains(path)); + if self.wires.len() > MAX_WIRE_CACHES { + self.wires.clear(); + } + output + } + + fn refresh_roots(&mut self, processes: &[KimiProcess]) { + let mut roots = default_roots(); + roots.extend(processes.iter().map(|process| process.root.clone())); + roots.sort(); + roots.dedup(); + self.roots = roots; + } + + fn parse_wire(&mut self, path: &Path) -> WireState { + if is_symlink(path) { + self.wires.remove(path); + return WireState { + lifecycle_failure: Some(StatusReason::ProtocolMalformed), + ..WireState::default() + }; + } + let cache = self.wires.entry(path.to_path_buf()).or_default(); + let availability = cache.refresh(path); + let mut state = cache.state.clone(); + if let WireAvailability::Failed(reason) = availability { + state.lifecycle_failure = Some(reason); + } + state + } + + fn parse_agent_wire( + &mut self, + session_dir: &Path, + agent_id: &str, + activity_boundary_ms: u64, + include_session_fallback: bool, + observed_at_ms: u64, + ) -> WireState { + let agent_dir = session_dir.join("agents").join(agent_id); + let mut state = self.parse_wire(&agent_dir.join("wire.jsonl")); + let mut task_dirs = vec![agent_dir.join("tasks")]; + if include_session_fallback { + task_dirs.push(session_dir.join("tasks")); + } + match read_task_snapshots(session_dir, &task_dirs, activity_boundary_ms) { + Ok(snapshots) => state.reconcile_task_snapshots(snapshots), + Err(reason) => { + state.lifecycle_failure.get_or_insert(reason); + } + } + state.expire_foreground_lease_at(observed_at_ms); + state + } + + fn build_session( + &mut self, + meta: &KimiSession, + pid: u32, + activity_boundary_ms: u64, + ownership: KimiRowOwnership, + shared: &super::SharedProcessData, + ) -> Option { + let KimiRowOwnership { + ambiguous, + authority: pairing_authority, + action_process_incarnation, + } = ownership; + let observed_at_ms = current_time_ms(); + let wire = self.parse_agent_wire( + &meta.dir, + "main", + activity_boundary_ms, + true, + observed_at_ms, + ); + let context_window = model_context_limit(&meta.root, &wire.model_alias, &wire.model); + let context_percent = if context_window == 0 { + 0.0 + } else { + wire.last_context_tokens as f64 * 100.0 / context_window as f64 + }; + + let mut child_wires = Vec::new(); + let mut subagent_states = wire.subagents.clone(); + for agent in meta.agents.iter().filter(|a| a.kind == "sub") { + let child = self.parse_agent_wire( + &meta.dir, + &agent.id, + activity_boundary_ms, + false, + observed_at_ms, + ); + merge_child_subagent( + &mut subagent_states, + &agent.id, + &child, + activity_boundary_ms, + ); + child_wires.push((agent.id.clone(), child)); + } + for subagent in subagent_states.values_mut() { + if task_status_is_active(&subagent.status) && subagent.started_at < activity_boundary_ms + { + subagent.status = "idle".to_string(); + } + } + let active_child_process = + process::has_active_descendant(pid, &shared.children_map, &shared.process_info, 5.0); + let (status, awaiting_input) = wire_session_status( + &wire, + child_wires.iter().map(|(_, child)| child), + activity_boundary_ms, + ambiguous, + active_child_process, + ); + let status_evidence = kimi_status_evidence( + status, + &wire, + child_wires.iter().map(|(_, child)| child), + activity_boundary_ms, + active_child_process, + if ambiguous { + StatusAuthority::Unavailable + } else { + pairing_authority + }, + observed_at_ms, + ); + let current_tasks = kimi_current_tasks( + &wire, + &child_wires, + activity_boundary_ms, + ambiguous, + status, + awaiting_input, + active_child_process, + ); + + let mut subagents = subagent_states + .values() + .map(SubagentState::to_model) + .collect::>(); + subagents.sort_by(|left, right| left.name.cmp(&right.name)); + let (pending_since_ms, thinking_since_ms) = lifecycle_timestamps( + status, + &wire, + child_wires.iter().map(|(_, child)| child), + activity_boundary_ms, + ); + + let proc_info = shared.process_info.get(&pid); + Some(AgentSession { + agent_cli: "kimi", + pid, + action_process_incarnation, + session_id: meta.id.clone(), + cwd: meta.cwd.clone(), + project_name: process::last_path_segment(&meta.cwd) + .unwrap_or("?") + .to_string(), + started_at: meta.created_at, + status, + status_evidence, + model: if wire.model.is_empty() { + "-".into() + } else { + wire.model.clone() + }, + effort: wire.effort.clone(), + context_percent, + total_input_tokens: wire.total_input, + total_output_tokens: wire.total_output, + total_cache_read: wire.total_cache_read, + total_cache_create: wire.total_cache_create, + turn_count: wire.turn_count, + current_tasks, + mem_mb: proc_info.map_or(0, |p| p.rss_kb / 1024), + version: String::new(), + git_branch: String::new(), + git_added: 0, + git_modified: 0, + token_history: wire.token_history_snapshot(), + context_history: wire.context_history.iter().copied().collect(), + compaction_count: wire.compaction_count, + context_window, + subagents, + mem_file_count: 0, + mem_line_count: 0, + children: collect_children(pid, shared), + initial_prompt: if meta.title.is_empty() { + wire.initial_prompt.clone() + } else { + clean_text(&meta.title, 120) + }, + first_assistant_text: wire.first_assistant_text.clone(), + chat_messages: wire.chat_messages.clone(), + tool_calls: wire.tool_calls.clone(), + pending_since_ms, + awaiting_input, + thinking_since_ms, + file_accesses: wire.file_accesses.clone(), + config_root: abbrev_path(&meta.root), + }) + } +} + +fn kimi_current_tasks( + wire: &WireState, + child_wires: &[(String, WireState)], + activity_boundary_ms: u64, + ambiguous: bool, + status: SessionStatus, + awaiting_input: bool, + active_child_process: bool, +) -> Vec { + if status == SessionStatus::Unknown + && (wire.lifecycle_failure.is_some() + || child_wires + .iter() + .any(|(_, child)| child.lifecycle_failure.is_some())) + { + return vec!["status evidence unavailable".to_string()]; + } + if ambiguous { + return vec!["session ownership is ambiguous".to_string()]; + } + match status { + SessionStatus::Unknown => vec!["status evidence unavailable".to_string()], + SessionStatus::Waiting if awaiting_input => vec!["waiting for user input".to_string()], + SessionStatus::Executing => { + let mut tasks = wire.execution_labels_since(activity_boundary_ms); + tasks.extend( + child_wires + .iter() + .filter(|(_, child)| child.has_live_activity_since(activity_boundary_ms)) + .map(|(id, _)| format!("subagent {}", clean_text(id, 80))), + ); + if active_child_process && tasks.is_empty() { + tasks.push("child process".to_string()); + } + tasks.sort(); + tasks.dedup(); + if tasks.is_empty() { + vec!["executing".to_string()] + } else { + tasks + } + } + SessionStatus::Error => vec!["error".to_string()], + SessionStatus::Thinking => vec!["thinking".to_string()], + SessionStatus::Idle => vec!["idle".to_string()], + _ => Vec::new(), + } +} + +impl Default for KimiCollector { + fn default() -> Self { + Self::new() + } +} + +impl AgentCollector for KimiCollector { + fn collect(&mut self, shared: &super::SharedProcessData) -> Vec { + self.collect_sessions(shared) + } +} + +#[derive(Clone)] +struct KimiProcess { + pid: u32, + cwd: String, + root: PathBuf, + explicit_session: Option, + /// Kimi rewrites argv to this bare title for interactive and host modes. + /// It can support display correlation, but never provider-owned PID actions. + bare_title: bool, + started_at: Option, + incarnation: String, +} + +#[derive(Clone)] +struct KimiAssignment { + dir: PathBuf, + confirmed: bool, + authority: StatusAuthority, + activity_boundary_ms: u64, + process_incarnation: String, +} + +struct KimiRowOwnership { + ambiguous: bool, + authority: StatusAuthority, + action_process_incarnation: Option, +} + +#[derive(Clone)] +struct KimiSession { + id: String, + dir: PathBuf, + root: PathBuf, + cwd: String, + title: String, + created_at: u64, + updated_at: u64, + archived: bool, + agents: Vec, +} + +#[derive(Clone)] +struct KimiAgent { + id: String, + kind: String, +} + +#[derive(Default)] +struct WireCache { + offset: u64, + prefix: Vec, + partial: Vec, + dropping_long_line: bool, + integrity_failure: Option, + state: WireState, +} + +impl WireCache { + fn refresh(&mut self, path: &Path) -> WireAvailability { + let Ok(meta) = fs::metadata(path) else { + return WireAvailability::Failed(StatusReason::Unavailable); + }; + if !meta.is_file() { + return WireAvailability::Failed(StatusReason::ProtocolMalformed); + } + let Ok(mut file) = File::open(path) else { + return WireAvailability::Failed(StatusReason::Unavailable); + }; + let mut prefix = Vec::new(); + if file + .by_ref() + .take(meta.len().min(WIRE_PREFIX_BYTES)) + .read_to_end(&mut prefix) + .is_err() + { + return WireAvailability::Failed(StatusReason::Unavailable); + } + let replaced = !self.prefix.is_empty() + && (prefix.len() < self.prefix.len() || !prefix.starts_with(&self.prefix)); + if meta.len() < self.offset || replaced { + *self = Self::default(); + } + self.prefix = prefix; + if meta.len() == self.offset { + return self.availability(meta.len()); + } + if file.seek(SeekFrom::Start(self.offset)).is_err() { + return WireAvailability::Failed(StatusReason::Unavailable); + } + let mut bytes = Vec::new(); + if file + .take(MAX_WIRE_READ_BYTES) + .read_to_end(&mut bytes) + .is_err() + { + return WireAvailability::Failed(StatusReason::Unavailable); + } + self.offset = self.offset.saturating_add(bytes.len() as u64); + self.consume(&bytes); + self.availability(meta.len()) + } + + fn consume(&mut self, bytes: &[u8]) { + for &byte in bytes { + if self.dropping_long_line { + if byte == b'\n' { + self.dropping_long_line = false; + } + continue; + } + if byte == b'\n' { + match std::str::from_utf8(&self.partial).map(str::trim) { + Ok("") => {} + Ok(line) => match serde_json::from_str::(line) { + Ok(value) => self.state.apply(&value), + Err(_) => self.integrity_failure = Some(StatusReason::ProtocolMalformed), + }, + Err(_) => self.integrity_failure = Some(StatusReason::ProtocolMalformed), + } + self.partial.clear(); + } else if self.partial.len() < MAX_WIRE_LINE_BYTES { + self.partial.push(byte); + } else { + self.partial.clear(); + self.dropping_long_line = true; + self.integrity_failure = Some(StatusReason::ProtocolMalformed); + } + } + } + + fn availability(&self, file_len: u64) -> WireAvailability { + if let Some(reason) = self.integrity_failure { + WireAvailability::Failed(reason) + } else if self.offset < file_len || !self.partial.is_empty() || self.dropping_long_line { + WireAvailability::Failed(StatusReason::Stale) + } else if !self.state.metadata_seen { + WireAvailability::Failed(StatusReason::ProtocolMalformed) + } else { + WireAvailability::Available + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WireAvailability { + Available, + Failed(StatusReason), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum WireProtocol { + /// Reducer-only test input that did not pass through the persisted-wire parser. + #[default] + Synthetic, + V1_4, +} + +impl WireProtocol { + fn is_persisted(self) -> bool { + !matches!(self, Self::Synthetic) + } +} + +#[derive(Clone, Default)] +struct WireState { + protocol: WireProtocol, + metadata_seen: bool, + non_metadata_seen: bool, + model: String, + model_alias: String, + effort: String, + total_input: u64, + total_output: u64, + total_cache_read: u64, + total_cache_create: u64, + turn_count: u32, + active_turn: bool, + active_step: bool, + turn_started_at: u64, + step_started_at: u64, + llm_started_at: u64, + foreground_observed_at: u64, + compaction_active: bool, + compaction_auto: bool, + compaction_started_at: u64, + compaction_observed_at: u64, + compaction_stale_since: u64, + current_turn_id: Option, + current_turn_tokens: u64, + last_context_tokens: u64, + token_history: VecDeque, + context_history: VecDeque, + compaction_count: u32, + pending_tools: HashMap, + pending_interactions: HashMap, + active_tasks: HashMap, + last_error: Option, + fatal_error_since: u64, + foreground_uncertain_since: u64, + foreground_stale_since: u64, + lifecycle_failure: Option, + initial_prompt: String, + first_assistant_text: String, + open_assistant_text: String, + chat_messages: Vec, + tool_calls: Vec, + tool_indices: HashMap, + file_accesses: Vec, + subagents: HashMap, +} + +#[derive(Clone)] +struct PendingTool { + name: String, + arg: String, + started_at: u64, + waits_for_user: bool, +} + +#[derive(Clone, Debug)] +struct ActiveTask { + kind: String, + name: String, + started_at: u64, + detached: bool, +} + +#[derive(Debug)] +struct TaskSnapshot { + id: String, + task: Option, + modified_at: u64, +} + +#[derive(Clone)] +struct PendingInteraction { + requested_at: u64, + reason: StatusReason, +} +impl ActiveTask { + fn label(&self) -> String { + match self.kind.as_str() { + "agent" if !self.name.is_empty() => format!("subagent {}", self.name), + "agent" => "subagent".to_string(), + "process" => "background process".to_string(), + "question" if self.detached => "background question".to_string(), + "question" => "question".to_string(), + _ if !self.name.is_empty() => self.name.clone(), + _ => "background task".to_string(), + } + } +} +impl PendingTool { + fn label(&self) -> String { + if self.arg.is_empty() { + self.name.clone() + } else { + format!("{} {}", self.name, self.arg) + } + } +} + +fn validate_wire_record(value: &Value, protocol: WireProtocol) -> Result<(), StatusReason> { + let Some(record) = value.as_object() else { + return Err(StatusReason::ProtocolMalformed); + }; + let Some(kind) = record.get("type").and_then(Value::as_str) else { + return Err(StatusReason::ProtocolMalformed); + }; + if kind.is_empty() { + return Err(StatusReason::ProtocolMalformed); + } + if protocol == WireProtocol::V1_4 { + if matches!( + kind, + "interaction.request" + | "interaction.resolved" + | "turn.started" + | "turn.ended" + | "turn.step.started" + | "turn.step.completed" + | "turn.step.interrupted" + | "task.started" + | "task.terminated" + | "background.task.started" + | "background.task.terminated" + ) { + return Err(StatusReason::ProtocolUnknown); + } + if kind == "llm.request" { + match record.get("kind").and_then(Value::as_str) { + Some("loop" | "compaction") => {} + Some(_) => return Err(StatusReason::ProtocolUnknown), + None => return Err(StatusReason::ProtocolMalformed), + } + } + if kind == "turn.cancel" + && record.get("turnId").is_some() + && record.get("turnId").and_then(Value::as_u64).is_none() + { + return Err(StatusReason::ProtocolMalformed); + } + if kind == "full_compaction.begin" { + match record.get("source").and_then(Value::as_str) { + Some("manual" | "auto") => {} + Some(_) => return Err(StatusReason::ProtocolUnknown), + None => return Err(StatusReason::ProtocolMalformed), + } + } + let requires_time = matches!( + kind, + "turn.prompt" + | "turn.steer" + | "turn.cancel" + | "full_compaction.begin" + | "full_compaction.cancel" + | "full_compaction.complete" + | "context.apply_compaction" + ) || kind == "llm.request" + || (kind == "context.append_loop_event" + && matches!( + record + .get("event") + .and_then(|event| event.get("type")) + .and_then(Value::as_str), + Some("step.begin" | "step.end" | "content.part" | "tool.call" | "tool.result") + )); + if requires_time + && record + .get("time") + .and_then(Value::as_u64) + .is_none_or(|time| time == 0) + { + return Err(StatusReason::ProtocolMalformed); + } + if kind == "turn.cancel" && record.contains_key("target") { + return Err(StatusReason::ProtocolUnknown); + } + } + match kind { + "metadata" => Err(StatusReason::ProtocolMalformed), + "interaction.request" => { + if record + .get("id") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + || record + .get("time") + .and_then(Value::as_u64) + .is_none_or(|time| time == 0) + { + return Err(StatusReason::ProtocolMalformed); + } + match record.get("kind").and_then(Value::as_str) { + Some("approval" | "question" | "user_tool") => Ok(()), + Some(_) => Err(StatusReason::ProtocolUnknown), + None => Err(StatusReason::ProtocolMalformed), + } + } + "interaction.resolved" => { + if record + .get("id") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + || record + .get("time") + .and_then(Value::as_u64) + .is_none_or(|time| time == 0) + { + Err(StatusReason::ProtocolMalformed) + } else { + Ok(()) + } + } + "turn.ended" => { + if record + .get("time") + .and_then(Value::as_u64) + .is_none_or(|time| time == 0) + { + return Err(StatusReason::ProtocolMalformed); + } + match record.get("reason").and_then(Value::as_str) { + Some("completed" | "cancelled" | "failed" | "blocked") => Ok(()), + Some(_) => Err(StatusReason::ProtocolUnknown), + None => Err(StatusReason::ProtocolMalformed), + } + } + "context.append_loop_event" => { + let event = record.get("event").unwrap_or(&Value::Null); + match event.get("type").and_then(Value::as_str) { + Some("tool.call" | "tool.result") + if event + .get("toolCallId") + .and_then(Value::as_str) + .is_none_or(str::is_empty) => + { + Err(StatusReason::ProtocolMalformed) + } + Some("step.begin" | "step.end") if protocol.is_persisted() => { + if record + .get("time") + .and_then(Value::as_u64) + .is_none_or(|time| time == 0) + || event + .get("uuid") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + || json_id(event.get("turnId").unwrap_or(&Value::Null)) + .is_none_or(|id| id.is_empty()) + { + return Err(StatusReason::ProtocolMalformed); + } + if event.get("type").and_then(Value::as_str) == Some("step.end") { + match event.get("finishReason").and_then(Value::as_str) { + Some( + "tool_use" | "end_turn" | "max_tokens" | "paused" | "filtered", + ) => {} + Some(_) => return Err(StatusReason::ProtocolUnknown), + None => return Err(StatusReason::ProtocolMalformed), + } + } + Ok(()) + } + _ => Ok(()), + } + } + "turn.cancel" => { + if record + .get("time") + .and_then(Value::as_u64) + .is_none_or(|time| time == 0) + { + return Err(StatusReason::ProtocolMalformed); + } + if let Some(target) = record.get("target") { + match target.as_str() { + Some("active" | "queued") => {} + Some(_) => return Err(StatusReason::ProtocolUnknown), + None => return Err(StatusReason::ProtocolMalformed), + } + } + if record.get("turnId").is_some() + && json_id(record.get("turnId").unwrap_or(&Value::Null)) + .is_none_or(|id| id.is_empty()) + { + return Err(StatusReason::ProtocolMalformed); + } + Ok(()) + } + _ => Ok(()), + } +} + +#[derive(Clone, Default)] +struct SubagentState { + agent_id: String, + name: String, + status: String, + tokens: u64, + started_at: u64, +} +impl SubagentState { + fn to_model(&self) -> SubAgent { + SubAgent { + name: self.name.clone(), + status: self.status.clone(), + tokens: self.tokens, + } + } +} + +fn merge_child_subagent( + subagents: &mut HashMap, + agent_id: &str, + child: &WireState, + activity_boundary_ms: u64, +) { + let tokens = + child.total_input + child.total_output + child.total_cache_read + child.total_cache_create; + if let Some(existing) = subagents.values_mut().find(|state| { + state.agent_id == agent_id || (state.agent_id.is_empty() && state.name == agent_id) + }) { + existing.tokens = tokens; + if child.has_pending_input_since(activity_boundary_ms) { + existing.status = "waiting".to_string(); + } else if child.has_live_activity_since(activity_boundary_ms) { + existing.status = "working".to_string(); + } else if task_status_is_active(&existing.status) + && existing.started_at < activity_boundary_ms + { + existing.status = "idle".to_string(); + } + return; + } + subagents.insert( + format!("agent:{agent_id}"), + SubagentState { + agent_id: agent_id.to_string(), + name: agent_id.to_string(), + status: if child.has_pending_input_since(activity_boundary_ms) { + "waiting".to_string() + } else if child.has_live_activity_since(activity_boundary_ms) { + "working".to_string() + } else { + "idle".to_string() + }, + tokens, + started_at: child.activity_since(activity_boundary_ms), + }, + ); +} + +impl WireState { + fn apply(&mut self, value: &Value) { + let Some(kind) = value["type"].as_str() else { + self.lifecycle_failure + .get_or_insert(StatusReason::ProtocolMalformed); + return; + }; + if kind == "metadata" { + self.apply_metadata(value); + return; + } + self.non_metadata_seen = true; + if let Err(reason) = validate_wire_record(value, self.protocol) { + self.lifecycle_failure.get_or_insert(reason); + return; + } + let time = value["time"].as_u64().unwrap_or(0); + match kind { + "llm.request" => { + match value["kind"].as_str() { + Some("loop") => { + self.fatal_error_since = 0; + self.last_error = None; + self.foreground_uncertain_since = 0; + self.foreground_stale_since = 0; + self.llm_started_at = time; + self.foreground_observed_at = time; + } + Some("compaction") => { + if self.protocol.is_persisted() && !self.compaction_active { + self.lifecycle_failure + .get_or_insert(StatusReason::ProtocolMalformed); + return; + } + self.compaction_active = true; + if self.compaction_started_at == 0 { + self.compaction_started_at = time; + } + self.compaction_observed_at = time; + self.compaction_stale_since = 0; + } + _ => {} + } + if let Some(v) = value["model"].as_str() { + self.model = clean_text(v, 120); + } + if let Some(v) = value["modelAlias"].as_str() { + self.model_alias = clean_text(v, 120); + } + if let Some(v) = value["thinkingEffort"].as_str() { + self.effort = clean_text(v, 40); + } + } + "full_compaction.begin" => { + if self.compaction_active { + self.lifecycle_failure + .get_or_insert(StatusReason::ProtocolMalformed); + return; + } + if value["source"].as_str() == Some("manual") { + self.active_turn = false; + self.active_step = false; + self.turn_started_at = 0; + self.step_started_at = 0; + self.llm_started_at = 0; + self.current_turn_id = None; + self.pending_tools.clear(); + self.pending_interactions.clear(); + self.foreground_observed_at = 0; + self.foreground_uncertain_since = 0; + self.foreground_stale_since = 0; + } + self.last_error = None; + self.fatal_error_since = 0; + self.compaction_active = true; + self.compaction_auto = value["source"].as_str() == Some("auto"); + self.compaction_started_at = time; + self.compaction_observed_at = time; + self.compaction_stale_since = 0; + } + "full_compaction.complete" | "full_compaction.cancel" => { + if self.protocol.is_persisted() && !self.compaction_active { + self.lifecycle_failure + .get_or_insert(StatusReason::ProtocolMalformed); + return; + } + let ambiguous_auto_cancel = + kind == "full_compaction.cancel" && self.compaction_auto; + self.compaction_active = false; + self.compaction_auto = false; + self.compaction_started_at = 0; + self.compaction_observed_at = 0; + self.compaction_stale_since = 0; + if ambiguous_auto_cancel { + self.active_turn = false; + self.active_step = false; + self.turn_started_at = 0; + self.step_started_at = 0; + self.llm_started_at = 0; + self.current_turn_id = None; + self.pending_tools.clear(); + self.pending_interactions.clear(); + self.foreground_observed_at = 0; + self.foreground_stale_since = 0; + self.foreground_uncertain_since = time; + } + } + "config.update" | "profile.bind" => { + if let Some(v) = value["modelAlias"].as_str() { + self.model_alias = clean_text(v, 120); + } + if let Some(v) = value["thinkingEffort"] + .as_str() + .or_else(|| value["thinkingLevel"].as_str()) + { + self.effort = clean_text(v, 40); + } + } + "usage.record" => { + let u = &value["usage"]; + let input = u64_field(u, &["inputOther", "input_other"]); + let output = u64_field(u, &["output", "outputTokens"]); + let cache_read = u64_field(u, &["inputCacheRead", "input_cache_read"]); + let cache_create = u64_field(u, &["inputCacheCreation", "input_cache_creation"]); + self.total_input = self.total_input.saturating_add(input); + self.total_output = self.total_output.saturating_add(output); + self.total_cache_read = self.total_cache_read.saturating_add(cache_read); + self.total_cache_create = self.total_cache_create.saturating_add(cache_create); + let total = input + output + cache_read + cache_create; + let scope = value["usageScope"].as_str(); + let belongs_to_turn = scope == Some("turn") + || (scope.is_none() + && (self.active_turn + || self.active_step + || self.current_turn_id.is_some())); + if belongs_to_turn { + self.current_turn_tokens = self.current_turn_tokens.saturating_add(total); + } + if self.model.is_empty() { + if let Some(v) = value["model"].as_str() { + self.model = clean_text(v, 120); + } + } + } + "turn.prompt" => { + self.finish_assistant_message(); + self.finish_turn_tokens(); + self.pending_tools.clear(); + self.pending_interactions.clear(); + self.turn_count = self.turn_count.saturating_add(1); + self.active_turn = true; + self.active_step = false; + self.turn_started_at = time; + self.step_started_at = 0; + self.llm_started_at = 0; + self.current_turn_id = None; + self.last_error = None; + self.fatal_error_since = 0; + self.foreground_uncertain_since = 0; + self.foreground_stale_since = 0; + self.foreground_observed_at = time; + let text = content_text(&value["input"]); + if self.initial_prompt.is_empty() { + self.initial_prompt = clean_text(&text, 120); + } + push_chat( + &mut self.chat_messages, + ChatRole::User, + clean_text(&text, 500), + ); + } + "turn.steer" => { + self.last_error = None; + self.fatal_error_since = 0; + self.foreground_uncertain_since = 0; + self.foreground_stale_since = 0; + if !self.active_turn { + self.active_turn = true; + self.active_step = false; + self.turn_started_at = time; + self.step_started_at = 0; + self.llm_started_at = 0; + } + self.foreground_observed_at = time; + } + "turn.ended" => { + self.finish_assistant_message(); + self.active_turn = false; + self.active_step = false; + self.turn_started_at = 0; + self.step_started_at = 0; + self.llm_started_at = 0; + self.pending_tools.clear(); + self.pending_interactions.clear(); + self.last_error = None; + self.fatal_error_since = 0; + self.foreground_uncertain_since = 0; + self.foreground_stale_since = 0; + self.foreground_observed_at = 0; + if matches!(value["reason"].as_str(), Some("failed" | "blocked")) { + let msg = value["error"]["message"] + .as_str() + .or_else(|| value["error"].as_str()) + .unwrap_or("Kimi turn failed"); + self.last_error = Some(clean_text(msg, 160)); + self.fatal_error_since = time; + } + } + "interaction.request" => { + let id = value["id"].as_str().expect("validated interaction id"); + let reason = match value["kind"].as_str() { + Some("approval") => Some(StatusReason::ProviderWaitingApproval), + Some("question") => Some(StatusReason::ProviderWaitingUserInput), + Some("user_tool") => None, + _ => unreachable!("validated interaction kind"), + }; + if let Some(reason) = reason { + self.pending_interactions.insert( + id.to_string(), + PendingInteraction { + requested_at: time, + reason, + }, + ); + } + } + "interaction.resolved" => { + if let Some(id) = value["id"].as_str() { + self.pending_interactions.remove(id); + } + } + "context.clear" => { + self.last_context_tokens = 0; + push_history(&mut self.context_history, 0); + } + "context.update_token_count" => { + if let Some(tokens) = value["tokenCount"].as_u64() { + self.last_context_tokens = tokens; + push_history(&mut self.context_history, tokens); + } + } + "context.apply_compaction" => { + if self.protocol.is_persisted() && !self.compaction_active { + self.lifecycle_failure + .get_or_insert(StatusReason::ProtocolMalformed); + return; + } + if self.compaction_active { + self.compaction_observed_at = time; + self.compaction_stale_since = 0; + } + self.compaction_count = self.compaction_count.saturating_add(1); + if let Some(tokens) = value["tokensAfter"].as_u64() { + self.last_context_tokens = tokens; + push_history(&mut self.context_history, tokens); + } + } + "turn.started" => { + self.pending_tools.clear(); + self.pending_interactions.clear(); + self.active_turn = true; + self.active_step = false; + self.turn_started_at = time; + self.step_started_at = 0; + self.llm_started_at = 0; + self.last_error = None; + self.fatal_error_since = 0; + self.foreground_uncertain_since = 0; + self.foreground_stale_since = 0; + self.foreground_observed_at = time; + } + "turn.step.started" => { + self.foreground_uncertain_since = 0; + self.foreground_stale_since = 0; + self.foreground_observed_at = time; + if !self.active_turn { + self.active_turn = true; + self.turn_started_at = time; + } + self.active_step = true; + self.step_started_at = time; + } + "turn.step.completed" | "turn.step.interrupted" => { + self.active_step = false; + self.step_started_at = 0; + self.llm_started_at = 0; + } + "context.append_message" => self.apply_message(&value["message"]), + "context.append_loop_event" => self.apply_loop_event(&value["event"], time), + "turn.cancel" => self.apply_turn_cancel(value, time), + "task.started" | "background.task.started" => { + self.apply_task(&value["info"], true, time) + } + "task.terminated" | "background.task.terminated" => { + self.apply_task(&value["info"], false, time) + } + _ => {} + } + } + + fn apply_metadata(&mut self, value: &Value) { + if self.metadata_seen || self.non_metadata_seen { + self.lifecycle_failure + .get_or_insert(StatusReason::ProtocolMalformed); + return; + } + self.metadata_seen = true; + if value["created_at"].as_u64().is_none_or(|time| time == 0) { + self.lifecycle_failure + .get_or_insert(StatusReason::ProtocolMalformed); + return; + } + self.protocol = match value["protocol_version"].as_str() { + Some("1.4") => WireProtocol::V1_4, + Some(_) => { + self.lifecycle_failure + .get_or_insert(StatusReason::ProtocolUnknown); + return; + } + None => { + self.lifecycle_failure + .get_or_insert(StatusReason::ProtocolMalformed); + return; + } + }; + } + + fn apply_turn_cancel(&mut self, value: &Value, time: u64) { + if value["target"].as_str() == Some("queued") { + return; + } + if let Some(cancelled_id) = json_id(&value["turnId"]) { + if self.current_turn_id.as_deref() != Some(cancelled_id.as_str()) { + return; + } + } + if !self.active_turn + && !self.active_step + && self.llm_started_at == 0 + && self.pending_tools.is_empty() + && self.pending_interactions.is_empty() + { + return; + } + self.finish_assistant_message(); + self.active_turn = false; + self.active_step = false; + self.turn_started_at = 0; + self.step_started_at = 0; + self.llm_started_at = 0; + self.current_turn_id = None; + self.pending_tools.clear(); + self.pending_interactions.clear(); + self.foreground_uncertain_since = time; + self.foreground_stale_since = 0; + self.foreground_observed_at = 0; + } + + fn apply_message(&mut self, message: &Value) { + if message["role"].as_str() != Some("assistant") { + return; + } + let text = clean_text(&content_text(&message["content"]), 500); + if self.first_assistant_text.is_empty() { + self.first_assistant_text = text.clone(); + } + push_chat(&mut self.chat_messages, ChatRole::Assistant, text); + } + + fn apply_loop_event(&mut self, event: &Value, time: u64) { + match event["type"].as_str() { + Some("step.begin") => { + self.finish_assistant_message(); + self.pending_tools.clear(); + self.pending_interactions.clear(); + self.begin_loop_turn(event, time); + self.active_step = true; + self.step_started_at = time; + self.llm_started_at = 0; + self.foreground_uncertain_since = 0; + self.foreground_stale_since = 0; + self.foreground_observed_at = time; + } + Some("step.end") => { + self.finish_assistant_message(); + self.active_step = false; + self.step_started_at = 0; + self.llm_started_at = 0; + self.pending_tools.clear(); + self.pending_interactions.clear(); + if let Some(u) = event.get("usage").filter(|usage| usage.is_object()) { + let total = u64_field(u, &["inputOther"]) + + u64_field(u, &["output"]) + + u64_field(u, &["inputCacheRead"]) + + u64_field(u, &["inputCacheCreation"]); + if total > 0 { + self.last_context_tokens = total; + push_history(&mut self.context_history, total); + } + } + let finish_reason = event["finishReason"].as_str(); + self.active_turn = finish_reason == Some("tool_use"); + if self.active_turn { + self.foreground_uncertain_since = 0; + self.foreground_stale_since = 0; + self.foreground_observed_at = time; + } else { + self.turn_started_at = 0; + self.foreground_observed_at = 0; + if self.protocol.is_persisted() { + self.foreground_uncertain_since = time; + self.foreground_stale_since = 0; + } + } + if self.protocol == WireProtocol::V1_4 && finish_reason == Some("filtered") { + self.active_turn = false; + self.foreground_uncertain_since = 0; + self.foreground_stale_since = 0; + self.foreground_observed_at = 0; + self.last_error = Some("Kimi response filtered".to_string()); + self.fatal_error_since = time; + } + } + Some("content.part") => { + self.foreground_uncertain_since = 0; + self.foreground_stale_since = 0; + if !self.active_turn { + self.active_turn = true; + self.turn_started_at = time; + } + self.foreground_observed_at = time; + self.append_assistant_part(&event["part"]); + } + Some("tool.call") => { + self.llm_started_at = 0; + self.foreground_uncertain_since = 0; + self.foreground_stale_since = 0; + if !self.active_turn { + self.active_turn = true; + self.turn_started_at = time; + } + self.foreground_observed_at = time; + let id = event["toolCallId"].as_str().unwrap_or("").to_string(); + if id.is_empty() { + return; + } + let name = clean_text(event["name"].as_str().unwrap_or("tool"), 80); + let arg = safe_tool_arg(&event["args"]); + // `background` lets Kimi continue other work while the question is + // parked; it does not make the user's answer optional. Every live, + // unresolved AskUserQuestion call is therefore an exact input wait. + let waits_for_user = name == "AskUserQuestion"; + self.pending_tools.insert( + id.clone(), + PendingTool { + name: name.clone(), + arg: arg.clone(), + started_at: time, + waits_for_user, + }, + ); + if self.tool_calls.len() < MAX_TOOL_CALLS { + self.tool_indices.insert(id.clone(), self.tool_calls.len()); + self.tool_calls.push(ToolCall { + name: name.clone(), + arg: arg.clone(), + duration_ms: 0, + }); + } + if let Some(op) = file_op(&name) { + if !arg.is_empty() && self.file_accesses.len() < MAX_FILE_ACCESSES { + self.file_accesses.push(FileAccess { + path: arg, + operation: op, + turn_index: self.turn_count, + }); + } + } + } + Some("tool.result") => { + self.foreground_uncertain_since = 0; + self.foreground_stale_since = 0; + if !self.active_turn { + self.active_turn = true; + self.turn_started_at = time; + } + self.foreground_observed_at = time; + if let Some(id) = event["toolCallId"].as_str() { + if let Some(pending) = self.pending_tools.remove(id) { + if let Some(&idx) = self.tool_indices.get(id) { + if let Some(call) = self.tool_calls.get_mut(idx) { + call.duration_ms = time.saturating_sub(pending.started_at); + } + } + } + } + } + _ => {} + } + } + + fn apply_task(&mut self, info: &Value, started: bool, event_time: u64) { + let Some(id) = json_id(&info["taskId"]) else { + return; + }; + let kind = clean_text(info["kind"].as_str().unwrap_or("task"), 40); + let status = clean_text( + info["status"] + .as_str() + .unwrap_or(if started { "running" } else { "completed" }), + 40, + ); + let task_started_at = info["startedAt"].as_u64().unwrap_or(event_time); + let name = clean_text( + info["subagentType"] + .as_str() + .or_else(|| info["agentId"].as_str()) + .or_else(|| info["description"].as_str()) + .unwrap_or(&id), + 80, + ); + // Kimi's BackgroundTaskInfo contract normalizes an omitted legacy value to + // detached=true. A present non-boolean value is not trustworthy lifecycle data. + let detached = match info.get("detached") { + Some(Value::Bool(detached)) => *detached, + None => true, + Some(_) => { + self.lifecycle_failure + .get_or_insert(StatusReason::ProtocolMalformed); + return; + } + }; + + if started && task_status_is_active(&status) { + self.active_tasks.insert( + id.clone(), + ActiveTask { + kind: kind.clone(), + name: name.clone(), + started_at: task_started_at, + detached, + }, + ); + } else { + self.active_tasks.remove(&id); + } + + if kind == "agent" { + self.subagents.insert( + id, + SubagentState { + agent_id: clean_text(info["agentId"].as_str().unwrap_or(""), 120), + name, + status, + tokens: 0, + started_at: task_started_at, + }, + ); + } + } + + fn reconcile_task_snapshots(&mut self, snapshots: Vec) { + let mut newest = HashMap::::new(); + for snapshot in snapshots { + let replace = newest + .get(&snapshot.id) + .is_none_or(|current| snapshot.modified_at >= current.modified_at); + if replace { + newest.insert(snapshot.id.clone(), snapshot); + } + } + for (id, snapshot) in newest { + self.active_tasks.remove(&id); + if let Some(task) = snapshot.task { + self.active_tasks.insert(id.clone(), task); + } + } + } + + fn expire_foreground_lease_at(&mut self, observed_at_ms: u64) { + if self.protocol != WireProtocol::V1_4 { + return; + } + if self.compaction_active + && (self.compaction_observed_at == 0 + || observed_at_ms.saturating_sub(self.compaction_observed_at) + > V1_NON_WAIT_FOREGROUND_LEASE_MS) + { + self.compaction_active = false; + self.compaction_auto = false; + self.compaction_started_at = 0; + self.compaction_observed_at = 0; + self.compaction_stale_since = observed_at_ms.max(1); + } + if self.pending_tools.values().any(|tool| tool.waits_for_user) + || !self.pending_interactions.is_empty() + { + return; + } + let has_open_foreground = self.active_turn + || self.active_step + || self.llm_started_at > 0 + || !self.pending_tools.is_empty(); + if !has_open_foreground + || (self.foreground_observed_at > 0 + && observed_at_ms.saturating_sub(self.foreground_observed_at) + <= V1_NON_WAIT_FOREGROUND_LEASE_MS) + { + return; + } + self.active_turn = false; + self.active_step = false; + self.turn_started_at = 0; + self.step_started_at = 0; + self.llm_started_at = 0; + self.current_turn_id = None; + self.pending_tools.clear(); + self.pending_interactions.clear(); + self.foreground_observed_at = 0; + self.foreground_uncertain_since = 0; + self.foreground_stale_since = observed_at_ms.max(1); + } + + fn begin_loop_turn(&mut self, event: &Value, time: u64) { + self.last_error = None; + self.fatal_error_since = 0; + let Some(turn_id) = json_id(&event["turnId"]) else { + if !self.active_turn { + self.turn_count = self.turn_count.saturating_add(1); + } + self.active_turn = true; + if self.turn_started_at == 0 { + self.turn_started_at = time; + } + return; + }; + + match self.current_turn_id.as_deref() { + Some(current) if current == turn_id => {} + None if self.active_turn => self.current_turn_id = Some(turn_id.to_string()), + _ => { + self.finish_turn_tokens(); + self.turn_count = self.turn_count.saturating_add(1); + self.current_turn_id = Some(turn_id.to_string()); + self.turn_started_at = time; + } + } + self.active_turn = true; + if self.turn_started_at == 0 { + self.turn_started_at = time; + } + } + + fn append_assistant_part(&mut self, part: &Value) { + if part["type"].as_str() != Some("text") { + return; + } + let Some(text) = part["text"].as_str() else { + return; + }; + let text = clean_text(text, 500); + if text.is_empty() { + return; + } + let current_len = self.open_assistant_text.chars().count(); + if current_len >= 500 { + return; + } + if !self.open_assistant_text.is_empty() { + self.open_assistant_text.push(' '); + } + let remaining = 500usize.saturating_sub(self.open_assistant_text.chars().count()); + self.open_assistant_text + .extend(text.chars().take(remaining)); + } + + fn finish_assistant_message(&mut self) { + let text = clean_text(&std::mem::take(&mut self.open_assistant_text), 500); + if text.is_empty() { + return; + } + if self.first_assistant_text.is_empty() { + self.first_assistant_text = text.clone(); + } + push_chat(&mut self.chat_messages, ChatRole::Assistant, text); + } + + fn finish_turn_tokens(&mut self) { + if self.current_turn_tokens == 0 { + return; + } + push_history(&mut self.token_history, self.current_turn_tokens); + self.current_turn_tokens = 0; + } + + fn token_history_snapshot(&self) -> Vec { + let mut history = self.token_history.clone(); + if self.current_turn_tokens > 0 { + push_history(&mut history, self.current_turn_tokens); + } + history.into_iter().collect() + } + + fn has_pending_input_since(&self, activity_boundary_ms: u64) -> bool { + self.pending_input_reason_since(activity_boundary_ms) + .is_some() + } + + fn pending_input_reason_since(&self, activity_boundary_ms: u64) -> Option { + let interaction_reason = self + .pending_interactions + .values() + .filter(|interaction| interaction.requested_at >= activity_boundary_ms) + .map(|interaction| interaction.reason) + .max_by_key(|reason| u8::from(*reason == StatusReason::ProviderWaitingApproval)); + if interaction_reason.is_some() { + return interaction_reason; + } + let tool_wait = self + .pending_tools + .values() + .any(|tool| tool.waits_for_user && tool.started_at >= activity_boundary_ms) + || self + .active_tasks + .values() + .any(|task| task.kind == "question" && task.started_at >= activity_boundary_ms); + tool_wait.then_some(StatusReason::ProviderWaitingUserInput) + } + + fn pending_input_since(&self, activity_boundary_ms: u64) -> u64 { + self.pending_interactions + .values() + .filter_map(|interaction| { + (interaction.requested_at >= activity_boundary_ms) + .then_some(interaction.requested_at) + }) + .chain(self.pending_tools.values().filter_map(|tool| { + (tool.waits_for_user && tool.started_at >= activity_boundary_ms) + .then_some(tool.started_at) + })) + .chain(self.active_tasks.values().filter_map(|task| { + (task.kind == "question" && task.started_at >= activity_boundary_ms) + .then_some(task.started_at) + })) + .min() + .unwrap_or(0) + } + + fn fatal_error_since(&self, activity_boundary_ms: u64) -> u64 { + if self.fatal_error_since > 0 && self.fatal_error_since >= activity_boundary_ms { + self.fatal_error_since + } else { + 0 + } + } + + fn has_executing_work_since(&self, activity_boundary_ms: u64) -> bool { + self.pending_tools + .values() + .any(|tool| tool.started_at >= activity_boundary_ms) + || self + .active_tasks + .values() + .any(|task| task.kind != "question" && task.started_at >= activity_boundary_ms) + } + + fn has_foreground_uncertainty_since(&self, activity_boundary_ms: u64) -> bool { + (self.foreground_uncertain_since >= activity_boundary_ms + && self.foreground_uncertain_since > 0) + || (self.foreground_stale_since >= activity_boundary_ms + && self.foreground_stale_since > 0) + || (self.compaction_stale_since >= activity_boundary_ms + && self.compaction_stale_since > 0) + } + + fn foreground_uncertainty_reason_since( + &self, + activity_boundary_ms: u64, + ) -> Option { + if (self.foreground_stale_since >= activity_boundary_ms && self.foreground_stale_since > 0) + || (self.compaction_stale_since >= activity_boundary_ms + && self.compaction_stale_since > 0) + { + Some(StatusReason::Stale) + } else if self.foreground_uncertain_since >= activity_boundary_ms + && self.foreground_uncertain_since > 0 + { + Some(StatusReason::ProtocolUnknown) + } else { + None + } + } + + fn has_thinking_work_since(&self, activity_boundary_ms: u64) -> bool { + (self.active_turn && self.turn_started_at >= activity_boundary_ms) + || (self.active_step && self.step_started_at >= activity_boundary_ms) + || (self.llm_started_at > 0 && self.llm_started_at >= activity_boundary_ms) + || (self.compaction_active && self.compaction_started_at >= activity_boundary_ms) + } + + fn has_live_activity_since(&self, activity_boundary_ms: u64) -> bool { + self.has_pending_input_since(activity_boundary_ms) + || self.has_executing_work_since(activity_boundary_ms) + || self.has_thinking_work_since(activity_boundary_ms) + } + + fn execution_labels_since(&self, activity_boundary_ms: u64) -> Vec { + let mut labels = self + .pending_tools + .values() + .filter(|tool| tool.started_at >= activity_boundary_ms) + .map(PendingTool::label) + .chain( + self.active_tasks + .values() + .filter(|task| { + task.kind != "question" && task.started_at >= activity_boundary_ms + }) + .map(ActiveTask::label), + ) + .collect::>(); + labels.sort(); + labels.dedup(); + labels + } + + fn pending_since(&self, activity_boundary_ms: u64) -> u64 { + self.pending_tools + .values() + .filter_map(|tool| (tool.started_at >= activity_boundary_ms).then_some(tool.started_at)) + .chain(self.active_tasks.values().filter_map(|task| { + (task.kind != "question" && task.started_at >= activity_boundary_ms) + .then_some(task.started_at) + })) + .chain( + self.pending_interactions + .values() + .filter_map(|interaction| { + (interaction.requested_at >= activity_boundary_ms) + .then_some(interaction.requested_at) + }), + ) + .min() + .unwrap_or(0) + } + + fn thinking_since(&self, activity_boundary_ms: u64) -> u64 { + [ + self.active_turn.then_some(self.turn_started_at), + self.active_step.then_some(self.step_started_at), + (self.llm_started_at > 0).then_some(self.llm_started_at), + self.compaction_active.then_some(self.compaction_started_at), + ] + .into_iter() + .flatten() + .filter(|started_at| *started_at >= activity_boundary_ms) + .min() + .unwrap_or(0) + } + + fn activity_since(&self, activity_boundary_ms: u64) -> u64 { + [ + self.pending_since(activity_boundary_ms), + self.thinking_since(activity_boundary_ms), + ] + .into_iter() + .filter(|started_at| *started_at > 0) + .min() + .unwrap_or(0) + } +} + +fn wire_session_status<'a>( + wire: &WireState, + child_wires: impl IntoIterator, + activity_boundary_ms: u64, + ambiguous: bool, + active_child_process: bool, +) -> (SessionStatus, bool) { + let children = child_wires.into_iter().collect::>(); + if wire.lifecycle_failure.is_some() + || children + .iter() + .any(|child| child.lifecycle_failure.is_some()) + { + return (SessionStatus::Unknown, false); + } + if ambiguous { + return (SessionStatus::Unknown, false); + } + + let awaiting_input = wire.has_pending_input_since(activity_boundary_ms) + || children + .iter() + .any(|child| child.has_pending_input_since(activity_boundary_ms)); + let foreground_uncertain = wire.has_foreground_uncertainty_since(activity_boundary_ms) + || children + .iter() + .any(|child| child.has_foreground_uncertainty_since(activity_boundary_ms)); + let status = if awaiting_input { + SessionStatus::Waiting + } else if wire.fatal_error_since(activity_boundary_ms) > 0 + || children + .iter() + .any(|child| child.fatal_error_since(activity_boundary_ms) > 0) + { + SessionStatus::Error + } else if wire.has_executing_work_since(activity_boundary_ms) + || children + .iter() + .any(|child| child.has_live_activity_since(activity_boundary_ms)) + || active_child_process + { + SessionStatus::Executing + } else if wire.has_thinking_work_since(activity_boundary_ms) { + SessionStatus::Thinking + } else if foreground_uncertain { + SessionStatus::Unknown + } else { + SessionStatus::Idle + }; + (status, awaiting_input) +} + +fn lifecycle_timestamps<'a>( + status: SessionStatus, + wire: &WireState, + child_wires: impl IntoIterator, + activity_boundary_ms: u64, +) -> (u64, u64) { + match status { + SessionStatus::Executing => { + let pending_since_ms = std::iter::once(wire.pending_since(activity_boundary_ms)) + .chain( + child_wires + .into_iter() + .map(|child| child.activity_since(activity_boundary_ms)), + ) + .filter(|started_at| *started_at > 0) + .min() + .unwrap_or(0); + (pending_since_ms, 0) + } + SessionStatus::Thinking => (0, wire.thinking_since(activity_boundary_ms)), + _ => (0, 0), + } +} + +fn kimi_status_evidence<'a>( + status: SessionStatus, + wire: &WireState, + child_wires: impl IntoIterator, + activity_boundary_ms: u64, + active_child_process: bool, + pairing_authority: StatusAuthority, + observed_at_ms: u64, +) -> StatusEvidence { + let children = child_wires.into_iter().collect::>(); + let lifecycle_failure = wire + .lifecycle_failure + .or_else(|| children.iter().find_map(|child| child.lifecycle_failure)); + let (mut authority, mut reason) = if let Some(reason) = lifecycle_failure { + (StatusAuthority::Unavailable, reason) + } else if status == SessionStatus::Unknown { + let reason = std::iter::once(wire) + .chain(children.iter().copied()) + .filter_map(|state| state.foreground_uncertainty_reason_since(activity_boundary_ms)) + .max_by_key(|reason| u8::from(*reason == StatusReason::Stale)) + .unwrap_or(StatusReason::OwnershipUnconfirmed); + (StatusAuthority::Unavailable, reason) + } else if pairing_authority == StatusAuthority::Unavailable { + ( + StatusAuthority::Unavailable, + StatusReason::OwnershipUnconfirmed, + ) + } else { + match status { + SessionStatus::Waiting => { + let reason = wire + .pending_input_reason_since(activity_boundary_ms) + .or_else(|| { + children.iter().find_map(|child| { + child.pending_input_reason_since(activity_boundary_ms) + }) + }) + .unwrap_or(StatusReason::ProviderWaitingUserInput); + (StatusAuthority::Provider, reason) + } + SessionStatus::Executing + if wire.has_executing_work_since(activity_boundary_ms) + || children + .iter() + .any(|child| child.has_live_activity_since(activity_boundary_ms)) => + { + (StatusAuthority::Provider, StatusReason::ProviderExecuting) + } + SessionStatus::Executing if active_child_process => ( + StatusAuthority::Heuristic, + StatusReason::BackgroundTerminalActive, + ), + SessionStatus::Thinking => (StatusAuthority::Provider, StatusReason::ProviderThinking), + SessionStatus::Error => (StatusAuthority::Provider, StatusReason::ProviderError), + SessionStatus::Idle => (StatusAuthority::Provider, StatusReason::ProviderIdle), + _ => (StatusAuthority::Unavailable, StatusReason::Unavailable), + } + }; + + if authority == StatusAuthority::Provider && pairing_authority == StatusAuthority::Heuristic { + authority = StatusAuthority::Heuristic; + reason = StatusReason::CollectorInference; + } + if authority == StatusAuthority::Provider + && matches!( + status, + SessionStatus::Thinking | SessionStatus::Executing | SessionStatus::Idle + ) + && std::iter::once(wire) + .chain(children.iter().copied()) + .any(|state| state.protocol == WireProtocol::V1_4) + { + authority = StatusAuthority::Heuristic; + reason = StatusReason::CollectorInference; + } + + let mut evidence = StatusEvidence::default(); + evidence.observe(StatusObservation::new( + status, + authority, + reason, + observed_at_ms, + 0, + )); + let exact_since = match status { + SessionStatus::Waiting => std::iter::once(wire.pending_input_since(activity_boundary_ms)) + .chain( + children + .iter() + .map(|child| child.pending_input_since(activity_boundary_ms)), + ) + .filter(|since| *since > 0) + .min() + .unwrap_or(0), + SessionStatus::Error => std::iter::once(wire.fatal_error_since(activity_boundary_ms)) + .chain( + children + .iter() + .map(|child| child.fatal_error_since(activity_boundary_ms)), + ) + .filter(|since| *since > 0) + .min() + .unwrap_or(0), + SessionStatus::Executing => std::iter::once(wire.pending_since(activity_boundary_ms)) + .chain( + children + .iter() + .map(|child| child.activity_since(activity_boundary_ms)), + ) + .filter(|since| *since > 0) + .min() + .unwrap_or(0), + SessionStatus::Thinking => wire.thinking_since(activity_boundary_ms), + _ => 0, + }; + if exact_since > 0 { + evidence.status_since_ms = exact_since; + } + evidence +} + +fn task_status_is_active(status: &str) -> bool { + matches!(status.to_ascii_lowercase().as_str(), "running" | "queued") +} + +fn read_task_snapshots( + session_dir: &Path, + task_dirs: &[PathBuf], + activity_boundary_ms: u64, +) -> Result, StatusReason> { + let mut snapshots = Vec::new(); + for task_dir in task_dirs { + let relative = task_dir + .strip_prefix(session_dir) + .map_err(|_| StatusReason::ProtocolMalformed)?; + if relative.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir | std::path::Component::RootDir + ) + }) || has_symlink_component(session_dir, task_dir) + { + return Err(StatusReason::ProtocolMalformed); + } + let entries = match fs::read_dir(task_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err(StatusReason::Unavailable), + }; + let mut json_count = 0usize; + for entry in entries { + let entry = entry.map_err(|_| StatusReason::Unavailable)?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + json_count = json_count.saturating_add(1); + if json_count > MAX_TASK_FILES { + return Err(StatusReason::Stale); + } + let metadata = fs::symlink_metadata(&path).map_err(|_| StatusReason::Unavailable)?; + let modified_at = metadata_modified_ms(&metadata).unwrap_or(0); + let current = modified_at >= activity_boundary_ms; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() > MAX_TASK_BYTES + { + if current { + return Err(StatusReason::ProtocolMalformed); + } + continue; + } + let bytes = fs::read(&path).map_err(|_| StatusReason::Unavailable)?; + let value = match serde_json::from_slice::(&bytes) { + Ok(value) => value, + Err(_) if !current => continue, + Err(_) => return Err(StatusReason::ProtocolMalformed), + }; + match parse_task_snapshot(&path, &value, modified_at, activity_boundary_ms) { + Ok(Some(snapshot)) => snapshots.push(snapshot), + Ok(None) => {} + Err(_) if !current => {} + Err(reason) => return Err(reason), + } + } + } + Ok(snapshots) +} + +fn parse_task_snapshot( + path: &Path, + value: &Value, + modified_at: u64, + activity_boundary_ms: u64, +) -> Result, StatusReason> { + let file_id = path + .file_stem() + .and_then(|name| name.to_str()) + .filter(|id| valid_task_id(id)) + .ok_or(StatusReason::ProtocolMalformed)?; + let id = value["taskId"] + .as_str() + .or_else(|| value["task_id"].as_str()) + .filter(|id| *id == file_id) + .ok_or(StatusReason::ProtocolMalformed)?; + let kind = value["kind"] + .as_str() + .filter(|kind| matches!(*kind, "process" | "agent" | "question")) + .ok_or(StatusReason::ProtocolMalformed)?; + let status = value["status"] + .as_str() + .filter(|status| { + matches!( + *status, + "running" | "completed" | "failed" | "timed_out" | "killed" | "lost" + ) + }) + .ok_or(StatusReason::ProtocolMalformed)?; + let started_at = u64_field(value, &["startedAt", "started_at"]); + if started_at == 0 { + return Err(StatusReason::ProtocolMalformed); + } + let ended_at = u64_field(value, &["endedAt", "ended_at"]); + if (status == "running" && ended_at != 0) || (status != "running" && ended_at == 0) { + return Err(StatusReason::ProtocolMalformed); + } + if started_at < activity_boundary_ms { + return Ok(None); + } + // Current snapshots carry `detached`; Kimi explicitly defines omitted legacy + // values as detached, while any present non-boolean value is malformed. + let detached = match value.get("detached") { + Some(Value::Bool(detached)) => *detached, + None => true, + Some(_) => return Err(StatusReason::ProtocolMalformed), + }; + let task = (status == "running").then(|| ActiveTask { + kind: kind.to_string(), + name: clean_text( + value["subagentType"] + .as_str() + .or_else(|| value["subagent_type"].as_str()) + .or_else(|| value["agentId"].as_str()) + .or_else(|| value["agent_id"].as_str()) + .or_else(|| value["description"].as_str()) + .unwrap_or(id), + 80, + ), + started_at, + detached, + }); + Ok(Some(TaskSnapshot { + id: id.to_string(), + task, + modified_at, + })) +} + +fn valid_task_id(id: &str) -> bool { + let mut parts = id.split('-').peekable(); + let mut prefix_parts = 0usize; + while let Some(part) = parts.next() { + if parts.peek().is_none() { + return prefix_parts > 0 + && part.len() == 8 + && part + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); + } + if part.is_empty() + || !part + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + { + return false; + } + prefix_parts += 1; + } + false +} + +fn select_session<'a>( + process: &KimiProcess, + candidates: &[&'a KimiSession], + assignment: Option<&KimiAssignment>, + claimed: &HashSet, + follow_fresher_activity: bool, +) -> Option<&'a KimiSession> { + if let Some(explicit) = process.explicit_session.as_deref() { + if let Some(session) = candidates + .iter() + .copied() + .filter(|session| !claimed.contains(&session.dir)) + .find(|session| session.id == explicit) + { + return Some(session); + } + } + + let freshest = candidates + .iter() + .copied() + .filter(|session| !claimed.contains(&session.dir)) + .max_by_key(|session| session.updated_at); + let assigned = assignment.and_then(|assignment| { + candidates + .iter() + .copied() + .filter(|session| !claimed.contains(&session.dir)) + .find(|session| session.dir == assignment.dir) + }); + + match (assigned, freshest) { + (Some(current), Some(newest)) + if follow_fresher_activity + && newest.updated_at > current.updated_at + && assignment.is_some_and(|assignment| { + newest.updated_at > assignment.activity_boundary_ms + }) => + { + Some(newest) + } + (Some(current), _) => Some(current), + (None, newest) => newest, + } +} + +#[cfg(test)] +fn pairing_is_confirmed( + process: &KimiProcess, + session: &KimiSession, + previous: Option<&KimiAssignment>, +) -> bool { + pairing_authority(process, session, previous) != StatusAuthority::Unavailable +} + +fn pairing_authority( + process: &KimiProcess, + session: &KimiSession, + previous: Option<&KimiAssignment>, +) -> StatusAuthority { + if process.explicit_session.as_deref() == Some(session.id.as_str()) { + return if process.bare_title { + StatusAuthority::Heuristic + } else { + StatusAuthority::Provider + }; + } + if let Some(assignment) = previous.filter(|assignment| { + assignment.confirmed + && assignment.dir == session.dir + && assignment.process_incarnation == process.incarnation + }) { + return if process.bare_title && assignment.authority == StatusAuthority::Provider { + StatusAuthority::Heuristic + } else { + assignment.authority + }; + } + if process + .started_at + .is_some_and(|started_at| session.updated_at >= started_at) + || previous.is_some_and(|assignment| { + assignment.dir == session.dir + && assignment.process_incarnation == process.incarnation + && session.updated_at > assignment.activity_boundary_ms + }) + { + StatusAuthority::Heuristic + } else { + StatusAuthority::Unavailable + } +} + +fn session_matches_process(session: &KimiSession, process: &KimiProcess) -> bool { + !session.archived + && session.root == process.root + && normalize_path(&session.cwd) == normalize_path(&process.cwd) +} + +fn process_root(pid: u32, cwd: &str) -> Option { + let configured = process::read_process_env_var(pid, "KIMI_CODE_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("KIMI_CODE_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + }); + configured + .or_else(|| dirs::home_dir().map(|home| home.join(".kimi-code"))) + .map(|root| absolute_root(root, Path::new(cwd))) +} + +fn absolute_root(root: PathBuf, base: &Path) -> PathBuf { + if root.is_absolute() { + root + } else { + base.join(root) + } +} + +fn default_roots() -> Vec { + let configured = std::env::var_os("KIMI_CODE_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + let root = configured.or_else(|| dirs::home_dir().map(|home| home.join(".kimi-code"))); + let Some(root) = root else { return Vec::new() }; + let base = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); + vec![absolute_root(root, &base)] +} + +pub(crate) fn is_kimi_process(command: &str) -> bool { + let tokens = process::command_tokens(command); + is_kimi_process_tokens(&tokens) +} + +pub(crate) fn is_kimi_process_tokens(tokens: &[String]) -> bool { + let direct = tokens.first().is_some_and(|token| { + process::token_has_binary(token, "kimi") || process::token_has_binary(token, "kimi-code") + }); + let node_entrypoint = tokens.len() >= 2 + && tokens.first().is_some_and(|token| { + process::token_has_binary(token, "node") || process::token_has_binary(token, "nodejs") + }) + && is_kimi_node_entrypoint(&tokens[1]); + let recognized = direct || node_entrypoint; + recognized + && !kimi_subcommand(tokens, if node_entrypoint { 2 } else { 1 }) + .is_some_and(is_kimi_non_session_command) +} + +fn kimi_process_observation_is_exact( + expected_incarnation: &str, + current_incarnation: Option<&str>, + tokens: &[String], +) -> bool { + current_incarnation == Some(expected_incarnation) && is_kimi_process_tokens(tokens) +} + +fn is_bare_kimi_tokens(tokens: &[String]) -> bool { + tokens.len() == 1 + && tokens.first().is_some_and(|token| { + process::token_has_binary(token, "kimi") + || process::token_has_binary(token, "kimi-code") + }) +} + +fn is_kimi_node_entrypoint(value: &str) -> bool { + let normalized = value.replace('\\', "/").to_ascii_lowercase(); + normalized == "@moonshot-ai/kimi-code/dist/main.mjs" + || normalized == "@moonshot-ai/kimi-code/dist/main.js" + || normalized.ends_with("/@moonshot-ai/kimi-code/dist/main.mjs") + || normalized.ends_with("/@moonshot-ai/kimi-code/dist/main.js") +} + +fn kimi_subcommand(tokens: &[String], mut index: usize) -> Option<&str> { + while let Some(token) = tokens.get(index) { + if token == "--" { + return None; + } + if is_kimi_prompt_option(token) { + // `ps` may flatten a multi-word prompt into later whitespace + // tokens. Prompt mode owns the rest of the invocation, so those + // words must never be reinterpreted as `web`, `acp`, or a helper. + return None; + } + if option_has_inline_value(token) || is_kimi_boolean_option(token) { + index += 1; + continue; + } + if is_kimi_value_option(token) { + index += 2; + continue; + } + if token.starts_with('-') { + // Unknown options are rejected by Kimi. Treat them as flags here + // so arbitrary following prompt text cannot become a host mode. + index += 1; + continue; + } + return Some(token); + } + None +} + +fn is_kimi_prompt_option(value: &str) -> bool { + matches!(value, "-p" | "--prompt") || value.starts_with("-p=") || value.starts_with("--prompt=") +} + +fn is_kimi_non_session_command(value: &str) -> bool { + matches!( + value.to_ascii_lowercase().as_str(), + "export" + | "provider" + | "acp" + | "web" + | "server" + | "login" + | "doctor" + | "vis" + | "migrate" + | "upgrade" + | "update" + | "__plugin_run_node" + ) +} + +fn option_has_inline_value(value: &str) -> bool { + value.starts_with("--") && value.contains('=') +} + +fn is_kimi_value_option(value: &str) -> bool { + matches!( + value, + "-S" | "--session" + | "-r" + | "--resume" + | "-m" + | "--model" + | "-p" + | "--prompt" + | "--output-format" + | "--skills-dir" + | "--agent" + | "--agent-file" + | "--add-dir" + ) +} + +fn is_kimi_boolean_option(value: &str) -> bool { + matches!( + value, + "-c" | "--continue" + | "-C" + | "-y" + | "--yolo" + | "--auto" + | "--yes" + | "--auto-approve" + | "--plan" + | "-h" + | "--help" + | "-V" + | "--version" + ) +} + +#[cfg(test)] +fn explicit_session_id(command: &str) -> Option { + let parts = process::command_tokens(command); + explicit_session_id_from_tokens(&parts) +} + +fn explicit_session_id_from_tokens(parts: &[String]) -> Option { + for (i, part) in parts.iter().enumerate() { + if let Some(id) = part + .strip_prefix("--session=") + .or_else(|| part.strip_prefix("--resume=")) + .or_else(|| part.strip_prefix("-r=")) + .or_else(|| part.strip_prefix("-S=")) + { + if !id.is_empty() { + return Some(id.to_string()); + } + } + if matches!(part.as_str(), "--session" | "--resume" | "-r" | "-S") { + if let Some(id) = parts.get(i + 1) { + if !id.starts_with('-') { + return Some(id.to_string()); + } + } + } + } + None +} + +fn read_session_index(root: &Path) -> Vec { + let index_path = root.join("session_index.jsonl"); + let sessions_root = root.join("sessions"); + let mut entries = HashMap::::new(); + let mut tombstoned = HashSet::new(); + if !is_symlink(&index_path) { + if let Ok(meta) = fs::metadata(&index_path) { + if meta.len() <= MAX_INDEX_BYTES { + if let Ok(text) = fs::read_to_string(&index_path) { + for line in text.lines() { + if line.len() > MAX_WIRE_LINE_BYTES { + continue; + } + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + let Some(id) = value["sessionId"] + .as_str() + .filter(|s| !s.is_empty() && s.len() <= 256) + else { + continue; + }; + if value["deleted"].as_bool() == Some(true) { + entries.remove(id); + tombstoned.insert(id.to_string()); + continue; + } + let Some(dir) = value["sessionDir"].as_str().map(PathBuf::from) else { + continue; + }; + let cwd = value["workDir"].as_str().unwrap_or("").to_string(); + if valid_session_dir(&sessions_root, &dir, id) { + tombstoned.remove(id); + entries.insert(id.to_string(), (dir, cwd)); + } + } + } + } + } + } + + // The index is an optimization rather than the only source of truth. Kimi + // itself falls back to directory enumeration when it is absent or stale. + // Preserve explicit deletion tombstones while adding valid unindexed dirs. + if !is_symlink(&sessions_root) { + if let Ok(workdirs) = fs::read_dir(&sessions_root) { + for workdir in workdirs.flatten().take(MAX_SESSIONS * 2) { + if is_symlink(&workdir.path()) { + continue; + } + let Ok(session_dirs) = fs::read_dir(workdir.path()) else { + continue; + }; + for entry in session_dirs.flatten().take(MAX_SESSIONS * 2) { + let dir = entry.path(); + if is_symlink(&dir) || !dir.is_dir() { + continue; + } + let Some(id) = dir.file_name().and_then(|v| v.to_str()) else { + continue; + }; + if id.is_empty() + || id.len() > 256 + || tombstoned.contains(id) + || entries.contains_key(id) + || !valid_session_dir(&sessions_root, &dir, id) + { + continue; + } + entries.insert(id.to_string(), (dir, String::new())); + } + } + } + } + + entries + .into_iter() + .filter_map(|(id, (dir, cwd))| read_session_state(root, id, dir, cwd)) + .collect() +} + +fn valid_session_dir(sessions_root: &Path, dir: &Path, id: &str) -> bool { + let safe_relative = dir.strip_prefix(sessions_root).is_ok_and(|relative| { + !relative.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir | std::path::Component::RootDir + ) + }) + }); + dir.is_absolute() + && dir.starts_with(sessions_root) + && safe_relative + && dir.file_name().is_some_and(|v| v == id) + && !has_symlink_component(sessions_root, dir) +} + +fn read_session_state( + root: &Path, + id: String, + dir: PathBuf, + indexed_cwd: String, +) -> Option { + let dir_meta = fs::metadata(&dir).ok()?; + let (_state_path, state_meta, value) = read_state_document(&dir)?; + let state_id = value["id"].as_str().unwrap_or(&id); + if state_id != id { + return None; + } + let cwd = value["cwd"] + .as_str() + .or_else(|| value["workDir"].as_str()) + .or_else(|| value["custom"]["cwd"].as_str()) + .unwrap_or(&indexed_cwd); + if cwd.is_empty() { + return None; + } + let parsed_created_at = parse_time(&value["createdAt"]); + let created_at = if parsed_created_at > 0 { + parsed_created_at + } else { + metadata_created_ms(&dir_meta) + .or_else(|| metadata_modified_ms(&dir_meta)) + .unwrap_or(0) + }; + let mut updated_at = parse_time(&value["updatedAt"]) + .max(created_at) + .max(metadata_modified_ms(&dir_meta).unwrap_or(0)) + .max(metadata_modified_ms(&state_meta).unwrap_or(0)); + let agents: Vec = value["agents"] + .as_object() + .into_iter() + .flat_map(|m| m.iter()) + .map(|(id, v)| KimiAgent { + id: id.clone(), + kind: v["type"].as_str().unwrap_or("").to_string(), + }) + .collect(); + for wire_path in [dir.join("wire.jsonl")].into_iter().chain( + std::iter::once("main") + .chain(agents.iter().map(|agent| agent.id.as_str())) + .map(|agent_id| dir.join("agents").join(agent_id).join("wire.jsonl")), + ) { + if !is_symlink(&wire_path) { + if let Some(modified) = fs::metadata(wire_path) + .ok() + .as_ref() + .and_then(metadata_modified_ms) + { + updated_at = updated_at.max(modified); + } + } + } + Some(KimiSession { + id, + dir, + root: root.to_path_buf(), + cwd: cwd.to_string(), + title: value["isCustomTitle"] + .as_bool() + .and_then(|_| value["title"].as_str()) + .or_else(|| value["customTitle"].as_str()) + .or_else(|| value["title"].as_str()) + .or_else(|| value["lastPrompt"].as_str()) + .unwrap_or("") + .to_string(), + created_at, + updated_at, + archived: value["archived"].as_bool().unwrap_or(false), + agents, + }) +} + +fn read_state_document(dir: &Path) -> Option<(PathBuf, fs::Metadata, Value)> { + [dir.join("state.json"), dir.join("session-meta/state.json")] + .into_iter() + .find_map(|path| { + if has_symlink_component(dir, &path) { + return None; + } + let meta = fs::metadata(&path).ok()?; + if !meta.is_file() || meta.len() > MAX_STATE_BYTES { + return None; + } + let value = serde_json::from_slice(&fs::read(&path).ok()?).ok()?; + Some((path, meta, value)) + }) +} + +fn metadata_created_ms(meta: &fs::Metadata) -> Option { + system_time_ms(meta.created().ok()?) +} + +fn metadata_modified_ms(meta: &fs::Metadata) -> Option { + system_time_ms(meta.modified().ok()?) +} + +fn system_time_ms(time: std::time::SystemTime) -> Option { + time.duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis() as u64) +} + +fn current_time_ms() -> u64 { + system_time_ms(std::time::SystemTime::now()).unwrap_or(0) +} + +fn parse_time(value: &Value) -> u64 { + value + .as_u64() + .or_else(|| { + value + .as_str() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .and_then(|d| u64::try_from(d.timestamp_millis()).ok()) + }) + .unwrap_or(0) +} + +fn model_context_limit(root: &Path, alias: &str, model: &str) -> u64 { + let path = root.join("config.toml"); + if is_symlink(&path) { + return 0; + } + let Ok(text) = fs::read_to_string(path) else { + return 0; + }; + if text.len() > MAX_STATE_BYTES as usize { + return 0; + } + let Ok(value) = toml::from_str::(&text) else { + return 0; + }; + let models = value.get("models").and_then(toml::Value::as_table); + let entry = models + .and_then(|m| m.get(alias)) + .or_else(|| models.and_then(|m| m.get(model))) + .or_else(|| unique_model_entry(models, model)); + let Some(entry) = entry else { return 0 }; + let overrides = entry.get("overrides"); + let max_input = overrides + .and_then(|v| v.get("max_input_size")) + .and_then(toml::Value::as_integer) + .or_else(|| { + entry + .get("max_input_size") + .and_then(toml::Value::as_integer) + }); + let max_context = overrides + .and_then(|v| v.get("max_context_size")) + .and_then(toml::Value::as_integer) + .or_else(|| { + entry + .get("max_context_size") + .and_then(toml::Value::as_integer) + }); + let max_input = max_input.and_then(|v| u64::try_from(v).ok()); + let max_context = max_context.and_then(|v| u64::try_from(v).ok()); + match (max_input, max_context) { + (Some(input), Some(context)) => input.min(context), + (Some(input), None) => input, + (None, Some(context)) => context, + (None, None) => 0, + } +} + +fn unique_model_entry<'a>( + models: Option<&'a toml::map::Map>, + model: &str, +) -> Option<&'a toml::Value> { + if model.is_empty() { + return None; + } + let mut matches = models?.values().filter(|entry| { + entry + .get("model") + .and_then(toml::Value::as_str) + .is_some_and(|configured| configured == model) + }); + let first = matches.next()?; + matches.next().is_none().then_some(first) +} + +fn collect_children(pid: u32, shared: &super::SharedProcessData) -> Vec { + let mut out = Vec::new(); + let mut stack = shared.children_map.get(&pid).cloned().unwrap_or_default(); + let mut visited = HashSet::new(); + while let Some(child_pid) = stack.pop() { + if !visited.insert(child_pid) { + continue; + } + if let Some(info) = shared.process_info.get(&child_pid) { + out.push(ChildProcess { + pid: child_pid, + command: info.command.clone(), + mem_kb: info.rss_kb, + port: shared + .ports + .get(&child_pid) + .and_then(|p| p.first().copied()), + }); + } + if let Some(children) = shared.children_map.get(&child_pid) { + stack.extend(children); + } + } + out +} + +fn safe_tool_arg(args: &Value) -> String { + for key in ["file_path", "path", "cwd", "directory"] { + if let Some(value) = args.get(key).and_then(Value::as_str) { + return clean_text(value, 120); + } + } + String::new() +} + +fn file_op(name: &str) -> Option { + let lower = name.to_ascii_lowercase(); + if lower.contains("read") { + Some(FileOp::Read) + } else if lower.contains("edit") { + Some(FileOp::Edit) + } else if lower.contains("write") || lower.contains("create") { + Some(FileOp::Write) + } else { + None + } +} + +fn content_text(value: &Value) -> String { + if let Some(text) = value.as_str() { + return text.to_string(); + } + value + .as_array() + .into_iter() + .flat_map(|a| a.iter()) + .filter_map(|part| { + if part["type"].as_str().is_none_or(|t| t == "text") { + part["text"].as_str() + } else { + None + } + }) + .collect::>() + .join(" ") +} + +fn clean_text(text: &str, max: usize) -> String { + let safe = sanitize_terminal_text(text); + let redacted = redact_secrets(&safe); + redacted + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(max) + .collect() +} + +fn push_chat(messages: &mut Vec, role: ChatRole, text: String) { + if text.is_empty() { + return; + } + messages.push(ChatMessage { role, text }); + if messages.len() > MAX_CHAT_MESSAGES { + messages.remove(0); + } +} + +fn u64_field(value: &Value, keys: &[&str]) -> u64 { + keys.iter() + .find_map(|key| value[*key].as_u64()) + .unwrap_or(0) +} + +fn json_id(value: &Value) -> Option { + value + .as_str() + .map(ToString::to_string) + .or_else(|| value.as_u64().map(|id| id.to_string())) +} + +fn push_history(history: &mut VecDeque, value: u64) { + if history.len() == MAX_HISTORY_POINTS { + history.pop_front(); + } + history.push_back(value); +} + +fn normalize_path(path: &str) -> String { + Path::new(path) + .components() + .collect::() + .to_string_lossy() + .into_owned() +} + +fn is_symlink(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) +} + +fn has_symlink_component(root: &Path, path: &Path) -> bool { + let Ok(relative) = path.strip_prefix(root) else { + return true; + }; + let mut current = root.to_path_buf(); + if is_symlink(¤t) { + return true; + } + for component in relative.components() { + current.push(component); + if is_symlink(¤t) { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn status_at(wire: &WireState, activity_boundary_ms: u64) -> (SessionStatus, bool) { + wire_session_status(wire, std::iter::empty(), activity_boundary_ms, false, false) + } + + fn evidence_at( + wire: &WireState, + activity_boundary_ms: u64, + pairing_authority: StatusAuthority, + active_child_process: bool, + ) -> (SessionStatus, bool, StatusEvidence) { + let ambiguous = pairing_authority == StatusAuthority::Unavailable; + let (status, awaiting_input) = wire_session_status( + wire, + std::iter::empty(), + activity_boundary_ms, + ambiguous, + active_child_process, + ); + let evidence = kimi_status_evidence( + status, + wire, + std::iter::empty(), + activity_boundary_ms, + active_child_process, + pairing_authority, + 50, + ); + (status, awaiting_input, evidence) + } + + #[test] + fn recognizes_current_processes_and_excludes_hosts() { + assert!(is_kimi_process("kimi-code")); + assert!(is_kimi_process("kimi -r abc")); + assert!(is_kimi_process( + "node /x/@moonshot-ai/kimi-code/dist/main.mjs" + )); + assert!(!is_kimi_process("kimi __plugin_run_node")); + assert!(!is_kimi_process("kimi acp")); + assert!(!is_kimi_process("kimi web")); + assert!(!is_kimi_process("bash -lc kimi")); + assert!(!is_kimi_process( + "cat /x/@moonshot-ai/kimi-code/dist/main.mjs" + )); + assert!(!is_kimi_process("node /tmp/kimi-code/dist/main.mjs")); + assert!(!is_kimi_process( + "node /x/@moonshot-ai/kimi-code/dist/main.mjs web" + )); + assert!(!is_kimi_process( + r#"node.exe "C:\Program Files\node_modules\@moonshot-ai\kimi-code\dist\main.mjs" acp"# + )); + assert!(!is_kimi_process( + "node /x/@moonshot-ai/kimi-code/dist/main.mjs __plugin_run_node" + )); + assert!(!is_kimi_process( + "node server.js --banner=/x/@moonshot-ai/kimi-code/dist/main.mjs" + )); + assert!(is_kimi_process("kimi -p web")); + assert!(is_kimi_process("kimi -p build web UI")); + assert!(is_kimi_process("kimi --prompt \"build web UI\"")); + assert!(is_kimi_process("kimi --prompt=build web UI")); + assert!(is_kimi_process("kimi -S web")); + assert!(is_kimi_process("kimi --model acp")); + assert!(is_kimi_process("kimi -p __plugin_run_node")); + assert!(is_kimi_process( + "node /x/@moonshot-ai/kimi-code/dist/main.mjs -p build acp client" + )); + assert!(!is_kimi_process("kimi vis session-123")); + assert!(!is_kimi_process("kimi server")); + assert!(!is_kimi_process("kimi login")); + assert!(!is_kimi_process("not-kimi")); + } + + #[test] + fn parses_explicit_session_flags() { + assert_eq!(explicit_session_id("kimi -r abc"), Some("abc".into())); + assert_eq!( + explicit_session_id("kimi --session=xyz"), + Some("xyz".into()) + ); + assert_eq!(explicit_session_id("kimi -S old"), Some("old".into())); + assert_eq!( + explicit_session_id("kimi --resume=restored"), + Some("restored".into()) + ); + assert_eq!( + explicit_session_id("kimi --session \"session with spaces\""), + Some("session with spaces".into()) + ); + assert_eq!(explicit_session_id("kimi -r --model k2"), None); + } + + #[test] + fn directory_fallback_respects_index_tombstones() { + let root = tempfile::tempdir().unwrap(); + let session_dir = root + .path() + .join("sessions") + .join("wd_project_abc") + .join("session_test"); + fs::create_dir_all(&session_dir).unwrap(); + fs::write( + session_dir.join("state.json"), + r#"{"id":"session_test","cwd":"/tmp/project","createdAt":1,"updatedAt":2,"agents":{}}"#, + ) + .unwrap(); + + assert_eq!(read_session_index(root.path()).len(), 1); + fs::write( + root.path().join("session_index.jsonl"), + r#"{"sessionId":"session_test","deleted":true} +"#, + ) + .unwrap(); + assert!(read_session_index(root.path()).is_empty()); + } + + #[test] + fn selection_is_root_scoped_keeps_old_resumes_and_follows_fresher_activity() { + let root_a = PathBuf::from("/tmp/kimi-a"); + let root_b = PathBuf::from("/tmp/kimi-b"); + let make_session = |id: &str, root: &Path, updated_at| KimiSession { + id: id.to_string(), + dir: root.join("sessions/bucket").join(id), + root: root.to_path_buf(), + cwd: "/tmp/project".to_string(), + title: String::new(), + created_at: 1, + updated_at, + archived: false, + agents: Vec::new(), + }; + let old = make_session("old", &root_a, 10); + let fresh = make_session("fresh", &root_a, 1_001); + let other_profile = make_session("other", &root_b, 1_002); + let process = KimiProcess { + pid: 1, + cwd: "/tmp/project".to_string(), + root: root_a, + explicit_session: None, + bare_title: true, + started_at: Some(1_000), + incarnation: "process-1".to_string(), + }; + assert!(session_matches_process(&old, &process)); + assert!(!session_matches_process(&other_profile, &process)); + + let candidates = vec![&old, &fresh]; + let assignment = KimiAssignment { + dir: old.dir.clone(), + confirmed: false, + authority: StatusAuthority::Unavailable, + activity_boundary_ms: 1_000, + process_incarnation: "process-1".to_string(), + }; + let selected = select_session( + &process, + &candidates, + Some(&assignment), + &HashSet::new(), + true, + ) + .unwrap(); + assert_eq!(selected.id, "fresh"); + assert!(pairing_is_confirmed(&process, selected, Some(&assignment))); + assert_eq!( + pairing_authority(&process, selected, Some(&assignment)), + StatusAuthority::Heuristic + ); + let shared = select_session( + &process, + &candidates, + Some(&assignment), + &HashSet::new(), + false, + ) + .unwrap(); + assert_eq!(shared.id, "old"); + let resumed = select_session(&process, &[&old], None, &HashSet::new(), true).unwrap(); + assert_eq!(resumed.id, "old"); + assert!(!pairing_is_confirmed(&process, &old, None)); + assert_eq!( + pairing_authority(&process, &old, None), + StatusAuthority::Unavailable + ); + + let process_without_start = KimiProcess { + started_at: None, + ..process + }; + let observed_assignment = KimiAssignment { + dir: old.dir.clone(), + confirmed: false, + authority: StatusAuthority::Unavailable, + activity_boundary_ms: 10, + process_incarnation: "process-1".to_string(), + }; + assert!(!pairing_is_confirmed( + &process_without_start, + &old, + Some(&observed_assignment) + )); + let post_observation = make_session("old", &PathBuf::from("/tmp/kimi-a"), 11); + assert!(pairing_is_confirmed( + &process_without_start, + &post_observation, + Some(&observed_assignment) + )); + assert_eq!( + pairing_authority( + &process_without_start, + &post_observation, + Some(&observed_assignment) + ), + StatusAuthority::Heuristic + ); + } + + #[test] + fn explicit_session_mapping_has_provider_authority() { + let root = PathBuf::from("/tmp/kimi-explicit"); + let session = KimiSession { + id: "session-exact".to_string(), + dir: root.join("sessions/bucket/session-exact"), + root: root.clone(), + cwd: "/tmp/project".to_string(), + title: String::new(), + created_at: 1, + updated_at: 2, + archived: false, + agents: Vec::new(), + }; + let process = KimiProcess { + pid: 1, + cwd: session.cwd.clone(), + root, + explicit_session: Some(session.id.clone()), + bare_title: false, + started_at: None, + incarnation: "process-exact".to_string(), + }; + + assert_eq!( + pairing_authority(&process, &session, None), + StatusAuthority::Provider + ); + } + + #[test] + fn exact_explicit_session_mapping_rejects_pid_reuse() { + let tokens = vec![ + "/usr/local/bin/kimi-code".to_string(), + "--session".to_string(), + "session-a".to_string(), + ]; + + assert_eq!( + explicit_session_id_from_tokens(&tokens).as_deref(), + Some("session-a") + ); + assert!(kimi_process_observation_is_exact( + "process-a", + Some("process-a"), + &tokens + )); + assert!(!kimi_process_observation_is_exact( + "process-a", + Some("process-b"), + &tokens + )); + } + + #[test] + fn reused_pid_cannot_inherit_a_confirmed_assignment_from_an_old_incarnation() { + let root = PathBuf::from("/tmp/kimi-reused-pid"); + let session = KimiSession { + id: "session-old".to_string(), + dir: root.join("sessions/bucket/session-old"), + root: root.clone(), + cwd: "/tmp/project".to_string(), + title: String::new(), + created_at: 1, + updated_at: 100, + archived: false, + agents: Vec::new(), + }; + let process = KimiProcess { + pid: 42, + cwd: session.cwd.clone(), + root, + explicit_session: None, + bare_title: true, + started_at: Some(1_000), + incarnation: "new-process".to_string(), + }; + let old_assignment = KimiAssignment { + dir: session.dir.clone(), + confirmed: true, + authority: StatusAuthority::Provider, + activity_boundary_ms: 10, + process_incarnation: "old-process".to_string(), + }; + + assert_eq!( + pairing_authority(&process, &session, Some(&old_assignment)), + StatusAuthority::Unavailable + ); + } + + #[test] + fn unique_process_follows_in_process_session_switch_activity() { + let root = PathBuf::from("/tmp/kimi-switch"); + let make_session = |id: &str, updated_at| KimiSession { + id: id.to_string(), + dir: root.join("sessions/bucket").join(id), + root: root.clone(), + cwd: "/tmp/project".to_string(), + title: String::new(), + created_at: 1, + updated_at, + archived: false, + agents: Vec::new(), + }; + let session_a = make_session("session-a", 1_100); + let session_b_before_switch = make_session("session-b", 1_050); + let process = KimiProcess { + pid: 42, + cwd: "/tmp/project".to_string(), + root: root.clone(), + explicit_session: None, + bare_title: true, + started_at: Some(1_000), + incarnation: "process-42".to_string(), + }; + let assignment = KimiAssignment { + dir: session_a.dir.clone(), + confirmed: true, + authority: StatusAuthority::Heuristic, + activity_boundary_ms: 1_000, + process_incarnation: "process-42".to_string(), + }; + + let selected = select_session( + &process, + &[&session_a, &session_b_before_switch], + Some(&assignment), + &HashSet::new(), + true, + ) + .unwrap(); + assert_eq!(selected.id, "session-a"); + + let session_b_after_switch = make_session("session-b", 1_200); + let selected = select_session( + &process, + &[&session_a, &session_b_after_switch], + Some(&assignment), + &HashSet::new(), + true, + ) + .unwrap(); + assert_eq!(selected.id, "session-b"); + + let ambiguous = select_session( + &process, + &[&session_a, &session_b_after_switch], + Some(&assignment), + &HashSet::new(), + false, + ) + .unwrap(); + assert_eq!(ambiguous.id, "session-a"); + } + + #[test] + fn reads_legacy_v2_state_and_v1_metadata_fallbacks() { + let root = tempfile::tempdir().unwrap(); + let session_dir = root.path().join("sessions/bucket/session_legacy"); + fs::create_dir_all(session_dir.join("session-meta")).unwrap(); + fs::write( + session_dir.join("session-meta/state.json"), + r#"{ + "id":"session_legacy", + "custom":{"cwd":"/tmp/legacy-project"}, + "customTitle":"Legacy title", + "agents":{} + }"#, + ) + .unwrap(); + + let session = read_session_state( + root.path(), + "session_legacy".to_string(), + session_dir, + String::new(), + ) + .unwrap(); + assert_eq!(session.cwd, "/tmp/legacy-project"); + assert_eq!(session.title, "Legacy title"); + assert!(session.created_at > 0); + assert!(session.updated_at >= session.created_at); + } + + #[test] + fn usage_is_not_double_counted_from_step_end() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({"type":"context.append_loop_event","event":{"type":"step.end","usage":{"inputOther":10,"output":5,"inputCacheRead":3,"inputCacheCreation":2}}})); + state.apply(&serde_json::json!({"type":"usage.record","model":"k2","usage":{"inputOther":10,"output":5,"inputCacheRead":3,"inputCacheCreation":2}})); + assert_eq!( + ( + state.total_input, + state.total_output, + state.total_cache_read, + state.total_cache_create + ), + (10, 5, 3, 2) + ); + assert_eq!(state.last_context_tokens, 20); + state.apply(&serde_json::json!({"type":"context.update_token_count","tokenCount":42})); + assert_eq!(state.last_context_tokens, 42); + } + + #[test] + fn v1_step_end_finishes_status_and_reconstructs_assistant_text() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({"type":"turn.prompt","input":[{"type":"text","text":"hello"}],"time":10})); + state.apply(&serde_json::json!({"type":"context.append_loop_event","event":{"type":"step.begin","uuid":"s1","turnId":"t1"},"time":11})); + state.apply(&serde_json::json!({"type":"context.append_loop_event","event":{"type":"content.part","stepUuid":"s1","part":{"type":"text","text":"Hello"}},"time":12})); + state.apply(&serde_json::json!({"type":"context.append_loop_event","event":{"type":"content.part","stepUuid":"s1","part":{"type":"text","text":"world"}},"time":13})); + state.apply(&serde_json::json!({"type":"context.append_loop_event","event":{"type":"tool.call","toolCallId":"stale","name":"ReadFile","args":{"path":"/tmp/a"}},"time":14})); + state.apply(&serde_json::json!({"type":"interaction.request","id":"approval","kind":"approval","time":15})); + state.apply(&serde_json::json!({"type":"context.append_loop_event","event":{"type":"step.end","uuid":"s1","turnId":"t1","finishReason":"end_turn","usage":{"inputOther":10,"output":5,"inputCacheRead":0,"inputCacheCreation":0}},"time":16})); + state.apply(&serde_json::json!({"type":"usage.record","usageScope":"turn","usage":{"inputOther":10,"output":5,"inputCacheRead":0,"inputCacheCreation":0},"time":17})); + + assert!(!state.active_turn && !state.active_step); + assert!(state.pending_tools.is_empty()); + assert!(state.pending_interactions.is_empty()); + assert_eq!(state.first_assistant_text, "Hello world"); + assert_eq!(state.chat_messages.last().unwrap().text, "Hello world"); + assert_eq!(state.turn_count, 1); + assert_eq!(state.token_history_snapshot(), vec![15]); + } + + #[test] + fn token_history_aggregates_multi_step_turns() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":1})); + state.apply(&serde_json::json!({"type":"context.append_loop_event","event":{"type":"step.begin","turnId":"t1"},"time":2})); + state.apply(&serde_json::json!({"type":"context.append_loop_event","event":{"type":"step.end","turnId":"t1","finishReason":"tool_use"},"time":3})); + state.apply(&serde_json::json!({"type":"usage.record","usageScope":"turn","usage":{"inputOther":3},"time":4})); + state.apply(&serde_json::json!({"type":"context.append_loop_event","event":{"type":"step.begin","turnId":"t1"},"time":5})); + state.apply(&serde_json::json!({"type":"context.append_loop_event","event":{"type":"step.end","turnId":"t1","finishReason":"end_turn"},"time":6})); + state.apply(&serde_json::json!({"type":"usage.record","usageScope":"turn","usage":{"output":4},"time":7})); + assert_eq!(state.token_history_snapshot(), vec![7]); + assert_eq!(state.turn_count, 1); + + state.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":8})); + assert_eq!( + state.token_history.iter().copied().collect::>(), + vec![7] + ); + } + + #[test] + fn context_zero_usage_preserves_last_measurement_and_histories_keep_newest() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({"type":"context.update_token_count","tokenCount":42})); + state.apply(&serde_json::json!({"type":"context.append_loop_event","event":{"type":"step.end","usage":{"inputOther":0,"output":0,"inputCacheRead":0,"inputCacheCreation":0}}})); + state.apply(&serde_json::json!({"type":"context.apply_compaction"})); + assert_eq!(state.last_context_tokens, 42); + + let mut history = VecDeque::new(); + for value in 0..=MAX_HISTORY_POINTS as u64 { + push_history(&mut history, value); + } + assert_eq!(history.len(), MAX_HISTORY_POINTS); + assert_eq!(history.front(), Some(&1)); + assert_eq!(history.back(), Some(&(MAX_HISTORY_POINTS as u64))); + } + + #[test] + fn interactions_before_process_start_are_not_live() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({"type":"interaction.request","id":"old","kind":"question","time":100})); + assert!(state.has_pending_input_since(100)); + assert!(!state.has_pending_input_since(101)); + } + + #[test] + fn first_observation_boundary_rejects_historical_waits_and_tools() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({"type":"interaction.request","id":"old","kind":"question","time":100})); + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":101, + "event":{ + "type":"tool.call", + "toolCallId":"old-question", + "name":"AskUserQuestion", + "args":{} + } + })); + + let (status, awaiting_input) = status_at(&state, 102); + assert_eq!(status, SessionStatus::Idle); + assert!(!awaiting_input); + + state.apply(&serde_json::json!({ + "type":"interaction.request", + "id":"new", + "kind":"question", + "time":103 + })); + let (status, awaiting_input) = status_at(&state, 102); + assert_eq!(status, SessionStatus::Waiting); + assert!(awaiting_input); + } + + #[test] + fn foreground_ask_user_question_waits_for_input() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":10})); + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":20, + "event":{ + "type":"tool.call", + "toolCallId":"question", + "name":"AskUserQuestion", + "args":{"questions":[{"question":"Continue?"}]} + } + })); + + let (status, awaiting_input) = status_at(&state, 10); + assert_eq!(status, SessionStatus::Waiting); + assert!(awaiting_input); + assert!(state.pending_tools["question"].waits_for_user); + } + + #[test] + fn background_ask_user_question_still_waits_for_user_input() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":10})); + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":20, + "event":{ + "type":"tool.call", + "toolCallId":"question", + "name":"AskUserQuestion", + "args":{"background":true,"questions":[]} + } + })); + + let (status, awaiting_input, evidence) = + evidence_at(&state, 10, StatusAuthority::Provider, false); + assert_eq!(status, SessionStatus::Waiting); + assert!(awaiting_input); + assert!(state.pending_tools["question"].waits_for_user); + assert_eq!(evidence.authority, StatusAuthority::Provider); + assert_eq!(evidence.reason, StatusReason::ProviderWaitingUserInput); + + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":30, + "event":{"type":"tool.result","toolCallId":"question"} + })); + assert_eq!(status_at(&state, 10), (SessionStatus::Thinking, false)); + } + + #[test] + fn ask_user_question_result_clears_input_wait() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":10})); + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":20, + "event":{ + "type":"tool.call", + "toolCallId":"question", + "name":"AskUserQuestion", + "args":{} + } + })); + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":30, + "event":{"type":"tool.result","toolCallId":"question"} + })); + + let (status, awaiting_input) = status_at(&state, 10); + assert_eq!(status, SessionStatus::Thinking); + assert!(!awaiting_input); + assert!(state.pending_tools.is_empty()); + } + + #[test] + fn ask_user_question_before_process_start_is_not_a_live_wait() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":100, + "event":{ + "type":"tool.call", + "toolCallId":"old-question", + "name":"AskUserQuestion", + "args":{} + } + })); + + let (status, awaiting_input) = status_at(&state, 101); + assert_eq!(status, SessionStatus::Idle); + assert!(!awaiting_input); + } + + #[test] + fn ordinary_pending_tool_does_not_wait_for_input() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":20, + "event":{ + "type":"tool.call", + "toolCallId":"read", + "name":"ReadFile", + "args":{"path":"/tmp/a"} + } + })); + + let (status, awaiting_input) = status_at(&state, 10); + assert_eq!(status, SessionStatus::Executing); + assert!(!awaiting_input); + } + + #[test] + fn v2_interaction_wait_behavior_is_preserved() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":10})); + state.apply(&serde_json::json!({ + "type":"interaction.request", + "id":"approval", + "kind":"approval", + "time":20 + })); + let (status, awaiting_input) = status_at(&state, 10); + assert_eq!(status, SessionStatus::Waiting); + assert!(awaiting_input); + + state.apply(&serde_json::json!({ + "type":"interaction.resolved", + "id":"approval", + "time":30 + })); + let (status, awaiting_input) = status_at(&state, 10); + assert_eq!(status, SessionStatus::Thinking); + assert!(!awaiting_input); + } + + #[test] + fn quiet_live_session_is_idle_and_unknown_ownership_wins() { + let state = WireState::default(); + let (status, awaiting_input, evidence) = + evidence_at(&state, 10, StatusAuthority::Provider, false); + assert_eq!(status, SessionStatus::Idle); + assert!(!awaiting_input); + assert_eq!(evidence.authority, StatusAuthority::Provider); + assert_eq!(evidence.reason, StatusReason::ProviderIdle); + assert_eq!(evidence.observations[0].status, SessionStatus::Idle); + + let mut waiting = WireState::default(); + waiting.apply(&serde_json::json!({ + "type":"interaction.request", + "id":"approval", + "kind":"approval", + "time":20 + })); + let (status, awaiting_input) = + wire_session_status(&waiting, std::iter::empty(), 10, true, true); + assert_eq!(status, SessionStatus::Unknown); + assert!(!awaiting_input); + let evidence = kimi_status_evidence( + status, + &waiting, + std::iter::empty(), + 10, + true, + StatusAuthority::Unavailable, + 50, + ); + assert_eq!(evidence.authority, StatusAuthority::Unavailable); + assert_eq!(evidence.reason, StatusReason::OwnershipUnconfirmed); + } + + #[test] + fn exact_wait_evidence_distinguishes_approval_and_question() { + let mut approval = WireState::default(); + approval.apply(&serde_json::json!({ + "type":"interaction.request", + "id":"approval", + "kind":"approval", + "time":20 + })); + let (status, awaiting_input, evidence) = + evidence_at(&approval, 10, StatusAuthority::Provider, false); + assert_eq!(status, SessionStatus::Waiting); + assert!(awaiting_input); + assert_eq!(evidence.authority, StatusAuthority::Provider); + assert_eq!(evidence.reason, StatusReason::ProviderWaitingApproval); + + let mut question = WireState::default(); + question.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":20, + "event":{ + "type":"tool.call", + "toolCallId":"question", + "name":"AskUserQuestion", + "args":{} + } + })); + let (status, awaiting_input, evidence) = + evidence_at(&question, 10, StatusAuthority::Provider, false); + assert_eq!(status, SessionStatus::Waiting); + assert!(awaiting_input); + assert_eq!(evidence.reason, StatusReason::ProviderWaitingUserInput); + } + + #[test] + fn only_actionable_native_interaction_kinds_wait() { + let mut user_tool = WireState::default(); + user_tool.apply(&serde_json::json!({ + "type":"interaction.request", + "id":"user-tool", + "kind":"user_tool", + "time":20 + })); + assert_eq!(status_at(&user_tool, 10), (SessionStatus::Idle, false)); + assert!(user_tool.lifecycle_failure.is_none()); + + let mut unknown = WireState::default(); + unknown.apply(&serde_json::json!({ + "type":"interaction.request", + "id":"future", + "kind":"future_kind", + "time":20 + })); + assert_eq!( + unknown.lifecycle_failure, + Some(StatusReason::ProtocolUnknown) + ); + assert_eq!(status_at(&unknown, 10), (SessionStatus::Unknown, false)); + + let mut malformed = WireState::default(); + malformed.apply(&serde_json::json!({ + "type":"interaction.request", + "id":"missing-kind", + "time":20 + })); + assert_eq!( + malformed.lifecycle_failure, + Some(StatusReason::ProtocolMalformed) + ); + assert_eq!(status_at(&malformed, 10), (SessionStatus::Unknown, false)); + } + + #[test] + fn background_question_task_and_interaction_wait_for_user() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":20, + "event":{ + "type":"tool.call", + "toolCallId":"question-call", + "name":"AskUserQuestion", + "args":{"background":true} + } + })); + state.apply(&serde_json::json!({ + "type":"task.started", + "time":21, + "info":{ + "kind":"question", + "taskId":"question-task", + "toolCallId":"question-call", + "status":"running", + "detached":true, + "startedAt":21 + } + })); + state.apply(&serde_json::json!({ + "type":"interaction.request", + "id":"background-question", + "kind":"question", + "toolCallId":"question-call", + "time":22 + })); + + assert!(state + .pending_interactions + .contains_key("background-question")); + let (status, awaiting_input, evidence) = + evidence_at(&state, 10, StatusAuthority::Provider, false); + assert_eq!((status, awaiting_input), (SessionStatus::Waiting, true)); + assert_eq!(evidence.authority, StatusAuthority::Provider); + assert_eq!(evidence.reason, StatusReason::ProviderWaitingUserInput); + } + + #[test] + fn exact_wait_precedes_provider_error() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({ + "type":"turn.ended", + "reason":"failed", + "error":{"message":"sensitive failure"}, + "time":20 + })); + state.apply(&serde_json::json!({ + "type":"interaction.request", + "id":"approval", + "kind":"approval", + "time":21 + })); + + assert_eq!(status_at(&state, 10), (SessionStatus::Waiting, true)); + } + + #[test] + fn best_effort_pairing_downgrades_wire_evidence_to_heuristic() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":20})); + let (status, awaiting_input, evidence) = + evidence_at(&state, 10, StatusAuthority::Heuristic, false); + assert_eq!(status, SessionStatus::Thinking); + assert!(!awaiting_input); + assert_eq!(evidence.authority, StatusAuthority::Heuristic); + assert_eq!(evidence.reason, StatusReason::CollectorInference); + } + + #[test] + fn lifecycle_timestamps_are_owned_only_by_active_statuses() { + let mut waiting = WireState::default(); + waiting.apply(&serde_json::json!({ + "type":"interaction.request", + "id":"approval", + "kind":"approval", + "time":20 + })); + assert_eq!( + lifecycle_timestamps(SessionStatus::Waiting, &waiting, std::iter::empty(), 10), + (0, 0) + ); + assert_eq!( + lifecycle_timestamps(SessionStatus::Unknown, &waiting, std::iter::empty(), 10), + (0, 0) + ); + assert_eq!( + lifecycle_timestamps( + SessionStatus::Idle, + &WireState::default(), + std::iter::empty(), + 10 + ), + (0, 0) + ); + + let mut executing = WireState::default(); + executing.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":20, + "event":{ + "type":"tool.call", + "toolCallId":"read", + "name":"ReadFile", + "args":{} + } + })); + assert_eq!( + lifecycle_timestamps(SessionStatus::Executing, &executing, std::iter::empty(), 10), + (20, 0) + ); + + let mut thinking = WireState::default(); + thinking.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":20})); + assert_eq!( + lifecycle_timestamps(SessionStatus::Thinking, &thinking, std::iter::empty(), 10), + (0, 20) + ); + } + + #[test] + fn background_question_wait_takes_precedence_over_tools_tasks_and_child_processes() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":20, + "event":{ + "type":"tool.call", + "toolCallId":"read", + "name":"ReadFile", + "args":{} + } + })); + state.apply(&serde_json::json!({ + "type":"task.started", + "time":21, + "info":{ + "kind":"process", + "taskId":"background-process", + "status":"running", + "detached":true, + "startedAt":21 + } + })); + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":22, + "event":{ + "type":"tool.call", + "toolCallId":"question", + "name":"AskUserQuestion", + "args":{"background":true} + } + })); + + let (status, awaiting_input) = + wire_session_status(&state, std::iter::empty(), 10, false, true); + assert_eq!(status, SessionStatus::Waiting); + assert!(awaiting_input); + let evidence = kimi_status_evidence( + status, + &state, + std::iter::empty(), + 10, + true, + StatusAuthority::Provider, + 30, + ); + assert_eq!(evidence.authority, StatusAuthority::Provider); + assert_eq!(evidence.reason, StatusReason::ProviderWaitingUserInput); + } + + #[test] + fn active_detached_tasks_classify_question_as_wait_and_terminal_updates_to_idle() { + for kind in ["process", "agent", "question"] { + let mut state = WireState::default(); + state.apply(&serde_json::json!({ + "type":"task.started", + "time":20, + "info":{ + "kind":kind, + "taskId":"task-1", + "status":"running", + "detached":true, + "startedAt":20, + "description":"background work", + "agentId":"agent-1" + } + })); + let (status, awaiting_input) = status_at(&state, 10); + let expected = if kind == "question" { + (SessionStatus::Waiting, true) + } else { + (SessionStatus::Executing, false) + }; + assert_eq!((status, awaiting_input), expected, "kind={kind}"); + + state.apply(&serde_json::json!({ + "type":"task.terminated", + "time":30, + "info":{ + "kind":kind, + "taskId":"task-1", + "status":"completed", + "detached":true, + "startedAt":20, + "endedAt":30, + "agentId":"agent-1" + } + })); + let (status, awaiting_input) = status_at(&state, 10); + assert_eq!(status, SessionStatus::Idle, "kind={kind}"); + assert!(!awaiting_input, "kind={kind}"); + } + } + + #[test] + fn stale_turn_tools_and_tasks_do_not_create_live_activity() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":10})); + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":11, + "event":{ + "type":"tool.call", + "toolCallId":"old", + "name":"ReadFile", + "args":{} + } + })); + state.apply(&serde_json::json!({ + "type":"task.started", + "time":12, + "info":{ + "kind":"process", + "taskId":"old-task", + "status":"running", + "startedAt":12 + } + })); + + let (status, awaiting_input) = status_at(&state, 20); + assert_eq!(status, SessionStatus::Idle); + assert!(!awaiting_input); + assert!(state.execution_labels_since(20).is_empty()); + assert_eq!(state.pending_since(20), 0); + let evidence = kimi_status_evidence( + status, + &state, + std::iter::empty(), + 20, + false, + StatusAuthority::Provider, + 50, + ); + assert_eq!(evidence.authority, StatusAuthority::Provider); + assert_eq!(evidence.reason, StatusReason::ProviderIdle); + } + + #[test] + fn child_wait_and_work_are_aggregated_before_parent_classification() { + let parent = WireState::default(); + let mut child = WireState::default(); + child.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":20})); + + let (status, awaiting_input) = + wire_session_status(&parent, std::iter::once(&child), 10, false, false); + assert_eq!(status, SessionStatus::Executing); + assert!(!awaiting_input); + + child.apply(&serde_json::json!({ + "type":"interaction.request", + "id":"child-question", + "kind":"question", + "time":21 + })); + let (status, awaiting_input) = + wire_session_status(&parent, std::iter::once(&child), 10, false, false); + assert_eq!(status, SessionStatus::Waiting); + assert!(awaiting_input); + let evidence = kimi_status_evidence( + status, + &parent, + std::iter::once(&child), + 10, + false, + StatusAuthority::Provider, + 50, + ); + assert_eq!(evidence.authority, StatusAuthority::Provider); + assert_eq!(evidence.reason, StatusReason::ProviderWaitingUserInput); + } + + #[test] + fn unavailable_child_wire_fails_parent_status_closed() { + let parent = WireState::default(); + let child = WireState { + lifecycle_failure: Some(StatusReason::ProtocolMalformed), + ..WireState::default() + }; + let (status, awaiting_input) = + wire_session_status(&parent, std::iter::once(&child), 10, false, false); + assert_eq!(status, SessionStatus::Unknown); + assert!(!awaiting_input); + let evidence = kimi_status_evidence( + status, + &parent, + std::iter::once(&child), + 10, + false, + StatusAuthority::Provider, + 50, + ); + assert_eq!(evidence.authority, StatusAuthority::Unavailable); + assert_eq!(evidence.reason, StatusReason::ProtocolMalformed); + } + + #[test] + fn stale_child_wait_tool_and_task_do_not_leak_across_boundary() { + let parent = WireState::default(); + let mut child = WireState::default(); + child.apply(&serde_json::json!({ + "type":"interaction.request", + "id":"old-wait", + "kind":"question", + "time":5 + })); + child.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":6, + "event":{ + "type":"tool.call", + "toolCallId":"old-tool", + "name":"ReadFile", + "args":{} + } + })); + child.apply(&serde_json::json!({ + "type":"task.started", + "time":7, + "info":{ + "kind":"agent", + "taskId":"old-task", + "status":"running", + "startedAt":7 + } + })); + + let (status, awaiting_input) = + wire_session_status(&parent, std::iter::once(&child), 10, false, false); + assert_eq!(status, SessionStatus::Idle); + assert!(!awaiting_input); + let evidence = kimi_status_evidence( + status, + &parent, + std::iter::once(&child), + 10, + false, + StatusAuthority::Provider, + 50, + ); + assert_eq!(evidence.authority, StatusAuthority::Provider); + assert_eq!(evidence.reason, StatusReason::ProviderIdle); + } + + #[test] + fn open_llm_request_thinks_and_failed_turn_becomes_provider_error() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({ + "type":"llm.request", + "kind":"loop", + "model":"k2", + "time":20 + })); + let (status, awaiting_input) = status_at(&state, 10); + assert_eq!(status, SessionStatus::Thinking); + assert!(!awaiting_input); + + state.apply(&serde_json::json!({ + "type":"turn.ended", + "reason":"failed", + "error":{"message":"model failed"}, + "time":30 + })); + let (status, awaiting_input) = status_at(&state, 10); + assert_eq!(status, SessionStatus::Error); + assert!(!awaiting_input); + assert_eq!(state.last_error.as_deref(), Some("model failed")); + let evidence = kimi_status_evidence( + status, + &state, + std::iter::empty(), + 10, + false, + StatusAuthority::Provider, + 40, + ); + assert_eq!(evidence.authority, StatusAuthority::Provider); + assert_eq!(evidence.reason, StatusReason::ProviderError); + assert_eq!(evidence.status_since_ms, 30); + assert_eq!( + kimi_current_tasks(&state, &[], 10, false, status, false, false), + vec!["error".to_string()] + ); + } + + #[test] + fn active_os_child_process_is_executing() { + let state = WireState::default(); + let (status, awaiting_input, evidence) = + evidence_at(&state, 10, StatusAuthority::Provider, true); + assert_eq!(status, SessionStatus::Executing); + assert!(!awaiting_input); + assert_eq!(evidence.authority, StatusAuthority::Heuristic); + assert_eq!(evidence.reason, StatusReason::BackgroundTerminalActive); + } + + #[test] + fn child_wire_tokens_join_task_lifecycle_by_agent_id() { + let mut parent = WireState::default(); + parent.apply(&serde_json::json!({ + "type":"task.started", + "info":{ + "kind":"agent", + "taskId":"task-1", + "agentId":"agent-1", + "subagentType":"coder", + "status":"running" + } + })); + let child = WireState { + total_input: 10, + total_output: 2, + ..WireState::default() + }; + merge_child_subagent(&mut parent.subagents, "agent-1", &child, 0); + assert_eq!(parent.subagents.len(), 1); + let subagent = parent.subagents.values().next().unwrap(); + assert_eq!(subagent.name, "coder"); + assert_eq!(subagent.tokens, 12); + } + + #[test] + fn status_state_tracks_tools_interactions_and_turns() { + let mut state = WireState::default(); + state.apply(&serde_json::json!({"type":"turn.prompt","input":[{"type":"text","text":"hello"}],"time":10})); + assert!(state.active_turn); + state.apply(&serde_json::json!({"type":"context.append_loop_event","event":{"type":"tool.call","toolCallId":"t","name":"ReadFile","args":{"path":"/tmp/a"}},"time":20})); + assert!(state.pending_tools.contains_key("t")); + state.apply( + &serde_json::json!({"type":"interaction.request","id":"i","kind":"approval","time":21}), + ); + assert!(state.pending_interactions.contains_key("i")); + state.apply(&serde_json::json!({"type":"interaction.resolved","id":"i","time":22})); + state.apply(&serde_json::json!({"type":"context.append_loop_event","event":{"type":"tool.result","toolCallId":"t"},"time":30})); + state.apply( + &serde_json::json!({"type":"turn.ended","turnId":0,"reason":"completed","time":31}), + ); + assert!( + !state.active_turn + && state.pending_tools.is_empty() + && state.pending_interactions.is_empty() + ); + assert_eq!(state.tool_calls[0].duration_ms, 10); + } + + #[test] + fn incremental_parser_buffers_partial_lines_and_resets_on_shrink() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("wire.jsonl"); + let mut file = File::create(&path).unwrap(); + write!( + file, + "{{\"type\":\"metadata\",\"protocol_version\":\"1.4\",\"created_at\":1" + ) + .unwrap(); + file.flush().unwrap(); + let mut cache = WireCache::default(); + assert_eq!( + cache.refresh(&path), + WireAvailability::Failed(StatusReason::Stale) + ); + assert_eq!(cache.state.turn_count, 0); + writeln!(file, "}}").unwrap(); + file.flush().unwrap(); + assert_eq!(cache.refresh(&path), WireAvailability::Available); + assert_eq!(cache.state.turn_count, 0); + writeln!(file, "{{\"type\":\"turn.prompt\",\"input\":[],\"time\":2}}").unwrap(); + file.flush().unwrap(); + assert_eq!(cache.refresh(&path), WireAvailability::Available); + assert_eq!(cache.state.turn_count, 1); + fs::write( + &path, + concat!( + "{\"type\":\"metadata\",\"protocol_version\":\"1.4\",", + "\"created_at\":1}\n", + "{\"type\":\"usage.record\",\"usage\":{\"inputOther\":2},", + "\"time\":3}\n" + ), + ) + .unwrap(); + assert_eq!(cache.refresh(&path), WireAvailability::Available); + assert_eq!(cache.state.turn_count, 0); + assert_eq!(cache.state.total_input, 2); + } + + #[test] + fn malformed_wire_record_fails_closed_until_file_rewrite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("wire.jsonl"); + fs::write( + &path, + concat!( + "{\"type\":\"metadata\",\"protocol_version\":\"1.4\",", + "\"created_at\":1}\n", + "{\"type\":\"interaction.request\",\"id\":\"approval\",", + "\"kind\":\"approval\",\"time\":20}\n", + "not-json\n", + "{\"type\":\"interaction.resolved\",\"id\":\"approval\",", + "\"time\":21}\n" + ), + ) + .unwrap(); + let mut cache = WireCache::default(); + assert_eq!( + cache.refresh(&path), + WireAvailability::Failed(StatusReason::ProtocolMalformed) + ); + assert!(cache.state.pending_interactions.is_empty()); + + fs::write( + &path, + concat!( + "{\"type\":\"metadata\",\"protocol_version\":\"1.4\",", + "\"created_at\":1}\n", + "{\"type\":\"usage.record\",\"usage\":{\"inputOther\":2},", + "\"time\":30}\n" + ), + ) + .unwrap(); + assert_eq!(cache.refresh(&path), WireAvailability::Available); + assert_eq!(cache.state.total_input, 2); + } + + #[test] + fn oversized_wire_record_is_protocol_malformed() { + let mut cache = WireCache::default(); + cache.consume(&vec![b'x'; MAX_WIRE_LINE_BYTES + 1]); + cache.consume(b"\n"); + assert_eq!( + cache.availability(cache.offset), + WireAvailability::Failed(StatusReason::ProtocolMalformed) + ); + } + + #[test] + fn missing_wire_source_is_unknown_instead_of_dropping_the_session() { + let dir = tempfile::tempdir().unwrap(); + let mut collector = KimiCollector::new(); + let state = collector.parse_wire(&dir.path().join("missing-wire.jsonl")); + assert_eq!(state.lifecycle_failure, Some(StatusReason::Unavailable)); + let (status, awaiting_input, evidence) = + evidence_at(&state, 10, StatusAuthority::Provider, false); + assert_eq!(status, SessionStatus::Unknown); + assert!(!awaiting_input); + assert_eq!(evidence.authority, StatusAuthority::Unavailable); + assert_eq!(evidence.reason, StatusReason::Unavailable); + } + + fn persisted_state(version: &str) -> WireState { + let mut state = WireState::default(); + state.apply(&serde_json::json!({ + "type":"metadata", + "protocol_version":version, + "created_at":1 + })); + state + } + + #[test] + fn persisted_wire_requires_exact_first_metadata() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("wire.jsonl"); + fs::write( + &path, + "{\"type\":\"turn.prompt\",\"input\":[],\"time\":2}\n", + ) + .unwrap(); + let mut cache = WireCache::default(); + assert_eq!( + cache.refresh(&path), + WireAvailability::Failed(StatusReason::ProtocolMalformed) + ); + + let mut unsupported = WireState::default(); + unsupported.apply(&serde_json::json!({ + "type":"metadata", + "protocol_version":"1.5", + "created_at":1 + })); + assert_eq!( + unsupported.lifecycle_failure, + Some(StatusReason::ProtocolUnknown) + ); + + let mut duplicate = persisted_state("1.4"); + duplicate.apply(&serde_json::json!({ + "type":"metadata", + "protocol_version":"1.4", + "created_at":1 + })); + assert_eq!( + duplicate.lifecycle_failure, + Some(StatusReason::ProtocolMalformed) + ); + } + + #[test] + fn v1_terminal_step_and_cancel_fail_closed_instead_of_staying_busy() { + let mut ended = persisted_state("1.4"); + ended.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":10})); + ended.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":11, + "event":{"type":"step.begin","uuid":"step-1","turnId":"1"} + })); + ended.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":12, + "event":{"type":"step.end","uuid":"step-1","turnId":"1","finishReason":"end_turn"} + })); + assert_eq!(status_at(&ended, 1), (SessionStatus::Unknown, false)); + assert!(!ended.active_turn && !ended.active_step && ended.pending_tools.is_empty()); + + let mut cancelled = persisted_state("1.4"); + cancelled.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":20})); + cancelled.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":21, + "event":{"type":"step.begin","uuid":"step-2","turnId":"2"} + })); + cancelled.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":22, + "event":{"type":"tool.call","toolCallId":"question","name":"AskUserQuestion","args":{}} + })); + assert_eq!(status_at(&cancelled, 1), (SessionStatus::Waiting, true)); + cancelled.apply(&serde_json::json!({"type":"turn.cancel","turnId":2,"time":23})); + assert_eq!(status_at(&cancelled, 1), (SessionStatus::Unknown, false)); + assert!(cancelled.pending_tools.is_empty()); + + cancelled.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":24})); + assert_eq!(status_at(&cancelled, 1), (SessionStatus::Thinking, false)); + } + + #[test] + fn v1_mismatched_cancel_is_ignored_and_filtered_is_error() { + let mut state = persisted_state("1.4"); + state.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":10})); + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":11, + "event":{"type":"step.begin","uuid":"step-1","turnId":"1"} + })); + state.apply(&serde_json::json!({"type":"turn.cancel","turnId":999,"time":12})); + assert_eq!(status_at(&state, 1), (SessionStatus::Thinking, false)); + state.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":13, + "event":{"type":"step.end","uuid":"step-1","turnId":"1","finishReason":"filtered"} + })); + let (status, awaiting_input, evidence) = + evidence_at(&state, 1, StatusAuthority::Provider, false); + assert_eq!((status, awaiting_input), (SessionStatus::Error, false)); + assert_eq!(evidence.authority, StatusAuthority::Provider); + assert_eq!(state.last_error.as_deref(), Some("Kimi response filtered")); + } + + #[test] + fn v1_rejects_live_only_lifecycle_records() { + let records = [ + serde_json::json!({"type":"interaction.request","id":"i","kind":"question","time":10}), + serde_json::json!({"type":"interaction.resolved","id":"i","time":10}), + serde_json::json!({"type":"turn.started","time":10}), + serde_json::json!({"type":"turn.ended","reason":"completed","time":10}), + serde_json::json!({"type":"turn.step.started","time":10}), + serde_json::json!({"type":"turn.step.completed","time":10}), + serde_json::json!({"type":"turn.step.interrupted","time":10}), + serde_json::json!({"type":"task.started","info":{},"time":10}), + serde_json::json!({"type":"task.terminated","info":{},"time":10}), + ]; + for record in records { + let mut state = persisted_state("1.4"); + state.apply(&record); + assert_eq!( + state.lifecycle_failure, + Some(StatusReason::ProtocolUnknown), + "record={record}" + ); + assert_eq!(status_at(&state, 1), (SessionStatus::Unknown, false)); + } + } + + #[test] + fn v1_requires_timestamps_on_every_foreground_mutation() { + let records = [ + serde_json::json!({"type":"turn.prompt","input":[]}), + serde_json::json!({"type":"turn.steer","input":[],"time":0}), + serde_json::json!({"type":"turn.cancel"}), + serde_json::json!({"type":"llm.request","kind":"loop"}), + serde_json::json!({"type":"full_compaction.begin","source":"manual"}), + serde_json::json!({"type":"full_compaction.complete"}), + serde_json::json!({"type":"full_compaction.cancel"}), + serde_json::json!({"type":"context.apply_compaction"}), + serde_json::json!({"type":"context.append_loop_event","event":{"type":"step.begin","uuid":"s","turnId":"1"}}), + serde_json::json!({"type":"context.append_loop_event","event":{"type":"step.end","uuid":"s","turnId":"1","finishReason":"end_turn"}}), + serde_json::json!({"type":"context.append_loop_event","event":{"type":"content.part","part":{"type":"text","text":"x"}}}), + serde_json::json!({"type":"context.append_loop_event","event":{"type":"tool.call","toolCallId":"t","name":"ReadFile","args":{}}}), + serde_json::json!({"type":"context.append_loop_event","event":{"type":"tool.result","toolCallId":"t"}}), + ]; + for record in records { + let mut state = persisted_state("1.4"); + state.apply(&record); + assert_eq!( + state.lifecycle_failure, + Some(StatusReason::ProtocolMalformed), + "record={record}" + ); + } + } + + #[test] + fn v1_validates_llm_request_kind_and_numeric_cancel_id() { + let mut missing_kind = persisted_state("1.4"); + missing_kind.apply(&serde_json::json!({"type":"llm.request","time":10})); + assert_eq!( + missing_kind.lifecycle_failure, + Some(StatusReason::ProtocolMalformed) + ); + + let mut unknown_kind = persisted_state("1.4"); + unknown_kind.apply(&serde_json::json!({"type":"llm.request","kind":"future","time":10})); + assert_eq!( + unknown_kind.lifecycle_failure, + Some(StatusReason::ProtocolUnknown) + ); + + let mut orphan_compaction_request = persisted_state("1.4"); + orphan_compaction_request + .apply(&serde_json::json!({"type":"llm.request","kind":"compaction","time":10})); + assert_eq!( + orphan_compaction_request.lifecycle_failure, + Some(StatusReason::ProtocolMalformed) + ); + + let mut compaction = persisted_state("1.4"); + compaction.apply(&serde_json::json!({ + "type":"full_compaction.begin", + "source":"manual", + "time":9 + })); + compaction.apply(&serde_json::json!({"type":"llm.request","kind":"compaction","time":10})); + assert_eq!(status_at(&compaction, 1), (SessionStatus::Thinking, false)); + + let mut string_cancel = persisted_state("1.4"); + string_cancel.apply(&serde_json::json!({"type":"turn.cancel","turnId":"1","time":10})); + assert_eq!( + string_cancel.lifecycle_failure, + Some(StatusReason::ProtocolMalformed) + ); + } + + #[test] + fn v1_compaction_lifecycle_is_fail_closed_and_leased() { + let mut manual = persisted_state("1.4"); + manual.apply(&serde_json::json!({ + "type":"full_compaction.begin", + "source":"manual", + "time":10 + })); + assert_eq!(status_at(&manual, 1), (SessionStatus::Thinking, false)); + manual.apply(&serde_json::json!({ + "type":"llm.request", + "kind":"compaction", + "time":20 + })); + manual.apply(&serde_json::json!({ + "type":"context.apply_compaction", + "tokensAfter":100, + "time":30 + })); + assert_eq!(status_at(&manual, 1), (SessionStatus::Thinking, false)); + manual.apply(&serde_json::json!({"type":"full_compaction.complete","time":40})); + assert_eq!(status_at(&manual, 1), (SessionStatus::Idle, false)); + assert_eq!(manual.compaction_count, 1); + + let mut manual_cancel = persisted_state("1.4"); + manual_cancel.apply(&serde_json::json!({ + "type":"full_compaction.begin", + "source":"manual", + "time":45 + })); + manual_cancel.apply(&serde_json::json!({"type":"full_compaction.cancel","time":46})); + assert_eq!(status_at(&manual_cancel, 1), (SessionStatus::Idle, false)); + + let mut auto = persisted_state("1.4"); + auto.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":50})); + auto.apply(&serde_json::json!({ + "type":"full_compaction.begin", + "source":"auto", + "time":60 + })); + auto.apply(&serde_json::json!({"type":"full_compaction.cancel","time":70})); + let (status, awaiting_input, evidence) = + evidence_at(&auto, 1, StatusAuthority::Provider, false); + assert_eq!((status, awaiting_input), (SessionStatus::Unknown, false)); + assert_eq!(evidence.authority, StatusAuthority::Unavailable); + assert_eq!(evidence.reason, StatusReason::ProtocolUnknown); + assert!(!auto.active_turn && !auto.active_step && auto.pending_tools.is_empty()); + + auto.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":80, + "event":{"type":"step.begin","uuid":"step-2","turnId":1} + })); + assert_eq!(status_at(&auto, 1), (SessionStatus::Thinking, false)); + assert_eq!(auto.foreground_uncertain_since, 0); + + let mut stale = persisted_state("1.4"); + stale.apply(&serde_json::json!({ + "type":"full_compaction.begin", + "source":"manual", + "time":100 + })); + stale.expire_foreground_lease_at(101 + V1_NON_WAIT_FOREGROUND_LEASE_MS); + let (status, awaiting_input, evidence) = + evidence_at(&stale, 1, StatusAuthority::Provider, false); + assert_eq!((status, awaiting_input), (SessionStatus::Unknown, false)); + assert_eq!(evidence.authority, StatusAuthority::Unavailable); + assert_eq!(evidence.reason, StatusReason::Stale); + } + + #[test] + fn v1_steer_starts_work_and_foreground_lease_expires_fail_closed() { + let mut state = persisted_state("1.4"); + state.apply(&serde_json::json!({"type":"turn.steer","input":[],"time":100})); + assert_eq!(status_at(&state, 1), (SessionStatus::Thinking, false)); + state.expire_foreground_lease_at(100 + V1_NON_WAIT_FOREGROUND_LEASE_MS); + assert_eq!(status_at(&state, 1), (SessionStatus::Thinking, false)); + state.expire_foreground_lease_at(101 + V1_NON_WAIT_FOREGROUND_LEASE_MS); + assert_eq!(status_at(&state, 1), (SessionStatus::Unknown, false)); + assert!(!state.active_turn && state.pending_tools.is_empty()); + let (_, _, evidence) = evidence_at(&state, 1, StatusAuthority::Provider, false); + assert_eq!(evidence.authority, StatusAuthority::Unavailable); + assert_eq!(evidence.reason, StatusReason::Stale); + state.apply(&serde_json::json!({ + "type":"turn.steer", + "input":[], + "time":102 + V1_NON_WAIT_FOREGROUND_LEASE_MS + })); + assert_eq!(status_at(&state, 1), (SessionStatus::Thinking, false)); + + let mut executing = persisted_state("1.4"); + executing.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":200})); + executing.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":201, + "event":{"type":"tool.call","toolCallId":"tool","name":"ReadFile","args":{}} + })); + assert_eq!(status_at(&executing, 1), (SessionStatus::Executing, false)); + executing.expire_foreground_lease_at(202 + V1_NON_WAIT_FOREGROUND_LEASE_MS); + assert_eq!(status_at(&executing, 1), (SessionStatus::Unknown, false)); + + let mut waiting = persisted_state("1.4"); + waiting.apply(&serde_json::json!({"type":"turn.prompt","input":[],"time":300})); + waiting.apply(&serde_json::json!({ + "type":"context.append_loop_event", + "time":301, + "event":{"type":"tool.call","toolCallId":"question","name":"AskUserQuestion","args":{}} + })); + waiting.expire_foreground_lease_at(301 + V1_NON_WAIT_FOREGROUND_LEASE_MS * 10); + assert_eq!(status_at(&waiting, 1), (SessionStatus::Waiting, true)); + let (_, _, evidence) = evidence_at(&waiting, 1, StatusAuthority::Provider, false); + assert_eq!(evidence.authority, StatusAuthority::Provider); + assert_eq!(evidence.reason, StatusReason::ProviderWaitingUserInput); + } + + #[test] + fn durable_task_snapshots_keep_background_work_executing() { + let dir = tempfile::tempdir().unwrap(); + let task_dir = dir.path().join("agents/main/tasks"); + fs::create_dir_all(&task_dir).unwrap(); + let path = task_dir.join("bash-abcdefgh.json"); + fs::write( + &path, + r#"{"taskId":"bash-abcdefgh","kind":"process","status":"running","startedAt":20,"endedAt":null,"description":"tests"}"#, + ) + .unwrap(); + let snapshots = + read_task_snapshots(dir.path(), std::slice::from_ref(&task_dir), 10).unwrap(); + let mut state = WireState::default(); + state.reconcile_task_snapshots(snapshots); + assert_eq!(status_at(&state, 10), (SessionStatus::Executing, false)); + assert_eq!(state.execution_labels_since(10), vec!["background process"]); + + fs::write( + &path, + r#"{"taskId":"bash-abcdefgh","kind":"process","status":"completed","startedAt":20,"endedAt":30}"#, + ) + .unwrap(); + let snapshots = read_task_snapshots(dir.path(), &[task_dir], 10).unwrap(); + state.reconcile_task_snapshots(snapshots); + assert_eq!(status_at(&state, 10), (SessionStatus::Idle, false)); + } + + #[test] + fn durable_detached_question_waits_over_parallel_background_work_and_clears() { + let dir = tempfile::tempdir().unwrap(); + let task_dir = dir.path().join("agents/main/tasks"); + fs::create_dir_all(&task_dir).unwrap(); + let process_path = task_dir.join("bash-abcdefgh.json"); + let question_path = task_dir.join("question-abcdefgh.json"); + fs::write( + &process_path, + r#"{"taskId":"bash-abcdefgh","kind":"process","status":"running","detached":true,"startedAt":20,"endedAt":null}"#, + ) + .unwrap(); + fs::write( + &question_path, + r#"{"taskId":"question-abcdefgh","kind":"question","status":"running","detached":true,"startedAt":21,"endedAt":null,"toolCallId":"question-call"}"#, + ) + .unwrap(); + + let snapshots = + read_task_snapshots(dir.path(), std::slice::from_ref(&task_dir), 10).unwrap(); + let mut state = persisted_state("1.4"); + state.reconcile_task_snapshots(snapshots); + state.expire_foreground_lease_at(21 + V1_NON_WAIT_FOREGROUND_LEASE_MS * 10); + let (status, awaiting_input, evidence) = + evidence_at(&state, 10, StatusAuthority::Provider, false); + assert_eq!((status, awaiting_input), (SessionStatus::Waiting, true)); + assert_eq!(evidence.authority, StatusAuthority::Provider); + assert_eq!(evidence.reason, StatusReason::ProviderWaitingUserInput); + assert_eq!(state.pending_input_since(10), 21); + assert_eq!(state.execution_labels_since(10), vec!["background process"]); + + fs::write( + &question_path, + r#"{"taskId":"question-abcdefgh","kind":"question","status":"completed","detached":true,"startedAt":21,"endedAt":30,"toolCallId":"question-call"}"#, + ) + .unwrap(); + let snapshots = + read_task_snapshots(dir.path(), std::slice::from_ref(&task_dir), 10).unwrap(); + state.reconcile_task_snapshots(snapshots); + assert_eq!(status_at(&state, 10), (SessionStatus::Executing, false)); + + fs::write( + &process_path, + r#"{"taskId":"bash-abcdefgh","kind":"process","status":"completed","detached":true,"startedAt":20,"endedAt":31}"#, + ) + .unwrap(); + let snapshots = read_task_snapshots(dir.path(), &[task_dir], 10).unwrap(); + state.reconcile_task_snapshots(snapshots); + assert_eq!(status_at(&state, 10), (SessionStatus::Idle, false)); + } + + #[test] + fn durable_foreground_question_waits_and_terminal_snapshot_clears() { + let dir = tempfile::tempdir().unwrap(); + let task_dir = dir.path().join("agents/main/tasks"); + fs::create_dir_all(&task_dir).unwrap(); + let path = task_dir.join("question-abcdefgh.json"); + fs::write( + &path, + r#"{"taskId":"question-abcdefgh","kind":"question","status":"running","detached":false,"startedAt":20,"endedAt":null,"toolCallId":"question-call"}"#, + ) + .unwrap(); + let snapshots = + read_task_snapshots(dir.path(), std::slice::from_ref(&task_dir), 10).unwrap(); + let mut state = WireState::default(); + state.reconcile_task_snapshots(snapshots); + assert_eq!(status_at(&state, 10), (SessionStatus::Waiting, true)); + assert_eq!(state.pending_input_since(10), 20); + + fs::write( + &path, + r#"{"taskId":"question-abcdefgh","kind":"question","status":"completed","detached":false,"startedAt":20,"endedAt":30,"toolCallId":"question-call"}"#, + ) + .unwrap(); + let snapshots = read_task_snapshots(dir.path(), &[task_dir], 10).unwrap(); + state.reconcile_task_snapshots(snapshots); + assert_eq!(status_at(&state, 10), (SessionStatus::Idle, false)); + } + + #[test] + fn legacy_question_snapshot_without_detached_still_waits_for_user() { + let dir = tempfile::tempdir().unwrap(); + let task_dir = dir.path().join("agents/main/tasks"); + fs::create_dir_all(&task_dir).unwrap(); + fs::write( + task_dir.join("question-abcdefgh.json"), + r#"{"taskId":"question-abcdefgh","kind":"question","status":"running","startedAt":20,"endedAt":null}"#, + ) + .unwrap(); + let snapshots = read_task_snapshots(dir.path(), &[task_dir], 10).unwrap(); + let mut state = WireState::default(); + state.reconcile_task_snapshots(snapshots); + assert!(state.active_tasks["question-abcdefgh"].detached); + assert_eq!(status_at(&state, 10), (SessionStatus::Waiting, true)); + } + + #[test] + fn current_malformed_task_snapshot_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + let task_dir = dir.path().join("agents/main/tasks"); + fs::create_dir_all(&task_dir).unwrap(); + let path = task_dir.join("bash-abcdefgh.json"); + fs::write(&path, "not-json").unwrap(); + assert_eq!( + read_task_snapshots(dir.path(), std::slice::from_ref(&task_dir), 0).unwrap_err(), + StatusReason::ProtocolMalformed + ); + + fs::write( + &path, + r#"{"taskId":"bash-abcdefgh","kind":"process","status":"running","detached":"maybe","startedAt":20,"endedAt":null}"#, + ) + .unwrap(); + assert_eq!( + read_task_snapshots(dir.path(), &[task_dir], 0).unwrap_err(), + StatusReason::ProtocolMalformed + ); + } + + #[test] + fn bare_rewritten_title_never_retains_provider_ownership() { + let root = PathBuf::from("/tmp/kimi-bare"); + let session = KimiSession { + id: "session-a".to_string(), + dir: root.join("sessions/bucket/session-a"), + root: root.clone(), + cwd: "/tmp/project".to_string(), + title: String::new(), + created_at: 1, + updated_at: 20, + archived: false, + agents: Vec::new(), + }; + let process = KimiProcess { + pid: 1, + cwd: session.cwd.clone(), + root, + explicit_session: None, + bare_title: true, + started_at: Some(10), + incarnation: "same-process".to_string(), + }; + let prior = KimiAssignment { + dir: session.dir.clone(), + confirmed: true, + authority: StatusAuthority::Provider, + activity_boundary_ms: 10, + process_incarnation: "same-process".to_string(), + }; + assert_eq!( + pairing_authority(&process, &session, Some(&prior)), + StatusAuthority::Heuristic + ); + } + + #[test] + fn config_limit_uses_override_input_first() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("config.toml"), + r#"[models.k2] +max_context_size = 200000 +max_input_size = 150000 +[models.k2.overrides] +max_input_size = 120000 + +[models.alias] +model = "moonshot/model-id" +max_context_size = 64000 +"#, + ) + .unwrap(); + assert_eq!(model_context_limit(dir.path(), "k2", ""), 120_000); + assert_eq!( + model_context_limit(dir.path(), "", "moonshot/model-id"), + 64_000 + ); + assert_eq!(model_context_limit(dir.path(), "missing", ""), 0); + } + + #[test] + fn sanitizes_tool_arguments() { + let arg = safe_tool_arg(&serde_json::json!({"path":"\u{202e}/tmp/sk-ant-secret"})); + assert!(!arg.contains('\u{202e}')); + assert!(!arg.contains("secret")); + assert!(safe_tool_arg(&serde_json::json!({"command":"echo secret"})).is_empty()); + assert!(safe_tool_arg(&serde_json::json!({"pattern":"private search text"})).is_empty()); + } +} diff --git a/src/collector/mcp.rs b/src/collector/mcp.rs index 4212698..06c2ac0 100644 --- a/src/collector/mcp.rs +++ b/src/collector/mcp.rs @@ -357,10 +357,7 @@ pub(crate) fn kill_rollout_scan_child(pid: u32) { kill_pid(pid); } -#[cfg(any( - test, - all(not(target_os = "linux"), not(target_os = "windows")) -))] +#[cfg(any(test, all(not(target_os = "linux"), not(target_os = "windows"))))] pub(crate) fn parse_lsof_rollout_output(stdout: &str) -> HashMap> { let mut map: HashMap> = HashMap::new(); let mut current_pid: Option = None; diff --git a/src/collector/mod.rs b/src/collector/mod.rs index 930caf1..4cf0504 100644 --- a/src/collector/mod.rs +++ b/src/collector/mod.rs @@ -1,5 +1,9 @@ pub mod claude; pub mod codex; +pub(crate) mod codexbar; +pub mod grok; +mod herdr; +pub mod kimi; pub mod mcp; pub mod opencode; pub mod process; @@ -7,6 +11,8 @@ pub mod rate_limit; pub use claude::ClaudeCollector; pub use codex::CodexCollector; +pub use grok::GrokCollector; +pub use kimi::KimiCollector; pub use mcp::McpServer; pub use opencode::OpenCodeCollector; pub use rate_limit::read_rate_limits; @@ -82,7 +88,9 @@ pub(crate) fn sanitize_terminal_text(s: &str) -> String { .collect() } -use crate::model::{AgentSession, OrphanPort, RateLimitInfo, SessionStatus}; +use crate::model::{ + AgentSession, OrphanPort, RateLimitInfo, SessionStatus, StatusAuthority, StatusReason, +}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::{ @@ -282,7 +290,7 @@ impl Drop for DesktopRolloutScanner { } } -/// Aggregates sessions from multiple collectors (Claude, Codex, etc.) +/// Aggregates sessions from all supported collectors. pub struct MultiCollector { collectors: Vec>, codex_enabled: bool, @@ -335,6 +343,12 @@ impl MultiCollector { if !is_hidden("opencode") { collectors.push(Box::new(OpenCodeCollector::new())); } + if !is_hidden("grok") { + collectors.push(Box::new(GrokCollector::new())); + } + if !is_hidden("kimi") { + collectors.push(Box::new(KimiCollector::new())); + } let codex_enabled = !is_hidden("codex"); Self { collectors, @@ -440,8 +454,19 @@ impl MultiCollector { } } - // Hide dead sessions: Codex uses pid==0 sentinel, Claude is filtered in collect(). - all.retain(|s| !matches!(s.status, SessionStatus::Done)); + // Keep only short-lived, source-qualified process-exit tombstones. + // Historical rollout files must not turn into persistent PID=0 rows. + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + all.retain(|session| { + !matches!(session.status, SessionStatus::Done) + || (session.status_evidence.authority != StatusAuthority::Unavailable + && session.status_evidence.reason == StatusReason::ProcessExited + && session.status_evidence.observed_at_ms <= now_ms + && now_ms - session.status_evidence.observed_at_ms <= 30_000) + }); all.sort_by_key(|s| std::cmp::Reverse(s.started_at)); // --- Orphan port detection --- @@ -507,27 +532,35 @@ mod tests { #[test] fn with_hidden_empty_keeps_all_collectors() { let mc = MultiCollector::with_hidden(&[]); - assert_eq!(mc.collectors.len(), 3); + assert_eq!(mc.collectors.len(), 5); } #[test] fn with_hidden_codex_drops_codex_only() { let mc = MultiCollector::with_hidden(&["codex".to_string()]); - assert_eq!(mc.collectors.len(), 2); + assert_eq!(mc.collectors.len(), 4); + } + + #[test] + fn with_hidden_new_providers_drops_each_provider_only() { + let mc = MultiCollector::with_hidden(&["grok".to_string()]); + assert_eq!(mc.collectors.len(), 4); + let mc = MultiCollector::with_hidden(&["kimi".to_string()]); + assert_eq!(mc.collectors.len(), 4); } #[test] fn with_hidden_is_case_insensitive() { let mc = MultiCollector::with_hidden(&["CODEX".to_string()]); - assert_eq!(mc.collectors.len(), 2); + assert_eq!(mc.collectors.len(), 4); let mc = MultiCollector::with_hidden(&["Claude".to_string()]); - assert_eq!(mc.collectors.len(), 2); + assert_eq!(mc.collectors.len(), 4); } #[test] fn with_hidden_unknown_names_are_ignored() { let mc = MultiCollector::with_hidden(&["kiro".to_string(), "gemini".to_string()]); - assert_eq!(mc.collectors.len(), 3); + assert_eq!(mc.collectors.len(), 5); } #[test] @@ -536,6 +569,8 @@ mod tests { "claude".to_string(), "codex".to_string(), "opencode".to_string(), + "grok".to_string(), + "kimi".to_string(), ]); assert!(mc.collectors.is_empty()); } diff --git a/src/collector/opencode.rs b/src/collector/opencode.rs index 608e3b6..d2b9fc4 100644 --- a/src/collector/opencode.rs +++ b/src/collector/opencode.rs @@ -1,13 +1,22 @@ -use super::{process, context_window_for_model}; -use crate::model::{AgentSession, ChildProcess, SessionStatus}; +use super::{context_window_for_model, process}; +use crate::model::{ + AgentSession, ChildProcess, SessionStatus, StatusAuthority, StatusEvidence, StatusObservation, + StatusReason, +}; use serde_json::Value; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; /// Maximum sessions to fetch from the DB per query. const MAX_SESSIONS: u32 = 20; +/// Lifecycle inference must come from the current successful query path and +/// remain very recent. Failed reads/parses never reuse cached state. +const LIFECYCLE_QUERY_FRESHNESS_MS: u64 = 5_000; +/// Allow small DB/filesystem timestamp skew when proving that a row could +/// belong to the observed process incarnation. +const OWNERSHIP_START_GRACE_MS: u64 = 5_000; /// Collector for OpenCode sessions. /// @@ -18,14 +27,23 @@ const MAX_SESSIONS: u32 = 20; /// /// Uses `sqlite3 -readonly -json` for safe concurrent reads (WAL mode). /// DB rows are cached and only refreshed on `shared.slow_tick` (every ~10s) -/// so we don't fork a sqlite3 process every 2s. PID matching, status -/// derivation and the children walk run every tick using live process info. +/// so the aggregate query does not run every 2s. A lightweight lifecycle +/// query runs on every session tick while OpenCode is live; PID matching and +/// the children walk also use the current process snapshot on every tick. pub struct OpenCodeCollector { db_path: PathBuf, /// Whether sqlite3 CLI is available (checked once). sqlite3_available: Option, /// Cached DB rows from the last slow-tick query. Reused on fast ticks. cached_db_sessions: Vec, + /// Per-session lifecycle rows from the newest successful query. The map is + /// discarded immediately on failure and timestamped to bound lifecycle + /// inference even on the successful path. + cached_db_lifecycles: HashMap, + /// Outcome of the newest lifecycle query attempted while a live OpenCode + /// process existed. `false` invalidates cached lifecycle status + /// immediately; cached Waiting must never survive a failed query. + lifecycle_query_succeeded: bool, /// Whether the "sqlite3 missing" warning has been emitted (once). #[cfg(target_os = "windows")] warned_sqlite3_missing: bool, @@ -43,6 +61,8 @@ impl OpenCodeCollector { db_path, sqlite3_available: None, cached_db_sessions: Vec::new(), + cached_db_lifecycles: HashMap::new(), + lifecycle_query_succeeded: false, #[cfg(target_os = "windows")] warned_sqlite3_missing: false, } @@ -61,6 +81,8 @@ impl OpenCodeCollector { // Security: skip if db_path is a symlink (fail-closed) if is_symlink(&self.db_path) || !self.db_path.exists() { self.cached_db_sessions.clear(); + self.cached_db_lifecycles.clear(); + self.lifecycle_query_succeeded = false; return vec![]; } if !self.check_sqlite3() { @@ -78,20 +100,27 @@ impl OpenCodeCollector { ); } self.cached_db_sessions.clear(); + self.cached_db_lifecycles.clear(); + self.lifecycle_query_succeeded = false; return vec![]; } - // Find running opencode PIDs and their commands for cwd matching + // Find only session-owning OpenCode processes. Generic "second token + // is named opencode" matching accepts unrelated commands such as + // `rg opencode`, while host/admin modes cannot own a TUI session. let opencode_pids = Self::find_opencode_pids(&shared.process_info); - let pid_commands: HashMap = opencode_pids + let mut live_processes: Vec = opencode_pids .iter() .filter_map(|&pid| { - shared - .process_info - .get(&pid) - .map(|p| (pid, p.command.as_str())) + shared.process_info.get(&pid).map(|p| LiveOpenCodeProcess { + pid, + command: p.command.clone(), + cwd: get_process_cwd(pid), + started_at_ms: process::get_process_started_at_ms(pid), + }) }) .collect(); + live_processes.sort_by_key(|process| process.pid); // Refresh DB rows on slow ticks only; reuse cache on fast ticks so // we don't fork sqlite3 every 2s. @@ -101,41 +130,54 @@ impl OpenCodeCollector { } } + // Status changes are latency-sensitive (especially user questions), + // so query just the latest message and active tool-part metadata every + // session tick. Any failed read/parse immediately invalidates status + // authority and discards cached rows, so Waiting (or any other status) + // can never survive that failure. + if !opencode_pids.is_empty() { + match self.query_lifecycles() { + Some(lifecycles) => { + let observed_at_ms = current_time_ms(); + self.cached_db_lifecycles = lifecycles + .into_iter() + .map(|(id, lifecycle)| { + ( + id, + CachedLifecycle { + lifecycle, + observed_at_ms, + }, + ) + }) + .collect(); + self.lifecycle_query_succeeded = true; + } + None => { + self.lifecycle_query_succeeded = false; + self.cached_db_lifecycles.clear(); + } + } + } + let now_ms = current_time_ms(); + let ownership_plan = plan_session_ownership(&self.cached_db_sessions, &live_processes); let mut sessions = Vec::new(); - let mut claimed_pids = HashSet::new(); - for ds in &self.cached_db_sessions { - let matched_pid = - Self::match_pid_to_session_once(&pid_commands, &ds.directory, &mut claimed_pids); - // Drop sessions whose process isn't running. (Done sessions are - // filtered out by MultiCollector::collect anyway, so emitting - // a Done row here would be dead code.) - let Some(matched_pid) = matched_pid else { - continue; - }; + for planned in ownership_plan { + let ds = &self.cached_db_sessions[planned.session_index]; + let matched_pid = planned.ownership.pid().unwrap_or(0); let proc = shared.process_info.get(&matched_pid); let mem_mb = proc.map(|p| p.rss_kb / 1024).unwrap_or(0); - let age_ms = now_ms.saturating_sub(ds.time_updated); - let since_update_secs = age_ms / 1000; - let status = if since_update_secs < 30 { - SessionStatus::Thinking - } else { - let cpu_active = proc.is_some_and(|p| p.cpu_pct > 1.0); - let has_active_child = process::has_active_descendant( - matched_pid, - &shared.children_map, - &shared.process_info, - 5.0, - ); - if cpu_active || has_active_child { - SessionStatus::Thinking - } else { - SessionStatus::Waiting - } - }; + let lifecycle = resolve_session_status( + planned.ownership, + self.cached_db_lifecycles.get(&ds.id), + self.lifecycle_query_succeeded, + now_ms, + ); + let status = lifecycle.status; let project_name = if !ds.project_name.is_empty() { ds.project_name.clone() @@ -146,19 +188,42 @@ impl OpenCodeCollector { .to_string() }; - let current_tasks = if matches!(status, SessionStatus::Waiting) { - vec!["waiting for input".to_string()] + let current_tasks = match &status { + SessionStatus::Waiting => vec!["waiting for user input".to_string()], + SessionStatus::Executing => vec![lifecycle + .active_tool + .clone() + .unwrap_or_else(|| "running child process".to_string())], + SessionStatus::Thinking => vec!["thinking".to_string()], + SessionStatus::Idle => vec!["idle".to_string()], + SessionStatus::Error => vec!["provider error".to_string()], + SessionStatus::Unknown => vec![lifecycle.task.clone()], + _ => vec![], + }; + let pending_since_ms = + if matches!(&status, SessionStatus::Waiting | SessionStatus::Executing) { + lifecycle.since_ms + } else { + 0 + }; + let thinking_since_ms = if matches!(&status, SessionStatus::Thinking) { + lifecycle.since_ms } else { - vec!["thinking...".to_string()] + 0 }; + let awaiting_input = matches!(&status, SessionStatus::Waiting); // Collect child processes with cycle guard (visited set) let mut children = Vec::new(); - let mut stack: Vec = shared - .children_map - .get(&matched_pid) - .cloned() - .unwrap_or_default(); + let mut stack: Vec = if planned.ownership.is_confirmed() { + shared + .children_map + .get(&matched_pid) + .cloned() + .unwrap_or_default() + } else { + Vec::new() + }; let mut visited = std::collections::HashSet::new(); while let Some(cpid) = stack.pop() { if !visited.insert(cpid) { @@ -196,11 +261,15 @@ impl OpenCodeCollector { sessions.push(AgentSession { agent_cli: "opencode", pid: matched_pid, + // OpenCode exposes no exact PID/session registry. Cwd matching + // is display-only and must never authorize a PID action. + action_process_incarnation: None, session_id: ds.id.clone(), cwd: ds.directory.clone(), project_name, started_at: ds.time_created, status, + status_evidence: lifecycle.evidence, model, effort: String::new(), context_percent, @@ -227,8 +296,9 @@ impl OpenCodeCollector { first_assistant_text: String::new(), chat_messages: vec![], tool_calls: vec![], - pending_since_ms: 0, - thinking_since_ms: 0, + pending_since_ms, + awaiting_input, + thinking_since_ms, file_accesses: vec![], config_root: super::abbrev_path( self.db_path.parent().unwrap_or(std::path::Path::new(".")), @@ -243,61 +313,11 @@ impl OpenCodeCollector { fn find_opencode_pids(process_info: &HashMap) -> Vec { process_info .iter() - .filter(|(_, info)| { - process::cmd_has_binary(&info.command, "opencode") && !info.command.contains("grep") - }) + .filter(|(_, info)| is_session_owning_opencode_process(&info.command)) .map(|(pid, _)| *pid) .collect() } - /// Match a running PID to a session by comparing its working directory - /// with the DB session's `directory`, falling back to a command-line - /// substring match. Returns `None` if no PID's cwd or command line ties - /// to this session — we deliberately do not fall back to "the only - /// opencode process" here, because that would mark every DB row as - /// alive whenever a single opencode is running in an unrelated dir. - #[cfg(test)] - fn match_pid_to_session(pid_commands: &HashMap, session_dir: &str) -> Option { - Self::match_pid_to_session_excluding(pid_commands, session_dir, &HashSet::new()) - } - - fn match_pid_to_session_once( - pid_commands: &HashMap, - session_dir: &str, - claimed_pids: &mut HashSet, - ) -> Option { - let pid = Self::match_pid_to_session_excluding(pid_commands, session_dir, claimed_pids)?; - claimed_pids.insert(pid); - Some(pid) - } - - fn match_pid_to_session_excluding( - pid_commands: &HashMap, - session_dir: &str, - claimed_pids: &HashSet, - ) -> Option { - // Empty / single-character `session_dir` (e.g. "" or "/") would - // make the substring fallback match unrelated commands, so skip - // matching entirely in that case. - if session_dir.len() < 2 { - return None; - } - for (&pid, &cmd) in pid_commands { - if claimed_pids.contains(&pid) { - continue; - } - if let Some(cwd) = get_process_cwd(pid) { - if paths_equal(&cwd, session_dir) { - return Some(pid); - } - } - if cmd.contains(session_dir) { - return Some(pid); - } - } - None - } - /// Run a single sqlite3 query and parse the JSON output. fn run_query(&self, sql: &str) -> Option> { let db = self.db_path.to_str()?; @@ -405,6 +425,115 @@ LIMIT {};"#, Some(sessions) } + + /// Query only lifecycle metadata from the newest message and session-wide + /// active tool parts. Raw prompts, tool inputs, outputs, and error content + /// are deliberately excluded from the SELECT list. + fn query_lifecycles(&self) -> Option> { + let lifecycle_sql = format!( + r#" +WITH recent_sessions AS ( + SELECT id + FROM session + ORDER BY time_updated DESC + LIMIT {} +), +latest_messages AS ( + SELECT + rs.id AS session_id, + ( + SELECT m.id + FROM message m + WHERE m.session_id = rs.id + ORDER BY m.time_created DESC, m.id DESC + LIMIT 1 + ) AS message_id, + COALESCE(( + SELECT json_extract(m.data, '$.role') + FROM message m + WHERE m.session_id = rs.id + ORDER BY m.time_created DESC, m.id DESC + LIMIT 1 + ), '') AS latest_role, + COALESCE(( + SELECT json_extract(m.data, '$.time.created') + FROM message m + WHERE m.session_id = rs.id + ORDER BY m.time_created DESC, m.id DESC + LIMIT 1 + ), 0) AS latest_created, + COALESCE(( + SELECT json_extract(m.data, '$.time.completed') + FROM message m + WHERE m.session_id = rs.id + ORDER BY m.time_created DESC, m.id DESC + LIMIT 1 + ), 0) AS latest_completed, + COALESCE(( + SELECT CASE + WHEN json_extract(m.data, '$.error.name') = 'MessageAbortedError' + THEN 0 + WHEN ( + json_type(m.data, '$.error') IS NOT NULL + AND json_type(m.data, '$.error') != 'null' + ) OR json_extract(m.data, '$.finish') = 'error' + THEN 1 + ELSE 0 + END + FROM message m + WHERE m.session_id = rs.id + ORDER BY m.time_created DESC, m.id DESC + LIMIT 1 + ), 0) AS latest_has_error + FROM recent_sessions rs +), +ranked_active_tools AS ( + SELECT + p.session_id, + p.message_id, + COALESCE(json_extract(p.data, '$.tool'), '') AS tool, + COALESCE(json_extract(p.data, '$.state.status'), '') AS tool_status, + COALESCE(json_extract(p.data, '$.state.time.start'), p.time_updated, 0) AS tool_started, + ROW_NUMBER() OVER ( + PARTITION BY p.session_id + ORDER BY + CASE WHEN p.message_id = a.message_id THEN 0 ELSE 1 END, + CASE + WHEN LOWER(COALESCE(json_extract(p.data, '$.tool'), '')) = 'question' + AND json_extract(p.data, '$.state.status') = 'running' + THEN 0 + ELSE 1 + END, + p.time_updated DESC, + p.id DESC + ) AS rank + FROM part p + JOIN latest_messages a + ON a.session_id = p.session_id + WHERE json_extract(p.data, '$.type') = 'tool' + AND json_extract(p.data, '$.state.status') IN ('pending', 'running') +) +SELECT + m.session_id AS id, + COALESCE(m.message_id, '') AS latest_message_id, + m.latest_role, + m.latest_created, + m.latest_completed, + m.latest_has_error, + COALESCE(t.message_id, '') AS active_tool_message_id, + COALESCE(t.tool, '') AS active_tool, + COALESCE(t.tool_status, '') AS active_tool_status, + COALESCE(t.tool_started, 0) AS active_tool_started +FROM latest_messages m +LEFT JOIN ranked_active_tools t + ON t.session_id = m.session_id + AND t.rank = 1;"#, + MAX_SESSIONS + ); + + let rows = self.run_query(&lifecycle_sql)?; + parse_lifecycle_rows(rows) + } } impl Default for OpenCodeCollector { @@ -419,6 +548,7 @@ impl super::AgentCollector for OpenCodeCollector { } } +#[derive(Debug, Clone)] struct DbSession { id: String, title: String, @@ -436,6 +566,703 @@ struct DbSession { provider: String, } +#[derive(Debug, Clone)] +struct LiveOpenCodeProcess { + pid: u32, + command: String, + cwd: Option, + started_at_ms: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProcessOwnership { + Confirmed { pid: u32, started_at_ms: u64 }, + Unconfirmed, +} + +impl ProcessOwnership { + fn pid(self) -> Option { + match self { + Self::Confirmed { pid, .. } => Some(pid), + Self::Unconfirmed => None, + } + } + + fn started_at_ms(self) -> Option { + match self { + Self::Confirmed { started_at_ms, .. } => Some(started_at_ms), + Self::Unconfirmed => None, + } + } + + fn is_confirmed(self) -> bool { + matches!(self, Self::Confirmed { .. }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PlannedSessionOwnership { + session_index: usize, + ownership: ProcessOwnership, +} + +/// Build a deterministic, fail-closed ownership plan. +/// +/// An explicit `--session`/`-s` process argument is authoritative. Otherwise +/// a directory group is actionable only when exactly one live process and one +/// fetched DB row share that directory. Any 1:N, N:1, or N:N group emits at +/// most one row per live process, newest first, with no actionable PID. +fn plan_session_ownership( + sessions: &[DbSession], + processes: &[LiveOpenCodeProcess], +) -> Vec { + let mut plan = Vec::new(); + let mut used_sessions = HashSet::new(); + let mut used_processes = HashSet::new(); + + let session_by_id: HashMap<&str, usize> = sessions + .iter() + .enumerate() + .map(|(index, session)| (session.id.as_str(), index)) + .collect(); + let mut explicit = BTreeMap::>::new(); + for (process_index, process) in processes.iter().enumerate() { + if let Some(session_id) = explicit_session_id(&process.command) { + // A process naming an uncached session must not be reassigned to a + // different historical row merely because the cwd matches. + used_processes.insert(process_index); + explicit.entry(session_id).or_default().push(process_index); + } + } + for (session_id, process_indices) in explicit { + let Some(&session_index) = session_by_id.get(session_id.as_str()) else { + continue; + }; + used_sessions.insert(session_index); + let ownership = if process_indices.len() == 1 { + let process = &processes[process_indices[0]]; + process + .started_at_ms + .map(|started_at_ms| ProcessOwnership::Confirmed { + pid: process.pid, + started_at_ms, + }) + .unwrap_or(ProcessOwnership::Unconfirmed) + } else { + ProcessOwnership::Unconfirmed + }; + plan.push(PlannedSessionOwnership { + session_index, + ownership, + }); + } + + let mut session_groups = BTreeMap::>::new(); + for (index, session) in sessions.iter().enumerate() { + if used_sessions.contains(&index) { + continue; + } + if let Some(key) = path_key(&session.directory) { + session_groups.entry(key).or_default().push(index); + } + } + + let mut process_groups = BTreeMap::>::new(); + for (process_index, process) in processes.iter().enumerate() { + if used_processes.contains(&process_index) { + continue; + } + let key = if let Some(cwd) = process.cwd.as_deref() { + path_key(cwd).filter(|key| session_groups.contains_key(key)) + } else { + let matching_keys: Vec<_> = session_groups + .keys() + .filter(|key| command_mentions_session_dir(&process.command, key)) + .cloned() + .collect(); + (matching_keys.len() == 1).then(|| matching_keys[0].clone()) + }; + if let Some(key) = key { + process_groups.entry(key).or_default().push(process_index); + } + } + + for (key, mut session_indices) in session_groups { + let Some(mut process_indices) = process_groups.remove(&key) else { + continue; + }; + session_indices.sort_by(|left, right| { + sessions[*right] + .time_updated + .cmp(&sessions[*left].time_updated) + .then_with(|| { + sessions[*right] + .time_created + .cmp(&sessions[*left].time_created) + }) + .then_with(|| sessions[*left].id.cmp(&sessions[*right].id)) + }); + process_indices.sort_by_key(|index| processes[*index].pid); + + if session_indices.len() == 1 + && process_indices.len() == 1 + && process_could_own_session( + &processes[process_indices[0]], + &sessions[session_indices[0]], + ) + { + let process = &processes[process_indices[0]]; + let Some(started_at_ms) = process.started_at_ms else { + plan.push(PlannedSessionOwnership { + session_index: session_indices[0], + ownership: ProcessOwnership::Unconfirmed, + }); + continue; + }; + plan.push(PlannedSessionOwnership { + session_index: session_indices[0], + ownership: ProcessOwnership::Confirmed { + pid: process.pid, + started_at_ms, + }, + }); + continue; + } + + // Do not invent a PID/session permutation for an ambiguous same-cwd + // group. Bound visible candidates by the number of live processes so + // old DB rows do not appear as duplicate live sessions. + for &session_index in session_indices.iter().take(process_indices.len()) { + plan.push(PlannedSessionOwnership { + session_index, + ownership: ProcessOwnership::Unconfirmed, + }); + } + } + + plan.sort_by(|left, right| { + sessions[right.session_index] + .time_updated + .cmp(&sessions[left.session_index].time_updated) + .then_with(|| { + sessions[left.session_index] + .id + .cmp(&sessions[right.session_index].id) + }) + }); + plan +} + +fn process_could_own_session(process: &LiveOpenCodeProcess, session: &DbSession) -> bool { + process.started_at_ms.is_some_and(|started_at_ms| { + session.time_updated >= started_at_ms.saturating_sub(OWNERSHIP_START_GRACE_MS) + }) +} + +#[derive(Debug, Clone)] +struct CachedLifecycle { + lifecycle: DbLifecycle, + observed_at_ms: u64, +} + +#[derive(Debug, Clone)] +struct DbLifecycle { + status: SessionStatus, + authority: StatusAuthority, + reason: StatusReason, + active_tool: Option, + since_ms: u64, + /// Timestamp on the exact provider record used for this classification. + /// Query time is freshness evidence, not proof that persisted lifecycle + /// state belongs to the current process incarnation. + source_at_ms: u64, +} + +struct LifecycleDecision { + status: SessionStatus, + evidence: StatusEvidence, + active_tool: Option, + since_ms: u64, + task: String, +} + +impl LifecycleDecision { + fn unknown(reason: StatusReason, observed_at_ms: u64, task: &str) -> Self { + Self { + status: SessionStatus::Unknown, + evidence: make_status_evidence( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + reason, + observed_at_ms, + observed_at_ms, + ), + active_tool: None, + since_ms: 0, + task: task.to_string(), + } + } +} + +fn resolve_lifecycle_status( + cached: Option<&CachedLifecycle>, + query_succeeded: bool, + now_ms: u64, +) -> LifecycleDecision { + if !query_succeeded { + return LifecycleDecision::unknown( + StatusReason::BackgroundProbeFailed, + now_ms, + "lifecycle query failed", + ); + } + + let Some(cached) = cached else { + return LifecycleDecision::unknown( + StatusReason::ProtocolUnknown, + now_ms, + "lifecycle unavailable", + ); + }; + if cached.observed_at_ms > now_ms + || now_ms - cached.observed_at_ms > LIFECYCLE_QUERY_FRESHNESS_MS + { + return LifecycleDecision::unknown(StatusReason::Stale, now_ms, "lifecycle data is stale"); + } + + lifecycle_decision( + &cached.lifecycle, + cached.lifecycle.authority, + cached.lifecycle.reason, + now_ms, + ) +} + +fn resolve_session_status( + ownership: ProcessOwnership, + cached: Option<&CachedLifecycle>, + query_succeeded: bool, + now_ms: u64, +) -> LifecycleDecision { + if !ownership.is_confirmed() { + return LifecycleDecision::unknown( + StatusReason::OwnershipUnconfirmed, + now_ms, + "session ownership is ambiguous", + ); + } + let decision = resolve_lifecycle_status(cached, query_succeeded, now_ms); + if decision.status == SessionStatus::Unknown { + return decision; + } + + let Some(process_started_at_ms) = ownership.started_at_ms() else { + return LifecycleDecision::unknown( + StatusReason::OwnershipUnconfirmed, + now_ms, + "process start is unavailable", + ); + }; + let Some(cached) = cached else { + return LifecycleDecision::unknown( + StatusReason::ProtocolUnknown, + now_ms, + "lifecycle unavailable", + ); + }; + if cached.lifecycle.source_at_ms == 0 || cached.lifecycle.source_at_ms < process_started_at_ms { + return LifecycleDecision::unknown( + StatusReason::Stale, + now_ms, + "lifecycle predates the process", + ); + } + decision +} + +fn lifecycle_decision( + lifecycle: &DbLifecycle, + authority: StatusAuthority, + reason: StatusReason, + observed_at_ms: u64, +) -> LifecycleDecision { + let task = match lifecycle.status { + SessionStatus::Waiting => "waiting for user input", + SessionStatus::Executing => "executing", + SessionStatus::Thinking => "thinking", + SessionStatus::Idle => "idle", + SessionStatus::Error => "provider error", + _ => "lifecycle unavailable", + }; + LifecycleDecision { + status: lifecycle.status, + evidence: make_status_evidence( + lifecycle.status, + authority, + reason, + observed_at_ms, + lifecycle.since_ms, + ), + active_tool: lifecycle.active_tool.clone(), + since_ms: lifecycle.since_ms, + task: task.to_string(), + } +} + +fn make_status_evidence( + status: SessionStatus, + authority: StatusAuthority, + reason: StatusReason, + observed_at_ms: u64, + status_since_ms: u64, +) -> StatusEvidence { + let mut evidence = StatusEvidence::default(); + evidence.observe(StatusObservation::new( + status, + authority, + reason, + observed_at_ms, + 0, + )); + if status_since_ms > 0 { + evidence.status_since_ms = status_since_ms; + } + evidence +} + +fn parse_lifecycle_rows(rows: Vec) -> Option> { + let mut lifecycles = HashMap::new(); + for row in rows { + let id = row.get("id")?.as_str().filter(|id| !id.is_empty())?; + let latest_message_id = row.get("latest_message_id")?.as_str()?; + let latest_role = row.get("latest_role")?.as_str()?; + if !matches!(latest_role, "" | "user" | "assistant") { + return None; + } + let latest_created = row.get("latest_created")?.as_u64()?; + let latest_completed = row.get("latest_completed")?.as_u64()?; + let latest_has_error = match row.get("latest_has_error")?.as_u64()? { + 0 => false, + 1 => true, + _ => return None, + }; + let tool_message_id = row.get("active_tool_message_id")?.as_str()?; + let tool = + super::redact_secrets(&sanitize_db_field(row.get("active_tool")?.as_str()?, 128)); + let tool_status = row.get("active_tool_status")?.as_str()?; + if !matches!(tool_status, "" | "pending" | "running") { + return None; + } + let tool_started = row.get("active_tool_started")?.as_u64()?; + + if (latest_role.is_empty() && (!latest_message_id.is_empty() || latest_created > 0)) + || (!latest_role.is_empty() && latest_message_id.is_empty()) + || (latest_completed > 0 && latest_completed < latest_created) + || (latest_has_error && latest_role != "assistant") + { + return None; + } + + let running_question = + tool.eq_ignore_ascii_case("question") && tool_status.eq_ignore_ascii_case("running"); + let has_active_tool = matches!(tool_status, "pending" | "running"); + if has_active_tool != (!tool.is_empty() && !tool_message_id.is_empty()) { + return None; + } + let same_message_terminal_tool = has_active_tool + && tool_message_id == latest_message_id + && latest_role == "assistant" + && latest_completed > 0; + if same_message_terminal_tool && tool_started > 0 && tool_started > latest_completed { + return None; + } + let active_tool_applies = has_active_tool + && !same_message_terminal_tool + && (tool_message_id == latest_message_id || latest_role == "user"); + + let (status, authority, reason, since_ms, source_at_ms) = + if active_tool_applies && running_question { + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::ProtocolUnknown, + 0, + tool_started, + ) + } else if latest_has_error { + let source_at_ms = latest_completed.max(latest_created); + ( + SessionStatus::Error, + StatusAuthority::Heuristic, + StatusReason::CollectorInference, + source_at_ms, + source_at_ms, + ) + } else if active_tool_applies { + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::ProtocolUnknown, + 0, + tool_started, + ) + } else if latest_role == "user" { + // OpenCode persists no marker for `noReply`, and a queued + // prompt is also written before the existing runner resumes. + // A lone user row therefore cannot prove model generation. + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::ProtocolUnknown, + 0, + latest_created, + ) + } else if latest_role == "assistant" && latest_completed == 0 { + ( + SessionStatus::Thinking, + StatusAuthority::Heuristic, + StatusReason::CollectorInference, + latest_created, + latest_created, + ) + } else if latest_role == "assistant" { + ( + SessionStatus::Idle, + StatusAuthority::Heuristic, + StatusReason::CollectorInference, + latest_completed, + latest_completed, + ) + } else { + ( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::ProtocolUnknown, + 0, + 0, + ) + }; + + lifecycles.insert( + id.to_string(), + DbLifecycle { + status, + authority, + reason, + active_tool: active_tool_applies.then_some(tool), + since_ms, + source_at_ms, + }, + ); + } + Some(lifecycles) +} + +fn is_session_owning_opencode_process(command: &str) -> bool { + let tokens = command_tokens(command); + let Some(entry_index) = opencode_entry_index(&tokens) else { + return false; + }; + let first_positional = first_cli_positional(&tokens[entry_index + 1..]); + !first_positional.is_some_and(|arg| { + matches!( + arg.to_ascii_lowercase().as_str(), + "completion" + | "acp" + | "mcp" + | "attach" + | "debug" + | "providers" + | "auth" + | "agent" + | "upgrade" + | "uninstall" + | "serve" + | "web" + | "models" + | "stats" + | "export" + | "import" + | "github" + | "session" + | "plugin" + | "plug" + | "db" + ) + }) +} + +fn explicit_session_id(command: &str) -> Option { + let tokens = command_tokens(command); + let entry_index = opencode_entry_index(&tokens)?; + let args = &tokens[entry_index + 1..]; + for (index, arg) in args.iter().enumerate() { + if arg == "--" { + break; + } + if let Some(id) = arg + .strip_prefix("--session=") + .or_else(|| arg.strip_prefix("-s=")) + { + return valid_session_id(id).then(|| id.to_string()); + } + if matches!(arg.as_str(), "--session" | "-s") { + let id = args.get(index + 1)?; + return valid_session_id(id).then(|| id.to_string()); + } + } + None +} + +fn valid_session_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 512 && !id.chars().any(char::is_whitespace) +} + +fn command_mentions_session_dir(command: &str, session_key: &str) -> bool { + let tokens = command_tokens(command); + let Some(entry_index) = opencode_entry_index(&tokens) else { + return false; + }; + let args = &tokens[entry_index + 1..]; + for (index, arg) in args.iter().enumerate() { + if matches!(arg.as_str(), "--cwd" | "--directory" | "--project") + && args + .get(index + 1) + .and_then(|value| path_key(value)) + .as_deref() + == Some(session_key) + { + return true; + } + for prefix in ["--cwd=", "--directory=", "--project="] { + if arg.strip_prefix(prefix).and_then(path_key).as_deref() == Some(session_key) { + return true; + } + } + } + + // The default TUI accepts its project directory as the first positional + // argument. `run` and `pr` are session modes, not directory values; their + // process cwd is the authoritative path when it is available. + first_cli_positional(args) + .filter(|arg| !matches!(arg.to_ascii_lowercase().as_str(), "run" | "pr")) + .and_then(path_key) + .as_deref() + == Some(session_key) +} + +fn opencode_entry_index(tokens: &[String]) -> Option { + if tokens + .first() + .is_some_and(|token| token_has_name(token, "opencode")) + { + return Some(0); + } + let wrapper = tokens.first().and_then(|token| token_base(token)); + let recognized_wrapper = wrapper.is_some_and(|name| { + ["node", "bun", "deno"] + .iter() + .any(|candidate| names_equal(name, candidate)) + }); + (recognized_wrapper + && tokens + .get(1) + .is_some_and(|token| token_has_name(token, "opencode"))) + .then_some(1) +} + +fn first_cli_positional(args: &[String]) -> Option<&str> { + let mut index = 0; + while index < args.len() { + let arg = args[index].as_str(); + if arg == "--" { + return args.get(index + 1).map(String::as_str); + } + if option_takes_value(arg) { + index += 2; + continue; + } + if arg.starts_with('-') { + index += 1; + continue; + } + return Some(arg); + } + None +} + +fn option_takes_value(arg: &str) -> bool { + if arg.contains('=') { + return false; + } + matches!( + arg, + "--log-level" + | "--port" + | "--hostname" + | "--mdns-domain" + | "--cors" + | "-m" + | "--model" + | "-s" + | "--session" + | "--prompt" + | "--agent" + | "--replay-limit" + | "--cwd" + | "--directory" + | "--project" + ) +} + +fn command_tokens(command: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut quote = None; + for ch in command.chars() { + match (quote, ch) { + (Some(active), value) if value == active => quote = None, + (None, '\'' | '"') => quote = Some(ch), + (None, value) if value.is_whitespace() => { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + } + _ => current.push(ch), + } + } + if !current.is_empty() { + tokens.push(current); + } + tokens +} + +fn token_has_name(token: &str, expected: &str) -> bool { + token_base(token).is_some_and(|name| names_equal(name, expected)) +} + +fn token_base(token: &str) -> Option<&str> { + let base = token.rsplit(['/', '\\']).next()?; + for suffix in [".exe", ".js", ".mjs", ".cjs", ".sh"] { + if base.len() > suffix.len() + && base[base.len() - suffix.len()..].eq_ignore_ascii_case(suffix) + { + return base.get(..base.len() - suffix.len()); + } + } + Some(base) +} + +#[cfg(target_os = "windows")] +fn names_equal(left: &str, right: &str) -> bool { + left.eq_ignore_ascii_case(right) +} + +#[cfg(not(target_os = "windows"))] +fn names_equal(left: &str, right: &str) -> bool { + left == right +} + /// Check if a path is a symlink (fail-closed: returns true on error). fn is_symlink(path: &Path) -> bool { fs::symlink_metadata(path) @@ -464,22 +1291,26 @@ fn truncate_field(s: &mut String, max_bytes: usize) { } } -/// Compare a process cwd with a DB session directory. -/// On Windows paths are case-insensitive and may mix `/` and `\`, so -/// normalize before comparing; elsewhere keep the exact comparison. -#[cfg(target_os = "windows")] -fn paths_equal(a: &str, b: &str) -> bool { - let norm = |s: &str| { - s.replace('/', "\\") - .trim_end_matches('\\') - .to_ascii_lowercase() - }; - norm(a) == norm(b) -} +/// Produce a lexical path key for exact cwd/argv-boundary comparison. Empty +/// paths and filesystem roots are deliberately unusable ownership evidence. +fn path_key(path: &str) -> Option { + let path = path.trim().trim_matches(['\'', '"']); + if path.is_empty() { + return None; + } + #[cfg(target_os = "windows")] + let normalized = path + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase(); + #[cfg(not(target_os = "windows"))] + let normalized = path.trim_end_matches('/').to_string(); -#[cfg(not(target_os = "windows"))] -fn paths_equal(a: &str, b: &str) -> bool { - a == b + if normalized.is_empty() || normalized == "/" || normalized.ends_with(':') { + None + } else { + Some(normalized) + } } /// On Windows, OpenCode builds (e.g. installed via npm) have been observed to @@ -561,6 +1392,96 @@ fn current_time_ms() -> u64 { #[cfg(test)] mod tests { use super::*; + use serde_json::json; + + fn db_session(id: &str, directory: &str, time_updated: u64) -> DbSession { + DbSession { + id: id.to_string(), + title: id.to_string(), + directory: directory.to_string(), + version: String::new(), + time_created: time_updated.saturating_sub(10), + time_updated, + project_name: String::new(), + turn_count: 0, + total_input: 0, + total_output: 0, + total_cache_read: 0, + total_cache_write: 0, + model: String::new(), + provider: String::new(), + } + } + + fn live_process(pid: u32, command: &str, cwd: Option<&str>) -> LiveOpenCodeProcess { + LiveOpenCodeProcess { + pid, + command: command.to_string(), + cwd: cwd.map(str::to_string), + started_at_ms: Some(50), + } + } + + fn confirmed(pid: u32) -> ProcessOwnership { + ProcessOwnership::Confirmed { + pid, + started_at_ms: 50, + } + } + + #[allow(clippy::too_many_arguments)] + fn lifecycle_row( + id: &str, + latest_role: &str, + latest_created: u64, + latest_completed: u64, + latest_has_error: bool, + active_tool: &str, + active_tool_status: &str, + active_tool_started: u64, + active_tool_message_id: Option<&str>, + ) -> Value { + let latest_message_id = if latest_role.is_empty() { + "" + } else { + "latest-message" + }; + json!({ + "id": id, + "latest_message_id": latest_message_id, + "latest_role": latest_role, + "latest_created": latest_created, + "latest_completed": latest_completed, + "latest_has_error": u8::from(latest_has_error), + "active_tool_message_id": active_tool_message_id.unwrap_or(""), + "active_tool": active_tool, + "active_tool_status": active_tool_status, + "active_tool_started": active_tool_started, + }) + } + + fn db_lifecycle(status: SessionStatus, since_ms: u64) -> DbLifecycle { + let (authority, reason) = match status { + SessionStatus::Waiting + | SessionStatus::Executing + | SessionStatus::Error + | SessionStatus::Thinking + | SessionStatus::Idle => (StatusAuthority::Heuristic, StatusReason::CollectorInference), + _ => (StatusAuthority::Unavailable, StatusReason::ProtocolUnknown), + }; + DbLifecycle { + status, + authority, + reason, + active_tool: match status { + SessionStatus::Waiting => Some("question".to_string()), + SessionStatus::Executing => Some("bash".to_string()), + _ => None, + }, + since_ms, + source_at_ms: since_ms, + } + } #[test] fn test_find_opencode_pids() { @@ -582,7 +1503,7 @@ mod tests { ppid: 1, rss_kb: 500, cpu_pct: 0.0, - command: "grep opencode".to_string(), + command: "rg opencode".to_string(), }, ); info.insert( @@ -597,11 +1518,42 @@ mod tests { ); let pids = OpenCodeCollector::find_opencode_pids(&info); assert!(pids.contains(&100)); - assert!(!pids.contains(&200)); // grep excluded + assert!(!pids.contains(&200)); assert!(pids.contains(&300)); assert_eq!(pids.len(), 2); } + #[test] + fn process_classifier_rejects_unrelated_wrappers_and_non_session_modes() { + for command in [ + "rg opencode", + "less /tmp/opencode", + "python /tmp/opencode", + "opencode serve", + "opencode web --port 3000", + "opencode acp", + "opencode session list", + "opencode export ses_123", + ] { + assert!( + !is_session_owning_opencode_process(command), + "unexpected session owner: {command}" + ); + } + for command in [ + "/usr/local/bin/opencode", + "node /usr/lib/node_modules/opencode/bin/opencode run fix", + "opencode --session ses_123", + "opencode run grep opencode", + "opencode /work/project", + ] { + assert!( + is_session_owning_opencode_process(command), + "missed session owner: {command}" + ); + } + } + #[test] fn test_db_path_default() { let collector = OpenCodeCollector::new(); @@ -627,70 +1579,613 @@ mod tests { } #[test] - fn match_pid_short_session_dir_never_matches() { - // Regression: previously, an empty `directory` made `cmd.contains("")` - // always true, and `/` matched every absolute path. Both should fail - // the length guard now, regardless of how many opencode procs run. - let mut pid_commands: HashMap = HashMap::new(); - pid_commands.insert(100, "/usr/local/bin/opencode"); - pid_commands.insert(200, "/usr/local/bin/opencode --foo"); + fn empty_and_root_paths_are_not_ownership_evidence() { + assert_eq!(path_key(""), None); + assert_eq!(path_key("/"), None); + } + + #[test] + fn command_path_matching_requires_an_exact_argument_boundary() { + let key = path_key("/home/u/proj-a").unwrap(); + assert!(command_mentions_session_dir( + "node /usr/bin/opencode run --cwd=/home/u/proj-a", + &key + )); + assert!(command_mentions_session_dir( + "opencode \"/home/u/proj-a\"", + &key + )); + assert!(!command_mentions_session_dir( + "opencode /home/u/proj-ab", + &key + )); + assert!(!command_mentions_session_dir( + "/home/u/proj-a/bin/opencode", + &key + )); + } + + #[test] + fn unique_directory_ownership_is_confirmed() { + let sessions = vec![db_session("current", "/work/project", 100)]; + let processes = vec![live_process(42, "opencode", Some("/work/project"))]; assert_eq!( - OpenCodeCollector::match_pid_to_session(&pid_commands, ""), - None + plan_session_ownership(&sessions, &processes), + vec![PlannedSessionOwnership { + session_index: 0, + ownership: confirmed(42), + }] ); + } + + #[test] + fn historical_row_cannot_own_a_new_process_incarnation() { + let sessions = vec![db_session("stale", "/work/project", 100)]; + let mut process = live_process(42, "opencode", Some("/work/project")); + process.started_at_ms = Some(100_000); assert_eq!( - OpenCodeCollector::match_pid_to_session(&pid_commands, "/"), - None + plan_session_ownership(&sessions, &[process]), + vec![PlannedSessionOwnership { + session_index: 0, + ownership: ProcessOwnership::Unconfirmed, + }] + ); + } + + #[test] + fn same_cwd_ambiguity_never_assigns_an_actionable_pid() { + let sessions = vec![ + db_session("older", "/work/project", 100), + db_session("newer", "/work/project", 200), + ]; + let processes = vec![ + live_process(20, "opencode", Some("/work/project")), + live_process(10, "opencode", Some("/work/project")), + ]; + let forward = plan_session_ownership(&sessions, &processes); + let reverse = plan_session_ownership( + &sessions, + &processes.iter().cloned().rev().collect::>(), ); + assert_eq!(forward, reverse); + assert_eq!(forward.len(), 2); + assert!(forward + .iter() + .all(|row| row.ownership == ProcessOwnership::Unconfirmed)); } #[test] - fn match_pid_no_last_resort_when_cwd_and_cmdline_disagree() { - // Regression: previously, when exactly one opencode process was - // running, the `pid_commands.len() == 1` last-resort branch matched - // every DB session to it, even when neither the cwd nor the command - // line matched. With the fallback removed, this returns None. - let mut pid_commands: HashMap = HashMap::new(); - // u32::MAX is a synthetic PID that has no /proc//cwd entry, - // so the cwd branch can't accidentally succeed. - pid_commands.insert(u32::MAX, "/usr/local/bin/opencode"); + fn one_pid_with_historical_rows_emits_only_newest_unknown_candidate() { + let sessions = vec![ + db_session("old", "/work/project", 100), + db_session("current", "/work/project", 300), + db_session("middle", "/work/project", 200), + ]; + let plan = plan_session_ownership( + &sessions, + &[live_process(42, "opencode", Some("/work/project"))], + ); assert_eq!( - OpenCodeCollector::match_pid_to_session(&pid_commands, "/home/u/proj-a"), - None + plan, + vec![PlannedSessionOwnership { + session_index: 1, + ownership: ProcessOwnership::Unconfirmed, + }] + ); + } + + #[test] + fn explicit_session_ids_disambiguate_same_cwd_processes() { + let sessions = vec![ + db_session("ses_a", "/work/project", 100), + db_session("ses_b", "/work/project", 200), + ]; + let plan = plan_session_ownership( + &sessions, + &[ + live_process(20, "opencode --session ses_b", Some("/work/project")), + live_process(10, "opencode -s=ses_a", Some("/work/project")), + ], ); + assert_eq!(plan.len(), 2); + assert!(plan + .iter() + .any(|row| { row.session_index == 0 && row.ownership == confirmed(10) })); + assert!(plan + .iter() + .any(|row| { row.session_index == 1 && row.ownership == confirmed(20) })); } #[test] - fn match_pid_substring_fallback_still_works() { - let mut pid_commands: HashMap = HashMap::new(); - pid_commands.insert(u32::MAX, "node /usr/bin/opencode run --cwd=/home/u/proj-a"); + fn explicit_session_without_exact_process_start_is_not_confirmed() { + let sessions = vec![db_session("ses_a", "/work/project", 100)]; + let mut process = live_process(10, "opencode --session ses_a", Some("/work/project")); + process.started_at_ms = None; + assert_eq!( - OpenCodeCollector::match_pid_to_session(&pid_commands, "/home/u/proj-a"), - Some(u32::MAX) + plan_session_ownership(&sessions, &[process]), + vec![PlannedSessionOwnership { + session_index: 0, + ownership: ProcessOwnership::Unconfirmed, + }], ); } #[test] - fn match_pid_to_session_once_does_not_reuse_pid_for_old_rows() { - let mut pid_commands: HashMap = HashMap::new(); - pid_commands.insert(u32::MAX, "node /usr/bin/opencode run --cwd=/home/u/proj-a"); - let mut claimed_pids = std::collections::HashSet::new(); + fn lifecycle_parser_separates_durable_and_ambiguous_states() { + let rows = vec![ + lifecycle_row( + "wait", + "assistant", + 100, + 0, + false, + "question", + "running", + 110, + Some("latest-message"), + ), + lifecycle_row( + "pending-question", + "assistant", + 200, + 0, + false, + "question", + "pending", + 210, + Some("latest-message"), + ), + lifecycle_row( + "exec", + "assistant", + 300, + 0, + false, + "bash", + "running", + 310, + Some("latest-message"), + ), + lifecycle_row("no-reply", "user", 400, 0, false, "", "", 0, None), + lifecycle_row( + "think-assistant", + "assistant", + 500, + 0, + false, + "", + "", + 0, + None, + ), + lifecycle_row("idle", "assistant", 600, 650, false, "", "", 0, None), + lifecycle_row("error", "assistant", 700, 750, true, "", "", 0, None), + lifecycle_row("empty", "", 0, 0, false, "", "", 0, None), + ]; + let states = parse_lifecycle_rows(rows).expect("valid lifecycle rows"); + assert_eq!(states["wait"].status, SessionStatus::Unknown); + assert_eq!(states["wait"].authority, StatusAuthority::Unavailable); + assert_eq!(states["wait"].source_at_ms, 110); + assert_eq!(states["pending-question"].status, SessionStatus::Unknown); + assert_eq!(states["exec"].status, SessionStatus::Unknown); + assert_eq!(states["exec"].active_tool.as_deref(), Some("bash")); + assert_eq!(states["no-reply"].status, SessionStatus::Unknown); + assert_eq!(states["think-assistant"].status, SessionStatus::Thinking); assert_eq!( - OpenCodeCollector::match_pid_to_session_once( - &pid_commands, - "/home/u/proj-a", - &mut claimed_pids, + states["think-assistant"].authority, + StatusAuthority::Heuristic + ); + assert_eq!(states["idle"].status, SessionStatus::Idle); + assert_eq!(states["idle"].authority, StatusAuthority::Heuristic); + assert_eq!(states["error"].status, SessionStatus::Error); + assert_eq!(states["error"].reason, StatusReason::CollectorInference); + assert_eq!(states["empty"].status, SessionStatus::Unknown); + } + + #[test] + fn lifecycle_parser_sanitizes_tool_name() { + let states = parse_lifecycle_rows(vec![lifecycle_row( + "tool", + "assistant", + 1, + 0, + false, + "ba\u{202e}\u{0008}sh", + "running", + 2, + Some("latest-message"), + )]) + .expect("valid lifecycle row"); + assert_eq!(states.len(), 1); + assert_eq!(states["tool"].active_tool.as_deref(), Some("bash")); + } + + #[test] + fn session_wide_active_question_and_tool_override_a_queued_user_row() { + let rows = vec![ + lifecycle_row( + "queued-question", + "user", + 500, + 0, + false, + "question", + "running", + 400, + Some("prior-assistant"), + ), + lifecycle_row( + "queued-tool", + "user", + 700, + 0, + false, + "bash", + "running", + 600, + Some("prior-assistant"), ), - Some(u32::MAX) + ]; + let states = parse_lifecycle_rows(rows).expect("valid queued lifecycle rows"); + + assert_eq!(states["queued-question"].status, SessionStatus::Unknown); + assert_eq!(states["queued-question"].source_at_ms, 400); + assert_eq!(states["queued-tool"].status, SessionStatus::Unknown); + assert_eq!(states["queued-tool"].source_at_ms, 600); + } + + #[test] + fn older_active_tool_does_not_override_a_newer_assistant_lifecycle() { + let row = lifecycle_row( + "new-assistant", + "assistant", + 500, + 550, + false, + "question", + "running", + 400, + Some("prior-assistant"), ); - assert_eq!( - OpenCodeCollector::match_pid_to_session_once( - &pid_commands, - "/home/u/proj-a", - &mut claimed_pids, + let states = parse_lifecycle_rows(vec![row]).expect("valid lifecycle row"); + + assert_eq!(states["new-assistant"].status, SessionStatus::Idle); + assert_eq!(states["new-assistant"].active_tool, None); + assert_eq!(states["new-assistant"].source_at_ms, 550); + } + + #[test] + fn completed_assistant_supersedes_same_message_running_parts() { + let rows = vec![ + lifecycle_row( + "completed-question", + "assistant", + 500, + 550, + false, + "question", + "running", + 510, + Some("latest-message"), + ), + lifecycle_row( + "completed-tool", + "assistant", + 600, + 650, + false, + "bash", + "pending", + 610, + Some("latest-message"), + ), + lifecycle_row( + "failed-tool", + "assistant", + 700, + 750, + true, + "bash", + "running", + 710, + Some("latest-message"), ), - None + ]; + let states = parse_lifecycle_rows(rows).expect("valid terminal lifecycle rows"); + + assert_eq!(states["completed-question"].status, SessionStatus::Idle); + assert_eq!(states["completed-tool"].status, SessionStatus::Idle); + assert_eq!(states["failed-tool"].status, SessionStatus::Error); + assert!(states.values().all(|state| state.active_tool.is_none())); + } + + #[test] + fn malformed_lifecycle_row_fails_the_entire_parse_closed() { + let valid = lifecycle_row("valid", "assistant", 1, 2, false, "", "", 0, None); + for malformed in [ + json!({"id": ""}), + { + let mut row = lifecycle_row("bad-role", "assistant", 1, 0, false, "", "", 0, None); + row["latest_role"] = json!("unexpected"); + row + }, + { + let mut row = lifecycle_row( + "bad-tool-state", + "assistant", + 1, + 0, + false, + "bash", + "running", + 1, + Some("latest-message"), + ); + row["active_tool_status"] = json!("mystery"); + row + }, + { + let mut row = valid.clone(); + row["latest_created"] = json!("now"); + row + }, + ] { + assert!(parse_lifecycle_rows(vec![valid.clone(), malformed]).is_none()); + } + } + + #[test] + fn fresh_inferred_lifecycle_reports_heuristic_authority() { + let cached = CachedLifecycle { + lifecycle: db_lifecycle(SessionStatus::Idle, 900), + observed_at_ms: 1_000, + }; + let decision = resolve_lifecycle_status(Some(&cached), true, 1_100); + assert_eq!(decision.status, SessionStatus::Idle); + assert_eq!(decision.evidence.authority, StatusAuthority::Heuristic); + assert_eq!(decision.evidence.reason, StatusReason::CollectorInference); + assert_eq!(decision.evidence.status_since_ms, 900); + } + + #[test] + fn failed_lifecycle_query_immediately_invalidates_every_cached_status() { + for status in [ + SessionStatus::Waiting, + SessionStatus::Executing, + SessionStatus::Thinking, + SessionStatus::Idle, + ] { + let cached = CachedLifecycle { + lifecycle: db_lifecycle(status, 900), + observed_at_ms: 1_000, + }; + let decision = resolve_lifecycle_status(Some(&cached), false, 1_001); + assert_eq!(decision.status, SessionStatus::Unknown, "cached={status:?}"); + assert_eq!( + decision.evidence.authority, + StatusAuthority::Unavailable, + "cached={status:?}" + ); + assert_eq!( + decision.evidence.reason, + StatusReason::BackgroundProbeFailed, + "cached={status:?}" + ); + assert_eq!(decision.active_tool, None, "cached={status:?}"); + } + } + + #[test] + fn successful_but_stale_lifecycle_cache_is_unavailable() { + let cached = CachedLifecycle { + lifecycle: db_lifecycle(SessionStatus::Waiting, 900), + observed_at_ms: 1_000, + }; + let decision = + resolve_lifecycle_status(Some(&cached), true, 1_001 + LIFECYCLE_QUERY_FRESHNESS_MS); + assert_eq!(decision.status, SessionStatus::Unknown); + assert_eq!(decision.evidence.authority, StatusAuthority::Unavailable); + assert_eq!(decision.evidence.reason, StatusReason::Stale); + } + + #[test] + fn resolved_question_race_does_not_publish_waiting() { + // OpenCode removes the live pending question before the tool runner + // persists completion, so this exact DB row can also mean "already + // answered". Fresh query time cannot disambiguate that interval. + let lifecycle = parse_lifecycle_rows(vec![lifecycle_row( + "question", + "assistant", + 800, + 0, + false, + "question", + "running", + 900, + Some("latest-message"), + )]) + .expect("valid question row") + .remove("question") + .unwrap(); + let cached = CachedLifecycle { + lifecycle, + observed_at_ms: 1_000, + }; + let decision = resolve_lifecycle_status(Some(&cached), true, 1_001); + + assert_eq!(decision.status, SessionStatus::Unknown); + assert_eq!(decision.evidence.authority, StatusAuthority::Unavailable); + assert_eq!(decision.evidence.reason, StatusReason::ProtocolUnknown); + } + + #[test] + fn inferred_error_uses_a_canonical_non_content_task_label() { + let cached = CachedLifecycle { + lifecycle: db_lifecycle(SessionStatus::Error, 900), + observed_at_ms: 1_000, + }; + let decision = resolve_lifecycle_status(Some(&cached), true, 1_001); + + assert_eq!(decision.status, SessionStatus::Error); + assert_eq!(decision.task, "provider error"); + assert_eq!(decision.evidence.reason, StatusReason::CollectorInference); + } + + #[test] + fn ambiguous_ownership_overrides_fresh_lifecycle_status() { + let cached = CachedLifecycle { + lifecycle: db_lifecycle(SessionStatus::Executing, 900), + observed_at_ms: 1_000, + }; + let decision = + resolve_session_status(ProcessOwnership::Unconfirmed, Some(&cached), true, 1_100); + assert_eq!(decision.status, SessionStatus::Unknown); + assert_eq!(decision.evidence.authority, StatusAuthority::Unavailable); + assert_eq!(decision.evidence.reason, StatusReason::OwnershipUnconfirmed); + } + + #[test] + fn persisted_lifecycle_from_before_exact_process_start_is_unknown() { + for status in [ + SessionStatus::Waiting, + SessionStatus::Executing, + SessionStatus::Thinking, + SessionStatus::Idle, + SessionStatus::Error, + ] { + let cached = CachedLifecycle { + lifecycle: db_lifecycle(status, 1_000), + observed_at_ms: 20_000, + }; + let decision = resolve_session_status( + ProcessOwnership::Confirmed { + pid: 42, + started_at_ms: 10_000, + }, + Some(&cached), + true, + 20_001, + ); + assert_eq!(decision.status, SessionStatus::Unknown, "status={status:?}"); + assert_eq!( + decision.evidence.authority, + StatusAuthority::Unavailable, + "status={status:?}", + ); + assert_eq!( + decision.evidence.reason, + StatusReason::Stale, + "status={status:?}", + ); + } + } + + #[test] + fn lifecycle_at_or_after_exact_process_start_remains_available() { + let cached = CachedLifecycle { + lifecycle: db_lifecycle(SessionStatus::Error, 10_000), + observed_at_ms: 10_100, + }; + let decision = resolve_session_status( + ProcessOwnership::Confirmed { + pid: 42, + started_at_ms: 10_000, + }, + Some(&cached), + true, + 10_101, + ); + + assert_eq!(decision.status, SessionStatus::Error); + assert_eq!(decision.evidence.authority, StatusAuthority::Heuristic); + } + + #[test] + fn missing_lifecycle_after_query_failure_is_unavailable() { + let decision = resolve_lifecycle_status(None, false, 1_000); + assert_eq!(decision.status, SessionStatus::Unknown); + assert_eq!(decision.evidence.authority, StatusAuthority::Unavailable); + assert_eq!( + decision.evidence.reason, + StatusReason::BackgroundProbeFailed + ); + } + + #[test] + fn lifecycle_query_reads_session_wide_active_state_without_private_content() { + if Command::new("sqlite3").arg("--version").output().is_err() { + return; + } + + let temp = tempfile::tempdir().expect("create temp directory"); + let db_path = temp.path().join("opencode.db"); + let schema_and_rows = r#" +CREATE TABLE session (id TEXT PRIMARY KEY, time_updated INTEGER NOT NULL); +CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + data TEXT NOT NULL +); +CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL +); +INSERT INTO session VALUES + ('waiting', 1000), ('executing', 900), ('queued', 850), ('no-reply', 825), + ('error', 810), ('aborted', 805), ('idle', 800); +INSERT INTO message VALUES + ('w-old', 'waiting', 10, '{"role":"assistant","time":{"created":10,"completed":20}}'), + ('w-new', 'waiting', 30, '{"role":"assistant","time":{"created":30}}'), + ('e-new', 'executing', 40, '{"role":"assistant","time":{"created":40}}'), + ('q-old', 'queued', 41, '{"role":"assistant","time":{"created":41}}'), + ('q-new', 'queued', 42, '{"role":"user","time":{"created":42}}'), + ('n-new', 'no-reply', 45, '{"role":"user","time":{"created":45}}'), + ('err-new', 'error', 48, '{"role":"assistant","time":{"created":48,"completed":49},"finish":"error","error":{"name":"APIError","data":{"message":"must-not-be-read"}}}'), + ('abort-new', 'aborted', 49, '{"role":"assistant","time":{"created":49,"completed":50},"finish":"error","error":{"name":"MessageAbortedError","data":{"message":"must-not-be-read"}}}'), + ('i-new', 'idle', 50, '{"role":"assistant","time":{"created":50,"completed":60}}'); +INSERT INTO part VALUES + ('stale', 'w-old', 'waiting', 20, '{"type":"tool","tool":"bash","state":{"status":"running","time":{"start":11},"input":{"secret":"must-not-be-read"}}}'), + ('question', 'w-new', 'waiting', 31, '{"type":"tool","tool":"question","state":{"status":"running","time":{"start":31},"input":{"secret":"must-not-be-read"}}}'), + ('bash', 'e-new', 'executing', 41, '{"type":"tool","tool":"bash","state":{"status":"pending","raw":"must-not-be-read"}}'), + ('queued-question', 'q-old', 'queued', 41, '{"type":"tool","tool":"question","state":{"status":"running","time":{"start":41}}}'); +"#; + let created = Command::new("sqlite3") + .arg(&db_path) + .arg(schema_and_rows) + .output() + .expect("create SQLite fixture"); + assert!( + created.status.success(), + "sqlite3 fixture failed: {}", + String::from_utf8_lossy(&created.stderr) ); + + let collector = OpenCodeCollector { + db_path, + sqlite3_available: Some(true), + cached_db_sessions: vec![], + cached_db_lifecycles: HashMap::new(), + lifecycle_query_succeeded: false, + #[cfg(target_os = "windows")] + warned_sqlite3_missing: false, + }; + let states = collector.query_lifecycles().expect("query lifecycle"); + + assert_eq!(states["waiting"].status, SessionStatus::Unknown); + assert_eq!(states["waiting"].active_tool.as_deref(), Some("question")); + assert_eq!(states["executing"].status, SessionStatus::Unknown); + assert_eq!(states["executing"].active_tool.as_deref(), Some("bash")); + assert_eq!(states["queued"].status, SessionStatus::Unknown); + assert_eq!(states["queued"].active_tool.as_deref(), Some("question")); + assert_eq!(states["no-reply"].status, SessionStatus::Unknown); + assert_eq!(states["error"].status, SessionStatus::Error); + assert_eq!(states["aborted"].status, SessionStatus::Idle); + assert_eq!(states["idle"].status, SessionStatus::Idle); } } diff --git a/src/collector/process.rs b/src/collector/process.rs index a700f8f..eb23404 100644 --- a/src/collector/process.rs +++ b/src/collector/process.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; +use std::ffi::OsString; #[cfg(target_os = "linux")] use std::fs; +use std::path::PathBuf; use std::process::Command; #[derive(Debug)] @@ -12,6 +14,614 @@ pub struct ProcInfo { pub command: String, } +/// Return the OS-reported executable for one exact live PID. +/// +/// This does not inspect or reinterpret argv. Callers that bind a security +/// decision to the result must separately verify the process incarnation +/// before and after this lookup. +#[cfg(target_os = "linux")] +pub fn get_process_executable(pid: u32) -> Option { + fs::read_link(format!("/proc/{pid}/exe")).ok() +} + +#[cfg(target_vendor = "apple")] +pub fn get_process_executable(pid: u32) -> Option { + use std::ffi::CStr; + use std::os::raw::{c_int, c_void}; + use std::os::unix::ffi::OsStrExt; + + const PROC_PIDPATHINFO_MAXSIZE: usize = 4096; + #[link(name = "proc")] + unsafe extern "C" { + fn proc_pidpath(pid: c_int, buffer: *mut c_void, buffer_size: u32) -> c_int; + } + + let pid = c_int::try_from(pid).ok()?; + let mut buffer = vec![0_u8; PROC_PIDPATHINFO_MAXSIZE]; + // SAFETY: the buffer is writable for its full reported capacity and the + // PID conversion above is exact. + let length = unsafe { + proc_pidpath( + pid, + buffer.as_mut_ptr().cast(), + u32::try_from(buffer.len()).ok()?, + ) + }; + if length <= 0 || usize::try_from(length).ok()? >= buffer.len() { + return None; + } + let path = CStr::from_bytes_until_nul(&buffer).ok()?; + if path.to_bytes().is_empty() { + return None; + } + Some(PathBuf::from(std::ffi::OsStr::from_bytes(path.to_bytes()))) +} + +#[cfg(target_os = "windows")] +pub fn get_process_executable(pid: u32) -> Option { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + // SAFETY: the requested access is read-only and `pid` is passed by value. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if process.is_null() { + return None; + } + let mut buffer = vec![0_u16; 32_768]; + let mut length = 32_768_u32; + // SAFETY: `buffer` is writable for `length` UTF-16 code units and the + // process handle remains live until the matching CloseHandle below. + let succeeded = unsafe { + QueryFullProcessImageNameW(process, 0, buffer.as_mut_ptr(), &mut length as *mut u32) + }; + // SAFETY: this function owns the non-null process handle. + unsafe { CloseHandle(process) }; + if succeeded == 0 || length == 0 { + return None; + } + buffer.truncate(usize::try_from(length).ok()?); + Some(PathBuf::from(std::ffi::OsString::from_wide(&buffer))) +} + +#[cfg(not(any(target_os = "linux", target_vendor = "apple", target_os = "windows")))] +pub fn get_process_executable(_pid: u32) -> Option { + None +} + +/// Return the exact OS argv vector for one live PID, preserving empty and +/// non-UTF-8 arguments where the platform exposes them. +#[cfg(target_os = "linux")] +pub fn get_process_argv(pid: u32) -> Option> { + let bytes = fs::read(format!("/proc/{pid}/cmdline")).ok()?; + parse_linux_cmdline(&bytes) +} + +#[cfg(any(target_os = "linux", all(test, unix)))] +fn parse_linux_cmdline(bytes: &[u8]) -> Option> { + use std::os::unix::ffi::OsStringExt; + + if bytes.is_empty() || *bytes.last()? != 0 { + return None; + } + let mut parts = bytes.split(|byte| *byte == 0).collect::>(); + let terminator = parts.pop()?; + if !terminator.is_empty() { + return None; + } + let argv = parts + .into_iter() + .map(|argument| OsString::from_vec(argument.to_vec())) + .collect::>(); + (!argv.is_empty()).then_some(argv) +} + +#[cfg(target_vendor = "apple")] +pub fn get_process_argv(pid: u32) -> Option> { + use std::os::raw::{c_int, c_void}; + use std::os::unix::ffi::OsStringExt; + + const CTL_KERN: c_int = 1; + const KERN_ARGMAX: c_int = 8; + const KERN_PROCARGS2: c_int = 49; + + let pid = c_int::try_from(pid).ok()?; + let mut argmax: c_int = 0; + let mut argmax_size = std::mem::size_of::(); + let mut argmax_mib = [CTL_KERN, KERN_ARGMAX]; + // SAFETY: the MIB and output pointers describe initialized writable + // storage of the exact lengths supplied to sysctl. + if unsafe { + libc::sysctl( + argmax_mib.as_mut_ptr(), + u32::try_from(argmax_mib.len()).ok()?, + (&mut argmax as *mut c_int).cast::(), + &mut argmax_size, + std::ptr::null_mut(), + 0, + ) + } != 0 + || argmax <= 0 + { + return None; + } + let mut buffer = vec![0_u8; usize::try_from(argmax).ok()?]; + let mut size = buffer.len(); + let mut args_mib = [CTL_KERN, KERN_PROCARGS2, pid]; + // SAFETY: the MIB is valid for KERN_PROCARGS2 and `buffer` is writable for + // the byte count carried in `size`. + if unsafe { + libc::sysctl( + args_mib.as_mut_ptr(), + u32::try_from(args_mib.len()).ok()?, + buffer.as_mut_ptr().cast::(), + &mut size, + std::ptr::null_mut(), + 0, + ) + } != 0 + || size < std::mem::size_of::() + { + return None; + } + buffer.truncate(size); + let argc = c_int::from_ne_bytes(buffer.get(..4)?.try_into().ok()?); + let argc = usize::try_from(argc).ok()?; + if argc == 0 || argc > 65_536 { + return None; + } + let mut cursor = std::mem::size_of::(); + cursor += buffer.get(cursor..)?.iter().position(|byte| *byte == 0)? + 1; + while buffer.get(cursor) == Some(&0) { + cursor += 1; + } + let mut argv = Vec::with_capacity(argc); + for _ in 0..argc { + let remaining = buffer.get(cursor..)?; + let length = remaining.iter().position(|byte| *byte == 0)?; + argv.push(OsString::from_vec(remaining[..length].to_vec())); + cursor = cursor.checked_add(length + 1)?; + } + Some(argv) +} + +#[cfg(target_os = "windows")] +pub fn get_process_argv(pid: u32) -> Option> { + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; + + let mut system = System::new(); + let pid = Pid::from_u32(pid); + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[pid]), + false, + ProcessRefreshKind::new().with_cmd(UpdateKind::Always), + ); + let argv = system.process(pid)?.cmd().to_vec(); + (!argv.is_empty()).then_some(argv) +} + +#[cfg(not(any(target_os = "linux", target_vendor = "apple", target_os = "windows")))] +pub fn get_process_argv(_pid: u32) -> Option> { + None +} + +/// Return one live process's argv as losslessly separated UTF-8 tokens. +/// +/// Non-UTF-8 argv fails closed because provider/session classification must not +/// reinterpret a lossy executable or session identifier. Callers making an +/// ownership decision must bracket this lookup with exact incarnation reads. +pub(crate) fn get_process_tokens(pid: u32) -> Option> { + get_process_argv(pid)? + .into_iter() + .map(|argument| argument.into_string().ok()) + .collect() +} + +/// Check executable positions in an already separated argv observation. +pub(crate) fn tokens_have_binary(tokens: &[String], name: &str) -> bool { + tokens + .iter() + .take(2) + .any(|token| token_has_binary(token, name)) +} + +/// Split the command representation returned by the local process scanner. +/// This is intentionally small and shell-independent: it preserves simple +/// single/double-quoted executable paths without evaluating escapes or shell +/// expansions. +pub(crate) fn command_tokens(command: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut token_started = false; + for ch in command.chars() { + match (quote, ch) { + (Some(active), value) if value == active => quote = None, + (None, '\'' | '"') if !token_started => { + quote = Some(ch); + token_started = true; + } + (None, '\'' | '"') => { + current.push(ch); + token_started = true; + } + (None, value) if value.is_whitespace() => { + if token_started { + tokens.push(std::mem::take(&mut current)); + token_started = false; + } + } + _ => { + current.push(ch); + token_started = true; + } + } + } + if token_started { + tokens.push(current); + } + tokens +} + +#[cfg(any(test, target_os = "linux", target_os = "windows"))] +fn join_command_args(args: impl IntoIterator) -> String { + args.into_iter() + .map(|arg| { + if arg.is_empty() { + "\"\"".to_string() + } else if !arg + .chars() + .any(|character| character.is_whitespace() || matches!(character, '\'' | '"')) + { + arg + } else if !arg.contains('"') { + format!("\"{arg}\"") + } else if !arg.contains('\'') { + format!("'{arg}'") + } else { + // This representation is used for process classification, not + // shell execution. Preserve the most useful argv boundaries + // even for the exceedingly rare argument containing both + // quote styles. + arg.chars() + .map(|ch| if ch.is_whitespace() { '_' } else { ch }) + .collect() + } + }) + .collect::>() + .join(" ") +} + +/// Return the current working directory of a process. +/// +/// Linux exposes this directly through procfs, Windows is handled by +/// `sysinfo`, and other Unix platforms use the process's `cwd` descriptor +/// reported by `lsof`. +#[cfg(target_os = "linux")] +pub fn get_process_cwd(pid: u32) -> Option { + fs::read_link(format!("/proc/{pid}/cwd")) + .ok() + .map(|path| path.to_string_lossy().into_owned()) +} + +#[cfg(target_os = "windows")] +pub fn get_process_cwd(pid: u32) -> Option { + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; + + let mut system = System::new(); + let pid = Pid::from_u32(pid); + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[pid]), + false, + ProcessRefreshKind::new().with_cwd(UpdateKind::Always), + ); + system + .process(pid) + .and_then(|process| process.cwd()) + .map(|path| path.to_string_lossy().into_owned()) +} + +#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] +pub fn get_process_cwd(pid: u32) -> Option { + // `-a` ANDs the selectors. Without it, lsof can return cwd descriptors + // for unrelated processes as well as descriptors belonging to `pid`. + let pid = pid.to_string(); + let output = Command::new("lsof") + .args(["-a", "-p", &pid, "-d", "cwd", "-Fn"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + String::from_utf8_lossy(&output.stdout) + .lines() + .find_map(|line| line.strip_prefix('n').filter(|path| !path.is_empty())) + .map(str::to_string) +} + +/// Return the process start time as milliseconds since the Unix epoch. +#[cfg(target_os = "linux")] +pub fn get_process_started_at_ms(pid: u32) -> Option { + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + // The comm field is parenthesized and may itself contain spaces or `)`. + let fields: Vec<&str> = stat + .get(stat.rfind(')')? + 1..)? + .split_whitespace() + .collect(); + // After comm: state is field 3, while starttime is field 22. + let start_ticks = fields.get(19)?.parse::().ok()?; + let boot_secs = fs::read_to_string("/proc/stat") + .ok()? + .lines() + .find_map(|line| line.strip_prefix("btime "))? + .trim() + .parse::() + .ok()?; + let ticks_per_second = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if ticks_per_second <= 0 { + return None; + } + + let start_offset_ms = (u128::from(start_ticks) * 1_000) / ticks_per_second as u128; + let started_at = u128::from(boot_secs) * 1_000 + start_offset_ms; + u64::try_from(started_at).ok() +} + +#[cfg(target_os = "windows")] +pub fn get_process_started_at_ms(pid: u32) -> Option { + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + + let mut system = System::new(); + let pid = Pid::from_u32(pid); + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[pid]), + false, + ProcessRefreshKind::new(), + ); + let started_at = system.process(pid)?.start_time(); + (started_at != 0).then(|| started_at.saturating_mul(1_000)) +} + +#[cfg(target_os = "macos")] +pub fn get_process_started_at_ms(pid: u32) -> Option { + use proc_pidinfo::{proc_pidinfo, Pid, ProcBSDInfo}; + + let info = proc_pidinfo::(Pid(pid)).ok().flatten()?; + if info.pbi_start_tvsec == 0 || info.pbi_start_tvusec >= 1_000_000 { + return None; + } + + info.pbi_start_tvsec + .checked_mul(1_000)? + .checked_add(info.pbi_start_tvusec / 1_000) +} + +#[cfg(all( + not(target_os = "linux"), + not(target_os = "windows"), + not(target_os = "macos") +))] +pub fn get_process_started_at_ms(pid: u32) -> Option { + use chrono::{Local, LocalResult, NaiveDateTime, TimeZone}; + + let pid = pid.to_string(); + let output = Command::new("ps") + .args(["-p", &pid, "-o", "lstart="]) + .env("LC_ALL", "C") + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let raw = String::from_utf8_lossy(&output.stdout); + let naive = NaiveDateTime::parse_from_str(raw.trim(), "%a %b %e %H:%M:%S %Y").ok()?; + let started_at = match Local.from_local_datetime(&naive) { + LocalResult::Single(value) => value, + // A process can start during the repeated hour at the end of DST. + // Choosing the earlier occurrence is conservative for PID-reuse checks. + LocalResult::Ambiguous(earlier, later) => earlier.min(later), + LocalResult::None => return None, + }; + u64::try_from(started_at.timestamp_millis()).ok() +} + +/// Return an opaque, exact process-incarnation marker for `pid`. +/// +/// Unlike [`get_process_started_at_ms`], this value is never rounded or +/// reconstructed from a wall-clock estimate. Callers must treat it as opaque +/// and compare it only for exact equality while also matching the PID. +#[cfg(target_os = "linux")] +pub fn get_process_incarnation(pid: u32) -> Option { + let boot_id = fs::read_to_string("/proc/sys/kernel/random/boot_id").ok()?; + let boot_id = validated_linux_boot_id(&boot_id)?; + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let start_ticks = parse_linux_proc_start_ticks(&stat)?; + Some(format!("linux:{boot_id}:{start_ticks}")) +} + +#[cfg(any(test, target_os = "linux"))] +fn validated_linux_boot_id(raw: &str) -> Option { + let boot_id = raw.trim(); + if boot_id.len() != 36 { + return None; + } + for (index, byte) in boot_id.bytes().enumerate() { + let expected_hyphen = matches!(index, 8 | 13 | 18 | 23); + if (expected_hyphen && byte != b'-') || (!expected_hyphen && !byte.is_ascii_hexdigit()) { + return None; + } + } + Some(boot_id.to_ascii_lowercase()) +} + +#[cfg(any(test, target_os = "linux"))] +fn parse_linux_proc_start_ticks(stat: &str) -> Option { + // `comm` is parenthesized and may contain spaces and `)`, so the final + // closing parenthesis is the only safe boundary before the fixed fields. + let fields: Vec<&str> = stat + .get(stat.rfind(')')? + 1..)? + .split_whitespace() + .collect(); + // After `comm`, state is field 3 and starttime is field 22. + fields.get(19)?.parse().ok() +} + +#[cfg(target_os = "macos")] +pub fn get_process_incarnation(pid: u32) -> Option { + use proc_pidinfo::{proc_pidinfo, Pid, ProcBSDInfo}; + + let info = proc_pidinfo::(Pid(pid)).ok().flatten()?; + if info.pbi_pid != Pid(pid) { + return None; + } + format_macos_process_incarnation(info.pbi_start_tvsec, info.pbi_start_tvusec) +} + +#[cfg(any(test, target_os = "macos"))] +fn format_macos_process_incarnation(start_sec: u64, start_usec: u64) -> Option { + if start_sec == 0 || start_usec >= 1_000_000 { + return None; + } + Some(format!("macos:{start_sec}:{start_usec}")) +} + +#[cfg(target_os = "windows")] +pub fn get_process_incarnation(pid: u32) -> Option { + use std::mem::MaybeUninit; + use windows_sys::Win32::Foundation::{CloseHandle, FILETIME}; + use windows_sys::Win32::System::Threading::{ + GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + // SAFETY: `pid` is passed by value and no pointers cross this call. + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() { + return None; + } + + let mut creation_time = MaybeUninit::::uninit(); + let mut exit_time = MaybeUninit::::uninit(); + let mut kernel_time = MaybeUninit::::uninit(); + let mut user_time = MaybeUninit::::uninit(); + // SAFETY: the handle is live, each output points to writable storage, and + // all four values are read only after GetProcessTimes reports success. + let read_succeeded = unsafe { + GetProcessTimes( + handle, + creation_time.as_mut_ptr(), + exit_time.as_mut_ptr(), + kernel_time.as_mut_ptr(), + user_time.as_mut_ptr(), + ) + } != 0; + // SAFETY: OpenProcess returned this non-null owned handle exactly once. + let close_succeeded = unsafe { CloseHandle(handle) } != 0; + if !read_succeeded || !close_succeeded { + return None; + } + + // SAFETY: GetProcessTimes initialized every output on success. + let creation_time = unsafe { creation_time.assume_init() }; + Some(format_windows_process_incarnation( + creation_time.dwHighDateTime, + creation_time.dwLowDateTime, + )) +} + +#[cfg(any(test, target_os = "windows"))] +fn format_windows_process_incarnation(high: u32, low: u32) -> String { + let creation_filetime = (u64::from(high) << 32) | u64::from(low); + format!("windows:{creation_filetime}") +} + +#[cfg(all( + not(target_os = "linux"), + not(target_os = "macos"), + not(target_os = "windows") +))] +pub fn get_process_incarnation(_pid: u32) -> Option { + None +} + +/// Read one environment variable from a running process. +/// +/// Access can legitimately fail because of OS privacy controls or because the +/// process exits while it is being inspected; callers should treat `None` as +/// an unavailable value rather than as an error. +#[cfg(target_os = "linux")] +pub fn read_process_env_var(pid: u32, name: &str) -> Option { + let data = fs::read(format!("/proc/{pid}/environ")).ok()?; + parse_nul_env_var(&data, name) +} + +#[cfg(target_os = "windows")] +pub fn read_process_env_var(pid: u32, name: &str) -> Option { + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; + + if !valid_env_name(name) { + return None; + } + let mut system = System::new(); + let pid = Pid::from_u32(pid); + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&[pid]), + false, + ProcessRefreshKind::new().with_environ(UpdateKind::Always), + ); + system.process(pid)?.environ().iter().find_map(|entry| { + let entry = entry.to_string_lossy(); + let (key, value) = entry.split_once('=')?; + key.eq_ignore_ascii_case(name).then(|| value.to_string()) + }) +} + +#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] +pub fn read_process_env_var(pid: u32, name: &str) -> Option { + if !valid_env_name(name) { + return None; + } + // This is best-effort: BSD ps can withhold or truncate another process's + // environment, and whitespace in values cannot be represented reliably. + let pid = pid.to_string(); + let output = Command::new("ps").args(["eww", "-p", &pid]).output().ok()?; + if !output.status.success() { + return None; + } + parse_ps_env_var(&String::from_utf8_lossy(&output.stdout), name) +} + +fn valid_env_name(name: &str) -> bool { + !name.is_empty() && !name.contains('=') && !name.bytes().any(|byte| byte == 0) +} + +#[cfg(any(test, target_os = "linux"))] +fn parse_nul_env_var(data: &[u8], name: &str) -> Option { + if !valid_env_name(name) { + return None; + } + let name = name.as_bytes(); + data.split(|byte| *byte == 0).find_map(|entry| { + (entry.len() > name.len() && entry.starts_with(name) && entry[name.len()] == b'=') + .then(|| String::from_utf8(entry[name.len() + 1..].to_vec()).ok()) + .flatten() + }) +} + +#[cfg(any(test, all(not(target_os = "linux"), not(target_os = "windows"))))] +fn parse_ps_env_var(output: &str, name: &str) -> Option { + if !valid_env_name(name) { + return None; + } + let prefix = format!("{name}="); + output + .split_whitespace() + .find_map(|token| token.strip_prefix(&prefix).map(str::to_string)) +} + /// Resolve all symlinks in /proc/{pid}/fd, returning their targets. /// Used by both port discovery (socket inodes) and Codex JSONL discovery. #[cfg(target_os = "linux")] @@ -75,10 +685,9 @@ pub fn get_process_info() -> HashMap { let rss_kb = rss_pages * page_size / 1024; // CPU%: lifetime average (total CPU time / wall time). - // This differs from ps's instantaneous %CPU but is sufficient for - // abtop's Working/Waiting threshold (cpu_pct > 1.0). A long-idle - // process that was busy at startup will show a declining average, - // eventually dropping below 1.0 as elapsed time grows. + // This differs from ps's instantaneous %CPU and is used only as a + // best-effort signal that a validated descendant is active. Lifecycle + // collectors must never infer Waiting or Idle from low CPU usage. let uptime_ticks = (uptime_secs * clk_tck) as u64; let elapsed_ticks = uptime_ticks.saturating_sub(starttime); let cpu_pct = if elapsed_ticks > 0 { @@ -87,12 +696,20 @@ pub fn get_process_info() -> HashMap { 0.0 }; - // /proc/{pid}/cmdline: NUL-separated - let command = fs::read_to_string(format!("/proc/{pid}/cmdline")) - .unwrap_or_default() - .replace('\0', " ") - .trim() - .to_string(); + // /proc/{pid}/cmdline is NUL-separated. Re-quote arguments containing + // whitespace so downstream provider parsers do not mistake prompt + // words for CLI subcommands. + let command = fs::read(format!("/proc/{pid}/cmdline")) + .ok() + .map(|bytes| { + join_command_args( + bytes + .split(|byte| *byte == 0) + .filter(|arg| !arg.is_empty()) + .map(|arg| String::from_utf8_lossy(arg).into_owned()), + ) + }) + .unwrap_or_default(); if command.is_empty() { continue; // kernel thread, skip } @@ -118,8 +735,8 @@ pub fn get_process_info() -> HashMap { // sysinfo's `cpu_usage()` is a delta between two refreshes — a freshly // constructed `System` always reports 0. Hold one across calls so the // second tick onward returns real CPU%, instead of every Windows process - // looking idle (which would break `has_active_descendant` and the - // Working/Waiting threshold downstream). + // looking inactive to `has_active_descendant`. Low CPU remains + // insufficient evidence for Waiting or Idle. static SYS: OnceLock> = OnceLock::new(); let sys_mutex = SYS.get_or_init(|| Mutex::new(sysinfo::System::new())); let mut sys = sys_mutex @@ -144,12 +761,7 @@ pub fn get_process_info() -> HashMap { let command = if proc_.cmd().is_empty() { proc_.name().to_string_lossy().into_owned() } else { - proc_ - .cmd() - .iter() - .map(|s| s.to_string_lossy().into_owned()) - .collect::>() - .join(" ") + join_command_args(proc_.cmd().iter().map(|s| s.to_string_lossy().into_owned())) }; if command.is_empty() { continue; @@ -328,30 +940,44 @@ pub fn get_listening_ports() -> HashMap> { #[cfg(target_os = "windows")] pub fn get_listening_ports() -> HashMap> { - let mut map: HashMap> = HashMap::new(); let output = Command::new("netstat") .args(["-ano", "-p", "TCP"]) .output() .ok(); - if let Some(output) = output { - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if !line.contains("LISTENING") { - continue; - } - let parts: Vec<&str> = line.split_whitespace().collect(); - let local_addr = parts.first(); - let pid_str = parts.last(); - if let (Some(addr), Some(pid_s)) = (local_addr, pid_str) { - if let (Some(port_str), Ok(pid)) = (addr.rsplit(':').next(), pid_s.parse::()) { - if let Ok(port) = port_str.parse::() { - map.entry(pid).or_default().push(port); - } - } - } + output + .map(|output| parse_windows_netstat(&String::from_utf8_lossy(&output.stdout))) + .unwrap_or_default() +} + +#[cfg(any(test, target_os = "windows"))] +fn parse_windows_netstat(output: &str) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + + for line in output.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 5 + || !parts[0].eq_ignore_ascii_case("TCP") + || !parts[parts.len() - 2].eq_ignore_ascii_case("LISTENING") + { + continue; } + + // Standard netstat rows start with the protocol, then the local + // address: `TCP 127.0.0.1:3000 ... LISTENING 42`. + let Some(port) = parts[1] + .rsplit(':') + .next() + .and_then(|port| port.parse::().ok()) + else { + continue; + }; + let Some(pid) = parts.last().and_then(|pid| pid.parse::().ok()) else { + continue; + }; + map.entry(pid).or_default().push(port); } + map } @@ -405,15 +1031,17 @@ pub fn last_path_segment(s: &str) -> Option<&str> { /// miss this, so we also accept any path of the form `<...>//versions/`. #[cfg(not(windows))] pub fn cmd_has_binary(cmd: &str, name: &str) -> bool { - let mut tokens = cmd.split_whitespace().take(2); - tokens.any(|tok| unix_token_has_binary(tok, name)) + command_tokens(cmd) + .into_iter() + .take(2) + .any(|token| unix_token_has_binary(&token, name)) } #[cfg(not(windows))] pub fn cmd_first_token_has_binary(cmd: &str, name: &str) -> bool { - cmd.split_whitespace() - .next() - .is_some_and(|tok| unix_token_has_binary(tok, name)) + command_tokens(cmd) + .first() + .is_some_and(|token| unix_token_has_binary(token, name)) } #[cfg(not(windows))] @@ -432,6 +1060,11 @@ fn unix_token_has_binary(tok: &str, name: &str) -> bool { matches!((iter.next(), iter.next()), (Some("versions"), Some(parent)) if parent == name) } +#[cfg(not(windows))] +pub(crate) fn token_has_binary(token: &str, name: &str) -> bool { + unix_token_has_binary(token, name) +} + /// Windows variant: checks executable-position tokens, splits on `\`, strips a /// trailing `.exe` and common script extensions (`.js`, `.sh`, `.py`), and /// matches case-insensitively. @@ -439,6 +1072,11 @@ fn unix_token_has_binary(tok: &str, name: &str) -> bool { /// (`Claude` must not match `claude` on linux/macOS). #[cfg(windows)] pub fn cmd_has_binary(cmd: &str, name: &str) -> bool { + windows_cmd_has_binary(cmd, name) +} + +#[cfg(any(test, windows))] +fn windows_cmd_has_binary(cmd: &str, name: &str) -> bool { windows_command_tokens(cmd) .into_iter() .take(2) @@ -452,16 +1090,17 @@ pub fn cmd_first_token_has_binary(cmd: &str, name: &str) -> bool { .is_some_and(|tok| windows_token_has_binary(tok, name)) } -#[cfg(windows)] +#[cfg(any(test, windows))] fn windows_token_has_binary(tok: &str, name: &str) -> bool { let mut iter = tok.rsplit(['/', '\\']); let base = iter.next().unwrap_or(tok); - let base = base + let normalized = base.to_ascii_lowercase(); + let base = normalized .strip_suffix(".exe") - .or_else(|| base.strip_suffix(".js")) - .or_else(|| base.strip_suffix(".sh")) - .or_else(|| base.strip_suffix(".py")) - .unwrap_or(base); + .or_else(|| normalized.strip_suffix(".js")) + .or_else(|| normalized.strip_suffix(".sh")) + .or_else(|| normalized.strip_suffix(".py")) + .unwrap_or(&normalized); if base.eq_ignore_ascii_case(name) { return true; } @@ -473,6 +1112,11 @@ fn windows_token_has_binary(tok: &str, name: &str) -> bool { } #[cfg(windows)] +pub(crate) fn token_has_binary(token: &str, name: &str) -> bool { + windows_token_has_binary(token, name) +} + +#[cfg(any(test, windows))] fn windows_command_tokens(cmd: &str) -> Vec { let mut tokens = Vec::new(); let mut current = String::new(); @@ -534,6 +1178,206 @@ pub fn collect_git_stats(cwd: &str) -> (u32, u32) { mod tests { use super::*; + #[test] + fn command_tokens_preserve_quoted_executable_paths() { + assert_eq!( + command_tokens("\"/Applications/Grok Build/grok\" --resume abc"), + vec!["/Applications/Grok Build/grok", "--resume", "abc"] + ); + } + + #[test] + fn command_arg_join_preserves_whitespace_boundaries() { + let joined = join_command_args([ + "C:\\Program Files\\nodejs\\node.exe".to_string(), + "C:\\pkg\\main.mjs".to_string(), + "prompt with spaces".to_string(), + ]); + assert_eq!( + command_tokens(&joined), + vec![ + "C:\\Program Files\\nodejs\\node.exe", + "C:\\pkg\\main.mjs", + "prompt with spaces" + ] + ); + } + + #[test] + fn windows_classifier_handles_serialized_sysinfo_argv_with_spaces() { + let command = join_command_args([ + "C:\\Program Files\\nodejs\\node.exe".to_string(), + "C:\\Users\\GK\\App Data\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\CODEX.JS" + .to_string(), + "--resume".to_string(), + ]); + + assert!(windows_cmd_has_binary(&command, "codex")); + assert!(!windows_cmd_has_binary(&command, "claude")); + } + + #[test] + fn windows_classifier_strips_extensions_case_insensitively() { + assert!(windows_cmd_has_binary( + r#"C:\Tools\CLAUDE.EXE --resume abc"#, + "claude" + )); + assert!(windows_cmd_has_binary( + r#"node.exe C:\Tools\CODEX.JS --resume abc"#, + "codex" + )); + } + + #[test] + fn windows_netstat_parser_reads_local_address_column() { + let output = r#" + Proto Local Address Foreign Address State PID + TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 4242 + TCP [::1]:8080 [::]:0 LISTENING 4242 + TCP 127.0.0.1:9000 127.0.0.1:50000 ESTABLISHED 99 + UDP 0.0.0.0:5353 *:* 100 +"#; + + let parsed = parse_windows_netstat(output); + assert_eq!( + parsed.get(&4242).map(Vec::as_slice), + Some(&[3000, 8080][..]) + ); + assert_eq!(parsed.len(), 1); + } + + #[test] + fn parse_nul_env_var_matches_exact_name_and_preserves_equals() { + let data = b"GROK_HOME=/tmp/grok\0TOKEN=a=b=c\0GROK_HOME_EXTRA=nope\0"; + assert_eq!( + parse_nul_env_var(data, "GROK_HOME").as_deref(), + Some("/tmp/grok") + ); + assert_eq!(parse_nul_env_var(data, "TOKEN").as_deref(), Some("a=b=c")); + assert_eq!(parse_nul_env_var(data, "GROK"), None); + assert_eq!(parse_nul_env_var(data, ""), None); + } + + #[test] + fn parse_ps_env_var_matches_exact_whitespace_delimited_entry() { + let output = "123 ?? S command GROK_HOME=/tmp/grok OTHER=value"; + assert_eq!( + parse_ps_env_var(output, "GROK_HOME").as_deref(), + Some("/tmp/grok") + ); + assert_eq!(parse_ps_env_var(output, "GROK"), None); + assert_eq!(parse_ps_env_var(output, "BAD=NAME"), None); + } + + #[test] + fn process_helpers_inspect_current_process() { + let pid = std::process::id(); + let expected_cwd = std::env::current_dir().unwrap().canonicalize().unwrap(); + let actual_cwd = std::path::PathBuf::from(get_process_cwd(pid).unwrap()) + .canonicalize() + .unwrap(); + assert_eq!(actual_cwd, expected_cwd); + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let started_at_ms = get_process_started_at_ms(pid).unwrap(); + assert!(started_at_ms > 0); + assert!(started_at_ms <= now_ms.saturating_add(5_000)); + } + + #[test] + fn linux_incarnation_parsers_validate_exact_fields() { + let boot_id = "01234567-89AB-cdef-0123-456789abcdef\n"; + assert_eq!( + validated_linux_boot_id(boot_id).as_deref(), + Some("01234567-89ab-cdef-0123-456789abcdef") + ); + assert_eq!(validated_linux_boot_id("not-a-boot-id"), None); + assert_eq!( + validated_linux_boot_id("01234567-89ab-cdef-0123-456789abcdeg"), + None + ); + + let stat = "4242 (worker ) name) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 987654 20"; + assert_eq!(parse_linux_proc_start_ticks(stat), Some(987_654)); + assert_eq!(parse_linux_proc_start_ticks("4242 (short) S 1 2"), None); + assert_eq!( + parse_linux_proc_start_ticks( + "4242 (bad start) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 invalid" + ), + None + ); + } + + #[test] + fn platform_incarnation_format_preserves_native_precision() { + assert_eq!( + format_macos_process_incarnation(1_700_000_000, 42).as_deref(), + Some("macos:1700000000:42") + ); + assert_eq!(format_macos_process_incarnation(0, 42), None); + assert_eq!(format_macos_process_incarnation(1, 1_000_000), None); + assert_eq!( + format_windows_process_incarnation(1, 2), + "windows:4294967298" + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + #[test] + fn exact_incarnation_is_stable_for_current_process() { + let pid = std::process::id(); + let first = get_process_incarnation(pid).expect("current process must have an identity"); + let second = get_process_incarnation(pid).expect("current process must remain queryable"); + assert_eq!(first, second); + + #[cfg(target_os = "linux")] + assert!(first.starts_with("linux:")); + #[cfg(target_os = "macos")] + assert!(first.starts_with("macos:")); + #[cfg(target_os = "windows")] + assert!(first.starts_with("windows:")); + } + + #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] + #[test] + fn exact_incarnation_disappears_after_process_is_reaped() { + #[cfg(windows)] + let mut child = Command::new("cmd") + .args(["/C", "exit", "0"]) + .spawn() + .expect("spawn short-lived child"); + #[cfg(not(windows))] + let mut child = Command::new("sh") + .args(["-c", "exit 0"]) + .spawn() + .expect("spawn short-lived child"); + + let pid = child.id(); + child.wait().expect("wait for child"); + drop(child); + + for _ in 0..50 { + if get_process_incarnation(pid).is_none() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("reaped process {pid} remained queryable"); + } + + #[cfg(any(target_os = "linux", target_os = "windows"))] + #[test] + fn process_env_helper_reads_inherited_path() { + let expected = std::env::var("PATH").unwrap(); + assert_eq!( + read_process_env_var(std::process::id(), "PATH").as_deref(), + Some(expected.as_str()) + ); + } + #[test] fn cmd_has_binary_basename_match() { assert!(cmd_has_binary("/usr/local/bin/claude --foo", "claude")); @@ -660,4 +1504,76 @@ mod tests { m.insert(20, proc(20, 99)); assert!(!is_descendant_of(20, 7, &m)); } + + #[cfg(any(target_os = "linux", target_vendor = "apple", target_os = "windows"))] + #[test] + fn current_process_executable_is_absolute_and_stable() { + let first = get_process_executable(std::process::id()) + .expect("current process executable must be queryable"); + let second = get_process_executable(std::process::id()) + .expect("current process executable must remain queryable"); + assert!(first.is_absolute()); + assert_eq!(first, second); + } + + #[cfg(any(target_os = "linux", target_vendor = "apple", target_os = "windows"))] + #[test] + fn current_process_argv_is_exact_and_stable() { + let expected = std::env::args_os().collect::>(); + let first = + get_process_argv(std::process::id()).expect("current process argv must be queryable"); + let second = get_process_argv(std::process::id()) + .expect("current process argv must remain queryable"); + assert_eq!(first, expected); + assert_eq!(second, expected); + } + + #[cfg(any(unix, windows))] + #[test] + fn live_process_argv_preserves_an_empty_argument() { + #[cfg(unix)] + let mut child = std::process::Command::new("/bin/sh") + .args(["-c", "read _", "abtop-argv-probe", ""]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn argv probe"); + #[cfg(windows)] + let mut child = std::process::Command::new("cmd.exe") + .args(["/D", "/Q", "/K", "rem", ""]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn argv probe"); + + let mut observed = None; + for _ in 0..100 { + observed = get_process_argv(child.id()) + .filter(|argv| argv.iter().any(|argument| argument.is_empty())); + if observed.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let _ = child.kill(); + let _ = child.wait(); + + let argv = observed.expect("live OS argv must preserve the empty argument"); + assert!(argv.iter().any(|argument| argument.is_empty())); + } + + #[cfg(unix)] + #[test] + fn exact_cmdline_parser_preserves_empty_arguments() { + use std::os::unix::ffi::OsStrExt; + + let argv = parse_linux_cmdline(b"/native/codex\0--remote\0\0frontend\0\0") + .expect("terminated cmdline must parse"); + assert_eq!(argv.len(), 5); + assert_eq!(argv[2].as_os_str().as_bytes(), b""); + assert_eq!(argv[4].as_os_str().as_bytes(), b""); + assert!(parse_linux_cmdline(b"/native/codex\0unterminated").is_none()); + } } diff --git a/src/collector/rate_limit.rs b/src/collector/rate_limit.rs index e9722d5..318ccd9 100644 --- a/src/collector/rate_limit.rs +++ b/src/collector/rate_limit.rs @@ -1,5 +1,6 @@ -use crate::model::RateLimitInfo; -use serde::Deserialize; +use super::codexbar::canonical_provider_id; +use crate::model::{RateLimitInfo, RateLimitProvenance, RateLimitWindow}; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; /// File written by the StatusLine hook: ~/.claude/abtop-rate-limits.json @@ -8,7 +9,10 @@ const CLAUDE_RATE_FILE: &str = "abtop-rate-limits.json"; /// Cached Codex rate limit: ~/.cache/abtop/codex-rate-limits.json const CODEX_CACHE_FILE: &str = "codex-rate-limits.json"; -#[derive(Debug, Deserialize)] +const MAX_RATE_LIMIT_FILE_BYTES: u64 = 64 * 1024; +const MAX_NATIVE_WINDOW_MINUTES: u64 = 365 * 24 * 60; + +#[derive(Debug, Deserialize, Serialize)] struct RateLimitFile { #[serde(default)] source: String, @@ -18,9 +22,13 @@ struct RateLimitFile { seven_day: Option, #[serde(default)] updated_at: Option, + /// Native source-slot identities added by abtop's Codex cache. Older + /// caches and Claude StatusLine files omit this field. + #[serde(default)] + windows: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] struct WindowInfo { #[serde(default)] used_percentage: f64, @@ -30,6 +38,20 @@ struct WindowInfo { window_minutes: Option, } +/// Content-free native Codex source slot persisted by abtop. +/// +/// Labels and provenance are intentionally not serialized: both are derived +/// from the allowlisted slot and bounded duration when the cache is read. +#[derive(Debug, Deserialize, Serialize)] +struct CachedNativeWindow { + id: String, + used_percentage: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + resets_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + window_minutes: Option, +} + /// Read rate limit info from all known Claude config directories. /// Checks the default ~/.claude, CLAUDE_CONFIG_DIR if set, and any /// additional directories discovered from running Claude processes. @@ -77,49 +99,73 @@ pub fn write_codex_cache(info: &RateLimitInfo) { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } + let _ = write_codex_cache_file(&path, info); +} - let json = format!( - r#"{{"source":"codex","five_hour":{},"seven_day":{},"updated_at":{}}}"#, - window_json( +fn write_codex_cache_file(path: &Path, info: &RateLimitInfo) -> std::io::Result<()> { + let cache = RateLimitFile { + source: "codex".to_string(), + five_hour: stored_legacy_window( info.five_hour_pct, info.five_hour_resets_at, - info.five_hour_window_minutes + info.five_hour_window_minutes, ), - window_json( + seven_day: stored_legacy_window( info.seven_day_pct, info.seven_day_resets_at, - info.seven_day_window_minutes + info.seven_day_window_minutes, ), - info.updated_at - .map(|v| v.to_string()) - .unwrap_or_else(|| "null".to_string()), - ); + updated_at: info.updated_at, + windows: cached_native_windows(info), + }; + let json = serde_json::to_vec(&cache).map_err(std::io::Error::other)?; - // Atomic write: temp file + rename to avoid corrupted reads + // Atomic write: temp file + rename to avoid corrupted reads. let tmp = path.with_extension("tmp"); - if std::fs::write(&tmp, &json).is_ok() { - let _ = std::fs::rename(&tmp, &path); - } + std::fs::write(&tmp, json)?; + std::fs::rename(tmp, path) } -fn window_json(pct: Option, resets_at: Option, window_minutes: Option) -> String { - match (pct, resets_at) { - (Some(p), Some(r)) => match window_minutes { - Some(m) => format!( - r#"{{"used_percentage":{},"resets_at":{},"window_minutes":{}}}"#, - p, r, m - ), - None => format!(r#"{{"used_percentage":{},"resets_at":{}}}"#, p, r), - }, - (Some(p), None) => match window_minutes { - Some(m) => format!( - r#"{{"used_percentage":{},"resets_at":0,"window_minutes":{}}}"#, - p, m - ), - None => format!(r#"{{"used_percentage":{},"resets_at":0}}"#, p), - }, - _ => "null".to_string(), +fn stored_legacy_window( + used_percentage: Option, + resets_at: Option, + window_minutes: Option, +) -> Option { + let used_percentage = + used_percentage.filter(|value| value.is_finite() && (0.0..=100.0).contains(value))?; + if window_minutes.is_some() && bounded_window_minutes(window_minutes).is_none() { + return None; } + Some(WindowInfo { + used_percentage, + resets_at: resets_at.unwrap_or(0), + window_minutes: bounded_window_minutes(window_minutes), + }) +} + +fn cached_native_windows(info: &RateLimitInfo) -> Vec { + ["primary", "secondary"] + .into_iter() + .filter_map(|id| { + let window = info.windows.iter().find(|window| { + window.id == id && window.provenance == RateLimitProvenance::Native + })?; + if !window.used_pct.is_finite() || !(0.0..=100.0).contains(&window.used_pct) { + return None; + } + if window.window_minutes.is_some() + && bounded_window_minutes(window.window_minutes).is_none() + { + return None; + } + Some(CachedNativeWindow { + id: id.to_string(), + used_percentage: window.used_pct, + resets_at: window.resets_at, + window_minutes: window.window_minutes, + }) + }) + .collect() } fn codex_cache_path() -> Option { @@ -127,36 +173,277 @@ fn codex_cache_path() -> Option { } fn read_rate_file(path: &Path, default_source: &str) -> Option { + if std::fs::metadata(path).ok()?.len() > MAX_RATE_LIMIT_FILE_BYTES { + return None; + } let content = std::fs::read_to_string(path).ok()?; let file: RateLimitFile = serde_json::from_str(&content).ok()?; + let five_hour = valid_legacy_window(file.five_hour.as_ref()); + let seven_day = valid_legacy_window(file.seven_day.as_ref()); + let mut windows = if default_source == "codex" { + decoded_native_windows(&file.windows) + } else { + Vec::new() + }; + if default_source == "codex" && windows.is_empty() && five_hour.is_none() { + if let Some(window) = seven_day.filter(|window| { + window + .window_minutes + .is_some_and(|minutes| minutes > 10_080) + }) { + if let Some(primary) = RateLimitWindow::try_new( + "primary", + native_window_label(window.window_minutes, "primary"), + window.used_percentage, + Some(window.resets_at), + window.window_minutes, + RateLimitProvenance::Native, + ) { + windows.push(primary); + } + } + } - // Reject if both windows are absent - if file.five_hour.is_none() && file.seven_day.is_none() { + // Reject if every legacy and source-slot window is absent. + if five_hour.is_none() && seven_day.is_none() && windows.is_empty() { return None; } - let source = if file.source.is_empty() { - default_source.to_string() - } else { - file.source - }; + let trusted_source = canonical_provider_id(default_source)?; + let source = canonical_provider_id(&file.source) + .filter(|source| source == &trusted_source) + .unwrap_or(trusted_source); Some(RateLimitInfo { source, - five_hour_pct: file.five_hour.as_ref().map(|w| w.used_percentage), - five_hour_resets_at: file.five_hour.as_ref().map(|w| w.resets_at), - five_hour_window_minutes: file - .five_hour - .as_ref() + five_hour_pct: five_hour.map(|w| w.used_percentage), + five_hour_resets_at: five_hour.map(|w| w.resets_at), + five_hour_window_minutes: five_hour .and_then(|w| w.window_minutes) - .or(file.five_hour.as_ref().map(|_| 300)), - seven_day_pct: file.seven_day.as_ref().map(|w| w.used_percentage), - seven_day_resets_at: file.seven_day.as_ref().map(|w| w.resets_at), - seven_day_window_minutes: file - .seven_day - .as_ref() + .or(five_hour.map(|_| 300)), + seven_day_pct: seven_day.map(|w| w.used_percentage), + seven_day_resets_at: seven_day.map(|w| w.resets_at), + seven_day_window_minutes: seven_day .and_then(|w| w.window_minutes) - .or(file.seven_day.as_ref().map(|_| 10_080)), + .or(seven_day.map(|_| 10_080)), updated_at: file.updated_at, + windows, }) } + +fn valid_legacy_window(window: Option<&WindowInfo>) -> Option<&WindowInfo> { + let window = window?; + if !window.used_percentage.is_finite() + || !(0.0..=100.0).contains(&window.used_percentage) + || (window.window_minutes.is_some() + && bounded_window_minutes(window.window_minutes).is_none()) + { + return None; + } + Some(window) +} + +fn decoded_native_windows(windows: &[CachedNativeWindow]) -> Vec { + ["primary", "secondary"] + .into_iter() + .filter_map(|id| { + let stored = windows.iter().find(|window| window.id == id)?; + if !stored.used_percentage.is_finite() + || !(0.0..=100.0).contains(&stored.used_percentage) + || (stored.window_minutes.is_some() + && bounded_window_minutes(stored.window_minutes).is_none()) + { + return None; + } + RateLimitWindow::try_new( + id, + native_window_label(stored.window_minutes, id), + stored.used_percentage, + stored.resets_at, + stored.window_minutes, + RateLimitProvenance::Native, + ) + }) + .collect() +} + +fn bounded_window_minutes(window_minutes: Option) -> Option { + window_minutes.filter(|minutes| (1..=MAX_NATIVE_WINDOW_MINUTES).contains(minutes)) +} + +fn native_window_label(window_minutes: Option, slot: &str) -> String { + match window_minutes { + Some(minutes) if minutes % (24 * 60) == 0 => format!("{}d", minutes / (24 * 60)), + Some(minutes) if minutes % 60 == 0 => format!("{}h", minutes / 60), + Some(minutes) => format!("{minutes}m"), + None if slot == "primary" => "Primary".to_string(), + None => "Secondary".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn native_window( + id: &str, + used_pct: f64, + resets_at: u64, + window_minutes: u64, + ) -> RateLimitWindow { + RateLimitWindow::try_new( + id, + "ignored cache label", + used_pct, + Some(resets_at), + Some(window_minutes), + RateLimitProvenance::Native, + ) + .unwrap() + } + + #[test] + fn codex_cache_round_trip_preserves_free_primary_source_slot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(CODEX_CACHE_FILE); + let original = RateLimitInfo { + source: "codex".to_string(), + seven_day_pct: Some(48.0), + seven_day_resets_at: Some(1_780_000_000), + seven_day_window_minutes: Some(43_200), + updated_at: Some(1_750_000_000), + windows: vec![native_window("primary", 48.0, 1_780_000_000, 43_200)], + ..RateLimitInfo::default() + }; + + write_codex_cache_file(&path, &original).unwrap(); + let serialized = std::fs::read_to_string(&path).unwrap(); + assert!(!serialized.contains("ignored cache label")); + assert!(!serialized.contains("provenance")); + let restored = read_rate_file(&path, "codex").unwrap(); + + assert_eq!(restored.seven_day_pct, Some(48.0)); + assert_eq!(restored.seven_day_window_minutes, Some(43_200)); + assert_eq!(restored.windows.len(), 1); + assert_eq!(restored.windows[0].id, "primary"); + assert_eq!(restored.windows[0].label, "30d"); + assert_eq!(restored.windows[0].used_pct, 48.0); + assert_eq!(restored.windows[0].window_minutes, Some(43_200)); + assert_eq!(restored.windows[0].provenance, RateLimitProvenance::Native); + } + + #[test] + fn codex_cache_round_trip_preserves_normal_secondary_source_slot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(CODEX_CACHE_FILE); + let original = RateLimitInfo { + source: "codex".to_string(), + seven_day_pct: Some(14.0), + seven_day_resets_at: Some(1_775_186_466), + seven_day_window_minutes: Some(10_080), + updated_at: Some(1_750_000_000), + windows: vec![native_window("secondary", 14.0, 1_775_186_466, 10_080)], + ..RateLimitInfo::default() + }; + + write_codex_cache_file(&path, &original).unwrap(); + let restored = read_rate_file(&path, "codex").unwrap(); + + assert_eq!(restored.windows.len(), 1); + assert_eq!(restored.windows[0].id, "secondary"); + assert_eq!(restored.windows[0].label, "7d"); + assert_eq!(restored.windows[0].used_pct, 14.0); + assert_eq!(restored.windows[0].window_minutes, Some(10_080)); + } + + #[test] + fn legacy_codex_and_claude_files_remain_readable() { + let dir = tempfile::tempdir().unwrap(); + let codex_path = dir.path().join("codex.json"); + std::fs::write( + &codex_path, + r#"{"source":"codex","five_hour":{"used_percentage":9.0,"resets_at":1774686045,"window_minutes":300},"seven_day":null,"updated_at":1750000000}"#, + ) + .unwrap(); + let codex = read_rate_file(&codex_path, "codex").unwrap(); + assert_eq!(codex.five_hour_pct, Some(9.0)); + assert!(codex.windows.is_empty()); + + let free_codex_path = dir.path().join("free-codex.json"); + std::fs::write( + &free_codex_path, + r#"{"source":"codex","five_hour":null,"seven_day":{"used_percentage":48.0,"resets_at":1780000000,"window_minutes":43200},"updated_at":1750000000}"#, + ) + .unwrap(); + let free_codex = read_rate_file(&free_codex_path, "codex").unwrap(); + assert_eq!(free_codex.windows.len(), 1); + assert_eq!(free_codex.windows[0].id, "primary"); + assert_eq!(free_codex.windows[0].label, "30d"); + + let claude_path = dir.path().join("claude.json"); + std::fs::write( + &claude_path, + r#"{"five_hour":{"used_percentage":28.0,"resets_at":1774686045},"seven_day":{"used_percentage":6.0,"resets_at":1775186466}}"#, + ) + .unwrap(); + let claude = read_rate_file(&claude_path, "claude").unwrap(); + assert_eq!(claude.source, "claude"); + assert_eq!(claude.five_hour_window_minutes, Some(300)); + assert_eq!(claude.seven_day_window_minutes, Some(10_080)); + assert!(claude.windows.is_empty()); + } + + #[test] + fn source_must_match_the_canonical_trusted_default() { + let dir = tempfile::tempdir().unwrap(); + let normalized_path = dir.path().join("normalized.json"); + std::fs::write( + &normalized_path, + r#"{"source":"CoDeX","five_hour":{"used_percentage":1.0,"resets_at":1}}"#, + ) + .unwrap(); + assert_eq!( + read_rate_file(&normalized_path, "codex").unwrap().source, + "codex" + ); + + let mismatched_path = dir.path().join("mismatched.json"); + std::fs::write( + &mismatched_path, + r#"{"source":"grok","five_hour":{"used_percentage":1.0,"resets_at":1}}"#, + ) + .unwrap(); + assert_eq!( + read_rate_file(&mismatched_path, "codex").unwrap().source, + "codex" + ); + + let unsafe_path = dir.path().join("unsafe.json"); + std::fs::write( + &unsafe_path, + r#"{"source":"grok\nprivate","five_hour":{"used_percentage":1.0,"resets_at":1}}"#, + ) + .unwrap(); + assert_eq!( + read_rate_file(&unsafe_path, "codex").unwrap().source, + "codex" + ); + } + + #[test] + fn invalid_legacy_duration_is_not_rewritten_as_a_default_window() { + assert!(stored_legacy_window(Some(1.0), Some(10), Some(u64::MAX)).is_none()); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("invalid-duration.json"); + std::fs::write( + &path, + format!( + r#"{{"source":"codex","five_hour":{{"used_percentage":1.0,"resets_at":1,"window_minutes":{}}}}}"#, + u64::MAX + ), + ) + .unwrap(); + assert!(read_rate_file(&path, "codex").is_none()); + } +} diff --git a/src/config.rs b/src/config.rs index 4cd5532..8ed105d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,26 +27,27 @@ impl Default for PanelVisibility { pub struct AppConfig { pub theme: String, - /// Agent CLI names to exclude from the TUI (e.g. ["codex"] to hide Codex). + /// Opt-in integration that asks the local CodexBar CLI for quotas from its + /// configured providers. The legacy field name is retained for compatibility. + pub codexbar_quota_fallback: bool, + /// Agent CLI names to exclude from the TUI: claude, codex, opencode, grok, + /// or kimi (matched case-insensitively). /// Matched case-insensitively against each collector's agent_cli identifier. pub hidden_agents: Vec, /// Additional Claude config directories to scan for sessions. /// Useful for multi-profile setups that use separate CLAUDE_CONFIG_DIR roots. pub claude_config_dirs: Vec, pub panels: PanelVisibility, - /// UI language override. Empty string means auto-detect from `LANG`. - /// Recognized values: "en", "zh" (anything starting with "zh" maps to Simplified Chinese). - pub language: String, } impl Default for AppConfig { fn default() -> Self { Self { theme: "btop".to_string(), + codexbar_quota_fallback: false, hidden_agents: Vec::new(), claude_config_dirs: Vec::new(), panels: PanelVisibility::default(), - language: String::new(), } } } @@ -96,7 +97,9 @@ fn parse_config_body(content: &str) -> AppConfig { let val = val.trim_matches('"').trim_matches('\''); match key { "theme" => config.theme = val.to_string(), - "language" => config.language = val.to_string(), + "codexbar_quota_fallback" => { + config.codexbar_quota_fallback = parse_bool(val).unwrap_or(false) + } "show_context" => config.panels.context = parse_bool(val).unwrap_or(true), "show_quota" => config.panels.quota = parse_bool(val).unwrap_or(true), "show_tokens" => config.panels.tokens = parse_bool(val).unwrap_or(true), @@ -170,6 +173,10 @@ pub fn save_panel_visibility(panels: &PanelVisibility) -> Result<(), String> { ]) } +pub fn save_codexbar_quota_fallback(enabled: bool) -> Result<(), String> { + write_with_updates(&[("codexbar_quota_fallback", enabled.to_string())]) +} + /// Read the config, replace or append each (key, value) pair, write it back. /// Lines that don't match any key are preserved verbatim so unknown keys and /// comments survive saves driven by unrelated parts of the UI. @@ -316,12 +323,26 @@ mod tests { } #[test] - fn rewrite_language_replaces_existing() { - let before = "theme = \"btop\"\nlanguage = \"en\"\n"; - let updates: Vec<(&str, String)> = vec![("language", "\"zh\"".to_string())]; - let after = rewrite_kv_lines(before, &updates); + fn codexbar_quota_fallback_is_opt_in_and_persistable() { + assert!(!parse_config_body("").codexbar_quota_fallback); + assert!(parse_config_body("codexbar_quota_fallback = true\n").codexbar_quota_fallback); + let rewritten = rewrite_kv_lines( + "theme = \"btop\"\n", + &[("codexbar_quota_fallback", "true".to_string())], + ); + assert!(rewritten.contains("codexbar_quota_fallback = true")); + } + + #[test] + fn legacy_language_key_is_ignored_and_preserved_by_other_updates() { + let cfg = parse_config_body("theme = \"btop\"\nlanguage = \"zh\"\n"); + assert_eq!(cfg.theme, "btop"); + + let after = rewrite_kv_lines( + "theme = \"btop\"\nlanguage = \"zh\"\n", + &theme_update("nord"), + ); + assert!(after.contains("theme = \"nord\"")); assert!(after.contains("language = \"zh\"")); - assert!(!after.contains("language = \"en\"")); - assert!(after.contains("theme = \"btop\"")); } } diff --git a/src/demo.rs b/src/demo.rs index 3e28358..0d7cbd1 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -1,7 +1,8 @@ use crate::app::App; use crate::model::{ AgentSession, ChatMessage, ChatRole, ChildProcess, FileAccess, FileOp, OrphanPort, - RateLimitInfo, SessionStatus, SubAgent, ToolCall, + RateLimitInfo, SessionStatus, StatusAuthority, StatusEvidence, StatusObservation, StatusReason, + SubAgent, ToolCall, }; use std::time::{SystemTime, UNIX_EPOCH}; @@ -16,6 +17,33 @@ fn now_secs() -> u64 { now_ms() / 1000 } +fn demo_status_evidence( + status: SessionStatus, + authority: StatusAuthority, + reason: StatusReason, + status_since_ms: u64, + observed_at_ms: u64, +) -> StatusEvidence { + let mut evidence = StatusEvidence::default(); + evidence.observe(StatusObservation::new( + status, + authority, + reason, + status_since_ms, + 0, + )); + if observed_at_ms > status_since_ms { + evidence.observe(StatusObservation::new( + status, + authority, + reason, + observed_at_ms, + 0, + )); + } + evidence +} + pub fn populate_demo(app: &mut App) { let now = now_ms(); @@ -24,11 +52,19 @@ pub fn populate_demo(app: &mut App) { AgentSession { agent_cli: "claude", pid: 7336, + action_process_incarnation: None, session_id: "a1b2c3d4-5678-9abc-def0-111111111111".into(), cwd: "/Users/demo/webshop".into(), project_name: "webshop".into(), started_at: now - 2 * 3600 * 1000, // 2h ago status: SessionStatus::Executing, + status_evidence: demo_status_evidence( + SessionStatus::Executing, + StatusAuthority::Provider, + StatusReason::ProviderExecuting, + now - 6_000, + now, + ), model: "claude-opus-4-6".into(), effort: String::new(), context_percent: 72.0, @@ -197,6 +233,7 @@ pub fn populate_demo(app: &mut App) { }, ], pending_since_ms: now - 6_000, // 6s ago => bar animates + awaiting_input: false, thinking_since_ms: 0, config_root: "~/.claude".into(), file_accesses: vec![ @@ -255,11 +292,19 @@ pub fn populate_demo(app: &mut App) { AgentSession { agent_cli: "claude", pid: 8840, + action_process_incarnation: None, session_id: "b2c3d4e5-6789-abcd-ef01-222222222222".into(), cwd: "/Users/demo/ml-pipeline".into(), project_name: "ml-pipeline".into(), started_at: now - 47 * 60 * 1000, // 47m ago status: SessionStatus::Waiting, + status_evidence: demo_status_evidence( + SessionStatus::Waiting, + StatusAuthority::Provider, + StatusReason::ProviderWaitingUserInput, + now - 30_000, + now, + ), model: "claude-sonnet-4-6".into(), effort: String::new(), context_percent: 91.0, @@ -305,6 +350,7 @@ pub fn populate_demo(app: &mut App) { initial_prompt: "Add batch inference endpoint with GPU scheduling".into(), tool_calls: vec![], pending_since_ms: 0, + awaiting_input: true, thinking_since_ms: 0, config_root: "~/.claude-work".into(), file_accesses: vec![], @@ -312,11 +358,19 @@ pub fn populate_demo(app: &mut App) { AgentSession { agent_cli: "claude", pid: 9102, + action_process_incarnation: None, session_id: "c3d4e5f6-789a-bcde-f012-333333333333".into(), cwd: "/Users/demo/api-server".into(), project_name: "api-server".into(), started_at: now - 15 * 60 * 1000, // 15m ago status: SessionStatus::Executing, + status_evidence: demo_status_evidence( + SessionStatus::Executing, + StatusAuthority::Provider, + StatusReason::ProviderExecuting, + now - 90_000, + now, + ), model: "claude-haiku-4-5".into(), effort: String::new(), context_percent: 42.0, @@ -402,25 +456,31 @@ pub fn populate_demo(app: &mut App) { ToolCall { name: "Bash".into(), arg: "npm run dev".into(), - duration_ms: 1500, + duration_ms: 0, }, ], - pending_since_ms: 0, - // Model is generating its next reply - virtual "Thinking" row - // animates. 1s offset keeps the bar visibly growing against the - // session's 2.8s max tool duration before it caps at 100%. - thinking_since_ms: now - 1_000, + pending_since_ms: now - 90_000, + awaiting_input: false, + thinking_since_ms: 0, config_root: "~/.claude".into(), file_accesses: vec![], }, AgentSession { agent_cli: "codex", pid: 8901, + action_process_incarnation: None, session_id: "d4e5f6a7-89ab-cdef-0123-444444444444".into(), cwd: "/Users/demo/data-viz".into(), project_name: "data-viz".into(), started_at: now - 5 * 60 * 1000, // 5m ago - status: SessionStatus::Thinking, + status: SessionStatus::Unknown, + status_evidence: demo_status_evidence( + SessionStatus::Unknown, + StatusAuthority::Unavailable, + StatusReason::OwnershipUnconfirmed, + now, + now, + ), model: "gpt-5.4".into(), effort: "medium".into(), context_percent: 18.0, @@ -429,7 +489,7 @@ pub fn populate_demo(app: &mut App) { total_cache_read: 85_000, total_cache_create: 12_000, turn_count: 6, - current_tasks: vec!["Write src/charts/heatmap.py".into()], + current_tasks: vec!["authoritative status unavailable".into()], mem_mb: 64, version: "0.116.0".into(), git_branch: "feat/heatmap".into(), @@ -463,6 +523,7 @@ pub fn populate_demo(app: &mut App) { initial_prompt: "Create interactive heatmap component with D3.js".into(), tool_calls: vec![], pending_since_ms: 0, + awaiting_input: false, thinking_since_ms: 0, config_root: "~/.codex".into(), file_accesses: vec![], @@ -470,11 +531,19 @@ pub fn populate_demo(app: &mut App) { AgentSession { agent_cli: "opencode", pid: 9500, + action_process_incarnation: None, session_id: "ses_e5f6a7b8-9abc-def0-1234-555555555555".into(), cwd: "/Users/demo/infra".into(), project_name: "infra".into(), started_at: now - 8 * 60 * 1000, // 8m ago - status: SessionStatus::Thinking, + status: SessionStatus::Idle, + status_evidence: demo_status_evidence( + SessionStatus::Idle, + StatusAuthority::Heuristic, + StatusReason::CollectorInference, + now - 60_000, + now, + ), model: "ollama/qwen3:14b".into(), effort: String::new(), context_percent: 35.0, @@ -483,7 +552,7 @@ pub fn populate_demo(app: &mut App) { total_cache_read: 0, total_cache_create: 0, turn_count: 9, - current_tasks: vec!["thinking...".into()], + current_tasks: vec![], mem_mb: 156, version: "1.4.0".into(), git_branch: "main".into(), @@ -505,6 +574,7 @@ pub fn populate_demo(app: &mut App) { initial_prompt: "Refactor Terraform modules for multi-region".into(), tool_calls: vec![], pending_since_ms: 0, + awaiting_input: false, thinking_since_ms: 0, config_root: "~/.local/share/opencode".into(), file_accesses: vec![], @@ -544,6 +614,7 @@ pub fn populate_demo(app: &mut App) { seven_day_resets_at: Some(now_secs() + 5 * 24 * 3600), seven_day_window_minutes: Some(10_080), updated_at: Some(now_secs() - 10), + windows: Vec::new(), }, RateLimitInfo { source: "codex".into(), @@ -554,6 +625,7 @@ pub fn populate_demo(app: &mut App) { seven_day_resets_at: Some(now_secs() + 6 * 24 * 3600), seven_day_window_minutes: Some(10_080), updated_at: Some(now_secs() - 5), + windows: Vec::new(), }, ]; @@ -585,3 +657,68 @@ pub fn populate_demo(app: &mut App) { }); app.agent_aggregate = crate::host_info::AgentAggregate::from_sessions(&app.sessions); } + +#[cfg(test)] +mod tests { + use super::*; + + fn demo_app() -> App { + let mut app = App::new_with_config( + crate::theme::Theme::default(), + &[], + crate::config::PanelVisibility::default(), + ); + populate_demo(&mut app); + app + } + + #[test] + fn demo_status_evidence_matches_every_displayed_status() { + let app = demo_app(); + + for session in &app.sessions { + let evidence = &session.status_evidence; + let latest = evidence + .observations + .last() + .expect("every demo row has status evidence"); + assert_eq!(latest.status, session.status, "{}", session.session_id); + assert_eq!(latest.authority, evidence.authority); + assert_eq!(latest.reason, evidence.reason); + assert_eq!(latest.observed_at_ms, evidence.observed_at_ms); + assert_eq!(latest.connection_generation, evidence.connection_generation); + assert!(evidence.status_since_ms <= evidence.observed_at_ms); + assert_eq!( + session.awaiting_input, + session.status == SessionStatus::Waiting + ); + } + } + + #[test] + fn demo_codex_obeys_unmanaged_unknown_contract() { + let app = demo_app(); + let codex = app + .sessions + .iter() + .find(|session| session.agent_cli == "codex") + .expect("demo includes Codex"); + + assert_eq!(codex.status, SessionStatus::Unknown); + assert_eq!( + codex.status_evidence.authority, + StatusAuthority::Unavailable + ); + assert_eq!( + codex.status_evidence.reason, + StatusReason::OwnershipUnconfirmed + ); + assert!(!codex.awaiting_input); + assert_eq!(codex.pending_since_ms, 0); + assert_eq!(codex.thinking_since_ms, 0); + assert_eq!( + codex.current_tasks, + vec!["authoritative status unavailable".to_string()] + ); + } +} diff --git a/src/herdr.rs b/src/herdr.rs new file mode 100644 index 0000000..4fc6302 --- /dev/null +++ b/src/herdr.rs @@ -0,0 +1,676 @@ +//! Bounded client for the local Herdr CLI protocol. +//! +//! The monitor and terminal-jump adapter share these content-free response +//! types. Callers must still correlate a pane to an exact process before using +//! any returned state or issuing a focus request. + +use serde::Deserialize; +use std::ffi::OsStr; +#[cfg(unix)] +use std::io; +use std::io::Read; +use std::process::{Command, ExitStatus, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +pub(crate) const HERDR_ENV: &str = "HERDR_ENV"; +pub(crate) const HERDR_PANE_ID: &str = "HERDR_PANE_ID"; +pub(crate) const HERDR_SOCKET_PATH: &str = "HERDR_SOCKET_PATH"; +pub(crate) const MAX_COMMAND_OUTPUT: usize = 256 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CommandOutput { + pub success: bool, + pub status: String, + pub stdout: Vec, + pub stderr: Vec, +} + +#[derive(Debug, Deserialize)] +struct StatusAgentListEnvelope { + result: StatusAgentListResult, +} + +#[derive(Debug, Deserialize)] +struct StatusAgentListResult { + #[serde(default)] + agents: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct StatusAgentPane { + #[serde(default)] + pub terminal_id: String, + #[serde(default)] + pub agent: Option, + #[serde(default)] + pub agent_status: String, + #[serde(default)] + pub screen_detection_skipped: bool, + #[serde(default)] + pub agent_session: Option, + pub pane_id: String, + pub state_change_seq: u64, + #[serde(default)] + pub revision: u64, +} + +#[derive(Debug, Deserialize)] +struct FocusAgentListEnvelope { + result: FocusAgentListResult, +} + +#[derive(Debug, Deserialize)] +struct FocusAgentListResult { + #[serde(default)] + agents: Vec, +} + +/// Identity fields needed to focus a Herdr pane. +/// +/// Lifecycle metadata deliberately does not enter this type, so a compatible +/// status-schema change cannot disable an otherwise exact focus operation. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub(crate) struct FocusAgentPane { + #[serde(default)] + pub agent: Option, + #[serde(default)] + pub agent_session: Option, + pub pane_id: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct AgentSessionInfo { + pub source: String, + pub agent: String, + pub kind: String, + pub value: String, +} + +#[derive(Debug, Deserialize)] +struct ProcessInfoEnvelope { + result: ProcessInfoResult, +} + +#[derive(Debug, Deserialize)] +struct ProcessInfoResult { + process_info: ProcessInfo, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub(crate) struct ProcessInfo { + pub pane_id: String, + #[serde(default)] + pub foreground_processes: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub(crate) struct ProcessPid { + pub pid: u32, +} + +#[derive(Debug, Deserialize)] +struct FocusedPaneEnvelope { + result: FocusedPaneResult, +} + +#[derive(Debug, Deserialize)] +struct FocusedPaneResult { + agent: FocusedPane, +} + +#[derive(Debug, Deserialize)] +struct FocusedPane { + pane_id: String, +} + +#[derive(Debug, Deserialize)] +struct FocusedAgentEnvelope { + result: FocusedAgentResult, +} + +#[derive(Debug, Deserialize)] +struct FocusedAgentResult { + agent: FocusAgentPane, +} + +#[derive(Debug, Deserialize)] +struct ErrorEnvelope { + error: ErrorBody, +} + +#[derive(Debug, Deserialize)] +struct ErrorBody { + code: String, + message: String, +} + +pub(crate) fn parse_status_agent_list(bytes: &[u8]) -> Result, String> { + serde_json::from_slice::(bytes) + .map(|envelope| envelope.result.agents) + .map_err(|error| format!("agent list returned invalid JSON ({error})")) +} + +pub(crate) fn parse_focus_agent_list(bytes: &[u8]) -> Result, String> { + serde_json::from_slice::(bytes) + .map(|envelope| envelope.result.agents) + .map_err(|error| format!("agent list returned invalid JSON ({error})")) +} + +pub(crate) fn parse_process_info(bytes: &[u8]) -> Result { + serde_json::from_slice::(bytes) + .map(|envelope| envelope.result.process_info) + .map_err(|error| format!("pane process-info returned invalid JSON ({error})")) +} + +pub(crate) fn parse_focused_pane(bytes: &[u8]) -> Result { + serde_json::from_slice::(bytes) + .map(|envelope| envelope.result.agent.pane_id) + .map_err(|error| format!("agent focus returned invalid JSON ({error})")) +} + +pub(crate) fn parse_focused_agent(bytes: &[u8]) -> Result { + serde_json::from_slice::(bytes) + .map(|envelope| envelope.result.agent) + .map_err(|error| format!("agent focus returned invalid JSON ({error})")) +} + +fn parse_error_body(bytes: &[u8]) -> Option { + serde_json::from_slice::(bytes) + .ok() + .map(|envelope| envelope.error) +} + +pub(crate) fn error_code(output: &CommandOutput) -> Option { + parse_error_body(&output.stderr) + .or_else(|| parse_error_body(&output.stdout)) + .map(|error| error.code) +} + +pub(crate) fn format_command_failure(action: &str, output: &CommandOutput) -> String { + let detail = parse_error_body(&output.stderr) + .or_else(|| parse_error_body(&output.stdout)) + .map(|error| error.message) + .and_then(|message| concise_detail(message.as_bytes())) + .or_else(|| concise_detail(&output.stderr)) + .or_else(|| concise_detail(&output.stdout)); + let mut message = format!("{action} exited {}", output.status); + if let Some(detail) = detail { + message.push_str(": "); + message.push_str(&detail); + } + message +} + +fn concise_detail(output: &[u8]) -> Option { + let text = String::from_utf8_lossy(output); + let normalized = text.split_whitespace().collect::>().join(" "); + if normalized.is_empty() { + return None; + } + let mut detail: String = normalized.chars().take(160).collect(); + if normalized.chars().count() > 160 { + detail.push('…'); + } + Some(detail) +} + +/// Run one Herdr CLI request with null stdin, capped output, and a deadline. +pub(crate) fn run_herdr( + socket_path: &str, + args: &[String], + timeout: Duration, +) -> Result { + run_command( + OsStr::new("herdr"), + Some(socket_path), + args, + Instant::now() + timeout, + ) +} + +/// Run one Herdr CLI request against an existing absolute deadline. +/// +/// This form is used by multi-command actions so scheduling and setup time +/// between requests can never be added back to their aggregate budget. +pub(crate) fn run_herdr_until( + socket_path: &str, + args: &[String], + deadline: Instant, +) -> Result { + run_command(OsStr::new("herdr"), Some(socket_path), args, deadline) +} + +/// Run one content-free local helper command with the same output and deadline +/// bounds as the Herdr client, but without injecting a Herdr socket context. +#[cfg(unix)] +pub(crate) fn run_bounded_command_until( + binary: &OsStr, + args: &[String], + deadline: Instant, +) -> Result { + run_command(binary, None, args, deadline) +} + +fn run_command( + binary: &OsStr, + socket_path: Option<&str>, + args: &[String], + deadline: Instant, +) -> Result { + if Instant::now() >= deadline { + return Err("CLI timed out".to_string()); + } + run_command_inner(binary, socket_path, args, deadline) +} + +fn run_command_inner( + binary: &OsStr, + socket_path: Option<&str>, + args: &[String], + deadline: Instant, +) -> Result { + if Instant::now() >= deadline { + return Err("CLI timed out".to_string()); + } + let mut command = Command::new(binary); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(socket_path) = socket_path { + command.env(HERDR_SOCKET_PATH, socket_path); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + + // Rust's synchronous process creation API cannot be preempted safely. Run + // it directly so a timed-out focus can never continue in a detached worker, + // and bracket it with the same absolute deadline used by every controllable + // wait, output drain, and cleanup phase. + if Instant::now() >= deadline { + return Err("CLI timed out".to_string()); + } + let mut child = command + .spawn() + .map_err(|error| format!("CLI not runnable ({error})"))?; + if Instant::now() >= deadline { + terminate_child_bounded(child, deadline); + return Err("CLI timed out".to_string()); + } + let Some(stdout) = child.stdout.take() else { + terminate_child_bounded(child, deadline); + return Err("CLI stdout pipe unavailable".to_string()); + }; + let Some(stderr) = child.stderr.take() else { + terminate_child_bounded(child, deadline); + return Err("CLI stderr pipe unavailable".to_string()); + }; + let (tx, rx) = mpsc::channel(); + spawn_bounded_reader(stdout, 0, tx.clone()); + spawn_bounded_reader(stderr, 1, tx); + + #[cfg(unix)] + loop { + match child_exited_without_reaping(child.id()) { + Ok(true) => break, + Ok(false) if Instant::now() < deadline => { + std::thread::sleep( + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_millis(5)), + ); + } + Ok(false) => { + terminate_child_bounded(child, deadline); + return Err("CLI timed out".to_string()); + } + Err(error) => { + terminate_child_bounded(child, deadline); + return Err(format!("CLI wait failed ({error})")); + } + } + } + + #[cfg(not(unix))] + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep( + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_millis(5)), + ); + } + Ok(None) => { + terminate_child_bounded(child, deadline); + return Err("CLI timed out".to_string()); + } + Err(error) => { + terminate_child_bounded(child, deadline); + return Err(format!("CLI wait failed ({error})")); + } + } + }; + + let now = Instant::now(); + if now >= deadline { + terminate_child_bounded(child, deadline); + return Err("CLI timed out".to_string()); + } + let drain_deadline = output_drain_deadline(now, deadline); + let mut streams: [Option<(Vec, bool)>; 2] = [None, None]; + while streams.iter().any(Option::is_none) { + let now = Instant::now(); + if now >= drain_deadline { + terminate_child_bounded(child, deadline); + return Err("CLI retained an output pipe after exit".to_string()); + } + match rx.recv_timeout(drain_deadline.saturating_duration_since(now)) { + Ok((slot, bytes, truncated)) if slot < streams.len() => { + streams[slot] = Some((bytes, truncated)); + } + Ok(_) => { + terminate_child_bounded(child, deadline); + return Err("CLI returned an invalid output stream".to_string()); + } + Err(_) => { + terminate_child_bounded(child, deadline); + return Err("CLI output drain timed out".to_string()); + } + } + } + let (stdout, stdout_truncated) = streams[0].take().unwrap_or_default(); + let (stderr, stderr_truncated) = streams[1].take().unwrap_or_default(); + #[cfg(unix)] + let status = finalize_exited_child(child, deadline)?; + if stdout_truncated || stderr_truncated { + return Err("CLI output exceeded 256 KiB".to_string()); + } + Ok(command_output(status, stdout, stderr)) +} + +fn output_drain_deadline(now: Instant, command_deadline: Instant) -> Instant { + command_deadline.min(now + Duration::from_millis(100)) +} + +fn command_output(status: ExitStatus, stdout: Vec, stderr: Vec) -> CommandOutput { + CommandOutput { + success: status.success(), + status: status.to_string(), + stdout, + stderr, + } +} + +fn spawn_bounded_reader( + mut reader: impl Read + Send + 'static, + slot: usize, + tx: mpsc::Sender<(usize, Vec, bool)>, +) { + std::thread::spawn(move || { + let mut output = Vec::new(); + let mut truncated = false; + let mut buffer = [0_u8; 8192]; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(read) => { + let remaining = MAX_COMMAND_OUTPUT.saturating_sub(output.len()); + let retained = remaining.min(read); + output.extend_from_slice(&buffer[..retained]); + truncated |= retained < read; + } + Err(_) => break, + } + } + let _ = tx.send((slot, output, truncated)); + }); +} + +#[cfg(unix)] +fn child_exited_without_reaping(pid: u32) -> io::Result { + let mut information = std::mem::MaybeUninit::::zeroed(); + let result = unsafe { + libc::waitid( + libc::P_PID, + pid as libc::id_t, + information.as_mut_ptr(), + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + let information = unsafe { information.assume_init() }; + Ok(unsafe { information.si_pid() } != 0) +} + +#[cfg(unix)] +fn finalize_exited_child( + mut child: std::process::Child, + deadline: Instant, +) -> Result { + // The unreaped leader keeps its PID/PGID reserved while descendants are + // terminated, so the group signal cannot hit an unrelated reused ID. + signal_child_process_tree(&mut child); + match child.try_wait() { + Ok(Some(status)) => Ok(status), + Ok(None) => { + terminate_child_bounded(child, deadline); + Err("CLI exit could not be reaped before its deadline".to_string()) + } + Err(error) => { + terminate_child_bounded(child, deadline); + Err(format!("CLI wait failed ({error})")) + } + } +} + +fn signal_child_process_tree(child: &mut std::process::Child) { + #[cfg(unix)] + unsafe { + libc::kill(-(child.id() as i32), libc::SIGKILL); + } + let _ = child.kill(); +} + +fn terminate_child_bounded(mut child: std::process::Child, deadline: Instant) { + #[cfg(not(unix))] + { + // Non-Unix polling may already have reaped the direct child. Never + // signal its reusable numeric PID after that point. + if matches!(child.try_wait(), Ok(Some(_))) { + return; + } + } + signal_child_process_tree(&mut child); + loop { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep( + deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_millis(1)), + ); + } + Ok(None) | Err(_) => break, + } + } + + // Never make the caller wait indefinitely for a broken platform reap. + // The process was already terminated; a detached reaper prevents a zombie + // if the kernel reports its exit after the command deadline. + let _ = std::thread::Builder::new() + .name("abtop-herdr-reaper".to_string()) + .spawn(move || { + let _ = child.wait(); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + #[cfg(unix)] + use std::time::Instant; + + #[test] + fn status_agent_list_parser_matches_herdr_0_7_5_omitted_false_shape() { + let agents = parse_status_agent_list( + br#"{"result":{"agents":[{"terminal_id":"t1","agent":"codex","agent_status":"blocked","agent_session":{"source":"herdr:codex","agent":"codex","kind":"id","value":"s1"},"workspace_id":"w1","tab_id":"tab1","pane_id":"p1","focused":false,"state_change_seq":9,"revision":11,"terminal_title":"secret prompt"}]}}"#, + ) + .unwrap(); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].agent_status, "blocked"); + assert!(!agents[0].screen_detection_skipped); + assert_eq!(agents[0].pane_id, "p1"); + assert_eq!(agents[0].state_change_seq, 9); + } + + #[test] + fn status_agent_list_parser_preserves_explicit_skipped_detection() { + let agents = parse_status_agent_list( + br#"{"result":{"agents":[{"terminal_id":"t1","agent":"codex","agent_status":"blocked","screen_detection_skipped":true,"agent_session":{"source":"herdr:codex","agent":"codex","kind":"id","value":"s1"},"pane_id":"p1","state_change_seq":9}]}}"#, + ) + .unwrap(); + assert!(agents[0].screen_detection_skipped); + } + + #[test] + fn status_agent_list_parser_requires_sequence_metadata() { + let missing_sequence = br#"{"result":{"agents":[{"terminal_id":"t1","agent":"codex","agent_status":"blocked","screen_detection_skipped":false,"agent_session":{"source":"herdr:codex","agent":"codex","kind":"id","value":"s1"},"pane_id":"p1"}]}}"#; + assert!(parse_status_agent_list(missing_sequence).is_err()); + } + + #[test] + fn focus_agent_list_parser_does_not_require_lifecycle_metadata() { + let agents = parse_focus_agent_list( + br#"{"result":{"agents":[{"agent":"codex","agent_session":{"source":"herdr:codex","agent":"codex","kind":"id","value":"s1"},"pane_id":"p1"}]}}"#, + ) + .unwrap(); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].agent.as_deref(), Some("codex")); + assert_eq!(agents[0].pane_id, "p1"); + } + + #[test] + fn focused_agent_parser_keeps_exact_session_identity() { + let agent = parse_focused_agent( + br#"{"result":{"agent":{"terminal_id":"t1","agent":"codex","agent_status":"idle","agent_session":{"source":"herdr:codex","agent":"codex","kind":"id","value":"s1"},"workspace_id":"w1","tab_id":"tab1","pane_id":"p1","focused":true,"state_change_seq":10,"revision":12}}}"#, + ) + .unwrap(); + assert_eq!(agent.pane_id, "p1"); + assert_eq!(agent.agent.as_deref(), Some("codex")); + assert_eq!(agent.agent_session.unwrap().value, "s1"); + } + + #[test] + fn structured_command_errors_prefer_stderr_with_stdout_fallback() { + let stderr_first = CommandOutput { + success: false, + status: "exit status: 1".to_string(), + stdout: br#"{"error":{"code":"stdout_code","message":"stdout detail"}}"#.to_vec(), + stderr: br#"{"error":{"code":"stderr_code","message":"stderr detail"}}"#.to_vec(), + }; + assert_eq!(error_code(&stderr_first).as_deref(), Some("stderr_code")); + assert_eq!( + format_command_failure("agent focus", &stderr_first), + "agent focus exited exit status: 1: stderr detail" + ); + + let stdout_compatibility = CommandOutput { + stderr: Vec::new(), + ..stderr_first + }; + assert_eq!( + error_code(&stdout_compatibility).as_deref(), + Some("stdout_code") + ); + assert_eq!( + format_command_failure("agent focus", &stdout_compatibility), + "agent focus exited exit status: 1: stdout detail" + ); + } + + #[test] + fn process_info_parser_keeps_only_pids() { + let info = parse_process_info( + br#"{"result":{"process_info":{"pane_id":"p1","foreground_processes":[{"pid":42,"argv":["secret"]}]}}}"#, + ) + .unwrap(); + assert_eq!(info.pane_id, "p1"); + assert_eq!(info.foreground_processes[0].pid, 42); + } + + #[test] + fn bounded_reader_reports_truncated_output_without_growing_past_the_cap() { + let (tx, rx) = mpsc::channel(); + spawn_bounded_reader(Cursor::new(vec![b'x'; MAX_COMMAND_OUTPUT + 1]), 0, tx); + let (slot, output, truncated) = rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert_eq!(slot, 0); + assert_eq!(output.len(), MAX_COMMAND_OUTPUT); + assert!(truncated); + } + + #[test] + fn output_drain_is_contained_by_the_command_deadline() { + let now = Instant::now(); + let short_deadline = now + Duration::from_millis(25); + assert_eq!(output_drain_deadline(now, short_deadline), short_deadline); + assert_eq!( + output_drain_deadline(now, now + Duration::from_secs(1)), + now + Duration::from_millis(100) + ); + } + + #[cfg(unix)] + #[test] + fn command_timeout_kills_the_process_group_promptly() { + let started = Instant::now(); + let error = run_command( + OsStr::new("/bin/sh"), + Some("/tmp/herdr-test.sock"), + &["-c".to_string(), "sleep 5".to_string()], + Instant::now() + Duration::from_millis(30), + ) + .unwrap_err(); + assert!(error.contains("timed out")); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[cfg(unix)] + #[test] + fn retained_descendant_output_pipe_kills_the_process_group() { + let temp = tempfile::tempdir().unwrap(); + let marker = temp.path().join("descendant-survived"); + let started = Instant::now(); + let error = run_command( + OsStr::new("/bin/sh"), + Some(marker.to_str().unwrap()), + &[ + "-c".to_string(), + "(sleep 1; : > \"$HERDR_SOCKET_PATH\") &".to_string(), + ], + Instant::now() + Duration::from_secs(2), + ) + .unwrap_err(); + assert!( + error.contains("output pipe") || error.contains("output drain"), + "unexpected error: {error}" + ); + assert!(started.elapsed() < Duration::from_secs(1)); + + std::thread::sleep(Duration::from_millis(1_100)); + assert!( + !marker.exists(), + "descendant survived after retaining the output pipe" + ); + } +} diff --git a/src/jump/herdr.rs b/src/jump/herdr.rs new file mode 100644 index 0000000..7156d2c --- /dev/null +++ b/src/jump/herdr.rs @@ -0,0 +1,1095 @@ +//! Herdr backend. +//! +//! Herdr injects its pane and socket identity into every managed pane process. +//! The inherited pane ID is the fast path, while `agent list` recovers the +//! current public ID after a cross-workspace pane move. Before focusing, every +//! candidate is checked against Herdr's foreground-process inventory so a +//! stale ID or PID collision cannot redirect the jump. + +#[cfg(unix)] +use super::parse_env_var; +use super::{JumpAttempt, TerminalJumper}; +use crate::herdr::{ + error_code, format_command_failure, parse_focus_agent_list, parse_focused_agent, + parse_focused_pane, parse_process_info, CommandOutput, FocusAgentPane, HERDR_ENV, + HERDR_PANE_ID, HERDR_SOCKET_PATH, +}; +use std::collections::HashSet; +#[cfg(unix)] +use std::ffi::OsStr; +use std::time::{Duration, Instant}; + +const MAX_ID_BYTES: usize = 512; +const MAX_PID_AGENT_ROWS: usize = 256; +const MAX_PID_PANE_PROBES: usize = 32; +const PID_JUMP_TIMEOUT: Duration = Duration::from_secs(1); +const PID_COMMAND_TIMEOUT: Duration = Duration::from_millis(500); +const PID_COMPLETION_RESERVE: Duration = Duration::from_millis(25); +const SEMANTIC_JUMP_TIMEOUT: Duration = Duration::from_secs(1); +const SEMANTIC_COMMAND_TIMEOUT: Duration = Duration::from_millis(500); + +pub struct HerdrJumper; + +#[derive(Default)] +struct TargetHerdrEnv { + socket_path: Option, + pane_id: Option, +} + +impl TargetHerdrEnv { + fn get(&self, name: &str) -> Option { + match name { + HERDR_SOCKET_PATH => self.socket_path.clone(), + HERDR_PANE_ID => self.pane_id.clone(), + _ => None, + } + } +} + +impl TerminalJumper for HerdrJumper { + fn name(&self) -> &'static str { + "herdr" + } + + fn try_jump(&self, pid: u32) -> JumpAttempt { + try_pid_jump_with_budget( + pid, + PID_JUMP_TIMEOUT, + PID_COMMAND_TIMEOUT, + |name| std::env::var(name).ok(), + |deadline| read_target_herdr_env_before_deadline(pid, deadline), + crate::herdr::run_herdr_until, + ) + } +} + +fn try_pid_jump_with_budget( + pid: u32, + total_timeout: Duration, + command_timeout: Duration, + mut current_env: impl FnMut(&str) -> Option, + read_target_env: impl FnOnce(Instant) -> Result, + mut run: impl FnMut(&str, &[String], Instant) -> Result, +) -> JumpAttempt { + let completion_deadline = Instant::now() + total_timeout; + let work_deadline = completion_deadline + .checked_sub(PID_COMPLETION_RESERVE) + .unwrap_or(completion_deadline); + let current_herdr = non_empty(current_env(HERDR_ENV)); + if current_herdr.as_deref() != Some("1") { + return JumpAttempt::NotApplicable; + } + let Some(current_socket) = non_empty(current_env(HERDR_SOCKET_PATH)) else { + return JumpAttempt::NotApplicable; + }; + let target_env = match read_target_env(work_deadline) { + Ok(target_env) => target_env, + Err(message) => return JumpAttempt::Failed(message), + }; + + try_jump_with( + pid, + |name| match name { + HERDR_ENV => current_herdr.clone(), + HERDR_SOCKET_PATH => Some(current_socket.clone()), + _ => None, + }, + |name| target_env.get(name), + |socket_path, args| { + let now = Instant::now(); + if now >= work_deadline { + return Err("CLI timed out".to_string()); + } + let command_deadline = now + .checked_add(command_timeout) + .unwrap_or(work_deadline) + .min(work_deadline); + run(socket_path, args, command_deadline) + }, + ) +} + +fn remaining_before(deadline: Instant) -> Option { + let remaining = deadline.checked_duration_since(Instant::now())?; + (!remaining.is_zero()).then_some(remaining) +} + +#[cfg(unix)] +fn read_target_herdr_env_before_deadline( + pid: u32, + deadline: Instant, +) -> Result { + let _remaining = remaining_before(deadline) + .ok_or_else(|| "process environment lookup timed out".to_string())?; + let args = strings(&["eww", "-p", &pid.to_string()]); + let output = crate::herdr::run_bounded_command_until(OsStr::new("ps"), &args, deadline) + .map_err(|error| format!("process environment lookup failed ({error})"))?; + if !output.success { + return Ok(TargetHerdrEnv::default()); + } + let output = String::from_utf8_lossy(&output.stdout); + Ok(TargetHerdrEnv { + socket_path: parse_env_var(&output, HERDR_SOCKET_PATH), + pane_id: parse_env_var(&output, HERDR_PANE_ID), + }) +} + +#[cfg(not(unix))] +fn read_target_herdr_env_before_deadline( + pid: u32, + deadline: Instant, +) -> Result { + let remaining = remaining_before(deadline) + .ok_or_else(|| "process environment lookup timed out".to_string())?; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(TargetHerdrEnv { + socket_path: crate::collector::process::read_process_env_var(pid, HERDR_SOCKET_PATH), + pane_id: crate::collector::process::read_process_env_var(pid, HERDR_PANE_ID), + }); + }); + rx.recv_timeout(remaining) + .map_err(|_| "process environment lookup timed out".to_string()) +} + +/// Focus an exact provider-native session reported by Herdr. +/// +/// Unlike PID-based terminal actions, this path is safe for lifecycle rows +/// whose status or process-action ownership is unavailable: Herdr's native +/// session reference is itself the focus identity. It never authorizes a PID +/// action and falls through when Herdr has no exact session reference. +pub(super) fn try_session_jump(provider: &str, session_id: &str) -> JumpAttempt { + let deadline = Instant::now() + SEMANTIC_JUMP_TIMEOUT; + try_session_jump_with( + provider, + session_id, + |name| std::env::var(name).ok(), + |socket_path, args| { + run_herdr_before_deadline(socket_path, args, deadline, SEMANTIC_COMMAND_TIMEOUT) + }, + ) +} + +fn run_herdr_before_deadline( + socket_path: &str, + args: &[String], + deadline: Instant, + command_timeout: Duration, +) -> Result { + let now = Instant::now(); + if now >= deadline { + return Err("CLI timed out".to_string()); + } + let command_deadline = now + .checked_add(command_timeout) + .unwrap_or(deadline) + .min(deadline); + crate::herdr::run_herdr_until(socket_path, args, command_deadline) +} + +fn try_session_jump_with( + provider: &str, + session_id: &str, + mut current_env: impl FnMut(&str) -> Option, + mut run: impl FnMut(&str, &[String]) -> Result, +) -> JumpAttempt { + if non_empty(current_env(HERDR_ENV)).as_deref() != Some("1") { + return JumpAttempt::NotApplicable; + } + let Some(socket_path) = non_empty(current_env(HERDR_SOCKET_PATH)) else { + return JumpAttempt::NotApplicable; + }; + if !valid_identity_component(provider) || !valid_identity_component(session_id) { + return JumpAttempt::NotApplicable; + } + + let first = match list_agents(&socket_path, &mut run) { + Ok(agents) => agents, + Err(message) => return JumpAttempt::Failed(message), + }; + let first_pane = match unique_session_pane(&first, provider, session_id) { + Ok(Some(pane_id)) => pane_id, + Ok(None) => return JumpAttempt::NotApplicable, + Err(message) => return JumpAttempt::Failed(message), + }; + + // Re-read immediately before focus. A single cross-workspace pane move is + // accepted by following the new unique pane ID; disappearance, duplication, + // or any identity drift fails closed. + let second = match list_agents(&socket_path, &mut run) { + Ok(agents) => agents, + Err(message) => return JumpAttempt::Failed(message), + }; + let pane_id = match unique_session_pane(&second, provider, session_id) { + Ok(Some(pane_id)) => pane_id, + Ok(None) => { + return JumpAttempt::Failed(format!( + "selected {provider} session disappeared before focus" + )) + } + Err(message) => return JumpAttempt::Failed(message), + }; + let _moved = pane_id != first_pane; + focus_session_pane(&socket_path, &pane_id, provider, session_id, &mut run) +} + +fn list_agents( + socket_path: &str, + run: &mut impl FnMut(&str, &[String]) -> Result, +) -> Result, String> { + let args = strings(&["agent", "list"]); + let output = run(socket_path, &args)?; + if !output.success { + return Err(format_command_failure("agent list", &output)); + } + parse_focus_agent_list(&output.stdout) +} + +fn unique_session_pane( + agents: &[FocusAgentPane], + provider: &str, + session_id: &str, +) -> Result, String> { + let mut matching = agents + .iter() + .filter(|agent| agent_matches_session_identity(agent, provider, session_id)); + let Some(agent) = matching.next() else { + return Ok(None); + }; + if matching.next().is_some() { + return Err(format!( + "multiple panes claim the selected {provider} session" + )); + } + Ok(Some(agent.pane_id.clone())) +} + +fn agent_matches_session_identity( + agent: &FocusAgentPane, + provider: &str, + session_id: &str, +) -> bool { + let expected_source = format!("herdr:{provider}"); + valid_identity_component(&agent.pane_id) + && agent.agent.as_deref() == Some(provider) + && agent.agent_session.as_ref().is_some_and(|session| { + valid_identity_component(&session.source) + && valid_identity_component(&session.agent) + && valid_identity_component(&session.kind) + && valid_identity_component(&session.value) + && session.source == expected_source + && session.agent == provider + && session.kind == "id" + && session.value == session_id + }) +} + +fn valid_identity_component(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_ID_BYTES + && !value + .bytes() + .any(|byte| byte == 0 || byte == b'\n' || byte == b'\r') +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PaneProbe { + OwnsPid, + DifferentPid, + Missing, +} + +fn try_jump_with( + pid: u32, + mut current_env: impl FnMut(&str) -> Option, + mut target_env: impl FnMut(&str) -> Option, + mut run: impl FnMut(&str, &[String]) -> Result, +) -> JumpAttempt { + if non_empty(current_env(HERDR_ENV)).as_deref() != Some("1") { + return JumpAttempt::NotApplicable; + } + let Some(socket_path) = non_empty(current_env(HERDR_SOCKET_PATH)) else { + return JumpAttempt::NotApplicable; + }; + + let target_socket = non_empty(target_env(HERDR_SOCKET_PATH)); + let same_session_claimed = target_socket.as_deref() == Some(socket_path.as_str()); + + let inherited_pane = non_empty(target_env(HERDR_PANE_ID)); + let mut inspected = HashSet::new(); + let mut probes = 0_usize; + if let Some(pane_id) = inherited_pane.as_deref() { + inspected.insert(pane_id.to_string()); + probes += 1; + match probe_pane(&socket_path, pane_id, pid, &mut run) { + Ok(PaneProbe::OwnsPid) => { + return focus_pane(&socket_path, pane_id, &mut run); + } + Ok(PaneProbe::DifferentPid | PaneProbe::Missing) => {} + Err(message) => return JumpAttempt::Failed(message), + } + } + + let list_args = strings(&["agent", "list"]); + let list_output = match run(&socket_path, &list_args) { + Ok(output) => output, + Err(message) => return JumpAttempt::Failed(message), + }; + if !list_output.success { + return JumpAttempt::Failed(format_command_failure("agent list", &list_output)); + } + let listed = match parse_focus_agent_list(&list_output.stdout) { + Ok(agents) => agents, + Err(error) => { + return JumpAttempt::Failed(error); + } + }; + if listed.len() > MAX_PID_AGENT_ROWS { + return JumpAttempt::Failed(format!( + "agent list returned more than {MAX_PID_AGENT_ROWS} rows" + )); + } + + let mut matches = Vec::new(); + for agent in listed { + if agent.pane_id.is_empty() || !inspected.insert(agent.pane_id.clone()) { + continue; + } + if probes >= MAX_PID_PANE_PROBES { + return JumpAttempt::Failed(format!( + "agent list required more than {MAX_PID_PANE_PROBES} pane probes" + )); + } + probes += 1; + match probe_pane(&socket_path, &agent.pane_id, pid, &mut run) { + Ok(PaneProbe::OwnsPid) => matches.push(agent.pane_id), + Ok(PaneProbe::DifferentPid | PaneProbe::Missing) => {} + Err(message) => return JumpAttempt::Failed(message), + } + } + + match matches.as_slice() { + [pane_id] => focus_pane(&socket_path, pane_id, &mut run), + [] if same_session_claimed => { + JumpAttempt::Failed(format!("no current pane owns PID {pid}")) + } + [] => JumpAttempt::NotApplicable, + _ => JumpAttempt::Failed(format!("multiple panes claim PID {pid}")), + } +} + +fn probe_pane( + socket_path: &str, + pane_id: &str, + pid: u32, + run: &mut impl FnMut(&str, &[String]) -> Result, +) -> Result { + let args = strings(&["pane", "process-info", "--pane", pane_id]); + let output = run(socket_path, &args)?; + if !output.success { + if error_code(&output).as_deref() == Some("pane_not_found") { + return Ok(PaneProbe::Missing); + } + return Err(format_command_failure("pane process-info", &output)); + } + + let info = parse_process_info(&output.stdout)?; + if info.pane_id != pane_id { + return Err(format!( + "pane process-info returned {} for requested {pane_id}", + info.pane_id + )); + } + if info + .foreground_processes + .iter() + .any(|process| process.pid == pid) + { + Ok(PaneProbe::OwnsPid) + } else { + Ok(PaneProbe::DifferentPid) + } +} + +fn focus_session_pane( + socket_path: &str, + pane_id: &str, + provider: &str, + session_id: &str, + run: &mut impl FnMut(&str, &[String]) -> Result, +) -> JumpAttempt { + let args = strings(&["agent", "focus", pane_id]); + let output = match run(socket_path, &args) { + Ok(output) => output, + Err(message) => return JumpAttempt::Failed(message), + }; + if !output.success { + return JumpAttempt::Failed(format_command_failure("agent focus", &output)); + } + let focused = match parse_focused_agent(&output.stdout) { + Ok(agent) => agent, + Err(error) => return JumpAttempt::Failed(error), + }; + if focused.pane_id != pane_id { + return JumpAttempt::Failed(format!( + "agent focus returned {} for requested {pane_id}", + focused.pane_id + )); + } + if !agent_matches_session_identity(&focused, provider, session_id) { + return JumpAttempt::Failed(format!( + "agent focus did not confirm the selected {provider} session identity" + )); + } + JumpAttempt::Jumped +} + +fn focus_pane( + socket_path: &str, + pane_id: &str, + run: &mut impl FnMut(&str, &[String]) -> Result, +) -> JumpAttempt { + let args = strings(&["agent", "focus", pane_id]); + let output = match run(socket_path, &args) { + Ok(output) => output, + Err(message) => return JumpAttempt::Failed(message), + }; + if !output.success { + return JumpAttempt::Failed(format_command_failure("agent focus", &output)); + } + let focused_pane = match parse_focused_pane(&output.stdout) { + Ok(pane_id) => pane_id, + Err(error) => return JumpAttempt::Failed(error), + }; + if focused_pane != pane_id { + return JumpAttempt::Failed(format!( + "agent focus returned {} for requested {pane_id}", + focused_pane + )); + } + JumpAttempt::Jumped +} + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() +} + +fn non_empty(value: Option) -> Option { + value.filter(|candidate| !candidate.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + + fn ok(json: &str) -> Result { + Ok(CommandOutput { + success: true, + status: "exit status: 0".to_string(), + stdout: json.as_bytes().to_vec(), + stderr: Vec::new(), + }) + } + + fn failed(json: &str) -> Result { + Ok(CommandOutput { + success: false, + status: "exit status: 1".to_string(), + stdout: Vec::new(), + stderr: json.as_bytes().to_vec(), + }) + } + + fn process_info(pane_id: &str, pids: &[u32]) -> String { + let processes = pids + .iter() + .map(|pid| serde_json::json!({"pid": pid})) + .collect::>(); + serde_json::json!({ + "result": { + "process_info": { + "pane_id": pane_id, + "foreground_processes": processes + } + } + }) + .to_string() + } + + fn agent_list(pane_ids: &[&str]) -> String { + let agents = pane_ids + .iter() + .map(|pane_id| serde_json::json!({"pane_id": pane_id})) + .collect::>(); + serde_json::json!({"result": {"agents": agents}}).to_string() + } + + fn session_agent_list(rows: &[(&str, &str, &str)]) -> String { + let agents = rows + .iter() + .map(|(pane_id, provider, session_id)| { + serde_json::json!({ + "agent": provider, + "agent_session": { + "source": format!("herdr:{provider}"), + "agent": provider, + "kind": "id", + "value": session_id + }, + "pane_id": pane_id, + }) + }) + .collect::>(); + serde_json::json!({"result": {"agents": agents}}).to_string() + } + + fn focused(pane_id: &str) -> String { + serde_json::json!({"result": {"agent": {"pane_id": pane_id}}}).to_string() + } + + fn focused_session(pane_id: &str, provider: &str, session_id: &str) -> String { + serde_json::json!({ + "result": { + "agent": { + "terminal_id": format!("terminal-{pane_id}"), + "agent": provider, + "agent_status": "idle", + "agent_session": { + "source": format!("herdr:{provider}"), + "agent": provider, + "kind": "id", + "value": session_id + }, + "workspace_id": "workspace-1", + "tab_id": "tab-1", + "pane_id": pane_id, + "focused": true, + "state_change_seq": 9, + "revision": 11 + } + } + }) + .to_string() + } + + fn scripted( + script: Vec<(Vec, Result)>, + ) -> impl FnMut(&str, &[String]) -> Result { + let mut script = VecDeque::from(script); + move |socket_path, args| { + assert_eq!(socket_path, "/tmp/herdr.sock"); + let (expected, output) = script.pop_front().expect("unexpected Herdr command"); + assert_eq!(args, expected.as_slice()); + output + } + } + + #[test] + fn outside_herdr_is_not_applicable() { + let mut called = false; + let attempt = try_jump_with( + 42, + |_| None, + |_| None, + |_, _| { + called = true; + ok("{}") + }, + ); + assert_eq!(attempt, JumpAttempt::NotApplicable); + assert!(!called); + } + + #[test] + fn expired_jump_deadline_fails_before_starting_the_cli() { + let error = run_herdr_before_deadline( + "/path/that/must/not/be-used.sock", + &strings(&["agent", "list"]), + Instant::now(), + Duration::from_secs(1), + ) + .unwrap_err(); + assert_eq!(error, "CLI timed out"); + } + + #[test] + fn pid_jump_budget_includes_environment_lookup_and_command_elapsed() { + let total_timeout = Duration::from_millis(300); + let started = Instant::now(); + let mut environment_deadline = None; + let mut command_deadline = None; + let attempt = try_pid_jump_with_budget( + 42, + total_timeout, + Duration::from_millis(290), + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |deadline| { + environment_deadline = Some(deadline); + std::thread::sleep(Duration::from_millis(120)); + Ok(TargetHerdrEnv::default()) + }, + |_, _, deadline| { + command_deadline = Some(deadline); + std::thread::sleep(deadline.saturating_duration_since(Instant::now())); + Err("CLI timed out".to_string()) + }, + ); + let elapsed = started.elapsed(); + + assert_eq!(attempt, JumpAttempt::Failed("CLI timed out".to_string())); + assert!( + command_deadline.is_some() && command_deadline == environment_deadline, + "the same aggregate absolute deadline did not reach both operations: env={environment_deadline:?}, command={command_deadline:?}" + ); + assert!( + elapsed < total_timeout + Duration::from_millis(40), + "aggregate PID jump budget was exceeded: {elapsed:?}" + ); + } + + #[test] + fn exact_native_session_focus_ignores_lifecycle_status() { + let list = session_agent_list(&[("w1:p2", "codex", "session-1")]); + let script = vec![ + (strings(&["agent", "list"]), ok(&list)), + (strings(&["agent", "list"]), ok(&list)), + ( + strings(&["agent", "focus", "w1:p2"]), + ok(&focused_session("w1:p2", "codex", "session-1")), + ), + ]; + let attempt = try_session_jump_with( + "codex", + "session-1", + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!(attempt, JumpAttempt::Jumped); + } + + #[test] + fn exact_native_session_follows_one_pane_move() { + let first = session_agent_list(&[("w1:p2", "codex", "session-1")]); + let second = session_agent_list(&[("w2:p9", "codex", "session-1")]); + let script = vec![ + (strings(&["agent", "list"]), ok(&first)), + (strings(&["agent", "list"]), ok(&second)), + ( + strings(&["agent", "focus", "w2:p9"]), + ok(&focused_session("w2:p9", "codex", "session-1")), + ), + ]; + let attempt = try_session_jump_with( + "codex", + "session-1", + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!(attempt, JumpAttempt::Jumped); + } + + #[test] + fn semantic_focus_rejects_a_mismatched_returned_session_identity() { + let list = session_agent_list(&[("w1:p2", "codex", "session-1")]); + let script = vec![ + (strings(&["agent", "list"]), ok(&list)), + (strings(&["agent", "list"]), ok(&list)), + ( + strings(&["agent", "focus", "w1:p2"]), + ok(&focused_session("w1:p2", "codex", "session-2")), + ), + ]; + let attempt = try_session_jump_with( + "codex", + "session-1", + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!( + attempt, + JumpAttempt::Failed( + "agent focus did not confirm the selected codex session identity".to_string() + ) + ); + } + + #[test] + fn missing_or_mismatched_native_session_falls_through() { + let list = session_agent_list(&[("w1:p2", "codex", "session-2")]); + let script = vec![(strings(&["agent", "list"]), ok(&list))]; + let attempt = try_session_jump_with( + "codex", + "session-1", + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!(attempt, JumpAttempt::NotApplicable); + } + + #[test] + fn duplicate_native_session_claims_fail_closed() { + let list = session_agent_list(&[ + ("w1:p2", "codex", "session-1"), + ("w2:p9", "codex", "session-1"), + ]); + let script = vec![(strings(&["agent", "list"]), ok(&list))]; + let attempt = try_session_jump_with( + "codex", + "session-1", + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + scripted(script), + ); + assert!(matches!( + attempt, + JumpAttempt::Failed(message) if message.contains("multiple panes claim") + )); + } + + #[test] + fn semantic_match_requires_exact_source_agent_and_kind() { + let parsed = parse_focus_agent_list( + session_agent_list(&[("w1:p2", "codex", "session-1")]).as_bytes(), + ) + .unwrap(); + assert_eq!( + unique_session_pane(&parsed, "codex", "session-1").unwrap(), + Some("w1:p2".to_string()) + ); + + for field in ["source", "agent", "kind"] { + let mut changed = parsed.clone(); + let session = changed[0].agent_session.as_mut().unwrap(); + match field { + "source" => session.source = "herdr:claude".to_string(), + "agent" => session.agent = "claude".to_string(), + "kind" => session.kind = "path".to_string(), + _ => unreachable!(), + } + assert_eq!( + unique_session_pane(&changed, "codex", "session-1").unwrap(), + None, + "{field} must match exactly" + ); + } + } + + #[test] + fn another_herdr_session_falls_through_after_current_server_check() { + let script = vec![ + ( + strings(&["pane", "process-info", "--pane", "w2:p1"]), + failed(r#"{"error":{"code":"pane_not_found","message":"pane not found"}}"#), + ), + (strings(&["agent", "list"]), ok(&agent_list(&[]))), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/other.sock".to_string()), + HERDR_PANE_ID => Some("w2:p1".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!(attempt, JumpAttempt::NotApplicable); + } + + #[test] + fn inherited_pane_with_exact_pid_focuses_directly() { + let script = vec![ + ( + strings(&["pane", "process-info", "--pane", "w1:p2"]), + ok(&process_info("w1:p2", &[7, 42])), + ), + (strings(&["agent", "focus", "w1:p2"]), ok(&focused("w1:p2"))), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + HERDR_PANE_ID => Some("w1:p2".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!(attempt, JumpAttempt::Jumped); + } + + #[test] + fn stale_inherited_pane_falls_back_to_current_agent_list() { + let script = vec![ + ( + strings(&["pane", "process-info", "--pane", "w1:p2"]), + failed(r#"{"error":{"code":"pane_not_found","message":"pane not found"}}"#), + ), + (strings(&["agent", "list"]), ok(&agent_list(&["w2:p9"]))), + ( + strings(&["pane", "process-info", "--pane", "w2:p9"]), + ok(&process_info("w2:p9", &[42])), + ), + (strings(&["agent", "focus", "w2:p9"]), ok(&focused("w2:p9"))), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |name| match name { + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + HERDR_PANE_ID => Some("w1:p2".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!(attempt, JumpAttempt::Jumped); + } + + #[test] + fn no_matching_current_server_pane_falls_through() { + let script = vec![ + (strings(&["agent", "list"]), ok(&agent_list(&["w1:p1"]))), + ( + strings(&["pane", "process-info", "--pane", "w1:p1"]), + ok(&process_info("w1:p1", &[7])), + ), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |_| None, + scripted(script), + ); + assert_eq!(attempt, JumpAttempt::NotApplicable); + } + + #[test] + fn claimed_same_session_without_current_pane_is_a_failure() { + let script = vec![(strings(&["agent", "list"]), ok(&agent_list(&[])))]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |name| match name { + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!( + attempt, + JumpAttempt::Failed("no current pane owns PID 42".to_string()) + ); + } + + #[test] + fn multiple_pid_matches_fail_closed() { + let script = vec![ + ( + strings(&["agent", "list"]), + ok(&agent_list(&["w1:p1", "w1:p2"])), + ), + ( + strings(&["pane", "process-info", "--pane", "w1:p1"]), + ok(&process_info("w1:p1", &[42])), + ), + ( + strings(&["pane", "process-info", "--pane", "w1:p2"]), + ok(&process_info("w1:p2", &[42])), + ), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |_| None, + scripted(script), + ); + assert_eq!( + attempt, + JumpAttempt::Failed("multiple panes claim PID 42".to_string()) + ); + } + + #[test] + fn malformed_agent_list_is_reported() { + let script = vec![(strings(&["agent", "list"]), ok("not-json"))]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |_| None, + scripted(script), + ); + assert!(matches!( + attempt, + JumpAttempt::Failed(message) if message.starts_with("agent list returned invalid JSON") + )); + } + + #[test] + fn legacy_pid_focus_rejects_an_oversized_agent_list_before_probing() { + let pane_ids = (0..=MAX_PID_AGENT_ROWS) + .map(|index| format!("w1:p{index}")) + .collect::>(); + let pane_refs = pane_ids.iter().map(String::as_str).collect::>(); + let script = vec![(strings(&["agent", "list"]), ok(&agent_list(&pane_refs)))]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |_| None, + scripted(script), + ); + assert_eq!( + attempt, + JumpAttempt::Failed(format!( + "agent list returned more than {MAX_PID_AGENT_ROWS} rows" + )) + ); + } + + #[test] + fn legacy_pid_focus_caps_the_number_of_pane_probes() { + let pane_ids = (0..=MAX_PID_PANE_PROBES) + .map(|index| format!("w1:p{index}")) + .collect::>(); + let pane_refs = pane_ids.iter().map(String::as_str).collect::>(); + let mut script = vec![(strings(&["agent", "list"]), ok(&agent_list(&pane_refs)))]; + script.extend(pane_ids.iter().take(MAX_PID_PANE_PROBES).map(|pane_id| { + ( + strings(&["pane", "process-info", "--pane", pane_id]), + ok(&process_info(pane_id, &[])), + ) + })); + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |_| None, + scripted(script), + ); + assert_eq!( + attempt, + JumpAttempt::Failed(format!( + "agent list required more than {MAX_PID_PANE_PROBES} pane probes" + )) + ); + } + + #[test] + fn focus_response_must_name_the_requested_pane() { + let script = vec![ + ( + strings(&["pane", "process-info", "--pane", "w1:p2"]), + ok(&process_info("w1:p2", &[42])), + ), + (strings(&["agent", "focus", "w1:p2"]), ok(&focused("w1:p3"))), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |name| match name { + HERDR_PANE_ID => Some("w1:p2".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!( + attempt, + JumpAttempt::Failed("agent focus returned w1:p3 for requested w1:p2".to_string()) + ); + } + + #[test] + fn command_failure_uses_structured_error_message() { + let script = vec![ + ( + strings(&["pane", "process-info", "--pane", "w1:p2"]), + ok(&process_info("w1:p2", &[42])), + ), + ( + strings(&["agent", "focus", "w1:p2"]), + failed(r#"{"error":{"code":"agent_not_found","message":"agent not found"}}"#), + ), + ]; + let attempt = try_jump_with( + 42, + |name| match name { + HERDR_ENV => Some("1".to_string()), + HERDR_SOCKET_PATH => Some("/tmp/herdr.sock".to_string()), + _ => None, + }, + |name| match name { + HERDR_PANE_ID => Some("w1:p2".to_string()), + _ => None, + }, + scripted(script), + ); + assert_eq!( + attempt, + JumpAttempt::Failed("agent focus exited exit status: 1: agent not found".to_string()) + ); + } +} diff --git a/src/jump/mod.rs b/src/jump/mod.rs index e1d42fa..3648c3a 100644 --- a/src/jump/mod.rs +++ b/src/jump/mod.rs @@ -11,6 +11,7 @@ //! process. Only `Failed`/`Jumped` stop the walk. mod cmux; +mod herdr; #[cfg(target_os = "macos")] mod iterm2; mod tmux; @@ -54,12 +55,13 @@ pub fn resolve(jumpers: &[Box], pid: u32) -> JumpOutcome { } /// The registry: the single ordered source of truth for supported terminals. -/// Order = most specific first: cmux (env-tagged) → tmux (multiplexer) → -/// iTerm2 on macOS (emulator). They are mutually exclusive by tty, so order -/// only matters for the multiplexer-inside-emulator case. +/// Order = most specific first: Herdr (multiplexer) → cmux (env-tagged) → +/// tmux (multiplexer) → iTerm2 on macOS (emulator). Order matters when a +/// multiplexer inherits its outer terminal's environment. #[cfg(target_os = "macos")] pub fn jumpers() -> Vec> { vec![ + Box::new(herdr::HerdrJumper), Box::new(cmux::CmuxJumper), Box::new(tmux::TmuxJumper), Box::new(iterm2::ITerm2Jumper), @@ -71,7 +73,11 @@ pub fn jumpers() -> Vec> { /// sessions no-op cleanly instead of trying macOS-only `osascript`. #[cfg(not(target_os = "macos"))] pub fn jumpers() -> Vec> { - vec![Box::new(cmux::CmuxJumper), Box::new(tmux::TmuxJumper)] + vec![ + Box::new(herdr::HerdrJumper), + Box::new(cmux::CmuxJumper), + Box::new(tmux::TmuxJumper), + ] } /// Entry point used by the app: run the selected PID through the registry. @@ -79,6 +85,17 @@ pub fn run_jump(pid: u32) -> JumpOutcome { resolve(&jumpers(), pid) } +/// Try the exact Herdr-native session identity before any PID-based adapter. +/// `NoOp` means Herdr had no exact reference and the caller may apply the +/// existing process-action policy before falling back to [`run_jump`]. +pub(crate) fn run_herdr_session_jump(provider: &str, session_id: &str) -> JumpOutcome { + match herdr::try_session_jump(provider, session_id) { + JumpAttempt::NotApplicable => JumpOutcome::NoOp, + JumpAttempt::Jumped => JumpOutcome::Jumped, + JumpAttempt::Failed(message) => JumpOutcome::Failed(format!("herdr: {message}")), + } +} + // --------------------------------------------------------------------------- // Shared parsing helpers (pure — unit-tested below). // --------------------------------------------------------------------------- @@ -325,10 +342,12 @@ mod tests { #[test] fn jumpers_include_platform_backends() { #[cfg(target_os = "macos")] - assert_eq!(jumpers().len(), 3); + assert_eq!(jumpers().len(), 4); #[cfg(not(target_os = "macos"))] - assert_eq!(jumpers().len(), 2); + assert_eq!(jumpers().len(), 3); + + assert_eq!(jumpers()[0].name(), "herdr"); } // ---- find_pane_target (tmux) ---- diff --git a/src/lib.rs b/src/lib.rs index b393efc..359eed1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,7 +18,8 @@ //! //! Enum wire formats are part of the snapshot contract: variants such as //! [`model::SessionStatus`] serialize as their CamelCase names (`"Thinking"`, -//! `"Executing"`, …) and chat roles serialize as `"user"` / `"assistant"`. +//! `"Executing"`, `"Working"`, `"Idle"`, …) and chat roles serialize as `"user"` / +//! `"assistant"`. //! These strings are stable and won't be renamed without a major version bump. //! //! # Threading model @@ -36,11 +37,12 @@ //! use abtop::{config, theme::Theme}; //! //! let cfg = config::load_config(); -//! let mut app = App::new_with_config_and_claude_dirs( +//! let mut app = App::new_with_config_and_claude_dirs_and_codexbar( //! Theme::default(), //! &cfg.hidden_agents, //! cfg.panels, //! &cfg.claude_config_dirs, +//! cfg.codexbar_quota_fallback, //! ); //! loop { //! app.tick_no_summaries(); // refresh without spawning `claude --print` @@ -52,9 +54,12 @@ //! ``` pub mod app; +mod codex_compat; +mod codex_hooks; pub mod collector; pub mod config; pub mod demo; +mod herdr; pub mod host_info; pub mod jump; pub mod locale; @@ -67,50 +72,150 @@ pub mod ui; use app::{App, JumpOutcome}; use crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, - MouseButton, MouseEvent, MouseEventKind, + KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use crossterm::ExecutableCommand; use ratatui::prelude::*; +use std::ffi::{OsStr, OsString}; use std::io::{self, stdout}; use std::time::Duration; -/// Construct a headless `App` from loaded config + theme. Shared by the -/// `--json` and `--once` entry points. -fn build_app(theme: theme::Theme, cfg: &config::AppConfig) -> App { - App::new_with_config_and_claude_dirs( - theme, - &cfg.hidden_agents, - cfg.panels, - &cfg.claude_config_dirs, - ) +// The collector enforces a 15-second process timeout. One-shot modes wait one +// additional second so they observe the sanitized terminal result instead of +// racing the worker and serializing a stale `checking` state. +const CODEXBAR_ONESHOT_WAIT: Duration = Duration::from_secs(16); + +/// Construct a headless `App`. Demo mode deliberately ignores user-specific +/// visibility and discovery settings so its snapshots remain reproducible. +fn build_app(theme: theme::Theme, cfg: &config::AppConfig, demo_mode: bool) -> App { + if demo_mode { + App::new_with_config_and_claude_dirs(theme, &[], config::PanelVisibility::default(), &[]) + } else { + App::new_with_config_and_claude_dirs_and_codexbar( + theme, + &cfg.hidden_agents, + cfg.panels, + &cfg.claude_config_dirs, + cfg.codexbar_quota_fallback, + ) + } } pub fn run() -> io::Result<()> { - // --version / -V flag: print version and exit - if std::env::args().any(|a| a == "--version" || a == "-V") { - println!("abtop {}", env!("CARGO_PKG_VERSION")); + let raw_args = std::env::args_os().skip(1).collect::>(); + + // Hook launchers must never print, block the provider on an error, or + // enter the TUI. Any invocation beginning with this private flag is + // consumed here and exits successfully, including malformed arguments. + if raw_args + .first() + .is_some_and(|argument| argument == OsStr::new("--codex-hook-ingest")) + { + codex_hooks::ingest_silently(raw_args[1..].to_vec()); return Ok(()); } - // --update flag: self-update via GitHub releases installer - if std::env::args().any(|a| a == "--update") { - return run_update(); + match codex_dispatch(raw_args.clone()) { + CodexDispatch::NotRequested => {} + CodexDispatch::Help => { + print_codex_launcher_help(); + return Ok(()); + } + CodexDispatch::Invalid(message) => { + eprintln!("{message}"); + eprintln!("usage: abtop codex -- [LEGACY_FORWARDED_ARGS...]"); + std::process::exit(2); + } + CodexDispatch::Launch(args) => { + let exit_code = codex_compat::run(args)?; + if exit_code != 0 { + std::process::exit(exit_code); + } + return Ok(()); + } } - // --setup flag: configure StatusLine hook and exit - if std::env::args().any(|a| a == "--setup") { + // Native Codex plugin administration is separate from Claude's --setup. + match codex_admin_dispatch(raw_args.clone()) { + CodexAdminDispatch::NotRequested => {} + CodexAdminDispatch::Invalid => { + eprintln!( + "usage: abtop --setup-codex | --uninstall-codex | --codex-integration-status" + ); + std::process::exit(2); + } + CodexAdminDispatch::Setup => { + match codex_hooks::plugin::setup() { + Ok(report) => print_codex_setup_report(&report), + Err(error) => { + eprintln!("Codex plugin setup failed: {error}"); + std::process::exit(1); + } + } + return Ok(()); + } + CodexAdminDispatch::Uninstall => { + match codex_hooks::plugin::uninstall() { + Ok(report) => print_codex_uninstall_report(&report), + Err(error) => { + eprintln!("Codex plugin uninstall failed: {error}"); + std::process::exit(1); + } + } + return Ok(()); + } + CodexAdminDispatch::Status => { + match codex_hooks::plugin::status() { + Ok(status) => { + print_codex_integration_status(&status); + if !status.healthy { + std::process::exit(1); + } + } + Err(error) => { + eprintln!("Codex integration status failed: {error}"); + std::process::exit(1); + } + } + return Ok(()); + } + } + + // --setup is deliberately a Claude-only exact singleton. + if raw_args.iter().any(|a| a == OsStr::new("--setup")) { + if raw_args.as_slice() != [OsString::from("--setup")] { + eprintln!("usage: abtop --setup"); + std::process::exit(2); + } setup::run_setup(); return Ok(()); } + // --version / -V flag: print version and exit + if raw_args + .iter() + .any(|a| a == OsStr::new("--version") || a == OsStr::new("-V")) + { + println!("abtop {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + // --update flag: self-update via GitHub releases installer + if raw_args.iter().any(|a| a == OsStr::new("--update")) { + return run_update(); + } + // Load config once; it drives both the default theme and the hidden-agents list. let cfg = config::load_config(); - // --theme flag > config file > default - let initial_theme = std::env::args() + let demo_mode = std::env::args().any(|a| a == "--demo"); + + // --theme flag > config file > default. Demo mode ignores the persisted + // theme when no explicit override is supplied. + let explicit_theme = std::env::args() .position(|a| a == "--theme") .map(|pos| { let val = std::env::args().nth(pos + 1); @@ -137,10 +242,15 @@ pub fn run() -> io::Result<()> { ); std::process::exit(1); }) - }) - .or_else(|| theme::Theme::by_name(&cfg.theme)); + }); + let initial_theme = explicit_theme.or_else(|| { + if demo_mode { + None + } else { + theme::Theme::by_name(&cfg.theme) + } + }); - let demo_mode = std::env::args().any(|a| a == "--demo"); let exit_on_jump = std::env::args().any(|a| a == "--exit-on-jump"); let mouse_capture = should_enable_mouse_capture(std::env::args()); @@ -149,11 +259,12 @@ pub fn run() -> io::Result<()> { // manual check of the web snapshot API; the web tool uses the library // `App::to_snapshot` directly rather than shelling out to this. if std::env::args().any(|a| a == "--json") { - let mut app = build_app(initial_theme.unwrap_or_default(), &cfg); + let mut app = build_app(initial_theme.unwrap_or_default(), &cfg, demo_mode); if demo_mode { demo::populate_demo(&mut app); } else { app.tick_no_summaries(); + app.wait_for_initial_codexbar_quota(CODEXBAR_ONESHOT_WAIT); } match serde_json::to_string_pretty(&app.to_snapshot(2000)) { Ok(json) => { @@ -169,7 +280,7 @@ pub fn run() -> io::Result<()> { // --once flag: print snapshot and exit if std::env::args().any(|a| a == "--once") { - let mut app = build_app(initial_theme.unwrap_or_default(), &cfg); + let mut app = build_app(initial_theme.unwrap_or_default(), &cfg, demo_mode); if demo_mode { demo::populate_demo(&mut app); } else { @@ -183,6 +294,11 @@ pub fn run() -> io::Result<()> { } std::thread::sleep(Duration::from_millis(500)); } + // Summary generation can take long enough for lifecycle state to + // change. Recollect once without scheduling another summary job so + // the printed status reflects the end of the wait, not its start. + app.tick_no_summaries(); + app.wait_for_initial_codexbar_quota(CODEXBAR_ONESHOT_WAIT); } print_snapshot(&app); return Ok(()); @@ -196,15 +312,7 @@ pub fn run() -> io::Result<()> { } let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; - let app_result = run_app( - &mut terminal, - demo_mode, - initial_theme, - exit_on_jump, - &cfg.hidden_agents, - cfg.panels, - &cfg.claude_config_dirs, - ); + let app_result = run_app(&mut terminal, demo_mode, initial_theme, exit_on_jump, &cfg); // Always attempt both cleanup steps regardless of app result let r1 = if mouse_capture { @@ -219,6 +327,214 @@ pub fn run() -> io::Result<()> { app_result.and(r1).and(r2).and(r3) } +#[derive(Debug, PartialEq, Eq)] +enum CodexDispatch { + NotRequested, + Help, + Launch(Vec), + Invalid(String), +} + +#[derive(Debug, PartialEq, Eq)] +enum CodexAdminDispatch { + NotRequested, + Setup, + Uninstall, + Status, + Invalid, +} + +fn codex_admin_dispatch(args: I) -> CodexAdminDispatch +where + I: IntoIterator, +{ + let args: Vec<_> = args.into_iter().collect(); + let requested = args.iter().any(|argument| { + matches!( + argument.to_str(), + Some("--setup-codex" | "--uninstall-codex" | "--codex-integration-status") + ) + }); + if !requested { + return CodexAdminDispatch::NotRequested; + } + match args.as_slice() { + [argument] if argument == OsStr::new("--setup-codex") => CodexAdminDispatch::Setup, + [argument] if argument == OsStr::new("--uninstall-codex") => CodexAdminDispatch::Uninstall, + [argument] if argument == OsStr::new("--codex-integration-status") => { + CodexAdminDispatch::Status + } + _ => CodexAdminDispatch::Invalid, + } +} + +fn codex_dispatch(args: I) -> CodexDispatch +where + I: IntoIterator, +{ + let mut args = args.into_iter(); + if args.next().as_deref() != Some(OsStr::new("codex")) { + return CodexDispatch::NotRequested; + } + + let Some(separator) = args.next() else { + return CodexDispatch::Invalid("missing `--` before Codex arguments".to_string()); + }; + if separator == OsStr::new("--help") || separator == OsStr::new("-h") { + if args.next().is_some() { + return CodexDispatch::Invalid( + "launcher help does not accept additional arguments".to_string(), + ); + } + return CodexDispatch::Help; + } + if separator != OsStr::new("--") { + return CodexDispatch::Invalid(format!( + "expected `--` before Codex arguments, got `{}`", + separator.to_string_lossy() + )); + } + + CodexDispatch::Launch(args.collect()) +} + +fn print_codex_launcher_help() { + println!("Compatibility trampoline for retired abtop 0.6 shell integration."); + println!(); + println!("usage: abtop codex -- [LEGACY_FORWARDED_ARGS...]"); + println!(); + println!("It directly executes the binary captured by the old shell block."); + println!("Use native `codex ...` for normal work and run `abtop --setup-codex`"); + println!("to remove the retired block and install the isolated hook plugin."); +} + +const CODEX_COVERAGE_NOTICE: &str = "Supported Codex releases cannot attest effective managed/cloud/profile/live hook coverage; installation readiness is diagnostic only."; +const CODEX_LIVE_STATUS_NOTICE: &str = "Exact supported Codex hook/rollout shapes may report non-actionable heuristic Think, Exec, or Idle; tool and interaction ambiguity remains Unknown. Exactly correlated Herdr terminals may additionally report non-actionable heuristic Wait, Idle, or generic Work when activity cannot be refined. Exact supported process/session Live→Gone transitions report non-actionable heuristic Done for 30 seconds."; + +fn print_codex_coverage_notice() { + println!("{CODEX_COVERAGE_NOTICE}"); + println!("{CODEX_LIVE_STATUS_NOTICE}"); +} + +fn codex_trust_review_required_notice(hook_count: usize) -> String { + format!( + "Codex lifecycle monitoring is not ready until all {hook_count} abtop hooks are reviewed and approved and a fresh Codex session is started." + ) +} + +fn print_codex_setup_report(report: &codex_hooks::plugin::SetupReport) { + println!("Codex hook base installation completed."); + println!(" Plugin: {}", codex_hooks::plugin::PLUGIN_ID); + println!(" Marketplace: {}", report.paths.marketplace_root.display()); + println!(" Hooks declared: {}", report.hook_count); + println!( + " Base config trusted: {}/{}", + report.base_config_trusted_hooks, report.hook_count + ); + println!( + " Base config enabled: {}/{}", + report.base_config_enabled_hooks, report.hook_count + ); + if !report.legacy_cleanup.changed_files.is_empty() { + println!( + " Removed retired shell blocks from {} file(s).", + report.legacy_cleanup.changed_files.len() + ); + } + if let Some(guidance) = &report.legacy_cleanup.powershell_guidance { + println!(" {guidance}"); + } + println!("Native `codex` was not wrapped, aliased, or replaced."); + print_codex_coverage_notice(); + if report.review_required { + println!("{}", codex_trust_review_required_notice(report.hook_count)); + println!( + "Restart Codex and review only the {} abtop hooks in its trust prompt.", + report.hook_count + ); + println!("Do not approve unrelated hooks from the same prompt."); + } else { + println!("Restart existing Codex sessions so they load this plugin version."); + } +} + +fn print_codex_uninstall_report(report: &codex_hooks::plugin::UninstallReport) { + println!("Codex hook integration uninstalled."); + println!(" Plugin removed: {}", report.plugin_removed); + println!(" Marketplace removed: {}", report.marketplace_removed); + if !report.legacy_cleanup.changed_files.is_empty() { + println!( + " Removed retired shell blocks from {} file(s).", + report.legacy_cleanup.changed_files.len() + ); + } + println!( + " Content-free audit data preserved at: {}", + report.preserved_data_root.display() + ); + if let Some(guidance) = &report.legacy_cleanup.powershell_guidance { + println!(" {guidance}"); + } + println!("Native `codex` was not modified."); +} + +fn print_codex_integration_status(status: &codex_hooks::plugin::IntegrationStatus) { + println!( + "Codex hook base installation: {}", + if status.healthy { "ready" } else { "not ready" } + ); + print_codex_coverage_notice(); + println!(" CODEX_HOME: {}", status.paths.codex_home.display()); + println!( + " Codex executable: {}", + status + .codex_binary + .as_deref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "—".to_string()) + ); + println!(" Hook schema revision: {}", status.hook_schema_revision); + println!( + " Helper digest: {}", + status.helper_digest.as_deref().unwrap_or("—") + ); + println!( + " Marketplace registered: {}", + status.marketplace_registered + ); + println!(" Plugin installed: {}", status.plugin_installed); + println!(" Plugin enabled: {}", status.plugin_enabled); + println!( + " Installed version: {}", + status.installed_version.as_deref().unwrap_or("—") + ); + println!(" Bundle valid: {}", status.bundle_valid); + println!(" Attestation valid: {}", status.attestation_valid); + println!( + " Base config trusted: {}/{}", + status.base_config_trusted_hooks, status.hook_count + ); + println!( + " Base config enabled: {}/{}", + status.base_config_enabled_hooks, status.hook_count + ); + println!( + " Legacy profile inspection: {}", + if status.legacy_inspection_valid { + "valid" + } else { + "failed" + } + ); + println!( + " Retired shell blocks found: {}", + status.legacy_marker_files.len() + ); + for detail in &status.details { + println!(" Note: {detail}"); + } +} + fn should_enable_mouse_capture(args: I) -> bool where I: IntoIterator, @@ -232,16 +548,24 @@ fn run_app( demo_mode: bool, initial_theme: Option, exit_on_jump: bool, - hidden_agents: &[String], - panels: config::PanelVisibility, - claude_config_dirs: &[std::path::PathBuf], + config: &config::AppConfig, ) -> io::Result<()> { - let mut app = App::new_with_config_and_claude_dirs( - initial_theme.unwrap_or_default(), - hidden_agents, - panels, - claude_config_dirs, - ); + let mut app = if demo_mode { + App::new_with_config_and_claude_dirs( + initial_theme.unwrap_or_default(), + &[], + config::PanelVisibility::default(), + &[], + ) + } else { + App::new_with_config_and_claude_dirs_and_codexbar( + initial_theme.unwrap_or_default(), + &config.hidden_agents, + config.panels, + &config.claude_config_dirs, + config.codexbar_quota_fallback, + ) + }; if demo_mode { demo::populate_demo(&mut app); } else { @@ -330,7 +654,13 @@ fn handle_key_press( KeyCode::Backspace => app.filter_pop(), KeyCode::Down => app.select_next(), KeyCode::Up => app.select_prev(), - KeyCode::Char(c) => app.filter_push(c), + KeyCode::Char(c) + if !key.modifiers.intersects( + KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, + ) => + { + app.filter_push(c); + } _ => {} } } else { @@ -452,37 +782,38 @@ fn print_snapshot(app: &App) { } println!(); } + print_snapshot_quota(app); for session in &app.sessions { - let status = match &session.status { - model::SessionStatus::Thinking => "◉ Think", - model::SessionStatus::Executing => "● Exec", - model::SessionStatus::Waiting => "◌ Wait", - model::SessionStatus::Unknown => "? Unknown", - model::SessionStatus::RateLimited => "⏳ Rate", - model::SessionStatus::Done => "✓ Done", - }; - let sid_short = if session.session_id.len() >= 7 { - &session.session_id[..7] - } else { - &session.session_id - }; + let status = snapshot_status_label(&session.status); + let sid_short: String = session.session_id.chars().take(7).collect(); let project_label = format!("{}({})", session.project_name, sid_short); let summary = sanitize_output(&app.session_summary(session)); println!( - " {} {:<20} {} {} {:<10} CTX:{:>3.0}% Tok:{} Mem:{}M {}", - session.pid, - sanitize_output(&project_label), - summary, - status, - session.model.replace("claude-", ""), - session.context_percent, - fmt_tok(session.total_tokens()), - session.mem_mb, - session.elapsed_display(), + "{}", + format_snapshot_session_line(session, &project_label, &summary, status) ); - if let Some(task) = session.current_tasks.last() { + if let Some(task) = session.display_task() { println!(" └─ {}", sanitize_output(task)); } + if session.status_evidence.has_sample() { + let recent = session.status_evidence.recent(5); + let statuses = recent + .observations + .iter() + .map(|sample| snapshot_status_name(&sample.status)) + .collect::>() + .join(","); + println!( + " evidence={} reason={} observed_at_ms={} status_since_ms={} generation={} matching={} recent=[{}]", + session.status_evidence.authority.as_str(), + session.status_evidence.reason.as_str(), + session.status_evidence.observed_at_ms, + session.status_evidence.status_since_ms, + session.status_evidence.connection_generation, + session.status_evidence.consecutive_matching, + statuses, + ); + } for child in &session.children { let port = child.port.map(|p| format!(":{}", p)).unwrap_or_default(); println!( @@ -503,6 +834,203 @@ fn print_snapshot(app: &App) { } } +fn print_snapshot_quota(app: &App) { + let unavailable_providers = app + .codexbar_provider_snapshots() + .iter() + .filter(|provider| provider.error.is_some()) + .map(|provider| provider.provider.as_str()); + let rows = snapshot_quota_rows(&app.rate_limits, unavailable_providers); + if rows.is_empty() { + return; + } + println!("quota:"); + for row in rows { + println!("{row}"); + } + println!(); +} + +fn snapshot_quota_rows<'a>( + rate_limits: &[model::RateLimitInfo], + unavailable_providers: impl IntoIterator, +) -> Vec { + let mut providers = std::collections::BTreeMap::, bool)>::new(); + + for info in rate_limits { + let Some(provider) = collector::codexbar::canonical_provider_id(&info.source) else { + continue; + }; + let row = providers.entry(provider).or_default(); + if info.windows.is_empty() { + if let Some(window) = format_snapshot_quota_window( + info.five_hour_pct, + info.five_hour_resets_at, + info.five_hour_window_minutes, + ) { + row.0.push(window); + } + if let Some(window) = format_snapshot_quota_window( + info.seven_day_pct, + info.seven_day_resets_at, + info.seven_day_window_minutes, + ) { + row.0.push(window); + } + } else { + row.0 + .extend(info.windows.iter().map(format_snapshot_named_quota_window)); + } + } + + for provider in unavailable_providers { + let Some(provider) = collector::codexbar::canonical_provider_id(provider) else { + continue; + }; + providers.entry(provider).or_default().1 = true; + } + + let mut providers = providers.into_iter().collect::>(); + providers.sort_by(|(left, _), (right, _)| { + snapshot_provider_rank(left) + .cmp(&snapshot_provider_rank(right)) + .then_with(|| left.cmp(right)) + }); + providers + .into_iter() + .filter_map(|(provider, (windows, unavailable))| { + if windows.is_empty() { + unavailable.then(|| format!(" {provider}: unavailable")) + } else { + Some(format!(" {provider}: {}", windows.join(" | "))) + } + }) + .collect() +} + +fn snapshot_provider_rank(provider: &str) -> u8 { + match provider { + "claude" => 0, + "codex" => 1, + "grok" => 2, + "kimi" => 3, + _ => 4, + } +} + +fn format_snapshot_named_quota_window(window: &model::RateLimitWindow) -> String { + let label = sanitize_output(&window.label); + let label = label.trim(); + let label = if label.is_empty() { "window" } else { label }; + format_snapshot_quota_value(label, window.used_pct, window.resets_at) +} + +fn format_snapshot_quota_window( + used_pct: Option, + resets_at: Option, + window_minutes: Option, +) -> Option { + let used_pct = used_pct?; + let label = match window_minutes { + Some(minutes) if minutes % 1_440 == 0 => format!("{}d", minutes / 1_440), + Some(minutes) if minutes % 60 == 0 => format!("{}h", minutes / 60), + Some(minutes) => format!("{minutes}m"), + None => "window".to_string(), + }; + Some(format_snapshot_quota_value(&label, used_pct, resets_at)) +} + +fn format_snapshot_quota_value(label: &str, used_pct: f64, resets_at: Option) -> String { + let usage = if used_pct.is_finite() { + format!("{used_pct:.0}% used") + } else { + "usage unavailable".to_string() + }; + let reset = resets_at.map(format_snapshot_reset).unwrap_or_default(); + if reset.is_empty() { + format!("{label} {usage}") + } else { + format!("{label} {usage}, {reset}") + } +} + +fn format_snapshot_reset(resets_at: u64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let remaining = resets_at.saturating_sub(now); + if remaining == 0 { + return "reset due".to_string(); + } + if remaining < 3_600 { + format!("resets in {}m", remaining.div_ceil(60)) + } else if remaining < 86_400 { + format!("resets in {}h", remaining.div_ceil(3_600)) + } else { + format!("resets in {}d", remaining.div_ceil(86_400)) + } +} + +fn snapshot_status_name(status: &model::SessionStatus) -> &'static str { + match status { + model::SessionStatus::Thinking => "Thinking", + model::SessionStatus::Executing => "Executing", + model::SessionStatus::Working => "Working", + model::SessionStatus::Waiting => "Waiting", + model::SessionStatus::Idle => "Idle", + model::SessionStatus::Unknown => "Unknown", + model::SessionStatus::RateLimited => "RateLimited", + model::SessionStatus::Error => "Error", + model::SessionStatus::Done => "Done", + } +} + +fn snapshot_status_label(status: &model::SessionStatus) -> &'static str { + match status { + model::SessionStatus::Thinking => "◉ Think", + model::SessionStatus::Executing => "● Exec", + model::SessionStatus::Working => "◐ Work", + model::SessionStatus::Waiting => "◌ Wait", + model::SessionStatus::Idle => "○ Idle", + model::SessionStatus::Unknown => "? Unknown", + model::SessionStatus::Error => "✗ Error", + model::SessionStatus::RateLimited => "⏳ Rate", + model::SessionStatus::Done => "✓ Done", + } +} + +fn format_snapshot_session_line( + session: &model::AgentSession, + project_label: &str, + summary: &str, + status: &str, +) -> String { + let context = if session.context_window == 0 { + "—".to_string() + } else { + format!("{:.0}%", session.context_percent) + }; + + let model = session + .model + .strip_prefix("claude-") + .unwrap_or(&session.model); + format!( + " {:<8} {} {:<20} {} {} {:<10} CTX:{:>4} Tok:{} Mem:{}M {}", + sanitize_output(session.agent_cli), + session.pid, + sanitize_output(project_label), + summary, + status, + sanitize_output(model), + context, + fmt_tok(session.total_tokens()), + session.mem_mb, + session.elapsed_display(), + ) +} + fn run_update() -> io::Result<()> { let current = env!("CARGO_PKG_VERSION"); println!("abtop v{current} — checking for updates...\n"); @@ -577,7 +1105,7 @@ fn fmt_tok(n: u64) -> String { #[cfg(test)] mod tests { use super::*; - use crossterm::event::{KeyEvent, KeyModifiers}; + use crossterm::event::KeyEvent; use ratatui::backend::TestBackend; #[test] @@ -586,6 +1114,251 @@ mod tests { assert!(should_enable_mouse_capture(["abtop", "--mouse"])); } + #[test] + fn demo_app_ignores_persisted_panel_visibility() { + let cfg = config::AppConfig { + panels: config::PanelVisibility { + context: false, + quota: false, + tokens: false, + projects: false, + ports: false, + sessions: false, + mcp: false, + }, + ..config::AppConfig::default() + }; + + let app = build_app(theme::Theme::default(), &cfg, true); + + assert!(app.show_context); + assert!(app.show_quota); + assert!(app.show_tokens); + assert!(app.show_projects); + assert!(app.show_ports); + assert!(app.show_sessions); + assert!(app.show_mcp); + } + + #[test] + fn codex_launcher_requires_explicit_separator() { + assert_eq!( + codex_dispatch([OsString::from("codex")]), + CodexDispatch::Invalid("missing `--` before Codex arguments".to_string()) + ); + assert!(matches!( + codex_dispatch([OsString::from("codex"), OsString::from("resume")]), + CodexDispatch::Invalid(_) + )); + } + + #[test] + fn codex_admin_commands_are_exact_singletons() { + assert_eq!( + codex_admin_dispatch([OsString::from("--setup-codex")]), + CodexAdminDispatch::Setup + ); + assert_eq!( + codex_admin_dispatch([OsString::from("--uninstall-codex")]), + CodexAdminDispatch::Uninstall + ); + assert_eq!( + codex_admin_dispatch([OsString::from("--codex-integration-status")]), + CodexAdminDispatch::Status + ); + assert_eq!( + codex_admin_dispatch([OsString::from("--setup")]), + CodexAdminDispatch::NotRequested + ); + assert_eq!( + codex_admin_dispatch([OsString::from("--setup-codex"), OsString::from("--json"),]), + CodexAdminDispatch::Invalid + ); + } + + #[test] + fn codex_launcher_preserves_os_arguments_after_separator() { + let prompt = OsString::from("review paths with spaces"); + assert_eq!( + codex_dispatch([ + OsString::from("codex"), + OsString::from("--"), + OsString::from("resume"), + prompt.clone(), + ]), + CodexDispatch::Launch(vec![OsString::from("resume"), prompt]) + ); + } + + #[test] + fn codex_launcher_help_is_unambiguous() { + assert_eq!( + codex_dispatch([OsString::from("codex"), OsString::from("--help")]), + CodexDispatch::Help + ); + assert_eq!( + codex_dispatch([ + OsString::from("codex"), + OsString::from("--"), + OsString::from("--help"), + ]), + CodexDispatch::Launch(vec![OsString::from("--help")]) + ); + } + + #[test] + fn codex_admin_notice_separates_installation_from_live_status_proof() { + assert!(CODEX_COVERAGE_NOTICE.contains("cannot attest effective")); + assert!(CODEX_COVERAGE_NOTICE.contains("installation readiness is diagnostic only")); + assert!(CODEX_LIVE_STATUS_NOTICE.contains("non-actionable heuristic")); + assert!(CODEX_LIVE_STATUS_NOTICE.contains("Herdr")); + assert!(CODEX_LIVE_STATUS_NOTICE.contains("generic Work")); + assert!(CODEX_LIVE_STATUS_NOTICE.contains("Live→Gone")); + assert!(CODEX_LIVE_STATUS_NOTICE.contains("Done for 30 seconds")); + } + + #[test] + fn codex_setup_trust_notice_requires_approval_and_a_fresh_session() { + let notice = codex_trust_review_required_notice(11); + + assert!(notice.contains("lifecycle monitoring is not ready")); + assert!(notice.contains("all 11 abtop hooks are reviewed and approved")); + assert!(notice.contains("a fresh Codex session is started")); + } + + #[test] + fn once_session_line_includes_provider_and_unavailable_context() { + let mut app = App::new_with_config( + theme::Theme::default(), + &[], + config::PanelVisibility::default(), + ); + demo::populate_demo(&mut app); + let session = &mut app.sessions[0]; + session.agent_cli = "grok"; + session.context_window = 0; + session.context_percent = 99.0; + + let line = format_snapshot_session_line(session, "project(session)", "summary", "◌ Wait"); + + assert!(line.starts_with(" grok")); + assert!(line.contains("CTX: —")); + assert!(!line.contains("99%")); + } + + #[test] + fn once_status_labels_distinguish_idle_from_actionable_waiting() { + assert_eq!( + snapshot_status_name(&model::SessionStatus::Working), + "Working" + ); + assert_eq!( + snapshot_status_label(&model::SessionStatus::Working), + "◐ Work" + ); + assert_eq!(snapshot_status_label(&model::SessionStatus::Idle), "○ Idle"); + assert_eq!( + snapshot_status_label(&model::SessionStatus::Waiting), + "◌ Wait" + ); + } + + #[test] + fn once_quota_formatter_uses_actual_window_lengths() { + assert_eq!( + format_snapshot_quota_window(Some(25.0), None, Some(300)).as_deref(), + Some("5h 25% used") + ); + assert_eq!( + format_snapshot_quota_window(Some(40.0), None, Some(10_080)).as_deref(), + Some("7d 40% used") + ); + assert_eq!( + format_snapshot_quota_window(Some(50.0), None, Some(43_200)).as_deref(), + Some("30d 50% used") + ); + } + + #[test] + fn once_quota_rows_render_every_window_in_stable_provider_order() { + let window = |id: &str, label: &str, used_pct: f64| model::RateLimitWindow { + id: id.to_string(), + label: label.to_string(), + used_pct, + resets_at: None, + window_minutes: None, + provenance: model::RateLimitProvenance::CodexBar, + }; + let limits = vec![ + model::RateLimitInfo { + source: "zeta".to_string(), + windows: vec![window("primary", "Primary", 9.0)], + ..Default::default() + }, + model::RateLimitInfo { + source: "grok".to_string(), + windows: vec![window("primary", "Primary", 18.0)], + ..Default::default() + }, + model::RateLimitInfo { + source: "claude".to_string(), + windows: vec![ + window("primary", "5h", 28.0), + window("tertiary", "30d", 11.0), + window("fable", "Fable only", 0.0), + ], + ..Default::default() + }, + model::RateLimitInfo { + source: "codex".to_string(), + windows: vec![window("spark", "Codex Spark Weekly", 0.0)], + ..Default::default() + }, + model::RateLimitInfo { + source: "alpha".to_string(), + windows: vec![window("primary", "Primary", 3.0)], + ..Default::default() + }, + ]; + + assert_eq!( + snapshot_quota_rows(&limits, ["KIMI"]), + vec![ + " claude: 5h 28% used | 30d 11% used | Fable only 0% used", + " codex: Codex Spark Weekly 0% used", + " grok: Primary 18% used", + " kimi: unavailable", + " alpha: Primary 3% used", + " zeta: Primary 9% used", + ] + ); + } + + #[test] + fn once_quota_rows_fall_back_to_legacy_windows_without_duplicates() { + let limits = vec![model::RateLimitInfo { + source: "claude".to_string(), + five_hour_pct: Some(25.0), + five_hour_window_minutes: Some(300), + seven_day_pct: Some(40.0), + seven_day_window_minutes: Some(10_080), + ..Default::default() + }]; + + assert_eq!( + snapshot_quota_rows(&limits, std::iter::empty()), + vec![" claude: 5h 25% used | 7d 40% used"] + ); + } + + #[test] + fn once_quota_rows_never_render_provider_error_details() { + let rows = snapshot_quota_rows(&[], ["kimi"]); + + assert_eq!(rows, vec![" kimi: unavailable"]); + assert!(!rows.join("\n").contains("provider_error")); + } + #[test] fn enter_jump_failure_renders_footer_status() { let mut app = App::new_with_config( @@ -612,4 +1385,35 @@ mod tests { assert!(!text.contains("Broken pipe")); assert!(!text.contains("select-workspace")); } + + #[test] + fn control_modified_character_cannot_corrupt_an_active_filter() { + let mut app = App::new_with_config( + theme::Theme::default(), + &[], + config::PanelVisibility::default(), + ); + app.filter_active = true; + app.filter_text = "019fc2c5".to_string(); + + handle_key_press( + &mut app, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), + false, + false, + |_| JumpOutcome::NoOp, + ); + assert_eq!(app.filter_text, "019fc2c5"); + assert!(app.filter_active); + + handle_key_press( + &mut app, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + false, + false, + |_| JumpOutcome::NoOp, + ); + assert_eq!(app.filter_text, "019fc2c5"); + assert!(!app.filter_active); + } } diff --git a/src/locale.rs b/src/locale.rs index abf098f..26481d5 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -1,30 +1,18 @@ -// i18n module -use std::env; +// Centralized English UI strings. use std::sync::LazyLock; -static CURRENT_LANG: LazyLock<&str> = LazyLock::new(|| { - let from_config = crate::config::load_config().language; - let lang = if !from_config.is_empty() { - from_config - } else { - env::var("LANG").unwrap_or_default() - }; - if lang.to_lowercase().starts_with("zh") { - "zh-CN" - } else { - "en" - } -}); - static LOCALE_EN: LazyLock> = LazyLock::new(|| { let mut m = std::collections::HashMap::new(); // Status icons m.insert("sess.think", "◉ Think"); m.insert("sess.exec", "● Exec"); + m.insert("sess.work", "◐ Work"); m.insert("sess.wait", "◌ Wait"); + m.insert("sess.idle", "○ Idle"); m.insert("sess.unknown", "? Unknown"); m.insert("sess.rate", "⏳ Rate"); + m.insert("sess.error", "✗ Error"); m.insert("sess.done", "✓ Done"); // Column headers @@ -104,6 +92,11 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("detail.accesses", "accesses"); m.insert("detail.unique_files", "unique files"); m.insert("detail.no_active_sessions", "no active sessions"); + m.insert("detail.evidence", "EVIDENCE"); + m.insert("detail.matching", "matching"); + m.insert("detail.since", "since"); + m.insert("detail.observed", "observed"); + m.insert("detail.generation", "generation"); // Help panel m.insert("help.title", " Keybindings "); @@ -200,8 +193,31 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("quota.5h", "5h"); m.insert("quota.7d", "7d"); m.insert("quota.no_data", "no data"); + m.insert("quota.unavailable", "unavailable"); + m.insert("quota.more_providers", "providers"); + m.insert("quota.more_windows", "windows"); m.insert("quota.abtop_setup", "abtop --setup"); m.insert("quota.run_codex", "run codex once"); + m.insert("quota.codexbar_checking", "CodexBar checking"); + m.insert("quota.codexbar_unavailable", "CodexBar unavailable"); + m.insert("quota.codexbar_install", "install CodexBar CLI"); + m.insert("quota.codexbar_timeout", "CodexBar timed out"); + m.insert( + "quota.codexbar_output_too_large", + "CodexBar output too large", + ); + m.insert("quota.codexbar_process_failed", "check CodexBar providers"); + m.insert("quota.codexbar_update", "update CodexBar"); + m.insert("quota.codexbar_cancelled", "CodexBar check cancelled"); + m.insert("quota.codexbar_retry", "retry CodexBar"); + m.insert("quota.codexbar_install_compact", "install"); + m.insert("quota.codexbar_timeout_compact", "timeout"); + m.insert("quota.codexbar_output_compact", "too big"); + m.insert("quota.codexbar_login_compact", "providers"); + m.insert("quota.codexbar_invalid_compact", "invalid"); + m.insert("quota.codexbar_update_compact", "update"); + m.insert("quota.codexbar_cancel_compact", "cancel"); + m.insert("quota.codexbar_retry_compact", "retry"); m.insert("quota.total", "total"); m.insert("quota.in", "in"); @@ -240,6 +256,11 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("config.ports_panel", "Ports panel (5)"); m.insert("config.sessions_panel", "Sessions panel (6)"); m.insert("config.mcp_panel", "MCP servers (7)"); + m.insert("config.codexbar_quota", "CodexBar quotas"); + m.insert("config.codexbar_checking", "checking"); + m.insert("config.codexbar_active", "active"); + m.insert("config.codexbar_partial", "partial"); + m.insert("config.codexbar_unavailable", "unavailable"); // Terminal size too small m.insert("term.too_small", "Terminal size too small:"); @@ -264,261 +285,55 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m }); -static LOCALE_ZH: LazyLock> = LazyLock::new(|| { - let mut m = std::collections::HashMap::new(); - - // Status icons - m.insert("sess.think", "◉ 思考"); - m.insert("sess.exec", "● 执行"); - m.insert("sess.wait", "◌ 等待"); - m.insert("sess.unknown", "? Unknown"); - m.insert("sess.rate", "⏳ 限速"); - m.insert("sess.done", "✓ 完成"); - - // Column headers - m.insert("col.ai", "AI"); - m.insert("col.pid", "PID"); - m.insert("col.project", "项目"); - m.insert("col.session", "会话"); - m.insert("col.sess", "会"); - m.insert("col.summary", "摘要"); - m.insert("col.status", "状态"); - m.insert("col.model", "模型"); - m.insert("col.context", "上下文"); - m.insert("col.ctx", "上"); - m.insert("col.tokens", "Token"); - m.insert("col.memory", "内存"); - m.insert("col.turn", "轮"); - m.insert("col.config", "配置"); - m.insert("col.cfg", "配"); - - // Agent labels - m.insert("agent.claude", "*CC"); - m.insert("agent.codex", ">CD"); - - // Tool labels - m.insert("tool.bash", "终端"); - m.insert("tool.read", "读取"); - m.insert("tool.write", "写入"); - m.insert("tool.edit", "编辑"); - m.insert("tool.glob", "匹配"); - m.insert("tool.grep", "搜索"); - m.insert("tool.brexec", "批执行"); - m.insert("tool.web_search", "搜索"); - m.insert("tool.web_fetch", "抓取"); - m.insert("tool.tdd", "TDD"); - m.insert("tool.investigate", "调查"); - m.insert("tool.lsp", "LSP"); - m.insert("tool.notebook_edit", "笔记本"); - m.insert("tool.task_create", "建任务"); - m.insert("tool.task_update", "更任务"); - m.insert("tool.task_list", "列任务"); - m.insert("tool.task_get", "查任务"); - m.insert("tool.cron_create", "定时"); - m.insert("tool.cron_delete", "删定时"); - m.insert("tool.cron_list", "列定时"); - m.insert("tool.browse", "浏览"); - m.insert("tool.mcp__gitnexus__query", "GN查询"); - m.insert("tool.mcp__gitnexus__context", "GN上下文"); - m.insert("tool.mcp__gitnexus__impact", "GN影响"); - m.insert("tool.mcp__gitnexus__cypher", "GN查询"); - m.insert("tool.mcp__openrouter__chat", "路由"); - m.insert("tool.mcp__filesystem__read", "FS读"); - m.insert("tool.mcp__filesystem__write", "FS写"); - m.insert("tool.mcp__filesystem__glob", "FS匹配"); - m.insert("tool.mcp__codex__ask", "Codex问"); - m.insert("tool.mcp__slack__post_message", "Slack"); - m.insert("tool.mcp__linear__create_issue", "Linear"); - m.insert("tool.mcp__github__create_issue", "GH问题"); - - // Sessions detail - m.insert("detail.session", "会话"); - m.insert("detail.task", "任务"); - m.insert("detail.children", "子进程"); - m.insert("detail.subagents", "子代理"); - m.insert("detail.mem", "内存"); - m.insert("detail.ctx", "上下文"); - m.insert("detail.files", "文件"); - m.insert("detail.lines", "行"); - m.insert("detail.turns", "轮"); - m.insert("detail.effort", "投入"); - m.insert("detail.timeline", "时间线"); - m.insert("detail.chat", "聊天"); - m.insert("detail.calls", "调用"); - m.insert("detail.running", "运行中"); - m.insert("detail.thinking", "思考中"); - m.insert("detail.generating", "生成回复"); - m.insert("detail.file_audit", "文件审计"); - m.insert("detail.accesses", "访问"); - m.insert("detail.unique_files", "唯一文件"); - m.insert("detail.no_active_sessions", "无活跃会话"); - - // Help panel - m.insert("help.title", " 快捷键 "); - m.insert("help.navigation", "导航"); - m.insert("help.actions", "操作"); - m.insert("help.views", "视图"); - m.insert("help.help", "帮助"); - m.insert("help.press_key", " 按任意键关闭 "); - m.insert("help.select_session", "选择会话"); - m.insert("help.jump_tmux", "jump to session terminal"); - m.insert("help.filter", "过滤会话"); - m.insert("help.clear_filter", "清除过滤 / 关闭覆盖"); - m.insert("help.kill_session", "终止选中的会话"); - m.insert("help.kill_orphans", "终止孤立端口"); - m.insert("help.refresh", "强制刷新"); - m.insert("help.quit", "退出"); - m.insert("help.view_menu", "打开视图菜单"); - m.insert("help.open_config", "打开配置"); - m.insert("help.cycle_theme", "切换主题 / 切换树视图"); - m.insert("help.toggle_timeline", "切换时间线"); - m.insert("help.toggle_file_audit", "切换文件审计"); - m.insert( - "help.toggle_panels", - "切换面板 (上下文/配额/词元/项目/端口/会话/MCP)", - ); - m.insert("help.mcp_suppress", "切换会话面板中的 MCP 服务器隐藏"); - m.insert("help.this_help", "显示帮助"); - - // Footer - m.insert("footer.select", "选择"); - m.insert("footer.kill", "终止"); - m.insert("footer.filter", "过滤"); - m.insert("footer.view", "视图"); - m.insert("footer.config", "配置"); - m.insert("footer.help", "帮助"); - m.insert("footer.quit", "退出"); - m.insert("footer.sessions", "会话"); - m.insert("footer.auto", "自动"); - m.insert("footer.peak_hours", "Claude 高峰时段"); - m.insert("footer.resets_in", "重置于"); - m.insert("footer.esc_clear", "Esc 清除,Enter 保留"); - m.insert("footer.jump", "跳转"); - - // View menu - m.insert("view.title", " 视图 "); - m.insert("view.on", "开"); - m.insert("view.off", "关"); - m.insert("view.action", "→"); - m.insert("view.tree_view", "树视图"); - m.insert("view.timeline", "时间线"); - m.insert("view.file_audit", "文件审计"); - m.insert("view.context_panel", "上下文面板"); - m.insert("view.quota_panel", "配额面板"); - m.insert("view.tokens_panel", "词元面板"); - m.insert("view.projects_panel", "项目面板"); - m.insert("view.ports_panel", "端口面板"); - m.insert("view.sessions_panel", "会话面板"); - m.insert("view.mcp_servers_panel", "MCP 服务器面板"); - m.insert("view.mcp_session_hide", "隐藏 MCP 会话"); - m.insert("view.cycle_theme", "切换主题"); - m.insert("view.key_toggle", "按键切换 · Esc 关闭 "); - - // Header - m.insert("header.cpu", "CPU"); - m.insert("header.mem", "内存"); - m.insert("header.load", "负载"); - m.insert("header.agents", "代理"); - m.insert("header.ctx", "上下文"); - - // Tokens panel - m.insert("tokens.total", "总计"); - m.insert("tokens.input", "输入"); - m.insert("tokens.output", "输出"); - m.insert("tokens.cache_r", "缓存读"); - m.insert("tokens.cache_w", "缓存写"); - m.insert("tokens.turns", "轮数"); - m.insert("tokens.avg", "平均"); - m.insert("tokens.tokens_turn", "词元/轮"); - - // Context panel - m.insert("context.rate", "速率"); - m.insert("context.total", "总计"); - m.insert("context.active", "活跃"); - m.insert("context.project", "项目"); - m.insert("context.context", "上下文"); - m.insert("context.window", "窗口"); - m.insert("context.token_rate", "Token 速率"); - m.insert("context.no_active_sessions", "无活跃会话"); - - // Quota panel - m.insert("quota.5h", "5小时"); - m.insert("quota.7d", "7天"); - m.insert("quota.no_data", "无数据"); - m.insert("quota.abtop_setup", "abtop --setup"); - m.insert("quota.run_codex", "运行一次 codex"); - m.insert("quota.total", "总计"); - m.insert("quota.in", "还有"); - - // Projects panel - m.insert("projects.no_git", "非 Git"); - m.insert("projects.clean", "✓干净"); - m.insert("projects.no_projects", "无项目"); - - // Ports panel - m.insert("ports.port", "端口"); - m.insert("ports.session", "会话"); - m.insert("ports.orphan", "孤立"); - m.insert("ports.no_open_ports", "无开放端口"); - m.insert("ports.kill_orphans", "X 终止孤立"); - - // MCP panel - m.insert("mcp.parent", "父进程"); - m.insert("mcp.profile", "配置"); - m.insert("mcp.act_tot", "活跃/总计"); - m.insert("mcp.last", "最近"); - m.insert("mcp.no_servers", "无 MCP 服务器"); - m.insert("mcp.default", "默认"); - m.insert("mcp.suppress_off", "隐藏: 关闭 (M)"); - - // Config panel - m.insert("config.title", " 配置 "); - m.insert("config.theme", "主题"); - m.insert("config.on", "开"); - m.insert("config.off", "关"); - m.insert("config.change", "Enter/空格 更改"); - m.insert("config.close", "Esc 关闭"); - m.insert("config.context_panel", "上下文面板 (1)"); - m.insert("config.quota_panel", "配额面板 (2)"); - m.insert("config.tokens_panel", "词元面板 (3)"); - m.insert("config.projects_panel", "项目面板 (4)"); - m.insert("config.ports_panel", "端口面板 (5)"); - m.insert("config.sessions_panel", "会话面板 (6)"); - m.insert("config.mcp_panel", "MCP 服务器 (7)"); - - // Terminal size too small - m.insert("term.too_small", "终端尺寸过小:"); - m.insert("term.width", "宽度"); - m.insert("term.height", "高度"); - m.insert("term.needed", "当前配置需要:"); - - // Time formatting - m.insert("time.s_ago", "秒前"); - m.insert("time.m_ago", "分前"); - m.insert("time.h_ago", "时前"); - m.insert("time.d_ago", "天前"); - m.insert("time.s", "秒"); - m.insert("time.m", "分"); - m.insert("time.h", "时"); - m.insert("time.d", "天"); - - // Misc - m.insert("misc.dash", "—"); - m.insert("misc.active", "活跃"); +pub fn t(key: &str) -> String { + LOCALE_EN + .get(key) + .map(|s| s.to_string()) + .unwrap_or_else(|| key.to_string()) +} - m -}); +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn lifecycle_status_labels_are_distinct() { + let keys = [ + "sess.think", + "sess.exec", + "sess.work", + "sess.wait", + "sess.idle", + "sess.unknown", + "sess.rate", + "sess.error", + "sess.done", + ]; + + let labels = keys + .iter() + .map(|key| *LOCALE_EN.get(key).expect("every status has a label")) + .collect::>(); + assert_eq!(labels.len(), keys.len()); + + assert_eq!(LOCALE_EN["sess.exec"], "● Exec"); + assert_eq!(LOCALE_EN["sess.work"], "◐ Work"); + assert_eq!(LOCALE_EN["sess.wait"], "◌ Wait"); + assert_eq!(LOCALE_EN["sess.idle"], "○ Idle"); + assert_eq!(LOCALE_EN["sess.unknown"], "? Unknown"); + } -pub fn t(key: &str) -> String { - if *CURRENT_LANG == "zh-CN" { - LOCALE_ZH - .get(key) - .map(|s| s.to_string()) - .unwrap_or_else(|| key.to_string()) - } else { - LOCALE_EN - .get(key) - .map(|s| s.to_string()) - .unwrap_or_else(|| key.to_string()) + #[test] + fn codexbar_quota_copy_is_provider_wide() { + assert_eq!(LOCALE_EN["config.codexbar_quota"], "CodexBar quotas"); + assert_eq!(LOCALE_EN["config.codexbar_partial"], "partial"); + assert_eq!(LOCALE_EN["quota.unavailable"], "unavailable"); + assert_eq!(LOCALE_EN["quota.more_providers"], "providers"); + assert_eq!(LOCALE_EN["quota.more_windows"], "windows"); + assert_eq!( + LOCALE_EN["quota.codexbar_process_failed"], + "check CodexBar providers" + ); } } diff --git a/src/model/session.rs b/src/model/session.rs index cc3a5bf..7fa6f8d 100644 --- a/src/model/session.rs +++ b/src/model/session.rs @@ -32,10 +32,92 @@ pub struct FileAccess { /// Maximum file access entries kept per session to bound memory. pub const MAX_FILE_ACCESSES: usize = 1000; +/// Maximum UTF-8 byte length accepted for a quota window identity. +pub const MAX_RATE_LIMIT_WINDOW_ID_BYTES: usize = 128; + +/// Maximum UTF-8 byte length accepted for a quota window display label. +pub const MAX_RATE_LIMIT_WINDOW_LABEL_BYTES: usize = 128; + +/// Source that supplied one account-level quota window. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[non_exhaustive] +pub enum RateLimitProvenance { + /// Data emitted directly by the provider integration. + #[default] + Native, + /// Data returned by the optional CodexBar integration. + CodexBar, +} + +/// One bounded account-level quota window. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct RateLimitWindow { + /// Stable source-owned identity used to merge matching windows. + pub id: String, + /// Short user-facing window label. + pub label: String, + /// Used quota percentage (0-100). + pub used_pct: f64, + /// Reset timestamp in epoch seconds, when reported by the source. + pub resets_at: Option, + /// Window duration in minutes, when reported by the source. + pub window_minutes: Option, + /// Integration that supplied this window. + pub provenance: RateLimitProvenance, +} + +impl RateLimitWindow { + /// Build a window only when its owned identity and label are safely bounded. + /// + /// IDs are rejected rather than truncated because truncation could merge + /// distinct source windows under the same identity. + pub fn try_new( + id: impl Into, + label: impl Into, + used_pct: f64, + resets_at: Option, + window_minutes: Option, + provenance: RateLimitProvenance, + ) -> Option { + let id = id.into(); + let label = label.into(); + if id.is_empty() + || id.len() > MAX_RATE_LIMIT_WINDOW_ID_BYTES + || id.chars().any(is_unsafe_quota_text_char) + || label.is_empty() + || label.len() > MAX_RATE_LIMIT_WINDOW_LABEL_BYTES + || label.chars().any(is_unsafe_quota_text_char) + || !used_pct.is_finite() + || !(0.0..=100.0).contains(&used_pct) + || window_minutes == Some(0) + { + return None; + } + + Some(Self { + id, + label, + used_pct, + resets_at, + window_minutes, + provenance, + }) + } +} + +fn is_unsafe_quota_text_char(character: char) -> bool { + character.is_control() + || matches!( + character, + '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{200E}' | '\u{200F}' + ) +} + /// Account-level rate limit info (shared across all sessions). #[derive(Debug, Clone, Default, Serialize)] pub struct RateLimitInfo { - /// "claude" or "codex" + /// Canonical provider ID, such as "claude", "codex", or "grok". pub source: String, /// 5-hour window usage percentage (0-100) pub five_hour_pct: Option, @@ -54,20 +136,35 @@ pub struct RateLimitInfo { pub seven_day_window_minutes: Option, /// When this data was last updated pub updated_at: Option, + /// Authoritative ordered quota windows from all available integrations. + /// + /// The legacy five-hour and seven-day fields remain populated for API + /// compatibility, but new consumers should prefer this collection. + pub windows: Vec, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub enum SessionStatus { - /// Model is generating a response (last_user_ts_ms > 0) + /// Model is generating a response and no tool is currently running Thinking, - /// Running a tool (descendant CPU active OR current_task non-empty) + /// A tool, background terminal, or active child is doing work Executing, - /// Idle, waiting for user input or permission prompt + /// Exact terminal evidence proves activity, but cannot distinguish model + /// generation from tool execution + Working, + /// Exact provider or terminal evidence says the session needs user input + /// or approval Waiting, - /// Session appears recent, but process ownership is not confirmed + /// Process is alive, but no model turn, tool, or user interaction is active + Idle, + /// No sufficiently fresh, complete, and trustworthy lifecycle proof exists + #[default] Unknown, /// Waiting due to rate limit RateLimited, + /// Provider reported a live session or turn failure + Error, /// Session finished Done, } @@ -75,7 +172,226 @@ pub enum SessionStatus { impl SessionStatus { /// Returns true for states where the agent is actively doing work. pub fn is_active(&self) -> bool { - matches!(self, SessionStatus::Thinking | SessionStatus::Executing) + matches!( + self, + SessionStatus::Thinking | SessionStatus::Executing | SessionStatus::Working + ) + } +} + +/// Maximum status samples retained on a live session. +pub const MAX_STATUS_OBSERVATIONS: usize = 128; + +/// Maximum status samples included in a JSON snapshot and rendered in detail. +pub const MAX_VISIBLE_STATUS_OBSERVATIONS: usize = 5; + +/// How directly abtop can substantiate the displayed lifecycle status. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum StatusAuthority { + /// Exact provider-emitted lifecycle data supplied the status. + Provider, + /// abtop derived the status from local files or process metadata. + Heuristic, + /// No sufficiently reliable status source is currently available. + #[default] + Unavailable, +} + +impl StatusAuthority { + pub fn as_str(self) -> &'static str { + match self { + Self::Provider => "provider", + Self::Heuristic => "heuristic", + Self::Unavailable => "unavailable", + } + } +} + +/// Machine-readable explanation for a status observation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum StatusReason { + ProviderIdle, + ProviderThinking, + ProviderExecuting, + ProviderWaitingApproval, + ProviderWaitingUserInput, + ProviderRateLimit, + ProviderError, + ProcessExited, + ProtocolUnknown, + ProtocolMalformed, + Disconnected, + Stale, + Bootstrap, + BackgroundProbePending, + BackgroundProbeFailed, + BackgroundTerminalActive, + OwnershipUnconfirmed, + CollectorInference, + /// The installed Codex hook integration cannot be verified exactly. + HookIntegrationUnverified, + /// The installed Codex hook declaration/helper identity changed. + HookConfigChanged, + /// Required Codex hook lifecycle events are missing or out of order. + HookEventGap, + /// The private Codex hook state failed schema or filesystem validation. + HookStateMalformed, + /// Codex emitted an interaction request without an exact resolution event. + HookInteractionResolutionUnavailable, + /// A covered Codex tool call is open in both hook and rollout evidence. + HookToolOpen, + /// A complete Codex hook subagent set matches active direct-child model work. + HookSubagentActive, + /// A Codex turn is open in both hook and rollout evidence. + HookTurnOpen, + /// A Codex stop hook and rollout terminal event agree that the turn ended. + HookTurnComplete, + /// Herdr's private terminal detector reports a visible Codex blocker. + HerdrScreenBlocked, + /// Herdr's private terminal detector reports visible Codex activity. + HerdrScreenWorking, + /// Herdr's private terminal detector reports the live Codex composer idle. + HerdrScreenIdle, + /// Herdr reports work, but Codex evidence cannot classify that activity. + HerdrWorkingUnrefined, + #[default] + Unavailable, +} + +impl StatusReason { + pub fn as_str(self) -> &'static str { + match self { + Self::ProviderIdle => "provider idle", + Self::ProviderThinking => "provider thinking", + Self::ProviderExecuting => "provider executing", + Self::ProviderWaitingApproval => "waiting for approval", + Self::ProviderWaitingUserInput => "waiting for user input", + Self::ProviderRateLimit => "provider rate limit", + Self::ProviderError => "provider error", + Self::ProcessExited => "process exited", + Self::ProtocolUnknown => "unknown protocol state", + Self::ProtocolMalformed => "malformed protocol state", + Self::Disconnected => "disconnected", + Self::Stale => "stale observation", + Self::Bootstrap => "initializing", + Self::BackgroundProbePending => "checking background work", + Self::BackgroundProbeFailed => "background check failed", + Self::BackgroundTerminalActive => "background terminal active", + Self::OwnershipUnconfirmed => "ownership unconfirmed", + Self::CollectorInference => "collector inference", + Self::HookIntegrationUnverified => "Codex hook integration unverified", + Self::HookConfigChanged => "Codex hook configuration changed", + Self::HookEventGap => "Codex hook event gap", + Self::HookStateMalformed => "malformed Codex hook state", + Self::HookInteractionResolutionUnavailable => { + "Codex interaction resolution unavailable" + } + Self::HookToolOpen => "Codex hook tool open", + Self::HookSubagentActive => "Codex hook subagent active", + Self::HookTurnOpen => "Codex hook turn open", + Self::HookTurnComplete => "Codex hook turn complete", + Self::HerdrScreenBlocked => "Herdr screen needs attention", + Self::HerdrScreenWorking => "Herdr screen working", + Self::HerdrScreenIdle => "Herdr screen idle", + Self::HerdrWorkingUnrefined => "Herdr work type unavailable", + Self::Unavailable => "evidence unavailable", + } + } +} + +/// One bounded, content-free status sample. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +#[non_exhaustive] +pub struct StatusObservation { + pub status: SessionStatus, + pub authority: StatusAuthority, + pub reason: StatusReason, + /// Unix-epoch milliseconds when this state was observed. + pub observed_at_ms: u64, + /// Provider connection generation, or zero for non-protocol sources. + pub connection_generation: u64, +} + +impl StatusObservation { + pub fn new( + status: SessionStatus, + authority: StatusAuthority, + reason: StatusReason, + observed_at_ms: u64, + connection_generation: u64, + ) -> Self { + Self { + status, + authority, + reason, + observed_at_ms, + connection_generation, + } + } +} + +/// Current status provenance plus a bounded observation history. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +#[non_exhaustive] +pub struct StatusEvidence { + pub authority: StatusAuthority, + pub reason: StatusReason, + /// Unix-epoch milliseconds of the newest authoritative/heuristic sample. + pub observed_at_ms: u64, + /// Unix-epoch milliseconds when the current status first began. + pub status_since_ms: u64, + /// Provider connection generation, or zero for non-protocol sources. + pub connection_generation: u64, + /// Consecutive samples matching status, authority, and connection generation. + pub consecutive_matching: u32, + /// Content-free samples, oldest first. + pub observations: Vec, +} + +impl StatusEvidence { + /// Record one sample and update the current evidence summary. + pub fn observe(&mut self, observation: StatusObservation) { + let previous = self.observations.last(); + let same_status = previous.is_some_and(|sample| { + sample.status == observation.status + && sample.authority == observation.authority + && sample.connection_generation == observation.connection_generation + }); + + if same_status { + self.consecutive_matching = self.consecutive_matching.saturating_add(1).max(1); + } else { + self.status_since_ms = observation.observed_at_ms; + self.consecutive_matching = 1; + } + + self.authority = observation.authority; + self.reason = observation.reason; + self.observed_at_ms = observation.observed_at_ms; + self.connection_generation = observation.connection_generation; + self.observations.push(observation); + if self.observations.len() > MAX_STATUS_OBSERVATIONS { + let excess = self.observations.len() - MAX_STATUS_OBSERVATIONS; + self.observations.drain(..excess); + } + } + + /// Return a copy whose observation ledger contains only the newest samples. + pub fn recent(&self, limit: usize) -> Self { + let mut recent = self.clone(); + if recent.observations.len() > limit { + let start = recent.observations.len() - limit; + recent.observations = recent.observations.split_off(start); + } + recent + } + + pub fn has_sample(&self) -> bool { + self.observed_at_ms > 0 || !self.observations.is_empty() } } @@ -132,16 +448,28 @@ pub const MAX_CHAT_MESSAGES: usize = 12; #[derive(Debug, Clone)] pub struct AgentSession { - /// Which CLI tool this session belongs to: "claude", "codex", etc. + /// Which CLI tool this session belongs to: "claude", "codex", "opencode", + /// "grok", or "kimi". /// Also used as the identifier for the `hidden_agents` config key /// (case-insensitive match). pub agent_cli: &'static str, pub pid: u32, + /// Internal, exact opaque OS process-incarnation anchor that the collector + /// tied to this logical row while validating ownership. Never render or + /// serialize this value. PID actions must compare this retained value with + /// fresh OS observations; they must never create the expected identity by + /// resampling the PID after collection. + /// + /// `None` means ownership is not strong enough for kill or terminal-jump + /// actions, even when lifecycle metadata is otherwise useful for display. + pub action_process_incarnation: Option, pub session_id: String, pub cwd: String, pub project_name: String, pub started_at: u64, pub status: SessionStatus, + /// Provenance, freshness, and bounded samples supporting `status`. + pub status_evidence: StatusEvidence, pub model: String, /// Reasoning effort setting (Codex CLI only: "minimal" | "low" | "medium" | "high"). /// Empty string when unknown or not applicable. @@ -182,6 +510,10 @@ pub struct AgentSession { /// turn has already been closed (no tools currently in flight). /// Used to animate the timeline bar for the running tool(s). pub pending_since_ms: u64, + /// True when exact provider or terminal evidence exposes a pending + /// interaction that needs a user response. Codex hook candidates alone + /// remain false because their contract cannot prove prompt resolution. + pub awaiting_input: bool, /// Unix-epoch ms of the most recent `user` line (prompt or tool_result) /// that has not yet been followed by an assistant response. Zero when /// the last transcript entry was an assistant turn. Used to render a @@ -191,11 +523,22 @@ pub struct AgentSession { pub file_accesses: Vec, /// Config root directory for this session's agent (home-abbreviated, e.g. "~/.claude-work"). /// For Claude Code: the active .claude* profile folder. For Codex: "~/.codex". - /// For OpenCode: the data directory containing opencode.db. + /// For OpenCode: the data directory containing opencode.db. For Grok and + /// Kimi: the active GROK_HOME or KIMI_CODE_HOME directory. pub config_root: String, } impl AgentSession { + /// Keep the compatibility flag exactly aligned with the lifecycle enum. + /// Waiting is reserved for an exactly observed actionable interaction. + pub fn enforce_status_contract(&mut self) { + self.awaiting_input = matches!(self.status, SessionStatus::Waiting); + } + + pub fn is_awaiting_input(&self) -> bool { + matches!(self.status, SessionStatus::Waiting) + } + pub fn total_tokens(&self) -> u64 { self.total_input_tokens + self.total_output_tokens @@ -209,6 +552,30 @@ impl AgentSession { self.total_input_tokens + self.total_output_tokens + self.total_cache_create } + /// Task text suitable for user-facing session summaries. + /// + /// Status-owned labels take precedence over stale provider task text. + /// Only Executing may expose a provider tool/task preview: every terminal, + /// quiescent, waiting, or uncertain state uses a canonical label so an old + /// tool name cannot make an Idle or Unknown row look active. + pub fn display_task(&self) -> Option<&str> { + match self.status { + SessionStatus::Thinking => Some("thinking"), + SessionStatus::Executing => self + .current_tasks + .last() + .map(String::as_str) + .or(Some("executing")), + SessionStatus::Working => Some("working"), + SessionStatus::Waiting => Some("waiting for user input"), + SessionStatus::Idle => Some("idle"), + SessionStatus::RateLimited => Some("rate limited"), + SessionStatus::Error => Some("error"), + SessionStatus::Unknown => Some("status evidence unavailable"), + SessionStatus::Done => Some("finished"), + } + } + pub fn elapsed(&self) -> Duration { let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -237,6 +604,11 @@ pub struct SessionFile { pub cwd: String, #[serde(rename = "startedAt")] pub started_at: u64, + /// Optional, undocumented per-PID Claude session status. Serde defaults it + /// when absent for compatibility; the collector uses an exact decision-tool + /// fallback when this signal is not waiting. + #[serde(default)] + pub status: Option, } impl SessionFile { @@ -263,15 +635,111 @@ fn truncate_string(s: &mut String, max_bytes: usize) { mod tests { use super::*; + #[test] + fn rate_limit_window_provenance_has_stable_wire_names() { + assert_eq!( + serde_json::to_string(&RateLimitProvenance::Native).unwrap(), + r#""native""# + ); + assert_eq!( + serde_json::to_string(&RateLimitProvenance::CodexBar).unwrap(), + r#""codexbar""# + ); + } + + #[test] + fn rate_limit_info_default_preserves_legacy_fields_and_empty_windows() { + let value = serde_json::to_value(RateLimitInfo::default()).unwrap(); + assert_eq!(value["source"], ""); + assert!(value["five_hour_pct"].is_null()); + assert!(value["seven_day_pct"].is_null()); + assert_eq!(value["windows"], serde_json::json!([])); + } + + #[test] + fn rate_limit_window_serializes_all_public_fields() { + let window = RateLimitWindow::try_new( + "codex-spark-weekly", + "Codex Spark Weekly", + 12.5, + Some(1_800_000_000), + Some(10_080), + RateLimitProvenance::CodexBar, + ) + .unwrap(); + + assert_eq!( + serde_json::to_value(window).unwrap(), + serde_json::json!({ + "id": "codex-spark-weekly", + "label": "Codex Spark Weekly", + "used_pct": 12.5, + "resets_at": 1_800_000_000_u64, + "window_minutes": 10_080, + "provenance": "codexbar" + }) + ); + } + + #[test] + fn rate_limit_window_rejects_unbounded_or_unsafe_identity_fields() { + let make = |id: String, label: String| { + RateLimitWindow::try_new(id, label, 1.0, None, None, RateLimitProvenance::Native) + }; + + assert!(make(String::new(), "Five hours".into()).is_none()); + assert!(make("primary".into(), String::new()).is_none()); + assert!(make( + "x".repeat(MAX_RATE_LIMIT_WINDOW_ID_BYTES + 1), + "Five hours".into() + ) + .is_none()); + assert!(make( + "primary".into(), + "x".repeat(MAX_RATE_LIMIT_WINDOW_LABEL_BYTES + 1) + ) + .is_none()); + assert!(make("primary\nsecondary".into(), "Five hours".into()).is_none()); + assert!(make("primary".into(), "Five\thours".into()).is_none()); + assert!(make("primary".into(), "Five \u{202E}hours".into()).is_none()); + + let bounded_unicode_label = "ą".repeat(MAX_RATE_LIMIT_WINDOW_LABEL_BYTES / 2); + assert!(make("primary".into(), bounded_unicode_label).is_some()); + } + + #[test] + fn rate_limit_window_rejects_invalid_measurements() { + let make = |used_pct, window_minutes| { + RateLimitWindow::try_new( + "primary", + "Five hours", + used_pct, + None, + window_minutes, + RateLimitProvenance::Native, + ) + }; + + assert!(make(f64::NAN, Some(300)).is_none()); + assert!(make(f64::INFINITY, Some(300)).is_none()); + assert!(make(-0.1, Some(300)).is_none()); + assert!(make(100.1, Some(300)).is_none()); + assert!(make(50.0, Some(0)).is_none()); + assert!(make(0.0, None).is_some()); + assert!(make(100.0, Some(300)).is_some()); + } + fn make_session(input: u64, output: u64, cache_read: u64, cache_create: u64) -> AgentSession { AgentSession { agent_cli: "claude", pid: 0, + action_process_incarnation: None, session_id: String::new(), cwd: String::new(), project_name: String::new(), started_at: 0, status: SessionStatus::Waiting, + status_evidence: StatusEvidence::default(), model: String::new(), effort: String::new(), context_percent: 0.0, @@ -299,6 +767,7 @@ mod tests { chat_messages: Vec::new(), tool_calls: Vec::new(), pending_since_ms: 0, + awaiting_input: false, thinking_since_ms: 0, file_accesses: Vec::new(), config_root: String::new(), @@ -316,4 +785,186 @@ mod tests { let session = make_session(100, 50, 200, 30); assert_eq!(session.active_tokens(), 180); // 100 + 50 + 30, excludes cache_read } + + #[test] + fn thinking_executing_and_working_are_active() { + for status in [ + SessionStatus::Thinking, + SessionStatus::Executing, + SessionStatus::Working, + ] { + assert!(status.is_active(), "{status:?}"); + } + for status in [ + SessionStatus::Waiting, + SessionStatus::Idle, + SessionStatus::Unknown, + SessionStatus::RateLimited, + SessionStatus::Error, + SessionStatus::Done, + ] { + assert!(!status.is_active(), "{status:?}"); + } + } + + #[test] + fn codex_active_subagent_reason_is_stable_and_content_free() { + assert_eq!( + StatusReason::HookSubagentActive.as_str(), + "Codex hook subagent active" + ); + assert_eq!( + serde_json::to_string(&StatusReason::HookSubagentActive).unwrap(), + r#""HookSubagentActive""# + ); + } + + #[test] + fn herdr_status_reasons_are_stable_and_content_free() { + let cases = [ + (StatusReason::HerdrScreenBlocked, "HerdrScreenBlocked"), + (StatusReason::HerdrScreenWorking, "HerdrScreenWorking"), + (StatusReason::HerdrScreenIdle, "HerdrScreenIdle"), + (StatusReason::HerdrWorkingUnrefined, "HerdrWorkingUnrefined"), + ]; + for (reason, wire_name) in cases { + assert!(reason.as_str().starts_with("Herdr ")); + assert_eq!( + serde_json::to_string(&reason).unwrap(), + format!("\"{wire_name}\"") + ); + } + } + + #[test] + fn status_owned_task_labels_override_stale_provider_text() { + let mut session = make_session(0, 0, 0, 0); + session.current_tasks.push("Edit stale.rs".into()); + + session.status = SessionStatus::Waiting; + assert_eq!(session.display_task(), Some("waiting for user input")); + + session.status = SessionStatus::Thinking; + assert_eq!(session.display_task(), Some("thinking")); + + session.status = SessionStatus::Working; + assert_eq!(session.display_task(), Some("working")); + + session.status = SessionStatus::RateLimited; + assert_eq!(session.display_task(), Some("rate limited")); + } + + #[test] + fn status_owned_task_labels_never_leak_stale_work() { + let mut session = make_session(0, 0, 0, 0); + session.current_tasks.push("Edit stale.rs".into()); + + for (status, expected) in [ + (SessionStatus::Working, "working"), + (SessionStatus::Waiting, "waiting for user input"), + (SessionStatus::Idle, "idle"), + (SessionStatus::Unknown, "status evidence unavailable"), + (SessionStatus::RateLimited, "rate limited"), + (SessionStatus::Error, "error"), + (SessionStatus::Done, "finished"), + ] { + session.status = status; + assert_eq!(session.display_task(), Some(expected), "{status:?}"); + } + } + + #[test] + fn missing_status_evidence_fails_closed() { + let evidence: StatusEvidence = + serde_json::from_str("{}").expect("missing fields use safe defaults"); + assert_eq!(evidence.authority, StatusAuthority::Unavailable); + assert_eq!(evidence.reason, StatusReason::Unavailable); + assert_eq!(evidence.observed_at_ms, 0); + assert_eq!(evidence.consecutive_matching, 0); + assert!(evidence.observations.is_empty()); + assert_eq!( + serde_json::from_str::("\"Error\"").unwrap(), + SessionStatus::Error + ); + assert_eq!( + serde_json::to_string(&SessionStatus::Working).unwrap(), + "\"Working\"" + ); + } + + #[test] + fn status_evidence_tracks_transitions_and_bounds_samples() { + let mut evidence = StatusEvidence::default(); + evidence.observe(StatusObservation::new( + SessionStatus::Idle, + StatusAuthority::Provider, + StatusReason::ProviderIdle, + 10, + 1, + )); + evidence.observe(StatusObservation::new( + SessionStatus::Idle, + StatusAuthority::Provider, + StatusReason::ProviderIdle, + 20, + 1, + )); + assert_eq!(evidence.status_since_ms, 10); + assert_eq!(evidence.consecutive_matching, 2); + + evidence.observe(StatusObservation::new( + SessionStatus::Thinking, + StatusAuthority::Provider, + StatusReason::ProviderThinking, + 30, + 1, + )); + assert_eq!(evidence.status_since_ms, 30); + assert_eq!(evidence.consecutive_matching, 1); + + for observed_at_ms in 31..=(MAX_STATUS_OBSERVATIONS as u64 + 40) { + evidence.observe(StatusObservation::new( + SessionStatus::Thinking, + StatusAuthority::Provider, + StatusReason::ProviderThinking, + observed_at_ms, + 1, + )); + } + assert_eq!(evidence.observations.len(), MAX_STATUS_OBSERVATIONS); + assert_eq!( + evidence + .recent(MAX_VISIBLE_STATUS_OBSERVATIONS) + .observations + .len(), + MAX_VISIBLE_STATUS_OBSERVATIONS + ); + } + + #[test] + fn awaiting_input_is_exactly_the_waiting_status() { + let mut session = make_session(0, 0, 0, 0); + for status in [ + SessionStatus::Thinking, + SessionStatus::Executing, + SessionStatus::Working, + SessionStatus::Idle, + SessionStatus::Unknown, + SessionStatus::RateLimited, + SessionStatus::Error, + SessionStatus::Done, + ] { + session.status = status; + session.awaiting_input = true; + session.enforce_status_contract(); + assert!(!session.awaiting_input, "{status:?}"); + assert!(!session.is_awaiting_input(), "{status:?}"); + } + + session.status = SessionStatus::Waiting; + session.awaiting_input = false; + session.enforce_status_contract(); + assert!(session.awaiting_input); + assert!(session.is_awaiting_input()); + } } diff --git a/src/setup.rs b/src/setup.rs index 90f2d9a..86de187 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -140,6 +140,7 @@ pub fn run_setup() { } } - println!("\n done! rate limit data will appear in abtop after the next Claude response."); - println!(" restart any running Claude Code sessions to activate."); + println!("\n ✓ Claude StatusLine setup complete."); + println!(" Rate limit data will appear in abtop after the next Claude response."); + println!(" Restart any running Claude Code sessions to activate."); } diff --git a/src/snapshot.rs b/src/snapshot.rs index d89c257..01d9443 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -11,14 +11,21 @@ //! Call it after [`App::tick_no_summaries`] (or `tick`) on a background thread. use crate::app::App; +use crate::collector::codexbar::{ + canonical_provider_id, CodexBarPollError, CodexBarProviderSnapshot, CodexBarQuotaState, +}; use crate::collector::mcp::ACTIVE_MTIME_SECS; use crate::host_info::{AgentAggregate, HostMetrics}; use crate::model::{ - ChatRole, ChildProcess, OrphanPort, RateLimitInfo, SessionStatus, MAX_CHAT_MESSAGES, + ChatRole, ChildProcess, OrphanPort, RateLimitInfo, RateLimitProvenance, SessionStatus, + StatusEvidence, MAX_CHAT_MESSAGES, MAX_VISIBLE_STATUS_OBSERVATIONS, }; use serde::Serialize; +use std::collections::BTreeMap; use std::time::{SystemTime, UNIX_EPOCH}; +const QUOTA_FRESH_SECS: u64 = 600; + /// Top-level snapshot returned by [`App::to_snapshot`]. #[derive(Debug, Clone, Serialize)] pub struct Snapshot { @@ -40,8 +47,11 @@ pub struct Snapshot { pub interval_ms: u64, /// Live agent sessions, newest first (same order as the TUI). pub sessions: Vec, - /// Account-level rate limits (Claude, Codex, …). + /// Account-level rate limits from native integrations and every provider + /// returned by the optional CodexBar integration. pub rate_limits: Vec, + /// Sanitized state and source provenance for the opt-in CodexBar quotas. + pub codexbar_quota: CodexBarQuotaView, /// Ports left open by processes whose parent session has ended. Empty on a /// one-shot snapshot — orphan detection needs cross-tick history, so it /// only populates for a long-running monitor. @@ -50,6 +60,43 @@ pub struct Snapshot { pub mcp_servers: Vec, } +/// Content-free diagnostics for the optional CodexBar quota integration. +#[derive(Debug, Clone, Serialize)] +pub struct CodexBarQuotaView { + /// Persisted user preference. Session-collector visibility does not disable + /// this independent account-level integration. + pub enabled: bool, + /// One of `off`, `checking`, `active`, `active_stale`, `partial`, or + /// `unavailable`. + pub state: &'static str, + /// Backward-compatible summary of the selected Codex row: `native`, + /// `codexbar`, or `mixed`. Use `providers` for every provider. + pub provenance: Option<&'static str>, + /// Backward-compatible staleness summary for the selected Codex row. + pub stale: bool, + /// Unix-epoch seconds of the last completed CodexBar check. + pub last_checked_at: Option, + /// Fixed, content-free failure category for the latest check. + pub error: Option<&'static str>, + /// Bounded provider diagnostics in stable display order. + pub providers: Vec, +} + +/// Content-free diagnostic for one account-quota provider. +#[derive(Debug, Clone, Serialize)] +pub struct CodexBarProviderView { + /// Canonical lowercase provider ID. + pub provider: String, + /// One of `active`, `active_stale`, or `unavailable`. + pub state: &'static str, + /// Selected window sources: `native`, `codexbar`, or `mixed`. + pub provenance: Option<&'static str>, + /// Whether the selected provider sample is older than ten minutes. + pub stale: bool, + /// Fixed failure category. Provider failures serialize as `provider_error`. + pub error: Option<&'static str>, +} + /// One chat line from the transcript tail (detail view only). #[derive(Debug, Clone, Serialize)] pub struct ChatMsgView { @@ -84,7 +131,7 @@ pub struct SubAgentView { /// A single session, flattened and curated for JSON consumers. #[derive(Debug, Clone, Serialize)] pub struct SessionView { - /// Owning CLI: "claude", "codex", "opencode". + /// Owning CLI: "claude", "codex", "opencode", "grok", or "kimi". pub agent_cli: &'static str, /// OS process id of the agent CLI for this session. pub pid: u32, @@ -98,9 +145,13 @@ pub struct SessionView { pub config_root: String, /// Coarse activity state; serializes as its variant name (e.g. `"Thinking"`). pub status: SessionStatus, + /// Provenance, freshness, and the latest five content-free status samples. + pub status_evidence: StatusEvidence, + /// Whether the session is blocked on a response from the user. + pub awaiting_input: bool, /// Model identifier reported by the session (e.g. `"claude-opus-4-6"`). pub model: String, - /// Reasoning effort (Codex only); empty when N/A. + /// Reasoning effort reported by the provider; empty when unavailable. pub effort: String, /// Agent CLI version string, if known. pub version: String, @@ -189,6 +240,209 @@ fn epoch_ms(t: SystemTime) -> Option { .map(|d| d.as_millis() as u64) } +fn epoch_secs(t: SystemTime) -> Option { + t.duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()) +} + +#[derive(Default)] +struct ProviderDiagnostic { + provider: String, + has_quota: bool, + native: bool, + codexbar: bool, + updated_at: Option, + provider_error: bool, +} + +fn provider_sort_key(provider: &str) -> (u8, &str) { + let rank = match provider { + "claude" => 0, + "codex" => 1, + "grok" => 2, + "kimi" => 3, + _ => 4, + }; + (rank, provider) +} + +fn provider_diagnostics( + rate_limits: &[RateLimitInfo], + codexbar: &[CodexBarProviderSnapshot], + now_secs: u64, + codexbar_transport_failed: bool, +) -> Vec { + let mut diagnostics = BTreeMap::::new(); + + for rate_limit in rate_limits { + let Some(provider) = canonical_provider_id(&rate_limit.source) else { + continue; + }; + let has_legacy_window = rate_limit.five_hour_pct.is_some() + || rate_limit.seven_day_pct.is_some() + || rate_limit.five_hour_resets_at.is_some() + || rate_limit.seven_day_resets_at.is_some(); + if rate_limit.windows.is_empty() && !has_legacy_window { + continue; + } + + let diagnostic = + diagnostics + .entry(provider.clone()) + .or_insert_with(|| ProviderDiagnostic { + provider, + ..ProviderDiagnostic::default() + }); + diagnostic.has_quota = true; + diagnostic.updated_at = match (diagnostic.updated_at, rate_limit.updated_at) { + (Some(current), Some(candidate)) => Some(current.max(candidate)), + (None, candidate) => candidate, + (current, None) => current, + }; + + if rate_limit.windows.is_empty() { + // Legacy native rows predate per-window provenance. + diagnostic.native = true; + } else { + for window in &rate_limit.windows { + match window.provenance { + RateLimitProvenance::Native => diagnostic.native = true, + RateLimitProvenance::CodexBar => diagnostic.codexbar = true, + } + } + } + } + + for snapshot in codexbar { + let Some(provider) = canonical_provider_id(&snapshot.provider) else { + continue; + }; + let diagnostic = + diagnostics + .entry(provider.clone()) + .or_insert_with(|| ProviderDiagnostic { + provider, + ..ProviderDiagnostic::default() + }); + if snapshot.error.is_some() { + diagnostic.provider_error = true; + } + + // The merged quota model is authoritative for selected provenance. A + // raw CodexBar success is used only as a defensive fallback if the + // merge has not yet published that provider. + if !diagnostic.has_quota && !snapshot.windows.is_empty() { + diagnostic.has_quota = true; + diagnostic.codexbar = true; + diagnostic.updated_at = snapshot.updated_at; + } + } + + let mut providers = diagnostics + .into_values() + .map(|diagnostic| { + let stale = diagnostic.has_quota + && ((codexbar_transport_failed && diagnostic.codexbar) + || diagnostic.updated_at.is_none_or(|updated_at| { + now_secs.saturating_sub(updated_at) > QUOTA_FRESH_SECS + })); + let provenance = match (diagnostic.native, diagnostic.codexbar) { + (true, true) => Some("mixed"), + (true, false) => Some("native"), + (false, true) => Some("codexbar"), + (false, false) => None, + }; + CodexBarProviderView { + provider: diagnostic.provider, + state: if !diagnostic.has_quota { + "unavailable" + } else if stale { + "active_stale" + } else { + "active" + }, + provenance, + stale, + error: diagnostic.provider_error.then_some("provider_error"), + } + }) + .collect::>(); + providers.sort_by(|left, right| { + provider_sort_key(&left.provider).cmp(&provider_sort_key(&right.provider)) + }); + providers +} + +fn active_global_state(providers: &[CodexBarProviderView]) -> &'static str { + let has_active = providers + .iter() + .any(|provider| provider.state != "unavailable"); + let has_provider_error = providers.iter().any(|provider| provider.error.is_some()); + if has_active && has_provider_error { + "partial" + } else if !has_active { + "unavailable" + } else if providers + .iter() + .filter(|provider| provider.state != "unavailable") + .all(|provider| provider.stale) + { + "active_stale" + } else { + "active" + } +} + +fn global_quota_state( + poll_state: CodexBarQuotaState, + providers: &[CodexBarProviderView], +) -> &'static str { + match poll_state { + CodexBarQuotaState::Off => "off", + CodexBarQuotaState::Checking => "checking", + CodexBarQuotaState::Available => active_global_state(providers), + CodexBarQuotaState::Partial => "partial", + CodexBarQuotaState::Unavailable => "unavailable", + } +} + +fn poll_error_label(error: CodexBarPollError) -> &'static str { + match error { + CodexBarPollError::NotRunnable => "not_runnable", + CodexBarPollError::TimedOut => "timed_out", + CodexBarPollError::OutputTooLarge => "output_too_large", + CodexBarPollError::ProcessFailed => "process_failed", + CodexBarPollError::InvalidResponse => "invalid_response", + CodexBarPollError::UnsupportedResponse => "unsupported_response", + CodexBarPollError::Cancelled => "cancelled", + CodexBarPollError::InternalError => "internal_error", + } +} + +fn codexbar_snapshot(app: &App) -> CodexBarQuotaView { + let status = app.codexbar_quota_status(); + let providers = provider_diagnostics( + &app.rate_limits, + app.codexbar_provider_snapshots(), + epoch_secs(SystemTime::now()).unwrap_or(0), + status.error.is_some(), + ); + let codex = providers + .iter() + .find(|provider| provider.provider == "codex"); + let provenance = codex.and_then(|provider| provider.provenance); + let stale = codex.is_some_and(|provider| provider.stale); + let state = global_quota_state(status.state, &providers); + CodexBarQuotaView { + enabled: app.codexbar_quota_fallback, + state, + provenance, + stale, + last_checked_at: status.last_checked_at, + error: status.error.map(poll_error_label), + providers, + } +} + impl App { /// Build an owned, JSON-serializable snapshot of the current monitor state. /// @@ -207,7 +461,9 @@ impl App { project_name: s.project_name.clone(), cwd: s.cwd.clone(), config_root: s.config_root.clone(), - status: s.status.clone(), + status: s.status, + status_evidence: s.status_evidence.recent(MAX_VISIBLE_STATUS_OBSERVATIONS), + awaiting_input: s.is_awaiting_input(), model: s.model.clone(), effort: s.effort.clone(), version: s.version.clone(), @@ -226,7 +482,7 @@ impl App { started_at_ms: s.started_at, elapsed_secs: s.elapsed().as_secs(), summary: self.session_summary(s), - current_task: s.current_tasks.last().cloned(), + current_task: s.display_task().map(str::to_owned), children: s.children.clone(), compaction_count: s.compaction_count, token_history: tail(&s.token_history, 64), @@ -281,6 +537,7 @@ impl App { interval_ms, sessions, rate_limits: self.rate_limits.clone(), + codexbar_quota: codexbar_snapshot(self), orphan_ports: self.orphan_ports.clone(), mcp_servers, } @@ -291,9 +548,12 @@ impl App { mod tests { use super::*; use crate::app::App; + use crate::collector::codexbar::{ + CodexBarProviderError, CodexBarProviderSnapshot, CodexBarWindow, + }; use crate::config::PanelVisibility; use crate::demo::populate_demo; - use crate::model::SessionStatus; + use crate::model::{RateLimitProvenance, RateLimitWindow, SessionStatus}; use crate::theme::Theme; use std::time::{Duration, UNIX_EPOCH}; @@ -320,6 +580,159 @@ mod tests { assert_eq!(epoch_ms(later), Some(1_500)); } + fn quota(provider: &str, updated_at: u64, sources: &[RateLimitProvenance]) -> RateLimitInfo { + RateLimitInfo { + source: provider.to_string(), + updated_at: Some(updated_at), + windows: sources + .iter() + .enumerate() + .map(|(index, provenance)| { + RateLimitWindow::try_new( + format!("window-{index}"), + format!("Window {index}"), + 25.0, + None, + None, + *provenance, + ) + .unwrap() + }) + .collect(), + ..RateLimitInfo::default() + } + } + + #[test] + fn provider_diagnostics_are_stable_complete_and_sanitized() { + let rate_limits = vec![ + quota("zeta", 1_900, &[RateLimitProvenance::CodexBar]), + quota("grok", 1_900, &[RateLimitProvenance::CodexBar]), + quota( + "codex", + 1_900, + &[RateLimitProvenance::Native, RateLimitProvenance::CodexBar], + ), + quota("claude", 1_900, &[RateLimitProvenance::Native]), + quota("alpha", 1_900, &[RateLimitProvenance::CodexBar]), + ]; + let codexbar = vec![CodexBarProviderSnapshot { + provider: "kimi".to_string(), + windows: Vec::new(), + updated_at: None, + error: Some(CodexBarProviderError::Unavailable), + }]; + + let providers = provider_diagnostics(&rate_limits, &codexbar, 2_000, false); + assert_eq!( + providers + .iter() + .map(|provider| provider.provider.as_str()) + .collect::>(), + ["claude", "codex", "grok", "kimi", "alpha", "zeta"] + ); + assert_eq!(providers[0].provenance, Some("native")); + assert_eq!(providers[1].provenance, Some("mixed")); + assert_eq!(providers[2].provenance, Some("codexbar")); + assert_eq!(providers[3].state, "unavailable"); + assert_eq!(providers[3].provenance, None); + assert_eq!(providers[3].error, Some("provider_error")); + assert_eq!(active_global_state(&providers), "partial"); + + let wire = serde_json::to_value(&providers).unwrap(); + assert_eq!(wire[3]["error"], "provider_error"); + assert!(!wire.to_string().contains("Unavailable")); + } + + #[test] + fn provider_diagnostics_preserve_staleness_and_raw_success_fallback() { + let codexbar = vec![CodexBarProviderSnapshot { + provider: "grok".to_string(), + windows: vec![CodexBarWindow { + id: "primary".to_string(), + label: "Primary".to_string(), + used_pct: 18.0, + resets_at: Some(3_000), + window_minutes: None, + }], + updated_at: Some(1_399), + error: None, + }]; + + let providers = provider_diagnostics(&[], &codexbar, 2_000, false); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].provider, "grok"); + assert_eq!(providers[0].provenance, Some("codexbar")); + assert_eq!(providers[0].state, "active_stale"); + assert!(providers[0].stale); + assert_eq!(active_global_state(&providers), "active_stale"); + + let boundary = quota("grok", 1_400, &[RateLimitProvenance::CodexBar]); + let providers = provider_diagnostics(&[boundary], &[], 2_000, false); + assert_eq!(providers[0].state, "active"); + assert!(!providers[0].stale); + } + + #[test] + fn transport_failure_immediately_stales_retained_codexbar_data_only() { + let rate_limits = vec![ + quota("claude", 1_990, &[RateLimitProvenance::Native]), + quota( + "codex", + 1_990, + &[RateLimitProvenance::Native, RateLimitProvenance::CodexBar], + ), + quota("zeta", 1_990, &[RateLimitProvenance::CodexBar]), + ]; + let retained = vec![CodexBarProviderSnapshot { + provider: "grok".to_string(), + windows: vec![CodexBarWindow { + id: "primary".to_string(), + label: "Primary".to_string(), + used_pct: 18.0, + resets_at: Some(3_000), + window_minutes: None, + }], + updated_at: Some(1_990), + error: None, + }]; + + let providers = provider_diagnostics(&rate_limits, &retained, 2_000, true); + let claude = providers + .iter() + .find(|provider| provider.provider == "claude") + .unwrap(); + let codex = providers + .iter() + .find(|provider| provider.provider == "codex") + .unwrap(); + let grok = providers + .iter() + .find(|provider| provider.provider == "grok") + .unwrap(); + let zeta = providers + .iter() + .find(|provider| provider.provider == "zeta") + .unwrap(); + + assert_eq!(claude.state, "active"); + assert!(!claude.stale, "pure native quota remains fresh"); + assert_eq!(codex.state, "active_stale"); + assert!(codex.stale, "mixed quota is conservatively stale"); + assert_eq!(grok.state, "active_stale"); + assert!( + grok.stale, + "raw retained CodexBar success is immediately stale" + ); + assert_eq!(zeta.state, "active_stale"); + assert!(zeta.stale, "merged CodexBar window is immediately stale"); + assert_eq!( + global_quota_state(CodexBarQuotaState::Partial, &providers), + "partial" + ); + assert_eq!(poll_error_label(CodexBarPollError::TimedOut), "timed_out"); + } + #[test] fn session_status_serializes_as_variant_name() { // The web UI matches on these exact strings — they are part of the @@ -327,9 +740,12 @@ mod tests { for (status, wire) in [ (SessionStatus::Thinking, "\"Thinking\""), (SessionStatus::Executing, "\"Executing\""), + (SessionStatus::Working, "\"Working\""), (SessionStatus::Waiting, "\"Waiting\""), + (SessionStatus::Idle, "\"Idle\""), (SessionStatus::Unknown, "\"Unknown\""), (SessionStatus::RateLimited, "\"RateLimited\""), + (SessionStatus::Error, "\"Error\""), (SessionStatus::Done, "\"Done\""), ] { assert_eq!(serde_json::to_string(&status).unwrap(), wire); @@ -351,6 +767,20 @@ mod tests { #[test] fn to_snapshot_maps_fields_and_passes_interval_through() { let app = demo_app(); + let awaiting_session_id = app + .sessions + .iter() + .find(|session| session.status == SessionStatus::Waiting) + .expect("demo includes an actionable wait") + .session_id + .clone(); + let idle_session_id = app + .sessions + .iter() + .find(|session| session.status == SessionStatus::Idle) + .expect("demo includes an idle session") + .session_id + .clone(); let snap = app.to_snapshot(1_234); assert_eq!(snap.interval_ms, 1_234); @@ -363,11 +793,30 @@ mod tests { // Bounded tails. assert!(s.token_history.len() <= 64); assert!(s.tool_calls.len() <= 24); + assert!(s.status_evidence.observations.len() <= 5); + assert_eq!( + s.awaiting_input, + matches!(s.status, SessionStatus::Waiting), + "awaiting_input must be derived from status" + ); // Chat roles map to the stable wire strings only. for m in &s.chat_messages { assert!(m.role == "user" || m.role == "assistant"); } } + + assert!(snap + .sessions + .iter() + .find(|s| s.session_id == awaiting_session_id) + .is_some_and( + |s| s.awaiting_input && s.current_task.as_deref() == Some("waiting for user input") + )); + assert!(snap + .sessions + .iter() + .find(|s| s.session_id == idle_session_id) + .is_some_and(|s| !s.awaiting_input && s.current_task.as_deref() == Some("idle"))); } #[test] @@ -376,9 +825,112 @@ mod tests { let json = serde_json::to_string(&snap).expect("snapshot serializes"); assert!(json.contains("\"sessions\"")); assert!(json.contains("\"interval_ms\":2000")); + assert!( + !json.contains("action_process_incarnation"), + "private process anchors must never enter JSON snapshots" + ); // Re-parse as generic JSON to confirm it is well-formed. let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON"); assert!(parsed["sessions"].is_array()); + assert!(parsed["codexbar_quota"].is_object()); + assert_eq!(parsed["codexbar_quota"]["state"], "off"); + assert!(parsed["codexbar_quota"]["error"].is_null()); + assert!(parsed["codexbar_quota"]["providers"].is_array()); + assert!(parsed["sessions"] + .as_array() + .is_some_and(|sessions| sessions + .iter() + .all(|session| session["awaiting_input"].is_boolean() + && session["status_evidence"].is_object()))); + let sessions = parsed["sessions"].as_array().expect("sessions array"); + assert!(sessions.iter().any(|session| { + session["status"] == "Waiting" && session["awaiting_input"] == true + })); + assert!(sessions + .iter() + .any(|session| session["status"] == "Idle" && session["awaiting_input"] == false)); + } + + #[test] + fn hidden_codex_sessions_do_not_disable_codexbar_snapshot_state() { + let app = App::new_with_config_and_claude_dirs_and_codexbar( + Theme::default(), + &["codex".to_string()], + PanelVisibility::default(), + &[], + true, + ); + let parsed = serde_json::to_value(app.to_snapshot(2_000)).expect("snapshot serializes"); + + assert_eq!(parsed["codexbar_quota"]["enabled"], true); + assert_eq!(parsed["codexbar_quota"]["state"], "unavailable"); + assert!(parsed["codexbar_quota"]["provenance"].is_null()); + assert!(parsed["codexbar_quota"]["error"].is_null()); + assert_eq!(parsed["codexbar_quota"]["providers"], serde_json::json!([])); + } + + #[test] + fn snapshot_includes_only_the_latest_five_status_samples() { + use crate::model::{StatusAuthority, StatusObservation, StatusReason}; + + let mut app = demo_app(); + let session = app.sessions.first_mut().expect("demo session"); + session.status_evidence = StatusEvidence::default(); + for observed_at_ms in 1..=8 { + session.status_evidence.observe(StatusObservation::new( + session.status, + StatusAuthority::Provider, + StatusReason::ProviderExecuting, + observed_at_ms, + 1, + )); + } + + let snapshot = app.to_snapshot(2_000); + let observations = &snapshot.sessions[0].status_evidence.observations; + assert_eq!(observations.len(), MAX_VISIBLE_STATUS_OBSERVATIONS); + assert_eq!(observations[0].observed_at_ms, 4); + assert_eq!(observations[4].observed_at_ms, 8); + assert_eq!(snapshot.sessions[0].status_evidence.consecutive_matching, 8); + } + + #[test] + fn snapshot_unknown_task_cannot_reuse_a_stale_execution_label() { + let mut app = demo_app(); + let session = app.sessions.first_mut().expect("demo session"); + session.status = SessionStatus::Unknown; + session.current_tasks = vec!["Edit stale.rs".to_string()]; + + let snapshot = app.to_snapshot(2_000); + + assert_eq!(snapshot.sessions[0].status, SessionStatus::Unknown); + assert!(!snapshot.sessions[0].awaiting_input); + assert_eq!( + snapshot.sessions[0].current_task.as_deref(), + Some("status evidence unavailable") + ); + } + + #[test] + fn snapshot_working_is_active_but_not_awaiting_input() { + let mut app = demo_app(); + let session = app.sessions.first_mut().expect("demo session"); + session.status = SessionStatus::Working; + session.current_tasks = vec!["Edit stale.rs".to_string()]; + session.enforce_status_contract(); + + let snapshot = app.to_snapshot(2_000); + let session = &snapshot.sessions[0]; + + assert_eq!(session.status, SessionStatus::Working); + assert!(session.status.is_active()); + assert!(!session.awaiting_input); + assert_eq!(session.current_task.as_deref(), Some("working")); + + let wire = serde_json::to_value(session).expect("session snapshot serializes"); + assert_eq!(wire["status"], "Working"); + assert_eq!(wire["awaiting_input"], false); + assert_eq!(wire["current_task"], "working"); } #[test] diff --git a/src/ui/config.rs b/src/ui/config.rs index 3085ae0..468dd5b 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -1,4 +1,5 @@ use crate::app::App; +use crate::collector::codexbar::CodexBarQuotaState; use crate::locale::t; use crate::theme::Theme; use ratatui::layout::{Alignment, Rect}; @@ -45,42 +46,59 @@ pub(crate) fn draw_config_overlay(f: &mut Frame, app: &App, theme: &Theme) { let theme_label = t("config.theme"); let on_str = t("config.on"); let off_str = t("config.off"); - let items: Vec<(String, String)> = vec![ - (theme_label, app.theme.name.to_string()), + let codexbar_state = app.codexbar_quota_status().state; + let items: Vec<(String, String, ConfigValueTone)> = vec![ + ( + theme_label, + app.theme.name.to_string(), + ConfigValueTone::Plain, + ), ( t("config.context_panel"), toggle_str(&on_str, &off_str, app.show_context), + toggle_tone(app.show_context), ), ( t("config.quota_panel"), toggle_str(&on_str, &off_str, app.show_quota), + toggle_tone(app.show_quota), ), ( t("config.tokens_panel"), toggle_str(&on_str, &off_str, app.show_tokens), + toggle_tone(app.show_tokens), ), ( t("config.projects_panel"), toggle_str(&on_str, &off_str, app.show_projects), + toggle_tone(app.show_projects), ), ( t("config.ports_panel"), toggle_str(&on_str, &off_str, app.show_ports), + toggle_tone(app.show_ports), ), ( t("config.sessions_panel"), toggle_str(&on_str, &off_str, app.show_sessions), + toggle_tone(app.show_sessions), ), ( t("config.mcp_panel"), toggle_str(&on_str, &off_str, app.show_mcp), + toggle_tone(app.show_mcp), + ), + ( + t("config.codexbar_quota"), + codexbar_state_label(codexbar_state), + codexbar_state_tone(codexbar_state), ), ]; let mut lines = Vec::new(); lines.push(Line::from("")); - for (i, (label, value)) in items.iter().enumerate() { + for (i, (label, value, tone)) in items.iter().enumerate() { let selected = i == app.config_selected; let cursor = if selected { ">" } else { " " }; @@ -95,17 +113,19 @@ pub(crate) fn draw_config_overlay(f: &mut Frame, app: &App, theme: &Theme) { let value_style = if selected { Style::default().fg(theme.selected_fg).bg(theme.selected_bg) - } else if value == "on" { - Style::default().fg(theme.proc_misc) - } else if value == "off" { - Style::default().fg(theme.inactive_fg) } else { - Style::default().fg(theme.session_id) + Style::default().fg(match tone { + ConfigValueTone::Plain => theme.session_id, + ConfigValueTone::Active => theme.proc_misc, + ConfigValueTone::Inactive => theme.inactive_fg, + ConfigValueTone::Pending => theme.warning_fg, + ConfigValueTone::Error => theme.status_fg, + }) }; - let label_w = 22; + let label_w = 25; let padded_label = format!("{} {: String { off_str.to_string() } } + +#[derive(Clone, Copy)] +enum ConfigValueTone { + Plain, + Active, + Inactive, + Pending, + Error, +} + +fn toggle_tone(enabled: bool) -> ConfigValueTone { + if enabled { + ConfigValueTone::Active + } else { + ConfigValueTone::Inactive + } +} + +fn codexbar_state_label(state: CodexBarQuotaState) -> String { + match state { + CodexBarQuotaState::Off => t("config.off"), + CodexBarQuotaState::Checking => t("config.codexbar_checking"), + CodexBarQuotaState::Available => t("config.codexbar_active"), + CodexBarQuotaState::Partial => t("config.codexbar_partial"), + CodexBarQuotaState::Unavailable => t("config.codexbar_unavailable"), + } +} + +fn codexbar_state_tone(state: CodexBarQuotaState) -> ConfigValueTone { + match state { + CodexBarQuotaState::Off => ConfigValueTone::Inactive, + CodexBarQuotaState::Available => ConfigValueTone::Active, + CodexBarQuotaState::Checking | CodexBarQuotaState::Partial => ConfigValueTone::Pending, + CodexBarQuotaState::Unavailable => ConfigValueTone::Error, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::PanelVisibility; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + #[test] + fn codexbar_states_have_compact_unambiguous_labels() { + assert_eq!(codexbar_state_label(CodexBarQuotaState::Off), "off"); + assert_eq!( + codexbar_state_label(CodexBarQuotaState::Checking), + "checking" + ); + assert_eq!( + codexbar_state_label(CodexBarQuotaState::Available), + "active" + ); + assert_eq!(codexbar_state_label(CodexBarQuotaState::Partial), "partial"); + assert_eq!( + codexbar_state_label(CodexBarQuotaState::Unavailable), + "unavailable" + ); + } + + #[test] + fn config_overlay_keeps_codexbar_state_visible_at_minimum_size() { + let app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + let backend = TestBackend::new(60, 18); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| draw_config_overlay(f, &app, &app.theme)) + .unwrap(); + let text = format!("{}", terminal.backend()); + + assert!(text.contains("CodexBar"), "{text}"); + assert!(text.contains("off"), "{text}"); + } +} diff --git a/src/ui/context.rs b/src/ui/context.rs index bb9f85b..ab5b9f2 100644 --- a/src/ui/context.rs +++ b/src/ui/context.rs @@ -151,9 +151,12 @@ fn draw_context_bars(f: &mut Frame, app: &App, area: Rect, cpu_grad: &[Color; 10 let window_label = t("context.window"); for session in &app.sessions { + let context_known = session.context_window > 0; let raw_pct = session.context_percent; let bar_pct = raw_pct.min(100.0); - let warn = if raw_pct >= 90.0 { + let warn = if !context_known { + "" + } else if raw_pct >= 90.0 { "⚠" } else if raw_pct >= 75.0 { "!" @@ -178,7 +181,11 @@ fn draw_context_bars(f: &mut Frame, app: &App, area: Rect, cpu_grad: &[Color; 10 Cell::from(Line::from({ let mut spans = meter_bar(bar_pct, bar_width, cpu_grad, theme.meter_bg); spans.push(Span::styled( - format!(" {:>3.0}%{}", raw_pct, warn), + if context_known { + format!(" {:>3.0}%{}", raw_pct, warn) + } else { + " —".to_string() + }, Style::default().fg(pct_color), )); spans diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1f77b08..17b0d0a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -477,7 +477,11 @@ fn desktop_layout(app: &App, area: Rect) -> DesktopLayout { n += 1; } if mid_h > 0 { - constraints[n] = Constraint::Length(mid_h); + constraints[n] = if sessions_h == 0 { + Constraint::Min(mid_h) + } else { + Constraint::Length(mid_h) + }; n += 1; } if sessions_h > 0 { @@ -1009,8 +1013,8 @@ mod tests { #[test] fn fmt_age_buckets() { - // t() defaults to English when ABTOP_LANG is unset, so the strings - // here match the en-US locale values for `time.{s,m,h,d}_ago`. + // The centralized catalog is English, so these strings match the + // values for `time.{s,m,h,d}_ago`. assert_eq!(fmt_age(5), "5s ago"); assert_eq!(fmt_age(59), "59s ago"); assert_eq!(fmt_age(60), "1m ago"); @@ -1357,6 +1361,40 @@ mod tests { } } + #[test] + fn quota_only_desktop_expands_mid_row_and_pins_footer() { + let panels = PanelVisibility { + context: false, + quota: true, + tokens: false, + projects: false, + ports: false, + sessions: false, + mcp: false, + }; + let app = App::new_with_config(Theme::default(), &[], panels); + let layout = desktop_layout(&app, Rect::new(0, 0, 160, 48)); + + assert!(layout.context.is_none()); + assert!(layout.sessions.is_none()); + assert_eq!( + layout.mid, + [(NarrowSection::Quota, Rect::new(0, 1, 160, 46))] + ); + assert_eq!(layout.footer, Rect::new(0, 47, 160, 1)); + } + + #[test] + fn sessions_keep_first_claim_over_the_fixed_mid_row() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + crate::demo::populate_demo(&mut app); + let layout = desktop_layout(&app, Rect::new(0, 0, 160, 40)); + + assert_eq!(layout.mid.first().unwrap().1.height, 8); + assert!(layout.sessions.unwrap().height >= 8); + assert_eq!(layout.footer, Rect::new(0, 39, 160, 1)); + } + fn render_demo(width: u16, height: u16) -> String { let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); crate::demo::populate_demo(&mut app); diff --git a/src/ui/ports.rs b/src/ui/ports.rs index 4dca9e3..9417d1c 100644 --- a/src/ui/ports.rs +++ b/src/ui/ports.rs @@ -23,14 +23,10 @@ pub(crate) fn draw_ports_panel_active( // Collect (port, project_name, session_id_short) let mut all_ports: Vec<(u16, String, String)> = Vec::new(); for session in &app.sessions { - let sid_short = if session.session_id.len() >= 8 { - &session.session_id[..8] - } else { - &session.session_id - }; + let sid_short: String = session.session_id.chars().take(8).collect(); for child in &session.children { if let Some(port) = child.port { - all_ports.push((port, session.project_name.clone(), sid_short.to_string())); + all_ports.push((port, session.project_name.clone(), sid_short.clone())); } } } diff --git a/src/ui/quota.rs b/src/ui/quota.rs index 1c65813..9eba6c4 100644 --- a/src/ui/quota.rs +++ b/src/ui/quota.rs @@ -1,20 +1,99 @@ use crate::app::App; +use crate::collector::codexbar::canonical_provider_id; use crate::locale::t; -use crate::model::RateLimitInfo; +use crate::model::{RateLimitInfo, RateLimitProvenance, RateLimitWindow}; use crate::theme::Theme; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -use super::{btop_block_active, fmt_tokens, grad_at, make_gradient, remaining_bar, styled_label}; +use super::{btop_block_active, fmt_tokens, grad_at, make_gradient, remaining_bar}; -/// Data considered "stale" when its updated_at is older than this many seconds. +/// Data considered stale when its update timestamp is older than this many seconds. const STALE_SECS: u64 = 600; +const DETAILED_MIN_WIDTH: u16 = 24; +const COMPACT_MIN_WIDTH: u16 = 12; +const MIN_COMPACT_HEIGHT: u16 = 2; -/// Fixed source order so columns stay stable across runs. -const SOURCES: &[&str] = &["claude", "codex"]; +#[derive(Clone, Debug)] +struct ProviderCard { + key: String, + windows: Vec, + updated_at: Option, + unavailable: bool, + force_stale: bool, +} + +impl ProviderCard { + fn from_rate_limit(rate_limit: &RateLimitInfo) -> Option { + let key = provider_key(&rate_limit.source)?; + Some(Self { + key, + windows: effective_windows(rate_limit), + updated_at: rate_limit.updated_at, + unavailable: false, + force_stale: false, + }) + } + + fn unavailable(key: String) -> Self { + Self { + key, + windows: Vec::new(), + updated_at: None, + unavailable: true, + force_stale: false, + } + } + + fn is_stale(&self, now: u64) -> bool { + self.force_stale + || self + .updated_at + .is_none_or(|timestamp| now.saturating_sub(timestamp) > STALE_SECS) + } + + fn has_codexbar_windows(&self) -> bool { + self.windows + .iter() + .any(|window| window.provenance == RateLimitProvenance::CodexBar) + } + + fn heading(&self) -> String { + let has_native = self + .windows + .iter() + .any(|window| window.provenance == RateLimitProvenance::Native); + let has_codexbar = self.unavailable + || self + .windows + .iter() + .any(|window| window.provenance == RateLimitProvenance::CodexBar); + let suffix = match (has_native, has_codexbar) { + (true, true) => "·MIX", + (false, true) => "·CB", + _ => "", + }; + format!("{}{}", self.key.to_uppercase(), suffix) + } + + fn detailed_height(&self) -> u16 { + if self.unavailable || self.windows.is_empty() { + 2 + } else { + 1_u16.saturating_add(self.windows.len() as u16) + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CardMode { + Detailed, + Compact, +} pub(crate) fn draw_quota_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { draw_quota_panel_active(f, app, area, theme, false); @@ -33,213 +112,700 @@ pub(crate) fn draw_quota_panel_active( f.render_widget(block, area); let inner = Rect { - x: area.x + 1, - y: area.y + 1, + x: area.x.saturating_add(1), + y: area.y.saturating_add(1), width: area.width.saturating_sub(2), height: area.height.saturating_sub(2), }; + if inner.height == 0 || inner.width == 0 { + return; + } - // Bottom summary: total tokens + rate - let total_tokens: u64 = app.sessions.iter().map(|s| s.total_tokens()).sum(); - let rates = &app.token_rates; - let ticks_per_min = 30usize; - let tokens_per_min: f64 = rates.iter().rev().take(ticks_per_min).sum(); - - // Split into side-by-side columns: one per known source (CLAUDE | CODEX). - // Columns are always rendered so the panel layout stays stable even when a - // source has no data yet. - let num_sources = SOURCES.len() as u16; - let col_w = inner.width / num_sources; - let content_h = inner.height.saturating_sub(1); // reserve last row for totals - - for (i, source) in SOURCES.iter().enumerate() { - let col_x = inner.x + (i as u16) * col_w; - let this_w = if i as u16 == num_sources - 1 { - inner.width - (i as u16) * col_w - } else { - col_w + let cards = collect_provider_cards(app); + let content_height = inner.height.saturating_sub(1); + draw_provider_grid( + f, + &cards, + Rect { + height: content_height, + ..inner + }, + &cpu_grad, + theme, + ); + + let total_tokens: u64 = app + .sessions + .iter() + .map(|session| session.total_tokens()) + .sum(); + let tokens_per_min: f64 = app.token_rates.iter().rev().take(30).sum(); + let total_line = Line::from(vec![ + Span::styled( + format!(" {} {}", t("quota.total"), fmt_tokens(total_tokens)), + Style::default().fg(theme.main_fg), + ), + Span::styled( + format!(" {}/min", fmt_tokens(tokens_per_min as u64)), + Style::default().fg(theme.graph_text), + ), + ]); + f.render_widget( + Paragraph::new(total_line), + Rect { + x: inner.x, + y: inner.y.saturating_add(content_height), + width: inner.width, + height: 1, + }, + ); +} + +fn collect_provider_cards(app: &App) -> Vec { + let mut cards: Vec = Vec::new(); + for rate_limit in &app.rate_limits { + let Some(incoming) = ProviderCard::from_rate_limit(rate_limit) else { + continue; }; - let col_area = Rect { - x: col_x, - y: inner.y, - width: this_w, - height: content_h, + if let Some(existing) = cards.iter_mut().find(|card| card.key == incoming.key) { + merge_provider_card(existing, incoming); + } else { + cards.push(incoming); + } + } + + // Successful snapshots are represented by the merged rate-limit rows above. + // Snapshot failures add a tile only when no usable row exists for that provider. + for snapshot in app.codexbar_provider_snapshots() { + if snapshot.error.is_none() { + continue; + } + let Some(key) = provider_key(&snapshot.provider) else { + continue; }; + if !cards.iter().any(|card| card.key == key) { + cards.push(ProviderCard::unavailable(key)); + } + } - let rl = app - .rate_limits + apply_codexbar_transport_failure(&mut cards, app.codexbar_quota_status().error.is_some()); + + cards.sort_by(|left, right| provider_sort_key(&left.key).cmp(&provider_sort_key(&right.key))); + cards +} + +fn apply_codexbar_transport_failure(cards: &mut [ProviderCard], failed: bool) { + if !failed { + return; + } + for card in cards { + if card.has_codexbar_windows() { + card.force_stale = true; + } + } +} + +fn merge_provider_card(existing: &mut ProviderCard, incoming: ProviderCard) { + existing.updated_at = match (existing.updated_at, incoming.updated_at) { + (Some(left), Some(right)) => Some(left.max(right)), + (left, right) => left.or(right), + }; + existing.unavailable &= incoming.unavailable; + existing.force_stale |= incoming.force_stale; + for window in incoming.windows { + if let Some(index) = existing + .windows .iter() - .find(|r| r.source.eq_ignore_ascii_case(source)); - draw_source_column(f, col_area, source, rl, &cpu_grad, theme); + .position(|current| current.id.eq_ignore_ascii_case(&window.id)) + { + if existing.windows[index].provenance == RateLimitProvenance::CodexBar + && window.provenance == RateLimitProvenance::Native + { + existing.windows[index] = window; + } + } else { + existing.windows.push(window); + } + } +} + +fn effective_windows(rate_limit: &RateLimitInfo) -> Vec { + if !rate_limit.windows.is_empty() { + return rate_limit.windows.clone(); } - // Total tokens summary on last row (full width) - let bottom_area = Rect { - x: inner.x, - y: inner.y + content_h, - width: inner.width, - height: 1, + // Compatibility for native collectors and external JSON consumers that still + // populate the historical two slots while migrating to `windows`. + let mut windows = Vec::with_capacity(2); + if let Some(used_pct) = rate_limit.five_hour_pct { + if let Some(window) = RateLimitWindow::try_new( + "primary", + format_window_label(rate_limit.five_hour_window_minutes, t("quota.5h")), + used_pct, + rate_limit.five_hour_resets_at, + rate_limit.five_hour_window_minutes, + RateLimitProvenance::Native, + ) { + windows.push(window); + } + } + if let Some(used_pct) = rate_limit.seven_day_pct { + if let Some(window) = RateLimitWindow::try_new( + "secondary", + format_window_label(rate_limit.seven_day_window_minutes, t("quota.7d")), + used_pct, + rate_limit.seven_day_resets_at, + rate_limit.seven_day_window_minutes, + RateLimitProvenance::Native, + ) { + windows.push(window); + } + } + windows +} + +fn provider_key(source: &str) -> Option { + canonical_provider_id(source) +} + +fn provider_sort_key(provider: &str) -> (u8, &str) { + let rank = match provider { + "claude" => 0, + "codex" => 1, + "grok" => 2, + "kimi" => 3, + _ => 4, }; - let total_label = t("quota.total"); - f.render_widget( - Paragraph::new(vec![Line::from(vec![ - Span::styled( - format!(" {} {}", total_label, fmt_tokens(total_tokens)), - Style::default().fg(theme.main_fg), - ), - Span::styled( - format!(" {}/min", fmt_tokens(tokens_per_min as u64)), - Style::default().fg(theme.graph_text), - ), - ])]), - bottom_area, - ); + (rank, provider) } -fn draw_source_column( +fn draw_provider_grid( f: &mut Frame, + cards: &[ProviderCard], area: Rect, - source: &str, - rl: Option<&RateLimitInfo>, cpu_grad: &[ratatui::style::Color; 101], theme: &Theme, ) { - let col_w_usize = area.width as usize; - let bar_w = col_w_usize.saturating_sub(10).clamp(2, 8); + if area.width == 0 || area.height == 0 { + return; + } + if cards.is_empty() { + f.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" — {}", t("quota.no_data")), + Style::default().fg(theme.inactive_fg), + ))), + area, + ); + return; + } - let Some(rl) = rl else { - let hint = if source.eq_ignore_ascii_case("claude") { - t("quota.abtop_setup") - } else { - t("quota.run_codex") - }; - let no_data = t("quota.no_data"); - let lines = vec![ - Line::from(Span::styled( - format!(" {}", source.to_uppercase()), + if let Some((columns, row_heights)) = detailed_layout(cards, area) { + draw_complete_grid( + f, + cards, + area, + columns, + &row_heights, + CardMode::Detailed, + cpu_grad, + theme, + ); + return; + } + + draw_compact_grid(f, cards, area, cpu_grad, theme); +} + +fn detailed_layout(cards: &[ProviderCard], area: Rect) -> Option<(usize, Vec)> { + let max_columns = cards + .len() + .min(usize::from(area.width / DETAILED_MIN_WIDTH)); + for columns in (1..=max_columns).rev() { + let row_heights: Vec = cards + .chunks(columns) + .map(|row| { + row.iter() + .map(ProviderCard::detailed_height) + .max() + .unwrap_or(0) + }) + .collect(); + if row_heights.iter().copied().fold(0_u16, u16::saturating_add) <= area.height { + return Some((columns, row_heights)); + } + } + None +} + +#[allow(clippy::too_many_arguments)] +fn draw_complete_grid( + f: &mut Frame, + cards: &[ProviderCard], + area: Rect, + columns: usize, + row_heights: &[u16], + mode: CardMode, + cpu_grad: &[ratatui::style::Color; 101], + theme: &Theme, +) { + let mut y = area.y; + for (row_index, row) in cards.chunks(columns).enumerate() { + let height = row_heights[row_index]; + for (column, card) in row.iter().enumerate() { + let cell = grid_cell(area, columns, column, y, height); + draw_provider_card(f, cell, card, mode, cpu_grad, theme); + } + y = y.saturating_add(height); + } +} + +fn draw_compact_grid( + f: &mut Frame, + cards: &[ProviderCard], + area: Rect, + cpu_grad: &[ratatui::style::Color; 101], + theme: &Theme, +) { + let card_columns = cards + .len() + .min(usize::from((area.width / COMPACT_MIN_WIDTH).max(1))); + let maximum_card_rows = usize::from(area.height / MIN_COMPACT_HEIGHT); + let card_capacity = card_columns.saturating_mul(maximum_card_rows); + if card_capacity < cards.len() { + let strip_columns = cards + .len() + .min(usize::from((area.width / COMPACT_MIN_WIDTH).max(1))); + if cards.len().div_ceil(strip_columns) <= usize::from(area.height) { + draw_strip_grid(f, cards, area, strip_columns, theme); + return; + } + } + + if area.height == 1 { + let hidden_providers = cards.len().saturating_sub(1); + let hidden_windows = cards.iter().map(|card| card.windows.len()).sum::(); + let overflow = format!("+{hidden_providers}p +{hidden_windows}w"); + let overflow_width = UnicodeWidthStr::width(overflow.as_str()); + let heading_width = usize::from(area.width) + .saturating_sub(overflow_width) + .saturating_sub(1); + let text = format!("{} {overflow}", fit_heading(&cards[0], heading_width)); + f.render_widget( + Paragraph::new(Line::from(Span::styled( + fit_text(&text, usize::from(area.width)), Style::default() .fg(theme.title) .add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - format!(" — {}", no_data), - Style::default().fg(theme.inactive_fg), - )), - Line::from(Span::styled( - format!(" {}", hint), - Style::default().fg(theme.graph_text), - )), - ]; - f.render_widget(Paragraph::new(lines), area); + ))), + area, + ); + return; + } + + let columns = card_columns; + let maximum_rows = usize::from((area.height / MIN_COMPACT_HEIGHT).max(1)); + let capacity = columns.saturating_mul(maximum_rows).max(1); + if capacity == 1 && cards.len() > 1 { + let hidden_windows = cards[0].windows.len().saturating_sub(1) + + cards[1..] + .iter() + .map(|card| card.windows.len()) + .sum::(); + draw_single_capacity_card(f, area, &cards[0], cards.len() - 1, hidden_windows, theme); return; + } + let (visible_cards, overflow_tile, hidden_providers) = if cards.len() > capacity { + (capacity - 1, true, cards.len() - (capacity - 1)) + } else { + (cards.len(), false, 0) }; + let tile_count = visible_cards + usize::from(overflow_tile); + let rows = tile_count.div_ceil(columns).max(1); + let row_heights = even_row_heights(area.height, rows); + + let mut y = area.y; + for (row, height) in row_heights.iter().copied().enumerate() { + for column in 0..columns { + let tile = row * columns + column; + if tile >= tile_count { + break; + } + let cell = grid_cell(area, columns, column, y, height); + if tile < visible_cards { + draw_provider_card(f, cell, &cards[tile], CardMode::Compact, cpu_grad, theme); + } else { + draw_provider_overflow(f, cell, hidden_providers, theme); + } + } + y = y.saturating_add(height); + } +} +fn draw_single_capacity_card( + f: &mut Frame, + area: Rect, + card: &ProviderCard, + hidden_providers: usize, + hidden_windows: usize, + theme: &Theme, +) { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); - let is_stale = rl - .updated_at - .is_some_and(|ts| now.saturating_sub(ts) > STALE_SECS); - - // Stale data → dim the source name. Drops the unlabeled "Xs ago" row - // we used to render here; keeping a distinct freshness color but no - // explicit number is enough to signal "values may be out of date" - // without competing with the reset countdown for the user's attention. - let source_color = if is_stale { + let heading_color = if card.is_stale(now) { theme.inactive_fg } else { theme.title }; - - let mut lines: Vec = Vec::new(); - lines.push(Line::from(Span::styled( - format!(" {}", rl.source.to_uppercase()), + let width = usize::from(area.width); + let mut lines = vec![Line::from(Span::styled( + format!(" {}", fit_heading(card, width.saturating_sub(1))), Style::default() - .fg(source_color) + .fg(heading_color) .add_modifier(Modifier::BOLD), - ))); - - // Reset countdown is only meaningful when the data is fresh enough - // that the reported `resets_at` is still in the future. Stale sources - // get the bar (the % is approximately correct) but no countdown row. - let show_reset = !is_stale; - - if let Some(used_pct) = rl.five_hour_pct { - let remaining = (100.0 - used_pct).clamp(0.0, 100.0); - let reset = if show_reset { - rl.five_hour_resets_at - .map(format_reset_time) - .unwrap_or_default() - } else { - String::new() - }; - let c = grad_at(cpu_grad, used_pct); - let label_5h = format_window_label(rl.five_hour_window_minutes, t("quota.5h")); - let mut s = vec![styled_label( - format!(" {}", label_5h).as_str(), - theme.graph_text, - )]; - s.extend(remaining_bar(remaining, bar_w, cpu_grad, theme.meter_bg)); - s.push(Span::styled( - format!(" {:>3.0}%", remaining), - Style::default().fg(c), - )); - lines.push(Line::from(s)); - // Always reserve the row so both columns line up vertically; - // when there's nothing meaningful to show (stale source or the - // cached reset moment is past), render it blank. + ))]; + let status = compact_card_status(card); + let overflow = format!("+{hidden_providers}p +{hidden_windows}w"); + if area.height >= 3 { lines.push(Line::from(Span::styled( - if reset.is_empty() { - String::new() - } else { - format!(" {}", reset) - }, + format!(" {}", fit_text(&status, width.saturating_sub(1))), Style::default().fg(theme.graph_text), ))); + lines.push(Line::from(Span::styled( + format!(" {}", fit_text(&overflow, width.saturating_sub(1))), + Style::default().fg(theme.graph_text), + ))); + } else if area.height >= 2 { + let overflow_width = UnicodeWidthStr::width(overflow.as_str()); + let status_width = width.saturating_sub(overflow_width).saturating_sub(1); + let status = fit_text(&status, status_width); + lines.push(Line::from(Span::styled( + fit_text(&format!("{status} {overflow}"), width), + Style::default().fg(theme.graph_text), + ))); + } + f.render_widget(Paragraph::new(lines), area); +} + +fn compact_card_status(card: &ProviderCard) -> String { + if card.unavailable { + return t("quota.unavailable"); } - if let Some(used_pct) = rl.seven_day_pct { - let remaining = (100.0 - used_pct).clamp(0.0, 100.0); - let reset = if show_reset { - rl.seven_day_resets_at - .map(format_reset_time) - .unwrap_or_default() + if let Some(window) = card.windows.first() { + return format!("{:.0}%", (100.0 - window.used_pct).clamp(0.0, 100.0)); + } + format!("— {}", t("quota.no_data")) +} + +fn draw_strip_grid( + f: &mut Frame, + cards: &[ProviderCard], + area: Rect, + columns: usize, + theme: &Theme, +) { + for (index, card) in cards.iter().enumerate() { + let row = index / columns; + let column = index % columns; + let cell = grid_cell(area, columns, column, area.y + row as u16, 1); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let color = if card.is_stale(now) { + theme.inactive_fg } else { - String::new() + theme.title }; - let c = grad_at(cpu_grad, used_pct); - let label_7d = format_window_label(rl.seven_day_window_minutes, t("quota.7d")); - let mut s = vec![styled_label( - format!(" {}", label_7d).as_str(), - theme.graph_text, - )]; - s.extend(remaining_bar(remaining, bar_w, cpu_grad, theme.meter_bg)); - s.push(Span::styled( - format!(" {:>3.0}%", remaining), - Style::default().fg(c), + f.render_widget( + Paragraph::new(Line::from(Span::styled( + strip_text(card, usize::from(cell.width)), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ))), + cell, + ); + } +} + +fn strip_text(card: &ProviderCard, width: usize) -> String { + if width == 0 { + return String::new(); + } + let heading = fit_heading(card, width.saturating_sub(1)); + let detail = if card.unavailable { + let full = format!(" {heading} {}", t("quota.unavailable")); + if UnicodeWidthStr::width(full.as_str()) <= width { + return full; + } + format!(" {heading} unavail") + } else if let Some(window) = card.windows.first() { + let remaining = (100.0 - window.used_pct).clamp(0.0, 100.0); + let hidden = card.windows.len().saturating_sub(1); + if hidden == 0 { + format!(" {heading} {:>3.0}%", remaining) + } else { + let with_value = format!(" {heading} {:>3.0}% +{hidden}w", remaining); + if UnicodeWidthStr::width(with_value.as_str()) <= width { + return with_value; + } + format!(" {heading} +{hidden}w") + } + } else { + format!(" {heading} —") + }; + fit_text(&detail, width) +} + +fn fit_heading(card: &ProviderCard, width: usize) -> String { + let heading = card.heading(); + if UnicodeWidthStr::width(heading.as_str()) <= width { + return heading; + } + let suffix = if heading.ends_with("·MIX") { + "·MIX" + } else if heading.ends_with("·CB") { + "·CB" + } else { + "" + }; + let suffix_width = UnicodeWidthStr::width(suffix); + if suffix_width >= width { + return fit_text(suffix, width); + } + let base = heading.strip_suffix(suffix).unwrap_or(heading.as_str()); + format!("{}{}", fit_text(base, width - suffix_width), suffix) +} + +fn even_row_heights(total_height: u16, rows: usize) -> Vec { + let rows_u16 = rows as u16; + let base = total_height / rows_u16; + let remainder = total_height % rows_u16; + (0..rows_u16) + .map(|row| base + u16::from(row < remainder)) + .collect() +} + +fn grid_cell(area: Rect, columns: usize, column: usize, y: u16, height: u16) -> Rect { + let columns_u16 = columns as u16; + let base = area.width / columns_u16; + let remainder = area.width % columns_u16; + let column_u16 = column as u16; + let x_offset = column_u16 + .saturating_mul(base) + .saturating_add(column_u16.min(remainder)); + Rect { + x: area.x.saturating_add(x_offset), + y, + width: base + u16::from(column_u16 < remainder), + height, + } +} + +fn draw_provider_card( + f: &mut Frame, + area: Rect, + card: &ProviderCard, + mode: CardMode, + cpu_grad: &[ratatui::style::Color; 101], + theme: &Theme, +) { + if area.width == 0 || area.height == 0 { + return; + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let stale = card.is_stale(now); + let heading_color = if stale { + theme.inactive_fg + } else { + theme.title + }; + let heading = match mode { + CardMode::Detailed => card.heading(), + CardMode::Compact => fit_heading(card, usize::from(area.width.saturating_sub(1))), + }; + let mut lines = vec![Line::from(Span::styled( + format!(" {heading}"), + Style::default() + .fg(heading_color) + .add_modifier(Modifier::BOLD), + ))]; + + if card.unavailable { + lines.push(Line::from(Span::styled( + format!(" {}", t("quota.unavailable")), + Style::default().fg(theme.inactive_fg), + ))); + } else if card.windows.is_empty() { + lines.push(Line::from(Span::styled( + format!(" — {}", t("quota.no_data")), + Style::default().fg(theme.inactive_fg), + ))); + } else { + match mode { + CardMode::Detailed => { + for window in &card.windows { + let reset = (!stale) + .then(|| window.resets_at.map(format_reset_time)) + .flatten() + .unwrap_or_default(); + lines.push(detailed_window_line( + window, area.width, &reset, cpu_grad, theme, + )); + } + } + CardMode::Compact => { + append_compact_windows(&mut lines, card, area, cpu_grad, theme); + } + } + } + + f.render_widget(Paragraph::new(lines), area); +} + +fn detailed_window_line( + window: &RateLimitWindow, + width: u16, + reset: &str, + cpu_grad: &[ratatui::style::Color; 101], + theme: &Theme, +) -> Line<'static> { + let width = usize::from(width); + let raw_reset_width = if reset.is_empty() { + 0 + } else { + UnicodeWidthStr::width(reset) + 1 + }; + let reset_width = if 1 + 4 + 1 + 2 + 5 + raw_reset_width <= width { + raw_reset_width + } else { + 0 + }; + let maximum_label_width = width + .saturating_sub(1 + 1 + 2 + 5 + reset_width) + .clamp(4, 24); + let label_width = UnicodeWidthStr::width(window.label.as_str()) + .clamp(4, 24) + .min(maximum_label_width); + let label = pad_to_width(&fit_text(&window.label, label_width), label_width); + let bar_width = width + .saturating_sub(1 + label_width + 1 + 5 + reset_width) + .clamp(2, 10); + let remaining = (100.0 - window.used_pct).clamp(0.0, 100.0); + let mut spans = vec![Span::styled( + format!(" {label} "), + Style::default().fg(theme.graph_text), + )]; + spans.extend(remaining_bar( + remaining, + bar_width, + cpu_grad, + theme.meter_bg, + )); + spans.push(Span::styled( + format!(" {:>3.0}%", remaining), + Style::default().fg(grad_at(cpu_grad, window.used_pct)), + )); + if reset_width > 0 { + spans.push(Span::styled( + format!(" {reset}"), + Style::default().fg(theme.graph_text), )); - lines.push(Line::from(s)); - // Always reserve the row so both columns line up vertically; - // when there's nothing meaningful to show (stale source or the - // cached reset moment is past), render it blank. + } + Line::from(spans) +} + +fn append_compact_windows( + lines: &mut Vec>, + card: &ProviderCard, + area: Rect, + cpu_grad: &[ratatui::style::Color; 101], + theme: &Theme, +) { + let body_rows = usize::from(area.height.saturating_sub(1)); + if body_rows == 0 { + return; + } + let visible_windows = if card.windows.len() <= body_rows { + card.windows.len() + } else { + body_rows.saturating_sub(1) + }; + for window in card.windows.iter().take(visible_windows) { + let remaining = (100.0 - window.used_pct).clamp(0.0, 100.0); + let label_width = usize::from(area.width).saturating_sub(7).max(1); + let label = fit_text(&window.label, label_width); + lines.push(Line::from(vec![ + Span::styled(format!(" {label}"), Style::default().fg(theme.graph_text)), + Span::styled( + format!(" {:>3.0}%", remaining), + Style::default().fg(grad_at(cpu_grad, window.used_pct)), + ), + ])); + } + let hidden = card.windows.len().saturating_sub(visible_windows); + if hidden > 0 { lines.push(Line::from(Span::styled( - if reset.is_empty() { - String::new() - } else { - format!(" {}", reset) - }, + format!(" +{} {}", hidden, t("quota.more_windows")), Style::default().fg(theme.graph_text), ))); } +} +fn draw_provider_overflow(f: &mut Frame, area: Rect, hidden: usize, theme: &Theme) { + let mut lines = vec![Line::from(Span::styled( + format!(" +{hidden}"), + Style::default() + .fg(theme.title) + .add_modifier(Modifier::BOLD), + ))]; + if area.height > 1 { + lines.push(Line::from(Span::styled( + format!(" {}", t("quota.more_providers")), + Style::default().fg(theme.graph_text), + ))); + } f.render_widget(Paragraph::new(lines), area); } +fn fit_text(value: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(value) <= width { + return value.to_string(); + } + if width == 1 { + return "…".to_string(); + } + let target = width - 1; + let mut output = String::new(); + let mut used = 0; + for character in value.chars() { + let character_width = UnicodeWidthChar::width(character).unwrap_or(0); + if used + character_width > target { + break; + } + output.push(character); + used += character_width; + } + output.push('…'); + output +} + +fn pad_to_width(value: &str, width: usize) -> String { + let padding = width.saturating_sub(UnicodeWidthStr::width(value)); + format!("{value}{}", " ".repeat(padding)) +} + fn format_window_label(window_minutes: Option, fallback: String) -> String { let Some(minutes) = window_minutes else { return fallback; }; - if minutes == 0 { fallback } else if minutes % (24 * 60) == 0 { @@ -251,13 +817,7 @@ fn format_window_label(window_minutes: Option, fallback: String) -> String } } -/// Format a reset timestamp as a human countdown labeled "in X" so the -/// row reads as a time-until-reset. Returns an empty string when the -/// reset is already in the past — the actual next reset depends on the -/// window length which the caller doesn't track, and showing a "now" -/// sentinel was misleading on stale sources where the window had -/// already rolled over multiple times. Callers skip the row when this -/// returns empty. +/// Format a future reset timestamp as a localized relative countdown. pub(crate) fn format_reset_time(reset_ts: u64) -> String { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -273,12 +833,414 @@ pub(crate) fn format_reset_time(reset_ts: u64) -> String { } else if diff < 3600 { format!("{} {}{}", prefix, diff / 60, t("time.m")) } else if diff < 86400 { - let h = diff / 3600; - let m = (diff % 3600) / 60; - format!("{} {}{} {}{}", prefix, h, t("time.h"), m, t("time.m")) + let hours = diff / 3600; + let minutes = (diff % 3600) / 60; + format!( + "{} {}{} {}{}", + prefix, + hours, + t("time.h"), + minutes, + t("time.m") + ) } else { - let d = diff / 86400; - let h = (diff % 86400) / 3600; - format!("{} {}{} {}{}", prefix, d, t("time.d"), h, t("time.h")) + let days = diff / 86400; + let hours = (diff % 86400) / 3600; + format!( + "{} {}{} {}{}", + prefix, + days, + t("time.d"), + hours, + t("time.h") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } + + fn window( + id: &str, + label: &str, + used_pct: f64, + provenance: RateLimitProvenance, + ) -> RateLimitWindow { + RateLimitWindow::try_new( + id, + label, + used_pct, + Some(now_secs() + 7200), + None, + provenance, + ) + .unwrap() + } + + fn card(key: &str, windows: Vec) -> ProviderCard { + ProviderCard { + key: key.to_string(), + windows, + updated_at: Some(now_secs()), + unavailable: false, + force_stale: false, + } + } + + fn real_shape_cards() -> Vec { + let mut cards = vec![ + card( + "grok", + vec![window( + "primary", + "Primary", + 18.0, + RateLimitProvenance::CodexBar, + )], + ), + ProviderCard::unavailable("kimi".to_string()), + card( + "claude", + vec![ + window("primary", "5h", 28.0, RateLimitProvenance::CodexBar), + window("secondary", "7d", 6.0, RateLimitProvenance::CodexBar), + window( + "claude-weekly-scoped-fable", + "Fable only", + 0.0, + RateLimitProvenance::CodexBar, + ), + ], + ), + card( + "codex", + vec![ + window("secondary", "7d", 48.0, RateLimitProvenance::CodexBar), + window( + "codex-spark-weekly", + "Codex Spark Weekly", + 0.0, + RateLimitProvenance::CodexBar, + ), + ], + ), + ]; + cards.sort_by(|left, right| { + provider_sort_key(&left.key).cmp(&provider_sort_key(&right.key)) + }); + cards + } + + #[test] + fn missing_update_timestamp_is_rendered_as_stale() { + let mut unknown_age = card( + "grok", + vec![window( + "primary", + "Primary", + 18.0, + RateLimitProvenance::CodexBar, + )], + ); + unknown_age.updated_at = None; + assert!(unknown_age.is_stale(now_secs())); + } + + fn render_cards(cards: &[ProviderCard], width: u16, height: u16) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + let theme = Theme::default(); + let gradient = make_gradient(theme.cpu_grad.start, theme.cpu_grad.mid, theme.cpu_grad.end); + terminal + .draw(|frame| { + draw_provider_grid( + frame, + cards, + Rect::new(0, 0, width, height), + &gradient, + &theme, + ) + }) + .unwrap(); + format!("{}", terminal.backend()) + } + + #[test] + fn provider_order_is_stable_and_future_providers_are_lexical() { + let mut cards = [ + card("zeta", Vec::new()), + card("kimi", Vec::new()), + card("codex", Vec::new()), + card("alpha", Vec::new()), + card("claude", Vec::new()), + card("grok", Vec::new()), + ]; + cards.sort_by(|left, right| { + provider_sort_key(&left.key).cmp(&provider_sort_key(&right.key)) + }); + assert_eq!( + cards + .iter() + .map(|card| card.key.as_str()) + .collect::>(), + ["claude", "codex", "grok", "kimi", "alpha", "zeta"] + ); + } + + #[test] + fn headings_report_codexbar_and_mixed_provenance() { + let codexbar = card( + "grok", + vec![window( + "primary", + "Primary", + 20.0, + RateLimitProvenance::CodexBar, + )], + ); + let mixed = card( + "codex", + vec![ + window("primary", "5h", 20.0, RateLimitProvenance::Native), + window("spark", "Spark Weekly", 0.0, RateLimitProvenance::CodexBar), + ], + ); + assert_eq!(codexbar.heading(), "GROK·CB"); + assert_eq!(mixed.heading(), "CODEX·MIX"); + assert_eq!( + ProviderCard::unavailable("kimi".into()).heading(), + "KIMI·CB" + ); + } + + #[test] + fn four_real_shape_providers_render_at_supported_sizes() { + let cards = real_shape_cards(); + for (width, height) in [(58, 2), (58, 15), (18, 5), (98, 6), (98, 37), (158, 45)] { + let text = render_cards(&cards, width, height); + for heading in ["CLAUDE·CB", "CODEX·CB", "GROK·CB", "KIMI·CB"] { + assert!( + text.contains(heading), + "{width}x{height} missing {heading}\n{text}" + ); + } + let unavailable = if width == 18 { + "unavail" + } else { + "unavailable" + }; + assert!(text.contains(unavailable), "{width}x{height}\n{text}"); + } + } + + #[test] + fn hundred_by_eighteen_default_quota_keeps_value_and_exact_overflow() { + // At 100x18 with all five desktop mid panels enabled, Quota receives a + // 20x6 outer cell: 18x3 remains after borders and the totals row. + let cards = real_shape_cards(); + let mut app = App::new_with_config( + Theme::default(), + &[], + crate::config::PanelVisibility::default(), + ); + crate::demo::populate_demo(&mut app); + app.rate_limits = cards + .iter() + .map(|card| RateLimitInfo { + source: card.key.clone(), + updated_at: card.updated_at, + windows: card.windows.clone(), + ..RateLimitInfo::default() + }) + .collect(); + let backend = TestBackend::new(100, 18); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| super::super::draw(frame, &app)) + .unwrap(); + let text = format!("{}", terminal.backend()); + assert!(text.contains("CLAUDE·CB"), "{text}"); + assert!(text.contains("72%"), "{text}"); + assert!(text.contains("+3p +5w"), "{text}"); + + let two_row_text = render_cards(&cards, 18, 2); + assert!(two_row_text.contains("72% +3p +5w"), "{two_row_text}"); + } + + #[test] + fn compact_heading_truncation_preserves_provenance_suffix() { + let text = render_cards( + &[card( + "very-long-provider", + vec![window( + "primary", + "Primary", + 25.0, + RateLimitProvenance::CodexBar, + )], + )], + 12, + 2, + ); + assert!(text.contains("·CB"), "{text}"); + } + + #[test] + fn normal_wide_quota_panel_shows_every_window() { + let text = render_cards(&real_shape_cards(), 158, 45); + for label in ["5h", "7d", "Fable only", "Codex Spark Weekly", "Primary"] { + assert!(text.contains(label), "missing {label}\n{text}"); + } + assert!(!text.contains("+1 windows"), "{text}"); + } + + #[test] + fn stale_detailed_card_suppresses_reset_countdown() { + let mut stale = card( + "claude", + vec![window("primary", "5h", 28.0, RateLimitProvenance::Native)], + ); + stale.updated_at = Some(now_secs() - STALE_SECS - 1); + let stale_text = render_cards(&[stale], 30, 5); + assert!(!stale_text.contains("in "), "{stale_text}"); + + let fresh_text = render_cards( + &[card( + "claude", + vec![window("primary", "5h", 28.0, RateLimitProvenance::Native)], + )], + 30, + 5, + ); + assert!(fresh_text.contains("in "), "{fresh_text}"); + } + + #[test] + fn retained_transport_failure_stales_only_codexbar_backed_cards() { + let mut cards = vec![ + card( + "claude", + vec![window( + "primary", + "Native Primary", + 28.0, + RateLimitProvenance::Native, + )], + ), + card( + "grok", + vec![window( + "primary", + "CodexBar Primary", + 18.0, + RateLimitProvenance::CodexBar, + )], + ), + card( + "codex", + vec![ + window( + "primary", + "Native Primary", + 48.0, + RateLimitProvenance::Native, + ), + window("spark", "Spark Weekly", 0.0, RateLimitProvenance::CodexBar), + ], + ), + ]; + apply_codexbar_transport_failure(&mut cards, true); + let now = now_secs(); + assert!(!cards[0].is_stale(now)); + assert!(cards[1].is_stale(now)); + assert!(cards[2].is_stale(now)); + + let native_text = render_cards(&cards[..1], 36, 3); + assert!(native_text.contains("in "), "{native_text}"); + let codexbar_text = render_cards(&cards[1..2], 36, 3); + assert!(!codexbar_text.contains("in "), "{codexbar_text}"); + } + + #[test] + fn compact_cards_report_window_overflow() { + let windows = (0..5) + .map(|index| { + window( + &format!("window-{index}"), + &format!("Window {index}"), + index as f64, + RateLimitProvenance::CodexBar, + ) + }) + .collect(); + let text = render_cards(&[card("claude", windows)], 12, 3); + assert!(text.contains("+4 windows"), "{text}"); + } + + #[test] + fn compact_grid_reports_provider_overflow() { + let cards = (0..10) + .map(|index| card(&format!("provider-{index}"), Vec::new())) + .collect::>(); + let text = render_cards(&cards, 24, 4); + assert!(text.contains("+7"), "{text}"); + assert!(text.contains("providers"), "{text}"); + } + + #[test] + fn panel_preserves_total_and_rate_summary() { + let mut app = App::new_with_config( + Theme::default(), + &[], + crate::config::PanelVisibility::default(), + ); + app.token_rates.extend([100.0, 200.0]); + let backend = TestBackend::new(60, 6); + let mut terminal = Terminal::new(backend).unwrap(); + let theme = Theme::default(); + terminal + .draw(|frame| draw_quota_panel(frame, &app, Rect::new(0, 0, 60, 6), &theme)) + .unwrap(); + let text = format!("{}", terminal.backend()); + assert!(text.contains("total 0 300/min"), "{text}"); + } + + #[test] + fn legacy_slots_remain_visible_during_model_migration() { + let rate_limit = RateLimitInfo { + source: "claude".into(), + five_hour_pct: Some(25.0), + five_hour_window_minutes: Some(300), + seven_day_pct: Some(50.0), + seven_day_window_minutes: Some(10_080), + ..RateLimitInfo::default() + }; + let windows = effective_windows(&rate_limit); + assert_eq!(windows.len(), 2); + assert_eq!(windows[0].label, "5h"); + assert_eq!(windows[1].label, "7d"); + assert!(windows + .iter() + .all(|window| window.provenance == RateLimitProvenance::Native)); + } + + #[test] + fn text_fitting_is_unicode_width_safe() { + assert_eq!(fit_text("Codex Spark Weekly", 8), "Codex S…"); + assert_eq!( + UnicodeWidthStr::width(fit_text("日本語 provider", 8).as_str()), + 8 + ); + assert_eq!(pad_to_width("5h", 4), "5h "); } } diff --git a/src/ui/sessions.rs b/src/ui/sessions.rs index 1a911eb..e8652ef 100644 --- a/src/ui/sessions.rs +++ b/src/ui/sessions.rs @@ -133,31 +133,27 @@ pub(crate) fn draw_sessions_panel_active( let selected = i == app.selected; let marker = if selected { "►" } else { " " }; - let (agent_label, agent_color) = match session.agent_cli { - "claude" => ("*CC", Color::Rgb(217, 119, 87)), // #D97757 terracotta - "codex" => (">CD", Color::Rgb(122, 157, 255)), // #7A9DFF periwinkle - "opencode" => ("#OC", Color::Rgb(74, 222, 128)), // #4ADE80 emerald - other => { - let fallback: String = other.chars().take(3).collect::().to_uppercase(); - ( - Box::leak(fallback.into_boxed_str()) as &str, - theme.inactive_fg, - ) - } - }; + let (agent_label, agent_color) = agent_badge(session.agent_cli, theme); let (status_icon_str, status_color) = match &session.status { crate::model::SessionStatus::Thinking => (t("sess.think"), theme.proc_misc), crate::model::SessionStatus::Executing => (t("sess.exec"), theme.hi_fg), + crate::model::SessionStatus::Working => (t("sess.work"), theme.proc_misc), crate::model::SessionStatus::Waiting => (t("sess.wait"), grad_at(&proc_grad, 50.0)), + crate::model::SessionStatus::Idle => (t("sess.idle"), theme.inactive_fg), crate::model::SessionStatus::Unknown => (t("sess.unknown"), theme.inactive_fg), crate::model::SessionStatus::RateLimited => (t("sess.rate"), theme.status_fg), + crate::model::SessionStatus::Error => (t("sess.error"), grad_at(&proc_grad, 100.0)), crate::model::SessionStatus::Done => (t("sess.done"), theme.inactive_fg), }; let is_1m = session.context_window >= 1_000_000 || session.model.contains("[1m]"); let model_short = shorten_model(&session.model, is_1m); - let ctx_color = grad_at(&proc_grad, session.context_percent); + let ctx_color = if session.context_window == 0 { + theme.inactive_fg + } else { + grad_at(&proc_grad, session.context_percent) + }; let is_done = matches!(session.status, crate::model::SessionStatus::Done); let row_style = if selected { @@ -171,11 +167,7 @@ pub(crate) fn draw_sessions_panel_active( Style::default() }; - let sid_short = if session.session_id.len() >= 8 { - &session.session_id[..8] - } else { - &session.session_id - }; + let sid_short: String = session.session_id.chars().take(8).collect(); let summary_col = app.session_summary(session); @@ -195,7 +187,7 @@ pub(crate) fn draw_sessions_panel_active( ))); if show_session_id { cells.push(Cell::from(Span::styled( - truncate_str(sid_short, session_w as usize), + truncate_str(&sid_short, session_w as usize), Style::default().fg(theme.session_id), ))); } @@ -226,7 +218,7 @@ pub(crate) fn draw_sessions_panel_active( ))); } cells.push(Cell::from(Span::styled( - format!("{:.0}%", session.context_percent), + context_percent_label(session), Style::default().fg(ctx_color), ))); if show_tokens { @@ -267,11 +259,7 @@ pub(crate) fn draw_sessions_panel_active( let task_cells: Vec = (0..total_cols) .map(|j| { if j == summary_idx { - let task_text = session - .current_tasks - .last() - .map(|s| s.as_str()) - .unwrap_or(""); + let task_text = session.display_task().unwrap_or(""); Cell::from(Span::styled( task_row_text(task_text, w.saturating_sub(24) as usize), Style::default().fg(theme.graph_text), @@ -557,7 +545,7 @@ pub(crate) fn draw_sessions_panel_active( // Always show SESSION header (task) at top, then children/subagents/timeline/file_audit below let session_header_h: u16 = { - let mut h = 1u16; // SESSION title + let mut h = 2u16; // SESSION title + status evidence if !session.initial_prompt.is_empty() { h += 1; } @@ -584,11 +572,7 @@ pub(crate) fn draw_sessions_panel_active( // SESSION header — always rendered { let mut lines = Vec::new(); - let sid_short = if session.session_id.len() >= 8 { - &session.session_id[..8] - } else { - &session.session_id - }; + let sid_short: String = session.session_id.chars().take(8).collect(); let session_ref = if header_area.width <= 80 { format!("►{} · {}", sid_short, session.project_name) } else { @@ -603,6 +587,13 @@ pub(crate) fn draw_sessions_panel_active( .fg(theme.title) .add_modifier(Modifier::BOLD), ))); + lines.push(Line::from(Span::styled( + truncate_str( + &format!(" {}", status_evidence_text(session)), + header_area.width as usize, + ), + Style::default().fg(theme.graph_text), + ))); if !session.initial_prompt.is_empty() { let max_w = (header_area.width as usize).saturating_sub(9); lines.push(Line::from(vec![ @@ -988,6 +979,112 @@ fn task_row_text(task_text: &str, max_width: usize) -> String { truncate_str(&format!("└─ {task_text}"), max_width) } +fn status_evidence_text(session: &AgentSession) -> String { + use crate::model::{SessionStatus, MAX_VISIBLE_STATUS_OBSERVATIONS}; + + let evidence = &session.status_evidence; + let sample_start = evidence + .observations + .len() + .saturating_sub(MAX_VISIBLE_STATUS_OBSERVATIONS); + let samples = evidence.observations[sample_start..] + .iter() + .map(|sample| match sample.status { + SessionStatus::Thinking => "Think", + SessionStatus::Executing => "Exec", + SessionStatus::Working => "Work", + SessionStatus::Waiting => "Wait", + SessionStatus::Idle => "Idle", + SessionStatus::Unknown => "Unknown", + SessionStatus::RateLimited => "Rate", + SessionStatus::Error => "Error", + SessionStatus::Done => "Done", + }) + .collect::>() + .join(" "); + let samples = if samples.is_empty() { + "—".to_string() + } else { + samples + }; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let age = if evidence.status_since_ms == 0 { + String::new() + } else { + format!( + " · {} {}", + t("detail.since").as_str(), + fmt_compact_age(now_ms.saturating_sub(evidence.status_since_ms)) + ) + }; + let observed = if evidence.observed_at_ms == 0 { + "—".to_string() + } else { + format!( + "{} ago", + fmt_compact_age(now_ms.saturating_sub(evidence.observed_at_ms)) + ) + }; + let generation = if evidence.connection_generation == 0 { + "—".to_string() + } else { + evidence.connection_generation.to_string() + }; + + format!( + "{} {} · {} · {} {} · {} {} · [{}] · {} {}{}", + t("detail.evidence").as_str(), + evidence.authority.as_str(), + evidence.reason.as_str(), + t("detail.observed").as_str(), + observed, + t("detail.generation").as_str(), + generation, + samples, + evidence.consecutive_matching, + t("detail.matching").as_str(), + age, + ) +} + +fn fmt_compact_age(age_ms: u64) -> String { + let seconds = age_ms / 1_000; + if seconds < 60 { + format!("{seconds}s") + } else if seconds < 3_600 { + format!("{}m", seconds / 60) + } else if seconds < 86_400 { + format!("{}h", seconds / 3_600) + } else { + format!("{}d", seconds / 86_400) + } +} + +/// Return the three-cell provider badge used by the session table. Unknown +/// provider IDs are truncated into an owned label instead of leaking one +/// allocation on every render. +fn agent_badge(agent_cli: &str, theme: &Theme) -> (String, Color) { + match agent_cli { + "claude" => ("*CC".into(), Color::Rgb(217, 119, 87)), // terracotta + "codex" => (">CD".into(), Color::Rgb(122, 157, 255)), // periwinkle + "opencode" => ("#OC".into(), Color::Rgb(74, 222, 128)), // emerald + "grok" => ("xGR".into(), theme.title), + "kimi" => ("☾KM".into(), Color::Rgb(167, 139, 250)), // violet + other => (truncate_str(&other.to_uppercase(), 3), theme.inactive_fg), + } +} + +fn context_percent_label(session: &AgentSession) -> String { + if session.context_window == 0 { + "—".into() + } else { + format!("{:.0}%", session.context_percent) + } +} + pub(crate) fn shorten_model(model: &str, is_1m: bool) -> String { // "claude-opus-4-6" → "opus4.6", "claude-sonnet-4-6" → "sonnet4.6", "claude-haiku-4-5" → "haiku4.5" let s = model.strip_prefix("claude-").unwrap_or(model); @@ -1061,14 +1158,7 @@ fn draw_timeline( scroll: usize, ) { let tool_calls = &session.tool_calls; - let is_thinking = session.thinking_since_ms > 0 - && matches!( - session.status, - crate::model::SessionStatus::Thinking - | crate::model::SessionStatus::Executing - | crate::model::SessionStatus::Waiting - | crate::model::SessionStatus::Unknown - ); + let is_thinking = timeline_has_live_thinking(session); if tool_calls.is_empty() && !is_thinking { return; } @@ -1082,18 +1172,17 @@ fn draw_timeline( .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); + let has_live_tool = timeline_has_live_tool(session); let live_duration = |tc: &crate::model::ToolCall| -> u64 { if tc.duration_ms > 0 { tc.duration_ms - } else if session.pending_since_ms > 0 { + } else if has_live_tool { now_ms.saturating_sub(session.pending_since_ms) } else { 0 } }; - let is_pending = |tc: &crate::model::ToolCall| -> bool { - tc.duration_ms == 0 && session.pending_since_ms > 0 - }; + let is_pending = |tc: &crate::model::ToolCall| -> bool { tc.duration_ms == 0 && has_live_tool }; let thinking_duration = if is_thinking { now_ms.saturating_sub(session.thinking_since_ms) } else { @@ -1247,6 +1336,18 @@ fn draw_timeline( f.render_widget(Paragraph::new(lines), area); } +fn timeline_has_live_thinking(session: &AgentSession) -> bool { + session.thinking_since_ms > 0 + && matches!( + session.status, + crate::model::SessionStatus::Thinking | crate::model::SessionStatus::Executing + ) +} + +fn timeline_has_live_tool(session: &AgentSession) -> bool { + session.pending_since_ms > 0 && matches!(session.status, crate::model::SessionStatus::Executing) +} + #[cfg(test)] mod tests { use super::*; @@ -1254,6 +1355,29 @@ mod tests { use crate::model::SessionStatus; use ratatui::backend::TestBackend; use ratatui::Terminal; + use unicode_width::UnicodeWidthStr; + + #[test] + fn grok_and_kimi_badges_are_three_cells_wide() { + let theme = Theme::default(); + let (grok, _) = agent_badge("grok", &theme); + let (kimi, _) = agent_badge("kimi", &theme); + + assert_eq!(grok, "xGR"); + assert_eq!(kimi, "☾KM"); + assert_eq!(UnicodeWidthStr::width(grok.as_str()), 3); + assert_eq!(UnicodeWidthStr::width(kimi.as_str()), 3); + } + + #[test] + fn unavailable_context_ignores_stale_percentage() { + let mut session = test_session("grok-session", "project"); + session.agent_cli = "grok"; + session.context_window = 0; + session.context_percent = 91.0; + + assert_eq!(context_percent_label(&session), "—"); + } #[test] fn codex_exec_command_uses_bash_color() { @@ -1271,17 +1395,178 @@ mod tests { assert!(tool_label("exec_command").len() <= 6); } + #[test] + fn idle_and_waiting_rows_render_distinct_status_and_task_labels() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + let mut idle = test_session("idle1111", "idle-project"); + idle.status = SessionStatus::Idle; + idle.current_tasks = vec!["Edit stale-idle.rs".into()]; + let mut waiting = test_session("wait2222", "wait-project"); + waiting.awaiting_input = true; + waiting.current_tasks = vec!["Edit stale.rs".into()]; + app.sessions = vec![idle, waiting]; + + let backend = TestBackend::new(120, 16); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + draw_sessions_panel( + f, + &app, + Rect { + x: 0, + y: 0, + width: 120, + height: 16, + }, + &app.theme, + ) + }) + .unwrap(); + let text = format!("{}", terminal.backend()); + + assert!( + text.contains(&t("sess.idle")), + "idle status missing\n{text}" + ); + assert!( + text.contains(&t("sess.wait")), + "wait status missing\n{text}" + ); + assert!(text.contains("└─ idle"), "idle task missing\n{text}"); + assert!( + text.contains("└─ waiting for user inp"), + "actionable wait task missing\n{text}" + ); + assert!( + !text.contains("Edit stale-idle.rs"), + "stale idle task leaked\n{text}" + ); + assert!(!text.contains("Edit stale.rs"), "stale task leaked\n{text}"); + } + + #[test] + fn working_row_renders_generic_status_and_task_labels() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + let mut session = test_session("work1111", "work-project"); + session.status = SessionStatus::Working; + session.current_tasks = vec!["Edit stale-working.rs".into()]; + app.sessions = vec![session]; + + let backend = TestBackend::new(120, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + draw_sessions_panel( + f, + &app, + Rect { + x: 0, + y: 0, + width: 120, + height: 12, + }, + &app.theme, + ) + }) + .unwrap(); + let text = format!("{}", terminal.backend()); + + assert!( + text.contains(&t("sess.work")), + "work status missing\n{text}" + ); + assert!(text.contains("└─ working"), "work task missing\n{text}"); + assert!( + !text.contains("Edit stale-working.rs"), + "stale work task leaked\n{text}" + ); + } + + #[test] + fn timeline_animates_only_active_statuses() { + let mut session = test_session("timeline", "project"); + session.pending_since_ms = 10; + session.thinking_since_ms = 10; + + for status in [ + SessionStatus::Working, + SessionStatus::Waiting, + SessionStatus::Idle, + SessionStatus::Unknown, + SessionStatus::RateLimited, + SessionStatus::Error, + SessionStatus::Done, + ] { + session.status = status; + assert!(!timeline_has_live_thinking(&session)); + assert!(!timeline_has_live_tool(&session)); + } + + session.status = SessionStatus::Thinking; + assert!(timeline_has_live_thinking(&session)); + assert!(!timeline_has_live_tool(&session)); + + session.status = SessionStatus::Executing; + assert!(timeline_has_live_thinking(&session)); + assert!(timeline_has_live_tool(&session)); + } + + #[test] + fn status_evidence_detail_shows_the_latest_five_samples() { + use crate::model::{StatusAuthority, StatusObservation, StatusReason}; + + let mut session = test_session("evidence", "project"); + session.status = SessionStatus::Idle; + for observed_at_ms in 1..=7 { + session.status_evidence.observe(StatusObservation::new( + SessionStatus::Idle, + StatusAuthority::Provider, + StatusReason::ProviderIdle, + observed_at_ms, + 2, + )); + } + + let text = status_evidence_text(&session); + assert!(text.contains("EVIDENCE provider"), "{text}"); + assert!(text.contains("provider idle"), "{text}"); + assert!(text.contains("observed"), "{text}"); + assert!(text.contains("generation 2"), "{text}"); + assert!(text.contains("[Idle Idle Idle Idle Idle]"), "{text}"); + assert!(text.contains("7 matching"), "{text}"); + } + + #[test] + fn working_status_uses_generic_history_name() { + use crate::model::{StatusAuthority, StatusObservation, StatusReason}; + + let mut session = test_session("working", "project"); + session.status = SessionStatus::Working; + session.status_evidence.observe(StatusObservation::new( + SessionStatus::Working, + StatusAuthority::Heuristic, + StatusReason::HerdrWorkingUnrefined, + 1, + 0, + )); + + assert!(status_evidence_text(&session).contains("[Work]")); + } + #[test] fn codex_non_1m_context_window_does_not_show_1m_suffix() { let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); app.sessions.push(AgentSession { agent_cli: "codex", pid: 42, + action_process_incarnation: None, session_id: "codex-session".into(), cwd: "/tmp/project".into(), project_name: "project".into(), started_at: 0, status: SessionStatus::Waiting, + status_evidence: crate::model::StatusEvidence::default(), model: "gpt-5".into(), effort: String::new(), context_percent: 58.7, @@ -1309,6 +1594,7 @@ mod tests { chat_messages: Vec::new(), tool_calls: Vec::new(), pending_since_ms: 0, + awaiting_input: false, thinking_since_ms: 0, file_accesses: Vec::new(), config_root: String::new(), @@ -1429,11 +1715,13 @@ mod tests { AgentSession { agent_cli: "claude", pid: 42, + action_process_incarnation: None, session_id: session_id.into(), cwd: format!("/tmp/{project_name}"), project_name: project_name.into(), started_at: 0, status: SessionStatus::Waiting, + status_evidence: crate::model::StatusEvidence::default(), model: "claude-opus-4-6".into(), effort: String::new(), context_percent: 10.0, @@ -1461,6 +1749,7 @@ mod tests { chat_messages: Vec::new(), tool_calls: Vec::new(), pending_since_ms: 0, + awaiting_input: false, thinking_since_ms: 0, file_accesses: Vec::new(), config_root: "~/.claude".into(), diff --git a/tests/codex_compat.rs b/tests/codex_compat.rs new file mode 100644 index 0000000..03a204b --- /dev/null +++ b/tests/codex_compat.rs @@ -0,0 +1,254 @@ +#![cfg(unix)] + +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::io::Write; +use std::os::unix::ffi::OsStringExt; +use std::os::unix::fs::symlink; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::ExitStatusExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +const LEGACY_BINARY_ENV: &str = "ABTOP_MANAGED_CODEX_BINARY"; + +fn write_executable(directory: &Path, name: &str, source: &[u8]) -> PathBuf { + let path = directory.join(name); + fs::write(&path, source).expect("write native Codex test executable"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) + .expect("make native Codex test executable private"); + path +} + +fn compatibility_command(root: &Path, native_binary: Option<&OsStr>) -> Command { + let home = root.join("home"); + let codex_home = root.join("codex-home"); + let config_home = root.join("config"); + let cache_home = root.join("cache"); + for directory in [&home, &codex_home, &config_home, &cache_home] { + fs::create_dir(directory).expect("create isolated test directory"); + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)) + .expect("keep isolated test directory private"); + } + + let mut command = Command::new(env!("CARGO_BIN_EXE_abtop")); + command + .arg("codex") + .arg("--") + .env("HOME", home) + .env("CODEX_HOME", codex_home) + .env("XDG_CONFIG_HOME", config_home) + .env("XDG_CACHE_HOME", cache_home) + .env_remove(LEGACY_BINARY_ENV); + if let Some(binary) = native_binary { + command.env(LEGACY_BINARY_ENV, binary); + } + command +} + +fn run_with_input(mut command: Command, input: &[u8]) -> Output { + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("launch abtop compatibility trampoline"); + child + .stdin + .take() + .expect("open child stdin") + .write_all(input) + .expect("write child stdin"); + child + .wait_with_output() + .expect("wait for compatibility trampoline") +} + +#[test] +fn preserves_exact_os_arguments_without_shell_interpretation() { + let temp = tempfile::tempdir().expect("create private temporary directory"); + let native = write_executable( + temp.path(), + "native-codex", + b"#!/bin/sh\nfor argument do\n printf '%s\\0' \"$argument\"\ndone\n", + ); + let arguments = vec![ + OsString::from("--yolo"), + OsString::from(""), + OsString::from("space separated"), + OsString::from("*?[literal]"), + OsString::from("single'quote"), + OsString::from("double\"quote"), + OsString::from_vec(b"non-utf8-\xff-value".to_vec()), + ]; + + let mut command = compatibility_command(temp.path(), Some(native.as_os_str())); + command.args(&arguments); + let output = command.output().expect("run compatibility trampoline"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let expected = arguments + .iter() + .flat_map(|argument| { + let mut bytes = argument.clone().into_vec(); + bytes.push(0); + bytes + }) + .collect::>(); + assert_eq!(output.stdout, expected); + assert!(output.stderr.is_empty()); +} + +#[test] +fn preserves_argv_zero_for_a_multicall_codex_symlink() { + let temp = tempfile::tempdir().expect("create private temporary directory"); + let multicall = write_executable( + temp.path(), + "multicall", + b"#!/bin/sh\ncase \"$0\" in\n */codex) printf 'codex applet' ;;\n *) printf 'wrong applet: %s' \"$0\" >&2; exit 43 ;;\nesac\n", + ); + let codex = temp.path().join("codex"); + symlink(&multicall, &codex).expect("create argv0-sensitive Codex shim"); + + let output = compatibility_command(temp.path(), Some(codex.as_os_str())) + .output() + .expect("run compatibility trampoline through multicall shim"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output.stdout, b"codex applet"); + assert!(output.stderr.is_empty()); +} + +#[test] +fn inherits_standard_streams_byte_for_byte() { + let temp = tempfile::tempdir().expect("create private temporary directory"); + let native = write_executable( + temp.path(), + "native-codex", + b"#!/bin/sh\n/bin/cat\nprintf 'native stderr' >&2\n", + ); + let input = b"stdin with spaces, quotes '\" and binary: \x00\xff\n"; + + let command = compatibility_command(temp.path(), Some(native.as_os_str())); + let output = run_with_input(command, input); + + assert!(output.status.success()); + assert_eq!(output.stdout, input); + assert_eq!(output.stderr, b"native stderr"); +} + +#[test] +fn preserves_native_exit_code() { + let temp = tempfile::tempdir().expect("create private temporary directory"); + let native = write_executable(temp.path(), "native-codex", b"#!/bin/sh\nexit 37\n"); + + let status = compatibility_command(temp.path(), Some(native.as_os_str())) + .status() + .expect("run compatibility trampoline"); + + assert_eq!(status.code(), Some(37)); +} + +#[test] +fn preserves_native_signal_termination() { + let temp = tempfile::tempdir().expect("create private temporary directory"); + let native = write_executable( + temp.path(), + "native-codex", + b"#!/bin/sh\nkill -TERM \"$$\"\nexit 99\n", + ); + + let status = compatibility_command(temp.path(), Some(native.as_os_str())) + .status() + .expect("run compatibility trampoline"); + + assert_eq!(status.signal(), Some(libc::SIGTERM)); + assert_eq!(status.code(), None); +} + +#[test] +fn removes_the_private_binary_variable_from_the_native_environment() { + let temp = tempfile::tempdir().expect("create private temporary directory"); + let native = write_executable( + temp.path(), + "native-codex", + b"#!/bin/sh\nif [ \"${ABTOP_MANAGED_CODEX_BINARY+x}\" = x ]; then\n printf 'private compatibility variable leaked' >&2\n exit 71\nfi\n", + ); + + let output = compatibility_command(temp.path(), Some(native.as_os_str())) + .output() + .expect("run compatibility trampoline"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); +} + +#[test] +fn creates_no_runtime_or_plugin_state() { + let temp = tempfile::tempdir().expect("create private temporary directory"); + let native = write_executable(temp.path(), "native-codex", b"#!/bin/sh\nexit 0\n"); + + let output = compatibility_command(temp.path(), Some(native.as_os_str())) + .output() + .expect("run compatibility trampoline"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + for directory in ["home", "codex-home", "config", "cache"] { + let path = temp.path().join(directory); + let mut entries = fs::read_dir(&path).expect("inspect isolated state root"); + assert!( + entries.next().is_none(), + "compatibility trampoline wrote state below {}", + path.display() + ); + } +} + +#[test] +fn rejects_a_missing_captured_binary() { + let temp = tempfile::tempdir().expect("create private temporary directory"); + + let output = compatibility_command(temp.path(), None) + .output() + .expect("run compatibility trampoline"); + + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!( + String::from_utf8_lossy(&output.stderr).contains(LEGACY_BINARY_ENV), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn rejects_a_relative_captured_binary() { + let temp = tempfile::tempdir().expect("create private temporary directory"); + + let output = compatibility_command(temp.path(), Some(OsStr::new("relative/codex"))) + .output() + .expect("run compatibility trampoline"); + + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains(LEGACY_BINARY_ENV), "stderr: {stderr}"); + assert!(stderr.contains("absolute"), "stderr: {stderr}"); +}