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/LICENSE +201 -0
- package/README.md +190 -0
- package/bun.lock +228 -0
- package/package.json +28 -0
- package/src/app.tsx +18 -0
- package/src/config.ts +118 -0
- package/src/db/reader.ts +459 -0
- package/src/hooks/use-attach.ts +144 -0
- package/src/hooks/use-keybindings.ts +103 -0
- package/src/hooks/use-vim-navigation.ts +43 -0
- package/src/index.tsx +52 -0
- package/src/poller.ts +270 -0
- package/src/registry/instances.ts +176 -0
- package/src/store.ts +159 -0
- package/src/types/marked-terminal.d.ts +10 -0
- package/src/views/conversation.tsx +560 -0
- package/src/views/dashboard.tsx +549 -0
- package/src/views/spawn.tsx +198 -0
- package/test/spike-attach.tsx +32 -0
- package/test/spike-chat.ts +67 -0
- package/test/spike-status.ts +33 -0
- package/tsconfig.json +14 -0
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()
|
package/src/db/reader.ts
ADDED
|
@@ -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 {} })
|