Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,34 @@ Rationale:
- No code changes needed in tool handlers — `.describe()` calls stay identical
- Verified: Option A produces correct JSON Schema with all descriptions

**Why Option A alone is insufficient (post-implementation learning):**
- OpenCode is distributed as a **compiled Bun binary** — zod is bundled inside the binary
- `peerDependencies` hoisting is irrelevant when host's zod is in a compiled bundle
- Even with peerDeps, in monorepo dev setup another package pulls zod 4.3.6 into plugin/node_modules
- Confirmed: `import('zod')` from plugin context resolves to plugin's 4.3.6, not OC's 4.1.8

**Final implementation: Option A + Option C (tool.definition hook with dynamic import)**

The `tool.definition` hook bridges registries:
1. Receives `output.parameters` (ZodObject created by host's zod.object(def.args))
2. Reads field descriptions via `.description` getter (works cross-instance from plugin's registry)
3. Dynamically imports `'zod'` — in production (no local node_modules/zod), this resolves to host's zod
4. Registers each field schema's description into host's `globalRegistry`
5. When host calls `z.toJSONSchema(output.parameters)`, it now finds all descriptions

**Why dynamic import works in production:**
- When installed as a package (peerDep), plugin has no local `node_modules/zod`
- ESM dynamic `import('zod')` resolves up the file tree to host's (OpenCode's) zod
- Module cache returns the same singleton — same `globalRegistry` — descriptions found ✅

**Registry bridge research (exhaustive):**
- `bag` property on schema: NOT where descriptions are stored
- `_zod.parent` trick: loses type info (toJSONSchema treats schema as ref to parent type)
- `ZodRegistry.prototype.add` patch: works but requires having a ZodRegistry instance
- Cross-instance `$ZodRegistry.prototype`: different class instances per module, patching one doesn't affect the other
- `toJSONSchema({ metadata: pluginRegistry })`: works but can't change OC's hardcoded call
- Dynamic import approach: cleanest workable solution for production

## Implementation Plan (Code Phase Tasks)

1. **`opencode-plugin/package.json`**: Move `zod` from `dependencies` to `peerDependencies` with version `">=4.1.8"`
Expand Down
88 changes: 86 additions & 2 deletions packages/opencode-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,20 @@ export const WorkflowsPlugin: Plugin = async (
// duplicate synthetic part for that specific message.
let postCompactionMessagePending = false;

// Set to true right after session.compacted fires so that the very next
// chat.message (OpenCode's own auto-continue) bypasses the agent filter
// and injects proper phase instructions instead of a suppression notice.
let postCompactionAutoResume = false;

// Last-known model from chat.message hook. Cached so proceed_to_phase can
// pass providerID + modelID to the summarize API (which requires them).
let lastKnownModel: { providerID: string; modelID: string } | null = null;

// Last-known agent from chat.message hook. Used when sending the
// post-compaction phase-aware continue message so it runs under the
// correct agent (e.g. 'workflow') rather than OpenCode's default.
let lastKnownAgent: string | null = null;

/**
* Set buffered instructions from a tool result.
* The next chat.message hook will use these instead of calling WhatsNextHandler.
Expand Down Expand Up @@ -305,6 +315,13 @@ export const WorkflowsPlugin: Plugin = async (
lastKnownModel = hookInput.model;
}

// Cache the agent for use by the post-compaction continue message.
// Only cache when the agent is enabled (i.e. a primary workflow agent),
// so we don't accidentally cache a subagent name.
if (hookInput.agent && isAgentEnabled(hookInput.agent)) {
lastKnownAgent = hookInput.agent;
}

// If this message was the post-compaction instructions prompt sent by Hook 4,
// skip synthetic part injection — the message body IS the instructions.
if (postCompactionMessagePending) {
Expand All @@ -315,14 +332,27 @@ export const WorkflowsPlugin: Plugin = async (
return;
}

// After compaction, OpenCode sends an auto-continue message which may arrive
// as a non-workflow agent (e.g. 'build'). In that case we still want to inject
// the phase instructions rather than the suppression/no-workflow notice, so we
// consume the flag and fall through to normal phase-instruction injection below.
const bypassAgentFilter = postCompactionAutoResume;
if (bypassAgentFilter) {
postCompactionAutoResume = false;
logger.debug(
'chat.message: bypassing agent filter for post-compaction auto-resume',
{ agent: hookInput.agent }
);
}

// If WORKFLOW_AGENTS is set and this agent is not in the allowlist, inject a
// suppression instruction as a synthetic part so the LLM knows not to call the
// workflow tools (which would only throw errors for non-enabled agents).
// We use chat.message (not experimental.chat.system.transform) because:
// 1. chat.message already has hookInput.agent directly — no stale-state risk.
// 2. chat.message fires reliably for every user turn; transform may fire for
// intermediate tool-loop LLM calls without a preceding chat.message.
if (!isAgentEnabled(hookInput.agent)) {
if (!bypassAgentFilter && !isAgentEnabled(hookInput.agent)) {
logger.debug(
'chat.message: Agent not enabled — injecting tool suppression',
{
Expand Down Expand Up @@ -560,6 +590,11 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin

if (event.type === 'session.compacted') {
postCompactionSession = event.properties.sessionID as string;
// Set flag so the next chat.message (OpenCode's own auto-continue,
// which may fire as a non-workflow agent like 'build') bypasses the
// agent filter and injects proper phase instructions instead of a
// suppression/no-workflow notice.
postCompactionAutoResume = true;
logger.info('session.compacted: pending phase-aware continue', {
sessionID: postCompactionSession,
});
Expand Down Expand Up @@ -614,14 +649,18 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin
session: {
promptAsync(params: {
path: { id: string };
body: { parts: Array<{ type: string; text: string }> };
body: {
parts: Array<{ type: string; text: string }>;
agent?: string;
};
}): Promise<unknown>;
};
};
await client.session.promptAsync({
path: { id: sessionID },
body: {
parts: [{ type: 'text', text: promptText }],
...(lastKnownAgent ? { agent: lastKnownAgent } : {}),
},
});
logger.info('session.idle: phase-aware continue sent (async)', {
Expand Down Expand Up @@ -758,6 +797,51 @@ ACTION REQUIRED: Use proceed_to_phase tool to move to a phase that allows editin
),
};
})(),

/**
* Bridge Zod .describe() descriptions from plugin's registry into host's registry.
*
* Problem: this plugin uses a different zod instance than OpenCode (host). In Zod v4,
* .describe() stores descriptions in a module-level globalRegistry singleton. When
* OpenCode calls z.toJSONSchema(parameters), it reads from its own registry which has
* no entries for plugin schemas — so all parameter descriptions are missing from the
* JSON Schema sent to the LLM.
*
* Solution: dynamically import 'zod' at hook call time. When the plugin is installed
* without its own node_modules/zod (zod is a peerDependency), this import resolves to
* the host's (OpenCode's) zod module — the same instance used when creating
* output.parameters. We then register each field schema's description into the host's
* globalRegistry, making them visible to the subsequent z.toJSONSchema() call.
*/
'tool.definition': async (
_input: { toolID: string },
output: { description: string; parameters: unknown }
): Promise<void> => {
try {
const parameters = output.parameters as {
_zod?: { def?: { shape?: Record<string, { description?: string }> } };
};
const shape = parameters?._zod?.def?.shape;
if (!shape) return;

// Dynamically import zod — in production (installed without local node_modules/zod),
// this resolves to the host's zod instance, sharing the same globalRegistry.
const { globalRegistry: hostRegistry } = await import('zod');

for (const [_key, fieldSchema] of Object.entries(shape)) {
const desc = fieldSchema?.description;
if (desc && typeof desc === 'string') {
(
hostRegistry as {
add(schema: unknown, meta: { description: string }): void;
}
).add(fieldSchema, { description: desc });
}
}
} catch {
// Silently ignore — descriptions are a nice-to-have, not critical
}
},
};
};

Expand Down
Loading