@jossuealcala/madre 1.0.1 → 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 (189) hide show
  1. package/ADDITIONAL-TERMS.md +93 -0
  2. package/CHANGELOG.en.md +2013 -0
  3. package/CHANGELOG.md +102 -1
  4. package/CONTRIBUTING.md +1 -1
  5. package/LICENSE +661 -202
  6. package/NOTICE +26 -14
  7. package/README.en.md +241 -0
  8. package/README.md +21 -12
  9. package/TRADEMARKS.en.md +50 -0
  10. package/TRADEMARKS.md +33 -16
  11. package/bin/madre.cjs +40 -0
  12. package/bin/madre.mjs +7 -0
  13. package/docs/INTERNALS.md +6 -0
  14. package/docs/REFERENCE.md +40 -5
  15. package/docs/SDK.md +4 -2
  16. package/docs/en/INTERNALS.md +103 -0
  17. package/docs/en/REFERENCE.md +365 -0
  18. package/docs/en/SDK.md +308 -0
  19. package/docs/en/training/README.md +59 -0
  20. package/docs/training/README.md +48 -9
  21. package/docs/training/train.sh +7 -2
  22. package/package.json +13 -5
  23. package/public/abduction.js +2 -0
  24. package/public/app.js +3154 -539
  25. package/public/attribution.js +114 -0
  26. package/public/brands.js +23 -6
  27. package/public/code-tools.js +143 -0
  28. package/public/editor.js +22 -4
  29. package/public/es.js +1004 -416
  30. package/public/hardware.js +50 -0
  31. package/public/i18n.js +25 -1
  32. package/public/index.html +18 -11
  33. package/public/inquiry.js +2 -0
  34. package/public/loader.js +2 -0
  35. package/public/macroverso.js +189 -96
  36. package/public/market-art.js +161 -0
  37. package/public/market-view.js +529 -0
  38. package/public/market.js +189 -0
  39. package/public/neural.js +110 -0
  40. package/public/nostromo-engine.js +414 -47
  41. package/public/power.js +100 -0
  42. package/public/resay.js +11 -0
  43. package/public/review-ask.js +2 -0
  44. package/public/routing.js +23 -1
  45. package/public/salas-mac.css +119 -18
  46. package/public/salas-mac.html +14 -2
  47. package/public/salas-mac.js +358 -56
  48. package/public/salas.css +2 -0
  49. package/public/salas.html +1 -0
  50. package/public/salas.js +4 -2
  51. package/public/styles.css +1793 -77
  52. package/public/syntax.js +2 -0
  53. package/public/touch.js +2 -0
  54. package/public/tour-chapters.js +2 -0
  55. package/public/tour.js +84 -5
  56. package/public/troubleshooting.js +24 -17
  57. package/public/wallpaper.js +275 -0
  58. package/src/adapters/claude.mjs +2 -0
  59. package/src/adapters/codex.mjs +2 -0
  60. package/src/adapters/cursor.mjs +96 -0
  61. package/src/adapters/gemini.mjs +13 -4
  62. package/src/adapters/madre.mjs +5 -2
  63. package/src/adapters/opencode.mjs +7 -3
  64. package/src/adapters/process.mjs +2 -0
  65. package/src/asking.mjs +2 -0
  66. package/src/auth-probe.mjs +145 -12
  67. package/src/bridge/devices.mjs +2 -0
  68. package/src/bridge/handoff.mjs +2 -0
  69. package/src/bridge/host.mjs +2 -0
  70. package/src/bridge/network.mjs +2 -0
  71. package/src/bridge/policy.mjs +3 -1
  72. package/src/bridge/qr.mjs +2 -0
  73. package/src/bridge/server.mjs +2 -0
  74. package/src/capabilities.mjs +11 -0
  75. package/src/chats.mjs +2 -0
  76. package/src/checkpoint.mjs +18 -1
  77. package/src/choices.mjs +2 -0
  78. package/src/cli-args.mjs +2 -0
  79. package/src/cold.mjs +2 -0
  80. package/src/commands.mjs +2 -0
  81. package/src/config.mjs +2 -0
  82. package/src/conversation-context.mjs +15 -1
  83. package/src/credentials.mjs +35 -3
  84. package/src/dataset.mjs +78 -4
  85. package/src/directives.mjs +2 -0
  86. package/src/distiller.mjs +2 -0
  87. package/src/embeddings.mjs +2 -0
  88. package/src/enlaces.mjs +214 -6
  89. package/src/event-store.mjs +2 -0
  90. package/src/exam.mjs +6 -4
  91. package/src/extensions.mjs +13 -3
  92. package/src/eyecat-watch.mjs +2 -0
  93. package/src/eyecat.mjs +2 -0
  94. package/src/file-ops.mjs +2 -0
  95. package/src/files.mjs +32 -0
  96. package/src/github.mjs +2 -0
  97. package/src/heartbeat.mjs +2 -0
  98. package/src/i18n.mjs +31 -1
  99. package/src/image-studio.mjs +2 -0
  100. package/src/json-file.mjs +2 -0
  101. package/src/launch.mjs +2 -0
  102. package/src/lease.mjs +5 -1
  103. package/src/lobby-work.mjs +2 -0
  104. package/src/local-ai.mjs +2 -0
  105. package/src/looks.mjs +127 -2
  106. package/src/market-key.mjs +8 -0
  107. package/src/market-remote.mjs +239 -0
  108. package/src/maturity.mjs +3 -1
  109. package/src/mcp/connect.mjs +345 -0
  110. package/src/mcp/github-server.mjs +2 -0
  111. package/src/mcp/image-server.mjs +2 -0
  112. package/src/mcp/memory-server.mjs +2 -0
  113. package/src/mcp/proxy.mjs +153 -0
  114. package/src/mcp/smtp-server.mjs +2 -0
  115. package/src/memory-dashboard.mjs +2 -0
  116. package/src/memory-tools.mjs +2 -0
  117. package/src/memory.mjs +2 -0
  118. package/src/models.mjs +49 -3
  119. package/src/module-scope.mjs +68 -0
  120. package/src/modules/ahp.mjs +104 -7
  121. package/src/modules/ash-policy.mjs +2 -0
  122. package/src/modules/ash.mjs +3 -1
  123. package/src/modules/bishop-exam.mjs +122 -0
  124. package/src/modules/bishop-plan.mjs +5 -3
  125. package/src/modules/bishop.mjs +84 -23
  126. package/src/modules/choices.mjs +2 -0
  127. package/src/modules/correo.mjs +2 -0
  128. package/src/modules/enlaces.mjs +89 -6
  129. package/src/modules/git-pulse.mjs +2 -0
  130. package/src/modules/github.mjs +2 -0
  131. package/src/modules/helpers.mjs +2 -0
  132. package/src/modules/image-studio.mjs +13 -2
  133. package/src/modules/index.mjs +4 -1
  134. package/src/modules/intents-calibration.mjs +2 -0
  135. package/src/modules/intents-desk.mjs +4 -2
  136. package/src/modules/intents-observe.mjs +2 -0
  137. package/src/modules/intents-policy.mjs +4 -1
  138. package/src/modules/intents-try.mjs +2 -0
  139. package/src/modules/intents.mjs +11 -7
  140. package/src/modules/local-models.mjs +124 -0
  141. package/src/modules/mcp-connect.mjs +293 -0
  142. package/src/modules/ollama.mjs +15 -4
  143. package/src/modules/playwright.mjs +88 -15
  144. package/src/modules/ripley.mjs +2 -0
  145. package/src/modules/runs.mjs +5 -3
  146. package/src/modules/sdk.mjs +63 -9
  147. package/src/modules/updates.mjs +2 -0
  148. package/src/mother.mjs +2 -0
  149. package/src/npm-launch.mjs +18 -0
  150. package/src/ollama.mjs +8 -5
  151. package/src/outbound.mjs +16 -6
  152. package/src/override.mjs +2 -0
  153. package/src/ports.mjs +6 -2
  154. package/src/prices.mjs +2 -0
  155. package/src/privacy.mjs +2 -0
  156. package/src/quota-monitor.mjs +2 -0
  157. package/src/quota-sources.mjs +2 -0
  158. package/src/request-guard.mjs +2 -0
  159. package/src/review-ask.mjs +2 -0
  160. package/src/room/archivist.mjs +2 -0
  161. package/src/room/attachments.mjs +2 -0
  162. package/src/room/budget.mjs +2 -0
  163. package/src/room/context.mjs +2 -0
  164. package/src/room/control.mjs +7 -1
  165. package/src/room/copilot.mjs +76 -0
  166. package/src/room/economy.mjs +2 -0
  167. package/src/room/escalation.mjs +2 -0
  168. package/src/room/ghost.mjs +2 -0
  169. package/src/room/guard.mjs +2 -0
  170. package/src/room/prompt.mjs +25 -5
  171. package/src/room/returns.mjs +82 -0
  172. package/src/room/runs.mjs +4 -0
  173. package/src/room/vectors.mjs +2 -0
  174. package/src/room.mjs +223 -35
  175. package/src/router.mjs +3 -1
  176. package/src/runs.mjs +6 -1
  177. package/src/runtime-detection.mjs +11 -0
  178. package/src/salas.mjs +28 -0
  179. package/src/sentinel-errors.mjs +2 -0
  180. package/src/server-sentinel.mjs +2 -0
  181. package/src/server.mjs +757 -81
  182. package/src/setup.mjs +3 -1
  183. package/src/smtp.mjs +2 -0
  184. package/src/tour.mjs +2 -0
  185. package/src/updates.mjs +2 -0
  186. package/src/usage-sentinel.mjs +2 -0
  187. package/src/vault.mjs +2 -0
  188. package/src/verdict.mjs +3 -1
  189. package/src/whats-new.mjs +2 -0
package/src/server.mjs CHANGED
@@ -1,12 +1,15 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  import http from 'node:http';
2
- 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';
3
5
  import { existsSync, openSync, rmSync } from 'node:fs';
4
6
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
5
7
  import { probePort, framable } from './ports.mjs';
6
8
  import { ControlDesk } from './room/control.mjs';
7
9
  import { basename, dirname, join, resolve } from 'node:path';
8
10
  import { fileURLToPath } from 'node:url';
9
- import { homedir, hostname } from 'node:os';
11
+ import { gzipSync } from 'node:zlib';
12
+ import { homedir, hostname, tmpdir } from 'node:os';
10
13
  import { execFile } from 'node:child_process';
11
14
  import { promisify } from 'node:util';
12
15
 
@@ -20,9 +23,10 @@ import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
20
23
  import { ErrorSentinel } from './sentinel-errors.mjs';
21
24
  import { probeOllama, ollamaEmbedder, ollamaGenerate, ollamaInvoker, pullModel, ollamaVersions, ownOllamaServers } from './ollama.mjs';
22
25
  import { recallForTurn } from './modules/enlaces.mjs';
26
+ import { hardwareOnce, MODEL_TIERS } from './modules/local-models.mjs';
23
27
  import { MODULES, moduleById, describeModules, findModuleRoute, toolsForTurn as modulesToolsForTurn, loadExternalModules, loadFailures, moduleFolders, moduleCommands, installModuleFile, installModuleText, verifyModuleText, moduleOrigin, removeExternalModule } from './modules/index.mjs';
24
28
  import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
25
- import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
29
+ import { exportDataset, forgetCorpus, keepCorpus, keptCorpus, pruneCorpora, readiness as datasetReadiness } from './dataset.mjs';
26
30
  import { OutboundLog, outboundView, DEFAULT_REPORT_URL } from './outbound.mjs';
27
31
  import { language as roomLanguage, setLanguage as setRoomLanguage, t } from './i18n.mjs';
28
32
 
@@ -60,10 +64,11 @@ export function injectRipleyBridge(body) {
60
64
  import { QuotaMonitor } from './quota-monitor.mjs';
61
65
  import { defaultQuotaSources } from './quota-sources.mjs';
62
66
  import { Room } from './room.mjs';
63
- import { changeNeedsPhrase, keyHolds, mergeOverride, overrideSettings, phraseHash, publicOverride } from './override.mjs';
67
+ import { COPILOT_WINDOWS } from './room/copilot.mjs';
68
+ import { OVERRIDE_GRACE, changeNeedsPhrase, keyHolds, mergeOverride, overrideSettings, phraseHash, publicOverride } from './override.mjs';
64
69
  import { extensionById, findOnPath, runInstaller } from './extensions.mjs';
65
- import { accountNoteFor, installPlanFor, looksLikeAdminProblem, loginPlanFor, looksLikeCodePrompt, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
66
- import { applyKey, keyPlanFor, OPENCODE_PROVIDERS, readOpenCodeProviders, removeOpenCodeKey } from './credentials.mjs';
70
+ import { accountNoteFor, deviceCodeIn, geminiSignIn, installPlanFor, looksLikeAdminProblem, loginPlanFor, looksLikeCodePrompt, looksLikeConsentPrompt, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
71
+ import { applyKey, keyPlanFor, openCodeKeyProviders, readOpenCodeProviders, removeOpenCodeKey } from './credentials.mjs';
67
72
  import { forgetSecret, readSecret, summary as vaultSummary, writeSecret } from './vault.mjs';
68
73
  import { applyConfigToEnv, loadConfig as readConfig, updateConfig } from './config.mjs';
69
74
  import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
@@ -78,7 +83,7 @@ import { moduleUpdate } from './modules/updates.mjs';
78
83
  import { adoptStrays, chatIndex, chatLedger, createChat, deleteChat, isChatId, listChats, openChat as openChatIndex, projectFloor, renameChat, touchChat } from './chats.mjs';
79
84
  import { spawn } from 'node:child_process';
80
85
  import { cpus, freemem, loadavg, totalmem } from 'node:os';
81
- import { discoverModels, groupOpenCode, openCodeHidden, providerLabel, OPENCODE_OWN } from './models.mjs';
86
+ import { discoverModels, groupOpenCode, openCodeHidden, parseOpenCodeVerbose, providerLabel, OPENCODE_OWN } from './models.mjs';
82
87
  import { observable } from './modules/intents-observe.mjs';
83
88
  import { normalizeMode, setImageModule } from './capabilities.mjs';
84
89
  import { resolveGeminiKey } from './image-studio.mjs';
@@ -100,16 +105,58 @@ import { currentNetwork, lanAddresses } from './bridge/network.mjs';
100
105
  import { PHONE_MAX_MODE, writtenMode } from './bridge/policy.mjs';
101
106
  import { qrSvg } from './bridge/qr.mjs';
102
107
  import { readHandoff, writeHandoff } from './bridge/handoff.mjs';
103
- 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';
104
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']);
105
115
  import { heartbeat } from './heartbeat.mjs';
106
- 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';
107
117
  import { parseWork, recentWork, WORK_SYSTEM, workPrompt } from './lobby-work.mjs';
108
- 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';
109
121
 
110
122
  const sourceDirectory = dirname(fileURLToPath(import.meta.url));
111
123
  const publicDirectory = join(sourceDirectory, '..', 'public');
112
124
 
125
+ // The page's own scripts and styles (2026-10-15): an ETag from the file's size and time, so a
126
+ // reload that finds them unchanged gets «304» and the browser reuses its copy — and the code it
127
+ // already compiled — instead of reading 2.5 MB again; and gzip for whoever asks, compressed once per
128
+ // change of the file. `no-cache` still revalidates every time, so an edit shows on the next load.
129
+ // Only the compressed copy is kept in memory (~0.6 MB for all of them); the plain file is read when
130
+ // a client does not take gzip.
131
+ const assetCache = new Map();
132
+ async function sendAsset(request, response, file, type) {
133
+ const info = await stat(file);
134
+ const tag = `W/"${info.size.toString(36)}-${Math.floor(info.mtimeMs).toString(36)}"`;
135
+ const headers = { 'content-type': type, 'cache-control': 'no-cache', etag: tag, vary: 'accept-encoding' };
136
+ if (request.headers['if-none-match'] === tag) { response.writeHead(304, headers); return response.end(); }
137
+ const gzip = /\bgzip\b/.test(String(request.headers['accept-encoding'] ?? '')) && info.size > 8192;
138
+ if (gzip) {
139
+ let held = assetCache.get(file);
140
+ if (held?.tag !== tag) { held = { tag, body: gzipSync(await readFile(file), { level: 6 }) }; assetCache.set(file, held); }
141
+ response.writeHead(200, { ...headers, 'content-encoding': 'gzip', 'content-length': held.body.length });
142
+ return response.end(held.body);
143
+ }
144
+ const body = await readFile(file);
145
+ response.writeHead(200, { ...headers, 'content-length': body.length });
146
+ return response.end(body);
147
+ }
148
+
149
+ // The record as a page opening it needs it (2026-10-15). `quota.updated` is how full each agent's
150
+ // window is, read again every few minutes; the page keeps only the last reading of each agent, so
151
+ // every older one was downloaded, parsed and drawn for nothing — 87% of a long conversation's
152
+ // record, and seconds of drawing. The ledger keeps them all; the page gets the newest of each.
153
+ export function forThePage(events) {
154
+ const lastQuota = new Map();
155
+ for (const event of events) if (event?.type === 'quota.updated') lastQuota.set(event.payload?.agent ?? null, event);
156
+ const keep = new Set(lastQuota.values());
157
+ return events.filter((event) => event?.type !== 'quota.updated' || keep.has(event));
158
+ }
159
+
113
160
  function sendJson(response, status, value) {
114
161
  response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
115
162
  response.end(JSON.stringify(value));
@@ -137,7 +184,7 @@ export function openUrl(url) {
137
184
 
138
185
  // PULSE_AGENT_TIMEOUT_MS applies to every agent; PULSE_<AGENT>_TIMEOUT_MS
139
186
  // (e.g. PULSE_CLAUDE_TIMEOUT_MS) overrides it for one.
140
- export function agentTimeoutsFromEnv(env = process.env, agentIds = ['codex', 'claude', 'gemini', 'opencode']) {
187
+ export function agentTimeoutsFromEnv(env = process.env, agentIds = ['codex', 'claude', 'gemini', 'opencode', 'cursor']) {
141
188
  const timeouts = {};
142
189
  const shared = Number(env.PULSE_AGENT_TIMEOUT_MS);
143
190
  if (Number.isFinite(shared) && shared > 0) timeouts.default = shared;
@@ -195,6 +242,10 @@ export async function createPulseServer({
195
242
  imageKey = resolveGeminiKey,
196
243
  // The sentinel's outbound channel; tests hand in a fake.
197
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,
198
249
  // Ollama detection; tests hand in a fake probe. PULSE_OLLAMA=0 leaves Ollama alone entirely.
199
250
  ollamaProbe = process.env.PULSE_OLLAMA === '0' ? async () => ({ running: false, host: null, models: [], embedModel: null, chatModel: null, disabled: true }) : probeOllama,
200
251
  }) {
@@ -207,8 +258,11 @@ export async function createPulseServer({
207
258
  // What language the screens speak. The prompt the agents read is not one of them, and the
208
259
  // environment wins over the file, as it does for every other setting here.
209
260
  setRoomLanguage(process.env.PULSE_LANGUAGE ?? (await readConfig(root)).language);
261
+ // Whether anyone set it: a MADRE nobody has set takes the language of the first browser that
262
+ // opens it (public/i18n.js, browserLanguage), once, and keeps it as if it had been chosen.
263
+ let languageChosen = Boolean(process.env.PULSE_LANGUAGE || (await readConfig(root)).language);
210
264
  // RIPLEY renders HTML in the viewer only while the human has it switched on; read live, the switch is the module's.
211
- const ripleyOn = async () => Boolean((await readConfig(root)).modules?.ripley?.enabled);
265
+ const ripleyOn = async () => moduleOnHere(await readConfig(root), 'ripley');
212
266
  agentTimeouts ??= agentTimeoutsFromEnv();
213
267
  const detector = detect ?? (providedAgents ? null : detectAgents);
214
268
  const agents = providedAgents ?? await detector();
@@ -225,6 +279,7 @@ export async function createPulseServer({
225
279
  const unwatchOutbound = outbound.install();
226
280
  process.env.PULSE_OUTBOUND_LOG = join(roomDir, 'outbound.jsonl');
227
281
  reportFetch = outbound.watch(reportFetch);
282
+ marketFetch = outbound.watch(marketFetch);
228
283
 
229
284
  // Attachments live beside the room log, never inside the project, and belong to the project
230
285
  // rather than to one conversation.
@@ -316,6 +371,23 @@ export async function createPulseServer({
316
371
  // default; PULSE_UPDATE_CHECK=0 or the switch in MU/TH/UR turns it off. Never self-updates.
317
372
  const updatesEnabled = () => (process.env.PULSE_UPDATE_CHECK !== undefined ? process.env.PULSE_UPDATE_CHECK !== '0' : updatesConfig.check !== false);
318
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);
319
391
  const install = detectInstall({ projectRoot: canonicalProjectRoot });
320
392
  async function versionView({ force = false } = {}) {
321
393
  const check = await checkForUpdate({ name: PACKAGE.name, current: PACKAGE.version, cacheFile: join(root, 'updates.json'), fetchImpl: reportFetch, enabled: updatesEnabled(), force });
@@ -516,6 +588,10 @@ export async function createPulseServer({
516
588
  const ollamaSettings = async () => ({ enabled: true, embeddings: true, archivist: true, agent: true, ...((await readConfig(root)).modules?.ollama ?? {}) });
517
589
  // Whether this project uses the local AI at all, as Home decides it (src/local-ai.mjs).
518
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);
519
595
  const localAiHere = async () => localAiFor(await readConfig(root), thisRoomId, { home: salasRoom });
520
596
  // This project's own switch, outside «auto»; null when Home decides.
521
597
  async function setLocalAiHere(on) {
@@ -524,10 +600,110 @@ export async function createPulseServer({
524
600
  return wireOllama({ probe: false });
525
601
  }
526
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.
527
- 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() {
528
703
  const slug = basename(canonicalProjectRoot).toLowerCase().replace(/[^a-z0-9]+/g, '-');
529
- const gb = Math.round(totalmem() / 1024 ** 3);
530
- 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 };
531
707
  }
532
708
  async function wireOllama({ probe: doProbe = true } = {}) {
533
709
  if (doProbe) ollama = await ollamaProbe();
@@ -557,7 +733,7 @@ export async function createPulseServer({
557
733
  wireLocal(room);
558
734
  for (const held of parked.values()) wireLocal(held.room);
559
735
  if (room) {
560
- if (changed) await room.record('agents.updated', { agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local), route: agent.route ?? null })), removed: fifth.ready ? [] : [MADRE_AGENT_ID], reason: fifth.ready ? `@madre is in the room · ${fifth.version}` : '@madre left the room: Ollama has no chat model running' });
736
+ if (changed) await room.record('agents.updated', { agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local), route: agent.route ?? null })), removed: fifth.ready ? [] : [MADRE_AGENT_ID], reason: fifth.ready ? `@madre is in the project · ${fifth.version}` : '@madre left the project: Ollama has no chat model running' });
561
737
  // That a local model is running is not the same as it being of use on this project, and
562
738
  // the second thing is the one a person needs told. Said once per model, in the room, with
563
739
  // the one button that settles it — the crew line above fires only when the crew CHANGES,
@@ -602,8 +778,10 @@ export async function createPulseServer({
602
778
  const passes = new Map(); // token -> { agent, mode, until, once }
603
779
  const keyMisses = []; // when the last wrong keys were typed
604
780
  const PASS_ONCE_MS = 30 * 60 * 1000;
605
- function issuePass(agent, mode) {
606
- const minutes = overrideConfig.grace[mode] ?? 0;
781
+ // `asked` is a window the human chose on an agent's request for this mode (2026-10-14): one of
782
+ // the windows the project allows, never longer, and only for that agent and mode.
783
+ function issuePass(agent, mode, asked = 0) {
784
+ const minutes = asked > 0 && OVERRIDE_GRACE[mode]?.includes(asked) ? asked : overrideConfig.grace[mode] ?? 0;
607
785
  const token = randomBytes(24).toString('hex');
608
786
  const pass = { agent, mode, until: Date.now() + (minutes ? minutes * 60000 : PASS_ONCE_MS), once: !minutes };
609
787
  passes.set(token, pass);
@@ -616,6 +794,18 @@ export async function createPulseServer({
616
794
  if (pass.once) passes.delete(token);
617
795
  return true;
618
796
  }
797
+ // The copilot (src/room/copilot.mjs): one window for the project, opened with the #3 key, up to
798
+ // 24 hours. In memory only, like the passes: a restart and STOP ALL close it. Every conversation
799
+ // of the project asks this one when it decides whether a message is a copilot's.
800
+ let copilot = null; // { since, until, minutes, timer }
801
+ const copilotNow = () => (copilot && copilot.until > Date.now() ? { since: copilot.since, until: copilot.until, minutes: copilot.minutes } : null);
802
+ const copilotState = () => ({ active: Boolean(copilotNow()), ...(copilotNow() ?? {}), windows: COPILOT_WINDOWS });
803
+ async function endCopilot(reason) {
804
+ if (!copilot) return;
805
+ clearTimeout(copilot.timer);
806
+ copilot = null;
807
+ await room.record('copilot.ended', { reason }).catch(() => null);
808
+ }
619
809
  let listening = null;
620
810
  const listeningPort = () => listening?.address?.()?.port ?? Number(process.env.PULSE_PORT ?? 4317);
621
811
  // One CONTROL desk for the whole project, shared by every conversation alive at once: a single
@@ -635,6 +825,7 @@ export async function createPulseServer({
635
825
  bishopForTurn: salasRoom ? async (turn) => moduleById('bishop').promptForTurn({ ...(await moduleContext()), record: (type, payload) => ((turn.turn && liveRooms().find((one) => one.runningTurn?.(turn.turn))) || room).record(type, payload) }, turn) : null,
636
826
  // Fase 3e: notes a link with automatic recall brings to a turn. Never in the room of Salas
637
827
  // (BISHOP has its own); recorded in the conversation of the turn that read them.
828
+ copilot: copilotNow,
638
829
  linkedForTurn: salasRoom ? null : async (turn) => recallForTurn(await moduleContext(), { ...turn, port: listeningPort(), roomDir,
639
830
  record: (type, payload) => ((turn.turn && liveRooms().find((one) => one.runningTurn?.(turn.turn))) || room).record(type, payload) }),
640
831
  softTokenBudget,
@@ -788,8 +979,9 @@ export async function createPulseServer({
788
979
  let loggingIn = null;
789
980
  // A sign-in in flight, as the room is allowed to see it: which door, the address to open, and
790
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.
791
983
  let loginRun = null;
792
- 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);
793
985
 
794
986
  async function loginAgent(id, { method = null } = {}) {
795
987
  const agent = agents.find((item) => item.id === id);
@@ -799,36 +991,91 @@ export async function createPulseServer({
799
991
  if (!plan) return { status: 404, body: { error: `No sign-in flow known for ${id}.` } };
800
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 } };
801
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];
802
1010
  loggingIn = id;
803
- 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 };
804
1012
  await room.record('connection.login.started', { agent: id, command: plan.display, note: plan.note, method: plan.method ?? null });
805
1013
  void (async () => {
806
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;
807
1028
  const result = await runInstaller({
808
1029
  command: plan.command,
809
1030
  args: plan.args,
810
- projectRoot: canonicalProjectRoot,
1031
+ projectRoot: cwd,
1032
+ env,
1033
+ ci: plan.ci !== false,
811
1034
  // A person is in the middle of this: the browser, the account, the code. Five minutes was
812
1035
  // the installer's clock, and it killed sign-ins that were only waiting on a human.
813
1036
  timeoutMs: 900000,
814
1037
  // A CLI that asks for a code needs a way to be answered, and its question never ends in a
815
1038
  // newline, so the half-line is flushed rather than left sitting in the buffer.
816
- stdin: plan.asksForCode ? 'pipe' : 'ignore',
817
- flushIdleMs: plan.asksForCode ? 600 : 0,
1039
+ stdin: plan.asksForCode || plan.consent || plan.answers?.length ? 'pipe' : 'ignore',
1040
+ flushIdleMs: plan.asksForCode || plan.consent ? 600 : 0,
818
1041
  quietNote: 'the sign-in is waiting on you: finish it in the page that opened',
819
1042
  onChild: (handle) => { if (loginRun?.agent === id) loginRun.child = handle; },
820
- 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;
821
1048
  lines.push(line);
1049
+ if (flow?.refused?.(line) && !refused) { refused = true; loginRun?.child?.cancel?.(); }
822
1050
  const url = line.match(/https?:\/\/\S+/)?.[0] ?? null;
1051
+ const deviceCode = plan.deviceCode ? deviceCodeIn(line) : null;
823
1052
  if (loginRun?.agent === id) {
824
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); }
825
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'); }
826
1061
  }
827
- 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 } : {}) });
828
1063
  },
829
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
+ }
830
1072
  await refreshSessions();
831
- 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) });
832
1079
  loggingIn = null;
833
1080
  loginRun = null;
834
1081
  })();
@@ -943,10 +1190,7 @@ export async function createPulseServer({
943
1190
 
944
1191
  const startupConfig = await readConfig(root);
945
1192
  room.setScopes(startupConfig.scopes ?? {});
946
- setImageModule(startupConfig.modules?.imageStudio ?? {});
947
- room.setAsh(Boolean(startupConfig.modules?.ash?.enabled));
948
- room.setChoices(Boolean(startupConfig.modules?.choices?.enabled));
949
- room.setRuns(Boolean(startupConfig.modules?.runs?.enabled));
1193
+ applyModuleSwitches(room, startupConfig);
950
1194
  const recoveredTurns = await room.reconcile();
951
1195
  if (recoveredTurns) console.error(`MADRE recovered ${recoveredTurns} unfinished turn(s) from a previous run.`);
952
1196
  const quotaMonitor = new QuotaMonitor({
@@ -1123,8 +1367,9 @@ export async function createPulseServer({
1123
1367
  // its own takes the first thing the human said in it.
1124
1368
  unsubscribeChats = room.subscribe((event) => {
1125
1369
  if (event?.type !== 'message.created' || event.ghost) return;
1370
+ // A message MADRE wrote for the human (results going back) neither names nor previews it.
1126
1371
  const human = event.payload?.role === 'user';
1127
- chatWrites = chatWrites.then(() => touchChat(roomDir, chatId, { text: human ? saidText(event.payload) : null, counts: human })).catch(() => null);
1372
+ chatWrites = chatWrites.then(() => touchChat(roomDir, chatId, { text: human && !event.payload?.auto ? saidText(event.payload) : null, counts: human })).catch(() => null);
1128
1373
  });
1129
1374
  unsubscribeActivity = watchActivity(room, chatId);
1130
1375
  sentinel.seed(historicalEvents);
@@ -1221,7 +1466,7 @@ export async function createPulseServer({
1221
1466
  const offTouch = target.subscribe((event) => {
1222
1467
  if (event?.type !== 'message.created' || event.ghost) return;
1223
1468
  const human = event.payload?.role === 'user';
1224
- chatWrites = chatWrites.then(() => touchChat(roomDir, id, { text: human ? saidText(event.payload) : null, counts: human })).catch(() => null);
1469
+ chatWrites = chatWrites.then(() => touchChat(roomDir, id, { text: human && !event.payload?.auto ? saidText(event.payload) : null, counts: human })).catch(() => null);
1225
1470
  });
1226
1471
  const offActivity = watchActivity(target, id);
1227
1472
  const offModules = wireModules(target, id);
@@ -1298,11 +1543,17 @@ export async function createPulseServer({
1298
1543
  async function applyRoomSettings(next, previous) {
1299
1544
  const config = await readConfig(root);
1300
1545
  next.setScopes(config.scopes ?? {});
1301
- next.setAsh(Boolean(config.modules?.ash?.enabled));
1302
- next.setChoices(Boolean(config.modules?.choices?.enabled));
1303
- next.setRuns(Boolean(config.modules?.runs?.enabled));
1546
+ applyModuleSwitches(next, config);
1304
1547
  if (previous) next.configure(previous.settings());
1305
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
+ }
1306
1557
 
1307
1558
  // Opening another conversation. The project keeps its memory, its crew, its modules, its
1308
1559
  // privacy list and its numbering; what changes is the record being shown. With TURNS on, a
@@ -1373,6 +1624,8 @@ export async function createPulseServer({
1373
1624
  const config = await readConfig(root);
1374
1625
  return {
1375
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,
1376
1629
  readConfig: () => readConfig(root),
1377
1630
  updateConfig: (patch) => updateConfig(root, patch),
1378
1631
  record: (type, payload) => room.record(type, payload),
@@ -1552,6 +1805,9 @@ export async function createPulseServer({
1552
1805
  }
1553
1806
  const before = await extension.detect(canonicalProjectRoot);
1554
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 : [];
1555
1811
  installing = id;
1556
1812
  await room.record('extension.install.started', { id, name: extension.name, command: plan.display, platforms: plan.platforms ?? [], alreadyInstalled: before.installed });
1557
1813
  void (async () => {
@@ -1565,17 +1821,19 @@ export async function createPulseServer({
1565
1821
  pending = [];
1566
1822
  await room.record('extension.install.output', { id, lines: batch });
1567
1823
  };
1568
- const result = await runInstaller({
1569
- command: plan.command,
1570
- args: plan.args,
1571
- projectRoot: canonicalProjectRoot,
1572
- onLine: (line) => {
1573
- lines.push(line);
1574
- pending.push(line.slice(0, 500));
1575
- if (pending.length >= 20) void flush();
1576
- else if (!flushTimer) flushTimer = setTimeout(() => { void flush(); }, 400);
1577
- },
1578
- });
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 });
1579
1837
  clearTimeout(flushTimer);
1580
1838
  await flush();
1581
1839
  const after = await extension.detect(canonicalProjectRoot);
@@ -1611,15 +1869,11 @@ export async function createPulseServer({
1611
1869
  response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...PAGE_GUARDS });
1612
1870
  return response.end(html);
1613
1871
  }
1614
- 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'].includes(url.pathname)) {
1615
- const js = await readFile(join(publicDirectory, url.pathname.slice(1)));
1616
- response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
1617
- return response.end(js);
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)) {
1873
+ return sendAsset(request, response, join(publicDirectory, url.pathname.slice(1)), 'text/javascript; charset=utf-8');
1618
1874
  }
1619
1875
  if (request.method === 'GET' && url.pathname === '/styles.css') {
1620
- const css = await readFile(join(publicDirectory, 'styles.css'));
1621
- response.writeHead(200, { 'content-type': 'text/css; charset=utf-8' });
1622
- return response.end(css);
1876
+ return sendAsset(request, response, join(publicDirectory, 'styles.css'), 'text/css; charset=utf-8');
1623
1877
  }
1624
1878
  const abductionAssets = new Map([
1625
1879
  ['/assets/abduction-cat.png', 'abduction-cat.png'],
@@ -1773,9 +2027,7 @@ export async function createPulseServer({
1773
2027
  return response.end(html);
1774
2028
  }
1775
2029
  if (request.method === 'GET' && (url.pathname === '/salas-mac.js' || url.pathname === '/salas-mac.css' || url.pathname === '/macroverso.js')) {
1776
- const file = await readFile(join(publicDirectory, url.pathname.slice(1)));
1777
- response.writeHead(200, { 'content-type': url.pathname.endsWith('.js') ? 'text/javascript; charset=utf-8' : 'text/css; charset=utf-8', 'cache-control': 'no-cache' });
1778
- return response.end(file);
2030
+ return sendAsset(request, response, join(publicDirectory, url.pathname.slice(1)), url.pathname.endsWith('.js') ? 'text/javascript; charset=utf-8' : 'text/css; charset=utf-8');
1779
2031
  }
1780
2032
  // A widget of Home, read here (homeWidget above): only from this Mac's own pages.
1781
2033
  if (request.method === 'GET' && url.pathname === '/api/salas/widget') {
@@ -1833,7 +2085,7 @@ export async function createPulseServer({
1833
2085
  // the room of Salas, whether or not that room exists yet (phase 3d).
1834
2086
  const salas = salasRoomId(root);
1835
2087
  const enlaces = await Promise.all(activeContracts(await readConfig(root), { salas }).map(async (contract) => ({ ...contract, traffic: await linkTraffic(servingRooms(contract).map((id) => join(root, 'rooms', id)), contract) })));
1836
- return sendJson(response, 200, { mac: hostname().replace(/\.local$/i, ''), language: roomLanguage(), here, version: PACKAGE.version ?? null, rooms: seen, handoff: (await readHandoff(root)).mac ?? null, salas, enlaces, phonePreview: await phonePreview() });
2088
+ return sendJson(response, 200, { mac: hostname().replace(/\.local$/i, ''), language: roomLanguage(), languageChosen, here, version: PACKAGE.version ?? null, rooms: seen, handoff: (await readHandoff(root)).mac ?? null, salas, enlaces, phonePreview: await phonePreview() });
1837
2089
  }
1838
2090
  // The open rooms, each in one line (its glance: who works, who waits, its last conversation;
1839
2091
  // never its census or the links), for the room's switcher (2026-10-10), asked every few
@@ -1898,7 +2150,7 @@ export async function createPulseServer({
1898
2150
  // folders (Sec-Fetch-Site) or to add one (Origin), and so is any host name but this Mac's
1899
2151
  // own, so a name pointed at it from outside reaches nothing. Folders are listed inside the
1900
2152
  // home folder only; what is listed is names, never what a file says.
1901
- 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') {
1902
2154
  const host = String(request.headers.host ?? '');
1903
2155
  const localHost = /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host);
1904
2156
  const site = request.headers['sec-fetch-site'];
@@ -1974,6 +2226,21 @@ export async function createPulseServer({
1974
2226
  if (!lobbyDecisions.list) await lobbyDecisions.running;
1975
2227
  return sendJson(response, 200, { decisions: lobbyDecisions.list ?? [], at: lobbyDecisions.at || null });
1976
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
+ }
1977
2244
  if (request.method === 'POST' && url.pathname === '/api/salas/projects') {
1978
2245
  const payload = await body(request).catch(() => ({}));
1979
2246
  const roots = Array.isArray(payload.roots) ? payload.roots.filter((one) => typeof one === 'string').slice(0, 50) : [];
@@ -2008,6 +2275,27 @@ export async function createPulseServer({
2008
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)));
2009
2276
  return sendJson(response, 200, { rooms: rooms.length, stopped: stopped.filter(Boolean).length });
2010
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
+ }
2011
2299
  // This room, asked to close by Salas: what is running stops and the room closes the way a
2012
2300
  // signal closes it (startPulse listens). Nothing here ends the process itself.
2013
2301
  if (request.method === 'POST' && url.pathname === '/api/room/close') {
@@ -2059,6 +2347,89 @@ export async function createPulseServer({
2059
2347
  await noteCrossing(roomDir, { kind: 'read', contract: contract.id, other: asking, count: notes.length, agent: reader });
2060
2348
  return sendJson(response, 200, { contract: contract.id, from: here, notes });
2061
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
+ }
2062
2433
  // The side that asks (fase 3c): a tool of this room's own turn asks this room, and this room
2063
2434
  // asks the room that serves, by the channel. The same three doors as above — never a phone,
2064
2435
  // never a page, only the key — because the tool is a process of this Mac and nothing else is.
@@ -2103,6 +2474,84 @@ export async function createPulseServer({
2103
2474
  if (answer.unreachable) return sendJson(response, 502, { error: t('That project did not answer.') });
2104
2475
  return sendJson(response, answer.status ?? 502, { error: answer.refused ?? t('That project did not answer.') });
2105
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
+ }
2106
2555
  // What another room tells this one by the channel: a link of this room's was signed or
2107
2556
  // revoked. One line in the ledger, never a message; any other type is refused.
2108
2557
  if (request.method === 'POST' && url.pathname === '/api/enlace/notice') {
@@ -2146,7 +2595,12 @@ export async function createPulseServer({
2146
2595
  // «Include always» only from the page (INI-04 of the 1.0 review): its own route refuses a
2147
2596
  // process, and signing let one set it anyway, so a project got another's notes every turn.
2148
2597
  const recall = payload.recall === true && !signed && request.headers.origin !== undefined;
2149
- 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 });
2150
2604
  } catch (error) {
2151
2605
  if (error.code === 'DESIGNATION') return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
2152
2606
  if (error.code === 'ALREADY') return sendJson(response, 409, { error: t(error.message) });
@@ -2161,11 +2615,35 @@ export async function createPulseServer({
2161
2615
  if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/recall') {
2162
2616
  const payload = await body(request).catch(() => ({}));
2163
2617
  if (request.headers.origin === undefined) return sendJson(response, 403, { error: t('This is changed by you, from Home.') });
2164
- if (typeof payload.recall !== 'boolean') return sendJson(response, 422, { error: t('recall is on or off.') });
2618
+ if (typeof payload.recall !== 'boolean') return sendJson(response, 422, { error: t('automatic recall can only be on or off.') });
2165
2619
  const contract = await recallContract(root, String(payload.id ?? ''), payload.recall, { salas: salasRoomId(root) });
2166
2620
  if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
2167
2621
  return sendJson(response, 200, { contract });
2168
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
+ }
2169
2647
  if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/revoke') {
2170
2648
  const payload = await body(request).catch(() => ({}));
2171
2649
  const id = String(payload.id ?? '');
@@ -2173,6 +2651,7 @@ export async function createPulseServer({
2173
2651
  const already = Boolean((await readConfig(root)).enlaces?.[id]?.revoked);
2174
2652
  const contract = await revokeContract(root, id);
2175
2653
  if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
2654
+ await forgetLinkedCorpus(contract);
2176
2655
  if (!already) await tellRooms('enlace.revoked', contract, await knownRooms(root)).catch(() => null);
2177
2656
  return sendJson(response, 200, { contract });
2178
2657
  }
@@ -2257,6 +2736,7 @@ export async function createPulseServer({
2257
2736
  // wins over the config here as it does everywhere else, and reading the file again
2258
2737
  // would hand the page a different answer from the one the server itself is using.
2259
2738
  language: roomLanguage(),
2739
+ languageChosen,
2260
2740
  // Which conversation this page is looking at, and the others it could open.
2261
2741
  chats: await chatsView(),
2262
2742
  agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })),
@@ -2283,7 +2763,7 @@ export async function createPulseServer({
2283
2763
  capabilities: room.capabilities(),
2284
2764
  quotaSources: quotaMonitor.snapshot(),
2285
2765
  budgetWindow: room.budgetWindow(),
2286
- events: await store.readAll(),
2766
+ events: forThePage(await store.readAll()),
2287
2767
  });
2288
2768
  }
2289
2769
  if (request.method === 'GET' && url.pathname === '/api/events') {
@@ -2418,6 +2898,29 @@ export async function createPulseServer({
2418
2898
  await room.record('project.file.saved', { path: relative, bytes: result.bytes });
2419
2899
  return sendJson(response, 200, { ok: true, bytes: result.bytes, mtimeMs: result.mtimeMs });
2420
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
+ }
2421
2924
  // RIPLEY: the project rendered as a site, inside a frame that runs scripts but
2422
2925
  // reaches nothing: an opaque origin (sandbox without same-origin), no network
2423
2926
  // (connect-src none), no forms, no top navigation, assets only from MADRE's
@@ -2477,12 +2980,37 @@ export async function createPulseServer({
2477
2980
  if ((mode !== 3 && mode !== 4) || !agents.some((one) => one.id === agent)) return sendJson(response, 400, { error: t('Unlock #3 or #4 for an agent of this project.') });
2478
2981
  const now = Date.now();
2479
2982
  while (keyMisses.length && keyMisses[0] < now - 60000) keyMisses.shift();
2480
- if (keyMisses.length >= 8) return sendJson(response, 429, { error: t('Too many wrong keys. Wait a minute and try again.') });
2983
+ if (keyMisses.length >= 8) return sendJson(response, 429, { error: t('Too many wrong confirmations. Wait a minute and try again.') });
2481
2984
  if (!keyHolds(overrideConfig, { mode, words: payload.words, designation: basename(canonicalProjectRoot) })) {
2482
2985
  keyMisses.push(now);
2483
2986
  return sendJson(response, 403, { error: t('That is not it. Try again, or cancel.'), denied: true });
2484
2987
  }
2485
- return sendJson(response, 200, issuePass(agent, mode));
2988
+ return sendJson(response, 200, issuePass(agent, mode, Number(payload.minutes) || 0));
2989
+ }
2990
+ // The copilot: what it is now, turning it on with the #3 key for a chosen while, turning it off.
2991
+ if (request.method === 'GET' && url.pathname === '/api/copilot') return sendJson(response, 200, copilotState());
2992
+ if (request.method === 'POST' && url.pathname === '/api/copilot') {
2993
+ if (viaPhone) return sendJson(response, 403, { error: t('The copilot is turned on from the computer, not from the phone.') });
2994
+ const payload = await body(request).catch(() => ({}));
2995
+ const minutes = Number(payload.minutes);
2996
+ if (!COPILOT_WINDOWS.includes(minutes)) return sendJson(response, 400, { error: t('Choose how long the copilot lasts: up to 24 hours.') });
2997
+ const now = Date.now();
2998
+ while (keyMisses.length && keyMisses[0] < now - 60000) keyMisses.shift();
2999
+ if (keyMisses.length >= 8) return sendJson(response, 429, { error: t('Too many wrong confirmations. Wait a minute and try again.') });
3000
+ if (!keyHolds(overrideConfig, { mode: 3, words: payload.words, designation: basename(canonicalProjectRoot) })) {
3001
+ keyMisses.push(now);
3002
+ return sendJson(response, 403, { error: t('That is not it. Try again, or cancel.'), denied: true });
3003
+ }
3004
+ if (copilot) clearTimeout(copilot.timer);
3005
+ const until = now + minutes * 60000;
3006
+ copilot = { since: now, until, minutes, timer: setTimeout(() => { void endCopilot('time'); }, minutes * 60000) };
3007
+ copilot.timer.unref?.();
3008
+ await room.record('copilot.started', { until: new Date(until).toISOString(), minutes });
3009
+ return sendJson(response, 200, copilotState());
3010
+ }
3011
+ if (request.method === 'POST' && url.pathname === '/api/copilot/off') {
3012
+ await endCopilot('human');
3013
+ return sendJson(response, 200, copilotState());
2486
3014
  }
2487
3015
  // This project's switch for the local AI (src/local-ai.mjs). In «auto» Home decides and the
2488
3016
  // switch does not move; /apply is how Home tells an open project the choice changed.
@@ -2513,7 +3041,7 @@ export async function createPulseServer({
2513
3041
  const proof = patch.override.proof;
2514
3042
  delete patch.override.proof;
2515
3043
  if (changeNeedsPhrase(overrideConfig) && !(typeof proof === 'string' && proof.trim() && phraseHash(overrideConfig.phrase.salt, proof) === overrideConfig.phrase.hash)) {
2516
- return sendJson(response, 403, { error: t('Type your current phrase to change the keys.'), needsPhrase: true });
3044
+ return sendJson(response, 403, { error: t('Type your current phrase to change how modes are confirmed.'), needsPhrase: true });
2517
3045
  }
2518
3046
  }
2519
3047
  return sendJson(response, 200, { settings: await applySettings(patch) });
@@ -2549,6 +3077,12 @@ export async function createPulseServer({
2549
3077
  const payload = request.method === 'POST' ? await body(request).catch(() => ({})) : {};
2550
3078
  // `viaPhone` (encargo §6, excepción 4): a module can refuse the phone by its own hand.
2551
3079
  const result = await moduleRoute.route.handler(await moduleContext(), { request, url, params: moduleRoute.params, payload, viaPhone });
3080
+ // A page instead of JSON, for the one place a browser lands on a module's route on its
3081
+ // own: the way back from a sign-in (Connect MCP's OAuth). Never cached.
3082
+ if (typeof result.html === 'string') {
3083
+ response.writeHead(result.status ?? 200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'" });
3084
+ return response.end(result.html);
3085
+ }
2552
3086
  return sendJson(response, result.status ?? 200, result.body ?? {});
2553
3087
  }
2554
3088
  // Where this room's tokens go: what each block of the briefing has cost, how much came back
@@ -2634,6 +3168,7 @@ export async function createPulseServer({
2634
3168
  image: Boolean(modules['image-studio']?.enabled),
2635
3169
  npm: updatesEnabled(),
2636
3170
  github: updatesEnabled(),
3171
+ market: marketOn(),
2637
3172
  reports: Boolean(sentinel.settings().autoReport),
2638
3173
  anthropic: agents.some((agent) => agent.id === 'claude' && agent.ready),
2639
3174
  ollama: Boolean(ollama.running),
@@ -2685,29 +3220,61 @@ export async function createPulseServer({
2685
3220
  const notes = memory ? memory.memories({ limit: 5000 }) : [];
2686
3221
  if (request.method === 'GET') {
2687
3222
  const manifest = await readFile(join(dir, 'manifest.json'), 'utf8').then(JSON.parse).catch(() => null);
2688
- const ready = datasetReadiness(await store.readAll(), notes);
3223
+ const ready = datasetReadiness(await projectEvents(), notes);
2689
3224
  const research = room.memoryResearch();
2690
- 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() });
2691
3226
  }
2692
- const events = await store.readAll();
2693
- const result = await exportDataset({ events, notes, dir, project: basename(canonicalProjectRoot), home: homedir(), privacy });
2694
- 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 })) } : {}) });
2695
3234
  const after = datasetReadiness(events, notes);
2696
3235
  const drawn = room.memoryResearch();
2697
- 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() });
2698
3237
  }
2699
3238
  // PRIVACY: the terms live in config.json only; the ledger records counts, never words.
2700
3239
  // Which language MADRE speaks. The page keeps its own copy so the first paint needs no
2701
3240
  // round trip; this is the durable one, shared by every browser on this machine.
2702
3241
  // The looks every page shares (src/looks.mjs): Home and each project are different origins,
2703
3242
  // so the browser alone kept a choice for one page only.
2704
- 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));
2705
3266
  // This project's own wallpaper («only in this project»), or back to the shared one with null.
2706
3267
  if (request.method === 'POST' && url.pathname === '/api/looks/room') {
2707
3268
  const payload = await body(request).catch(() => ({}));
2708
3269
  const answer = await changeRoomLook(root, basename(roomDir), payload?.wallpaper ?? null);
2709
3270
  return sendJson(response, answer.error ? 400 : 200, answer);
2710
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
+ }
2711
3278
  if (request.method === 'POST' && url.pathname === '/api/looks') {
2712
3279
  const answer = await changeLooks(root, await body(request).catch(() => null));
2713
3280
  return sendJson(response, answer.error ? 400 : 200, answer);
@@ -2716,6 +3283,7 @@ export async function createPulseServer({
2716
3283
  const payload = await body(request).catch(() => ({}));
2717
3284
  const language = payload.language === 'en' ? 'en' : 'es';
2718
3285
  await updateConfig(root, { language });
3286
+ languageChosen = true;
2719
3287
  setRoomLanguage(process.env.PULSE_LANGUAGE ?? language);
2720
3288
  return sendJson(response, 200, { language });
2721
3289
  }
@@ -2883,7 +3451,7 @@ export async function createPulseServer({
2883
3451
  const payload = await body(request).catch(() => ({}));
2884
3452
  if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
2885
3453
  const result = await room.code000({ strikes: Number(payload.strikes) || CODE000_STRIKES });
2886
- return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: t('Security is silent.') });
3454
+ return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: t('The Security module did not respond.') });
2887
3455
  }
2888
3456
  if (request.method === 'GET' && url.pathname === '/api/memory') {
2889
3457
  if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
@@ -3194,10 +3762,11 @@ export async function createPulseServer({
3194
3762
  return sendJson(response, result.status, result.body);
3195
3763
  }
3196
3764
  if (request.method === 'GET' && url.pathname === '/api/models') {
3765
+ // `--verbose` adds each model's metadata, its «variants» (the reasoning rungs) among it.
3197
3766
  const listOpenCode = async (opencode) => {
3198
- const models = [];
3199
- await runInstaller({ command: opencode.path, args: ['models'], projectRoot: canonicalProjectRoot, timeoutMs: 20000, onLine: (line) => { if (/^[\w.-]+\/[\w.:-]+$/.test(line.trim())) models.push(line.trim()); } });
3200
- return models;
3767
+ const lines = [];
3768
+ await runInstaller({ command: opencode.path, args: ['models', '--verbose'], projectRoot: canonicalProjectRoot, timeoutMs: 30000, onLine: (line) => { lines.push(line); } });
3769
+ return parseOpenCodeVerbose(lines);
3201
3770
  };
3202
3771
  return sendJson(response, 200, { models: await discoverModels({ agents, config: await readConfig(root), listOpenCode: url.searchParams.get('opencode') === '1' ? listOpenCode : async () => [] }) });
3203
3772
  }
@@ -3212,12 +3781,15 @@ export async function createPulseServer({
3212
3781
  const hidden = new Set(openCodeHidden(config));
3213
3782
  const signed = new Map((await readOpenCodeProviders({ home: credentialHome })).map((one) => [one.id, one.type]));
3214
3783
  const groups = new Map(groupOpenCode(names).map((group) => [group.id, group]));
3215
- const ids = [...new Set([...OPENCODE_PROVIDERS.map((one) => one.id), ...signed.keys(), ...groups.keys()])];
3784
+ // Every provider a key opens, from OpenCode's own catalogue (credentials.mjs), plus the
3785
+ // ones already signed in or listing models.
3786
+ const keyable = new Map((await openCodeKeyProviders({ home: credentialHome })).map((one) => [one.id, one]));
3787
+ const ids = [...new Set([...keyable.keys(), ...signed.keys(), ...groups.keys()])];
3216
3788
  const providers = ids.map((id) => {
3217
- const known = OPENCODE_PROVIDERS.find((one) => one.id === id);
3789
+ const known = keyable.get(id);
3218
3790
  const own = id === OPENCODE_OWN;
3219
3791
  const via = own ? 'own' : signed.get(id) ?? null;
3220
- return { id, label: providerLabel(id), keyUrl: known?.keyUrl ?? null, takesKey: Boolean(known), via, models: groups.get(id)?.models ?? [], hidden: hidden.has(id) };
3792
+ return { id, label: known?.label ?? providerLabel(id), keyUrl: known?.keyUrl ?? null, takesKey: Boolean(known), featured: Boolean(known?.featured), via, models: groups.get(id)?.models ?? [], hidden: hidden.has(id) };
3221
3793
  }).sort((a, b) => Number(Boolean(b.via)) - Number(Boolean(a.via)) || Number(a.via === 'own') - Number(b.via === 'own') || a.label.localeCompare(b.label));
3222
3794
  return sendJson(response, 200, { providers, default: process.env.PULSE_OPENCODE_MODEL ?? null });
3223
3795
  }
@@ -3267,6 +3839,7 @@ export async function createPulseServer({
3267
3839
  // Every conversation at work, not only the one on screen: the brake is the room's, not the chat's.
3268
3840
  const result = await room.stopAll();
3269
3841
  passes.clear();
3842
+ await endCopilot('stopall');
3270
3843
  for (const held of parked.values()) {
3271
3844
  const more = await held.room.stopAll().catch(() => ({ plans: 0, turns: 0 }));
3272
3845
  result.plans += more.plans ?? 0;
@@ -3298,9 +3871,18 @@ export async function createPulseServer({
3298
3871
  if (typeof payload.responseMessageId !== 'string' || !Number.isInteger(index) || index < 0) return sendJson(response, 400, { error: t('Name the request and the line to run.') });
3299
3872
  const here = await scopeOf(url);
3300
3873
  if (here.error) return sendJson(response, 404, here);
3301
- const outcome = await here.room.runRequested({ responseMessageId: payload.responseMessageId, index });
3874
+ const outcome = await here.room.runRequested({ responseMessageId: payload.responseMessageId, index, returnCap: viaPhone ? PHONE_MAX_MODE : 3 });
3302
3875
  return sendJson(response, outcome.status, outcome.ok ? outcome.result : { error: outcome.error });
3303
3876
  }
3877
+ // The human ran some lines of a request and hands back what there is, without the rest.
3878
+ if (request.method === 'POST' && url.pathname === '/api/runs/return') {
3879
+ const payload = await body(request).catch(() => ({}));
3880
+ if (typeof payload.responseMessageId !== 'string') return sendJson(response, 400, { error: t('Say which request to hand back.') });
3881
+ const here = await scopeOf(url);
3882
+ if (here.error) return sendJson(response, 404, here);
3883
+ const outcome = await here.room.returnRun({ responseMessageId: payload.responseMessageId, returnCap: viaPhone ? PHONE_MAX_MODE : 3 });
3884
+ return sendJson(response, outcome.status, outcome.ok ? outcome : { error: outcome.error });
3885
+ }
3304
3886
  if (request.method === 'GET' && url.pathname === '/api/commands') {
3305
3887
  const ctx = await moduleContext();
3306
3888
  const fromModules = await Promise.all(moduleCommands().map(async (command) => {
@@ -3345,6 +3927,76 @@ export async function createPulseServer({
3345
3927
  void warmModuleUpdates(extensions);
3346
3928
  return sendJson(response, 200, { installing, extensions, failures: loadFailures, folders: moduleFolders({ stateRoot: root, projectRoot: canonicalProjectRoot }), sdk: SDK_PAGE[roomLanguage()] ?? SDK_PAGE.es });
3347
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
+ }
3348
4000
  // The human installs a module file an agent wrote (or they did): checked first, then copied into the chosen folder.
3349
4001
  if (request.method === 'POST' && url.pathname === '/api/extensions/install-file') {
3350
4002
  const payload = await body(request).catch(() => ({}));
@@ -3360,6 +4012,7 @@ export async function createPulseServer({
3360
4012
  if (removeMatch) {
3361
4013
  try {
3362
4014
  const removed = await removeExternalModule({ id: removeMatch[1], stateRoot: root, projectRoot: canonicalProjectRoot });
4015
+ await forgetFromMarket(root, removed.id).catch(() => false);
3363
4016
  await room.record('extension.removed', { id: removed.id, name: removed.name, by: 'you' });
3364
4017
  return sendJson(response, 200, { removed, extensions: await describeModules(await moduleContext()) });
3365
4018
  } catch (error) { return sendJson(response, moduleById(removeMatch[1]) ? 403 : 404, { error: error.message }); }
@@ -3389,6 +4042,19 @@ export async function createPulseServer({
3389
4042
  const result = await module.setControl(await moduleContext(), await body(request));
3390
4043
  return sendJson(response, result.status, result.body);
3391
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
+ }
3392
4058
  const installMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/install$/);
3393
4059
  if (installMatch) {
3394
4060
  // A phone switches MADRE's own modules; installing one runs commands in the project.
@@ -3412,7 +4078,7 @@ export async function createPulseServer({
3412
4078
  if (!asked) return sendJson(response, 409, { error: t('That request is no longer pending.') });
3413
4079
  if (decision !== 'deny' && asked.mode > PHONE_MAX_MODE) {
3414
4080
  if (viaPhone) return sendJson(response, 403, { error: t('#3 EDIT and #4 FULL ACCESS are opened from the Mac.') });
3415
- if (!spendPass(key, asked.agent, asked.mode)) return sendJson(response, 403, { error: t('#{n} needs its key. Unlock it again and send.', { n: asked.mode }), needsKey: true });
4081
+ if (!spendPass(key, asked.agent, asked.mode)) return sendJson(response, 403, { error: t('#{n} needs your confirmation. Unlock it again and send.', { n: asked.mode }), needsKey: true });
3416
4082
  }
3417
4083
  const result = here.room.decideMode(requestId, decision);
3418
4084
  return sendJson(response, result.ok ? 200 : 409, result);
@@ -3431,13 +4097,23 @@ export async function createPulseServer({
3431
4097
  // like any other: the conversation on screen is one of the "others" when it is working.
3432
4098
  const here = await scopeOf(url);
3433
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));
3434
4102
  const room = here.room;
4103
+ // A copilot message at #3 goes with the copilot's window instead of a pass; from this
4104
+ // computer only. With the window closed it is said, and the page asks for the key again.
4105
+ if (payload.copilot === true && (viaPhone || !copilotNow())) {
4106
+ if (!viaPhone) return sendJson(response, 409, { error: t('The copilot is off: it ended or was stopped. Turn it on again, or send and confirm #3.'), copilotOff: true });
4107
+ delete payload.copilot;
4108
+ }
3435
4109
  // Modes are checked before the turn is accepted, so the composer
3436
4110
  // hears "no" with a reason instead of a silent log line.
3437
4111
  const gate = await room.modeCheck(payload);
3438
4112
  if (!gate.ok) return sendJson(response, gate.status ?? 403, { error: gate.error, mode: gate.mode, maxMode: gate.maxMode });
4113
+ const byCopilot = payload.copilot === true && gate.mode === 3;
4114
+ if (!byCopilot) delete payload.copilot;
3439
4115
  // #3 and #4 go with the pass their key gave, for this agent (above).
3440
- if (gate.mode >= 3 && !spendPass(payload.key, gate.agent, gate.mode)) return sendJson(response, 403, { error: t('#{n} needs its key. Unlock it again and send.', { n: gate.mode }), mode: gate.mode, needsKey: true });
4116
+ if (gate.mode >= 3 && !byCopilot && !spendPass(payload.key, gate.agent, gate.mode)) return sendJson(response, 403, { error: t('#{n} needs your confirmation. Unlock it again and send.', { n: gate.mode }), mode: gate.mode, needsKey: true });
3441
4117
  delete payload.key;
3442
4118
  const turns = await turnsPolicy();
3443
4119
  if (turns.enabled) {