@bprp/flockcode 0.0.2
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/package.json +45 -0
- package/src/app.ts +153 -0
- package/src/diagnose-stream.ts +305 -0
- package/src/env.ts +35 -0
- package/src/event-discovery.ts +355 -0
- package/src/event-driven-test.ts +72 -0
- package/src/index.ts +223 -0
- package/src/opencode.ts +278 -0
- package/src/prompt.ts +127 -0
- package/src/router/agents.ts +57 -0
- package/src/router/base.ts +10 -0
- package/src/router/commands.ts +57 -0
- package/src/router/context.ts +22 -0
- package/src/router/diffs.ts +46 -0
- package/src/router/index.ts +24 -0
- package/src/router/models.ts +55 -0
- package/src/router/permissions.ts +28 -0
- package/src/router/projects.ts +175 -0
- package/src/router/sessions.ts +316 -0
- package/src/router/snapshot.ts +9 -0
- package/src/server.ts +15 -0
- package/src/spawn-opencode.ts +166 -0
- package/src/sprite-configure-services.ts +302 -0
- package/src/sprite-sync.ts +413 -0
- package/src/sprites.ts +328 -0
- package/src/start-server.ts +49 -0
- package/src/state-stream.ts +711 -0
- package/src/transcribe.ts +100 -0
- package/src/types.ts +430 -0
- package/src/voice-prompt.ts +222 -0
- package/src/worktree-name.ts +62 -0
- package/src/worktree.ts +549 -0
package/src/opencode.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Wrapper around the opencode SDK that maps responses to our types.
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createOpencodeClient,
|
|
5
|
+
type Event as OpencodeEvent,
|
|
6
|
+
type PermissionRequest,
|
|
7
|
+
} from "@opencode-ai/sdk/v2"
|
|
8
|
+
import type {
|
|
9
|
+
Message,
|
|
10
|
+
MessagePart,
|
|
11
|
+
} from "./types"
|
|
12
|
+
|
|
13
|
+
export type { OpencodeEvent }
|
|
14
|
+
|
|
15
|
+
export type OpencodeClient = ReturnType<typeof createOpencodeClient>
|
|
16
|
+
|
|
17
|
+
export function createClient(baseUrl: string): OpencodeClient {
|
|
18
|
+
const client = createOpencodeClient({ baseUrl })
|
|
19
|
+
return client
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Map SDK event parts into our app message types. */
|
|
23
|
+
|
|
24
|
+
export function mapPart(p: any): MessagePart {
|
|
25
|
+
switch (p.type) {
|
|
26
|
+
case "text":
|
|
27
|
+
return { type: "text" as const, id: p.id, text: p.text }
|
|
28
|
+
case "tool":
|
|
29
|
+
return {
|
|
30
|
+
type: "tool" as const,
|
|
31
|
+
id: p.id,
|
|
32
|
+
tool: p.tool,
|
|
33
|
+
state: {
|
|
34
|
+
status: p.state?.status ?? "pending",
|
|
35
|
+
input: p.state?.input,
|
|
36
|
+
output: p.state?.output,
|
|
37
|
+
title: p.state?.title,
|
|
38
|
+
error: p.state?.error,
|
|
39
|
+
metadata: p.state?.metadata,
|
|
40
|
+
time: p.state?.time,
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
case "step-start":
|
|
44
|
+
return { type: "step-start" as const, id: p.id }
|
|
45
|
+
case "step-finish":
|
|
46
|
+
return { type: "step-finish" as const, id: p.id }
|
|
47
|
+
case "reasoning":
|
|
48
|
+
return { type: "reasoning" as const, id: p.id, text: p.text }
|
|
49
|
+
default:
|
|
50
|
+
return { type: "text" as const, id: p.id, text: `[${p.type}]` }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function mapMessage(raw: any): Message {
|
|
55
|
+
const info = raw.info
|
|
56
|
+
const parts: MessagePart[] = (raw.parts ?? []).map(mapPart)
|
|
57
|
+
|
|
58
|
+
// Extract model info — user messages nest it under info.model,
|
|
59
|
+
// assistant messages have it flat on info
|
|
60
|
+
const modelID = info.role === "user"
|
|
61
|
+
? info.model?.modelID
|
|
62
|
+
: info.modelID
|
|
63
|
+
const providerID = info.role === "user"
|
|
64
|
+
? info.model?.providerID
|
|
65
|
+
: info.providerID
|
|
66
|
+
|
|
67
|
+
const agent = info.agent as string | undefined
|
|
68
|
+
|
|
69
|
+
const msg: Message = {
|
|
70
|
+
id: info.id,
|
|
71
|
+
sessionId: info.sessionID,
|
|
72
|
+
role: info.role,
|
|
73
|
+
parts,
|
|
74
|
+
createdAt: info.time?.created ?? 0,
|
|
75
|
+
...(modelID ? { modelID } : {}),
|
|
76
|
+
...(providerID ? { providerID } : {}),
|
|
77
|
+
...(agent ? { agent } : {}),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (info.role === "assistant") {
|
|
81
|
+
msg.cost = info.cost
|
|
82
|
+
msg.tokens = info.tokens
|
|
83
|
+
? {
|
|
84
|
+
input: info.tokens.input,
|
|
85
|
+
output: info.tokens.output,
|
|
86
|
+
reasoning: info.tokens.reasoning,
|
|
87
|
+
}
|
|
88
|
+
: undefined
|
|
89
|
+
msg.finish = info.finish
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return msg
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Callback signature for receiving Opencode events. */
|
|
96
|
+
export type OpencodeEventCallback = (event: OpencodeEvent) => void
|
|
97
|
+
|
|
98
|
+
export class Opencode {
|
|
99
|
+
#client: OpencodeClient
|
|
100
|
+
|
|
101
|
+
constructor(baseUrl: string) {
|
|
102
|
+
this.#client = createOpencodeClient({ baseUrl })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async spawnListener(callback: OpencodeEventCallback, baseUrl: string) {
|
|
106
|
+
const url = `${baseUrl}/global/event`
|
|
107
|
+
const res = await fetch(url)
|
|
108
|
+
if (!res.ok || !res.body) throw new Error(`Failed to connect to ${url}: ${res.status}`)
|
|
109
|
+
const reader = res.body.getReader()
|
|
110
|
+
const decoder = new TextDecoder()
|
|
111
|
+
let buffer = ""
|
|
112
|
+
const processStream = async () => {
|
|
113
|
+
while (true) {
|
|
114
|
+
const { done, value } = await reader.read()
|
|
115
|
+
if (done) break
|
|
116
|
+
buffer += decoder.decode(value, { stream: true })
|
|
117
|
+
const lines = buffer.split("\n")
|
|
118
|
+
buffer = lines.pop() ?? ""
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
if (!line.startsWith("data:")) continue
|
|
121
|
+
const json = line.slice("data:".length).trim()
|
|
122
|
+
if (!json) continue
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(json)
|
|
125
|
+
// Global events wrap the payload: { directory, payload: <event> }
|
|
126
|
+
const event = parsed.payload ?? parsed
|
|
127
|
+
if (event.type === "server.heartbeat" || event.type === "server.connected") continue
|
|
128
|
+
callback(event)
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
processStream()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async listProjects() {
|
|
137
|
+
const res = await this.#client.project.list()
|
|
138
|
+
if (res.error) throw new Error("Failed to list projects")
|
|
139
|
+
if (!res.data) throw new Error('No projects found')
|
|
140
|
+
return res.data
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async listSessions() {
|
|
144
|
+
const res = await this.#client.session.list()
|
|
145
|
+
if (res.error) throw new Error("Failed to list sessions")
|
|
146
|
+
if (!res.data) throw new Error('No sessions found')
|
|
147
|
+
return (res.data)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Exhaustive event handler contract.
|
|
153
|
+
* Translates every OpencodeEvent into the appropriate StateStream calls.
|
|
154
|
+
*/
|
|
155
|
+
export type StateStreamSink = {
|
|
156
|
+
sessionCreated(info: any): void
|
|
157
|
+
sessionUpdated(info: any): void
|
|
158
|
+
sessionDeleted(info: any): void
|
|
159
|
+
sessionStatus(sessionId: string, status: { type: "idle" } | { type: "busy" } | { type: "retry"; attempt: number; message: string; next: number }): void
|
|
160
|
+
sessionIdle(sessionId: string): void
|
|
161
|
+
sessionCompacted(sessionId: string): void
|
|
162
|
+
sessionDiff(sessionId: string, diff: any[]): void
|
|
163
|
+
sessionError(sessionId: string | undefined, error: any): void
|
|
164
|
+
messageUpdated(info: any): void
|
|
165
|
+
messageRemoved(sessionId: string, messageId: string): void
|
|
166
|
+
messagePartUpdated(part: any): void
|
|
167
|
+
messagePartDelta(messageId: string, partId: string, field: string, delta: string): void
|
|
168
|
+
messagePartRemoved(sessionId: string, messageId: string, partId: string): void
|
|
169
|
+
permissionAsked(permission: PermissionRequest): void
|
|
170
|
+
permissionReplied(sessionId: string, requestId: string, reply: string): void
|
|
171
|
+
todoUpdated(sessionId: string, todos: any[]): void
|
|
172
|
+
commandExecuted(sessionId: string, name: string, args: string, messageId: string): void
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function handleOpencodeEvent(
|
|
176
|
+
event: OpencodeEvent,
|
|
177
|
+
sink: StateStreamSink,
|
|
178
|
+
): void {
|
|
179
|
+
switch (event.type) {
|
|
180
|
+
// --- Session lifecycle ---
|
|
181
|
+
case "session.created":
|
|
182
|
+
return sink.sessionCreated(event.properties.info)
|
|
183
|
+
case "session.updated":
|
|
184
|
+
return sink.sessionUpdated(event.properties.info)
|
|
185
|
+
case "session.deleted":
|
|
186
|
+
return sink.sessionDeleted(event.properties.info)
|
|
187
|
+
case "session.status":
|
|
188
|
+
return sink.sessionStatus(event.properties.sessionID, (event.properties as any).status ?? { type: "busy" })
|
|
189
|
+
case "session.idle":
|
|
190
|
+
return sink.sessionIdle(event.properties.sessionID)
|
|
191
|
+
case "session.compacted":
|
|
192
|
+
return sink.sessionCompacted(event.properties.sessionID)
|
|
193
|
+
case "session.diff":
|
|
194
|
+
return sink.sessionDiff(event.properties.sessionID, event.properties.diff)
|
|
195
|
+
case "session.error":
|
|
196
|
+
return sink.sessionError(event.properties.sessionID, event.properties.error)
|
|
197
|
+
|
|
198
|
+
// --- Messages ---
|
|
199
|
+
case "message.updated":
|
|
200
|
+
return sink.messageUpdated(event.properties.info)
|
|
201
|
+
case "message.removed":
|
|
202
|
+
return sink.messageRemoved(event.properties.sessionID, event.properties.messageID)
|
|
203
|
+
|
|
204
|
+
// --- Message parts ---
|
|
205
|
+
case "message.part.updated":
|
|
206
|
+
return sink.messagePartUpdated(event.properties.part)
|
|
207
|
+
case "message.part.delta":
|
|
208
|
+
return sink.messagePartDelta(
|
|
209
|
+
event.properties.messageID,
|
|
210
|
+
event.properties.partID,
|
|
211
|
+
event.properties.field,
|
|
212
|
+
event.properties.delta,
|
|
213
|
+
)
|
|
214
|
+
case "message.part.removed":
|
|
215
|
+
return sink.messagePartRemoved(
|
|
216
|
+
event.properties.sessionID,
|
|
217
|
+
event.properties.messageID,
|
|
218
|
+
event.properties.partID,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
// --- Permissions ---
|
|
222
|
+
case "permission.asked":
|
|
223
|
+
return sink.permissionAsked(event.properties)
|
|
224
|
+
case "permission.replied":
|
|
225
|
+
return sink.permissionReplied(
|
|
226
|
+
event.properties.sessionID,
|
|
227
|
+
event.properties.requestID,
|
|
228
|
+
event.properties.reply,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
// --- Todos & commands ---
|
|
232
|
+
case "todo.updated":
|
|
233
|
+
return sink.todoUpdated(event.properties.sessionID, event.properties.todos)
|
|
234
|
+
case "command.executed":
|
|
235
|
+
return sink.commandExecuted(
|
|
236
|
+
event.properties.sessionID,
|
|
237
|
+
event.properties.name,
|
|
238
|
+
event.properties.arguments,
|
|
239
|
+
event.properties.messageID,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
// --- Non-session events (no-ops for state stream) ---
|
|
243
|
+
case "server.instance.disposed":
|
|
244
|
+
case "server.connected":
|
|
245
|
+
case "global.disposed":
|
|
246
|
+
case "installation.updated":
|
|
247
|
+
case "installation.update-available":
|
|
248
|
+
case "project.updated":
|
|
249
|
+
case "lsp.client.diagnostics":
|
|
250
|
+
case "lsp.updated":
|
|
251
|
+
case "file.edited":
|
|
252
|
+
case "file.watcher.updated":
|
|
253
|
+
case "vcs.branch.updated":
|
|
254
|
+
case "tui.prompt.append":
|
|
255
|
+
case "tui.command.execute":
|
|
256
|
+
case "tui.toast.show":
|
|
257
|
+
case "tui.session.select":
|
|
258
|
+
case "mcp.tools.changed":
|
|
259
|
+
case "mcp.browser.open.failed":
|
|
260
|
+
case "pty.created":
|
|
261
|
+
case "pty.updated":
|
|
262
|
+
case "pty.exited":
|
|
263
|
+
case "pty.deleted":
|
|
264
|
+
case "question.asked":
|
|
265
|
+
case "question.replied":
|
|
266
|
+
case "question.rejected":
|
|
267
|
+
case "worktree.ready":
|
|
268
|
+
case "worktree.failed":
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
default: {
|
|
272
|
+
// Exhaustive check: if TypeScript complains here, a new event type
|
|
273
|
+
// was added to the SDK and needs to be handled above.
|
|
274
|
+
const _exhaustive: never = event
|
|
275
|
+
console.warn("Unhandled opencode event type:", (event as any).type)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Extracted prompt logic: resolves audio parts via transcription, then forwards to OpenCode.
|
|
2
|
+
|
|
3
|
+
import type { OpencodeClient } from "./opencode"
|
|
4
|
+
import { mapMessage } from "./opencode"
|
|
5
|
+
import { transcribeAudio } from "./transcribe"
|
|
6
|
+
import type { Message, PromptPartInput } from "./types"
|
|
7
|
+
import type { StateStream } from "./state-stream"
|
|
8
|
+
|
|
9
|
+
/** Line reference from the diff viewer — the user selected these lines before sending. */
|
|
10
|
+
interface LineReference {
|
|
11
|
+
file: string
|
|
12
|
+
startLine: number
|
|
13
|
+
endLine: number
|
|
14
|
+
side?: "additions" | "deletions"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function sendPrompt(
|
|
18
|
+
client: OpencodeClient,
|
|
19
|
+
sessionId: string,
|
|
20
|
+
parts: PromptPartInput[],
|
|
21
|
+
directory?: string,
|
|
22
|
+
model?: { providerID: string; modelID: string },
|
|
23
|
+
agent?: string,
|
|
24
|
+
lineReference?: LineReference,
|
|
25
|
+
stateStream?: StateStream,
|
|
26
|
+
/** Client-generated message ID. When provided, the real OpenCode user message
|
|
27
|
+
* will be created with this ID, enabling seamless client-side dedup. */
|
|
28
|
+
clientMessageId?: string,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
// Fetch conversation context for audio transcription
|
|
31
|
+
let conversationContext: Message[] | undefined
|
|
32
|
+
const hasAudio = parts.some((p) => p.type === "audio")
|
|
33
|
+
if (hasAudio && clientMessageId) {
|
|
34
|
+
// Notify client that the server has the audio and transcription is starting
|
|
35
|
+
stateStream?.emitPendingTranscription(clientMessageId, sessionId, "transcribing")
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const res = await client.session.messages({ sessionID: sessionId, directory })
|
|
39
|
+
if (!res.error && res.data) {
|
|
40
|
+
conversationContext = (res.data as any[]).map(mapMessage)
|
|
41
|
+
}
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const partSummary = parts.map((p) => p.type === "audio" ? "audio" : `text(${p.text.length})`).join(", ")
|
|
46
|
+
console.log(`[prompt] session=${sessionId} received ${parts.length} part(s): ${partSummary}`)
|
|
47
|
+
|
|
48
|
+
// Resolve all parts to text, transcribing audio via Gemini (in parallel)
|
|
49
|
+
const textParts = await Promise.all(
|
|
50
|
+
parts.map(async (p) => {
|
|
51
|
+
if (p.type === "audio") {
|
|
52
|
+
try {
|
|
53
|
+
const transcription = await transcribeAudio(
|
|
54
|
+
p.audioData,
|
|
55
|
+
p.mimeType ?? "audio/mp4",
|
|
56
|
+
conversationContext,
|
|
57
|
+
)
|
|
58
|
+
let text = transcription || "[inaudible]"
|
|
59
|
+
|
|
60
|
+
// Prepend per-chunk line reference if the audio part carries one
|
|
61
|
+
if (p.lineReference) {
|
|
62
|
+
const lines = p.lineReference.startLine === p.lineReference.endLine
|
|
63
|
+
? `${p.lineReference.startLine}`
|
|
64
|
+
: `${p.lineReference.startLine}-${p.lineReference.endLine}`
|
|
65
|
+
const sideAttr = p.lineReference.side ? ` side="${p.lineReference.side}"` : ""
|
|
66
|
+
text = `<reference lines="${lines}" file="${p.lineReference.file}"${sideAttr}>\n${text}\n</reference>`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { type: "text" as const, text }
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error(`[prompt] transcription error:`, err)
|
|
72
|
+
return { type: "text" as const, text: "[transcription error]" }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { type: "text" as const, text: p.text }
|
|
76
|
+
}),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
// Notify client that transcription is complete with the resolved text
|
|
80
|
+
if (hasAudio && clientMessageId) {
|
|
81
|
+
const resolvedText = textParts.map((p) => p.text).join("\n")
|
|
82
|
+
stateStream?.emitPendingTranscription(clientMessageId, sessionId, "completed", resolvedText)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Prepend line reference context if the user selected lines in the diff viewer
|
|
86
|
+
if (lineReference && textParts.length > 0) {
|
|
87
|
+
const lines = lineReference.startLine === lineReference.endLine
|
|
88
|
+
? `${lineReference.startLine}`
|
|
89
|
+
: `${lineReference.startLine}-${lineReference.endLine}`
|
|
90
|
+
const sideAttr = lineReference.side ? ` side="${lineReference.side}"` : ""
|
|
91
|
+
const prefix = `<reference lines="${lines}" file="${lineReference.file}"${sideAttr}>\n`
|
|
92
|
+
const suffix = `\n</reference>`
|
|
93
|
+
textParts[0] = { type: "text", text: prefix + textParts[0].text + suffix }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const resolvedText = textParts.map((p) => p.text.slice(0, 100)).join(" | ")
|
|
97
|
+
console.log(`[prompt] session=${sessionId} forwarding to opencode: ${textParts.length} part(s), text preview: "${resolvedText.slice(0, 200)}"`)
|
|
98
|
+
|
|
99
|
+
const res = await client.session.promptAsync({
|
|
100
|
+
sessionID: sessionId,
|
|
101
|
+
directory,
|
|
102
|
+
parts: textParts,
|
|
103
|
+
// Disable the question tool — our mobile client doesn't support answering
|
|
104
|
+
// questions yet, and unanswered questions cause the session to hang.
|
|
105
|
+
// See: https://github.com/ben-pr-p/flockcode/issues/2
|
|
106
|
+
tools: { question: false },
|
|
107
|
+
...(model ? { model } : {}),
|
|
108
|
+
...(agent ? { agent } : {}),
|
|
109
|
+
// Use the client-generated message ID so the real user message arrives with
|
|
110
|
+
// the same ID, enabling seamless client-side dedup with the pending placeholder.
|
|
111
|
+
...(clientMessageId ? { messageID: clientMessageId } : {}),
|
|
112
|
+
})
|
|
113
|
+
if (res.error) {
|
|
114
|
+
console.error(`[prompt] session=${sessionId} opencode error:`, res.error)
|
|
115
|
+
throw new Error(`Prompt failed: ${JSON.stringify(res.error)}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Notify client the prompt has been forwarded to OpenCode.
|
|
119
|
+
// The real user message will arrive via the instance stream with the same ID,
|
|
120
|
+
// and the client's allMessages merge deduplicates by ID (server wins) —
|
|
121
|
+
// no explicit delete needed.
|
|
122
|
+
if (hasAudio && clientMessageId) {
|
|
123
|
+
stateStream?.emitPendingTranscription(clientMessageId, sessionId, "forwarded", textParts.map((p) => p.text).join("\n"))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log(`[prompt] session=${sessionId} prompt accepted by opencode (async)`)
|
|
127
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ORPCError } from "@orpc/server"
|
|
2
|
+
import { base } from "./base"
|
|
3
|
+
|
|
4
|
+
export const agents = {
|
|
5
|
+
/** List available agents, pre-shaped for the client. */
|
|
6
|
+
list: base
|
|
7
|
+
.handler(async ({ context }) => {
|
|
8
|
+
try {
|
|
9
|
+
const res = await (context.client.app as any).agents()
|
|
10
|
+
if (res.error) {
|
|
11
|
+
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
12
|
+
message: "Failed to list agents",
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const data = res.data as any
|
|
17
|
+
|
|
18
|
+
const agents: {
|
|
19
|
+
name: string
|
|
20
|
+
description?: string
|
|
21
|
+
mode: string
|
|
22
|
+
color?: string
|
|
23
|
+
}[] = []
|
|
24
|
+
|
|
25
|
+
if (data && typeof data === "object") {
|
|
26
|
+
if (Array.isArray(data)) {
|
|
27
|
+
for (const agent of data) {
|
|
28
|
+
agents.push({
|
|
29
|
+
name: agent.name ?? "",
|
|
30
|
+
description: agent.description,
|
|
31
|
+
mode: agent.mode ?? "primary",
|
|
32
|
+
color: agent.color,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
for (const [key, value] of Object.entries(data)) {
|
|
37
|
+
const agent = value as any
|
|
38
|
+
agents.push({
|
|
39
|
+
name: agent.name ?? key,
|
|
40
|
+
description: agent.description,
|
|
41
|
+
mode: agent.mode ?? "primary",
|
|
42
|
+
color: agent.color,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return agents
|
|
49
|
+
} catch (err: any) {
|
|
50
|
+
if (err instanceof ORPCError) throw err
|
|
51
|
+
console.error("[agents.list]", err)
|
|
52
|
+
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
53
|
+
message: err.message ?? "Failed to list agents",
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}),
|
|
57
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { os } from "@orpc/server"
|
|
2
|
+
import type { RouterContext } from "./context"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Base procedure builder with the shared RouterContext.
|
|
6
|
+
*
|
|
7
|
+
* All router procedures should be built from this base so they
|
|
8
|
+
* automatically receive the injected context (client, appDs, etc.).
|
|
9
|
+
*/
|
|
10
|
+
export const base = os.$context<RouterContext>()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { ORPCError } from "@orpc/server"
|
|
2
|
+
import { base } from "./base"
|
|
3
|
+
|
|
4
|
+
export const commands = {
|
|
5
|
+
/** List available commands, pre-shaped for the client. */
|
|
6
|
+
list: base
|
|
7
|
+
.handler(async ({ context }) => {
|
|
8
|
+
try {
|
|
9
|
+
const res = await (context.client.command as any).list()
|
|
10
|
+
if (res.error) {
|
|
11
|
+
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
12
|
+
message: "Failed to list commands",
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const data = res.data as any
|
|
17
|
+
|
|
18
|
+
const commands: {
|
|
19
|
+
name: string
|
|
20
|
+
description?: string
|
|
21
|
+
agent?: string
|
|
22
|
+
template: string
|
|
23
|
+
}[] = []
|
|
24
|
+
|
|
25
|
+
if (data && typeof data === "object") {
|
|
26
|
+
if (Array.isArray(data)) {
|
|
27
|
+
for (const cmd of data) {
|
|
28
|
+
commands.push({
|
|
29
|
+
name: cmd.name ?? "",
|
|
30
|
+
description: cmd.description,
|
|
31
|
+
agent: cmd.agent,
|
|
32
|
+
template: cmd.template ?? "",
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
for (const [key, value] of Object.entries(data)) {
|
|
37
|
+
const cmd = value as any
|
|
38
|
+
commands.push({
|
|
39
|
+
name: cmd.name ?? key,
|
|
40
|
+
description: cmd.description,
|
|
41
|
+
agent: cmd.agent,
|
|
42
|
+
template: cmd.template ?? "",
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return commands
|
|
49
|
+
} catch (err: any) {
|
|
50
|
+
if (err instanceof ORPCError) throw err
|
|
51
|
+
console.error("[commands.list]", err)
|
|
52
|
+
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
53
|
+
message: err.message ?? "Failed to list commands",
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}),
|
|
57
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { DurableStreamServer } from "durable-streams-web-standard"
|
|
2
|
+
import type { OpencodeClient } from "../opencode"
|
|
3
|
+
import type { StateStream } from "../state-stream"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared context provided to every oRPC procedure.
|
|
7
|
+
*
|
|
8
|
+
* Injected at handler creation time in app.ts — procedures receive this
|
|
9
|
+
* via oRPC's context mechanism, making them independently testable.
|
|
10
|
+
*/
|
|
11
|
+
export interface RouterContext {
|
|
12
|
+
/** OpenCode SDK client for talking to the opencode backend. */
|
|
13
|
+
client: OpencodeClient
|
|
14
|
+
/** Persistent app-level durable stream (survives server restarts). */
|
|
15
|
+
appDs: DurableStreamServer
|
|
16
|
+
/** In-memory ephemeral durable stream (resets on server restart). */
|
|
17
|
+
ephemeralDs: DurableStreamServer
|
|
18
|
+
/** In-memory index of session → worktree mappings. */
|
|
19
|
+
sessionWorktrees: Map<string, { worktreePath: string; projectWorktree: string }>
|
|
20
|
+
/** Real-time state stream for pushing updates to connected clients. */
|
|
21
|
+
stateStream: StateStream
|
|
22
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ORPCError } from "@orpc/server"
|
|
2
|
+
import { z } from "zod/v4"
|
|
3
|
+
import { base } from "./base"
|
|
4
|
+
|
|
5
|
+
export const diffs = {
|
|
6
|
+
/** Returns { file, before, after } for a single file in a session. */
|
|
7
|
+
get: base
|
|
8
|
+
.input(z.object({ session: z.string(), file: z.string() }))
|
|
9
|
+
.handler(async ({ input, context }) => {
|
|
10
|
+
const { session: sessionId, file } = input
|
|
11
|
+
const sessionRes = await context.client.session.get({ sessionID: sessionId })
|
|
12
|
+
const directory = sessionRes.data?.directory
|
|
13
|
+
const res = await context.client.session.diff({ sessionID: sessionId, directory })
|
|
14
|
+
if (res.error) {
|
|
15
|
+
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
16
|
+
message: "Failed to fetch diffs",
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
const match = (res.data ?? []).find((d: any) => d.file === file)
|
|
20
|
+
if (!match) {
|
|
21
|
+
throw new ORPCError("NOT_FOUND", {
|
|
22
|
+
message: `File not found: ${file}`,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
return { file: match.file as string, before: match.before as string, after: match.after as string }
|
|
26
|
+
}),
|
|
27
|
+
|
|
28
|
+
/** Returns all file diffs for a session. */
|
|
29
|
+
list: base
|
|
30
|
+
.input(z.object({ session: z.string() }))
|
|
31
|
+
.handler(async ({ input, context }) => {
|
|
32
|
+
const sessionRes = await context.client.session.get({ sessionID: input.session })
|
|
33
|
+
const directory = sessionRes.data?.directory
|
|
34
|
+
const res = await context.client.session.diff({ sessionID: input.session, directory })
|
|
35
|
+
if (res.error) {
|
|
36
|
+
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
37
|
+
message: "Failed to fetch diffs",
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
return (res.data ?? []).map((d: any) => ({
|
|
41
|
+
file: d.file as string,
|
|
42
|
+
before: d.before as string,
|
|
43
|
+
after: d.after as string,
|
|
44
|
+
}))
|
|
45
|
+
}),
|
|
46
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { sessions } from "./sessions"
|
|
2
|
+
import { projects } from "./projects"
|
|
3
|
+
import { diffs } from "./diffs"
|
|
4
|
+
import { models } from "./models"
|
|
5
|
+
import { agents } from "./agents"
|
|
6
|
+
import { commands } from "./commands"
|
|
7
|
+
import { permissions } from "./permissions"
|
|
8
|
+
import { snapshot } from "./snapshot"
|
|
9
|
+
|
|
10
|
+
export type { RouterContext } from "./context"
|
|
11
|
+
|
|
12
|
+
/** The complete oRPC router — mounted at /api/* in the Hono app. */
|
|
13
|
+
export const router = {
|
|
14
|
+
sessions,
|
|
15
|
+
projects,
|
|
16
|
+
diffs,
|
|
17
|
+
models,
|
|
18
|
+
agents,
|
|
19
|
+
commands,
|
|
20
|
+
permissions,
|
|
21
|
+
snapshot,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Router = typeof router
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ORPCError } from "@orpc/server"
|
|
2
|
+
import { base } from "./base"
|
|
3
|
+
|
|
4
|
+
export const models = {
|
|
5
|
+
/** List available providers and models, pre-shaped for the client. */
|
|
6
|
+
list: base
|
|
7
|
+
.handler(async ({ context }) => {
|
|
8
|
+
try {
|
|
9
|
+
const res = await context.client.provider.list()
|
|
10
|
+
if (res.error) {
|
|
11
|
+
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
12
|
+
message: "Failed to list providers",
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const data = res.data as any
|
|
17
|
+
|
|
18
|
+
// Extract connected provider IDs
|
|
19
|
+
const connectedSet = new Set<string>(data.connected ?? [])
|
|
20
|
+
|
|
21
|
+
// Flatten providers → models, filtering to connected providers only
|
|
22
|
+
const models: {
|
|
23
|
+
id: string
|
|
24
|
+
name: string
|
|
25
|
+
providerID: string
|
|
26
|
+
providerName: string
|
|
27
|
+
status?: string
|
|
28
|
+
}[] = []
|
|
29
|
+
|
|
30
|
+
for (const provider of data.all ?? []) {
|
|
31
|
+
if (!connectedSet.has(provider.id)) continue
|
|
32
|
+
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
|
|
33
|
+
const m = model as any
|
|
34
|
+
models.push({
|
|
35
|
+
id: modelId,
|
|
36
|
+
name: m.name ?? modelId,
|
|
37
|
+
providerID: provider.id,
|
|
38
|
+
providerName: provider.name ?? provider.id,
|
|
39
|
+
status: m.status,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const defaults: Record<string, string> = data.default ?? {}
|
|
45
|
+
|
|
46
|
+
return { models, defaults }
|
|
47
|
+
} catch (err: any) {
|
|
48
|
+
if (err instanceof ORPCError) throw err
|
|
49
|
+
console.error("[models.list]", err)
|
|
50
|
+
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
51
|
+
message: err.message ?? "Failed to list models",
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}),
|
|
55
|
+
}
|