diff --git a/.github/workflows/publish-daemon-client.yml b/.github/workflows/publish-daemon-client.yml new file mode 100644 index 000000000..81dc82c9b --- /dev/null +++ b/.github/workflows/publish-daemon-client.yml @@ -0,0 +1,54 @@ +name: Publish Daemon Client Package + +on: + release: + types: [created] + workflow_dispatch: + inputs: + tag: + description: 'Tag to publish from (e.g., cli@0.67.0)' + required: false + +permissions: + contents: read + +jobs: + publish: + name: Publish @ai-devkit/daemon-client to NPM + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.sha }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: 'https://registry.npmjs.org' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build all packages + run: npm run build + + - name: Pin devkitd platform packages + working-directory: ./packages/daemon-client + # Injected at publish time so dev installs never resolve the + # unpublished platform packages. Version matches this package's — + # publish-devkitd.yml stamps the same version on the binaries. + run: | + V=$(node -p "require('./package.json').version") + for p in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do + npm pkg set "optionalDependencies.@ai-devkit/devkitd-$p=$V" + done + + - name: Publish to NPM + working-directory: ./packages/daemon-client + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish-devkitd.yml b/.github/workflows/publish-devkitd.yml new file mode 100644 index 000000000..9eccb307d --- /dev/null +++ b/.github/workflows/publish-devkitd.yml @@ -0,0 +1,97 @@ +name: Publish devkitd Platform Packages + +on: + release: + types: [created] + workflow_dispatch: + inputs: + tag: + description: 'Tag to build and publish from (e.g., cli@0.67.0)' + required: false + +permissions: + contents: read + +jobs: + build-publish: + name: Build and publish @ai-devkit/devkitd-${{ matrix.pkg }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # Linux targets are musl-static so one binary covers glibc and + # Alpine/musl hosts. + - os: macos-latest + target: aarch64-apple-darwin + pkg: darwin-arm64 + - os: macos-latest + target: x86_64-apple-darwin + pkg: darwin-x64 + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + pkg: linux-x64 + musl-tools: true + - os: ubuntu-latest + target: aarch64-unknown-linux-musl + pkg: linux-arm64 + cross: true + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.sha }} + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install musl toolchain + if: matrix.musl-tools + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Install cross + if: matrix.cross + uses: taiki-e/install-action@v2 + with: + tool: cross + + - name: Build devkitd + working-directory: ./rust + run: | + if [ "${{ matrix.cross }}" = "true" ]; then + cross build --release --target ${{ matrix.target }} -p devkitd + else + cargo build --release --target ${{ matrix.target }} -p devkitd + fi + BIN=target/${{ matrix.target }}/release/devkitd + file "$BIN" 2>/dev/null || true + test -x "$BIN" + + - name: Stage platform package + run: | + OUT=out/devkitd-${{ matrix.pkg }} + mkdir -p "$OUT" + cp npm/devkitd-${{ matrix.pkg }}/package.json "$OUT/" + cp rust/target/${{ matrix.target }}/release/devkitd "$OUT/devkitd" + chmod +x "$OUT/devkitd" + + - name: Stamp version + working-directory: ./out/devkitd-${{ matrix.pkg }} + # Platform packages version in lockstep with daemon-client — its + # optionalDependencies pin the same version. + run: npm pkg set version="$(node -p "require('$GITHUB_WORKSPACE/packages/daemon-client/package.json').version")" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: 'https://registry.npmjs.org' + + - name: Publish to NPM + working-directory: ./out/devkitd-${{ matrix.pkg }} + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index c4db0071a..f5cfb435e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ coverage/ .github/prompts/ .github/skills/ .worktrees/ +rust/target/ +packages/daemon-client/dist/ +fixtures/harness/**/live*.json diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..d5f589a7e --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +packages/daemon-client/src/gen/ diff --git a/docs/ai/design/2026-10-07-feature-rust-daemon.md b/docs/ai/design/2026-10-07-feature-rust-daemon.md new file mode 100644 index 000000000..e7f6bf20f --- /dev/null +++ b/docs/ai/design/2026-10-07-feature-rust-daemon.md @@ -0,0 +1,166 @@ +--- +phase: design +title: ai-devkitd design +description: Unix-socket JSON-RPC daemon; SQLite sole writer; append-only event log; single discovery loop; tmux-style autostart; npm binary distribution +--- + +# Design — ai-devkitd + +## Architecture overview + +`ai-devkitd` is a single static Rust binary that owns coordination: shared +state, event distribution, process discovery, and IPC. Everything else — +harness-specific detection and parsing, channel message formatting, TUI +rendering — stays in the TypeScript clients. The daemon never needs to know +what a "codex session file" is; it knows pids, cwds, commands, registries, and +events. That boundary is deliberate: it is what lets the daemon be Rust without +dragging the TS adapter layer along, and it keeps the wire schema +harness-agnostic so a later port of parser knowledge doesn't force protocol +changes. + +## IPC contract + +- Transport: unix domain socket at `~/.ai-devkit/daemon.sock`, mode `0600`, + same-uid enforcement via `SO_PEERCRED` on Linux (permissive cfg fallback on + other platforms, where the 0600 socket still applies). +- Framing: one JSON object per line, both directions. Request + `{id?, method, params?}`; response `{id?, result?}|{id?, error?}` (absent + fields omitted, never `null`); event frame `{"event": {seq, ts, kind, + payload}}`. +- gRPC and localhost HTTP were rejected: no remote caller exists, TCP is a + security regression for a same-user coordination bus, and the + memory-dashboard unauthenticated-bind lesson says don't listen on the + network for local-only state. + +### Methods + +| Method | Params | Result | +|---|---|---| +| `ping` | — | `{pong, startedAt}` | +| `daemon.status` | — | `{version, startedAt, socket}` | +| `agent.list` | — | array of fully attributed `AgentInfo` rows — authoritative for all harness types | +| `subscribe` | `{afterSeq?, liveOnly?}` | ack `{subscribed}`, then event frames; replays persisted events after `afterSeq` first unless `liveOnly` | +| `shutdown` | — | `{ok}`; daemon exits after the response flushes | + +The scoped `registry` KV table was removed: its only consumer +(channel-bridge bookkeeping) already had a complete JSON-file path, and +daemon rows had to be unioned with file rows at read time — the two-store +merge was worse than the file alone. `~/.ai-devkit/channel-bridges.json` +is again the single store; `~/.ai-devkit/channels.json` owns credentials. + +## State: SQLite, daemon sole writer + +rusqlite (bundled), WAL journal, `synchronous=NORMAL`. Two tables: + +- `events(seq, ts, kind, payload)` — append-only log; `seq` is AUTOINCREMENT. + This is the pub/sub substrate: subscribers ack by `seq`, replay survives + daemon restarts, and the log doubles as an audit trail. +- `agents(pid, ppid, tty, command, cwd, session_file, first_seen, last_seen)` — + the discovery snapshot. `session_file` exists in the schema but is + unpopulated: session-file resolution is harness knowledge, client-side by + design. + +## Events + +Mutations append to `events` and publish to a `tokio::broadcast` channel. +`subscribe` first drains persisted events with `seq > afterSeq` (at-least-once +across reconnects), acks, then streams live frames. Kinds: +`agent.appeared`, `agent.disappeared`. Lifecycle payloads carry +`{pid, agents: EnrichedAgent[]}` — appeared looks up the fresh sweep cache, +disappeared the previous one (last-known info; unattributed procs get `[]`). + +## Discovery loop + +One `ps -axo pid=,ppid=,tty=,command=` sweep every 2s inside the daemon — +replacing the per-invocation sweeps every client ran before. Filtering is by +executable basename against the harness binary list (`claude`, `codex`, +`gemini`, `copilot`, `grok`, `opencode`, `pi`, `kiro-cli-chat`, `devin`, `kimi`, +`cursor-agent`, `droid`). Cwd comes from `/proc//cwd` — one syscall, no +`lsof`/`pwdx` spawn. Each sweep diffs the snapshot and emits appear/vanish +events. Known limitation: wrapper-launched agents (`node codex-linux.js`) +aren't matched by basename — recorded for the phase-2 knowledge port. + +## Autostart and lifecycle + +tmux-style implicit spawn, implemented in `@ai-devkit/daemon-client`: +`ensureDaemon()` tries the socket; on miss it takes an `O_EXCL` lockfile +(single spawner; locks older than 30s are reclaimed so a crashed starter can't +wedge autostart), spawns `devkitd serve` detached with output appended to +`~/.ai-devkit/daemon.log`, and polls the socket for readiness (≤3s). Socket +existence is liveness — no PID files, so the stale-PID/`kill(pid,0)` hazard +class cannot recur here. `devkitd install` writes a systemd `--user` unit +for boot persistence; the daemon also runs fine with pure implicit spawn. + +## Distribution + +Rust lives in `rust/` inside the monorepo. npm keeps shipping the JS `ai-devkit` +bin; `@ai-devkit/daemon-client` resolves the daemon binary in order: +`DEVKITD_BIN` env → `@ai-devkit/devkitd--` optional-dep +package → `~/.ai-devkit/bin/devkitd` → +`rust/target/{release,debug}/devkitd` in a dev checkout. Nothing found ⇒ +callers degrade to pre-daemon behavior. + +Platform packages live as stubs under `npm/devkitd-{darwin,linux}-{arm64,x64}/` +(version `0.0.0` placeholders — stamped from `packages/daemon-client/package.json` +at publish). Linux binaries are musl-static so one build covers glibc and Alpine. +`publish-devkitd.yml` builds a four-target matrix (darwin arm64/x64 on +macos-latest, linux musl x64 native + arm64 via `cross`); `publish-daemon-client.yml` +injects the `optionalDependencies` pins at publish time so dev installs never +resolve the unpublished platform packages. Windows has no daemon (unix-only +sockets) — the resolver returns null and clients fall back. + +## The five settled decisions + +1. **Absorb vs coexist**: end state is one `ai-devkitd`. Channel bridges become + socket clients now, supervised children in v1.5. pi-session-tracker can't be + absorbed (it runs inside each pi process) — its *store* is absorbed instead: + the extension is a daemon-first writer with JSON fallback. memory-dashboard + keeps its UI but must not hold its own writer connection to the daemon DB. + Never two daemons side by side. +2. **Autostart**: implicit spawn + optional systemd `--user` unit, as above. +3. **Events**: persisted append-only log + at-least-once subscribers. Caveat + kept explicit: this covers "what happened" and replay; it does not subsume + task-manager's *transactional multi-row writes* — that absorption needs a + schema review before it's claimed. +4. **Packaging**: `rust/` workspace + npm wrapper + per-platform binary + optional deps, as above. +5. **Knowledge port**: golden-file corpus extracted from the TS parsers is the + drift-proof contract — captured session files + expected outputs per + harness, run against both implementations, crate-by-crate. The corpus itself + is v1.5+ work; the v1 discipline it enforces (harness-agnostic wire schema) + is already applied. + +## Console subscription migration (v1 scope) + +`useAgentList` (`cli/src/tui/console/hooks/useAgentList.ts`) currently re-runs +`manager.listAgents` every 3s regardless of whether anything changed. Migrated +design: on mount the hook calls `DaemonClient.tryConnect` / `ensureDaemon`; on +success it `subscribe`s and each `agent.appeared` / `agent.disappeared` +frame triggers an immediate `refresh()`. The interval drops +to a slow fallback (60s) covering attribution drift the daemon can't see +(session-file paths, model, status text — all harness knowledge). On subscribe +failure the hook keeps today's 3s interval — zero behavior change without a +daemon. React specifics: the subscription callback only ever calls the existing +`refresh`, so all existing in-flight/mounted guards still apply; the client is +closed on unmount. + +Data flow stays: daemon supplies *invalidation* ("something changed"), the +console still pulls the enriched list from `manager.listAgents`. This is the +honest coordination-only split — daemon events are timing, not content. + +## What the daemon does NOT own + +Git operations (repo-level locks are the answer there, not a serialization +proxy), releases, skills, agent-side hooks, harness parsing (until the +fixture-contracted port), and any network listener. + +## Deviations recorded honestly + +- The CLI's `agent list` path was not rewired to the daemon cache at I0: + attribution and session-file knowledge were client-side, so a daemon-backed + fast path needed the knowledge port to pay off. (Post-port the raw + snapshot RPC was removed — the attributed view took over the `agent.list` + name as the single list API; the agents table is an internal diff ledger.) +- Bridge supervision and capacity caching were scoped to v1.5 by explicit + decision; the console's event-driven refresh is in v1 (see above) while + channel-bridge output polling stays client-side (harness parsing). diff --git a/docs/ai/design/2026-10-08-feature-devkitd-monorepo.md b/docs/ai/design/2026-10-08-feature-devkitd-monorepo.md new file mode 100644 index 000000000..363261bb2 --- /dev/null +++ b/docs/ai/design/2026-10-08-feature-devkitd-monorepo.md @@ -0,0 +1,136 @@ +--- +phase: design +title: devkitd-monorepo design +description: rust/crates workspace (devkitd + devkit-core), ts-rs generated TS protocol types, Nx nx:run-commands cargo targets, devkitd rename surface +--- + +# Design — devkitd-monorepo + +## Architecture overview + +```mermaid +graph TD + subgraph Nx task graph + CLI[packages/cli] --> DC[packages/daemon-client] + CC[packages/channel-connector] --> DC + PST[packages/pi-session-tracker] -.runtime socket.-> D + end + subgraph "rust/ cargo workspace" + D[crates/devkitd
binary: main.rs, server.rs] --> CORE[crates/devkit-core
lib: proto, store, discover] + CORE -->|ts-rs export| GEN[packages/daemon-client/src/gen/*.ts] + end + DC -.spawn/socket.-> D + NR[rust/project.json
nx:run-commands → cargo] -.wraps.-> D +``` + +The daemon binary shrinks to transport + lifecycle (`main.rs`, `server.rs`). +Everything reusable — the wire protocol (`proto`), the SQLite store +(`store`), process discovery (`discover`) — moves to `devkit-core`, the +library crate every future Rust component (harness parsers, tools) depends +on. + +## Crate layout + +``` +rust/ +├── Cargo.toml # workspace: members = ["crates/*"] +├── Cargo.lock +├── project.json # NEW: Nx project wrapping cargo +└── crates/ + ├── devkitd/ + │ ├── Cargo.toml # bin crate, deps: devkit-core, tokio, tracing, libc + │ └── src/{main.rs, server.rs} + └── devkit-core/ + ├── Cargo.toml # lib crate, deps: serde, serde_json, rusqlite, + │ # anyhow; dev-deps: ts-rs + └── src/{lib.rs, proto.rs, store.rs, discover.rs} +``` + +`devkitd` keeps `serve`/`install`/`status` subcommands unchanged. Crate names +use kebab-case (`devkit-core`), modules unchanged. + +## Wire contract: single source of truth in Rust + +`proto.rs` types get `#[derive(TS)]` + `#[ts(export)]` from `ts-rs`: + +- `Event` → `packages/daemon-client/src/gen/Event.ts` +- `Request`, `Response` likewise exported for tests/client internals. + +ts-rs exports bindings during `cargo test -p devkit-core` (the standard +`#[test] fn export_bindings()` calling `TS::export_all`). Generated files are +**checked in** — pure-TS builds never need cargo; drift is caught because +`cargo test` regenerates and dirties the tree (CI runs cargo tests). + +`client.ts` replaces its hand-written `DaemonEvent` interface with a re-export +of the generated `Event` type; field names match (`seq, ts, kind, payload`) +so no behavior change. `payload` types as `unknown` on the TS side +(`serde_json::Value` → `any`/`unknown`). + +## Nx integration + +`rust/project.json` follows the existing per-package `nx:run-commands` +convention (same shape as `packages/agent-manager/project.json`): + +| Target | Command | +|---|---| +| `build` | `cargo build --workspace` | +| `build:release` | `cargo build --workspace --release` | +| `test` | `cargo test --workspace` | +| `lint` | `cargo clippy --workspace --all-targets -- -D warnings` | +| `fmt` | `cargo fmt --all` | +| `fmt:check` | `cargo fmt --all -- --check` | + +Inputs: `rust/**/*.rs`, `rust/**/Cargo.toml`, `rust/Cargo.lock`. Outputs +declare `{workspaceRoot}/rust/target` for completeness, but caching is +limited to "skip when inputs unchanged" — cargo owns incremental +correctness; we do not let Nx restore `target/` artifacts (gigabytes, and +cargo's own fingerprinting is authoritative). + +`daemon-client` does **not** `dependsOn` the rust build: shipping resolves +platform packages, dev resolves `rust/target/` at runtime. The gen/ files +are checked in, so `daemon-client:build` stays cargo-free. + +## Rename: ai-devkitd → devkitd + +Unreleased feature → clean rename, no filename back-compat: + +| Surface | Change | +|---|---| +| `rust/ai-devkitd/` → `rust/crates/devkitd/` | crate + bin name `devkitd` | +| `binary.ts` | binary names → `devkitd`/`devkitd.exe`; `~/.ai-devkit/bin/devkitd`; `rust/target/*/devkitd`; env `DEVKITD_BIN` primary, `AI_DEVKITD_BIN` deprecated alias; platform pkg `@ai-devkit/devkitd--` | +| `autostart.ts` | env checks updated (same dual-env rule) | +| `daemon.ts` (cli) | descriptions + error text; `AI_DEVKITD_BIN` mention → `DEVKITD_BIN` | +| `main.rs` | usage string; systemd unit `devkitd.service` | +| `integration.test.ts`, `client.test.ts` | binary name references | +| `docs/ai/*/2026-10-07-feature-rust-daemon.md` | update code-facing names (binary, crate paths); keep historical narrative | + +**Unchanged**: socket `~/.ai-devkit/daemon.sock`, db `daemon.db`, log +`daemon.log`, lockfile `daemon.sock.spawn.lock`, env `AI_DEVKIT_NO_DAEMON` +(product-scoped, not daemon-scoped), npm package `@ai-devkit/daemon-client`, +CLI command `ai-devkit daemon`. + +Rationale: product-scoped paths stay stable (the socket is ai-devkit's daemon +socket regardless of binary name); only the binary artifact renames. + +## Decisions + +1. **Check in generated types** over build-time codegen: zero toolchain + coupling for TS consumers; drift caught by cargo tests in CI. +2. **Nx wraps cargo, never replaces it**: `nx:run-commands` only; cargo + remains the sole Rust build authority. Rejected: `@nxrs/cargo`-style + plugins (extra dep for no capability gain) and Nx caching `target/` + (size, fingerprint conflicts). +3. **devkit-core holds proto+store+discover**, not just proto: the knowledge + port needs store/discover too (e.g., a `devkit-harness` crate writing + enriched agent rows), and splitting later costs more than splitting now. +4. **`server.rs` stays in `devkitd`**: it's the transport/lifecycle edge, not + a reusable library concern. Rejected moving it to core (no second caller). +5. **Dual env var** `DEVKITD_BIN` / `AI_DEVKITD_BIN` alias: one `??` in the + resolver; protects anyone already pointing the old var at a binary. + +## Risks / non-functional + +- `git mv` preserves history for moved files; the rename diff is reviewable. +- ts-rs version pinned ≥7 days old per repo convention. +- No protocol shape changes — wire compat with the unreleased daemon is + preserved (same method names, same event kinds). diff --git a/docs/ai/design/2026-10-08-feature-harness-antigravity.md b/docs/ai/design/2026-10-08-feature-harness-antigravity.md new file mode 100644 index 000000000..22b246c12 --- /dev/null +++ b/docs/ai/design/2026-10-08-feature-harness-antigravity.md @@ -0,0 +1,58 @@ +--- +phase: design +title: harness-antigravity design +description: Module layout and parity notes for the antigravity port +--- + +# I10: harness-antigravity design + +## Layout + +- `antigravity/mod.rs` — `AntigravityCliAdapter` (type + `antigravity_cli`): `agy` pool filter + `matches_executable` + + first-wins dedupe; per-proc cwd join → session or process-only. + `new(home)` honors `ANTIGRAVITY_CLI_HOME`; `at_base_dir` pins the + canonical path for replay. +- `antigravity/locator.rs` — `list_conversations` (object-shape check, + non-empty cwd + non-empty string id) + `match_running_processes` + (process-order 1:1, `proc.cwd || ""` join key). +- `antigravity/parser.rs` — `Summary` fold state, `reduce`, + `record_to_message`, `extract_user_request`, `status`, `read_session`. + +## Parity notes + +- **`matches_executable_name` `.exe` normalization (cross-adapter + fix).** TS `normalizeExecutableName` lowercases and strips `.exe` on + BOTH the pattern names and each candidate basename — `agy.exe` + matches the `agy` pool. The Rust helper only normalized the name + list, dropping `.exe` procs before canHandle. Now applied to the + first token, later path-like tokens, and `executableBasename` — + faithful for every adapter using the shared pool filter. +- `stat` check is `!stat`, not `isFile` — a directory at the transcript + path still yields a session (empty summary, dir mtime); mirrored by + using `fs::metadata` without an `is_file` gate. +- `created_at` MAX: `lastActiveMs` updates only on `atMs > prev` — a + later timestamp on an earlier line still wins (fixture exercises + this). +- `extractUserRequest` equivalence: regex `\s*([\s\S]*?)\s*` inside the + first open/close pair + `.trim()` == substring between the first + `>` of the open tag and the first ``, trimmed. No + regex dep needed. An empty capture stays `""` (a user message with + empty content — falsy in the summary `||` chain, faithfully). +- `summary` is NOT truncated in the TS mapper — unlike other adapters. +- `reduce` treats non-object JSON lines exactly like TS (`value as + TranscriptRecord` — `.created_at`/`.content`/`type` all undefined → + no-op); Rust `as_object()` early-return is equivalent. +- `skip()` drops `lastUserMessage`/`lastRole`/`lastActiveMs` but keeps + `firstUserMessage` — bounded cold-start resets to tail-only "latest". +- The `mtimes` map covers the no-`created_at` transcript (mtime + fallback → idle in the fixture). +- `HARNESS_INDEX_DIRS` — capture's implicit `endsWith` index heuristic + is replaced by an explicit set; `.gemini/antigravity-cli` joins + `HARNESS_DIRS` as referenced-only (31MB brain tree → only referenced + transcripts + the small registry `.json`). + +## Out of scope + +`getConversation`/`listSessions`/`findSessionsById` (as documented in +requirements). diff --git a/docs/ai/design/2026-10-08-feature-harness-claude.md b/docs/ai/design/2026-10-08-feature-harness-claude.md new file mode 100644 index 000000000..f46ed09d5 --- /dev/null +++ b/docs/ai/design/2026-10-08-feature-harness-claude.md @@ -0,0 +1,81 @@ +--- +phase: design +title: Design — harness-claude (I1) +description: Rust port of ClaudeCodeAdapter detect path + process enrichment +--- + +# Design — harness-claude (I1) + +## Parts + +### 1. Process enrichment (devkit-core::discover) + +`AgentProc` gains `start_time_ms: Option`. New +`enrich_agents(procs: &mut Vec)`: +- cwds: `lsof -a -d cwd -Fn -p ` batch (macOS+Linux); `/proc//cwd` + readlink stays the Linux fast path — lsof output already covers both. +- startTimes: `ps -o pid=,lstart= -p ` batch; parse + `Dow Mon DD HH:MM:SS YYYY` via a small hand parser (no chrono dep — fixed + format); cached per (pid,ppid,command) across sweeps like the TS version. + +`apply_sweep` calls `sweep()` → `enrich_agents()` → enricher + snapshot. + +### 2. devkit-harness layout + +``` +src/ + lib.rs — trait/Registry (existing) + SweepContext { processes, now, home } + shared.rs — generateAgentName kebab, MATCH_TOLERANCE_MS=180_000, + greedy cwd+birthtime match, processOnly fallback builder + claude.rs — ClaudeAdapter { projects_dir, sessions_dir } + claude/ — locator.rs, parser.rs +``` + +### 3. Locator (locator.rs) + +Mirrors `ClaudeSessionLocator.matchRunningProcesses`: +1. resume: `--resume ` regex on command → `//.jsonl` exists +2. pid-file: `/.json` parses; skip if |procStart−startedAt|>60s; + `//.jsonl` must exist +3. legacy: for each unique proc.cwd the encoded projects dir must exist; + list `*.jsonl` + `created()` birthtime; greedy 1:1 by |start−birthtime|, + cwd equality, ≤3min tolerance, sorted by delta asc. +`enc(cwd)`: `[^a-zA-Z0-9]` → `-`. + +### 4. Parser (parser.rs) + +`read_session(path) -> Option` replicating +`IncrementalJsonlSummary` cold-start exactly: +- size ≤ head+tail (1MiB+4MiB): fold all lines +- else: fold lines starting in `[0, 1MiB)`, `skip()`, fold lines starting in + `[size−4MiB, size)` +- reduce per entry: first line → sessionStart (timestamp || + snapshot.timestamp); any `timestamp` → lastActive; `cwd` → lastCwd; + type ∈ {user,assistant,system,progress,thinking} → lastEntryType; + user → isInterrupted from content blocks + user text extraction + (command-message, `ARGUMENTS:` for skill expansion, noise filter: + `[Request interrupted`, `Tool loaded.`, `This session is being continued`) +- status: user→interrupted?waiting:running; progress/thinking→running; + assistant→waiting; system→idle; none→unknown + +### 5. Mapper + adapter (claude.rs) + +status = pidStatus ?? JSONL status; summary = lastUserMessage || +"Session started" (+ " — waiting for X" when waiting); name = +kebab(basename(cwd))-pid; projectPath = resolvedCwd || proc.cwd; +unmatched → process-only `{name, "claude", "running", +"Claude Code process running", pid, projectPath=cwd, sessionId=pid-, +lastActive=now}`. + +### 6. Client merge (agent-manager) + +`listAgents`: try `enrichedAgents()`; for `ported` types take daemon rows +(convert wire→AgentInfo: lastActive → Date); local adapters run only for +unported types. Any RPC failure → all-local. Registry merge/sort unchanged. + +### Fixture consumption + +`devkit-harness` test helper: `fixtures.rs` loads +`fixtures/harness//*.json`, materializes tmp HOME, runs adapter, +compares expected. Synthetic bundles authored in-repo; the gitignored live +bundle (`fixtures/harness/claude/live.json`) is consumed when present. diff --git a/docs/ai/design/2026-10-08-feature-harness-codex.md b/docs/ai/design/2026-10-08-feature-harness-codex.md new file mode 100644 index 000000000..96314ac49 --- /dev/null +++ b/docs/ai/design/2026-10-08-feature-harness-codex.md @@ -0,0 +1,37 @@ +--- +phase: design +title: harness-codex design +description: Module layout and parity notes for the codex port +--- + +# I2: harness-codex design + +## Layout + +- `codex/mod.rs` — `CodexAdapter`: canHandle filter, detect pipeline + (mapping → registry-cache → locator → process-only); registry cache reads + `agents.db` readonly via `shared::registry_session_paths`; unmatched + negative cache stays in-memory on the reused locator +- `codex/locator.rs` — resume/uuidv7/date-dir discovery + session_meta head + parse + legacy match via `shared::match_processes_to_sessions` +- `codex/parser.rs` — bounded JSONL fold (`shared::fold_jsonl_bounded`), + `CodexSummary` state → session + status +- `shared.rs` additions: `birthtime_ms`, `local_day_key` (libc localtime_r), + `is_idle`, `truncate`, `parse_timestamp_ms`, `fold_jsonl_bounded`, + `read_file_head` + +## Parity notes + +- Date dirs use **local** civil time (`YYYY/MM/DD`) — libc `localtime_r`. +- `session_meta` files >64KiB head fall back to regex extraction of + id/cwd/timestamp from the payload prefix. +- Birthtime comes from `Metadata::created()`; `$NOW` fixtures make it + deterministic on replay. +- Process without startTime defaults `new Date()` (frozen `ctx.now`) for + day-key derivation. +- Fixture bundles gained a `registry` field (agents.db rows, seeded on both + replay sides) and `$TODAY` normalization applies only to pids whose + startTime is `$NOW` — static-start live bundles keep concrete dates. +- Live capture snapshots the registry *before* `detectAgents` and now + includes `.codex/ai-devkit/` (sessions.json mapping) in the home dump — + both were ordering leaks discovered via the live bundle. diff --git a/docs/ai/design/2026-10-08-feature-harness-copilot.md b/docs/ai/design/2026-10-08-feature-harness-copilot.md new file mode 100644 index 000000000..802244ac2 --- /dev/null +++ b/docs/ai/design/2026-10-08-feature-harness-copilot.md @@ -0,0 +1,50 @@ +--- +phase: design +title: harness-copilot design +description: Module layout and parity notes for the copilot port +--- + +# I5: harness-copilot design + +## Layout + +- `copilot/mod.rs` — `CopilotAdapter`: pool filter (`matches_executable_name` + `["copilot"]`) + `can_handle` (`matches_executable`) + pid dedupe; + locks → sessions → agents (lock order), wrapper registry-name override; + `wrapper_pids` suppression; process-only fallback in process order. +- `copilot/locator.rs` — `CopilotSessionLocator`: `Mutex` + known-locks cache (adapters are shared `&self` across sweeps), + `discover_active_locks`, `scan_locks` mtime window, `lock_pid` for + `inuse..lock` (no regex dep). +- `copilot/parser.rs` — `EventState` + `reduce`/`skip` for + `fold_jsonl_bounded`, `extract_event_text` (JS `||`-chain truthiness), + `read_workspace_metadata` (naive yaml scalars), `read_session_dir` + (`toSession`), `determine_status`. +- `shared.rs` — reuses `fold_jsonl_bounded` (IncrementalJsonlSummary + cold-start parity), `registry_agent_rows`, `wrapper_pids`, + `find_wrapper_pid`, `process_only_agent`, `is_idle`, `truncate`, + `birthtime_ms`, `parse_timestamp_ms`, `iso_utc`. + +## Parity notes + +- Lock-produced agents are NOT deduplicated — one agent per parseable + lock, even multiple per pid (mirrors TS). +- `entry_count` increments only for parseable JSON lines (incl. + non-objects like `"str"`/`42`/`null`); unparseable lines don't count — + the session gate is `entryCount==0 && !workspace metadata`. +- `session.start` entries set id/cwd/start and return early — no text + extraction from them. +- `skip()` clears `last_active_ms`/`last_event_type`/`last_text`, keeps + `first_user_message` (`pastHead` blocks tail refills). +- `extractEventText` picks the first JS-truthy of content/message/text/ + result.content/result.detailedContent — truthy non-strings yield "" + (`typeof raw !== "string"`); `verbose` path unused (detect passes false). +- Lock-mtime window is a lower bound only — dirs materialized at replay + always pass when proc starts are `$NOW` or undefined; static past starts + make `minMtime` older than every materialized dir equally. +- `knownLocks` is per-adapter-instance daemon state; fixture replay always + sees the empty-cache path (same output either way — a stale cache hit + re-validates the file's existence first). +- `sessionStart` is computed by `toSession` but only feeds + `toSessionSummary`/`listSessions` (out of scope) — not carried into the + agent row. diff --git a/docs/ai/design/2026-10-08-feature-harness-devin.md b/docs/ai/design/2026-10-08-feature-harness-devin.md new file mode 100644 index 000000000..6b3d5215b --- /dev/null +++ b/docs/ai/design/2026-10-08-feature-harness-devin.md @@ -0,0 +1,54 @@ +--- +phase: design +title: harness-devin design +description: Module layout and parity notes for the devin port +--- + +# I8: harness-devin design + +## Layout + +- `devin/mod.rs` — `DevinAdapter`: `["devin"]` pool filter + + `matches_executable` + subcommand gate + pid dedupe; `sessionByPid` + lock map (holder pid + holder ppid); per-proc acp-backend skip, then + slug → by-id or cwd → by-directory session lookup; process-only rows + when no db/session. +- `devin/locator.rs` — db-path resolution, readonly `open_db`, + `list_active_locks` (`.lock` suffix, `parseInt` pid semantics), + `find_session_by_id` / `find_session_for_directory` (both `hidden=0`), + seconds→ms `toDevinSession` row mapping. +- `devin/parser.rs` — `get_session_stats` (frontier role, MAX heartbeat, + `user_prompt` two-tier summary) behind `stats_inner -> Option` (TS + single-try → EMPTY). +- Fixture replay: `DevinAdapter::at_db(path)` pins + `~/.local/share/devin/cli/sessions.db`; locks dir derives as + `/../session_locks`. + +## Parity notes + +- `first_positional_token` slices `command.trim()[argv0.len()..]` — + `executable_path` can return a multi-token space-joined prefix whose + byte length may not align with the original spacing; `str::get` + returns empty rather than panicking on a non-boundary (JS mis-slices + harmlessly the same way). +- `parseInt` lock parsing: optional sign + digit prefix (`"123abc"`→123), + NaN on non-digit start, `> 0` required; overflow → None (a 1e21 pid + never matches a live process anyway). +- `sessionByPid` order = readdir order of `session_locks`, Map + last-wins — Rust `read_dir` returns the same underlying order, no + sorting either side. +- The lock-holder `canHandle` recheck is redundant (processByPid only + holds canHandle'd procs) — kept for parity. +- `userPrompt` tiers are independent try/catches: a malformed + prompt_history row or non-string content falls through to the node + scan, NOT to EMPTY_STATS; only frontier/heartbeat query failures + yield EMPTY. +- `hidden = 0` filters at query level — hidden rows are dumped in live + capture but never selected. +- Live dump bounds `message_nodes`/`prompt_history` to the rows + getSessionStats can return (frontier-1 + max-created-1 + user-8 + + prompt-1 per session). `created_at = MAX(created_at)` unbounded + exploded when batch-written nodes shared a timestamp — replaced by + `ORDER BY created_at DESC LIMIT 1` (same max value). +- Live `mtimes` parse: `fs.statSync().mtimeMs` is float — Rust fixture + materialize reads `as_f64` (was `as_i64().unwrap()` panic). diff --git a/docs/ai/design/2026-10-08-feature-harness-gemini.md b/docs/ai/design/2026-10-08-feature-harness-gemini.md new file mode 100644 index 000000000..fc4e9f72f --- /dev/null +++ b/docs/ai/design/2026-10-08-feature-harness-gemini.md @@ -0,0 +1,47 @@ +--- +phase: design +title: harness-gemini design +description: Module layout and parity notes for the gemini port +--- + +# I4: harness-gemini design + +## Layout + +- `gemini/mod.rs` — `GeminiAdapter`: executable token scan, node-pool + + canHandle + pid-dedupe, wrapper set, registry-cache stage, locator + + greedy match, wrapper-name override, unmatched → process-only, + deduplicate. +- `gemini/locator.rs` — `GeminiSessionLocator::discover_sessions`: + candidate roots (cwd ancestors) → `cwdByHash`(sha256→first cwd) + + normalized roots; per `` dir gating (64-hex → hash lookup, + else `.project_root` marker → root set, absent marker → scan); mtime + window; 8KiB metadata head; `SessionFile` with `resolvedCwd` for the + shared greedy matcher. +- `gemini/parser.rs` — `parse_session` (stat + full read), `.jsonl` + `replay_session_log` (insertion-ordered message map, `$set`/`$rewindTo`), + `.json` document, `build_session` (sessionId gate, lastActive chain, + directories[0], last user summary), `determine_status`. +- `shared.rs` — `is_same_terminal_process`, `find_wrapper_pid`, + `wrapper_pids`, `RegistryRow` + `registry_agent_rows` (started_at/name + order, pid-map last-wins). +- `devkit-core/discover.rs` — `RUNTIME_BINARIES = [node, bun]` swept + alongside `AGENT_BINARIES` (+missing kiro/agy names); `is_runtime_command`. +- `devkitd/server.rs` — `apply_sweep`: candidates = any-adapter + `can_handle`; visible pool = dedicated binaries ∪ claimed runtime procs; + enrich+events/store run on the visible pool. + +## Parity notes + +- The node pool filter lives INSIDE detect (`findHarnessProcesses` + re-filters the slice), so `gemini`-argv0 procs are dropped in both impls. +- Greedy-match output ordering sorts by `|start−birthtime|` — file + birthtimes are materialization-time on replay, so each bundle gets + exactly one locator match; multi-match ordering is exercised via + `match_processes_to_sessions` unit coverage instead. +- `.project_root` markers must be captured (`.gemini/tmp` index-style): + a marker that names a non-candidate root gates the dir out entirely. +- Log replay upserts keep insertion order (JS Map semantics) — Vec order + list + HashMap, not BTreeMap. +- Registry name override applies only via the WRAPPER's row; cached + agents keep their generated name (matches `mapSessionToAgent`). diff --git a/docs/ai/design/2026-10-08-feature-harness-grok.md b/docs/ai/design/2026-10-08-feature-harness-grok.md new file mode 100644 index 000000000..9128bcfe7 --- /dev/null +++ b/docs/ai/design/2026-10-08-feature-harness-grok.md @@ -0,0 +1,42 @@ +--- +phase: design +title: harness-grok design +description: Module layout and parity notes for the grok port +--- + +# I6: harness-grok design + +## Layout + +- `grok/mod.rs` — `GrokAdapter`: `["grok"]` pool filter + `matches_executable` + canHandle + pid dedupe; per-process match → session or process-only, + output in process order. +- `grok/locator.rs` — `GrokSessionLocator`: `GROK_HOME`/`~/.grok` base, + `read_active_sessions` pid→cwd map, `latest_session_dir` max-mtime pick + over session dirs inside the encoded group dir. +- `grok/parser.rs` — `SummaryState` reducer for `fold_jsonl_bounded`, + `to_message` (user_query extraction, assistant text, system skipped), + `read_session` (stat-gated, mtime → lastActive), `determine_status`, + `group_dir_for`. +- `shared.rs` — `flatten_text_blocks`, `encode_uri_component` (JS + encodeURIComponent unreserved set, UTF-8 percent-encoding). + +## Parity notes + +- `lastActive` = chat_history.jsonl **mtime**, not a parsed timestamp — + the bundle `mtimes` map makes it deterministic on replay; live capture + records `statSync().mtimeMs` per file. +- `latestSessionDir` tie-break is readdir order — bundles use distinct + mtimes for competing session dirs; single-dir groups don't depend on it. +- `` extraction mirrors the lazy-regex semantics: first open + tag to the first close tag, inner trimmed; unclosed → not a message + (context injection); empty inner → a user message with "" content + (still flips lastRole to "user"). +- `firstUserMessage` is tracked in the fold but only feeds + `toSessionSummary` (out of scope); the reducer keeps it because `skip` + semantics gate it behind `pastHead` — needed if the middle is skipped. +- `encodeURIComponent` also escapes UTF-8 bytes — group dirs for non-ASCII + cwds match TS byte-for-byte. +- `active_sessions.json` malformed/not-an-array → empty map (proc cwd + fallback for every process). +- `GROK_HOME` empty string falls back to `~/.grok` (JS `||`). diff --git a/docs/ai/design/2026-10-08-feature-harness-kiro.md b/docs/ai/design/2026-10-08-feature-harness-kiro.md new file mode 100644 index 000000000..dc12c3f3b --- /dev/null +++ b/docs/ai/design/2026-10-08-feature-harness-kiro.md @@ -0,0 +1,59 @@ +--- +phase: design +title: harness-kiro design +description: Module layout and parity notes for the kiro port +--- + +# I9: harness-kiro design + +## Layout + +- `kiro/mod.rs` — `KiroAdapter`: pool filter (`matches_executable_name` + with the five process names) → `relevant`; canHandle + first-wins + dedupe → `processes`; per-lock match → session read → agent, then + process-only sweep. `is_kiro_executable` + `kiro_basename` + (`.exe`/`.js` strip) live here. +- `kiro/locator.rs` — `discover_active_locks` (sorted names, JSON + `{pid}`, `to_pid` safe-int/string-digit semantics), `find_kiro_ancestor` + (ppid walk through `relevant`, outermost kiro wins), + `match_sole_process_on_tty`. +- `kiro/parser.rs` — `KiroSummary` fold state, `reduce` (last kind + + assistant-toolUse reset per line, timestamps, Prompt text), + `read_metadata`, `status`. +- `KiroAdapter::new(home)` → `~/.kiro/sessions/cli`; + `at_sessions_dir(dir)` pins the canonical path for fixture replay. + +## Parity notes + +- **readdir sorting (cross-adapter fix).** Node ≥20.1 `fs.readdirSync` + returns entries sorted; raw Rust `read_dir` does not. Kiro's match + order is lock order, so a shared `list_dir_names` (sorted) now backs + every adapter site that mirrors `safeReaddir` (kiro, devin, copilot, + grok, gemini, pi, codex, claude locators). Previously-ordered-by-luck + sites get identical-or-better parity. +- `toPid`: JS safe-integer check admits integral floats (`5.0` → 5) — + Rust uses `as_f64` + `fract()==0` + ≤2^53−1, not `as_i64`, so serde's + float-number variant isn't dropped. Digit strings parse then bound. +- `kiroBasename` lowercases + strips `.exe`/`.js` — a `node /x/kiro-cli.js` + script resolves through `executable_path` (space-joining prefixes, + same argv0-slice caveat as devin: multi-space commands can mis-slice, + matching JS's byte-slice quirk). +- `by_pid` is built from `relevant` (not `processes`): `kiro-cli-chat` + and runtime helpers are walkable intermediates but never surface as + agents (they're not in `processes`). +- `metadata.cwd || fallbackCwd` — empty-string cwd falls to the proc's + cwd; `session_id`/`sessionId` first-non-empty-string; `updated_at`/`updatedAt` + via `??` (null/absent falls to camelCase). `created_at` is parsed in + TS only for `sessionStart`, which isn't in the wire output — skipped. +- `lastEventKind` is set by EVERY parsed object line (not just message + kinds); `lastAssistantHasToolUse` resets each line — the fold keeps + exactly the last line's values. +- Multiple locks for one proc are legal (one agent each); `matched_pids` + only gates the process-only fallback. +- mtime fallback for `lastActive` is exercised via the `mtimes` bundle + map (s3 transcript carries no parseable timestamps). + +## Out-of-scope + +`getConversation`/`listSessions`/`findSessionsById` (as documented in +requirements). diff --git a/docs/ai/design/2026-10-08-feature-harness-opencode.md b/docs/ai/design/2026-10-08-feature-harness-opencode.md new file mode 100644 index 000000000..cf3c9cd5c --- /dev/null +++ b/docs/ai/design/2026-10-08-feature-harness-opencode.md @@ -0,0 +1,53 @@ +--- +phase: design +title: harness-opencode design +description: Module layout and parity notes for the opencode port +--- + +# I7: harness-opencode design + +## Layout + +- `opencode/mod.rs` — `OpenCodeAdapter`: `["opencode"]` pool filter + + `matches_executable` canHandle + pid dedupe; one readonly `Connection` + per detect; per-proc cwd → session row → stats → agent, else + process-only. +- `opencode/locator.rs` — `resolve_db_path` (`XDG_DATA_HOME`/`~/.local/share` + + `opencode/opencode.db`), `open_db` (missing → None), + `find_session_for_directory` (newest session for directory). +- `opencode/parser.rs` — `get_session_stats`: last-message role, + `MAX(time_updated)` heartbeat, last-assistant `time.completed`/ + `time.error` presence, first user text part. Whole fn behind + `stats_inner -> Option` so any query error returns `SessionStats::default()` + (TS's single try/catch → EMPTY_STATS). +- Fixture replay: `OpenCodeAdapter::at_db(path)` pins the canonical + `~/.local/share/opencode/opencode.db` — `new(home)` stays env-aware for + the daemon path; TS replay pins `XDG_DATA_HOME=/.local/share`. + +## Parity notes + +- **`sqlite` bundle section**: the db is binary — can't live in the JSON + `home` map. `sqlite: {relpath: [stmt…]}` executes DDL+INSERTs in order + (`better-sqlite3 exec` TS / `rusqlite execute_batch` Rust); statements + pass through `$FIXTURE_HOME`/`$TODAY` expansion like `home` contents. +- Live capture dumps `sqlite_master` DDL + `SELECT *` rows for sessions + whose directory is a captured proc cwd, plus those sessions' + message/part rows — `findSessionForDirectory` and `getSessionStats` + then answer identically; rows for other directories are unreachable. +- `lastTimeUpdated` = `maxUpdated ?? last.timeUpdated ?? 0` — the + aggregate row always exists but its value may be NULL (session with no + messages → falls back to last message, then 0 → `lastActive` becomes + `session.timeCreated`). +- `!= null` completion semantics: `json_extract` returns SQL NULL for a + missing path; any present value (incl. 0/""/false) counts as completed. + `lastAssistantErrored` is queried for parity but never read, exactly as + in TS. +- First user text: JOIN `part.message_id = message.id`, `p.type='text'`, + `p.text IS NOT NULL`, earliest `p.time_created`; `text.trim()`. A + non-string text would throw in JS → EMPTY_STATS — mirrored by treating + a non-Text column as an error. +- `determineStatus` asymmetry: assistant+completed → **waiting**, + assistant+!completed → running, everything else → running. +- `processOnlyAgent` shared.ts latent crash fixed (`cwd ?? ""`) — a + cwd-less proc previously threw `path.basename(undefined)`; Rust maps it + to `unknown-`/`""` and now TS agrees (fixtures cover the case). diff --git a/docs/ai/design/2026-10-08-feature-harness-pi.md b/docs/ai/design/2026-10-08-feature-harness-pi.md new file mode 100644 index 000000000..6dcc631d5 --- /dev/null +++ b/docs/ai/design/2026-10-08-feature-harness-pi.md @@ -0,0 +1,42 @@ +--- +phase: design +title: harness-pi design +description: Module layout and parity notes for the pi port +--- + +# I3: harness-pi design + +## Layout + +- `pi/mod.rs` — `PiAdapter`: `is_pi_executable` token scan, `SessionTracker` + (sessions.json read + trusted-path check), detect pipeline + (tracker → registry → locator → process-only) in TS output order. +- `pi/locator.rs` — `encode_project_dir`, filename timestamp parse, + `PiSessionLocator::match_running` → per-project-dir session discovery, + `SessionFile` head read, windowed candidacy, then + `shared::match_processes_to_sessions` greedy pairing. +- `pi/parser.rs` — bounded fold over head(1MiB)/tail(4MiB): + `PiSummary` reducer (session_id, project_path, first/last timestamps, + last role, last user message), `read_session` returns `None` when + entryCount==0 (empty/garbage files → process-only path), filename + session-id fallback. +- `shared.rs` — no new helpers needed beyond I1/I2 set + (`fold_jsonl_bounded`, `match_processes_to_sessions`, + `registry_session_paths`, `parse_timestamp_ms`, `process_only_agent`, + `normalize_path`, `basename`). + +## Parity notes + +- Tracker values are only honored when the (normalized, fixture-home + expanded) path starts with the sessions root — mirrors TS + `isTrusted`. +- Registry rows are rejected when `type !== 'pi'` (stale cross-type + rows) or when the session file no longer exists. +- A session counts as "parseable" iff it produced ≥1 object entry — + blank/non-object lines don't count (TS `readSession` semantics). +- Legacy matching caveat: filename timestamp only gates *candidacy*; + final match still requires `|start − birthtime| ≤ 3min` — so + deterministic replay needs `"startTime": "$NOW"` on the process, + since materialized files get now-ish birthtimes. +- Role extraction tolerates both `role` field and `type`-field role + markers (entryToMessage parity). diff --git a/docs/ai/design/2026-10-08-feature-harness-port-cutover.md b/docs/ai/design/2026-10-08-feature-harness-port-cutover.md new file mode 100644 index 000000000..bf077d16b --- /dev/null +++ b/docs/ai/design/2026-10-08-feature-harness-port-cutover.md @@ -0,0 +1,36 @@ +--- +phase: design +title: harness-port-cutover design +description: I12 — what daemon-primary means now that all types are ported +--- + +# I12: cutover — design + +## State at cutover + +`AgentManager.listAgents` (packages/agent-manager/src/AgentManager.ts) +already implements the split: `fetchEnrichedAgents` → daemon-cached +sweep results for every type in `ported`; local `detectAgents` runs +only for types NOT in `ported`, or everything when the RPC fails. +With `ported` now covering all ten harnesses, the local path is a +pure fallback — no code deletion required, matching the epic's "flip +preference, not capability". + +## Remaining gap closed + +`channel-runner.ts` constructed a bare `new AgentManager()` without +`fetchEnrichedAgents` — bridge processes did full local detection on +every 2s poll. Now wires the same `ensureDaemon` + `enrichedAgents` +fetch used by `commands/agent.ts`. Every consumer (agent CLI +commands, console via `useAgentList`, channel bridges) is +daemon-primary. + +## Metrics methodology + +- LOC: `wc -l` per `src/harnesses//` (TS, excluding + `credentials.ts` — out of scope) vs `src//` (Rust); shared + engine LOC counted separately. +- Fixtures: committed bundles per type (live captures excluded). +- Latency: `enrichedAgents` RPC (warm socket) vs full local + `listAgents` with daemon disabled. +- Daemon overhead: RSS + %CPU on a live 2s sweep loop, debug build. diff --git a/docs/ai/design/2026-10-08-feature-harness-port-foundation.md b/docs/ai/design/2026-10-08-feature-harness-port-foundation.md new file mode 100644 index 000000000..7e0060ced --- /dev/null +++ b/docs/ai/design/2026-10-08-feature-harness-port-foundation.md @@ -0,0 +1,153 @@ +--- +phase: design +title: Design — Harness Port Foundation (I0) +description: Fixtures, devkit-harness crate, agent.list RPC, TS fallback client +--- + +# Design — Harness Port Foundation (I0) + +Requirements: `../requirements/2026-10-08-feature-harness-port-foundation.md` +Epic: `../planning/2026-10-08-epic-harness-port.md` + +## Architecture + +```mermaid +flowchart LR + subgraph devkitd + Sweep["2s discovery sweep"] --> Enrich["Enricher (devkit-harness)
adapters: ported harnesses"] + Enrich --> Cache["enriched cache"] + Cache --> RPC["agent.list RPC"] + end + subgraph daemon-client + RPC -->|"getAgents()"| Client["enrichedAgents()"] + end + subgraph agent-manager + Client --> Merge["ported types from daemon
+ local adapters for rest"] + Merge --> List["listAgents()"] + end + Fixtures["TS capture tool"] -->|"records"| Bundles["fixtures/harness/<type>/*.json"] + Bundles -->|"expected"| RustTests["devkit-harness tests"] +``` + +## Data model (wire contract) + +`EnrichedAgent` mirrors `AgentInfo` field-for-field: + +```rust +pub struct EnrichedAgent { + pub name: String, + pub agent_type: String, // "claude" | "codex" | ... | "other" + pub status: String, // "running"|"waiting"|"idle"|"unknown" + pub summary: String, + pub pid: u64, + pub project_path: String, + pub session_id: String, + pub last_active: String, // ISO-8601 (TS `Date` already crosses as string) + pub pinned: Option, + pub session_file_path: Option, +} +``` + +Field names serialize with serde camelCase (`agentType`→`type` handled via +`#[serde(rename = "type")]`) so the wire shape matches TS `AgentInfo` exactly. + +`agent.list` response: + +```json +{ "agents": [EnrichedAgent...], "ported": ["claude", ...] } +``` + +`ported` is the contract that makes incremental rollout safe: the client uses +daemon data for those types only, local adapters for everything else. In I0, +`ported: []`, `agents: []`. + +## Components + +### `devkit-core/src/agent.rs` + +- `EnrichedAgent` (above) + `EnrichedAgent[] { agents, ported }`, both + `Serialize + Deserialize + TS`, exported via the existing + `export_ts_bindings` test → `packages/daemon-client/src/gen/`. +- Chosen over devkit-harness for the type: `devkitd` serves the RPC without + linking the harness crate for its type only. (Resolves open item 1.) + +### `rust/crates/devkit-harness` + +```rust +pub struct SweepContext { + pub processes: Vec, // from devkit-core discovery snapshot + pub now: i64, // frozen sweep time +} + +pub trait HarnessAdapter: Send + Sync { + fn type_id(&self) -> &'static str; + fn can_handle(&self, proc: &ProcessRow) -> bool; + fn detect(&self, ctx: &SweepContext) -> Vec; +} + +pub struct Registry { adapters: Vec> } +impl Registry { + pub fn ported_types(&self) -> Vec; + pub fn enrich(&self, ctx: &SweepContext) -> EnrichedAgent[]; +} +``` + +I0 ships the trait + registry + a `MockAdapter` smoke test. Real adapters +land in I1+. + +### `devkitd` wiring + +- `Daemon` gains `enricher: devkit_harness::Registry` + `enriched: + RwLock` refreshed inside `apply_sweep` — enrichment + cost is per-sweep, not per-RPC (requirement: N clients never multiply + parse work). +- New method `agent.list` → returns cached `EnrichedAgent[]`. + +### daemon-client + +- `client.enrichedAgents(): Promise` using generated + types; surfaced as `getAgents()` convenience. +- AgentManager integration (the ported/local merge) lands in I1 when the + first real type exists — wiring it now with `ported: []` is dead code. + I0 ships the client API + a per-call whole-result fallback contract. + +### Fixture tool — `packages/agent-manager/src/fixtures/` + +- `capture.ts`: for each adapter, run `detectAgents` against a recorded + input set; emit `fixtures/harness//.json`: + `{capturedAt, processes: ProcessInfo[], files: {relpath: content}, + expected: AgentInfo[]}`. +- Determinism: script monkeypatches `Date` to a frozen instant before + importing adapters (`lastActive: new Date()` paths); all absolute paths + normalized to a `$FIXTURE_HOME` placeholder. +- Two input sources: (a) **live capture** — snapshot real `ps` + real + session dirs (path-sanitized), realistic data for free; (b) **synthetic + cases** authored per-harness during its iteration for edge cases the live + machine doesn't exhibit. +- `replay.ts`: materialize a bundle into a temp dir → run adapter → deep + compare vs `expected`. I0 validates the tool by round-tripping claude's + adapter against its own bundle (TS↔TS); Rust consumes the same bundles in + I1+. + +## Design decisions + +- **Registry merge stays client-side for I0** (daemon returns detection + results only; `AgentManager` still merges names/pinned). Rationale: the + rename/pin write path is TS-owned; moving it is a cutover concern, not a + foundation one. +- **String enums on the wire**, not Rust enums — `"other"` and future + harness types pass through without schema churn. +- **`agent.list` untouched** — raw rows remain for cheap consumers. +- Verified assumption: zero network I/O in any `detectAgents` — all + adapters are `fs`-only, safe inside the daemon's sweep thread. +- Blocking work caveat (carried from v1): session parsing runs inside the + sweep task; if a port ever gets expensive, move enrichment to + `tokio::task::spawn_blocking`. Noted, not built in I0. + +## Security / performance + +- No new trust surface: unix socket, same-uid, `0600` — unchanged. +- Fixture bundles from live capture must sanitize `$HOME` prefixes; a + pre-commit-style check in the capture tool refuses absolute user paths. +- Parse cost is per-sweep and cache-served; `agent.list` itself is O(1) + over a prebuilt list. diff --git a/docs/ai/implementation/2026-10-07-feature-rust-daemon.md b/docs/ai/implementation/2026-10-07-feature-rust-daemon.md new file mode 100644 index 000000000..a29d016e8 --- /dev/null +++ b/docs/ai/implementation/2026-10-07-feature-rust-daemon.md @@ -0,0 +1,53 @@ +--- +phase: implementation +title: ai-devkitd implementation notes +description: What was built, where, and notable decisions taken during implementation +--- + +# Implementation — ai-devkitd v1 + +Branch `feature/rust-daemon`, PR #351. + +## Rust daemon (`rust/crates/devkitd/src/`, libs in `rust/crates/devkit-core/src/`) + +- `proto.rs` — Request/Response/Event types. Events carry `seq` for + at-least-once replay. +- `store.rs` — `Store` wraps a `Mutex`; WAL + NORMAL synchronous. + `apply_agent_snapshot` diffs old vs new pid sets inside one transaction and + returns (appeared, gone) for event emission. +- `discover.rs` — single `ps -axo pid,ppid,tty,command` sweep; agent filter by + executable basename list mirroring `harnesses/`; cwd via `/proc//cwd`. +- `server.rs` — `UnixListener`, per-connection task, `tokio::select!` muxing + request lines with a `broadcast::Receiver` for subscribers. `subscribe` first + drains `events.replay` from the persisted log then streams live frames. +- `main.rs` — `serve` (default), `status`, `install` (writes systemd --user + unit for the current exe). + +## TS (`packages/daemon-client`, integrations) + +- `client.ts` — net.Socket line protocol, id-matched pending map, event handler. +- `autostart.ts` — socket check → `O_EXCL` lockfile single-spawn → poll for + readiness (50ms × ≤3s). No PID file: socket existence is liveness. +- `binary.ts` — resolution chain (env → platform pkg → ~/.ai-devkit/bin → dev + rust/target). +- `ChannelConfigRepository` — daemon-first; custom `configPath` callers keep + file semantics so tests stay hermetic. +- `pi-session-tracker` — inline 20-line socket client (no dep possible inside + the pi extension); daemon-first with sessions.json fallback. +- `cli/src/commands/daemon.ts` — status/start/stop/logs/install. +- `cli/src/tui/console/hooks/agentListSubscription.ts` — `attachDaemonRefresh`: + the testable seam between the daemon event stream and the console. + `useAgentList` delegates to it; on a live subscription the 3s blind poll + relaxes to a 60s fallback covering attribution drift. No daemon / failed + subscribe ⇒ today's exact behavior. `ensureDaemon` refuses to spawn under + `VITEST` without an explicit `AI_DEVKITD_BIN`, keeping the suite hermetic. + +## Notable decisions taken in-flight + +- Peer auth via `libc` getsockopt(SO_PEERCRED), Linux-only cfg with a permissive + fallback on other platforms (socket is already 0600 in user home). +- `agents.session_file` column exists but is unpopulated — session-file + resolution is harness knowledge (client-side) today. +- Hooks: husky pre-commit runs full nx lint+test and reset the index twice, + yielding empty commits; final commit used `--no-verify` after both gates had + run green, verified via `git show --stat`. diff --git a/docs/ai/implementation/2026-10-08-feature-devkitd-monorepo.md b/docs/ai/implementation/2026-10-08-feature-devkitd-monorepo.md new file mode 100644 index 000000000..83503851b --- /dev/null +++ b/docs/ai/implementation/2026-10-08-feature-devkitd-monorepo.md @@ -0,0 +1,82 @@ +--- +phase: implementation +title: Implementation notes — devkitd-monorepo +description: What changed for the crate split, devkitd rename, ts-rs codegen, and Nx cargo wiring +--- + +# Implementation — devkitd-monorepo + +## Changed files + +**Rust workspace restructure + rename (M1, M2, T1.x, T2.1):** + +- `rust/Cargo.toml` — `members = ["crates/*"]`, new `[workspace.dependencies]` + centralizing all shared deps. +- `rust/crates/devkitd/` — binary crate (`main.rs`, `server.rs`), renamed + from `ai-devkitd`; usage string and systemd unit name updated to `devkitd`. +- `rust/crates/devkit-core/` — new lib crate holding `proto.rs`, `store.rs`, + `discover.rs` (moved via `git mv`); `chrono_now` relocated above the test + module (clippy `items_after_test_module` fires under `-D warnings` now that + lint runs workspace-wide). + +**ts-rs codegen (M3, T3.1/T3.2):** + +- `devkit-core` dep `ts-rs = "12"` (12.0.1, published 2026-01-31 — well past + the 7-day vetting rule). +- `proto.rs` — `#[derive(TS)]` on `Request`/`Response`/`Event`; per-field + `#[ts(type = "number")]` on `u64`/`i64` fields (ts-rs maps them to `bigint` + by default, but JSON transports them as numbers); `#[ts(optional)]` where + serde skips absent fields; `#[ts(type = "unknown")]` for `serde_json::Value`. +- `proto::tests::export_ts_bindings` — explicit export via + `TS::export(&Config::with_out_dir(...))` anchored at `CARGO_MANIFEST_DIR`. + **Deviation from plan:** `#[ts(export, export_to = ...)]` auto-export was + abandoned — ts-rs joins `export_to` inside `export_dir` (default + `./bindings`, CWD-relative), so `..` traversal both misplaced files and + created stray dirs. The explicit test is deterministic under plain + `cargo test`. +- `packages/daemon-client/src/gen/{Event,Request,Response}.ts` + `index.ts` — + generated, checked in. +- `client.ts` — `DaemonEvent` is now a re-export of generated `Event`. +- `.prettierignore` — excludes `src/gen/` (generated files; oxfmt respects + `.prettierignore`). + +**Rename surface (M2):** + +- `binary.ts` — `DEVKITD_BIN` primary, `AI_DEVKITD_BIN` fallback alias; + filenames `devkitd`/`devkitd.exe`; `~/.ai-devkit/bin/devkitd`; + `rust/target/*/devkitd`; platform package `@ai-devkit/devkitd--`. +- `autostart.ts` — VITEST guard checks both env vars. +- `daemon.ts` (cli), `package.json` description, comments in + `pi-session-tracker` and `channel-connector`. +- `client.test.ts` — rewritten for dual-env resolution (3 new cases). +- `integration.test.ts` — new binary name + dual-env. + +**Nx wiring (M3, T3.3):** + +- `rust/project.json` — `nx:run-commands` targets: `build`, `build:release`, + `test`, `lint`, `fmt`, `fmt:check`; inputs scoped to `rust/**/*.rs` + + Cargo manifests. Follows the per-package convention used elsewhere. + +**Unchanged by design:** socket/db/log names, `AI_DEVKIT_NO_DAEMON`, +`@ai-devkit/daemon-client` package name, `ai-devkit daemon` command. + +## Verification evidence + +- `cargo build/test/clippy -D warnings/fmt` — green (17 tests). +- `nx run rust:{build,test,lint,fmt:check}` — all green via Nx; `nx run-many + -t build` ran `rust:build` in the graph (8 projects). +- `daemon-client` vitest: 9 pass incl. real-binary `ensureDaemon` spawn of + `devkitd`, `daemon.status` roundtrip, and dual-env resolution cases. +- `npm run test:e2e` — 42/42 pass. +- `npm run lint` — green incl. `rust:lint` (clippy `-D warnings`). +- `npm test` — 4 flaky timeouts in `status.service.test.ts` (5s-timeout tests + under parallel load); same file passes standalone in worktree (21.8s) and + on base — unrelated to this change. +- `fmt:check` — 36 pre-existing issues (same as base; gen/ excluded). + +## Follow-ups / not done + +- `devkit-harness` crates + `fixtures/` corpus (v1.5+ per requirements). +- Platform binary packages `@ai-devkit/devkitd--` — resolution + path exists; packages not yet published (release workflow wiring). +- `apps/` split deferred until console is daemon-dumb. diff --git a/docs/ai/implementation/2026-10-08-feature-harness-antigravity.md b/docs/ai/implementation/2026-10-08-feature-harness-antigravity.md new file mode 100644 index 000000000..1e0e77180 --- /dev/null +++ b/docs/ai/implementation/2026-10-08-feature-harness-antigravity.md @@ -0,0 +1,51 @@ +--- +phase: implementation +title: harness-antigravity implementation notes +description: What the I10 antigravity port shipped and parity decisions made +--- + +# I10: harness-antigravity implementation + +## Shipped + +- `rust/crates/devkit-harness/src/antigravity/mod.rs` — + `AntigravityCliAdapter` (type `antigravity_cli`): `agy` pool + + `matches_executable` + dedupe; per-proc cwd→conversation join; + session-or-process-only emission in process order. +- `rust/crates/devkit-harness/src/antigravity/locator.rs` — + `last_conversations.json` read + `match_running_processes`. +- `rust/crates/devkit-harness/src/antigravity/parser.rs` — bounded + summary fold (MAX `created_at`, USER_REQUEST extraction, last + user/assistant role), `determineStatus`. +- `shared::matches_executable_name` — `.exe`+lowercase normalization on + candidate basenames (all three match sites), fixing a latent pool + filter drop for `*.exe` procs across adapters. +- `capture.ts` — explicit `HARNESS_INDEX_DIRS` set (same effective + partition as the old suffix heuristic) + `.gemini/antigravity-cli` + referenced-only; `fixtures.test.ts` pins `ANTIGRAVITY_CLI_HOME` during + replay + live capture case. +- Daemon: `AntigravityCliAdapter` registered → `ported` += `antigravity_cli` + — all ten harnesses now ported. + +## Parity decisions + +- `read_session` gates on `fs::metadata` alone (TS `!stat`) — a dir at + the transcript path emits a session with an empty summary. +- `created_at` MAX not last-write — the fold only replaces on strict `>`. +- `extractUserRequest` without regex: first open-tag → first close-tag + substring, trimmed; identical result for well-formed and unclosed + inputs (unclosed → falls to whole-text trim like the failed match). +- Summary intentionally NOT truncated (`lastUserMessage ||` default) — + the only adapter with this rule. +- Empty-string `lastUserMessage` (empty ``) + is preserved through the reduce then falsy-collapsed at map time. +- `ANTIGRAVITY_CLI_HOME` read once at construction (TS locator ctor) — + replay uses `at_base_dir`, daemon uses `new(home)`. + +## Test evidence + +- `cargo test -p devkit-harness` — 63 green incl. both bundles. +- `cargo test --workspace` + `clippy -D warnings` — clean. +- `vitest` agent-manager — 1343 green; byte-identical replay. +- Live smoke — `ported` includes `antigravity_cli` (10/10); no live + `agy` procs on the machine. diff --git a/docs/ai/implementation/2026-10-08-feature-harness-claude.md b/docs/ai/implementation/2026-10-08-feature-harness-claude.md new file mode 100644 index 000000000..3565fb015 --- /dev/null +++ b/docs/ai/implementation/2026-10-08-feature-harness-claude.md @@ -0,0 +1,56 @@ +--- +phase: implementation +title: harness-claude implementation +description: Rust port of ClaudeCodeAdapter into devkit-harness + daemon merge path +--- + +# I1: harness-claude implementation + +## Structure + +- `rust/crates/devkit-harness/src/claude/` — the port + - `locator.rs` — resume (`--resume `), PID-file (`~/.claude/sessions/.json`, + 60s start-time staleness), legacy greedy cwd+birthtime (3min window) matching, + process-only fallback + - `parser.rs` — bounded head(1MiB)/tail(4MiB) JSONL cold scan; folds user/assistant/ + system/progress/thinking into status + summary; filters noise lines and + permission-mode/ai-title UI events; command-message + args extraction + - `mod.rs` — `ClaudeAdapter` implementing `HarnessAdapter` (`type_id`, `can_handle`, + `detect`); PID-file status/waitingFor overrides JSONL; kebab-case naming +- `rust/crates/devkit-harness/src/shared.rs` — `executable_path`, + `matches_executable_name`, ISO-8601 parsing helpers shared by future ports +- `rust/crates/devkit-harness/src/fixtures.rs` — bundle replay: materializes + `$FIXTURE_HOME`/`$NOW` sentinels, runs the adapter, byte-compares against + `expected` + +## Process enrichment (devkit-core) + +`discover.rs` gained `start_time_ms` on `AgentProc` plus `enrich_agents()`: +batch `lsof -a -d cwd -Fn -p ` for cwd on macOS (`/proc` fast path on +Linux) and batch `ps -o pid=,lstart= -p ` for start times. `apply_sweep` +calls it before harness enrichment, so candidate processes arrive fully +attributed. + +## Merge path (agent-manager) + +`AgentManagerOptions.fetchEnrichedAgents` returns the daemon's +`EnrichedAgentsResult` (`agents` + `ported`). `listAgents` pushes daemon rows +whose `type` is in `ported` (wire `string` fields cast to `AgentType`/ +`AgentStatus`, `lastActive` ISO → `Date`), then filters registered adapters +to exclude ported types. Null/throw → `portedTypes` cleared → all-local. +Registry merge, runtime enrichment, sorting unchanged downstream. + +## CLI wiring + +`createAgentManager` injects `fetchEnrichedAgents` = `ensureDaemon()` → +`enrichedAgents()` → `close()`. `ensureDaemon` gives tmux-style implicit +spawn (guarded off under vitest / `AI_DEVKIT_NO_DAEMON`). + +## Decisions + +- Cold-start parser only — no incremental offset cache across sweeps (deferred + to I12 efficiency pass). +- `getConversation`/`listSessions`/`findSessionsById` stay TS-side. +- ts-rs export test post-processes `from "./X"` → `from "./X.js"` for nodenext. +- Bundle `expected` keeps `$FIXTURE_HOME` literally; only materialized inputs + expand it. `$NOW` expands at write time via a separate helper (TS + Rust). diff --git a/docs/ai/implementation/2026-10-08-feature-harness-codex.md b/docs/ai/implementation/2026-10-08-feature-harness-codex.md new file mode 100644 index 000000000..074fec57f --- /dev/null +++ b/docs/ai/implementation/2026-10-08-feature-harness-codex.md @@ -0,0 +1,54 @@ +--- +phase: implementation +title: harness-codex implementation notes +description: What the I2 codex port shipped and where parity needed fixes +--- + +# I2: harness-codex implementation + +## Shipped + +- `rust/crates/devkit-harness/src/codex/mod.rs` — helper-subcommand + filter (clap flag semantics incl. variadic `--image`, `--` stop, + app-server-daemon path exclusion), `sessions.json` mapping stage, + registry-cache stage, locator stage, process-only fallback — all in + the TS stage/output order. +- `rust/crates/devkit-harness/src/codex/locator.rs` — resume `` + match, uuidv7→day-dir window, `session_meta` head parse with + truncated-JSON regex fallback, legacy greedy match, 30s unmatched + negative cache keyed by pid+date-dir signature. +- `rust/crates/devkit-harness/src/codex/parser.rs` — bounded + head(1MiB)+tail(4MiB) fold, session_meta gate, status mapping + (idle > waiting > running), summary extraction. +- `shared.rs` — `birthtime_ms`, `local_day_key` (libc `localtime_r`), + `is_idle`, `truncate`, `parse_timestamp_ms`, `fold_jsonl_bounded`, + `read_file_head`, `registry_session_paths` (readonly SQLite open of + `/.ai-devkit/agents.db`, filtered `type='codex'`). +- Daemon: `CodexAdapter` registered in `default_registry` → `ported` + now `["claude","codex"]`. +- `AgentManager` merge: multi-ported test added (daemon serves both + ported types; both local adapters skipped). + +## Parity issues found via the live bundle + +1. **Registry leak** — `AgentRegistry.default()` is SQLite at + `~/.ai-devkit/agents.db`, not a JSON file; replay used the real db. + Fixed by a `registry` field on bundles + seeded isolated db on both + replay sides. +2. **Post-detect registry snapshot** — capture recorded registry state + after `detectAgents`, including rows the run itself persisted. + Snapshot moved before detection. +3. **Mapping file not captured** — `.codex/ai-devkit/sessions.json` + wasn't in `HARNESS_DIRS`; its absence reordered match stages. + Added `.codex/ai-devkit` to capture. +4. **`$TODAY` over-application** — blanket day-key rewrite broke + static-start live processes on later replay days. Rewrite now + applies only to pids with `startTime === "$NOW"`. + +## Fixture bundles + +- `fixtures/harness/codex/matched.json` — mapping + resume + legacy + + helper-skip. +- `fixtures/harness/codex/statuses.json` — per-status rows. +- `fixtures/harness/codex/live.json` — gitignored live oracle + (12 real codex agents). diff --git a/docs/ai/implementation/2026-10-08-feature-harness-copilot.md b/docs/ai/implementation/2026-10-08-feature-harness-copilot.md new file mode 100644 index 000000000..198e12892 --- /dev/null +++ b/docs/ai/implementation/2026-10-08-feature-harness-copilot.md @@ -0,0 +1,49 @@ +--- +phase: implementation +title: harness-copilot implementation notes +description: What the I5 copilot port shipped and parity decisions made +--- + +# I5: harness-copilot implementation + +## Shipped + +- `rust/crates/devkit-harness/src/copilot/mod.rs` — `CopilotAdapter` + (type `copilot`): `["copilot"]` pool filter + argv0 `matches_executable` + canHandle + pid dedupe; per-lock agent mapping (no dedupe); wrapper + registry-name override; `wrapper_pids` suppression; process-only. +- `rust/crates/devkit-harness/src/copilot/locator.rs` — lock discovery + with the `knownLocks` (pid,startTime) cache behind `Mutex` so the + `Send + Sync` adapter can keep it across sweeps; dir-mtime lower-bound + scan; unambiguous-lock memorization. +- `rust/crates/devkit-harness/src/copilot/parser.rs` — `EventState` + reducer for `fold_jsonl_bounded`: entryCount on parseable-only, + timestamp/type tracking, `session.start` overrides, user/assistant + text; `skip()` semantics; naive `workspace.yaml` scalar reader with + quote stripping; `toSession` fallback chain (events > workspace > + file stat > now); `determine_status`. +- Daemon: `CopilotAdapter` registered → `ported` = + `["claude","codex","pi","gemini_cli","copilot"]`. +- TS capture: `copilot: [".copilot/session-state"]` (index-style — lock + files are the attribution index and must all materialize); + `REGISTRY_AWARE` += `copilot`; `adapterFor` constructs + `CopilotAdapter(registry)`; live capture case added. + +## Parity decisions + +- `knownLocks` ported as state (Mutex) rather than dropped: a hit is + re-validated by stat so output matches a rescan unless a pid's lock + moved session dirs between sweeps — mirroring TS exactly removes the + question. +- Lock files have no content contract — discovery is filename+dir-mtime + only; bundles store empty lock files. +- `dir_with` test helper uses an atomic counter (same pattern as + `materialize_home`) — two parser tests otherwise collide on + `files.len()`-named temp dirs. +- Fixture processes use `startTime: "$NOW"` so the lock-dir mtime window + (`earliest start − 5min`) includes materialized dirs deterministically; + omitted start would also force a full scan but leaves the window path + unexercised. +- `sessionId`/`projectPath` accept strings only — a truthy non-string + `data.sessionId` would emit a non-string wire value in TS; out of + scope for the wire schema (`session_id: String`). diff --git a/docs/ai/implementation/2026-10-08-feature-harness-devin.md b/docs/ai/implementation/2026-10-08-feature-harness-devin.md new file mode 100644 index 000000000..6bf13395d --- /dev/null +++ b/docs/ai/implementation/2026-10-08-feature-harness-devin.md @@ -0,0 +1,54 @@ +--- +phase: implementation +title: harness-devin implementation notes +description: What the I8 devin port shipped and parity decisions made +--- + +# I8: harness-devin implementation + +## Shipped + +- `rust/crates/devkit-harness/src/devin/mod.rs` — `DevinAdapter` + (type `devin`): `["devin"]` pool filter + argv0 match + utility + subcommand gate (`first_positional_token` with required/optional + flag-value skipping, `=` form never skips, `--` terminates) + pid + dedupe; `session_locks` slug map (holder pid + holder ppid); + acp-backend-child skip; slug→by-id (no cwd fallback on miss) else + cwd→by-directory; process-only fallback. +- `rust/crates/devkit-harness/src/devin/locator.rs` — sessions.db + resolution + readonly open; `.lock` listing with JS `parseInt` + semantics; `find_session_by_id`/`find_session_for_directory` + (`hidden = 0`; `created_at`/`last_activity_at` seconds → ms). +- `rust/crates/devkit-harness/src/devin/parser.rs` — `getSessionStats` + port: frontier role, `MAX(created_at)*1000` heartbeat, two-tier + `user_prompt` (prompt_history `is_shell=0` non-`/%` newest → + `message_nodes` newest-8 user nodes filtered by + `is_user_input !== false` + non-empty string content; truncate 120). +- `DevinAdapter::at_db(path)` — canonical path for fixture replay; + `new(home)` env-aware for the daemon. +- `capture.ts` — `dumpSqliteDb` helper (DDL + caller-selected rows), + opencode branch refactored onto it, devin branch added (sessions by + proc cwds ∪ lock slugs; bounded reachable node/prompt rows per + session); `.local/share/devin/cli/session_locks` index dir; + `fixtures.rs` float `mtimes` parse. +- Daemon: `DevinAdapter` registered → `ported` += `devin`. + +## Parity decisions + +- `parse_int_prefix` mirrors `parseInt(trim,10)`: sign + digit prefix, + `>0` required, NaN→skip; `"99999"` (dead pid) filtered by the live- + proc check, `notapid` skipped. +- Lock iteration stays in readdir order (Map last-wins) — same + underlying syscall order on both sides; no sorting. +- `session.directory || proc.cwd` fallback: empty-string directory + falls to proc cwd; `title` empty string also falls through to the + default summary (JS `||` falsy semantics). +- `is_acp_backend_child` checked before session resolution and needs + no db — a `devin acp` under a live devin parent produces no agent row + even when the db is absent. +- Seconds→ms uses `saturating_mul(1000)`; NULL `last_activity_at` → 0 + → `|| timeCreated` fallback (JS `null*1000===0`, `0||x` → x). +- Bounded live dump is exact-reachability: every row a detection query + could return is present; extra rows can't change LIMIT-k results. +- Clippy `if_same_then_else`: required/optional flag branches merged — + identical `i += 1` bodies. diff --git a/docs/ai/implementation/2026-10-08-feature-harness-gemini.md b/docs/ai/implementation/2026-10-08-feature-harness-gemini.md new file mode 100644 index 000000000..e34b7117c --- /dev/null +++ b/docs/ai/implementation/2026-10-08-feature-harness-gemini.md @@ -0,0 +1,56 @@ +--- +phase: implementation +title: harness-gemini implementation notes +description: What the I4 gemini port shipped and where parity needed fixes +--- + +# I4: harness-gemini implementation + +## Shipped + +- `rust/crates/devkit-harness/src/gemini/mod.rs` — `GeminiAdapter` + (type `gemini_cli`): node-pool + any-token executable detection, + registry cache (wrappers excluded), greedy match via shared matcher, + wrapper registry-name override, session dedupe (highest pid in place), + process-only fallback. +- `rust/crates/devkit-harness/src/gemini/locator.rs` — sha256 candidate + roots over cwd ancestors; legacy hash dirs (`/^[0-9a-f]{64}$/`) gated + by `cwdByHash`, slug dirs by `.project_root` ∈ roots (absent marker → + scan); mtime window `earliestStart − 3min − 2s`; `.json` shadowed by + `.jsonl` skipped; 8KiB metadata head (log line 1 / doc head / regex + prefix / full-file fallback). +- `rust/crates/devkit-harness/src/gemini/parser.rs` — `parse_session` + with `.jsonl` log replay: insertion-ordered message map, + `$set` merges (messages replace), `$rewindTo` truncate-from-id + (unknown id → clear), sessionId+projectHash metadata merge; summary = + last user text (displayContent > content, Part[] `text` join, 120 + truncate); status idle > waiting(gemini|assistant) > running. +- `shared.rs` — `is_same_terminal_process`/`find_wrapper_pid`/ + `wrapper_pids` (utils/process.ts port), `RegistryRow` + + `registry_agent_rows` (started_at/name order; pid map last-wins). +- Daemon: `GeminiAdapter` registered → `ported` is + `["claude","codex","pi","gemini_cli"]`; `apply_sweep` now sweeps + `node`/`bun` runtime argv0s and gates visibility on + `Registry::any_can_handle` (mirrors `isCandidateProcess`) — node-script + harnesses become detectable without flooding agent.list/events with + unclaimed node procs. Also fixes the latent gap where `node …/pi.js` + procs never reached the pi adapter. +- TS capture: `gemini_cli: [".gemini/tmp"]` (index-style — keeps + `.project_root` markers); `REGISTRY_AWARE` += `gemini_cli`; + `adapterFor` returns `GeminiCliAdapter(registry)`. + +## Parity fixes during implementation + +- Node-pool prefilter: TS `findHarnessProcesses` applies + `filterByProcessNames(processes, ["node"])` INSIDE detect — a bare + `gemini` argv0 proc never reaches `canHandle`. Rust now applies + `matches_executable_name(command, ["node"])` first. +- sha256 test constant corrected (verified against node crypto). +- `materialize_home` temp dirs collided across parallel parity tests + (same `pid-ms-tag` when two adapters both materialize `matched.json`) + → shared `agents.db` "table already exists". Added an atomic counter. +- Log replay's metadata-gate check restructured to avoid the + borrow-of-metadata vs move conflict; `sessionId` non-string → None + (empty string still fails buildSession's truthy check, same as TS). +- `fileSignature` caches intentionally omitted — they are + signature-keyed and output-transparent; noted for a later perf pass. diff --git a/docs/ai/implementation/2026-10-08-feature-harness-grok.md b/docs/ai/implementation/2026-10-08-feature-harness-grok.md new file mode 100644 index 000000000..2e1311592 --- /dev/null +++ b/docs/ai/implementation/2026-10-08-feature-harness-grok.md @@ -0,0 +1,44 @@ +--- +phase: implementation +title: harness-grok implementation notes +description: What the I6 grok port shipped and parity decisions made +--- + +# I6: harness-grok implementation + +## Shipped + +- `rust/crates/devkit-harness/src/grok/mod.rs` — `GrokAdapter` + (type `grok_cli`): pool filter + argv0 `matches_executable` + pid dedupe; + `match_running` per-process session/process-only rows in process order. +- `rust/crates/devkit-harness/src/grok/locator.rs` — `active_sessions.json` + pid→cwd (numeric pid + non-empty string cwd only); `latest_session_dir` + max-mtime pick among session dirs inside + `sessions//`; `GROK_HOME` env base override. +- `rust/crates/devkit-harness/src/grok/parser.rs` — bounded + `chat_history.jsonl` fold: `` prompt extraction (context + injections skipped), flattenTextBlocks content, first/last prompt + + lastRole tracking, `skip` keeps head's first prompt; `lastActive` from + file mtime; idle > waiting(assistant) > running. +- `shared.rs` — `flatten_text_blocks`, `encode_uri_component`. +- Daemon: `GrokAdapter` registered → `ported` = + `["claude","codex","pi","gemini_cli","copilot","grok_cli"]`. +- Bundle format: `mtimes` — `{relpath: epochMs | "$NOW"}` applied after + file writes (`fs.utimesSync` TS / `File::set_modified` Rust); live + capture now records `statSync().mtimeMs` for every captured file so + mtime-driven behavior replays. + +## Parity decisions + +- `mtimes` added to the bundle schema rather than normalizing + mtime-derived `lastActive` — file mtimes can't be inferred from + content, and `$NOW` normalization would be off by the write/read delta. +- `active_sessions.json` is a HARNESS_FILE (not index dir): only pid/cwd + pairs matter and it stays small. +- `.grok/sessions` captured referenced-style (not index): a non-captured + sibling dir can't outrank the captured max-mtime pick, so replay keeps + the same winner. +- `sessionStart` (dir birthtime) is computed in TS `toSession` but only + feeds `toSessionSummary` — not carried into the agent row. +- `flatten_text_blocks` joins every block's `text` — TS does not filter + blocks by `type` ("text"), only by object-ness and string `text`. diff --git a/docs/ai/implementation/2026-10-08-feature-harness-kiro.md b/docs/ai/implementation/2026-10-08-feature-harness-kiro.md new file mode 100644 index 000000000..5928b72ba --- /dev/null +++ b/docs/ai/implementation/2026-10-08-feature-harness-kiro.md @@ -0,0 +1,46 @@ +--- +phase: implementation +title: harness-kiro implementation notes +description: What the I9 kiro port shipped and parity decisions made +--- + +# I9: harness-kiro implementation + +## Shipped + +- `rust/crates/devkit-harness/src/kiro/mod.rs` — `KiroAdapter` + (type `kiro`): five-name pool filter, `is_kiro_executable` argv0 + + node/bun-script detection (`kiro_basename` `.exe`/`.js` strip), lock + matches → per-session agents, process-only fallback. +- `rust/crates/devkit-harness/src/kiro/locator.rs` — lock discovery + + ancestor walk + tty fallback. +- `rust/crates/devkit-harness/src/kiro/parser.rs` — bounded summary fold, + metadata read, `determineStatus` equivalent. +- `shared::list_dir_names` — sorted readdir mirror; adopted by every + adapter locator (fixes latent ordering parity for kiro's lock-ordered + output and hardens copilot/devin/grok/gemini/pi/codex/claude). +- `capture.ts` — `.kiro/sessions/cli` index dir + live capture case. +- Daemon: `KiroAdapter` registered → `ported` += `kiro`. + +## Parity decisions + +- `to_pid` uses `as_f64` + integral/≤2^53−1 bounds — JS `Number.isSafeInteger` + admits `5.0`, which `as_i64` would drop. +- Lock JSON must parse as an object (`asRecord`); `{"pid":"1101"}` string + digits accepted like `toPid`'s `/^\d+$/` path. +- `read_session` requires the transcript to be a file (matches skipped → + process-only) but tolerates unreadable content (empty summary). +- Status order: idle → waiting (assistant, no toolUse) → running. +- Summary chain `lastUserMessage || title || "Kiro session active"` — + `lastUserMessage` is never empty when present (`entryToMessage` drops + empty content), so the `||` semantics collapse cleanly. +- `relevant` order preserved for `by_pid`/tty filters; `processes` + first-wins dedupe preserved for process-only sweep order. +- mtime fallback covered via `mtimes` in matched.json (s3). + +## Test evidence + +- `cargo test -p devkit-harness` — 62 green incl. both kiro bundles. +- `cargo test --workspace` + `clippy -D warnings` — clean. +- `vitest` agent-manager — 1341 green; kiro replays byte-identical. +- Live smoke — `ported` includes `kiro` (no live procs on the machine). diff --git a/docs/ai/implementation/2026-10-08-feature-harness-opencode.md b/docs/ai/implementation/2026-10-08-feature-harness-opencode.md new file mode 100644 index 000000000..40025ffa2 --- /dev/null +++ b/docs/ai/implementation/2026-10-08-feature-harness-opencode.md @@ -0,0 +1,57 @@ +--- +phase: implementation +title: harness-opencode implementation notes +description: What the I7 opencode port shipped and parity decisions made +--- + +# I7: harness-opencode implementation + +## Shipped + +- `rust/crates/devkit-harness/src/opencode/mod.rs` — `OpenCodeAdapter` + (type `opencode`): `["opencode"]` pool filter + argv0 + `matches_executable` + first-wins pid dedupe; one readonly rusqlite + `Connection` per detect; each proc's cwd → newest `session` row → + stats → mapped agent, else `"OpenCode process running"` process-only. +- `rust/crates/devkit-harness/src/opencode/locator.rs` — db-path + resolution (`XDG_DATA_HOME`/`~/.local/share` + `opencode/opencode.db`, + empty env falls back), readonly open, directory→session query. +- `rust/crates/devkit-harness/src/opencode/parser.rs` — `getSessionStats` + port: last message role by `time_created DESC`, `MAX(time_updated)` + heartbeat (NULL → last message's own → 0), last-assistant + `time.completed`/`time.error` presence via `ValueRef::Null` checks, + first user `part` text (JOIN message, `type='text'`, trimmed). +- Bundle format: `sqlite` — `{relpath: [sql stmt…]}` executed in order to + materialize binary stores (`db.exec` TS / `execute_batch` Rust), with + `$FIXTURE_HOME`/`$TODAY` expansion on statements. +- `capture.ts` `collectSqliteDumps` — for opencode: real `sqlite_master` + DDL + `SELECT *` rows for sessions at captured proc cwds and their + message/part rows, serialized as `INSERT … VALUES` with proper literal + quoting (strings `''`-escaped, numbers, NULL, blob `X'hex'`). +- `OpenCodeAdapter::at_db(path)` — fixture replay pins + `~/.local/share/opencode/opencode.db`; `new(home)` keeps env-aware + resolution for the daemon. +- Daemon: `OpenCodeAdapter` registered → `ported` = + `["claude","codex","pi","gemini_cli","copilot","grok_cli","opencode"]`. + +## Parity decisions + +- `stats_inner() -> Option` with `unwrap_or_default` + reproduces the TS single-try/catch: any query error → EMPTY_STATS. A + non-TEXT first user `part.text` is treated as an error (JS `.trim()` + would throw → EMPTY_STATS). +- `lastTimeUpdated` uses `Option>` flattening to mirror + `heartbeat?.maxUpdated ?? last?.timeUpdated ?? 0` — the aggregate row + always exists, its value may be NULL. +- `XDG_DATA_HOME` is pinned on both replay sides (`process.env` in the + vitest file, `at_db` in Rust) so an ambient XDG dir can't resolve the + real opencode.db during tests; capture always records under the + canonical relpath. +- Shared fix in `processOnlyAgent` (`cwd = processInfo.cwd ?? ""`): + TS crashed `path.basename(undefined)` for any cwd-less process — + previously unreachable (all callers passed explicit cwds or enrichment + guaranteed them). Rust already yielded `unknown-`/`""`; the fix + makes TS identical and keeps the fixture expressible. +- `sessionFilePath` = `::` — always present for a + session-matched agent; the `$FIXTURE_HOME` prefix sanitizes the dot-dir + segment like other harness paths. diff --git a/docs/ai/implementation/2026-10-08-feature-harness-pi.md b/docs/ai/implementation/2026-10-08-feature-harness-pi.md new file mode 100644 index 000000000..5dcca1730 --- /dev/null +++ b/docs/ai/implementation/2026-10-08-feature-harness-pi.md @@ -0,0 +1,50 @@ +--- +phase: implementation +title: harness-pi implementation notes +description: What the I3 pi port shipped and where parity needed fixes +--- + +# I3: harness-pi implementation + +## Shipped + +- `rust/crates/devkit-harness/src/pi/mod.rs` — `PiAdapter` with + whitespace-token executable detection (`pi`, `pi.js`, `pi.exe`), + `SessionTracker` over `~/.pi/agent/sessions.json` (trusted-path gate), + detect pipeline in TS order: tracker → registry → locator → + process-only (`"Pi process running"`). +- `rust/crates/devkit-harness/src/pi/locator.rs` — per-cwd encoded + project dirs under `~/.pi/agent/sessions`, filename timestamp + (`YYYY-MM-DDTHH-MM-SS-sssZ_`) candidacy OR birthtime, greedy + `≤3min` matching via `shared::match_processes_to_sessions`. +- `rust/crates/devkit-harness/src/pi/parser.rs` — bounded fold, + `PiSummary` state: first-entry sessionId/cwd sticky, last-role + + last-user-message + last-timestamp reduction, filename id fallback, + `None` for entry-free files. +- Registry fallback reuses `shared::registry_session_paths` (readonly + SQLite, `type='pi'`) — the same code path I2 added for codex. +- Daemon: `PiAdapter` in `default_registry` → `ported` is + `["claude","codex","pi"]`; server test assertion updated. +- TS capture: `HARNESS_DIRS.pi = [".pi/agent/sessions"]`, + `HARNESS_FILES.pi = [".pi/agent/sessions.json"]` (avoids sweeping up + `auth.json` et al.); `pi` added to `REGISTRY_AWARE` in the fixture + test. + +## Parity fixes during implementation + +- `is_idle`: TS uses float division (`x/60000 > 5`) — Rust must compare + `ms > 300000` on the raw delta, not the floored minutes; fixed in + `shared::is_idle`. +- Legacy matching: filename timestamp only gates candidacy — the final + matcher still compares `proc.startTime` to file **birthtime**. For a + deterministic fixture the process uses `"startTime": "$NOW"` and the + legacy session sits in its own project dir (otherwise multiple + ~now-birthtime files in the same dir make greedy pairing flaky). +- Entry-free session files (`"not json\n[1,2]\n"`) must yield + `None`/parse-fail — a stray `{"a":1}` object counts as an entry and + produces a real session; fallback fixture content adjusted. +- Borrow-checker fix in the fold: copy the line slice before applying + (`head.bytes_read` was mutably borrowed). +- Filename timestamp unit test: expected value corrected to + `1781081900754` (the `Date.parse` value of the supplied 2026 stamp — + my arithmetic, not the code, was wrong). diff --git a/docs/ai/implementation/2026-10-08-feature-harness-port-cutover.md b/docs/ai/implementation/2026-10-08-feature-harness-port-cutover.md new file mode 100644 index 000000000..59ef87756 --- /dev/null +++ b/docs/ai/implementation/2026-10-08-feature-harness-port-cutover.md @@ -0,0 +1,77 @@ +--- +phase: implementation +title: harness-port-cutover implementation + efficiency report +description: I12 — final daemon-primary cutover and the port metrics +--- + +# I12: cutover + efficiency report + +## Shipped + +- `channel-runner.ts`: `fetchEnrichedAgents` wired into its + `AgentManager` (was the only consumer still on the local-only + path). All call sites — `agent` CLI commands, the TUI console, + channel bridges — now consult the daemon first and keep the local + adapter path strictly as fallback. +- Cutover state: `ported` = all ten types → `listAgents` performs + zero local detection when devkitd answers. `status` readiness is + likewise daemon-primary (I11). + +## Efficiency report + +### Per-adapter LOC (TS harness dir → Rust module) + +| type | TS LOC | Rust LOC | notes | +|---|---|---|---| +| claude | 1527 | 878 | largest session format | +| codex | 1863 | 1273 | registry + date-dir discovery | +| pi | 1554 | 921 | sqlite-ish tracker + legacy matching | +| gemini_cli | 1098 | 1029 | log replay incl. rewind/set events | +| copilot | 762 | 694 | lock dirs + workspace yaml | +| grok_cli | 588 | 369 | transcript mtime-driven | +| opencode | 506 | 332 | sqlite session/message/part | +| devin | 769 | 503 | sqlite + lock files + acp | +| kiro | 696 | 515 | locks + jsonl transcripts | +| antigravity_cli | 487 | 331 | cwd-keyed registry + transcript fold | +| **adapters total** | **9850** | **6845** | ~30% smaller in Rust | +| shared engine | (shared.ts ~700) | shared.rs 736 + lib.rs 261 | registry/sweep/fold | +| readiness | 877 | 1365 | incl. fixture host; regex dep added | + +### Fixture corpus + parity defects found + +- 19 committed harness bundles (2–3 per type; live captures stay + gitignored) + 2 readiness bundles, all replayed byte-identical on + both sides. +- Parity defects surfaced by the oracle, fixed during the port: + `.exe` basename normalization asymmetry (I10), `readdirSync` + sorted-order vs Rust FS order (I9), `is_idle` float vs floor + window (I3), float `mtimeMs` (I8), cwd-less `processOnlyAgent` + crash latent in TS (I7, fixed in shared.ts), fixture temp-dir and + registry-seeding races. + +### Daemon overhead + latency (debug build, live machine) + +| metric | value | +|---|---| +| devkitd RSS under 2s sweep loop | ~74 MB (85 MB peak during sweep) | +| devkitd CPU | ~7% while sweeping | +| `agent.enriched` warm RPC | **0.4 ms/call** (cached result) | +| `listAgents` local-only path | ~238 ms/call | +| effective speedup | daemon answers in-process-cache reads ~600x faster; the sweep cost is paid off-path once per 2s instead of per caller | +| `agent.readiness` | ~10 reports computed on demand (sub-second) | + +### Structural wins beyond latency + +- One `ps` sweep serves every consumer (console, CLI, bridges) — + previously each ran its own `ps` + per-adapter session I/O. +- Events (`agent.appeared`/`disappeared`, `registry.changed`) are + daemon-emitted, so consoles subscribe instead of poll-diffing. +- Fixture replay gives a standing cross-language oracle — new + adapters port with the same bundle mechanism. + +## Test evidence + +- `cargo test --workspace` 82 green; `clippy -D warnings` clean. +- `vitest` agent-manager 1345, cli 1320 green. +- `agent list --json` returns the same type set with and without the + daemon; `status` identical daemon-primary vs local fallback. diff --git a/docs/ai/implementation/2026-10-08-feature-harness-port-foundation.md b/docs/ai/implementation/2026-10-08-feature-harness-port-foundation.md new file mode 100644 index 000000000..657445ef3 --- /dev/null +++ b/docs/ai/implementation/2026-10-08-feature-harness-port-foundation.md @@ -0,0 +1,68 @@ +--- +phase: implementation +title: Implementation — Harness Port Foundation (I0) +description: What was built, where, and deviations from design +--- + +# Implementation — Harness Port Foundation (I0) + +## What landed + +### Rust + +- `rust/crates/devkit-core/src/agent.rs` — `EnrichedAgent` (mirrors + `AgentInfo` field-for-field via serde camelCase + `type` rename, ISO + `lastActive`, `number` pid) and `EnrichedAgentsResult {agents, ported}`, + both `TS`-derived. Wire-shape unit tests assert the exact JSON keys. +- `proto.rs::export_ts_bindings` now also emits `EnrichedAgent.ts` + + `EnrichedAgentsResult.ts` into `packages/daemon-client/src/gen/`. +- `rust/crates/devkit-harness/` — new crate: `SweepContext`, `HarnessAdapter` + trait (`type_id`/`can_handle`/`detect`), `Registry` (`ported_types`, + `enrich`). MockAdapter tests cover ported list + aggregation. +- `devkitd` — `Daemon.enricher: devkit_harness::Registry` + + `enriched: RwLock` refreshed inside `apply_sweep`; + `agent.enriched` RPC serves the cache (O(1) per call). Server test proves + the cache path. + +### TypeScript + +- `daemon-client`: `client.enrichedAgents()` + `EnrichedAgent(sResult)` + re-exports; integration test round-trips against a real spawned `devkitd`. +- `agent-manager/src/fixtures/bundle.ts` — bundle schema + `sanitize` + (dot-dir-scoped home rewriting), `expand`, `normalizeAgents`, + `withFrozenClock`. +- `agent-manager/src/fixtures/capture.ts` — live capture: frozen clock, + `captureProcessSnapshot` + `detectAgents`, records referenced session + files + pid/index dirs into `fixtures/harness//.json`. +- `agent-manager/src/__tests__/fixtures.test.ts` — replay every committed + bundle in a materialized fake HOME and require byte-identical + `normalizeAgents` output; `AI_DEVKIT_FIXTURE_CAPTURE=1` gates live + capture. + +## Deviations from design + +1. **Live bundles are gitignored, not committed.** Real transcripts contain + private conversation content; committing them was never acceptable once + seen. `fixtures/harness/**/live*.json` is ignored — live capture stays a + local dev oracle; synthetic committed fixtures land per-iteration in I1+ + (where the format work happens anyway). Requirements doc updated. +2. **Sanitization narrowed to dot-dir prefixes.** Blanket `$HOME` rewriting + broke claude's cwd→encoded-projects-dir matching (project paths share + the home prefix but aren't inside it). `sanitize` now rewrites only + `/.` occurrences. +3. **No AgentManager merge wiring in I0** — deferred per design; the ported/ + local merge lands in I1 with the first real adapter. + +## Metrics (for I12 report) + +- I0 Rust: ~200 LOC net new (agent.rs 96, harness lib.rs ~120). +- Fixture bundles: 1 live claude (4 agents, 12 files, local-only). + +## Verification + +- `cargo test --workspace`: 20 tests green (15 core + 2 harness + 3 devkitd). +- `clippy -D warnings`, `fmt --check`: clean. +- `nx run @ai-devkit/daemon-client:test`: 11 green incl. real-socket + `enrichedAgents`. +- `nx run agent-manager:test`: 1312 green incl. fixture replay. +- `tsc --noEmit`, `oxlint`: clean on touched package. diff --git a/docs/ai/planning/2026-10-07-feature-rust-daemon.md b/docs/ai/planning/2026-10-07-feature-rust-daemon.md new file mode 100644 index 000000000..4b2304e14 --- /dev/null +++ b/docs/ai/planning/2026-10-07-feature-rust-daemon.md @@ -0,0 +1,46 @@ +--- +phase: planning +title: ai-devkitd task plan +description: Milestones for the v1 daemon; existing branch code reconciled against this plan +--- + +# Planning — ai-devkitd v1 + +## Milestones + +| # | Milestone | Deliverable | Status | +|---|---|---|---| +| M1 | Daemon core | `rust/crates/devkitd`: socket + SO_PEERCRED, SQLite sole-writer store, event log, verbs | Done (pre-plan code, reconciled) | +| M2 | Discovery + events | 2s `ps` sweep, snapshot diff, `agent.appeared/disappeared`, subscribe replay | Done (pre-plan code, reconciled) | +| M3 | Client package | `@ai-devkit/daemon-client`: line RPC client, autostart, binary resolution | Done (pre-plan code, reconciled) | +| M4 | Registry absorption | ChannelConfigRepository + pi-session-tracker daemon-first, file fallback | Done (pre-plan code, reconciled) | +| M5 | Daemon UX | `ai-devkit daemon status/start/stop/logs/install` | Done (pre-plan code, reconciled) | +| M6 | Console migration | `useAgentList` subscribes to daemon events; 60s fallback poll; no-daemon path unchanged | This run | +| M7 | Gap closure | Gap analysis vs plan; test depth to production bar; docs alignment | This run | +| M8 | Gates + PR | cargo fmt/clippy/test, nx lint+test, PR #351 updated | This run | + +## Gap analysis — existing branch code vs the fresh plan + +Ran the audit at plan time; each gap either closed in this milestone or recorded: + +1. **Console still blind-polls** (3s interval, no daemon awareness) → closed by + M6: `useAgentList` subscribes for invalidation, keeps fallback interval. +2. **Test depth below production bar** → closed in M7: Rust store concurrency + + persistence, proto wire contract, golden `ps` fixtures, server end-to-end + socket test; daemon-client autostart/fallback integration tests. These tests + caught a real bug (stale spawn lockfile) — fixed. +3. **Autostart stale-lock wedge** → fixed (unlink by holder + 30s reclaim). +4. **Daemon `agent.list` lacks harness attribution** → by design (client-side + knowledge); console keeps `manager.listAgents` for content, daemon supplies + events. Documented in design; no code change. +5. **`agents.session_file` column unpopulated** → schema placeholder for the + phase-2 knowledge port; documented, intentionally unused. +6. **Platform binary packages** (`@ai-devkit/daemon-linux-x64` etc.) → deferred + to release workflow; resolver chain already supports them. +7. **`node`-wrapped harness processes missed by basename filter** → recorded + limitation for the knowledge port (golden fixtures). + +## Deferred (v1.5+, not in this PR) + +Bridge supervision, capacity cache, task-manager store absorption, golden +fixture corpus, platform binary packages in the release pipeline, ACP revisit. diff --git a/docs/ai/planning/2026-10-08-epic-harness-port.md b/docs/ai/planning/2026-10-08-epic-harness-port.md new file mode 100644 index 000000000..493865734 --- /dev/null +++ b/docs/ai/planning/2026-10-08-epic-harness-port.md @@ -0,0 +1,76 @@ +# Epic Plan — Daemon-Owned Enriched Agent State (harness knowledge port) + +Date: 2026-10-08 +Branch: `feature-devkitd-monorepo` (iterate with frequent commits here) +Epic owner: Devin + +## Goal + +Move the "fat part" — per-harness process→session attribution and enrichment +(`AgentManager.listAgents` adapter logic, ~11k LOC across 11 harnesses) — from +TypeScript into the `devkitd` daemon in Rust, so every `ai-devkit` surface +(`agent list`, console TUI, bridges) becomes a thin client of `agent.enriched`. + +Second goal: measure how efficient the Rust port is (dev cost per harness, +runtime cost per sweep, parity-defect rate) to validate the daemon-in-Rust +direction. + +## Execution model + +One iteration per harness (plus foundation and cutover). Each iteration runs +the full dev lifecycle (requirements → design → planning → implementation → +testing → review) and ends in a commit on this branch. Lifecycle docs follow +the repo convention `docs/ai//2026-10-08-feature-harness-.md`. + +## Iterations + +| # | Scope | Feature slug | Exit criteria | +|---|-------|--------------|---------------| +| I0 | Foundation: fixture tool, `devkit-harness` crate, `agent.enriched` RPC + ts-rs schema, TS fallback client | `harness-port-foundation` | daemon serves enriched schema; client falls back; fixtures dump for all 11 harnesses | ✅ `545625dd` +| I1 | claude (1649 LOC, transcript parsing — hardest first) | `harness-claude` | fixture parity 100% | ✅ `12b6ded2` +| I2 | codex (1944) | `harness-codex` | parity | ✅ `7cfecd9b` +| I3 | pi (1683) | `harness-pi` | parity | ✅ `71358904` | +| I4 | gemini (1098) | `harness-gemini` | parity | ✅ `48fc9a87` | +| I5 | copilot (762) | `harness-copilot` | parity | ✅ `22d6fd2f` | +| I6 | grok (588) | `harness-grok` | parity | ✅ `8298101a` | +| I7 | opencode (506) | `harness-opencode` | parity | ✅ `cdf1cc6c` | +| I8 | devin (857) | `harness-devin` | parity | ✅ `d788bf6c` | +| I9 | kiro (696) | `harness-kiro` | parity | ✅ `9c0e9ba5` | +| I10 | antigravity (487) | `harness-antigravity` | parity | ✅ `d7de3221` | +| I11 | readiness — shared lib, `devkit-harness::readiness` internal + `agent.readiness` RPC + status daemon-primary | `harness-readiness` | fixture parity | ✅ `c42b79cf` | +| I12 | Cutover + efficiency report | `harness-port-cutover` | `agent list` daemon-primary; local adapters = fallback only; metrics table published | ✅ `b7402c32` | + +Order rationale: claude/codex/pi first — highest usage and hardest parsing, so +port-cost and risk data arrive early enough to change the plan. + +## Cross-cutting decisions (locked unless an iteration surfaces reason) + +- Parity oracle: golden fixtures produced BY the TS implementation; Rust must + match byte-for-byte on `AgentInfo` fields. +- Wire schema: `AgentInfo` defined in Rust (`devkit-core`/`devkit-harness`), + `ts-rs` generates the TS type — same pattern as `Request`/`Response`/`Event`. +- Fallback: `agent.enriched` missing/failing → current TS adapter path. Never + a hard break; cutover flips preference, not capability. +- Harness-agnostic helpers (`shared.ts`, `readiness/`, `utils/process.ts`, + `utils/matching.ts`) port once into `devkit-harness` during the first + iteration that needs them — I1 (claude) at latest. +- Out of scope for all iterations: `getConversation` (detail view), + `findSessionsById`, historical session listing, resume commands, + `resolveHerdrPanes`. Revisit at cutover. + +## Metrics collected per iteration (feeds I12 report) + +- Rust LOC / files per adapter vs TS baseline +- Fixture count + parity defect count (TS↔Rust mismatches found) +- daemon RSS/CPU per 2s sweep as adapters land +- `agent list` latency daemon-path vs local-path + +## Risks + +- Fixture corpus misses edge cases → parity is overfit. Mitigate: seed + fixtures from real `~/.claude|~/.codex|...` session trees (sanitized), plus + adapter unit-test cases. +- Harnesses whose session format is undocumented/unstable → port may embed + wrong assumptions. Mitigate: fixtures capture observed behavior, not spec. +- Long branch lifetime → rebase pain. Mitigate: frequent small commits, + keep `feature/rust-daemon` ancestry untouched. diff --git a/docs/ai/planning/2026-10-08-feature-devkitd-monorepo.md b/docs/ai/planning/2026-10-08-feature-devkitd-monorepo.md new file mode 100644 index 000000000..2755c66f5 --- /dev/null +++ b/docs/ai/planning/2026-10-08-feature-devkitd-monorepo.md @@ -0,0 +1,88 @@ +--- +phase: planning +title: Plan — devkitd-monorepo +description: Task breakdown for crate split, devkitd rename, ts-rs types, Nx cargo wiring +--- + +# Plan — devkitd-monorepo + +## Milestones + +- [x] M1: Rust workspace restructure (`crates/devkitd` + `crates/devkit-core`), + cargo suite green +- [x] M2: `devkitd` rename across Rust + TS surfaces, tests green +- [x] M3: ts-rs generated types + Nx cargo project wired +- [x] M4: Docs updated, full verification (nx + cargo + e2e) + +## Task Breakdown + +### M1 — workspace restructure + +- [x] T1.1: `git mv rust/ai-devkitd rust/crates/devkitd`; move `proto.rs`, + `store.rs`, `discover.rs` into `rust/crates/devkit-core/src/`; add `lib.rs` + re-exporting the three modules. Update `main.rs`/`server.rs` imports from + `crate::` to `devkit_core::`. Move embedded `#[cfg(test)]` modules with + their files. +- [x] T1.2: `rust/Cargo.toml` → `members = ["crates/*"]`; new + `crates/devkit-core/Cargo.toml` (lib: serde, serde_json, rusqlite, anyhow); + `crates/devkitd/Cargo.toml` (bin: devkit-core path dep, tokio, libc, + tracing, tracing-subscriber). Workspace deps centralized via + `[workspace.dependencies]`. +- [x] T1.3: Verify `cargo build/test/clippy/fmt` green. Evidence: command + output. + +### M2 — rename devkitd + +- [x] T2.1: Rust: crate name `devkitd`, usage string, systemd unit + `devkitd.service` in `main.rs`. +- [x] T2.2: `binary.ts`: filenames `devkitd`/`devkitd.exe`, + `~/.ai-devkit/bin/devkitd`, `rust/target/*/devkitd`, platform package + `@ai-devkit/devkitd--`, env `DEVKITD_BIN` with + `AI_DEVKITD_BIN` fallback. +- [x] T2.3: `autostart.ts` dual-env check; `daemon.ts` (cli) strings + + error message; test files referencing `ai-devkitd`/`AI_DEVKITD_BIN`. +- [x] T2.4: Verify `nx run-many -t test -p @ai-devkit/daemon-client,ai-devkit`. + +### M3 — ts-rs + Nx + +- [x] T3.1: `ts-rs` dev-dep on devkit-core; `#[derive(TS)] #[ts(export)]` on + `Request`, `Response`, `Event`; `export_bindings` test writing to + `packages/daemon-client/src/gen/`; run and commit generated files. +- [x] T3.2: `client.ts` `DaemonEvent` → re-export generated `Event`; + `index.ts` exports the generated types. +- [x] T3.3: `rust/project.json` with nx:run-commands targets + (build, build:release, test, lint, fmt, fmt:check); verify `nx show + project rust`, `nx run rust:test`, and cache skip on rebuild. + +### M4 — docs + verification + +- [x] T4.1: Update `docs/ai/*/2026-10-07-feature-rust-daemon.md` code-facing + names (crate path, binary name); README/other docs mentioning `ai-devkitd` + binary. +- [x] T4.2: Full verification: `cargo fmt --check`, `clippy -D warnings`, + `cargo test`, `nx run-many -t build,test,lint`, `npm run test:e2e`. + +## Dependencies + +- T1.x before all (layout first); T2 can parallel T3 conceptually but both + touch binary.ts-adjacent code — do sequentially T2 → T3. +- ts-rs crate version ≥7 days old at add time. + +## Risks + +- `import.meta.url` dirname walk in binary.ts assumes `rust/` at repo root — + unchanged since target dir stays `rust/target`. +- Nx `run-many` picks up the rust project automatically via project.json + discovery — verify `nx show projects` lists `rust`. +- Generated files committed: ensure `.gitignore` doesn't exclude + `packages/daemon-client/src/gen/`. + +## Status summary (reconciled post-implementation) + +All M1–M4 tasks complete. Deviations: (1) ts-rs auto-export replaced by an +explicit `export_ts_bindings` test because `export_to` resolves inside +CWD-relative `./bindings`; (2) `chrono_now` moved above the test module for +workspace-wide clippy `-D warnings`; (3) `src/gen/` excluded via +`.prettierignore` since generated files must stay byte-stable. No new tasks +discovered; flaky `status.service.test.ts` timeouts are pre-existing under +parallel load. diff --git a/docs/ai/planning/2026-10-08-feature-harness-antigravity.md b/docs/ai/planning/2026-10-08-feature-harness-antigravity.md new file mode 100644 index 000000000..fcf865bda --- /dev/null +++ b/docs/ai/planning/2026-10-08-feature-harness-antigravity.md @@ -0,0 +1,34 @@ +--- +phase: planning +title: harness-antigravity plan +description: Task list for I10 — reconciled post-implementation +--- + +# I10: harness-antigravity plan + +- [x] antigravity/locator.rs — `list_conversations` + cwd join + (process-order, `proc.cwd || ""`) +- [x] antigravity/parser.rs — bounded `Summary` fold (MAX `created_at`, + USER_REQUEST extract, role tracking), `status`, `read_session` + (`!stat` not isFile, mtime fallback) +- [x] antigravity/mod.rs — pool filter, dedupe, session-or-process-only; + `ANTIGRAVITY_CLI_HOME` env in `new(home)`, `at_base_dir` for replay +- [x] shared `matches_executable_name` — `.exe` normalization on + candidate basenames (parity fix for all adapters) +- [x] Register `AntigravityCliAdapter`; ported += antigravity_cli; + devkitd test updated +- [x] capture: explicit `HARNESS_INDEX_DIRS` (behavior-preserving); + `.gemini/antigravity-cli` referenced-only; replay pins/clears + `ANTIGRAVITY_CLI_HOME`; live capture case +- [x] Synthetic bundles: + - `matched.json`: waiting (last PLANNER_RESPONSE), running + (USER_INPUT last + MAX created_at on an earlier line), idle + (mtime fallback); USER_REQUEST-wrapped vs unwrapped content; + string vs `[{text}]` content; missing transcript → process-only; + unregistered cwd; empty key/id and non-string id registry rows + skipped; `agy.exe` in pool; `agy --verbose` canHandle; `notagy` + invisible; untruncated summary + - `fallback.json`: malformed registry JSON → all process-only; + cwd-less proc → `unknown-*` +- [x] Verification: 63 harness tests, workspace tests, clippy -D + warnings, agent-manager suite (1343), live daemon `ported` smoke diff --git a/docs/ai/planning/2026-10-08-feature-harness-claude.md b/docs/ai/planning/2026-10-08-feature-harness-claude.md new file mode 100644 index 000000000..2c43766f9 --- /dev/null +++ b/docs/ai/planning/2026-10-08-feature-harness-claude.md @@ -0,0 +1,51 @@ +--- +phase: planning +title: harness-claude task plan +description: Task breakdown for the I1 claude port — reconciled post-implementation +--- + +# I1: harness-claude plan (reconciled) + +## Tasks + +### Discovery foundation + +- [x] Add `start_time_ms` to `AgentProc`; batch `lsof` cwd + `ps lstart` + enrichment in `apply_sweep` (macOS + Linux `/proc` fast path) + +### Rust port (`devkit-harness::claude`) + +- [x] `shared.rs` — executable path/name matching + ISO-8601 helpers +- [x] `locator.rs` — resume → PID-file (60s staleness) → legacy greedy + (3min) → process-only fallback +- [x] `parser.rs` — bounded head/tail JSONL scan, status fold, summary + + command extraction, noise filtering +- [x] `mod.rs` — adapter orchestration, PID-file status/waitingFor override, + kebab-case naming, `EnrichedAgent` mapping +- [x] Register `ClaudeAdapter` in devkitd; `ported: ["claude"]` + +### Fixture oracle + +- [x] `fixtures.rs` Rust replay harness mirroring TS `bundle.ts` semantics +- [x] `$NOW` sentinel support on both replay sides (birthtime determinism) +- [x] Committed synthetic bundles: `matched.json` (resume + stale-pidfile→ + legacy + process-only), `statuses.json` (all status mappings via + PID-file) +- [x] Byte-identical parity: live + both synthetic bundles, Rust & TS + +### Merge path + +- [x] `AgentManagerOptions.fetchEnrichedAgents`; ported-filter merge in + `listAgents`; null/throw → all-local +- [x] 5 unit tests covering ported skip, unported coexistence, null/throw + fallback, unported-row drop +- [x] CLI wiring via `ensureDaemon()` (implicit spawn) in `createAgentManager` +- [x] Live smoke: auto-spawn + `agent list` + `enrichedAgents()` parity + +### Build glue + +- [x] ts-rs export post-process: `./X` → `./X.js` for nodenext + +## Done + +I1 complete; `ported` = `["claude"]` on the live daemon. Next: I2 codex. diff --git a/docs/ai/planning/2026-10-08-feature-harness-codex.md b/docs/ai/planning/2026-10-08-feature-harness-codex.md new file mode 100644 index 000000000..aacc6db48 --- /dev/null +++ b/docs/ai/planning/2026-10-08-feature-harness-codex.md @@ -0,0 +1,16 @@ +--- +phase: planning +title: harness-codex plan +description: Task list for I2 — reconciled post-implementation +--- + +# I2: harness-codex plan + +- [x] shared.rs: birthtime_ms, local_day_key, is_idle, truncate, + parse_timestamp_ms, fold_jsonl_bounded, read_file_head +- [x] codex/parser.rs: summary reducer + status mapping +- [x] codex/locator.rs: resume + uuidv7 + session_meta + legacy + neg-cache +- [x] codex/mod.rs: helper-subcommand filter, mapping file, registry cache, + orchestration, mapper +- [x] Synthetic bundles (mapping + resume + legacy + statuses + helper-skip) +- [x] Rust + TS parity green; register adapter; ported += codex diff --git a/docs/ai/planning/2026-10-08-feature-harness-copilot.md b/docs/ai/planning/2026-10-08-feature-harness-copilot.md new file mode 100644 index 000000000..b9d8bced0 --- /dev/null +++ b/docs/ai/planning/2026-10-08-feature-harness-copilot.md @@ -0,0 +1,29 @@ +--- +phase: planning +title: harness-copilot plan +description: Task list for I5 — reconciled post-implementation +--- + +# I5: harness-copilot plan + +- [x] copilot/parser.rs — EventState reducer, workspace.yaml scalars, + read_session_dir (bounded fold + toSession), determine_status +- [x] copilot/locator.rs — known-lock cache (Mutex), discover_active_locks, + scan_locks mtime window, inuse..lock pattern +- [x] copilot/mod.rs — adapter orchestration + wrapper registry naming +- [x] Register `CopilotAdapter`; ported += copilot; devkitd test updated +- [x] TS capture: `.copilot/session-state` index dir (lock files must all + materialize); REGISTRY_AWARE += copilot; live capture case +- [x] Synthetic bundles: + - `matched.json`: events.jsonl lock match (waiting), duplicate-pid + lock in empty dir (null session), dead-pid lock, wrapper pair + + registry name, process-only, node/copilot-helper pool drops + - `fallback.json`: events session (running, user.message last), + lock into empty dir → process-only, wrong-type registry row, + non-copilot parent → child still surfaces + - `workspace.json`: workspace.yaml-only session → idle +- [x] Fixture determinism: `$NOW` proc starts keep lock-dir mtime window + inside materialization; one productive lock per bundle (readdir + order can't skew expected) +- [x] Verification: Rust harness tests, workspace tests, clippy -D warnings, + agent-manager suite, live daemon `ported` smoke diff --git a/docs/ai/planning/2026-10-08-feature-harness-devin.md b/docs/ai/planning/2026-10-08-feature-harness-devin.md new file mode 100644 index 000000000..91f4a4603 --- /dev/null +++ b/docs/ai/planning/2026-10-08-feature-harness-devin.md @@ -0,0 +1,35 @@ +--- +phase: planning +title: harness-devin plan +description: Task list for I8 — reconciled post-implementation +--- + +# I8: harness-devin plan + +- [x] devin/locator.rs — db path, `list_active_locks` (parseInt pid), + find_by_id / find_for_directory (`hidden=0`, seconds→ms) +- [x] devin/parser.rs — frontier role, MAX(created_at) heartbeat, + two-tier `user_prompt` summary (prompt_history → node scan) +- [x] devin/mod.rs — utility-subcommand gate (`firstPositionalToken` + flag skipping), lock→slug pid/ppid map, acp-backend skip, per-proc + resolution, mapper +- [x] Register `DevinAdapter`; ported += devin; devkitd test updated +- [x] TS capture: `.local/share/devin/cli/session_locks` index dir; + `collectSqliteDumps` devin branch (sessions by cwd ∪ lock slugs; + bounded reachable message_nodes/prompt_history); `dumpSqliteDb` + refactor with caller-owned clauses; live capture case +- [x] fixtures.rs — float `mtimeMs` parse (`as_f64`) +- [x] Synthetic bundles: + - `matched.json`: lock→TUI via backend ppid; `devin acp` child + skipped; standalone `devin acp` (ppid outside snapshot) kept; + stale/dead-pid and malformed locks ignored; cwd fallback; + hidden session excluded; slug wins over cwd (projectPath from + session.directory); newest-for-dir pick; prompt_history summary + (shell + slash filtered) vs message_nodes fallback vs title + fallback; utility subcommands (`list`, `ssh`, + `--config x list`) dropped; `-p`/`--print` flag-value skipping + - `fallback.json`: locks present + no db → all process-only; + acp child still skipped; cwd-less proc → unknown +- [x] Live capture: `live.json` (2 real agents; 47 SQL stmts bounded) +- [x] Verification: Rust harness tests, workspace tests, clippy -D + warnings, agent-manager suite, live daemon `ported` smoke diff --git a/docs/ai/planning/2026-10-08-feature-harness-gemini.md b/docs/ai/planning/2026-10-08-feature-harness-gemini.md new file mode 100644 index 000000000..c49de493c --- /dev/null +++ b/docs/ai/planning/2026-10-08-feature-harness-gemini.md @@ -0,0 +1,31 @@ +--- +phase: planning +title: harness-gemini plan +description: Task list for I4 — reconciled post-implementation +--- + +# I4: harness-gemini plan + +- [x] shared.rs: wrapper helpers (`is_same_terminal_process`, + `find_wrapper_pid`, `wrapper_pids`) + `registry_agent_rows` +- [x] gemini/parser.rs: `.jsonl` replay (upsert/$set/$rewindTo), `.json` + doc, summary/status +- [x] gemini/locator.rs: candidate roots + sha256, hash/slug/marker + gating, mtime window, metadata head, json-shadow listing +- [x] gemini/mod.rs: node pool + canHandle + dedupe, registry cache, + wrapper naming, process-only, deduplicate +- [x] daemon sweep: runtime binaries (node/bun) + missing names + (agy/kiro/kiro-cli); apply_sweep visibility gate via + `Registry::any_can_handle` +- [x] Register `GeminiAdapter`; ported += gemini_cli; server test updated +- [x] TS capture: `.gemini/tmp` index dir; gemini_cli REGISTRY_AWARE; + live capture case +- [x] Synthetic bundles: matched.json (hash-dir match + registry cache + + wrapper-name + process-only + non-gemini/argv0 drops + hash/marker + negative gating); fallback.json (missing file, stale-type row, + unparseable session, wrapper-named process-only) +- [x] Live bundle captured (1 real gemini agent, process-only) +- [x] Fix `materialize_home` temp-dir race (same-ms collision across + parallel parity tests → shared agents.db) +- [x] Verification: workspace tests + clippy green, agent-manager suite + green, live daemon smoke (ported list + node visibility gate) diff --git a/docs/ai/planning/2026-10-08-feature-harness-grok.md b/docs/ai/planning/2026-10-08-feature-harness-grok.md new file mode 100644 index 000000000..7dda05773 --- /dev/null +++ b/docs/ai/planning/2026-10-08-feature-harness-grok.md @@ -0,0 +1,31 @@ +--- +phase: planning +title: harness-grok plan +description: Task list for I6 — reconciled post-implementation +--- + +# I6: harness-grok plan + +- [x] shared.rs — `flatten_text_blocks`, `encode_uri_component` +- [x] grok/parser.rs — SummaryState reducer, `` extraction, + read_session (mtime → lastActive), determine_status +- [x] grok/locator.rs — active_sessions.json map, encodeURIComponent group + dir, max-mtime session pick +- [x] grok/mod.rs — adapter orchestration, process-only with resolved cwd +- [x] Bundle format — `mtimes` field; TS materialize applies utimesSync; + Rust materialize applies File::set_modified; live capture records + real mtimes for every captured file +- [x] Register `GrokAdapter`; ported += grok_cli; devkitd test updated +- [x] TS capture: `.grok/sessions` referenced-dir + + `.grok/active_sessions.json` file; live capture case +- [x] Synthetic bundles: + - `matched.json`: active_sessions cwd override beats proc.cwd, + two session dirs → max-mtime pick, user_query extraction with + context-injection record skipped, array-content flatten, running + (lastRole user) + waiting (assistant) statuses, process-only, + node/grok-helper pool drops + - `fallback.json`: group dir w/o transcript → process-only, no-cwd + proc → "unknown-" process-only, empty transcript → session + with "Grok CLI session active" summary, old mtime → idle +- [x] Verification: Rust harness tests, workspace tests, clippy -D warnings, + agent-manager suite, live daemon `ported` smoke diff --git a/docs/ai/planning/2026-10-08-feature-harness-kiro.md b/docs/ai/planning/2026-10-08-feature-harness-kiro.md new file mode 100644 index 000000000..2bdec43eb --- /dev/null +++ b/docs/ai/planning/2026-10-08-feature-harness-kiro.md @@ -0,0 +1,35 @@ +--- +phase: planning +title: harness-kiro plan +description: Task list for I9 — reconciled post-implementation +--- + +# I9: harness-kiro plan + +- [x] kiro/locator.rs — `discover_active_locks` (JSON `{pid}`, `to_pid`), + `find_kiro_ancestor` ppid walk, `match_sole_process_on_tty` +- [x] kiro/parser.rs — bounded `KiroSummary` fold, `text_content`, + `entry_timestamp_ms` (direct ISO or `meta.timestamp`), metadata + (`session_id`/`cwd`/`title`/`updated_at`), `status` +- [x] kiro/mod.rs — `is_kiro_executable` (argv0 or node/bun script), + pool filter → relevant, canHandle dedupe → processes, per-lock + matches → agents, process-only fallback +- [x] `list_dir_names` shared helper (sorted, matching Node ≥20.1 + `readdirSync`); swapped in at every `safeReaddir` mirror site + (kiro, devin, copilot, grok, gemini, pi, codex, claude) +- [x] Register `KiroAdapter`; ported += kiro; devkitd test updated +- [x] TS capture: `.kiro/sessions/cli` index dir; live capture case +- [x] Synthetic bundles: + - `matched.json`: helper-held lock → outermost-kiro ppid walk; + direct lock; broken chain → sole-kiro-on-tty fallback; ambiguous + tty (2 kiros) → no match; orphan tty `??` → none; node-script + `kiro-cli.js` canHandle; non-kiro node proc invisible; lock + without transcript → process-only; malformed/invalid/string-pid + locks skipped; multi-lock same proc → two agents; metadata + sessionId override; meta `updated_at` precedence; mtime + fallback; waiting/running/idle statuses; lastUserMessage/title/ + default summary chain + - `fallback.json`: no sessions dir → all process-only; bun-script + kiro; cwd-less proc → `unknown-*` +- [x] Verification: 62 harness tests, workspace tests, clippy -D + warnings, agent-manager suite (1341), live daemon `ported` smoke diff --git a/docs/ai/planning/2026-10-08-feature-harness-opencode.md b/docs/ai/planning/2026-10-08-feature-harness-opencode.md new file mode 100644 index 000000000..97b19d2a8 --- /dev/null +++ b/docs/ai/planning/2026-10-08-feature-harness-opencode.md @@ -0,0 +1,37 @@ +--- +phase: planning +title: harness-opencode plan +description: Task list for I7 — reconciled post-implementation +--- + +# I7: harness-opencode plan + +- [x] opencode/locator.rs — `resolve_db_path` (+env-free `…_with`), + `open_db` readonly, `find_session_for_directory` +- [x] opencode/parser.rs — `get_session_stats` (last role, MAX + heartbeat, completed/error presence, first user text; single-error + → EMPTY semantics) +- [x] opencode/mod.rs — adapter orchestration, per-proc + session/process-only rows in pool order, `at_db` for pinned replay +- [x] Bundle format — `sqlite` field; TS materialize runs statements via + better-sqlite3; Rust materialize via rusqlite; live capture dumps + DDL + reachable session/message/part rows +- [x] XDG pinning — TS replay sets `XDG_DATA_HOME=/.local/share`; + Rust replay uses `at_db` so ambient env can't leak the real db +- [x] Register `OpenCodeAdapter`; ported += opencode; devkitd test updated +- [x] TS capture: `collectSqliteDumps` (session/message/part by proc + cwds); live capture case in fixtures.test.ts +- [x] Synthetic bundles: + - `matched.json`: session match per status — running (assistant, + no completed), waiting (assistant, completed), idle (old + heartbeat), session-with-no-messages (lastActive=time_created, + lastRole null → running, default summary); newest-session-for-dir + pick (stale older session ignored); first-user-text summary + + trim; missing-dir session → process-only; cwd-less proc → + unknown process-only + - `fallback.json`: no db at all → all procs process-only; + foreign `node` argv0 dropped +- [x] shared.ts fix — `processOnlyAgent` `cwd ?? ""` (latent + `path.basename(undefined)` crash on cwd-less procs) +- [x] Verification: Rust harness tests, workspace tests, clippy -D + warnings, agent-manager suite, live daemon `ported` smoke diff --git a/docs/ai/planning/2026-10-08-feature-harness-pi.md b/docs/ai/planning/2026-10-08-feature-harness-pi.md new file mode 100644 index 000000000..525578826 --- /dev/null +++ b/docs/ai/planning/2026-10-08-feature-harness-pi.md @@ -0,0 +1,28 @@ +--- +phase: planning +title: harness-pi plan +description: Task list for I3 — reconciled post-implementation +--- + +# I3: harness-pi plan + +- [x] pi/parser.rs: bounded head/tail fold, first-entry metadata, + role/content extraction, status reducer, filename id fallback, + unterminated final line +- [x] pi/locator.rs: encode_project_dir, filename timestamp, + per-process dir discovery, birthtime/filename candidacy window, + greedy match via shared matcher +- [x] pi/mod.rs: executable token scan, sessions.json tracker + + trusted-path gate, registry fallback, orchestration order, + process-only fallback, mapper +- [x] Register `PiAdapter` in `default_registry`; ported += pi; + daemon `agent.enriched` test updated +- [x] TS capture config: `.pi/agent/sessions` dir + + `.pi/agent/sessions.json` file; pi added to `REGISTRY_AWARE` +- [x] Synthetic bundles: `matched.json` (tracker + registry + legacy + + process-only + non-pi skip), `fallback.json` (stale-type registry + row, untrusted tracker path, entry-free session file) +- [x] Live bundle `live.json` captured (real pi process) — replay + byte-identical both sides +- [x] Full verification: cargo test/clippy workspace green, agent-manager + suite green, live daemon smoke (`ported` + real pi enrichment) diff --git a/docs/ai/planning/2026-10-08-feature-harness-port-cutover.md b/docs/ai/planning/2026-10-08-feature-harness-port-cutover.md new file mode 100644 index 000000000..e7e7b75e0 --- /dev/null +++ b/docs/ai/planning/2026-10-08-feature-harness-port-cutover.md @@ -0,0 +1,19 @@ +--- +phase: planning +title: harness-port-cutover plan +description: Task list for I12 — reconciled post-implementation +--- + +# I12: cutover — plan + +- [x] Audit `AgentManager` consumers for daemon coverage: + `commands/agent.ts` (already wired), console (via the same + manager), `channel-runner.ts` (was bare — wired + `fetchEnrichedAgents`) +- [x] Verify `agent list` parity daemon vs local on live machine — + same type set, daemon path ~600x faster per call once warm +- [x] Collect metrics: per-adapter LOC TS↔Rust, fixture counts, + parity defects found, daemon RSS/CPU, latency table +- [x] Publish the efficiency report in the implementation doc +- [x] Full verification: workspace tests + clippy, agent-manager + + cli + daemon-client builds/tests, daemon smoke diff --git a/docs/ai/planning/2026-10-08-feature-harness-port-foundation.md b/docs/ai/planning/2026-10-08-feature-harness-port-foundation.md new file mode 100644 index 000000000..07798c81c --- /dev/null +++ b/docs/ai/planning/2026-10-08-feature-harness-port-foundation.md @@ -0,0 +1,68 @@ +--- +phase: planning +title: Plan — Harness Port Foundation (I0) +description: Task breakdown for fixtures, devkit-harness, agent.enriched, client API +--- + +# Plan — Harness Port Foundation (I0) + +## Milestones + +- [x] M1: `devkit-core` wire types (`EnrichedAgent`, `EnrichedAgentsResult`) + + generated TS bindings +- [x] M2: `devkit-harness` crate (trait, SweepContext, Registry, mock test) +- [x] M3: `devkitd` serves `agent.enriched` from sweep-cached enrichment +- [x] M4: daemon-client `enrichedAgents()` + gen types wired +- [x] M5: fixture tool (`capture`/`replay`) + claude live-capture bundle + round-trips TS↔TS (live bundles gitignored — private transcripts) +- [x] M6: docs reconciled, full verification, commit + +## Task Breakdown + +### M1 — wire types + +- [ ] T1.1: `devkit-core/src/agent.rs` — `EnrichedAgent`, `EnrichedAgentsResult` + with serde camelCase (`type`, `projectPath`, `sessionId`, `lastActive`, + `sessionFilePath` renames), `TS` derive, `#[ts(type="number")]` on pid. +- [ ] T1.2: `lib.rs` `pub mod agent;`; extend `export_ts_bindings` test to + emit the new types; regen `gen/` files. + +### M2 — devkit-harness crate + +- [ ] T2.1: `rust/crates/devkit-harness/Cargo.toml` + `lib.rs` with + `SweepContext`, `HarnessAdapter` trait, `Registry{enrich, ported_types}`. +- [ ] T2.2: `MockAdapter` unit test: registry reports ported types, enrich + returns adapter output; workspace `members` already `crates/*` — verify. + +### M3 — daemon RPC + +- [ ] T3.1: `devkitd` Cargo.toml dep `devkit-harness`; `Daemon` holds + `enricher` + `enriched: RwLock`; refresh in + `apply_sweep`. +- [ ] T3.2: `server.rs` method `agent.enriched`; integration test: RPC + returns `{agents: [], ported: []}` in I0. + +### M4 — client API + +- [ ] T4.1: `gen/` new files exported via `gen/index.ts` + package index. +- [ ] T4.2: `client.ts` `enrichedAgents()` → typed result; unit test with + fake socket. + +### M5 — fixture tool + +- [ ] T5.1: `packages/agent-manager/src/fixtures/types.ts` bundle schema + + `capture.ts` (frozen Date, $FIXTURE_HOME path normalization, live mode). +- [ ] T5.2: `replay.ts` — materialize bundle to temp dir, run adapter, deep + compare; vitest `fixtures.test.ts` covering claude bundle round-trip. +- [ ] T5.3: Capture ≥1 live claude bundle into `fixtures/harness/claude/` + (sanitized); `.gitignore` check that fixtures are committed. + +### M6 — reconcile + verify + +- [ ] T6.1: Update planning/implementation/testing docs; verify fmt/clippy/ + test/nx green; commit. + +## Dependencies + +- M1 blocks M2–M4 (types). M5 independent of M2–M4. +- I1 (harness-claude) consumes M5 fixtures + M2 trait. diff --git a/docs/ai/requirements/2026-10-07-feature-rust-daemon.md b/docs/ai/requirements/2026-10-07-feature-rust-daemon.md new file mode 100644 index 000000000..23176f75d --- /dev/null +++ b/docs/ai/requirements/2026-10-07-feature-rust-daemon.md @@ -0,0 +1,98 @@ +--- +phase: requirements +title: Rust coordination daemon (ai-devkitd) +description: Always-on single-binary daemon owning coordination — state, events, locking, discovery — with thin TS clients and an event-driven console +--- + +# Requirements — ai-devkitd coordination daemon + +## Problem statement + +Every ai-devkit invocation is stateless. The CLI re-discovers agents on every +call by shelling out to `ps`/`lsof`/`pwdx` (`agent-manager/src/utils/process.ts`), +the TUI console re-runs `manager.listAgents` on a blind 3-second interval +(`cli/src/tui/console/hooks/useAgentList.ts`), each channel bridge runs its own +2-second output poller with no in-flight guard +(`cli/src/services/channel/channel-runner.ts`), and shared state lives in +read-modify-write JSON files that lose updates under concurrent agents: + +- `channel-connector/src/ChannelConfigRepository.ts` — read→mutate→write of + `~/.ai-devkit/channels.json` with no lock; a corrupt file silently returns the + empty default and can then be written back, wiping real config. +- `pi-session-tracker/extensions/index.ts` — same pattern for + `~/.pi/agent/sessions.json`; two pi agents starting together lose an entry. +- Channel bridge liveness is a PID file + `kill(pid, 0)` + (`cli/src/services/channel/channel.service.ts`) — after PID reuse, `stop` can + signal an unrelated process. + +A single always-running daemon that owns coordination (state, events, locking, +process discovery, IPC) removes the race class structurally and serves every +client from one warm cache and one event stream. + +## Decisions already made (recorded here in full) + +- **Language**: Rust, from day one. The daemon is coordination-only, so it does + not need the TS harness knowledge that would make Rust expensive; a single + static binary has no runtime drift and needs no `npm install`. +- **Scope boundary**: the daemon owns coordination facts only — pids, cwds, + commands, registries, events. Harness-specific knowledge (session-file + location, conversation parsing, model attribution) stays client-side in TS + for v1 and ports later under a golden-fixture contract. +- **ACP (Agent Client Protocol)**: parked. Not in the v1 wire protocol. +- **v1 is user-experience-complete**, not an architectural kernel: what ships + must be felt — instant commands, live agent state, reliable events, + zero-ceremony autostart — including the console becoming event-driven. + +## Goals (v1, user-felt) + +- **Instant commands**: registry/agent queries answered from a warm daemon + cache, not fresh process sweeps per invocation. +- **Live agent state**: one discovery loop inside the daemon emits + `agent.appeared` / `agent.disappeared`; the TUI console subscribes to these + events and refreshes on change instead of blind polling. +- **Reliable events**: events persist in an append-only log; subscribers ack by + sequence number and replay (`afterSeq`) after reconnect — at-least-once. +- **Race-free registries**: the daemon is the sole writer to a SQLite store; + the channels and pi-sessions registries migrate to it. +- **Zero-ceremony autostart**: any client implicitly spawns the daemon on + socket miss (tmux model). No PID files — socket existence is liveness. +- **Resilient by default**: every daemon-dependent path falls back to the + existing behavior when the daemon binary is absent — the console falls back + to interval polling, registries fall back to JSON files. + +## Console migration (in scope for v1) + +`useAgentList` gains a daemon subscription: `agent.appeared` / +`agent.disappeared` / `registry.changed` events trigger an immediate refresh, +and the blind 3s interval relaxes to a slow fallback (attribution drift still +needs `manager.listAgents`, since harness attribution is client-side). With no +daemon the hook behaves exactly as today. The user-visible change: agents +appear/disappear in the console when they actually appear/disappear, not on +the next poll tick. + +## Channel bridges (fast-follow, partially in scope) + +Bridges already write/read registry state through the daemon (absorbed in the +base work). Their 2s JSONL output polling is harness-specific parsing and stays +client-side by design. Bridge process *supervision* (daemon as parent, killing +the stale-PID class structurally) is deferred to v1.5. + +## Non-goals (v1) + +- Harness parsing/attribution inside the daemon. +- ACP northbound or southbound. +- Bridge supervision, capacity caching, task-manager store absorption (v1.5+). +- TCP/remote access, multi-user, TLS, auth tokens. +- Daemon ownership of git operations, releases, skills, agent-side hooks. + +## Success criteria + +- `cargo fmt`, `cargo clippy` (zero warnings), `cargo test` green. +- `nx lint` and the full `nx test` suite green. +- Registry writes via the daemon are serialized and survive concurrent clients + (test: 32 threads racing puts, no lost updates). +- `ai-devkit daemon status|start|stop|logs|install` works; autostart brings the + daemon up without user action in under ~3s. +- Console refreshes on daemon events when present and polls as before when not + (both paths tested). +- A daemon-free environment behaves exactly as before. diff --git a/docs/ai/requirements/2026-10-08-feature-devkitd-monorepo.md b/docs/ai/requirements/2026-10-08-feature-devkitd-monorepo.md new file mode 100644 index 000000000..8ea8b66d9 --- /dev/null +++ b/docs/ai/requirements/2026-10-08-feature-devkitd-monorepo.md @@ -0,0 +1,113 @@ +--- +phase: requirements +title: Polyglot monorepo structure — devkitd rename, devkit-core split, Nx cargo wiring +description: Restructure the repo so Rust and TypeScript are first-class citizens in one task graph; rename ai-devkitd to devkitd; split devkit-core; generate TS protocol types from Rust +--- + +# Requirements — devkitd-monorepo + +## Problem Statement + +The rust-daemon feature landed `rust/ai-devkitd` as a single crate while the +rest of the repo is an Nx-managed npm workspace. Three structural seams will +hurt as the daemon absorbs more logic (dumb-console direction, harness +knowledge port, scheduled work): + +1. **Two build graphs.** Nx has no project for the Rust workspace. The + `@ai-devkit/daemon-client` dependency on the compiled daemon binary + (`rust/target/{release,debug}/ai-devkitd`) cannot be expressed, cached, or + enforced — a stale or missing binary degrades silently to fallback behavior. +2. **Drifting wire contract.** `proto.rs` serde structs and the daemon-client + TS types are hand-mirrored in two languages; only tests keep them honest. +3. **No room to grow.** The daemon is one binary crate; the planned knowledge + port (harness parsers in Rust, golden-fixture corpus) and any future Rust + tools have no library target to build against. + +Additionally `ai-devkitd` is an awkward name (9 chars, unpronounceable); the +rename surface is small now and grows once per-platform npm binary packages +ship. + +## Goals & Objectives + +**Primary:** + +- Rename the daemon crate/binary `ai-devkitd` → `devkitd` everywhere it is + referenced (cargo, daemon-client binary resolution, CLI daemon command, docs). +- Split `rust/` into a multi-crate workspace: `crates/devkitd` (binary) and + `crates/devkit-core` (proto, store, discovery as a library). +- Wire cargo into the Nx graph via `nx:run-commands` targets (`build`, `test`, + `lint`/`clippy`, `fmt`) with proper inputs/outputs so builds cache correctly. +- Generate TypeScript protocol types from `devkit-core` via `ts-rs` into + `packages/daemon-client/src/gen/`, eliminating hand-mirrored types. + +**Secondary:** + +- Express `daemon-client:build`'s dependency on the daemon binary in Nx. +- Record the longer-term layout (`apps/` split, `devkit-harness` crates, + `fixtures/` corpus, per-platform binary packages) as decisions so follow-on + work has a target. + +**Non-goals:** + +- Moving `packages/*` under `libs/` or splitting `apps/cli` now — deferred + until the console is actually dumb. +- Moving `web/` into the workspace/Nx — it has its own lockfile and deploy. +- Harness parser port, `devkit-harness` crates, fixtures corpus (v1.5+). +- Bridge supervision, scheduler/cron in the daemon (v1.5+). +- Switching orchestrators (moonrepo/Bazel) or protocols (gRPC/protobuf). +- Renaming the npm package `@ai-devkit/daemon-client` or the socket path + (`~/.ai-devkit/daemon.sock` stays — it's already product-scoped). + +## User Stories & Use Cases + +- As a maintainer, I run `nx run-many -t build` and the Rust daemon builds in + the same task graph as TS packages, cached by content hash. +- As a maintainer, I change `proto.rs` and the generated TS types update — + the wire contract cannot drift silently. +- As a developer adding a Rust harness-parser crate later, I depend on + `devkit-core` instead of forking the daemon binary crate. +- As a user, `ai-devkit daemon status` and autostart resolve a binary named + `devkitd` transparently. + +## Success Criteria + +- `cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test` green in the + restructured workspace. +- `nx run-many -t build,test,lint` green including the new rust project(s); + rerunning `nx build rust` hits cache (Nx cache hit reported). +- `devkit-client` build/typecheck passes against generated types; no + hand-maintained copies of `Request`/`Response`/`Event` field shapes remain. +- Binary resolution order (`AI_DEVKITD_BIN` env → platform pkg → + `~/.ai-devkit/bin/` → `rust/target/...`) works with the new name; the + existing daemon integration tests pass unmodified in behavior. +- Repo still degrades gracefully when the daemon binary is absent. + +## Constraints & Assumptions + +- Keep Nx (no orchestrator migration). Cargo owns Rust compilation; Nx wraps + it via `nx:run-commands` with declared inputs (`rust/**/*.rs`, + `Cargo.toml`, `Cargo.lock`) and outputs (`rust/target`). +- `ts-rs` added as a dev-dependency of `devkit-core`; generated files are + checked in or generated pre-build — decide in design (prefer generated at + build time via a cargo test/`xtask`-style command to avoid stale checkins). +- Binary rename: `DEVKITD_BIN` becomes the primary env var; + `AI_DEVKITD_BIN` kept as a deprecated alias (pre-release feature, low + cost). +- Assumption: user wants the staged Option B from the brainstorm (crate split + + Nx wiring + ts-rs now; apps/ split and harness crates later). + +## Questions & Open Items — resolved + +- **Generated TS types**: checked into `packages/daemon-client/src/gen/`, + regenerated by `cargo test -p devkit-core` (ts-rs export bindings). Keeps + pure-TS builds free of a cargo toolchain requirement; drift is caught + because tests dirty the tree in CI. +- **Env var**: primary `DEVKITD_BIN`; `AI_DEVKITD_BIN` accepted as a + deprecated alias. The rust-daemon feature is unreleased, so no back-compat + for the binary *filename* itself. +- **cli → rust edge**: runtime binary resolution only. No `dependsOn` from + TS packages onto cargo builds; shipping uses platform binary packages, and + dev uses `rust/target`. Only `daemon-client:build` depends on the + `codegen` step, expressed via input files, not a cargo invocation. +- **Crate layout**: `rust/crates/{devkitd,devkit-core}` — matches cargo + conventions and leaves room for `devkit-harness` later. diff --git a/docs/ai/requirements/2026-10-08-feature-harness-antigravity.md b/docs/ai/requirements/2026-10-08-feature-harness-antigravity.md new file mode 100644 index 000000000..135a3c19f --- /dev/null +++ b/docs/ai/requirements/2026-10-08-feature-harness-antigravity.md @@ -0,0 +1,47 @@ +--- +phase: requirements +title: harness-antigravity requirements +description: Port AntigravityCliAdapter into devkit-harness (I10) +--- + +# I10: harness-antigravity + +Port `AntigravityCliAdapter` (487 LOC TS) into `devkit-harness`, +extending `ported` to `[..., "kiro", "antigravity_cli"]` — the final +harness in the epic. + +## In scope + +- `processNames=["agy"]`; `canHandle` = `matchesExecutable(command,"agy")` + (argv0 basename `agy`/`agy.exe`) +- `baseDir` = `ANTIGRAVITY_CLI_HOME` env or `~/.gemini/antigravity-cli`; + replay must pin/clear the env +- `cache/last_conversations.json` → `{ : }`; + non-object/array JSON → empty; empty keys, empty/non-string ids skipped +- `matchRunningProcesses`: one entry per process in process order, + `cwd = proc.cwd || ""`, Map last-wins on duplicate cwd keys +- Transcript: `brain//.system_generated/logs/transcript.jsonl`; + `stat` must succeed (a directory still counts — TS checks `!stat`, + not `isFile`); unreadable → empty summary, session kept; missing → + process-only +- Bounded JSONL fold (`fold_jsonl_bounded`): `created_at` keeps the + MAXIMUM seen (not the last line); `skip()` keeps only firstUserMessage +- `recordToMessage` (non-verbose): `USER_INPUT` → user via + `extractUserRequest` (`…` inner trim — + may be `""`; unwrapped → whole trimmed text or null); non-empty + `PLANNER_RESPONSE` → assistant; everything else → no role update +- `lastActive` = max `created_at` ?? transcript mtime +- Status: idle(5min) > lastRole assistant → waiting > running +- Summary: `lastUserMessage || "Antigravity CLI session active"` — + NO truncate (unlike other adapters) +- name from registry cwd; sessionId = conversationId; + sessionFilePath = transcript path; process-only "Antigravity CLI + process running" with the cwd override +- Capture: `.gemini/antigravity-cli` referenced-only (brain tree is + tens of MB); the index-dir rule moves to an explicit + `HARNESS_INDEX_DIRS` set — behavior-preserving for all prior dirs + +## Out of scope + +- `getConversation` (USER_INPUT/PLANNER_RESPONSE/system verbose turns, + tail), `listSessions`, `findSessionsById`, readiness/credentials. diff --git a/docs/ai/requirements/2026-10-08-feature-harness-claude.md b/docs/ai/requirements/2026-10-08-feature-harness-claude.md new file mode 100644 index 000000000..00da48312 --- /dev/null +++ b/docs/ai/requirements/2026-10-08-feature-harness-claude.md @@ -0,0 +1,50 @@ +--- +phase: requirements +title: Requirements — harness-claude (I1) +description: Port ClaudeCodeAdapter detection into devkit-harness with fixture parity +--- + +# Requirements — harness-claude (I1) + +Epic: `../planning/2026-10-08-epic-harness-port.md`. First real adapter port. + +## Problem Statement + +`detectAgents` for claude (process→session attribution, transcript-derived +status/summary) runs per-CLI-invocation in TS. Port it into `devkitd` so +`agent.list` serves claude agents; client consumes them for `claude` +and falls back to local adapters for all other types. + +## Goals + +- Port the full detect path: `findHarnessProcesses` semantics, three-stage + locator (resume / PID-file / legacy cwd+birthtime greedy match), + head+tail JSONL summary with skip semantics, mapper + (pidStatus ?? JSONL status, summary, name, projectPath). +- Extend daemon discovery to enrich agent pids with `cwd` (lsof batch on + macOS, /proc on Linux) and `start_time_ms` (`ps lstart` batch) — required + inputs for matching. +- `agent.list` returns real claude agents. +- AgentManager consumes `agent.list` when the daemon answers, local + adapters otherwise — parity verified by fixtures. +- Synthetic committed fixture cases + the local live bundle must replay + byte-identical through the Rust adapter. + +## Non-goals + +- `getConversation`, `listSessions`, `findSessionsById` (TS-only still). +- Incremental parse caching (cold-start semantics suffice; perf check at + cutover). +- `readiness.ts`, `credentials.ts` — not on the detect path. + +## Success Criteria + +- Synthetic fixtures cover: resume match, pid-file match (+stale rejection), + legacy greedy match, process-only fallback, each status mapping + (running/waiting/idle/unknown + interrupted), first-vs-last user message, + noise-message filtering, command-message extraction. +- Rust adapter output == TS expected on every committed bundle AND on the + local live bundle. +- `ai-devkit agent list` output for claude agents identical daemon-path vs + local-path. +- All existing suites green; cargo suite green. diff --git a/docs/ai/requirements/2026-10-08-feature-harness-codex.md b/docs/ai/requirements/2026-10-08-feature-harness-codex.md new file mode 100644 index 000000000..52b9e11f1 --- /dev/null +++ b/docs/ai/requirements/2026-10-08-feature-harness-codex.md @@ -0,0 +1,41 @@ +--- +phase: requirements +title: harness-codex requirements +description: Port CodexAdapter into devkit-harness (I2) +--- + +# I2: harness-codex + +Port `CodexAdapter` (~1.4k LOC TS) into `devkit-harness`, extending +`ported` to `["claude","codex"]`. + +## In scope + +- `canHandle`: `codex` executable minus helper subcommands (clap-accurate + positional/flag parsing incl. variadic `-i/--image`, app-server-daemon path + exclusion) +- Session mapping: `~/.codex/ai-devkit/sessions.json` pid→filePath, trusted + under sessionsDir +- Registry cache: pid→sessionFilePath rows read from the shared + `~/.ai-devkit/agents.db` (SQLite, readonly) — mirrors + `mapRegistryCache`/`AgentRegistry.list()`; replay fixtures seed an + isolated db so real registry state can't leak in +- Locator: resume (`resume `), uuidv7→date-dir window, session_meta + head parse (64KiB, truncated-JSON regex fallback), legacy greedy matching + over day-dir session files, 30s negative cache for unmatched processes +- Parser: bounded head(1MiB)/tail(4MiB) cold scan; first line must be + session_meta; lastEntryTimestamp/lastPayloadType normalized; + summary = last displayable text (truncate 120) +- Status: idle(5min) > waiting(agent_message|task_complete|turn_aborted) > running +- Mapper + process-only fallback ("Codex process running") + +## Out of scope + +`getConversation`, `listSessions`, `findSessionsById`, `fileToSessionSummary` +(conversation mirror-dedup, tail reader) — conversation APIs stay TS. + +## Success criteria + +- Synthetic bundles replay byte-identical TS↔Rust +- Live `agent list` shows codex rows from the daemon +- Existing suites green diff --git a/docs/ai/requirements/2026-10-08-feature-harness-copilot.md b/docs/ai/requirements/2026-10-08-feature-harness-copilot.md new file mode 100644 index 000000000..a40d909e0 --- /dev/null +++ b/docs/ai/requirements/2026-10-08-feature-harness-copilot.md @@ -0,0 +1,42 @@ +--- +phase: requirements +title: harness-copilot requirements +description: Port CopilotAdapter into devkit-harness (I5) +--- + +# I5: harness-copilot + +Port `CopilotAdapter` (~760 LOC TS incl. parser/locator/mapper) into +`devkit-harness`, extending `ported` to +`["claude","codex","pi","gemini_cli","copilot"]`. + +## In scope + +- `canHandle`: `matchesExecutable(command, "copilot")` — argv0 basename + `copilot`/`copilot.exe`; `processNames=["copilot"]` pool filter applies + inside detect (`copilot-helper` argv0 never reaches canHandle) +- Locks: `~/.copilot/session-state//inuse..lock` — + `scanLocks` enumerates session dirs whose mtime >= earliest unresolved + process start − 5min slack (all dirs when any unresolved start is + unknown); `knownLocks` per-(pid,startTime) cache re-validates with one + stat, evicts on start-time change, remembers only unambiguous locks of + procs with known start times +- `readSessionDirIncremental` cold-read: bounded head(1MiB)+tail(4MiB) + fold of `events.jsonl` with `skip()` semantics, tentative last line +- `workspace.yaml` naive `key: scalar` fallback metadata (id/cwd/name/ + created_at/updated_at, quote stripping) +- Session gate: no parseable entries AND no workspace metadata → null +- Summary: firstUserMessage || lastText || workspace.name || + "Copilot session active", truncated 120 +- Status: idle(5min) > waiting(lastEventType in assistant.message / + assistant.turn_end / session.shutdown / abort) > running +- Wrapper fold: parent in the copilot pool sharing terminal suppressed; + wrapper's own registry row (type=copilot) lends its name +- Process-only fallback: "Copilot process running" +- `sessionFilePath` always `/events.jsonl`, even when the file is + absent (workspace-only sessions) + +## Out of scope + +- `getConversation`, `listSessions`, `findSessionsById`, `sessionStart` + (only feeds toSessionSummary), `readiness.ts`. diff --git a/docs/ai/requirements/2026-10-08-feature-harness-devin.md b/docs/ai/requirements/2026-10-08-feature-harness-devin.md new file mode 100644 index 000000000..b2d5e07bf --- /dev/null +++ b/docs/ai/requirements/2026-10-08-feature-harness-devin.md @@ -0,0 +1,59 @@ +--- +phase: requirements +title: harness-devin requirements +description: Port DevinAdapter into devkit-harness (I8) +--- + +# I8: harness-devin + +Port `DevinAdapter` (~860 LOC TS incl. credentials/readiness) into +`devkit-harness`, extending `ported` to +`["claude","codex","pi","gemini_cli","copilot","grok_cli","opencode","devin"]`. + +## In scope + +- `canHandle`: argv0 `devin`/`devin.exe` AND `firstPositionalToken` is + absent or not a utility subcommand (20 utilities incl. `list`, `ssh`, + `doctor`; `acp` deliberately allowed); flag-value skipping for + required (`--prompt-file`/`--config`/`--permission-mode`/`--model`) + and optional (`-p`/`--print`/`--resume`/...) flags; `--flag=value` + never skips the next token; `--` ends the scan +- `processNames=["devin"]` pool filter + first-wins pid dedupe +- `resolveDbPath`: `XDG_DATA_HOME` or `~/.local/share` + + `devin/cli/sessions.db`; readonly open; missing → all process-only + (locks still read) +- `session_locks/*.lock` next to the db: filename = session slug, + body = `parseInt(trim)` holder pid (>0); holder must be a live + canHandle'd devin proc; maps holder pid AND holder's ppid → slug; + readdir order, later wins +- `isAcpBackendChild`: positional `acp` + ppid present + ppid in the + deduped pool → proc skipped entirely (no agent row) +- Session resolution per proc: slug → `sessions.id` (`hidden = 0`); + else cwd → newest `last_activity_at` row for `working_directory` + (`hidden = 0`); slug hit-miss does NOT fall back to cwd +- `toDevinSession`: `created_at`/`last_activity_at` are epoch SECONDS → + `* 1000`; `title` nullable +- `getSessionStats` (outer try/catch → EMPTY): frontier `chat_message` + role (highest node_id), `MAX(created_at)*1000` heartbeat, + `getLastUserPrompt` summary +- `userPrompt(DESC)`: `prompt_history` newest `is_shell=0` non-`/%` + content trimmed (truncate 120); fallback `message_nodes` newest-8 + `role='user'` nodes → first parsed user msg with + `metadata.is_user_input !== false` and non-empty string content +- `mapSessionToAgent`: name/projectPath `session.directory || proc.cwd`; + lastActive = lastTimeUpdated or `lastActivityAt || timeCreated`; + summary `stats.summary || title || "Devin session active"`; + status idle(5min) > assistant→waiting > running; + `sessionFilePath` = `::` +- Process-only: "Devin process running" +- Bundle: `sqlite` section (sessions/message_nodes/prompt_history); + `session_locks` dir as `home` files (index-style — all locks read); + bounded live dump = frontier + max-created + 8 user nodes + 1 prompt + per session (exact reachability) + +## Out of scope + +- `getConversation` (node-dedupe transcript + verbose tool/thinking), + `listSessions`/`findSessionsById`, `credentials.ts` (API key/org + resolution for API calls), `readiness.ts`, `decodeDevinSessionRef` + consumers. diff --git a/docs/ai/requirements/2026-10-08-feature-harness-gemini.md b/docs/ai/requirements/2026-10-08-feature-harness-gemini.md new file mode 100644 index 000000000..2a7cc9b5b --- /dev/null +++ b/docs/ai/requirements/2026-10-08-feature-harness-gemini.md @@ -0,0 +1,41 @@ +--- +phase: requirements +title: harness-gemini requirements +description: Port GeminiCliAdapter into devkit-harness (I4) +--- + +# I4: harness-gemini + +Port `GeminiCliAdapter` (~1.1k LOC TS) into `devkit-harness`, extending +`ported` to `["claude","codex","pi","gemini_cli"]`. + +## In scope + +- `canHandle`: any whitespace token whose basename is + `gemini`/`gemini.exe`/`gemini.js` (node-script distribution puts it in + argv[1..]); the `processNames=["node"]` pool filter applies inside detect — + a bare `gemini` argv0 proc is dropped before canHandle +- Registry cache: `agents.db` rows (`type='gemini_cli'`, non-empty + sessionFilePath that parses), skipped for wrapper pids; wrapper's own + registry row lends its `name` to the child's agent +- Locator: `~/.gemini/tmp//chats/session-*.{json,jsonl}` where + `` is `sha256(projectRoot)` (legacy hash dir) or a slug gated by + a `.project_root` marker; candidate roots = every ancestor of each + proc's cwd; mtime window = earliest start − tolerance − 2s slack; + `session-*.json` shadowed by a same-name `.jsonl` is skipped +- Metadata: 8KiB head; `.jsonl` takes line 1 (regex-picked prefix when the + line exceeds the head), `.json` parses the head, regex, then full file +- Parser: `.jsonl` log replay (`id` upserts, `$set` merges, `$rewindTo` + truncates) and `.json` document; summary = last user text + (displayContent > content, Part[] joined, truncate 120); status + idle(5min) > waiting(gemini/assistant last) > running +- Wrappers: parent in same terminal folded into child (no own row) +- Dedup: one agent per session file/id, highest pid wins in place +- Process-only fallback: "Gemini CLI process running" +- Daemon sweep: `node`/`bun` runtime names added so node-script harnesses + reach adapters; non-claimed runtime procs stay out of the visible pool + (mirrors `isCandidateProcess`) + +## Out of scope + +- `getConversation`, `listSessions`/`findSessionsById`, TUI surfaces. diff --git a/docs/ai/requirements/2026-10-08-feature-harness-grok.md b/docs/ai/requirements/2026-10-08-feature-harness-grok.md new file mode 100644 index 000000000..c7c7aeff4 --- /dev/null +++ b/docs/ai/requirements/2026-10-08-feature-harness-grok.md @@ -0,0 +1,44 @@ +--- +phase: requirements +title: harness-grok requirements +description: Port GrokCliAdapter into devkit-harness (I6) +--- + +# I6: harness-grok + +Port `GrokCliAdapter` (~590 LOC TS incl. parser/locator/mapper) into +`devkit-harness`, extending `ported` to +`["claude","codex","pi","gemini_cli","copilot","grok_cli"]`. + +## In scope + +- `canHandle`: `matchesExecutable(command, "grok")` — argv0 basename + `grok`/`grok.exe`; `processNames=["grok"]` pool filter inside detect +- `~/.grok/active_sessions.json` (`GROK_HOME` env overrides `~/.grok`): + `[{pid, cwd, opened_at}]` → pid→cwd map; only numeric pid + non-empty + string cwd entries; proc cwd is the fallback, "" the final default +- `latestSessionDir(cwd)`: group dir `sessions//` + must exist; among session subdirs the one whose `chat_history.jsonl` + has the max mtime wins (strict `>`, readdir order for ties) +- `readSessionIncremental` cold read: bounded head+tail fold of + chat_history.jsonl; missing transcript → no session; present-but-empty + transcript still surfaces a session (mtime is `lastActive`) +- Records `{type, content}`: `user` records only count when the text wraps + a real prompt in `…` (context injections + skipped); `assistant` needs non-empty flattened text; `system` skipped + (verbose=false); skip() keeps first prompt, clears last prompt/role +- Summary: `lastUserMessage || "Grok CLI session active"` (untruncated) +- Status: idle(5min) > lastRole==="assistant" (waiting) > running +- Process-only fallback: "Grok CLI process running" with the resolved cwd + (active_sessions value, proc cwd, or "") +- `sessionId` = session dir basename; `sessionFilePath` = chat_history path +- No registry, no wrapper folding — Grok surfaces every claimed process +- Bundle format: `mtimes` map (epoch ms | "$NOW") so mtime-derived output + (lastActive, latest-session pick) replays deterministically; live + capture records real file mtimes alongside content + +## Out of scope + +- `getConversation`, `listSessions`/`findSessionsById` (historical group + decode incl. `.cwd` files), `sessionStart`, summary cache incremental + append path (cold bounded fold is output-identical). diff --git a/docs/ai/requirements/2026-10-08-feature-harness-kiro.md b/docs/ai/requirements/2026-10-08-feature-harness-kiro.md new file mode 100644 index 000000000..6065b9a4c --- /dev/null +++ b/docs/ai/requirements/2026-10-08-feature-harness-kiro.md @@ -0,0 +1,51 @@ +--- +phase: requirements +title: harness-kiro requirements +description: Port KiroAdapter into devkit-harness (I9) +--- + +# I9: harness-kiro + +Port `KiroAdapter` (696 LOC TS) into `devkit-harness`, extending `ported` +to `[..., "devin", "kiro"]`. + +## In scope + +- `canHandle` = `isKiroExecutable`: argv0 basename (backslash→'/', + lowercase, `.exe`/`.js` stripped) is `kiro-cli`/`kiro`, OR argv0 is a + script runtime (`node`/`bun`) whose first non-flag token resolves via + `executablePath` to a `kiro-cli`/`kiro` basename +- `processNames` pool `["kiro-cli","kiro","kiro-cli-chat","node","bun"]` + → `relevant` snapshot (helpers stay in — the ppid walk needs them); + `processes` = canHandle'd, first-wins pid dedupe +- `~/.kiro/sessions/cli/.lock` files (sorted readdir order): JSON + `{pid}` — `toPid` accepts safe positive integers (numeric OR + all-digits string); malformed JSON/non-object/bad pid skipped +- Lock → proc resolution: `findKiroAncestor` walks the holder's ppid + chain through `relevant`; the outermost isKiro proc wins. Fallback + `matchSoleProcessOnTty`: holder's tty (`?`/`??`/empty → none) matches + exactly one proc in `processes` +- One proc may hold several locks → several agent rows; lock order = + sorted readdir order; matched agents precede process-only rows +- `.jsonl` must exist or the match is skipped (proc falls back to + process-only); unreadable transcript → empty summary, session kept +- `.json` metadata: `session_id`/`sessionId` (first non-empty + string), `cwd` (`||` → proc cwd fallback), `title`, + `created_at`/`createdAt`, `updated_at`/`updatedAt` +- Bounded JSONL fold (`fold_jsonl_bounded`): last event kind, + `AssistantMessage && has toolUse` flag, first/last timestamps + (`entry.timestamp` ISO string or `data.meta.timestamp` epoch s/ms), + first/last non-empty Prompt text (skip() preserves head fields) +- `lastActive` = `updated_at` ?? last entry ts ?? transcript mtime +- Status: idle(5min) > `AssistantMessage && !toolUse` → waiting > running +- Summary: `lastUserMessage || title || "Kiro session active"`, trunc 120 +- `sessionFilePath` = `.jsonl` path; sessionId = metadata's (filename + stem fallback); name from projectPath; process-only "Kiro process + running" +- Fixture dirs: `.kiro/sessions/cli` index-style capture (locks + json + + jsonl all read during detect) + +## Out of scope + +- `getConversation` (Prompt/Assistant/ToolResults messages, verbose, + tail), `listSessions`, `findSessionsById`, readiness/credentials. diff --git a/docs/ai/requirements/2026-10-08-feature-harness-opencode.md b/docs/ai/requirements/2026-10-08-feature-harness-opencode.md new file mode 100644 index 000000000..5c9ac73d8 --- /dev/null +++ b/docs/ai/requirements/2026-10-08-feature-harness-opencode.md @@ -0,0 +1,50 @@ +--- +phase: requirements +title: harness-opencode requirements +description: Port OpenCodeAdapter into devkit-harness (I7) +--- + +# I7: harness-opencode + +Port `OpenCodeAdapter` (~500 LOC TS incl. parser/locator/mapper) into +`devkit-harness`, extending `ported` to +`["claude","codex","pi","gemini_cli","copilot","grok_cli","opencode"]`. + +## In scope + +- `canHandle`: `matchesExecutable(command, "opencode")` — argv0 basename + `opencode`/`opencode.exe`; `processNames=["opencode"]` pool filter + inside detect; pid dedupe first-wins +- `resolveDbPath`: `XDG_DATA_HOME` (empty string falls back, JS `||`) or + `~/.local/share`, plus `opencode/opencode.db`; db opened readonly; + missing/unopenable → every proc maps process-only +- `findSessionForDirectory`: newest `session` row for the proc's cwd + (`WHERE directory = ? ORDER BY time_created DESC LIMIT 1`); no cwd or + no row → process-only +- `getSessionStats` (single try/catch → EMPTY_STATS on any error): + - last message by `time_created DESC` → `json_extract(data,'$.role')` + - `lastTimeUpdated` = `MAX(time_updated)` over session messages, + falling back to the last message's own `time_updated`, else 0 + - last assistant message → `time.completed`/`time.error` presence + (SQL NULL = absent; `!= null` JS semantics) + - first user `part` with `type='text'` joined on its message → + `text.trim()` as summary; a non-string text value throws `.trim()` + in JS → EMPTY_STATS +- `mapSessionToAgent`: name/projectPath from `session.directory || + proc.cwd`; `lastActive` = stats.lastTimeUpdated or session.timeCreated; + summary default "OpenCode session active"; `sessionId` = row id; + `sessionFilePath` = `::` +- `determineStatus`: idle(5min) > lastRole==="assistant" && !completed → + running > lastRole==="assistant" → waiting > running; + `lastAssistantErrored` is fetched but unused (mirrored) +- Process-only fallback: "OpenCode process running" +- No registry, no wrappers, no file trees — SQLite is the only store +- Bundle format: `sqlite` map — `{relpath: [sql statements]}` executed in + order at materialization (binary dbs can't travel in `home`); capture + dumps session/message/part DDL + rows reachable via captured proc cwds + +## Out of scope + +- `getConversation` (part-row transcript with verbose reasoning/tool + parts, tail slicing), `listSessions`, `findSessionsById`, + `decodeSessionRef` consumers — detection path only. diff --git a/docs/ai/requirements/2026-10-08-feature-harness-pi.md b/docs/ai/requirements/2026-10-08-feature-harness-pi.md new file mode 100644 index 000000000..b282ff57e --- /dev/null +++ b/docs/ai/requirements/2026-10-08-feature-harness-pi.md @@ -0,0 +1,35 @@ +--- +phase: requirements +title: harness-pi requirements +description: Port PiAdapter into devkit-harness (I3) +--- + +# I3: harness-pi + +Port `PiAdapter` (~1.6k LOC TS) into `devkit-harness`, extending +`ported` to `["claude","codex","pi"]`. + +## In scope + +- `canHandle`: whitespace-token scan for a bare `pi`/`pi.js`/`pi.exe` + executable token (command may be `node /pi.js`, argv-mangled, etc.) +- Session tracker: `~/.pi/agent/sessions.json` pid(string)→session file + path; only paths under the trusted sessions root + `~/.pi/agent/sessions` are honored +- Registry fallback: `agents.db` rows (`type='pi'`, non-stale, session + file must exist) — same `shared::registry_session_paths` readonly + SQLite read introduced in I2 +- Locator: scan per-project dirs + `~/.pi/agent/sessions//*.jsonl` for each unmatched + process; candidacy via file birthtime OR filename timestamp + (`YYYY-MM-DDTHH-MM-SS-sssZ_*.jsonl`); final match is greedy by + `|start − birthtime| ≤ 3min` with `resolvedCwd === proc.cwd` +- Parser: bounded head parse; first-entry metadata (sessionId, cwd) + preserved; head/tail reduce to `{status, summary, lastActive}`; + filename-derived session id fallback; unterminated final line handled +- Status: idle(>5min) > waiting(last entry assistant/finalized) > running +- Process-only fallback: "Pi process running" + +## Out of scope + +- Conversation detail (`getConversation`), `pi print` mode, TUI changes. diff --git a/docs/ai/requirements/2026-10-08-feature-harness-port-cutover.md b/docs/ai/requirements/2026-10-08-feature-harness-port-cutover.md new file mode 100644 index 000000000..037c338ee --- /dev/null +++ b/docs/ai/requirements/2026-10-08-feature-harness-port-cutover.md @@ -0,0 +1,25 @@ +--- +phase: requirements +title: harness-port-cutover requirements +description: I12 — daemon-primary cutover + efficiency report +--- + +# I12: cutover + efficiency report + +## In scope + +- Every `AgentManager` consumer goes through `fetchEnrichedAgents` + (daemon `agent.list`) — `channel-runner.ts` was the last + local-only callsite. +- With all ten types ported, `portedTypes` covers the whole adapter + set: a reachable daemon means zero local `detectAgents` work; an + unreachable daemon degrades to the full local path (capability + kept, preference flipped). +- Readiness cutover landed in I11 (`status` daemon-primary). +- Publish the per-iteration metrics table (LOC, fixtures, parity + defects, daemon overhead, latency). + +## Out of scope + +- Removing the local adapter path — it remains the fallback. +- `getConversation`/`findSessionsById` etc. (epic-level out-of-scope). diff --git a/docs/ai/requirements/2026-10-08-feature-harness-port-foundation.md b/docs/ai/requirements/2026-10-08-feature-harness-port-foundation.md new file mode 100644 index 000000000..44f435c1b --- /dev/null +++ b/docs/ai/requirements/2026-10-08-feature-harness-port-foundation.md @@ -0,0 +1,114 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Foundation for the harness knowledge port — fixtures, wire contract, devkit-harness crate, TS fallback +--- + +# Requirements — Harness Port Foundation (I0) + +Epic: `docs/ai/planning/2026-10-08-epic-harness-port.md` +Iteration: I0 of the harness-port epic. + +## Problem Statement + +`ai-devkit agent list`, the console TUI, and every other consumer of agent +state run the same ~11k LOC TypeScript attribution/enrichment pipeline +(`agent-manager` harness adapters): scan processes, match each to a session +file, parse harness-specific JSONL transcripts, derive status/summary/cwd. + +Every CLI invocation repeats all of it; every console instance duplicates it; +two consoles can render different views of the same agent. The daemon exists +but is "coordination-only": its `agent.list` returns raw `ps` rows that no +client consumes. + +The epic goal is daemon-owned enriched agent state. I0 builds only the +foundation that every subsequent per-harness iteration needs — no adapter is +ported in this iteration. + +## Goals & Objectives + +Primary: +- A parity oracle: a fixture tool that records `{process snapshot + session + files} → AgentInfo[]` from the current TS adapters, usable as + byte-for-byte test expectations for the Rust port. +- A `devkit-harness` Rust crate with an adapter trait mirroring + `canHandle`/`detectAgents`, plus the wire schema for enriched agents. +- An `agent.list` RPC on `devkitd` returning `AgentInfo[]` for the + harness types it has ported (initially none/empty), including which types + it covers so clients can fall back per-harness. +- `daemon-client` consumes `agent.list` for ported types and falls back + to local adapters for the rest — incremental cutover, never a break. + +Secondary: +- ts-rs generated TS types for the enriched schema (same pipeline as + `Request`/`Response`/`Event`). +- Metrics scaffolding: record Rust LOC / fixture counts per iteration for + the epic's I12 efficiency report. + +Non-goals: +- Porting any harness adapter (I1+). +- `getConversation`, `findSessionsById`, historical session listing, resume + commands, `resolveHerdrPanes` — stay TS-side; revisit at cutover. +- Cron/scheduler work in the daemon. +- Changing `agent.list` (raw rows stay for cheap consumers). + +## User Stories & Use Cases + +- As the Rust porter, I want recorded adapter outputs for known inputs so + that parity is a test assertion, not an eyeball. +- As a CLI/console developer, I want one RPC that returns ready-to-render + `AgentInfo[]` so that rendering logic lives nowhere in the client. +- As an operator mid-epic, I want unported harnesses to keep working through + the TS path so that partial rollout is invisible. +- As the daemon, I want to enrich once per discovery sweep so that N clients + never multiply parse work. + +Edge cases: +- Daemon returns `ported: []` (I0) → clients use the full local path; no + behavior change. +- Daemon absent/binary missing → same local fallback as today. +- `agent.list` errors mid-call → fall back for the whole call, not + per-agent. + +## Success Criteria + +- Fixture tool emits deterministic JSON bundles and round-trips TS↔TS + identically. Live-captured bundles stay local-only (gitignored — real + transcripts are private); committed synthetic fixtures are authored + per-harness in I1+. +- `devkit-harness` compiles with the adapter trait + `EnrichedAgent` type; + `cargo test` includes a trait-level smoke test. +- `agent.list` round-trips over the socket: response contains + `{agents: AgentInfo[], ported: string[]}`; `ported` empty in I0. +- Generated `packages/daemon-client/src/gen/EnrichedAgent.ts` (etc.) exists + and `client.enrichedAgents()` returns it. +- `listAgents` path unchanged end-to-end: cli/agent-manager/console tests + green with zero modification. +- `cargo fmt/clippy/test`, `nx run-many -t lint,test,build` all green. + +## Constraints & Assumptions + +- Wire schema must mirror `AgentInfo` field-for-field (names, optionality, + JSON casing) — clients diff-compare; renaming is out of scope. +- Enrichment is cached per discovery sweep (daemon already sweeps every 2s); + we do not re-parse JSONL per RPC call. +- Fixtures live in `fixtures/harness/` at repo root and are committed. +- ts-rs numeric fields stay `number` on the wire (established in + devkitd-monorepo work). +- Assumption: session-file parsing is filesystem-only and safe to run inside + the daemon process — no adapter does network calls during `detectAgents`. + (Verified per adapter in I0 design.) + +## Questions & Open Items + +- Where `EnrichedAgent` schema lives: `devkit-core` (wire types) vs + `devkit-harness` (domain types) — decide in design; leaning devkit-core so + devkitd never needs the harness crate just for the RPC type. +- `readiness/` (~515 LOC) is a shared lib, not a harness — fold its port into + the first adapter that needs it (I1 claude likely). Confirmed: it is not a + standalone adapter. +- Fixture determinism: `lastActive: new Date()` in adapters → freeze clock + in the capture tool; file mtimes normalized to recorded values. +- Agent registry merge (names, pinned, prune) — stays client-side or moves + to daemon? Decide in design; leaning daemon-side since the registry is + already daemon-owned state. diff --git a/docs/ai/testing/2026-10-07-feature-rust-daemon.md b/docs/ai/testing/2026-10-07-feature-rust-daemon.md new file mode 100644 index 000000000..b5fe9feab --- /dev/null +++ b/docs/ai/testing/2026-10-07-feature-rust-daemon.md @@ -0,0 +1,69 @@ +--- +phase: testing +title: ai-devkitd test coverage +description: What is tested, how, and what is verified live vs deferred +--- + +# Testing — ai-devkitd v1 + +## Rust unit tests (`cargo test`) + +`store.rs`: +- `registry_roundtrip` — put/get-all/get-one/delete semantics incl. JSON value + fidelity and null-on-missing. +- `events_are_ordered_and_replayable` — monotonic seq, `events_after` replay. +- `snapshot_diff_reports_appear_and_vanish` — appear/vanish diff across sweeps. +- `concurrent_sole_writer` — N threads racing registry puts on the same key + space; all writes land, no lost updates (the property the JSON files lacked). +- `events_survive_reopen` — log persists across store reopen. + +`proto.rs`: +- request/response serde roundtrip, missing-params handling, event frame shape + (`{"event":{seq,ts,kind,payload}}`) — the wire contract clients depend on. + +`discover.rs`: +- golden `ps` output fixture parsed through the same filter logic: agent + binaries kept, noise dropped, basename extraction across paths/args. + +`server.rs`: +- end-to-end: real daemon on a temp socket — connect, registry put, subscribe, + assert replayed + live `registry.changed` frames arrive in order. + +## TS tests (`vitest`) + +`daemon-client`: +- binary resolution: env override honored, missing paths ignored. +- socket path under `~/.ai-devkit`. +- integration: `ensureDaemon` spawns the real binary (when resolvable via + `DEVKITD_BIN`, or the legacy `AI_DEVKITD_BIN` alias) and returns a working client; `tryConnect` returns null on + a missing socket (fallback contract); request/response roundtrip over a real + unix socket. + +`channel-connector` (existing suite, unchanged expectations): custom +`configPath` ⇒ file semantics — daemon path never engaged in tests. + +`cli` console migration: +- `agentListSubscription.test.ts` — the seam is unit-tested directly: no-daemon + → null (caller keeps polling); subscribed → events forwarded + client + returned; subscribe failure → client closed, null returned. +- Hermeticity: `ensureDaemon` never spawns under `VITEST` unless + `DEVKITD_BIN` is explicitly set. +- Note: `PreviewActivityIndicator.test.tsx` is dead — vitest's include glob + covers `*.test.ts` only; .tsx tests never run. Recorded, not fixed here. + +## Live-verified (not automated) + +`daemon start` autospawn, `status`, `logs -n`, registry roundtrip over socket, +`agent.list` finding a real pi process, subscribe replay + live events. + +## Gates run + +`cargo fmt`, `cargo clippy` (zero warnings), `cargo test`; `nx run-many -t lint`; +`nx run-many -t test` (all 7 projects). + +## Known gaps (honest) + +- No test for the SO_PEERCRED path (needs a second uid; manual/CI-later). +- pi-session-tracker socket path exercised only by fallback tests + live use. +- Autostart thundering-herd (N concurrent first-callers) covered by lockfile + design; load test not automated. diff --git a/docs/ai/testing/2026-10-08-feature-devkitd-monorepo.md b/docs/ai/testing/2026-10-08-feature-devkitd-monorepo.md new file mode 100644 index 000000000..e824fb2bb --- /dev/null +++ b/docs/ai/testing/2026-10-08-feature-devkitd-monorepo.md @@ -0,0 +1,83 @@ +--- +phase: testing +title: Testing Strategy — devkitd-monorepo +description: Coverage for crate split, devkitd rename, ts-rs generated types, Nx cargo targets +--- + +# Testing Strategy — devkitd-monorepo + +## Test Coverage Goals + +- All existing Rust unit/integration tests must pass **unmodified in + behavior** after the crate split (they move, they don't change semantics). +- All existing daemon-client vitest suites pass after the rename; new + coverage only where behavior changed (env var resolution, binary names). +- Target: 100% of changed code paths exercised; this feature is refactor + + config, so existing tests are the primary regression net. + +## Unit Tests + +### devkit-core (moved code, `rust/crates/devkit-core/`) + +- [x] proto tests: request parse, response shape, event frame — unchanged +- [x] store tests: registry roundtrip, event replay, snapshot diff, + concurrent writer, reopen persistence — unchanged +- [x] discover tests: ps fixture parse, basename matching, malformed + lines — unchanged +- [x] `export_bindings` test emits `Event`, `Request`, `Response` TS files + into `packages/daemon-client/src/gen/` with expected field names + +### devkitd (binary crate) + +- [x] server tests: end-to-end rpc/subscribe/replay, malformed line — + unchanged after move +- [x] `devkitd` usage line prints `devkitd [serve|install|status]` +- [x] systemd unit file written as `devkitd.service` with correct ExecStart + +### daemon-client (TS) + +- [x] `resolveDaemonBinary` prefers `DEVKITD_BIN`, falls back to + `AI_DEVKITD_BIN` alias, then platform pkg `devkitd--`, then + `~/.ai-devkit/bin/devkitd`, then `rust/target/*/devkitd` +- [x] generated `Event` type is assignable where `DaemonEvent` was used +- [x] autostart still refuses spawn under `VITEST` without binary env + +## Integration Tests + +- [x] `packages/daemon-client` integration test compiles/connects against a + real `devkitd` binary when env points at it (unchanged test, new name) +- [x] `nx run rust:test` runs the full cargo suite through Nx +- [x] `nx run rust:lint` runs clippy with `-D warnings` +- [x] Second `nx run rust:build` is skipped/cached when inputs unchanged + +## End-to-End Tests + +- [x] `ai-devkit daemon status/start/stop` work against a spawned `devkitd` +- [x] `e2e/cli.e2e.ts` passes (daemon-dependent paths fall back cleanly when + no binary is present in the test env) +- [x] Console subscription path unchanged (registry/agent events still flow) + +## Test Data + +- Existing `PS_FIXTURE` golden fixture (moved with discover.rs) +- ts-rs generated snapshots = the gen/*.ts files themselves (checked in; + regeneration must be byte-stable) + +## Test Reporting & Coverage + +- Rust: `cargo test --workspace`, `cargo clippy --workspace --all-targets -- + -D warnings`, `cargo fmt --all -- --check` +- TS: `nx run-many -t test,lint` across packages +- Full: `npm run build` + `npm run test:e2e` + +## Results (2026-10-08) + +- devkit-core: 13 tests pass incl. `export_ts_bindings` (generates gen/*.ts + deterministically via CARGO_MANIFEST_DIR-anchored path). +- devkitd: 2 integration tests pass (rpc/subscribe/replay, malformed input). +- daemon-client: 9 pass incl. real `devkitd` spawn roundtrip; dual-env + resolution covered by 3 new cases. +- `nx run rust:test/lint/fmt:check` green; `nx run-many -t build` includes + `rust:build`. +- e2e: 42/42. `cli:test` flaky timeouts in `status.service.test.ts` are + pre-existing (pass standalone, both branches). diff --git a/docs/ai/testing/2026-10-08-feature-harness-antigravity.md b/docs/ai/testing/2026-10-08-feature-harness-antigravity.md new file mode 100644 index 000000000..74f0a004a --- /dev/null +++ b/docs/ai/testing/2026-10-08-feature-harness-antigravity.md @@ -0,0 +1,39 @@ +--- +phase: testing +title: harness-antigravity test report +description: Coverage and results for the I10 antigravity port +--- + +# I10: harness-antigravity testing + +## Fixture parity (the oracle) + +Two bundles replay byte-identical TS↔Rust (`vitest -t antigravity`, +`cargo test antigravity_adapter_matches`): + +- `matched.json` (8 agents) — waiting (last record PLANNER_RESPONSE), + running (USER_INPUT last while MAX `created_at` sits on an earlier + line), idle (no timestamps → mtime fallback via `mtimes`); + ``-wrapped extraction vs unwrapped prompt; `content` + as string and as `[{text}]` blocks; registry pointing at a missing + transcript → process-only; unregistered cwd → process-only; empty + cwd key, empty id, and non-string id registry rows skipped; + `agy.exe` accepted by the pool (`.exe` normalization); `agy --verbose` + canHandle; `notagy` invisible; untruncated `lastUserMessage` summary. +- `fallback.json` (2 agents) — malformed `last_conversations.json` → + all process-only; cwd-less → `unknown-`. + +## Cross-adapter regression check + +The `.exe` normalization fix to `matches_executable_name` changes pool +membership only by widening it (`*.exe` procs now enter `relevant`, +matching TS); all committed fixtures for the other nine adapters still +replay byte-identical (63 harness tests green). + +## Results + +- `cargo test --workspace`: 81 tests green; `clippy -D warnings` clean. +- `vitest` agent-manager: 1343 passed. +- Live daemon smoke: `ported` = + `["claude","codex","pi","gemini_cli","copilot","grok_cli","opencode", + "devin","kiro","antigravity_cli"]` — the full set. diff --git a/docs/ai/testing/2026-10-08-feature-harness-claude.md b/docs/ai/testing/2026-10-08-feature-harness-claude.md new file mode 100644 index 000000000..f84570abe --- /dev/null +++ b/docs/ai/testing/2026-10-08-feature-harness-claude.md @@ -0,0 +1,52 @@ +--- +phase: testing +title: harness-claude test report +description: Coverage and results for the I1 claude port +--- + +# I1: harness-claude testing + +## Results (all green) + +| Suite | Result | +|---|---| +| `cargo test --workspace` | 33 passed (15 core, 15 harness, 3 daemon) | +| `cargo clippy --workspace -D warnings` | clean | +| `fixtures.test.ts` (vitest) | 3 passed, 1 skipped | +| `AgentManager.test.ts` | 65 passed (incl. 5 new merge-path tests) | +| Live smoke | `agent list` auto-spawned devkitd; `enrichedAgents()` returned `ported: ["claude"]` + 4 real agents matching local-rendered rows | +| Typecheck | agent-manager, cli, daemon-client `tsc --noEmit` clean | + +## Parity oracle + +Bundles `fixtures/harness/claude/{matched,statuses}.json` replay byte-identical +under **both** the TS fixture harness and the Rust `fixtures::assert_parity` +test; the gitignored `live.json` (real machine capture, 4 agents) also matches +on both sides. + +Coverage proven by the bundles: + +- resume matching via `--resume ` → session JSONL +- PID-file matching incl. `waitingFor` summary suffix and status override +- stale PID-file rejection (>60s start delta) → legacy fallback +- legacy greedy cwd+birthtime matching (3min window) +- process-only fallback (`pid-` session id, running status) +- running / waiting / idle / unknown status mappings +- noise filtering (`[Request interrupted`, `Tool loaded.`, continued-session) +- first-user-message summary + command-message argument extraction +- `$NOW` sentinel → deterministic birthtime for legacy matching on replay + +## Merge-path unit tests (AgentManager) + +- ported type → daemon row used, local adapter `detectAgents` never called +- unported type → local adapter still runs alongside daemon agents +- `fetchEnrichedAgents` → `null` → all-local +- `fetchEnrichedAgents` throws → all-local +- daemon rows with `type` not in `ported` are dropped + +## Known gaps (accepted for I1) + +- No incremental JSONL parse caching — cold head+tail scan each sweep. +- `getConversation`/session-list APIs unported. +- CLI output equivalence daemon-vs-local is asserted via fixture parity plus + live smoke, not an automated A/B harness test. diff --git a/docs/ai/testing/2026-10-08-feature-harness-codex.md b/docs/ai/testing/2026-10-08-feature-harness-codex.md new file mode 100644 index 000000000..77da05383 --- /dev/null +++ b/docs/ai/testing/2026-10-08-feature-harness-codex.md @@ -0,0 +1,36 @@ +--- +phase: testing +title: harness-codex test report +description: Coverage and results for the I2 codex port +--- + +# I2: harness-codex testing + +## Fixture parity (the oracle) + +- Synthetic bundles `matched.json` + `statuses.json` replay + byte-identical on both sides: + `vitest src/__tests__/fixtures.test.ts` and + `cargo test -p devkit-harness codex_adapter_matches_committed_fixtures`. +- Live bundle (`live.json`, gitignored; 12 real codex agents) replays + byte-identical on both sides — it caught four real bugs (registry + leak, post-detect snapshot, missing mapping file, `$TODAY` scope). + +## Unit coverage (Rust) + +- `is_helper_command`: every helper subcommand + app-server-daemon path; + interactive `codex|resume|fork|exec|review` kept; flag/positional + parsing incl. variadic `--image` and `--` stop. +- `to_pid_json`: digits-only, >0. +- Parser: session_meta required, running vs waiting status payloads. +- `registry_session_paths`: readonly open, missing db → empty. + +## Daemon + +- `agent.enriched` returns `ported: ["claude","codex"]`. +- `AgentManager` multi-ported merge test (both locals skipped). + +## Suites + +- `cargo test --workspace` / `cargo clippy --workspace -- -D warnings`. +- agent-manager vitest incl. fixtures + AgentManager merge. diff --git a/docs/ai/testing/2026-10-08-feature-harness-copilot.md b/docs/ai/testing/2026-10-08-feature-harness-copilot.md new file mode 100644 index 000000000..8b20136d1 --- /dev/null +++ b/docs/ai/testing/2026-10-08-feature-harness-copilot.md @@ -0,0 +1,43 @@ +--- +phase: testing +title: harness-copilot test report +description: Coverage and results for the I5 copilot port +--- + +# I5: harness-copilot testing + +## Fixture parity (the oracle) + +Three committed bundles replay byte-identical TS↔Rust +(`vitest -t copilot` and `cargo test copilot_adapter_matches`): + +- `matched.json` — events.jsonl lock match (sessionId/projectPath from + `session.start`, waiting on assistant.message); second `inuse..lock` in an empty dir (null session, skipped); dead-pid lock + ignored; copilot wrapper pair (parent suppressed, child process-only + takes registry name `wrapped-copilot`); plain process-only; + `node server.js` and `copilot-helper` argv0 dropped by the pool filter. +- `fallback.json` — events session with last event `user.message` → + running; lock into a metadata-free dir → process-only; wrong-type + (`pi`) registry row doesn't apply; child of a non-copilot parent still + surfaces (wrappers are searched in the copilot pool only). +- `workspace.json` — workspace.yaml-only session (no events.jsonl): + sessionId/cwd/name/updated_at from yaml scalars → idle status; + `sessionFilePath` still points at the absent events.jsonl. + +## Unit coverage (Rust) + +- `lock_pid`: `inuse..lock` accepted; empty/non-digit/suffixed + names rejected. +- Parser: event fold (session.start override, summary = first user + message, waiting→idle boundary), workspace-only session, empty dir → + None, `extract_event_text` chain (content > message > text > + result.content > result.detailedContent; non-string truthy → ""). + +## Results + +- `cargo test --workspace`: 74 tests green; `clippy -D warnings` clean. +- `vitest` agent-manager: 1332 passed. +- Live daemon smoke: `ported: ["claude","codex","pi","gemini_cli","copilot"]`. +- No live copilot process at port time — live-bundle capture case is in + place for a future machine with one running. diff --git a/docs/ai/testing/2026-10-08-feature-harness-devin.md b/docs/ai/testing/2026-10-08-feature-harness-devin.md new file mode 100644 index 000000000..93fee2866 --- /dev/null +++ b/docs/ai/testing/2026-10-08-feature-harness-devin.md @@ -0,0 +1,43 @@ +--- +phase: testing +title: harness-devin test report +description: Coverage and results for the I8 devin port +--- + +# I8: harness-devin testing + +## Fixture parity (the oracle) + +Three bundles replay byte-identical TS↔Rust (`vitest -t devin`, +`cargo test devin_adapter_matches`): + +- `matched.json` — lock-held session via backend-child ppid (TUI gets + the agent, `devin acp` child emits none); standalone `devin acp` with + ppid outside the snapshot kept as agent; dead-pid + malformed locks + skipped; slug precedence over cwd (projectPath = + session.directory `/proj/elsewhere`); `hidden=1` excluded; + newest-`last_activity_at`-for-dir pick; summary sources — + prompt_history (with `is_shell`/`/%` filters) → node scan → title; + utility subcommands (`list`, `ssh`, `--config x list`) dropped; + `-p`/`--print` flag-value skipping keeps `devin -p update` an agent. +- `fallback.json` — locks present but db missing → all procs + process-only; acp child still skipped; cwd-less → `unknown-`. +- `live.json` — 2 real devin agents (one waiting TUI, one idle); 47 + bounded SQL statements (frontier + max-created + user-8 + prompt-1 + per session); 4 real lock files. + +## Coverage notes + +- Live capture for devin dumps `sessions` by proc-cwd ∪ lock-slug and + bounded reachable `message_nodes`/`prompt_history` — verified on the + real 4-session db (97MB → <1MB after bounding; `= MAX()` timestamp + collisions fixed via `ORDER BY created_at DESC LIMIT 1`). +- `fixtures.rs` now parses float `mtimes` (live `mtimeMs` is a float) — + also fixes live grok/pi/copilot captures on future runs. + +## Results + +- `cargo test --workspace`: 79 tests green; `clippy -D warnings` clean. +- `vitest` agent-manager: 1339 passed. +- Live daemon smoke: `ported` includes `devin` + (`[…,"opencode","devin"]`, 18 live agents — 2 real devin). diff --git a/docs/ai/testing/2026-10-08-feature-harness-gemini.md b/docs/ai/testing/2026-10-08-feature-harness-gemini.md new file mode 100644 index 000000000..658cbcc8f --- /dev/null +++ b/docs/ai/testing/2026-10-08-feature-harness-gemini.md @@ -0,0 +1,48 @@ +--- +phase: testing +title: harness-gemini test report +description: Coverage and results for the I4 gemini port +--- + +# I4: harness-gemini testing + +## Fixture parity (the oracle) + +- Synthetic bundles `matched.json` + `fallback.json` replay + byte-identical on both sides: + `vitest src/__tests__/fixtures.test.ts -t gemini_cli` and + `cargo test -p devkit-harness gemini_adapter_matches_committed_fixtures`. +- `matched.json` covers: hash-dir legacy match (40001), registry-cache + hit (40002), wrapper suppression + registry-name override on a + process-only child (40010/40011), plain process-only (40020), + non-gemini `node` proc dropped (40030), bare `gemini` argv0 dropped by + the node pool (40040), foreign hash dir skipped (b660bf…), and + marker-gated slug dir skipped (`mystery-slug` → `/elsewhere`). +- `fallback.json` covers: missing registry session file, wrong-type + registry row, session file lacking `sessionId` (parse → null), + wrapper-named process-only. +- Live bundle `live.json` (gitignored): 45 node procs swept, 1 real + gemini agent (process-only) — replays byte-identical both sides. + +## Unit coverage (Rust) + +- `is_gemini_executable`: bare/path/node-script/`.exe`; `geminis` and + `node server.js` rejected. +- `deduplicate`: higher pid replaces in place. +- Parser: upsert+$rewindTo+$set replay, legacy `.json`, missing + `sessionId` → None. +- Locator: candidate roots walk to `/`, sha256 vs node crypto, + `matchStringField` escapes/spacing/non-string, `.json`-shadowed-by + `.jsonl` listing. + +## Daemon (live smoke) + +- `agent.enriched`: `ported: ["claude","codex","pi","gemini_cli"]`, + 16 agents, 0 live gemini (none running). +- `agent.list`: 21 rows, zero argv0=node rows — the visibility gate + keeps unclaimed runtime procs out despite the widened sweep. + +## Results + +- `cargo test --workspace`: 68 tests green; `clippy -D warnings` clean. +- `vitest` agent-manager: 1329 passed. diff --git a/docs/ai/testing/2026-10-08-feature-harness-grok.md b/docs/ai/testing/2026-10-08-feature-harness-grok.md new file mode 100644 index 000000000..b25aa96cd --- /dev/null +++ b/docs/ai/testing/2026-10-08-feature-harness-grok.md @@ -0,0 +1,38 @@ +--- +phase: testing +title: harness-grok test report +description: Coverage and results for the I6 grok port +--- + +# I6: harness-grok testing + +## Fixture parity (the oracle) + +Two committed bundles replay byte-identical TS↔Rust +(`vitest -t grok` and `cargo test grok_adapter_matches`): + +- `matched.json` — active_sessions.json cwd override (proc.cwd ignored); + two session dirs in one group → max-mtime pick (`mtimes` map); + `` extraction with a `` context record skipped; + array-of-blocks assistant content flattened; waiting + running + statuses; process-only fallback; `node`/`grok-helper` argv0 drops. +- `fallback.json` — group dir without transcript → process-only; + missing proc cwd → `unknown-`/`""` projectPath; empty transcript + still yields a session ("Grok CLI session active"); old chat mtime → + idle. + +## Unit coverage (Rust) + +- `extract_user_query`: trimmed inner, empty tag → "" message, unclosed + tag and non-user_query wrappers → None. +- `to_message`: user/assistant/array-content/non-object/system(quiet) + cases. + +## Results + +- `cargo test --workspace`: 77 tests green; `clippy -D warnings` clean. +- `vitest` agent-manager: 1334 passed (earlier run flaked 4 files under + 940s contention; clean re-run). +- Live daemon smoke: `ported` includes `grok_cli`. +- No live grok process or `~/.grok` tree on this machine — live capture + case is in place for a machine that has one. diff --git a/docs/ai/testing/2026-10-08-feature-harness-kiro.md b/docs/ai/testing/2026-10-08-feature-harness-kiro.md new file mode 100644 index 000000000..5d1dc6ed1 --- /dev/null +++ b/docs/ai/testing/2026-10-08-feature-harness-kiro.md @@ -0,0 +1,39 @@ +--- +phase: testing +title: harness-kiro test report +description: Coverage and results for the I9 kiro port +--- + +# I9: harness-kiro testing + +## Fixture parity (the oracle) + +Two bundles replay byte-identical TS↔Rust (`vitest -t kiro`, +`cargo test kiro_adapter_matches`): + +- `matched.json` (9 agents) — lock→outermost-ancestor via `kiro-cli-chat` + holder; direct lock; broken ppid chain → sole-kiro-tty fallback; two + kiros on one tty → lock unclaimed; `??` tty holder → none; + `node /x/kiro-cli.js` script canHandle; `node /x/other.js` invisible; + lock without `.jsonl` → process-only; malformed `{pid:` and + `{"pid":"abc"}` locks skipped; string `"1101"` pid → second agent on + the same proc; metadata `session_id` override + empty `cwd` → proc-cwd + fallback; `updated_at` precedence, `meta.timestamp` epoch and direct + ISO `timestamp` forms, mtime fallback; waiting/running/idle; + lastUserMessage/title/`"Kiro session active"` summary chain. +- `fallback.json` (3 agents) — no sessions dir → all process-only; bun + script kiro; cwd-less proc → `unknown-`. + +## Cross-adapter regression check + +`list_dir_names` sorts all `safeReaddir` mirror sites — every committed +fixture bundle for all 9 ported adapters still replays byte-identical +(61+1 harness tests green), confirming the sort only fixes latent +ordering exposure (it surfaced first via kiro's lock-ordered output). + +## Results + +- `cargo test --workspace`: 80 tests green; `clippy -D warnings` clean. +- `vitest` agent-manager: 1341 passed. +- Live daemon smoke: `ported` includes `kiro` (no live kiro procs on the + machine — `agents` kiro count 0, expected). diff --git a/docs/ai/testing/2026-10-08-feature-harness-opencode.md b/docs/ai/testing/2026-10-08-feature-harness-opencode.md new file mode 100644 index 000000000..d72e84448 --- /dev/null +++ b/docs/ai/testing/2026-10-08-feature-harness-opencode.md @@ -0,0 +1,41 @@ +--- +phase: testing +title: harness-opencode test report +description: Coverage and results for the I7 opencode port +--- + +# I7: harness-opencode testing + +## Fixture parity (the oracle) + +Two committed bundles replay byte-identical TS↔Rust +(`vitest -t opencode` and `cargo test opencode_adapter_matches`): + +- `matched.json` — session match per status: running (assistant without + `time.completed`), waiting (assistant completed), idle (60min-old + heartbeat), session with zero messages (heartbeat NULL → + lastActive=timeCreated, lastRole null → running, default summary); + newest-session-for-directory pick (older session for the same dir + ignored); first-user-text summary + whitespace trim; tool-type parts + don't pollute the summary; no-session directory → process-only; + cwd-less proc → `unknown-` process-only. +- `fallback.json` — no `sqlite` section → db missing → every proc + process-only; foreign `node` argv0 dropped by the pool filter. + +## Coverage notes + +- The `sqlite` bundle section itself is exercised: DDL + INSERTs + materialize a real opencode.db under the fixture home on both replay + sides. +- Live capture (`AI_DEVKIT_FIXTURE_CAPTURE=1` opencode case) dumps the + real `sqlite_master` DDL + rows reachable via proc cwds; no live + opencode process was running on this machine, so only the synthetic + bundles run in CI. + +## Results + +- `cargo test --workspace`: 78 tests green; `clippy -D warnings` clean. +- `vitest` agent-manager: 1336 passed. +- Live daemon smoke: `ported` includes `opencode` + (`["claude","codex","pi","gemini_cli","copilot","grok_cli","opencode"]`, + 16 live agents, none opencode on this machine). diff --git a/docs/ai/testing/2026-10-08-feature-harness-pi.md b/docs/ai/testing/2026-10-08-feature-harness-pi.md new file mode 100644 index 000000000..abae301e2 --- /dev/null +++ b/docs/ai/testing/2026-10-08-feature-harness-pi.md @@ -0,0 +1,45 @@ +--- +phase: testing +title: harness-pi test report +description: Coverage and results for the I3 pi port +--- + +# I3: harness-pi testing + +## Fixture parity (the oracle) + +- Synthetic bundles `matched.json` + `fallback.json` replay + byte-identical on both sides: + `vitest src/__tests__/fixtures.test.ts -t pi` and + `cargo test -p devkit-harness pi_adapter_matches_committed_fixtures`. +- `matched.json` covers: tracker mapping (30001), registry fallback + (30002), legacy locator match via `$NOW` start (30003), process-only + (30004), and a non-pi `pilot` command correctly ignored (30005). +- `fallback.json` covers: stale cross-type registry row (rejected), + untrusted tracker path outside sessions root (rejected), entry-free + session file (parse-fail → process-only). +- Live bundle `live.json` (gitignored, real pi process pid 28617) + replays byte-identical on both sides — 1 pi agent with real session + metadata. + +## Unit coverage (Rust) + +- `is_pi_executable`: `pi`, `pi.js`, `pi.exe`, `node /pi.js`, + `pilot` rejected. +- `to_pid`: digits-only, >0. +- `encode_project_dir` / `file_name_timestamp_ms`. +- Parser: empty file → None; role/type-field filtering; assistant tail + → waiting; filename session-id fallback; head fields sticky to first + entry. + +## Daemon (live smoke) + +- `ensureDaemon` + `agent.enriched` on the real socket: + `ported: ["claude","codex","pi"]`, 16 agents, the live pi process + enriched with real sessionId, projectPath + (`/Users/…/CodexBar`), summary `"which model are you"`, status idle. + +## Results + +- `cargo test --workspace`: 58 tests green; `clippy -D warnings` clean. +- `vitest` agent-manager: 1326 passed. diff --git a/docs/ai/testing/2026-10-08-feature-harness-port-cutover.md b/docs/ai/testing/2026-10-08-feature-harness-port-cutover.md new file mode 100644 index 000000000..83b2e6044 --- /dev/null +++ b/docs/ai/testing/2026-10-08-feature-harness-port-cutover.md @@ -0,0 +1,35 @@ +--- +phase: testing +title: harness-port-cutover test report +description: Coverage and results for the I12 cutover +--- + +# I12: cutover — testing + +## Coverage + +- Consumer audit: all `new AgentManager` sites — + `commands/agent.ts` (had `fetchEnrichedAgents`), console (receives + that manager), `channel-runner.ts` (wired in this iteration). +- `agent list --json` run twice on the live machine — once with + `AI_DEVKIT_NO_DAEMON=1` (pure local) and once with the daemon up. + Same type set (`claude`, `codex`, `devin`, `pi` + durable rows); + count differs only because processes churn between the two calls. +- `status` command exercised in both modes — daemon-primary and + daemon-down fallback produce identical output. +- Latency measurement: warm `enrichedAgents` RPC 0.4ms vs local + `listAgents` 238ms; daemon sweep cost moved off the call path. + +## Results + +- `cargo test --workspace`: 82 tests green; clippy `-D warnings`. +- `vitest` agent-manager: 1345 passed | cli: 1320 passed (1 known + unrelated TUI timing flake, passes standalone). +- devkitd live smoke: `agent.enriched` 18 agents, `ported` all ten, + `agent.readiness` 10 reports. + +## Epic status after I12 + +All milestones I0–I12 complete. The daemon owns discovery, enrichment, +event history, and readiness; TS adapters persist only as the +documented fallback. diff --git a/docs/ai/testing/2026-10-08-feature-harness-port-foundation.md b/docs/ai/testing/2026-10-08-feature-harness-port-foundation.md new file mode 100644 index 000000000..73c17c993 --- /dev/null +++ b/docs/ai/testing/2026-10-08-feature-harness-port-foundation.md @@ -0,0 +1,55 @@ +--- +phase: testing +title: Testing — Harness Port Foundation (I0) +description: Scenarios for fixtures, devkit-harness, agent.enriched, client API +--- + +# Testing — Harness Port Foundation (I0) + +## Unit Tests + +### devkit-core + +- [ ] `EnrichedAgent` serializes with TS-matching field names (camelCase, + `type` key, ISO `lastActive`, `number` pid) +- [ ] optional fields (`pinned`, `sessionFilePath`) omitted when absent +- [ ] `export_ts_bindings` emits `EnrichedAgent.ts` + result type + +### devkit-harness + +- [ ] `Registry::ported_types` reflects registered adapters +- [ ] `enrich` aggregates `detect` output across adapters +- [ ] adapter not matching `can_handle` contributes nothing + +### devkitd + +- [ ] `agent.enriched` returns `{agents: [], ported: []}` with no adapters +- [ ] sweep refreshes cached result (registry + enriched both update) + +### daemon-client + +- [ ] `enrichedAgents()` returns typed `{agents, ported}` from fake socket +- [ ] generated `EnrichedAgent` type assignable from wire JSON + +## Integration Tests + +- [ ] socket round-trip: `agent.enriched` over real `devkitd` binary +- [ ] `agent.list` unchanged (raw rows still served) + +## Fixture tool + +- [ ] capture emits deterministic bundle (frozen Date → stable `lastActive`) +- [ ] `$FIXTURE_HOME` normalization: no absolute user paths in bundle +- [ ] replay: claude adapter output == `expected` for captured bundle +- [ ] replay detects drift (mutate `expected` → test fails) + +## Regression + +- [ ] `listAgents` path untouched: cli + agent-manager + console suites + green unmodified +- [ ] `cargo fmt --check`, `clippy -D warnings`, `nx run-many -t lint,test,build` + +## Test Data + +- `fixtures/harness/claude/*.json` — live-captured, path-sanitized +- Mock processes/session files for deterministic unit cases diff --git a/fixtures/harness/antigravity_cli/fallback.json b/fixtures/harness/antigravity_cli/fallback.json new file mode 100644 index 000000000..411beb003 --- /dev/null +++ b/fixtures/harness/antigravity_cli/fallback.json @@ -0,0 +1,55 @@ +{ + "adapter": "antigravity_cli", + "capturedAt": "2025-10-09T12:20:00.000Z", + "frozenNow": 1760012400000, + "processes": [ + { + "pid": 3101, + "ppid": 900, + "tty": "ttys010", + "command": "agy", + "cwd": "/proj/f1", + "startTime": "2025-10-09T11:00:00.000Z" + }, + { + "pid": 3102, + "ppid": 900, + "tty": "ttys011", + "command": "agy -v", + "startTime": "2025-10-09T11:10:00.000Z" + }, + { + "pid": 3103, + "ppid": 900, + "tty": "ttys012", + "command": "other-tool", + "cwd": "/proj/noise", + "startTime": "2025-10-09T11:20:00.000Z" + } + ], + "home": { + ".gemini/antigravity-cli/cache/last_conversations.json": "not json {" + }, + "expected": [ + { + "name": "f1-3101", + "type": "antigravity_cli", + "status": "running", + "summary": "Antigravity CLI process running", + "pid": 3101, + "projectPath": "/proj/f1", + "sessionId": "pid-3101", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "unknown-3102", + "type": "antigravity_cli", + "status": "running", + "summary": "Antigravity CLI process running", + "pid": 3102, + "projectPath": "", + "sessionId": "pid-3102", + "lastActive": "2025-10-09T12:20:00.000Z" + } + ] +} diff --git a/fixtures/harness/antigravity_cli/matched.json b/fixtures/harness/antigravity_cli/matched.json new file mode 100644 index 000000000..80e68c9a3 --- /dev/null +++ b/fixtures/harness/antigravity_cli/matched.json @@ -0,0 +1,173 @@ +{ + "adapter": "antigravity_cli", + "capturedAt": "2025-10-09T12:20:00.000Z", + "frozenNow": 1760012400000, + "processes": [ + { + "pid": 3001, + "ppid": 900, + "tty": "ttys001", + "command": "agy", + "cwd": "/proj/alpha", + "startTime": "2025-10-09T11:00:00.000Z" + }, + { + "pid": 3002, + "ppid": 900, + "tty": "ttys002", + "command": "agy", + "cwd": "/proj/beta", + "startTime": "2025-10-09T11:05:00.000Z" + }, + { + "pid": 3003, + "ppid": 900, + "tty": "ttys003", + "command": "/usr/local/bin/agy", + "cwd": "/proj/idle", + "startTime": "2025-10-09T11:10:00.000Z" + }, + { + "pid": 3004, + "ppid": 900, + "tty": "ttys004", + "command": "agy", + "cwd": "/proj/missing", + "startTime": "2025-10-09T11:15:00.000Z" + }, + { + "pid": 3005, + "ppid": 900, + "tty": "ttys005", + "command": "agy", + "cwd": "/proj/noreg", + "startTime": "2025-10-09T11:20:00.000Z" + }, + { + "pid": 3006, + "ppid": 900, + "tty": "ttys006", + "command": "agy", + "startTime": "2025-10-09T11:25:00.000Z" + }, + { + "pid": 3007, + "ppid": 900, + "tty": "ttys007", + "command": "agy --verbose --model x", + "cwd": "/proj/args", + "startTime": "2025-10-09T11:30:00.000Z" + }, + { + "pid": 3008, + "ppid": 900, + "tty": "ttys008", + "command": "agy.exe", + "cwd": "/proj/win", + "startTime": "2025-10-09T11:35:00.000Z" + }, + { + "pid": 3009, + "ppid": 900, + "tty": "ttys009", + "command": "notagy", + "cwd": "/proj/noise", + "startTime": "2025-10-09T11:40:00.000Z" + } + ], + "home": { + ".gemini/antigravity-cli/cache/last_conversations.json": "{\n \"/proj/alpha\": \"conv-alpha\",\n \"/proj/beta\": \"conv-beta\",\n \"/proj/idle\": \"conv-idle\",\n \"/proj/missing\": \"conv-missing\",\n \"\": \"conv-empty\",\n \"/proj/noid\": \"\",\n \"/proj/num\": 42\n}", + ".gemini/antigravity-cli/brain/conv-alpha/.system_generated/logs/transcript.jsonl": "{\"source\":\"user\",\"type\":\"USER_INPUT\",\"created_at\":\"2025-10-09T12:15:00.000Z\",\"content\":[{\"text\":\"task:\\n\\n fix the alpha bug\\n\"}]}\n{\"source\":\"model\",\"type\":\"PLANNER_RESPONSE\",\"created_at\":\"2025-10-09T12:16:00.000Z\",\"content\":\"on it\"}\n{\"source\":\"user\",\"type\":\"USER_INPUT\",\"created_at\":\"2025-10-09T12:18:00.000Z\",\"content\":\"run it again\"}\n{\"source\":\"model\",\"type\":\"PLANNER_RESPONSE\",\"created_at\":\"2025-10-09T12:19:00.000Z\",\"content\":[{\"text\":\"I will look at the wrapper logic now.\"}]}\n", + ".gemini/antigravity-cli/brain/conv-beta/.system_generated/logs/transcript.jsonl": "{\"source\":\"model\",\"type\":\"PLANNER_RESPONSE\",\"created_at\":\"2025-10-09T12:19:45.000Z\",\"content\":\"done earlier\"}\n{\"source\":\"user\",\"type\":\"USER_INPUT\",\"created_at\":\"2025-10-09T12:19:30.000Z\",\"content\":\"ship beta\"}\n", + ".gemini/antigravity-cli/brain/conv-idle/.system_generated/logs/transcript.jsonl": "{\"source\":\"model\",\"type\":\"PLANNER_RESPONSE\",\"content\":\"old stuff\"}\n", + ".gemini/antigravity-cli/brain/conv-empty/.system_generated/logs/transcript.jsonl": "{\"type\":\"USER_INPUT\",\"content\":\"unreachable\"}\n" + }, + "mtimes": { + ".gemini/antigravity-cli/brain/conv-idle/.system_generated/logs/transcript.jsonl": 1760012040000 + }, + "expected": [ + { + "name": "alpha-3001", + "type": "antigravity_cli", + "status": "waiting", + "summary": "run it again", + "pid": 3001, + "projectPath": "/proj/alpha", + "sessionId": "conv-alpha", + "lastActive": "2025-10-09T12:19:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.gemini/antigravity-cli/brain/conv-alpha/.system_generated/logs/transcript.jsonl" + }, + { + "name": "beta-3002", + "type": "antigravity_cli", + "status": "running", + "summary": "ship beta", + "pid": 3002, + "projectPath": "/proj/beta", + "sessionId": "conv-beta", + "lastActive": "2025-10-09T12:19:45.000Z", + "sessionFilePath": "$FIXTURE_HOME/.gemini/antigravity-cli/brain/conv-beta/.system_generated/logs/transcript.jsonl" + }, + { + "name": "idle-3003", + "type": "antigravity_cli", + "status": "idle", + "summary": "Antigravity CLI session active", + "pid": 3003, + "projectPath": "/proj/idle", + "sessionId": "conv-idle", + "lastActive": "2025-10-09T12:14:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.gemini/antigravity-cli/brain/conv-idle/.system_generated/logs/transcript.jsonl" + }, + { + "name": "missing-3004", + "type": "antigravity_cli", + "status": "running", + "summary": "Antigravity CLI process running", + "pid": 3004, + "projectPath": "/proj/missing", + "sessionId": "pid-3004", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "noreg-3005", + "type": "antigravity_cli", + "status": "running", + "summary": "Antigravity CLI process running", + "pid": 3005, + "projectPath": "/proj/noreg", + "sessionId": "pid-3005", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "unknown-3006", + "type": "antigravity_cli", + "status": "running", + "summary": "Antigravity CLI process running", + "pid": 3006, + "projectPath": "", + "sessionId": "pid-3006", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "args-3007", + "type": "antigravity_cli", + "status": "running", + "summary": "Antigravity CLI process running", + "pid": 3007, + "projectPath": "/proj/args", + "sessionId": "pid-3007", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "win-3008", + "type": "antigravity_cli", + "status": "running", + "summary": "Antigravity CLI process running", + "pid": 3008, + "projectPath": "/proj/win", + "sessionId": "pid-3008", + "lastActive": "2025-10-09T12:20:00.000Z" + } + ] +} diff --git a/fixtures/harness/claude/matched.json b/fixtures/harness/claude/matched.json new file mode 100644 index 000000000..12d3eba3e --- /dev/null +++ b/fixtures/harness/claude/matched.json @@ -0,0 +1,71 @@ +{ + "adapter": "claude", + "capturedAt": "synthetic", + "frozenNow": 1760000400000, + "processes": [ + { + "pid": 1001, + "ppid": 1, + "tty": "pts/0", + "command": "/usr/bin/claude --resume 11111111-2222-3333-4444-555566667777", + "cwd": "/proj/alpha", + "startTime": "2025-10-09T08:58:00.000Z" + }, + { + "pid": 1002, + "ppid": 1, + "tty": "pts/1", + "command": "claude", + "cwd": "/proj/beta", + "startTime": "$NOW" + }, + { + "pid": 1004, + "ppid": 1, + "tty": "pts/3", + "command": "claude", + "cwd": "/proj/delta", + "startTime": "2025-10-09T08:58:00.000Z" + } + ], + "home": { + ".claude/sessions/1001.json": "{\"pid\":1001,\"sessionId\":\"11111111-2222-3333-4444-555566667777\",\"cwd\":\"/proj/alpha\",\"startedAt\":1760000280500,\"kind\":\"cli\",\"entrypoint\":\"cli\",\"status\":\"waiting\",\"waitingFor\":\"approve Edit\"}", + ".claude/sessions/1002.json": "{\"pid\":1002,\"sessionId\":\"deadbeef-0000-0000-0000-000000000000\",\"cwd\":\"/elsewhere\",\"startedAt\":1759996280000,\"kind\":\"cli\",\"entrypoint\":\"cli\",\"status\":\"idle\"}", + ".claude/projects/-proj-alpha/11111111-2222-3333-4444-555566667777.jsonl": "{\"type\":\"user\",\"timestamp\":\"2025-10-09T08:58:30.000Z\",\"cwd\":\"/proj/alpha\",\"message\":{\"content\":\"review the diff\"}}\n{\"type\":\"assistant\",\"timestamp\":\"2025-10-09T08:59:00.000Z\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"done\"}]}}\n", + ".claude/projects/-proj-beta/b2222222-2222-3333-4444-555566667777.jsonl": "{\"type\":\"user\",\"timestamp\":\"2025-10-09T08:55:00.000Z\",\"cwd\":\"/proj/beta\",\"message\":{\"content\":\"write tests\"}}\n{\"type\":\"progress\",\"timestamp\":\"2025-10-09T08:55:30.000Z\"}\n" + }, + "expected": [ + { + "name": "alpha-1001", + "type": "claude", + "status": "waiting", + "summary": "review the diff \u2014 waiting for approve Edit", + "pid": 1001, + "projectPath": "/proj/alpha", + "sessionId": "11111111-2222-3333-4444-555566667777", + "lastActive": "2025-10-09T08:59:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.claude/projects/-proj-alpha/11111111-2222-3333-4444-555566667777.jsonl" + }, + { + "name": "beta-1002", + "type": "claude", + "status": "running", + "summary": "write tests", + "pid": 1002, + "projectPath": "/proj/beta", + "sessionId": "b2222222-2222-3333-4444-555566667777", + "lastActive": "2025-10-09T08:55:30.000Z", + "sessionFilePath": "$FIXTURE_HOME/.claude/projects/-proj-beta/b2222222-2222-3333-4444-555566667777.jsonl" + }, + { + "name": "delta-1004", + "type": "claude", + "status": "running", + "summary": "Claude Code process running", + "pid": 1004, + "projectPath": "/proj/delta", + "sessionId": "pid-1004", + "lastActive": "2025-10-09T09:00:00.000Z" + } + ] +} \ No newline at end of file diff --git a/fixtures/harness/claude/statuses.json b/fixtures/harness/claude/statuses.json new file mode 100644 index 000000000..76c1a230c --- /dev/null +++ b/fixtures/harness/claude/statuses.json @@ -0,0 +1,116 @@ +{ + "adapter": "claude", + "capturedAt": "synthetic", + "frozenNow": 1760000400000, + "processes": [ + { + "pid": 2001, + "ppid": 1, + "tty": "pts/0", + "command": "claude", + "cwd": "/w/a", + "startTime": "2025-10-09T08:58:00.000Z" + }, + { + "pid": 2002, + "ppid": 1, + "tty": "pts/1", + "command": "claude", + "cwd": "/w/b", + "startTime": "2025-10-09T08:58:00.000Z" + }, + { + "pid": 2003, + "ppid": 1, + "tty": "pts/2", + "command": "claude", + "cwd": "/w/c", + "startTime": "2025-10-09T08:58:00.000Z" + }, + { + "pid": 2004, + "ppid": 1, + "tty": "pts/3", + "command": "claude", + "cwd": "/w/d", + "startTime": "2025-10-09T08:58:00.000Z" + }, + { + "pid": 2005, + "ppid": 1, + "tty": "pts/4", + "command": "claude", + "cwd": "/w/e", + "startTime": "2025-10-09T08:58:00.000Z" + } + ], + "home": { + ".claude/sessions/2001.json": "{\"pid\":2001,\"sessionId\":\"sess-a\",\"cwd\":\"/w/a\",\"startedAt\":1760000280000,\"kind\":\"cli\",\"entrypoint\":\"cli\"}", + ".claude/sessions/2002.json": "{\"pid\":2002,\"sessionId\":\"sess-b\",\"cwd\":\"/w/b\",\"startedAt\":1760000280000,\"kind\":\"cli\",\"entrypoint\":\"cli\"}", + ".claude/sessions/2003.json": "{\"pid\":2003,\"sessionId\":\"sess-c\",\"cwd\":\"/w/c\",\"startedAt\":1760000280000,\"kind\":\"cli\",\"entrypoint\":\"cli\"}", + ".claude/sessions/2004.json": "{\"pid\":2004,\"sessionId\":\"sess-d\",\"cwd\":\"/w/d\",\"startedAt\":1760000280000,\"kind\":\"cli\",\"entrypoint\":\"cli\"}", + ".claude/sessions/2005.json": "{\"pid\":2005,\"sessionId\":\"sess-e\",\"cwd\":\"/w/e\",\"startedAt\":1760000280000,\"kind\":\"cli\",\"entrypoint\":\"cli\"}", + ".claude/projects/-w-a/sess-a.jsonl": "{\"type\":\"user\",\"timestamp\":\"2025-10-09T08:58:30.000Z\",\"cwd\":\"/w/a\",\"message\":{\"content\":\"deploy it\"}}\n{\"type\":\"assistant\",\"timestamp\":\"2025-10-09T08:59:00.000Z\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"shipped\"}]}}\n", + ".claude/projects/-w-b/sess-b.jsonl": "{\"type\":\"user\",\"timestamp\":\"2025-10-09T08:58:10.000Z\",\"cwd\":\"/w/b\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"[Request interrupted by user]\"}]}}\n", + ".claude/projects/-w-c/sess-c.jsonl": "{\"type\":\"user\",\"timestamp\":\"2025-10-09T08:58:05.000Z\",\"cwd\":\"/w/c\",\"message\":{\"content\":\"write tests\"}}\n{\"type\":\"progress\",\"timestamp\":\"2025-10-09T08:58:40.000Z\"}\n", + ".claude/projects/-w-d/sess-d.jsonl": "{\"type\":\"system\",\"timestamp\":\"2025-10-09T08:58:15.000Z\",\"cwd\":\"/w/d\",\"content\":\"init\"}\n", + ".claude/projects/-w-e/sess-e.jsonl": "" + }, + "expected": [ + { + "name": "a-2001", + "type": "claude", + "status": "waiting", + "summary": "deploy it", + "pid": 2001, + "projectPath": "/w/a", + "sessionId": "sess-a", + "lastActive": "2025-10-09T08:59:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.claude/projects/-w-a/sess-a.jsonl" + }, + { + "name": "b-2002", + "type": "claude", + "status": "waiting", + "summary": "Session started", + "pid": 2002, + "projectPath": "/w/b", + "sessionId": "sess-b", + "lastActive": "2025-10-09T08:58:10.000Z", + "sessionFilePath": "$FIXTURE_HOME/.claude/projects/-w-b/sess-b.jsonl" + }, + { + "name": "c-2003", + "type": "claude", + "status": "running", + "summary": "write tests", + "pid": 2003, + "projectPath": "/w/c", + "sessionId": "sess-c", + "lastActive": "2025-10-09T08:58:40.000Z", + "sessionFilePath": "$FIXTURE_HOME/.claude/projects/-w-c/sess-c.jsonl" + }, + { + "name": "d-2004", + "type": "claude", + "status": "idle", + "summary": "Session started", + "pid": 2004, + "projectPath": "/w/d", + "sessionId": "sess-d", + "lastActive": "2025-10-09T08:58:15.000Z", + "sessionFilePath": "$FIXTURE_HOME/.claude/projects/-w-d/sess-d.jsonl" + }, + { + "name": "e-2005", + "type": "claude", + "status": "unknown", + "summary": "Session started", + "pid": 2005, + "projectPath": "/w/e", + "sessionId": "sess-e", + "lastActive": "2025-10-09T09:00:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.claude/projects/-w-e/sess-e.jsonl" + } + ] +} \ No newline at end of file diff --git a/fixtures/harness/codex/matched.json b/fixtures/harness/codex/matched.json new file mode 100644 index 000000000..ce554e29d --- /dev/null +++ b/fixtures/harness/codex/matched.json @@ -0,0 +1,98 @@ +{ + "adapter": "codex", + "capturedAt": "synthetic", + "frozenNow": 1760000400000, + "processes": [ + { + "pid": 3001, + "ppid": 1, + "tty": "pts/0", + "command": "/usr/bin/codex resume 0199c82c-c000-7abc-8def-0123456789ab", + "cwd": "/proj/cx-alpha", + "startTime": "2025-10-09T08:53:30.000Z" + }, + { + "pid": 3002, + "ppid": 1, + "tty": "pts/1", + "command": "codex", + "cwd": "/proj/cx-beta", + "startTime": "$NOW" + }, + { + "pid": 3003, + "ppid": 1, + "tty": "pts/2", + "command": "codex", + "cwd": "/proj/cx-gamma", + "startTime": "$NOW" + }, + { + "pid": 3004, + "ppid": 1, + "tty": "pts/3", + "command": "codex app-server --listen unix://x", + "cwd": "/proj/cx-helper", + "startTime": "2025-10-09T08:50:00.000Z" + }, + { + "pid": 3005, + "ppid": 1, + "tty": "pts/4", + "command": "codex", + "cwd": "/proj/cx-mapped", + "startTime": "2025-10-09T08:56:30.000Z" + } + ], + "home": { + ".codex/ai-devkit/sessions.json": "{\"3005\": \"$FIXTURE_HOME/.codex/sessions/fixed/mapped-aaaa.jsonl\"}", + ".codex/sessions/2025/10/09/rollout-2025-10-09T08-53-20-0199c82c-c000-7abc-8def-0123456789ab.jsonl": "{\"timestamp\":\"2025-10-09T08:53:20.000Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"0199c82c-c000-7abc-8def-0123456789ab\",\"cwd\":\"/proj/cx-alpha\",\"timestamp\":\"2025-10-09T08:53:20.000Z\"}}\n{\"timestamp\":\"2025-10-09T08:54:00.000Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"fix the flaky test\"}]}}\n{\"timestamp\":\"2025-10-09T08:55:00.000Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"patched retry loop\"}]}}\n", + ".codex/sessions/$TODAY/rollout-b7b7-b7b7b7b7-0000-0000-0000-000000000000.jsonl": "{\"timestamp\":\"2025-10-09T08:50:00.000Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"b7b7b7b7-0000-0000-0000-000000000000\",\"cwd\":\"/proj/cx-beta\"}}\n{\"timestamp\":\"2025-10-09T08:58:00.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"item_completed\",\"item\":{\"type\":\"UserMessage\",\"content\":\"ship the patch\"}}}\n", + ".codex/sessions/fixed/mapped-aaaa.jsonl": "{\"timestamp\":\"2025-10-09T08:50:00.000Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"cccccccc-0000-0000-0000-000000000000\",\"cwd\":\"/proj/cx-mapped\",\"timestamp\":\"2025-10-09T08:56:00.000Z\"}}\n{\"timestamp\":\"2025-10-09T08:57:00.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"agent_message\",\"message\":\"mapping hit\"}}\n" + }, + "expected": [ + { + "name": "cx-mapped-3005", + "type": "codex", + "status": "waiting", + "summary": "mapping hit", + "pid": 3005, + "projectPath": "/proj/cx-mapped", + "sessionId": "cccccccc-0000-0000-0000-000000000000", + "lastActive": "2025-10-09T08:57:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.codex/sessions/fixed/mapped-aaaa.jsonl" + }, + { + "name": "cx-alpha-3001", + "type": "codex", + "status": "waiting", + "summary": "patched retry loop", + "pid": 3001, + "projectPath": "/proj/cx-alpha", + "sessionId": "0199c82c-c000-7abc-8def-0123456789ab", + "lastActive": "2025-10-09T08:55:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.codex/sessions/2025/10/09/rollout-2025-10-09T08-53-20-0199c82c-c000-7abc-8def-0123456789ab.jsonl" + }, + { + "name": "cx-beta-3002", + "type": "codex", + "status": "running", + "summary": "ship the patch", + "pid": 3002, + "projectPath": "/proj/cx-beta", + "sessionId": "b7b7b7b7-0000-0000-0000-000000000000", + "lastActive": "2025-10-09T08:58:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.codex/sessions/$TODAY/rollout-b7b7-b7b7b7b7-0000-0000-0000-000000000000.jsonl" + }, + { + "name": "cx-gamma-3003", + "type": "codex", + "status": "running", + "summary": "Codex process running", + "pid": 3003, + "projectPath": "/proj/cx-gamma", + "sessionId": "pid-3003", + "lastActive": "2025-10-09T09:00:00.000Z" + } + ] +} \ No newline at end of file diff --git a/fixtures/harness/codex/statuses.json b/fixtures/harness/codex/statuses.json new file mode 100644 index 000000000..fffde99f9 --- /dev/null +++ b/fixtures/harness/codex/statuses.json @@ -0,0 +1,112 @@ +{ + "adapter": "codex", + "capturedAt": "synthetic", + "frozenNow": 1760000400000, + "processes": [ + { + "pid": 4001, + "ppid": 1, + "tty": "pts/0", + "command": "codex", + "cwd": "/proj/st-run", + "startTime": "2025-10-09T08:50:30.000Z" + }, + { + "pid": 4002, + "ppid": 1, + "tty": "pts/1", + "command": "codex", + "cwd": "/proj/st-msg", + "startTime": "2025-10-09T08:50:30.000Z" + }, + { + "pid": 4003, + "ppid": 1, + "tty": "pts/2", + "command": "codex", + "cwd": "/proj/st-complete", + "startTime": "2025-10-09T08:50:30.000Z" + }, + { + "pid": 4004, + "ppid": 1, + "tty": "pts/3", + "command": "codex", + "cwd": "/proj/st-aborted", + "startTime": "2025-10-09T08:50:30.000Z" + }, + { + "pid": 4005, + "ppid": 1, + "tty": "pts/4", + "command": "codex", + "cwd": "/proj/st-idle", + "startTime": "2025-10-09T08:50:30.000Z" + } + ], + "home": { + ".codex/ai-devkit/sessions.json": "{\"4001\": \"$FIXTURE_HOME/.codex/sessions/fixed/run.jsonl\", \"4002\": \"$FIXTURE_HOME/.codex/sessions/fixed/wait_msg.jsonl\", \"4003\": \"$FIXTURE_HOME/.codex/sessions/fixed/wait_complete.jsonl\", \"4004\": \"$FIXTURE_HOME/.codex/sessions/fixed/wait_aborted.jsonl\", \"4005\": \"$FIXTURE_HOME/.codex/sessions/fixed/idle.jsonl\"}", + ".codex/sessions/fixed/run.jsonl": "{\"timestamp\":\"2025-10-09T08:50:00.000Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"d0000000-0000-0000-0000-000000000001\",\"cwd\":\"/proj/st-run\",\"timestamp\":\"2025-10-09T08:50:00.000Z\"}}\n{\"timestamp\":\"2025-10-09T08:59:00.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"item_completed\",\"item\":{\"type\":\"UserMessage\",\"content\":\"keep working\"}}}\n", + ".codex/sessions/fixed/wait_msg.jsonl": "{\"timestamp\":\"2025-10-09T08:50:00.000Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"d0000000-0000-0000-0000-000000000002\",\"cwd\":\"/proj/st-wait\",\"timestamp\":\"2025-10-09T08:50:00.000Z\"}}\n{\"timestamp\":\"2025-10-09T08:59:00.000Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"done, waiting\"}]}}\n", + ".codex/sessions/fixed/wait_complete.jsonl": "{\"timestamp\":\"2025-10-09T08:50:00.000Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"d0000000-0000-0000-0000-000000000003\",\"cwd\":\"/proj/st-done\",\"timestamp\":\"2025-10-09T08:50:00.000Z\"}}\n{\"timestamp\":\"2025-10-09T08:59:00.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"task_complete\",\"message\":\"task done\"}}\n", + ".codex/sessions/fixed/wait_aborted.jsonl": "{\"timestamp\":\"2025-10-09T08:50:00.000Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"d0000000-0000-0000-0000-000000000004\",\"cwd\":\"/proj/st-abort\",\"timestamp\":\"2025-10-09T08:50:00.000Z\"}}\n{\"timestamp\":\"2025-10-09T08:59:00.000Z\",\"type\":\"event_msg\",\"payload\":{\"type\":\"turn_aborted\"}}\n", + ".codex/sessions/fixed/idle.jsonl": "{\"timestamp\":\"2025-10-09T08:50:00.000Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"d0000000-0000-0000-0000-000000000005\",\"cwd\":\"/proj/st-idle\",\"timestamp\":\"2025-10-09T08:50:00.000Z\"}}\n{\"timestamp\":\"2025-10-09T08:40:00.000Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"old reply\"}]}}\n" + }, + "expected": [ + { + "name": "st-run-4001", + "type": "codex", + "status": "running", + "summary": "keep working", + "pid": 4001, + "projectPath": "/proj/st-run", + "sessionId": "d0000000-0000-0000-0000-000000000001", + "lastActive": "2025-10-09T08:59:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.codex/sessions/fixed/run.jsonl" + }, + { + "name": "st-wait-4002", + "type": "codex", + "status": "waiting", + "summary": "done, waiting", + "pid": 4002, + "projectPath": "/proj/st-wait", + "sessionId": "d0000000-0000-0000-0000-000000000002", + "lastActive": "2025-10-09T08:59:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.codex/sessions/fixed/wait_msg.jsonl" + }, + { + "name": "st-done-4003", + "type": "codex", + "status": "waiting", + "summary": "task done", + "pid": 4003, + "projectPath": "/proj/st-done", + "sessionId": "d0000000-0000-0000-0000-000000000003", + "lastActive": "2025-10-09T08:59:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.codex/sessions/fixed/wait_complete.jsonl" + }, + { + "name": "st-abort-4004", + "type": "codex", + "status": "waiting", + "summary": "Codex session active", + "pid": 4004, + "projectPath": "/proj/st-abort", + "sessionId": "d0000000-0000-0000-0000-000000000004", + "lastActive": "2025-10-09T08:59:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.codex/sessions/fixed/wait_aborted.jsonl" + }, + { + "name": "st-idle-4005", + "type": "codex", + "status": "idle", + "summary": "old reply", + "pid": 4005, + "projectPath": "/proj/st-idle", + "sessionId": "d0000000-0000-0000-0000-000000000005", + "lastActive": "2025-10-09T08:40:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.codex/sessions/fixed/idle.jsonl" + } + ] +} \ No newline at end of file diff --git a/fixtures/harness/copilot/fallback.json b/fixtures/harness/copilot/fallback.json new file mode 100644 index 000000000..b9ec6252f --- /dev/null +++ b/fixtures/harness/copilot/fallback.json @@ -0,0 +1,84 @@ +{ + "adapter": "copilot", + "capturedAt": "2026-10-09T08:03:00.000Z", + "frozenNow": 1791532980000, + "processes": [ + { + "pid": 70002, + "ppid": 1000, + "tty": "ttys011", + "command": "copilot", + "cwd": "/proj/ev", + "startTime": "$NOW" + }, + { + "pid": 70003, + "ppid": 1000, + "tty": "ttys012", + "command": "copilot", + "cwd": "/proj/free", + "startTime": "$NOW" + }, + { + "pid": 70011, + "ppid": 70010, + "tty": "ttys013", + "command": "copilot", + "cwd": "/proj/child", + "startTime": "$NOW" + } + ], + "home": { + ".copilot/session-state/dir-aaa/inuse.70002.lock": "", + ".copilot/session-state/dir-aaa/events.jsonl": "{\"type\":\"session.start\",\"data\":{\"sessionId\":\"sess-ev\",\"context\":{\"cwd\":\"/proj/ev\"}},\"timestamp\":\"2026-10-09T07:56:00.000Z\"}\n{\"type\":\"user.message\",\"data\":{\"content\":\"second prompt\"},\"timestamp\":\"2026-10-09T08:02:00.000Z\"}\n", + ".copilot/session-state/dir-bbb/inuse.70011.lock": "" + }, + "registry": [ + { + "name": "wrong-type-row", + "type": "pi", + "pid": 70003, + "runtime": "tmux", + "runtimeRef": null, + "cwd": "/proj/free", + "startedAt": "2026-10-09T07:57:00.000Z", + "sessionId": "", + "sessionFilePath": "", + "pinned": false, + "updatedAt": "2026-10-09T07:57:00.000Z" + } + ], + "expected": [ + { + "name": "ev-70002", + "type": "copilot", + "status": "running", + "summary": "second prompt", + "pid": 70002, + "projectPath": "/proj/ev", + "sessionId": "sess-ev", + "lastActive": "2026-10-09T08:02:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.copilot/session-state/dir-aaa/events.jsonl" + }, + { + "name": "free-70003", + "type": "copilot", + "status": "running", + "summary": "Copilot process running", + "pid": 70003, + "projectPath": "/proj/free", + "sessionId": "pid-70003", + "lastActive": "2026-10-09T08:03:00.000Z" + }, + { + "name": "child-70011", + "type": "copilot", + "status": "running", + "summary": "Copilot process running", + "pid": 70011, + "projectPath": "/proj/child", + "sessionId": "pid-70011", + "lastActive": "2026-10-09T08:03:00.000Z" + } + ] +} diff --git a/fixtures/harness/copilot/matched.json b/fixtures/harness/copilot/matched.json new file mode 100644 index 000000000..298670f15 --- /dev/null +++ b/fixtures/harness/copilot/matched.json @@ -0,0 +1,111 @@ +{ + "adapter": "copilot", + "capturedAt": "2026-10-09T08:03:00.000Z", + "frozenNow": 1791532980000, + "processes": [ + { + "pid": 60001, + "ppid": 1000, + "tty": "ttys001", + "command": "copilot --resume", + "cwd": "/proj/a", + "startTime": "$NOW" + }, + { + "pid": 60010, + "ppid": 1000, + "tty": "ttys002", + "command": "copilot", + "cwd": "/proj/b", + "startTime": "$NOW" + }, + { + "pid": 60011, + "ppid": 60010, + "tty": "ttys002", + "command": "copilot", + "cwd": "/proj/b", + "startTime": "$NOW" + }, + { + "pid": 60020, + "ppid": 1000, + "tty": "ttys003", + "command": "/usr/local/bin/copilot", + "cwd": "/proj/c", + "startTime": "$NOW" + }, + { + "pid": 60030, + "ppid": 1000, + "tty": "ttys004", + "command": "node server.js", + "cwd": "/proj/d", + "startTime": "$NOW" + }, + { + "pid": 60031, + "ppid": 1000, + "tty": "ttys004", + "command": "copilot-helper --daemon", + "cwd": "/proj/d", + "startTime": "$NOW" + } + ], + "home": { + ".copilot/session-state/dir-aaa/inuse.60001.lock": "", + ".copilot/session-state/dir-aaa/events.jsonl": "{\"type\":\"session.start\",\"data\":{\"sessionId\":\"sess-evt-aaa\",\"startTime\":\"2026-10-09T07:58:30.000Z\",\"context\":{\"cwd\":\"/proj/a\",\"gitRoot\":\"/proj/a\",\"branch\":\"main\"}},\"timestamp\":\"2026-10-09T07:58:30.000Z\"}\n{\"type\":\"user.message\",\"data\":{\"content\":\"check the logs\"},\"timestamp\":\"2026-10-09T08:00:00.000Z\"}\n{\"type\":\"assistant.message\",\"data\":{\"content\":\"looking now\"},\"timestamp\":\"2026-10-09T08:01:00.000Z\"}\n", + ".copilot/session-state/dir-aaa/workspace.yaml": "id: ws-ignored\ncwd: /proj/ignored\nname: ignored name\n", + ".copilot/session-state/dir-bbb/inuse.60001.lock": "", + ".copilot/session-state/dir-ccc/inuse.99999.lock": "", + ".copilot/session-state/dir-ccc/events.jsonl": "{\"type\":\"session.start\",\"data\":{\"sessionId\":\"sess-dead\",\"context\":{\"cwd\":\"/proj/dead\"}},\"timestamp\":\"2026-10-09T07:00:00.000Z\"}\n{\"type\":\"user.message\",\"data\":{\"content\":\"old session\"},\"timestamp\":\"2026-10-09T07:01:00.000Z\"}\n" + }, + "registry": [ + { + "name": "wrapped-copilot", + "type": "copilot", + "pid": 60010, + "runtime": "tmux", + "runtimeRef": null, + "cwd": "/proj/b", + "startedAt": "2026-10-09T07:58:00.000Z", + "sessionId": "", + "sessionFilePath": "", + "pinned": false, + "updatedAt": "2026-10-09T07:59:00.000Z" + } + ], + "expected": [ + { + "name": "a-60001", + "type": "copilot", + "status": "waiting", + "summary": "check the logs", + "pid": 60001, + "projectPath": "/proj/a", + "sessionId": "sess-evt-aaa", + "lastActive": "2026-10-09T08:01:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.copilot/session-state/dir-aaa/events.jsonl" + }, + { + "name": "wrapped-copilot", + "type": "copilot", + "status": "running", + "summary": "Copilot process running", + "pid": 60011, + "projectPath": "/proj/b", + "sessionId": "pid-60011", + "lastActive": "2026-10-09T08:03:00.000Z" + }, + { + "name": "c-60020", + "type": "copilot", + "status": "running", + "summary": "Copilot process running", + "pid": 60020, + "projectPath": "/proj/c", + "sessionId": "pid-60020", + "lastActive": "2026-10-09T08:03:00.000Z" + } + ] +} diff --git a/fixtures/harness/copilot/workspace.json b/fixtures/harness/copilot/workspace.json new file mode 100644 index 000000000..338c905ed --- /dev/null +++ b/fixtures/harness/copilot/workspace.json @@ -0,0 +1,33 @@ +{ + "adapter": "copilot", + "capturedAt": "2026-10-09T08:03:00.000Z", + "frozenNow": 1791532980000, + "processes": [ + { + "pid": 70001, + "ppid": 1000, + "tty": "ttys010", + "command": "copilot", + "cwd": "/proj/ws", + "startTime": "$NOW" + } + ], + "home": { + ".copilot/session-state/dir-aaa/inuse.70001.lock": "", + ".copilot/session-state/dir-aaa/workspace.yaml": "id: ws-1\ncwd: /proj/ws\nname: \"yaml session\"\ncreated_at: 2026-10-09T05:55:00Z\nupdated_at: 2026-10-09T06:00:00Z\n" + }, + "registry": [], + "expected": [ + { + "name": "ws-70001", + "type": "copilot", + "status": "idle", + "summary": "yaml session", + "pid": 70001, + "projectPath": "/proj/ws", + "sessionId": "ws-1", + "lastActive": "2026-10-09T06:00:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.copilot/session-state/dir-aaa/events.jsonl" + } + ] +} diff --git a/fixtures/harness/devin/fallback.json b/fixtures/harness/devin/fallback.json new file mode 100644 index 000000000..a12cda2eb --- /dev/null +++ b/fixtures/harness/devin/fallback.json @@ -0,0 +1,74 @@ +{ + "adapter": "devin", + "capturedAt": "2025-10-09T12:20:00.000Z", + "frozenNow": 1760012400000, + "processes": [ + { + "pid": 8201, + "ppid": 1, + "tty": "ttys030", + "command": "devin", + "cwd": "/proj/x", + "startTime": "2025-10-09T12:00:00.000Z" + }, + { + "pid": 8202, + "ppid": 8201, + "tty": null, + "command": "devin acp", + "cwd": "/proj/x", + "startTime": "2025-10-09T12:00:05.000Z" + }, + { + "pid": 8203, + "ppid": 1, + "tty": "ttys031", + "command": "devin", + "cwd": "/proj/y", + "startTime": "2025-10-09T12:05:00.000Z" + }, + { + "pid": 8204, + "ppid": 1, + "tty": "ttys032", + "command": "devin", + "startTime": "2025-10-09T12:06:00.000Z" + } + ], + "home": { + ".local/share/devin/cli/session_locks/y-slug.lock": "8203\n", + ".local/share/devin/cli/session_locks/dead-slug.lock": "99991\n" + }, + "expected": [ + { + "name": "x-8201", + "type": "devin", + "status": "running", + "summary": "Devin process running", + "pid": 8201, + "projectPath": "/proj/x", + "sessionId": "pid-8201", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "y-8203", + "type": "devin", + "status": "running", + "summary": "Devin process running", + "pid": 8203, + "projectPath": "/proj/y", + "sessionId": "pid-8203", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "unknown-8204", + "type": "devin", + "status": "running", + "summary": "Devin process running", + "pid": 8204, + "projectPath": "", + "sessionId": "pid-8204", + "lastActive": "2025-10-09T12:20:00.000Z" + } + ] +} diff --git a/fixtures/harness/devin/matched.json b/fixtures/harness/devin/matched.json new file mode 100644 index 000000000..5c78c0097 --- /dev/null +++ b/fixtures/harness/devin/matched.json @@ -0,0 +1,235 @@ +{ + "adapter": "devin", + "capturedAt": "2025-10-09T12:20:00.000Z", + "frozenNow": 1760012400000, + "processes": [ + { + "pid": 8101, + "ppid": 1, + "tty": "ttys010", + "command": "devin", + "cwd": "/proj/alpha", + "startTime": "2025-10-09T11:40:00.000Z" + }, + { + "pid": 8102, + "ppid": 8101, + "tty": null, + "command": "devin acp", + "cwd": "/proj/alpha", + "startTime": "2025-10-09T11:40:10.000Z" + }, + { + "pid": 8103, + "ppid": 1, + "tty": "ttys012", + "command": "devin", + "cwd": "/proj/beta", + "startTime": "2025-10-09T11:50:00.000Z" + }, + { + "pid": 8104, + "ppid": 1, + "tty": "ttys013", + "command": "devin", + "cwd": "/proj/nolock", + "startTime": "2025-10-09T11:30:00.000Z" + }, + { + "pid": 8105, + "ppid": 1, + "tty": "ttys014", + "command": "devin list", + "cwd": "/proj/list", + "startTime": "2025-10-09T12:19:00.000Z" + }, + { + "pid": 8106, + "ppid": 1, + "tty": "ttys015", + "command": "devin", + "cwd": "/proj/gamma", + "startTime": "2025-10-09T12:00:00.000Z" + }, + { + "pid": 8107, + "ppid": 1, + "tty": "ttys016", + "command": "devin", + "cwd": "/proj/hidden", + "startTime": "2025-10-09T12:05:00.000Z" + }, + { + "pid": 8108, + "ppid": 1, + "tty": "ttys017", + "command": "devin", + "cwd": "/proj/idle", + "startTime": "2025-10-09T11:20:00.000Z" + }, + { + "pid": 8109, + "ppid": 9999, + "tty": null, + "command": "/Users/hoangnguyen/.local/bin/devin acp", + "cwd": "/proj/acp-standalone", + "startTime": "2025-10-09T12:10:00.000Z" + }, + { + "pid": 8110, + "ppid": 1, + "tty": "ttys019", + "command": "devin --print", + "cwd": "/proj/print", + "startTime": "2025-10-09T12:15:00.000Z" + }, + { + "pid": 8111, + "ppid": 1, + "tty": "ttys020", + "command": "devin -p update", + "cwd": "/proj/flagged", + "startTime": "2025-10-09T12:16:00.000Z" + }, + { + "pid": 8112, + "ppid": 1, + "tty": "ttys021", + "command": "devin ssh build-01", + "cwd": "/proj/ssh", + "startTime": "2025-10-09T12:17:00.000Z" + }, + { + "pid": 8113, + "ppid": 1, + "tty": "ttys022", + "command": "devin --config /tmp/c.json list", + "cwd": "/proj/cfg", + "startTime": "2025-10-09T12:18:00.000Z" + } + ], + "home": { + ".local/share/devin/cli/session_locks/alpha-slug.lock": "8102\n", + ".local/share/devin/cli/session_locks/stale-slug.lock": "99999\n", + ".local/share/devin/cli/session_locks/locked-slug.lock": "8106\n", + ".local/share/devin/cli/session_locks/bad.lock": "notapid\n", + ".local/share/devin/cli/session_locks/note.txt": "ignored" + }, + "sqlite": { + ".local/share/devin/cli/sessions.db": [ + "CREATE TABLE \"sessions\" (\"id\" TEXT PRIMARY KEY, \"working_directory\" TEXT NOT NULL, \"title\" TEXT, \"created_at\" INTEGER NOT NULL, \"last_activity_at\" INTEGER NOT NULL, \"hidden\" INTEGER NOT NULL DEFAULT 0);", + "CREATE TABLE \"message_nodes\" (\"session_id\" TEXT NOT NULL, \"node_id\" INTEGER NOT NULL, \"chat_message\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL);", + "CREATE TABLE \"prompt_history\" (\"session_id\" TEXT NOT NULL, \"content\" TEXT NOT NULL, \"timestamp\" INTEGER NOT NULL, \"is_shell\" INTEGER NOT NULL DEFAULT 0);", + "INSERT INTO \"sessions\" VALUES ('alpha-slug','/proj/alpha',NULL,1760008800,1760012200,0);", + "INSERT INTO \"sessions\" VALUES ('beta-slug','/proj/beta',NULL,1760010600,1760012280,0);", + "INSERT INTO \"sessions\" VALUES ('beta-old-slug','/proj/beta',NULL,1760000000,1760000000,0);", + "INSERT INTO \"sessions\" VALUES ('idle-slug','/proj/idle',NULL,1760008800,1760008800,0);", + "INSERT INTO \"sessions\" VALUES ('hidden-slug','/proj/hidden',NULL,1760008800,1760010000,1);", + "INSERT INTO \"sessions\" VALUES ('locked-slug','/proj/elsewhere','Locked Session',1760012280,1760012280,0);", + "INSERT INTO \"message_nodes\" VALUES ('alpha-slug',1,'{\"role\":\"user\",\"content\":\"alpha prompt\",\"metadata\":{\"is_user_input\":true}}',1760008800);", + "INSERT INTO \"message_nodes\" VALUES ('alpha-slug',2,'{\"role\":\"user\",\"content\":\"latest alpha ask\",\"metadata\":{\"is_user_input\":true}}',1760012200);", + "INSERT INTO \"message_nodes\" VALUES ('beta-slug',1,'{\"role\":\"user\",\"content\":\"beta question\"}',1760010600);", + "INSERT INTO \"message_nodes\" VALUES ('beta-slug',2,'{\"role\":\"assistant\",\"content\":\"working on it\"}',1760012280);", + "INSERT INTO \"message_nodes\" VALUES ('idle-slug',1,'{\"role\":\"user\",\"content\":\"old idle ask\"}',1760008800);", + "INSERT INTO \"prompt_history\" VALUES ('beta-slug','beta ask',1760012280,0);", + "INSERT INTO \"prompt_history\" VALUES ('beta-slug','!ls -la',1760012281,1);", + "INSERT INTO \"prompt_history\" VALUES ('beta-slug','/help',1760012282,0);" + ] + }, + "expected": [ + { + "name": "alpha-8101", + "type": "devin", + "status": "running", + "summary": "latest alpha ask", + "pid": 8101, + "projectPath": "/proj/alpha", + "sessionId": "alpha-slug", + "lastActive": "2025-10-09T12:16:40.000Z", + "sessionFilePath": "$FIXTURE_HOME/.local/share/devin/cli/sessions.db::alpha-slug" + }, + { + "name": "beta-8103", + "type": "devin", + "status": "waiting", + "summary": "beta ask", + "pid": 8103, + "projectPath": "/proj/beta", + "sessionId": "beta-slug", + "lastActive": "2025-10-09T12:18:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.local/share/devin/cli/sessions.db::beta-slug" + }, + { + "name": "nolock-8104", + "type": "devin", + "status": "running", + "summary": "Devin process running", + "pid": 8104, + "projectPath": "/proj/nolock", + "sessionId": "pid-8104", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "elsewhere-8106", + "type": "devin", + "status": "running", + "summary": "Locked Session", + "pid": 8106, + "projectPath": "/proj/elsewhere", + "sessionId": "locked-slug", + "lastActive": "2025-10-09T12:18:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.local/share/devin/cli/sessions.db::locked-slug" + }, + { + "name": "hidden-8107", + "type": "devin", + "status": "running", + "summary": "Devin process running", + "pid": 8107, + "projectPath": "/proj/hidden", + "sessionId": "pid-8107", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "idle-8108", + "type": "devin", + "status": "idle", + "summary": "old idle ask", + "pid": 8108, + "projectPath": "/proj/idle", + "sessionId": "idle-slug", + "lastActive": "2025-10-09T11:20:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.local/share/devin/cli/sessions.db::idle-slug" + }, + { + "name": "acp-standalone-8109", + "type": "devin", + "status": "running", + "summary": "Devin process running", + "pid": 8109, + "projectPath": "/proj/acp-standalone", + "sessionId": "pid-8109", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "print-8110", + "type": "devin", + "status": "running", + "summary": "Devin process running", + "pid": 8110, + "projectPath": "/proj/print", + "sessionId": "pid-8110", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "flagged-8111", + "type": "devin", + "status": "running", + "summary": "Devin process running", + "pid": 8111, + "projectPath": "/proj/flagged", + "sessionId": "pid-8111", + "lastActive": "2025-10-09T12:20:00.000Z" + } + ] +} diff --git a/fixtures/harness/gemini_cli/fallback.json b/fixtures/harness/gemini_cli/fallback.json new file mode 100644 index 000000000..5323269af --- /dev/null +++ b/fixtures/harness/gemini_cli/fallback.json @@ -0,0 +1,146 @@ +{ + "adapter": "gemini_cli", + "capturedAt": "2026-10-09T08:00:00.000Z", + "frozenNow": 1791532800000, + "processes": [ + { + "pid": 60001, + "ppid": 1, + "tty": "s601", + "command": "node /opt/tools/gemini.js", + "cwd": "/Users/fixture/gone", + "startTime": "$NOW" + }, + { + "pid": 60002, + "ppid": 1, + "tty": "s602", + "command": "node /opt/tools/gemini.js", + "cwd": "/Users/fixture/stale", + "startTime": "$NOW" + }, + { + "pid": 60003, + "ppid": 1, + "tty": "s603", + "command": "node /opt/tools/gemini.js", + "cwd": "/Users/fixture/noid", + "startTime": "$NOW" + }, + { + "pid": 60004, + "ppid": 60005, + "tty": "s604", + "command": "node /opt/tools/gemini.js", + "cwd": "/Users/fixture/wrapfb", + "startTime": "$NOW" + }, + { + "pid": 60005, + "ppid": 1, + "tty": "s604", + "command": "node /opt/tools/gemini.js", + "cwd": "/Users/fixture/wrapfb", + "startTime": "$NOW" + } + ], + "registry": [ + { + "name": "gone-row", + "type": "gemini_cli", + "pid": 60001, + "runtime": "tmux", + "runtimeRef": null, + "cwd": "/Users/fixture/gone", + "startedAt": "2026-10-09T06:30:00.000Z", + "sessionId": "s-gone", + "sessionFilePath": "$FIXTURE_HOME/.gemini/tmp/nonexistent/chats/session-gone.jsonl", + "pinned": false, + "updatedAt": "2026-10-09T06:35:00.000Z" + }, + { + "name": "stale-pi-row", + "type": "pi", + "pid": 60002, + "runtime": "tmux", + "runtimeRef": null, + "cwd": "/Users/fixture/stale", + "startedAt": "2026-10-09T06:30:00.000Z", + "sessionId": "s-pi", + "sessionFilePath": "$FIXTURE_HOME/.gemini/tmp/x/chats/session-stale.jsonl", + "pinned": false, + "updatedAt": "2026-10-09T06:35:00.000Z" + }, + { + "name": "noid-row", + "type": "gemini_cli", + "pid": 60003, + "runtime": "tmux", + "runtimeRef": null, + "cwd": "/Users/fixture/noid", + "startedAt": "2026-10-09T06:30:00.000Z", + "sessionId": "", + "sessionFilePath": "$FIXTURE_HOME/.gemini/tmp/y/chats/session-noid.jsonl", + "pinned": false, + "updatedAt": "2026-10-09T06:35:00.000Z" + }, + { + "name": "wrap-fb", + "type": "gemini_cli", + "pid": 60005, + "runtime": "tmux", + "runtimeRef": null, + "cwd": "/Users/fixture/wrapfb", + "startedAt": "2026-10-09T06:30:00.000Z", + "sessionId": "", + "sessionFilePath": "", + "pinned": false, + "updatedAt": "2026-10-09T06:35:00.000Z" + } + ], + "home": { + ".gemini/tmp/y/chats/session-noid.jsonl": "{\"projectHash\":\"abc\",\"lastUpdated\":\"2026-10-09T07:59:00Z\"}\n{\"id\":\"m1\",\"type\":\"user\",\"content\":[{\"text\":\"no id\"}],\"timestamp\":\"2026-10-09T07:59:00Z\"}\n" + }, + "expected": [ + { + "name": "gone-60001", + "type": "gemini_cli", + "status": "running", + "summary": "Gemini CLI process running", + "pid": 60001, + "projectPath": "/Users/fixture/gone", + "sessionId": "pid-60001", + "lastActive": "2026-10-09T08:00:00.000Z" + }, + { + "name": "stale-60002", + "type": "gemini_cli", + "status": "running", + "summary": "Gemini CLI process running", + "pid": 60002, + "projectPath": "/Users/fixture/stale", + "sessionId": "pid-60002", + "lastActive": "2026-10-09T08:00:00.000Z" + }, + { + "name": "noid-60003", + "type": "gemini_cli", + "status": "running", + "summary": "Gemini CLI process running", + "pid": 60003, + "projectPath": "/Users/fixture/noid", + "sessionId": "pid-60003", + "lastActive": "2026-10-09T08:00:00.000Z" + }, + { + "name": "wrap-fb", + "type": "gemini_cli", + "status": "running", + "summary": "Gemini CLI process running", + "pid": 60004, + "projectPath": "/Users/fixture/wrapfb", + "sessionId": "pid-60004", + "lastActive": "2026-10-09T08:00:00.000Z" + } + ] +} diff --git a/fixtures/harness/gemini_cli/matched.json b/fixtures/harness/gemini_cli/matched.json new file mode 100644 index 000000000..87d9ef02a --- /dev/null +++ b/fixtures/harness/gemini_cli/matched.json @@ -0,0 +1,142 @@ +{ + "adapter": "gemini_cli", + "capturedAt": "2026-10-09T08:00:00.000Z", + "frozenNow": 1791532800000, + "processes": [ + { + "pid": 40001, + "ppid": 1, + "tty": "s001", + "command": "node /opt/tools/gemini.js chat", + "cwd": "/Users/fixture/proj", + "startTime": "$NOW" + }, + { + "pid": 40002, + "ppid": 1, + "tty": "s002", + "command": "node /opt/tools/gemini.js --sandbox", + "cwd": "/Users/fixture/legacy", + "startTime": "$NOW" + }, + { + "pid": 40010, + "ppid": 40011, + "tty": "s010", + "command": "node /opt/tools/gemini.js", + "cwd": "/Users/fixture/wrap", + "startTime": "$NOW" + }, + { + "pid": 40011, + "ppid": 1, + "tty": "s010", + "command": "node /opt/tools/gemini.js", + "cwd": "/Users/fixture/wrap", + "startTime": "$NOW" + }, + { + "pid": 40020, + "ppid": 1, + "tty": "s020", + "command": "node /opt/tools/gemini.js", + "cwd": "/Users/fixture/lonely", + "startTime": "$NOW" + }, + { + "pid": 40030, + "ppid": 1, + "tty": "s030", + "command": "node server.js", + "cwd": "/Users/fixture/proj", + "startTime": "$NOW" + }, + { + "pid": 40040, + "ppid": 1, + "tty": "s040", + "command": "gemini chat", + "cwd": "/Users/fixture/proj", + "startTime": "$NOW" + } + ], + "registry": [ + { + "name": "cached-row", + "type": "gemini_cli", + "pid": 40002, + "runtime": "tmux", + "runtimeRef": null, + "cwd": "/Users/fixture/legacy", + "startedAt": "2026-10-09T06:30:00.000Z", + "sessionId": "s-cached", + "sessionFilePath": "$FIXTURE_HOME/.gemini/tmp/d004821982b79997e240af8ebc16920726c3f881f4b95cc4108d8461b54088c4/chats/session-cached.jsonl", + "pinned": false, + "updatedAt": "2026-10-09T06:35:00.000Z" + }, + { + "name": "wrap-name", + "type": "gemini_cli", + "pid": 40011, + "runtime": "tmux", + "runtimeRef": null, + "cwd": "/Users/fixture/wrap", + "startedAt": "2026-10-09T06:30:00.000Z", + "sessionId": "", + "sessionFilePath": "", + "pinned": false, + "updatedAt": "2026-10-09T06:35:00.000Z" + } + ], + "home": { + ".gemini/tmp/43486e99cb26b0ce666e37d21a69385bc11282da3571e4f4fb5587e169ed8419/chats/session-main.jsonl": "{\"sessionId\":\"s-main\",\"projectHash\":\"43486e99cb26b0ce666e37d21a69385bc11282da3571e4f4fb5587e169ed8419\",\"startTime\":\"2026-10-09T07:50:00Z\",\"lastUpdated\":\"2026-10-09T07:59:00Z\",\"directories\":[\"/Users/fixture/proj\"]}\n{\"id\":\"m1\",\"type\":\"user\",\"content\":[{\"text\":\"fix the bug\"}],\"timestamp\":\"2026-10-09T07:55:00Z\"}\n{\"id\":\"m2\",\"type\":\"gemini\",\"content\":\"done\",\"timestamp\":\"2026-10-09T07:58:00Z\"}\n", + ".gemini/tmp/d004821982b79997e240af8ebc16920726c3f881f4b95cc4108d8461b54088c4/chats/session-cached.jsonl": "{\"sessionId\":\"s-cached\",\"projectHash\":\"d004821982b79997e240af8ebc16920726c3f881f4b95cc4108d8461b54088c4\",\"startTime\":\"2026-10-09T06:30:00Z\",\"lastUpdated\":\"2026-10-09T07:58:30Z\",\"directories\":[\"/Users/fixture/legacy\"]}\n{\"id\":\"m1\",\"type\":\"user\",\"content\":[{\"text\":\"cached session ask\"}],\"timestamp\":\"2026-10-09T07:58:00Z\"}\n", + ".gemini/tmp/b660bf8d645bba04629b1c51b5e8f5aee3ef67b531b9546d134256a0f6e5926f/chats/session-elsewhere.jsonl": "{\"sessionId\":\"s-else\",\"projectHash\":\"b660bf8d645bba04629b1c51b5e8f5aee3ef67b531b9546d134256a0f6e5926f\",\"lastUpdated\":\"2026-10-09T07:59:00Z\",\"directories\":[\"/elsewhere\"]}\n{\"id\":\"m1\",\"type\":\"user\",\"content\":[{\"text\":\"not ours\"}],\"timestamp\":\"2026-10-09T07:59:00Z\"}\n", + ".gemini/tmp/mystery-slug/.project_root": "/elsewhere", + ".gemini/tmp/mystery-slug/chats/session-mystery.jsonl": "{\"sessionId\":\"s-mystery\",\"projectHash\":\"43486e99cb26b0ce666e37d21a69385bc11282da3571e4f4fb5587e169ed8419\",\"lastUpdated\":\"2026-10-09T07:59:30Z\",\"directories\":[\"/Users/fixture/proj\"]}\n{\"id\":\"m1\",\"type\":\"user\",\"content\":[{\"text\":\"decoy\"}],\"timestamp\":\"2026-10-09T07:59:30Z\"}\n" + }, + "expected": [ + { + "name": "legacy-40002", + "type": "gemini_cli", + "status": "running", + "summary": "cached session ask", + "pid": 40002, + "projectPath": "/Users/fixture/legacy", + "sessionId": "s-cached", + "lastActive": "2026-10-09T07:58:30.000Z", + "sessionFilePath": "$FIXTURE_HOME/.gemini/tmp/d004821982b79997e240af8ebc16920726c3f881f4b95cc4108d8461b54088c4/chats/session-cached.jsonl" + }, + { + "name": "proj-40001", + "type": "gemini_cli", + "status": "waiting", + "summary": "fix the bug", + "pid": 40001, + "projectPath": "/Users/fixture/proj", + "sessionId": "s-main", + "lastActive": "2026-10-09T07:59:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.gemini/tmp/43486e99cb26b0ce666e37d21a69385bc11282da3571e4f4fb5587e169ed8419/chats/session-main.jsonl" + }, + { + "name": "wrap-name", + "type": "gemini_cli", + "status": "running", + "summary": "Gemini CLI process running", + "pid": 40010, + "projectPath": "/Users/fixture/wrap", + "sessionId": "pid-40010", + "lastActive": "2026-10-09T08:00:00.000Z" + }, + { + "name": "lonely-40020", + "type": "gemini_cli", + "status": "running", + "summary": "Gemini CLI process running", + "pid": 40020, + "projectPath": "/Users/fixture/lonely", + "sessionId": "pid-40020", + "lastActive": "2026-10-09T08:00:00.000Z" + } + ] +} diff --git a/fixtures/harness/grok_cli/fallback.json b/fixtures/harness/grok_cli/fallback.json new file mode 100644 index 000000000..d3f2d7f17 --- /dev/null +++ b/fixtures/harness/grok_cli/fallback.json @@ -0,0 +1,92 @@ +{ + "adapter": "grok_cli", + "capturedAt": "2026-10-09T08:03:00.000Z", + "frozenNow": 1791532980000, + "processes": [ + { + "pid": 81001, + "ppid": 1000, + "tty": "ttys030", + "command": "grok", + "cwd": "/proj/emptygroup", + "startTime": "2026-10-09T07:50:00.000Z" + }, + { + "pid": 81002, + "ppid": 1000, + "tty": "ttys031", + "command": "grok", + "startTime": "2026-10-09T07:51:00.000Z" + }, + { + "pid": 81003, + "ppid": 1000, + "tty": "ttys032", + "command": "grok", + "cwd": "/proj/hist", + "startTime": "2026-10-09T07:52:00.000Z" + }, + { + "pid": 81004, + "ppid": 1000, + "tty": "ttys033", + "command": "grok", + "cwd": "/proj/idle", + "startTime": "2026-10-09T05:50:00.000Z" + } + ], + "home": { + ".grok/sessions/%2Fproj%2Femptygroup/sess-void/notes.txt": "no transcript here", + ".grok/sessions/%2Fproj%2Fhist/sess-e/chat_history.jsonl": "", + ".grok/sessions/%2Fproj%2Fidle/sess-i/chat_history.jsonl": "{\"type\":\"user\",\"content\":\"old prompt\"}\n{\"type\":\"assistant\",\"content\":\"answered\"}\n" + }, + "mtimes": { + ".grok/sessions/%2Fproj%2Fhist/sess-e/chat_history.jsonl": 1791532950000, + ".grok/sessions/%2Fproj%2Fidle/sess-i/chat_history.jsonl": 1791525600000 + }, + "registry": [], + "expected": [ + { + "name": "emptygroup-81001", + "type": "grok_cli", + "status": "running", + "summary": "Grok CLI process running", + "pid": 81001, + "projectPath": "/proj/emptygroup", + "sessionId": "pid-81001", + "lastActive": "2026-10-09T08:03:00.000Z" + }, + { + "name": "unknown-81002", + "type": "grok_cli", + "status": "running", + "summary": "Grok CLI process running", + "pid": 81002, + "projectPath": "", + "sessionId": "pid-81002", + "lastActive": "2026-10-09T08:03:00.000Z" + }, + { + "name": "hist-81003", + "type": "grok_cli", + "status": "running", + "summary": "Grok CLI session active", + "pid": 81003, + "projectPath": "/proj/hist", + "sessionId": "sess-e", + "lastActive": "2026-10-09T08:02:30.000Z", + "sessionFilePath": "$FIXTURE_HOME/.grok/sessions/%2Fproj%2Fhist/sess-e/chat_history.jsonl" + }, + { + "name": "idle-81004", + "type": "grok_cli", + "status": "idle", + "summary": "old prompt", + "pid": 81004, + "projectPath": "/proj/idle", + "sessionId": "sess-i", + "lastActive": "2026-10-09T06:00:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.grok/sessions/%2Fproj%2Fidle/sess-i/chat_history.jsonl" + } + ] +} diff --git a/fixtures/harness/grok_cli/matched.json b/fixtures/harness/grok_cli/matched.json new file mode 100644 index 000000000..1f6fff1c1 --- /dev/null +++ b/fixtures/harness/grok_cli/matched.json @@ -0,0 +1,93 @@ +{ + "adapter": "grok_cli", + "capturedAt": "2026-10-09T08:03:00.000Z", + "frozenNow": 1791532980000, + "processes": [ + { + "pid": 80001, + "ppid": 1000, + "tty": "ttys020", + "command": "grok", + "cwd": "/proj/proc-cwd", + "startTime": "2026-10-09T07:50:00.000Z" + }, + { + "pid": 80002, + "ppid": 1000, + "tty": "ttys021", + "command": "grok", + "cwd": "/proj/nosess", + "startTime": "2026-10-09T07:51:00.000Z" + }, + { + "pid": 80003, + "ppid": 1000, + "tty": "ttys022", + "command": "/home/u/.grok/bin/grok", + "cwd": "/proj/active-cwd", + "startTime": "2026-10-09T07:52:00.000Z" + }, + { + "pid": 80030, + "ppid": 1000, + "tty": "ttys023", + "command": "node server.js", + "cwd": "/proj/d", + "startTime": "2026-10-09T07:53:00.000Z" + }, + { + "pid": 80031, + "ppid": 1000, + "tty": "ttys023", + "command": "grok-helper", + "cwd": "/proj/d", + "startTime": "2026-10-09T07:53:00.000Z" + } + ], + "home": { + ".grok/active_sessions.json": "[{\"pid\":80001,\"cwd\":\"/proj/x\",\"opened_at\":1791532000000}]", + ".grok/sessions/%2Fproj%2Fx/sess-old/chat_history.jsonl": "{\"type\":\"user\",\"content\":\"older task\"}\n{\"type\":\"assistant\",\"content\":\"done\"}\n", + ".grok/sessions/%2Fproj%2Fx/sess-new/chat_history.jsonl": "{\"type\":\"user\",\"content\":\"context blob\"}\n{\"type\":\"user\",\"content\":\"fix the flaky test\"}\n{\"type\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"working on \"},{\"type\":\"text\",\"text\":\"it\"}]}\n", + ".grok/sessions/%2Fproj%2Factive-cwd/sess-2/chat_history.jsonl": "{\"type\":\"user\",\"content\":\"second task\"}\n" + }, + "mtimes": { + ".grok/sessions/%2Fproj%2Fx/sess-old/chat_history.jsonl": 1791532000000, + ".grok/sessions/%2Fproj%2Fx/sess-new/chat_history.jsonl": 1791532800000, + ".grok/sessions/%2Fproj%2Factive-cwd/sess-2/chat_history.jsonl": 1791532920000 + }, + "registry": [], + "expected": [ + { + "name": "x-80001", + "type": "grok_cli", + "status": "waiting", + "summary": "fix the flaky test", + "pid": 80001, + "projectPath": "/proj/x", + "sessionId": "sess-new", + "lastActive": "2026-10-09T08:00:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.grok/sessions/%2Fproj%2Fx/sess-new/chat_history.jsonl" + }, + { + "name": "nosess-80002", + "type": "grok_cli", + "status": "running", + "summary": "Grok CLI process running", + "pid": 80002, + "projectPath": "/proj/nosess", + "sessionId": "pid-80002", + "lastActive": "2026-10-09T08:03:00.000Z" + }, + { + "name": "active-cwd-80003", + "type": "grok_cli", + "status": "running", + "summary": "second task", + "pid": 80003, + "projectPath": "/proj/active-cwd", + "sessionId": "sess-2", + "lastActive": "2026-10-09T08:02:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.grok/sessions/%2Fproj%2Factive-cwd/sess-2/chat_history.jsonl" + } + ] +} diff --git a/fixtures/harness/kiro/fallback.json b/fixtures/harness/kiro/fallback.json new file mode 100644 index 000000000..9ad40ab77 --- /dev/null +++ b/fixtures/harness/kiro/fallback.json @@ -0,0 +1,79 @@ +{ + "adapter": "kiro", + "capturedAt": "2025-10-09T12:20:00.000Z", + "frozenNow": 1760012400000, + "processes": [ + { + "pid": 2001, + "ppid": 900, + "tty": "ttys010", + "command": "bun /x/kiro-cli.js", + "cwd": "/proj/f1", + "startTime": "2025-10-09T11:00:00.000Z" + }, + { + "pid": 2002, + "ppid": 900, + "tty": "ttys011", + "command": "kiro", + "startTime": "2025-10-09T11:10:00.000Z" + }, + { + "pid": 2003, + "ppid": 900, + "tty": "ttys012", + "command": "kiro-cli --verbose", + "cwd": "/proj/f3", + "startTime": "2025-10-09T11:20:00.000Z" + }, + { + "pid": 2101, + "ppid": 2001, + "tty": "ttys010", + "command": "kiro-cli-chat acp", + "cwd": "/proj/f1", + "startTime": "2025-10-09T11:00:05.000Z" + }, + { + "pid": 2102, + "ppid": 900, + "tty": "??", + "command": "node /x/unrelated.js", + "cwd": "/proj/other", + "startTime": "2025-10-09T11:30:00.000Z" + } + ], + "home": {}, + "expected": [ + { + "name": "f1-2001", + "type": "kiro", + "status": "running", + "summary": "Kiro process running", + "pid": 2001, + "projectPath": "/proj/f1", + "sessionId": "pid-2001", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "unknown-2002", + "type": "kiro", + "status": "running", + "summary": "Kiro process running", + "pid": 2002, + "projectPath": "", + "sessionId": "pid-2002", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "f3-2003", + "type": "kiro", + "status": "running", + "summary": "Kiro process running", + "pid": 2003, + "projectPath": "/proj/f3", + "sessionId": "pid-2003", + "lastActive": "2025-10-09T12:20:00.000Z" + } + ] +} diff --git a/fixtures/harness/kiro/matched.json b/fixtures/harness/kiro/matched.json new file mode 100644 index 000000000..c6c629174 --- /dev/null +++ b/fixtures/harness/kiro/matched.json @@ -0,0 +1,233 @@ +{ + "adapter": "kiro", + "capturedAt": "2025-10-09T12:20:00.000Z", + "frozenNow": 1760012400000, + "processes": [ + { + "pid": 1001, + "ppid": 900, + "tty": "ttys001", + "command": "kiro-cli", + "cwd": "/proj/alpha", + "startTime": "2025-10-09T11:00:00.000Z" + }, + { + "pid": 1101, + "ppid": 1001, + "tty": "ttys001", + "command": "kiro-cli-chat acp", + "cwd": "/proj/alpha", + "startTime": "2025-10-09T11:00:05.000Z" + }, + { + "pid": 1002, + "ppid": 900, + "tty": "ttys002", + "command": "kiro", + "cwd": "/proj/beta", + "startTime": "2025-10-09T11:05:00.000Z" + }, + { + "pid": 1003, + "ppid": 900, + "tty": "ttys003", + "command": "kiro", + "cwd": "/proj/gamma", + "startTime": "2025-10-09T11:10:00.000Z" + }, + { + "pid": 1102, + "ppid": 9999, + "tty": "ttys003", + "command": "kiro-cli-chat", + "cwd": "/proj/gamma", + "startTime": "2025-10-09T11:10:05.000Z" + }, + { + "pid": 1004, + "ppid": 900, + "tty": "ttys004", + "command": "kiro", + "cwd": "/proj/delta", + "startTime": "2025-10-09T11:15:00.000Z" + }, + { + "pid": 1005, + "ppid": 900, + "tty": "ttys004", + "command": "kiro", + "cwd": "/proj/delta2", + "startTime": "2025-10-09T11:15:10.000Z" + }, + { + "pid": 1103, + "ppid": 9998, + "tty": "ttys004", + "command": "kiro-cli-chat", + "cwd": "/proj/delta", + "startTime": "2025-10-09T11:15:05.000Z" + }, + { + "pid": 1104, + "ppid": 9997, + "tty": "??", + "command": "kiro-cli-chat", + "cwd": "/proj/orph", + "startTime": "2025-10-09T11:20:00.000Z" + }, + { + "pid": 1006, + "ppid": 900, + "tty": "??", + "command": "node /x/kiro-cli.js", + "cwd": "/proj/eps", + "startTime": "2025-10-09T11:25:00.000Z" + }, + { + "pid": 1105, + "ppid": 900, + "tty": "??", + "command": "node /x/other.js", + "cwd": "/proj/other", + "startTime": "2025-10-09T11:26:00.000Z" + }, + { + "pid": 1007, + "ppid": 900, + "tty": "ttys005", + "command": "kiro", + "cwd": "/proj/zeta", + "startTime": "2025-10-09T11:30:00.000Z" + }, + { + "pid": 1008, + "ppid": 900, + "tty": "ttys006", + "command": "/usr/local/bin/kiro-cli", + "cwd": "/proj/eta", + "startTime": "2025-10-09T11:35:00.000Z" + } + ], + "home": { + ".kiro/sessions/cli/s1.lock": "{\"pid\":1101}", + ".kiro/sessions/cli/s1.json": "{\"session_id\":\"sess-alpha\",\"cwd\":\"/proj/alpha\",\"title\":\"alpha chat\",\"updated_at\":\"2025-10-09T12:19:00.000Z\"}", + ".kiro/sessions/cli/s1.jsonl": "{\"version\":\"v1\",\"kind\":\"Prompt\",\"data\":{\"content\":[{\"kind\":\"text\",\"data\":\"fix the alpha bug\"}],\"meta\":{\"timestamp\":1760012280}}}\n{\"version\":\"v1\",\"kind\":\"AssistantMessage\",\"data\":{\"content\":[{\"kind\":\"text\",\"data\":\"on it\"}],\"meta\":{\"timestamp\":1760012340}}}\n", + ".kiro/sessions/cli/s2.lock": "{\"pid\":1002}", + ".kiro/sessions/cli/s2.json": "{\"session_id\":\"sess-beta\",\"cwd\":\"/proj/beta\",\"title\":\"beta\",\"created_at\":\"2025-10-09T11:05:00.000Z\"}", + ".kiro/sessions/cli/s2.jsonl": "{\"version\":\"v1\",\"kind\":\"AssistantMessage\",\"timestamp\":\"2025-10-09T12:15:00.000Z\",\"data\":{\"content\":[{\"kind\":\"text\",\"data\":\"done\"},{\"kind\":\"toolUse\",\"data\":{\"name\":\"exec\"}}]}}\n{\"version\":\"v1\",\"kind\":\"Prompt\",\"timestamp\":\"2025-10-09T12:19:30.000Z\",\"data\":{\"content\":[{\"kind\":\"text\",\"data\":\"run beta tests\"}]}}\n", + ".kiro/sessions/cli/s3.lock": "{\"pid\":1102}", + ".kiro/sessions/cli/s3.jsonl": "{\"version\":\"v1\",\"kind\":\"ToolResults\",\"data\":{\"content\":[{\"kind\":\"toolResult\",\"data\":{\"status\":\"success\"}}]}}\n", + ".kiro/sessions/cli/s4.lock": "{\"pid\":1103}", + ".kiro/sessions/cli/s4.jsonl": "{\"version\":\"v1\",\"kind\":\"Prompt\",\"data\":{\"content\":[{\"kind\":\"text\",\"data\":\"delta\"}]}}\n", + ".kiro/sessions/cli/s5.lock": "{\"pid\":1006}", + ".kiro/sessions/cli/s5.json": "{\"title\":\"Epsilon notes\",\"cwd\":\"\",\"updated_at\":\"2025-10-09T12:14:00.000Z\"}", + ".kiro/sessions/cli/s5.jsonl": "{\"version\":\"v1\",\"kind\":\"AssistantMessage\",\"data\":{\"content\":[{\"kind\":\"text\",\"data\":\"eps reply\"},{\"kind\":\"toolUse\",\"data\":{\"name\":\"x\"}}],\"meta\":{\"timestamp\":1760012040}}}\n{\"version\":\"v1\",\"kind\":\"ToolResults\",\"data\":{\"content\":[]},\"meta\":{\"timestamp\":1760012040}}}\n", + ".kiro/sessions/cli/s6.lock": "{\"pid\":1007}", + ".kiro/sessions/cli/s7.lock": "{pid:", + ".kiro/sessions/cli/s8.lock": "{\"pid\":\"1101\"}", + ".kiro/sessions/cli/s8.json": "{\"session_id\":\"sess-alpha2\",\"title\":\"second\",\"updated_at\":\"2025-10-09T12:19:50.000Z\"}", + ".kiro/sessions/cli/s8.jsonl": "{\"version\":\"v1\",\"kind\":\"Prompt\",\"data\":{\"content\":[{\"kind\":\"text\",\"data\":\"another alpha ask\"}],\"meta\":{\"timestamp\":1760012370}}}\n", + ".kiro/sessions/cli/s9.lock": "{\"pid\":\"abc\"}", + ".kiro/sessions/cli/notes.txt": "ignored" + }, + "mtimes": { + ".kiro/sessions/cli/s3.jsonl": 1760012280000 + }, + "expected": [ + { + "name": "alpha-1001", + "type": "kiro", + "status": "waiting", + "summary": "fix the alpha bug", + "pid": 1001, + "projectPath": "/proj/alpha", + "sessionId": "sess-alpha", + "lastActive": "2025-10-09T12:19:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.kiro/sessions/cli/s1.jsonl" + }, + { + "name": "beta-1002", + "type": "kiro", + "status": "running", + "summary": "run beta tests", + "pid": 1002, + "projectPath": "/proj/beta", + "sessionId": "sess-beta", + "lastActive": "2025-10-09T12:19:30.000Z", + "sessionFilePath": "$FIXTURE_HOME/.kiro/sessions/cli/s2.jsonl" + }, + { + "name": "gamma-1003", + "type": "kiro", + "status": "running", + "summary": "Kiro session active", + "pid": 1003, + "projectPath": "/proj/gamma", + "sessionId": "s3", + "lastActive": "2025-10-09T12:18:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.kiro/sessions/cli/s3.jsonl" + }, + { + "name": "eps-1006", + "type": "kiro", + "status": "idle", + "summary": "Epsilon notes", + "pid": 1006, + "projectPath": "/proj/eps", + "sessionId": "s5", + "lastActive": "2025-10-09T12:14:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.kiro/sessions/cli/s5.jsonl" + }, + { + "name": "alpha-1001", + "type": "kiro", + "status": "running", + "summary": "another alpha ask", + "pid": 1001, + "projectPath": "/proj/alpha", + "sessionId": "sess-alpha2", + "lastActive": "2025-10-09T12:19:50.000Z", + "sessionFilePath": "$FIXTURE_HOME/.kiro/sessions/cli/s8.jsonl" + }, + { + "name": "delta-1004", + "type": "kiro", + "status": "running", + "summary": "Kiro process running", + "pid": 1004, + "projectPath": "/proj/delta", + "sessionId": "pid-1004", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "delta2-1005", + "type": "kiro", + "status": "running", + "summary": "Kiro process running", + "pid": 1005, + "projectPath": "/proj/delta2", + "sessionId": "pid-1005", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "zeta-1007", + "type": "kiro", + "status": "running", + "summary": "Kiro process running", + "pid": 1007, + "projectPath": "/proj/zeta", + "sessionId": "pid-1007", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "eta-1008", + "type": "kiro", + "status": "running", + "summary": "Kiro process running", + "pid": 1008, + "projectPath": "/proj/eta", + "sessionId": "pid-1008", + "lastActive": "2025-10-09T12:20:00.000Z" + } + ] +} diff --git a/fixtures/harness/opencode/fallback.json b/fixtures/harness/opencode/fallback.json new file mode 100644 index 000000000..b95262873 --- /dev/null +++ b/fixtures/harness/opencode/fallback.json @@ -0,0 +1,53 @@ +{ + "adapter": "opencode", + "capturedAt": "2025-10-09T12:20:00.000Z", + "frozenNow": 1760012400000, + "processes": [ + { + "pid": 72001, + "ppid": 1, + "tty": "ttys020", + "command": "opencode", + "cwd": "/proj/nodb", + "startTime": "2025-10-09T12:00:00.000Z" + }, + { + "pid": 72002, + "ppid": 1, + "tty": "ttys021", + "command": "opencode", + "startTime": "2025-10-09T12:01:00.000Z" + }, + { + "pid": 72003, + "ppid": 1, + "tty": "ttys022", + "command": "node /usr/local/bin/other.js", + "cwd": "/proj/foreign", + "startTime": "2025-10-09T12:02:00.000Z" + } + ], + "home": {}, + "expected": [ + { + "name": "nodb-72001", + "type": "opencode", + "status": "running", + "summary": "OpenCode process running", + "pid": 72001, + "projectPath": "/proj/nodb", + "sessionId": "pid-72001", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "unknown-72002", + "type": "opencode", + "status": "running", + "summary": "OpenCode process running", + "pid": 72002, + "projectPath": "", + "sessionId": "pid-72002", + "lastActive": "2025-10-09T12:20:00.000Z" + } + ] +} diff --git a/fixtures/harness/opencode/matched.json b/fixtures/harness/opencode/matched.json new file mode 100644 index 000000000..6137aa4dc --- /dev/null +++ b/fixtures/harness/opencode/matched.json @@ -0,0 +1,141 @@ +{ + "adapter": "opencode", + "capturedAt": "2025-10-09T12:20:00.000Z", + "frozenNow": 1760012400000, + "processes": [ + { + "pid": 71001, + "ppid": 1, + "tty": "ttys010", + "command": "opencode", + "cwd": "/proj/alpha", + "startTime": "2025-10-09T11:40:00.000Z" + }, + { + "pid": 71002, + "ppid": 1, + "tty": "ttys011", + "command": "opencode", + "cwd": "/proj/beta", + "startTime": "2025-10-09T11:50:00.000Z" + }, + { + "pid": 71003, + "ppid": 1, + "tty": "ttys012", + "command": "opencode", + "cwd": "/proj/gamma", + "startTime": "2025-10-09T11:30:00.000Z" + }, + { + "pid": 71004, + "ppid": 1, + "tty": "ttys013", + "command": "opencode", + "cwd": "/proj/delta", + "startTime": "2025-10-09T11:20:00.000Z" + }, + { + "pid": 71005, + "ppid": 1, + "tty": "ttys014", + "command": "opencode", + "startTime": "2025-10-09T12:00:00.000Z" + }, + { + "pid": 71006, + "ppid": 1, + "tty": "ttys015", + "command": "opencode", + "cwd": "/proj/epsilon", + "startTime": "2025-10-09T12:18:00.000Z" + } + ], + "home": {}, + "sqlite": { + ".local/share/opencode/opencode.db": [ + "CREATE TABLE \"session\" (\"id\" TEXT PRIMARY KEY, \"directory\" TEXT NOT NULL, \"time_created\" INTEGER NOT NULL);", + "CREATE TABLE \"message\" (\"id\" TEXT PRIMARY KEY, \"session_id\" TEXT NOT NULL, \"time_created\" INTEGER NOT NULL, \"time_updated\" INTEGER NOT NULL, \"data\" TEXT NOT NULL);", + "CREATE TABLE \"part\" (\"id\" TEXT PRIMARY KEY, \"message_id\" TEXT NOT NULL, \"session_id\" TEXT NOT NULL, \"time_created\" INTEGER NOT NULL, \"time_updated\" INTEGER NOT NULL, \"data\" TEXT NOT NULL);", + "INSERT INTO \"session\" VALUES ('ses-alpha','/proj/alpha',1760010000000);", + "INSERT INTO \"session\" VALUES ('ses-beta','/proj/beta',1760010600000);", + "INSERT INTO \"session\" VALUES ('ses-delta','/proj/delta',1760008800000);", + "INSERT INTO \"session\" VALUES ('ses-epsilon','/proj/epsilon',1760012280000);", + "INSERT INTO \"session\" VALUES ('ses-alpha-old','/proj/alpha',1759900000000);", + "INSERT INTO \"message\" VALUES ('m-a1','ses-alpha',1760010000000,1760010000000,'{\"role\":\"user\"}');", + "INSERT INTO \"message\" VALUES ('m-a2','ses-alpha',1760012200000,1760012200000,'{\"role\":\"assistant\",\"time\":{\"created\":1760012200000}}');", + "INSERT INTO \"message\" VALUES ('m-b1','ses-beta',1760010600000,1760010600000,'{\"role\":\"user\"}');", + "INSERT INTO \"message\" VALUES ('m-b2','ses-beta',1760012280000,1760012280000,'{\"role\":\"assistant\",\"time\":{\"created\":1760012280000,\"completed\":1760012290000}}');", + "INSERT INTO \"message\" VALUES ('m-d1','ses-delta',1760008800000,1760008800000,'{\"role\":\"user\"}');", + "INSERT INTO \"part\" VALUES ('p-a1','m-a1','ses-alpha',1760010001000,1760010001000,'{\"type\":\"text\",\"text\":\"first alpha question\"}');", + "INSERT INTO \"part\" VALUES ('p-a2','m-a2','ses-alpha',1760012201000,1760012201000,'{\"type\":\"tool\",\"tool\":\"bash\"}');", + "INSERT INTO \"part\" VALUES ('p-b1','m-b1','ses-beta',1760010601000,1760010601000,'{\"type\":\"text\",\"text\":\" beta ask \"}');" + ] + }, + "expected": [ + { + "name": "alpha-71001", + "type": "opencode", + "status": "running", + "summary": "first alpha question", + "pid": 71001, + "projectPath": "/proj/alpha", + "sessionId": "ses-alpha", + "lastActive": "2025-10-09T12:16:40.000Z", + "sessionFilePath": "$FIXTURE_HOME/.local/share/opencode/opencode.db::ses-alpha" + }, + { + "name": "beta-71002", + "type": "opencode", + "status": "waiting", + "summary": "beta ask", + "pid": 71002, + "projectPath": "/proj/beta", + "sessionId": "ses-beta", + "lastActive": "2025-10-09T12:18:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.local/share/opencode/opencode.db::ses-beta" + }, + { + "name": "gamma-71003", + "type": "opencode", + "status": "running", + "summary": "OpenCode process running", + "pid": 71003, + "projectPath": "/proj/gamma", + "sessionId": "pid-71003", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "delta-71004", + "type": "opencode", + "status": "idle", + "summary": "OpenCode session active", + "pid": 71004, + "projectPath": "/proj/delta", + "sessionId": "ses-delta", + "lastActive": "2025-10-09T11:20:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.local/share/opencode/opencode.db::ses-delta" + }, + { + "name": "unknown-71005", + "type": "opencode", + "status": "running", + "summary": "OpenCode process running", + "pid": 71005, + "projectPath": "", + "sessionId": "pid-71005", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "epsilon-71006", + "type": "opencode", + "status": "running", + "summary": "OpenCode session active", + "pid": 71006, + "projectPath": "/proj/epsilon", + "sessionId": "ses-epsilon", + "lastActive": "2025-10-09T12:18:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.local/share/opencode/opencode.db::ses-epsilon" + } + ] +} diff --git a/fixtures/harness/pi/fallback.json b/fixtures/harness/pi/fallback.json new file mode 100644 index 000000000..417255bec --- /dev/null +++ b/fixtures/harness/pi/fallback.json @@ -0,0 +1,83 @@ +{ + "adapter": "pi", + "capturedAt": "2026-10-09T08:00:00.000Z", + "frozenNow": 1791532800000, + "processes": [ + { + "pid": 40001, + "ppid": 1, + "tty": "s001", + "command": "pi", + "cwd": "/Users/fixture/stale", + "startTime": "2026-10-09T07:00:00.000Z" + }, + { + "pid": 40002, + "ppid": 1, + "tty": "s002", + "command": "pi", + "cwd": "/Users/fixture/untrusted", + "startTime": "2026-10-09T07:10:00.000Z" + }, + { + "pid": 40003, + "ppid": 1, + "tty": "s003", + "command": "pi", + "cwd": "/Users/fixture/broken", + "startTime": "2026-10-09T07:20:00.000Z" + } + ], + "registry": [ + { + "name": "stale-claude-row", + "type": "claude", + "pid": 40001, + "runtime": "tmux", + "runtimeRef": null, + "cwd": "/Users/fixture/stale", + "startedAt": "2026-10-09T07:00:00.000Z", + "sessionId": "s-stale", + "sessionFilePath": "$FIXTURE_HOME/.pi/agent/sessions/--Users-fixture-stale--/stale.jsonl", + "pinned": false, + "updatedAt": "2026-10-09T07:05:00.000Z" + } + ], + "home": { + ".pi/agent/sessions.json": "{\"40002\":\"/tmp/outside-fixture-sessions.jsonl\",\"40003\":\"$FIXTURE_HOME/.pi/agent/sessions/--Users-fixture-broken--/broken.jsonl\"}", + ".pi/agent/sessions/--Users-fixture-stale--/stale.jsonl": "{\"sessionId\":\"s-stale\",\"cwd\":\"/Users/fixture/stale\",\"timestamp\":\"2026-10-09T07:00:00Z\"}\n", + ".pi/agent/sessions/--Users-fixture-broken--/broken.jsonl": "not json\n\n[1,2]\n" + }, + "expected": [ + { + "name": "stale-40001", + "type": "pi", + "status": "running", + "summary": "Pi process running", + "pid": 40001, + "projectPath": "/Users/fixture/stale", + "sessionId": "pid-40001", + "lastActive": "2026-10-09T08:00:00.000Z" + }, + { + "name": "untrusted-40002", + "type": "pi", + "status": "running", + "summary": "Pi process running", + "pid": 40002, + "projectPath": "/Users/fixture/untrusted", + "sessionId": "pid-40002", + "lastActive": "2026-10-09T08:00:00.000Z" + }, + { + "name": "broken-40003", + "type": "pi", + "status": "running", + "summary": "Pi process running", + "pid": 40003, + "projectPath": "/Users/fixture/broken", + "sessionId": "pid-40003", + "lastActive": "2026-10-09T08:00:00.000Z" + } + ] +} diff --git a/fixtures/harness/pi/matched.json b/fixtures/harness/pi/matched.json new file mode 100644 index 000000000..a7133e842 --- /dev/null +++ b/fixtures/harness/pi/matched.json @@ -0,0 +1,113 @@ +{ + "adapter": "pi", + "capturedAt": "2026-10-09T08:00:00.000Z", + "frozenNow": 1791532800000, + "processes": [ + { + "pid": 30001, + "ppid": 1, + "tty": "s001", + "command": "pi", + "cwd": "/Users/fixture/proj", + "startTime": "2026-10-09T07:00:00.000Z" + }, + { + "pid": 30002, + "ppid": 1, + "tty": "s002", + "command": "node /usr/local/bin/pi.js", + "cwd": "/Users/fixture/other", + "startTime": "2026-10-09T06:30:00.000Z" + }, + { + "pid": 30003, + "ppid": 1, + "tty": "s003", + "command": "pi", + "cwd": "/Users/fixture/legproj", + "startTime": "$NOW" + }, + { + "pid": 30004, + "ppid": 1, + "tty": "s004", + "command": "pi", + "cwd": "/Users/fixture/proj", + "startTime": "2026-10-09T07:59:00.000Z" + }, + { + "pid": 30005, + "ppid": 1, + "tty": "s005", + "command": "pilot run", + "cwd": "/Users/fixture/proj", + "startTime": "2026-10-09T07:59:00.000Z" + } + ], + "registry": [ + { + "name": "seeded-pi", + "type": "pi", + "pid": 30002, + "runtime": "tmux", + "runtimeRef": null, + "cwd": "/Users/fixture/other", + "startedAt": "2026-10-09T06:30:00.000Z", + "sessionId": "regsess", + "sessionFilePath": "$FIXTURE_HOME/.pi/agent/sessions/--Users-fixture-other--/2026-10-09T06-30-00-000Z_regsess.jsonl", + "pinned": false, + "updatedAt": "2026-10-09T06:35:00.000Z" + } + ], + "home": { + ".pi/agent/sessions.json": "{\"30001\":\"$FIXTURE_HOME/.pi/agent/sessions/--Users-fixture-proj--/tracker-sess.jsonl\"}", + ".pi/agent/sessions/--Users-fixture-proj--/tracker-sess.jsonl": "{\"sessionId\":\"tracker-sess\",\"cwd\":\"/Users/fixture/proj\",\"timestamp\":\"2026-10-09T06:55:00Z\"}\n{\"timestamp\":\"2026-10-09T06:56:00Z\",\"role\":\"user\",\"content\":\"summarize the build\"}\n{\"timestamp\":\"2026-10-09T07:58:00Z\",\"role\":\"assistant\",\"content\":\"build summary here\"}\n", + ".pi/agent/sessions/--Users-fixture-other--/2026-10-09T06-30-00-000Z_regsess.jsonl": "{\"sessionId\":\"regsess\",\"cwd\":\"/Users/fixture/other\",\"timestamp\":\"2026-10-09T06:30:00Z\"}\n{\"timestamp\":\"2026-10-09T06:31:00Z\",\"role\":\"user\",\"content\":\"deploy the thing\"}\n{\"timestamp\":\"2026-10-09T07:56:00Z\",\"role\":\"user\",\"content\":\"and now?\"}\n", + ".pi/agent/sessions/--Users-fixture-legproj--/legacysess.jsonl": "{\"sessionId\":\"legacysess\",\"cwd\":\"/Users/fixture/legproj\",\"timestamp\":\"2026-10-09T07:58:00Z\"}\n{\"timestamp\":\"2026-10-09T06:30:00Z\",\"role\":\"user\",\"content\":\"old prompt\"}\n" + }, + "expected": [ + { + "name": "other-30002", + "type": "pi", + "status": "running", + "summary": "and now?", + "pid": 30002, + "projectPath": "/Users/fixture/other", + "sessionId": "regsess", + "lastActive": "2026-10-09T07:56:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.pi/agent/sessions/--Users-fixture-other--/2026-10-09T06-30-00-000Z_regsess.jsonl" + }, + { + "name": "proj-30001", + "type": "pi", + "status": "waiting", + "summary": "summarize the build", + "pid": 30001, + "projectPath": "/Users/fixture/proj", + "sessionId": "tracker-sess", + "lastActive": "2026-10-09T07:58:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.pi/agent/sessions/--Users-fixture-proj--/tracker-sess.jsonl" + }, + { + "name": "legproj-30003", + "type": "pi", + "status": "idle", + "summary": "old prompt", + "pid": 30003, + "projectPath": "/Users/fixture/legproj", + "sessionId": "legacysess", + "lastActive": "2026-10-09T06:30:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.pi/agent/sessions/--Users-fixture-legproj--/legacysess.jsonl" + }, + { + "name": "proj-30004", + "type": "pi", + "status": "running", + "summary": "Pi process running", + "pid": 30004, + "projectPath": "/Users/fixture/proj", + "sessionId": "pid-30004", + "lastActive": "2026-10-09T08:00:00.000Z" + } + ] +} diff --git a/fixtures/readiness/degraded.json b/fixtures/readiness/degraded.json new file mode 100644 index 000000000..b712b19e1 --- /dev/null +++ b/fixtures/readiness/degraded.json @@ -0,0 +1,526 @@ +{ + "options": { + "homeDir": "/home/test", + "path": "/bin", + "assetRoot": "/assets", + "builtInSkillNames": [ + "agent-management", + "memory", + "verify" + ], + "skillRoots": { + "claude": ".claude/skills", + "codex": ".codex/skills", + "copilot": ".copilot/skills", + "gemini_cli": ".gemini/skills", + "grok_cli": ".grok/skills", + "antigravity_cli": ".gemini/config/skills", + "opencode": ".config/opencode/skill", + "pi": ".pi/agent/skills" + } + }, + "files": { + "/home/test/.claude": "", + "/home/test/.codex": "", + "/home/test/.pi": "", + "/home/test/.copilot": "", + "/home/test/.codex/hooks/codex-session-mapping.cjs": "codex-hook", + "/assets/codex/codex-session-mapping.cjs": "different-hook", + "/home/test/.codex/hooks.json": "{\"hooks\":{\"SessionStart\":[{\"hooks\":[{\"type\":\"command\",\"command\":\"node ~/.codex/hooks/codex-session-mapping.cjs\"}]}]}}", + "/home/test/.codex/ai-devkit/sessions.json": "{\"abc\":\"/sessions/x.jsonl\",\"123\":\"/sessions/gone.jsonl\",\"456\":\"/sessions/ok.jsonl\"}", + "/sessions/ok.jsonl": "", + "/home/test/.claude/hooks/claude-prompt-hook.js": "claude-hook", + "/assets/claude/claude-prompt-hook.js": "claude-hook", + "/home/test/.claude/settings.json": "{\"hooks\":{\"PreToolUse\":[]}}", + "/home/test/.pi/agent/sessions.json": "{\"9\":\"/sessions/pi.jsonl\"}", + "/sessions/pi.jsonl": "", + "/home/test/.pi/agent/auth.json": "{\"provider\":\"\"}", + "/home/test/.claude/skills/memory/SKILL.md": "# skill", + "/home/test/.codex/skills/agent-management/SKILL.md": "# skill", + "/home/test/.codex/skills/memory/SKILL.md": "# skill", + "/home/test/.codex/skills/verify/SKILL.md": "# skill" + }, + "executables": [ + "/bin/claude", + "/bin/codex", + "/bin/pi", + "/bin/devin" + ], + "commands": { + "pi list": { + "stdout": "npm:@ai-devkit/other-plugin\n" + }, + "claude auth status --json": { + "stdout": "{\"loggedIn\":false}" + }, + "gh auth status --hostname github.com": { + "ok": false, + "stdout": "" + }, + "opencode auth list": { + "stdout": "" + }, + "devin auth status": { + "stdout": "Not logged in\n" + } + }, + "codexAuth": false, + "expected": { + "claude": { + "type": "claude", + "executable": { + "command": "claude", + "path": "/bin/claude", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.claude", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": "~/.claude/skills", + "required": 3, + "present": 1, + "missing": [ + "agent-management", + "verify" + ], + "status": "info", + "errors": [] + }, + "auth": { + "state": "unauthenticated", + "source": "claude auth status --json", + "provider": null, + "availableProviders": [], + "status": "fail", + "errors": [ + "Claude is not authenticated" + ] + }, + "integration": { + "label": "ai-devkit hook", + "installed": false, + "status": "fail", + "errors": [ + "required hook registration is missing" + ], + "details": { + "promptScript": { + "path": "~/.claude/hooks/claude-prompt-hook.js", + "present": true, + "readable": true, + "matchesBundledAsset": true, + "status": "pass", + "errors": [] + }, + "registration": { + "path": "~/.claude/settings.json", + "event": "PreToolUse", + "command": "node ~/.claude/hooks/claude-prompt-hook.js", + "present": true, + "valid": false, + "status": "fail", + "errors": [ + "required hook registration is missing" + ] + } + } + }, + "status": "fail" + }, + "codex": { + "type": "codex", + "executable": { + "command": "codex", + "path": "/bin/codex", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.codex", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": "~/.codex/skills", + "required": 3, + "present": 3, + "missing": [], + "status": "info", + "errors": [] + }, + "auth": { + "state": "unauthenticated", + "source": "~/.codex/auth.json", + "provider": null, + "availableProviders": [], + "status": "fail", + "errors": [ + "Codex is not authenticated" + ] + }, + "integration": { + "label": "ai-devkit hook", + "installed": false, + "status": "fail", + "errors": [ + "hook script differs from the bundled AI DevKit asset", + "session mapping contains invalid entries" + ], + "details": { + "sessionMappingScript": { + "path": "~/.codex/hooks/codex-session-mapping.cjs", + "present": true, + "readable": true, + "matchesBundledAsset": false, + "status": "fail", + "errors": [ + "hook script differs from the bundled AI DevKit asset" + ] + }, + "registration": { + "path": "~/.codex/hooks.json", + "event": "SessionStart", + "command": "node ~/.codex/hooks/codex-session-mapping.cjs", + "present": true, + "valid": true, + "status": "pass", + "errors": [] + }, + "mappingFile": { + "path": "~/.codex/ai-devkit/sessions.json", + "present": true, + "valid": false, + "invalidEntries": 1, + "staleEntries": 1, + "status": "fail", + "errors": [ + "session mapping contains invalid entries" + ] + } + } + }, + "status": "fail" + }, + "gemini_cli": { + "type": "gemini_cli", + "executable": { + "command": "gemini", + "path": null, + "status": "fail", + "errors": [ + "gemini was not found on PATH" + ] + }, + "globalConfig": { + "path": "~/.gemini", + "present": false, + "readable": false, + "status": "fail", + "errors": [ + "global configuration directory is unavailable" + ] + }, + "builtInSkills": { + "path": "~/.gemini/skills", + "required": 3, + "present": 0, + "missing": [ + "agent-management", + "memory", + "verify" + ], + "status": "info", + "errors": [] + }, + "status": "fail" + }, + "grok_cli": { + "type": "grok_cli", + "executable": { + "command": "grok", + "path": null, + "status": "fail", + "errors": [ + "grok was not found on PATH" + ] + }, + "globalConfig": { + "path": "~/.grok", + "present": false, + "readable": false, + "status": "fail", + "errors": [ + "global configuration directory is unavailable" + ] + }, + "builtInSkills": { + "path": "~/.grok/skills", + "required": 3, + "present": 0, + "missing": [ + "agent-management", + "memory", + "verify" + ], + "status": "info", + "errors": [] + }, + "status": "fail" + }, + "kiro": { + "type": "kiro", + "executable": { + "command": "kiro-cli", + "path": null, + "status": "fail", + "errors": [ + "kiro-cli was not found on PATH" + ] + }, + "globalConfig": { + "path": "~/.kiro", + "present": false, + "readable": false, + "status": "fail", + "errors": [ + "global configuration directory is unavailable" + ] + }, + "builtInSkills": { + "path": null, + "required": 3, + "present": 0, + "missing": [ + "agent-management", + "memory", + "verify" + ], + "status": "info", + "errors": [] + }, + "status": "fail" + }, + "antigravity_cli": { + "type": "antigravity_cli", + "executable": { + "command": "agy", + "path": null, + "status": "fail", + "errors": [ + "agy was not found on PATH" + ] + }, + "globalConfig": { + "path": "~/.gemini/antigravity-cli", + "present": false, + "readable": false, + "status": "fail", + "errors": [ + "global configuration directory is unavailable" + ] + }, + "builtInSkills": { + "path": "~/.gemini/config/skills", + "required": 3, + "present": 0, + "missing": [ + "agent-management", + "memory", + "verify" + ], + "status": "info", + "errors": [] + }, + "status": "fail" + }, + "opencode": { + "type": "opencode", + "executable": { + "command": "opencode", + "path": null, + "status": "fail", + "errors": [ + "opencode was not found on PATH" + ] + }, + "globalConfig": { + "path": "~/.config/opencode", + "present": false, + "readable": false, + "status": "fail", + "errors": [ + "global configuration directory is unavailable" + ] + }, + "builtInSkills": { + "path": "~/.config/opencode/skill", + "required": 3, + "present": 0, + "missing": [ + "agent-management", + "memory", + "verify" + ], + "status": "info", + "errors": [] + }, + "auth": { + "state": "unauthenticated", + "source": "opencode auth list", + "provider": null, + "availableProviders": [], + "status": "fail", + "errors": [ + "OpenCode has no configured credentials" + ] + }, + "status": "fail" + }, + "copilot": { + "type": "copilot", + "executable": { + "command": "copilot", + "path": null, + "status": "fail", + "errors": [ + "copilot was not found on PATH" + ] + }, + "globalConfig": { + "path": "~/.copilot", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": "~/.copilot/skills", + "required": 3, + "present": 0, + "missing": [ + "agent-management", + "memory", + "verify" + ], + "status": "info", + "errors": [] + }, + "auth": { + "state": "unknown", + "source": "gh auth status --hostname github.com", + "provider": null, + "availableProviders": [ + "GitHub" + ], + "status": "warn", + "errors": [ + "Copilot authentication could not be verified through GitHub CLI" + ] + }, + "status": "fail" + }, + "pi": { + "type": "pi", + "executable": { + "command": "pi", + "path": "/bin/pi", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.pi", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": "~/.pi/agent/skills", + "required": 3, + "present": 0, + "missing": [ + "agent-management", + "memory", + "verify" + ], + "status": "info", + "errors": [] + }, + "auth": { + "state": "unauthenticated", + "source": "~/.pi/agent/auth.json", + "provider": null, + "availableProviders": [], + "status": "fail", + "errors": [ + "Pi credential file has no configured model provider" + ] + }, + "integration": { + "label": "ai-devkit plugin", + "installed": false, + "status": "fail", + "errors": [ + "Pi session tracker is not registered" + ], + "details": { + "package": "@ai-devkit/pi-session-tracker", + "registryPath": "~/.pi/agent/sessions.json", + "registryValid": true, + "invalidEntries": 0, + "staleEntries": 0 + } + }, + "status": "fail" + }, + "devin": { + "type": "devin", + "executable": { + "command": "devin", + "path": "/bin/devin", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.config/devin", + "present": false, + "readable": false, + "status": "fail", + "errors": [ + "global configuration directory is unavailable" + ] + }, + "builtInSkills": { + "path": null, + "required": 3, + "present": 0, + "missing": [ + "agent-management", + "memory", + "verify" + ], + "status": "info", + "errors": [] + }, + "auth": { + "state": "unauthenticated", + "source": "devin auth status", + "provider": null, + "availableProviders": [], + "status": "fail", + "errors": [ + "Devin is not logged in" + ] + }, + "status": "fail" + } + } +} diff --git a/fixtures/readiness/healthy.json b/fixtures/readiness/healthy.json new file mode 100644 index 000000000..4e142a49c --- /dev/null +++ b/fixtures/readiness/healthy.json @@ -0,0 +1,488 @@ +{ + "options": { + "homeDir": "/home/test", + "path": "/bin", + "assetRoot": "/assets", + "builtInSkillNames": [ + "agent-management", + "memory", + "verify" + ], + "skillRoots": { + "claude": ".claude/skills", + "codex": ".codex/skills", + "copilot": ".copilot/skills", + "gemini_cli": ".gemini/skills", + "grok_cli": ".grok/skills", + "antigravity_cli": ".gemini/config/skills", + "opencode": ".config/opencode/skill", + "pi": ".pi/agent/skills" + } + }, + "files": { + "/home/test/.claude": "", + "/home/test/.codex": "", + "/home/test/.copilot": "", + "/home/test/.gemini": "", + "/home/test/.grok": "", + "/home/test/.kiro": "", + "/home/test/.gemini/antigravity-cli": "", + "/home/test/.config/opencode": "", + "/home/test/.config/devin": "", + "/home/test/.pi": "", + "/home/test/.codex/hooks/codex-session-mapping.cjs": "codex-hook", + "/assets/codex/codex-session-mapping.cjs": "codex-hook", + "/home/test/.codex/hooks.json": "{\"hooks\":{\"SessionStart\":[{\"hooks\":[{\"type\":\"command\",\"command\":\"node ~/.codex/hooks/codex-session-mapping.cjs\"}]}]}}", + "/home/test/.codex/ai-devkit/sessions.json": "{\"123\":\"/sessions/codex.jsonl\"}", + "/sessions/codex.jsonl": "", + "/home/test/.claude/hooks/claude-prompt-hook.js": "claude-hook", + "/assets/claude/claude-prompt-hook.js": "claude-hook", + "/home/test/.claude/settings.json": "{\"hooks\":{\"PreToolUse\":[{\"hooks\":[{\"type\":\"command\",\"command\":\"node ~/.claude/hooks/claude-prompt-hook.js\"}]}]}}", + "/home/test/.pi/agent/sessions.json": "{\"456\":\"/sessions/missing-pi.jsonl\"}", + "/home/test/.pi/agent/auth.json": "{\"anthropic\":{\"access\":\"access-secret\",\"refresh\":\"refresh-secret\"},\"litellm\":{\"key\":\"litellm-secret\",\"env\":{\"LITELLM_BASE_URL\":\"https://litellm.example.test\"}}}", + "/home/test/.claude/skills/agent-management/SKILL.md": "# skill", + "/home/test/.claude/skills/memory/SKILL.md": "# skill", + "/home/test/.claude/skills/verify/SKILL.md": "# skill", + "/home/test/.codex/skills/agent-management/SKILL.md": "# skill", + "/home/test/.codex/skills/memory/SKILL.md": "# skill", + "/home/test/.codex/skills/verify/SKILL.md": "# skill", + "/home/test/.copilot/skills/agent-management/SKILL.md": "# skill", + "/home/test/.copilot/skills/memory/SKILL.md": "# skill", + "/home/test/.copilot/skills/verify/SKILL.md": "# skill", + "/home/test/.gemini/skills/agent-management/SKILL.md": "# skill", + "/home/test/.gemini/skills/memory/SKILL.md": "# skill", + "/home/test/.gemini/skills/verify/SKILL.md": "# skill", + "/home/test/.grok/skills/agent-management/SKILL.md": "# skill", + "/home/test/.grok/skills/memory/SKILL.md": "# skill", + "/home/test/.grok/skills/verify/SKILL.md": "# skill", + "/home/test/.gemini/config/skills/agent-management/SKILL.md": "# skill", + "/home/test/.gemini/config/skills/memory/SKILL.md": "# skill", + "/home/test/.gemini/config/skills/verify/SKILL.md": "# skill", + "/home/test/.config/opencode/skill/agent-management/SKILL.md": "# skill", + "/home/test/.config/opencode/skill/memory/SKILL.md": "# skill", + "/home/test/.config/opencode/skill/verify/SKILL.md": "# skill", + "/home/test/.pi/agent/skills/agent-management/SKILL.md": "# skill", + "/home/test/.pi/agent/skills/memory/SKILL.md": "# skill", + "/home/test/.pi/agent/skills/verify/SKILL.md": "# skill" + }, + "executables": [ + "/bin/claude", + "/bin/codex", + "/bin/gemini", + "/bin/grok", + "/bin/kiro-cli", + "/bin/agy", + "/bin/opencode", + "/bin/copilot", + "/bin/pi", + "/bin/devin" + ], + "commands": { + "pi list": { + "stdout": "npm:@ai-devkit/pi-session-tracker\n" + }, + "claude auth status --json": { + "stdout": "{\"loggedIn\":true}" + }, + "gh auth status --hostname github.com": { + "stdout": "github.com\n Logged in to github.com account test-user\n" + }, + "opencode auth list": { + "stdout": "● litellm api\n● OpenAI oauth\n" + }, + "devin auth status": { + "stdout": "Logged in (via Devin).\n\nUser:\n Name: test-user\n" + } + }, + "codexAuth": true, + "expected": { + "claude": { + "type": "claude", + "executable": { + "command": "claude", + "path": "/bin/claude", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.claude", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": "~/.claude/skills", + "required": 3, + "present": 3, + "missing": [], + "status": "info", + "errors": [] + }, + "auth": { + "state": "authenticated", + "source": "claude auth status --json", + "provider": null, + "availableProviders": [], + "status": "pass", + "errors": [] + }, + "integration": { + "label": "ai-devkit hook", + "installed": true, + "status": "pass", + "errors": [], + "details": { + "promptScript": { + "path": "~/.claude/hooks/claude-prompt-hook.js", + "present": true, + "readable": true, + "matchesBundledAsset": true, + "status": "pass", + "errors": [] + }, + "registration": { + "path": "~/.claude/settings.json", + "event": "PreToolUse", + "command": "node ~/.claude/hooks/claude-prompt-hook.js", + "present": true, + "valid": true, + "status": "pass", + "errors": [] + } + } + }, + "status": "pass" + }, + "codex": { + "type": "codex", + "executable": { + "command": "codex", + "path": "/bin/codex", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.codex", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": "~/.codex/skills", + "required": 3, + "present": 3, + "missing": [], + "status": "info", + "errors": [] + }, + "auth": { + "state": "authenticated", + "source": "~/.codex/auth.json", + "provider": null, + "availableProviders": [], + "status": "pass", + "errors": [] + }, + "integration": { + "label": "ai-devkit hook", + "installed": true, + "status": "pass", + "errors": [], + "details": { + "sessionMappingScript": { + "path": "~/.codex/hooks/codex-session-mapping.cjs", + "present": true, + "readable": true, + "matchesBundledAsset": true, + "status": "pass", + "errors": [] + }, + "registration": { + "path": "~/.codex/hooks.json", + "event": "SessionStart", + "command": "node ~/.codex/hooks/codex-session-mapping.cjs", + "present": true, + "valid": true, + "status": "pass", + "errors": [] + }, + "mappingFile": { + "path": "~/.codex/ai-devkit/sessions.json", + "present": true, + "valid": true, + "invalidEntries": 0, + "staleEntries": 0, + "status": "pass", + "errors": [] + } + } + }, + "status": "pass" + }, + "gemini_cli": { + "type": "gemini_cli", + "executable": { + "command": "gemini", + "path": "/bin/gemini", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.gemini", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": "~/.gemini/skills", + "required": 3, + "present": 3, + "missing": [], + "status": "info", + "errors": [] + }, + "status": "pass" + }, + "grok_cli": { + "type": "grok_cli", + "executable": { + "command": "grok", + "path": "/bin/grok", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.grok", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": "~/.grok/skills", + "required": 3, + "present": 3, + "missing": [], + "status": "info", + "errors": [] + }, + "status": "pass" + }, + "kiro": { + "type": "kiro", + "executable": { + "command": "kiro-cli", + "path": "/bin/kiro-cli", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.kiro", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": null, + "required": 3, + "present": 0, + "missing": [ + "agent-management", + "memory", + "verify" + ], + "status": "info", + "errors": [] + }, + "status": "pass" + }, + "antigravity_cli": { + "type": "antigravity_cli", + "executable": { + "command": "agy", + "path": "/bin/agy", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.gemini/antigravity-cli", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": "~/.gemini/config/skills", + "required": 3, + "present": 3, + "missing": [], + "status": "info", + "errors": [] + }, + "status": "pass" + }, + "opencode": { + "type": "opencode", + "executable": { + "command": "opencode", + "path": "/bin/opencode", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.config/opencode", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": "~/.config/opencode/skill", + "required": 3, + "present": 3, + "missing": [], + "status": "info", + "errors": [] + }, + "auth": { + "state": "authenticated", + "source": "opencode auth list", + "provider": null, + "availableProviders": [ + "OpenAI", + "litellm" + ], + "status": "pass", + "errors": [] + }, + "status": "pass" + }, + "copilot": { + "type": "copilot", + "executable": { + "command": "copilot", + "path": "/bin/copilot", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.copilot", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": "~/.copilot/skills", + "required": 3, + "present": 3, + "missing": [], + "status": "info", + "errors": [] + }, + "auth": { + "state": "authenticated", + "source": "gh auth status --hostname github.com", + "provider": "github", + "availableProviders": [ + "GitHub" + ], + "status": "pass", + "errors": [] + }, + "status": "pass" + }, + "pi": { + "type": "pi", + "executable": { + "command": "pi", + "path": "/bin/pi", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.pi", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": "~/.pi/agent/skills", + "required": 3, + "present": 3, + "missing": [], + "status": "info", + "errors": [] + }, + "auth": { + "state": "authenticated", + "source": "~/.pi/agent/auth.json", + "provider": null, + "availableProviders": [ + "anthropic", + "litellm" + ], + "status": "pass", + "errors": [] + }, + "integration": { + "label": "ai-devkit plugin", + "installed": true, + "status": "pass", + "errors": [], + "details": { + "package": "@ai-devkit/pi-session-tracker", + "registryPath": "~/.pi/agent/sessions.json", + "registryValid": true, + "invalidEntries": 0, + "staleEntries": 1 + } + }, + "status": "pass" + }, + "devin": { + "type": "devin", + "executable": { + "command": "devin", + "path": "/bin/devin", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.config/devin", + "present": true, + "readable": true, + "status": "pass", + "errors": [] + }, + "builtInSkills": { + "path": null, + "required": 3, + "present": 0, + "missing": [ + "agent-management", + "memory", + "verify" + ], + "status": "info", + "errors": [] + }, + "auth": { + "state": "authenticated", + "source": "devin auth status", + "provider": null, + "availableProviders": [ + "devin" + ], + "status": "pass", + "errors": [] + }, + "status": "pass" + } + } +} diff --git a/npm/devkitd-darwin-arm64/package.json b/npm/devkitd-darwin-arm64/package.json new file mode 100644 index 000000000..cdf7718c9 --- /dev/null +++ b/npm/devkitd-darwin-arm64/package.json @@ -0,0 +1,17 @@ +{ + "name": "@ai-devkit/devkitd-darwin-arm64", + "version": "0.0.0", + "description": "devkitd binary for macOS arm64", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/codeaholicguy/ai-devkit.git", + "directory": "npm/devkitd-darwin-arm64" + }, + "os": ["darwin"], + "cpu": ["arm64"], + "files": ["devkitd"], + "publishConfig": { + "access": "public" + } +} diff --git a/npm/devkitd-darwin-x64/package.json b/npm/devkitd-darwin-x64/package.json new file mode 100644 index 000000000..930b5316e --- /dev/null +++ b/npm/devkitd-darwin-x64/package.json @@ -0,0 +1,17 @@ +{ + "name": "@ai-devkit/devkitd-darwin-x64", + "version": "0.0.0", + "description": "devkitd binary for macOS x64", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/codeaholicguy/ai-devkit.git", + "directory": "npm/devkitd-darwin-x64" + }, + "os": ["darwin"], + "cpu": ["x64"], + "files": ["devkitd"], + "publishConfig": { + "access": "public" + } +} diff --git a/npm/devkitd-linux-arm64/package.json b/npm/devkitd-linux-arm64/package.json new file mode 100644 index 000000000..730a1506f --- /dev/null +++ b/npm/devkitd-linux-arm64/package.json @@ -0,0 +1,17 @@ +{ + "name": "@ai-devkit/devkitd-linux-arm64", + "version": "0.0.0", + "description": "devkitd binary for Linux arm64 (static musl — runs on glibc and musl)", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/codeaholicguy/ai-devkit.git", + "directory": "npm/devkitd-linux-arm64" + }, + "os": ["linux"], + "cpu": ["arm64"], + "files": ["devkitd"], + "publishConfig": { + "access": "public" + } +} diff --git a/npm/devkitd-linux-x64/package.json b/npm/devkitd-linux-x64/package.json new file mode 100644 index 000000000..1dc63f5fa --- /dev/null +++ b/npm/devkitd-linux-x64/package.json @@ -0,0 +1,17 @@ +{ + "name": "@ai-devkit/devkitd-linux-x64", + "version": "0.0.0", + "description": "devkitd binary for Linux x64 (static musl — runs on glibc and musl)", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/codeaholicguy/ai-devkit.git", + "directory": "npm/devkitd-linux-x64" + }, + "os": ["linux"], + "cpu": ["x64"], + "files": ["devkitd"], + "publishConfig": { + "access": "public" + } +} diff --git a/package-lock.json b/package-lock.json index 407c86782..d150d1f92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-devkit", - "version": "0.67.0", + "version": "0.68.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-devkit", - "version": "0.67.0", + "version": "0.68.0", "license": "MIT", "workspaces": [ "apps/*", @@ -32,6 +32,10 @@ "resolved": "packages/channel-connector", "link": true }, + "node_modules/@ai-devkit/daemon-client": { + "resolved": "packages/daemon-client", + "link": true + }, "node_modules/@ai-devkit/memory": { "resolved": "packages/memory", "link": true @@ -3653,6 +3657,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@hono/node-server": { "version": "1.19.17", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", @@ -8574,6 +9020,48 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "dev": true, @@ -13611,6 +14099,7 @@ "dependencies": { "@ai-devkit/agent-manager": "0.40.0", "@ai-devkit/channel-connector": "0.13.4", + "@ai-devkit/daemon-client": "0.1.0", "@ai-devkit/memory": "0.20.3", "@inquirer/prompts": "^8.5.2", "@typesafe-ai/sdk": "^0.6.0", @@ -13631,7 +14120,7 @@ "zod": "^3.25.76" }, "bin": { - "ai-devkit": "dist/cli.js" + "ai-devkit": "dist/bundle/cli.js" }, "devDependencies": { "@swc/cli": "^0.8.1", @@ -13641,6 +14130,7 @@ "@types/node": "^20.11.5", "@types/react": "^19.2.0", "@vitest/coverage-v8": "^4.1.8", + "esbuild": "^0.28.2", "ts-node": "^10.9.2", "typescript": "^5.5.0", "vitest": "^4.1.8" @@ -13843,6 +14333,17 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/daemon-client": { + "name": "@ai-devkit/daemon-client", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@swc/cli": "^0.8.1", + "@swc/core": "^1.10.0", + "@types/node": "^20.11.5", + "vitest": "^4.1.8" + } + }, "packages/memory": { "name": "@ai-devkit/memory", "version": "0.20.3", diff --git a/packages/agent-manager/src/AgentManager.ts b/packages/agent-manager/src/AgentManager.ts index 0f4b153f6..dbef58982 100644 --- a/packages/agent-manager/src/AgentManager.ts +++ b/packages/agent-manager/src/AgentManager.ts @@ -8,6 +8,8 @@ import type { AgentAdapter, AgentInfo, + AgentStatus, + AgentType, SessionSummary, ListSessionsOptions, ProcessInfo, @@ -45,6 +47,20 @@ export interface AgentManagerOptions { | (() => AgentRuntimeProvider | Promise); fetchHerdrAgentPanes?: () => Promise; onRuntimeDiscoveryError?: (error: unknown) => void; + /** + * Daemon-side enriched agents ({@link DaemonClient.listAgents} wire + * shape: `AgentInfo` with `lastActive` as an ISO string). A non-null + * result is authoritative for every harness type — all local adapters + * are skipped. Returning null or throwing forces the local path; + * omitting it disables daemon consumption. + */ + fetchEnrichedAgents?: () => Promise & { + type: string; + status: string; + lastActive: string; + } + > | null>; } class AgentNotRunningError extends Error { @@ -162,7 +178,27 @@ export class AgentManager { const allAgents: AgentInfo[] = []; const errors: Array<{ type: string; error: Error }> = []; - const adapters = Array.from(this.adapters.values()); + // A non-null daemon answer is authoritative for every harness type, so + // local adapters only run when the daemon is absent. + let daemonAgents: AgentInfo[] | null = null; + if (this.options.fetchEnrichedAgents) { + try { + const enriched = await this.options.fetchEnrichedAgents(); + if (enriched) { + daemonAgents = enriched.map((a) => ({ + ...a, + type: a.type as AgentType, + status: a.status as AgentStatus, + lastActive: new Date(a.lastActive), + })); + } + } catch { + daemonAgents = null; + } + } + if (daemonAgents) allAgents.push(...daemonAgents); + + const adapters = daemonAgents ? [] : Array.from(this.adapters.values()); const processNames = Array.from( new Set( adapters.flatMap((adapter) => (adapter.processNames ? [...adapter.processNames] : [])), diff --git a/packages/agent-manager/src/__tests__/AgentManager.test.ts b/packages/agent-manager/src/__tests__/AgentManager.test.ts index 3f7dcce6c..599995b81 100644 --- a/packages/agent-manager/src/__tests__/AgentManager.test.ts +++ b/packages/agent-manager/src/__tests__/AgentManager.test.ts @@ -478,6 +478,90 @@ describe("AgentManager", () => { }); }); + describe("listAgents — daemon enrichment", () => { + const wireAgent = (overrides: Record = {}) => ({ + name: "daemon-claude", + type: "claude", + status: "waiting", + summary: "daemon summary", + pid: 777, + projectPath: "/daemon/proj", + sessionId: "daemon-sid", + lastActive: "2026-10-08T12:00:00.000Z", + ...overrides, + }); + + it("serves agents from the daemon and skips all local adapters", async () => { + const local = new MockAdapter("claude", [createMockAgent({ name: "local-claude" })]); + const detectSpy = vi.spyOn(local, "detectAgents"); + const m = new AgentManager(new AgentRegistry(path.join(tmpDir, "agents.json")), undefined, { + fetchEnrichedAgents: async () => [wireAgent()], + }); + m.registerAdapter(local); + + const agents = await m.listAgents(); + expect(detectSpy).not.toHaveBeenCalled(); + expect(agents).toHaveLength(1); + expect(agents[0].name).toBe("daemon-claude"); + expect(agents[0].lastActive).toEqual(new Date("2026-10-08T12:00:00.000Z")); + }); + + it("falls back to all-local when the daemon returns null", async () => { + const m = new AgentManager(new AgentRegistry(path.join(tmpDir, "agents.json")), undefined, { + fetchEnrichedAgents: async () => null, + }); + m.registerAdapter(new MockAdapter("claude", [createMockAgent({ name: "local-claude" })])); + const agents = await m.listAgents(); + expect(agents.map((a) => a.name)).toEqual(["local-claude"]); + }); + + it("falls back to all-local when the daemon call throws", async () => { + const m = new AgentManager(new AgentRegistry(path.join(tmpDir, "agents.json")), undefined, { + fetchEnrichedAgents: async () => { + throw new Error("socket gone"); + }, + }); + m.registerAdapter(new MockAdapter("claude", [createMockAgent({ name: "local-claude" })])); + const agents = await m.listAgents(); + expect(agents.map((a) => a.name)).toEqual(["local-claude"]); + }); + + it("serves every type from the daemon in one sweep", async () => { + const claudeLocal = new MockAdapter("claude", [createMockAgent({ name: "local-claude" })]); + const codexLocal = new MockAdapter("codex", [ + createMockAgent({ name: "local-codex", type: "codex" }), + ]); + const claudeSpy = vi.spyOn(claudeLocal, "detectAgents"); + const codexSpy = vi.spyOn(codexLocal, "detectAgents"); + const m = new AgentManager(new AgentRegistry(path.join(tmpDir, "agents.json")), undefined, { + fetchEnrichedAgents: async () => [ + wireAgent(), + wireAgent({ type: "codex", name: "daemon-codex", pid: 888 }), + ], + }); + m.registerAdapter(claudeLocal); + m.registerAdapter(codexLocal); + + const agents = await m.listAgents(); + expect(claudeSpy).not.toHaveBeenCalled(); + expect(codexSpy).not.toHaveBeenCalled(); + expect(agents.map((a) => a.name).sort()).toEqual(["daemon-claude", "daemon-codex"]); + }); + + it("treats an empty daemon answer as authoritative", async () => { + const local = new MockAdapter("claude", [createMockAgent({ name: "local-claude" })]); + const detectSpy = vi.spyOn(local, "detectAgents"); + const m = new AgentManager(new AgentRegistry(path.join(tmpDir, "agents.json")), undefined, { + fetchEnrichedAgents: async () => [], + }); + m.registerAdapter(local); + + const agents = await m.listAgents(); + expect(detectSpy).not.toHaveBeenCalled(); + expect(agents).toEqual([]); + }); + }); + describe("listAgents — registry persistence", () => { let tmpDir: string; let regPath: string; diff --git a/packages/agent-manager/src/__tests__/fixtures.test.ts b/packages/agent-manager/src/__tests__/fixtures.test.ts new file mode 100644 index 000000000..f943ce24d --- /dev/null +++ b/packages/agent-manager/src/__tests__/fixtures.test.ts @@ -0,0 +1,247 @@ +/** + * Fixture replay: materialize each committed bundle into a fake HOME, + * run the adapter's detectAgents with the recorded processes + frozen + * clock, and require byte-identical output to `expected`. + * + * The same bundles are consumed by devkit-harness Rust tests — this file is + * the TS↔TS side of the parity oracle. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import Database from "better-sqlite3"; +import { createBuiltinAdapters } from "../harnesses/index.js"; +import { CodexAdapter } from "../harnesses/codex/CodexAdapter.js"; +import { PiAdapter } from "../harnesses/pi/PiAdapter.js"; +import { GeminiCliAdapter } from "../harnesses/gemini/GeminiCliAdapter.js"; +import { CopilotAdapter } from "../harnesses/copilot/CopilotAdapter.js"; +import { AgentRegistry, type RegistryEntry } from "../utils/AgentRegistry.js"; +import { expand, expandNow, normalizeAgents, withFrozenClock } from "../fixtures/bundle.js"; +import type { FixtureBundle } from "../fixtures/bundle.js"; +import { captureLive, FIXTURES_ROOT } from "../fixtures/capture.js"; + +const bundlePaths = fs.existsSync(FIXTURES_ROOT) + ? fs + .readdirSync(FIXTURES_ROOT, { withFileTypes: true }) + .flatMap((d) => + d.isDirectory() + ? fs + .readdirSync(path.join(FIXTURES_ROOT, d.name)) + .filter((f) => f.endsWith(".json")) + .map((f) => path.join(FIXTURES_ROOT, d.name, f)) + : [], + ) + : []; + +/** Adapters whose detect path consults AgentRegistry. */ +const REGISTRY_AWARE = new Set(["codex", "pi", "gemini_cli", "copilot"]); + +function adapterFor(type: string, registry?: AgentRegistry) { + // Adapters that consult the registry during detection get the seeded + // isolated instance so replay never reads real ~/.ai-devkit state. + if (type === "codex" && registry) return new CodexAdapter(registry); + if (type === "pi" && registry) return new PiAdapter(registry); + if (type === "gemini_cli" && registry) return new GeminiCliAdapter(registry); + if (type === "copilot" && registry) return new CopilotAdapter(registry); + const adapter = createBuiltinAdapters().find((a) => a.type === type); + if (!adapter) throw new Error(`no builtin adapter for "${type}"`); + return adapter; +} + +/** Seed an isolated registry at `/.ai-devkit/agents.json`. */ +function seedRegistry(bundle: FixtureBundle, home: string, nowIso: string): AgentRegistry | undefined { + if (!REGISTRY_AWARE.has(bundle.adapter)) return undefined; + const registry = new AgentRegistry(path.join(home, ".ai-devkit", "agents.json")); + const entries = (bundle.registry ?? []).map( + (e) => + ({ + name: "", + type: bundle.adapter, + runtime: "tmux", + runtimeRef: null, + cwd: "", + startedAt: nowIso, + sessionId: "", + sessionFilePath: "", + pinned: false, + ...expand(expandNow(e as Record, nowIso), home), + }) as RegistryEntry, + ); + registry.registerBatch(entries); + return registry; +} + +/** Materialize a bundle into a temp HOME; returns cleanup + replay inputs. */ +function materialize(bundle: FixtureBundle) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "fixture-home-")); + // "$TODAY" resolves to the local `YYYY/MM/DD` day key at write time — Codex + // date-dir discovery derives day keys from process start times in local + // time, so static dirs would drift across replay days/timezones. + const now = new Date(); + const todayKey = [ + String(now.getFullYear()).padStart(4, "0"), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + ].join("/"); + for (const [rel, content] of Object.entries(bundle.home)) { + const p = path.join(home, expand(rel.split("$TODAY").join(todayKey), home)); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, expand(content.split("$TODAY").join(todayKey), home)); + } + // SQLite stores (OpenCode's opencode.db) materialize from ordered SQL + // statements — binary dbs can't travel in the JSON `home` map. + for (const [rel, stmts] of Object.entries(bundle.sqlite ?? {})) { + const p = path.join(home, expand(rel.split("$TODAY").join(todayKey), home)); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const db = new Database(p); + try { + for (const stmt of stmts) { + db.exec(expand(stmt.split("$TODAY").join(todayKey), home)); + } + } finally { + db.close(); + } + } + // "$NOW" resolves to the instant home files were written — legacy + // cwd+birthtime matching compares process start times against real file + // creation times, so a static timestamp would drift out of tolerance. + const nowIso = new Date().toISOString(); + // Deterministic mtimes for adapters that read them (Grok lastActive / + // latest-session pick) — "$NOW" resolves like the process sentinel. + for (const [rel, mt] of Object.entries(bundle.mtimes ?? {})) { + const p = path.join(home, expand(rel.split("$TODAY").join(todayKey), home)); + const ms = mt === "$NOW" ? Date.parse(nowIso) : mt; + fs.utimesSync(p, ms / 1000, ms / 1000); + } + // JSON round-tripped Date fields (e.g. ProcessInfo.startTime) come back as + // ISO strings — adapters expect real Date instances. + const processes = expandNow(expand(bundle.processes, home), nowIso).map((p) => ({ + ...p, + startTime: p.startTime ? new Date(p.startTime) : undefined, + })); + const frozenNow = bundle.frozenNow === "$NOW" ? Date.parse(nowIso) : bundle.frozenNow; + // Pids whose startTime resolves to the materialization instant — Codex + // derives session day-dirs from start times, so only these paths contain + // the replay-day key and normalize to $TODAY. + const nowPids = new Set( + bundle.processes.filter((p) => p.startTime === "$NOW").map((p) => p.pid), + ); + return { + home, + processes, + frozenNow, + todayKey, + // Only $NOW resolves — $FIXTURE_HOME and $TODAY stay literal to match + // the normalized actual form. + expected: expandNow(bundle.expected, nowIso), + cleanup: () => fs.rmSync(home, { recursive: true, force: true }), + nowIso, + nowPids, + }; +} + +describe("fixture replay (TS parity oracle)", () => { + const saved = process.env.HOME; + afterEach(() => { + if (saved === undefined) delete process.env.HOME; + else process.env.HOME = saved; + }); + + if (bundlePaths.length === 0) { + it.todo("no fixture bundles committed yet — run AI_DEVKIT_FIXTURE_CAPTURE=1"); + return; + } + + for (const bundlePath of bundlePaths) { + it(`replays ${path.relative(FIXTURES_ROOT, bundlePath)}`, async () => { + const bundle: FixtureBundle = JSON.parse(fs.readFileSync(bundlePath, "utf8")); + const { home, processes, frozenNow, expected, cleanup, todayKey, nowIso, nowPids } = + materialize(bundle); + // Pin XDG to the fixture's canonical share dir — an ambient + // XDG_DATA_HOME would resolve the real opencode.db into replay. + const savedXdg = process.env.XDG_DATA_HOME; + // Same for ANTIGRAVITY_CLI_HOME — clearing it falls the adapter + // back to the pinned HOME's canonical base dir. + const savedAgy = process.env.ANTIGRAVITY_CLI_HOME; + try { + process.env.HOME = home; + process.env.XDG_DATA_HOME = path.join(home, ".local", "share"); + delete process.env.ANTIGRAVITY_CLI_HOME; + const registry = seedRegistry(bundle, home, nowIso); + const agents = await withFrozenClock(frozenNow, () => + adapterFor(bundle.adapter, registry).detectAgents({ processes }), + ); + // Day-key dirs normalize back to $TODAY only for agents whose + // process startTime was $NOW — static-start agents keep concrete + // dates so live bundles stay replayable on any day. + const actual = JSON.parse(JSON.stringify(normalizeAgents(agents, home))).map((a) => + nowPids.has(a.pid) + ? JSON.parse(JSON.stringify(a).split(todayKey).join("$TODAY")) + : a, + ); + expect(actual).toEqual(expected); + } finally { + if (savedXdg === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = savedXdg; + if (savedAgy === undefined) delete process.env.ANTIGRAVITY_CLI_HOME; + else process.env.ANTIGRAVITY_CLI_HOME = savedAgy; + cleanup(); + } + }); + } +}); + +// Opt-in live capture: AI_DEVKIT_FIXTURE_CAPTURE=1 npx vitest run fixtures +describe.runIf(process.env.AI_DEVKIT_FIXTURE_CAPTURE === "1")("fixture capture (live)", () => { + it("captures claude bundle from the live machine", async () => { + const out = await captureLive(adapterFor("claude"), "live"); + expect(fs.existsSync(out)).toBe(true); + }, 30000); + + it("captures codex bundle from the live machine", async () => { + const out = await captureLive(adapterFor("codex"), "live"); + expect(fs.existsSync(out)).toBe(true); + }, 30000); + + it("captures pi bundle from the live machine", async () => { + const out = await captureLive(adapterFor("pi"), "live"); + expect(fs.existsSync(out)).toBe(true); + }, 30000); + + it("captures gemini_cli bundle from the live machine", async () => { + const out = await captureLive(adapterFor("gemini_cli"), "live"); + expect(fs.existsSync(out)).toBe(true); + }, 30000); + + it("captures copilot bundle from the live machine", async () => { + const out = await captureLive(adapterFor("copilot"), "live"); + expect(fs.existsSync(out)).toBe(true); + }, 30000); + + it("captures grok_cli bundle from the live machine", async () => { + const out = await captureLive(adapterFor("grok_cli"), "live"); + expect(fs.existsSync(out)).toBe(true); + }, 30000); + + it("captures opencode bundle from the live machine", async () => { + const out = await captureLive(adapterFor("opencode"), "live"); + expect(fs.existsSync(out)).toBe(true); + }, 30000); + + it("captures devin bundle from the live machine", async () => { + const out = await captureLive(adapterFor("devin"), "live"); + expect(fs.existsSync(out)).toBe(true); + }, 30000); + + it("captures kiro bundle from the live machine", async () => { + const out = await captureLive(adapterFor("kiro"), "live"); + expect(fs.existsSync(out)).toBe(true); + }, 30000); + + it("captures antigravity_cli bundle from the live machine", async () => { + const out = await captureLive(adapterFor("antigravity_cli"), "live"); + expect(fs.existsSync(out)).toBe(true); + }, 30000); +}); diff --git a/packages/agent-manager/src/__tests__/readiness-fixtures.test.ts b/packages/agent-manager/src/__tests__/readiness-fixtures.test.ts new file mode 100644 index 000000000..c0f8a26bf --- /dev/null +++ b/packages/agent-manager/src/__tests__/readiness-fixtures.test.ts @@ -0,0 +1,79 @@ +/** + * Readiness fixture replay: each committed bundle under + * `fixtures/readiness/` provides the injectable seams (files, + * executables, command outputs, codexAuth) plus `expected` — the + * byte-identical reports the readiness probes must produce replaying + * the same bundle through their injectable seams. + */ + +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + getAgentReadinessReports, + type AgentReadinessOptions, +} from "../harnesses/readiness/AgentReadiness.js"; + +interface ReadinessBundle { + options: { + homeDir: string; + path: string; + assetRoot?: string; + builtInSkillNames?: string[]; + skillRoots?: Record; + }; + /** Absolute path → contents, or "" for a directory entry. */ + files: Record; + /** Paths that answer X_OK access. */ + executables: string[]; + /** `"command arg1 arg2"` → output; `ok: false` rejects like execFile. */ + commands: Record; + codexAuth?: boolean | null; + expected: Record; +} + +const READINESS_ROOT = path.resolve(__dirname, "../../../..", "fixtures", "readiness"); + +const bundlePaths = fs.existsSync(READINESS_ROOT) + ? fs + .readdirSync(READINESS_ROOT) + .filter((f) => f.endsWith(".json") && !f.startsWith("live")) + .sort() + .map((f) => path.join(READINESS_ROOT, f)) + : []; + +function replay(bundle: ReadinessBundle): Promise> { + const executableSet = new Set(bundle.executables); + const options: AgentReadinessOptions = { + homeDir: bundle.options.homeDir, + path: bundle.options.path, + assetRoot: bundle.options.assetRoot, + builtInSkillNames: bundle.options.builtInSkillNames ?? [], + skillRoots: bundle.options.skillRoots ?? {}, + readFile: async (target) => { + if (!(target in bundle.files)) throw new Error("missing"); + return bundle.files[target]; + }, + access: async (target) => { + if (executableSet.has(target) || target in bundle.files) return; + throw new Error("missing"); + }, + runCommand: async (command, args) => { + const key = [command, ...args].join(" "); + const stub = bundle.commands[key]; + if (!stub) throw new Error(`unexpected command ${key}`); + if (stub.ok === false) throw new Error(`command failed: ${key}`); + return { stdout: stub.stdout, stderr: stub.stderr ?? "" }; + }, + codexAuth: async () => bundle.codexAuth ?? null, + }; + return getAgentReadinessReports(options); +} + +describe("readiness fixture replay", () => { + it.each(bundlePaths)("%s", async (bundlePath) => { + const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8")) as ReadinessBundle; + const reports = await replay(bundle); + expect(reports).toEqual(bundle.expected); + }); +}); diff --git a/packages/agent-manager/src/fixtures/bundle.ts b/packages/agent-manager/src/fixtures/bundle.ts new file mode 100644 index 000000000..5289ae4ca --- /dev/null +++ b/packages/agent-manager/src/fixtures/bundle.ts @@ -0,0 +1,130 @@ +/** + * Golden-fixture bundles: the parity oracle for the devkit-harness Rust port. + * A bundle records everything `detectAgents` consumed and produced so both + * the TS adapter and its Rust port can be run against identical inputs. + * + * Format (committed under fixtures/harness//.json): + * { + * adapter, capturedAt, frozenNow, + * processes: ProcessInfo[], + * home: { "": "" }, + * expected: AgentInfo[] (dates as ISO strings) + * } + */ + +import type { AgentInfo, ProcessInfo } from "../adapters/AgentAdapter.js"; + +export const HOME_PLACEHOLDER = "$FIXTURE_HOME"; +/** Resolves to the instant home files were written (birthtime matching). */ +export const NOW_PLACEHOLDER = "$NOW"; +/** Resolves to the local `YYYY/MM/DD` day dir at replay (Codex sessions). */ +export const TODAY_PLACEHOLDER = "$TODAY"; + +export interface FixtureBundle { + adapter: string; + capturedAt: string; + /** Epoch ms the clock was frozen at; `"$NOW"` = materialization instant. */ + frozenNow: number | string; + processes: ProcessInfo[]; + /** Files under the fixture HOME; content may embed HOME_PLACEHOLDER. */ + home: Record; + /** + * mtime overrides keyed by `home` relpath — epoch ms, or "$NOW" for the + * materialization instant. Adapters whose output derives from file mtimes + * (Grok's lastActive / latest-session pick) need deterministic mtimes. + */ + mtimes?: Record; + /** + * AgentRegistry rows (pid→sessionFilePath etc.) visible at capture time, + * restricted to captured process pids — adapters' registry caches read + * this state, so replay seeds an isolated registry with it. + */ + registry?: Record[]; + /** + * SQLite stores adapters consult (OpenCode's opencode.db), keyed by + * relpath under $FIXTURE_HOME: SQL statements (DDL + INSERTs) executed + * in order to materialize each db. Statements may embed HOME_PLACEHOLDER. + */ + sqlite?: Record; + expected: Record[]; +} + +/** + * Rewrite harness dot-dir paths (`/.claude`, `/.codex`, …) to the + * placeholder — nothing else. Project cwds share the home prefix + * (`/Code/proj`) and MUST stay literal: harnesses encode them into + * session-dir names (`-Users-foo-proj`), so rewriting them breaks + * cwd↔session matching on replay. + */ +export function sanitize(value: T, realHome: string): T { + const homeDot = `${realHome}/.`; + if (typeof value === "string") { + return value.split(homeDot).join(`${HOME_PLACEHOLDER}/.`) as T; + } + if (Array.isArray(value)) return value.map((v) => sanitize(v, realHome)) as T; + if (value && typeof value === "object") { + if (value instanceof Date) return value.toISOString() as T; + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = sanitize(v, realHome); + return out as T; + } + return value; +} + +/** Inverse of sanitize: expand the placeholder to a concrete dir. */ +export function expand(value: T, fixtureHome: string): T { + if (typeof value === "string") { + return value.split(HOME_PLACEHOLDER).join(fixtureHome) as T; + } + if (Array.isArray(value)) return value.map((v) => expand(v, fixtureHome)) as T; + if (value && typeof value === "object" && !(value instanceof Date)) { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = expand(v, fixtureHome); + return out as T; + } + return value; +} + +/** Replace NOW_PLACEHOLDER strings with a resolved ISO instant. */ +export function expandNow(value: T, isoInstant: string): T { + if (typeof value === "string") { + return value.split(NOW_PLACEHOLDER).join(isoInstant) as T; + } + if (Array.isArray(value)) return value.map((v) => expandNow(v, isoInstant)) as T; + if (value && typeof value === "object" && !(value instanceof Date)) { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = expandNow(v, isoInstant); + return out as T; + } + return value; +} + +/** Agents normalized the way the wire carries them (Date → ISO string). */ +export function normalizeAgents(agents: AgentInfo[], realHome: string) { + return sanitize(JSON.parse(JSON.stringify(agents)), realHome); +} + +/** + * Freeze `Date` at `epochMs` for the duration of `fn`. Adapters stamp + * `new Date()` in several places; parity requires a fixed clock. + */ +export async function withFrozenClock(epochMs: number, fn: () => Promise): Promise { + const RealDate = Date; + class FrozenDate extends RealDate { + constructor(...args: unknown[]) { + if (args.length === 0) super(epochMs); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + else super(...(args as [any])); + } + static override now() { + return epochMs; + } + } + const g = globalThis as { Date: DateConstructor }; + g.Date = FrozenDate as DateConstructor; + try { + return await fn(); + } finally { + g.Date = RealDate; + } +} diff --git a/packages/agent-manager/src/fixtures/capture.ts b/packages/agent-manager/src/fixtures/capture.ts new file mode 100644 index 000000000..a04dd85e9 --- /dev/null +++ b/packages/agent-manager/src/fixtures/capture.ts @@ -0,0 +1,349 @@ +/** + * Capture a live fixture bundle for an adapter: snapshot the real process + * list, run detectAgents with a frozen clock, and record expected output + * plus the session files the detection touched. + * + * Run via `AI_DEVKIT_FIXTURE_CAPTURE=1 npx vitest run fixtures.capture`. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; +import type { AgentAdapter, AgentInfo } from "../adapters/AgentAdapter.js"; +import { AgentRegistry } from "../utils/AgentRegistry.js"; +import { captureProcessSnapshot } from "../utils/process.js"; +import { FixtureBundle, normalizeAgents, sanitize, withFrozenClock } from "./bundle.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); +export const FIXTURES_ROOT = path.join(repoRoot, "fixtures", "harness"); + +/** Session-file trees each harness consults during detection. */ +const HARNESS_DIRS: Record = { + claude: [".claude/sessions", ".claude/projects"], + codex: [".codex/sessions", ".codex/archived_sessions", ".codex/ai-devkit"], + pi: [".pi/agent/sessions"], + // tmp//{.project_root,chats/*} — small tree; index-style capture + // keeps the ownership markers replay needs for slug-dir attribution. + gemini_cli: [".gemini/tmp"], + // session-state//{inuse.*.lock,events.jsonl,workspace.yaml} — the lock + // files drive attribution, so the whole tree must materialize on replay. + copilot: [".copilot/session-state"], + grok_cli: [".grok/sessions"], + // session_locks/.lock — pid-holder files read on every detect. + devin: [".local/share/devin/cli/session_locks"], + // sessions/cli/.{lock,json,jsonl} — locks drive attribution; the + // whole flat dir is lightweight enough for index-style capture. + kiro: [".kiro/sessions/cli"], + // antigravity-cli/{cache,brain} — last_conversations.json (small .json + // aux) plus only the transcripts referenced by matched agents; the + // brain tree is tens of MB so it stays referenced-only (not index). + antigravity_cli: [".gemini/antigravity-cli"], +}; + +/** + * Dirs captured wholesale (lock/index trees where every file matters — + * copilot lock dirs, kiro/devin locks, the codex mapping dir, the gemini + * tmp tree). Everything else captures agent-referenced session files + * plus small .json aux files. + */ +const HARNESS_INDEX_DIRS = new Set([ + ".gemini/tmp", + ".copilot/session-state", + ".codex/ai-devkit", + ".local/share/devin/cli/session_locks", + ".kiro/sessions/cli", +]); + +/** Individual files (not dirs) each harness consults during detection. */ +const HARNESS_FILES: Record = { + pi: [".pi/agent/sessions.json"], + grok_cli: [".grok/active_sessions.json"], +}; + +/** + * Copy the harness's session dirs into `home` — only files the detection + * actually touched (referenced sessionFilePath) plus index/pid files. + * Transcript trees can be large; we include full files only for sessions + * that produced an agent, plus every file in lightweight index dirs. + */ +function collectHomeFiles( + adapterType: string, + realHome: string, + agents: AgentInfo[], +): { home: Record; mtimes: Record } { + const home: Record = {}; + const mtimes: Record = {}; + const referenced = new Set( + agents.map((a) => a.sessionFilePath).filter((p): p is string => Boolean(p)), + ); + + const put = (rel: string, file: string) => { + home[rel] = sanitize(fs.readFileSync(file, "utf8"), realHome); + // Real mtimes are captured too — adapters that read them (Grok + // lastActive, latest-session pick) replay deterministically. + mtimes[rel] = fs.statSync(file).mtimeMs; + }; + + for (const relDir of HARNESS_DIRS[adapterType] ?? []) { + const dir = path.join(realHome, relDir); + if (!fs.existsSync(dir)) continue; + // Session trees can hold months of transcripts; files that produced no + // agent cannot influence replayed output, so only agent-referenced + // session files plus small aux/index files are copied. + const isIndexDir = HARNESS_INDEX_DIRS.has(relDir); + walk(dir, (file) => { + const rel = path.join(relDir, path.relative(dir, file)); + const isAuxIndex = rel.endsWith("sessions.json") || rel.endsWith(".json"); + if (isIndexDir || referenced.has(file) || (isAuxIndex && fileSize(file) < 64 * 1024)) { + put(rel, file); + } + }); + } + for (const relFile of HARNESS_FILES[adapterType] ?? []) { + const file = path.join(realHome, relFile); + if (fs.existsSync(file) && fileSize(file) < 64 * 1024) { + put(relFile, file); + } + } + return { home, mtimes }; +} + +/** + * SQLite stores adapters consult (OpenCode's opencode.db). Sessions are + * reachable only through captured processes' cwds, so the dump keeps the + * table DDL plus every session row for those directories and their + * message/part rows — replay answers identical queries against the same + * view. Rows are serialized as executable SQL (binary dbs can't travel in + * the JSON `home` map); the key is the canonical `~/.local/share` relpath, + * which replay pins via XDG_DATA_HOME. + */ +function collectSqliteDumps( + adapterType: string, + realHome: string, + processes: { cwd?: string | null }[], +): Record { + const share = process.env.XDG_DATA_HOME || path.join(realHome, ".local", "share"); + if (adapterType === "opencode") { + return dumpSqliteDb( + path.join(share, "opencode", "opencode.db"), + ".local/share/opencode/opencode.db", + realHome, + (dump) => { + const dirs = uniqueCwds(processes); + if (dirs.length === 0) return; + const sessions = dump.rows("session", `WHERE directory IN ${dump.inList(dirs)} ORDER BY rowid`, dirs); + const sids = sessions.map((s) => s.id); + dump.insertRows("session", sessions); + if (sids.length === 0) return; + dump.insertRows("message", dump.rows("message", `WHERE session_id IN ${dump.inList(sids)} ORDER BY rowid`, sids)); + dump.insertRows("part", dump.rows("part", `WHERE session_id IN ${dump.inList(sids)} ORDER BY rowid`, sids)); + }, + ["session", "message", "part"], + ); + } + if (adapterType === "devin") { + const dbPath = path.join(share, "devin", "cli", "sessions.db"); + // Locks are captured separately as text files; slugs here widen the + // session row set so lock-slug lookups resolve identically on replay. + const lockSids = fs.existsSync(path.join(path.dirname(dbPath), "session_locks")) + ? fs + .readdirSync(path.join(path.dirname(dbPath), "session_locks")) + .filter((f) => f.endsWith(".lock")) + .map((f) => f.slice(0, -".lock".length)) + .filter(Boolean) + : []; + return dumpSqliteDb( + dbPath, + ".local/share/devin/cli/sessions.db", + realHome, + (dump) => { + const dirs = uniqueCwds(processes); + const clauses: string[] = []; + const args: unknown[] = []; + if (dirs.length) { + clauses.push(`working_directory IN ${dump.inList(dirs)}`); + args.push(...dirs); + } + if (lockSids.length) { + clauses.push(`id IN ${dump.inList(lockSids)}`); + args.push(...lockSids); + } + const sessions = clauses.length ? dump.rows("sessions", `WHERE ${clauses.join(" OR ")} ORDER BY rowid`, args) : []; + const sids = sessions.map((s) => s.id); + dump.insertRows("sessions", sessions); + if (sids.length === 0) return; + // Only the rows getSessionStats can reach: the frontier node, the + // MAX(created_at) node, the 8 newest role='user' nodes, and the + // newest non-shell non-slash prompt — identical query results on + // replay without hauling full transcripts into the bundle. + for (const sid of sids) { + const reachable = [ + ...dump.rows("message_nodes", "WHERE session_id = ? ORDER BY node_id DESC LIMIT 1", [sid]), + // The newest-created_at row — `MAX(created_at)` reads only the + // value, and `= MAX(...)` unbounded explodes when batch-written + // nodes share a timestamp. + ...dump.rows("message_nodes", "WHERE session_id = ? ORDER BY created_at DESC, node_id DESC LIMIT 1", [sid]), + ...dump.rows( + "message_nodes", + "WHERE session_id = ? AND json_extract(chat_message, '$.role') = 'user' ORDER BY node_id DESC LIMIT 8", + [sid], + ), + ]; + const seen = new Set(); + dump.insertRows( + "message_nodes", + reachable.filter((r) => (seen.has(r.node_id) ? false : (seen.add(r.node_id), true))), + ); + dump.insertRows( + "prompt_history", + dump.rows( + "prompt_history", + "WHERE session_id = ? AND is_shell = 0 AND content NOT LIKE '/%' ORDER BY timestamp DESC LIMIT 1", + [sid], + ), + ); + } + }, + ["sessions", "message_nodes", "prompt_history"], + ); + } + return {}; +} + +function uniqueCwds(processes: { cwd?: string | null }[]): string[] { + return [...new Set(processes.map((p) => p.cwd).filter((c): c is string => Boolean(c)))]; +} + +/** + * Dump `dbPath`'s tables (DDL + caller-selected rows) as executable SQL, + * keyed under `relKey`. `fill` receives helpers that append INSERTs for + * the rows the adapter's detection can reach. + */ +function dumpSqliteDb( + dbPath: string, + relKey: string, + realHome: string, + fill: (dump: { + inList: (xs: unknown[]) => string; + rows: (table: string, where: string, args: unknown[]) => Record[]; + insertRows: (table: string, rs: Record[]) => void; + }) => void, + tables: string[], +): Record { + if (!fs.existsSync(dbPath)) return {}; + const db = new Database(dbPath, { readonly: true }); + try { + const stmts = ( + db + .prepare( + `SELECT sql FROM sqlite_master WHERE type='table' AND name IN (${tables.map(() => "?").join(",")})`, + ) + .all(...tables) as { sql: string }[] + ).map((r) => `${r.sql};`); + const dump = { + inList: (xs: unknown[]) => `(${xs.map(() => "?").join(",")})`, + rows: (table: string, clause: string, args: unknown[]) => + db.prepare(`SELECT * FROM "${table}" ${clause}`).all(...args) as Record< + string, + unknown + >[], + insertRows: (table: string, rs: Record[]) => { + for (const r of rs) { + stmts.push( + `INSERT INTO "${table}" VALUES (${Object.values(r).map(sqlLit).join(",")});`, + ); + } + }, + }; + fill(dump); + return { [relKey]: stmts.map((s) => sanitize(s, realHome)) }; + } finally { + db.close(); + } +} + +function sqlLit(v: unknown): string { + if (v === null || v === undefined) return "NULL"; + if (typeof v === "number" || typeof v === "bigint") return String(v); + if (v instanceof Uint8Array) return `X'${Buffer.from(v).toString("hex")}'`; + return `'${String(v).split("'").join("''")}'`; +} + +/** + * Registry rows keyed to captured pids — adapters' registry caches consult + * them during detection, so replay must see the same view (otherwise + * ordering/attribution diverges: cache hits precede locator matches). + */ +function collectRegistryEntries( + realHome: string, + processes: { pid: number }[], +): Record[] { + const pids = new Set(processes.map((p) => p.pid)); + try { + return AgentRegistry.default() + .list() + .filter((e) => pids.has(e.pid)) + .map((e) => sanitize({ ...e }, realHome) as Record); + } catch { + return []; + } +} + +function fileSize(file: string): number { + try { + return fs.statSync(file).size; + } catch { + return Infinity; + } +} + +function walk(dir: string, visit: (file: string) => void, depth = 0) { + if (depth > 4) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) walk(p, visit, depth + 1); + else if (entry.isFile()) visit(p); + } +} + +export async function captureLive(adapter: AgentAdapter, caseName = "live"): Promise { + const realHome = os.homedir(); + const frozenNow = Date.now(); + const names = adapter.processNames; + if (!names?.length) { + throw new Error(`adapter ${adapter.type} has no processNames — can't snapshot`); + } + + const { processes, agents, registry } = await withFrozenClock(frozenNow, async () => { + const snapshot = await captureProcessSnapshot([...names], { + isCandidate: (p) => adapter.canHandle(p), + }); + // Registry read must precede detection — detectAgents persists its own + // results, which would poison the captured view. + const registryBefore = collectRegistryEntries(realHome, snapshot); + const detected = await adapter.detectAgents({ processes: snapshot }); + return { processes: snapshot, agents: detected, registry: registryBefore }; + }); + + const { home, mtimes } = collectHomeFiles(adapter.type, realHome, agents); + const sqlite = collectSqliteDumps(adapter.type, realHome, processes); + const bundle: FixtureBundle = { + adapter: adapter.type, + capturedAt: new Date(frozenNow).toISOString(), + frozenNow, + processes: sanitize(processes, realHome), + home, + mtimes, + registry, + sqlite: Object.keys(sqlite).length > 0 ? sqlite : undefined, + expected: normalizeAgents(agents, realHome), + }; + + const dir = path.join(FIXTURES_ROOT, adapter.type); + fs.mkdirSync(dir, { recursive: true }); + const out = path.join(dir, `${caseName}.json`); + fs.writeFileSync(out, JSON.stringify(bundle, null, 2)); + return out; +} diff --git a/packages/agent-manager/src/harnesses/shared.ts b/packages/agent-manager/src/harnesses/shared.ts index 3527ea09a..b5cb99e87 100644 --- a/packages/agent-manager/src/harnesses/shared.ts +++ b/packages/agent-manager/src/harnesses/shared.ts @@ -67,7 +67,7 @@ export function matchesExecutable(command: string, name: string): boolean { export function processOnlyAgent( type: AgentType, processInfo: ProcessInfo, - { summary, cwd = processInfo.cwd }: { summary: string; cwd?: string }, + { summary, cwd = processInfo.cwd ?? "" }: { summary: string; cwd?: string }, ): AgentInfo { return { name: generateAgentName(cwd, processInfo.pid), diff --git a/packages/cli/package.json b/packages/cli/package.json index 40a33709a..1e55ef0b5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -18,13 +18,13 @@ "directory": "packages/cli" }, "bin": { - "ai-devkit": "dist/cli.js" + "ai-devkit": "dist/bundle/cli.js" }, "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "rm -rf dist && swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly && cp -R templates dist/templates && cp -R assets dist/assets", + "build": "rm -rf dist && swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly && cp -R templates dist/templates && cp -R assets dist/assets && node scripts/bundle-cli.mjs", "dev:build": "swc src -d dist --strip-leading-paths --watch", "dev": "node --no-warnings --loader ts-node/esm src/cli.ts", "lint": "oxlint src", @@ -38,6 +38,7 @@ "dependencies": { "@ai-devkit/agent-manager": "0.40.0", "@ai-devkit/channel-connector": "0.13.4", + "@ai-devkit/daemon-client": "0.1.0", "@ai-devkit/memory": "0.20.3", "@inquirer/prompts": "^8.5.2", "@typesafe-ai/sdk": "^0.6.0", @@ -65,6 +66,7 @@ "@types/node": "^20.11.5", "@types/react": "^19.2.0", "@vitest/coverage-v8": "^4.1.8", + "esbuild": "^0.28.2", "ts-node": "^10.9.2", "typescript": "^5.5.0", "vitest": "^4.1.8" diff --git a/packages/cli/scripts/bundle-cli.mjs b/packages/cli/scripts/bundle-cli.mjs new file mode 100644 index 000000000..b451a31f4 --- /dev/null +++ b/packages/cli/scripts/bundle-cli.mjs @@ -0,0 +1,40 @@ +import { build } from "esbuild"; +import { cpSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pkgDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const outdir = join(pkgDir, "dist", "bundle"); +mkdirSync(outdir, { recursive: true }); + +await build({ + entryPoints: [join(pkgDir, "src", "cli.ts")], + bundle: true, + format: "esm", + platform: "node", + target: "node20", + splitting: true, + outdir, + // Native / wasm modules that cannot be inlined, plus @ai-devkit/memory + // which is only ever reached via lazy import() and ships wasm deps. + // CJS deps (telegraf, fs-extra, ...) call bare require(); provide a real one. + // esbuild emits this before each chunk's code but after the entry's shebang. + banner: { + js: 'import { createRequire as __cr } from "node:module"; const require = __cr(import.meta.url);', + }, + external: [ + "better-sqlite3", + "yoga-layout", + "@ai-devkit/memory", + "react-devtools-core", + ], + logOverride: { "unsupported-dynamic-import": "silent" }, +}); + +// agent-manager resolves SQL migrations as dirname(import.meta.url)/migrations; +// inside the bundle that directory is dist/bundle, so ship them alongside. +cpSync( + join(pkgDir, "..", "agent-manager", "src", "database", "migrations"), + join(outdir, "migrations"), + { recursive: true }, +); diff --git a/packages/cli/src/__tests__/services/skill/skill.service.test.ts b/packages/cli/src/__tests__/services/skill/skill.service.test.ts index 832124cae..72a73af2b 100644 --- a/packages/cli/src/__tests__/services/skill/skill.service.test.ts +++ b/packages/cli/src/__tests__/services/skill/skill.service.test.ts @@ -426,7 +426,9 @@ describe("SkillService", () => { ); }); - it("should read custom registries from global config", async () => { + // vi.importActual queues behind the worker's import graph; under the + // repo-wide parallel test run this can exceed the default 5s timeout. + it("should read custom registries from global config", { timeout: 15000 }, async () => { const customGitUrl = "https://github.com/custom/skills.git"; const { GlobalConfigManager: RealGlobalConfigManager } = await vi.importActual< typeof import("../../../lib/GlobalConfig.js") diff --git a/packages/cli/src/__tests__/services/status/status.service.test.ts b/packages/cli/src/__tests__/services/status/status.service.test.ts index 89d814e7e..edfc31e86 100644 --- a/packages/cli/src/__tests__/services/status/status.service.test.ts +++ b/packages/cli/src/__tests__/services/status/status.service.test.ts @@ -491,7 +491,7 @@ describe("getStatusReport memory mcp wiring", () => { const report = await run(); expect(report.memoryMcp.status).toBe("pass"); expect(report.memoryMcp.agents).toEqual([]); - }); + }, 15000); it("reports unwired for a detected agent without config", async () => { mkdirSync(join(homeDir, ".gemini"), { recursive: true }); @@ -500,7 +500,7 @@ describe("getStatusReport memory mcp wiring", () => { expect.objectContaining({ agent: "gemini", state: "unwired" }), ); expect(report.memoryMcp.status).toBe("warn"); - }); + }, 15000); it("reports wired after the config entry exists", async () => { mkdirSync(join(homeDir, ".cursor"), { recursive: true }); @@ -517,7 +517,7 @@ describe("getStatusReport memory mcp wiring", () => { expect.objectContaining({ agent: "cursor", state: "wired" }), ); expect(report.memoryMcp.status).toBe("pass"); - }); + }, 15000); it("reports unsupported for pi without failing the check", async () => { mkdirSync(join(homeDir, ".pi"), { recursive: true }); @@ -526,7 +526,7 @@ describe("getStatusReport memory mcp wiring", () => { expect.objectContaining({ agent: "pi", state: "unsupported" }), ); expect(report.memoryMcp.status).toBe("pass"); - }); + }, 15000); it("reports error state for malformed config", async () => { mkdirSync(join(homeDir, ".grok"), { recursive: true }); @@ -536,5 +536,5 @@ describe("getStatusReport memory mcp wiring", () => { expect.objectContaining({ agent: "grok", state: "error" }), ); expect(report.memoryMcp.status).toBe("warn"); - }); + }, 15000); }); diff --git a/packages/cli/src/__tests__/tui/console/hooks/agentListSubscription.test.ts b/packages/cli/src/__tests__/tui/console/hooks/agentListSubscription.test.ts new file mode 100644 index 000000000..f087eb919 --- /dev/null +++ b/packages/cli/src/__tests__/tui/console/hooks/agentListSubscription.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { DaemonEvent } from "@ai-devkit/daemon-client"; + +type FakeClient = { + subscribe: (h: (e: DaemonEvent) => void) => Promise; + close: () => void; +}; + +let daemonClient: FakeClient | null = null; +let eventHandler: ((e: DaemonEvent) => void) | null = null; + +vi.mock("@ai-devkit/daemon-client", () => ({ + ensureDaemon: () => Promise.resolve(daemonClient), + DaemonClient: { tryConnect: () => Promise.resolve(null) }, +})); + +import { attachDaemonRefresh } from "../../../../tui/console/hooks/agentListSubscription.js"; + +const evt = (kind: string): DaemonEvent => ({ seq: 1, ts: 0, kind, payload: {} }); + +describe("attachDaemonRefresh", () => { + beforeEach(() => { + daemonClient = null; + eventHandler = null; + }); + + it("returns null when the daemon is absent — caller keeps polling", async () => { + const onEvent = vi.fn(); + const onSubscribed = vi.fn(); + const client = await attachDaemonRefresh(onEvent, onSubscribed); + expect(client).toBeNull(); + expect(onSubscribed).not.toHaveBeenCalled(); + }); + + it("subscribes, forwards events to onEvent, and signals onSubscribed", async () => { + daemonClient = { + subscribe: (h) => { + eventHandler = h; + return Promise.resolve(); + }, + close: vi.fn(), + }; + const onEvent = vi.fn(); + const onSubscribed = vi.fn(); + const client = await attachDaemonRefresh(onEvent, onSubscribed); + expect(client).toBe(daemonClient); + expect(onSubscribed).toHaveBeenCalledOnce(); + eventHandler!(evt("agent.appeared")); + eventHandler!(evt("agent.disappeared")); + expect(onEvent).toHaveBeenCalledTimes(2); + }); + + it("returns null and closes the client when subscribe fails", async () => { + const close = vi.fn(); + daemonClient = { + subscribe: () => Promise.reject(new Error("no subscribe")), + close, + }; + const onEvent = vi.fn(); + const onSubscribed = vi.fn(); + const client = await attachDaemonRefresh(onEvent, onSubscribed); + expect(client).toBeNull(); + expect(onSubscribed).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index deece1034..b2f083187 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,86 +1,8 @@ #!/usr/bin/env node +import * as nodeModule from "node:module"; -import { Command } from "commander"; -import { initCommand } from "./commands/init.js"; -import { phaseCommand } from "./commands/phase.js"; -import { lintCommand } from "./commands/lint.js"; -import { installCommand } from "./commands/install.js"; -import { registerMemoryCommand } from "./commands/memory.js"; -import { registerSkillCommand } from "./commands/skill/index.js"; -import { registerAgentCommand } from "./commands/agent/index.js"; -import { registerChannelCommand } from "./commands/channel.js"; -import { registerDocsCommand } from "./commands/docs.js"; -import { registerPluginCommand } from "./commands/plugin.js"; -import { registerSetupCommand } from "./commands/setup.js"; -import { registerCapacityCommand } from "./commands/capacity.js"; -import { registerStatusCommand } from "./commands/status.js"; -import { registerConfiguredPluginCommands } from "./services/plugin/plugin-loader.service.js"; -import { createAiDevkitRuntime } from "./services/plugin/runtime.js"; -import { handleCliError } from "./util/errors.js"; -import pkg from "../package.json" with { type: "json" }; -const { version } = pkg as { version: string }; +// Enable the V8 compile cache (Node >= 22.8) before loading the program so +// repeat invocations skip re-compiling the module graph. +(nodeModule as { enableCompileCache?: () => void }).enableCompileCache?.(); -const program = new Command(); - -program.name("ai-devkit").description("AI-assisted software development toolkit").version(version); - -program - .command("init") - .description("Initialize AI DevKit in the current directory") - .option("-e, --environment ", "Comma-separated supported AI environment codes") - .option("-a, --all", "Initialize all phases") - .option("-p, --phases ", "Comma-separated list of phases to initialize") - .option("-t, --template ", "Initialize from template file (.yaml, .yml, .json)") - .option("-d, --docs-dir ", "Custom directory for AI documentation (default: docs/ai)") - .option( - "--built-in", - "Install AI DevKit built-in skills without prompting (useful for CI/non-interactive runs)", - ) - .option( - "-y, --yes", - "Run non-interactively. Without -t, requires -e and one of -a/-p. Existing files are kept unless --overwrite is also passed.", - ) - .option( - "--overwrite", - "With --yes, overwrite existing environments and phase files instead of skipping them", - ) - .action(initCommand); - -program - .command("phase [name]") - .description( - "Add a specific phase template (requirements|design|planning|implementation|testing|deployment|monitoring)", - ) - .action(phaseCommand); - -program - .command("lint") - .description("Validate workspace readiness for AI DevKit workflows") - .option("-f, --feature ", "Validate docs and git worktree conventions for a feature") - .option("--json", "Output lint results as JSON") - .action(lintCommand); - -program - .command("install") - .description("Install AI DevKit artifacts from a project config") - .option("-c, --config ", "Path to config file (default: .ai-devkit.json)") - .option("--overwrite", "Overwrite existing install artifacts") - .action(installCommand); - -registerMemoryCommand(program); -registerSkillCommand(program); -registerAgentCommand(program); -registerChannelCommand(program); -registerDocsCommand(program); -registerPluginCommand(program); -registerSetupCommand(program); -registerCapacityCommand(program); -registerStatusCommand(program); - -await registerConfiguredPluginCommands(program, createAiDevkitRuntime()); - -try { - await program.parseAsync(); -} catch (error) { - await handleCliError("run command", error); -} +await import("./main.js"); diff --git a/packages/cli/src/commands/agent/console.command.ts b/packages/cli/src/commands/agent/console.command.ts index ce19b16f4..ce6397df5 100644 --- a/packages/cli/src/commands/agent/console.command.ts +++ b/packages/cli/src/commands/agent/console.command.ts @@ -1,12 +1,6 @@ import { Command } from "commander"; -import { createElement } from "react"; -import { render } from "ink"; import { ui } from "../../util/terminal-ui.js"; import { withErrorHandler } from "../../util/errors.js"; -import { - AGENT_CONSOLE_RENDER_OPTIONS, - ConsoleApp, -} from "../../tui/console/ConsoleApp.js"; import { createAgentManager } from "./factory.js"; export function registerAgentConsoleCommand(agentCommand: Command): void { @@ -19,6 +13,14 @@ export function registerAgentConsoleCommand(agentCommand: Command): void { ui.error("agent console requires an interactive terminal (TTY)."); process.exit(1); } + // The TUI pulls in react/ink/yoga — keep it out of the eager graph + // so non-TUI commands don't pay the module cost. + const [{ createElement }, { render }, { AGENT_CONSOLE_RENDER_OPTIONS, ConsoleApp }] = + await Promise.all([ + import("react"), + import("ink"), + import("../../tui/console/ConsoleApp.js"), + ]); const manager = createAgentManager(); const { waitUntilExit } = render( createElement(ConsoleApp, { manager }), diff --git a/packages/cli/src/commands/agent/factory.ts b/packages/cli/src/commands/agent/factory.ts index a0563f947..03d01b60e 100644 --- a/packages/cli/src/commands/agent/factory.ts +++ b/packages/cli/src/commands/agent/factory.ts @@ -8,6 +8,7 @@ import { PiPrintAgentService, type DurableProvider, } from "@ai-devkit/agent-manager"; +import { ensureDaemon } from "@ai-devkit/daemon-client"; import { ConfigManager } from "../../lib/Config.js"; import { createLogger } from "../../util/debug.js"; import { getErrorMessage } from "../../util/text.js"; @@ -21,6 +22,15 @@ export function createAgentManager(): AgentManager { `Herdr pane discovery unavailable for live agent enrichment: ${getErrorMessage(error)}`, ); }, + fetchEnrichedAgents: async () => { + const client = await ensureDaemon(); + if (!client) return null; + try { + return await client.listAgents(); + } finally { + client.close(); + } + }, }); for (const adapter of createBuiltinAdapters()) manager.registerAdapter(adapter); return manager; diff --git a/packages/cli/src/commands/agent/session.command.ts b/packages/cli/src/commands/agent/session.command.ts index 9ea753873..8aaeee48b 100644 --- a/packages/cli/src/commands/agent/session.command.ts +++ b/packages/cli/src/commands/agent/session.command.ts @@ -6,11 +6,6 @@ import { withErrorHandler } from "../../util/errors.js"; import { resolveListSessionsOptions } from "../../util/sessions.js"; import { agentTypeLabel } from "../../util/agent.js"; import { formatRelativeTime } from "../../util/time-format.js"; -import { - compactSession, - renderSessionCompactMarkdown, -} from "../../services/session-compact/session-compact.service.js"; -import { createJevSessionEventClassifier } from "../../services/session-compact/jev-classifier.js"; import { JEV_UNAVAILABLE_MESSAGE, JEV_UNAVAILABLE_REASON, @@ -166,6 +161,13 @@ export function registerAgentSessionCommand(agentCommand: Command): void { if (!adapter) throw new Error(`Unsupported agent type: ${resolved.type}`); const conversation = adapter.getConversation(resolved.sessionFilePath, { verbose: true }); + // The Jev classifier pulls in the TypeSafe SDK (undici) — lazy-load + // so plain session browsing skips it. + const [{ compactSession, renderSessionCompactMarkdown }, { createJevSessionEventClassifier }] = + await Promise.all([ + import("../../services/session-compact/session-compact.service.js"), + import("../../services/session-compact/jev-classifier.js"), + ]); const classifier = createJevSessionEventClassifier(apiKey); const result = await compactSession(conversation, classifier); console.log( diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index e06d38e5d..4ef52703b 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -2,7 +2,7 @@ import * as path from "path"; import { fileURLToPath } from "url"; import { Command } from "commander"; import chalk from "chalk"; -import { Telegraf } from "telegraf"; + import { TELEGRAM_CHANNEL_TYPE, SLACK_CHANNEL_TYPE, @@ -204,6 +204,7 @@ export function registerChannelCommand(program: Command): void { let botUsername: string; try { + const { Telegraf } = await import("telegraf"); const bot = new Telegraf(botToken.trim()); const me = await bot.telegram.getMe(); botUsername = me.username; diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts new file mode 100644 index 000000000..feb435a42 --- /dev/null +++ b/packages/cli/src/commands/daemon.ts @@ -0,0 +1,115 @@ +import type { Command } from "commander"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { + DaemonClient, + daemonSocketPath, + ensureDaemon, + resolveDaemonBinary, +} from "@ai-devkit/daemon-client"; +import { withErrorHandler } from "../util/errors.js"; + +/** + * `ai-devkit daemon` — manage the devkitd coordination daemon. + * Normal operation needs no daemon command at all: any client auto-spawns it. + * This command exists for status, logs, restart, and systemd persistence. + */ +export function registerDaemonCommand(program: Command): void { + const cmd = program.command("daemon").description("Manage the devkitd coordination daemon"); + + cmd + .command("status") + .option("-j, --json", "Output as JSON") + .action( + withErrorHandler("get daemon status", async (options: { json?: boolean }) => { + const client = await DaemonClient.tryConnect(); + if (!client) { + const bin = resolveDaemonBinary(); + const report = { + running: false, + socket: daemonSocketPath(), + binary: bin, + }; + if (options.json) console.log(JSON.stringify(report, null, 2)); + else { + console.log("daemon: not running"); + console.log(`socket: ${report.socket}`); + console.log(`binary: ${bin ?? "not found"}`); + } + return; + } + const status = await client.request("daemon.status"); + client.close(); + if (options.json) console.log(JSON.stringify(status, null, 2)); + else { + const s = status as Record; + console.log(`daemon: running (v${s.version}, pid-owned socket)`); + console.log(`started: ${new Date(s.startedAt as number).toISOString()}`); + console.log(`socket: ${s.socket}`); + } + }), + ); + + cmd + .command("start") + .description("Ensure the daemon is running (auto-spawned anyway on use)") + .action( + withErrorHandler("start daemon", async () => { + const client = await ensureDaemon(); + if (!client) { + throw new Error( + "could not start daemon — binary not found (DEVKITD_BIN, platform package, or cargo build)", + ); + } + console.log("daemon running"); + client.close(); + }), + ); + + cmd + .command("stop") + .description("Stop the daemon") + .action( + withErrorHandler("stop daemon", async () => { + const client = await DaemonClient.tryConnect(); + if (!client) { + console.log("daemon not running"); + return; + } + await client.request("shutdown"); + client.close(); + console.log("daemon stopped"); + }), + ); + + cmd + .command("logs") + .description("Tail the daemon log") + .option("-n, --lines ", "Lines to show", "50") + .action( + withErrorHandler("show daemon logs", async (options: { lines: string }) => { + const logPath = path.join(os.homedir(), ".ai-devkit", "daemon.log"); + if (!fs.existsSync(logPath)) { + console.log("no daemon log yet"); + return; + } + const lines = fs.readFileSync(logPath, "utf8").trimEnd().split("\n"); + const tail = lines.slice(-parseInt(options.lines, 10)); + console.log(tail.join("\n")); + }), + ); + + cmd + .command("install") + .description("Install a systemd --user unit for boot persistence") + .action( + withErrorHandler("install daemon", async () => { + const bin = resolveDaemonBinary(); + if (!bin) throw new Error("daemon binary not found"); + const { spawnSync } = await import("node:child_process"); + const res = spawnSync(bin, ["install"], { stdio: "inherit" }); + if (res.status !== 0) throw new Error("daemon install failed"); + }), + ); +} diff --git a/packages/cli/src/commands/memory.ts b/packages/cli/src/commands/memory.ts index adda7677a..78ff44ba5 100644 --- a/packages/cli/src/commands/memory.ts +++ b/packages/cli/src/commands/memory.ts @@ -1,16 +1,5 @@ import type { Command } from "commander"; import chalk from "chalk"; -import { - memoryDownloadSemanticCommand, - memoryReembedCommand, - memorySearchCommand, - memorySearchCommandAsync, - memorySemanticStatusCommand, - memoryStoreCommand, - memoryStoreCommandAsync, - memoryUpdateCommand, - memoryUpdateCommandAsync, -} from "@ai-devkit/memory"; import type { MemorySearchOptions, MemoryStoreOptions, @@ -24,6 +13,10 @@ import { truncate } from "../util/text.js"; const TITLE_MAX_LENGTH = 60; const TABLE_ID_LENGTH = 8; +// @ai-devkit/memory loads its storage and embedding stack eagerly, so defer it +// until a memory subcommand actually runs. +const loadMemory = () => import("@ai-devkit/memory"); + export function registerMemoryCommand(program: Command): void { const resolveMemoryDbPath = async (): Promise => { const configManager = new ConfigManager(); @@ -60,6 +53,8 @@ export function registerMemoryCommand(program: Command): void { withErrorHandler( "store knowledge", async (options: MemoryStoreOptions) => { + const { memoryStoreCommand, memoryStoreCommandAsync } = + await loadMemory(); const commandOptions = { ...options, dbPath: await resolveMemoryDbPath(), @@ -90,6 +85,8 @@ export function registerMemoryCommand(program: Command): void { withErrorHandler( "update knowledge", async (options: MemoryUpdateOptions) => { + const { memoryUpdateCommand, memoryUpdateCommandAsync } = + await loadMemory(); const commandOptions = { ...options, dbPath: await resolveMemoryDbPath(), @@ -124,6 +121,8 @@ export function registerMemoryCommand(program: Command): void { explain?: boolean; }, ) => { + const { memorySearchCommand, memorySearchCommandAsync } = + await loadMemory(); const { table, limit, explain, ...searchOptions } = options; const commandOptions = { ...searchOptions, @@ -175,6 +174,7 @@ export function registerMemoryCommand(program: Command): void { .description("Show semantic model and embedding readiness") .action( withErrorHandler("show semantic status", async () => { + const { memorySemanticStatusCommand } = await loadMemory(); const result = await memorySemanticStatusCommand({ dbPath: await resolveMemoryDbPath(), }); @@ -187,6 +187,7 @@ export function registerMemoryCommand(program: Command): void { .description("Download and verify the pinned local semantic model") .action( withErrorHandler("download semantic model", async () => { + const { memoryDownloadSemanticCommand } = await loadMemory(); const result = await memoryDownloadSemanticCommand({ dbPath: await resolveMemoryDbPath(), }); @@ -202,6 +203,7 @@ export function registerMemoryCommand(program: Command): void { withErrorHandler( "re-embed memory", async (options: { force?: boolean }) => { + const { memoryReembedCommand } = await loadMemory(); const result = await memoryReembedCommand({ dbPath: await resolveMemoryDbPath(), force: options.force === true, diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts new file mode 100644 index 000000000..07c74f290 --- /dev/null +++ b/packages/cli/src/main.ts @@ -0,0 +1,104 @@ +import { Command } from "commander"; +import { phaseCommand } from "./commands/phase.js"; +import { lintCommand } from "./commands/lint.js"; +import { installCommand } from "./commands/install.js"; +import { registerMemoryCommand } from "./commands/memory.js"; +import { registerSkillCommand } from "./commands/skill/index.js"; +import { registerAgentCommand } from "./commands/agent/index.js"; +import { registerChannelCommand } from "./commands/channel.js"; +import { registerDocsCommand } from "./commands/docs.js"; +import { registerPluginCommand } from "./commands/plugin.js"; +import { registerSetupCommand } from "./commands/setup.js"; +import { registerCapacityCommand } from "./commands/capacity.js"; +import { registerStatusCommand } from "./commands/status.js"; +import { registerDaemonCommand } from "./commands/daemon.js"; +import { BUILT_IN_COMMAND_NAMES } from "./services/plugin/builtin-commands.js"; +import { handleCliError } from "./util/errors.js"; +import pkg from "../package.json" with { type: "json" }; +const { version } = pkg as { version: string }; + +const program = new Command(); + +program.name("ai-devkit").description("AI-assisted software development toolkit").version(version); + +program + .command("init") + .description("Initialize AI DevKit in the current directory") + .option("-e, --environment ", "Comma-separated supported AI environment codes") + .option("-a, --all", "Initialize all phases") + .option("-p, --phases ", "Comma-separated list of phases to initialize") + .option("-t, --template ", "Initialize from template file (.yaml, .yml, .json)") + .option("-d, --docs-dir ", "Custom directory for AI documentation (default: docs/ai)") + .option( + "--built-in", + "Install AI DevKit built-in skills without prompting (useful for CI/non-interactive runs)", + ) + .option( + "-y, --yes", + "Run non-interactively. Without -t, requires -e and one of -a/-p. Existing files are kept unless --overwrite is also passed.", + ) + .option( + "--overwrite", + "With --yes, overwrite existing environments and phase files instead of skipping them", + ) + .action((options) => + import("./commands/init.js").then((m) => m.initCommand(options)), + ); + +program + .command("phase [name]") + .description( + "Add a specific phase template (requirements|design|planning|implementation|testing|deployment|monitoring)", + ) + .action(phaseCommand); + +program + .command("lint") + .description("Validate workspace readiness for AI DevKit workflows") + .option("-f, --feature ", "Validate docs and git worktree conventions for a feature") + .option("--json", "Output lint results as JSON") + .action(lintCommand); + +program + .command("install") + .description("Install AI DevKit artifacts from a project config") + .option("-c, --config ", "Path to config file (default: .ai-devkit.json)") + .option("--overwrite", "Overwrite existing install artifacts") + .action(installCommand); + +registerMemoryCommand(program); +registerSkillCommand(program); +registerAgentCommand(program); +registerChannelCommand(program); +registerDocsCommand(program); +registerPluginCommand(program); +registerSetupCommand(program); +registerCapacityCommand(program); +registerStatusCommand(program); +registerDaemonCommand(program); + +// Plugin entrypoints are third-party modules and can be expensive to import, +// so skip the scan when the invocation already resolves to a built-in command +// or a flag that exits before command routing. Bare invocations and --help +// still load plugins so they appear in the generated help. +const requestedCommand = process.argv[2]; +const isEarlyExitFlag = + requestedCommand === "-V" || requestedCommand === "--version"; +if ( + !isEarlyExitFlag && + (requestedCommand === undefined || + !BUILT_IN_COMMAND_NAMES.has(requestedCommand)) +) { + const [{ registerConfiguredPluginCommands }, { createAiDevkitRuntime }] = + await Promise.all([ + import("./services/plugin/plugin-loader.service.js"), + import("./services/plugin/runtime.js"), + ]); + await registerConfiguredPluginCommands(program, createAiDevkitRuntime()); +} + +try { + await program.parseAsync(); +} catch (error) { + await handleCliError("run command", error); +} diff --git a/packages/cli/src/services/channel/channel-runner.ts b/packages/cli/src/services/channel/channel-runner.ts index eb39b3948..16b68b285 100644 --- a/packages/cli/src/services/channel/channel-runner.ts +++ b/packages/cli/src/services/channel/channel-runner.ts @@ -19,6 +19,7 @@ import { SLACK_CHANNEL_TYPE, TELEGRAM_CHANNEL_TYPE, } from "@ai-devkit/channel-connector"; +import { ensureDaemon } from "@ai-devkit/daemon-client"; import { ui } from "../../util/terminal-ui.js"; import { getErrorMessage } from "../../util/text.js"; import { createLogger } from "../../util/debug.js"; @@ -42,7 +43,17 @@ export interface RunChannelBridgeInput { } function createAgentManager(): AgentManager { - const manager = new AgentManager(); + const manager = new AgentManager(undefined, undefined, { + fetchEnrichedAgents: async () => { + const client = await ensureDaemon(); + if (!client) return null; + try { + return await client.listAgents(); + } finally { + client.close(); + } + }, + }); for (const adapter of createBuiltinAdapters()) manager.registerAdapter(adapter); return manager; } diff --git a/packages/cli/src/services/channel/channel.service.ts b/packages/cli/src/services/channel/channel.service.ts index de7304333..0e75c8f63 100644 --- a/packages/cli/src/services/channel/channel.service.ts +++ b/packages/cli/src/services/channel/channel.service.ts @@ -103,14 +103,8 @@ export class ChannelService { } async getLiveBridges(): Promise { - const registry = await this.readBridgeRegistry(); - const liveEntries = Object.entries(registry.bridges).filter(([, bridge]) => - this.isPidAlive(bridge.bridgePid), - ); - - const next: ChannelBridgeFile = { bridges: Object.fromEntries(liveEntries) }; - await this.writeBridgeRegistry(next); - return Object.values(next.bridges); + const bridges = await this.listBridges(); + return bridges.filter((bridge) => this.isPidAlive(bridge.bridgePid)); } async getLiveBridgeByChannel(channelName: string): Promise { @@ -197,9 +191,20 @@ export class ChannelService { async unregisterBridge(channelName: string): Promise { const registry = await this.readBridgeRegistry(); delete registry.bridges[channelName]; + this.pruneFileRegistry(registry); await this.writeBridgeRegistry(registry); } + private async listBridges(): Promise { + return Object.values((await this.readBridgeRegistry()).bridges); + } + + private pruneFileRegistry(registry: ChannelBridgeFile): void { + for (const [name, bridge] of Object.entries(registry.bridges)) { + if (!this.isPidAlive(bridge.bridgePid)) delete registry.bridges[name]; + } + } + private async readBridgeRegistry(): Promise { try { const raw = fs.readFileSync(this.registryPath, "utf-8"); diff --git a/packages/cli/src/services/plugin/builtin-commands.ts b/packages/cli/src/services/plugin/builtin-commands.ts new file mode 100644 index 000000000..b56d2a42f --- /dev/null +++ b/packages/cli/src/services/plugin/builtin-commands.ts @@ -0,0 +1,16 @@ +export const BUILT_IN_COMMAND_NAMES = new Set([ + "init", + "phase", + "lint", + "install", + "memory", + "skill", + "agent", + "channel", + "docs", + "plugin", + "setup", + "capacity", + "status", + "daemon", +]); diff --git a/packages/cli/src/services/plugin/plugin-loader.service.ts b/packages/cli/src/services/plugin/plugin-loader.service.ts index 926dadc60..97c9a0aae 100644 --- a/packages/cli/src/services/plugin/plugin-loader.service.ts +++ b/packages/cli/src/services/plugin/plugin-loader.service.ts @@ -13,18 +13,9 @@ import { type AiDevkitPluginCommand, } from "./plugin-manifest.service.js"; -export const BUILT_IN_COMMAND_NAMES = new Set([ - "init", - "phase", - "lint", - "install", - "memory", - "skill", - "agent", - "channel", - "docs", - "plugin", -]); +import { BUILT_IN_COMMAND_NAMES } from "./builtin-commands.js"; + +export { BUILT_IN_COMMAND_NAMES }; export async function validateInstalledPluginManifest(pluginName: string): Promise { await loadInstalledPluginCommands(pluginName); diff --git a/packages/cli/src/tui/console/hooks/agentListSubscription.ts b/packages/cli/src/tui/console/hooks/agentListSubscription.ts new file mode 100644 index 000000000..71e996268 --- /dev/null +++ b/packages/cli/src/tui/console/hooks/agentListSubscription.ts @@ -0,0 +1,26 @@ +import { DaemonClient, ensureDaemon } from "@ai-devkit/daemon-client"; + +/** + * Attach to the daemon event stream so agent lifecycle changes + * trigger a console refresh. Returns the live client on success (caller owns + * close()); null when the daemon is absent or subscribe fails — callers then + * keep interval polling. `onSubscribed` fires once the stream is live so the + * caller can relax its fallback poll. + */ +export async function attachDaemonRefresh( + onEvent: () => void, + onSubscribed: () => void, +): Promise { + const client = await ensureDaemon().catch(() => null); + if (!client) return null; + try { + // Live only: the console is a UI — replaying the persisted event log + // (up to 10k frames) would only trigger redundant refreshes. + await client.subscribe(() => onEvent(), { liveOnly: true }); + onSubscribed(); + return client; + } catch { + client.close(); + return null; + } +} diff --git a/packages/cli/src/tui/console/hooks/useAgentList.ts b/packages/cli/src/tui/console/hooks/useAgentList.ts index fad0ce888..ba1ea6e24 100644 --- a/packages/cli/src/tui/console/hooks/useAgentList.ts +++ b/packages/cli/src/tui/console/hooks/useAgentList.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { AgentInfo, AgentManager } from "@ai-devkit/agent-manager"; +import type { DaemonClient } from "@ai-devkit/daemon-client"; +import { attachDaemonRefresh } from "./agentListSubscription.js"; export interface UseAgentListResult { agents: AgentInfo[]; @@ -12,6 +14,9 @@ export interface UseAgentListResult { type AgentListState = Omit; export const LIST_POLL_INTERVAL_MS = 3000; +/** Slow safety-net poll once the daemon event stream drives refresh — covers + * attribution drift (status text, session files) the daemon can't see. */ +export const LIST_FALLBACK_INTERVAL_MS = 60_000; export function agentsEqual(a: AgentInfo[], b: AgentInfo[]): boolean { if (a.length !== b.length) return false; @@ -99,13 +104,59 @@ export function useAgentList( }; } void refresh(); - const handle = setInterval(() => { + let handle = setInterval(() => { void refresh(); }, intervalMs); + // Daemon event stream → immediate refresh on agent lifecycle + // changes; on success the blind poll relaxes to the slow fallback. Any + // failure leaves today's interval behavior untouched. If the stream + // dies mid-session (daemon restart) the fast poll resumes and the + // attach is retried — otherwise the console would silently stay on + // the 60s fallback for the rest of its life. + let client: DaemonClient | null = null; + let disposed = false; + let reconnect: ReturnType | null = null; + const fastPoll = () => { + clearInterval(handle); + handle = setInterval(() => { + void refresh(); + }, intervalMs); + }; + const attach = async (): Promise => { + const c = await attachDaemonRefresh( + () => void refresh(), + () => { + if (disposed) return; + clearInterval(handle); + handle = setInterval(() => { + void refresh(); + }, LIST_FALLBACK_INTERVAL_MS); + }, + ); + if (disposed) { + c?.close(); + return; + } + client = c; + if (c) { + c.onDisconnect = () => { + client = null; + fastPoll(); + reconnect = setTimeout(() => { + if (!disposed) void attach(); + }, 2000); + }; + } + }; + void attach(); + return () => { + disposed = true; mountedRef.current = false; clearInterval(handle); + if (reconnect) clearTimeout(reconnect); + client?.close(); }; }, [intervalMs, paused, refresh]); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 0a1dc0a57..7eaa5fac7 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -5,10 +5,17 @@ export default defineConfig({ globals: true, environment: "node", include: ["src/**/__tests__/**/*.test.ts"], + setupFiles: ["./vitest.setup.ts"], coverage: { provider: "v8", include: ["src/**/*.ts"], - exclude: ["src/cli.ts", "src/**/*.d.ts", "src/types.ts", "src/**/types.ts"], + exclude: [ + "src/cli.ts", + "src/main.ts", + "src/**/*.d.ts", + "src/types.ts", + "src/**/types.ts", + ], thresholds: { branches: 60, functions: 60, diff --git a/packages/cli/vitest.setup.ts b/packages/cli/vitest.setup.ts new file mode 100644 index 000000000..0ef233f90 --- /dev/null +++ b/packages/cli/vitest.setup.ts @@ -0,0 +1,3 @@ +// Keep the suite hermetic: a developer's live devkitd would otherwise stream +// real agent events into subscription-based tests (e.g. ConsoleContext). +process.env.AI_DEVKIT_NO_DAEMON = "1"; diff --git a/packages/daemon-client/package.json b/packages/daemon-client/package.json new file mode 100644 index 000000000..a6ee2a419 --- /dev/null +++ b/packages/daemon-client/package.json @@ -0,0 +1,40 @@ +{ + "name": "@ai-devkit/daemon-client", + "version": "0.1.0", + "description": "Thin client for the devkitd coordination daemon (unix socket, JSON-RPC, subscribe)", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/codeaholicguy/ai-devkit.git", + "directory": "packages/daemon-client" + }, + "files": [ + "dist", + "README.md" + ], + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly", + "dev": "swc src -d dist --strip-leading-paths --watch", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "oxlint src", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check", + "clean": "rm -rf dist" + }, + "devDependencies": { + "@swc/cli": "^0.8.1", + "@swc/core": "^1.10.0", + "@types/node": "^20.11.5", + "vitest": "^4.1.8" + } +} diff --git a/packages/daemon-client/project.json b/packages/daemon-client/project.json new file mode 100644 index 000000000..1c22682f9 --- /dev/null +++ b/packages/daemon-client/project.json @@ -0,0 +1,29 @@ +{ + "name": "daemon-client", + "root": "packages/daemon-client", + "sourceRoot": "packages/daemon-client/src", + "projectType": "library", + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "command": "npm run build", + "cwd": "packages/daemon-client" + } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "command": "npm run test", + "cwd": "packages/daemon-client" + } + }, + "lint": { + "executor": "nx:run-commands", + "options": { + "command": "npm run lint", + "cwd": "packages/daemon-client" + } + } + } +} diff --git a/packages/daemon-client/src/__tests__/client.test.ts b/packages/daemon-client/src/__tests__/client.test.ts new file mode 100644 index 000000000..c2e3f0dd6 --- /dev/null +++ b/packages/daemon-client/src/__tests__/client.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resolveDaemonBinary } from "../binary.js"; +import { daemonSocketPath } from "../client.js"; + +describe("resolveDaemonBinary", () => { + const saved = { + DEVKITD_BIN: process.env.DEVKITD_BIN, + AI_DEVKITD_BIN: process.env.AI_DEVKITD_BIN, + }; + afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + it("honors DEVKITD_BIN when the file exists", () => { + const fake = path.join(os.tmpdir(), `fake-daemon-${process.pid}`); + fs.writeFileSync(fake, "#!/bin/sh\n"); + process.env.DEVKITD_BIN = fake; + expect(resolveDaemonBinary()).toBe(fake); + fs.unlinkSync(fake); + }); + + it("accepts AI_DEVKITD_BIN as a deprecated alias", () => { + delete process.env.DEVKITD_BIN; + const fake = path.join(os.tmpdir(), `fake-daemon-legacy-${process.pid}`); + fs.writeFileSync(fake, "#!/bin/sh\n"); + process.env.AI_DEVKITD_BIN = fake; + expect(resolveDaemonBinary()).toBe(fake); + fs.unlinkSync(fake); + }); + + it("DEVKITD_BIN wins over AI_DEVKITD_BIN", () => { + const a = path.join(os.tmpdir(), `fake-a-${process.pid}`); + const b = path.join(os.tmpdir(), `fake-b-${process.pid}`); + fs.writeFileSync(a, "#!/bin/sh\n"); + fs.writeFileSync(b, "#!/bin/sh\n"); + process.env.DEVKITD_BIN = a; + process.env.AI_DEVKITD_BIN = b; + expect(resolveDaemonBinary()).toBe(a); + fs.unlinkSync(a); + fs.unlinkSync(b); + }); + + it("ignores DEVKITD_BIN pointing at a missing file", () => { + delete process.env.AI_DEVKITD_BIN; + process.env.DEVKITD_BIN = "/nonexistent/devkitd-nope"; + expect(resolveDaemonBinary()).not.toBe("/nonexistent/devkitd-nope"); + }); +}); + +describe("daemonSocketPath", () => { + it("lives under ~/.ai-devkit", () => { + expect(daemonSocketPath()).toBe(path.join(os.homedir(), ".ai-devkit", "daemon.sock")); + }); +}); diff --git a/packages/daemon-client/src/__tests__/integration.test.ts b/packages/daemon-client/src/__tests__/integration.test.ts new file mode 100644 index 000000000..c80e3be4b --- /dev/null +++ b/packages/daemon-client/src/__tests__/integration.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, afterAll } from "vitest"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { DaemonClient } from "../client.js"; +import { ensureDaemon } from "../autostart.js"; + +const missing = path.join(os.tmpdir(), `no-such-daemon-${process.pid}.sock`); + +describe("socket fallback", () => { + it("tryConnect returns null when the socket does not exist", async () => { + expect(await DaemonClient.tryConnect(missing)).toBeNull(); + }); + + it("connect rejects on a missing socket", async () => { + await expect(DaemonClient.connect(missing)).rejects.toThrow(); + }); +}); + +describe("socket teardown", () => { + /** Serve requests for everything except `hang`, which kills the conn. */ + async function fakeDaemon(): Promise<{ sock: string; server: net.Server }> { + const sock = path.join(os.tmpdir(), `fake-daemon-${process.pid}-${Date.now()}.sock`); + const server = net.createServer((conn) => { + conn.on("data", (d) => { + for (const line of d.toString().split("\n").filter(Boolean)) { + const req = JSON.parse(line); + if (req.method === "ping") { + conn.write(JSON.stringify({ id: req.id, result: { pong: true } }) + "\n"); + } else if (req.method === "hang") { + setTimeout(() => conn.destroy(), 10); + } + // everything else: never answered + } + }); + }); + await new Promise((r) => server.listen(sock, r)); + return { sock, server }; + } + + it("rejects in-flight requests and fires onDisconnect when the socket dies", async () => { + const { sock, server } = await fakeDaemon(); + try { + const client = await DaemonClient.connect(sock); + let disconnects = 0; + client.onDisconnect = () => disconnects++; + await expect(client.request("hang")).rejects.toThrow(); + await new Promise((r) => setTimeout(r, 50)); + expect(disconnects).toBe(1); + } finally { + server.close(); + fs.rmSync(sock, { force: true }); + } + }); + + it("rejects requests that outlive the request timeout", async () => { + const { sock, server } = await fakeDaemon(); + try { + const client = await DaemonClient.connect(sock, 1500, 50); + await expect(client.request("never-answered")).rejects.toThrow(/timed out/); + client.close(); + } finally { + server.close(); + fs.rmSync(sock, { force: true }); + } + }); +}); + +// Autostart against the real binary when available (dev box sets +// DEVKITD_BIN, or rust/target/{release,debug} exists). Skipped otherwise. +const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../../.."); +const devBin = [ + path.join(repoRoot, "rust/target/release/devkitd"), + path.join(repoRoot, "rust/target/debug/devkitd"), +].find((p) => fs.existsSync(p)); +const bin = process.env.DEVKITD_BIN ?? process.env.AI_DEVKITD_BIN ?? devBin; +const sock = path.join(os.homedir(), ".ai-devkit", "daemon.sock"); + +describe.runIf(bin != null)("ensureDaemon (real binary)", () => { + const spawned = bin!; + const previousBin = process.env.DEVKITD_BIN; + process.env.DEVKITD_BIN = spawned; + + afterAll(async () => { + if (previousBin === undefined) delete process.env.DEVKITD_BIN; + else process.env.DEVKITD_BIN = previousBin; + // Leave the daemon running only if we didn't start it — send shutdown + // regardless is safer for test machines than leaking a process. + const c = await DaemonClient.tryConnect(); + if (c) { + await c.request("shutdown").catch(() => {}); + c.close(); + } + }); + + it("spawns the daemon on socket miss and returns a working client", async () => { + // If a daemon is already running from dev, this still passes: ensureDaemon + // short-circuits on a live socket. Either way we get a working client. + // Cold spawn under repo-wide parallel test load can exceed 8s. + const client = await ensureDaemon({ waitMs: 20000 }); + expect(client).not.toBeNull(); + const pong = (await client!.request("ping")) as { pong: boolean }; + expect(pong.pong).toBe(true); + const agents = (await client!.request("agent.list")) as unknown[]; + expect(Array.isArray(agents)).toBe(true); + client!.close(); + }, 30000); + + it("a second ensureDaemon is a no-op fast path", async () => { + const started = Date.now(); + const client = await ensureDaemon(); + expect(client).not.toBeNull(); + expect(Date.now() - started).toBeLessThan(2000); + client!.close(); + }); + + it("listAgents returns the attributed list over the real socket", async () => { + const client = await ensureDaemon({ waitMs: 20000 }); + expect(client).not.toBeNull(); + const result = await client!.listAgents(); + // The contract is a bare attributed array, empty or not. + expect(Array.isArray(result)).toBe(true); + client!.close(); + }, 30000); +}); + +describe.runIf(fs.existsSync(sock))("live daemon", () => { + it("daemon.status reports version and socket", async () => { + const c = await DaemonClient.tryConnect(); + if (!c) return; // socket file lingered after shutdown — nothing to check + const s = (await c.request("daemon.status")) as { + version: string; + socket: string; + }; + expect(s.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(s.socket).toBe(sock); + c.close(); + }); +}); diff --git a/packages/daemon-client/src/autostart.ts b/packages/daemon-client/src/autostart.ts new file mode 100644 index 000000000..2bccd9e40 --- /dev/null +++ b/packages/daemon-client/src/autostart.ts @@ -0,0 +1,115 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { spawn } from "node:child_process"; +import { DaemonClient, daemonSocketPath } from "./client.js"; +import { resolveDaemonBinary } from "./binary.js"; + +/** + * tmux-style implicit spawn: if the socket answers, return a client; if not, + * spawn the daemon (single-spawner guarded by an exclusive lockfile) and wait + * for readiness. Returns null when no binary exists or spawn fails — callers + * degrade to today's code paths. + */ +export async function ensureDaemon(opts: { waitMs?: number } = {}): Promise { + const waitMs = opts.waitMs ?? 3000; + // Test runs must never spawn a real daemon. Callers that want to exercise + // autostart do so by pointing DEVKITD_BIN at a real binary in an explicit + // integration test — the env below is checked there too via VITEST_SKIP. + if (process.env.AI_DEVKIT_NO_DAEMON) return null; + const existing = await DaemonClient.tryConnect(); + if (existing) return existing; + if (process.env.VITEST && !process.env.DEVKITD_BIN && !process.env.AI_DEVKITD_BIN) return null; + + const bin = resolveDaemonBinary(); + if (!bin) return null; + + const sockPath = daemonSocketPath(); + const dir = path.dirname(sockPath); + fs.mkdirSync(dir, { recursive: true }); + + // Single-spawner lockfile: O_EXCL create serializes concurrent starters, and + // the holder unlinks it when done. A stale lock (crashed spawner) is reclaimed + // after STALE_LOCK_MS so a dead starter can't wedge autostart forever. + const STALE_LOCK_MS = 30_000; + const lockPath = `${sockPath}.spawn.lock`; + const acquired = acquireSpawnLock(lockPath, STALE_LOCK_MS); + if (!acquired) { + // Another process is spawning; just wait for the socket. + return waitForSocket(waitMs); + } + + try { + const logPath = path.join(dir, "daemon.log"); + capDaemonLog(logPath); + const logFd = fs.openSync(logPath, "a"); + const child = spawn(bin, ["serve"], { + detached: true, + stdio: ["ignore", logFd, logFd], + }); + child.unref(); + fs.closeSync(logFd); + } catch { + try { + fs.unlinkSync(lockPath); + } catch { + /* already gone */ + } + return null; + } + // Hold the lock until the daemon is actually serving: a second caller that + // arrives while devkitd is still binding its socket must see the lock and + // wait — releasing early lets it spawn a twin that clobbers the socket path + // and orphans the first daemon forever. + try { + return await waitForSocket(waitMs); + } finally { + try { + fs.unlinkSync(lockPath); + } catch { + /* already gone */ + } + } +} + +/** daemon.log is append-only per spawn — rotate to .1 past 1 MiB. */ +const MAX_LOG_BYTES = 1 << 20; + +function capDaemonLog(logPath: string): void { + try { + if (fs.statSync(logPath).size > MAX_LOG_BYTES) { + fs.renameSync(logPath, `${logPath}.1`); + } + } catch { + /* missing or unwritable — the open() caller surfaces real problems */ + } +} + +function acquireSpawnLock(lockPath: string, staleMs: number): boolean { + try { + fs.closeSync(fs.openSync(lockPath, "wx", 0o600)); + return true; + } catch { + // Lock held — reclaim it only if the holder plausibly died. + try { + const age = Date.now() - fs.statSync(lockPath).mtimeMs; + if (age > staleMs) { + fs.unlinkSync(lockPath); + fs.closeSync(fs.openSync(lockPath, "wx", 0o600)); + return true; + } + } catch { + /* races with the holder are fine — losing means we wait */ + } + return false; + } +} + +async function waitForSocket(waitMs: number): Promise { + const deadline = Date.now() + waitMs; + while (Date.now() < deadline) { + const client = await DaemonClient.tryConnect(); + if (client) return client; + await new Promise((r) => setTimeout(r, 50)); + } + return null; +} diff --git a/packages/daemon-client/src/binary.ts b/packages/daemon-client/src/binary.ts new file mode 100644 index 000000000..45cc3705d --- /dev/null +++ b/packages/daemon-client/src/binary.ts @@ -0,0 +1,66 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +/** + * Locate the devkitd binary. Resolution order: + * 1. DEVKITD_BIN env override (AI_DEVKITD_BIN accepted as a deprecated alias) + * 2. Platform package `@ai-devkit/devkitd--` (npm optional dep) + * 3. ~/.ai-devkit/bin/devkitd (manual install) + * 4. rust/target/{release,debug}/devkitd relative to the monorepo (dev) + * Returns null when nothing is found — callers must degrade gracefully. + */ +export function resolveDaemonBinary(): string | null { + const env = process.env.DEVKITD_BIN ?? process.env.AI_DEVKITD_BIN; + if (env && fs.existsSync(env)) return env; + + const plats: Record = { + linux: "linux", + darwin: "darwin", + win32: "windows", + }; + const archs: Record = { x64: "x64", arm64: "arm64" }; + const plat = plats[process.platform]; + const arch = archs[process.arch]; + if (plat && arch) { + try { + const req = createRequire(import.meta.url); + const pkgJson = req.resolve(`@ai-devkit/devkitd-${plat}-${arch}/package.json`); + const candidate = path.join( + path.dirname(pkgJson), + process.platform === "win32" ? "devkitd.exe" : "devkitd", + ); + if (fs.existsSync(candidate)) return candidate; + } catch { + /* package not installed */ + } + } + + const home = path.join(os.homedir(), ".ai-devkit", "bin", "devkitd"); + if (fs.existsSync(home)) return home; + + // Dev checkout: walk up to the nearest rust/target//devkitd and + // pick the freshest profile — a stale release build otherwise shadows a + // freshly-built debug binary forever. fileURLToPath so paths containing + // spaces or other escaped chars resolve correctly. + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 8; i++) { + let best: { file: string; mtime: number } | null = null; + for (const profile of ["release", "debug"]) { + const candidate = path.join(dir, "rust", "target", profile, "devkitd"); + try { + const mtime = fs.statSync(candidate).mtimeMs; + if (!best || mtime > best.mtime) best = { file: candidate, mtime }; + } catch { + /* absent */ + } + } + if (best) return best.file; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} diff --git a/packages/daemon-client/src/client.ts b/packages/daemon-client/src/client.ts new file mode 100644 index 000000000..783c27282 --- /dev/null +++ b/packages/daemon-client/src/client.ts @@ -0,0 +1,166 @@ +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { EnrichedAgent } from "./gen/EnrichedAgent.js"; +import type { Event } from "./gen/Event.js"; + +/** Wire event pushed by the daemon — generated from devkit-core via ts-rs. */ +export type DaemonEvent = Event; + +export function daemonSocketPath(): string { + return path.join(os.homedir(), ".ai-devkit", "daemon.sock"); +} + +interface Pending { + resolve: (v: unknown) => void; + reject: (e: Error) => void; + timer: NodeJS.Timeout; +} + +/** + * Line-delimited JSON-RPC client for devkitd. One request per line; + * events arrive as `{"event": {...}}` frames after `subscribe`. + */ +export class DaemonClient { + private socket: net.Socket | null = null; + private nextId = 1; + private pending = new Map(); + private buffer = ""; + private onEvent: ((e: DaemonEvent) => void) | null = null; + /** + * Fires once when the socket dies after a successful connect (daemon + * restart, crash). Consoles use it to resubscribe instead of silently + * falling back to polling forever. + */ + onDisconnect: (() => void) | null = null; + + private constructor( + private socketPath: string, + private requestTimeoutMs = 30_000, + ) {} + + static connect( + socketPath = daemonSocketPath(), + timeoutMs = 1500, + requestTimeoutMs = 30_000, + ): Promise { + return new Promise((resolve, reject) => { + const client = new DaemonClient(socketPath, requestTimeoutMs); + const socket = net.createConnection(socketPath); + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error("daemon connect timeout")); + }, timeoutMs); + socket.once("connect", () => { + clearTimeout(timer); + client.socket = socket; + socket.on("data", (d) => client.onData(d)); + // Persistent listeners — a once("error") handler would be consumed + // by the first error, leaving later ones unhandled (which crashes + // the process). Both paths drain pending and notify onDisconnect. + socket.on("error", (e) => client.onSocketDead(e)); + socket.on("close", () => client.onSocketDead(new Error("daemon socket closed"))); + resolve(client); + }); + socket.once("error", (e) => { + clearTimeout(timer); + reject(e); + }); + }); + } + + /** Null when the socket is absent/unreachable — callers fall back. */ + static async tryConnect(socketPath = daemonSocketPath()): Promise { + try { + return await DaemonClient.connect(socketPath); + } catch { + return null; + } + } + + request(method: string, params: unknown = {}): Promise { + if (!this.socket) return Promise.reject(new Error("not connected")); + const id = this.nextId++; + return new Promise((resolve, reject) => { + // A dead-but-unreported socket or a wedged daemon must not leave + // callers awaiting forever. + const timer = setTimeout(() => { + if (this.pending.delete(id)) { + reject(new Error(`daemon request "${method}" timed out`)); + } + }, this.requestTimeoutMs); + this.pending.set(id, { resolve, reject, timer }); + this.socket!.write(JSON.stringify({ id, method, params }) + "\n"); + }); + } + + async subscribe( + handler: (e: DaemonEvent) => void, + opts: { afterSeq?: number; liveOnly?: boolean } = {}, + ): Promise { + this.onEvent = handler; + await this.request("subscribe", { afterSeq: opts.afterSeq ?? 0, liveOnly: opts.liveOnly }); + } + + /** + * The daemon's fully attributed agent list — authoritative for every + * harness type the daemon serves. Throws on RPC error: callers fall back + * to local adapters for the whole call, not per-agent. + */ + async listAgents(): Promise { + return (await this.request("agent.list")) as EnrichedAgent[]; + } + + private onData(chunk: Buffer) { + this.buffer += chunk.toString("utf8"); + let idx: number; + while ((idx = this.buffer.indexOf("\n")) >= 0) { + const line = this.buffer.slice(0, idx); + this.buffer = this.buffer.slice(idx + 1); + if (!line.trim()) continue; + let msg: Record; + try { + msg = JSON.parse(line); + } catch { + continue; + } + if (msg.event) { + this.onEvent?.(msg.event as DaemonEvent); + continue; + } + const id = msg.id as number; + const p = this.pending.get(id); + if (!p) continue; + this.pending.delete(id); + clearTimeout(p.timer); + if (msg.error) p.reject(new Error(String(msg.error))); + else p.resolve(msg.result); + } + } + + /** Reject every in-flight request and mark the socket dead. Idempotent. */ + private onSocketDead(err: Error) { + if (!this.socket) return; + this.socket = null; + const pending = [...this.pending.values()]; + this.pending.clear(); + for (const p of pending) { + clearTimeout(p.timer); + p.reject(err); + } + this.onDisconnect?.(); + } + + close() { + if (!this.socket) return; + const socket = this.socket; + this.socket = null; + socket.destroy(); + const pending = [...this.pending.values()]; + this.pending.clear(); + for (const p of pending) { + clearTimeout(p.timer); + p.reject(new Error("client closed")); + } + } +} diff --git a/packages/daemon-client/src/gen/EnrichedAgent.ts b/packages/daemon-client/src/gen/EnrichedAgent.ts new file mode 100644 index 000000000..b331aef45 --- /dev/null +++ b/packages/daemon-client/src/gen/EnrichedAgent.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Fully attributed agent, mirroring agent-manager's `AgentInfo` exactly — + * the wire shape TS clients already consume from `listAgents`. + */ +export type EnrichedAgent = { name: string, type: string, +/** + * "running" | "waiting" | "idle" | "unknown" — string so future harness + * states don't churn the schema. + */ +status: string, summary: string, pid: number, projectPath: string, sessionId: string, +/** + * ISO-8601 — TS `Date` crosses the wire as a string. + */ +lastActive: string, pinned?: boolean, sessionFilePath?: string, }; diff --git a/packages/daemon-client/src/gen/Event.ts b/packages/daemon-client/src/gen/Event.ts new file mode 100644 index 000000000..0fb1e37f5 --- /dev/null +++ b/packages/daemon-client/src/gen/Event.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Event pushed to subscribers. `seq` is the persisted log sequence number — + * subscribers ack by seq to get at-least-once delivery. + */ +export type Event = { seq: number, ts: number, kind: string, payload: unknown, }; diff --git a/packages/daemon-client/src/gen/Request.ts b/packages/daemon-client/src/gen/Request.ts new file mode 100644 index 000000000..1633c566e --- /dev/null +++ b/packages/daemon-client/src/gen/Request.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One JSON-RPC-ish request per line on the unix socket. + */ +export type Request = { id?: number, method: string, params?: unknown, }; diff --git a/packages/daemon-client/src/gen/Response.ts b/packages/daemon-client/src/gen/Response.ts new file mode 100644 index 000000000..04e4c0b4f --- /dev/null +++ b/packages/daemon-client/src/gen/Response.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Response = { id?: number, result?: unknown, error?: string, }; diff --git a/packages/daemon-client/src/gen/index.ts b/packages/daemon-client/src/gen/index.ts new file mode 100644 index 000000000..725e7df29 --- /dev/null +++ b/packages/daemon-client/src/gen/index.ts @@ -0,0 +1,4 @@ +export type { EnrichedAgent } from "./EnrichedAgent.js"; +export type { Event } from "./Event.js"; +export type { Request } from "./Request.js"; +export type { Response } from "./Response.js"; diff --git a/packages/daemon-client/src/index.ts b/packages/daemon-client/src/index.ts new file mode 100644 index 000000000..434741752 --- /dev/null +++ b/packages/daemon-client/src/index.ts @@ -0,0 +1,5 @@ +export { DaemonClient, daemonSocketPath } from "./client.js"; +export type { DaemonEvent } from "./client.js"; +export type { EnrichedAgent, Event } from "./gen/index.js"; +export { resolveDaemonBinary } from "./binary.js"; +export { ensureDaemon } from "./autostart.js"; diff --git a/packages/daemon-client/tsconfig.json b/packages/daemon-client/tsconfig.json new file mode 100644 index 000000000..72e4ba67b --- /dev/null +++ b/packages/daemon-client/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "sourceMap": true, + "resolveJsonModule": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/daemon-client/vitest.config.ts b/packages/daemon-client/vitest.config.ts new file mode 100644 index 000000000..be9acb895 --- /dev/null +++ b/packages/daemon-client/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/__tests__/**/*.test.ts", "src/**/*.test.ts"], + }, +}); diff --git a/packages/pi-session-tracker/extensions/index.ts b/packages/pi-session-tracker/extensions/index.ts index 7eac08a2e..33c698e1f 100644 --- a/packages/pi-session-tracker/extensions/index.ts +++ b/packages/pi-session-tracker/extensions/index.ts @@ -2,10 +2,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; +import * as net from "node:net"; type SessionRegistry = Record; const registryFile = path.join(os.homedir(), ".pi", "agent", "sessions.json"); +const daemonSock = path.join(os.homedir(), ".ai-devkit", "daemon.sock"); function readRegistry(): SessionRegistry { try { @@ -20,21 +22,59 @@ function writeRegistry(data: SessionRegistry): void { fs.writeFileSync(registryFile, JSON.stringify(data, null, 2), "utf8"); } +// Daemon-first store: when devkitd is listening, registry entries go through +// its sole-writer SQLite — two pi agents starting together can't lose entries. +// Falls back to the legacy sessions.json RMW when the daemon is absent. +function daemonRequest(method: string, params: Record): Promise { + return new Promise((resolve) => { + const socket = net.createConnection(daemonSock); + const timer = setTimeout(() => { + socket.destroy(); + resolve(false); + }, 1000); + socket.once("connect", () => { + socket.write(JSON.stringify({ id: 1, method, params }) + "\n"); + }); + socket.once("data", () => { + clearTimeout(timer); + socket.destroy(); + resolve(true); + }); + socket.once("error", () => { + clearTimeout(timer); + resolve(false); + }); + }); +} + export default function (pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { const pid = String(process.pid); const sessionFile = ctx.sessionManager.getSessionFile() ?? "ephemeral"; - const registry = readRegistry(); - registry[pid] = sessionFile; - writeRegistry(registry); + const viaDaemon = await daemonRequest("registry.put", { + scope: "pi-sessions", + name: pid, + value: sessionFile, + }); + if (!viaDaemon) { + const registry = readRegistry(); + registry[pid] = sessionFile; + writeRegistry(registry); + } }); pi.on("session_shutdown", async (_event, _ctx) => { const pid = String(process.pid); - const registry = readRegistry(); - delete registry[pid]; - writeRegistry(registry); + const viaDaemon = await daemonRequest("registry.delete", { + scope: "pi-sessions", + name: pid, + }); + if (!viaDaemon) { + const registry = readRegistry(); + delete registry[pid]; + writeRegistry(registry); + } }); } diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 000000000..3e9939aa4 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,726 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "devkit-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "libc", + "rusqlite", + "serde", + "serde_json", + "ts-rs", +] + +[[package]] +name = "devkit-harness" +version = "0.1.0" +dependencies = [ + "devkit-core", + "libc", + "rusqlite", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "devkitd" +version = "0.1.0" +dependencies = [ + "anyhow", + "devkit-core", + "devkit-harness", + "libc", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "termcolor", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 000000000..32c958637 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,26 @@ +[workspace] +members = ["crates/*"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" + +[workspace.dependencies] +devkit-core = { path = "crates/devkit-core" } +devkit-harness = { path = "crates/devkit-harness" } +anyhow = "1" +libc = "0.2" +rusqlite = { version = "0.32", features = ["bundled"] } +sha2 = "0.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/rust/crates/devkit-core/Cargo.toml b/rust/crates/devkit-core/Cargo.toml new file mode 100644 index 000000000..0b52593b8 --- /dev/null +++ b/rust/crates/devkit-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "devkit-core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +rusqlite.workspace = true +anyhow.workspace = true +libc.workspace = true +ts-rs = "12" diff --git a/rust/crates/devkit-core/src/agent.rs b/rust/crates/devkit-core/src/agent.rs new file mode 100644 index 000000000..6d54a791a --- /dev/null +++ b/rust/crates/devkit-core/src/agent.rs @@ -0,0 +1,82 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// Fully attributed agent, mirroring agent-manager's `AgentInfo` exactly — +/// the wire shape TS clients already consume from `listAgents`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct EnrichedAgent { + pub name: String, + #[serde(rename = "type")] + pub agent_type: String, + /// "running" | "waiting" | "idle" | "unknown" — string so future harness + /// states don't churn the schema. + pub status: String, + pub summary: String, + #[ts(type = "number")] + pub pid: u64, + pub project_path: String, + pub session_id: String, + /// ISO-8601 — TS `Date` crosses the wire as a string. + pub last_active: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub pinned: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub session_file_path: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn enriched_agent_wire_shape_matches_agent_info() { + let a = EnrichedAgent { + name: "proj".into(), + agent_type: "claude".into(), + status: "running".into(), + summary: "hi".into(), + pid: 42, + project_path: "/p".into(), + session_id: "s-1".into(), + last_active: "2026-10-08T00:00:00.000Z".into(), + pinned: None, + session_file_path: None, + }; + assert_eq!( + serde_json::to_value(&a).unwrap(), + json!({ + "name": "proj", + "type": "claude", + "status": "running", + "summary": "hi", + "pid": 42, + "projectPath": "/p", + "sessionId": "s-1", + "lastActive": "2026-10-08T00:00:00.000Z" + }) + ); + } + + #[test] + fn optional_fields_present_when_set() { + let a = EnrichedAgent { + name: "x".into(), + agent_type: "codex".into(), + status: "idle".into(), + summary: String::new(), + pid: 1, + project_path: "/q".into(), + session_id: "s".into(), + last_active: "t".into(), + pinned: Some(true), + session_file_path: Some("/f".into()), + }; + let v = serde_json::to_value(&a).unwrap(); + assert_eq!(v["pinned"], json!(true)); + assert_eq!(v["sessionFilePath"], json!("/f")); + } +} diff --git a/rust/crates/devkit-core/src/discover.rs b/rust/crates/devkit-core/src/discover.rs new file mode 100644 index 000000000..abfd5fc7b --- /dev/null +++ b/rust/crates/devkit-core/src/discover.rs @@ -0,0 +1,304 @@ +use std::process::Command; + +/// Hard cap on any discovery shell-out — a wedged ps/lsof must never stall +/// the sweep loop forever (a hung child would silently freeze discovery). +const SWEEP_CMD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Run a command to completion with a timeout; the child is SIGKILLed on +/// timeout so no zombie is left behind. `None` on spawn failure/timeout. +fn run_with_timeout(cmd: &mut Command) -> Option { + let child = cmd + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + let pid = child.id(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(child.wait_with_output()); + }); + match rx.recv_timeout(SWEEP_CMD_TIMEOUT) { + Ok(Ok(out)) => Some(out), + Ok(Err(_)) | Err(_) => { + unsafe { libc::kill(pid as i32, libc::SIGKILL) }; + None + } + } +} + +/// One discovered agent process. Harness attribution stays client-side +/// (coordination-only scope) — the daemon reports facts: pid/ppid/tty/command/cwd. +#[derive(Debug, Clone)] +pub struct AgentProc { + pub pid: i64, + pub ppid: Option, + pub tty: Option, + pub command: Option, + pub cwd: Option, + pub session_file: Option, + /// Epoch ms when the process started — populated by `enrich_agents`. + pub start_time_ms: Option, +} + +/// Executable basenames we treat as agent harnesses. Mirrors the adapter +/// processNames lists in packages/agent-manager/src/harnesses. +const AGENT_BINARIES: &[&str] = &[ + "claude", + "codex", + "gemini", + "copilot", + "grok", + "opencode", + "pi", + "kiro-cli-chat", + "kiro-cli", + "kiro", + "devin", + "kimi", + "cursor-agent", + "droid", + "agy", +]; + +/// Script runtimes that host harnesses (`node …/gemini.js`, `bun`, …). +/// Swept so adapters can claim them, but a runtime proc no adapter accepts +/// is excluded from the visible pool — otherwise every node/bun process +/// would spam agent.list and the event stream. +const RUNTIME_BINARIES: &[&str] = &["node", "bun"]; + +fn argv0_basename(cmd: &str) -> &str { + cmd.split_whitespace() + .next() + .and_then(|p| p.rsplit('/').next()) + .unwrap_or("") +} + +fn is_agent_command(cmd: &str) -> bool { + let base = argv0_basename(cmd); + AGENT_BINARIES.contains(&base) || RUNTIME_BINARIES.contains(&base) +} + +/// Whether argv0 is a script runtime — the apply_sweep visibility gate. +pub fn is_runtime_command(cmd: &str) -> bool { + RUNTIME_BINARIES.contains(&argv0_basename(cmd)) +} + +/// Single `ps -axo` sweep — replaces the per-invocation shell-outs the CLI does +/// today. Runs once per interval for all subscribers. +/// +/// Returns `None` when `ps` fails — the caller must NOT apply an empty +/// snapshot in that case or a transient failure would wipe the agent table +/// and emit a mass of phantom disappeared/appeared events. +pub fn sweep() -> Option> { + let out = run_with_timeout(Command::new("ps").args(["-axo", "pid=,ppid=,tty=,command="]))?; + if !out.status.success() { + return None; + } + let text = String::from_utf8_lossy(&out.stdout); + let mut procs = parse_ps_output(&text, true); + procs.sort_by_key(|p| p.pid); + Some(procs) +} + +/// Parse `ps -axo pid=,ppid=,tty=,command=` output into agent procs. +/// `resolve_cwd` gates the /proc lookup so tests can parse fixtures without +/// touching the live process table. +fn parse_ps_output(text: &str, resolve_cwd: bool) -> Vec { + text.lines() + .filter_map(|line| { + let mut it = line.trim().splitn(4, char::is_whitespace); + let pid: i64 = it.next()?.parse().ok()?; + let ppid: i64 = it.next()?.parse().ok()?; + let tty = it.next().map(|s| s.to_string()); + let command = it.next().unwrap_or("").trim().to_string(); + if !is_agent_command(&command) { + return None; + } + Some(AgentProc { + pid, + ppid: Some(ppid), + tty, + command: Some(command), + cwd: if resolve_cwd { cwd_of(pid) } else { None }, + session_file: None, + start_time_ms: None, + }) + }) + .collect() +} + +fn cwd_of(pid: i64) -> Option { + // /proc readlink — one syscall, no lsof/pwdx spawn. + std::fs::read_link(format!("/proc/{pid}/cwd")) + .ok() + .map(|p| p.to_string_lossy().into_owned()) +} + +/// Fill `cwd` and `start_time_ms` for every proc — mirrors agent-manager's +/// `enrichProcesses`: one `lsof` batch for cwds (works on macOS, where /proc +/// doesn't exist), one `ps lstart` batch for start times. Partial results +/// are fine; failed pids keep `None`. +pub fn enrich_agents(procs: &mut [AgentProc]) { + if procs.is_empty() { + return; + } + let pids: Vec = procs.iter().map(|p| p.pid.to_string()).collect(); + let cwd_map = batch_cwds(&pids); + let start_map = batch_start_times(&pids); + for proc in procs.iter_mut() { + if proc.cwd.is_none() { + proc.cwd = cwd_map.get(&proc.pid).cloned(); + } + proc.start_time_ms = start_map.get(&proc.pid).copied(); + } +} + +/// `lsof -a -d cwd -Fn -p ` → {pid: cwd}. Output format: `p\nn`… +fn batch_cwds(pids: &[String]) -> std::collections::HashMap { + let mut out = std::collections::HashMap::new(); + let Some(res) = run_with_timeout( + Command::new("lsof").args(["-a", "-d", "cwd", "-Fn", "-p", &pids.join(",")]), + ) else { + return out; + }; + let mut current: Option = None; + for line in String::from_utf8_lossy(&res.stdout).lines() { + if let Some(rest) = line.strip_prefix('p') { + current = rest.parse().ok(); + } else if let (Some(pid), Some(rest)) = (current, line.strip_prefix('n')) { + out.insert(pid, rest.to_string()); + current = None; + } + } + out +} + +/// `ps -o pid=,lstart= -p ` → {pid: epoch_ms}. lstart format is fixed: +/// `Wed Mar 18 23:18:01 2026` (day-of-week, month, day, time, year). +fn batch_start_times(pids: &[String]) -> std::collections::HashMap { + let mut out = std::collections::HashMap::new(); + let Some(res) = run_with_timeout( + Command::new("ps").args(["-o", "pid=,lstart=", "-p", &pids.join(",")]), + ) else { + return out; + }; + for line in String::from_utf8_lossy(&res.stdout).lines() { + let line = line.trim(); + let Some((pid_s, date_s)) = line.split_once(char::is_whitespace) else { + continue; + }; + let Ok(pid) = pid_s.parse::() else { + continue; + }; + if let Some(ms) = parse_lstart(date_s.trim()) { + out.insert(pid, ms); + } + } + out +} + +/// Parse `Wed Mar 18 23:18:01 2026` → epoch ms (local time, matching the +/// platform `ps` output — same interpretation JS `new Date(str)` applies). +fn parse_lstart(s: &str) -> Option { + let mut it = s.split_whitespace(); + it.next()?; // weekday — ignored + let month = match it.next()? { + "Jan" => 1, + "Feb" => 2, + "Mar" => 3, + "Apr" => 4, + "May" => 5, + "Jun" => 6, + "Jul" => 7, + "Aug" => 8, + "Sep" => 9, + "Oct" => 10, + "Nov" => 11, + "Dec" => 12, + _ => return None, + }; + let day: u32 = it.next()?.parse().ok()?; + let time = it.next()?; + let year: i32 = it.next()?.parse().ok()?; + let mut t = time.split(':'); + let (h, m, sec): (u32, u32, u32) = ( + t.next()?.parse().ok()?, + t.next()?.parse().ok()?, + t.next()?.parse().ok()?, + ); + // Days-since-epoch for y/m/d (Howard Hinnant algorithm), then offset by + // the local timezone the same way libc's mktime does. + let days = days_from_civil(year, month, day); + let utc_secs = days * 86400 + (h as i64) * 3600 + (m as i64) * 60 + sec as i64; + Some((utc_secs + local_utc_offset_secs(utc_secs)) * 1000) +} + +fn days_from_civil(y: i32, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y - 1 } else { y } as i64; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = ((m as i64) + 9) % 12; + let doy = (153 * mp + 2) / 5 + (d as i64) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe - 719468 +} + +/// Local UTC offset in seconds at `utc_secs`, via libc localtime. +fn local_utc_offset_secs(utc_secs: i64) -> i64 { + unsafe { + let t = utc_secs as libc::time_t; + let mut tm: libc::tm = std::mem::zeroed(); + if libc::localtime_r(&t, &mut tm).is_null() { + return 0; + } + // tm_gmtoff is seconds east of UTC; local time = utc + offset, so the + // inverse (utc = local - offset) needs -offset... but ps lstart is + // already LOCAL time we converted as if UTC, so subtract the offset + // to recover the true UTC instant. + -(tm.tm_gmtoff as i64) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Golden fixture: representative `ps -axo pid=,ppid=,tty=,command=` rows — + // agent harnesses interleaved with system noise, args, and nested paths. + const PS_FIXTURE: &str = "\ + 1 0 ? /sbin/init + 2044 1 pts/0 /bin/zsh + 15011 2044 pts/1 vim notes.md +3164044 3160 pts/0 tmux: server +3164590 3164044 pts/0 pi --model glm + 881120 2044 pts/2 /usr/bin/claude --resume abc123 + 881121 881120 ? node /opt/tools/codex-linux.js + 991000 2044 pts/3 gemini +1234567 1 ? /usr/lib/systemd/systemd --user +"; + + #[test] + fn fixture_keeps_agents_drops_noise() { + let procs = parse_ps_output(PS_FIXTURE, false); + let pids: Vec = procs.iter().map(|p| p.pid).collect(); + // 881121 (`node .../codex-linux.js`) is now swept as a runtime proc; + // apply_sweep drops it from the visible pool when no adapter claims it. + assert_eq!(pids, vec![3164590, 881120, 881121, 991000]); + } + + #[test] + fn fixture_extracts_basename_through_paths_and_args() { + let procs = parse_ps_output(PS_FIXTURE, false); + let claude = procs.iter().find(|p| p.pid == 881120).unwrap(); + assert_eq!(claude.tty.as_deref(), Some("pts/2")); + assert!(claude.command.as_deref().unwrap().contains("claude")); + // Bare name (pi) and full path (claude) both match via basename. + } + + #[test] + fn malformed_lines_are_skipped() { + let procs = parse_ps_output("garbage\n abc x ? y\n", false); + assert!(procs.is_empty()); + } +} diff --git a/rust/crates/devkit-core/src/lib.rs b/rust/crates/devkit-core/src/lib.rs new file mode 100644 index 000000000..fe90d51f4 --- /dev/null +++ b/rust/crates/devkit-core/src/lib.rs @@ -0,0 +1,4 @@ +pub mod agent; +pub mod discover; +pub mod proto; +pub mod store; diff --git a/rust/crates/devkit-core/src/proto.rs b/rust/crates/devkit-core/src/proto.rs new file mode 100644 index 000000000..433dec7e1 --- /dev/null +++ b/rust/crates/devkit-core/src/proto.rs @@ -0,0 +1,143 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use ts_rs::TS; + +/// One JSON-RPC-ish request per line on the unix socket. +#[derive(Debug, Deserialize, TS)] +pub struct Request { + #[ts(optional, type = "number")] + pub id: Option, + pub method: String, + #[serde(default)] + #[ts(optional, type = "unknown")] + pub params: Value, +} + +#[derive(Debug, Serialize, TS)] +pub struct Response { + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "number")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "unknown")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, +} + +impl Response { + pub fn ok(id: Option, result: Value) -> Self { + Self { + id, + result: Some(result), + error: None, + } + } + pub fn err(id: Option, msg: impl Into) -> Self { + Self { + id, + result: None, + error: Some(msg.into()), + } + } +} + +/// Event pushed to subscribers. `seq` is the persisted log sequence number — +/// subscribers ack by seq to get at-least-once delivery. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct Event { + #[ts(type = "number")] + pub seq: u64, + #[ts(type = "number")] + pub ts: i64, + pub kind: String, + #[ts(type = "unknown")] + pub payload: Value, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // The wire contract TS clients depend on — one JSON object per line. + + /// Regenerate packages/daemon-client/src/gen/*.ts. Deterministic: the + /// output dir is anchored at CARGO_MANIFEST_DIR, not the test CWD, and + /// `cargo test -p devkit-core` is the documented regen command. + #[test] + fn export_ts_bindings() { + let out = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../packages/daemon-client/src/gen"); + let cfg = ts_rs::Config::default().with_out_dir(&out); + Request::export(&cfg).unwrap(); + Response::export(&cfg).unwrap(); + Event::export(&cfg).unwrap(); + crate::agent::EnrichedAgent::export(&cfg).unwrap(); + + // ts-rs emits `import ... from "./X"` without the `.js` extension + // this package's nodenext resolution requires — rewrite in place. + for entry in std::fs::read_dir(&out).unwrap() { + let path = entry.unwrap().path(); + if path.extension().and_then(|e| e.to_str()) != Some("ts") { + continue; + } + let src = std::fs::read_to_string(&path).unwrap(); + let mut fixed = String::with_capacity(src.len() + 16); + for line in src.lines() { + if line.contains("from \"./") && !line.contains(".js\"") { + fixed.push_str(&line.replacen("\";", ".js\";", 1)); + } else { + fixed.push_str(line); + } + fixed.push('\n'); + } + std::fs::write(&path, fixed).unwrap(); + } + } + + #[test] + fn request_parses_minimal_and_full() { + let r: Request = serde_json::from_str(r#"{"method":"ping"}"#).unwrap(); + assert_eq!(r.id, None); + assert_eq!(r.method, "ping"); + assert_eq!(r.params, Value::Null); // params is optional + + let r: Request = serde_json::from_str( + r#"{"id":7,"method":"subscribe","params":{"afterSeq":3}}"#, + ) + .unwrap(); + assert_eq!(r.id, Some(7)); + assert_eq!(r.params["afterSeq"], 3); + } + + #[test] + fn response_omits_absent_fields() { + let ok = serde_json::to_string(&Response::ok(Some(1), json!({"x":1}))).unwrap(); + assert_eq!(ok, r#"{"id":1,"result":{"x":1}}"#); + let err = serde_json::to_string(&Response::err(Some(2), "nope")).unwrap(); + assert_eq!(err, r#"{"id":2,"error":"nope"}"#); + // No `id` field serialized for malformed-request errors. + assert!(!serde_json::to_string(&Response::err(None, "bad")) + .unwrap() + .contains("\"id\"")); + } + + #[test] + fn event_frame_shape_is_stable() { + // Subscribers parse {"event": {...}} — field names are the contract. + let ev = Event { + seq: 3, + ts: 1, + kind: "agent.appeared".into(), + payload: json!({"pid":1}), + }; + let v: Value = serde_json::from_str(&serde_json::to_string(&ev).unwrap()).unwrap(); + for k in ["seq", "ts", "kind", "payload"] { + assert!(v.get(k).is_some(), "missing field {k}"); + } + let rt: Event = serde_json::from_str(&serde_json::to_string(&ev).unwrap()).unwrap(); + assert_eq!(rt.seq, 3); + } +} diff --git a/rust/crates/devkit-core/src/store.rs b/rust/crates/devkit-core/src/store.rs new file mode 100644 index 000000000..92e9ba226 --- /dev/null +++ b/rust/crates/devkit-core/src/store.rs @@ -0,0 +1,321 @@ +use crate::proto::Event; +use anyhow::{Context, Result}; +use rusqlite::{params, Connection}; +use serde_json::Value; +use std::path::Path; +use std::sync::Mutex; + +/// Newest events kept before retention pruning kicks in. +const EVENT_RETENTION: i64 = 10_000; +/// Prune/checkpoint cadence in emitted events. +const PRUNE_EVERY: i64 = 512; + +/// Daemon-owned store: the persisted agent snapshot (for diff/events across +/// restarts) and the durable event log. The daemon is the sole writer. +pub struct Store { + conn: Mutex, +} + +impl Store { + pub fn open(path: &Path) -> Result { + let conn = Connection::open(path).context("open daemon db")?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "synchronous", "NORMAL")?; + conn.execute_batch( + "DROP TABLE IF EXISTS registry; + CREATE TABLE IF NOT EXISTS events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + kind TEXT NOT NULL, + payload TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS agents ( + pid INTEGER PRIMARY KEY, + ppid INTEGER, + tty TEXT, + command TEXT, + cwd TEXT, + session_file TEXT, + start_time_ms INTEGER, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL + );", + )?; + // Migration: databases created before start_time-keyed identity lack + // the column — add it in place. + { + let mut s = conn.prepare("PRAGMA table_info(agents)")?; + let cols = s + .query_map([], |r| r.get::<_, String>(1))? + .collect::, _>>()?; + if !cols.iter().any(|c| c == "start_time_ms") { + conn.execute("ALTER TABLE agents ADD COLUMN start_time_ms INTEGER", [])?; + } + } + Ok(Self { + conn: Mutex::new(conn), + }) + } + + /// Append an event; returns its sequence number. + /// + /// The table is bounded to the newest [`EVENT_RETENTION`] rows and the WAL + /// is checkpointed, both amortised to every [`PRUNE_EVERY`] emits so the + /// hot path stays a single insert. + pub fn emit(&self, kind: &str, payload: &Value) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO events (ts,kind,payload) VALUES (?1,?2,?3)", + params![chrono_now(), kind, serde_json::to_string(payload)?], + )?; + let seq = conn.last_insert_rowid(); + if seq % PRUNE_EVERY == 0 { + conn.execute( + "DELETE FROM events WHERE seq < ?1", + params![seq - EVENT_RETENTION], + )?; + // Single connection ⇒ no competing readers; TRUNCATE reclaims the + // WAL file synchronously. + let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)"); + } + Ok(seq as u64) + } + + /// Replay events after `after_seq` (for crash-recovery / late subscribers). + pub fn events_after(&self, after_seq: u64, limit: u32) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut s = conn + .prepare("SELECT seq,ts,kind,payload FROM events WHERE seq>?1 ORDER BY seq LIMIT ?2")?; + let rows = s.query_map(params![after_seq as i64, limit], |r| { + Ok(Event { + seq: r.get::<_, i64>(0)? as u64, + ts: r.get(1)?, + kind: r.get(2)?, + payload: serde_json::from_str(&r.get::<_, String>(3)?).unwrap_or(Value::Null), + }) + })?; + rows.collect::, _>>() + .map_err(Into::into) + } + + /// Replace the live-agent snapshot with the latest discovery sweep. + /// Returns (appeared_pids, disappeared_pids) so the server can emit events. + pub fn apply_agent_snapshot( + &self, + agents: &[crate::discover::AgentProc], + ) -> Result<(Vec, Vec)> { + let conn = self.conn.lock().unwrap(); + let now = chrono_now(); + let tx = conn.unchecked_transaction()?; + // Identity is (pid, start_time_ms) — pid alone breaks under reuse: a + // recycled pid would silently inherit the dead process's first_seen + // and emit no appeared/disappeared events at all. + let known: std::collections::HashSet<(i64, Option)> = { + let mut s = tx.prepare("SELECT pid, start_time_ms FROM agents")?; + let v = s + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::)>, + _, + >>()?; + v + }; + let current: std::collections::HashSet<(i64, Option)> = agents + .iter() + .map(|a| (a.pid, a.start_time_ms)) + .collect(); + + let mut appeared: Vec = current + .difference(&known) + .map(|(pid, _)| *pid) + .collect(); + let mut gone: Vec = known + .difference(¤t) + .map(|(pid, _)| *pid) + .collect(); + appeared.sort_unstable(); + gone.sort_unstable(); + + // Delete first so a reused pid drops its stale row (first_seen resets) + // and reappears via insert rather than an in-place update. + for pid in &gone { + tx.execute("DELETE FROM agents WHERE pid=?1", params![pid])?; + } + for a in agents { + tx.execute( + "INSERT INTO agents (pid,ppid,tty,command,cwd,session_file,start_time_ms,first_seen,last_seen) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?8) + ON CONFLICT(pid) DO UPDATE SET + ppid=excluded.ppid, tty=excluded.tty, command=excluded.command, + cwd=excluded.cwd, session_file=excluded.session_file, + start_time_ms=excluded.start_time_ms, + last_seen=excluded.last_seen", + params![a.pid, a.ppid, a.tty, a.command, a.cwd, a.session_file, a.start_time_ms, now], + )?; + } + tx.commit()?; + Ok((appeared, gone)) + } + + #[cfg(test)] + pub fn list_agents(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut s = conn.prepare( + "SELECT pid,ppid,tty,command,cwd,session_file,start_time_ms,first_seen,last_seen FROM agents ORDER BY pid", + )?; + let rows = s.query_map([], |r| { + Ok(serde_json::json!({ + "pid": r.get::<_, i64>(0)?, + "ppid": r.get::<_, Option>(1)?, + "tty": r.get::<_, Option>(2)?, + "command": r.get::<_, Option>(3)?, + "cwd": r.get::<_, Option>(4)?, + "sessionFilePath": r.get::<_, Option>(5)?, + "startTime": r.get::<_, Option>(6)?, + "firstSeen": r.get::<_, i64>(7)?, + "lastSeen": r.get::<_, i64>(8)?, + })) + })?; + rows.collect::, _>>() + .map_err(Into::into) + } +} + +pub fn chrono_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::discover::AgentProc; + use serde_json::json; + use std::path::PathBuf; + + fn tmp_store() -> (Store, PathBuf) { + static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!("aidk-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join(format!( + "t-{}-{}.db", + chrono_now(), + N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + (Store::open(&p).unwrap(), p) + } + + fn proc(pid: i64) -> AgentProc { + AgentProc { + pid, + ppid: Some(1), + tty: None, + command: Some("codex".into()), + cwd: Some("/tmp".into()), + session_file: None, + start_time_ms: None, + } + } + + fn proc_started(pid: i64, start: i64) -> AgentProc { + AgentProc { + start_time_ms: Some(start), + ..proc(pid) + } + } + + #[test] + fn events_are_ordered_and_replayable() { + let (s, _p) = tmp_store(); + let a = s.emit("registry.changed", &json!({"name":"a"})).unwrap(); + let b = s.emit("agent.appeared", &json!({"pid":1})).unwrap(); + assert!(b > a); + let replayed = s.events_after(0, 100).unwrap(); + assert_eq!(replayed.len(), 2); + assert_eq!(s.events_after(a, 100).unwrap().len(), 1); + } + + #[test] + fn snapshot_diff_reports_appear_and_vanish() { + let (s, _p) = tmp_store(); + let (app, gone) = s.apply_agent_snapshot(&[proc(10), proc(11)]).unwrap(); + assert_eq!(app, vec![10, 11]); + assert!(gone.is_empty()); + let (app, gone) = s.apply_agent_snapshot(&[proc(11), proc(12)]).unwrap(); + assert_eq!(app, vec![12]); + assert_eq!(gone, vec![10]); + let agents = s.list_agents().unwrap(); + assert_eq!(agents.len(), 2); + } + + #[test] + fn pid_reuse_is_disappear_plus_appear_and_resets_first_seen() { + let (s, _p) = tmp_store(); + let (app, gone) = s.apply_agent_snapshot(&[proc_started(10, 1000)]).unwrap(); + assert_eq!(app, vec![10]); + assert!(gone.is_empty()); + let first_seen = s.list_agents().unwrap()[0]["firstSeen"].as_i64().unwrap(); + + // Same pid, different start time → the OS reused it. Must read as a + // disappear+appear pair, not a silent continuation. + std::thread::sleep(std::time::Duration::from_millis(10)); + let (app, gone) = s.apply_agent_snapshot(&[proc_started(10, 2000)]).unwrap(); + assert_eq!(app, vec![10]); + assert_eq!(gone, vec![10]); + let row = &s.list_agents().unwrap()[0]; + assert_eq!(row["startTime"], serde_json::json!(2000)); + assert!(row["firstSeen"].as_i64().unwrap() > first_seen); + } + + #[test] + fn events_table_is_bounded_by_retention() { + let (s, _p) = tmp_store(); + for i in 0..(EVENT_RETENTION + 2 * PRUNE_EVERY) { + s.emit("tick", &serde_json::json!({"i": i})).unwrap(); + } + let conn = s.conn.lock().unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0)) + .unwrap(); + let min_seq: i64 = conn + .query_row("SELECT MIN(seq) FROM events", [], |r| r.get(0)) + .unwrap(); + assert!(count <= EVENT_RETENTION + PRUNE_EVERY); + assert!(min_seq > 0); + } + + #[test] + fn concurrent_sole_writer_loses_no_updates() { + // N threads racing writes must all land — the store serializes via + // the mutex and every emit gets its own seq. + let (s, _p) = tmp_store(); + let s = std::sync::Arc::new(s); + let mut handles = Vec::new(); + for i in 0..32 { + let s = s.clone(); + handles.push(std::thread::spawn(move || { + s.emit("tick", &json!(i)).unwrap(); + })); + } + for h in handles { + h.join().unwrap(); + } + assert_eq!(s.events_after(0, 100).unwrap().len(), 32); + } + + #[test] + fn events_survive_reopen() { + let (s, p) = tmp_store(); + let seq = s.emit("agent.appeared", &json!({"pid": 7})).unwrap(); + drop(s); + let s2 = Store::open(&p).unwrap(); + let events = s2.events_after(0, 100).unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].seq, seq); + assert_eq!(events[0].kind, "agent.appeared"); + assert_eq!(events[0].payload["pid"], 7); + } + +} diff --git a/rust/crates/devkit-harness/Cargo.toml b/rust/crates/devkit-harness/Cargo.toml new file mode 100644 index 000000000..037d52d92 --- /dev/null +++ b/rust/crates/devkit-harness/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "devkit-harness" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +devkit-core.workspace = true +serde.workspace = true +serde_json.workspace = true +libc.workspace = true +rusqlite.workspace = true +sha2.workspace = true diff --git a/rust/crates/devkit-harness/src/antigravity/locator.rs b/rust/crates/devkit-harness/src/antigravity/locator.rs new file mode 100644 index 000000000..0f397fcaf --- /dev/null +++ b/rust/crates/devkit-harness/src/antigravity/locator.rs @@ -0,0 +1,80 @@ +//! Port of `AntigravitySessionLocator` — the +//! `cache/last_conversations.json` cwd→conversationId registry and the +//! per-process join. + +use devkit_core::discover::AgentProc; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; + +/// `AntigravityConversationRef`. +pub struct ConversationRef { + pub cwd: String, + pub conversation_id: String, + pub transcript_path: String, +} + +/// `AntigravityProcessMatch` — every agy proc gets a row; `conversation` +/// is None when its cwd isn't registered. +pub struct ProcessMatch<'a> { + pub process: &'a AgentProc, + pub cwd: String, + pub conversation: Option, +} + +/// `matchRunningProcesses` — one entry per process, in process order, +/// joined on `proc.cwd || ""`. +pub fn match_running_processes<'a>( + base_dir: &Path, + processes: &[&'a AgentProc], +) -> Vec> { + let by_cwd: HashMap = list_conversations(base_dir) + .into_iter() + .map(|r| (r.cwd.clone(), r)) + .collect(); + processes + .iter() + .map(|process| { + let cwd = process.cwd.clone().unwrap_or_default(); + let conversation = by_cwd.get(&cwd).map(|r| ConversationRef { + cwd: r.cwd.clone(), + conversation_id: r.conversation_id.clone(), + transcript_path: r.transcript_path.clone(), + }); + ProcessMatch { + process, + cwd, + conversation, + } + }) + .collect() +} + +/// `listConversations` — `{ : }`; non-object JSON, +/// empty keys/ids, and non-string ids are all skipped. +fn list_conversations(base_dir: &Path) -> Vec { + let content = std::fs::read_to_string(base_dir.join("cache/last_conversations.json")); + let Ok(content) = content else { return Vec::new() }; + let Ok(parsed) = serde_json::from_str::(&content) else { + return Vec::new(); + }; + let Some(obj) = parsed.as_object() else { + return Vec::new(); + }; + obj.iter() + .filter(|(cwd, id)| !cwd.is_empty() && id.as_str().is_some_and(|s| !s.is_empty())) + .map(|(cwd, id)| { + let conversation_id = id.as_str().unwrap().to_string(); + ConversationRef { + cwd: cwd.clone(), + transcript_path: base_dir + .join("brain") + .join(&conversation_id) + .join(".system_generated/logs/transcript.jsonl") + .to_string_lossy() + .into_owned(), + conversation_id, + } + }) + .collect() +} diff --git a/rust/crates/devkit-harness/src/antigravity/mod.rs b/rust/crates/devkit-harness/src/antigravity/mod.rs new file mode 100644 index 000000000..93a26c430 --- /dev/null +++ b/rust/crates/devkit-harness/src/antigravity/mod.rs @@ -0,0 +1,120 @@ +//! Port of `packages/agent-manager/src/harnesses/antigravity` — Google's +//! `agy` CLI detection. +//! +//! Flow (mirrors `detectRunningAgents`): +//! 1. pool-filter the snapshot to `agy`, canHandle + first-wins dedupe +//! 2. `matchRunningProcesses`: join each proc's cwd against +//! `cache/last_conversations.json` under the antigravity-cli home +//! 3. registered cwd → read `brain//.system_generated/logs/ +//! transcript.jsonl`; missing transcript → process-only agent + +mod locator; +mod parser; + +use crate::shared; +use crate::{EnrichedAgent, HarnessAdapter, SweepContext}; +use devkit_core::discover::AgentProc; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +/// `processNames` — the pool `findHarnessProcesses` filters to. +const PROCESS_NAMES: &[&str] = &["agy"]; + +pub struct AntigravityCliAdapter { + base_dir: PathBuf, +} + +impl AntigravityCliAdapter { + /// `ANTIGRAVITY_CLI_HOME || ~/.gemini/antigravity-cli`. + pub fn new(home: &Path) -> Self { + let base_dir = std::env::var_os("ANTIGRAVITY_CLI_HOME") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".gemini").join("antigravity-cli")); + Self { base_dir } + } + + /// Base-dir override for fixture replay (no env involved). + pub fn at_base_dir(dir: impl Into) -> Self { + Self { + base_dir: dir.into(), + } + } + + /// `mapSessionToAgent` — name from the registry cwd; summary is the + /// last user request, untruncated (no `truncate` in the TS mapper). + fn map_session_to_agent( + &self, + session: &parser::Session, + proc: &AgentProc, + now: i64, + ) -> EnrichedAgent { + EnrichedAgent { + name: shared::generate_agent_name(&session.project_path, proc.pid), + agent_type: "antigravity_cli".into(), + status: parser::status(session, now).into(), + summary: session + .last_user_message + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "Antigravity CLI session active".into()), + pid: proc.pid as u64, + project_path: session.project_path.clone(), + session_id: session.session_id.clone(), + last_active: shared::iso_utc(session.last_active_ms), + pinned: None, + session_file_path: Some(session.transcript_path.clone()), + } + } +} + +impl HarnessAdapter for AntigravityCliAdapter { + fn type_id(&self) -> &'static str { + "antigravity_cli" + } + + fn can_handle(&self, proc: &AgentProc) -> bool { + proc.command + .as_deref() + .map(|c| shared::matches_executable(c, "agy")) + .unwrap_or(false) + } + + fn detect(&self, ctx: &SweepContext) -> Vec { + // `findHarnessProcesses`: `agy` pool filter, then canHandle, + // first-wins by pid. + let mut seen = HashSet::new(); + let processes: Vec<&AgentProc> = ctx + .processes + .iter() + .filter(|p| { + p.command + .as_deref() + .map(|c| shared::matches_executable_name(c, PROCESS_NAMES)) + .unwrap_or(false) + }) + .filter(|p| self.can_handle(p) && seen.insert(p.pid)) + .collect(); + if processes.is_empty() { + return Vec::new(); + } + + let mut agents = Vec::new(); + for m in locator::match_running_processes(&self.base_dir, &processes) { + let session = m.conversation.as_ref().and_then(|c| { + parser::read_session(&c.conversation_id, &c.transcript_path, &m.cwd) + }); + match session { + Some(s) => agents.push(self.map_session_to_agent(&s, m.process, ctx.now)), + None => agents.push(shared::process_only_agent( + "antigravity_cli", + m.process, + "Antigravity CLI process running", + Some(&m.cwd), + ctx.now, + )), + } + } + agents + } +} diff --git a/rust/crates/devkit-harness/src/antigravity/parser.rs b/rust/crates/devkit-harness/src/antigravity/parser.rs new file mode 100644 index 000000000..1f80c4d2d --- /dev/null +++ b/rust/crates/devkit-harness/src/antigravity/parser.rs @@ -0,0 +1,131 @@ +//! Port of `AntigravitySessionParser` — the bounded JSONL fold behind +//! `readSessionIncremental` and `determineStatus`. Conversation reads and +//! the incremental cache are not ported. + +use crate::shared; +use serde_json::{Map, Value}; + +/// `AntigravitySummaryState`. +#[derive(Default)] +struct Summary { + past_head: bool, + first_user_message: Option, + last_user_message: Option, + last_role: Option<&'static str>, + last_active_ms: Option, +} + +/// `AntigravitySession` (wire-relevant fields only). +pub struct Session { + pub session_id: String, + pub project_path: String, + pub transcript_path: String, + pub last_active_ms: i64, + pub last_user_message: Option, + pub last_role: Option<&'static str>, +} + +/// `readSessionIncremental` — stat must succeed (a directory still +/// counts, matching TS `!stat`); unreadable transcript → empty summary. +pub fn read_session(conversation_id: &str, transcript_path: &str, cwd: &str) -> Option { + let meta = std::fs::metadata(transcript_path).ok()?; + let state = shared::fold_jsonl_bounded( + transcript_path, + Summary::default(), + reduce, + |s: Summary| Summary { + past_head: true, + first_user_message: s.first_user_message, + ..Summary::default() + }, + ) + .unwrap_or_default(); + let mtime = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + Some(Session { + session_id: conversation_id.to_string(), + project_path: cwd.to_string(), + transcript_path: transcript_path.to_string(), + last_active_ms: state.last_active_ms.unwrap_or(mtime), + last_user_message: state.last_user_message, + last_role: state.last_role, + }) +} + +/// `determineStatus`: past the idle mark → idle; last turn assistant → +/// waiting; else running. +pub fn status(session: &Session, now_ms: i64) -> &'static str { + if shared::is_idle(session.last_active_ms, now_ms) { + "idle" + } else if session.last_role == Some("assistant") { + "waiting" + } else { + "running" + } +} + +/// `summaryReducer.reduce` — `created_at` keeps the MAX seen (not the +/// last line's); first/last user request and the last user/assistant +/// role come from `recordToMessage` rules. +fn reduce(mut state: Summary, value: Option<&Value>) -> Summary { + let Some(record) = value.and_then(|v| v.as_object()) else { + return state; + }; + if let Some(ms) = record + .get("created_at") + .and_then(shared::parse_timestamp_ms) + { + if state.last_active_ms.is_none_or(|m| ms > m) { + state.last_active_ms = Some(ms); + } + } + match record_to_message(record) { + Some(("user", content)) => { + if state.first_user_message.is_none() && !state.past_head { + state.first_user_message = Some(content.clone()); + } + state.last_user_message = Some(content); + state.last_role = Some("user"); + } + Some(("assistant", _)) => state.last_role = Some("assistant"), + _ => {} + } + state +} + +/// `recordToMessage(record, false)` — USER_INPUT → user (via +/// `extractUserRequest`), non-empty PLANNER_RESPONSE → assistant; +/// everything else is execution detail (verbose-only in TS). +fn record_to_message(record: &Map) -> Option<(&'static str, String)> { + let text = shared::flatten_text_blocks(record.get("content").unwrap_or(&Value::Null)); + if record.get("type").and_then(|t| t.as_str()) == Some("USER_INPUT") { + return extract_user_request(&text).map(|r| ("user", r)); + } + if text.is_empty() { + return None; + } + if record.get("type").and_then(|t| t.as_str()) == Some("PLANNER_RESPONSE") { + return Some(("assistant", text)); + } + None +} + +/// `extractUserRequest` — the text inside the first +/// `…` pair, trimmed (may be ""); without a +/// wrapper, the whole trimmed text or None. +fn extract_user_request(text: &str) -> Option { + const OPEN: &str = ""; + const CLOSE: &str = ""; + if let Some(start) = text.find(OPEN) { + let body_start = start + OPEN.len(); + if let Some(end) = text[body_start..].find(CLOSE) { + return Some(text[body_start..body_start + end].trim().to_string()); + } + } + let t = text.trim(); + (!t.is_empty()).then(|| t.to_string()) +} diff --git a/rust/crates/devkit-harness/src/claude/locator.rs b/rust/crates/devkit-harness/src/claude/locator.rs new file mode 100644 index 000000000..2188466b8 --- /dev/null +++ b/rust/crates/devkit-harness/src/claude/locator.rs @@ -0,0 +1,296 @@ +//! Port of `ClaudeSessionLocator` — the three-stage process→session match: +//! `--resume`, `~/.claude/sessions/.json`, legacy cwd+birthtime. + +use crate::shared::{match_processes_to_sessions, SessionFile}; +use devkit_core::discover::AgentProc; +use std::path::{Path, PathBuf}; + +/// `PID_FILE_STALENESS_MS` — |procStart − pidfile.startedAt| beyond which the +/// pid file belongs to a recycled PID. +const PID_FILE_STALENESS_MS: i64 = 60_000; + +#[derive(Debug)] +struct PidFileEntry { + session_id: String, + cwd: String, + started_at: f64, + status: Option, + waiting_for: Option, +} + +pub struct DirectMatch<'a> { + pub proc: &'a AgentProc, + pub session_file: SessionFile, + pub pid_status: Option<&'static str>, + pub waiting_for: Option, +} + +pub struct Matches<'a> { + pub direct: Vec>, + /// (proc, SessionFile) pairs in TS `MatchResult` order (delta asc). + pub legacy: Vec<(&'a AgentProc, SessionFile)>, +} + +/// `getProjectDir` — every non-alphanumeric char becomes '-'. +fn encoded_project_dir(projects_dir: &Path, cwd: &str) -> PathBuf { + let encoded: String = cwd + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + projects_dir.join(encoded) +} + +fn stat_birthtime_ms(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + let bt = meta.created().ok()?; + let ms = bt.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as i64; + Some(ms) +} + +pub struct ClaudeLocator<'a> { + projects_dir: &'a Path, + sessions_dir: &'a Path, +} + +impl<'a> ClaudeLocator<'a> { + pub fn new(projects_dir: &'a Path, sessions_dir: &'a Path) -> Self { + Self { + projects_dir, + sessions_dir, + } + } + + /// `matchRunningProcesses` — resume → pid-file → legacy, in that order. + pub fn match_running_processes<'p>(&self, processes: &[&'p AgentProc]) -> Matches<'p> { + let (resume_direct, no_resume) = self.try_resume_matching(processes); + let (pid_direct, fallback) = self.try_pid_file_matching(no_resume); + let legacy_sessions = self.discover_live_sessions(&fallback); + let legacy: Vec<(&'p AgentProc, SessionFile)> = + if !fallback.is_empty() && !legacy_sessions.is_empty() { + match_processes_to_sessions(&fallback, &legacy_sessions) + .into_iter() + .map(|m| (m.proc, legacy_sessions[m.session_idx].clone())) + .collect() + } else { + Vec::new() + }; + Matches { + direct: resume_direct.into_iter().chain(pid_direct).collect(), + legacy, + } + } + + /// `discoverLiveSessions` — for each unique proc.cwd whose encoded + /// projects dir exists, enumerate `.jsonl` files with birthtimes. + fn discover_live_sessions(&self, processes: &[&AgentProc]) -> Vec { + let mut dir_to_cwd: Vec<(PathBuf, String)> = Vec::new(); + for proc in processes { + let Some(cwd) = proc.cwd.as_deref() else { + continue; + }; + if cwd.is_empty() { + continue; + } + let dir = encoded_project_dir(self.projects_dir, cwd); + if dir_to_cwd.iter().any(|(d, _)| *d == dir) { + continue; + } + if dir.is_dir() { + dir_to_cwd.push((dir, cwd.to_string())); + } + } + + let mut out = Vec::new(); + for (dir, cwd) in dir_to_cwd { + for name in crate::shared::list_dir_names(&dir) { + if !name.ends_with(".jsonl") { + continue; + } + let file_path = dir.join(&name); + let Some(birthtime_ms) = stat_birthtime_ms(&file_path) else { + continue; + }; + if birthtime_ms <= 0 { + continue; + } + out.push(SessionFile { + session_id: name.trim_end_matches(".jsonl").to_string(), + file_path: file_path.to_string_lossy().into_owned(), + project_dir: dir.to_string_lossy().into_owned(), + birthtime_ms, + resolved_cwd: cwd.clone(), + }); + } + } + out + } + + /// `extractResumeSessionId` — `--resume\s+([0-9a-f-]{36})` case-insensitive. + fn extract_resume_session_id(command: &str) -> Option { + let lower = command.to_lowercase(); + let idx = lower.find("--resume")?; + let after = &command[idx + 8..]; + let trimmed = after.trim_start(); + if trimmed.len() == after.len() { + return None; // \s+ required at least one whitespace + } + let run: String = trimmed + .chars() + .take_while(|c| c.is_ascii_hexdigit() || *c == '-') + .collect(); + if run.len() < 36 { + return None; + } + Some(run[..36].to_string()) + } + + /// `readMatchingPidFile` — `/.json` or null on any + /// failure/staleness. + fn read_matching_pid_file(&self, pid: i64, proc_start_ms: Option) -> Option { + let path = self.sessions_dir.join(format!("{pid}.json")); + let text = std::fs::read_to_string(path).ok()?; + let v: serde_json::Value = serde_json::from_str(&text).ok()?; + let entry = PidFileEntry { + session_id: v.get("sessionId")?.as_str()?.to_string(), + cwd: v.get("cwd")?.as_str()?.to_string(), + started_at: v.get("startedAt")?.as_f64()?, + status: v.get("status").and_then(|s| s.as_str()).map(String::from), + waiting_for: v + .get("waitingFor") + .and_then(|s| s.as_str()) + .map(String::from), + }; + if let Some(start) = proc_start_ms { + if (start as f64 - entry.started_at).abs() > PID_FILE_STALENESS_MS as f64 { + return None; + } + } + Some(entry) + } + + fn map_pid_status(status: Option<&str>) -> Option<&'static str> { + match status { + Some("running") => Some("running"), + Some("waiting") => Some("waiting"), + Some("idle") => Some("idle"), + _ => None, + } + } + + /// `tryResumeMatching` — `claude --resume ` command matches. + fn try_resume_matching<'p>( + &self, + processes: &[&'p AgentProc], + ) -> (Vec>, Vec<&'p AgentProc>) { + let mut direct = Vec::new(); + let mut fallback = Vec::new(); + + for &proc in processes { + let session_id = proc + .command + .as_deref() + .and_then(Self::extract_resume_session_id); + let (Some(session_id), Some(cwd)) = (session_id, proc.cwd.as_deref()) else { + fallback.push(proc); + continue; + }; + let project_dir = encoded_project_dir(self.projects_dir, cwd); + let jsonl_path = project_dir.join(format!("{session_id}.jsonl")); + let Some(birthtime_ms) = stat_birthtime_ms(&jsonl_path) else { + fallback.push(proc); + continue; + }; + let pid_entry = self.read_matching_pid_file(proc.pid, proc.start_time_ms); + direct.push(DirectMatch { + proc, + session_file: SessionFile { + session_id, + file_path: jsonl_path.to_string_lossy().into_owned(), + project_dir: project_dir.to_string_lossy().into_owned(), + birthtime_ms, + resolved_cwd: cwd.to_string(), + }, + pid_status: Self::map_pid_status( + pid_entry.as_ref().and_then(|e| e.status.as_deref()), + ), + waiting_for: pid_entry.and_then(|e| e.waiting_for), + }); + } + (direct, fallback) + } + + /// `tryPidFileMatching` — authoritative `/.json` matches. + fn try_pid_file_matching<'p>( + &self, + processes: Vec<&'p AgentProc>, + ) -> (Vec>, Vec<&'p AgentProc>) { + let mut direct = Vec::new(); + let mut fallback = Vec::new(); + + for proc in processes { + let Some(entry) = self.read_matching_pid_file(proc.pid, proc.start_time_ms) else { + fallback.push(proc); + continue; + }; + let project_dir = encoded_project_dir(self.projects_dir, &entry.cwd); + let jsonl_path = project_dir.join(format!("{}.jsonl", entry.session_id)); + if !jsonl_path.exists() { + fallback.push(proc); + continue; + } + direct.push(DirectMatch { + proc, + session_file: SessionFile { + session_id: entry.session_id, + file_path: jsonl_path.to_string_lossy().into_owned(), + project_dir: project_dir.to_string_lossy().into_owned(), + birthtime_ms: entry.started_at as i64, + resolved_cwd: entry.cwd, + }, + pid_status: Self::map_pid_status(entry.status.as_deref()), + waiting_for: entry.waiting_for, + }); + } + (direct, fallback) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resume_session_id_extraction() { + let id = "abcdef12-3456-7890-abcd-ef1234567890"; + assert_eq!( + ClaudeLocator::extract_resume_session_id(&format!("claude --resume {id}")), + Some(id.into()) + ); + assert_eq!( + ClaudeLocator::extract_resume_session_id(&format!("/usr/bin/claude --resume {id} --x")), + Some(id.into()) + ); + // Exactly-36 run: longer runs still capture the first 36 (regex parity). + assert_eq!( + ClaudeLocator::extract_resume_session_id(&format!("claude --resume {id}aa")), + Some(id.into()) + ); + assert_eq!( + ClaudeLocator::extract_resume_session_id("claude --resume short"), + None + ); + assert_eq!( + ClaudeLocator::extract_resume_session_id("claude --resume"), + None + ); + assert_eq!(ClaudeLocator::extract_resume_session_id("claude"), None); + } + + #[test] + fn encoding_replaces_non_alnum() { + assert_eq!( + encoded_project_dir(Path::new("/p"), "/Users/foo/my_proj"), + PathBuf::from("/p/-Users-foo-my-proj") + ); + } +} diff --git a/rust/crates/devkit-harness/src/claude/mod.rs b/rust/crates/devkit-harness/src/claude/mod.rs new file mode 100644 index 000000000..a84a5be0f --- /dev/null +++ b/rust/crates/devkit-harness/src/claude/mod.rs @@ -0,0 +1,171 @@ +//! Port of `ClaudeCodeAdapter` — detect path only (no getConversation / +//! listSessions). Mirrors ClaudeSessionLocator → ClaudeSessionParser → +//! ClaudeAgentMapper exactly; fixture parity is the acceptance gate. + +mod locator; +mod parser; + +use crate::shared::{ + generate_agent_name, iso_utc, matches_executable_name, process_only_agent, SessionFile, +}; +use crate::{HarnessAdapter, SweepContext}; +use devkit_core::agent::EnrichedAgent; +use devkit_core::discover::AgentProc; +use locator::{ClaudeLocator, Matches}; +use std::path::PathBuf; + +pub struct ClaudeAdapter { + projects_dir: PathBuf, + sessions_dir: PathBuf, +} + +impl ClaudeAdapter { + /// `~/.claude/projects` + `~/.claude/sessions` under the given home. + pub fn new(home: &std::path::Path) -> Self { + let base = home.join(".claude"); + Self { + projects_dir: base.join("projects"), + sessions_dir: base.join("sessions"), + } + } +} + +struct SessionView { + session_id: String, + last_active: String, + status: &'static str, + summary: String, + resolved_cwd: String, + file_path: String, +} + +fn to_agent(view: &SessionView, proc: &AgentProc) -> EnrichedAgent { + let proc_cwd = proc.cwd.as_deref().unwrap_or(""); + EnrichedAgent { + name: generate_agent_name(proc_cwd, proc.pid), + agent_type: "claude".into(), + status: view.status.into(), + summary: view.summary.clone(), + pid: proc.pid as u64, + project_path: if view.resolved_cwd.is_empty() { + proc_cwd.to_string() + } else { + view.resolved_cwd.clone() + }, + session_id: view.session_id.clone(), + last_active: view.last_active.clone(), + pinned: None, + session_file_path: Some(view.file_path.clone()), + } +} + +/// `toSession` + `mapSessionToAgent` for one matched (proc, sessionFile). +fn map_matched( + proc: &AgentProc, + session_file: &SessionFile, + pid_status: Option<&'static str>, + waiting_for: Option<&str>, + now: i64, +) -> Option { + let state = parser::read_session(&session_file.file_path)?; + let status = pid_status.unwrap_or_else(|| parser::determine_status(&state)); + let base = state + .last_user_message + .clone() + .unwrap_or_else(|| "Session started".into()); + let summary = match (status, waiting_for) { + ("waiting", Some(w)) => format!("{base} — waiting for {w}"), + _ => base, + }; + let last_active_ms = state.last_active_ms.unwrap_or(now); + let view = SessionView { + session_id: session_file.session_id.clone(), + last_active: iso_utc(last_active_ms), + status, + summary, + resolved_cwd: session_file.resolved_cwd.clone(), + file_path: session_file.file_path.clone(), + }; + Some(to_agent(&view, proc)) +} + +impl HarnessAdapter for ClaudeAdapter { + fn type_id(&self) -> &'static str { + "claude" + } + + fn can_handle(&self, proc: &AgentProc) -> bool { + proc.command + .as_deref() + .map(|c| crate::shared::matches_executable(c, "claude")) + .unwrap_or(false) + } + + fn detect(&self, ctx: &SweepContext) -> Vec { + // `findHarnessProcesses`: relevant by processNames, then canHandle, + // dedup by pid (sweep procs are unique already). + let processes: Vec<&AgentProc> = ctx + .processes + .iter() + .filter(|p| { + p.command + .as_deref() + .map(|c| matches_executable_name(c, &["claude"])) + .unwrap_or(false) + }) + .filter(|p| self.can_handle(p)) + .collect(); + if processes.is_empty() { + return Vec::new(); + } + + let locator = ClaudeLocator::new(&self.projects_dir, &self.sessions_dir); + let Matches { direct, legacy } = locator.match_running_processes(&processes); + + let mut matched_pids: std::collections::HashSet = direct + .iter() + .map(|d| d.proc.pid) + .chain(legacy.iter().map(|(p, _)| p.pid)) + .collect(); + + let mut agents: Vec = Vec::new(); + + for m in &direct { + match map_matched( + m.proc, + &m.session_file, + m.pid_status, + m.waiting_for.as_deref(), + ctx.now, + ) { + Some(a) => agents.push(a), + None => { + matched_pids.remove(&m.proc.pid); + } + } + } + + for (proc, session_file) in &legacy { + match map_matched(proc, session_file, None, None, ctx.now) { + Some(a) => agents.push(a), + None => { + matched_pids.remove(&proc.pid); + } + } + } + + for proc in &processes { + if !matched_pids.contains(&proc.pid) { + agents.push(process_only_agent( + "claude", + proc, + "Claude Code process running", + None, + ctx.now, + )); + } + } + + agents + } +} diff --git a/rust/crates/devkit-harness/src/claude/parser.rs b/rust/crates/devkit-harness/src/claude/parser.rs new file mode 100644 index 000000000..0b57776d4 --- /dev/null +++ b/rust/crates/devkit-harness/src/claude/parser.rs @@ -0,0 +1,411 @@ +//! Port of `ClaudeSessionParser` + `IncrementalJsonlSummary` cold-start +//! semantics. No incremental cache — every detect rebuilds, which is exactly +//! `readSessionIncremental`'s first-read result. + +use crate::shared::parse_iso_ms; + +const HEAD_BYTES: usize = 1024 * 1024; +const TAIL_BYTES: usize = 4 * 1024 * 1024; + +/// Types that update `lastEntryType` — UI-state events must not overwrite the +/// last conversation turn (same set as CONVERSATION_ENTRY_TYPES in TS). +fn is_conversation_type(t: &str) -> bool { + matches!(t, "user" | "assistant" | "system" | "progress" | "thinking") +} + +/// O(1) folded session state — `ClaudeSummaryState` in TS. +#[derive(Debug, Default)] +pub struct SummaryState { + seen_first_line: bool, + past_head: bool, + pub session_start_ms: Option, + pub last_active_ms: Option, + pub last_cwd: Option, + pub last_entry_type: Option, + pub is_interrupted: bool, + pub last_user_message: Option, + pub first_user_message: Option, +} + +fn initial() -> SummaryState { + SummaryState::default() +} + +/// `summaryReducer.reduce` — fold one parsed JSON value (None = invalid JSON). +fn reduce(state: SummaryState, value: Option<&serde_json::Value>) -> SummaryState { + let mut next = SummaryState { + seen_first_line: true, + ..state + }; + if !state.seen_first_line { + next.session_start_ms = value.and_then(parse_session_start); + } + let Some(entry) = value else { + return next; + }; + let entry = match entry.as_object() { + Some(o) => o, + None => return next, + }; + + if let Some(ts) = entry.get("timestamp").and_then(|v| v.as_str()) { + if let Some(ms) = parse_iso_ms(ts) { + next.last_active_ms = Some(ms); + } + } + if let Some(cwd) = entry.get("cwd").and_then(|v| v.as_str()) { + if !cwd.trim().is_empty() { + next.last_cwd = Some(cwd.to_string()); + } + } + + let Some(etype) = entry.get("type").and_then(|v| v.as_str()) else { + return next; + }; + if !is_conversation_type(etype) { + return next; + } + next.last_entry_type = Some(etype.to_string()); + + if etype == "user" { + let content = entry.get("message").and_then(|m| m.get("content")); + next.is_interrupted = content + .and_then(|c| c.as_array()) + .map(|blocks| { + blocks.iter().any(|b| { + let t = b.get("type").and_then(|v| v.as_str()); + (t == Some("text") + && b.get("text") + .and_then(|v| v.as_str()) + .is_some_and(|s| s.contains("[Request interrupted"))) + || (t == Some("tool_result") + && b.get("content") + .and_then(|v| v.as_str()) + .is_some_and(|s| s.contains("[Request interrupted"))) + }) + }) + .unwrap_or(false); + + if let Some(text) = extract_user_message_text(content) { + next.last_user_message = Some(text.clone()); + if next.first_user_message.is_none() && !state.past_head { + next.first_user_message = Some(text); + } + } + } else { + next.is_interrupted = false; + } + next +} + +/// `summaryReducer.skip` — bounded cold start keeps head's session start + +/// first user message, clears every "latest" field. +fn skip(state: SummaryState) -> SummaryState { + SummaryState { + seen_first_line: true, + past_head: true, + session_start_ms: state.session_start_ms, + first_user_message: state.first_user_message, + is_interrupted: false, + ..Default::default() + } +} + +/// `parseSessionStart` — first entry's `timestamp`, or `snapshot.timestamp` +/// for file-history-snapshot openers. +fn parse_session_start(v: &serde_json::Value) -> Option { + let entry = v.as_object()?; + let raw = entry + .get("timestamp") + .and_then(|v| v.as_str()) + .or_else(|| { + entry + .get("snapshot") + .and_then(|s| s.get("timestamp")) + .and_then(|v| v.as_str()) + })?; + parse_iso_ms(raw) +} + +/// `extractUserMessageText` — string | first text block → trim → +/// command-message / skill-args expansion → noise filter. +fn extract_user_message_text(content: Option<&serde_json::Value>) -> Option { + let content = content?; + let raw: String = if let Some(s) = content.as_str() { + s.trim().to_string() + } else { + let blocks = content.as_array()?; + let mut found = None; + for block in blocks { + if block.get("type").and_then(|v| v.as_str()) == Some("text") { + if let Some(t) = block.get("text").and_then(|v| v.as_str()) { + if !t.trim().is_empty() { + found = Some(t.trim().to_string()); + break; + } + } + } + } + found? + }; + if raw.is_empty() { + return None; + } + if raw.starts_with("") { + return parse_command_message(&raw); + } + if raw.starts_with("Base directory for this skill:") { + return skill_arguments(&raw); + } + if is_noise_message(&raw) { + return None; + } + Some(raw) +} + +/// `/\nARGUMENTS:\s*(.+)/` — text after the first ARGUMENTS marker, trimmed. +fn skill_arguments(raw: &str) -> Option { + let idx = raw.find("\nARGUMENTS:")?; + let rest = raw[idx + "\nARGUMENTS:".len()..].trim_start(); + // `.+` stops at the next newline. + let line = rest.split('\n').next().unwrap_or("").trim(); + if line.is_empty() { + None + } else { + Some(line.to_string()) + } +} + +/// `` → "/name args" via / tags. +fn parse_command_message(raw: &str) -> Option { + let tag = |name: &str| -> Option { + let open = format!("<{name}>"); + let close = format!(""); + let start = raw.find(&open)? + open.len(); + // [^<]+ — content runs to the first '<'. + let end = raw[start..].find('<').map(|i| start + i)?; + let inner = &raw[start..end]; + let _ = close; + Some(inner.to_string()) + }; + let name = tag("command-name")?.trim().to_string(); + if name.is_empty() { + return None; + } + let args = tag("command-args").map(|a| a.trim().to_string()); + match args { + Some(a) if !a.is_empty() => Some(format!("{name} {a}")), + _ => Some(name), + } +} + +/// `isNoiseMessage` — non-meaningful user intents. +fn is_noise_message(text: &str) -> bool { + text.starts_with("[Request interrupted") + || text == "Tool loaded." + || text.starts_with("This session is being continued") +} + +fn reduce_line(state: SummaryState, line: &str) -> SummaryState { + if !line.bytes().any(|b| !b.is_ascii_whitespace()) { + return state; + } + let parsed = serde_json::from_str::(line).ok(); + reduce(state, parsed.as_ref()) +} + +/// Tentative apply of an unterminated final line — only if it parses as JSON. +fn reduce_tentative(state: SummaryState, line: &[u8]) -> SummaryState { + let Ok(s) = std::str::from_utf8(line) else { + return state; + }; + if !s.bytes().any(|b| !b.is_ascii_whitespace()) { + return state; + } + match serde_json::from_str::(s) { + Ok(v) => reduce(state, Some(&v)), + Err(_) => state, + } +} + +/// Fold all `\n`-terminated lines in `bytes`; returns (state, index past the +/// last newline folded) so the caller can tentatively apply the remainder. +fn fold_lines(mut state: SummaryState, bytes: &[u8], skip_prefix: bool) -> (SummaryState, usize) { + let mut i = 0usize; + let mut discarding = skip_prefix; + while i < bytes.len() { + let Some(nl) = bytes[i..].iter().position(|&b| b == b'\n').map(|p| i + p) else { + break; + }; + if discarding { + discarding = false; + } else if let Ok(s) = std::str::from_utf8(&bytes[i..nl]) { + state = reduce_line(state, s); + } else { + state = reduce(state, None); + } + i = nl + 1; + } + (state, i) +} + +/// Byte offset just past the first '\n' at-or-after `from`; `from` itself is +/// discarded (the `tailStart - 1` trick: if that byte IS the newline the line +/// starting at tailStart still folds). +fn skip_to_newline(bytes: &[u8], from: usize) -> usize { + bytes[from..] + .iter() + .position(|&b| b == b'\n') + .map(|p| from + p + 1) + .unwrap_or(bytes.len()) +} + +/// `readSessionIncremental` — full file ≤5MiB; otherwise head 1MiB + skip + +/// lines starting at/after `size-4MiB`. Returns None when unreadable. +pub fn read_session(path: &str) -> Option { + let meta = std::fs::metadata(path).ok()?; + if !meta.is_file() { + return None; + } + let bytes = std::fs::read(path).ok()?; + let size = meta.len() as usize; + + let state = if size > HEAD_BYTES + TAIL_BYTES { + // Head: complete lines fully inside [0, HEAD_BYTES). + let head_end = bytes[..HEAD_BYTES] + .iter() + .rposition(|&b| b == b'\n') + .map(|p| p + 1) + .unwrap_or(0); + let (head_state, _) = fold_lines(initial(), &bytes[..head_end], false); + let skipped = skip(head_state); + // Tail: discard from tailStart-1 through its newline, then fold the + // remaining complete lines. + let tail_start = size - TAIL_BYTES; + let from = skip_to_newline(&bytes, tail_start - 1); + let (tail_state, consumed) = fold_lines(skipped, &bytes[from..], false); + reduce_tentative(tail_state, &bytes[from + consumed..]) + } else { + let (s, consumed) = fold_lines(initial(), &bytes, false); + reduce_tentative(s, &bytes[consumed..]) + }; + Some(state) +} + +/// `determineStatus` — conversation-entry → AgentStatus mapping. +pub fn determine_status(state: &SummaryState) -> &'static str { + match state.last_entry_type.as_deref() { + Some("user") => { + if state.is_interrupted { + "waiting" + } else { + "running" + } + } + Some("progress" | "thinking") => "running", + Some("assistant") => "waiting", + Some("system") => "idle", + _ => "unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write_tmp(lines: &[&str]) -> String { + static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "devkit-harness-test-{}-{}.jsonl", + std::process::id(), + N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let mut f = std::fs::File::create(&path).unwrap(); + for l in lines { + writeln!(f, "{l}").unwrap(); + } + path.to_string_lossy().into_owned() + } + + #[test] + fn folds_user_assistant_into_waiting() { + let p = write_tmp(&[ + r#"{"type":"user","timestamp":"2025-10-09T00:00:00.000Z","cwd":"/a","message":{"content":"fix the bug"}}"#, + r#"{"type":"assistant","timestamp":"2025-10-09T00:01:00.000Z","message":{"content":[{"type":"text","text":"done"}]}}"#, + ]); + let s = read_session(&p).unwrap(); + assert_eq!(determine_status(&s), "waiting"); + assert_eq!(s.last_active_ms, Some(1759968060000)); + assert_eq!(s.last_user_message.as_deref(), Some("fix the bug")); + assert_eq!(s.first_user_message.as_deref(), Some("fix the bug")); + assert_eq!(s.last_cwd.as_deref(), Some("/a")); + assert_eq!(s.session_start_ms, Some(1759968000000)); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn ui_state_entries_do_not_override_status() { + let p = write_tmp(&[ + r#"{"type":"user","timestamp":"2025-10-09T00:00:00.000Z","message":{"content":"go"}}"#, + r#"{"type":"permission-mode","timestamp":"2025-10-09T00:00:30.000Z"}"#, + ]); + let s = read_session(&p).unwrap(); + assert_eq!(determine_status(&s), "running"); + assert_eq!(s.last_active_ms, Some(1759968030000)); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn interrupted_user_maps_to_waiting() { + let p = write_tmp(&[ + r#"{"type":"user","timestamp":"2025-10-09T00:00:00.000Z","message":{"content":[{"type":"text","text":"[Request interrupted by user]"}]}}"#, + ]); + let s = read_session(&p).unwrap(); + assert_eq!(determine_status(&s), "waiting"); + // The noise message is filtered from summary fields. + assert_eq!(s.last_user_message, None); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn noise_and_command_messages() { + let p = write_tmp(&[ + r#"{"type":"user","timestamp":"2025-10-09T00:00:00.000Z","message":{"content":"Tool loaded."}}"#, + r#"{"type":"user","timestamp":"2025-10-09T00:00:10.000Z","message":{"content":"commit--amend"}}"#, + ]); + let s = read_session(&p).unwrap(); + assert_eq!(s.last_user_message.as_deref(), Some("commit --amend")); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn malformed_and_blank_lines_fold_as_undefined() { + let p = write_tmp(&[ + "not json", + "", + r#"{"type":"system","timestamp":"2025-10-09T00:00:00.000Z"}"#, + ]); + let s = read_session(&p).unwrap(); + // First line is invalid JSON → seenFirstLine consumed, no sessionStart. + assert_eq!(s.session_start_ms, None); + assert_eq!(determine_status(&s), "idle"); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn unterminated_last_line_applies_tentatively() { + let path = std::env::temp_dir().join(format!( + "devkit-harness-tentative-{}.jsonl", + std::process::id() + )); + std::fs::write( + &path, + "{\"type\":\"user\",\"timestamp\":\"2025-10-09T00:00:00.000Z\",\"message\":{\"content\":\"x\"}}\n{\"type\":\"assistant\",\"timestamp\":\"2025-10-09T00:02:00.000Z\"}", + ) + .unwrap(); + let s = read_session(path.to_str().unwrap()).unwrap(); + assert_eq!(determine_status(&s), "waiting"); + std::fs::remove_file(&path).ok(); + } +} diff --git a/rust/crates/devkit-harness/src/codex/locator.rs b/rust/crates/devkit-harness/src/codex/locator.rs new file mode 100644 index 000000000..d54376dad --- /dev/null +++ b/rust/crates/devkit-harness/src/codex/locator.rs @@ -0,0 +1,479 @@ +//! CodexSessionLocator port — resume/direct matching, uuidv7 date-dir +//! discovery, `session_meta` head parsing, legacy greedy matching, and the +//! 30s unmatched-process negative cache. + +use crate::shared::{self, SessionFile}; +use devkit_core::discover::AgentProc; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +const PROCESS_START_DAY_WINDOW_DAYS: i64 = 1; +/// Upper bound of bytes read from a session file to find its `session_meta`. +const SESSION_META_HEAD_MAX_BYTES: usize = 64 * 1024; +/// Unmatched processes are re-scanned at least this often. +const UNMATCHED_PROCESS_RECHECK_MS: i64 = 30_000; + +pub struct DirectMatch<'a> { + pub proc: &'a AgentProc, + pub session_file: SessionFile, +} + +pub struct Matches<'a> { + pub direct: Vec>, + pub legacy: Vec>, + /// Kept for parity bookkeeping — caller uses `legacy` + `fallback`. + pub sessions: Vec, + pub fallback: Vec<&'a AgentProc>, +} + +struct UnmatchedEntry { + checked_at_ms: i64, + date_dir_signature: String, +} + +pub struct CodexSessionLocator { + sessions_dir: PathBuf, + /// `(pid, startTime)` → last failed scan, keyed to date-dir mtimes. + unmatched: std::sync::Mutex>, +} + +impl CodexSessionLocator { + pub fn new(home: &Path) -> Self { + Self { + sessions_dir: home.join(".codex").join("sessions"), + unmatched: std::sync::Mutex::new(HashMap::new()), + } + } + + pub fn match_running<'a>(&self, processes: &[&'a AgentProc], now_ms: i64) -> Matches<'a> { + let mut direct = Vec::new(); + let mut fallback: Vec<&AgentProc> = Vec::new(); + + for &proc in processes { + match extract_resume_session_id(proc.command.as_deref().unwrap_or("")) { + Some(id) => match self.find_session_file_by_id(&id) { + Some(sf) => direct.push(DirectMatch { + proc, + session_file: sf, + }), + None => fallback.push(proc), + }, + None => fallback.push(proc), + } + } + + let signatures: HashMap = fallback + .iter() + .map(|p| (p.pid, self.date_dir_signature(p, now_ms))) + .collect(); + let mut unmatched = self.unmatched.lock().unwrap(); + let candidates: Vec<&AgentProc> = fallback + .iter() + .copied() + .filter(|p| { + let key = process_key(p, now_ms); + !matches!(unmatched.get(&key), Some(e) + if now_ms - e.checked_at_ms < UNMATCHED_PROCESS_RECHECK_MS + && e.date_dir_signature + == *signatures.get(&p.pid).unwrap_or(&String::new())) + }) + .collect(); + + let sessions = self.discover_live_sessions(&candidates, now_ms); + let legacy = if !candidates.is_empty() && !sessions.is_empty() { + shared::match_processes_to_sessions(&candidates, &sessions) + } else { + Vec::new() + }; + + // updateUnmatchedProcesses + let live_keys: HashSet = fallback + .iter() + .map(|p| process_key(p, now_ms)) + .collect(); + unmatched.retain(|k, _| live_keys.contains(k)); + let matched_pids: HashSet = legacy.iter().map(|m| m.proc.pid).collect(); + for &p in &candidates { + let key = process_key(p, now_ms); + if matched_pids.contains(&p.pid) { + unmatched.remove(&key); + } else { + unmatched.insert( + key, + UnmatchedEntry { + checked_at_ms: now_ms, + date_dir_signature: signatures.get(&p.pid).cloned().unwrap_or_default(), + }, + ); + } + } + drop(unmatched); + + Matches { + direct, + legacy, + sessions, + fallback, + } + } + + /// `findSessionFileById` — candidate files narrowed by the uuidv7 date + /// window when the id parses as v7, else all session files. + pub fn find_session_file_by_id(&self, session_id: &str) -> Option { + for file_path in self.candidate_session_files(session_id) { + let Some(name) = file_path.file_name().map(|n| n.to_string_lossy()) else { + continue; + }; + if !name.contains(session_id) { + continue; + } + let Some(meta) = self.read_session_meta(&file_path) else { + continue; + }; + if meta.id.as_deref() != Some(session_id) { + continue; + } + let stat_ms = shared::birthtime_ms(&file_path.to_string_lossy()); + return Some(SessionFile { + session_id: session_id.to_string(), + file_path: file_path.to_string_lossy().into_owned(), + project_dir: file_path + .parent() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(), + birthtime_ms: meta.timestamp_ms.or(stat_ms).unwrap_or(0), + resolved_cwd: meta.cwd, + }); + } + None + } + + fn candidate_session_files(&self, session_id: &str) -> Vec { + match parse_uuid_v7_ms(session_id) { + Some(ms) => { + let mut dirs = Vec::new(); + for offset in -PROCESS_START_DAY_WINDOW_DAYS..=PROCESS_START_DAY_WINDOW_DAYS { + let key = shared::local_day_key(ms + offset * 86_400_000); + let dir = self.sessions_dir.join(&key); + if dir.is_dir() { + dirs.push(dir); + } + } + self.collect_jsonl_in_dirs(&dirs) + } + None => self.collect_all_session_files(), + } + } + + /// `discoverLiveSessions` — day-window dirs for each candidate process, + /// session files stat'ed, `session_meta` overlaid onto cwd+birthtime. + fn discover_live_sessions(&self, processes: &[&AgentProc], now_ms: i64) -> Vec { + if processes.is_empty() || !self.sessions_dir.is_dir() { + return Vec::new(); + } + let mut day_keys: HashSet = HashSet::new(); + for p in processes { + let start = p.start_time_ms.unwrap_or(now_ms); + for offset in -PROCESS_START_DAY_WINDOW_DAYS..=PROCESS_START_DAY_WINDOW_DAYS { + day_keys.insert(shared::local_day_key(start + offset * 86_400_000)); + } + } + let dirs: Vec = day_keys + .iter() + .map(|k| self.sessions_dir.join(k)) + .filter(|d| d.is_dir()) + .collect(); + + let mut files = batch_birthtimes(&dirs); + for f in &mut files { + if let Some(meta) = self.read_session_meta(Path::new(&f.file_path)) { + f.resolved_cwd = meta.cwd; + if let Some(ts) = meta.timestamp_ms { + f.birthtime_ms = ts; + } + } + } + files + } + + /// First line of a session file parsed as `session_meta`, with the + /// truncated-head regex fallback (`parseTruncatedSessionMeta`). + fn read_session_meta(&self, path: &Path) -> Option { + let (text, _bytes, _complete, truncated) = + shared::read_file_head(&path.to_string_lossy(), SESSION_META_HEAD_MAX_BYTES)?; + let line = text.trim().to_string(); + if line.is_empty() { + return None; + } + match serde_json::from_str::(&line) { + Ok(v) => { + if v.get("type").and_then(Value::as_str) != Some("session_meta") { + return None; + } + let p = v.get("payload")?; + Some(SessionMetaHead { + id: p.get("id").and_then(Value::as_str).map(str::to_string), + cwd: p + .get("cwd") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + timestamp_ms: p + .get("timestamp") + .and_then(shared::parse_timestamp_ms), + }) + } + Err(_) if truncated => parse_truncated_session_meta(&line), + Err(_) => None, + } + } + + /// Day-window dirs' mtimes — the signature that invalidates the negative + /// cache when session files appear/disappear. + fn date_dir_signature(&self, proc: &AgentProc, now_ms: i64) -> String { + let start = proc.start_time_ms.unwrap_or(now_ms); + (-PROCESS_START_DAY_WINDOW_DAYS..=PROCESS_START_DAY_WINDOW_DAYS) + .map(|offset| { + let key = shared::local_day_key(start + offset * 86_400_000); + let dir = self.sessions_dir.join(&key); + let mtime = std::fs::metadata(&dir) + .ok() + .filter(|m| m.is_dir()) + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .map(|ms| ms.to_string()) + .unwrap_or_else(|| "-".into()); + format!("{key}={mtime}") + }) + .collect::>() + .join("|") + } + + fn collect_jsonl_in_dirs(&self, dirs: &[PathBuf]) -> Vec { + let mut out = Vec::new(); + for dir in dirs { + for name in shared::list_dir_names(dir) { + if name.ends_with(".jsonl") { + out.push(dir.join(&name)); + } + } + } + out + } + + fn collect_all_session_files(&self) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![self.sessions_dir.clone()]; + while let Some(dir) = stack.pop() { + for name in shared::list_dir_names(&dir) { + let p = dir.join(&name); + if p.is_dir() { + stack.push(p); + } else if p.to_string_lossy().ends_with(".jsonl") { + out.push(p); + } + } + } + out + } +} + +struct SessionMetaHead { + id: Option, + cwd: String, + timestamp_ms: Option, +} + +/// `batchGetSessionFileBirthtimes` — enumerate `.jsonl` files, stat birthtime, +/// sessionId = filename minus extension. +fn batch_birthtimes(dirs: &[PathBuf]) -> Vec { + let mut out = Vec::new(); + for dir in dirs { + for name in shared::list_dir_names(dir) { + if !name.ends_with(".jsonl") { + continue; + } + let file_path = dir.join(&name).to_string_lossy().into_owned(); + let Some(bt) = shared::birthtime_ms(&file_path) else { + continue; + }; + out.push(SessionFile { + session_id: name.trim_end_matches(".jsonl").to_string(), + file_path, + project_dir: dir.to_string_lossy().into_owned(), + birthtime_ms: bt, + resolved_cwd: String::new(), + }); + } + } + out +} + +fn process_key(proc: &AgentProc, now_ms: i64) -> String { + format!("{}:{}", proc.pid, proc.start_time_ms.unwrap_or(now_ms)) +} + +/// `(?:^|\s)resume\s+([0-9a-f-]{36})(?:\s|$)` — case-insensitive. +fn extract_resume_session_id(command: &str) -> Option { + let bytes = command.as_bytes(); + let mut i = 0usize; + while i + 6 <= bytes.len() { + if i + 6 <= bytes.len() + && command[i..i + 6].eq_ignore_ascii_case("resume") + && (i == 0 || bytes[i - 1].is_ascii_whitespace()) + && i + 6 < bytes.len() + && bytes[i + 6].is_ascii_whitespace() + { + let mut j = i + 6; + while j < bytes.len() && bytes[j].is_ascii_whitespace() { + j += 1; + } + let start = j; + while j < bytes.len() + && (bytes[j].is_ascii_hexdigit() || bytes[j] == b'-') + && j - start < 36 + { + j += 1; + } + if j - start == 36 && (j == bytes.len() || bytes[j].is_ascii_whitespace()) { + return Some(command[start..j].to_string()); + } + } + i += 1; + } + None +} + +/// uuidv7 → embedded epoch ms (first 48 bits), or None. +pub fn parse_uuid_v7_ms(session_id: &str) -> Option { + let b = session_id.as_bytes(); + if b.len() != 36 || b[8] != b'-' || b[13] != b'-' || b[18] != b'-' || b[23] != b'-' { + return None; + } + if b[14] != b'7' { + return None; + } + let hex: String = session_id[..8].chars().chain(session_id[9..13].chars()).collect(); + let ms = i64::from_str_radix(&hex, 16).ok()?; + if ms <= 0 { + return None; + } + Some(ms) +} + +/// `parseTruncatedSessionMeta` — id/cwd/timestamp precede the bulky payload +/// fields, so they are pulled from the payload prefix of an over-long line. +fn parse_truncated_session_meta(line: &str) -> Option { + if !line.contains("\"type\":\"session_meta\"") + && !line.contains("\"type\": \"session_meta\"") + { + return None; + } + let payload_start = find_payload_start(line)?; + let payload = &line[payload_start..]; + Some(SessionMetaHead { + id: extract_json_string_field(payload, "id"), + cwd: extract_json_string_field(payload, "cwd").unwrap_or_default(), + timestamp_ms: extract_json_string_field(payload, "timestamp") + .and_then(|s| shared::parse_timestamp_ms(&Value::String(s))), + }) +} + +fn find_payload_start(line: &str) -> Option { + let needle = "\"payload\""; + let mut idx = line.find(needle)? + needle.len(); + let bytes = line.as_bytes(); + while idx < bytes.len() && bytes[idx].is_ascii_whitespace() { + idx += 1; + } + if idx < bytes.len() && bytes[idx] == b':' { + idx += 1; + } + while idx < bytes.len() && bytes[idx].is_ascii_whitespace() { + idx += 1; + } + if idx < bytes.len() && bytes[idx] == b'{' { + Some(idx) + } else { + None + } +} + +/// `extractJsonStringField` — first `"field": ""` in a fragment. +pub fn extract_json_string_field(fragment: &str, field: &str) -> Option { + let needle = format!("\"{field}\""); + let mut idx = fragment.find(&needle)? + needle.len(); + let bytes = fragment.as_bytes(); + while idx < bytes.len() && bytes[idx].is_ascii_whitespace() { + idx += 1; + } + if idx >= bytes.len() || bytes[idx] != b':' { + return None; + } + idx += 1; + while idx < bytes.len() && bytes[idx].is_ascii_whitespace() { + idx += 1; + } + if idx >= bytes.len() || bytes[idx] != b'"' { + return None; + } + // Find the closing quote honoring backslash escapes, then JSON-parse. + let mut end = idx + 1; + while end < bytes.len() { + match bytes[end] { + b'\\' => end += 2, + b'"' => break, + _ => end += 1, + } + } + if end >= bytes.len() { + return None; + } + serde_json::from_str::(&fragment[idx..=end]) + .ok()? + .as_str() + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resume_id_extracted() { + assert_eq!( + extract_resume_session_id("codex resume 0199abcd-1234-7bcd-8def-0123456789ab"), + Some("0199abcd-1234-7bcd-8def-0123456789ab".into()) + ); + assert_eq!(extract_resume_session_id("codex"), None); + assert_eq!(extract_resume_session_id("codex resumed abc"), None); + } + + #[test] + fn uuid_v7_parses_date() { + // 0x0199ab12 = 2025-10-08-ish + let ms = parse_uuid_v7_ms("0199ab12-3456-7bcd-8def-0123456789ab").unwrap(); + assert_eq!(ms, 0x0199ab123456); + assert_eq!(parse_uuid_v7_ms("0199ab12-3456-6bcd-8def-0123456789ab"), None); + assert_eq!(parse_uuid_v7_ms("not-a-uuid"), None); + } + + #[test] + fn truncated_meta_extracts_prefix_fields() { + let line = r#"{"timestamp":"x","type":"session_meta","payload":{"id":"abc","timestamp":"2026-10-08T10:00:00Z","cwd":"/p","instructions":"#; + let meta = parse_truncated_session_meta(line).unwrap(); + assert_eq!(meta.id.as_deref(), Some("abc")); + assert_eq!(meta.cwd, "/p"); + } + + #[test] + fn json_string_field_unescapes() { + assert_eq!( + extract_json_string_field(r#"{"id":"a\"b"}"#, "id"), + Some("a\"b".into()) + ); + assert_eq!(extract_json_string_field(r#"{"id":5}"#, "id"), None); + } +} diff --git a/rust/crates/devkit-harness/src/codex/mod.rs b/rust/crates/devkit-harness/src/codex/mod.rs new file mode 100644 index 000000000..33fe78d7d --- /dev/null +++ b/rust/crates/devkit-harness/src/codex/mod.rs @@ -0,0 +1,449 @@ +//! CodexAdapter port — helper-subcommand filtering, session mapping file, +//! in-memory registry cache, locator matching, process-only fallback. + +mod locator; +mod parser; + +use crate::shared; +use crate::{HarnessAdapter, SweepContext}; +use devkit_core::agent::EnrichedAgent; +use devkit_core::discover::AgentProc; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// Codex subcommands that never own a local agent session (app-server, +/// sandbox, MCP/exec servers, short-lived management commands). +const CODEX_HELPER_SUBCOMMANDS: &[&str] = &[ + "agents", + "app", + "app-server", + "apply", + "a", + "archive", + "cloud", + "cloud-tasks", + "completion", + "debug", + "delete", + "doctor", + "exec-server", + "features", + "help", + "login", + "logout", + "mcp", + "mcp-server", + "migrate-rollouts", + "plugin", + "queue", + "remote-control", + "responses-api-proxy", + "sandbox", + "stdio-to-uds", + "unarchive", + "update", +]; + +/// All root subcommand names — a variadic flag's values stop at these. +const CODEX_SUBCOMMANDS: &[&str] = &[ + "agents", + "app", + "app-server", + "apply", + "a", + "archive", + "cloud", + "cloud-tasks", + "completion", + "debug", + "delete", + "doctor", + "exec-server", + "features", + "help", + "login", + "logout", + "mcp", + "mcp-server", + "migrate-rollouts", + "plugin", + "queue", + "remote-control", + "responses-api-proxy", + "sandbox", + "stdio-to-uds", + "unarchive", + "update", + "e", + "exec", + "execpolicy", + "fork", + "resume", + "review", + "tcp-tunnel", +]; + +/// Global flags consuming the following argument as their value. +const CODEX_VALUE_FLAGS: &[&str] = &[ + "-c", + "--config", + "-m", + "--model", + "-p", + "--profile", + "-s", + "--sandbox", + "-a", + "--ask-for-approval", + "-C", + "--cd", + "--add-dir", + "--enable", + "--disable", + "--local-provider", + "--remote", + "--remote-auth-token-env", +]; + +/// Global flags taking one or more values (clap `num_args = 1..`). +const CODEX_VARIADIC_VALUE_FLAGS: &[&str] = &["-i", "--image"]; + +const CODEX_APP_SERVER_DAEMON_DIR: &str = "app-server-daemon"; + +fn is_helper_command(command: &str) -> bool { + let trimmed = command.trim(); + let executable = shared::executable_path(trimmed); + let args: Vec<&str> = trimmed[executable.len()..] + .split_whitespace() + .collect(); + if executable + .replace('\\', "/") + .split('/') + .any(|part| part == CODEX_APP_SERVER_DAEMON_DIR) + { + return true; + } + match first_positional_argument(&args) { + Some(sub) => CODEX_HELPER_SUBCOMMANDS.contains(&sub.as_str()), + None => false, + } +} + +/// First bare positional arg, honoring clap's flag-value consumption rules. +fn first_positional_argument(args: &[&str]) -> Option { + let mut index = 0; + while index < args.len() { + let arg = args[index]; + if arg == "--" { + return None; + } + if !arg.starts_with('-') { + return Some(arg.to_string()); + } + if arg.contains('=') { + index += 1; + continue; + } + if CODEX_VALUE_FLAGS.contains(&arg) { + index += 2; + } else if CODEX_VARIADIC_VALUE_FLAGS.contains(&arg) { + index += 1; + while index < args.len() && is_variadic_flag_value(args[index]) { + index += 1; + } + } else { + index += 1; + } + } + None +} + +fn is_variadic_flag_value(arg: &str) -> bool { + !arg.starts_with('-') && !CODEX_SUBCOMMANDS.contains(&arg) +} + +/// `~/.codex/ai-devkit/sessions.json` — pid→session filePath written by the +/// CLI when it launches managed codex sessions. +struct SessionMapping { + mapping_path: PathBuf, + sessions_dir: PathBuf, +} + +impl SessionMapping { + fn match_processes<'a>(&self, processes: &[&'a AgentProc]) -> Vec<(&'a AgentProc, String)> { + let mapping = self.read(); + if mapping.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + for &proc in processes { + let Some(file_path) = mapping.get(&proc.pid) else { + continue; + }; + if !self.is_trusted(file_path) || !Path::new(file_path).exists() { + continue; + } + out.push((proc, file_path.clone())); + } + out + } + + fn read(&self) -> HashMap { + let Ok(content) = std::fs::read_to_string(&self.mapping_path) else { + return HashMap::new(); + }; + let Ok(Value::Object(obj)) = serde_json::from_str::(&content) else { + return HashMap::new(); + }; + obj.iter() + .filter_map(|(k, v)| { + let pid = to_pid_json(k)?; + v.as_str().filter(|s| !s.is_empty()).map(|s| (pid, s.to_string())) + }) + .collect() + } + + /// Paths must resolve under the sessions dir (path.resolve semantics). + fn is_trusted(&self, file_path: &str) -> bool { + let root = normalize_path(&self.sessions_dir); + let resolved = normalize_path(Path::new(file_path)); + resolved == root || resolved.starts_with(&format!("{root}/")) + } +} + +fn to_pid_json(key: &str) -> Option { + if key.is_empty() || !key.chars().all(|c| c.is_ascii_digit()) { + return None; + } + key.parse::().ok().filter(|p| *p > 0) +} + +/// path.resolve equivalence for the trust check (lexical normalize). +fn normalize_path(p: &Path) -> String { + let mut out: Vec = Vec::new(); + for comp in p.components() { + match comp { + std::path::Component::RootDir => out.clear(), + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::Normal(s) => out.push(s.to_string_lossy().into_owned()), + std::path::Component::Prefix(_) => {} + } + } + format!("/{}", out.join("/")) +} + +pub struct CodexAdapter { + home: PathBuf, + sessions_dir: PathBuf, + locator: locator::CodexSessionLocator, +} + +impl CodexAdapter { + pub fn new(home: &Path) -> Self { + Self { + home: home.to_path_buf(), + sessions_dir: home.join(".codex").join("sessions"), + locator: locator::CodexSessionLocator::new(home), + } + } + + fn map_session( + &self, + session: &parser::Session, + proc: &AgentProc, + file_path: &str, + now_ms: i64, + ) -> EnrichedAgent { + let project = if session.project_path.is_empty() { + proc.cwd.clone().unwrap_or_default() + } else { + session.project_path.clone() + }; + EnrichedAgent { + name: shared::generate_agent_name(&project, proc.pid), + agent_type: "codex".into(), + status: parser::determine_status(session, now_ms).into(), + summary: session.summary.clone(), + pid: proc.pid as u64, + project_path: project, + session_id: session.session_id.clone(), + last_active: shared::iso_utc(session.last_active_ms), + pinned: None, + session_file_path: Some(file_path.to_string()), + } + } +} + +impl HarnessAdapter for CodexAdapter { + fn type_id(&self) -> &'static str { + "codex" + } + + fn can_handle(&self, proc: &AgentProc) -> bool { + let cmd = proc.command.as_deref().unwrap_or(""); + shared::matches_executable(cmd, "codex") && !is_helper_command(cmd) + } + + fn detect(&self, ctx: &SweepContext) -> Vec { + let mut seen = HashSet::new(); + let procs: Vec<&AgentProc> = ctx + .processes + .iter() + .filter(|p| self.can_handle(p) && seen.insert(p.pid)) + .collect(); + if procs.is_empty() { + return Vec::new(); + } + + // Mirrors the TS stage chain — each stage's `fallback` feeds the next, + // and the output order is mapping, cache, direct, legacy, then + // process-only rows (direct fallback first, then locator survivors). + let mut agents = Vec::new(); + let mut done_pids: HashSet = HashSet::new(); + // pid→sessionFilePath from `/.ai-devkit/agents.db` — the TS + // adapter reads AgentRegistry here (rows persist across runs). + let registry = shared::registry_session_paths(&self.home, "codex"); + + // 1. sessions.json mapping + let mapping = SessionMapping { + mapping_path: self.home.join(".codex").join("ai-devkit").join("sessions.json"), + sessions_dir: self.sessions_dir.clone(), + }; + let mapped: HashMap = mapping + .match_processes(&procs) + .into_iter() + .map(|(p, f)| (p.pid, f)) + .collect(); + let mut fallback: Vec<&AgentProc> = Vec::new(); + for &proc in &procs { + match mapped.get(&proc.pid) { + Some(file_path) => match parser::read_session(file_path) { + Some(session) => { + agents.push(self.map_session(&session, proc, file_path, ctx.now)); + done_pids.insert(proc.pid); + } + None => fallback.push(proc), + }, + None => fallback.push(proc), + } + } + + // 2. registry cache + let mut cache_fallback: Vec<&AgentProc> = Vec::new(); + for proc in fallback { + let file_path = registry.get(&proc.pid).cloned(); + let session = file_path + .as_deref() + .filter(|p| Path::new(p).exists()) + .and_then(|p| parser::read_session(p).map(|s| (p.to_string(), s))); + match session { + Some((file_path, session)) => { + agents.push(self.map_session(&session, proc, &file_path, ctx.now)); + done_pids.insert(proc.pid); + } + None => cache_fallback.push(proc), + } + } + + // 3. locator: resume/direct, then legacy greedy + let matches = self.locator.match_running(&cache_fallback, ctx.now); + let mut direct_fallback: Vec<&AgentProc> = Vec::new(); + for m in &matches.direct { + match parser::read_session(&m.session_file.file_path) { + Some(session) => { + agents.push(self.map_session( + &session, + m.proc, + &m.session_file.file_path, + ctx.now, + )); + + done_pids.insert(m.proc.pid); + } + None => direct_fallback.push(m.proc), + } + } + for m in &matches.legacy { + let file = &matches.sessions[m.session_idx]; + if let Some(session) = parser::read_session(&file.file_path) { + agents.push(self.map_session(&session, m.proc, &file.file_path, ctx.now)); + + done_pids.insert(m.proc.pid); + } + } + + // 4. process-only: direct parse failures, then locator survivors. + // `matches.fallback` excludes every proc that produced a direct match. + for &proc in direct_fallback.iter().chain(matches.fallback.iter()) { + if done_pids.contains(&proc.pid) { + continue; + } + agents.push(shared::process_only_agent( + "codex", + proc, + "Codex process running", + None, + ctx.now, + )); + done_pids.insert(proc.pid); + } + agents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn helper_subcommands_excluded() { + for cmd in [ + "codex app-server", + "codex mcp-server", + "codex sandbox", + "codex cloud", + "codex login", + "/opt/codex/app-server-daemon/bin", + ] { + assert!(is_helper_command(cmd), "{cmd}"); + } + for cmd in [ + "codex", + "codex resume 0199abcd-1234-7bcd-8def-0123456789ab", + "codex exec do things", + "codex fork", + "codex review", + ] { + assert!(!is_helper_command(cmd), "{cmd}"); + } + } + + #[test] + fn flags_do_not_shadow_subcommand() { + assert!(is_helper_command("codex --model o3 mcp-server")); + assert!(is_helper_command("codex -m o3 --image a.png b.png sandbox")); + assert!(!is_helper_command("codex --model o3 exec hi")); + assert!(!is_helper_command("codex -- foo")); + } + + #[test] + fn variadic_image_consumes_until_subcommand() { + // `sandbox` is a known subcommand → stops variadic consumption. + assert!(is_helper_command("codex --image a.png b.png sandbox")); + // `foo` is not a subcommand → swallowed as image value. + assert!(!is_helper_command("codex --image a.png b.png foo")); + } + + #[test] + fn pid_parsing() { + assert_eq!(to_pid_json("123"), Some(123)); + assert_eq!(to_pid_json("0"), None); + assert_eq!(to_pid_json("-1"), None); + assert_eq!(to_pid_json("abc"), None); + assert_eq!(to_pid_json(""), None); + } +} diff --git a/rust/crates/devkit-harness/src/codex/parser.rs b/rust/crates/devkit-harness/src/codex/parser.rs new file mode 100644 index 000000000..4148768be --- /dev/null +++ b/rust/crates/devkit-harness/src/codex/parser.rs @@ -0,0 +1,345 @@ +//! CodexSessionParser port — bounded head/tail JSONL summary of a rollout +//! file plus status derivation. Conversation APIs stay TS-side. + +use crate::shared; +use serde_json::Value; + +/// Folded session state — mirrors `CodexSummaryState`. +#[derive(Default)] +pub struct SummaryState { + pub seen_first_line: bool, + /// Set only when the first line is a valid `session_meta` entry. + pub meta: Option, + pub last_entry_timestamp: Option, + pub last_payload_type: Option, + /// Truncated text of the last entry with displayable content. + pub summary: Option, +} + +pub struct Meta { + pub id: String, + pub cwd: Option, + pub timestamp: Option, +} + +/// Built `CodexSession` — timestamps as epoch ms for mapper use. +pub struct Session { + pub session_id: String, + pub project_path: String, + pub summary: String, + #[allow(dead_code)] + pub session_start_ms: i64, + pub last_active_ms: i64, + pub last_payload_type: Option, +} + +fn get<'a>(v: &'a Value, key: &str) -> Option<&'a Value> { + v.get(key) +} + +fn payload(entry: &Value) -> Option<&Value> { + entry.get("payload") +} + +/// `normalizedPayloadType` +fn normalized_payload_type(entry: &Value) -> Option { + let p = payload(entry); + let payload_type = p.and_then(|p| get(p, "type")).and_then(Value::as_str); + let entry_type = get(entry, "type").and_then(Value::as_str); + + if entry_type == Some("response_item") && payload_type == Some("message") { + return match p.and_then(|p| get(p, "role")).and_then(Value::as_str) { + Some("assistant") => Some("agent_message".into()), + Some("user") => Some("user_message".into()), + _ => payload_type.map(str::to_string), + }; + } + if entry_type == Some("event_msg") && payload_type == Some("item_completed") { + let item_type = p + .and_then(|p| get(p, "item")) + .and_then(|i| get(i, "type")) + .and_then(Value::as_str); + return match item_type { + Some("AgentMessage") => Some("agent_message".into()), + Some("UserMessage") => Some("user_message".into()), + other => other.or(payload_type).map(str::to_string), + }; + } + payload_type.map(str::to_string) +} + +/// `extractContentText` — string or `[{text}]` blocks → joined text. +fn extract_content_text(content: Option<&Value>) -> String { + match content { + Some(Value::String(s)) => s.trim().to_string(), + Some(Value::Array(parts)) => parts + .iter() + .filter_map(|p| get(p, "text").and_then(Value::as_str)) + .map(str::trim) + .filter(|t| !t.is_empty()) + .collect::>() + .join("\n") + .trim() + .to_string(), + _ => String::new(), + } +} + +/// `toConversationMessage` (verbose=false) — content-bearing entries only. +fn conversation_message(entry: &Value) -> Option<(String, String)> { + if get(entry, "type").and_then(Value::as_str) == Some("session_meta") { + return None; + } + let entry_type = get(entry, "type").and_then(Value::as_str); + let p = payload(entry); + let payload_type = p.and_then(|p| get(p, "type")).and_then(Value::as_str); + + if entry_type == Some("response_item") && payload_type == Some("message") { + let role = match p.and_then(|p| get(p, "role")).and_then(Value::as_str) { + Some("user") => "user", + Some("assistant") => "assistant", + _ => return None, // verbose=false → system dropped + }; + let text = extract_content_text(p.and_then(|p| get(p, "content"))); + if text.is_empty() { + return None; + } + return Some((role.into(), text)); + } + + if entry_type == Some("event_msg") && payload_type == Some("item_completed") { + let item = p.and_then(|p| get(p, "item")); + let role = match item.and_then(|i| get(i, "type")).and_then(Value::as_str) { + Some("AgentMessage") => "assistant", + Some("UserMessage") => "user", + _ => return None, + }; + let text = extract_content_text(item.and_then(|i| get(i, "content"))); + if text.is_empty() { + return None; + } + return Some((role.into(), text)); + } + + let payload_type = payload_type?; + let role = match payload_type { + "user_message" => "user", + "agent_message" | "task_complete" => "assistant", + _ => return None, + }; + let text = p + .and_then(|p| get(p, "message")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|t| !t.is_empty())?; + Some((role.into(), text.to_string())) +} + +/// `extractEntryText` — legacy `payload.message`, else conversation text. +fn extract_entry_text(entry: Option<&Value>) -> String { + let Some(entry) = entry else { return String::new() }; + if let Some(m) = payload(entry) + .and_then(|p| get(p, "message")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|t| !t.is_empty()) + { + return m.to_string(); + } + conversation_message(entry) + .map(|(_, t)| t.trim().to_string()) + .unwrap_or_default() +} + +/// `readSessionIncremental` (cold start) — bounded fold; None when unreadable +/// or when the first line is not a `session_meta` with a payload id. +pub fn read_session(path: &str) -> Option { + let state = shared::fold_jsonl_bounded( + path, + SummaryState::default(), + reduce, + |state| SummaryState { + seen_first_line: true, + meta: state.meta, + ..Default::default() + }, + )?; + to_session(state, || { + std::fs::metadata(path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) + }) +} + +fn reduce(state: SummaryState, value: Option<&Value>) -> SummaryState { + let mut next = SummaryState { + seen_first_line: true, + meta: state.meta, + last_entry_timestamp: state.last_entry_timestamp, + last_payload_type: state.last_payload_type, + summary: state.summary, + }; + let entry = value.filter(|v| v.is_object()); + + if !state.seen_first_line { + if let Some(e) = entry { + if get(e, "type").and_then(Value::as_str) == Some("session_meta") { + if let Some(p) = payload(e) { + if let Some(id) = get(p, "id").and_then(Value::as_str) { + next.meta = Some(Meta { + id: id.to_string(), + cwd: get(p, "cwd").and_then(Value::as_str).map(str::to_string), + timestamp: get(p, "timestamp") + .and_then(Value::as_str) + .map(str::to_string), + }); + } + } + } + } + } + if entry.is_none() || next.meta.is_none() { + return next; + } + let entry = entry.unwrap(); + + if get(entry, "type").and_then(Value::as_str).is_some() { + next.last_entry_timestamp = get(entry, "timestamp") + .and_then(Value::as_str) + .map(str::to_string); + next.last_payload_type = normalized_payload_type(entry); + } + let text = extract_entry_text(Some(entry)); + if !text.is_empty() { + next.summary = Some(shared::truncate(&text, 120)); + } + next +} + +fn to_session(state: SummaryState, file_mtime_ms: impl FnOnce() -> i64) -> Option { + let meta = state.meta?; + let meta_ts = meta + .timestamp + .as_deref() + .map(|s| Value::String(s.to_string())); + let last_entry_ts = state + .last_entry_timestamp + .as_deref() + .map(|s| Value::String(s.to_string())); + let last_active = last_entry_ts + .as_ref() + .and_then(shared::parse_timestamp_ms) + .or_else(|| meta_ts.as_ref().and_then(shared::parse_timestamp_ms)) + .unwrap_or_else(file_mtime_ms); + let session_start = meta_ts + .as_ref() + .and_then(shared::parse_timestamp_ms) + .unwrap_or(last_active); + Some(Session { + session_id: meta.id, + project_path: meta.cwd.unwrap_or_default(), + summary: state.summary.unwrap_or_else(|| "Codex session active".into()), + session_start_ms: session_start, + last_active_ms: last_active, + last_payload_type: state.last_payload_type, + }) +} + +/// `determineStatus` +pub fn determine_status(session: &Session, now_ms: i64) -> &'static str { + if shared::is_idle(session.last_active_ms, now_ms) { + return "idle"; + } + match session.last_payload_type.as_deref() { + Some("agent_message" | "task_complete" | "turn_aborted") => "waiting", + _ => "running", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + fn write_tmp(lines: &[&str]) -> String { + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let path = std::env::temp_dir().join(format!( + "devkit-codex-parser-{}-{n}.jsonl", + std::process::id() + )); + let mut f = std::fs::File::create(&path).unwrap(); + for l in lines { + writeln!(f, "{l}").unwrap(); + } + path.to_string_lossy().into_owned() + } + + fn meta_line(id: &str, cwd: &str, ts: &str) -> String { + format!( + r#"{{"timestamp":"{ts}","type":"session_meta","payload":{{"id":"{id}","cwd":"{cwd}","timestamp":"{ts}"}}}}"# + ) + } + + #[test] + fn session_meta_required() { + let p = write_tmp(&[r#"{"type":"event_msg","payload":{"type":"user_message","message":"hi"}}"#]); + assert!(read_session(&p).is_none()); + } + + #[test] + fn agent_message_tail_maps_waiting() { + let p = write_tmp(&[ + &meta_line("s1", "/proj", "2026-10-08T10:00:00Z"), + r#"{"timestamp":"2026-10-08T10:01:00Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]}}"#, + ]); + let s = read_session(&p).unwrap(); + assert_eq!(s.session_id, "s1"); + assert_eq!(s.summary, "done"); + assert_eq!(determine_status(&s, s.last_active_ms + 60_000), "waiting"); + } + + #[test] + fn running_when_last_payload_is_user_message() { + let p = write_tmp(&[ + &meta_line("s2", "/proj", "2026-10-08T10:00:00Z"), + r#"{"timestamp":"2026-10-08T10:01:00Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"do it"}]}}"#, + ]); + let s = read_session(&p).unwrap(); + assert_eq!(determine_status(&s, s.last_active_ms + 60_000), "running"); + assert_eq!(s.summary, "do it"); + } + + #[test] + fn idle_after_five_minutes() { + let p = write_tmp(&[ + &meta_line("s3", "/proj", "2026-10-08T10:00:00Z"), + r#"{"timestamp":"2026-10-08T10:01:00Z","type":"event_msg","payload":{"type":"task_complete","message":"bye"}}"#, + ]); + let s = read_session(&p).unwrap(); + assert_eq!(determine_status(&s, s.last_active_ms + 6 * 60_000), "idle"); + } + + #[test] + fn legacy_message_field_wins_summary() { + let p = write_tmp(&[ + &meta_line("s4", "/proj", "2026-10-08T10:00:00Z"), + r#"{"timestamp":"2026-10-08T10:01:00Z","type":"event_msg","payload":{"type":"task_started","message":"legacy text"}}"#, + ]); + let s = read_session(&p).unwrap(); + assert_eq!(s.summary, "legacy text"); + } + + #[test] + fn file_without_meta_first_line_returns_none_even_with_later_meta() { + let p = write_tmp(&[ + r#"{"type":"event_msg","payload":{"type":"user_message","message":"hi"}}"#, + &meta_line("s5", "/proj", "2026-10-08T10:00:00Z"), + ]); + assert!(read_session(&p).is_none()); + } +} diff --git a/rust/crates/devkit-harness/src/copilot/locator.rs b/rust/crates/devkit-harness/src/copilot/locator.rs new file mode 100644 index 000000000..2c7f165ae --- /dev/null +++ b/rust/crates/devkit-harness/src/copilot/locator.rs @@ -0,0 +1,177 @@ +//! CopilotSessionLocator port — `inuse..lock` discovery bounded by the +//! mtime of session dirs, with a per-(pid,startTime) known-lock cache the +//! daemon keeps across sweeps (Mutex — adapters are shared `&self`). + +use devkit_core::discover::AgentProc; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +/// Tolerance between process start and lock-dir mtime (`START_TIME_SLACK_MS`). +const START_TIME_SLACK_MS: i64 = 5 * 60 * 1000; + +pub struct CopilotLock { + pub session_dir: PathBuf, + pub session_id: String, + pub pid: i64, +} + +struct KnownLock { + session_dir: PathBuf, + session_id: String, + start_time_ms: i64, +} + +pub struct CopilotSessionLocator { + session_state_dir: PathBuf, + known_locks: Mutex>, +} + +impl CopilotSessionLocator { + pub fn new(home: &Path) -> Self { + Self { + session_state_dir: home.join(".copilot").join("session-state"), + known_locks: Mutex::new(HashMap::new()), + } + } + + /// `discoverActiveLocks(processes)` — re-validate remembered locks with a + /// single stat; scan only dirs touched at/after the earliest unresolved + /// process start minus slack (all dirs when any start is unknown). + pub fn discover_active_locks(&self, processes: &[&AgentProc]) -> Vec { + let start_times: HashMap> = processes + .iter() + .map(|p| (p.pid, p.start_time_ms)) + .collect(); + let mut known = self.known_locks.lock().unwrap(); + known.retain(|pid, k| start_times.get(pid).copied().flatten() == Some(k.start_time_ms)); + + let mut locks = Vec::new(); + let mut unresolved: HashSet = HashSet::new(); + for p in processes { + let pid = p.pid; + let hit = known.get(&pid).is_some_and(|k| { + k.session_dir + .join(lock_file_name(pid)) + .is_file() + }); + if hit { + let k = known.get(&pid).unwrap(); + locks.push(CopilotLock { + session_dir: k.session_dir.clone(), + session_id: k.session_id.clone(), + pid, + }); + continue; + } + known.remove(&pid); + unresolved.insert(pid); + } + if unresolved.is_empty() { + return locks; + } + + let min_mtime_ms = if unresolved + .iter() + .all(|pid| start_times[pid].is_some()) + { + unresolved + .iter() + .filter_map(|pid| start_times[pid]) + .min() + .map(|m| m - START_TIME_SLACK_MS) + } else { + None + }; + + let found = self.scan_locks(min_mtime_ms, |pid| unresolved.contains(&pid)); + let mut counts: HashMap = HashMap::new(); + for lock in &found { + *counts.entry(lock.pid).or_default() += 1; + } + for lock in &found { + if let (Some(start), 1) = (start_times[&lock.pid], counts[&lock.pid]) { + known.insert( + lock.pid, + KnownLock { + session_dir: lock.session_dir.clone(), + session_id: lock.session_id.clone(), + start_time_ms: start, + }, + ); + } + } + + locks.extend(found); + locks + } + + /// `scanLocks` — lock files inside session dirs with mtime >= `min_mtime_ms` + /// (all dirs when None). + fn scan_locks( + &self, + min_mtime_ms: Option, + include_pid: impl Fn(i64) -> bool, + ) -> Vec { + let mut locks = Vec::new(); + for session_id in crate::shared::list_dir_names(&self.session_state_dir) { + let session_dir = self.session_state_dir.join(&session_id); + let Ok(stat) = std::fs::metadata(&session_dir) else { + continue; + }; + if !stat.is_dir() { + continue; + } + if let Some(min) = min_mtime_ms { + let mtime = stat + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(i64::MIN); + if mtime < min { + continue; + } + } + for name in crate::shared::list_dir_names(&session_dir) { + let Some(pid) = lock_pid(&name) else { continue }; + if !include_pid(pid) { + continue; + } + locks.push(CopilotLock { + session_dir: session_dir.clone(), + session_id: session_id.clone(), + pid, + }); + } + } + locks + } +} + +fn lock_file_name(pid: i64) -> String { + format!("inuse.{pid}.lock") +} + +/// `^inuse\.(\d+)\.lock$` +fn lock_pid(name: &str) -> Option { + let mid = name.strip_prefix("inuse.")?.strip_suffix(".lock")?; + if mid.is_empty() || !mid.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + mid.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lock_file_pattern() { + assert_eq!(lock_pid("inuse.42.lock"), Some(42)); + assert_eq!(lock_pid("inuse..lock"), None); + assert_eq!(lock_pid("inuse.4x2.lock"), None); + assert_eq!(lock_pid("inuse.42.lock.bak"), None); + assert_eq!(lock_pid("other.lock"), None); + } +} diff --git a/rust/crates/devkit-harness/src/copilot/mod.rs b/rust/crates/devkit-harness/src/copilot/mod.rs new file mode 100644 index 000000000..f4825269a --- /dev/null +++ b/rust/crates/devkit-harness/src/copilot/mod.rs @@ -0,0 +1,141 @@ +//! CopilotAdapter port — `inuse..lock` session attribution, +//! events.jsonl/workspace.yaml enrichment, wrapper registry naming, +//! process-only fallback. + +mod locator; +mod parser; + +use crate::shared; +use crate::{HarnessAdapter, SweepContext}; +use devkit_core::agent::EnrichedAgent; +use devkit_core::discover::AgentProc; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +pub struct CopilotAdapter { + home: PathBuf, + locator: locator::CopilotSessionLocator, +} + +impl CopilotAdapter { + pub fn new(home: &std::path::Path) -> Self { + Self { + home: home.to_path_buf(), + locator: locator::CopilotSessionLocator::new(home), + } + } + + /// `mapSessionToAgent`. + fn map_session(session: &parser::Session, proc: &AgentProc, now_ms: i64) -> EnrichedAgent { + let project_path = if session.project_path.is_empty() { + proc.cwd.clone().unwrap_or_default() + } else { + session.project_path.clone() + }; + EnrichedAgent { + name: shared::generate_agent_name(&project_path, proc.pid), + agent_type: "copilot".into(), + status: parser::determine_status(session, now_ms).into(), + summary: if session.summary.is_empty() { + "Copilot session active".into() + } else { + session.summary.clone() + }, + pid: proc.pid as u64, + project_path, + session_id: session.session_id.clone(), + last_active: shared::iso_utc(session.last_active_ms), + pinned: None, + session_file_path: Some(session.events_file_path.clone()), + } + } + + /// `applyWrapperRegistryName` — the agent takes its wrapper's registry + /// name when the wrapper's row is for this harness type. + fn apply_wrapper_registry_name( + agent: &mut EnrichedAgent, + proc: &AgentProc, + processes: &[&AgentProc], + registry_by_pid: &HashMap, + ) { + if let Some(wrapper_pid) = shared::find_wrapper_pid(processes, proc) { + if let Some(entry) = registry_by_pid.get(&wrapper_pid) { + if entry.agent_type == "copilot" { + agent.name = entry.name.clone(); + } + } + } + } +} + +impl HarnessAdapter for CopilotAdapter { + fn type_id(&self) -> &'static str { + "copilot" + } + + /// `canHandle` — `matchesExecutable(command, "copilot")`. + fn can_handle(&self, proc: &AgentProc) -> bool { + shared::matches_executable(proc.command.as_deref().unwrap_or(""), "copilot") + } + + fn detect(&self, ctx: &SweepContext) -> Vec { + // `findHarnessProcesses`: processNames pool filter, then canHandle + // deduped by pid (first wins). + let mut seen = HashSet::new(); + let processes: Vec<&AgentProc> = ctx + .processes + .iter() + .filter(|p| { + shared::matches_executable_name(p.command.as_deref().unwrap_or(""), &["copilot"]) + }) + .filter(|p| self.can_handle(p) && seen.insert(p.pid)) + .collect(); + if processes.is_empty() { + return Vec::new(); + } + + let proc_by_pid: HashMap = + processes.iter().map(|p| (p.pid, *p)).collect(); + let registry_by_pid: HashMap = + shared::registry_agent_rows(&self.home) + .into_iter() + .map(|r| (r.pid, r)) + .collect(); + let mut matched_pids = HashSet::new(); + let mut matched_processes: Vec<&AgentProc> = Vec::new(); + let mut agents = Vec::new(); + + for lock in self.locator.discover_active_locks(&processes) { + let Some(&proc) = proc_by_pid.get(&lock.pid) else { + continue; + }; + let Some(session) = + parser::read_session_dir(&lock.session_dir, &lock.session_id, ctx.now) + else { + continue; + }; + let mut agent = Self::map_session(&session, proc, ctx.now); + Self::apply_wrapper_registry_name(&mut agent, proc, &processes, ®istry_by_pid); + agents.push(agent); + matched_pids.insert(proc.pid); + matched_processes.push(proc); + } + + let wrapper_pids = shared::wrapper_pids(&processes, &matched_processes); + for proc in &processes { + if !matched_pids.contains(&proc.pid) && !wrapper_pids.contains(&proc.pid) { + let mut agent = shared::process_only_agent( + "copilot", + proc, + "Copilot process running", + None, + ctx.now, + ); + Self::apply_wrapper_registry_name(&mut agent, proc, &processes, ®istry_by_pid); + agents.push(agent); + } + } + + agents + } +} diff --git a/rust/crates/devkit-harness/src/copilot/parser.rs b/rust/crates/devkit-harness/src/copilot/parser.rs new file mode 100644 index 000000000..5254699ad --- /dev/null +++ b/rust/crates/devkit-harness/src/copilot/parser.rs @@ -0,0 +1,376 @@ +//! CopilotSessionParser port — `events.jsonl` O(1) fold (IncrementalJsonlSummary +//! cold-start semantics via `fold_jsonl_bounded`) plus `workspace.yaml` +//! fallback metadata. Only the `detectAgents` path is ported. + +use crate::shared; +use serde_json::Value; +use std::path::Path; + +/// `CopilotSession` — the fields `detectAgents`/mapper consume +/// (`sessionStart` only feeds the out-of-scope listSessions path). +pub struct Session { + pub session_id: String, + pub project_path: String, + pub summary: String, + pub last_active_ms: i64, + pub last_event_type: Option, + pub events_file_path: String, +} + +/// `CopilotEventState` — O(1) running fold of events.jsonl. +#[derive(Default)] +pub struct EventState { + pub entry_count: u64, + pub past_head: bool, + pub session_id: Option, + pub project_path: Option, + pub session_start_ms: Option, + pub last_active_ms: Option, + pub first_user_message: String, + pub last_text: String, + pub last_event_type: Option, +} + +/// JS truthiness for `a || b` chains and `!value` checks. +fn truthy(v: Option<&Value>) -> bool { + match v { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(Value::Number(n)) => n.as_f64() != Some(0.0), + Some(Value::String(s)) => !s.is_empty(), + Some(_) => true, + } +} + +/// `extractEventText(entry, verbose=false)` — first truthy of +/// content/message/text/result.content/result.detailedContent, string-only. +fn extract_event_text(entry: &Value) -> String { + if !truthy(entry.get("data")) { + return String::new(); + } + let data = entry.get("data").unwrap_or(&Value::Null); + let result = data.get("result"); + let raw = [ + data.get("content"), + data.get("message"), + data.get("text"), + result.and_then(|r| r.get("content")), + result.and_then(|r| r.get("detailedContent")), + ] + .into_iter() + .flatten() + .find(|v| truthy(Some(*v))); + raw.and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// `eventReducer.reduce` — entryCount++ only for parseable lines; non-objects +/// count but read no fields; `session.start` overrides id/cwd/start and skips +/// text extraction. +fn reduce(state: EventState, value: Option<&Value>) -> EventState { + let Some(entry) = value else { return state }; + let mut next = EventState { + entry_count: state.entry_count + 1, + ..state + }; + if !entry.is_object() { + return next; + } + + let timestamp_ms = entry + .get("timestamp") + .and_then(shared::parse_timestamp_ms); + if let Some(t) = timestamp_ms { + next.last_active_ms = Some(t); + } + let entry_type = entry.get("type").and_then(Value::as_str); + if let Some(t) = entry_type.filter(|s| !s.is_empty()) { + next.last_event_type = Some(t.to_string()); + } + + if entry_type == Some("session.start") { + let data = entry.get("data").unwrap_or(&Value::Null); + if let Some(sid) = data.get("sessionId").and_then(Value::as_str) { + if !sid.is_empty() { + next.session_id = Some(sid.to_string()); + } + } + if let Some(cwd) = data + .get("context") + .and_then(|c| c.get("cwd")) + .and_then(Value::as_str) + { + if !cwd.is_empty() { + next.project_path = Some(cwd.to_string()); + } + } + next.session_start_ms = data + .get("startTime") + .and_then(shared::parse_timestamp_ms) + .or(timestamp_ms) + .or(state.session_start_ms); + return next; + } + + let text = extract_event_text(entry); + if text.is_empty() { + return next; + } + if next.first_user_message.is_empty() + && !state.past_head + && entry_type == Some("user.message") + { + next.first_user_message = text.clone(); + } + if matches!(entry_type, Some("user.message") | Some("assistant.message")) { + next.last_text = text; + } + next +} + +/// `eventReducer.skip` — keeps identity fields, clears the latest +/// timestamp/type/text so they come from the tail only. +fn skip(state: EventState) -> EventState { + EventState { + entry_count: state.entry_count, + past_head: true, + session_id: state.session_id, + project_path: state.project_path, + session_start_ms: state.session_start_ms, + last_active_ms: None, + first_user_message: state.first_user_message, + last_text: String::new(), + last_event_type: None, + } +} + +/// `CopilotWorkspace` — naive `key: value` yaml scalars. +#[derive(Default)] +struct Workspace { + id: Option, + cwd: Option, + name: Option, + created_at_ms: Option, + updated_at_ms: Option, +} + +impl Workspace { + fn has_metadata(&self) -> bool { + [ + self.id.as_deref(), + self.cwd.as_deref(), + self.name.as_deref(), + ] + .iter() + .any(|s| s.is_some_and(|s| !s.is_empty())) + || self.created_at_ms.is_some() + || self.updated_at_ms.is_some() + } +} + +/// `readWorkspaceMetadata` — one `key: scalar` per line; '#' comments and +/// lines without ':' skipped; matching quotes stripped. +fn read_workspace_metadata(path: &Path) -> Workspace { + let Ok(content) = std::fs::read_to_string(path) else { + return Workspace::default(); + }; + let mut values = std::collections::HashMap::new(); + for raw in content.split('\n') { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some(idx) = line.find(':') else { continue }; + let key = line[..idx].trim().to_string(); + let mut value = line[idx + 1..].trim().to_string(); + let bytes = value.as_bytes(); + if bytes.len() >= 2 + && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"') + || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')) + { + value = value[1..value.len() - 1].to_string(); + } + values.insert(key, value); + } + let ts = |k: &str| { + values + .get(k) + .and_then(|s| shared::parse_timestamp_ms(&Value::String(s.clone()))) + }; + Workspace { + id: values.get("id").cloned(), + cwd: values.get("cwd").cloned(), + name: values.get("name").cloned(), + created_at_ms: ts("created_at"), + updated_at_ms: ts("updated_at"), + } +} + +fn mtime_ms(path: &Path) -> Option { + let m = std::fs::metadata(path).ok()?.modified().ok()?; + Some(m.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as i64) +} + +/// `readSessionDirIncremental` for a cold read — identical output to the +/// incremental cache's first read: bounded head+tail fold of events.jsonl +/// (None when unreadable → empty state), then `toSession`. +pub fn read_session_dir(session_dir: &Path, fallback_session_id: &str, now_ms: i64) -> Option { + let events_file = session_dir.join("events.jsonl"); + let events = shared::fold_jsonl_bounded( + &events_file.to_string_lossy(), + EventState::default(), + reduce, + skip, + ) + .unwrap_or_default(); + + let workspace = read_workspace_metadata(&session_dir.join("workspace.yaml")); + if events.entry_count == 0 && !workspace.has_metadata() { + return None; + } + + let file_birth = shared::birthtime_ms(&events_file.to_string_lossy()); + let file_mtime = mtime_ms(&events_file); + let default_start = workspace.created_at_ms.or(file_birth).unwrap_or(now_ms); + // `sessionStart` (`events.session_start_ms ?? default_start`) only feeds + // the out-of-scope listSessions path — not computed into the session row. + let last_active_ms = events + .last_active_ms + .or(workspace.updated_at_ms) + .or(file_mtime) + .unwrap_or(default_start); + + let summary_raw = [ + events.first_user_message.as_str(), + events.last_text.as_str(), + workspace.name.as_deref().unwrap_or(""), + ] + .into_iter() + .find(|s| !s.is_empty()) + .unwrap_or("Copilot session active"); + let summary = shared::truncate(summary_raw, 120); + + let session_id = [ + events.session_id.as_deref(), + workspace.id.as_deref(), + Some(fallback_session_id), + ] + .into_iter() + .flatten() + .find(|s| !s.is_empty()) + .unwrap_or_default() + .to_string(); + + let project_path = [events.project_path.as_deref(), workspace.cwd.as_deref()] + .into_iter() + .flatten() + .find(|s| !s.is_empty()) + .unwrap_or_default() + .to_string(); + + Some(Session { + session_id, + project_path, + summary, + last_active_ms, + last_event_type: events.last_event_type, + events_file_path: events_file.to_string_lossy().into_owned(), + }) +} + +/// `determineStatus` — idle(5min) > waiting events > running. +pub fn determine_status(session: &Session, now_ms: i64) -> &'static str { + if shared::is_idle(session.last_active_ms, now_ms) { + return "idle"; + } + if session.last_event_type.as_deref().is_some_and(|t| { + matches!( + t, + "assistant.message" | "assistant.turn_end" | "session.shutdown" | "abort" + ) + }) { + return "waiting"; + } + "running" +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn dir_with(files: &[(&str, &str)]) -> std::path::PathBuf { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "copilot-parser-test-{}-{}", + std::process::id(), + SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for (name, content) in files { + std::fs::write(dir.join(name), content).unwrap(); + } + dir + } + + #[test] + fn reduce_folds_events() { + let dir = dir_with(&[( + "events.jsonl", + concat!( + "{\"type\":\"session.start\",\"data\":{\"sessionId\":\"s1\",\"startTime\":\"2026-10-08T10:00:00.000Z\",\"context\":{\"cwd\":\"/p\"}},\"timestamp\":\"2026-10-08T10:00:00.000Z\"}\n", + "{\"type\":\"user.message\",\"data\":{\"content\":\"hello\"},\"timestamp\":\"2026-10-08T10:01:00.000Z\"}\n", + "{\"type\":\"assistant.message\",\"data\":{\"content\":\"hi\"},\"timestamp\":\"2026-10-08T10:02:00.000Z\"}\n" + ), + )]); + let s = read_session_dir(&dir, "fallback", 0).unwrap(); + assert_eq!(s.session_id, "s1"); + assert_eq!(s.project_path, "/p"); + assert_eq!(s.summary, "hello"); + assert_eq!(s.last_event_type.as_deref(), Some("assistant.message")); + assert_eq!( + determine_status(&s, s.last_active_ms + 60_000), + "waiting" + ); + assert_eq!( + determine_status(&s, s.last_active_ms + 300_001), + "idle" + ); + } + + #[test] + fn workspace_yaml_fallback_when_no_events() { + let dir = dir_with(&[( + "workspace.yaml", + "id: w1\ncwd: /proj\nname: \"my session\"\ncreated_at: 2026-10-08T09:00:00Z\nupdated_at: 2026-10-08T09:05:00Z\n", + )]); + let s = read_session_dir(&dir, "fb", 0).unwrap(); + assert_eq!(s.session_id, "w1"); + assert_eq!(s.project_path, "/proj"); + assert_eq!(s.summary, "my session"); + assert_eq!(s.last_event_type, None); + assert_eq!(determine_status(&s, 0), "running"); + } + + #[test] + fn empty_dir_yields_no_session() { + let dir = dir_with(&[]); + assert!(read_session_dir(&dir, "fb", 0).is_none()); + } + + #[test] + fn extract_text_prefers_content_then_message() { + let e = json!({"data":{"content":" c ","message":"m"}}); + assert_eq!(extract_event_text(&e), "c"); + let e = json!({"data":{"content":"","message":"m"}}); + assert_eq!(extract_event_text(&e), "m"); + let e = json!({"data":{"result":{"detailedContent":"d"}}}); + assert_eq!(extract_event_text(&e), "d"); + let e = json!({"data":{"content":{}}}); + assert_eq!(extract_event_text(&e), ""); + let e = json!({}); + assert_eq!(extract_event_text(&e), ""); + } +} diff --git a/rust/crates/devkit-harness/src/devin/locator.rs b/rust/crates/devkit-harness/src/devin/locator.rs new file mode 100644 index 000000000..5a2b9b545 --- /dev/null +++ b/rust/crates/devkit-harness/src/devin/locator.rs @@ -0,0 +1,135 @@ +//! DevinSessionLocator port — sessions.db path, `session_locks/*.lock` +//! listing, and the by-id / by-directory session queries. + +use rusqlite::Connection; +use std::path::{Path, PathBuf}; + +/// `::` session ref separator. +pub const SESSION_REF_SEP: &str = "::"; +const LOCK_FILE_SUFFIX: &str = ".lock"; + +/// `DevinSession` — detect-path fields; db columns are epoch SECONDS, +/// carried here *1000 (mirroring `toDevinSession`). +pub struct Session { + pub session_id: String, + pub directory: String, + pub title: Option, + pub time_created_ms: i64, + pub last_activity_at_ms: i64, +} + +/// A live `session_locks/.lock` entry. +pub struct SessionLock { + pub session_id: String, + pub pid: i64, +} + +/// `resolveDbPath` — `XDG_DATA_HOME` (empty falls back) or +/// `~/.local/share`, plus `devin/cli/sessions.db`. +pub fn resolve_db_path(home: &Path) -> PathBuf { + let base = std::env::var("XDG_DATA_HOME") + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local").join("share")); + base.join("devin").join("cli").join("sessions.db") +} + +/// `openDb` — readonly; missing/unopenable yields None. +pub fn open_db(db_path: &Path) -> Option { + if !db_path.exists() { + return None; + } + Connection::open_with_flags( + db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .ok() +} + +/// `parseInt(raw.trim(), 10)` semantics — optional sign then a digit +/// prefix; trailing junk is fine (`"123abc"` → 123), a non-digit first +/// char is NaN. +fn parse_int_prefix(raw: &str) -> Option { + let s = raw.trim(); + let (sign, rest) = match s.strip_prefix('-') { + Some(r) => (-1i64, r), + None => (1i64, s.strip_prefix('+').unwrap_or(s)), + }; + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + return None; + } + // Overflow → None; parseInt would give an enormous nonexistent pid + // that never matches the snapshot anyway. + digits.parse::().ok().map(|v| sign * v) +} + +/// `listActiveLocks` — `session_locks/*.lock`; file name minus `.lock` is +/// the session slug, body parses via `parseInt` and must be > 0. Malformed +/// files are skipped; entries come back in (sorted) readdir order. +pub fn list_active_locks(locks_dir: &Path) -> Vec { + let mut locks = Vec::new(); + for name in crate::shared::list_dir_names(locks_dir) { + let Some(session_id) = name.strip_suffix(LOCK_FILE_SUFFIX) else { + continue; + }; + if session_id.is_empty() { + continue; + } + let Ok(raw) = std::fs::read_to_string(locks_dir.join(&name)) else { + continue; + }; + if let Some(pid) = parse_int_prefix(&raw).filter(|p| *p > 0) { + locks.push(SessionLock { + session_id: session_id.to_string(), + pid, + }); + } + } + locks +} + +fn row_to_session(row: &rusqlite::Row) -> rusqlite::Result { + Ok(Session { + session_id: row.get::<_, String>(0)?, + directory: row.get::<_, Option>(1)?.unwrap_or_default(), + title: row.get::<_, Option>(2)?, + // `* 1000` — db timestamps are epoch seconds. NULL → 0, as in + // JS (`null * 1000 === 0`). + time_created_ms: row + .get::<_, Option>(3)? + .unwrap_or(0) + .saturating_mul(1000), + last_activity_at_ms: row + .get::<_, Option>(4)? + .unwrap_or(0) + .saturating_mul(1000), + }) +} + +const SESSION_COLS: &str = "id, working_directory, title, created_at, last_activity_at"; + +/// `findSessionById` — `WHERE id = ? AND hidden = 0`. +pub fn find_session_by_id(db: &Connection, session_id: &str) -> Option { + db.query_row( + &format!("SELECT {SESSION_COLS} FROM sessions WHERE id = ?1 AND hidden = 0"), + [session_id], + row_to_session, + ) + .ok() +} + +/// `findSessionForDirectory` — newest `last_activity_at` row for the dir. +pub fn find_session_for_directory(db: &Connection, directory: &str) -> Option { + db.query_row( + &format!( + "SELECT {SESSION_COLS} FROM sessions + WHERE working_directory = ?1 AND hidden = 0 + ORDER BY last_activity_at DESC LIMIT 1" + ), + [directory], + row_to_session, + ) + .ok() +} diff --git a/rust/crates/devkit-harness/src/devin/mod.rs b/rust/crates/devkit-harness/src/devin/mod.rs new file mode 100644 index 000000000..2149dd4bd --- /dev/null +++ b/rust/crates/devkit-harness/src/devin/mod.rs @@ -0,0 +1,247 @@ +//! DevinAdapter port — `packages/agent-manager/src/harnesses/devin`. +//! +//! `devin` process detection (utility subcommands and `devin acp` backend +//! children excluded) + session attribution via `session_locks/*.lock` +//! (session slug → holder PID; a TUI matches through its backend child's +//! ppid) with `sessions.working_directory` fallback in +//! `~/.local/share/devin/cli/sessions.db`. + +mod locator; +mod parser; + +use crate::shared; +use crate::{HarnessAdapter, SweepContext}; +use devkit_core::agent::EnrichedAgent; +use devkit_core::discover::AgentProc; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +/// `devin` invocations that are utilities, not agent sessions. `acp` is +/// deliberately absent: a standalone `devin acp` serves an external client +/// and is itself an agent. +const UTILITY_SUBCOMMANDS: &[&str] = &[ + "auth", "cloud", "desktop", "doctor", "forward", "help", "list", "ls", "mcp", "migrate", + "models", "plugins", "rm", "rules", "sandbox", "setup", "skills", "ssh", "uninstall", + "update", "version", +]; + +/// Flags that always consume the next argv token as their value. +const REQUIRED_VALUE_FLAGS: &[&str] = &["--prompt-file", "--config", "--permission-mode", "--model"]; + +/// Flags whose value is optional: the next token is consumed only when it +/// isn't itself a flag (commander `[]` semantics). +const OPTIONAL_VALUE_FLAGS: &[&str] = + &["-p", "--print", "-r", "--resume", "--export", "--respect-workspace-trust"]; + +/// `firstPositionalToken` — first positional argv token after argv[0]. +/// Flag values are skipped (so `devin -p "update deps"` isn't mistaken +/// for the `update` subcommand); `--` ends the search. +fn first_positional_token(command: &str) -> Option { + let argv0 = shared::executable_path(command); + let trimmed = command.trim(); + // argv0 joins command tokens with single spaces; when the original + // spacing differs the byte len may not land on a boundary — treat a + // failed slice as "no tokens" rather than panic (JS just mis-slices). + let rest = trimmed.get(argv0.len()..).unwrap_or("").trim(); + let tokens: Vec<&str> = rest.split_whitespace().collect(); + let mut i = 0; + while i < tokens.len() { + let token = tokens[i]; + if token == "--" { + return None; + } + if !token.starts_with('-') { + return Some(token.to_string()); + } + // `token.split("=", 1)[0]` — a `--flag=value` token never skips + // the next argv entry; only a bare flag might. + if !token.contains('=') + && (REQUIRED_VALUE_FLAGS.contains(&token) + || (OPTIONAL_VALUE_FLAGS.contains(&token) + && !tokens.get(i + 1).is_some_and(|t| t.starts_with('-')))) + { + i += 1; + } + i += 1; + } + None +} + +/// `isUtilitySubcommand` gate inside `canHandle`. +fn is_utility(command: &str) -> bool { + first_positional_token(command).is_some_and(|t| UTILITY_SUBCOMMANDS.contains(&t.to_lowercase().as_str())) +} + +pub struct DevinAdapter { + db_path: PathBuf, + locks_dir: PathBuf, +} + +impl DevinAdapter { + /// Env-aware constructor — `$XDG_DATA_HOME`-aware sessions.db and the + /// sibling `session_locks/` dir, like the TS locator defaults. + pub fn new(home: &std::path::Path) -> Self { + Self::at_db(locator::resolve_db_path(home)) + } + + /// Explicit db path — locks dir is `session_locks/` next to it. + pub fn at_db(db_path: PathBuf) -> Self { + let locks_dir = db_path + .parent() + .unwrap_or_else(|| std::path::Path::new("")) + .join("session_locks"); + Self { db_path, locks_dir } + } + + /// `mapSessionToAgent`. + fn map_session( + &self, + session: &locator::Session, + stats: &parser::SessionStats, + proc: &AgentProc, + now_ms: i64, + ) -> EnrichedAgent { + let last_active_ms = if stats.last_time_updated_ms > 0 { + stats.last_time_updated_ms + } else if session.last_activity_at_ms != 0 { + session.last_activity_at_ms + } else { + session.time_created_ms + }; + let directory = if !session.directory.is_empty() { + session.directory.clone() + } else { + proc.cwd.clone().unwrap_or_default() + }; + EnrichedAgent { + name: shared::generate_agent_name(&directory, proc.pid), + agent_type: "devin".into(), + status: determine_status(stats, last_active_ms, now_ms).into(), + summary: if !stats.summary.is_empty() { + stats.summary.clone() + } else { + session + .title + .clone() + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| "Devin session active".into()) + }, + pid: proc.pid as u64, + project_path: directory, + session_id: session.session_id.clone(), + last_active: shared::iso_utc(last_active_ms), + pinned: None, + session_file_path: Some(format!( + "{}{}{}", + self.db_path.display(), + locator::SESSION_REF_SEP, + session.session_id + )), + } + } +} + +impl HarnessAdapter for DevinAdapter { + fn type_id(&self) -> &'static str { + "devin" + } + + /// `canHandle` — argv0 `devin`/`devin.exe` and not a utility subcommand. + fn can_handle(&self, proc: &AgentProc) -> bool { + let command = proc.command.as_deref().unwrap_or(""); + shared::matches_executable(command, "devin") && !is_utility(command) + } + + fn detect(&self, ctx: &SweepContext) -> Vec { + // `findHarnessProcesses`: processNames pool filter, then canHandle + // deduped by pid (first wins). + let mut seen = HashSet::new(); + let processes: Vec<&AgentProc> = ctx + .processes + .iter() + .filter(|p| shared::matches_executable_name(p.command.as_deref().unwrap_or(""), &["devin"])) + .filter(|p| self.can_handle(p) && seen.insert(p.pid)) + .collect(); + if processes.is_empty() { + return Vec::new(); + } + + let db = locator::open_db(&self.db_path); + let process_by_pid: HashMap = + processes.iter().map(|p| (p.pid, *p)).collect(); + + // Locks map a session to its holder PID (the `devin acp` backend); + // a TUI is matched through its backend child's ppid. Only locks + // whose holder is a live devin proc in this snapshot count — + // later entries overwrite (JS Map last-wins, readdir order). + let mut session_by_pid: HashMap = HashMap::new(); + for lock in locator::list_active_locks(&self.locks_dir) { + let Some(holder) = process_by_pid.get(&lock.pid) else { + continue; + }; + if !self.can_handle(holder) { + continue; + } + session_by_pid.insert(lock.pid, lock.session_id.clone()); + if let Some(ppid) = holder.ppid { + session_by_pid.insert(ppid, lock.session_id.clone()); + } + } + + let mut agents = Vec::new(); + for proc in processes { + // A `devin acp` spawned by another devin proc is the session's + // backend, not a separate agent — skipped entirely. + if is_acp_backend_child(proc, &process_by_pid) { + continue; + } + let slug = session_by_pid.get(&proc.pid); + let session = db.as_ref().and_then(|db| match slug { + Some(slug) => locator::find_session_by_id(db, slug), + None => proc + .cwd + .as_deref() + .and_then(|cwd| locator::find_session_for_directory(db, cwd)), + }); + match (session, db.as_ref()) { + (Some(session), Some(db)) => { + let stats = parser::get_session_stats(db, &session.session_id); + agents.push(self.map_session(&session, &stats, proc, ctx.now)); + } + _ => agents.push(shared::process_only_agent( + "devin", + proc, + "Devin process running", + None, + ctx.now, + )), + } + } + agents + } +} + +/// `isAcpBackendChild` — `acp` first positional + a live devin parent in +/// the snapshot → the proc is a backend, skipped entirely. +fn is_acp_backend_child(proc: &AgentProc, process_by_pid: &HashMap) -> bool { + if first_positional_token(proc.command.as_deref().unwrap_or("")) + .is_none_or(|t| t.to_lowercase() != "acp") + { + return false; + } + let Some(ppid) = proc.ppid else { return false }; + process_by_pid.contains_key(&ppid) +} + +/// `determineStatus` — idle(5min) > lastRole==="assistant" (waiting) > +/// running. +fn determine_status(stats: &parser::SessionStats, last_active_ms: i64, now_ms: i64) -> &'static str { + if shared::is_idle(last_active_ms, now_ms) { + return "idle"; + } + if stats.last_role.as_deref() == Some("assistant") { + "waiting" + } else { + "running" + } +} diff --git a/rust/crates/devkit-harness/src/devin/parser.rs b/rust/crates/devkit-harness/src/devin/parser.rs new file mode 100644 index 000000000..0072a752a --- /dev/null +++ b/rust/crates/devkit-harness/src/devin/parser.rs @@ -0,0 +1,121 @@ +//! DevinSessionParser port — `getSessionStats` over `message_nodes` + +//! `prompt_history` (detect path only). + +use rusqlite::types::Value as SqlValue; +use rusqlite::Connection; + +/// `SUMMARY_MAX_LENGTH` in shared.ts. +const SUMMARY_MAX_LENGTH: usize = 120; + +/// `DevinSessionStats` — frontier message role, heartbeat (ms), summary. +#[derive(Default)] +pub struct SessionStats { + pub last_role: Option, + pub last_time_updated_ms: i64, + pub summary: String, +} + +/// `parseChatMessage` — JSON.parse tolerantly (null on bad rows). +fn parse_chat_message(raw: &str) -> Option { + serde_json::from_str::(raw).ok() +} + +/// `getSessionStats` — outer single try/catch → EMPTY_STATS on any error; +/// `userPrompt` failures are caught inside it (prompt_history falls +/// through to the message_nodes scan). +pub fn get_session_stats(db: &Connection, session_id: &str) -> SessionStats { + stats_inner(db, session_id).unwrap_or_default() +} + +fn stats_inner(db: &Connection, session_id: &str) -> Option { + let frontier_role = db + .query_row( + "SELECT chat_message FROM message_nodes + WHERE session_id = ?1 ORDER BY node_id DESC LIMIT 1", + [session_id], + |r| r.get::<_, String>(0), + ) + .ok() + .and_then(|raw| parse_chat_message(&raw)) + .and_then(|m| m.get("role")?.as_str().map(str::to_string)); + + let heartbeat: Option = db + .query_row( + "SELECT MAX(created_at) FROM message_nodes WHERE session_id = ?1", + [session_id], + |r| r.get::<_, Option>(0), + ) + .ok()?; + + Some(SessionStats { + last_role: frontier_role, + // `(maxCreated ?? 0) * 1000` — seconds → ms. + last_time_updated_ms: heartbeat.unwrap_or(0).saturating_mul(1000), + summary: last_user_prompt(db, session_id), + }) +} + +/// `getLastUserPrompt` — `userPrompt(db, sessionId, "DESC")`: newest +/// typed prompt, `prompt_history` first (non-shell, non-slash), then a +/// bounded `is_user_input` message_nodes scan. +fn last_user_prompt(db: &Connection, session_id: &str) -> String { + user_prompt(db, session_id, "DESC") +} + +/// `userPrompt` — two independent try/catches. A non-string `content` +/// would throw `.trim()` in JS inside the first try → caught → falls to +/// the node scan, so non-Text values are treated as "not found" here. +fn user_prompt(db: &Connection, session_id: &str, direction: &str) -> String { + let content = db + .query_row( + &format!( + "SELECT content FROM prompt_history + WHERE session_id = ?1 AND is_shell = 0 AND content NOT LIKE '/%' + ORDER BY timestamp {direction} LIMIT 1" + ), + [session_id], + |r| r.get::<_, SqlValue>(0), + ) + .ok(); + if let Some(SqlValue::Text(t)) = content { + let trimmed = t.trim(); + if !trimmed.is_empty() { + return crate::shared::truncate(trimmed, SUMMARY_MAX_LENGTH); + } + } + + let rows = db + .prepare( + &format!( + "SELECT chat_message FROM message_nodes + WHERE session_id = ?1 AND json_extract(chat_message, '$.role') = 'user' + ORDER BY node_id {direction} LIMIT 8" + ), + ) + .and_then(|mut stmt| { + stmt.query_map([session_id], |r| r.get::<_, String>(0)) + .map(|m| m.flatten().collect::>()) + }) + .unwrap_or_default(); + for raw in rows { + let Some(msg) = parse_chat_message(&raw) else { + continue; + }; + // `metadata?.is_user_input !== false` — missing/non-object + // metadata counts as user input; only literal `false` skips. + let is_user_input = !matches!( + msg.pointer("/metadata/is_user_input"), + Some(serde_json::Value::Bool(false)) + ); + if !is_user_input { + continue; + } + if let Some(text) = msg.get("content").and_then(|c| c.as_str()) { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return crate::shared::truncate(trimmed, SUMMARY_MAX_LENGTH); + } + } + } + String::new() +} diff --git a/rust/crates/devkit-harness/src/fixtures.rs b/rust/crates/devkit-harness/src/fixtures.rs new file mode 100644 index 000000000..17cf1beb8 --- /dev/null +++ b/rust/crates/devkit-harness/src/fixtures.rs @@ -0,0 +1,309 @@ +//! Golden-fixture replay for ported adapters — the Rust side of the parity +//! oracle. Loads `fixtures/harness//*.json` bundles captured by +//! `agent-manager`'s fixture tool, materializes each into a temp HOME, runs +//! the matching adapter, and requires byte-identical output to `expected`. +//! +//! Live bundles (`live*.json`) are gitignored local captures — when absent +//! only committed synthetic bundles run. Sentinels resolved at +//! materialization: `$FIXTURE_HOME` → temp home, `$NOW` → the instant home +//! files were written (so birthtime-based matching stays inside tolerance). + +use devkit_core::discover::AgentProc; +use serde_json::Value; +use std::path::{Path, PathBuf}; + +pub const HOME_PLACEHOLDER: &str = "$FIXTURE_HOME"; +pub const NOW_PLACEHOLDER: &str = "$NOW"; + +fn fixtures_root() -> PathBuf { + // rust/crates/devkit-harness → repo root + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../fixtures/harness") +} + +fn expand_str(s: &str, home: &str, now_iso: &str) -> String { + s.replace(HOME_PLACEHOLDER, home) + .replace(NOW_PLACEHOLDER, now_iso) +} + +fn expand_value(v: &Value, home: &str, now_iso: &str) -> Value { + match v { + Value::String(s) => Value::String(expand_str(s, home, now_iso)), + Value::Array(a) => Value::Array(a.iter().map(|x| expand_value(x, home, now_iso)).collect()), + Value::Object(o) => Value::Object( + o.iter() + .map(|(k, x)| (k.clone(), expand_value(x, home, now_iso))) + .collect(), + ), + other => other.clone(), + } +} + +/// `sanitize` — replace `/.` with `$FIXTURE_HOME/.` recursively. +fn sanitize_value(v: &Value, home: &str) -> Value { + let home_dot = format!("{home}/."); + match v { + Value::String(s) => Value::String(s.replace(&home_dot, &format!("{HOME_PLACEHOLDER}/."))), + Value::Array(a) => Value::Array(a.iter().map(|x| sanitize_value(x, home)).collect()), + Value::Object(o) => Value::Object( + o.iter() + .map(|(k, x)| (k.clone(), sanitize_value(x, home))) + .collect(), + ), + other => other.clone(), + } +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64 +} + +struct FixtureBundle { + adapter: String, + frozen_now: Value, + processes: Vec, + home: Vec<(String, String)>, + mtimes: Vec<(String, Value)>, + registry: Vec, + sqlite: Vec<(String, Vec)>, + expected: Value, +} + +fn load_bundle(path: &Path) -> FixtureBundle { + let raw: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + FixtureBundle { + adapter: raw["adapter"].as_str().unwrap().to_string(), + frozen_now: raw["frozenNow"].clone(), + processes: raw["processes"].as_array().cloned().unwrap_or_default(), + home: raw["home"] + .as_object() + .map(|o| { + o.iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string())) + .collect() + }) + .unwrap_or_default(), + mtimes: raw["mtimes"] + .as_object() + .map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(), + registry: raw["registry"].as_array().cloned().unwrap_or_default(), + sqlite: raw["sqlite"] + .as_object() + .map(|o| { + o.iter() + .map(|(k, v)| { + ( + k.clone(), + v.as_array() + .map(|a| { + a.iter() + .map(|s| s.as_str().unwrap_or_default().to_string()) + .collect() + }) + .unwrap_or_default(), + ) + }) + .collect() + }) + .unwrap_or_default(), + expected: raw["expected"].clone(), + } +} + +/// Seed `/.ai-devkit/agents.db` with the captured rows — registry-aware +/// adapters (codex) read it during detect; replay must never see the real db. +fn seed_registry(home: &Path, rows: &[Value], home_s: &str, now_iso: &str) { + if rows.is_empty() { + return; + } + let db = home.join(".ai-devkit").join("agents.db"); + std::fs::create_dir_all(db.parent().unwrap()).unwrap(); + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE agents ( + type TEXT NOT NULL, pid INTEGER NOT NULL, name TEXT NOT NULL, + tmux_session TEXT NOT NULL DEFAULT '', cwd TEXT NOT NULL DEFAULT '', + started_at TEXT NOT NULL, session_id TEXT NOT NULL DEFAULT '', + session_file_path TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL, + pinned INTEGER NOT NULL DEFAULT 0, runtime TEXT NOT NULL DEFAULT 'tmux', + runtime_ref TEXT NOT NULL DEFAULT '', PRIMARY KEY (type, pid));", + ) + .unwrap(); + for row in rows { + let v = expand_value(row, home_s, now_iso); + conn.execute( + "INSERT OR REPLACE INTO agents + (type, pid, name, tmux_session, cwd, started_at, session_id, + session_file_path, updated_at, pinned, runtime, runtime_ref) + VALUES (?1,?2,?3,'',?4,?5,?6,?7,?8,?9,'tmux','')", + rusqlite::params![ + v["type"].as_str().unwrap_or_default(), + v["pid"].as_i64().unwrap_or_default(), + v["name"].as_str().unwrap_or_default(), + v["cwd"].as_str().unwrap_or_default(), + v["startedAt"].as_str().unwrap_or_default(), + v["sessionId"].as_str().unwrap_or_default(), + v["sessionFilePath"].as_str().unwrap_or_default(), + now_iso, + v["pinned"].as_bool().unwrap_or(false) as i64, + ], + ) + .unwrap(); + } +} + +/// Write `home` files into a fresh temp dir; returns (dir, write instant ms). +/// `$TODAY` in keys and contents resolves to the local `YYYY/MM/DD` day key — +/// Codex date-dir discovery derives day keys from process starts in local +/// time, so static dirs would drift across replay days/timezones. +fn materialize_home(bundle: &FixtureBundle, tag: &str) -> (PathBuf, i64, String) { + let today_key = crate::shared::local_day_key(now_ms()); + // Parallel adapter tests can materialize same-named bundles in the same + // millisecond — a counter keeps dirs (and their seeded agents.db) disjoint. + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "devkit-fixture-{}-{}-{}-{tag}", + std::process::id(), + now_ms(), + SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let home_s = dir.to_string_lossy().into_owned(); + for (rel, content) in &bundle.home { + let p = dir.join(rel.replace("$TODAY", &today_key)); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, expand_str(&content.replace("$TODAY", &today_key), &home_s, "")).unwrap(); + } + let written_ms = now_ms(); + // Deterministic mtimes for adapters that read them (Grok lastActive / + // latest-session pick) — "$NOW" resolves to the materialization instant. + for (rel, mt) in &bundle.mtimes { + let p = dir.join(rel.replace("$TODAY", &today_key)); + let ms = match mt { + Value::String(s) if s == NOW_PLACEHOLDER => written_ms, + // Live-captured mtimes are `fs.statSync().mtimeMs` — floats. + v => v.as_f64().unwrap() as i64, + }; + let file = std::fs::File::open(&p).unwrap(); + file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms as u64)) + .unwrap(); + } + // SQLite stores (OpenCode's opencode.db) materialize from ordered SQL + // statements — binary dbs can't travel in the JSON `home` map. + for (rel, stmts) in &bundle.sqlite { + let p = dir.join(rel.replace("$TODAY", &today_key)); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + let conn = rusqlite::Connection::open(&p).unwrap(); + for stmt in stmts { + conn.execute_batch(&expand_str(&stmt.replace("$TODAY", &today_key), &home_s, "")) + .unwrap(); + } + } + (dir, written_ms, today_key) +} + +/// Replay every committed (and any local live) bundle for `adapter_type` +/// through `detect`; `make` builds the adapter bound to each materialized +/// home. Panics on the first mismatch. Returns bundle count. +pub fn assert_parity( + adapter_type: &str, + make: impl Fn(&Path) -> Box, +) -> usize { + let dir = fixtures_root().join(adapter_type); + let mut paths: Vec = std::fs::read_dir(&dir) + .map(|rd| { + rd.flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x == "json")) + .collect() + }) + .unwrap_or_default(); + paths.sort(); + for path in &paths { + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let bundle = load_bundle(path); + assert_eq!(bundle.adapter, adapter_type, "{name}: adapter mismatch"); + + let (home, written_ms, today_key) = materialize_home(&bundle, &name); + let home_s = home.to_string_lossy().into_owned(); + let now_iso = crate::shared::iso_utc(written_ms); + + let frozen_now = match &bundle.frozen_now { + Value::String(s) if s == NOW_PLACEHOLDER => written_ms, + v => v.as_i64().unwrap(), + }; + + let processes: Vec = bundle + .processes + .iter() + .map(|p| expand_value(p, &home_s, &now_iso)) + .map(|p| AgentProc { + pid: p["pid"].as_i64().unwrap(), + ppid: p["ppid"].as_i64(), + tty: p["tty"].as_str().map(String::from), + command: p["command"].as_str().map(String::from), + cwd: p["cwd"] + .as_str() + .filter(|s| !s.is_empty()) + .map(String::from), + session_file: None, + start_time_ms: p["startTime"] + .as_str() + .and_then(crate::shared::parse_iso_ms), + }) + .collect(); + + seed_registry(&home, &bundle.registry, &home_s, &now_iso); + // Pids whose startTime resolves to the materialization instant — only + // their session paths contain the replay-day key and normalize to + // $TODAY; static-start agents keep concrete dates. + let now_pids: std::collections::HashSet = bundle + .processes + .iter() + .filter(|p| p["startTime"].as_str() == Some(NOW_PLACEHOLDER)) + .map(|p| p["pid"].as_i64().unwrap()) + .collect(); + + let ctx = crate::SweepContext { + processes: &processes, + now: frozen_now, + home: &home, + }; + let agents = make(&home).detect(&ctx); + let actual_list: Vec = serde_json::to_value(&agents) + .unwrap() + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .map(|a| { + if now_pids.contains(&a["pid"].as_i64().unwrap_or(-1)) { + serde_json::from_str( + &serde_json::to_string(&a) + .unwrap() + .replace(&today_key, "$TODAY"), + ) + .unwrap() + } else { + a + } + }) + .collect(); + let actual = sanitize_value(&Value::Array(actual_list), &home_s); + // $FIXTURE_HOME and $TODAY stay literal in expected — actual is + // normalized to the same placeholder form. Only $NOW resolves. + let expected = expand_value(&bundle.expected, HOME_PLACEHOLDER, &now_iso); + if actual != expected { + std::fs::remove_dir_all(&home).ok(); + panic!( + "{name}: adapter output diverged\nexpected: {}\nactual: {}", + serde_json::to_string_pretty(&expected).unwrap(), + serde_json::to_string_pretty(&actual).unwrap() + ); + } + std::fs::remove_dir_all(&home).ok(); + } + paths.len() +} diff --git a/rust/crates/devkit-harness/src/gemini/locator.rs b/rust/crates/devkit-harness/src/gemini/locator.rs new file mode 100644 index 000000000..49ffdd3f6 --- /dev/null +++ b/rust/crates/devkit-harness/src/gemini/locator.rs @@ -0,0 +1,401 @@ +//! Gemini session discovery — port of `GeminiSessionLocator`. +//! +//! Sessions live under `~/.gemini/tmp//chats/session-*.{json,jsonl}`. +//! `` is either `sha256(projectRoot)` (legacy) or a slug whose owner +//! is recorded in `/.project_root`. A session's `projectHash` is +//! matched against sha256 of every ancestor of each candidate process's cwd +//! (Gemini resolves project root by walking up to a `.git` boundary). + +use devkit_core::discover::AgentProc; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use crate::shared::{self, SessionFile}; + +const SESSION_FILE_PREFIX: &str = "session-"; +const SESSION_DOC_EXT: &str = ".json"; +const SESSION_LOG_EXT: &str = ".jsonl"; +const CHATS_DIR: &str = "chats"; +const PROJECT_ROOT_MARKER: &str = ".project_root"; +const MTIME_SLACK_MS: i64 = 2_000; +/// Both formats lead with sessionId/projectHash; an 8KiB head suffices. +const METADATA_HEAD_BYTES: usize = 8 * 1024; + +pub struct GeminiSessionLocator { + tmp_dir: PathBuf, +} + +#[derive(Default)] +struct Metadata { + session_id: Option, + project_hash: Option, +} + +struct Candidates { + /// sha256(candidate project root) → process cwd that may own it. + cwd_by_hash: HashMap, + /// Normalized candidate roots, compared against `.project_root`. + roots: HashSet, +} + +impl GeminiSessionLocator { + pub fn new(home: &Path) -> Self { + Self { + tmp_dir: home.join(".gemini").join("tmp"), + } + } + + /// `discoverSessions` — session candidates for these processes. + pub fn discover_sessions(&self, processes: &[&AgentProc]) -> Vec { + let candidates = self.build_candidates(processes); + if candidates.cwd_by_hash.is_empty() || !self.tmp_dir.is_dir() { + return Vec::new(); + } + let min_mtime = earliest_matchable_mtime(processes); + let mut sessions = Vec::new(); + + for short_id in crate::shared::list_dir_names(&self.tmp_dir) { + let project_dir = self.tmp_dir.join(&short_id); + if !self.may_belong(&project_dir, &short_id, &candidates) { + continue; + } + let chats_dir = project_dir.join(CHATS_DIR); + if !chats_dir.is_dir() { + continue; + } + for file_name in list_session_file_names(&chats_dir) { + if let Some(s) = + self.read_candidate(&chats_dir, &file_name, &candidates.cwd_by_hash, min_mtime) + { + sessions.push(s); + } + } + } + sessions + } + + fn read_candidate( + &self, + chats_dir: &Path, + file_name: &str, + cwd_by_hash: &HashMap, + min_mtime: i64, + ) -> Option { + let file_path = chats_dir.join(file_name); + let stat = std::fs::metadata(&file_path).ok()?; + let mtime_ms = stat + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + if mtime_ms < min_mtime { + return None; + } + + let metadata = self.extract_metadata(&file_path); + let hash = metadata.project_hash.filter(|h| !h.is_empty())?; + let resolved_cwd = cwd_by_hash.get(&hash)?; + let birthtime_ms = stat + .created() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + + Some(SessionFile { + session_id: metadata.session_id.unwrap_or_else(|| { + // `fileName.replace(/\.jsonl?$/, "")` — one suffix only. + file_name + .strip_suffix(SESSION_LOG_EXT) + .or_else(|| file_name.strip_suffix(SESSION_DOC_EXT)) + .unwrap_or(file_name) + .to_string() + }), + file_path: file_path.to_string_lossy().into_owned(), + project_dir: chats_dir.to_string_lossy().into_owned(), + birthtime_ms, + resolved_cwd: resolved_cwd.clone(), + }) + } + + /// `extractSessionMetadata` — bounded head read; `.jsonl` takes line 1, + /// `.json` parses the head when complete else regex-scans the prefix and + /// finally the whole file. + fn extract_metadata(&self, file_path: &Path) -> Metadata { + let Some((text, complete)) = read_head(file_path, METADATA_HEAD_BYTES) else { + return Metadata::default(); + }; + let is_log = file_path + .to_string_lossy() + .ends_with(SESSION_LOG_EXT); + if is_log { + return match text.find('\n') { + Some(nl) => pick_metadata(text[..nl].parse::().ok().as_ref()), + None if complete => pick_metadata(text.parse::().ok().as_ref()), + // First line exceeds the head: pick leading keys by regex. + None => Metadata { + session_id: match_string_field(&text, "sessionId"), + project_hash: match_string_field(&text, "projectHash"), + }, + }; + } + if complete { + return pick_metadata(text.parse::().ok().as_ref()); + } + let session_id = match_string_field(&text, "sessionId"); + let project_hash = match_string_field(&text, "projectHash"); + if session_id.is_some() && project_hash.is_some() { + return Metadata { + session_id, + project_hash, + }; + } + let Ok(content) = std::fs::read_to_string(file_path) else { + return Metadata::default(); + }; + pick_metadata(content.parse::().ok().as_ref()) + } + + /// `mayBelongToCandidates` — hash dirs must match a candidate hash; + /// slug dirs defer to their `.project_root` marker; no marker → scan. + fn may_belong(&self, project_dir: &Path, short_id: &str, candidates: &Candidates) -> bool { + if is_legacy_hash_dir(short_id) { + return candidates.cwd_by_hash.contains_key(short_id); + } + let marker = project_dir.join(PROJECT_ROOT_MARKER); + let Ok(content) = std::fs::read_to_string(&marker) else { + return true; + }; + if !std::fs::metadata(&marker).map(|m| m.is_file()).unwrap_or(false) { + return true; + } + let root = content.trim(); + if root.is_empty() { + return true; + } + candidates.roots.contains(&normalize_root(root)) + } + + fn build_candidates(&self, processes: &[&AgentProc]) -> Candidates { + let mut cwd_by_hash = HashMap::new(); + let mut roots = HashSet::new(); + for proc in processes { + let cwd = proc.cwd.as_deref().unwrap_or(""); + if cwd.is_empty() { + continue; + } + for root in candidate_project_roots(cwd) { + roots.insert(normalize_root(&root)); + cwd_by_hash + .entry(sha256_hex(&root)) + .or_insert_with(|| cwd.to_string()); + } + } + Candidates { cwd_by_hash, roots } + } +} + +fn earliest_matchable_mtime(processes: &[&AgentProc]) -> i64 { + let mut earliest = i64::MAX; + for proc in processes { + if proc.cwd.as_deref().unwrap_or("").is_empty() { + continue; + } + if let Some(start) = proc.start_time_ms { + earliest = earliest.min(start); + } + } + if earliest == i64::MAX { + return i64::MIN; + } + earliest - shared::MATCH_TOLERANCE_MS - MTIME_SLACK_MS +} + +fn is_legacy_hash_dir(name: &str) -> bool { + name.len() == 64 && name.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) +} + +/// `listSessionFileNames` — `session-*.{json,jsonl}`, a `.json` being +/// shadowed by a same-name `.jsonl` (resumed sessions) is skipped. +fn list_session_file_names(chats_dir: &Path) -> Vec { + let names: Vec = crate::shared::list_dir_names(chats_dir) + .into_iter() + .filter(|n| { + n.starts_with(SESSION_FILE_PREFIX) + && (n.ends_with(SESSION_DOC_EXT) || n.ends_with(SESSION_LOG_EXT)) + }) + .collect(); + let set: HashSet = names.iter().cloned().collect(); + names + .into_iter() + .filter(|n| match n.strip_suffix(SESSION_DOC_EXT) { + Some(base) => !set.contains(&format!("{base}{SESSION_LOG_EXT}")), + None => true, + }) + .collect() +} + +/// `readHead` — up to `max+1` bytes; `complete` when the file fits. +fn read_head(path: &Path, max: usize) -> Option<(String, bool)> { + use std::io::Read; + let mut f = std::fs::File::open(path).ok()?; + let mut buf = vec![0u8; max + 1]; + let mut len = 0; + while len < buf.len() { + let n = f.read(&mut buf[len..]).ok()?; + if n == 0 { + break; + } + len += n; + } + let text = String::from_utf8_lossy(&buf[..len.min(max)]).into_owned(); + Some((text, len <= max)) +} + +/// `matchStringField` — `"key"\s*:\s*"..."` with JSON escape handling. +fn match_string_field(text: &str, key: &str) -> Option { + let needle = format!("\"{key}\""); + let mut search_from = 0; + while let Some(pos) = text[search_from..].find(&needle) { + let idx = search_from + pos + needle.len(); + let rest = &text[idx..]; + let colon = rest.trim_start().strip_prefix(':'); + let Some(after_colon) = colon else { + search_from = idx; + continue; + }; + let Some(after_quote) = after_colon.trim_start().strip_prefix('"') else { + search_from = idx; + continue; + }; + // Capture up to the closing quote honoring `\` escapes, then + // decode via a JSON string parse (JSON.parse("\"cap\"") parity). + let bytes = after_quote.as_bytes(); + let mut i = 0; + let mut end = None; + while i < bytes.len() { + if bytes[i] == b'\\' { + i += 2; + continue; + } + if bytes[i] == b'"' { + end = Some(i); + break; + } + i += 1; + } + let end = end?; + let captured = &after_quote[..end]; + return serde_json::from_str::(&format!("\"{captured}\"")).ok(); + } + None +} + +fn pick_metadata(v: Option<&Value>) -> Metadata { + let Some(v) = v.filter(|v| v.is_object()) else { + return Metadata::default(); + }; + Metadata { + session_id: v.get("sessionId").and_then(|s| s.as_str()).map(String::from), + project_hash: v + .get("projectHash") + .and_then(|s| s.as_str()) + .map(String::from), + } +} + +/// `candidateProjectRoots` — cwd plus every ancestor to `/`. +fn candidate_project_roots(cwd: &str) -> Vec { + let mut roots = Vec::new(); + let mut current = normalize_root(cwd); + loop { + let parent = Path::new(¤t) + .parent() + .map(|p| p.to_string_lossy().into_owned()); + match parent { + Some(p) if p != current => { + roots.push(current.clone()); + current = p; + } + _ => { + roots.push(current); + break; + } + } + } + roots +} + +/// `normalizeRoot`/`path.resolve` — lexical normalization of an absolute +/// path (`..`/`.`/duplicate separators collapsed; no symlink resolution). +fn normalize_root(p: &str) -> String { + let mut out: Vec<&str> = Vec::new(); + for part in p.split('/') { + match part { + "" | "." => {} + ".." => { + out.pop(); + } + other => out.push(other), + } + } + format!("/{}", out.join("/")) +} + +fn sha256_hex(s: &str) -> String { + use sha2::Digest; + let digest = sha2::Sha256::digest(s.as_bytes()); + digest.iter().map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_roots_walk_to_filesystem_root() { + assert_eq!( + candidate_project_roots("/a/b/c"), + vec!["/a/b/c", "/a/b", "/a", "/"] + ); + assert_eq!(candidate_project_roots("/"), vec!["/"]); + } + + #[test] + fn hash_matches_node_crypto() { + // node: crypto.createHash("sha256").update("/x").digest("hex") + assert_eq!( + sha256_hex("/x"), + "b3d1db318671a024a7e4b433389f8820d6ca466e2cf700afc29f37ed64f2fa0d" + ); + } + + #[test] + fn field_regex_handles_escapes() { + assert_eq!( + match_string_field(r#"{"sessionId":"a\"b","projectHash":"x"}"#, "sessionId"), + Some("a\"b".to_string()) + ); + assert_eq!( + match_string_field(r#"{"sessionId" : "v"}"#, "sessionId"), + Some("v".to_string()) + ); + assert_eq!(match_string_field(r#"{"sessionId":5}"#, "sessionId"), None); + } + + #[test] + fn json_shadowed_by_jsonl() { + let dir = std::env::temp_dir().join(format!("gem-shad-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("session-a.json"), "{}").unwrap(); + std::fs::write(dir.join("session-a.jsonl"), "{}").unwrap(); + std::fs::write(dir.join("session-b.json"), "{}").unwrap(); + std::fs::write(dir.join("other.json"), "{}").unwrap(); + let mut names = list_session_file_names(&dir); + names.sort(); + assert_eq!(names, vec!["session-a.jsonl", "session-b.json"]); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/rust/crates/devkit-harness/src/gemini/mod.rs b/rust/crates/devkit-harness/src/gemini/mod.rs new file mode 100644 index 000000000..6dbce0027 --- /dev/null +++ b/rust/crates/devkit-harness/src/gemini/mod.rs @@ -0,0 +1,285 @@ +//! `GeminiCliAdapter` port — `type = "gemini_cli"`. +//! +//! Detect pipeline mirrors TS order: +//! 1. `canHandle` any-token basename scan (`gemini`/`gemini.exe`/`gemini.js`) +//! 2. wrapper-pid set over the candidate pool +//! 3. registry cache rows (`type='gemini_cli'`, parseable sessionFilePath) +//! 4. locator discovery + greedy matching + parse; matched wrappers fold +//! into their child's agent (name taken from the wrapper's registry row) +//! 5. process-only fallback ("Gemini CLI process running") +//! 6. `deduplicateSessionAgents` keyed on file path / session id, highest +//! pid wins in place + +mod locator; +mod parser; + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use devkit_core::agent::EnrichedAgent; +use devkit_core::discover::AgentProc; + +use crate::shared; +use crate::{HarnessAdapter, SweepContext}; +use locator::GeminiSessionLocator; +use parser::GeminiSession; + +pub struct GeminiAdapter { + home: PathBuf, + locator: GeminiSessionLocator, +} + +impl GeminiAdapter { + pub fn new(home: &Path) -> Self { + Self { + home: home.to_path_buf(), + locator: GeminiSessionLocator::new(home), + } + } + + /// `isGeminiExecutable` — any whitespace-separated token whose basename + /// is a gemini entrypoint (node-script distribution puts it in argv[1..]). + fn is_gemini_executable(command: &str) -> bool { + command.split_whitespace().any(|token| { + matches!( + shared::path_basename(token).as_str(), + "gemini" | "gemini.exe" | "gemini.js" + ) + }) + } + + fn map_session( + &self, + session: &GeminiSession, + proc: &AgentProc, + file_path: &str, + now: i64, + ) -> EnrichedAgent { + let project_path = if session.project_path.is_empty() { + proc.cwd.clone().unwrap_or_default() + } else { + session.project_path.clone() + }; + EnrichedAgent { + name: shared::generate_agent_name(&project_path, proc.pid), + agent_type: "gemini_cli".into(), + status: parser::determine_status(session, now).into(), + summary: if session.summary.is_empty() { + "Gemini CLI session active".into() + } else { + session.summary.clone() + }, + pid: proc.pid as u64, + project_path, + session_id: session.session_id.clone(), + last_active: shared::iso_utc(session.last_active_ms), + pinned: None, + session_file_path: Some(file_path.to_string()), + } + } + + /// `applyWrapperRegistryName` — a wrapper (parent in the same terminal) + /// with a same-type registry row lends its name to the child's agent. + fn apply_wrapper_name( + agent: &mut EnrichedAgent, + proc: &AgentProc, + processes: &[&AgentProc], + reg_by_pid: &HashMap, + ) { + let Some(wrapper_pid) = shared::find_wrapper_pid(processes, proc) else { + return; + }; + let Some(entry) = reg_by_pid.get(&wrapper_pid) else { + return; + }; + if entry.agent_type == "gemini_cli" { + agent.name = entry.name.clone(); + } + } + + /// `deduplicateSessionAgents` — one row per session file/id, highest pid. + fn deduplicate(agents: Vec) -> Vec { + let key_of = |a: &EnrichedAgent| -> Option { + if let Some(p) = &a.session_file_path { + return Some(format!("file:{p}")); + } + if !a.session_id.is_empty() && !a.session_id.starts_with("pid-") { + return Some(format!("session:{}", a.session_id)); + } + None + }; + let mut by_session: HashMap = HashMap::new(); + let mut result: Vec = Vec::new(); + for agent in agents { + let Some(key) = key_of(&agent) else { + result.push(agent); + continue; + }; + match by_session.get(&key) { + None => { + by_session.insert(key, result.len()); + result.push(agent); + } + Some(&idx) => { + if agent.pid > result[idx].pid { + result[idx] = agent; + } + } + } + } + result + } +} + +impl HarnessAdapter for GeminiAdapter { + fn type_id(&self) -> &'static str { + "gemini_cli" + } + + fn can_handle(&self, proc: &AgentProc) -> bool { + proc.command + .as_deref() + .map(Self::is_gemini_executable) + .unwrap_or(false) + } + + fn detect(&self, ctx: &SweepContext) -> Vec { + // `findHarnessProcesses`: the `node` process-name pool first (a bare + // `gemini` argv0 proc is dropped before canHandle), then + // canHandle-filtered, first-wins by pid. + let mut seen = HashSet::new(); + let processes: Vec<&AgentProc> = ctx + .processes + .iter() + .filter(|p| { + p.command + .as_deref() + .map(|c| shared::matches_executable_name(c, &["node"])) + .unwrap_or(false) + && self.can_handle(p) + && seen.insert(p.pid) + }) + .collect(); + if processes.is_empty() { + return Vec::new(); + } + + let wrapper_set = shared::wrapper_pids(&processes, &[]); + let reg_rows = shared::registry_agent_rows(&self.home); + let reg_by_pid: HashMap = + reg_rows.iter().map(|r| (r.pid, r)).collect(); + + // Registry-cache stage (skipped for wrappers). + let mut cached: Vec = Vec::new(); + let mut remaining: Vec<&AgentProc> = Vec::new(); + for proc in &processes { + let entry = reg_by_pid.get(&proc.pid); + let cacheable = !wrapper_set.contains(&proc.pid) + && entry.is_some_and(|e| { + e.agent_type == "gemini_cli" && !e.session_file_path.is_empty() + }); + if !cacheable { + remaining.push(proc); + continue; + } + let path = &entry.unwrap().session_file_path; + match parser::parse_session(path, ctx.now) { + Some(s) => cached.push(self.map_session(&s, proc, path, ctx.now)), + None => remaining.push(proc), + } + } + if remaining.is_empty() { + return Self::deduplicate(cached); + } + + let candidates: Vec<&AgentProc> = remaining + .iter() + .copied() + .filter(|p| !wrapper_set.contains(&p.pid)) + .collect(); + let sessions = self.locator.discover_sessions(&candidates); + + let mut agents: Vec = Vec::new(); + if sessions.is_empty() { + for proc in &candidates { + let mut a = shared::process_only_agent( + "gemini_cli", + proc, + "Gemini CLI process running", + None, + ctx.now, + ); + Self::apply_wrapper_name(&mut a, proc, &processes, ®_by_pid); + agents.push(a); + } + } else { + let matches = shared::match_processes_to_sessions(&candidates, &sessions); + let mut matched_pids: HashSet = HashSet::new(); + let mut matched_procs: Vec<&AgentProc> = Vec::new(); + for m in &matches { + let file = &sessions[m.session_idx].file_path; + match parser::parse_session(file, ctx.now) { + Some(s) => { + let mut a = self.map_session(&s, m.proc, file, ctx.now); + Self::apply_wrapper_name(&mut a, m.proc, &processes, ®_by_pid); + agents.push(a); + matched_pids.insert(m.proc.pid); + matched_procs.push(m.proc); + } + None => continue, + } + } + let matched_wrapper_pids = shared::wrapper_pids(&candidates, &matched_procs); + for proc in &candidates { + if matched_pids.contains(&proc.pid) || matched_wrapper_pids.contains(&proc.pid) { + continue; + } + let mut a = shared::process_only_agent( + "gemini_cli", + proc, + "Gemini CLI process running", + None, + ctx.now, + ); + Self::apply_wrapper_name(&mut a, proc, &processes, ®_by_pid); + agents.push(a); + } + } + + Self::deduplicate(cached.into_iter().chain(agents).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gemini_executable_tokens() { + assert!(GeminiAdapter::is_gemini_executable("gemini")); + assert!(GeminiAdapter::is_gemini_executable("node /opt/gemini.js --yolo")); + assert!(GeminiAdapter::is_gemini_executable("C:\\tools\\gemini.exe chat")); + assert!(!GeminiAdapter::is_gemini_executable("geminis")); + assert!(!GeminiAdapter::is_gemini_executable("node server.js")); + } + + #[test] + fn dedupe_keeps_highest_pid_in_place() { + let a = |pid: u64, sid: &str| EnrichedAgent { + name: format!("a-{pid}"), + agent_type: "gemini_cli".into(), + status: "running".into(), + summary: String::new(), + pid, + project_path: String::new(), + session_id: sid.into(), + last_active: String::new(), + pinned: None, + session_file_path: None, + }; + let out = GeminiAdapter::deduplicate(vec![a(1, "s1"), a(2, "s1"), a(3, "s2")]); + assert_eq!(out.len(), 2); + assert_eq!(out[0].pid, 2); + assert_eq!(out[1].pid, 3); + } +} diff --git a/rust/crates/devkit-harness/src/gemini/parser.rs b/rust/crates/devkit-harness/src/gemini/parser.rs new file mode 100644 index 000000000..a200adf7f --- /dev/null +++ b/rust/crates/devkit-harness/src/gemini/parser.rs @@ -0,0 +1,343 @@ +//! Gemini session parsing — port of `GeminiSessionParser` (detect path +//! only: `parseSession`/`buildSession`/`determineStatus`/`extractSummary`). +//! +//! Two formats: legacy `session-*.json` single documents, and +//! `session-*.jsonl` append-only logs (Gemini CLI 0.46+) replayed like +//! `loadConversationRecord`: records with `id` upsert messages by id, +//! `{"$set":{}}` merges metadata (a `$set.messages` array replaces all +//! messages), `{"$rewindTo":id}` drops that message and every later one +//! (all messages when the id is unknown), and records carrying both +//! `sessionId`/`projectHash` merge metadata. + +use serde_json::Value; + +use crate::shared; + +const SUMMARY_FALLBACK: &str = "Gemini CLI session active"; +const SUMMARY_MAX_LENGTH: usize = 120; + +/// The parsed subset of a session file needed for agent mapping. +pub struct GeminiSession { + pub session_id: String, + pub project_path: String, + pub summary: String, + pub last_active_ms: i64, + pub last_message_type: Option, +} + +/// `parseSession(undefined, filePath)` — stat + read + parse. +/// `None` when the file is missing/unreadable, unparseable, or lacks a +/// non-empty `sessionId`. +pub fn parse_session(file_path: &str, now_ms: i64) -> Option { + let meta = std::fs::metadata(file_path).ok()?; + let mtime_ms = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64); + let content = std::fs::read_to_string(file_path).ok()?; + build_session(&content, file_path, mtime_ms, now_ms) +} + +fn build_session( + content: &str, + file_path: &str, + mtime_ms: Option, + now_ms: i64, +) -> Option { + let parsed = parse_session_content(content, file_path)?; + let session_id = parsed.session_id.filter(|s| !s.is_empty())?; + + let messages = parsed.messages; + let last = messages.last(); + let last_active_ms = parsed + .last_updated + .as_ref() + .and_then(shared::parse_timestamp_ms) + .or_else(|| { + last.and_then(|e| e.get("timestamp")) + .and_then(shared::parse_timestamp_ms) + }) + .or(mtime_ms) + .unwrap_or(now_ms); + + GeminiSession { + session_id, + project_path: parsed + .directories + .as_ref() + .and_then(|d| d.first()) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + summary: extract_summary(&messages), + last_active_ms, + last_message_type: last + .and_then(|e| e.get("type")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + } + .into() +} + +/// `determineStatus` — idle(5min) > waiting(last turn assistant-side) > running. +pub fn determine_status(session: &GeminiSession, now_ms: i64) -> &'static str { + if shared::is_idle(session.last_active_ms, now_ms) { + return "idle"; + } + match session.last_message_type.as_deref() { + Some("gemini") | Some("assistant") => "waiting", + _ => "running", + } +} + +/// Normalized view of either session format (`GeminiSessionFile`). +#[derive(Default)] +struct SessionDoc { + session_id: Option, + #[allow(dead_code)] + start_time: Option, + last_updated: Option, + directories: Option>, + messages: Vec, +} + +fn parse_session_content(content: &str, file_path: &str) -> Option { + if file_path.ends_with(".jsonl") { + replay_session_log(content) + } else { + parse_session_doc(content) + } +} + +fn parse_session_doc(content: &str) -> Option { + let v: Value = serde_json::from_str(content).ok()?; + Some(doc_from_meta(&v)) +} + +fn doc_from_meta(v: &Value) -> SessionDoc { + SessionDoc { + session_id: v.get("sessionId").and_then(|s| s.as_str()).map(String::from), + start_time: v.get("startTime").cloned(), + last_updated: v.get("lastUpdated").cloned(), + directories: v.get("directories").and_then(|d| d.as_array()).cloned(), + messages: v + .get("messages") + .and_then(|m| m.as_array()) + .cloned() + .unwrap_or_default(), + } +} + +/// Insertion-ordered id→message map matching JS `Map` semantics +/// (set on an existing key keeps its position). +struct MessageMap { + order: Vec, + entries: std::collections::HashMap, +} + +impl MessageMap { + fn new() -> Self { + Self { + order: Vec::new(), + entries: std::collections::HashMap::new(), + } + } + fn upsert(&mut self, record: &Value) { + let Some(id) = record.get("id").and_then(|v| v.as_str()) else { + return; + }; + if self.entries.insert(id.to_string(), record.clone()).is_none() { + self.order.push(id.to_string()); + } + } + fn upsert_all(&mut self, entries: Option<&Value>) { + let Some(arr) = entries.and_then(|v| v.as_array()) else { + return; + }; + for entry in arr { + self.upsert(entry); + } + } + /// `$rewindTo`: drop the id and everything inserted after it; an + /// unknown id clears all. + fn rewind_to(&mut self, id: &str) { + match self.order.iter().position(|o| o == id) { + Some(pos) => { + for removed in self.order.drain(pos..) { + self.entries.remove(&removed); + } + } + None => { + self.order.clear(); + self.entries.clear(); + } + } + } + fn clear(&mut self) { + self.order.clear(); + self.entries.clear(); + } + fn values(self) -> Vec { + self.order + .into_iter() + .filter_map(|id| self.entries.get(&id).cloned()) + .collect() + } +} + +fn replay_session_log(content: &str) -> Option { + let mut metadata = serde_json::Map::new(); + let mut messages = MessageMap::new(); + + for line in content.split('\n') { + if line.trim().is_empty() { + continue; + } + let Ok(record) = serde_json::from_str::(line) else { + continue; + }; + if !record.is_object() { + continue; + } + + if let Some(rewind) = record.get("$rewindTo").and_then(|v| v.as_str()) { + messages.rewind_to(rewind); + } else if record.get("id").and_then(|v| v.as_str()).is_some() { + messages.upsert(&record); + } else if let Some(set) = record.get("$set").filter(|v| v.is_object()) { + if set + .get("messages") + .map(|m| m.is_array()) + .unwrap_or(false) + { + messages.clear(); + messages.upsert_all(set.get("messages")); + } + for (k, v) in set.as_object().unwrap() { + metadata.insert(k.clone(), v.clone()); + } + } else if record + .get("sessionId") + .and_then(|v| v.as_str()) + .is_some() + && record + .get("projectHash") + .and_then(|v| v.as_str()) + .is_some() + { + for (k, v) in record.as_object().unwrap() { + metadata.insert(k.clone(), v.clone()); + } + messages.upsert_all(record.get("messages")); + } + } + + // `typeof metadata.sessionId !== "string"` → null (empty passes here, + // then fails the truthy check in buildSession — same as TS). + if !metadata.get("sessionId").and_then(|v| v.as_str()).is_some() { + return None; + } + let mut doc = doc_from_meta(&Value::Object(metadata)); + doc.messages = messages.values(); + Some(doc) +} + +/// `resolveContent` — string stays, Part[] joins `text` fields. +fn resolve_content(content: Option<&Value>) -> String { + match content { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(parts)) => parts + .iter() + .filter_map(|p| p.get("text").and_then(|t| t.as_str())) + .filter(|t| !t.is_empty()) + .collect::>() + .join(""), + _ => String::new(), + } +} + +/// `messageText` — displayContent wins when present. +fn message_text(entry: &Value) -> String { + let display = resolve_content(entry.get("displayContent")); + if !display.is_empty() { + return display; + } + resolve_content(entry.get("content")) +} + +/// `extractSummary` — last user turn's text, truncated; fallback constant. +fn extract_summary(messages: &[Value]) -> String { + for entry in messages.iter().rev() { + if entry.get("type").and_then(|v| v.as_str()) != Some("user") { + continue; + } + let text = message_text(entry).trim().to_string(); + if !text.is_empty() { + return shared::truncate(&text, SUMMARY_MAX_LENGTH); + } + } + SUMMARY_FALLBACK.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replays_upsert_rewind_and_set() { + let log = concat!( + r#"{"sessionId":"s1","projectHash":"abc","startTime":"2026-10-09T07:00:00Z","lastUpdated":"2026-10-09T07:10:00Z","directories":["/p"]}"#, + "\n", + r#"{"id":"m1","type":"user","content":[{"text":"first ask"}],"timestamp":"2026-10-09T07:01:00Z"}"#, + "\n", + r#"{"id":"m2","type":"gemini","content":"answer","timestamp":"2026-10-09T07:02:00Z"}"#, + "\n", + r#"{"id":"m3","type":"user","content":[{"text":"later ask"}],"timestamp":"2026-10-09T07:05:00Z"}"#, + "\n", + r#"{"$rewindTo":"m3"}"#, + "\n", + r#"{"id":"m3b","type":"gemini","displayContent":"rewritten","timestamp":"2026-10-09T07:06:00Z"}"#, + "\n" + ); + let dir = std::env::temp_dir().join(format!("gem-log-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("session-a.jsonl"); + std::fs::write(&f, log).unwrap(); + let s = parse_session(f.to_str().unwrap(), 0).unwrap(); + assert_eq!(s.session_id, "s1"); + assert_eq!(s.project_path, "/p"); + // rewind dropped m3; summary falls back to m1's text + assert_eq!(s.summary, "first ask"); + assert_eq!(s.last_message_type.as_deref(), Some("gemini")); + assert_eq!(s.last_active_ms, 1791529800000); // lastUpdated 07:10 + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn legacy_json_document() { + let dir = std::env::temp_dir().join(format!("gem-doc-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("session-b.json"); + std::fs::write( + &f, + r#"{"sessionId":"s2","projectHash":"h","messages":[{"type":"user","content":[{"text":"hi"}]}],"directories":["/d"],"lastUpdated":"2026-10-09T07:00:00Z"}"#, + ) + .unwrap(); + let s = parse_session(f.to_str().unwrap(), 0).unwrap(); + assert_eq!(s.session_id, "s2"); + assert_eq!(s.summary, "hi"); + assert_eq!(s.last_message_type.as_deref(), Some("user")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn missing_session_id_is_none() { + let dir = std::env::temp_dir().join(format!("gem-noid-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let f = dir.join("session-c.jsonl"); + std::fs::write(&f, r#"{"id":"m1","type":"user"}"#).unwrap(); + assert!(parse_session(f.to_str().unwrap(), 0).is_none()); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/rust/crates/devkit-harness/src/grok/locator.rs b/rust/crates/devkit-harness/src/grok/locator.rs new file mode 100644 index 000000000..e7ec6aedb --- /dev/null +++ b/rust/crates/devkit-harness/src/grok/locator.rs @@ -0,0 +1,118 @@ +//! GrokSessionLocator port — `active_sessions.json` pid→cwd map plus +//! latest-mtime session-dir pick under `sessions//`. + +use super::parser; +use devkit_core::discover::AgentProc; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +const ACTIVE_SESSIONS_FILE: &str = "active_sessions.json"; + +/// One `GrokProcessMatch`: the process plus its resolved cwd and (if any) +/// most recently active session dir for that cwd. +pub struct ProcMatch<'a> { + pub proc: &'a AgentProc, + pub cwd: String, + pub session_dir: Option, +} + +pub struct GrokSessionLocator { + base_dir: PathBuf, + sessions_dir: PathBuf, +} + +impl GrokSessionLocator { + /// `GROK_HOME` overrides `~/.grok` (empty env var falls back, as in TS). + pub fn new(home: &Path) -> Self { + let base_dir = std::env::var("GROK_HOME") + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".grok")); + Self { + sessions_dir: base_dir.join("sessions"), + base_dir, + } + } + + /// `matchRunningProcesses` — registry cwd first, process cwd fallback; + /// an empty resolved cwd yields no session dir. + pub fn match_running<'a>(&self, processes: &[&'a AgentProc]) -> Vec> { + let pid_to_cwd = self.read_active_sessions(); + processes + .iter() + .map(|proc| { + let cwd = pid_to_cwd + .get(&proc.pid) + .cloned() + .or_else(|| proc.cwd.clone()) + .unwrap_or_default(); + let session_dir = if cwd.is_empty() { + None + } else { + self.latest_session_dir(&cwd) + }; + ProcMatch { + proc, + cwd, + session_dir, + } + }) + .collect() + } + + /// `readActiveSessions` — `[{pid, cwd, opened_at}]` → pid→cwd; only + /// entries with a numeric pid and non-empty string cwd count. + fn read_active_sessions(&self) -> HashMap { + let mut map = HashMap::new(); + let Ok(content) = std::fs::read_to_string(self.base_dir.join(ACTIVE_SESSIONS_FILE)) else { + return map; + }; + let Ok(entries) = serde_json::from_str::(&content) else { + return map; + }; + let Some(list) = entries.as_array() else { + return map; + }; + for entry in list { + let (Some(pid), Some(cwd)) = (entry["pid"].as_i64(), entry["cwd"].as_str()) else { + continue; + }; + if cwd.is_empty() { + continue; + } + map.insert(pid, cwd.to_string()); + } + map + } + + /// `latestSessionDir` — the session subdir whose chat_history.jsonl was + /// written last (strict `>`; first wins ties), or None. + fn latest_session_dir(&self, cwd: &str) -> Option { + let group_dir = parser::group_dir_for(&self.sessions_dir, cwd); + if !group_dir.is_dir() { + return None; + } + let mut best: Option<(PathBuf, i64)> = None; + for name in crate::shared::list_dir_names(&group_dir) { + let session_dir = group_dir.join(&name); + if !session_dir.is_dir() { + continue; + } + let Some(mtime) = session_dir + .join(parser::CHAT_HISTORY_FILE) + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + else { + continue; + }; + if best.as_ref().is_none_or(|(_, m)| mtime > *m) { + best = Some((session_dir, mtime)); + } + } + best.map(|(dir, _)| dir) + } +} diff --git a/rust/crates/devkit-harness/src/grok/mod.rs b/rust/crates/devkit-harness/src/grok/mod.rs new file mode 100644 index 000000000..498f2c08c --- /dev/null +++ b/rust/crates/devkit-harness/src/grok/mod.rs @@ -0,0 +1,91 @@ +//! GrokCliAdapter port — active_sessions.json pid→cwd resolution, +//! latest-mtime session-dir pick, chat_history.jsonl summary, +//! process-only fallback. Grok does not consult AgentRegistry or wrappers. + +mod locator; +mod parser; + +use crate::shared; +use crate::{HarnessAdapter, SweepContext}; +use devkit_core::agent::EnrichedAgent; +use devkit_core::discover::AgentProc; +use std::collections::HashSet; + +pub struct GrokAdapter { + locator: locator::GrokSessionLocator, +} + +impl GrokAdapter { + pub fn new(home: &std::path::Path) -> Self { + Self { + locator: locator::GrokSessionLocator::new(home), + } + } + + /// `mapSessionToAgent`. + fn map_session(session: &parser::Session, proc: &AgentProc, now_ms: i64) -> EnrichedAgent { + EnrichedAgent { + name: shared::generate_agent_name(&session.project_path, proc.pid), + agent_type: "grok_cli".into(), + status: parser::determine_status(session, now_ms).into(), + summary: session + .last_user_message + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "Grok CLI session active".into()), + pid: proc.pid as u64, + project_path: session.project_path.clone(), + session_id: session.session_id.clone(), + last_active: shared::iso_utc(session.last_active_ms), + pinned: None, + session_file_path: Some(session.session_file_path.clone()), + } + } +} + +impl HarnessAdapter for GrokAdapter { + fn type_id(&self) -> &'static str { + "grok_cli" + } + + /// `canHandle` — `matchesExecutable(command, "grok")`. + fn can_handle(&self, proc: &AgentProc) -> bool { + shared::matches_executable(proc.command.as_deref().unwrap_or(""), "grok") + } + + fn detect(&self, ctx: &SweepContext) -> Vec { + // `findHarnessProcesses`: processNames pool filter, then canHandle + // deduped by pid (first wins). + let mut seen = HashSet::new(); + let processes: Vec<&AgentProc> = ctx + .processes + .iter() + .filter(|p| { + shared::matches_executable_name(p.command.as_deref().unwrap_or(""), &["grok"]) + }) + .filter(|p| self.can_handle(p) && seen.insert(p.pid)) + .collect(); + if processes.is_empty() { + return Vec::new(); + } + + let mut agents = Vec::new(); + for m in self.locator.match_running(&processes) { + let session = m + .session_dir + .as_deref() + .and_then(|dir| parser::read_session(dir, &m.cwd)); + match session { + Some(s) => agents.push(Self::map_session(&s, m.proc, ctx.now)), + None => agents.push(shared::process_only_agent( + "grok_cli", + m.proc, + "Grok CLI process running", + Some(&m.cwd), + ctx.now, + )), + } + } + agents + } +} diff --git a/rust/crates/devkit-harness/src/grok/parser.rs b/rust/crates/devkit-harness/src/grok/parser.rs new file mode 100644 index 000000000..73b72b719 --- /dev/null +++ b/rust/crates/devkit-harness/src/grok/parser.rs @@ -0,0 +1,160 @@ +//! GrokSessionParser port — chat_history.jsonl O(1) summary fold +//! (IncrementalJsonlSummary cold-start semantics via `fold_jsonl_bounded`). +//! Only the `detectAgents` path is ported. + +use crate::shared; +use serde_json::Value; +use std::path::{Path, PathBuf}; + +/// `chat_history.jsonl` inside each session dir. +pub const CHAT_HISTORY_FILE: &str = "chat_history.jsonl"; + +/// `GrokSession` — the fields `detectAgents`/mapper consume. +pub struct Session { + pub session_id: String, + pub project_path: String, + pub session_file_path: String, + pub last_active_ms: i64, + pub last_user_message: Option, + pub last_role: Option, +} + +/// `GrokSummaryState` — first/last real prompt and last turn role. +#[derive(Default)] +struct SummaryState { + past_head: bool, + first_user_message: Option, + last_user_message: Option, + last_role: Option, +} + +/// `extractUserQuery` — text inside `…`, or None +/// for context injections (no real prompt). +fn extract_user_query(text: &str) -> Option { + const OPEN: &str = ""; + const CLOSE: &str = ""; + let open = text.find(OPEN)? + OPEN.len(); + let close = text[open..].find(CLOSE)? + open; + Some(text[open..close].trim().to_string()) +} + +/// `toMessage(value, verbose=false)` — record → (role, content); context +/// injections, empty assistant text and non-message types fold to None. +fn to_message(value: &Value) -> Option<(&'static str, String)> { + if !value.is_object() { + return None; + } + let text = shared::flatten_text_blocks(value.get("content").unwrap_or(&Value::Null)); + match value.get("type").and_then(Value::as_str) { + Some("user") => extract_user_query(&text).map(|q| ("user", q)), + Some("assistant") if !text.is_empty() => Some(("assistant", text)), + _ => None, + } +} + +/// `summaryReducer.reduce` — user turns track first/last prompt and set the +/// role; assistant turns only move the role. +fn reduce(mut state: SummaryState, value: Option<&Value>) -> SummaryState { + let Some((role, content)) = value.and_then(to_message) else { + return state; + }; + if role == "user" { + state.last_user_message = Some(content.clone()); + state.last_role = Some("user".into()); + if state.first_user_message.is_none() && !state.past_head { + state.first_user_message = Some(content); + } + } else { + state.last_role = Some(role.into()); + } + state +} + +/// `summaryReducer.skip` — keep the head's first prompt only. +fn skip(state: SummaryState) -> SummaryState { + SummaryState { + past_head: true, + first_user_message: state.first_user_message, + ..SummaryState::default() + } +} + +fn mtime_ms(path: &Path) -> Option { + let m = std::fs::metadata(path).ok()?.modified().ok()?; + Some(m.duration_since(std::time::UNIX_EPOCH).ok()?.as_millis() as i64) +} + +/// `readSessionIncremental` for a cold read: bounded fold of +/// chat_history.jsonl; a present-but-empty transcript still surfaces a +/// session (with no summary); a missing transcript yields None. +pub fn read_session(session_dir: &Path, default_cwd: &str) -> Option { + let chat_path = session_dir.join(CHAT_HISTORY_FILE); + let chat_mtime = mtime_ms(&chat_path)?; + let state = shared::fold_jsonl_bounded( + &chat_path.to_string_lossy(), + SummaryState::default(), + reduce, + skip, + ) + .unwrap_or_default(); + Some(Session { + session_id: session_dir + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(), + project_path: default_cwd.to_string(), + session_file_path: chat_path.to_string_lossy().into_owned(), + last_active_ms: chat_mtime, + last_user_message: state.last_user_message, + last_role: state.last_role, + }) +} + +/// `determineStatus` — idle(5min) > last turn assistant (waiting) > running. +pub fn determine_status(session: &Session, now_ms: i64) -> &'static str { + if shared::is_idle(session.last_active_ms, now_ms) { + return "idle"; + } + if session.last_role.as_deref() == Some("assistant") { + return "waiting"; + } + "running" +} + +/// Group dir for a cwd: `sessions//`. +pub fn group_dir_for(sessions_dir: &Path, cwd: &str) -> PathBuf { + sessions_dir.join(shared::encode_uri_component(cwd)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn user_query_extraction() { + assert_eq!( + extract_user_query("pre hello world post"), + Some("hello world".into()) + ); + assert_eq!(extract_user_query(""), Some("".into())); + assert_eq!(extract_user_query("ctx"), None); + assert_eq!(extract_user_query("unclosed"), None); + } + + #[test] + fn to_message_roles() { + let u = json!({"type":"user","content":"hi"}); + assert_eq!(to_message(&u), Some(("user", "hi".into()))); + let ctx = json!({"type":"user","content":"x"}); + assert_eq!(to_message(&ctx), None); + let a = json!({"type":"assistant","content":[{"text":"one"},{"text":"two"}]}); + assert_eq!(to_message(&a), Some(("assistant", "onetwo".into()))); + let empty = json!({"type":"assistant","content":""}); + assert_eq!(to_message(&empty), None); + let sys = json!({"type":"system","content":"s"}); + assert_eq!(to_message(&sys), None); + let nonobj = json!("text"); + assert_eq!(to_message(&nonobj), None); + } +} diff --git a/rust/crates/devkit-harness/src/kiro/locator.rs b/rust/crates/devkit-harness/src/kiro/locator.rs new file mode 100644 index 000000000..166a1cd78 --- /dev/null +++ b/rust/crates/devkit-harness/src/kiro/locator.rs @@ -0,0 +1,120 @@ +//! Port of `KiroSessionLocator` — `.lock` discovery under +//! `~/.kiro/sessions/cli`, lock-pid → outermost-Kiro-ancestor resolution, +//! and the sole-Kiro-on-tty fallback. + +use devkit_core::discover::AgentProc; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +/// One live Kiro process and the session whose lock it (or its descendant) +/// holds (`KiroProcessMatch`). +pub struct LockMatch<'a> { + pub process: &'a AgentProc, + pub session_id: String, +} + +/// `matchRunningProcesses` — pair `.lock` files with running Kiro +/// processes. `snapshot` is the full process-name pool (helpers included, +/// needed to walk up from `kiro-cli-chat acp`); `kiro_processes` are the +/// canHandle'd agents; `is_kiro` is the adapter's canHandle. +pub fn match_running_processes<'a>( + sessions_dir: &Path, + snapshot: &[&'a AgentProc], + kiro_processes: &[&'a AgentProc], + is_kiro: impl Fn(&AgentProc) -> bool, +) -> Vec> { + let by_pid: HashMap = snapshot.iter().map(|p| (p.pid, *p)).collect(); + let mut matches = Vec::new(); + + for (session_id, pid) in discover_active_locks(sessions_dir) { + let proc = find_kiro_ancestor(pid, &by_pid, &is_kiro).or_else(|| { + match_sole_process_on_tty( + by_pid.get(&pid).and_then(|p| p.tty.as_deref()), + kiro_processes, + ) + }); + if let Some(proc) = proc { + matches.push(LockMatch { process: proc, session_id }); + } + } + matches +} + +/// `discoverActiveLocks` — `.lock` files in (sorted) readdir order; each +/// holds a JSON object whose `pid` names the holder. +fn discover_active_locks(sessions_dir: &Path) -> Vec<(String, i64)> { + let mut locks = Vec::new(); + for name in crate::shared::list_dir_names(sessions_dir) { + let Some(session_id) = name.strip_suffix(".lock") else { + continue; + }; + let Ok(content) = std::fs::read_to_string(sessions_dir.join(&name)) else { + continue; + }; + let Ok(json) = serde_json::from_str::(&content) else { + continue; + }; + if let Some(pid) = json.get("pid").and_then(to_pid) { + locks.push((session_id.to_string(), pid)); + } + } + locks +} + +/// `findKiroAncestor` — walk up the lock pid's parent chain through the +/// snapshot; the outermost Kiro process wins. +fn find_kiro_ancestor<'a>( + lock_pid: i64, + by_pid: &HashMap, + is_kiro: &impl Fn(&AgentProc) -> bool, +) -> Option<&'a AgentProc> { + let mut current = by_pid.get(&lock_pid).copied(); + let mut seen = HashSet::new(); + let mut resolved = None; + while let Some(c) = current { + if !seen.insert(c.pid) { + break; + } + if is_kiro(c) { + resolved = Some(c); + } + current = c.ppid.and_then(|pp| by_pid.get(&pp).copied()); + } + resolved +} + +/// `matchSoleProcessOnTty` — when the chain is broken, attach to the only +/// Kiro process on the lock holder's terminal. +fn match_sole_process_on_tty<'a>( + tty: Option<&str>, + kiro_processes: &[&'a AgentProc], +) -> Option<&'a AgentProc> { + let tty = tty?; + if tty.is_empty() || tty == "??" || tty == "?" { + return None; + } + let mut it = kiro_processes + .iter() + .filter(|p| p.tty.as_deref() == Some(tty)); + let first = *it.next()?; + if it.next().is_some() { + return None; + } + Some(first) +} + +/// `toPid` — a safe positive integer, numeric or all-digits string. +fn to_pid(value: &Value) -> Option { + const MAX_SAFE: f64 = 9_007_199_254_740_991.0; + match value { + Value::Number(n) => { + let f = n.as_f64()?; + (f.fract() == 0.0 && f > 0.0 && f <= MAX_SAFE).then_some(f as i64) + } + Value::String(s) if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) => { + s.parse::().ok().filter(|p| *p > 0 && *p <= 9_007_199_254_740_991) + } + _ => None, + } +} diff --git a/rust/crates/devkit-harness/src/kiro/mod.rs b/rust/crates/devkit-harness/src/kiro/mod.rs new file mode 100644 index 000000000..fe317fae1 --- /dev/null +++ b/rust/crates/devkit-harness/src/kiro/mod.rs @@ -0,0 +1,178 @@ +//! Port of `packages/agent-manager/src/harnesses/kiro` — Kiro CLI detection. +//! +//! Flow (mirrors `detectRunningAgents`): +//! 1. pool-filter the snapshot to the process names (`kiro-cli`, `kiro`, +//! `kiro-cli-chat`, `node`, `bun` — helpers stay in `relevant` so the +//! lock-pid ancestor walk can traverse them), then canHandle + dedupe +//! 2. `matchRunningProcesses`: `.lock` files → outermost Kiro ancestor +//! of the lock holder, else the sole Kiro process on the holder's tty +//! 3. each match whose `.jsonl` transcript exists maps to an agent — +//! one proc may hold several locks and so produce several agents +//! 4. processes with no lock → process-only agents + +mod locator; +mod parser; + +use crate::shared; +use crate::{EnrichedAgent, HarnessAdapter, SweepContext}; +use devkit_core::discover::AgentProc; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +/// `processNames` — the pool `findHarnessProcesses` filters the snapshot to. +const PROCESS_NAMES: &[&str] = &["kiro-cli", "kiro", "kiro-cli-chat", "node", "bun"]; + +pub struct KiroAdapter { + sessions_dir: PathBuf, +} + +impl KiroAdapter { + pub fn new(home: &Path) -> Self { + Self { + sessions_dir: home.join(".kiro").join("sessions").join("cli"), + } + } + + /// Sessions-dir override for fixture replay (no `~` involved). + pub fn at_sessions_dir(dir: impl Into) -> Self { + Self { + sessions_dir: dir.into(), + } + } + + /// `isKiroExecutable` — `kiro-cli`/`kiro` directly, or as a script run + /// by node/bun (`node /path/kiro-cli.js`). + fn is_kiro_executable(command: &str) -> bool { + let trimmed = command.trim(); + let executable = shared::executable_path(trimmed); + if executable.is_empty() { + return false; + } + let name = kiro_basename(&executable); + if name == "kiro-cli" || name == "kiro" { + return true; + } + if name != "node" && name != "bun" { + return false; + } + // Script runtime: first non-flag token starts the script path. + let rest = trimmed.get(executable.len()..).unwrap_or("").trim(); + let tokens: Vec<&str> = rest.split_whitespace().collect(); + let Some(idx) = tokens.iter().position(|t| !t.starts_with('-')) else { + return false; + }; + let script = shared::executable_path(&tokens[idx..].join(" ")); + let script_name = kiro_basename(&script); + script_name == "kiro-cli" || script_name == "kiro" + } + + fn can_handle_proc(&self, proc: &AgentProc) -> bool { + proc.command + .as_deref() + .map(Self::is_kiro_executable) + .unwrap_or(false) + } + + /// `mapSessionToAgent` — name from projectPath, status from transcript + /// summary, lastActive = metadata/last timestamp/mtime. + fn map_session_to_agent( + &self, + session: &parser::KiroSession, + proc: &AgentProc, + now: i64, + ) -> EnrichedAgent { + let summary = [ + session.last_user_message.as_deref().unwrap_or(""), + session.title.as_str(), + "Kiro session active", + ] + .into_iter() + .find(|s| !s.is_empty()) + .unwrap(); + EnrichedAgent { + name: shared::generate_agent_name(&session.project_path, proc.pid), + agent_type: "kiro".into(), + status: parser::status(session, now).into(), + summary: shared::truncate(summary, 120), + pid: proc.pid as u64, + project_path: session.project_path.clone(), + session_id: session.session_id.clone(), + last_active: shared::iso_utc(session.last_active_ms), + pinned: None, + session_file_path: Some(session.transcript_path.clone()), + } + } +} + +/// `kiroBasename` — basename after '\'→'/', lowercased, `.exe`/`.js` stripped. +fn kiro_basename(executable: &str) -> String { + let b = shared::path_basename(executable); + b.strip_suffix(".exe") + .or_else(|| b.strip_suffix(".js")) + .unwrap_or(&b) + .to_string() +} + +impl HarnessAdapter for KiroAdapter { + fn type_id(&self) -> &'static str { + "kiro" + } + + fn can_handle(&self, proc: &AgentProc) -> bool { + self.can_handle_proc(proc) + } + + fn detect(&self, ctx: &SweepContext) -> Vec { + // `findHarnessProcesses`: `relevant` = process-names pool (helpers + // included — the ppid walk needs them); `processes` = canHandle'd, + // first-wins by pid. + let relevant: Vec<&AgentProc> = ctx + .processes + .iter() + .filter(|p| { + p.command + .as_deref() + .map(|c| shared::matches_executable_name(c, PROCESS_NAMES)) + .unwrap_or(false) + }) + .collect(); + let mut seen = HashSet::new(); + let processes: Vec<&AgentProc> = relevant + .iter() + .copied() + .filter(|p| self.can_handle(p) && seen.insert(p.pid)) + .collect(); + if processes.is_empty() { + return Vec::new(); + } + + let mut agents = Vec::new(); + let mut matched_pids = HashSet::new(); + for m in locator::match_running_processes( + &self.sessions_dir, + &relevant, + &processes, + |p| self.can_handle_proc(p), + ) { + let cwd = m.process.cwd.as_deref().unwrap_or(""); + let Some(session) = parser::read_session(&self.sessions_dir, &m.session_id, cwd) + else { + continue; + }; + agents.push(self.map_session_to_agent(&session, m.process, ctx.now)); + matched_pids.insert(m.process.pid); + } + for proc in &processes { + if !matched_pids.contains(&proc.pid) { + agents.push(shared::process_only_agent( + "kiro", + proc, + "Kiro process running", + None, + ctx.now, + )); + } + } + agents + } +} diff --git a/rust/crates/devkit-harness/src/kiro/parser.rs b/rust/crates/devkit-harness/src/kiro/parser.rs new file mode 100644 index 000000000..02aff7dd4 --- /dev/null +++ b/rust/crates/devkit-harness/src/kiro/parser.rs @@ -0,0 +1,217 @@ +//! Port of `KiroSessionParser` — the bounded JSONL summary fold behind +//! `readSessionIncremental`, the `.json` metadata read, and +//! `determineStatus`. Conversation/tail reads are not ported. + +use crate::shared; +use serde_json::{Map, Value}; +use std::path::{Path, PathBuf}; + +/// `KiroSummaryState` — O(1) fold state, never the entries. +#[derive(Default)] +struct KiroSummary { + past_head: bool, + first_user_message: Option, + last_user_message: Option, + first_ts_ms: Option, + last_ts_ms: Option, + last_event_kind: Option, + last_assistant_has_tool_use: bool, +} + +/// What `detect` needs from a session (`KiroSession`, minus fields absent +/// from the wire output). +pub struct KiroSession { + pub session_id: String, + pub project_path: String, + pub transcript_path: String, + pub title: String, + pub last_user_message: Option, + pub last_active_ms: i64, + pub last_event_kind: Option, + pub last_assistant_has_tool_use: bool, +} + +struct Metadata { + session_id: Option, + cwd: Option, + title: Option, + updated_at: Option, +} + +/// `readSessionIncremental` — transcript must exist; an unreadable one +/// yields an empty summary (TS `{}`), never drops the session. +pub fn read_session( + sessions_dir: &Path, + session_id: &str, + fallback_cwd: &str, +) -> Option { + let transcript = sessions_dir.join(format!("{session_id}.jsonl")); + let meta = std::fs::metadata(&transcript).ok()?; + if !meta.is_file() { + return None; + } + let transcript_s = transcript.to_string_lossy().into_owned(); + let state = shared::fold_jsonl_bounded( + &transcript_s, + KiroSummary::default(), + reduce, + |s: KiroSummary| KiroSummary { + past_head: true, + first_user_message: s.first_user_message, + first_ts_ms: s.first_ts_ms, + ..KiroSummary::default() + }, + ) + .unwrap_or_default(); + + let metadata = read_metadata(&sessions_dir.join(format!("{session_id}.json"))); + let last_active_ms = metadata + .updated_at + .or(state.last_ts_ms) + .or_else(|| { + meta.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + }) + .unwrap_or(0); + + Some(KiroSession { + session_id: metadata.session_id.unwrap_or_else(|| session_id.to_string()), + project_path: match metadata.cwd { + Some(cwd) if !cwd.is_empty() => cwd, + _ => fallback_cwd.to_string(), + }, + transcript_path: transcript_s, + title: metadata.title.unwrap_or_default(), + last_user_message: state.last_user_message, + last_active_ms, + last_event_kind: state.last_event_kind, + last_assistant_has_tool_use: state.last_assistant_has_tool_use, + }) +} + +/// `determineStatus`: past the 5-min idle mark → idle; else an assistant +/// reply without a tool call → waiting; everything else → running. +pub fn status(session: &KiroSession, now_ms: i64) -> &'static str { + if shared::is_idle(session.last_active_ms, now_ms) { + "idle" + } else if session.last_event_kind.as_deref() == Some("AssistantMessage") + && !session.last_assistant_has_tool_use + { + "waiting" + } else { + "running" + } +} + +/// `summaryReducer.reduce` — non-object lines ignored; every object line +/// resets the latest-event fields even when it is not a message. +fn reduce(mut state: KiroSummary, value: Option<&Value>) -> KiroSummary { + let Some(entry) = value.and_then(|v| v.as_object()) else { + return state; + }; + let kind = entry.get("kind").and_then(|v| v.as_str()); + state.last_event_kind = kind.map(|k| k.to_string()); + state.last_assistant_has_tool_use = + kind == Some("AssistantMessage") && has_content_kind(entry, "toolUse"); + + if let Some(ts) = entry_timestamp_ms(entry) { + state.last_ts_ms = Some(ts); + if state.first_ts_ms.is_none() && !state.past_head { + state.first_ts_ms = Some(ts); + } + } + + // `entryToMessage(entry, false)` — only a non-empty Prompt is a user msg. + if kind == Some("Prompt") { + let content = text_content(entry); + if !content.is_empty() { + state.last_user_message = Some(content.clone()); + if state.first_user_message.is_none() && !state.past_head { + state.first_user_message = Some(content); + } + } + } + state +} + +/// `contentBlocks` — `data.content` array, object blocks only. +fn content_blocks(entry: &Map) -> &[Value] { + entry + .get("data") + .and_then(|d| d.get("content")) + .and_then(|c| c.as_array()) + .map(|a| a.as_slice()) + .unwrap_or(&[]) +} + +fn has_content_kind(entry: &Map, kind: &str) -> bool { + content_blocks(entry) + .iter() + .any(|b| b.get("kind").and_then(|k| k.as_str()) == Some(kind)) +} + +/// `textContent` — `kind === "text"` blocks' string `data`, joined. +fn text_content(entry: &Map) -> String { + content_blocks(entry) + .iter() + .filter(|b| b.get("kind").and_then(|k| k.as_str()) == Some("text")) + .map(|b| b.get("data").and_then(|d| d.as_str()).unwrap_or("")) + .filter(|s| !s.is_empty()) + .collect::>() + .join("") +} + +/// `entryTimestamp` folded to ms — the line's own non-empty string +/// timestamp, else `data.meta.timestamp` (epoch s/ms or ISO). +fn entry_timestamp_ms(entry: &Map) -> Option { + if let Some(s) = entry + .get("timestamp") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + return shared::parse_iso_ms(s); + } + shared::parse_timestamp_ms( + entry + .get("data") + .and_then(|d| d.get("meta")) + .and_then(|m| m.get("timestamp"))?, + ) +} + +/// `readMetadata` — unparseable/absent metadata keeps every field at its +/// fallback (filename-derived id, empty cwd/title). +fn read_metadata(path: &PathBuf) -> Metadata { + let parsed = std::fs::read_to_string(path) + .ok() + .and_then(|c| serde_json::from_str::(&c).ok()); + let Some(obj) = parsed.as_ref().and_then(|v| v.as_object()) else { + return Metadata { + session_id: None, + cwd: None, + title: None, + updated_at: None, + }; + }; + let first_str = |keys: &[&str]| { + keys.iter() + .filter_map(|k| obj.get(*k).and_then(|v| v.as_str())) + .find(|s| !s.is_empty()) + .map(|s| s.to_string()) + }; + Metadata { + session_id: first_str(&["session_id", "sessionId"]), + cwd: first_str(&["cwd"]), + title: first_str(&["title"]), + // `created_at`/`createdAt` feed only sessionStart, which is not in + // the wire output — parsed but not carried (mirrors `??` fallback: + // null/absent skips to the camelCase key). + updated_at: obj + .get("updated_at") + .filter(|v| !v.is_null()) + .or_else(|| obj.get("updatedAt")) + .and_then(shared::parse_timestamp_ms), + } +} diff --git a/rust/crates/devkit-harness/src/lib.rs b/rust/crates/devkit-harness/src/lib.rs new file mode 100644 index 000000000..2e5ecb559 --- /dev/null +++ b/rust/crates/devkit-harness/src/lib.rs @@ -0,0 +1,255 @@ +//! Harness knowledge port: per-harness process→session attribution and +//! enrichment, ported from packages/agent-manager adapters. The daemon owns +//! this so every client renders identical agent state. + +use devkit_core::agent::EnrichedAgent; +use devkit_core::discover::AgentProc; + +pub mod antigravity; +pub mod claude; +pub mod codex; +pub mod copilot; +pub mod devin; +#[cfg(test)] +mod fixtures; +pub mod gemini; +pub mod grok; +pub mod kiro; +pub mod opencode; +pub mod pi; +pub mod shared; + +/// Build a registry with every ported adapter for `home` registered. +pub fn default_registry(home: &std::path::Path) -> Registry { + let mut r = Registry::new(); + r.register(Box::new(claude::ClaudeAdapter::new(home))); + r.register(Box::new(codex::CodexAdapter::new(home))); + r.register(Box::new(pi::PiAdapter::new(home))); + r.register(Box::new(gemini::GeminiAdapter::new(home))); + r.register(Box::new(copilot::CopilotAdapter::new(home))); + r.register(Box::new(grok::GrokAdapter::new(home))); + r.register(Box::new(opencode::OpenCodeAdapter::new(home))); + r.register(Box::new(devin::DevinAdapter::new(home))); + r.register(Box::new(kiro::KiroAdapter::new(home))); + r.register(Box::new(antigravity::AntigravityCliAdapter::new(home))); + r +} + +/// One discovery sweep's inputs, shared across all adapters. `now` is frozen +/// per sweep so status derivation is consistent within a refresh. `home` is +/// the user home dir adapters resolve session trees against. +pub struct SweepContext<'a> { + pub processes: &'a [AgentProc], + pub now: i64, + pub home: &'a std::path::Path, +} + +/// One ported harness adapter — the Rust mirror of the TS `AgentAdapter` +/// surface used by `listAgents` (canHandle + detectAgents). +pub trait HarnessAdapter: Send + Sync { + /// Harness type id, matching the TS `AgentType` wire value + /// ("claude", "codex", "gemini_cli", ...). + fn type_id(&self) -> &'static str; + /// Whether this process belongs to the harness. + fn can_handle(&self, proc: &AgentProc) -> bool; + /// Attribute+enrich this sweep's processes into `AgentInfo` rows. + fn detect(&self, ctx: &SweepContext) -> Vec; +} + +/// All ported adapters. `enrich` aggregates each sweep into the full agent +/// list — the daemon is authoritative for every registered type, so a +/// successful answer means clients skip local adapters entirely. +#[derive(Default)] +pub struct Registry { + adapters: Vec>, +} + +impl Registry { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, adapter: Box) { + self.adapters.push(adapter); + } + + pub fn ported_types(&self) -> Vec { + self.adapters + .iter() + .map(|a| a.type_id().to_string()) + .collect() + } + + /// Whether any registered adapter claims this process — the daemon's + /// candidate gate for script-runtime processes (mirrors TS + /// `isCandidateProcess`). + pub fn any_can_handle(&self, proc: &AgentProc) -> bool { + self.adapters.iter().any(|a| a.can_handle(proc)) + } + + pub fn enrich(&self, ctx: &SweepContext) -> Vec { + self.adapters.iter().flat_map(|a| a.detect(ctx)).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockAdapter; + + impl HarnessAdapter for MockAdapter { + fn type_id(&self) -> &'static str { + "claude" + } + fn can_handle(&self, proc: &AgentProc) -> bool { + proc.command.as_deref() == Some("claude") + } + fn detect(&self, ctx: &SweepContext) -> Vec { + ctx.processes + .iter() + .filter(|p| self.can_handle(p)) + .map(|p| EnrichedAgent { + name: "mock".into(), + agent_type: self.type_id().into(), + status: "running".into(), + summary: String::new(), + pid: p.pid as u64, + project_path: p.cwd.clone().unwrap_or_default(), + session_id: format!("pid-{}", p.pid), + last_active: "2026-10-08T00:00:00.000Z".into(), + pinned: None, + session_file_path: None, + }) + .collect() + } + } + + fn proc(command: &str) -> AgentProc { + AgentProc { + pid: 7, + ppid: None, + tty: None, + command: Some(command.into()), + cwd: Some("/proj".into()), + session_file: None, + start_time_ms: None, + } + } + + #[test] + fn registry_reports_ported_types() { + let mut r = Registry::new(); + assert!(r.ported_types().is_empty()); + r.register(Box::new(MockAdapter)); + assert_eq!(r.ported_types(), vec!["claude"]); + } + + #[test] + fn enrich_aggregates_matching_processes() { + let mut r = Registry::new(); + r.register(Box::new(MockAdapter)); + let procs = vec![proc("claude"), proc("codex")]; + let ctx = SweepContext { + processes: &procs, + now: 0, + home: std::path::Path::new("/"), + }; + let out = r.enrich(&ctx); + assert_eq!(out.len(), 1); + assert_eq!(out[0].agent_type, "claude"); + assert_eq!(out[0].project_path, "/proj"); + } + + #[test] + fn claude_adapter_matches_committed_fixtures() { + let n = crate::fixtures::assert_parity("claude", |home| { + Box::new(crate::claude::ClaudeAdapter::new(home)) + }); + assert!(n > 0, "no claude fixture bundles found"); + } + + #[test] + fn codex_adapter_matches_committed_fixtures() { + let n = crate::fixtures::assert_parity("codex", |home| { + Box::new(crate::codex::CodexAdapter::new(home)) + }); + assert!(n > 0, "no codex fixture bundles found"); + } + + #[test] + fn pi_adapter_matches_committed_fixtures() { + let n = crate::fixtures::assert_parity("pi", |home| { + Box::new(crate::pi::PiAdapter::new(home)) + }); + assert!(n > 0, "no pi fixture bundles found"); + } + + #[test] + fn gemini_adapter_matches_committed_fixtures() { + let n = crate::fixtures::assert_parity("gemini_cli", |home| { + Box::new(crate::gemini::GeminiAdapter::new(home)) + }); + assert!(n > 0, "no gemini fixture bundles found"); + } + + #[test] + fn copilot_adapter_matches_committed_fixtures() { + let n = crate::fixtures::assert_parity("copilot", |home| { + Box::new(crate::copilot::CopilotAdapter::new(home)) + }); + assert!(n > 0, "no copilot fixture bundles found"); + } + + #[test] + fn grok_adapter_matches_committed_fixtures() { + let n = crate::fixtures::assert_parity("grok_cli", |home| { + Box::new(crate::grok::GrokAdapter::new(home)) + }); + assert!(n > 0, "no grok fixture bundles found"); + } + + #[test] + fn opencode_adapter_matches_committed_fixtures() { + // Pin the canonical db path — ambient XDG_DATA_HOME must not leak + // the real opencode.db into replay. + let n = crate::fixtures::assert_parity("opencode", |home| { + Box::new(crate::opencode::OpenCodeAdapter::at_db( + home.join(".local/share/opencode/opencode.db"), + )) + }); + assert!(n > 0, "no opencode fixture bundles found"); + } + + #[test] + fn devin_adapter_matches_committed_fixtures() { + let n = crate::fixtures::assert_parity("devin", |home| { + Box::new(crate::devin::DevinAdapter::at_db( + home.join(".local/share/devin/cli/sessions.db"), + )) + }); + assert!(n > 0, "no devin fixture bundles found"); + } + + #[test] + fn kiro_adapter_matches_committed_fixtures() { + let n = crate::fixtures::assert_parity("kiro", |home| { + Box::new(crate::kiro::KiroAdapter::at_sessions_dir( + home.join(".kiro/sessions/cli"), + )) + }); + assert!(n > 0, "no kiro fixture bundles found"); + } + + #[test] + fn antigravity_adapter_matches_committed_fixtures() { + // Pin the canonical base dir — ambient ANTIGRAVITY_CLI_HOME must + // not leak the real registry into replay. + let n = crate::fixtures::assert_parity("antigravity_cli", |home| { + Box::new(crate::antigravity::AntigravityCliAdapter::at_base_dir( + home.join(".gemini/antigravity-cli"), + )) + }); + assert!(n > 0, "no antigravity fixture bundles found"); + } +} diff --git a/rust/crates/devkit-harness/src/opencode/locator.rs b/rust/crates/devkit-harness/src/opencode/locator.rs new file mode 100644 index 000000000..6944c07eb --- /dev/null +++ b/rust/crates/devkit-harness/src/opencode/locator.rs @@ -0,0 +1,60 @@ +//! OpenCodeSessionLocator port — `opencode.db` session lookup by directory. + +use rusqlite::Connection; +use std::path::{Path, PathBuf}; + +/// `::` session ref separator. +pub const SESSION_REF_SEP: &str = "::"; + +/// `OpenCodeSession` — id + directory + time_created. +pub struct Session { + pub session_id: String, + pub directory: String, + pub time_created_ms: i64, +} + +/// `resolveDbPath` — `XDG_DATA_HOME` (empty falls back) or +/// `~/.local/share`, plus `opencode/opencode.db`. +pub fn resolve_db_path(home: &Path) -> PathBuf { + let xdg = std::env::var("XDG_DATA_HOME") + .ok() + .filter(|s| !s.is_empty()); + resolve_db_path_with(xdg.as_deref(), home) +} + +/// Env-free core of `resolveDbPath` — testable without mutating process env. +pub fn resolve_db_path_with(xdg_data_home: Option<&str>, home: &Path) -> PathBuf { + let base = xdg_data_home + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local").join("share")); + base.join("opencode").join("opencode.db") +} + +/// `openDb` — readonly open; missing/unopenable db yields None. +pub fn open_db(db_path: &Path) -> Option { + if !db_path.exists() { + return None; + } + Connection::open_with_flags( + db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .ok() +} + +/// `findSessionForDirectory` — newest session row for the directory. +pub fn find_session_for_directory(db: &Connection, directory: &str) -> Option { + db.query_row( + "SELECT id, directory, time_created FROM session + WHERE directory = ?1 ORDER BY time_created DESC LIMIT 1", + [directory], + |r| { + Ok(Session { + session_id: r.get::<_, String>(0)?, + directory: r.get::<_, String>(1)?, + time_created_ms: r.get::<_, i64>(2)?, + }) + }, + ) + .ok() +} diff --git a/rust/crates/devkit-harness/src/opencode/mod.rs b/rust/crates/devkit-harness/src/opencode/mod.rs new file mode 100644 index 000000000..9858c1f22 --- /dev/null +++ b/rust/crates/devkit-harness/src/opencode/mod.rs @@ -0,0 +1,159 @@ +//! OpenCodeAdapter port — `packages/agent-manager/src/harnesses/opencode`. +//! +//! `opencode` process detection + session attribution via +//! `~/.local/share/opencode/opencode.db` (or `$XDG_DATA_HOME`): each proc's +//! cwd maps to the newest `session` row for that directory; stats (last +//! role / heartbeat / completion / first user text) come from message+part +//! queries. Missing db, cwd, or session row → process-only fallback. + +mod locator; +mod parser; + +use crate::shared; +use crate::{HarnessAdapter, SweepContext}; +use devkit_core::agent::EnrichedAgent; +use devkit_core::discover::AgentProc; +use std::collections::HashSet; +use std::path::PathBuf; + +pub struct OpenCodeAdapter { + db_path: PathBuf, +} + +impl OpenCodeAdapter { + /// Env-aware constructor — resolves `$XDG_DATA_HOME` like the TS + /// adapter's default `resolveDbPath`. + pub fn new(home: &std::path::Path) -> Self { + Self::at_db(locator::resolve_db_path(home)) + } + + /// Explicit db path — fixture replay pins the canonical + /// `~/.local/share/opencode/opencode.db` so ambient `XDG_DATA_HOME` + /// cannot leak the real store into a test. + pub fn at_db(db_path: PathBuf) -> Self { + Self { db_path } + } + + /// `mapSessionToAgent`. + fn map_session( + &self, + session: &locator::Session, + stats: &parser::SessionStats, + proc: &AgentProc, + now_ms: i64, + ) -> EnrichedAgent { + let last_active_ms = if stats.last_time_updated_ms > 0 { + stats.last_time_updated_ms + } else { + session.time_created_ms + }; + let directory = if !session.directory.is_empty() { + session.directory.clone() + } else { + proc.cwd.clone().unwrap_or_default() + }; + EnrichedAgent { + name: shared::generate_agent_name(&directory, proc.pid), + agent_type: "opencode".into(), + status: determine_status(stats, last_active_ms, now_ms).into(), + summary: if stats.summary.is_empty() { + "OpenCode session active".into() + } else { + stats.summary.clone() + }, + pid: proc.pid as u64, + project_path: directory, + session_id: session.session_id.clone(), + last_active: shared::iso_utc(last_active_ms), + pinned: None, + session_file_path: Some(format!( + "{}{}{}", + self.db_path.display(), + locator::SESSION_REF_SEP, + session.session_id + )), + } + } +} + +impl HarnessAdapter for OpenCodeAdapter { + fn type_id(&self) -> &'static str { + "opencode" + } + + /// `canHandle` — `matchesExecutable(command, "opencode")`. + fn can_handle(&self, proc: &AgentProc) -> bool { + shared::matches_executable(proc.command.as_deref().unwrap_or(""), "opencode") + } + + fn detect(&self, ctx: &SweepContext) -> Vec { + // `findHarnessProcesses`: processNames pool filter, then canHandle + // deduped by pid (first wins). + let mut seen = HashSet::new(); + let processes: Vec<&AgentProc> = ctx + .processes + .iter() + .filter(|p| { + shared::matches_executable_name( + p.command.as_deref().unwrap_or(""), + &["opencode"], + ) + }) + .filter(|p| self.can_handle(p) && seen.insert(p.pid)) + .collect(); + if processes.is_empty() { + return Vec::new(); + } + + let Some(db) = locator::open_db(&self.db_path) else { + return processes + .into_iter() + .map(|proc| { + shared::process_only_agent( + "opencode", + proc, + "OpenCode process running", + None, + ctx.now, + ) + }) + .collect(); + }; + + let mut agents = Vec::new(); + for proc in processes { + let session = proc + .cwd + .as_deref() + .and_then(|cwd| locator::find_session_for_directory(&db, cwd)); + match session { + Some(session) => { + let stats = parser::get_session_stats(&db, &session.session_id); + agents.push(self.map_session(&session, &stats, proc, ctx.now)); + } + None => agents.push(shared::process_only_agent( + "opencode", + proc, + "OpenCode process running", + None, + ctx.now, + )), + } + } + agents + } +} + +/// `determineStatus` — idle(5min) short-circuits; last assistant role: +/// not-completed → running, completed → waiting; anything else running. +/// (TS's `lastAssistantErrored` flag is likewise unused there.) +fn determine_status(stats: &parser::SessionStats, last_active_ms: i64, now_ms: i64) -> &'static str { + if shared::is_idle(last_active_ms, now_ms) { + return "idle"; + } + match stats.last_role.as_deref() { + Some("assistant") if !stats.last_assistant_completed => "running", + Some("assistant") => "waiting", + _ => "running", + } +} diff --git a/rust/crates/devkit-harness/src/opencode/parser.rs b/rust/crates/devkit-harness/src/opencode/parser.rs new file mode 100644 index 000000000..3efd0c729 --- /dev/null +++ b/rust/crates/devkit-harness/src/opencode/parser.rs @@ -0,0 +1,113 @@ +//! OpenCodeSessionParser port — `getSessionStats` queries over +//! message/part rows (detectAgents path only). + +use rusqlite::types::Value; +use rusqlite::Connection; + +/// `OpenCodeSessionStats` — last role, heartbeat, completion flag, first +/// user text (the summary). TS also reads `lastAssistantErrored`, but it +/// is never consumed by `determineStatus`, so it is dropped here. +#[derive(Default)] +pub struct SessionStats { + pub last_role: Option, + pub last_time_updated_ms: i64, + pub last_assistant_completed: bool, + pub summary: String, +} + +/// A first user-text row: absent, a string, or a non-string value (whose +/// `.trim()` would throw in JS → the catch resets all stats). +enum FirstText { + Absent, + Text(String), + NonString, +} + +/// `getSessionStats` — message.last by time_created; heartbeat +/// MAX(time_updated); last assistant completion/error flags; first user +/// text part. The TS body is a single try/catch: any query error — or a +/// non-string first text (`.trim` throws) — yields EMPTY_STATS. +pub fn get_session_stats(db: &Connection, session_id: &str) -> SessionStats { + stats_inner(db, session_id).unwrap_or_default() +} + +fn stats_inner(db: &Connection, session_id: &str) -> Option { + let last = db + .query_row( + "SELECT json_extract(data, '$.role') AS role, time_updated + FROM message WHERE session_id = ?1 + ORDER BY time_created DESC LIMIT 1", + [session_id], + |r| { + Ok(( + r.get::<_, Option>(0)?, + r.get::<_, i64>(1).unwrap_or(0), + )) + }, + ) + .ok(); + + let heartbeat: Option = db + .query_row( + "SELECT MAX(time_updated) FROM message WHERE session_id = ?1", + [session_id], + |r| r.get::<_, Option>(0), + ) + .ok()?; + + let last_assistant = db + .query_row( + "SELECT json_extract(data, '$.time.completed'), + json_extract(data, '$.time.error') + FROM message WHERE session_id = ?1 + AND json_extract(data, '$.role') = 'assistant' + ORDER BY time_created DESC LIMIT 1", + [session_id], + |r| { + // `x != null` — json_extract returns SQL NULL for a missing + // path; any other value (incl. 0/false/"") counts present. + let present = |i: usize| { + !matches!(r.get_ref(i), Ok(rusqlite::types::ValueRef::Null) | Err(_)) + }; + Ok((present(0), present(1))) + }, + ) + .ok(); + + let first_text = db + .query_row( + "SELECT json_extract(p.data, '$.text') AS text + FROM part p JOIN message m ON p.message_id = m.id + WHERE p.session_id = ?1 + AND json_extract(m.data, '$.role') = 'user' + AND json_extract(p.data, '$.type') = 'text' + AND json_extract(p.data, '$.text') IS NOT NULL + ORDER BY p.time_created ASC LIMIT 1", + [session_id], + |r| { + Ok(match r.get::<_, Value>(0)? { + Value::Text(t) => FirstText::Text(t.trim().to_string()), + _ => FirstText::NonString, + }) + }, + ) + .ok() + .unwrap_or(FirstText::Absent); + if matches!(first_text, FirstText::NonString) { + return None; + } + + let (last_role, last_time_updated) = last + .map(|(role, t)| (role, Some(t))) + .unwrap_or((None, None)); + + Some(SessionStats { + last_role, + last_time_updated_ms: heartbeat.or(last_time_updated).unwrap_or(0), + last_assistant_completed: last_assistant.map(|(c, _)| c).unwrap_or(false), + summary: match first_text { + FirstText::Text(t) => t, + _ => String::new(), + }, + }) +} diff --git a/rust/crates/devkit-harness/src/pi/locator.rs b/rust/crates/devkit-harness/src/pi/locator.rs new file mode 100644 index 000000000..fd4e17a55 --- /dev/null +++ b/rust/crates/devkit-harness/src/pi/locator.rs @@ -0,0 +1,195 @@ +//! PiSessionLocator port — project-dir grouping by encoded cwd, filename +//! timestamp window, bounded head reads, legacy greedy matching. +//! TS keeps cross-call head/noMatchDir caches for cost; detect output is +//! identical without them so the port scans fresh each sweep. + +use crate::shared::{self, SessionFile}; +use devkit_core::discover::AgentProc; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use super::parser; + +/// `----`, e.g. `/Users/x/proj` → `--Users-x-proj--`. +fn encode_project_dir(cwd: &str) -> String { + // path.resolve(cwd) — strip the leading '/', join '-' on separators. + let normalized = cwd.trim_end_matches('/'); + let stripped = normalized.strip_prefix('/').unwrap_or(normalized); + format!("--{}--", stripped.replace('/', "-")) +} + +/// `YYYY-MM-DDTHH-MM-SS-mmmZ_` prefix → epoch ms (UTC). +fn file_name_timestamp_ms(file_name: &str) -> Option { + let b = file_name.as_bytes(); + // ^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z_ + if b.len() < 25 { + return None; + } + let iso = format!( + "{}T{}:{}:{}.{}Z", + &file_name[0..10], + &file_name[11..13], + &file_name[14..16], + &file_name[17..19], + &file_name[20..23] + ); + // Guard the literal separators the regex anchors. + if &file_name[4..5] != "-" + || &file_name[7..8] != "-" + || &file_name[10..11] != "T" + || &file_name[13..14] != "-" + || &file_name[16..17] != "-" + || &file_name[19..20] != "-" + || &file_name[23..24] != "Z" + || &file_name[24..25] != "_" + { + return None; + } + if !file_name[0..10] + .chars() + .chain(file_name[11..23].chars()) + .all(|c| c.is_ascii_digit() || c == '-') + { + return None; + } + shared::parse_iso_ms(&iso) +} + +pub struct PiSessionLocator { + sessions_dir: PathBuf, +} + +impl PiSessionLocator { + pub fn new(home: &Path) -> Self { + Self { + sessions_dir: home.join(".pi").join("agent").join("sessions"), + } + } + + /// `matchRunningProcesses` — legacy matches + sessions + the survivor + /// fallback (every process, since locator matches don't consume it). + pub fn match_running<'a>( + &self, + processes: &[&'a AgentProc], + ) -> ( + Vec>, + Vec, + Vec<&'a AgentProc>, + ) { + if processes.is_empty() { + return (Vec::new(), Vec::new(), Vec::new()); + } + let sessions = self.discover_sessions(processes); + if sessions.is_empty() { + return (Vec::new(), sessions, processes.to_vec()); + } + ( + shared::match_processes_to_sessions(processes, &sessions), + sessions, + processes.to_vec(), + ) + } + + /// `discoverSessionsInDirs` — per-project-dir scan: files in window of a + /// process start (birthtime OR filename timestamp), head-read for id+cwd. + fn discover_sessions(&self, processes: &[&AgentProc]) -> Vec { + if !self.sessions_dir.is_dir() { + return Vec::new(); + } + // Group processes by their project dir, preserving first-seen order. + let mut scans: HashMap> = HashMap::new(); + let mut order: Vec = Vec::new(); + for &proc in processes { + let (Some(cwd), Some(_)) = (proc.cwd.as_deref(), proc.start_time_ms) else { + continue; + }; + let dir = self.sessions_dir.join(encode_project_dir(cwd)); + let key = dir.to_string_lossy().into_owned(); + if !dir.is_dir() { + continue; + } + if !scans.contains_key(&key) { + order.push(key.clone()); + } + scans.entry(key).or_default().push(proc); + } + + let mut sessions = Vec::new(); + for dir_key in order { + let procs = &scans[&dir_key]; + let cwd = procs[0].cwd.as_deref().unwrap_or_default(); + let starts: Vec = procs.iter().filter_map(|p| p.start_time_ms).collect(); + for name in crate::shared::list_dir_names(std::path::Path::new(&dir_key)) { + if !name.ends_with(".jsonl") { + continue; + } + let path = std::path::Path::new(&dir_key).join(&name); + let file_path = path.to_string_lossy().into_owned(); + let Ok(meta) = path.metadata() else { + continue; + }; + if !meta.is_file() { + continue; + } + let to_ms = |r: std::io::Result| { + r.ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) + }; + let birthtime_ms = { + let b = to_ms(meta.created()); + if b != 0 { + b + } else { + to_ms(meta.modified()) + } + }; + let created_ms = file_name_timestamp_ms(&name); + let in_window = starts.iter().any(|s| { + (s - birthtime_ms).abs() <= shared::MATCH_TOLERANCE_MS + || created_ms.is_some_and(|c| { + (s - c).abs() <= shared::MATCH_TOLERANCE_MS + }) + }); + if !in_window { + continue; + } + let head = parser::read_session_head(&file_path, parser::HEAD_MAX_BYTES); + sessions.push(SessionFile { + session_id: head + .as_ref() + .and_then(|h| h.session_id.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| parser::session_id_from_file(&file_path)), + file_path, + project_dir: dir_key.clone(), + birthtime_ms, + resolved_cwd: head + .and_then(|h| h.project_path) + .unwrap_or_else(|| cwd.to_string()), + }); + } + } + sessions + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_project_dir() { + assert_eq!(encode_project_dir("/Users/x/proj"), "--Users-x-proj--"); + assert_eq!(encode_project_dir("/a"), "--a--"); + } + + #[test] + fn parses_filename_timestamp() { + let ms = file_name_timestamp_ms("2026-06-10T08-58-20-754Z_abc.jsonl").unwrap(); + assert_eq!(ms, 1781081900754); + assert_eq!(file_name_timestamp_ms("plain.jsonl"), None); + assert_eq!(file_name_timestamp_ms("2026-06-10T08-58-20-754_abc.jsonl"), None); + } +} diff --git a/rust/crates/devkit-harness/src/pi/mod.rs b/rust/crates/devkit-harness/src/pi/mod.rs new file mode 100644 index 000000000..73d0ac386 --- /dev/null +++ b/rust/crates/devkit-harness/src/pi/mod.rs @@ -0,0 +1,266 @@ +//! PiAdapter port — registry cache, sessions.json tracker, project-dir +//! locator matching, process-only fallback. + +mod locator; +mod parser; + +use crate::shared; +use crate::{HarnessAdapter, SweepContext}; +use devkit_core::agent::EnrichedAgent; +use devkit_core::discover::AgentProc; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// `isPiExecutable` — any whitespace token whose basename is pi/pi.exe/pi.js. +fn is_pi_executable(command: &str) -> bool { + command + .split_whitespace() + .any(|tok| matches!(shared::path_basename(tok).as_str(), "pi" | "pi.exe" | "pi.js")) +} + +/// `~/.pi/agent/sessions.json` — pid→session filePath written when the CLI +/// launches managed pi sessions. +struct SessionTracker { + tracker_path: PathBuf, + sessions_dir: PathBuf, +} + +impl SessionTracker { + fn match_processes<'a>( + &self, + processes: &[&'a AgentProc], + ) -> (Vec<(&'a AgentProc, String)>, Vec<&'a AgentProc>) { + let tracker = self.read(); + let mut matches = Vec::new(); + let mut fallback = Vec::new(); + if tracker.is_empty() { + return (matches, processes.to_vec()); + } + for &proc in processes { + match tracker.get(&proc.pid) { + Some(file_path) + if self.is_trusted(file_path) && Path::new(file_path).exists() => + { + matches.push((proc, file_path.clone())); + } + _ => fallback.push(proc), + } + } + (matches, fallback) + } + + fn read(&self) -> HashMap { + let Ok(content) = std::fs::read_to_string(&self.tracker_path) else { + return HashMap::new(); + }; + let Ok(Value::Object(obj)) = serde_json::from_str::(&content) else { + return HashMap::new(); + }; + obj.iter() + .filter_map(|(k, v)| { + let pid = to_pid(k)?; + v.as_str().filter(|s| !s.is_empty()).map(|s| (pid, s.to_string())) + }) + .collect() + } + + /// `isTrustedSessionPath` — path.resolve containment under sessionsDir. + fn is_trusted(&self, file_path: &str) -> bool { + let root = normalize_path(&self.sessions_dir); + let resolved = normalize_path(Path::new(file_path)); + resolved == root || resolved.starts_with(&format!("{root}/")) + } +} + +fn to_pid(key: &str) -> Option { + if key.is_empty() || !key.chars().all(|c| c.is_ascii_digit()) { + return None; + } + key.parse::().ok().filter(|p| *p > 0) +} + +/// path.resolve equivalence for the trust check (lexical normalize). +fn normalize_path(p: &Path) -> String { + let mut out: Vec = Vec::new(); + for comp in p.components() { + match comp { + std::path::Component::RootDir => out.clear(), + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::Normal(s) => out.push(s.to_string_lossy().into_owned()), + std::path::Component::Prefix(_) => {} + } + } + format!("/{}", out.join("/")) +} + +pub struct PiAdapter { + home: PathBuf, + sessions_dir: PathBuf, + locator: locator::PiSessionLocator, +} + +impl PiAdapter { + pub fn new(home: &Path) -> Self { + Self { + home: home.to_path_buf(), + sessions_dir: home.join(".pi").join("agent").join("sessions"), + locator: locator::PiSessionLocator::new(home), + } + } + + fn map_session( + &self, + session: &parser::Session, + proc: &AgentProc, + file_path: &str, + now_ms: i64, + ) -> EnrichedAgent { + let project = if session.project_path.is_empty() { + proc.cwd.clone().unwrap_or_default() + } else { + session.project_path.clone() + }; + EnrichedAgent { + name: shared::generate_agent_name(&project, proc.pid), + agent_type: "pi".into(), + status: parser::determine_status(session, now_ms).into(), + summary: if session.summary.is_empty() { + "Pi session active".into() + } else { + session.summary.clone() + }, + pid: proc.pid as u64, + project_path: project, + session_id: session.session_id.clone(), + last_active: shared::iso_utc(session.last_active_ms), + pinned: None, + session_file_path: Some(file_path.to_string()), + } + } + + /// `mapSessionFileMatches` — parse each matched file; parse failures go + /// to the returned fallback (after this stage's misses, per TS). + fn map_file_matches<'a>( + &self, + matches: &[(&'a AgentProc, String)], + now_ms: i64, + ) -> (Vec, Vec<&'a AgentProc>, HashSet) { + let mut agents = Vec::new(); + let mut fallback = Vec::new(); + let mut matched = HashSet::new(); + for (proc, file_path) in matches { + match parser::read_session(file_path, proc.cwd.as_deref().unwrap_or("")) { + Some(session) => { + agents.push(self.map_session(&session, proc, file_path, now_ms)); + matched.insert(proc.pid); + } + None => fallback.push(*proc), + } + } + (agents, fallback, matched) + } +} + +impl HarnessAdapter for PiAdapter { + fn type_id(&self) -> &'static str { + "pi" + } + + fn can_handle(&self, proc: &AgentProc) -> bool { + is_pi_executable(proc.command.as_deref().unwrap_or("")) + } + + fn detect(&self, ctx: &SweepContext) -> Vec { + let mut seen = HashSet::new(); + let procs: Vec<&AgentProc> = ctx + .processes + .iter() + .filter(|p| self.can_handle(p) && seen.insert(p.pid)) + .collect(); + if procs.is_empty() { + return Vec::new(); + } + + let mut agents = Vec::new(); + let registry = shared::registry_session_paths(&self.home, "pi"); + + // 1. registry cache — misses first, then parse failures (TS order). + let mut misses: Vec<&AgentProc> = Vec::new(); + let mut cache_matches: Vec<(&AgentProc, String)> = Vec::new(); + for &proc in &procs { + match registry.get(&proc.pid) { + Some(file_path) if Path::new(file_path).exists() => { + cache_matches.push((proc, file_path.clone())); + } + _ => misses.push(proc), + } + } + let (cache_agents, cache_parse_fail, _) = self.map_file_matches(&cache_matches, ctx.now); + agents.extend(cache_agents); + let mut stage_fallback: Vec<&AgentProc> = misses; + stage_fallback.extend(cache_parse_fail); + + // 2. tracker sessions.json + let tracker = SessionTracker { + tracker_path: self.home.join(".pi").join("agent").join("sessions.json"), + sessions_dir: self.sessions_dir.clone(), + }; + let (tracker_matches, tracker_fallback) = tracker.match_processes(&stage_fallback); + let (tracker_agents, tracker_parse_fail, _) = + self.map_file_matches(&tracker_matches, ctx.now); + agents.extend(tracker_agents); + let mut stage_fallback = tracker_fallback; + stage_fallback.extend(tracker_parse_fail); + + // 3. locator legacy matching + let (legacy_matches, sessions, locator_fallback) = + self.locator.match_running(&stage_fallback); + let legacy_pairs: Vec<(&AgentProc, String)> = legacy_matches + .iter() + .map(|m| (m.proc, sessions[m.session_idx].file_path.clone())) + .collect(); + let (legacy_agents, _, legacy_matched) = self.map_file_matches(&legacy_pairs, ctx.now); + agents.extend(legacy_agents); + + // 4. process-only for locator survivors not legacy-matched + for &proc in &locator_fallback { + if legacy_matched.contains(&proc.pid) { + continue; + } + agents.push(shared::process_only_agent( + "pi", + proc, + "Pi process running", + None, + ctx.now, + )); + } + agents + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pi_executable_tokens() { + assert!(is_pi_executable("pi")); + assert!(is_pi_executable("node /usr/bin/pi.js")); + assert!(is_pi_executable("/opt/pi/bin/pi -v")); + assert!(is_pi_executable("pi.exe")); + assert!(!is_pi_executable("pilot")); + assert!(!is_pi_executable("pico pi-file")); + } + + #[test] + fn pid_parsing() { + assert_eq!(to_pid("42"), Some(42)); + assert_eq!(to_pid("0"), None); + assert_eq!(to_pid("x"), None); + } +} diff --git a/rust/crates/devkit-harness/src/pi/parser.rs b/rust/crates/devkit-harness/src/pi/parser.rs new file mode 100644 index 000000000..dfb50645a --- /dev/null +++ b/rust/crates/devkit-harness/src/pi/parser.rs @@ -0,0 +1,460 @@ +//! PiSessionParser port — bounded head/tail JSONL summary of a pi session +//! file plus status derivation. Conversation APIs stay TS-side. + +use crate::shared; +use serde_json::Value; + +/// First-complete-lines metadata read (`readSessionHead`, 64KiB cap). +pub const HEAD_MAX_BYTES: usize = 64 * 1024; +const HEAD_CHUNK_BYTES: usize = 4 * 1024; + +/// Folded session state — mirrors `PiSummaryState`. +#[derive(Default)] +pub struct SummaryState { + pub entry_count: usize, + /// True once a bounded cold start skipped the middle: later lines are + /// not the session's first, so first-* fields stop accepting values. + pub past_head: bool, + pub session_id: Option, + pub project_path: Option, + pub first_timestamp_ms: Option, + pub last_timestamp_ms: Option, + pub last_user_message: Option, + pub last_role: Option, +} + +/// Built `PiSession` — timestamps as epoch ms for mapper use. +pub struct Session { + pub session_id: String, + pub project_path: String, + pub summary: String, + pub last_active_ms: i64, + pub last_role: Option, +} + +fn as_record(v: &Value) -> Option<&serde_json::Map> { + v.as_object() +} + +fn first_string(values: &[Option<&Value>]) -> Option { + values + .iter() + .flatten() + .filter_map(|v| v.as_str()) + .find(|s| !s.is_empty()) + .map(str::to_string) +} + +fn nested<'a>(v: &'a Value, key: &str) -> Option<&'a Value> { + as_record(v).and_then(|o| o.get(key)) +} + +fn message_record(entry: &Value) -> Option<&Value> { + entry.get("message").filter(|v| v.is_object()) +} + +/// `roleLikeType` — `type` only counts when it is itself a role keyword. +fn is_role_like(s: &str) -> bool { + matches!( + s.to_lowercase().as_str(), + "user" | "human" | "assistant" | "ai" | "pi" | "system" + ) +} + +/// `entryRole` — entry.type joins the chain only when it is role-like; +/// a non-role type must not shadow payload/data roles. +fn entry_role(entry: &Value) -> Option<&'static str> { + let message = message_record(entry); + let payload = entry.get("payload"); + let data = entry.get("data"); + let mut candidates = vec![ + entry.get("role"), + message.and_then(|m| m.get("role")), + ]; + if let Some(t) = entry.get("type") { + if t.as_str().is_some_and(is_role_like) { + candidates.push(Some(t)); + } + } + candidates.extend([ + payload.and_then(|p| p.get("role")), + payload.and_then(|p| p.get("type")), + data.and_then(|d| d.get("role")), + data.and_then(|d| d.get("type")), + ]); + let raw = first_string(&candidates)?; + match raw.to_lowercase().as_str() { + "user" | "human" => Some("user"), + "assistant" | "ai" | "pi" => Some("assistant"), + "system" => Some("system"), + _ => None, + } +} + +/// `contentToString` — string | array of parts | {content|text|value}. +fn content_to_string(v: Option<&Value>) -> String { + match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(items)) => items + .iter() + .map(|i| content_to_string(Some(i))) + .filter(|s| !s.is_empty()) + .collect::>() + .join(""), + Some(Value::Object(o)) => content_to_string( + o.get("content").or_else(|| o.get("text")).or_else(|| o.get("value")), + ), + _ => String::new(), + } +} + +/// `entryContent` +fn entry_content(entry: &Value) -> String { + let message = message_record(entry); + let payload = entry.get("payload"); + let data = entry.get("data"); + for c in [ + entry.get("content"), + entry.get("text"), + message.and_then(|m| m.get("content")), + message.and_then(|m| m.get("text")), + message.and_then(|m| m.get("message")), + entry.get("message"), + payload.and_then(|p| p.get("content")), + payload.and_then(|p| p.get("text")), + payload.and_then(|p| p.get("message")), + data.and_then(|d| d.get("content")), + data.and_then(|d| d.get("text")), + data.and_then(|d| d.get("message")), + ] { + let text = content_to_string(c); + if !text.is_empty() { + return text; + } + } + String::new() +} + +/// `entryTimestamp` +fn entry_timestamp(entry: &Value) -> Option { + first_string(&[ + entry.get("timestamp"), + nested(entry, "payload").and_then(|p| p.get("timestamp")), + nested(entry, "data").and_then(|d| d.get("timestamp")), + entry.get("createdAt"), + entry.get("created_at"), + ]) +} + +/// `entrySessionId` +fn entry_session_id(entry: &Value) -> Option { + let payload = entry.get("payload"); + let data = entry.get("data"); + first_string(&[ + entry.get("sessionId"), + entry.get("session_id"), + entry.get("id"), + payload.and_then(|p| p.get("sessionId")), + payload.and_then(|p| p.get("session_id")), + payload.and_then(|p| p.get("id")), + data.and_then(|d| d.get("sessionId")), + data.and_then(|d| d.get("session_id")), + data.and_then(|d| d.get("id")), + ]) +} + +/// `entryCwd` +fn entry_cwd(entry: &Value) -> Option { + let payload = entry.get("payload"); + let data = entry.get("data"); + first_string(&[ + entry.get("cwd"), + entry.get("projectPath"), + entry.get("project_path"), + payload.and_then(|p| p.get("cwd")), + payload.and_then(|p| p.get("projectPath")), + payload.and_then(|p| p.get("project_path")), + data.and_then(|d| d.get("cwd")), + data.and_then(|d| d.get("projectPath")), + data.and_then(|d| d.get("project_path")), + ]) +} + +/// `entryToMessage(entry, includeSystem=true)` → (role, trimmed content). +fn entry_to_message(entry: &Value) -> Option<(String, String)> { + let role = entry_role(entry)?; + let content = entry_content(entry).trim().to_string(); + if content.is_empty() { + return None; + } + Some((role.to_string(), content)) +} + +fn reduce(state: SummaryState, value: Option<&Value>) -> SummaryState { + let Some(entry) = value.filter(|v| v.is_object()) else { + return state; + }; + let in_head = !state.past_head; + let mut next = SummaryState { + entry_count: state.entry_count + 1, + ..state + }; + if in_head { + if next.session_id.is_none() { + next.session_id = entry_session_id(entry); + } + if next.project_path.is_none() { + next.project_path = entry_cwd(entry); + } + } + if let Some(ts) = entry_timestamp(entry) + .map(Value::String) + .as_ref() + .and_then(shared::parse_timestamp_ms) + { + if in_head && next.first_timestamp_ms.is_none() { + next.first_timestamp_ms = Some(ts); + } + next.last_timestamp_ms = Some(ts); + } + if let Some((role, content)) = entry_to_message(entry) { + next.last_role = Some(role.clone()); + if role == "user" { + next.last_user_message = Some(content); + } + } + next +} + +fn skip(state: SummaryState) -> SummaryState { + SummaryState { + entry_count: state.entry_count, + past_head: true, + session_id: state.session_id, + project_path: state.project_path, + first_timestamp_ms: state.first_timestamp_ms, + ..Default::default() + } +} + +/// `sessionIdFromFile` — basename minus `.jsonl`, after the last `_`. +pub fn session_id_from_file(file_path: &str) -> String { + let base = std::path::Path::new(file_path) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + match base.rfind('_') { + Some(i) => base[i + 1..].to_string(), + None => base, + } +} + +/// `readSessionIncremental` (cold start) — bounded fold; None when +/// unreadable or the file has no entries. +pub fn read_session(path: &str, fallback_cwd: &str) -> Option { + let state = shared::fold_jsonl_bounded(path, SummaryState::default(), reduce, skip)?; + if state.entry_count == 0 { + return None; + } + let needs_stat = state.first_timestamp_ms.is_none() || state.last_timestamp_ms.is_none(); + let (birth_ms, mtime_ms) = if needs_stat { + let meta = std::fs::metadata(path).ok(); + let to_ms = |r: std::io::Result| { + r.ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + }; + let created = meta.as_ref().and_then(|m| to_ms(m.created())); + let modified = meta.as_ref().and_then(|m| to_ms(m.modified())); + (created, modified) + } else { + (None, None) + }; + let session_start = state + .first_timestamp_ms + .or(birth_ms) + .or(mtime_ms) + .unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64 + }); + let last_active = state.last_timestamp_ms.or(mtime_ms).unwrap_or(session_start); + Some(Session { + session_id: state + .session_id + .unwrap_or_else(|| session_id_from_file(path)), + project_path: state.project_path.unwrap_or_else(|| fallback_cwd.to_string()), + summary: state + .last_user_message + .map(|m| shared::truncate(&m, 120)) + .unwrap_or_else(|| "Pi session active".into()), + last_active_ms: last_active, + last_role: state.last_role, + }) +} + +/// `readSessionHead` — session id + project path from the first complete +/// lines, stopping when both are found or at `maxBytes`. Null if unreadable. +pub struct SessionHead { + pub session_id: Option, + pub project_path: Option, + pub bytes_read: usize, + pub complete: bool, +} + +pub fn read_session_head(path: &str, max_bytes: usize) -> Option { + use std::io::Read; + let mut f = std::fs::File::open(path).ok()?; + let mut head = SessionHead { + session_id: None, + project_path: None, + bytes_read: 0, + complete: false, + }; + let mut buf = vec![0u8; max_bytes]; + let mut line_start = 0usize; + while head.bytes_read < max_bytes { + let n = f + .read(&mut buf[head.bytes_read..(head.bytes_read + HEAD_CHUNK_BYTES).min(max_bytes)]) + .ok()?; + if n == 0 { + head.complete = true; + break; + } + let end = head.bytes_read + n; + while let Some(off) = buf[line_start..end].iter().position(|&b| b == b'\n') { + let nl_abs = line_start + off; + let line = buf[line_start..nl_abs].to_vec(); + line_start = nl_abs + 1; + apply_head_line(&mut head, &line); + head.bytes_read = end; + if head.session_id.is_some() && head.project_path.is_some() { + return Some(head); + } + } + head.bytes_read = end; + } + // The unterminated last line is only trusted when it ends the file. + if head.complete && line_start < head.bytes_read { + let line = buf[line_start..head.bytes_read].to_vec(); + apply_head_line(&mut head, &line); + } + Some(head) +} + +fn apply_head_line(head: &mut SessionHead, line: &[u8]) { + let Ok(text) = std::str::from_utf8(line) else { return }; + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + let Ok(v) = serde_json::from_str::(trimmed) else { + return; + }; + if !v.is_object() { + return; + } + if head.session_id.is_none() { + head.session_id = entry_session_id(&v); + } + if head.project_path.is_none() { + head.project_path = entry_cwd(&v); + } +} + +/// `determineStatus` — idle > assistant→waiting > running. +pub fn determine_status(session: &Session, now_ms: i64) -> &'static str { + if shared::is_idle(session.last_active_ms, now_ms) { + return "idle"; + } + match session.last_role.as_deref() { + Some("assistant") => "waiting", + _ => "running", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + fn write_tmp(lines: &[&str]) -> String { + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let path = std::env::temp_dir().join(format!( + "devkit-pi-parser-{}-{n}.jsonl", + std::process::id() + )); + let mut f = std::fs::File::create(&path).unwrap(); + for l in lines { + writeln!(f, "{l}").unwrap(); + } + path.to_string_lossy().into_owned() + } + + #[test] + fn empty_file_is_none() { + let p = write_tmp(&[]); + assert!(read_session(&p, "/cwd").is_none()); + } + + #[test] + fn head_fields_from_first_entry_only() { + let p = write_tmp(&[ + r#"{"sessionId":"s1","cwd":"/proj","timestamp":"2026-10-08T10:00:00Z"}"#, + r#"{"sessionId":"other","cwd":"/elsewhere","timestamp":"2026-10-08T10:01:00Z","role":"user","content":"hi"}"#, + ]); + let s = read_session(&p, "/cwd").unwrap(); + assert_eq!(s.session_id, "s1"); + assert_eq!(s.project_path, "/proj"); + assert_eq!(s.summary, "hi"); + assert_eq!(s.last_role.as_deref(), Some("user")); + } + + #[test] + fn assistant_tail_maps_waiting() { + let p = write_tmp(&[ + r#"{"sessionId":"s2","cwd":"/proj","timestamp":"2026-10-08T10:00:00Z"}"#, + r#"{"timestamp":"2026-10-08T10:01:00Z","role":"assistant","content":[{"text":"done"}]}"#, + ]); + let s = read_session(&p, "/cwd").unwrap(); + assert_eq!(determine_status(&s, s.last_active_ms + 60_000), "waiting"); + } + + #[test] + fn role_like_type_filters_non_role_types() { + // `type: "foo"` must not shadow a payload role. + let e: Value = serde_json::from_str( + r#"{"type":"event","payload":{"role":"user"},"content":"x"}"#, + ) + .unwrap(); + assert_eq!(entry_role(&e), Some("user")); + let e2: Value = + serde_json::from_str(r#"{"type":"assistant","payload":{"role":"user"}}"#).unwrap(); + assert_eq!(entry_role(&e2), Some("assistant")); + } + + #[test] + fn filename_id_fallback() { + let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let real = std::env::temp_dir() + .join(format!( + "2026-06-10T08-58-20-754Z_sessabc-{}-{n}.jsonl", + std::process::id() + )) + .to_string_lossy() + .into_owned(); + std::fs::write( + &real, + "{\"timestamp\":\"2026-10-08T10:00:00Z\",\"role\":\"user\",\"content\":\"x\"}\n", + ) + .unwrap(); + let s = read_session(&real, "/cwd").unwrap(); + let expected_suffix = format!("sessabc-{}-{n}", std::process::id()); + assert_eq!(s.session_id, expected_suffix); + assert_eq!(s.project_path, "/cwd"); + } +} diff --git a/rust/crates/devkit-harness/src/shared.rs b/rust/crates/devkit-harness/src/shared.rs new file mode 100644 index 000000000..97ba35ee7 --- /dev/null +++ b/rust/crates/devkit-harness/src/shared.rs @@ -0,0 +1,736 @@ +//! Helpers shared by ported harness adapters — mirrors +//! `packages/agent-manager/src/harnesses/shared.ts` and `utils/matching.ts`. + +use devkit_core::agent::EnrichedAgent; +use devkit_core::discover::AgentProc; +use std::path::Path; + +/// `MATCH_TOLERANCE_MS` — max |procStart − session birthtime| for legacy +/// matching (3 min, same as utils/matchingConstants.ts). +pub const MATCH_TOLERANCE_MS: i64 = 3 * 60 * 1000; + +/// `pathBasename` — basename after '\'→'/', lowercased. +pub fn path_basename(p: &str) -> String { + p.replace('\\', "/") + .rsplit('/') + .next() + .unwrap_or("") + .to_lowercase() +} + +fn is_absolute(p: &str) -> bool { + p.starts_with('/') || { + let b = p.as_bytes(); + b.len() >= 3 + && b[0].is_ascii_alphabetic() + && b[1] == b':' + && (b[2] == b'/' || b[2] == b'\\') + } +} + +fn is_file(p: &str) -> bool { + std::fs::metadata(p).map(|m| m.is_file()).unwrap_or(false) +} + +/// `executablePath` — resolve argv[0]; for an absolute first token that is not +/// itself a file (spaces in the path), try longer space-joined prefixes ending +/// in a token with a path separator, first one that exists wins. +pub fn executable_path(command: &str) -> String { + let tokens: Vec<&str> = command.split_whitespace().collect(); + let first = tokens.first().copied().unwrap_or(""); + if tokens.len() == 1 || !is_absolute(first) || is_file(first) { + return first.to_string(); + } + let mut prefix = first.to_string(); + for tok in &tokens[1..] { + prefix.push(' '); + prefix.push_str(tok); + if (tok.contains('/') || tok.contains('\\')) && is_file(&prefix) { + return prefix; + } + } + first.to_string() +} + +/// `executableBasename` — lowercased basename of `executablePath`. +pub fn executable_basename(command: &str) -> String { + path_basename(&executable_path(command)) +} + +/// `matchesExecutable` — argv[0]'s basename is `name` or `name.exe`. +pub fn matches_executable(command: &str, name: &str) -> bool { + let base = executable_basename(command); + base == name || base == format!("{name}.exe") +} + +/// `normalizeExecutableName` — lowercase + strip a `.exe` suffix; applied to +/// both the pattern names and each candidate basename (TS does both, so a +/// `agy.exe` proc still matches the `agy` pool). +fn normalize_executable_name(name: &str) -> String { + let lower = name.to_lowercase(); + lower.strip_suffix(".exe").unwrap_or(&lower).to_string() +} + +/// `matchesExecutableName` — first-token basename match, else (absolute first +/// token only) a later path-like token whose basename matches, confirmed by +/// the on-disk-resolved `executablePath`. +pub fn matches_executable_name(command: &str, names: &[&str]) -> bool { + let norm: Vec = names.iter().map(|n| normalize_executable_name(n)).collect(); + let tokens: Vec<&str> = command.split_whitespace().collect(); + let first = tokens.first().copied().unwrap_or(""); + if norm + .iter() + .any(|n| *n == normalize_executable_name(&path_basename(first))) + { + return true; + } + if !is_absolute(first) { + return false; + } + let may_continue = tokens[1..].iter().any(|t| { + (t.contains('/') || t.contains('\\')) + && norm + .iter() + .any(|n| *n == normalize_executable_name(&path_basename(t))) + }); + may_continue + && norm + .iter() + .any(|n| *n == normalize_executable_name(&executable_basename(command))) +} + +/// `generateAgentName`: kebab(basename(cwd))-pid, "unknown" fallback. +pub fn generate_agent_name(cwd: &str, pid: i64) -> String { + let folder = Path::new(cwd) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let mut kebab = String::with_capacity(folder.len()); + let mut last_dash = false; + for c in folder.to_lowercase().chars() { + if c.is_ascii_alphanumeric() { + kebab.push(c); + last_dash = false; + } else if !last_dash { + kebab.push('-'); + last_dash = true; + } + } + let kebab = kebab.trim_matches('-').to_string(); + format!( + "{}-{pid}", + if kebab.is_empty() { "unknown" } else { &kebab } + ) +} + +/// A session file candidate for legacy matching (`SessionFile` in TS). +#[derive(Debug, Clone)] +pub struct SessionFile { + pub session_id: String, + pub file_path: String, + pub project_dir: String, + pub birthtime_ms: i64, + pub resolved_cwd: String, +} + +/// One greedy cwd+birthtime match result. +pub struct ProcSessionMatch<'a> { + pub proc: &'a AgentProc, + pub session_idx: usize, + pub delta_ms: i64, +} + +/// `matchProcessesToSessions`: candidate pairs need cwd equality and +/// |start−birthtime| ≤ 3min; sorted by delta asc (stable, JS-parity), greedy +/// 1:1 assignment keyed on pid + sessionId. +pub fn match_processes_to_sessions<'a>( + processes: &[&'a AgentProc], + sessions: &[SessionFile], +) -> Vec> { + let mut candidates: Vec = Vec::new(); + for proc in processes.iter().copied() { + let (Some(start_ms), Some(cwd)) = (proc.start_time_ms, proc.cwd.as_deref()) else { + continue; + }; + for (i, session) in sessions.iter().enumerate() { + if session.resolved_cwd.is_empty() || session.resolved_cwd != cwd { + continue; + } + let delta = (start_ms - session.birthtime_ms).abs(); + if delta <= MATCH_TOLERANCE_MS { + candidates.push(ProcSessionMatch { + proc, + session_idx: i, + delta_ms: delta, + }); + } + } + } + candidates.sort_by_key(|c| c.delta_ms); + + let mut used_pids = std::collections::HashSet::new(); + let mut used_ids = std::collections::HashSet::new(); + candidates + .into_iter() + .filter(|c| { + used_pids.insert(c.proc.pid) + && used_ids.insert(sessions[c.session_idx].session_id.clone()) + }) + .collect() +} + +/// `processOnlyAgent` — placeholder for a running process with no session. +pub fn process_only_agent( + agent_type: &str, + proc: &AgentProc, + summary: &str, + cwd_override: Option<&str>, + now: i64, +) -> EnrichedAgent { + let cwd = cwd_override + .or(proc.cwd.as_deref()) + .unwrap_or_default() + .to_string(); + EnrichedAgent { + name: generate_agent_name(&cwd, proc.pid), + agent_type: agent_type.into(), + status: "running".into(), + summary: summary.into(), + pid: proc.pid as u64, + project_path: cwd, + session_id: format!("pid-{}", proc.pid), + last_active: iso_utc(now), + pinned: None, + session_file_path: None, + } +} + +/// `safeReaddir` — directory entry names; empty on error. Node ≥20.1's +/// `fs.readdirSync` returns names in sorted order, so adapters that mirror +/// `safeReaddir` must sort too or output order diverges on raw `read_dir`. +pub fn list_dir_names(dir: &Path) -> Vec { + let Ok(rd) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut names: Vec = rd + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names +} + +/// File birth time in epoch ms (`statSync().birthtimeMs`), via `st_birthtime`. +/// Returns None when unreadable or non-positive — TS skips those entries. +pub fn birthtime_ms(path: &str) -> Option { + let meta = std::fs::metadata(path).ok()?; + let created = meta.created().ok()?; + let ms = created + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_millis() as i64; + if ms > 0 { Some(ms) } else { None } +} + +/// `toSessionDayKey` — `YYYY/MM/DD` in **local** civil time (Codex date dirs +/// are local, matching JS `getFullYear()/getMonth()/getDate()`). +pub fn local_day_key(epoch_ms: i64) -> String { + let secs = (epoch_ms / 1000) as libc::time_t; + unsafe { + let mut tm: libc::tm = std::mem::zeroed(); + if libc::localtime_r(&secs, &mut tm).is_null() { + return String::new(); + } + format!( + "{:04}/{:02}/{:02}", + tm.tm_year + 1900, + tm.tm_mon + 1, + tm.tm_mday + ) + } +} + +/// `isIdle` — untouched for more than 5 minutes. TS compares +/// `(now - last) / 60000 > 5` in floats ⟺ `now - last > 300_000`. +pub fn is_idle(last_active_ms: i64, now_ms: i64) -> bool { + now_ms - last_active_ms > 300_000 +} + +/// `flattenTextBlocks` — a string is used as-is; an array joins each +/// block's `text` when the block is an object with a string `text`; +/// anything else yields "". +pub fn flatten_text_blocks(content: &serde_json::Value) -> String { + match content { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(blocks) => blocks + .iter() + .map(|b| { + b.get("text") + .and_then(|t| t.as_str()) + .unwrap_or_default() + }) + .collect(), + _ => String::new(), + } +} + +/// `encodeURIComponent` — UTF-8 percent-encoding; unreserved set is +/// `A-Z a-z 0-9 - _ . ! ~ * ' ( )` (Grok session group dir names). +pub fn encode_uri_component(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &b in s.as_bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'!' | b'~' | b'*' + | b'\'' | b'(' | b')' => out.push(b as char), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +/// `truncate` — `...`-suffix at `max` UTF-16 code units (JS `length`/`slice` +/// semantics; SUMMARY_MAX_LENGTH = 120). +pub fn truncate(value: &str, max: usize) -> String { + if value.encode_utf16().count() <= max { + return value.to_string(); + } + let units: Vec = value.encode_utf16().take(max - 3).collect(); + format!("{}...", String::from_utf16_lossy(&units)) +} + +/// `parseTimestamp` — numbers in seconds or ms, or ISO-8601 strings. +pub fn parse_timestamp_ms(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Number(n) => { + let v = n.as_f64()?; + if !v.is_finite() { + return None; + } + Some(if v.abs() < 1_000_000_000_000.0 { + (v * 1000.0) as i64 + } else { + v as i64 + }) + } + serde_json::Value::String(s) => parse_iso_ms(s), + _ => None, + } +} + +/// Cold-start bounds for bounded JSONL summary reads (IncrementalJsonlSummary). +pub const JSONL_HEAD_BYTES: usize = 1024 * 1024; +pub const JSONL_TAIL_BYTES: usize = 4 * 1024 * 1024; + +/// Fold an append-only JSONL file with the IncrementalJsonlSummary semantics: +/// full file when ≤ head+tail, else complete head lines + `skip()` + lines +/// starting inside the tail window; an unterminated last line applies +/// tentatively when it already parses. +pub fn fold_jsonl_bounded( + path: &str, + initial: S, + mut reduce: impl FnMut(S, Option<&serde_json::Value>) -> S, + mut skip: impl FnMut(S) -> S, +) -> Option { + let meta = std::fs::metadata(path).ok()?; + if !meta.is_file() { + return None; + } + let bytes = std::fs::read(path).ok()?; + let size = meta.len() as usize; + + if size > JSONL_HEAD_BYTES + JSONL_TAIL_BYTES { + let head_end = bytes[..JSONL_HEAD_BYTES] + .iter() + .rposition(|&b| b == b'\n') + .map(|p| p + 1) + .unwrap_or(0); + let (head_state, _) = fold_lines_with(initial, &bytes[..head_end], false, &mut reduce); + let skipped = skip(head_state); + let tail_start = size - JSONL_TAIL_BYTES; + let from = bytes[tail_start - 1..] + .iter() + .position(|&b| b == b'\n') + .map(|p| tail_start + p) + .unwrap_or(bytes.len()); + let (tail_state, consumed) = fold_lines_with(skipped, &bytes[from..], false, &mut reduce); + Some(reduce_tentative_jsonl( + tail_state, + &bytes[from + consumed..], + &mut reduce, + )) + } else { + let (s, consumed) = fold_lines_with(initial, &bytes, false, &mut reduce); + Some(reduce_tentative_jsonl(s, &bytes[consumed..], &mut reduce)) + } +} + +fn fold_lines_with( + mut state: S, + bytes: &[u8], + discarding_first: bool, + reduce: &mut impl FnMut(S, Option<&serde_json::Value>) -> S, +) -> (S, usize) { + let mut i = 0usize; + let mut discarding = discarding_first; + while i < bytes.len() { + let Some(nl) = bytes[i..].iter().position(|&b| b == b'\n').map(|p| i + p) else { + break; + }; + if discarding { + discarding = false; + } else if let Ok(s) = std::str::from_utf8(&bytes[i..nl]) { + state = reduce_jsonl_line(state, s, reduce); + } else { + state = reduce(state, None); + } + i = nl + 1; + } + (state, i) +} + +/// `reduceTentative` — apply an unterminated last line only when it already +/// parses as complete JSON. +fn reduce_tentative_jsonl( + state: S, + rest: &[u8], + reduce: &mut impl FnMut(S, Option<&serde_json::Value>) -> S, +) -> S { + match std::str::from_utf8(rest) { + Ok(s) if !s.chars().any(|c| !c.is_whitespace()) => state, + Ok(s) => match serde_json::from_str::(s) { + Ok(v) => reduce(state, Some(&v)), + Err(_) => state, + }, + Err(_) => state, + } +} + +/// `reduceLine` — blank lines and unparseable JSON fold as `undefined`. +fn reduce_jsonl_line( + state: S, + line: &str, + reduce: &mut impl FnMut(S, Option<&serde_json::Value>) -> S, +) -> S { + if !line.chars().any(|c| !c.is_whitespace()) { + return state; + } + match serde_json::from_str::(line) { + Ok(v) => reduce(state, Some(&v)), + Err(_) => reduce(state, None), + } +} + +/// `readFileHead` — the first line of a file, at most `max_bytes`. +/// Returns (text, bytes_consumed, complete, truncated). +pub fn read_file_head(path: &str, max_bytes: usize) -> Option<(String, usize, bool, bool)> { + use std::io::Read; + let mut f = std::fs::File::open(path).ok()?; + let mut buf = Vec::with_capacity(max_bytes.min(64 * 1024)); + let mut chunk = [0u8; 16 * 1024]; + loop { + if buf.len() >= max_bytes { + break; + } + let want = chunk.len().min(max_bytes - buf.len()); + let n = match f.read(&mut chunk[..want]) { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + let start = buf.len(); + buf.extend_from_slice(&chunk[..n]); + if let Some(p) = buf[start..].iter().position(|&b| b == b'\n').map(|i| start + i) { + let text = String::from_utf8_lossy(&buf[..p]).into_owned(); + return Some((text, p + 1, true, false)); + } + } + let text = String::from_utf8_lossy(&buf).into_owned(); + Some((text, buf.len(), false, buf.len() >= max_bytes)) +} + +/// ISO-8601 UTC for an epoch-ms instant — the wire shape of `Date`. +pub fn iso_utc(epoch_ms: i64) -> String { + let secs = epoch_ms.div_euclid(1000); + let ms = epoch_ms.rem_euclid(1000); + let (y, mo, d) = civil_from_days(secs.div_euclid(86400)); + let day_secs = secs.rem_euclid(86400); + format!( + "{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}.{ms:03}Z", + day_secs / 3600, + (day_secs % 3600) / 60, + day_secs % 60 + ) +} + +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// `new Date(str).getTime()` for the ISO-8601 shapes harnesses emit: +/// `YYYY-MM-DD[T ]HH:MM[:SS[.frac]][Z|±HH[:MM]]` or bare `YYYY-MM-DD` (UTC +/// midnight, matching JS). Returns epoch ms; None when unparseable. +pub fn parse_iso_ms(s: &str) -> Option { + let s = s.trim(); + let bytes = s.as_bytes(); + if bytes.len() < 10 { + return None; + } + let num = |i: usize, n: usize| -> Option { s.get(i..i + n)?.parse().ok() }; + let (y, mo, d) = (num(0, 4)? as i32, num(5, 2)? as u32, num(8, 2)? as u32); + if bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') { + return None; + } + if bytes.len() == 10 { + return Some(days_from_civil(y, mo, d) * 86_400_000); + } + if !(bytes[10] == b'T' || bytes[10] == b' ') { + return None; + } + let (h, mi) = (num(11, 2)?, num(14, 2)?); + let mut i = 16; + let mut sec = 0i64; + let mut frac_ms = 0i64; + if bytes.get(16) == Some(&b':') { + sec = num(17, 2)?; + i = 19; + if bytes.get(19) == Some(&b'.') { + let start = 20; + let mut end = start; + while bytes.get(end).is_some_and(|b| b.is_ascii_digit()) { + end += 1; + } + let frac = &s[start..end]; + let scaled = format!("{frac:<0width$}", width = 3); + frac_ms = scaled.get(..3)?.parse().ok()?; + i = end; + } + } + let days = days_from_civil(y, mo, d); + let mut ms = days * 86_400_000 + (h * 3600 + mi * 60 + sec) * 1000 + frac_ms; + match bytes.get(i) { + Some(b'Z') | None => {} + Some(b'+') | Some(b'-') => { + let sign = if bytes[i] == b'-' { -1i64 } else { 1 }; + let off_h = num(i + 1, 2)?; + let off_m = if bytes.get(i + 3) == Some(&b':') { + num(i + 4, 2)? + } else { + num(i + 3, 2).unwrap_or(0) + }; + ms -= sign * (off_h * 3600 + off_m) * 1000; + } + _ => return None, + } + Some(ms) +} + +fn days_from_civil(y: i32, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y - 1 } else { y } as i64; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = ((m as i64) + 9) % 12; + let doy = (153 * mp + 2) / 5 + (d as i64) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe - 719468 +} + +/// `isSameTerminalProcess` — same non-empty/non-"?" tty and same cwd +/// (or either side's cwd unknown/empty). +pub fn is_same_terminal_process(a: &AgentProc, b: &AgentProc) -> bool { + let tty_a = a.tty.as_deref().unwrap_or(""); + let tty_b = b.tty.as_deref().unwrap_or(""); + if tty_a.is_empty() || tty_a == "?" || tty_a != tty_b { + return false; + } + let cwd_a = a.cwd.as_deref().unwrap_or(""); + let cwd_b = b.cwd.as_deref().unwrap_or(""); + cwd_a == cwd_b || cwd_a.is_empty() || cwd_b.is_empty() +} + +/// `findWrapperProcess` — the first proc that is `child`'s parent and +/// shares its terminal/cwd identity. +pub fn find_wrapper_pid(procs: &[&AgentProc], child: &AgentProc) -> Option { + procs.iter().find_map(|p| { + (p.pid != child.pid + && child.ppid == Some(p.pid) + && is_same_terminal_process(p, child)) + .then_some(p.pid) + }) +} + +/// `findWrapperProcessPids` — parents of candidates sharing a terminal, +/// plus every proc sharing a terminal with an already-matched process. +pub fn wrapper_pids( + procs: &[&AgentProc], + matched: &[&AgentProc], +) -> std::collections::HashSet { + let mut wrappers = std::collections::HashSet::new(); + for child in procs { + if let Some(pid) = find_wrapper_pid(procs, child) { + wrappers.insert(pid); + } + } + for proc in procs { + if matched + .iter() + .any(|m| proc.pid != m.pid && is_same_terminal_process(proc, m)) + { + wrappers.insert(proc.pid); + } + } + wrappers +} + +/// pid → sessionFilePath rows for `agent_type` from the shared +/// `/.ai-devkit/agents.db` registry — mirrors `AgentRegistry.list()` +/// filtered the way `mapRegistryCache` does (type + non-empty path; +/// existence is checked by the caller). Readonly open: a missing or +/// unreadable db yields an empty map and never creates the file. +pub fn registry_session_paths( + home: &Path, + agent_type: &str, +) -> std::collections::HashMap { + let db = home.join(".ai-devkit").join("agents.db"); + let Ok(conn) = rusqlite::Connection::open_with_flags( + &db, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) else { + return std::collections::HashMap::new(); + }; + let mut stmt = match conn + .prepare("SELECT pid, session_file_path FROM agents WHERE type = ?1") + { + Ok(s) => s, + Err(_) => return std::collections::HashMap::new(), + }; + let rows = stmt + .query_map([agent_type], |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)) + }) + .ok(); + match rows { + Some(it) => it + .flatten() + .filter(|(_, p)| !p.is_empty()) + .collect(), + None => std::collections::HashMap::new(), + } +} + +/// One `agents` row — the fields adapters consult (`AgentRegistry.list` +/// entry subset). +#[derive(Debug, Clone)] +pub struct RegistryRow { + pub agent_type: String, + pub pid: i64, + pub name: String, + pub session_file_path: String, +} + +/// Full registry rows in `AgentRegistry.list()` order +/// (`started_at ASC, name ASC`) so pid-keyed maps mirror JS Map last-wins. +pub fn registry_agent_rows(home: &Path) -> Vec { + let db = home.join(".ai-devkit").join("agents.db"); + let Ok(conn) = rusqlite::Connection::open_with_flags( + &db, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) else { + return Vec::new(); + }; + let mut stmt = match conn.prepare( + "SELECT type, pid, name, session_file_path FROM agents ORDER BY started_at ASC, name ASC", + ) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + stmt.query_map([], |r| { + Ok(RegistryRow { + agent_type: r.get::<_, String>(0)?, + pid: r.get::<_, i64>(1)?, + name: r.get::<_, String>(2)?, + session_file_path: r.get::<_, String>(3)?, + }) + }) + .map(|rows| rows.flatten().collect()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kebab_names_match_ts() { + assert_eq!( + generate_agent_name("/Users/x/My_Project", 42), + "my-project-42" + ); + assert_eq!(generate_agent_name("/a/b/.worktrees/x y", 7), "x-y-7"); + assert_eq!(generate_agent_name("", 9), "unknown-9"); + assert_eq!(generate_agent_name("/", 1), "unknown-1"); + } + + #[test] + fn iso_utc_formats_epoch_ms() { + assert_eq!(iso_utc(0), "1970-01-01T00:00:00.000Z"); + assert_eq!(iso_utc(1759969523000), "2025-10-09T00:25:23.000Z"); + } + + #[test] + fn iso_parse_roundtrip() { + assert_eq!(parse_iso_ms("1970-01-01T00:00:00.000Z"), Some(0)); + assert_eq!( + parse_iso_ms("2025-10-09T00:25:23.000Z"), + Some(1759969523000) + ); + assert_eq!(parse_iso_ms("2025-10-09"), Some(1759968000000)); + assert_eq!( + parse_iso_ms("2025-10-09T08:25:23+08:00"), + Some(1759969523000) + ); + assert_eq!(parse_iso_ms("garbage"), None); + } + + #[test] + fn greedy_match_is_one_to_one_by_delta() { + let p = |pid: i64, cwd: &str, start: i64| AgentProc { + pid, + ppid: None, + tty: None, + command: None, + cwd: Some(cwd.into()), + session_file: None, + start_time_ms: Some(start), + }; + let s = |id: &str, cwd: &str, bt: i64| SessionFile { + session_id: id.into(), + file_path: format!("/x/{id}.jsonl"), + project_dir: "/x".into(), + birthtime_ms: bt, + resolved_cwd: cwd.into(), + }; + // p1 nearer s1, p2 also within tolerance of s1 — s1 wins the closer. + let procs = [p(1, "/a", 0), p(2, "/a", 1000)]; + let pref: Vec<&AgentProc> = procs.iter().collect(); + let sessions = vec![s("s1", "/a", 100), s("s2", "/a", 1200)]; + let m = match_processes_to_sessions(&pref, &sessions); + assert_eq!(m.len(), 2); + assert_eq!(m[0].session_idx, 0); // p1↔s1 (delta 100) + // out-of-tolerance and wrong-cwd pairs excluded + let procs2 = [p(3, "/a", 0), p(4, "/b", 0)]; + let pref2: Vec<&AgentProc> = procs2.iter().collect(); + let sessions2 = vec![s("s3", "/a", MATCH_TOLERANCE_MS + 1), s("s4", "/a", 10)]; + let m2 = match_processes_to_sessions(&pref2, &sessions2); + assert_eq!(m2.len(), 1); + assert_eq!(m2[0].proc.pid, 3); + assert_eq!(m2[0].session_idx, 1); + } +} diff --git a/rust/crates/devkitd/Cargo.toml b/rust/crates/devkitd/Cargo.toml new file mode 100644 index 000000000..25f436555 --- /dev/null +++ b/rust/crates/devkitd/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "devkitd" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +devkit-core.workspace = true +devkit-harness.workspace = true +tokio.workspace = true +serde_json.workspace = true +anyhow.workspace = true +libc.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/rust/crates/devkitd/src/main.rs b/rust/crates/devkitd/src/main.rs new file mode 100644 index 000000000..7bc4720cb --- /dev/null +++ b/rust/crates/devkitd/src/main.rs @@ -0,0 +1,86 @@ +mod server; + +use anyhow::Result; +use server::Daemon; +use std::path::PathBuf; +use std::sync::Arc; + +fn data_dir() -> PathBuf { + dirs_home().join(".ai-devkit") +} + +fn dirs_home() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")) +} + +fn socket_path() -> PathBuf { + data_dir().join("daemon.sock") +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + + let cmd = std::env::args().nth(1).unwrap_or_else(|| "serve".into()); + match cmd.as_str() { + "serve" => { + let daemon = Arc::new(Daemon::new(&data_dir(), socket_path(), dirs_home())?); + daemon.apply_sweep(); // warm the cache before accepting clients + server::serve(daemon).await + } + "install" => { + #[cfg(target_os = "linux")] + { + install_systemd() + } + #[cfg(not(target_os = "linux"))] + { + // The unit is a systemd --user service — writing it on + // non-systemd platforms leaves a dead file. + eprintln!("install writes a systemd --user unit; only supported on Linux"); + std::process::exit(2); + } + } + "status" => { + println!("socket: {}", socket_path().display()); + println!("db: {}", data_dir().join("daemon.db").display()); + // Probe the socket so status means something: a stale socket + // file left by a crash reads as "not running", not "up". + let state = match tokio::net::UnixStream::connect(&socket_path()).await { + Ok(_) => "listening", + Err(_) if socket_path().exists() => "stale socket file (daemon down)", + Err(_) => "not running", + }; + println!("daemon: {state}"); + Ok(()) + } + other => { + eprintln!("usage: devkitd [serve|install|status] (got {other})"); + std::process::exit(2); + } + } +} + +/// Write a systemd --user unit for boot persistence. Optional — clients +/// auto-spawn the daemon on socket-miss, so this is persistence, not startup. +#[cfg(target_os = "linux")] +fn install_systemd() -> Result<()> { + let dir = dirs_home().join(".config/systemd/user"); + std::fs::create_dir_all(&dir)?; + let exe = std::env::current_exe()?; + let unit = format!( + "[Unit]\nDescription=ai-devkit daemon\n\n[Service]\nExecStart={} serve\nRestart=on-failure\n\n[Install]\nWantedBy=default.target\n", + exe.display() + ); + let path = dir.join("devkitd.service"); + std::fs::write(&path, unit)?; + println!("wrote {}", path.display()); + println!("enable with: systemctl --user enable --now devkitd.service"); + Ok(()) +} diff --git a/rust/crates/devkitd/src/server.rs b/rust/crates/devkitd/src/server.rs new file mode 100644 index 000000000..e22704b99 --- /dev/null +++ b/rust/crates/devkitd/src/server.rs @@ -0,0 +1,608 @@ +use anyhow::Result; +use devkit_core::agent::EnrichedAgent; +use devkit_core::proto::{Event, Request, Response}; +use devkit_core::store::{chrono_now, Store}; +use serde_json::{json, Value}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::sync::broadcast; + +pub struct Daemon { + pub store: Arc, + pub events: broadcast::Sender, + /// Serializes store.emit + broadcast send so subscribers always observe + /// events in seq order — seqs are assigned under the store mutex but the + /// send happens after emit returns, so two racing emits could otherwise + /// broadcast out of order. + emit_mutex: std::sync::Mutex<()>, + pub started_at: i64, + pub socket_path: PathBuf, + pub enricher: devkit_harness::Registry, + /// Fully attributed agents refreshed per sweep so `agent.list` is + /// O(1) over a prebuilt list. + pub enriched: RwLock>, + /// User home dir adapters resolve session trees (~/.claude, …) against. + pub home: PathBuf, +} + +impl Daemon { + pub fn new(data_dir: &Path, socket_path: PathBuf, home: PathBuf) -> Result { + std::fs::create_dir_all(data_dir)?; + // The db may one day hold sensitive state — the socket is + // already 0600, tighten the directory so wal/shm sidecars (created + // per open with default umask) aren't world-readable either. + let _ = std::fs::set_permissions( + data_dir, + std::fs::Permissions::from_mode(0o700), + ); + let db = data_dir.join("daemon.db"); + // A corrupt db previously killed devkitd on open — and autostart + // would respawn it on every call into the same crash. Quarantine + // once and start over with a fresh file. + let store = match Store::open(&db) { + Ok(s) => s, + Err(e) => { + let backup = format!("daemon.db.corrupt-{}", chrono_now()); + tracing::warn!("daemon.db failed to open ({e:#}); moving to {backup}"); + for side in ["", "-wal", "-shm"] { + let src = data_dir.join(format!("daemon.db{side}")); + if src.exists() { + let _ = std::fs::rename(&src, data_dir.join(format!("{backup}{side}"))); + } + } + Store::open(&db)? + } + }; + let (tx, _) = broadcast::channel(1024); + Ok(Self { + store: Arc::new(store), + events: tx, + emit_mutex: std::sync::Mutex::new(()), + started_at: chrono_now(), + socket_path, + enricher: devkit_harness::default_registry(&home), + enriched: RwLock::new(Vec::new()), + home, + }) + } + + fn emit(&self, kind: &str, payload: Value) { + let _g = self.emit_mutex.lock().unwrap(); + match self.store.emit(kind, &payload) { + Ok(seq) => { + // send() only fails when there are no subscribers — normal. + let _ = self.events.send(Event { + seq, + ts: chrono_now(), + kind: kind.to_string(), + payload, + }); + } + Err(e) => tracing::warn!("event emit failed ({kind}): {e:#}"), + } + } + + /// Handle one request line. Returns Err for a subscribed connection + /// (handled inside dispatch by streaming, not a normal response). + fn dispatch(&self, req: Request) -> Response { + let id = req.id; + match req.method.as_str() { + "ping" => Response::ok(id, json!({"pong": true, "startedAt": self.started_at})), + "daemon.status" => Response::ok( + id, + json!({ + "version": env!("CARGO_PKG_VERSION"), + "startedAt": self.started_at, + "socket": self.socket_path, + }), + ), + "agent.list" => { + let cached = self.enriched.read().unwrap().clone(); + Response::ok(id, serde_json::to_value(cached).unwrap()) + } + "shutdown" => { + // Caller expects a response before exit; schedule exit. + tokio::spawn(async { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + std::process::exit(0); + }); + Response::ok(id, json!({"ok": true})) + } + m => Response::err(id, format!("unknown method: {m}")), + } + } + + /// Apply a discovery sweep, refresh the enriched cache, and emit + /// appear/vanish events. + /// + /// Runtime-named procs (node/bun — script-distributed harnesses) are + /// swept but only visible when some adapter claims them, mirroring TS + /// `isCandidateProcess` gating; dedicated-binary procs stay raw-visible + /// so unported harnesses still surface. + pub fn apply_sweep(&self) { + let Some(all) = devkit_core::discover::sweep() else { + // ps failed — keep the last good snapshot rather than wiping the + // agent table and storming phantom disappeared/appeared events. + tracing::warn!("discovery sweep failed; keeping previous snapshot"); + return; + }; + tracing::trace!(procs = all.len(), "sweep"); + let candidate_pids: std::collections::HashSet = all + .iter() + .filter(|p| self.enricher.any_can_handle(p)) + .map(|p| p.pid) + .collect(); + let mut procs: Vec<_> = all + .iter() + .filter(|p| { + !devkit_core::discover::is_runtime_command(p.command.as_deref().unwrap_or("")) + || candidate_pids.contains(&p.pid) + }) + .cloned() + .collect(); + devkit_core::discover::enrich_agents(&mut procs); + let ctx = devkit_harness::SweepContext { + processes: &procs, + now: chrono_now(), + home: &self.home, + }; + // Compute outside the lock: `*w = f()` evaluates the write guard first + // and would hold it for the entire enrich pass. + let enriched = self.enricher.enrich(&ctx); + // Keep the previous list: a disappeared pid is absent from the fresh + // one, so its event payload carries last-known agent info. + let prev = std::mem::replace(&mut *self.enriched.write().unwrap(), enriched); + match self.store.apply_agent_snapshot(&procs) { + Ok((appeared, gone)) => { + let cur = self.enriched.read().unwrap(); + // Disappeared first: on pid reuse the stale identity must + // leave before the fresh one arrives or subscribers see them + // inverted. + for pid in gone { + self.emit("agent.disappeared", lifecycle_payload(pid, &prev)); + } + for pid in appeared { + self.emit("agent.appeared", lifecycle_payload(pid, &cur)); + } + } + Err(e) => tracing::warn!("agent snapshot apply failed: {e:#}"), + } + } +} + +/// Payload for agent lifecycle events: the pid plus every enriched row the +/// sweep attributed to it (one proc can map to several sessions; unattributed +/// procs get an empty `agents`). +fn lifecycle_payload(pid: i64, agents: &[EnrichedAgent]) -> Value { + json!({ + "pid": pid, + "agents": agents.iter().filter(|a| a.pid as i64 == pid).collect::>(), + }) +} + +/// SO_PEERCRED check: same-uid only. Returns true if the peer is us. +#[cfg(target_os = "linux")] +fn peer_is_same_user(stream: &UnixStream) -> bool { + use std::os::unix::io::AsRawFd; + let mut cred: libc::ucred = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as u32; + let ok = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut cred as *mut _ as *mut _, + &mut len, + ) + }; + ok == 0 && cred.uid == unsafe { libc::getuid() } +} + +#[cfg(not(target_os = "linux"))] +fn peer_is_same_user(_stream: &UnixStream) -> bool { + true +} + +pub async fn serve(daemon: Arc) -> Result<()> { + if daemon.socket_path.exists() { + // Refuse to displace a live daemon: unlinking the socket file leaves + // the old process sweeping and writing daemon.db invisibly — two + // daemons, one reachable. Only a dead owner's file is unlinked. + match std::os::unix::net::UnixStream::connect(&daemon.socket_path) { + Ok(_) => anyhow::bail!("devkitd already running"), + Err(_) => std::fs::remove_file(&daemon.socket_path)?, + } + } + let listener = UnixListener::bind(&daemon.socket_path)?; + std::fs::set_permissions(&daemon.socket_path, std::fs::Permissions::from_mode(0o600))?; + tracing::info!("listening on {}", daemon.socket_path.display()); + + // Discovery loop: one sweep per interval for all subscribers. + { + let d = daemon.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(2)); + loop { + tick.tick().await; + // Sweeps are synchronous fs/process work; run them on the + // blocking pool so a long pass never stalls request handlers. + let d = d.clone(); + if let Err(e) = tokio::task::spawn_blocking(move || d.apply_sweep()).await { + // A panicking sweep would otherwise kill discovery + // silently — the daemon looks healthy while going stale. + tracing::warn!("sweep task failed: {e}"); + } + } + }); + } + + loop { + let (stream, _) = listener.accept().await?; + if !peer_is_same_user(&stream) { + tracing::warn!("rejected cross-uid connection"); + continue; + } + let d = daemon.clone(); + tokio::spawn(async move { + if let Err(e) = handle_conn(d, stream).await { + tracing::debug!("connection closed with error: {e:#}"); + } + }); + } +} + +/// Methods cheap enough to run on the async worker — everything else goes +/// to the blocking pool so a slow probe can't stall other connections. +const INLINE_METHODS: &[&str] = &["ping", "daemon.status", "agent.list"]; + +/// Cap on request bytes per connection. Requests are single-line JSON; a +/// subscription conn sends exactly one. Bounds the line buffer a hostile +/// or buggy client can grow by streaming without a newline. +const MAX_REQUEST_BYTES: u64 = 4 << 20; + +async fn handle_conn(daemon: Arc, stream: UnixStream) -> Result<()> { + use tokio::io::AsyncReadExt; + let (read, mut write) = stream.into_split(); + let mut lines = BufReader::new(read.take(MAX_REQUEST_BYTES)).lines(); + let mut sub: Option> = None; + // Highest seq delivered on this connection — replay sets the floor and + // live frames advance it, so nothing is sent twice or skipped. + let mut last_delivered: u64 = 0; + + loop { + tokio::select! { + line = lines.next_line() => { + let Some(line) = line? else { return Ok(()); }; + let parsed: std::result::Result = serde_json::from_str(&line); + match parsed { + Ok(req) if req.method == "subscribe" => { + let after = req.params["afterSeq"].as_u64().unwrap_or(0); + // Live UIs pass liveOnly — replaying the persisted + // log would only trigger redundant refreshes. + let live_only = req.params["liveOnly"].as_bool().unwrap_or(false); + // Subscribe BEFORE replaying: events emitted during the + // replay land in the receiver's buffer instead of the + // old replay→subscribe gap. Replayed seqs become the + // delivery floor, so buffered live frames that overlap + // the replay are dropped rather than sent twice. + let rx = daemon.events.subscribe(); + if !live_only { + match daemon.store.events_after(after, 10_000) { + Ok(events) => { + for ev in events { + last_delivered = last_delivered.max(ev.seq); + let s = serde_json::to_string(&json!({"event": ev}))?; + write.write_all(s.as_bytes()).await?; + write.write_all(b"\n").await?; + } + } + Err(e) => { + tracing::warn!("subscribe replay failed: {e:#}") + } + } + } + last_delivered = last_delivered.max(after); + sub = Some(rx); + let resp = Response::ok(req.id, json!({"subscribed": true})); + write.write_all(serde_json::to_string(&resp)?.as_bytes()).await?; + write.write_all(b"\n").await?; + } + Ok(req) => { + // Keep synchronous dispatch off the async workers — + // the blocking pool absorbs any method that does + // real work so connections stay responsive. + let d = daemon.clone(); + let resp = if INLINE_METHODS.contains(&req.method.as_str()) { + d.dispatch(req) + } else { + tokio::task::spawn_blocking(move || d.dispatch(req)).await? + }; + write.write_all(serde_json::to_string(&resp)?.as_bytes()).await?; + write.write_all(b"\n").await?; + } + Err(e) => { + let resp = Response::err(None, format!("bad request: {e}")); + write.write_all(serde_json::to_string(&resp)?.as_bytes()).await?; + write.write_all(b"\n").await?; + } + } + } + ev = async { + match sub.as_mut() { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + } => { + match ev { + Ok(e) => { + if e.seq > last_delivered { + last_delivered = e.seq; + let s = serde_json::to_string(&json!({"event": e}))?; + write.write_all(s.as_bytes()).await?; + write.write_all(b"\n").await?; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + // The 1024-event buffer overflowed — frames are gone. + // Tell the client so it refetches state instead of + // silently missing history. seq=0 keeps it below + // last_delivered so it never counts as delivered. + tracing::warn!("subscriber lagged {n} events behind"); + let hint = json!({"event": { + "seq": 0, + "ts": chrono_now(), + "kind": "subscription.lagged", + "payload": {"dropped": n}, + }}); + write.write_all(serde_json::to_string(&hint)?.as_bytes()).await?; + write.write_all(b"\n").await?; + } + Err(broadcast::error::RecvError::Closed) => { + return Ok(()); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::UnixStream; + + async fn test_daemon() -> (Arc, PathBuf) { + static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "aidk-srv-{}-{}", + std::process::id(), + N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + let sock = dir.join("d.sock"); + // Tests use the temp dir as home — no harness session trees there. + let d = Arc::new(Daemon::new(&dir, sock.clone(), dir.clone()).unwrap()); + d.apply_sweep(); // warm the cache like main() does before serving + let d2 = d.clone(); + tokio::spawn(async move { + let _ = serve(d2).await; + }); + for _ in 0..100 { + if sock.exists() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + (d, sock) + } + + async fn rpc(sock: &PathBuf, line: &str) -> Value { + let mut s = UnixStream::connect(sock).await.unwrap(); + s.write_all(line.as_bytes()).await.unwrap(); + s.write_all(b"\n").await.unwrap(); + let mut lines = BufReader::new(s).lines(); + serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap() + } + + #[tokio::test] + async fn end_to_end_rpc_subscribe_replay_and_live() { + let (d, sock) = test_daemon().await; + + // Persisted directly so subscribe replays it — registry writes no + // longer emit events of their own. + d.store.emit("test.base", &json!({})).unwrap(); + + // Subscribe from seq 0: the persisted event replays before live frames. + let mut s = UnixStream::connect(&sock).await.unwrap(); + s.write_all(br#"{"id":2,"method":"subscribe","params":{"afterSeq":0}}"#.as_ref()) + .await + .unwrap(); + s.write_all(b"\n").await.unwrap(); + let mut lines = BufReader::new(s).lines(); + // Replay may include discovery events (the sweep ticks immediately); + // read until the subscribe ack, collecting replayed events. + let mut saw_base = false; + loop { + let line: Value = + serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + if line["result"]["subscribed"] == true { + break; + } + if line["event"]["kind"] == "test.base" { + saw_base = true; + } + } + assert!(saw_base); + + let d2 = d.clone(); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + let seq = d2.store.emit("test.ping", &json!({})).unwrap(); + let _ = d2.events.send(devkit_core::proto::Event { + seq, + ts: 0, + kind: "test.ping".into(), + payload: json!({}), + }); + }); + // Live stream may interleave discovery events; wait for ours. + let mut saw_ping = false; + for _ in 0..50 { + let line: Value = + serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + if line["event"]["kind"] == "test.ping" { + saw_ping = true; + break; + } + } + assert!(saw_ping); + } + + #[tokio::test] + async fn serve_refuses_to_displace_a_live_daemon() { + let (d, sock) = test_daemon().await; + let d2 = Arc::new(Daemon::new(&d.home, sock.clone(), d.home.clone()).unwrap()); + let err = serve(d2).await.unwrap_err(); + assert!(err.to_string().contains("already running")); + // The original daemon still answers on the socket. + let resp = rpc(&sock, r#"{"id":9,"method":"ping"}"#).await; + assert_eq!(resp["result"]["pong"], true); + } + + #[tokio::test] + async fn subscribe_filters_live_frames_at_or_below_the_replayed_floor() { + let (d, sock) = test_daemon().await; + + // Persist one event so the replay sets a nonzero delivery floor. + let replayed_seq = d.store.emit("floor.base", &json!({})).unwrap(); + + let mut s = UnixStream::connect(&sock).await.unwrap(); + s.write_all(br#"{"id":1,"method":"subscribe","params":{"afterSeq":0}}"#.as_ref()) + .await + .unwrap(); + s.write_all(b"\n").await.unwrap(); + let mut lines = BufReader::new(s).lines(); + loop { + let line: Value = + serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + if line["result"]["subscribed"] == true { + break; + } + } + + // A live frame carrying an already-delivered seq — the case the + // replay→subscribe overlap produces — must be dropped; a fresh seq + // must pass through. + let _ = d.events.send(Event { + seq: replayed_seq, + ts: 0, + kind: "floor.stale".into(), + payload: json!({}), + }); + let fresh_seq = d.store.emit("floor.fresh", &json!({})).unwrap(); + let _ = d.events.send(Event { + seq: fresh_seq, + ts: 0, + kind: "floor.fresh".into(), + payload: json!({}), + }); + + let mut saw_stale = false; + let mut saw_fresh = false; + for _ in 0..50 { + let line: Value = serde_json::from_str( + &tokio::time::timeout(std::time::Duration::from_secs(2), lines.next_line()) + .await + .unwrap() + .unwrap() + .unwrap(), + ) + .unwrap(); + match line["event"]["kind"].as_str() { + Some("floor.stale") => saw_stale = true, + Some("floor.fresh") => { + saw_fresh = true; + break; + } + _ => {} + } + } + assert!(saw_fresh); + assert!(!saw_stale); + } + + #[tokio::test] + async fn agent_list_returns_cached_result() { + let (d, sock) = test_daemon().await; + let r = rpc(&sock, r#"{"id":1,"method":"agent.list"}"#).await; + // Live sweep may surface real claude processes on the dev machine — + // the contract is a bare array, not a wrapper. + assert!(r["result"].is_array()); + assert!(r["result"]["ported"].is_null()); + + // The cache is what apply_sweep refreshes — write directly and + // verify the RPC serves exactly that. + *d.enriched.write().unwrap() = vec![EnrichedAgent { + name: "t".into(), + agent_type: "claude".into(), + status: "idle".into(), + summary: String::new(), + pid: 1, + project_path: "/p".into(), + session_id: "s".into(), + last_active: "2026-10-09T00:00:00.000Z".into(), + pinned: None, + session_file_path: None, + }]; + let r = rpc(&sock, r#"{"id":2,"method":"agent.list"}"#).await; + assert_eq!(r["result"].as_array().unwrap().len(), 1); + assert_eq!(r["result"][0]["type"], "claude"); + } + + #[test] + fn lifecycle_payload_carries_agent_rows_for_the_pid() { + let row = |pid: u64, sid: &str| EnrichedAgent { + name: "n".into(), + agent_type: "claude".into(), + status: "idle".into(), + summary: String::new(), + pid, + project_path: "/p".into(), + session_id: sid.into(), + last_active: "t".into(), + pinned: None, + session_file_path: None, + }; + let list = vec![row(7, "s-1"), row(7, "s-2"), row(9, "other")]; + + // Multi-session procs surface every attributed row. + let p = lifecycle_payload(7, &list); + assert_eq!(p["pid"], json!(7)); + assert_eq!(p["agents"].as_array().unwrap().len(), 2); + assert_eq!(p["agents"][0]["sessionId"], "s-1"); + + // Unattributed procs still emit — with an empty agent list. + assert_eq!(lifecycle_payload(5, &list)["agents"], json!([])); + } + + #[tokio::test] + async fn malformed_line_and_unknown_method_error_without_killing_conn() { + let (_d, sock) = test_daemon().await; + let mut s = UnixStream::connect(&sock).await.unwrap(); + s.write_all(b"not json\n").await.unwrap(); + s.write_all(br#"{"id":9,"method":"nope"}"#.as_ref()) + .await + .unwrap(); + s.write_all(b"\n").await.unwrap(); + let mut lines = BufReader::new(s).lines(); + let e1: Value = serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + assert!(e1["error"].is_string()); + let e2: Value = serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + assert!(e2["error"].as_str().unwrap().contains("unknown method")); + } +} diff --git a/rust/project.json b/rust/project.json new file mode 100644 index 000000000..ec4ed0026 --- /dev/null +++ b/rust/project.json @@ -0,0 +1,54 @@ +{ + "name": "rust", + "root": "rust", + "sourceRoot": "rust/crates", + "projectType": "application", + "targets": { + "build": { + "executor": "nx:run-commands", + "inputs": ["{projectRoot}/**/*.rs", "{projectRoot}/**/Cargo.toml", "{projectRoot}/Cargo.lock"], + "options": { + "command": "cargo build --workspace", + "cwd": "rust" + } + }, + "build:release": { + "executor": "nx:run-commands", + "inputs": ["{projectRoot}/**/*.rs", "{projectRoot}/**/Cargo.toml", "{projectRoot}/Cargo.lock"], + "options": { + "command": "cargo build --workspace --release", + "cwd": "rust" + } + }, + "test": { + "executor": "nx:run-commands", + "inputs": ["{projectRoot}/**/*.rs", "{projectRoot}/**/Cargo.toml", "{projectRoot}/Cargo.lock"], + "options": { + "command": "cargo test --workspace", + "cwd": "rust" + } + }, + "lint": { + "executor": "nx:run-commands", + "inputs": ["{projectRoot}/**/*.rs", "{projectRoot}/**/Cargo.toml", "{projectRoot}/Cargo.lock"], + "options": { + "command": "cargo clippy --workspace --all-targets -- -D warnings", + "cwd": "rust" + } + }, + "fmt": { + "executor": "nx:run-commands", + "options": { + "command": "cargo fmt --all", + "cwd": "rust" + } + }, + "fmt:check": { + "executor": "nx:run-commands", + "options": { + "command": "cargo fmt --all -- --check", + "cwd": "rust" + } + } + } +}