@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,711 @@
|
|
|
1
|
+
export { StateStream }
|
|
2
|
+
|
|
3
|
+
import type { DurableStreamServer } from "durable-streams-web-standard"
|
|
4
|
+
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
|
|
5
|
+
import type { OpencodeClient, StateStreamSink } from "./opencode"
|
|
6
|
+
import { mapMessage, mapPart } from "./opencode"
|
|
7
|
+
import type { Message, MessagePart, ChangedFile } from "./types"
|
|
8
|
+
import { WorktreeDriver } from "./worktree"
|
|
9
|
+
|
|
10
|
+
type InstanceEventType = "project" | "session" | "message"
|
|
11
|
+
type EphemeralEventType = "sessionStatus" | "message" | "change" | "worktreeStatus" | "permissionRequest" | "pendingTranscription"
|
|
12
|
+
|
|
13
|
+
type StateEvent = {
|
|
14
|
+
type: InstanceEventType | EphemeralEventType
|
|
15
|
+
key: string
|
|
16
|
+
value?: unknown
|
|
17
|
+
headers: { operation: "insert" | "update" | "upsert" | "delete" }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Session-to-worktree mapping shared with app.ts. */
|
|
21
|
+
export type SessionWorktreeMap = Map<string, { worktreePath: string; projectWorktree: string }>
|
|
22
|
+
|
|
23
|
+
type SessionStatus = "idle" | "busy" | "error"
|
|
24
|
+
|
|
25
|
+
/** Permission request value emitted to the client via the ephemeral stream. */
|
|
26
|
+
export type PermissionRequestValue = {
|
|
27
|
+
sessionId: string
|
|
28
|
+
requestId: string
|
|
29
|
+
permission: string
|
|
30
|
+
patterns: string[]
|
|
31
|
+
description: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pending transcription value emitted to the client via the ephemeral stream.
|
|
36
|
+
*
|
|
37
|
+
* Keyed by `messageId` (a client-generated UUID) so that multiple concurrent
|
|
38
|
+
* voice messages to the same session can each have independent status.
|
|
39
|
+
* The same `messageId` is passed to OpenCode's `promptAsync` so the real user
|
|
40
|
+
* message arrives with the same ID — enabling seamless client-side dedup.
|
|
41
|
+
*/
|
|
42
|
+
export type PendingTranscriptionValue = {
|
|
43
|
+
messageId: string
|
|
44
|
+
sessionId: string
|
|
45
|
+
status: "uploading" | "upload-confirmed" | "transcribing" | "completed" | "forwarded"
|
|
46
|
+
/** The transcribed text, available when status is 'completed' or 'forwarded'. */
|
|
47
|
+
text?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class StateStream implements StateStreamSink {
|
|
51
|
+
#instanceDs: DurableStreamServer
|
|
52
|
+
#ephemeralDs: DurableStreamServer
|
|
53
|
+
#client: OpencodeClient
|
|
54
|
+
#messages: Map<string, Message> = new Map()
|
|
55
|
+
#sessionDirectories: Map<string, string> = new Map()
|
|
56
|
+
#sessionStatuses: Map<string, { status: SessionStatus; error?: string }> = new Map()
|
|
57
|
+
#pendingPermissions: Map<string, PermissionRequestValue> = new Map()
|
|
58
|
+
#pendingTranscriptions: Map<string, PendingTranscriptionValue> = new Map()
|
|
59
|
+
#lastEmittedSessions: Map<string, any> = new Map()
|
|
60
|
+
#sessionWorktrees: SessionWorktreeMap
|
|
61
|
+
|
|
62
|
+
constructor(instanceDs: DurableStreamServer, ephemeralDs: DurableStreamServer, client: OpencodeClient, sessionWorktrees: SessionWorktreeMap) {
|
|
63
|
+
this.#instanceDs = instanceDs
|
|
64
|
+
this.#ephemeralDs = ephemeralDs
|
|
65
|
+
this.#client = client
|
|
66
|
+
this.#sessionWorktrees = sessionWorktrees
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async initialize() {
|
|
70
|
+
await this.#instanceDs.createStream("/", { contentType: "application/json" })
|
|
71
|
+
await this.#ephemeralDs.createStream("/", { contentType: "application/json" })
|
|
72
|
+
|
|
73
|
+
// Load all projects
|
|
74
|
+
const projects = await this.#client.project.list()
|
|
75
|
+
for (const project of projects.data ?? []) {
|
|
76
|
+
this.#appendInstanceEvent({
|
|
77
|
+
type: "project",
|
|
78
|
+
key: project.id,
|
|
79
|
+
value: mapProject(project),
|
|
80
|
+
headers: { operation: "insert" },
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// For each project, collect all directories that may contain sessions:
|
|
85
|
+
// the main worktree plus any git worktrees created for parallel sessions.
|
|
86
|
+
// Also build a reverse map from worktree path → project worktree so we can
|
|
87
|
+
// populate sessionWorktrees for sessions discovered in worktree directories.
|
|
88
|
+
const allDirectories: string[] = []
|
|
89
|
+
const worktreePathToProject = new Map<string, string>()
|
|
90
|
+
for (const project of projects.data ?? []) {
|
|
91
|
+
allDirectories.push(project.worktree)
|
|
92
|
+
try {
|
|
93
|
+
const driver = await WorktreeDriver.open(project.worktree)
|
|
94
|
+
const entries = await driver.list()
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
if (entry.path !== project.worktree) {
|
|
97
|
+
allDirectories.push(entry.path)
|
|
98
|
+
worktreePathToProject.set(entry.path, project.worktree)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Not a git repo or worktree listing failed — skip
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Load sessions for each directory in parallel
|
|
107
|
+
const projectSessions = await Promise.all(
|
|
108
|
+
allDirectories.map(async (directory) => {
|
|
109
|
+
const res = await this.#client.session.list({ directory })
|
|
110
|
+
|
|
111
|
+
for (const session of res.data ?? []) {
|
|
112
|
+
if (session.directory) {
|
|
113
|
+
this.#sessionDirectories.set(session.id, session.directory)
|
|
114
|
+
}
|
|
115
|
+
this.#emitSession(session.id, mapSession(session), "insert")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return res.data ?? []
|
|
119
|
+
})
|
|
120
|
+
)
|
|
121
|
+
const sessions = projectSessions.flat()
|
|
122
|
+
|
|
123
|
+
// Load all messages for each session
|
|
124
|
+
for (const session of sessions ?? []) {
|
|
125
|
+
const msgs = await this.#client.session.messages({ sessionID: session.id, directory: session.directory })
|
|
126
|
+
for (const raw of msgs.data ?? []) {
|
|
127
|
+
const msg = mapMessage(raw)
|
|
128
|
+
this.#messages.set(msg.id, msg)
|
|
129
|
+
this.#appendInstanceEvent({
|
|
130
|
+
type: "message",
|
|
131
|
+
key: msg.id,
|
|
132
|
+
value: msg,
|
|
133
|
+
headers: { operation: "insert" },
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
// Populate sessionWorktrees for any sessions discovered in worktree
|
|
140
|
+
// directories that aren't already tracked (e.g. app state stream was lost
|
|
141
|
+
// or the session was created while the server was down).
|
|
142
|
+
for (const session of sessions ?? []) {
|
|
143
|
+
const dir = session.directory
|
|
144
|
+
if (dir && !this.#sessionWorktrees.has(session.id) && worktreePathToProject.has(dir)) {
|
|
145
|
+
this.#sessionWorktrees.set(session.id, {
|
|
146
|
+
worktreePath: dir,
|
|
147
|
+
projectWorktree: worktreePathToProject.get(dir)!,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Load file changes for sessions that have diffs
|
|
153
|
+
for (const session of sessions ?? []) {
|
|
154
|
+
if (session.summary?.files && session.summary.files > 0) {
|
|
155
|
+
this.#refetchChanges(session.id)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Emit worktree status for all worktree sessions
|
|
160
|
+
for (const sessionId of this.#sessionWorktrees.keys()) {
|
|
161
|
+
this.#emitWorktreeStatus(sessionId)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- StateStreamSink implementation ---
|
|
166
|
+
|
|
167
|
+
sessionCreated(info: any) {
|
|
168
|
+
if (info.directory) this.#sessionDirectories.set(info.id, info.directory)
|
|
169
|
+
this.#emitSession(info.id, mapSession(info), "insert")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
sessionUpdated(info: any) {
|
|
173
|
+
if (info.directory) this.#sessionDirectories.set(info.id, info.directory)
|
|
174
|
+
this.#emitSession(info.id, mapSession(info), "update")
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
sessionDeleted(info: any) {
|
|
178
|
+
this.#sessionDirectories.delete(info.id)
|
|
179
|
+
this.#sessionStatuses.delete(info.id)
|
|
180
|
+
this.#lastEmittedSessions.delete(info.id)
|
|
181
|
+
this.#appendInstanceEvent({
|
|
182
|
+
type: "session",
|
|
183
|
+
key: info.id,
|
|
184
|
+
headers: { operation: "delete" },
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
sessionStatus(sessionId: string, status: { type: "idle" } | { type: "busy" } | { type: "retry"; attempt: number; message: string; next: number }) {
|
|
189
|
+
// Map retry to busy for the client — the session is still working
|
|
190
|
+
const clientStatus: SessionStatus = status.type === "retry" ? "busy" : status.type
|
|
191
|
+
this.#setSessionStatus(sessionId, clientStatus)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
sessionIdle(sessionId: string) {
|
|
195
|
+
this.#setSessionStatus(sessionId, "idle")
|
|
196
|
+
this.#fullMessageSync(sessionId)
|
|
197
|
+
this.#refetchChanges(sessionId)
|
|
198
|
+
// Clear any stale pending permission when the session goes idle
|
|
199
|
+
if (this.#pendingPermissions.has(sessionId)) {
|
|
200
|
+
this.#pendingPermissions.delete(sessionId)
|
|
201
|
+
this.#appendEphemeralEvent({
|
|
202
|
+
type: "permissionRequest",
|
|
203
|
+
key: sessionId,
|
|
204
|
+
headers: { operation: "delete" },
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
// Full worktree status refresh — commits happen when the session goes idle
|
|
208
|
+
if (this.#sessionWorktrees.has(sessionId)) {
|
|
209
|
+
this.#emitWorktreeStatus(sessionId)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
sessionCompacted(_sessionId: string) {
|
|
214
|
+
// No-op for now
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
sessionDiff(sessionId: string, diff: any[]) {
|
|
218
|
+
// Live diff during active work — ephemeral, not finalized
|
|
219
|
+
this.#appendEphemeralEvent({
|
|
220
|
+
type: "change",
|
|
221
|
+
key: sessionId,
|
|
222
|
+
value: { sessionId, files: this.#mapChanges(diff) },
|
|
223
|
+
headers: { operation: "upsert" },
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
sessionError(sessionId: string | undefined, error: any) {
|
|
228
|
+
if (!sessionId) return
|
|
229
|
+
const message = typeof error === "string" ? error
|
|
230
|
+
: error?.data?.message ?? error?.message ?? "Unknown error"
|
|
231
|
+
this.#setSessionStatus(sessionId, "error", message)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
messageUpdated(info: any) {
|
|
235
|
+
// Extract model info — user messages nest it under info.model,
|
|
236
|
+
// assistant messages have it flat on info
|
|
237
|
+
const modelID = info.role === "user"
|
|
238
|
+
? info.model?.modelID
|
|
239
|
+
: info.modelID
|
|
240
|
+
const providerID = info.role === "user"
|
|
241
|
+
? info.model?.providerID
|
|
242
|
+
: info.providerID
|
|
243
|
+
|
|
244
|
+
const agent = info.agent as string | undefined
|
|
245
|
+
|
|
246
|
+
const existing = this.#messages.get(info.id)
|
|
247
|
+
if (existing) {
|
|
248
|
+
existing.createdAt = info.time?.created ?? existing.createdAt
|
|
249
|
+
if (modelID) existing.modelID = modelID
|
|
250
|
+
if (providerID) existing.providerID = providerID
|
|
251
|
+
if (agent) existing.agent = agent
|
|
252
|
+
if (info.role === "assistant") {
|
|
253
|
+
existing.cost = info.cost
|
|
254
|
+
existing.tokens = info.tokens
|
|
255
|
+
? { input: info.tokens.input, output: info.tokens.output, reasoning: info.tokens.reasoning }
|
|
256
|
+
: existing.tokens
|
|
257
|
+
existing.finish = info.finish
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
const msg: Message = {
|
|
261
|
+
id: info.id,
|
|
262
|
+
sessionId: info.sessionID,
|
|
263
|
+
role: info.role,
|
|
264
|
+
parts: [],
|
|
265
|
+
createdAt: info.time?.created ?? 0,
|
|
266
|
+
...(modelID ? { modelID } : {}),
|
|
267
|
+
...(providerID ? { providerID } : {}),
|
|
268
|
+
...(agent ? { agent } : {}),
|
|
269
|
+
...(info.role === "assistant" ? {
|
|
270
|
+
cost: info.cost,
|
|
271
|
+
tokens: info.tokens
|
|
272
|
+
? { input: info.tokens.input, output: info.tokens.output, reasoning: info.tokens.reasoning }
|
|
273
|
+
: undefined,
|
|
274
|
+
finish: info.finish,
|
|
275
|
+
} : {}),
|
|
276
|
+
}
|
|
277
|
+
this.#messages.set(info.id, msg)
|
|
278
|
+
}
|
|
279
|
+
// If we're receiving an assistant message without a finish signal,
|
|
280
|
+
// the session is actively working — mark it busy if not already
|
|
281
|
+
if (info.role === "assistant" && !info.finish) {
|
|
282
|
+
const current = this.#sessionStatuses.get(info.sessionID)
|
|
283
|
+
if (!current || current.status !== "busy") {
|
|
284
|
+
this.#setSessionStatus(info.sessionID, "busy")
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Finalized messages (user messages, or assistant messages with finish signal)
|
|
289
|
+
// go to the instance stream. In-progress assistant messages go to ephemeral.
|
|
290
|
+
const isFinalized = info.role === "user" || !!info.finish
|
|
291
|
+
this.#emitMessage(info.id, isFinalized ? "instance" : "ephemeral")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
messageRemoved(_sessionId: string, messageId: string) {
|
|
295
|
+
this.#messages.delete(messageId)
|
|
296
|
+
this.#appendInstanceEvent({
|
|
297
|
+
type: "message",
|
|
298
|
+
key: messageId,
|
|
299
|
+
headers: { operation: "delete" },
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
messagePartUpdated(part: any) {
|
|
304
|
+
const msg = this.#messages.get(part.messageID)
|
|
305
|
+
if (!msg) return
|
|
306
|
+
const mapped = mapPart(part)
|
|
307
|
+
const idx = msg.parts.findIndex((p) => p.id === mapped.id)
|
|
308
|
+
if (idx >= 0) {
|
|
309
|
+
msg.parts[idx] = mapped
|
|
310
|
+
} else {
|
|
311
|
+
msg.parts.push(mapped)
|
|
312
|
+
}
|
|
313
|
+
// Part updates are in-progress — ephemeral
|
|
314
|
+
this.#emitMessage(part.messageID, "ephemeral")
|
|
315
|
+
|
|
316
|
+
// Refresh worktree status when a file-editing tool completes.
|
|
317
|
+
// For bash, do a full refresh since it can run git commands that change
|
|
318
|
+
// the commit graph (e.g. git commit). For other edit tools that only
|
|
319
|
+
// produce staged changes, a partial uncommitted-changes refresh suffices.
|
|
320
|
+
if (
|
|
321
|
+
part.type === "tool" &&
|
|
322
|
+
part.state?.status === "completed" &&
|
|
323
|
+
isFileEditTool(part.tool)
|
|
324
|
+
) {
|
|
325
|
+
const sessionId = msg.sessionId
|
|
326
|
+
if (this.#sessionWorktrees.has(sessionId)) {
|
|
327
|
+
const fullRefresh = part.tool === "bash"
|
|
328
|
+
this.#debouncedWorktreeStatusRefresh(sessionId, fullRefresh)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
messagePartDelta(messageId: string, partId: string, field: string, delta: string) {
|
|
334
|
+
const msg = this.#messages.get(messageId)
|
|
335
|
+
if (!msg) return
|
|
336
|
+
const part = msg.parts.find((p) => p.id === partId)
|
|
337
|
+
if (part && field === "text" && "text" in part) {
|
|
338
|
+
;(part as { text: string }).text = (part.text ?? "") + delta
|
|
339
|
+
// Streaming deltas — ephemeral
|
|
340
|
+
this.#emitMessage(messageId, "ephemeral")
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
messagePartRemoved(_sessionId: string, messageId: string, partId: string) {
|
|
345
|
+
const msg = this.#messages.get(messageId)
|
|
346
|
+
if (!msg) return
|
|
347
|
+
msg.parts = msg.parts.filter((p) => p.id !== partId)
|
|
348
|
+
// Part removal — ephemeral
|
|
349
|
+
this.#emitMessage(messageId, "ephemeral")
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
permissionAsked(permission: PermissionRequest) {
|
|
353
|
+
const sessionId = permission.sessionID
|
|
354
|
+
const value: PermissionRequestValue = {
|
|
355
|
+
sessionId,
|
|
356
|
+
requestId: permission.id,
|
|
357
|
+
permission: permission.permission,
|
|
358
|
+
patterns: permission.patterns,
|
|
359
|
+
description: buildPermissionDescription(permission.permission, permission.patterns),
|
|
360
|
+
}
|
|
361
|
+
this.#pendingPermissions.set(sessionId, value)
|
|
362
|
+
this.#appendEphemeralEvent({
|
|
363
|
+
type: "permissionRequest",
|
|
364
|
+
key: sessionId,
|
|
365
|
+
value,
|
|
366
|
+
headers: { operation: "upsert" },
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
permissionReplied(sessionId: string, _requestId: string, _reply: string) {
|
|
371
|
+
this.#pendingPermissions.delete(sessionId)
|
|
372
|
+
this.#appendEphemeralEvent({
|
|
373
|
+
type: "permissionRequest",
|
|
374
|
+
key: sessionId,
|
|
375
|
+
headers: { operation: "delete" },
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Emit a pending transcription status update for a specific message. */
|
|
380
|
+
emitPendingTranscription(messageId: string, sessionId: string, status: PendingTranscriptionValue["status"], text?: string) {
|
|
381
|
+
const value: PendingTranscriptionValue = { messageId, sessionId, status, ...(text ? { text } : {}) }
|
|
382
|
+
this.#pendingTranscriptions.set(messageId, value)
|
|
383
|
+
this.#appendEphemeralEvent({
|
|
384
|
+
type: "pendingTranscription",
|
|
385
|
+
key: messageId,
|
|
386
|
+
value,
|
|
387
|
+
headers: { operation: "upsert" },
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
todoUpdated(_sessionId: string, _todos: any[]) {
|
|
394
|
+
// No-op for now
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
commandExecuted(_sessionId: string, _name: string, _args: string, _messageId: string) {
|
|
398
|
+
// No-op for now
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// --- Snapshot ---
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Returns the materialized ephemeral state for client bootstrapping.
|
|
405
|
+
*
|
|
406
|
+
* The client fetches this before subscribing to the ephemeral stream,
|
|
407
|
+
* then subscribes from the returned offset to avoid missing events.
|
|
408
|
+
* Since Bun is single-threaded, the offset and map reads are consistent.
|
|
409
|
+
*/
|
|
410
|
+
getEphemeralSnapshot(): {
|
|
411
|
+
offset: number
|
|
412
|
+
sessionStatuses: Record<string, { status: SessionStatus; error?: string }>
|
|
413
|
+
worktreeStatuses: Record<string, any>
|
|
414
|
+
pendingPermissions: Record<string, PermissionRequestValue>
|
|
415
|
+
pendingTranscriptions: Record<string, PendingTranscriptionValue>
|
|
416
|
+
} {
|
|
417
|
+
const { messages } = this.#ephemeralDs.readStream("/")
|
|
418
|
+
return {
|
|
419
|
+
offset: messages.length,
|
|
420
|
+
sessionStatuses: Object.fromEntries(this.#sessionStatuses),
|
|
421
|
+
worktreeStatuses: Object.fromEntries(this.#lastWorktreeStatus),
|
|
422
|
+
pendingPermissions: Object.fromEntries(this.#pendingPermissions),
|
|
423
|
+
pendingTranscriptions: Object.fromEntries(this.#pendingTranscriptions),
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// --- Internal helpers ---
|
|
428
|
+
|
|
429
|
+
#emitMessage(messageId: string, target: "instance" | "ephemeral") {
|
|
430
|
+
const msg = this.#messages.get(messageId)
|
|
431
|
+
if (!msg) return
|
|
432
|
+
const event: StateEvent = {
|
|
433
|
+
type: "message",
|
|
434
|
+
key: messageId,
|
|
435
|
+
value: msg,
|
|
436
|
+
headers: { operation: "upsert" },
|
|
437
|
+
}
|
|
438
|
+
if (target === "instance") {
|
|
439
|
+
this.#appendInstanceEvent(event)
|
|
440
|
+
} else {
|
|
441
|
+
this.#appendEphemeralEvent(event)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Emit session metadata to the instance stream (no status — that's ephemeral). */
|
|
446
|
+
#emitSession(sessionId: string, sessionData: any, operation: "insert" | "update") {
|
|
447
|
+
this.#lastEmittedSessions.set(sessionId, sessionData)
|
|
448
|
+
this.#appendInstanceEvent({
|
|
449
|
+
type: "session",
|
|
450
|
+
key: sessionId,
|
|
451
|
+
value: sessionData,
|
|
452
|
+
headers: { operation },
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Emit session status to the ephemeral stream. */
|
|
457
|
+
#setSessionStatus(sessionId: string, status: SessionStatus, error?: string) {
|
|
458
|
+
this.#sessionStatuses.set(sessionId, { status, ...(error ? { error } : {}) })
|
|
459
|
+
this.#appendEphemeralEvent({
|
|
460
|
+
type: "sessionStatus",
|
|
461
|
+
key: sessionId,
|
|
462
|
+
value: { sessionId, status, ...(error ? { error } : {}) },
|
|
463
|
+
headers: { operation: "upsert" },
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async #fullMessageSync(sessionId: string) {
|
|
468
|
+
try {
|
|
469
|
+
const directory = this.#sessionDirectories.get(sessionId)
|
|
470
|
+
const res = await this.#client.session.messages({ sessionID: sessionId, ...(directory ? { directory } : {}) })
|
|
471
|
+
if (res.error) return
|
|
472
|
+
for (const raw of res.data ?? []) {
|
|
473
|
+
const msg = mapMessage(raw)
|
|
474
|
+
this.#messages.set(msg.id, msg)
|
|
475
|
+
// Reconciliation writes finalized messages to the instance stream
|
|
476
|
+
this.#appendInstanceEvent({
|
|
477
|
+
type: "message",
|
|
478
|
+
key: msg.id,
|
|
479
|
+
value: msg,
|
|
480
|
+
headers: { operation: "upsert" },
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
} catch {}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
#mapChanges(diff: any[]): ChangedFile[] {
|
|
487
|
+
return diff.map((d: any) => ({
|
|
488
|
+
path: d.file as string,
|
|
489
|
+
status: (d.status === "deleted" ? "deleted"
|
|
490
|
+
: d.status === "added" ? "added"
|
|
491
|
+
: "modified") as ChangedFile["status"],
|
|
492
|
+
added: d.additions as number,
|
|
493
|
+
removed: d.deletions as number,
|
|
494
|
+
}))
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async #refetchChanges(sessionId: string) {
|
|
498
|
+
try {
|
|
499
|
+
const directory = this.#sessionDirectories.get(sessionId)
|
|
500
|
+
const res = await this.#client.session.diff({ sessionID: sessionId, ...(directory ? { directory } : {}) })
|
|
501
|
+
if (res.error) return
|
|
502
|
+
// Finalized changes go to the ephemeral stream — only the latest matters
|
|
503
|
+
this.#appendEphemeralEvent({
|
|
504
|
+
type: "change",
|
|
505
|
+
key: sessionId,
|
|
506
|
+
value: { sessionId, files: this.#mapChanges(res.data ?? []) },
|
|
507
|
+
headers: { operation: "upsert" },
|
|
508
|
+
})
|
|
509
|
+
} catch {}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Debounce timers for worktree status refresh per session
|
|
513
|
+
#worktreeStatusTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
|
514
|
+
|
|
515
|
+
// Last emitted worktree status per session, used for partial updates
|
|
516
|
+
#lastWorktreeStatus: Map<string, any> = new Map()
|
|
517
|
+
|
|
518
|
+
/** Debounced worktree status refresh — coalesces rapid tool completions. */
|
|
519
|
+
#debouncedWorktreeStatusRefresh(sessionId: string, fullRefresh: boolean) {
|
|
520
|
+
const existing = this.#worktreeStatusTimers.get(sessionId)
|
|
521
|
+
if (existing) clearTimeout(existing)
|
|
522
|
+
this.#worktreeStatusTimers.set(
|
|
523
|
+
sessionId,
|
|
524
|
+
setTimeout(() => {
|
|
525
|
+
this.#worktreeStatusTimers.delete(sessionId)
|
|
526
|
+
if (fullRefresh) {
|
|
527
|
+
this.#emitWorktreeStatus(sessionId)
|
|
528
|
+
} else {
|
|
529
|
+
this.#emitUncommittedStatus(sessionId)
|
|
530
|
+
}
|
|
531
|
+
}, 2000), // 2s debounce
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Compute and emit full worktree status for a session: merge state,
|
|
537
|
+
* unmerged commits, and uncommitted changes.
|
|
538
|
+
*
|
|
539
|
+
* Called during initialization, on sessionIdle, and after merge API operations.
|
|
540
|
+
*/
|
|
541
|
+
async #emitWorktreeStatus(sessionId: string) {
|
|
542
|
+
const worktreeInfo = this.#sessionWorktrees.get(sessionId)
|
|
543
|
+
if (!worktreeInfo) {
|
|
544
|
+
const value = { sessionId, isWorktreeSession: false }
|
|
545
|
+
this.#lastWorktreeStatus.set(sessionId, value)
|
|
546
|
+
this.#appendEphemeralEvent({
|
|
547
|
+
type: "worktreeStatus",
|
|
548
|
+
key: sessionId,
|
|
549
|
+
value,
|
|
550
|
+
headers: { operation: "upsert" },
|
|
551
|
+
})
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
const driver = await WorktreeDriver.open(worktreeInfo.projectWorktree)
|
|
557
|
+
const branch = await driver.branchForPath(worktreeInfo.worktreePath)
|
|
558
|
+
if (!branch) {
|
|
559
|
+
const value = { sessionId, isWorktreeSession: true, error: "Could not resolve branch for worktree" }
|
|
560
|
+
this.#lastWorktreeStatus.set(sessionId, value)
|
|
561
|
+
this.#appendEphemeralEvent({
|
|
562
|
+
type: "worktreeStatus",
|
|
563
|
+
key: sessionId,
|
|
564
|
+
value,
|
|
565
|
+
headers: { operation: "upsert" },
|
|
566
|
+
})
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const [rawMerged, hasUnmerged, hasUncommitted] = await Promise.all([
|
|
571
|
+
driver.isMerged(branch, "main"),
|
|
572
|
+
driver.hasUnmergedCommits(branch, "main"),
|
|
573
|
+
driver.hasUncommittedChanges(worktreeInfo.worktreePath),
|
|
574
|
+
])
|
|
575
|
+
|
|
576
|
+
// A branch that git considers "merged" but has no unmerged commits never
|
|
577
|
+
// actually diverged from main — it's just sitting at the same commit.
|
|
578
|
+
// Don't report that as "merged" since no merge actually happened.
|
|
579
|
+
const merged = rawMerged && !hasUnmerged ? false : rawMerged
|
|
580
|
+
|
|
581
|
+
const value = {
|
|
582
|
+
sessionId,
|
|
583
|
+
isWorktreeSession: true,
|
|
584
|
+
branch,
|
|
585
|
+
merged,
|
|
586
|
+
hasUnmergedCommits: hasUnmerged,
|
|
587
|
+
hasUncommittedChanges: hasUncommitted,
|
|
588
|
+
}
|
|
589
|
+
this.#lastWorktreeStatus.set(sessionId, value)
|
|
590
|
+
this.#appendEphemeralEvent({
|
|
591
|
+
type: "worktreeStatus",
|
|
592
|
+
key: sessionId,
|
|
593
|
+
value,
|
|
594
|
+
headers: { operation: "upsert" },
|
|
595
|
+
})
|
|
596
|
+
} catch (err: any) {
|
|
597
|
+
const value = { sessionId, isWorktreeSession: true, error: err.message ?? "Failed to check worktree status" }
|
|
598
|
+
this.#lastWorktreeStatus.set(sessionId, value)
|
|
599
|
+
this.#appendEphemeralEvent({
|
|
600
|
+
type: "worktreeStatus",
|
|
601
|
+
key: sessionId,
|
|
602
|
+
value,
|
|
603
|
+
headers: { operation: "upsert" },
|
|
604
|
+
})
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Lightweight refresh: only update the `hasUncommittedChanges` field.
|
|
610
|
+
*
|
|
611
|
+
* Used after edit tool completions where only the working tree changed,
|
|
612
|
+
* not the commit history. Merges the result into the last-known full status.
|
|
613
|
+
*/
|
|
614
|
+
async #emitUncommittedStatus(sessionId: string) {
|
|
615
|
+
const worktreeInfo = this.#sessionWorktrees.get(sessionId)
|
|
616
|
+
if (!worktreeInfo) return
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
const driver = await WorktreeDriver.open(worktreeInfo.projectWorktree)
|
|
620
|
+
const hasUncommitted = await driver.hasUncommittedChanges(worktreeInfo.worktreePath)
|
|
621
|
+
|
|
622
|
+
const last = this.#lastWorktreeStatus.get(sessionId) ?? { sessionId, isWorktreeSession: true }
|
|
623
|
+
const value = { ...last, hasUncommittedChanges: hasUncommitted }
|
|
624
|
+
this.#lastWorktreeStatus.set(sessionId, value)
|
|
625
|
+
this.#appendEphemeralEvent({
|
|
626
|
+
type: "worktreeStatus",
|
|
627
|
+
key: sessionId,
|
|
628
|
+
value,
|
|
629
|
+
headers: { operation: "upsert" },
|
|
630
|
+
})
|
|
631
|
+
} catch (err: any) {
|
|
632
|
+
console.error(`[StateStream] Failed to refresh uncommitted status for ${sessionId}:`, err)
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Public method for API endpoints to trigger a full worktree status refresh
|
|
638
|
+
* after merge operations.
|
|
639
|
+
*/
|
|
640
|
+
refreshWorktreeStatus(sessionId: string) {
|
|
641
|
+
this.#emitWorktreeStatus(sessionId).catch((err) => {
|
|
642
|
+
console.error(`[StateStream] Failed to refresh worktree status for ${sessionId}:`, err)
|
|
643
|
+
})
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
#appendInstanceEvent(event: StateEvent) {
|
|
647
|
+
this.#instanceDs.appendToStream("/", JSON.stringify(event), {
|
|
648
|
+
contentType: "application/json",
|
|
649
|
+
})
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
#appendEphemeralEvent(event: StateEvent) {
|
|
653
|
+
this.#ephemeralDs.appendToStream("/", JSON.stringify(event), {
|
|
654
|
+
contentType: "application/json",
|
|
655
|
+
})
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function mapProject(raw: any) {
|
|
660
|
+
return {
|
|
661
|
+
id: raw.id,
|
|
662
|
+
worktree: raw.worktree,
|
|
663
|
+
vcsDir: raw.vcsDir,
|
|
664
|
+
vcs: raw.vcs,
|
|
665
|
+
time: {
|
|
666
|
+
created: raw.time?.created ?? 0,
|
|
667
|
+
initialized: raw.time?.initialized,
|
|
668
|
+
},
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function mapSession(raw: any) {
|
|
673
|
+
return {
|
|
674
|
+
id: raw.id,
|
|
675
|
+
title: raw.title,
|
|
676
|
+
directory: raw.directory,
|
|
677
|
+
projectID: raw.projectID,
|
|
678
|
+
parentID: raw.parentID,
|
|
679
|
+
version: raw.version,
|
|
680
|
+
summary: raw.summary,
|
|
681
|
+
share: raw.share,
|
|
682
|
+
time: {
|
|
683
|
+
created: raw.time?.created ?? 0,
|
|
684
|
+
updated: raw.time?.updated ?? 0,
|
|
685
|
+
},
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Tool names that modify files and may result in commits. */
|
|
690
|
+
const FILE_EDIT_TOOLS = new Set(["edit", "write", "bash", "multi_edit"])
|
|
691
|
+
|
|
692
|
+
function isFileEditTool(toolName: string): boolean {
|
|
693
|
+
return FILE_EDIT_TOOLS.has(toolName)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Build a human-readable description from a permission name and glob patterns. */
|
|
697
|
+
function buildPermissionDescription(permission: string, patterns: string[]): string {
|
|
698
|
+
const patternSuffix = patterns.length > 0 ? `: ${patterns.join(", ")}` : ""
|
|
699
|
+
switch (permission) {
|
|
700
|
+
case "bash":
|
|
701
|
+
return `Run bash command${patternSuffix}`
|
|
702
|
+
case "edit":
|
|
703
|
+
return `Edit files${patternSuffix}`
|
|
704
|
+
case "write":
|
|
705
|
+
return `Write files${patternSuffix}`
|
|
706
|
+
case "read":
|
|
707
|
+
return `Read files${patternSuffix}`
|
|
708
|
+
default:
|
|
709
|
+
return `${permission}${patternSuffix}`
|
|
710
|
+
}
|
|
711
|
+
}
|