Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/sidecar/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
_ "github.com/marcus/sidecar/internal/adapter/opencode"
_ "github.com/marcus/sidecar/internal/adapter/pi"
_ "github.com/marcus/sidecar/internal/adapter/piagent"
_ "github.com/marcus/sidecar/internal/adapter/omp"
_ "github.com/marcus/sidecar/internal/adapter/warp"
"github.com/marcus/sidecar/internal/app"
"github.com/marcus/sidecar/internal/config"
Expand Down
6 changes: 6 additions & 0 deletions internal/adapter/omp/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Package omp implements an adapter for Oh My Pi (OMP) agent sessions.
//
// OMP is a fork of Pi Agent and uses the same JSONL format. Sessions are stored
// in ~/.omp/agent/sessions/<encoded-path>/ where encoded-path is the project
// path with slashes replaced by dashes, wrapped in double dashes.
package omp
80 changes: 80 additions & 0 deletions internal/adapter/omp/register.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package omp

import (
"os"
"path/filepath"
"strings"

"github.com/marcus/sidecar/internal/adapter"
"github.com/marcus/sidecar/internal/adapter/piagent"
)

func init() {
adapter.RegisterFactory(func() adapter.Adapter {
home, _ := os.UserHomeDir()
sessionsDir := filepath.Join(home, ".omp", "agent", "sessions")
return piagent.NewCustom(sessionsDir, "omp", "OMP", "Ω").
WithProjectDirFunc(projectDirPath)
})
}

// projectDirPath implements OMP's session directory encoding.
//
// OMP v15+ uses a home-relative encoding for paths within the user's home
// directory, a tmpdir-relative encoding for paths within the temp directory,
// and falls back to the legacy absolute encoding (--path-encoded--) for
// everything else. Old sessions created before the encoding change retain
// the legacy name; OMP migrates home-relative sessions automatically but
// leaves tmp sessions in place.
//
// Encoding rules (after symlink resolution and macOS /private stripping):
// - within home: -<relative-path-with-slashes-as-dashes>
// - within tmpdir: -tmp-<relative-path-with-slashes-as-dashes> (or -tmp if cwd==tmpdir)
// - elsewhere: --<abs-path-with-slashes-as-dashes>-- (legacy)
func projectDirPath(sessionsDir, projectRoot string) string {
cwd, err := filepath.Abs(projectRoot)
if err != nil {
cwd = projectRoot
}
if resolved, err := filepath.EvalSymlinks(cwd); err == nil {
cwd = resolved
}
// On macOS, strip the /private prefix that EvalSymlinks adds for /tmp etc.
// OMP does this to keep paths like /tmp stable across symlink resolution.
if strings.HasPrefix(cwd, "/private/") {
stripped := cwd[len("/private"):]
if sr, err := filepath.EvalSymlinks(stripped); err == nil && sr == cwd {
cwd = stripped
}
}

home, _ := os.UserHomeDir()
if resolved, err := filepath.EvalSymlinks(home); err == nil {
home = resolved
}
tmpdir := os.TempDir() // returns /tmp on macOS (already without /private)

switch {
case cwd == home || strings.HasPrefix(cwd, home+string(filepath.Separator)):
rel, _ := filepath.Rel(home, cwd)
encoded := strings.ReplaceAll(rel, string(filepath.Separator), "-")
if encoded == "." || encoded == "" {
return filepath.Join(sessionsDir, "-")
}
return filepath.Join(sessionsDir, "-"+encoded)

case cwd == tmpdir || strings.HasPrefix(cwd, tmpdir+string(filepath.Separator)):
rel, _ := filepath.Rel(tmpdir, cwd)
encoded := strings.ReplaceAll(rel, string(filepath.Separator), "-")
if encoded == "." || encoded == "" {
return filepath.Join(sessionsDir, "-tmp")
}
return filepath.Join(sessionsDir, "-tmp-"+encoded)

default:
// Legacy absolute encoding — also used for dirs that haven't been migrated.
path := strings.TrimPrefix(cwd, "/")
encoded := strings.ReplaceAll(path, "/", "-")
return filepath.Join(sessionsDir, "--"+encoded+"--")
}
}
37 changes: 33 additions & 4 deletions internal/adapter/piagent/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ type toolUseRef struct {
// Adapter implements the adapter.Adapter interface for standalone Pi Agent sessions.
type Adapter struct {
sessionsDir string
id string
name string
icon string
projectDirFn func(sessionsDir, projectRoot string) string // nil → default piagent encoding
sessionIndex map[string]string // sessionID -> file path
metaCache map[string]sessionMetaCacheEntry
msgCache *cache.Cache[messageCacheEntry]
Expand All @@ -71,22 +75,38 @@ type Adapter struct {
// New creates a new Pi Agent adapter.
func New() *Adapter {
home, _ := os.UserHomeDir()
return NewCustom(filepath.Join(home, ".pi", "agent", "sessions"), adapterID, adapterName, adapterIcon)
}

// NewCustom creates a Pi Agent-compatible adapter with a custom sessions directory and identity.
// Use this for forks of Pi Agent that share the same JSONL format but store sessions elsewhere.
func NewCustom(sessionsDir, id, name, icon string) *Adapter {
return &Adapter{
sessionsDir: filepath.Join(home, ".pi", "agent", "sessions"),
sessionsDir: sessionsDir,
id: id,
name: name,
icon: icon,
sessionIndex: make(map[string]string),
metaCache: make(map[string]sessionMetaCacheEntry),
msgCache: cache.New[messageCacheEntry](msgCacheMaxEntries),
}
}

// WithProjectDirFunc overrides the function used to map a project root to its
// sessions directory. Use this for Pi forks that encode paths differently.
func (a *Adapter) WithProjectDirFunc(fn func(sessionsDir, projectRoot string) string) *Adapter {
a.projectDirFn = fn
return a
}

// ID returns the adapter identifier.
func (a *Adapter) ID() string { return adapterID }
func (a *Adapter) ID() string { return a.id }

// Name returns the human-readable adapter name.
func (a *Adapter) Name() string { return adapterName }
func (a *Adapter) Name() string { return a.name }

// Icon returns the adapter icon for badge display.
func (a *Adapter) Icon() string { return adapterIcon }
func (a *Adapter) Icon() string { return a.icon }

// Detect checks if Pi Agent sessions exist for the given project.
func (a *Adapter) Detect(projectRoot string) (bool, error) {
Expand Down Expand Up @@ -267,11 +287,20 @@ func (a *Adapter) Watch(projectRoot string) (<-chan adapter.Event, io.Closer, er

// projectDirPath converts a project root path to the Pi Agent sessions directory path.
// Pi Agent encodes paths as: /home/user/project → --home-user-project--
// If a.projectDirFn is set, it is called instead.
func (a *Adapter) projectDirPath(projectRoot string) string {
if a.projectDirFn != nil {
return a.projectDirFn(a.sessionsDir, projectRoot)
}
absPath, err := filepath.Abs(projectRoot)
if err != nil {
absPath = projectRoot
}
// Resolve symlinks so that paths like /tmp (→ /private/tmp on macOS) match
// the encoding that Pi Agent uses when it records the session cwd.
if resolved, err := filepath.EvalSymlinks(absPath); err == nil {
absPath = resolved
}
// /home/user/project → --home-user-project--
// Strip leading slash, replace remaining slashes with dashes, wrap in --
path := strings.TrimPrefix(absPath, "/")
Expand Down
4 changes: 3 additions & 1 deletion internal/adapter/piagent/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ func TestProjectDirPath(t *testing.T) {
a := New()

tests := []struct {
input string
input string
wantSuffix string
}{
{"/home/user/project", "--home-user-project--"},
{"/home/user/my-app", "--home-user-my-app--"},
{"/", "----"},
// /tmp is a symlink to /private/tmp on macOS; must resolve to --private-tmp--
{"/tmp", "--private-tmp--"},
}

for _, tt := range tests {
Expand Down