opencode-global-habit-profile 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +87 -2
- package/dist/assets/index-CMgH-UX9.js +21 -0
- package/dist/assets/index-CX37tAhW.css +1 -0
- package/dist/index.html +4 -0
- package/docs/mcp-setup.md +32 -0
- package/docs/metrics.md +200 -0
- package/index.js +501 -0
- package/package.json +51 -4
- package/src/App.vue +753 -0
- package/src/mcp.js +39 -0
- package/src/metrics.js +614 -0
- package/src/plugin-stats.js +215 -0
- package/src/store.js +296 -0
- package/src/ui-model.js +184 -0
- package/src/ui-navigation.js +13 -0
- package/src/ui-server.js +300 -0
- package/src/ui.css +221 -0
- package/src/ui.js +5 -0
package/src/ui-model.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
function directoryName(value) {
|
|
2
|
+
const name = typeof value === 'string' ? value.trim() : ''
|
|
3
|
+
return name && name.length <= 128 && !/[\\/\u0000-\u001f\u007f]/.test(name) ? name : ''
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function groupCheckpoints(checkpoints) {
|
|
7
|
+
const groups = new Map()
|
|
8
|
+
const directories = new Map()
|
|
9
|
+
for (const item of checkpoints) {
|
|
10
|
+
const key = item.project
|
|
11
|
+
if (!groups.has(key)) groups.set(key, { key, project: item.project, items: [] })
|
|
12
|
+
groups.get(key).items.push(item)
|
|
13
|
+
const name = directoryName(item?.projectDirectoryName)
|
|
14
|
+
if (name) {
|
|
15
|
+
const createdAt = typeof item?.createdAt === 'string' ? item.createdAt : ''
|
|
16
|
+
if (!directories.has(key) || createdAt >= directories.get(key).createdAt) directories.set(key, { name, createdAt })
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return [...groups.values()].map((group) => ({ ...group, directoryName: directories.get(group.key)?.name ?? '' }))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function groupTodos(todos = [], includeArchived = false, directories = new Map()) {
|
|
23
|
+
const groups = new Map()
|
|
24
|
+
const names = new Map()
|
|
25
|
+
for (const todo of todos) {
|
|
26
|
+
if (!includeArchived && todo.status === 'archived') continue
|
|
27
|
+
if (!groups.has(todo.project)) groups.set(todo.project, { key: todo.project, project: todo.project, total: 0, pending: [], completed: [], archived: [] })
|
|
28
|
+
const group = groups.get(todo.project)
|
|
29
|
+
const name = directoryName(todo.projectDirectoryName)
|
|
30
|
+
if (name) {
|
|
31
|
+
const createdAt = typeof todo.createdAt === 'string' ? todo.createdAt : ''
|
|
32
|
+
if (!names.has(todo.project) || createdAt >= names.get(todo.project).createdAt) names.set(todo.project, { name, createdAt })
|
|
33
|
+
}
|
|
34
|
+
group[todo.status].push(todo)
|
|
35
|
+
group.total++
|
|
36
|
+
}
|
|
37
|
+
return [...groups.values()].map((group) => ({ ...group, directoryName: names.get(group.key)?.name ?? directories.get(group.key) ?? '' }))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function projectDirectoryLabel(scope, language = 'es') {
|
|
41
|
+
return typeof scope?.directoryName === 'string' && scope.directoryName.trim()
|
|
42
|
+
? scope.directoryName
|
|
43
|
+
: language === 'es' ? 'Directorio no disponible' : 'Directory unavailable'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function checkpointTitle(item, language = 'es') {
|
|
47
|
+
return typeof item?.sessionTitle === 'string' && item.sessionTitle.trim()
|
|
48
|
+
? item.sessionTitle
|
|
49
|
+
: language === 'es' ? 'Título no disponible' : 'Title unavailable'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function reportCategory(key) {
|
|
53
|
+
const match = /^(request:)?(tool|cli):([^:]+)$/.exec(key)
|
|
54
|
+
if (!match) return null
|
|
55
|
+
return {
|
|
56
|
+
group: match[2] === 'tool' ? 'tools' : 'commands',
|
|
57
|
+
kind: match[2],
|
|
58
|
+
name: match[3],
|
|
59
|
+
signal: match[1] ? 'request' : 'activity',
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function reportGroups(counts = {}) {
|
|
64
|
+
const groups = Object.fromEntries(['tools', 'commands'].map((key) => [key, { key, total: 0, items: [] }]))
|
|
65
|
+
for (const [key, count] of Object.entries(counts)) {
|
|
66
|
+
const category = reportCategory(key)
|
|
67
|
+
if (!category || !Number.isSafeInteger(count) || count < 1) continue
|
|
68
|
+
const group = groups[category.group]
|
|
69
|
+
group.total += count
|
|
70
|
+
group.items.push({ key, name: category.name, kind: category.kind, signal: category.signal, count })
|
|
71
|
+
}
|
|
72
|
+
for (const group of Object.values(groups)) group.items.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
|
|
73
|
+
return Object.values(groups)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function dailyTrend(daily = {}, today = new Date().toISOString().slice(0, 10)) {
|
|
77
|
+
const cutoff = new Date(`${today}T00:00:00.000Z`)
|
|
78
|
+
cutoff.setUTCDate(cutoff.getUTCDate() - 364)
|
|
79
|
+
const oldest = cutoff.toISOString().slice(0, 10)
|
|
80
|
+
return Object.entries(daily).filter(([date]) => /^\d{4}-\d{2}-\d{2}$/.test(date) && date >= oldest && date <= today && new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) === date).sort(([a], [b]) => a.localeCompare(b)).map(([date, counts]) => {
|
|
81
|
+
const row = { date, tools: 0, commands: 0 }
|
|
82
|
+
for (const [key, count] of Object.entries(counts ?? {})) {
|
|
83
|
+
const category = reportCategory(key)
|
|
84
|
+
if (category && Number.isSafeInteger(count) && count > 0) row[category.group] += count
|
|
85
|
+
}
|
|
86
|
+
return row
|
|
87
|
+
}).filter(({ tools, commands }) => tools + commands > 0)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function metricDate(value) {
|
|
91
|
+
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10) === value
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function metricsRange(days, today = new Date().toISOString().slice(0, 10)) {
|
|
95
|
+
const allowed = [7, 30, 90]
|
|
96
|
+
const span = allowed.includes(Number(days)) ? Number(days) : 30
|
|
97
|
+
const to = metricDate(today) ? today : new Date().toISOString().slice(0, 10)
|
|
98
|
+
const from = new Date(`${to}T00:00:00.000Z`)
|
|
99
|
+
from.setUTCDate(from.getUTCDate() - (span - 1))
|
|
100
|
+
return { from: from.toISOString().slice(0, 10), to }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function tokenBars(series = [], options = {}) {
|
|
104
|
+
if (!Array.isArray(series) || !series.length) return []
|
|
105
|
+
const rows = series.filter((row) => row && metricDate(row.day))
|
|
106
|
+
if (!rows.length) return []
|
|
107
|
+
const width = options.width ?? 720
|
|
108
|
+
const height = options.height ?? 180
|
|
109
|
+
const left = options.left ?? 28
|
|
110
|
+
const right = options.right ?? 16
|
|
111
|
+
const top = options.top ?? 12
|
|
112
|
+
const bottom = options.bottom ?? 28
|
|
113
|
+
const plotWidth = width - left - right
|
|
114
|
+
const plotHeight = height - top - bottom
|
|
115
|
+
const max = Math.max(1, ...rows.map((row) => {
|
|
116
|
+
const tokens = row.tokens ?? {}
|
|
117
|
+
return Number(tokens.input || 0) + Number(tokens.cacheRead || 0) + Number(tokens.output || 0)
|
|
118
|
+
}))
|
|
119
|
+
const slot = plotWidth / rows.length
|
|
120
|
+
const barWidth = Math.max(1, Math.min(28, slot * .62))
|
|
121
|
+
return rows.flatMap((row, index) => {
|
|
122
|
+
const x = rows.length === 1 ? left + (plotWidth - barWidth) / 2 : left + index * slot + (slot - barWidth) / 2
|
|
123
|
+
const values = [
|
|
124
|
+
['input', Number(row.tokens?.input || 0)],
|
|
125
|
+
['cache', Number(row.tokens?.cacheRead || 0)],
|
|
126
|
+
['output', Number(row.tokens?.output || 0)],
|
|
127
|
+
]
|
|
128
|
+
let cursor = top + plotHeight
|
|
129
|
+
return values.map(([key, value]) => {
|
|
130
|
+
const heightValue = Math.max(0, value) / max * plotHeight
|
|
131
|
+
const y = Math.max(top, cursor - heightValue)
|
|
132
|
+
const bar = { day: row.day, key, value, x, y, width: barWidth, height: cursor - y }
|
|
133
|
+
cursor -= heightValue
|
|
134
|
+
return bar
|
|
135
|
+
}).filter(({ value, height: barHeight }) => value > 0 && barHeight > 0)
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function metricTokenTotal(row) {
|
|
140
|
+
const tokens = row?.tokens ?? {}
|
|
141
|
+
return ['input', 'output', 'reasoning', 'cacheRead', 'cacheWrite']
|
|
142
|
+
.reduce((total, field) => total + (Number(tokens[field]) || 0), 0)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function sortMetricRows(rows = [], key = 'steps', direction = 'desc') {
|
|
146
|
+
if (!Array.isArray(rows)) return []
|
|
147
|
+
const column = key === 'name' || key === 'tokens' ? key : 'steps'
|
|
148
|
+
const factor = direction === 'asc' ? 1 : -1
|
|
149
|
+
const nameOf = (row) => String(row?.name ?? row?.key ?? '')
|
|
150
|
+
const providerOf = (row) => String(row?.provider ?? '')
|
|
151
|
+
const valueOf = (row) => column === 'tokens'
|
|
152
|
+
? metricTokenTotal(row)
|
|
153
|
+
: column === 'steps' ? Number(row?.steps) || 0 : 0
|
|
154
|
+
return rows
|
|
155
|
+
.map((row, index) => ({ row, index }))
|
|
156
|
+
.sort((a, b) => {
|
|
157
|
+
const byName = nameOf(a.row).localeCompare(nameOf(b.row))
|
|
158
|
+
const primary = column === 'name' ? byName * factor : (valueOf(a.row) - valueOf(b.row)) * factor
|
|
159
|
+
if (primary) return primary
|
|
160
|
+
if (column === 'name') return providerOf(a.row).localeCompare(providerOf(b.row)) || a.index - b.index
|
|
161
|
+
return byName || providerOf(a.row).localeCompare(providerOf(b.row)) || a.index - b.index
|
|
162
|
+
})
|
|
163
|
+
.map(({ row }) => row)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function trendBars(series) {
|
|
167
|
+
if (!series.length) return []
|
|
168
|
+
const width = 720, height = 180, left = 28, right = 16, top = 12, bottom = 28
|
|
169
|
+
const days = series.map(({ date }) => Date.parse(`${date}T00:00:00.000Z`))
|
|
170
|
+
const distinctDays = days.at(-1) !== days[0]
|
|
171
|
+
const span = distinctDays ? Math.round((days.at(-1) - days[0]) / 86_400_000) : 1
|
|
172
|
+
const max = Math.max(1, ...series.flatMap(({ tools, commands }) => [tools, commands]))
|
|
173
|
+
const plotWidth = width - left - right, plotHeight = height - top - bottom
|
|
174
|
+
const slot = plotWidth / (span + 1), barWidth = Math.max(.5, slot * .18)
|
|
175
|
+
const groupWidth = barWidth * 2
|
|
176
|
+
return series.flatMap((row, index) => {
|
|
177
|
+
const progress = distinctDays ? ((days[index] - days[0]) / 86_400_000) / span : .5
|
|
178
|
+
const center = left + groupWidth / 2 + progress * (plotWidth - groupWidth)
|
|
179
|
+
return ['tools', 'commands'].map((key, offset) => {
|
|
180
|
+
const barHeight = row[key] / max * plotHeight
|
|
181
|
+
return { date: row.date, key, count: row[key], x: center + (offset - 1) * barWidth, y: top + plotHeight - barHeight, width: barWidth, height: barHeight }
|
|
182
|
+
}).filter(({ count }) => count > 0)
|
|
183
|
+
})
|
|
184
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const viewIds = Object.freeze(['reports', 'checkpoints', 'todos', 'metrics'])
|
|
2
|
+
|
|
3
|
+
export function viewFromHash(hash) {
|
|
4
|
+
const view = hash.startsWith('#') ? hash.slice(1) : hash
|
|
5
|
+
return viewIds.includes(view) ? view : viewIds[0]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function watchHashView(target, onChange) {
|
|
9
|
+
const sync = () => onChange(viewFromHash(target.location.hash))
|
|
10
|
+
target.addEventListener('hashchange', sync)
|
|
11
|
+
sync()
|
|
12
|
+
return () => target.removeEventListener('hashchange', sync)
|
|
13
|
+
}
|
package/src/ui-server.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto"
|
|
2
|
+
import { lstatSync, mkdirSync, watch } from "node:fs"
|
|
3
|
+
import { mkdir, readFile, writeFile, lstat, unlink, rename, rmdir } from "node:fs/promises"
|
|
4
|
+
import { basename, dirname, join } from "node:path"
|
|
5
|
+
import { fileURLToPath } from "node:url"
|
|
6
|
+
import { homedir } from "node:os"
|
|
7
|
+
import { createServer, request as httpRequest } from "node:http"
|
|
8
|
+
import { clearCheckpoints, clearHabits, deleteCheckpoint, getData, storePath, updateCheckpoint } from "./store.js"
|
|
9
|
+
import { metricsPath, queryMetrics } from "./metrics.js"
|
|
10
|
+
import { pluginSnapshot, recordRequestStatus, recordServerError, recordSseClient, recordSseRefresh } from "./plugin-stats.js"
|
|
11
|
+
|
|
12
|
+
const maxBody = 8192
|
|
13
|
+
const distPath = join(fileURLToPath(new URL("../dist/", import.meta.url)))
|
|
14
|
+
const controlPath = "/__habit_memory_control"
|
|
15
|
+
export function createUiServer({ port = 0 } = {}) {
|
|
16
|
+
const token = randomBytes(32).toString("hex")
|
|
17
|
+
const clients = new Set()
|
|
18
|
+
let watcher, refreshTimer
|
|
19
|
+
const server = createServer(async (req, res) => {
|
|
20
|
+
const host = req.headers.host
|
|
21
|
+
if (![ `127.0.0.1:${server.address()?.port}`, `localhost:${server.address()?.port}` ].includes(host)) { recordRequestStatus(403); res.writeHead(403).end("Forbidden"); return }
|
|
22
|
+
const method = req.method || "GET", url = new URL(req.url || "/", `http://${host}`)
|
|
23
|
+
if (url.pathname === controlPath) {
|
|
24
|
+
const supplied = req.headers["x-habit-token"]
|
|
25
|
+
const authorized = ["GET", "POST"].includes(method) && req.headers.origin === `http://${host}` && typeof supplied === "string" && Buffer.byteLength(supplied) === Buffer.byteLength(token) && timingSafeEqual(Buffer.from(supplied), Buffer.from(token))
|
|
26
|
+
if (!authorized) { recordRequestStatus(403); res.writeHead(403).end(); return }
|
|
27
|
+
recordRequestStatus(200)
|
|
28
|
+
if (method === "GET") { res.writeHead(200, { "cache-control": "no-store" }).end("active"); return }
|
|
29
|
+
res.writeHead(200, { "cache-control": "no-store" }).end("stopping")
|
|
30
|
+
setImmediate(() => { server.closeStreams(); server.close() })
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
const securityHeaders = { "x-content-type-options": "nosniff", "content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'", "referrer-policy": "no-referrer" }
|
|
34
|
+
const send = (status, payload, type = "application/json; charset=utf-8") => { recordRequestStatus(status); res.writeHead(status, { "content-type": type, "cache-control": "no-store", ...securityHeaders }); res.end(payload) }
|
|
35
|
+
if (method === "GET" && url.pathname === "/") {
|
|
36
|
+
try { const html = (await readFile(join(distPath, "index.html"), "utf8")).replace("<head>", `<head><meta name="habit-token" content="${token}">`); send(200, html, "text/html; charset=utf-8") }
|
|
37
|
+
catch { send(503, "WebUI assets are missing (dist/index.html was not found). Run `npm run build` from the plugin directory to generate `dist/`.", "text/plain; charset=utf-8") }
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
const asset = /^\/assets\/(index-[a-zA-Z0-9_-]+\.(?:js|css))$/.exec(url.pathname)
|
|
41
|
+
if (method === "GET" && asset) {
|
|
42
|
+
try { const file = asset[1], type = file.endsWith(".js") ? "text/javascript; charset=utf-8" : "text/css; charset=utf-8"; send(200, await readFile(join(distPath, "assets", file)), type) }
|
|
43
|
+
catch { send(404, "Not found", "text/plain; charset=utf-8") }
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
if (method === "GET" && url.pathname === "/api/data") {
|
|
47
|
+
try { const data = await getData(); send(200, JSON.stringify({ habits: data.habits, checkpoints: data.checkpoints, todos: data.todos })) }
|
|
48
|
+
catch { send(500, JSON.stringify({ error: "Unable to read local memory" })) }
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
if (method === "GET" && url.pathname === "/api/metrics") {
|
|
52
|
+
try {
|
|
53
|
+
const filters = {}
|
|
54
|
+
for (const key of ["from", "to", "project", "agent", "provider", "model", "kind"]) {
|
|
55
|
+
const value = url.searchParams.get(key)
|
|
56
|
+
if (value !== null && value !== "") filters[key] = value
|
|
57
|
+
}
|
|
58
|
+
let storeData
|
|
59
|
+
try { storeData = await getData() } catch {}
|
|
60
|
+
const result = queryMetrics(filters)
|
|
61
|
+
result.plugin = pluginSnapshot({ storeData, storePath, metricsPath })
|
|
62
|
+
send(200, JSON.stringify(result))
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const invalid = error?.code === "INVALID_METRICS_QUERY"
|
|
65
|
+
send(invalid ? 400 : 500, JSON.stringify({ error: invalid ? "Invalid metrics query" : "Unable to read metrics" }))
|
|
66
|
+
}
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (method === "GET" && url.pathname === "/api/events") {
|
|
70
|
+
recordRequestStatus(200)
|
|
71
|
+
res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-store", connection: "keep-alive", ...securityHeaders })
|
|
72
|
+
res.write(": connected\n\n")
|
|
73
|
+
clients.add(res)
|
|
74
|
+
recordSseClient(1)
|
|
75
|
+
const heartbeat = setInterval(() => {
|
|
76
|
+
if (res.writableEnded) return
|
|
77
|
+
try { res.write(": ping\n\n") } catch { res.end() }
|
|
78
|
+
}, 30_000)
|
|
79
|
+
heartbeat.unref?.()
|
|
80
|
+
res.on("close", () => { clearInterval(heartbeat); if (clients.delete(res)) recordSseClient(-1) })
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if (method !== "POST" || !["/api/checkpoint/edit", "/api/checkpoint/delete", "/api/scope/delete", "/api/habits/clear"].includes(url.pathname)) { send(404, JSON.stringify({ error: "Not found" })); return }
|
|
84
|
+
const origin = req.headers.origin
|
|
85
|
+
if (origin !== `http://${host}` || req.headers["x-habit-token"] !== token) { send(403, JSON.stringify({ error: "Forbidden" })); return }
|
|
86
|
+
let raw = ""
|
|
87
|
+
try {
|
|
88
|
+
if (!/^application\/json(?:\s*;|$)/i.test(req.headers["content-type"] || "")) throw new Error()
|
|
89
|
+
for await (const chunk of req) { raw += chunk; if (Buffer.byteLength(raw) > maxBody) { send(413, JSON.stringify({ error: "Request too large" })); return } }
|
|
90
|
+
const body = JSON.parse(raw)
|
|
91
|
+
const clearingHabits = url.pathname === "/api/habits/clear", editing = url.pathname === "/api/checkpoint/edit", deleting = url.pathname === "/api/checkpoint/delete"
|
|
92
|
+
const fields = clearingHabits ? ["confirm"] : editing ? ["project", "context", "id", "summary"] : deleting ? ["project", "context", "id"] : ["project", "context"]
|
|
93
|
+
if (!body || Array.isArray(body) || Object.keys(body).length !== fields.length || Object.keys(body).some((key) => !fields.includes(key))) throw new Error()
|
|
94
|
+
if (clearingHabits) {
|
|
95
|
+
if (body.confirm !== true) throw new Error()
|
|
96
|
+
await clearHabits()
|
|
97
|
+
send(200, JSON.stringify({ changed: true }))
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
const changed = editing ? await updateCheckpoint(body.project, body.context, body.id, body.summary) : deleting ? await deleteCheckpoint(body.project, body.context, body.id) : await clearCheckpoints(body.project, body.context)
|
|
101
|
+
send(changed ? 200 : 404, JSON.stringify({ changed }))
|
|
102
|
+
} catch { send(400, JSON.stringify({ error: "Invalid request" })) }
|
|
103
|
+
})
|
|
104
|
+
server.on("clientError", (_error, socket) => { recordRequestStatus(400); socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n") })
|
|
105
|
+
try {
|
|
106
|
+
const directory = dirname(storePath)
|
|
107
|
+
const metricsName = basename(metricsPath)
|
|
108
|
+
// -shm is excluded: read-only metrics queries update it and would self-trigger SSE.
|
|
109
|
+
const watched = new Set([basename(storePath), metricsName, `${metricsName}-wal`])
|
|
110
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
111
|
+
watcher = watch(directory, (_event, filename) => {
|
|
112
|
+
if (filename && !watched.has(filename)) return
|
|
113
|
+
if (refreshTimer) return
|
|
114
|
+
refreshTimer = setTimeout(() => {
|
|
115
|
+
refreshTimer = undefined
|
|
116
|
+
if (clients.size) recordSseRefresh()
|
|
117
|
+
for (const client of clients) {
|
|
118
|
+
if (client.writableEnded) { if (clients.delete(client)) recordSseClient(-1); continue }
|
|
119
|
+
try { client.write("data: refresh\n\n") } catch { if (clients.delete(client)) recordSseClient(-1); recordServerError() }
|
|
120
|
+
}
|
|
121
|
+
}, 100)
|
|
122
|
+
})
|
|
123
|
+
watcher.unref?.()
|
|
124
|
+
watcher.on("error", () => { watcher?.close(); watcher = undefined })
|
|
125
|
+
} catch {}
|
|
126
|
+
server.closeStreams = () => { const count = clients.size; for (const client of clients) { try { client.end() } catch {} } clients.clear(); if (count) recordSseClient(-count) }
|
|
127
|
+
server.on("close", () => { clearTimeout(refreshTimer); watcher?.close(); server.closeStreams() })
|
|
128
|
+
server.habitToken = token
|
|
129
|
+
return server.listen(port, "127.0.0.1")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let uiServer
|
|
133
|
+
|
|
134
|
+
const ipcFiles = () => {
|
|
135
|
+
const base = process.env.XDG_RUNTIME_DIR && isSafeRuntimeDir(process.env.XDG_RUNTIME_DIR)
|
|
136
|
+
? join(process.env.XDG_RUNTIME_DIR, "habit-memory")
|
|
137
|
+
: join(homedir(), ".local", "share", "habit-memory")
|
|
138
|
+
const dir = base
|
|
139
|
+
return { dir, descriptor: join(dir, "ui-control.json"), token: join(dir, "ui-control.token"), lock: join(dir, "ui-control.lock") }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isSafeRuntimeDir(path) {
|
|
143
|
+
try {
|
|
144
|
+
const stat = lstatSync(path)
|
|
145
|
+
return stat.isDirectory() && stat.uid === process.getuid?.() && !(stat.mode & 0o077)
|
|
146
|
+
} catch { return false }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function withLock(paths, fn) {
|
|
150
|
+
const deadline = Date.now() + 3000
|
|
151
|
+
while (true) {
|
|
152
|
+
try { await mkdir(paths.lock, { mode: 0o700 }); break }
|
|
153
|
+
catch (error) { if (error.code !== "EEXIST" || Date.now() >= deadline) throw error; await new Promise((resolve) => setTimeout(resolve, 25)) }
|
|
154
|
+
}
|
|
155
|
+
try { return await fn() } finally { await rmdir(paths.lock).catch(() => {}) }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function readDescriptor(paths = ipcFiles()) {
|
|
159
|
+
const stat = await lstat(paths.descriptor)
|
|
160
|
+
if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077)) throw new Error("Unsafe UI control descriptor")
|
|
161
|
+
const raw = await readFile(paths.descriptor, "utf8")
|
|
162
|
+
const descriptor = JSON.parse(raw)
|
|
163
|
+
const secret = await lstat(paths.token)
|
|
164
|
+
if (!secret.isFile() || secret.uid !== process.getuid?.() || (secret.mode & 0o077) || descriptor.token !== await readFile(paths.token, "utf8") || !/^[a-f0-9]{64}$/.test(descriptor.token) || !Number.isInteger(descriptor.port) || descriptor.port < 1 || descriptor.port > 65535) throw new Error("Invalid UI control descriptor")
|
|
165
|
+
return { ...descriptor, paths, identity: { descriptor: { dev: stat.dev, ino: stat.ino, raw }, token: { dev: secret.dev, ino: secret.ino } } }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function controlRequest(descriptor, { method = "GET", path = controlPath, timeout = 500, bodyLimit = 64 } = {}) {
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const req = httpRequest({ hostname: "127.0.0.1", port: descriptor.port, path, method, headers: { host: `127.0.0.1:${descriptor.port}`, origin: `http://127.0.0.1:${descriptor.port}`, ...(path === controlPath ? { "x-habit-token": descriptor.token } : {}) } }, (res) => {
|
|
171
|
+
const chunks = []
|
|
172
|
+
let size = 0
|
|
173
|
+
res.on("data", (chunk) => {
|
|
174
|
+
size += chunk.length
|
|
175
|
+
if (size > bodyLimit) { req.destroy(new Error("Control response too large")); return }
|
|
176
|
+
chunks.push(chunk)
|
|
177
|
+
})
|
|
178
|
+
res.once("end", () => { clearTimeout(timer); resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString() }) })
|
|
179
|
+
res.once("aborted", () => { clearTimeout(timer); reject(Object.assign(new Error("Control response aborted"), { code: "ECONNRESET" })) })
|
|
180
|
+
res.once("error", (error) => { clearTimeout(timer); reject(error) })
|
|
181
|
+
})
|
|
182
|
+
const timer = setTimeout(() => req.destroy(Object.assign(new Error("Control request timed out"), { code: "ETIMEDOUT" })), timeout)
|
|
183
|
+
req.once("error", (error) => { clearTimeout(timer); reject(error) })
|
|
184
|
+
req.end()
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function requestRemoteStop(descriptor) {
|
|
189
|
+
try {
|
|
190
|
+
const response = await controlRequest(descriptor, { method: "POST", timeout: 1000 })
|
|
191
|
+
if (response.status !== 200 || response.body !== "stopping") return false
|
|
192
|
+
const deadline = Date.now() + 1500
|
|
193
|
+
while (Date.now() < deadline) {
|
|
194
|
+
try { await controlRequest(descriptor, { path: "/", timeout: 100, bodyLimit: 0 }) }
|
|
195
|
+
catch (error) { if (error.code === "ECONNREFUSED") return true }
|
|
196
|
+
await new Promise((done) => setTimeout(done, 25))
|
|
197
|
+
}
|
|
198
|
+
return false
|
|
199
|
+
} catch { return false }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function remoteIsActive(descriptor) {
|
|
203
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
204
|
+
try {
|
|
205
|
+
const response = await controlRequest(descriptor)
|
|
206
|
+
if (response.status === 200 && response.body === "active") return true
|
|
207
|
+
throw new Error("Existing UI control endpoint returned an unexpected response")
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (error.code === "ECONNREFUSED") return false
|
|
210
|
+
if (error.code === "ETIMEDOUT") throw new Error("Existing UI control endpoint timed out")
|
|
211
|
+
if (error.message.startsWith("Existing UI control endpoint")) throw error
|
|
212
|
+
if (attempt === 0 && error.code === "ECONNRESET") continue
|
|
213
|
+
const code = typeof error.code === "string" && /^[A-Z0-9_]{1,40}$/.test(error.code) ? ` (${error.code})` : ""
|
|
214
|
+
throw new Error(`Existing UI control endpoint could not be verified${code}`)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function removeStale(paths, existing) {
|
|
220
|
+
const current = await readDescriptor(paths)
|
|
221
|
+
if (current.identity.descriptor.dev !== existing.identity.descriptor.dev || current.identity.descriptor.ino !== existing.identity.descriptor.ino || current.identity.descriptor.raw !== existing.identity.descriptor.raw || current.identity.token.dev !== existing.identity.token.dev || current.identity.token.ino !== existing.identity.token.ino || current.token !== existing.token) throw new Error("UI control state changed during recovery")
|
|
222
|
+
await unlink(paths.descriptor)
|
|
223
|
+
const tokenStat = await lstat(paths.token)
|
|
224
|
+
if (tokenStat.dev !== existing.identity.token.dev || tokenStat.ino !== existing.identity.token.ino || await readFile(paths.token, "utf8") !== existing.token) throw new Error("UI control token changed during recovery")
|
|
225
|
+
await unlink(paths.token)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function removeOwned(paths, token, descriptorIdentity) {
|
|
229
|
+
try {
|
|
230
|
+
if (await readFile(paths.token, "utf8") !== token) return
|
|
231
|
+
if (!descriptorIdentity) { await unlink(paths.token); return }
|
|
232
|
+
const current = await readDescriptor(paths)
|
|
233
|
+
const stat = await lstat(paths.descriptor)
|
|
234
|
+
if (current.token !== token || stat.dev !== descriptorIdentity.dev || stat.ino !== descriptorIdentity.ino) return
|
|
235
|
+
await unlink(paths.descriptor)
|
|
236
|
+
if (await readFile(paths.token, "utf8") === token) await unlink(paths.token)
|
|
237
|
+
} catch {}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function installControl(server) {
|
|
241
|
+
const paths = ipcFiles()
|
|
242
|
+
await mkdir(paths.dir, { recursive: true, mode: 0o700 })
|
|
243
|
+
const dirStat = await lstat(paths.dir)
|
|
244
|
+
if (!dirStat.isDirectory() || dirStat.uid !== process.getuid?.() || (dirStat.mode & 0o077)) throw new Error("Unsafe Habit Memory control directory")
|
|
245
|
+
await withLock(paths, async () => {
|
|
246
|
+
let descriptorExists = true
|
|
247
|
+
try { await lstat(paths.descriptor) }
|
|
248
|
+
catch (error) { if (error.code === "ENOENT") descriptorExists = false; else throw error }
|
|
249
|
+
if (descriptorExists) {
|
|
250
|
+
const existing = await readDescriptor(paths)
|
|
251
|
+
if (await remoteIsActive(existing)) {
|
|
252
|
+
server.remotePort = existing.port
|
|
253
|
+
server.close()
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
await removeStale(paths, existing)
|
|
257
|
+
}
|
|
258
|
+
const token = server.habitToken
|
|
259
|
+
let identity
|
|
260
|
+
server.once("close", () => {
|
|
261
|
+
if (identity) void withLock(paths, () => removeOwned(paths, token, identity)).catch(() => {})
|
|
262
|
+
})
|
|
263
|
+
const tempToken = `${paths.token}.${randomBytes(8).toString("hex")}.tmp`, tempDescriptor = `${paths.descriptor}.${randomBytes(8).toString("hex")}.tmp`
|
|
264
|
+
await writeFile(tempToken, token, { mode: 0o600, flag: "wx" })
|
|
265
|
+
await rename(tempToken, paths.token)
|
|
266
|
+
const publish = () => {
|
|
267
|
+
const descriptor = JSON.stringify({ port: server.address().port, token })
|
|
268
|
+
return writeFile(tempDescriptor, descriptor, { mode: 0o600, flag: "wx" }).then(() => rename(tempDescriptor, paths.descriptor))
|
|
269
|
+
}
|
|
270
|
+
if (!server.listening) throw new Error("UI listener closed before control publication")
|
|
271
|
+
await publish()
|
|
272
|
+
identity = await lstat(paths.descriptor)
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function startUiServer() {
|
|
277
|
+
if (uiServer?.listening) return uiServer
|
|
278
|
+
const server = createUiServer({ port: Number(process.env.HABIT_MEMORY_UI_PORT || 43109) })
|
|
279
|
+
uiServer = server
|
|
280
|
+
server.controlReady = new Promise((resolve, reject) => {
|
|
281
|
+
server.once("listening", () => installControl(server).then(resolve, (error) => {
|
|
282
|
+
server.close()
|
|
283
|
+
reject(error)
|
|
284
|
+
}))
|
|
285
|
+
server.once("error", reject)
|
|
286
|
+
})
|
|
287
|
+
server.once("close", () => { if (uiServer === server) uiServer = undefined })
|
|
288
|
+
server.on("listening", () => process.stdout.write(`Habit Memory UI: http://127.0.0.1:${server.address().port}\n`))
|
|
289
|
+
return server
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function stopUiServer() {
|
|
293
|
+
if (!uiServer?.listening) return withLock(ipcFiles(), async () => { const descriptor = await readDescriptor(); if (!await requestRemoteStop(descriptor)) return false; return true }).catch(() => false)
|
|
294
|
+
const server = uiServer
|
|
295
|
+
await server.controlReady
|
|
296
|
+
server.closeStreams()
|
|
297
|
+
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve(true)))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) startUiServer()
|