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.
@@ -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
+ }
@@ -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()