oh-my-opencode-dashboard 0.0.1 → 0.0.2

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,289 @@
1
+ import * as fs from "node:fs"
2
+ import * as path from "node:path"
3
+ import type { OpenCodeStorageRoots, StoredMessageMeta } from "./session"
4
+ import { getMessageDir } from "./session"
5
+ import { readAllSessionMetas } from "./background-tasks"
6
+
7
+ export type TimeSeriesTone = "muted" | "teal" | "red" | "green"
8
+
9
+ export type TimeSeriesSeries = {
10
+ id: string
11
+ label: string
12
+ tone: TimeSeriesTone
13
+ values: number[]
14
+ }
15
+
16
+ export type TimeSeriesPayload = {
17
+ windowMs: number
18
+ bucketMs: number
19
+ buckets: number
20
+ anchorMs: number
21
+ serverNowMs: number
22
+ series: TimeSeriesSeries[]
23
+ }
24
+
25
+ type CanonicalAgent = "sisyphus" | "prometheus" | "atlas" | "other"
26
+
27
+ const SERIES_ORDER: Array<Pick<TimeSeriesSeries, "id" | "label" | "tone">> = [
28
+ { id: "overall-main", label: "Overall", tone: "muted" },
29
+ { id: "agent:sisyphus", label: "Sisyphus", tone: "teal" },
30
+ { id: "agent:prometheus", label: "Prometheus", tone: "red" },
31
+ { id: "agent:atlas", label: "Atlas", tone: "green" },
32
+ { id: "background-total", label: "Background tasks (total)", tone: "muted" },
33
+ ]
34
+
35
+ function zeroBuckets(size: number): number[] {
36
+ return Array.from({ length: size }, () => 0)
37
+ }
38
+
39
+ function listJsonFiles(dir: string): string[] {
40
+ try {
41
+ return fs.readdirSync(dir).filter((f) => f.endsWith(".json"))
42
+ } catch {
43
+ return []
44
+ }
45
+ }
46
+
47
+ function readJsonFile<T>(filePath: string): T | null {
48
+ try {
49
+ const content = fs.readFileSync(filePath, "utf8")
50
+ return JSON.parse(content) as T
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
56
+ function readRecentMessageMetas(messageDir: string, maxMessages: number): StoredMessageMeta[] {
57
+ if (!messageDir || !fs.existsSync(messageDir)) return []
58
+ const ranked = listJsonFiles(messageDir)
59
+ .map((f) => ({
60
+ f,
61
+ mtime: (() => {
62
+ try {
63
+ return fs.statSync(path.join(messageDir, f)).mtimeMs
64
+ } catch {
65
+ return 0
66
+ }
67
+ })(),
68
+ }))
69
+ .sort((a, b) => b.mtime - a.mtime)
70
+ .slice(0, maxMessages)
71
+
72
+ const metas: StoredMessageMeta[] = []
73
+ for (const item of ranked) {
74
+ const meta = readJsonFile<StoredMessageMeta>(path.join(messageDir, item.f))
75
+ if (meta && typeof meta.id === "string") metas.push(meta)
76
+ }
77
+ return metas
78
+ }
79
+
80
+ function countToolParts(partStorage: string, messageId: string): number {
81
+ const partDir = path.join(partStorage, messageId)
82
+ if (!fs.existsSync(partDir)) return 0
83
+
84
+ let count = 0
85
+ const files = listJsonFiles(partDir).sort()
86
+ for (const file of files) {
87
+ const part = readJsonFile<{ type?: string }>(path.join(partDir, file))
88
+ if (part && part.type === "tool") count += 1
89
+ }
90
+ return count
91
+ }
92
+
93
+ function canonicalizeAgent(agent: unknown): CanonicalAgent {
94
+ if (typeof agent !== "string") return "other"
95
+ const trimmed = agent.trim()
96
+ if (!trimmed) return "other"
97
+ const lowered = trimmed.toLowerCase()
98
+ if (lowered.startsWith("sisyphus-junior")) return "sisyphus"
99
+ if (lowered.startsWith("sisyphus")) return "sisyphus"
100
+ if (lowered.startsWith("prometheus")) return "prometheus"
101
+ if (lowered.startsWith("atlas")) return "atlas"
102
+ return "other"
103
+ }
104
+
105
+ function addToBucket(values: number[], bucketIndex: number, count: number): void {
106
+ if (bucketIndex < 0 || bucketIndex >= values.length) return
107
+ values[bucketIndex] += count
108
+ }
109
+
110
+ function getCreated(meta: StoredMessageMeta): number {
111
+ const created = meta.time?.created
112
+ return typeof created === "number" ? created : -Infinity
113
+ }
114
+
115
+ function bucketMessageTools(opts: {
116
+ storage: OpenCodeStorageRoots
117
+ messageDir: string
118
+ startMs: number
119
+ anchorMs: number
120
+ bucketMs: number
121
+ overall: number[]
122
+ perAgent?: Record<Exclude<CanonicalAgent, "other">, number[]>
123
+ }): void {
124
+ const metas = readRecentMessageMetas(opts.messageDir, 200)
125
+ const ordered = [...metas].sort((a, b) => {
126
+ const at = getCreated(a)
127
+ const bt = getCreated(b)
128
+ if (bt !== at) return bt - at
129
+ return String(a.id).localeCompare(String(b.id))
130
+ })
131
+
132
+ for (const meta of ordered) {
133
+ const created = getCreated(meta)
134
+ if (created < opts.startMs) break
135
+ if (created >= opts.anchorMs) continue
136
+
137
+ const bucketIndex = Math.floor((created - opts.startMs) / opts.bucketMs)
138
+ const toolCount = countToolParts(opts.storage.part, meta.id)
139
+ if (toolCount <= 0) continue
140
+
141
+ addToBucket(opts.overall, bucketIndex, toolCount)
142
+ const perAgent = opts.perAgent
143
+ if (perAgent) {
144
+ const agent = canonicalizeAgent(meta.agent)
145
+ if (agent === "sisyphus" || agent === "prometheus" || agent === "atlas") {
146
+ addToBucket(perAgent[agent], bucketIndex, toolCount)
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ function bucketBackgroundTools(opts: {
153
+ storage: OpenCodeStorageRoots
154
+ sessionIds: string[]
155
+ startMs: number
156
+ anchorMs: number
157
+ bucketMs: number
158
+ output: number[]
159
+ }): void {
160
+ for (const sessionId of opts.sessionIds) {
161
+ const messageDir = getMessageDir(opts.storage.message, sessionId)
162
+ const metas = readRecentMessageMetas(messageDir, 200)
163
+ const ordered = [...metas].sort((a, b) => {
164
+ const at = getCreated(a)
165
+ const bt = getCreated(b)
166
+ if (bt !== at) return bt - at
167
+ return String(a.id).localeCompare(String(b.id))
168
+ })
169
+
170
+ for (const meta of ordered) {
171
+ const created = getCreated(meta)
172
+ if (created < opts.startMs) break
173
+ if (created >= opts.anchorMs) continue
174
+
175
+ const bucketIndex = Math.floor((created - opts.startMs) / opts.bucketMs)
176
+ const toolCount = countToolParts(opts.storage.part, meta.id)
177
+ if (toolCount <= 0) continue
178
+ addToBucket(opts.output, bucketIndex, toolCount)
179
+ }
180
+ }
181
+ }
182
+
183
+ function bucketSessionAgents(opts: {
184
+ storage: OpenCodeStorageRoots
185
+ sessionIds: string[]
186
+ startMs: number
187
+ anchorMs: number
188
+ bucketMs: number
189
+ overall: number[]
190
+ perAgent: Record<Exclude<CanonicalAgent, "other">, number[]>
191
+ }): void {
192
+ for (const sessionId of opts.sessionIds) {
193
+ const messageDir = getMessageDir(opts.storage.message, sessionId)
194
+ if (!messageDir) continue
195
+ bucketMessageTools({
196
+ storage: opts.storage,
197
+ messageDir,
198
+ startMs: opts.startMs,
199
+ anchorMs: opts.anchorMs,
200
+ bucketMs: opts.bucketMs,
201
+ overall: opts.overall,
202
+ // Background sessions are owned by Sisyphus; don't smear activity into Prometheus/Atlas.
203
+ perAgent: undefined,
204
+ })
205
+ }
206
+ }
207
+
208
+ export function deriveTimeSeriesActivity(opts: {
209
+ storage: OpenCodeStorageRoots
210
+ mainSessionId: string | null
211
+ nowMs?: number
212
+ windowMs?: number
213
+ bucketMs?: number
214
+ }): TimeSeriesPayload {
215
+ const windowMs = opts.windowMs ?? 300_000
216
+ const bucketMs = opts.bucketMs ?? 2_000
217
+ const buckets = Math.floor(windowMs / bucketMs)
218
+ const nowMs = opts.nowMs ?? Date.now()
219
+ const anchorMs = Math.floor(nowMs / bucketMs) * bucketMs
220
+ const startMs = anchorMs - windowMs
221
+
222
+ const overall = zeroBuckets(buckets)
223
+ const sisyphus = zeroBuckets(buckets)
224
+ const prometheus = zeroBuckets(buckets)
225
+ const atlas = zeroBuckets(buckets)
226
+ const background = zeroBuckets(buckets)
227
+
228
+ const mainSessionId = opts.mainSessionId
229
+ if (mainSessionId) {
230
+ const messageDir = getMessageDir(opts.storage.message, mainSessionId)
231
+ if (messageDir) {
232
+ bucketMessageTools({
233
+ storage: opts.storage,
234
+ messageDir,
235
+ startMs,
236
+ anchorMs,
237
+ bucketMs,
238
+ overall,
239
+ perAgent: { sisyphus, prometheus, atlas },
240
+ })
241
+ }
242
+
243
+ const childSessions = readAllSessionMetas(opts.storage.session)
244
+ .filter((meta) => meta.parentID === mainSessionId)
245
+ .sort((a, b) => {
246
+ const at = a.time?.updated ?? 0
247
+ const bt = b.time?.updated ?? 0
248
+ if (bt !== at) return bt - at
249
+ return String(a.id).localeCompare(String(b.id))
250
+ })
251
+ .slice(0, 25)
252
+ .map((meta) => meta.id)
253
+
254
+ if (childSessions.length > 0) {
255
+ bucketBackgroundTools({
256
+ storage: opts.storage,
257
+ sessionIds: childSessions,
258
+ startMs,
259
+ anchorMs,
260
+ bucketMs,
261
+ output: background,
262
+ })
263
+ bucketSessionAgents({
264
+ storage: opts.storage,
265
+ sessionIds: childSessions,
266
+ startMs,
267
+ anchorMs,
268
+ bucketMs,
269
+ overall,
270
+ perAgent: { sisyphus, prometheus, atlas },
271
+ })
272
+ }
273
+ }
274
+
275
+ return {
276
+ windowMs,
277
+ bucketMs,
278
+ buckets,
279
+ anchorMs,
280
+ serverNowMs: nowMs,
281
+ series: [
282
+ { ...SERIES_ORDER[0], values: overall },
283
+ { ...SERIES_ORDER[1], values: sisyphus },
284
+ { ...SERIES_ORDER[2], values: prometheus },
285
+ { ...SERIES_ORDER[3], values: atlas },
286
+ { ...SERIES_ORDER[4], values: background },
287
+ ],
288
+ }
289
+ }
@@ -8,6 +8,14 @@ describe('API Routes', () => {
8
8
  mainSession: { agent: "x", currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
9
9
  planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started" },
10
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
+ },
11
19
  raw: null,
12
20
  }),
13
21
  })
@@ -23,6 +31,14 @@ describe('API Routes', () => {
23
31
  mainSession: { agent: "x", currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
24
32
  planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started" },
25
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
+ },
26
42
  raw: { ok: true },
27
43
  }),
28
44
  })
@@ -35,6 +51,7 @@ describe('API Routes', () => {
35
51
  expect(data).toHaveProperty("mainSession")
36
52
  expect(data).toHaveProperty("planProgress")
37
53
  expect(data).toHaveProperty("backgroundTasks")
54
+ expect(data).toHaveProperty("timeSeries")
38
55
  expect(data).toHaveProperty("raw")
39
56
 
40
57
  const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
@@ -132,4 +132,47 @@ describe("buildDashboardPayload", () => {
132
132
  fs.rmSync(projectRoot, { recursive: true, force: true })
133
133
  }
134
134
  })
135
+
136
+ it("includes timeSeries and sanitized raw payload when no sessions exist", () => {
137
+ const storageRoot = mkStorageRoot()
138
+ const storage = getStorageRoots(storageRoot)
139
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
140
+
141
+ try {
142
+ const payload = buildDashboardPayload({
143
+ projectRoot,
144
+ storage,
145
+ nowMs: 2000,
146
+ })
147
+
148
+ expect(payload).toHaveProperty("timeSeries")
149
+ expect(payload.raw).toHaveProperty("timeSeries")
150
+
151
+ const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
152
+
153
+ const hasSensitiveKeys = (value: unknown): boolean => {
154
+ if (typeof value !== "object" || value === null) {
155
+ return false
156
+ }
157
+
158
+ for (const key of Object.keys(value)) {
159
+ if (sensitiveKeys.includes(key)) {
160
+ return true
161
+ }
162
+ const nextValue = (value as Record<string, unknown>)[key]
163
+ if (typeof nextValue === "object" && nextValue !== null) {
164
+ if (hasSensitiveKeys(nextValue)) {
165
+ return true
166
+ }
167
+ }
168
+ }
169
+ return false
170
+ }
171
+
172
+ expect(hasSensitiveKeys(payload.raw)).toBe(false)
173
+ } finally {
174
+ fs.rmSync(storageRoot, { recursive: true, force: true })
175
+ fs.rmSync(projectRoot, { recursive: true, force: true })
176
+ }
177
+ })
135
178
  })
@@ -1,7 +1,8 @@
1
1
  import * as fs from "node:fs"
2
2
  import * as path from "node:path"
3
- import { readBoulderState, readPlanProgress } from "../ingest/boulder"
3
+ import { readBoulderState, readPlanProgress, readPlanSteps, type PlanStep } from "../ingest/boulder"
4
4
  import { deriveBackgroundTasks } from "../ingest/background-tasks"
5
+ import { deriveTimeSeriesActivity, type TimeSeriesPayload } from "../ingest/timeseries"
5
6
  import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
6
7
 
7
8
  export type DashboardPayload = {
@@ -18,6 +19,7 @@ export type DashboardPayload = {
18
19
  total: number
19
20
  path: string
20
21
  statusPill: string
22
+ steps: PlanStep[]
21
23
  }
22
24
  backgroundTasks: Array<{
23
25
  id: string
@@ -28,6 +30,7 @@ export type DashboardPayload = {
28
30
  lastTool: string
29
31
  timeline: string
30
32
  }>
33
+ timeSeries: TimeSeriesPayload
31
34
  raw: unknown
32
35
  }
33
36
 
@@ -68,6 +71,7 @@ export function buildDashboardPayload(opts: {
68
71
  const planName = boulder?.plan_name ?? "(no active plan)"
69
72
  const planPath = boulder?.active_plan ?? ""
70
73
  const plan = boulder ? readPlanProgress(opts.projectRoot, boulder.active_plan) : { total: 0, completed: 0, isComplete: false, missing: true }
74
+ const planSteps = boulder ? readPlanSteps(opts.projectRoot, boulder.active_plan) : { missing: true, steps: [] as PlanStep[] }
71
75
 
72
76
  const sessionId = pickActiveSessionId({
73
77
  projectRoot: opts.projectRoot,
@@ -92,6 +96,11 @@ export function buildDashboardPayload(opts: {
92
96
  : { agent: "unknown", currentTool: null, lastUpdated: null, sessionLabel: "(no session)", status: "unknown" as const }
93
97
 
94
98
  const tasks = sessionId ? deriveBackgroundTasks({ storage: opts.storage, mainSessionId: sessionId, nowMs }) : []
99
+ const timeSeries = deriveTimeSeriesActivity({
100
+ storage: opts.storage,
101
+ mainSessionId: sessionId ?? null,
102
+ nowMs,
103
+ })
95
104
 
96
105
  const payload: DashboardPayload = {
97
106
  mainSession: {
@@ -107,6 +116,7 @@ export function buildDashboardPayload(opts: {
107
116
  total: plan.total,
108
117
  path: planPath,
109
118
  statusPill: planStatusPill(plan),
119
+ steps: planSteps.missing ? [] : planSteps.steps,
110
120
  },
111
121
  backgroundTasks: tasks.map((t) => ({
112
122
  id: t.id,
@@ -117,6 +127,7 @@ export function buildDashboardPayload(opts: {
117
127
  lastTool: t.lastTool ?? "-",
118
128
  timeline: typeof t.timeline === "string" ? t.timeline : "",
119
129
  })),
130
+ timeSeries,
120
131
  raw: null,
121
132
  }
122
133
 
@@ -124,6 +135,7 @@ export function buildDashboardPayload(opts: {
124
135
  mainSession: payload.mainSession,
125
136
  planProgress: payload.planProgress,
126
137
  backgroundTasks: payload.backgroundTasks,
138
+ timeSeries: payload.timeSeries,
127
139
  }
128
140
  return payload
129
141
  }
package/src/sound.test.ts CHANGED
@@ -5,6 +5,7 @@ describe("playDing", () => {
5
5
 
6
6
  afterEach(() => {
7
7
  ;(globalThis as unknown as { window?: unknown }).window = prevWindow
8
+ return import("./sound").then((m) => m.__resetAudioForTests())
8
9
  })
9
10
 
10
11
  it("plays two tones for waiting", async () => {
@@ -52,4 +53,50 @@ describe("playDing", () => {
52
53
 
53
54
  expect(oscillators).toHaveLength(2)
54
55
  })
56
+
57
+ it("plays three tones for question", async () => {
58
+ const oscillators: Array<{ start: ReturnType<typeof vi.fn> }> = []
59
+
60
+ class FakeAudioContext {
61
+ public state: AudioContextState = "suspended"
62
+ public currentTime = 1
63
+ public destination = null as unknown as AudioDestinationNode
64
+
65
+ resume = vi.fn(async () => {
66
+ this.state = "running"
67
+ })
68
+
69
+ createOscillator(): OscillatorNode {
70
+ const osc = {
71
+ type: "sine" as OscillatorType,
72
+ frequency: { setValueAtTime: vi.fn() },
73
+ connect: vi.fn(),
74
+ start: vi.fn(),
75
+ stop: vi.fn(),
76
+ }
77
+ oscillators.push({ start: osc.start })
78
+ return osc as unknown as OscillatorNode
79
+ }
80
+
81
+ createGain(): GainNode {
82
+ const g = {
83
+ gain: {
84
+ setValueAtTime: vi.fn(),
85
+ linearRampToValueAtTime: vi.fn(),
86
+ },
87
+ connect: vi.fn(),
88
+ }
89
+ return g as unknown as GainNode
90
+ }
91
+ }
92
+
93
+ ;(globalThis as unknown as { window?: unknown }).window = {
94
+ AudioContext: FakeAudioContext,
95
+ } as unknown as Window & typeof globalThis
96
+
97
+ const { playDing } = await import("./sound")
98
+ await playDing("question")
99
+
100
+ expect(oscillators).toHaveLength(3)
101
+ })
55
102
  })
package/src/sound.ts CHANGED
@@ -1,7 +1,11 @@
1
- export type DingKind = "waiting" | "task" | "all"
1
+ export type DingKind = "waiting" | "task" | "all" | "question"
2
2
 
3
3
  let ctx: AudioContext | null = null
4
4
 
5
+ export function __resetAudioForTests(): void {
6
+ ctx = null
7
+ }
8
+
5
9
  function getCtx(): AudioContext | null {
6
10
  if (typeof window === "undefined") return null
7
11
  const AnyAudioContext = (window.AudioContext ?? (window as any).webkitAudioContext) as
@@ -82,6 +86,13 @@ export async function playDing(kind: DingKind): Promise<void> {
82
86
  return
83
87
  }
84
88
 
89
+ if (kind === "question") {
90
+ playTone({ at: t0, freq: 988, dur: 0.07, gain: baseGain })
91
+ playTone({ at: t0 + 0.09, freq: 740, dur: 0.11, gain: baseGain })
92
+ playTone({ at: t0 + 0.22, freq: 880, dur: 0.08, gain: baseGain })
93
+ return
94
+ }
95
+
85
96
  // all
86
97
  playTone({ at: t0, freq: 523.25, dur: 0.10, gain: baseGain })
87
98
  playTone({ at: t0 + 0.12, freq: 659.25, dur: 0.10, gain: baseGain })