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
@@ -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