oh-my-opencode-dashboard 0.0.3 → 0.0.5

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,157 @@
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 { assertAllowedPath } from "./paths"
6
+
7
+ type FsLike = Pick<typeof fs, "readFileSync" | "readdirSync" | "existsSync" | "statSync">
8
+
9
+ export const MAX_TOOL_CALL_MESSAGES = 200
10
+ export const MAX_TOOL_CALLS = 300
11
+
12
+ export type ToolCallSummary = {
13
+ sessionId: string
14
+ messageId: string
15
+ callId: string
16
+ tool: string
17
+ status: "pending" | "running" | "completed" | "error" | "unknown"
18
+ createdAtMs: number | null
19
+ }
20
+
21
+ export type ToolCallSummaryResult = {
22
+ toolCalls: ToolCallSummary[]
23
+ truncated: boolean
24
+ }
25
+
26
+ type StoredToolPartMeta = {
27
+ type?: string
28
+ callID?: string
29
+ tool?: string
30
+ state?: { status?: string }
31
+ }
32
+
33
+ function readJsonFile<T>(filePath: string, fsLike: FsLike): T | null {
34
+ try {
35
+ const content = fsLike.readFileSync(filePath, "utf8")
36
+ return JSON.parse(content) as T
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ function listJsonFiles(dir: string, fsLike: FsLike): string[] {
43
+ try {
44
+ return fsLike.readdirSync(dir).filter((f) => f.endsWith(".json"))
45
+ } catch {
46
+ return []
47
+ }
48
+ }
49
+
50
+ function readRecentMessageMetas(
51
+ messageDir: string,
52
+ maxMessages: number,
53
+ fsLike: FsLike
54
+ ): { metas: StoredMessageMeta[]; totalMessages: number } {
55
+ if (!messageDir || !fsLike.existsSync(messageDir)) return { metas: [], totalMessages: 0 }
56
+ const files = listJsonFiles(messageDir, fsLike)
57
+ const ranked = files
58
+ .map((f) => ({
59
+ f,
60
+ mtime: (() => {
61
+ try {
62
+ return fsLike.statSync(path.join(messageDir, f)).mtimeMs
63
+ } catch {
64
+ return 0
65
+ }
66
+ })(),
67
+ }))
68
+ .sort((a, b) => b.mtime - a.mtime)
69
+ .slice(0, maxMessages)
70
+
71
+ const metas: StoredMessageMeta[] = []
72
+ for (const item of ranked) {
73
+ const meta = readJsonFile<StoredMessageMeta>(path.join(messageDir, item.f), fsLike)
74
+ if (meta && typeof meta.id === "string") metas.push(meta)
75
+ }
76
+ return { metas, totalMessages: files.length }
77
+ }
78
+
79
+ function readToolPartsForMessage(
80
+ partStorage: string,
81
+ messageId: string,
82
+ fsLike: FsLike,
83
+ allowedRoots?: string[]
84
+ ): StoredToolPartMeta[] {
85
+ const partDir = path.join(partStorage, messageId)
86
+ if (allowedRoots && allowedRoots.length > 0) {
87
+ assertAllowedPath({ candidatePath: partDir, allowedRoots })
88
+ }
89
+ if (!fsLike.existsSync(partDir)) return []
90
+
91
+ const files = listJsonFiles(partDir, fsLike).sort()
92
+ const parts: StoredToolPartMeta[] = []
93
+ for (const file of files) {
94
+ const part = readJsonFile<StoredToolPartMeta>(path.join(partDir, file), fsLike)
95
+ if (part && part.type === "tool" && typeof part.tool === "string" && typeof part.callID === "string") {
96
+ parts.push(part)
97
+ }
98
+ }
99
+ return parts
100
+ }
101
+
102
+ function readStatus(value: StoredToolPartMeta["state"]): ToolCallSummary["status"] {
103
+ const status = value?.status
104
+ if (status === "pending" || status === "running" || status === "completed" || status === "error") {
105
+ return status
106
+ }
107
+ return "unknown"
108
+ }
109
+
110
+ export function deriveToolCalls(opts: {
111
+ storage: OpenCodeStorageRoots
112
+ sessionId: string
113
+ fs?: FsLike
114
+ allowedRoots?: string[]
115
+ }): ToolCallSummaryResult {
116
+ const fsLike: FsLike = opts.fs ?? fs
117
+ const messageDir = getMessageDir(opts.storage.message, opts.sessionId)
118
+ if (messageDir && opts.allowedRoots && opts.allowedRoots.length > 0) {
119
+ assertAllowedPath({ candidatePath: messageDir, allowedRoots: opts.allowedRoots })
120
+ }
121
+ const { metas, totalMessages } = readRecentMessageMetas(messageDir, MAX_TOOL_CALL_MESSAGES, fsLike)
122
+ const truncatedByMessages = totalMessages > MAX_TOOL_CALL_MESSAGES
123
+
124
+ const calls: Array<ToolCallSummary & { createdSortKey: number }> = []
125
+ for (const meta of metas) {
126
+ const createdAtMs = typeof meta.time?.created === "number" ? meta.time.created : null
127
+ const createdSortKey = createdAtMs ?? -Infinity
128
+ const parts = readToolPartsForMessage(opts.storage.part, meta.id, fsLike, opts.allowedRoots)
129
+ for (const part of parts) {
130
+ calls.push({
131
+ sessionId: opts.sessionId,
132
+ messageId: meta.id,
133
+ callId: part.callID ?? "",
134
+ tool: part.tool ?? "",
135
+ status: readStatus(part.state),
136
+ createdAtMs,
137
+ createdSortKey,
138
+ })
139
+ }
140
+ }
141
+
142
+ const truncatedByCalls = calls.length > MAX_TOOL_CALLS
143
+ const toolCalls = calls
144
+ .sort((a, b) => {
145
+ if (a.createdSortKey !== b.createdSortKey) return b.createdSortKey - a.createdSortKey
146
+ const messageCompare = String(a.messageId).localeCompare(String(b.messageId))
147
+ if (messageCompare !== 0) return messageCompare
148
+ return String(a.callId).localeCompare(String(b.callId))
149
+ })
150
+ .slice(0, MAX_TOOL_CALLS)
151
+ .map(({ createdSortKey, ...row }) => row)
152
+
153
+ return {
154
+ toolCalls,
155
+ truncated: truncatedByMessages || truncatedByCalls,
156
+ }
157
+ }
@@ -1,24 +1,100 @@
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 writeMessageMeta(opts: {
19
+ storageRoot: string
20
+ sessionId: string
21
+ messageId: string
22
+ created?: number
23
+ }): void {
24
+ const msgDir = path.join(opts.storageRoot, "message", opts.sessionId)
25
+ fs.mkdirSync(msgDir, { recursive: true })
26
+ const meta: Record<string, unknown> = {
27
+ id: opts.messageId,
28
+ sessionID: opts.sessionId,
29
+ role: "assistant",
30
+ }
31
+ if (typeof opts.created === "number") {
32
+ meta.time = { created: opts.created }
33
+ }
34
+ fs.writeFileSync(path.join(msgDir, `${opts.messageId}.json`), JSON.stringify(meta), "utf8")
35
+ }
36
+
37
+ function writeToolPart(opts: {
38
+ storageRoot: string
39
+ sessionId: string
40
+ messageId: string
41
+ callId: string
42
+ tool: string
43
+ state?: Record<string, unknown>
44
+ }): void {
45
+ const partDir = path.join(opts.storageRoot, "part", opts.messageId)
46
+ fs.mkdirSync(partDir, { recursive: true })
47
+ fs.writeFileSync(
48
+ path.join(partDir, `${opts.callId}.json`),
49
+ JSON.stringify({
50
+ id: `part_${opts.callId}`,
51
+ sessionID: opts.sessionId,
52
+ messageID: opts.messageId,
53
+ type: "tool",
54
+ callID: opts.callId,
55
+ tool: opts.tool,
56
+ state: opts.state ?? { status: "completed", input: {} },
57
+ }),
58
+ "utf8"
59
+ )
60
+ }
61
+
62
+ const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
63
+
64
+ function hasSensitiveKeys(value: unknown): boolean {
65
+ if (!value || typeof value !== "object") return false
66
+ if (Array.isArray(value)) {
67
+ return value.some((item) => hasSensitiveKeys(item))
68
+ }
69
+ for (const [key, child] of Object.entries(value)) {
70
+ if (sensitiveKeys.includes(key)) return true
71
+ if (hasSensitiveKeys(child)) return true
72
+ }
73
+ return false
74
+ }
75
+
76
+ const createStore = (): DashboardStore => ({
77
+ getSnapshot: (): DashboardPayload => ({
78
+ mainSession: { agent: "x", currentModel: null, currentTool: "-", lastUpdatedLabel: "never", session: "s", statusPill: "idle" },
79
+ planProgress: { name: "p", completed: 0, total: 0, path: "", statusPill: "not started", steps: [] as PlanStep[] },
80
+ backgroundTasks: [],
81
+ timeSeries: {
82
+ windowMs: 0,
83
+ bucketMs: 0,
84
+ buckets: 0,
85
+ anchorMs: 0,
86
+ serverNowMs: 0,
87
+ series: [{ id: "overall-main", label: "Overall", tone: "muted" as const, values: [] as number[] }],
88
+ },
89
+ raw: null,
90
+ }),
91
+ } satisfies DashboardStore)
3
92
 
4
93
  describe('API Routes', () => {
5
94
  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
- 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
- })
95
+ const storageRoot = mkStorageRoot()
96
+ const store = createStore()
97
+ const api = createApi({ store, storageRoot })
22
98
 
23
99
  const res = await api.request("/health")
24
100
  expect(res.status).toBe(200)
@@ -26,22 +102,9 @@ describe('API Routes', () => {
26
102
  })
27
103
 
28
104
  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" },
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
- })
105
+ const storageRoot = mkStorageRoot()
106
+ const store = createStore()
107
+ const api = createApi({ store, storageRoot })
45
108
 
46
109
  const res = await api.request("/dashboard")
47
110
  expect(res.status).toBe(200)
@@ -54,26 +117,72 @@ describe('API Routes', () => {
54
117
  expect(data).toHaveProperty("timeSeries")
55
118
  expect(data).toHaveProperty("raw")
56
119
 
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)
120
+ expect(hasSensitiveKeys(data)).toBe(false)
121
+ })
122
+
123
+ it('should reject invalid session IDs', async () => {
124
+ const storageRoot = mkStorageRoot()
125
+ const store = createStore()
126
+ const api = createApi({ store, storageRoot })
127
+
128
+ const res = await api.request("/tool-calls/not_valid!")
129
+ expect(res.status).toBe(400)
130
+ expect(await res.json()).toEqual({ ok: false, sessionId: "not_valid!", toolCalls: [] })
131
+ })
132
+
133
+ it('should return 404 for missing sessions', async () => {
134
+ const storageRoot = mkStorageRoot()
135
+ const store = createStore()
136
+ const api = createApi({ store, storageRoot })
137
+
138
+ const res = await api.request("/tool-calls/ses_missing")
139
+ expect(res.status).toBe(404)
140
+ expect(await res.json()).toEqual({ ok: false, sessionId: "ses_missing", toolCalls: [] })
141
+ })
142
+
143
+ it('should return empty tool calls for existing sessions', async () => {
144
+ const storageRoot = mkStorageRoot()
145
+ writeMessageMeta({ storageRoot, sessionId: "ses_empty", messageId: "msg_1", created: 1000 })
146
+ const store = createStore()
147
+ const api = createApi({ store, storageRoot })
148
+
149
+ const res = await api.request("/tool-calls/ses_empty")
150
+ expect(res.status).toBe(200)
151
+
152
+ const data = await res.json()
153
+ expect(data.ok).toBe(true)
154
+ expect(data.sessionId).toBe("ses_empty")
155
+ expect(data.toolCalls).toEqual([])
156
+ expect(data.caps).toEqual({ maxMessages: 200, maxToolCalls: 300 })
157
+ expect(data.truncated).toBe(false)
158
+ expect(hasSensitiveKeys(data)).toBe(false)
159
+ })
160
+
161
+ it('should redact tool call payload fields', async () => {
162
+ const storageRoot = mkStorageRoot()
163
+ writeMessageMeta({ storageRoot, sessionId: "ses_redact", messageId: "msg_1", created: 1000 })
164
+ writeToolPart({
165
+ storageRoot,
166
+ sessionId: "ses_redact",
167
+ messageId: "msg_1",
168
+ callId: "call_1",
169
+ tool: "bash",
170
+ state: {
171
+ status: "completed",
172
+ input: { prompt: "SECRET", nested: { output: "HIDDEN" } },
173
+ output: "NOPE",
174
+ error: "NOPE",
175
+ },
176
+ })
177
+ const store = createStore()
178
+ const api = createApi({ store, storageRoot })
179
+
180
+ const res = await api.request("/tool-calls/ses_redact")
181
+ expect(res.status).toBe(200)
182
+
183
+ const data = await res.json()
184
+ expect(data.ok).toBe(true)
185
+ expect(data.toolCalls.length).toBe(1)
186
+ expect(hasSensitiveKeys(data)).toBe(false)
78
187
  })
79
188
  })
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 }): 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
@@ -75,6 +75,7 @@ describe("buildDashboardPayload", () => {
75
75
  expect(payload.mainSession.statusPill).toBe("running tool")
76
76
  expect(payload.mainSession.currentTool).toBe("delegate_task")
77
77
  expect(payload.mainSession.agent).toBe("sisyphus")
78
+ expect(payload.mainSession.currentModel).toBeNull()
78
79
 
79
80
  expect(payload.raw).not.toHaveProperty("prompt")
80
81
  expect(payload.raw).not.toHaveProperty("input")
@@ -147,6 +148,7 @@ describe("buildDashboardPayload", () => {
147
148
 
148
149
  expect(payload).toHaveProperty("timeSeries")
149
150
  expect(payload.raw).toHaveProperty("timeSeries")
151
+ expect(payload.mainSession.currentModel).toBeNull()
150
152
 
151
153
  const sensitiveKeys = ["prompt", "input", "output", "error", "state"]
152
154
 
@@ -175,4 +177,141 @@ describe("buildDashboardPayload", () => {
175
177
  fs.rmSync(projectRoot, { recursive: true, force: true })
176
178
  }
177
179
  })
180
+
181
+ it("includes latest model strings for main and background sessions", () => {
182
+ const storageRoot = mkStorageRoot()
183
+ const storage = getStorageRoots(storageRoot)
184
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
185
+ const sessionId = "ses_with_models"
186
+ const backgroundSessionId = "ses_bg_1"
187
+ const messageId = "msg_1"
188
+ const projectID = "proj_1"
189
+
190
+ try {
191
+ const sessionMetaDir = path.join(storage.session, projectID)
192
+ fs.mkdirSync(sessionMetaDir, { recursive: true })
193
+ fs.writeFileSync(
194
+ path.join(sessionMetaDir, `${sessionId}.json`),
195
+ JSON.stringify({
196
+ id: sessionId,
197
+ projectID,
198
+ directory: projectRoot,
199
+ time: { created: 1000, updated: 1000 },
200
+ }),
201
+ "utf8"
202
+ )
203
+ fs.writeFileSync(
204
+ path.join(sessionMetaDir, `${backgroundSessionId}.json`),
205
+ JSON.stringify({
206
+ id: backgroundSessionId,
207
+ projectID,
208
+ directory: projectRoot,
209
+ parentID: sessionId,
210
+ title: "Background: model task",
211
+ time: { created: 1000, updated: 1100 },
212
+ }),
213
+ "utf8"
214
+ )
215
+
216
+ const messageDir = path.join(storage.message, sessionId)
217
+ fs.mkdirSync(messageDir, { recursive: true })
218
+ fs.writeFileSync(
219
+ path.join(messageDir, `${messageId}.json`),
220
+ JSON.stringify({
221
+ id: messageId,
222
+ sessionID: sessionId,
223
+ role: "assistant",
224
+ agent: "sisyphus",
225
+ time: { created: 1000 },
226
+ }),
227
+ "utf8"
228
+ )
229
+
230
+ const partDir = path.join(storage.part, messageId)
231
+ fs.mkdirSync(partDir, { recursive: true })
232
+ fs.writeFileSync(
233
+ path.join(partDir, "part_1.json"),
234
+ JSON.stringify({
235
+ id: "part_1",
236
+ sessionID: sessionId,
237
+ messageID: messageId,
238
+ type: "tool",
239
+ callID: "call_1",
240
+ tool: "delegate_task",
241
+ state: {
242
+ status: "completed",
243
+ input: {
244
+ run_in_background: true,
245
+ description: "model task",
246
+ subagent_type: "explore",
247
+ },
248
+ },
249
+ }),
250
+ "utf8"
251
+ )
252
+
253
+ const backgroundMessageDir = path.join(storage.message, backgroundSessionId)
254
+ fs.mkdirSync(backgroundMessageDir, { recursive: true })
255
+ fs.writeFileSync(
256
+ path.join(backgroundMessageDir, "msg_bg_1.json"),
257
+ JSON.stringify({
258
+ id: "msg_bg_1",
259
+ sessionID: backgroundSessionId,
260
+ role: "assistant",
261
+ providerID: "openai",
262
+ modelID: "gpt-4o",
263
+ time: { created: 1100 },
264
+ }),
265
+ "utf8"
266
+ )
267
+
268
+ const payload = buildDashboardPayload({
269
+ projectRoot,
270
+ storage,
271
+ nowMs: 2000,
272
+ })
273
+
274
+ expect(payload.mainSession.currentModel).toBeNull()
275
+ expect(payload.backgroundTasks).toHaveLength(1)
276
+ expect(payload.backgroundTasks[0]?.lastModel).toBe("openai/gpt-4o")
277
+ expect(payload.raw).toHaveProperty("backgroundTasks.0.lastModel", "openai/gpt-4o")
278
+ } finally {
279
+ fs.rmSync(storageRoot, { recursive: true, force: true })
280
+ fs.rmSync(projectRoot, { recursive: true, force: true })
281
+ }
282
+ })
283
+
284
+ it("does not include elapsed time in status pill when status is unknown", () => {
285
+ const storageRoot = mkStorageRoot()
286
+ const storage = getStorageRoots(storageRoot)
287
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
288
+ const sessionId = "ses_unknown"
289
+ const projectID = "proj_1"
290
+
291
+ try {
292
+ const sessionMetaDir = path.join(storage.session, projectID)
293
+ fs.mkdirSync(sessionMetaDir, { recursive: true })
294
+ fs.writeFileSync(
295
+ path.join(sessionMetaDir, `${sessionId}.json`),
296
+ JSON.stringify({
297
+ id: sessionId,
298
+ projectID,
299
+ directory: projectRoot,
300
+ time: { created: 1000, updated: 1000 },
301
+ }),
302
+ "utf8"
303
+ )
304
+
305
+ const payload = buildDashboardPayload({
306
+ projectRoot,
307
+ storage,
308
+ nowMs: 65_000,
309
+ })
310
+
311
+ expect(payload.mainSession.statusPill).toBe("unknown")
312
+ } finally {
313
+ fs.rmSync(storageRoot, { recursive: true, force: true })
314
+ fs.rmSync(projectRoot, { recursive: true, force: true })
315
+ }
316
+ })
178
317
  })
@@ -3,9 +3,41 @@ import * as path from "node:path"
3
3
  import { readBoulderState, readPlanProgress, readPlanSteps, type PlanStep } from "../ingest/boulder"
4
4
  import { deriveBackgroundTasks } from "../ingest/background-tasks"
5
5
  import { deriveTimeSeriesActivity, type TimeSeriesPayload } from "../ingest/timeseries"
6
- import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
6
+ import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type MainSessionView, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
7
7
 
8
8
  export type DashboardPayload = {
9
+ mainSession: {
10
+ agent: string
11
+ currentModel: string | null
12
+ currentTool: string
13
+ lastUpdatedLabel: string
14
+ session: string
15
+ statusPill: string
16
+ }
17
+ planProgress: {
18
+ name: string
19
+ completed: number
20
+ total: number
21
+ path: string
22
+ statusPill: string
23
+ steps: PlanStep[]
24
+ }
25
+ backgroundTasks: Array<{
26
+ id: string
27
+ description: string
28
+ agent: string
29
+ lastModel: string | null
30
+ status: string
31
+ toolCalls: number
32
+ lastTool: string
33
+ timeline: string
34
+ sessionId: string | null
35
+ }>
36
+ timeSeries: TimeSeriesPayload
37
+ raw: unknown
38
+ }
39
+
40
+ export type LegacyDashboardPayload = {
9
41
  mainSession: {
10
42
  agent: string
11
43
  currentTool: string
@@ -35,7 +67,7 @@ export type DashboardPayload = {
35
67
  }
36
68
 
37
69
  export type DashboardStore = {
38
- getSnapshot: () => DashboardPayload
70
+ getSnapshot: () => DashboardPayload | LegacyDashboardPayload
39
71
  }
40
72
 
41
73
  function formatIso(ts: number | null): string {
@@ -101,10 +133,13 @@ export function buildDashboardPayload(opts: {
101
133
  mainSessionId: sessionId ?? null,
102
134
  nowMs,
103
135
  })
104
-
136
+ const mainCurrentModel = "currentModel" in main
137
+ ? (main as MainSessionView).currentModel
138
+ : null
105
139
  const payload: DashboardPayload = {
106
140
  mainSession: {
107
141
  agent: main.agent,
142
+ currentModel: mainCurrentModel,
108
143
  currentTool: main.currentTool ?? "-",
109
144
  lastUpdatedLabel: formatIso(main.lastUpdated),
110
145
  session: main.sessionLabel,
@@ -122,10 +157,12 @@ export function buildDashboardPayload(opts: {
122
157
  id: t.id,
123
158
  description: t.description,
124
159
  agent: t.agent,
160
+ lastModel: t.lastModel ?? null,
125
161
  status: t.status,
126
162
  toolCalls: t.toolCalls ?? 0,
127
163
  lastTool: t.lastTool ?? "-",
128
164
  timeline: typeof t.timeline === "string" ? t.timeline : "",
165
+ sessionId: t.sessionId ?? null,
129
166
  })),
130
167
  timeSeries,
131
168
  raw: null,