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.
@@ -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
+ }))
@@ -0,0 +1,10 @@
1
+ declare module "marked-terminal" {
2
+ import type { Extension } from "marked"
3
+
4
+ interface MarkedTerminalOptions {
5
+ width?: number
6
+ reflowText?: boolean
7
+ }
8
+
9
+ export function markedTerminal(options?: MarkedTerminalOptions): Extension
10
+ }