diff --git a/README.md b/README.md index 8e20aaf..0b573c8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,29 @@ OrgX plugin peer for **OpenCode**. One of three reference peers (alongside `orgx **The peer model:** this plugin opens its own authenticated WebSocket to OrgX server, receives `task.dispatch` messages, runs them in your local OpenCode session (your subscription pays the tokens), and posts receipts + deviations back. It also writes compact, redacted Work Graph events locally so audit-first reconciliation can preserve progress and fingerprints across signup. No central broker. If another peer goes down, this one keeps running. -## Install + run +## Install + +OpenCode can load the peer as a native plugin from `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["@useorgx/orgx-opencode-plugin"] +} +``` + +Then start OpenCode with the OrgX credentials available in the environment: + +```bash +export ORGX_API_KEY=oxk_... +export ORGX_WORKSPACE_ID= +opencode +``` + +The native plugin starts the OrgX peer when the local OpenCode server connects. +Set `ORGX_BASE_URL` only when testing against a non-production OrgX API. + +You can also run the peer directly: ```bash npm install -g @useorgx/orgx-opencode-plugin diff --git a/package.json b/package.json index a0e84e9..dfc38f9 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,12 @@ "@useorgx/orgx-gateway-sdk": "git+https://github.com/useorgx/orgx-gateway-sdk.git#main" }, "devDependencies": { + "@opencode-ai/plugin": "^1.15.7", "@types/node": "^22.0.0", "typescript": "^5.4.0", "vitest": "^2.0.0" }, - "keywords": ["orgx", "opencode", "plugin-peer", "byok"], + "keywords": ["orgx", "opencode", "opencode-plugin", "plugin-peer", "byok"], "repository": { "type": "git", "url": "git+https://github.com/useorgx/orgx-opencode-plugin.git" diff --git a/src/OpenCodeDriver.ts b/src/OpenCodeDriver.ts index e8092ad..c166103 100644 --- a/src/OpenCodeDriver.ts +++ b/src/OpenCodeDriver.ts @@ -36,7 +36,7 @@ import type { PeerToServerMessage, } from '@useorgx/orgx-gateway-sdk'; -import { recordWorkGraphEvent } from './workGraphOutbox'; +import { recordWorkGraphEvent } from './workGraphOutbox.js'; type OpenCodeState = { port: number; diff --git a/src/index.ts b/src/index.ts index 782e63b..f614970 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,20 @@ -export { OpenCodeDriver, type OpenCodeDriverOptions } from './OpenCodeDriver'; -export { startPeer } from './peer'; +export { OpenCodeDriver, type OpenCodeDriverOptions } from './OpenCodeDriver.js'; +export { + default, + createOrgXOpenCodePlugin, + OrgXOpenCodePlugin, + type CreateOrgXOpenCodePluginOptions, +} from './plugin.js'; +export type { StartedPeer, StartPeerOptions } from './peer.js'; export { buildWorkGraphEventRecord, recordWorkGraphEvent, resolveWorkGraphOutboxPath, -} from './workGraphOutbox'; +} from './workGraphOutbox.js'; + +export async function startPeer( + opts: import('./peer.js').StartPeerOptions +): Promise { + const peer = await import('./peer.js'); + return peer.startPeer(opts); +} diff --git a/src/peer.ts b/src/peer.ts index 1896258..bd03c22 100644 --- a/src/peer.ts +++ b/src/peer.ts @@ -17,7 +17,7 @@ import { readFile } from 'fs/promises'; import { resolve } from 'path'; import { fileURLToPath } from 'url'; -import { OpenCodeDriver } from './OpenCodeDriver'; +import { OpenCodeDriver } from './OpenCodeDriver.js'; export type StartPeerOptions = { apiKey: string; diff --git a/src/plugin.test.ts b/src/plugin.test.ts new file mode 100644 index 0000000..b5128b2 --- /dev/null +++ b/src/plugin.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createOrgXOpenCodePlugin } from './plugin'; + +type PluginHooks = { + event: (input: { event: { type?: string } }) => Promise; +}; + +function createLogger() { + return { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +async function loadHooks( + opts: Parameters[0] +): Promise { + const plugin = createOrgXOpenCodePlugin(opts); + return (await plugin({} as never)) as PluginHooks; +} + +describe('OrgXOpenCodePlugin', () => { + it('starts the peer on server.connected with env config', async () => { + const stop = vi.fn(); + const startPeer = vi.fn(async () => ({ stop })); + const logger = createLogger(); + const hooks = await loadHooks({ + startPeer, + logger, + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_WORKSPACE_ID: 'workspace-123', + ORGX_BASE_URL: 'https://example.org', + }, + }); + + await hooks.event({ event: { type: 'session.created' } }); + expect(startPeer).not.toHaveBeenCalled(); + + await hooks.event({ event: { type: 'server.connected' } }); + expect(startPeer).toHaveBeenCalledTimes(1); + expect(startPeer).toHaveBeenCalledWith({ + apiKey: 'oxk_test', + workspaceId: 'workspace-123', + baseUrl: 'https://example.org', + }); + expect(logger.log).toHaveBeenCalledWith( + '[orgx-opencode-plugin] native OpenCode plugin peer started' + ); + }); + + it('does not start more than once', async () => { + const startPeer = vi.fn(async () => ({ stop: vi.fn() })); + const logger = createLogger(); + const hooks = await loadHooks({ + startPeer, + logger, + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_WORKSPACE_ID: 'workspace-123', + }, + }); + + await hooks.event({ event: { type: 'server.connected' } }); + await hooks.event({ event: { type: 'server.connected' } }); + + expect(startPeer).toHaveBeenCalledTimes(1); + }); + + it('warns once when required env config is missing', async () => { + const startPeer = vi.fn(async () => ({ stop: vi.fn() })); + const logger = createLogger(); + const hooks = await loadHooks({ + startPeer, + logger, + env: {}, + }); + + await hooks.event({ event: { type: 'server.connected' } }); + await hooks.event({ event: { type: 'server.connected' } }); + + expect(startPeer).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + '[orgx-opencode-plugin] native plugin loaded, but ORGX_API_KEY and ORGX_WORKSPACE_ID are required to connect' + ); + }); + + it('logs start failures without throwing through OpenCode hooks', async () => { + const startPeer = vi.fn(async () => { + throw new Error('connect failed'); + }); + const logger = createLogger(); + const hooks = await loadHooks({ + startPeer, + logger, + env: { + ORGX_API_KEY: 'oxk_test', + ORGX_WORKSPACE_ID: 'workspace-123', + }, + }); + + await expect( + hooks.event({ event: { type: 'server.connected' } }) + ).resolves.toBeUndefined(); + + expect(logger.error).toHaveBeenCalledWith( + '[orgx-opencode-plugin] failed to start peer', + 'connect failed' + ); + }); + + it('loads the package entry without eagerly importing the peer runtime', async () => { + const mod = await import('./index'); + + expect(typeof mod.default).toBe('function'); + expect(typeof mod.OrgXOpenCodePlugin).toBe('function'); + expect(typeof mod.startPeer).toBe('function'); + }); +}); diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..9708719 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,71 @@ +import type { Plugin } from '@opencode-ai/plugin'; + +import type { StartedPeer, StartPeerOptions } from './peer.js'; + +type StartPeer = (opts: StartPeerOptions) => Promise; +type Env = Record; +type Logger = Pick; + +export type CreateOrgXOpenCodePluginOptions = { + startPeer?: StartPeer; + env?: Env; + logger?: Logger; +}; + +export function createOrgXOpenCodePlugin( + opts: CreateOrgXOpenCodePluginOptions = {} +): Plugin { + const start = opts.startPeer ?? defaultStartPeer; + const env = opts.env ?? process.env; + const logger = opts.logger ?? console; + let peer: Promise | null = null; + let warnedMissingConfig = false; + + async function startIfConfigured() { + if (peer) return; + + const apiKey = env.ORGX_API_KEY; + const workspaceId = env.ORGX_WORKSPACE_ID; + const baseUrl = env.ORGX_BASE_URL; + + if (!apiKey || !workspaceId) { + if (!warnedMissingConfig) { + logger.warn( + '[orgx-opencode-plugin] native plugin loaded, but ORGX_API_KEY and ORGX_WORKSPACE_ID are required to connect' + ); + warnedMissingConfig = true; + } + return; + } + + peer = start({ apiKey, workspaceId, baseUrl }); + try { + await peer; + logger.log('[orgx-opencode-plugin] native OpenCode plugin peer started'); + } catch (err) { + peer = null; + logger.error('[orgx-opencode-plugin] failed to start peer', formatError(err)); + } + } + + return async () => ({ + event: async ({ event }: { event: { type?: string } }) => { + if (event.type === 'server.connected') { + await startIfConfigured(); + } + }, + }); +} + +async function defaultStartPeer(opts: StartPeerOptions): Promise { + const { startPeer } = await import('./peer.js'); + return startPeer(opts); +} + +function formatError(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + +export const OrgXOpenCodePlugin = createOrgXOpenCodePlugin(); +export default OrgXOpenCodePlugin;