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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs"
|
|
2
|
+
import { homedir } from "os"
|
|
3
|
+
import { join } from "path"
|
|
4
|
+
import { execSync } from "child_process"
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = join(homedir(), ".config", "ocmux")
|
|
7
|
+
const INSTANCES_FILE = join(CONFIG_DIR, "instances.json")
|
|
8
|
+
|
|
9
|
+
export interface SpawnedInstance {
|
|
10
|
+
port: number
|
|
11
|
+
pid: number
|
|
12
|
+
cwd: string
|
|
13
|
+
sessionId: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ensureDir() {
|
|
17
|
+
mkdirSync(CONFIG_DIR, { recursive: true })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function loadSpawnedInstances(): SpawnedInstance[] {
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(INSTANCES_FILE, "utf-8")
|
|
23
|
+
return JSON.parse(raw) as SpawnedInstance[]
|
|
24
|
+
} catch {
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function saveSpawnedInstances(instances: SpawnedInstance[]): void {
|
|
30
|
+
ensureDir()
|
|
31
|
+
writeFileSync(INSTANCES_FILE, JSON.stringify(instances, null, 2))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isPidAlive(pid: number): boolean {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0)
|
|
37
|
+
return true
|
|
38
|
+
} catch {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function isPortAlive(port: number): Promise<boolean> {
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`http://localhost:${port}/doc`, {
|
|
46
|
+
signal: AbortSignal.timeout(1000),
|
|
47
|
+
})
|
|
48
|
+
return res.ok
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Remove instances whose process is dead or port is unresponsive.
|
|
56
|
+
* Called on startup to clean up stale entries.
|
|
57
|
+
*/
|
|
58
|
+
export async function cleanDeadInstances(): Promise<void> {
|
|
59
|
+
const instances = loadSpawnedInstances()
|
|
60
|
+
if (instances.length === 0) return
|
|
61
|
+
|
|
62
|
+
const alive: SpawnedInstance[] = []
|
|
63
|
+
for (const inst of instances) {
|
|
64
|
+
if (isPidAlive(inst.pid) && (await isPortAlive(inst.port))) {
|
|
65
|
+
alive.push(inst)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (alive.length !== instances.length) {
|
|
70
|
+
saveSpawnedInstances(alive)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Find the next available port for a new opencode serve instance.
|
|
76
|
+
*/
|
|
77
|
+
export async function findNextPort(startPort = 4096, endPort = 4115): Promise<number> {
|
|
78
|
+
const existing = new Set(loadSpawnedInstances().map((i) => i.port))
|
|
79
|
+
for (let port = startPort; port <= endPort; port++) {
|
|
80
|
+
if (existing.has(port)) continue
|
|
81
|
+
// Check if anything is already listening
|
|
82
|
+
const inUse = await isPortAlive(port)
|
|
83
|
+
if (!inUse) return port
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`No available ports in range ${startPort}-${endPort}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Wait until opencode serve is ready to accept requests.
|
|
90
|
+
*/
|
|
91
|
+
export async function waitForServer(port: number, timeoutMs = 15000): Promise<void> {
|
|
92
|
+
const deadline = Date.now() + timeoutMs
|
|
93
|
+
while (Date.now() < deadline) {
|
|
94
|
+
if (await isPortAlive(port)) return
|
|
95
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`opencode server on port ${port} did not start within ${timeoutMs}ms`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Find the PID of an opencode process (TUI or serve) running in the given directory.
|
|
102
|
+
* Used as a fallback when the instance isn't in instances.json.
|
|
103
|
+
*/
|
|
104
|
+
function findPidByWorktree(worktree: string): number | null {
|
|
105
|
+
try {
|
|
106
|
+
const psOutput = execSync("ps -eo pid,args 2>/dev/null", {
|
|
107
|
+
encoding: "utf-8",
|
|
108
|
+
timeout: 3000,
|
|
109
|
+
})
|
|
110
|
+
for (const line of psOutput.split("\n")) {
|
|
111
|
+
const trimmed = line.trim()
|
|
112
|
+
// Match both TUI and serve patterns
|
|
113
|
+
const match = trimmed.match(/^(\d+)\s+opencode(?:\s+serve|\s+-s\s+\S+)?/)
|
|
114
|
+
if (!match) continue
|
|
115
|
+
const pid = parseInt(match[1]!, 10)
|
|
116
|
+
try {
|
|
117
|
+
let cwd: string
|
|
118
|
+
if (process.platform === "linux") {
|
|
119
|
+
cwd = execSync(`readlink /proc/${pid}/cwd 2>/dev/null`, {
|
|
120
|
+
encoding: "utf-8", timeout: 1000,
|
|
121
|
+
}).trim()
|
|
122
|
+
} else {
|
|
123
|
+
const lsofOut = execSync(`lsof -p ${pid} 2>/dev/null`, {
|
|
124
|
+
encoding: "utf-8", timeout: 2000,
|
|
125
|
+
})
|
|
126
|
+
const cwdLine = lsofOut.split("\n").find((l) => l.includes(" cwd "))
|
|
127
|
+
cwd = cwdLine?.trim().split(/\s+/).slice(8).join(" ") ?? ""
|
|
128
|
+
}
|
|
129
|
+
// Match if: exact, cwd is under worktree, OR worktree is under cwd
|
|
130
|
+
if (
|
|
131
|
+
cwd === worktree ||
|
|
132
|
+
cwd.startsWith(worktree + "/") ||
|
|
133
|
+
worktree.startsWith(cwd + "/")
|
|
134
|
+
) return pid
|
|
135
|
+
} catch {
|
|
136
|
+
// process exited — skip
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// ps failed
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Kill the opencode instance associated with the given worktree and optional sessionId.
|
|
147
|
+
* Handles both spawned serve instances (from instances.json) and TUI instances.
|
|
148
|
+
*/
|
|
149
|
+
export function killInstance(worktree: string, sessionId: string | null): void {
|
|
150
|
+
// 1. Check instances.json for a matching spawned serve process.
|
|
151
|
+
// Match by sessionId first (most reliable), then by cwd prefix.
|
|
152
|
+
// Note: instances.json cwd may differ from the project's SQLite worktree.
|
|
153
|
+
const instances = loadSpawnedInstances()
|
|
154
|
+
const idx = instances.findIndex((i) => {
|
|
155
|
+
if (sessionId && i.sessionId === sessionId) return true
|
|
156
|
+
return (
|
|
157
|
+
i.cwd === worktree ||
|
|
158
|
+
i.cwd.startsWith(worktree + "/") ||
|
|
159
|
+
worktree.startsWith(i.cwd + "/")
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
if (idx >= 0) {
|
|
164
|
+
const inst = instances[idx]!
|
|
165
|
+
try { process.kill(inst.pid, "SIGTERM") } catch { /* already dead */ }
|
|
166
|
+
instances.splice(idx, 1)
|
|
167
|
+
saveSpawnedInstances(instances)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 2. Fallback: find and kill TUI process by cwd/worktree
|
|
172
|
+
const pid = findPidByWorktree(worktree)
|
|
173
|
+
if (pid) {
|
|
174
|
+
try { process.kill(pid, "SIGTERM") } catch { /* already dead */ }
|
|
175
|
+
}
|
|
176
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { create } from "zustand"
|
|
2
|
+
|
|
3
|
+
// ─── Status types ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export type SessionStatus =
|
|
6
|
+
| "working"
|
|
7
|
+
| "needs-input"
|
|
8
|
+
| "idle"
|
|
9
|
+
| "error"
|
|
10
|
+
|
|
11
|
+
// ─── Instance type (one per running opencode process) ────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface OcmInstance {
|
|
14
|
+
id: string // unique key: "{worktree}-{sessionId}"
|
|
15
|
+
sessionId: string // the session this process is running
|
|
16
|
+
sessionTitle: string // title from session table
|
|
17
|
+
projectId: string
|
|
18
|
+
worktree: string // absolute path
|
|
19
|
+
repoName: string // basename(worktree)
|
|
20
|
+
status: SessionStatus
|
|
21
|
+
lastPreview: string
|
|
22
|
+
lastPreviewRole: "user" | "assistant"
|
|
23
|
+
hasChildren: boolean
|
|
24
|
+
model: string | null // last model used in this session (shortened)
|
|
25
|
+
port: number | null // only set for opencode serve instances (spawned via OCMux)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Session type (for subagent tree children) ────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export interface OcmSession {
|
|
31
|
+
id: string
|
|
32
|
+
projectId: string
|
|
33
|
+
title: string
|
|
34
|
+
directory: string
|
|
35
|
+
status: SessionStatus
|
|
36
|
+
lastMessagePreview: string
|
|
37
|
+
lastMessageRole: "user" | "assistant"
|
|
38
|
+
model: string | null
|
|
39
|
+
timeUpdated: number
|
|
40
|
+
hasChildren?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── Conversation message types ───────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export interface ConversationMessagePart {
|
|
46
|
+
id: string
|
|
47
|
+
type: string
|
|
48
|
+
text?: string
|
|
49
|
+
tool?: string
|
|
50
|
+
toolStatus?: string
|
|
51
|
+
callId?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ConversationMessage {
|
|
55
|
+
id: string
|
|
56
|
+
sessionId: string
|
|
57
|
+
role: "user" | "assistant"
|
|
58
|
+
timeCreated: number
|
|
59
|
+
timeCompleted: number | null
|
|
60
|
+
modelId: string | null
|
|
61
|
+
providerId: string | null
|
|
62
|
+
parts: ConversationMessagePart[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── View types ───────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export type ViewName = "dashboard" | "conversation" | "spawn"
|
|
68
|
+
|
|
69
|
+
// ─── Store ────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
interface Store {
|
|
72
|
+
// Live instances (one per running opencode process)
|
|
73
|
+
instances: OcmInstance[]
|
|
74
|
+
setInstances: (instances: OcmInstance[]) => void
|
|
75
|
+
|
|
76
|
+
// Expandable subagent tree
|
|
77
|
+
expandedSessions: Set<string>
|
|
78
|
+
childSessions: Map<string, { children: OcmSession[]; totalCount: number }>
|
|
79
|
+
childScrollOffsets: Map<string, number>
|
|
80
|
+
toggleExpanded: (sessionId: string) => void
|
|
81
|
+
collapseSession: (sessionId: string) => void
|
|
82
|
+
setChildSessions: (parentId: string, children: OcmSession[], totalCount: number) => void
|
|
83
|
+
setChildScrollOffset: (sessionId: string, offset: number) => void
|
|
84
|
+
|
|
85
|
+
// Navigation
|
|
86
|
+
view: ViewName
|
|
87
|
+
selectedProjectId: string | null
|
|
88
|
+
selectedSessionId: string | null
|
|
89
|
+
cursorIndex: number
|
|
90
|
+
setCursorIndex: (index: number) => void
|
|
91
|
+
navigate: (view: ViewName, projectId?: string, sessionId?: string) => void
|
|
92
|
+
|
|
93
|
+
// Conversation (loaded on demand from SQLite)
|
|
94
|
+
messages: ConversationMessage[]
|
|
95
|
+
messagesLoading: boolean
|
|
96
|
+
setMessages: (messages: ConversationMessage[]) => void
|
|
97
|
+
setMessagesLoading: (loading: boolean) => void
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const useStore = create<Store>((set) => ({
|
|
101
|
+
// Instances
|
|
102
|
+
instances: [],
|
|
103
|
+
setInstances: (instances) => set({ instances }),
|
|
104
|
+
|
|
105
|
+
// Expandable subagent tree
|
|
106
|
+
expandedSessions: new Set(),
|
|
107
|
+
childSessions: new Map(),
|
|
108
|
+
childScrollOffsets: new Map(),
|
|
109
|
+
toggleExpanded: (sessionId) =>
|
|
110
|
+
set((state) => {
|
|
111
|
+
const next = new Set(state.expandedSessions)
|
|
112
|
+
if (next.has(sessionId)) {
|
|
113
|
+
next.delete(sessionId)
|
|
114
|
+
} else {
|
|
115
|
+
next.add(sessionId)
|
|
116
|
+
}
|
|
117
|
+
return { expandedSessions: next }
|
|
118
|
+
}),
|
|
119
|
+
collapseSession: (sessionId) =>
|
|
120
|
+
set((state) => {
|
|
121
|
+
const next = new Set(state.expandedSessions)
|
|
122
|
+
next.delete(sessionId)
|
|
123
|
+
return { expandedSessions: next }
|
|
124
|
+
}),
|
|
125
|
+
setChildSessions: (parentId, children, totalCount) =>
|
|
126
|
+
set((state) => {
|
|
127
|
+
const next = new Map(state.childSessions)
|
|
128
|
+
next.set(parentId, { children, totalCount })
|
|
129
|
+
return { childSessions: next }
|
|
130
|
+
}),
|
|
131
|
+
setChildScrollOffset: (sessionId, offset) =>
|
|
132
|
+
set((state) => {
|
|
133
|
+
const next = new Map(state.childScrollOffsets)
|
|
134
|
+
next.set(sessionId, offset)
|
|
135
|
+
return { childScrollOffsets: next }
|
|
136
|
+
}),
|
|
137
|
+
|
|
138
|
+
// Navigation
|
|
139
|
+
view: "dashboard",
|
|
140
|
+
selectedProjectId: null,
|
|
141
|
+
selectedSessionId: null,
|
|
142
|
+
cursorIndex: 0,
|
|
143
|
+
setCursorIndex: (cursorIndex) => set({ cursorIndex }),
|
|
144
|
+
navigate: (view, projectId, sessionId) =>
|
|
145
|
+
set({
|
|
146
|
+
view,
|
|
147
|
+
selectedProjectId: projectId ?? null,
|
|
148
|
+
selectedSessionId: sessionId ?? null,
|
|
149
|
+
cursorIndex: 0,
|
|
150
|
+
messages: [],
|
|
151
|
+
messagesLoading: false,
|
|
152
|
+
}),
|
|
153
|
+
|
|
154
|
+
// Conversation
|
|
155
|
+
messages: [],
|
|
156
|
+
messagesLoading: false,
|
|
157
|
+
setMessages: (messages) => set({ messages }),
|
|
158
|
+
setMessagesLoading: (messagesLoading) => set({ messagesLoading }),
|
|
159
|
+
}))
|