@jossuealcala/madre 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.en.md +69 -1
  2. package/CHANGELOG.md +69 -1
  3. package/README.en.md +11 -15
  4. package/README.md +3 -3
  5. package/bin/madre.cjs +40 -0
  6. package/bin/madre.mjs +5 -0
  7. package/docs/REFERENCE.md +29 -3
  8. package/docs/SDK.md +4 -2
  9. package/docs/en/REFERENCE.md +29 -3
  10. package/docs/en/SDK.md +4 -2
  11. package/docs/training/README.md +28 -1
  12. package/docs/training/train.sh +5 -1
  13. package/package.json +3 -3
  14. package/public/app.js +1626 -151
  15. package/public/attribution.js +44 -1
  16. package/public/brands.js +20 -5
  17. package/public/code-tools.js +143 -0
  18. package/public/editor.js +20 -4
  19. package/public/es.js +393 -25
  20. package/public/hardware.js +50 -0
  21. package/public/index.html +5 -1
  22. package/public/macroverso.js +187 -96
  23. package/public/market-art.js +161 -0
  24. package/public/market-view.js +529 -0
  25. package/public/market.js +189 -0
  26. package/public/neural.js +110 -0
  27. package/public/nostromo-engine.js +412 -47
  28. package/public/power.js +100 -0
  29. package/public/resay.js +5 -0
  30. package/public/salas-mac.css +98 -14
  31. package/public/salas-mac.html +9 -1
  32. package/public/salas-mac.js +307 -30
  33. package/public/styles.css +838 -61
  34. package/public/tour.js +48 -6
  35. package/public/troubleshooting.js +6 -1
  36. package/public/wallpaper.js +275 -0
  37. package/src/adapters/cursor.mjs +96 -0
  38. package/src/adapters/gemini.mjs +10 -3
  39. package/src/adapters/madre.mjs +2 -1
  40. package/src/auth-probe.mjs +143 -12
  41. package/src/bridge/policy.mjs +1 -1
  42. package/src/capabilities.mjs +9 -0
  43. package/src/checkpoint.mjs +16 -1
  44. package/src/credentials.mjs +3 -0
  45. package/src/dataset.mjs +76 -4
  46. package/src/enlaces.mjs +208 -2
  47. package/src/extensions.mjs +11 -3
  48. package/src/i18n.mjs +16 -0
  49. package/src/looks.mjs +125 -2
  50. package/src/market-key.mjs +8 -0
  51. package/src/market-remote.mjs +239 -0
  52. package/src/models.mjs +4 -0
  53. package/src/module-scope.mjs +68 -0
  54. package/src/modules/ahp.mjs +102 -7
  55. package/src/modules/bishop-exam.mjs +122 -0
  56. package/src/modules/bishop.mjs +71 -12
  57. package/src/modules/enlaces.mjs +87 -6
  58. package/src/modules/image-studio.mjs +10 -1
  59. package/src/modules/intents.mjs +3 -1
  60. package/src/modules/local-models.mjs +124 -0
  61. package/src/modules/ollama.mjs +12 -3
  62. package/src/modules/playwright.mjs +73 -15
  63. package/src/modules/sdk.mjs +61 -9
  64. package/src/npm-launch.mjs +18 -0
  65. package/src/ollama.mjs +6 -5
  66. package/src/outbound.mjs +9 -1
  67. package/src/ports.mjs +4 -2
  68. package/src/room/control.mjs +1 -1
  69. package/src/room/prompt.mjs +13 -2
  70. package/src/room.mjs +14 -4
  71. package/src/router.mjs +1 -1
  72. package/src/runs.mjs +4 -1
  73. package/src/runtime-detection.mjs +9 -0
  74. package/src/salas.mjs +26 -0
  75. package/src/server.mjs +617 -48
  76. package/src/setup.mjs +1 -1
package/src/server.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
3
3
  import http from 'node:http';
4
- import { readFile, realpath, writeFile, mkdir, cp, rename, access, stat } from 'node:fs/promises';
4
+ import { readFile, realpath, writeFile, mkdir, mkdtemp, cp, rename, access, rm, stat } from 'node:fs/promises';
5
5
  import { existsSync, openSync, rmSync } from 'node:fs';
6
6
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
7
7
  import { probePort, framable } from './ports.mjs';
@@ -9,7 +9,7 @@ import { ControlDesk } from './room/control.mjs';
9
9
  import { basename, dirname, join, resolve } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { gzipSync } from 'node:zlib';
12
- import { homedir, hostname } from 'node:os';
12
+ import { homedir, hostname, tmpdir } from 'node:os';
13
13
  import { execFile } from 'node:child_process';
14
14
  import { promisify } from 'node:util';
15
15
 
@@ -23,9 +23,10 @@ import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
23
23
  import { ErrorSentinel } from './sentinel-errors.mjs';
24
24
  import { probeOllama, ollamaEmbedder, ollamaGenerate, ollamaInvoker, pullModel, ollamaVersions, ownOllamaServers } from './ollama.mjs';
25
25
  import { recallForTurn } from './modules/enlaces.mjs';
26
+ import { hardwareOnce, MODEL_TIERS } from './modules/local-models.mjs';
26
27
  import { MODULES, moduleById, describeModules, findModuleRoute, toolsForTurn as modulesToolsForTurn, loadExternalModules, loadFailures, moduleFolders, moduleCommands, installModuleFile, installModuleText, verifyModuleText, moduleOrigin, removeExternalModule } from './modules/index.mjs';
27
28
  import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
28
- import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
29
+ import { exportDataset, forgetCorpus, keepCorpus, keptCorpus, pruneCorpora, readiness as datasetReadiness } from './dataset.mjs';
29
30
  import { OutboundLog, outboundView, DEFAULT_REPORT_URL } from './outbound.mjs';
30
31
  import { language as roomLanguage, setLanguage as setRoomLanguage, t } from './i18n.mjs';
31
32
 
@@ -66,7 +67,7 @@ import { Room } from './room.mjs';
66
67
  import { COPILOT_WINDOWS } from './room/copilot.mjs';
67
68
  import { OVERRIDE_GRACE, changeNeedsPhrase, keyHolds, mergeOverride, overrideSettings, phraseHash, publicOverride } from './override.mjs';
68
69
  import { extensionById, findOnPath, runInstaller } from './extensions.mjs';
69
- import { accountNoteFor, installPlanFor, looksLikeAdminProblem, loginPlanFor, looksLikeCodePrompt, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
70
+ import { accountNoteFor, deviceCodeIn, geminiSignIn, installPlanFor, looksLikeAdminProblem, loginPlanFor, looksLikeCodePrompt, looksLikeConsentPrompt, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
70
71
  import { applyKey, keyPlanFor, openCodeKeyProviders, readOpenCodeProviders, removeOpenCodeKey } from './credentials.mjs';
71
72
  import { forgetSecret, readSecret, summary as vaultSummary, writeSecret } from './vault.mjs';
72
73
  import { applyConfigToEnv, loadConfig as readConfig, updateConfig } from './config.mjs';
@@ -104,12 +105,19 @@ import { currentNetwork, lanAddresses } from './bridge/network.mjs';
104
105
  import { PHONE_MAX_MODE, writtenMode } from './bridge/policy.mjs';
105
106
  import { qrSvg } from './bridge/qr.mjs';
106
107
  import { readHandoff, writeHandoff } from './bridge/handoff.mjs';
107
- import { changeLooks, readLooks, changeRoomLook, roomLooks } from './looks.mjs';
108
+ import { changeHome, changeLooks, readHome, readLooks, changeRoomLook, roomLooks, WALLPAPER_BYTES, dropWallpaper, frameWallpaper, readWallpaper, saveWallpaper, wallpaperBytes } from './looks.mjs';
108
109
  import { localAiConfig, localAiFor, localAiPatch } from './local-ai.mjs';
110
+ import { moduleOnIn, scopeView, settingsIn } from './module-scope.mjs';
111
+ import { forgetFromMarket, installFromMarket, installedFromMarket, keptCatalog, marketAnnounced, marketAnnouncements, marketNews, marketNewsSeen, refreshCatalog, updateFromMarket } from './market-remote.mjs';
112
+ // Not moved project by project: the Coordinator is Home's, Git has no switch, and Local AI keeps
113
+ // its own list of projects (local-ai.mjs).
114
+ const NOT_BY_PROJECT = new Set(['bishop', 'git-pulse', 'ollama']);
109
115
  import { heartbeat } from './heartbeat.mjs';
110
- import { askRoom, associateProject, ensureSalasProject, forgetProject, isSalasRoom, knownRooms, listFolders, markProject, projectRoomId, roomComesUp, salasRoomId, startRoom, suggestProjects, universeOf } from './salas.mjs';
116
+ import { askRoom, associateProject, createProject, ensureSalasProject, forgetProject, isSalasRoom, knownRooms, listFolders, markProject, projectRoomId, roomComesUp, salasRoomId, startRoom, suggestProjects, universeOf } from './salas.mjs';
111
117
  import { parseWork, recentWork, WORK_SYSTEM, workPrompt } from './lobby-work.mjs';
112
- import { CHANNEL_HEADER, MAX_LIMIT as ENLACE_MAX_LIMIT, NO_SUCH_LINK, NOTICES as ENLACE_NOTICES, activeContracts, askEnlace, channelKey, countRecent, linkTraffic, mayServe, noteCrossing, noticeEnlace, readsFrom, recallContract, revokeContract, sameKey, servingRooms, signContract } from './enlaces.mjs';
118
+ import { CHANNEL_HEADER, CORPUS_MAX, CORPUS_PER_HOUR, DELIVER_MAX, MAX_LIMIT as ENLACE_MAX_LIMIT, NO_SUCH_LINK, NOTICES as ENLACE_NOTICES, activeContracts, allowContract, askCorpus, askEnlace, askState, channelKey, countRecent, decideDelivery, deliverable, deliverNotes, deliversTo, linkTraffic, mayServe, noteCrossing, noticeEnlace, readInbox, readsFrom, recallContract, receiveDelivery, revokeContract, sameKey, servingRooms, signContract, trainContract } from './enlaces.mjs';
119
+ import { readContinuity } from './modules/ahp.mjs';
120
+ import { readAt } from './checkpoint.mjs';
113
121
 
114
122
  const sourceDirectory = dirname(fileURLToPath(import.meta.url));
115
123
  const publicDirectory = join(sourceDirectory, '..', 'public');
@@ -176,7 +184,7 @@ export function openUrl(url) {
176
184
 
177
185
  // PULSE_AGENT_TIMEOUT_MS applies to every agent; PULSE_<AGENT>_TIMEOUT_MS
178
186
  // (e.g. PULSE_CLAUDE_TIMEOUT_MS) overrides it for one.
179
- export function agentTimeoutsFromEnv(env = process.env, agentIds = ['codex', 'claude', 'gemini', 'opencode']) {
187
+ export function agentTimeoutsFromEnv(env = process.env, agentIds = ['codex', 'claude', 'gemini', 'opencode', 'cursor']) {
180
188
  const timeouts = {};
181
189
  const shared = Number(env.PULSE_AGENT_TIMEOUT_MS);
182
190
  if (Number.isFinite(shared) && shared > 0) timeouts.default = shared;
@@ -234,6 +242,10 @@ export async function createPulseServer({
234
242
  imageKey = resolveGeminiKey,
235
243
  // The sentinel's outbound channel; tests hand in a fake.
236
244
  reportFetch = globalThis.fetch,
245
+ // The Market's catalog and module files from madre.run (src/market-remote.mjs); tests hand in a
246
+ // fake, and the key their catalog is signed with.
247
+ marketFetch = globalThis.fetch,
248
+ marketKey = null,
237
249
  // Ollama detection; tests hand in a fake probe. PULSE_OLLAMA=0 leaves Ollama alone entirely.
238
250
  ollamaProbe = process.env.PULSE_OLLAMA === '0' ? async () => ({ running: false, host: null, models: [], embedModel: null, chatModel: null, disabled: true }) : probeOllama,
239
251
  }) {
@@ -250,7 +262,7 @@ export async function createPulseServer({
250
262
  // opens it (public/i18n.js, browserLanguage), once, and keeps it as if it had been chosen.
251
263
  let languageChosen = Boolean(process.env.PULSE_LANGUAGE || (await readConfig(root)).language);
252
264
  // RIPLEY renders HTML in the viewer only while the human has it switched on; read live, the switch is the module's.
253
- const ripleyOn = async () => Boolean((await readConfig(root)).modules?.ripley?.enabled);
265
+ const ripleyOn = async () => moduleOnHere(await readConfig(root), 'ripley');
254
266
  agentTimeouts ??= agentTimeoutsFromEnv();
255
267
  const detector = detect ?? (providedAgents ? null : detectAgents);
256
268
  const agents = providedAgents ?? await detector();
@@ -267,6 +279,7 @@ export async function createPulseServer({
267
279
  const unwatchOutbound = outbound.install();
268
280
  process.env.PULSE_OUTBOUND_LOG = join(roomDir, 'outbound.jsonl');
269
281
  reportFetch = outbound.watch(reportFetch);
282
+ marketFetch = outbound.watch(marketFetch);
270
283
 
271
284
  // Attachments live beside the room log, never inside the project, and belong to the project
272
285
  // rather than to one conversation.
@@ -358,6 +371,23 @@ export async function createPulseServer({
358
371
  // default; PULSE_UPDATE_CHECK=0 or the switch in MU/TH/UR turns it off. Never self-updates.
359
372
  const updatesEnabled = () => (process.env.PULSE_UPDATE_CHECK !== undefined ? process.env.PULSE_UPDATE_CHECK !== '0' : updatesConfig.check !== false);
360
373
  let updatesConfig = { ...((await readConfig(root)).updates ?? {}) };
374
+ // THE MARKET from madre.run (src/market-remote.mjs): read when MADRE opens, while Look for new
375
+ // versions is on (PULSE_MARKET=0 says no to it alone). A module got from it updates itself and
376
+ // says so once, the next time a page asks (market/updates.json).
377
+ const marketOn = () => process.env.PULSE_MARKET !== '0' && updatesEnabled();
378
+ const marketKeyed = marketKey ? { publicKey: marketKey } : {};
379
+ const marketTools = () => ({ stateRoot: root, projectRoot: canonicalProjectRoot, madre: PACKAGE.version, fetchImpl: marketFetch, verifyText: verifyModuleText, installText: installModuleText });
380
+ let marketSync = null;
381
+ const syncMarket = ({ force = false } = {}) => {
382
+ if (!marketOn()) return Promise.resolve({ catalog: null, off: true });
383
+ marketSync ??= (async () => {
384
+ const read = await refreshCatalog({ stateRoot: root, fetchImpl: marketFetch, force, ...marketKeyed });
385
+ if (read.catalog) await updateFromMarket({ ...marketTools(), catalog: read.catalog, names: Object.fromEntries(MODULES.map((one) => [one.id, one.name])) }).catch(() => []);
386
+ return read;
387
+ })().finally(() => { marketSync = null; });
388
+ return marketSync;
389
+ };
390
+ void syncMarket().catch(() => null);
361
391
  const install = detectInstall({ projectRoot: canonicalProjectRoot });
362
392
  async function versionView({ force = false } = {}) {
363
393
  const check = await checkForUpdate({ name: PACKAGE.name, current: PACKAGE.version, cacheFile: join(root, 'updates.json'), fetchImpl: reportFetch, enabled: updatesEnabled(), force });
@@ -558,6 +588,10 @@ export async function createPulseServer({
558
588
  const ollamaSettings = async () => ({ enabled: true, embeddings: true, archivist: true, agent: true, ...((await readConfig(root)).modules?.ollama ?? {}) });
559
589
  // Whether this project uses the local AI at all, as Home decides it (src/local-ai.mjs).
560
590
  const thisRoomId = projectRoomId(canonicalProjectRoot);
591
+ // This project's word on a module's switch (module-scope.mjs). Home's room is not a project: it
592
+ // reads, and writes, the switch for every project.
593
+ const scopeRoom = salasRoom ? null : thisRoomId;
594
+ const moduleOnHere = (config, key, fallback = false) => moduleOnIn(config?.modules?.[key] ?? {}, scopeRoom, fallback);
561
595
  const localAiHere = async () => localAiFor(await readConfig(root), thisRoomId, { home: salasRoom });
562
596
  // This project's own switch, outside «auto»; null when Home decides.
563
597
  async function setLocalAiHere(on) {
@@ -566,10 +600,110 @@ export async function createPulseServer({
566
600
  return wireOllama({ probe: false });
567
601
  }
568
602
  // What the TRAIN card needs: where the recipe ships, the room, the model name @madre will pick up, a base that fits this machine.
569
- function trainingInfo() {
603
+ // Every event of every conversation of this project, in order: the training export and its
604
+ // readiness read the whole project, not only the conversation on screen.
605
+ async function projectEvents() {
606
+ const { chats: list } = await listChats(roomDir).catch(() => ({ chats: [] }));
607
+ const ids = list.length ? list.map((chat) => chat.id) : ['main'];
608
+ const all = [];
609
+ for (const id of ids) {
610
+ const raw = await readFile(chatLedger(roomDir, id), 'utf8').catch(() => '');
611
+ for (const line of raw.split('\n')) {
612
+ if (!line) continue;
613
+ try { all.push(JSON.parse(line)); } catch { /* a torn line is skipped */ }
614
+ }
615
+ }
616
+ return all.sort((x, y) => (x.sequence ?? 0) - (y.sequence ?? 0));
617
+ }
618
+ // The code workspace (1.0.3): an agent's last EDIT turn on a file, read from every conversation
619
+ // of the project (only the lines about control turns are parsed), and the parts the human kept.
620
+ async function lastAgentChange(path) {
621
+ const { chats: list } = await listChats(roomDir).catch(() => ({ chats: [] }));
622
+ const ids = list.length ? list.map((chat) => chat.id) : ['main'];
623
+ let found = null;
624
+ const undone = new Set();
625
+ for (const id of ids) {
626
+ const raw = await readFile(chatLedger(roomDir, id), 'utf8').catch(() => '');
627
+ for (const line of raw.split('\n')) {
628
+ if (!line.includes('"control.')) continue;
629
+ let event;
630
+ try { event = JSON.parse(line); } catch { continue; }
631
+ if (event.type === 'control.reverted') { undone.add(event.payload?.checkpointId); continue; }
632
+ if (event.type !== 'control.changed') continue;
633
+ const file = (event.payload?.files ?? []).find((one) => one.path === path);
634
+ if (!file || file.status === 'D' || !event.payload.agent || event.payload.agent === 'you') continue;
635
+ const at = Date.parse(event.timestamp) || 0;
636
+ if (!found || at >= found.at) found = { at, agent: event.payload.agent, checkpointId: event.payload.checkpointId, afterTree: event.payload.afterTree ?? null, status: file.status, messageId: event.payload.responseMessageId ?? event.payload.messageId ?? null };
637
+ }
638
+ }
639
+ return found && !undone.has(found.checkpointId) ? { ...found, at: new Date(found.at).toISOString() } : null;
640
+ }
641
+ const reviewedFile = join(roomDir, 'code-reviewed.json');
642
+ let reviewedWrites = Promise.resolve();
643
+ async function readReviewed() {
644
+ try { const raw = JSON.parse(await readFile(reviewedFile, 'utf8')); return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; } catch { return {}; }
645
+ }
646
+ function keepReviewed(key, hunk) {
647
+ const run = reviewedWrites.then(async () => {
648
+ const all = await readReviewed();
649
+ const list = [...new Set([...(Array.isArray(all[key]) ? all[key] : []), hunk])].slice(-200);
650
+ delete all[key];
651
+ all[key] = list;
652
+ // The newest 300 files are enough: an older change was long since kept or taken back.
653
+ const keys = Object.keys(all);
654
+ for (const old of keys.slice(0, Math.max(0, keys.length - 300))) delete all[old];
655
+ await writeFile(reviewedFile, `${JSON.stringify(all)}\n`);
656
+ return list;
657
+ });
658
+ reviewedWrites = run.catch(() => null);
659
+ return run;
660
+ }
661
+ // D-S15 · what every project that lets it has to teach the Coordinator, asked now. A project that
662
+ // is asleep or does not answer is not lost: the copy Salas kept the last time it answered is used
663
+ // and the manifest says so; one that never answered is listed as missing. Copies of projects that
664
+ // no longer allow it are forgotten first.
665
+ async function gatherLinked(datasetDir) {
666
+ const salas = salasRoomId(root);
667
+ const contracts = activeContracts(await readConfig(root), { salas });
668
+ const sources = readsFrom(contracts, salas).filter(({ contract }) => contract.train);
669
+ await pruneCorpora(datasetDir, new Set(sources.map(({ from }) => from)));
670
+ if (!sources.length) return [];
671
+ const rooms = await knownRooms(root);
672
+ const key = await channelKey(root).catch(() => null);
673
+ const out = [];
674
+ for (const { contract, from } of sources) {
675
+ const where = rooms.find((one) => one.id === from);
676
+ const answer = where?.open && key
677
+ ? await askCorpus({ port: where.open.port, contract, asking: salas, key, record: (type, event) => room.record(type, event), roomDir })
678
+ : { unreachable: true };
679
+ if (answer.notes) {
680
+ const at = new Date().toISOString();
681
+ // D-S16: with the state shared too, a dated photo of it goes with the notes.
682
+ const shared = contract.state ? await askState({ port: where.open.port, contract, asking: salas, key }) : null;
683
+ const corpus = { ...answer, ...(shared?.state ? { state: shared.state } : {}) };
684
+ await keepCorpus(datasetDir, corpus);
685
+ out.push({ ...corpus, source: 'live', at });
686
+ continue;
687
+ }
688
+ const kept = await keptCorpus(datasetDir, from);
689
+ out.push(kept && kept.contract === contract.id ? { ...kept, source: 'kept' } : { contract: contract.id, from, name: where?.name ?? from, notes: [], source: 'missing' });
690
+ }
691
+ return out;
692
+ }
693
+ // A link that stops allowing it (revoked, or the box cleared): the copy Salas kept goes now.
694
+ async function forgetLinkedCorpus(contract) {
695
+ const salas = salasRoomId(root);
696
+ if (contract.rooms.a !== salas && contract.rooms.b !== salas) return;
697
+ const other = contract.rooms.a === salas ? contract.rooms.b : contract.rooms.a;
698
+ await forgetCorpus(join(root, 'rooms', salas, 'dataset'), other).catch(() => null);
699
+ }
700
+ // The base to train on comes from this computer's hardware (src/modules/local-models.mjs), not
701
+ // from one rule for every machine: the page lists every size and what this one can do with it.
702
+ async function trainingInfo() {
570
703
  const slug = basename(canonicalProjectRoot).toLowerCase().replace(/[^a-z0-9]+/g, '-');
571
- const gb = Math.round(totalmem() / 1024 ** 3);
572
- return { roomDir, recipeDir: fileURLToPath(new URL('../docs/training/', import.meta.url)), modelName: `madre-${slug}`, baseModel: gb >= 32 ? 'Qwen/Qwen2.5-3B-Instruct' : 'Qwen/Qwen2.5-1.5B-Instruct', memoryGb: gb };
704
+ const fit = await hardwareOnce();
705
+ const base = fit.recommended.train ?? MODEL_TIERS[1];
706
+ return { roomDir, recipeDir: fileURLToPath(new URL('../docs/training/', import.meta.url)), modelName: `madre-${slug}`, baseModel: base.base, memoryGb: fit.hardware?.memoryGb ?? Math.round(totalmem() / 1024 ** 3), fit };
573
707
  }
574
708
  async function wireOllama({ probe: doProbe = true } = {}) {
575
709
  if (doProbe) ollama = await ollamaProbe();
@@ -845,8 +979,9 @@ export async function createPulseServer({
845
979
  let loggingIn = null;
846
980
  // A sign-in in flight, as the room is allowed to see it: which door, the address to open, and
847
981
  // whether the CLI is waiting to be told the code that page gave back. Never the code itself.
982
+ // A device sign-in's code is the opposite case: it is shown to be typed on the provider's page.
848
983
  let loginRun = null;
849
- const loginState = () => (loginRun ? { agent: loginRun.agent, method: loginRun.method, command: loginRun.command, url: loginRun.url, asksForCode: loginRun.asksForCode, awaitingCode: loginRun.awaitingCode, startedAt: loginRun.startedAt } : null);
984
+ const loginState = () => (loginRun ? { agent: loginRun.agent, method: loginRun.method, command: loginRun.command, url: loginRun.url, deviceCode: loginRun.deviceCode, asksForCode: loginRun.asksForCode, awaitingCode: loginRun.awaitingCode, startedAt: loginRun.startedAt } : null);
850
985
 
851
986
  async function loginAgent(id, { method = null } = {}) {
852
987
  const agent = agents.find((item) => item.id === id);
@@ -856,36 +991,91 @@ export async function createPulseServer({
856
991
  if (!plan) return { status: 404, body: { error: `No sign-in flow known for ${id}.` } };
857
992
  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 } };
858
993
  if (loggingIn) return { status: 409, body: { error: `A sign-in is already running (${loggingIn}).` } };
994
+ // A CLI with no sign-in command of its own (Gemini) signs in on its first question: its settings
995
+ // are set before, and the run is stopped as soon as the credentials are written. It runs in an
996
+ // empty folder, so that question never sees the project.
997
+ const flow = typeof plan.flow === 'object' ? plan.flow : plan.flow === 'gemini-google' ? geminiSignIn() : null;
998
+ let cwd = canonicalProjectRoot;
999
+ if (flow) {
1000
+ try {
1001
+ await flow.prepare();
1002
+ cwd = await mkdtemp(join(tmpdir(), 'madre-signin-'));
1003
+ } catch (error) {
1004
+ await flow.undo().catch(() => {});
1005
+ return { status: 500, body: { error: `The sign-in could not be prepared: ${error.message}` } };
1006
+ }
1007
+ }
1008
+ const env = { ...process.env, ...(plan.env ?? {}) };
1009
+ for (const name of [...(plan.dropEnv ?? []), ...(plan.ci === false ? ['CI'] : [])]) delete env[name];
859
1010
  loggingIn = id;
860
- loginRun = { agent: id, method: plan.method ?? null, command: plan.display, url: null, asksForCode: Boolean(plan.asksForCode), awaitingCode: false, startedAt: Date.now(), child: null };
1011
+ loginRun = { agent: id, method: plan.method ?? null, command: plan.display, url: null, deviceCode: null, asksForCode: Boolean(plan.asksForCode), awaitingCode: false, startedAt: Date.now(), child: null };
861
1012
  await room.record('connection.login.started', { agent: id, command: plan.display, note: plan.note, method: plan.method ?? null });
862
1013
  void (async () => {
863
1014
  const lines = [];
1015
+ let consented = false;
1016
+ const answered = new Set();
1017
+ let finished = false;
1018
+ let refused = false;
1019
+ // Signed in is not yet working: Google lets a free personal account sign in to Gemini CLI and
1020
+ // refuses it at the first question (2026-10-17). So the run is let ask its one-word question,
1021
+ // in an empty folder, and the sign-in counts only if that answer is not a refusal. A run that
1022
+ // goes quiet is stopped after half a minute, and counts.
1023
+ const watch = flow ? setInterval(async () => {
1024
+ if (finished || !(await flow.signedIn())) return;
1025
+ finished = true;
1026
+ setTimeout(() => { if (loginRun?.agent === id) loginRun.child?.cancel?.(); }, flow.verifyMs ?? 30000).unref?.();
1027
+ }, 700) : null;
864
1028
  const result = await runInstaller({
865
1029
  command: plan.command,
866
1030
  args: plan.args,
867
- projectRoot: canonicalProjectRoot,
1031
+ projectRoot: cwd,
1032
+ env,
1033
+ ci: plan.ci !== false,
868
1034
  // A person is in the middle of this: the browser, the account, the code. Five minutes was
869
1035
  // the installer's clock, and it killed sign-ins that were only waiting on a human.
870
1036
  timeoutMs: 900000,
871
1037
  // A CLI that asks for a code needs a way to be answered, and its question never ends in a
872
1038
  // newline, so the half-line is flushed rather than left sitting in the buffer.
873
- stdin: plan.asksForCode ? 'pipe' : 'ignore',
874
- flushIdleMs: plan.asksForCode ? 600 : 0,
1039
+ stdin: plan.asksForCode || plan.consent || plan.answers?.length ? 'pipe' : 'ignore',
1040
+ flushIdleMs: plan.asksForCode || plan.consent ? 600 : 0,
875
1041
  quietNote: 'the sign-in is waiting on you: finish it in the page that opened',
876
1042
  onChild: (handle) => { if (loginRun?.agent === id) loginRun.child = handle; },
877
- onLine: (line) => {
1043
+ onLine: (raw) => {
1044
+ // A terminal UI (OpenCode's) hides and moves its cursor even with NO_COLOR: the room
1045
+ // shows what it says, not the escapes it draws with.
1046
+ const line = raw.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '');
1047
+ if (!line.trim()) return;
878
1048
  lines.push(line);
1049
+ if (flow?.refused?.(line) && !refused) { refused = true; loginRun?.child?.cancel?.(); }
879
1050
  const url = line.match(/https?:\/\/\S+/)?.[0] ?? null;
1051
+ const deviceCode = plan.deviceCode ? deviceCodeIn(line) : null;
880
1052
  if (loginRun?.agent === id) {
881
1053
  if (url) loginRun.url = url;
1054
+ if (deviceCode) loginRun.deviceCode = deviceCode;
1055
+ // A menu the CLI draws before its sign-in (OpenCode asks which GitHub): the button
1056
+ // pressed already chose, so the room answers it once, the way the plan says.
1057
+ for (const answer of plan.answers ?? []) if (!answered.has(answer) && line.includes(answer.prompt)) { answered.add(answer); loginRun.child?.write?.(answer.send); }
882
1058
  if (plan.asksForCode && looksLikeCodePrompt(line)) loginRun.awaitingCode = true;
1059
+ // The person already said yes: that was the button they pressed.
1060
+ if (plan.consent && !consented && looksLikeConsentPrompt(line)) { consented = true; loginRun.child?.write?.('y\n'); }
883
1061
  }
884
- void room.record('connection.login.output', { agent: id, line: line.slice(0, 500), url });
1062
+ void room.record('connection.login.output', { agent: id, line: line.slice(0, 500), url, ...(deviceCode ? { deviceCode } : {}) });
885
1063
  },
886
1064
  });
1065
+ if (watch) clearInterval(watch);
1066
+ const good = finished && !refused;
1067
+ if (flow) {
1068
+ // Not finished, or refused at its first question: everything goes back as it was.
1069
+ if (!good) await flow.undo().catch(() => {});
1070
+ await rm(cwd, { recursive: true, force: true }).catch(() => {});
1071
+ }
887
1072
  await refreshSessions();
888
- await room.record('connection.login.finished', { agent: id, code: result.code, error: result.error ?? null, session: sessions[id] ?? null, tail: lines.slice(-6) });
1073
+ // A run with no flow (Codex, Claude, OpenCode) ends on its own, and its exit says how it went.
1074
+ const code = flow ? (good ? 0 : (result.code || 1)) : result.code;
1075
+ const error = refused
1076
+ ? t('Google signed you in, but it refuses this account on Gemini CLI (free personal accounts are refused). Gemini was left as it was: paste a Gemini API key from Google AI Studio instead.')
1077
+ : finished ? null : result.error ?? null;
1078
+ await room.record('connection.login.finished', { agent: id, code, error, session: sessions[id] ?? null, tail: lines.slice(-6) });
889
1079
  loggingIn = null;
890
1080
  loginRun = null;
891
1081
  })();
@@ -1000,10 +1190,7 @@ export async function createPulseServer({
1000
1190
 
1001
1191
  const startupConfig = await readConfig(root);
1002
1192
  room.setScopes(startupConfig.scopes ?? {});
1003
- setImageModule(startupConfig.modules?.imageStudio ?? {});
1004
- room.setAsh(Boolean(startupConfig.modules?.ash?.enabled));
1005
- room.setChoices(Boolean(startupConfig.modules?.choices?.enabled));
1006
- room.setRuns(Boolean(startupConfig.modules?.runs?.enabled));
1193
+ applyModuleSwitches(room, startupConfig);
1007
1194
  const recoveredTurns = await room.reconcile();
1008
1195
  if (recoveredTurns) console.error(`MADRE recovered ${recoveredTurns} unfinished turn(s) from a previous run.`);
1009
1196
  const quotaMonitor = new QuotaMonitor({
@@ -1356,11 +1543,17 @@ export async function createPulseServer({
1356
1543
  async function applyRoomSettings(next, previous) {
1357
1544
  const config = await readConfig(root);
1358
1545
  next.setScopes(config.scopes ?? {});
1359
- next.setAsh(Boolean(config.modules?.ash?.enabled));
1360
- next.setChoices(Boolean(config.modules?.choices?.enabled));
1361
- next.setRuns(Boolean(config.modules?.runs?.enabled));
1546
+ applyModuleSwitches(next, config);
1362
1547
  if (previous) next.configure(previous.settings());
1363
1548
  }
1549
+ // The switches a room holds itself, as this project says them (module-scope.mjs). Read again
1550
+ // before every message, so a switch moved in Home reaches a project that is already open.
1551
+ function applyModuleSwitches(target, config) {
1552
+ setImageModule(settingsIn(config.modules?.imageStudio ?? {}, scopeRoom));
1553
+ target.setAsh(moduleOnHere(config, 'ash'));
1554
+ target.setChoices(moduleOnHere(config, 'choices'));
1555
+ target.setRuns(moduleOnHere(config, 'runs'));
1556
+ }
1364
1557
 
1365
1558
  // Opening another conversation. The project keeps its memory, its crew, its modules, its
1366
1559
  // privacy list and its numbering; what changes is the record being shown. With TURNS on, a
@@ -1431,6 +1624,8 @@ export async function createPulseServer({
1431
1624
  const config = await readConfig(root);
1432
1625
  return {
1433
1626
  projectRoot: canonicalProjectRoot, stateRoot: root, config, env: process.env, agents, room,
1627
+ // Which project's word a module's switch reads and writes; none in Home (module-scope.mjs).
1628
+ roomId: scopeRoom,
1434
1629
  readConfig: () => readConfig(root),
1435
1630
  updateConfig: (patch) => updateConfig(root, patch),
1436
1631
  record: (type, payload) => room.record(type, payload),
@@ -1610,6 +1805,9 @@ export async function createPulseServer({
1610
1805
  }
1611
1806
  const before = await extension.detect(canonicalProjectRoot);
1612
1807
  const plan = installers[id]?.({ projectRoot: canonicalProjectRoot, agents }) ?? extension.installCommand({ agents });
1808
+ // What the module asked MADRE to do first (Continuity: `git init` in a project with no git),
1809
+ // shown in the same confirmation and run before the install, each step stopping the next.
1810
+ const prepare = Array.isArray(preflight.prepare) ? preflight.prepare : [];
1613
1811
  installing = id;
1614
1812
  await room.record('extension.install.started', { id, name: extension.name, command: plan.display, platforms: plan.platforms ?? [], alreadyInstalled: before.installed });
1615
1813
  void (async () => {
@@ -1623,17 +1821,19 @@ export async function createPulseServer({
1623
1821
  pending = [];
1624
1822
  await room.record('extension.install.output', { id, lines: batch });
1625
1823
  };
1626
- const result = await runInstaller({
1627
- command: plan.command,
1628
- args: plan.args,
1629
- projectRoot: canonicalProjectRoot,
1630
- onLine: (line) => {
1631
- lines.push(line);
1632
- pending.push(line.slice(0, 500));
1633
- if (pending.length >= 20) void flush();
1634
- else if (!flushTimer) flushTimer = setTimeout(() => { void flush(); }, 400);
1635
- },
1636
- });
1824
+ const onLine = (line) => {
1825
+ lines.push(line);
1826
+ pending.push(line.slice(0, 500));
1827
+ if (pending.length >= 20) void flush();
1828
+ else if (!flushTimer) flushTimer = setTimeout(() => { void flush(); }, 400);
1829
+ };
1830
+ let result = { code: 0 };
1831
+ for (const step of prepare) {
1832
+ onLine(`$ ${step.display}`);
1833
+ result = await runInstaller({ command: step.command, args: step.args, projectRoot: canonicalProjectRoot, onLine });
1834
+ if (result.code !== 0) break;
1835
+ }
1836
+ if (result.code === 0) result = await runInstaller({ command: plan.command, args: plan.args, projectRoot: canonicalProjectRoot, onLine });
1637
1837
  clearTimeout(flushTimer);
1638
1838
  await flush();
1639
1839
  const after = await extension.detect(canonicalProjectRoot);
@@ -1669,7 +1869,7 @@ export async function createPulseServer({
1669
1869
  response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...PAGE_GUARDS });
1670
1870
  return response.end(html);
1671
1871
  }
1672
- if (request.method === 'GET' && ['/app.js', '/nostromo-engine.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js', '/editor.js', '/routing.js', '/abduction.js', '/review-ask.js', '/loader.js', '/tour.js', '/tour-chapters.js', '/touch.js', '/attribution.js'].includes(url.pathname)) {
1872
+ if (request.method === 'GET' && ['/app.js', '/nostromo-engine.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js', '/editor.js', '/routing.js', '/abduction.js', '/review-ask.js', '/loader.js', '/tour.js', '/tour-chapters.js', '/touch.js', '/attribution.js', '/power.js', '/wallpaper.js', '/neural.js', '/hardware.js', '/code-tools.js', '/market.js', '/market-view.js', '/market-art.js'].includes(url.pathname)) {
1673
1873
  return sendAsset(request, response, join(publicDirectory, url.pathname.slice(1)), 'text/javascript; charset=utf-8');
1674
1874
  }
1675
1875
  if (request.method === 'GET' && url.pathname === '/styles.css') {
@@ -1950,7 +2150,7 @@ export async function createPulseServer({
1950
2150
  // folders (Sec-Fetch-Site) or to add one (Origin), and so is any host name but this Mac's
1951
2151
  // own, so a name pointed at it from outside reaches nothing. Folders are listed inside the
1952
2152
  // home folder only; what is listed is names, never what a file says.
1953
- if (url.pathname === '/api/salas/folders' || url.pathname === '/api/salas/suggestions' || url.pathname === '/api/salas/projects' || url.pathname === '/api/salas/work' || url.pathname === '/api/salas/decisions') {
2153
+ if (url.pathname === '/api/salas/folders' || url.pathname === '/api/salas/suggestions' || url.pathname === '/api/salas/projects' || url.pathname === '/api/salas/new' || url.pathname === '/api/salas/work' || url.pathname === '/api/salas/decisions') {
1954
2154
  const host = String(request.headers.host ?? '');
1955
2155
  const localHost = /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host);
1956
2156
  const site = request.headers['sec-fetch-site'];
@@ -2026,6 +2226,21 @@ export async function createPulseServer({
2026
2226
  if (!lobbyDecisions.list) await lobbyDecisions.running;
2027
2227
  return sendJson(response, 200, { decisions: lobbyDecisions.list ?? [], at: lobbyDecisions.at || null });
2028
2228
  }
2229
+ // A project from nothing: a new folder, named by the person, in the folder they were in.
2230
+ if (request.method === 'POST' && url.pathname === '/api/salas/new') {
2231
+ const payload = await body(request).catch(() => ({}));
2232
+ const made = await createProject({ name: typeof payload.name === 'string' ? payload.name : '', parent: typeof payload.parent === 'string' ? payload.parent : null, home, stateRoot: root });
2233
+ if (!made.error) return sendJson(response, 200, { added: [made] });
2234
+ const said = {
2235
+ empty: t('Write a name for the project.'),
2236
+ long: t('That name is too long.'),
2237
+ name: t('A folder cannot be called that: leave out slashes and : * ? " < > |, and do not start with a dot or end with a dot or a space.'),
2238
+ exists: t('There is already a folder with that name there.'),
2239
+ outside: t('Only folders inside your home folder.'),
2240
+ madre: t('That folder is MADRE\'s own.'),
2241
+ }[made.error] ?? t('That folder is not there.');
2242
+ return sendJson(response, made.error === 'exists' ? 409 : 422, { error: said, code: made.error });
2243
+ }
2029
2244
  if (request.method === 'POST' && url.pathname === '/api/salas/projects') {
2030
2245
  const payload = await body(request).catch(() => ({}));
2031
2246
  const roots = Array.isArray(payload.roots) ? payload.roots.filter((one) => typeof one === 'string').slice(0, 50) : [];
@@ -2060,6 +2275,27 @@ export async function createPulseServer({
2060
2275
  const stopped = await Promise.all(rooms.map((one) => askRoom(one.open.port, '/api/stop-all', { method: 'POST', body: {}, timeoutMs: 3000 }).then((answer) => !answer.unreachable && !answer.outdated)));
2061
2276
  return sendJson(response, 200, { rooms: rooms.length, stopped: stopped.filter(Boolean).length });
2062
2277
  }
2278
+ // Turning MADRE off (2026-10-16), from a page of this Mac only, after the person confirmed it.
2279
+ // Every other open project is asked to close the way Home closes one — its agents stop, their
2280
+ // turns are recorded, its memory and its marker are put away — and waited for; then this room
2281
+ // closes the same way. Ollama is its own program and keeps running. Nothing else is touched.
2282
+ if (request.method === 'POST' && url.pathname === '/api/madre/shutdown') {
2283
+ const host = String(request.headers.host ?? '');
2284
+ const site = request.headers['sec-fetch-site'];
2285
+ if (viaPhone || !/^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host) || (site !== undefined && site !== 'same-origin' && site !== 'none') || request.headers.origin !== `http://${host}`) return sendJson(response, 403, { error: t('MADRE is turned off from its own page, on this computer.') });
2286
+ const here = basename(roomDir);
2287
+ const others = (await knownRooms(root)).filter((one) => one.open && one.id !== here);
2288
+ await Promise.all(others.map((one) => askRoom(one.open.port, '/api/room/close', { method: 'POST', body: {}, timeoutMs: 3000 }).catch(() => null)));
2289
+ let left = others;
2290
+ for (let tries = 0; tries < 60 && left.length; tries += 1) {
2291
+ await new Promise((resolve) => setTimeout(resolve, 250));
2292
+ const now = await knownRooms(root);
2293
+ left = left.filter((one) => now.find((room) => room.id === one.id)?.open);
2294
+ }
2295
+ sendJson(response, 202, { closing: true, closed: others.length - left.length, stuck: left.map((one) => one.name) });
2296
+ setTimeout(() => server.emit('madre:close'), 150);
2297
+ return undefined;
2298
+ }
2063
2299
  // This room, asked to close by Salas: what is running stops and the room closes the way a
2064
2300
  // signal closes it (startPulse listens). Nothing here ends the process itself.
2065
2301
  if (request.method === 'POST' && url.pathname === '/api/room/close') {
@@ -2111,6 +2347,89 @@ export async function createPulseServer({
2111
2347
  await noteCrossing(roomDir, { kind: 'read', contract: contract.id, other: asking, count: notes.length, agent: reader });
2112
2348
  return sendJson(response, 200, { contract: contract.id, from: here, notes });
2113
2349
  }
2350
+ // D-S15 · the whole of what this project lets the Coordinator learn, for its training export.
2351
+ // The same doors as a reading, a contract that says `train`, and a ceiling of its own: every
2352
+ // note that travels (`travels()`), of the kinds signed, through this room's privacy guard.
2353
+ // Not a search and not the latest: all of it, up to CORPUS_MAX, so nothing the project knows
2354
+ // is left out of what the Coordinator learns about it. Nothing of the ledger crosses.
2355
+ if (request.method === 'GET' && url.pathname === '/api/enlace/corpus') {
2356
+ if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
2357
+ if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot read across a connection; only a project can.') });
2358
+ const key = await channelKey(root).catch(() => null);
2359
+ if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
2360
+ const here = basename(roomDir);
2361
+ const asking = String(url.searchParams.get('asking') ?? '');
2362
+ const contract = activeContracts(await readConfig(root), { salas: salasRoomId(root) }).find((one) => one.id === url.searchParams.get('contract')) ?? null;
2363
+ if (!contract?.train || !mayServe({ contract, serving: here, asking }).ok) return sendJson(response, 404, { error: NO_SUCH_LINK });
2364
+ const sealed = room.motherStatus()?.lockedForMs ?? 0;
2365
+ 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 });
2366
+ if (!memory) return sendJson(response, 503, { error: t('The project has no memory.') });
2367
+ const taken = await countRecent(roomDir, contract.id, 'corpus');
2368
+ if (taken >= CORPUS_PER_HOUR) return sendJson(response, 429, { error: t('The Coordinator took what this project knows {n} times in the last hour, its limit. Try again later.', { n: taken }) });
2369
+ await refreshPrivacy();
2370
+ const notes = memory.memories({ limit: CORPUS_MAX * 2 })
2371
+ .filter((row) => memoryTravels(row) && contract.kinds.includes(row.kind))
2372
+ .slice(0, CORPUS_MAX)
2373
+ .map((row) => ({ id: row.id, kind: row.kind, zone: row.zone, text: privacy.redact(row.text, { always: true }).text, fromSequence: row.fromSequence, throughSequence: row.throughSequence, created: row.created }));
2374
+ await room.record('enlace.corpus', { contract: contract.id, to: asking, count: notes.length, kinds: contract.kinds });
2375
+ await noteCrossing(roomDir, { kind: 'corpus', contract: contract.id, other: asking, count: notes.length });
2376
+ return sendJson(response, 200, { contract: contract.id, from: here, name: basename(canonicalProjectRoot), notes });
2377
+ }
2378
+ // D-S16 · this project's canonical state (Continuity, AHP+), for the room that reads under a
2379
+ // link that says `state`. The same doors as a reading and the same ceiling, counted apart: the
2380
+ // state is read from .ahp/ now, through this room's privacy guard, and nothing else of the
2381
+ // project (no git, no files, no evidence) crosses. Without .ahp/ the answer says so: `null`.
2382
+ if (request.method === 'GET' && url.pathname === '/api/enlace/state') {
2383
+ if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
2384
+ if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot read across a connection; only a project can.') });
2385
+ const key = await channelKey(root).catch(() => null);
2386
+ if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
2387
+ const here = basename(roomDir);
2388
+ const asking = String(url.searchParams.get('asking') ?? '');
2389
+ const contract = activeContracts(await readConfig(root), { salas: salasRoomId(root) }).find((one) => one.id === url.searchParams.get('contract')) ?? null;
2390
+ if (!contract?.state || !mayServe({ contract, serving: here, asking }).ok) return sendJson(response, 404, { error: NO_SUCH_LINK });
2391
+ const sealed = room.motherStatus()?.lockedForMs ?? 0;
2392
+ 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 });
2393
+ const reads = await countRecent(roomDir, contract.id, 'state');
2394
+ if (reads >= contract.orders.perHour) return sendJson(response, 429, { error: t('The state of this project was read {n} times in the last hour, its limit. Try again later.', { n: reads }) });
2395
+ await refreshPrivacy();
2396
+ const state = await readContinuity(canonicalProjectRoot, { redact: (text) => privacy.redact(text, { always: true }).text });
2397
+ const reader = /^[a-z][a-z0-9_-]{0,30}$/.test(String(url.searchParams.get('agent') ?? '')) ? url.searchParams.get('agent') : null;
2398
+ await room.record('enlace.state', { contract: contract.id, to: asking, source: state?.source ?? null, ...(reader ? { agent: reader } : {}) });
2399
+ await noteCrossing(roomDir, { kind: 'state', contract: contract.id, other: asking, count: state ? 1 : 0, agent: reader });
2400
+ return sendJson(response, 200, { contract: contract.id, from: here, name: basename(canonicalProjectRoot), state });
2401
+ }
2402
+ // D-S17 · notes another project hands to this one, under a link that says `deliver` and lets
2403
+ // that project read from this one. The same doors; the contract's ceiling, counted apart. They
2404
+ // wait in this room's inbox (enlaces-inbox.json): nothing enters the memory until this project
2405
+ // accepts it, by your press or by an agent of this project in #3 or above.
2406
+ if (request.method === 'POST' && url.pathname === '/api/enlace/deliver') {
2407
+ if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
2408
+ if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot deliver across a connection; only a project can.') });
2409
+ const key = await channelKey(root).catch(() => null);
2410
+ if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
2411
+ const payload = await body(request).catch(() => ({}));
2412
+ const here = basename(roomDir);
2413
+ const from = String(payload.from ?? '');
2414
+ const contract = activeContracts(await readConfig(root), { salas: salasRoomId(root) }).find((one) => one.id === payload.contract) ?? null;
2415
+ if (!contract?.deliver || !mayServe({ contract, serving: here, asking: from }).ok) return sendJson(response, 404, { error: NO_SUCH_LINK });
2416
+ if (!Array.isArray(payload.notes) || !payload.notes.length || payload.notes.length > DELIVER_MAX) return sendJson(response, 422, { error: t('A delivery carries between 1 and {n} notes.', { n: DELIVER_MAX }) });
2417
+ let notes;
2418
+ try { notes = payload.notes.map(deliverable); } catch (error) { return sendJson(response, 422, { error: t(error.message) }); }
2419
+ const received = await countRecent(roomDir, contract.id, 'delivered');
2420
+ if (received >= contract.orders.perHour) return sendJson(response, 429, { error: t('This project received {n} deliveries in the last hour, its limit. Try again later.', { n: received }) });
2421
+ // What arrives is cleaned here too, by this room's guard, before it is even shown.
2422
+ await refreshPrivacy();
2423
+ const clean = notes.map((note) => ({ ...note, text: privacy.redact(note.text, { always: true }).text, why: note.why ? privacy.redact(note.why, { always: true }).text : null }));
2424
+ const sender = (await knownRooms(root)).find((one) => one.id === from);
2425
+ const agent = /^[a-z][a-z0-9_-]{0,30}$/.test(String(payload.agent ?? '')) ? payload.agent : null;
2426
+ let added;
2427
+ try { added = await receiveDelivery(roomDir, { contract: contract.id, from, name: sender?.name ?? from, agent, notes: clean }); }
2428
+ catch (error) { return sendJson(response, error.code === 'FULL' ? 429 : 500, { error: t(error.message) }); }
2429
+ await room.record('enlace.delivered', { contract: contract.id, from, name: sender?.name ?? from, agent, coordinator: from === salasRoomId(root), notes: added.map(({ id, kind, text, why }) => ({ id, kind, text, why })) });
2430
+ await noteCrossing(roomDir, { kind: 'delivered', contract: contract.id, other: from, count: added.length, agent });
2431
+ return sendJson(response, 202, { accepted: added.length, ids: added.map((note) => note.id) });
2432
+ }
2114
2433
  // The side that asks (fase 3c): a tool of this room's own turn asks this room, and this room
2115
2434
  // asks the room that serves, by the channel. The same three doors as above — never a phone,
2116
2435
  // never a page, only the key — because the tool is a process of this Mac and nothing else is.
@@ -2155,6 +2474,84 @@ export async function createPulseServer({
2155
2474
  if (answer.unreachable) return sendJson(response, 502, { error: t('That project did not answer.') });
2156
2475
  return sendJson(response, answer.status ?? 502, { error: answer.refused ?? t('That project did not answer.') });
2157
2476
  }
2477
+ // D-S16 and D-S17, the side that asks: a tool of this room's own turn reads the state of a
2478
+ // project it reads from, or delivers notes to it. The same doors and the same check that the
2479
+ // turn is running here, for that agent, above GHOST, as /api/enlace/ask.
2480
+ if (request.method === 'POST' && (url.pathname === '/api/enlace/ask-state' || url.pathname === '/api/enlace/send')) {
2481
+ if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
2482
+ if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot read across a connection; only a project can.') });
2483
+ const key = await channelKey(root).catch(() => null);
2484
+ if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
2485
+ const payload = await body(request).catch(() => ({}));
2486
+ const here = basename(roomDir);
2487
+ const turnId = String(payload.turn ?? '');
2488
+ const host = turnId ? liveRooms().find((one) => one.runningTurn?.(turnId)) : null;
2489
+ const running = host?.runningTurn(turnId) ?? null;
2490
+ if (!running || running.agent !== payload.agent || !(running.mode >= 1)) return sendJson(response, 409, { error: t('That reading does not belong to a turn running in this project.') });
2491
+ const wanted = String(payload.room ?? '').trim();
2492
+ const rooms = await knownRooms(root);
2493
+ const named = rooms.filter((one) => one.id === wanted || one.name.toLowerCase() === wanted.toLowerCase());
2494
+ if (!wanted || !named.length) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
2495
+ const contracts = activeContracts(await readConfig(root), { salas: salasRoomId(root) });
2496
+ const sending = url.pathname === '/api/enlace/send';
2497
+ const usable = sending ? deliversTo(contracts, here).map(({ contract, to }) => ({ contract, other: to })) : readsFrom(contracts, here).filter(({ contract }) => contract.state).map(({ contract, from }) => ({ contract, other: from }));
2498
+ const link = named.map((source) => ({ source, contract: usable.find((one) => one.other === source.id)?.contract ?? null })).find((one) => one.contract) ?? null;
2499
+ if (!link) return sendJson(response, 404, { error: sending ? t('No signed connection lets this project deliver notes to that one.') : t('No signed connection lets this project read the state of that one.') });
2500
+ const { source, contract } = link;
2501
+ if (!source.open) return sendJson(response, 409, { error: t('That project is asleep. Wake it from Home to read from it.') });
2502
+ if (!sending) {
2503
+ const answer = await askState({ port: source.open.port, contract, asking: here, key, agent: running.agent });
2504
+ if (answer.outdated) return sendJson(response, 409, { error: t('That project runs an older MADRE. Update it, or close it from its terminal.') });
2505
+ if (answer.unreachable) return sendJson(response, 502, { error: t('That project did not answer.') });
2506
+ if (answer.refused) return sendJson(response, answer.status ?? 502, { error: answer.refused });
2507
+ await host.record('enlace.state.received', { contract: contract.id, from: source.id, name: source.name, agent: running.agent, turn: turnId, found: Boolean(answer.state) });
2508
+ return sendJson(response, 200, { from: source.name, contract: contract.id, state: answer.state });
2509
+ }
2510
+ if (!Array.isArray(payload.notes) || !payload.notes.length || payload.notes.length > DELIVER_MAX) return sendJson(response, 422, { error: t('A delivery carries between 1 and {n} notes.', { n: DELIVER_MAX }) });
2511
+ let notes;
2512
+ try { notes = payload.notes.map(deliverable); } catch (error) { return sendJson(response, 422, { error: t(error.message) }); }
2513
+ // What leaves is cleaned by this room's guard first, as every note that crosses.
2514
+ await refreshPrivacy();
2515
+ notes = notes.map((note) => ({ ...note, text: privacy.redact(note.text, { always: true }).text, why: note.why ? privacy.redact(note.why, { always: true }).text : null }));
2516
+ const sent = await deliverNotes({ port: source.open.port, contract, from: here, notes, key, agent: running.agent });
2517
+ if (sent.outdated) return sendJson(response, 409, { error: t('That project runs an older MADRE. Update it, or close it from its terminal.') });
2518
+ if (sent.unreachable) return sendJson(response, 502, { error: t('That project did not answer.') });
2519
+ if (sent.refused) return sendJson(response, sent.status ?? 502, { error: sent.refused });
2520
+ await host.record('enlace.sent', { contract: contract.id, to: source.id, name: source.name, agent: running.agent, turn: turnId, notes: notes.map(({ kind, text }) => ({ kind, text: text.slice(0, 200) })) });
2521
+ await noteCrossing(roomDir, { kind: 'sent', contract: contract.id, other: source.id, count: sent.accepted, agent: running.agent, turn: turnId });
2522
+ return sendJson(response, 202, { to: source.name, delivered: sent.accepted });
2523
+ }
2524
+ // D-S17 · the notes other projects delivered here. Read by the page or by a tool of a turn of
2525
+ // this room; decided by you from the page, or by an agent of this project whose turn runs in
2526
+ // #3 or #4 (it changes the project's memory, as CONTROL may). Accepted, the note enters the
2527
+ // memory as a distilled note does — held first — citing the ledger line of its acceptance.
2528
+ // Read like the room's other records (the guard keeps other computers and other sites out).
2529
+ if (url.pathname === '/api/enlace/inbox' && request.method === 'GET') {
2530
+ return sendJson(response, 200, { notes: (await readInbox(roomDir)).slice(-100).reverse() });
2531
+ }
2532
+ if (url.pathname === '/api/enlace/inbox/decide' && request.method === 'POST') {
2533
+ const payload = await body(request).catch(() => ({}));
2534
+ let by = 'you';
2535
+ if (request.headers.origin === undefined) {
2536
+ const key = await channelKey(root).catch(() => null);
2537
+ if (viaPhone || !key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
2538
+ const turnId = String(payload.turn ?? '');
2539
+ const running = turnId ? liveRooms().find((one) => one.runningTurn?.(turnId))?.runningTurn(turnId) : null;
2540
+ if (!running || running.agent !== payload.agent) return sendJson(response, 409, { error: t('That reading does not belong to a turn running in this project.') });
2541
+ if (!(running.mode >= 3)) return sendJson(response, 403, { error: t('Only an agent in #3 or #4 accepts a delivered note; below that, you decide it.') });
2542
+ by = running.agent;
2543
+ } else if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
2544
+ if (typeof payload.accept !== 'boolean') return sendJson(response, 422, { error: t('A delivered note is accepted or declined.') });
2545
+ if (payload.accept && !memory) return sendJson(response, 503, { error: t('The project has no memory.') });
2546
+ const note = await decideDelivery(roomDir, String(payload.id ?? ''), { accept: payload.accept, by });
2547
+ if (!note) return sendJson(response, 404, { error: t('That delivered note is not waiting here.') });
2548
+ const event = await room.record(payload.accept ? 'enlace.accepted' : 'enlace.declined', { id: note.id, contract: note.contract, from: note.from, name: note.name, kind: note.kind, text: note.text, by });
2549
+ if (payload.accept) {
2550
+ const sequence = Number(event?.sequence) || 0;
2551
+ memory.addMemories([{ kind: note.kind, text: `${note.text} (delivered by ${note.name})` }], { agent: by, fromSequence: sequence, throughSequence: sequence, origin: 'delivered' });
2552
+ }
2553
+ return sendJson(response, 200, { note });
2554
+ }
2158
2555
  // What another room tells this one by the channel: a link of this room's was signed or
2159
2556
  // revoked. One line in the ledger, never a message; any other type is refused.
2160
2557
  if (request.method === 'POST' && url.pathname === '/api/enlace/notice') {
@@ -2198,7 +2595,12 @@ export async function createPulseServer({
2198
2595
  // «Include always» only from the page (INI-04 of the 1.0 review): its own route refuses a
2199
2596
  // process, and signing let one set it anyway, so a project got another's notes every turn.
2200
2597
  const recall = payload.recall === true && !signed && request.headers.origin !== undefined;
2201
- contract = await signContract(root, { rooms: { a: payload.a, b: payload.b }, direction: payload.direction, kinds: payload.kinds, expires: payload.expires ?? null, orders: payload.orders ?? { allowed: false, maxMode: 0, perHour: 6 }, signed, recall }, { rooms, salas: salasRoomId(root), designation: payload.designation ?? null });
2598
+ // D-S15: «the Coordinator may learn from it» the same way: only you, only from the page.
2599
+ const train = payload.train === true && !signed && request.headers.origin !== undefined;
2600
+ // D-S16 and D-S17 the same way: the state and deliveries, only you, only from the page.
2601
+ const state = payload.state === true && !signed && request.headers.origin !== undefined;
2602
+ const deliver = payload.deliver === true && !signed && request.headers.origin !== undefined;
2603
+ contract = await signContract(root, { rooms: { a: payload.a, b: payload.b }, direction: payload.direction, kinds: payload.kinds, expires: payload.expires ?? null, orders: payload.orders ?? { allowed: false, maxMode: 0, perHour: 6 }, signed, recall, train, state, deliver }, { rooms, salas: salasRoomId(root), designation: payload.designation ?? null });
2202
2604
  } catch (error) {
2203
2605
  if (error.code === 'DESIGNATION') return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
2204
2606
  if (error.code === 'ALREADY') return sendJson(response, 409, { error: t(error.message) });
@@ -2218,6 +2620,30 @@ export async function createPulseServer({
2218
2620
  if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
2219
2621
  return sendJson(response, 200, { contract });
2220
2622
  }
2623
+ // D-S15: «the Coordinator may learn from this project», ticked or cleared by the human from
2624
+ // Home (never a phone, never a process). Cleared, Salas forgets the copy it kept of that
2625
+ // project at once, and the next export leaves it out.
2626
+ if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/train') {
2627
+ const payload = await body(request).catch(() => ({}));
2628
+ if (viaPhone || request.headers.origin === undefined) return sendJson(response, 403, { error: t('This is changed by you, from Home.') });
2629
+ if (typeof payload.train !== 'boolean') return sendJson(response, 422, { error: t('learning can only be on or off.') });
2630
+ let contract;
2631
+ try { contract = await trainContract(root, String(payload.id ?? ''), payload.train, { salas: salasRoomId(root) }); } catch (error) { return sendJson(response, 422, { error: t(error.message) }); }
2632
+ if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
2633
+ if (!contract.train) await forgetLinkedCorpus(contract);
2634
+ return sendJson(response, 200, { contract });
2635
+ }
2636
+ // D-S16 and D-S17: «it shares its state» and «notes can be delivered to it», ticked or cleared
2637
+ // by you from Home (never a phone, never a process).
2638
+ if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/allow') {
2639
+ const payload = await body(request).catch(() => ({}));
2640
+ if (viaPhone || request.headers.origin === undefined) return sendJson(response, 403, { error: t('This is changed by you, from Home.') });
2641
+ if (!['state', 'deliver'].includes(payload.field) || typeof payload.on !== 'boolean') return sendJson(response, 422, { error: t('That is not something a connection can allow.') });
2642
+ let contract;
2643
+ try { contract = await allowContract(root, String(payload.id ?? ''), payload.field, payload.on, { salas: salasRoomId(root) }); } catch (error) { return sendJson(response, 422, { error: t(error.message) }); }
2644
+ if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
2645
+ return sendJson(response, 200, { contract });
2646
+ }
2221
2647
  if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/revoke') {
2222
2648
  const payload = await body(request).catch(() => ({}));
2223
2649
  const id = String(payload.id ?? '');
@@ -2225,6 +2651,7 @@ export async function createPulseServer({
2225
2651
  const already = Boolean((await readConfig(root)).enlaces?.[id]?.revoked);
2226
2652
  const contract = await revokeContract(root, id);
2227
2653
  if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
2654
+ await forgetLinkedCorpus(contract);
2228
2655
  if (!already) await tellRooms('enlace.revoked', contract, await knownRooms(root)).catch(() => null);
2229
2656
  return sendJson(response, 200, { contract });
2230
2657
  }
@@ -2471,6 +2898,29 @@ export async function createPulseServer({
2471
2898
  await room.record('project.file.saved', { path: relative, bytes: result.bytes });
2472
2899
  return sendJson(response, 200, { ok: true, bytes: result.bytes, mtimeMs: result.mtimeMs });
2473
2900
  }
2901
+ // The code workspace (1.0.3): the last time an agent changed a file in an EDIT turn, as git
2902
+ // kept it — the file before the turn and the file the turn left — and which parts of it the
2903
+ // human already kept. A turn taken back with UNDO is no longer a change.
2904
+ if (request.method === 'GET' && url.pathname === '/api/files/changes') {
2905
+ const path = String(url.searchParams.get('path') ?? '').replace(/^\.?\/+/, '');
2906
+ if (!path || path.split('/').includes('..')) return sendJson(response, 400, { error: t('That is not a file of this project.') });
2907
+ const change = await lastAgentChange(path);
2908
+ if (!change) return sendJson(response, 200, { change: null, reviewed: [] });
2909
+ const before = change.status === 'A' ? '' : await readAt(canonicalProjectRoot, { checkpointId: change.checkpointId, path });
2910
+ if (before === null) return sendJson(response, 200, { change: null, reviewed: [] });
2911
+ const after = change.afterTree ? await readAt(canonicalProjectRoot, { tree: change.afterTree, path }) : null;
2912
+ const reviewed = (await readReviewed())[`${change.checkpointId}|${path}`] ?? [];
2913
+ return sendJson(response, 200, { change: { agent: change.agent, checkpointId: change.checkpointId, at: change.at, status: change.status, before, after, messageId: change.messageId }, reviewed });
2914
+ }
2915
+ if (request.method === 'POST' && url.pathname === '/api/files/changes/review') {
2916
+ const payload = await body(request).catch(() => ({}));
2917
+ const path = String(payload.path ?? '').replace(/^\.?\/+/, '');
2918
+ const checkpointId = String(payload.checkpointId ?? '');
2919
+ const hunk = String(payload.hunk ?? '');
2920
+ if (!path || path.split('/').includes('..') || !/^[0-9A-Za-z][\w-]{0,79}$/.test(checkpointId) || !/^[0-9a-f]{6,16}$/.test(hunk)) return sendJson(response, 400, { error: t('That is not a change of this project.') });
2921
+ const reviewed = await keepReviewed(`${checkpointId}|${path}`, hunk);
2922
+ return sendJson(response, 200, { reviewed });
2923
+ }
2474
2924
  // RIPLEY: the project rendered as a site, inside a frame that runs scripts but
2475
2925
  // reaches nothing: an opaque origin (sandbox without same-origin), no network
2476
2926
  // (connect-src none), no forms, no top navigation, assets only from MADRE's
@@ -2718,6 +3168,7 @@ export async function createPulseServer({
2718
3168
  image: Boolean(modules['image-studio']?.enabled),
2719
3169
  npm: updatesEnabled(),
2720
3170
  github: updatesEnabled(),
3171
+ market: marketOn(),
2721
3172
  reports: Boolean(sentinel.settings().autoReport),
2722
3173
  anthropic: agents.some((agent) => agent.id === 'claude' && agent.ready),
2723
3174
  ollama: Boolean(ollama.running),
@@ -2769,29 +3220,61 @@ export async function createPulseServer({
2769
3220
  const notes = memory ? memory.memories({ limit: 5000 }) : [];
2770
3221
  if (request.method === 'GET') {
2771
3222
  const manifest = await readFile(join(dir, 'manifest.json'), 'utf8').then(JSON.parse).catch(() => null);
2772
- const ready = datasetReadiness(await store.readAll(), notes);
3223
+ const ready = datasetReadiness(await projectEvents(), notes);
2773
3224
  const research = room.memoryResearch();
2774
- return sendJson(response, 200, { dataset: manifest, dir, trained: ollama.madreModel ?? null, readiness: ready, maturity: maturity({ readiness: ready, notes, links: research?.links ?? [], stats: research?.stats ?? null }), training: trainingInfo() });
3225
+ return sendJson(response, 200, { dataset: manifest, dir, trained: ollama.madreModel ?? null, readiness: ready, maturity: maturity({ readiness: ready, notes, links: research?.links ?? [], stats: research?.stats ?? null }), training: await trainingInfo() });
2775
3226
  }
2776
- const events = await store.readAll();
2777
- const result = await exportDataset({ events, notes, dir, project: basename(canonicalProjectRoot), home: homedir(), privacy });
2778
- await room.record('dataset.exported', { pairs: result.pairs, turns: result.turns, notes: result.notes, train: result.train, valid: result.valid, dir });
3227
+ // Every conversation of the project, not only the one open: a turn answered in another
3228
+ // conversation is as much the project's as the one on screen.
3229
+ const events = await projectEvents();
3230
+ // D-S15: in the room of Salas, what each project that lets it has to teach the Coordinator.
3231
+ const linked = salasRoom ? await gatherLinked(dir) : [];
3232
+ const result = await exportDataset({ events, notes, dir, project: basename(canonicalProjectRoot), home: homedir(), privacy, linked });
3233
+ await room.record('dataset.exported', { pairs: result.pairs, turns: result.turns, notes: result.notes, train: result.train, valid: result.valid, dir, ...(salasRoom ? { linked: result.linked.map((one) => ({ room: one.room, notes: one.notes, source: one.source })) } : {}) });
2779
3234
  const after = datasetReadiness(events, notes);
2780
3235
  const drawn = room.memoryResearch();
2781
- return sendJson(response, 200, { dataset: result, dir, trained: ollama.madreModel ?? null, readiness: after, maturity: maturity({ readiness: after, notes, links: drawn?.links ?? [], stats: drawn?.stats ?? null }), training: trainingInfo() });
3236
+ return sendJson(response, 200, { dataset: result, dir, trained: ollama.madreModel ?? null, readiness: after, maturity: maturity({ readiness: after, notes, links: drawn?.links ?? [], stats: drawn?.stats ?? null }), training: await trainingInfo() });
2782
3237
  }
2783
3238
  // PRIVACY: the terms live in config.json only; the ledger records counts, never words.
2784
3239
  // Which language MADRE speaks. The page keeps its own copy so the first paint needs no
2785
3240
  // round trip; this is the durable one, shared by every browser on this machine.
2786
3241
  // The looks every page shares (src/looks.mjs): Home and each project are different origins,
2787
3242
  // so the browser alone kept a choice for one page only.
2788
- if (request.method === 'GET' && url.pathname === '/api/looks') return sendJson(response, 200, await readLooks(root, { room: basename(roomDir) }));
3243
+ if (request.method === 'GET' && url.pathname === '/api/looks') return sendJson(response, 200, { ...await readLooks(root, { room: basename(roomDir) }), custom: await readWallpaper(root) });
3244
+ // Your own wallpaper (src/looks.mjs, 1.0.3): one picture, kept once for every page. Served as
3245
+ // the picture it is, never sniffed into anything else; written only from this page.
3246
+ if (request.method === 'GET' && url.pathname === '/api/looks/wallpaper') {
3247
+ const picture = await wallpaperBytes(root);
3248
+ if (!picture) return sendJson(response, 404, { error: t('There is no picture of yours here.') });
3249
+ response.writeHead(200, { 'content-type': picture.type, 'content-length': picture.bytes.length, 'cache-control': 'private, max-age=31536000, immutable', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'" });
3250
+ return response.end(picture.bytes);
3251
+ }
3252
+ if (request.method === 'PUT' && url.pathname === '/api/looks/wallpaper') {
3253
+ let bytes;
3254
+ try { bytes = await rawBody(request, WALLPAPER_BYTES); } catch { return sendJson(response, 413, { error: t('The picture is larger than 8 MB.') }); }
3255
+ let meta = null;
3256
+ try { meta = JSON.parse(decodeURIComponent(String(request.headers['x-madre-wallpaper'] ?? ''))); } catch { /* checked with the picture */ }
3257
+ const answer = await saveWallpaper(root, bytes, meta);
3258
+ if (answer.error) return sendJson(response, 400, { error: answer.error === 'type' ? t('Only a WebP, JPEG or PNG picture can be a wallpaper.') : t('That picture could not be kept.') });
3259
+ return sendJson(response, 200, answer);
3260
+ }
3261
+ if (request.method === 'POST' && url.pathname === '/api/looks/wallpaper/frame') {
3262
+ const answer = await frameWallpaper(root, await body(request).catch(() => null));
3263
+ return sendJson(response, answer.error ? 400 : 200, answer);
3264
+ }
3265
+ if (request.method === 'DELETE' && url.pathname === '/api/looks/wallpaper') return sendJson(response, 200, await dropWallpaper(root));
2789
3266
  // This project's own wallpaper («only in this project»), or back to the shared one with null.
2790
3267
  if (request.method === 'POST' && url.pathname === '/api/looks/room') {
2791
3268
  const payload = await body(request).catch(() => ({}));
2792
3269
  const answer = await changeRoomLook(root, basename(roomDir), payload?.wallpaper ?? null);
2793
3270
  return sendJson(response, answer.error ? 400 : 200, answer);
2794
3271
  }
3272
+ // How Home is arranged (src/looks.mjs, home.json): the same on every port Home is shown from.
3273
+ if (request.method === 'GET' && url.pathname === '/api/home-layout') return sendJson(response, 200, await readHome(root));
3274
+ if (request.method === 'POST' && url.pathname === '/api/home-layout') {
3275
+ const answer = await changeHome(root, await body(request).catch(() => null));
3276
+ return sendJson(response, answer.error ? 400 : 200, answer);
3277
+ }
2795
3278
  if (request.method === 'POST' && url.pathname === '/api/looks') {
2796
3279
  const answer = await changeLooks(root, await body(request).catch(() => null));
2797
3280
  return sendJson(response, answer.error ? 400 : 200, answer);
@@ -3444,6 +3927,76 @@ export async function createPulseServer({
3444
3927
  void warmModuleUpdates(extensions);
3445
3928
  return sendJson(response, 200, { installing, extensions, failures: loadFailures, folders: moduleFolders({ stateRoot: root, projectRoot: canonicalProjectRoot }), sdk: SDK_PAGE[roomLanguage()] ?? SDK_PAGE.es });
3446
3929
  }
3930
+ // THE MARKET's catalog for the page: the one kept here at once (and a fresh one asked for
3931
+ // behind it), what was got from it, and what it updated on its own that nobody was told yet.
3932
+ if (request.method === 'GET' && url.pathname === '/api/market') {
3933
+ const kept = await keptCatalog({ stateRoot: root, ...marketKeyed });
3934
+ const read = kept ? (void syncMarket().catch(() => null), { catalog: kept }) : await syncMarket().catch(() => ({ catalog: null }));
3935
+ return sendJson(response, 200, { catalog: read.catalog ?? null, off: !marketOn(), installed: await installedFromMarket(root), news: await marketNews(root), announce: await marketAnnouncements(root, read.catalog, MODULES.map((one) => one.id)), madre: PACKAGE.version });
3936
+ }
3937
+ if (request.method === 'POST' && url.pathname === '/api/market/seen') {
3938
+ await marketNewsSeen(root);
3939
+ return sendJson(response, 200, { ok: true });
3940
+ }
3941
+ // The Market's news a page has just told: not again, on this computer.
3942
+ if (request.method === 'POST' && url.pathname === '/api/market/announced') {
3943
+ const payload = await body(request).catch(() => ({}));
3944
+ await marketAnnounced(root, Array.isArray(payload.keys) ? payload.keys.slice(0, 50) : []);
3945
+ return sendJson(response, 200, { ok: true });
3946
+ }
3947
+ // A module from the Market onto this computer: its signed file, checked like any module, for
3948
+ // every project; then every project open right now loads it, and each one turns it on.
3949
+ if (request.method === 'POST' && url.pathname === '/api/market/install') {
3950
+ if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
3951
+ const payload = await body(request).catch(() => ({}));
3952
+ const catalog = (await keptCatalog({ stateRoot: root, ...marketKeyed })) ?? (await syncMarket({ force: true }).catch(() => ({}))).catalog;
3953
+ if (!catalog) return sendJson(response, 503, { error: t('MADRE could not reach the Market. Check your internet connection and try again.') });
3954
+ try {
3955
+ const done = await installFromMarket({ ...marketTools(), id: String(payload.id ?? ''), catalog });
3956
+ await room.record('extension.installed', { id: done.id, name: done.name, origin: 'market', version: done.version, by: 'you' });
3957
+ for (const one of await knownRooms(root)) {
3958
+ if (!one.open?.port || one.id === thisRoomId) continue;
3959
+ await fetch(`http://127.0.0.1:${one.open.port}/api/extensions/reload`, { method: 'POST' }).catch(() => null);
3960
+ }
3961
+ return sendJson(response, 200, { installed: done, extensions: await describeModules(await moduleContext()) });
3962
+ } catch (error) { return sendJson(response, 422, { error: error.message }); }
3963
+ }
3964
+ // Home's view of which projects each module is on in (module-scope.mjs), and where the
3965
+ // modules that install into a project's folder are installed.
3966
+ if (request.method === 'GET' && url.pathname === '/api/modules/scope') {
3967
+ const config = await readConfig(root);
3968
+ const home = salasRoomId(root);
3969
+ const projects = (await knownRooms(root)).filter((one) => one.id !== home && one.root).map((one) => ({ id: one.id, name: one.name, open: Boolean(one.open) }));
3970
+ const roots = new Map((await knownRooms(root)).map((one) => [one.id, one.root]));
3971
+ const ids = projects.map((one) => one.id);
3972
+ const modules = [];
3973
+ for (const module of MODULES) {
3974
+ if (module.scope && !NOT_BY_PROJECT.has(module.id)) modules.push({ id: module.id, switch: true, ...scopeView(config.modules?.[module.configKey] ?? {}, Boolean(module.defaults?.enabled), ids) });
3975
+ else if (module.kind === 'installer' && module.detect) {
3976
+ const on = [];
3977
+ for (const id of ids) if ((await module.detect(roots.get(id)).catch(() => null))?.installed) on.push(id);
3978
+ modules.push({ id: module.id, switch: false, on, everywhere: false });
3979
+ }
3980
+ }
3981
+ return sendJson(response, 200, { projects, modules });
3982
+ }
3983
+ // The keys modules put beside MADRE's version (sdk.mjs, `footer`): only those on here.
3984
+ if (request.method === 'GET' && url.pathname === '/api/modules/footer') {
3985
+ const context = await moduleContext();
3986
+ const keys = MODULES.filter((module) => module.footer?.length && module.settingsFrom?.(context.config, context.roomId)?.enabled).map((module) => ({ id: module.id, name: module.name, footer: module.footer }));
3987
+ return sendJson(response, 200, { keys });
3988
+ }
3989
+ // Home's word: every project (`all`), or some (`rooms`). A project's own MODULES moves only its word.
3990
+ const scopeMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/scope$/);
3991
+ if (scopeMatch) {
3992
+ if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
3993
+ const module = moduleById(scopeMatch[1]);
3994
+ if (!module?.scope || NOT_BY_PROJECT.has(module.id)) return sendJson(response, 404, { error: t('That module is not turned on by project.') });
3995
+ const payload = await body(request).catch(() => ({}));
3996
+ const result = await module.scope(await moduleContext(), { all: payload.all, rooms: payload.rooms, confirm: payload.confirm });
3997
+ for (const held of parked.values()) await applyRoomSettings(held.room, null);
3998
+ return sendJson(response, result.status, result.body);
3999
+ }
3447
4000
  // The human installs a module file an agent wrote (or they did): checked first, then copied into the chosen folder.
3448
4001
  if (request.method === 'POST' && url.pathname === '/api/extensions/install-file') {
3449
4002
  const payload = await body(request).catch(() => ({}));
@@ -3459,6 +4012,7 @@ export async function createPulseServer({
3459
4012
  if (removeMatch) {
3460
4013
  try {
3461
4014
  const removed = await removeExternalModule({ id: removeMatch[1], stateRoot: root, projectRoot: canonicalProjectRoot });
4015
+ await forgetFromMarket(root, removed.id).catch(() => false);
3462
4016
  await room.record('extension.removed', { id: removed.id, name: removed.name, by: 'you' });
3463
4017
  return sendJson(response, 200, { removed, extensions: await describeModules(await moduleContext()) });
3464
4018
  } catch (error) { return sendJson(response, moduleById(removeMatch[1]) ? 403 : 404, { error: error.message }); }
@@ -3488,6 +4042,19 @@ export async function createPulseServer({
3488
4042
  const result = await module.setControl(await moduleContext(), await body(request));
3489
4043
  return sendJson(response, result.status, result.body);
3490
4044
  }
4045
+ // What a module offers to put right before it can install (Continuity on a Mac with no git:
4046
+ // Apple's installer for its command line tools). Asked from this Mac only; the command is the
4047
+ // module's own, never one sent by the page.
4048
+ const fixMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/fix$/);
4049
+ if (fixMatch) {
4050
+ if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
4051
+ const extension = extensionById(fixMatch[1]);
4052
+ const checked = extension?.preflight ? await extension.preflight(canonicalProjectRoot) : null;
4053
+ if (!checked?.fix) return sendJson(response, 409, { error: t('There is nothing to set up for this module.') });
4054
+ const ran = await runInstaller({ command: checked.fix.command, args: checked.fix.args, projectRoot: canonicalProjectRoot, timeoutMs: 60000 });
4055
+ await room.record('extension.fix.started', { id: fixMatch[1], command: checked.fix.display, code: ran.code });
4056
+ return sendJson(response, ran.code === 0 ? 202 : 500, ran.code === 0 ? { started: true, command: checked.fix.display } : { error: ran.error ?? t('It could not be started.') });
4057
+ }
3491
4058
  const installMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/install$/);
3492
4059
  if (installMatch) {
3493
4060
  // A phone switches MADRE's own modules; installing one runs commands in the project.
@@ -3530,6 +4097,8 @@ export async function createPulseServer({
3530
4097
  // like any other: the conversation on screen is one of the "others" when it is working.
3531
4098
  const here = await scopeOf(url);
3532
4099
  if (here.error) return sendJson(response, 404, here);
4100
+ // A switch moved in Home since this project opened reaches it here (module-scope.mjs).
4101
+ applyModuleSwitches(here.room, await readConfig(root));
3533
4102
  const room = here.room;
3534
4103
  // A copilot message at #3 goes with the copilot's window instead of a pass; from this
3535
4104
  // computer only. With the window closed it is said, and the page asks for the key again.