diff --git a/CHANGELOG.md b/CHANGELOG.md index 7853515..50eb6ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,66 +4,56 @@ All notable changes to this project will be documented in this file. This project follows Semantic Versioning. ## Unreleased + +- Add: `consensus graph` builds a versioned, provider-neutral execution graph from retained events. +- Add: turn-scoped step nodes and lifecycle filtering prevent ordinary multi-turn sessions from appearing as loops. +- Add: bounded metadata-only Claude hook history for one-shot scan and graph inspection. +- Add: current Claude hook coverage for `MessageDisplay`, `PostToolBatch`, `PermissionDenied`, task events, and `StopFailure`. +- Fix: treat `UserPromptSubmit` as the sole retained Claude turn boundary so slash-command expansion does not create a duplicate turn. +- Fix: graph IDs use opaque agent keys instead of reversible raw session paths. +- Fix: historical loops no longer inherit an agent's unrelated current state. +- Fix: live graph inspection disables OpenCode server autostart. +- Fix: loop edge evidence is named `transitionObservations` rather than implied iteration count. +- Fix: graph snapshot input now rejects malformed agents, provider kinds, states, and events. - Fix: normalize OpenCode detection for mixed-case binary paths to keep servers in the correct lane. -- Fix: OpenCode activity now uses work-only timestamps (not heartbeat events) and decays in-flight after idle. +- Fix: OpenCode activity uses work-only timestamps and decays stale in-flight state. - Fix: Claude CLI prompts use a short pulse instead of sticking active indefinitely. -- Fix: prevent OpenCode “server” misclassification when prompts include “server” text (tokenized subcommand parsing). -- Fix: reduce OpenCode server idle flicker with a higher CPU threshold for servers. -- Fix: avoid Codex in-flight flicker by clearing on explicit assistant completion (tool call tracking + assistant message end). -- Fix: remove short in-flight idle clearing by default (configurable via `CONSENSUS_CODEX_INFLIGHT_IDLE_MS`). -- Fix: stabilize Codex activation with prompt pulse + file-growth activity and longer event/hold defaults. -- Fix: Codex prompt pulse for instant activation without waiting on streaming events. -- Fix: ignore OpenCode helper processes without sessions to avoid false active tiles. -- Fix: reduce OpenCode active/hold defaults for faster idle transitions. -- Fix Codex session matching by using `session_meta` cwd when session IDs are missing. -- Add sustained CPU fallback for Codex active detection when log signals lag. -- Use OpenCode API/storage activity timestamps to reduce activation lag. -- Harden WebSocket override handling for UI tests. -- Switch to event-driven updates (Codex log watch + OpenCode SSE) with slow PID polling. -- Surface OpenCode API failures in the UI status line. -- Do not treat Codex prompts as activity (avoid false active state). -- Deduplicate by PID to avoid hiding live agents when session association drifts. -- Cache process/session scans for fast event-driven refreshes. -- Expand Codex in-flight detection for response created/delta events. -- Reduce Codex idle lag by shortening default active + hold windows. -- Clear Codex in-flight state when activity is stale. -- Fix: keep Codex in-flight active until timeout to prevent active/idle flicker mid-run. -- Parallelize Codex tail reads to reduce pickup lag. -- Lower Codex active windows for sub-second idle transitions. +- Fix: prevent OpenCode server misclassification when prompts include server text. +- Fix: reduce OpenCode server idle flicker with a higher CPU threshold. +- Fix: stabilize Codex in-flight activity across tool calls and assistant completion. +- Fix: improve Codex session matching, event-driven updates, and bounded caches. ## 0.1.6 - 2026-01-25 -- Treat Codex response items as activity only for assistant output/tool work (not user prompts). + +- Treat Codex response items as activity only for assistant output and tool work. - Reduce false active state by using activity timestamps instead of generic event timestamps. ## 0.1.5 - 2026-01-25 -- Add Claude Code process detection with prompt/resume parsing. -- Apply CLI-specific palettes (Codex/OpenCode/Claude Code) across tiles and lane items. -- Add Claude CLI parsing unit tests. -- Update README with Claude Code support. + +- Add Claude Code process detection with prompt and resume parsing. +- Apply provider palettes across tiles and lane items. +- Add Claude CLI parsing tests. ## 0.1.4 - 2026-01-24 -- Fix OpenCode event tracking build error (pid activity typing). + +- Fix OpenCode event tracking build error. ## 0.1.3 - 2026-01-24 -- Add OpenCode integration (API sessions, event stream, storage fallback). -- Autostart OpenCode server with opt-out and CLI flags. -- Split servers into a dedicated lane with distinct palette. -- Improve layout keys to prevent tile overlap. -- Add OpenCode unit/integration tests and configuration docs. + +- Add OpenCode sessions, event stream, and storage fallback. +- Add optional OpenCode server autostart. +- Split servers into a dedicated lane. ## 0.1.2 - 2026-01-24 -- Lower CPU threshold for active detection. -- Increase activity window defaults for long-running turns. -- Skip vendor codex helper processes to avoid duplicate tiles. -- Improve session mapping for active-state detection. + +- Improve activity thresholds and session mapping. +- Skip duplicate Codex vendor helper processes. ## 0.1.1 - 2026-01-24 -- Smooth active state to prevent animation flicker. -- Add `consensus-cli` binary alias so `npx consensus-cli` works. -- Extend active window to match Codex event cadence. + +- Smooth active-state rendering. +- Add the `consensus-cli` binary alias. ## 0.1.0 - 2026-01-24 -- Initial public release. -- Improve work summaries and recent events (latest-first, event-only fallback). -- Mark agents active based on recent events (not just CPU). -- License: Apache-2.0. + +- Initial public release under Apache-2.0. diff --git a/README.md b/README.md index 852f3e4..c3e242c 100644 --- a/README.md +++ b/README.md @@ -4,242 +4,214 @@ [![GitHub release](https://img.shields.io/github/v/release/integrate-your-mind/consensus-cli?display_name=tag&color=2563eb)](https://github.com/integrate-your-mind/consensus-cli/releases) [![License](https://img.shields.io/npm/l/consensus-cli.svg?color=6b7280)](LICENSE) -Live isometric atlas for Codex, OpenCode, and Claude Code sessions, rendered in a local browser. +Local-first observability for coding-agent graphs, loops, and live activity. ## Status -Beta. Local-only, no hosted service. -## Who it's for -Developers running multiple Codex, OpenCode, or Claude Code sessions who want a visual, at-a-glance view of activity. +Beta. Local-only, with no hosted service. ## Core use cases -- Track which agents are active right now. -- Spot errors or idle processes quickly. -- Inspect recent activity without digging through logs. -## Scope (non-goals) -- Does not start, stop, or manage processes. -- Does not connect to remote Codex, OpenCode, or Claude Code instances. -- No authentication or multi-user access. +- Inspect observed prompt, model, tool, command, and edit transitions. +- Detect cycles inside individual observed turns. +- Track active, idle, and error states across local coding agents. +- Compare evidence depth across different agent harnesses. +- Inspect recent activity without reading raw provider logs. + +## Scope + +Consensus observes local runs. It does not start, stop, retry, route, approve, or otherwise control agent work. The default server can auto-start OpenCode for the live dashboard; the one-shot `consensus graph` command explicitly disables that side effect. ## Quickstart + ```bash npm install npm run dev ``` -The server prints the local URL (default `http://127.0.0.1:8787`). -Consensus reads local Codex CLI sessions and does not require API keys. -You just need Codex CLI installed and signed in (Pro subscription or team plan). -If OpenCode is installed, Consensus will auto-start its local server. -If Claude Code is installed, it will appear automatically (run `claude` once to sign in). -Claude activity tracking requires hooks (see "Claude hooks" below). -`npm run dev` also keeps `dist/claudeHook.js` up to date so hooks can point at the compiled entry during development. +The server prints a local URL, normally `http://127.0.0.1:8787`. + +Run the published CLI: -## Run via npx ```bash npx consensus-cli ``` -Expected output: +Consensus reads local runtime evidence and does not require an OpenAI API key. + +## Graphs and loops + +Inspect current local sessions: + +```bash +npx consensus-cli graph ``` -consensus dev server running on http://127.0.0.1:8787 + +Print the versioned JSON graph: + +```bash +npx consensus-cli graph --json ``` -## What you get -- One tile per running `codex`, `opencode`, or `claude` process. -- Activity state (active/idle/error) from CPU and recent events. -- Best-effort "doing" summary from Codex session JSONL, OpenCode events, or Claude CLI flags. -- Click a tile for details and recent events. -- Active lane for agents plus a dedicated lane for servers. - -## How it works -1) Scan OS process list for Codex + OpenCode + Claude Code. -2) Resolve Codex session JSONL under `CODEX_HOME/sessions/`. -3) Query the OpenCode local server API and event stream (with storage fallback). -4) Ingest Claude Code hook events to infer activity (CLI flags only for "doing"). -5) Poll and push snapshots over WebSocket. -6) Render tiles on a canvas with isometric projection. - -## Install options -- Local dev: `npm install` + `npm run dev` -- npx: `npx consensus-cli` -- Docker: not available yet -- Hosted: planned (opt-in aggregation later) +Analyze a saved snapshot: -## Configuration -- `CONSENSUS_HOST`: bind address (default `127.0.0.1`). -- `CONSENSUS_PORT`: server port (default `8787`). -- `CONSENSUS_POLL_MS`: process presence polling interval in ms (default `500`). -- `CONSENSUS_SCAN_TIMEOUT_MS`: scan timeout in ms (default `5000`). -- `CONSENSUS_SCAN_STALL_MS`: scan stall warning threshold in ms (default `60%` of timeout, min `250`). -- `CONSENSUS_SCAN_STALL_CHECK_MS`: scan stall check interval in ms (default `min(1000, stallMs)`, min `250`). -- `CONSENSUS_CODEX_HOME`: override Codex home (default `~/.codex`). -- `CONSENSUS_CODEX_NOTIFY_INSTALL`: optional path to install a Codex `notify` hook via `codex config set -g notify=[""]` (set to `0`/`false` to disable auto-install). -- `CONSENSUS_OPENCODE_HOST`: OpenCode server host (default `127.0.0.1`). -- `CONSENSUS_OPENCODE_PORT`: OpenCode server port (default `4096`). -- `CONSENSUS_OPENCODE_TIMEOUT_MS`: OpenCode request timeout in ms (default `5000`). -- `CONSENSUS_OPENCODE_AUTOSTART`: set to `0` to disable OpenCode autostart. -- `CONSENSUS_OPENCODE_EVENTS`: set to `0` to disable OpenCode event stream. -- `CONSENSUS_OPENCODE_HOME`: override OpenCode storage (default `~/.local/share/opencode`). -- `CONSENSUS_OPENCODE_EVENT_ACTIVE_MS`: OpenCode active window after last event in ms (default `0`). -- `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS`: OpenCode hold window in ms (default `3000`). -- `CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS`: OpenCode in-flight idle timeout in ms (defaults to `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`). -- `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`: OpenCode hard in-flight timeout in ms (default `15000`). -- `CONSENSUS_PROCESS_MATCH`: regex to match codex processes. -- `CONSENSUS_REDACT_PII`: set to `0` to disable redaction (default enabled). -- `CONSENSUS_UI_PORT`: dev UI port for Vite when running `npm run dev` (default `5173`). -- `CONSENSUS_DEBUG_OPENCODE`: set to `1` to log OpenCode server discovery. -- `CONSENSUS_CODEX_EVENT_ACTIVE_MS`: Codex active window after last event in ms (default `30000`). -- `CONSENSUS_CODEX_ACTIVE_HOLD_MS`: Codex hold window in ms (default `3000`). -- `CONSENSUS_CODEX_INFLIGHT_IDLE_MS`: Codex in-flight idle timeout in ms (default `30000`, set to `0` to disable). -- `CONSENSUS_CODEX_CPU_SUSTAIN_MS`: sustained CPU window before Codex becomes active without logs (default `500`). -- `CONSENSUS_CODEX_CPU_SPIKE`: Codex CPU spike threshold for immediate activation (default derived). -- `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS`: Codex in-flight timeout in ms (default `3000`). -- `CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS`: Codex max event age for in-flight signals (default `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS`). -- `CONSENSUS_PROCESS_CACHE_MS`: process cache TTL in ms for full scans (default `1000`). -- `CONSENSUS_PROCESS_CACHE_FAST_MS`: process cache TTL in ms for fast scans (default `500`). -- `CONSENSUS_SESSION_CACHE_MS`: Codex session list cache TTL in ms for full scans (default `1000`). -- `CONSENSUS_SESSION_CACHE_FAST_MS`: Codex session list cache TTL in ms for fast scans (default `500`). -- `CONSENSUS_EVENT_ACTIVE_MS`: active window after last event in ms (default `300000`). -- `CONSENSUS_CPU_ACTIVE`: CPU threshold for active state (default `1`). -- `CONSENSUS_CLAUDE_CPU_ACTIVE`: Claude CPU threshold override (default `1`). -- `CONSENSUS_CLAUDE_CPU_SUSTAIN_MS`: Claude sustained CPU window in ms (default `1000`). -- `CONSENSUS_CLAUDE_CPU_SPIKE`: Claude spike threshold override (default derived). -- Claude activity uses hooks; CPU settings are legacy and ignored for TUI activity. -- `CONSENSUS_CLAUDE_EVENT_TTL_MS`: Claude hook event TTL in ms (default `1800000`). -- `CONSENSUS_CLAUDE_INFLIGHT_TIMEOUT_MS`: Claude in-flight timeout if no hook events (default `15000`). -- `CONSENSUS_CLAUDE_ACTIVE_HOLD_MS`: Claude hold window in ms (default `3000`). -- `CONSENSUS_ACTIVE_HOLD_MS`: keep active state this long after activity (default `3000`). -- `CONSENSUS_IDLE_HOLD_MS`: hold idle state briefly after spans end (default `200`). -- `CONSENSUS_SPAN_STALE_MS`: span stale timeout for event progress (default `15000`). - -Full config details: `docs/configuration.md` - -## Claude hooks (required for activity) -Claude Code hooks are configured in `~/.claude/settings.json`, `.claude/settings.json`, or -`.claude/settings.local.json`. -Consensus ignores Claude `statusLine`; hooks are the sole Claude activity signal. -Hook handler source lives in `src/claudeHook.ts` (Effect) and is compiled to `dist/claudeHook.js`. - -Example (repeat the command for the events you want to track): -```json -{ - "hooks": { - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "node /path/to/consensus-cli/dist/claudeHook.js http://127.0.0.1:8787/api/claude-event" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "node /path/to/consensus-cli/dist/claudeHook.js http://127.0.0.1:8787/api/claude-event" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node /path/to/consensus-cli/dist/claudeHook.js http://127.0.0.1:8787/api/claude-event" - } - ] - } - ], - "SessionEnd": [ - { - "hooks": [ - { - "type": "command", - "command": "node /path/to/consensus-cli/dist/claudeHook.js http://127.0.0.1:8787/api/claude-event" - } - ] - } - ] - } -} +```bash +npm run scan > snapshot.json +npx consensus-cli graph --snapshot snapshot.json +``` + +Read a snapshot from standard input: + +```bash +cat snapshot.json | npx consensus-cli graph --snapshot - +``` + +The graph model: + +- Creates one opaque agent key per observed process or session. +- Splits step nodes by observed turn so separate prompts cannot create a false cycle. +- Filters lifecycle and transport events such as `turn.completed`, `session.status`, heartbeat, and token-count records. +- Collapses adjacent fragments of the same phase. +- Detects strongly connected components only within one agent and turn segment. +- Applies live state only to the latest observed step. Historical loops remain idle. +- Reports `transitionObservations`, which counts internal transition evidence rather than claiming completed loop iterations. + +Raw session paths are not included in graph IDs. See [`docs/graphs-and-loops.md`](docs/graphs-and-loops.md) for the full contract. + +## Harness coverage + +List the registry and evidence tiers: + +```bash +npx consensus-cli harnesses +npx consensus-cli harnesses --json +npx consensus-cli harnesses --setup gemini +npx consensus-cli harnesses --setup copilot +``` + +The registry includes: + +- Codex, OpenCode, and Claude Code. +- OpenClaw, Hermes Agent, Kimi Code, Factory Droid, Gemini CLI, Qwen Code, and GitHub Copilot CLI. +- Cursor Agent, Warp Oz, MiniMax CLI, Antigravity, Aider, Goose, Amp, Amazon Q, Kiro, and OpenHands. +- Cline, Roo Code, Windsurf Cascade, JetBrains Junie, Replit Agent, and Zed Agent. + +Consensus distinguishes process discovery from event-level support. Exact process recognition does not imply a complete graph. Each harness reports one of these evidence tiers: native events, hooks, structured stream, remote API, process only, tool only, or manual bridge. + +The enriched snapshot path used by `npm run scan` and `consensus graph` performs exact executable discovery for supported local harnesses. It does not search prompt text for provider names. See [`docs/harness-providers.md`](docs/harness-providers.md) for the provider matrix and adapter limits. + +## Shared metadata-only hooks + +The shared collector supports Claude-compatible or mapped lifecycle hooks for: + +```text +claude +hermes +kimi +factory +gemini +qwen +copilot ``` -Notes: -- Tool-related hooks (`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`) require a `matcher`. Use `"*"` to capture all tools. -- Claude hooks send JSON via stdin; `dist/claudeHook.js` expects `hook_event_name` and `session_id`. -- Dev (dynamic TS): use `node --import tsx /path/to/consensus-cli/src/claudeHook.ts http://127.0.0.1:8787/api/claude-event` so hook changes apply without rebuilds. +Run it as the provider's command hook: -Recommended events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, -`PostToolUse`, `PostToolUseFailure`, `Stop`, `SubagentStart`, `SubagentStop`, `SessionEnd`, -`Notification`. +```text +node /path/to/consensus-cli/dist/harnessHook.js +``` + +The collector accepts provider JSON on standard input and writes normalized metadata to `~/.consensus/harness-events.jsonl` by default. + +It does not retain prompt text, assistant text, tool input or output, shell commands, raw working directories, raw session or turn identifiers, or error details. Gemini CLI and GitHub Copilot CLI receive a compact one-line `{}` acknowledgment because their hook contracts require valid JSON on standard output; passive command-hook providers remain silent. + +Hook session assignment fails closed when several sessions share the same working directory. Exact opaque session keys take priority; cwd fallback is used only when one session matches. + +## Provider data flow + +1. Scan specialized Codex, OpenCode, and Claude Code process/session sources. +2. Read bounded Codex JSONL summaries and notify events. +3. Read OpenCode sessions and SSE events from the local server. +4. Receive Claude and shared multi-harness hook metadata. +5. Discover additional exact local harness executables without exporting command payloads. +6. Correlate processes and hook sessions with opaque keys. +7. Derive turn-scoped execution graphs and detected cycles. + +### Codex + +`consensus setup` writes the notify command to the user-level `~/.codex/config.toml`. + +```bash +npx consensus-cli setup +``` + +### OpenCode + +Consensus reads the local OpenCode HTTP server and its SSE event streams. The dashboard may auto-start `opencode serve` unless `CONSENSUS_OPENCODE_AUTOSTART=0`. The graph command never auto-starts it. + +Current OpenCode server docs also expose `parentID` and `/session/:id/children`; those relationships are reserved for a later explicit parent/subagent graph edge rather than inferred in version 1. + +### Claude Code + +Claude activity and graph history require hooks. The existing HTTP collector remains available: + +```text +node /path/to/consensus-cli/dist/claudeHook.js http://127.0.0.1:8787/api/claude-event +``` + +The shared standalone collector can also normalize Claude metadata through `harnessHook.js claude`. + +Claude Code can emit several `MessageDisplay` calls for one assistant message. Consensus treats partial batches as activity but retains only the final batch marker. It does not retain the message delta. + +`UserPromptSubmit` is the canonical turn boundary. `UserPromptExpansion` can update live activity, but Consensus does not retain it as a second prompt node. + +## Configuration + +The main settings are: + +- `CONSENSUS_HOST` — bind address, default `127.0.0.1`. +- `CONSENSUS_PORT` — HTTP port, default `8787`. +- `CONSENSUS_POLL_MS` — process scan interval. +- `CONSENSUS_CODEX_HOME` — Codex home directory. +- `CONSENSUS_OPENCODE_HOST` / `CONSENSUS_OPENCODE_PORT` — OpenCode server address. +- `CONSENSUS_OPENCODE_AUTOSTART=0` — disable dashboard autostart. +- `CONSENSUS_REDACT_PII=0` — disable normal text redaction. Opaque graph IDs remain opaque. +- `CONSENSUS_CLAUDE_EVENT_LOG` — Claude metadata log path, or `0` to disable. +- `CONSENSUS_HARNESS_EVENT_LOG` — shared harness metadata log path, or `0` to disable. +- `CONSENSUS_HARNESS_EVENT_LOG_MAX_BYTES` — shared log byte bound, default 2 MiB. +- `CONSENSUS_HARNESS_EVENT_TTL_MS` — shared event retention window. +- `CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS` — shared hook in-flight timeout. +- `CONSENSUS_GENERIC_PROCESS_CACHE_MS` — generic process discovery cache, default 1000 ms. + +See [`docs/configuration.md`](docs/configuration.md) for the complete list. ## Utilities -- `npm run scan` prints a one-shot JSON snapshot. -- `npm run tail -- ` tails a session file. + +```bash +npm run scan +npm run graph +npm run tail -- +``` ## Tests + ```bash npm run test +npm run test:ui +npm run build ``` -## Troubleshooting -- Port conflict on 8787: set `CONSENSUS_PORT=8790`. -- If the browser cannot connect, try `http://[::1]:` or set `CONSENSUS_HOST=127.0.0.1`. -- If "doing" is empty, the session log may not be resolvable yet. +## Documentation + +- [`docs/architecture.md`](docs/architecture.md) +- [`docs/configuration.md`](docs/configuration.md) +- [`docs/cli.md`](docs/cli.md) +- [`docs/graphs-and-loops.md`](docs/graphs-and-loops.md) +- [`docs/harness-providers.md`](docs/harness-providers.md) +- [`docs/data-inventory.md`](docs/data-inventory.md) +- [`docs/threat-model.md`](docs/threat-model.md) +- [`docs/testing.md`](docs/testing.md) +- [`docs/troubleshooting.md`](docs/troubleshooting.md) -More: `docs/troubleshooting.md` +## License -## Documentation -- `docs/architecture.md` -- `docs/configuration.md` -- `docs/install.md` -- `docs/examples.md` -- `docs/cli.md` -- `docs/decisions/` -- `docs/audience.md` -- `docs/promises.md` -- `docs/problem.md` -- `docs/data-inventory.md` -- `docs/threat-model.md` -- `docs/constitution.md` -- `docs/testing.md` -- `docs/release.md` -- `docs/troubleshooting.md` - -## Contributing -See `CONTRIBUTING.md`. - -## Security -See `SECURITY.md`. - -## Governance -See `GOVERNANCE.md`. - -## Roadmap -See `ROADMAP.md`. - -## Support -See `SUPPORT.md`. - -## License and trademark -Apache-2.0 License. See `LICENSE`. - -"consensus" is a project name used by the maintainer. Please do not imply -endorsement or use logos without permission. - -## Open source note -Development happens in the open via issues and pull requests. - -## Why open source -This project is meant to be forked, remixed, and adapted to your local workflows. - -## Hosted vision -The OSS version stays local-first. A future hosted service could optionally -aggregate agents across machines with a unified web dashboard. +Apache-2.0. See [`LICENSE`](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md index 4688658..fdf7fdd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,34 @@ # Roadmap -Near-term goals: -- Improve session-to-process matching with PID metadata when available. -- Add compact mini-map and grouping controls in the UI. -- Add basic performance profiling and render budget notes. -- Multi-device aggregation: optional cloud relay for agents across machines with a unified timeline. +## Current direction: graphs and loops + +Consensus is moving from a process atlas toward a local-first view of coding-agent execution graphs. It remains an observability product, not an orchestrator. + +### Now + +- Produce privacy-safe, turn-scoped graphs for Codex, OpenCode, and Claude Code. +- Keep loop state and counts semantically exact. +- Maintain bounded provider metadata and clear data-retention controls. +- Get full unit, integration, UI, and build checks green on supported platforms. + +### Next + +- Add explicit parent and child edges where provider contracts expose them. + - OpenCode currently exposes `parentID` and `/session/:id/children`. + - Claude hooks expose subagent and task lifecycle events. +- Stream graph deltas through the server. +- Render turn segments, edges, and loop state in the browser. +- Add traversal-aware loop iterations, duration, stop-rule status, and stuck-loop warnings. + +### Later + +- Compare declared workflow graphs with runtime-created task graphs. +- Add graph replay and run comparison. +- Add an optional multi-device relay while keeping local-only mode complete. +- Add compact graph grouping and performance profiling. + +## Reliability work + +- Improve process-to-session matching when providers expose stable metadata. +- Keep activity parsing stable across provider releases. +- Maintain Windows and shell-portable test and setup paths. diff --git a/docs/architecture.md b/docs/architecture.md index b6fb85c..d7c4875 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,13 +1,35 @@ # Architecture -## Data flow -1) `scan.ts` enumerates OS processes and collects CPU/memory stats. -2) `codexLogs.ts` scans `CODEX_HOME/sessions/` for recent JSONL logs. -3) `server.ts` ingests Codex notify + Claude hook events into in-memory stores. -4) `server.ts` polls snapshots and pushes updates over WebSocket. -5) `public/src` renders the isometric map in a canvas (see `public/src/components/CanvasScene.tsx`). - -## Components -- Server: Express + ws, static assets, `/api/snapshot`, `/health`. -- Client: Canvas renderer with pan/zoom and a detail side panel. -- Log tailer: best-effort JSONL parser for "doing" summaries. +## Live data flow + +1. `scan.ts` enumerates Codex, OpenCode, and Claude Code processes. +2. `codexLogs.ts` reads bounded recent JSONL summaries. +3. `opencodeEvents.ts` consumes the local OpenCode SSE stream. +4. `server.ts` validates Codex notify and Claude hook payloads. +5. `services/claudeEvents.ts` keeps bounded hook summaries in memory and writes metadata-only cross-process history. +6. `server.ts` emits snapshots over WebSocket. +7. `public/src` renders the live isometric view. + +## One-shot scan and graph flow + +1. `scanSnapshot.ts` hydrates bounded Claude metadata from disk. +2. It runs the normal process scanner. +3. `claudeSnapshot.ts` attaches retained Claude hook summaries to matching Claude agents. +4. `cli/graph.ts` disables OpenCode autostart before dynamically importing the scan wrapper. +5. `graph.ts` builds opaque agent nodes and turn-scoped step nodes. +6. `graphLoops.ts` detects strongly connected components inside the resulting transition graph. + +## Main components + +- Express and `ws` local server. +- Provider adapters for Codex, OpenCode, and Claude Code. +- Event-driven activity state derivation with bounded caches. +- Versioned graph and loop analysis. +- React/Vite client with canvas rendering. + +## Design boundaries + +- Read-only graph inspection. +- No inferred cross-agent causality in version 1. +- No prompt, assistant-message, or tool-output persistence in Consensus-owned Claude metadata. +- No raw session paths in graph relationship IDs. diff --git a/docs/cli.md b/docs/cli.md index 05c213e..b8da9fb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,45 @@ # CLI -Run: +## Start the local server + +```bash +npx consensus-cli +``` + +The server prints the local browser URL. Use `npx consensus-cli --help` for server flags. + +## Configure Codex + +```bash +npx consensus-cli setup +``` + +Setup writes the notify command to the user-level `~/.codex/config.toml`. It does not rely on project-local Codex config, where the current Codex contract does not support `notify`. + +## Inspect graphs and loops + +```bash +npx consensus-cli graph +npx consensus-cli graph --json +npx consensus-cli graph --snapshot snapshot.json +cat snapshot.json | npx consensus-cli graph --snapshot - +``` + +Live graph inspection is read-only. It sets OpenCode autostart off before loading the scanner, restores the environment afterward, and never calls `opencode serve` itself. + +Snapshot input must contain: + +- finite numeric `ts` +- an `agents` array +- valid agent `id`, `pid`, provider `kind`, and state +- structurally valid retained events when `events` is present + +Malformed JSON, unknown provider kinds, invalid states, and malformed events fail with a non-zero exit code. + +## One-shot snapshot + ```bash -npx consensus-cli --help +npm run scan ``` + +This wrapper hydrates bounded Claude hook metadata before scanning, so one-shot output can include Claude phase history captured by the local server. diff --git a/docs/configuration.md b/docs/configuration.md index 1b4e7f6..d1fd85f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,199 +1,151 @@ # Configuration -All configuration is via environment variables. +All configuration uses environment variables. + +## Server -## Variables - `CONSENSUS_HOST` - Default: `127.0.0.1` - - Bind address for the server. + - HTTP bind address. - `CONSENSUS_PORT` - Default: `8787` - - Port for the HTTP server. + - HTTP and WebSocket port. - `CONSENSUS_UI_PORT` - Default: `5173` - - Port for the Vite dev server when running `npm run dev`. + - Vite development port. - `CONSENSUS_POLL_MS` - - Default: `250` - - Poll interval for process presence scans. + - Process-presence scan interval. - `CONSENSUS_SCAN_TIMEOUT_MS` - Default: `5000` - - Max time (ms) for a scan tick before timing out (guardrail for stuck scans). + - Maximum scan duration. - `CONSENSUS_SCAN_STALL_MS` - - Default: `60%` of `CONSENSUS_SCAN_TIMEOUT_MS` (min `250`) - - Emit a stall warning/metric when a scan exceeds this duration. + - Default: 60% of the scan timeout, minimum `250`. - `CONSENSUS_SCAN_STALL_CHECK_MS` - - Default: `min(1000, CONSENSUS_SCAN_STALL_MS)` (min `250`) - - Interval for checking whether a scan is stalling. -- `CONSENSUS_OTEL_ENABLED` - - Default: disabled - - Set to `1` to enable Effect OpenTelemetry tracing/metrics (dev-only rollout). -- `CONSENSUS_OTEL_ENDPOINT` - - Default: unset - - OTLP/HTTP base endpoint (e.g., `http://localhost:4318`). When unset, exporters fall back to console. -- `CONSENSUS_OTEL_SERVICE_NAME` - - Default: `consensus-cli` - - Service name for telemetry resource. -- `CONSENSUS_OTEL_ENV` - - Default: `development` - - Deployment environment. -- `CONSENSUS_OTEL_VERSION` - - Default: package version (or `unknown`). - - Service version for telemetry resource. -- `CONSENSUS_OTEL_SAMPLE_RATIO` - - Default: `1` - - Trace sampling ratio (0–1). Prod can lower to `0.1` later. -- `CONSENSUS_OTEL_METRIC_INTERVAL_MS` - - Default: `10000` - - Metric export interval in milliseconds. -- `CONSENSUS_OTEL_CONSOLE_FALLBACK` - - Default: enabled - - Set to `0` to disable console exporters when OTLP endpoint is unset. + - Stall-check interval. + +## Codex + - `CONSENSUS_CODEX_HOME` - - Default: `~/.codex` - - Override Codex home directory. + - Default: `~/.codex`. - `CONSENSUS_CODEX_NOTIFY_INSTALL` - - Optional. If set, consensus will run `codex config set -g notify=[""]` on startup. - - Intended for wiring Codex TUI `notify` hook to consensus without manual setup. - - Use `CONSENSUS_CODEX_NOTIFY_INSTALL_TIMEOUT_MS` to cap install time (default 5000). - - Set to `0`, `false`, or `off` to disable the auto-install. + - Deprecated compatibility path. Use `npx consensus-cli setup`. + - Values `0`, `false`, or `off` disable it. +- `CONSENSUS_CODEX_NOTIFY_INSTALL_TIMEOUT_MS` + - Default: `5000`. - `CONSENSUS_CODEX_WATCH_POLL` - - Default: enabled - - Set to `0` to disable polling for Codex JSONL watch events. Polling is the default because native FS events are unreliable for these files. + - Set to `0` to disable polling-based JSONL watching. - `CONSENSUS_CODEX_WATCH_INTERVAL_MS` - - Default: `1000` - - Polling interval (ms) for Codex JSONL watcher when polling is enabled. + - Default: `1000`. - `CONSENSUS_CODEX_WATCH_BINARY_INTERVAL_MS` - - Default: `CONSENSUS_CODEX_WATCH_INTERVAL_MS` - - Polling interval (ms) for binary file changes when polling is enabled. + - Defaults to the watch interval. +- `CONSENSUS_CODEX_EVENT_ACTIVE_MS` + - Recent-event activity window. +- `CONSENSUS_CODEX_ACTIVE_HOLD_MS` + - Default: `3000`. +- `CONSENSUS_CODEX_INFLIGHT_IDLE_MS` + - Default: `30000`; set `0` to disable. +- `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS` + - Default: `3000`. +- `CONSENSUS_CODEX_FILE_FRESH_MS` + - JSONL freshness window. +- `CONSENSUS_CODEX_STALE_FILE_MS` + - Default: `120000`. +- `CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS` + - Defaults to the in-flight timeout. + +Current Codex documentation defines `notify` as an array of command arguments in the user-level `~/.codex/config.toml`. Project-local config does not support `notify`. + +## OpenCode + - `CONSENSUS_OPENCODE_HOST` - - Default: `127.0.0.1` - - OpenCode server host. + - Default: `127.0.0.1`. - `CONSENSUS_OPENCODE_PORT` - - Default: `4096` - - OpenCode server port. + - Default: `4096`. - `CONSENSUS_OPENCODE_TIMEOUT_MS` - - Default: `5000` - - Timeout for OpenCode HTTP requests. + - Default: `5000`. - `CONSENSUS_OPENCODE_AUTOSTART` - - Default: enabled - - Set to `0` to disable OpenCode server autostart. + - Default: enabled for the live dashboard. + - Set to `0` to disable. + - `consensus graph` disables autostart internally regardless of this setting. - `CONSENSUS_OPENCODE_EVENTS` - - Default: enabled - - Set to `0` to disable OpenCode event stream. + - Set to `0` to disable SSE activity ingestion. - `CONSENSUS_OPENCODE_HOME` - - Default: `~/.local/share/opencode` - - Override OpenCode storage directory. + - Default: `~/.local/share/opencode`. - `CONSENSUS_OPENCODE_EVENT_ACTIVE_MS` - - Default: `0` - - OpenCode event window before dropping to idle. + - Recent-event activity window. - `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS` - - Default: `3000` - - OpenCode hold window after activity. + - Default: `3000`. - `CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS` - - Default: `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS` - - OpenCode in-flight idle timeout in ms before dropping to idle if no activity is observed. + - In-flight idle decay window. - `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS` - - Default: `15000` - - Hard timeout (ms) used to clear OpenCode in-flight when no fresh events are observed. -- `CONSENSUS_PROCESS_MATCH` - - Default: unset - - Regex to match process name or command line. + - Default: `15000`. - `CONSENSUS_DEBUG_OPENCODE` - - Default: unset - - Set to `1` to log OpenCode server discovery (debug only). + - Set to `1` for discovery logs. + +## Claude Code + +- `CONSENSUS_CLAUDE_EVENT_TTL_MS` + - Default: `1800000` (30 minutes). + - In-memory and hydrated metadata age limit. +- `CONSENSUS_CLAUDE_INFLIGHT_TIMEOUT_MS` + - Default: `15000`. +- `CONSENSUS_CLAUDE_ACTIVE_HOLD_MS` + - Default: `3000`. +- `CONSENSUS_CLAUDE_EVENT_LOG` + - Default: `~/.consensus/claude-events.jsonl`. + - Set a custom local path, or `0`, `false`, or `off` to disable cross-process graph history. +- `CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES` + - Default: `1048576` (1 MiB). + - Minimum accepted value: 64 KiB. + - When the file exceeds the bound, Consensus keeps roughly the newest half. + +The Claude log contains metadata only. It excludes prompts, assistant text, message deltas, tool input/output, transcript paths, task descriptions, and error details. + +## Privacy and matching + - `CONSENSUS_REDACT_PII` - - Default: enabled - - Set to `0` to disable redaction. -- `ACTIVITY_TEST_MODE` - - Default: disabled - - Set to `1` to enable test-only activity injection endpoints under `/__test`. -- `CONSENSUS_CODEX_EVENT_ACTIVE_MS` - - Default: `30000` - - Codex event window before dropping to idle. -- `CONSENSUS_CODEX_MTIME_ACTIVE_MS` - - Default: `750` - - Treat recent Codex JSONL file mtime as activity within this window (bridges log write lag). -- `CONSENSUS_CODEX_ACTIVE_HOLD_MS` - - Default: `3000` - - Codex hold window after activity. -- `CONSENSUS_CODEX_INFLIGHT_GRACE_MS` - - Default: `750` - - Codex in-flight grace window after the last in-flight signal (prevents brief idle flicker). -- `CONSENSUS_CODEX_STRICT_INFLIGHT` - - Default: disabled - - Set to `1` to require explicit in-flight signals from logs (disables CPU/mtime bridging). -- `CONSENSUS_CODEX_INFLIGHT_IDLE_MS` - - Default: `30000` - - Idle timeout to clear Codex in-flight when activity is stale. Set to `0` to disable. -- `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS` - - Default: `3000` - - Hard timeout to clear Codex in-flight if no recent signals and file is not fresh. -- `CONSENSUS_CODEX_FILE_FRESH_MS` - - Default: `10000` - - Treat recent Codex JSONL file mtime within this window as fresh and keep in-flight on. -- `CONSENSUS_CODEX_CPU_SUSTAIN_MS` - - Default: `500` - - Require sustained Codex CPU activity (ms) before marking active when logs are missing. -- `CONSENSUS_CODEX_CPU_SPIKE` - - Default: derived (`max(cpuThreshold * 10, 25)`) - - Codex CPU spike threshold for immediate active state when logs are missing. -- `CONSENSUS_CODEX_STALE_FILE_MS` - - Default: `120000` - - Treat Codex JSONL files older than this as stale to prevent stale sessions from staying active. -- `CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS` - - Default: `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS` - - Ignore Codex events older than this when setting in-flight signals (prevents stale sessions after restart). + - Default: enabled. + - Set to `0` to disable normal text redaction. + - Graph identity fields remain opaque even when text redaction is disabled. +- `CONSENSUS_PROCESS_MATCH` + - Optional regular expression for Codex process matching. +- `CONSENSUS_INCLUDE_CODEX_VENDOR` + - Set to `1` or `true` to include standalone vendor processes. + +## Cache and activity + - `CONSENSUS_PROCESS_CACHE_MS` - - Default: `1000` - - Process cache TTL (ms) for full scans. + - Default: `1000`. - `CONSENSUS_PROCESS_CACHE_FAST_MS` - - Default: `500` - - Process cache TTL (ms) for fast scans. + - Default: `500`. - `CONSENSUS_SESSION_CACHE_MS` - - Default: `1000` - - Codex session list cache TTL (ms) for full scans. + - Default: `1000`. - `CONSENSUS_SESSION_CACHE_FAST_MS` - - Default: `500` - - Codex session list cache TTL (ms) for fast scans. + - Default: `500`. - `CONSENSUS_EVENT_ACTIVE_MS` - - Default: `300000` - - Window after the last event to mark an agent active. -- Claude activity is hook-driven; CPU thresholds are legacy and ignored for TUI activity. -- `CONSENSUS_CPU_ACTIVE` - - Default: `1` - - CPU threshold for marking an agent active. -- `CONSENSUS_CLAUDE_CPU_ACTIVE` - - Default: `1` - - Claude CPU threshold override. -- `CONSENSUS_CLAUDE_CPU_SUSTAIN_MS` - - Default: `1000` - - Claude sustained CPU window in ms. -- `CONSENSUS_CLAUDE_CPU_SPIKE` - - Default: derived - - Claude spike threshold override. -- `CONSENSUS_CLAUDE_EVENT_TTL_MS` - - Default: `1800000` - - Claude hook event TTL before a session is pruned. -- `CONSENSUS_CLAUDE_INFLIGHT_TIMEOUT_MS` - - Default: `15000` - - Clear Claude in-flight if no hook activity arrives within this window. -- `CONSENSUS_CLAUDE_ACTIVE_HOLD_MS` - - Default: `3000` - - Claude-specific active hold window to smooth brief hook gaps (reduce flicker). -- `CONSENSUS_CLAUDE_START_ACTIVE_MS` - - Default: `1200` - - Legacy (not used for hook-driven Claude activity). + - General recent-event window. - `CONSENSUS_ACTIVE_HOLD_MS` - - Default: `3000` - - Keep agents active for this long after activity. + - Default: `3000`. - `CONSENSUS_IDLE_HOLD_MS` - - Default: `200` - - Hold an agent in idle briefly after spans end (prevents idle flicker). + - Default: `200`. - `CONSENSUS_SPAN_STALE_MS` - - Default: `15000` - - Consider agent spans stale after this duration with no progress updates. + - Default: `15000`. + +## Observability + +- `CONSENSUS_OTEL_ENABLED` +- `CONSENSUS_OTEL_ENDPOINT` +- `CONSENSUS_OTEL_SERVICE_NAME` +- `CONSENSUS_OTEL_ENV` +- `CONSENSUS_OTEL_VERSION` +- `CONSENSUS_OTEL_SAMPLE_RATIO` +- `CONSENSUS_OTEL_METRIC_INTERVAL_MS` +- `CONSENSUS_OTEL_CONSOLE_FALLBACK` +- `CONSENSUS_PROFILE` +- `CONSENSUS_PROFILE_MS` +- `CONSENSUS_DEBUG_ACTIVITY` + +## Test mode -## Example -```bash -CONSENSUS_PORT=8790 CONSENSUS_POLL_MS=250 npm run dev -``` +- `ACTIVITY_TEST_MODE=1` + - Enables test-only activity routes. diff --git a/docs/data-inventory.md b/docs/data-inventory.md index 4c0baed..d49e4b4 100644 --- a/docs/data-inventory.md +++ b/docs/data-inventory.md @@ -1,16 +1,64 @@ # Data inventory -## Data collected -- Process metadata: pid, cmdline, cpu, memory, cwd. -- Recent Codex session event summaries (best-effort). -- Claude hook events (session id, cwd, transcript path, event type). +## Data observed -## Storage -- No persistent storage outside the local machine. -- Data is held in memory for live rendering only. +- Process metadata: PID, command line, CPU, memory, and working directory. +- Recent Codex event summaries and local session metadata. +- OpenCode session, status, message-summary, and SSE event metadata. +- Claude hook metadata: event type, session ID, timestamp, working directory, transcript path, notification type, tool name, agent type, final-message marker, and current activity state. -## Retention -- No historical retention; data refreshes per poll. +Raw prompt, assistant, tool-input, and tool-output content can exist in provider-owned logs or hook payloads. Consensus normalizes only bounded summaries for its live snapshot. -## Deletion -- Stop the server to clear in-memory data. +## Graph export + +Graph JSON contains: + +- opaque agent keys +- provider and repository labels +- turn-segment numbers +- normalized phases and event types +- transition observation counts +- loop membership and current state + +Graph relationship IDs do not contain raw Codex session paths or raw working directories. + +## Claude metadata storage + +To make one-shot `scan` and `graph` commands see hook history captured by the running server, Consensus writes a bounded local metadata file at: + +```text +~/.consensus/claude-events.jsonl +``` + +Stored fields: + +- schema version +- event type +- session ID +- timestamp +- opaque SHA-256 working-directory key +- notification type +- tool name +- agent type +- final-message marker + +Not stored: + +- raw working directory +- transcript path +- prompt text +- assistant text or `MessageDisplay.delta` +- message or turn IDs +- tool input or output +- task subject or description +- error details + +The file is mode `0600`, the directory is created with mode `0700`, and the default bound is 1 MiB. + +## Retention and deletion + +- Provider event arrays are bounded in memory. +- Claude metadata older than `CONSENSUS_CLAUDE_EVENT_TTL_MS` is ignored during hydration. +- Set `CONSENSUS_CLAUDE_EVENT_LOG=0` to disable new writes. +- Stop Consensus and delete `~/.consensus/claude-events.jsonl` to clear persisted Claude metadata. +- Stop the server to clear in-memory state. diff --git a/docs/graphs-and-loops.md b/docs/graphs-and-loops.md new file mode 100644 index 0000000..803bc16 --- /dev/null +++ b/docs/graphs-and-loops.md @@ -0,0 +1,139 @@ +# Graphs and loops + +Consensus treats an observed agent loop as a cycle inside a turn-scoped execution graph. + +- **Graph:** which agents and normalized work phases were observed, and which transitions occurred? +- **Loop:** which transitions within one observed turn return to an earlier phase? + +Consensus remains read-only. It reports local activity and does not start, stop, retry, route, approve, or otherwise control agent work. + +## Commands + +```bash +npx consensus-cli graph +npx consensus-cli graph --json +npx consensus-cli graph --snapshot snapshot.json +cat snapshot.json | npx consensus-cli graph --snapshot - +``` + +Live graph inspection disables OpenCode autostart before loading the scanner. Saved snapshots receive strict structural validation before graph construction. + +## Version 1 data model + +### Agent nodes + +Each observed process or session produces one `agent` node. Graph output derives an opaque `agentKey` from provider plus local identity. It never includes the original Codex session path in graph IDs or relationship fields. + +### Turn segments + +Step nodes are scoped to an observed turn segment: + +```text +step::s: +``` + +A new prompt opens a new segment. Explicit end events such as `turn.completed`, `Stop`, `StopFailure`, and `SessionEnd` close the current segment. No transition edge crosses a segment boundary. + +This rule prevents a routine second prompt from closing a cycle through the prior turn. + +### Phases + +Provider events normalize to: + +- `prompt` +- `model` +- `tool` +- `command` +- `edit` + +Unknown non-lifecycle events remain visible under their normalized event name. Lifecycle and transport records are excluded, including session/thread/turn/response status events, token counts, heartbeats, connection events, compaction, notifications, and standalone configuration or filesystem notifications. + +### Edges + +- `contains`: agent to turn-scoped step. +- `transition`: one phase to the next phase in the same segment. + +Adjacent events in the same phase collapse into one visit. Repeated non-adjacent transitions increase the edge observation count. + +### Loops + +Consensus runs strongly connected component detection over transition edges. + +- A self-edge is a `self` loop. +- Two or more mutually reachable step nodes form a `cycle` loop. + +`transitionObservations` is the sum of internal edge observations. It is not a completed-iteration count and must not be used as a retry budget without a separate traversal model. + +### Current state + +Only the latest observed step for an agent is marked `current` and receives the agent's live `active`, `idle`, or `error` state. Older step nodes remain idle, even if their phase names match the current phase in a later turn. A historical loop therefore cannot become active merely because the agent is active elsewhere. + +`hadError` records whether an error event occurred on a step without converting that historical step into a current error state. + +## Provider coverage + +Provider contracts were checked on July 18, 2026. + +### Codex + +Consensus uses the user-level Codex notify command plus bounded local JSONL summaries. Current Codex configuration documents `notify` as an array of command arguments in `~/.codex/config.toml`; project-local config does not support it. + +Source: + +### OpenCode + +Consensus reads the local HTTP server, `/global/event` and `/event` SSE streams, session status, and retained message activity. Version 1 does not infer parent or delegation edges. The current server contract exposes `parentID` on session creation and `/session/:id/children`, which can support explicit parent/subagent edges later. + +Source: + +### Claude Code + +Consensus receives hook events through `src/claudeHook.ts`. Current Claude Code hooks include `MessageDisplay`, `PostToolBatch`, `PermissionDenied`, `TaskCreated`, `TaskCompleted`, and `StopFailure` in addition to the original session, prompt, tool, subagent, and stop events. + +`MessageDisplay` can fire several times while one assistant message streams. Consensus uses partial batches only as activity pulses and retains the final marker. It never stores `delta` content. + +`UserPromptSubmit` defines the graph turn boundary. `UserPromptExpansion` is treated as activity only and is not retained as a second prompt phase, avoiding a duplicate segment for direct slash-command expansion. + +The metadata log retains only: + +- event type +- session ID +- timestamp +- opaque working-directory key +- notification type +- tool name +- agent type +- final-message marker + +It excludes prompt text, assistant text, message and turn IDs, tool input and output, transcript path, task subject and description, and error details. + +Source: + +### OpenClaw framing + +OpenClaw's documented agent loop remains a serialized runtime loop around model inference, tools, streaming, and persistence. Consensus models that loop as turn-scoped cycles inside a broader observed graph. + +Source: + +## Retention and privacy + +- Snapshot events remain bounded by each provider adapter. +- Claude cross-process metadata defaults to `~/.consensus/claude-events.jsonl`. +- The file mode is set to `0600`; its directory is created with mode `0700`. +- Default maximum size is 1 MiB. When exceeded, the log keeps roughly the newest half. +- Set `CONSENSUS_CLAUDE_EVENT_LOG=0` to disable it. +- Stop the server and remove the metadata file to clear retained Claude graph history. + +## Current limits + +- The graph reflects retained evidence, not a full workflow definition. +- Turn boundaries are best effort when a provider omits explicit lifecycle events; every prompt still starts a new segment. +- Version 1 does not represent parent, delegation, approval, retry, handoff, or cross-agent causal edges. +- Loop duration, iteration count, stop-rule status, and budget use need additional event models. + +## Next slices + +1. Add explicit parent and child edges from provider-supported identifiers. +2. Stream graph deltas through the server and render them in the browser. +3. Compare declared workflow graphs with runtime-created task graphs. +4. Add traversal-aware loop counts, budgets, stop rules, and stuck-loop alerts. diff --git a/docs/harness-providers.md b/docs/harness-providers.md new file mode 100644 index 0000000..ae2a1ca --- /dev/null +++ b/docs/harness-providers.md @@ -0,0 +1,145 @@ +# Harness providers + +Consensus models the **agent harness or runtime** separately from the model or tool provider. + +Examples: + +- `openai`, `anthropic`, `minimax`, and `moonshot` may provide models or tools. +- Codex, Claude Code, OpenClaw, Hermes, Kimi Code, Cursor Agent, Factory Droid, Gemini CLI, GitHub Copilot CLI, and Warp Oz provide execution loops or agent surfaces. + +This distinction prevents a MiniMax model used inside OpenClaw from being reported as a separate agent loop unless a distinct MiniMax runtime is actually running. + +## Coverage levels + +Consensus reports coverage by evidence source rather than a single supported flag: + +- **Native events:** structured local runtime, server, RPC, or log events. +- **Hooks:** lifecycle callbacks configured in the harness. +- **Structured stream:** machine-readable CLI output attached to a run. +- **Remote API:** run status and history fetched from a hosted agent service. +- **Process only:** the local process is visible, but no step history is attached. +- **Tool only:** the process supplies a model or tool to another harness rather than owning the loop. +- **Manual:** an IDE or extension adapter is required; process matching would be unreliable. + +## Current registry + +| Harness | Class | Best evidence source | Current Consensus depth | +| --- | --- | --- | --- | +| Codex | Local CLI/runtime | Notify events and bounded local session records | Event graph | +| OpenCode | Local server/TUI | HTTP API and SSE event streams | Event graph | +| Claude Code | Local CLI | Lifecycle hooks | Event graph | +| OpenClaw | Local server/runtime | Gateway RPC run, tool, assistant, and lifecycle streams | Process discovery; native adapter next | +| Hermes Agent | Local CLI/gateway | Gateway, plugin, and shell hooks | Process discovery plus shared hook graph | +| Kimi Code | Local CLI | Lifecycle hooks | Process discovery plus shared hook graph | +| MiniMax CLI | Tool/model integration | CLI invocation inside another harness | Tool-only discovery | +| Cursor Agent | Local CLI/remote agent | Partial hooks and `stream-json` CLI output | Process discovery; stream adapter next | +| Warp Oz | Local and remote agent | Oz API/SDK run status and transcripts | Process discovery; remote API adapter next | +| Factory Droid | Local CLI | Lifecycle hooks | Process discovery plus shared hook graph | +| Qwen Code | Local CLI | Command or HTTP lifecycle hooks | Process discovery plus shared hook graph | +| Gemini CLI | Local CLI | Lifecycle hooks | Process discovery plus shared hook graph | +| GitHub Copilot CLI | Local CLI | Lifecycle hooks | Process discovery plus shared hook graph | +| Antigravity CLI | Local CLI | No Consensus event adapter yet | Process discovery | +| Aider | Local CLI | No Consensus event adapter yet | Process discovery | +| Goose | Local CLI | No Consensus event adapter yet | Process discovery | +| Amp | Local CLI | No Consensus event adapter yet | Process discovery | +| Amazon Q Developer | Local CLI | No Consensus event adapter yet | Process discovery | +| Kiro | Local CLI/IDE | No Consensus event adapter yet | Process discovery | +| OpenHands | Local or hosted runtime | Runtime/API integration needed | Process discovery | +| Cline | IDE extension | Extension adapter | Registry only | +| Roo Code | IDE extension | Extension adapter | Registry only | +| Windsurf Cascade | IDE agent | IDE adapter | Registry only | +| JetBrains Junie | IDE agent | IDE adapter | Registry only | +| Replit Agent | Remote agent | Remote API adapter | Registry only | +| Zed Agent | IDE agent | IDE adapter | Registry only | + +## Process discovery contract + +The snapshot and graph scanners detect exact executable names. They do not search arbitrary prompt text for provider names. + +Examples: + +- `openclaw gateway` becomes `openclaw-server`. +- `openclaw agent` becomes `openclaw-cli`. +- `hermes gateway` becomes `hermes-server`. +- `oz agent run` becomes `warp-cli`; unrelated `oz` commands are ignored. +- `q chat` becomes `amazon-q-cli`; unrelated `q` processes are ignored. +- Cursor detection uses `cursor-agent`; the generic executable name `agent` is not matched. +- Windows `.exe` names receive the same exact-token matching as Unix executables. + +Generic process snapshots deliberately do not export raw command lines. Inline prompts, session arguments, and other command payloads are replaced by a stable harness label. Raw working-directory and session values are used only to derive redacted display fields and opaque correlation keys. + +Generic discovery uses a short process and usage cache so a fast dashboard poll does not issue a second full process query for every harness. Set `CONSENSUS_GENERIC_PROCESS_CACHE_MS=0` to disable this cache. + +## Shared hook collector + +The metadata-only collector is: + +```text +node /path/to/consensus-cli/dist/harnessHook.js +``` + +Supported hook IDs are: + +```text +claude +hermes +kimi +factory +gemini +qwen +copilot +``` + +The collector accepts provider JSON on standard input and writes normalized metadata to `~/.consensus/harness-events.jsonl` by default. It does not retain prompt text, assistant text, tool input or output, shell commands, raw paths, raw session identifiers, raw turn identifiers, or error details. + +Gemini CLI and GitHub Copilot CLI require valid JSON on hook stdout. Consensus returns a compact one-line `{}` acknowledgment for those two harnesses, including malformed or ignored input. Passive command-hook providers remain silent. + +The shared log: + +- Uses mode `0600` in a directory created with mode `0700`. +- Uses an inter-process lock for append and trim operations. +- Deduplicates repeat deliveries both in memory and against the recent on-disk tail. +- Bounds UTF-8 byte size and keeps complete JSON lines. +- Rejects malformed, expired, and far-future records during replay. +- Can be disabled with `CONSENSUS_HARNESS_EVENT_LOG=0`. + +## Session assignment + +Hook history attaches to a process in this order: + +1. Exact opaque session key. +2. A working-directory key only when exactly one unclaimed session matches. +3. A one-process/one-session fallback only when both sides are unambiguous. + +When several sessions share a directory, Consensus leaves them unattached rather than assigning the newest session to an arbitrary process. + +## Adapter order + +1. **Shared hook adapter:** Claude, Hermes, Kimi Code, Factory Droid, Gemini CLI, Qwen Code, and GitHub Copilot CLI feed one normalized, metadata-only event model. +2. **OpenClaw adapter:** consume Gateway RPC lifecycle, assistant, and tool streams keyed by `runId` and session. +3. **Cursor adapter:** ingest `--output-format stream-json` for headless runs and use supported CLI hooks where available. +4. **Warp adapter:** read Oz run status, transcripts, and metadata through the official API/SDK. +5. **IDE adapters:** add explicit extension bridges for Cline, Roo Code, Windsurf, Junie, and Zed rather than guessing from Electron helper processes. +6. **Remote adapters:** add authenticated, opt-in connectors for Replit Agent and other hosted runtimes. + +## Product boundary + +Consensus does not claim event-level support merely because it recognizes a process. It also does not infer that a model vendor owns the active loop. Parent, delegation, handoff, retry, approval, and cross-harness causal edges require provider evidence. + +## Sources checked + +Provider contracts were checked on July 19, 2026: + +- Codex configuration: +- Claude Code hooks: +- OpenCode server and SSE: +- OpenClaw agent loop and runtimes: and +- Hermes hooks: +- Kimi Code hooks: +- MiniMax CLI: +- Cursor CLI: +- Warp Oz CLI/API: +- Factory Droid hooks: +- Gemini CLI hooks: +- Qwen Code hooks: +- GitHub Copilot hooks: diff --git a/docs/threat-model.md b/docs/threat-model.md index f982459..3bce4ca 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -1,19 +1,78 @@ # Threat model ## Entry points -- CLI args and environment variables. -- HTTP server endpoints. + +- CLI arguments and snapshot files. +- Environment variables. +- Local HTTP and WebSocket endpoints. +- Codex notify payloads. +- OpenCode HTTP/SSE responses. +- Claude hook payloads. ## Trust boundaries -- Local machine only by default. -- No external services required. -## Secrets handling -- The app does not require secrets. -- Redaction protects common PII patterns. +- Consensus binds to localhost by default. +- Provider logs, events, and user-supplied snapshots are untrusted input. +- The project does not require API secrets. +- Exposing the server beyond localhost creates a new trust boundary and is not the default deployment model. + +## Main risks and controls + +### Path and identity disclosure + +Risk: provider identities can contain home directories or raw session paths. + +Controls: + +- Graph keys use truncated SHA-256 identifiers derived from provider plus local identity. +- Raw identities are not emitted in graph node or loop relationship fields. +- Claude persisted metadata uses an opaque working-directory key rather than the path. + +### Content retention + +Risk: hook payloads can contain prompts, assistant text, tool input/output, transcript paths, tasks, and error details. + +Controls: + +- The Claude hook adapter selects only small metadata fields before posting to Consensus. +- `MessageDisplay.delta`, tool input/output, prompt content, task descriptions, and error details are not retained. +- The local metadata log is bounded and can be disabled. + +### Local file access + +Risk: another local user reads retained metadata. + +Controls: + +- Claude metadata directory is created with mode `0700`. +- Metadata file is set to mode `0600` after writes and rotation. +- No network upload path exists. + +### Graph false positives + +Risk: lifecycle records or separate turns form a false cycle. + +Controls: + +- Lifecycle and transport events are excluded from graph phases. +- Every prompt opens a new segment. +- Transition edges never cross segment boundaries. +- Only the latest step receives live state. + +### Side effects from inspection + +Risk: a read-only graph command starts an OpenCode server. + +Control: `consensus graph` sets `CONSENSUS_OPENCODE_AUTOSTART=0` before dynamically loading the scanner, then restores the prior environment value. + +### Malformed snapshots + +Risk: invalid JSON or fields crash graph inspection or misclassify providers. + +Control: the graph CLI validates timestamps, agents, provider kinds, states, and retained events before building a graph. -## Data in transit -- HTTP over localhost unless explicitly exposed. +## Logging -## Logging risks -- Avoid logging secrets; use redaction for summaries. +- Avoid logging secrets or content-bearing provider payloads. +- Use existing redaction for user-facing summaries. +- Do not treat URL encoding as anonymization. diff --git a/package.json b/package.json index 03cb885..0aac85b 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "build": "tsc && npm run build:client", "build:client": "cd public && vite build", "start": "node dist/server.js", - "scan": "node dist/scan.js", + "scan": "node dist/scanSnapshot.js", + "graph": "tsx src/cli.ts graph", "tail": "node dist/tail.js", "test": "npm run test:unit && npm run test:integration", "test:unit": "node --test --import tsx tests/unit/**/*.test.ts", diff --git a/public/src/lib/palette.ts b/public/src/lib/palette.ts index 9d85f29..d89dfa9 100644 --- a/public/src/lib/palette.ts +++ b/public/src/lib/palette.ts @@ -49,6 +49,22 @@ export const cliPalettes: Record = { accentSoft: 'rgba(127, 183, 255, 0.35)', glow: '127, 183, 255', }, + other: { + agent: { + active: { top: '#6f5b91', left: '#55456f', right: '#46395c', stroke: '#b89be8' }, + idle: { top: '#3d3a49', left: '#302d3a', right: '#282530', stroke: '#756d88' }, + error: { top: '#814143', left: '#693436', right: '#562b2d', stroke: '#dc6c72' }, + }, + server: { + active: { top: '#665d78', left: '#4f485e', right: '#423c4e', stroke: '#aa9bc3' }, + idle: { top: '#393742', left: '#2d2b34', right: '#25232b', stroke: '#6b6578' }, + error: { top: '#814143', left: '#693436', right: '#562b2d', stroke: '#dc6c72' }, + }, + accent: '#b89be8', + accentStrong: 'rgba(184, 155, 232, 0.6)', + accentSoft: 'rgba(184, 155, 232, 0.35)', + glow: '184, 155, 232', + }, }; export const stateOpacity: Record = { @@ -61,7 +77,8 @@ export function cliForAgent(agent: AgentSnapshot): CliType { const kind = agent.kind || ''; if (kind.startsWith('opencode')) return 'opencode'; if (kind.startsWith('claude')) return 'claude'; - return 'codex'; + if (kind === 'tui' || kind === 'exec' || kind === 'app-server') return 'codex'; + return 'other'; } export function isServerKind(kind: string): boolean { @@ -71,29 +88,29 @@ export function isServerKind(kind: string): boolean { export function paletteFor(agent: AgentSnapshot): TileColors { const cli = cliForAgent(agent); - const palette = cliPalettes[cli] ?? cliPalettes.codex; + const palette = cliPalettes[cli] ?? cliPalettes.other; const scope = isServerKind(agent.kind) ? palette.server : palette.agent; return scope[agent.state] ?? scope.idle; } export function accentFor(agent: AgentSnapshot): string { const cli = cliForAgent(agent); - return (cliPalettes[cli] ?? cliPalettes.codex).accent; + return (cliPalettes[cli] ?? cliPalettes.other).accent; } export function accentStrongFor(agent: AgentSnapshot): string { const cli = cliForAgent(agent); - return (cliPalettes[cli] ?? cliPalettes.codex).accentStrong; + return (cliPalettes[cli] ?? cliPalettes.other).accentStrong; } export function accentSoftFor(agent: AgentSnapshot): string { const cli = cliForAgent(agent); - return (cliPalettes[cli] ?? cliPalettes.codex).accentSoft; + return (cliPalettes[cli] ?? cliPalettes.other).accentSoft; } export function accentGlow(agent: AgentSnapshot, alpha: number): string { const cli = cliForAgent(agent); - const tint = (cliPalettes[cli] ?? cliPalettes.codex).glow; + const tint = (cliPalettes[cli] ?? cliPalettes.other).glow; return `rgba(${tint}, ${alpha})`; } diff --git a/public/src/types.ts b/public/src/types.ts index 8e6ddf7..d080dbf 100644 --- a/public/src/types.ts +++ b/public/src/types.ts @@ -1,5 +1,5 @@ export type AgentState = 'active' | 'idle' | 'error'; -export type CliType = 'codex' | 'opencode' | 'claude'; +export type CliType = 'codex' | 'opencode' | 'claude' | 'other'; export interface AgentSummary { current?: string; @@ -12,7 +12,10 @@ export interface AgentSummary { export interface AgentEvent { ts: number; + type?: string; summary: string; + isError?: boolean; + turnId?: string | number; } export interface AgentSnapshot { @@ -40,23 +43,24 @@ export interface AgentSnapshot { } export interface ActivityCounts { - active?: number; - idle?: number; - error?: number; + active: number; + idle: number; + error: number; } export interface ActivityTransitionSummary { - total?: number; - byReason?: Record; - byState?: Record; + total: number; + byReason: Record; + byState: Record; } export interface SnapshotMeta { + pollMs?: number; opencode?: { - ok?: boolean; + ok: boolean; reachable?: boolean; - error?: string; status?: number; + error?: string; }; activity?: { counts?: Record; @@ -71,34 +75,47 @@ export interface SnapshotPayload { meta?: SnapshotMeta; } -export type DeltaOp = - | { op: 'upsert'; id: string; value: AgentSnapshot } - | { op: 'remove'; id: string } - | { op: 'meta'; value: SnapshotMeta | null } - | { op: 'ts'; value: number }; +export interface TileColors { + top: string; + left: string; + right: string; + stroke: string; +} + +export interface CliPalette { + agent: Record; + server: Record; + accent: string; + accentStrong: string; + accentSoft: string; + glow: string; +} export interface WsHelloMessage { v: 1; t: 'hello'; - role: 'viewer'; - enc: 'json'; - lastSeq?: number; + protocol: 'json'; } export interface WsWelcomeMessage { v: 1; t: 'welcome'; - enc: 'json'; - serverTime: number; + seq: number; } export interface WsSnapshotMessage { v: 1; t: 'snapshot'; seq: number; - data: SnapshotPayload; + payload: SnapshotPayload; } +export type DeltaOp = + | { op: 'upsert'; id: string; value: AgentSnapshot } + | { op: 'remove'; id: string } + | { op: 'meta'; value: SnapshotMeta | null } + | { op: 'ts'; value: number }; + export interface WsDeltaMessage { v: 1; t: 'delta'; @@ -109,51 +126,18 @@ export interface WsDeltaMessage { export interface WsPingMessage { v: 1; t: 'ping'; + seq: number; } export interface WsPongMessage { v: 1; t: 'pong'; - ts: number; + seq: number; } export type WsClientMessage = WsHelloMessage | WsPongMessage; -export type WsServerMessage = - | WsWelcomeMessage - | WsSnapshotMessage - | WsDeltaMessage +export type WsServerMessage = + | WsWelcomeMessage + | WsSnapshotMessage + | WsDeltaMessage | WsPingMessage; - -export interface TileColors { - top: string; - left: string; - right: string; - stroke: string; -} - -export interface CliPalette { - agent: Record; - server: Record; - accent: string; - accentStrong: string; - accentSoft: string; - glow: string; -} - -export type WsStatus = 'connecting' | 'live' | 'stale' | 'error' | 'disconnected'; - -export interface ViewState { - x: number; - y: number; - scale: number; -} - -export interface Coordinate { - x: number; - y: number; -} - -export interface ScreenPoint { - x: number; - y: number; -} diff --git a/src/claude/types.ts b/src/claude/types.ts index 0a86c9c..4d06771 100644 --- a/src/claude/types.ts +++ b/src/claude/types.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import type { EventSummary, WorkSummary } from "../types.js"; export const ClaudeEventSchema = Schema.Struct({ type: Schema.String, @@ -6,6 +7,9 @@ export const ClaudeEventSchema = Schema.Struct({ cwd: Schema.optional(Schema.String), transcriptPath: Schema.optional(Schema.String), notificationType: Schema.optional(Schema.String), + toolName: Schema.optional(Schema.String), + agentType: Schema.optional(Schema.String), + final: Schema.optional(Schema.Boolean), timestamp: Schema.Number, }); @@ -15,8 +19,13 @@ export interface ClaudeSessionState { sessionId: string; inFlight: boolean; lastActivityAt?: number; + lastEventAt?: number; lastSeenAt: number; cwd?: string; + cwdKey?: string; transcriptPath?: string; lastEvent?: string; + events: EventSummary[]; + summary: WorkSummary; + hasError?: boolean; } diff --git a/src/claudeHook.ts b/src/claudeHook.ts index 3bf5546..c092b88 100644 --- a/src/claudeHook.ts +++ b/src/claudeHook.ts @@ -9,6 +9,9 @@ type NormalizedEvent = { cwd?: string; transcriptPath?: string; notificationType?: string; + toolName?: string; + agentType?: string; + final?: boolean; timestamp: number; }; @@ -18,6 +21,10 @@ function readString(value: unknown): string | undefined { return trimmed ? trimmed : undefined; } +function readBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + async function readStdin(): Promise { if (process.stdin.isTTY) return ""; const chunks: Buffer[] = []; @@ -35,17 +42,18 @@ function normalizePayload(payload: RawPayload): NormalizedEvent | null { readString(payload.type); const sessionId = readString(payload.session_id) || readString(payload.sessionId); if (!hookEvent || !sessionId) return null; - const cwd = readString(payload.cwd); - const transcriptPath = readString(payload.transcript_path) || readString(payload.transcriptPath); - const notificationType = - readString(payload.notification_type) || readString(payload.notificationType); return { type: hookEvent, sessionId, - cwd, - transcriptPath, - notificationType, + cwd: readString(payload.cwd), + transcriptPath: + readString(payload.transcript_path) || readString(payload.transcriptPath), + notificationType: + readString(payload.notification_type) || readString(payload.notificationType), + toolName: readString(payload.tool_name) || readString(payload.toolName), + agentType: readString(payload.agent_type) || readString(payload.agentType), + final: readBoolean(payload.final), timestamp: Date.now(), }; } diff --git a/src/claudeSnapshot.ts b/src/claudeSnapshot.ts new file mode 100644 index 0000000..cd4fcc6 --- /dev/null +++ b/src/claudeSnapshot.ts @@ -0,0 +1,50 @@ +import type { AgentSnapshot, SnapshotPayload, WorkSummary } from "./types.js"; +import { + getClaudeActivityByCwd, + getClaudeActivityBySession, +} from "./services/claudeEvents.js"; + +function claudeSessionId(agent: AgentSnapshot): string | undefined { + for (const candidate of [agent.sessionPath, agent.identity]) { + if (!candidate?.startsWith("claude:")) continue; + const sessionId = candidate.slice("claude:".length).trim(); + if (sessionId) return sessionId; + } + return undefined; +} + +function mergeSummary( + existing: WorkSummary | undefined, + observed: WorkSummary | undefined +): WorkSummary | undefined { + if (!existing && !observed) return undefined; + return { ...(existing ?? {}), ...(observed ?? {}) }; +} + +export function attachClaudeEvents(snapshot: SnapshotPayload): SnapshotPayload { + const agents = snapshot.agents.map((agent) => { + if (!agent.kind.startsWith("claude")) return agent; + + const sessionId = claudeSessionId(agent); + const state = + (sessionId ? getClaudeActivityBySession(sessionId, snapshot.ts) : undefined) ?? + (agent.cwd ? getClaudeActivityByCwd(agent.cwd, snapshot.ts) : undefined); + if (!state) return agent; + + const summary = mergeSummary(agent.summary, state.summary); + return { + ...agent, + state: state.hasError ? "error" : agent.state, + lastEventAt: + typeof state.lastEventAt === "number" + ? Math.max(agent.lastEventAt ?? 0, state.lastEventAt) + : agent.lastEventAt, + lastActivityAt: state.lastActivityAt ?? agent.lastActivityAt, + doing: summary?.current ?? agent.doing, + summary, + events: state.events.slice(-20), + }; + }); + + return { ...snapshot, agents }; +} diff --git a/src/cli.ts b/src/cli.ts old mode 100644 new mode 100755 index 9c056fe..bb1e652 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,141 +1,162 @@ #!/usr/bin/env node -import { spawn } from "child_process"; -import fs from "fs"; -import { fileURLToPath } from "url"; -import path from "path"; -import process from "process"; -import { Effect, Console } from "effect"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import process from "node:process"; +import { Effect } from "effect"; import { annotateSpan, disposeObservability, runPromise, withSpan, } from "./observability/index.js"; +import { runGraph } from "./cli/graph.js"; +import { runHarnesses } from "./cli/harnesses.js"; import { runSetup } from "./cli/setup.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); - const args = process.argv.slice(2); -// Handle setup command -if (args[0] === "setup") { - runSetup() - .then(() => { +function runOneShot( + label: string, + action: () => Promise +): void { + action() + .then(async () => { + await disposeObservability().catch(() => undefined); process.exit(0); }) - .catch(() => { + .catch(async (error) => { + process.stderr.write(`[consensus] ${label} error: ${String(error)}\n`); + await disposeObservability().catch(() => undefined); process.exit(1); }); - // Exit early - don't start server -} else { +} -function readArg(name: string): string | undefined { - const index = args.findIndex((arg) => arg === name || arg.startsWith(`${name}=`)); - if (index === -1) return undefined; - const arg = args[index]; - if (arg.includes("=")) { - return arg.split("=").slice(1).join("="); +// Handle one-shot commands before starting the server. +if (args[0] === "setup") { + runOneShot("setup", runSetup); +} else if (args[0] === "graph") { + runOneShot("graph", () => runGraph(args.slice(1))); +} else if (args[0] === "harnesses") { + runOneShot("harnesses", () => runHarnesses(args.slice(1))); +} else { + function readArg(name: string): string | undefined { + const index = args.findIndex((arg) => arg === name || arg.startsWith(`${name}=`)); + if (index === -1) return undefined; + const arg = args[index]; + if (arg.includes("=")) { + return arg.split("=").slice(1).join("="); + } + return args[index + 1]; } - return args[index + 1]; -} -function hasFlag(name: string): boolean { - return args.includes(name); -} + function hasFlag(name: string): boolean { + return args.includes(name); + } -function printHelp(): void { - process.stdout.write(`consensus\n\n`); - process.stdout.write(`Usage:\n consensus [command] [options]\n\n`); - process.stdout.write(`Commands:\n`); - process.stdout.write(` setup Configure Codex notify hook (recommended first step)\n`); - process.stdout.write(` (default) Start the consensus server\n\n`); - process.stdout.write(`Options:\n`); - process.stdout.write(` --host Bind address (default 127.0.0.1)\n`); - process.stdout.write(` --port Port (default 8787)\n`); - process.stdout.write(` --poll Poll interval in ms\n`); - process.stdout.write(` --codex-home Override CODEX_HOME\n`); - process.stdout.write(` --codex-notify Install Codex notify hook at path\n`); - process.stdout.write(` --opencode-host OpenCode host (default 127.0.0.1)\n`); - process.stdout.write(` --opencode-port

OpenCode port (default 4096)\n`); - process.stdout.write(` --no-opencode-autostart Disable OpenCode server autostart\n`); - process.stdout.write(` --process-match Regex for process matching\n`); - process.stdout.write(` --no-redact Disable PII redaction\n`); - process.stdout.write(` -h, --help Show help\n`); -} + function printHelp(): void { + process.stdout.write("consensus\n\n"); + process.stdout.write("Usage:\n consensus [command] [options]\n\n"); + process.stdout.write("Commands:\n"); + process.stdout.write( + " setup Configure Codex notify hook (recommended first step)\n" + ); + process.stdout.write( + " graph Inspect agent transitions and detect loops\n" + ); + process.stdout.write( + " harnesses List harnesses, evidence tiers, and setup guidance\n" + ); + process.stdout.write(" (default) Start the consensus server\n\n"); + process.stdout.write("Options:\n"); + process.stdout.write(" --host Bind address (default 127.0.0.1)\n"); + process.stdout.write(" --port Port (default 8787)\n"); + process.stdout.write(" --poll Poll interval in ms\n"); + process.stdout.write(" --codex-home Override CODEX_HOME\n"); + process.stdout.write(" --codex-notify Install Codex notify hook at path\n"); + process.stdout.write(" --opencode-host OpenCode host (default 127.0.0.1)\n"); + process.stdout.write(" --opencode-port

OpenCode port (default 4096)\n"); + process.stdout.write(" --no-opencode-autostart Disable OpenCode server autostart\n"); + process.stdout.write(" --process-match Regex for process matching\n"); + process.stdout.write(" --no-redact Disable PII redaction\n"); + process.stdout.write(" -h, --help Show help\n"); + } -if (hasFlag("--help") || hasFlag("-h")) { - printHelp(); - process.exit(0); -} + if (hasFlag("--help") || hasFlag("-h")) { + printHelp(); + process.exit(0); + } -const env = { ...process.env } as Record; + const env = { ...process.env } as Record; -const host = readArg("--host"); -const port = readArg("--port"); -const poll = readArg("--poll"); -const codexHome = readArg("--codex-home"); -const codexNotify = readArg("--codex-notify"); -const opencodeHost = readArg("--opencode-host"); -const opencodePort = readArg("--opencode-port"); -const noOpenCodeAutostart = hasFlag("--no-opencode-autostart"); -const match = readArg("--process-match"); -const noRedact = hasFlag("--no-redact"); + const host = readArg("--host"); + const port = readArg("--port"); + const poll = readArg("--poll"); + const codexHome = readArg("--codex-home"); + const codexNotify = readArg("--codex-notify"); + const opencodeHost = readArg("--opencode-host"); + const opencodePort = readArg("--opencode-port"); + const noOpenCodeAutostart = hasFlag("--no-opencode-autostart"); + const match = readArg("--process-match"); + const noRedact = hasFlag("--no-redact"); -if (host) env.CONSENSUS_HOST = host; -if (port) env.CONSENSUS_PORT = port; -if (poll) env.CONSENSUS_POLL_MS = poll; -if (codexHome) env.CONSENSUS_CODEX_HOME = codexHome; -const normalizeNotify = (value: string | undefined): string | undefined => { - if (!value) return undefined; - const lowered = value.trim().toLowerCase(); - if (!lowered || lowered === "0" || lowered === "false" || lowered === "off") { - return undefined; - } - return value; -}; -const defaultNotifyPath = path.join(__dirname, "codexNotify.js"); -const resolvedNotify = - normalizeNotify(codexNotify) || - normalizeNotify(env.CONSENSUS_CODEX_NOTIFY_INSTALL) || - (fs.existsSync(defaultNotifyPath) ? defaultNotifyPath : undefined); -if (resolvedNotify) env.CONSENSUS_CODEX_NOTIFY_INSTALL = resolvedNotify; -if (opencodeHost) env.CONSENSUS_OPENCODE_HOST = opencodeHost; -if (opencodePort) env.CONSENSUS_OPENCODE_PORT = opencodePort; -if (noOpenCodeAutostart) env.CONSENSUS_OPENCODE_AUTOSTART = "0"; -if (match) env.CONSENSUS_PROCESS_MATCH = match; -if (noRedact) env.CONSENSUS_REDACT_PII = "0"; + if (host) env.CONSENSUS_HOST = host; + if (port) env.CONSENSUS_PORT = port; + if (poll) env.CONSENSUS_POLL_MS = poll; + if (codexHome) env.CONSENSUS_CODEX_HOME = codexHome; + const normalizeNotify = (value: string | undefined): string | undefined => { + if (!value) return undefined; + const lowered = value.trim().toLowerCase(); + if (!lowered || lowered === "0" || lowered === "false" || lowered === "off") { + return undefined; + } + return value; + }; + const defaultNotifyPath = path.join(__dirname, "codexNotify.js"); + const resolvedNotify = + normalizeNotify(codexNotify) || + normalizeNotify(env.CONSENSUS_CODEX_NOTIFY_INSTALL) || + (fs.existsSync(defaultNotifyPath) ? defaultNotifyPath : undefined); + if (resolvedNotify) env.CONSENSUS_CODEX_NOTIFY_INSTALL = resolvedNotify; + if (opencodeHost) env.CONSENSUS_OPENCODE_HOST = opencodeHost; + if (opencodePort) env.CONSENSUS_OPENCODE_PORT = opencodePort; + if (noOpenCodeAutostart) env.CONSENSUS_OPENCODE_AUTOSTART = "0"; + if (match) env.CONSENSUS_PROCESS_MATCH = match; + if (noRedact) env.CONSENSUS_REDACT_PII = "0"; -const serverPath = path.join(__dirname, "server.js"); -const spanAttributes: Record = { - "cli.args_count": args.length, -}; -if (host) spanAttributes["consensus.host"] = host; -if (port) spanAttributes["consensus.port"] = Number(port); -if (poll) spanAttributes["consensus.poll_ms"] = Number(poll); -if (opencodeHost) spanAttributes["consensus.opencode_host"] = opencodeHost; -if (opencodePort) spanAttributes["consensus.opencode_port"] = Number(opencodePort); + const serverPath = path.join(__dirname, "server.js"); + const spanAttributes: Record = { + "cli.args_count": args.length, + }; + if (host) spanAttributes["consensus.host"] = host; + if (port) spanAttributes["consensus.port"] = Number(port); + if (poll) spanAttributes["consensus.poll_ms"] = Number(poll); + if (opencodeHost) spanAttributes["consensus.opencode_host"] = opencodeHost; + if (opencodePort) spanAttributes["consensus.opencode_port"] = Number(opencodePort); -const program = Effect.async((resume) => { - const child = spawn(process.execPath, [serverPath], { stdio: "inherit", env }); - child.on("exit", (code) => { - resume(Effect.succeed(code ?? 0)); + const program = Effect.async((resume) => { + const child = spawn(process.execPath, [serverPath], { stdio: "inherit", env }); + child.on("exit", (code) => { + resume(Effect.succeed(code ?? 0)); + }); }); -}); -const instrumented = program.pipe( - withSpan("cli.run", { attributes: spanAttributes }), - Effect.tap((code) => annotateSpan("process.exit_code", code)) -); + const instrumented = program.pipe( + withSpan("cli.run", { attributes: spanAttributes }), + Effect.tap((code) => annotateSpan("process.exit_code", code)) + ); -runPromise(instrumented) - .then(async (code) => { - await disposeObservability().catch(() => undefined); - process.exit(code); - }) - .catch(async (err) => { - process.stderr.write(`[consensus] cli error: ${String(err)}\n`); - await disposeObservability().catch(() => undefined); - process.exit(1); - }); + runPromise(instrumented) + .then(async (code) => { + await disposeObservability().catch(() => undefined); + process.exit(code); + }) + .catch(async (error) => { + process.stderr.write(`[consensus] cli error: ${String(error)}\n`); + await disposeObservability().catch(() => undefined); + process.exit(1); + }); } diff --git a/src/cli/graph.ts b/src/cli/graph.ts new file mode 100644 index 0000000..d41ea4c --- /dev/null +++ b/src/cli/graph.ts @@ -0,0 +1,286 @@ +import { readFile, stat } from "node:fs/promises"; +import { buildAgentGraph, formatAgentGraph } from "../graph.js"; +import { isAgentKind } from "../harnesses.js"; +import type { + AgentSnapshot, + AgentState, + EventSummary, + SnapshotPayload, + WorkSummary, +} from "../types.js"; + +type SnapshotScanner = (options?: { + mode?: "fast" | "full"; + includeActivity?: boolean; +}) => Promise; + +export type GraphScanLoader = () => Promise<{ + scanCodexProcesses: SnapshotScanner; +}>; + +const agentStates = new Set(["active", "idle", "error"]); +export const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024; +const MAX_AGENTS = 10_000; +const MAX_EVENTS_PER_AGENT = 10_000; +const MAX_TEXT_LENGTH = 16_384; +const MAX_EVENT_TYPE_LENGTH = 256; +const MAX_EVENT_SUMMARY_LENGTH = 4_096; +const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/; + +function graphHelp(): string { + return [ + "consensus graph", + "", + "Inspect observed agent transitions as a graph and report detected loops.", + "", + "Usage:", + " consensus graph [--json] [--snapshot ]", + "", + "Options:", + " --json Print the full graph snapshot as JSON", + " --snapshot Read a saved snapshot file, or '-' for stdin", + " -h, --help Show graph command help", + "", + ].join("\n"); +} + +function snapshotTooLarge(source: string): Error { + return new Error( + `snapshot from ${source} exceeds ${MAX_SNAPSHOT_BYTES} bytes` + ); +} + +async function readStdin(): Promise { + if (process.stdin.isTTY) { + throw new Error("--snapshot - requires JSON piped on stdin"); + } + const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of process.stdin) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.length; + if (totalBytes > MAX_SNAPSHOT_BYTES) throw snapshotTooLarge("stdin"); + chunks.push(buffer); + } + return Buffer.concat(chunks, totalBytes).toString("utf8"); +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isNonNegativeFiniteNumber(value: unknown): value is number { + return isFiniteNumber(value) && value >= 0; +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +function isSafeString(value: unknown, maxLength: number = MAX_TEXT_LENGTH): value is string { + return ( + typeof value === "string" && + value.length <= maxLength && + !CONTROL_CHARACTER_RE.test(value) + ); +} + +function isNonEmptySafeString( + value: unknown, + maxLength: number = MAX_TEXT_LENGTH +): value is string { + return isSafeString(value, maxLength) && value.length > 0; +} + +function isOptionalSafeString( + value: unknown, + maxLength: number = MAX_TEXT_LENGTH +): boolean { + return value === undefined || isSafeString(value, maxLength); +} + +function isOptionalTimestamp(value: unknown): boolean { + return value === undefined || isNonNegativeFiniteNumber(value); +} + +function isOptionalTurnId(value: unknown): boolean { + return ( + value === undefined || + isNonEmptySafeString(value, MAX_EVENT_TYPE_LENGTH) || + (typeof value === "number" && Number.isFinite(value)) + ); +} + +function isWorkSummary(value: unknown): value is WorkSummary | undefined { + if (value === undefined) return true; + if (!isRecord(value)) return false; + return [ + value.current, + value.lastCommand, + value.lastEdit, + value.lastMessage, + value.lastTool, + value.lastPrompt, + ].every((entry) => isOptionalSafeString(entry, MAX_EVENT_SUMMARY_LENGTH)); +} + +function isEventSummary(value: unknown): value is EventSummary { + if (!isRecord(value)) return false; + return ( + isNonNegativeFiniteNumber(value.ts) && + isNonEmptySafeString(value.type, MAX_EVENT_TYPE_LENGTH) && + isSafeString(value.summary, MAX_EVENT_SUMMARY_LENGTH) && + (value.isError === undefined || typeof value.isError === "boolean") && + isOptionalTurnId(value.turnId) + ); +} + +function isAgentSnapshot(value: unknown): value is AgentSnapshot { + if (!isRecord(value)) return false; + if ( + !isNonEmptySafeString(value.id) || + !isNonNegativeInteger(value.pid) || + !isNonEmptySafeString(value.cmd) || + !isNonEmptySafeString(value.cmdShort) || + !isAgentKind(value.kind) || + typeof value.state !== "string" || + !agentStates.has(value.state as AgentState) || + !isNonNegativeFiniteNumber(value.cpu) || + !isNonNegativeFiniteNumber(value.mem) + ) { + return false; + } + if ( + !isOptionalSafeString(value.identity) || + !isOptionalSafeString(value.title) || + !isOptionalSafeString(value.doing) || + !isOptionalSafeString(value.sessionPath) || + !isOptionalSafeString(value.repo) || + !isOptionalSafeString(value.cwd) || + !isOptionalSafeString(value.model) || + !isOptionalSafeString(value.activityReason) + ) { + return false; + } + if ( + !isOptionalTimestamp(value.startedAt) || + !isOptionalTimestamp(value.lastEventAt) || + !isOptionalTimestamp(value.lastActivityAt) || + !isWorkSummary(value.summary) + ) { + return false; + } + if (value.events !== undefined) { + if ( + !Array.isArray(value.events) || + value.events.length > MAX_EVENTS_PER_AGENT || + !value.events.every(isEventSummary) + ) { + return false; + } + } + return true; +} + +export function parseSnapshot(input: string, source: string): SnapshotPayload { + if (Buffer.byteLength(input, "utf8") > MAX_SNAPSHOT_BYTES) { + throw snapshotTooLarge(source); + } + + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch (error) { + throw new Error(`invalid snapshot JSON from ${source}: ${String(error)}`); + } + + if ( + !isRecord(parsed) || + !isNonNegativeFiniteNumber(parsed.ts) || + !Array.isArray(parsed.agents) || + parsed.agents.length > MAX_AGENTS || + !parsed.agents.every(isAgentSnapshot) + ) { + throw new Error(`invalid snapshot payload from ${source}`); + } + + return parsed as unknown as SnapshotPayload; +} + +const defaultScanLoader: GraphScanLoader = async () => { + const { scanSnapshot } = await import("../scanSnapshot.js"); + return { scanCodexProcesses: scanSnapshot }; +}; + +async function loadLiveSnapshot( + loadScan: GraphScanLoader = defaultScanLoader +): Promise { + const previousAutostart = process.env.CONSENSUS_OPENCODE_AUTOSTART; + process.env.CONSENSUS_OPENCODE_AUTOSTART = "0"; + try { + const { scanCodexProcesses } = await loadScan(); + return await scanCodexProcesses({ mode: "full" }); + } finally { + if (previousAutostart === undefined) { + delete process.env.CONSENSUS_OPENCODE_AUTOSTART; + } else { + process.env.CONSENSUS_OPENCODE_AUTOSTART = previousAutostart; + } + } +} + +async function readSnapshotFile(snapshotPath: string): Promise { + const info = await stat(snapshotPath); + if (info.size > MAX_SNAPSHOT_BYTES) throw snapshotTooLarge(snapshotPath); + return readFile(snapshotPath, "utf8"); +} + +export async function loadSnapshot( + snapshotPath: string | undefined, + loadScan: GraphScanLoader = defaultScanLoader +): Promise { + if (!snapshotPath) return loadLiveSnapshot(loadScan); + + const input = + snapshotPath === "-" ? await readStdin() : await readSnapshotFile(snapshotPath); + return parseSnapshot(input, snapshotPath === "-" ? "stdin" : snapshotPath); +} + +export async function runGraph(args: string[]): Promise { + let json = false; + let snapshotPath: string | undefined; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "-h" || arg === "--help") { + process.stdout.write(graphHelp()); + return; + } + if (arg === "--json") { + json = true; + continue; + } + if (arg === "--snapshot") { + snapshotPath = args[index + 1]; + if (!snapshotPath) throw new Error("--snapshot requires a path or '-'"); + index += 1; + continue; + } + if (arg.startsWith("--snapshot=")) { + snapshotPath = arg.slice("--snapshot=".length); + if (!snapshotPath) throw new Error("--snapshot requires a path or '-'"); + continue; + } + throw new Error(`unknown graph option: ${arg}`); + } + + const snapshot = await loadSnapshot(snapshotPath); + const graph = buildAgentGraph(snapshot); + process.stdout.write( + json ? `${JSON.stringify(graph, null, 2)}\n` : formatAgentGraph(graph) + ); +} diff --git a/src/cli/harnesses.ts b/src/cli/harnesses.ts new file mode 100644 index 0000000..f82314f --- /dev/null +++ b/src/cli/harnesses.ts @@ -0,0 +1,169 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + harnessDefinitions, + harnessForId, + harnessIds, + type HarnessDefinition, + type HarnessId, +} from "../harnesses.js"; +import { isHookHarnessId } from "../harnessHookModel.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function isHarnessId(value: string): value is HarnessId { + return (harnessIds as readonly string[]).includes(value); +} + +function help(): string { + return [ + "consensus harnesses", + "", + "List registered coding-agent harnesses and their evidence sources.", + "", + "Usage:", + " consensus harnesses [--json]", + " consensus harnesses --setup ", + "", + "Options:", + " --json Print the registry as JSON", + " --setup Print hook or adapter setup guidance", + " -h, --help Show this help", + "", + ].join("\n"); +} + +function setupCommand(scriptPath: string, harnessId: HarnessId): string { + return `node ${JSON.stringify(scriptPath)} ${harnessId}`; +} + +export function harnessSetupText( + harness: HarnessDefinition, + scriptPath: string +): string { + const lines = [ + `${harness.displayName} (${harness.id})`, + `class=${harness.class} telemetry=${harness.telemetry}`, + ]; + if (harness.docs) lines.push(`docs=${harness.docs}`); + + if (isHookHarnessId(harness.id)) { + lines.push( + "", + "Configure each supported lifecycle event to run this command and pass the hook JSON on stdin:", + ` ${setupCommand(scriptPath, harness.id)}`, + "", + "Consensus stores only normalized event type, opaque session/cwd/turn keys, timestamp, and small tool/agent labels.", + "Prompt text, tool input/output, transcript paths, raw session IDs, and error details are discarded before persistence." + ); + } else if (harness.telemetry === "native-events") { + lines.push( + "", + "This harness needs a native event adapter. Process discovery is available where an exact binary rule exists." + ); + } else if (harness.telemetry === "structured-stream") { + lines.push( + "", + "Run history should be attached through the harness's structured output stream. Process discovery alone has no step graph." + ); + } else if (harness.telemetry === "remote-api") { + lines.push( + "", + "Run history requires an explicit, opt-in remote API adapter. Local process discovery does not fetch remote data." + ); + } else if (harness.telemetry === "tool-only") { + lines.push( + "", + "This entry represents a tool/model integration. Consensus attributes the execution loop to the owning harness." + ); + } else if (harness.telemetry === "manual") { + lines.push( + "", + "This IDE agent needs an extension bridge. Consensus does not identify it from generic Electron helper processes." + ); + } else { + lines.push( + "", + "Current depth is exact process discovery only; no step history is claimed." + ); + } + + if (harness.note) lines.push("", `note=${harness.note}`); + return `${lines.join("\n")}\n`; +} + +function formatRegistry(): string { + const rows = harnessDefinitions.map((harness) => ({ + name: harness.displayName, + id: harness.id, + class: harness.class, + telemetry: harness.telemetry, + discovery: harness.processRules?.length ? "process" : "adapter", + })); + const widths = { + name: Math.max("HARNESS".length, ...rows.map((row) => row.name.length)), + id: Math.max("ID".length, ...rows.map((row) => row.id.length)), + class: Math.max("CLASS".length, ...rows.map((row) => row.class.length)), + telemetry: Math.max( + "EVIDENCE".length, + ...rows.map((row) => row.telemetry.length) + ), + }; + const line = (row: (typeof rows)[number]): string => + `${row.name.padEnd(widths.name)} ${row.id.padEnd(widths.id)} ` + + `${row.class.padEnd(widths.class)} ${row.telemetry.padEnd(widths.telemetry)} ` + + row.discovery; + return [ + `${"HARNESS".padEnd(widths.name)} ${"ID".padEnd(widths.id)} ` + + `${"CLASS".padEnd(widths.class)} ${"EVIDENCE".padEnd(widths.telemetry)} DISCOVERY`, + ...rows.map(line), + "", + "Use `consensus harnesses --setup ` for the provider-specific next step.", + "", + ].join("\n"); +} + +export async function runHarnesses(args: string[]): Promise { + let json = false; + let setupId: string | undefined; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "-h" || arg === "--help") { + process.stdout.write(help()); + return; + } + if (arg === "--json") { + json = true; + continue; + } + if (arg === "--setup") { + setupId = args[index + 1]; + if (!setupId) throw new Error("--setup requires a harness id"); + index += 1; + continue; + } + if (arg.startsWith("--setup=")) { + setupId = arg.slice("--setup=".length); + if (!setupId) throw new Error("--setup requires a harness id"); + continue; + } + throw new Error(`unknown harnesses option: ${arg}`); + } + + if (setupId) { + if (!isHarnessId(setupId)) { + throw new Error(`unknown harness id: ${setupId}`); + } + const scriptPath = path.resolve(__dirname, "..", "harnessHook.js"); + process.stdout.write(harnessSetupText(harnessForId(setupId), scriptPath)); + return; + } + + process.stdout.write( + json + ? `${JSON.stringify({ version: 1, harnesses: harnessDefinitions }, null, 2)}\n` + : formatRegistry() + ); +} diff --git a/src/genericHarnessSnapshot.ts b/src/genericHarnessSnapshot.ts new file mode 100644 index 0000000..e0877a7 --- /dev/null +++ b/src/genericHarnessSnapshot.ts @@ -0,0 +1,293 @@ +import path from "node:path"; +import pidusage from "pidusage"; +import psList from "ps-list"; +import { + detectGenericHarnessProcess, + type DetectedHarnessProcess, +} from "./harnesses.js"; +import { harnessCwdKey, harnessSessionKey } from "./harnessKeys.js"; +import { redactText } from "./redact.js"; +import type { AgentSnapshot, SnapshotPayload } from "./types.js"; + +export interface GenericHarnessProcess { + pid: number; + name?: string; + cmd?: string; +} + +export interface GenericHarnessUsage { + cpu?: number; + memory?: number; + elapsed?: number; +} + +type ProcessLoader = () => Promise; +type UsageLoader = ( + pids: number[] +) => Promise>; + +export interface GenericHarnessSnapshotOptions { + processes?: readonly GenericHarnessProcess[]; + usage?: Readonly>; + now?: number; + cacheMs?: number; + processLoader?: ProcessLoader; + usageLoader?: UsageLoader; +} + +interface DetectedProcess { + process: GenericHarnessProcess; + detected: DetectedHarnessProcess; +} + +interface GenericProcessCache { + at: number; + matches: DetectedProcess[]; + usage: Record; +} + +const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g; +let genericProcessCache: GenericProcessCache | undefined; + +function commandTokens(command: string): string[] { + return ( + command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((part) => + part.replace(/^["']|["']$/g, "") + ) ?? [] + ); +} + +function extractFlagValue( + tokens: string[], + flags: ReadonlySet +): string | undefined { + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (flags.has(token)) { + const value = tokens[index + 1]; + if (value && !value.startsWith("-")) return value; + } + for (const flag of flags) { + if (token.startsWith(`${flag}=`)) { + const value = token.slice(flag.length + 1); + if (value) return value; + } + } + } + return undefined; +} + +function extractCwd(command: string): string | undefined { + return extractFlagValue( + commandTokens(command), + new Set(["--cwd", "--working-dir", "--workdir", "--dir"]) + ); +} + +function extractSessionId(command: string): string | undefined { + const tokens = commandTokens(command); + const direct = extractFlagValue( + tokens, + new Set(["--session", "--session-id", "--conversation-id", "-s"]) + ); + if (direct) return direct; + + const resumeIndex = tokens.findIndex( + (token) => token === "resume" || token === "--resume" + ); + if (resumeIndex >= 0) { + const value = tokens[resumeIndex + 1]; + if (value && !value.startsWith("-")) return value; + } + return undefined; +} + +async function loadUsage( + pids: number[] +): Promise> { + if (pids.length === 0) return {}; + try { + const result = await pidusage(pids); + if ( + pids.length === 1 && + result && + typeof result === "object" && + "cpu" in result + ) { + return { [pids[0]]: result as GenericHarnessUsage }; + } + return result as unknown as Record; + } catch { + return {}; + } +} + +function resolveCacheMs(override?: number): number { + if (typeof override === "number" && Number.isFinite(override)) { + return Math.max(0, Math.floor(override)); + } + const parsed = Number(process.env.CONSENSUS_GENERIC_PROCESS_CACHE_MS); + return Number.isFinite(parsed) && parsed >= 0 + ? Math.floor(parsed) + : 1_000; +} + +function processState(cpu: number): AgentSnapshot["state"] { + const threshold = Number(process.env.CONSENSUS_GENERIC_CPU_ACTIVE || 1); + return Number.isFinite(threshold) && cpu > threshold ? "active" : "idle"; +} + +function safeSnapshotText( + value: string | undefined, + maxLength = 4_096 +): string | undefined { + if (!value) return undefined; + const sanitized = value + .replace(CONTROL_RE, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); + return sanitized || undefined; +} + +function detectProcesses( + processes: readonly GenericHarnessProcess[] +): DetectedProcess[] { + return processes.flatMap((process) => { + if (!Number.isInteger(process.pid) || process.pid < 0) return []; + const detected = detectGenericHarnessProcess(process.cmd, process.name); + return detected ? [{ process, detected }] : []; + }); +} + +async function loadDetectedProcesses( + options: GenericHarnessSnapshotOptions +): Promise<{ + matches: DetectedProcess[]; + usage: Readonly>; +}> { + if (options.processes) { + const matches = detectProcesses(options.processes); + const usage = + options.usage ?? + (await (options.usageLoader ?? loadUsage)( + matches.map(({ process }) => process.pid) + )); + return { matches, usage }; + } + + const cacheMs = resolveCacheMs(options.cacheMs); + const cacheNow = Date.now(); + if ( + genericProcessCache && + cacheMs > 0 && + cacheNow - genericProcessCache.at <= cacheMs + ) { + return { + matches: genericProcessCache.matches, + usage: genericProcessCache.usage, + }; + } + + const processes = await (options.processLoader ?? psList)(); + const matches = detectProcesses(processes); + const usage = + options.usage ?? + (await (options.usageLoader ?? loadUsage)( + matches.map(({ process }) => process.pid) + )); + genericProcessCache = { + at: cacheNow, + matches, + usage: { ...usage }, + }; + return { matches, usage }; +} + +function toAgentSnapshot( + process: GenericHarnessProcess, + detected: DetectedHarnessProcess, + usage: GenericHarnessUsage, + now: number +): AgentSnapshot { + const commandRaw = process.cmd || process.name || detected.harness.displayName; + const cwdRaw = extractCwd(commandRaw); + const sessionId = extractSessionId(commandRaw); + const cwd = safeSnapshotText(redactText(cwdRaw) || cwdRaw); + const cpu = + typeof usage.cpu === "number" && Number.isFinite(usage.cpu) + ? Math.max(0, usage.cpu) + : 0; + const mem = + typeof usage.memory === "number" && Number.isFinite(usage.memory) + ? Math.max(0, usage.memory) + : 0; + const elapsed = + typeof usage.elapsed === "number" && + Number.isFinite(usage.elapsed) && + usage.elapsed >= 0 + ? usage.elapsed + : undefined; + const startedAt = + typeof elapsed === "number" + ? Math.max(0, Math.floor((now - elapsed) / 1000)) + : undefined; + const state = processState(cpu); + const role = detected.kind.endsWith("server") ? "server" : "agent"; + const startIdentity = + typeof startedAt === "number" ? `:start:${startedAt}` : ""; + const safeCommand = `${detected.harness.id} ${role}`; + const repo = cwdRaw + ? safeSnapshotText(path.basename(path.resolve(cwdRaw)), 255) + : undefined; + + return { + identity: `${detected.harness.id}:pid:${process.pid}${startIdentity}`, + id: String(process.pid), + pid: process.pid, + startedAt, + title: detected.harness.displayName, + cmd: safeCommand, + cmdShort: safeCommand, + kind: detected.kind, + cpu, + mem, + state, + activityReason: state === "active" ? "process_cpu" : "process_detected", + doing: `${detected.harness.displayName} ${role}`, + cwd, + repo, + harnessCwdKey: cwdRaw + ? harnessCwdKey(detected.harness.id, cwdRaw) + : undefined, + harnessSessionKey: sessionId + ? harnessSessionKey(detected.harness.id, sessionId) + : undefined, + }; +} + +export async function attachGenericHarnessProcesses( + snapshot: SnapshotPayload, + options: GenericHarnessSnapshotOptions = {} +): Promise { + const { matches, usage } = await loadDetectedProcesses(options); + const existingPids = new Set(snapshot.agents.map((agent) => agent.pid)); + const uniqueMatches = matches.filter( + ({ process }) => !existingPids.has(process.pid) + ); + if (uniqueMatches.length === 0) return snapshot; + + const now = options.now ?? snapshot.ts; + const additionalAgents = uniqueMatches.map(({ process, detected }) => + toAgentSnapshot(process, detected, usage[process.pid] ?? {}, now) + ); + + return { + ...snapshot, + agents: [...snapshot.agents, ...additionalAgents], + }; +} + +export function resetGenericHarnessProcessCacheForTests(): void { + genericProcessCache = undefined; +} diff --git a/src/graph.ts b/src/graph.ts new file mode 100644 index 0000000..f163b7a --- /dev/null +++ b/src/graph.ts @@ -0,0 +1,674 @@ +import { createHash } from "node:crypto"; +import { + harnessCoverageNote, + harnessDefinitions, + harnessIdForKind, +} from "./harnesses.js"; +import type { + AgentKind, + AgentSnapshot, + AgentState, + EventSummary, + SnapshotPayload, +} from "./types.js"; +import { detectGraphLoops } from "./graphLoops.js"; +import type { + AgentGraphCoverage, + AgentGraphEdge, + AgentGraphInput, + AgentGraphLoop, + AgentGraphNode, + AgentGraphProviderCoverage, + AgentGraphSnapshot, + GraphEdgeKind, + GraphHistoryStatus, +} from "./graphTypes.js"; + +export type { + AgentGraphCoverage, + AgentGraphEdge, + AgentGraphInput, + AgentGraphLoop, + AgentGraphNode, + AgentGraphProviderCoverage, + AgentGraphSnapshot, + AgentGraphStats, + AgentGraphWindow, + GraphEdgeKind, + GraphHistoryStatus, + GraphLoopKind, + GraphNodeKind, +} from "./graphTypes.js"; + +interface IndexedEvent { + event: EventSummary; + index: number; +} + +const LIFECYCLE_OR_META_TYPES = new Set([ + "agentturncomplete", + "sessionstart", + "sessionend", + "setup", + "stop", + "stopfailure", + "userpromptexpansion", + "precompact", + "postcompact", + "notification", + "instructionsloaded", + "configchange", + "cwdchanged", + "filechanged", + "worktreecreate", + "worktreeremove", + "teammateidle", + "serverconnected", + "serverdisconnected", + "heartbeat", + "connected", + "ready", + "ping", + "pong", + "snapshot", + "history", + "tokencount", +]); +const GRAPH_TEXT_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g; +const MAX_GRAPH_LABEL_LENGTH = 240; + +function identityForAgent(agent: AgentSnapshot): string { + return agent.identity || agent.id; +} + +function providerForKind(kind: AgentKind): string { + return harnessIdForKind(kind); +} + +function opaqueAgentKey(provider: string, identity: string): string { + const digest = createHash("sha256") + .update(provider) + .update("\0") + .update(identity) + .digest("hex") + .slice(0, 20); + return `${provider}:${digest}`; +} + +function graphText(value: string | undefined, fallback: string): string { + const normalized = value + ?.replace(GRAPH_TEXT_CONTROL_RE, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, MAX_GRAPH_LABEL_LENGTH); + return normalized || fallback; +} + +function optionalGraphText(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const normalized = graphText(value, ""); + return normalized || undefined; +} + +function idPart(value: string): string { + return encodeURIComponent(value); +} + +function agentNodeId(agentKey: string): string { + return `agent:${agentKey}`; +} + +function stepNodeId(agentKey: string, segment: number, phase: string): string { + return `step:${agentKey}:s${segment}:${idPart(phase)}`; +} + +function edgeId(kind: GraphEdgeKind, source: string, target: string): string { + return `${kind}:${source}->${target}`; +} + +function normalizedEventType(event: EventSummary): string { + const type = event.type?.trim(); + return type || "event"; +} + +function normalizedSummary(event: EventSummary): string { + return event.summary?.trim().toLowerCase() || ""; +} + +function compactEventName(value: string): string { + return value.replace(/[^a-z0-9]/gi, "").toLowerCase(); +} + +function isLifecycleOrMetaEvent(event: EventSummary): boolean { + const eventType = normalizedEventType(event).toLowerCase(); + const compact = compactEventName(eventType); + const summary = normalizedSummary(event); + + if ( + /^(thread|turn|response|run|session)\.(started|start|in_progress|running|completed|complete|failed|failure|errored|error|canceled|cancelled|aborted|interrupted|stopped|stop|idle|status|created|updated|ended|end)$/.test( + eventType + ) + ) { + return true; + } + + if (LIFECYCLE_OR_META_TYPES.has(compact)) return true; + + return ( + summary.startsWith("event:") && + /(heartbeat|connected|ready|snapshot|history|token_count|compaction)/.test( + `${eventType} ${summary}` + ) + ); +} + +function phaseForEvent(event: EventSummary): string | undefined { + if (isLifecycleOrMetaEvent(event)) return undefined; + + const summary = normalizedSummary(event); + const eventType = normalizedEventType(event); + const lowerType = eventType.toLowerCase(); + const compact = compactEventName(eventType); + + if (summary.startsWith("cmd:")) return "command"; + if (summary.startsWith("edit:")) return "edit"; + if (summary.startsWith("tool:")) return "tool"; + if (summary.startsWith("prompt:")) return "prompt"; + if (/file_(change|edit|write)|patch/.test(lowerType)) return "edit"; + if (/tool|function_call|mcp|permission|subagent|task/.test(lowerType)) { + return "tool"; + } + if (/command|(?:^|[._-])exec(?:ute|ution)?(?:[._-]|$)/.test(lowerType)) { + return "command"; + } + if (/prompt|user_message/.test(lowerType) || compact === "userpromptsubmit") { + return "prompt"; + } + if ( + summary === "thinking" || + summary === "message" || + compact === "messagedisplay" || + /reasoning|assistant|agent_message|message\.part\.updated|response\..*delta/.test( + lowerType + ) + ) { + return "model"; + } + if (summary && !summary.startsWith("event:") && !summary.startsWith("compaction:")) { + return "model"; + } + return graphText(eventType, "event"); +} + +function isTurnStartEvent(event: EventSummary): boolean { + const type = normalizedEventType(event).toLowerCase(); + const compact = compactEventName(type); + return ( + /^(turn|run)\.(started|start)$/.test(type) || + compact === "userpromptsubmit" + ); +} + +function isTurnEndEvent(event: EventSummary): boolean { + const type = normalizedEventType(event).toLowerCase(); + const compact = compactEventName(type); + return ( + /^(turn|response|run)\.(completed|complete|failed|failure|errored|error|canceled|cancelled|aborted|interrupted|stopped|stop|ended|end)$/.test( + type + ) || + type === "session.idle" || + compact === "agentturncomplete" || + compact === "stop" || + compact === "stopfailure" || + compact === "sessionend" + ); +} + +function maxDefined(values: Array): number | undefined { + let max: number | undefined; + for (const value of values) { + if (typeof value !== "number" || !Number.isFinite(value)) continue; + max = typeof max === "number" ? Math.max(max, value) : value; + } + return max; +} + +function minDefined(values: Array): number | undefined { + let min: number | undefined; + for (const value of values) { + if (typeof value !== "number" || !Number.isFinite(value)) continue; + min = typeof min === "number" ? Math.min(min, value) : value; + } + return min; +} + +function sortedEvents(events: EventSummary[] | undefined): EventSummary[] { + if (!events?.length) return []; + return events + .map((event, index): IndexedEvent => ({ event, index })) + .filter(({ event }) => typeof event.ts === "number" && Number.isFinite(event.ts)) + .sort((a, b) => a.event.ts - b.event.ts || a.index - b.index) + .map(({ event }) => event); +} + +function addContainsEdge( + edges: Map, + source: string, + target: string, + lastSeenAt: number | undefined +): void { + const id = edgeId("contains", source, target); + const current = edges.get(id); + if (current) { + current.lastSeenAt = maxDefined([current.lastSeenAt, lastSeenAt]); + return; + } + edges.set(id, { + id, + source, + target, + kind: "contains", + observations: 1, + lastSeenAt, + }); +} + +function addTransitionEdge( + edges: Map, + source: string, + target: string, + lastSeenAt: number +): void { + const id = edgeId("transition", source, target); + const current = edges.get(id); + if (current) { + current.observations += 1; + current.lastSeenAt = Math.max(current.lastSeenAt ?? lastSeenAt, lastSeenAt); + return; + } + edges.set(id, { + id, + source, + target, + kind: "transition", + observations: 1, + lastSeenAt, + }); +} + +function historyStatusForProvider( + provider: string, + agents: number, + agentsWithEvents: number +): { history: GraphHistoryStatus; note?: string } { + if (agentsWithEvents === agents && agents > 0) { + return { history: "available" }; + } + if (agentsWithEvents > 0) { + return { + history: "partial", + note: "Only some observed agents included retained events.", + }; + } + if (provider === "codex" || provider === "opencode") { + return { + history: "available", + note: "No retained graph events were present in this snapshot window.", + }; + } + + const harness = harnessDefinitions.find((candidate) => candidate.id === provider); + if (harness) { + return { + history: "unavailable", + note: harnessCoverageNote(harness, false), + }; + } + + return { + history: "unavailable", + note: "This harness does not expose retained graph events in the current adapter.", + }; +} + +function normalizeCoverage( + coverage: Map< + string, + { agents: number; agentsWithEvents: number; retainedEvents: number } + > +): AgentGraphCoverage { + const providers: Record = {}; + for (const provider of [...coverage.keys()].sort()) { + const current = coverage.get(provider); + if (!current) continue; + const status = historyStatusForProvider( + provider, + current.agents, + current.agentsWithEvents + ); + providers[provider] = { + ...current, + ...status, + }; + } + return { providers }; +} + +function deriveCoverageFromNodes(nodes: AgentGraphNode[]): AgentGraphCoverage { + const coverage = new Map< + string, + { agents: number; agentsWithEvents: number; retainedEvents: number } + >(); + const agentKeysByProvider = new Map>(); + const eventAgentsByProvider = new Map>(); + for (const node of nodes) { + const agentSet = agentKeysByProvider.get(node.provider) ?? new Set(); + agentSet.add(node.agentKey); + agentKeysByProvider.set(node.provider, agentSet); + if (node.kind === "step") { + const eventSet = + eventAgentsByProvider.get(node.provider) ?? new Set(); + eventSet.add(node.agentKey); + eventAgentsByProvider.set(node.provider, eventSet); + } + } + for (const [provider, agentKeys] of agentKeysByProvider) { + coverage.set(provider, { + agents: agentKeys.size, + agentsWithEvents: eventAgentsByProvider.get(provider)?.size ?? 0, + retainedEvents: nodes + .filter((node) => node.provider === provider && node.kind === "step") + .reduce((total, node) => total + (node.observations ?? 0), 0), + }); + } + return normalizeCoverage(coverage); +} + +export function analyzeAgentGraph(input: AgentGraphInput): AgentGraphSnapshot { + const nodes = [...input.nodes].sort((a, b) => a.id.localeCompare(b.id)); + const edges = [...input.edges].sort((a, b) => a.id.localeCompare(b.id)); + const loops = detectGraphLoops(nodes, edges); + const transitionEdges = edges.filter((edge) => edge.kind === "transition"); + + return { + version: 1, + source: input.source, + ts: input.ts, + window: input.window, + coverage: input.coverage ?? deriveCoverageFromNodes(nodes), + nodes, + edges, + loops, + stats: { + agents: nodes.filter((node) => node.kind === "agent").length, + steps: nodes.filter((node) => node.kind === "step").length, + edges: edges.length, + transitionEdges: transitionEdges.length, + transitions: transitionEdges.reduce( + (total, edge) => total + edge.observations, + 0 + ), + loops: loops.length, + activeLoops: loops.filter((loop) => loop.state === "active").length, + errorLoops: loops.filter((loop) => loop.state === "error").length, + }, + }; +} + +export function buildAgentGraph(snapshot: SnapshotPayload): AgentGraphSnapshot { + const nodes = new Map(); + const edges = new Map(); + const retainedEventTimes: number[] = []; + let graphEvents = 0; + const coverage = new Map< + string, + { agents: number; agentsWithEvents: number; retainedEvents: number } + >(); + const agents = [...snapshot.agents].sort((a, b) => + identityForAgent(a).localeCompare(identityForAgent(b)) + ); + + for (const agent of agents) { + const identity = identityForAgent(agent); + const provider = providerForKind(agent.kind); + const agentKey = opaqueAgentKey(provider, identity); + const rootNodeId = agentNodeId(agentKey); + const events = sortedEvents(agent.events); + const providerCoverage = coverage.get(provider) ?? { + agents: 0, + agentsWithEvents: 0, + retainedEvents: 0, + }; + providerCoverage.agents += 1; + providerCoverage.retainedEvents += events.length; + if (events.length > 0) providerCoverage.agentsWithEvents += 1; + coverage.set(provider, providerCoverage); + + nodes.set(rootNodeId, { + id: rootNodeId, + kind: "agent", + label: graphText(agent.title || agent.doing, `${provider} agent`), + state: agent.state, + agentKey, + provider, + repo: optionalGraphText(agent.repo), + firstSeenAt: + typeof agent.startedAt === "number" ? agent.startedAt * 1000 : undefined, + lastSeenAt: maxDefined([agent.lastActivityAt, agent.lastEventAt, snapshot.ts]), + observations: 1, + }); + + let segmentCounter = 0; + let activeSegment: number | undefined; + let activeTurnId: string | undefined; + let previousStepNodeId: string | undefined; + let segmentHasPhase = false; + let latestStepNodeId: string | undefined; + + const startSegment = (turnId?: string): number => { + segmentCounter += 1; + activeSegment = segmentCounter; + activeTurnId = turnId; + previousStepNodeId = undefined; + segmentHasPhase = false; + latestStepNodeId = undefined; + return activeSegment; + }; + + const closeSegment = (): void => { + activeSegment = undefined; + activeTurnId = undefined; + previousStepNodeId = undefined; + segmentHasPhase = false; + }; + + for (const event of events) { + retainedEventTimes.push(event.ts); + const phase = phaseForEvent(event); + const turnId = + typeof event.turnId === "number" + ? String(event.turnId) + : event.turnId?.trim() || undefined; + const turnStart = isTurnStartEvent(event); + const turnEnd = isTurnEndEvent(event); + + if (turnId) { + if (activeSegment === undefined || activeTurnId !== turnId) { + startSegment(turnId); + } + } else if ( + phase === "prompt" && + (activeSegment === undefined || segmentHasPhase) + ) { + startSegment(); + } else if (turnStart && activeSegment === undefined) { + startSegment(); + } else if (phase && activeSegment === undefined) { + startSegment(); + } + + if (phase && activeSegment !== undefined) { + graphEvents += 1; + const currentStepNodeId = stepNodeId(agentKey, activeSegment, phase); + const current = nodes.get(currentStepNodeId); + + if (current) { + current.observations = (current.observations ?? 0) + 1; + current.firstSeenAt = Math.min(current.firstSeenAt ?? event.ts, event.ts); + current.lastSeenAt = Math.max(current.lastSeenAt ?? event.ts, event.ts); + current.eventTypes = Array.from( + new Set([...(current.eventTypes ?? []), normalizedEventType(event)]) + ).sort(); + if (event.isError) current.hadError = true; + } else { + nodes.set(currentStepNodeId, { + id: currentStepNodeId, + kind: "step", + label: phase, + state: "idle", + agentKey, + provider, + repo: optionalGraphText(agent.repo), + phase, + segment: activeSegment, + hadError: !!event.isError, + eventTypes: [normalizedEventType(event)], + firstSeenAt: event.ts, + lastSeenAt: event.ts, + observations: 1, + }); + } + + addContainsEdge(edges, rootNodeId, currentStepNodeId, event.ts); + if (previousStepNodeId && previousStepNodeId !== currentStepNodeId) { + addTransitionEdge(edges, previousStepNodeId, currentStepNodeId, event.ts); + } + previousStepNodeId = currentStepNodeId; + latestStepNodeId = currentStepNodeId; + segmentHasPhase = true; + } + + if (turnEnd) closeSegment(); + } + + if (latestStepNodeId) { + const latest = nodes.get(latestStepNodeId); + if (latest) { + latest.current = true; + latest.state = agent.state; + } + } + } + + return analyzeAgentGraph({ + source: "observed-events", + ts: snapshot.ts, + window: { + retainedEvents: retainedEventTimes.length, + graphEvents, + oldestEventAt: minDefined(retainedEventTimes), + newestEventAt: maxDefined(retainedEventTimes), + }, + coverage: normalizeCoverage(coverage), + nodes: Array.from(nodes.values()), + edges: Array.from(edges.values()), + }); +} + +function graphStateLabel(state: AgentState): string { + return state.toUpperCase().padEnd(6, " "); +} + +function formatLoop( + loop: AgentGraphLoop, + nodeById: Map +): string { + const labels = loop.nodeIds.map( + (nodeId) => nodeById.get(nodeId)?.label || nodeId + ); + const path = + loop.kind === "self" + ? `${labels[0]} -> ${labels[0]}` + : labels.length === 2 + ? `${labels[0]} -> ${labels[1]} -> ${labels[0]}` + : `cycle{${labels.join(", ")}}`; + const segmentLabel = + loop.segments.length === 1 + ? `segment=${loop.segments[0]}` + : `segments=${loop.segments.join(",")}`; + return `${path} ${segmentLabel}`; +} + +export function formatAgentGraph(graph: AgentGraphSnapshot): string { + const nodeById = new Map(graph.nodes.map((node) => [node.id, node])); + const agentNodes = graph.nodes.filter((node) => node.kind === "agent"); + const stepCountByAgent = new Map(); + const loopCountByAgent = new Map(); + + for (const node of graph.nodes) { + if (node.kind !== "step") continue; + stepCountByAgent.set( + node.agentKey, + (stepCountByAgent.get(node.agentKey) ?? 0) + 1 + ); + } + for (const loop of graph.loops) { + for (const agentKey of loop.agentKeys) { + loopCountByAgent.set( + agentKey, + (loopCountByAgent.get(agentKey) ?? 0) + 1 + ); + } + } + + const lines = [ + "consensus graph", + `agents=${graph.stats.agents} steps=${graph.stats.steps} ` + + `transition_edges=${graph.stats.transitionEdges} transitions=${graph.stats.transitions} ` + + `loops=${graph.stats.loops} window_events=${graph.window.retainedEvents} ` + + `graph_events=${graph.window.graphEvents}`, + "", + "COVERAGE", + ]; + + const coverageEntries = Object.entries(graph.coverage.providers); + if (coverageEntries.length === 0) { + lines.push(" none observed"); + } else { + for (const [provider, coverage] of coverageEntries) { + const suffix = coverage.note ? ` — ${coverage.note}` : ""; + lines.push( + ` ${provider} history=${coverage.history} agents=${coverage.agents} ` + + `agents_with_events=${coverage.agentsWithEvents} events=${coverage.retainedEvents}${suffix}` + ); + } + } + + lines.push("", "AGENTS"); + if (agentNodes.length === 0) { + lines.push(" none observed"); + } else { + for (const node of agentNodes) { + const steps = stepCountByAgent.get(node.agentKey) ?? 0; + const loops = loopCountByAgent.get(node.agentKey) ?? 0; + lines.push( + ` ${graphStateLabel(node.state)} ${node.label} ` + + `[${node.provider}] steps=${steps} loops=${loops}` + ); + } + } + + lines.push("", "LOOPS"); + if (graph.loops.length === 0) { + lines.push(" none detected in the retained event window"); + } else { + for (const loop of graph.loops) { + lines.push( + ` ${graphStateLabel(loop.state)} ${formatLoop(loop, nodeById)} ` + + `transition_observations=${loop.transitionObservations}` + ); + } + } + + return `${lines.join("\n")}\n`; +} diff --git a/src/graphLoops.ts b/src/graphLoops.ts new file mode 100644 index 0000000..3652b5e --- /dev/null +++ b/src/graphLoops.ts @@ -0,0 +1,193 @@ +import type { AgentState } from "./types.js"; +import type { + AgentGraphEdge, + AgentGraphLoop, + AgentGraphNode, +} from "./graphTypes.js"; + +function stateRank(state: AgentState): number { + if (state === "error") return 2; + if (state === "active") return 1; + return 0; +} + +function strongestState(states: Iterable): AgentState { + let strongest: AgentState = "idle"; + for (const state of states) { + if (stateRank(state) > stateRank(strongest)) strongest = state; + } + return strongest; +} + +function maxDefined(values: Array): number | undefined { + let max: number | undefined; + for (const value of values) { + if (typeof value !== "number" || !Number.isFinite(value)) continue; + max = typeof max === "number" ? Math.max(max, value) : value; + } + return max; +} + +function sortedAdjacency( + nodeIds: string[], + edges: AgentGraphEdge[], + reverse = false +): Map { + const targetSets = new Map>(); + for (const nodeId of nodeIds) targetSets.set(nodeId, new Set()); + + for (const edge of edges) { + const source = reverse ? edge.target : edge.source; + const target = reverse ? edge.source : edge.target; + const targets = targetSets.get(source); + if (!targets || !targetSets.has(target)) continue; + targets.add(target); + } + + return new Map( + [...targetSets.entries()].map(([nodeId, targets]) => [ + nodeId, + [...targets].sort(), + ]) + ); +} + +function findStronglyConnectedComponents( + nodeIds: string[], + edges: AgentGraphEdge[] +): string[][] { + const uniqueNodeIds = [...new Set(nodeIds)].sort(); + const adjacency = sortedAdjacency(uniqueNodeIds, edges); + const reverseAdjacency = sortedAdjacency(uniqueNodeIds, edges, true); + const visited = new Set(); + const finishOrder: string[] = []; + + for (const startNodeId of uniqueNodeIds) { + if (visited.has(startNodeId)) continue; + visited.add(startNodeId); + const stack: Array<{ nodeId: string; nextTargetIndex: number }> = [ + { nodeId: startNodeId, nextTargetIndex: 0 }, + ]; + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const targets = adjacency.get(frame.nodeId) ?? []; + if (frame.nextTargetIndex < targets.length) { + const target = targets[frame.nextTargetIndex]; + frame.nextTargetIndex += 1; + if (!visited.has(target)) { + visited.add(target); + stack.push({ nodeId: target, nextTargetIndex: 0 }); + } + continue; + } + + stack.pop(); + finishOrder.push(frame.nodeId); + } + } + + const assigned = new Set(); + const components: string[][] = []; + for (let index = finishOrder.length - 1; index >= 0; index -= 1) { + const startNodeId = finishOrder[index]; + if (assigned.has(startNodeId)) continue; + + const component: string[] = []; + const stack = [startNodeId]; + assigned.add(startNodeId); + while (stack.length > 0) { + const nodeId = stack.pop(); + if (!nodeId) continue; + component.push(nodeId); + for (const target of reverseAdjacency.get(nodeId) ?? []) { + if (assigned.has(target)) continue; + assigned.add(target); + stack.push(target); + } + } + component.sort(); + components.push(component); + } + + return components.sort((a, b) => (a[0] ?? "").localeCompare(b[0] ?? "")); +} + +function isTurnScopedTransition( + edge: AgentGraphEdge, + stepNodeById: Map +): boolean { + if (edge.kind !== "transition") return false; + const source = stepNodeById.get(edge.source); + const target = stepNodeById.get(edge.target); + if (!source || !target) return false; + return ( + source.agentKey === target.agentKey && + source.provider === target.provider && + typeof source.segment === "number" && + source.segment === target.segment + ); +} + +export function detectGraphLoops( + nodes: AgentGraphNode[], + edges: AgentGraphEdge[] +): AgentGraphLoop[] { + const stepNodes = nodes.filter((node) => node.kind === "step"); + const stepNodeById = new Map(stepNodes.map((node) => [node.id, node])); + const transitionEdges = edges.filter((edge) => + isTurnScopedTransition(edge, stepNodeById) + ); + const selfLoopNodeIds = new Set( + transitionEdges + .filter((edge) => edge.source === edge.target) + .map((edge) => edge.source) + ); + const components = findStronglyConnectedComponents( + [...stepNodeById.keys()], + transitionEdges + ); + + const loops: AgentGraphLoop[] = []; + for (const component of components) { + const isSelfLoop = component.length === 1 && selfLoopNodeIds.has(component[0]); + if (component.length < 2 && !isSelfLoop) continue; + + const memberIds = new Set(component); + const loopEdges = transitionEdges.filter( + (edge) => memberIds.has(edge.source) && memberIds.has(edge.target) + ); + const memberNodes = component + .map((nodeId) => stepNodeById.get(nodeId)) + .filter((node): node is AgentGraphNode => !!node); + const currentMemberNodes = memberNodes.filter((node) => node.current); + + loops.push({ + id: `loop:${component.join("|")}`, + kind: isSelfLoop ? "self" : "cycle", + nodeIds: component, + edgeIds: loopEdges.map((edge) => edge.id).sort(), + agentKeys: Array.from( + new Set(memberNodes.map((node) => node.agentKey)) + ).sort(), + providers: Array.from( + new Set(memberNodes.map((node) => node.provider)) + ).sort(), + segments: Array.from( + new Set( + memberNodes + .map((node) => node.segment) + .filter((segment): segment is number => typeof segment === "number") + ) + ).sort((a, b) => a - b), + state: strongestState(currentMemberNodes.map((node) => node.state)), + transitionObservations: loopEdges.reduce( + (total, edge) => total + edge.observations, + 0 + ), + lastSeenAt: maxDefined(loopEdges.map((edge) => edge.lastSeenAt)), + }); + } + + return loops.sort((a, b) => a.id.localeCompare(b.id)); +} diff --git a/src/graphTypes.ts b/src/graphTypes.ts new file mode 100644 index 0000000..b362903 --- /dev/null +++ b/src/graphTypes.ts @@ -0,0 +1,97 @@ +import type { AgentState } from "./types.js"; + +export type GraphNodeKind = "agent" | "step"; +export type GraphEdgeKind = "contains" | "transition"; +export type GraphLoopKind = "self" | "cycle"; +export type GraphHistoryStatus = "available" | "partial" | "unavailable"; + +export interface AgentGraphNode { + id: string; + kind: GraphNodeKind; + label: string; + state: AgentState; + agentKey: string; + provider: string; + repo?: string; + phase?: string; + segment?: number; + current?: boolean; + hadError?: boolean; + eventTypes?: string[]; + firstSeenAt?: number; + lastSeenAt?: number; + observations?: number; +} + +export interface AgentGraphEdge { + id: string; + source: string; + target: string; + kind: GraphEdgeKind; + observations: number; + lastSeenAt?: number; +} + +export interface AgentGraphLoop { + id: string; + kind: GraphLoopKind; + nodeIds: string[]; + edgeIds: string[]; + agentKeys: string[]; + providers: string[]; + segments: number[]; + state: AgentState; + transitionObservations: number; + lastSeenAt?: number; +} + +export interface AgentGraphWindow { + retainedEvents: number; + graphEvents: number; + oldestEventAt?: number; + newestEventAt?: number; +} + +export interface AgentGraphProviderCoverage { + agents: number; + agentsWithEvents: number; + retainedEvents: number; + history: GraphHistoryStatus; + note?: string; +} + +export interface AgentGraphCoverage { + providers: Record; +} + +export interface AgentGraphStats { + agents: number; + steps: number; + edges: number; + transitionEdges: number; + transitions: number; + loops: number; + activeLoops: number; + errorLoops: number; +} + +export interface AgentGraphSnapshot { + version: 1; + source: "observed-events"; + ts: number; + window: AgentGraphWindow; + coverage: AgentGraphCoverage; + nodes: AgentGraphNode[]; + edges: AgentGraphEdge[]; + loops: AgentGraphLoop[]; + stats: AgentGraphStats; +} + +export interface AgentGraphInput { + source: AgentGraphSnapshot["source"]; + ts: number; + window: AgentGraphWindow; + coverage?: AgentGraphCoverage; + nodes: AgentGraphNode[]; + edges: AgentGraphEdge[]; +} diff --git a/src/harnessHook.ts b/src/harnessHook.ts new file mode 100644 index 0000000..0160a89 --- /dev/null +++ b/src/harnessHook.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import { + isHookHarnessId, + normalizeHarnessHookPayload, +} from "./harnessHookModel.js"; +import { harnessHookAcknowledgement } from "./harnessHookOutput.js"; +import { + flushHarnessEventPersistence, + queueHarnessEventPersistence, +} from "./services/harnessEventLog.js"; + +const MAX_HOOK_INPUT_BYTES = 1024 * 1024; + +async function readStdin(): Promise { + if (process.stdin.isTTY) return ""; + const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of process.stdin) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.length; + if (totalBytes > MAX_HOOK_INPUT_BYTES) return ""; + chunks.push(buffer); + } + return Buffer.concat(chunks, totalBytes).toString("utf8"); +} + +async function main(): Promise { + const harnessId = process.argv[2]; + if (!isHookHarnessId(harnessId)) return; + + try { + const input = await readStdin(); + if (input.trim()) { + let payload: unknown; + try { + payload = JSON.parse(input); + } catch { + payload = undefined; + } + const event = normalizeHarnessHookPayload( + harnessId, + payload, + Date.now() + ); + if (event) { + await queueHarnessEventPersistence(event); + await flushHarnessEventPersistence(); + } + } + } catch { + // Hooks must never block or fail the owning harness. + } finally { + const acknowledgement = harnessHookAcknowledgement(harnessId); + if (acknowledgement) process.stdout.write(acknowledgement); + } +} + +void main(); diff --git a/src/harnessHookModel.ts b/src/harnessHookModel.ts new file mode 100644 index 0000000..6bcec96 --- /dev/null +++ b/src/harnessHookModel.ts @@ -0,0 +1,422 @@ +import { createHash } from "node:crypto"; +import type { HarnessId } from "./harnesses.js"; +import type { EventSummary } from "./types.js"; + +export const hookHarnessIds = [ + "claude", + "hermes", + "kimi", + "factory", + "gemini", + "qwen", + "copilot", +] as const satisfies readonly HarnessId[]; + +export type HookHarnessId = (typeof hookHarnessIds)[number]; +export type CanonicalHarnessHookType = + | "SessionStart" + | "SessionEnd" + | "UserPromptSubmit" + | "UserPromptExpansion" + | "MessageDisplay" + | "PreToolUse" + | "PostToolUse" + | "PostToolUseFailure" + | "PostToolBatch" + | "PermissionRequest" + | "PermissionResult" + | "PermissionDenied" + | "SubagentStart" + | "SubagentStop" + | "TaskCreated" + | "TaskCompleted" + | "PreVerify" + | "ErrorOccurred" + | "Stop" + | "StopFailure" + | "Interrupt" + | "PreCompact" + | "PostCompact" + | "Notification"; + +export interface NormalizedHarnessHookEvent { + version: 1; + harnessId: HookHarnessId; + type: CanonicalHarnessHookType; + sessionKey: string; + timestamp: number; + cwdKey?: string; + turnKey?: string; + toolName?: string; + agentType?: string; + notificationType?: string; + final?: boolean; +} + +const MAX_LABEL_LENGTH = 160; +const MAX_IDENTIFIER_LENGTH = 512; +export const MAX_HOOK_FUTURE_SKEW_MS = 5 * 60_000; +const SAFE_TEXT_RE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g; +const DIRECT_TYPES = new Map([ + ["sessionstart", "SessionStart"], + ["sessionend", "SessionEnd"], + ["userpromptsubmit", "UserPromptSubmit"], + ["userpromptsubmitted", "UserPromptSubmit"], + ["userpromptexpansion", "UserPromptExpansion"], + ["messagedisplay", "MessageDisplay"], + ["pretooluse", "PreToolUse"], + ["posttooluse", "PostToolUse"], + ["posttoolusefailure", "PostToolUseFailure"], + ["posttoolbatch", "PostToolBatch"], + ["permissionrequest", "PermissionRequest"], + ["permissionresult", "PermissionResult"], + ["permissiondenied", "PermissionDenied"], + ["subagentstart", "SubagentStart"], + ["subagentstop", "SubagentStop"], + ["taskcreated", "TaskCreated"], + ["taskcompleted", "TaskCompleted"], + ["preverify", "PreVerify"], + ["erroroccurred", "ErrorOccurred"], + ["agentstop", "Stop"], + ["stop", "Stop"], + ["stopfailure", "StopFailure"], + ["interrupt", "Interrupt"], + ["precompact", "PreCompact"], + ["postcompact", "PostCompact"], + ["notification", "Notification"], +]); +const HERMES_TYPES = new Map([ + ["pretoolcall", "PreToolUse"], + ["posttoolcall", "PostToolUse"], + ["prellmcall", "UserPromptSubmit"], + ["postllmcall", "Stop"], + ["preverify", "PreVerify"], + ["onsessionstart", "SessionStart"], + ["onsessionend", "SessionEnd"], + ["onsessionfinalize", "SessionEnd"], + ["onsessionreset", "SessionEnd"], + ["subagentstart", "SubagentStart"], + ["subagentstop", "SubagentStop"], + ["agentstart", "UserPromptSubmit"], + ["agentend", "Stop"], +]); +const GEMINI_TYPES = new Map([ + ["sessionstart", "SessionStart"], + ["sessionend", "SessionEnd"], + ["beforeagent", "UserPromptSubmit"], + ["afteragent", "Stop"], + ["aftermodel", "MessageDisplay"], + ["beforetool", "PreToolUse"], + ["aftertool", "PostToolUse"], + ["precompress", "PreCompact"], + ["notification", "Notification"], +]); + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function compactName(value: string): string { + return value.replace(/[^a-z0-9]/gi, "").toLowerCase(); +} + +function safeText(value: unknown, maxLength = MAX_LABEL_LENGTH): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value + .replace(SAFE_TEXT_RE, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); + return normalized || undefined; +} + +function identifier(value: unknown): string | undefined { + return safeText(value, MAX_IDENTIFIER_LENGTH); +} + +function firstString( + payload: Record, + keys: readonly string[] +): string | undefined { + for (const key of keys) { + const value = identifier(payload[key]); + if (value) return value; + } + return undefined; +} + +function firstLabel( + payload: Record, + keys: readonly string[] +): string | undefined { + for (const key of keys) { + const value = safeText(payload[key]); + if (value) return value; + } + return undefined; +} + +function nestedLabel( + payload: Record, + containerKey: string, + keys: readonly string[] +): string | undefined { + const container = payload[containerKey]; + if (!isRecord(container)) return undefined; + return firstLabel(container, keys); +} + +function parseTimestamp(value: unknown, receivedAt: number): number | undefined { + let parsed: number | undefined; + if (typeof value === "number" && Number.isFinite(value)) { + parsed = value < 100_000_000_000 ? value * 1000 : value; + } else if (typeof value === "string") { + const asNumber = Number(value); + if (Number.isFinite(asNumber) && value.trim() !== "") { + parsed = asNumber < 100_000_000_000 ? asNumber * 1000 : asNumber; + } else { + const asDate = Date.parse(value); + if (!Number.isNaN(asDate)) parsed = asDate; + } + } + const fallback = + Number.isFinite(receivedAt) && receivedAt >= 0 ? receivedAt : Date.now(); + if (parsed === undefined) return fallback; + if (parsed < 0) return undefined; + return parsed > fallback + MAX_HOOK_FUTURE_SKEW_MS ? fallback : parsed; +} + +function opaqueKey(scope: string, value: string): string { + return createHash("sha256") + .update(scope) + .update("\0") + .update(value) + .digest("hex") + .slice(0, 24); +} + +function canonicalType( + harnessId: HookHarnessId, + rawType: string +): CanonicalHarnessHookType | undefined { + const compact = compactName(rawType); + if (harnessId === "hermes") return HERMES_TYPES.get(compact); + if (harnessId === "gemini") return GEMINI_TYPES.get(compact); + return DIRECT_TYPES.get(compact); +} + +function extractRawType(payload: Record): string | undefined { + return firstString(payload, [ + "hook_event_name", + "hookEventName", + "event_name", + "eventName", + "event", + "type", + ]); +} + +function extractSessionId( + harnessId: HookHarnessId, + payload: Record +): string | undefined { + const direct = firstString(payload, [ + "session_id", + "sessionId", + "conversation_id", + "conversationId", + ]); + if (direct) return direct; + if (harnessId !== "hermes") return undefined; + return firstString(payload, ["task_id", "taskId"]); +} + +function extractTurnId(payload: Record): string | undefined { + const direct = firstString(payload, [ + "turn_id", + "turnId", + "api_request_id", + "apiRequestId", + ]); + if (direct) return direct; + const extra = payload.extra; + if (!isRecord(extra)) return undefined; + return firstString(extra, [ + "turn_id", + "turnId", + "api_request_id", + "apiRequestId", + ]); +} + +function extractBoolean( + payload: Record, + keys: readonly string[] +): boolean | undefined { + for (const key of keys) { + if (typeof payload[key] === "boolean") return payload[key] as boolean; + } + return undefined; +} + +export function isHookHarnessId(value: unknown): value is HookHarnessId { + return ( + typeof value === "string" && + (hookHarnessIds as readonly string[]).includes(value) + ); +} + +export function normalizeHarnessHookPayload( + harnessId: HookHarnessId, + input: unknown, + receivedAt: number = Date.now() +): NormalizedHarnessHookEvent | undefined { + if (!isRecord(input)) return undefined; + const rawType = extractRawType(input); + const sessionId = extractSessionId(harnessId, input); + if (!rawType || !sessionId) return undefined; + const type = canonicalType(harnessId, rawType); + if (!type) return undefined; + const timestamp = parseTimestamp(input.timestamp, receivedAt); + if (timestamp === undefined) return undefined; + + const cwd = firstString(input, ["cwd", "working_directory", "workingDirectory"]); + const turnId = extractTurnId(input); + const toolName = + firstLabel(input, ["tool_name", "toolName", "matcher"]) ?? + nestedLabel(input, "extra", ["tool_name", "toolName"]); + const agentType = + firstLabel(input, [ + "agent_type", + "agentType", + "agent_name", + "agentName", + "subagent_type", + "subagentType", + ]) ?? + nestedLabel(input, "extra", [ + "agent_type", + "agentType", + "agent_name", + "agentName", + "child_role", + "childRole", + ]); + const notificationType = firstLabel(input, [ + "notification_type", + "notificationType", + "reason", + ]); + const final = extractBoolean(input, ["final", "is_final", "isFinal"]); + + return { + version: 1, + harnessId, + type, + sessionKey: opaqueKey(`session:${harnessId}`, sessionId), + timestamp, + ...(cwd ? { cwdKey: opaqueKey(`cwd:${harnessId}`, cwd) } : {}), + ...(turnId + ? { turnKey: opaqueKey(`turn:${harnessId}`, `${sessionId}\0${turnId}`) } + : {}), + ...(toolName ? { toolName } : {}), + ...(agentType ? { agentType } : {}), + ...(notificationType ? { notificationType } : {}), + ...(final === undefined ? {} : { final }), + }; +} + +export function harnessHookEventSummary( + event: NormalizedHarnessHookEvent +): EventSummary | undefined { + let type: string = event.type; + let summary = `event: ${event.type}`; + let isError = false; + + switch (event.type) { + case "UserPromptSubmit": + summary = "prompt"; + break; + case "UserPromptExpansion": + return undefined; + case "MessageDisplay": + if (event.final === false) return undefined; + summary = "message"; + break; + case "PreToolUse": + case "PostToolUse": + case "PostToolBatch": + case "PermissionRequest": + case "PermissionResult": + case "PermissionDenied": + summary = `tool: ${event.toolName || event.type}`; + break; + case "PostToolUseFailure": + summary = `tool: ${event.toolName || event.type}`; + isError = true; + break; + case "SubagentStart": + case "SubagentStop": + summary = event.agentType + ? `tool: subagent ${event.agentType}` + : "tool: subagent"; + break; + case "TaskCreated": + case "TaskCompleted": + summary = "tool: task"; + break; + case "PreVerify": + summary = "tool: verify"; + break; + case "ErrorOccurred": + type = "error.occurred"; + isError = true; + break; + case "StopFailure": + type = "turn.failed"; + isError = true; + break; + case "Interrupt": + type = "turn.interrupted"; + break; + case "Stop": + type = "turn.completed"; + break; + case "SessionStart": + type = "session.started"; + break; + case "SessionEnd": + type = "session.ended"; + break; + case "PreCompact": + case "PostCompact": + case "Notification": + return undefined; + } + + return { + ts: event.timestamp, + type, + summary, + ...(isError ? { isError: true } : {}), + ...(event.turnKey ? { turnId: event.turnKey } : {}), + }; +} + +export function harnessHookEventKey( + event: NormalizedHarnessHookEvent +): string { + return JSON.stringify([ + event.version, + event.harnessId, + event.type, + event.sessionKey, + event.timestamp, + event.cwdKey ?? null, + event.turnKey ?? null, + event.toolName ?? null, + event.agentType ?? null, + event.notificationType ?? null, + event.final ?? null, + ]); +} diff --git a/src/harnessHookOutput.ts b/src/harnessHookOutput.ts new file mode 100644 index 0000000..036199d --- /dev/null +++ b/src/harnessHookOutput.ts @@ -0,0 +1,9 @@ +import type { HookHarnessId } from "./harnessHookModel.js"; + +const JSON_ACK_HARNESSES = new Set(["gemini", "copilot"]); + +export function harnessHookAcknowledgement( + harnessId: HookHarnessId +): string { + return JSON_ACK_HARNESSES.has(harnessId) ? "{}\n" : ""; +} diff --git a/src/harnessKeys.ts b/src/harnessKeys.ts new file mode 100644 index 0000000..a571a16 --- /dev/null +++ b/src/harnessKeys.ts @@ -0,0 +1,27 @@ +import { createHash } from "node:crypto"; +import type { HarnessId } from "./harnesses.js"; + +function opaqueHarnessKey(scope: string, value: string): string { + return createHash("sha256") + .update(scope) + .update("\0") + .update(value) + .digest("hex") + .slice(0, 24); +} + +export function harnessSessionKey(harnessId: HarnessId, sessionId: string): string { + return opaqueHarnessKey(`session:${harnessId}`, sessionId); +} + +export function harnessCwdKey(harnessId: HarnessId, cwd: string): string { + return opaqueHarnessKey(`cwd:${harnessId}`, cwd); +} + +export function harnessTurnKey( + harnessId: HarnessId, + sessionId: string, + turnId: string +): string { + return opaqueHarnessKey(`turn:${harnessId}`, `${sessionId}\0${turnId}`); +} diff --git a/src/harnessSnapshot.ts b/src/harnessSnapshot.ts new file mode 100644 index 0000000..85f7ade --- /dev/null +++ b/src/harnessSnapshot.ts @@ -0,0 +1,174 @@ +import { + harnessIdForKind, + type HarnessId, +} from "./harnesses.js"; +import { + isHookHarnessId, + type HookHarnessId, +} from "./harnessHookModel.js"; +import { + listHarnessActivity, + type HarnessSessionState, +} from "./services/harnessEvents.js"; +import type { + AgentSnapshot, + EventSummary, + SnapshotPayload, + WorkSummary, +} from "./types.js"; + +const MAX_EVENTS = 50; + +function eventKey(event: EventSummary): string { + return JSON.stringify([ + event.ts, + event.type, + event.summary, + event.isError === true, + event.turnId ?? null, + ]); +} + +function mergeEvents( + existing: EventSummary[] | undefined, + observed: EventSummary[] +): EventSummary[] { + const seen = new Set(); + return [...(existing ?? []), ...observed] + .map((event, index) => ({ event, index })) + .filter(({ event }) => { + const key = eventKey(event); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((a, b) => a.event.ts - b.event.ts || a.index - b.index) + .slice(-MAX_EVENTS) + .map(({ event }) => event); +} + +function summaryFromEvents( + existing: WorkSummary | undefined, + events: EventSummary[] +): WorkSummary | undefined { + if (!existing && events.length === 0) return undefined; + const summary: WorkSummary = { ...(existing ?? {}) }; + for (const event of events) { + if (event.summary === "prompt" || event.summary.startsWith("prompt:")) { + summary.lastPrompt = event.summary; + } + if (event.summary === "message") summary.lastMessage = event.summary; + if (event.summary.startsWith("tool:")) summary.lastTool = event.summary; + if (event.summary.startsWith("cmd:")) summary.lastCommand = event.summary; + if (event.summary.startsWith("edit:")) summary.lastEdit = event.summary; + if (!event.summary.startsWith("event:")) summary.current = event.summary; + } + return summary; +} + +function maxDefined( + left: number | undefined, + right: number | undefined +): number | undefined { + if (typeof left !== "number") return right; + if (typeof right !== "number") return left; + return Math.max(left, right); +} + +function stateForHarnessSession( + state: HarnessSessionState +): AgentSnapshot["state"] { + if (state.hasError) return "error"; + return state.inFlight ? "active" : "idle"; +} + +function selectState( + agent: AgentSnapshot, + states: HarnessSessionState[], + singleAgent: boolean +): HarnessSessionState | undefined { + if (agent.harnessSessionKey) { + const exact = states.find( + (state) => state.sessionKey === agent.harnessSessionKey + ); + if (exact) return exact; + } + if (agent.harnessCwdKey) { + const cwdMatches = states.filter( + (state) => state.cwdKey === agent.harnessCwdKey + ); + if (cwdMatches.length === 1) return cwdMatches[0]; + if (cwdMatches.length > 1) return undefined; + } + return singleAgent && states.length === 1 ? states[0] : undefined; +} + +function attachState( + agent: AgentSnapshot, + state: HarnessSessionState +): AgentSnapshot { + const events = mergeEvents(agent.events, state.events); + const summary = summaryFromEvents(agent.summary, events); + const hookIsCurrent = state.lastSeenAt >= (agent.lastEventAt ?? 0); + const nextState = hookIsCurrent ? stateForHarnessSession(state) : agent.state; + return { + ...agent, + state: nextState, + activityReason: hookIsCurrent + ? nextState === "error" + ? "harness_hook_error" + : nextState === "active" + ? "harness_hook_in_flight" + : "harness_hook_idle" + : agent.activityReason, + lastEventAt: maxDefined(agent.lastEventAt, state.lastSeenAt), + lastActivityAt: + hookIsCurrent && nextState === "idle" + ? undefined + : maxDefined(agent.lastActivityAt, state.lastActivityAt), + doing: summary?.current ?? agent.doing, + summary, + events, + harnessSessionKey: state.sessionKey, + harnessCwdKey: state.cwdKey ?? agent.harnessCwdKey, + }; +} + +export function attachHarnessEvents(snapshot: SnapshotPayload): SnapshotPayload { + const agentsByHarness = new Map(); + for (const agent of snapshot.agents) { + const harnessId: HarnessId = harnessIdForKind(agent.kind); + if (!isHookHarnessId(harnessId)) continue; + const current = agentsByHarness.get(harnessId) ?? []; + current.push(agent); + agentsByHarness.set(harnessId, current); + } + if (agentsByHarness.size === 0) return snapshot; + + const stateByIdentity = new Map(); + for (const [harnessId, agents] of agentsByHarness) { + const states = listHarnessActivity(harnessId, snapshot.ts); + const singleAgent = agents.length === 1; + const claimedSessions = new Set(); + + for (const agent of agents) { + const selected = selectState( + agent, + states.filter((state) => !claimedSessions.has(state.sessionKey)), + singleAgent + ); + if (!selected) continue; + claimedSessions.add(selected.sessionKey); + stateByIdentity.set(agent.identity || agent.id, selected); + } + } + + if (stateByIdentity.size === 0) return snapshot; + return { + ...snapshot, + agents: snapshot.agents.map((agent) => { + const state = stateByIdentity.get(agent.identity || agent.id); + return state ? attachState(agent, state) : agent; + }), + }; +} diff --git a/src/harnesses.ts b/src/harnesses.ts new file mode 100644 index 0000000..e06b2e8 --- /dev/null +++ b/src/harnesses.ts @@ -0,0 +1,480 @@ +export const agentKinds = [ + "tui", + "exec", + "app-server", + "opencode-tui", + "opencode-cli", + "opencode-server", + "claude-tui", + "claude-cli", + "openclaw-cli", + "openclaw-server", + "hermes-cli", + "hermes-server", + "kimi-cli", + "minimax-cli", + "cursor-cli", + "warp-cli", + "factory-cli", + "gemini-cli", + "antigravity-cli", + "qwen-cli", + "copilot-cli", + "aider-cli", + "goose-cli", + "amp-cli", + "amazon-q-cli", + "kiro-cli", + "openhands-cli", + "cline-ide", + "roo-code-ide", + "windsurf-ide", + "junie-ide", + "replit-agent", + "zed-agent", + "unknown", +] as const; + +export type AgentKind = (typeof agentKinds)[number]; + +export const harnessIds = [ + "codex", + "opencode", + "claude", + "openclaw", + "hermes", + "kimi", + "minimax", + "cursor", + "warp", + "factory", + "gemini", + "antigravity", + "qwen", + "copilot", + "aider", + "goose", + "amp", + "amazon-q", + "kiro", + "openhands", + "cline", + "roo-code", + "windsurf", + "junie", + "replit", + "zed", + "other", +] as const; + +export type HarnessId = (typeof harnessIds)[number]; +export type HarnessClass = + | "local-cli" + | "local-server" + | "ide-agent" + | "remote-agent" + | "tool-provider"; +export type HarnessTelemetry = + | "native-events" + | "hooks" + | "structured-stream" + | "remote-api" + | "process-only" + | "tool-only" + | "manual"; + +export interface HarnessProcessRule { + binaries: readonly string[]; + kind: AgentKind; + requiredAnyToken?: readonly string[]; +} + +export interface HarnessDefinition { + id: HarnessId; + displayName: string; + kinds: readonly AgentKind[]; + class: HarnessClass; + telemetry: HarnessTelemetry; + docs?: string; + processRules?: readonly HarnessProcessRule[]; + note?: string; +} + +export const harnessDefinitions: readonly HarnessDefinition[] = [ + { + id: "codex", + displayName: "Codex", + kinds: ["tui", "exec", "app-server"], + class: "local-cli", + telemetry: "native-events", + docs: "https://developers.openai.com/codex/config-reference", + }, + { + id: "opencode", + displayName: "OpenCode", + kinds: ["opencode-tui", "opencode-cli", "opencode-server"], + class: "local-server", + telemetry: "native-events", + docs: "https://opencode.ai/docs/server/", + }, + { + id: "claude", + displayName: "Claude Code", + kinds: ["claude-tui", "claude-cli"], + class: "local-cli", + telemetry: "hooks", + docs: "https://code.claude.com/docs/en/hooks", + }, + { + id: "openclaw", + displayName: "OpenClaw", + kinds: ["openclaw-cli", "openclaw-server"], + class: "local-server", + telemetry: "native-events", + docs: "https://docs.openclaw.ai/concepts/agent-loop", + processRules: [ + { + binaries: ["openclaw"], + kind: "openclaw-server", + requiredAnyToken: ["gateway", "serve", "server", "daemon"], + }, + { binaries: ["openclaw"], kind: "openclaw-cli" }, + ], + }, + { + id: "hermes", + displayName: "Hermes Agent", + kinds: ["hermes-cli", "hermes-server"], + class: "local-cli", + telemetry: "hooks", + docs: "https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks/", + processRules: [ + { + binaries: ["hermes"], + kind: "hermes-server", + requiredAnyToken: ["gateway", "serve", "server", "daemon"], + }, + { binaries: ["hermes"], kind: "hermes-cli" }, + ], + }, + { + id: "kimi", + displayName: "Kimi Code", + kinds: ["kimi-cli"], + class: "local-cli", + telemetry: "hooks", + docs: "https://moonshotai.github.io/kimi-code/en/customization/hooks", + processRules: [ + { binaries: ["kimi", "kimi-code", "kimi-cli"], kind: "kimi-cli" }, + ], + }, + { + id: "minimax", + displayName: "MiniMax CLI", + kinds: ["minimax-cli"], + class: "tool-provider", + telemetry: "tool-only", + docs: "https://platform.minimax.io/docs/token-plan/minimax-cli", + processRules: [ + { binaries: ["mmx", "mmx-cli", "minimax-cli"], kind: "minimax-cli" }, + ], + note: + "MiniMax CLI is currently modeled as a tool/model integration, not a full coding-agent runtime.", + }, + { + id: "cursor", + displayName: "Cursor Agent", + kinds: ["cursor-cli"], + class: "local-cli", + telemetry: "structured-stream", + docs: "https://docs.cursor.com/en/cli/using", + processRules: [{ binaries: ["cursor-agent"], kind: "cursor-cli" }], + note: + "Cursor CLI exposes structured stream output and a partial hook set; coverage must state which source was observed.", + }, + { + id: "warp", + displayName: "Warp Oz", + kinds: ["warp-cli"], + class: "remote-agent", + telemetry: "remote-api", + docs: "https://docs.warp.dev/reference/cli", + processRules: [ + { + binaries: ["oz", "oz-preview"], + kind: "warp-cli", + requiredAnyToken: ["agent"], + }, + ], + }, + { + id: "factory", + displayName: "Factory Droid", + kinds: ["factory-cli"], + class: "local-cli", + telemetry: "hooks", + docs: "https://docs.factory.ai/reference/hooks-reference", + processRules: [{ binaries: ["droid"], kind: "factory-cli" }], + }, + { + id: "gemini", + displayName: "Gemini CLI", + kinds: ["gemini-cli"], + class: "local-cli", + telemetry: "hooks", + docs: "https://geminicli.com/docs/hooks/", + processRules: [{ binaries: ["gemini"], kind: "gemini-cli" }], + }, + { + id: "antigravity", + displayName: "Antigravity CLI", + kinds: ["antigravity-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [ + { binaries: ["antigravity", "antigravity-cli"], kind: "antigravity-cli" }, + ], + }, + { + id: "qwen", + displayName: "Qwen Code", + kinds: ["qwen-cli"], + class: "local-cli", + telemetry: "hooks", + docs: "https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/", + processRules: [{ binaries: ["qwen", "qwen-code"], kind: "qwen-cli" }], + }, + { + id: "copilot", + displayName: "GitHub Copilot CLI", + kinds: ["copilot-cli"], + class: "local-cli", + telemetry: "hooks", + docs: "https://docs.github.com/en/copilot/reference/hooks-reference", + processRules: [{ binaries: ["copilot"], kind: "copilot-cli" }], + }, + { + id: "aider", + displayName: "Aider", + kinds: ["aider-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [{ binaries: ["aider"], kind: "aider-cli" }], + }, + { + id: "goose", + displayName: "Goose", + kinds: ["goose-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [{ binaries: ["goose"], kind: "goose-cli" }], + }, + { + id: "amp", + displayName: "Amp", + kinds: ["amp-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [{ binaries: ["amp"], kind: "amp-cli" }], + }, + { + id: "amazon-q", + displayName: "Amazon Q Developer", + kinds: ["amazon-q-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [ + { + binaries: ["q"], + kind: "amazon-q-cli", + requiredAnyToken: ["chat", "agent"], + }, + ], + }, + { + id: "kiro", + displayName: "Kiro", + kinds: ["kiro-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [{ binaries: ["kiro", "kiro-cli"], kind: "kiro-cli" }], + }, + { + id: "openhands", + displayName: "OpenHands", + kinds: ["openhands-cli"], + class: "local-cli", + telemetry: "process-only", + processRules: [ + { binaries: ["openhands", "openhands-cli"], kind: "openhands-cli" }, + ], + }, + { + id: "cline", + displayName: "Cline", + kinds: ["cline-ide"], + class: "ide-agent", + telemetry: "manual", + }, + { + id: "roo-code", + displayName: "Roo Code", + kinds: ["roo-code-ide"], + class: "ide-agent", + telemetry: "manual", + }, + { + id: "windsurf", + displayName: "Windsurf Cascade", + kinds: ["windsurf-ide"], + class: "ide-agent", + telemetry: "manual", + }, + { + id: "junie", + displayName: "JetBrains Junie", + kinds: ["junie-ide"], + class: "ide-agent", + telemetry: "manual", + }, + { + id: "replit", + displayName: "Replit Agent", + kinds: ["replit-agent"], + class: "remote-agent", + telemetry: "remote-api", + }, + { + id: "zed", + displayName: "Zed Agent", + kinds: ["zed-agent"], + class: "ide-agent", + telemetry: "manual", + }, + { + id: "other", + displayName: "Other agent", + kinds: ["unknown"], + class: "local-cli", + telemetry: "process-only", + }, +] as const; + +const agentKindSet = new Set(agentKinds); +const harnessById = new Map( + harnessDefinitions.map((definition) => [definition.id, definition]) +); +const harnessByKind = new Map(); +for (const definition of harnessDefinitions) { + for (const kind of definition.kinds) harnessByKind.set(kind, definition); +} + +function stripQuotes(value: string): string { + return value.replace(/^["']|["']$/g, ""); +} + +function basename(value: string): string { + const parts = stripQuotes(value).split(/[\\/]/); + return (parts[parts.length - 1] || value) + .toLowerCase() + .replace(/\.exe$/, ""); +} + +function commandTokens(command: string | undefined): string[] { + if (!command) return []; + return ( + command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map(stripQuotes) ?? [] + ); +} + +function ruleMatches( + rule: HarnessProcessRule, + command: string | undefined, + processName: string | undefined +): boolean { + const tokens = commandTokens(command); + const executable = tokens[0] ? basename(tokens[0]) : undefined; + const name = processName ? basename(processName) : undefined; + const binaries = new Set( + rule.binaries.map((binary) => binary.toLowerCase().replace(/\.exe$/, "")) + ); + if (!((executable && binaries.has(executable)) || (name && binaries.has(name)))) { + return false; + } + if (!rule.requiredAnyToken?.length) return true; + const commandTokensLower = new Set( + tokens.slice(1).map((token) => token.toLowerCase()) + ); + return rule.requiredAnyToken.some((token) => + commandTokensLower.has(token.toLowerCase()) + ); +} + +export interface DetectedHarnessProcess { + harness: HarnessDefinition; + kind: AgentKind; +} + +export function isAgentKind(value: unknown): value is AgentKind { + return typeof value === "string" && agentKindSet.has(value); +} + +export function harnessForId(id: HarnessId): HarnessDefinition { + return harnessById.get(id) ?? harnessById.get("other")!; +} + +export function harnessForKind(kind: AgentKind): HarnessDefinition { + return harnessByKind.get(kind) ?? harnessById.get("other")!; +} + +export function harnessIdForKind(kind: AgentKind): HarnessId { + return harnessForKind(kind).id; +} + +export function detectGenericHarnessProcess( + command: string | undefined, + processName: string | undefined +): DetectedHarnessProcess | undefined { + for (const harness of harnessDefinitions) { + if ( + harness.id === "codex" || + harness.id === "opencode" || + harness.id === "claude" + ) { + continue; + } + for (const rule of harness.processRules ?? []) { + if (ruleMatches(rule, command, processName)) { + return { harness, kind: rule.kind }; + } + } + } + return undefined; +} + +export function harnessCoverageNote( + harness: HarnessDefinition, + hasEvents: boolean +): string | undefined { + if (hasEvents) return undefined; + if (harness.note) return harness.note; + if (harness.telemetry === "hooks") { + return `${harness.displayName} was detected, but no retained hook events were available.`; + } + if (harness.telemetry === "native-events") { + return `${harness.displayName} was detected, but no native runtime events were retained.`; + } + if (harness.telemetry === "structured-stream") { + return `${harness.displayName} was detected, but no structured stream was attached to this snapshot.`; + } + if (harness.telemetry === "remote-api") { + return `${harness.displayName} was detected locally; remote run history requires an explicit API adapter.`; + } + if (harness.telemetry === "tool-only") { + return `${harness.displayName} is a tool/model integration rather than a complete observed agent loop.`; + } + if (harness.telemetry === "manual") { + return `${harness.displayName} requires an IDE or extension adapter; process detection alone is not reliable.`; + } + return `${harness.displayName} currently has process-level coverage only.`; +} diff --git a/src/scanSnapshot.ts b/src/scanSnapshot.ts new file mode 100644 index 0000000..cf694aa --- /dev/null +++ b/src/scanSnapshot.ts @@ -0,0 +1,37 @@ +import type { ScanOptions } from "./scan.js"; +import { scanCodexProcesses } from "./scan.js"; +import { attachClaudeEvents } from "./claudeSnapshot.js"; +import { attachGenericHarnessProcesses } from "./genericHarnessSnapshot.js"; +import { attachHarnessEvents } from "./harnessSnapshot.js"; +import { hydrateClaudeEventsFromDisk } from "./services/claudeEvents.js"; +import { hydrateHarnessEventsFromDisk } from "./services/harnessEvents.js"; +import type { SnapshotPayload } from "./types.js"; + +export async function scanSnapshot( + options: ScanOptions = { mode: "full" } +): Promise { + await Promise.all([ + hydrateClaudeEventsFromDisk(), + hydrateHarnessEventsFromDisk(), + ]); + const snapshot = await scanCodexProcesses(options); + const withClaudeEvents = attachClaudeEvents(snapshot); + const withGenericHarnesses = await attachGenericHarnessProcesses(withClaudeEvents); + return attachHarnessEvents(withGenericHarnesses); +} + +const isDirectRun = + process.argv[1] && + (process.argv[1].endsWith("scanSnapshot.js") || + process.argv[1].endsWith("scanSnapshot.ts")); + +if (isDirectRun) { + scanSnapshot({ mode: "full" }) + .then((snapshot) => { + process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`); + }) + .catch((error) => { + process.stderr.write(`[consensus] scan error: ${String(error)}\n`); + process.exit(1); + }); +} diff --git a/src/services/claudeEventLog.ts b/src/services/claudeEventLog.ts new file mode 100644 index 0000000..f5328c6 --- /dev/null +++ b/src/services/claudeEventLog.ts @@ -0,0 +1,159 @@ +import { + appendFile, + chmod, + mkdir, + readFile, + rename, + stat, + writeFile, +} from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import type { ClaudeEvent } from "../claude/types.js"; +import { + CLAUDE_STALE_TTL_MS, + fromStoredClaudeEvent, + isStoredClaudeEvent, + storedClaudeEventKey, + toStoredClaudeEvent, + type StoredClaudeEvent, +} from "./claudeEventModel.js"; + +const DEFAULT_EVENT_LOG_MAX_BYTES = 1024 * 1024; +const MAX_SEEN_STORED_EVENTS = 5000; +const seenStoredEvents = new Set(); +let seenEventLogPath: string | undefined; +let persistenceQueue: Promise = Promise.resolve(); + +function useSeenEventLogPath(filePath: string): void { + if (seenEventLogPath === filePath) return; + seenStoredEvents.clear(); + seenEventLogPath = filePath; +} + +function rememberStoredEvent(key: string): boolean { + if (seenStoredEvents.has(key)) return false; + seenStoredEvents.add(key); + while (seenStoredEvents.size > MAX_SEEN_STORED_EVENTS) { + const oldest = seenStoredEvents.values().next().value; + if (typeof oldest !== "string") break; + seenStoredEvents.delete(oldest); + } + return true; +} + +function resolveEventLogPath(): string | undefined { + const configured = process.env.CONSENSUS_CLAUDE_EVENT_LOG?.trim(); + if (configured) { + const lowered = configured.toLowerCase(); + if (lowered === "0" || lowered === "false" || lowered === "off") { + return undefined; + } + return path.resolve(configured); + } + return path.join(homedir(), ".consensus", "claude-events.jsonl"); +} + +function resolveEventLogMaxBytes(): number { + const parsed = Number(process.env.CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_EVENT_LOG_MAX_BYTES; + return Math.max(64 * 1024, Math.floor(parsed)); +} + +function newestCompleteLines(buffer: Buffer, keepBytes: number): Buffer { + if (buffer.length <= keepBytes) return buffer; + const tail = buffer.subarray(Math.max(0, buffer.length - keepBytes)); + const firstNewline = tail.indexOf(0x0a); + return firstNewline >= 0 ? tail.subarray(firstNewline + 1) : Buffer.alloc(0); +} + +async function trimEventLog(filePath: string, maxBytes: number): Promise { + let info; + try { + info = await stat(filePath); + } catch { + return; + } + if (info.size <= maxBytes) return; + + const content = await readFile(filePath); + const keepBytes = Math.max(32 * 1024, Math.floor(maxBytes / 2)); + const trimmed = newestCompleteLines(content, keepBytes); + const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + await writeFile(tempPath, trimmed, { mode: 0o600 }); + await rename(tempPath, filePath); + await chmod(filePath, 0o600).catch(() => undefined); +} + +async function persistStoredEvent( + filePath: string, + event: StoredClaudeEvent +): Promise { + await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + await appendFile(filePath, `${JSON.stringify(event)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await chmod(filePath, 0o600).catch(() => undefined); + await trimEventLog(filePath, resolveEventLogMaxBytes()); +} + +export function queueClaudeEventPersistence(event: ClaudeEvent): Promise { + const stored = toStoredClaudeEvent(event); + const filePath = resolveEventLogPath(); + if (!stored || !filePath) return persistenceQueue; + + useSeenEventLogPath(filePath); + const key = storedClaudeEventKey(stored); + if (!rememberStoredEvent(key)) return persistenceQueue; + + persistenceQueue = persistenceQueue + .then(() => persistStoredEvent(filePath, stored)) + .catch(() => { + if (seenEventLogPath === filePath) seenStoredEvents.delete(key); + }); + return persistenceQueue; +} + +export function flushClaudeEventPersistence(): Promise { + return persistenceQueue; +} + +export async function readStoredClaudeEvents(): Promise< + Array<{ event: ClaudeEvent; cwdKey?: string }> +> { + const filePath = resolveEventLogPath(); + if (!filePath) return []; + useSeenEventLogPath(filePath); + + let input: string; + try { + input = await readFile(filePath, "utf8"); + } catch { + return []; + } + + const cutoff = Date.now() - CLAUDE_STALE_TTL_MS; + const storedEvents: StoredClaudeEvent[] = []; + for (const line of input.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const parsed: unknown = JSON.parse(line); + if (!isStoredClaudeEvent(parsed) || parsed.timestamp < cutoff) continue; + storedEvents.push(parsed); + } catch { + // Ignore malformed or partially written lines. + } + } + storedEvents.sort((a, b) => a.timestamp - b.timestamp); + + const result: Array<{ event: ClaudeEvent; cwdKey?: string }> = []; + for (const stored of storedEvents) { + const key = storedClaudeEventKey(stored); + if (!rememberStoredEvent(key)) continue; + result.push({ event: fromStoredClaudeEvent(stored), cwdKey: stored.cwdKey }); + } + return result; +} diff --git a/src/services/claudeEventModel.ts b/src/services/claudeEventModel.ts new file mode 100644 index 0000000..cb88f9e --- /dev/null +++ b/src/services/claudeEventModel.ts @@ -0,0 +1,420 @@ +import { createHash } from "node:crypto"; +import type { EventSummary, WorkSummary } from "../types.js"; +import type { ClaudeEvent, ClaudeSessionState } from "../claude/types.js"; + +export const CLAUDE_STALE_TTL_MS = Number( + process.env.CONSENSUS_CLAUDE_EVENT_TTL_MS || 30 * 60 * 1000 +); +const INFLIGHT_TIMEOUT_MS = Number( + process.env.CONSENSUS_CLAUDE_INFLIGHT_TIMEOUT_MS || 15000 +); +const MAX_EVENTS = 50; +const MAX_METADATA_LABEL_LENGTH = 160; +const MAX_SESSION_ID_LENGTH = 512; +const METADATA_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g; +export const CLAUDE_EVENT_LOG_VERSION = 1 as const; + +const CANONICAL_TYPES: Record = { + setup: "Setup", + sessionstart: "SessionStart", + sessionend: "SessionEnd", + userpromptsubmit: "UserPromptSubmit", + userpromptexpansion: "UserPromptExpansion", + messagedisplay: "MessageDisplay", + pretooluse: "PreToolUse", + posttooluse: "PostToolUse", + posttoolusefailure: "PostToolUseFailure", + posttoolbatch: "PostToolBatch", + permissionrequest: "PermissionRequest", + permissiondenied: "PermissionDenied", + notification: "Notification", + subagentstart: "SubagentStart", + subagentstop: "SubagentStop", + taskcreated: "TaskCreated", + taskcompleted: "TaskCompleted", + stop: "Stop", + stopfailure: "StopFailure", + teammateidle: "TeammateIdle", + configchange: "ConfigChange", + cwdchanged: "CwdChanged", + filechanged: "FileChanged", + worktreecreate: "WorktreeCreate", + worktreeremove: "WorktreeRemove", + instructionsloaded: "InstructionsLoaded", + precompact: "PreCompact", + postcompact: "PostCompact", + elicitation: "Elicitation", + elicitationresult: "ElicitationResult", +}; + +const INFLIGHT_EVENTS = new Set([ + "UserPromptSubmit", + "UserPromptExpansion", + "MessageDisplay", + "PreToolUse", + "PermissionRequest", + "PermissionDenied", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "SubagentStart", + "SubagentStop", + "TaskCreated", + "TaskCompleted", + "Elicitation", + "ElicitationResult", +]); +const IDLE_EVENTS = new Set(["Stop", "StopFailure", "SessionEnd"]); +const NON_ACTIVITY_EVENTS = new Set([ + "Setup", + "SessionStart", + "Notification", + "TeammateIdle", + "ConfigChange", + "CwdChanged", + "FileChanged", + "WorktreeCreate", + "WorktreeRemove", + "InstructionsLoaded", + "PreCompact", + "PostCompact", + ...IDLE_EVENTS, +]); +const ERROR_EVENTS = new Set(["PostToolUseFailure", "StopFailure"]); +const GRAPH_EVENT_TYPES = new Set([ + "UserPromptSubmit", + "MessageDisplay", + "PreToolUse", + "PermissionRequest", + "PermissionDenied", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "SubagentStart", + "SubagentStop", + "TaskCreated", + "TaskCompleted", + "Stop", + "StopFailure", + "SessionEnd", + "Elicitation", + "ElicitationResult", +]); + +export interface StoredClaudeEvent { + version: typeof CLAUDE_EVENT_LOG_VERSION; + type: string; + sessionId: string; + timestamp: number; + cwdKey?: string; + notificationType?: string; + toolName?: string; + agentType?: string; + final?: boolean; +} + +export type ClaudeStateMap = Map; + +function normalizeMetadataText( + value: string | undefined, + maxLength: number +): string | undefined { + const normalized = value + ?.replace(METADATA_CONTROL_RE, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); + return normalized || undefined; +} + +export function boundedClaudeLabel(value: string | undefined): string | undefined { + return normalizeMetadataText(value, MAX_METADATA_LABEL_LENGTH); +} + +function validClaudeSessionId(value: string | undefined): string | undefined { + if (!value || value.length > MAX_SESSION_ID_LENGTH) return undefined; + const normalized = normalizeMetadataText(value, MAX_SESSION_ID_LENGTH); + return normalized === value.trim() ? normalized : undefined; +} + +export function normalizeClaudeEventType(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return trimmed; + const key = trimmed.replace(/[^a-z0-9]/gi, "").toLowerCase(); + return CANONICAL_TYPES[key] ?? boundedClaudeLabel(trimmed) ?? ""; +} + +export function stableClaudePathKey(value: string | undefined): string | undefined { + return value + ? createHash("sha256").update(value).digest("hex").slice(0, 24) + : undefined; +} + +function isActivityEvent(type: string): boolean { + return !!type && !NON_ACTIVITY_EVENTS.has(type); +} + +function shouldRetainGraphEvent(event: ClaudeEvent, type: string): boolean { + if (!GRAPH_EVENT_TYPES.has(type)) return false; + return type !== "MessageDisplay" || event.final !== false; +} + +export function shouldPersistClaudeEvent(event: ClaudeEvent, type: string): boolean { + if (shouldRetainGraphEvent(event, type)) return true; + if (type === "SessionStart" || type === "CwdChanged") return true; + return ( + type === "Notification" && + event.notificationType?.trim().toLowerCase() === "idle_prompt" + ); +} + +function summarizeClaudeEvent(event: ClaudeEvent, type: string): EventSummary { + let summary = `event: ${type}`; + if (type === "UserPromptSubmit" || type === "UserPromptExpansion") { + summary = "prompt"; + } else if (type === "MessageDisplay") { + summary = "message"; + } else if (type === "SubagentStart" || type === "SubagentStop") { + const agentType = boundedClaudeLabel(event.agentType); + summary = agentType ? `tool: subagent ${agentType}` : "tool: subagent"; + } else if (type === "TaskCreated" || type === "TaskCompleted") { + summary = "tool: task"; + } else if (type === "Elicitation" || type === "ElicitationResult") { + summary = "tool: elicitation"; + } else if ( + type === "PreToolUse" || + type === "PostToolUse" || + type === "PostToolUseFailure" || + type === "PostToolBatch" || + type === "PermissionRequest" || + type === "PermissionDenied" + ) { + summary = `tool: ${boundedClaudeLabel(event.toolName) || type}`; + } + + return { + ts: event.timestamp, + type, + summary, + ...(ERROR_EVENTS.has(type) ? { isError: true } : {}), + }; +} + +function eventSummaryKey(event: EventSummary): string { + return JSON.stringify([ + event.ts, + event.type, + event.summary, + event.isError === true, + event.turnId ?? null, + ]); +} + +function mergeRetainedEvents( + previous: EventSummary[], + entry: EventSummary, + retain: boolean +): EventSummary[] { + if (!retain) return previous; + const seen = new Set(); + return [...previous, entry] + .map((event, index) => ({ event, index })) + .filter(({ event }) => { + const key = eventSummaryKey(event); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((a, b) => a.event.ts - b.event.ts || a.index - b.index) + .slice(-MAX_EVENTS) + .map(({ event }) => event); +} + +function updateSummary( + previous: WorkSummary, + entry: EventSummary, + activity: boolean +): WorkSummary { + const summary = { ...previous }; + if (entry.summary === "prompt") summary.lastPrompt = entry.summary; + if (entry.summary === "message") summary.lastMessage = entry.summary; + if (entry.summary.startsWith("tool:")) summary.lastTool = entry.summary; + if (activity && !entry.summary.startsWith("event:")) { + summary.current = entry.summary; + } + return summary; +} + +export function expireClaudeInFlight( + state: ClaudeSessionState, + now: number +): ClaudeSessionState { + if (!state.inFlight) return state; + const lastSignal = state.lastActivityAt ?? state.lastSeenAt; + return typeof lastSignal === "number" && now - lastSignal > INFLIGHT_TIMEOUT_MS + ? { ...state, inFlight: false } + : state; +} + +export function pruneClaudeState(map: ClaudeStateMap, now: number): ClaudeStateMap { + let changed = false; + const next = new Map(); + for (const [sessionId, state] of map.entries()) { + if (now - state.lastSeenAt > CLAUDE_STALE_TTL_MS) { + changed = true; + continue; + } + const updated = expireClaudeInFlight(state, now); + if (updated !== state) changed = true; + next.set(sessionId, updated); + } + return changed ? next : map; +} + +export function applyClaudeEvent( + map: ClaudeStateMap, + event: ClaudeEvent, + storedCwdKey?: string +): ClaudeStateMap { + const sessionId = validClaudeSessionId(event.sessionId); + const now = event.timestamp; + if ( + !sessionId || + !Number.isFinite(now) || + now < 0 + ) { + return map; + } + + const type = normalizeClaudeEventType(event.type); + if (!type) return map; + const isIdleNotification = + type === "Notification" && + event.notificationType?.trim().toLowerCase() === "idle_prompt"; + const previous = map.get(sessionId); + const entry = summarizeClaudeEvent(event, type); + const activity = isActivityEvent(type); + const retain = shouldRetainGraphEvent(event, type); + const events = mergeRetainedEvents(previous?.events ?? [], entry, retain); + const eventCwdKey = stableClaudePathKey(event.cwd); + + if (previous && now < previous.lastSeenAt) { + const nextMap = new Map(map); + nextMap.set(sessionId, { + ...previous, + cwd: previous.cwd ?? event.cwd, + cwdKey: previous.cwdKey ?? eventCwdKey ?? storedCwdKey, + transcriptPath: previous.transcriptPath ?? event.transcriptPath, + events, + }); + return nextMap; + } + + const next: ClaudeSessionState = { + sessionId, + inFlight: previous?.inFlight ?? false, + lastSeenAt: Math.max(previous?.lastSeenAt ?? now, now), + lastEventAt: Math.max(previous?.lastEventAt ?? now, now), + cwd: event.cwd ?? previous?.cwd, + cwdKey: eventCwdKey ?? storedCwdKey ?? previous?.cwdKey, + transcriptPath: event.transcriptPath ?? previous?.transcriptPath, + lastEvent: type, + lastActivityAt: previous?.lastActivityAt, + events, + summary: updateSummary(previous?.summary ?? {}, entry, activity), + hasError: entry.isError ? true : activity ? false : previous?.hasError, + }; + + if (INFLIGHT_EVENTS.has(type)) next.inFlight = true; + if (IDLE_EVENTS.has(type) || isIdleNotification) { + next.inFlight = false; + next.lastActivityAt = undefined; + } else if (activity) { + next.lastActivityAt = now; + } + + const nextMap = new Map(map); + nextMap.set(sessionId, next); + return nextMap; +} + +export function toStoredClaudeEvent( + event: ClaudeEvent +): StoredClaudeEvent | undefined { + const sessionId = validClaudeSessionId(event.sessionId); + const type = normalizeClaudeEventType(event.type); + if ( + !sessionId || + !type || + !Number.isFinite(event.timestamp) || + event.timestamp < 0 || + !shouldPersistClaudeEvent(event, type) + ) { + return undefined; + } + return { + version: CLAUDE_EVENT_LOG_VERSION, + type, + sessionId, + timestamp: event.timestamp, + cwdKey: stableClaudePathKey(event.cwd), + notificationType: boundedClaudeLabel(event.notificationType), + toolName: boundedClaudeLabel(event.toolName), + agentType: boundedClaudeLabel(event.agentType), + final: event.final, + }; +} + +export function storedClaudeEventKey(event: StoredClaudeEvent): string { + return [ + event.version, + event.sessionId, + event.timestamp, + event.type, + event.cwdKey ?? "", + event.notificationType ?? "", + event.toolName ?? "", + event.agentType ?? "", + event.final === undefined ? "" : String(event.final), + ].join("\0"); +} + +function isOptionalBoundedLabel(value: unknown): boolean { + return ( + value === undefined || + (typeof value === "string" && boundedClaudeLabel(value) === value) + ); +} + +export function isStoredClaudeEvent(value: unknown): value is StoredClaudeEvent { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const event = value as Record; + return ( + event.version === CLAUDE_EVENT_LOG_VERSION && + typeof event.type === "string" && + normalizeClaudeEventType(event.type) === event.type && + typeof event.sessionId === "string" && + validClaudeSessionId(event.sessionId) === event.sessionId && + typeof event.timestamp === "number" && + Number.isFinite(event.timestamp) && + event.timestamp >= 0 && + (event.cwdKey === undefined || + (typeof event.cwdKey === "string" && /^[a-f0-9]{24}$/.test(event.cwdKey))) && + isOptionalBoundedLabel(event.notificationType) && + isOptionalBoundedLabel(event.toolName) && + isOptionalBoundedLabel(event.agentType) && + (event.final === undefined || typeof event.final === "boolean") + ); +} + +export function fromStoredClaudeEvent(event: StoredClaudeEvent): ClaudeEvent { + return { + type: event.type, + sessionId: event.sessionId, + notificationType: boundedClaudeLabel(event.notificationType), + toolName: boundedClaudeLabel(event.toolName), + agentType: boundedClaudeLabel(event.agentType), + final: event.final, + timestamp: event.timestamp, + } as ClaudeEvent; +} diff --git a/src/services/claudeEvents.ts b/src/services/claudeEvents.ts index 4fdc7bc..dfdf7f1 100644 --- a/src/services/claudeEvents.ts +++ b/src/services/claudeEvents.ts @@ -1,129 +1,57 @@ -import { Effect, Ref } from "effect"; +import { Effect, Ref, pipe } from "effect"; import type { ClaudeEvent, ClaudeSessionState } from "../claude/types.js"; - -const STALE_TTL_MS = Number(process.env.CONSENSUS_CLAUDE_EVENT_TTL_MS || 30 * 60 * 1000); -const INFLIGHT_TIMEOUT_MS = Number( - process.env.CONSENSUS_CLAUDE_INFLIGHT_TIMEOUT_MS || 15000 -); - -const INFLIGHT_EVENTS = new Set([ - "UserPromptSubmit", - "PreToolUse", - "PermissionRequest", - "PostToolUse", - "PostToolUseFailure", - "SubagentStart", -]); - -const IDLE_EVENTS = new Set(["Stop", "SubagentStop", "SessionEnd"]); -const NON_ACTIVITY_EVENTS = new Set(["SessionStart", ...IDLE_EVENTS]); - -type StateMap = Map; +import { + applyClaudeEvent, + pruneClaudeState, + stableClaudePathKey, + type ClaudeStateMap, +} from "./claudeEventModel.js"; +import { + flushClaudeEventPersistence, + queueClaudeEventPersistence, + readStoredClaudeEvents, +} from "./claudeEventLog.js"; const stateRef = Effect.runSync(Ref.make(new Map())); -function normalizeType(input: string): string { - const trimmed = input.trim(); - if (!trimmed) return trimmed; - const key = trimmed.replace(/[^a-z0-9]/gi, "").toLowerCase(); - switch (key) { - case "userpromptsubmit": - return "UserPromptSubmit"; - case "pretooluse": - return "PreToolUse"; - case "posttooluse": - return "PostToolUse"; - case "posttoolusefailure": - return "PostToolUseFailure"; - case "permissionrequest": - return "PermissionRequest"; - case "subagentstart": - return "SubagentStart"; - case "subagentstop": - return "SubagentStop"; - case "sessionstart": - return "SessionStart"; - case "sessionend": - return "SessionEnd"; - case "stop": - return "Stop"; - case "notification": - return "Notification"; - default: - return trimmed; - } -} - -function isActivityEvent(type: string): boolean { - if (!type) return false; - return !NON_ACTIVITY_EVENTS.has(type); -} - -function expireInFlight(state: ClaudeSessionState, now: number): ClaudeSessionState { - if (!state.inFlight) return state; - const lastSignal = state.lastActivityAt ?? state.lastSeenAt; - if (typeof lastSignal === "number" && now - lastSignal > INFLIGHT_TIMEOUT_MS) { - return { ...state, inFlight: false }; - } - return state; +export async function hydrateClaudeEventsFromDisk(): Promise { + const storedEvents = await readStoredClaudeEvents(); + Effect.runSync( + Ref.update(stateRef, (map) => { + let next = pruneClaudeState(map, Date.now()); + for (const stored of storedEvents) { + next = applyClaudeEvent(next, stored.event, stored.cwdKey); + } + return next; + }) + ); } -function pruneStale(map: StateMap, now: number): StateMap { - let changed = false; - const next = new Map(); - for (const [sessionId, state] of map.entries()) { - if (now - state.lastSeenAt > STALE_TTL_MS) { - changed = true; - continue; - } - const updated = expireInFlight(state, now); - if (updated !== state) changed = true; - next.set(sessionId, updated); - } - return changed ? next : map; -} +export { flushClaudeEventPersistence }; -function applyEvent(map: StateMap, event: ClaudeEvent): StateMap { - const now = typeof event.timestamp === "number" ? event.timestamp : Date.now(); - const type = normalizeType(event.type); - const notificationType = event.notificationType?.trim(); - const isIdleNotification = - type === "Notification" && notificationType?.toLowerCase() === "idle_prompt"; - const prev = map.get(event.sessionId); - const next: ClaudeSessionState = { - sessionId: event.sessionId, - inFlight: prev?.inFlight ?? false, - lastSeenAt: now, - cwd: event.cwd ?? prev?.cwd, - transcriptPath: event.transcriptPath ?? prev?.transcriptPath, - lastEvent: type, - lastActivityAt: prev?.lastActivityAt, - }; - if (INFLIGHT_EVENTS.has(type)) next.inFlight = true; - if (IDLE_EVENTS.has(type) || isIdleNotification) { - next.inFlight = false; - next.lastActivityAt = undefined; - } else if (isActivityEvent(type)) { - next.lastActivityAt = now; - } - const nextMap = new Map(map); - nextMap.set(event.sessionId, next); - return nextMap; +function updateClaudeEventState(event: ClaudeEvent): Effect.Effect { + return Ref.update(stateRef, (map) => { + const now = typeof event.timestamp === "number" ? event.timestamp : Date.now(); + return applyClaudeEvent(pruneClaudeState(map, now), event); + }); } export const handleClaudeEventEffect = (event: ClaudeEvent): Effect.Effect => - Ref.update(stateRef, (map) => { - const now = typeof event.timestamp === "number" ? event.timestamp : Date.now(); - const pruned = pruneStale(map, now); - return applyEvent(pruned, event); - }); + pipe( + updateClaudeEventState(event), + Effect.flatMap(() => { + void queueClaudeEventPersistence(event); + return Effect.succeed(undefined as void); + }), + Effect.catchAll(() => Effect.succeed(undefined as void)) + ); export const getClaudeActivityBySessionEffect = ( sessionId: string, now: number = Date.now() ): Effect.Effect => Ref.modify(stateRef, (map) => { - const pruned = pruneStale(map, now); + const pruned = pruneClaudeState(map, now); return [pruned.get(sessionId), pruned]; }); @@ -133,11 +61,12 @@ export const getClaudeActivityByCwdEffect = ( ): Effect.Effect => Ref.modify(stateRef, (map) => { if (!cwd) return [undefined, map]; - const pruned = pruneStale(map, now); + const pruned = pruneClaudeState(map, now); + const cwdKey = stableClaudePathKey(cwd); let best: ClaudeSessionState | undefined; let bestAt = 0; for (const state of pruned.values()) { - if (!state.cwd || state.cwd !== cwd) continue; + if (state.cwd !== cwd && (!cwdKey || state.cwdKey !== cwdKey)) continue; const candidateAt = state.lastActivityAt ?? state.lastSeenAt ?? 0; if (!best || candidateAt > bestAt) { best = state; @@ -147,9 +76,10 @@ export const getClaudeActivityByCwdEffect = ( return [best, pruned]; }); -// Sync wrappers for non-Effect code paths (scan.ts/tests). +// Sync wrappers for non-Effect code paths and tests. The HTTP path uses the +// Effect handler above, which also queues bounded metadata persistence. export function handleClaudeEvent(event: ClaudeEvent): void { - Effect.runSync(handleClaudeEventEffect(event)); + Effect.runSync(updateClaudeEventState(event)); } export function getClaudeActivityBySession( diff --git a/src/services/harnessEventLog.ts b/src/services/harnessEventLog.ts new file mode 100644 index 0000000..347579a --- /dev/null +++ b/src/services/harnessEventLog.ts @@ -0,0 +1,365 @@ +import { + appendFile, + chmod, + mkdir, + open, + readFile, + rename, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { + MAX_HOOK_FUTURE_SKEW_MS, + harnessHookEventKey, + isHookHarnessId, + type CanonicalHarnessHookType, + type NormalizedHarnessHookEvent, +} from "../harnessHookModel.js"; + +const DEFAULT_LOG_MAX_BYTES = 2 * 1024 * 1024; +const MIN_LOG_MAX_BYTES = 64 * 1024; +const DEFAULT_RETENTION_MS = 30 * 60 * 1000; +const MAX_SEEN_EVENTS = 10_000; +const LOCK_WAIT_MS = 1_000; +const LOCK_RETRY_MS = 10; +const LOCK_STALE_MS = 30_000; +const DEDUP_TAIL_BYTES = 256 * 1024; +const KEY_RE = /^[a-f0-9]{24}$/; +const LABEL_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/; +const CANONICAL_TYPES = new Set([ + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "UserPromptExpansion", + "MessageDisplay", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "PermissionRequest", + "PermissionResult", + "PermissionDenied", + "SubagentStart", + "SubagentStop", + "TaskCreated", + "TaskCompleted", + "PreVerify", + "ErrorOccurred", + "Stop", + "StopFailure", + "Interrupt", + "PreCompact", + "PostCompact", + "Notification", +]); +const seenEvents = new Set(); +let seenPath: string | undefined; +let persistenceQueue: Promise = Promise.resolve(); + +function resolveLogPath(): string | undefined { + const configured = process.env.CONSENSUS_HARNESS_EVENT_LOG?.trim(); + if (configured) { + const lowered = configured.toLowerCase(); + if (lowered === "0" || lowered === "false" || lowered === "off") { + return undefined; + } + return path.resolve(configured); + } + return path.join(homedir(), ".consensus", "harness-events.jsonl"); +} + +function resolveLogMaxBytes(): number { + const parsed = Number(process.env.CONSENSUS_HARNESS_EVENT_LOG_MAX_BYTES); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LOG_MAX_BYTES; + return Math.max(MIN_LOG_MAX_BYTES, Math.floor(parsed)); +} + +function resolveRetentionMs(): number { + const parsed = Number(process.env.CONSENSUS_HARNESS_EVENT_TTL_MS); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_RETENTION_MS; + return Math.floor(parsed); +} + +function useSeenPath(filePath: string): void { + if (seenPath === filePath) return; + seenEvents.clear(); + seenPath = filePath; +} + +function rememberEvent(key: string): boolean { + if (seenEvents.has(key)) return false; + seenEvents.add(key); + while (seenEvents.size > MAX_SEEN_EVENTS) { + const oldest = seenEvents.values().next().value; + if (typeof oldest !== "string") break; + seenEvents.delete(oldest); + } + return true; +} + +function newestCompleteLines(buffer: Buffer, keepBytes: number): Buffer { + if (buffer.length <= keepBytes) return buffer; + const tail = buffer.subarray(Math.max(0, buffer.length - keepBytes)); + const firstNewline = tail.indexOf(0x0a); + return firstNewline >= 0 ? tail.subarray(firstNewline + 1) : Buffer.alloc(0); +} + +async function trimLog(filePath: string, maxBytes: number): Promise { + let info; + try { + info = await stat(filePath); + } catch { + return; + } + if (info.size <= maxBytes) return; + + const content = await readFile(filePath); + const keepBytes = Math.max(32 * 1024, Math.floor(maxBytes / 2)); + const trimmed = newestCompleteLines(content, keepBytes); + const tempPath = `${filePath}.tmp-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + await writeFile(tempPath, trimmed, { mode: 0o600 }); + await rename(tempPath, filePath); + await chmod(filePath, 0o600).catch(() => undefined); +} + +async function removeStaleLock(lockPath: string): Promise { + try { + const info = await stat(lockPath); + if (Date.now() - info.mtimeMs <= LOCK_STALE_MS) return false; + await unlink(lockPath); + return true; + } catch { + return true; + } +} + +async function withLogLock( + filePath: string, + action: () => Promise +): Promise { + await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const lockPath = `${filePath}.lock`; + const deadline = Date.now() + LOCK_WAIT_MS; + + while (true) { + try { + const handle = await open(lockPath, "wx", 0o600); + try { + return await action(); + } finally { + await handle.close().catch(() => undefined); + await unlink(lockPath).catch(() => undefined); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw error; + if (await removeStaleLock(lockPath)) continue; + if (Date.now() >= deadline) { + throw new Error(`timed out acquiring harness event log lock: ${lockPath}`); + } + await delay(LOCK_RETRY_MS); + } + } +} + +function isSafeLabel(value: unknown): boolean { + return ( + value === undefined || + (typeof value === "string" && + value.length <= 160 && + !LABEL_CONTROL_RE.test(value)) + ); +} + +export function isStoredHarnessHookEvent( + value: unknown +): value is NormalizedHarnessHookEvent { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const event = value as Record; + return ( + event.version === 1 && + isHookHarnessId(event.harnessId) && + typeof event.type === "string" && + CANONICAL_TYPES.has(event.type as CanonicalHarnessHookType) && + typeof event.sessionKey === "string" && + KEY_RE.test(event.sessionKey) && + typeof event.timestamp === "number" && + Number.isFinite(event.timestamp) && + event.timestamp >= 0 && + (event.cwdKey === undefined || + (typeof event.cwdKey === "string" && KEY_RE.test(event.cwdKey))) && + (event.turnKey === undefined || + (typeof event.turnKey === "string" && KEY_RE.test(event.turnKey))) && + isSafeLabel(event.toolName) && + isSafeLabel(event.agentType) && + isSafeLabel(event.notificationType) && + (event.final === undefined || typeof event.final === "boolean") + ); +} + +async function tailContainsEventKey( + filePath: string, + key: string +): Promise { + let info; + try { + info = await stat(filePath); + } catch { + return false; + } + if (info.size <= 0) return false; + + const bytesToRead = Math.min(info.size, DEDUP_TAIL_BYTES); + const handle = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(bytesToRead); + const position = Math.max(0, info.size - bytesToRead); + const { bytesRead } = await handle.read( + buffer, + 0, + bytesToRead, + position + ); + let input = buffer.subarray(0, bytesRead).toString("utf8"); + if (position > 0) { + const firstNewline = input.indexOf("\n"); + input = firstNewline >= 0 ? input.slice(firstNewline + 1) : ""; + } + for (const line of input.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const parsed: unknown = JSON.parse(line); + if ( + isStoredHarnessHookEvent(parsed) && + harnessHookEventKey(parsed) === key + ) { + return true; + } + } catch { + // Ignore malformed or partially written lines. + } + } + return false; + } finally { + await handle.close(); + } +} + +async function persistEvent( + filePath: string, + event: NormalizedHarnessHookEvent, + key: string +): Promise { + await withLogLock(filePath, async () => { + if (await tailContainsEventKey(filePath, key)) return; + await appendFile(filePath, `${JSON.stringify(event)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await chmod(filePath, 0o600).catch(() => undefined); + await trimLog(filePath, resolveLogMaxBytes()); + }); +} + +export function queueHarnessEventPersistence( + event: NormalizedHarnessHookEvent +): Promise { + const filePath = resolveLogPath(); + if (!filePath || !isStoredHarnessHookEvent(event)) return persistenceQueue; + useSeenPath(filePath); + const key = harnessHookEventKey(event); + if (!rememberEvent(key)) return persistenceQueue; + + persistenceQueue = persistenceQueue + .then(() => persistEvent(filePath, event, key)) + .catch(() => { + if (seenPath === filePath) seenEvents.delete(key); + }); + return persistenceQueue; +} + +export function flushHarnessEventPersistence(): Promise { + return persistenceQueue; +} + +async function readLogText(filePath: string): Promise { + const maxReadBytes = Math.max( + DEFAULT_LOG_MAX_BYTES * 2, + resolveLogMaxBytes() * 2 + ); + const info = await stat(filePath); + if (info.size <= maxReadBytes) return readFile(filePath, "utf8"); + + const handle = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(maxReadBytes); + const position = Math.max(0, info.size - maxReadBytes); + const { bytesRead } = await handle.read( + buffer, + 0, + maxReadBytes, + position + ); + const tail = buffer.subarray(0, bytesRead); + const firstNewline = tail.indexOf(0x0a); + if (firstNewline < 0) return ""; + return tail.subarray(firstNewline + 1).toString("utf8"); + } finally { + await handle.close(); + } +} + +export async function readStoredHarnessEvents(): Promise< + NormalizedHarnessHookEvent[] +> { + const filePath = resolveLogPath(); + if (!filePath) return []; + useSeenPath(filePath); + + let input: string; + try { + input = await readLogText(filePath); + } catch { + return []; + } + + const now = Date.now(); + const cutoff = now - resolveRetentionMs(); + const latestAcceptedAt = now + MAX_HOOK_FUTURE_SKEW_MS; + const events: NormalizedHarnessHookEvent[] = []; + for (const line of input.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const parsed: unknown = JSON.parse(line); + if ( + !isStoredHarnessHookEvent(parsed) || + parsed.timestamp < cutoff || + parsed.timestamp > latestAcceptedAt + ) { + continue; + } + events.push(parsed); + } catch { + // Ignore malformed and partially written lines. + } + } + events.sort( + (a, b) => + a.timestamp - b.timestamp || + harnessHookEventKey(a).localeCompare(harnessHookEventKey(b)) + ); + + const result: NormalizedHarnessHookEvent[] = []; + for (const event of events) { + const key = harnessHookEventKey(event); + if (!rememberEvent(key)) continue; + result.push(event); + } + return result; +} diff --git a/src/services/harnessEvents.ts b/src/services/harnessEvents.ts new file mode 100644 index 0000000..347cd12 --- /dev/null +++ b/src/services/harnessEvents.ts @@ -0,0 +1,257 @@ +import type { HarnessId } from "../harnesses.js"; +import { + harnessHookEventKey, + harnessHookEventSummary, + type HookHarnessId, + type NormalizedHarnessHookEvent, +} from "../harnessHookModel.js"; +import type { EventSummary } from "../types.js"; +import { + flushHarnessEventPersistence, + queueHarnessEventPersistence, + readStoredHarnessEvents, +} from "./harnessEventLog.js"; + +const DEFAULT_RETENTION_MS = 30 * 60 * 1000; +const DEFAULT_INFLIGHT_TIMEOUT_MS = 30_000; +const MAX_EVENTS = 50; +const INFLIGHT_TYPES = new Set([ + "UserPromptSubmit", + "UserPromptExpansion", + "MessageDisplay", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "PermissionRequest", + "PermissionResult", + "PermissionDenied", + "SubagentStart", + "SubagentStop", + "TaskCreated", + "TaskCompleted", + "PreVerify", +]); +const IDLE_TYPES = new Set([ + "Stop", + "StopFailure", + "Interrupt", + "SessionEnd", +]); +const ERROR_TYPES = new Set([ + "PostToolUseFailure", + "ErrorOccurred", + "StopFailure", +]); +const NON_ACTIVITY_TYPES = new Set([ + "SessionStart", + "SessionEnd", + "PreCompact", + "PostCompact", + "Notification", +]); + +export interface HarnessSessionState { + harnessId: HookHarnessId; + sessionKey: string; + cwdKey?: string; + inFlight: boolean; + hasError: boolean; + lastSeenAt: number; + lastActivityAt?: number; + lastEventType?: NormalizedHarnessHookEvent["type"]; + events: EventSummary[]; +} + +type StateMap = Map; +const stateBySession = new Map(); + +function stateKey(harnessId: HarnessId, sessionKey: string): string { + return `${harnessId}:${sessionKey}`; +} + +function retentionMs(): number { + const parsed = Number(process.env.CONSENSUS_HARNESS_EVENT_TTL_MS); + return Number.isFinite(parsed) && parsed > 0 + ? Math.floor(parsed) + : DEFAULT_RETENTION_MS; +} + +function inFlightTimeoutMs(): number { + const parsed = Number(process.env.CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS); + return Number.isFinite(parsed) && parsed > 0 + ? Math.floor(parsed) + : DEFAULT_INFLIGHT_TIMEOUT_MS; +} + +function eventSummaryKey(event: EventSummary): string { + return JSON.stringify([ + event.ts, + event.type, + event.summary, + event.isError === true, + event.turnId ?? null, + ]); +} + +function mergeEvents( + existing: EventSummary[], + next: EventSummary | undefined +): EventSummary[] { + if (!next) return existing; + const seen = new Set(); + return [...existing, next] + .map((event, index) => ({ event, index })) + .filter(({ event }) => { + const key = eventSummaryKey(event); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .sort((a, b) => a.event.ts - b.event.ts || a.index - b.index) + .slice(-MAX_EVENTS) + .map(({ event }) => event); +} + +function isActivityEvent(event: NormalizedHarnessHookEvent): boolean { + if (event.type === "Notification") { + return event.notificationType?.toLowerCase() !== "idle_prompt"; + } + return !NON_ACTIVITY_TYPES.has(event.type); +} + +function expireState( + state: HarnessSessionState, + now: number +): HarnessSessionState { + if (!state.inFlight || typeof state.lastActivityAt !== "number") return state; + return now - state.lastActivityAt > inFlightTimeoutMs() + ? { ...state, inFlight: false } + : state; +} + +function pruneState(map: StateMap, now: number): StateMap { + const cutoff = now - retentionMs(); + const next = new Map(); + for (const [key, state] of map) { + if (state.lastSeenAt < cutoff) continue; + next.set(key, expireState(state, now)); + } + return next; +} + +export function applyHarnessEvent( + map: StateMap, + event: NormalizedHarnessHookEvent +): StateMap { + const key = stateKey(event.harnessId, event.sessionKey); + const previous = map.get(key); + const summary = harnessHookEventSummary(event); + const events = mergeEvents(previous?.events ?? [], summary); + + if (previous && event.timestamp < previous.lastSeenAt) { + const nextMap = new Map(map); + nextMap.set(key, { + ...previous, + cwdKey: previous.cwdKey ?? event.cwdKey, + events, + }); + return nextMap; + } + + const idleNotification = + event.type === "Notification" && + event.notificationType?.toLowerCase() === "idle_prompt"; + const activity = isActivityEvent(event); + const hasError = ERROR_TYPES.has(event.type) + ? true + : activity + ? false + : previous?.hasError ?? false; + let inFlight = previous?.inFlight ?? false; + if (INFLIGHT_TYPES.has(event.type)) inFlight = true; + if (IDLE_TYPES.has(event.type) || idleNotification) inFlight = false; + + const nextState: HarnessSessionState = { + harnessId: event.harnessId, + sessionKey: event.sessionKey, + cwdKey: event.cwdKey ?? previous?.cwdKey, + inFlight, + hasError, + lastSeenAt: event.timestamp, + lastActivityAt: + IDLE_TYPES.has(event.type) || idleNotification + ? undefined + : activity + ? event.timestamp + : previous?.lastActivityAt, + lastEventType: event.type, + events, + }; + const nextMap = new Map(map); + nextMap.set(key, nextState); + return nextMap; +} + +function replaceState(next: StateMap): void { + stateBySession.clear(); + for (const [key, value] of next) stateBySession.set(key, value); +} + +export async function hydrateHarnessEventsFromDisk(): Promise { + const storedEvents = await readStoredHarnessEvents(); + let next = pruneState(new Map(stateBySession), Date.now()); + for (const event of storedEvents) next = applyHarnessEvent(next, event); + replaceState(next); +} + +export function handleNormalizedHarnessEvent( + event: NormalizedHarnessHookEvent, + persist = true +): void { + const next = applyHarnessEvent( + pruneState(new Map(stateBySession), event.timestamp), + event + ); + replaceState(next); + if (persist) void queueHarnessEventPersistence(event); +} + +export function getHarnessActivityBySession( + harnessId: HookHarnessId, + sessionKey: string, + now: number = Date.now() +): HarnessSessionState | undefined { + replaceState(pruneState(new Map(stateBySession), now)); + return stateBySession.get(stateKey(harnessId, sessionKey)); +} + +export function getHarnessActivityByCwd( + harnessId: HookHarnessId, + cwdKey: string, + now: number = Date.now() +): HarnessSessionState | undefined { + replaceState(pruneState(new Map(stateBySession), now)); + let best: HarnessSessionState | undefined; + for (const state of stateBySession.values()) { + if (state.harnessId !== harnessId || state.cwdKey !== cwdKey) continue; + if (!best || state.lastSeenAt > best.lastSeenAt) best = state; + } + return best; +} + +export function listHarnessActivity( + harnessId: HookHarnessId, + now: number = Date.now() +): HarnessSessionState[] { + replaceState(pruneState(new Map(stateBySession), now)); + return [...stateBySession.values()] + .filter((state) => state.harnessId === harnessId) + .sort((a, b) => b.lastSeenAt - a.lastSeenAt); +} + +export function resetHarnessActivityForTests(): void { + stateBySession.clear(); +} + +export { flushHarnessEventPersistence, harnessHookEventKey }; diff --git a/src/types.ts b/src/types.ts index faa8ffc..bf5168c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,20 +1,14 @@ +import type { AgentKind } from "./harnesses.js"; + +export type { AgentKind } from "./harnesses.js"; export type AgentState = "active" | "idle" | "error"; -export type AgentKind = - | "tui" - | "exec" - | "app-server" - | "opencode-tui" - | "opencode-cli" - | "opencode-server" - | "claude-tui" - | "claude-cli" - | "unknown"; export interface EventSummary { ts: number; type: string; summary: string; isError?: boolean; + turnId?: string | number; } export interface WorkSummary { @@ -48,6 +42,8 @@ export interface AgentSnapshot { model?: string; summary?: WorkSummary; events?: EventSummary[]; + harnessSessionKey?: string; + harnessCwdKey?: string; } export interface SnapshotMeta { diff --git a/tests/integration/harnessHookCli.test.ts b/tests/integration/harnessHookCli.test.ts new file mode 100644 index 0000000..af1face --- /dev/null +++ b/tests/integration/harnessHookCli.test.ts @@ -0,0 +1,115 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(testDirectory, "../.."); +const hookPath = path.join(repositoryRoot, "src/harnessHook.ts"); + +async function runHook( + harnessId: string, + input: string, + logPath: string +): Promise<{ code: number | null; stdout: string; stderr: string }> { + const child = spawn( + process.execPath, + ["--import", "tsx", hookPath, harnessId], + { + cwd: repositoryRoot, + env: { + ...process.env, + CONSENSUS_HARNESS_EVENT_LOG: logPath, + }, + stdio: ["pipe", "pipe", "pipe"], + } + ); + child.stdin.end(input); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + const code = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("exit", resolve); + }); + return { code, stdout, stderr }; +} + +describe("shared harness hook CLI", { concurrency: false }, () => { + it("acknowledges Gemini with JSON and persists metadata only", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-gemini-hook-")); + const logPath = path.join(directory, "events.jsonl"); + try { + const result = await runHook( + "gemini", + JSON.stringify({ + hook_event_name: "BeforeTool", + session_id: "secret-session", + cwd: "/Users/alice/private-project", + timestamp: Date.now(), + tool_name: "write_file", + prompt: "private prompt", + tool_input: { path: "/private/file", content: "private content" }, + }), + logPath + ); + + assert.equal(result.code, 0); + assert.equal(result.stdout, "{}\n"); + assert.equal(result.stderr, ""); + const persisted = await readFile(logPath, "utf8"); + assert.match(persisted, /"harnessId":"gemini"/); + assert.match(persisted, /"type":"PreToolUse"/); + assert.match(persisted, /"toolName":"write_file"/); + assert.equal(persisted.includes("secret-session"), false); + assert.equal(persisted.includes("/Users/alice"), false); + assert.equal(persisted.includes("private prompt"), false); + assert.equal(persisted.includes("private content"), false); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("acknowledges malformed Copilot input without writing a record", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-copilot-hook-")); + const logPath = path.join(directory, "events.jsonl"); + try { + const result = await runHook("copilot", "{", logPath); + assert.equal(result.code, 0); + assert.equal(result.stdout, "{}\n"); + assert.equal(result.stderr, ""); + await assert.rejects(() => readFile(logPath, "utf8")); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("keeps passive Claude hook stdout empty", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-claude-hook-")); + const logPath = path.join(directory, "events.jsonl"); + try { + const result = await runHook( + "claude", + JSON.stringify({ + hook_event_name: "SessionStart", + session_id: "session", + timestamp: Date.now(), + }), + logPath + ); + assert.equal(result.code, 0); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, ""); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/claudeEventLog.test.ts b/tests/unit/claudeEventLog.test.ts new file mode 100644 index 0000000..4487c0b --- /dev/null +++ b/tests/unit/claudeEventLog.test.ts @@ -0,0 +1,107 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + flushClaudeEventPersistence, + queueClaudeEventPersistence, +} from "../../src/services/claudeEventLog.js"; +import type { ClaudeEvent } from "../../src/claude/types.js"; + +function event( + sessionId: string, + timestamp: number, + extra: Partial = {} +): ClaudeEvent { + return { + type: "SubagentStart", + sessionId, + timestamp, + agentType: "Explore", + ...extra, + } as ClaudeEvent; +} + +async function withEventLog( + run: (directory: string, logPath: string) => Promise +): Promise { + const previousPath = process.env.CONSENSUS_CLAUDE_EVENT_LOG; + const previousMax = process.env.CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES; + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-claude-log-")); + const logPath = path.join(directory, "events.jsonl"); + process.env.CONSENSUS_CLAUDE_EVENT_LOG = logPath; + + try { + await run(directory, logPath); + } finally { + await flushClaudeEventPersistence(); + if (previousPath === undefined) { + delete process.env.CONSENSUS_CLAUDE_EVENT_LOG; + } else { + process.env.CONSENSUS_CLAUDE_EVENT_LOG = previousPath; + } + if (previousMax === undefined) { + delete process.env.CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES; + } else { + process.env.CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES = previousMax; + } + await rm(directory, { recursive: true, force: true }); + } +} + +describe("Claude event log", { concurrency: false }, () => { + it("persists duplicate hook deliveries only once", async () => { + await withEventLog(async (_directory, logPath) => { + const duplicate = event("duplicate-session", Date.now()); + void queueClaudeEventPersistence(duplicate); + void queueClaudeEventPersistence(duplicate); + await flushClaudeEventPersistence(); + + const lines = (await readFile(logPath, "utf8")) + .split(/\r?\n/) + .filter(Boolean); + assert.equal(lines.length, 1); + }); + }); + + it("captures the configured path when a write is queued", async () => { + await withEventLog(async (directory, firstPath) => { + const secondPath = path.join(directory, "later.jsonl"); + const queued = queueClaudeEventPersistence( + event("path-session", Date.now()) + ); + process.env.CONSENSUS_CLAUDE_EVENT_LOG = secondPath; + await queued; + + const first = await readFile(firstPath, "utf8"); + assert.match(first, /path-session/); + await assert.rejects(() => readFile(secondPath, "utf8")); + }); + }); + + it("enforces the byte limit with multibyte metadata and keeps valid JSON lines", async () => { + await withEventLog(async (_directory, logPath) => { + process.env.CONSENSUS_CLAUDE_EVENT_LOG_MAX_BYTES = String(64 * 1024); + const now = Date.now(); + const agentType = "🚀".repeat(80); + + for (let index = 0; index < 500; index += 1) { + void queueClaudeEventPersistence( + event(`unicode-${index}`, now + index, { agentType }) + ); + } + await flushClaudeEventPersistence(); + + const info = await stat(logPath); + assert.ok(info.size <= 64 * 1024, `event log was ${info.size} bytes`); + const lines = (await readFile(logPath, "utf8")) + .split(/\r?\n/) + .filter(Boolean); + assert.ok(lines.length > 0); + for (const line of lines) { + assert.doesNotThrow(() => JSON.parse(line)); + } + }); + }); +}); diff --git a/tests/unit/claudeEventModel.test.ts b/tests/unit/claudeEventModel.test.ts new file mode 100644 index 0000000..ad2a30f --- /dev/null +++ b/tests/unit/claudeEventModel.test.ts @@ -0,0 +1,132 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + applyClaudeEvent, + boundedClaudeLabel, + isStoredClaudeEvent, + toStoredClaudeEvent, + type ClaudeStateMap, +} from "../../src/services/claudeEventModel.js"; +import type { ClaudeEvent } from "../../src/claude/types.js"; + +function event( + type: string, + timestamp: number, + extra: Partial = {} +): ClaudeEvent { + return { + type, + sessionId: "claude-model-session", + timestamp, + ...extra, + } as ClaudeEvent; +} + +function state( + events: ClaudeEvent[] +): ReturnType { + let map: ClaudeStateMap = new Map(); + for (const current of events) map = applyClaudeEvent(map, current); + return map; +} + +describe("Claude event model", () => { + it("deduplicates repeated retained hook deliveries in memory", () => { + const duplicate = event("PreToolUse", 100, { toolName: "Bash" }); + const map = state([duplicate, duplicate]); + const current = map.get(duplicate.sessionId); + + assert.equal(current?.events.length, 1); + assert.equal(current?.events[0].summary, "tool: Bash"); + assert.equal(current?.inFlight, true); + }); + + it("does not let an older stored stop event clear newer live activity", () => { + const prompt = event("UserPromptSubmit", 200); + let map = applyClaudeEvent(new Map(), prompt); + map = applyClaudeEvent(map, event("Stop", 100)); + const current = map.get(prompt.sessionId); + + assert.equal(current?.inFlight, true); + assert.equal(current?.lastSeenAt, 200); + assert.equal(current?.lastEventAt, 200); + assert.equal(current?.lastEvent, "UserPromptSubmit"); + assert.deepEqual( + current?.events.map((entry) => [entry.ts, entry.type]), + [ + [100, "Stop"], + [200, "UserPromptSubmit"], + ] + ); + }); + + it("does not let an older failure overwrite a newer successful state", () => { + const message = event("MessageDisplay", 200, { final: true }); + let map = applyClaudeEvent(new Map(), message); + map = applyClaudeEvent(map, event("StopFailure", 100)); + const current = map.get(message.sessionId); + + assert.notEqual(current?.hasError, true); + assert.equal(current?.lastEvent, "MessageDisplay"); + assert.equal(current?.lastSeenAt, 200); + assert.deepEqual( + current?.events.map((entry) => entry.type), + ["StopFailure", "MessageDisplay"] + ); + }); + + it("rejects invalid session ids and timestamps without mutating state", () => { + const original: ClaudeStateMap = new Map(); + const controlSession = event("UserPromptSubmit", 100, { + sessionId: "bad\nidentity", + }); + const negativeTime = event("UserPromptSubmit", -1); + + assert.equal(applyClaudeEvent(original, controlSession), original); + assert.equal(applyClaudeEvent(original, negativeTime), original); + assert.equal(toStoredClaudeEvent(controlSession), undefined); + assert.equal(toStoredClaudeEvent(negativeTime), undefined); + }); + + it("sanitizes and bounds metadata labels before persistence", () => { + const label = `Bash\n\u001b[2J${"x".repeat(300)}`; + const normalized = boundedClaudeLabel(label); + const stored = toStoredClaudeEvent( + event("PreToolUse", 100, { toolName: label }) + ); + + assert.ok(normalized); + assert.equal(normalized?.length, 160); + assert.equal(/[\u0000-\u001f\u007f-\u009f]/.test(normalized ?? ""), false); + assert.equal(stored?.toolName, normalized); + }); + + it("rejects malformed stored records at the disk boundary", () => { + const base = { + version: 1, + type: "PreToolUse", + sessionId: "stored-session", + timestamp: 100, + cwdKey: "a".repeat(24), + toolName: "Bash", + }; + + assert.equal(isStoredClaudeEvent(base), true); + assert.equal( + isStoredClaudeEvent({ ...base, timestamp: -1 }), + false + ); + assert.equal( + isStoredClaudeEvent({ ...base, cwdKey: "not-a-hash" }), + false + ); + assert.equal( + isStoredClaudeEvent({ ...base, toolName: "Bash\nforged" }), + false + ); + assert.equal( + isStoredClaudeEvent({ ...base, sessionId: "s".repeat(513) }), + false + ); + }); +}); diff --git a/tests/unit/claudeGraph.test.ts b/tests/unit/claudeGraph.test.ts new file mode 100644 index 0000000..ab913e9 --- /dev/null +++ b/tests/unit/claudeGraph.test.ts @@ -0,0 +1,190 @@ +import { createHash } from "crypto"; +import { appendFile, mkdtemp, readFile, rm } from "fs/promises"; +import os from "os"; +import path from "path"; +import { describe, it } from "node:test"; +import { Effect } from "effect"; +import assert from "node:assert/strict"; +import { attachClaudeEvents } from "../../src/claudeSnapshot.js"; +import { buildAgentGraph } from "../../src/graph.js"; +import { + flushClaudeEventPersistence, + getClaudeActivityByCwd, + getClaudeActivityBySession, + handleClaudeEventEffect, + hydrateClaudeEventsFromDisk, +} from "../../src/services/claudeEvents.js"; +import type { ClaudeEvent } from "../../src/claude/types.js"; +import type { SnapshotPayload } from "../../src/types.js"; + +async function send( + sessionId: string, + timestamp: number, + type: string, + extra: Partial = {} +): Promise { + await Effect.runPromise( + handleClaudeEventEffect({ + type, + sessionId, + timestamp, + cwd: "/tmp/project", + ...extra, + } as ClaudeEvent) + ); +} + +function cwdKey(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 24); +} + +describe("Claude graph events", () => { + it("retains metadata-only hook history and builds prompt/model/tool phases", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-claude-")); + const logPath = path.join(directory, "events.jsonl"); + process.env.CONSENSUS_CLAUDE_EVENT_LOG = logPath; + const now = Date.now(); + const sessionId = `claude-test-${now}-a`; + + await send(sessionId, now, "UserPromptSubmit"); + await send(sessionId, now + 1, "UserPromptExpansion"); + await send(sessionId, now + 2, "MessageDisplay", { final: false }); + await send(sessionId, now + 3, "MessageDisplay", { final: true }); + await send(sessionId, now + 4, "PreToolUse", { toolName: "Bash" }); + await send(sessionId, now + 5, "PostToolUse", { toolName: "Bash" }); + await send(sessionId, now + 6, "SubagentStart", { agentType: "Explore" }); + await send(sessionId, now + 7, "SubagentStop", { agentType: "Explore" }); + + const beforeStop = getClaudeActivityBySession(sessionId, now + 7); + assert.equal(beforeStop?.inFlight, true); + + await send(sessionId, now + 8, "Stop"); + await flushClaudeEventPersistence(); + const state = getClaudeActivityBySession(sessionId, now + 8); + assert.equal(state?.inFlight, false); + assert.deepEqual( + state?.events.map((entry) => entry.type), + [ + "UserPromptSubmit", + "MessageDisplay", + "PreToolUse", + "PostToolUse", + "SubagentStart", + "SubagentStop", + "Stop", + ] + ); + assert.equal(state?.summary.lastPrompt, "prompt"); + assert.equal(state?.summary.lastMessage, "message"); + assert.equal(state?.summary.lastTool, "tool: subagent Explore"); + + const snapshot: SnapshotPayload = { + ts: now + 8, + agents: [ + { + identity: `claude:${sessionId}`, + id: "55", + pid: 55, + cmd: "claude", + cmdShort: "claude", + kind: "claude-tui", + cpu: 0, + mem: 0, + state: "idle", + cwd: "/tmp/project", + sessionPath: `claude:${sessionId}`, + }, + ], + }; + const enriched = attachClaudeEvents(snapshot); + assert.equal(enriched.agents[0].events?.length, 7); + + const graph = buildAgentGraph(enriched); + assert.deepEqual( + graph.nodes + .filter((node) => node.kind === "step") + .map((node) => node.phase) + .sort(), + ["model", "prompt", "tool"] + ); + + const persisted = await readFile(logPath, "utf8"); + assert.ok(!persisted.includes("/tmp/project")); + assert.ok(!persisted.includes("transcript")); + assert.ok(!persisted.includes("turn_id")); + assert.ok(!persisted.includes("message_id")); + assert.ok(!persisted.includes("tool_input")); + assert.ok(!persisted.includes("delta")); + + delete process.env.CONSENSUS_CLAUDE_EVENT_LOG; + await rm(directory, { recursive: true, force: true }); + }); + + it("hydrates a fresh session from the bounded metadata log by opaque cwd key", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-claude-")); + const logPath = path.join(directory, "events.jsonl"); + process.env.CONSENSUS_CLAUDE_EVENT_LOG = logPath; + const now = Date.now(); + const sessionId = `persisted-${now}`; + const cwd = "/Users/private/work/project"; + const stored = [ + { + version: 1, + type: "UserPromptSubmit", + sessionId, + timestamp: now, + cwdKey: cwdKey(cwd), + }, + { + version: 1, + type: "MessageDisplay", + sessionId, + timestamp: now + 1, + cwdKey: cwdKey(cwd), + final: true, + }, + { + version: 1, + type: "Stop", + sessionId, + timestamp: now + 2, + cwdKey: cwdKey(cwd), + }, + ]; + await appendFile(logPath, `${stored.map((entry) => JSON.stringify(entry)).join("\n")}\n`); + + await hydrateClaudeEventsFromDisk(); + const state = getClaudeActivityByCwd(cwd, now + 2); + assert.equal(state?.sessionId, sessionId); + assert.deepEqual(state?.events.map((entry) => entry.type), [ + "UserPromptSubmit", + "MessageDisplay", + "Stop", + ]); + assert.equal(state?.cwd, undefined); + assert.equal(state?.cwdKey, cwdKey(cwd)); + + delete process.env.CONSENSUS_CLAUDE_EVENT_LOG; + await rm(directory, { recursive: true, force: true }); + }); + + it("marks StopFailure as a session error without storing error details", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-claude-")); + const logPath = path.join(directory, "events.jsonl"); + process.env.CONSENSUS_CLAUDE_EVENT_LOG = logPath; + const now = Date.now(); + const sessionId = `claude-test-${now}-b`; + await send(sessionId, now, "UserPromptSubmit"); + await send(sessionId, now + 1, "StopFailure"); + await flushClaudeEventPersistence(); + const state = getClaudeActivityBySession(sessionId, now + 1); + assert.equal(state?.inFlight, false); + assert.equal(state?.hasError, true); + assert.equal(state?.events.at(-1)?.summary, "event: StopFailure"); + const persisted = await readFile(logPath, "utf8"); + assert.ok(!persisted.includes("error_details")); + + delete process.env.CONSENSUS_CLAUDE_EVENT_LOG; + await rm(directory, { recursive: true, force: true }); + }); +}); diff --git a/tests/unit/genericHarnessSnapshot.test.ts b/tests/unit/genericHarnessSnapshot.test.ts new file mode 100644 index 0000000..89c9359 --- /dev/null +++ b/tests/unit/genericHarnessSnapshot.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + attachGenericHarnessProcesses, + resetGenericHarnessProcessCacheForTests, +} from "../../src/genericHarnessSnapshot.js"; +import { buildAgentGraph } from "../../src/graph.js"; +import { harnessCwdKey, harnessSessionKey } from "../../src/harnessKeys.js"; +import type { SnapshotPayload } from "../../src/types.js"; + +const emptySnapshot: SnapshotPayload = { + ts: 10_000, + agents: [], +}; + +beforeEach(() => { + resetGenericHarnessProcessCacheForTests(); +}); + +describe("generic harness snapshot", () => { + it("adds process-level agents for detected runtimes", async () => { + const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { + now: 10_000, + processes: [ + { + pid: 10, + name: "openclaw", + cmd: "openclaw agent --cwd /tmp/project --session session-1", + }, + { pid: 11, name: "droid", cmd: "droid" }, + { pid: 12, name: "node", cmd: "node server.js" }, + ], + usage: { + 10: { cpu: 4, memory: 1000, elapsed: 2000 }, + 11: { cpu: 0, memory: 2000, elapsed: 1000 }, + }, + }); + + assert.equal(snapshot.agents.length, 2); + const openclaw = snapshot.agents.find((agent) => agent.pid === 10); + const factory = snapshot.agents.find((agent) => agent.pid === 11); + assert.equal(openclaw?.kind, "openclaw-cli"); + assert.equal(openclaw?.state, "active"); + assert.equal(openclaw?.repo, "project"); + assert.equal(openclaw?.startedAt, 8); + assert.equal(openclaw?.identity, "openclaw:pid:10:start:8"); + assert.equal(openclaw?.cmd, "openclaw agent"); + assert.equal(openclaw?.cmdShort, "openclaw agent"); + assert.equal( + openclaw?.harnessCwdKey, + harnessCwdKey("openclaw", "/tmp/project") + ); + assert.equal( + openclaw?.harnessSessionKey, + harnessSessionKey("openclaw", "session-1") + ); + assert.equal(factory?.kind, "factory-cli"); + assert.equal(factory?.state, "idle"); + }); + + it("clamps impossible process start times instead of exporting negatives", async () => { + const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { + now: 1_000, + processes: [{ pid: 15, name: "droid", cmd: "droid" }], + usage: { 15: { cpu: 0, memory: 0, elapsed: 10_000 } }, + }); + + assert.equal(snapshot.agents[0].startedAt, 0); + assert.equal(snapshot.agents[0].identity, "factory:pid:15:start:0"); + }); + + it("does not duplicate a process already represented by a specialized adapter", async () => { + const snapshot = await attachGenericHarnessProcesses( + { + ts: 10_000, + agents: [ + { + identity: "codex:session", + id: "50", + pid: 50, + cmd: "codex", + cmdShort: "codex", + kind: "tui", + cpu: 0, + mem: 0, + state: "idle", + }, + ], + }, + { + processes: [ + { pid: 50, name: "openclaw", cmd: "openclaw agent" }, + ], + usage: { 50: { cpu: 9, memory: 1 } }, + } + ); + + assert.equal(snapshot.agents.length, 1); + assert.equal(snapshot.agents[0].kind, "tui"); + }); + + it("does not export command prompts or session values", async () => { + const secret = "private prompt contents"; + const sessionSecret = "session-secret-value"; + const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { + processes: [ + { + pid: 70, + name: "cursor-agent", + cmd: `cursor-agent --print ${secret} --session ${sessionSecret}`, + }, + ], + usage: { 70: { cpu: 0, memory: 0 } }, + }); + + const agent = snapshot.agents[0]; + const serialized = JSON.stringify(agent); + assert.equal(agent.title, "Cursor Agent"); + assert.equal(agent.doing, "Cursor Agent agent"); + assert.equal(agent.cmd, "cursor agent"); + assert.equal(agent.cmdShort, "cursor agent"); + assert.equal(serialized.includes(secret), false); + assert.equal(serialized.includes(sessionSecret), false); + assert.match(agent.harnessSessionKey ?? "", /^[a-f0-9]{24}$/); + }); + + it("sanitizes control and bidi characters from exported paths", async () => { + const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { + processes: [ + { + pid: 71, + name: "droid", + cmd: "droid --cwd '/tmp/repo\u001b[2J\u202Eevil'", + }, + ], + usage: { 71: { cpu: 0, memory: 0 } }, + }); + const serialized = JSON.stringify(snapshot.agents[0]); + + assert.equal( + /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test( + serialized + ), + false + ); + }); + + it("caches generic process and usage discovery for short polling windows", async () => { + let processLoads = 0; + let usageLoads = 0; + const processLoader = async () => { + processLoads += 1; + return [{ pid: 72, name: "gemini", cmd: "gemini" }]; + }; + const usageLoader = async () => { + usageLoads += 1; + return { 72: { cpu: 0, memory: 0 } }; + }; + + const first = await attachGenericHarnessProcesses(emptySnapshot, { + cacheMs: 1_000, + processLoader, + usageLoader, + }); + const second = await attachGenericHarnessProcesses(emptySnapshot, { + cacheMs: 1_000, + processLoader, + usageLoader, + }); + + assert.equal(first.agents.length, 1); + assert.equal(second.agents.length, 1); + assert.equal(processLoads, 1); + assert.equal(usageLoads, 1); + }); + + it("can disable generic process caching for deterministic refreshes", async () => { + let processLoads = 0; + const processLoader = async () => { + processLoads += 1; + return [{ pid: 73, name: "copilot", cmd: "copilot" }]; + }; + const usageLoader = async () => ({ 73: { cpu: 0, memory: 0 } }); + + await attachGenericHarnessProcesses(emptySnapshot, { + cacheMs: 0, + processLoader, + usageLoader, + }); + await attachGenericHarnessProcesses(emptySnapshot, { + cacheMs: 0, + processLoader, + usageLoader, + }); + + assert.equal(processLoads, 2); + }); + + it("reports honest hook coverage when no hook history is attached", async () => { + const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { + processes: [{ pid: 80, name: "gemini", cmd: "gemini" }], + usage: { 80: { cpu: 0, memory: 0 } }, + }); + const graph = buildAgentGraph(snapshot); + + assert.equal(graph.coverage.providers.gemini.agents, 1); + assert.equal(graph.coverage.providers.gemini.agentsWithEvents, 0); + assert.equal(graph.coverage.providers.gemini.history, "unavailable"); + assert.match( + graph.coverage.providers.gemini.note ?? "", + /no retained hook events/i + ); + }); + + it("distinguishes tool integrations from full agent loops", async () => { + const snapshot = await attachGenericHarnessProcesses(emptySnapshot, { + processes: [{ pid: 90, name: "mmx", cmd: "mmx image generate" }], + usage: { 90: { cpu: 0, memory: 0 } }, + }); + const graph = buildAgentGraph(snapshot); + + assert.equal(graph.coverage.providers.minimax.history, "unavailable"); + assert.match( + graph.coverage.providers.minimax.note ?? "", + /tool\/model integration/i + ); + }); +}); diff --git a/tests/unit/graph.test.ts b/tests/unit/graph.test.ts new file mode 100644 index 0000000..60f52b0 --- /dev/null +++ b/tests/unit/graph.test.ts @@ -0,0 +1,327 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + analyzeAgentGraph, + buildAgentGraph, + formatAgentGraph, + type AgentGraphNode, +} from "../../src/graph.js"; +import type { + AgentKind, + AgentSnapshot, + EventSummary, + SnapshotPayload, +} from "../../src/types.js"; + +function event( + ts: number, + type: string, + summary: string = type, + isError = false, + turnId?: string +): EventSummary { + return { + ts, + type, + summary, + ...(isError ? { isError } : {}), + ...(turnId ? { turnId } : {}), + }; +} + +function agent( + events: EventSummary[], + state: AgentSnapshot["state"] = "active", + identity = "codex:session-1", + kind: AgentKind = "tui" +): AgentSnapshot { + const second = identity.endsWith("2"); + return { + identity, + id: second ? "102" : "101", + pid: second ? 102 : 101, + title: second ? "second worker" : "graph worker", + cmd: kind.startsWith("claude") ? "claude" : "codex", + cmdShort: kind.startsWith("claude") ? "claude" : "codex", + kind, + cpu: 1, + mem: 100, + state, + events, + }; +} + +function snapshot(events: EventSummary[]): SnapshotPayload { + return { ts: 1_000, agents: [agent(events)] }; +} + +describe("agent graph", () => { + it("keeps live agents when no step events are retained", () => { + const graph = buildAgentGraph(snapshot([])); + + assert.equal(graph.stats.agents, 1); + assert.equal(graph.stats.steps, 0); + assert.equal(graph.stats.loops, 0); + assert.equal(graph.window.retainedEvents, 0); + assert.equal(graph.window.graphEvents, 0); + assert.equal(graph.coverage.providers.codex.history, "available"); + }); + + it("normalizes provider events into model and tool phases", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "event_msg", "thinking"), + event(2, "response_item", "tool: shell"), + event(3, "event_msg", "message"), + event(4, "response_item", "tool: shell"), + ]) + ); + + assert.equal(graph.stats.steps, 2); + assert.equal(graph.stats.transitionEdges, 2); + assert.equal(graph.stats.transitions, 3); + assert.equal(graph.loops.length, 1); + assert.equal(graph.loops[0].kind, "cycle"); + assert.equal(graph.loops[0].transitionObservations, 3); + assert.deepEqual( + graph.nodes + .filter((node) => node.kind === "step") + .map((node) => node.label) + .sort(), + ["model", "tool"] + ); + }); + + it("keeps tool execution events in the tool phase", () => { + const graph = buildAgentGraph( + snapshot([event(1, "tool_execution", "event: tool_execution")]) + ); + + const step = graph.nodes.find((node) => node.kind === "step"); + assert.equal(step?.phase, "tool"); + }); + + it("collapses adjacent stream fragments instead of reporting a false self loop", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "response.delta", "thinking"), + event(2, "response.delta", "thinking"), + event(3, "response.delta", "message"), + ]) + ); + + const step = graph.nodes.find((node) => node.kind === "step"); + assert.equal(graph.stats.steps, 1); + assert.equal(graph.stats.transitionEdges, 0); + assert.equal(graph.stats.loops, 0); + assert.equal(step?.observations, 3); + }); + + it("detects an explicit self edge", () => { + const node: AgentGraphNode = { + id: "step:codex:abc:s1:tool", + kind: "step", + label: "tool", + state: "active", + agentKey: "codex:abc", + provider: "codex", + phase: "tool", + segment: 1, + current: true, + }; + const graph = analyzeAgentGraph({ + source: "observed-events", + ts: 1, + window: { retainedEvents: 1, graphEvents: 1 }, + nodes: [node], + edges: [ + { + id: "transition:self", + source: node.id, + target: node.id, + kind: "transition", + observations: 2, + }, + ], + }); + + assert.equal(graph.loops.length, 1); + assert.equal(graph.loops[0].kind, "self"); + assert.equal(graph.loops[0].transitionObservations, 2); + }); + + it("does not report a one-way phase chain as a loop", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "prompt", "prompt: build"), + event(2, "reasoning", "thinking"), + event(3, "assistant", "message"), + ]) + ); + + assert.equal(graph.stats.transitionEdges, 1); + assert.equal(graph.stats.loops, 0); + }); + + it("does not connect independent turns into a false loop", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "turn.started", "event: turn.started", false, "turn-1"), + event(2, "user_message", "prompt: first", false, "turn-1"), + event(3, "response_item", "thinking", false, "turn-1"), + event(4, "turn.completed", "event: turn.completed", false, "turn-1"), + event(5, "turn.started", "event: turn.started", false, "turn-2"), + event(6, "user_message", "prompt: second", false, "turn-2"), + event(7, "response_item", "message", false, "turn-2"), + ]) + ); + + assert.equal(graph.stats.loops, 0); + assert.equal(graph.stats.steps, 4); + assert.equal(graph.stats.transitionEdges, 2); + assert.deepEqual( + graph.nodes + .filter((node) => node.kind === "step") + .map((node) => node.segment) + .sort(), + [1, 1, 2, 2] + ); + }); + + it("uses prompt boundaries when a provider does not expose turn ids", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "prompt", "prompt: first"), + event(2, "assistant", "message"), + event(3, "prompt", "prompt: second"), + event(4, "assistant", "message"), + ]) + ); + + assert.equal(graph.stats.loops, 0); + assert.equal(graph.stats.steps, 4); + assert.equal(graph.stats.transitionEdges, 2); + }); + + it("does not mark a completed historical loop active during a later phase", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "turn.started", "event: turn.started", false, "turn-1"), + event(2, "response_item", "thinking", false, "turn-1"), + event(3, "tool", "tool: shell", false, "turn-1"), + event(4, "response_item", "message", false, "turn-1"), + event(5, "turn.completed", "event: turn.completed", false, "turn-1"), + event(6, "turn.started", "event: turn.started", false, "turn-2"), + event(7, "prompt", "prompt: next", false, "turn-2"), + event(8, "file_edit", "edit: src/index.ts", false, "turn-2"), + ]) + ); + + assert.equal(graph.stats.loops, 1); + assert.equal(graph.loops[0].state, "idle"); + assert.equal(graph.stats.activeLoops, 0); + assert.deepEqual(graph.loops[0].segments, [1]); + const currentNode = graph.nodes.find( + (node) => node.kind === "step" && node.current + ); + assert.equal(currentNode?.phase, "edit"); + assert.equal(currentNode?.state, "active"); + }); + + it("isolates equal phase names by agent identity and turn", () => { + const graph = buildAgentGraph({ + ts: 1_000, + agents: [ + agent([ + event(1, "tool", "tool: shell"), + event(2, "message", "message"), + event(3, "tool", "tool: shell"), + ]), + agent( + [event(1, "tool", "tool: shell")], + "idle", + "codex:session-2" + ), + ], + }); + + assert.equal(graph.stats.agents, 2); + assert.equal(graph.stats.steps, 3); + assert.equal(graph.stats.loops, 1); + assert.equal(graph.loops[0].agentKeys.length, 1); + }); + + it("does not expose raw or URL-encoded session identities", () => { + const identity = + "/Users/alice/.codex/sessions/2026/07/18/rollout-secret.jsonl"; + const graph = buildAgentGraph({ + ts: 1_000, + agents: [agent([event(1, "assistant", "message")], "idle", identity)], + }); + const json = JSON.stringify(graph); + + assert.equal(json.includes(identity), false); + assert.equal(json.includes(encodeURIComponent(identity)), false); + const agentNode = graph.nodes.find((node) => node.kind === "agent"); + assert.match(agentNode?.agentKey ?? "", /^codex:[a-f0-9]{20}$/); + }); + + it("marks Claude graph history unavailable when no retained events exist", () => { + const graph = buildAgentGraph({ + ts: 1_000, + agents: [ + agent([], "active", "claude:session-1", "claude-tui"), + ], + }); + + assert.equal(graph.stats.agents, 1); + assert.equal(graph.stats.steps, 0); + assert.equal(graph.coverage.providers.claude.history, "unavailable"); + assert.match( + graph.coverage.providers.claude.note ?? "", + /No retained Claude hook events were available/i + ); + }); + + it("marks a current loop as error only when the current phase is in that loop", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "event_msg", "thinking"), + event(2, "tool", "tool: shell"), + event(3, "event_msg", "message"), + ]) + ); + const current = graph.nodes.find( + (node) => node.kind === "step" && node.current + ); + if (current) current.state = "error"; + const analyzed = analyzeAgentGraph({ + source: graph.source, + ts: graph.ts, + window: graph.window, + coverage: graph.coverage, + nodes: graph.nodes, + edges: graph.edges, + }); + + assert.equal(analyzed.loops[0].state, "error"); + assert.equal(analyzed.stats.errorLoops, 1); + }); + + it("renders coverage and explicit transition observation wording", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "event_msg", "thinking"), + event(2, "tool", "tool: shell"), + event(3, "event_msg", "message"), + ]) + ); + const output = formatAgentGraph(graph); + + assert.match(output, /consensus graph/); + assert.match(output, /agents=1 steps=2/); + assert.match(output, /COVERAGE/); + assert.match(output, /model -> tool -> model/); + assert.match(output, /transition_observations=2/); + }); +}); diff --git a/tests/unit/graphCli.test.ts b/tests/unit/graphCli.test.ts new file mode 100644 index 0000000..eaa53ea --- /dev/null +++ b/tests/unit/graphCli.test.ts @@ -0,0 +1,228 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + loadSnapshot, + MAX_SNAPSHOT_BYTES, + parseSnapshot, +} from "../../src/cli/graph.js"; +import type { SnapshotPayload } from "../../src/types.js"; + +const validSnapshot: SnapshotPayload = { + ts: 1_000, + agents: [ + { + identity: "/Users/alice/.codex/sessions/secret.jsonl", + id: "101", + pid: 101, + startedAt: 100, + lastEventAt: 900, + lastActivityAt: 900, + activityReason: "tail_event", + cmd: "codex", + cmdShort: "codex", + kind: "tui", + cpu: 0, + mem: 100, + state: "idle", + summary: { + current: "message", + lastMessage: "message", + }, + events: [ + { + ts: 900, + type: "user_message", + summary: "prompt: test", + turnId: 7, + }, + ], + }, + ], +}; + +describe("graph CLI snapshot contract", () => { + it("accepts a valid snapshot and numeric turn ids", () => { + assert.deepEqual( + parseSnapshot(JSON.stringify(validSnapshot), "test"), + validSnapshot + ); + }); + + it("rejects invalid JSON", () => { + assert.throws( + () => parseSnapshot("{", "test"), + /invalid snapshot JSON from test/ + ); + }); + + it("rejects oversized snapshot text before JSON parsing", () => { + const oversized = " ".repeat(MAX_SNAPSHOT_BYTES + 1); + assert.throws( + () => parseSnapshot(oversized, "test"), + /snapshot from test exceeds/ + ); + }); + + it("rejects malformed agent records", () => { + const malformed = { + ...validSnapshot, + agents: [{ ...validSnapshot.agents[0], state: "running" }], + }; + assert.throws( + () => parseSnapshot(JSON.stringify(malformed), "test"), + /invalid snapshot payload from test/ + ); + }); + + it("rejects malformed retained events", () => { + const malformed = { + ...validSnapshot, + agents: [ + { + ...validSnapshot.agents[0], + events: [{ ts: "yesterday", type: "message", summary: "message" }], + }, + ], + }; + assert.throws( + () => parseSnapshot(JSON.stringify(malformed), "test"), + /invalid snapshot payload from test/ + ); + }); + + it("rejects non-finite and negative timestamps", () => { + assert.throws( + () => parseSnapshot('{"ts":1e400,"agents":[]}', "test"), + /invalid snapshot payload from test/ + ); + assert.throws( + () => + parseSnapshot( + JSON.stringify({ ...validSnapshot, ts: -1 }), + "test" + ), + /invalid snapshot payload from test/ + ); + assert.throws( + () => + parseSnapshot( + JSON.stringify({ + ...validSnapshot, + agents: [{ ...validSnapshot.agents[0], lastEventAt: null }], + }), + "test" + ), + /invalid snapshot payload from test/ + ); + }); + + it("rejects invalid process metrics", () => { + for (const patch of [ + { pid: 1.5 }, + { pid: -1 }, + { cpu: -0.1 }, + { mem: -1 }, + ]) { + assert.throws( + () => + parseSnapshot( + JSON.stringify({ + ...validSnapshot, + agents: [{ ...validSnapshot.agents[0], ...patch }], + }), + "test" + ), + /invalid snapshot payload from test/ + ); + } + }); + + it("rejects terminal control characters in exported labels", () => { + const malformedTitle = { + ...validSnapshot, + agents: [{ ...validSnapshot.agents[0], title: "worker\u001b[2J" }], + }; + const malformedEvent = { + ...validSnapshot, + agents: [ + { + ...validSnapshot.agents[0], + events: [{ ts: 900, type: "message\nforged", summary: "message" }], + }, + ], + }; + + assert.throws( + () => parseSnapshot(JSON.stringify(malformedTitle), "test"), + /invalid snapshot payload from test/ + ); + assert.throws( + () => parseSnapshot(JSON.stringify(malformedEvent), "test"), + /invalid snapshot payload from test/ + ); + }); + + it("rejects unbounded retained event arrays", () => { + const event = { ts: 900, type: "message", summary: "message" }; + const malformed = { + ...validSnapshot, + agents: [ + { + ...validSnapshot.agents[0], + events: Array.from({ length: 10_001 }, () => event), + }, + ], + }; + + assert.throws( + () => parseSnapshot(JSON.stringify(malformed), "test"), + /invalid snapshot payload from test/ + ); + }); + + it("disables OpenCode autostart during live graph scans and restores the environment", async () => { + const previous = process.env.CONSENSUS_OPENCODE_AUTOSTART; + process.env.CONSENSUS_OPENCODE_AUTOSTART = "1"; + let valueDuringLoad: string | undefined; + let valueDuringScan: string | undefined; + + try { + const snapshot = await loadSnapshot(undefined, async () => { + valueDuringLoad = process.env.CONSENSUS_OPENCODE_AUTOSTART; + return { + scanCodexProcesses: async () => { + valueDuringScan = process.env.CONSENSUS_OPENCODE_AUTOSTART; + return validSnapshot; + }, + }; + }); + + assert.equal(snapshot, validSnapshot); + assert.equal(valueDuringLoad, "0"); + assert.equal(valueDuringScan, "0"); + assert.equal(process.env.CONSENSUS_OPENCODE_AUTOSTART, "1"); + } finally { + if (previous === undefined) { + delete process.env.CONSENSUS_OPENCODE_AUTOSTART; + } else { + process.env.CONSENSUS_OPENCODE_AUTOSTART = previous; + } + } + }); + + it("restores an unset autostart environment after a live scan", async () => { + const previous = process.env.CONSENSUS_OPENCODE_AUTOSTART; + delete process.env.CONSENSUS_OPENCODE_AUTOSTART; + + try { + await loadSnapshot(undefined, async () => ({ + scanCodexProcesses: async () => validSnapshot, + })); + assert.equal(process.env.CONSENSUS_OPENCODE_AUTOSTART, undefined); + } finally { + if (previous !== undefined) { + process.env.CONSENSUS_OPENCODE_AUTOSTART = previous; + } + } + }); +}); diff --git a/tests/unit/graphHardening.test.ts b/tests/unit/graphHardening.test.ts new file mode 100644 index 0000000..7edfe4e --- /dev/null +++ b/tests/unit/graphHardening.test.ts @@ -0,0 +1,215 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + analyzeAgentGraph, + buildAgentGraph, + formatAgentGraph, + type AgentGraphEdge, + type AgentGraphNode, +} from "../../src/graph.js"; +import type { + AgentSnapshot, + EventSummary, + SnapshotPayload, +} from "../../src/types.js"; + +function event( + ts: number, + type: string, + summary: string = type, + turnId?: string +): EventSummary { + return { ts, type, summary, ...(turnId ? { turnId } : {}) }; +} + +function snapshot( + events: EventSummary[], + overrides: Partial = {} +): SnapshotPayload { + return { + ts: 10_000, + agents: [ + { + identity: "codex:hardening-session", + id: "901", + pid: 901, + cmd: "codex", + cmdShort: "codex", + kind: "tui", + cpu: 0, + mem: 100, + state: "active", + events, + ...overrides, + }, + ], + }; +} + +function stepNode( + id: string, + agentKey: string, + segment: number, + phase: string +): AgentGraphNode { + return { + id, + kind: "step", + label: phase, + state: "idle", + agentKey, + provider: "codex", + segment, + phase, + }; +} + +function transition( + id: string, + source: string, + target: string +): AgentGraphEdge { + return { + id, + source, + target, + kind: "transition", + observations: 1, + }; +} + +describe("agent graph hardening", () => { + it("does not reactivate a historical loop when a new turn starts without a phase", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "turn.started", "event: turn.started", "turn-1"), + event(2, "response_item", "thinking", "turn-1"), + event(3, "tool", "tool: shell", "turn-1"), + event(4, "response_item", "message", "turn-1"), + event(5, "turn.completed", "event: turn.completed", "turn-1"), + event(6, "turn.started", "event: turn.started", "turn-2"), + ]) + ); + + assert.equal(graph.stats.loops, 1); + assert.equal(graph.stats.activeLoops, 0); + assert.equal(graph.loops[0].state, "idle"); + assert.equal( + graph.nodes.some((node) => node.kind === "step" && node.current), + false + ); + }); + + it("ignores UserPromptExpansion as activity metadata rather than a second prompt", () => { + const graph = buildAgentGraph( + snapshot( + [ + event(1, "UserPromptSubmit", "prompt"), + event(2, "MessageDisplay", "message"), + event(3, "UserPromptExpansion", "prompt"), + event(4, "PreToolUse", "tool: Bash"), + ], + { kind: "claude-tui", cmd: "claude", cmdShort: "claude" } + ) + ); + + const stepNodes = graph.nodes.filter((node) => node.kind === "step"); + assert.deepEqual( + stepNodes.map((node) => node.phase).sort(), + ["model", "prompt", "tool"] + ); + assert.deepEqual( + [...new Set(stepNodes.map((node) => node.segment))], + [1] + ); + assert.equal( + stepNodes.filter((node) => node.phase === "prompt").length, + 1 + ); + }); + + it("filters lifecycle events even when their summary looks like a prompt", () => { + const graph = buildAgentGraph( + snapshot([ + event(1, "turn.started", "prompt: forged"), + event(2, "assistant_message", "message"), + ]) + ); + + const stepNodes = graph.nodes.filter((node) => node.kind === "step"); + assert.deepEqual(stepNodes.map((node) => node.phase), ["model"]); + assert.equal(graph.window.graphEvents, 1); + }); + + it("sanitizes live labels and repository names before text or JSON export", () => { + const graph = buildAgentGraph( + snapshot([event(1, "assistant_message", "message")], { + title: "worker\u001b[2J\u202Eevil", + repo: "repo\nforged", + }) + ); + const output = formatAgentGraph(graph); + const json = JSON.stringify(graph); + const unsafeTerminalControl = + /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/; + + assert.equal(unsafeTerminalControl.test(output), false); + assert.equal(unsafeTerminalControl.test(json), false); + assert.match(output, /worker .*evil/); + }); + + it("rejects cross-segment transitions as loop evidence", () => { + const first = stepNode("step:a:s1:model", "codex:a", 1, "model"); + const second = stepNode("step:a:s2:tool", "codex:a", 2, "tool"); + const graph = analyzeAgentGraph({ + source: "observed-events", + ts: 1, + window: { retainedEvents: 2, graphEvents: 2 }, + nodes: [first, second], + edges: [ + transition("cross-1", first.id, second.id), + transition("cross-2", second.id, first.id), + ], + }); + + assert.equal(graph.stats.loops, 0); + }); + + it("rejects cross-agent transitions as loop evidence", () => { + const first = stepNode("step:a:s1:model", "codex:a", 1, "model"); + const second = stepNode("step:b:s1:tool", "codex:b", 1, "tool"); + const graph = analyzeAgentGraph({ + source: "observed-events", + ts: 1, + window: { retainedEvents: 2, graphEvents: 2 }, + nodes: [first, second], + edges: [ + transition("cross-agent-1", first.id, second.id), + transition("cross-agent-2", second.id, first.id), + ], + }); + + assert.equal(graph.stats.loops, 0); + }); + + it("handles a deep acyclic graph without recursive stack overflow", () => { + const nodeCount = 20_000; + const nodes = Array.from({ length: nodeCount }, (_, index) => + stepNode(`step:deep:s1:p${index}`, "codex:deep", 1, `p${index}`) + ); + const edges = Array.from({ length: nodeCount - 1 }, (_, index) => + transition(`deep-${index}`, nodes[index].id, nodes[index + 1].id) + ); + + const graph = analyzeAgentGraph({ + source: "observed-events", + ts: 1, + window: { retainedEvents: nodeCount, graphEvents: nodeCount }, + nodes, + edges, + }); + + assert.equal(graph.stats.loops, 0); + assert.equal(graph.stats.transitionEdges, nodeCount - 1); + }); +}); diff --git a/tests/unit/harnessCli.test.ts b/tests/unit/harnessCli.test.ts new file mode 100644 index 0000000..1be0c87 --- /dev/null +++ b/tests/unit/harnessCli.test.ts @@ -0,0 +1,53 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + harnessSetupText, +} from "../../src/cli/harnesses.js"; +import { harnessForId } from "../../src/harnesses.js"; + +describe("harness CLI", () => { + it("prints a privacy-safe hook command for hook-capable harnesses", () => { + const text = harnessSetupText( + harnessForId("kimi"), + "/Applications/Consensus/dist/harnessHook.js" + ); + + assert.match( + text, + /node "\/Applications\/Consensus\/dist\/harnessHook\.js" kimi/ + ); + assert.match(text, /Prompt text, tool input\/output/); + assert.match(text, /telemetry=hooks/); + }); + + it("does not claim hook setup for process-only providers", () => { + const text = harnessSetupText( + harnessForId("gemini"), + "/tmp/harnessHook.js" + ); + + assert.doesNotMatch(text, /node .*harnessHook/); + assert.match(text, /process discovery only/i); + }); + + it("distinguishes MiniMax tool integration from an owning loop", () => { + const text = harnessSetupText( + harnessForId("minimax"), + "/tmp/harnessHook.js" + ); + + assert.match(text, /tool\/model integration/i); + assert.doesNotMatch(text, /Configure each supported lifecycle event/); + }); + + it("requires explicit adapters for IDE and remote harnesses", () => { + assert.match( + harnessSetupText(harnessForId("cline"), "/tmp/harnessHook.js"), + /extension bridge/i + ); + assert.match( + harnessSetupText(harnessForId("warp"), "/tmp/harnessHook.js"), + /remote API adapter/i + ); + }); +}); diff --git a/tests/unit/harnessEventLog.test.ts b/tests/unit/harnessEventLog.test.ts new file mode 100644 index 0000000..572702c --- /dev/null +++ b/tests/unit/harnessEventLog.test.ts @@ -0,0 +1,201 @@ +import { + appendFile, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { NormalizedHarnessHookEvent } from "../../src/harnessHookModel.js"; +import { + flushHarnessEventPersistence, + isStoredHarnessHookEvent, + queueHarnessEventPersistence, + readStoredHarnessEvents, +} from "../../src/services/harnessEventLog.js"; + +function event( + index: number, + extra: Partial = {} +): NormalizedHarnessHookEvent { + return { + version: 1, + harnessId: "kimi", + type: "PreToolUse", + sessionKey: index.toString(16).padStart(24, "0").slice(-24), + timestamp: Date.now() + index, + toolName: "Bash", + ...extra, + }; +} + +async function withLog( + run: (directory: string, logPath: string) => Promise +): Promise { + const previousPath = process.env.CONSENSUS_HARNESS_EVENT_LOG; + const previousMax = process.env.CONSENSUS_HARNESS_EVENT_LOG_MAX_BYTES; + const previousTtl = process.env.CONSENSUS_HARNESS_EVENT_TTL_MS; + const directory = await mkdtemp(path.join(os.tmpdir(), "consensus-harness-log-")); + const logPath = path.join(directory, "events.jsonl"); + process.env.CONSENSUS_HARNESS_EVENT_LOG = logPath; + + try { + await run(directory, logPath); + } finally { + await flushHarnessEventPersistence(); + if (previousPath === undefined) { + delete process.env.CONSENSUS_HARNESS_EVENT_LOG; + } else { + process.env.CONSENSUS_HARNESS_EVENT_LOG = previousPath; + } + if (previousMax === undefined) { + delete process.env.CONSENSUS_HARNESS_EVENT_LOG_MAX_BYTES; + } else { + process.env.CONSENSUS_HARNESS_EVENT_LOG_MAX_BYTES = previousMax; + } + if (previousTtl === undefined) { + delete process.env.CONSENSUS_HARNESS_EVENT_TTL_MS; + } else { + process.env.CONSENSUS_HARNESS_EVENT_TTL_MS = previousTtl; + } + await rm(directory, { recursive: true, force: true }); + } +} + +beforeEach(async () => { + await flushHarnessEventPersistence(); +}); + +describe("harness event log", { concurrency: false }, () => { + it("persists duplicate deliveries only once", async () => { + await withLog(async (_directory, logPath) => { + const duplicate = event(1); + void queueHarnessEventPersistence(duplicate); + void queueHarnessEventPersistence(duplicate); + await flushHarnessEventPersistence(); + + const lines = (await readFile(logPath, "utf8")) + .split(/\r?\n/) + .filter(Boolean); + assert.equal(lines.length, 1); + }); + }); + + it("deduplicates a retry already written by another hook process", async () => { + await withLog(async (_directory, logPath) => { + const duplicate = event(3); + await writeFile(logPath, `${JSON.stringify(duplicate)}\n`, "utf8"); + void queueHarnessEventPersistence(duplicate); + await flushHarnessEventPersistence(); + + const lines = (await readFile(logPath, "utf8")) + .split(/\r?\n/) + .filter(Boolean); + assert.equal(lines.length, 1); + }); + }); + + it("captures the configured path when writes are queued", async () => { + await withLog(async (directory, firstPath) => { + const secondPath = path.join(directory, "later.jsonl"); + const queued = queueHarnessEventPersistence(event(2)); + process.env.CONSENSUS_HARNESS_EVENT_LOG = secondPath; + await queued; + + assert.match(await readFile(firstPath, "utf8"), /PreToolUse/); + await assert.rejects(() => readFile(secondPath, "utf8")); + }); + }); + + it("enforces UTF-8 byte bounds while retaining complete JSON lines", async () => { + await withLog(async (_directory, logPath) => { + process.env.CONSENSUS_HARNESS_EVENT_LOG_MAX_BYTES = String(64 * 1024); + for (let index = 0; index < 600; index += 1) { + void queueHarnessEventPersistence( + event(index + 100, { toolName: "🚀".repeat(80) }) + ); + } + await flushHarnessEventPersistence(); + + const info = await stat(logPath); + assert.ok(info.size <= 64 * 1024, `log was ${info.size} bytes`); + const lines = (await readFile(logPath, "utf8")) + .split(/\r?\n/) + .filter(Boolean); + assert.ok(lines.length > 0); + for (const line of lines) assert.doesNotThrow(() => JSON.parse(line)); + }); + }); + + it("ignores malformed, expired, future-skewed, and duplicate disk records", async () => { + await withLog(async (_directory, logPath) => { + process.env.CONSENSUS_HARNESS_EVENT_TTL_MS = "1000"; + const current = event(900); + const expired = event(901, { timestamp: Date.now() - 2_000 }); + const future = event(902, { timestamp: Date.now() + 10 * 60_000 }); + await appendFile( + logPath, + [ + JSON.stringify(current), + JSON.stringify(current), + JSON.stringify(expired), + JSON.stringify(future), + JSON.stringify({ ...current, sessionKey: "raw-session" }), + "{", + "", + ].join("\n") + ); + + const events = await readStoredHarnessEvents(); + assert.deepEqual(events, [current]); + }); + }); + + it("reads only a bounded complete-line tail from an oversized log", async () => { + await withLog(async (_directory, logPath) => { + const current = event(950); + const oversizedPrefix = "x".repeat(5 * 1024 * 1024); + await writeFile( + logPath, + `${oversizedPrefix}\n${JSON.stringify(current)}\n`, + "utf8" + ); + + const events = await readStoredHarnessEvents(); + assert.deepEqual(events, [current]); + }); + }); + + it("validates the complete persisted metadata boundary", () => { + const valid = event(1); + assert.equal(isStoredHarnessHookEvent(valid), true); + assert.equal( + isStoredHarnessHookEvent({ ...valid, harnessId: "unknown" }), + false + ); + assert.equal( + isStoredHarnessHookEvent({ ...valid, timestamp: -1 }), + false + ); + assert.equal( + isStoredHarnessHookEvent({ ...valid, sessionKey: "session-secret" }), + false + ); + assert.equal( + isStoredHarnessHookEvent({ ...valid, toolName: "Bash\nforged" }), + false + ); + assert.equal( + isStoredHarnessHookEvent({ + ...valid, + harnessId: "copilot", + type: "ErrorOccurred", + }), + true + ); + }); +}); diff --git a/tests/unit/harnessEvents.test.ts b/tests/unit/harnessEvents.test.ts new file mode 100644 index 0000000..98d3b11 --- /dev/null +++ b/tests/unit/harnessEvents.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + applyHarnessEvent, + getHarnessActivityBySession, + handleNormalizedHarnessEvent, + listHarnessActivity, + resetHarnessActivityForTests, + type HarnessSessionState, +} from "../../src/services/harnessEvents.js"; +import type { NormalizedHarnessHookEvent } from "../../src/harnessHookModel.js"; + +function event( + type: NormalizedHarnessHookEvent["type"], + timestamp: number, + extra: Partial = {} +): NormalizedHarnessHookEvent { + return { + version: 1, + harnessId: "kimi", + type, + sessionKey: "a".repeat(24), + timestamp, + ...extra, + }; +} + +beforeEach(() => { + resetHarnessActivityForTests(); +}); + +describe("harness event state", () => { + it("builds bounded turn history and clears in-flight state on stop", () => { + let map = new Map(); + map = applyHarnessEvent(map, event("UserPromptSubmit", 1, { turnKey: "b".repeat(24) })); + map = applyHarnessEvent( + map, + event("PreToolUse", 2, { + turnKey: "b".repeat(24), + toolName: "Bash", + }) + ); + map = applyHarnessEvent(map, event("Stop", 3, { turnKey: "b".repeat(24) })); + const state = [...map.values()][0]; + + assert.equal(state.inFlight, false); + assert.equal(state.hasError, false); + assert.equal(state.lastActivityAt, undefined); + assert.deepEqual( + state.events.map((entry) => [entry.type, entry.summary]), + [ + ["UserPromptSubmit", "prompt"], + ["PreToolUse", "tool: Bash"], + ["turn.completed", "event: Stop"], + ] + ); + }); + + it("deduplicates repeated hook deliveries", () => { + const duplicate = event("PreToolUse", 1, { toolName: "Bash" }); + let map = applyHarnessEvent(new Map(), duplicate); + map = applyHarnessEvent(map, duplicate); + const state = [...map.values()][0]; + + assert.equal(state.events.length, 1); + assert.equal(state.inFlight, true); + }); + + it("merges older history without overwriting newer live state", () => { + let map = applyHarnessEvent(new Map(), event("UserPromptSubmit", 200)); + map = applyHarnessEvent(map, event("StopFailure", 100)); + const state = [...map.values()][0]; + + assert.equal(state.inFlight, true); + assert.equal(state.hasError, false); + assert.equal(state.lastSeenAt, 200); + assert.equal(state.lastEventType, "UserPromptSubmit"); + assert.deepEqual( + state.events.map((entry) => entry.type), + ["turn.failed", "UserPromptSubmit"] + ); + }); + + it("marks current failures and clears them on later successful activity", () => { + let map = applyHarnessEvent(new Map(), event("PostToolUseFailure", 1)); + let state = [...map.values()][0]; + assert.equal(state.hasError, true); + assert.equal(state.inFlight, true); + + map = applyHarnessEvent(map, event("PostToolUse", 2)); + state = [...map.values()][0]; + assert.equal(state.hasError, false); + assert.equal(state.inFlight, true); + }); + + it("keeps prompt expansion as activity without adding a graph event", () => { + const map = applyHarnessEvent( + new Map(), + event("UserPromptExpansion", 10) + ); + const state = [...map.values()][0]; + + assert.equal(state.inFlight, true); + assert.equal(state.lastActivityAt, 10); + assert.equal(state.events.length, 0); + }); + + it("marks Copilot errorOccurred metadata as an error without ending the turn", () => { + let map = applyHarnessEvent( + new Map(), + event("UserPromptSubmit", 1, { harnessId: "copilot" }) + ); + map = applyHarnessEvent( + map, + event("ErrorOccurred", 2, { harnessId: "copilot" }) + ); + const state = [...map.values()][0]; + + assert.equal(state.hasError, true); + assert.equal(state.inFlight, true); + assert.equal(state.lastEventType, "ErrorOccurred"); + assert.equal(state.events.at(-1)?.type, "error.occurred"); + assert.equal(state.events.at(-1)?.isError, true); + }); + + it("expires an in-flight session after the configured timeout", () => { + const previous = process.env.CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS; + process.env.CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS = "10"; + try { + handleNormalizedHarnessEvent(event("UserPromptSubmit", 100), false); + assert.equal( + getHarnessActivityBySession("kimi", "a".repeat(24), 105)?.inFlight, + true + ); + assert.equal( + getHarnessActivityBySession("kimi", "a".repeat(24), 111)?.inFlight, + false + ); + } finally { + if (previous === undefined) { + delete process.env.CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_HARNESS_INFLIGHT_TIMEOUT_MS = previous; + } + } + }); + + it("keeps sessions isolated by harness and opaque session key", () => { + handleNormalizedHarnessEvent(event("UserPromptSubmit", 1), false); + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 2, { + harnessId: "factory", + sessionKey: "c".repeat(24), + }), + false + ); + + assert.equal(listHarnessActivity("kimi", 2).length, 1); + assert.equal(listHarnessActivity("factory", 2).length, 1); + assert.equal(listHarnessActivity("qwen", 2).length, 0); + }); +}); diff --git a/tests/unit/harnessHookModel.test.ts b/tests/unit/harnessHookModel.test.ts new file mode 100644 index 0000000..1ab50b6 --- /dev/null +++ b/tests/unit/harnessHookModel.test.ts @@ -0,0 +1,346 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + MAX_HOOK_FUTURE_SKEW_MS, + harnessHookEventKey, + harnessHookEventSummary, + isHookHarnessId, + normalizeHarnessHookPayload, +} from "../../src/harnessHookModel.js"; + +describe("shared harness hook model", () => { + it("normalizes Claude-style hooks without retaining prompt or tool input", () => { + const event = normalizeHarnessHookPayload( + "kimi", + { + hook_event_name: "PreToolUse", + session_id: "session-secret", + cwd: "/Users/alice/private/project", + timestamp: "2026-07-19T01:00:00.000Z", + tool_name: "Bash", + tool_input: { command: "rm -rf private" }, + prompt: "private prompt", + }, + 1 + ); + + assert.ok(event); + assert.equal(event?.harnessId, "kimi"); + assert.equal(event?.type, "PreToolUse"); + assert.equal(event?.toolName, "Bash"); + assert.match(event?.sessionKey ?? "", /^[a-f0-9]{24}$/); + assert.match(event?.cwdKey ?? "", /^[a-f0-9]{24}$/); + const serialized = JSON.stringify(event); + assert.equal(serialized.includes("session-secret"), false); + assert.equal(serialized.includes("/Users/alice"), false); + assert.equal(serialized.includes("rm -rf"), false); + assert.equal(serialized.includes("private prompt"), false); + }); + + it("maps Hermes loop hooks to canonical turn and tool events", () => { + const payloads = [ + ["pre_llm_call", "UserPromptSubmit", "prompt"], + ["pre_tool_call", "PreToolUse", "tool: terminal"], + ["post_tool_call", "PostToolUse", "tool: terminal"], + ["pre_verify", "PreVerify", "tool: verify"], + ["post_llm_call", "Stop", "event: Stop"], + ["subagent_stop", "SubagentStop", "tool: subagent explore"], + ] as const; + + for (const [rawType, canonicalType, summary] of payloads) { + const normalized = normalizeHarnessHookPayload("hermes", { + hook_event_name: rawType, + session_id: "hermes-session", + timestamp: 1_000, + tool_name: "terminal", + extra: { child_role: "explore", turn_id: "turn-1" }, + }); + assert.equal(normalized?.type, canonicalType, rawType); + assert.equal(harnessHookEventSummary(normalized!)?.summary, summary, rawType); + } + }); + + it("normalizes Kimi interrupt and failure events into turn boundaries", () => { + const interrupted = normalizeHarnessHookPayload("kimi", { + hook_event_name: "Interrupt", + session_id: "session", + timestamp: 1, + }); + const failed = normalizeHarnessHookPayload("kimi", { + hook_event_name: "StopFailure", + session_id: "session", + timestamp: 2, + }); + + assert.equal(harnessHookEventSummary(interrupted!)?.type, "turn.interrupted"); + assert.equal(harnessHookEventSummary(failed!)?.type, "turn.failed"); + assert.equal(harnessHookEventSummary(failed!)?.isError, true); + }); + + it("drops non-final stream fragments and activity-only events from graph history", () => { + const partial = normalizeHarnessHookPayload("claude", { + hook_event_name: "MessageDisplay", + session_id: "session", + timestamp: 1, + final: false, + }); + const expansion = normalizeHarnessHookPayload("claude", { + hook_event_name: "UserPromptExpansion", + session_id: "session", + timestamp: 2, + }); + const notification = normalizeHarnessHookPayload("factory", { + hook_event_name: "Notification", + session_id: "session", + timestamp: 3, + notification_type: "idle_prompt", + }); + + assert.equal(harnessHookEventSummary(partial!), undefined); + assert.equal(harnessHookEventSummary(expansion!), undefined); + assert.equal(harnessHookEventSummary(notification!), undefined); + }); + + it("uses opaque turn correlation keys consistently", () => { + const first = normalizeHarnessHookPayload("qwen", { + hook_event_name: "UserPromptSubmit", + session_id: "session", + turn_id: "turn-secret", + timestamp: 1, + }); + const second = normalizeHarnessHookPayload("qwen", { + hook_event_name: "PreToolUse", + session_id: "session", + turn_id: "turn-secret", + timestamp: 2, + tool_name: "Bash", + }); + + assert.equal(first?.turnKey, second?.turnKey); + assert.match(first?.turnKey ?? "", /^[a-f0-9]{24}$/); + assert.equal(JSON.stringify(first).includes("turn-secret"), false); + assert.equal(harnessHookEventSummary(first!)?.turnId, first?.turnKey); + }); + + it("parses seconds, milliseconds, ISO timestamps, and received-time fallback", () => { + const seconds = normalizeHarnessHookPayload("factory", { + hook_event_name: "SessionStart", + session_id: "s1", + timestamp: 1_700_000_000, + }); + const milliseconds = normalizeHarnessHookPayload("factory", { + hook_event_name: "SessionStart", + session_id: "s2", + timestamp: 1_700_000_000_000, + }); + const iso = normalizeHarnessHookPayload("factory", { + hook_event_name: "SessionStart", + session_id: "s3", + timestamp: "2026-07-19T00:00:00.000Z", + }); + const fallback = normalizeHarnessHookPayload( + "factory", + { hook_event_name: "SessionStart", session_id: "s4" }, + 1234 + ); + + assert.equal(seconds?.timestamp, 1_700_000_000_000); + assert.equal(milliseconds?.timestamp, 1_700_000_000_000); + assert.equal(iso?.timestamp, Date.parse("2026-07-19T00:00:00.000Z")); + assert.equal(fallback?.timestamp, 1234); + }); + + it("rejects unknown events and malformed payloads", () => { + assert.equal(normalizeHarnessHookPayload("kimi", null), undefined); + assert.equal( + normalizeHarnessHookPayload("kimi", { + hook_event_name: "UnknownFutureEvent", + session_id: "session", + }), + undefined + ); + assert.equal( + normalizeHarnessHookPayload("kimi", { + hook_event_name: "PreToolUse", + }), + undefined + ); + }); + + it("creates stable deduplication keys from retained metadata only", () => { + const event = normalizeHarnessHookPayload("factory", { + hook_event_name: "PostToolUse", + session_id: "session", + timestamp: 1, + tool_name: "Execute", + tool_output: "secret", + }); + const key = harnessHookEventKey(event!); + + assert.equal(key.includes("secret"), false); + assert.equal(key, harnessHookEventKey(event!)); + }); + + it("normalizes Gemini agent, model, and tool hooks without payload content", () => { + const now = Date.parse("2026-07-19T01:00:00.000Z"); + const beforeAgent = normalizeHarnessHookPayload( + "gemini", + { + hook_event_name: "BeforeAgent", + session_id: "gemini-session", + cwd: "/Users/alice/private-project", + timestamp: new Date(now).toISOString(), + prompt: "private prompt", + }, + now + ); + const afterModel = normalizeHarnessHookPayload( + "gemini", + { + hook_event_name: "AfterModel", + session_id: "gemini-session", + timestamp: new Date(now + 1_000).toISOString(), + llm_response: { candidates: [{ content: "private response" }] }, + }, + now + 1_000 + ); + const beforeTool = normalizeHarnessHookPayload( + "gemini", + { + hook_event_name: "BeforeTool", + session_id: "gemini-session", + timestamp: new Date(now + 2_000).toISOString(), + tool_name: "write_file", + tool_input: { path: "/private/file" }, + }, + now + 2_000 + ); + const afterAgent = normalizeHarnessHookPayload( + "gemini", + { + hook_event_name: "AfterAgent", + session_id: "gemini-session", + timestamp: new Date(now + 3_000).toISOString(), + prompt_response: "private final response", + }, + now + 3_000 + ); + + assert.equal(isHookHarnessId("gemini"), true); + assert.equal(beforeAgent?.type, "UserPromptSubmit"); + assert.equal(afterModel?.type, "MessageDisplay"); + assert.equal(beforeTool?.type, "PreToolUse"); + assert.equal(beforeTool?.toolName, "write_file"); + assert.equal(afterAgent?.type, "Stop"); + const serialized = JSON.stringify([ + beforeAgent, + afterModel, + beforeTool, + afterAgent, + ]); + assert.equal(serialized.includes("private"), false); + }); + + it("normalizes Copilot camelCase and Claude-compatible hooks", () => { + const now = 1_800_000_000_000; + const prompt = normalizeHarnessHookPayload( + "copilot", + { + event: "userPromptSubmitted", + sessionId: "copilot-session", + timestamp: now, + cwd: "/private/repo", + prompt: "private prompt", + }, + now + ); + const tool = normalizeHarnessHookPayload( + "copilot", + { + hook_event_name: "PreToolUse", + session_id: "copilot-session", + timestamp: new Date(now + 1_000).toISOString(), + tool_name: "bash", + tool_input: { command: "cat ~/.ssh/id_ed25519" }, + }, + now + 1_000 + ); + const subagent = normalizeHarnessHookPayload( + "copilot", + { + event: "subagentStart", + sessionId: "copilot-session", + timestamp: now + 2_000, + agentName: "reviewer", + }, + now + 2_000 + ); + const failure = normalizeHarnessHookPayload( + "copilot", + { + event: "errorOccurred", + sessionId: "copilot-session", + timestamp: now + 3_000, + error: "private stack trace", + }, + now + 3_000 + ); + const stop = normalizeHarnessHookPayload( + "copilot", + { + event: "agentStop", + sessionId: "copilot-session", + timestamp: now + 4_000, + }, + now + 4_000 + ); + + assert.equal(isHookHarnessId("copilot"), true); + assert.equal(prompt?.type, "UserPromptSubmit"); + assert.equal(tool?.type, "PreToolUse"); + assert.equal(subagent?.agentType, "reviewer"); + assert.equal(failure?.type, "ErrorOccurred"); + assert.equal(harnessHookEventSummary(failure!)?.type, "error.occurred"); + assert.equal(harnessHookEventSummary(failure!)?.isError, true); + assert.equal(stop?.type, "Stop"); + const serialized = JSON.stringify([prompt, tool, subagent, failure, stop]); + assert.equal(serialized.includes("private prompt"), false); + assert.equal(serialized.includes("id_ed25519"), false); + assert.equal(serialized.includes("private stack trace"), false); + }); + + it("clamps hook timestamps beyond the allowed receive-time skew", () => { + const receivedAt = 1_800_000_000_000; + const tooFar = normalizeHarnessHookPayload( + "kimi", + { + hook_event_name: "UserPromptSubmit", + session_id: "future", + timestamp: receivedAt + MAX_HOOK_FUTURE_SKEW_MS + 1, + }, + receivedAt + ); + const allowed = normalizeHarnessHookPayload( + "kimi", + { + hook_event_name: "UserPromptSubmit", + session_id: "allowed", + timestamp: receivedAt + MAX_HOOK_FUTURE_SKEW_MS, + }, + receivedAt + ); + const negative = normalizeHarnessHookPayload( + "kimi", + { + hook_event_name: "UserPromptSubmit", + session_id: "negative", + timestamp: -1, + }, + receivedAt + ); + + assert.equal(tooFar?.timestamp, receivedAt); + assert.equal(allowed?.timestamp, receivedAt + MAX_HOOK_FUTURE_SKEW_MS); + assert.equal(negative, undefined); + }); +}); diff --git a/tests/unit/harnessHookOutput.test.ts b/tests/unit/harnessHookOutput.test.ts new file mode 100644 index 0000000..b8228b2 --- /dev/null +++ b/tests/unit/harnessHookOutput.test.ts @@ -0,0 +1,18 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { harnessHookAcknowledgement } from "../../src/harnessHookOutput.js"; + +describe("harness hook acknowledgements", () => { + it("returns compact JSON for harnesses that require JSON stdout", () => { + assert.equal(harnessHookAcknowledgement("gemini"), "{}\n"); + assert.equal(harnessHookAcknowledgement("copilot"), "{}\n"); + }); + + it("keeps stdout empty for harnesses with passive command hooks", () => { + assert.equal(harnessHookAcknowledgement("claude"), ""); + assert.equal(harnessHookAcknowledgement("hermes"), ""); + assert.equal(harnessHookAcknowledgement("kimi"), ""); + assert.equal(harnessHookAcknowledgement("factory"), ""); + assert.equal(harnessHookAcknowledgement("qwen"), ""); + }); +}); diff --git a/tests/unit/harnessSnapshot.test.ts b/tests/unit/harnessSnapshot.test.ts new file mode 100644 index 0000000..44bbc5c --- /dev/null +++ b/tests/unit/harnessSnapshot.test.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { buildAgentGraph } from "../../src/graph.js"; +import { attachHarnessEvents } from "../../src/harnessSnapshot.js"; +import type { NormalizedHarnessHookEvent } from "../../src/harnessHookModel.js"; +import { + handleNormalizedHarnessEvent, + resetHarnessActivityForTests, +} from "../../src/services/harnessEvents.js"; +import type { AgentSnapshot, SnapshotPayload } from "../../src/types.js"; + +function event( + type: NormalizedHarnessHookEvent["type"], + timestamp: number, + extra: Partial = {} +): NormalizedHarnessHookEvent { + return { + version: 1, + harnessId: "factory", + type, + sessionKey: "a".repeat(24), + cwdKey: "b".repeat(24), + timestamp, + ...extra, + }; +} + +function agent(overrides: Partial = {}): AgentSnapshot { + return { + identity: "factory:pid:10:start:1", + id: "10", + pid: 10, + cmd: "factory agent", + cmdShort: "factory agent", + kind: "factory-cli", + cpu: 0, + mem: 0, + state: "idle", + harnessSessionKey: "a".repeat(24), + harnessCwdKey: "b".repeat(24), + ...overrides, + }; +} + +function snapshot(agents: AgentSnapshot[], ts = 100): SnapshotPayload { + return { ts, agents }; +} + +beforeEach(() => { + resetHarnessActivityForTests(); +}); + +describe("harness snapshot attachment", () => { + it("attaches exact session history and active state", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { turnKey: "c".repeat(24) }), + false + ); + handleNormalizedHarnessEvent( + event("PreToolUse", 91, { + turnKey: "c".repeat(24), + toolName: "Execute", + }), + false + ); + + const attached = attachHarnessEvents(snapshot([agent()])); + const current = attached.agents[0]; + assert.equal(current.state, "active"); + assert.equal(current.activityReason, "harness_hook_in_flight"); + assert.deepEqual( + current.events?.map((entry) => entry.summary), + ["prompt", "tool: Execute"] + ); + assert.equal(current.summary?.lastPrompt, "prompt"); + assert.equal(current.summary?.lastTool, "tool: Execute"); + assert.equal(current.harnessSessionKey, "a".repeat(24)); + }); + + it("uses cwd matching when the process command has no session id", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { sessionKey: "d".repeat(24) }), + false + ); + const attached = attachHarnessEvents( + snapshot([ + agent({ harnessSessionKey: undefined, harnessCwdKey: "b".repeat(24) }), + ]) + ); + + assert.equal(attached.agents[0].harnessSessionKey, "d".repeat(24)); + assert.equal(attached.agents[0].state, "active"); + }); + + it("rejects cwd matching when several hook sessions share the directory", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { + sessionKey: "d".repeat(24), + cwdKey: "b".repeat(24), + }), + false + ); + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 91, { + sessionKey: "e".repeat(24), + cwdKey: "b".repeat(24), + }), + false + ); + + const attached = attachHarnessEvents( + snapshot([ + agent({ harnessSessionKey: undefined, harnessCwdKey: "b".repeat(24) }), + ]) + ); + + assert.equal(attached.agents[0].events, undefined); + assert.equal(attached.agents[0].harnessSessionKey, undefined); + assert.equal(attached.agents[0].state, "idle"); + }); + + it("uses an exact session key even when cwd matching is ambiguous", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { + sessionKey: "d".repeat(24), + cwdKey: "b".repeat(24), + }), + false + ); + handleNormalizedHarnessEvent( + event("Stop", 91, { + sessionKey: "e".repeat(24), + cwdKey: "b".repeat(24), + }), + false + ); + + const attached = attachHarnessEvents( + snapshot([ + agent({ + harnessSessionKey: "d".repeat(24), + harnessCwdKey: "b".repeat(24), + }), + ]) + ); + + assert.equal(attached.agents[0].harnessSessionKey, "d".repeat(24)); + assert.equal(attached.agents[0].state, "active"); + assert.deepEqual( + attached.agents[0].events?.map((entry) => entry.summary), + ["prompt"] + ); + }); + + it("uses one-to-one fallback only when assignment is unambiguous", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { + sessionKey: "e".repeat(24), + cwdKey: undefined, + }), + false + ); + const single = attachHarnessEvents( + snapshot([agent({ harnessSessionKey: undefined, harnessCwdKey: undefined })]) + ); + assert.equal(single.agents[0].harnessSessionKey, "e".repeat(24)); + + resetHarnessActivityForTests(); + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { + sessionKey: "e".repeat(24), + cwdKey: undefined, + }), + false + ); + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 91, { + sessionKey: "f".repeat(24), + cwdKey: undefined, + }), + false + ); + const ambiguous = attachHarnessEvents( + snapshot([agent({ harnessSessionKey: undefined, harnessCwdKey: undefined })]) + ); + assert.equal(ambiguous.agents[0].events, undefined); + }); + + it("does not overwrite newer specialized activity with stale hook state", () => { + handleNormalizedHarnessEvent(event("Stop", 80), false); + const attached = attachHarnessEvents( + snapshot([ + agent({ + state: "active", + lastEventAt: 95, + lastActivityAt: 95, + activityReason: "native_event", + }), + ]) + ); + + assert.equal(attached.agents[0].state, "active"); + assert.equal(attached.agents[0].activityReason, "native_event"); + assert.equal(attached.agents[0].lastEventAt, 95); + assert.deepEqual( + attached.agents[0].events?.map((entry) => entry.type), + ["turn.completed"] + ); + }); + + it("keeps sessions isolated when several agents share a harness", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { + sessionKey: "1".repeat(24), + cwdKey: "3".repeat(24), + }), + false + ); + handleNormalizedHarnessEvent( + event("Stop", 91, { + sessionKey: "2".repeat(24), + cwdKey: "4".repeat(24), + }), + false + ); + const attached = attachHarnessEvents( + snapshot([ + agent({ + identity: "factory:1", + id: "1", + pid: 1, + harnessSessionKey: "1".repeat(24), + harnessCwdKey: "3".repeat(24), + }), + agent({ + identity: "factory:2", + id: "2", + pid: 2, + harnessSessionKey: "2".repeat(24), + harnessCwdKey: "4".repeat(24), + }), + ]) + ); + + assert.equal(attached.agents[0].state, "active"); + assert.equal(attached.agents[1].state, "idle"); + }); + + it("turns attached hook metadata into provider-scoped graph phases", () => { + handleNormalizedHarnessEvent( + event("UserPromptSubmit", 90, { turnKey: "c".repeat(24) }), + false + ); + handleNormalizedHarnessEvent( + event("PreToolUse", 91, { + turnKey: "c".repeat(24), + toolName: "Execute", + }), + false + ); + handleNormalizedHarnessEvent( + event("Stop", 92, { turnKey: "c".repeat(24) }), + false + ); + + const graph = buildAgentGraph(attachHarnessEvents(snapshot([agent()], 100))); + assert.equal(graph.coverage.providers.factory.history, "available"); + assert.deepEqual( + graph.nodes + .filter((node) => node.kind === "step") + .map((node) => node.phase) + .sort(), + ["prompt", "tool"] + ); + assert.equal(graph.stats.loops, 0); + }); +}); diff --git a/tests/unit/harnesses.test.ts b/tests/unit/harnesses.test.ts new file mode 100644 index 0000000..b17da69 --- /dev/null +++ b/tests/unit/harnesses.test.ts @@ -0,0 +1,109 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + agentKinds, + detectGenericHarnessProcess, + harnessDefinitions, + harnessForId, + harnessIdForKind, + isAgentKind, +} from "../../src/harnesses.js"; + +describe("harness registry", () => { + it("maps every agent kind to exactly one harness", () => { + const seen = new Map(); + for (const harness of harnessDefinitions) { + for (const kind of harness.kinds) { + assert.equal(seen.has(kind), false, `${kind} mapped more than once`); + seen.set(kind, harness.id); + } + } + + assert.deepEqual([...seen.keys()].sort(), [...agentKinds].sort()); + for (const kind of agentKinds) { + assert.equal(isAgentKind(kind), true); + assert.equal(harnessIdForKind(kind), seen.get(kind)); + } + }); + + it("detects the named local harness binaries", () => { + const cases = [ + ["openclaw agent --message test", "openclaw", "openclaw-cli"], + ["openclaw gateway", "openclaw", "openclaw-server"], + ["hermes chat", "hermes", "hermes-cli"], + ["hermes gateway", "hermes", "hermes-server"], + ["kimi --resume session", "kimi", "kimi-cli"], + ["mmx video generate", "minimax", "minimax-cli"], + ["cursor-agent --print task", "cursor", "cursor-cli"], + ["oz agent run --prompt task", "warp", "warp-cli"], + ["droid", "factory", "factory-cli"], + ["gemini", "gemini", "gemini-cli"], + ["qwen-code", "qwen", "qwen-cli"], + ["copilot", "copilot", "copilot-cli"], + ["q chat", "amazon-q", "amazon-q-cli"], + ["kiro-cli", "kiro", "kiro-cli"], + ["openhands", "openhands", "openhands-cli"], + ] as const; + + for (const [command, harnessId, kind] of cases) { + const detected = detectGenericHarnessProcess(command, undefined); + assert.equal(detected?.harness.id, harnessId, command); + assert.equal(detected?.kind, kind, command); + } + }); + + it("normalizes Windows executable aliases", () => { + assert.equal( + detectGenericHarnessProcess( + '"C:\\Tools\\gemini.exe"', + "gemini.exe" + )?.kind, + "gemini-cli" + ); + assert.equal( + detectGenericHarnessProcess( + "C:\\Tools\\copilot.exe", + "copilot.exe" + )?.kind, + "copilot-cli" + ); + }); + + it("declares Gemini and Copilot as hook-backed harnesses", () => { + assert.equal(harnessForId("gemini").telemetry, "hooks"); + assert.equal(harnessForId("copilot").telemetry, "hooks"); + assert.match( + harnessForId("gemini").docs ?? "", + /geminicli\.com\/docs\/hooks/ + ); + assert.match( + harnessForId("copilot").docs ?? "", + /docs\.github\.com/ + ); + }); + + it("uses the executable token instead of matching names mentioned in prompts", () => { + assert.equal( + detectGenericHarnessProcess("node runner.js --prompt 'use openclaw'", "node"), + undefined + ); + assert.equal( + detectGenericHarnessProcess("node runner.js --prompt 'use gemini'", "node"), + undefined + ); + assert.equal(detectGenericHarnessProcess("oz config list", "oz"), undefined); + assert.equal(detectGenericHarnessProcess("q --version", "q"), undefined); + assert.equal(detectGenericHarnessProcess("agent --print task", "agent"), undefined); + }); + + it("prefers server rules before broad CLI rules", () => { + assert.equal( + detectGenericHarnessProcess("openclaw daemon", "openclaw")?.kind, + "openclaw-server" + ); + assert.equal( + detectGenericHarnessProcess("hermes serve", "hermes")?.kind, + "hermes-server" + ); + }); +});