opencode-island 0.0.0-stage → 0.2.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/model.ts ADDED
@@ -0,0 +1,416 @@
1
+ import type { OpenCode } from "@opencode/client"
2
+ import type {
3
+ IslandState,
4
+ PermissionDecision,
5
+ PermissionView,
6
+ SessionPhase,
7
+ SessionStatus,
8
+ SessionView,
9
+ } from "./state"
10
+
11
+ export type Client = ReturnType<typeof OpenCode.make>
12
+
13
+ /**
14
+ * The events that change what the island shows. Everything else on the stream
15
+ * is text, reasoning and other noise a status display does not need.
16
+ */
17
+ const WATCHED = [
18
+ "session.created",
19
+ "session.deleted",
20
+ "session.renamed",
21
+ "session.status",
22
+ "session.idle",
23
+ "session.execution.started",
24
+ "session.execution.succeeded",
25
+ "session.execution.failed",
26
+ "session.tool.input.started",
27
+ "session.tool.success",
28
+ "session.tool.failed",
29
+ "session.agent.selected",
30
+ "session.usage.updated",
31
+ "session.text.started",
32
+ "session.text.delta",
33
+ "session.text.ended",
34
+ "session.reasoning.started",
35
+ "session.reasoning.ended",
36
+ "permission.asked",
37
+ "permission.replied",
38
+ ] as const
39
+
40
+ /**
41
+ * The fields of the watched events the island reads.
42
+ *
43
+ * The client types an event's `data` as the union of every event payload, which
44
+ * cannot be narrowed by `type`. This local union can, and it is the contract we
45
+ * actually depend on.
46
+ */
47
+ type Watched =
48
+ | {
49
+ type: "session.created"
50
+ created: number
51
+ data: {
52
+ sessionID: string
53
+ title?: string
54
+ agent?: string
55
+ parentID?: string
56
+ location?: { directory?: string }
57
+ }
58
+ }
59
+ | { type: "session.deleted"; data: { sessionID: string } }
60
+ | { type: "session.renamed"; data: { sessionID: string; title: string } }
61
+ | {
62
+ type: "session.status"
63
+ data: { sessionID: string; status: { type: SessionStatus } }
64
+ }
65
+ | {
66
+ type:
67
+ | "session.idle"
68
+ | "session.execution.started"
69
+ | "session.execution.succeeded"
70
+ | "session.execution.failed"
71
+ data: { sessionID: string }
72
+ }
73
+ | {
74
+ type: "session.tool.input.started"
75
+ data: { sessionID: string; id: string; name: string }
76
+ }
77
+ | {
78
+ type: "session.tool.success" | "session.tool.failed"
79
+ data: { sessionID: string; id: string }
80
+ }
81
+ | { type: "session.agent.selected"; data: { sessionID: string; agent: string } }
82
+ | {
83
+ type: "session.text.started"
84
+ data: { sessionID: string; assistantMessageID: string; ordinal: number }
85
+ }
86
+ | {
87
+ type: "session.text.delta"
88
+ data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
89
+ }
90
+ | {
91
+ type: "session.text.ended" | "session.reasoning.started" | "session.reasoning.ended"
92
+ data: { sessionID: string; assistantMessageID: string; ordinal: number }
93
+ }
94
+ | {
95
+ type: "session.usage.updated"
96
+ data: { sessionID: string; cost: number; tokens: { input: number; output: number } }
97
+ }
98
+ | {
99
+ type: "permission.asked"
100
+ data: {
101
+ id: string
102
+ sessionID: string
103
+ action: string
104
+ resources: string[]
105
+ message?: string
106
+ }
107
+ }
108
+ | { type: "permission.replied"; data: { sessionID: string; requestID: string } }
109
+
110
+ /** A session untouched for this long stops being worth a row. */
111
+ const EVICT_AFTER_MS = 6 * 60 * 60 * 1000
112
+
113
+ /** How much of an answer is kept around, enough for the island to show. */
114
+ const STREAM_WINDOW = 200
115
+
116
+ /** How many words of a streamed answer the island shows. */
117
+ const STREAM_WORDS = 10
118
+
119
+ /** Keeps the last of a text whole: a cut inside a word would show half of it. */
120
+ export function keepWords(text: string) {
121
+ if (text.length <= STREAM_WINDOW) return text
122
+ const cut = text.slice(-STREAM_WINDOW)
123
+ const space = cut.indexOf(" ")
124
+ return space === -1 ? cut : cut.slice(space + 1)
125
+ }
126
+
127
+ /** The last words of a text, which is what a streaming island shows. */
128
+ export function lastWords(text: string, count: number) {
129
+ const parts = text.trim().split(/\s+/)
130
+ if (!parts[0]) return ""
131
+ return parts.slice(-count).join(" ")
132
+ }
133
+
134
+ type Entry = SessionView & {
135
+ /** Tool call id -> tool name, so a completion clears the one that finished. */
136
+ tools: Map<string, string>
137
+ /** The tail of the answer being streamed, kept to make the last words. */
138
+ buffer: string
139
+ /** The agent is reasoning, before it starts writing. */
140
+ reasoning: boolean
141
+ /** The agent is writing its answer. */
142
+ writing: boolean
143
+ }
144
+
145
+ export interface Reply {
146
+ sessionID: string
147
+ requestID: string
148
+ decision: PermissionDecision
149
+ }
150
+
151
+ /**
152
+ * The model of every session on this machine, fed by the server's event
153
+ * stream. It is seeded from the session list so the island is correct on first
154
+ * paint, not only for work started after it appeared.
155
+ */
156
+ export function createModel(client: Client, onChange: (state: IslandState) => void) {
157
+ const sessions = new Map<string, Entry>()
158
+
159
+ const ensure = (id: string): Entry => {
160
+ const found = sessions.get(id)
161
+ if (found) return found
162
+ const created: Entry = {
163
+ id,
164
+ title: "",
165
+ agent: "",
166
+ directory: "",
167
+ status: "busy",
168
+ cost: 0,
169
+ tokens: 0,
170
+ created: Date.now(),
171
+ updated: Date.now(),
172
+ tools: new Map(),
173
+ buffer: "",
174
+ reasoning: false,
175
+ writing: false,
176
+ }
177
+ sessions.set(id, created)
178
+ return created
179
+ }
180
+
181
+ const touch = (id: string, patch: Partial<SessionView> = {}) => {
182
+ const entry = sessions.get(id)
183
+ if (!entry) return
184
+ Object.assign(entry, patch, { updated: Date.now() })
185
+ }
186
+
187
+ const view = (entry: Entry): SessionView => {
188
+ const { tools: _tools, buffer: _buffer, reasoning: _r, writing: _w, ...rest } = entry
189
+ return { ...rest, phase: phaseOf(entry) }
190
+ }
191
+
192
+ /** What the session is doing right now, in the agent's own words. */
193
+ function phaseOf(entry: Entry): SessionPhase {
194
+ if (entry.permission) return "waiting"
195
+ if (entry.status === "retry") return "retrying"
196
+ if (entry.tools.size > 0) return "working"
197
+ if (entry.reasoning) return "thinking"
198
+ if (entry.writing) return "writing"
199
+ return "idle"
200
+ }
201
+
202
+ const state = (): IslandState => {
203
+ const now = Date.now()
204
+ const visible: SessionView[] = []
205
+ for (const entry of sessions.values()) {
206
+ if (now - entry.updated > EVICT_AFTER_MS) continue
207
+ visible.push(view(entry))
208
+ }
209
+ // Waiting first, then working, then idle; newest first within a rank.
210
+ const rank = (s: SessionView) =>
211
+ s.permission ? 0 : s.status === "busy" ? 1 : s.status === "retry" ? 2 : 3
212
+ visible.sort((a, b) => rank(a) - rank(b) || b.updated - a.updated)
213
+ return {
214
+ sessions: visible,
215
+ working: visible.filter((s) => s.status === "busy" || s.status === "retry").length,
216
+ waiting: visible.filter((s) => s.permission).length,
217
+ }
218
+ }
219
+
220
+ const publish = () => onChange(state())
221
+
222
+ const toPermission = (data: {
223
+ id: string
224
+ sessionID: string
225
+ action: string
226
+ resources?: string[]
227
+ message?: string
228
+ }): PermissionView => ({
229
+ id: data.id,
230
+ sessionID: data.sessionID,
231
+ action: data.action,
232
+ resources: data.resources ?? [],
233
+ message: data.message,
234
+ })
235
+
236
+ const seed = async (signal: AbortSignal) => {
237
+ try {
238
+ const [listed, pending] = await Promise.all([
239
+ client.session.list({ limit: 200 }, { signal }),
240
+ client.permission.request.list(undefined, { signal }),
241
+ ])
242
+ for (const info of listed.data) {
243
+ // A live event may have beaten the list here; keep the fresher record.
244
+ if (sessions.has(info.id)) continue
245
+ sessions.set(info.id, {
246
+ id: info.id,
247
+ title: info.title ?? "",
248
+ agent: info.agent ?? "",
249
+ parentID: info.parentID,
250
+ directory: info.location?.directory ?? "",
251
+ status: "idle",
252
+ cost: info.cost,
253
+ tokens: info.tokens.input + info.tokens.output,
254
+ created: info.time.created,
255
+ updated: info.time.updated,
256
+ tools: new Map(),
257
+ buffer: "",
258
+ reasoning: false,
259
+ writing: false,
260
+ })
261
+ }
262
+ for (const request of pending.data) {
263
+ ensure(request.sessionID).permission = toPermission(request)
264
+ }
265
+ publish()
266
+ } catch {
267
+ // The stream still keeps the island correct; a failed seed only loses history.
268
+ }
269
+ }
270
+
271
+ const listen = async (signal: AbortSignal) => {
272
+ try {
273
+ const stream = client.event.subscribe({
274
+ types: [...WATCHED],
275
+ signal,
276
+ }) as AsyncIterable<Watched>
277
+ for await (const event of stream) {
278
+ switch (event.type) {
279
+ case "session.created": {
280
+ const data = event.data
281
+ const entry = ensure(data.sessionID)
282
+ entry.title = data.title ?? entry.title
283
+ entry.agent = data.agent ?? entry.agent
284
+ entry.parentID = data.parentID ?? entry.parentID
285
+ entry.directory = data.location?.directory ?? entry.directory
286
+ entry.created = event.created
287
+ entry.updated = Date.now()
288
+ break
289
+ }
290
+ case "session.deleted": {
291
+ sessions.delete(event.data.sessionID)
292
+ break
293
+ }
294
+ case "session.renamed": {
295
+ touch(event.data.sessionID, { title: event.data.title })
296
+ break
297
+ }
298
+ case "session.status": {
299
+ touch(event.data.sessionID, { status: event.data.status.type })
300
+ break
301
+ }
302
+ case "session.idle":
303
+ case "session.execution.succeeded":
304
+ case "session.execution.failed": {
305
+ const entry = sessions.get(event.data.sessionID)
306
+ if (entry) {
307
+ entry.reasoning = false
308
+ entry.writing = false
309
+ }
310
+ touch(event.data.sessionID, { status: "idle", tool: undefined })
311
+ break
312
+ }
313
+ case "session.execution.started": {
314
+ touch(event.data.sessionID, { status: "busy" })
315
+ break
316
+ }
317
+ case "session.tool.input.started": {
318
+ const data = event.data
319
+ ensure(data.sessionID).tools.set(data.id, data.name)
320
+ touch(data.sessionID, { status: "busy", tool: data.name })
321
+ break
322
+ }
323
+ case "session.tool.success":
324
+ case "session.tool.failed": {
325
+ const data = event.data
326
+ const entry = sessions.get(data.sessionID)
327
+ if (!entry) break
328
+ entry.tools.delete(data.id)
329
+ const next = entry.tools.values().next()
330
+ entry.tool = next.done ? undefined : next.value
331
+ break
332
+ }
333
+ case "session.agent.selected": {
334
+ touch(event.data.sessionID, { agent: event.data.agent })
335
+ break
336
+ }
337
+ case "session.text.started": {
338
+ // A new answer starts, so the old words go.
339
+ const entry = ensure(event.data.sessionID)
340
+ entry.buffer = ""
341
+ entry.writing = true
342
+ entry.reasoning = false
343
+ break
344
+ }
345
+ case "session.text.ended": {
346
+ const entry = sessions.get(event.data.sessionID)
347
+ if (entry) entry.writing = false
348
+ break
349
+ }
350
+ case "session.reasoning.started": {
351
+ const entry = ensure(event.data.sessionID)
352
+ entry.reasoning = true
353
+ entry.writing = false
354
+ touch(event.data.sessionID, { status: "busy" })
355
+ break
356
+ }
357
+ case "session.reasoning.ended": {
358
+ const entry = sessions.get(event.data.sessionID)
359
+ if (entry) entry.reasoning = false
360
+ break
361
+ }
362
+ case "session.text.delta": {
363
+ const data = event.data
364
+ const entry = ensure(data.sessionID)
365
+ entry.buffer = keepWords(entry.buffer + data.delta)
366
+ const text = lastWords(entry.buffer, STREAM_WORDS)
367
+ if (!text) break
368
+ touch(data.sessionID, { stream: text })
369
+ break
370
+ }
371
+ case "session.usage.updated": {
372
+ const data = event.data
373
+ touch(data.sessionID, {
374
+ cost: data.cost,
375
+ tokens: data.tokens.input + data.tokens.output,
376
+ })
377
+ break
378
+ }
379
+ case "permission.asked": {
380
+ const data = event.data
381
+ ensure(data.sessionID).permission = toPermission(data)
382
+ touch(data.sessionID)
383
+ break
384
+ }
385
+ case "permission.replied": {
386
+ const data = event.data
387
+ const entry = sessions.get(data.sessionID)
388
+ if (entry && entry.permission?.id === data.requestID) {
389
+ entry.permission = undefined
390
+ entry.updated = Date.now()
391
+ }
392
+ break
393
+ }
394
+ }
395
+ publish()
396
+ }
397
+ } catch {
398
+ // Aborted on shutdown, or the server restarted. The plugin reconnects.
399
+ }
400
+ }
401
+
402
+ return {
403
+ start(signal: AbortSignal) {
404
+ void seed(signal)
405
+ void listen(signal)
406
+ },
407
+ reply(reply: Reply) {
408
+ return client.permission.reply({
409
+ sessionID: reply.sessionID,
410
+ requestID: reply.requestID,
411
+ decision: reply.decision,
412
+ })
413
+ },
414
+ state,
415
+ }
416
+ }
package/src/state.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * What the island shows, as plain JSON.
3
+ *
4
+ * The island renders in its own Electron window with no access to the plugin
5
+ * SDK, so this is the only shape that crosses the process boundary.
6
+ */
7
+
8
+ export type SessionStatus = "idle" | "busy" | "retry"
9
+
10
+ /** What the session is doing right now, in a word. */
11
+ export type SessionPhase = "idle" | "thinking" | "writing" | "working" | "waiting" | "retrying"
12
+
13
+ export type PermissionDecision = "once" | "always" | "reject"
14
+
15
+ export interface PermissionView {
16
+ id: string
17
+ sessionID: string
18
+ action: string
19
+ resources: string[]
20
+ message?: string
21
+ }
22
+
23
+ export interface SessionView {
24
+ id: string
25
+ title: string
26
+ agent: string
27
+ /** Set when this session is a subagent spawned by another session. */
28
+ parentID?: string
29
+ /** Absolute path of the project the session belongs to. */
30
+ directory: string
31
+ status: SessionStatus
32
+ /** What the session is doing right now: thinking, working, and so on. */
33
+ phase?: SessionPhase
34
+ /** Name of the tool currently running, if any. */
35
+ tool?: string
36
+ /** The last words the assistant said, while it is still saying them. */
37
+ stream?: string
38
+ /** Set while the session waits on the user to approve something. */
39
+ permission?: PermissionView
40
+ cost: number
41
+ tokens: number
42
+ /** Epoch millis of the last event that touched this session. */
43
+ updated: number
44
+ /** Epoch millis when the session was created. */
45
+ created: number
46
+ }
47
+
48
+ export interface IslandState {
49
+ sessions: SessionView[]
50
+ /** Sessions currently working or retrying. */
51
+ working: number
52
+ /** Sessions waiting on a permission decision. */
53
+ waiting: number
54
+ }
55
+
56
+ export interface IslandMessage extends IslandState {
57
+ /** Whether the island shows its list, or only the contracted line. */
58
+ open: boolean
59
+ }