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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,9 @@ ends of: a request injected into it would return its answer down Ora's pipe, and
the client capabilities Ora declares in its own `initialize` are what decide
whether OpenCode reports a model selector at all.

Answers are reused for five minutes per workspace, so a picker that re-renders does
not restart the CLI, while a model that appears after a provider login shows up
the next time the picker is opened.
Answers are reused for five minutes per workspace, so a picker that re-renders
does not restart the CLI, while a model that appears after a provider login
shows up the next time the picker is opened.

## Known limits

Expand All @@ -157,3 +157,11 @@ the next time the picker is opened.
up with; the five-minute cache is what keeps the count down.
- Killing the CLI on agent stop is best effort; Ora retains process-tree reaping
as a backstop.

## Project Effects

Ora manages `.opencode/skills` as a Skill Effect Resource. Configured MCP
plugins are not written into `.opencode/opencode.json`; Ora injects them through
ACP `session/new` and `session/load` `mcpServers`, which this plugin forwards
unchanged. Secret values stay in Ora's configuration store and never appear in
Workspace files, logs, or `ORA_MCP_*` environment variables.
5 changes: 3 additions & 2 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ora-space/opencode-agent",
"version": "0.1.0",
"version": "0.6.0",
"exports": "./src/main.ts",
"minimumDependencyAge": 0,
"imports": {
Expand All @@ -11,9 +11,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/acp-forward.test.ts",
"lint": "deno lint src scripts tests bundle.config.ts",
"format": "deno fmt src scripts tests bundle.config.ts deno.json README.md",
"test": "deno test --allow-read tests/acp-forward.test.ts",
"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",
"build": "deno bundle src/main.ts -o dist/main.js",
Expand Down
12 changes: 10 additions & 2 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 src/handlers/acp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { JsonValue } from "@ora-space/plugin-sdk";
import type { SkillEffectCoordinator } from "./effects.ts";
import type { AgentEffectCoordinator } from "./effects.ts";
import type { OpenCodeClient } from "../services/opencode-client.ts";

/**
Expand All @@ -16,7 +16,7 @@ import type { OpenCodeClient } from "../services/opencode-client.ts";
*/
export function forwardAcpFrame(
client: OpenCodeClient,
effects: SkillEffectCoordinator,
effects: AgentEffectCoordinator,
frame: JsonValue,
): Promise<void> | void {
if (effects.intercept(frame)) {
Expand Down
6 changes: 3 additions & 3 deletions src/handlers/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const QUIESCE_POLL_MS = 50;
* what was held, and `verifyReady` reports whether the process Ora is about to mark ready is one
* that has actually read the Skills on disk.
*/
export class SkillEffectCoordinator {
export class AgentEffectCoordinator {
readonly #client: OpenCodeClient;
readonly #cwd: () => string | undefined;
readonly #openTurns = new Set<string | number>();
Expand Down Expand Up @@ -179,13 +179,13 @@ export class SkillEffectCoordinator {
if (!this.#client.running) {
throw new PluginMethodError(
CONSUMER_NOT_READY,
"the OpenCode CLI is not running, so it has read no Skills",
"the OpenCode CLI is not running, so it has not read project Skills",
);
}
if (this.#held !== undefined) {
throw new PluginMethodError(
CONSUMER_NOT_READY,
"OpenCode is quiesced for a Skill mutation and has not rescanned yet",
"OpenCode is quiesced for a project Effect mutation and has not reloaded yet",
);
}
return {
Expand Down
4 changes: 2 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
runAgentPlugin,
} from "./base/agent-plugin.ts";
import { forwardAcpFrame } from "./handlers/acp.ts";
import { SkillEffectCoordinator } from "./handlers/effects.ts";
import { AgentEffectCoordinator } from "./handlers/effects.ts";
import { startOpenCode, stopOpenCode } from "./handlers/lifecycle.ts";
import {
invalidateAllOpenCodeModels,
Expand Down Expand Up @@ -59,7 +59,7 @@ class OpenCodeAgentPlugin extends AgentPlugin {
},
});

readonly #effects = new SkillEffectCoordinator(this.#client, () => this.#cwd);
readonly #effects = new AgentEffectCoordinator(this.#client, () => this.#cwd);

override readonly effects = this.#effects.definition;

Expand Down
68 changes: 68 additions & 0 deletions tests/acp-forward.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { assertEquals } from "jsr:@std/assert@1";
import type { JsonValue } from "@ora-space/plugin-sdk";
import {
AgentEffectCoordinator,
SKILLS_RESOURCE,
} from "../src/handlers/effects.ts";
import { OpenCodeClient } from "../src/services/opencode-client.ts";

/** Secret-bearing stdio and HTTP servers the host injects through ACP. */
function mcpServers(): JsonValue[] {
return [
{
type: "stdio",
name: "ora-space/tavily-search",
command: "/pkg/assets/server",
args: ["."],
env: [{ name: "TAVILY_API_KEY", value: "super-secret" }],
},
{
type: "http",
name: "ora-space/alpha-search",
url: "https://mcp.example.test/mcp",
headers: [{ name: "Authorization", value: "Bearer super-secret" }],
},
];
}

Deno.test("registers only the Skill Effect Resource", () => {
const effects = new AgentEffectCoordinator(
new OpenCodeClient(),
() => undefined,
);
assertEquals(effects.definition.resources, [SKILLS_RESOURCE]);
});

Deno.test("does not intercept session/new mcpServers", () => {
const effects = new AgentEffectCoordinator(
new OpenCodeClient(),
() => undefined,
);
const frame = {
jsonrpc: "2.0",
id: 2,
method: "session/new",
params: { cwd: "/workspace", mcpServers: mcpServers() },
};
assertEquals(effects.intercept(frame), false);
assertEquals(frame.params.mcpServers, mcpServers());
});

Deno.test("does not intercept session/load mcpServers", () => {
const effects = new AgentEffectCoordinator(
new OpenCodeClient(),
() => undefined,
);
const frame = {
jsonrpc: "2.0",
id: 3,
method: "session/load",
params: {
sessionId: "ses_1",
cwd: "/workspace",
mcpServers: mcpServers(),
},
};
assertEquals(effects.intercept(frame), false);
assertEquals(frame.params.mcpServers, mcpServers());
});
47 changes: 41 additions & 6 deletions tests/host-simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ interface SimulatedChildProcess {

const simulatedChildProcesses = new Map<string, SimulatedChildProcess>();
let nextChildProcessId = 1;
let agentProcessSpawns = 0;

/** Recognizes a plugin-to-host request for `ora/childprocess/*`. */
function isChildProcessRequest(
Expand Down Expand Up @@ -284,11 +285,28 @@ async function dispatchChildProcessMethod(
const command = resolveSimulatedProgram(params);
const args = (params.args as string[] | undefined) ?? [];
const cwd = (params.cwd as string | null | undefined) ?? undefined;
const requestedEnvironment = (params.env ?? {}) as Record<string, string>;
if (
Object.keys(requestedEnvironment).some((key) =>
key.startsWith("ORA_MCP_")
)
) {
throw new SimulatedSpawnError(
"invalid_params",
"reserved MCP environment",
);
}
if (args.includes("acp")) {
agentProcessSpawns += 1;
}
let child: Deno.ChildProcess;
try {
child = new Deno.Command(command, {
args,
cwd,
env: {
...requestedEnvironment,
},
stdin: "piped",
stdout: "piped",
stderr: "piped",
Expand Down Expand Up @@ -404,11 +422,23 @@ const register = await waitFor(
);
console.log(`ok: register ${JSON.stringify(register.params)}`);

const effectResources =
(register.params as { effectResources?: unknown[] } | undefined)
?.effectResources ?? [];
if (effectResources.length === 0) {
throw new Error("registration did not declare any Effect Resource");
const effectResources = (register.params as
| { effectResources?: Record<string, unknown>[] }
| undefined)
?.effectResources ?? [];
const resourceSignatures = effectResources.map((resource) =>
`${resource.workspaceRelativePath}:${resource.materializationFormat}`
).sort();
const expectedResourceSignatures = [
".opencode/skills:ora/skill-directory.v1",
];
if (
JSON.stringify(resourceSignatures) !==
JSON.stringify(expectedResourceSignatures)
) {
throw new Error(
`unexpected Effect Resources: ${JSON.stringify(effectResources)}`,
);
}
console.log(`ok: effectResources ${JSON.stringify(effectResources)}`);

Expand Down Expand Up @@ -497,7 +527,7 @@ console.log(
// from these, so any stable pair drives the same code the host would.
const coordinationParams = {
targetId: "sim-target",
resourceIds: ["sim-resource"],
resourceIds: ["sim-skills"],
};

await send({
Expand Down Expand Up @@ -591,6 +621,11 @@ const sessionAfterRestart = await waitFor(
(message) => message.method === "agent/acp" && acpFrame(message).id === 3,
"ACP session/new after restart",
);
if (agentProcessSpawns !== 2) {
throw new Error(
`expected one initial spawn and one shared Effect restart, got ${agentProcessSpawns}`,
);
}
console.log(
`ok: session/new after restart ${
JSON.stringify(
Expand Down