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,220 @@
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 {
6
+ getMainSessionView,
7
+ getStorageRoots,
8
+ pickActiveSessionId,
9
+ readMainSessionMetas,
10
+ } from "./session"
11
+
12
+ function mkStorageRoot(): string {
13
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "omo-storage-"))
14
+ fs.mkdirSync(path.join(root, "session"), { recursive: true })
15
+ fs.mkdirSync(path.join(root, "message"), { recursive: true })
16
+ fs.mkdirSync(path.join(root, "part"), { recursive: true })
17
+ return root
18
+ }
19
+
20
+ describe("pickActiveSessionId", () => {
21
+ it("prefers last boulder session_id that exists", () => {
22
+ const storageRoot = mkStorageRoot()
23
+ const storage = getStorageRoots(storageRoot)
24
+ const projectRoot = "/tmp/project"
25
+
26
+ fs.mkdirSync(path.join(storage.message, "ses_ok"), { recursive: true })
27
+
28
+ const picked = pickActiveSessionId({
29
+ projectRoot,
30
+ storage,
31
+ boulderSessionIds: ["ses_missing", "ses_ok"],
32
+ })
33
+ expect(picked).toBe("ses_ok")
34
+ })
35
+
36
+ it("falls back to newest main session metadata for directory", () => {
37
+ const storageRoot = mkStorageRoot()
38
+ const storage = getStorageRoots(storageRoot)
39
+ const projectRoot = "/tmp/project/"
40
+ const projectID = "proj_1"
41
+ fs.mkdirSync(path.join(storage.session, projectID), { recursive: true })
42
+
43
+ fs.writeFileSync(
44
+ path.join(storage.session, projectID, "ses_1.json"),
45
+ JSON.stringify({
46
+ id: "ses_1",
47
+ projectID,
48
+ directory: "/tmp/project",
49
+ time: { created: 1, updated: 10 },
50
+ }),
51
+ "utf8"
52
+ )
53
+ fs.writeFileSync(
54
+ path.join(storage.session, projectID, "ses_2.json"),
55
+ JSON.stringify({
56
+ id: "ses_2",
57
+ projectID,
58
+ directory: "/tmp/project",
59
+ time: { created: 2, updated: 20 },
60
+ }),
61
+ "utf8"
62
+ )
63
+
64
+ const picked = pickActiveSessionId({ projectRoot, storage })
65
+ expect(picked).toBe("ses_2")
66
+ })
67
+ })
68
+
69
+ describe("getMainSessionView", () => {
70
+ it("derives current tool and busy state from latest tool part", () => {
71
+ const storageRoot = mkStorageRoot()
72
+ const storage = getStorageRoots(storageRoot)
73
+ const projectRoot = "/tmp/project"
74
+ const sessionId = "ses_1"
75
+
76
+ const messageDir = path.join(storage.message, sessionId)
77
+ fs.mkdirSync(messageDir, { recursive: true })
78
+
79
+ const messageID = "msg_1"
80
+ fs.writeFileSync(
81
+ path.join(messageDir, `${messageID}.json`),
82
+ JSON.stringify({
83
+ id: messageID,
84
+ sessionID: sessionId,
85
+ role: "assistant",
86
+ agent: "sisyphus",
87
+ time: { created: 1000 },
88
+ }),
89
+ "utf8"
90
+ )
91
+
92
+ const partDir = path.join(storage.part, messageID)
93
+ fs.mkdirSync(partDir, { recursive: true })
94
+ fs.writeFileSync(
95
+ path.join(partDir, "part_1.json"),
96
+ JSON.stringify({
97
+ id: "part_1",
98
+ sessionID: sessionId,
99
+ messageID,
100
+ type: "tool",
101
+ callID: "call_1",
102
+ tool: "delegate_task",
103
+ state: { status: "running", input: {} },
104
+ }),
105
+ "utf8"
106
+ )
107
+
108
+ const metas = readMainSessionMetas(storage.session, projectRoot)
109
+ const view = getMainSessionView({
110
+ projectRoot,
111
+ sessionId,
112
+ storage,
113
+ sessionMeta: metas[0] ?? null,
114
+ nowMs: 2000,
115
+ })
116
+
117
+ expect(view.agent).toBe("sisyphus")
118
+ expect(view.currentTool).toBe("delegate_task")
119
+ expect(view.status).toBe("running_tool")
120
+ })
121
+
122
+ it("reproduces bug: newest meta is user, tool still running", () => {
123
+ const storageRoot = mkStorageRoot()
124
+ const storage = getStorageRoots(storageRoot)
125
+ const projectRoot = "/tmp/project"
126
+ const sessionId = "ses_1"
127
+
128
+ const messageDir = path.join(storage.message, sessionId)
129
+ fs.mkdirSync(messageDir, { recursive: true })
130
+
131
+ // Create an older assistant message with a running tool part
132
+ const assistantMessageID = "msg_1"
133
+ fs.writeFileSync(
134
+ path.join(messageDir, `${assistantMessageID}.json`),
135
+ JSON.stringify({
136
+ id: assistantMessageID,
137
+ sessionID: sessionId,
138
+ role: "assistant",
139
+ agent: "sisyphus",
140
+ time: { created: 1000 },
141
+ }),
142
+ "utf8"
143
+ )
144
+
145
+ const assistantPartDir = path.join(storage.part, assistantMessageID)
146
+ fs.mkdirSync(assistantPartDir, { recursive: true })
147
+ fs.writeFileSync(
148
+ path.join(assistantPartDir, "part_1.json"),
149
+ JSON.stringify({
150
+ id: "part_1",
151
+ sessionID: sessionId,
152
+ messageID: assistantMessageID,
153
+ type: "tool",
154
+ callID: "call_1",
155
+ tool: "grep",
156
+ state: { status: "running", input: { query: "test" } },
157
+ }),
158
+ "utf8"
159
+ )
160
+
161
+ // Create a newer user message (this becomes the "newest meta")
162
+ const userMessageID = "msg_2"
163
+ fs.writeFileSync(
164
+ path.join(messageDir, `${userMessageID}.json`),
165
+ JSON.stringify({
166
+ id: userMessageID,
167
+ sessionID: sessionId,
168
+ role: "user",
169
+ time: { created: 2000 },
170
+ }),
171
+ "utf8"
172
+ )
173
+
174
+ const metas = readMainSessionMetas(storage.session, projectRoot)
175
+ const view = getMainSessionView({
176
+ projectRoot,
177
+ sessionId,
178
+ storage,
179
+ sessionMeta: metas[0] ?? null,
180
+ nowMs: 50000, // Force idle branch: nowMs - newestMeta.time.created > 15_000
181
+ })
182
+
183
+ // Should detect running tool and return running_tool status
184
+ expect(view.status).toBe("running_tool")
185
+ expect(view.currentTool).toBe("grep")
186
+ })
187
+
188
+ it("derives thinking state when latest assistant message is not completed", () => {
189
+ const storageRoot = mkStorageRoot()
190
+ const storage = getStorageRoots(storageRoot)
191
+ const projectRoot = "/tmp/project"
192
+ const sessionId = "ses_1"
193
+
194
+ const messageDir = path.join(storage.message, sessionId)
195
+ fs.mkdirSync(messageDir, { recursive: true })
196
+
197
+ const messageID = "msg_1"
198
+ fs.writeFileSync(
199
+ path.join(messageDir, `${messageID}.json`),
200
+ JSON.stringify({
201
+ id: messageID,
202
+ sessionID: sessionId,
203
+ role: "assistant",
204
+ agent: "sisyphus",
205
+ time: { created: 1000 },
206
+ }),
207
+ "utf8"
208
+ )
209
+
210
+ const view = getMainSessionView({
211
+ projectRoot,
212
+ sessionId,
213
+ storage,
214
+ sessionMeta: null,
215
+ nowMs: 50_000,
216
+ })
217
+
218
+ expect(view.status).toBe("thinking")
219
+ })
220
+ })
@@ -0,0 +1,283 @@
1
+ import * as fs from "node:fs"
2
+ import * as path from "node:path"
3
+ import { getOpenCodeStorageDir, realpathSafe } from "./paths"
4
+
5
+ export type SessionMetadata = {
6
+ id: string
7
+ projectID: string
8
+ directory: string
9
+ title?: string
10
+ parentID?: string
11
+ time: { created: number; updated: number }
12
+ }
13
+
14
+ export type StoredMessageMeta = {
15
+ id: string
16
+ sessionID: string
17
+ role: "user" | "assistant"
18
+ time?: { created: number; completed?: number }
19
+ agent?: string
20
+ }
21
+
22
+ export type StoredToolPart = {
23
+ id: string
24
+ sessionID: string
25
+ messageID: string
26
+ type: "tool"
27
+ callID: string
28
+ tool: string
29
+ state: { status: "pending" | "running" | "completed" | "error"; input: Record<string, unknown> }
30
+ }
31
+
32
+ export type MainSessionView = {
33
+ agent: string
34
+ currentTool: string | null
35
+ lastUpdated: number | null
36
+ sessionLabel: string
37
+ status: "busy" | "idle" | "unknown" | "running_tool" | "thinking"
38
+ }
39
+
40
+ export type OpenCodeStorageRoots = {
41
+ session: string
42
+ message: string
43
+ part: string
44
+ }
45
+
46
+ export function getStorageRoots(storageRoot: string): OpenCodeStorageRoots {
47
+ return {
48
+ session: path.join(storageRoot, "session"),
49
+ message: path.join(storageRoot, "message"),
50
+ part: path.join(storageRoot, "part"),
51
+ }
52
+ }
53
+
54
+ export function defaultStorageRoots(): OpenCodeStorageRoots {
55
+ return getStorageRoots(getOpenCodeStorageDir())
56
+ }
57
+
58
+ export function getMessageDir(messageStorage: string, sessionID: string): string {
59
+ const directPath = path.join(messageStorage, sessionID)
60
+ if (fs.existsSync(directPath)) return directPath
61
+
62
+ try {
63
+ for (const dir of fs.readdirSync(messageStorage)) {
64
+ const sessionPath = path.join(messageStorage, dir, sessionID)
65
+ if (fs.existsSync(sessionPath)) return sessionPath
66
+ }
67
+ } catch {
68
+ return ""
69
+ }
70
+
71
+ return ""
72
+ }
73
+
74
+ export function sessionExists(messageStorage: string, sessionID: string): boolean {
75
+ return getMessageDir(messageStorage, sessionID) !== ""
76
+ }
77
+
78
+ export function readMainSessionMetas(
79
+ sessionStorage: string,
80
+ directoryFilter?: string
81
+ ): SessionMetadata[] {
82
+ if (!fs.existsSync(sessionStorage)) return []
83
+
84
+ const directoryNeedle = typeof directoryFilter === "string" && directoryFilter.length > 0
85
+ ? ((): string => {
86
+ const abs = path.resolve(directoryFilter)
87
+ const real = realpathSafe(abs) ?? abs
88
+ return path.normalize(real)
89
+ })()
90
+ : null
91
+
92
+ const metas: SessionMetadata[] = []
93
+ try {
94
+ const projectDirs = fs.readdirSync(sessionStorage, { withFileTypes: true })
95
+ for (const dirent of projectDirs) {
96
+ if (!dirent.isDirectory()) continue
97
+ const projectPath = path.join(sessionStorage, dirent.name)
98
+ for (const file of fs.readdirSync(projectPath)) {
99
+ if (!file.endsWith(".json")) continue
100
+ try {
101
+ const content = fs.readFileSync(path.join(projectPath, file), "utf8")
102
+ const meta = JSON.parse(content) as SessionMetadata
103
+ if (meta.parentID) continue
104
+
105
+ if (directoryNeedle) {
106
+ const metaAbs = path.resolve(meta.directory)
107
+ const metaReal = realpathSafe(metaAbs) ?? metaAbs
108
+ const metaDir = path.normalize(metaReal)
109
+ if (metaDir !== directoryNeedle) continue
110
+ }
111
+
112
+ metas.push(meta)
113
+ } catch {
114
+ continue
115
+ }
116
+ }
117
+ }
118
+ } catch {
119
+ return []
120
+ }
121
+
122
+ return metas.sort((a, b) => b.time.updated - a.time.updated)
123
+ }
124
+
125
+ export function pickActiveSessionId(opts: {
126
+ projectRoot: string
127
+ storage: OpenCodeStorageRoots
128
+ boulderSessionIds?: string[]
129
+ }): string | null {
130
+ const ids = opts.boulderSessionIds ?? []
131
+ for (let i = ids.length - 1; i >= 0; i--) {
132
+ const id = ids[i]
133
+ if (sessionExists(opts.storage.message, id)) return id
134
+ }
135
+
136
+ const metas = readMainSessionMetas(opts.storage.session, opts.projectRoot)
137
+ return metas[0]?.id ?? null
138
+ }
139
+
140
+ function readMostRecentMessageMeta(messageDir: string, maxMessages: number): StoredMessageMeta | null {
141
+ if (!messageDir || !fs.existsSync(messageDir)) return null
142
+
143
+ const files = fs.readdirSync(messageDir).filter((f) => f.endsWith(".json"))
144
+ const ranked = files
145
+ .map((f) => ({
146
+ f,
147
+ mtime: (() => {
148
+ try {
149
+ return fs.statSync(path.join(messageDir, f)).mtimeMs
150
+ } catch {
151
+ return 0
152
+ }
153
+ })(),
154
+ }))
155
+ .sort((a, b) => b.mtime - a.mtime)
156
+ .slice(0, maxMessages)
157
+
158
+ // Deterministic: parse meta.time.created and pick the newest.
159
+ let best: { created: number; id: string; meta: StoredMessageMeta } | null = null
160
+ for (const item of ranked) {
161
+ try {
162
+ const content = fs.readFileSync(path.join(messageDir, item.f), "utf8")
163
+ const meta = JSON.parse(content) as StoredMessageMeta
164
+ const created = meta.time?.created ?? 0
165
+ const id = String(meta.id ?? "")
166
+ if (!best || created > best.created || (created === best.created && id > best.id)) {
167
+ best = { created, id, meta }
168
+ }
169
+ } catch {
170
+ continue
171
+ }
172
+ }
173
+
174
+ return best?.meta ?? null
175
+ }
176
+
177
+ function readRecentMessageMetas(messageDir: string, maxMessages: number): StoredMessageMeta[] {
178
+ if (!messageDir || !fs.existsSync(messageDir)) return []
179
+
180
+ const files = fs.readdirSync(messageDir).filter((f) => f.endsWith(".json"))
181
+ const ranked = files
182
+ .map((f) => ({
183
+ f,
184
+ mtime: (() => {
185
+ try {
186
+ return fs.statSync(path.join(messageDir, f)).mtimeMs
187
+ } catch {
188
+ return 0
189
+ }
190
+ })(),
191
+ }))
192
+ .sort((a, b) => b.mtime - a.mtime)
193
+ .slice(0, maxMessages)
194
+
195
+ const metas: { created: number; id: string; meta: StoredMessageMeta }[] = []
196
+ for (const item of ranked) {
197
+ try {
198
+ const content = fs.readFileSync(path.join(messageDir, item.f), "utf8")
199
+ const meta = JSON.parse(content) as StoredMessageMeta
200
+ const created = meta.time?.created ?? 0
201
+ const id = String(meta.id ?? "")
202
+ metas.push({ created, id, meta })
203
+ } catch {
204
+ continue
205
+ }
206
+ }
207
+
208
+ return metas
209
+ .sort((a, b) => {
210
+ if (b.created !== a.created) return b.created - a.created
211
+ return b.id.localeCompare(a.id)
212
+ })
213
+ .map(item => item.meta)
214
+ }
215
+
216
+ function readLastToolPart(partStorage: string, messageID: string): { tool: string; status: string } | null {
217
+ const partDir = path.join(partStorage, messageID)
218
+ if (!fs.existsSync(partDir)) return null
219
+
220
+ const files = fs.readdirSync(partDir).filter((f) => f.endsWith(".json")).sort()
221
+ for (let i = files.length - 1; i >= 0; i--) {
222
+ const file = files[i]
223
+ try {
224
+ const content = fs.readFileSync(path.join(partDir, file), "utf8")
225
+ const part = JSON.parse(content) as Partial<StoredToolPart>
226
+ if (part.type === "tool" && typeof part.tool === "string") {
227
+ const status = (part as StoredToolPart).state?.status
228
+ return { tool: part.tool, status: typeof status === "string" ? status : "unknown" }
229
+ }
230
+ } catch {
231
+ continue
232
+ }
233
+ }
234
+ return null
235
+ }
236
+
237
+ export function getMainSessionView(opts: {
238
+ projectRoot: string
239
+ sessionId: string
240
+ storage: OpenCodeStorageRoots
241
+ sessionMeta?: SessionMetadata | null
242
+ nowMs?: number
243
+ }): MainSessionView {
244
+ const nowMs = opts.nowMs ?? Date.now()
245
+
246
+ const messageDir = getMessageDir(opts.storage.message, opts.sessionId)
247
+ const recent = readMostRecentMessageMeta(messageDir, 200)
248
+
249
+ const lastUpdated = recent?.time?.created ?? null
250
+ const sessionLabel = opts.sessionMeta?.title ?? opts.sessionId
251
+ const agent = recent?.agent ?? "unknown"
252
+
253
+ // Scan recent messages for any in-flight tool parts
254
+ let activeTool: { tool: string; status: string } | null = null
255
+ const recentMetas = readRecentMessageMetas(messageDir, 200)
256
+
257
+ // Iterate newest → oldest, early-exit on first tool part with pending/running status
258
+ for (const meta of recentMetas) {
259
+ const toolPart = readLastToolPart(opts.storage.part, meta.id)
260
+ if (toolPart && (toolPart.status === "pending" || toolPart.status === "running")) {
261
+ activeTool = toolPart
262
+ break
263
+ }
264
+ }
265
+
266
+ let status: MainSessionView["status"] = "unknown"
267
+ if (activeTool?.status === "pending" || activeTool?.status === "running") {
268
+ status = "running_tool"
269
+ } else if (recent?.role === "assistant" && typeof recent?.time?.created === "number" && typeof recent?.time?.completed !== "number") {
270
+ status = "thinking"
271
+ } else if (typeof lastUpdated === "number") {
272
+ // Use freshness window fallback exactly as today ONLY when no active tool is found
273
+ status = nowMs - lastUpdated <= 15_000 ? "busy" : "idle"
274
+ }
275
+
276
+ return {
277
+ agent,
278
+ currentTool: activeTool?.tool ?? null,
279
+ lastUpdated,
280
+ sessionLabel,
281
+ status,
282
+ }
283
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from "react";
2
+ import ReactDOM from "react-dom/client";
3
+ import App from "./App";
4
+ import "./styles.css";
5
+
6
+ ReactDOM.createRoot(document.getElementById("root")!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { createApi } from "./api"
3
+
4
+ describe('API Routes', () => {
5
+ it('should return health check', async () => {
6
+ const api = createApi({
7
+ getSnapshot: () => ({
8
+ mainSession: { agent: "x", currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
9
+ planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started" },
10
+ backgroundTasks: [],
11
+ raw: null,
12
+ }),
13
+ })
14
+
15
+ const res = await api.request("/health")
16
+ expect(res.status).toBe(200)
17
+ expect(await res.json()).toEqual({ ok: true })
18
+ })
19
+
20
+ it('should return dashboard data without sensitive keys', async () => {
21
+ const api = createApi({
22
+ getSnapshot: () => ({
23
+ mainSession: { agent: "x", currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
24
+ planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started" },
25
+ backgroundTasks: [{ id: "1", description: "d", agent: "a", status: "queued", toolCalls: 0, lastTool: "-", timeline: "" }],
26
+ raw: { ok: true },
27
+ }),
28
+ })
29
+
30
+ const res = await api.request("/dashboard")
31
+ expect(res.status).toBe(200)
32
+
33
+ const data = await res.json()
34
+
35
+ expect(data).toHaveProperty("mainSession")
36
+ expect(data).toHaveProperty("planProgress")
37
+ expect(data).toHaveProperty("backgroundTasks")
38
+ expect(data).toHaveProperty("raw")
39
+
40
+ const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
41
+
42
+ const checkForSensitiveKeys = (obj: any): boolean => {
43
+ if (typeof obj !== 'object' || obj === null) {
44
+ return false;
45
+ }
46
+
47
+ for (const key of Object.keys(obj)) {
48
+ if (sensitiveKeys.includes(key)) {
49
+ return true;
50
+ }
51
+ if (typeof obj[key] === 'object' && obj[key] !== null) {
52
+ if (checkForSensitiveKeys(obj[key])) {
53
+ return true;
54
+ }
55
+ }
56
+ }
57
+ return false;
58
+ };
59
+
60
+ expect(checkForSensitiveKeys(data)).toBe(false)
61
+ })
62
+ })
@@ -0,0 +1,16 @@
1
+ import { Hono } from "hono"
2
+ import type { DashboardStore } from "./dashboard"
3
+
4
+ export function createApi(store: DashboardStore): Hono {
5
+ const api = new Hono()
6
+
7
+ api.get("/health", (c) => {
8
+ return c.json({ ok: true })
9
+ })
10
+
11
+ api.get("/dashboard", (c) => {
12
+ return c.json(store.getSnapshot())
13
+ })
14
+
15
+ return api
16
+ }
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+
3
+ console.log("API build complete (no-op)")
4
+
5
+ process.exit(0)