@tduyng/pi-opencode-z 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,195 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
2
+
3
+ import {
4
+ currentService,
5
+ fetchZenModels,
6
+ opencodeBin,
7
+ resolveService,
8
+ startEventStream,
9
+ type EventStreamHandle,
10
+ type ZenModel
11
+ } from './client.js'
12
+ import { startMcpServer, type McpServerHandle } from './mcp.js'
13
+ import { isFreeModel, selectModels, toPiModel, type PiModelEntry } from './models.js'
14
+ import { allSessions, disposeAll } from './session.js'
15
+ import { attachMcpServer, createBridgeRuntime, routeEvent, streamOpenCodeBridge, toolHintsFrom } from './stream.js'
16
+ import { bridgeModelsRaw, configPath, projectConfigPath } from './config.js'
17
+
18
+ const PROVIDER_ID = 'opencode-z'
19
+ const API_ID = 'opencode-z'
20
+
21
+ export default async function opencodeBridge(pi: ExtensionAPI): Promise<void> {
22
+ const runtime = createBridgeRuntime()
23
+ runtime.toolHints = toolHintsFrom(() => pi.getAllTools())
24
+ let mcpServer: McpServerHandle | undefined
25
+ let events: EventStreamHandle | undefined
26
+ let catalogue: ZenModel[] = []
27
+ let startupError: string | undefined
28
+ let starting: Promise<void> | undefined
29
+
30
+ async function loadModels(): Promise<PiModelEntry[]> {
31
+ const filter = bridgeModelsRaw()
32
+ catalogue = selectModels(await fetchZenModels(), filter)
33
+ if (filter && catalogue.length === 0) throw new Error(`no configured models matched "${filter}"`)
34
+ return catalogue.map(toPiModel)
35
+ }
36
+
37
+ function register(models: PiModelEntry[]): void {
38
+ pi.registerProvider(PROVIDER_ID, {
39
+ name: 'OpenCode Bridge',
40
+ baseUrl: 'opencode://local',
41
+ apiKey: 'opencode-z-local',
42
+ api: API_ID,
43
+ models,
44
+ streamSimple: (model, context, options) => streamOpenCodeBridge(runtime, model, context, options),
45
+ refreshModels: async () => {
46
+ await ensureStarted()
47
+ return loadModels()
48
+ }
49
+ })
50
+ }
51
+
52
+ async function start(): Promise<void> {
53
+ await resolveService()
54
+ mcpServer = await startMcpServer()
55
+ attachMcpServer(runtime, mcpServer)
56
+ events = startEventStream((event) => routeEvent(runtime, event))
57
+ register(await loadModels())
58
+ startupError = undefined
59
+ }
60
+
61
+ function ensureStarted(): Promise<void> {
62
+ if (mcpServer) return Promise.resolve()
63
+ starting ??= startBridge()
64
+ return starting
65
+ }
66
+
67
+ async function startBridge(): Promise<void> {
68
+ try {
69
+ await start()
70
+ } catch (error) {
71
+ startupError = error instanceof Error ? error.message : String(error)
72
+ starting = undefined
73
+ throw error
74
+ }
75
+ }
76
+
77
+ function modelLines(): string[] {
78
+ if (catalogue.length === 0) return ['No Zen models available.']
79
+ const free = catalogue.filter(isFreeModel)
80
+ const paid = catalogue.filter((model) => !isFreeModel(model))
81
+ return [
82
+ `${catalogue.length} model(s), ${free.length} free:`,
83
+ ...free.map((model) => ` ${PROVIDER_ID}/${model.id} (free)`),
84
+ ...paid.map((model) => ` ${PROVIDER_ID}/${model.id}`)
85
+ ]
86
+ }
87
+
88
+ function statusLines(): string[] {
89
+ const service = currentService()
90
+ const lines = [
91
+ `opencode server: ${service ? service.url : 'not connected'}`,
92
+ `opencode binary: ${opencodeBin()}`,
93
+ `config: ${configPath()}`,
94
+ `project config: ${projectConfigPath()}`,
95
+ `models filter: ${bridgeModelsRaw() ?? 'all'}`,
96
+ `MCP endpoint: ${mcpServer ? `http://127.0.0.1:${mcpServer.port}/mcp` : 'not running'}`,
97
+ `live bridge sessions: ${allSessions(runtime.sessions).length}`
98
+ ]
99
+ if (startupError) lines.push(`startup problem: ${startupError}`)
100
+ return [...lines, '', ...modelLines()]
101
+ }
102
+
103
+ register([])
104
+
105
+ // Pi waits for async factories, so the Zen models are listed before --model is resolved.
106
+ await ensureStarted().catch(() => undefined)
107
+
108
+ pi.on('session_start', async (_event, ctx: ExtensionContext) => {
109
+ try {
110
+ await ensureStarted()
111
+ } catch {
112
+ ctx.ui.notify(
113
+ `opencode-z is off: ${startupError}. Check that ${opencodeBin()} service status works.`,
114
+ 'warning'
115
+ )
116
+ }
117
+ })
118
+
119
+ pi.on('session_shutdown', async () => {
120
+ if (starting) await starting.catch(() => undefined)
121
+ events?.stop()
122
+ events = undefined
123
+ const server = mcpServer
124
+ mcpServer = undefined
125
+ runtime.mcp = undefined
126
+ starting = undefined
127
+ startupError = undefined
128
+ if (!server) return
129
+ await disposeAll(runtime.sessions, server.unregister)
130
+ await server.close()
131
+ })
132
+
133
+ const show = (text: string) =>
134
+ pi.sendMessage({ customType: 'opencode-z', display: true, content: text }, { triggerTurn: false })
135
+
136
+ pi.registerCommand('opencode-z', {
137
+ description: 'Status and model list for the opencode bridge',
138
+ getArgumentCompletions: (prefix: string) =>
139
+ [
140
+ { value: 'status', label: 'status', description: 'server, config, MCP endpoint and live sessions' },
141
+ { value: 'models', label: 'models', description: 'every Zen model the bridge exposes' },
142
+ { value: 'refresh', label: 'refresh', description: 're-read the config file and model catalogue' },
143
+ { value: 'config', label: 'config', description: 'resolved settings and where they come from' },
144
+ { value: 'help', label: 'help', description: 'usage and settings' }
145
+ ].filter((item) => item.value.startsWith(prefix.trim())),
146
+ handler: async (args: string) => {
147
+ const sub = args.trim().split(/\s+/).filter(Boolean)[0] ?? 'status'
148
+
149
+ if (sub === 'refresh') {
150
+ try {
151
+ await ensureStarted()
152
+ await resolveService(true)
153
+ register(await loadModels())
154
+ startupError = undefined
155
+ await show(`opencode-z: refreshed, ${catalogue.length} model(s) available.`)
156
+ } catch (error) {
157
+ await show(`opencode-z refresh failed: ${error instanceof Error ? error.message : String(error)}`)
158
+ }
159
+ return
160
+ }
161
+
162
+ if (sub === 'help') {
163
+ await show(
164
+ [
165
+ 'Usage: /opencode-z [status|models|refresh|config|help]',
166
+ '',
167
+ `Settings live in ${configPath()} and ${projectConfigPath()}.`,
168
+ 'OPENCODE_Z_BIN and OPENCODE_Z_MODELS override the file.',
169
+ 'See the README for an example file.'
170
+ ].join('\n')
171
+ )
172
+ return
173
+ }
174
+
175
+ if (sub === 'config') {
176
+ await show(
177
+ [
178
+ `file: ${configPath()}`,
179
+ `project file: ${projectConfigPath()}`,
180
+ `opencode binary: ${opencodeBin()}`,
181
+ `models filter: ${bridgeModelsRaw() ?? 'all (every Zen model the account can reach)'}`
182
+ ].join('\n')
183
+ )
184
+ return
185
+ }
186
+
187
+ if (sub === 'models') {
188
+ await show(modelLines().join('\n'))
189
+ return
190
+ }
191
+
192
+ await show(statusLines().join('\n'))
193
+ }
194
+ })
195
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,315 @@
1
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
2
+ import { Validator } from 'ata-validator'
3
+ import { t } from 'ata-validator/t'
4
+
5
+ const isStringOrNumber = new Validator(t.union([t.string(), t.number()])).isValidObject
6
+ const isString = new Validator(t.string()).isValidObject
7
+
8
+ export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject
9
+ export type JsonObject = { [key: string]: JsonValue }
10
+
11
+ export type McpToolAnnotations = {
12
+ readOnlyHint?: boolean
13
+ destructiveHint?: boolean
14
+ idempotentHint?: boolean
15
+ openWorldHint?: boolean
16
+ }
17
+
18
+ export type McpToolDef = {
19
+ name: string
20
+ description: string
21
+ inputSchema: JsonObject
22
+ annotations?: McpToolAnnotations
23
+ }
24
+
25
+ export type McpContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
26
+
27
+ export type McpResult = {
28
+ content: McpContent[]
29
+ isError?: boolean
30
+ }
31
+
32
+ export type McpHandlers = {
33
+ listTools(): McpToolDef[]
34
+ callTool(name: string, args: JsonObject, meta: JsonObject): Promise<McpResult>
35
+ }
36
+
37
+ export type McpServerHandle = {
38
+ port: number
39
+ urlFor(token: string): string
40
+ register(token: string, handlers: McpHandlers): void
41
+ unregister(token: string): void
42
+ notifyToolsChanged(token: string): Promise<boolean>
43
+ close(): Promise<void>
44
+ }
45
+
46
+ const SERVER_INFO = { name: 'pi-opencode-z', version: '1.0.0' }
47
+ const DEFAULT_PROTOCOL = '2025-11-25'
48
+ const MAX_BODY_BYTES = 8 * 1024 * 1024
49
+ const LIST_WAIT_MS = 2_000
50
+
51
+ type RpcId = string | number
52
+
53
+ type McpRequest =
54
+ | { kind: 'malformed' }
55
+ | { kind: 'notification' }
56
+ | { kind: 'initialize'; id: RpcId; protocolVersion: string }
57
+ | { kind: 'ping'; id: RpcId }
58
+ | { kind: 'listTools'; id: RpcId }
59
+ | { kind: 'callTool'; id: RpcId; name: string; args: JsonObject; meta: JsonObject }
60
+ | { kind: 'unsupported'; id: RpcId; method: string }
61
+
62
+ const RecordSchema = t.object({}, { additionalProperties: true })
63
+ const isRecordSchema = new Validator(RecordSchema).isValidObject
64
+
65
+ function isJsonRecord(value: JsonValue | undefined): value is JsonObject {
66
+ return isRecordSchema(value) && !Array.isArray(value)
67
+ }
68
+
69
+ function asObject(value: JsonValue | undefined): JsonObject {
70
+ if (!isJsonRecord(value)) return {}
71
+ return value
72
+ }
73
+
74
+ function parseRequest(body: string): McpRequest {
75
+ let frame: JsonValue
76
+ try {
77
+ frame = JSON.parse(body) as JsonValue
78
+ } catch {
79
+ return { kind: 'malformed' }
80
+ }
81
+ const message = asObject(frame)
82
+ const id = message['id']
83
+ if (!isStringOrNumber(id)) return { kind: 'notification' }
84
+
85
+ const method = message['method']
86
+ if (!isString(method)) return { kind: 'malformed' }
87
+ const params = asObject(message['params'])
88
+
89
+ switch (method) {
90
+ case 'initialize': {
91
+ const requested = params['protocolVersion']
92
+ return {
93
+ kind: 'initialize',
94
+ id,
95
+ protocolVersion: isString(requested) ? requested : DEFAULT_PROTOCOL
96
+ }
97
+ }
98
+ case 'ping':
99
+ return { kind: 'ping', id }
100
+ case 'tools/list':
101
+ return { kind: 'listTools', id }
102
+ case 'tools/call': {
103
+ const name = params['name']
104
+ if (!isString(name)) return { kind: 'malformed' }
105
+ return { kind: 'callTool', id, name, args: asObject(params['arguments']), meta: asObject(params['_meta']) }
106
+ }
107
+ default:
108
+ return { kind: 'unsupported', id, method }
109
+ }
110
+ }
111
+
112
+ class RequestBodyError extends Error {
113
+ readonly status = 413
114
+
115
+ constructor() {
116
+ super(`MCP request body exceeds ${MAX_BODY_BYTES} bytes`)
117
+ this.name = 'RequestBodyError'
118
+ }
119
+ }
120
+
121
+ function readBody(req: IncomingMessage): Promise<string> {
122
+ return new Promise((resolve, reject) => {
123
+ let body = ''
124
+ let bytes = 0
125
+ let rejected = false
126
+ req.setEncoding('utf8')
127
+ req.on('data', (chunk: string) => {
128
+ if (rejected) return
129
+ bytes += Buffer.byteLength(chunk, 'utf8')
130
+ if (bytes > MAX_BODY_BYTES) {
131
+ rejected = true
132
+ reject(new RequestBodyError())
133
+ return
134
+ }
135
+ body += chunk
136
+ })
137
+ req.on('end', () => {
138
+ if (!rejected) resolve(body)
139
+ })
140
+ req.on('error', reject)
141
+ })
142
+ }
143
+
144
+ function send(res: ServerResponse, status: number, payload?: RpcPayload): void {
145
+ if (payload === undefined) {
146
+ res.writeHead(status).end()
147
+ return
148
+ }
149
+ const body = JSON.stringify(payload)
150
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }).end(body)
151
+ }
152
+
153
+ type RpcResult =
154
+ | {
155
+ protocolVersion: string
156
+ capabilities: { tools: { listChanged?: boolean } }
157
+ serverInfo: { name: string; version: string }
158
+ }
159
+ | { alive?: true }
160
+ | { tools: McpToolDef[] }
161
+ | McpResult
162
+
163
+ type RpcPayload =
164
+ | { jsonrpc: '2.0'; id: RpcId; result: RpcResult }
165
+ | { jsonrpc: '2.0'; id: RpcId; error: { code: number; message: string } }
166
+
167
+ type RpcReply = { status: number; body?: RpcPayload }
168
+
169
+ async function respond(request: McpRequest, handlers: McpHandlers): Promise<RpcReply> {
170
+ switch (request.kind) {
171
+ case 'malformed':
172
+ return { status: 400 }
173
+ case 'notification':
174
+ return { status: 202 }
175
+ case 'initialize':
176
+ return {
177
+ status: 200,
178
+ body: ok(request.id, {
179
+ protocolVersion: request.protocolVersion,
180
+ capabilities: { tools: { listChanged: true } },
181
+ serverInfo: SERVER_INFO
182
+ })
183
+ }
184
+ case 'ping':
185
+ return { status: 200, body: ok(request.id, {}) }
186
+ case 'listTools':
187
+ return { status: 200, body: ok(request.id, { tools: handlers.listTools() }) }
188
+ case 'callTool': {
189
+ let result: McpResult
190
+ try {
191
+ result = await handlers.callTool(request.name, request.args, request.meta)
192
+ } catch (error) {
193
+ result = {
194
+ content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
195
+ isError: true
196
+ }
197
+ }
198
+ return { status: 200, body: ok(request.id, result) }
199
+ }
200
+ case 'unsupported':
201
+ return {
202
+ status: 200,
203
+ body: {
204
+ jsonrpc: '2.0',
205
+ id: request.id,
206
+ error: { code: -32601, message: `unsupported method: ${request.method}` }
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ function ok(id: RpcId, result: RpcResult): RpcPayload {
213
+ return { jsonrpc: '2.0', id, result }
214
+ }
215
+
216
+ export function startMcpServer(): Promise<McpServerHandle> {
217
+ const registry = new Map<string, McpHandlers>()
218
+ const streams = new Map<string, Set<ServerResponse>>()
219
+ const listWaiters = new Map<string, Set<() => void>>()
220
+
221
+ const server: Server = createServer((req, res) => {
222
+ void (async () => {
223
+ const token = /^\/mcp\/([A-Za-z0-9_-]+)\/?$/.exec(req.url ?? '')?.[1]
224
+ const handlers = token ? registry.get(token) : undefined
225
+ if (!token || !handlers) {
226
+ send(res, 404)
227
+ return
228
+ }
229
+ if (req.method === 'GET') {
230
+ res.writeHead(200, {
231
+ 'content-type': 'text/event-stream',
232
+ 'cache-control': 'no-cache'
233
+ })
234
+ res.write(': connected\n\n')
235
+ const open = streams.get(token) ?? new Set<ServerResponse>()
236
+ open.add(res)
237
+ streams.set(token, open)
238
+ res.on('close', () => open.delete(res))
239
+ return
240
+ }
241
+ if (req.method !== 'POST') {
242
+ send(res, 405)
243
+ return
244
+ }
245
+
246
+ const request = parseRequest(await readBody(req))
247
+ const reply = await respond(request, handlers)
248
+ send(res, reply.status, reply.body)
249
+ if (request.kind === 'listTools') {
250
+ for (const wake of listWaiters.get(token) ?? []) wake()
251
+ }
252
+ })().catch((error) => {
253
+ if (!res.headersSent) send(res, error instanceof RequestBodyError ? error.status : 500)
254
+ })
255
+ })
256
+
257
+ return new Promise((resolve, reject) => {
258
+ server.on('error', reject)
259
+ server.listen(0, '127.0.0.1', () => {
260
+ const address = server.address()
261
+ if (address === null || isString(address)) {
262
+ reject(new Error('MCP server did not bind to a TCP port'))
263
+ return
264
+ }
265
+ server.unref()
266
+ resolve({
267
+ port: address.port,
268
+ urlFor: (token) => `http://127.0.0.1:${address.port}/mcp/${token}`,
269
+ register: (token, handlers) => {
270
+ registry.set(token, handlers)
271
+ },
272
+ unregister: (token) => {
273
+ registry.delete(token)
274
+ for (const res of streams.get(token) ?? []) res.end()
275
+ streams.delete(token)
276
+ listWaiters.delete(token)
277
+ },
278
+ notifyToolsChanged: (token) => {
279
+ const open = streams.get(token)
280
+ if (!open || open.size === 0) return Promise.resolve(false)
281
+ const frame = `event: message\ndata: ${JSON.stringify({
282
+ jsonrpc: '2.0',
283
+ method: 'notifications/tools/list_changed'
284
+ })}\n\n`
285
+ for (const res of open) res.write(frame)
286
+ return new Promise<boolean>((done) => {
287
+ const finish = (listed: boolean) => {
288
+ clearTimeout(timer)
289
+ listWaiters.get(token)?.delete(waiter)
290
+ done(listed)
291
+ }
292
+ const waiter = () => finish(true)
293
+ const timer = setTimeout(() => finish(false), LIST_WAIT_MS)
294
+ const waiters = listWaiters.get(token) ?? new Set<() => void>()
295
+ waiters.add(waiter)
296
+ listWaiters.set(token, waiters)
297
+ })
298
+ },
299
+ close: () =>
300
+ new Promise((done) => {
301
+ registry.clear()
302
+ listWaiters.clear()
303
+ for (const open of streams.values()) for (const res of open) res.end()
304
+ streams.clear()
305
+ server.closeAllConnections()
306
+ server.close(() => done())
307
+ })
308
+ })
309
+ })
310
+ })
311
+ }
312
+
313
+ export async function handleRpcFrame(body: string, handlers: McpHandlers): Promise<RpcReply> {
314
+ return respond(parseRequest(body), handlers)
315
+ }
package/src/models.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { ModelThinkingLevel, ThinkingLevelMap } from '@earendil-works/pi-ai'
2
+
3
+ import type { ZenModel } from './client.js'
4
+ import { bridgeModelsRaw } from './config.js'
5
+
6
+ export type PiModelEntry = {
7
+ id: string
8
+ name: string
9
+ reasoning: boolean
10
+ thinkingLevelMap?: ThinkingLevelMap
11
+ input: ('text' | 'image')[]
12
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number }
13
+ contextWindow: number
14
+ maxTokens: number
15
+ }
16
+
17
+ const DEFAULT_CONTEXT_WINDOW = 128_000
18
+ const DEFAULT_MAX_TOKENS = 16_384
19
+
20
+ const THINKING_LEVELS: ModelThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
21
+
22
+ function variantName(level: ModelThinkingLevel, offered: Set<string>): string | null {
23
+ if (level === 'off') return offered.has('off') ? 'off' : offered.has('none') ? 'none' : null
24
+ if (offered.has(level)) return level
25
+ if (level === 'max' && offered.has('xhigh')) return 'xhigh'
26
+ return null
27
+ }
28
+
29
+ export function thinkingLevelMap(model: ZenModel): ThinkingLevelMap | undefined {
30
+ const offered = new Set((model.variants ?? []).map((variant) => variant.id))
31
+ if (offered.size === 0) return undefined
32
+
33
+ const map: ThinkingLevelMap = {}
34
+ for (const level of THINKING_LEVELS) map[level] = variantName(level, offered)
35
+ return map
36
+ }
37
+
38
+ export function variantFor(
39
+ map: ThinkingLevelMap | undefined,
40
+ level: ModelThinkingLevel | undefined
41
+ ): string | undefined {
42
+ if (!map || !level) return undefined
43
+ const variant = map[level]
44
+ return variant ?? undefined
45
+ }
46
+
47
+ export function isFreeModel(model: ZenModel): boolean {
48
+ const cost = model.cost?.[0]
49
+ return (cost?.input ?? 0) === 0 && (cost?.output ?? 0) === 0
50
+ }
51
+
52
+ export function toPiModel(model: ZenModel): PiModelEntry {
53
+ const cost = model.cost?.[0]
54
+ const map = thinkingLevelMap(model)
55
+ const accepts = model.capabilities?.input ?? ['text']
56
+ const entry: PiModelEntry = {
57
+ id: model.id,
58
+ name: `${model.name?.trim() || model.id}${isFreeModel(model) ? ' (free)' : ''}`,
59
+ reasoning: map !== undefined,
60
+ input: accepts.includes('image') ? ['text', 'image'] : ['text'],
61
+ cost: {
62
+ input: cost?.input ?? 0,
63
+ output: cost?.output ?? 0,
64
+ cacheRead: cost?.cache?.read ?? 0,
65
+ cacheWrite: cost?.cache?.write ?? 0
66
+ },
67
+ contextWindow: model.limit?.context || DEFAULT_CONTEXT_WINDOW,
68
+ maxTokens: model.limit?.output || DEFAULT_MAX_TOKENS
69
+ }
70
+ if (map) entry.thinkingLevelMap = map
71
+ return entry
72
+ }
73
+
74
+ export function selectModels(models: ZenModel[], rawOverride?: string): ZenModel[] {
75
+ const raw = rawOverride ?? bridgeModelsRaw()
76
+ if (!raw) return models
77
+ if (raw === 'free') return models.filter(isFreeModel)
78
+
79
+ const wanted = new Set(
80
+ raw
81
+ .split(/[\s,]+/)
82
+ .map((part) => part.trim().replace(/^opencode\//, ''))
83
+ .filter(Boolean)
84
+ )
85
+ return models.filter((model) => wanted.has(model.id))
86
+ }