opencode-multiplexer 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/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "opencode-multiplexer",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Multiplexer for opencode AI coding agent sessions",
6
+ "keywords": ["opencode", "ai", "agent", "tui", "multiplexer"],
7
+ "bin": {
8
+ "ocmux": "./src/index.tsx"
9
+ },
10
+ "scripts": {
11
+ "dev": "bun src/index.tsx",
12
+ "build": "bun build src/index.tsx --outdir dist --target bun"
13
+ },
14
+ "dependencies": {
15
+ "@opencode-ai/sdk": "^1.2.27",
16
+ "ink": "^6.8.0",
17
+ "ink-text-input": "^6.0.0",
18
+ "marked": "^15.0.0",
19
+ "marked-terminal": "^7.3.0",
20
+ "react": "^19.2.4",
21
+ "zustand": "^5.0.12"
22
+ },
23
+ "devDependencies": {
24
+ "@types/bun": "^1.3.10",
25
+ "@types/react": "^19.2.14",
26
+ "typescript": "^5.9.3"
27
+ }
28
+ }
package/src/app.tsx ADDED
@@ -0,0 +1,18 @@
1
+ import React from "react"
2
+ import { useStore } from "./store.js"
3
+ import { Dashboard } from "./views/dashboard.js"
4
+ import { Conversation } from "./views/conversation.js"
5
+ import { Spawn } from "./views/spawn.js"
6
+
7
+ export function App() {
8
+ const view = useStore((s) => s.view)
9
+
10
+ switch (view) {
11
+ case "dashboard":
12
+ return <Dashboard />
13
+ case "conversation":
14
+ return <Conversation />
15
+ case "spawn":
16
+ return <Spawn />
17
+ }
18
+ }
package/src/config.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { readFileSync } from "fs"
2
+ import { homedir } from "os"
3
+ import { join } from "path"
4
+
5
+ export interface KeybindingsConfig {
6
+ dashboard: {
7
+ up: string
8
+ down: string
9
+ open: string
10
+ attach: string
11
+ spawn: string
12
+ expand: string
13
+ collapse: string
14
+ nextNeedsInput: string
15
+ kill: string
16
+ quit: string
17
+ help: string
18
+ rescan: string
19
+ }
20
+ conversation: {
21
+ back: string
22
+ attach: string
23
+ send: string
24
+ scrollUp: string
25
+ scrollDown: string
26
+ scrollHalfPageUp: string
27
+ scrollHalfPageDown: string
28
+ scrollPageUp: string
29
+ scrollPageDown: string
30
+ scrollBottom: string
31
+ scrollTop: string
32
+ }
33
+ spawn: {
34
+ cancel: string
35
+ confirm: string
36
+ }
37
+ }
38
+
39
+ export interface Config {
40
+ keybindings: KeybindingsConfig
41
+ pollIntervalMs: number
42
+ dbPath: string
43
+ }
44
+
45
+ const DEFAULTS: Config = {
46
+ keybindings: {
47
+ dashboard: {
48
+ up: "k",
49
+ down: "j",
50
+ open: "return",
51
+ attach: "a",
52
+ spawn: "n",
53
+ expand: "tab",
54
+ collapse: "shift-tab",
55
+ nextNeedsInput: "ctrl-n",
56
+ kill: "x",
57
+ quit: "q",
58
+ help: "?",
59
+ rescan: "r",
60
+ },
61
+ conversation: {
62
+ back: "escape",
63
+ attach: "a",
64
+ send: "return",
65
+ scrollUp: "k",
66
+ scrollDown: "j",
67
+ scrollHalfPageUp: "ctrl-u",
68
+ scrollHalfPageDown: "ctrl-d",
69
+ scrollPageUp: "ctrl-b",
70
+ scrollPageDown: "ctrl-f",
71
+ scrollBottom: "G",
72
+ scrollTop: "g",
73
+ },
74
+ spawn: {
75
+ cancel: "escape",
76
+ confirm: "return",
77
+ },
78
+ },
79
+ pollIntervalMs: 2000,
80
+ dbPath: join(homedir(), ".local", "share", "opencode", "opencode.db"),
81
+ }
82
+
83
+ function deepMerge<T extends object>(defaults: T, overrides: Partial<T>): T {
84
+ const result = { ...defaults }
85
+ for (const key of Object.keys(overrides) as Array<keyof T>) {
86
+ const val = overrides[key]
87
+ if (val !== undefined && val !== null) {
88
+ if (
89
+ typeof val === "object" &&
90
+ !Array.isArray(val) &&
91
+ typeof defaults[key] === "object"
92
+ ) {
93
+ result[key] = deepMerge(defaults[key] as object, val as object) as T[keyof T]
94
+ } else {
95
+ result[key] = val as T[keyof T]
96
+ }
97
+ }
98
+ }
99
+ return result
100
+ }
101
+
102
+ function loadConfig(): Config {
103
+ const configPath = join(
104
+ homedir(),
105
+ ".config",
106
+ "ocmux",
107
+ "config.json",
108
+ )
109
+ try {
110
+ const raw = readFileSync(configPath, "utf-8")
111
+ const parsed = JSON.parse(raw) as Partial<Config>
112
+ return deepMerge(DEFAULTS, parsed)
113
+ } catch {
114
+ return DEFAULTS
115
+ }
116
+ }
117
+
118
+ export const config = loadConfig()
@@ -0,0 +1,459 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { homedir } from "os"
3
+ import { join } from "path"
4
+ import { existsSync } from "fs"
5
+ import type { SessionStatus } from "../store.js"
6
+
7
+ const DB_PATH = join(homedir(), ".local", "share", "opencode", "opencode.db")
8
+
9
+ let _db: Database | null = null
10
+
11
+ function getDb(): Database {
12
+ if (_db) return _db
13
+ if (!existsSync(DB_PATH)) {
14
+ throw new Error(`opencode database not found at ${DB_PATH}`)
15
+ }
16
+ _db = new Database(DB_PATH, { readonly: true })
17
+ return _db
18
+ }
19
+
20
+ // ─── Project types ────────────────────────────────────────────────────────────
21
+
22
+ export interface DbProject {
23
+ id: string
24
+ worktree: string
25
+ name: string | null
26
+ timeCreated: number
27
+ timeUpdated: number
28
+ }
29
+
30
+ // ─── Session types ────────────────────────────────────────────────────────────
31
+
32
+ export interface DbSession {
33
+ id: string
34
+ projectId: string
35
+ title: string
36
+ directory: string
37
+ permission: string | null
38
+ timeCreated: number
39
+ timeUpdated: number
40
+ }
41
+
42
+ // ─── Message / Part types ─────────────────────────────────────────────────────
43
+
44
+ export interface DbMessagePart {
45
+ id: string
46
+ type: string
47
+ text?: string
48
+ tool?: string
49
+ toolStatus?: string
50
+ callId?: string
51
+ }
52
+
53
+ export interface DbMessage {
54
+ id: string
55
+ sessionId: string
56
+ role: "user" | "assistant"
57
+ timeCreated: number
58
+ timeCompleted: number | null
59
+ modelId: string | null
60
+ providerId: string | null
61
+ parts: DbMessagePart[]
62
+ }
63
+
64
+ // ─── Queries ──────────────────────────────────────────────────────────────────
65
+
66
+ export function getProjects(): DbProject[] {
67
+ const db = getDb()
68
+ const rows = db
69
+ .query<
70
+ { id: string; worktree: string; name: string | null; time_created: number; time_updated: number },
71
+ []
72
+ >(
73
+ `SELECT id, worktree, name, time_created, time_updated
74
+ FROM project
75
+ ORDER BY time_updated DESC`,
76
+ )
77
+ .all()
78
+
79
+ return rows.map((r) => ({
80
+ id: r.id,
81
+ worktree: r.worktree,
82
+ name: r.name,
83
+ timeCreated: r.time_created,
84
+ timeUpdated: r.time_updated,
85
+ }))
86
+ }
87
+
88
+ export function getSessionsForProject(projectId: string): DbSession[] {
89
+ const db = getDb()
90
+ const rows = db
91
+ .query<
92
+ {
93
+ id: string
94
+ project_id: string
95
+ title: string
96
+ directory: string
97
+ permission: string | null
98
+ time_created: number
99
+ time_updated: number
100
+ },
101
+ [string]
102
+ >(
103
+ `SELECT id, project_id, title, directory, permission, time_created, time_updated
104
+ FROM session
105
+ WHERE project_id = ?
106
+ AND time_archived IS NULL
107
+ ORDER BY time_updated DESC`,
108
+ )
109
+ .all(projectId)
110
+
111
+ return rows.map((r) => ({
112
+ id: r.id,
113
+ projectId: r.project_id,
114
+ title: r.title,
115
+ directory: r.directory,
116
+ permission: r.permission,
117
+ timeCreated: r.time_created,
118
+ timeUpdated: r.time_updated,
119
+ }))
120
+ }
121
+
122
+ export function countSessionsForProject(projectId: string): number {
123
+ const db = getDb()
124
+ const row = db
125
+ .query<{ cnt: number }, [string]>(
126
+ `SELECT COUNT(*) as cnt FROM session WHERE project_id = ? AND time_archived IS NULL`,
127
+ )
128
+ .get(projectId)
129
+ return row?.cnt ?? 0
130
+ }
131
+
132
+ export function getMostRecentSessionForProject(projectId: string, offset = 0): DbSession | null {
133
+ const db = getDb()
134
+ const row = db
135
+ .query<
136
+ {
137
+ id: string
138
+ project_id: string
139
+ title: string
140
+ directory: string
141
+ permission: string | null
142
+ time_created: number
143
+ time_updated: number
144
+ },
145
+ [string, number]
146
+ >(
147
+ `SELECT id, project_id, title, directory, permission, time_created, time_updated
148
+ FROM session
149
+ WHERE project_id = ? AND time_archived IS NULL AND parent_id IS NULL
150
+ ORDER BY time_updated DESC
151
+ LIMIT 1 OFFSET ?`,
152
+ )
153
+ .get(projectId, offset)
154
+ if (!row) return null
155
+ return {
156
+ id: row.id,
157
+ projectId: row.project_id,
158
+ title: row.title,
159
+ directory: row.directory,
160
+ permission: row.permission,
161
+ timeCreated: row.time_created,
162
+ timeUpdated: row.time_updated,
163
+ }
164
+ }
165
+
166
+ export function getSessionStatus(sessionId: string): SessionStatus {
167
+ const db = getDb()
168
+
169
+ // Check for a running question tool in the latest message → needs-input
170
+ // (agent is showing a question/multiselect prompt to the user)
171
+ const questionRow = db
172
+ .query<{ cnt: number }, [string, string]>(
173
+ `SELECT COUNT(*) as cnt
174
+ FROM part p
175
+ WHERE p.session_id = ?
176
+ AND json_extract(p.data, '$.type') = 'tool'
177
+ AND json_extract(p.data, '$.tool') = 'question'
178
+ AND json_extract(p.data, '$.state.status') = 'running'
179
+ AND p.message_id = (
180
+ SELECT id FROM message WHERE session_id = ? ORDER BY time_created DESC LIMIT 1
181
+ )`,
182
+ )
183
+ .get(sessionId, sessionId)
184
+ if ((questionRow?.cnt ?? 0) > 0) return "needs-input"
185
+
186
+ // Check for error tool parts in latest message → error
187
+ const errorRow = db
188
+ .query<{ cnt: number }, [string, string]>(
189
+ `SELECT COUNT(*) as cnt
190
+ FROM part p
191
+ WHERE p.session_id = ?
192
+ AND json_extract(p.data, '$.type') = 'tool'
193
+ AND json_extract(p.data, '$.state.status') = 'error'
194
+ AND p.message_id = (
195
+ SELECT id FROM message WHERE session_id = ? ORDER BY time_created DESC LIMIT 1
196
+ )`,
197
+ )
198
+ .get(sessionId, sessionId)
199
+ if ((errorRow?.cnt ?? 0) > 0) return "error"
200
+
201
+ // Check latest message
202
+ const lastMsg = db
203
+ .query<{ role: string; completed: number | null }, [string]>(
204
+ `SELECT json_extract(data, '$.role') as role,
205
+ json_extract(data, '$.time.completed') as completed
206
+ FROM message
207
+ WHERE session_id = ?
208
+ ORDER BY time_created DESC
209
+ LIMIT 1`,
210
+ )
211
+ .get(sessionId)
212
+
213
+ if (!lastMsg) return "idle"
214
+ // Agent is generating (incomplete assistant message)
215
+ if (lastMsg.role === "assistant" && lastMsg.completed === null) return "working"
216
+ // User sent a message, agent hasn't responded yet
217
+ if (lastMsg.role === "user") return "working"
218
+ // Assistant finished — session is idle (not "needs-input")
219
+ // "needs-input" only comes from an active question tool (checked above)
220
+ return "idle"
221
+ }
222
+
223
+ export function getLastMessagePreview(sessionId: string): { text: string; role: "user" | "assistant" } {
224
+ const db = getDb()
225
+
226
+ // Get the last meaningful text part — skip tool output XML (starts with '<')
227
+ // and internal markers. Prefer assistant prose or user messages.
228
+ const row = db
229
+ .query<
230
+ { text: string; role: string },
231
+ [string]
232
+ >(
233
+ `SELECT json_extract(p.data, '$.text') as text,
234
+ json_extract(m.data, '$.role') as role
235
+ FROM part p
236
+ JOIN message m ON p.message_id = m.id
237
+ WHERE p.session_id = ?
238
+ AND json_extract(p.data, '$.type') = 'text'
239
+ AND json_extract(p.data, '$.text') IS NOT NULL
240
+ AND json_extract(p.data, '$.text') != ''
241
+ AND json_extract(p.data, '$.text') NOT LIKE '<%'
242
+ ORDER BY m.time_created DESC, p.time_created DESC
243
+ LIMIT 1`,
244
+ )
245
+ .get(sessionId)
246
+
247
+ return {
248
+ text: row?.text ?? "",
249
+ role: (row?.role ?? "user") as "user" | "assistant",
250
+ }
251
+ }
252
+
253
+ export function getMessages(sessionId: string): DbMessage[] {
254
+ const db = getDb()
255
+
256
+ // Get all messages for the session
257
+ const messages = db
258
+ .query<
259
+ {
260
+ id: string
261
+ role: string
262
+ time_created: number
263
+ completed: number | null
264
+ model_id: string | null
265
+ provider_id: string | null
266
+ },
267
+ [string]
268
+ >(
269
+ `SELECT id,
270
+ json_extract(data, '$.role') as role,
271
+ time_created,
272
+ json_extract(data, '$.time.completed') as completed,
273
+ json_extract(data, '$.modelID') as model_id,
274
+ json_extract(data, '$.providerID') as provider_id
275
+ FROM message
276
+ WHERE session_id = ?
277
+ ORDER BY time_created ASC`,
278
+ )
279
+ .all(sessionId)
280
+
281
+ if (messages.length === 0) return []
282
+
283
+ // Get all parts for these messages in one query
284
+ const parts = db
285
+ .query<
286
+ {
287
+ id: string
288
+ message_id: string
289
+ part_type: string
290
+ text: string | null
291
+ tool: string | null
292
+ tool_status: string | null
293
+ call_id: string | null
294
+ },
295
+ [string]
296
+ >(
297
+ `SELECT p.id,
298
+ p.message_id,
299
+ json_extract(p.data, '$.type') as part_type,
300
+ json_extract(p.data, '$.text') as text,
301
+ json_extract(p.data, '$.tool') as tool,
302
+ json_extract(p.data, '$.state.status') as tool_status,
303
+ json_extract(p.data, '$.callID') as call_id
304
+ FROM part p
305
+ WHERE p.session_id = ?
306
+ ORDER BY p.time_created ASC`,
307
+ )
308
+ .all(sessionId)
309
+
310
+ // Group parts by message_id
311
+ const partsByMessage = new Map<string, DbMessagePart[]>()
312
+ for (const p of parts) {
313
+ const list = partsByMessage.get(p.message_id) ?? []
314
+ list.push({
315
+ id: p.id,
316
+ type: p.part_type,
317
+ text: p.text ?? undefined,
318
+ tool: p.tool ?? undefined,
319
+ toolStatus: p.tool_status ?? undefined,
320
+ callId: p.call_id ?? undefined,
321
+ })
322
+ partsByMessage.set(p.message_id, list)
323
+ }
324
+
325
+ return messages.map((m) => ({
326
+ id: m.id,
327
+ sessionId,
328
+ role: m.role as "user" | "assistant",
329
+ timeCreated: m.time_created,
330
+ timeCompleted: m.completed ?? null,
331
+ modelId: m.model_id,
332
+ providerId: m.provider_id,
333
+ parts: partsByMessage.get(m.id) ?? [],
334
+ }))
335
+ }
336
+
337
+ export function getSessionById(sessionId: string): DbSession | null {
338
+ const db = getDb()
339
+ const row = db
340
+ .query<
341
+ {
342
+ id: string
343
+ project_id: string
344
+ title: string
345
+ directory: string
346
+ permission: string | null
347
+ time_created: number
348
+ time_updated: number
349
+ },
350
+ [string]
351
+ >(
352
+ `SELECT id, project_id, title, directory, permission, time_created, time_updated
353
+ FROM session WHERE id = ?`,
354
+ )
355
+ .get(sessionId)
356
+
357
+ if (!row) return null
358
+ return {
359
+ id: row.id,
360
+ projectId: row.project_id,
361
+ title: row.title,
362
+ directory: row.directory,
363
+ permission: row.permission,
364
+ timeCreated: row.time_created,
365
+ timeUpdated: row.time_updated,
366
+ }
367
+ }
368
+
369
+ // ─── Child session queries (for subagent tree) ───────────────────────────────
370
+
371
+ export function getChildSessions(parentSessionId: string, limit = 10, offset = 0): DbSession[] {
372
+ const db = getDb()
373
+ const rows = db
374
+ .query<
375
+ {
376
+ id: string
377
+ project_id: string
378
+ title: string
379
+ directory: string
380
+ permission: string | null
381
+ time_created: number
382
+ time_updated: number
383
+ },
384
+ [string, number, number]
385
+ >(
386
+ `SELECT id, project_id, title, directory, permission, time_created, time_updated
387
+ FROM session
388
+ WHERE parent_id = ?
389
+ AND time_archived IS NULL
390
+ ORDER BY time_created DESC
391
+ LIMIT ? OFFSET ?`,
392
+ )
393
+ .all(parentSessionId, limit, offset)
394
+
395
+ return rows.map((r) => ({
396
+ id: r.id,
397
+ projectId: r.project_id,
398
+ title: r.title,
399
+ directory: r.directory,
400
+ permission: r.permission,
401
+ timeCreated: r.time_created,
402
+ timeUpdated: r.time_updated,
403
+ }))
404
+ }
405
+
406
+ export function countChildSessions(parentSessionId: string): number {
407
+ const db = getDb()
408
+ const row = db
409
+ .query<{ cnt: number }, [string]>(
410
+ `SELECT COUNT(*) as cnt FROM session WHERE parent_id = ? AND time_archived IS NULL`,
411
+ )
412
+ .get(parentSessionId)
413
+ return row?.cnt ?? 0
414
+ }
415
+
416
+ export function hasChildSessions(sessionId: string): boolean {
417
+ const db = getDb()
418
+ const row = db
419
+ .query<{ cnt: number }, [string]>(
420
+ `SELECT COUNT(*) as cnt FROM session WHERE parent_id = ? AND time_archived IS NULL LIMIT 1`,
421
+ )
422
+ .get(sessionId)
423
+ return (row?.cnt ?? 0) > 0
424
+ }
425
+
426
+ export function getSessionModel(sessionId: string): string | null {
427
+ const db = getDb()
428
+ const row = db
429
+ .query<{ model_id: string }, [string]>(
430
+ `SELECT json_extract(data, '$.modelID') as model_id
431
+ FROM message
432
+ WHERE session_id = ?
433
+ AND json_extract(data, '$.role') = 'assistant'
434
+ AND json_extract(data, '$.modelID') IS NOT NULL
435
+ ORDER BY time_created DESC
436
+ LIMIT 1`,
437
+ )
438
+ .get(sessionId)
439
+ return row?.model_id ?? null
440
+ }
441
+
442
+ export function getSessionAgent(sessionId: string): string | null {
443
+ const db = getDb()
444
+ const row = db
445
+ .query<{ agent: string }, [string]>(
446
+ `SELECT json_extract(data, '$.agent') as agent
447
+ FROM message
448
+ WHERE session_id = ?
449
+ AND json_extract(data, '$.role') = 'assistant'
450
+ AND json_extract(data, '$.agent') IS NOT NULL
451
+ ORDER BY time_created DESC
452
+ LIMIT 1`,
453
+ )
454
+ .get(sessionId)
455
+ return row?.agent ?? null
456
+ }
457
+
458
+ // Close DB on process exit to avoid WAL lock issues
459
+ process.on("exit", () => { try { _db?.close() } catch {} })