From 9532f10cbc7e46399b9ac81ff749bba0386e9c9d Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Wed, 10 Jun 2026 19:01:07 +0200 Subject: [PATCH] feat(agent-manager): add pi adapter --- README.md | 1 + .../design/2026-06-10-feature-pi-adapter.md | 103 +++ .../2026-06-10-feature-pi-adapter.md | 93 +++ .../planning/2026-06-10-feature-pi-adapter.md | 61 ++ .../2026-06-10-feature-pi-adapter.md | 80 +++ .../testing/2026-06-10-feature-pi-adapter.md | 82 +++ .../src/__tests__/adapters/PiAdapter.test.ts | 435 +++++++++++++ .../src/adapters/AgentAdapter.ts | 2 +- .../agent-manager/src/adapters/PiAdapter.ts | 597 ++++++++++++++++++ packages/agent-manager/src/adapters/index.ts | 1 + packages/agent-manager/src/index.ts | 1 + .../cli/src/__tests__/commands/agent.test.ts | 8 +- .../src/__tests__/commands/channel.test.ts | 2 + .../cli/src/__tests__/util/sessions.test.ts | 4 +- packages/cli/src/commands/agent.ts | 7 +- .../src/services/channel/channel-runner.ts | 2 + packages/cli/src/util/sessions.ts | 2 +- 17 files changed, 1473 insertions(+), 8 deletions(-) create mode 100644 docs/ai/design/2026-06-10-feature-pi-adapter.md create mode 100644 docs/ai/implementation/2026-06-10-feature-pi-adapter.md create mode 100644 docs/ai/planning/2026-06-10-feature-pi-adapter.md create mode 100644 docs/ai/requirements/2026-06-10-feature-pi-adapter.md create mode 100644 docs/ai/testing/2026-06-10-feature-pi-adapter.md create mode 100644 packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts create mode 100644 packages/agent-manager/src/adapters/PiAdapter.ts diff --git a/README.md b/README.md index b585b50d..bb101ea8 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ One `.ai-devkit.json` configures all of them. Add a new agent to your team witho | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | yes | yes | | [Codex CLI](https://github.com/openai/codex) | yes | yes | | [opencode](https://opencode.ai/) | yes | testing | +| Pi | — | yes | | [Cursor](https://cursor.sh/) | yes | — | | [GitHub Copilot](https://code.visualstudio.com/) | yes | — | | [Antigravity](https://antigravity.google/) | yes | — | diff --git a/docs/ai/design/2026-06-10-feature-pi-adapter.md b/docs/ai/design/2026-06-10-feature-pi-adapter.md new file mode 100644 index 00000000..f41105ed --- /dev/null +++ b/docs/ai/design/2026-06-10-feature-pi-adapter.md @@ -0,0 +1,103 @@ +--- +phase: design +title: "Pi Adapter in @ai-devkit/agent-manager - Design" +feature: pi-adapter +description: Design for Pi process detection, tracker matching, fallback session matching, and session parsing +--- + +# Design: Pi Adapter in @ai-devkit/agent-manager + +## Architecture Overview + +```mermaid +graph TD + AgentManager --> PiAdapter + PiAdapter --> ProcessUtils[listAgentProcesses + enrichProcesses] + PiAdapter --> Registry[AgentRegistry cache] + PiAdapter --> Tracker[~/.pi/agent/sessions.json] + PiAdapter --> Sessions[~/.pi/agent/sessions/**/*.jsonl] + PiAdapter --> Matching[matchProcessesToSessions fallback] + Sessions --> Parser[Pi JSONL parser] + Parser --> AgentInfo + Parser --> ConversationMessage + Parser --> SessionSummary +``` + +`PiAdapter` follows the existing adapter shape: +- Discover candidate Pi processes. +- Return registry-cached session matches when valid. +- Prefer tracker-based PID matching for remaining processes. +- Discover session files and apply shared legacy matching for anything not matched by tracker data. +- Map parsed sessions into `AgentInfo`. +- Fall back to process-only agents when session parsing is unavailable. + +## Data Models + +### Agent Type + +`AgentType` adds: + +```ts +'pi' +``` + +### Tracker Metadata + +The tracker schema is a plain PID-to-session-path map: + +```json +{ + "12345": "/Users/me/.pi/agent/sessions/project/session.jsonl" +} +``` + +The adapter only trusts entries whose PID matches a live process and whose path exists under the Pi sessions directory. + +### Pi Session + +Pi JSONL is parsed permissively. Entries may expose timestamps, roles, content, cwd/project path, session id, and event type under common top-level or nested fields. The adapter derives: +- `sessionId`: explicit id when available, otherwise filename UUID/timestamp fallback. +- `projectPath`: explicit cwd/project path when available, otherwise process cwd for running agents. For fallback matching, encoded Pi project directory names are compared against live process CWDs instead of decoded, because path segments may contain hyphens. +- `summary`: latest user message. +- `sessionStart`: earliest timestamp or file birthtime/mtime. +- `lastActive`: latest timestamp or file mtime. + +## API Design + +- Public adapter contract remains `AgentAdapter`. +- CLI and channel agent-manager wiring register `new PiAdapter()` with the existing adapters. +- Package exports expose `PiAdapter`. +- No external network APIs or authentication are introduced. + +## Component Breakdown + +### `PiAdapter` +- `canHandle(processInfo)`: identifies Pi executable/script tokens. +- `detectAgents()`: combines registry cache, tracker matching, fallback matching, and process-only fallback. +- `getConversation(sessionFilePath)`: reads JSONL and normalizes message entries. +- `listSessions(opts)`: enumerates historical Pi session files and applies optional strict `cwd`. + +### Tracker Matching +- Reads `~/.pi/agent/sessions.json` with `safeReadFile`. +- Extracts numeric object keys whose values are string session paths. +- Validates the session path exists and is inside `~/.pi/agent/sessions`. +- Parses the matched session directly. + +### Fallback Matching +- Recursively discovers `*.jsonl` session files under `~/.pi/agent/sessions`. +- Uses shared `matchProcessesToSessions()` with session birth/mtime and resolved CWD when available. + +## Design Decisions + +- Use exact tracker matching first because PID metadata is more reliable than file timestamp heuristics. +- Keep legacy fallback because tracker installation is optional. +- Parse Pi JSONL permissively because the adapter should tolerate minor schema drift. +- Do not add a shared tracker abstraction yet; Pi is the first adapter with this specific extension file. +- Validate tracker paths under the Pi sessions directory to avoid arbitrary file reads from compromised metadata. + +## Non-Functional Requirements + +- Detection must avoid throwing on malformed JSON, missing directories, or unreadable files. +- Recursive session discovery should be bounded to `.jsonl` files under the Pi sessions root. +- Tracker path validation must prevent escaping the Pi sessions root. +- Existing adapter behavior and exports must remain source-compatible. diff --git a/docs/ai/implementation/2026-06-10-feature-pi-adapter.md b/docs/ai/implementation/2026-06-10-feature-pi-adapter.md new file mode 100644 index 00000000..ab58be74 --- /dev/null +++ b/docs/ai/implementation/2026-06-10-feature-pi-adapter.md @@ -0,0 +1,93 @@ +--- +phase: implementation +title: "Pi Adapter Implementation Notes" +feature: pi-adapter +description: Implementation details and verification evidence for Pi adapter support +--- + +# Implementation: Pi Adapter + +## Development Setup + +- Feature worktree: `.worktrees/feature-pi-adapter` +- Branch: `feature-pi-adapter` +- Dependency bootstrap: `npm ci` completed; Husky prepare could not write `.git/config` in the sandbox, but npm exited 0. + +## Code Structure + +- `packages/agent-manager/src/adapters/PiAdapter.ts`: Pi process detection, tracker matching, fallback matching, JSONL parsing, conversations, and historical session listing. +- `packages/agent-manager/src/adapters/AgentAdapter.ts`: `AgentType` now includes `pi`. +- `packages/agent-manager/src/index.ts` and `packages/agent-manager/src/adapters/index.ts`: export `PiAdapter`. +- `packages/cli/src/commands/agent.ts`: registers `PiAdapter` and labels `pi` as `Pi`. +- `packages/cli/src/services/channel/channel-runner.ts`: registers `PiAdapter` for channel bridge agent resolution. +- `packages/cli/src/util/sessions.ts`: accepts `--type pi`. + +## Implementation Notes + +### Core Features +- Exact matching: reads `~/.pi/agent/sessions.json` as a plain PID-to-session-path map. +- Path safety: tracker paths are used only when they resolve inside `~/.pi/agent/sessions`. +- Fallback matching: recursively discovers Pi `.jsonl` files and reuses `matchProcessesToSessions()`. +- Encoded project directories: fallback matching builds Pi's encoded directory form from each live process CWD, then compares that value to session parent directory names. It does not decode directory names because real path segments can contain hyphens. +- Parsing: JSONL lines are parsed permissively; malformed lines are skipped. +- Summaries: latest user message becomes the `AgentInfo.summary`; process-only fallback uses `Pi process running`. +- Detail parsing: Pi `type: "message"` records are parsed from nested `message.role` and `message.content` fields, including text-part arrays like `[{ type: "text", text: "hello" }]`. +- Simplification pass: detection is split into tracker and fallback mapping stages; JSONL entries are parsed once for session summaries; message parsing now uses explicit nested-message helpers instead of treating every `type` value as a possible role. + +### Patterns & Best Practices +- Matches existing adapter structure: process discovery, registry cache, direct matching, fallback matching, process-only fallback. +- Uses shared utilities for safe file reads/stats and agent naming. +- Keeps tracker support local to Pi instead of introducing a premature shared abstraction. + +## Integration Points + +- Optional extension: `@ai-devkit/pi-session-tracker` writes `~/.pi/agent/sessions.json`. +- CLI: `ai-devkit agent sessions --type pi` and session detail filtering now validate `pi`. +- Channel bridge: Pi agents can be resolved by the shared `AgentManager` used by channel runner. + +## Error Handling + +- Missing or malformed `sessions.json` returns an empty tracker map and continues to fallback matching. +- Trusted tracker entries whose session files cannot be parsed re-enter fallback matching before returning process-only agents. +- Missing session root returns process-only agents for running Pi processes. +- Bad JSONL lines are ignored. +- Unparseable sessions that remain unmatched after fallback return process-only agents. + +## Performance Considerations + +- Tracker matching avoids session scans for exact PID matches. +- Recursive discovery is limited to `.jsonl` files under the Pi sessions root. +- No network calls or external process calls are added. + +## Security Notes + +- Tracker paths are constrained to the Pi sessions root before reading. +- Session parsing treats all file contents as untrusted and skips malformed records. +- No secrets are read or emitted. + +## Verification Evidence + +- Red step: `npx nx test agent-manager --runInBand --testPathPattern=PiAdapter.test.ts` failed with missing `PiAdapter.js`. +- Focused adapter: `npx vitest run src/__tests__/adapters/PiAdapter.test.ts` exited 0 with 9 tests passed. +- Package tests: `npx nx run agent-manager:test` exited 0 with 14 files and 394 tests passed. +- Builds: `npx nx run agent-manager:build` and `npx nx run channel-connector:build` exited 0. +- Focused CLI: `npx vitest run src/__tests__/util/sessions.test.ts src/__tests__/commands/agent.test.ts src/__tests__/commands/channel.test.ts` exited 0 with 3 files and 87 tests passed. +- CLI package: `npx nx run cli:test` exited 0 with 54 files and 697 tests passed. +- CLI build: `npx nx run cli:build` exited 0 and built `cli` plus dependent packages. +- Detail regression: `npm run dev -- agent detail --id ai-devkit-25877 --json` exited 0 and returned two Pi conversation messages after rebuilding `agent-manager`. +- Simplification verification: `npx vitest run src/__tests__/adapters/PiAdapter.test.ts`, `npx nx run agent-manager:test`, `npx nx run cli:test`, `npx nx run cli:build`, and live `agent detail --id ai-devkit-25877 --json` all exited 0 after the refactor. + +## Phase 6 Implementation Check + +- Alignment: implementation matches the requirements and design for `pi` type support, tracker-first matching, legacy fallback, package exports, CLI/channel registration, conversation parsing, and historical sessions. +- Design clarification applied: fallback CWD matching uses encoded Pi project directory names generated from live process CWDs, avoiding lossy decoding of hyphenated path segments. +- No blocking implementation deviations found. +- Remaining validation gap: no live Pi process with `@ai-devkit/pi-session-tracker` was available for manual end-to-end verification. +- Update (2026-06-10): live Pi detail was verified for `ai-devkit-25877` after adding nested Pi message parsing. + +## Phase 8 Code Review + +- Finding fixed: a trusted tracker entry with an unparseable session file previously returned a process-only agent without attempting legacy matching. `PiAdapter` now sends that process through fallback matching before returning process-only. +- Scope cleanup: README changes are limited to adding Pi support; unrelated Copilot support-table changes were removed. +- Holistic review outcome: no remaining blocking issues found across adapter exports, `AgentType`, CLI registration, session type validation, channel runner registration, docs, and tests. +- Final verification: `npx vitest run src/__tests__/adapters/PiAdapter.test.ts`, `npm run test:coverage --workspace packages/agent-manager`, `npx nx run agent-manager:test`, `npx nx run cli:test`, `npx nx run cli:build`, and `npx ai-devkit@latest lint --feature pi-adapter` all exited 0 after the Phase 8 fix. diff --git a/docs/ai/planning/2026-06-10-feature-pi-adapter.md b/docs/ai/planning/2026-06-10-feature-pi-adapter.md new file mode 100644 index 00000000..6fe632a0 --- /dev/null +++ b/docs/ai/planning/2026-06-10-feature-pi-adapter.md @@ -0,0 +1,61 @@ +--- +phase: planning +title: "Pi Adapter Implementation Plan" +feature: pi-adapter +description: Task breakdown for implementing and validating Pi adapter support +--- + +# Planning: Pi Adapter + +## Milestones + +- [x] Milestone 1: Requirements, design, testing, and planning docs are complete. +- [x] Milestone 2: Pi adapter behavior is covered by failing tests. +- [x] Milestone 3: Pi adapter implementation passes focused and package tests. + +## Task Breakdown + +### Phase 1: Docs and Setup +- [x] Task 1.1: Initialize feature worktree and docs. +- [x] Task 1.2: Document requirements, design, testing strategy, and implementation plan. + +### Phase 2: Red Tests +- [x] Task 2.1: Add Pi adapter tests for tracker matching and fallback matching. +- [x] Task 2.2: Add Pi parsing, conversation, listSessions, export, and registration tests. + +### Phase 3: Implementation +- [x] Task 3.1: Add `pi` to `AgentType`. +- [x] Task 3.2: Implement `PiAdapter`. +- [x] Task 3.3: Export `PiAdapter` and register it in CLI/channel agent-manager wiring. +- [x] Task 3.4: Update README support matrix if present. + +### Phase 4: Verification +- [x] Task 4.1: Run focused Pi adapter tests. +- [x] Task 4.2: Run `npx nx run agent-manager:test`. +- [x] Task 4.3: Update implementation/testing docs with evidence. + +## Dependencies + +- Existing shared utilities: `process`, `session`, `matching`, `AgentRegistry`. +- Optional external extension: `@ai-devkit/pi-session-tracker`; adapter must work without it. +- User-confirmed type string: `pi`. + +## Timeline & Estimates + +- Docs/setup: small. +- Tests: medium, because process and filesystem setup must mirror existing adapter patterns. +- Implementation: medium, due defensive tracker/schema parsing. +- Verification: small to medium, depending on suite runtime. + +## Risks & Mitigation + +- Risk: Tracker metadata is malformed or points at a stale file. Mitigation: accept only the documented PID-to-session-path map and fall back safely when entries are unusable. +- Risk: Pi JSONL schema differs from parser assumptions. Mitigation: parse common message fields permissively and never throw on unknown lines. +- Risk: Process command detection is too broad. Mitigation: match executable/script path tokens rather than arbitrary prose when possible. +- Risk: Real Pi is unavailable for manual verification. Mitigation: automated tests cover filesystem/process behavior; call out manual gap. + +## Resources Needed + +- Local test filesystem via temporary HOME. +- Vitest mocking for process utilities. +- Existing adapter tests as implementation examples. diff --git a/docs/ai/requirements/2026-06-10-feature-pi-adapter.md b/docs/ai/requirements/2026-06-10-feature-pi-adapter.md new file mode 100644 index 00000000..e361133a --- /dev/null +++ b/docs/ai/requirements/2026-06-10-feature-pi-adapter.md @@ -0,0 +1,80 @@ +--- +phase: requirements +title: "Pi Adapter in @ai-devkit/agent-manager - Requirements" +feature: pi-adapter +description: Add a Pi adapter that detects running Pi sessions from local session files and tracker metadata +--- + +# Requirements: Add Pi Adapter to @ai-devkit/agent-manager + +## Problem Statement + +`@ai-devkit/agent-manager` supports multiple local AI agents through adapters, but Pi sessions are not surfaced by `agent list`, `agent detail`, or related flows. Pi persists local sessions under `~/.pi/agent/sessions`, and the companion extension `@ai-devkit/pi-session-tracker` can write `~/.pi/agent/sessions.json` to map running PIDs to exact session files. + +Who is affected: +- Users running Pi who expect active sessions to appear alongside Claude, Codex, Gemini CLI, OpenCode, and Copilot. +- Maintainers adding adapter support who need Pi to follow existing agent-manager patterns. +- Users without the tracker extension, who still need a best-effort fallback instead of an empty result. + +## Goals & Objectives + +### Primary Goals +- Add `pi` as a first-class `AgentType`. +- Implement `PiAdapter` in `packages/agent-manager`. +- Detect running Pi processes and map them to session files. +- Prefer exact PID-to-session matching from `~/.pi/agent/sessions.json`. +- Fall back to legacy process/session matching when `sessions.json` is missing or unusable. +- Export and register the adapter so existing manager flows include Pi. + +### Secondary Goals +- Reuse shared process, session, matching, and registry utilities. +- Parse Pi JSONL sessions enough to expose summary, timestamps, project path, conversation messages, and historical session summaries. +- Cover tracker matching, fallback matching, malformed data, and conversation extraction with unit tests. + +### Non-Goals +- Implementing or installing `@ai-devkit/pi-session-tracker`. +- Changing Pi's session file format. +- Redesigning the agent list/detail/open UX. +- Removing existing legacy matching behavior for other adapters. + +## User Stories & Use Cases + +1. As a Pi user with `@ai-devkit/pi-session-tracker` installed, I want active Pi sessions to be matched by PID so the correct session is shown even when timestamp heuristics are ambiguous. +2. As a Pi user without the tracker file, I want `agent list` to fall back to existing matching heuristics so Pi still appears when session timing and CWD are enough. +3. As a user inspecting an agent, I want Pi conversation messages to be readable from the stored JSONL session. +4. As a maintainer, I want Pi support to reuse the adapter contract and exports used by the other adapters. + +## Success Criteria + +- `AgentType` includes `pi`. +- `PiAdapter` exists and implements `AgentAdapter`. +- `PiAdapter` is exported from adapter and package entry points. +- CLI and channel agent-manager wiring register `PiAdapter` with the existing adapters. +- When `~/.pi/agent/sessions.json` maps a running PID to a session path, `detectAgents()` returns the mapped session. +- When `sessions.json` is absent, `detectAgents()` uses session discovery plus shared legacy matching. +- Invalid or missing tracker/session files do not break other detection paths. +- `getConversation()` returns user and assistant messages from Pi JSONL entries. +- `listSessions()` returns historical Pi sessions and honors strict `cwd` filtering. +- Focused adapter tests and package tests pass. + +## Constraints & Assumptions + +### Technical Constraints +- Follow existing TypeScript, Nx, Vitest, and adapter conventions. +- Keep JSON/table output shape compatible with existing `AgentInfo`. +- Do not require tracker installation for fallback support. +- Isolate file parsing failures so one bad Pi session does not abort detection. + +### Assumptions +- Pi process commands can be identified by executable/script tokens containing `pi`. +- Pi sessions live below `~/.pi/agent/sessions`. +- Project-scoped Pi sessions may live in encoded project directories such as `--Users-hoangnguyen-Codeaholicguy-Code-ai-devkit--`. +- Session filenames can include an ISO-like timestamp and UUID, for example `2026-06-10T08-58-20-754Z_019eb0c1-06d2-71ed-90ee-7acbf4b21c5b.jsonl`. +- `~/.pi/agent/sessions.json` is created by `@ai-devkit/pi-session-tracker` and maps PIDs directly to session file paths. +- `pi` is the public adapter type string. + +## Questions & Open Items + +- Resolved (2026-06-10): Public adapter type is `pi`. +- Resolved (2026-06-10): `sessions.json` is optional. Missing tracker metadata falls back to legacy matching. +- Resolved (2026-06-10): Tracker JSON schema is a plain PID-to-session-path map, for example `{ "12345": "/path/to/session.jsonl" }`. diff --git a/docs/ai/testing/2026-06-10-feature-pi-adapter.md b/docs/ai/testing/2026-06-10-feature-pi-adapter.md new file mode 100644 index 00000000..785d473e --- /dev/null +++ b/docs/ai/testing/2026-06-10-feature-pi-adapter.md @@ -0,0 +1,82 @@ +--- +phase: testing +title: "Pi Adapter Testing Strategy" +feature: pi-adapter +description: Test coverage for Pi tracker matching, fallback matching, parsing, exports, and registration +--- + +# Testing Strategy: Pi Adapter + +## Test Coverage Goals + +- Target 100% meaningful branch coverage for new Pi adapter code. +- Cover exact tracker matching and missing-tracker fallback. +- Cover malformed files without adapter-wide failure. +- Run package-level tests for agent-manager after implementation. + +## Unit Tests + +### `PiAdapter` +- [x] Detects a Pi process and maps it through `~/.pi/agent/sessions.json` by PID. +- [x] Falls back to shared legacy matching when `sessions.json` is missing. +- [x] Returns a process-only Pi agent when no session file can be matched. +- [x] Ignores malformed tracker JSON and still uses fallback matching. +- [x] Falls back to legacy matching when a trusted tracker session file is unparseable. +- [x] Ignores tracker paths outside `~/.pi/agent/sessions`. +- [x] Parses user/assistant conversation messages from JSONL. +- [x] Parses real Pi `type: "message"` entries with nested `message.role` and text-part content arrays. +- [x] Includes system messages only when conversation detail is requested with `verbose`. +- [x] Derives waiting/running/idle status from recent message role and session age. +- [x] Truncates long user prompts for session summaries. +- [x] Falls back to filename-derived session IDs when explicit session IDs are absent. +- [x] Lists historical Pi sessions and applies strict `cwd` filtering. +- [x] Handles malformed session lines without throwing. +- [x] `canHandle()` accepts Pi commands and rejects unrelated commands. + +### Exports and Registration +- [x] Adapter exports include `PiAdapter`. +- [x] CLI and channel agent-manager wiring register Pi. + +## Integration Tests + +- [x] `npx nx run agent-manager:test` passes. +- [x] `npx nx run cli:test` passes. +- [x] `npx nx run cli:build` passes. + +## End-to-End Tests + +- [x] Manual real Pi verification: `ai-devkit-25877` detail returned user and assistant messages from the tracked Pi session file. + +## Test Data + +- Temporary HOME directories with `.pi/agent/sessions`. +- JSONL session files using filenames like `2026-06-10T08-58-20-754Z_019eb0c1-06d2-71ed-90ee-7acbf4b21c5b.jsonl`. +- `sessions.json` PID-to-session-path map shape. +- Mocked process utilities for live process discovery. + +## Test Reporting & Coverage + +- Report exact commands, exit codes, and key pass/fail output. +- Any manual Pi verification gaps must be called out explicitly. + +### Phase 7 Verification + +- `npx vitest run src/__tests__/adapters/PiAdapter.test.ts`: exit 0, 14 Pi adapter tests passed after narrowing tracker support to the documented PID-to-session-path map. +- `npm run test:coverage --workspace packages/agent-manager`: exit 0, 399 tests passed. `PiAdapter.ts` coverage: 90.47% statements, 74.87% branches, 98% functions, 96.03% lines. +- `npx nx run agent-manager:test`: exit 0, 399 tests passed. +- `npx nx run cli:test`: exit 0, 697 tests passed. +- `npx nx run cli:build`: exit 0. + +Remaining uncovered Pi branches are defensive paths around malformed optional metadata and filesystem edge cases; they are documented as residual low-risk coverage gaps rather than expanded into brittle tests. + +## Manual Testing + +- Optional: run a real Pi session with `@ai-devkit/pi-session-tracker` installed and verify `ai-devkit agent list --type pi`. + +## Performance Testing + +- Unit tests should include multiple session files but no separate load test is required for this local filesystem adapter. + +## Bug Tracking + +- Regressions found during implementation are tracked in the planning and implementation docs. diff --git a/packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts b/packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts new file mode 100644 index 00000000..dbcaa436 --- /dev/null +++ b/packages/agent-manager/src/__tests__/adapters/PiAdapter.test.ts @@ -0,0 +1,435 @@ +/** + * Tests for PiAdapter + */ + +import type { MockedFunction } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { PiAdapter } from '../../adapters/PiAdapter.js'; +import type { ProcessInfo } from '../../adapters/AgentAdapter.js'; +import { AgentStatus } from '../../adapters/AgentAdapter.js'; +import { AgentRegistry } from '../../utils/AgentRegistry.js'; +import { listAgentProcesses, enrichProcesses } from '../../utils/process.js'; +import { matchProcessesToSessions, generateAgentName } from '../../utils/matching.js'; + +vi.mock('../../utils/process.js', () => ({ + listAgentProcesses: vi.fn(), + enrichProcesses: vi.fn(), +})); + +vi.mock('../../utils/matching.js', () => ({ + matchProcessesToSessions: vi.fn(), + generateAgentName: vi.fn(), +})); + +const mockedListAgentProcesses = listAgentProcesses as MockedFunction; +const mockedEnrichProcesses = enrichProcesses as MockedFunction; +const mockedMatchProcessesToSessions = matchProcessesToSessions as MockedFunction; +const mockedGenerateAgentName = generateAgentName as MockedFunction; + +describe('PiAdapter', () => { + let adapter: PiAdapter; + let tmpHome: string; + let sessionsDir: string; + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-adapter-test-')); + process.env.HOME = tmpHome; + sessionsDir = path.join(tmpHome, '.pi', 'agent', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + adapter = new PiAdapter(new AgentRegistry(path.join(tmpHome, 'agents.json'))); + mockedListAgentProcesses.mockReset(); + mockedEnrichProcesses.mockReset(); + mockedMatchProcessesToSessions.mockReset(); + mockedGenerateAgentName.mockReset(); + + mockedEnrichProcesses.mockImplementation((procs) => procs); + mockedMatchProcessesToSessions.mockReturnValue([]); + mockedGenerateAgentName.mockImplementation((cwd: string, pid: number) => { + const folder = path.basename(cwd) || 'unknown'; + return `${folder} (${pid})`; + }); + }); + + afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('exposes pi type', () => { + expect(adapter.type).toBe('pi'); + }); + + it('identifies Pi commands without matching unrelated paths', () => { + expect(adapter.canHandle({ pid: 1, command: 'pi', cwd: '/repo', tty: 'ttys001' })).toBe(true); + expect(adapter.canHandle({ pid: 2, command: '/usr/local/bin/PI --model x', cwd: '/repo', tty: 'ttys002' })).toBe(true); + expect(adapter.canHandle({ pid: 3, command: 'node /opt/pi/bin/pi.js', cwd: '/repo', tty: 'ttys003' })).toBe(true); + expect(adapter.canHandle({ pid: 4, command: 'node /repo/feature-pi-adapter/script.js', cwd: '/repo', tty: 'ttys004' })).toBe(false); + }); + + it('maps a running Pi process to the tracker session for its PID', async () => { + const cwd = '/repo/project-a'; + const proc = makeProcess({ pid: 101, cwd }); + const sessionFile = writePiSession(cwd, [ + { type: 'session_meta', timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-101', cwd }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'implement Pi adapter' }, + { role: 'assistant', timestamp: new Date().toISOString(), content: 'working on it' }, + ]); + fs.writeFileSync( + path.join(tmpHome, '.pi', 'agent', 'sessions.json'), + JSON.stringify({ 101: sessionFile }), + ); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'pi', + pid: 101, + projectPath: cwd, + sessionId: 'sess-101', + summary: 'implement Pi adapter', + status: AgentStatus.WAITING, + sessionFilePath: sessionFile, + }); + expect(mockedMatchProcessesToSessions).not.toHaveBeenCalled(); + }); + + it('truncates long user prompts in detected agent summaries', async () => { + const cwd = '/repo/project-long-summary'; + const proc = makeProcess({ pid: 112, cwd }); + const longPrompt = 'x'.repeat(140); + const sessionFile = writePiSession(cwd, [ + { type: 'session', timestamp: '2026-06-10T08:58:20.754Z', id: 'sess-long', cwd }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: longPrompt }, + ]); + fs.writeFileSync( + path.join(tmpHome, '.pi', 'agent', 'sessions.json'), + JSON.stringify({ 112: sessionFile }), + ); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents[0].summary).toHaveLength(120); + expect(agents[0].summary.endsWith('...')).toBe(true); + }); + + it('uses the filename session id fallback and reports running when the latest message is from the user', async () => { + const cwd = '/repo/project-filename-fallback'; + const proc = makeProcess({ pid: 113, cwd }); + const sessionFile = writePiSessionWithFileName(cwd, 'plain-session.jsonl', [ + { role: 'user', timestamp: new Date().toISOString(), content: 'still working' }, + ]); + fs.writeFileSync( + path.join(tmpHome, '.pi', 'agent', 'sessions.json'), + JSON.stringify({ 113: sessionFile }), + ); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents[0]).toMatchObject({ + sessionId: 'plain-session', + summary: 'still working', + status: AgentStatus.RUNNING, + }); + }); + + it('falls back to legacy matching when sessions.json is missing', async () => { + const cwd = '/repo/project-b'; + const proc = makeProcess({ pid: 202, cwd }); + const sessionFile = writePiSession(cwd, [ + { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-202' }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'fallback matching please' }, + ]); + mockedListAgentProcesses.mockReturnValue([proc]); + mockedMatchProcessesToSessions.mockReturnValue([ + { + process: proc, + session: { + sessionId: 'sess-202', + filePath: sessionFile, + projectDir: path.dirname(sessionFile), + birthtimeMs: Date.now(), + resolvedCwd: cwd, + }, + deltaMs: 0, + }, + ]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'pi', + pid: 202, + projectPath: cwd, + sessionId: 'sess-202', + summary: 'fallback matching please', + }); + expect(mockedMatchProcessesToSessions).toHaveBeenCalledWith( + [proc], + expect.arrayContaining([ + expect.objectContaining({ filePath: sessionFile, resolvedCwd: cwd }), + ]), + ); + }); + + it('ignores malformed tracker metadata and still falls back to legacy matching', async () => { + const cwd = '/repo/project-c'; + const proc = makeProcess({ pid: 303, cwd }); + const sessionFile = writePiSession(cwd, [ + { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-303', cwd }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'recover from bad tracker' }, + ]); + fs.writeFileSync(path.join(tmpHome, '.pi', 'agent', 'sessions.json'), '{bad json'); + mockedListAgentProcesses.mockReturnValue([proc]); + mockedMatchProcessesToSessions.mockReturnValue([ + { + process: proc, + session: { + sessionId: 'sess-303', + filePath: sessionFile, + projectDir: path.dirname(sessionFile), + birthtimeMs: Date.now(), + resolvedCwd: cwd, + }, + deltaMs: 0, + }, + ]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0].sessionId).toBe('sess-303'); + }); + + it('falls back to legacy matching when a trusted tracker session is unparseable', async () => { + const cwd = '/repo/project-bad-tracker-session'; + const proc = makeProcess({ pid: 304, cwd }); + const badSessionFile = writePiSessionWithFileName(cwd, 'bad.jsonl', ['{not json']); + const fallbackSessionFile = writePiSession(cwd, [ + { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-304', cwd }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'fallback after bad tracker session' }, + ]); + fs.writeFileSync( + path.join(tmpHome, '.pi', 'agent', 'sessions.json'), + JSON.stringify({ 304: badSessionFile }), + ); + mockedListAgentProcesses.mockReturnValue([proc]); + mockedMatchProcessesToSessions.mockReturnValue([ + { + process: proc, + session: { + sessionId: 'sess-304', + filePath: fallbackSessionFile, + projectDir: path.dirname(fallbackSessionFile), + birthtimeMs: Date.now(), + resolvedCwd: cwd, + }, + deltaMs: 0, + }, + ]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + sessionId: 'sess-304', + summary: 'fallback after bad tracker session', + sessionFilePath: fallbackSessionFile, + }); + expect(mockedMatchProcessesToSessions).toHaveBeenCalled(); + }); + + it('does not trust tracker paths outside the Pi sessions directory', async () => { + const cwd = '/repo/project-d'; + const proc = makeProcess({ pid: 404, cwd }); + const outside = path.join(tmpHome, 'outside.jsonl'); + fs.writeFileSync(outside, JSON.stringify({ role: 'user', content: 'nope' })); + fs.writeFileSync( + path.join(tmpHome, '.pi', 'agent', 'sessions.json'), + JSON.stringify({ 404: outside }), + ); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'pi', + pid: 404, + sessionId: 'pid-404', + summary: 'Pi process running', + }); + }); + + it('returns a process-only agent when no session can be matched', async () => { + const proc = makeProcess({ pid: 505, cwd: '/repo/project-e' }); + mockedListAgentProcesses.mockReturnValue([proc]); + + const agents = await adapter.detectAgents(); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: 'pi', + status: AgentStatus.RUNNING, + pid: 505, + projectPath: '/repo/project-e', + sessionId: 'pid-505', + summary: 'Pi process running', + }); + }); + + it('reads user and assistant conversation messages from JSONL', () => { + const cwd = '/repo/project-f'; + const sessionFile = writePiSession(cwd, [ + { role: 'system', timestamp: '2026-06-10T08:58:20.000Z', content: 'hidden' }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'hello pi' }, + { type: 'assistant', timestamp: '2026-06-10T08:58:22.000Z', message: { content: 'hello human' } }, + '{not json', + ]); + + expect(adapter.getConversation(sessionFile)).toEqual([ + { role: 'user', content: 'hello pi', timestamp: '2026-06-10T08:58:21.000Z' }, + { role: 'assistant', content: 'hello human', timestamp: '2026-06-10T08:58:22.000Z' }, + ]); + }); + + it('includes system entries only in verbose conversation mode', () => { + const cwd = '/repo/project-verbose'; + const sessionFile = writePiSession(cwd, [ + { role: 'system', timestamp: '2026-06-10T08:58:20.000Z', content: 'model changed' }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'visible' }, + ]); + + expect(adapter.getConversation(sessionFile)).toEqual([ + { role: 'user', content: 'visible', timestamp: '2026-06-10T08:58:21.000Z' }, + ]); + expect(adapter.getConversation(sessionFile, { verbose: true })).toEqual([ + { role: 'system', content: 'model changed', timestamp: '2026-06-10T08:58:20.000Z' }, + { role: 'user', content: 'visible', timestamp: '2026-06-10T08:58:21.000Z' }, + ]); + }); + + it('reads real Pi message entries with nested role and text parts', async () => { + const cwd = '/repo/project-real'; + const proc = makeProcess({ pid: 606, cwd }); + const sessionFile = writePiSession(cwd, [ + { type: 'session', version: 3, id: 'sess-real', timestamp: '2026-06-10T13:27:17.581Z', cwd }, + { type: 'model_change', id: 'model-1', timestamp: '2026-06-10T13:27:17.655Z', modelId: 'claude-sonnet-4-6' }, + { + type: 'message', + id: 'msg-user', + timestamp: '2026-06-10T13:27:37.975Z', + message: { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + timestamp: 1781098057974, + }, + }, + { + type: 'message', + id: 'msg-assistant', + timestamp: '2026-06-10T13:27:40.161Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Hello! How can I help you today?' }], + provider: 'anthropic', + model: 'claude-sonnet-4-6', + timestamp: 1781098058012, + }, + }, + ]); + fs.writeFileSync( + path.join(tmpHome, '.pi', 'agent', 'sessions.json'), + JSON.stringify({ 606: sessionFile }), + ); + mockedListAgentProcesses.mockReturnValue([proc]); + + expect(adapter.getConversation(sessionFile)).toEqual([ + { role: 'user', content: 'hello', timestamp: '2026-06-10T13:27:37.975Z' }, + { role: 'assistant', content: 'Hello! How can I help you today?', timestamp: '2026-06-10T13:27:40.161Z' }, + ]); + + const agents = await adapter.detectAgents(); + expect(agents[0]).toMatchObject({ + sessionId: 'sess-real', + summary: 'hello', + lastActive: new Date('2026-06-10T13:27:40.161Z'), + }); + }); + + it('lists historical sessions and applies cwd filtering', async () => { + const matchingCwd = '/repo/project-g'; + const otherCwd = '/repo/project-h'; + const matchingSession = writePiSession(matchingCwd, [ + { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-g', cwd: matchingCwd }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'first matching message' }, + ]); + writePiSession(otherCwd, [ + { timestamp: '2026-06-10T08:58:20.754Z', sessionId: 'sess-h', cwd: otherCwd }, + { role: 'user', timestamp: '2026-06-10T08:58:21.000Z', content: 'other message' }, + ]); + + const sessions = await adapter.listSessions({ cwd: matchingCwd }); + + expect(sessions).toEqual([ + expect.objectContaining({ + type: 'pi', + sessionId: 'sess-g', + cwd: matchingCwd, + firstUserMessage: 'first matching message', + sessionFilePath: matchingSession, + }), + ]); + }); + + function makeProcess(overrides: Partial): ProcessInfo { + return { + pid: 1, + command: 'pi', + cwd: '/repo', + tty: 'ttys001', + startTime: new Date('2026-06-10T08:58:20.000Z'), + ...overrides, + }; + } + + function writePiSession(cwd: string, entries: Array | string>): string { + const projectDir = path.join(sessionsDir, encodeProjectDir(cwd)); + fs.mkdirSync(projectDir, { recursive: true }); + const sessionId = entries + .map((entry) => typeof entry === 'string' ? undefined : entry.sessionId) + .find((value): value is string => typeof value === 'string') ?? cryptoRandomSessionId(); + const filePath = path.join(projectDir, `2026-06-10T08-58-20-754Z_${sessionId}.jsonl`); + fs.writeFileSync( + filePath, + entries.map((entry) => typeof entry === 'string' ? entry : JSON.stringify(entry)).join('\n'), + ); + return filePath; + } + + function writePiSessionWithFileName(cwd: string, fileName: string, entries: Array | string>): string { + const projectDir = path.join(sessionsDir, encodeProjectDir(cwd)); + fs.mkdirSync(projectDir, { recursive: true }); + const filePath = path.join(projectDir, fileName); + fs.writeFileSync( + filePath, + entries.map((entry) => typeof entry === 'string' ? entry : JSON.stringify(entry)).join('\n'), + ); + return filePath; + } + + function encodeProjectDir(cwd: string): string { + return cwd.replace(/\//g, '-').replace(/^-?/, '--') + '--'; + } + + function cryptoRandomSessionId(): string { + return `019eb0c1-06d2-71ed-90ee-${Math.random().toString(16).slice(2, 14).padEnd(12, '0')}`; + } +}); diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts index 2ccff15b..2ad13c3f 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -8,7 +8,7 @@ /** * Type of AI agent */ -export type AgentType = 'claude' | 'gemini_cli' | 'codex' | 'opencode' | 'copilot' | 'other'; +export type AgentType = 'claude' | 'gemini_cli' | 'codex' | 'opencode' | 'copilot' | 'pi' | 'other'; /** * Current status of an agent diff --git a/packages/agent-manager/src/adapters/PiAdapter.ts b/packages/agent-manager/src/adapters/PiAdapter.ts new file mode 100644 index 00000000..8c31b4be --- /dev/null +++ b/packages/agent-manager/src/adapters/PiAdapter.ts @@ -0,0 +1,597 @@ +/** + * Pi Adapter + * + * Detects running Pi agents by: + * 1. Finding running Pi processes + * 2. Matching exact PID-to-session metadata from ~/.pi/agent/sessions.json + * 3. Falling back to shared process/session matching over Pi JSONL session files + * 4. Parsing Pi JSONL entries defensively for summary and conversation output + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { + AgentAdapter, + AgentInfo, + ProcessInfo, + ConversationMessage, + SessionSummary, + ListSessionsOptions, +} from './AgentAdapter.js'; +import { AgentStatus } from './AgentAdapter.js'; +import { listAgentProcesses, enrichProcesses } from '../utils/process.js'; +import { isDirectory, safeReadFile, safeReaddir, safeStat } from '../utils/session.js'; +import type { SessionFile } from '../utils/session.js'; +import { matchProcessesToSessions, generateAgentName } from '../utils/matching.js'; +import { AgentRegistry } from '../utils/AgentRegistry.js'; + +interface PiSession { + sessionId: string; + projectPath: string; + summary: string; + sessionStart: Date; + lastActive: Date; + lastRole?: ConversationMessage['role']; +} + +interface PiLine { + timestamp?: string; + role?: string; + type?: string; + content?: unknown; + text?: unknown; + message?: unknown; + sessionId?: string; + session_id?: string; + id?: string; + cwd?: string; + projectPath?: string; + project_path?: string; + payload?: Record; + data?: Record; + [key: string]: unknown; +} + +type PiRecord = Record; + +interface TrackerMatch { + process: ProcessInfo; + filePath: string; +} + +interface TrackerAgentResult { + agents: AgentInfo[]; + fallback: ProcessInfo[]; +} + +export class PiAdapter implements AgentAdapter { + readonly type = 'pi' as const; + + private static readonly IDLE_THRESHOLD_MINUTES = 5; + + private piAgentDir: string; + private piSessionsDir: string; + private trackerPath: string; + private registry: AgentRegistry; + + constructor(registry: AgentRegistry = AgentRegistry.default()) { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + this.piAgentDir = path.join(homeDir, '.pi', 'agent'); + this.piSessionsDir = path.join(this.piAgentDir, 'sessions'); + this.trackerPath = path.join(this.piAgentDir, 'sessions.json'); + this.registry = registry; + } + + canHandle(processInfo: ProcessInfo): boolean { + return this.isPiExecutable(processInfo.command); + } + + async detectAgents(): Promise { + const processes = enrichProcesses(this.listPiProcesses()); + if (processes.length === 0) return []; + + const { cachedAgents, remaining } = this.tryRegistryCache(processes); + if (remaining.length === 0) return cachedAgents; + + const trackerResult = this.mapTrackerMatches(remaining); + const fallbackAgents = this.mapFallbackMatches(trackerResult.fallback); + + return [ + ...cachedAgents, + ...trackerResult.agents, + ...fallbackAgents, + ]; + } + + private mapTrackerMatches(processes: ProcessInfo[]): TrackerAgentResult { + const { matches: trackerMatches, fallback } = this.matchFromTracker(processes); + const agents: AgentInfo[] = []; + + for (const match of trackerMatches) { + const session = this.parseSession(match.filePath, match.process.cwd); + if (session) { + agents.push(this.mapSessionToAgent(session, match.process, match.filePath)); + } else { + fallback.push(match.process); + } + } + + return { agents, fallback }; + } + + private mapFallbackMatches(processes: ProcessInfo[]): AgentInfo[] { + if (processes.length === 0) return []; + + const sessions = this.discoverSessions(processes); + if (sessions.length === 0) { + return processes.map((p) => this.mapProcessOnlyAgent(p)); + } + + const matches = matchProcessesToSessions(processes, sessions); + const matchedPids = new Set(matches.map((m) => m.process.pid)); + const agents: AgentInfo[] = []; + + for (const match of matches) { + const session = this.parseSession(match.session.filePath, match.process.cwd); + if (session) { + agents.push(this.mapSessionToAgent(session, match.process, match.session.filePath)); + } else { + matchedPids.delete(match.process.pid); + } + } + + for (const proc of processes) { + if (!matchedPids.has(proc.pid)) { + agents.push(this.mapProcessOnlyAgent(proc)); + } + } + + return agents; + } + + private listPiProcesses(): ProcessInfo[] { + const byPid = new Map(); + for (const proc of listAgentProcesses('pi')) { + if (this.canHandle(proc)) byPid.set(proc.pid, proc); + } + for (const proc of listAgentProcesses('node')) { + if (this.canHandle(proc)) byPid.set(proc.pid, proc); + } + return Array.from(byPid.values()); + } + + private tryRegistryCache(processes: ProcessInfo[]): { + cachedAgents: AgentInfo[]; + remaining: ProcessInfo[]; + } { + const cachedAgents: AgentInfo[] = []; + const remaining: ProcessInfo[] = []; + const byPid = new Map(this.registry.list().map((e) => [e.pid, e])); + + for (const proc of processes) { + const entry = byPid.get(proc.pid); + if ( + !entry || + entry.type !== this.type || + !entry.sessionFilePath || + !fs.existsSync(entry.sessionFilePath) + ) { + remaining.push(proc); + continue; + } + + const session = this.parseSession(entry.sessionFilePath, proc.cwd); + if (!session) { + remaining.push(proc); + continue; + } + + cachedAgents.push(this.mapSessionToAgent(session, proc, entry.sessionFilePath)); + } + + return { cachedAgents, remaining }; + } + + private matchFromTracker(processes: ProcessInfo[]): { + matches: TrackerMatch[]; + fallback: ProcessInfo[]; + } { + const tracker = this.readTracker(); + if (tracker.size === 0) return { matches: [], fallback: processes }; + + const matches: TrackerMatch[] = []; + const fallback: ProcessInfo[] = []; + + for (const proc of processes) { + const filePath = tracker.get(proc.pid); + if (!filePath || !this.isTrustedSessionPath(filePath) || !fs.existsSync(filePath)) { + fallback.push(proc); + continue; + } + matches.push({ process: proc, filePath }); + } + + return { matches, fallback }; + } + + private readTracker(): Map { + const content = safeReadFile(this.trackerPath); + if (content === undefined) return new Map(); + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return new Map(); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return new Map(); + + const map = new Map(); + for (const [key, value] of Object.entries(parsed)) { + const keyPid = this.toPid(key); + if (keyPid !== null && typeof value === 'string' && value) { + map.set(keyPid, value); + } + } + return map; + } + + private toPid(value: unknown): number | null { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value; + if (typeof value !== 'string' || !/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; + } + + private isTrustedSessionPath(filePath: string): boolean { + const resolvedRoot = path.resolve(this.piSessionsDir); + const resolvedPath = path.resolve(filePath); + return resolvedPath === resolvedRoot || resolvedPath.startsWith(`${resolvedRoot}${path.sep}`); + } + + private discoverSessions(processes: ProcessInfo[] = []): SessionFile[] { + if (!isDirectory(this.piSessionsDir)) return []; + + const cwdByProjectDir = this.buildProjectDirCwdMap(processes); + const sessions: SessionFile[] = []; + for (const filePath of this.collectJsonlFiles(this.piSessionsDir)) { + const stat = safeStat(filePath); + if (!stat) continue; + + const session = this.parseSession(filePath); + const sessionId = session?.sessionId || this.sessionIdFromFile(filePath); + const projectDir = path.dirname(filePath); + sessions.push({ + sessionId, + filePath, + projectDir, + birthtimeMs: stat.birthtimeMs || stat.mtimeMs, + resolvedCwd: session?.projectPath || cwdByProjectDir.get(path.basename(projectDir)) || '', + }); + } + + return sessions; + } + + private buildProjectDirCwdMap(processes: ProcessInfo[]): Map { + const map = new Map(); + for (const proc of processes) { + if (!proc.cwd) continue; + map.set(this.encodeProjectDir(proc.cwd), proc.cwd); + } + return map; + } + + private collectJsonlFiles(dir: string): string[] { + const files: string[] = []; + for (const entry of safeReaddir(dir)) { + const fullPath = path.join(dir, entry); + const stat = safeStat(fullPath); + if (!stat) continue; + if (stat.isDirectory()) { + files.push(...this.collectJsonlFiles(fullPath)); + } else if (stat.isFile() && entry.endsWith('.jsonl')) { + files.push(fullPath); + } + } + return files; + } + + private parseSession(filePath: string, fallbackCwd = ''): PiSession | null { + const entries = this.readJsonl(filePath); + if (entries.length === 0) return null; + return this.sessionFromEntries(entries, filePath, fallbackCwd); + } + + private sessionFromEntries(entries: PiLine[], filePath: string, fallbackCwd = ''): PiSession { + const stat = safeStat(filePath); + const timestamps = entries + .map((entry) => this.parseTimestamp(this.entryTimestamp(entry))) + .filter((value): value is Date => value !== null); + + const sessionStart = timestamps[0] ?? stat?.birthtime ?? stat?.mtime ?? new Date(); + const lastActive = timestamps[timestamps.length - 1] ?? stat?.mtime ?? sessionStart; + const messages = this.entriesToMessages(entries, true); + const lastUser = [...messages].reverse().find((msg) => msg.role === 'user'); + const lastMessage = messages[messages.length - 1]; + + return { + sessionId: this.sessionIdFromEntries(entries) || this.sessionIdFromFile(filePath), + projectPath: this.cwdFromEntries(entries) || fallbackCwd, + summary: lastUser?.content ? this.truncate(lastUser.content, 120) : 'Pi session active', + sessionStart, + lastActive, + lastRole: lastMessage?.role, + }; + } + + private readJsonl(filePath: string): PiLine[] { + const content = safeReadFile(filePath); + if (content === undefined) return []; + + const entries: PiLine[] = []; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + entries.push(parsed as PiLine); + } + } catch { + continue; + } + } + return entries; + } + + private entryToMessage(entry: PiLine, includeSystem: boolean): ConversationMessage | null { + const role = this.entryRole(entry); + if (!role) return null; + if (role === 'system' && !includeSystem) return null; + + const content = this.entryContent(entry).trim(); + if (!content) return null; + + return { + role, + content, + timestamp: this.entryTimestamp(entry), + }; + } + + private entryRole(entry: PiLine): ConversationMessage['role'] | null { + const message = this.messageRecord(entry); + const raw = this.firstString( + entry.role, + message?.role, + this.roleLikeType(entry.type), + entry.payload?.role, + entry.payload?.type, + entry.data?.role, + entry.data?.type, + ); + if (!raw) return null; + const normalized = raw.toLowerCase(); + if (normalized === 'user' || normalized === 'human') return 'user'; + if (normalized === 'assistant' || normalized === 'ai' || normalized === 'pi') return 'assistant'; + if (normalized === 'system') return 'system'; + return null; + } + + private entryContent(entry: PiLine): string { + const message = this.messageRecord(entry); + const candidates = [ + entry.content, + entry.text, + message?.content, + message?.text, + message?.message, + entry.message, + entry.payload?.content, + entry.payload?.text, + entry.payload?.message, + entry.data?.content, + entry.data?.text, + entry.data?.message, + ]; + + for (const candidate of candidates) { + const text = this.contentToString(candidate); + if (text) return text; + } + return ''; + } + + private contentToString(value: unknown): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map((item) => this.contentToString(item)).filter(Boolean).join(''); + } + if (!value || typeof value !== 'object') return ''; + + const record = value as Record; + return this.contentToString(record.content ?? record.text ?? record.value); + } + + private asRecord(value: unknown): PiRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as PiRecord; + } + + private messageRecord(entry: PiLine): PiRecord | null { + return this.asRecord(entry.message); + } + + private roleLikeType(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.toLowerCase(); + if (['user', 'human', 'assistant', 'ai', 'pi', 'system'].includes(normalized)) { + return value; + } + return undefined; + } + + private entryTimestamp(entry: PiLine): string | undefined { + return this.firstString( + entry.timestamp, + entry.payload?.timestamp, + entry.data?.timestamp, + entry.createdAt, + entry.created_at, + ); + } + + private sessionIdFromEntries(entries: PiLine[]): string | null { + for (const entry of entries) { + const sessionId = this.firstString( + entry.sessionId, + entry.session_id, + entry.id, + entry.payload?.sessionId, + entry.payload?.session_id, + entry.payload?.id, + entry.data?.sessionId, + entry.data?.session_id, + entry.data?.id, + ); + if (sessionId) return sessionId; + } + return null; + } + + private cwdFromEntries(entries: PiLine[]): string { + for (const entry of entries) { + const cwd = this.firstString( + entry.cwd, + entry.projectPath, + entry.project_path, + entry.payload?.cwd, + entry.payload?.projectPath, + entry.payload?.project_path, + entry.data?.cwd, + entry.data?.projectPath, + entry.data?.project_path, + ); + if (cwd) return cwd; + } + return ''; + } + + private sessionIdFromFile(filePath: string): string { + const base = path.basename(filePath, '.jsonl'); + const underscore = base.lastIndexOf('_'); + return underscore >= 0 ? base.slice(underscore + 1) : base; + } + + private encodeProjectDir(cwd: string): string { + const normalized = path.resolve(cwd); + return `--${normalized.replace(/^\//, '').replace(/\//g, '-')}--`; + } + + private firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string' && value) return value; + } + return undefined; + } + + private parseTimestamp(value?: string): Date | null { + if (!value) return null; + const timestamp = new Date(value); + return Number.isNaN(timestamp.getTime()) ? null : timestamp; + } + + private mapSessionToAgent(session: PiSession, processInfo: ProcessInfo, filePath: string): AgentInfo { + const projectPath = session.projectPath || processInfo.cwd || ''; + return { + name: generateAgentName(projectPath, processInfo.pid), + type: this.type, + status: this.determineStatus(session), + summary: session.summary || 'Pi session active', + pid: processInfo.pid, + projectPath, + sessionId: session.sessionId, + lastActive: session.lastActive, + sessionFilePath: filePath, + }; + } + + private mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo { + return { + name: generateAgentName(processInfo.cwd || '', processInfo.pid), + type: this.type, + status: AgentStatus.RUNNING, + summary: 'Pi process running', + pid: processInfo.pid, + projectPath: processInfo.cwd || '', + sessionId: `pid-${processInfo.pid}`, + lastActive: new Date(), + }; + } + + private determineStatus(session: PiSession): AgentStatus { + const diffMs = Date.now() - session.lastActive.getTime(); + const diffMinutes = diffMs / 60000; + + if (diffMinutes > PiAdapter.IDLE_THRESHOLD_MINUTES) return AgentStatus.IDLE; + if (session.lastRole === 'assistant') return AgentStatus.WAITING; + return AgentStatus.RUNNING; + } + + private truncate(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3)}...`; + } + + private isPiExecutable(command: string): boolean { + for (const token of command.trim().split(/\s+/)) { + const base = path.basename(token).toLowerCase(); + if (base === 'pi' || base === 'pi.exe' || base === 'pi.js') return true; + } + return false; + } + + getConversation(sessionFilePath: string, options?: { verbose?: boolean }): ConversationMessage[] { + const includeSystem = options?.verbose ?? false; + return this.entriesToMessages(this.readJsonl(sessionFilePath), includeSystem); + } + + private entriesToMessages(entries: PiLine[], includeSystem: boolean): ConversationMessage[] { + return entries + .map((entry) => this.entryToMessage(entry, includeSystem)) + .filter((msg): msg is ConversationMessage => msg !== null); + } + + async listSessions(opts?: ListSessionsOptions): Promise { + if (!isDirectory(this.piSessionsDir)) return []; + + const summaries: SessionSummary[] = []; + for (const filePath of this.collectJsonlFiles(this.piSessionsDir)) { + const summary = this.fileToSessionSummary(filePath); + if (!summary) continue; + if (opts?.cwd !== undefined && summary.cwd !== opts.cwd) continue; + summaries.push(summary); + } + return summaries; + } + + private fileToSessionSummary(filePath: string): SessionSummary | null { + const entries = this.readJsonl(filePath); + if (entries.length === 0) return null; + + const session = this.sessionFromEntries(entries, filePath); + const firstUserMessage = this.entriesToMessages(entries, false) + .find((msg) => msg.role === 'user')?.content ?? ''; + return { + type: this.type, + sessionId: session.sessionId, + cwd: session.projectPath, + firstUserMessage, + lastActive: session.lastActive, + startedAt: session.sessionStart, + sessionFilePath: filePath, + }; + } +} diff --git a/packages/agent-manager/src/adapters/index.ts b/packages/agent-manager/src/adapters/index.ts index 18362a33..384f6111 100644 --- a/packages/agent-manager/src/adapters/index.ts +++ b/packages/agent-manager/src/adapters/index.ts @@ -3,5 +3,6 @@ export { CodexAdapter } from './CodexAdapter.js'; export { CopilotAdapter } from './CopilotAdapter.js'; export { GeminiCliAdapter } from './GeminiCliAdapter.js'; export { OpenCodeAdapter } from './OpenCodeAdapter.js'; +export { PiAdapter } from './PiAdapter.js'; export { AgentStatus } from './AgentAdapter.js'; export type { AgentAdapter, AgentType, AgentInfo, ProcessInfo } from './AgentAdapter.js'; diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index b5c5f87d..6379144f 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -5,6 +5,7 @@ export { CodexAdapter } from './adapters/CodexAdapter.js'; export { CopilotAdapter } from './adapters/CopilotAdapter.js'; export { GeminiCliAdapter } from './adapters/GeminiCliAdapter.js'; export { OpenCodeAdapter } from './adapters/OpenCodeAdapter.js'; +export { PiAdapter } from './adapters/PiAdapter.js'; export { AgentStatus } from './adapters/AgentAdapter.js'; export type { AgentAdapter, diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index eeafa31e..33fd4216 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -71,6 +71,7 @@ vi.mock('@ai-devkit/agent-manager', () => ({ CopilotAdapter: vi.fn(), GeminiCliAdapter: vi.fn(), OpenCodeAdapter: vi.fn(), + PiAdapter: vi.fn(), TerminalFocusManager: vi.fn(function () { return mockFocusManager; }), TtyWriter: { send: (location: any, message: string) => mockTtyWriterSend(location, message) }, AgentStatus: { @@ -178,6 +179,7 @@ describe('agent command', () => { await program.parseAsync(['node', 'test', 'agent', 'list', '--json']); expect(AgentManager).toHaveBeenCalled(); + expect(mockManager.registerAdapter).toHaveBeenCalledTimes(6); expect(logSpy).toHaveBeenCalledWith(JSON.stringify(agents, null, 2)); }); @@ -233,7 +235,8 @@ describe('agent command', () => { { name: 'a', type: 'claude', status: AgentStatus.RUNNING, summary: '', lastActive: new Date('2026-02-26T10:00:00.000Z'), pid: 1 }, { name: 'b', type: 'codex', status: AgentStatus.RUNNING, summary: '', lastActive: new Date('2026-02-26T10:00:00.000Z'), pid: 2 }, { name: 'c', type: 'gemini_cli', status: AgentStatus.RUNNING, summary: '', lastActive: new Date('2026-02-26T10:00:00.000Z'), pid: 3 }, - { name: 'd', type: 'other', status: AgentStatus.RUNNING, summary: '', lastActive: new Date('2026-02-26T10:00:00.000Z'), pid: 4 }, + { name: 'd', type: 'pi', status: AgentStatus.RUNNING, summary: '', lastActive: new Date('2026-02-26T10:00:00.000Z'), pid: 4 }, + { name: 'e', type: 'other', status: AgentStatus.RUNNING, summary: '', lastActive: new Date('2026-02-26T10:00:00.000Z'), pid: 5 }, ]); const program = new Command(); @@ -244,7 +247,8 @@ describe('agent command', () => { expect(tableArg.rows[0][2]).toBe('Claude Code'); expect(tableArg.rows[1][2]).toBe('Codex'); expect(tableArg.rows[2][2]).toBe('Gemini CLI'); - expect(tableArg.rows[3][2]).toBe('Other'); + expect(tableArg.rows[3][2]).toBe('Pi'); + expect(tableArg.rows[4][2]).toBe('Other'); }); it('truncates working-on text to first line', async () => { diff --git a/packages/cli/src/__tests__/commands/channel.test.ts b/packages/cli/src/__tests__/commands/channel.test.ts index 729ae5fc..0cdba724 100644 --- a/packages/cli/src/__tests__/commands/channel.test.ts +++ b/packages/cli/src/__tests__/commands/channel.test.ts @@ -70,6 +70,7 @@ vi.mock('@ai-devkit/agent-manager', () => ({ CodexAdapter: vi.fn(), CopilotAdapter: vi.fn(), GeminiCliAdapter: vi.fn(), + PiAdapter: vi.fn(), TerminalFocusManager: vi.fn(function () { return mockTerminalFocusManager; }), TtyWriter: { send: vi.fn(), @@ -594,6 +595,7 @@ describe('channel command', () => { agentPid: 4321, bridgePid: process.pid, })); + expect(mockAgentManager.registerAdapter).toHaveBeenCalledTimes(5); expect(mockChannelService.registerBridge.mock.invocationCallOrder[0]) .toBeLessThan(mockChannelManager.startAll.mock.invocationCallOrder[0]); diff --git a/packages/cli/src/__tests__/util/sessions.test.ts b/packages/cli/src/__tests__/util/sessions.test.ts index fdd4dd4b..b548f12c 100644 --- a/packages/cli/src/__tests__/util/sessions.test.ts +++ b/packages/cli/src/__tests__/util/sessions.test.ts @@ -45,7 +45,7 @@ describe('sessions util', () => { }); it('forwards a valid --type', () => { - for (const type of ['claude', 'codex', 'gemini_cli', 'opencode', 'copilot'] as const) { + for (const type of ['claude', 'codex', 'gemini_cli', 'opencode', 'copilot', 'pi'] as const) { const result = resolveListSessionsOptions({ all: true, type }); expect(result.adapterOptions.type).toBe(type); } @@ -53,7 +53,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, opencode, copilot.', + 'Invalid --type "wrong". Expected one of: claude, codex, gemini_cli, opencode, copilot, pi.', ); }); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index d7ca1b02..25fc2508 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -13,6 +13,7 @@ import { CopilotAdapter, GeminiCliAdapter, OpenCodeAdapter, + PiAdapter, AgentStatus, TerminalFocusManager, TtyWriter, @@ -85,6 +86,7 @@ const TYPE_LABELS: Record = { copilot: 'Copilot', gemini_cli: 'Gemini CLI', opencode: 'OpenCode', + pi: 'Pi', other: 'Other', }; @@ -166,6 +168,7 @@ function createAgentManager(): AgentManager { manager.registerAdapter(new CopilotAdapter()); manager.registerAdapter(new GeminiCliAdapter()); manager.registerAdapter(new OpenCodeAdapter()); + manager.registerAdapter(new PiAdapter()); return manager; } @@ -390,7 +393,7 @@ export function registerAgentCommand(program: Command): void { .description('List historical Claude/Codex/Gemini/OpenCode sessions for resume') .option('--all', 'Include sessions from every cwd (default: only current cwd)') .option('--cwd ', 'Override the cwd filter (implies non-default scope)') - .option('--type ', 'Filter to one of: claude, codex, gemini_cli, opencode') + .option('--type ', 'Filter to one of: claude, codex, gemini_cli, opencode, copilot, pi') .option('--limit ', 'Max rows to print (default: 50; 0 = no limit)', '50') .option('-j, --json', 'Output as JSON') .action(withErrorHandler('list sessions', async (options) => { @@ -444,7 +447,7 @@ export function registerAgentCommand(program: Command): void { .description('Show detailed information about a historical session') .requiredOption('--id ', 'Session ID (as shown in agent sessions)') .option('-j, --json', 'Output as JSON') - .option('--type ', 'Filter to one of: claude, codex, gemini_cli, opencode') + .option('--type ', 'Filter to one of: claude, codex, gemini_cli, opencode, copilot, pi') .option('--full', 'Show entire conversation history') .option('--tail ', 'Show last N messages (default: 20)', '20') .option('--verbose', 'Include tool call/result details') diff --git a/packages/cli/src/services/channel/channel-runner.ts b/packages/cli/src/services/channel/channel-runner.ts index fa21ff1a..4bd288b9 100644 --- a/packages/cli/src/services/channel/channel-runner.ts +++ b/packages/cli/src/services/channel/channel-runner.ts @@ -4,6 +4,7 @@ import { CodexAdapter, CopilotAdapter, GeminiCliAdapter, + PiAdapter, TerminalFocusManager, TtyWriter, type AgentAdapter, @@ -39,6 +40,7 @@ function createAgentManager(): AgentManager { manager.registerAdapter(new CodexAdapter()); manager.registerAdapter(new CopilotAdapter()); manager.registerAdapter(new GeminiCliAdapter()); + manager.registerAdapter(new PiAdapter()); return manager; } diff --git a/packages/cli/src/util/sessions.ts b/packages/cli/src/util/sessions.ts index f7e8dc16..2f0fb00a 100644 --- a/packages/cli/src/util/sessions.ts +++ b/packages/cli/src/util/sessions.ts @@ -7,7 +7,7 @@ import { truncate } from './text.js'; const FIRST_MESSAGE_MAX_WIDTH = 80; const FIRST_MESSAGE_PLACEHOLDER = '(no message yet)'; -const VALID_AGENT_TYPES: AgentType[] = ['claude', 'codex', 'gemini_cli', 'opencode', 'copilot']; +const VALID_AGENT_TYPES: AgentType[] = ['claude', 'codex', 'gemini_cli', 'opencode', 'copilot', 'pi']; export interface ResolvedListSessionsOptions { adapterOptions: ListSessionsOptions;