oh-my-opencode-dashboard 0.0.4 → 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.
@@ -1,24 +1,106 @@
1
+ import * as fs from "node:fs"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
1
4
  import { describe, it, expect } from "vitest"
2
5
  import { createApi } from "./api"
6
+ import type { DashboardPayload, DashboardStore } from "./dashboard"
7
+ import type { PlanStep } from "../ingest/boulder"
8
+ import type { TimeSeriesPayload } from "../ingest/timeseries"
9
+
10
+ function mkStorageRoot(): string {
11
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "omo-dashboard-storage-"))
12
+ fs.mkdirSync(path.join(root, "session"), { recursive: true })
13
+ fs.mkdirSync(path.join(root, "message"), { recursive: true })
14
+ fs.mkdirSync(path.join(root, "part"), { recursive: true })
15
+ return root
16
+ }
17
+
18
+ function mkProjectRoot(): string {
19
+ return fs.mkdtempSync(path.join(os.tmpdir(), "omo-dashboard-project-"))
20
+ }
21
+
22
+ function writeMessageMeta(opts: {
23
+ storageRoot: string
24
+ sessionId: string
25
+ messageId: string
26
+ created?: number
27
+ }): void {
28
+ const msgDir = path.join(opts.storageRoot, "message", opts.sessionId)
29
+ fs.mkdirSync(msgDir, { recursive: true })
30
+ const meta: Record<string, unknown> = {
31
+ id: opts.messageId,
32
+ sessionID: opts.sessionId,
33
+ role: "assistant",
34
+ }
35
+ if (typeof opts.created === "number") {
36
+ meta.time = { created: opts.created }
37
+ }
38
+ fs.writeFileSync(path.join(msgDir, `${opts.messageId}.json`), JSON.stringify(meta), "utf8")
39
+ }
40
+
41
+ function writeToolPart(opts: {
42
+ storageRoot: string
43
+ sessionId: string
44
+ messageId: string
45
+ callId: string
46
+ tool: string
47
+ state?: Record<string, unknown>
48
+ }): void {
49
+ const partDir = path.join(opts.storageRoot, "part", opts.messageId)
50
+ fs.mkdirSync(partDir, { recursive: true })
51
+ fs.writeFileSync(
52
+ path.join(partDir, `${opts.callId}.json`),
53
+ JSON.stringify({
54
+ id: `part_${opts.callId}`,
55
+ sessionID: opts.sessionId,
56
+ messageID: opts.messageId,
57
+ type: "tool",
58
+ callID: opts.callId,
59
+ tool: opts.tool,
60
+ state: opts.state ?? { status: "completed", input: {} },
61
+ }),
62
+ "utf8"
63
+ )
64
+ }
65
+
66
+ const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
67
+
68
+ function hasSensitiveKeys(value: unknown): boolean {
69
+ if (!value || typeof value !== "object") return false
70
+ if (Array.isArray(value)) {
71
+ return value.some((item) => hasSensitiveKeys(item))
72
+ }
73
+ for (const [key, child] of Object.entries(value)) {
74
+ if (sensitiveKeys.includes(key)) return true
75
+ if (hasSensitiveKeys(child)) return true
76
+ }
77
+ return false
78
+ }
79
+
80
+ const createStore = (): DashboardStore => ({
81
+ getSnapshot: (): DashboardPayload => ({
82
+ mainSession: { agent: "x", currentModel: null, currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
83
+ planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started", steps: [] as PlanStep[] },
84
+ backgroundTasks: [],
85
+ mainSessionTasks: [],
86
+ timeSeries: {
87
+ windowMs: 0,
88
+ bucketMs: 0,
89
+ buckets: 0,
90
+ anchorMs: 0,
91
+ serverNowMs: 0,
92
+ series: [{ id: "overall-main", label: "Overall", tone: "muted" as const, values: [] as number[] }],
93
+ },
94
+ raw: null,
95
+ }),
96
+ } satisfies DashboardStore)
3
97
 
4
98
  describe('API Routes', () => {
5
99
  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", steps: [] },
10
- backgroundTasks: [],
11
- timeSeries: {
12
- windowMs: 0,
13
- bucketMs: 0,
14
- buckets: 0,
15
- anchorMs: 0,
16
- serverNowMs: 0,
17
- series: [{ id: "overall-main", label: "Overall", tone: "muted", values: [] }],
18
- },
19
- raw: null,
20
- }),
21
- })
100
+ const storageRoot = mkStorageRoot()
101
+ const projectRoot = mkProjectRoot()
102
+ const store = createStore()
103
+ const api = createApi({ store, storageRoot, projectRoot })
22
104
 
23
105
  const res = await api.request("/health")
24
106
  expect(res.status).toBe(200)
@@ -26,22 +108,10 @@ describe('API Routes', () => {
26
108
  })
27
109
 
28
110
  it('should return dashboard data without sensitive keys', async () => {
29
- const api = createApi({
30
- getSnapshot: () => ({
31
- mainSession: { agent: "x", currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
32
- planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started", steps: [] },
33
- backgroundTasks: [{ id: "1", description: "d", agent: "a", status: "queued", toolCalls: 0, lastTool: "-", timeline: "" }],
34
- timeSeries: {
35
- windowMs: 0,
36
- bucketMs: 0,
37
- buckets: 0,
38
- anchorMs: 0,
39
- serverNowMs: 0,
40
- series: [{ id: "overall-main", label: "Overall", tone: "muted", values: [] }],
41
- },
42
- raw: { ok: true },
43
- }),
44
- })
111
+ const storageRoot = mkStorageRoot()
112
+ const projectRoot = mkProjectRoot()
113
+ const store = createStore()
114
+ const api = createApi({ store, storageRoot, projectRoot })
45
115
 
46
116
  const res = await api.request("/dashboard")
47
117
  expect(res.status).toBe(200)
@@ -54,26 +124,78 @@ describe('API Routes', () => {
54
124
  expect(data).toHaveProperty("timeSeries")
55
125
  expect(data).toHaveProperty("raw")
56
126
 
57
- const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
58
-
59
- const checkForSensitiveKeys = (obj: any): boolean => {
60
- if (typeof obj !== 'object' || obj === null) {
61
- return false;
62
- }
63
-
64
- for (const key of Object.keys(obj)) {
65
- if (sensitiveKeys.includes(key)) {
66
- return true;
67
- }
68
- if (typeof obj[key] === 'object' && obj[key] !== null) {
69
- if (checkForSensitiveKeys(obj[key])) {
70
- return true;
71
- }
72
- }
73
- }
74
- return false;
75
- };
76
-
77
- expect(checkForSensitiveKeys(data)).toBe(false)
127
+ expect(hasSensitiveKeys(data)).toBe(false)
128
+ })
129
+
130
+ it('should reject invalid session IDs', async () => {
131
+ const storageRoot = mkStorageRoot()
132
+ const projectRoot = mkProjectRoot()
133
+ const store = createStore()
134
+ const api = createApi({ store, storageRoot, projectRoot })
135
+
136
+ const res = await api.request("/tool-calls/not_valid!")
137
+ expect(res.status).toBe(400)
138
+ expect(await res.json()).toEqual({ ok: false, sessionId: "not_valid!", toolCalls: [] })
78
139
  })
140
+
141
+ it('should return 404 for missing sessions', async () => {
142
+ const storageRoot = mkStorageRoot()
143
+ const projectRoot = mkProjectRoot()
144
+ const store = createStore()
145
+ const api = createApi({ store, storageRoot, projectRoot })
146
+
147
+ const res = await api.request("/tool-calls/ses_missing")
148
+ expect(res.status).toBe(404)
149
+ expect(await res.json()).toEqual({ ok: false, sessionId: "ses_missing", toolCalls: [] })
150
+ })
151
+
152
+ it('should return empty tool calls for existing sessions', async () => {
153
+ const storageRoot = mkStorageRoot()
154
+ const projectRoot = mkProjectRoot()
155
+ writeMessageMeta({ storageRoot, sessionId: "ses_empty", messageId: "msg_1", created: 1000 })
156
+ const store = createStore()
157
+ const api = createApi({ store, storageRoot, projectRoot })
158
+
159
+ const res = await api.request("/tool-calls/ses_empty")
160
+ expect(res.status).toBe(200)
161
+
162
+ const data = await res.json()
163
+ expect(data.ok).toBe(true)
164
+ expect(data.sessionId).toBe("ses_empty")
165
+ expect(data.toolCalls).toEqual([])
166
+ expect(data.caps).toEqual({ maxMessages: 200, maxToolCalls: 300 })
167
+ expect(data.truncated).toBe(false)
168
+ expect(hasSensitiveKeys(data)).toBe(false)
169
+ })
170
+
171
+ it('should redact tool call payload fields', async () => {
172
+ const storageRoot = mkStorageRoot()
173
+ const projectRoot = mkProjectRoot()
174
+ writeMessageMeta({ storageRoot, sessionId: "ses_redact", messageId: "msg_1", created: 1000 })
175
+ writeToolPart({
176
+ storageRoot,
177
+ sessionId: "ses_redact",
178
+ messageId: "msg_1",
179
+ callId: "call_1",
180
+ tool: "bash",
181
+ state: {
182
+ status: "completed",
183
+ input: { prompt: "SECRET", nested: { output: "HIDDEN" } },
184
+ output: "NOPE",
185
+ error: "NOPE",
186
+ },
187
+ })
188
+ const store = createStore()
189
+ const api = createApi({ store, storageRoot, projectRoot })
190
+
191
+ const res = await api.request("/tool-calls/ses_redact")
192
+ expect(res.status).toBe(200)
193
+
194
+ const data = await res.json()
195
+ expect(data.ok).toBe(true)
196
+ expect(data.toolCalls.length).toBe(1)
197
+ expect(hasSensitiveKeys(data)).toBe(false)
198
+ })
199
+
200
+ // /sessions was intentionally removed along with the manual session picker.
79
201
  })
package/src/server/api.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import { Hono } from "hono"
2
2
  import type { DashboardStore } from "./dashboard"
3
+ import { assertAllowedPath } from "../ingest/paths"
4
+ import { getMessageDir, getStorageRoots } from "../ingest/session"
5
+ import { deriveToolCalls, MAX_TOOL_CALL_MESSAGES, MAX_TOOL_CALLS } from "../ingest/tool-calls"
3
6
 
4
- export function createApi(store: DashboardStore): Hono {
7
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
8
+
9
+ export function createApi(opts: { store: DashboardStore; storageRoot: string; projectRoot: string }): Hono {
5
10
  const api = new Hono()
6
11
 
7
12
  api.get("/health", (c) => {
@@ -9,7 +14,39 @@ export function createApi(store: DashboardStore): Hono {
9
14
  })
10
15
 
11
16
  api.get("/dashboard", (c) => {
12
- return c.json(store.getSnapshot())
17
+ return c.json(opts.store.getSnapshot())
18
+ })
19
+
20
+ api.get("/tool-calls/:sessionId", (c) => {
21
+ const sessionId = c.req.param("sessionId")
22
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
23
+ return c.json({ ok: false, sessionId, toolCalls: [] }, 400)
24
+ }
25
+
26
+ const storage = getStorageRoots(opts.storageRoot)
27
+ const messageDir = getMessageDir(storage.message, sessionId)
28
+ if (!messageDir) {
29
+ return c.json({ ok: false, sessionId, toolCalls: [] }, 404)
30
+ }
31
+
32
+ assertAllowedPath({ candidatePath: messageDir, allowedRoots: [opts.storageRoot] })
33
+
34
+ const { toolCalls, truncated } = deriveToolCalls({
35
+ storage,
36
+ sessionId,
37
+ allowedRoots: [opts.storageRoot],
38
+ })
39
+
40
+ return c.json({
41
+ ok: true,
42
+ sessionId,
43
+ toolCalls,
44
+ caps: {
45
+ maxMessages: MAX_TOOL_CALL_MESSAGES,
46
+ maxToolCalls: MAX_TOOL_CALLS,
47
+ },
48
+ truncated,
49
+ })
13
50
  })
14
51
 
15
52
  return api
@@ -76,9 +76,49 @@ describe("buildDashboardPayload", () => {
76
76
  expect(payload.mainSession.currentTool).toBe("delegate_task")
77
77
  expect(payload.mainSession.agent).toBe("sisyphus")
78
78
  expect(payload.mainSession.currentModel).toBeNull()
79
+ expect(payload.mainSession.sessionId).toBe(sessionId)
79
80
 
80
81
  expect(payload.raw).not.toHaveProperty("prompt")
81
82
  expect(payload.raw).not.toHaveProperty("input")
83
+
84
+ expect(payload).toHaveProperty("mainSessionTasks")
85
+ expect((payload as any).mainSessionTasks).toEqual([
86
+ {
87
+ id: "main-session",
88
+ description: "Main session",
89
+ subline: sessionId,
90
+ agent: "sisyphus",
91
+ lastModel: null,
92
+ status: "running",
93
+ toolCalls: 1,
94
+ lastTool: "delegate_task",
95
+ timeline: "1970-01-01T00:00:01Z: 1s",
96
+ sessionId,
97
+ },
98
+ ])
99
+
100
+ expect(payload.raw).toHaveProperty("mainSessionTasks.0.lastTool", "delegate_task")
101
+ } finally {
102
+ fs.rmSync(storageRoot, { recursive: true, force: true })
103
+ fs.rmSync(projectRoot, { recursive: true, force: true })
104
+ }
105
+ })
106
+
107
+ it("includes mainSessionTasks in raw payload when no sessions exist", () => {
108
+ const storageRoot = mkStorageRoot()
109
+ const storage = getStorageRoots(storageRoot)
110
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
111
+
112
+ try {
113
+ const payload = buildDashboardPayload({
114
+ projectRoot,
115
+ storage,
116
+ nowMs: 2000,
117
+ })
118
+
119
+ expect(payload).toHaveProperty("mainSessionTasks")
120
+ expect((payload as any).mainSessionTasks).toEqual([])
121
+ expect(payload.raw).toHaveProperty("mainSessionTasks")
82
122
  } finally {
83
123
  fs.rmSync(storageRoot, { recursive: true, force: true })
84
124
  fs.rmSync(projectRoot, { recursive: true, force: true })
@@ -149,6 +189,7 @@ describe("buildDashboardPayload", () => {
149
189
  expect(payload).toHaveProperty("timeSeries")
150
190
  expect(payload.raw).toHaveProperty("timeSeries")
151
191
  expect(payload.mainSession.currentModel).toBeNull()
192
+ expect(payload.mainSession.sessionId).toBeNull()
152
193
 
153
194
  const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
154
195
 
@@ -4,6 +4,7 @@ import { readBoulderState, readPlanProgress, readPlanSteps, type PlanStep } from
4
4
  import { deriveBackgroundTasks } from "../ingest/background-tasks"
5
5
  import { deriveTimeSeriesActivity, type TimeSeriesPayload } from "../ingest/timeseries"
6
6
  import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type MainSessionView, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
7
+ import { deriveToolCalls } from "../ingest/tool-calls"
7
8
 
8
9
  export type DashboardPayload = {
9
10
  mainSession: {
@@ -12,6 +13,7 @@ export type DashboardPayload = {
12
13
  currentTool: string
13
14
  lastUpdatedLabel: string
14
15
  session: string
16
+ sessionId: string | null
15
17
  statusPill: string
16
18
  }
17
19
  planProgress: {
@@ -31,6 +33,19 @@ export type DashboardPayload = {
31
33
  toolCalls: number
32
34
  lastTool: string
33
35
  timeline: string
36
+ sessionId: string | null
37
+ }>
38
+ mainSessionTasks: Array<{
39
+ id: string
40
+ description: string
41
+ subline?: string
42
+ agent: string
43
+ lastModel: string | null
44
+ status: string
45
+ toolCalls: number
46
+ lastTool: string
47
+ timeline: string
48
+ sessionId: string | null
34
49
  }>
35
50
  timeSeries: TimeSeriesPayload
36
51
  raw: unknown
@@ -91,6 +106,33 @@ function mainStatusPill(status: string): string {
91
106
  return "unknown"
92
107
  }
93
108
 
109
+ function formatIsoNoMs(ts: number): string {
110
+ const iso = new Date(ts).toISOString()
111
+ return iso.replace(/\.\d{3}Z$/, "Z")
112
+ }
113
+
114
+ function formatElapsed(ms: number): string {
115
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000))
116
+ const seconds = totalSeconds % 60
117
+ const totalMinutes = Math.floor(totalSeconds / 60)
118
+ const minutes = totalMinutes % 60
119
+ const totalHours = Math.floor(totalMinutes / 60)
120
+ const hours = totalHours % 24
121
+ const days = Math.floor(totalHours / 24)
122
+
123
+ if (days > 0) return hours > 0 ? `${days}d${hours}h` : `${days}d`
124
+ if (totalHours > 0) return minutes > 0 ? `${totalHours}h${minutes}m` : `${totalHours}h`
125
+ if (totalMinutes > 0) return seconds > 0 ? `${totalMinutes}m${seconds}s` : `${totalMinutes}m`
126
+ return `${seconds}s`
127
+ }
128
+
129
+ function formatTimeline(startAt: number | null, endAtMs: number): string {
130
+ if (typeof startAt !== "number") return ""
131
+ const start = formatIsoNoMs(startAt)
132
+ const elapsed = formatElapsed(endAtMs - startAt)
133
+ return `${start}: ${elapsed}`
134
+ }
135
+
94
136
  export function buildDashboardPayload(opts: {
95
137
  projectRoot: string
96
138
  storage: OpenCodeStorageRoots
@@ -135,6 +177,41 @@ export function buildDashboardPayload(opts: {
135
177
  const mainCurrentModel = "currentModel" in main
136
178
  ? (main as MainSessionView).currentModel
137
179
  : null
180
+
181
+ const mainSessionTasks = (() => {
182
+ if (!sessionId) return []
183
+
184
+ const mainStatus = main.status
185
+ const status = mainStatus === "running_tool" || mainStatus === "thinking" || mainStatus === "busy"
186
+ ? "running"
187
+ : mainStatus === "idle"
188
+ ? "idle"
189
+ : "unknown"
190
+
191
+ const { toolCalls } = deriveToolCalls({
192
+ storage: opts.storage,
193
+ sessionId,
194
+ })
195
+
196
+ const startAt = sessionMeta?.time?.created ?? null
197
+ const endAtMs = status === "running" ? nowMs : (main.lastUpdated ?? nowMs)
198
+
199
+ return [
200
+ {
201
+ id: "main-session",
202
+ description: "Main session",
203
+ subline: sessionId,
204
+ agent: main.agent,
205
+ lastModel: mainCurrentModel,
206
+ status,
207
+ toolCalls: toolCalls.length,
208
+ lastTool: toolCalls[0]?.tool ?? "-",
209
+ timeline: formatTimeline(startAt, endAtMs),
210
+ sessionId,
211
+ },
212
+ ]
213
+ })()
214
+
138
215
  const payload: DashboardPayload = {
139
216
  mainSession: {
140
217
  agent: main.agent,
@@ -142,6 +219,7 @@ export function buildDashboardPayload(opts: {
142
219
  currentTool: main.currentTool ?? "-",
143
220
  lastUpdatedLabel: formatIso(main.lastUpdated),
144
221
  session: main.sessionLabel,
222
+ sessionId: sessionId ?? null,
145
223
  statusPill: mainStatusPill(main.status),
146
224
  },
147
225
  planProgress: {
@@ -161,7 +239,9 @@ export function buildDashboardPayload(opts: {
161
239
  toolCalls: t.toolCalls ?? 0,
162
240
  lastTool: t.lastTool ?? "-",
163
241
  timeline: typeof t.timeline === "string" ? t.timeline : "",
242
+ sessionId: t.sessionId ?? null,
164
243
  })),
244
+ mainSessionTasks,
165
245
  timeSeries,
166
246
  raw: null,
167
247
  }
@@ -170,6 +250,7 @@ export function buildDashboardPayload(opts: {
170
250
  mainSession: payload.mainSession,
171
251
  planProgress: payload.planProgress,
172
252
  backgroundTasks: payload.backgroundTasks,
253
+ mainSessionTasks: payload.mainSessionTasks,
173
254
  timeSeries: payload.timeSeries,
174
255
  }
175
256
  return payload
package/src/server/dev.ts CHANGED
@@ -26,14 +26,16 @@ const resolvedProjectPath = projectPath ?? process.cwd()
26
26
 
27
27
  const app = new Hono()
28
28
 
29
+ const storageRoot = getOpenCodeStorageDir()
30
+
29
31
  const store = createDashboardStore({
30
32
  projectRoot: resolvedProjectPath,
31
- storageRoot: getOpenCodeStorageDir(),
33
+ storageRoot,
32
34
  watch: true,
33
35
  pollIntervalMs: 2000,
34
36
  })
35
37
 
36
- app.route("/api", createApi(store))
38
+ app.route("/api", createApi({ store, storageRoot, projectRoot: resolvedProjectPath }))
37
39
 
38
40
  Bun.serve({
39
41
  fetch: app.fetch,
@@ -21,14 +21,16 @@ const port = parseInt(values.port || '51234')
21
21
 
22
22
  const app = new Hono()
23
23
 
24
+ const storageRoot = getOpenCodeStorageDir()
25
+
24
26
  const store = createDashboardStore({
25
27
  projectRoot: project,
26
- storageRoot: getOpenCodeStorageDir(),
28
+ storageRoot,
27
29
  watch: true,
28
30
  pollIntervalMs: 2000,
29
31
  })
30
32
 
31
- app.route('/api', createApi(store))
33
+ app.route('/api', createApi({ store, storageRoot, projectRoot: project }))
32
34
 
33
35
  const distRoot = join(import.meta.dir, '../../dist')
34
36