Skip to content
Draft
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ tracedecay install --agent antigravity # Google Antigravity (formerly Windsu
tracedecay install --agent claude # Claude Code
tracedecay install --agent cline # Cline
tracedecay install --agent codex # OpenAI Codex CLI
tracedecay install --agent codex --automation
tracedecay install --agent copilot # GitHub Copilot
tracedecay install --agent cursor # Cursor
tracedecay install --agent gemini # Gemini CLI
Expand All @@ -151,7 +152,7 @@ For project-scoped setup, run from the repository root:
tracedecay install --local --agent cursor
```

Local install writes only workspace files such as `.mcp.json`, `.codex/config.toml`, `.vscode/mcp.json`, `.hermes/plugins/tracedecay/`, or the equivalent project config for Claude, Codex, Gemini, Hermes, Kiro, OpenCode, Copilot/VS Code, Zed, Roo Code, Kimi, Kilo, and Vibe. Generated MCP configs and plugin wrappers use the resolved absolute `tracedecay` executable path. Hermes installs into `~/.hermes/plugins/tracedecay/` by default, or into `~/.hermes/profiles/<name>/plugins/tracedecay/` with `--profile <name>`; profile names are normalized to lowercase and must match `[a-z0-9][a-z0-9_-]{0,63}`. The generated context engine resolves its project from explicit host kwargs first, then a `project_root` key on the Hermes config object, then the install-time pin, and finally the session cwd. Pin a profile to one project with `tracedecay install --agent hermes [--profile <name>] --project-root /abs/path` — the pin is written into the generated plugin, applies to every plugin tool call regardless of the Hermes working directory, and survives later unpinned reinstalls. Use `tracedecay uninstall --agent hermes --profile <name>` to remove a named profile install; `reinstall` and `doctor --agent hermes` currently operate on the default profile. To refresh generated plugin code after an upgrade without any config writes, run `tracedecay update-plugin` (alias `update-plugins`): it rewrites only tracedecay-generated artifacts — the Hermes plugin files and dashboard page for every detected profile (pin re-read from `config.yaml`, never written), the Cursor plugin bundle, the Codex plugin bundle, and the Kiro managed agent — and leaves `config.yaml`, `config.toml`, `mcp.json`, settings, hooks, and prompt rules byte-for-byte intact. Config-managed integrations (Claude, Gemini, …) have no generated artifacts and are reported as untouched; `tracedecay reinstall` remains the command that reconciles their config entries. Hermes wrappers run from Hermes' current working directory, use a 600-second timeout, and include truncated stdout/stderr in error JSON. Hermes local install without `--profile` writes only project plugin files and `.hermes/config.yaml`; `tracedecay install --local --agent hermes --profile <name>` is a deliberate mixed-scope mode that targets the named profile instead. Project-local Hermes plugins are loaded by launching Hermes with `HERMES_HOME=<project>/.hermes`. For Codex, global install writes a Codex plugin source bundle to `~/plugins/tracedecay`, registers it in `~/.agents/plugins/marketplace.json`, and prints `codex plugin add tracedecay@personal`; hooks and AGENTS.md stay config-managed because current Codex plugin manifests accept MCP and skills but not hook declarations. For Cursor, both global and `--local` install put the plugin in `~/.cursor/plugins/local/tracedecay` and require a Cursor reload. The plugin MCP config runs `tracedecay serve --path ${workspaceFolder}`, so it resolves the active workspace instead of the plugin directory and uses that workspace's active project store rather than the legacy global Cursor MCP registration. Cursor install no longer writes `.cursor/mcp.json`, `.cursor/hooks.json`, `.cursor/rules/tracedecay.mdc` (legacy artifact name), or `.cursor/permissions.json`; approvals are left to Cursor approval/run-mode behavior. The plugin hooks are:
Local install writes only workspace files such as `.mcp.json`, `.codex/config.toml`, `.vscode/mcp.json`, `.hermes/plugins/tracedecay/`, or the equivalent project config for Claude, Codex, Gemini, Hermes, Kiro, OpenCode, Copilot/VS Code, Zed, Roo Code, Kimi, Kilo, and Vibe. Generated MCP configs and plugin wrappers use the resolved absolute `tracedecay` executable path. Hermes installs into `~/.hermes/plugins/tracedecay/` by default, or into `~/.hermes/profiles/<name>/plugins/tracedecay/` with `--profile <name>`; profile names are normalized to lowercase and must match `[a-z0-9][a-z0-9_-]{0,63}`. The generated context engine resolves its project from explicit host kwargs first, then a `project_root` key on the Hermes config object, then the install-time pin, and finally the session cwd. Pin a profile to one project with `tracedecay install --agent hermes [--profile <name>] --project-root /abs/path` — the pin is written into the generated plugin, applies to every plugin tool call regardless of the Hermes working directory, and survives later unpinned reinstalls. Use `tracedecay uninstall --agent hermes --profile <name>` to remove a named profile install; `reinstall` and `doctor --agent hermes` currently operate on the default profile. To refresh generated plugin code after an upgrade without any config writes, run `tracedecay update-plugin` (alias `update-plugins`): it rewrites only tracedecay-generated artifacts — the Hermes plugin files and dashboard page for every detected profile (pin re-read from `config.yaml`, never written), the Cursor plugin bundle, the Codex plugin bundle, and the Kiro managed agent — and leaves `config.yaml`, `config.toml`, `mcp.json`, settings, hooks, and prompt rules byte-for-byte intact. Config-managed integrations (Claude, Gemini, …) have no generated artifacts and are reported as untouched; `tracedecay reinstall` remains the command that reconciles their config entries. Hermes wrappers run from Hermes' current working directory, use a 600-second timeout, and include truncated stdout/stderr in error JSON. Hermes local install without `--profile` writes only project plugin files and `.hermes/config.yaml`; `tracedecay install --local --agent hermes --profile <name>` is a deliberate mixed-scope mode that targets the named profile instead. Project-local Hermes plugins are loaded by launching Hermes with `HERMES_HOME=<project>/.hermes`. For Codex, global install writes a Codex plugin source bundle to `~/plugins/tracedecay`, registers it in `~/.agents/plugins/marketplace.json`, and prints `codex plugin add tracedecay@personal`; hooks and AGENTS.md stay config-managed because current Codex plugin manifests accept MCP and skills but not hook declarations. Passing `--automation` with `--agent codex` also installs or updates the Codex-native `Watch TraceDecay Memory` automation in the user profile store at `~/.codex/automations/watch-tracedecay-memory/automation.toml`; project scope is encoded in that global record's `cwds` field, so no project-local automation files are written. For Cursor, both global and `--local` install put the plugin in `~/.cursor/plugins/local/tracedecay` and require a Cursor reload. The plugin MCP config runs `tracedecay serve --path ${workspaceFolder}`, so it resolves the active workspace instead of the plugin directory and uses that workspace's active project store rather than the legacy global Cursor MCP registration. Cursor install no longer writes `.cursor/mcp.json`, `.cursor/hooks.json`, `.cursor/rules/tracedecay.mdc` (legacy artifact name), or `.cursor/permissions.json`; approvals are left to Cursor approval/run-mode behavior. The plugin hooks are:

- `sessionStart` — fire-and-forget; injects context steering the Agent toward tracedecay MCP tools and reports index freshness (suggests `tracedecay init` when no initialized project store is found).
- `subagentStart` — blocks research/explore subagents until tracedecay MCP tools have been tried; the plugin's own `code-explorer`/`code-health-auditor`/`session-historian` agents are allow-listed.
Expand Down
6 changes: 6 additions & 0 deletions docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,12 @@ hooks, using the resolved absolute `tracedecay` path). The local hooks are
identical to the global Codex install described under "Codex lifecycle hooks"
below.

Pass `--automation` with `--agent codex` to install or update the Codex-native
`Watch TraceDecay Memory` automation. TraceDecay writes the native global record
at `~/.codex/automations/watch-tracedecay-memory/automation.toml` and scopes it
to the current project with that record's `cwds` field; it does not write
project-local automation files.

#### Codex lifecycle hooks

Codex supports a Claude-style lifecycle hook system (enabled by default; verified against Codex 0.136.0). Both global (`~/.codex/hooks.json`) and project-local (`<root>/.codex/hooks.json`) installs register tracedecay hooks using Codex's nested config shape — `hooks[event] -> [ { matcher?, hooks: [ { type: "command", command, timeout } ] } ]` — and reconcile them idempotently while preserving any foreign hooks. Each hook reads Codex's single stdin JSON event (`session_id`, `cwd`, `hook_event_name`, plus event-specific fields) and writes Codex-shaped stdout. The project root is resolved from the event `cwd`, and every hook is fail-open and only acts when it finds an initialized project store.
Expand Down
242 changes: 242 additions & 0 deletions src/agents/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
//! until trusted. The installer prints that guidance after writing `hooks.json`.

use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use serde_json::json;

Expand Down Expand Up @@ -344,6 +345,157 @@ fn codex_update_project_path(ctx: &InstallContext) -> Option<PathBuf> {
.or_else(|| std::env::current_dir().ok())
}

const CODEX_TRACEDECAY_AUTOMATION_ID: &str = "watch-tracedecay-memory";
const CODEX_TRACEDECAY_AUTOMATION_NAME: &str = "Watch TraceDecay Memory";
const CODEX_TRACEDECAY_AUTOMATION_RRULE: &str = "FREQ=HOURLY;INTERVAL=1;BYMINUTE=0,15,30,45";

pub fn install_codex_native_automation(home: &Path, project_path: &Path) -> Result<PathBuf> {
let automation_dir = home
.join(".codex/automations")
.join(CODEX_TRACEDECAY_AUTOMATION_ID);
let automation_path = automation_dir.join("automation.toml");
let now_ms = unix_timestamp_millis();
let created_at = existing_codex_automation_created_at(&automation_path).unwrap_or(now_ms);

std::fs::create_dir_all(&automation_dir).map_err(|e| TraceDecayError::Config {
message: format!(
"cannot create Codex automation directory {}: {e}",
automation_dir.display()
),
})?;
set_private_dir_permissions(&automation_dir);

let mut table = toml::map::Map::new();
table.insert("version".to_string(), toml::Value::Integer(1));
table.insert(
"id".to_string(),
toml::Value::String(CODEX_TRACEDECAY_AUTOMATION_ID.to_string()),
);
table.insert("kind".to_string(), toml::Value::String("cron".to_string()));
table.insert(
"name".to_string(),
toml::Value::String(CODEX_TRACEDECAY_AUTOMATION_NAME.to_string()),
);
table.insert(
"prompt".to_string(),
toml::Value::String(codex_native_automation_prompt(project_path)),
);
table.insert(
"status".to_string(),
toml::Value::String("ACTIVE".to_string()),
);
table.insert(
"rrule".to_string(),
toml::Value::String(CODEX_TRACEDECAY_AUTOMATION_RRULE.to_string()),
);
table.insert(
"model".to_string(),
toml::Value::String("gpt-5.5".to_string()),
);
table.insert(
"reasoning_effort".to_string(),
toml::Value::String("medium".to_string()),
);
table.insert(
"execution_environment".to_string(),
toml::Value::String("local".to_string()),
);
table.insert(
"cwds".to_string(),
toml::Value::Array(vec![toml::Value::String(
project_path.display().to_string(),
)]),
);
table.insert("created_at".to_string(), toml::Value::Integer(created_at));
table.insert("updated_at".to_string(), toml::Value::Integer(now_ms));

let contents =
toml::to_string(&toml::Value::Table(table)).map_err(|e| TraceDecayError::Config {
message: format!("failed to serialize Codex automation TOML: {e}"),
})?;
safe_write_private_text_file(&automation_path, &contents)?;
eprintln!(
"\x1b[32m✔\x1b[0m Installed Codex automation {}",
automation_path.display()
);
Ok(automation_path)
}

fn existing_codex_automation_created_at(path: &Path) -> Option<i64> {
let contents = std::fs::read_to_string(path).ok()?;
let parsed = toml::from_str::<toml::Table>(&contents).ok()?;
parsed.get("created_at")?.as_integer()
}

fn safe_write_private_text_file(path: &Path, contents: &str) -> Result<()> {
let new_path = PathBuf::from(format!("{}.new", path.display()));
if let Err(e) = std::fs::write(&new_path, contents) {
std::fs::remove_file(&new_path).ok();
return Err(TraceDecayError::Config {
message: format!(
"failed to write new Codex automation file {}: {e}",
new_path.display()
),
});
}
set_private_file_permissions(&new_path);
if let Err(e) = std::fs::rename(&new_path, path) {
std::fs::remove_file(&new_path).ok();
return Err(TraceDecayError::Config {
message: format!(
"failed to rename {} → {}: {e}",
new_path.display(),
path.display()
),
});
}
set_private_file_permissions(path);
Ok(())
}

fn unix_timestamp_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(i64::MAX as u128) as i64
}

#[cfg(unix)]
fn set_private_dir_permissions(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
}

#[cfg(not(unix))]
fn set_private_dir_permissions(_path: &Path) {}

#[cfg(unix)]
fn set_private_file_permissions(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}

#[cfg(not(unix))]
fn set_private_file_permissions(_path: &Path) {}

pub fn codex_native_automation_prompt(project_path: &Path) -> String {
format!(
"Watch the TraceDecay project at {}. Monitor TraceDecay project memory \
and relevant session context for stale, duplicate, contradictory, or \
useful durable facts. Use the installed TraceDecay plugin and the \
user/profile-level TraceDecay store scoped to this project. Do not \
create repo-local TraceDecay storage or automation files. Start \
read-only: inspect memory health/status and search/list/probe/contradict \
related facts before proposing changes. Do not delete or destructively \
mutate facts without explicit user confirmation. If nothing important \
changed, report briefly. If there is a useful maintenance action, \
explain what changed or what needs review, including fact ids and \
reasons when applicable.",
project_path.display()
)
}

fn install_codex_plugin(home: &Path, tracedecay_bin: &str) -> Result<()> {
let cached_dirs = codex_plugin_cached_install_dirs(home);
if !cached_dirs.is_empty() {
Expand Down Expand Up @@ -1393,4 +1545,94 @@ mod tests {
"Codex skill bodies reference skills absent from the bundle: {dangling:?}"
);
}

#[test]
fn codex_native_automation_installs_global_project_scoped_record() {
let home = tempfile::TempDir::new().expect("temp home");
let project = Path::new("/work/project");

let path =
install_codex_native_automation(home.path(), project).expect("automation install");
assert_eq!(
path,
home.path()
.join(".codex/automations/watch-tracedecay-memory/automation.toml")
);
assert!(
!project.join(".codex/automations").exists(),
"Codex automations must live in the global user profile, not the project"
);

let contents = std::fs::read_to_string(&path).expect("automation toml");
let parsed = toml::from_str::<toml::Table>(&contents).expect("valid toml");
assert_eq!(
parsed.get("id").and_then(|value| value.as_str()),
Some("watch-tracedecay-memory")
);
assert_eq!(
parsed.get("name").and_then(|value| value.as_str()),
Some("Watch TraceDecay Memory")
);
assert_eq!(
parsed.get("rrule").and_then(|value| value.as_str()),
Some("FREQ=HOURLY;INTERVAL=1;BYMINUTE=0,15,30,45")
);
assert_eq!(
parsed
.get("cwds")
.and_then(|value| value.as_array())
.and_then(|values| values.first())
.and_then(|value| value.as_str()),
Some("/work/project")
);
assert_eq!(
parsed.get("model").and_then(|value| value.as_str()),
Some("gpt-5.5")
);
assert_eq!(
parsed
.get("reasoning_effort")
.and_then(|value| value.as_str()),
Some("medium")
);
assert!(parsed
.get("prompt")
.and_then(|value| value.as_str())
.unwrap_or_default()
.contains("Do not create repo-local TraceDecay storage or automation files"));

let created_at = parsed
.get("created_at")
.and_then(|value| value.as_integer())
.expect("created_at");
install_codex_native_automation(home.path(), project).expect("automation reinstall");
let reparsed = std::fs::read_to_string(&path)
.expect("automation toml")
.parse::<toml::Table>()
.expect("valid toml");
assert_eq!(
reparsed
.get("created_at")
.and_then(|value| value.as_integer()),
Some(created_at),
"reinstall should preserve created_at for the existing Codex automation"
);

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir_mode = std::fs::metadata(path.parent().unwrap())
.expect("automation dir metadata")
.permissions()
.mode()
& 0o777;
let file_mode = std::fs::metadata(&path)
.expect("automation file metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(dir_mode, 0o700);
assert_eq!(file_mode, 0o600);
}
}
}
Loading
Loading