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.
- package/README.md +100 -0
- package/dashboard-ui.png +0 -0
- package/dist/assets/index-D6OVzN1o.css +1 -0
- package/dist/assets/index-SEmwze_4.js +40 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +51 -0
- package/src/App.tsx +518 -0
- package/src/cli/dev.ts +139 -0
- package/src/cli/ports.test.ts +40 -0
- package/src/cli/ports.ts +43 -0
- package/src/ding-policy.test.ts +48 -0
- package/src/ding-policy.ts +39 -0
- package/src/ingest/background-tasks.test.ts +707 -0
- package/src/ingest/background-tasks.ts +317 -0
- package/src/ingest/boulder.test.ts +77 -0
- package/src/ingest/boulder.ts +71 -0
- package/src/ingest/paths.test.ts +82 -0
- package/src/ingest/paths.ts +76 -0
- package/src/ingest/session.test.ts +220 -0
- package/src/ingest/session.ts +283 -0
- package/src/main.tsx +10 -0
- package/src/server/api.test.ts +62 -0
- package/src/server/api.ts +16 -0
- package/src/server/build.ts +5 -0
- package/src/server/dashboard.test.ts +135 -0
- package/src/server/dashboard.ts +191 -0
- package/src/server/dev.ts +44 -0
- package/src/server/start.ts +93 -0
- package/src/sound.test.ts +55 -0
- package/src/sound.ts +89 -0
- package/src/styles.css +457 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +14 -0
|
@@ -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,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
|
+
}
|