@jossuealcala/madre 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +281 -0
- package/bin/madre.mjs +134 -0
- package/docs/madre-banner.svg +25 -0
- package/package.json +63 -0
- package/public/app.js +4031 -0
- package/public/brands.js +59 -0
- package/public/index.html +231 -0
- package/public/styles.css +976 -0
- package/public/troubleshooting.js +460 -0
- package/src/adapters/claude.mjs +105 -0
- package/src/adapters/codex.mjs +84 -0
- package/src/adapters/gemini.mjs +335 -0
- package/src/adapters/opencode.mjs +148 -0
- package/src/adapters/process.mjs +132 -0
- package/src/ashcode.mjs +64 -0
- package/src/auth-probe.mjs +145 -0
- package/src/capabilities.mjs +126 -0
- package/src/checkpoint.mjs +106 -0
- package/src/cli-args.mjs +38 -0
- package/src/commands.mjs +98 -0
- package/src/config.mjs +59 -0
- package/src/conversation-context.mjs +59 -0
- package/src/directives.mjs +70 -0
- package/src/distiller.mjs +78 -0
- package/src/embeddings.mjs +80 -0
- package/src/event-store.mjs +152 -0
- package/src/extensions.mjs +263 -0
- package/src/files.mjs +177 -0
- package/src/image-studio.mjs +25 -0
- package/src/lease.mjs +83 -0
- package/src/mcp/image-server.mjs +169 -0
- package/src/mcp/memory-server.mjs +221 -0
- package/src/memory-tools.mjs +34 -0
- package/src/memory.mjs +543 -0
- package/src/models.mjs +78 -0
- package/src/mother.mjs +163 -0
- package/src/quota-monitor.mjs +119 -0
- package/src/quota-sources.mjs +161 -0
- package/src/room.mjs +1159 -0
- package/src/router.mjs +13 -0
- package/src/runtime-detection.mjs +85 -0
- package/src/server.mjs +693 -0
- package/src/setup.mjs +192 -0
- package/src/usage-sentinel.mjs +65 -0
package/src/server.mjs
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { detectAgents } from './runtime-detection.mjs';
|
|
9
|
+
import { EventStore } from './event-store.mjs';
|
|
10
|
+
import { RoomMemory } from './memory.mjs';
|
|
11
|
+
import { createEmbedder } from './embeddings.mjs';
|
|
12
|
+
import { memoryServerFor } from './memory-tools.mjs';
|
|
13
|
+
import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
|
|
14
|
+
import { QuotaMonitor } from './quota-monitor.mjs';
|
|
15
|
+
import { defaultQuotaSources } from './quota-sources.mjs';
|
|
16
|
+
import { Room } from './room.mjs';
|
|
17
|
+
import { applyConfigToEnv, loadConfig } from './config.mjs';
|
|
18
|
+
import { extensionById, listExtensions, runInstaller } from './extensions.mjs';
|
|
19
|
+
import { loginPlanFor, probeAll } from './auth-probe.mjs';
|
|
20
|
+
import { loadConfig as readConfig, updateConfig } from './config.mjs';
|
|
21
|
+
import { discoverModels } from './models.mjs';
|
|
22
|
+
import { setImageModule } from './capabilities.mjs';
|
|
23
|
+
import { resolveGeminiKey } from './image-studio.mjs';
|
|
24
|
+
import { commandByName, listCommands, parseCommand } from './commands.mjs';
|
|
25
|
+
import { listDirectory, searchFiles, readServable, storeAttachment, MAX_ATTACHMENT_BYTES } from './files.mjs';
|
|
26
|
+
|
|
27
|
+
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const publicDirectory = join(sourceDirectory, '..', 'public');
|
|
29
|
+
|
|
30
|
+
function sendJson(response, status, value) {
|
|
31
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
32
|
+
response.end(JSON.stringify(value));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function body(request) {
|
|
36
|
+
let text = '';
|
|
37
|
+
for await (const chunk of request) {
|
|
38
|
+
text += chunk;
|
|
39
|
+
if (text.length > 100_000) throw new Error('Request too large.');
|
|
40
|
+
}
|
|
41
|
+
return JSON.parse(text || '{}');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function openUrl(url) {
|
|
45
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
46
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
47
|
+
execFile(command, args, () => {});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// PULSE_AGENT_TIMEOUT_MS applies to every agent; PULSE_<AGENT>_TIMEOUT_MS
|
|
51
|
+
// (e.g. PULSE_CLAUDE_TIMEOUT_MS) overrides it for one.
|
|
52
|
+
export function agentTimeoutsFromEnv(env = process.env, agentIds = ['codex', 'claude', 'gemini', 'opencode']) {
|
|
53
|
+
const timeouts = {};
|
|
54
|
+
const shared = Number(env.PULSE_AGENT_TIMEOUT_MS);
|
|
55
|
+
if (Number.isFinite(shared) && shared > 0) timeouts.default = shared;
|
|
56
|
+
for (const id of agentIds) {
|
|
57
|
+
const value = Number(env[`PULSE_${id.toUpperCase()}_TIMEOUT_MS`]);
|
|
58
|
+
if (Number.isFinite(value) && value > 0) timeouts[id] = value;
|
|
59
|
+
}
|
|
60
|
+
return timeouts;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function projectRoomId(projectRoot) {
|
|
64
|
+
const path = resolve(projectRoot);
|
|
65
|
+
const digest = createHash('sha256').update(path).digest('hex').slice(0, 16);
|
|
66
|
+
return `${basename(path) || 'root'}-${digest}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function createPulseServer({
|
|
70
|
+
projectRoot,
|
|
71
|
+
stateRoot,
|
|
72
|
+
agents: providedAgents,
|
|
73
|
+
testMode = process.env.PULSE_TEST_MODE === '1',
|
|
74
|
+
softTokenBudget = Number(process.env.PULSE_SOFT_TOKEN_BUDGET ?? 500000),
|
|
75
|
+
contextMaxChars = Number(process.env.PULSE_CONTEXT_MAX_CHARS ?? 16000),
|
|
76
|
+
// Real limits per CLI where the CLI publishes them. Only the CLI entry
|
|
77
|
+
// point (startPulse) turns them on; tests and embedders pass their own.
|
|
78
|
+
quotaSources = [],
|
|
79
|
+
quotaPollIntervalMs = Number(process.env.PULSE_QUOTA_POLL_INTERVAL_MS ?? 60000),
|
|
80
|
+
broadcastIntervalMs = Number(process.env.PULSE_BROADCAST_INTERVAL_MS ?? 500),
|
|
81
|
+
sseMaxBufferedBytes = Number(process.env.PULSE_SSE_MAX_BUFFERED_BYTES ?? 1_048_576),
|
|
82
|
+
agentTimeouts,
|
|
83
|
+
maxMessageChars = Number(process.env.PULSE_MAX_MESSAGE_CHARS ?? 20000),
|
|
84
|
+
delegation = process.env.PULSE_DELEGATION !== '0',
|
|
85
|
+
maxPlanSteps = Number(process.env.PULSE_MAX_PLAN_STEPS ?? 4),
|
|
86
|
+
invokers,
|
|
87
|
+
// Tests substitute the real installers with local scripts.
|
|
88
|
+
installers = {},
|
|
89
|
+
loginRunners = {},
|
|
90
|
+
probe = probeAll,
|
|
91
|
+
imageKey = resolveGeminiKey,
|
|
92
|
+
}) {
|
|
93
|
+
const root = stateRoot ?? process.env.PULSE_HOME ?? join(homedir(), '.pulse');
|
|
94
|
+
// ~/.pulse/config.json fills in whatever the environment did not set.
|
|
95
|
+
applyConfigToEnv(await loadConfig(root));
|
|
96
|
+
// RIPLEY renders HTML in the viewer only while the human has it switched on.
|
|
97
|
+
let ripleyEnabled = Boolean((await readConfig(root)).modules?.ripley?.enabled);
|
|
98
|
+
agentTimeouts ??= agentTimeoutsFromEnv();
|
|
99
|
+
const agents = providedAgents ?? await detectAgents();
|
|
100
|
+
const canonicalProjectRoot = await realpath(projectRoot).catch(() => resolve(projectRoot));
|
|
101
|
+
const roomDir = join(root, 'rooms', projectRoomId(canonicalProjectRoot));
|
|
102
|
+
const store = await new EventStore(join(roomDir, 'events.jsonl')).initialize();
|
|
103
|
+
const historicalEvents = await store.readAll();
|
|
104
|
+
// The room's memory: derived from the ledger, rebuilt if missing or stale,
|
|
105
|
+
// and never a reason for the room not to open.
|
|
106
|
+
const memory = await new RoomMemory(join(roomDir, 'memory.sqlite')).initialize(store)
|
|
107
|
+
.catch((error) => { console.error(`MADRE memory unavailable, turns get the recent window only: ${error.message}`); return null; });
|
|
108
|
+
// Meaning-aware recall through the user's own Gemini key, when there is one;
|
|
109
|
+
// and the memory as MCP tools for every agent's turn.
|
|
110
|
+
if (memory) {
|
|
111
|
+
const key = process.env.PULSE_EMBED === '0' ? null : await imageKey().catch(() => null);
|
|
112
|
+
memory.attachEmbedder(createEmbedder({ key }));
|
|
113
|
+
}
|
|
114
|
+
const memoryServer = memory ? memoryServerFor({ dbFile: memory.file, projectRoot: canonicalProjectRoot }) : null;
|
|
115
|
+
// MOTHER's channel to the crew: her seal's fingerprint lives in the memory
|
|
116
|
+
// file, so a deleted or edited .pulse/mother.env is noticed.
|
|
117
|
+
const mother = new MotherChannel(join(canonicalProjectRoot, '.pulse', 'mother.env'), { meta: memory ? { get: (key) => memory.metaGet(key), set: (key, value) => memory.metaSet(key, value) } : null });
|
|
118
|
+
const motherOutcome = await mother.load().catch((error) => { console.error(`MADRE could not open MOTHER's channel: ${error.message}`); return null; });
|
|
119
|
+
const room = new Room({
|
|
120
|
+
store,
|
|
121
|
+
agents,
|
|
122
|
+
projectRoot,
|
|
123
|
+
softTokenBudget,
|
|
124
|
+
contextMaxChars,
|
|
125
|
+
memory,
|
|
126
|
+
memoryServer,
|
|
127
|
+
mother: motherOutcome ? mother : null,
|
|
128
|
+
historicalEvents,
|
|
129
|
+
invokers,
|
|
130
|
+
agentTimeouts,
|
|
131
|
+
maxMessageChars,
|
|
132
|
+
delegation,
|
|
133
|
+
maxPlanSteps,
|
|
134
|
+
});
|
|
135
|
+
const embedKick = memory?.embedder ? setTimeout(() => void room.embedNow(), 2000) : null;
|
|
136
|
+
embedKick?.unref?.();
|
|
137
|
+
if (motherOutcome === 'deleted' || motherOutcome === 'altered') setTimeout(() => void room.motherTampered(motherOutcome).catch(() => {}), 500).unref?.();
|
|
138
|
+
// Session state per agent, refreshed on demand from the connections panel.
|
|
139
|
+
let sessions = {};
|
|
140
|
+
let sessionsAt = null;
|
|
141
|
+
async function refreshSessions() {
|
|
142
|
+
sessions = await probe(agents);
|
|
143
|
+
sessionsAt = new Date().toISOString();
|
|
144
|
+
return sessions;
|
|
145
|
+
}
|
|
146
|
+
void refreshSessions().catch((error) => console.error(`MADRE session probe failed: ${error.message}`));
|
|
147
|
+
|
|
148
|
+
// Effective settings = environment + config file + live changes; the file
|
|
149
|
+
// is what survives a restart.
|
|
150
|
+
function effectiveSettings() {
|
|
151
|
+
const current = room.settings();
|
|
152
|
+
return {
|
|
153
|
+
timeouts: Object.fromEntries(agents.map((agent) => [agent.id, room.timeoutFor(agent.id)])),
|
|
154
|
+
defaultTimeout: current.agentTimeouts.default ?? 180000,
|
|
155
|
+
delegation: current.delegation,
|
|
156
|
+
maxPlanSteps: current.maxPlanSteps,
|
|
157
|
+
softTokenBudget: current.softTokenBudget,
|
|
158
|
+
opencodeModel: process.env.PULSE_OPENCODE_MODEL ?? null,
|
|
159
|
+
geminiIdleMs: Number(process.env.PULSE_GEMINI_IDLE_MS ?? 90000),
|
|
160
|
+
geminiRetries: Number(process.env.PULSE_GEMINI_RETRIES ?? 1),
|
|
161
|
+
capabilities: room.capabilities(),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
async function applySettings(patch) {
|
|
165
|
+
const config = {};
|
|
166
|
+
const live = {};
|
|
167
|
+
if (patch.opencode && 'model' in patch.opencode) {
|
|
168
|
+
config.opencode = { model: patch.opencode.model || undefined };
|
|
169
|
+
if (patch.opencode.model) process.env.PULSE_OPENCODE_MODEL = String(patch.opencode.model); else delete process.env.PULSE_OPENCODE_MODEL;
|
|
170
|
+
}
|
|
171
|
+
if (patch.timeouts) {
|
|
172
|
+
const timeouts = { ...room.settings().agentTimeouts };
|
|
173
|
+
for (const [key, value] of Object.entries(patch.timeouts)) {
|
|
174
|
+
const ms = Number(value);
|
|
175
|
+
if (Number.isFinite(ms) && ms > 0) timeouts[key] = ms; else delete timeouts[key];
|
|
176
|
+
}
|
|
177
|
+
// A one-field save must not erase the other timeouts on disk.
|
|
178
|
+
const stored = { ...((await readConfig(root)).timeouts ?? {}) };
|
|
179
|
+
for (const [key, value] of Object.entries(patch.timeouts)) { if (Number(value) > 0) stored[key] = Number(value); else delete stored[key]; }
|
|
180
|
+
config.timeouts = stored;
|
|
181
|
+
live.agentTimeouts = timeouts;
|
|
182
|
+
}
|
|
183
|
+
if (patch.room) {
|
|
184
|
+
config.room = { ...((await readConfig(root)).room ?? {}) };
|
|
185
|
+
if (typeof patch.room.delegation === 'boolean') { config.room.delegation = patch.room.delegation; live.delegation = patch.room.delegation; }
|
|
186
|
+
if (Number(patch.room.maxPlanSteps) > 0) { config.room.maxPlanSteps = Number(patch.room.maxPlanSteps); live.maxPlanSteps = Number(patch.room.maxPlanSteps); }
|
|
187
|
+
if (Number(patch.room.softTokenBudget) > 0) { config.room.softTokenBudget = Number(patch.room.softTokenBudget); live.softTokenBudget = Number(patch.room.softTokenBudget); }
|
|
188
|
+
}
|
|
189
|
+
if (patch.scopes && typeof patch.scopes === 'object') {
|
|
190
|
+
const current = (await readConfig(root)).scopes ?? {};
|
|
191
|
+
config.scopes = { ...current };
|
|
192
|
+
for (const [agentId, scopes] of Object.entries(patch.scopes)) {
|
|
193
|
+
if (!agents.some((agent) => agent.id === agentId) || !scopes || typeof scopes !== 'object') continue;
|
|
194
|
+
config.scopes[agentId] = { ...(current[agentId] ?? {}) };
|
|
195
|
+
for (const scope of ['write', 'imageGen', 'web', 'alwaysCreate']) if (typeof scopes[scope] === 'boolean') config.scopes[agentId][scope] = scopes[scope];
|
|
196
|
+
if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 3) config.scopes[agentId].maxMode = Number(scopes.maxMode);
|
|
197
|
+
}
|
|
198
|
+
room.setScopes(config.scopes);
|
|
199
|
+
}
|
|
200
|
+
if (patch.gemini) {
|
|
201
|
+
config.gemini = { ...((await readConfig(root)).gemini ?? {}) };
|
|
202
|
+
if (Number(patch.gemini.idleMs) > 0) { config.gemini.idleMs = Number(patch.gemini.idleMs); process.env.PULSE_GEMINI_IDLE_MS = String(Number(patch.gemini.idleMs)); }
|
|
203
|
+
if (Number.isFinite(Number(patch.gemini.retries))) { config.gemini.retries = Number(patch.gemini.retries); process.env.PULSE_GEMINI_RETRIES = String(Number(patch.gemini.retries)); }
|
|
204
|
+
}
|
|
205
|
+
room.configure(live);
|
|
206
|
+
await updateConfig(root, config);
|
|
207
|
+
await room.record('room.settings', { changed: Object.keys(config), settings: effectiveSettings() });
|
|
208
|
+
return effectiveSettings();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let loggingIn = null;
|
|
212
|
+
async function loginAgent(id) {
|
|
213
|
+
const agent = agents.find((item) => item.id === id);
|
|
214
|
+
if (!agent) return { status: 404, body: { error: `Unknown agent: ${id}.` } };
|
|
215
|
+
if (!agent.detected) return { status: 412, body: { error: `${agent.label} is not installed on this computer.`, install: loginPlanFor(agent)?.install ?? [] } };
|
|
216
|
+
const plan = loginRunners[id]?.(agent) ?? loginPlanFor(agent);
|
|
217
|
+
if (!plan) return { status: 404, body: { error: `No sign-in flow known for ${id}.` } };
|
|
218
|
+
if (!plan.headless) return { status: 409, body: { error: `${agent.label} signs in from its own prompt. Run this in a terminal, then press RECHECK.`, command: plan.display, note: plan.note } };
|
|
219
|
+
if (loggingIn) return { status: 409, body: { error: `A sign-in is already running (${loggingIn}).` } };
|
|
220
|
+
loggingIn = id;
|
|
221
|
+
await room.record('connection.login.started', { agent: id, command: plan.display, note: plan.note });
|
|
222
|
+
void (async () => {
|
|
223
|
+
const lines = [];
|
|
224
|
+
const result = await runInstaller({
|
|
225
|
+
command: plan.command,
|
|
226
|
+
args: plan.args,
|
|
227
|
+
projectRoot: canonicalProjectRoot,
|
|
228
|
+
timeoutMs: 300000,
|
|
229
|
+
onLine: (line) => {
|
|
230
|
+
lines.push(line);
|
|
231
|
+
const url = line.match(/https?:\/\/\S+/)?.[0] ?? null;
|
|
232
|
+
void room.record('connection.login.output', { agent: id, line: line.slice(0, 500), url });
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
await refreshSessions();
|
|
236
|
+
await room.record('connection.login.finished', { agent: id, code: result.code, error: result.error ?? null, session: sessions[id] ?? null, tail: lines.slice(-6) });
|
|
237
|
+
loggingIn = null;
|
|
238
|
+
})();
|
|
239
|
+
return { status: 202, body: { accepted: true, command: plan.display } };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Attachments live beside the room log, never inside the project.
|
|
243
|
+
const roomDirectory = join(root, 'rooms', projectRoomId(canonicalProjectRoot));
|
|
244
|
+
const attachmentsRoot = join(roomDirectory, 'attachments');
|
|
245
|
+
room.restoreAttachments(historicalEvents
|
|
246
|
+
.filter((event) => event.type === 'attachment.stored')
|
|
247
|
+
.map((event) => ({ ...event.payload, path: join(attachmentsRoot, event.payload.fileName), dir: attachmentsRoot })));
|
|
248
|
+
|
|
249
|
+
async function rawBody(request, limit) {
|
|
250
|
+
const chunks = [];
|
|
251
|
+
let total = 0;
|
|
252
|
+
for await (const chunk of request) {
|
|
253
|
+
total += chunk.length;
|
|
254
|
+
if (total > limit) throw Object.assign(new Error(`Attachment is larger than ${Math.round(limit / 1024 / 1024)} MB.`), { status: 413 });
|
|
255
|
+
chunks.push(chunk);
|
|
256
|
+
}
|
|
257
|
+
return Buffer.concat(chunks);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const startupConfig = await readConfig(root);
|
|
261
|
+
room.setScopes(startupConfig.scopes ?? {});
|
|
262
|
+
setImageModule(startupConfig.modules?.imageStudio ?? {});
|
|
263
|
+
room.setAshCode(Boolean(startupConfig.modules?.ashCode?.enabled));
|
|
264
|
+
const recoveredTurns = await room.reconcile();
|
|
265
|
+
if (recoveredTurns) console.error(`MADRE recovered ${recoveredTurns} unfinished turn(s) from a previous run.`);
|
|
266
|
+
const quotaMonitor = new QuotaMonitor({
|
|
267
|
+
sources: quotaSources,
|
|
268
|
+
intervalMs: quotaPollIntervalMs,
|
|
269
|
+
onReport: (report) => room.reportOfficialQuota(report),
|
|
270
|
+
});
|
|
271
|
+
await quotaMonitor.start();
|
|
272
|
+
// SSE fan-out works from the durable log, not from in-memory emits, so events
|
|
273
|
+
// appended by another MADRE process on the same room reach open pages too.
|
|
274
|
+
// `clients` maps each SSE response to the last sequence it already holds.
|
|
275
|
+
const clients = new Map();
|
|
276
|
+
let lastBroadcastSequence = historicalEvents.at(-1)?.sequence ?? 0;
|
|
277
|
+
// Byte offset of the log already broadcast; the poller only reads past it.
|
|
278
|
+
let tailOffset = (await store.tail(0)).offset;
|
|
279
|
+
let inFlight = null;
|
|
280
|
+
let dirty = false;
|
|
281
|
+
// A client that stops draining is dropped instead of buffering without bound.
|
|
282
|
+
const writeEvent = (client, event) => {
|
|
283
|
+
// Ghost events have no sequence: they are never in the log, so no `id:`
|
|
284
|
+
// line, and a reconnecting client will not ask for them again.
|
|
285
|
+
client.write(`${event.sequence ? `id: ${event.sequence}\n` : ''}data: ${JSON.stringify(event)}\n\n`);
|
|
286
|
+
if (client.writableLength > sseMaxBufferedBytes) {
|
|
287
|
+
clients.delete(client);
|
|
288
|
+
client.destroy();
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
function broadcastPending() {
|
|
295
|
+
if (inFlight) {
|
|
296
|
+
dirty = true;
|
|
297
|
+
return inFlight;
|
|
298
|
+
}
|
|
299
|
+
inFlight = (async () => {
|
|
300
|
+
try {
|
|
301
|
+
do {
|
|
302
|
+
dirty = false;
|
|
303
|
+
const { events, offset } = await store.tail(tailOffset);
|
|
304
|
+
tailOffset = offset;
|
|
305
|
+
for (const event of events) {
|
|
306
|
+
if (event.sequence <= lastBroadcastSequence) continue;
|
|
307
|
+
for (const [client, floor] of clients) {
|
|
308
|
+
if (event.sequence > floor) writeEvent(client, event);
|
|
309
|
+
}
|
|
310
|
+
lastBroadcastSequence = event.sequence;
|
|
311
|
+
}
|
|
312
|
+
} while (dirty);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.error(`MADRE broadcast error: ${error.message}`);
|
|
315
|
+
} finally {
|
|
316
|
+
inFlight = null;
|
|
317
|
+
}
|
|
318
|
+
})();
|
|
319
|
+
return inFlight;
|
|
320
|
+
}
|
|
321
|
+
const unsubscribe = room.subscribe(() => { void broadcastPending(); });
|
|
322
|
+
const unsubscribeGhost = room.subscribeGhost((event) => { for (const client of clients.keys()) writeEvent(client, event); });
|
|
323
|
+
const poller = setInterval(() => { void broadcastPending(); }, broadcastIntervalMs);
|
|
324
|
+
poller.unref();
|
|
325
|
+
|
|
326
|
+
let installing = null;
|
|
327
|
+
async function installExtension(id, { confirm } = {}) {
|
|
328
|
+
const extension = extensionById(id);
|
|
329
|
+
if (!extension) return { status: 404, body: { error: `Unknown module: ${id}.` } };
|
|
330
|
+
if (extension.id === 'ripley') {
|
|
331
|
+
const current = await readConfig(root);
|
|
332
|
+
const enabled = !Boolean(current.modules?.ripley?.enabled);
|
|
333
|
+
await updateConfig(root, { modules: { ...current.modules, ripley: { enabled } } });
|
|
334
|
+
ripleyEnabled = enabled;
|
|
335
|
+
await room.record('extension.toggled', { id, name: extension.name, enabled });
|
|
336
|
+
return { status: 200, body: { enabled } };
|
|
337
|
+
}
|
|
338
|
+
if (extension.id === 'ashcode') {
|
|
339
|
+
const current = await readConfig(root);
|
|
340
|
+
const enabled = !Boolean(current.modules?.ashCode?.enabled);
|
|
341
|
+
if (enabled && confirm !== true) return { status: 400, body: { error: 'AshCode is beta and can change meaning. Send { "confirm": true } to enable it.' } };
|
|
342
|
+
await updateConfig(root, { modules: { ...current.modules, ashCode: { enabled } } });
|
|
343
|
+
room.setAshCode(enabled);
|
|
344
|
+
await room.record('extension.toggled', { id, name: extension.name, enabled, beta: true });
|
|
345
|
+
return { status: 200, body: { enabled, beta: true, warning: 'AshCode beta may alter meaning; review the original. Character reduction is not verified token savings.' } };
|
|
346
|
+
}
|
|
347
|
+
if (extension.kind === 'builtin') {
|
|
348
|
+
// Image Studio: a switch in config.json, nothing written to the project.
|
|
349
|
+
const current = await readConfig(root);
|
|
350
|
+
const enabled = !(current.modules?.imageStudio?.enabled);
|
|
351
|
+
const key = await imageKey();
|
|
352
|
+
if (enabled && !key) return { status: 412, body: { error: 'No Gemini API key found. Sign in with the Gemini CLI (/auth → API key) or set GEMINI_API_KEY, then enable Image Studio.' } };
|
|
353
|
+
const model = typeof arguments[1]?.model === 'string' && arguments[1].model ? arguments[1].model : (current.modules?.imageStudio?.model ?? extension.models[0]);
|
|
354
|
+
await updateConfig(root, { modules: { ...current.modules, imageStudio: { enabled, model } } });
|
|
355
|
+
setImageModule({ enabled, model });
|
|
356
|
+
await room.record('extension.toggled', { id, name: extension.name, enabled, model });
|
|
357
|
+
return { status: 200, body: { enabled, model, capabilities: room.capabilities() } };
|
|
358
|
+
}
|
|
359
|
+
if (confirm !== true) return { status: 400, body: { error: 'Installing a module writes into the project; send { "confirm": true } to proceed.' } };
|
|
360
|
+
if (installing) return { status: 409, body: { error: `Another install is running (${installing}).` } };
|
|
361
|
+
const preflight = extension.preflight ? await extension.preflight(canonicalProjectRoot) : { ok: true, problems: [] };
|
|
362
|
+
if (!preflight.ok && !installers[id]) {
|
|
363
|
+
await room.record('extension.install.refused', { id, name: extension.name, problems: preflight.problems });
|
|
364
|
+
return { status: 412, body: { error: preflight.problems.join(' '), problems: preflight.problems } };
|
|
365
|
+
}
|
|
366
|
+
const before = await extension.detect(canonicalProjectRoot);
|
|
367
|
+
const plan = installers[id]?.({ projectRoot: canonicalProjectRoot, agents }) ?? extension.installCommand({ agents });
|
|
368
|
+
installing = id;
|
|
369
|
+
await room.record('extension.install.started', { id, name: extension.name, command: plan.display, platforms: plan.platforms ?? [], alreadyInstalled: before.installed });
|
|
370
|
+
void (async () => {
|
|
371
|
+
const lines = [];
|
|
372
|
+
let pending = [];
|
|
373
|
+
let flushTimer = null;
|
|
374
|
+
const flush = async () => {
|
|
375
|
+
flushTimer = null;
|
|
376
|
+
if (!pending.length) return;
|
|
377
|
+
const batch = pending;
|
|
378
|
+
pending = [];
|
|
379
|
+
await room.record('extension.install.output', { id, lines: batch });
|
|
380
|
+
};
|
|
381
|
+
const result = await runInstaller({
|
|
382
|
+
command: plan.command,
|
|
383
|
+
args: plan.args,
|
|
384
|
+
projectRoot: canonicalProjectRoot,
|
|
385
|
+
onLine: (line) => {
|
|
386
|
+
lines.push(line);
|
|
387
|
+
pending.push(line.slice(0, 500));
|
|
388
|
+
if (pending.length >= 20) void flush();
|
|
389
|
+
else if (!flushTimer) flushTimer = setTimeout(() => { void flush(); }, 400);
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
clearTimeout(flushTimer);
|
|
393
|
+
await flush();
|
|
394
|
+
const after = await extension.detect(canonicalProjectRoot);
|
|
395
|
+
await room.record('extension.install.finished', {
|
|
396
|
+
id,
|
|
397
|
+
name: extension.name,
|
|
398
|
+
code: result.code,
|
|
399
|
+
error: result.error ?? null,
|
|
400
|
+
ok: result.code === 0 && after.installed,
|
|
401
|
+
status: after,
|
|
402
|
+
tail: lines.slice(-12),
|
|
403
|
+
});
|
|
404
|
+
installing = null;
|
|
405
|
+
})();
|
|
406
|
+
return { status: 202, body: { accepted: true, command: plan.display } };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const server = http.createServer(async (request, response) => {
|
|
410
|
+
const url = new URL(request.url, 'http://127.0.0.1');
|
|
411
|
+
try {
|
|
412
|
+
if (request.method === 'GET' && url.pathname === '/') {
|
|
413
|
+
const html = await readFile(join(publicDirectory, 'index.html'));
|
|
414
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
415
|
+
return response.end(html);
|
|
416
|
+
}
|
|
417
|
+
if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js'].includes(url.pathname)) {
|
|
418
|
+
const js = await readFile(join(publicDirectory, url.pathname.slice(1)));
|
|
419
|
+
response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
|
|
420
|
+
return response.end(js);
|
|
421
|
+
}
|
|
422
|
+
if (request.method === 'GET' && url.pathname === '/styles.css') {
|
|
423
|
+
const css = await readFile(join(publicDirectory, 'styles.css'));
|
|
424
|
+
response.writeHead(200, { 'content-type': 'text/css; charset=utf-8' });
|
|
425
|
+
return response.end(css);
|
|
426
|
+
}
|
|
427
|
+
if (request.method === 'GET' && url.pathname === '/api/state') {
|
|
428
|
+
return sendJson(response, 200, {
|
|
429
|
+
projectRoot,
|
|
430
|
+
agents,
|
|
431
|
+
ashCode: { enabled: room.ashCodeEnabled() },
|
|
432
|
+
ripley: { enabled: ripleyEnabled },
|
|
433
|
+
softTokenBudget,
|
|
434
|
+
timeouts: Object.fromEntries(agents.map((agent) => [agent.id, room.timeoutFor(agent.id)])),
|
|
435
|
+
delegation: { enabled: room.settings().delegation, maxPlanSteps: room.settings().maxPlanSteps },
|
|
436
|
+
plans: room.activePlans(),
|
|
437
|
+
turns: room.activeTurns(),
|
|
438
|
+
modeRequests: room.pendingModeRequests(),
|
|
439
|
+
control: room.control(),
|
|
440
|
+
sessions,
|
|
441
|
+
sessionsAt,
|
|
442
|
+
capabilities: room.capabilities(),
|
|
443
|
+
quotaSources: quotaMonitor.snapshot(),
|
|
444
|
+
budgetWindow: room.budgetWindow(),
|
|
445
|
+
events: await store.readAll(),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
if (request.method === 'GET' && url.pathname === '/api/events') {
|
|
449
|
+
const sinceValue = request.headers['last-event-id'] ?? url.searchParams.get('since');
|
|
450
|
+
const since = sinceValue === undefined || sinceValue === null || sinceValue === '' ? null : Number(sinceValue);
|
|
451
|
+
response.writeHead(200, {
|
|
452
|
+
'content-type': 'text/event-stream',
|
|
453
|
+
'cache-control': 'no-cache',
|
|
454
|
+
connection: 'keep-alive',
|
|
455
|
+
});
|
|
456
|
+
response.write(': connected\n\n');
|
|
457
|
+
// Replay what this client missed between /api/state and this connection,
|
|
458
|
+
// then register it so the shared cursor delivers everything newer.
|
|
459
|
+
const events = Number.isFinite(since) ? await store.readAll() : [];
|
|
460
|
+
let floor = Number.isFinite(since) ? since : 0;
|
|
461
|
+
for (const event of events) {
|
|
462
|
+
if (event.sequence > floor && event.sequence <= lastBroadcastSequence) {
|
|
463
|
+
writeEvent(response, event);
|
|
464
|
+
floor = event.sequence;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
clients.set(response, floor);
|
|
468
|
+
request.on('close', () => clients.delete(response));
|
|
469
|
+
void broadcastPending();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (request.method === 'GET' && url.pathname === '/api/tree/search') {
|
|
473
|
+
const matches = await searchFiles(canonicalProjectRoot, url.searchParams.get('q') ?? '', { limit: Math.min(Number(url.searchParams.get('limit')) || 20, 50) });
|
|
474
|
+
return sendJson(response, 200, { matches });
|
|
475
|
+
}
|
|
476
|
+
if (request.method === 'GET' && url.pathname === '/api/tree') {
|
|
477
|
+
const listing = await listDirectory(canonicalProjectRoot, url.searchParams.get('path') ?? '.');
|
|
478
|
+
if (listing.status !== 200) return sendJson(response, listing.status, { error: listing.error });
|
|
479
|
+
return sendJson(response, 200, { path: url.searchParams.get('path') ?? '.', entries: listing.entries, truncated: listing.truncated });
|
|
480
|
+
}
|
|
481
|
+
if (request.method === 'GET' && url.pathname === '/api/files') {
|
|
482
|
+
const which = url.searchParams.get('root') === 'attachments' ? attachmentsRoot : canonicalProjectRoot;
|
|
483
|
+
const file = await readServable(which, url.searchParams.get('path') ?? '');
|
|
484
|
+
if (file.status !== 200) return sendJson(response, file.status, { error: file.error });
|
|
485
|
+
response.writeHead(200, {
|
|
486
|
+
'content-type': file.contentType,
|
|
487
|
+
'content-length': file.size,
|
|
488
|
+
'cache-control': 'no-store',
|
|
489
|
+
'x-content-type-options': 'nosniff',
|
|
490
|
+
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; img-src data:",
|
|
491
|
+
'content-disposition': `inline; filename="${encodeURIComponent(basename(file.path))}"`,
|
|
492
|
+
});
|
|
493
|
+
return response.end(file.body);
|
|
494
|
+
}
|
|
495
|
+
// RIPLEY: the same file, served to be rendered, inside a frame that may run
|
|
496
|
+
// nothing, load nothing from outside and keep nothing.
|
|
497
|
+
if (request.method === 'GET' && url.pathname === '/api/preview') {
|
|
498
|
+
if (!ripleyEnabled) return sendJson(response, 412, { error: 'RIPLEY is off. Enable it in MODULES to render files.' });
|
|
499
|
+
const which = url.searchParams.get('root') === 'attachments' ? attachmentsRoot : canonicalProjectRoot;
|
|
500
|
+
const relative = url.searchParams.get('path') ?? '';
|
|
501
|
+
const kind = /\.(html?|xhtml)$/i.test(relative) ? 'text/html; charset=utf-8' : /\.svg$/i.test(relative) ? 'image/svg+xml' : null;
|
|
502
|
+
if (!kind) return sendJson(response, 415, { error: 'RIPLEY renders .html and .svg here; Markdown is rendered in the viewer itself.' });
|
|
503
|
+
const file = await readServable(which, relative);
|
|
504
|
+
if (file.status !== 200) return sendJson(response, file.status, { error: file.error });
|
|
505
|
+
response.writeHead(200, {
|
|
506
|
+
'content-type': kind,
|
|
507
|
+
'content-length': file.size,
|
|
508
|
+
'cache-control': 'no-store',
|
|
509
|
+
'x-content-type-options': 'nosniff',
|
|
510
|
+
'referrer-policy': 'no-referrer',
|
|
511
|
+
'content-security-policy': "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; media-src 'self' data:; form-action 'none'; base-uri 'none'",
|
|
512
|
+
'content-disposition': `inline; filename="${encodeURIComponent(basename(file.path))}"`,
|
|
513
|
+
});
|
|
514
|
+
return response.end(file.body);
|
|
515
|
+
}
|
|
516
|
+
if (request.method === 'POST' && url.pathname === '/api/attachments') {
|
|
517
|
+
let bytes;
|
|
518
|
+
try {
|
|
519
|
+
bytes = await rawBody(request, MAX_ATTACHMENT_BYTES);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
return sendJson(response, error.status ?? 400, { error: error.message });
|
|
522
|
+
}
|
|
523
|
+
const name = decodeURIComponent(request.headers['x-pulse-filename'] ?? url.searchParams.get('name') ?? 'attachment');
|
|
524
|
+
const record = await room.registerAttachment(await storeAttachment(attachmentsRoot, { name, bytes }));
|
|
525
|
+
return sendJson(response, 201, { attachment: { id: record.id, name: record.name, fileName: record.fileName, size: record.size, contentType: record.contentType, url: `/api/files?root=attachments&path=${encodeURIComponent(record.fileName)}` } });
|
|
526
|
+
}
|
|
527
|
+
if (request.method === 'GET' && url.pathname === '/api/settings') {
|
|
528
|
+
return sendJson(response, 200, { settings: effectiveSettings(), config: await readConfig(root), sessions, sessionsAt, loggingIn, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent) })) });
|
|
529
|
+
}
|
|
530
|
+
if (request.method === 'POST' && url.pathname === '/api/settings') {
|
|
531
|
+
return sendJson(response, 200, { settings: await applySettings(await body(request)) });
|
|
532
|
+
}
|
|
533
|
+
// NOSTROMO: the archive is behind the project designation, like CONTROL.
|
|
534
|
+
const designationOk = (given) => typeof given === 'string' && given.trim().toLowerCase() === basename(canonicalProjectRoot).toLowerCase();
|
|
535
|
+
if (request.method === 'GET' && url.pathname === '/api/mother') {
|
|
536
|
+
return sendJson(response, 200, { mother: room.motherStatus(), strikes: CODE000_STRIKES });
|
|
537
|
+
}
|
|
538
|
+
if (request.method === 'POST' && url.pathname === '/api/mother/code000') {
|
|
539
|
+
const payload = await body(request).catch(() => ({}));
|
|
540
|
+
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
|
|
541
|
+
const result = await room.code000({ strikes: Number(payload.strikes) || CODE000_STRIKES });
|
|
542
|
+
return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: 'MOTHER is silent.' });
|
|
543
|
+
}
|
|
544
|
+
if (request.method === 'GET' && url.pathname === '/api/memory') {
|
|
545
|
+
if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
|
|
546
|
+
const sealed = room.motherStatus()?.lockedForMs ?? 0;
|
|
547
|
+
if (sealed > 0) return sendJson(response, 423, { error: `CODE000. THE ARCHIVE IS SEALED FOR ${Math.ceil(sealed / 60000)} MORE MINUTE${Math.ceil(sealed / 60000) === 1 ? '' : 'S'}.`, lockedForMs: sealed });
|
|
548
|
+
const research = room.memoryResearch();
|
|
549
|
+
return research ? sendJson(response, 200, research) : sendJson(response, 503, { error: 'The room has no memory.' });
|
|
550
|
+
}
|
|
551
|
+
const forgetMatch = request.method === 'DELETE' && url.pathname.match(/^\/api\/memory\/(\d+)$/);
|
|
552
|
+
if (forgetMatch) {
|
|
553
|
+
const payload = await body(request).catch(() => ({}));
|
|
554
|
+
if (!designationOk(payload.designation)) return sendJson(response, 403, { error: 'UNABLE TO COMPUTE. UNABLE TO CLARIFY.' });
|
|
555
|
+
if ((room.motherStatus()?.lockedForMs ?? 0) > 0) return sendJson(response, 423, { error: 'CODE000. THE ARCHIVE IS SEALED.' });
|
|
556
|
+
const row = await room.forgetMemory(forgetMatch[1]);
|
|
557
|
+
return row ? sendJson(response, 200, { forgotten: row, stats: room.memoryStats() }) : sendJson(response, 404, { error: 'No such memory.' });
|
|
558
|
+
}
|
|
559
|
+
if (request.method === 'POST' && url.pathname === '/api/agents/probe') {
|
|
560
|
+
return sendJson(response, 200, { sessions: await refreshSessions(), sessionsAt });
|
|
561
|
+
}
|
|
562
|
+
const loginMatch = request.method === 'POST' && url.pathname.match(/^\/api\/agents\/([a-z0-9-]+)\/login$/);
|
|
563
|
+
if (loginMatch) {
|
|
564
|
+
const result = await loginAgent(loginMatch[1]);
|
|
565
|
+
return sendJson(response, result.status, result.body);
|
|
566
|
+
}
|
|
567
|
+
if (request.method === 'GET' && url.pathname === '/api/models') {
|
|
568
|
+
const listOpenCode = async (opencode) => {
|
|
569
|
+
const models = [];
|
|
570
|
+
await runInstaller({ command: opencode.path, args: ['models'], projectRoot: canonicalProjectRoot, timeoutMs: 20000, onLine: (line) => { if (/^[\w.-]+\/[\w.:-]+$/.test(line.trim())) models.push(line.trim()); } });
|
|
571
|
+
return models;
|
|
572
|
+
};
|
|
573
|
+
return sendJson(response, 200, { models: await discoverModels({ agents, config: await readConfig(root), listOpenCode: url.searchParams.get('opencode') === '1' ? listOpenCode : async () => [] }) });
|
|
574
|
+
}
|
|
575
|
+
if (request.method === 'GET' && url.pathname === '/api/agents/opencode/models') {
|
|
576
|
+
const opencode = agents.find((agent) => agent.id === 'opencode');
|
|
577
|
+
if (!opencode?.detected) return sendJson(response, 404, { error: 'OpenCode is not installed.' });
|
|
578
|
+
const models = [];
|
|
579
|
+
await runInstaller({ command: opencode.path, args: ['models'], projectRoot: canonicalProjectRoot, timeoutMs: 20000, onLine: (line) => { if (/^[\w.-]+\/[\w.:-]+$/.test(line.trim())) models.push(line.trim()); } });
|
|
580
|
+
return sendJson(response, 200, { models });
|
|
581
|
+
}
|
|
582
|
+
if (request.method === 'POST' && url.pathname === '/api/stop-all') {
|
|
583
|
+
const result = await room.stopAll();
|
|
584
|
+
return sendJson(response, 202, { stopped: true, ...result });
|
|
585
|
+
}
|
|
586
|
+
const stopMatch = request.method === 'POST' && url.pathname.match(/^\/api\/plans\/([0-9a-f-]+)\/stop$/);
|
|
587
|
+
if (stopMatch) {
|
|
588
|
+
const stopped = await room.stopPlan(stopMatch[1]);
|
|
589
|
+
return sendJson(response, stopped ? 202 : 404, stopped ? { stopped: true } : { error: 'No running plan with that id.' });
|
|
590
|
+
}
|
|
591
|
+
if (request.method === 'GET' && url.pathname === '/api/commands') {
|
|
592
|
+
return sendJson(response, 200, { commands: await listCommands({ projectRoot: canonicalProjectRoot }) });
|
|
593
|
+
}
|
|
594
|
+
if (request.method === 'POST' && url.pathname === '/api/commands') {
|
|
595
|
+
const { text } = await body(request);
|
|
596
|
+
const parsed = parseCommand(text);
|
|
597
|
+
if (!parsed) return sendJson(response, 400, { error: 'Not a command. Commands start with "/" followed by a name.' });
|
|
598
|
+
const command = commandByName(parsed.name);
|
|
599
|
+
if (!command) return sendJson(response, 404, { error: `Unknown command /${parsed.name}.` });
|
|
600
|
+
if (!(await command.available({ projectRoot: canonicalProjectRoot }))) return sendJson(response, 412, { error: `/${parsed.name} is not available in this project (${command.title}).` });
|
|
601
|
+
const result = await command.execute({ projectRoot: canonicalProjectRoot, args: parsed.args });
|
|
602
|
+
const event = await room.recordCommand({ name: parsed.name, args: parsed.args, ...result });
|
|
603
|
+
return sendJson(response, result.ok ? 200 : 422, { command: parsed.name, title: result.title, ok: result.ok, sequence: event.sequence });
|
|
604
|
+
}
|
|
605
|
+
if (request.method === 'GET' && url.pathname === '/api/extensions') {
|
|
606
|
+
return sendJson(response, 200, { installing, extensions: await listExtensions({ projectRoot: canonicalProjectRoot, agents, config: await readConfig(root), imageKey }) });
|
|
607
|
+
}
|
|
608
|
+
const installMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/install$/);
|
|
609
|
+
if (installMatch) {
|
|
610
|
+
const result = await installExtension(installMatch[1], await body(request));
|
|
611
|
+
return sendJson(response, result.status, result.body);
|
|
612
|
+
}
|
|
613
|
+
if (request.method === 'POST' && /^\/api\/control\/[\w-]+\/undo$/.test(url.pathname)) {
|
|
614
|
+
const result = await room.undoControl(url.pathname.split('/')[3]);
|
|
615
|
+
return sendJson(response, result.ok ? 200 : result.status ?? 400, result);
|
|
616
|
+
}
|
|
617
|
+
if (request.method === 'POST' && /^\/api\/modes\/[\w-]+\/decide$/.test(url.pathname)) {
|
|
618
|
+
const requestId = url.pathname.split('/')[3];
|
|
619
|
+
const { decision } = await body(request);
|
|
620
|
+
const result = room.decideMode(requestId, decision);
|
|
621
|
+
return sendJson(response, result.ok ? 200 : 409, result);
|
|
622
|
+
}
|
|
623
|
+
if (request.method === 'POST' && url.pathname === '/api/messages') {
|
|
624
|
+
const payload = await body(request);
|
|
625
|
+
// Modes are checked before the turn is accepted, so the composer
|
|
626
|
+
// hears "no" with a reason instead of a silent log line.
|
|
627
|
+
const gate = await room.modeCheck(payload);
|
|
628
|
+
if (!gate.ok) return sendJson(response, gate.status ?? 403, { error: gate.error, mode: gate.mode, maxMode: gate.maxMode });
|
|
629
|
+
void room.send(payload).catch((error) => {
|
|
630
|
+
console.error(`MADRE room error: ${error.message}`);
|
|
631
|
+
});
|
|
632
|
+
return sendJson(response, 202, { accepted: true });
|
|
633
|
+
}
|
|
634
|
+
if (request.method === 'POST' && url.pathname === '/api/test/limits' && testMode) {
|
|
635
|
+
const payload = await body(request);
|
|
636
|
+
const warning = await room.reportLimit({
|
|
637
|
+
agent: payload.agent,
|
|
638
|
+
usedPercent: payload.usedPercent,
|
|
639
|
+
source: 'test-simulation',
|
|
640
|
+
});
|
|
641
|
+
return sendJson(response, 200, { emitted: Boolean(warning), warning });
|
|
642
|
+
}
|
|
643
|
+
sendJson(response, 404, { error: 'Not found.' });
|
|
644
|
+
} catch (error) {
|
|
645
|
+
sendJson(response, 400, { error: error.message });
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
// `server.close()` only resolves once every connection has ended, so the
|
|
649
|
+
// long-lived SSE responses must be ended before the native close runs.
|
|
650
|
+
const nativeClose = server.close.bind(server);
|
|
651
|
+
server.close = (callback) => {
|
|
652
|
+
clearInterval(poller);
|
|
653
|
+
clearTimeout(embedKick);
|
|
654
|
+
// In-flight agent processes are killed and their turns recorded as failed
|
|
655
|
+
// before the SSE clients go away, so open pages see the outcome.
|
|
656
|
+
const shutdown = room.shutdown().catch((error) => console.error(`MADRE shutdown error: ${error.message}`));
|
|
657
|
+
let result;
|
|
658
|
+
shutdown.then(() => {
|
|
659
|
+
void broadcastPending().then(() => {
|
|
660
|
+
unsubscribe();
|
|
661
|
+
unsubscribeGhost();
|
|
662
|
+
for (const client of clients.keys()) client.end();
|
|
663
|
+
clients.clear();
|
|
664
|
+
// The memory file closes last, once nothing else writes to it; WAL and shm go with it.
|
|
665
|
+
try { memory?.close(); } catch { /* already closed */ }
|
|
666
|
+
result = nativeClose(callback);
|
|
667
|
+
server.closeIdleConnections?.();
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
return server;
|
|
671
|
+
};
|
|
672
|
+
server.on('close', () => quotaMonitor.stop());
|
|
673
|
+
|
|
674
|
+
return { server, store, agents, quotaMonitor };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export async function startPulse({ port, projectRoot, openBrowser }) {
|
|
678
|
+
const { server, agents } = await createPulseServer({ projectRoot, quotaSources: defaultQuotaSources() });
|
|
679
|
+
await new Promise((resolve, reject) => {
|
|
680
|
+
server.once('error', reject);
|
|
681
|
+
server.listen(port, '127.0.0.1', resolve);
|
|
682
|
+
});
|
|
683
|
+
const address = server.address();
|
|
684
|
+
const url = `http://127.0.0.1:${address.port}`;
|
|
685
|
+
const ready = agents.filter((agent) => agent.ready).map((agent) => agent.label).join(', ') || 'none';
|
|
686
|
+
const detected = agents.filter((agent) => agent.detected).map((agent) => agent.label).join(', ') || 'none';
|
|
687
|
+
console.log(`\nMADRE is ready\n\n ${url}\n Project: ${projectRoot}\n Ready: ${ready}\n Detected: ${detected}\n`);
|
|
688
|
+
if (openBrowser) openUrl(url);
|
|
689
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
690
|
+
process.once(signal, () => server.close(() => process.exit(0)));
|
|
691
|
+
}
|
|
692
|
+
return { server, url };
|
|
693
|
+
}
|