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,198 @@
1
+ import React from "react"
2
+ import { Box, Text } from "ink"
3
+ import TextInput from "ink-text-input"
4
+ import { execSync, spawn } from "child_process"
5
+ import { existsSync } from "fs"
6
+ import { createOpencodeClient } from "@opencode-ai/sdk"
7
+ import { useStore } from "../store.js"
8
+ import { useSpawnKeys } from "../hooks/use-keybindings.js"
9
+ import { refreshNow } from "../poller.js"
10
+ import { yieldToOpencode } from "../hooks/use-attach.js"
11
+ import {
12
+ findNextPort,
13
+ waitForServer,
14
+ loadSpawnedInstances,
15
+ saveSpawnedInstances,
16
+ } from "../registry/instances.js"
17
+
18
+ function expandHome(p: string): string {
19
+ if (p.startsWith("~")) {
20
+ return p.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? "")
21
+ }
22
+ return p
23
+ }
24
+
25
+ function isFzfAvailable(): boolean {
26
+ try {
27
+ execSync("which fzf", { stdio: "pipe" })
28
+ return true
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ function runFzf(): string | undefined {
35
+ try {
36
+ const searchPaths = [
37
+ expandHome("~/Programming"),
38
+ expandHome("~/repos"),
39
+ expandHome("~/projects"),
40
+ expandHome("~/code"),
41
+ expandHome("~"),
42
+ ]
43
+ .filter((p) => p && existsSync(p))
44
+ .join(" ")
45
+
46
+ const cmd = `find ${searchPaths} -maxdepth 3 -type d 2>/dev/null | fzf --prompt="Select project folder: " --height=40%`
47
+ const result = execSync(cmd, {
48
+ stdio: ["inherit", "pipe", "inherit"],
49
+ encoding: "utf-8",
50
+ })
51
+ return result.trim() || undefined
52
+ } catch {
53
+ return undefined
54
+ }
55
+ }
56
+
57
+ export function Spawn() {
58
+ const navigate = useStore((s) => s.navigate)
59
+ const [manualPath, setManualPath] = React.useState("")
60
+ const [status, setStatus] = React.useState<"idle" | "spawning" | "error">("idle")
61
+ const [errorMsg, setErrorMsg] = React.useState("")
62
+ const [hasFzf] = React.useState(() => isFzfAvailable())
63
+
64
+ useSpawnKeys({
65
+ onCancel: () => navigate("dashboard"),
66
+ })
67
+
68
+ const doSpawn = React.useCallback(
69
+ async (cwd: string) => {
70
+ const expanded = expandHome(cwd.trim())
71
+ if (!expanded) return
72
+ if (!existsSync(expanded)) {
73
+ setErrorMsg(`Path does not exist: ${expanded}`)
74
+ setStatus("error")
75
+ return
76
+ }
77
+
78
+ setStatus("spawning")
79
+ setErrorMsg("")
80
+
81
+ try {
82
+ // 1. Find next available port
83
+ const port = await findNextPort()
84
+
85
+ // 2. Spawn opencode serve --port {port} as a detached background process
86
+ const proc = spawn("opencode", ["serve", "--port", String(port)], {
87
+ cwd: expanded,
88
+ detached: true,
89
+ stdio: "ignore",
90
+ })
91
+ proc.unref() // don't block OCMux's event loop on this child
92
+
93
+ // 3. Wait for the server to be ready
94
+ await waitForServer(port)
95
+
96
+ // 4. Create an initial session via SDK
97
+ const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` })
98
+ const sessionResult = await client.session.create()
99
+ const sessionId = (sessionResult as any).data?.id ?? null
100
+
101
+ // 5. Persist the spawned instance info
102
+ const instances = loadSpawnedInstances()
103
+ instances.push({
104
+ port,
105
+ pid: proc.pid!,
106
+ cwd: expanded,
107
+ sessionId,
108
+ })
109
+ saveSpawnedInstances(instances)
110
+
111
+ // 6. Refresh so the dashboard knows about the new instance
112
+ refreshNow()
113
+
114
+ // 7. Immediately attach to the new session
115
+ if (sessionId) {
116
+ yieldToOpencode(sessionId, expanded)
117
+ }
118
+
119
+ navigate("dashboard")
120
+ } catch (e) {
121
+ setErrorMsg(String(e))
122
+ setStatus("error")
123
+ }
124
+ },
125
+ [navigate],
126
+ )
127
+
128
+ const openFzf = React.useCallback(() => {
129
+ const selected = runFzf()
130
+ if (selected) {
131
+ void doSpawn(selected)
132
+ }
133
+ }, [doSpawn])
134
+
135
+ React.useEffect(() => {
136
+ if (hasFzf) {
137
+ setTimeout(() => openFzf(), 50)
138
+ }
139
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
140
+
141
+ if (status === "spawning") {
142
+ return (
143
+ <Box flexDirection="column" padding={1}>
144
+ <Text bold>ocmux — spawning opencode server...</Text>
145
+ <Text dimColor>Starting background server, please wait...</Text>
146
+ </Box>
147
+ )
148
+ }
149
+
150
+ return (
151
+ <Box flexDirection="column" padding={1}>
152
+ <Text bold>ocmux — open in opencode</Text>
153
+ <Text dimColor>{"─".repeat(60)}</Text>
154
+
155
+ {hasFzf ? (
156
+ <Box flexDirection="column" marginTop={1}>
157
+ <Text>Press Enter to open folder picker (fzf)</Text>
158
+ <Text dimColor>or type a path manually below:</Text>
159
+ </Box>
160
+ ) : (
161
+ <Box marginTop={1}>
162
+ <Text dimColor>fzf not found. Enter path manually:</Text>
163
+ </Box>
164
+ )}
165
+
166
+ <Box marginTop={1}>
167
+ <Text dimColor>{">"} </Text>
168
+ <TextInput
169
+ value={manualPath}
170
+ onChange={setManualPath}
171
+ onSubmit={() => {
172
+ if (manualPath.trim()) {
173
+ void doSpawn(manualPath)
174
+ } else if (hasFzf) {
175
+ openFzf()
176
+ }
177
+ }}
178
+ placeholder={
179
+ hasFzf
180
+ ? "Enter to open picker, or type path..."
181
+ : "Enter path to project..."
182
+ }
183
+ />
184
+ </Box>
185
+
186
+ {status === "error" && (
187
+ <Box marginTop={1}>
188
+ <Text color="red">{errorMsg}</Text>
189
+ <Text dimColor> (press Esc to go back)</Text>
190
+ </Box>
191
+ )}
192
+
193
+ <Box marginTop={1}>
194
+ <Text dimColor>Esc: back to dashboard</Text>
195
+ </Box>
196
+ </Box>
197
+ )
198
+ }
@@ -0,0 +1,32 @@
1
+ import React from "react"
2
+ import { render, Text, Box, useInput } from "ink"
3
+ import { execSync } from "child_process"
4
+
5
+ function App() {
6
+ const [status, setStatus] = React.useState(
7
+ "Ready. Press 'a' to attach to 'spike-test' tmux session, 'q' to quit."
8
+ )
9
+
10
+ useInput((input) => {
11
+ if (input === "a") {
12
+ setStatus("Attaching to tmux...")
13
+ setTimeout(() => {
14
+ try {
15
+ execSync("tmux attach-session -t spike-test", { stdio: "inherit" })
16
+ setStatus("Returned from tmux successfully. Ink recovered! Press 'q' to quit.")
17
+ } catch (e: any) {
18
+ setStatus(`Error or detached: ${e?.message ?? e}. Press 'q' to quit.`)
19
+ }
20
+ }, 100)
21
+ }
22
+ if (input === "q") process.exit(0)
23
+ })
24
+
25
+ return (
26
+ <Box flexDirection="column">
27
+ <Text>{status}</Text>
28
+ </Box>
29
+ )
30
+ }
31
+
32
+ render(<App />)
@@ -0,0 +1,67 @@
1
+ import { createOpencodeClient, type Session, type TextPart } from "@opencode-ai/sdk"
2
+
3
+ const port = process.argv[2] || "4096"
4
+ const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` })
5
+
6
+ console.log(`Connecting to opencode on port ${port}...`)
7
+
8
+ // List sessions
9
+ const sessionsResult = await client.session.list()
10
+ if (sessionsResult.error || !sessionsResult.data) {
11
+ console.error("Failed to list sessions. Is opencode running on this port?", sessionsResult.error)
12
+ process.exit(1)
13
+ }
14
+
15
+ const sessions = sessionsResult.data
16
+ if (sessions.length === 0) {
17
+ console.log("No sessions found. Create one in the opencode TUI first.")
18
+ process.exit(1)
19
+ }
20
+
21
+ console.log(`Found ${sessions.length} sessions:`)
22
+ sessions.forEach((s: Session, i: number) => console.log(` [${i}] ${s.id}: "${s.title}"`))
23
+
24
+ const session = sessions[0]!
25
+ console.log(`\nUsing session: "${session.title}" (${session.id})`)
26
+
27
+ // Get available providers
28
+ console.log("\nFetching available providers...")
29
+ try {
30
+ const providersResult = await client.provider.list()
31
+ console.log("Providers:", JSON.stringify(providersResult.data, null, 2))
32
+ } catch (e) {
33
+ console.log("Could not fetch providers:", e)
34
+ }
35
+
36
+ // Get messages to see conversation state
37
+ console.log("\nFetching messages...")
38
+ const messagesResult = await client.session.messages({ path: { id: session.id } })
39
+ if (messagesResult.error || !messagesResult.data) {
40
+ console.log("Could not fetch messages:", messagesResult.error)
41
+ } else {
42
+ const messages = messagesResult.data
43
+ console.log(`Found ${messages.length} messages`)
44
+
45
+ if (messages.length > 0) {
46
+ const last = messages[messages.length - 1]!
47
+ console.log(`Last message role: ${last.info.role}`)
48
+ const textParts = last.parts.filter((p): p is TextPart => p.type === "text")
49
+ if (textParts.length > 0) {
50
+ console.log(`Last message preview: "${textParts[0]!.text.substring(0, 100)}..."`)
51
+ }
52
+ }
53
+ }
54
+
55
+ console.log("\n--- Spike complete ---")
56
+ console.log("To test sending a message, uncomment the chat section below and fill in providerID/modelID")
57
+ console.log("based on the providers output above.")
58
+
59
+ // Uncomment to test sending a message:
60
+ // const result = await client.session.prompt({
61
+ // path: { id: session.id },
62
+ // body: {
63
+ // model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
64
+ // parts: [{ type: "text", text: "What files are in the current directory?" }],
65
+ // },
66
+ // })
67
+ // console.log("Response:", JSON.stringify(result.data, null, 2))
@@ -0,0 +1,33 @@
1
+ import { createOpencodeClient } from "@opencode-ai/sdk"
2
+
3
+ const port = process.argv[2] || "4096"
4
+ const client = createOpencodeClient({ baseUrl: `http://localhost:${port}` })
5
+
6
+ console.log(`Connecting to opencode on port ${port}...`)
7
+
8
+ const sessionsResult = await client.session.list()
9
+ if (sessionsResult.error || !sessionsResult.data) {
10
+ console.error("Failed to list sessions. Is opencode running on this port?", sessionsResult.error)
11
+ process.exit(1)
12
+ }
13
+
14
+ const sessions = sessionsResult.data
15
+ console.log(`Found ${sessions.length} sessions:`)
16
+ for (const s of sessions) {
17
+ console.log(` - ${s.id}: "${s.title}" (created: ${new Date(s.time.created).toLocaleString()})`)
18
+ }
19
+
20
+ console.log("\nListening for events (Ctrl+C to stop)...")
21
+ console.log("Interact with the opencode instance to see events flow.\n")
22
+
23
+ try {
24
+ const { stream } = await client.event.subscribe()
25
+ for await (const event of stream) {
26
+ const timestamp = new Date().toLocaleTimeString()
27
+ console.log(`[${timestamp}] ${event.type}`)
28
+ console.log(` ${JSON.stringify(event.properties, null, 2).split("\n").join("\n ")}`)
29
+ console.log()
30
+ }
31
+ } catch (e) {
32
+ console.error("Event stream error:", e)
33
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "outDir": "dist",
9
+ "rootDir": ".",
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true
12
+ },
13
+ "include": ["src/**/*", "test/**/*"]
14
+ }