1use super::{AttributeContext, HarnessAdapter, RegistryHints, SessionSummary, SessionTracker, SpanRetention};
37use crate::model::{Activity, Attribution, Harness, ProcNode, SpanKind, TokenUsage};
38use crate::process::RawProc;
39use rusqlite::{Connection, OpenFlags};
40use std::collections::HashSet;
41use std::path::{Path, PathBuf};
42use std::time::{Duration, SystemTime, UNIX_EPOCH};
43
44pub fn data_dir() -> Option<PathBuf> {
46 if let Some(d) = std::env::var_os("OPENCODE_DATA_DIR") {
47 return Some(PathBuf::from(d));
48 }
49 if let Some(d) = std::env::var_os("XDG_DATA_HOME") {
50 return Some(PathBuf::from(d).join("opencode"));
51 }
52 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/opencode"))
53}
54
55pub fn db_path() -> Option<PathBuf> {
57 let p = data_dir()?.join("opencode.db");
58 p.exists().then_some(p)
59}
60
61fn open_ro(db: &Path) -> rusqlite::Result<Connection> {
65 Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)
66}
67
68fn to_ms(t: SystemTime) -> i64 {
69 t.duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
70}
71
72fn from_ms(ms: i64) -> Option<SystemTime> {
73 (ms > 0).then(|| UNIX_EPOCH + Duration::from_millis(ms as u64))
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Session {
79 pub id: String,
80 pub directory: PathBuf,
81 pub created: Option<SystemTime>,
82 pub updated: Option<SystemTime>,
83}
84
85pub fn session_path(db: &Path, session_id: &str) -> PathBuf {
88 db.join(session_id)
89}
90
91pub fn session_id_of(path: &Path) -> Option<String> {
93 path.file_name().map(|f| f.to_string_lossy().into_owned())
94}
95
96pub fn recent_sessions(db: &Path, since: SystemTime) -> Vec<Session> {
98 let Ok(conn) = open_ro(db) else { return Vec::new() };
99 let sql = "SELECT id, directory, time_created, time_updated FROM session \
100 WHERE parent_id IS NULL AND time_updated >= ?1 ORDER BY time_updated DESC";
101 let Ok(mut stmt) = conn.prepare(sql) else { return Vec::new() };
102 let rows = stmt.query_map([to_ms(since)], |r| {
103 Ok(Session {
104 id: r.get::<_, String>(0)?,
105 directory: PathBuf::from(r.get::<_, String>(1)?),
106 created: from_ms(r.get::<_, i64>(2)?),
107 updated: from_ms(r.get::<_, i64>(3)?),
108 })
109 });
110 rows.map(|it| it.flatten().collect()).unwrap_or_default()
111}
112
113fn model_id(raw: &str) -> Option<String> {
116 let raw = raw.trim();
117 if raw.is_empty() {
118 return None;
119 }
120 match serde_json::from_str::<serde_json::Value>(raw) {
121 Ok(v) => v.get("id").and_then(|x| x.as_str()).map(str::to_string).or_else(|| Some(raw.to_string())),
122 Err(_) => Some(raw.to_string()),
123 }
124}
125
126#[derive(Default)]
128pub struct OpenCodeAdapter {
129 db: Option<PathBuf>,
130 recent: Vec<Session>,
131}
132
133impl OpenCodeAdapter {
134 fn db(&self) -> Option<PathBuf> {
135 self.db.clone().or_else(db_path)
136 }
137}
138
139impl HarnessAdapter for OpenCodeAdapter {
140 fn harness(&self) -> Harness {
141 Harness::OpenCode
142 }
143
144 fn rescan(&mut self, since: SystemTime) {
145 self.db = db_path();
146 self.recent = match &self.db {
147 Some(db) => recent_sessions(db, since),
148 None => Vec::new(),
149 };
150 }
151
152 fn hints(&self, _pid: u32) -> Option<RegistryHints> {
153 None
154 }
155
156 fn attribute(&self, _root: &ProcNode, _raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution) {
160 let (Some(cwd), Some(db)) = (ctx.cwd, self.db()) else { return (Vec::new(), Attribution::None) };
161 let slack = Duration::from_secs(60);
162 let mut mine: Vec<&Session> = self
163 .recent
164 .iter()
165 .filter(|s| s.directory == cwd)
166 .filter(|s| s.created.is_none_or(|c| c + slack >= ctx.proc_start))
167 .filter(|s| !ctx.attached.contains(&session_path(&db, &s.id)))
168 .collect();
169 mine.sort_by_key(|s| std::cmp::Reverse(s.updated));
170 match mine.first() {
171 Some(s) => (vec![session_path(&db, &s.id)], Attribution::CwdHeuristic),
172 None => (Vec::new(), Attribution::None),
173 }
174 }
175
176 fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf> {
177 let Some(db) = self.db() else { return Vec::new() };
178 self.recent.iter().map(|s| session_path(&db, &s.id)).filter(|p| !attached.contains(p)).collect()
179 }
180
181 fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker> {
182 Box::new(OpenCodeTranscript::new(path, spans))
183 }
184
185 fn detect(&self, _path: &Path) -> bool {
188 false
189 }
190
191 fn transcripts(&self) -> Vec<(String, PathBuf)> {
192 let Some(db) = self.db() else { return Vec::new() };
193 recent_sessions(&db, UNIX_EPOCH).into_iter().map(|s| (s.id.clone(), session_path(&db, &s.id))).collect()
194 }
195}
196
197pub struct OpenCodeTranscript {
204 db: PathBuf,
205 session_id: String,
206 virtual_path: PathBuf,
207 conn: Option<Connection>,
208 retention: SpanRetention,
209 summary: SessionSummary,
210 last_updated: Option<i64>,
212}
213
214impl OpenCodeTranscript {
215 pub fn new(virtual_path: &Path, retention: SpanRetention) -> Self {
216 let session_id = session_id_of(virtual_path).unwrap_or_default();
218 let db = virtual_path.parent().map(Path::to_path_buf).unwrap_or_default();
219 OpenCodeTranscript {
220 db,
221 session_id,
222 virtual_path: virtual_path.to_path_buf(),
223 conn: None,
224 retention,
225 summary: SessionSummary { harness: Some(Harness::OpenCode), spans: retention.log(), ..Default::default() },
226 last_updated: None,
227 }
228 }
229
230 fn conn(&mut self) -> Option<&Connection> {
231 if self.conn.is_none() {
232 self.conn = open_ro(&self.db).ok();
233 }
234 self.conn.as_ref()
235 }
236
237 fn reload(&mut self) -> rusqlite::Result<()> {
238 let retention = self.retention;
239 let id = self.session_id.clone();
240 let Some(conn) = self.conn() else { return Ok(()) };
241
242 let mut summary = SessionSummary { harness: Some(Harness::OpenCode), spans: retention.log(), ..Default::default() };
245 let mut ids: Vec<(String, bool)> = Vec::new(); {
247 let sql = "SELECT id, directory, agent, model, cost, tokens_input, tokens_output, tokens_reasoning, \
248 tokens_cache_read, tokens_cache_write, time_created, time_updated, version, parent_id \
249 FROM session WHERE id = ?1 OR parent_id = ?1 ORDER BY (parent_id IS NOT NULL), time_created";
250 let mut stmt = conn.prepare(sql)?;
251 let mut rows = stmt.query([&id])?;
252 while let Some(r) = rows.next()? {
253 let row_id: String = r.get(0)?;
254 let parent: Option<String> = r.get(13)?;
255 let is_sub = parent.is_some();
256 ids.push((row_id.clone(), is_sub));
257
258 let usage = TokenUsage {
259 input: r.get::<_, i64>(5)? as u64,
260 output: (r.get::<_, i64>(6)? + r.get::<_, i64>(7)?) as u64, cache_read: r.get::<_, i64>(8)? as u64,
262 cache_write_5m: r.get::<_, i64>(9)? as u64,
263 cache_write_1h: 0,
264 };
265 summary.usage.add(&usage);
266 summary.cost_usd += r.get::<_, f64>(4)?;
267
268 if !is_sub {
269 summary.session_id = Some(row_id.clone());
270 summary.cwd = Some(PathBuf::from(r.get::<_, String>(1)?));
271 summary.model = r.get::<_, Option<String>>(3)?.as_deref().and_then(model_id);
272 summary.harness_version = r.get::<_, Option<String>>(12)?;
273 summary.started_at = from_ms(r.get::<_, i64>(10)?);
274 summary.last_activity = from_ms(r.get::<_, i64>(11)?);
275 }
276 }
277 }
278 if ids.is_empty() {
279 self.summary = summary;
281 return Ok(());
282 }
283
284 for (sid, is_sub) in &ids {
286 let n: i64 = conn.query_row(
287 "SELECT count(*) FROM message WHERE session_id = ?1 AND json_extract(data,'$.role') = 'assistant'",
288 [sid],
289 |r| r.get(0),
290 )?;
291 summary.turns += n as u64;
292 summary.health.billable_messages += n as u64;
293 if *is_sub {
294 summary.subagent_turns += n as u64;
295 }
296 }
297 summary.health.usage_records = summary.health.billable_messages;
298 if summary.usage.total() == 0 {
299 summary.health.empty_usage_records = summary.health.usage_records;
300 }
301
302 for (sid, _is_sub) in &ids {
305 summary.tool_calls +=
306 conn.query_row("SELECT count(*) FROM part WHERE session_id = ?1 AND json_extract(data,'$.type') = 'tool'", [sid], |r| {
307 r.get::<_, i64>(0)
308 })? as u64;
309 }
310 let limit = match retention {
311 SpanRetention::All => -1,
312 SpanRetention::Recent => super::MAX_SPANS as i64,
313 };
314 let sub_ids: HashSet<&str> = ids.iter().filter(|(_, s)| *s).map(|(i, _)| i.as_str()).collect();
315 {
316 let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
317 let sql = format!(
318 "SELECT session_id, json_extract(data,'$.tool'), json_extract(data,'$.callID'), \
319 json_extract(data,'$.state.status'), json_extract(data,'$.state.time.start'), \
320 json_extract(data,'$.state.time.end') \
321 FROM part WHERE json_extract(data,'$.type') = 'tool' AND session_id IN ({placeholders}) \
322 ORDER BY time_created DESC LIMIT ?{}",
323 ids.len() + 1
324 );
325 let mut stmt = conn.prepare(&sql)?;
326 let params: Vec<&dyn rusqlite::ToSql> =
327 ids.iter().map(|(i, _)| i as &dyn rusqlite::ToSql).chain(std::iter::once(&limit as &dyn rusqlite::ToSql)).collect();
328 let mut rows = stmt.query(params.as_slice())?;
329 #[derive(Clone)]
330 struct Row {
331 name: String,
332 id: String,
333 sidechain: bool,
334 error: bool,
335 start: SystemTime,
336 end: SystemTime,
337 }
338 let mut collected: Vec<Row> = Vec::new();
339 while let Some(r) = rows.next()? {
340 let sid: String = r.get(0)?;
341 let name: String = r.get::<_, Option<String>>(1)?.unwrap_or_else(|| "tool".into());
342 let call_id: String = r.get::<_, Option<String>>(2)?.unwrap_or_default();
343 let status: Option<String> = r.get(3)?;
344 let Some(start) = r.get::<_, Option<i64>>(4)?.and_then(from_ms) else { continue };
345 let end = r.get::<_, Option<i64>>(5)?.and_then(from_ms).unwrap_or(start);
346 collected.push(Row {
347 name,
348 id: call_id,
349 sidechain: sub_ids.contains(sid.as_str()),
350 error: status.as_deref() == Some("error"),
351 start,
352 end,
353 });
354 }
355 collected.sort_by_key(|r| r.start);
357 for (i, row) in collected.into_iter().enumerate() {
358 let id = if row.id.is_empty() { format!("oc-{i}") } else { row.id };
360 summary.spans.open_kind(id.clone(), row.name, row.start, row.sidechain, SpanKind::Tool);
361 summary.spans.close(&id, row.end.max(row.start), row.error);
362 }
363 }
364
365 summary.activity = Activity::Unknown;
366 self.summary = summary;
367 Ok(())
368 }
369}
370
371impl SessionTracker for OpenCodeTranscript {
372 fn refresh(&mut self) -> anyhow::Result<bool> {
373 let id = self.session_id.clone();
374 let updated: Option<i64> =
375 self.conn().and_then(|c| c.query_row("SELECT time_updated FROM session WHERE id = ?1", [&id], |r| r.get(0)).ok());
376 if updated.is_some() && updated == self.last_updated {
378 return Ok(false);
379 }
380 self.last_updated = updated;
381 let _ = self.reload();
384 Ok(false)
385 }
386
387 fn summary(&self) -> &SessionSummary {
388 &self.summary
389 }
390
391 fn path(&self) -> &Path {
392 &self.virtual_path
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use rusqlite::Connection;
400
401 fn make_db(dir: &Path) -> PathBuf {
403 let db = dir.join("opencode.db");
404 let conn = Connection::open(&db).unwrap();
405 conn.execute_batch(
406 "CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, directory TEXT, agent TEXT, \
407 model TEXT, cost REAL DEFAULT 0, tokens_input INTEGER DEFAULT 0, tokens_output INTEGER DEFAULT 0, \
408 tokens_reasoning INTEGER DEFAULT 0, tokens_cache_read INTEGER DEFAULT 0, tokens_cache_write INTEGER DEFAULT 0, \
409 time_created INTEGER, time_updated INTEGER, version TEXT);
410 CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT);
411 CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT);",
412 )
413 .unwrap();
414 conn.execute(
416 "INSERT INTO session VALUES ('ses_parent', 'p', NULL, '/tmp/proj', 'build', \
417 '{\"id\":\"deepseek-v4-pro\",\"providerID\":\"deepseek\"}', 0.25, 1000, 200, 50, 900000, 0, 1000, 5000, '1.18.15')",
418 [],
419 )
420 .unwrap();
421 conn.execute(
422 "INSERT INTO session VALUES ('ses_child', 'p', 'ses_parent', '/tmp/proj', 'explore', \
423 '{\"id\":\"deepseek-v4-pro\"}', 0.05, 300, 40, 10, 1000, 0, 2000, 3000, '1.18.15')",
424 [],
425 )
426 .unwrap();
427 for (i, sid, role) in
429 [(1, "ses_parent", "user"), (2, "ses_parent", "assistant"), (3, "ses_parent", "assistant"), (4, "ses_child", "assistant")]
430 {
431 conn.execute(
432 "INSERT INTO message VALUES (?1, ?2, ?3, ?4)",
433 rusqlite::params![format!("m{i}"), sid, 1000 + i as i64, format!("{{\"role\":\"{role}\"}}")],
434 )
435 .unwrap();
436 }
437 let tool = |tool: &str, call: &str, status: &str, start: i64, end: i64| {
439 format!(
440 "{{\"type\":\"tool\",\"tool\":\"{tool}\",\"callID\":\"{call}\",\"state\":{{\"status\":\"{status}\",\"time\":{{\"start\":{start},\"end\":{end}}}}}}}"
441 )
442 };
443 for (i, sid, data) in [
444 (1, "ses_parent", tool("read", "c1", "completed", 1000, 1300)),
445 (2, "ses_parent", tool("bash", "c2", "error", 1400, 2400)),
446 (3, "ses_child", tool("grep", "c3", "completed", 1500, 1600)),
447 ] {
448 conn.execute("INSERT INTO part VALUES (?1, 'm', ?2, ?3, ?4)", rusqlite::params![format!("p{i}"), sid, 1000 + i as i64, data])
449 .unwrap();
450 }
451 db
452 }
453
454 #[test]
455 fn reads_a_session_folds_its_subagent_and_builds_tool_spans() {
456 let dir = std::env::temp_dir().join(format!("agent-top-oc-{}", std::process::id()));
457 let _ = std::fs::remove_dir_all(&dir);
458 std::fs::create_dir_all(&dir).unwrap();
459 let db = make_db(&dir);
460 let path = session_path(&db, "ses_parent");
461 let mut t = OpenCodeTranscript::new(&path, SpanRetention::All);
462 t.refresh().unwrap();
463 let s = t.summary();
464 assert_eq!(s.session_id.as_deref(), Some("ses_parent"));
465 assert_eq!(s.model.as_deref(), Some("deepseek-v4-pro"));
466 assert_eq!(s.cwd.as_deref(), Some(Path::new("/tmp/proj")));
467 assert_eq!(s.harness_version.as_deref(), Some("1.18.15"));
468 assert_eq!(s.usage.input, 1300);
470 assert_eq!(s.usage.output, 300);
471 assert_eq!(s.usage.cache_read, 901000);
472 assert!((s.cost_usd - 0.30).abs() < 1e-9, "{}", s.cost_usd);
474 assert_eq!(s.unpriced_tokens, 0, "OpenCode prices its own session");
475 assert_eq!(s.turns, 3, "two assistant turns in the parent, one in the subagent");
476 assert_eq!(s.subagent_turns, 1);
477 assert_eq!(s.tool_calls, 3);
478 let tools: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
479 assert_eq!(tools.len(), 3);
480 assert_eq!(tools[0].name, "read");
481 assert_eq!(tools[0].duration_ms, Some(300));
482 let bash = tools.iter().find(|sp| sp.name == "bash").unwrap();
483 assert!(bash.error);
484 let grep = tools.iter().find(|sp| sp.name == "grep").unwrap();
485 assert!(grep.sidechain, "the subagent's tool call is a sidechain");
486 assert!(!s.health.fields_unrecognised());
487 let _ = std::fs::remove_dir_all(&dir);
488 }
489
490 #[test]
491 fn recent_sessions_lists_only_top_level_and_attributes_by_directory() {
492 let dir = std::env::temp_dir().join(format!("agent-top-oc-attr-{}", std::process::id()));
493 let _ = std::fs::remove_dir_all(&dir);
494 std::fs::create_dir_all(&dir).unwrap();
495 let db = make_db(&dir);
496 let found = recent_sessions(&db, UNIX_EPOCH);
497 assert_eq!(found.len(), 1, "only the parent, not the subagent");
498 assert_eq!(found[0].id, "ses_parent");
499 assert_eq!(found[0].directory, PathBuf::from("/tmp/proj"));
500
501 let adapter = OpenCodeAdapter { db: Some(db.clone()), recent: found };
502 let ctx = AttributeContext {
503 cwd: Some(Path::new("/tmp/proj")),
504 proc_start: UNIX_EPOCH + Duration::from_secs(3),
505 now: SystemTime::now(),
506 attached: &HashSet::new(),
507 activity_timeout: Duration::from_secs(900),
508 };
509 let root = ProcNode {
510 pid: 1,
511 ppid: None,
512 name: "opencode".into(),
513 cmdline: "opencode".into(),
514 kind: crate::model::ProcKind::Agent,
515 harness: Some(Harness::OpenCode),
516 cpu_percent: 0.0,
517 rss_bytes: 0,
518 age_secs: 0,
519 cwd: None,
520 children: Vec::new(),
521 };
522 let (paths, attribution) = adapter.attribute(&root, None, &ctx);
523 assert_eq!(paths, vec![session_path(&db, "ses_parent")]);
524 assert_eq!(attribution, Attribution::CwdHeuristic);
525 let ctx2 = AttributeContext { cwd: Some(Path::new("/tmp/other")), ..ctx };
527 assert!(adapter.attribute(&root, None, &ctx2).0.is_empty());
528 let _ = std::fs::remove_dir_all(&dir);
529 }
530
531 #[test]
532 fn model_id_is_pulled_from_the_json_blob() {
533 assert_eq!(model_id(r#"{"id":"deepseek-v4-pro","providerID":"deepseek"}"#), Some("deepseek-v4-pro".into()));
534 assert_eq!(model_id("claude-fable-5-1"), Some("claude-fable-5-1".into()));
535 assert_eq!(model_id(""), None);
536 }
537}