From bc15c0dcf88a1519ab8ec5daab4a22047a332d31 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sat, 10 Oct 2026 17:30:42 +0200 Subject: [PATCH] feat(muse-adapter): add Muse Code harness to agent-manager, CLI, and Rust devkitd Detect live sessions via runtime-file PID join plus dated-archive walk, parse framed transcripts, report readiness. Extends shared process discovery with opt-in basename prefixes for versioned launchers. Golden fixtures replay on TS and Rust. --- .../design/2026-10-10-feature-muse-adapter.md | 78 ++++ .../2026-10-10-feature-muse-adapter.md | 89 ++++ .../2026-10-10-feature-muse-adapter.md | 91 ++++ .../2026-10-10-feature-muse-adapter.md | 91 ++++ .../2026-10-10-feature-muse-adapter.md | 56 +++ fixtures/harness/muse/fallback.json | 53 +++ fixtures/harness/muse/matched.json | 32 ++ fixtures/readiness/degraded.json | 43 ++ fixtures/readiness/healthy.json | 44 +- packages/agent-manager/src/AgentManager.ts | 42 +- .../harnesses/muse/MuseAdapter.test.ts | 199 ++++++++ .../harnesses/muse/MuseAgentMapper.test.ts | 79 ++++ .../harnesses/muse/MuseSessionLocator.test.ts | 164 +++++++ .../harnesses/muse/MuseSessionParser.test.ts | 279 +++++++++++ .../readiness/AgentReadiness.test.ts | 36 ++ .../harnesses/runtimeProfiles.test.ts | 13 + .../src/__tests__/utils/process.test.ts | 30 ++ .../src/adapters/AgentAdapter.ts | 9 + .../agent-manager/src/fixtures/capture.ts | 10 +- packages/agent-manager/src/harnesses/index.ts | 2 + .../src/harnesses/muse/MuseAdapter.ts | 159 +++++++ .../src/harnesses/muse/MuseAgentMapper.ts | 34 ++ .../src/harnesses/muse/MuseSessionLocator.ts | 286 ++++++++++++ .../src/harnesses/muse/MuseSessionParser.ts | 438 ++++++++++++++++++ .../src/harnesses/muse/readiness.ts | 38 ++ .../src/harnesses/readiness/AgentReadiness.ts | 2 + .../src/harnesses/runtimeProfiles.ts | 1 + .../agent-manager/src/harnesses/shared.ts | 15 +- packages/agent-manager/src/utils/process.ts | 72 ++- .../services/status/status.service.test.ts | 2 + .../tui/console/StartAgentPane.test.ts | 7 +- .../cli/src/__tests__/util/sessions.test.ts | 2 +- packages/cli/src/util/agent.ts | 2 + rust/crates/devkit-core/src/discover.rs | 17 +- rust/crates/devkit-harness/src/lib.rs | 10 + .../crates/devkit-harness/src/muse/locator.rs | 275 +++++++++++ rust/crates/devkit-harness/src/muse/mod.rs | 160 +++++++ rust/crates/devkit-harness/src/muse/parser.rs | 363 +++++++++++++++ rust/crates/devkit-harness/src/shared.rs | 23 + 39 files changed, 3314 insertions(+), 32 deletions(-) create mode 100644 docs/ai/design/2026-10-10-feature-muse-adapter.md create mode 100644 docs/ai/implementation/2026-10-10-feature-muse-adapter.md create mode 100644 docs/ai/planning/2026-10-10-feature-muse-adapter.md create mode 100644 docs/ai/requirements/2026-10-10-feature-muse-adapter.md create mode 100644 docs/ai/testing/2026-10-10-feature-muse-adapter.md create mode 100644 fixtures/harness/muse/fallback.json create mode 100644 fixtures/harness/muse/matched.json create mode 100644 packages/agent-manager/src/__tests__/harnesses/muse/MuseAdapter.test.ts create mode 100644 packages/agent-manager/src/__tests__/harnesses/muse/MuseAgentMapper.test.ts create mode 100644 packages/agent-manager/src/__tests__/harnesses/muse/MuseSessionLocator.test.ts create mode 100644 packages/agent-manager/src/__tests__/harnesses/muse/MuseSessionParser.test.ts create mode 100644 packages/agent-manager/src/harnesses/muse/MuseAdapter.ts create mode 100644 packages/agent-manager/src/harnesses/muse/MuseAgentMapper.ts create mode 100644 packages/agent-manager/src/harnesses/muse/MuseSessionLocator.ts create mode 100644 packages/agent-manager/src/harnesses/muse/MuseSessionParser.ts create mode 100644 packages/agent-manager/src/harnesses/muse/readiness.ts create mode 100644 rust/crates/devkit-harness/src/muse/locator.rs create mode 100644 rust/crates/devkit-harness/src/muse/mod.rs create mode 100644 rust/crates/devkit-harness/src/muse/parser.rs diff --git a/docs/ai/design/2026-10-10-feature-muse-adapter.md b/docs/ai/design/2026-10-10-feature-muse-adapter.md new file mode 100644 index 000000000..7436933cd --- /dev/null +++ b/docs/ai/design/2026-10-10-feature-muse-adapter.md @@ -0,0 +1,78 @@ +--- +phase: design +title: System Design & Architecture +description: Design the system architecture and technical approach +--- + +# System Design & Architecture + +## Architecture + +New `muse/` harness mirroring the `claude/` and `codex/` layout. Detection joins two sources: +the small live set (`runtime/muse/sessions/*.json`) and the dated archive +(`sessions/yyyy/mm/dd//session.jsonl`). + +```mermaid +flowchart LR + PS[process snapshot] --> LOC[MuseSessionLocator] + RT[runtime/muse/sessions/*.json] --> LOC + AR[sessions/yyyy/mm/dd/*/session.jsonl] --> LOC + LOC -->|direct + legacy matches| MAP[MuseAgentMapper] + LOC --> ADP[MuseAdapter] + PAR[MuseSessionParser] --> ADP + ADP --> AM[AgentManager] + RD[muse/readiness.ts] --> AR2[AgentReadiness] + CR[muse/credentials.ts] --> RD +``` + +## Components + +- `MuseSessionLocator`: three-stage match like Claude's — runtime-file PID match (authoritative, + with a start-time staleness guard), then archive fallback by session id, then legacy + cwd+birthtime heuristic. Enumerates the archive with a flatten walk + UUID regex, mtime + cache, excluding `subagent/`, `approval-review/`, logs, and sqlite files. +- `MuseSessionParser`: two-stage framed parse (outer line, then inner `record_json`) dispatched + on `payload_type`; extracts user/assistant messages, first-user-message filtering, µs→ms + timestamps, `workspace_root`/`model_id` from metadata payloads. Spike alternative: `muse export` + JSON document (decided in planning; see open item). +- `MuseAgentMapper`: name from `workspace_label`/`session_name`, summary from last user prompt, + status from tail heuristic (no published live status exists). +- `MuseAdapter`: registers `muse` in `AGENT_TYPES`, `processNames` covering the + `muse-bin--` shape, `canHandle`, `detectAgents`, tail-efficient + `getConversation`, `listSessions`, `findSessionsById` (runtime-first). +- Type-forced registration checklist (all three are `Record` over every `AGENT_TYPES` + member, so the compiler enforces them): `HARNESS_RUNTIME_PROFILES.muse` in + `runtimeProfiles.ts` using a `matchArgv0Name("muse")`-style matcher (argv0 basenames look + like `muse-bin-1.4.4-R5419.1`, so exact-name matching fails); `READINESS_PROFILES.muse` + in `AgentReadiness.ts` pointing at `muse/readiness.ts`; adapter instance in + `createBuiltinAdapters()`. The readiness `executableCheck` resolves the profile's + `command`, so the profile command must be `muse` (on PATH). +- `muse/readiness.ts` + `muse/credentials.ts`: executable on PATH, `~/.config/muse/` dir, + `auth.json` state, built-in skills presence — reusing the shared readiness checks. + +## Data Models + +- Runtime entry: `{ schema_version, session_id, session_name|null, endpoint_hint?, + workspace_label?, target_eligibility?, process_generation_hint: "pid=" }`. +- Transcript frame: `{ retained_frame, record_json: string, ... }` with inner + `{ stream:{kind,id}, sequence, recorded_at(µs), record_type, payload_type, payload }`. +- Outputs reuse `AgentInfo`, `SessionSummary`, `ConversationMessage` unchanged. + +## Design Decisions + +- Runtime-first lookup everywhere: the live set is tiny, the archive walk is cached. +- Exclude, never parse, non-transcript files; require `session.jsonl` presence per session dir. +- Verbatim session-id round-trip for `muse resume` compatibility. +- Read-only: no writes under Muse directories; temp files only if the export path wins the spike. + +## Security / Performance + +- Tolerate `0600` files; skip unreadable entries instead of failing enumeration. +- Never log transcript content or the export document (bundles encrypted reasoning). +- Tail reads scale with the tail; full-file reads only where the caller asks for full history. +- One cached archive walk per refresh; keyed invalidation by directory mtime. + +## Open Items + +- Parser vs `export` spike outcome (cost/latency/freshness comparison on a large transcript). +- Status heuristic definition (which tail payloads map to running/waiting/idle). diff --git a/docs/ai/implementation/2026-10-10-feature-muse-adapter.md b/docs/ai/implementation/2026-10-10-feature-muse-adapter.md new file mode 100644 index 000000000..1c7988add --- /dev/null +++ b/docs/ai/implementation/2026-10-10-feature-muse-adapter.md @@ -0,0 +1,89 @@ +--- +phase: implementation +title: Implementation Guide +description: Guide the implementation with detailed technical specifications +--- + +# Implementation Notes + +What shipped for `feature-muse-adapter` (v1 vertical slice, `agent-manager` only). + +## Changed files + +- `packages/agent-manager/src/harnesses/muse/MuseSessionParser.ts` (new): framed-transcript + parsing (direct / `record_json` / `retained_frame` children), `payload_type` dispatch, + µs→ms timestamps, incremental cache + tail reader reuse. +- `packages/agent-manager/src/harnesses/muse/MuseSessionLocator.ts` (new): runtime-file PID + matching with birthtime staleness guard, dated-archive walk with UUID filter, legacy + cwd+birthtime fallback via bounded metadata head-reads. +- `packages/agent-manager/src/harnesses/muse/MuseAgentMapper.ts` (new): project-named + agents, last-prompt summaries, parser-derived status. +- `packages/agent-manager/src/harnesses/muse/MuseAdapter.ts` (new): `type "muse"`, + `processNames ["muse"]` + `processNamePrefixes ["muse"]`, precise `canHandle`. +- `packages/agent-manager/src/harnesses/muse/readiness.ts` (new): config dir + `.config/muse`, auth probe on `auth.json` presence. +- Shared discovery extension: `AgentAdapter.processNamePrefixes`, prefix matching in + `utils/process.ts` (capture, filter), threaded through `AgentManager`, + `harnesses/shared.ts`, `fixtures/capture.ts`. Existing adapters pass no prefixes, so + their capture call shapes are byte-identical (verified by untouched mock assertions). +- Registration: `AGENT_TYPES` + `createBuiltinAdapters` + `READINESS_PROFILES.muse` + + `HARNESS_RUNTIME_PROFILES.muse` (`matchArgv0Name("muse")`, command `muse`). +- Tests: 4 new muse suites (20 tests) + prefix tests + runtime-profile case + muse + readiness cases; updated readiness keys, fixture bundles + (`fixtures/harness/muse/matched|fallback.json`), readiness fixtures + (healthy/degraded muse entries). +- Deliberately NOT created: `muse/credentials.ts` (no token consumer; auth probe reads + `auth.json` directly like the pi pattern). + +## Decisions and deviations + +- T1 spike picked the hand-written framed parser over `muse export`: 0.37s in-process vs + 0.51s + subprocess + 13MB temp file + sensitive reasoning bundle. +- Staleness guard compares process start against transcript birthtime (runtime files carry + no start timestamp). +- First-user-message may come from any pre-skip line (Claude semantics); session start + from the first line only. +- Fixture timestamps: matched bundle uses `$NOW` starts (birthtime-coupled) with `$TODAY` + day dirs; fallback uses a frozen clock. + +## Edge cases handled + +0600 files skipped, malformed runtime entries ignored, non-UUID dirs excluded, `subagent/` +and logs never match (require `session.jsonl`), verbatim id round-trip, empty transcripts +return null. + +## Minor deviation from design + +Design mentioned an mtime cache for the archive walk; not implemented — walks are +readdir-only and bounded like Claude's `discoverHistoricalSessionFiles`. Add caching only +if profiling shows refresh cost. + +## Cross-package fallout (found via CLI build) + +Adding `muse` to `AGENT_TYPES` broke the CLI build: `AGENT_TYPE_LABELS` and +`AGENT_TYPE_LABELS_COMPACT` in `packages/cli/src/util/agent.ts` are exhaustive +`Record`. Added `muse: "Muse Code"` / `muse: "muse"`. This also +made muse startable in the console start pane and listable in `--type` filters and +status reports (all automatic via the runtime profile); updated those list-membership +tests. Spawning `muse` from the console works through the profile command, so no +exclusion mechanism was added. + +## Rust `devkitd` detection parity (added scope) + +- `rust/crates/devkit-harness/src/muse/{mod,locator,parser}.rs`: framed-transcript fold + (envelope/children unwrap, µs timestamps, intent/assistant/approval/input signals), + runtime-file PID matching with birthtime staleness guard, dated-archive walk, legacy + cwd+birthtime join; status mirrors TS exactly (no idle override). +- `shared.rs`: `matches_executable_prefix`; `devkit-core/discover.rs`: daemon pool gate + admits `muse`/`muse-*` basenames (precision stays in `can_handle`). +- Wiring: `lib.rs` module + `default_registry` + parity test. +- Golden fixtures `fixtures/harness/muse/*` replay byte-identical through the Rust port; + `cargo test` 70 + 16 green, clippy clean. `cargo fmt` drift is repo-wide pre-existing + (newer local rustfmt); new code matches surrounding style, no reformatting. +- Deliberately NOT ported: conversation reads, incremental cache, display names, model + ids, history listing, readiness checks (daemon surface is detect-only). + +## v2 handoff + +Send transport (endpoint socket vs `session-message` CLI), runtime-file exit cleanup, +status-heuristic refinement, Rust `devkitd` mirror, CLI status surfacing. diff --git a/docs/ai/planning/2026-10-10-feature-muse-adapter.md b/docs/ai/planning/2026-10-10-feature-muse-adapter.md new file mode 100644 index 000000000..f2b6652cc --- /dev/null +++ b/docs/ai/planning/2026-10-10-feature-muse-adapter.md @@ -0,0 +1,91 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: Break down the implementation into actionable tasks +--- + +# Project Planning & Task Breakdown + +Scope: v1 vertical slice (user-approved). Every task traces to requirements (R), +design (D), or testing scenarios (T). + +## Milestone 0 — Worktree bootstrap + +- [x] T0 Bootstrap worktree dependencies. + Outcome: worktree `npm ci` (or documented offline fallback) so vitest/tsc run. + Deps: none. Validation: `vitest --version` runs in worktree agent-manager. + Risk: sandbox network/npm-cache limits; record fallback if install fails. + +## Milestone 1 — Parser spike (time-boxed) + +- [x] T1 Decide framed parser vs `muse export` for `getConversation`. + Outcome: recorded decision with cost/latency/freshness numbers on a large transcript. + Deps: T0. Validation: spike notes in implementation doc. Traces to D-open-item, R-open-item. + Scenarios: T tail-cost, T message ordering. + +## Milestone 2 — Discovery (locator + fixtures) + +- [x] T2 `muse/MuseSessionLocator.ts`: runtime-dir PID match with start-time staleness guard, + dated-archive flatten walk with UUID regex, live/archive merge, noise exclusions + (`subagent/`, `approval-review/`, logs, sqlite), mtime cache. + Outcome: staged strategies mirror Claude's locator. Deps: T0. Validation: locator unit tests + on fake home trees. Traces to R-goals 1–3, D-locator. Scenarios: live detect, stale pid, + archive filter, runtime-first resolve, unreadable files. +- [x] T3 `fixtures/harness/muse/matched.json` + `fallback.json`: live runtime entry, stale + variant, framed transcript lines (test in worktree, never real `~/.local`). + Deps: T2. Validation: fixtures load in tests. Traces to R-goal 6. + +## Milestone 3 — Parsing + mapping + +- [x] T4 `muse/MuseSessionParser.ts`: two-stage framed parse, `payload_type` dispatch, + user/assistant extraction, first-message filtering, µs→ms timestamps. + (If T1 picks export: thin wrapper + redaction handling instead.) + Deps: T1. Validation: parser tests incl. tail slicing. Traces to R-goal 4, D-parser. + Scenarios: ordering, tail cost, filtering, timestamp normalization. +- [x] T5 `muse/MuseAgentMapper.ts`: name from `workspace_label`/`session_name`, summary from + last user prompt, tail-heuristic status, idle threshold via shared helper. + Deps: T4. Validation: mapper tests. Traces to R-goal 1, D-mapper. + +## Milestone 4 — Adapter + registration + +- [x] T6 `muse/MuseAdapter.ts` + type-forced registration: `AGENT_TYPES`, `processNames` + for the `muse-bin-*` shape, `canHandle`, `detectAgents`, `getConversation`, + `listSessions` (strict cwd), `findSessionsById` (runtime-first); register in + `createBuiltinAdapters()`; add `HARNESS_RUNTIME_PROFILES.muse` (`matchArgv0Name`-style, + command `muse`). + Deps: T2, T5. Validation: adapter tests + `tsc` (Record types enforce the checklist). + Traces to R-goals 1–4, D-checklist. Scenarios: detect, cwd filter, verbatim id round-trip. + +## Milestone 5 — Readiness + +- [x] T7 `muse/readiness.ts` + `muse/credentials.ts` (`~/.config/muse/auth.json` state) + + `READINESS_PROFILES.muse` (configDir `.config/muse`). + Deps: T0. Validation: readiness matrix tests. Traces to R-goal 5, D-readiness. + Scenarios: readiness matrix. + +## Milestone 6 — Verification + docs + +- [x] T8 Blast-radius sweep: run full agent-manager suite; fix count/snapshot tests that + enumerate `AGENT_TYPES` or readiness types; run `lint --feature muse-adapter`. + Deps: T6, T7. Validation: green suite, lint 12+ checks pass. +- [ ] T9 Update implementation doc with what shipped, deviations, and v2 handoff notes + (send transport, exit-cleanup, status heuristic). + Deps: T8. Validation: Phase 7 check (code matches design). +- [x] T10 Rust `devkitd` detection parity (added scope): `muse/` port (locator, parser, + adapter), `matches_executable_prefix` in harness `shared.rs`, daemon pool-gate prefix + in `devkit-core` `discover.rs`, registry + parity-test wiring, golden fixtures replay. + Validation: `cargo test -p devkit-harness -p devkit-core` green, clippy clean. + +## Risks / sequencing + +- Order: T0 → T1 → T2/T3 → T4/T5 → T6 → T7 (T7 parallelizable after T0) → T8 → T9. +- Biggest risk: framed-schema drift; mitigated by validating against `muse schema` output. +- `AGENT_TYPES` append affects every consumer of the union; T8 exists for that reason. +- Rust mirror, send, durable runs stay out; any code reaching toward them gets cut. + +## Status (Phase 6 reconciliation) + +All implementation tasks T0–T8 done; T9 (this implementation doc update) in progress. +No scope changes. One intermittent ManagedAgentRuntime spaced-path failure observed once +in full-suite parallelism (passes in isolation and on reruns); treated as pre-existing +flake, needs a watch item, not a code change. No new tasks discovered. diff --git a/docs/ai/requirements/2026-10-10-feature-muse-adapter.md b/docs/ai/requirements/2026-10-10-feature-muse-adapter.md new file mode 100644 index 000000000..8afe544e8 --- /dev/null +++ b/docs/ai/requirements/2026-10-10-feature-muse-adapter.md @@ -0,0 +1,91 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Clarify the problem space, gather requirements, and define success criteria +--- + +# Requirements & Problem Understanding + +## Problem Statement + +**What problem are we solving?** + +Muse Code sessions are invisible to AI DevKit. `agent list`, the console, `listSessions`, +and readiness reporting cover Claude Code, Codex, Gemini, and the other harnesses, but a +developer running `muse` gets no supervision, no history, and no conversation access through +the shared control plane. The current workaround is inspecting `~/.local/share/muse` by hand. + +- Who is affected: developers whose agent mix includes Muse Code (e.g. this repo's own users). +- Current situation: manual `ls`/`cat` of runtime and transcript files; no programmatic access. + +## Goals & Objectives + +- Primary goals (v1, vertical slice in `agent-manager`): + 1. Detect live Muse sessions (process + runtime-file match) with correct pid, session id, + project path, and display name. + 2. Enumerate historical sessions from the dated archive with cwd filtering. + 3. Resolve a session id without a full archive walk (`findSessionsById`, runtime-first). + 4. Read conversations including tail-efficient reads for large transcripts. + 5. Report Muse readiness (executable, config dir, auth, built-in skills) through the + existing readiness pipeline. + 6. Ship fixtures and tests matching the harness conventions (adapter, locator, parser, + readiness, summary equivalence, tail parity). +- Secondary goals: document the Muse storage layout and open questions for the v2 + (send/steer, durable runs) feature. +- Non-goals (explicitly out of scope for v1): + - Steering a live session (`agent send`; socket vs `session-message` transport undecided). + - Durable/headless runner on `muse exec` and resume wiring. + - CLI start-pane wiring and MCP setup writer for Muse. + - Rust `devkitd` mirror (moved INTO scope per user instruction; detection parity only — + `canHandle` + `detect`, no history/conversation/status-config surfaces). + - CLI `status`-command surfacing for Muse (skill-root map entry and readiness display; + tracked separately even though harness-level readiness checks are in scope). + +## User Stories & Use Cases + +- As a developer running `muse`, I want `agent list` to show my live session so that I can + supervise it alongside Claude/Codex sessions. +- As a developer, I want session history for Muse so that I can find and resume prior work + with `muse resume ` round-tripping verbatim. +- As a developer, I want the last user prompt and conversation tail for a Muse session so + that detail views and handoffs work like other harnesses. +- Edge cases: stale runtime file after process exit; PID reuse; `0600` runtime files; + `subagent/` child sessions that must not double-count; huge transcripts (tail-only reads); + sessions whose transcript dir has noise (`cli-*.log`, `cron.db`, `*.sqlite3`). + +## Success Criteria + +- `detectAgents` finds the live Muse session with exact pid, session id, and workspace root. +- `listSessions` enumerates archive sessions with strict cwd filtering; `findSessionsById` + hits the runtime dir first and avoids full walks. +- `getConversation` returns user/assistant messages with `tail` reading proportional to the + tail, not the file; first-user-message noise filtering matches harness conventions. +- Readiness reports pass/warn/fail for executable, config, auth, and built-in skills. +- New fixtures (`fixtures/harness/muse/`) and tests pass; `lint --feature muse-adapter` passes. +- No writes to Muse-owned directories except an explicit temp file if the `export` path is chosen. + +## Constraints & Assumptions + +- Technical constraints: read-only against Muse storage; tolerate `0700`/`0600` permissions; + follow the `AgentAdapter` contract (`AGENT_TYPES`, `processNames`, `canHandle`, + `detectAgents`, `getConversation`, `listSessions`, `findSessionsById`). +- Assumptions: session ids are UUID-shaped; `process_generation_hint` format is `pid=`; + transcript timestamps are microseconds (normalize to ms); framed log schema is stable and + `muse schema` is its contract; `subagent/`, `approval-review/`, logs, and sqlite files are + excluded from enumeration. +- Validation: repo's own vitest suites for the touched harness surface must pass in-session. + +## Rollout + +Additive only: a new optional harness. No existing adapter behavior changes except appending +`muse` to the shared `AGENT_TYPES` list. No migration, no config changes, no rollout risk +beyond the standard CI gate. + +## Questions & Open Items + +- Deferred to design spike: hand-written framed parser vs `muse export` JSON for + `getConversation` (cost, latency, temp-file writes, sensitive reasoning payload). +- Deferred to v2: send transport (endpoint socket vs `session-message` CLI), exit-cleanup + semantics of runtime files, and the source for running/waiting/idle status. +- Memory/task tracing CLIs are unusable in this environment (DB open failures); lifecycle + continues without task logging. diff --git a/docs/ai/testing/2026-10-10-feature-muse-adapter.md b/docs/ai/testing/2026-10-10-feature-muse-adapter.md new file mode 100644 index 000000000..0b65c69d4 --- /dev/null +++ b/docs/ai/testing/2026-10-10-feature-muse-adapter.md @@ -0,0 +1,56 @@ +--- +phase: testing +title: Testing Strategy +description: Define testing approach and coverage requirements +--- + +# Testing Strategy + +Derived from the requirements success criteria and the design components. Follows the +existing harness suites (adapter, locator, parser, readiness, summary equivalence, tail parity). + +## Scenarios + +- [x] Live detection: runtime file + matching `muse-bin-*` process yields one `AgentInfo` + with exact pid, session id, and `workspace_root` project path. +- [x] Stale runtime file (dead pid, recycled pid outside staleness window) is not reported live. +- [x] Archive enumeration finds dated sessions, applies strict cwd filter, and excludes + `subagent/`, logs, `cron.db`, and `*.sqlite3` noise. +- [x] `findSessionsById` resolves from the runtime dir without walking the archive. +- [x] `getConversation` returns ordered user/assistant messages; `tail: N` returns the last N + with cost proportional to the tail on a large fixture transcript. +- [x] First-user-message filtering skips tool-result blocks and system-injected content. +- [x] Microsecond timestamps normalize to millisecond `Date`s. +- [x] Unreadable (`0600`) files are skipped, not fatal. +- [x] Readiness matrix: missing executable, missing config dir, missing/invalid `auth.json`, + missing built-in skills → correct pass/warn/fail states. +- [x] Session-summary equivalence with the harness conventions (verbatim id round-trip). + +## Mocks / Fixtures + +- `fixtures/harness/muse/matched.json` and `fallback.json` (runtime entry + framed transcript + lines, live and stale variants). +- Large synthetic framed transcript for tail-cost assertions. +- Fake home trees for readiness states (no real `~/.local` touched). + +## Coverage Target + +- New `muse/` harness files at the same bar as `claude/`/`codex/` suites: locator, parser, + mapper, adapter, readiness, and parity tests all green alongside the existing suites. + +## Results + +- New suites: `MuseSessionParser` (10), `MuseSessionLocator` (5), `MuseAgentMapper` (3), + `MuseAdapter` (6) — all green. +- Extended suites: readiness `AgentReadiness` (+3 muse cases), `runtimeProfiles` (+1), + `process` prefix matching (+1). +- Golden fixtures: `fixtures/harness/muse/{matched,fallback}.json` replay byte-identical; + readiness `healthy`/`degraded` replay green with muse entries. +- Full agent-manager suite: 1365+ passed, 0 failed; `tsc --noEmit` clean; `oxlint` 0/0. +- Coverage (scoped run): `harnesses/muse` ~86% lines; remaining lines are defensive + close/error paths. Global-threshold errors on scoped runs are expected (subset artifact). +- Live verification (read-only, not committed): parser run against the real session + transcript reproduced session id, workspace root, display name, model, 65 messages, + correct tail and WAITING status. +- Watch item: one intermittent `ManagedAgentRuntime` spaced-path failure under full-suite + parallelism (passes in isolation/reruns; green on the untouched base too). diff --git a/fixtures/harness/muse/fallback.json b/fixtures/harness/muse/fallback.json new file mode 100644 index 000000000..d91245c53 --- /dev/null +++ b/fixtures/harness/muse/fallback.json @@ -0,0 +1,53 @@ +{ + "adapter": "muse", + "capturedAt": "2025-10-09T12:20:00.000Z", + "frozenNow": 1760012400000, + "processes": [ + { + "pid": 4101, + "ppid": 900, + "tty": "ttys010", + "command": "$FIXTURE_HOME/.local/bin/muse-bin-1.4.4-R5419.1", + "cwd": "/proj/f1", + "startTime": "2025-10-09T11:00:00.000Z" + }, + { + "pid": 4102, + "ppid": 900, + "tty": "ttys011", + "command": "muse", + "startTime": "2025-10-09T11:10:00.000Z" + }, + { + "pid": 4103, + "ppid": 900, + "tty": "ttys012", + "command": "other-tool", + "cwd": "/proj/noise", + "startTime": "2025-10-09T11:20:00.000Z" + } + ], + "home": {}, + "expected": [ + { + "name": "f1-4101", + "type": "muse", + "status": "running", + "summary": "Muse process running", + "pid": 4101, + "projectPath": "/proj/f1", + "sessionId": "pid-4101", + "lastActive": "2025-10-09T12:20:00.000Z" + }, + { + "name": "unknown-4102", + "type": "muse", + "status": "running", + "summary": "Muse process running", + "pid": 4102, + "projectPath": "", + "sessionId": "pid-4102", + "lastActive": "2025-10-09T12:20:00.000Z" + } + ] +} diff --git a/fixtures/harness/muse/matched.json b/fixtures/harness/muse/matched.json new file mode 100644 index 000000000..4dd2bc624 --- /dev/null +++ b/fixtures/harness/muse/matched.json @@ -0,0 +1,32 @@ +{ + "adapter": "muse", + "capturedAt": "2025-10-09T12:20:00.000Z", + "frozenNow": "$NOW", + "processes": [ + { + "pid": 4001, + "ppid": 900, + "tty": "ttys001", + "command": "$FIXTURE_HOME/.local/bin/muse-bin-1.4.4-R5419.1", + "cwd": "/proj/alpha", + "startTime": "$NOW" + } + ], + "home": { + ".local/share/muse/runtime/muse/sessions/11111111-1111-1111-1111-111111111111.json": "{\"schema_version\":1,\"session_id\":\"11111111-1111-1111-1111-111111111111\",\"process_generation_hint\":\"pid=4001\"}", + ".local/share/muse/sessions/$TODAY/11111111-1111-1111-1111-111111111111/session.jsonl": "{\"schema_version\":1,\"stream\":{\"kind\":\"session\",\"id\":\"11111111-1111-1111-1111-111111111111\"},\"sequence\":1,\"recorded_at\":1760011200000000,\"record_type\":\"event\",\"payload_type\":\"runtime.session.metadata\",\"payload\":{\"kind\":\"metadata\",\"record\":{\"workspace_root\":\"/proj/alpha\",\"model_id\":\"muse-spark-1.3\"}}}\n{\"schema_version\":1,\"stream\":{\"kind\":\"session\",\"id\":\"11111111-1111-1111-1111-111111111111\"},\"sequence\":2,\"recorded_at\":1760011260000000,\"record_type\":\"event\",\"payload_type\":\"runtime.user_intent.accepted\",\"payload\":{\"intent_id\":\"intent-1\",\"refill_blocks\":[{\"kind\":\"text\",\"text\":\"ship it\"}]}}" + }, + "expected": [ + { + "name": "alpha-4001", + "type": "muse", + "status": "running", + "summary": "ship it", + "pid": 4001, + "projectPath": "/proj/alpha", + "sessionId": "11111111-1111-1111-1111-111111111111", + "lastActive": "2025-10-09T12:01:00.000Z", + "sessionFilePath": "$FIXTURE_HOME/.local/share/muse/sessions/$TODAY/11111111-1111-1111-1111-111111111111/session.jsonl" + } + ] +} diff --git a/fixtures/readiness/degraded.json b/fixtures/readiness/degraded.json index b712b19e1..1f2eb3179 100644 --- a/fixtures/readiness/degraded.json +++ b/fixtures/readiness/degraded.json @@ -521,6 +521,49 @@ ] }, "status": "fail" + }, + "muse": { + "type": "muse", + "executable": { + "command": "muse", + "path": null, + "status": "fail", + "errors": [ + "muse was not found on PATH" + ] + }, + "globalConfig": { + "path": "~/.config/muse", + "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": "~/.config/muse/auth.json", + "provider": null, + "availableProviders": [], + "status": "fail", + "errors": [ + "Muse credential file is missing or invalid" + ] + }, + "status": "fail" } } } diff --git a/fixtures/readiness/healthy.json b/fixtures/readiness/healthy.json index 4e142a49c..f9c7b7170 100644 --- a/fixtures/readiness/healthy.json +++ b/fixtures/readiness/healthy.json @@ -63,7 +63,9 @@ "/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" + "/home/test/.pi/agent/skills/verify/SKILL.md": "# skill", + "/home/test/.config/muse": "", + "/home/test/.config/muse/auth.json": "{\"meta\": {\"token\": \"secret\"}}" }, "executables": [ "/bin/claude", @@ -75,7 +77,8 @@ "/bin/opencode", "/bin/copilot", "/bin/pi", - "/bin/devin" + "/bin/devin", + "/bin/muse" ], "commands": { "pi list": { @@ -483,6 +486,43 @@ "errors": [] }, "status": "pass" + }, + "muse": { + "type": "muse", + "executable": { + "command": "muse", + "path": "/bin/muse", + "status": "pass", + "errors": [] + }, + "globalConfig": { + "path": "~/.config/muse", + "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": "~/.config/muse/auth.json", + "provider": null, + "availableProviders": [], + "status": "pass", + "errors": [] + }, + "status": "pass" } } } diff --git a/packages/agent-manager/src/AgentManager.ts b/packages/agent-manager/src/AgentManager.ts index dbef58982..4b8b0dcc3 100644 --- a/packages/agent-manager/src/AgentManager.ts +++ b/packages/agent-manager/src/AgentManager.ts @@ -24,6 +24,7 @@ import { import { captureProcessSnapshot, filterByProcessNames, + snapshotOptions, type ProcessSnapshotCapture, } from "./utils/process.js"; import { @@ -33,6 +34,17 @@ import { type HerdrAgentPane, } from "./runtime/herdr/HerdrAgentDiscovery.js"; +/** Discovery patterns of one adapter: exact names plus basename prefixes. */ +function adapterPatterns(adapter: AgentAdapter): { + names: string[]; + prefixes: string[]; +} { + return { + names: adapter.processNames ? [...adapter.processNames] : [], + prefixes: adapter.processNamePrefixes ? [...adapter.processNamePrefixes] : [], + }; +} + export interface ListAgentsOptions { /** * Sort key for the returned list. Defaults to `status`, which orders @@ -204,12 +216,20 @@ export class AgentManager { adapters.flatMap((adapter) => (adapter.processNames ? [...adapter.processNames] : [])), ), ); + const namePrefixes = Array.from( + new Set( + adapters.flatMap((adapter) => + adapter.processNamePrefixes ? [...adapter.processNamePrefixes] : [], + ), + ), + ); let processes: readonly ProcessInfo[] = []; - if (processNames.length > 0) { + if (processNames.length > 0 || namePrefixes.length > 0) { try { - processes = await this.captureSnapshot(processNames, { - isCandidate: (process) => this.isCandidateProcess(adapters, process), - }); + processes = await this.captureSnapshot( + processNames, + snapshotOptions(namePrefixes, (process) => this.isCandidateProcess(adapters, process)), + ); } catch { processes = []; } @@ -218,9 +238,12 @@ export class AgentManager { // Query all adapters in parallel using executable-scoped slices of the shared snapshot. const adapterPromises = adapters.map(async (adapter) => { try { - const agents = adapter.processNames + const hasPatterns = + adapter.processNames !== undefined || adapter.processNamePrefixes !== undefined; + const { names, prefixes } = adapterPatterns(adapter); + const agents = hasPatterns ? await adapter.detectAgents({ - processes: filterByProcessNames(processes, adapter.processNames), + processes: filterByProcessNames(processes, names, prefixes), }) : await adapter.detectAgents(); return { type: adapter.type, agents, error: null }; @@ -284,8 +307,11 @@ export class AgentManager { */ private isCandidateProcess(adapters: readonly AgentAdapter[], process: ProcessInfo): boolean { return adapters.some((adapter) => { - if (!adapter.processNames) return false; - if (filterByProcessNames([process], adapter.processNames).length === 0) return false; + if (adapter.processNames === undefined && adapter.processNamePrefixes === undefined) { + return false; + } + const { names, prefixes } = adapterPatterns(adapter); + if (filterByProcessNames([process], names, prefixes).length === 0) return false; try { return adapter.canHandle(process); } catch { diff --git a/packages/agent-manager/src/__tests__/harnesses/muse/MuseAdapter.test.ts b/packages/agent-manager/src/__tests__/harnesses/muse/MuseAdapter.test.ts new file mode 100644 index 000000000..4ddde554a --- /dev/null +++ b/packages/agent-manager/src/__tests__/harnesses/muse/MuseAdapter.test.ts @@ -0,0 +1,199 @@ +/** + * Tests for harnesses/muse/MuseAdapter.ts — detection through the real + * locator/parser/mapper against a fake Muse home (no mocks). + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { AgentStatus } from "../../../adapters/AgentAdapter.js"; +import type { ProcessInfo } from "../../../adapters/AgentAdapter.js"; +import { MuseAdapter } from "../../../harnesses/muse/MuseAdapter.js"; + +const SID = "01a12618-d918-7b63-91c8-4e896326eb1a"; +const PROJECT = "codeaholicguy/Code/ai-devkit"; + +function frame(inner: unknown): string { + return JSON.stringify(inner); +} + +function intent(text: string, recordedAtUs: number): unknown { + return { + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence: 2, + recorded_at: recordedAtUs, + record_type: "event", + payload_type: "runtime.user_intent.accepted", + payload: { refill_blocks: [{ kind: "text", text }] }, + }; +} + +describe("MuseAdapter", () => { + let home: string; + let adapter: MuseAdapter; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "muse-adapter-test-")); + adapter = new MuseAdapter({ homeDir: home }); + }); + + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + function writeTranscript(lines: string[]): string { + const dir = path.join(home, ".local", "share", "muse", "sessions", "2026/10/10", SID); + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, "session.jsonl"); + fs.writeFileSync(filePath, lines.join("\n")); + return filePath; + } + + function writeRuntimeFile(pid: number): void { + const dir = path.join(home, ".local", "share", "muse", "runtime", "muse", "sessions"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, `${SID}.json`), + JSON.stringify({ schema_version: 1, session_id: SID, process_generation_hint: `pid=${pid}` }), + ); + } + + function proc(overrides: Partial = {}): ProcessInfo { + return { + pid: 16174, + command: "/Users/tester/.local/bin/muse-bin-1.4.4-R5419.1", + cwd: `/Users/tester/${PROJECT}`, + tty: "ttys001", + startTime: new Date(), + ...overrides, + }; + } + + it("handles versioned launcher commands and rejects others", () => { + expect(adapter.canHandle(proc())).toBe(true); + expect(adapter.canHandle(proc({ command: "/usr/local/bin/muse" }))).toBe(true); + expect(adapter.canHandle(proc({ command: "/usr/local/bin/claude" }))).toBe(false); + expect(adapter.canHandle(proc({ command: "/usr/bin/museums" }))).toBe(false); + }); + + it("detects a live session through the runtime file", async () => { + const filePath = writeTranscript([ + frame({ + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence: 1, + recorded_at: 1791640525332173, + record_type: "event", + payload_type: "runtime.session.metadata", + payload: { + kind: "metadata", + record: { workspace_root: `/Users/tester/${PROJECT}` }, + }, + }), + frame(intent("hello", 1791640525332174)), + ]); + writeRuntimeFile(16174); + const start = new Date(fs.statSync(filePath).birthtimeMs); + + const agents = await adapter.detectAgents({ processes: [proc({ startTime: start })] }); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: "muse", + pid: 16174, + sessionId: SID, + projectPath: `/Users/tester/${PROJECT}`, + summary: "hello", + }); + expect(agents[0].sessionFilePath).toBe(filePath); + }); + + it("matches a process by cwd and birthtime without a runtime file", async () => { + const filePath = writeTranscript([ + frame({ + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence: 1, + recorded_at: 1791640525332173, + record_type: "event", + payload_type: "runtime.session.metadata", + payload: { + kind: "metadata", + record: { workspace_root: `/Users/tester/${PROJECT}` }, + }, + }), + frame(intent("legacy hello", 1791640525332174)), + ]); + const start = new Date(fs.statSync(filePath).birthtimeMs); + + const agents = await adapter.detectAgents({ + processes: [proc({ pid: 7777, startTime: start })], + }); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: "muse", + pid: 7777, + sessionId: SID, + summary: "legacy hello", + }); + }); + + it("skips transcripts with no conversation signal in history", async () => { + writeTranscript([ + frame({ + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence: 1, + recorded_at: 1791640525332173, + record_type: "event", + payload_type: "runtime.session.metadata", + payload: { kind: "metadata", record: { workspace_root: "/Users/tester/empty" } }, + }), + ]); + expect(await adapter.listSessions()).toEqual([]); + }); + + it("reports an unmatched process as a running placeholder", async () => { + const agents = await adapter.detectAgents({ processes: [proc({ pid: 4242 })] }); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: "muse", + status: AgentStatus.RUNNING, + sessionId: "pid-4242", + }); + }); + + it("lists historical sessions with strict cwd filtering", async () => { + writeTranscript([ + frame({ + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence: 1, + recorded_at: 1791640525332173, + record_type: "event", + payload_type: "runtime.session.metadata", + payload: { + kind: "metadata", + record: { workspace_root: `/Users/tester/${PROJECT}` }, + }, + }), + frame(intent("hello", 1791640525332174)), + ]); + + const all = await adapter.listSessions(); + expect(all).toHaveLength(1); + expect(all[0]).toMatchObject({ + type: "muse", + sessionId: SID, + cwd: `/Users/tester/${PROJECT}`, + firstUserMessage: "hello", + }); + + expect(await adapter.listSessions({ cwd: "/elsewhere" })).toEqual([]); + const found = await adapter.findSessionsById(SID); + expect(found.map((s) => s.sessionId)).toEqual([SID]); + expect(await adapter.findSessionsById("00000000-0000-0000-0000-000000000000")).toEqual( + [], + ); + }); +}); diff --git a/packages/agent-manager/src/__tests__/harnesses/muse/MuseAgentMapper.test.ts b/packages/agent-manager/src/__tests__/harnesses/muse/MuseAgentMapper.test.ts new file mode 100644 index 000000000..706607817 --- /dev/null +++ b/packages/agent-manager/src/__tests__/harnesses/muse/MuseAgentMapper.test.ts @@ -0,0 +1,79 @@ +import { AgentStatus } from "../../../adapters/AgentAdapter.js"; +import type { ProcessInfo } from "../../../adapters/AgentAdapter.js"; +import type { SessionFile } from "../../../utils/session.js"; +import { MuseAgentMapper } from "../../../harnesses/muse/MuseAgentMapper.js"; +import type { MuseSession } from "../../../harnesses/muse/MuseSessionParser.js"; + +const SID = "01a12618-d918-7b63-91c8-4e896326eb1a"; + +function session(overrides: Partial = {}): MuseSession { + return { + sessionId: SID, + projectPath: "/Users/tester/proj", + sessionStart: new Date("2026-10-10T13:55:25.332Z"), + lastActive: new Date("2026-10-10T14:00:00.000Z"), + lastSignal: "assistant", + lastUserMessage: "what is this repo about", + firstUserMessage: "what is this repo about", + ...overrides, + }; +} + +function sessionFile(): SessionFile { + return { + sessionId: SID, + filePath: `/home/tester/.local/share/muse/sessions/2026/10/10/${SID}/session.jsonl`, + projectDir: `/home/tester/.local/share/muse/sessions/2026/10/10/${SID}`, + birthtimeMs: 1791640525332, + resolvedCwd: "/Users/tester/proj", + }; +} + +function proc(): ProcessInfo { + return { + pid: 16174, + command: "/Users/tester/.local/bin/muse-bin-1.4.4-R5419.1", + cwd: "/Users/tester/proj", + tty: "ttys001", + }; +} + +describe("MuseAgentMapper", () => { + const mapper = new MuseAgentMapper(); + + it("maps a session to a project-named agent", () => { + const agent = mapper.mapSessionToAgent({ session: session(), processInfo: proc(), sessionFile: sessionFile() }); + expect(agent).toMatchObject({ + name: "proj-16174", + type: "muse", + status: AgentStatus.WAITING, + summary: "what is this repo about", + pid: 16174, + projectPath: "/Users/tester/proj", + sessionId: SID, + }); + expect(agent.lastActive).toEqual(new Date("2026-10-10T14:00:00.000Z")); + expect(agent.sessionFilePath).toContain("session.jsonl"); + }); + + it("falls back to a started summary and process cwd without workspace root", () => { + const agent = mapper.mapSessionToAgent({ + session: session({ projectPath: "", lastUserMessage: undefined, lastSignal: undefined }), + processInfo: proc(), + sessionFile: { ...sessionFile(), resolvedCwd: "" }, + }); + expect(agent.summary).toBe("Session started"); + expect(agent.projectPath).toBe("/Users/tester/proj"); + expect(agent.status).toBe(AgentStatus.UNKNOWN); + }); + + it("maps an unmatched process to a running placeholder", () => { + const agent = mapper.mapProcessOnlyAgent(proc()); + expect(agent).toMatchObject({ + type: "muse", + status: AgentStatus.RUNNING, + pid: 16174, + sessionId: "pid-16174", + }); + }); +}); diff --git a/packages/agent-manager/src/__tests__/harnesses/muse/MuseSessionLocator.test.ts b/packages/agent-manager/src/__tests__/harnesses/muse/MuseSessionLocator.test.ts new file mode 100644 index 000000000..f5a2c1c5e --- /dev/null +++ b/packages/agent-manager/src/__tests__/harnesses/muse/MuseSessionLocator.test.ts @@ -0,0 +1,164 @@ +/** + * Tests for harnesses/muse/MuseSessionLocator.ts — runtime-file matching, + * dated-archive discovery, and session lookup by id. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import type { ProcessInfo } from "../../../adapters/AgentAdapter.js"; +import { MuseSessionLocator } from "../../../harnesses/muse/MuseSessionLocator.js"; + +const SID = "01a12618-d918-7b63-91c8-4e896326eb1a"; +const OTHER_SID = "2288dbd2-d703-444d-afce-59e40a8ad8b1"; + +function frame(inner: unknown): string { + return JSON.stringify(inner); +} + +function intent(text: string, recordedAtUs: number): unknown { + return { + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence: 2, + recorded_at: recordedAtUs, + record_type: "event", + payload_type: "runtime.user_intent.accepted", + payload: { refill_blocks: [{ kind: "text", text }] }, + }; +} + +function metadata(workspaceRoot: string, recordedAtUs: number): unknown { + return { + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence: 1, + recorded_at: recordedAtUs, + record_type: "event", + payload_type: "runtime.session.metadata", + payload: { kind: "metadata", record: { workspace_root: workspaceRoot } }, + }; +} + +function proc(pid: number, cwd: string, startTime: Date): ProcessInfo { + return { + pid, + command: `/Users/tester/.local/bin/muse-bin-1.4.4-R5419.1`, + cwd, + tty: "ttys001", + startTime, + }; +} + +describe("MuseSessionLocator", () => { + let home: string; + let locator: MuseSessionLocator; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "muse-locator-test-")); + locator = new MuseSessionLocator({ homeDir: home }); + }); + + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + function writeTranscript(sessionId: string, dayDir: string, lines: string[]): string { + const dir = path.join(home, ".local", "share", "muse", "sessions", dayDir, sessionId); + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, "session.jsonl"); + fs.writeFileSync(filePath, lines.join("\n")); + return filePath; + } + + function writeRuntimeFile(sessionId: string, pid: number): string { + const dir = path.join(home, ".local", "share", "muse", "runtime", "muse", "sessions"); + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, `${sessionId}.jsonl`.replace(".jsonl", ".json")); + fs.writeFileSync( + filePath, + JSON.stringify({ + schema_version: 1, + session_id: sessionId, + process_generation_hint: `pid=${pid}`, + }), + ); + return filePath; + } + + it("matches a live process to its transcript via the runtime file", () => { + const now = Date.now(); + const filePath = writeTranscript(SID, "2026/10/10", [ + frame(metadata("/Users/tester/proj", now * 1000)), + frame(intent("hello", now * 1000)), + ]); + writeRuntimeFile(SID, 16174); + // Align file birthtime with process start for the staleness guard. + const start = new Date(fs.statSync(filePath).birthtimeMs); + + const { direct } = locator.matchRunningProcesses([proc(16174, "/Users/tester/proj", start)]); + expect(direct).toHaveLength(1); + expect(direct[0].process.pid).toBe(16174); + expect(direct[0].sessionFile.filePath).toBe(filePath); + expect(direct[0].sessionFile.sessionId).toBe(SID); + }); + + it("rejects runtime matches when the pid was recycled", () => { + writeTranscript(SID, "2026/10/10", [frame(intent("hello", 1791640525332173))]); + writeRuntimeFile(SID, 16174); + + // Process started an hour after the session file was born: recycled pid. + const { direct, fallback } = locator.matchRunningProcesses([ + proc(16174, "/Users/tester/proj", new Date(Date.now() + 3600_000)), + ]); + expect(direct).toHaveLength(0); + expect(fallback.map((p) => p.pid)).toContain(16174); + }); + + it("ignores malformed runtime files (processes fall through to legacy)", () => { + writeTranscript(SID, "2026/10/10", [frame(intent("hello", 1791640525332173))]); + const dir = path.join(home, ".local", "share", "muse", "runtime", "muse", "sessions"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${SID}.json`), "not json"); + fs.writeFileSync( + path.join(dir, "other.json"), + JSON.stringify({ session_id: "other", process_generation_hint: "nonsense" }), + ); + + // Non-muse processes never reach the locator: the adapter pre-filters via canHandle. + const { direct, fallback } = locator.matchRunningProcesses([ + proc(16174, "/Users/tester/proj", new Date()), + ]); + expect(direct).toHaveLength(0); + expect(fallback.map((p) => p.pid)).toEqual([16174]); + }); + + it("discovers historical sessions across dated dirs, skipping noise", () => { + writeTranscript(SID, "2026/10/10", [frame(intent("hello", 1791640525332173))]); + writeTranscript(OTHER_SID, "2026/10/09", [frame(intent("world", 1791640525332173))]); + // Noise: subagent-style nested dir without session.jsonl, stray files. + const noise = path.join(home, ".local", "share", "muse", "sessions", "2026/10/10", "not-a-uuid"); + fs.mkdirSync(noise, { recursive: true }); + fs.writeFileSync(path.join(noise, "notes.txt"), "hi"); + + const found = locator.discoverHistoricalSessionFiles(); + expect(found.map((f) => f.filePath).sort()).toEqual( + [ + path.join( + home, ".local", "share", "muse", "sessions", "2026/10/09", OTHER_SID, "session.jsonl", + ), + path.join( + home, ".local", "share", "muse", "sessions", "2026/10/10", SID, "session.jsonl", + ), + ].sort(), + ); + }); + + it("finds a session file by id without enumerating everything", () => { + const filePath = writeTranscript(SID, "2026/10/10", [frame(intent("hello", 1791640525332173))]); + const found = locator.findHistoricalSessionFilesById(SID); + expect(found).toEqual([{ filePath, defaultCwd: "" }]); + expect(locator.findHistoricalSessionFilesById("nope")).toEqual([]); + expect(locator.findHistoricalSessionFilesById("../evil")).toEqual([]); + }); +}); diff --git a/packages/agent-manager/src/__tests__/harnesses/muse/MuseSessionParser.test.ts b/packages/agent-manager/src/__tests__/harnesses/muse/MuseSessionParser.test.ts new file mode 100644 index 000000000..51f89b613 --- /dev/null +++ b/packages/agent-manager/src/__tests__/harnesses/muse/MuseSessionParser.test.ts @@ -0,0 +1,279 @@ +/** + * Tests for harnesses/muse/MuseSessionParser.ts — framed transcript parsing. + * + * Muse session.jsonl lines are frames: either a direct inner record, a + * `record_json` string, or a `retained_frame` wrapper whose children carry + * `record_json` strings. Inner records dispatch on `payload_type`. + */ + +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { AgentStatus } from "../../../adapters/AgentAdapter.js"; +import { MuseSessionParser } from "../../../harnesses/muse/MuseSessionParser.js"; + +const SID = "01a12618-d918-7b63-91c8-4e896326eb1a"; + +function frame(inner: unknown): string { + return JSON.stringify(inner); +} + +function wrapped(inner: unknown): string { + return JSON.stringify({ record_json: JSON.stringify(inner) }); +} + +function retained(inner: unknown): string { + return JSON.stringify({ + retained_frame: "session_permission_transaction", + frame_schema_version: 1, + children: [{ child_index: 0, record_json: JSON.stringify(inner) }], + }); +} + +function intent(text: string, sequence: number, recordedAtUs: number): unknown { + return { + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence, + recorded_at: recordedAtUs, + record_type: "event", + payload_type: "runtime.user_intent.accepted", + payload: { + intent_id: `intent-${sequence}`, + refill_blocks: [{ kind: "text", text }], + }, + }; +} + +function assistantMessage(text: string, sequence: number, recordedAtUs: number): unknown { + return { + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence, + recorded_at: recordedAtUs, + record_type: "event", + payload_type: "runtime.session", + payload: { + kind: "run", + run_id: "run-1", + event: { kind: "assistant_message_committed", text }, + }, + }; +} + +function metadata(recordedAtUs: number): unknown { + return { + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence: 1, + recorded_at: recordedAtUs, + record_type: "event", + payload_type: "runtime.session.metadata", + payload: { + kind: "metadata", + record: { workspace_root: "/Users/tester/proj", model_id: "muse-spark-1.3" }, + }, + }; +} + +function nameChanged(name: string, sequence: number, recordedAtUs: number): unknown { + return { + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence, + recorded_at: recordedAtUs, + record_type: "event", + payload_type: "session.name.changed", + payload: { session_id: SID, previous_name: null, new_name: name }, + }; +} + +describe("MuseSessionParser", () => { + let parser: MuseSessionParser; + const tempDirs: string[] = []; + + beforeEach(() => { + parser = new MuseSessionParser(); + }); + + afterEach(() => { + for (const dir of tempDirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } + tempDirs.length = 0; + }); + + function writeTranscript(lines: string[]): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "muse-parser-test-")); + tempDirs.push(dir); + const filePath = path.join(dir, "session.jsonl"); + fs.writeFileSync(filePath, lines.join("\n")); + return filePath; + } + + it("returns null for missing or empty files", () => { + expect(parser.readSession("/nonexistent/session.jsonl", "/proj")).toBeNull(); + expect(parser.readSession(writeTranscript([]), "/proj")).toBeNull(); + }); + + it("skips unparseable lines and wrapper frames without records", () => { + const filePath = writeTranscript([ + "not json at all", + JSON.stringify({ retained_frame: "x", children: [] }), + frame(intent("hello", 2, 1791640525332173)), + ]); + const session = parser.readSession(filePath, "/proj"); + expect(session?.lastUserMessage).toBe("hello"); + }); + + it("unwraps record_json strings and retained children", () => { + const filePath = writeTranscript([ + wrapped(intent("first", 2, 1791640525332173)), + retained(intent("second", 3, 1791640525332174)), + ]); + const session = parser.readSession(filePath, "/proj"); + expect(session?.firstUserMessage).toBe("first"); + expect(session?.lastUserMessage).toBe("second"); + }); + + it("reads workspace root, model, and display name", () => { + const filePath = writeTranscript([ + frame(metadata(1791640525332173)), + frame(intent("hi", 2, 1791640525332174)), + frame(nameChanged("flowing-monoceros", 3, 1791640525332175)), + ]); + // Empty project path lets the recorded workspace root win (archive sessions). + const session = parser.readSession(filePath, ""); + expect(session?.projectPath).toBe("/Users/tester/proj"); + expect(session?.modelId).toBe("muse-spark-1.3"); + expect(session?.displayName).toBe("flowing-monoceros"); + // First user message is found even when it is not on the first line. + expect(session?.firstUserMessage).toBe("hi"); + }); + + it("normalizes microsecond timestamps to millisecond dates", () => { + const filePath = writeTranscript([frame(intent("hi", 2, 1791640525332173))]); + const session = parser.readSession(filePath, "/proj"); + expect(session?.lastActive).toEqual(new Date(1791640525332)); + }); + + it("maps last user intent to RUNNING and committed reply to WAITING", () => { + const running = parser.readSession( + writeTranscript([ + frame(intent("q", 2, 1791640525332173)), + frame(assistantMessage("a", 3, 1791640525332174)), + frame(intent("q2", 4, 1791640525332175)), + ]), + "/proj", + ); + expect(parser.determineStatus(running!)).toBe(AgentStatus.RUNNING); + + const waiting = parser.readSession( + writeTranscript([ + frame(intent("q", 2, 1791640525332173)), + frame(assistantMessage("a", 3, 1791640525332174)), + ]), + "/proj", + ); + expect(parser.determineStatus(waiting!)).toBe(AgentStatus.WAITING); + }); + + it("maps approval waits and terminal runs to WAITING and IDLE", () => { + const waiting = parser.readSession( + writeTranscript([ + frame({ + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence: 2, + recorded_at: 1791640525332173, + record_type: "event", + payload_type: "runtime.session", + payload: { + kind: "run", + run_id: "run-1", + event: { kind: "approval_wait.effect.started", tool_name: "edit" }, + }, + }), + ]), + "/proj", + ); + expect(parser.determineStatus(waiting!)).toBe(AgentStatus.WAITING); + + const idle = parser.readSession( + writeTranscript([ + frame({ + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence: 2, + recorded_at: 1791640525332173, + record_type: "event", + payload_type: "runtime.session", + payload: { kind: "run", run_id: "run-1", event: { kind: "terminal" } }, + }), + ]), + "/proj", + ); + expect(parser.determineStatus(idle!)).toBe(AgentStatus.IDLE); + }); + + it("returns UNKNOWN when no conversation signal exists", () => { + const session = parser.readSession( + writeTranscript([frame(metadata(1791640525332173))]), + "/proj", + ); + expect(parser.determineStatus(session!)).toBe(AgentStatus.UNKNOWN); + }); + + it("getConversation returns ordered messages and honors tail", () => { + const filePath = writeTranscript([ + frame(metadata(1791640525332173)), + frame(intent("first question", 2, 1791640525332174)), + frame(assistantMessage("first answer", 3, 1791640525332175)), + frame(intent("second question", 4, 1791640525332176)), + ]); + const full = parser.getConversation(filePath); + expect(full.map((m) => [m.role, m.content])).toEqual([ + ["user", "first question"], + ["assistant", "first answer"], + ["user", "second question"], + ]); + const tail = parser.getConversation(filePath, { tail: 1 }); + expect(tail).toEqual([ + { role: "user", content: "second question", timestamp: expect.anything() }, + ]); + }); + + it("returns [] for a missing transcript and tool calls in verbose mode", () => { + expect(parser.getConversation("/nonexistent/session.jsonl")).toEqual([]); + const filePath = writeTranscript([ + frame({ + schema_version: 1, + stream: { kind: "session", id: SID }, + sequence: 2, + recorded_at: 1791640525332173, + record_type: "event", + payload_type: "runtime.session", + payload: { + kind: "run", + run_id: "run-1", + event: { + kind: "assistant_tool_calls_committed", + tool_calls: [{ tool_name: "read_file" }, { tool_name: "search" }], + }, + }, + }), + ]); + expect(parser.getConversation(filePath)).toEqual([]); + expect(parser.getConversation(filePath, { verbose: true })).toEqual([ + { + role: "assistant", + content: "tool calls: read_file, search", + timestamp: expect.anything(), + }, + ]); + }); +}); diff --git a/packages/agent-manager/src/__tests__/harnesses/readiness/AgentReadiness.test.ts b/packages/agent-manager/src/__tests__/harnesses/readiness/AgentReadiness.test.ts index 7600a0ec0..39ed9a45d 100644 --- a/packages/agent-manager/src/__tests__/harnesses/readiness/AgentReadiness.test.ts +++ b/packages/agent-manager/src/__tests__/harnesses/readiness/AgentReadiness.test.ts @@ -68,6 +68,7 @@ function fixture(overrides: Partial = {}) { ".gemini/antigravity-cli", ".config/opencode", ".config/devin", + ".config/muse", ".pi", ]) { files[path.join(homeDir, directory)] = ""; @@ -128,6 +129,7 @@ describe("agent readiness", () => { "copilot", "pi", "devin", + "muse", ]); for (const [agent, report] of Object.entries(reports) as Array< [ReadinessAgentType, (typeof reports)[ReadinessAgentType]] @@ -166,6 +168,7 @@ describe("agent readiness", () => { expect(reports.kiro.integration).toBeUndefined(); expect(reports.antigravity_cli.integration).toBeUndefined(); expect(reports.opencode.integration).toBeUndefined(); + expect(reports.muse.integration).toBeUndefined(); }); it("resolves agent executables from PATH directories containing spaces", async () => { @@ -309,6 +312,39 @@ describe("agent readiness", () => { }); }); + it("checks Muse auth from the muse auth.json file", async () => { + const authPath = path.join("/home/test", ".config", "muse", "auth.json"); + const { options, files } = fixture(); + files[authPath] = JSON.stringify({ meta: { token: "secret" } }); + + const report = await getAgentReadinessReport("muse", options); + + expect(report.auth).toMatchObject({ state: "authenticated", status: "pass" }); + expect(report.globalConfig).toMatchObject({ status: "pass" }); + }); + + it("reports Muse as unauthenticated with an empty auth file", async () => { + const authPath = path.join("/home/test", ".config", "muse", "auth.json"); + const { options, files } = fixture(); + files[authPath] = JSON.stringify({}); + + const report = await getAgentReadinessReport("muse", options); + + expect(report.auth).toMatchObject({ + state: "unauthenticated", + status: "fail", + errors: ["Muse credential file has no entries"], + }); + }); + + it("reports Muse as unauthenticated without an auth file", async () => { + const { options } = fixture(); + + const report = await getAgentReadinessReport("muse", options); + + expect(report.auth).toMatchObject({ state: "unauthenticated", status: "fail" }); + }); + it("reports Devin as unauthenticated without a login line", async () => { const { options } = fixture({ runCommand: vi.fn(async (command) => { diff --git a/packages/agent-manager/src/__tests__/harnesses/runtimeProfiles.test.ts b/packages/agent-manager/src/__tests__/harnesses/runtimeProfiles.test.ts index 09700b8d2..a3634c85a 100644 --- a/packages/agent-manager/src/__tests__/harnesses/runtimeProfiles.test.ts +++ b/packages/agent-manager/src/__tests__/harnesses/runtimeProfiles.test.ts @@ -36,6 +36,19 @@ describe("harness runtime profiles", () => { expect(runtimeAgentMatchesHarness("gemini", "gemini_cli")).toBe(true); expect(runtimeAgentMatchesHarness("codex", "claude")).toBe(false); }); + + it("profiles the versioned muse launcher", () => { + expect(HARNESS_RUNTIME_PROFILES.muse).toMatchObject({ + command: "muse", + runtimeKind: "muse", + discoveryAliases: ["muse"], + }); + expect( + HARNESS_RUNTIME_PROFILES.muse.matches("/Users/dev/.local/bin/muse-bin-1.4.4-R5419.1"), + ).toBe(true); + expect(HARNESS_RUNTIME_PROFILES.muse.matches("/usr/local/bin/muse")).toBe(true); + expect(runtimeAgentMatchesHarness("muse", "muse")).toBe(true); + }); }); describe("harness matchers with executables under paths containing spaces", () => { diff --git a/packages/agent-manager/src/__tests__/utils/process.test.ts b/packages/agent-manager/src/__tests__/utils/process.test.ts index e8f4b519e..cef131f95 100644 --- a/packages/agent-manager/src/__tests__/utils/process.test.ts +++ b/packages/agent-manager/src/__tests__/utils/process.test.ts @@ -18,6 +18,8 @@ import { captureProcessSnapshot, createProcessSnapshotCapture, filterByProcessNames, + matchesExecutablePrefix, + snapshotOptions, executableBasename, executablePath, type ProcessExec, @@ -259,6 +261,34 @@ describe("filterByProcessNames", () => { expect(filterByProcessNames(processes, ["node"])).toEqual([processes[0], processes[1]]); expect(filterByProcessNames(processes, ["node.exe"])).toEqual([processes[0], processes[1]]); }); + + it("matches versioned launchers by basename prefix without leaking to siblings", () => { + const processes: ProcessInfo[] = [ + { pid: 1, command: "/Users/dev/.local/bin/muse-bin-1.4.4-R5419.1", cwd: "", tty: "" }, + { pid: 2, command: "/usr/local/bin/muse", cwd: "", tty: "" }, + { pid: 3, command: "/usr/bin/museums", cwd: "", tty: "" }, + { pid: 4, command: "/usr/local/bin/claude", cwd: "", tty: "" }, + ]; + + expect(filterByProcessNames(processes, ["muse"], ["muse"])).toEqual([ + processes[0], + processes[1], + ]); + // Exact names alone never match the versioned launcher. + expect(filterByProcessNames(processes, ["muse"])).toEqual([processes[1]]); + // No prefixes: previous behavior is unchanged. + expect(filterByProcessNames(processes, ["claude"])).toEqual([processes[3]]); + expect(matchesExecutablePrefix(processes[0].command, new Set(["muse"]))).toBe(true); + expect(matchesExecutablePrefix(processes[2].command, new Set(["muse"]))).toBe(false); + expect(matchesExecutablePrefix(processes[0].command, new Set())).toBe(false); + }); + + it("builds snapshot options with prefixes only when present", () => { + const isCandidate = () => true; + expect(snapshotOptions([], isCandidate)).toEqual({ isCandidate }); + const withPrefixes = snapshotOptions(["muse"], isCandidate); + expect(withPrefixes).toMatchObject({ namePrefixes: ["muse"], isCandidate }); + }); }); describe("executables installed under paths containing spaces", () => { diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts index e3ede04d9..0c60d3af5 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -17,6 +17,7 @@ export const AGENT_TYPES = [ "copilot", "pi", "devin", + "muse", ] as const; /** @@ -201,6 +202,14 @@ export interface AgentAdapter { /** Executable basenames required for shared process discovery. */ readonly processNames?: readonly string[]; + /** + * Basename prefixes for shared process discovery: an argv[0] basename + * matches when it equals the prefix or starts with `-`. + * For versioned launchers such as `muse-bin-1.4.4-R5419.1`. + * Recall-oriented like `processNames`; `canHandle` stays the precision gate. + */ + readonly processNamePrefixes?: readonly string[]; + /** * Detect running agents of this type * @returns List of detected agents diff --git a/packages/agent-manager/src/fixtures/capture.ts b/packages/agent-manager/src/fixtures/capture.ts index a04dd85e9..fbaff705c 100644 --- a/packages/agent-manager/src/fixtures/capture.ts +++ b/packages/agent-manager/src/fixtures/capture.ts @@ -13,7 +13,7 @@ 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 { captureProcessSnapshot, snapshotOptions } from "../utils/process.js"; import { FixtureBundle, normalizeAgents, sanitize, withFrozenClock } from "./bundle.js"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); @@ -317,9 +317,11 @@ export async function captureLive(adapter: AgentAdapter, caseName = "live"): Pro } const { processes, agents, registry } = await withFrozenClock(frozenNow, async () => { - const snapshot = await captureProcessSnapshot([...names], { - isCandidate: (p) => adapter.canHandle(p), - }); + const prefixes = adapter.processNamePrefixes ? [...adapter.processNamePrefixes] : []; + const snapshot = await captureProcessSnapshot( + [...names], + snapshotOptions(prefixes, (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); diff --git a/packages/agent-manager/src/harnesses/index.ts b/packages/agent-manager/src/harnesses/index.ts index d98fb10b7..d87b3993c 100644 --- a/packages/agent-manager/src/harnesses/index.ts +++ b/packages/agent-manager/src/harnesses/index.ts @@ -9,6 +9,7 @@ import { KiroAdapter } from "./kiro/KiroAdapter.js"; import { OpenCodeAdapter } from "./opencode/OpenCodeAdapter.js"; import { PiAdapter } from "./pi/PiAdapter.js"; import { DevinAdapter } from "./devin/DevinAdapter.js"; +import { MuseAdapter } from "./muse/MuseAdapter.js"; /** * One instance of every built-in harness adapter, in registration order @@ -26,5 +27,6 @@ export function createBuiltinAdapters(): AgentAdapter[] { new OpenCodeAdapter(), new PiAdapter(), new DevinAdapter(), + new MuseAdapter(), ]; } diff --git a/packages/agent-manager/src/harnesses/muse/MuseAdapter.ts b/packages/agent-manager/src/harnesses/muse/MuseAdapter.ts new file mode 100644 index 000000000..5ee2daa2e --- /dev/null +++ b/packages/agent-manager/src/harnesses/muse/MuseAdapter.ts @@ -0,0 +1,159 @@ +import * as path from "path"; +import type { + AgentAdapter, + AgentDetectionContext, + AgentInfo, + ConversationMessage, + ConversationOptions, + ListSessionsOptions, + ProcessInfo, + SessionSummary, +} from "../../adapters/AgentAdapter.js"; +import { safeStat } from "../../utils/session.js"; +import { MuseAgentMapper } from "./MuseAgentMapper.js"; +import { MuseSessionLocator } from "./MuseSessionLocator.js"; +import { MuseSessionParser } from "./MuseSessionParser.js"; +import { findHarnessProcesses, homeDir } from "../shared.js"; + +export interface MuseAdapterOptions { + homeDir?: string; +} + +/** + * Muse (Meta Muse Code) adapter. + * + * Detection joins two sources: + * 1. Live runtime entries (`~/.local/share/muse/runtime/muse/sessions/.json`) + * matched to processes by `process_generation_hint: "pid="`. + * 2. The dated transcript archive (`sessions/yyyy/mm/dd//session.jsonl`). + * + * The launcher binary is versioned (`muse-bin--`), so discovery + * uses `processNamePrefixes`; `canHandle` keeps the precise shape check. + */ +export class MuseAdapter implements AgentAdapter { + readonly type = "muse" as const; + readonly processNames = ["muse"] as const; + readonly processNamePrefixes = ["muse"] as const; + + private readonly home: string; + private readonly parser: MuseSessionParser; + private readonly mapper: MuseAgentMapper; + + constructor(options: MuseAdapterOptions = {}) { + this.home = options.homeDir ?? homeDir(); + this.parser = new MuseSessionParser(); + this.mapper = new MuseAgentMapper(this.parser); + } + + canHandle(processInfo: ProcessInfo): boolean { + const base = path + .basename(processInfo.command.trim().split(/\s+/)[0] ?? "") + .toLowerCase(); + return base === "muse" || base === "muse.exe" || base.startsWith("muse-bin-"); + } + + async detectAgents(context?: AgentDetectionContext): Promise { + try { + return await this.detectRunningAgents(context); + } finally { + this.parser.pruneSessionCache(); + } + } + + private async detectRunningAgents(context?: AgentDetectionContext): Promise { + const { processes } = await findHarnessProcesses(this, context); + if (processes.length === 0) return []; + + const { direct, legacyMatches } = this.createLocator().matchRunningProcesses(processes); + const matchedPids = new Set([ + ...direct.map((d) => d.process.pid), + ...legacyMatches.map((m) => m.process.pid), + ]); + const agents: AgentInfo[] = []; + + for (const match of direct) { + const sessionData = this.parser.readSessionIncremental( + match.sessionFile.filePath, + match.sessionFile.resolvedCwd, + ); + if (sessionData) { + agents.push( + this.mapper.mapSessionToAgent({ + session: sessionData, + processInfo: match.process, + sessionFile: match.sessionFile, + }), + ); + } else { + matchedPids.delete(match.process.pid); + } + } + + for (const match of legacyMatches) { + const sessionData = this.parser.readSessionIncremental( + match.session.filePath, + match.session.resolvedCwd, + ); + if (sessionData) { + agents.push( + this.mapper.mapSessionToAgent({ + session: sessionData, + processInfo: match.process, + sessionFile: match.session, + }), + ); + } else { + matchedPids.delete(match.process.pid); + } + } + + for (const proc of processes) { + if (!matchedPids.has(proc.pid)) { + agents.push(this.mapper.mapProcessOnlyAgent(proc)); + } + } + return agents; + } + + private createLocator(): MuseSessionLocator { + return new MuseSessionLocator({ homeDir: this.home }); + } + + getConversation(sessionFilePath: string, options?: ConversationOptions): ConversationMessage[] { + return this.parser.getConversation(sessionFilePath, options); + } + + async listSessions(opts?: ListSessionsOptions): Promise { + const filterCwd = opts?.cwd; + const summaries: SessionSummary[] = []; + for (const { filePath, defaultCwd } of this.createLocator().discoverHistoricalSessionFiles()) { + const summary = this.toSessionSummary(filePath, defaultCwd); + if (!summary) continue; + if (filterCwd !== undefined && summary.cwd !== filterCwd) continue; + summaries.push(summary); + } + return summaries; + } + + async findSessionsById(sessionId: string): Promise { + return this.createLocator() + .findHistoricalSessionFilesById(sessionId) + .map(({ filePath, defaultCwd }) => this.toSessionSummary(filePath, defaultCwd)) + .filter((summary): summary is SessionSummary => summary?.sessionId === sessionId); + } + + private toSessionSummary(filePath: string, defaultCwd: string): SessionSummary | null { + const session = this.parser.readSession(filePath, defaultCwd); + if (!session?.lastSignal && !session?.firstUserMessage) return null; + const stat = safeStat(filePath); + return { + type: "muse", + sessionId: session.sessionId, + cwd: session.projectPath || defaultCwd, + firstUserMessage: session.firstUserMessage || "", + lastActive: session.lastActive ?? stat?.mtime ?? new Date(), + startedAt: session.sessionStart ?? stat?.birthtime ?? stat?.mtime ?? new Date(), + sessionFilePath: filePath, + }; + } +} diff --git a/packages/agent-manager/src/harnesses/muse/MuseAgentMapper.ts b/packages/agent-manager/src/harnesses/muse/MuseAgentMapper.ts new file mode 100644 index 000000000..0cbc4eb0d --- /dev/null +++ b/packages/agent-manager/src/harnesses/muse/MuseAgentMapper.ts @@ -0,0 +1,34 @@ +import type { AgentInfo, ProcessInfo } from "../../adapters/AgentAdapter.js"; +import { generateAgentName } from "../../utils/matching.js"; +import type { SessionFile } from "../../utils/session.js"; +import { MuseSessionParser, type MuseSession } from "./MuseSessionParser.js"; +import { processOnlyAgent } from "../shared.js"; + +export interface MuseSessionAgentInput { + session: MuseSession; + processInfo: ProcessInfo; + sessionFile: SessionFile; +} + +export class MuseAgentMapper { + constructor(private readonly parser: MuseSessionParser = new MuseSessionParser()) {} + + mapSessionToAgent({ session, processInfo, sessionFile }: MuseSessionAgentInput): AgentInfo { + return { + name: generateAgentName(processInfo.cwd, processInfo.pid), + type: "muse", + status: this.parser.determineStatus(session), + summary: session.lastUserMessage || "Session started", + pid: processInfo.pid, + projectPath: session.projectPath || sessionFile.resolvedCwd || processInfo.cwd || "", + sessionId: sessionFile.sessionId, + lastActive: session.lastActive, + sessionFilePath: sessionFile.filePath, + }; + } + + mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo { + return processOnlyAgent("muse", processInfo, { summary: "Muse process running" }); + } +} + diff --git a/packages/agent-manager/src/harnesses/muse/MuseSessionLocator.ts b/packages/agent-manager/src/harnesses/muse/MuseSessionLocator.ts new file mode 100644 index 000000000..c5176d1f8 --- /dev/null +++ b/packages/agent-manager/src/harnesses/muse/MuseSessionLocator.ts @@ -0,0 +1,286 @@ +import * as fs from "fs"; +import * as path from "path"; +import type { ProcessInfo } from "../../adapters/AgentAdapter.js"; +import { matchProcessesToSessions, type MatchResult } from "../../utils/matching.js"; +import { + isDirectory, + isSafePathSegment, + safeReaddir, + safeStat, + type SessionFile, +} from "../../utils/session.js"; +import { homeDir } from "../shared.js"; + +/** Entry in `~/.local/share/muse/runtime/muse/sessions/.json`. */ +export interface MuseRuntimeEntry { + sessionId: string; + pid: number; +} + +/** A live Muse process paired with its transcript through the runtime file. */ +export interface MuseDirectMatch { + process: ProcessInfo; + sessionFile: SessionFile; +} + +export interface MuseProcessSessionMatches { + direct: MuseDirectMatch[]; + legacyMatches: MatchResult[]; + /** Muse processes no strategy could pair (become process-only agents). */ + fallback: ProcessInfo[]; +} + +export interface MuseSessionLocatorOptions { + homeDir?: string; + runtimeDir?: string; + archiveDir?: string; +} + +/** Session ids are UUID-shaped; anything else is never a session dir. */ +const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Maximum |process start − transcript birth| before a pid match is recycled. */ +const PID_STALENESS_MS = 60000; + +function parsePidHint(value: unknown): number | null { + if (typeof value !== "string") return null; + const match = /^pid=(\d+)$/.exec(value.trim()); + return match ? Number(match[1]) : null; +} + +function parseRuntimeEntry(filePath: string): MuseRuntimeEntry | null { + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf-8"); + } catch { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const entry = parsed as { session_id?: unknown; process_generation_hint?: unknown }; + const sessionId = typeof entry.session_id === "string" ? entry.session_id : null; + const pid = parsePidHint(entry.process_generation_hint); + if (!sessionId || !SESSION_ID_PATTERN.test(sessionId) || pid === null) return null; + return { sessionId, pid }; +} + +/** Head-read a transcript for its recorded workspace root (bounded, cheap). */ +function readWorkspaceRootHead(filePath: string, maxBytes = 8192): string | null { + let fd: number; + try { + fd = fs.openSync(filePath, "r"); + } catch { + return null; + } + try { + const stat = fs.fstatSync(fd); + const buf = Buffer.alloc(Math.min(maxBytes, stat.size)); + fs.readSync(fd, buf, 0, buf.length, 0); + const text = buf.toString("utf-8"); + for (const line of text.split("\n")) { + if (!line.includes("runtime.session.metadata")) continue; + try { + const outer = JSON.parse(line) as { + record_json?: string; + payload?: { record?: { workspace_root?: unknown } }; + }; + const inner = + typeof outer.record_json === "string" + ? (JSON.parse(outer.record_json) as typeof outer) + : outer; + const root = inner.payload?.record?.workspace_root; + if (typeof root === "string" && root) return root; + } catch { + /* keep scanning */ + } + } + return null; + } catch { + return null; + } finally { + try { + fs.closeSync(fd); + } catch { + /* best effort */ + } + } +} + +export class MuseSessionLocator { + private readonly home: string; + private readonly runtimeDir: string; + private readonly archiveDir: string; + + constructor(options: MuseSessionLocatorOptions = {}) { + this.home = options.homeDir ?? homeDir(); + const store = path.join(this.home, ".local", "share", "muse"); + this.runtimeDir = options.runtimeDir ?? path.join(store, "runtime", "muse", "sessions"); + this.archiveDir = options.archiveDir ?? path.join(store, "sessions"); + } + + /** + * Pair live Muse processes with transcripts. + * + * Two staged strategies, each handing unmatched processes onward: + * 1. Runtime-file matching via `runtime/muse/sessions/.json` + * (authoritative; guarded against recycled pids). + * 2. Legacy cwd+birthtime heuristic over recent transcripts. + */ + matchRunningProcesses(processes: ProcessInfo[]): MuseProcessSessionMatches { + // Callers pass muse processes only (the adapter pre-filters via canHandle). + const { direct, fallback: noDirect } = this.tryRuntimeFileMatching(processes); + const legacySessions = this.discoverLiveSessions(noDirect); + const legacyMatches = + noDirect.length > 0 && legacySessions.length > 0 + ? matchProcessesToSessions(noDirect, legacySessions) + : []; + const matchedPids = new Set([ + ...direct.map((d) => d.process.pid), + ...legacyMatches.map((m) => m.process.pid), + ]); + return { + direct, + legacyMatches, + fallback: noDirect.filter((p) => !matchedPids.has(p.pid)), + }; + } + + private tryRuntimeFileMatching(processes: ProcessInfo[]): { + direct: MuseDirectMatch[]; + fallback: ProcessInfo[]; + } { + const byPid = new Map(); + for (const proc of processes) { + if (!byPid.has(proc.pid)) byPid.set(proc.pid, proc); + } + const direct: MuseDirectMatch[] = []; + const usedPids = new Set(); + if (!isDirectory(this.runtimeDir)) return { direct, fallback: processes }; + + for (const name of safeReaddir(this.runtimeDir)) { + if (!name.endsWith(".json")) continue; + const entry = parseRuntimeEntry(path.join(this.runtimeDir, name)); + if (!entry) continue; + const proc = byPid.get(entry.pid); + if (!proc || usedPids.has(proc.pid)) continue; + const transcript = this.findTranscriptById(entry.sessionId); + if (!transcript) continue; + // Guard against pid recycling: the live process must have started + // around the transcript's creation. + const birth = safeStat(transcript)?.birthtimeMs; + const start = proc.startTime?.getTime(); + if (birth === undefined || start === undefined) continue; + if (Math.abs(start - birth) > PID_STALENESS_MS) continue; + usedPids.add(proc.pid); + direct.push({ + process: proc, + sessionFile: { + sessionId: entry.sessionId, + filePath: transcript, + projectDir: path.dirname(transcript), + birthtimeMs: birth, + resolvedCwd: proc.cwd ?? "", + }, + }); + } + return { direct, fallback: processes.filter((p) => !usedPids.has(p.pid)) }; + } + + /** Recent transcripts for legacy cwd+birthtime matching (today-first, bounded). */ + discoverLiveSessions(processes: ProcessInfo[]): SessionFile[] { + const cwds = new Set(processes.map((p) => p.cwd).filter((cwd): cwd is string => !!cwd)); + if (cwds.size === 0 || !isDirectory(this.archiveDir)) return []; + const dayDirs = this.listDayDirs().slice(-2); + const sessionFiles: SessionFile[] = []; + for (const dayDir of dayDirs) { + for (const name of safeReaddir(dayDir)) { + if (!SESSION_ID_PATTERN.test(name)) continue; + const filePath = path.join(dayDir, name, "session.jsonl"); + const stat = safeStat(filePath); + if (!stat?.isFile() || !Number.isFinite(stat.birthtimeMs) || stat.birthtimeMs <= 0) { + continue; + } + const workspaceRoot = readWorkspaceRootHead(filePath); + if (!workspaceRoot || !cwds.has(workspaceRoot)) continue; + sessionFiles.push({ + sessionId: name, + filePath, + projectDir: path.dirname(filePath), + birthtimeMs: stat.birthtimeMs, + resolvedCwd: workspaceRoot, + }); + } + } + return sessionFiles; + } + + /** + * Every archived transcript: walks `sessions/yyyy/mm/dd//session.jsonl`. + * Only directories matching the session-id pattern that contain a transcript + * are returned; `subagent/`, logs, and databases never match. + */ + discoverHistoricalSessionFiles(): Array<{ filePath: string; defaultCwd: string }> { + const out: Array<{ filePath: string; defaultCwd: string }> = []; + for (const filePath of this.walkTranscripts()) { + out.push({ filePath, defaultCwd: "" }); + } + return out; + } + + findHistoricalSessionFilesById( + sessionId: string, + ): Array<{ filePath: string; defaultCwd: string }> { + if (!isSafePathSegment(sessionId) || !SESSION_ID_PATTERN.test(sessionId)) return []; + const filePath = this.findTranscriptById(sessionId); + return filePath ? [{ filePath, defaultCwd: "" }] : []; + } + + /** Exact transcript lookup by session id across dated dirs. */ + findTranscriptById(sessionId: string): string | null { + if (!isSafePathSegment(sessionId) || !SESSION_ID_PATTERN.test(sessionId)) return null; + for (const dayDir of this.listDayDirs()) { + const filePath = path.join(dayDir, sessionId, "session.jsonl"); + if (safeStat(filePath)?.isFile()) return filePath; + } + return null; + } + + private walkTranscripts(): string[] { + const out: string[] = []; + for (const dayDir of this.listDayDirs()) { + for (const name of safeReaddir(dayDir)) { + if (!SESSION_ID_PATTERN.test(name)) continue; + const filePath = path.join(dayDir, name, "session.jsonl"); + if (safeStat(filePath)?.isFile()) out.push(filePath); + } + } + return out; + } + + /** Dated directories `sessions/yyyy/mm/dd`, each level guarded by a pattern. */ + private listDayDirs(): string[] { + const out: string[] = []; + if (!isDirectory(this.archiveDir)) return out; + for (const yyyy of safeReaddir(this.archiveDir)) { + if (!/^\d{4}$/.test(yyyy)) continue; + const yDir = path.join(this.archiveDir, yyyy); + if (!isDirectory(yDir)) continue; + for (const mm of safeReaddir(yDir)) { + if (!/^\d{2}$/.test(mm)) continue; + const mDir = path.join(yDir, mm); + if (!isDirectory(mDir)) continue; + for (const dd of safeReaddir(mDir)) { + if (!/^\d{2}$/.test(dd)) continue; + const dDir = path.join(mDir, dd); + if (isDirectory(dDir)) out.push(dDir); + } + } + } + return out; + } +} diff --git a/packages/agent-manager/src/harnesses/muse/MuseSessionParser.ts b/packages/agent-manager/src/harnesses/muse/MuseSessionParser.ts new file mode 100644 index 000000000..43bcd84e9 --- /dev/null +++ b/packages/agent-manager/src/harnesses/muse/MuseSessionParser.ts @@ -0,0 +1,438 @@ +import * as fs from "fs"; +import * as path from "path"; +import type { ConversationMessage, ConversationOptions } from "../../adapters/AgentAdapter.js"; +import { AgentStatus } from "../../adapters/AgentAdapter.js"; +import { + IncrementalJsonlSummary, + reduceJsonlContent, + type JsonlSummaryReducer, +} from "../../utils/IncrementalJsonlSummary.js"; +import { JsonlTailReader, normalizeTail } from "../../utils/jsonlTail.js"; + +/** + * A single unwrapped inner record from a Muse session frame. + * + * Each session.jsonl line is one of: a direct inner record, a + * `{ record_json: string }` envelope, or a `retained_frame` wrapper whose + * `children[]` carry `record_json` strings. Inner records dispatch on + * `payload_type`; timestamps (`recorded_at`) are microseconds. + */ +export interface MuseInnerRecord { + stream?: { kind?: string; id?: string }; + sequence?: number; + recorded_at?: number; + record_type?: string; + payload_type?: string; + payload?: Record; +} + +/** Parsed session state extracted from a Muse transcript. */ +export interface MuseSession { + sessionId: string; + projectPath: string; + /** Last display name from `session.name.changed`, when present. */ + displayName?: string; + modelId?: string; + sessionStart: Date; + lastActive: Date; + /** Last conversation signal: user-intent, assistant reply, approval, tool batch… */ + lastSignal?: string; + lastUserMessage?: string; + /** First meaningful user prompt in the session. */ + firstUserMessage?: string; +} + +interface MuseSummaryState { + seenFirstLine: boolean; + pastHead?: boolean; + sessionStartMs?: number; + lastActiveMs?: number; + workspaceRoot?: string; + modelId?: string; + displayName?: string; + lastSignal?: string; + lastUserMessage?: string; + firstUserMessage?: string; +} + +/** + * Conversation-relevant shape of one inner record. Both the summary fold and + * the message reader switch on this instead of re-matching payload shapes. + */ +type MuseRecordKind = + | "metadata" + | "name-changed" + | "user-intent" + | "assistant" + | "tool-calls" + | "approval-started" + | "approval-settled" + | "input-requested" + | "input-settled" + | "run-terminal" + | "other"; + +function classifyRecord(record: MuseInnerRecord): MuseRecordKind { + const payload = record.payload; + if (!isRecord(payload)) return "other"; + switch (record.payload_type) { + case "runtime.session.metadata": + return "metadata"; + case "session.name.changed": + return "name-changed"; + case "runtime.user_intent.accepted": + case "runtime.user_intent.materialized": + return "user-intent"; + case "runtime.session": + break; + default: + return "other"; + } + if (!isRecord(payload.event) || typeof payload.event.kind !== "string") return "other"; + if (payload.kind === "run") { + switch (payload.event.kind) { + case "assistant_message_committed": + return "assistant"; + case "assistant_tool_calls_committed": + return "tool-calls"; + case "terminal": + return "run-terminal"; + } + } + switch (payload.event.kind) { + case "approval_wait.effect.started": + return "approval-started"; + case "approval_wait.effect.terminal": + return "approval-settled"; + case "user_input_prompt_requested": + return "input-requested"; + case "user_input_prompt_settled": + return "input-settled"; + default: + return "other"; + } +} + +const MICROSECONDS_THRESHOLD = 1_000_000_000_000_000; + +/** `recorded_at` microseconds → epoch ms; null when invalid. */ +export function museTimestampToMs(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + const ms = value >= MICROSECONDS_THRESHOLD ? Math.floor(value / 1000) : value; + return ms >= 0 ? ms : null; +} + +/** Unwrap one raw frame line into zero or more inner records. */ +export function unwrapFrameRecords(line: string): MuseInnerRecord[] { + let outer: unknown; + try { + outer = JSON.parse(line); + } catch { + return []; + } + return unwrapOuterRecords(outer); +} + +/** Unwrap an already-parsed frame value into zero or more inner records. */ +export function unwrapOuterRecords(outer: unknown): MuseInnerRecord[] { + if (!outer || typeof outer !== "object") return []; + const obj = outer as { record_json?: unknown; children?: unknown }; + if (typeof obj.record_json === "string") { + try { + const inner = JSON.parse(obj.record_json) as MuseInnerRecord; + return [inner]; + } catch { + return []; + } + } + if (Array.isArray(obj.children)) { + const out: MuseInnerRecord[] = []; + for (const child of obj.children) { + if ( + child && + typeof child === "object" && + typeof (child as { record_json?: unknown }).record_json === "string" + ) { + try { + out.push(JSON.parse((child as { record_json: string }).record_json)); + } catch { + /* skip malformed child */ + } + } + } + return out; + } + return [obj as MuseInnerRecord]; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function nonEmptyText(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +/** Collect `{ kind: "text", text }` strings from intent/model message blocks. */ +function collectTextBlocks(value: unknown): string[] { + const out: string[] = []; + if (Array.isArray(value)) { + for (const block of value) { + if (isRecord(block) && block.kind === "text") { + const text = nonEmptyText(block.text); + if (text) out.push(text); + } + } + } + return out; +} + +/** User turn text from an intent payload (`refill_blocks` then `model_messages`). */ +function intentText(payload: Record): string | null { + const refill = collectTextBlocks(payload.refill_blocks); + if (refill.length > 0) return refill.join("\n"); + const modelMessages = payload.model_messages; + if (Array.isArray(modelMessages)) { + const parts: string[] = []; + for (const message of modelMessages) { + if (isRecord(message)) parts.push(...collectTextBlocks(message.content)); + } + if (parts.length > 0) return parts.join("\n"); + } + return null; +} + +/** Assistant reply text from a `run.assistant_message_committed` event. */ +function assistantEventText(event: Record): string | null { + return nonEmptyText(event.text); +} + +export class MuseSessionParser { + private readonly tailReader = new JsonlTailReader(); + private readonly sessionCache: IncrementalJsonlSummary; + + constructor() { + this.sessionCache = new IncrementalJsonlSummary(this.summaryReducer, {}); + } + + readSession(filePath: string, projectPath: string): MuseSession | null { + let content: string; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + return null; + } + if (!content.trim()) return null; + return this.toSession(filePath, projectPath, reduceJsonlContent(this.summaryReducer, content)); + } + + readSessionIncremental(filePath: string, projectPath: string): MuseSession | null { + const result = this.sessionCache.read(filePath); + return result ? this.toSession(filePath, projectPath, result.state) : null; + } + + pruneSessionCache(): void { + this.sessionCache.prune(); + } + + private readonly summaryReducer: JsonlSummaryReducer = { + initial: () => ({ seenFirstLine: false }), + reduce: (state, value) => { + const next: MuseSummaryState = { ...state, seenFirstLine: true }; + // Session start comes from the first line only; the first user message + // may come from anywhere before a bounded-skip (mirrors Claude semantics). + const flags = { isHead: !state.seenFirstLine, canBeFirst: !state.pastHead }; + for (const record of unwrapOuterRecords(value)) { + this.foldRecord(next, record, flags); + } + return next; + }, + skip: (state) => ({ + seenFirstLine: true, + pastHead: true, + sessionStartMs: state.sessionStartMs, + firstUserMessage: state.firstUserMessage, + }), + }; + + /** + * Fold one inner record into the summary. `isHead` marks the first line + * (session start only); `canBeFirst` allows the first user message from any + * line read before a bounded-skip. + */ + private foldRecord( + next: MuseSummaryState, + record: MuseInnerRecord, + flags: { isHead: boolean; canBeFirst: boolean }, + ): void { + const { isHead, canBeFirst } = flags; + const atMs = museTimestampToMs(record.recorded_at); + if (atMs !== null) { + if (isHead && next.sessionStartMs === undefined) next.sessionStartMs = atMs; + next.lastActiveMs = atMs; + } + const payload = record.payload; + if (!isRecord(payload)) return; + switch (classifyRecord(record)) { + case "metadata": { + if (!isRecord(payload.record)) return; + const root = nonEmptyText(payload.record.workspace_root); + if (root) next.workspaceRoot = root; + const model = nonEmptyText(payload.record.model_id); + if (model) next.modelId = model; + return; + } + case "name-changed": { + const name = nonEmptyText(payload.new_name); + if (name) next.displayName = name; + return; + } + case "user-intent": { + next.lastSignal = "user-intent"; + const text = intentText(payload); + if (text) { + next.lastUserMessage = text; + if (canBeFirst && !next.firstUserMessage) next.firstUserMessage = text; + } + return; + } + case "assistant": + next.lastSignal = "assistant"; + return; + case "approval-started": + next.lastSignal = "approval-wait"; + return; + case "approval-settled": + if (next.lastSignal === "approval-wait") next.lastSignal = "assistant"; + return; + case "input-requested": + next.lastSignal = "input-requested"; + return; + case "input-settled": + if (next.lastSignal === "input-requested") next.lastSignal = "assistant"; + return; + case "run-terminal": + next.lastSignal = "run-terminal"; + return; + default: + return; + } + } + + private toSession(filePath: string, projectPath: string, state: MuseSummaryState): MuseSession { + const sessionStartMs = state.sessionStartMs ?? state.lastActiveMs; + return { + sessionId: path.basename(path.dirname(filePath)), + projectPath: projectPath || state.workspaceRoot || "", + displayName: state.displayName, + modelId: state.modelId, + sessionStart: sessionStartMs !== undefined ? new Date(sessionStartMs) : new Date(), + lastActive: state.lastActiveMs !== undefined ? new Date(state.lastActiveMs) : new Date(), + lastSignal: state.lastSignal, + lastUserMessage: state.lastUserMessage, + firstUserMessage: state.firstUserMessage, + }; + } + + /** + * Status mapping over the last conversation signal: + * - user-intent → RUNNING (agent is processing the new turn) + * - assistant → WAITING (reply committed, awaiting user) + * - approval-wait / input-requested → WAITING (blocked on the user) + * - run-terminal → IDLE; no signal → UNKNOWN. + */ + determineStatus(session: MuseSession): AgentStatus { + switch (session.lastSignal) { + case "user-intent": + return AgentStatus.RUNNING; + case "assistant": + case "approval-wait": + case "input-requested": + return AgentStatus.WAITING; + case "run-terminal": + return AgentStatus.IDLE; + default: + return AgentStatus.UNKNOWN; + } + } + + getConversation(sessionFilePath: string, options?: ConversationOptions): ConversationMessage[] { + const verbose = options?.verbose ?? false; + const tail = normalizeTail(options?.tail); + if (tail !== undefined) { + return this.tailReader.read( + sessionFilePath, + tail, + { parseLine: (line) => this.lineToMessage(line, verbose) }, + String(verbose), + ); + } + let content: string; + try { + content = fs.readFileSync(sessionFilePath, "utf-8"); + } catch { + return []; + } + const messages: ConversationMessage[] = []; + for (const line of content.trim().split("\n")) { + messages.push(...this.linesToMessages(line, verbose)); + } + return messages; + } + + /** One raw frame line → conversation messages (usually zero or one). */ + private linesToMessages(line: string, verbose: boolean): ConversationMessage[] { + const out: ConversationMessage[] = []; + for (const record of unwrapFrameRecords(line)) { + const message = this.recordToMessage(record, verbose); + if (message) out.push(message); + } + return out; + } + + /** + * One raw frame line → its first conversation message, for tail reads. + * A retained frame can theoretically hold several messages; the tail + * window counts lines, so a boundary line keeps only its first message. + */ + private lineToMessage(line: string, verbose: boolean): ConversationMessage | null { + return this.linesToMessages(line, verbose)[0] ?? null; + } + + private recordToMessage( + record: MuseInnerRecord, + verbose: boolean, + ): ConversationMessage | null { + const payload = record.payload; + if (!isRecord(payload)) return null; + const atMs = museTimestampToMs(record.recorded_at); + const timestamp = atMs !== null ? new Date(atMs).toISOString() : undefined; + + switch (classifyRecord(record)) { + case "user-intent": { + const text = intentText(payload); + if (!text) return null; + return { role: "user", content: text, timestamp }; + } + case "assistant": { + if (!isRecord(payload.event)) return null; + const text = assistantEventText(payload.event); + if (!text) return null; + return { role: "assistant", content: text, timestamp }; + } + case "tool-calls": { + if (!verbose || !isRecord(payload.event) || !Array.isArray(payload.event.tool_calls)) { + return null; + } + const names = (payload.event.tool_calls as unknown[]) + .filter(isRecord) + .map((call) => nonEmptyText(call.tool_name)) + .filter((name): name is string => name !== null); + if (names.length === 0) return null; + return { role: "assistant", content: `tool calls: ${names.join(", ")}`, timestamp }; + } + default: + return null; + } + } +} diff --git a/packages/agent-manager/src/harnesses/muse/readiness.ts b/packages/agent-manager/src/harnesses/muse/readiness.ts new file mode 100644 index 000000000..f3da609c4 --- /dev/null +++ b/packages/agent-manager/src/harnesses/muse/readiness.ts @@ -0,0 +1,38 @@ +import { join } from "node:path"; +import { displayHome, record } from "../readiness/checks.js"; +import type { + AuthReadinessCheck, + HarnessReadinessProfile, + ReadinessRuntime, +} from "../readiness/types.js"; + +async function authCheck(runtime: ReadinessRuntime): Promise { + const sourcePath = join(runtime.homeDir, ".config", "muse", "auth.json"); + try { + const parsed = record(JSON.parse(await runtime.readFile(sourcePath))); + if (!parsed) throw new Error("invalid"); + const authenticated = Object.keys(parsed).length > 0; + return { + state: authenticated ? "authenticated" : "unauthenticated", + source: displayHome(sourcePath, runtime.homeDir), + provider: null, + availableProviders: [], + status: authenticated ? "pass" : "fail", + errors: authenticated ? [] : ["Muse credential file has no entries"], + }; + } catch { + return { + state: "unauthenticated", + source: displayHome(sourcePath, runtime.homeDir), + provider: null, + availableProviders: [], + status: "fail", + errors: ["Muse credential file is missing or invalid"], + }; + } +} + +export const museReadiness: HarnessReadinessProfile = { + configDir: ".config/muse", + auth: authCheck, +}; diff --git a/packages/agent-manager/src/harnesses/readiness/AgentReadiness.ts b/packages/agent-manager/src/harnesses/readiness/AgentReadiness.ts index 75a7fdea6..56f0bb682 100644 --- a/packages/agent-manager/src/harnesses/readiness/AgentReadiness.ts +++ b/packages/agent-manager/src/harnesses/readiness/AgentReadiness.ts @@ -4,6 +4,7 @@ import { copilotReadiness } from "../copilot/readiness.js"; import { openCodeReadiness } from "../opencode/readiness.js"; import { piReadiness } from "../pi/readiness.js"; import { devinReadiness } from "../devin/readiness.js"; +import { museReadiness } from "../muse/readiness.js"; import { builtInSkillsCheck, createReadinessRuntime, @@ -31,6 +32,7 @@ const READINESS_PROFILES: Record = copilot: copilotReadiness, pi: piReadiness, devin: devinReadiness, + muse: museReadiness, }; export async function getAgentReadinessReport( diff --git a/packages/agent-manager/src/harnesses/runtimeProfiles.ts b/packages/agent-manager/src/harnesses/runtimeProfiles.ts index 352d8997a..5e7c29959 100644 --- a/packages/agent-manager/src/harnesses/runtimeProfiles.ts +++ b/packages/agent-manager/src/harnesses/runtimeProfiles.ts @@ -22,6 +22,7 @@ export const HARNESS_RUNTIME_PROFILES: Record & { readonly processNames: readonly string[] }, + adapter: Pick & { + readonly processNames: readonly string[]; + }, context?: AgentDetectionContext, ): Promise { + const prefixes = adapter.processNamePrefixes ? [...adapter.processNamePrefixes] : []; const snapshot = context?.processes ?? - (await captureProcessSnapshot(adapter.processNames, { - isCandidate: (process) => adapter.canHandle(process), - })); - const relevant = filterByProcessNames(snapshot, adapter.processNames); + (await captureProcessSnapshot( + adapter.processNames, + snapshotOptions(prefixes, (process) => adapter.canHandle(process)), + )); + const relevant = filterByProcessNames(snapshot, adapter.processNames, prefixes); const byPid = new Map(); for (const process of relevant) { diff --git a/packages/agent-manager/src/utils/process.ts b/packages/agent-manager/src/utils/process.ts index 8fbb3123b..732fbf6e7 100644 --- a/packages/agent-manager/src/utils/process.ts +++ b/packages/agent-manager/src/utils/process.ts @@ -88,13 +88,46 @@ function matchesExecutableName(command: string, names: ReadonlySet): boo return mayContinuePath && names.has(normalizeExecutableName(executableBasename(command))); } +/** + * Whether argv[0]'s basename is exactly `prefix` or starts with `prefix-`. + * + * Covers versioned launchers such as `muse-bin-1.4.4-R5419.1` for prefix + * `muse`. This is a recall-oriented prefilter: adapters still apply + * `canHandle` for precise shaping, so a coincidental `prefix-*` binary + * cannot leak into another harness. + */ +export function matchesExecutablePrefix(command: string, prefixes: ReadonlySet): boolean { + if (prefixes.size === 0) return false; + const base = normalizeExecutableName(pathBasename(command.trim().split(/\s+/)[0] ?? "")); + if (prefixes.has(base)) return true; + for (const prefix of prefixes) { + if (base.startsWith(`${prefix}-`)) return true; + } + return false; +} + +/** Whether argv[0] matches one of `names` exactly or one of `prefixes`. */ +export function matchesProcessPatterns( + command: string, + names: ReadonlySet, + prefixes: ReadonlySet = new Set(), +): boolean { + return ( + matchesExecutableName(command, names) || matchesExecutablePrefix(command, prefixes) + ); +} + export function filterByProcessNames( processes: readonly ProcessInfo[], namePatterns: readonly string[], + namePrefixes: readonly string[] = [], ): ProcessInfo[] { const names = normalizedProcessNames(namePatterns); - if (names.size === 0) return []; - return processes.filter((process) => matchesExecutableName(process.command, names)); + const prefixes = normalizedProcessNames(namePrefixes); + if (names.size === 0 && prefixes.size === 0) return []; + return processes.filter((process) => + matchesProcessPatterns(process.command, names, prefixes), + ); } /** @@ -160,6 +193,11 @@ export interface ProcessSnapshotOptions { * only) so adapters can walk parent chains. Omitted = enrich everything. */ isCandidate?: (process: ProcessInfo) => boolean; + /** + * Basename prefixes matched like `processNamePrefixes`: exact or + * `-*`. Same validation as namePatterns. + */ + namePrefixes?: readonly string[]; } export type ProcessSnapshotCapture = ( @@ -167,6 +205,18 @@ export type ProcessSnapshotCapture = ( options?: ProcessSnapshotOptions, ) => Promise; +/** + * One construction site for snapshot options: `namePrefixes` is sent only + * when non-empty, so adapters without prefixes keep the exact legacy call + * shape that existing mock assertions pin. + */ +export function snapshotOptions( + namePrefixes: readonly string[], + isCandidate: (process: ProcessInfo) => boolean, +): ProcessSnapshotOptions { + return namePrefixes.length > 0 ? { namePrefixes: [...namePrefixes], isCandidate } : { isCandidate }; +} + const execFileText: ProcessExec = (file, args) => new Promise((resolve, reject) => { execFile( @@ -190,7 +240,11 @@ interface ParsedProcessList { matched: ProcessInfo[]; } -function parseProcessList(output: string, namePatterns: ReadonlySet): ParsedProcessList { +function parseProcessList( + output: string, + namePatterns: ReadonlySet, + namePrefixes: ReadonlySet = new Set(), +): ParsedProcessList { const allPids = new Set(); const matched: ProcessInfo[] = []; @@ -207,7 +261,7 @@ function parseProcessList(output: string, namePatterns: ReadonlySet): Pa const tty = match[3]; const command = match[4]; - if (!matchesExecutableName(command, namePatterns)) continue; + if (!matchesProcessPatterns(command, namePatterns, namePrefixes)) continue; matched.push({ pid, @@ -317,14 +371,14 @@ export function createProcessSnapshotCapture( }; return async (namePatterns, options = {}) => { - const names = normalizedProcessNames( - namePatterns.filter((name) => Boolean(name) && VALID_EXECUTABLE_NAME.test(name)), - ); - if (names.size === 0) return []; + const valid = (name: string) => Boolean(name) && VALID_EXECUTABLE_NAME.test(name); + const names = normalizedProcessNames(namePatterns.filter(valid)); + const prefixes = normalizedProcessNames((options.namePrefixes ?? []).filter(valid)); + if (names.size === 0 && prefixes.size === 0) return []; try { const output = await exec("ps", ["-axo", "pid=,ppid=,tty=,command="]); - const { allPids, matched } = parseProcessList(output, names); + const { allPids, matched } = parseProcessList(output, names, prefixes); for (const pid of startTimeCache.keys()) { if (!allPids.has(pid)) startTimeCache.delete(pid); 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 edfc31e86..7736cb080 100644 --- a/packages/cli/src/__tests__/services/status/status.service.test.ts +++ b/packages/cli/src/__tests__/services/status/status.service.test.ts @@ -229,7 +229,9 @@ describe("getStatusReport", () => { "copilot", "pi", "devin", + "muse", ]); + expect(report.agents.muse.integration).toBeUndefined(); expect(report.agents.copilot.integration).toBeUndefined(); expect(report.agents.gemini_cli.builtInSkills).toMatchObject({ present: 2, required: 2 }); expect(report.agents.antigravity_cli.builtInSkills).toMatchObject({ present: 2, required: 2 }); diff --git a/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts b/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts index 1a23017ef..9777c1b08 100644 --- a/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts +++ b/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts @@ -32,6 +32,7 @@ describe("StartAgentPane helpers", () => { "pi", "kiro", "devin", + "muse", ]); }); @@ -42,7 +43,8 @@ describe("StartAgentPane helpers", () => { expect(nextStartAgentType("opencode")).toBe("pi"); expect(nextStartAgentType("pi")).toBe("kiro"); expect(nextStartAgentType("kiro")).toBe("devin"); - expect(nextStartAgentType("devin")).toBe("claude"); + expect(nextStartAgentType("devin")).toBe("muse"); + expect(nextStartAgentType("muse")).toBe("claude"); }); it("cycles to the previous agent type", () => { @@ -51,7 +53,8 @@ describe("StartAgentPane helpers", () => { expect(previousStartAgentType("pi")).toBe("opencode"); expect(previousStartAgentType("kiro")).toBe("pi"); expect(previousStartAgentType("devin")).toBe("kiro"); - expect(previousStartAgentType("claude")).toBe("devin"); + expect(previousStartAgentType("muse")).toBe("devin"); + expect(previousStartAgentType("claude")).toBe("muse"); }); it("renders one row per type with a marker on the selected type", () => { diff --git a/packages/cli/src/__tests__/util/sessions.test.ts b/packages/cli/src/__tests__/util/sessions.test.ts index eec6a0b65..a819d7b33 100644 --- a/packages/cli/src/__tests__/util/sessions.test.ts +++ b/packages/cli/src/__tests__/util/sessions.test.ts @@ -63,7 +63,7 @@ describe("sessions util", () => { it("throws on an invalid --type", () => { expect(() => resolveListSessionsOptions({ all: true, type: "wrong" })).toThrow( - 'Invalid --type "wrong". Expected one of: claude, codex, gemini_cli, grok_cli, kiro, antigravity_cli, opencode, copilot, pi, devin.', + 'Invalid --type "wrong". Expected one of: claude, codex, gemini_cli, grok_cli, kiro, antigravity_cli, opencode, copilot, pi, devin, muse.', ); }); diff --git a/packages/cli/src/util/agent.ts b/packages/cli/src/util/agent.ts index c1074c3dd..bea37f70b 100644 --- a/packages/cli/src/util/agent.ts +++ b/packages/cli/src/util/agent.ts @@ -12,6 +12,7 @@ export const AGENT_TYPE_LABELS: Record = { opencode: "OpenCode", pi: "Pi", devin: "Devin", + muse: "Muse Code", other: "Other", }; @@ -31,6 +32,7 @@ export const AGENT_TYPE_LABELS_COMPACT: Record = { opencode: "opencode", pi: "pi", devin: "devin", + muse: "muse", other: "other", }; diff --git a/rust/crates/devkit-core/src/discover.rs b/rust/crates/devkit-core/src/discover.rs index abfd5fc7b..a6c8480a6 100644 --- a/rust/crates/devkit-core/src/discover.rs +++ b/rust/crates/devkit-core/src/discover.rs @@ -76,7 +76,12 @@ fn argv0_basename(cmd: &str) -> &str { fn is_agent_command(cmd: &str) -> bool { let base = argv0_basename(cmd); - AGENT_BINARIES.contains(&base) || RUNTIME_BINARIES.contains(&base) + AGENT_BINARIES.contains(&base) + || RUNTIME_BINARIES.contains(&base) + // Versioned Muse launcher (`muse-bin--`); adapters + // apply the precise shape check via can_handle. + || base == "muse" + || base.starts_with("muse-") } /// Whether argv0 is a script runtime — the apply_sweep visibility gate. @@ -301,4 +306,14 @@ mod tests { let procs = parse_ps_output("garbage\n abc x ? y\n", false); assert!(procs.is_empty()); } + + #[test] + fn versioned_muse_launcher_passes_pool_gate() { + let procs = parse_ps_output( + "16174 2044 pts/1 /Users/dev/.local/bin/muse-bin-1.4.4-R5419.1\n 16175 2044 pts/1 /usr/bin/museums\n", + false, + ); + let pids: Vec = procs.iter().map(|p| p.pid).collect(); + assert_eq!(pids, vec![16174]); + } } diff --git a/rust/crates/devkit-harness/src/lib.rs b/rust/crates/devkit-harness/src/lib.rs index 2e5ecb559..5847fdd63 100644 --- a/rust/crates/devkit-harness/src/lib.rs +++ b/rust/crates/devkit-harness/src/lib.rs @@ -15,6 +15,7 @@ mod fixtures; pub mod gemini; pub mod grok; pub mod kiro; +pub mod muse; pub mod opencode; pub mod pi; pub mod shared; @@ -31,6 +32,7 @@ pub fn default_registry(home: &std::path::Path) -> Registry { 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(muse::MuseAdapter::new(home))); r.register(Box::new(antigravity::AntigravityCliAdapter::new(home))); r } @@ -241,6 +243,14 @@ mod tests { assert!(n > 0, "no kiro fixture bundles found"); } + #[test] + fn muse_adapter_matches_committed_fixtures() { + let n = crate::fixtures::assert_parity("muse", |home| { + Box::new(crate::muse::MuseAdapter::new(home)) + }); + assert!(n > 0, "no muse fixture bundles found"); + } + #[test] fn antigravity_adapter_matches_committed_fixtures() { // Pin the canonical base dir — ambient ANTIGRAVITY_CLI_HOME must diff --git a/rust/crates/devkit-harness/src/muse/locator.rs b/rust/crates/devkit-harness/src/muse/locator.rs new file mode 100644 index 000000000..3dd8fd352 --- /dev/null +++ b/rust/crates/devkit-harness/src/muse/locator.rs @@ -0,0 +1,275 @@ +//! Port of `MuseSessionLocator` — runtime-file PID matching with a +//! birthtime staleness guard, the dated-archive walk, and the legacy +//! cwd+birthtime fallback. + +use crate::shared::{self, SessionFile}; +use devkit_core::discover::AgentProc; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +/// Max |process start − transcript birth| before a pid match is recycled. +const PID_STALENESS_MS: i64 = 60_000; + +/// A live Muse process paired with its transcript — through the runtime +/// file (authoritative) or the legacy cwd+birthtime join. Consumers map both +/// identically, so one shape serves the two strategies. +pub struct SessionMatch<'a> { + pub process: &'a AgentProc, + pub session_file: SessionFile, +} + +/// Runtime entry: `~/.local/share/muse/runtime/muse/sessions/.json`. +struct RuntimeEntry { + session_id: String, + pid: i64, +} + +fn is_session_id(s: &str) -> bool { + let b = s.as_bytes(); + if b.len() != 36 { + return false; + } + for (i, c) in b.iter().enumerate() { + let hex = c.is_ascii_hexdigit(); + let dash = *c == b'-' && (i == 8 || i == 13 || i == 18 || i == 23); + if !hex && !dash { + return false; + } + } + true +} + +fn is_safe_segment(s: &str) -> bool { + !s.is_empty() && !s.contains('/') && !s.contains('\\') && !s.contains('\0') +} + +fn parse_pid_hint(value: &serde_json::Value) -> Option { + value + .as_str() + .and_then(|s| s.strip_prefix("pid=")) + .and_then(|n| n.parse::().ok()) +} + +fn parse_runtime_entry(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + let parsed: serde_json::Value = serde_json::from_str(&content).ok()?; + let obj = parsed.as_object()?; + let session_id = obj.get("session_id")?.as_str()?; + if !is_session_id(session_id) { + return None; + } + let pid = parse_pid_hint(obj.get("process_generation_hint")?)?; + Some(RuntimeEntry { + session_id: session_id.to_string(), + pid, + }) +} + +/// Dated directories `sessions/yyyy/mm/dd`, each level pattern-guarded. +fn list_day_dirs(archive_dir: &Path) -> Vec { + let mut out = Vec::new(); + for yyyy in shared::list_dir_names(archive_dir) { + if yyyy.len() != 4 || !yyyy.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + let y_dir = archive_dir.join(&yyyy); + for mm in shared::list_dir_names(&y_dir) { + if mm.len() != 2 || !mm.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + let m_dir = y_dir.join(&mm); + for dd in shared::list_dir_names(&m_dir) { + if dd.len() != 2 || !dd.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + let d_dir = m_dir.join(&dd); + if d_dir.is_dir() { + out.push(d_dir); + } + } + } + } + out +} + +/// Exact transcript lookup by session id across dated dirs. +pub fn find_transcript_by_id(archive_dir: &Path, session_id: &str) -> Option { + if !is_safe_segment(session_id) || !is_session_id(session_id) { + return None; + } + list_day_dirs(archive_dir) + .into_iter() + .map(|day| day.join(session_id).join("session.jsonl")) + .find(|p| p.is_file()) +} + +/// Head-read a transcript for its recorded workspace root (bounded, cheap). +/// Reuses the parser's frame unwrap and metadata rule — one rule, one place. +fn read_workspace_root_head(path: &Path) -> Option { + let (text, _, _, _) = shared::read_file_head(&path.to_string_lossy(), 8192)?; + for line in text.split('\n') { + if !line.contains("runtime.session.metadata") { + continue; + } + let outer: serde_json::Value = serde_json::from_str(line).ok()?; + for record in super::parser::unwrap_outer_owned(&outer) { + if let Some(root) = super::parser::record_workspace_root(&record) { + return Some(root); + } + } + } + None +} + +fn transcript_session_file(path: PathBuf, resolved_cwd: String) -> Option { + let birth = shared::birthtime_ms(&path.to_string_lossy())?; + Some(SessionFile { + session_id: path + .parent()? + .file_name()? + .to_string_lossy() + .into_owned(), + file_path: path.to_string_lossy().into_owned(), + project_dir: path + .parent() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(), + birthtime_ms: birth, + resolved_cwd, + }) +} + +/// Recent transcripts for legacy cwd+birthtime matching (last two day dirs). +fn discover_live_sessions(archive_dir: &Path, cwds: &HashSet) -> Vec { + if cwds.is_empty() { + return Vec::new(); + } + let mut days = list_day_dirs(archive_dir); + days.sort(); + let mut out = Vec::new(); + for day in days.iter().rev().take(2) { + let mut names = shared::list_dir_names(day); + names.sort(); + for name in names { + if !is_session_id(&name) { + continue; + } + let path = day.join(&name).join("session.jsonl"); + if !path.is_file() { + continue; + } + let Some(root) = read_workspace_root_head(&path) else { + continue; + }; + if !cwds.contains(&root) { + continue; + } + if let Some(file) = + transcript_session_file(path, root) + { + out.push(file); + } + } + } + out +} + +/// Pair live processes with transcripts: runtime-file matching first +/// (authoritative, staleness-guarded), then the legacy cwd+birthtime join. +pub fn match_running_processes<'a>( + runtime_dir: &Path, + archive_dir: &Path, + processes: &[&'a AgentProc], +) -> (Vec>, Vec<&'a AgentProc>) { + let by_pid: HashMap = processes.iter().map(|p| (p.pid, *p)).collect(); + let mut matched = Vec::new(); + let mut used: HashSet = HashSet::new(); + + let mut runtime_files = shared::list_dir_names(runtime_dir); + runtime_files.sort(); + for name in runtime_files { + if !name.ends_with(".json") { + continue; + } + let Some(entry) = parse_runtime_entry(&runtime_dir.join(&name)) else { + continue; + }; + let (Some(proc), false) = (by_pid.get(&entry.pid), used.contains(&entry.pid)) else { + continue; + }; + let Some(transcript) = find_transcript_by_id(archive_dir, &entry.session_id) else { + continue; + }; + let (Some(birth), Some(start)) = ( + shared::birthtime_ms(&transcript.to_string_lossy()), + proc.start_time_ms, + ) else { + continue; + }; + if (start - birth).abs() > PID_STALENESS_MS { + continue; + } + let Some(session_file) = transcript_session_file( + transcript, + proc.cwd.clone().unwrap_or_default(), + ) else { + continue; + }; + used.insert(proc.pid); + matched.push(SessionMatch { + process: proc, + session_file, + }); + } + + let remaining: Vec<&AgentProc> = processes + .iter() + .filter(|p| !used.contains(&p.pid)) + .copied() + .collect(); + let cwds: HashSet = remaining + .iter() + .filter_map(|p| p.cwd.clone()) + .filter(|c| !c.is_empty()) + .collect(); + let legacy_sessions = discover_live_sessions(archive_dir, &cwds); + for m in shared::match_processes_to_sessions(&remaining, &legacy_sessions) { + if let Some(session_file) = legacy_sessions.get(m.session_idx) { + used.insert(m.proc.pid); + matched.push(SessionMatch { + process: m.proc, + session_file: session_file.clone(), + }); + } + } + let fallback: Vec<&AgentProc> = remaining + .into_iter() + .filter(|p| !used.contains(&p.pid)) + .collect(); + (matched, fallback) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_id_shape_validation() { + assert!(is_session_id("11111111-1111-1111-1111-111111111111")); + assert!(!is_session_id("not-a-uuid")); + assert!(!is_session_id("../evil")); + assert!(!is_session_id("11111111-1111-1111-1111-11111111111x")); + assert!(!is_safe_segment("a/b")); + assert!(is_safe_segment("11111111-1111-1111-1111-111111111111")); + } + + #[test] + fn pid_hint_parsing() { + assert_eq!( + parse_pid_hint(&serde_json::json!("pid=16174")), + Some(16174) + ); + assert_eq!(parse_pid_hint(&serde_json::json!("nonsense")), None); + assert_eq!(parse_pid_hint(&serde_json::Value::Null), None); + } +} diff --git a/rust/crates/devkit-harness/src/muse/mod.rs b/rust/crates/devkit-harness/src/muse/mod.rs new file mode 100644 index 000000000..25ea0bec3 --- /dev/null +++ b/rust/crates/devkit-harness/src/muse/mod.rs @@ -0,0 +1,160 @@ +//! Port of `packages/agent-manager/src/harnesses/muse` — Meta Muse Code +//! detection. +//! +//! Flow (mirrors `detectRunningAgents`): +//! 1. pool-filter the snapshot to `muse` (exact) and `muse-*` launchers, +//! canHandle + first-wins dedupe +//! 2. `match_running_processes`: runtime-file PID join (authoritative, +//! staleness-guarded), then the legacy cwd+birthtime join +//! 3. transcript → `map_session_to_agent`; missing/unreadable 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}; + +pub struct MuseAdapter { + store_dir: PathBuf, +} + +impl MuseAdapter { + /// `~/.local/share/muse` under `home`. + pub fn new(home: &Path) -> Self { + Self { + store_dir: home.join(".local").join("share").join("muse"), + } + } + + fn runtime_dir(&self) -> PathBuf { + self.store_dir + .join("runtime") + .join("muse") + .join("sessions") + } + + fn archive_dir(&self) -> PathBuf { + self.store_dir.join("sessions") + } + + /// `canHandle` — basename is exactly `muse`/`muse.exe` or a versioned + /// `muse-bin-*` launcher. Deliberately narrower than the pool filter + /// below: the pool is recall, this is precision (mirrors TS). + fn is_muse_command(command: &str) -> bool { + let base = shared::executable_basename(command); + let base = base.strip_suffix(".exe").unwrap_or(&base).to_lowercase(); + base == "muse" || base.starts_with("muse-bin-") + } + + /// `mapSessionToAgent` — project-based name, last prompt summary, + /// parser status (no idle override, matching TS). + fn map_session_to_agent( + &self, + session: &parser::Session, + proc: &AgentProc, + ) -> EnrichedAgent { + let cwd = proc.cwd.clone().unwrap_or_default(); + EnrichedAgent { + name: shared::generate_agent_name(&cwd, proc.pid), + agent_type: "muse".into(), + status: parser::status(session).into(), + summary: session + .last_user_message + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "Session started".into()), + pid: proc.pid as u64, + project_path: if session.project_path.is_empty() { + cwd + } else { + 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 MuseAdapter { + fn type_id(&self) -> &'static str { + "muse" + } + + fn can_handle(&self, proc: &AgentProc) -> bool { + proc.command + .as_deref() + .map(Self::is_muse_command) + .unwrap_or(false) + } + + fn detect(&self, ctx: &SweepContext) -> Vec { + // Pool filter is recall (exact `muse` or any `muse-*` launcher); + // canHandle below stays the precision gate. + 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, &["muse"]) + || shared::matches_executable_prefix(c, "muse") + }) + .unwrap_or(false) + }) + .filter(|p| self.can_handle(p) && seen.insert(p.pid)) + .collect(); + if processes.is_empty() { + return Vec::new(); + } + + let runtime_dir = self.runtime_dir(); + let archive_dir = self.archive_dir(); + let (matched, fallback) = + locator::match_running_processes(&runtime_dir, &archive_dir, &processes); + + let mut agents = Vec::new(); + let mut mapped: HashSet = HashSet::new(); + let mut unmapped: HashSet = HashSet::new(); + for m in &matched { + let session = parser::read_session( + &m.session_file.session_id, + &m.session_file.file_path, + &m.session_file.resolved_cwd, + ); + match session { + Some(s) => { + mapped.insert(m.process.pid); + agents.push(self.map_session_to_agent(&s, m.process)); + } + None => { + unmapped.insert(m.process.pid); + } + } + } + + // Like TS: unmatched processes plus matches whose transcript fails + // to parse become process-only rows. + for proc in fallback + .into_iter() + .chain(processes.iter().copied().filter(|p| unmapped.contains(&p.pid))) + { + if mapped.contains(&proc.pid) { + continue; + } + agents.push(shared::process_only_agent( + "muse", + proc, + "Muse process running", + None, + ctx.now, + )); + } + agents + } +} diff --git a/rust/crates/devkit-harness/src/muse/parser.rs b/rust/crates/devkit-harness/src/muse/parser.rs new file mode 100644 index 000000000..b104c7543 --- /dev/null +++ b/rust/crates/devkit-harness/src/muse/parser.rs @@ -0,0 +1,363 @@ +//! Port of `MuseSessionParser` — the bounded framed-JSONL fold behind +//! session summaries and status. Conversation reads, the incremental cache, +//! display names, and model ids are not ported (the daemon only needs +//! detection fields). +//! +//! Each `session.jsonl` line is a frame: a direct inner record, a +//! `{ record_json }` envelope, or a `retained_frame` wrapper whose +//! `children[]` carry `record_json` strings. Inner records dispatch on +//! `payload_type`; `recorded_at` is microseconds. + +use crate::shared; +use serde_json::Value; + +/// Fold state (the summary fields the daemon consumes; the first prompt is +/// history-listing concern and lives in TS only). +#[derive(Default)] +struct Summary { + seen_first_line: bool, + workspace_root: Option, + last_signal: Option<&'static str>, + last_user_message: Option, + last_active_ms: Option, +} + +/// `MuseSession` (detection-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_signal: Option<&'static str>, + pub last_user_message: Option, +} + +/// `recorded_at` microseconds → epoch ms (mirrors `museTimestampToMs`). +fn recorded_at_ms(value: &Value) -> Option { + let v = value.as_f64()?; + if !v.is_finite() || v < 0.0 { + return None; + } + Some(if v >= 1_000_000_000_000_000.0 { + (v / 1000.0).floor() as i64 + } else { + v as i64 + }) +} + +/// Conversation-relevant shape of one inner record — both the fold and (in +/// TS) the message reader switch on this instead of re-matching shapes. +#[derive(PartialEq, Eq)] +enum RecordKind { + Metadata, + UserIntent, + Assistant, + ApprovalStarted, + ApprovalSettled, + InputRequested, + InputSettled, + RunTerminal, + Other, +} + +fn classify(record: &serde_json::Map) -> RecordKind { + let payload = match record.get("payload").and_then(|p| p.as_object()) { + Some(p) => p, + None => return RecordKind::Other, + }; + match record.get("payload_type").and_then(|t| t.as_str()) { + Some("runtime.session.metadata") => return RecordKind::Metadata, + Some("runtime.user_intent.accepted") | Some("runtime.user_intent.materialized") => { + return RecordKind::UserIntent + } + Some("runtime.session") => {} + _ => return RecordKind::Other, + } + let event = match payload.get("event").and_then(|e| e.as_object()) { + Some(e) => e, + None => return RecordKind::Other, + }; + let kind = event.get("kind").and_then(|k| k.as_str()).unwrap_or(""); + let is_run = payload.get("kind").and_then(|k| k.as_str()) == Some("run"); + if is_run { + match kind { + "assistant_message_committed" => return RecordKind::Assistant, + "terminal" => return RecordKind::RunTerminal, + _ => {} + } + } + match kind { + "approval_wait.effect.started" => RecordKind::ApprovalStarted, + "approval_wait.effect.terminal" => RecordKind::ApprovalSettled, + "user_input_prompt_requested" => RecordKind::InputRequested, + "user_input_prompt_settled" => RecordKind::InputSettled, + _ => RecordKind::Other, + } +} + +/// Workspace root from one inner record's metadata payload, if present — +/// the one rule shared by the fold and the locator's bounded head-scan. +pub(crate) fn record_workspace_root( + record: &serde_json::Map, +) -> Option { + if classify(record) != RecordKind::Metadata { + return None; + } + record + .get("payload")? + .get("record")? + .get("workspace_root")? + .as_str() + .filter(|s| !s.is_empty()) + .map(String::from) +} + +/// Unwrap one parsed frame into owned inner records. +pub(crate) fn unwrap_outer_owned(outer: &Value) -> Vec> { + let Some(obj) = outer.as_object() else { + return Vec::new(); + }; + if let Some(s) = obj.get("record_json").and_then(|v| v.as_str()) { + return serde_json::from_str::(s) + .ok() + .and_then(|v| v.as_object().cloned()) + .into_iter() + .collect(); + } + if let Some(children) = obj.get("children").and_then(|v| v.as_array()) { + return children + .iter() + .filter_map(|c| c.get("record_json")?.as_str()) + .filter_map(|s| serde_json::from_str::(s).ok()) + .filter_map(|v| v.as_object().cloned()) + .collect(); + } + vec![obj.clone()] +} + +fn non_empty_text(value: Option<&Value>) -> Option { + value + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) +} + +/// `{ kind: "text", text }` strings from intent/model message blocks. +fn collect_text_blocks(value: Option<&Value>) -> Vec { + match value { + Some(Value::Array(blocks)) => blocks + .iter() + .filter_map(|b| { + if b.get("kind").and_then(|k| k.as_str()) == Some("text") { + non_empty_text(b.get("text")) + } else { + None + } + }) + .collect(), + _ => Vec::new(), + } +} + +/// User turn text: `refill_blocks` first, then `model_messages[].content`. +fn intent_text(payload: &serde_json::Map) -> Option { + let refill = collect_text_blocks(payload.get("refill_blocks")); + if !refill.is_empty() { + return Some(refill.join("\n")); + } + let mut parts = Vec::new(); + if let Some(Value::Array(messages)) = payload.get("model_messages") { + for message in messages { + if let Some(obj) = message.as_object() { + parts.extend(collect_text_blocks(obj.get("content"))); + } + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("\n")) + } +} + +/// `readSession` — stat must succeed; unreadable transcript → None, empty +/// summary (no signal, no message) → None, mirroring the TS null contract. +pub fn read_session( + session_id: &str, + transcript_path: &str, + default_cwd: &str, +) -> Option { + let meta = std::fs::metadata(transcript_path).ok()?; + if !meta.is_file() { + return None; + } + let state = shared::fold_jsonl_bounded( + transcript_path, + Summary::default(), + reduce, + |_: Summary| Summary::default(), + ) + .unwrap_or_default(); + // Null contract mirrors TS `readSession`: None only when nothing folded + // (missing/unreadable/empty file). Signal-less transcripts still map — + // to UNKNOWN-status agents, not process-only rows. + if !state.seen_first_line { + return None; + } + 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); + let project_path = state + .workspace_root + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| default_cwd.to_string()); + Some(Session { + session_id: session_id.to_string(), + project_path, + transcript_path: transcript_path.to_string(), + last_active_ms: state.last_active_ms.unwrap_or(mtime), + last_signal: state.last_signal, + last_user_message: state.last_user_message, + }) +} + +/// `determineStatus` — last signal wins; no idle override (TS parity: the +/// mapper uses the parser status directly). +pub fn status(session: &Session) -> &'static str { + match session.last_signal { + Some("user-intent") => "running", + Some("assistant") | Some("approval-wait") | Some("input-requested") => "waiting", + Some("run-terminal") => "idle", + _ => "unknown", + } +} + +/// Summary fold over one parsed frame value. +fn reduce(mut state: Summary, value: Option<&Value>) -> Summary { + let Some(outer) = value else { + state.seen_first_line = true; + return state; + }; + state.seen_first_line = true; + for record in unwrap_outer_owned(outer) { + fold_record(&mut state, &record); + } + state +} + +fn fold_record(state: &mut Summary, record: &serde_json::Map) { + if let Some(ms) = record.get("recorded_at").and_then(recorded_at_ms) { + state.last_active_ms = Some(ms); + } + let Some(payload) = record.get("payload").and_then(|p| p.as_object()) else { + return; + }; + match classify(record) { + RecordKind::Metadata => { + if let Some(root) = record_workspace_root(record) { + state.workspace_root = Some(root); + } + } + RecordKind::UserIntent => { + state.last_signal = Some("user-intent"); + if let Some(text) = intent_text(payload) { + state.last_user_message = Some(text); + } + } + RecordKind::Assistant => state.last_signal = Some("assistant"), + RecordKind::ApprovalStarted => state.last_signal = Some("approval-wait"), + RecordKind::ApprovalSettled => { + if state.last_signal == Some("approval-wait") { + state.last_signal = Some("assistant"); + } + } + RecordKind::InputRequested => state.last_signal = Some("input-requested"), + RecordKind::InputSettled => { + if state.last_signal == Some("input-requested") { + state.last_signal = Some("assistant"); + } + } + RecordKind::RunTerminal => state.last_signal = Some("run-terminal"), + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(inner: serde_json::Value) -> String { + serde_json::to_string(&inner).unwrap() + } + + fn intent(text: &str) -> serde_json::Value { + serde_json::json!({ + "schema_version": 1, + "sequence": 2, + "recorded_at": 1791640525332173i64, + "record_type": "event", + "payload_type": "runtime.user_intent.accepted", + "payload": { "refill_blocks": [{ "kind": "text", "text": text }] }, + }) + } + + #[test] + fn unwraps_envelopes_and_children() { + let wrapped = serde_json::json!({ "record_json": frame(intent("a")) }); + assert_eq!(unwrap_outer_owned(&wrapped).len(), 1); + let retained = serde_json::json!({ + "retained_frame": "x", + "children": [{ "record_json": frame(intent("b")) }], + }); + assert_eq!(unwrap_outer_owned(&retained).len(), 1); + assert!(unwrap_outer_owned(&serde_json::json!({"a": 1})).len() == 1); + assert!(unwrap_outer_owned(&serde_json::Value::Null).is_empty()); + } + + #[test] + fn microsecond_timestamps_normalize_to_ms() { + assert_eq!( + recorded_at_ms(&serde_json::json!(1791640525332173i64)), + Some(1791640525332) + ); + assert_eq!(recorded_at_ms(&serde_json::json!(1791640525i64)), Some(1791640525)); + assert_eq!(recorded_at_ms(&serde_json::Value::Null), None); + } + + #[test] + fn intent_and_status_signals_fold() { + // Bare inner records fold directly… + let state = reduce(Summary::default(), Some(&intent("hello"))); + assert_eq!(state.last_user_message.as_deref(), Some("hello")); + // …and so do envelope frames. + let wrapped = serde_json::json!({ "record_json": frame(intent("hi")) }); + let state = reduce(Summary::default(), Some(&wrapped)); + assert_eq!(state.last_user_message.as_deref(), Some("hi")); + assert_eq!(state.last_signal, Some("user-intent")); + assert_eq!(state.last_active_ms, Some(1791640525332)); + + let assistant = serde_json::json!({ + "recorded_at": 1791640525332174i64, + "payload_type": "runtime.session", + "payload": { + "kind": "run", + "event": { "kind": "assistant_message_committed", "text": "done" }, + }, + }); + let state = reduce(state, Some(&assistant)); + assert_eq!(state.last_signal, Some("assistant")); + let session = super::Session { + session_id: "s".into(), + project_path: "/p".into(), + transcript_path: "t".into(), + last_active_ms: 1, + last_signal: state.last_signal, + last_user_message: state.last_user_message, + }; + assert_eq!(super::status(&session), "waiting"); + } +} diff --git a/rust/crates/devkit-harness/src/shared.rs b/rust/crates/devkit-harness/src/shared.rs index 97ba35ee7..d80f7b96b 100644 --- a/rust/crates/devkit-harness/src/shared.rs +++ b/rust/crates/devkit-harness/src/shared.rs @@ -99,6 +99,18 @@ pub fn matches_executable_name(command: &str, names: &[&str]) -> bool { .any(|n| *n == normalize_executable_name(&executable_basename(command))) } +/// `matchesExecutablePrefix` — argv[0]'s first-token basename is exactly +/// `prefix` or starts with `-`; covers versioned launchers such as +/// `muse-bin-1.4.4-R5419.1`. Recall-oriented like the name lists — adapters +/// keep the precise shape check in `can_handle`. +pub fn matches_executable_prefix(command: &str, prefix: &str) -> bool { + let base = normalize_executable_name(&path_basename( + command.split_whitespace().next().unwrap_or(""), + )); + let prefix = normalize_executable_name(prefix); + base == prefix || base.starts_with(&format!("{prefix}-")) +} + /// `generateAgentName`: kebab(basename(cwd))-pid, "unknown" fallback. pub fn generate_agent_name(cwd: &str, pid: i64) -> String { let folder = Path::new(cwd) @@ -667,6 +679,17 @@ pub fn registry_agent_rows(home: &Path) -> Vec { mod tests { use super::*; + #[test] + fn prefix_matches_versioned_launcher_only() { + assert!(matches_executable_prefix( + "/Users/dev/.local/bin/muse-bin-1.4.4-R5419.1", + "muse" + )); + assert!(matches_executable_prefix("/usr/local/bin/muse", "muse")); + assert!(!matches_executable_prefix("/usr/bin/museums", "muse")); + assert!(!matches_executable_prefix("/usr/local/bin/claude", "muse")); + } + #[test] fn kebab_names_match_ts() { assert_eq!(