@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
|
@@ -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
|
+
})
|