@tiuxo/opencode-openrgb 0.0.0-stage → 0.1.0

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/src/state.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Maps opencode's event stream onto a single lighting state.
3
+ *
4
+ * State is aggregated across *all* sessions rather than tracked per session.
5
+ * Subagent/task invocations create their own sessions that emit their own
6
+ * status events, so aggregation is what makes nested work behave sensibly: the
7
+ * lights stay busy until everything is genuinely finished.
8
+ *
9
+ * Events are typed structurally rather than against the SDK's event union.
10
+ * We read a handful of fields from a large and evolving union, so structural
11
+ * access with defensive lookups is more robust than tracking upstream changes.
12
+ */
13
+
14
+ export type LightingState = "idle" | "busy" | "retry" | "permission" | "error"
15
+
16
+ /**
17
+ * The subset of an opencode V2 event we care about.
18
+ *
19
+ * V2 carries the payload on `data` (V1 used `properties`) and renamed several
20
+ * of the events below. See `SUBSCRIBED_EVENTS` for the full list we ask for.
21
+ */
22
+ export interface OpencodeEvent {
23
+ type: string
24
+ data?: unknown
25
+ }
26
+
27
+ /**
28
+ * Exactly the events `handle` acts on, for `ctx.event.subscribe({ types })`.
29
+ *
30
+ * Subscribing narrowly keeps the plugin off the firehose: session.step.*
31
+ * and the text/reasoning delta events fire constantly during a response and
32
+ * none of them change the lighting.
33
+ */
34
+ export const SUBSCRIBED_EVENTS = [
35
+ "session.status",
36
+ "session.idle",
37
+ "session.execution.failed",
38
+ "session.retry.scheduled",
39
+ "permission.asked",
40
+ "permission.replied",
41
+ "session.deleted",
42
+ ] as const
43
+
44
+ type SessionActivity = "idle" | "busy" | "retry"
45
+
46
+ /** Higher wins when several conditions hold at once. */
47
+ const PRIORITY: Record<LightingState, number> = {
48
+ error: 4,
49
+ permission: 3,
50
+ retry: 2,
51
+ busy: 1,
52
+ idle: 0,
53
+ }
54
+
55
+ export interface StateTrackerOptions {
56
+ /**
57
+ * How long an error keeps the lights red before decaying back. Errors have
58
+ * no natural "cleared" event, so they expire on a timer.
59
+ */
60
+ errorHoldMs?: number
61
+ /** Called when an error expires, since that changes state with no event. */
62
+ onExpire?: () => void
63
+ }
64
+
65
+ export class StateTracker {
66
+ private readonly sessions = new Map<string, SessionActivity>()
67
+ /** permissionID -> sessionID */
68
+ private readonly permissions = new Map<string, string>()
69
+ private readonly errors = new Map<string, NodeJS.Timeout>()
70
+
71
+ private readonly errorHoldMs: number
72
+ private readonly onExpire: (() => void) | undefined
73
+
74
+ constructor(options: StateTrackerOptions = {}) {
75
+ this.errorHoldMs = options.errorHoldMs ?? 10_000
76
+ this.onExpire = options.onExpire
77
+ }
78
+
79
+ /** Feed an event in. Returns true if it was relevant to the lighting state. */
80
+ handle(event: OpencodeEvent): boolean {
81
+ const data = event.data
82
+
83
+ switch (event.type) {
84
+ case "session.status": {
85
+ const sessionID = readString(data, "sessionID")
86
+ const status = readString(readProp(data, "status"), "type")
87
+ if (!sessionID) return false
88
+ if (status === "busy" || status === "retry" || status === "idle") {
89
+ this.sessions.set(sessionID, status)
90
+ if (status === "busy") this.clearError(sessionID)
91
+ return true
92
+ }
93
+ return false
94
+ }
95
+
96
+ case "session.idle": {
97
+ const sessionID = readString(data, "sessionID")
98
+ if (!sessionID) return false
99
+ this.sessions.set(sessionID, "idle")
100
+ this.clearError(sessionID)
101
+ return true
102
+ }
103
+
104
+ // V1 called this "session.error".
105
+ case "session.execution.failed": {
106
+ const sessionID = readString(data, "sessionID") ?? "__global__"
107
+ this.raiseError(sessionID)
108
+ return true
109
+ }
110
+
111
+ /*
112
+ * V2 announces a scheduled retry in its own event rather than only as a
113
+ * session.status transition. Marking the session here means the retry
114
+ * color shows even if the status event is coalesced away.
115
+ */
116
+ case "session.retry.scheduled": {
117
+ const sessionID = readString(data, "sessionID")
118
+ if (!sessionID) return false
119
+ this.sessions.set(sessionID, "retry")
120
+ return true
121
+ }
122
+
123
+ // V1 called this "permission.updated".
124
+ case "permission.asked": {
125
+ const permissionID = readString(data, "id")
126
+ const sessionID = readString(data, "sessionID") ?? ""
127
+ if (!permissionID) return false
128
+ this.permissions.set(permissionID, sessionID)
129
+ return true
130
+ }
131
+
132
+ // V2 identifies the answered request as `requestID`, not `permissionID`.
133
+ case "permission.replied": {
134
+ const permissionID = readString(data, "requestID")
135
+ if (!permissionID) return false
136
+ return this.permissions.delete(permissionID)
137
+ }
138
+
139
+ // V2 flattens the session reference; V1 nested it under `info.id`.
140
+ case "session.deleted": {
141
+ const sessionID = readString(data, "sessionID")
142
+ if (!sessionID) return false
143
+ this.forget(sessionID)
144
+ return true
145
+ }
146
+
147
+ default:
148
+ return false
149
+ }
150
+ }
151
+
152
+ resolve(): LightingState {
153
+ let winner: LightingState = "idle"
154
+
155
+ const consider = (candidate: LightingState) => {
156
+ if (PRIORITY[candidate] > PRIORITY[winner]) winner = candidate
157
+ }
158
+
159
+ if (this.errors.size > 0) consider("error")
160
+ if (this.permissions.size > 0) consider("permission")
161
+ for (const activity of this.sessions.values()) {
162
+ if (activity === "retry") consider("retry")
163
+ else if (activity === "busy") consider("busy")
164
+ }
165
+
166
+ return winner
167
+ }
168
+
169
+ private raiseError(sessionID: string): void {
170
+ this.clearError(sessionID)
171
+ const timer = setTimeout(() => {
172
+ this.errors.delete(sessionID)
173
+ this.onExpire?.()
174
+ }, this.errorHoldMs)
175
+ timer.unref?.()
176
+ this.errors.set(sessionID, timer)
177
+ }
178
+
179
+ private clearError(sessionID: string): void {
180
+ const timer = this.errors.get(sessionID)
181
+ if (timer) {
182
+ clearTimeout(timer)
183
+ this.errors.delete(sessionID)
184
+ }
185
+ }
186
+
187
+ private forget(sessionID: string): void {
188
+ this.sessions.delete(sessionID)
189
+ this.clearError(sessionID)
190
+ for (const [permissionID, owner] of this.permissions) {
191
+ if (owner === sessionID) this.permissions.delete(permissionID)
192
+ }
193
+ }
194
+
195
+ dispose(): void {
196
+ for (const timer of this.errors.values()) clearTimeout(timer)
197
+ this.errors.clear()
198
+ this.sessions.clear()
199
+ this.permissions.clear()
200
+ }
201
+ }
202
+
203
+ /*---------------------------------------------------------------------------*\
204
+ | Defensive property access |
205
+ \*---------------------------------------------------------------------------*/
206
+
207
+ function readProp(source: unknown, key: string): unknown {
208
+ if (typeof source !== "object" || source === null) return undefined
209
+ return (source as Record<string, unknown>)[key]
210
+ }
211
+
212
+ function readString(source: unknown, key: string): string | undefined {
213
+ const value = readProp(source, key)
214
+ return typeof value === "string" && value.length > 0 ? value : undefined
215
+ }