feat(opencode2): add first-party OpenCode 2.0 beta support

MCP (mcp.servers + oauth:false), dependency-free plugin shape
(ai-memory-opencode2.ts), managed run opencode2, v2 transcript
adapter (session_v2/session_message), acceptance case, docs,
changelog. Shares v1's config dir, session store, and agent kind;
no migration. Verified live against beta-18999.
This commit is contained in:
enrell
2026-09-03 23:30:16 -03:00
parent 1d9b1abbd2
commit 33b5dd2c2d
23 changed files with 1231 additions and 40 deletions
+3 -3
View File
@@ -60,7 +60,7 @@ install or refresh work.
If you're about to write a durable project rule ("always X", "never
Y", "all PRs must ..."), write it in the project's canonical agent instruction file.
Many projects use CLAUDE.md for Claude Code and
AGENTS.md for Codex / OpenCode / Cursor / Gemini CLI / Grok Build CLI / Kimi Code / Kiro CLI / Command Code,
AGENTS.md for Codex / OpenCode / OpenCode 2 / Cursor / Gemini CLI / Grok Build CLI / Kimi Code / Kiro CLI / Command Code,
but if the project says one file is canonical, use that file.
If the rule is a standing *user/team* preference that should apply to
@@ -77,7 +77,7 @@ latest binary's recommended copy:
- **From the agent** (no terminal needed): ask "refresh the ai-memory
routing in this project". The agent calls `memory_install_self_routing`,
picks the right filename for itself (Claude Code -> `CLAUDE.md`; Codex /
OpenCode / Cursor / Gemini / Grok -> `AGENTS.md`; Kimi Code / Kiro CLI / Command Code -> `AGENTS.md`),
OpenCode / OpenCode 2 / Cursor / Gemini / Grok -> `AGENTS.md`; Kimi Code / Kiro CLI / Command Code -> `AGENTS.md`),
uses its Write / Edit tool to replace or append the returned
`markered_block` while preserving
non-ai-memory user content, then writes or updates each returned
@@ -94,7 +94,7 @@ start/end HTML-comment markers, without disturbing the rest of the file.
# AGENTS.md — ai-memory contributor guide
This file is the single canonical instruction file for AI coding agents
working in this repository (Claude Code, Codex, OpenCode, Cursor, Gemini
working in this repository (Claude Code, Codex, OpenCode, OpenCode 2, Cursor, Gemini
CLI, Kimi Code, Command Code, and other AGENTS-aware harnesses). `CLAUDE.md` is only a
short pointer here — do not duplicate rules into it.
+20
View File
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Added first-party OpenCode 2.0 beta (`opencode2`) support.
`install-mcp --client opencode2` merges the V2 `mcp.servers` remote entry
(no `enabled` field, `oauth: false` for header credentials);
`install-hooks --agent opencode2` writes an `ai-memory-opencode2.ts`
plugin in the beta `{ id, setup }` shape (no runtime dependency:
`Plugin.define` is identity), with handoff injection through the
`context` hook and the same `session.*` lifecycle coverage as v1;
`ai-memory run opencode2` launches through the `opencode2` binary and
imports the beta `session_v2`/`session_message` transcript. The beta
shares v1's config dir, session store, and agent kind, so no store
migration ships. Verified live against beta-18999 (hook capture,
handoff loop, managed launch/resume/import, `opencode↔opencode2`
session resume). Two beta caveats are documented: back up
`~/.local/share/opencode` first (the beta has migrated the shared
database in place before), and move its background service off port
49374 when ai-memory serves there. Managed ledger-delta
acknowledgement does not fire through the shared background service
(redelivery, never loss); see the managed-workstreams notes (#622).
### Fixed
- `bootstrap` no longer aborts the whole multi-chunk run when one chunk
returns no `pages` key (#614). Later chunks are told which paths
+3 -2
View File
@@ -94,6 +94,7 @@ caveats is in [`docs/support-matrix.md`](docs/support-matrix.md).
| Command Code | Supported |
| Devin CLI | Supported |
| OpenCode | Supported |
| OpenCode 2 (`opencode2` beta) | Supported |
| Cursor | Supported |
| Gemini CLI | Supported |
| Oh My Pi / OMP | Supported |
@@ -201,7 +202,7 @@ docker run -d --name ai-memory \
# 3. Wire your agent CLI in two commands. The wrapper takes care of
# mounts and each client's config-path detection. Re-run with
# `--agent codex`, `--agent command-code`, `--agent devin`, `--agent opencode`, `--agent gemini-cli`,
# `--agent codex`, `--agent command-code`, `--agent devin`, `--agent opencode`, `--agent opencode2`, `--agent gemini-cli`,
# `--agent grok`, `--agent kimi-code`, `--agent kiro-cli`, `--agent omp`,
# `--agent oh-my-pi`, `--client cursor`,
# `--client gemini-cli`, `--client grok`, `--client kiro-cli`, etc.
@@ -320,7 +321,7 @@ diagram, crate breakdown, schema notes, and invariants.
|---|---|
| [`docs/install.md`](docs/install.md) | **Installation cookbook.** Every agent CLI, every alternative (curl, source build, no-docker, no-auth), and the server-on-a-different-machine (homelab/LAN) walkthrough. Read after the Quick start if your setup doesn't match the happy path. |
| [`docs/usage.md`](docs/usage.md) | Handoffs, proactive memory queries, slim routing snippet + managed Agent Skills, migration from other memory tools, web UI, raw-wiki inspection, and rules-vs-facts workflow. |
| [`docs/managed-workstreams.md`](docs/managed-workstreams.md) | Optional `ai-memory run` continuity across Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, Command Code, Kiro CLI v2/v3, OMP, Grok Build CLI, and Antigravity CLI: automatic harness selection, native resume, argument forwarding, ledger search, privacy, and recovery. |
| [`docs/managed-workstreams.md`](docs/managed-workstreams.md) | Optional `ai-memory run` continuity across Claude Code, Codex, OpenCode, OpenCode 2 beta, Pi, Crush, Kimi Code, Command Code, Kiro CLI v2/v3, OMP, Grok Build CLI, and Antigravity CLI: automatic harness selection, native resume, argument forwarding, ledger search, privacy, and recovery. |
| [`docs/managed-harness-contributions.md`](docs/managed-harness-contributions.md) | Protocol and acceptance bar for contributors adding managed resume, read-only transcript import, and startup context delivery to another harness. |
| [`docs/marker-file.md`](docs/marker-file.md) | `.ai-memory.toml` workspace/project routing for multi-client trees, mono-repos, worktrees, and work/personal separation. |
| [`docs/auto-scope.md`](docs/auto-scope.md) | `[auto_scope]` modes for shared servers: default single-slot routing, session-aware isolation, and multi-user `per_actor` behavior. |
+66 -4
View File
@@ -263,6 +263,9 @@ pub enum RunHarnessChoice {
/// OpenCode.
#[value(name = "opencode", alias = "open-code")]
OpenCode,
/// OpenCode 2.0 beta (`opencode2` binary, side-by-side with v1).
#[value(name = "opencode2", alias = "opencode-v2", alias = "open-code2")]
OpenCode2,
/// Pi coding agent.
Pi,
/// Charmbracelet Crush.
@@ -1381,6 +1384,13 @@ pub enum AgentChoice {
/// them straight to `--agent`, which used to fail on this one.
#[value(alias = "opencode")]
OpenCode,
/// OpenCode 2.0 beta (`opencode2`, side-by-side with v1) — TypeScript
/// plugin hooks under `~/.config/opencode/plugins/` using the V2
/// `{ id, setup }` plugin shape. `--apply` writes `ai-memory-opencode2.ts`
/// directly; restart OpenCode 2 for it to load. Shares v1's config
/// dir, session store, and agent kind.
#[value(name = "opencode2", alias = "opencode-v2", alias = "open-code2")]
OpenCode2,
/// Real Pi coding agent. The generated TypeScript extension provides
/// lifecycle capture and bridges ai-memory's HTTP MCP tools into Pi.
Pi,
@@ -1469,7 +1479,7 @@ impl AgentChoice {
Self::Codex => AgentKind::Codex,
Self::Cursor => AgentKind::Cursor,
Self::GeminiCli => AgentKind::GeminiCli,
Self::OpenCode => AgentKind::OpenCode,
Self::OpenCode | Self::OpenCode2 => AgentKind::OpenCode,
Self::Pi => AgentKind::Pi,
Self::Omp => AgentKind::Omp,
Self::Openclaw => AgentKind::OpenClaw,
@@ -1493,9 +1503,13 @@ impl AgentChoice {
#[must_use]
pub const fn script_hook_subdir(self) -> Option<&'static str> {
match self {
Self::OpenCode | Self::Pi | Self::Omp | Self::Openclaw | Self::Zero | Self::Zcode => {
None
}
Self::OpenCode
| Self::OpenCode2
| Self::Pi
| Self::Omp
| Self::Openclaw
| Self::Zero
| Self::Zcode => None,
_ => Some(self.kind().as_str()),
}
}
@@ -1554,6 +1568,12 @@ pub enum McpClient {
/// hook-staging dir name.
#[value(alias = "opencode")]
OpenCode,
/// OpenCode 2.0 beta (`opencode2`) — `opencode.jsonc`, nested
/// `mcp.servers` map with `type: "remote"` + `url` + `headers`.
/// V2 drops v1's `enabled` field and disables OAuth discovery for
/// header-credentialed servers via `oauth: false`.
#[value(name = "opencode2", alias = "opencode-v2", alias = "open-code2")]
OpenCode2,
/// Cursor IDE — `~/.cursor/mcp.json` or `.cursor/mcp.json`.
Cursor,
/// Anthropic Claude Desktop — uses the `mcp-remote` stdio shim
@@ -2757,6 +2777,48 @@ mod tests {
}
}
#[test]
fn opencode2_aliases_parse_to_the_beta_variants() {
for alias in ["opencode2", "opencode-v2", "open-code2"] {
let cli = Cli::try_parse_from([
"ai-memory",
"install-hooks",
"--agent",
alias,
"--server-url",
"http://127.0.0.1:49374",
])
.unwrap_or_else(|error| panic!("failed to parse opencode2 alias {alias}: {error}"));
let Command::InstallHooks(args) = cli.command else {
panic!("expected install-hooks for opencode2 alias {alias}");
};
assert_eq!(args.agent, AgentChoice::OpenCode2);
assert_eq!(args.agent.kind(), ai_memory_core::AgentKind::OpenCode);
assert_eq!(args.agent.script_hook_subdir(), None);
let cli = Cli::try_parse_from([
"ai-memory",
"install-mcp",
"--client",
alias,
"--server-url",
"http://127.0.0.1:49374/mcp",
])
.unwrap_or_else(|error| panic!("failed to parse opencode2 alias {alias}: {error}"));
let Command::InstallMcp(args) = cli.command else {
panic!("expected install-mcp for opencode2 alias {alias}");
};
assert_eq!(args.client, McpClient::OpenCode2);
let cli = Cli::try_parse_from(["ai-memory", "run", alias])
.unwrap_or_else(|error| panic!("failed to parse run {alias}: {error}"));
let Command::Run(args) = cli.command else {
panic!("expected run for opencode2 alias {alias}");
};
assert!(matches!(args.harness, Some(RunHarnessChoice::OpenCode2)));
}
}
#[test]
fn devin_hook_agent_parses() {
let hook_cli = Cli::try_parse_from([
@@ -379,7 +379,11 @@ pub fn run(config: &Config, mut args: InstallHooksArgs) -> Result<()> {
}
let generated = matches!(
args.agent,
AgentChoice::OpenCode | AgentChoice::Omp | AgentChoice::Pi | AgentChoice::Openclaw
AgentChoice::OpenCode
| AgentChoice::OpenCode2
| AgentChoice::Omp
| AgentChoice::Pi
| AgentChoice::Openclaw
);
if generated || local_hook_policy_v1_supported() {
eprintln!(
@@ -442,6 +446,7 @@ pub fn run(config: &Config, mut args: InstallHooksArgs) -> Result<()> {
args.project_strategy = install_project_strategy(&args);
return match args.agent {
AgentChoice::OpenCode => apply_to_opencode_plugin(&server_url, auth, &args),
AgentChoice::OpenCode2 => apply_to_opencode2_plugin(&server_url, auth, &args),
AgentChoice::Pi => apply_to_pi_extension(&server_url, auth, &args),
AgentChoice::Omp => apply_to_omp_extension(&server_url, auth, &args),
AgentChoice::ClaudeCode => {
@@ -536,6 +541,7 @@ pub fn run(config: &Config, mut args: InstallHooksArgs) -> Result<()> {
let strategy = args.project_strategy.and_then(ProjectStrategyArg::baked);
match args.agent {
AgentChoice::OpenCode => render_opencode_plugin(&server_url, auth, strategy),
AgentChoice::OpenCode2 => render_opencode2_plugin(&server_url, auth, strategy),
AgentChoice::Pi => render_pi_extension(&server_url, auth, strategy),
AgentChoice::Omp => {
render_omp_extension(&server_url, auth, strategy, args.profile.as_deref())
@@ -767,6 +773,7 @@ fn existing_agent_config(args: &InstallHooksArgs) -> Option<String> {
AgentChoice::Cursor => cursor_hooks_path().ok()?,
AgentChoice::GeminiCli => gemini_settings_path().ok()?,
AgentChoice::OpenCode => opencode_plugin_path().ok()?,
AgentChoice::OpenCode2 => opencode2_plugin_path().ok()?,
AgentChoice::Pi => pi_extension_path().ok()?,
AgentChoice::Omp => omp_extension_path(args.profile.as_deref()).ok()?,
AgentChoice::Openclaw => openclaw_plugin::default_plugin_dir()
@@ -793,9 +800,14 @@ fn existing_agent_config(args: &InstallHooksArgs) -> Option<String> {
/// TypeScript files carry an explicit ownership header.
fn baked_project_strategy(agent: AgentChoice, existing: &str) -> Option<ProjectStrategyArg> {
match agent {
AgentChoice::OpenCode | AgentChoice::Pi | AgentChoice::Omp | AgentChoice::Openclaw => {
AgentChoice::OpenCode
| AgentChoice::OpenCode2
| AgentChoice::Pi
| AgentChoice::Omp
| AgentChoice::Openclaw => {
let marker = match agent {
AgentChoice::OpenCode => "--agent opencode --apply`.",
AgentChoice::OpenCode2 => "--agent opencode2 --apply`.",
AgentChoice::Pi => "--agent pi --apply`.",
AgentChoice::Omp => "--agent omp --apply`.",
AgentChoice::Openclaw => "--agent openclaw --apply`.",
@@ -1022,6 +1034,11 @@ fn infer_installed_mcp_config(agent: AgentChoice) -> Result<Option<InferredMcpCo
&["mcp", "ai-memory"],
"url",
)),
McpClient::OpenCode2 => Ok(infer_json_mcp_config(
&content,
&["mcp", "servers", "ai-memory"],
"url",
)),
McpClient::Cursor => Ok(infer_json_mcp_config(
&content,
&["mcpServers", "ai-memory"],
@@ -1131,6 +1148,7 @@ fn mcp_client_for_agent(agent: AgentChoice) -> Option<McpClient> {
AgentChoice::Cursor => Some(McpClient::Cursor),
AgentChoice::GeminiCli => Some(McpClient::GeminiCli),
AgentChoice::OpenCode => Some(McpClient::OpenCode),
AgentChoice::OpenCode2 => Some(McpClient::OpenCode2),
AgentChoice::Omp => Some(McpClient::Omp),
AgentChoice::Openclaw => Some(McpClient::Openclaw),
AgentChoice::AntigravityCli => Some(McpClient::AntigravityCli),
@@ -2771,6 +2789,273 @@ fn render_opencode_plugin(
Ok(())
}
/// `~/.config/opencode/plugins/ai-memory-opencode2.ts` — the OpenCode 2.0
/// beta plugin file. The beta shares v1's config dir and session store but
/// not its plugin API, so this is a distinct file from `ai-memory.ts`
/// (uninstall keys each file to its own banner + agent constant).
pub(crate) fn opencode2_plugin_path() -> anyhow::Result<std::path::PathBuf> {
Ok(home_dir()
.context("could not locate $HOME for ~/.config/opencode")?
.join(".config")
.join("opencode")
.join("plugins")
.join("ai-memory-opencode2.ts"))
}
/// Generate an OpenCode 2.0 beta plugin at
/// `~/.config/opencode/plugins/ai-memory-opencode2.ts`.
///
/// The beta's plugin API (`Plugin.define({ id, setup })` with
/// `ctx.session.hook` / `ctx.tool.hook` / `ctx.event.subscribe`) is
/// incompatible with v1's function plugin, so the beta gets its own file.
/// Both files share the one auto-loaded dir while the beta is side-by-side;
/// a host may warn about its sibling's file (API mismatch) — that warning
/// is benign, and `uninstall` removes each file only on its own ownership
/// markers.
fn apply_to_opencode2_plugin(
server_url: &str,
auth_token: Option<&str>,
args: &InstallHooksArgs,
) -> Result<()> {
let path = match &args.config_file {
Some(p) => p.clone(),
None => opencode2_plugin_path()?,
};
let strategy = args.project_strategy.and_then(ProjectStrategyArg::baked);
let body = build_opencode2_plugin(server_url, auth_token, strategy)?;
let outcome = apply_atomic(&path, move |_existing| Ok(body.clone()))?;
println!(
"✓ {} {} ({})",
outcome.verb(),
path.display(),
match outcome {
ApplyOutcome::Created => "new plugin file",
ApplyOutcome::Updated => "backup written next to it",
ApplyOutcome::NoOp => "already up to date",
}
);
if !matches!(outcome, ApplyOutcome::NoOp) {
println!();
println!("OpenCode 2 auto-loads plugins from ~/.config/opencode/plugins/ on next start.");
println!("If you're already inside an `opencode2` session, restart it for the");
println!("new plugin to take effect.");
}
Ok(())
}
fn render_opencode2_plugin(
server_url: &str,
auth_token: Option<&str>,
project_strategy: Option<&str>,
) -> Result<()> {
println!(
"// OpenCode 2.0 beta plugin — write to ~/.config/opencode/plugins/ai-memory-opencode2.ts"
);
println!("// Or re-run with `--apply` to install it automatically.");
println!("// Restart OpenCode 2 after changing plugins; config is loaded at startup.");
println!();
println!(
"{}",
build_opencode2_plugin(server_url, auth_token, project_strategy)?
);
Ok(())
}
/// Build the beta plugin from the v1 template. The capture prelude (hook
/// queue + spooling, marker resolution, capture policy, session maps,
/// `/hook` + `/handoff` helpers) is host-independent and reused verbatim;
/// only the host binding is rewritten for the V2 API. Anchors are plain
/// rendered-TS constants like the spool patch's: a template edit that moves
/// them fails loudly here instead of shipping a half-v1 plugin.
fn build_opencode2_plugin(
server_url: &str,
auth_token: Option<&str>,
project_strategy: Option<&str>,
) -> Result<String> {
let v1 = build_opencode_plugin(server_url, auth_token, project_strategy);
const BANNER_V1: &str =
"// Auto-generated by `ai-memory install-hooks --agent opencode --apply`.";
const BANNER_V2: &str =
"// Auto-generated by `ai-memory install-hooks --agent opencode2 --apply`.";
const AGENT_V1: &str = "const AGENT = \"open-code\";";
const AGENT_V2: &str = "const AGENT = \"opencode2\";";
// Start of the v1 host binding through end of file. Everything before
// this line (including the spooled delivery path) is shared.
const V1_BINDING_ANCHOR: &str =
"\nexport const AiMemoryHooks: Plugin = async ({ directory }) => {";
// `replacen` silently keeps the v1 text when its anchor moves, which
// would ship a half-v1 plugin — fail loudly instead, like the spool
// patch's anchors do.
let out = replace_opencode_anchor(&v1, BANNER_V1, BANNER_V2, "banner")?;
let out = replace_opencode_anchor(&out, AGENT_V1, AGENT_V2, "agent constant")?;
let Some(binding_at) = out.find(V1_BINDING_ANCHOR) else {
anyhow::bail!("opencode2 template drifted: v1 host binding anchor not found");
};
let mut rebuilt = out[..binding_at].to_string();
rebuilt.push_str(OPENCODE2_BINDING);
Ok(rebuilt)
}
fn replace_opencode_anchor(haystack: &str, from: &str, to: &str, what: &str) -> Result<String> {
if !haystack.contains(from) {
anyhow::bail!("opencode2 template drifted: v1 {what} anchor not found");
}
Ok(haystack.replacen(from, to, 1))
}
/// V2 host binding for the shared capture prelude: the beta's `{ id, setup }`
/// plugin shape with its session/tool/event hooks (verified against
/// `@opencode-ai/plugin@beta`, including a `tsc --noEmit` pass over the
/// rendered file). Lifecycle arrives on `ctx.event.subscribe` whose
/// envelopes carry `{ type, data, location }` (v1's `event.properties`
/// is kept as a fallback because the beta schema is still changing).
/// Handoff injection moved from v1's removed
/// `experimental.chat.system.transform` to the `context` hook, which edits
/// the outgoing model call without persisting into history — guarded to
/// inject once per session because it fires on every continuation.
const OPENCODE2_BINDING: &str = r#"
// `Plugin.define` is an identity wrapper, so the binding exports the
// `{ id, setup }` shape directly and keeps the shared `import type` line:
// a runtime import of `@opencode-ai/plugin` does not resolve from the
// global plugins dir and fails the load.
const AiMemoryOpencode2: Plugin = {
id: "ai-memory-opencode2",
setup: async (ctx) => {
const ctxAny = ctx as any;
const directory = ctxAny?.location?.directory;
const controller = new AbortController();
void (async () => {
try {
for await (const evt of ctx.event.subscribe({ signal: controller.signal })) {
const event = evt as any;
const type = event?.type;
const data = event?.data ?? event?.properties ?? {};
const info = data?.info ?? {};
const loc = data?.location?.directory ?? data?.directory
?? event?.location?.directory ?? directory;
if (type === "session.created") {
const id = data?.sessionID ?? data?.id ?? info?.id;
startSession(id, data?.location?.directory ?? loc, {
title: data?.title ?? info?.title,
projectID: data?.projectID ?? info?.projectID,
});
}
if (type === "session.idle") {
const id = data?.sessionID ?? data?.id;
startSession(id, cwdFor(id, loc));
postHook("stop", { sessionID: id, cwd: cwdFor(id, loc) });
}
if (type === "session.deleted") {
const id = data?.sessionID ?? data?.id ?? info?.id;
endSession(id, loc, data?.directory ?? info?.directory);
}
if (type === "session.compaction.started") {
const id = data?.sessionID ?? data?.id;
postPreCompact(id, loc);
}
if (type === "session.compacted") {
const id = data?.sessionID ?? data?.id;
postPreCompact(id, loc);
}
}
} catch (_e) {
// The stream ends on unload. Capture is best-effort and must never
// break the host.
}
})();
const promptRegistration = await ctx.session.hook("prompt", (event) => {
const e = event as any;
const id = e?.sessionID;
const prompt = e?.prompt ?? {};
const cwd = cwdFor(id, directory);
startSession(id, cwd, { agent: prompt?.agent, model: prompt?.model });
postHook("user-prompt", {
sessionID: id,
cwd,
agent: prompt?.agent,
model: prompt?.model,
messageID: e?.messageID,
prompt: typeof prompt?.text === "string" ? prompt.text : textFromParts(prompt?.parts),
});
});
const handoffInjected = new Set<string>();
const contextRegistration = await ctx.session.hook("context", async (event) => {
const e = event as any;
const id = e?.sessionID;
if (!id || handoffInjected.has(id)) return;
startSession(id, cwdFor(id, directory));
let pending = handoffFetches.get(id);
if (!pending) {
pending = fetchHandoff(cwdFor(id, directory), id);
handoffFetches.set(id, pending);
}
const handoff = await pending;
if (handoff) {
// SystemPart is `{ type: "text", text }` — the `type` discriminator
// is required: without it the host fails the model call with a
// schema validation error (observed live on beta-18999).
e.system.push({ type: "text", text: handoff });
handoffInjected.add(id);
}
});
const beforeRegistration = await ctx.tool.hook("execute.before", (event) => {
const e = event as any;
const id = e?.sessionID;
startSession(id, cwdFor(id, directory));
postHook("pre-tool-use", {
sessionID: id,
cwd: cwdFor(id, directory),
tool: e?.tool,
callID: e?.id,
args: e?.input,
});
});
const afterRegistration = await ctx.tool.hook("execute.after", (event) => {
const e = event as any;
const id = e?.sessionID;
startSession(id, cwdFor(id, directory));
// The beta reports failures on the same channel (`status: "error"`,
// no `result`); keep the message where v1 kept output so the
// failure reason survives consolidation.
const failed = e?.status === "error";
postHook("post-tool-use", {
sessionID: id,
cwd: cwdFor(id, directory),
tool: e?.tool,
callID: e?.id,
args: e?.input,
title: failed ? undefined : e?.result?.title,
output: failed
? String(e?.error?.message ?? e?.error ?? "tool failed")
: e?.result?.output,
metadata: failed ? undefined : e?.result?.metadata,
});
});
return async () => {
controller.abort();
for (const registration of [
promptRegistration,
contextRegistration,
beforeRegistration,
afterRegistration,
]) {
try {
await registration.dispose();
} catch (_e) {
// Unload is best-effort; a dead host has nothing to unregister.
}
}
for (const id of Array.from(startedSessions)) {
endSession(id, directory);
}
await drainHookQueueForDispose();
};
},
};
export default AiMemoryOpencode2;
"#;
/// Emit the `applyMarkerParams` TypeScript function shared verbatim by the
/// OpenCode plugin and the OMP extension.
///
@@ -5817,6 +6102,7 @@ command = "AI_MEMORY_HOOK_URL=http://h AI_MEMORY_PROJECT_STRATEGY=repo-root /x/a
fn baked_project_strategy_reads_every_owned_generated_integration() {
for (agent, name) in [
(AgentChoice::OpenCode, "opencode"),
(AgentChoice::OpenCode2, "opencode2"),
(AgentChoice::Pi, "pi"),
(AgentChoice::Omp, "omp"),
(AgentChoice::Openclaw, "openclaw"),
@@ -7633,6 +7919,91 @@ model = "gpt-5"
assert!(plugin.contains("url.searchParams.set(\"project\", repoProject)"));
}
#[test]
fn opencode2_plugin_binds_the_v2_api() {
let plugin = build_opencode2_plugin("http://127.0.0.1:49374", Some("tok"), None).unwrap();
// Ownership markers the uninstall gate keys on.
assert!(plugin.contains("install-hooks --agent opencode2 --apply"));
assert!(plugin.contains("const AGENT = \"opencode2\";"));
// Type-only import: `Plugin.define` is identity, and a runtime
// import does not resolve from the global plugins dir (the beta
// refuses the load). The host only needs the `{ id, setup }` shape.
assert!(plugin.contains("import type { Plugin } from \"@opencode-ai/plugin\";"));
assert!(!plugin.contains("export default Plugin.define"));
assert!(!plugin.contains("Plugin.define({"));
assert!(plugin.contains("const AiMemoryOpencode2: Plugin = {"));
assert!(plugin.contains("export default AiMemoryOpencode2;"));
// Beta hooks, verified against `@opencode-ai/plugin@beta`.
assert!(plugin.contains("ctx.event.subscribe"));
assert!(plugin.contains("ctx.session.hook(\"prompt\""));
assert!(plugin.contains("ctx.session.hook(\"context\""));
assert!(plugin.contains("ctx.tool.hook(\"execute.before\""));
assert!(plugin.contains("ctx.tool.hook(\"execute.after\""));
// V2 lifecycle envelopes carry `{ type, data, location }`.
for event in [
"session.created",
"session.idle",
"session.deleted",
"session.compacted",
] {
assert!(plugin.contains(event), "missing {event}");
}
// Handoff injection moved off v1's removed experimental hook and
// fires once per session (the context hook runs per model call).
// System items are `{ type: "text", text }` objects on the V2 API, not strings.
assert!(plugin.contains("handoffInjected"));
assert!(plugin.contains("system.push({ type: \"text\", text: handoff })"));
assert!(!plugin.contains("experimental."));
// Hook registrations are disposed on unload so a reload cannot
// leave stale callbacks capturing twice.
assert!(plugin.contains("registration.dispose()"));
// Pre-compaction arrives on its own start event; the completion
// event stays as the consolidation trigger, like v1.
assert!(plugin.contains("session.compaction.started"));
// Tool failures share the channel with the reason preserved.
assert!(plugin.contains("status === \"error\""));
// No v1 remnants.
assert!(!plugin.contains("export default AiMemoryHooks"));
assert!(!plugin.contains("chat.message"));
// Shared capture prelude survived the rewrite byte-identical.
for shared in [
"function startSession",
"function endSession",
"postHook(\"session-start\"",
"postHook(\"user-prompt\"",
"function applyMarkerParams",
"requestSpoolDrain();",
] {
assert!(plugin.contains(shared), "missing {shared}");
}
assert!(plugin.contains("Bearer ${TOKEN}"));
assert!(plugin.contains("tok"));
}
#[test]
fn opencode2_plugin_rejects_v1_template_drift() {
// The builder rewrites the v1 template, so a v1 edit that moves the
// binding anchor must fail loudly instead of shipping a hybrid.
let plugin =
build_opencode2_plugin("http://127.0.0.1:49374/", None, Some("repo-root")).unwrap();
assert!(plugin.contains("const TOKEN: string | null = null;"));
assert!(
plugin.contains("const DEFAULT_PROJECT_STRATEGY = \"repo-root\";"),
"strategy bake-through must survive the v2 rewrite"
);
}
#[test]
fn opencode2_anchor_rewrite_fails_loudly_on_drift() {
let err =
replace_opencode_anchor("no v1 anchors here", "missing", "x", "banner").unwrap_err();
assert!(
err.to_string().contains("banner"),
"drift must name the moved anchor: {err:#}"
);
}
#[test]
fn opencode_plugin_normalizes_payloads_without_legacy_wrapper() {
let plugin = build_opencode_plugin("http://127.0.0.1:49374/", None, None);
@@ -65,6 +65,7 @@ pub fn run(config: &Config, args: InstallMcpArgs) -> Result<()> {
McpClient::Codex => render_codex(&args),
McpClient::Grok => render_grok(&args)?,
McpClient::OpenCode => render_opencode(&args)?,
McpClient::OpenCode2 => render_opencode2(&args)?,
McpClient::Cursor => render_cursor(&args)?,
McpClient::ClaudeDesktop => render_claude_desktop(&args)?,
McpClient::GeminiCli => render_gemini_cli(&args)?,
@@ -165,6 +166,15 @@ pub(crate) fn mcp_config_path(client: crate::cli::McpClient) -> Result<PathBuf>
.join(".config")
.join("opencode")
.join("opencode.json"),
// V2 reads the same global config file (`opencode.json(c)`); its
// `mcp.servers` key coexists with v1's `mcp` key in one strict-JSON
// file, so both binaries stay wired side by side. Users who keep
// comments in `opencode.jsonc` should pass it via `--config-file`
// (`mutate_json` refuses to rewrite non-strict JSON).
McpClient::OpenCode2 => home()?
.join(".config")
.join("opencode")
.join("opencode.json"),
McpClient::Cursor => home()?.join(".cursor").join("mcp.json"),
McpClient::ClaudeDesktop => {
#[cfg(target_os = "macos")]
@@ -494,6 +504,9 @@ fn json_mcp_location(client: McpClient) -> Option<JsonMcpLocation> {
| McpClient::CommandCode
| McpClient::Swival => Some(JsonMcpLocation::RootMcpServers),
McpClient::OpenCode => Some(JsonMcpLocation::RootMcp),
// V2 nests servers under `mcp.servers` — the same shape OpenClaw,
// Zero, and ZCode use.
McpClient::OpenCode2 => Some(JsonMcpLocation::NestedMcpServers),
// Zero's config.json nests servers under `mcp.servers`, the same
// shape OpenClaw uses. ZCode nests its servers the same way.
McpClient::Openclaw | McpClient::Zero | McpClient::Zcode => {
@@ -509,6 +522,7 @@ fn build_json_mcp_entry(args: &InstallMcpArgs) -> Result<serde_json::Value> {
validate_args(args)?;
match args.client {
McpClient::OpenCode => build_mcp_entry_opencode(args),
McpClient::OpenCode2 => build_mcp_entry_opencode2(args),
McpClient::Openclaw => build_mcp_entry_openclaw(args),
McpClient::Zero => build_mcp_entry_zero(args),
McpClient::Zcode => build_mcp_entry_zcode(args),
@@ -761,6 +775,27 @@ fn build_mcp_entry_opencode(args: &InstallMcpArgs) -> Result<serde_json::Value>
Ok(serde_json::Value::Object(entry))
}
/// OpenCode 2.0 beta MCP entry: `type: "remote"` + `url` + optional
/// `headers` under `mcp.servers`. V2 has no `enabled` field (servers
/// connect unless `disabled: true`), and header-credentialed servers
/// must set `oauth: false` so the beta does not attempt OAuth discovery
/// against ai-memory's local endpoint
/// (https://opencode.ai/v2/docs/mcp-servers).
fn build_mcp_entry_opencode2(args: &InstallMcpArgs) -> Result<serde_json::Value> {
let bearer = bearer_header_value(args.auth_token.as_deref());
let server_url = args.server_url.as_deref().unwrap_or(DEFAULT_MCP_URL);
let mut entry = serde_json::Map::new();
entry.insert("type".into(), json!("remote"));
entry.insert("url".into(), json!(server_url));
// `false`, not absence: without it the beta probes the endpoint for
// OAuth metadata on every connect.
entry.insert("oauth".into(), json!(false));
if let Some(b) = bearer {
entry.insert("headers".into(), json!({"Authorization": b}));
}
Ok(serde_json::Value::Object(entry))
}
fn build_mcp_entry_openclaw(args: &InstallMcpArgs) -> Result<serde_json::Value> {
let bearer = bearer_header_value(args.auth_token.as_deref());
let server_url = args.server_url.as_deref().unwrap_or(DEFAULT_MCP_URL);
@@ -1050,6 +1085,21 @@ fn render_opencode(args: &InstallMcpArgs) -> Result<String> {
))
}
fn render_opencode2(args: &InstallMcpArgs) -> Result<String> {
Ok(format!(
"# OpenCode 2.0 beta (`opencode2`) — merge into\n\
# ~/.config/opencode/opencode.json(c) under \"mcp\" → \"servers\":\n\
#\n\
# V2 nests servers under `mcp.servers` (v1 used top-level `mcp`)\n\
# and has no `enabled` field. Both keys coexist in the one file,\n\
# so v1 and the beta stay wired side by side. If your config is\n\
# `opencode.jsonc` with comments, re-run with\n\
# `--config-file ~/.config/opencode/opencode.jsonc`.\n\
{snippet}\n",
snippet = render_json_mcp_fragment(args)?,
))
}
fn render_cursor(args: &InstallMcpArgs) -> Result<String> {
Ok(format!(
"# Cursor — write to one of:\n\
@@ -1332,6 +1382,41 @@ mod tests {
}
}
#[test]
fn opencode2_entry_uses_v2_servers_shape() {
// V2 nests under `mcp.servers`, drops v1's `enabled`, and disables
// OAuth discovery: ai-memory authenticates with a static header.
let entry = build_json_mcp_entry(&args_with_token(McpClient::OpenCode2)).unwrap();
assert_eq!(entry["type"], json!("remote"));
assert_eq!(entry["url"], json!("http://127.0.0.1:49374/mcp"));
assert_eq!(entry["oauth"], json!(false));
assert!(entry.get("enabled").is_none());
assert_eq!(
entry["headers"]["Authorization"],
json!("Bearer test-token-deadbeef")
);
let mut root = serde_json::Map::new();
upsert_json_mcp_entry(&mut root, &args_with_token(McpClient::OpenCode2)).unwrap();
assert!(root["mcp"]["servers"]["ai-memory"].is_object());
assert!(root.get("mcpServers").is_none());
// v1 still lands under top-level `mcp`, so both stay wired.
let mut v1 = serde_json::Map::new();
upsert_json_mcp_entry(&mut v1, &args_with_token(McpClient::OpenCode)).unwrap();
assert!(v1["mcp"]["ai-memory"].is_object());
assert!(v1["mcp"].get("servers").is_none());
}
#[test]
fn opencode2_render_points_at_v2_key() {
let out = render_opencode2(&args_for(McpClient::OpenCode2)).unwrap();
assert!(out.contains("opencode2"));
assert!(out.contains("mcp"));
assert!(out.contains("servers"));
assert!(out.contains("http://127.0.0.1:49374/mcp"));
}
#[test]
fn claude_desktop_render_lists_packaged_and_unpacked_windows_paths() {
let rendered = render_claude_desktop(&args_for(McpClient::ClaudeDesktop)).unwrap();
@@ -1681,6 +1766,7 @@ mod tests {
McpClient::Codex => render_codex(&args),
McpClient::Grok => render_grok(&args).unwrap(),
McpClient::OpenCode => render_opencode(&args).unwrap(),
McpClient::OpenCode2 => render_opencode2(&args).unwrap(),
McpClient::Cursor => render_cursor(&args).unwrap(),
McpClient::ClaudeDesktop => render_claude_desktop(&args).unwrap(),
McpClient::GeminiCli => render_gemini_cli(&args).unwrap(),
@@ -1709,6 +1795,7 @@ mod tests {
McpClient::Codex,
McpClient::Grok,
McpClient::OpenCode,
McpClient::OpenCode2,
McpClient::Cursor,
McpClient::ClaudeDesktop,
McpClient::GeminiCli,
@@ -1750,6 +1837,7 @@ mod tests {
McpClient::Codex,
McpClient::Grok,
McpClient::OpenCode,
McpClient::OpenCode2,
McpClient::Cursor,
McpClient::ClaudeDesktop,
McpClient::GeminiCli,
@@ -1783,6 +1871,7 @@ mod tests {
McpClient::Codex => render_codex(&args),
McpClient::Grok => render_grok(&args).unwrap(),
McpClient::OpenCode => render_opencode(&args).unwrap(),
McpClient::OpenCode2 => render_opencode2(&args).unwrap(),
McpClient::Cursor => render_cursor(&args).unwrap(),
McpClient::ClaudeDesktop => render_claude_desktop(&args).unwrap(),
McpClient::GeminiCli => render_gemini_cli(&args).unwrap(),
+1
View File
@@ -1342,6 +1342,7 @@ const fn managed_harness(choice: RunHarnessChoice) -> ManagedHarness {
RunHarnessChoice::Claude => ManagedHarness::Claude,
RunHarnessChoice::Codex => ManagedHarness::Codex,
RunHarnessChoice::OpenCode => ManagedHarness::OpenCode,
RunHarnessChoice::OpenCode2 => ManagedHarness::OpenCode2,
RunHarnessChoice::Pi => ManagedHarness::Pi,
RunHarnessChoice::Crush => ManagedHarness::Crush,
RunHarnessChoice::Omp => ManagedHarness::Omp,
@@ -63,7 +63,11 @@ pub fn run(config: &Config, args: SetupAgentArgs) -> Result<()> {
};
if matches!(
args.agent,
AgentChoice::OpenCode | AgentChoice::Pi | AgentChoice::Omp | AgentChoice::Openclaw
AgentChoice::OpenCode
| AgentChoice::OpenCode2
| AgentChoice::Pi
| AgentChoice::Omp
| AgentChoice::Openclaw
) {
emit_extension_setup_hint(&args)?;
return Ok(());
@@ -165,6 +169,7 @@ pub fn run(config: &Config, args: SetupAgentArgs) -> Result<()> {
}
AgentChoice::Pool => emit_pool(&emit_root, &args),
AgentChoice::OpenCode
| AgentChoice::OpenCode2
| AgentChoice::Pi
| AgentChoice::Omp
| AgentChoice::Openclaw
@@ -250,6 +255,12 @@ fn emit_extension_setup_hint(args: &SetupAgentArgs) -> Result<()> {
"Then restart OpenCode so it loads ~/.config/opencode/plugins/ai-memory.ts.",
"opencode",
),
AgentChoice::OpenCode2 => (
"OpenCode 2",
"opencode2",
"Then restart OpenCode 2 so it loads ~/.config/opencode/plugins/ai-memory-opencode2.ts.",
"opencode2",
),
AgentChoice::Omp => (
"OMP",
"omp",
@@ -64,6 +64,7 @@ enum HookConfigShape {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DeleteKind {
OpenCodePlugin,
OpenCode2Plugin,
PiExtension,
OmpExtension,
OpenClawPackageJson,
@@ -77,6 +78,7 @@ impl DeleteKind {
const fn label(self) -> &'static str {
match self {
Self::OpenCodePlugin => "OpenCode plugin",
Self::OpenCode2Plugin => "OpenCode 2 plugin",
Self::PiExtension => "Pi extension",
Self::OmpExtension => "OMP extension",
Self::OpenClawPackageJson => "OpenClaw package manifest",
@@ -294,6 +296,9 @@ fn build_plan(args: &UninstallArgs) -> anyhow::Result<Vec<PlannedChange>> {
let plugin = install_hooks::opencode_plugin_path()?;
push_generated_delete(&mut plan, plugin, DeleteKind::OpenCodePlugin);
let plugin2 = install_hooks::opencode2_plugin_path()?;
push_generated_delete(&mut plan, plugin2, DeleteKind::OpenCode2Plugin);
let omp_profile = args.profile.as_deref();
let omp = install_hooks::omp_extension_path(omp_profile)?;
push_generated_delete(&mut plan, omp.clone(), DeleteKind::OmpExtension);
@@ -337,6 +342,7 @@ fn build_plan(args: &UninstallArgs) -> anyhow::Result<Vec<PlannedChange>> {
Codex,
Grok,
OpenCode,
OpenCode2,
Cursor,
ClaudeDesktop,
GeminiCli,
@@ -1026,6 +1032,11 @@ fn generated_file_is_ours(path: &Path, kind: DeleteKind) -> bool {
content.contains("Auto-generated by `ai-memory install-hooks --agent opencode --apply`")
&& content.contains("const AGENT = \"open-code\";")
}
DeleteKind::OpenCode2Plugin => {
content
.contains("Auto-generated by `ai-memory install-hooks --agent opencode2 --apply`")
&& content.contains("const AGENT = \"opencode2\";")
}
DeleteKind::OmpExtension => {
content.contains("Auto-generated by `ai-memory install-hooks --agent omp --apply`")
&& content.contains("const AGENT = \"omp\";")
@@ -1106,6 +1117,7 @@ fn mcp_servers_path(client: McpClient) -> Option<&'static [&'static str]> {
| McpClient::Swival
| McpClient::Devin => Some(&["mcpServers"]),
McpClient::OpenCode => Some(&["mcp"]),
McpClient::OpenCode2 => Some(&["mcp", "servers"]),
McpClient::Openclaw | McpClient::Zero | McpClient::Zcode => Some(&["mcp", "servers"]),
McpClient::VsCodeCopilot => Some(&["servers"]),
McpClient::Zed => Some(&["context_servers"]),
+18 -1
View File
@@ -318,7 +318,7 @@ impl AgentKind {
match s {
"claude-code" | "claude_code" | "claude" => Self::ClaudeCode,
"codex" => Self::Codex,
"open-code" | "opencode" => Self::OpenCode,
"open-code" | "opencode" | "opencode2" | "opencode-v2" | "open-code2" => Self::OpenCode,
"cursor" => Self::Cursor,
"gemini-cli" | "gemini" => Self::GeminiCli,
"claude-desktop" | "claude_desktop" => Self::ClaudeDesktop,
@@ -580,6 +580,23 @@ mod tests {
assert!(!AgentKind::KiroCli.user_prompt_injects_handoff());
}
#[test]
fn agent_kind_opencode_wire_names_share_one_kind() {
// OpenCode 2.0 ships side by side as `opencode2` against the same
// config dir and session store, so its wire names resolve to the
// existing kind — no new variant, no store migration.
assert_eq!(AgentKind::OpenCode.as_str(), "open-code");
for alias in [
"open-code",
"opencode",
"opencode2",
"opencode-v2",
"open-code2",
] {
assert_eq!(AgentKind::from_wire(alias), AgentKind::OpenCode);
}
}
#[test]
fn user_prompt_handoff_injection_is_kimi_only() {
for agent in AgentKind::ALL {
+2 -2
View File
@@ -88,7 +88,7 @@ install or refresh work.
If you're about to write a durable project rule ("always X", "never
Y", "all PRs must ..."), write it in the project's canonical agent instruction file.
Many projects use CLAUDE.md for Claude Code and
AGENTS.md for Codex / OpenCode / Cursor / Gemini CLI / Grok Build CLI / Kimi Code / Kiro CLI / Command Code,
AGENTS.md for Codex / OpenCode / OpenCode 2 / Cursor / Gemini CLI / Grok Build CLI / Kimi Code / Kiro CLI / Command Code,
but if the project says one file is canonical, use that file.
If the rule is a standing *user/team* preference that should apply to
@@ -105,7 +105,7 @@ latest binary's recommended copy:
- **From the agent** (no terminal needed): ask "refresh the ai-memory
routing in this project". The agent calls `memory_install_self_routing`,
picks the right filename for itself (Claude Code -> `CLAUDE.md`; Codex /
OpenCode / Cursor / Gemini / Grok -> `AGENTS.md`; Kimi Code / Kiro CLI / Command Code -> `AGENTS.md`),
OpenCode / OpenCode 2 / Cursor / Gemini / Grok -> `AGENTS.md`; Kimi Code / Kiro CLI / Command Code -> `AGENTS.md`),
uses its Write / Edit tool to replace or append the returned
`markered_block` while preserving
non-ai-memory user content, then writes or updates each returned
+85 -7
View File
@@ -16,6 +16,10 @@ pub enum ManagedHarness {
Codex,
/// OpenCode.
OpenCode,
/// OpenCode 2.0 beta (`opencode2` binary, side-by-side with v1).
/// Shares v1's config dir, session store, and agent kind; only the
/// launched executable differs.
OpenCode2,
/// Pi coding agent.
Pi,
/// Charmbracelet Crush.
@@ -44,6 +48,7 @@ impl ManagedHarness {
"claude" | "claude-code" => Some(Self::Claude),
"codex" => Some(Self::Codex),
"opencode" | "open-code" => Some(Self::OpenCode),
"opencode2" | "opencode-v2" | "open-code2" => Some(Self::OpenCode2),
"pi" => Some(Self::Pi),
"crush" => Some(Self::Crush),
"omp" | "oh-my-pi" => Some(Self::Omp),
@@ -62,7 +67,7 @@ impl ManagedHarness {
match self {
Self::Claude => AgentKind::ClaudeCode,
Self::Codex => AgentKind::Codex,
Self::OpenCode => AgentKind::OpenCode,
Self::OpenCode | Self::OpenCode2 => AgentKind::OpenCode,
Self::Pi => AgentKind::Pi,
Self::Crush => AgentKind::Crush,
Self::Omp => AgentKind::Omp,
@@ -81,6 +86,7 @@ impl ManagedHarness {
Self::Claude => "claude",
Self::Codex => "codex",
Self::OpenCode => "opencode",
Self::OpenCode2 => "opencode2",
Self::Pi => "pi",
Self::Crush => "crush",
Self::Omp => "omp",
@@ -105,6 +111,7 @@ impl ManagedHarness {
Self::Claude => "claude",
Self::Codex => "codex",
Self::OpenCode => "opencode",
Self::OpenCode2 => "opencode2",
Self::Pi => "pi",
Self::Crush => "crush",
Self::Omp => "omp",
@@ -242,7 +249,7 @@ pub fn build_launch_plan(
expected = Some(id.to_string());
}
}
ManagedHarness::OpenCode => {
ManagedHarness::OpenCode | ManagedHarness::OpenCode2 => {
if let Some(id) = linked_session_id {
if first_arg_is(&args, "run") {
args.insert(1, OsString::from(id));
@@ -349,7 +356,7 @@ pub fn apply_yolo(harness: ManagedHarness, args: &mut Vec<OsString>) {
let flag = match harness {
ManagedHarness::Claude => Some("--dangerously-skip-permissions"),
ManagedHarness::Codex => Some("--dangerously-bypass-approvals-and-sandbox"),
ManagedHarness::OpenCode => Some("--auto"),
ManagedHarness::OpenCode | ManagedHarness::OpenCode2 => Some("--auto"),
ManagedHarness::Pi => Some("--approve"),
ManagedHarness::Crush => Some("--yolo"),
ManagedHarness::Omp => None,
@@ -400,7 +407,7 @@ fn noninteractive_invocation(harness: ManagedHarness, args: &[OsString]) -> bool
match harness {
ManagedHarness::Claude => has_flag(args, &["--print", "-p"]),
ManagedHarness::Codex => first_arg_is(args, "exec"),
ManagedHarness::OpenCode => first_arg_is(args, "run"),
ManagedHarness::OpenCode | ManagedHarness::OpenCode2 => first_arg_is(args, "run"),
ManagedHarness::Crush => first_arg_is(args, "run"),
ManagedHarness::Pi | ManagedHarness::Omp => has_flag(args, &["--print", "-p"]),
ManagedHarness::Kimi => has_flag(args, &["--prompt", "-p"]),
@@ -527,6 +534,15 @@ fn launch_mode(harness: ManagedHarness, args: &[OsString]) -> LaunchMode {
"db",
]
.as_slice(),
// Beta subcommands, verified on `opencode2 v0.0.0-beta-18999`
// (`opencode2 --help`). `run` stays session-bearing (see
// `noninteractive_invocation`); `mini` is the minimal interactive
// UI and also stays session-bearing.
ManagedHarness::OpenCode2 => [
"upgrade", "acp", "api", "debug", "console", "auth", "mcp", "plugin", "models",
"stats", "export", "import", "service", "pair", "serve",
]
.as_slice(),
ManagedHarness::Pi => {
["install", "remove", "uninstall", "update", "list", "config"].as_slice()
}
@@ -694,7 +710,7 @@ pub fn has_native_session_selector(harness: ManagedHarness, args: &[OsString]) -
|| args.first().and_then(|arg| arg.to_str()) == Some("exec")
&& args.get(1).and_then(|arg| arg.to_str()) == Some("resume")
}
ManagedHarness::OpenCode => {
ManagedHarness::OpenCode | ManagedHarness::OpenCode2 => {
has_flag(args, &["--session", "-s", "--continue", "-c", "--fork"])
}
ManagedHarness::Pi => has_flag(
@@ -779,7 +795,9 @@ fn explicit_session_id(harness: ManagedHarness, args: &[OsString]) -> Option<Str
positional_after_command(args, &["resume"])
}
}
ManagedHarness::OpenCode => flag_value(args, &["--session", "-s"]),
ManagedHarness::OpenCode | ManagedHarness::OpenCode2 => {
flag_value(args, &["--session", "-s"])
}
ManagedHarness::Pi => flag_value(args, &["--session", "--session-id"]),
ManagedHarness::Crush => flag_value(args, &["--session", "-s"]),
ManagedHarness::Omp => flag_value(args, &["--resume", "-r"]),
@@ -888,7 +906,11 @@ fn environment_session_dir_with(
match harness {
ManagedHarness::Claude => value("CLAUDE_CONFIG_DIR").map(|dir| dir.join("projects")),
ManagedHarness::Codex => value("CODEX_HOME").map(|dir| dir.join("sessions")),
ManagedHarness::OpenCode => value("XDG_DATA_HOME").map(|dir| dir.join("opencode")),
// The beta channel keeps v1's `opencode.db` filename (other channels
// get `opencode-<channel>.db`), so both harnesses share one store.
ManagedHarness::OpenCode | ManagedHarness::OpenCode2 => {
value("XDG_DATA_HOME").map(|dir| dir.join("opencode"))
}
ManagedHarness::Pi => value("PI_CODING_AGENT_SESSION_DIR")
.or_else(|| value("PI_CODING_AGENT_DIR").map(|dir| dir.join("sessions"))),
ManagedHarness::Crush => None,
@@ -1060,6 +1082,62 @@ mod tests {
));
}
#[test]
fn opencode2_shares_v1_session_contract_with_its_own_binary() {
for name in ["opencode2", "opencode-v2", "open-code2"] {
assert_eq!(
ManagedHarness::from_name(name),
Some(ManagedHarness::OpenCode2)
);
}
let beta = ManagedHarness::OpenCode2;
// Same store, same kind, same flags — only the executable differs,
// so `run opencode2` resumes v1 sessions and vice versa.
assert_eq!(beta.executable(), "opencode2");
assert_eq!(beta.as_str(), "opencode2");
assert_eq!(beta.agent_kind(), AgentKind::OpenCode);
assert_eq!(ManagedHarness::OpenCode.agent_kind(), AgentKind::OpenCode);
let plan = build_launch_plan(
beta,
None,
vec![OsString::from("run"), OsString::from("continue here")],
Some("shared-id"),
)
.unwrap();
assert_eq!(
strings(&plan.args),
["run", "--session", "shared-id", "continue here"]
);
assert_eq!(plan.expected_session_id.as_deref(), Some("shared-id"));
let mut yolo = Vec::new();
apply_yolo(beta, &mut yolo);
apply_yolo(beta, &mut yolo);
assert_eq!(strings(&yolo), ["--auto"]);
assert!(allows_native_session_adoption(
beta,
&[OsString::from("--auto")]
));
assert!(!allows_native_session_adoption(
beta,
&[OsString::from("run"), OsString::from("continue here")]
));
for utility in ["service", "api", "auth"] {
let plan =
build_launch_plan(beta, None, vec![OsString::from(utility)], Some("shared-id"))
.unwrap();
assert_eq!(plan.mode, LaunchMode::Passthrough, "{utility}");
}
// `mini` is the interactive UI, not a utility: it stays
// session-bearing.
let mini =
build_launch_plan(beta, None, vec![OsString::from("mini")], Some("shared-id")).unwrap();
assert_eq!(mini.mode, LaunchMode::Session);
}
#[test]
fn opencode_resume_places_selector_after_run_subcommand() {
let plan = build_launch_plan(
+435 -2
View File
@@ -116,6 +116,9 @@ pub async fn export_transcript(
if harness == ManagedHarness::OpenCode {
return export_opencode(home, session_dir, native_session_id, source_cursor);
}
if harness == ManagedHarness::OpenCode2 {
return export_opencode2(home, session_dir, native_session_id, source_cursor);
}
if harness == ManagedHarness::Crush {
return export_crush(cwd, session_dir, native_session_id, source_cursor);
}
@@ -147,6 +150,9 @@ pub async fn discover_native_session(
if harness == ManagedHarness::OpenCode {
return discover_opencode(home, session_dir, cwd, started_at);
}
if harness == ManagedHarness::OpenCode2 {
return discover_opencode2(home, session_dir, cwd, started_at);
}
if harness == ManagedHarness::Crush {
return discover_crush(cwd, session_dir, started_at);
}
@@ -185,6 +191,9 @@ pub async fn list_native_sessions(
if harness == ManagedHarness::OpenCode {
return list_opencode_sessions(home, session_dir, cwd, limit);
}
if harness == ManagedHarness::OpenCode2 {
return list_opencode2_sessions(home, session_dir, cwd, limit);
}
if harness == ManagedHarness::Crush {
return list_crush_sessions(cwd, session_dir, limit);
}
@@ -237,6 +246,9 @@ pub fn native_session_exists(
if harness == ManagedHarness::OpenCode {
return Ok(opencode_updated(home, session_dir, native_session_id)?.is_some());
}
if harness == ManagedHarness::OpenCode2 {
return Ok(opencode2_updated(home, session_dir, native_session_id)?.is_some());
}
if harness == ManagedHarness::Crush {
return Ok(crush_updated(cwd, session_dir, native_session_id)?.is_some());
}
@@ -285,6 +297,8 @@ pub async fn wait_for_transcript_flush(
for _ in 0..10 {
let current = if harness == ManagedHarness::OpenCode {
opencode_updated(home, session_dir, native_session_id)?.map(|value| value.to_string())
} else if harness == ManagedHarness::OpenCode2 {
opencode2_updated(home, session_dir, native_session_id)?.map(|value| value.to_string())
} else if harness == ManagedHarness::Crush {
crush_updated(cwd, session_dir, native_session_id)?.map(|value| value.to_string())
} else {
@@ -440,7 +454,10 @@ fn export_jsonl(
&mut events,
&mut losses,
),
ManagedHarness::OpenCode | ManagedHarness::Crush | ManagedHarness::Antigravity => {
ManagedHarness::OpenCode
| ManagedHarness::OpenCode2
| ManagedHarness::Crush
| ManagedHarness::Antigravity => {
return Err(anyhow!(
"{} transcripts must use their SQLite adapter",
harness.as_str()
@@ -2405,6 +2422,7 @@ fn session_header(harness: ManagedHarness, path: &Path) -> Result<Option<(String
value.get("cwd").and_then(Value::as_str),
),
ManagedHarness::OpenCode
| ManagedHarness::OpenCode2
| ManagedHarness::Crush
| ManagedHarness::Kimi
| ManagedHarness::CommandCode
@@ -2916,6 +2934,284 @@ fn opencode_db(home: &Path, session_dir: Option<&Path>) -> PathBuf {
)
}
/// OpenCode 2.0 beta transcript adapter.
///
/// The beta keeps v1's database file but replaced its tables: `session_v2`
/// carries sessions, `session_message` carries typed messages. Shapes below
/// were verified against beta-18999: user `{text, files}`, assistant
/// `content[]` with `text` / `tool` / `reasoning` parts. Reasoning content
/// is encrypted and excluded like v1's hidden reasoning; anything else
/// unrecognized becomes a bounded loss, never a guess — the beta schema is
/// still changing.
fn export_opencode2(
home: &Path,
session_dir: Option<&Path>,
session: &str,
source_cursor: Option<&str>,
) -> Result<ExportedTranscript> {
let db = opencode_db(home, session_dir);
let connection = Connection::open_with_flags(
&db,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.with_context(|| {
format!(
"opening OpenCode session database {} read-only",
db.display()
)
})?;
let cursor = source_cursor
.and_then(|raw| serde_json::from_str::<SqlCursor>(raw).ok())
.unwrap_or_default();
let mut statement = connection.prepare(
"SELECT id, time_updated, type, data FROM session_message \
WHERE session_id = ?1 AND (time_updated > ?2 OR (time_updated = ?2 AND id > ?3)) \
ORDER BY time_updated, id",
)?;
let rows = statement.query_map(params![session, cursor.updated, cursor.id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})?;
let mut events = Vec::new();
let mut losses = Vec::new();
let mut next_cursor = cursor;
for row in rows {
let (id, updated, message_type, data_raw) = row?;
next_cursor = SqlCursor {
updated,
id: id.clone(),
};
let Ok(data) = serde_json::from_str::<Value>(&data_raw) else {
losses.push(format!("malformed OpenCode 2 message {id}"));
continue;
};
parse_opencode2(&message_type, &data, session, &id, &mut events, &mut losses);
}
Ok(ExportedTranscript {
native_session_id: session.to_string(),
source_cursor: Some(serde_json::to_string(&next_cursor)?),
events,
losses: deduplicate_losses(losses),
})
}
fn parse_opencode2(
message_type: &str,
data: &Value,
session: &str,
id: &str,
events: &mut Vec<NewWorkstreamEvent>,
losses: &mut Vec<String>,
) {
match message_type {
"user" => {
if let Some(text) = data.get("text").and_then(Value::as_str) {
push_event(
events,
AgentKind::OpenCode,
session,
id,
0,
WorkstreamEventKind::Message,
Some("user"),
text,
None,
json!({}),
);
}
}
"assistant" => {
let Some(parts) = data.get("content").and_then(Value::as_array) else {
losses.push(format!(
"OpenCode 2 assistant message {id} has no content parts"
));
return;
};
for (index, part) in parts.iter().enumerate() {
let kind = part.get("type").and_then(Value::as_str).unwrap_or_default();
// One message holds many parts; the block namespaces each
// part's events deterministically (call before its result).
let block = index * 2;
match kind {
"text" => {
if let Some(text) = part.get("text").and_then(Value::as_str) {
push_event(
events,
AgentKind::OpenCode,
session,
id,
block,
WorkstreamEventKind::Message,
Some("assistant"),
text,
None,
json!({}),
);
}
}
"tool" => {
let name = part.get("name").and_then(Value::as_str).unwrap_or("tool");
let state = part.get("state").unwrap_or(&Value::Null);
let input = state.get("input").map(compact_json).unwrap_or_default();
push_event(
events,
AgentKind::OpenCode,
session,
id,
block,
WorkstreamEventKind::ToolCall,
Some("assistant"),
&format!("{name}: {input}"),
None,
json!({"tool": name}),
);
let status = state
.get("status")
.and_then(Value::as_str)
.unwrap_or("completed");
if status == "completed" {
let output = state
.get("content")
.and_then(Value::as_array)
.map(|contents| {
contents
.iter()
.filter_map(|item| item.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
if !output.trim().is_empty() {
push_event(
events,
AgentKind::OpenCode,
session,
id,
block + 1,
WorkstreamEventKind::ToolResult,
Some("tool"),
&output,
None,
json!({"status": status}),
);
}
} else {
losses
.push(format!("OpenCode 2 tool {name} ended with status {status}"));
}
}
"reasoning" => {
losses.push("OpenCode 2 hidden reasoning was intentionally excluded".into())
}
other => losses.push(format!(
"OpenCode 2 {other} part was intentionally excluded"
)),
}
}
}
// System catalog notices and similar non-conversation records.
_ => {}
}
}
fn discover_opencode2(
home: &Path,
session_dir: Option<&Path>,
cwd: &Path,
started_at: SystemTime,
) -> Result<Option<String>> {
let db = opencode_db(home, session_dir);
if !db.is_file() {
return Ok(None);
}
let connection = Connection::open_with_flags(
&db,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
let since = started_at
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
let mut statement = connection.prepare(
"SELECT id FROM session_v2 WHERE directory = ?1 AND time_updated >= ?2 \
ORDER BY time_updated DESC LIMIT 1",
)?;
match statement.query_row(params![cwd.to_string_lossy(), since], |row| row.get(0)) {
Ok(id) => Ok(Some(id)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(error) => Err(error.into()),
}
}
fn list_opencode2_sessions(
home: &Path,
session_dir: Option<&Path>,
cwd: &Path,
limit: usize,
) -> Result<Vec<NativeSessionCandidate>> {
let db = opencode_db(home, session_dir);
if !db.is_file() {
return Ok(Vec::new());
}
let connection = Connection::open_with_flags(
&db,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
let mut statement = connection.prepare(
"SELECT id, time_updated FROM session_v2 \
WHERE directory = ?1 ORDER BY time_updated DESC LIMIT ?2",
)?;
let rows = statement.query_map(params![cwd.to_string_lossy(), limit as i64], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut sessions = Vec::new();
for row in rows {
let (native_session_id, updated_millis) = row?;
let Ok(updated_millis) = u64::try_from(updated_millis) else {
continue;
};
if !valid_native_session_id(&native_session_id) {
continue;
}
let Some(updated_at) = UNIX_EPOCH.checked_add(Duration::from_millis(updated_millis)) else {
continue;
};
sessions.push(NativeSessionCandidate {
native_session_id,
updated_at,
});
}
Ok(sessions)
}
fn opencode2_updated(
home: &Path,
session_dir: Option<&Path>,
session: &str,
) -> Result<Option<i64>> {
let db = opencode_db(home, session_dir);
if !db.is_file() {
return Ok(None);
}
let connection = Connection::open_with_flags(
&db,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
match connection.query_row(
"SELECT time_updated FROM session_v2 WHERE id = ?1",
[session],
|row| row.get(0),
) {
Ok(value) => Ok(Some(value)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(error) => Err(error.into()),
}
}
fn session_root(harness: ManagedHarness, home: &Path, override_dir: Option<&Path>) -> PathBuf {
if let Some(override_dir) = override_dir {
return override_dir.to_path_buf();
@@ -2923,7 +3219,7 @@ fn session_root(harness: ManagedHarness, home: &Path, override_dir: Option<&Path
match harness {
ManagedHarness::Claude => home.join(".claude/projects"),
ManagedHarness::Codex => home.join(".codex/sessions"),
ManagedHarness::OpenCode => home.join(".local/share/opencode"),
ManagedHarness::OpenCode | ManagedHarness::OpenCode2 => home.join(".local/share/opencode"),
ManagedHarness::Pi => home.join(".pi/agent/sessions"),
ManagedHarness::Crush => home.join(".crush"),
ManagedHarness::Omp => home.join(".omp/agent/sessions"),
@@ -3151,6 +3447,7 @@ mod tests {
// summary.json, not the journal; covered by their own
// discovery tests.
ManagedHarness::OpenCode
| ManagedHarness::OpenCode2
| ManagedHarness::Crush
| ManagedHarness::Kimi
| ManagedHarness::CommandCode
@@ -3808,6 +4105,142 @@ mod tests {
assert_eq!(second.events[0].content, "second");
}
/// Beta store fixture: `session_v2` + `session_message` with the shapes
/// observed on beta-18999 (user text, assistant text/tool/reasoning).
fn opencode2_fixture(home: &Path) {
let db = opencode_db(home, None);
fs::create_dir_all(db.parent().unwrap()).unwrap();
let connection = Connection::open(&db).unwrap();
connection
.execute_batch(
"CREATE TABLE session_v2(id TEXT PRIMARY KEY, directory TEXT NOT NULL, \
time_updated INTEGER NOT NULL);\
CREATE TABLE session_message(id TEXT PRIMARY KEY, session_id TEXT, type TEXT, \
time_updated INTEGER, data TEXT);",
)
.unwrap();
connection
.execute(
"INSERT INTO session_v2 VALUES ('s2', ?1, 10)",
[home.join("repo").to_string_lossy().to_string()],
)
.unwrap();
for (id, updated, kind, data) in [
(
"m1",
1,
"user",
json!({"text": "do the thing", "files": []}).to_string(),
),
(
"m2",
2,
"assistant",
json!({"content": [
{"type": "reasoning", "text": "", "state": {}},
{"type": "text", "text": "on it"},
{"type": "tool", "name": "read", "state": {
"status": "completed",
"input": {"path": "a.txt"},
"content": [{"type": "text", "text": "file bytes"}],
}},
]})
.to_string(),
),
] {
connection
.execute(
"INSERT INTO session_message VALUES (?1, 's2', ?2, ?3, ?4)",
params![id, kind, updated, data],
)
.unwrap();
}
}
#[test]
fn opencode2_adapter_reads_v2_tables_incrementally() {
let home = tempfile::tempdir().unwrap();
opencode2_fixture(home.path());
let first = export_opencode2(home.path(), None, "s2", None).unwrap();
let contents: Vec<_> = first
.events
.iter()
.map(|event| (event.kind, event.content.as_str()))
.collect();
assert!(contents.contains(&(WorkstreamEventKind::Message, "do the thing")));
assert!(contents.contains(&(WorkstreamEventKind::Message, "on it")));
assert!(
contents
.iter()
.any(|(kind, content)| *kind == WorkstreamEventKind::ToolCall
&& content.starts_with("read: "))
);
assert!(contents.contains(&(WorkstreamEventKind::ToolResult, "file bytes")));
assert!(
first.losses.iter().any(|loss| loss.contains("reasoning")),
"encrypted reasoning must stay a named loss: {:?}",
first.losses
);
// Incremental cursor: nothing new since the first export.
let second =
export_opencode2(home.path(), None, "s2", first.source_cursor.as_deref()).unwrap();
assert!(second.events.is_empty());
assert!(second.losses.is_empty());
}
#[test]
fn opencode2_updated_tracks_session_v2_rows() {
let home = tempfile::tempdir().unwrap();
opencode2_fixture(home.path());
assert_eq!(
opencode2_updated(home.path(), None, "s2").unwrap(),
Some(10)
);
assert_eq!(
opencode2_updated(home.path(), None, "missing").unwrap(),
None
);
}
#[test]
fn opencode2_discovery_ignores_other_checkouts() {
let temp = tempfile::tempdir().unwrap();
let cwd = temp.path().join("repo");
let other = temp.path().join("other");
fs::create_dir_all(&cwd).unwrap();
let db_root = temp.path().join("opencode");
fs::create_dir_all(&db_root).unwrap();
let connection = Connection::open(db_root.join("opencode.db")).unwrap();
connection
.execute_batch(
"CREATE TABLE session_v2( \
id TEXT PRIMARY KEY, directory TEXT NOT NULL, time_updated INTEGER NOT NULL);",
)
.unwrap();
for (id, directory, updated) in [
("older", &cwd, 100_i64),
("newer", &cwd, 200_i64),
("unrelated", &other, 300_i64),
] {
connection
.execute(
"INSERT INTO session_v2 VALUES (?1, ?2, ?3)",
params![id, directory.to_string_lossy(), updated],
)
.unwrap();
}
let found = discover_opencode2(
temp.path(),
Some(&db_root),
&cwd,
UNIX_EPOCH + Duration::from_millis(150),
)
.unwrap();
assert_eq!(found.as_deref(), Some("newer"));
}
/// Build a two-bucket kimi store: `session_a` checked out at `cwd`,
/// `session_b` at `other`. Returns `(root, wire_a)`.
fn kimi_store_fixture(cwd: &Path, other: &Path) -> (tempfile::TempDir, PathBuf) {
+46 -1
View File
@@ -1110,6 +1110,51 @@ docker run --rm akitaonrails/ai-memory:latest \
Restart OpenCode after installing or changing the plugin; plugins are
loaded at startup.
### OpenCode 2 (beta)
The 2.0 beta installs side by side as `opencode2` and shares v1's config
dir and session store, but its MCP schema and plugin API changed. Wire it
with the `opencode2` client/agent names:
```bash
docker run --rm akitaonrails/ai-memory:latest \
install-mcp --client opencode2 \
--server-url "http://homelab:49374/mcp" \
--auth-token "$TOKEN"
# Plugin — write to ~/.config/opencode/plugins/ai-memory-opencode2.ts.
# If you have the local wrapper installed, prefer `--apply`:
ai-memory install-hooks --agent opencode2 --apply \
--server-url "http://homelab:49374" \
--auth-token "$TOKEN"
```
V2 nests servers under `mcp.servers` (no `enabled` field), so the v1
(`mcp`) and v2 (`mcp.servers`) entries coexist in the one
`~/.config/opencode/opencode.json(c)` file — the beta explicitly supports
this mixed nesting, so keep both entries and do not "convert" the file by
removing the v1 one
(see [Migrate from V1](https://opencode.ai/v2/docs/migrate-v1)). `ai-memory run opencode2`
resumes the same native sessions as `ai-memory run opencode` through the
`opencode2` binary. Both plugins share the one auto-loaded dir while the
beta is side-by-side; a host may warn about its sibling's file (the two
plugin APIs are incompatible) — that warning is benign, and `uninstall`
removes each file only on its own ownership markers.
> **Back up `~/.local/share/opencode` before running the beta against
> your real data.** The two binaries share the one `opencode.db` file and
> the beta has migrated its schema in place before, leaving stable
> `opencode` 1.x broken
> ([upstream #42260](https://github.com/anomalyco/opencode/issues/42260)).
> ai-memory only ever opens that database read-only; the migration risk
> comes from launching `opencode2` itself, not from this integration.
>
> **The beta's background service defaults to port 49374 — ai-memory's own
> default.** Running both on defaults crash-loops the opencode2 service
> (`Managed service port 49374 ... is already in use`). Move one side:
> `opencode2 service set port <free-port>`, or start ai-memory with
> `--bind 127.0.0.1:<free-port>` (and matching `--server-url` installs).
**On a Gemini/Vertex model, serve Gemini-safe schemas.** OpenCode forwards MCP
tool schemas to the configured provider verbatim, and Google's `Schema`
(Vertex/Gemini `functionDeclaration.parameters`) accepts only a single `type` per
@@ -1347,7 +1392,7 @@ docker run --rm akitaonrails/ai-memory:latest \
```
The curl script installer supports
`--agent claude-code|codex|cursor|gemini-cli|antigravity-cli|grok|opencode|openclaw|omp|oh-my-pi|pi`
`--agent claude-code|codex|cursor|gemini-cli|antigravity-cli|grok|opencode|opencode2|openclaw|omp|oh-my-pi|pi`
and `--to <dir>`; `--help` prints the full flag list. OpenCode,
OpenClaw, OMP / Oh My Pi, and Pi do not need script extraction because
`install-hooks` generates TypeScript plugin/extension files for them
+1 -1
View File
@@ -1,7 +1,7 @@
# Adding a managed harness
Managed-workstream support is narrower than MCP or lifecycle-hook support. This
release can manage Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, Command
release can manage Claude Code, Codex, OpenCode, OpenCode 2 beta, Pi, Crush, Kimi Code, Command
Code, Kiro CLI v2/v3, OMP, Grok Build CLI, and Antigravity CLI. Gemini CLI,
Devin CLI, Cursor, and the other integrations in the README support matrix do
not become managed merely because ai-memory can capture their hooks.
+17 -3
View File
@@ -1,7 +1,7 @@
# Managed cross-harness workstreams
`ai-memory run` is an opt-in launcher that lets one logical coding session move
between Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, Command Code, Kiro
between Claude Code, Codex, OpenCode, OpenCode 2 beta, Pi, Crush, Kimi Code, Command Code, Kiro
CLI v2/v3, OMP, Grok Build CLI, and Antigravity CLI. Direct agent launches
keep their existing ai-memory behavior. There is no global mode toggle and no
`switch` command: using `run` selects the current workstream and transparently
@@ -42,7 +42,7 @@ file, and the current checkout remain authoritative.
ai-memory run [--workspace NAME] [--project NAME]
[--workstream NAME | --new NAME] [--executable PATH]
[--yolo] [--fresh]
[claude|codex|opencode|pi|crush|omp|kimi|command-code|kiro|grok|antigravity]
[claude|codex|opencode|opencode2|pi|crush|omp|kimi|command-code|kiro|grok|antigravity]
[native arguments...]
```
@@ -211,7 +211,20 @@ has a usable local session. It never chooses a newer but obsolete session from
another harness merely because that file has a later timestamp. Kiro's v2 and
v3 candidates share one server agent identity, but the selected native engine
flavor remains exact. OMP, Grok, and Antigravity remain available explicitly
but are not in the automatic pool.
but are not in the automatic pool. OpenCode 2 is likewise explicit-only
(`ai-memory run opencode2`): it shares v1's session store, so listing both
would duplicate every candidate.
OpenCode 2 sessions run inside a shared background service, so its plugin
cannot see a managed run's environment the way in-process plugins do. Managed
`run opencode2` legs launch with the correct resume selectors, capture through
hooks, and import the beta transcript — all verified live — but the
ledger-delta acknowledgement (`context_delivered`) does not fire, because the
accept path requires the run id on the session-start hook. The failure mode is
redelivery, never loss: a later leg into another harness may receive the range
again. Until the beta offers per-invocation plugin context, cross-harness
continuity into opencode2 arrives through the ordinary handoff loop rather
than the ledger delta.
Bare mode accepts wrapper options but not harness-native arguments or
`--executable`, because their meaning depends on the selected harness. In a new
@@ -309,6 +322,7 @@ is labelled completed evidence and must never be replayed as a pending call.
| Claude Code | generated `--session-id` | `--resume <id>` | `~/.claude/projects/**/*.jsonl` |
| Codex | native default creation | `resume <id>` | `~/.codex/sessions/**/rollout-*.jsonl` |
| OpenCode | native default creation | `--session <id>` | `~/.local/share/opencode/opencode.db` opened read-only |
| OpenCode 2 beta | native default creation | `--session <id>` | same `opencode.db` as v1 (the beta channel keeps v1's filename; the beta adds `session_v2`/`session_message` tables beside v1's); launched via the `opencode2` binary |
| Pi | generated `--session-id` | `--session <id>` | `~/.pi/agent/sessions/**/*.jsonl` |
| Crush | native default creation | `--session <id>` | `<project>/.crush/crush.db` opened read-only |
| Kimi Code | native default creation | `--session <id>` | `$KIMI_CODE_HOME/sessions/*/*/agents/main/wire.jsonl` |
+5 -3
View File
@@ -1277,11 +1277,13 @@ that *starts* the next one - to play nicely with ai-memory:
| Side | What's needed | Covered by |
|---|---|---|
| **Ending side** | The agent must create a handoff through a true session-end hook, the manual finalizer, or `memory_handoff_begin`. | Built-in automatically for Claude Code, Devin CLI, Cursor, Gemini CLI, Grok Build CLI, Zero, Kimi Code, OpenClaw, OpenCode, and OMP. Codex, Antigravity CLI, both Kiro CLI engines, and Command Code have no reliable true session-end event; run `ai-memory finalize-session` with the corresponding `--agent` after the final turn. MCP-only clients such as Swival must call `memory_handoff_begin` explicitly. |
| **Starting side** | Either (a) the session-start/plugin path injects the handoff via `/handoff`, OR (b) the model proactively calls `memory_handoff_accept` on first turn. | (a) is built-in for Claude Code / Codex / Devin CLI / Cursor / Gemini CLI / Antigravity CLI / Kimi Code / both Kiro CLI engines / Command Code / OpenClaw / OpenCode / OMP. It requires a client that consumes startup-hook stdout or an equivalent context-injection result. Grok and Zero discard SessionStart stdout; Swival is MCP-only. Use (b) for those clients. (b) works for any MCP-capable client if you nudge the model - see [the managed routing package](usage.md#install-the-routing-snippet-and-agent-skills). |
| **Ending side** | The agent must create a handoff through a true session-end hook, the manual finalizer, or `memory_handoff_begin`. | Built-in automatically for Claude Code, Devin CLI, Cursor, Gemini CLI, Grok Build CLI, Zero, Kimi Code, OpenClaw, OpenCode, OpenCode 2 beta, and OMP. Codex, Antigravity CLI, both Kiro CLI engines, and Command Code have no reliable true session-end event; run `ai-memory finalize-session` with the corresponding `--agent` after the final turn. MCP-only clients such as Swival must call `memory_handoff_begin` explicitly. |
| **Starting side** | Either (a) the session-start/plugin path injects the handoff via `/handoff`, OR (b) the model proactively calls `memory_handoff_accept` on first turn. | (a) is built-in for Claude Code / Codex / Devin CLI / Cursor / Gemini CLI / Antigravity CLI / Kimi Code / both Kiro CLI engines / Command Code / OpenClaw / OpenCode / OpenCode 2 beta / OMP. It requires a client that consumes startup-hook stdout or an equivalent context-injection result. Grok and Zero discard SessionStart stdout; Swival is MCP-only. Use (b) for those clients. (b) works for any MCP-capable client if you nudge the model - see [the managed routing package](usage.md#install-the-routing-snippet-and-agent-skills). |
OpenCode uses its official `session.deleted` plugin event for true session-end
delivery. Its generated plugin also sends a deduped best-effort close for any
delivery. The OpenCode 2 beta plugin subscribes to the same event name on the
V2 event stream and injects handoffs through the `context` hook instead of
v1's removed `experimental.chat.system.transform`. Its generated plugin also sends a deduped best-effort close for any
still-active sessions from `dispose` during normal plugin teardown; abrupt
process exits can still lose that fallback, so `session.deleted` remains the
primary close path.
+2 -1
View File
@@ -15,12 +15,13 @@
| Command Code | Supported | MCP config (`~/.commandcode/mcp.json`) + its four stable lifecycle-hook events (`~/.commandcode/settings.json`); native commands enforce capture exclusions and `SessionStart` injects handoffs. `Stop` is only a turn boundary, so use `ai-memory finalize-session --agent command-code` after the final turn. `ai-memory run command-code` adds exact v3 native-session resume and visible-event import; experimental unsandboxed Mods remain excluded. |
| Devin CLI | Supported | MCP config + lifecycle hooks. Hooks use Devin's `PostCompaction` event, inject handoffs via `hookSpecificOutput.additionalContext`, and omit subagent events because Devin does not expose them. |
| OpenCode | Supported | Remote MCP config + generated TypeScript plugin; generated plugin enforces capture exclusions. |
| OpenCode 2 (`opencode2` beta) | Supported | V2 `mcp.servers` remote MCP config + generated `Plugin.define` TypeScript plugin (`ai-memory-opencode2.ts`); shares v1's config dir, session store, and agent kind. Managed runs launch, resume, and import; ledger-delta acknowledgement is limited by the shared background service (see managed-workstreams notes). Beta plugin API and schema — expect churn. |
| Cursor | Supported | MCP config + lifecycle hooks. |
| Gemini CLI | Supported | MCP config + lifecycle hooks. |
| Oh My Pi / OMP | Supported | Use `--client omp` / `--agent omp` (or `oh-my-pi`) for native `.omp` MCP config + TypeScript extension; generated extension enforces capture exclusions. |
| Pi | Supported | Generated `~/.pi/agent/extensions/ai-memory-pi.ts` extension provides lifecycle capture and an HTTP MCP bridge; generated extension enforces capture exclusions. |
| Crush | Managed-only | `ai-memory run crush` resumes its project-local session database and supplies portable context through a temporary supported global-context file; no lifecycle-hook installer is provided. |
| Managed workstreams | Opt-in | `ai-memory run` provides transparent cross-harness continuity for Claude Code, Codex, OpenCode, Pi, Crush, Kimi Code, Command Code, both incompatible Kiro CLI engines, OMP, Grok Build CLI, and Antigravity CLI. Direct launches remain unchanged. See [`docs/managed-workstreams.md`](docs/managed-workstreams.md). |
| Managed workstreams | Opt-in | `ai-memory run` provides transparent cross-harness continuity for Claude Code, Codex, OpenCode, OpenCode 2 beta, Pi, Crush, Kimi Code, Command Code, both incompatible Kiro CLI engines, OMP, Grok Build CLI, and Antigravity CLI. Direct launches remain unchanged. See [`docs/managed-workstreams.md`](docs/managed-workstreams.md). |
| Claude Desktop | MCP-only | Uses `mcp-remote`; no lifecycle hooks. |
| OpenClaw | Supported | MCP config + native plugin lifecycle hooks; generated plugin enforces capture exclusions. |
| Antigravity CLI | Supported | MCP config (`serverUrl`) + lifecycle hooks (`agy` alias). Only `PreInvocation` with `invocationNum = 0` maps to SessionStart; later model calls cannot consume a next-session handoff. No automatic true session-end hook, so run `ai-memory finalize-session --agent antigravity-cli` after the final turn when you need a summary, handoff, and opt-in SessionEnd consolidation. `ai-memory run antigravity` (aliases `antigravity-cli`, `agy`) adds managed workstream resume via `--conversation`; conversation text is not decoded, so the ledger for this harness comes from hook capture. |
+3 -1
View File
@@ -318,6 +318,8 @@ Client cleanup hints:
under `$KIRO_HOME` when set) for stale ai-memory entries.
- OpenCode, OpenClaw, and OMP: check MCP config and plugin/extension directories;
move old memory plugins to a disabled/quarantine directory before deleting.
For the OpenCode 2 beta the plugin file is `ai-memory-opencode2.ts` and the
MCP entry lives under `mcp.servers` in the same `opencode.json(c)`.
- VS Code Copilot, Claude Desktop, and Zed: these are MCP-only, so confirm
whether the old tool was providing capture hooks elsewhere. Zed's MCP
entries live under `context_servers` in its user `settings.json`.
@@ -451,7 +453,7 @@ page and no argument, ai-memory appends no preference block.
Durable project rules belong in the agent's rules file, not only in the
wiki. For Claude Code that is `CLAUDE.md`; for Codex, Devin CLI, OpenCode,
Cursor, Gemini CLI, Grok Build CLI, Kimi Code, Kiro CLI, and Command Code it is usually
OpenCode 2 beta, Cursor, Gemini CLI, Grok Build CLI, Kimi Code, Kiro CLI, and Command Code it is usually
`AGENTS.md`.
The consolidator classifies compiled observations as `decision`,
+1 -1
View File
@@ -81,7 +81,7 @@
the workstream immediately. If a linked native transcript was deleted,
ai-memory detects the orphan before launch and starts fresh; `--fresh` forces
that recovery for one harness. Managed mode currently covers Claude Code,
Codex, OpenCode, Pi, Crush, Kimi Code, Command Code, Kiro CLI v2/v3, OMP,
Codex, OpenCode, OpenCode 2 beta, Pi, Crush, Kimi Code, Command Code, Kiro CLI v2/v3, OMP,
Grok Build CLI, and Antigravity CLI; direct harness launches remain unchanged. See
[Managed cross-harness workstreams](docs/managed-workstreams.md).
- **"Just put me back where I was."** From any directory, with no name to
+2 -1
View File
@@ -558,7 +558,8 @@ Windows agent builds.
- MCP over HTTP should be less path-sensitive than hooks, but
`install-mcp --apply` still writes to a client-specific config file;
confirm the agent actually loads it.
- OpenClaw, OpenCode, OMP / Oh My Pi, and Pi use generated TypeScript
- OpenClaw, OpenCode (v1 `ai-memory.ts` plus the 2 beta's
`ai-memory-opencode2.ts`), OMP / Oh My Pi, and Pi use generated TypeScript
integrations rather than the shell hook bundle, so their Windows
behavior depends on the host runtime loading those files correctly.
Pi's generated extension also bridges MCP tools because Pi has no native
+11 -3
View File
@@ -9,7 +9,7 @@
# ai-memory-install-hooks --agent claude-code
#
# Options:
# --agent <claude-code|codex|command-code|cursor|gemini-cli|kimi-code|kiro-cli|antigravity-cli|grok|opencode|openclaw|omp|oh-my-pi|pi>
# --agent <claude-code|codex|command-code|cursor|gemini-cli|kimi-code|kiro-cli|antigravity-cli|grok|opencode|opencode2|openclaw|omp|oh-my-pi|pi>
# which agent (default: claude-code;
# generated-plugin agents print hints)
# --to <dir> install root (default: $HOME/.ai-memory/hooks)
@@ -46,11 +46,12 @@ while [[ $# -gt 0 ]]; do
done
case "$AGENT" in
claude-code|codex|command-code|cursor|gemini-cli|kimi-code|kiro-cli|antigravity-cli|grok|opencode|openclaw|omp|pi|oh-my-pi) ;;
claude-code|codex|command-code|cursor|gemini-cli|kimi-code|kiro-cli|antigravity-cli|grok|opencode|opencode2|openclaw|omp|pi|oh-my-pi) ;;
commandcode|cmdc|cmd) AGENT="command-code" ;;
kiro) AGENT="kiro-cli" ;;
opencode-v2|open-code2) AGENT="opencode2" ;;
*)
echo "unsupported agent: $AGENT (expected claude-code | codex | command-code | cursor | gemini-cli | kimi-code | kiro-cli | antigravity-cli | grok | opencode | openclaw | omp | pi | oh-my-pi)" >&2
echo "unsupported agent: $AGENT (expected claude-code | codex | command-code | cursor | gemini-cli | kimi-code | kiro-cli | antigravity-cli | grok | opencode | opencode2 | openclaw | omp | pi | oh-my-pi)" >&2
exit 64 ;;
esac
@@ -70,6 +71,13 @@ if [[ "$AGENT" == "opencode" ]]; then
exit 0
fi
if [[ "$AGENT" == "opencode2" ]]; then
echo "OpenCode 2 uses a generated TypeScript plugin, not shell hook scripts."
echo "Run: ai-memory install-hooks --agent opencode2 --apply"
echo "Then restart OpenCode 2 so it loads ~/.config/opencode/plugins/ai-memory-opencode2.ts."
exit 0
fi
if [[ "$AGENT" == "openclaw" ]]; then
echo "OpenClaw uses a generated native TypeScript plugin, not shell hook scripts."
echo "Run: ai-memory install-hooks --agent openclaw --apply"
+24 -1
View File
@@ -1365,6 +1365,7 @@ CODEX_ACCEPTANCE_HOME="$CONFIG/codex-home"
CODEX_HOOKS="$CODEX_ACCEPTANCE_HOME/.codex/hooks.json"
OPENCODE_CONFIG_HOME="$CONFIG/opencode-xdg"
OPENCODE_PLUGIN="$OPENCODE_CONFIG_HOME/opencode/plugins/ai-memory.ts"
OPENCODE2_PLUGIN="$OPENCODE_CONFIG_HOME/opencode/plugins/ai-memory-opencode2.ts"
OPENCODE_DATA_HOME="$CONFIG/opencode-xdg-data"
PI_EXTENSION="$CONFIG/pi/ai-memory.ts"
OMP_EXTENSION="$CONFIG/omp/ai-memory.ts"
@@ -1422,6 +1423,22 @@ for config_name in opencode.json opencode.jsonc tui.json; do
fi
done
# opencode2's background service defaults to port 49374 — ai-memory's own
# default — so an isolated service left on defaults crash-loops against any
# live ai-memory server on this machine. Pin the fixture service at a
# neighbouring free port before any opencode2 leg runs.
if command -v opencode2 >/dev/null 2>&1; then
OPENCODE2_PORT=$((PORT + 1))
for _ in $(seq 1 50); do
if ! curl -sS --max-time 0.1 "http://127.0.0.1:$OPENCODE2_PORT/" >/dev/null 2>&1; then
break
fi
OPENCODE2_PORT=$((OPENCODE2_PORT + 1))
done
XDG_CONFIG_HOME="$OPENCODE_CONFIG_HOME" XDG_DATA_HOME="$OPENCODE_DATA_HOME" \
opencode2 service set port "$OPENCODE2_PORT" >/dev/null
fi
# Kimi Code keeps providers/model and hooks in one config.toml under
# $KIMI_CODE_HOME. Seed the isolated home with the operator's provider
# settings and minimum login state; install-hooks merges its [[hooks]]
@@ -1496,6 +1513,7 @@ install_hook() {
install_hook claude-code "$CLAUDE_SETTINGS"
install_hook codex "$CODEX_HOOKS"
install_hook opencode "$OPENCODE_PLUGIN"
install_hook opencode2 "$OPENCODE2_PLUGIN"
install_hook pi "$PI_EXTENSION"
install_hook omp "$OMP_EXTENSION"
install_hook kimi-code "$KIMI_ACCEPTANCE_HOME/config.toml"
@@ -1506,6 +1524,7 @@ agent_wire_name() {
case "$1" in
claude) printf 'claude-code\n' ;;
opencode) printf 'open-code\n' ;;
opencode2) printf 'open-code\n' ;;
kimi) printf 'kimi-code\n' ;;
command-code) printf 'command-code\n' ;;
antigravity) printf 'antigravity-cli\n' ;;
@@ -1557,6 +1576,10 @@ run_harness() {
native_args=(run --format json --auto "$prompt")
[ -z "${AI_MEMORY_ACCEPTANCE_OPENCODE_MODEL:-}" ] || native_args=(run --format json --auto --model "$AI_MEMORY_ACCEPTANCE_OPENCODE_MODEL" "$prompt")
;;
opencode2)
native_args=(run --format json --auto "$prompt")
[ -z "${AI_MEMORY_ACCEPTANCE_OPENCODE2_MODEL:-${AI_MEMORY_ACCEPTANCE_OPENCODE_MODEL:-}}" ] || native_args=(run --format json --auto --model "${AI_MEMORY_ACCEPTANCE_OPENCODE2_MODEL:-$AI_MEMORY_ACCEPTANCE_OPENCODE_MODEL}" "$prompt")
;;
pi)
native_args=(-p --no-tools --no-extensions --extension "$PI_EXTENSION" --session-dir "$CONFIG/pi/sessions" "$prompt")
[ -z "${AI_MEMORY_ACCEPTANCE_PI_MODEL:-}" ] || native_args=(-p --no-tools --no-extensions --extension "$PI_EXTENSION" --session-dir "$CONFIG/pi/sessions" --model "$AI_MEMORY_ACCEPTANCE_PI_MODEL" "$prompt")
@@ -1601,7 +1624,7 @@ run_harness() {
CODEX_HOME="$CODEX_ACCEPTANCE_HOME/.codex" \
"$BIN" --data-dir "$DATA" run "${wrapper_args[@]}" "$harness" "${native_args[@]}") \
>"$log" 2>&1
elif [ "$harness" = opencode ]; then
elif [ "$harness" = opencode ] || [ "$harness" = opencode2 ]; then
(cd "$REPO" && XDG_CONFIG_HOME="$OPENCODE_CONFIG_HOME" \
XDG_DATA_HOME="$OPENCODE_DATA_HOME" \
"$BIN" --data-dir "$DATA" run "${wrapper_args[@]}" "$harness" "${native_args[@]}") \