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,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
|
+
}
|