oh-my-opencode-dashboard 0.0.4 → 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,161 @@
1
+ import * as os from "node:os"
2
+ import * as path from "node:path"
3
+ import * as fs from "node:fs"
4
+ import { describe, expect, it } from "vitest"
5
+ import { deriveToolCalls, MAX_TOOL_CALL_MESSAGES, MAX_TOOL_CALLS } from "./tool-calls"
6
+ import { getStorageRoots } from "./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
+ function writeMessageMeta(opts: {
17
+ storageRoot: string
18
+ sessionId: string
19
+ messageId: string
20
+ created?: number
21
+ }): void {
22
+ const storage = getStorageRoots(opts.storageRoot)
23
+ const msgDir = path.join(storage.message, opts.sessionId)
24
+ fs.mkdirSync(msgDir, { recursive: true })
25
+ const meta: Record<string, unknown> = {
26
+ id: opts.messageId,
27
+ sessionID: opts.sessionId,
28
+ role: "assistant",
29
+ }
30
+ if (typeof opts.created === "number") {
31
+ meta.time = { created: opts.created }
32
+ }
33
+ fs.writeFileSync(path.join(msgDir, `${opts.messageId}.json`), JSON.stringify(meta), "utf8")
34
+ }
35
+
36
+ function writeToolPart(opts: {
37
+ storageRoot: string
38
+ sessionId: string
39
+ messageId: string
40
+ callId: string
41
+ tool: string
42
+ state?: Record<string, unknown>
43
+ }): void {
44
+ const storage = getStorageRoots(opts.storageRoot)
45
+ const partDir = path.join(storage.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
+ function hasBannedKeys(value: unknown, banned: Set<string>): boolean {
63
+ if (!value || typeof value !== "object") return false
64
+ if (Array.isArray(value)) {
65
+ return value.some((item) => hasBannedKeys(item, banned))
66
+ }
67
+ for (const [key, child] of Object.entries(value)) {
68
+ if (banned.has(key)) return true
69
+ if (hasBannedKeys(child, banned)) return true
70
+ }
71
+ return false
72
+ }
73
+
74
+ describe("deriveToolCalls", () => {
75
+ it("orders tool calls deterministically and sorts null timestamps last", () => {
76
+ const storageRoot = mkStorageRoot()
77
+ const storage = getStorageRoots(storageRoot)
78
+ const sessionId = "ses_main"
79
+
80
+ writeMessageMeta({ storageRoot, sessionId, messageId: "msg_0", created: 500 })
81
+ writeToolPart({ storageRoot, sessionId, messageId: "msg_0", callId: "call_a", tool: "read" })
82
+
83
+ writeMessageMeta({ storageRoot, sessionId, messageId: "msg_1", created: 1000 })
84
+ writeToolPart({ storageRoot, sessionId, messageId: "msg_1", callId: "call_a", tool: "bash" })
85
+
86
+ writeMessageMeta({ storageRoot, sessionId, messageId: "msg_2", created: 1000 })
87
+ writeToolPart({ storageRoot, sessionId, messageId: "msg_2", callId: "call_b", tool: "grep" })
88
+ writeToolPart({ storageRoot, sessionId, messageId: "msg_2", callId: "call_a", tool: "grep" })
89
+
90
+ writeMessageMeta({ storageRoot, sessionId, messageId: "msg_3" })
91
+ writeToolPart({ storageRoot, sessionId, messageId: "msg_3", callId: "call_z", tool: "read" })
92
+
93
+ const result = deriveToolCalls({ storage, sessionId })
94
+ expect(result.toolCalls.map((row) => `${row.messageId}:${row.callId}`)).toEqual([
95
+ "msg_1:call_a",
96
+ "msg_2:call_a",
97
+ "msg_2:call_b",
98
+ "msg_0:call_a",
99
+ "msg_3:call_z",
100
+ ])
101
+ expect(result.toolCalls[0].createdAtMs).toBe(1000)
102
+ expect(result.toolCalls[4].createdAtMs).toBe(null)
103
+ expect(result.truncated).toBe(false)
104
+ })
105
+
106
+ it("caps message scan and tool call output", () => {
107
+ const storageRoot = mkStorageRoot()
108
+ const storage = getStorageRoots(storageRoot)
109
+ const sessionId = "ses_main"
110
+
111
+ const totalMessages = MAX_TOOL_CALL_MESSAGES + 5
112
+ for (let i = 0; i < totalMessages; i += 1) {
113
+ const suffix = String(i).padStart(3, "0")
114
+ const messageId = `msg_${suffix}`
115
+ writeMessageMeta({ storageRoot, sessionId, messageId, created: i })
116
+ writeToolPart({ storageRoot, sessionId, messageId, callId: `call_${suffix}_a`, tool: "bash" })
117
+ writeToolPart({ storageRoot, sessionId, messageId, callId: `call_${suffix}_b`, tool: "read" })
118
+ }
119
+
120
+ const result = deriveToolCalls({ storage, sessionId })
121
+ expect(result.toolCalls.length).toBe(MAX_TOOL_CALLS)
122
+ expect(result.truncated).toBe(true)
123
+
124
+ const messageIds = new Set(result.toolCalls.map((row) => row.messageId))
125
+ for (let i = 0; i < 5; i += 1) {
126
+ const suffix = String(i).padStart(3, "0")
127
+ expect(messageIds.has(`msg_${suffix}`)).toBe(false)
128
+ }
129
+ for (let i = totalMessages - 5; i < totalMessages; i += 1) {
130
+ const suffix = String(i).padStart(3, "0")
131
+ expect(messageIds.has(`msg_${suffix}`)).toBe(true)
132
+ }
133
+ })
134
+
135
+ it("redacts tool call payload fields", () => {
136
+ const storageRoot = mkStorageRoot()
137
+ const storage = getStorageRoots(storageRoot)
138
+ const sessionId = "ses_main"
139
+
140
+ writeMessageMeta({ storageRoot, sessionId, messageId: "msg_1", created: 1000 })
141
+ writeToolPart({
142
+ storageRoot,
143
+ sessionId,
144
+ messageId: "msg_1",
145
+ callId: "call_secret",
146
+ tool: "bash",
147
+ state: {
148
+ status: "completed",
149
+ input: { prompt: "SECRET", nested: { output: "HIDDEN" } },
150
+ output: "NOPE",
151
+ error: "NOPE",
152
+ },
153
+ })
154
+
155
+ const result = deriveToolCalls({ storage, sessionId })
156
+ expect(result.toolCalls.length).toBe(1)
157
+
158
+ const banned = new Set(["prompt", "input", "output", "error", "state"])
159
+ expect(hasBannedKeys(result.toolCalls[0], banned)).toBe(false)
160
+ })
161
+ })
@@ -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", 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
- })
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", 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
- })
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
@@ -31,6 +31,7 @@ export type DashboardPayload = {
31
31
  toolCalls: number
32
32
  lastTool: string
33
33
  timeline: string
34
+ sessionId: string | null
34
35
  }>
35
36
  timeSeries: TimeSeriesPayload
36
37
  raw: unknown
@@ -161,6 +162,7 @@ export function buildDashboardPayload(opts: {
161
162
  toolCalls: t.toolCalls ?? 0,
162
163
  lastTool: t.lastTool ?? "-",
163
164
  timeline: typeof t.timeline === "string" ? t.timeline : "",
165
+ sessionId: t.sessionId ?? null,
164
166
  })),
165
167
  timeSeries,
166
168
  raw: null,
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 }))
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 }))
32
34
 
33
35
  const distRoot = join(import.meta.dir, '../../dist')
34
36