@hybridlabor-api/bdb-synapse 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,304 @@
1
+ package opencode
2
+
3
+ import (
4
+ "database/sql"
5
+ "encoding/json"
6
+ "os"
7
+ "path/filepath"
8
+ "sort"
9
+ "time"
10
+
11
+ "github.com/hybridlabor-api/bdb-synapse/internal/adapter"
12
+ "github.com/hybridlabor-api/bdb-synapse/internal/model"
13
+ _ "modernc.org/sqlite"
14
+ )
15
+
16
+ type Adapter struct {
17
+ Dir string
18
+ }
19
+
20
+ func DefaultDir() string {
21
+ home, err := os.UserHomeDir()
22
+ if err != nil {
23
+ return ""
24
+ }
25
+ return filepath.Join(home, ".local", "share", "opencode")
26
+ }
27
+
28
+ func (a Adapter) Harness() string { return "opencode" }
29
+
30
+ func (a Adapter) SessionDir() string {
31
+ if a.Dir != "" {
32
+ return a.Dir
33
+ }
34
+ return DefaultDir()
35
+ }
36
+
37
+ func (a Adapter) dbPath() string {
38
+ return filepath.Join(a.SessionDir(), "opencode.db")
39
+ }
40
+
41
+ func (a Adapter) openDB() (*sql.DB, error) {
42
+ dbPath := a.dbPath()
43
+ if _, err := os.Stat(dbPath); err != nil {
44
+ return nil, err
45
+ }
46
+ return sql.Open("sqlite", "file:"+dbPath+"?mode=ro")
47
+ }
48
+
49
+ func (a Adapter) ListSessions() ([]model.SessionMeta, error) {
50
+ db, err := a.openDB()
51
+ if err != nil {
52
+ return nil, nil
53
+ }
54
+ defer db.Close()
55
+
56
+ rows, err := db.Query(`
57
+ SELECT id, title, directory, parent_id, time_created, time_updated
58
+ FROM session
59
+ ORDER BY time_updated DESC
60
+ `)
61
+ if err != nil {
62
+ return nil, err
63
+ }
64
+ defer rows.Close()
65
+
66
+ var metas []model.SessionMeta
67
+ for rows.Next() {
68
+ var (
69
+ id string
70
+ title string
71
+ directory string
72
+ parentID sql.NullString
73
+ timeCreated int64
74
+ timeUpdated int64
75
+ )
76
+ if err := rows.Scan(&id, &title, &directory, &parentID, &timeCreated, &timeUpdated); err != nil {
77
+ continue
78
+ }
79
+
80
+ startedAt := time.UnixMilli(timeCreated).UTC().Format(time.RFC3339)
81
+ endedAt := time.UnixMilli(timeUpdated).UTC().Format(time.RFC3339)
82
+ sessionPath := filepath.Join(a.SessionDir(), id)
83
+
84
+ meta := model.SessionMeta{
85
+ Key: adapter.SessionKey(a.Harness(), sessionPath),
86
+ ID: id,
87
+ Harness: "opencode",
88
+ Path: sessionPath,
89
+ Title: title,
90
+ Cwd: directory,
91
+ StartedAt: startedAt,
92
+ EndedAt: endedAt,
93
+ }
94
+
95
+ if parentID.Valid && parentID.String != "" {
96
+ meta.Auxiliary = true
97
+ }
98
+
99
+ var eventCount, userTurns int
100
+ _ = db.QueryRow(`
101
+ SELECT
102
+ COUNT(CASE WHEN data LIKE '%"type":"tool"%' THEN 1 END),
103
+ COUNT(CASE WHEN data LIKE '%"type":"text"%' THEN 1 END)
104
+ FROM part
105
+ WHERE session_id = ?
106
+ `, id).Scan(&eventCount, &userTurns)
107
+
108
+ meta.EventCount = eventCount
109
+ meta.UserTurns = userTurns
110
+
111
+ metas = append(metas, meta)
112
+ }
113
+
114
+ sort.Slice(metas, func(i, j int) bool {
115
+ return metas[i].EndedAt > metas[j].EndedAt
116
+ })
117
+ return metas, nil
118
+ }
119
+
120
+ func (a Adapter) SummaryInputs(path string) []string {
121
+ return []string{a.dbPath()}
122
+ }
123
+
124
+ func (a Adapter) Summarize(path string) (model.SessionMeta, error) {
125
+ id := filepath.Base(path)
126
+ db, err := a.openDB()
127
+ if err != nil {
128
+ return model.SessionMeta{}, err
129
+ }
130
+ defer db.Close()
131
+
132
+ var (
133
+ title string
134
+ directory string
135
+ parentID sql.NullString
136
+ timeCreated int64
137
+ timeUpdated int64
138
+ )
139
+ err = db.QueryRow(`
140
+ SELECT title, directory, parent_id, time_created, time_updated
141
+ FROM session
142
+ WHERE id = ?
143
+ `, id).Scan(&title, &directory, &parentID, &timeCreated, &timeUpdated)
144
+ if err != nil {
145
+ return model.SessionMeta{}, err
146
+ }
147
+
148
+ startedAt := time.UnixMilli(timeCreated).UTC().Format(time.RFC3339)
149
+ endedAt := time.UnixMilli(timeUpdated).UTC().Format(time.RFC3339)
150
+
151
+ meta := model.SessionMeta{
152
+ Key: adapter.SessionKey(a.Harness(), path),
153
+ ID: id,
154
+ Harness: "opencode",
155
+ Path: path,
156
+ Title: title,
157
+ Cwd: directory,
158
+ StartedAt: startedAt,
159
+ EndedAt: endedAt,
160
+ }
161
+
162
+ if parentID.Valid && parentID.String != "" {
163
+ meta.Auxiliary = true
164
+ }
165
+
166
+ var eventCount, userTurns int
167
+ _ = db.QueryRow(`
168
+ SELECT
169
+ COUNT(CASE WHEN data LIKE '%"type":"tool"%' THEN 1 END),
170
+ COUNT(CASE WHEN data LIKE '%"type":"text"%' THEN 1 END)
171
+ FROM part
172
+ WHERE session_id = ?
173
+ `, id).Scan(&eventCount, &userTurns)
174
+
175
+ meta.EventCount = eventCount
176
+ meta.UserTurns = userTurns
177
+ return meta, nil
178
+ }
179
+
180
+ type toolPart struct {
181
+ Type string `json:"type"`
182
+ Tool string `json:"tool"`
183
+ CallID string `json:"callID"`
184
+ State struct {
185
+ Status string `json:"status"`
186
+ Input map[string]any `json:"input"`
187
+ Output any `json:"output"`
188
+ } `json:"state"`
189
+ Title string `json:"title"`
190
+ Time struct {
191
+ Start int64 `json:"start"`
192
+ End int64 `json:"end"`
193
+ } `json:"time"`
194
+ }
195
+
196
+ type textPart struct {
197
+ Type string `json:"type"`
198
+ Text string `json:"text"`
199
+ }
200
+
201
+ func (a Adapter) Parse(path string) (*model.Trace, error) {
202
+ id := filepath.Base(path)
203
+ db, err := a.openDB()
204
+ if err != nil {
205
+ return nil, err
206
+ }
207
+ defer db.Close()
208
+
209
+ var (
210
+ title string
211
+ directory string
212
+ timeCreated int64
213
+ timeUpdated int64
214
+ )
215
+ err = db.QueryRow(`
216
+ SELECT title, directory, time_created, time_updated
217
+ FROM session
218
+ WHERE id = ?
219
+ `, id).Scan(&title, &directory, &timeCreated, &timeUpdated)
220
+ if err != nil {
221
+ return nil, err
222
+ }
223
+
224
+ startedAt := time.UnixMilli(timeCreated).UTC().Format(time.RFC3339)
225
+ endedAt := time.UnixMilli(timeUpdated).UTC().Format(time.RFC3339)
226
+
227
+ trace := &model.Trace{
228
+ Version: 1,
229
+ Session: model.TraceSession{
230
+ ID: id,
231
+ Harness: "opencode",
232
+ Path: path,
233
+ Title: title,
234
+ Cwd: directory,
235
+ StartedAt: startedAt,
236
+ EndedAt: endedAt,
237
+ },
238
+ Events: []model.Event{},
239
+ Marks: []model.Mark{},
240
+ }
241
+
242
+ rows, err := db.Query(`
243
+ SELECT data, time_created
244
+ FROM part
245
+ WHERE session_id = ?
246
+ ORDER BY time_created ASC, id ASC
247
+ `, id)
248
+ if err != nil {
249
+ return trace, nil
250
+ }
251
+ defer rows.Close()
252
+
253
+ for rows.Next() {
254
+ var rawData string
255
+ var partTime int64
256
+ if err := rows.Scan(&rawData, &partTime); err != nil {
257
+ continue
258
+ }
259
+
260
+ var tp toolPart
261
+ if err := json.Unmarshal([]byte(rawData), &tp); err == nil && tp.Type == "tool" {
262
+ ts := time.UnixMilli(partTime).UTC().Format(time.RFC3339)
263
+ call := adapter.ToolCall{
264
+ ID: tp.CallID,
265
+ Name: tp.Tool,
266
+ Input: tp.State.Input,
267
+ Timestamp: ts,
268
+ }
269
+ outStr := ""
270
+ if s, ok := tp.State.Output.(string); ok {
271
+ outStr = s
272
+ } else if tp.State.Output != nil {
273
+ b, _ := json.Marshal(tp.State.Output)
274
+ outStr = string(b)
275
+ }
276
+ result := adapter.ToolResult{
277
+ Content: outStr,
278
+ OutcomeKnown: tp.State.Status != "",
279
+ IsError: tp.State.Status == "error",
280
+ }
281
+ event := adapter.BuildEvent(trace, call, result)
282
+ trace.Events = append(trace.Events, event)
283
+ continue
284
+ }
285
+
286
+ var txt textPart
287
+ if err := json.Unmarshal([]byte(rawData), &txt); err == nil && txt.Type == "text" && txt.Text != "" {
288
+ if !adapter.InjectedUserMessage(txt.Text) {
289
+ trace.Marks = append(trace.Marks, model.Mark{
290
+ Seq: len(trace.Events),
291
+ Type: "user-message",
292
+ Note: adapter.UserMessageNote(txt.Text),
293
+ })
294
+ }
295
+ }
296
+ }
297
+
298
+ for i := range trace.Events {
299
+ trace.Events[i].Seq = i
300
+ }
301
+ trace.Session.EventCount = len(trace.Events)
302
+ trace.Stats = model.ComputeStats(trace, 0, model.ObservabilityExact)
303
+ return trace, nil
304
+ }
@@ -26,7 +26,7 @@ func TestFreshScanPicksUpCodexTitleIndexChange(t *testing.T) {
26
26
  }
27
27
  writeIndex("Title One")
28
28
 
29
- s := New(Config{ClaudeDir: filepath.Join(t.TempDir(), "claude"), CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi")})
29
+ s := New(Config{ClaudeDir: filepath.Join(t.TempDir(), "claude"), CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi"), AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode")})
30
30
  initial := requestSessions(t, s, "/api/sessions")
31
31
  if len(initial) != 1 || initial[0].Title != "Title One" {
32
32
  t.Fatalf("initial sessions = %#v", initial)
@@ -26,6 +26,7 @@ import (
26
26
  "github.com/hybridlabor-api/bdb-synapse/internal/adapter/claudecode"
27
27
  "github.com/hybridlabor-api/bdb-synapse/internal/adapter/codex"
28
28
  "github.com/hybridlabor-api/bdb-synapse/internal/adapter/agy"
29
+ "github.com/hybridlabor-api/bdb-synapse/internal/adapter/opencode"
29
30
  "github.com/hybridlabor-api/bdb-synapse/internal/adapter/pi"
30
31
  "github.com/hybridlabor-api/bdb-synapse/internal/citymap"
31
32
  "github.com/hybridlabor-api/bdb-synapse/internal/judge"
@@ -41,6 +42,7 @@ type Config struct {
41
42
  CodexDir string
42
43
  PiDir string
43
44
  AgyDir string
45
+ OpencodeDir string
44
46
  OpenSession string
45
47
  Dev bool
46
48
  RepoRoot string
@@ -122,7 +124,7 @@ const (
122
124
  func New(cfg Config) *Server {
123
125
  s := &Server{
124
126
  cfg: cfg,
125
- adapters: []adapter.Source{claudecode.Adapter{Dir: cfg.ClaudeDir}, codex.Adapter{Dir: cfg.CodexDir}, pi.Adapter{Dir: cfg.PiDir}, agy.Adapter{Dir: cfg.AgyDir}},
127
+ adapters: []adapter.Source{claudecode.Adapter{Dir: cfg.ClaudeDir}, codex.Adapter{Dir: cfg.CodexDir}, pi.Adapter{Dir: cfg.PiDir}, agy.Adapter{Dir: cfg.AgyDir}, opencode.Adapter{Dir: cfg.OpencodeDir}},
126
128
  agentGraphs: map[string]agentGraphCacheEntry{},
127
129
  agentGraphLoads: map[string]*inflightAgentGraph{},
128
130
  summaries: map[string]summaryCacheEntry{},
@@ -540,7 +542,14 @@ func (s *Server) scanSessions() ([]model.SessionMeta, error) {
540
542
  }
541
543
  seen := map[string]bool{}
542
544
  var files []sessionFile
545
+ var directSessions []model.SessionMeta
543
546
  for _, source := range s.adapters {
547
+ if source.Harness() == "opencode" {
548
+ if list, err := source.ListSessions(); err == nil {
549
+ directSessions = append(directSessions, list...)
550
+ }
551
+ continue
552
+ }
544
553
  dir := source.SessionDir()
545
554
  if dir == "" {
546
555
  continue
@@ -558,6 +567,11 @@ func (s *Server) scanSessions() ([]model.SessionMeta, error) {
558
567
  if filepath.Ext(path) != ".jsonl" {
559
568
  return nil
560
569
  }
570
+ if source.Harness() == "agy" {
571
+ if filepath.Base(path) != "transcript.jsonl" || strings.Contains(path, "/chunks/") {
572
+ return nil
573
+ }
574
+ }
561
575
  info, err := entry.Info()
562
576
  if err != nil {
563
577
  return nil
@@ -605,8 +619,8 @@ func (s *Server) scanSessions() ([]model.SessionMeta, error) {
605
619
  }
606
620
  }
607
621
 
608
- catalog := make(map[string]model.SessionMeta, len(files))
609
- sessions := make([]model.SessionMeta, 0, len(files))
622
+ catalog := make(map[string]model.SessionMeta, len(files)+len(directSessions))
623
+ sessions := make([]model.SessionMeta, 0, len(files)+len(directSessions))
610
624
  for _, meta := range results {
611
625
  if meta == nil {
612
626
  continue
@@ -616,6 +630,15 @@ func (s *Server) scanSessions() ([]model.SessionMeta, error) {
616
630
  sessions = append(sessions, *meta)
617
631
  }
618
632
  }
633
+ for _, meta := range directSessions {
634
+ catalog[meta.Key] = meta
635
+ if !meta.Auxiliary {
636
+ sessions = append(sessions, meta)
637
+ }
638
+ }
639
+ sort.Slice(sessions, func(i, j int) bool {
640
+ return sessions[i].EndedAt > sessions[j].EndedAt
641
+ })
619
642
  s.mu.Lock()
620
643
  s.sessionCatalog = catalog
621
644
  s.mu.Unlock()
@@ -967,6 +990,9 @@ func (s *Server) findSession(selector string) (model.SessionMeta, error) {
967
990
  func fingerprintFile(path string) (fileFingerprint, error) {
968
991
  info, err := os.Stat(path)
969
992
  if err != nil {
993
+ if dbInfo, dbErr := os.Stat(filepath.Join(filepath.Dir(path), "opencode.db")); dbErr == nil {
994
+ return fileFingerprint{size: dbInfo.Size(), modTime: dbInfo.ModTime()}, nil
995
+ }
970
996
  return fileFingerprint{}, err
971
997
  }
972
998
  return fileFingerprint{size: info.Size(), modTime: info.ModTime()}, nil
@@ -28,7 +28,7 @@ func TestTraceStillLoadsWhenSessionCwdIsMissing(t *testing.T) {
28
28
  `{"type":"user","timestamp":"2026-07-09T00:00:02Z","sessionId":"missingcwd","cwd":`+quoteJSON(missingRoot)+`,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"r1","content":"ok","is_error":false}]}}`,
29
29
  )
30
30
 
31
- s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi")})
31
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi"), AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode")})
32
32
  traceResp := httptest.NewRecorder()
33
33
  s.handleSessionResource(traceResp, httptest.NewRequest(http.MethodGet, "/api/sessions/missingcwd/trace", nil))
34
34
  if traceResp.Code != http.StatusOK {
@@ -79,7 +79,7 @@ func TestOpenSessionUsesUniqueKeyAndFindSessionAcceptsBasename(t *testing.T) {
79
79
  `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"internal-id","cwd":"/tmp","message":{"role":"user","content":"hello"}}`,
80
80
  )
81
81
 
82
- s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi"), OpenSession: session})
82
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi"), AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode"), OpenSession: session})
83
83
  wantKey := adapter.SessionKey("claude-code", session)
84
84
  if got := s.openSessionKey(); got != wantKey {
85
85
  t.Fatalf("openSessionKey = %q, want %q", got, wantKey)
@@ -110,7 +110,7 @@ func TestDuplicateSessionIDsUseDistinctKeysAndCaches(t *testing.T) {
110
110
  })
111
111
  }
112
112
 
113
- s := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi")})
113
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi"), AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode")})
114
114
  sessions, err := s.listSessions()
115
115
  if err != nil {
116
116
  t.Fatal(err)
@@ -158,7 +158,7 @@ func TestTraceCacheReloadsWhenActiveSessionGrows(t *testing.T) {
158
158
  `{"type":"user","timestamp":"2026-07-09T00:00:02Z","sessionId":"growing","cwd":`+quoteJSON(repoRoot)+`,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"r1","content":"ok","is_error":false}]}}`,
159
159
  )
160
160
 
161
- s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi")})
161
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi"), AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode")})
162
162
  firstTrace, firstCity, err := s.traceAndMap("growing")
163
163
  if err != nil {
164
164
  t.Fatal(err)
@@ -192,7 +192,7 @@ func TestSessionsFreshBypassesListTTL(t *testing.T) {
192
192
  writeServerSession(t, filepath.Join(claudeDir, "first.jsonl"),
193
193
  `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"first","cwd":"/tmp","message":{"role":"user","content":"hello"}}`,
194
194
  )
195
- s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi")})
195
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi"), AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode")})
196
196
 
197
197
  initial := requestSessions(t, s, "/api/sessions")
198
198
  if len(initial) != 1 {
@@ -217,7 +217,7 @@ func TestConcurrentFreshGenerationReusesCompletedScan(t *testing.T) {
217
217
  writeServerSession(t, filepath.Join(claudeDir, "session.jsonl"),
218
218
  `{"type":"user","timestamp":"2026-07-09T00:00:00Z","sessionId":"session","cwd":"/tmp","message":{"role":"user","content":"hello"}}`,
219
219
  )
220
- s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi")})
220
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi"), AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode")})
221
221
 
222
222
  // Two fresh callers that enter before either scan completes observe the
223
223
  // same generation. The second must reuse the first completed scan.
@@ -389,7 +389,7 @@ func TestServerLoadsCodexSessions(t *testing.T) {
389
389
  },
390
390
  )
391
391
 
392
- s := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi")})
392
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi"), AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode")})
393
393
  sessionsResp := httptest.NewRecorder()
394
394
  s.handleSessions(sessionsResp, httptest.NewRequest(http.MethodGet, "/api/sessions", nil))
395
395
  if sessionsResp.Code != http.StatusOK {
@@ -478,7 +478,7 @@ func TestCodexAgentAPIsDoNotDeriveChildAcrossDuplicateRootIDs(t *testing.T) {
478
478
  },
479
479
  })
480
480
 
481
- s := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi")})
481
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi"), AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode")})
482
482
  sessions := requestSessions(t, s, "/api/sessions")
483
483
  if len(sessions) != 2 {
484
484
  t.Fatalf("visible sessions = %#v, want duplicate-ID roots only", sessions)
@@ -537,7 +537,7 @@ func TestServerRetainsClaudeSubagentInCatalog(t *testing.T) {
537
537
  `{"type":"user","timestamp":"2026-07-09T00:00:01Z","sessionId":"root-id","agentId":"child","isSidechain":true,"cwd":"/tmp","message":{"role":"user","content":"internal"}}`,
538
538
  )
539
539
 
540
- s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi")})
540
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi"), AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode")})
541
541
  sessions := requestSessions(t, s, "/api/sessions")
542
542
  if len(sessions) != 1 || sessions[0].ID != "root-id" {
543
543
  t.Fatalf("sessions = %#v", sessions)
@@ -857,7 +857,7 @@ func TestFreshScanReloadsClaudeSidecarMetadata(t *testing.T) {
857
857
  `{"type":"user","timestamp":"2026-07-09T00:00:01Z","sessionId":"root-id","agentId":"child","isSidechain":true,"cwd":"/tmp","message":{"role":"user","content":"internal"}}`,
858
858
  )
859
859
 
860
- s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi")})
860
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: filepath.Join(t.TempDir(), "pi"), AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode")})
861
861
  requestSessions(t, s, "/api/sessions")
862
862
  childKey := adapter.SessionKey("claude-code", child)
863
863
  if got := s.sessionCatalog[childKey].Agent.Role; got != "" {
@@ -914,7 +914,7 @@ func TestServerSkipsCodexSubagentSessions(t *testing.T) {
914
914
  },
915
915
  )
916
916
 
917
- s := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi")})
917
+ s := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi"), AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode")})
918
918
  sessions, err := s.scanSessions()
919
919
  if err != nil {
920
920
  t.Fatal(err)
@@ -925,7 +925,7 @@ func TestServerSkipsCodexSubagentSessions(t *testing.T) {
925
925
 
926
926
  // Explicitly opening an auxiliary rollout keeps it available internally
927
927
  // without leaking it into the visible session list.
928
- explicit := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi"), OpenSession: subagentSession})
928
+ explicit := New(Config{ClaudeDir: claudeDir, CodexDir: codexDir, PiDir: filepath.Join(t.TempDir(), "pi"), AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode"), OpenSession: subagentSession})
929
929
  explicitSessions, err := explicit.listSessions()
930
930
  if err != nil {
931
931
  t.Fatal(err)
@@ -1362,7 +1362,7 @@ func TestSessionAgentsForSourceWithoutGraphSupport(t *testing.T) {
1362
1362
  t.Fatal(err)
1363
1363
  }
1364
1364
 
1365
- s := New(Config{ClaudeDir: filepath.Join(t.TempDir(), "claude"), CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: piRoot})
1365
+ s := New(Config{ClaudeDir: filepath.Join(t.TempDir(), "claude"), CodexDir: filepath.Join(t.TempDir(), "codex"), PiDir: piRoot, AgyDir: filepath.Join(t.TempDir(), "agy"), OpencodeDir: filepath.Join(t.TempDir(), "opencode")})
1366
1366
  if sessions := requestSessions(t, s, "/api/sessions"); len(sessions) != 1 {
1367
1367
  t.Fatalf("sessions = %#v", sessions)
1368
1368
  }