openpetswithchatandmcp/docs/phases/phase-19b-opencode-plugin-runtime.md
2026-05-10 13:30:48 +02:00

12 KiB

Phase 19B — OpenCode Plugin Runtime

Goal

Add the OpenCode runtime plugin that turns OpenCode activity into OpenPets reactions and short safe speech.

This phase builds on Phase 19A's foundation package and shared speech validation. It should make the plugin importable/testable and ready for manual OpenCode config, but it should not add Desktop UI or openpets configure --agent opencode yet.

Non-goals

  • No Desktop Integrations OpenCode card.
  • No CLI configure --agent opencode setup flow.
  • No writes to real user OpenCode config.
  • No OpenCode source changes under v1/opencode/.
  • No new public MCP tools.
  • No external SSE watcher or new TCP/HTTP listener.
  • No pet install/remove/default controls.

User-visible/manual outcome

Developers can manually add the plugin to an OpenCode config and see OpenPets react to OpenCode activity.

Expected manual plugin spec shape:

{
  "plugin": [
    ["@open-pets/opencode", { "pet": "fixer" }]
  ]
}

For local development, a file URL or absolute built plugin path can be used after build, but app/desktop setup is still deferred.

Acceptance criteria

  • @open-pets/opencode exposes an OpenCode-compatible server plugin entrypoint.
  • Package export contract supports OpenCode's loader expectations:
    • npm package can resolve a server plugin entrypoint through an explicit package export such as ./server without replacing the helper . export;
    • default export is an object with server();
    • file/local plugin entry includes a stable id;
    • built output can be dynamically imported in tests;
    • package-level resolution of @open-pets/opencode/server is covered by tests.
  • Plugin options accept:
    • optional pet id using the same strict pet id validation as Claude/OpenCode foundation;
    • optional debug flag or rely on OPENPETS_DEBUG=1.
  • Plugin hooks must return immediately. OpenPets IPC work must run fire-and-forget and never be awaited by OpenCode plugin hooks.
  • The event hook must be a synchronous non-throwing wrapper. OpenCode calls event with void hook.event(...) and does not await/catch it, so event must catch synchronous errors and schedule async work internally.
  • OpenPets calls must be best-effort:
    • catch all errors internally;
    • debug-only sanitized logging;
    • short client timeouts;
    • never throw back into OpenCode.
  • If pet is configured, plugin reactions/speech should acquire or reuse an OpenPets lease and pass leaseId to react/say.
  • Lease handling must not block OpenCode hooks. It can be a cached in-flight/background lease or per-event fire-and-forget acquisition, but hook functions themselves must remain synchronous or already-resolved async functions.
  • Event mapping must cover:
    • chat.messagethinking + throttled speech.
    • tool.execute.before edit/write/patch tool names → editing.
    • tool.execute.before shell/bash tool names with test-like command/category when safely inferable → testing.
    • tool.execute.before shell/bash tool names otherwise → running.
    • other non-OpenPets tools → working.
    • plugin event with permission.askedwaiting + approval-needed speech.
    • plugin event with session.status idle, if stable and useful → success sparingly.
    • plugin event with session.errorerror.
  • The plugin must ignore OpenPets MCP tool calls to avoid self-reaction loops, including likely OpenCode MCP tool names such as:
    • openpets_openpets_status
    • openpets_openpets_say
    • openpets_openpets_react
  • Speech must use shared @open-pets/agent-events validation and message pools.
  • Speech must not include prompt text, command text, tool args, tool output, code, logs, file paths, URLs, or secrets.
  • Throttling for OpenCode speech must be namespaced separately from Claude, e.g. opencode-hook-throttle.json or equivalent.
  • Tests cover event classification, speech safety, self-tool suppression, fire-and-forget behavior, import shape, and failure swallowing.

Proposed files/directories

Likely changed/new files:

  • packages/opencode/package.json
  • packages/opencode/src/plugin.ts
  • packages/opencode/src/opencode-plugin-runtime.ts
  • packages/opencode/src/check-opencode-plugin.ts
  • packages/opencode/src/index.ts
  • packages/opencode/src/check-opencode-foundation.ts

Possible shared utility additions:

  • packages/agent-events/src/index.ts for reusable throttling primitives, if needed.

Technical approach

OpenCode plugin contract

From v1/opencode:

  • Plugin config specs are strings or [string, options]: v1/opencode/packages/opencode/src/config/plugin.ts lines 12-17.
  • Plugin loader imports the resolved module and reads the default export: v1/opencode/packages/opencode/src/plugin/loader.ts lines 118-128.
  • A server plugin must default-export an object with a server() function: v1/opencode/packages/opencode/src/plugin/shared.ts lines 272-303.
  • File plugins need an id: v1/opencode/packages/opencode/src/plugin/shared.ts lines 306-316.
  • Hook type surface includes event, chat.message, tool.execute.before, tool.execute.after, and command.execute.before: v1/opencode/packages/plugin/src/index.ts lines 222-333.

Implementation should export something compatible with:

export default {
  id: "open-pets-opencode",
  server: async (_input, options) => ({
    "chat.message": () => { /* schedule, return immediately */ },
    "tool.execute.before": () => { /* schedule, return immediately */ },
    event: () => { /* sync non-throwing schedule wrapper */ },
  }),
};

The exact TypeScript types can be local structural types to avoid adding a heavy dependency on OpenCode internals. If @opencode-ai/plugin is added as a dev/type dependency, it must not introduce runtime/package issues.

Hook latency rule

OpenCode awaits plugin hooks in several paths:

  • Plugin.trigger awaits hook promises: v1/opencode/packages/opencode/src/plugin/index.ts lines 258-269.
  • Tool execution calls tool.execute.before before running tools: v1/opencode/packages/opencode/src/session/prompt.ts lines 428-433.

Therefore every plugin hook must schedule OpenPets work and then return immediately. Do not await client.react, await client.say, or await client.acquireLease directly inside a hook.

Use a small scheduler/helper such as:

function fireAndForget(work: () => Promise<void>): void {
  void work().catch(debugLog);
}

Hook functions may be async for OpenCode compatibility, but they must not await the scheduled work.

Exception: the event hook should not be async, because OpenCode does not await/catch it.

OpenPets client behavior

Use createOpenPetsClient({ connectTimeoutMs: 500, responseTimeoutMs: 500 }) or a similarly short timeout.

When pet is configured:

  • Acquire a lease in the background.
  • Cache the lease id while valid if simple.
  • Use the lease id for say/react once available.
  • If lease acquisition fails, fall back to default target or no lease without surfacing errors.

Event classification

Keep classification pure and testable.

Proposed pure functions:

  • classifyOpenCodeToolReaction(toolName, args): OpenPetsReaction | undefined
  • classifyOpenCodeBusEvent(event): { reaction?, speechCategory?, forceSpeech? } | undefined
  • shouldIgnoreOpenPetsTool(toolName): boolean

Tool classification should rely on tool names and safe coarse categories. It may inspect a shell command only to decide whether it looks test-like, and must never send the command text to speech.

Expected tool name patterns:

  • edit/write/patch: names containing edit, write, patch, apply_patch.
  • shell/bash: names containing bash, shell, terminal, or OpenCode's shell tool id if known.
  • OpenPets MCP tools: suppress names ending in or equal to openpets_status, openpets_say, openpets_react, including server-prefixed forms.

Bus event classification:

  • permission.asked from permission bus → waiting, permission speech.
  • session.errorerror speech category.
  • session.status with idle may map to success, but avoid noisy success spam by throttling.

Speech/throttling

Use shared messages and validator from @open-pets/agent-events.

OpenCode speech categories should match Claude categories for now:

  • thinking
  • success
  • error
  • permission

Throttle storage must be separate from Claude. If file-backed throttling is added in this phase, it must use only OpenCode-specific path names and best-effort writes.

Risks and tradeoffs

  • OpenCode plugin APIs may evolve; keep plugin-specific runtime isolated in packages/opencode.
  • Fire-and-forget scheduling avoids blocking OpenCode but means pet reactions may be dropped if Node exits immediately.
  • Lease caching adds state; if too complex, prefer simple background per-event lease acquisition for correctness.
  • Mapping session idle to success may be noisy. It should be throttled or omitted if unstable during implementation.
  • Adding runtime client dependency to @open-pets/opencode is expected in this phase; avoid depending on CLI or desktop.

Security/privacy notes

  • Never send prompt text, command text, tool args, tool output, code, logs, file paths, URLs, or secrets to pet speech.
  • Debug logging must sanitize paths and secrets.
  • Plugin must ignore OpenPets MCP tools to avoid loops.
  • Plugin must catch all OpenPets client errors.
  • No new local server, TCP listener, or HTTP endpoint is added.
  • pet option must be strictly validated before use.

Test/check plan

  • pnpm --filter @open-pets/opencode check
  • pnpm --filter @open-pets/agent-events check
  • pnpm --filter @open-pets/claude check
  • pnpm check after implementation review fixes.

Specific tests:

  • Dynamic import of built plugin entry returns default object with id and server().
  • Dynamic import of package export @open-pets/opencode/server returns default object with id and server().
  • server() returns hooks for chat.message, tool.execute.before, and event.
  • Hook calls schedule work and return before a deliberately unresolved fake client promise completes.
  • event hook is synchronous, non-throwing, and does not produce unhandled rejections when classification/scheduling fails.
  • OpenPets client errors are swallowed.
  • Configured pet id is validated; invalid pet option fails plugin setup safely.
  • chat.message maps to thinking without using prompt text.
  • edit/write/patch tools map to editing.
  • shell/bash test-like tools map to testing; non-test shell maps to running.
  • OpenPets MCP tools are ignored.
  • permission.asked maps to waiting + permission speech.
  • session.error maps to error.
  • Speech validator rejects unsafe generated messages.
  • Throttle state is OpenCode-namespaced if implemented.

Manual verification guide

After implementation and review:

  1. Run pnpm --filter @open-pets/opencode check.
  2. Run pnpm check.
  3. Start OpenPets desktop locally.
  4. Build packages.
  5. Manually add the local built OpenCode plugin to a test OpenCode project config.
  6. Start OpenCode in that test project.
  7. Submit a prompt and confirm the selected/default pet reacts with thinking.
  8. Run an edit/write action and confirm editing.
  9. Run a test-like shell action and confirm testing.
  10. Trigger a permission request if practical and confirm waiting / approval speech.
  11. Confirm OpenPets MCP tool calls do not trigger recursive reactions.
  12. Confirm no prompt text, command text, file path, or tool output appears in pet speech.

Oracle plan review

Oracle reviewed the initial Phase 19B spec and found two plan blockers:

  • Package export contract needed to explicitly support OpenCode's ./server entry resolution while preserving helper exports.
  • The event hook must not be async/rejecting because OpenCode invokes it with void hook.event(...) and does not await/catch it.

Oracle feedback disposition

  • Fixed: Added explicit @open-pets/opencode/server export requirement and package-level import test.
  • Fixed: Required event to be synchronous, non-throwing, and internally catch/schedule async work.