mango-cms 0.3.33 → 0.3.35

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.
Files changed (34) hide show
  1. package/cli.js +57 -0
  2. package/default/infra/vibe/README.md +43 -0
  3. package/default/infra/vibe/cloudflare.ini.template +26 -0
  4. package/default/infra/vibe/ecosystem.vibe.config.cjs +44 -0
  5. package/default/infra/vibe/nginx-vibe-orchestrator.conf.template +50 -0
  6. package/default/infra/vibe/nginx-vibe-staging.conf.template +73 -0
  7. package/default/infra/vibe/vibe-gateway.service +38 -0
  8. package/default/infra/vibe/vibe-orchestrator.service +44 -0
  9. package/default/infra/vibe/vibe.env.template +24 -0
  10. package/default/mango/config/settings.json +35 -1
  11. package/default/vite.config.js +46 -0
  12. package/lib/devProxyGateway.js +173 -0
  13. package/lib/staging-gateway.js +23 -1
  14. package/lib/vibe-orchestrator/README.md +76 -0
  15. package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
  16. package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
  17. package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
  18. package/lib/vibe-orchestrator/server.js +344 -0
  19. package/lib/vibe-orchestrator/src/attachments.js +98 -0
  20. package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
  21. package/lib/vibe-orchestrator/src/config.js +227 -0
  22. package/lib/vibe-orchestrator/src/costMirror.js +64 -0
  23. package/lib/vibe-orchestrator/src/costStore.js +209 -0
  24. package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
  25. package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
  26. package/lib/vibe-orchestrator/src/preamble.js +139 -0
  27. package/lib/vibe-orchestrator/src/publisher.js +376 -0
  28. package/lib/vibe-orchestrator/src/recovery.js +199 -0
  29. package/lib/vibe-orchestrator/src/screenshot.js +38 -0
  30. package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
  31. package/lib/vibe-orchestrator/src/streamParser.js +188 -0
  32. package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
  33. package/package.json +1 -1
  34. package/readme.md +6 -0
@@ -0,0 +1,199 @@
1
+ // recovery.js
2
+ //
3
+ // Always a way back (HAP-1124, protection #2). A vibe edit must never leave the
4
+ // staging clone in a non-recoverable state, so:
5
+ //
6
+ // • autoCommit(site) — after every run, stage the clone (settings.json
7
+ // excluded, exactly like publish) and commit any
8
+ // change with a structured message. History always
9
+ // exists, so any edit is verifiable + reversible.
10
+ // • ensureBaseline(site) — on the first auto-commit, tag the pre-edit tree as
11
+ // the known-good baseline `vibe-baseline`.
12
+ // • revertLast(site) — undo the most recent vibe edit (git revert, keeps
13
+ // history; non-destructive).
14
+ // • resetToBaseline(site) — hard-reset the clone back to the baseline tag,
15
+ // discarding all vibe edits (recoverable via reflog).
16
+ // • status(site) — baseline + recent vibe commits + what's available.
17
+ //
18
+ // All of this is plain git on the staging clone — no engineer required. The
19
+ // orchestrator exposes it as endpoints and a one-command script (scripts/
20
+ // vibe-recover.sh) so the client/board can recover with one call/click.
21
+
22
+ import { execFile } from 'node:child_process'
23
+ import { resolveSiteDir, config } from './config.js'
24
+ import { SETTINGS_PATH } from './publisher.js'
25
+
26
+ const EXCLUDE_SETTINGS = `:(exclude)${SETTINGS_PATH}`
27
+ const COMMIT_PREFIX = 'vibe:'
28
+
29
+ /** Promisified git scoped to a dir. Resolves { code, stdout, stderr }; rejects
30
+ * only when mustSucceed and the command fails. */
31
+ function git(cwd, args, { mustSucceed = false } = {}) {
32
+ return new Promise((resolve, reject) => {
33
+ execFile('git', args, { cwd, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
34
+ const code = err && typeof err.code === 'number' ? err.code : err ? 1 : 0
35
+ if (err && mustSucceed) {
36
+ const e = new Error(`git ${args[0]} failed: ${(stderr || err.message || '').trim()}`)
37
+ e.code = code
38
+ return reject(e)
39
+ }
40
+ resolve({ code, stdout: stdout || '', stderr: stderr || '' })
41
+ })
42
+ })
43
+ }
44
+
45
+ async function headSha(cwd) {
46
+ const r = await git(cwd, ['rev-parse', 'HEAD'])
47
+ return r.code === 0 ? r.stdout.trim() : null
48
+ }
49
+
50
+ async function tagSha(cwd, tag) {
51
+ const r = await git(cwd, ['rev-parse', '--verify', '--quiet', `${tag}^{commit}`])
52
+ return r.code === 0 ? r.stdout.trim() : null
53
+ }
54
+
55
+ /** Build a single-line, structured commit message for an applied vibe edit. */
56
+ function commitMessage({ summary, sessionId }) {
57
+ const s = String(summary || 'edit').replace(/\s+/g, ' ').trim().slice(0, 140) || 'edit'
58
+ const sess = sessionId ? ` [session ${sessionId}]` : ''
59
+ return `${COMMIT_PREFIX} ${s}${sess}`
60
+ }
61
+
62
+ /**
63
+ * Ensure the known-good baseline tag exists, pointing at the current HEAD. Called
64
+ * before the first auto-commit so the baseline captures the pre-edit tree.
65
+ * Idempotent — never moves an existing tag.
66
+ * @returns {Promise<{tag, sha, created}>}
67
+ */
68
+ async function ensureBaseline(site, opts = {}) {
69
+ const dir = opts.dir || resolveSiteDir(site)
70
+ const tag = opts.tag || config.baselineTag
71
+ const existing = await tagSha(dir, tag)
72
+ if (existing) return { tag, sha: existing, created: false }
73
+ const sha = await headSha(dir)
74
+ if (!sha) return { tag, sha: null, created: false } // no commits yet — nothing to tag
75
+ await git(dir, ['tag', '-a', tag, '-m', 'vibe known-good baseline (HAP-1124)'], { mustSucceed: true })
76
+ return { tag, sha, created: true }
77
+ }
78
+
79
+ /**
80
+ * Auto-commit any working-tree change from a vibe run. settings.json is excluded
81
+ * (same hard rule as publish). No-op when the tree is clean. Tags the baseline on
82
+ * the first commit.
83
+ * @returns {Promise<{committed, sha?, message?, baseline}>}
84
+ */
85
+ async function autoCommit(site, opts = {}) {
86
+ const dir = opts.dir || resolveSiteDir(site)
87
+
88
+ // Tag the pre-edit tree as baseline before the first commit lands.
89
+ const baseline = await ensureBaseline(site, { dir, tag: opts.tag })
90
+
91
+ // Stage everything except settings.json (overlay-materialized; never committed
92
+ // by vibe — it is also on the protected denylist).
93
+ await git(dir, ['add', '-A', '--', '.', EXCLUDE_SETTINGS], { mustSucceed: true })
94
+
95
+ const staged = await git(dir, ['diff', '--cached', '--quiet'])
96
+ if (staged.code === 0) return { committed: false, baseline } // nothing changed
97
+
98
+ const message = commitMessage(opts)
99
+ await git(dir, ['commit', '-m', message], { mustSucceed: true })
100
+ const sha = await headSha(dir)
101
+ return { committed: true, sha, message, baseline }
102
+ }
103
+
104
+ /** Recent vibe commits (newest first), most-recent `limit`. */
105
+ async function vibeCommits(dir, limit = 20) {
106
+ const r = await git(dir, ['log', `-n${limit}`, '--pretty=format:%H%x1f%s%x1f%cI'])
107
+ if (r.code !== 0 || !r.stdout.trim()) return []
108
+ return r.stdout.trim().split('\n').map((line) => {
109
+ const [sha, subject, date] = line.split('\x1f')
110
+ return { sha, subject, date, isVibe: (subject || '').startsWith(COMMIT_PREFIX) }
111
+ })
112
+ }
113
+
114
+ /**
115
+ * Recovery status: baseline, current HEAD, recent commits, and which recovery
116
+ * actions are currently available.
117
+ */
118
+ async function status(site, opts = {}) {
119
+ const dir = opts.dir || resolveSiteDir(site)
120
+ const tag = opts.tag || config.baselineTag
121
+ const [head, baseline, commits] = await Promise.all([
122
+ headSha(dir),
123
+ tagSha(dir, tag),
124
+ vibeCommits(dir),
125
+ ])
126
+ const lastIsVibe = commits.length > 0 && commits[0].isVibe
127
+ const aheadOfBaseline = !!(baseline && head && baseline !== head)
128
+ return {
129
+ site,
130
+ dir,
131
+ head,
132
+ baselineTag: tag,
133
+ baselineSha: baseline,
134
+ hasBaseline: !!baseline,
135
+ aheadOfBaseline,
136
+ recentCommits: commits,
137
+ canRevertLast: lastIsVibe,
138
+ canResetToBaseline: !!baseline && aheadOfBaseline,
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Revert the most recent vibe edit (git revert HEAD — keeps full history, never
144
+ * destructive). Refuses when HEAD is not a vibe commit so we never revert manual
145
+ * engineer work.
146
+ * @returns {Promise<{ok, revertedSha, newSha, message}>}
147
+ */
148
+ async function revertLast(site, opts = {}) {
149
+ const dir = opts.dir || resolveSiteDir(site)
150
+ const commits = await vibeCommits(dir, 1)
151
+ if (!commits.length || !commits[0].isVibe) {
152
+ const e = new Error('the most recent commit is not a vibe edit — nothing to revert')
153
+ e.statusCode = 409
154
+ throw e
155
+ }
156
+ const revertedSha = commits[0].sha
157
+ // Guard against a dirty tree wedging the revert: commit any stray change first.
158
+ await git(dir, ['add', '-A', '--', '.', EXCLUDE_SETTINGS])
159
+ const dirty = await git(dir, ['diff', '--cached', '--quiet'])
160
+ if (dirty.code !== 0) {
161
+ await git(dir, ['commit', '-m', `${COMMIT_PREFIX} snapshot before revert`], { mustSucceed: true })
162
+ }
163
+ await git(dir, ['revert', '--no-edit', revertedSha], { mustSucceed: true })
164
+ const newSha = await headSha(dir)
165
+ return { ok: true, revertedSha, newSha, message: `Reverted ${revertedSha.slice(0, 8)}` }
166
+ }
167
+
168
+ /**
169
+ * Reset the clone hard back to the known-good baseline, discarding all vibe edits
170
+ * since. Destructive on the working branch but fully recoverable via the reflog,
171
+ * and the baseline tag itself is never moved.
172
+ * @returns {Promise<{ok, baselineTag, baselineSha, previousHead}>}
173
+ */
174
+ async function resetToBaseline(site, opts = {}) {
175
+ const dir = opts.dir || resolveSiteDir(site)
176
+ const tag = opts.tag || config.baselineTag
177
+ const baseline = await tagSha(dir, tag)
178
+ if (!baseline) {
179
+ const e = new Error(`no baseline tag (${tag}) to reset to`)
180
+ e.statusCode = 409
181
+ throw e
182
+ }
183
+ const previousHead = await headSha(dir)
184
+ await git(dir, ['reset', '--hard', tag], { mustSucceed: true })
185
+ // Drop untracked files a vibe edit may have created so the tree truly matches
186
+ // the baseline. settings.json (and anything gitignored) is preserved with -e.
187
+ await git(dir, ['clean', '-fd', '-e', SETTINGS_PATH])
188
+ return { ok: true, baselineTag: tag, baselineSha: baseline, previousHead }
189
+ }
190
+
191
+ export {
192
+ autoCommit,
193
+ ensureBaseline,
194
+ revertLast,
195
+ resetToBaseline,
196
+ status,
197
+ commitMessage,
198
+ COMMIT_PREFIX,
199
+ }
@@ -0,0 +1,38 @@
1
+ // screenshot.js
2
+ //
3
+ // Persist a client-sent viewport screenshot (a base64 data URL) to a temp file
4
+ // the agent can Read — Claude Code reads images. The file lives in the OS temp
5
+ // dir, NOT in the staging repo, so it is never committed or published. One file
6
+ // per site is reused (overwritten each turn) to avoid unbounded growth.
7
+
8
+ import { writeFileSync, mkdirSync } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+
12
+ const DIR = join(tmpdir(), 'vibe-orchestrator')
13
+ const DATA_URL = /^data:image\/(png|jpeg|jpg|webp);base64,([A-Za-z0-9+/=]+)$/
14
+
15
+ /**
16
+ * Write a data-URL screenshot to disk and return its absolute path, or null when
17
+ * the input is missing/invalid or the write fails (best-effort — never throws).
18
+ * @param {string} site
19
+ * @param {string} dataUrl
20
+ * @returns {string|null}
21
+ */
22
+ function persistScreenshot(site, dataUrl) {
23
+ if (!dataUrl || typeof dataUrl !== 'string') return null
24
+ const m = DATA_URL.exec(dataUrl.trim())
25
+ if (!m) return null
26
+ try {
27
+ mkdirSync(DIR, { recursive: true })
28
+ const ext = m[1] === 'jpeg' ? 'jpg' : m[1]
29
+ const safe = String(site || 'site').replace(/[^a-z0-9_-]/gi, '_')
30
+ const path = join(DIR, `${safe}-viewport.${ext}`)
31
+ writeFileSync(path, Buffer.from(m[2], 'base64'))
32
+ return path
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ export { persistScreenshot }
@@ -0,0 +1,291 @@
1
+ // sessionManager.js
2
+ //
3
+ // Per-site session registry. One logical session per site survives across
4
+ // prompts: we remember the last claude session_id so the next prompt resumes
5
+ // the same conversation (`claude --resume <id>`), and we carry cumulative token
6
+ // usage forward so the per-session cap spans the whole conversation, not a
7
+ // single turn. Only one run may be in-flight per site at a time.
8
+
9
+ import { EventEmitter } from 'node:events'
10
+ import { runPrompt } from './claudeRunner.js'
11
+ import { resolveSiteDir, config, canRunLive } from './config.js'
12
+ import { composePrompt } from './preamble.js'
13
+ import { persistScreenshot } from './screenshot.js'
14
+ import { persistAttachments } from './attachments.js'
15
+ import { appendCost } from './costStore.js'
16
+ import { mirrorCost } from './costMirror.js'
17
+ import { autoCommit } from './recovery.js'
18
+
19
+ class SessionManager {
20
+ /** @param {object} [deps] - injectable runPrompt / clock / reaper for tests */
21
+ constructor(deps = {}) {
22
+ this.runPrompt = deps.runPrompt || runPrompt
23
+ this.cfg = deps.config || config
24
+ this.now = deps.now || (() => Date.now())
25
+ // HAP-1123: per-turn cost persistence. recordCost writes the durable NDJSON
26
+ // log (system of record); mirrorCost best-effort POSTs the same record to the
27
+ // Mango `vibeCosts` collection. Both injectable so tests stay fs/network-free.
28
+ this.recordCost = deps.recordCost || appendCost
29
+ this.mirrorCost = deps.mirrorCost || mirrorCost
30
+ // HAP-1124: after each run, auto-commit the applied edit to the clone's git so
31
+ // there is always a way back. Injectable so tests stay git-free.
32
+ this.autoCommit = deps.autoCommit || autoCommit
33
+ /** @type {Map<string, object>} site -> session record */
34
+ this.sessions = new Map()
35
+ this._reaper = null
36
+ // Auto-start the idle reaper unless explicitly disabled (tests pass false).
37
+ if (deps.startReaper !== false && this.cfg.sessionIdleMs > 0) this.startReaper()
38
+ }
39
+
40
+ _ensure(site) {
41
+ let s = this.sessions.get(site)
42
+ if (!s) {
43
+ s = {
44
+ site,
45
+ sessionId: null,
46
+ cumulativeTokens: 0,
47
+ status: 'idle', // idle | running | error
48
+ active: null, // current run handle while running
49
+ lastResult: null,
50
+ promptCount: 0,
51
+ lastActivity: this.now(), // ms; bumped by prompts, events, and keepalive touches
52
+ readers: 0, // open SSE connections actively reading this session
53
+ }
54
+ this.sessions.set(site, s)
55
+ }
56
+ return s
57
+ }
58
+
59
+ get(site) {
60
+ return this.sessions.get(site) || null
61
+ }
62
+
63
+ list() {
64
+ return [...this.sessions.values()].map((s) => ({
65
+ site: s.site,
66
+ sessionId: s.sessionId,
67
+ cumulativeTokens: s.cumulativeTokens,
68
+ status: s.status,
69
+ promptCount: s.promptCount,
70
+ }))
71
+ }
72
+
73
+ /** Reset a site's conversation (next prompt starts fresh). */
74
+ reset(site) {
75
+ const s = this.sessions.get(site)
76
+ if (s && s.status === 'running') throw new Error('cannot reset while running')
77
+ this.sessions.delete(site)
78
+ }
79
+
80
+ /**
81
+ * Submit a prompt to a site. Returns the run's EventEmitter immediately;
82
+ * progress events flow through it, and the session record is updated on end.
83
+ *
84
+ * @param {string} site
85
+ * @param {string} prompt
86
+ * @param {object} [opts]
87
+ * @param {object} [opts.pageContext] - per-turn snapshot of what the owner sees
88
+ * @param {Array} [opts.attachments] - { name, mime, dataUrl } files to persist
89
+ * @returns {{ emitter: EventEmitter, done: Promise<object>, abort: ()=>void }}
90
+ */
91
+ submit(site, prompt, opts = {}) {
92
+ if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
93
+ throw new Error('prompt is required')
94
+ }
95
+ if (!canRunLive()) {
96
+ throw new Error('CLAUDE_CODE_OAUTH_TOKEN not set — cannot run live')
97
+ }
98
+ const cwd = resolveSiteDir(site) // throws on bad slug / not allowed
99
+ const s = this._ensure(site)
100
+ if (s.status === 'running') {
101
+ throw new Error(`site ${site} already has a run in progress`)
102
+ }
103
+
104
+ // Remaining budget for this conversation (cap spans all turns).
105
+ const remaining = this.cfg.tokenCap > 0
106
+ ? Math.max(0, this.cfg.tokenCap - s.cumulativeTokens)
107
+ : 0
108
+ if (this.cfg.tokenCap > 0 && remaining === 0) {
109
+ throw new Error(`session token cap (${this.cfg.tokenCap}) reached for ${site}`)
110
+ }
111
+
112
+ // HAP-1111: enrich the prompt with agent orientation (first turn only) and a
113
+ // per-turn snapshot of what the owner is looking at, including a screenshot
114
+ // the agent can Read. Resumed turns already carry the orientation in history.
115
+ const ctx = { ...(opts.pageContext || {}) }
116
+ if (ctx.screenshot) {
117
+ const shot = persistScreenshot(site, ctx.screenshot)
118
+ delete ctx.screenshot
119
+ if (shot) ctx.screenshotPath = shot
120
+ }
121
+ // HAP-1126: persist any pasted/uploaded files (images + PDFs) to temp paths
122
+ // the agent can Read, and hand the absolute paths to the preamble. Passing an
123
+ // array (even empty) sweeps the previous turn's files so temp stays bounded.
124
+ const savedAttachments = persistAttachments(site, opts.attachments)
125
+ if (savedAttachments.length) {
126
+ ctx.attachmentPaths = savedAttachments.map((a) => ({ name: a.name, path: a.path }))
127
+ }
128
+ const composedPrompt = composePrompt({
129
+ prompt,
130
+ pageContext: ctx,
131
+ includePreamble: !s.sessionId,
132
+ })
133
+
134
+ // HAP-1123: remember a short, single-line prompt summary and the page the
135
+ // owner was on so the persisted cost record is human-readable on an invoice.
136
+ const promptSummary = prompt.trim().replace(/\s+/g, ' ').slice(0, 140)
137
+ const pagePath = ctx.route || ctx.url || null
138
+
139
+ const handle = this.runPrompt({
140
+ prompt: composedPrompt,
141
+ cwd,
142
+ oauthToken: this.cfg.oauthToken,
143
+ claudeBin: this.cfg.claudeBin,
144
+ permissionMode: this.cfg.permissionMode,
145
+ model: this.cfg.model,
146
+ resumeSessionId: s.sessionId,
147
+ tokenCap: remaining,
148
+ timeoutMs: this.cfg.runTimeoutMs,
149
+ markupPct: this.cfg.markupPct,
150
+ // HAP-1124: install the server-side PreToolUse path guard for this run.
151
+ pathGuardHook: this.cfg.pathGuardEnabled ? this.cfg.pathGuardHook : '',
152
+ // HAP-1096: pre-approve read-only web tools + grant read access to the temp
153
+ // dir holding the viewport screenshot and any pasted/uploaded attachments.
154
+ allowedTools: this.cfg.allowedTools,
155
+ addDirs: this.cfg.toolExtraDirs,
156
+ })
157
+
158
+ s.status = 'running'
159
+ s.active = handle
160
+ s.promptCount += 1
161
+ s.lastActivity = this.now()
162
+
163
+ // Every streamed event is fresh activity — keep the session warm while it runs.
164
+ if (handle.emitter?.on) handle.emitter.on('event', () => { s.lastActivity = this.now() })
165
+
166
+ handle.done.then((summary) => {
167
+ if (summary.sessionId) s.sessionId = summary.sessionId
168
+ // Accumulate BILLABLE tokens (new input + output), not the full volume that
169
+ // re-counts cached context each turn — keeps the per-session cap meaningful
170
+ // across multiple prompts. Falls back to totalTokens for older summaries.
171
+ if (summary.tokens) {
172
+ s.cumulativeTokens += summary.tokens.billableTokens ?? summary.tokens.totalTokens
173
+ }
174
+ s.status = summary.ok ? 'idle' : 'error'
175
+ s.active = null
176
+ s.lastResult = summary
177
+ s.lastActivity = this.now()
178
+
179
+ // Persist a per-turn cost record (HAP-1123). Best-effort: a logging failure
180
+ // must never surface to the run, and the Mango mirror is fire-and-forget.
181
+ try {
182
+ const tk = summary.tokens || {}
183
+ const entry = {
184
+ ts: this.now(),
185
+ site,
186
+ path: pagePath,
187
+ sessionId: summary.sessionId || s.sessionId || null,
188
+ model: summary.model || this.cfg.model || null,
189
+ costUsd: typeof summary.costUsd === 'number' ? summary.costUsd : null,
190
+ tokens: {
191
+ input: tk.inputTokens,
192
+ output: tk.outputTokens,
193
+ cacheRead: tk.cacheReadTokens,
194
+ total: tk.totalTokens,
195
+ billable: tk.billableTokens,
196
+ },
197
+ promptSummary,
198
+ ok: !!summary.ok,
199
+ }
200
+ const saved = this.recordCost(entry)
201
+ Promise.resolve(this.mirrorCost(saved)).catch(() => {})
202
+ } catch { /* cost logging is never allowed to break a run */ }
203
+
204
+ // HAP-1124: auto-commit any applied edit so history always exists and the
205
+ // tree is never left non-recoverable. Runs even on a non-ok/aborted summary
206
+ // (a partial edit must still be captured). Best-effort + injectable: a git
207
+ // failure must never surface to the run.
208
+ if (this.cfg.autoCommitEnabled !== false) {
209
+ Promise.resolve()
210
+ .then(() => this.autoCommit(site, {
211
+ summary: promptSummary,
212
+ sessionId: summary.sessionId || s.sessionId || null,
213
+ }))
214
+ .catch(() => {})
215
+ }
216
+ }).catch(() => {
217
+ s.status = 'error'
218
+ s.active = null
219
+ s.lastActivity = this.now()
220
+ })
221
+
222
+ return handle
223
+ }
224
+
225
+ /** Abort the in-flight run for a site, if any. */
226
+ abort(site) {
227
+ const s = this.sessions.get(site)
228
+ if (s?.active) s.active.abort()
229
+ }
230
+
231
+ /**
232
+ * Mark a session as freshly active without sending a prompt. Called on the SSE
233
+ * keepalive ping so a session that is being actively read (but not prompted) is
234
+ * not reaped mid-read — the original idle-reaper bug (HAP-1110 item 4): the old
235
+ * code only updated activity on a new message, so the keepalive did not count.
236
+ */
237
+ touch(site) {
238
+ const s = this.sessions.get(site)
239
+ if (s) s.lastActivity = this.now()
240
+ }
241
+
242
+ /** Register an open SSE reader for a site; a session with readers is never reaped. */
243
+ addReader(site) {
244
+ const s = this._ensure(site)
245
+ s.readers += 1
246
+ s.lastActivity = this.now()
247
+ }
248
+
249
+ /** Release an SSE reader and record the disconnect time as activity. */
250
+ removeReader(site) {
251
+ const s = this.sessions.get(site)
252
+ if (!s) return
253
+ s.readers = Math.max(0, s.readers - 1)
254
+ s.lastActivity = this.now()
255
+ }
256
+
257
+ /**
258
+ * Reap idle sessions. A session is reaped only when it is NOT running, has NO
259
+ * open readers, and has been quiet longer than the idle TTL. Running sessions
260
+ * and actively-read sessions are always kept warm.
261
+ * @returns {string[]} the sites that were reaped
262
+ */
263
+ reap() {
264
+ const ttl = this.cfg.sessionIdleMs
265
+ if (!ttl || ttl <= 0) return []
266
+ const now = this.now()
267
+ const reaped = []
268
+ for (const [site, s] of this.sessions) {
269
+ if (s.status === 'running' || s.readers > 0) continue
270
+ if (now - s.lastActivity > ttl) {
271
+ this.sessions.delete(site)
272
+ reaped.push(site)
273
+ }
274
+ }
275
+ return reaped
276
+ }
277
+
278
+ /** Start the periodic idle reaper (no-op if already running). */
279
+ startReaper() {
280
+ if (this._reaper) return
281
+ this._reaper = setInterval(() => this.reap(), this.cfg.reaperIntervalMs)
282
+ if (this._reaper.unref) this._reaper.unref() // never keep the process alive
283
+ }
284
+
285
+ /** Stop the idle reaper. */
286
+ stopReaper() {
287
+ if (this._reaper) { clearInterval(this._reaper); this._reaper = null }
288
+ }
289
+ }
290
+
291
+ export { SessionManager }