Skip to main content

txcript/harness/
cowork.rs

1//! Cowork — the Claude desktop app's local agent mode:
2//! `<app data>/Claude/local-agent-mode-sessions/<org>/<account>/local_<uuid>.json`.
3//!
4//! Cowork runs Claude Code headlessly (through the Agent SDK) with a private
5//! `CLAUDE_CONFIG_DIR` per task, and keeps its own session record next to it.
6//! One session is therefore three carriers:
7//!
8//! - `local_<id>.json` — the app's session record (**header** here): the
9//!   `local_…` session id, `cliSessionId` (the Claude Code session under it),
10//!   `cwd`, `createdAt`/`lastActivityAt` (epoch ms), `model`, `title`,
11//!   `isArchived`, the rendered system prompt, MCP/plugin settings. The app
12//!   lists sessions by reading these; a record its validator rejects is
13//!   silently skipped. Required: `sessionId`, `processName`, `cwd`,
14//!   `createdAt`, `lastActivityAt`.
15//! - `local_<id>/.claude/projects/<encoded-cwd>/<cliSessionId>.jsonl` — the
16//!   conversation, in Claude Code's own JSONL (**transcript**). The app
17//!   locates it by `cliSessionId` under any project slug and resumes it with
18//!   the CLI; txcript reuses the `claude_code` codec on it verbatim, so every
19//!   Claude Code record kind, tool, and quirk applies unchanged. Cowork-only
20//!   records (`queue-operation`, `last-prompt`, `attachment`, `ai-title`)
21//!   ride through as [`Record::Other`].
22//! - `local_<id>/audit.jsonl` — the Agent SDK stream as the app saw it
23//!   (**audit**): an append-only, HMAC-chained log whose key lives in
24//!   Electron's `safeStorage`. It is carried verbatim for native round trips
25//!   and never regenerated — the app tolerates its absence (unsigned and
26//!   missing logs are both valid states for it).
27//!
28//! Not carried: the per-task `.claude/.claude.json` config cache and its
29//! backups, `uploads/` and `outputs/` (the user's files), subagent
30//! transcripts under `<cliSessionId>/subagents/`, and `debug/`.
31//!
32//! `to_common` is the Claude Code mapping over the transcript; the header
33//! supplies `Meta` (id, title, cwd, model, start time). `from_common`
34//! regenerates the transcript through the Claude Code codec under a
35//! deterministic `cliSessionId` (`UUIDv5` of the session id) and a header
36//! carrying every field the app requires; the session id is given a `local_`
37//! prefix when it lacks one, which is the one way `Meta` can change through
38//! Common. The audit log is left empty. Everything `claude_code` cannot
39//! represent (its module docs) is lost here too.
40
41use 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/// The Cowork harness marker.
57#[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// ── native records ─────────────────────────────────────────────────────
66
67/// Faithful in-memory representation of one Cowork session: its app record,
68/// its Claude Code transcript, and its audit log.
69#[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/// The app's session record (`local_<id>.json`). Only the fields the codec
79/// reads or writes are typed; the rest (system prompt, MCP configuration,
80/// permission grants, …) flatten into `extra` untouched.
81#[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    /// Epoch milliseconds.
100    #[serde(rename = "createdAt", default, skip_serializing_if = "Option::is_none")]
101    pub created_at: Option<Number>,
102    /// Epoch milliseconds.
103    #[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
123// ── codec ──────────────────────────────────────────────────────────────
124
125impl 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
154/// The native session for a canonical conversation, plus the `Meta` it
155/// answers to (the session id gains Cowork's `local_` prefix if missing).
156fn 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    // The transcript is Claude Code's, stamped with the CLI session id —
161    // that is the id the app resumes by, not the `local_…` one.
162    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                // Only text opens a prompt in the app's record.
179                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    // The app runs the CLI on the host against `cwd` (rather than inside its
189    // Linux VM, whose cwd would be `/sessions/<processName>`).
190    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        // Required by the app's record validator; natively the VM/process
198        // name. Derived, so conversion stays a pure function of the input.
199        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
221/// Cowork session ids are `local_<uuid>`; the app lists only files with that
222/// prefix. An empty id mints a fresh one.
223fn 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
233/// Deterministic Claude Code session id for a Cowork session, so
234/// `from_common` is a pure function of the transcript.
235fn 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
243// ── metadata ───────────────────────────────────────────────────────────
244
245/// `Meta` from the session: the header is authoritative for what it carries
246/// (id, start time, cwd, title, model); the transcript supplies the rest
247/// (CLI version, git branch) and backfills any header gap.
248fn 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/// Epoch milliseconds as written by `Date.now()`; a fractional value is
273/// truncated to the millisecond.
274#[allow(clippy::cast_possible_truncation)] // guarded: finite, in i64 range
275fn 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// ── store ──────────────────────────────────────────────────────────────
285
286/// Reads and writes Cowork sessions under the app's
287/// `local-agent-mode-sessions` directory.
288///
289/// The directory holds one `<org-uuid>/<account-uuid>/` tree per signed-in
290/// account; discovery walks them all, and `save` writes into the most
291/// recently active one.
292#[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    /// The app's sessions root: `$COWORK_SESSIONS_DIR` when set, else
303    /// `local-agent-mode-sessions` under the Claude desktop app's data
304    /// directory (`~/Library/Application Support/Claude` on macOS,
305    /// `%APPDATA%\Claude` on Windows, `~/.config/Claude` elsewhere).
306    #[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    /// Every `<org>/<account>/` directory under the root. Both levels are
326    /// UUID-named; that is what separates account trees from the app's
327    /// other state (`skills-plugin/`, …) at the same level.
328    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    /// The account tree `save` writes into: the one whose newest session
352    /// record is most recent (the account the app is using), else the only
353    /// one there is.
354    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    /// The transcript file for a session record, if the header names a CLI
377    /// session and the file exists under any project slug.
378    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
390/// The session records in one directory, plus those of its `agent/`
391/// subdirectory (where the app keeps its agent-type sessions).
392fn 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
408/// Whether a path is named like an app session record: `local_*.json`.
409fn 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
416/// The session's storage directory: the record's path without `.json`.
417fn 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        // No root (Cowork never ran here) means no sessions.
439        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                // A record the app would reject, or that fails to read, is
448                // skipped, not fatal. Discovery meta comes from the header
449                // plus Claude Code's shallow scan of the transcript — the
450                // same fields a full load would yield.
451                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        // The CLI session the app will resume: the header's, or the one the
511        // codec would have derived.
512        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        // The app's cwd for host sessions; also where it expects uploads.
530        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    /// Removes the session record and its storage directory. Guarded on
548    /// shape and containment: the reference must be a `local_*.json` record
549    /// resolving to `<root>/<org>/<account>/[agent/]<id>.json`, so a stale or
550    /// foreign reference never removes an unrelated tree.
551    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            // The transcript is what grows; the record changes with it, so
585            // it stands in when the transcript can't be found.
586            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}