Skip to content
Closed
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
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ conversation runs against the OpenCode CLI through its native
to run by hand.
- Streams OpenCode's models, sessions, and responses straight through to Ora's
UI via ACP, including the in-session model picker.
- Materializes Ora's complete HTTP MCP snapshot into the Workspace-local,
exclusively managed `.opencode/opencode.json` document. OpenCode 0.3.0 does
not advertise stdio MCP support, so the Host skips those MCPs without blocking
supported HTTP servers.
- Ships with the OpenCode CLI bundled inside the package, so there is nothing
else to install.

Expand Down Expand Up @@ -46,7 +50,7 @@ cmd /c mklink /J "<ORA_DATA_DIR>\plugins" "%USERPROFILE%\.ora\plugins\installed"

```
deno task build
deno task package --tag v0.2.4 --repo ora-space/opencode-agent
deno task package --tag v0.3.0 --repo ora-space/opencode-agent
```

This produces one `.orax` package per target platform, with the matching
Expand All @@ -71,6 +75,25 @@ the binary under `assets/bin/` — never one on your `PATH`. That directory is
git-ignored and is only needed for this.

`deno task check` type checks and `deno task lint` lints the sources.
`deno task test` exercises MCP materialization in temporary Git and non-Git
Workspaces.

## Managed MCP configuration

Ora-generated MCP configuration is intentionally separate from user-owned
OpenCode configuration. The plugin never modifies project-root `opencode.json`
or `opencode.jsonc`. It writes only `.opencode/opencode.json`, and only when its
private applied/prepared fingerprint ledger proves ownership; a pre-existing or
externally changed file is preserved as a blocking conflict.

In a Git Workspace, only `/.opencode/opencode.json` is added to the repository's
local exclude file. The plugin does not touch `.gitignore` or ignore the
`.opencode` directory, so the neighboring Skill surface remains visible. The
managed document is atomically replaced from a same-directory staging file and
restricted to the current OS account because protocol v1 may contain resolved
plaintext headers. Root configuration collisions, tracked managed paths, Git
failures, and permission failures leave the previous committed document
unchanged.

## Known limits

Expand Down
8 changes: 5 additions & 3 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
{
"name": "@ora-space/opencode-agent",
"version": "0.1.0",
"version": "0.3.0",
"exports": "./src/main.ts",
"minimumDependencyAge": 0,
"imports": {
"@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.5.0",
"@ora-space/plugin-sdk": "jsr:@ora-space/plugin-sdk@0.6.0",
"@std/cli": "jsr:@std/cli@^1.0.32",
"@std/jsonc": "jsr:@std/jsonc@^1.0.2",
"@std/path": "jsr:@std/path@^1.1.6",
"@std/tar": "jsr:@std/tar@^0.1.10",
"@zip-js/zip-js": "jsr:@zip-js/zip-js@^2.8.61"
},
"tasks": {
"check": "deno check src/main.ts scripts/package.ts tests/host-simulator.ts",
"check": "deno check src/main.ts scripts/package.ts tests/host-simulator.ts tests/mcp-materializer.test.ts",
"lint": "deno lint src scripts tests bundle.config.ts",
"test": "deno test --allow-read --allow-write --allow-run tests/mcp-materializer.test.ts tests/mcp-registration.test.ts",
"format": "deno fmt src scripts tests bundle.config.ts deno.json package.json README.md",
"simulate": "deno run --allow-run --allow-read --allow-env --allow-net tests/host-simulator.ts",
"dev": "deno run --no-prompt --allow-run --allow-read --allow-env --allow-net src/main.ts",
Expand Down
18 changes: 13 additions & 5 deletions deno.lock

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@ora-space/opencode-agent",
"version": "0.1.0",
"description": "Ora agent plugin that runs OpenCode as an ACP agent.",
"version": "0.3.0",
"description": "Ora ACP agent plugin with safe HTTP MCP materialization for OpenCode.",
"type": "module",
"license": "Apache-2.0",
"ora": {
Expand Down
17 changes: 16 additions & 1 deletion src/base/agent-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {
type AcpSender,
type AgentEffectDefinition,
type AgentMcpConfigurationDefinition,
type AgentModel,
type AgentStartContext,
createHostProcesses,
createStorage,
defineAgent,
type HostProcesses,
type JsonValue,
type PluginStorage,
} from "@ora-space/plugin-sdk";

/**
Expand All @@ -20,6 +23,7 @@ import {
export interface PluginContext {
readonly pluginId: string;
readonly processes: HostProcesses;
readonly storage: PluginStorage;
}

/** What the caller of {@link runAgentPlugin} supplies; `processes` is assembled internally. */
Expand Down Expand Up @@ -98,6 +102,15 @@ export abstract class AgentPlugin {
* way `onStart` and friends are mounted above.
*/
effects: AgentEffectDefinition | undefined = undefined;

/**
* Declares MCP capability and handler as one SDK definition.
*
* Keeping this property high-level is intentional: the SDK is the only layer allowed to pair
* the registration capability with `agent/configureWorkspace`, so subclasses cannot publish a
* one-sided contract.
*/
mcpConfiguration: AgentMcpConfigurationDefinition | undefined = undefined;
}

/** One entry of the flattened dispatch table, already bound to its plugin instance. */
Expand Down Expand Up @@ -136,9 +149,11 @@ export async function runAgentPlugin(
| void
| Promise<void>,
effects: plugin.effects,
mcpConfiguration: plugin.mcpConfiguration,
});
const processes = createHostProcesses(definition);
await plugin.onActivate({ pluginId: options.pluginId, processes });
const storage = createStorage(definition);
await plugin.onActivate({ pluginId: options.pluginId, processes, storage });

try {
await definition.run();
Expand Down
15 changes: 15 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import { SkillEffectCoordinator } from "./handlers/effects.ts";
import { startOpenCode, stopOpenCode } from "./handlers/lifecycle.ts";
import { listOpenCodeModels } from "./handlers/models.ts";
import { OpenCodeClient } from "./services/opencode-client.ts";
import { defineOpenCodeMcpConfiguration } from "./mcp/definition.ts";
import { PluginStorageManagedStateStore } from "./mcp/ledger.ts";
import {
createOpenCodeMcpMaterializer,
type OpenCodeMcpMaterializer,
} from "./mcp/materializer.ts";

/** Must match `ora.id` in package.json, which is also this agent's identity inside Ora. */
const PLUGIN_ID = "ora-space.opencode";
Expand All @@ -33,6 +39,8 @@ class OpenCodeAgentPlugin extends AgentPlugin {
#cwd: string | undefined;
/** Set by `onActivate`, which the base class runs before the host can call anything. */
#processes: HostProcesses | undefined;
/** Installed during activation, before the SDK starts accepting configuration calls. */
#mcpMaterializer: OpenCodeMcpMaterializer | undefined;

readonly #client = new OpenCodeClient({
onAcpFrame: (frame) => {
Expand All @@ -54,10 +62,17 @@ class OpenCodeAgentPlugin extends AgentPlugin {

override readonly effects = this.#effects.definition;

override readonly mcpConfiguration = defineOpenCodeMcpConfiguration(
() => this.#mcpMaterializer,
);

override onActivate(context: PluginContext): void {
console.info(`${context.pluginId} activated`);
this.#processes = context.processes;
this.#client.attachProcesses(context.processes);
this.#mcpMaterializer = createOpenCodeMcpMaterializer(
new PluginStorageManagedStateStore(context.storage),
);
}

override onStart = async (
Expand Down
19 changes: 19 additions & 0 deletions src/mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# OpenCode MCP materialization

This module owns the OpenCode-specific half of Ora's MCP Configuration
Capability. It converts each complete protocol-v1 HTTP snapshot into the single
Workspace document `.opencode/opencode.json`; it does not merge with or modify
the project-root OpenCode configuration.

The materializer treats the generated document as an all-or-nothing managed
resource. A private plugin-storage ledger records both the last applied
fingerprint and a prepared operation before filesystem mutation. That evidence
allows an interrupted operation to be replayed without adopting a file merely
because it occupies the managed path. Root configuration collisions, tracked
files, Git exclude failures, permission failures, and fingerprint drift are
blocking preserved-state failures.

Filesystem staging, atomic replacement, permission restriction, Git inspection,
and ledger persistence are separate injectable ports. Production uses Deno and
Git implementations; tests substitute narrow failure implementations so cleanup
and previous-document preservation remain observable.
21 changes: 21 additions & 0 deletions src/mcp/definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { AgentMcpConfigurationDefinition } from "@ora-space/plugin-sdk";
import { McpMaterializationError } from "./errors.ts";
import type { OpenCodeMcpMaterializer } from "./materializer.ts";

/** Builds the one high-level SDK definition that pairs OpenCode's HTTP capability and handler. */
export function defineOpenCodeMcpConfiguration(
materializer: () => OpenCodeMcpMaterializer | undefined,
): AgentMcpConfigurationDefinition {
return {
protocolVersion: 1,
transports: ["http"],
coordination: "wait_for_idle_and_restart",
configureWorkspace: (request) => {
const active = materializer();
if (active === undefined) {
throw new McpMaterializationError("mcp_materialization_conflict");
}
return active.configureWorkspace(request);
},
};
}
23 changes: 23 additions & 0 deletions src/mcp/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** Stable public failure codes emitted by OpenCode MCP materialization. */
export type McpMaterializationErrorCode =
| "mcp_materialization_conflict"
| "mcp_native_key_collision"
| "mcp_config_file_tracked"
| "mcp_config_git_exclude_failed"
| "mcp_config_permissions_failed";

/**
* Carries only a stable code so a thrown error cannot accidentally echo snapshot secrets.
*
* The SDK converts the message to JSON-RPC; keeping it equal to the code makes both the wire
* failure and plugin stderr safe even when an upstream error contains a URL or header value.
*/
export class McpMaterializationError extends Error {
readonly code: McpMaterializationErrorCode;

constructor(code: McpMaterializationErrorCode) {
super(code);
this.name = "McpMaterializationError";
this.code = code;
}
}
Loading
Loading