@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,355 @@
|
|
|
1
|
+
// Event discovery driver: sends a real prompt and logs every event to understand
|
|
2
|
+
// how events map to messages, tool calls, and file changes.
|
|
3
|
+
//
|
|
4
|
+
// Usage: bun src/event-discovery.ts
|
|
5
|
+
//
|
|
6
|
+
// Requires: opencode server running on localhost:4096
|
|
7
|
+
|
|
8
|
+
import { createOpencodeClient, type Event as OpencodeEvent } from "@opencode-ai/sdk/v2"
|
|
9
|
+
import { mapMessage } from "./opencode"
|
|
10
|
+
|
|
11
|
+
const client = createOpencodeClient({ baseUrl: "http://localhost:4096" })
|
|
12
|
+
|
|
13
|
+
// ─── Event collection ────────────────────────────────────────────────
|
|
14
|
+
type CollectedEvent = {
|
|
15
|
+
index: number
|
|
16
|
+
timestamp: number
|
|
17
|
+
type: string
|
|
18
|
+
sessionId: string | undefined
|
|
19
|
+
raw: any
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const events: CollectedEvent[] = []
|
|
23
|
+
let eventIndex = 0
|
|
24
|
+
|
|
25
|
+
// ─── Start event subscription ────────────────────────────────────────
|
|
26
|
+
async function subscribeToEvents(targetSessionId: string): Promise<() => void> {
|
|
27
|
+
const subscription = await client.event.subscribe()
|
|
28
|
+
let running = true
|
|
29
|
+
|
|
30
|
+
const loop = async () => {
|
|
31
|
+
for await (const event of subscription.stream) {
|
|
32
|
+
if (!running) break
|
|
33
|
+
const props = event.properties as any
|
|
34
|
+
const sessionId: string | undefined =
|
|
35
|
+
props.sessionID ??
|
|
36
|
+
props.info?.sessionID ??
|
|
37
|
+
props.info?.id ??
|
|
38
|
+
props.part?.sessionID ??
|
|
39
|
+
undefined
|
|
40
|
+
|
|
41
|
+
// Only collect events for our target session
|
|
42
|
+
if (sessionId !== targetSessionId) continue
|
|
43
|
+
|
|
44
|
+
const collected: CollectedEvent = {
|
|
45
|
+
index: eventIndex++,
|
|
46
|
+
timestamp: Date.now(),
|
|
47
|
+
type: event.type,
|
|
48
|
+
sessionId,
|
|
49
|
+
raw: event,
|
|
50
|
+
}
|
|
51
|
+
events.push(collected)
|
|
52
|
+
|
|
53
|
+
// Print event in real-time
|
|
54
|
+
const summary = summarizeEvent(event)
|
|
55
|
+
console.log(`[EVENT ${collected.index}] ${event.type} ${summary}`)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
loop().catch(() => {})
|
|
59
|
+
|
|
60
|
+
return () => { running = false }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function summarizeEvent(event: OpencodeEvent): string {
|
|
64
|
+
const props = event.properties as any
|
|
65
|
+
switch (event.type) {
|
|
66
|
+
case "message.updated": {
|
|
67
|
+
const info = props.info
|
|
68
|
+
return `role=${info?.role} msgId=${info?.id?.slice(0, 8)}`
|
|
69
|
+
}
|
|
70
|
+
case "message.part.updated": {
|
|
71
|
+
const part = props.part
|
|
72
|
+
return `partType=${part?.type} tool=${part?.tool ?? "-"} status=${part?.state?.status ?? "-"} partId=${part?.id?.slice(0, 8)}`
|
|
73
|
+
}
|
|
74
|
+
case "session.updated":
|
|
75
|
+
return `title="${props.info?.title ?? ""}"`
|
|
76
|
+
case "session.status":
|
|
77
|
+
return `status=${props.status}`
|
|
78
|
+
case "session.idle":
|
|
79
|
+
return ""
|
|
80
|
+
case "session.diff":
|
|
81
|
+
return `files=${JSON.stringify(props.files?.map((f: any) => f.file) ?? [])}`
|
|
82
|
+
case "session.error":
|
|
83
|
+
return `error=${JSON.stringify(props.error)}`
|
|
84
|
+
case "message.removed":
|
|
85
|
+
return `msgId=${props.messageID?.slice(0, 8)}`
|
|
86
|
+
case "message.part.removed":
|
|
87
|
+
return `partId=${props.partID?.slice(0, 8)}`
|
|
88
|
+
case "todo.updated":
|
|
89
|
+
return `todo=${JSON.stringify(props.todo?.title ?? "")}`
|
|
90
|
+
case "command.executed":
|
|
91
|
+
return `cmd=${props.command}`
|
|
92
|
+
default:
|
|
93
|
+
return JSON.stringify(props).slice(0, 120)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Run a prompt and collect everything ─────────────────────────────
|
|
98
|
+
|
|
99
|
+
async function runPrompt(sessionId: string, text: string) {
|
|
100
|
+
console.log(`\n${"=".repeat(70)}`)
|
|
101
|
+
console.log(`PROMPTING: "${text}"`)
|
|
102
|
+
console.log(`${"=".repeat(70)}\n`)
|
|
103
|
+
|
|
104
|
+
const res = await client.session.prompt({
|
|
105
|
+
sessionID: sessionId,
|
|
106
|
+
parts: [{ type: "text", text }],
|
|
107
|
+
})
|
|
108
|
+
if (res.error) {
|
|
109
|
+
console.error("Prompt error:", res.error)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
console.log(`\nPrompt returned. Response message role=${(res.data as any)?.info?.role}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function fetchMessages(sessionId: string) {
|
|
116
|
+
const res = await client.session.messages({ sessionID: sessionId })
|
|
117
|
+
if (res.error) throw new Error("Failed to fetch messages")
|
|
118
|
+
return (res.data ?? []).map(mapMessage)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function fetchDiffs(sessionId: string) {
|
|
122
|
+
const res = await client.session.diff({ sessionID: sessionId })
|
|
123
|
+
if (res.error) throw new Error("Failed to fetch diffs")
|
|
124
|
+
return res.data ?? []
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Analysis ────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function analyzeEvents() {
|
|
130
|
+
console.log(`\n${"=".repeat(70)}`)
|
|
131
|
+
console.log("EVENT ANALYSIS")
|
|
132
|
+
console.log(`${"=".repeat(70)}`)
|
|
133
|
+
|
|
134
|
+
// Event type frequencies
|
|
135
|
+
const typeCounts = new Map<string, number>()
|
|
136
|
+
for (const e of events) {
|
|
137
|
+
typeCounts.set(e.type, (typeCounts.get(e.type) ?? 0) + 1)
|
|
138
|
+
}
|
|
139
|
+
console.log("\nEvent type frequencies:")
|
|
140
|
+
for (const [type, count] of [...typeCounts].sort((a, b) => b[1] - a[1])) {
|
|
141
|
+
console.log(` ${type}: ${count}`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Message part events breakdown
|
|
145
|
+
const partEvents = events.filter(e => e.type === "message.part.updated")
|
|
146
|
+
const partTypeBreakdown = new Map<string, number>()
|
|
147
|
+
const toolEvents: CollectedEvent[] = []
|
|
148
|
+
for (const e of partEvents) {
|
|
149
|
+
const part = (e.raw.properties as any).part
|
|
150
|
+
const key = part.type === "tool"
|
|
151
|
+
? `tool:${part.tool}:${part.state?.status}`
|
|
152
|
+
: part.type
|
|
153
|
+
partTypeBreakdown.set(key, (partTypeBreakdown.get(key) ?? 0) + 1)
|
|
154
|
+
if (part.type === "tool") toolEvents.push(e)
|
|
155
|
+
}
|
|
156
|
+
console.log("\nPart event breakdown:")
|
|
157
|
+
for (const [key, count] of [...partTypeBreakdown].sort()) {
|
|
158
|
+
console.log(` ${key}: ${count}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Tool lifecycle analysis
|
|
162
|
+
if (toolEvents.length > 0) {
|
|
163
|
+
console.log("\nTool call lifecycle (events in order):")
|
|
164
|
+
const toolCalls = new Map<string, { tool: string; events: { status: string; index: number; title?: string }[] }>()
|
|
165
|
+
for (const e of toolEvents) {
|
|
166
|
+
const part = (e.raw.properties as any).part
|
|
167
|
+
const callId = part.callID ?? part.id
|
|
168
|
+
if (!toolCalls.has(callId)) {
|
|
169
|
+
toolCalls.set(callId, { tool: part.tool, events: [] })
|
|
170
|
+
}
|
|
171
|
+
toolCalls.get(callId)!.events.push({
|
|
172
|
+
status: part.state?.status,
|
|
173
|
+
index: e.index,
|
|
174
|
+
title: part.state?.title,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
for (const [callId, info] of toolCalls) {
|
|
178
|
+
console.log(` ${info.tool} (${callId.slice(0, 8)}):`)
|
|
179
|
+
for (const ev of info.events) {
|
|
180
|
+
console.log(` [${ev.index}] ${ev.status}${ev.title ? ` "${ev.title}"` : ""}`)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Text streaming analysis
|
|
186
|
+
const textEvents = partEvents.filter(e => (e.raw.properties as any).part.type === "text")
|
|
187
|
+
if (textEvents.length > 0) {
|
|
188
|
+
console.log(`\nText part updates: ${textEvents.length} events`)
|
|
189
|
+
// Show first and last text content to understand streaming
|
|
190
|
+
const firstText = (textEvents[0].raw.properties as any).part.text
|
|
191
|
+
const lastText = (textEvents[textEvents.length - 1].raw.properties as any).part.text
|
|
192
|
+
console.log(` First text length: ${firstText?.length ?? 0}`)
|
|
193
|
+
console.log(` Last text length: ${lastText?.length ?? 0}`)
|
|
194
|
+
console.log(` First 100 chars: ${(firstText ?? "").slice(0, 100)}`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Session status events
|
|
198
|
+
const statusEvents = events.filter(e => e.type === "session.status" || e.type === "session.idle")
|
|
199
|
+
if (statusEvents.length > 0) {
|
|
200
|
+
console.log("\nSession status timeline:")
|
|
201
|
+
for (const e of statusEvents) {
|
|
202
|
+
const props = e.raw.properties as any
|
|
203
|
+
console.log(` [${e.index}] ${e.type} ${props.status ?? ""}`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Diff events
|
|
208
|
+
const diffEvents = events.filter(e => e.type === "session.diff")
|
|
209
|
+
if (diffEvents.length > 0) {
|
|
210
|
+
console.log("\nDiff events:")
|
|
211
|
+
for (const e of diffEvents) {
|
|
212
|
+
const props = e.raw.properties as any
|
|
213
|
+
console.log(` [${e.index}]`, JSON.stringify(props).slice(0, 200))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── Main ────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
async function main() {
|
|
221
|
+
// Create a fresh session
|
|
222
|
+
console.log("Creating session...")
|
|
223
|
+
const createRes = await client.session.create({
|
|
224
|
+
title: "event-discovery-test",
|
|
225
|
+
})
|
|
226
|
+
if (createRes.error || !createRes.data) {
|
|
227
|
+
console.error("Failed to create session:", createRes.error)
|
|
228
|
+
process.exit(1)
|
|
229
|
+
}
|
|
230
|
+
const sessionId = createRes.data.id
|
|
231
|
+
console.log(`Session created: ${sessionId}`)
|
|
232
|
+
|
|
233
|
+
// Start listening for events
|
|
234
|
+
const stopListening = await subscribeToEvents(sessionId)
|
|
235
|
+
|
|
236
|
+
// Wait a beat for subscription to be ready
|
|
237
|
+
await Bun.sleep(500)
|
|
238
|
+
|
|
239
|
+
// ─── Test 1: Simple grep task ───────────────────────────────────────
|
|
240
|
+
await runPrompt(
|
|
241
|
+
sessionId,
|
|
242
|
+
`Use the Grep tool to search for "export function" in the file src/opencode.ts. Then briefly tell me what you found. Do NOT create or modify any files.`,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
// Wait for events to settle
|
|
246
|
+
await Bun.sleep(3000)
|
|
247
|
+
|
|
248
|
+
// Fetch final state
|
|
249
|
+
console.log("\n--- Final messages state ---")
|
|
250
|
+
const messages1 = await fetchMessages(sessionId)
|
|
251
|
+
for (const m of messages1) {
|
|
252
|
+
console.log(`Message ${m.id.slice(0, 8)} role=${m.role} parts=${m.parts.length}`)
|
|
253
|
+
for (const p of m.parts) {
|
|
254
|
+
if (p.type === "text") {
|
|
255
|
+
console.log(` text: ${p.text.slice(0, 100)}...`)
|
|
256
|
+
} else if (p.type === "tool") {
|
|
257
|
+
console.log(` tool: ${p.tool} status=${p.state.status} title=${p.state.title ?? "-"}`)
|
|
258
|
+
} else {
|
|
259
|
+
console.log(` ${p.type}`)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Analyze events from test 1
|
|
265
|
+
analyzeEvents()
|
|
266
|
+
|
|
267
|
+
// ─── Test 2: File creation task ─────────────────────────────────────
|
|
268
|
+
const test2StartIndex = events.length
|
|
269
|
+
console.log(`\n\n${"#".repeat(70)}`)
|
|
270
|
+
console.log("TEST 2: File creation")
|
|
271
|
+
console.log(`${"#".repeat(70)}`)
|
|
272
|
+
|
|
273
|
+
await runPrompt(
|
|
274
|
+
sessionId,
|
|
275
|
+
`Create a new file called /tmp/event-discovery-test.txt with the content "hello from event discovery". Use the write tool.`,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
await Bun.sleep(3000)
|
|
279
|
+
|
|
280
|
+
// Check diffs
|
|
281
|
+
console.log("\n--- Diffs after file creation ---")
|
|
282
|
+
const diffs = await fetchDiffs(sessionId)
|
|
283
|
+
console.log("Diffs:", JSON.stringify(diffs, null, 2).slice(0, 500))
|
|
284
|
+
|
|
285
|
+
// Analyze new events
|
|
286
|
+
const test2Events = events.slice(test2StartIndex)
|
|
287
|
+
console.log(`\nTest 2 events (${test2Events.length}):`)
|
|
288
|
+
for (const e of test2Events) {
|
|
289
|
+
console.log(` [${e.index}] ${e.type} ${summarizeEvent(e.raw)}`)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Final full analysis
|
|
293
|
+
analyzeEvents()
|
|
294
|
+
|
|
295
|
+
// ─── Dump raw events for offline analysis ─────────────────────────
|
|
296
|
+
const dumpPath = "/tmp/event-discovery-dump.json"
|
|
297
|
+
await Bun.write(dumpPath, JSON.stringify(events.map(e => ({
|
|
298
|
+
index: e.index,
|
|
299
|
+
type: e.type,
|
|
300
|
+
sessionId: e.sessionId,
|
|
301
|
+
properties: e.raw.properties,
|
|
302
|
+
})), null, 2))
|
|
303
|
+
console.log(`\nRaw events dumped to ${dumpPath}`)
|
|
304
|
+
|
|
305
|
+
// Summary of findings
|
|
306
|
+
console.log(`\n${"=".repeat(70)}`)
|
|
307
|
+
console.log("RECONSTRUCTION GUIDE")
|
|
308
|
+
console.log(`${"=".repeat(70)}`)
|
|
309
|
+
console.log(`
|
|
310
|
+
Key findings for reconstructing session state from events:
|
|
311
|
+
|
|
312
|
+
1. MESSAGE UPDATES:
|
|
313
|
+
- "message.updated" fires when a message is created/modified
|
|
314
|
+
- properties.info contains full message metadata (id, role, sessionID, etc.)
|
|
315
|
+
- Use message.info.id to track which message is being updated
|
|
316
|
+
|
|
317
|
+
2. MESSAGE PARTS (streaming):
|
|
318
|
+
- "message.part.updated" fires for each part creation/update
|
|
319
|
+
- properties.part contains: { type, id, sessionID, messageID, ... }
|
|
320
|
+
- Text parts: text field grows as content streams in
|
|
321
|
+
- Tool parts: state transitions through pending → running → completed/error
|
|
322
|
+
|
|
323
|
+
3. TOOL CALL LIFECYCLE:
|
|
324
|
+
- part.type === "tool" with part.state.status tracking:
|
|
325
|
+
pending → running → completed (or error)
|
|
326
|
+
- part.tool = tool name, part.callID = unique call ID
|
|
327
|
+
- part.state.input = tool arguments
|
|
328
|
+
- part.state.output = tool result (on completed)
|
|
329
|
+
- part.state.title = human-readable description (on running/completed)
|
|
330
|
+
|
|
331
|
+
4. FILE CHANGES:
|
|
332
|
+
- "session.diff" event fires when files are modified
|
|
333
|
+
- Use session.diff API to get full before/after content
|
|
334
|
+
- Part type "patch" in messages tracks which files were affected
|
|
335
|
+
|
|
336
|
+
5. SESSION STATUS:
|
|
337
|
+
- "session.status" = session is busy (running)
|
|
338
|
+
- "session.idle" = session is done processing
|
|
339
|
+
|
|
340
|
+
6. RECONSTRUCTION STRATEGY:
|
|
341
|
+
- Listen to all events for a session
|
|
342
|
+
- On "message.part.updated": update the specific part in your local state
|
|
343
|
+
- On "message.updated": update message metadata
|
|
344
|
+
- On "session.diff": refresh file changes
|
|
345
|
+
- On "session.idle": mark session as idle, do final state sync
|
|
346
|
+
`)
|
|
347
|
+
|
|
348
|
+
stopListening()
|
|
349
|
+
process.exit(0)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
main().catch((err) => {
|
|
353
|
+
console.error("Fatal:", err)
|
|
354
|
+
process.exit(1)
|
|
355
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// End-to-end test: validates that the StateStream correctly reconstructs
|
|
2
|
+
// messages from the OpenCode event stream during a live prompt.
|
|
3
|
+
//
|
|
4
|
+
// Tests the durable stream state sync directly.
|
|
5
|
+
//
|
|
6
|
+
// Usage: bun src/event-driven-test.ts
|
|
7
|
+
// Requires: opencode server on localhost:4096
|
|
8
|
+
|
|
9
|
+
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
|
10
|
+
import { Opencode, mapMessage } from "./opencode"
|
|
11
|
+
import type { OpencodeEventCallback } from "./opencode"
|
|
12
|
+
|
|
13
|
+
const OPENCODE_URL = "http://localhost:4096"
|
|
14
|
+
|
|
15
|
+
async function main() {
|
|
16
|
+
const client = createOpencodeClient({ baseUrl: OPENCODE_URL })
|
|
17
|
+
const opencode = new Opencode(OPENCODE_URL)
|
|
18
|
+
|
|
19
|
+
// Create a fresh session
|
|
20
|
+
console.log("Creating session...")
|
|
21
|
+
const createRes = await client.session.create({ title: "event-driven-test" })
|
|
22
|
+
if (createRes.error || !createRes.data) throw new Error("Failed to create session")
|
|
23
|
+
const sessionId = createRes.data.id
|
|
24
|
+
console.log(`Session: ${sessionId}`)
|
|
25
|
+
|
|
26
|
+
// Track events received
|
|
27
|
+
let eventCount = 0
|
|
28
|
+
const onEvent: OpencodeEventCallback = (event) => {
|
|
29
|
+
eventCount++
|
|
30
|
+
console.log(`[Event #${eventCount}] ${event.type}`)
|
|
31
|
+
}
|
|
32
|
+
await opencode.spawnListener(onEvent, OPENCODE_URL)
|
|
33
|
+
|
|
34
|
+
// Send prompt directly via SDK
|
|
35
|
+
console.log("\n--- Sending prompt ---")
|
|
36
|
+
const promptRes = await client.session.prompt({
|
|
37
|
+
sessionID: sessionId,
|
|
38
|
+
parts: [{ type: "text", text: 'Respond with just the word "hi". Nothing else.' }],
|
|
39
|
+
})
|
|
40
|
+
if (promptRes.error) throw new Error(`Prompt failed: ${JSON.stringify(promptRes.error)}`)
|
|
41
|
+
console.log(`Prompt returned: role=${(promptRes.data as any)?.info?.role}`)
|
|
42
|
+
|
|
43
|
+
// Wait for trailing events
|
|
44
|
+
await Bun.sleep(3000)
|
|
45
|
+
|
|
46
|
+
// Fetch final messages via SDK
|
|
47
|
+
const msgsRes = await client.session.messages({ sessionID: sessionId })
|
|
48
|
+
const messages = (msgsRes.data ?? []).map(mapMessage)
|
|
49
|
+
|
|
50
|
+
console.log(`\n${"=".repeat(60)}`)
|
|
51
|
+
console.log("RESULTS")
|
|
52
|
+
console.log(`${"=".repeat(60)}`)
|
|
53
|
+
console.log(`Total events received: ${eventCount}`)
|
|
54
|
+
console.log(`Final messages: ${messages.length}`)
|
|
55
|
+
|
|
56
|
+
for (const msg of messages) {
|
|
57
|
+
const partsSummary = msg.parts.map((p: any) => {
|
|
58
|
+
if (p.type === "text") return `text(${p.text.length}ch)`
|
|
59
|
+
if (p.type === "tool") return `tool:${p.tool}:${p.state.status}`
|
|
60
|
+
return p.type
|
|
61
|
+
}).join(", ")
|
|
62
|
+
console.log(` ${msg.role}[${msg.id.slice(0, 8)}]: ${partsSummary}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log("\nDone.")
|
|
66
|
+
process.exit(0)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
main().catch((err) => {
|
|
70
|
+
console.error("Fatal:", err)
|
|
71
|
+
process.exit(1)
|
|
72
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* CLI entrypoint for the flockcode tool.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* flock start [--opencode-url <url>] [--port <port>] — start the HTTP server
|
|
7
|
+
* flock attach — attach to the managed opencode server
|
|
8
|
+
* flock sprite sync [--opencode-url <url>] [--dry-run] — sync projects to Fly Sprite
|
|
9
|
+
* flock sprite configure-services [--dry-run] — configure services & env on Sprite
|
|
10
|
+
* [--opencode-port <port>] [--opencode-dir <dir>]
|
|
11
|
+
* [--flock-server-port <port>]
|
|
12
|
+
* [--flock-auth-token <token>] [--gemini-api-key <key>]
|
|
13
|
+
* [--transcription-model <model>]
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Crust } from "@crustjs/core"
|
|
17
|
+
import { helpPlugin, versionPlugin } from "@crustjs/plugins"
|
|
18
|
+
import { flag, commandValidator } from "@crustjs/validate/zod"
|
|
19
|
+
import { z } from "zod/v4"
|
|
20
|
+
import { createClient } from "./opencode"
|
|
21
|
+
import { ensureOpenCode, opencodeStore } from "./spawn-opencode"
|
|
22
|
+
import { createSpriteClientFromEnv } from "./sprites"
|
|
23
|
+
import { sync } from "./sprite-sync"
|
|
24
|
+
import { configureServices, type ServiceResult } from "./sprite-configure-services"
|
|
25
|
+
import { startServer } from "./start-server"
|
|
26
|
+
import { env } from "./env"
|
|
27
|
+
|
|
28
|
+
/** Format a service result as a human-readable status string. */
|
|
29
|
+
function serviceStatus(r: ServiceResult): string {
|
|
30
|
+
if (r.created) return "created"
|
|
31
|
+
if (r.updated) return "updated"
|
|
32
|
+
return "unchanged"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// start — launch the Bun HTTP server
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const start = new Crust("start")
|
|
40
|
+
.meta({ description: "Start the HTTP server" })
|
|
41
|
+
.flags({
|
|
42
|
+
"opencode-url": flag(
|
|
43
|
+
z.string().url().optional().describe("OpenCode server URL"),
|
|
44
|
+
{ short: "u" },
|
|
45
|
+
),
|
|
46
|
+
port: flag(
|
|
47
|
+
z.coerce.number().int().positive().optional().describe("Server port"),
|
|
48
|
+
{ short: "p" },
|
|
49
|
+
),
|
|
50
|
+
})
|
|
51
|
+
.run(commandValidator(async ({ flags }) => {
|
|
52
|
+
const opencodeUrl = flags["opencode-url"] || undefined
|
|
53
|
+
const port = flags.port ?? env.PORT
|
|
54
|
+
|
|
55
|
+
await startServer({ opencodeUrl, port })
|
|
56
|
+
|
|
57
|
+
// Keep the process alive — Bun.serve runs in the background
|
|
58
|
+
await new Promise(() => {})
|
|
59
|
+
}))
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// attach — connect to a flock-managed opencode server
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
const attach = new Crust("attach")
|
|
66
|
+
.meta({ description: "Attach to the flock-managed opencode server" })
|
|
67
|
+
.run(commandValidator(async () => {
|
|
68
|
+
const { port } = await opencodeStore.read()
|
|
69
|
+
if (!port) {
|
|
70
|
+
console.error("No managed opencode server found. Start one with `flock start` first.")
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const url = `http://localhost:${port}`
|
|
75
|
+
const cwd = process.cwd()
|
|
76
|
+
const child = Bun.spawn(["opencode", "attach", url, "--dir", cwd], {
|
|
77
|
+
stdin: "inherit",
|
|
78
|
+
stdout: "inherit",
|
|
79
|
+
stderr: "inherit",
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const exitCode = await child.exited
|
|
83
|
+
process.exit(exitCode)
|
|
84
|
+
}))
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// sprite sync — sync projects to Fly Sprite
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
const spriteSync = new Crust("sync")
|
|
91
|
+
.meta({ description: "Sync projects to Fly Sprite" })
|
|
92
|
+
.flags({
|
|
93
|
+
"opencode-url": flag(
|
|
94
|
+
z.string().url().optional().describe("OpenCode server URL"),
|
|
95
|
+
{ short: "u" },
|
|
96
|
+
),
|
|
97
|
+
"dry-run": flag(
|
|
98
|
+
z.boolean().default(false).describe("Show what would happen without making changes"),
|
|
99
|
+
{ short: "n" },
|
|
100
|
+
),
|
|
101
|
+
})
|
|
102
|
+
.run(commandValidator(async ({ flags }) => {
|
|
103
|
+
const opencodeUrl = flags["opencode-url"] || env.OPENCODE_URL || undefined
|
|
104
|
+
const { url, child } = await ensureOpenCode(opencodeUrl)
|
|
105
|
+
const dryRun = flags["dry-run"]
|
|
106
|
+
|
|
107
|
+
const opencode = createClient(url)
|
|
108
|
+
const sprite = createSpriteClientFromEnv()
|
|
109
|
+
|
|
110
|
+
if (dryRun) {
|
|
111
|
+
console.log("Dry run — no changes will be made.\n")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const result = await sync(sprite, opencode, { dryRun })
|
|
116
|
+
|
|
117
|
+
console.log("\n--- Summary ---")
|
|
118
|
+
console.log(`Cloned: ${result.cloned.length}`)
|
|
119
|
+
console.log(`Existing: ${result.alreadyExists.length}`)
|
|
120
|
+
console.log(`Uploaded: ${result.filesUploaded.length}`)
|
|
121
|
+
if (result.filesSkipped.length > 0) {
|
|
122
|
+
console.log(`Skipped: ${result.filesSkipped.length}`)
|
|
123
|
+
}
|
|
124
|
+
if (result.warnings.length > 0) {
|
|
125
|
+
console.log(`Warnings: ${result.warnings.length}`)
|
|
126
|
+
}
|
|
127
|
+
} catch (err: any) {
|
|
128
|
+
console.error("Sync failed:", err.message ?? err)
|
|
129
|
+
} finally {
|
|
130
|
+
if (child) {
|
|
131
|
+
try { child.kill() } catch {}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}))
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// sprite configure-services — configure services & env on Sprite
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
const spriteConfigure = new Crust("configure-services")
|
|
141
|
+
.meta({ description: "Configure services and environment on Sprite" })
|
|
142
|
+
.flags({
|
|
143
|
+
"dry-run": flag(
|
|
144
|
+
z.boolean().default(false).describe("Show what would happen without making changes"),
|
|
145
|
+
{ short: "n" },
|
|
146
|
+
),
|
|
147
|
+
"opencode-port": flag(
|
|
148
|
+
z.coerce.number().int().positive().optional().describe("Port for opencode serve on Sprite"),
|
|
149
|
+
),
|
|
150
|
+
"opencode-dir": flag(
|
|
151
|
+
z.string().optional().describe("Working directory for opencode serve on Sprite"),
|
|
152
|
+
),
|
|
153
|
+
"flock-server-port": flag(
|
|
154
|
+
z.coerce.number().int().positive().optional().describe("Port for the flock server on Sprite"),
|
|
155
|
+
),
|
|
156
|
+
"flock-auth-token": flag(
|
|
157
|
+
z.string().optional().describe("Bearer token for mobile client auth (written to .flockenv)"),
|
|
158
|
+
),
|
|
159
|
+
"gemini-api-key": flag(
|
|
160
|
+
z.string().optional().describe("Gemini API key for transcription (written to .flockenv)"),
|
|
161
|
+
),
|
|
162
|
+
"transcription-model": flag(
|
|
163
|
+
z.string().optional().describe("Gemini model for transcription (gemini-3-flash-preview or gemini-3.1-flash-lite-preview)"),
|
|
164
|
+
),
|
|
165
|
+
})
|
|
166
|
+
.run(commandValidator(async ({ flags }) => {
|
|
167
|
+
const dryRun = flags["dry-run"]
|
|
168
|
+
const opencodePort = flags["opencode-port"]
|
|
169
|
+
const opencodeDir = flags["opencode-dir"]
|
|
170
|
+
const flockServerPort = flags["flock-server-port"]
|
|
171
|
+
const flockAuthToken = flags["flock-auth-token"] ?? (env.FLOCK_AUTH_TOKEN || undefined)
|
|
172
|
+
const geminiApiKey = flags["gemini-api-key"] ?? (env.GEMINI_API_KEY || undefined)
|
|
173
|
+
const transcriptionModel = flags["transcription-model"] ?? (env.TRANSCRIPTION_MODEL || undefined)
|
|
174
|
+
|
|
175
|
+
const sprite = createSpriteClientFromEnv()
|
|
176
|
+
|
|
177
|
+
if (dryRun) {
|
|
178
|
+
console.log("Dry run — no changes will be made.\n")
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const result = await configureServices(sprite, {
|
|
183
|
+
dryRun,
|
|
184
|
+
opencodePort,
|
|
185
|
+
opencodeDir,
|
|
186
|
+
flockServerPort,
|
|
187
|
+
flockAuthToken,
|
|
188
|
+
geminiApiKey,
|
|
189
|
+
transcriptionModel,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
console.log("\n--- Summary ---")
|
|
193
|
+
console.log(`Env file: .flockenv ${result.flockenvWritten ? "written" : "unchanged"}`)
|
|
194
|
+
console.log(`Service: opencode-serve ${serviceStatus(result.opencodeServe)}`)
|
|
195
|
+
console.log(`Service: flock-server ${serviceStatus(result.flockServer)}`)
|
|
196
|
+
} catch (err: any) {
|
|
197
|
+
console.error("Configure-services failed:", err.message ?? err)
|
|
198
|
+
process.exit(1)
|
|
199
|
+
}
|
|
200
|
+
}))
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// sprite — container command grouping Sprite operations
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
const sprite = new Crust("sprite")
|
|
207
|
+
.meta({ description: "Fly Sprite management commands" })
|
|
208
|
+
.command(spriteSync)
|
|
209
|
+
.command(spriteConfigure)
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// root — flockcode CLI
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
const main = new Crust("flock")
|
|
216
|
+
.meta({ description: "Mobile AI coding agent server" })
|
|
217
|
+
.use(versionPlugin("0.0.1"))
|
|
218
|
+
.use(helpPlugin())
|
|
219
|
+
.command(start)
|
|
220
|
+
.command(attach)
|
|
221
|
+
.command(sprite)
|
|
222
|
+
|
|
223
|
+
await main.execute()
|