opencode-island 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/LICENSE +21 -0
- package/README.md +185 -2
- package/dist/gui-experimental.cjs +21 -0
- package/dist/island/index.html +24 -0
- package/package.json +68 -4
- package/src/env.d.ts +1 -0
- package/src/gui.tsx +81 -0
- package/src/index.ts +11 -0
- package/src/island/app/components/island.tsx +487 -0
- package/src/island/app/components/ui/button.tsx +63 -0
- package/src/island/app/components/ui/toggle-group.tsx +83 -0
- package/src/island/app/components/ui/toggle.tsx +44 -0
- package/src/island/app/global.d.ts +22 -0
- package/src/island/app/index.html +15 -0
- package/src/island/app/island.css +101 -0
- package/src/island/app/lib/utils.ts +6 -0
- package/src/island/app/main.tsx +10 -0
- package/src/island/app/tsconfig.json +20 -0
- package/src/island/preload.cjs +20 -0
- package/src/model.ts +416 -0
- package/src/state.ts +59 -0
- package/src/window.ts +262 -0
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
|
+
}
|