diff --git a/README.md b/README.md index 344ccc0..b2c5b9a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

showagent — every AI coding session on your machine, in one TUI.
Browse, search, resume, branch — and convert a conversation from one agent to another.
-Codex · Claude Code · Gemini CLI · OpenCode · jcode

+Codex · Claude Code · Gemini CLI · OpenCode · jcode · Pi

CI @@ -29,7 +29,8 @@ branch + convert across agents. - **One list for everything** — sessions from every agent, grouped by workspace, fuzzy-searchable, newest first. - **Resume or branch anywhere** — reopen a session in its own CLI, or fork a - local copy to try a different direction. + local native-format copy of its transferable conversation to try a different + direction. - **Convert between agents** — rewrite a session into another agent's native format so that agent's own resume just works. Originals are never modified; conversions are written atomically. @@ -45,6 +46,7 @@ branch + convert across agents. | Gemini CLI | `gemini` | `~/.gemini/tmp//chats/` | `GEMINI_CLI_HOME` | ✅ | ✅ | | OpenCode | `opencode` | `opencode.db`, via the `opencode` CLI | `OPENCODE_DATA_HOME` | ✅ | ✅ | | jcode | `jcode` | `~/.jcode/sessions/*.json` | `JCODE_HOME` | ✅ | ✅ | +| Pi | `pi` | `~/.pi/agent/sessions/**/*.jsonl` | `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR` | ✅ | ✅ | Notes: @@ -58,6 +60,15 @@ Notes: its CLI because imports go through OpenCode itself. - jcode is a niche, experimental agent CLI. Its support is auto-hidden: if no `jcode` binary is on `PATH`, showagent never shows it. +- Pi sessions are versioned JSONL trees. showagent follows Pi's active leaf + through `parentId` links, so abandoned branches are not previewed or moved + into another agent. Converted sessions use Pi's native v3 format, verified + against `@earendil-works/pi-coding-agent` 0.80.6 source plus its export and + RPC session loader. +- A project-local Pi `sessionDir` is visible when showagent is launched from + that project. Like Pi itself, showagent cannot discover arbitrary custom + session roots belonging to other projects unless one is selected globally + with `PI_CODING_AGENT_SESSION_DIR`. - Platforms: Linux and macOS (amd64 + arm64). Windows (amd64) builds are released but **experimental**: resume runs the agent as a child process instead of replacing showagent. @@ -110,7 +121,7 @@ showagent --help # full CLI help | `t` | Cycle the convert scope: all turns, or latest 200/100/50/20/10 | | `x` | Preview convert; press `x` again to write and select the new session | | `n` | Branch: create a full local copy of the session | -| `y` | Toggle yolo resume (skip the agent's permission prompts) | +| `y` | Toggle the provider's yolo resume mode (jcode/Pi add no flag) | | `C` | Compound: resume with a learnings-capture prompt (see below) | | `d`, `del`, `backspace` | Delete the session — second press confirms, moving disarms | | `r` | Rescan session stores (keeps cursor, search, and filters) | @@ -225,8 +236,9 @@ that did not create the session converts it first, so it has full context. `showagent setup` installs the companion [compound-engineering plugin](https://github.com/EveryInc/compound-engineering-plugin) -into the Codex and Claude Code CLIs found on the machine. It is idempotent and -only installs what is missing. +into the Codex, Claude Code, and Pi CLIs found on the machine. For Pi it also +installs the `pi-subagents` and `pi-ask-user` companion packages. The command +is idempotent and only installs what is missing. ## FAQ @@ -237,7 +249,7 @@ provider, so MCP transcripts redact common secrets by default; keep that boundary in mind before registering the server. showagent's own HTTP client is used only by the optional release updater and startup update check (disable with `SHOWAGENT_NO_UPDATE_CHECK=1`). `showagent setup` invokes the installed -Codex/Claude CLIs, which may download the requested plugin. Message previews +Codex/Claude/Pi CLIs, which may download the requested plugin. Message previews also redact password-like strings and API keys before rendering (covered by tests in [`internal/session/session_test.go`](internal/session/session_test.go)). Release archives ship with a `SHA256SUMS` file, and releases after v0.7.0 @@ -257,15 +269,16 @@ second `x` writes it. In scripts, use `showagent convert ... --dry-run` for the same preview. Conversion intentionally does **not** copy tool-call internals, approval history, encrypted reasoning blobs, or provider attachments: those are private to the source agent and would not replay -correctly anyway. `t` / `--scope` trims the scope to the latest N turns before -converting. +correctly anyway. Branching uses the same safe user/assistant-turn projection; +it does not byte-clone provider-private runtime metadata. `t` / `--scope` trims +the scope to the latest N turns before converting. **What does delete actually do?** Codex sessions are deleted through `codex delete --force`; OpenCode through `opencode session delete` (which cascades inside its database). Claude Code removes the JSONL plus its matching index entry, jcode removes the JSON plus -backup/journal sidecars, and Gemini removes its session file. Delete always -takes two presses, and moving the cursor disarms it. +backup/journal sidecars, and Gemini and Pi remove their session files. Delete +always takes two presses, and moving the cursor disarms it. **Windows?** Binaries are released and the whole TUI works, but resume semantics are diff --git a/cmd/showagent/main.go b/cmd/showagent/main.go index 767086c..1d0af3f 100644 --- a/cmd/showagent/main.go +++ b/cmd/showagent/main.go @@ -169,7 +169,7 @@ func printNoSessions(stderr io.Writer) int { } _, _ = fmt.Fprintln(stderr, line) } - _, _ = fmt.Fprintln(stderr, "start a conversation with a supported agent (codex, claude, gemini, opencode, jcode), then run showagent again") + _, _ = fmt.Fprintln(stderr, "start a conversation with a supported agent (codex, claude, gemini, opencode, jcode, pi), then run showagent again") return 1 } @@ -430,7 +430,7 @@ func usageError(stderr io.Writer, message string) int { } func printHelp(w io.Writer) { - _, _ = fmt.Fprintf(w, `showagent — browse, resume, branch, and hand off local Codex, Claude Code, Gemini CLI, OpenCode, and jcode sessions. + _, _ = fmt.Fprintf(w, `showagent — browse, resume, branch, and hand off local Codex, Claude Code, Gemini CLI, OpenCode, jcode, and Pi sessions. Usage: showagent open the interactive session picker @@ -451,7 +451,8 @@ Flags: -h, --help show this help -v, --version print version --json (list) emit a JSON array of sessions - --yolo (resume) skip the agent's permission prompts + --yolo (resume) request the provider's permission bypass + (jcode and Pi add no extra flag) --to (convert) target provider: %s --scope (convert) all, or last:N / last-N --dry-run (convert) preview without writing anything @@ -471,6 +472,8 @@ Session locations: opencode ~/.local/share/opencode (override with OPENCODE_DATA_HOME; read via the opencode CLI) gemini ~/.gemini/tmp (override with GEMINI_CLI_HOME) + pi ~/.pi/agent/sessions (override with PI_CODING_AGENT_DIR or + PI_CODING_AGENT_SESSION_DIR) When stdout is not a terminal, 'showagent' prints the plain table (same as 'showagent list'). `, strings.Join(session.ProviderNames(), ", ")) diff --git a/cmd/showagent/main_test.go b/cmd/showagent/main_test.go index 4455e06..6028e07 100644 --- a/cmd/showagent/main_test.go +++ b/cmd/showagent/main_test.go @@ -31,6 +31,8 @@ func setFixtureHomes(t *testing.T) { t.Setenv("JCODE_HOME", filepath.Join(root, "empty-jcode")) t.Setenv("OPENCODE_DATA_HOME", filepath.Join(root, "empty-opencode")) t.Setenv("GEMINI_CLI_HOME", filepath.Join(root, "empty-gemini")) + t.Setenv("PI_CODING_AGENT_DIR", filepath.Join(root, "empty-pi")) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") writeFixture(t, filepath.Join(codexHome, "sessions", "2026", "06", "01", "rollout-2026-06-01T12-00-00-"+codexID+".jsonl"), ` {"timestamp":"2026-06-01T09:00:00Z","type":"session_meta","payload":{"id":"`+codexID+`","cwd":"/work/codex"}} @@ -67,7 +69,7 @@ func TestHelpFlag(t *testing.T) { if code != 0 { t.Fatalf("%s exit = %d, want 0", flag, code) } - for _, want := range []string{"Usage:", "list", "resume", "convert", "info", "mcp", "update", "setup", "CODEX_HOME", "CLAUDE_HOME", "JCODE_HOME", "--yolo", "--json", "--dry-run", "--read-only", "--allow-secrets"} { + for _, want := range []string{"Usage:", "list", "resume", "convert", "info", "mcp", "update", "setup", "CODEX_HOME", "CLAUDE_HOME", "JCODE_HOME", "PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR", "--yolo", "--json", "--dry-run", "--read-only", "--allow-secrets"} { if !strings.Contains(stdout, want) { t.Fatalf("%s output missing %q:\n%s", flag, want, stdout) } @@ -210,6 +212,8 @@ func TestListJSONEmptyIsValidArray(t *testing.T) { t.Setenv("JCODE_HOME", filepath.Join(root, "empty-jcode")) t.Setenv("OPENCODE_DATA_HOME", filepath.Join(root, "empty-opencode")) t.Setenv("GEMINI_CLI_HOME", filepath.Join(root, "empty-gemini")) + t.Setenv("PI_CODING_AGENT_DIR", filepath.Join(root, "empty-pi")) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") code, stdout, _ := runCLI(t, "list", "--json") if code != 0 { @@ -528,6 +532,8 @@ func TestListEmptyExplainsScannedDirs(t *testing.T) { t.Setenv("JCODE_HOME", filepath.Join(root, "empty-jcode")) t.Setenv("OPENCODE_DATA_HOME", filepath.Join(root, "empty-opencode")) t.Setenv("GEMINI_CLI_HOME", filepath.Join(root, "empty-gemini")) + t.Setenv("PI_CODING_AGENT_DIR", filepath.Join(root, "empty-pi")) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") code, _, stderr := runCLI(t, "list") if code != 1 { @@ -537,7 +543,7 @@ func TestListEmptyExplainsScannedDirs(t *testing.T) { "no supported local sessions found", filepath.Join(root, "empty-codex", "sessions"), filepath.Join(root, "empty-claude", "projects"), - "CODEX_HOME", "CLAUDE_HOME", "JCODE_HOME", + "CODEX_HOME", "CLAUDE_HOME", "JCODE_HOME", "PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR", "start a conversation with a supported agent", } { if !strings.Contains(stderr, want) { diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index da52324..73c581d 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -66,7 +66,7 @@ func New(version string, options ...Options) *mcp.Server { mcp.AddTool(server, &mcp.Tool{ Name: "list_sessions", - Description: "List local AI coding-agent sessions (Codex, Claude Code, Gemini CLI, OpenCode, jcode) newest first. " + + Description: "List local AI coding-agent sessions (Codex, Claude Code, Gemini CLI, OpenCode, jcode, Pi) newest first. " + "Filter by provider, workspace path substring, or free-text query over workspace and first/last user message. " + "Use this to find past work on a topic before pulling a session in with get_transcript or convert_session.", Annotations: readOnly, @@ -89,7 +89,7 @@ func New(version string, options ...Options) *mcp.Server { if !opts.ReadOnly { mcp.AddTool(server, &mcp.Tool{ Name: "branch_session", - Description: "Fork a session: write a full local copy as a new session of the same agent, leaving the original untouched. " + + Description: "Fork a session: write its transferable user/assistant turns as a new native session of the same agent, leaving the original untouched. " + "Returns the new session id, its file, and the exact shell command that resumes the copy (never executed by this server).", Annotations: additive, }, branchSession) @@ -127,7 +127,7 @@ func Run(ctx context.Context, version string, options ...Options) error { } type listSessionsArgs struct { - Provider string `json:"provider,omitempty" jsonschema:"only return sessions of this agent; one of the provider ids reported in results (codex, claude, gemini, opencode, jcode)"` + Provider string `json:"provider,omitempty" jsonschema:"only return sessions of this agent; one of the provider ids reported in results (codex, claude, gemini, opencode, jcode, pi)"` Workspace string `json:"workspace,omitempty" jsonschema:"case-insensitive substring the session's workspace path must contain"` Query string `json:"query,omitempty" jsonschema:"case-insensitive free text matched against the workspace path and the first/last user message"` Limit int `json:"limit,omitempty" jsonschema:"maximum sessions to return, newest first (default 25, max 100)"` @@ -305,7 +305,7 @@ func branchSession(_ context.Context, _ *mcp.CallToolRequest, args sessionIDArgs type convertSessionArgs struct { ID string `json:"id" jsonschema:"session id from list_sessions, or 'latest' for the most recent session"` - Target string `json:"target" jsonschema:"provider to convert the session to: codex, claude, gemini, opencode, or jcode"` + Target string `json:"target" jsonschema:"provider to convert the session to: codex, claude, gemini, opencode, jcode, or pi"` } func convertSession(_ context.Context, _ *mcp.CallToolRequest, args convertSessionArgs) (*mcp.CallToolResult, newSessionResult, error) { diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index dd99edb..d18bc06 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -33,6 +33,8 @@ func setFixtureHomes(t *testing.T) (codexHome, claudeHome string) { t.Setenv("JCODE_HOME", filepath.Join(root, "empty-jcode")) t.Setenv("OPENCODE_DATA_HOME", filepath.Join(root, "empty-opencode")) t.Setenv("GEMINI_CLI_HOME", filepath.Join(root, "empty-gemini")) + t.Setenv("PI_CODING_AGENT_DIR", filepath.Join(root, "empty-pi")) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") writeFile(t, filepath.Join(codexHome, "sessions", "2026", "06", "01", "rollout-2026-06-01T12-00-00-"+codexRateLimitID+".jsonl"), ` {"timestamp":"2026-06-01T09:00:00Z","type":"session_meta","payload":{"id":"`+codexRateLimitID+`","cwd":"/work/api-server"}} @@ -431,6 +433,29 @@ func TestNoDeleteToolExposed(t *testing.T) { } } +func TestProviderToolSchemasAdvertiseRegistry(t *testing.T) { + setFixtureHomes(t) + cs := newTestSession(t) + tools, err := cs.ListTools(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + for _, tool := range tools.Tools { + if tool.Name != "list_sessions" && tool.Name != "convert_session" { + continue + } + data, err := json.Marshal(tool) + if err != nil { + t.Fatal(err) + } + for _, provider := range session.ProviderNames() { + if !strings.Contains(string(data), provider) { + t.Fatalf("%s schema does not advertise provider %q: %s", tool.Name, provider, data) + } + } + } +} + func TestReadOnlyServerOmitsWriteTools(t *testing.T) { setFixtureHomes(t) cs := newTestSession(t, Options{ReadOnly: true}) diff --git a/internal/session/compound.go b/internal/session/compound.go index 9a0fb4c..a9f1794 100644 --- a/internal/session/compound.go +++ b/internal/session/compound.go @@ -14,9 +14,8 @@ import ( // directory. If the chosen agent differs from the session's own provider, the // session is first converted to that provider so the agent has full context. // -// The shared learnings directory is the common knowledge pool: both Codex and -// Claude read it before working and append to it when done, so what either tool -// learns compounds for the other. +// The shared learnings directory is the common knowledge pool: every supported +// compound-capable agent reads it before working and appends to it when done. func Compound(row Row, agent Provider, options ResumeOptions) error { dir, err := ensureLearningsDir(row.CWD) if err != nil { @@ -31,7 +30,7 @@ func Compound(row Row, agent Provider, options ResumeOptions) error { } target = converted } - return launch(target.resumeCWD(), target.CompoundCommand(options, compoundPrompt(dir))) + return launch(target.resumeCWD(), target.CompoundCommand(options, compoundPrompt(dir, agent))) } // CompoundCommand is the resume command for the session with an initial prompt @@ -55,8 +54,8 @@ func learningsBaseDir() string { // ProjectLearningsDir reports the per-project, cross-tool learnings directory // for a workspace, without creating it. Each project gets its own subdirectory -// so learnings never bleed between projects, while Codex and Claude share the -// same one within a project. +// so learnings never bleed between projects, while all supported agents share +// the same one within a project. func ProjectLearningsDir(cwd string) string { key, ok := projectLearningsKey(cwd) if !ok { @@ -137,11 +136,11 @@ func projectLearningsKey(cwd string) (string, bool) { return fmt.Sprintf("%s-%x", label, hash[:6]), true } -func compoundPrompt(dir string) string { +func compoundPrompt(dir string, agent Provider) string { return strings.Join([]string{ "Run a compound-engineering pass on the work from this session.", "", - "This project's shared learnings directory (used by BOTH Codex and Claude for THIS project) is the path inside the fence below:", + "This project's shared learnings directory (used by every supported coding agent for THIS project) is the path inside the fence below:", "```", dir, "```", @@ -150,9 +149,9 @@ func compoundPrompt(dir string) string { "Steps:", "1. Read the existing notes in that directory (the *.md files) so you build on prior learnings and avoid duplicating them.", "2. Identify the durable, reusable learnings from this session: the problem, the root cause, the fix, key decisions, and any gotchas or patterns worth keeping.", - "3. Write a concise markdown note into that directory named -.md with short frontmatter (title, date, tags, tool: codex or claude) followed by the learning. If a closely related note already exists, update it instead of duplicating it.", + fmt.Sprintf("3. Write a concise markdown note into that directory named -.md with short frontmatter (title, date, tags, tool: %s) followed by the learning. If a closely related note already exists, update it instead of duplicating it.", agent), "4. Keep it factual and reusable, and never write secrets or tokens.", "", - "This follows the compound-engineering method: each documented solution compounds the shared knowledge, so the next time either tool hits the same thing it is minutes, not hours.", + "This follows the compound-engineering method: each documented solution compounds the shared knowledge, so the next supported agent to hit the same thing needs minutes, not hours.", }, "\n") } diff --git a/internal/session/compound_plugin.go b/internal/session/compound_plugin.go index ad76c71..6953e9a 100644 --- a/internal/session/compound_plugin.go +++ b/internal/session/compound_plugin.go @@ -14,9 +14,13 @@ const ( compoundPluginMarketplace = "compound-engineering-plugin" compoundPluginMarketplaceSource = "EveryInc/compound-engineering-plugin" compoundPluginSelector = "compound-engineering@compound-engineering-plugin" + piCompoundPluginSource = "git:github.com/EveryInc/compound-engineering-plugin" + piSubagentsSource = "npm:pi-subagents" + piAskUserSource = "npm:pi-ask-user" ) var compoundPluginCommandTimeout = 30 * time.Second +var compoundPluginInstallTimeout = 2 * time.Minute type CompoundPluginSetupResult struct { Provider Provider @@ -41,24 +45,24 @@ func (r CompoundPluginSetupResult) Status() string { } // EnsureCompoundEngineeringPlugin installs EveryInc's Compound Engineering -// plugin for the local Codex and Claude CLIs when those CLIs are available and -// the plugin is not already installed. +// plugin for the local Codex, Claude, and Pi CLIs when those CLIs are available +// and the plugin is not already installed. func EnsureCompoundEngineeringPlugin() ([]CompoundPluginSetupResult, error) { - results := make([]CompoundPluginSetupResult, 0, 2) - - codex, err := ensureCodexCompoundPlugin() - if err != nil { - return append(results, codex), err - } - results = append(results, codex) - - claude, err := ensureClaudeCompoundPlugin() - if err != nil { - return append(results, claude), err + results := make([]CompoundPluginSetupResult, 0, 3) + setups := []func() (CompoundPluginSetupResult, error){ + ensureCodexCompoundPlugin, + ensureClaudeCompoundPlugin, + ensurePiCompoundPlugin, + } + var setupErrors []error + for _, setup := range setups { + result, err := setup() + results = append(results, result) + if err != nil { + setupErrors = append(setupErrors, err) + } } - results = append(results, claude) - - return results, nil + return results, errors.Join(setupErrors...) } func ensureCodexCompoundPlugin() (CompoundPluginSetupResult, error) { @@ -129,6 +133,37 @@ func ensureClaudeCompoundPlugin() (CompoundPluginSetupResult, error) { return result, nil } +func ensurePiCompoundPlugin() (CompoundPluginSetupResult, error) { + result := CompoundPluginSetupResult{Provider: ProviderPi, Command: "pi"} + if !commandAvailable("pi") { + return result, nil + } + result.Available = true + + list, err := runOutput("pi", "list", "--no-approve") + if err != nil { + return result, err + } + sources := []string{piCompoundPluginSource, piSubagentsSource, piAskUserSource} + var missing []string + for _, source := range sources { + if !strings.Contains(list, source) { + missing = append(missing, source) + } + } + if len(missing) == 0 { + result.AlreadyInstalled = true + return result, nil + } + for _, source := range missing { + if _, err := runOutputWithTimeout(compoundPluginInstallTimeout, "pi", "install", source); err != nil { + return result, err + } + } + result.Installed = true + return result, nil +} + func commandAvailable(name string) bool { _, err := exec.LookPath(name) return err == nil @@ -139,7 +174,11 @@ func pluginListContainsCompoundEngineering(output string) bool { } func runOutput(name string, args ...string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), compoundPluginCommandTimeout) + return runOutputWithTimeout(compoundPluginCommandTimeout, name, args...) +} + +func runOutputWithTimeout(timeout time.Duration, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() command := exec.CommandContext(ctx, name, args...) var stdout bytes.Buffer @@ -148,7 +187,7 @@ func runOutput(name string, args ...string) (string, error) { command.Stderr = &stderr if err := command.Run(); err != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) { - return "", fmt.Errorf("%s %s timed out after %s", name, strings.Join(args, " "), compoundPluginCommandTimeout) + return "", fmt.Errorf("%s %s timed out after %s", name, strings.Join(args, " "), timeout) } detail := strings.TrimSpace(stderr.String()) if detail == "" { diff --git a/internal/session/compound_plugin_test.go b/internal/session/compound_plugin_test.go index b3304af..9975f73 100644 --- a/internal/session/compound_plugin_test.go +++ b/internal/session/compound_plugin_test.go @@ -13,12 +13,14 @@ func TestEnsureCompoundEngineeringPluginInstallsMissingPlugins(t *testing.T) { calls := filepath.Join(bin, "calls.log") writeFakePluginCLI(t, bin, "codex", calls) writeFakePluginCLI(t, bin, "claude", calls) + writeFakePiCLI(t, bin) t.Setenv("PATH", bin) t.Setenv("CALLS_FILE", calls) t.Setenv("CODEX_PLUGIN_LIST", "github@openai-curated installed, enabled") t.Setenv("CODEX_MARKETPLACE_LIST", "MARKETPLACE ROOT\nopenai-curated /tmp/openai") t.Setenv("CLAUDE_PLUGIN_LIST", "Installed plugins:\ncontext7@claude-plugins-official\nStatus: ✔ enabled") t.Setenv("CLAUDE_MARKETPLACE_LIST", "Configured marketplaces:\nclaude-plugins-official") + t.Setenv("PI_PACKAGE_LIST", "User packages:") results, err := EnsureCompoundEngineeringPlugin() if err != nil { @@ -26,6 +28,7 @@ func TestEnsureCompoundEngineeringPluginInstallsMissingPlugins(t *testing.T) { } assertSetupResult(t, results, ProviderCodex, true, true, true, false) assertSetupResult(t, results, ProviderClaude, true, true, true, false) + assertSetupResult(t, results, ProviderPi, true, true, false, false) log := readCalls(t, calls) for _, want := range []string{ @@ -33,6 +36,9 @@ func TestEnsureCompoundEngineeringPluginInstallsMissingPlugins(t *testing.T) { "codex plugin add compound-engineering@compound-engineering-plugin", "claude plugin marketplace add EveryInc/compound-engineering-plugin", "claude plugin install compound-engineering@compound-engineering-plugin --scope user", + "pi install git:github.com/EveryInc/compound-engineering-plugin", + "pi install npm:pi-subagents", + "pi install npm:pi-ask-user", } { if !strings.Contains(log, want) { t.Fatalf("calls log missing %q:\n%s", want, log) @@ -45,10 +51,12 @@ func TestEnsureCompoundEngineeringPluginSkipsAlreadyInstalledPlugins(t *testing. calls := filepath.Join(bin, "calls.log") writeFakePluginCLI(t, bin, "codex", calls) writeFakePluginCLI(t, bin, "claude", calls) + writeFakePiCLI(t, bin) t.Setenv("PATH", bin) t.Setenv("CALLS_FILE", calls) t.Setenv("CODEX_PLUGIN_LIST", "compound-engineering@compound-engineering-plugin installed, enabled 3.13.1") t.Setenv("CLAUDE_PLUGIN_LIST", "compound-engineering@compound-engineering-plugin\nStatus: ✔ enabled") + t.Setenv("PI_PACKAGE_LIST", "git:github.com/EveryInc/compound-engineering-plugin\nnpm:pi-subagents\nnpm:pi-ask-user") results, err := EnsureCompoundEngineeringPlugin() if err != nil { @@ -56,9 +64,13 @@ func TestEnsureCompoundEngineeringPluginSkipsAlreadyInstalledPlugins(t *testing. } assertSetupResult(t, results, ProviderCodex, true, false, false, true) assertSetupResult(t, results, ProviderClaude, true, false, false, true) + assertSetupResult(t, results, ProviderPi, true, false, false, true) log := readCalls(t, calls) - for _, unwanted := range []string{"marketplace add", "plugin add compound-engineering", "plugin install compound-engineering"} { + if !strings.Contains(log, "pi list --no-approve") { + t.Fatalf("Pi package probe must ignore project-local packages:\n%s", log) + } + for _, unwanted := range []string{"marketplace add", "plugin add compound-engineering", "plugin install compound-engineering", "pi install"} { if strings.Contains(log, unwanted) { t.Fatalf("calls log unexpectedly contains %q:\n%s", unwanted, log) } @@ -74,6 +86,57 @@ func TestEnsureCompoundEngineeringPluginSkipsUnavailableCLIs(t *testing.T) { } assertSetupResult(t, results, ProviderCodex, false, false, false, false) assertSetupResult(t, results, ProviderClaude, false, false, false, false) + assertSetupResult(t, results, ProviderPi, false, false, false, false) +} + +func TestEnsureCompoundEngineeringPluginRepairsPartialPiInstall(t *testing.T) { + bin := t.TempDir() + calls := filepath.Join(bin, "calls.log") + writeFakePiCLI(t, bin) + t.Setenv("PATH", bin) + t.Setenv("CALLS_FILE", calls) + t.Setenv("PI_PACKAGE_LIST", "git:github.com/EveryInc/compound-engineering-plugin") + + results, err := EnsureCompoundEngineeringPlugin() + if err != nil { + t.Fatal(err) + } + assertSetupResult(t, results, ProviderPi, true, true, false, false) + log := readCalls(t, calls) + if strings.Contains(log, "pi install "+piCompoundPluginSource) { + t.Fatalf("already installed Pi plugin was reinstalled:\n%s", log) + } + for _, want := range []string{"pi install " + piSubagentsSource, "pi install " + piAskUserSource} { + if !strings.Contains(log, want) { + t.Fatalf("calls log missing %q:\n%s", want, log) + } + } +} + +func TestEnsureCompoundEngineeringPluginContinuesAfterProviderFailure(t *testing.T) { + bin := t.TempDir() + calls := filepath.Join(bin, "calls.log") + writeFakePiCLI(t, bin) + writeFile(t, filepath.Join(bin, "codex"), `#!/bin/sh +echo "codex $*" >> "$CALLS_FILE" +echo "codex probe failed" >&2 +exit 2 +`) + if err := os.Chmod(filepath.Join(bin, "codex"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin) + t.Setenv("CALLS_FILE", calls) + t.Setenv("PI_PACKAGE_LIST", "User packages:") + + results, err := EnsureCompoundEngineeringPlugin() + if err == nil || !strings.Contains(err.Error(), "codex probe failed") { + t.Fatalf("setup error = %v", err) + } + assertSetupResult(t, results, ProviderPi, true, true, false, false) + if log := readCalls(t, calls); !strings.Contains(log, "pi install "+piCompoundPluginSource) { + t.Fatalf("Pi setup did not continue after Codex failure:\n%s", log) + } } func TestCompoundPluginCommandTimesOut(t *testing.T) { @@ -143,6 +206,28 @@ exit 2 _ = calls } +func writeFakePiCLI(t *testing.T, dir string) { + t.Helper() + path := filepath.Join(dir, "pi") + script := `#!/bin/sh +echo "pi $*" >> "$CALLS_FILE" +case "$1" in + list) + printf '%s\n' "$PI_PACKAGE_LIST" + exit 0 + ;; + install) + exit 0 + ;; +esac +echo "unexpected command: $*" >&2 +exit 2 +` + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatal(err) + } +} + func readCalls(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) diff --git a/internal/session/e2e_real_test.go b/internal/session/e2e_real_test.go index 25064f9..3efae26 100644 --- a/internal/session/e2e_real_test.go +++ b/internal/session/e2e_real_test.go @@ -3,7 +3,7 @@ package session // Real-CLI end-to-end helpers, driven by scripts/e2e-real.sh. These run -// against the REAL agent homes (~/.claude, ~/.codex, ~/.gemini, opencode's +// against the REAL agent homes (~/.claude, ~/.codex, ~/.gemini, ~/.pi, opencode's // data dir) and only ever touch sessions whose cwd is one of the workspaces // passed in SHOWAGENT_E2E_WS_LIST. Never enabled in CI: the build tag plus // the env guard keep `go test ./...` unaffected. @@ -81,7 +81,7 @@ func TestRealCLIMutate(t *testing.T) { }) } - targets := []Provider{ProviderCodex, ProviderClaude, ProviderGemini, ProviderOpenCode, ProviderJCode} + targets := []Provider{ProviderCodex, ProviderClaude, ProviderGemini, ProviderOpenCode, ProviderJCode, ProviderPi} for _, row := range rows { branched, err := Branch(row) if err != nil { diff --git a/internal/session/opencode_test.go b/internal/session/opencode_test.go index 536ad25..b809090 100644 --- a/internal/session/opencode_test.go +++ b/internal/session/opencode_test.go @@ -64,6 +64,8 @@ func setEmptyHomes(t *testing.T, root string) { t.Setenv("CLAUDE_HOME", filepath.Join(root, "empty-claude")) t.Setenv("JCODE_HOME", filepath.Join(root, "empty-jcode")) t.Setenv("GEMINI_CLI_HOME", filepath.Join(root, "empty-gemini")) + t.Setenv("PI_CODING_AGENT_DIR", filepath.Join(root, "empty-pi")) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") } func TestDiscoverFindsOpenCodeSessions(t *testing.T) { @@ -174,7 +176,7 @@ func TestOpenCodeResumeAndCompoundCommands(t *testing.T) { func TestCompoundAgentsFollowRegistryOrder(t *testing.T) { agents := CompoundAgents() - want := []Provider{ProviderCodex, ProviderClaude, ProviderJCode, ProviderOpenCode, ProviderGemini} + want := []Provider{ProviderCodex, ProviderClaude, ProviderJCode, ProviderOpenCode, ProviderGemini, ProviderPi} if len(agents) != len(want) { t.Fatalf("agents = %v, want %v", agents, want) } diff --git a/internal/session/pi.go b/internal/session/pi.go new file mode 100644 index 0000000..a2aa1e5 --- /dev/null +++ b/internal/session/pi.go @@ -0,0 +1,500 @@ +package session + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + "time" +) + +// Pi coding agent support. +// +// Pi stores one versioned JSONL tree per session. The final entry in the file +// is the active leaf; parentId links select the branch Pi will resume. showagent +// follows that same leaf path so abandoned branches are not previewed or +// transferred to another provider. +const ProviderPi Provider = "pi" + +type piProvider struct{} + +func (piProvider) Name() Provider { return ProviderPi } +func (piProvider) DisplayName() string { return "Pi" } +func (piProvider) CommandName() string { return "pi" } +func (piProvider) Home() string { return defaultPiAgentDir() } + +func (piProvider) ScanTarget() ScanTarget { + return ScanTarget{ + Provider: ProviderPi, + Path: defaultPiSessionRoot(), + EnvVar: "PI_CODING_AGENT_SESSION_DIR or PI_CODING_AGENT_DIR", + } +} + +func (piProvider) Discover() []Row { + return discoverPi(defaultPiSessionRoot()) +} + +func (piProvider) ResumeArgs(row Row, _ ResumeOptions) []string { + selector := row.File + if selector == "" { + selector = row.ID + } + return []string{"pi", "--session", selector} +} + +func (p piProvider) CompoundArgs(row Row, options ResumeOptions, prompt string) []string { + return append(p.ResumeArgs(row, options), prompt) +} + +func (piProvider) Delete(row Row) error { + if row.File == "" { + return errors.New("pi session file is unknown") + } + if filepath.Ext(row.File) != ".jsonl" { + return fmt.Errorf("unexpected Pi session path %q", row.File) + } + return os.Remove(row.File) +} + +func (piProvider) Transcript(row Row) ([]Turn, error) { + return piTranscript(row.File) +} + +func (piProvider) WriteConverted(source Row, turns []Turn) (Row, error) { + return writePiConverted(source, turns) +} + +type piSessionHeader struct { + Type string `json:"type"` + Version int `json:"version"` + ID string `json:"id"` + Timestamp string `json:"timestamp"` + CWD string `json:"cwd"` +} + +type piSessionEntry struct { + Type string `json:"type"` + Version int `json:"version"` + ID string `json:"id"` + ParentID string `json:"parentId"` + Timestamp string `json:"timestamp"` + CWD string `json:"cwd"` + Message struct { + Role string `json:"role"` + Content any `json:"content"` + } `json:"message"` +} + +type piSessionLink struct { + Type string `json:"type"` + Version int `json:"version"` + ID string `json:"id"` + ParentID string `json:"parentId"` + Timestamp string `json:"timestamp"` + CWD string `json:"cwd"` +} + +type piSession struct { + Header piSessionHeader + Path []piSessionEntry +} + +func defaultPiAgentDir() string { + if value := os.Getenv("PI_CODING_AGENT_DIR"); value != "" { + return absoluteExpandedPath(value) + } + return filepath.Join(homeDir(), ".pi", "agent") +} + +// defaultPiSessionRoot returns the directory Pi searches globally. The +// explicit environment variable has the same precedence as Pi's CLI. A global +// settings.json sessionDir is also honored; project-local overrides cannot be +// discovered globally without already knowing every project path. +func defaultPiSessionRoot() string { + root, _ := piSessionRoot() + return root +} + +func piSessionRoot() (string, bool) { + cwd, err := os.Getwd() + if err != nil { + cwd = "." + } + return piSessionRootForCWD(cwd) +} + +func piSessionRootForCWD(cwd string) (string, bool) { + if value := os.Getenv("PI_CODING_AGENT_SESSION_DIR"); value != "" { + return absoluteExpandedPathFrom(value, cwd), true + } + + agentDir := defaultPiAgentDir() + sessionDir := readPiSessionDirSetting(filepath.Join(agentDir, "settings.json")) + if projectDir := readPiSessionDirSetting(filepath.Join(absoluteExpandedPath(cwd), ".pi", "settings.json")); projectDir != "" { + sessionDir = projectDir + } + if sessionDir != "" { + return absoluteExpandedPathFrom(sessionDir, cwd), true + } + return filepath.Join(agentDir, "sessions"), false +} + +func readPiSessionDirSetting(path string) string { + content, err := os.ReadFile(path) + if err != nil { + return "" + } + var settings struct { + SessionDir string `json:"sessionDir"` + } + if json.Unmarshal(content, &settings) != nil { + return "" + } + return strings.TrimSpace(settings.SessionDir) +} + +func absoluteExpandedPath(value string) string { + cwd, err := os.Getwd() + if err != nil { + cwd = "." + } + return absoluteExpandedPathFrom(value, cwd) +} + +func absoluteExpandedPathFrom(value, cwd string) string { + value = expandHome(strings.TrimSpace(value)) + if filepath.IsAbs(value) { + return filepath.Clean(value) + } + if absolute, err := filepath.Abs(filepath.Join(cwd, value)); err == nil { + return absolute + } + return filepath.Clean(filepath.Join(cwd, value)) +} + +func defaultPiSessionDir(cwd string) string { + root, custom := piSessionRootForCWD(cwd) + if custom { + return root + } + resolved := absoluteExpandedPath(cwd) + safePath := strings.TrimLeft(resolved, `/\`) + safePath = strings.NewReplacer("/", "-", `\`, "-", ":", "-").Replace(safePath) + return filepath.Join(root, "--"+safePath+"--") +} + +func discoverPi(root string) []Row { + return parseRowsBounded(jsonlPaths(root), parsePi) +} + +func parsePi(path string) (Row, bool) { + session, err := loadPiSession(path) + if err != nil || session.Header.ID == "" { + return Row{}, false + } + firstUser, lastUser, hasTransferableTurn := piPreview(session.Path) + if !hasTransferableTurn { + return Row{}, false + } + + lastAt, ok := piLastTimestamp(session) + if !ok { + lastAt, ok = fallbackMTime(path) + } + if !ok { + return Row{}, false + } + + cwd := strings.TrimSpace(session.Header.CWD) + if cwd == "" { + cwd = "(unknown cwd)" + } + return Row{ + Provider: ProviderPi, + ID: session.Header.ID, + LastAt: lastAt, + CWD: cwd, + LaunchCWD: cwd, + File: path, + FirstUser: firstUser, + LastUser: lastUser, + }, true +} + +func loadPiSession(path string) (piSession, error) { + file, err := os.Open(path) + if err != nil { + return piSession{}, err + } + defer func() { _ = file.Close() }() + + var result piSession + byID := map[string]piSessionLink{} + var leaf piSessionLink + headerSeen := false + invalidHeader := false + if err := scanPiLines(file, func(line []byte) { + if len(bytes.TrimSpace(line)) == 0 { + return + } + var link piSessionLink + if err := json.Unmarshal(line, &link); !headerSeen { + headerSeen = true + if err != nil || link.Type != "session" || link.ID == "" { + invalidHeader = true + return + } + result.Header = piSessionHeader{ + Type: link.Type, Version: link.Version, ID: link.ID, + Timestamp: link.Timestamp, CWD: link.CWD, + } + return + } else if err != nil || invalidHeader { + return + } + if link.Type == "session" { + return + } + if link.ID == "" { + return + } + leaf = link + byID[link.ID] = link + }); err != nil { + return piSession{}, err + } + if invalidHeader || result.Header.Type != "session" || result.Header.ID == "" { + return piSession{}, fmt.Errorf("%s is not a Pi session file", path) + } + if result.Header.Version < 2 { + _, entries, err := loadPiEntries(file, nil) + if err != nil { + return piSession{}, err + } + result.Path = entries + return result, nil + } + if leaf.ID == "" { + return result, nil + } + + current := leaf + var pathIDs []string + seen := make(map[string]bool, len(byID)) + for current.ID != "" && !seen[current.ID] { + seen[current.ID] = true + pathIDs = append(pathIDs, current.ID) + if current.ParentID == "" { + break + } + parent, ok := byID[current.ParentID] + if !ok { + break + } + current = parent + } + slices.Reverse(pathIDs) + entries, _, err := loadPiEntries(file, seen) + if err != nil { + return piSession{}, err + } + for _, id := range pathIDs { + if entry, ok := entries[id]; ok { + result.Path = append(result.Path, entry) + } + } + return result, nil +} + +func scanPiLines(file *os.File, visit func([]byte)) error { + if _, err := file.Seek(0, io.SeekStart); err != nil { + return err + } + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64*1024), scanBufferMax) + for scanner.Scan() { + visit(scanner.Bytes()) + } + return scanner.Err() +} + +func loadPiEntries(file *os.File, wanted map[string]bool) (map[string]piSessionEntry, []piSessionEntry, error) { + entries := make(map[string]piSessionEntry, len(wanted)) + var legacy []piSessionEntry + err := scanPiLines(file, func(line []byte) { + var link piSessionLink + if json.Unmarshal(line, &link) != nil || link.Type == "session" { + return + } + if wanted != nil && !wanted[link.ID] { + return + } + var entry piSessionEntry + if json.Unmarshal(line, &entry) != nil { + return + } + if wanted == nil { + legacy = append(legacy, entry) + return + } + entries[entry.ID] = entry + }) + return entries, legacy, err +} + +func piLastTimestamp(session piSession) (time.Time, bool) { + for index := len(session.Path) - 1; index >= 0; index-- { + if timestamp, ok := parseTimestamp(session.Path[index].Timestamp); ok { + return timestamp, true + } + } + return parseTimestamp(session.Header.Timestamp) +} + +func piTurns(entries []piSessionEntry) []Turn { + turns := make([]Turn, 0, len(entries)) + for _, entry := range entries { + if entry.Type != "message" { + continue + } + role := entry.Message.Role + text := cleanTranscriptText(textFromContent(entry.Message.Content)) + if !keepTranscriptTurn(role, text) { + continue + } + turns = append(turns, Turn{Role: role, Text: text}) + } + return turns +} + +func piPreview(entries []piSessionEntry) (firstUser, lastUser string, hasTransferableTurn bool) { + for _, entry := range entries { + if entry.Type != "message" { + continue + } + role := entry.Message.Role + if role == "assistant" && hasTransferableTurn { + continue + } + text := cleanTranscriptText(textFromContent(entry.Message.Content)) + if !keepTranscriptTurn(role, text) { + continue + } + hasTransferableTurn = true + if role == "user" { + preview := cleanPreviewText(text) + if preview == "" { + continue + } + if firstUser == "" { + firstUser = preview + } + lastUser = preview + } + } + return firstUser, lastUser, hasTransferableTurn +} + +func piTranscript(path string) ([]Turn, error) { + session, err := loadPiSession(path) + if err != nil { + return nil, err + } + return piTurns(session.Path), nil +} + +func writePiConverted(source Row, turns []Turn) (Row, error) { + cwd := strings.TrimSpace(source.CWD) + if cwd == "" || strings.HasPrefix(cwd, "(") { + return Row{}, errors.New("pi conversion needs a known workspace directory") + } + + sessionID, err := newUUID() + if err != nil { + return Row{}, err + } + now := time.Now().UTC() + directory := defaultPiSessionDir(cwd) + if err := os.MkdirAll(directory, 0o700); err != nil { + return Row{}, err + } + stamp := strings.NewReplacer(":", "-", ".", "-").Replace(now.Format(time.RFC3339Nano)) + path := filepath.Join(directory, stamp+"_"+sessionID+".jsonl") + + lastAt := now + if err := writeFileAtomic(path, func(file *os.File) error { + encoder := json.NewEncoder(file) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(map[string]any{ + "type": "session", + "version": 3, + "id": sessionID, + "timestamp": now.Format(time.RFC3339Nano), + "cwd": cwd, + }); err != nil { + return err + } + + var parentID any + for index, turn := range turns { + entryID, err := newPiEntryID() + if err != nil { + return err + } + entryTime := now.Add(time.Duration(index+1) * time.Millisecond) + lastAt = entryTime + message := map[string]any{ + "role": turn.Role, + "content": turn.Text, + "timestamp": entryTime.UnixMilli(), + } + if turn.Role == "assistant" { + message["content"] = []map[string]any{{"type": "text", "text": turn.Text}} + message["api"] = "openai-completions" + message["provider"] = "showagent" + message["model"] = "converted-session" + message["usage"] = map[string]any{ + "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0, + "cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0}, + } + message["stopReason"] = "stop" + } + if err := encoder.Encode(map[string]any{ + "type": "message", + "id": entryID, + "parentId": parentID, + "timestamp": entryTime.Format(time.RFC3339Nano), + "message": message, + }); err != nil { + return err + } + parentID = entryID + } + return nil + }); err != nil { + return Row{}, err + } + + firstUser, lastUser := userPreviewFromTurns(turns) + return Row{ + Provider: ProviderPi, + ID: sessionID, + LastAt: lastAt, + CWD: cwd, + LaunchCWD: cwd, + File: path, + FirstUser: firstUser, + LastUser: lastUser, + }, nil +} + +func newPiEntryID() (string, error) { + return randomHex(4) +} diff --git a/internal/session/pi_test.go b/internal/session/pi_test.go new file mode 100644 index 0000000..e19c70f --- /dev/null +++ b/internal/session/pi_test.go @@ -0,0 +1,293 @@ +package session + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +const piSessionID = "11111111-2222-4333-8444-555555555555" + +func TestDiscoverPiUsesOnlyTheActiveBranch(t *testing.T) { + agentDir := t.TempDir() + t.Setenv("PI_CODING_AGENT_DIR", agentDir) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") + path := filepath.Join(agentDir, "sessions", "--work-pi--", "2026-07-10T10-00-00-000Z_"+piSessionID+".jsonl") + writeFile(t, path, ` +{"type":"session","version":3,"id":"`+piSessionID+`","timestamp":"2026-07-10T10:00:00.000Z","cwd":"/work/pi"} +{"type":"message","id":"00000001","parentId":null,"timestamp":"2026-07-10T10:01:00.000Z","message":{"role":"user","content":"first pi question","timestamp":1783677660000}} +{"type":"message","id":"00000002","parentId":"00000001","timestamp":"2026-07-10T10:02:00.000Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"private reasoning"},{"type":"text","text":"pi answer"}],"api":"openai-completions","provider":"opencode-go","model":"glm-5.2","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1783677720000}} +{"type":"message","id":"00000003","parentId":"00000002","timestamp":"2026-07-10T10:03:00.000Z","message":{"role":"user","content":"abandoned branch","timestamp":1783677780000}} +{"type":"message","id":"00000004","parentId":"00000002","timestamp":"2026-07-10T10:04:00.000Z","message":{"role":"user","content":[{"type":"text","text":"active\nbranch sk-test-12345678901234567890"}],"timestamp":1783677840000}} +`) + + rows := discoverPi(defaultPiSessionRoot()) + if len(rows) != 1 { + t.Fatalf("rows = %#v, want one Pi session", rows) + } + row := rows[0] + if row.Provider != ProviderPi || row.ID != piSessionID || row.CWD != "/work/pi" || row.File != path { + t.Fatalf("row = %#v", row) + } + if row.FirstUser != "first pi question" || row.LastUser != "active branch [redacted-secret]" { + t.Fatalf("previews = %q / %q", row.FirstUser, row.LastUser) + } + wantTime := time.Date(2026, 7, 10, 10, 4, 0, 0, time.UTC) + if !row.LastAt.Equal(wantTime) { + t.Fatalf("LastAt = %s, want %s", row.LastAt, wantTime) + } + + turns, err := piTranscript(path) + if err != nil { + t.Fatal(err) + } + wantTurns := []Turn{ + {Role: "user", Text: "first pi question"}, + {Role: "assistant", Text: "pi answer"}, + {Role: "user", Text: "active\nbranch sk-test-12345678901234567890"}, + } + if !reflect.DeepEqual(turns, wantTurns) { + t.Fatalf("turns = %#v, want %#v", turns, wantTurns) + } +} + +func TestDiscoverPiReadsLegacyLinearSession(t *testing.T) { + agentDir := t.TempDir() + t.Setenv("PI_CODING_AGENT_DIR", agentDir) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") + path := filepath.Join(agentDir, "sessions", "--work-legacy--", "legacy.jsonl") + writeFile(t, path, ` +{"type":"session","version":1,"id":"`+piSessionID+`","timestamp":"2026-07-10T09:00:00Z","cwd":"/work/legacy"} +{"type":"message","timestamp":"2026-07-10T09:01:00Z","message":{"role":"user","content":"legacy first"}} +{"type":"message","timestamp":"2026-07-10T09:02:00Z","message":{"role":"assistant","content":[{"type":"text","text":"legacy answer"}]}} +{"type":"message","timestamp":"2026-07-10T09:03:00Z","message":{"role":"user","content":"legacy last"}} +`) + + rows := discoverPi(defaultPiSessionRoot()) + if len(rows) != 1 || rows[0].FirstUser != "legacy first" || rows[0].LastUser != "legacy last" { + t.Fatalf("legacy rows = %#v", rows) + } + turns, err := piTranscript(path) + if err != nil { + t.Fatal(err) + } + want := []Turn{{Role: "user", Text: "legacy first"}, {Role: "assistant", Text: "legacy answer"}, {Role: "user", Text: "legacy last"}} + if !reflect.DeepEqual(turns, want) { + t.Fatalf("legacy turns = %#v, want %#v", turns, want) + } +} + +func TestDiscoverPiSkipsMalformedFilesAndLines(t *testing.T) { + agentDir := t.TempDir() + t.Setenv("PI_CODING_AGENT_DIR", agentDir) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") + dir := filepath.Join(agentDir, "sessions", "--work-corrupt--") + writeFile(t, filepath.Join(dir, "invalid-header.jsonl"), "not json\n"+`{"type":"session","version":3,"id":"bad","cwd":"/work/corrupt"}`) + validPath := filepath.Join(dir, "valid-with-corrupt-line.jsonl") + writeFile(t, validPath, ` +{"type":"session","version":3,"id":"`+piSessionID+`","timestamp":"2026-07-10T09:00:00Z","cwd":"/work/corrupt"} +not json +{"type":"message","id":"00000001","parentId":null,"timestamp":"2026-07-10T09:01:00Z","message":{"role":"user","content":"survives corruption"}} +`) + + rows := discoverPi(defaultPiSessionRoot()) + if len(rows) != 1 || rows[0].File != validPath || rows[0].FirstUser != "survives corruption" { + t.Fatalf("malformed-file discovery rows = %#v", rows) + } +} + +func TestPiProviderResumeCompoundAndDelete(t *testing.T) { + path := filepath.Join(t.TempDir(), "pi session.jsonl") + writeFile(t, path, "session") + row := Row{Provider: ProviderPi, ID: piSessionID, CWD: "/work/pi", File: path} + provider := piProvider{} + + wantResume := []string{"pi", "--session", path} + for _, options := range []ResumeOptions{{}, {Dangerous: true}} { + if got := provider.ResumeArgs(row, options); !reflect.DeepEqual(got, wantResume) { + t.Fatalf("resume(%#v) = %v, want %v", options, got, wantResume) + } + } + wantCompound := append(append([]string{}, wantResume...), "capture durable learnings") + if got := provider.CompoundArgs(row, ResumeOptions{}, "capture durable learnings"); !reflect.DeepEqual(got, wantCompound) { + t.Fatalf("compound = %v, want %v", got, wantCompound) + } + if err := provider.Delete(row); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("Pi session still exists after delete: %v", err) + } + if err := provider.Delete(Row{Provider: ProviderPi}); err == nil || !strings.Contains(err.Error(), "unknown") { + t.Fatalf("delete without file error = %v", err) + } + if err := provider.Delete(Row{Provider: ProviderPi, File: filepath.Join(t.TempDir(), "session.json")}); err == nil || !strings.Contains(err.Error(), "unexpected") { + t.Fatalf("delete non-JSONL error = %v", err) + } + if _, err := writePiConverted(Row{Provider: ProviderCodex, CWD: "(unknown cwd)"}, []Turn{{Role: "user", Text: "hello"}}); err == nil || !strings.Contains(err.Error(), "known workspace") { + t.Fatalf("conversion with unknown cwd error = %v", err) + } +} + +func TestWritePiConvertedCreatesPrivateNativeSession(t *testing.T) { + agentDir := t.TempDir() + workspace := filepath.Join(t.TempDir(), "workspace with spaces") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PI_CODING_AGENT_DIR", agentDir) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") + turns := []Turn{ + {Role: "user", Text: "first converted prompt"}, + {Role: "assistant", Text: "converted answer"}, + {Role: "user", Text: "last converted prompt"}, + } + + row, err := writePiConverted(Row{Provider: ProviderCodex, CWD: workspace}, turns) + if err != nil { + t.Fatal(err) + } + if row.Provider != ProviderPi || row.ID == "" || row.CWD != workspace { + t.Fatalf("row = %#v", row) + } + if row.FirstUser != "first converted prompt" || row.LastUser != "last converted prompt" { + t.Fatalf("previews = %q / %q", row.FirstUser, row.LastUser) + } + if filepath.Dir(row.File) != defaultPiSessionDir(workspace) { + t.Fatalf("session dir = %q, want %q", filepath.Dir(row.File), defaultPiSessionDir(workspace)) + } + info, err := os.Stat(row.File) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %o, want 600", info.Mode().Perm()) + } + + file, err := os.Open(row.File) + if err != nil { + t.Fatal(err) + } + defer func() { _ = file.Close() }() + scanner := bufio.NewScanner(file) + if !scanner.Scan() { + t.Fatal("missing Pi session header") + } + var header struct { + Type string `json:"type"` + Version int `json:"version"` + ID string `json:"id"` + CWD string `json:"cwd"` + } + if err := json.Unmarshal(scanner.Bytes(), &header); err != nil { + t.Fatal(err) + } + if header.Type != "session" || header.Version != 3 || header.ID != row.ID || header.CWD != workspace { + t.Fatalf("header = %#v", header) + } + + gotTurns, err := piTranscript(row.File) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(gotTurns, turns) { + t.Fatalf("round-trip turns = %#v, want %#v", gotTurns, turns) + } +} + +func TestPiBranchCreatesIndependentTransferableCopy(t *testing.T) { + agentDir := t.TempDir() + t.Setenv("PI_CODING_AGENT_DIR", agentDir) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") + turns := []Turn{{Role: "user", Text: "branch this"}, {Role: "assistant", Text: "branch ready"}} + source, err := writePiConverted(Row{Provider: ProviderCodex, CWD: "/work/pi-branch"}, turns) + if err != nil { + t.Fatal(err) + } + branched, err := Branch(source) + if err != nil { + t.Fatal(err) + } + if branched.ID == source.ID || branched.File == source.File { + t.Fatalf("branch did not create an independent session: source=%#v branch=%#v", source, branched) + } + if _, err := os.Stat(source.File); err != nil { + t.Fatalf("branch modified the original: %v", err) + } + got, err := piTranscript(branched.File) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, turns) { + t.Fatalf("branch turns = %#v, want %#v", got, turns) + } +} + +func TestPiSessionRootHonorsSettingsAndEnvironmentPrecedence(t *testing.T) { + agentDir := t.TempDir() + t.Setenv("PI_CODING_AGENT_DIR", agentDir) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") + settingsRoot := filepath.Join(t.TempDir(), "settings-sessions") + settings, err := json.Marshal(map[string]string{"sessionDir": settingsRoot}) + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(agentDir, "settings.json"), string(settings)) + + if got := defaultPiSessionRoot(); got != settingsRoot { + t.Fatalf("settings session root = %q", got) + } + + envRoot := filepath.Join(t.TempDir(), "env-sessions") + t.Setenv("PI_CODING_AGENT_SESSION_DIR", envRoot) + if got := defaultPiSessionRoot(); got != envRoot { + t.Fatalf("env session root = %q, want %q", got, envRoot) + } + + row, err := writePiConverted(Row{Provider: ProviderClaude, CWD: "/work/pi"}, []Turn{{Role: "user", Text: "hello"}}) + if err != nil { + t.Fatal(err) + } + if filepath.Dir(row.File) != envRoot { + t.Fatalf("custom session file = %q, want directory %q", row.File, envRoot) + } + if !strings.HasSuffix(row.File, "_"+row.ID+".jsonl") { + t.Fatalf("unexpected Pi session filename %q", row.File) + } +} + +func TestPiSessionRootHonorsProjectSettingsForConversion(t *testing.T) { + agentDir := t.TempDir() + workspace := t.TempDir() + t.Setenv("PI_CODING_AGENT_DIR", agentDir) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") + writeFile(t, filepath.Join(agentDir, "settings.json"), `{"sessionDir":"global-sessions"}`) + writeFile(t, filepath.Join(workspace, ".pi", "settings.json"), `{"sessionDir":".pi/project-sessions"}`) + t.Chdir(workspace) + + want := filepath.Join(workspace, ".pi", "project-sessions") + if got := defaultPiSessionDir(workspace); got != want { + t.Fatalf("project session dir = %q, want %q", got, want) + } + nativePath := filepath.Join(want, "native.jsonl") + writeFile(t, nativePath, ` +{"type":"session","version":3,"id":"`+piSessionID+`","timestamp":"2026-07-10T10:00:00Z","cwd":"`+workspace+`"} +{"type":"message","id":"00000001","parentId":null,"timestamp":"2026-07-10T10:01:00Z","message":{"role":"user","content":"project session"}} +`) + rows := piProvider{}.Discover() + if len(rows) != 1 || rows[0].File != nativePath { + t.Fatalf("project discovery rows = %#v, want %q", rows, nativePath) + } + row, err := writePiConverted(Row{Provider: ProviderCodex, CWD: workspace}, []Turn{{Role: "user", Text: "hello"}}) + if err != nil { + t.Fatal(err) + } + if filepath.Dir(row.File) != want { + t.Fatalf("converted file = %q, want directory %q", row.File, want) + } +} diff --git a/internal/session/provider.go b/internal/session/provider.go index 61f1a2e..5e9f588 100644 --- a/internal/session/provider.go +++ b/internal/session/provider.go @@ -45,6 +45,7 @@ var registry = []ProviderImpl{ jcodeProvider{}, opencodeProvider{}, geminiProvider{}, + piProvider{}, } func providerFor(name Provider) (ProviderImpl, bool) { diff --git a/internal/session/session_test.go b/internal/session/session_test.go index efd968c..d66179b 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -16,6 +16,8 @@ func TestDiscoverFindsCodexAndClaudeSessions(t *testing.T) { t.Setenv("JCODE_HOME", filepath.Join(root, "empty-jcode")) t.Setenv("OPENCODE_DATA_HOME", filepath.Join(root, "empty-opencode")) t.Setenv("GEMINI_CLI_HOME", filepath.Join(root, "empty-gemini")) + t.Setenv("PI_CODING_AGENT_DIR", filepath.Join(root, "empty-pi")) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") writeFile(t, filepath.Join(codexHome, "sessions", "2026", "06", "01", "rollout-2026-06-01T12-00-00-aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb.jsonl"), ` {"timestamp":"2026-06-01T09:00:00Z","type":"session_meta","payload":{"id":"aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb","cwd":"/work/codex"}} @@ -796,6 +798,13 @@ func TestCompoundCommand(t *testing.T) { if strings.Join(gotJ, "\x1f") != strings.Join(wantJ, "\x1f") { t.Fatalf("jcode compound = %v, want %v", gotJ, wantJ) } + + pi := Row{Provider: ProviderPi, ID: "pid", File: "/tmp/pi.jsonl"} + gotPi := pi.CompoundCommand(ResumeOptions{Dangerous: true}, prompt) + wantPi := []string{"pi", "--session", "/tmp/pi.jsonl", prompt} + if strings.Join(gotPi, "\x1f") != strings.Join(wantPi, "\x1f") { + t.Fatalf("pi compound = %v, want %v", gotPi, wantPi) + } } func TestProjectLearningsDirIsPerProject(t *testing.T) { @@ -820,8 +829,8 @@ func TestProjectLearningsDirIsPerProject(t *testing.T) { } func TestCompoundPromptMentionsSharedDir(t *testing.T) { - p := compoundPrompt("/tmp/base/-home-u-proj-a") - for _, want := range []string{"/tmp/base/-home-u-proj-a", "Codex", "Claude", "compound-engineering"} { + p := compoundPrompt("/tmp/base/-home-u-proj-a", ProviderPi) + for _, want := range []string{"/tmp/base/-home-u-proj-a", "tool: pi", "supported coding agent", "compound-engineering"} { if !strings.Contains(p, want) { t.Fatalf("compound prompt missing %q", want) } @@ -894,11 +903,13 @@ func TestScanTargetsReportEnvOverrides(t *testing.T) { t.Setenv("JCODE_HOME", filepath.Join(root, "jcode")) t.Setenv("OPENCODE_DATA_HOME", filepath.Join(root, "empty-opencode")) t.Setenv("GEMINI_CLI_HOME", filepath.Join(root, "empty-gemini")) + t.Setenv("PI_CODING_AGENT_DIR", filepath.Join(root, "pi")) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") t.Setenv("PATH", filepath.Join(root, "empty-bin")) targets := ScanTargets() - if len(targets) != 5 { - t.Fatalf("targets = %d, want 5", len(targets)) + if len(targets) != 6 { + t.Fatalf("targets = %d, want 6", len(targets)) } want := map[Provider]struct{ path, env string }{ ProviderCodex: {filepath.Join(root, "codex", "sessions"), "CODEX_HOME"}, @@ -906,6 +917,7 @@ func TestScanTargetsReportEnvOverrides(t *testing.T) { ProviderJCode: {filepath.Join(root, "jcode", "sessions"), "JCODE_HOME"}, ProviderOpenCode: {filepath.Join(root, "empty-opencode"), "OPENCODE_DATA_HOME"}, ProviderGemini: {filepath.Join(root, "empty-gemini", ".gemini", "tmp"), "GEMINI_CLI_HOME"}, + ProviderPi: {filepath.Join(root, "pi", "sessions"), "PI_CODING_AGENT_SESSION_DIR or PI_CODING_AGENT_DIR"}, } for _, target := range targets { expected, ok := want[target.Provider] diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 423b24e..5a13ea9 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1046,8 +1046,8 @@ func (m model) resumeHint(row session.Row) string { if row.Provider == session.ProviderClaude { return fmt.Sprintf("enter → resume with %s · yolo: skips permission prompts · y → normal", row.Provider) } - if row.Provider == session.ProviderJCode { - return fmt.Sprintf("enter → resume with %s · yolo: no extra jcode flag · y → normal", row.Provider) + if row.Provider == session.ProviderJCode || row.Provider == session.ProviderPi { + return fmt.Sprintf("enter → resume with %s · yolo: no extra %s flag · y → normal", row.Provider, row.Provider) } return fmt.Sprintf("enter → resume with %s · yolo: bypasses approvals & sandbox · y → normal", row.Provider) } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 7066c2b..dd7488e 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -621,6 +621,17 @@ func TestScopeCycling(t *testing.T) { } } +func TestPiDangerousResumeHintDoesNotClaimUnsupportedBypass(t *testing.T) { + withFakeCommands(t, "pi") + row := session.Row{Provider: session.ProviderPi, ID: "pi-session", LastAt: time.Now(), File: "/tmp/pi.jsonl"} + m := sizedModel([]session.Row{row}) + m.dangerous = true + hint := m.resumeHint(row) + if !strings.Contains(hint, "no extra pi flag") || strings.Contains(hint, "bypasses approvals") { + t.Fatalf("Pi dangerous hint = %q", hint) + } +} + func TestHandoffTargetCycling(t *testing.T) { // Only providers whose CLI is on PATH qualify as hand-off targets. withFakeCommands(t, "claude", "jcode") @@ -1119,13 +1130,15 @@ func TestEmptyViewListsScannedDirs(t *testing.T) { t.Setenv("JCODE_HOME", filepath.Join(root, "jcode")) t.Setenv("OPENCODE_DATA_HOME", filepath.Join(root, "empty-opencode")) t.Setenv("GEMINI_CLI_HOME", filepath.Join(root, "empty-gemini")) + t.Setenv("PI_CODING_AGENT_DIR", filepath.Join(root, "empty-pi")) + t.Setenv("PI_CODING_AGENT_SESSION_DIR", "") m := sizedModel(nil) view := m.emptyView() for _, want := range []string{ filepath.Join(root, "codex", "sessions"), filepath.Join(root, "claude", "projects"), - "CODEX_HOME", "CLAUDE_HOME", "JCODE_HOME", "OPENCODE_DATA_HOME", "GEMINI_CLI_HOME", + "CODEX_HOME", "CLAUDE_HOME", "JCODE_HOME", "OPENCODE_DATA_HOME", "GEMINI_CLI_HOME", "PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR", "press r to rescan", } { if !strings.Contains(view, want) { diff --git a/scripts/e2e-real.sh b/scripts/e2e-real.sh index 253d9e9..6fb0462 100755 --- a/scripts/e2e-real.sh +++ b/scripts/e2e-real.sh @@ -6,11 +6,12 @@ # with `claude -p` and `codex exec` inside throwaway workspaces, then uses # showagent's own Branch/Convert/Delete code paths on those rows, and verifies # every artifact with the target CLI itself (claude --resume, codex exec -# resume, gemini --list-sessions, opencode export, jcode replay --export). Costs +# resume, gemini --list-sessions, opencode export, jcode replay --export, +# pi --export). Costs # a few small model calls. Only sessions whose cwd is one of the throwaway # workspaces are ever touched or deleted. # -# Usage: scripts/e2e-real.sh (requires: go, claude; optional: codex, gemini, opencode, jcode) +# Usage: scripts/e2e-real.sh (requires: go, claude; optional: codex, gemini, opencode, jcode, pi) set -uo pipefail cd "$(dirname "$0")/.." || exit 1 @@ -96,6 +97,28 @@ for a in json.load(open(sys.argv[1])): EOF } +manifest_file() { # provider id -> file + python3 - "$MANIFEST" "$1" "$2" <<'EOF' +import json, sys +for artifact in json.load(open(sys.argv[1])): + if artifact["provider"] == sys.argv[2] and artifact["id"] == sys.argv[3]: + print(artifact["file"]) + break +EOF +} + +pi_export_contains() { # html marker + python3 - "$1" "$2" <<'EOF' +import base64, re, sys +html = open(sys.argv[1], encoding="utf-8").read() +match = re.search(r'', html) +if not match: + raise SystemExit(1) +decoded = base64.b64decode(match.group(1)).decode("utf-8") +raise SystemExit(0 if sys.argv[2] in decoded else 1) +EOF +} + echo "== verifying artifacts with the real CLIs" while IFS=$'\t' read -r id cwd; do [ -z "$id" ] && continue @@ -165,6 +188,32 @@ if have jcode; then done < <(manifest_rows converted jcode) fi +if have pi; then + while IFS=$'\t' read -r id cwd; do + [ -z "$id" ] && continue + file="$(manifest_file pi "$id")" + export_path="$WS_ROOT/pi-$id.html" + if pi --export "$file" "$export_path" >/dev/null 2>&1 && pi_export_contains "$export_path" "E2E-SEED"; then + ok "pi --export validates $id with the converted transcript" + else + bad "pi --export could not validate $id" + fi + state="$(printf '{"id":"showagent-state","type":"get_state"}\n' | (cd "$cwd" && pi --mode rpc --session "$file") 2>/dev/null)" + if python3 -c 'import json, sys +expected = sys.argv[1] +records = [] +for line in sys.stdin: + try: records.append(json.loads(line)) + except json.JSONDecodeError: pass +ok = any(r.get("id") == "showagent-state" and r.get("success") is True and r.get("data", {}).get("sessionId") == expected and r.get("data", {}).get("messageCount", 0) > 0 for r in records) +raise SystemExit(0 if ok else 1)' "$id" <<<"$state"; then + ok "pi RPC resumes $id and reports its loaded transcript" + else + bad "pi RPC could not resume $id" + fi + done < <(manifest_rows converted pi) +fi + echo "== cleanup via showagent Delete" if SHOWAGENT_E2E_WS_LIST="$WS1:$WS2" SHOWAGENT_E2E_MANIFEST="$MANIFEST" \ go test -tags realcli -run TestRealCLICleanup -count=1 -v ./internal/session/ >"$WS_ROOT/cleanup.log" 2>&1; then