Skip to content
Open
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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,40 @@ cache the same queries run ~4.7× faster (e.g. `length` 966 ms →
drivers so the zero-file rule lives once.
- The emscripten (playground) build keeps the sequential engine —
no threads there.
## `toolpath-copilot`: the caller supplies the home directory — 2026-08-14

- **`toolpath-copilot`** (0.2.0): breaking. `PathResolver::new(home)`
takes the home directory as a required argument. The crate reads no
environment variable; it keeps the layout knowledge
(`<home>/.copilot`) and the caller owns "what is home".
`CopilotConvo::new(home)` and `ConvoIO::new(home)` take the same
argument.

Removed: the `Default` impls on `PathResolver`, `ConvoIO`, and
`CopilotConvo`; `PathResolver::with_home`; the `NoHomeDirectory`
error variant. `with_copilot_dir` stays as the full override, and it
wins against the home-derived default.

The home directory is always present, so `home_dir()`,
`copilot_dir()`, `session_state_dir()`,
`legacy_session_state_dir()`, `session_store_db()`, and
`ConvoIO::copilot_dir_path()` return a path instead of a `Result`.

Strict events parsing is a parameter. `CopilotConvo::with_strict(bool)`
and `ConvoIO::with_strict(bool)` set it,
`EventReader::read_lines_with(path, strict)` and
`EventReader::read_session_dir_with(dir, strict)` take it directly,
and `EventReader::read_lines(path)` stays lenient. The crate reads no
environment variable for it.
- **`path-cli`** (unreleased): `providers::copilot_resolver` returns
`Option<PathResolver>`. `None` means the configuration carries no home
directory, so Copilot is out of reach: the harness bundle omits it,
and a command that targets Copilot reports "cannot determine the home
directory". `Config` reads `$COPILOT_EVENTS_STRICT` and passes the
flag to every `CopilotConvo` it builds, so the variable keeps its
behavior for CLI users. `$COPILOT_HOME` stays a `Config` read, and the
resolver receives it as the injected Copilot root.

## `toolpath-claude`: the caller supplies the home directory — 2026-08-13

- **`toolpath-claude`** (0.13.0): breaking. `PathResolver::new(home)`
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" }
toolpath-claude = { version = "0.13.0", path = "crates/toolpath-claude", default-features = false }
toolpath-gemini = { version = "0.7.0", path = "crates/toolpath-gemini", default-features = false }
toolpath-codex = { version = "0.7.0", path = "crates/toolpath-codex" }
toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" }
toolpath-copilot = { version = "0.2.0", path = "crates/toolpath-copilot" }
toolpath-opencode = { version = "0.5.0", path = "crates/toolpath-opencode" }
toolpath-cursor = { version = "0.2.0", path = "crates/toolpath-cursor" }
toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" }
Expand Down
10 changes: 3 additions & 7 deletions crates/path-cli/src/cmd_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,10 +493,8 @@ fn run_copilot(

#[cfg(not(target_os = "emscripten"))]
fn write_into_copilot_project(session: &toolpath_copilot::Session, config: &Config) -> Result<()> {
let resolver = providers::copilot_resolver(config);
let state_dir = resolver
.session_state_dir()
.map_err(|e| anyhow::anyhow!("Cannot resolve ~/.copilot/session-state: {}", e))?;
let resolver = providers::require_copilot_resolver(config)?;
let state_dir = resolver.session_state_dir();
let sess_dir = state_dir.join(&session.id);
std::fs::create_dir_all(&sess_dir).with_context(|| format!("create {}", sess_dir.display()))?;

Expand All @@ -517,9 +515,7 @@ fn write_into_copilot_project(session: &toolpath_copilot::Session, config: &Conf
.with_context(|| "write workspace.yaml")?;

// session-store.db `sessions` row — the resume picker reads this index.
let db_path = resolver
.session_store_db()
.map_err(|e| anyhow::anyhow!("Cannot resolve session-store.db: {}", e))?;
let db_path = resolver.session_store_db();
let registration = register_copilot_session(&db_path, session);

eprintln!(
Expand Down
3 changes: 2 additions & 1 deletion crates/path-cli/src/cmd_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -930,7 +930,8 @@ fn pick_codex(manager: &toolpath_codex::CodexConvo) -> Result<Option<Vec<String>

fn derive_copilot(session: Option<String>, all: bool, config: &Config) -> Result<Vec<DerivedDoc>> {
let manager =
toolpath_copilot::CopilotConvo::with_resolver(providers::copilot_resolver(config));
toolpath_copilot::CopilotConvo::with_resolver(providers::require_copilot_resolver(config)?)
.with_strict(providers::copilot_strict(config));

let session_ids: Vec<String> = match (session, all) {
(Some(s), _) => vec![s],
Expand Down
4 changes: 3 additions & 1 deletion crates/path-cli/src/cmd_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,9 @@ fn run_codex(fmt: ListFormat, config: &Config) -> Result<()> {
// ── Copilot (preview) ─────────────────────────────────────────────────────────

fn run_copilot(fmt: ListFormat, config: &Config) -> Result<()> {
let manager = providers::copilot_convo(config);
let manager =
toolpath_copilot::CopilotConvo::with_resolver(providers::require_copilot_resolver(config)?)
.with_strict(providers::copilot_strict(config));
let sessions = manager
.list_sessions()
.map_err(|e| anyhow::anyhow!("{}", e))?;
Expand Down
12 changes: 5 additions & 7 deletions crates/path-cli/src/cmd_share.rs
Original file line number Diff line number Diff line change
Expand Up @@ -708,12 +708,10 @@ fn harness_status_copilot(bundle: &HarnessBundle, home: Option<&std::path::Path>
let Some(mgr) = &bundle.copilot else {
return HarnessStatus::unresolved();
};
match mgr.resolver().session_state_dir() {
Ok(p) => HarnessStatus {
path: crate::config::home_relative(&p, home),
exists: p.exists(),
},
Err(_) => HarnessStatus::unresolved(),
let p = mgr.resolver().session_state_dir();
HarnessStatus {
path: crate::config::home_relative(&p, home),
exists: p.exists(),
}
}

Expand Down Expand Up @@ -1148,7 +1146,7 @@ mod tests {
fn copilot_only_bundle(home: &Path) -> HarnessBundle {
let copilot_dir = home.join(".copilot");
std::fs::create_dir_all(&copilot_dir).unwrap();
let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir);
let resolver = toolpath_copilot::PathResolver::new(home).with_copilot_dir(&copilot_dir);
HarnessBundle {
copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)),
..Default::default()
Expand Down
5 changes: 4 additions & 1 deletion crates/path-cli/src/cmd_show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,10 @@ fn derive_one(source: ShowSource, config: &Config) -> Result<toolpath::v1::Path>
session,
project: _,
} => {
let manager = providers::copilot_convo(config);
let manager = toolpath_copilot::CopilotConvo::with_resolver(
providers::require_copilot_resolver(config)?,
)
.with_strict(providers::copilot_strict(config));
let s = manager
.read_session(&session)
.map_err(|e| anyhow::anyhow!("{}", e))?;
Expand Down
7 changes: 7 additions & 0 deletions crates/path-cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ pub struct Config {
/// unparseable rollout line. Presence is the signal; the value is
/// not read.
pub(crate) codex_rollout_strict: Option<String>,
/// `$COPILOT_EVENTS_STRICT`: the Copilot reader errors on a
/// malformed events line. Presence is the signal; the value is not
/// read.
pub(crate) copilot_events_strict: Option<String>,
/// `$COPILOT_HOME`: Copilot CLI session root override.
pub(crate) copilot_home: Option<PathBuf>,
/// `$HOME`: config-root fallback and the harness resolvers' root.
Expand Down Expand Up @@ -110,6 +114,7 @@ impl Config {
("APPDATA", "appdata"),
("CLAUDE_CLI_DEBUG", "claude_cli_debug"),
("CODEX_ROLLOUT_STRICT", "codex_rollout_strict"),
("COPILOT_EVENTS_STRICT", "copilot_events_strict"),
("COPILOT_HOME", "copilot_home"),
("HOME", "home"),
(PATHBASE_URL_ENV, "pathbase_url"),
Expand Down Expand Up @@ -205,6 +210,7 @@ mod tests {
jail.set_env("CLAUDE_CLI_DEBUG", "1");
jail.set_env("CODEX_ROLLOUT_STRICT", "1");
jail.set_env("XDG_DATA_HOME", "/home/jailed/.local/share");
jail.set_env("COPILOT_EVENTS_STRICT", "1");
jail.set_env("COPILOT_HOME", "/home/jailed/.copilot");
jail.set_env("APPDATA", "/home/jailed/appdata");
jail.set_env(PATHBASE_URL_ENV, "https://pathbase.test");
Expand All @@ -217,6 +223,7 @@ mod tests {
appdata: Some(PathBuf::from("/home/jailed/appdata")),
claude_cli_debug: Some("1".to_string()),
codex_rollout_strict: Some("1".to_string()),
copilot_events_strict: Some("1".to_string()),
copilot_home: Some(PathBuf::from("/home/jailed/.copilot")),
home: Some(PathBuf::from("/home/jailed")),
pathbase_url: Some("https://pathbase.test".to_string()),
Expand Down
5 changes: 4 additions & 1 deletion crates/path-cli/src/derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ pub(crate) fn derive_codex_session_with(
/// Derive a single Copilot session given an explicit session id.
pub(crate) fn derive_copilot_session(config: &Config, session: &str) -> Result<DerivedDoc> {
derive_copilot_session_with(
&toolpath_copilot::CopilotConvo::with_resolver(providers::copilot_resolver(config)),
&toolpath_copilot::CopilotConvo::with_resolver(providers::require_copilot_resolver(
config,
)?)
.with_strict(providers::copilot_strict(config)),
session,
)
}
Expand Down
1 change: 0 additions & 1 deletion crates/path-cli/src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ pub(crate) fn is_not_found_codex(err: &toolpath_codex::ConvoError) -> bool {
pub(crate) fn is_not_found_copilot(err: &toolpath_copilot::ConvoError) -> bool {
use toolpath_copilot::ConvoError;
matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
|| matches!(err, ConvoError::NoHomeDirectory)
|| matches!(err, ConvoError::CopilotDirectoryNotFound(_))
}

Expand Down
71 changes: 54 additions & 17 deletions crates/path-cli/src/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
//! error for a command that targets one harness.
//!
//! opencode, copilot, and cursor (Windows) get their directory
//! injected, not just the home: their resolvers read `$XDG_DATA_HOME`
//! / `$COPILOT_HOME` / `$APPDATA` internally, and those reads win
//! against `with_home`. The injected directory wins against both.
//! injected, not just the home. `$COPILOT_HOME` replaces the whole
//! Copilot root, so the injected directory wins against the
//! home-derived default. The opencode and cursor resolvers read
//! `$XDG_DATA_HOME` / `$APPDATA` internally, and those reads win
//! against `with_home`; the injected directory wins against both.

use crate::config::Config;
#[cfg(not(target_os = "emscripten"))]
Expand Down Expand Up @@ -66,15 +68,25 @@ pub(crate) fn codex_strict(config: &Config) -> bool {
config.codex_rollout_strict.is_some()
}

pub(crate) fn copilot_convo(config: &Config) -> toolpath_copilot::CopilotConvo {
let mut resolver = toolpath_copilot::PathResolver::new();
if let Some(home) = config.home_dir() {
resolver = resolver.with_home(home);
}
if let Some(dir) = &config.copilot_home {
resolver = resolver.with_copilot_dir(dir);
}
toolpath_copilot::CopilotConvo::with_resolver(resolver)
pub(crate) fn copilot_resolver(config: &Config) -> Option<toolpath_copilot::PathResolver> {
config.home_dir().map(|home| {
let resolver = toolpath_copilot::PathResolver::new(home);
match &config.copilot_home {
Some(dir) => resolver.with_copilot_dir(dir),
None => resolver,
}
})
Comment on lines +73 to +78

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous behavior: if neither $HOME nor $USERPROFILE are set $COPILOT_HOME replaces the root. The new resolver returns None. Is this the desired behavior?

}

/// [`copilot_resolver`] for a command that targets Copilot.
pub(crate) fn require_copilot_resolver(config: &Config) -> Result<toolpath_copilot::PathResolver> {
copilot_resolver(config).ok_or_else(|| missing_home("Copilot"))
}

/// The Copilot reader's strict flag. `$COPILOT_EVENTS_STRICT` is strict
/// when set, whatever its value.
pub(crate) fn copilot_strict(config: &Config) -> bool {
config.copilot_events_strict.is_some()
}

#[cfg(not(target_os = "emscripten"))]
Expand Down Expand Up @@ -132,7 +144,9 @@ pub(crate) fn harness_bundle(config: &Config) -> HarnessBundle {
codex: codex_resolver(config).map(|r| {
toolpath_codex::CodexConvo::with_resolver(r).with_strict(codex_strict(config))
}),
copilot: Some(copilot_convo(config)),
copilot: copilot_resolver(config).map(|r| {
toolpath_copilot::CopilotConvo::with_resolver(r).with_strict(copilot_strict(config))
}),
opencode: Some(opencode_convo(config)),
cursor: Some(cursor_convo(config)),
pi: Some(pi_convo(config, None)),
Expand Down Expand Up @@ -222,19 +236,42 @@ mod tests {
}

#[test]
fn copilot_convo_injects_copilot_dir() {
fn copilot_resolver_injects_copilot_dir() {
let config = Config {
home: Some(PathBuf::from("/home/jailed")),
copilot_home: Some(PathBuf::from("/copilot/root")),
..Config::default()
};
let manager = copilot_convo(&config);
let resolver = copilot_resolver(&config).unwrap();
assert_eq!(resolver.copilot_dir(), PathBuf::from("/copilot/root"));
}

#[test]
fn copilot_resolver_roots_at_config_home() {
let resolver = copilot_resolver(&config_with_home()).unwrap();
assert_eq!(
manager.resolver().copilot_dir().unwrap(),
PathBuf::from("/copilot/root")
resolver.session_state_dir(),
PathBuf::from("/home/jailed/.copilot/session-state")
);
}

#[test]
fn copilot_resolver_is_none_without_a_home() {
assert!(copilot_resolver(&Config::default()).is_none());
let err = require_copilot_resolver(&Config::default()).unwrap_err();
assert!(err.to_string().contains("home directory"));
}

#[test]
fn copilot_strict_follows_presence_of_the_variable() {
assert!(!copilot_strict(&Config::default()));
let config = Config {
copilot_events_strict: Some(String::new()),
..Config::default()
};
assert!(copilot_strict(&config));
}

#[test]
fn opencode_convo_injects_data_dir() {
let config = Config {
Expand Down
4 changes: 2 additions & 2 deletions crates/path-cli/src/sync/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -975,7 +975,7 @@ mod tests {
let user =
r#"{"type":"user.message","timestamp":"2026-07-01T00:00:01Z","data":{"content":"hi"}}"#;
std::fs::write(dir.join("events.jsonl"), format!("{start}\n{user}\n")).unwrap();
let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir);
let resolver = toolpath_copilot::PathResolver::new(home).with_copilot_dir(&copilot_dir);
HarnessBundle {
copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)),
..Default::default()
Expand Down Expand Up @@ -1166,7 +1166,7 @@ mod tests {
),
)
.unwrap();
let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir);
let resolver = toolpath_copilot::PathResolver::new(home).with_copilot_dir(&copilot_dir);
let bundle = HarnessBundle {
copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)),
..Default::default()
Expand Down
2 changes: 1 addition & 1 deletion crates/path-cli/src/sync/sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ impl ArtifactSource for CopilotSource<'_> {
self.0.resolver().session_state_dir(),
self.0.resolver().legacy_session_state_dir(),
];
for dir in dirs.into_iter().flatten() {
for dir in dirs {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
Expand Down
2 changes: 1 addition & 1 deletion crates/toolpath-copilot/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "toolpath-copilot"
version = "0.1.0"
version = "0.2.0"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
Expand Down
7 changes: 4 additions & 3 deletions crates/toolpath-copilot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ as the npm package [`@github/copilot`](https://www.npmjs.com/package/@github/cop

## What it reads

Sessions live under `~/.copilot/session-state/<session-id>/` (override the root
with `COPILOT_HOME`):
Sessions live under `~/.copilot/session-state/<session-id>/` (the caller
supplies the home directory; `PathResolver::with_copilot_dir` replaces the
whole root):

- `events.jsonl` — the append-only event stream this crate parses into a
conversation.
Expand All @@ -37,7 +38,7 @@ It also tolerates the legacy `history-session-state/` location.
```rust,no_run
use toolpath_copilot::{CopilotConvo, derive};

let convo = CopilotConvo::new();
let convo = CopilotConvo::new("/Users/alex");

// List sessions (newest first).
for meta in convo.list_sessions()? {
Expand Down
3 changes: 0 additions & 3 deletions crates/toolpath-copilot/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@ pub enum ConvoError {
#[error("JSON parsing error: {0}")]
Json(#[from] serde_json::Error),

#[error("Home directory not found")]
NoHomeDirectory,

#[error("Copilot directory not found at path: {0}")]
CopilotDirectoryNotFound(PathBuf),

Expand Down
Loading
Loading