Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
540 changes: 540 additions & 0 deletions docs/developer/opencode-v2-port.md

Large diffs are not rendered by default.

4,143 changes: 3,958 additions & 185 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@
"test": "npm run build && node --test tests/**/*.test.mjs"
},
"devDependencies": {
"@opencode-ai/plugin": "1.15.10",
"@opencode/plugin": "2.2.0",
"@types/node": "^25.3.5",
"esbuild": "^0.25.9",
"typescript": "^5.7.3"
},
"dependencies": {
"@opencode-ai/plugin": "1.15.10",
"yaml": "^2.8.2"
"yaml": "^2.8.2",
"zod": "^4.1.8"
}
}
3 changes: 2 additions & 1 deletion src/adapters/opencode/delegation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export type DelegateTaskErrorCode =
| "agent_session_mismatch"
| "unsupported_mode"
| "nested_delegate_forbidden"
| "self_delegate_forbidden";
| "self_delegate_forbidden"
| "delegate_error";

export interface DelegateTaskArgs {
agent?: string;
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/opencode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export {
type OpenCodeBootstrapInput,
type OpenCodeBootstrapOutput,
} from "./bootstrap";
export { OpenCodeCrewBeePlugin } from "./plugin";
export { OpenCodeCrewBeePlugin } from "./plugin-v1";
export { crewBeePluginV2, setupCrewBeeV2, CREWBEE_PLUGIN_ID } from "./v2/plugin";
export { createChatMessageHook } from "./chat-message-hook";
export {
createOpenCodeAgentConfig,
Expand Down
91 changes: 91 additions & 0 deletions src/adapters/opencode/plugin-v1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { Plugin } from "@opencode-ai/plugin";

import { ensureCrewBeeConfigFile, loadDefaultTeamLibrary } from "../../agent-teams";
import { resolveOpenCodeConfigRoot } from "../../install";
import { createSessionRuntimeBinding, type SessionRuntimeBinding } from "../../runtime";

import { createChatMessageHook } from "./chat-message-hook";
import { createCompactionHook } from "./compaction-hook";
import { createConfigHook, createInitialBootstrap, validateAndLogTeamLibrary } from "./config-hook";
import { createEventHook } from "./event-hook";
import { DelegateStateStore } from "./delegation/store";
import { createDelegateTools } from "./delegation/tools";
import { logCrewBee } from "./logging";
import { createSystemTransformHook } from "./system-transform-hook";
import { createToolDefinitionHook, createToolExecuteAfterHook, createToolExecuteBeforeHook } from "./tool-hooks";
import { startBackgroundReleaseRefresh } from "../../update/refresh";

/**
* OpenCode V1 adapter.
*
* Kept verbatim (apart from its module location) so OpenCode 1.x hosts keep working: the V2 entry
* point exposes this function as `server`, which V1 calls instead of `setup`.
*/
export const OpenCodeCrewBeePlugin: Plugin = async (ctx) => {
const crewbeeConfigUpdate = ensureCrewBeeConfigFile({
configRoot: resolveOpenCodeConfigRoot(),
mode: "startup",
});
if (crewbeeConfigUpdate.changed) {
await logCrewBee(ctx, "CrewBee auto-repaired Team config", {
backupPath: crewbeeConfigUpdate.backupPath,
configPath: crewbeeConfigUpdate.configPath,
reason: crewbeeConfigUpdate.reason,
}, "warn");
}

const teamLibrary = loadDefaultTeamLibrary(ctx.worktree);
await validateAndLogTeamLibrary(ctx, teamLibrary);

const initial = await createInitialBootstrap(ctx, teamLibrary);
await logCrewBee(ctx, "CrewBee plugin initialized", {
worktree: ctx.worktree,
projectedAgentCount: initial.boot.projectedAgents.length,
visibleAgentCount: initial.boot.projectedAgents.filter((agent) => !agent.hidden).length,
defaultAgent: initial.boot.configPatch.defaultAgent,
});
startBackgroundReleaseRefresh(ctx);

let boot = initial.boot;
let aliasIndex = initial.aliasIndex;
const bindings = new Map<string, SessionRuntimeBinding>();
const store = new DelegateStateStore(ctx.worktree);

return {
config: createConfigHook({
ctx,
teamLibrary,
getBoot: () => boot,
setBoot: (next) => {
boot = next;
},
setAliasIndex: (next) => {
aliasIndex = next;
},
}),
tool: createDelegateTools({
client: ctx.client,
store,
getProjectedAgents: () => boot.projectedAgents,
getAliasIndex: () => aliasIndex,
bindings,
}),
event: createEventHook(ctx, store),
"chat.message": createChatMessageHook({
bindings,
store,
getBoot: () => boot,
}),
"tool.definition": createToolDefinitionHook(() => boot.projectedAgents),
"tool.execute.before": createToolExecuteBeforeHook({
bindings,
getAliasIndex: () => aliasIndex,
}),
"tool.execute.after": createToolExecuteAfterHook(),
"experimental.chat.system.transform": createSystemTransformHook(
bindings,
() => teamLibrary.loadIssues ?? [],
),
"experimental.session.compacting": createCompactionHook(ctx, store),
};
};
109 changes: 24 additions & 85 deletions src/adapters/opencode/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,87 +1,26 @@
import type { Plugin } from "@opencode-ai/plugin";

import { ensureCrewBeeConfigFile, loadDefaultTeamLibrary } from "../../agent-teams";
import { resolveOpenCodeConfigRoot } from "../../install";
import { createSessionRuntimeBinding, type SessionRuntimeBinding } from "../../runtime";

import { createChatMessageHook } from "./chat-message-hook";
import { createCompactionHook } from "./compaction-hook";
import { createConfigHook, createInitialBootstrap, validateAndLogTeamLibrary } from "./config-hook";
import { createEventHook } from "./event-hook";
import { DelegateStateStore } from "./delegation/store";
import { createDelegateTools } from "./delegation/tools";
import { logCrewBee } from "./logging";
import { createSystemTransformHook } from "./system-transform-hook";
import { createToolDefinitionHook, createToolExecuteAfterHook, createToolExecuteBeforeHook } from "./tool-hooks";
import { startBackgroundReleaseRefresh } from "../../update/refresh";

export const OpenCodeCrewBeePlugin: Plugin = async (ctx) => {
const crewbeeConfigUpdate = ensureCrewBeeConfigFile({
configRoot: resolveOpenCodeConfigRoot(),
mode: "startup",
});
if (crewbeeConfigUpdate.changed) {
await logCrewBee(ctx, "CrewBee auto-repaired Team config", {
backupPath: crewbeeConfigUpdate.backupPath,
configPath: crewbeeConfigUpdate.configPath,
reason: crewbeeConfigUpdate.reason,
}, "warn");
}

const teamLibrary = loadDefaultTeamLibrary(ctx.worktree);
await validateAndLogTeamLibrary(ctx, teamLibrary);

const initial = await createInitialBootstrap(ctx, teamLibrary);
await logCrewBee(ctx, "CrewBee plugin initialized", {
worktree: ctx.worktree,
projectedAgentCount: initial.boot.projectedAgents.length,
visibleAgentCount: initial.boot.projectedAgents.filter((agent) => !agent.hidden).length,
defaultAgent: initial.boot.configPatch.defaultAgent,
});
startBackgroundReleaseRefresh(ctx);

let boot = initial.boot;
let aliasIndex = initial.aliasIndex;
const bindings = new Map<string, SessionRuntimeBinding>();
const store = new DelegateStateStore(ctx.worktree);

return {
config: createConfigHook({
ctx,
teamLibrary,
getBoot: () => boot,
setBoot: (next) => {
boot = next;
},
setAliasIndex: (next) => {
aliasIndex = next;
},
}),
tool: createDelegateTools({
client: ctx.client,
store,
getProjectedAgents: () => boot.projectedAgents,
getAliasIndex: () => aliasIndex,
bindings,
}),
event: createEventHook(ctx, store),
"chat.message": createChatMessageHook({
bindings,
store,
getBoot: () => boot,
}),
"tool.definition": createToolDefinitionHook(() => boot.projectedAgents),
"tool.execute.before": createToolExecuteBeforeHook({
bindings,
getAliasIndex: () => aliasIndex,
}),
"tool.execute.after": createToolExecuteAfterHook(),
"experimental.chat.system.transform": createSystemTransformHook(
bindings,
() => teamLibrary.loadIssues ?? [],
),
"experimental.session.compacting": createCompactionHook(ctx, store),
};
import { OpenCodeCrewBeePlugin } from "./plugin-v1";
import { crewBeePluginV2, setupCrewBeeV2 } from "./v2/plugin";

export { OpenCodeCrewBeePlugin };
export { crewBeePluginV2, setupCrewBeeV2 };
export { CREWBEE_PLUGIN_ID } from "./v2/plugin";

/**
* Dual OpenCode plugin entry point.
*
* - OpenCode V2 reads `id` + `setup` from the default export.
* - OpenCode V1 reads `server` (a V1 plugin function) from the same default export.
*
* This is the compatibility shape documented by OpenCode for packages that must support both
* plugin APIs during the V1 → V2 migration.
*/
const plugin = {
...crewBeePluginV2,
server: OpenCodeCrewBeePlugin,
};

export default OpenCodeCrewBeePlugin;
export const id = plugin.id;
export const setup = plugin.setup;
export const server = plugin.server;

export default plugin;
42 changes: 34 additions & 8 deletions src/adapters/opencode/tool-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,35 @@ export function createToolDefinitionHook(_getAgents: () => OpenCodeAgentConfig[]
return async () => {};
}

/**
* Post-processes a serialized CrewBee delegation result: adds resume hints, retry guidance and the
* empty-output warning, and derives the tool metadata CrewBee wants surfaced to the host.
*
* Shared by the V1 `tool.execute.after` hook and the V2 tool `execute` wrapper.
*/
export function normalizeDelegateTaskOutput(output: string): {
output: string;
metadata: Record<string, unknown>;
} | undefined {
const parsed = parseDelegateTaskResult(output);
if (!parsed) {
return undefined;
}

const result = withResumeHint(withRetryGuidance(withEmptyWarning(parsed)));
const metadata: Record<string, unknown> = {};

if (result.status !== "failed" && result.session_id) {
metadata.sessionId = result.session_id;
metadata.taskId = result.task_id ?? result.session_id;
if (result.task_ref) {
metadata.taskRef = result.task_ref;
}
}

return { output: stringifyDelegateTaskResult(result), metadata };
}

export function createToolExecuteBeforeHook(input: {
bindings: Map<string, { selectedAgentId: string }>;
getAliasIndex(): Map<string, OpenCodeAgentAliasEntry>;
Expand Down Expand Up @@ -104,21 +133,18 @@ export function createToolExecuteAfterHook() {
return;
}

const parsed = parseDelegateTaskResult(output.output);
if (!parsed) {
const normalized = normalizeDelegateTaskOutput(output.output);
if (!normalized) {
return;
}

const result = withResumeHint(withRetryGuidance(withEmptyWarning(parsed)));
output.output = stringifyDelegateTaskResult(result);
output.output = normalized.output;

if (result.status !== "failed" && result.session_id) {
if (Object.keys(normalized.metadata).length > 0) {
output.title = output.title || "CrewBee task";
output.metadata = {
...output.metadata,
sessionId: result.session_id,
taskId: result.task_id ?? result.session_id,
taskRef: result.task_ref,
...normalized.metadata,
};
}
};
Expand Down
72 changes: 72 additions & 0 deletions src/adapters/opencode/v2/agent-definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { OpenCodeAgentConfig } from "../projection";

import { toV2PermissionRules, type V2PermissionRule } from "./permissions";

/**
* Minimal structural view of the V2 agent record that CrewBee needs to write.
*
* OpenCode brands these fields (`Agent.ID`, `Agent.Name`, `Model.ID`, ...), so call sites cast the
* editor's agent object to this shape instead of fighting the brands.
*/
export interface OpenCodeV2AgentDefinition {
id: string;
name: string;
description?: string;
mode: "primary" | "subagent" | "all";
hidden: boolean;
system?: string;
model?: { providerID: string; id: string; variant?: string };
permissions: V2PermissionRule[];
}

export interface MutableAgentRecord extends OpenCodeV2AgentDefinition {
request?: {
settings?: Record<string, unknown>;
headers?: Record<string, string>;
body?: Record<string, unknown>;
};
color?: string;
steps?: number;
}

export function createV2AgentDefinition(agent: OpenCodeAgentConfig): OpenCodeV2AgentDefinition {
const resolved = agent.resolvedModel;

return {
id: agent.configKey,
name: agent.configKey,
description: agent.description,
mode: agent.mode === "primary" ? "primary" : "subagent",
hidden: agent.hidden,
system: agent.prompt,
model: resolved
? {
providerID: resolved.providerID,
id: resolved.modelID,
...(resolved.variant ? { variant: resolved.variant } : {}),
}
: undefined,
permissions: toV2PermissionRules(agent.permission),
};
}

export function applyV2AgentDefinition(target: MutableAgentRecord, definition: OpenCodeV2AgentDefinition): void {
target.name = definition.name;
target.description = definition.description;
target.mode = definition.mode;
target.hidden = definition.hidden;
target.system = definition.system;
target.permissions = definition.permissions;

if (definition.model) {
target.model = definition.model;
} else {
target.model = undefined;
}
}

/**
* Host agents owned by OpenCode: CrewBee hides them (and demotes them to subagents) while a
* CrewBee Agent Team owns the session, mirroring the V1 `disable: true` + `mode: "subagent"` patch.
*/
export const OPEN_CODE_HOST_AGENT_KEYS = ["build", "plan", "general", "explore", "scout"] as const;
Loading