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.
- package/cli.js +57 -0
- package/default/infra/vibe/README.md +43 -0
- package/default/infra/vibe/cloudflare.ini.template +26 -0
- package/default/infra/vibe/ecosystem.vibe.config.cjs +44 -0
- package/default/infra/vibe/nginx-vibe-orchestrator.conf.template +50 -0
- package/default/infra/vibe/nginx-vibe-staging.conf.template +73 -0
- package/default/infra/vibe/vibe-gateway.service +38 -0
- package/default/infra/vibe/vibe-orchestrator.service +44 -0
- package/default/infra/vibe/vibe.env.template +24 -0
- package/default/mango/config/settings.json +35 -1
- package/default/vite.config.js +46 -0
- package/lib/devProxyGateway.js +173 -0
- package/lib/staging-gateway.js +23 -1
- package/lib/vibe-orchestrator/README.md +76 -0
- package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
- package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
- package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
- package/lib/vibe-orchestrator/server.js +344 -0
- package/lib/vibe-orchestrator/src/attachments.js +98 -0
- package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
- package/lib/vibe-orchestrator/src/config.js +227 -0
- package/lib/vibe-orchestrator/src/costMirror.js +64 -0
- package/lib/vibe-orchestrator/src/costStore.js +209 -0
- package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
- package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
- package/lib/vibe-orchestrator/src/preamble.js +139 -0
- package/lib/vibe-orchestrator/src/publisher.js +376 -0
- package/lib/vibe-orchestrator/src/recovery.js +199 -0
- package/lib/vibe-orchestrator/src/screenshot.js +38 -0
- package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
- package/lib/vibe-orchestrator/src/streamParser.js +188 -0
- package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
- package/package.json +1 -1
- 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 }
|