@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/session.ts ADDED
@@ -0,0 +1,191 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import type { Message, Tool } from '@earendil-works/pi-ai'
7
+
8
+ import { api } from './client.js'
9
+ import type { JsonObject, McpResult } from './mcp.js'
10
+
11
+ export const MCP_SERVER_NAME = 'pi'
12
+ export const TOOL_PREFIX = `${MCP_SERVER_NAME}_`
13
+ export const BRIDGE_AGENT = 'pi'
14
+
15
+ const MCP_EXECUTION_TIMEOUT_MS = 300_000
16
+
17
+ export type ParkedCall = {
18
+ name: string
19
+ args: JsonObject
20
+ resolve(result: McpResult): void
21
+ }
22
+
23
+ export type TokenTotals = { input: number; output: number; cacheRead: number; cacheWrite: number }
24
+
25
+ export type BridgeSession = {
26
+ token: string
27
+ dir?: string
28
+ opencodeSessionId?: string
29
+ retiredOpencodeIds: Set<string>
30
+ modelId?: string
31
+ variant?: string
32
+ systemPrompt?: string
33
+ toolsKey?: string
34
+ syncedCount: number
35
+ fingerprints: string[]
36
+ tools: Tool[]
37
+ parked: Map<string, ParkedCall>
38
+ answeredPermissions: Set<string>
39
+ refusedTools: string[]
40
+ lateUsage: TokenTotals
41
+ latestStep: TokenTotals
42
+ }
43
+
44
+ export type BridgeSessionStore = { sessions: Map<string, BridgeSession> }
45
+
46
+ export function createSessionStore(): BridgeSessionStore {
47
+ return { sessions: new Map() }
48
+ }
49
+
50
+ export function bridgeSession(store: BridgeSessionStore, piSessionId: string): BridgeSession {
51
+ const existing = store.sessions.get(piSessionId)
52
+ if (existing) return existing
53
+
54
+ const created: BridgeSession = {
55
+ token: randomBytes(12).toString('hex'),
56
+ retiredOpencodeIds: new Set(),
57
+ syncedCount: 0,
58
+ fingerprints: [],
59
+ tools: [],
60
+ parked: new Map(),
61
+ answeredPermissions: new Set(),
62
+ refusedTools: [],
63
+ lateUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
64
+ latestStep: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
65
+ }
66
+ store.sessions.set(piSessionId, created)
67
+ return created
68
+ }
69
+
70
+ export function allSessions(store: BridgeSessionStore): BridgeSession[] {
71
+ return [...store.sessions.values()]
72
+ }
73
+
74
+ export function findByOpencodeId(store: BridgeSessionStore, opencodeSessionId: string): BridgeSession | undefined {
75
+ for (const session of store.sessions.values()) {
76
+ if (session.opencodeSessionId === opencodeSessionId) return session
77
+ if (session.retiredOpencodeIds.has(opencodeSessionId)) return session
78
+ }
79
+ return undefined
80
+ }
81
+
82
+ export function messageFingerprint(message: Message): string {
83
+ const value =
84
+ message.role === 'toolResult'
85
+ ? [message.role, message.toolCallId, message.toolName, message.isError, message.content]
86
+ : [message.role, message.content]
87
+ return createHash('sha256').update(JSON.stringify(value)).digest('base64url')
88
+ }
89
+
90
+ export function toolsKey(tools: Tool[] | undefined): string {
91
+ return JSON.stringify(
92
+ (tools ?? []).map((tool) => ({
93
+ name: tool.name,
94
+ description: tool.description,
95
+ parameters: tool.parameters
96
+ }))
97
+ )
98
+ }
99
+
100
+ const AGENT_SYSTEM = [
101
+ 'You are the model behind the Pi coding agent.',
102
+ '',
103
+ 'Pi tools are MCP tools named pi_*. Call the exact declared name, such as pi_read or pi_bash. A functions. namespace label does not rename a tool.',
104
+ 'OpenCode built-ins such as read, edit, write, bash, glob and grep are disabled. Use their pi_ counterparts instead.',
105
+ 'Your working directory is not the project. Put project paths in pi_ tool arguments.',
106
+ 'Answer in plain prose when no tool is needed.'
107
+ ].join('\n')
108
+
109
+ function bridgeConfig(mcpUrl: string): string {
110
+ return `${JSON.stringify(
111
+ {
112
+ mcp: {
113
+ timeout: { startup: 15_000, catalog: 15_000, execution: MCP_EXECUTION_TIMEOUT_MS },
114
+ servers: {
115
+ [MCP_SERVER_NAME]: {
116
+ type: 'remote',
117
+ url: mcpUrl,
118
+ codemode: false,
119
+ timeout: { execution: MCP_EXECUTION_TIMEOUT_MS }
120
+ }
121
+ }
122
+ },
123
+ agents: {
124
+ [BRIDGE_AGENT]: {
125
+ description: 'Pi bridge',
126
+ mode: 'primary',
127
+ system: AGENT_SYSTEM,
128
+ permissions: [
129
+ { action: '*', resource: '*', effect: 'ask' },
130
+ { action: `${TOOL_PREFIX}*`, resource: '*', effect: 'allow' }
131
+ ]
132
+ }
133
+ }
134
+ },
135
+ null,
136
+ 2
137
+ )}\n`
138
+ }
139
+
140
+ export async function ensureProjectDir(session: BridgeSession, mcpUrl: string): Promise<string> {
141
+ if (session.dir) return session.dir
142
+
143
+ const dir = await mkdtemp(join(tmpdir(), 'pi-opencode-z-'))
144
+ await writeFile(join(dir, 'opencode.jsonc'), bridgeConfig(mcpUrl), 'utf8')
145
+ session.dir = dir
146
+ return dir
147
+ }
148
+
149
+ export async function resetOpencodeSession(session: BridgeSession): Promise<void> {
150
+ const id = session.opencodeSessionId
151
+ session.opencodeSessionId = undefined
152
+ if (id) {
153
+ session.retiredOpencodeIds.add(id)
154
+ await api('DELETE', `/api/session/${id}`).catch(() => undefined)
155
+ }
156
+ session.modelId = undefined
157
+ session.variant = undefined
158
+ session.systemPrompt = undefined
159
+ session.toolsKey = undefined
160
+ session.syncedCount = 0
161
+ session.fingerprints = []
162
+ session.refusedTools = []
163
+ releaseParked(session, 'The bridge restarted this session; the call was dropped.')
164
+ }
165
+
166
+ export function releaseParked(session: BridgeSession, reason: string): void {
167
+ for (const call of session.parked.values()) {
168
+ call.resolve({ content: [{ type: 'text', text: reason }], isError: true })
169
+ }
170
+ session.parked.clear()
171
+ }
172
+
173
+ export async function disposeSession(
174
+ store: BridgeSessionStore,
175
+ piSessionId: string,
176
+ unregister: (token: string) => void
177
+ ): Promise<void> {
178
+ const session = store.sessions.get(piSessionId)
179
+ if (!session) return
180
+ store.sessions.delete(piSessionId)
181
+ unregister(session.token)
182
+ releaseParked(session, 'The Pi session ended.')
183
+ if (session.opencodeSessionId) {
184
+ await api('DELETE', `/api/session/${session.opencodeSessionId}`).catch(() => undefined)
185
+ }
186
+ if (session.dir) await rm(session.dir, { recursive: true, force: true }).catch(() => undefined)
187
+ }
188
+
189
+ export async function disposeAll(store: BridgeSessionStore, unregister: (token: string) => void): Promise<void> {
190
+ await Promise.all([...store.sessions.keys()].map((id) => disposeSession(store, id, unregister)))
191
+ }