1use std::collections::HashMap;
42use std::fs;
43use std::path::{Path, PathBuf};
44
45use chrono::{DateTime, Utc};
46use serde::{Deserialize, Serialize};
47use serde_json::{Map, Number, Value};
48use uuid::Uuid;
49
50use crate::common::{Block, Message, Meta, Role};
51use crate::error::{Error, Result};
52use crate::harness::claude_code::{self, Record};
53use crate::harness::jsonl;
54use crate::transcript::{Codec, Common, Discovered, Harness, Saved, Store, TextCodec, Transcript};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct Cowork;
59
60impl Harness for Cowork {
61 const NAME: &'static str = "cowork";
62 type Body = CoworkSession;
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct CoworkSession {
71 pub header: Header,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub transcript: Vec<Record>,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub audit: Vec<Value>,
76}
77
78#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
82pub struct Header {
83 #[serde(rename = "sessionId", default, skip_serializing_if = "Option::is_none")]
84 pub session_id: Option<String>,
85 #[serde(
86 rename = "cliSessionId",
87 default,
88 skip_serializing_if = "Option::is_none"
89 )]
90 pub cli_session_id: Option<String>,
91 #[serde(
92 rename = "processName",
93 default,
94 skip_serializing_if = "Option::is_none"
95 )]
96 pub process_name: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub cwd: Option<String>,
99 #[serde(rename = "createdAt", default, skip_serializing_if = "Option::is_none")]
101 pub created_at: Option<Number>,
102 #[serde(
104 rename = "lastActivityAt",
105 default,
106 skip_serializing_if = "Option::is_none"
107 )]
108 pub last_activity_at: Option<Number>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub model: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub title: Option<String>,
113 #[serde(
114 rename = "isArchived",
115 default,
116 skip_serializing_if = "Option::is_none"
117 )]
118 pub is_archived: Option<bool>,
119 #[serde(flatten)]
120 pub extra: Map<String, Value>,
121}
122
123impl Codec for Cowork {
126 fn to_common(transcript: &Transcript<Self>) -> Result<Transcript<Common>> {
127 Ok(Transcript::new(
128 transcript.meta.clone(),
129 claude_code::records_to_messages(
130 &transcript.body.transcript,
131 transcript.meta.timestamp,
132 ),
133 ))
134 }
135
136 fn from_common(transcript: &Transcript<Common>) -> Result<Transcript<Self>> {
137 let (meta, body) = body_from_messages(&transcript.meta, &transcript.body);
138 Ok(Transcript::new(meta, body))
139 }
140}
141
142impl TextCodec for Cowork {
143 fn from_text(text: &str) -> Result<Transcript<Self>> {
144 let body: CoworkSession = serde_json::from_str(text)?;
145 let meta = meta_from_body(&body);
146 Ok(Transcript::new(meta, body))
147 }
148
149 fn to_text(transcript: &Transcript<Self>) -> Result<String> {
150 Ok(serde_json::to_string_pretty(&transcript.body)?)
151 }
152}
153
154fn body_from_messages(meta: &Meta, messages: &[Message]) -> (Meta, CoworkSession) {
157 let session_id = session_id_for(&meta.id);
158 let cli_session_id = cli_session_id_for(&session_id);
159
160 let mut cli_meta = meta.clone();
163 cli_meta.id.clone_from(&cli_session_id);
164 let transcript = claude_code::messages_to_records(&cli_meta, messages);
165
166 let created_at = meta.timestamp.timestamp_millis();
167 let last_activity_at = messages
168 .iter()
169 .map(|m| m.timestamp.timestamp_millis())
170 .max()
171 .map_or(created_at, |last| last.max(created_at));
172 let initial_message = messages
173 .iter()
174 .find(|m| m.role == Role::User)
175 .and_then(|m| {
176 m.content.iter().find_map(|block| match block {
177 Block::Text { text } => Some(text.clone()),
178 Block::Thinking { .. }
180 | Block::ToolUse { .. }
181 | Block::ToolResult { .. }
182 | Block::Image { .. }
183 | Block::Artifact { .. } => None,
184 })
185 });
186
187 let mut extra = Map::new();
188 extra.insert("hostLoopMode".into(), Value::Bool(true));
191 if let Some(text) = initial_message {
192 extra.insert("initialMessage".into(), Value::String(text));
193 }
194 let header = Header {
195 session_id: Some(session_id.clone()),
196 cli_session_id: Some(cli_session_id.clone()),
197 process_name: Some(format!("txcript-{}", &cli_session_id[..8])),
200 cwd: Some(meta.cwd.clone().unwrap_or_default()),
201 created_at: Some(created_at.into()),
202 last_activity_at: Some(last_activity_at.into()),
203 model: meta.model.clone(),
204 title: meta.title.clone(),
205 is_archived: Some(false),
206 extra,
207 };
208
209 let mut out_meta = meta.clone();
210 out_meta.id = session_id;
211 (
212 out_meta,
213 CoworkSession {
214 header,
215 transcript,
216 audit: Vec::new(),
217 },
218 )
219}
220
221fn session_id_for(id: &str) -> String {
224 if id.is_empty() {
225 format!("local_{}", Uuid::new_v4())
226 } else if id.starts_with("local_") {
227 id.to_string()
228 } else {
229 format!("local_{id}")
230 }
231}
232
233fn cli_session_id_for(session_id: &str) -> String {
236 const NS: Uuid = Uuid::from_bytes([
237 0x3c, 0x7a, 0xe1, 0x52, 0x8b, 0x4d, 0x4e, 0x0f, 0x9a, 0x61, 0x2d, 0xc8, 0x7f, 0x15, 0xb9,
238 0x04,
239 ]);
240 Uuid::new_v5(&NS, session_id.as_bytes()).to_string()
241}
242
243fn meta_from_body(body: &CoworkSession) -> Meta {
249 meta_from_parts(
250 &body.header,
251 claude_code::meta_from_records(&body.transcript),
252 )
253}
254
255fn meta_from_parts(header: &Header, transcript: Meta) -> Meta {
256 let non_empty = |s: &Option<String>| s.clone().filter(|v| !v.trim().is_empty());
257 Meta {
258 id: header.session_id.clone().unwrap_or_default(),
259 timestamp: header
260 .created_at
261 .as_ref()
262 .and_then(epoch_millis)
263 .unwrap_or(transcript.timestamp),
264 cwd: non_empty(&header.cwd).or(transcript.cwd),
265 git_branch: transcript.git_branch,
266 title: non_empty(&header.title).or(transcript.title),
267 cli_version: transcript.cli_version,
268 model: non_empty(&header.model).or(transcript.model),
269 }
270}
271
272#[allow(clippy::cast_possible_truncation)] fn epoch_millis(n: &Number) -> Option<DateTime<Utc>> {
276 let ms = n.as_i64().or_else(|| {
277 n.as_f64()
278 .filter(|f| f.is_finite() && f.abs() < 9.0e15)
279 .map(|f| f as i64)
280 })?;
281 DateTime::from_timestamp_millis(ms)
282}
283
284#[derive(Debug, Clone)]
293pub struct CoworkStore {
294 pub root: PathBuf,
295}
296
297impl CoworkStore {
298 pub fn new(root: impl Into<PathBuf>) -> Self {
299 Self { root: root.into() }
300 }
301
302 #[must_use]
307 pub fn default_root() -> Option<Self> {
308 if let Some(dir) = std::env::var_os("COWORK_SESSIONS_DIR").filter(|v| !v.is_empty()) {
309 return Some(Self::new(PathBuf::from(dir)));
310 }
311 let home = super::home_dir()?;
312 let app_data = if cfg!(target_os = "macos") {
313 home.join("Library/Application Support/Claude")
314 } else if cfg!(windows) {
315 std::env::var_os("APPDATA")
316 .filter(|v| !v.is_empty())
317 .map_or_else(|| home.join("AppData").join("Roaming"), PathBuf::from)
318 .join("Claude")
319 } else {
320 home.join(".config/Claude")
321 };
322 Some(Self::new(app_data.join("local-agent-mode-sessions")))
323 }
324
325 fn account_dirs(&self) -> Vec<PathBuf> {
329 let uuid_dirs = |dir: &Path| -> Vec<PathBuf> {
330 fs::read_dir(dir)
331 .into_iter()
332 .flatten()
333 .flatten()
334 .map(|e| e.path())
335 .filter(|p| {
336 p.is_dir()
337 && p.file_name()
338 .and_then(|n| n.to_str())
339 .is_some_and(|n| Uuid::parse_str(n).is_ok())
340 })
341 .collect()
342 };
343 let mut out: Vec<PathBuf> = uuid_dirs(&self.root)
344 .iter()
345 .flat_map(|org| uuid_dirs(org))
346 .collect();
347 out.sort();
348 out
349 }
350
351 fn active_account_dir(&self) -> Result<PathBuf> {
355 let newest_record = |dir: &Path| {
356 session_files(dir)
357 .iter()
358 .filter_map(|p| fs::metadata(p).and_then(|m| m.modified()).ok())
359 .max()
360 };
361 self.account_dirs()
362 .into_iter()
363 .map(|dir| (newest_record(&dir), dir))
364 .max()
365 .map(|(_, dir)| dir)
366 .ok_or_else(|| Error::Unconvertible {
367 harness: Cowork::NAME,
368 detail: format!(
369 "no Cowork account directory under {}; open Cowork once so the app \
370 creates its <org>/<account> tree",
371 self.root.display()
372 ),
373 })
374 }
375
376 fn transcript_path(session_dir: &Path, header: &Header) -> Option<PathBuf> {
379 let cli = header.cli_session_id.as_deref()?;
380 super::checked_id_component(Cowork::NAME, cli).ok()?;
381 let projects = session_dir.join(".claude").join("projects");
382 fs::read_dir(projects)
383 .ok()?
384 .flatten()
385 .map(|slug| slug.path().join(format!("{cli}.jsonl")))
386 .find(|p| p.is_file())
387 }
388}
389
390fn session_files(dir: &Path) -> Vec<PathBuf> {
393 let records = |dir: &Path| -> Vec<PathBuf> {
394 fs::read_dir(dir)
395 .into_iter()
396 .flatten()
397 .flatten()
398 .map(|e| e.path())
399 .filter(|p| p.is_file() && is_session_record(p))
400 .collect()
401 };
402 let mut out = records(dir);
403 out.extend(records(&dir.join("agent")));
404 out.sort();
405 out
406}
407
408fn is_session_record(path: &Path) -> bool {
410 path.file_stem()
411 .and_then(|n| n.to_str())
412 .is_some_and(|n| n.starts_with("local_"))
413 && path.extension().is_some_and(|e| e == "json")
414}
415
416fn session_dir(record: &Path) -> PathBuf {
418 record.with_extension("")
419}
420
421fn read_header(path: &Path) -> Result<Header> {
422 let text = fs::read_to_string(path)?;
423 let header: Header = serde_json::from_str(&text)?;
424 if header.session_id.is_none() {
425 return Err(Error::Malformed {
426 harness: Cowork::NAME,
427 detail: format!("{} carries no sessionId", path.display()),
428 });
429 }
430 Ok(header)
431}
432
433impl Store for CoworkStore {
434 type H = Cowork;
435 type Ref = PathBuf;
436
437 fn discover(&self) -> Result<Vec<Discovered<PathBuf>>> {
438 if !self.root.is_dir() {
440 return Ok(Vec::new());
441 }
442 Ok(self
443 .account_dirs()
444 .iter()
445 .flat_map(|account| session_files(account))
446 .filter_map(|path| {
447 let header = read_header(&path).ok()?;
452 let transcript_meta = Self::transcript_path(&session_dir(&path), &header)
453 .and_then(|p| fs::read_to_string(p).ok())
454 .map_or_else(
455 || claude_code::meta_from_records(&[]),
456 |text| claude_code::meta_from_text(&text),
457 );
458 let mut meta = meta_from_parts(&header, transcript_meta);
459 if meta.id.is_empty() {
460 meta.id = jsonl::file_id(&path);
461 }
462 Some(Discovered {
463 meta,
464 reference: path,
465 })
466 })
467 .collect())
468 }
469
470 fn load(&self, reference: &PathBuf) -> Result<Transcript<Cowork>> {
471 let header = read_header(reference)?;
472 let dir = session_dir(reference);
473 let transcript = Self::transcript_path(&dir, &header)
474 .and_then(|p| fs::read_to_string(p).ok())
475 .map(|text| {
476 text.lines()
477 .filter(|line| !line.trim().is_empty())
478 .filter_map(claude_code::record_from_line)
479 .collect()
480 })
481 .unwrap_or_default();
482 let audit = fs::read_to_string(dir.join("audit.jsonl"))
483 .map(|text| jsonl::parse(&text))
484 .unwrap_or_default();
485 let body = CoworkSession {
486 header,
487 transcript,
488 audit,
489 };
490 let mut meta = meta_from_body(&body);
491 if meta.id.is_empty() {
492 meta.id = jsonl::file_id(reference);
493 }
494 Ok(Transcript::new(meta, body))
495 }
496
497 fn save(&self, transcript: &Transcript<Cowork>) -> Result<Saved<PathBuf>> {
498 let body = &transcript.body;
499 let id = body
500 .header
501 .session_id
502 .clone()
503 .filter(|id| !id.is_empty())
504 .unwrap_or_else(|| transcript.meta.id.clone());
505 super::checked_id_component(Cowork::NAME, &id)?;
506 let account = self.active_account_dir()?;
507 let record = account.join(format!("{id}.json"));
508 let dir = account.join(&id);
509
510 let cli = body
513 .header
514 .cli_session_id
515 .clone()
516 .unwrap_or_else(|| cli_session_id_for(&id));
517 super::checked_id_component(Cowork::NAME, &cli)?;
518 let cwd = body
519 .header
520 .cwd
521 .clone()
522 .or_else(|| transcript.meta.cwd.clone())
523 .unwrap_or_default();
524 let project_dir = dir
525 .join(".claude")
526 .join("projects")
527 .join(claude_code::encode_project_dir(&cwd));
528 fs::create_dir_all(&project_dir)?;
529 fs::create_dir_all(dir.join("outputs"))?;
531 fs::create_dir_all(dir.join("uploads"))?;
532
533 fs::write(
534 project_dir.join(format!("{cli}.jsonl")),
535 jsonl::render(&body.transcript)?,
536 )?;
537 if !body.audit.is_empty() {
538 fs::write(dir.join("audit.jsonl"), jsonl::render(&body.audit)?)?;
539 }
540 fs::write(&record, serde_json::to_string(&body.header)?)?;
541 Ok(Saved {
542 id,
543 reference: record,
544 })
545 }
546
547 fn delete(&self, reference: &PathBuf) -> Result<()> {
552 if !(is_session_record(reference) && reference.is_file()) {
553 return Err(Error::Malformed {
554 harness: Cowork::NAME,
555 detail: format!("not a Cowork session record: {}", reference.display()),
556 });
557 }
558 let canon = reference.canonicalize()?;
559 let root = self.root.canonicalize()?;
560 let contained = canon.strip_prefix(&root).is_ok_and(|rest| {
561 let parts: Vec<_> = rest.components().collect();
562 parts.len() == 3
563 || (parts.len() == 4 && parts[2].as_os_str() == std::ffi::OsStr::new("agent"))
564 });
565 if !contained {
566 return Err(Error::Malformed {
567 harness: Cowork::NAME,
568 detail: format!(
569 "refusing to delete outside the sessions root: {}",
570 reference.display()
571 ),
572 });
573 }
574 let dir = session_dir(&canon);
575 if dir.is_dir() {
576 fs::remove_dir_all(&dir)?;
577 }
578 Ok(fs::remove_file(canon)?)
579 }
580
581 fn fingerprints(&self, refs: &[PathBuf]) -> Result<HashMap<String, String>> {
582 let mut out = HashMap::with_capacity(refs.len());
583 for record in refs {
584 let file = read_header(record)
587 .ok()
588 .and_then(|h| Self::transcript_path(&session_dir(record), &h))
589 .unwrap_or_else(|| record.clone());
590 out.insert(
591 record.to_string_lossy().into_owned(),
592 claude_code::file_fingerprint(&file),
593 );
594 }
595 Ok(out)
596 }
597}