Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ harness = false
required-features = ["search"]

[features]
default = ["opencode", "hermes", "claude_chat", "chatgpt", "search"]
# Explicit cloud Cowork reads reuse the Claude Desktop transport.
cowork_remote = ["claude_chat"]
default = ["opencode", "hermes", "claude_chat", "cowork_remote", "chatgpt", "search"]
# The OpenCode harness store reads OpenCode's SQLite database. The OpenCode
# codec itself is always available; only the on-disk store needs rusqlite.
opencode = ["dep:rusqlite"]
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,11 @@ Each name links to its format documentation. Use the ID with `--from` and `--wit
| [Antigravity](docs/formats/antigravity.md) | `antigravity` | Yes | Yes |
| [Hermes Agent](docs/formats/hermes.md) | `hermes` | Yes | No |
| [Amp](docs/formats/amp.md) | `amp` | Yes | No |
| [Cloud Cowork](docs/formats/cowork-remote.md) | `cowork_remote` | Live account | No |
| [Claude Chat](docs/formats/claude-chat.md) | `claude_chat` | Live account | No |
| [ChatGPT](docs/formats/chatgpt.md) | `chatgpt` | Live account | No |

Local discovery skips the live accounts. Select `--from claude_chat` or `--from chatgpt` explicitly to read them. These sources use private web APIs and reuse an existing app login; requirements and limitations are in their linked docs.
Local discovery skips the live accounts. Select `--from claude_chat`, `--from cowork_remote`, or `--from chatgpt` explicitly to read them. These sources use private web APIs and reuse an existing app login; requirements and limitations are in their linked docs.

### Bring another agent

Expand Down
166 changes: 115 additions & 51 deletions cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ use std::path::PathBuf;
use std::process::ExitCode;

use clap::{CommandFactory, Parser, Subcommand};
use txcript::harness::{amp, chatgpt, claude_chat, simple};
use txcript::harness::{amp, chatgpt, claude_chat, cowork_remote, simple};
use txcript::{Codec, Common, HarnessId, Store, TextCodec, Transcript, local};

pub mod cache;
Expand All @@ -69,7 +69,7 @@ mod pager;
mod view;

pub const HARNESSES: &str = "harnesses: claude_code, claude_chat, chatgpt, codex, opencode, pi, campfire, cursor, cursor_desktop, grok, fx, hermes, \
amp, antigravity, simple, cowork";
amp, antigravity, simple, cowork, cowork_remote";

/// The `txcript` binary's command line.
#[derive(Parser)]
Expand Down Expand Up @@ -649,6 +649,54 @@ pub(crate) fn load_direct_chatgpt(source: &str, from: Option<HarnessId>) -> Opti
})())
}

/// A full cloud Cowork id bypasses listing, like Claude Chat UUID reads.
pub(crate) fn load_direct_cowork_remote(
source: &str,
from: Option<HarnessId>,
) -> Option<LoadedSession> {
if from != Some(HarnessId::CoworkRemote) {
return None;
}
let (id, request) = fragment::parse_ref(source);
let normalized = cowork_remote::normalize_id(id);
// A mistyped link/id must not turn into an account-wide title search.
if normalized.is_err()
&& !id.contains("://")
&& !id.starts_with("cse_")
&& !id.starts_with("session_")
&& uuid::Uuid::parse_str(id).is_err()
{
return None;
}
Some((|| {
let id = normalized.map_err(|error| error.to_string())?;
let store =
cowork_remote::CoworkRemoteStore::from_desktop().map_err(|error| error.to_string())?;
let reference = store
.session_ref(&id, None)
.map_err(|error| error.to_string())?;
let native = store.load(&reference).map_err(|error| error.to_string())?;
let common =
cowork_remote::CoworkRemote::to_common(&native).map_err(|error| error.to_string())?;
Ok((common, request))
})())
}

/// Dispatch only an explicitly selected live source with a full provider id.
pub(crate) fn load_direct_remote(
source: &str,
from: Option<HarnessId>,
) -> Option<(HarnessId, LoadedSession)> {
let harness = from?;
let loaded = match harness {
HarnessId::ClaudeChat => load_direct_claude_chat(source, from),
HarnessId::ChatGpt => load_direct_chatgpt(source, from),
HarnessId::CoworkRemote => load_direct_cowork_remote(source, from),
_ => return None,
}?;
Some((harness, loaded))
}

/// Positions of the first occurrence of each distinct id starting with
/// `prefix`. Claude Code writes a session resumed from another cwd under the
/// same id in a second store; those copies collapse to the first (newest —
Expand Down Expand Up @@ -855,6 +903,22 @@ mod filter_tests {

#[cfg(test)]
mod resolve_tests {

#[test]
fn cowork_urls_require_explicit_source_and_invalid_urls_never_become_searches() {
let valid = "https://claude.ai/chat/00000000-0000-8000-8000-000000000002";
assert!(super::load_direct_remote(valid, None).is_none());
for source in [
"https://evil.test/cowork/cse_one",
"cse_../bad",
"https://claude.ai/chat/00000000-0000-4000-8000-000000000002",
] {
assert!(matches!(
super::load_direct_remote(source, Some(txcript::HarnessId::CoworkRemote)),
Some((txcript::HarnessId::CoworkRemote, Err(_)))
));
}
}
use super::distinct_prefix_matches;

#[test]
Expand Down Expand Up @@ -1083,6 +1147,13 @@ mod identity_tests {
assert_eq!(copy.meta.timestamp, ts);
}

#[test]
fn cowork_remote_is_refused_even_for_an_in_place_continue() {
let error =
ensure_resumable_source(HarnessId::CoworkRemote, HarnessId::CoworkRemote).unwrap_err();
assert!(error.contains("pull-only"));
}

#[test]
fn claude_chat_is_refused_even_for_an_in_place_continue() {
let error =
Expand Down Expand Up @@ -1136,6 +1207,9 @@ fn cmd_list(
_ => " in that time range".to_string(),
};
match from {
Some(HarnessId::CoworkRemote) => {
println!("no cloud Cowork sessions found{scope}{when}");
}
Some(HarnessId::ClaudeChat) => {
println!("no Claude Chat sessions found{scope}{when}");
}
Expand Down Expand Up @@ -1233,23 +1307,23 @@ mod style {

const fn color(h: HarnessId) -> &'static str {
match h {
HarnessId::ClaudeCode => "\x1b[33m", // yellow
HarnessId::ClaudeChat => "\x1b[38;5;214m", // amber
HarnessId::ChatGpt => "\x1b[38;5;71m", // OpenAI green
HarnessId::Codex => "\x1b[36m", // cyan
HarnessId::OpenCode => "\x1b[32m", // green
HarnessId::Pi => "\x1b[35m", // magenta
HarnessId::Campfire => "\x1b[91m", // bright red
HarnessId::Cursor => "\x1b[34m", // blue
HarnessId::CursorDesktop => "\x1b[96m", // bright cyan
HarnessId::Grok => "\x1b[37m", // white
HarnessId::GrokBot => "\x1b[97m", // bright white
HarnessId::Fx => "\x1b[38;5;39m", // azure
HarnessId::Hermes => "\x1b[93m", // bright yellow
HarnessId::Amp => "\x1b[95m", // bright magenta
HarnessId::Antigravity => "\x1b[94m", // bright blue
HarnessId::Simple => "\x1b[92m", // bright green
HarnessId::Cowork => "\x1b[38;5;208m", // orange
HarnessId::ClaudeCode => "\x1b[33m", // yellow
HarnessId::CoworkRemote | HarnessId::ClaudeChat => "\x1b[38;5;214m", // amber
HarnessId::ChatGpt => "\x1b[38;5;71m", // OpenAI green
HarnessId::Codex => "\x1b[36m", // cyan
HarnessId::OpenCode => "\x1b[32m", // green
HarnessId::Pi => "\x1b[35m", // magenta
HarnessId::Campfire => "\x1b[91m", // bright red
HarnessId::Cursor => "\x1b[34m", // blue
HarnessId::CursorDesktop => "\x1b[96m", // bright cyan
HarnessId::Grok => "\x1b[37m", // white
HarnessId::GrokBot => "\x1b[97m", // bright white
HarnessId::Fx => "\x1b[38;5;39m", // azure
HarnessId::Hermes => "\x1b[93m", // bright yellow
HarnessId::Amp => "\x1b[95m", // bright magenta
HarnessId::Antigravity => "\x1b[94m", // bright blue
HarnessId::Simple => "\x1b[92m", // bright green
HarnessId::Cowork => "\x1b[38;5;208m", // orange
}
}
}
Expand All @@ -1270,19 +1344,17 @@ fn cmd_crop(
ensure_crop_target(target)?;
}

if let Some(loaded) = load_direct_claude_chat(source, from) {
let target = with.unwrap_or(HarnessId::ClaudeChat);
ensure_crop_target(target)?;
let (common, request) = loaded?;
return crop_loaded(&common, HarnessId::ClaudeChat, target, request.as_ref());
if with.is_none()
&& let Some(source) = from
{
ensure_crop_target(source)?;
}
if let Some(loaded) = load_direct_chatgpt(source, from) {
let target = with.unwrap_or(HarnessId::ChatGpt);
if let Some((source_harness, loaded)) = load_direct_remote(source, from) {
let target = with.unwrap_or(source_harness);
ensure_crop_target(target)?;
let (common, request) = loaded?;
return crop_loaded(&common, HarnessId::ChatGpt, target, request.as_ref());
return crop_loaded(&common, source_harness, target, request.as_ref());
}

let sessions = discover_with_spinner(from)?;
let (id, request) = match crop_ref(source) {
(_, Some(_)) if find_exact(&sessions, from, source).is_some() => (source, None),
Expand Down Expand Up @@ -1392,37 +1464,23 @@ fn cmd_continue(
);
}

if let Some(loaded) = load_direct_claude_chat(id, from) {
let target = with.unwrap_or(HarnessId::ClaudeChat);
ensure_resumable_source(HarnessId::ClaudeChat, target)?;
let (common, request) = loaded?;
return continue_loaded_remote(
common,
HarnessId::ClaudeChat,
target,
request.as_ref(),
out.map(PathBuf::as_path),
wants_resume(target, out.is_some(), no_resume),
metadata,
);
if let Some(source) = from {
ensure_resumable_source(source, with.unwrap_or(source))?;
}
if let Some(loaded) = load_direct_chatgpt(id, from) {
let target = with.unwrap_or(HarnessId::ChatGpt);
ensure_resumable_source(HarnessId::ChatGpt, target)?;
if let Some((source_harness, loaded)) = load_direct_remote(id, from) {
let target = with.unwrap_or(source_harness);
ensure_resumable_source(source_harness, target)?;
let (common, request) = loaded?;
return continue_loaded_remote(
common,
HarnessId::ChatGpt,
source_harness,
target,
request.as_ref(),
out.map(PathBuf::as_path),
wants_resume(target, out.is_some(), no_resume),
metadata,
);
}

// Locate the session by id (exact or unambiguous prefix) or exact title,
// optionally scoped to one harness.
let sessions = discover_with_spinner(from)?;
// A whole-input match (a title that itself contains `#12`) beats the
// fragment interpretation.
Expand Down Expand Up @@ -1833,7 +1891,8 @@ fn continue_loaded_remote(
fn ensure_crop_target(target: HarnessId) -> Result<(), String> {
if matches!(
target,
HarnessId::ClaudeChat
HarnessId::CoworkRemote
| HarnessId::ClaudeChat
| HarnessId::ChatGpt
| HarnessId::Hermes
| HarnessId::Amp
Expand All @@ -1848,7 +1907,9 @@ fn ensure_crop_target(target: HarnessId) -> Result<(), String> {
}

fn ensure_resumable_source(source: HarnessId, target: HarnessId) -> Result<(), String> {
if source == HarnessId::ClaudeChat && target == HarnessId::ClaudeChat {
if source == HarnessId::CoworkRemote && target == HarnessId::CoworkRemote {
Err("cloud Cowork is pull-only; choose another --with harness".into())
} else if source == HarnessId::ClaudeChat && target == HarnessId::ClaudeChat {
Err(
"Claude Chat is pull-only: choose another --with harness; txcript never continues conversations in Claude"
.to_string(),
Expand Down Expand Up @@ -2043,7 +2104,10 @@ fn resume_workdir(cwd: Option<&str>) -> Option<PathBuf> {

fn discover_with_spinner(from: Option<HarnessId>) -> Result<Vec<local::Session>, String> {
let spinner = spin::Spinner::start("searching local sessions…");
let sessions = if matches!(from, Some(HarnessId::ClaudeChat | HarnessId::ChatGpt)) {
let sessions = if matches!(
from,
Some(HarnessId::ClaudeChat | HarnessId::CoworkRemote | HarnessId::ChatGpt)
) {
let harness = from.unwrap_or(HarnessId::ClaudeChat);
spinner.set(format!("reading {harness}…"));
local::discover_harness(harness).map_err(|error| error.to_string())?
Expand Down
27 changes: 19 additions & 8 deletions cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,10 @@ impl SessionServer {
let from = parse_from(request.from.as_deref())?;
// Enumerating a live web account is not offered over MCP; the
// refusal is explicit so an agent doesn't read "no sessions" as truth.
if matches!(from, Some(HarnessId::ClaudeChat | HarnessId::ChatGpt)) {
if matches!(
from,
Some(HarnessId::ClaudeChat | HarnessId::CoworkRemote | HarnessId::ChatGpt)
) {
let name = from.map_or("live source", HarnessId::as_str);
return Err(ErrorData::invalid_params(
format!(
Expand Down Expand Up @@ -354,13 +357,7 @@ impl SessionServer {
Parameters(request): Parameters<ReadSessionRequest>,
) -> Result<String, ErrorData> {
let from = parse_from(request.from.as_deref())?;
if let Some(loaded) = super::load_direct_claude_chat(&request.id, from) {
let (common, span_req) =
loaded.map_err(|error| ErrorData::internal_error(error, None))?;
let src = crate::fragment::parse_ref(&request.id).0;
return render_read_session(src, &common, span_req.as_ref());
}
if let Some(loaded) = super::load_direct_chatgpt(&request.id, from) {
if let Some((_, loaded)) = super::load_direct_remote(&request.id, from) {
let (common, span_req) =
loaded.map_err(|error| ErrorData::internal_error(error, None))?;
let src = crate::fragment::parse_ref(&request.id).0;
Expand Down Expand Up @@ -647,6 +644,20 @@ mod tests {
assert_eq!(chunk_ranges(&[10, 10], 0, 100), vec![0..2]);
}

#[test]
fn list_sessions_refuses_cowork_remote() {
let error = SessionServer::new(None)
.list_sessions(Parameters(ListSessionsRequest {
from: Some("cowork_remote".into()),
cwd: None,
limit: None,
offset: None,
}))
.err()
.unwrap_or_else(|| panic!("cowork_remote listing is refused"));
assert!(error.message.contains("does not enumerate cowork_remote"));
}

#[test]
fn list_sessions_refuses_claude_chat() {
let error = SessionServer::new(None)
Expand Down
7 changes: 3 additions & 4 deletions cli/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,7 @@ pub fn load_source(
source: &str,
from: Option<HarnessId>,
) -> Result<(Transcript<Common>, Option<fragment::SpanReq>), String> {
if let Some(loaded) = super::load_direct_claude_chat(source, from) {
return loaded;
}
if let Some(loaded) = super::load_direct_chatgpt(source, from) {
if let Some((_, loaded)) = super::load_direct_remote(source, from) {
return loaded;
}
let sessions = super::discover_with_spinner(from)?;
Expand All @@ -52,6 +49,8 @@ pub fn load_source(
let session = super::find_session(&sessions, from, src)?.ok_or_else(|| {
let (origin, scope) = if from == Some(HarnessId::ClaudeChat) {
("Claude Chat", String::new())
} else if from == Some(HarnessId::CoworkRemote) {
("cloud Cowork", String::new())
} else if from == Some(HarnessId::ChatGpt) {
("ChatGPT", String::new())
} else {
Expand Down
1 change: 1 addition & 0 deletions docs/formats/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ than none.
| Document | Harness | Parser |
| --- | --- | --- |
| [claude-code.md](claude-code.md) | Claude Code (Anthropic) | `src/harness/claude_code.rs` |
| [cowork-remote.md](cowork-remote.md) | Cloud Cowork (Anthropic) | `src/harness/cowork_remote.rs` |
| [claude-chat.md](claude-chat.md) | Claude Chat (Anthropic) | `src/harness/claude_chat.rs` |
| [chatgpt.md](chatgpt.md) | ChatGPT (OpenAI) | `src/harness/chatgpt.rs` |
| [cowork.md](cowork.md) | Cowork (Claude desktop app) | `src/harness/cowork.rs` |
Expand Down
5 changes: 4 additions & 1 deletion docs/formats/claude-chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,10 @@ turns. Structured `content` takes precedence over the duplicate message-level

Live tool names are normalized to their Claude Code equivalents where the
arguments fit: `bash_tool` becomes `Bash`, `view` becomes `Read`, and
`create_file` becomes `Write`. `present_files` remains a raw tool event, while
`create_file` becomes `Write` and also contributes a text artifact when its
input includes the full `file_text`. Older `artifacts` create/rewrite tools
similarly contribute their complete content; update patches remain tool
records, since a patch alone is not a complete file. `present_files` remains a raw tool event, while
each file it presents becomes a first-class Common artifact carrying its
identity, filename, MIME type, and bytes. When writing Claude Code, the generic
Common artifact path materializes those bytes under the generated session's
Expand Down
Loading
Loading