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..234f9ec 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,42 @@ 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 +├── 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 # Claude/Codex account quota 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 +│ ├── codex.rs # Hook lifecycle evidence + local rollout metrics │ ├── 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 +64,31 @@ 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 remains intentionally limited to Claude and Codex +unless another provider exposes a reliable local account-level source. -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). No provider API calls or +authentication are used. The explicit setup commands write only their documented +Claude or Codex integration, and the installed Codex helper writes only its private, +bounded lifecycle state. ### 1. Claude Code session discovery: process + config-root mapping @@ -147,13 +151,256 @@ 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 `codex-cli 0.146.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 exactly `codex-cli` 0.146.0, exact `hooks stable true` and + `plugins stable true` feature rows, and exactly the supported 11 uppercase + `ManagedHooksRequirements.properties` in generated + `v2/ConfigRequirementsReadResponse.json`. + 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`. A changed executable path or byte content changes the helper + identity. Rerun setup after every binary update or replacement regardless; require a + fresh review whenever Codex presents the changed 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`; Codex 0.146.0 does not advertise it as a supported +plugin event. 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 +the launcher exits 0 without stdout or stderr so a failed monitor cannot deny a Codex +action after the bounded hook returns. + +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 terminal jump even when a lifecycle edge is otherwise useful. + +Codex 0.146.0 exposes 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`, so every live Codex `Thinking`, `Executing`, +and `Idle` candidate projects as non-actionable `Unknown / Unavailable`. Retain the raw +bounded lifecycle candidate only for audit and independent exit correlation. + +Exit proof requires exact installation, process, session, and event correlation. Require +the matched process-owned root rollout to report exact `cli_version = "0.146.0"`, and +require every discovered descendant rollout to report the same version. Missing, +different, 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. +- 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. +- Any otherwise exact lifecycle shape suggesting root model work, child model work, or + turn completion → `Unknown / Unavailable`, normally with + `HookIntegrationUnverified`, because effective live hook coverage is unattested. +- 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 internal candidate. The public live row remains `Unknown` +in both cases. + +Codex 0.146.0 does 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`. + +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, but 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 +414,80 @@ 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`. 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. -### 5. Subagents: `~/.claude/projects/{path}/{sessionId}/subagents/` +### 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. +- Grok contributes no quota data. The quota panel remains Claude + Codex only. + +### 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. +- Kimi contributes no quota data. The quota panel remains Claude + Codex only. + +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 +495,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 +526,171 @@ 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 +◌ 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 +0.146.0 hook contract cannot prove whether permission is still pending. Exact active +subagent model work remains an internal correlation candidate only; effective live hook +coverage is unattested, so the public row remains `Unknown`. + +After ownership/liveness and protocol validation, exact status precedence is `Waiting` > +`RateLimited` > `Error` > `Executing` > `Thinking` > `Idle`. 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`. 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 0.146.0 live rows never reach this + status because effective hook coverage is unattested. - 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 hook generation becomes `Unknown`. +- 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. +- Codex 0.146.0 approvals and questions are deliberately `Unknown`, not `Waiting`, because + exact prompt-display/resolution lifecycle is unavailable. +- Codex 0.146.0 exposes no thread/PID/generation-bound attestation of the effective hook + engine. Base hook trust and enablement are installation diagnostics only. Root and + child open tools remain `Unknown` because a selectively missing `PermissionRequest` + is indistinguishable from execution. All live `Thinking`, `Executing`, and `Idle` + candidates remain non-actionable `Unknown / Unavailable`; only an independently exact + supported process/session `Live → Gone` transition can promote to non-actionable + heuristic `Done` for 30 seconds. +- 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 + live hook evidence is `Unavailable`, while its exact transition-bound `Done` is capped + at `Heuristic`. +- 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 to an actionable terminal after fresh process 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 +705,30 @@ 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 +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 +762,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 @@ -366,9 +784,11 @@ Each adapter returns a three-way `JumpAttempt`: 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 `. +1. **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. 2. **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`. @@ -378,23 +798,79 @@ Order (most specific first), mutually exclusive by controlling tty: 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. +Parsing, registry, cmux command planning, socket failure, and AppleScript fallback logic +are unit-tested; the thin live `ps`/`osascript`/`tmux` 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. 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 network**: collectors never send provider session data anywhere and do not require + API keys or authentication. All collector reads are local. +- **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 +878,55 @@ 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 only exact `codex-cli` 0.146.0 and must + validate exact `hooks stable true` / `plugins stable true` feature rows plus exactly + the 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. Codex 0.146.0 cannot attest those effective layers, so every live lifecycle + candidate remains non-actionable `Unknown`; only independently exact transition-bound + `Done` may promote. 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. +- **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. 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**: live hook evidence is `Unknown / Unavailable`; local rollouts + remain useful for tokens, context, rate limits, metadata, and exact edge correlation, + but never promote `Thinking`, `Executing`, or `Idle`. Only an independently exact + supported process/session `Live → Gone` transition may produce non-actionable + `Done / Heuristic` for 30 seconds. Approval/question candidates and root/child open + tools remain `Unknown`, not `Waiting` or stale `Executing`. +- **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..2413f11 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,345 @@ 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 or provider authentication. +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) | Native `codex-cli` 0.146.0 with the stable, enabled `hooks` and `plugins` features and the exact supported 11-event generated hook schema | +| 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. -## Usage +`abtop --setup` is Claude-only. It does not inspect or change Codex configuration. + +### Codex lifecycle audit and exit correlation + +The integration records content-free lifecycle boundaries for auditing and exact +process-exit correlation. Codex 0.146.0 cannot attest the effective hook engine of a +live thread, so setup does not make live Codex `Thinking`, `Executing`, `Waiting`, or +`Idle` rows available; those rows remain non-actionable `Unknown / Unavailable`. + +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 # 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 +abtop --setup-codex ``` -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 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 +codex +codex resume +codex fork +codex --yolo +``` + +Setup supports exactly `codex-cli` 0.146.0. 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` are exactly the declared 11-event set. Older, +newer, or structurally different releases fail closed until that contract is audited; +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. + +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 +`Unknown` until the new plugin copy is installed, reviewed, and loaded by a fresh Codex +session. + +## 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. + +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. Codex 0.146.0 exposes no +thread/PID/generation-bound proof of the effective hook engine, so a healthy setup or +status result is installation readiness only: every live Codex `Think`, `Exec`, and +`Idle` candidate remains non-actionable `Unknown`. Only the independently validated +process-exit transition described below can promote to 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 exactly `codex-cli` 0.146.0, +exact `hooks stable true` and `plugins stable true` feature rows, and an exact match for +the supported 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 exact 11-event set validated for Codex +0.146.0: + +| 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. Monitoring therefore fails open for Codex itself: an +unavailable or outdated abtop reduces monitoring confidence but cannot deny or alter the +agent action after the bounded hook returns. + +Setup does not add `PostToolUseFailure`, because Codex 0.146.0 does not advertise that +event for plugins. It 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`. + +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 to an actionable terminal, 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 Claude and Codex quota windows and reset times. Other providers do not expose a reliable local quota source. | +| **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 terminal running an actionable selected agent. abtop supports +cmux and tmux where available, plus iTerm2 on macOS. Unsupported environments do +nothing. iTerm2 can request macOS Automation permission on first use. Before jumping, +abtop freshly revalidates the provider process and its exact process incarnation; +`Unknown`, `Done`, and otherwise non-actionable rows cannot be targeted. ```bash tmux new -s work @@ -75,24 +365,277 @@ tmux new -s work ## 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 | ⚠ exact exit only | ⚠ limited | ✅ | ⚠ mixed evidence | +| Current task | ✅ | ❌ live unavailable | ⚠ generic | ✅ | ✅ | +| Account quota | ✅ | ✅ | ❌ | ❌ | ❌ | +| 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. | +| `◌ 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` > `Idle`. 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 or process 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. `Unknown` and `Done` rows, `Unavailable` evidence, a +missing exact process identity, or a failed fresh PID/provider revalidation disables +both kill and terminal jump. Kimi is stricter: 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`. 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 column: if several Claude roots contain quota files, +the first discovered value is used, with `~/.claude` checked before the environment and +additional roots. + +### 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. + +Codex 0.146.0 exposes 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. abtop +retains the bounded lifecycle candidates for audit and exit correlation, but production +live Codex rows are always non-actionable `Unknown / Unavailable`; they never promote to +`Thinking`, `Executing`, or `Idle`. + +The independent exit proof still requires exact process-incarnation, session, +configuration, and event correlation. The matched process-owned root rollout must report +exact `cli_version = "0.146.0"`, and every discovered descendant rollout must report that +same version. Missing, different, 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 | +| Any otherwise complete lifecycle shape that would suggest root model work, child model work, or turn completion | `Unknown / Unavailable`; effective live hook coverage is unattested | +| 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` | + +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 remains an internal lifecycle +candidate only. The public live row stays `Unknown` in every case. Neither +`SessionStart` nor `Stop` alone proves `Idle`. + +Codex 0.146.0 does 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 +affected generations to `Unknown` instead of preserving stale work. 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`, and `HookIntegrationUnverified`. Hook evidence always has +`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 plugin remains +discoverable for rollout metrics but has `Unknown` live status. 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 abtop cannot bind them to the client PID and deliberately reports +`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. The account-quota panel remains limited to +Claude and Codex. ## 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 +663,89 @@ 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 and panel-visibility 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" + # 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 providers or +`claude_config_dirs = ["~/.claude-personal", "~/.claude-work-team"]` to add profile +roots. -| 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 to the selected actionable session terminal after fresh process 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,7 +754,7 @@ 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; @@ -191,9 +774,121 @@ 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 records to provider APIs. abtop starts no Codex relay or +daemon, attaches to no Codex daemon, and uses no provider API or transport credential. +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 `Unknown` +evidence. 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 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 Codex account quota windows and timestamps. +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/src/app.rs b/src/app.rs index c9de3cc..d181c48 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,14 @@ 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, 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; @@ -14,13 +17,78 @@ const MAX_SUMMARY_JOBS: usize = 3; /// Max summary attempts per session before giving up. const MAX_SUMMARY_RETRIES: u32 = 2; +/// 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,10 +158,14 @@ 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 (currently Claude and Codex only). pub 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, collector: MultiCollector, @@ -111,8 +183,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, @@ -179,6 +251,7 @@ impl App { token_rates: VecDeque::with_capacity(GRAPH_HISTORY_LEN), rate_limits: Vec::new(), prev_tokens: HashMap::new(), + status_evidence_ledger: HashMap::new(), rate_limit_counter: 5, collector, summaries, @@ -507,7 +580,16 @@ impl App { /// spawn subprocesses or consume the user's Claude quota. 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(); @@ -546,7 +628,8 @@ impl App { self.rate_limit_counter += 1; } - promote_waiting_to_rate_limited(&mut self.sessions, &self.rate_limits); + // Quota percentages are display-only. A lifecycle can become + // RateLimited only when the provider reports an active block. } /// Drain completed summary results and spawn retries. Does NOT recollect @@ -638,7 +721,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 +796,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 +874,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 +884,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) { @@ -792,13 +918,30 @@ 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. + /// 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 { if self.sessions.is_empty() { return JumpOutcome::NoOp; } - let target_pid = self.sessions[self.selected].pid; + let selected = &self.sessions[self.selected]; + 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; crate::jump::run_jump(target_pid) } @@ -840,6 +983,22 @@ 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::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 +1055,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 +1079,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 +1106,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,42 +1140,305 @@ 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)] @@ -1017,11 +1449,13 @@ mod tests { 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 +1481,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 +1490,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 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 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 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_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 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_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 +1736,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 +1753,363 @@ 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 unknown_ownership_cannot_jump_to_a_process() { + 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(), JumpOutcome::NoOp); + } + + #[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(), + 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..10c65c7 --- /dev/null +++ b/src/codex_hooks/ingest.rs @@ -0,0 +1,1239 @@ +//! 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, (store, process, integration)) = + parse_before_preflight(io::stdin().lock(), || { + ingress.reclaim_stale_artifacts_after_drain(observed_at_ms)?; + 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. +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(reader)?; + let verified = preflight()?; + Ok((parsed, verified)) +} + +fn parse_and_drain_hook_input(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?; + 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..25b61fc --- /dev/null +++ b/src/codex_hooks/plugin.rs @@ -0,0 +1,6225 @@ +//! 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 SUPPORTED_CODEX_VERSION: &str = "0.146.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_config_state_from_config(&paths, bundle.as_ref(), config) + .unwrap_or_else(|error| { + details.push(format!( + "Codex plugin installation inspection failed: {error}" + )); + CliState::default() + }), + 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 Codex 0.146.0, 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!( + "exec \"$PLUGIN_ROOT/scripts/abtop-codex-hook.sh\" --schema-revision {HOOK_SCHEMA_REVISION} --helper-digest {helper_digest}" + ); + 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}" + ); + 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 = 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, + } + } + + 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 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 Codex 0.146.0'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 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 { + 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, &bundle.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, bytes, executable) in [ + ( + version_root.join(".codex-plugin/plugin.json"), + bundle.plugin_manifest.as_slice(), + false, + ), + ( + version_root.join("hooks/hooks.json"), + bundle.hooks_manifest.as_slice(), + false, + ), + ( + version_root.join("scripts/abtop-codex-hook.sh"), + bundle.posix_launcher.as_slice(), + true, + ), + ( + version_root.join("scripts/abtop-codex-hook.cmd"), + bundle.windows_launcher.as_slice(), + false, + ), + ] { + if !private_regular_file_matches(&path, bytes, executable)? { + return Ok(false); + } + } + Ok(true) +} + +#[cfg(unix)] +fn cached_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 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(&bundle.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(&bundle.plugin_version); + let Some(version) = + open_managed_directory_at(&plugin, &bundle.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_matching_managed_file_at( + &manifest_directory, + "plugin.json", + &plugin_manifest_path, + &bundle.plugin_manifest, + false, + false, + )? + else { + return Ok(false); + }; + let Some(hooks_file) = open_matching_managed_file_at( + &hooks_directory, + "hooks.json", + &hooks_file_path, + &bundle.hooks_manifest, + false, + false, + )? + 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, + false, + )? + 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, + 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, + &bundle.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_file_at( + &rebound_manifest, + "plugin.json", + &plugin_manifest, + &plugin_manifest_path, + &bundle.plugin_manifest, + false, + false, + )?; + reopen_same_file_at( + &rebound_hooks, + "hooks.json", + &hooks_file, + &hooks_file_path, + &bundle.hooks_manifest, + false, + false, + )?; + reopen_same_file_at( + &rebound_scripts, + "abtop-codex-hook.sh", + &posix_launcher, + &posix_path, + &bundle.posix_launcher, + true, + false, + )?; + reopen_same_file_at( + &rebound_scripts, + "abtop-codex-hook.cmd", + &windows_launcher, + &windows_path, + &bundle.windows_launcher, + false, + 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_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 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) != (0, 146, 0) { + return Err(invalid_data(format!( + "Codex hook integration requires codex-cli {SUPPORTED_CODEX_VERSION}, but the selected executable reports {major}.{minor}.{patch}; every other release remains unsupported until its hook contract is audited" + ))); + } + Ok(()) +} + +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") + })?; + if version.is_empty() + || version + .bytes() + .any(|byte| !byte.is_ascii_digit() && byte != b'.') + { + return Err(invalid_data(format!( + "native Codex reported an unsupported semantic version `{version}`" + ))); + } + let components = version.split('.').collect::>(); + if components.len() != 3 + || components.iter().any(|component| { + component.is_empty() || component.len() > 1 && component.starts_with('0') + }) + { + return Err(invalid_data(format!( + "native Codex reported an unsupported semantic version `{version}`" + ))); + } + let parsed = components + .into_iter() + .map(|component| { + component.parse::().map_err(|_| { + invalid_data(format!( + "native Codex reported an unsupported semantic version `{version}`" + )) + }) + }) + .collect::>>()?; + Ok((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::>(); + if actual != expected { + return Err(invalid_data(format!( + "native Codex managed-hook event set is incompatible: expected {}, found {}", + expected.into_iter().collect::>().join(", "), + actual.into_iter().collect::>().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)); + assert!(handler["command"].as_str().unwrap().starts_with("exec ")); + } + assert!(hooks.get("PostToolUseFailure").is_none()); + } + + #[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_an_unaudited_codex_minor_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-future"); + executable_file(&codex, b"#!/bin/sh\nprintf 'codex-cli 0.147.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 codex-cli 0.146.0")); + 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-future"); + executable_file(&codex, b"#!/bin/sh\nprintf 'codex-cli 0.147.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 codex-cli 0.146.0")); + 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"})); + } + 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.146.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_only_exact_stable_semver_shape() { + 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) + ); + validate_supported_codex_release(b"codex-cli 0.146.0\n").unwrap(); + assert!(validate_supported_codex_release(b"codex-cli 0.146.1\n").is_err()); + assert!(validate_supported_codex_release(b"codex-cli 0.146.27\n").is_err()); + assert!(validate_supported_codex_release(b"codex-cli 0.145.9\n").is_err()); + assert!(validate_supported_codex_release(b"codex-cli 0.147.0\n").is_err()); + 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_exactly_the_supported_event_set() { + 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"}); + assert!(validate_hook_schema_bytes(&serde_json::to_vec(&extra).unwrap()).is_err()); + + 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 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..084ad5d --- /dev/null +++ b/src/codex_hooks/state.rs @@ -0,0 +1,4743 @@ +//! 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_STATE_FILES: usize = 256; +const MAX_FAULT_FILES: usize = 128; +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_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 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, + /// 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, + 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, + pub closed_subagents: BTreeSet, + pub open_questions: BTreeSet, + #[serde(default)] + pub question_opened_at_ms: BTreeMap, + pub closed_questions: BTreeSet, + #[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, + #[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() { + // Codex 0.146 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 { + // Codex 0.146 queues every SessionStart source and runs 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(), + open_child_tools: BTreeMap::new(), + child_tool_opened_at_ms: BTreeMap::new(), + closed_child_tools: BTreeMap::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(), + open_questions: BTreeSet::new(), + question_opened_at_ms: BTreeMap::new(), + closed_questions: BTreeSet::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_ingests: Vec::new(), + samples: Vec::new(), + } + } + + fn apply(&mut self, event: &HookEvent) { + 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.open_child_tools.clear(); + self.child_tool_opened_at_ms.clear(); + self.closed_child_tools.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.open_questions.clear(); + self.question_opened_at_ms.clear(); + self.closed_questions.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; + // 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.closed_child_tools.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); + self.closed_questions.insert(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); + self.closed_child_tools + .insert(tool_id.to_string(), agent.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); + self.closed_tools.insert(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.open_child_tools.clear(); + self.child_tool_opened_at_ms.clear(); + self.closed_child_tools.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.open_questions.clear(); + self.question_opened_at_ms.clear(); + self.closed_questions.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.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); + // 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.closed_child_tools.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.closed_tools.len() > MAX_OPEN_ITEMS + || self.closed_subagents.len() > MAX_OPEN_ITEMS + || self.closed_questions.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.closed_child_tools.len() > MAX_OPEN_ITEMS { + if let Some(key) = self.closed_child_tools.keys().next_back().cloned() { + self.closed_child_tools.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); + } + } + 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.closed_tools.len() > MAX_OPEN_ITEMS { + if let Some(key) = self.closed_tools.iter().next_back().cloned() { + self.closed_tools.remove(&key); + } + } + while self.closed_subagents.len() > MAX_OPEN_ITEMS { + if let Some(key) = self.closed_subagents.iter().next_back().cloned() { + self.closed_subagents.remove(&key); + } + } + while self.closed_questions.len() > MAX_OPEN_ITEMS { + if let Some(key) = self.closed_questions.iter().next_back().cloned() { + self.closed_questions.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); + } + } + } + } +} + +#[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 { + let mut scan = HookStateScan::default(); + let mut latest_failure_ms = 0_u64; + 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) { + // An in-flight atomic replacement still leaves the prior + // state readable. Count unexpected temp accumulation so it + // cannot be used to bypass the directory bound. + 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; + } + + let (faults, faults_truncated) = self.fault_dir.list_names(MAX_FAULT_DIRECTORY_ENTRIES)?; + if faults_truncated { + 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) + } + + #[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) -> io::Result<()> { + if now_ms == 0 { + return Err(invalid_data("invalid hook-ingest cleanup time")); + } + 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) + } + + /// 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 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, + states: &[HookSessionState], + now_ms: u64, + process_cache: &BTreeMap<(u32, String), bool>, +) -> io::Result<()> { + let (names, _) = directory.list_names(listing_bound)?; + for name in names { + 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) { + let _ = directory.remove_if_same(&name, &file); + } + } + 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_DIRECTORY_ENTRIES)?; + for name in names { + 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)? + { + let _ = fault_dir.remove_if_same(&name, &file); + } + } + 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, + states, + now_ms, + process_cache, + )?; + reclaim_stale_temporary_files( + fault_dir, + MAX_FAULT_DIRECTORY_ENTRIES, + 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_DIRECTORY_ENTRIES)?; + for name in fault_names { + 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)? { + let _ = fault_dir.remove_if_same(&name, &file); + } + } + 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(OsStr::new(FAULT_OVERFLOW_NAME), &encode_fault(&fault)?)?; + 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(); +} + +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.closed_child_tools.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.closed_tools.len() > MAX_OPEN_ITEMS + || state.closed_subagents.len() > MAX_OPEN_ITEMS + || state.closed_questions.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.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 = 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")); + } + dir.atomic_replace(name, &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; + continue; + } + 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; + } 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<()> { + 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(MAX_DIRECTORY_ENTRIES)?; + 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 remove_if_same(&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(name) + } + + fn remove(&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))?; + self.sync() + } + + 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 { + 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<()> { + 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(), libc::LOCK_EX | 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, + } + } + + #[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 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 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 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) + .unwrap(); + assert!( + marker_path.exists(), + "the 24h boundary is still fail-closed" + ); + ingress + .reclaim_stale_artifacts_after_drain(marker_time + TERMINAL_RETENTION_MS + 1) + .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 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) + .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) + .unwrap(); + assert!(state_temp_path.exists()); + assert!(fault_temp_path.exists()); + ingress + .reclaim_stale_artifacts_after_drain(artifact_time + TERMINAL_RETENTION_MS + 1) + .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) + .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()); + } + + #[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..3eb4708 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,140 @@ 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::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 +1195,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 +1228,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 +1309,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 +1607,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 +1663,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 +1683,7 @@ fn build_discovery_context( claimed_sids_by_pid, pids_per_cwd, self_pid, + session_incarnation_valid, } } @@ -1240,7 +1847,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 +1859,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 +1917,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 +1932,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 +1962,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 +1985,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 +2031,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 +2051,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 +2081,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 +2241,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 +2312,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 +2394,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 +2420,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 +2736,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 +3391,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 +3470,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 +3481,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 +3551,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 +3615,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 +3644,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 +4433,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 +4451,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 +4757,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 +4806,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 +4971,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..3d264ac 100644 --- a/src/collector/codex.rs +++ b/src/collector/codex.rs @@ -1,12 +1,20 @@ use super::process::{self, ProcInfo}; +use crate::codex_hooks::{ + plugin::{self, PluginPaths}, + state::{ + HookProjection, HookRootProjection, HookSessionState, HookStateStore, IntegrationIdentity, + }, +}; use crate::model::{ - AgentSession, ChatMessage, ChatRole, ChildProcess, RateLimitInfo, SessionStatus, ToolCall, + AgentSession, ChatMessage, ChatRole, ChildProcess, RateLimitInfo, SessionStatus, + StatusAuthority, StatusEvidence, StatusObservation, StatusReason, SubAgent, ToolCall, MAX_CHAT_MESSAGES, }; 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 +32,620 @@ 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>, +} + +const MAX_CODEX_PARSE_CACHE_ENTRIES: usize = 256; +const MAX_CODEX_DONE_TOMBSTONES: usize = 128; +#[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, + 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, +} + +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(), + 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 + }) + } + + fn is_exact_active(&self, now_ms: u64) -> bool { + self.has_exact_active_shape(now_ms) && self.open_tool_ids.is_empty() + } + + 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() + } + + 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() + } +} + +impl RolloutLifecycle { + fn has_exact_supported_release(&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. + self.root_cli_version == plugin::SUPPORTED_CODEX_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 + }) + } + + 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 the audited 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. Codex 0.146 exposes no such + /// proof, so production state conversion always leaves this false. Tests + /// may set it to true to exercise the hypothetical lifecycle projector. + 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, + 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, + }) +} + +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 +656,12 @@ struct CodexProcessContext { unknown_process_owner: bool, } +struct CodexCliSessionGroupLoad { + session: Option, + rate_limit: Option, + owned_paths: Vec, +} + struct DesktopRecentRolloutScanResult { rollouts: Vec, } @@ -105,83 +727,778 @@ 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 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 +} + +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 +} + +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" +} + +/// Bind hook state to one exact native Codex process observation. Incarnation +/// reads bracket argv/executable classification so PID reuse fails closed. +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" + ) + ) + }) +} - fn collect_sessions(&mut self, shared: &super::SharedProcessData) -> Vec { - if !self.sessions_dir.exists() { - self.last_rate_limit = None; - return vec![]; +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 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, + subagent_opened_at_ms, + subagent_stopped_at_ms, + candidate, + observations, + } +} - // Reset live rate limit each pass — only keep it if a current session provides one - self.last_rate_limit = None; +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) +} - // 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(); +/// 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 +} - let mut sessions = Vec::new(); - let mut seen_jsonl = std::collections::HashSet::new(); +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 +} - // 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 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 +} - 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() - .filter_map(|pid| { - shared - .desktop_rollout_fd_map - .get(pid) - .map(|paths| (*pid, paths.clone())) - }) - .collect(); +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(); +} - // Prefer the filesystem view so Desktop sessions appear immediately, +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 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.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.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.effective_hook_engine_attested + || !record.supported_release_attested + || rollout.is_none_or(|state| !state.has_exact_supported_release())) + { + 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() + }) + { + // 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_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::Idle => "idle".to_string(), + SessionStatus::Done => "finished".to_string(), + SessionStatus::Unknown => "status evidence unavailable".to_string(), + // Codex 0.146 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()), + } + } + + 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() + .filter_map(|pid| { + shared + .desktop_rollout_fd_map + .get(pid) + .map(|paths| (*pid, paths.clone())) + }) + .collect(); + + // Prefer the filesystem view so Desktop sessions appear immediately, // then use the async fd cache only to improve PID ownership. let desktop_pid_for_path = Self::desktop_pid_by_rollout_path( &desktop_pid_to_rollouts, @@ -207,9 +1524,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 +1537,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 +1573,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 +1591,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 +1637,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,106 +1655,863 @@ 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()) - .join(now.format("%m").to_string()) - .join(now.format("%d").to_string()); - if dir.exists() { - Some(dir) - } else { - None + /// 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) + } } } - fn is_active_desktop_rollout(path: &Path, active_mtime_secs: u64) -> bool { - let Ok(meta) = fs::metadata(path) else { - return false; - }; - let Ok(modified) = meta.modified() else { - return 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 age = std::time::SystemTime::now() - .duration_since(modified) - .unwrap_or_default(); - if age.as_secs() >= active_mtime_secs { - return false; + 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) + } - parse_codex_jsonl(path).is_some_and(|result| result.is_codex_desktop()) + 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 active_desktop_rollouts( - pid_to_rollouts: HashMap>, - seen_jsonl: &HashSet, - mcp_owned_rollouts: &HashSet, - active_mtime_secs: u64, - ) -> Vec<(u32, PathBuf)> { - let mut candidates: Vec<(u32, PathBuf)> = pid_to_rollouts + 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() - .flat_map(|(pid, paths)| paths.into_iter().map(move |path| (pid, path))) - .collect(); - candidates.sort_by_key(|(_, path)| { - std::cmp::Reverse( - fs::metadata(path) - .and_then(|meta| meta.modified()) - .unwrap_or(std::time::UNIX_EPOCH), - ) - }); + .take(snapshots.len() - MAX_CODEX_DONE_TOMBSTONES) + { + snapshots.remove(&key); + } + } - let mut emitted = HashSet::new(); - candidates + 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() - .filter(|(_, path)| { - !seen_jsonl.contains(path) - && !mcp_owned_rollouts.contains(path) - && emitted.insert(path.clone()) - && Self::is_active_desktop_rollout(path, active_mtime_secs) - }) - .collect() + .take(tombstones.len() - MAX_CODEX_DONE_TOMBSTONES) + { + tombstones.remove(&key); + } } - fn desktop_pid_by_rollout_path( - pid_to_rollouts: &HashMap>, - active_mtime_secs: u64, - ) -> HashMap { - Self::active_desktop_rollouts( - pid_to_rollouts.clone(), - &HashSet::new(), - &HashSet::new(), - active_mtime_secs, - ) - .into_iter() - .map(|(pid, path)| (path, pid)) - .collect() + 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; + snapshot.version = plugin::SUPPORTED_CODEX_VERSION.to_string(); + 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); } - fn foreground_desktop_rollouts( - sessions_dir: &Path, - seen_jsonl: &HashSet, - mcp_owned_rollouts: &HashSet, - active_mtime_secs: u64, - ) -> Vec { - let Some(today_dir) = Self::today_session_dir(sessions_dir) else { - return Vec::new(); - }; - let roots = [today_dir]; - Self::recent_desktop_rollouts_from_roots( - &roots, - seen_jsonl, - mcp_owned_rollouts, - active_mtime_secs, - ) + #[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 recent_desktop_rollouts_from_roots( - roots: &[PathBuf], + fn finalize_hook_records_with_scan( + &self, + sessions: Vec, + mut records: Vec, + shared: &super::SharedProcessData, + now_ms: u64, + hook_scan_available: bool, + ) -> Vec { + let eligible_pids = + Self::find_codex_pids_from_shared(&shared.process_info, &shared.mcp_server_pids) + .into_iter() + .map(|(pid, _)| pid) + .collect::>(); + self.observe_hook_process_transitions(&mut records, now_ms, hook_scan_available); + self.prune_hook_done_tombstones(now_ms); + + 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::>(); + for session in remaining.iter_mut().flatten() { + mark_codex_status_unavailable(session, now_ms, StatusReason::HookIntegrationUnverified); + } + + records.retain(|record| { + !record.session_id.is_empty() + && 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 rollout_binding_exact = matching_rollouts.len() == 1 + && selected.is_some_and(|index| { + remaining[index].as_ref().is_some_and(|session| { + session.pid == record.pid + && session.cwd == record.cwd + && shared.process_info.contains_key(&session.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_exact_supported_release); + record.supported_release_attested = active_generation + && process_visible + && !ownership_conflict + && !record.local_config_ambiguous + && supported_release; + 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 + && !record.local_config_ambiguous + && rollout_binding_exact + { + bindings.insert( + done_key, + HookProcessRolloutBinding { + session_id: record.session_id.clone(), + supported_release, + }, + ); + } + } + } + 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)), + ); + 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.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() { + Some(dir) + } else { + None + } + } + + fn is_active_desktop_rollout(path: &Path, active_mtime_secs: u64) -> bool { + let Ok(meta) = fs::metadata(path) else { + return false; + }; + let Ok(modified) = meta.modified() else { + return false; + }; + let age = std::time::SystemTime::now() + .duration_since(modified) + .unwrap_or_default(); + if age.as_secs() >= active_mtime_secs { + return false; + } + + parse_codex_jsonl(path).is_some_and(|result| result.is_codex_desktop()) + } + + fn active_desktop_rollouts( + pid_to_rollouts: HashMap>, + seen_jsonl: &HashSet, + mcp_owned_rollouts: &HashSet, + active_mtime_secs: u64, + ) -> Vec<(u32, PathBuf)> { + let mut candidates: Vec<(u32, PathBuf)> = pid_to_rollouts + .into_iter() + .flat_map(|(pid, paths)| paths.into_iter().map(move |path| (pid, path))) + .collect(); + candidates.sort_by_key(|(_, path)| { + std::cmp::Reverse( + fs::metadata(path) + .and_then(|meta| meta.modified()) + .unwrap_or(std::time::UNIX_EPOCH), + ) + }); + + let mut emitted = HashSet::new(); + candidates + .into_iter() + .filter(|(_, path)| { + !seen_jsonl.contains(path) + && !mcp_owned_rollouts.contains(path) + && emitted.insert(path.clone()) + && Self::is_active_desktop_rollout(path, active_mtime_secs) + }) + .collect() + } + + fn desktop_pid_by_rollout_path( + pid_to_rollouts: &HashMap>, + active_mtime_secs: u64, + ) -> HashMap { + Self::active_desktop_rollouts( + pid_to_rollouts.clone(), + &HashSet::new(), + &HashSet::new(), + active_mtime_secs, + ) + .into_iter() + .map(|(pid, path)| (path, pid)) + .collect() + } + + fn foreground_desktop_rollouts( + sessions_dir: &Path, + seen_jsonl: &HashSet, + mcp_owned_rollouts: &HashSet, + active_mtime_secs: u64, + ) -> Vec { + let Some(today_dir) = Self::today_session_dir(sessions_dir) else { + return Vec::new(); + }; + let roots = [today_dir]; + Self::recent_desktop_rollouts_from_roots( + &roots, + seen_jsonl, + mcp_owned_rollouts, + active_mtime_secs, + ) + } + + fn recent_desktop_rollouts_from_roots( + roots: &[PathBuf], seen_jsonl: &HashSet, mcp_owned_rollouts: &HashSet, active_mtime_secs: u64, @@ -535,9 +2610,166 @@ 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 result = self.parse_rollout_cached(jsonl_path)?; + self.build_session_with_rate_limit( + process_ctx, + result, + &[], + process_info, + children_map, + ports, + mcp_server_pids, + ) + } + + /// 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) @@ -550,45 +2782,76 @@ impl CodexCollector { .unwrap_or("?") .to_string(); - // 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. + // 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(); - // 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. + // 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 !pid_alive || (process_ctx.is_exec && result.task_complete) { + } 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 +2888,98 @@ 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(), + 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(), + }) + .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 +2998,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 +3006,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 +3034,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 +3093,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 +3120,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 +3163,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 +3189,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 +3225,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 +3245,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 +3271,12 @@ 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, + /// 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 +3285,203 @@ 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() + } } -fn event_timestamp_ms(val: &Value) -> 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()) +struct CodexRolloutGroup { + root: CodexJSONLResult, + children: Vec, + lifecycle_valid: bool, } -fn value_to_tool_arg(value: &Value) -> Option { - if let Some(s) = value.as_str() { - return Some(s.to_string()); +/// 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 let Some(items) = value.as_array() { - let parts: Vec<&str> = items.iter().filter_map(|item| item.as_str()).collect(); - if parts.is_empty() { - return None; + 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; } - if parts.len() >= 3 && parts[0] == "bash" && parts[1] == "-lc" { - return Some(parts[2].to_string()); + } + + 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; } - return Some(parts.join(" ")); + // 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); } - if value.is_number() || value.is_boolean() { - return Some(value.to_string()); + + 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 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(|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; } - None + result.latest_lifecycle_at_ms = timestamp_ms; } 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 +3520,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() @@ -1041,6 +3582,39 @@ fn close_codex_tool_call( pending_tasks.retain(|(id, _)| id != call_id); } +fn close_codex_turn_calls( + end_ms: u64, + tool_calls: &mut [ToolCall], + call_indices: &HashMap, + call_starts: &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 = call_starts + .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, + call_indices, + call_starts, + pending_tasks, + ); + } +} + +fn codex_tool_waits_for_user(name: &str) -> bool { + name == "request_user_input" +} + /// Parse a Codex rollout-*.jsonl file. /// /// Event types: @@ -1050,14 +3624,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 +3650,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,6 +3668,9 @@ 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(), + awaiting_input_since_ms: 0, thinking_since_ms: 0, }; let mut call_indices: HashMap = HashMap::new(); @@ -1103,10 +3693,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 +3710,68 @@ 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; } } 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() { + continue; + } let payload = &val["payload"]; if let Some(id) = payload["id"].as_str() { result.session_id = id.to_string(); } + result.parent_thread_id = payload["parent_thread_id"] + .as_str() + .or_else(|| { + payload["source"]["subagent"]["thread_spawn"]["parent_thread_id"].as_str() + }) + .map(str::to_string); + 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 +3782,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 +3800,44 @@ fn parse_codex_jsonl(path: &Path) -> Option { let payload = &val["payload"]; match payload["type"].as_str() { Some("task_started") => { + let boundary_ms = event_timestamp_ms(&val, parse_now_ms).unwrap_or(0); + let turn_id = payload["turn_id"] + .as_str() + .filter(|turn_id| !turn_id.is_empty()) + .map(str::to_string); + close_codex_turn_calls( + boundary_ms, + &mut result.tool_calls, + &call_indices, + &mut call_starts, + &mut pending_tasks, + &running_exec_by_session, + ); + 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 let Some(cw) = payload["model_context_window"].as_u64() { result.context_window = cw; } } Some("user_message") => { - result.model_generating = true; - result.thinking_since_ms = event_timestamp_ms(&val).unwrap_or(0); + 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(); @@ -1250,8 +3920,8 @@ fn parse_codex_jsonl(path: &Path) -> Option { } 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,13 +3931,76 @@ 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() + .filter(|turn_id| !turn_id.is_empty()) + .map(str::to_string); + 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 + && call_starts.is_empty(); + close_codex_turn_calls( + boundary_ms, + &mut result.tool_calls, + &call_indices, + &mut call_starts, + &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") => { + Some("turn_aborted") => { + let boundary_ms = event_timestamp_ms(&val, parse_now_ms).unwrap_or(0); + advance_rollout_lifecycle(&mut result, boundary_ms); + close_codex_turn_calls( + boundary_ms, + &mut result.tool_calls, + &call_indices, + &mut call_starts, + &mut pending_tasks, + &running_exec_by_session, + ); + result.lifecycle_valid = false; + 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; + } + Some( + "exec_command_end" + | "image_generation_end" + | "mcp_tool_call_end" + | "patch_apply_end" + | "web_search_end", + ) => { if let Some(call_id) = payload["call_id"].as_str() { - let end_ms = event_timestamp_ms(&val).unwrap_or(0); + if !call_starts.contains_key(call_id) { + result.lifecycle_valid = false; + } + let end_ms = event_timestamp_ms(&val, parse_now_ms).unwrap_or(0); + advance_rollout_lifecycle(&mut result, end_ms); close_codex_tool_call( call_id, end_ms, @@ -1276,22 +4009,46 @@ fn parse_codex_jsonl(path: &Path) -> Option { &mut call_starts, &mut pending_tasks, ); + } else { + result.lifecycle_valid = 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") { + 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(name) = payload["name"].as_str() { // 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,16 +4056,23 @@ 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); + let start_ms = event_timestamp_ms(&val, parse_now_ms).unwrap_or(0); + advance_rollout_lifecycle(&mut result, start_ms); + if call_id.is_empty() + || start_ms == 0 + || call_starts.contains_key(call_id) + { + result.lifecycle_valid = false; + } 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) + if let Some(session_id) = + raw_input.as_str().and_then(parse_codex_tool_session_id) { write_stdin_targets.insert(call_id.to_string(), session_id); } @@ -1325,11 +4089,25 @@ fn parse_codex_jsonl(path: &Path) -> Option { }); call_indices.insert(call_id.to_string(), idx); } + } 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); + if !call_starts.contains_key(call_id) { + result.lifecycle_valid = false; + } + let end_ms = event_timestamp_ms(&val, parse_now_ms).unwrap_or(0); + advance_rollout_lifecycle(&mut result, end_ms); + if end_ms == 0 { + result.lifecycle_valid = false; + } let output = payload["output"].as_str().unwrap_or_default(); match call_names.get(call_id).map(String::as_str) { Some("exec_command") => { @@ -1383,7 +4161,13 @@ fn parse_codex_jsonl(path: &Path) -> Option { ); } } + } 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 +4185,14 @@ 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; + } + _ => {} } } @@ -1414,8 +4206,28 @@ fn parse_codex_jsonl(path: &Path) -> Option { .map(|(_, task)| task.clone()) .unwrap_or_default(); result.pending_since_ms = call_starts.values().copied().min().unwrap_or(0); - if !result.model_generating { + result.open_tool_ids = call_starts.keys().cloned().collect(); + result.open_tool_started_at_ms = call_starts.clone(); + result.awaiting_input_since_ms = call_starts + .iter() + .filter_map(|(call_id, started_at)| { + call_names + .get(call_id) + .is_some_and(|name| codex_tool_waits_for_user(name)) + .then_some(*started_at) + }) + .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) @@ -1428,6 +4240,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 +4284,276 @@ 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(), + 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(); + } + 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(); + } + } + 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(), + open_child_tools: BTreeMap::new(), + child_tool_opened_at_ms: BTreeMap::new(), + closed_child_tools: BTreeMap::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(), + open_questions: BTreeSet::new(), + question_opened_at_ms: BTreeMap::new(), + closed_questions: BTreeSet::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_ingests: Vec::new(), + samples: Vec::new(), + } + } + + fn active_root_rollout(now_ms: u64) -> RolloutLifecycle { + RolloutLifecycle { + root_cli_version: plugin::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::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::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(), + } + } + + fn terminal_child_rollout( + session_id: &str, + direct_child: bool, + now_ms: u64, + ) -> DescendantRolloutLifecycle { + DescendantRolloutLifecycle { + session_id: session_id.to_string(), + cli_version: plugin::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(), + } + } + + 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 { @@ -1485,7 +4569,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 +4772,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 +4812,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 +4847,13 @@ 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()), }; let mut shared = super::super::SharedProcessData { process_info: HashMap::new(), @@ -1785,7 +4880,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 +4897,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(); @@ -1917,15 +5031,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 +5050,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 +5068,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 +5126,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, @@ -2050,24 +5158,130 @@ 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":"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, 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(), + &HashSet::new(), + ) + .unwrap(); + let session = finalize_unintegrated(&collector, session, process_info); + + 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].name, "request_user_input"); + assert_eq!(session.tool_calls[0].duration_ms, 0); + assert_eq!(session.pending_since_ms, 0); + } + + #[test] + 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_not_live_status() { let mut file = tempfile::NamedTempFile::new().unwrap(); write_lines( &mut file, @@ -2098,18 +5312,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_exec_command_output_closes_task_without_end_event() { + fn rollout_exec_output_records_duration_but_not_live_status() { let mut file = tempfile::NamedTempFile::new().unwrap(); write_lines( &mut file, @@ -2140,18 +5355,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 +5400,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 +5414,2371 @@ mod tests { assert_eq!(session.pending_since_ms, 0); } + #[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"}}"#, + 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"}}"#, + 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()); + + 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":[]}}"#, + ], + ); + let result = parse_codex_jsonl(open.path()).unwrap(); + assert_eq!(result.pending_since_ms, 0); + assert!(result.current_task.is_empty()); + assert_eq!(result.tool_calls[0].duration_ms, 3_000); + assert!(result.turn_active); + } + + #[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::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::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 only_known_codex_end_events_close_their_matching_call() { + let temp = tempfile::tempdir().unwrap(); + let known = temp.path().join("rollout-known-end.jsonl"); + write_jsonl( + &known, + &[ + 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(&known).unwrap(); + assert!(parsed.lifecycle_valid); + assert!(parsed.open_tool_ids.is_empty()); + + 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()])); + } + + #[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::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.task_complete); + assert!(parsed.completed_turn_id.is_none()); + } + + #[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_requires_the_exact_supported_root_cli_version() { + let now_ms = 100_000; + let record = hook_record(HookCandidate::TurnOpen, now_ms); + for unsupported in ["0.145.0", "0.146.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_cannot_promote_live_but_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, + "Codex 0.146 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 missing native attestation + // is the only reason live status cannot be promoted. + 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", + plugin::SUPPORTED_CODEX_VERSION, + ); + 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::Unknown); + assert_eq!( + live[0].status_evidence.authority, + StatusAuthority::Unavailable + ); + assert_eq!( + live[0].status_evidence.reason, + StatusReason::HookIntegrationUnverified + ); + 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!(done[0].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 an unaudited 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))]); + 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::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::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::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 fresh = + fresh_collector.finalize_hook_records(Vec::new(), vec![already_gone], &shared, 100_000); + assert_eq!(fresh[0].status, SessionStatus::Unknown); + assert!(fresh_collector.hook_done_tombstones.borrow().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::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_eq!(faulty[0].status, SessionStatus::Unknown); + assert_eq!(faulty[0].status_evidence.reason, reason); + assert!(collector.hook_done_tombstones.borrow().is_empty()); + } + } + + #[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::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.146.1", + ); + 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_eq!(gone[0].status, SessionStatus::Unknown); + assert_eq!( + gone[0].status_evidence.reason, + StatusReason::HookIntegrationUnverified + ); + 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::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::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::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::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::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::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::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.146.1".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.146.1".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/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/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..40f9c04 100644 --- a/src/collector/mod.rs +++ b/src/collector/mod.rs @@ -1,5 +1,7 @@ pub mod claude; pub mod codex; +pub mod grok; +pub mod kimi; pub mod mcp; pub mod opencode; pub mod process; @@ -7,6 +9,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 +86,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 +288,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 +341,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 +452,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 +530,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 +567,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/config.rs b/src/config.rs index 4cd5532..30f6be2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,16 +27,14 @@ impl Default for PanelVisibility { pub struct AppConfig { pub theme: String, - /// Agent CLI names to exclude from the TUI (e.g. ["codex"] to hide Codex). + /// 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 { @@ -46,7 +44,6 @@ impl Default for AppConfig { hidden_agents: Vec::new(), claude_config_dirs: Vec::new(), panels: PanelVisibility::default(), - language: String::new(), } } } @@ -96,7 +93,6 @@ 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(), "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), @@ -316,12 +312,15 @@ 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 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..585f94b 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![], @@ -585,3 +655,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/lib.rs b/src/lib.rs index b393efc..a1f4c8b 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"`, `"Idle"`, …) and chat roles serialize as `"user"` / +//! `"assistant"`. //! These strings are stable and won't be renamed without a major version bump. //! //! # Threading model @@ -52,6 +53,8 @@ //! ``` pub mod app; +mod codex_compat; +mod codex_hooks; pub mod collector; pub mod config; pub mod demo; @@ -74,43 +77,137 @@ use crossterm::terminal::{ }; 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, - ) +/// 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( + theme, + &cfg.hidden_agents, + cfg.panels, + &cfg.claude_config_dirs, + ) + } } 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 +234,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,7 +251,7 @@ 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 { @@ -169,7 +271,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 +285,10 @@ 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(); } print_snapshot(&app); return Ok(()); @@ -219,6 +325,204 @@ 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 = "Codex 0.146.0 cannot attest effective managed/cloud/profile/live hook coverage; installation readiness is diagnostic only."; +const CODEX_LIVE_STATUS_NOTICE: &str = "Live Codex Think/Exec/Wait/Idle remain Unknown and non-actionable; only an exact supported process/session Live→Gone transition can report non-actionable heuristic Done for 30 seconds."; + +fn print_codex_coverage_notice() { + println!("{CODEX_COVERAGE_NOTICE}"); + println!("{CODEX_LIVE_STATUS_NOTICE}"); +} + +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!("Restart Codex and review only the 11 abtop hooks in its trust prompt."); + 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, @@ -236,12 +540,21 @@ fn run_app( panels: config::PanelVisibility, claude_config_dirs: &[std::path::PathBuf], ) -> 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( + initial_theme.unwrap_or_default(), + hidden_agents, + panels, + claude_config_dirs, + ) + }; if demo_mode { demo::populate_demo(&mut app); } else { @@ -453,36 +766,36 @@ fn print_snapshot(app: &App) { println!(); } 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 +816,63 @@ fn print_snapshot(app: &App) { } } +fn snapshot_status_name(status: &model::SessionStatus) -> &'static str { + match status { + model::SessionStatus::Thinking => "Thinking", + model::SessionStatus::Executing => "Executing", + 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::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"); @@ -586,6 +956,136 @@ 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("Think/Exec/Wait/Idle remain Unknown")); + assert!(CODEX_LIVE_STATUS_NOTICE.contains("Live→Gone")); + assert!(CODEX_LIVE_STATUS_NOTICE.contains("Done for 30 seconds")); + } + + #[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_label(&model::SessionStatus::Idle), "○ Idle"); + assert_eq!( + snapshot_status_label(&model::SessionStatus::Waiting), + "◌ Wait" + ); + } + #[test] fn enter_jump_failure_renders_footer_status() { let mut app = App::new_with_config( diff --git a/src/locale.rs b/src/locale.rs index abf098f..f283585 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -1,21 +1,6 @@ -// 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(); @@ -23,8 +8,10 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("sess.think", "◉ Think"); m.insert("sess.exec", "● Exec"); 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 +91,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 "); @@ -264,261 +256,41 @@ 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", "活跃"); - - m -}); - 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()) + LOCALE_EN + .get(key) + .map(|s| s.to_string()) + .unwrap_or_else(|| key.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn lifecycle_status_labels_are_distinct() { + let keys = [ + "sess.think", + "sess.exec", + "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!(!LOCALE_EN.contains_key("sess.work")); + + assert_eq!(LOCALE_EN["sess.exec"], "● Exec"); + assert_eq!(LOCALE_EN["sess.wait"], "◌ Wait"); + assert_eq!(LOCALE_EN["sess.idle"], "○ Idle"); + assert_eq!(LOCALE_EN["sess.unknown"], "? Unknown"); } } diff --git a/src/model/session.rs b/src/model/session.rs index cc3a5bf..cad8313 100644 --- a/src/model/session.rs +++ b/src/model/session.rs @@ -56,18 +56,24 @@ pub struct RateLimitInfo { pub updated_at: Option, } -#[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 + /// An exact provider signal 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, } @@ -79,6 +85,210 @@ impl SessionStatus { } } +/// 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, + #[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::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() + } +} + #[derive(Debug, Clone, Serialize)] pub struct ChildProcess { pub pid: u32, @@ -132,16 +342,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 +404,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 the provider exposes a pending interaction that needs a user + /// response. Codex interaction candidates remain false here because its + /// current hook 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 +417,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 a provider-confirmed 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 +446,29 @@ 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::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 +497,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 { @@ -267,11 +532,13 @@ mod tests { 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 +566,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 +584,156 @@ mod tests { let session = make_session(100, 50, 200, 30); assert_eq!(session.active_tokens(), 180); // 100 + 50 + 30, excludes cache_read } + + #[test] + fn only_thinking_and_executing_are_active() { + for status in [SessionStatus::Thinking, SessionStatus::Executing] { + 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 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::RateLimited; + assert_eq!(session.display_task(), Some("rate limited")); + } + + #[test] + fn non_executing_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::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 + ); + } + + #[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::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..8bce148 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -14,7 +14,8 @@ use crate::app::App; 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, SessionStatus, StatusEvidence, + MAX_CHAT_MESSAGES, MAX_VISIBLE_STATUS_OBSERVATIONS, }; use serde::Serialize; use std::time::{SystemTime, UNIX_EPOCH}; @@ -40,7 +41,7 @@ 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. Currently populated for Claude and Codex. pub rate_limits: Vec, /// Ports left open by processes whose parent session has ended. Empty on a /// one-shot snapshot — orphan detection needs cross-tick history, so it @@ -84,7 +85,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 +99,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, @@ -207,7 +212,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 +233,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), @@ -328,8 +335,10 @@ mod tests { (SessionStatus::Thinking, "\"Thinking\""), (SessionStatus::Executing, "\"Executing\""), (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 +360,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 +386,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 +418,68 @@ 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["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 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] 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..7cd657d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1009,8 +1009,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"); 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/sessions.rs b/src/ui/sessions.rs index 1a911eb..d05bf66 100644 --- a/src/ui/sessions.rs +++ b/src/ui/sessions.rs @@ -133,31 +133,26 @@ 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::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 +166,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 +186,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 +217,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 +258,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 +544,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 +571,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 +586,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 +978,111 @@ 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::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 +1156,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 +1170,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 +1334,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 +1353,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 +1393,122 @@ 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 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::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 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 +1536,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 +1657,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 +1691,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}"); +}