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