diff --git a/cmd/sidecar/main.go b/cmd/sidecar/main.go index e5152e23..f69c72da 100644 --- a/cmd/sidecar/main.go +++ b/cmd/sidecar/main.go @@ -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" diff --git a/internal/adapter/omp/doc.go b/internal/adapter/omp/doc.go new file mode 100644 index 00000000..4cf0568d --- /dev/null +++ b/internal/adapter/omp/doc.go @@ -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// where encoded-path is the project +// path with slashes replaced by dashes, wrapped in double dashes. +package omp diff --git a/internal/adapter/omp/register.go b/internal/adapter/omp/register.go new file mode 100644 index 00000000..e438b75b --- /dev/null +++ b/internal/adapter/omp/register.go @@ -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: - +// - within tmpdir: -tmp- (or -tmp if cwd==tmpdir) +// - elsewhere: ---- (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+"--") + } +} diff --git a/internal/adapter/piagent/adapter.go b/internal/adapter/piagent/adapter.go index 7bfcd091..e41d26a4 100644 --- a/internal/adapter/piagent/adapter.go +++ b/internal/adapter/piagent/adapter.go @@ -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] @@ -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) { @@ -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, "/") diff --git a/internal/adapter/piagent/adapter_test.go b/internal/adapter/piagent/adapter_test.go index 33d6c077..d11d9590 100644 --- a/internal/adapter/piagent/adapter_test.go +++ b/internal/adapter/piagent/adapter_test.go @@ -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 {