@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.
@@ -0,0 +1,28 @@
1
+ import { ORPCError } from "@orpc/server"
2
+ import { z } from "zod/v4"
3
+ import { base } from "./base"
4
+
5
+ export const permissions = {
6
+ /** Reply to a pending permission request (approve once, always, or reject). */
7
+ reply: base
8
+ .input(z.object({
9
+ requestId: z.string(),
10
+ sessionId: z.string(),
11
+ reply: z.enum(["once", "always", "reject"]),
12
+ }))
13
+ .handler(async ({ input, context }) => {
14
+ const sessionRes = await context.client.session.get({ sessionID: input.sessionId })
15
+ const directory = sessionRes.data?.directory
16
+ const res = await context.client.permission.reply({
17
+ requestID: input.requestId,
18
+ reply: input.reply,
19
+ ...(directory ? { directory } : {}),
20
+ })
21
+ if ((res as any).error) {
22
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
23
+ message: "Permission reply failed",
24
+ })
25
+ }
26
+ return { success: true }
27
+ }),
28
+ }
@@ -0,0 +1,175 @@
1
+ import { ORPCError } from "@orpc/server"
2
+ import { z } from "zod/v4"
3
+ import { basename, resolve } from "node:path"
4
+ import { customAlphabet } from "nanoid"
5
+ import { base } from "./base"
6
+ import { sendPrompt } from "../prompt"
7
+ import { WorktreeDriver } from "../worktree"
8
+ import { generateWorktreeSlug } from "../worktree-name"
9
+ import { transcribeAudio } from "../transcribe"
10
+ import type { Session } from "../types"
11
+
12
+ const generateWorktreeId = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 4)
13
+
14
+ export const projects = {
15
+ /**
16
+ * Create a new session for a project and send the first prompt atomically.
17
+ * If `useWorktree: true`, a git worktree is created first and the session
18
+ * runs inside it instead of the main project directory.
19
+ */
20
+ createSession: base
21
+ .input(z.object({
22
+ projectId: z.string(),
23
+ parts: z.array(z.union([
24
+ z.object({ type: z.literal("text"), text: z.string() }),
25
+ z.object({
26
+ type: z.literal("audio"),
27
+ audioData: z.string(),
28
+ mimeType: z.string().optional(),
29
+ lineReference: z.object({
30
+ file: z.string(),
31
+ startLine: z.number(),
32
+ endLine: z.number(),
33
+ side: z.enum(["additions", "deletions"]).optional(),
34
+ }).optional(),
35
+ }),
36
+ ])),
37
+ model: z.object({ providerID: z.string(), modelID: z.string() }).optional(),
38
+ agent: z.string().optional(),
39
+ useWorktree: z.boolean().optional(),
40
+ /** Client-generated message ID for voice messages. */
41
+ clientMessageId: z.string().optional(),
42
+ }))
43
+ .handler(async ({ input, context }) => {
44
+ const { projectId, parts, model, useWorktree, agent, clientMessageId } = input
45
+
46
+ // Look up the project to get its worktree
47
+ const projectsRes = await context.client.project.list()
48
+ const project = (projectsRes.data ?? []).find((p: any) => p.id === projectId)
49
+ if (!project) {
50
+ throw new ORPCError("NOT_FOUND", {
51
+ message: `Project not found: ${projectId}`,
52
+ })
53
+ }
54
+
55
+ // Determine the directory for this session
56
+ let directory: string = project.worktree
57
+ let worktreePath: string | undefined
58
+
59
+ if (useWorktree) {
60
+ try {
61
+ // Extract text from prompt parts — transcribe audio if needed
62
+ let promptText = parts
63
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
64
+ .map((p) => p.text)
65
+ .join(" ")
66
+ .trim()
67
+
68
+ if (!promptText) {
69
+ const audioPart = parts.find(
70
+ (p): p is { type: "audio"; audioData: string; mimeType?: string } => p.type === "audio"
71
+ )
72
+ if (audioPart) {
73
+ promptText = await transcribeAudio(audioPart.audioData, audioPart.mimeType ?? "audio/aac")
74
+ }
75
+ }
76
+
77
+ const slug = await generateWorktreeSlug(promptText)
78
+ const worktreeId = `${generateWorktreeId()}-${slug}`
79
+ const projectName = basename(project.worktree)
80
+ // Place worktrees at ../worktrees/<project-name>/<id> relative to project root
81
+ const targetPath = resolve(project.worktree, "..", "worktrees", projectName, worktreeId)
82
+ const branchName = `worktree/${worktreeId}`
83
+
84
+ const driver = await WorktreeDriver.open(project.worktree)
85
+ await driver.create(branchName, { path: targetPath })
86
+
87
+ directory = targetPath
88
+ worktreePath = targetPath
89
+ } catch (err: any) {
90
+ console.error("[projects.createSession] worktree creation failed:", err)
91
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
92
+ message: `Failed to create worktree: ${err.message}`,
93
+ })
94
+ }
95
+ }
96
+
97
+ // Create the session in the chosen directory
98
+ const createRes = await context.client.session.create({
99
+ directory,
100
+ })
101
+ if (createRes.error) {
102
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
103
+ message: "Failed to create session",
104
+ })
105
+ }
106
+ const sessionId = createRes.data!.id
107
+
108
+ // Persist the session→worktree mapping so we can clean up on delete
109
+ if (worktreePath) {
110
+ context.sessionWorktrees.set(sessionId, { worktreePath, projectWorktree: project.worktree })
111
+ await context.appDs.appendToStream("/", JSON.stringify({
112
+ type: "sessionWorktree",
113
+ key: sessionId,
114
+ value: { sessionId, worktreePath, projectWorktree: project.worktree },
115
+ headers: { operation: "upsert" },
116
+ }), { contentType: "application/json" })
117
+ }
118
+
119
+ // Confirm upload if client provided a message ID
120
+ if (clientMessageId) {
121
+ context.stateStream.emitPendingTranscription(clientMessageId, sessionId, "upload-confirmed")
122
+ }
123
+
124
+ // Fire the prompt in the background — don't block the response.
125
+ // The client navigates to the session immediately and sees streaming
126
+ // updates via the SSE durable stream. Pending transcription events are
127
+ // emitted to the ephemeral stream so the client can show progress.
128
+ sendPrompt(context.client, sessionId, parts, directory, model, agent, undefined, context.stateStream, clientMessageId).catch(
129
+ (err: any) => {
130
+ console.error("[projects.createSession] prompt failed:", err)
131
+ },
132
+ )
133
+
134
+ return { sessionId }
135
+ }),
136
+
137
+ /** List sessions for a project (queries main worktree + all git worktrees). */
138
+ listSessions: base
139
+ .input(z.object({ projectId: z.string() }))
140
+ .handler(async ({ input, context }) => {
141
+ const { projectId } = input
142
+
143
+ // Look up the project to get its worktree
144
+ const projectsRes = await context.client.project.list()
145
+ const project = (projectsRes.data ?? []).find((p: any) => p.id === projectId)
146
+ if (!project) {
147
+ throw new ORPCError("NOT_FOUND", {
148
+ message: `Project not found: ${projectId}`,
149
+ })
150
+ }
151
+
152
+ // Collect all directories: main worktree + any git worktrees
153
+ const directories: string[] = [project.worktree]
154
+ try {
155
+ const driver = await WorktreeDriver.open(project.worktree)
156
+ const entries = await driver.list()
157
+ for (const entry of entries) {
158
+ if (entry.path !== project.worktree) {
159
+ directories.push(entry.path)
160
+ }
161
+ }
162
+ } catch {
163
+ // Not a git repo or worktree listing failed — just use main directory
164
+ }
165
+
166
+ // Query sessions for each directory in parallel
167
+ const results = await Promise.all(
168
+ directories.map(async (dir) => {
169
+ const res = await context.client.session.list({ directory: dir })
170
+ return ((res.data ?? []) as Session[])
171
+ })
172
+ )
173
+ return results.flat().sort((a, b) => b.time.updated - a.time.updated)
174
+ }),
175
+ }
@@ -0,0 +1,316 @@
1
+ import { ORPCError } from "@orpc/server"
2
+ import { z } from "zod/v4"
3
+ import { base } from "./base"
4
+ import { sendPrompt } from "../prompt"
5
+ import { handleVoicePrompt } from "../voice-prompt"
6
+ import { WorktreeDriver } from "../worktree"
7
+
8
+ export const sessions = {
9
+ /** Send a prompt to an existing session. */
10
+ prompt: base
11
+ .input(z.object({
12
+ sessionId: z.string(),
13
+ parts: z.array(z.union([
14
+ z.object({ type: z.literal("text"), text: z.string() }),
15
+ z.object({
16
+ type: z.literal("audio"),
17
+ audioData: z.string(),
18
+ mimeType: z.string().optional(),
19
+ lineReference: z.object({
20
+ file: z.string(),
21
+ startLine: z.number(),
22
+ endLine: z.number(),
23
+ side: z.enum(["additions", "deletions"]).optional(),
24
+ }).optional(),
25
+ }),
26
+ ])),
27
+ model: z.object({ providerID: z.string(), modelID: z.string() }).optional(),
28
+ agent: z.string().optional(),
29
+ lineReference: z.object({
30
+ file: z.string(),
31
+ startLine: z.number(),
32
+ endLine: z.number(),
33
+ side: z.enum(["additions", "deletions"]).optional(),
34
+ }).optional(),
35
+ /** Client-generated message ID for voice messages. Enables server-side
36
+ * transcription status tracking and seamless client-side dedup. */
37
+ clientMessageId: z.string().optional(),
38
+ }))
39
+ .handler(async ({ input, context }) => {
40
+ const { sessionId, parts, model, agent, lineReference, clientMessageId } = input
41
+ try {
42
+ const sessionRes = await context.client.session.get({ sessionID: sessionId })
43
+ const directory = sessionRes.data?.directory
44
+ const hasAudio = parts.some((p) => p.type === "audio")
45
+
46
+ if (hasAudio && clientMessageId) {
47
+ // Confirm upload — client can now show "safe to navigate away"
48
+ context.stateStream.emitPendingTranscription(clientMessageId, sessionId, "upload-confirmed")
49
+ }
50
+
51
+ if (hasAudio) {
52
+ // For audio prompts, return immediately and run transcription in the background.
53
+ // The client tracks progress via pending transcription events on the ephemeral stream.
54
+ sendPrompt(context.client, sessionId, parts, directory, model, agent, lineReference, context.stateStream, clientMessageId).catch(
55
+ (err: any) => {
56
+ console.error("[sessions.prompt] background audio prompt failed:", err)
57
+ },
58
+ )
59
+ return { success: true as const }
60
+ }
61
+ // Text-only prompts are fast — await them inline
62
+ await sendPrompt(context.client, sessionId, parts, directory, model, agent, lineReference, context.stateStream, clientMessageId)
63
+ return { success: true as const }
64
+ } catch (err: any) {
65
+ console.error("[sessions.prompt]", err)
66
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
67
+ message: err.message ?? "Prompt failed",
68
+ })
69
+ }
70
+ }),
71
+
72
+ /** Abort a currently running session. */
73
+ abort: base
74
+ .input(z.object({ sessionId: z.string() }))
75
+ .handler(async ({ input, context }) => {
76
+ const { sessionId } = input
77
+ try {
78
+ const sessionRes = await context.client.session.get({ sessionID: sessionId })
79
+ const directory = sessionRes.data?.directory
80
+ const res = await context.client.session.abort({
81
+ sessionID: sessionId,
82
+ ...(directory ? { directory } : {}),
83
+ })
84
+ if (res.error) {
85
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
86
+ message: "Failed to abort session",
87
+ })
88
+ }
89
+ return { success: true as const }
90
+ } catch (err: any) {
91
+ if (err instanceof ORPCError) throw err
92
+ console.error("[sessions.abort]", err)
93
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
94
+ message: err.message ?? "Abort failed",
95
+ })
96
+ }
97
+ }),
98
+
99
+ /** Delete a session permanently (and remove its worktree if one exists). */
100
+ delete: base
101
+ .input(z.object({ sessionId: z.string() }))
102
+ .handler(async ({ input, context }) => {
103
+ const { sessionId } = input
104
+ try {
105
+ const sessionRes = await context.client.session.get({ sessionID: sessionId })
106
+ const directory = sessionRes.data?.directory
107
+
108
+ const res = await context.client.session.delete({
109
+ sessionID: sessionId,
110
+ ...(directory ? { directory } : {}),
111
+ })
112
+ if (res.error) {
113
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
114
+ message: "Failed to delete session",
115
+ })
116
+ }
117
+
118
+ // Push the deletion through the durable stream immediately so
119
+ // connected clients see it without waiting for the SSE event.
120
+ context.stateStream.sessionDeleted({ id: sessionId })
121
+
122
+ // Clean up the git worktree if this session had one
123
+ const worktreeInfo = context.sessionWorktrees.get(sessionId)
124
+ if (worktreeInfo) {
125
+ try {
126
+ const driver = await WorktreeDriver.open(worktreeInfo.projectWorktree)
127
+ await driver.remove(worktreeInfo.worktreePath, { force: true, deleteBranch: true })
128
+ } catch (err: any) {
129
+ // Log but don't fail the delete — the session is already gone
130
+ console.error(`[sessions.delete] worktree cleanup failed:`, err)
131
+ }
132
+ context.sessionWorktrees.delete(sessionId)
133
+ }
134
+
135
+ return { success: true as const }
136
+ } catch (err: any) {
137
+ if (err instanceof ORPCError) throw err
138
+ console.error("[sessions.delete]", err)
139
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
140
+ message: err.message ?? "Delete failed",
141
+ })
142
+ }
143
+ }),
144
+
145
+ /** Execute a command on a session. */
146
+ command: base
147
+ .input(z.object({
148
+ sessionId: z.string(),
149
+ command: z.string(),
150
+ arguments: z.string(),
151
+ agent: z.string().optional(),
152
+ model: z.object({ providerID: z.string(), modelID: z.string() }).optional(),
153
+ }))
154
+ .handler(async ({ input, context }) => {
155
+ const { sessionId, command, arguments: args, agent, model } = input
156
+ try {
157
+ const sessionRes = await context.client.session.get({ sessionID: sessionId })
158
+ const directory = sessionRes.data?.directory
159
+ const res = await context.client.session.command({
160
+ sessionID: sessionId,
161
+ directory,
162
+ command,
163
+ arguments: args,
164
+ ...(agent ? { agent } : {}),
165
+ ...(model ? { model: `${model.providerID}/${model.modelID}` } : {}),
166
+ })
167
+ if (res.error) {
168
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
169
+ message: "Command failed",
170
+ })
171
+ }
172
+ return { success: true as const }
173
+ } catch (err: any) {
174
+ if (err instanceof ORPCError) throw err
175
+ console.error("[sessions.command]", err)
176
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
177
+ message: err.message ?? "Command failed",
178
+ })
179
+ }
180
+ }),
181
+
182
+ /** Walking-mode voice prompt: transcribe, route, optionally respond with TTS. */
183
+ voicePrompt: base
184
+ .input(z.object({
185
+ sessionId: z.string(),
186
+ audioData: z.string(),
187
+ mimeType: z.string().optional(),
188
+ model: z.object({ providerID: z.string(), modelID: z.string() }).optional(),
189
+ }))
190
+ .handler(async ({ input, context }) => {
191
+ const { sessionId, audioData, mimeType, model } = input
192
+ try {
193
+ const sessionRes = await context.client.session.get({ sessionID: sessionId })
194
+ const directory = sessionRes.data?.directory
195
+ return await handleVoicePrompt(
196
+ context.client,
197
+ sessionId,
198
+ audioData,
199
+ mimeType ?? "audio/x-caf",
200
+ directory,
201
+ model,
202
+ )
203
+ } catch (err: any) {
204
+ if (err instanceof ORPCError) throw err
205
+ console.error("[sessions.voicePrompt]", err)
206
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
207
+ message: err.message ?? "Voice prompt failed",
208
+ })
209
+ }
210
+ }),
211
+
212
+ /** Archive a session (persistent app state). */
213
+ archive: base
214
+ .input(z.object({ sessionId: z.string() }))
215
+ .handler(async ({ input, context }) => {
216
+ await context.appDs.appendToStream("/", JSON.stringify({
217
+ type: "sessionMeta",
218
+ key: input.sessionId,
219
+ value: { sessionId: input.sessionId, archived: true },
220
+ headers: { operation: "upsert" },
221
+ }), { contentType: "application/json" })
222
+ return { success: true as const }
223
+ }),
224
+
225
+ /** Unarchive a session (persistent app state). */
226
+ unarchive: base
227
+ .input(z.object({ sessionId: z.string() }))
228
+ .handler(async ({ input, context }) => {
229
+ await context.appDs.appendToStream("/", JSON.stringify({
230
+ type: "sessionMeta",
231
+ key: input.sessionId,
232
+ value: { sessionId: input.sessionId, archived: false },
233
+ headers: { operation: "upsert" },
234
+ }), { contentType: "application/json" })
235
+ return { success: true as const }
236
+ }),
237
+
238
+ /** Check the merge status for a worktree session. */
239
+ mergeStatus: base
240
+ .input(z.object({ sessionId: z.string() }))
241
+ .handler(async ({ input, context }) => {
242
+ const worktreeInfo = context.sessionWorktrees.get(input.sessionId)
243
+ if (!worktreeInfo) {
244
+ return { isWorktreeSession: false as const }
245
+ }
246
+
247
+ try {
248
+ const driver = await WorktreeDriver.open(worktreeInfo.projectWorktree)
249
+ const branch = await driver.branchForPath(worktreeInfo.worktreePath)
250
+ if (!branch) {
251
+ return { isWorktreeSession: true as const, error: "Could not resolve branch for worktree" }
252
+ }
253
+
254
+ const merged = await driver.isMerged(branch, "main")
255
+ const hasUnmerged = await driver.hasUnmergedCommits(branch, "main")
256
+
257
+ return {
258
+ isWorktreeSession: true as const,
259
+ branch,
260
+ merged,
261
+ hasUnmergedCommits: hasUnmerged,
262
+ }
263
+ } catch (err: any) {
264
+ console.error(`[sessions.mergeStatus]`, err)
265
+ return { isWorktreeSession: true as const, error: err.message ?? "Failed to check merge status" }
266
+ }
267
+ }),
268
+
269
+ /** Merge a worktree session's branch into main. */
270
+ merge: base
271
+ .input(z.object({ sessionId: z.string() }))
272
+ .handler(async ({ input, context }) => {
273
+ const worktreeInfo = context.sessionWorktrees.get(input.sessionId)
274
+ if (!worktreeInfo) {
275
+ throw new ORPCError("BAD_REQUEST", {
276
+ message: "Session does not have an associated worktree",
277
+ })
278
+ }
279
+
280
+ try {
281
+ const driver = await WorktreeDriver.open(worktreeInfo.projectWorktree)
282
+ const branch = await driver.branchForPath(worktreeInfo.worktreePath)
283
+ if (!branch) {
284
+ throw new ORPCError("BAD_REQUEST", {
285
+ message: "Could not resolve branch for worktree",
286
+ })
287
+ }
288
+
289
+ // Dry-run: check for conflicts before attempting the real merge
290
+ const check = await driver.canMerge(branch, "main")
291
+ if (!check.ok) {
292
+ throw new ORPCError("CONFLICT", {
293
+ message: "Merge would conflict",
294
+ data: {
295
+ reason: check.reason,
296
+ conflictingFiles: check.conflictingFiles,
297
+ },
298
+ })
299
+ }
300
+
301
+ // Real merge: --no-ff into main (default)
302
+ await driver.merge(branch, { into: "main" })
303
+
304
+ // Push updated worktree status through the stream
305
+ context.stateStream.refreshWorktreeStatus(input.sessionId)
306
+
307
+ return { success: true as const, branch }
308
+ } catch (err: any) {
309
+ if (err instanceof ORPCError) throw err
310
+ console.error(`[sessions.merge]`, err)
311
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
312
+ message: err.message ?? "Merge failed",
313
+ })
314
+ }
315
+ }),
316
+ }
@@ -0,0 +1,9 @@
1
+ import { base } from "./base"
2
+
3
+ export const snapshot = {
4
+ /** Return materialized ephemeral state so clients can bootstrap without replaying history. */
5
+ ephemeral: base
6
+ .handler(async ({ context }) => {
7
+ return context.stateStream.getEphemeralSnapshot()
8
+ }),
9
+ }
package/src/server.ts ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Standalone Bun HTTP server entrypoint for `bun run dev` / `bun run start`.
4
+ *
5
+ * Reads configuration from environment variables only (no CLI arg parsing).
6
+ * The CLI entrypoint (`index.ts`) calls {@link startServer} directly.
7
+ */
8
+
9
+ import { startServer } from "./start-server"
10
+ import { env } from "./env"
11
+
12
+ export const { app, instanceDs, ephemeralDs, appDs, stateStream, instanceId, server } = await startServer({
13
+ opencodeUrl: env.OPENCODE_URL || undefined,
14
+ port: env.PORT,
15
+ })