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,317 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as path from "node:path"
|
|
3
|
+
import type { OpenCodeStorageRoots, SessionMetadata, StoredMessageMeta, StoredToolPart } from "./session"
|
|
4
|
+
import { getMessageDir } from "./session"
|
|
5
|
+
|
|
6
|
+
export type BackgroundTaskRow = {
|
|
7
|
+
id: string
|
|
8
|
+
description: string
|
|
9
|
+
agent: string
|
|
10
|
+
status: "queued" | "running" | "completed" | "error" | "unknown"
|
|
11
|
+
toolCalls: number | null
|
|
12
|
+
lastTool: string | null
|
|
13
|
+
timeline: string
|
|
14
|
+
sessionId: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DESCRIPTION_MAX = 120
|
|
18
|
+
const AGENT_MAX = 30
|
|
19
|
+
|
|
20
|
+
function clampString(value: unknown, maxLen: number): string | null {
|
|
21
|
+
if (typeof value !== "string") return null
|
|
22
|
+
const s = value.trim()
|
|
23
|
+
if (!s) return null
|
|
24
|
+
return s.length <= maxLen ? s : s.slice(0, maxLen)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readJsonFile<T>(filePath: string): T | null {
|
|
28
|
+
try {
|
|
29
|
+
const content = fs.readFileSync(filePath, "utf8")
|
|
30
|
+
return JSON.parse(content) as T
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function listJsonFiles(dir: string): string[] {
|
|
37
|
+
try {
|
|
38
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".json"))
|
|
39
|
+
} catch {
|
|
40
|
+
return []
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readToolPartsForMessage(storage: OpenCodeStorageRoots, messageID: string): StoredToolPart[] {
|
|
45
|
+
const partDir = path.join(storage.part, messageID)
|
|
46
|
+
if (!fs.existsSync(partDir)) return []
|
|
47
|
+
|
|
48
|
+
const files = listJsonFiles(partDir).sort()
|
|
49
|
+
const parts: StoredToolPart[] = []
|
|
50
|
+
for (const f of files) {
|
|
51
|
+
const p = readJsonFile<StoredToolPart>(path.join(partDir, f))
|
|
52
|
+
if (p && p.type === "tool" && typeof p.tool === "string" && p.state && typeof p.state === "object") {
|
|
53
|
+
parts.push(p)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return parts
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readRecentMessageMetas(messageDir: string, maxMessages: number): StoredMessageMeta[] {
|
|
60
|
+
if (!messageDir || !fs.existsSync(messageDir)) return []
|
|
61
|
+
const files = listJsonFiles(messageDir)
|
|
62
|
+
.map((f) => ({
|
|
63
|
+
f,
|
|
64
|
+
mtime: (() => {
|
|
65
|
+
try {
|
|
66
|
+
return fs.statSync(path.join(messageDir, f)).mtimeMs
|
|
67
|
+
} catch {
|
|
68
|
+
return 0
|
|
69
|
+
}
|
|
70
|
+
})(),
|
|
71
|
+
}))
|
|
72
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
73
|
+
.slice(0, maxMessages)
|
|
74
|
+
|
|
75
|
+
const metas: StoredMessageMeta[] = []
|
|
76
|
+
for (const item of files) {
|
|
77
|
+
const meta = readJsonFile<StoredMessageMeta>(path.join(messageDir, item.f))
|
|
78
|
+
if (meta && typeof meta.id === "string") metas.push(meta)
|
|
79
|
+
}
|
|
80
|
+
return metas
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readAllSessionMetas(sessionStorage: string): SessionMetadata[] {
|
|
84
|
+
if (!fs.existsSync(sessionStorage)) return []
|
|
85
|
+
const metas: SessionMetadata[] = []
|
|
86
|
+
try {
|
|
87
|
+
const projectDirs = fs.readdirSync(sessionStorage, { withFileTypes: true })
|
|
88
|
+
for (const d of projectDirs) {
|
|
89
|
+
if (!d.isDirectory()) continue
|
|
90
|
+
const projectPath = path.join(sessionStorage, d.name)
|
|
91
|
+
for (const file of listJsonFiles(projectPath)) {
|
|
92
|
+
const meta = readJsonFile<SessionMetadata>(path.join(projectPath, file))
|
|
93
|
+
if (meta && typeof meta.id === "string") metas.push(meta)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
return []
|
|
98
|
+
}
|
|
99
|
+
return metas
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findBackgroundSessionId(opts: {
|
|
103
|
+
allSessionMetas: SessionMetadata[]
|
|
104
|
+
parentSessionId: string
|
|
105
|
+
description: string
|
|
106
|
+
startedAt: number
|
|
107
|
+
}): string | null {
|
|
108
|
+
const title = `Background: ${opts.description}`
|
|
109
|
+
const windowStart = opts.startedAt
|
|
110
|
+
const windowEnd = opts.startedAt + 60_000
|
|
111
|
+
|
|
112
|
+
const candidates = opts.allSessionMetas.filter(
|
|
113
|
+
(m) =>
|
|
114
|
+
m.parentID === opts.parentSessionId &&
|
|
115
|
+
m.title === title &&
|
|
116
|
+
m.time?.created >= windowStart &&
|
|
117
|
+
m.time?.created <= windowEnd
|
|
118
|
+
)
|
|
119
|
+
// Deterministic tie-breaking: max by time.created, then lexicographic id
|
|
120
|
+
candidates.sort((a, b) => {
|
|
121
|
+
const at = a.time?.created ?? 0
|
|
122
|
+
const bt = b.time?.created ?? 0
|
|
123
|
+
if (at !== bt) return bt - at
|
|
124
|
+
return String(a.id).localeCompare(String(b.id))
|
|
125
|
+
})
|
|
126
|
+
return candidates[0]?.id ?? null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findTaskSessionId(opts: {
|
|
130
|
+
allSessionMetas: SessionMetadata[]
|
|
131
|
+
parentSessionId: string
|
|
132
|
+
description: string
|
|
133
|
+
startedAt: number
|
|
134
|
+
}): string | null {
|
|
135
|
+
const title = `Task: ${opts.description}`
|
|
136
|
+
const windowStart = opts.startedAt
|
|
137
|
+
const windowEnd = opts.startedAt + 60_000
|
|
138
|
+
|
|
139
|
+
const candidates = opts.allSessionMetas.filter(
|
|
140
|
+
(m) =>
|
|
141
|
+
m.parentID === opts.parentSessionId &&
|
|
142
|
+
m.title === title &&
|
|
143
|
+
m.time?.created >= windowStart &&
|
|
144
|
+
m.time?.created <= windowEnd
|
|
145
|
+
)
|
|
146
|
+
candidates.sort((a, b) => {
|
|
147
|
+
const at = a.time?.created ?? 0
|
|
148
|
+
const bt = b.time?.created ?? 0
|
|
149
|
+
if (at !== bt) return bt - at
|
|
150
|
+
return String(a.id).localeCompare(String(b.id))
|
|
151
|
+
})
|
|
152
|
+
return candidates[0]?.id ?? null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function deriveBackgroundSessionStats(storage: OpenCodeStorageRoots, sessionId: string): { toolCalls: number; lastTool: string | null; lastUpdateAt: number | null } {
|
|
156
|
+
const messageDir = getMessageDir(storage.message, sessionId)
|
|
157
|
+
const metas = readRecentMessageMetas(messageDir, 200)
|
|
158
|
+
let toolCalls = 0
|
|
159
|
+
let lastTool: string | null = null
|
|
160
|
+
let lastUpdateAt: number | null = null
|
|
161
|
+
|
|
162
|
+
// Deterministic ordering by time.created then id.
|
|
163
|
+
const ordered = [...metas].sort((a, b) => {
|
|
164
|
+
const at = a.time?.created ?? 0
|
|
165
|
+
const bt = b.time?.created ?? 0
|
|
166
|
+
if (at !== bt) return at - bt
|
|
167
|
+
return String(a.id).localeCompare(String(b.id))
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
for (const meta of ordered) {
|
|
171
|
+
const created = meta.time?.created
|
|
172
|
+
if (typeof created === "number") lastUpdateAt = created
|
|
173
|
+
const parts = readToolPartsForMessage(storage, meta.id)
|
|
174
|
+
for (const p of parts) {
|
|
175
|
+
toolCalls += 1
|
|
176
|
+
lastTool = p.tool
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { toolCalls, lastTool, lastUpdateAt }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function formatIsoNoMs(ts: number): string {
|
|
184
|
+
const iso = new Date(ts).toISOString()
|
|
185
|
+
return iso.replace(/\.\d{3}Z$/, "Z")
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function formatElapsed(ms: number): string {
|
|
189
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000))
|
|
190
|
+
const seconds = totalSeconds % 60
|
|
191
|
+
const totalMinutes = Math.floor(totalSeconds / 60)
|
|
192
|
+
const minutes = totalMinutes % 60
|
|
193
|
+
const totalHours = Math.floor(totalMinutes / 60)
|
|
194
|
+
const hours = totalHours % 24
|
|
195
|
+
const days = Math.floor(totalHours / 24)
|
|
196
|
+
|
|
197
|
+
if (days > 0) return hours > 0 ? `${days}d${hours}h` : `${days}d`
|
|
198
|
+
if (totalHours > 0) return minutes > 0 ? `${totalHours}h${minutes}m` : `${totalHours}h`
|
|
199
|
+
if (totalMinutes > 0) return seconds > 0 ? `${totalMinutes}m${seconds}s` : `${totalMinutes}m`
|
|
200
|
+
return `${seconds}s`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function formatTimeline(startAt: number | null, endAtMs: number): string {
|
|
204
|
+
if (typeof startAt !== "number") return ""
|
|
205
|
+
const start = formatIsoNoMs(startAt)
|
|
206
|
+
const elapsed = formatElapsed(endAtMs - startAt)
|
|
207
|
+
return `${start}: ${elapsed}`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function deriveBackgroundTasks(opts: {
|
|
211
|
+
storage: OpenCodeStorageRoots
|
|
212
|
+
mainSessionId: string
|
|
213
|
+
nowMs?: number
|
|
214
|
+
}): BackgroundTaskRow[] {
|
|
215
|
+
const nowMs = opts.nowMs ?? Date.now()
|
|
216
|
+
const messageDir = getMessageDir(opts.storage.message, opts.mainSessionId)
|
|
217
|
+
const metas = readRecentMessageMetas(messageDir, 200)
|
|
218
|
+
const allSessionMetas = readAllSessionMetas(opts.storage.session)
|
|
219
|
+
|
|
220
|
+
const rows: BackgroundTaskRow[] = []
|
|
221
|
+
|
|
222
|
+
// Iterate newest-first to cap list and keep latest tasks.
|
|
223
|
+
const ordered = [...metas].sort((a, b) => (b.time?.created ?? 0) - (a.time?.created ?? 0))
|
|
224
|
+
for (const meta of ordered) {
|
|
225
|
+
const startedAt = meta.time?.created ?? null
|
|
226
|
+
if (typeof startedAt !== "number") continue
|
|
227
|
+
|
|
228
|
+
const parts = readToolPartsForMessage(opts.storage, meta.id)
|
|
229
|
+
for (const part of parts) {
|
|
230
|
+
if (part.tool !== "delegate_task") continue
|
|
231
|
+
if (!part.state || typeof part.state !== "object") continue
|
|
232
|
+
|
|
233
|
+
const input = part.state.input ?? {}
|
|
234
|
+
if (typeof input !== "object" || input === null) continue
|
|
235
|
+
|
|
236
|
+
const runInBackground = (input as Record<string, unknown>).run_in_background
|
|
237
|
+
if (runInBackground !== true && runInBackground !== false) continue
|
|
238
|
+
|
|
239
|
+
const description = clampString((input as Record<string, unknown>).description, DESCRIPTION_MAX)
|
|
240
|
+
if (!description) continue
|
|
241
|
+
|
|
242
|
+
const subagentType = clampString((input as Record<string, unknown>).subagent_type, AGENT_MAX)
|
|
243
|
+
const category = clampString((input as Record<string, unknown>).category, AGENT_MAX)
|
|
244
|
+
const agent = subagentType ?? (category ? `sisyphus-junior (${category})` : "unknown")
|
|
245
|
+
|
|
246
|
+
let backgroundSessionId: string | null = null
|
|
247
|
+
|
|
248
|
+
if (runInBackground) {
|
|
249
|
+
backgroundSessionId = findBackgroundSessionId({
|
|
250
|
+
allSessionMetas,
|
|
251
|
+
parentSessionId: opts.mainSessionId,
|
|
252
|
+
description,
|
|
253
|
+
startedAt,
|
|
254
|
+
})
|
|
255
|
+
} else {
|
|
256
|
+
// For sync tasks, check if resume is specified
|
|
257
|
+
const resume = (input as Record<string, unknown>).resume
|
|
258
|
+
if (typeof resume === "string" && resume.trim() !== "") {
|
|
259
|
+
// Check if resumed session exists (has readable messages dir)
|
|
260
|
+
const resumeMessageDir = getMessageDir(opts.storage.message, resume.trim())
|
|
261
|
+
if (fs.existsSync(resumeMessageDir) && fs.readdirSync(resumeMessageDir).length > 0) {
|
|
262
|
+
backgroundSessionId = resume.trim()
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!backgroundSessionId) {
|
|
267
|
+
backgroundSessionId = findBackgroundSessionId({
|
|
268
|
+
allSessionMetas,
|
|
269
|
+
parentSessionId: opts.mainSessionId,
|
|
270
|
+
description,
|
|
271
|
+
startedAt,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
if (!backgroundSessionId) {
|
|
275
|
+
backgroundSessionId = findTaskSessionId({
|
|
276
|
+
allSessionMetas,
|
|
277
|
+
parentSessionId: opts.mainSessionId,
|
|
278
|
+
description,
|
|
279
|
+
startedAt,
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const stats = backgroundSessionId
|
|
286
|
+
? deriveBackgroundSessionStats(opts.storage, backgroundSessionId)
|
|
287
|
+
: { toolCalls: 0, lastTool: null, lastUpdateAt: startedAt }
|
|
288
|
+
|
|
289
|
+
// Best-effort status: if background session exists and has any tool calls, treat as running unless idle.
|
|
290
|
+
let status: BackgroundTaskRow["status"] = "unknown"
|
|
291
|
+
if (!backgroundSessionId) {
|
|
292
|
+
status = "queued"
|
|
293
|
+
} else if (stats.lastUpdateAt && nowMs - stats.lastUpdateAt <= 15_000) {
|
|
294
|
+
status = "running"
|
|
295
|
+
} else if (stats.toolCalls > 0) {
|
|
296
|
+
status = "completed"
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const timelineEndMs = status === "completed" ? (stats.lastUpdateAt ?? nowMs) : nowMs
|
|
300
|
+
|
|
301
|
+
rows.push({
|
|
302
|
+
id: part.callID,
|
|
303
|
+
description,
|
|
304
|
+
agent,
|
|
305
|
+
status,
|
|
306
|
+
toolCalls: backgroundSessionId ? stats.toolCalls : null,
|
|
307
|
+
lastTool: stats.lastTool,
|
|
308
|
+
timeline: formatTimeline(startedAt, timelineEndMs),
|
|
309
|
+
sessionId: backgroundSessionId,
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (rows.length >= 50) break
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return rows
|
|
317
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
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 { readBoulderState, readPlanProgress } from "./boulder"
|
|
6
|
+
|
|
7
|
+
function mkProjectRoot(): string {
|
|
8
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
|
|
9
|
+
fs.mkdirSync(path.join(root, ".sisyphus"), { recursive: true })
|
|
10
|
+
return root
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("readBoulderState", () => {
|
|
14
|
+
it("returns null if boulder.json is missing", () => {
|
|
15
|
+
const projectRoot = mkProjectRoot()
|
|
16
|
+
expect(readBoulderState(projectRoot)).toBe(null)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("returns null if boulder.json is invalid JSON", () => {
|
|
20
|
+
const projectRoot = mkProjectRoot()
|
|
21
|
+
fs.writeFileSync(path.join(projectRoot, ".sisyphus", "boulder.json"), "{nope", "utf8")
|
|
22
|
+
expect(readBoulderState(projectRoot)).toBe(null)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe("readPlanProgress", () => {
|
|
27
|
+
it("computes checkbox progress for an existing plan", () => {
|
|
28
|
+
const projectRoot = mkProjectRoot()
|
|
29
|
+
const planPath = path.join(projectRoot, ".sisyphus", "plans", "plan.md")
|
|
30
|
+
fs.mkdirSync(path.dirname(planPath), { recursive: true })
|
|
31
|
+
fs.writeFileSync(
|
|
32
|
+
planPath,
|
|
33
|
+
["- [ ] Task 1", "- [x] Task 2", "- [X] Task 3"].join("\n"),
|
|
34
|
+
"utf8"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const progress = readPlanProgress(projectRoot, planPath)
|
|
38
|
+
expect(progress.missing).toBe(false)
|
|
39
|
+
expect(progress.total).toBe(3)
|
|
40
|
+
expect(progress.completed).toBe(2)
|
|
41
|
+
expect(progress.isComplete).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("treats missing plan file as not started", () => {
|
|
45
|
+
const projectRoot = mkProjectRoot()
|
|
46
|
+
const planPath = path.join(projectRoot, ".sisyphus", "plans", "missing.md")
|
|
47
|
+
const progress = readPlanProgress(projectRoot, planPath)
|
|
48
|
+
expect(progress.missing).toBe(true)
|
|
49
|
+
expect(progress.total).toBe(0)
|
|
50
|
+
expect(progress.completed).toBe(0)
|
|
51
|
+
expect(progress.isComplete).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("treats a plan with zero checkboxes as complete", () => {
|
|
55
|
+
const projectRoot = mkProjectRoot()
|
|
56
|
+
const planPath = path.join(projectRoot, ".sisyphus", "plans", "empty.md")
|
|
57
|
+
fs.mkdirSync(path.dirname(planPath), { recursive: true })
|
|
58
|
+
fs.writeFileSync(planPath, "# No tasks\nJust text", "utf8")
|
|
59
|
+
|
|
60
|
+
const progress = readPlanProgress(projectRoot, planPath)
|
|
61
|
+
expect(progress.missing).toBe(false)
|
|
62
|
+
expect(progress.total).toBe(0)
|
|
63
|
+
expect(progress.completed).toBe(0)
|
|
64
|
+
expect(progress.isComplete).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("rejects active_plan paths outside projectRoot", () => {
|
|
68
|
+
const projectRoot = mkProjectRoot()
|
|
69
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "omo-outside-"))
|
|
70
|
+
const outsidePlan = path.join(outside, "plan.md")
|
|
71
|
+
fs.writeFileSync(outsidePlan, "- [x] outside", "utf8")
|
|
72
|
+
|
|
73
|
+
const progress = readPlanProgress(projectRoot, outsidePlan)
|
|
74
|
+
expect(progress.missing).toBe(true)
|
|
75
|
+
expect(progress.isComplete).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as path from "node:path"
|
|
3
|
+
import { assertAllowedPath } from "./paths"
|
|
4
|
+
|
|
5
|
+
export type BoulderState = {
|
|
6
|
+
active_plan: string
|
|
7
|
+
started_at: string
|
|
8
|
+
session_ids: string[]
|
|
9
|
+
plan_name: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type PlanProgress = {
|
|
13
|
+
total: number
|
|
14
|
+
completed: number
|
|
15
|
+
isComplete: boolean
|
|
16
|
+
missing: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readBoulderState(projectRoot: string): BoulderState | null {
|
|
20
|
+
const filePath = assertAllowedPath({
|
|
21
|
+
candidatePath: path.join(projectRoot, ".sisyphus", "boulder.json"),
|
|
22
|
+
allowedRoots: [projectRoot],
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
if (!fs.existsSync(filePath)) return null
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const content = fs.readFileSync(filePath, "utf8")
|
|
29
|
+
return JSON.parse(content) as BoulderState
|
|
30
|
+
} catch {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getPlanProgressFromMarkdown(content: string): Omit<PlanProgress, "missing"> {
|
|
36
|
+
const uncheckedMatches = content.match(/^[-*]\s*\[\s*\]/gm) || []
|
|
37
|
+
const checkedMatches = content.match(/^[-*]\s*\[[xX]\]/gm) || []
|
|
38
|
+
|
|
39
|
+
const total = uncheckedMatches.length + checkedMatches.length
|
|
40
|
+
const completed = checkedMatches.length
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
total,
|
|
44
|
+
completed,
|
|
45
|
+
isComplete: total === 0 || completed === total,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readPlanProgress(projectRoot: string, planPath: string): PlanProgress {
|
|
50
|
+
let planReal: string
|
|
51
|
+
try {
|
|
52
|
+
planReal = assertAllowedPath({
|
|
53
|
+
candidatePath: planPath,
|
|
54
|
+
allowedRoots: [projectRoot],
|
|
55
|
+
})
|
|
56
|
+
} catch {
|
|
57
|
+
return { total: 0, completed: 0, isComplete: false, missing: true }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!fs.existsSync(planReal)) {
|
|
61
|
+
return { total: 0, completed: 0, isComplete: false, missing: true }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const content = fs.readFileSync(planReal, "utf8")
|
|
66
|
+
const progress = getPlanProgressFromMarkdown(content)
|
|
67
|
+
return { ...progress, missing: false }
|
|
68
|
+
} catch {
|
|
69
|
+
return { total: 0, completed: 0, isComplete: false, missing: true }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
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 { assertAllowedPath, getOpenCodeStorageDir } from "./paths"
|
|
6
|
+
|
|
7
|
+
describe("getOpenCodeStorageDir", () => {
|
|
8
|
+
it("uses XDG_DATA_HOME when set", () => {
|
|
9
|
+
const got = getOpenCodeStorageDir({ XDG_DATA_HOME: "/tmp/xdg" }, "/home/test")
|
|
10
|
+
expect(got).toBe("/tmp/xdg/opencode/storage")
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("falls back to ~/.local/share when XDG_DATA_HOME is unset", () => {
|
|
14
|
+
const got = getOpenCodeStorageDir({}, "/home/test")
|
|
15
|
+
expect(got).toBe("/home/test/.local/share/opencode/storage")
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe("assertAllowedPath", () => {
|
|
20
|
+
it("allows paths inside allowed roots", () => {
|
|
21
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), "omo-dashboard-"))
|
|
22
|
+
const allowed = path.join(base, "allowed")
|
|
23
|
+
fs.mkdirSync(allowed, { recursive: true })
|
|
24
|
+
|
|
25
|
+
const resolved = assertAllowedPath({
|
|
26
|
+
candidatePath: path.join(allowed, "file.txt"),
|
|
27
|
+
allowedRoots: [allowed],
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(resolved).toContain("/allowed/")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("rejects traversal outside allowed root", () => {
|
|
34
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), "omo-dashboard-"))
|
|
35
|
+
const allowed = path.join(base, "allowed")
|
|
36
|
+
fs.mkdirSync(allowed, { recursive: true })
|
|
37
|
+
const outside = path.join(base, "outside.txt")
|
|
38
|
+
|
|
39
|
+
expect(() =>
|
|
40
|
+
assertAllowedPath({
|
|
41
|
+
candidatePath: outside,
|
|
42
|
+
allowedRoots: [allowed],
|
|
43
|
+
})
|
|
44
|
+
).toThrow("Access denied")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("rejects symlink escapes", () => {
|
|
48
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), "omo-dashboard-"))
|
|
49
|
+
const allowed = path.join(base, "allowed")
|
|
50
|
+
const outsideDir = path.join(base, "outside")
|
|
51
|
+
fs.mkdirSync(allowed, { recursive: true })
|
|
52
|
+
fs.mkdirSync(outsideDir, { recursive: true })
|
|
53
|
+
|
|
54
|
+
const outsideFile = path.join(outsideDir, "secret.txt")
|
|
55
|
+
fs.writeFileSync(outsideFile, "nope", "utf8")
|
|
56
|
+
|
|
57
|
+
const linkPath = path.join(allowed, "link.txt")
|
|
58
|
+
fs.symlinkSync(outsideFile, linkPath)
|
|
59
|
+
|
|
60
|
+
expect(() =>
|
|
61
|
+
assertAllowedPath({
|
|
62
|
+
candidatePath: linkPath,
|
|
63
|
+
allowedRoots: [allowed],
|
|
64
|
+
})
|
|
65
|
+
).toThrow("Access denied")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("allows a non-existent file path if its nearest existing parent is inside", () => {
|
|
69
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), "omo-dashboard-"))
|
|
70
|
+
const allowed = path.join(base, "allowed")
|
|
71
|
+
fs.mkdirSync(allowed, { recursive: true })
|
|
72
|
+
|
|
73
|
+
const candidate = path.join(allowed, "missing", "file.txt")
|
|
74
|
+
const resolved = assertAllowedPath({
|
|
75
|
+
candidatePath: candidate,
|
|
76
|
+
allowedRoots: [allowed],
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
expect(resolved).toContain("/allowed/")
|
|
80
|
+
expect(resolved.endsWith(path.join("missing", "file.txt"))).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as fs from "node:fs"
|
|
2
|
+
import * as os from "node:os"
|
|
3
|
+
import * as path from "node:path"
|
|
4
|
+
|
|
5
|
+
export type Env = Record<string, string | undefined>
|
|
6
|
+
|
|
7
|
+
export function getDataDir(env: Env = process.env, homedir: string = os.homedir()): string {
|
|
8
|
+
// Match oh-my-opencode behavior exactly:
|
|
9
|
+
// XDG_DATA_HOME or ~/.local/share on all platforms.
|
|
10
|
+
return env.XDG_DATA_HOME ?? path.join(homedir, ".local", "share")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getOpenCodeStorageDir(env: Env = process.env, homedir: string = os.homedir()): string {
|
|
14
|
+
return path.join(getDataDir(env, homedir), "opencode", "storage")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function realpathSafe(p: string): string | null {
|
|
18
|
+
try {
|
|
19
|
+
return fs.realpathSync(p)
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isPathInside(rootReal: string, candidateReal: string): boolean {
|
|
26
|
+
const rel = path.relative(rootReal, candidateReal)
|
|
27
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveCandidateReal(candidateAbs: string): string | null {
|
|
31
|
+
const existing = realpathSafe(candidateAbs)
|
|
32
|
+
if (existing) return existing
|
|
33
|
+
|
|
34
|
+
// If the target doesn't exist, resolve the nearest existing parent and
|
|
35
|
+
// re-append the relative suffix.
|
|
36
|
+
let cur = candidateAbs
|
|
37
|
+
let prev = ""
|
|
38
|
+
while (cur !== prev) {
|
|
39
|
+
if (fs.existsSync(cur)) {
|
|
40
|
+
const parentReal = realpathSafe(cur)
|
|
41
|
+
if (!parentReal) return null
|
|
42
|
+
const suffix = path.relative(cur, candidateAbs)
|
|
43
|
+
return suffix ? path.join(parentReal, suffix) : parentReal
|
|
44
|
+
}
|
|
45
|
+
prev = cur
|
|
46
|
+
cur = path.dirname(cur)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type AssertAllowedPathOptions = {
|
|
53
|
+
candidatePath: string
|
|
54
|
+
allowedRoots: string[]
|
|
55
|
+
baseDir?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function assertAllowedPath(opts: AssertAllowedPathOptions): string {
|
|
59
|
+
const baseDir = opts.baseDir ?? process.cwd()
|
|
60
|
+
const candidateAbs = path.resolve(baseDir, opts.candidatePath)
|
|
61
|
+
|
|
62
|
+
const candidateReal = resolveCandidateReal(candidateAbs)
|
|
63
|
+
if (!candidateReal) {
|
|
64
|
+
throw new Error("Access denied")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const root of opts.allowedRoots) {
|
|
68
|
+
const rootAbs = path.resolve(baseDir, root)
|
|
69
|
+
const rootReal = resolveCandidateReal(rootAbs) ?? rootAbs
|
|
70
|
+
if (isPathInside(rootReal, candidateReal)) {
|
|
71
|
+
return candidateReal
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw new Error("Access denied")
|
|
76
|
+
}
|