@tiuxo/opencode-openrgb 0.0.0-stage → 0.1.1
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/CHANGELOG.md +57 -0
- package/LICENSE +11 -0
- package/README.md +287 -2
- package/index.ts +9 -0
- package/package.json +54 -4
- package/src/client.ts +464 -0
- package/src/controller.ts +360 -0
- package/src/index.ts +109 -0
- package/src/options.ts +229 -0
- package/src/palette.ts +86 -0
- package/src/protocol.ts +611 -0
- package/src/state.ts +215 -0
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
|
+
}
|