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,144 @@
|
|
|
1
|
+
import { execSync } from "child_process"
|
|
2
|
+
import { writeFileSync, readFileSync, unlinkSync } from "fs"
|
|
3
|
+
import { tmpdir } from "os"
|
|
4
|
+
import { join } from "path"
|
|
5
|
+
import { render } from "ink"
|
|
6
|
+
import React from "react"
|
|
7
|
+
|
|
8
|
+
const EXIT_ALT_SCREEN = "\x1b[?1049l"
|
|
9
|
+
const ENTER_ALT_SCREEN = "\x1b[?1049h"
|
|
10
|
+
const CLEAR_SCREEN = "\x1b[2J\x1b[H"
|
|
11
|
+
|
|
12
|
+
// We store the Ink instance so we can unmount and remount it
|
|
13
|
+
let _inkInstance: ReturnType<typeof render> | null = null
|
|
14
|
+
|
|
15
|
+
// Module-level side-channel for passing editor results across the remount boundary.
|
|
16
|
+
// onResult callbacks are stale closures after remount — use this instead.
|
|
17
|
+
let _pendingEditorResult: string | null = null
|
|
18
|
+
|
|
19
|
+
export function consumePendingEditorResult(): string | null {
|
|
20
|
+
const result = _pendingEditorResult
|
|
21
|
+
_pendingEditorResult = null
|
|
22
|
+
return result
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function setInkInstance(instance: ReturnType<typeof render>): void {
|
|
26
|
+
_inkInstance = instance
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Yield terminal control to opencode for a specific session.
|
|
31
|
+
* Exits alt screen, unmounts Ink, runs opencode, re-enters alt screen and remounts.
|
|
32
|
+
*/
|
|
33
|
+
export function yieldToOpencode(sessionId: string, cwd: string): void {
|
|
34
|
+
if (!_inkInstance) return
|
|
35
|
+
|
|
36
|
+
_inkInstance.unmount()
|
|
37
|
+
_inkInstance = null
|
|
38
|
+
|
|
39
|
+
// Exit alt screen so opencode gets a clean normal terminal
|
|
40
|
+
if (process.stdout.isTTY) process.stdout.write(EXIT_ALT_SCREEN)
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
execSync(`opencode -s ${sessionId}`, {
|
|
44
|
+
stdio: "inherit",
|
|
45
|
+
cwd,
|
|
46
|
+
})
|
|
47
|
+
} catch {
|
|
48
|
+
// User quit opencode (Ctrl-C or q) — normal exit, ignore error
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Re-enter alt screen and remount ocm
|
|
52
|
+
if (process.stdout.isTTY) process.stdout.write(ENTER_ALT_SCREEN + CLEAR_SCREEN)
|
|
53
|
+
_remountOcm()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Yield terminal control to opencode in a specific directory.
|
|
58
|
+
* Used for spawning new instances — opencode will create/resume session there.
|
|
59
|
+
*/
|
|
60
|
+
export function yieldToNewOpencode(cwd: string): void {
|
|
61
|
+
if (!_inkInstance) return
|
|
62
|
+
|
|
63
|
+
_inkInstance.unmount()
|
|
64
|
+
_inkInstance = null
|
|
65
|
+
|
|
66
|
+
if (process.stdout.isTTY) process.stdout.write(EXIT_ALT_SCREEN)
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
execSync("opencode", {
|
|
70
|
+
stdio: "inherit",
|
|
71
|
+
cwd,
|
|
72
|
+
})
|
|
73
|
+
} catch {
|
|
74
|
+
// User quit opencode — normal exit
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (process.stdout.isTTY) process.stdout.write(ENTER_ALT_SCREEN + CLEAR_SCREEN)
|
|
78
|
+
_remountOcm()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Open current input text in $EDITOR (Ctrl-X E pattern).
|
|
83
|
+
* Unmounts Ink, opens editor with text in a temp file,
|
|
84
|
+
* reads back the result, remounts OCMux, and calls onResult with the edited text.
|
|
85
|
+
*/
|
|
86
|
+
export function openInEditor(currentText: string, onResult: (text: string) => void): void {
|
|
87
|
+
if (!_inkInstance) return
|
|
88
|
+
|
|
89
|
+
const editor = process.env.EDITOR || process.env.VISUAL || "vi"
|
|
90
|
+
const tmpFile = join(tmpdir(), `ocmux-msg-${Date.now()}.md`)
|
|
91
|
+
|
|
92
|
+
_inkInstance.unmount()
|
|
93
|
+
_inkInstance = null
|
|
94
|
+
|
|
95
|
+
if (process.stdout.isTTY) process.stdout.write(EXIT_ALT_SCREEN)
|
|
96
|
+
|
|
97
|
+
// Write current text to temp file
|
|
98
|
+
try { writeFileSync(tmpFile, currentText) } catch { /* ignore */ }
|
|
99
|
+
|
|
100
|
+
// Open editor
|
|
101
|
+
try {
|
|
102
|
+
execSync(`${editor} ${JSON.stringify(tmpFile)}`, { stdio: "inherit" })
|
|
103
|
+
} catch { /* non-zero exit is fine */ }
|
|
104
|
+
|
|
105
|
+
// Read back
|
|
106
|
+
let edited = currentText
|
|
107
|
+
try { edited = readFileSync(tmpFile, "utf-8").trimEnd() } catch { /* ignore */ }
|
|
108
|
+
try { unlinkSync(tmpFile) } catch { /* ignore */ }
|
|
109
|
+
|
|
110
|
+
if (process.stdout.isTTY) process.stdout.write(ENTER_ALT_SCREEN + CLEAR_SCREEN)
|
|
111
|
+
|
|
112
|
+
// Store result in module-level slot — the remounted Conversation component
|
|
113
|
+
// will read this on mount (onResult callback is a stale closure after remount)
|
|
114
|
+
_pendingEditorResult = edited
|
|
115
|
+
|
|
116
|
+
// Remount OCMux
|
|
117
|
+
import("../app.js").then(({ App }) => {
|
|
118
|
+
_inkInstance = render(React.createElement(App))
|
|
119
|
+
setInkInstance(_inkInstance)
|
|
120
|
+
}).catch(console.error)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Check if opencode binary is available on PATH.
|
|
125
|
+
*/
|
|
126
|
+
export function isOpencodeAvailable(): boolean {
|
|
127
|
+
try {
|
|
128
|
+
execSync("which opencode", { stdio: "pipe" })
|
|
129
|
+
return true
|
|
130
|
+
} catch {
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Remount Ink after yielding — lazy import to avoid circular deps
|
|
136
|
+
function _remountOcm(): void {
|
|
137
|
+
// Use dynamic import to get App without circular dependency
|
|
138
|
+
import("../app.js")
|
|
139
|
+
.then(({ App }) => {
|
|
140
|
+
_inkInstance = render(React.createElement(App))
|
|
141
|
+
setInkInstance(_inkInstance)
|
|
142
|
+
})
|
|
143
|
+
.catch(console.error)
|
|
144
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useInput } from "ink"
|
|
2
|
+
import { config } from "../config.js"
|
|
3
|
+
|
|
4
|
+
type DashboardActions = {
|
|
5
|
+
onUp?: () => void
|
|
6
|
+
onDown?: () => void
|
|
7
|
+
onOpen?: () => void
|
|
8
|
+
onAttach?: () => void
|
|
9
|
+
onSpawn?: () => void
|
|
10
|
+
onExpand?: () => void
|
|
11
|
+
onCollapse?: () => void
|
|
12
|
+
onNextNeedsInput?: () => void
|
|
13
|
+
onKill?: () => void
|
|
14
|
+
onQuit?: () => void
|
|
15
|
+
onHelp?: () => void
|
|
16
|
+
onRescan?: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type ConversationActions = {
|
|
20
|
+
onBack?: () => void
|
|
21
|
+
onAttach?: () => void
|
|
22
|
+
onSend?: () => void
|
|
23
|
+
onScrollUp?: () => void
|
|
24
|
+
onScrollDown?: () => void
|
|
25
|
+
onScrollHalfPageUp?: () => void
|
|
26
|
+
onScrollHalfPageDown?: () => void
|
|
27
|
+
onScrollPageUp?: () => void
|
|
28
|
+
onScrollPageDown?: () => void
|
|
29
|
+
onScrollBottom?: () => void
|
|
30
|
+
onScrollTop?: () => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type SpawnActions = {
|
|
34
|
+
onCancel?: () => void
|
|
35
|
+
onConfirm?: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function matchKey(key: string, input: string, inkKey: any): boolean {
|
|
39
|
+
switch (key) {
|
|
40
|
+
case "return":
|
|
41
|
+
return inkKey.return
|
|
42
|
+
case "escape":
|
|
43
|
+
return inkKey.escape
|
|
44
|
+
case "tab":
|
|
45
|
+
return inkKey.tab && !inkKey.shift
|
|
46
|
+
case "shift-tab":
|
|
47
|
+
return inkKey.tab && inkKey.shift
|
|
48
|
+
case "up":
|
|
49
|
+
return inkKey.upArrow
|
|
50
|
+
case "down":
|
|
51
|
+
return inkKey.downArrow
|
|
52
|
+
default:
|
|
53
|
+
// Handle ctrl- prefixed keys like "ctrl-n"
|
|
54
|
+
if (key.startsWith("ctrl-")) {
|
|
55
|
+
const letter = key.slice(5)
|
|
56
|
+
return inkKey.ctrl && !inkKey.tab && !inkKey.return && !inkKey.escape && input === letter
|
|
57
|
+
}
|
|
58
|
+
return input === key
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useDashboardKeys(actions: DashboardActions) {
|
|
63
|
+
const kb = config.keybindings.dashboard
|
|
64
|
+
useInput((input, key) => {
|
|
65
|
+
if (matchKey(kb.up, input, key) || key.upArrow) actions.onUp?.()
|
|
66
|
+
else if (matchKey(kb.down, input, key) || key.downArrow) actions.onDown?.()
|
|
67
|
+
else if (matchKey(kb.open, input, key)) actions.onOpen?.()
|
|
68
|
+
else if (matchKey(kb.attach, input, key)) actions.onAttach?.()
|
|
69
|
+
else if (matchKey(kb.spawn, input, key)) actions.onSpawn?.()
|
|
70
|
+
else if (matchKey(kb.expand, input, key)) actions.onExpand?.()
|
|
71
|
+
else if (matchKey(kb.collapse, input, key)) actions.onCollapse?.()
|
|
72
|
+
else if (matchKey(kb.nextNeedsInput, input, key)) actions.onNextNeedsInput?.()
|
|
73
|
+
else if (matchKey(kb.kill, input, key)) actions.onKill?.()
|
|
74
|
+
else if (matchKey(kb.quit, input, key)) actions.onQuit?.()
|
|
75
|
+
else if (matchKey(kb.help, input, key)) actions.onHelp?.()
|
|
76
|
+
else if (matchKey(kb.rescan, input, key)) actions.onRescan?.()
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function useConversationKeys(actions: ConversationActions) {
|
|
81
|
+
const kb = config.keybindings.conversation
|
|
82
|
+
useInput((input, key) => {
|
|
83
|
+
if (matchKey(kb.back, input, key) || input === "q") actions.onBack?.()
|
|
84
|
+
else if (matchKey(kb.attach, input, key)) actions.onAttach?.()
|
|
85
|
+
else if (matchKey(kb.send, input, key)) actions.onSend?.()
|
|
86
|
+
else if (matchKey(kb.scrollUp, input, key) || key.upArrow) actions.onScrollUp?.()
|
|
87
|
+
else if (matchKey(kb.scrollDown, input, key) || key.downArrow) actions.onScrollDown?.()
|
|
88
|
+
else if (matchKey(kb.scrollHalfPageUp, input, key)) actions.onScrollHalfPageUp?.()
|
|
89
|
+
else if (matchKey(kb.scrollHalfPageDown, input, key)) actions.onScrollHalfPageDown?.()
|
|
90
|
+
else if (matchKey(kb.scrollPageUp, input, key)) actions.onScrollPageUp?.()
|
|
91
|
+
else if (matchKey(kb.scrollPageDown, input, key)) actions.onScrollPageDown?.()
|
|
92
|
+
else if (matchKey(kb.scrollBottom, input, key) && key.shift) actions.onScrollBottom?.()
|
|
93
|
+
else if (matchKey(kb.scrollTop, input, key) && !key.shift) actions.onScrollTop?.()
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function useSpawnKeys(actions: SpawnActions) {
|
|
98
|
+
const kb = config.keybindings.spawn
|
|
99
|
+
useInput((input, key) => {
|
|
100
|
+
if (matchKey(kb.cancel, input, key)) actions.onCancel?.()
|
|
101
|
+
else if (matchKey(kb.confirm, input, key)) actions.onConfirm?.()
|
|
102
|
+
})
|
|
103
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useInput } from "ink"
|
|
2
|
+
import React from "react"
|
|
3
|
+
|
|
4
|
+
export interface VimNavigationActions {
|
|
5
|
+
onUp?: () => void
|
|
6
|
+
onDown?: () => void
|
|
7
|
+
onHalfPageUp?: () => void
|
|
8
|
+
onHalfPageDown?: () => void
|
|
9
|
+
onTop?: () => void
|
|
10
|
+
onBottom?: () => void
|
|
11
|
+
onOpen?: () => void
|
|
12
|
+
onBack?: () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Adds vim-style navigation keys on top of existing keybindings.
|
|
17
|
+
* Call this alongside useDashboardKeys/useConversationKeys.
|
|
18
|
+
*/
|
|
19
|
+
export function useVimNavigation(actions: VimNavigationActions) {
|
|
20
|
+
const [pendingG, setPendingG] = React.useState(false)
|
|
21
|
+
|
|
22
|
+
useInput((input, key) => {
|
|
23
|
+
if (input === "g") {
|
|
24
|
+
if (pendingG) {
|
|
25
|
+
setPendingG(false)
|
|
26
|
+
actions.onTop?.()
|
|
27
|
+
} else {
|
|
28
|
+
setPendingG(true)
|
|
29
|
+
}
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (pendingG) setPendingG(false)
|
|
34
|
+
|
|
35
|
+
if (input === "j") actions.onDown?.()
|
|
36
|
+
else if (input === "k") actions.onUp?.()
|
|
37
|
+
else if (input === "l") actions.onOpen?.()
|
|
38
|
+
else if (input === "h") actions.onBack?.()
|
|
39
|
+
else if (input === "G") actions.onBottom?.()
|
|
40
|
+
else if (key.ctrl && input === "d") actions.onHalfPageDown?.()
|
|
41
|
+
else if (key.ctrl && input === "u") actions.onHalfPageUp?.()
|
|
42
|
+
})
|
|
43
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import React from "react"
|
|
3
|
+
import { render } from "ink"
|
|
4
|
+
import { App } from "./app.js"
|
|
5
|
+
import { setInkInstance } from "./hooks/use-attach.js"
|
|
6
|
+
import { startPoller, stopPoller } from "./poller.js"
|
|
7
|
+
import { config } from "./config.js"
|
|
8
|
+
import { cleanDeadInstances } from "./registry/instances.js"
|
|
9
|
+
|
|
10
|
+
// Enter alternate screen buffer — keeps our TUI isolated from terminal history.
|
|
11
|
+
// On resize, we clear the alternate screen so Ink always redraws from a clean slate.
|
|
12
|
+
const ENTER_ALT_SCREEN = "\x1b[?1049h"
|
|
13
|
+
const EXIT_ALT_SCREEN = "\x1b[?1049l"
|
|
14
|
+
const CLEAR_SCREEN = "\x1b[2J\x1b[H"
|
|
15
|
+
|
|
16
|
+
function enterAltScreen() {
|
|
17
|
+
process.stdout.write(ENTER_ALT_SCREEN)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function exitAltScreen() {
|
|
21
|
+
process.stdout.write(EXIT_ALT_SCREEN)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cleanup() {
|
|
25
|
+
stopPoller()
|
|
26
|
+
exitAltScreen()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
enterAltScreen()
|
|
31
|
+
|
|
32
|
+
// On resize: clear the alternate screen so Ink redraws without stale lines
|
|
33
|
+
if (process.stdout.isTTY) {
|
|
34
|
+
process.stdout.on("resize", () => {
|
|
35
|
+
process.stdout.write(CLEAR_SCREEN)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Remove stale spawned instances (dead pids / unresponsive ports)
|
|
40
|
+
await cleanDeadInstances()
|
|
41
|
+
|
|
42
|
+
startPoller(config.pollIntervalMs)
|
|
43
|
+
|
|
44
|
+
const inkInstance = render(<App />)
|
|
45
|
+
setInkInstance(inkInstance)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
process.on("SIGINT", () => { cleanup(); process.exit(0) })
|
|
49
|
+
process.on("SIGTERM", () => { cleanup(); process.exit(0) })
|
|
50
|
+
process.on("exit", () => { exitAltScreen() })
|
|
51
|
+
|
|
52
|
+
main().catch(console.error)
|
package/src/poller.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { basename } from "path"
|
|
2
|
+
import { execSync } from "child_process"
|
|
3
|
+
import {
|
|
4
|
+
getProjects,
|
|
5
|
+
getSessionById,
|
|
6
|
+
getSessionStatus,
|
|
7
|
+
getLastMessagePreview,
|
|
8
|
+
getSessionModel,
|
|
9
|
+
getChildSessions,
|
|
10
|
+
countChildSessions,
|
|
11
|
+
hasChildSessions,
|
|
12
|
+
getMostRecentSessionForProject,
|
|
13
|
+
} from "./db/reader.js"
|
|
14
|
+
|
|
15
|
+
export function shortenModel(model: string): string {
|
|
16
|
+
let s = model
|
|
17
|
+
// Strip org prefix e.g. "deepseek-ai/deepseek-v3.2" → "deepseek-v3.2"
|
|
18
|
+
if (s.includes("/")) s = s.split("/").pop()!
|
|
19
|
+
// Strip "claude-" prefix
|
|
20
|
+
s = s.replace(/^claude-/, "")
|
|
21
|
+
// Strip "antigravity-" prefix
|
|
22
|
+
s = s.replace(/^antigravity-/, "")
|
|
23
|
+
// Strip "codex-" from gpt models
|
|
24
|
+
s = s.replace(/codex-/, "")
|
|
25
|
+
// Strip "-preview" suffix
|
|
26
|
+
s = s.replace(/-preview$/, "")
|
|
27
|
+
return s
|
|
28
|
+
}
|
|
29
|
+
import { useStore, type OcmInstance, type OcmSession, type SessionStatus } from "./store.js"
|
|
30
|
+
import { loadSpawnedInstances } from "./registry/instances.js"
|
|
31
|
+
|
|
32
|
+
interface RunningProcess {
|
|
33
|
+
cwd: string
|
|
34
|
+
sessionId: string | null // from -s flag, instances.json, or null
|
|
35
|
+
port?: number // only for opencode serve processes
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get CWD for a PID. Cross-platform: macOS uses lsof, Linux uses /proc.
|
|
40
|
+
*/
|
|
41
|
+
function getCwdForPid(pid: number): string {
|
|
42
|
+
try {
|
|
43
|
+
if (process.platform === "linux") {
|
|
44
|
+
return execSync(`readlink /proc/${pid}/cwd 2>/dev/null`, {
|
|
45
|
+
encoding: "utf-8", timeout: 1000,
|
|
46
|
+
}).trim()
|
|
47
|
+
} else {
|
|
48
|
+
const lsofOutput = execSync(`lsof -p ${pid} 2>/dev/null`, {
|
|
49
|
+
encoding: "utf-8", timeout: 2000,
|
|
50
|
+
})
|
|
51
|
+
const cwdLine = lsofOutput.split("\n").find((l) => l.includes(" cwd "))
|
|
52
|
+
return cwdLine?.trim().split(/\s+/).slice(8).join(" ") ?? ""
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
return ""
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Find all currently running opencode processes (TUI and serve) with cwds and session IDs.
|
|
61
|
+
* TUI pattern: opencode [-s {sessionId}]
|
|
62
|
+
* Serve pattern: opencode serve --port {port}
|
|
63
|
+
*/
|
|
64
|
+
function getRunningOpencodeProcesses(): RunningProcess[] {
|
|
65
|
+
try {
|
|
66
|
+
const psOutput = execSync("ps -eo pid,args 2>/dev/null", {
|
|
67
|
+
encoding: "utf-8",
|
|
68
|
+
timeout: 3000,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Load spawned instances to resolve sessionIds for serve processes
|
|
72
|
+
const spawnedInstances = loadSpawnedInstances()
|
|
73
|
+
const spawnedByPort = new Map(spawnedInstances.map((i) => [i.port, i]))
|
|
74
|
+
|
|
75
|
+
const results: RunningProcess[] = []
|
|
76
|
+
for (const line of psOutput.split("\n")) {
|
|
77
|
+
const trimmed = line.trim()
|
|
78
|
+
|
|
79
|
+
// Match TUI: "opencode" or "opencode -s {sessionId}"
|
|
80
|
+
const tuiMatch = trimmed.match(/^(\d+)\s+opencode(?:\s+-s\s+(\S+))?$/)
|
|
81
|
+
if (tuiMatch) {
|
|
82
|
+
const pid = parseInt(tuiMatch[1]!, 10)
|
|
83
|
+
const sessionId = tuiMatch[2] ?? null
|
|
84
|
+
const cwd = getCwdForPid(pid)
|
|
85
|
+
if (cwd) results.push({ cwd, sessionId })
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Match serve: "opencode serve --port {port} ..."
|
|
90
|
+
const serveMatch = trimmed.match(/^(\d+)\s+opencode\s+serve\s+.*--port\s+(\d+)/)
|
|
91
|
+
if (serveMatch) {
|
|
92
|
+
const pid = parseInt(serveMatch[1]!, 10)
|
|
93
|
+
const port = parseInt(serveMatch[2]!, 10)
|
|
94
|
+
const spawned = spawnedByPort.get(port)
|
|
95
|
+
const cwd = spawned?.cwd ?? getCwdForPid(pid)
|
|
96
|
+
if (cwd) results.push({ cwd, sessionId: spawned?.sessionId ?? null, port })
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return results
|
|
102
|
+
} catch {
|
|
103
|
+
return []
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const STATUS_PRIORITY: Record<SessionStatus, number> = {
|
|
108
|
+
"needs-input": 0,
|
|
109
|
+
error: 1,
|
|
110
|
+
working: 2,
|
|
111
|
+
idle: 3,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Find the most specific project for a given cwd.
|
|
116
|
+
* If /Users/joey/repos/project and /Users/joey both match,
|
|
117
|
+
* prefer /Users/joey/repos/project (longer = more specific).
|
|
118
|
+
*/
|
|
119
|
+
function findBestProject(
|
|
120
|
+
cwd: string,
|
|
121
|
+
projects: Array<{ id: string; worktree: string }>,
|
|
122
|
+
): { id: string; worktree: string } | null {
|
|
123
|
+
let best: { id: string; worktree: string } | null = null
|
|
124
|
+
let bestLen = -1
|
|
125
|
+
for (const p of projects) {
|
|
126
|
+
const isMatch =
|
|
127
|
+
cwd === p.worktree ||
|
|
128
|
+
cwd.startsWith(p.worktree + "/") ||
|
|
129
|
+
p.worktree.startsWith(cwd + "/")
|
|
130
|
+
if (isMatch && p.worktree.length > bestLen) {
|
|
131
|
+
best = p
|
|
132
|
+
bestLen = p.worktree.length
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return best
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let _intervalId: ReturnType<typeof setInterval> | null = null
|
|
139
|
+
let _lastPollTime = 0
|
|
140
|
+
|
|
141
|
+
function loadFromDb(): void {
|
|
142
|
+
try {
|
|
143
|
+
const dbProjects = getProjects()
|
|
144
|
+
const runningProcesses = getRunningOpencodeProcesses()
|
|
145
|
+
|
|
146
|
+
// Build ONE OcmInstance per running process.
|
|
147
|
+
// Processes with -s get their explicit session.
|
|
148
|
+
// Processes without -s get assigned the Nth most-recent session for their project
|
|
149
|
+
// (where N is how many other flag-less processes in the same project came before).
|
|
150
|
+
// This way two flag-less processes in the same dir show different sessions.
|
|
151
|
+
const ocmInstances: OcmInstance[] = []
|
|
152
|
+
const seenSessionIds = new Set<string>()
|
|
153
|
+
// Track how many flag-less processes we've assigned per project
|
|
154
|
+
const flaglessCountByProject = new Map<string, number>()
|
|
155
|
+
|
|
156
|
+
for (const proc of runningProcesses) {
|
|
157
|
+
const project = findBestProject(proc.cwd, dbProjects)
|
|
158
|
+
if (!project) continue
|
|
159
|
+
|
|
160
|
+
// Resolve the session ID
|
|
161
|
+
let sessionId = proc.sessionId
|
|
162
|
+
if (!sessionId) {
|
|
163
|
+
// Assign the Nth most-recent session to avoid all flag-less processes
|
|
164
|
+
// in the same directory collapsing to the same session
|
|
165
|
+
const offset = flaglessCountByProject.get(project.id) ?? 0
|
|
166
|
+
flaglessCountByProject.set(project.id, offset + 1)
|
|
167
|
+
const recent = getMostRecentSessionForProject(project.id, offset)
|
|
168
|
+
sessionId = recent?.id ?? null
|
|
169
|
+
}
|
|
170
|
+
if (!sessionId) continue
|
|
171
|
+
|
|
172
|
+
// Still deduplicate if two processes explicitly target the same session
|
|
173
|
+
if (seenSessionIds.has(sessionId)) continue
|
|
174
|
+
seenSessionIds.add(sessionId)
|
|
175
|
+
|
|
176
|
+
const session = getSessionById(sessionId)
|
|
177
|
+
if (!session) continue
|
|
178
|
+
|
|
179
|
+
const status = getSessionStatus(sessionId)
|
|
180
|
+
const preview = getLastMessagePreview(sessionId)
|
|
181
|
+
const rawModel = getSessionModel(sessionId)
|
|
182
|
+
|
|
183
|
+
// For serve instances, use the actual process cwd as worktree — it's the
|
|
184
|
+
// authoritative source and must match what's stored in instances.json.
|
|
185
|
+
// For TUI instances, use the SQLite project worktree (more stable).
|
|
186
|
+
const instanceWorktree = proc.port ? proc.cwd : project.worktree
|
|
187
|
+
|
|
188
|
+
ocmInstances.push({
|
|
189
|
+
id: `${project.id}-${sessionId}`,
|
|
190
|
+
sessionId,
|
|
191
|
+
sessionTitle: session.title || sessionId.slice(0, 20),
|
|
192
|
+
projectId: project.id,
|
|
193
|
+
worktree: instanceWorktree,
|
|
194
|
+
repoName: basename(instanceWorktree),
|
|
195
|
+
status,
|
|
196
|
+
lastPreview: preview.text,
|
|
197
|
+
lastPreviewRole: preview.role,
|
|
198
|
+
hasChildren: hasChildSessions(sessionId),
|
|
199
|
+
model: rawModel ? shortenModel(rawModel) : null,
|
|
200
|
+
port: proc.port ?? null,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Sort: needs-input first, then working, then idle, then error
|
|
205
|
+
ocmInstances.sort((a, b) => {
|
|
206
|
+
const pa = STATUS_PRIORITY[a.status]
|
|
207
|
+
const pb = STATUS_PRIORITY[b.status]
|
|
208
|
+
if (pa !== pb) return pa - pb
|
|
209
|
+
// Secondary sort: repo name alphabetically
|
|
210
|
+
return a.repoName.localeCompare(b.repoName)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
useStore.getState().setInstances(ocmInstances)
|
|
214
|
+
|
|
215
|
+
// Refresh children for expanded sessions
|
|
216
|
+
const expandedSessions = useStore.getState().expandedSessions
|
|
217
|
+
const childScrollOffsets = useStore.getState().childScrollOffsets
|
|
218
|
+
for (const sessionId of expandedSessions) {
|
|
219
|
+
try {
|
|
220
|
+
const offset = childScrollOffsets.get(sessionId) ?? 0
|
|
221
|
+
const children = getChildSessions(sessionId, 10, offset)
|
|
222
|
+
const totalCount = countChildSessions(sessionId)
|
|
223
|
+
const childOcmSessions: OcmSession[] = children.map((c) => {
|
|
224
|
+
const status = getSessionStatus(c.id)
|
|
225
|
+
const preview = getLastMessagePreview(c.id)
|
|
226
|
+
return {
|
|
227
|
+
id: c.id,
|
|
228
|
+
projectId: c.projectId,
|
|
229
|
+
title: c.title,
|
|
230
|
+
directory: c.directory,
|
|
231
|
+
status,
|
|
232
|
+
lastMessagePreview: preview.text,
|
|
233
|
+
lastMessageRole: preview.role,
|
|
234
|
+
model: (() => { const m = getSessionModel(c.id); return m ? shortenModel(m) : null })(),
|
|
235
|
+
timeUpdated: c.timeUpdated,
|
|
236
|
+
hasChildren: hasChildSessions(c.id),
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
useStore.getState().setChildSessions(sessionId, childOcmSessions, totalCount)
|
|
240
|
+
} catch {
|
|
241
|
+
// Skip on error
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
_lastPollTime = Date.now()
|
|
246
|
+
} catch {
|
|
247
|
+
// DB may be locked briefly — skip this poll cycle
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function startPoller(intervalMs = 2000): void {
|
|
252
|
+
if (_intervalId) return
|
|
253
|
+
loadFromDb()
|
|
254
|
+
_intervalId = setInterval(() => { loadFromDb() }, intervalMs)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function stopPoller(): void {
|
|
258
|
+
if (_intervalId) {
|
|
259
|
+
clearInterval(_intervalId)
|
|
260
|
+
_intervalId = null
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function refreshNow(): void {
|
|
265
|
+
loadFromDb()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function getLastPollTime(): number {
|
|
269
|
+
return _lastPollTime
|
|
270
|
+
}
|