@davvidbaker/flambe-cli 0.1.3 → 0.1.5

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,237 @@
1
+ import {createHash} from 'node:crypto';
2
+ import {existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs';
3
+ import {homedir} from 'node:os';
4
+ import {dirname, join, resolve} from 'node:path';
5
+ import {setTimeout as delay} from 'node:timers/promises';
6
+ import {agentIdentityFromEnv, clientFromEnv} from './client.mjs';
7
+ import {loadProjectEnv} from './env.mjs';
8
+ import {atomicWrite, hasFlambeHooks} from './codex-setup.mjs';
9
+ import {assertRuntimeUrl} from './runtime.mjs';
10
+
11
+ const RESTORE = new Set(['SessionStart', 'SubagentStart', 'PostCompact', 'UserPromptSubmit']);
12
+ const EVENTS = new Set([...RESTORE, 'PreToolUse', 'PostToolUse', 'Stop', 'Interrupt']);
13
+ const hash = text => createHash('sha256').update(text).digest('hex');
14
+ const clean = value => String(value).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 160);
15
+ const emptyState = () => ({workInTurn: 0, workSinceTracked: 0, callsSinceReminder: 0});
16
+
17
+ function projectRoot(cwd) {
18
+ let directory = resolve(cwd);
19
+ for (;;) {
20
+ const path = join(directory, '.codex', 'hooks.json');
21
+ if (existsSync(path) && hasFlambeHooks(JSON.parse(readFileSync(path, 'utf8')))) return directory;
22
+ const parent = dirname(directory);
23
+ if (parent === directory || existsSync(join(directory, '.git'))) return null;
24
+ directory = parent;
25
+ }
26
+ }
27
+
28
+ function context(event, text) {
29
+ return {hookSpecificOutput: {hookEventName: event, additionalContext: text}};
30
+ }
31
+
32
+ function connectionWarning(event) {
33
+ const text = 'Single Burner hook could not verify the local connection. Check the running server and this project\'s .env, including FLAMBE_RUNTIME_MODE=local_self_contained. Work may continue; do not substitute Cloud or repeat queued activity commands.';
34
+ return event === 'Stop' || event === 'Interrupt' ? {systemMessage: text} : context(event, text);
35
+ }
36
+
37
+ // Only recognise a standalone CLI invocation. A mixed shell script still counts
38
+ // as work, even if it happens to contain a tracking command.
39
+ function trackingCommand(input) {
40
+ const command = input?.tool_input?.command ?? input?.tool_input?.cmd;
41
+ if (typeof command !== 'string' || /[\n;&|]/.test(command)) return null;
42
+ const match = command.trim().match(/^(?:(?:[A-Z_][A-Z_0-9]*=(?:"[^"]*"|'[^']*'|[^\s]+))\s+)*(?:flambe(?:\.cmd)?|(?:"[^"]*node(?:\.exe)?"|[^\s]*node(?:\.exe)?)\s+(?:"[^"]*flambe\.mjs"|[^\s]*flambe\.mjs))\s+(start|end|suspend|resume|plan|message|decide|status|ping|whoami)\b/);
43
+ return match?.[1] ?? null;
44
+ }
45
+
46
+ function succeeded(response) {
47
+ if (typeof response === 'string') {
48
+ try { return succeeded(JSON.parse(response)); } catch { return false; }
49
+ }
50
+ return response?.exit_code === 0 || response?.exitCode === 0;
51
+ }
52
+
53
+ function ownActivities(state, agentId) {
54
+ return (state.activities ?? []).filter(activity => activity.agentId === agentId
55
+ && activity.status === 'running');
56
+ }
57
+
58
+ function fingerprint(activities) {
59
+ return hash(JSON.stringify(activities.map(activity => [activity.id, activity.parentId,
60
+ activity.latestEvent?.id, activity.latestEvent?.timestamp]).sort((a, b) => a[0] - b[0])));
61
+ }
62
+
63
+ function guidance(root, activities, {restore = false, interrupted = false} = {}) {
64
+ const lines = [
65
+ 'Single Burner checkpoint: use this project\'s local flambe CLI; never substitute Cloud tools.',
66
+ `Run CLI commands from ${JSON.stringify(clean(root))} so they read the project .env.`,
67
+ ];
68
+ if (restore) lines.push('Read and follow .codex/skills/flambe-cli/SKILL.md. Track semantic work before beginning it, nest focused investigations and verification, and update the stack when focus changes. In agent mode, evaluate requested reviews yourself and submit with flambe decide; no other model is needed.');
69
+ lines.push(activities.length
70
+ ? `This agent's running activities (data): ${activities.slice(-5).map(activity => `${activity.id} ${JSON.stringify(clean(activity.name))}`).join('; ')}.`
71
+ : 'This agent has no running activity. Before substantial work, inspect its suspended work and start or resume an appropriate semantic activity. Trivial one-offs need no activity.');
72
+ if (interrupted) lines.push('The previous turn was interrupted. Reconcile its open work before continuing; an interruption does not mean the task finished.');
73
+ lines.push('Check whether the current activity still describes your actual focus. Keep parents open while working on children; pop completed children before moving on.');
74
+ return lines.join('\n');
75
+ }
76
+
77
+ async function locked(path, operation) {
78
+ mkdirSync(dirname(path), {recursive: true});
79
+ const lock = `${path}.lock`;
80
+ const started = Date.now();
81
+ for (;;) {
82
+ try {
83
+ mkdirSync(lock);
84
+ writeFileSync(join(lock, 'pid'), String(process.pid));
85
+ break;
86
+ }
87
+ catch (error) {
88
+ if (error.code !== 'EEXIST' || Date.now() - started > 2600) return {};
89
+ // A killed/timed-out hook must not disable future checkpoints forever.
90
+ try {
91
+ if (Date.now() - statSync(lock).mtimeMs > 30_000) {
92
+ rmSync(lock, {recursive: true, force: true});
93
+ continue;
94
+ }
95
+ const pid = Number(readFileSync(join(lock, 'pid'), 'utf8'));
96
+ if (Number.isSafeInteger(pid) && pid > 0) {
97
+ try { process.kill(pid, 0); }
98
+ catch (check) { if (check.code === 'ESRCH') rmSync(lock, {recursive: true, force: true}); }
99
+ }
100
+ } catch {
101
+ try { if (Date.now() - statSync(lock).mtimeMs > 10_000) rmSync(lock, {recursive: true, force: true}); }
102
+ catch { /* Another hook just released it. */ }
103
+ }
104
+ await delay(20);
105
+ }
106
+ }
107
+ try { return await operation(); }
108
+ finally { rmSync(lock, {recursive: true, force: true}); }
109
+ }
110
+
111
+ /** A checkpoint adapter, not an activity generator or model client. */
112
+ export async function runCodexHook(input, {
113
+ env: shellEnv = process.env, now = Date.now,
114
+ stateDir = shellEnv.FLAMBE_CODEX_HOOK_STATE_DIR?.trim() || join(homedir(), '.flambe', 'codex-hooks'),
115
+ fetchImpl = globalThis.fetch, checkEveryMs = 30_000,
116
+ remindEveryCalls = 10, remindEveryMs = 90_000,
117
+ } = {}) {
118
+ const event = input?.hook_event_name;
119
+ if (!EVENTS.has(event) || typeof input.cwd !== 'string' || !input.session_id) return {};
120
+ if (event === 'Stop' && input.stop_hook_active) return {};
121
+ let root;
122
+ let env;
123
+ let identity;
124
+ try {
125
+ root = projectRoot(input.cwd);
126
+ if (!root) return {};
127
+ env = {...shellEnv};
128
+ loadProjectEnv({cwd: root, env});
129
+ if (!['FLAMBE_URL', 'FLAMBE_API_TOKEN', 'FLAMBE_TRACE_ID'].some(key => env[key])) return {};
130
+ if (env.FLAMBE_RUNTIME_MODE !== 'local_self_contained') return connectionWarning(event);
131
+ assertRuntimeUrl(env.FLAMBE_URL, 'local_self_contained');
132
+ identity = agentIdentityFromEnv(env);
133
+ if (!identity.agentId) {
134
+ return RESTORE.has(event) ? context(event,
135
+ 'Single Burner: read .codex/skills/flambe-cli/SKILL.md. The hook could not derive this Codex agent\'s identity, so it did not inspect any activity stack. Check flambe whoami in the local Codex environment; do not reuse another agent\'s identity or put agent IDs in .env.') : {};
136
+ }
137
+ } catch { return connectionWarning(event); }
138
+ const key = hash(JSON.stringify([root, env.FLAMBE_URL, env.FLAMBE_TRACE_ID, identity.agentId, input.session_id]));
139
+ const path = join(stateDir, `${key}.json`);
140
+ try {
141
+ return await locked(path, async () => {
142
+ let state;
143
+ try { state = {...emptyState(), ...JSON.parse(readFileSync(path, 'utf8'))}; }
144
+ catch { state = emptyState(); }
145
+ const at = now();
146
+ const save = output => { atomicWrite(path, `${JSON.stringify(state)}\n`); return output; };
147
+ const turn = input.turn_id ?? state.turn ?? 'unknown';
148
+ if (turn !== state.turn || event === 'UserPromptSubmit') {
149
+ state.turn = turn;
150
+ delete state.stopTurn;
151
+ state.workInTurn = 0;
152
+ state.workSinceTracked = 0;
153
+ state.callsSinceReminder = 0;
154
+ state.missingWarned = false;
155
+ }
156
+ if (event === 'Interrupt') {
157
+ state.interrupted = true;
158
+ return save({});
159
+ }
160
+ const tracking = trackingCommand(input);
161
+ if (event === 'PostToolUse') {
162
+ if (tracking && !['status', 'ping', 'whoami'].includes(tracking) && succeeded(input.tool_response)) {
163
+ state.workSinceTracked = 0;
164
+ state.callsSinceReminder = 0;
165
+ state.remindedAt = at;
166
+ state.checkedAt = 0;
167
+ }
168
+ return save({});
169
+ }
170
+ if (event === 'PreToolUse') {
171
+ if (tracking) return save({});
172
+ const weight = /^(apply_patch|Edit|Write)$/.test(input.tool_name ?? '') ? 3 : 1;
173
+ state.workInTurn += weight;
174
+ state.workSinceTracked += weight;
175
+ state.callsSinceReminder++;
176
+ state.lastWorkAt = at;
177
+ }
178
+ if (event === 'Stop' && (state.workInTurn < 3 || state.workSinceTracked === 0 || state.stopTurn === turn)) return save({});
179
+ const restore = RESTORE.has(event);
180
+ const periodic = state.callsSinceReminder >= remindEveryCalls || at - (state.remindedAt ?? at) >= remindEveryMs;
181
+ const due = restore || periodic || event === 'Stop' || !state.checkedAt || at - state.checkedAt >= checkEveryMs;
182
+ let activities = [];
183
+ if (due) {
184
+ state.checkedAt = at;
185
+ try {
186
+ const client = clientFromEnv(env);
187
+ client.fetch = (url, options) => fetchImpl(url, {...options, signal: AbortSignal.timeout(1200)});
188
+ activities = ownActivities(await client.status({activeOnly: true}), identity.agentId);
189
+ const current = fingerprint(activities);
190
+ const latestChange = Math.max(0, ...activities.map(activity => Date.parse(activity.latestEvent?.timestamp) || 0));
191
+ if (state.fingerprint && state.fingerprint !== current
192
+ && ((!activities.length && state.hasActive) || latestChange >= (state.lastWorkAt ?? 0))) {
193
+ state.workSinceTracked = 0;
194
+ state.callsSinceReminder = 0;
195
+ state.remindedAt = at;
196
+ }
197
+ state.fingerprint = current;
198
+ state.available = true;
199
+ state.hasActive = activities.length > 0;
200
+ } catch {
201
+ state.available = false;
202
+ if (!state.warnedAt || at - state.warnedAt >= remindEveryMs || restore) {
203
+ state.warnedAt = at;
204
+ return save(connectionWarning(event));
205
+ }
206
+ return save({});
207
+ }
208
+ }
209
+ if (!state.available) return save({});
210
+ if (event === 'Stop') {
211
+ if (!state.workSinceTracked) return save({});
212
+ state.stopTurn = turn;
213
+ return save({decision: 'block', reason: guidance(root, activities)
214
+ + '\nBefore handing back, reconcile the work you actually did. End completed activities, suspend work waiting on the user, or explain why an activity remains running. Complete any requested reducer review. This is one checkpoint, not an instruction to invent work or end every open parent.'});
215
+ }
216
+ const firstUntracked = event === 'PreToolUse' && !state.missingWarned && !state.hasActive;
217
+ if (restore || firstUntracked || periodic) {
218
+ if (firstUntracked) state.missingWarned = true;
219
+ state.callsSinceReminder = 0;
220
+ state.remindedAt = at;
221
+ const interrupted = state.interrupted;
222
+ state.interrupted = false;
223
+ return save(context(event, guidance(root, activities, {restore, interrupted})));
224
+ }
225
+ return save({});
226
+ });
227
+ } catch { return connectionWarning(event); }
228
+ }
229
+
230
+ export async function readHookInput(stdin = process.stdin) {
231
+ let body = '';
232
+ for await (const chunk of stdin) {
233
+ body += chunk;
234
+ if (Buffer.byteLength(body) > 1024 * 1024) throw new Error('Hook input exceeds 1 MiB');
235
+ }
236
+ return JSON.parse(body);
237
+ }
@@ -0,0 +1,104 @@
1
+ import {existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync} from 'node:fs';
2
+ import {dirname, join, resolve} from 'node:path';
3
+ import {fileURLToPath} from 'node:url';
4
+ import {randomUUID} from 'node:crypto';
5
+
6
+ export const HOOK_COMMAND = 'flambe hook codex';
7
+ export const HOOK_COMMAND_WINDOWS = 'flambe.cmd hook codex';
8
+ export const HOOK_EVENTS = ['SessionStart', 'SubagentStart', 'UserPromptSubmit', 'PostCompact', 'PreToolUse', 'PostToolUse', 'Stop', 'Interrupt'];
9
+
10
+ export function managedHook(hook) {
11
+ return hook?.type === 'command' && hook.command === HOOK_COMMAND
12
+ && hook.commandWindows === HOOK_COMMAND_WINDOWS;
13
+ }
14
+
15
+ export function hasFlambeHooks(config) {
16
+ return Object.values(config?.hooks ?? {}).some(groups => Array.isArray(groups)
17
+ && groups.some(group => group.hooks?.some(managedHook)));
18
+ }
19
+
20
+ function readConfig(path) {
21
+ if (!existsSync(path)) return {hooks: {}};
22
+ let config;
23
+ try { config = JSON.parse(readFileSync(path, 'utf8')); }
24
+ catch { throw new Error('Cannot read .codex/hooks.json as JSON; existing hooks were not changed.'); }
25
+ if (!config || typeof config !== 'object' || Array.isArray(config)
26
+ || (config.hooks !== undefined && (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)))
27
+ || Object.values(config.hooks ?? {}).some(groups => !Array.isArray(groups)
28
+ || groups.some(group => !group || !Array.isArray(group.hooks)))) {
29
+ throw new Error('Unsupported .codex/hooks.json structure; existing hooks were not changed.');
30
+ }
31
+ return config;
32
+ }
33
+
34
+ export function atomicWrite(path, text) {
35
+ mkdirSync(dirname(path), {recursive: true});
36
+ const temporary = `${path}.${randomUUID()}.tmp`;
37
+ try {
38
+ writeFileSync(temporary, text, {mode: 0o600, flag: 'wx'});
39
+ renameSync(temporary, path);
40
+ } finally {
41
+ if (existsSync(temporary)) unlinkSync(temporary);
42
+ }
43
+ }
44
+
45
+ export function bundledSkill() {
46
+ const bundled = fileURLToPath(new URL('../skills/flambe-cli/SKILL.md', import.meta.url));
47
+ const source = fileURLToPath(new URL('../../.codex/skills/flambe-cli/SKILL.md', import.meta.url));
48
+ return readFileSync(existsSync(bundled) ? bundled : source, 'utf8');
49
+ }
50
+
51
+ function withoutManagedHooks(config) {
52
+ const result = {...config, hooks: {...config.hooks}};
53
+ for (const [event, groups] of Object.entries(result.hooks)) {
54
+ result.hooks[event] = groups.flatMap(group => {
55
+ if (!group.hooks.some(managedHook)) return [group];
56
+ const hooks = group.hooks.filter(hook => !managedHook(hook));
57
+ return hooks.length ? [{...group, hooks}] : [];
58
+ });
59
+ if (!result.hooks[event].length) delete result.hooks[event];
60
+ }
61
+ return result;
62
+ }
63
+
64
+ export function setupCodex({cwd = process.cwd(), action = 'install', skill = bundledSkill()} = {}) {
65
+ const root = resolve(cwd);
66
+ const path = join(root, '.codex', 'hooks.json');
67
+ const skillPath = join(root, '.codex', 'skills', 'flambe-cli', 'SKILL.md');
68
+ const existing = readConfig(path);
69
+ const installedEvents = HOOK_EVENTS.filter(event => existing.hooks?.[event]?.some(group =>
70
+ group.hooks.some(managedHook)));
71
+ const skillExists = existsSync(skillPath);
72
+ if (action === 'check') return {
73
+ root, hooksPath: path, installed: installedEvents.length === HOOK_EVENTS.length,
74
+ installedEvents, skillExists,
75
+ skillMatches: skillExists && readFileSync(skillPath, 'utf8') === skill,
76
+ };
77
+ const config = withoutManagedHooks(existing);
78
+ if (action === 'remove') {
79
+ if (installedEvents.length) atomicWrite(path, `${JSON.stringify(config, null, 2)}\n`);
80
+ return {root, hooksPath: path, removed: installedEvents.length > 0, skillRetained: skillExists};
81
+ }
82
+ if (action !== 'install') throw new Error('Unknown Codex setup action');
83
+ for (const event of HOOK_EVENTS) {
84
+ config.hooks[event] ??= [];
85
+ config.hooks[event].push({hooks: [{
86
+ type: 'command', command: HOOK_COMMAND, commandWindows: HOOK_COMMAND_WINDOWS,
87
+ timeout: event === 'Interrupt' ? 3 : 5,
88
+ statusMessage: 'Single Burner tracking checkpoint', additionalContextLimit: 1800,
89
+ }]});
90
+ }
91
+ // Preserve custom/existing instructions. Never silently replace a skill.
92
+ if (!skillExists) atomicWrite(skillPath, skill);
93
+ const serialized = `${JSON.stringify(config, null, 2)}\n`;
94
+ const unchanged = existsSync(path) && readFileSync(path, 'utf8') === serialized;
95
+ if (!unchanged) {
96
+ if (existsSync(path)) {
97
+ const backup = `${path}.before-flambe`;
98
+ if (!existsSync(backup)) writeFileSync(backup, readFileSync(path), {flag: 'wx', mode: 0o600});
99
+ }
100
+ atomicWrite(path, serialized);
101
+ }
102
+ return {root, hooksPath: path, unchanged, skillInstalled: !skillExists,
103
+ skillMatches: !skillExists || readFileSync(skillPath, 'utf8') === skill};
104
+ }
@@ -92,7 +92,7 @@ export function commandState(trace, filters = {}) {
92
92
  }
93
93
 
94
94
  /** A command and all of its corrections commit together, or not at all. */
95
- export function executeCommand(store, userId, command, attrs, headerAgent = null) {
95
+ export function executeCommand(store, userId, command, attrs, headerAgent = null, { modelReviewAvailable = false, reviewStarts = true } = {}) {
96
96
  if (!COMMANDS.includes(command) || !attrs || typeof attrs !== 'object' || Array.isArray(attrs)) {
97
97
  invalid('command and arguments are required');
98
98
  }
@@ -112,6 +112,8 @@ export function executeCommand(store, userId, command, attrs, headerAgent = null
112
112
  include_unstarted: bool(attrs.include_unstarted, 'include_unstarted') };
113
113
  if (filters.active_only && filters.suspended_only) invalid('active_only and suspended_only cannot both be true');
114
114
  const result = { direction: null, reply: null, actions_applied: [], rules_fired: [], closed_descendants: [] };
115
+ const structuralReview = !modelReviewAvailable ? { type: 'skipped', reason: 'model_unavailable' }
116
+ : reviewStarts ? { type: 'pending_model_review' } : { type: 'available', reason: 'work_ai_review_on_request' };
115
117
  if (command === 'status') {
116
118
  const state = commandState(trace, filters);
117
119
  if (filters.include_unstarted && !filters.active_only && !filters.suspended_only) {
@@ -150,7 +152,7 @@ export function executeCommand(store, userId, command, attrs, headerAgent = null
150
152
  requiredText(attrs.message, 'message');
151
153
  if (attrs.activity_id != null && !byId.has(positiveId(attrs.activity_id, 'activity_id'))) notFound();
152
154
  throw new CommandError(503, 'REDUCER_NOT_CONFIGURED',
153
- 'Model review is unavailable in the self-contained runtime. Lifecycle commands remain available; no content was sent to a provider.');
155
+ 'AI reducer review is off. Configure FLAMBE_LOCAL_REDUCER_MODE to enable it. Lifecycle commands remain available; no content was sent to a provider.');
154
156
  }
155
157
  if (attrs.message != null && typeof attrs.message !== 'string') invalid('message must be a string');
156
158
  if (attrs.description != null && typeof attrs.description !== 'string') invalid('description must be a string');
@@ -246,12 +248,12 @@ export function executeCommand(store, userId, command, attrs, headerAgent = null
246
248
  if (result.actions_applied.length) annotate(result.activity_id, 'resume_ancestor',
247
249
  result.actions_applied.map(a => a.activity_id));
248
250
  if (!parent && agentId && before.activities.some(a => a.agentId === agentId && a.status === 'running')) {
249
- annotate(result.activity_id, 'new_root_while_open', { type: 'skipped', reason: 'model_unavailable' });
251
+ annotate(result.activity_id, 'new_root_while_open', structuralReview);
250
252
  } else if (parent) {
251
253
  const ancestorTokens = new Set(ancestors(parent).flatMap(a => tokens(a.name)));
252
254
  const childTokens = tokens(name);
253
255
  if (childTokens.length && childTokens.every(t => !ancestorTokens.has(t))) {
254
- annotate(result.activity_id, 'name_unfit', { type: 'skipped', reason: 'model_unavailable' });
256
+ annotate(result.activity_id, 'name_unfit', structuralReview);
255
257
  }
256
258
  }
257
259
  }
@@ -0,0 +1,98 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+
3
+ /** Signed reciprocal creation history; snapshots never accept client-supplied row contents. */
4
+ export function reverseCreation(store, userId, traceId, body) {
5
+ traceId = Number(traceId);
6
+ if (!store.getTrace(userId, traceId)) return { status: 404, json: { error: 'NOT_FOUND' } };
7
+ const db = store.db;
8
+ const row = (table, id) => db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(Number(id));
9
+ const rows = (table, activityId) => db.prepare(`SELECT * FROM ${table} WHERE activity_id = ? ORDER BY id`).all(activityId);
10
+ const children = id => db.prepare('SELECT id, parent_id, thread_id FROM activities WHERE parent_id = ? ORDER BY id').all(id);
11
+ const categories = id => db.prepare('SELECT category_id FROM activities_categories WHERE activity_id = ? ORDER BY category_id').all(id).map(c => c.category_id);
12
+ const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
13
+ const fail = () => { throw new Error('HISTORY_CHANGED'); };
14
+ const signature = value => createHmac('sha256', store.credentials().rawToken).update(`creation-history-v1:${value}`).digest();
15
+ const sign = record => {
16
+ const value = Buffer.from(JSON.stringify(record)).toString('base64url');
17
+ return `${value}.${signature(value).toString('base64url')}`;
18
+ };
19
+ const insert = (table, data) => {
20
+ // Column names come from this database, not the restoration token.
21
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name);
22
+ db.prepare(`INSERT INTO ${table} (${columns.join(',')}) VALUES (${columns.map(() => '?').join(',')})`)
23
+ .run(...columns.map(column => data[column] ?? null));
24
+ };
25
+ let record;
26
+ if (typeof body.token === 'string') {
27
+ try {
28
+ const [value, mac, extra] = body.token.split('.');
29
+ const expected = signature(value);
30
+ const supplied = Buffer.from(mac ?? '', 'base64url');
31
+ if (extra || supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) throw new Error();
32
+ record = JSON.parse(Buffer.from(value, 'base64url').toString());
33
+ if (record.userId !== userId || record.traceId !== traceId || !['remove', 'restore'].includes(record.mode)) throw new Error();
34
+ } catch { return { status: 422, json: { error: 'INVALID_HISTORY' } }; }
35
+ }
36
+ db.exec('SAVEPOINT history_reverse');
37
+ try {
38
+ if (!record) {
39
+ const id = Number(body.target_id);
40
+ const activity = body.kind === 'activity' ? store.getActivity(userId, id) : null;
41
+ const event = body.kind === 'event' ? row('events', id) : null;
42
+ if (activity && (store.getThread(userId, activity.thread_id) && row('threads', activity.thread_id)?.trace_id) === traceId) {
43
+ record = { userId, traceId, mode: 'remove', snapshot: { kind: 'activity',
44
+ activity: row('activities', id), events: rows('events', id), categories: categories(id), children: children(id) } };
45
+ } else if (event?.trace_id === traceId) {
46
+ let weight = null;
47
+ if (body.restore_weight) {
48
+ const activityId = Number(body.restore_weight.activity_id);
49
+ const old = body.restore_weight.weight;
50
+ if (activityId !== event.activity_id || (old !== null && !Number.isInteger(old))) fail();
51
+ weight = { activity_id: activityId, before: row('activities', activityId).weight, after: old };
52
+ }
53
+ record = { userId, traceId, mode: 'remove', snapshot: { kind: 'event', event, weight } };
54
+ } else fail();
55
+ }
56
+ const { snapshot, mode } = record;
57
+ if (snapshot.kind === 'activity') {
58
+ const activity = snapshot.activity;
59
+ if ((store.getThread(userId, activity.thread_id) && row('threads', activity.thread_id)?.trace_id) !== traceId) fail();
60
+ if (mode === 'remove') {
61
+ if (!equal(row('activities', activity.id), activity) || !equal(rows('events', activity.id), snapshot.events)
62
+ || !equal(categories(activity.id), snapshot.categories) || !equal(children(activity.id), snapshot.children)) fail();
63
+ db.prepare('DELETE FROM activities WHERE id = ?').run(activity.id);
64
+ } else {
65
+ if (row('activities', activity.id)) fail();
66
+ if (activity.parent_id && row('activities', activity.parent_id)?.thread_id !== activity.thread_id) fail();
67
+ for (const child of snapshot.children) {
68
+ const current = store.getActivity(userId, child.id);
69
+ if (!current || current.parent_id !== null || current.thread_id !== child.thread_id) fail();
70
+ }
71
+ for (const event of snapshot.events) if (row('events', event.id) || event.trace_id !== traceId || event.activity_id !== activity.id) fail();
72
+ for (const id of snapshot.categories) if (row('categories', id)?.user_id !== userId) fail();
73
+ insert('activities', activity);
74
+ for (const event of snapshot.events) insert('events', event);
75
+ for (const id of snapshot.categories) db.prepare('INSERT INTO activities_categories (activity_id, category_id) VALUES (?, ?)').run(activity.id, id);
76
+ for (const child of snapshot.children) db.prepare('UPDATE activities SET parent_id = ? WHERE id = ?').run(activity.id, child.id);
77
+ }
78
+ } else if (snapshot.kind === 'event') {
79
+ const event = snapshot.event;
80
+ if (event.trace_id !== traceId || !store.getActivity(userId, event.activity_id)) fail();
81
+ if (mode === 'remove' ? !equal(row('events', event.id), event) : Boolean(row('events', event.id))) fail();
82
+ if (snapshot.weight) {
83
+ const weight = snapshot.weight;
84
+ if (weight.activity_id !== event.activity_id) fail();
85
+ if (row('activities', weight.activity_id).weight !== (mode === 'remove' ? weight.before : weight.after)) fail();
86
+ db.prepare('UPDATE activities SET weight = ? WHERE id = ?').run(mode === 'remove' ? weight.after : weight.before, weight.activity_id);
87
+ }
88
+ if (mode === 'remove') db.prepare('DELETE FROM events WHERE id = ?').run(event.id);
89
+ else insert('events', event);
90
+ } else fail();
91
+ const inverse = sign({ ...record, mode: mode === 'remove' ? 'restore' : 'remove' });
92
+ db.exec('RELEASE history_reverse');
93
+ return { status: 200, json: { data: { undo_token: inverse } } };
94
+ } catch {
95
+ db.exec('ROLLBACK TO history_reverse; RELEASE history_reverse');
96
+ return { status: 409, json: { error: 'HISTORY_CHANGED' } };
97
+ }
98
+ }
@@ -1,5 +1,6 @@
1
1
  import { createReadStream, existsSync, statSync } from 'node:fs';
2
2
  import { extname, join, normalize, resolve } from 'node:path';
3
+ import { reverseCreation } from './history.mjs';
3
4
  import { COMMANDS, CommandError, executeCommand } from './commands.mjs';
4
5
 
5
6
  function sendJson(res, status, body) {
@@ -112,7 +113,7 @@ function agentHeaders(agent) {
112
113
  };
113
114
  }
114
115
 
115
- async function dispatch(store, req, url, body, { auth, agent }) {
116
+ async function dispatch(store, req, url, body, { auth, agent, runCommand }) {
116
117
  const method = req.method ?? 'GET';
117
118
  const path = url.pathname;
118
119
 
@@ -146,13 +147,18 @@ async function dispatch(store, req, url, body, { auth, agent }) {
146
147
 
147
148
  if (path === '/api/agent-commands') {
148
149
  if (method === 'GET') return { status: 200, json: { data: {
149
- version: 1, commands: COMMANDS, runtime_mode: 'local_self_contained', model_review: false,
150
+ version: 1, commands: [...COMMANDS, ...(runCommand.info?.work_ai_reviews ? ['decide'] : [])], runtime_mode: 'local_self_contained',
151
+ model_review: runCommand.info?.enabled ?? false, reducer: runCommand.info ?? { mode: 'off' },
150
152
  } } };
151
153
  if (method === 'POST') return { status: 200, json: {
152
- data: executeCommand(store, userId, body.command, body.arguments, agent),
154
+ data: await runCommand(userId, body.command, body.arguments, agent),
153
155
  } };
154
156
  }
155
157
 
158
+ if (method === 'GET' && path === '/api/agents') {
159
+ return { status: 200, json: { data: store.listAgents(userId) } };
160
+ }
161
+
156
162
  if (method === 'GET' && path === '/api/agents/me') {
157
163
  if (!agent) {
158
164
  return { status: 400, json: { error: 'AGENT_ID_REQUIRED', detail: 'Send x-flambe-agent-id with a bearer token' } };
@@ -212,6 +218,9 @@ async function dispatch(store, req, url, body, { auth, agent }) {
212
218
  return { status: 200, json: { data: { threads: result.threads } } };
213
219
  }
214
220
 
221
+ const historyMatch = path.match(/^\/api\/traces\/(\d+)\/reverse_creation$/);
222
+ if (method === 'POST' && historyMatch) return reverseCreation(store, userId, historyMatch[1], body);
223
+
215
224
  const traceMatch = path.match(/^\/api\/traces\/(\d+)$/);
216
225
  if (traceMatch) {
217
226
  const id = traceMatch[1];
@@ -259,7 +268,7 @@ async function dispatch(store, req, url, body, { auth, agent }) {
259
268
  if (method === 'POST' && path === '/api/activities') {
260
269
  if (!Object.hasOwn(body, 'event') && auth.tokenName !== null) {
261
270
  const activity = body.activity ?? {};
262
- const planned = executeCommand(store, userId, 'plan', {
271
+ const planned = await runCommand(userId, 'plan', {
263
272
  trace_id: body.trace_id, name: activity.name, description: activity.description,
264
273
  weight: activity.weight, category_ids: activity.categories,
265
274
  scheduled_start: activity.scheduled_start_integer,
@@ -271,7 +280,7 @@ async function dispatch(store, req, url, body, { auth, agent }) {
271
280
  }
272
281
  if (auth.tokenName !== null) {
273
282
  const activity = body.activity ?? {};
274
- const folded = executeCommand(store, userId, 'start', {
283
+ const folded = await runCommand(userId, 'start', {
275
284
  trace_id: body.trace_id, thread_id: body.thread_id, name: activity.name,
276
285
  description: activity.description, category_ids: activity.categories,
277
286
  ...(Object.hasOwn(activity, 'parent_id') ? { parent_id: activity.parent_id } : {}),
@@ -283,7 +292,7 @@ async function dispatch(store, req, url, body, { auth, agent }) {
283
292
  phase: folded.state.activities.find(a => a.id === folded.activity_id)?.latestEvent.phase },
284
293
  reducer: folded.reducer ?? { actions_applied: folded.actions_applied },
285
294
  direction: folded.direction, reply: folded.reply, rules_fired: folded.rules_fired,
286
- actions_applied: folded.actions_applied,
295
+ actions_applied: folded.actions_applied, model_review: folded.model_review, review: folded.review,
287
296
  } } };
288
297
  }
289
298
  const result = store.createActivity(userId, {
@@ -327,7 +336,7 @@ async function dispatch(store, req, url, body, { auth, agent }) {
327
336
  const phase = body.event?.phase;
328
337
  const command = { E: 'end', J: 'end', V: 'end', S: 'suspend', R: 'resume' }[phase];
329
338
  if (auth.tokenName !== null && command) {
330
- const folded = executeCommand(store, userId, command, {
339
+ const folded = await runCommand(userId, command, {
331
340
  trace_id: body.trace_id, activity_id: body.activity_id,
332
341
  timestamp: body.event?.timestamp_integer, message: body.event?.message,
333
342
  phase, force: body.force ?? true,
@@ -336,7 +345,7 @@ async function dispatch(store, req, url, body, { auth, agent }) {
336
345
  id: folded.event_id, phase,
337
346
  reducer: { closed_descendants: folded.closed_descendants,
338
347
  rules_fired: folded.rules_fired, actions_applied: folded.actions_applied,
339
- direction: folded.direction, reply: folded.reply },
348
+ direction: folded.direction, reply: folded.reply, model_review: folded.model_review },
340
349
  } } };
341
350
  }
342
351
  const result = store.createEvent(userId, {
@@ -429,7 +438,8 @@ function tryStatic(staticDir, urlPath, res) {
429
438
  return true;
430
439
  }
431
440
 
432
- export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
441
+ export async function handleLocalRequest(store, req, res, { staticDir, mcp,
442
+ runCommand = (userId, command, args, agent) => executeCommand(store, userId, command, args, agent) } = {}) {
433
443
  const host = req.headers.host ?? '127.0.0.1';
434
444
  const url = new URL(req.url ?? '/', `http://${host}`);
435
445
  // A locally served UI must not send trace content to a compiled-in remote API.
@@ -448,6 +458,22 @@ export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
448
458
  }
449
459
  }
450
460
 
461
+ // Single Burner has no OAuth discovery documents. Do not serve the SPA (or
462
+ // its setup credentials) to MCP clients probing standard metadata paths.
463
+ if (url.pathname.startsWith('/.well-known/')) {
464
+ sendJson(res, 404, { error: 'NOT_FOUND' });
465
+ return;
466
+ }
467
+
468
+ if (url.pathname === '/mcp' && mcp) {
469
+ if (req.headers.origin) {
470
+ sendJson(res, 403, { error: 'MCP_BROWSER_ORIGIN_FORBIDDEN' });
471
+ return;
472
+ }
473
+ await mcp.handle(req, res);
474
+ return;
475
+ }
476
+
451
477
  if (!url.pathname.startsWith('/api') && !url.pathname.startsWith('/auth')) {
452
478
  if (tryStatic(staticDir, url.pathname, res)) return;
453
479
  const { rawToken, traceId } = store.credentials();
@@ -477,7 +503,7 @@ FLAMBE_TRACE_ID=${traceId}</pre>
477
503
  const agent = identifyAgent(store, req, auth);
478
504
  let result;
479
505
  try {
480
- result = await dispatch(store, req, url, body, { auth, agent });
506
+ result = await dispatch(store, req, url, body, { auth, agent, runCommand });
481
507
  } catch (error) {
482
508
  if (!(error instanceof CommandError)) throw error;
483
509
  result = { status: error.status, json: error.body };