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