oh-my-opencode-dashboard 0.0.1

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,135 @@
1
+ import * as fs from "node:fs"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
4
+ import { describe, expect, it } from "vitest"
5
+ import { buildDashboardPayload } from "./dashboard"
6
+ import { getStorageRoots } from "../ingest/session"
7
+
8
+ function mkStorageRoot(): string {
9
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "omo-storage-"))
10
+ fs.mkdirSync(path.join(root, "session"), { recursive: true })
11
+ fs.mkdirSync(path.join(root, "message"), { recursive: true })
12
+ fs.mkdirSync(path.join(root, "part"), { recursive: true })
13
+ return root
14
+ }
15
+
16
+ describe("buildDashboardPayload", () => {
17
+ it("surfaces 'running tool' status when session has in-flight tool", () => {
18
+ const storageRoot = mkStorageRoot()
19
+ const storage = getStorageRoots(storageRoot)
20
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
21
+ const sessionId = "ses_running_tool"
22
+ const messageId = "msg_1"
23
+ const projectID = "proj_1"
24
+
25
+ try {
26
+ const sessionMetaDir = path.join(storage.session, projectID)
27
+ fs.mkdirSync(sessionMetaDir, { recursive: true })
28
+ fs.writeFileSync(
29
+ path.join(sessionMetaDir, `${sessionId}.json`),
30
+ JSON.stringify({
31
+ id: sessionId,
32
+ projectID,
33
+ directory: projectRoot,
34
+ time: { created: 1000, updated: 1000 },
35
+ }),
36
+ "utf8"
37
+ )
38
+
39
+ const messageDir = path.join(storage.message, sessionId)
40
+ fs.mkdirSync(messageDir, { recursive: true })
41
+ fs.writeFileSync(
42
+ path.join(messageDir, `${messageId}.json`),
43
+ JSON.stringify({
44
+ id: messageId,
45
+ sessionID: sessionId,
46
+ role: "assistant",
47
+ agent: "sisyphus",
48
+ time: { created: 1000 },
49
+ }),
50
+ "utf8"
51
+ )
52
+
53
+ const partDir = path.join(storage.part, messageId)
54
+ fs.mkdirSync(partDir, { recursive: true })
55
+ fs.writeFileSync(
56
+ path.join(partDir, "part_1.json"),
57
+ JSON.stringify({
58
+ id: "part_1",
59
+ sessionID: sessionId,
60
+ messageID: messageId,
61
+ type: "tool",
62
+ callID: "call_1",
63
+ tool: "delegate_task",
64
+ state: { status: "running", input: {} },
65
+ }),
66
+ "utf8"
67
+ )
68
+
69
+ const payload = buildDashboardPayload({
70
+ projectRoot,
71
+ storage,
72
+ nowMs: 2000,
73
+ })
74
+
75
+ expect(payload.mainSession.statusPill).toBe("running tool")
76
+ expect(payload.mainSession.currentTool).toBe("delegate_task")
77
+ expect(payload.mainSession.agent).toBe("sisyphus")
78
+
79
+ expect(payload.raw).not.toHaveProperty("prompt")
80
+ expect(payload.raw).not.toHaveProperty("input")
81
+ } finally {
82
+ fs.rmSync(storageRoot, { recursive: true, force: true })
83
+ fs.rmSync(projectRoot, { recursive: true, force: true })
84
+ }
85
+ })
86
+
87
+ it("surfaces 'thinking' status when latest assistant message is not completed", () => {
88
+ const storageRoot = mkStorageRoot()
89
+ const storage = getStorageRoots(storageRoot)
90
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
91
+ const sessionId = "ses_thinking"
92
+ const messageId = "msg_1"
93
+ const projectID = "proj_1"
94
+
95
+ try {
96
+ const sessionMetaDir = path.join(storage.session, projectID)
97
+ fs.mkdirSync(sessionMetaDir, { recursive: true })
98
+ fs.writeFileSync(
99
+ path.join(sessionMetaDir, `${sessionId}.json`),
100
+ JSON.stringify({
101
+ id: sessionId,
102
+ projectID,
103
+ directory: projectRoot,
104
+ time: { created: 1000, updated: 1000 },
105
+ }),
106
+ "utf8"
107
+ )
108
+
109
+ const messageDir = path.join(storage.message, sessionId)
110
+ fs.mkdirSync(messageDir, { recursive: true })
111
+ fs.writeFileSync(
112
+ path.join(messageDir, `${messageId}.json`),
113
+ JSON.stringify({
114
+ id: messageId,
115
+ sessionID: sessionId,
116
+ role: "assistant",
117
+ agent: "sisyphus",
118
+ time: { created: 1000 },
119
+ }),
120
+ "utf8"
121
+ )
122
+
123
+ const payload = buildDashboardPayload({
124
+ projectRoot,
125
+ storage,
126
+ nowMs: 50_000,
127
+ })
128
+
129
+ expect(payload.mainSession.statusPill).toBe("thinking")
130
+ } finally {
131
+ fs.rmSync(storageRoot, { recursive: true, force: true })
132
+ fs.rmSync(projectRoot, { recursive: true, force: true })
133
+ }
134
+ })
135
+ })
@@ -0,0 +1,191 @@
1
+ import * as fs from "node:fs"
2
+ import * as path from "node:path"
3
+ import { readBoulderState, readPlanProgress } from "../ingest/boulder"
4
+ import { deriveBackgroundTasks } from "../ingest/background-tasks"
5
+ import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
6
+
7
+ export type DashboardPayload = {
8
+ mainSession: {
9
+ agent: string
10
+ currentTool: string
11
+ lastUpdatedLabel: string
12
+ session: string
13
+ statusPill: string
14
+ }
15
+ planProgress: {
16
+ name: string
17
+ completed: number
18
+ total: number
19
+ path: string
20
+ statusPill: string
21
+ }
22
+ backgroundTasks: Array<{
23
+ id: string
24
+ description: string
25
+ agent: string
26
+ status: string
27
+ toolCalls: number
28
+ lastTool: string
29
+ timeline: string
30
+ }>
31
+ raw: unknown
32
+ }
33
+
34
+ export type DashboardStore = {
35
+ getSnapshot: () => DashboardPayload
36
+ }
37
+
38
+ function formatIso(ts: number | null): string {
39
+ if (!ts) return "never"
40
+ try {
41
+ return new Date(ts).toISOString()
42
+ } catch {
43
+ return "never"
44
+ }
45
+ }
46
+
47
+ function planStatusPill(progress: { missing: boolean; isComplete: boolean }): string {
48
+ if (progress.missing) return "not started"
49
+ return progress.isComplete ? "complete" : "in progress"
50
+ }
51
+
52
+ function mainStatusPill(status: string): string {
53
+ if (status === "running_tool") return "running tool"
54
+ if (status === "thinking") return "thinking"
55
+ if (status === "busy") return "busy"
56
+ if (status === "idle") return "idle"
57
+ return "unknown"
58
+ }
59
+
60
+ export function buildDashboardPayload(opts: {
61
+ projectRoot: string
62
+ storage: OpenCodeStorageRoots
63
+ nowMs?: number
64
+ }): DashboardPayload {
65
+ const nowMs = opts.nowMs ?? Date.now()
66
+
67
+ const boulder = readBoulderState(opts.projectRoot)
68
+ const planName = boulder?.plan_name ?? "(no active plan)"
69
+ const planPath = boulder?.active_plan ?? ""
70
+ const plan = boulder ? readPlanProgress(opts.projectRoot, boulder.active_plan) : { total: 0, completed: 0, isComplete: false, missing: true }
71
+
72
+ const sessionId = pickActiveSessionId({
73
+ projectRoot: opts.projectRoot,
74
+ storage: opts.storage,
75
+ boulderSessionIds: boulder?.session_ids,
76
+ })
77
+
78
+ let sessionMeta: SessionMetadata | null = null
79
+ if (sessionId) {
80
+ const metas = readMainSessionMetas(opts.storage.session, opts.projectRoot)
81
+ sessionMeta = metas.find((m) => m.id === sessionId) ?? null
82
+ }
83
+
84
+ const main = sessionId
85
+ ? getMainSessionView({
86
+ projectRoot: opts.projectRoot,
87
+ sessionId,
88
+ storage: opts.storage,
89
+ sessionMeta,
90
+ nowMs,
91
+ })
92
+ : { agent: "unknown", currentTool: null, lastUpdated: null, sessionLabel: "(no session)", status: "unknown" as const }
93
+
94
+ const tasks = sessionId ? deriveBackgroundTasks({ storage: opts.storage, mainSessionId: sessionId, nowMs }) : []
95
+
96
+ const payload: DashboardPayload = {
97
+ mainSession: {
98
+ agent: main.agent,
99
+ currentTool: main.currentTool ?? "-",
100
+ lastUpdatedLabel: formatIso(main.lastUpdated),
101
+ session: main.sessionLabel,
102
+ statusPill: mainStatusPill(main.status),
103
+ },
104
+ planProgress: {
105
+ name: planName,
106
+ completed: plan.completed,
107
+ total: plan.total,
108
+ path: planPath,
109
+ statusPill: planStatusPill(plan),
110
+ },
111
+ backgroundTasks: tasks.map((t) => ({
112
+ id: t.id,
113
+ description: t.description,
114
+ agent: t.agent,
115
+ status: t.status,
116
+ toolCalls: t.toolCalls ?? 0,
117
+ lastTool: t.lastTool ?? "-",
118
+ timeline: typeof t.timeline === "string" ? t.timeline : "",
119
+ })),
120
+ raw: null,
121
+ }
122
+
123
+ payload.raw = {
124
+ mainSession: payload.mainSession,
125
+ planProgress: payload.planProgress,
126
+ backgroundTasks: payload.backgroundTasks,
127
+ }
128
+ return payload
129
+ }
130
+
131
+ function watchIfExists(target: string, onChange: () => void): fs.FSWatcher | null {
132
+ try {
133
+ if (!fs.existsSync(target)) return null
134
+ return fs.watch(target, { persistent: false }, () => onChange())
135
+ } catch {
136
+ return null
137
+ }
138
+ }
139
+
140
+ export function createDashboardStore(opts: {
141
+ projectRoot: string
142
+ storageRoot: string
143
+ pollIntervalMs?: number
144
+ watch?: boolean
145
+ }): DashboardStore {
146
+ const storage = getStorageRoots(opts.storageRoot)
147
+ const pollIntervalMs = opts.pollIntervalMs ?? 2000
148
+ const watch = opts.watch !== false
149
+
150
+ let lastComputedAt = 0
151
+ let dirty = true
152
+ let cached: DashboardPayload | null = null
153
+
154
+ const watchers: fs.FSWatcher[] = []
155
+ const markDirty = () => {
156
+ dirty = true
157
+ }
158
+
159
+ if (watch) {
160
+ watchers.push(...[
161
+ watchIfExists(path.join(opts.projectRoot, ".sisyphus", "boulder.json"), markDirty),
162
+ watchIfExists(path.join(opts.projectRoot, ".sisyphus", "plans"), markDirty),
163
+ watchIfExists(storage.session, markDirty),
164
+ watchIfExists(storage.message, markDirty),
165
+ watchIfExists(storage.part, markDirty),
166
+ ].filter(Boolean) as fs.FSWatcher[])
167
+
168
+ // Best-effort: close watchers on process exit.
169
+ process.on("exit", () => {
170
+ for (const w of watchers) {
171
+ try {
172
+ w.close()
173
+ } catch {
174
+ // ignore
175
+ }
176
+ }
177
+ })
178
+ }
179
+
180
+ return {
181
+ getSnapshot() {
182
+ const now = Date.now()
183
+ if (!cached || dirty || now - lastComputedAt > pollIntervalMs) {
184
+ cached = buildDashboardPayload({ projectRoot: opts.projectRoot, storage })
185
+ lastComputedAt = now
186
+ dirty = false
187
+ }
188
+ return cached
189
+ },
190
+ }
191
+ }
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bun
2
+ import { Hono } from "hono"
3
+ import { createApi } from "./api"
4
+ import { createDashboardStore } from "./dashboard"
5
+ import { getOpenCodeStorageDir } from "../ingest/paths"
6
+
7
+ const args = process.argv.slice(2)
8
+ let projectPath: string | undefined;
9
+ let port = 51234;
10
+
11
+ for (let i = 0; i < args.length; i++) {
12
+ const arg = args[i];
13
+ if (arg === '--project' && i + 1 < args.length) {
14
+ projectPath = args[i + 1];
15
+ i++;
16
+ } else if (arg === '--port' && i + 1 < args.length) {
17
+ const portValue = parseInt(args[i + 1], 10);
18
+ if (!isNaN(portValue)) {
19
+ port = portValue;
20
+ }
21
+ i++;
22
+ }
23
+ }
24
+
25
+ const resolvedProjectPath = projectPath ?? process.cwd()
26
+
27
+ const app = new Hono()
28
+
29
+ const store = createDashboardStore({
30
+ projectRoot: resolvedProjectPath,
31
+ storageRoot: getOpenCodeStorageDir(),
32
+ watch: true,
33
+ pollIntervalMs: 2000,
34
+ })
35
+
36
+ app.route("/api", createApi(store))
37
+
38
+ Bun.serve({
39
+ fetch: app.fetch,
40
+ hostname: '127.0.0.1',
41
+ port,
42
+ })
43
+
44
+ console.log(`Server running at http://127.0.0.1:${port}`)
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bun
2
+ import { Hono } from 'hono'
3
+ import { join } from 'node:path'
4
+ import { parseArgs } from 'util'
5
+ import { createApi } from "./api"
6
+ import { createDashboardStore } from "./dashboard"
7
+ import { getOpenCodeStorageDir } from "../ingest/paths"
8
+
9
+ const { values } = parseArgs({
10
+ args: Bun.argv,
11
+ options: {
12
+ project: { type: 'string' },
13
+ port: { type: 'string' },
14
+ },
15
+ allowPositionals: true,
16
+ })
17
+
18
+ const project = values.project ?? process.cwd()
19
+
20
+ const port = parseInt(values.port || '51234')
21
+
22
+ const app = new Hono()
23
+
24
+ const store = createDashboardStore({
25
+ projectRoot: project,
26
+ storageRoot: getOpenCodeStorageDir(),
27
+ watch: true,
28
+ pollIntervalMs: 2000,
29
+ })
30
+
31
+ app.route('/api', createApi(store))
32
+
33
+ const distRoot = join(import.meta.dir, '../../dist')
34
+
35
+ // SPA fallback middleware
36
+ app.use('*', async (c, next) => {
37
+ const path = c.req.path
38
+
39
+ // Skip API routes - let them pass through
40
+ if (path.startsWith('/api/')) {
41
+ return await next()
42
+ }
43
+
44
+ // For non-API routes without extensions, serve index.html
45
+ if (!path.includes('.')) {
46
+ const indexFile = Bun.file(join(distRoot, 'index.html'))
47
+ if (await indexFile.exists()) {
48
+ return c.html(await indexFile.text())
49
+ }
50
+ return c.notFound()
51
+ }
52
+
53
+ // For static files with extensions, try to serve them
54
+ const relativePath = path.startsWith('/') ? path.slice(1) : path
55
+ const file = Bun.file(join(distRoot, relativePath))
56
+ if (await file.exists()) {
57
+ const ext = path.split('.').pop() || ''
58
+ const contentType = getContentType(ext)
59
+ return new Response(file, {
60
+ headers: { 'Content-Type': contentType }
61
+ })
62
+ }
63
+
64
+ return c.notFound()
65
+ })
66
+
67
+ function getContentType(ext: string): string {
68
+ const types: Record<string, string> = {
69
+ 'html': 'text/html',
70
+ 'js': 'application/javascript',
71
+ 'css': 'text/css',
72
+ 'json': 'application/json',
73
+ 'png': 'image/png',
74
+ 'jpg': 'image/jpeg',
75
+ 'jpeg': 'image/jpeg',
76
+ 'gif': 'image/gif',
77
+ 'svg': 'image/svg+xml',
78
+ 'ico': 'image/x-icon',
79
+ 'woff': 'font/woff',
80
+ 'woff2': 'font/woff2',
81
+ 'ttf': 'font/ttf',
82
+ 'eot': 'application/vnd.ms-fontobject',
83
+ }
84
+ return types[ext] || 'text/plain'
85
+ }
86
+
87
+ Bun.serve({
88
+ fetch: app.fetch,
89
+ hostname: '127.0.0.1',
90
+ port,
91
+ })
92
+
93
+ console.log(`Server running on http://127.0.0.1:${port}`)
@@ -0,0 +1,55 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest"
2
+
3
+ describe("playDing", () => {
4
+ const prevWindow = (globalThis as unknown as { window?: unknown }).window
5
+
6
+ afterEach(() => {
7
+ ;(globalThis as unknown as { window?: unknown }).window = prevWindow
8
+ })
9
+
10
+ it("plays two tones for waiting", async () => {
11
+ const oscillators: Array<{ start: ReturnType<typeof vi.fn> }> = []
12
+
13
+ class FakeAudioContext {
14
+ public state: AudioContextState = "suspended"
15
+ public currentTime = 1
16
+ public destination = null as unknown as AudioDestinationNode
17
+
18
+ resume = vi.fn(async () => {
19
+ this.state = "running"
20
+ })
21
+
22
+ createOscillator(): OscillatorNode {
23
+ const osc = {
24
+ type: "sine" as OscillatorType,
25
+ frequency: { setValueAtTime: vi.fn() },
26
+ connect: vi.fn(),
27
+ start: vi.fn(),
28
+ stop: vi.fn(),
29
+ }
30
+ oscillators.push({ start: osc.start })
31
+ return osc as unknown as OscillatorNode
32
+ }
33
+
34
+ createGain(): GainNode {
35
+ const g = {
36
+ gain: {
37
+ setValueAtTime: vi.fn(),
38
+ linearRampToValueAtTime: vi.fn(),
39
+ },
40
+ connect: vi.fn(),
41
+ }
42
+ return g as unknown as GainNode
43
+ }
44
+ }
45
+
46
+ ;(globalThis as unknown as { window?: unknown }).window = {
47
+ AudioContext: FakeAudioContext,
48
+ } as unknown as Window & typeof globalThis
49
+
50
+ const { playDing } = await import("./sound")
51
+ await playDing("waiting")
52
+
53
+ expect(oscillators).toHaveLength(2)
54
+ })
55
+ })
package/src/sound.ts ADDED
@@ -0,0 +1,89 @@
1
+ export type DingKind = "waiting" | "task" | "all"
2
+
3
+ let ctx: AudioContext | null = null
4
+
5
+ function getCtx(): AudioContext | null {
6
+ if (typeof window === "undefined") return null
7
+ const AnyAudioContext = (window.AudioContext ?? (window as any).webkitAudioContext) as
8
+ | (new () => AudioContext)
9
+ | undefined
10
+ if (!AnyAudioContext) return null
11
+
12
+ if (!ctx) {
13
+ ctx = new AnyAudioContext()
14
+ }
15
+
16
+ return ctx
17
+ }
18
+
19
+ export async function unlockAudio(): Promise<boolean> {
20
+ const c = getCtx()
21
+ if (!c) return false
22
+ try {
23
+ if (c.state !== "running") {
24
+ await c.resume()
25
+ }
26
+ return c.state === "running"
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ function playTone(opts: {
33
+ at: number
34
+ freq: number
35
+ dur: number
36
+ gain: number
37
+ }): void {
38
+ const c = getCtx()
39
+ if (!c) return
40
+ if (c.state !== "running") return
41
+
42
+ const osc = c.createOscillator()
43
+ const g = c.createGain()
44
+
45
+ osc.type = "sine"
46
+ osc.frequency.setValueAtTime(opts.freq, opts.at)
47
+
48
+ const attack = Math.min(0.01, opts.dur / 4)
49
+ const release = Math.min(0.08, opts.dur / 2)
50
+
51
+ g.gain.setValueAtTime(0, opts.at)
52
+ g.gain.linearRampToValueAtTime(opts.gain, opts.at + attack)
53
+ g.gain.setValueAtTime(opts.gain, opts.at + Math.max(attack, opts.dur - release))
54
+ g.gain.linearRampToValueAtTime(0, opts.at + opts.dur)
55
+
56
+ osc.connect(g)
57
+ g.connect(c.destination)
58
+
59
+ osc.start(opts.at)
60
+ osc.stop(opts.at + opts.dur + 0.01)
61
+ }
62
+
63
+ export async function playDing(kind: DingKind): Promise<void> {
64
+ const ok = await unlockAudio()
65
+ if (!ok) return
66
+
67
+ const c = getCtx()
68
+ if (!c) return
69
+
70
+ const t0 = c.currentTime + 0.01
71
+ const baseGain = 0.06
72
+
73
+ if (kind === "waiting") {
74
+ playTone({ at: t0, freq: 784, dur: 0.08, gain: baseGain })
75
+ playTone({ at: t0 + 0.10, freq: 659, dur: 0.10, gain: baseGain })
76
+ return
77
+ }
78
+
79
+ if (kind === "task") {
80
+ playTone({ at: t0, freq: 659, dur: 0.08, gain: baseGain })
81
+ playTone({ at: t0 + 0.10, freq: 880, dur: 0.10, gain: baseGain })
82
+ return
83
+ }
84
+
85
+ // all
86
+ playTone({ at: t0, freq: 523.25, dur: 0.10, gain: baseGain })
87
+ playTone({ at: t0 + 0.12, freq: 659.25, dur: 0.10, gain: baseGain })
88
+ playTone({ at: t0 + 0.24, freq: 783.99, dur: 0.14, gain: baseGain })
89
+ }