@jossuealcala/madre 0.7.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +833 -1
  2. package/README.md +92 -28
  3. package/SECURITY.md +1 -0
  4. package/bin/madre.mjs +30 -10
  5. package/docs/REFERENCE.md +158 -79
  6. package/docs/SDK.md +23 -19
  7. package/package.json +1 -1
  8. package/public/app.js +11599 -4186
  9. package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
  10. package/public/assets/brand/Geist-latin.woff2 +0 -0
  11. package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
  12. package/public/assets/brand/GeistMono-latin.woff2 +0 -0
  13. package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
  14. package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
  15. package/public/assets/brand/OFL-Geist.txt +93 -0
  16. package/public/assets/brand/OFL-GeistMono.txt +93 -0
  17. package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
  18. package/public/assets/brand/OFL-Sora.txt +93 -0
  19. package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
  20. package/public/assets/brand/Sora-latin.woff2 +0 -0
  21. package/public/assets/brand/madre-app-180.png +0 -0
  22. package/public/assets/brand/madre-app-192.png +0 -0
  23. package/public/assets/brand/madre-app-512.png +0 -0
  24. package/public/assets/brand/madre-app.svg +1 -0
  25. package/public/assets/brand/madre-favicon.svg +1 -0
  26. package/public/assets/news/1.0/alerts.webp +0 -0
  27. package/public/assets/news/1.0/bar.webp +0 -0
  28. package/public/assets/news/1.0/finder.webp +0 -0
  29. package/public/assets/news/1.0/github.webp +0 -0
  30. package/public/assets/news/1.0/intents.webp +0 -0
  31. package/public/assets/news/1.0/looks.webp +0 -0
  32. package/public/assets/news/1.0/modules.webp +0 -0
  33. package/public/assets/news/1.0/mother.webp +0 -0
  34. package/public/assets/news/1.0/nostromo.webp +0 -0
  35. package/public/assets/news/1.0/permissions.webp +0 -0
  36. package/public/assets/news/1.0/room.webp +0 -0
  37. package/public/assets/news/1.0/settings.webp +0 -0
  38. package/public/assets/wallpapers/biznaga.webp +0 -0
  39. package/public/assets/wallpapers/canela.webp +0 -0
  40. package/public/assets/wallpapers/jarrito.webp +0 -0
  41. package/public/assets/wallpapers/magna.webp +0 -0
  42. package/public/assets/wallpapers/oceano.webp +0 -0
  43. package/public/assets/wallpapers/paraiso.webp +0 -0
  44. package/public/assets/wallpapers/quila.webp +0 -0
  45. package/public/assets/wallpapers/tradicion.webp +0 -0
  46. package/public/assets/wallpapers/tropical.webp +0 -0
  47. package/public/assets/wallpapers/urbania.webp +0 -0
  48. package/public/assets/wallpapers/ventura.webp +0 -0
  49. package/public/assets/wallpapers/xico.webp +0 -0
  50. package/public/brands.js +28 -2
  51. package/public/editor.js +744 -0
  52. package/public/es.js +2863 -1008
  53. package/public/i18n.js +1 -1
  54. package/public/index.html +201 -175
  55. package/public/inquiry.js +11 -11
  56. package/public/loader.js +79 -0
  57. package/public/macroverso.js +1497 -0
  58. package/public/manifest.webmanifest +14 -0
  59. package/public/nostromo-engine.js +2222 -0
  60. package/public/resay.js +24 -0
  61. package/public/review-ask.js +85 -0
  62. package/public/salas-mac.css +787 -0
  63. package/public/salas-mac.html +154 -0
  64. package/public/salas-mac.js +3264 -0
  65. package/public/salas.css +85 -0
  66. package/public/salas.html +47 -0
  67. package/public/salas.js +200 -0
  68. package/public/styles.css +6303 -414
  69. package/public/touch.js +83 -0
  70. package/public/tour-chapters.js +342 -0
  71. package/public/tour.js +510 -0
  72. package/public/troubleshooting.js +159 -140
  73. package/src/adapters/claude.mjs +4 -3
  74. package/src/adapters/codex.mjs +49 -8
  75. package/src/adapters/gemini.mjs +30 -10
  76. package/src/adapters/madre.mjs +110 -18
  77. package/src/adapters/process.mjs +42 -2
  78. package/src/auth-probe.mjs +2 -2
  79. package/src/bridge/devices.mjs +202 -0
  80. package/src/bridge/handoff.mjs +45 -0
  81. package/src/bridge/host.mjs +82 -0
  82. package/src/bridge/network.mjs +133 -0
  83. package/src/bridge/policy.mjs +89 -0
  84. package/src/bridge/qr.mjs +287 -0
  85. package/src/bridge/server.mjs +269 -0
  86. package/src/capabilities.mjs +5 -9
  87. package/src/chats.mjs +59 -22
  88. package/src/checkpoint.mjs +137 -35
  89. package/src/choices.mjs +45 -6
  90. package/src/cli-args.mjs +12 -1
  91. package/src/commands.mjs +15 -15
  92. package/src/config.mjs +73 -9
  93. package/src/conversation-context.mjs +5 -2
  94. package/src/dataset.mjs +3 -1
  95. package/src/directives.mjs +0 -5
  96. package/src/enlaces.mjs +451 -0
  97. package/src/event-store.mjs +55 -10
  98. package/src/exam.mjs +5 -5
  99. package/src/extensions.mjs +0 -2
  100. package/src/file-ops.mjs +42 -0
  101. package/src/files.mjs +52 -2
  102. package/src/github.mjs +407 -0
  103. package/src/json-file.mjs +63 -0
  104. package/src/lease.mjs +2 -2
  105. package/src/lobby-work.mjs +103 -0
  106. package/src/looks.mjs +55 -0
  107. package/src/maturity.mjs +3 -3
  108. package/src/mcp/github-server.mjs +164 -0
  109. package/src/mcp/image-server.mjs +1 -1
  110. package/src/mcp/memory-server.mjs +11 -3
  111. package/src/memory.mjs +38 -12
  112. package/src/models.mjs +109 -1
  113. package/src/modules/ahp.mjs +4 -4
  114. package/src/modules/ash.mjs +1 -1
  115. package/src/modules/bishop-plan.mjs +207 -0
  116. package/src/modules/bishop.mjs +645 -0
  117. package/src/modules/choices.mjs +2 -2
  118. package/src/modules/correo.mjs +1 -1
  119. package/src/modules/enlaces.mjs +255 -0
  120. package/src/modules/git-pulse.mjs +2 -2
  121. package/src/modules/github.mjs +322 -0
  122. package/src/modules/image-studio.mjs +3 -3
  123. package/src/modules/index.mjs +19 -4
  124. package/src/modules/intents-calibration.mjs +338 -0
  125. package/src/modules/intents-desk.mjs +481 -0
  126. package/src/modules/intents-observe.mjs +266 -0
  127. package/src/modules/intents-policy.mjs +414 -0
  128. package/src/modules/intents-try.mjs +91 -0
  129. package/src/modules/intents.mjs +145 -0
  130. package/src/modules/ollama.mjs +9 -5
  131. package/src/modules/playwright.mjs +121 -11
  132. package/src/modules/ripley.mjs +1 -1
  133. package/src/modules/runs.mjs +34 -0
  134. package/src/modules/sdk.mjs +10 -4
  135. package/src/ollama.mjs +25 -1
  136. package/src/outbound.mjs +19 -17
  137. package/src/override.mjs +100 -0
  138. package/src/ports.mjs +23 -1
  139. package/src/prices.mjs +72 -0
  140. package/src/privacy.mjs +30 -11
  141. package/src/quota-sources.mjs +0 -8
  142. package/src/request-guard.mjs +1 -1
  143. package/src/review-ask.mjs +111 -0
  144. package/src/room/archivist.mjs +55 -37
  145. package/src/room/budget.mjs +17 -5
  146. package/src/room/control.mjs +88 -8
  147. package/src/room/economy.mjs +35 -7
  148. package/src/room/escalation.mjs +2 -0
  149. package/src/room/guard.mjs +26 -2
  150. package/src/room/prompt.mjs +32 -13
  151. package/src/room/runs.mjs +151 -0
  152. package/src/room/vectors.mjs +19 -4
  153. package/src/room.mjs +402 -54
  154. package/src/router.mjs +3 -1
  155. package/src/runs.mjs +137 -0
  156. package/src/salas.mjs +313 -0
  157. package/src/sentinel-errors.mjs +1 -1
  158. package/src/server-sentinel.mjs +124 -0
  159. package/src/server.mjs +1578 -167
  160. package/src/setup.mjs +1 -0
  161. package/src/tour.mjs +159 -0
  162. package/src/updates.mjs +48 -8
  163. package/src/usage-sentinel.mjs +1 -1
  164. package/src/verdict.mjs +7 -7
  165. package/src/whats-new.mjs +160 -0
package/src/server.mjs CHANGED
@@ -1,36 +1,49 @@
1
1
  import http from 'node:http';
2
- import { readFile, realpath, writeFile, mkdir, rm, cp, rename, access } from 'node:fs/promises';
3
- import { rmSync } from 'node:fs';
4
- import { createHash } from 'node:crypto';
5
- import { classifyListening, probePort, framable } from './ports.mjs';
2
+ import { readFile, realpath, writeFile, mkdir, cp, rename, access, stat } from 'node:fs/promises';
3
+ import { existsSync, openSync, rmSync } from 'node:fs';
4
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
5
+ import { probePort, framable } from './ports.mjs';
6
+ import { ControlDesk } from './room/control.mjs';
6
7
  import { basename, dirname, join, resolve } from 'node:path';
7
8
  import { fileURLToPath } from 'node:url';
8
- import { homedir } from 'node:os';
9
+ import { homedir, hostname } from 'node:os';
9
10
  import { execFile } from 'node:child_process';
10
11
  import { promisify } from 'node:util';
11
12
 
12
13
  const execFileAsync = promisify(execFile);
13
14
  import { detectAgents, toolsPrefix } from './runtime-detection.mjs';
14
15
  import { EventStore } from './event-store.mjs';
15
- import { RoomMemory } from './memory.mjs';
16
+ import { RoomMemory, travels as memoryTravels } from './memory.mjs';
16
17
  import { createEmbedder } from './embeddings.mjs';
17
18
  import { memoryServerFor } from './memory-tools.mjs';
18
19
  import { MotherChannel, CODE000_STRIKES } from './mother.mjs';
19
20
  import { ErrorSentinel } from './sentinel-errors.mjs';
20
- import { probeOllama, ollamaEmbedder, ollamaInvoker, pullModel } from './ollama.mjs';
21
- import { moduleById, describeModules, findModuleRoute, toolsForTurn as modulesToolsForTurn, loadExternalModules, loadFailures, moduleFolders, moduleCommands, installModuleFile, installModuleText, verifyModuleText, moduleOrigin, removeExternalModule } from './modules/index.mjs';
21
+ import { probeOllama, ollamaEmbedder, ollamaGenerate, ollamaInvoker, pullModel, ollamaVersions, ownOllamaServers } from './ollama.mjs';
22
+ import { recallForTurn } from './modules/enlaces.mjs';
23
+ import { MODULES, moduleById, describeModules, findModuleRoute, toolsForTurn as modulesToolsForTurn, loadExternalModules, loadFailures, moduleFolders, moduleCommands, installModuleFile, installModuleText, verifyModuleText, moduleOrigin, removeExternalModule } from './modules/index.mjs';
22
24
  import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
23
25
  import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
24
26
  import { OutboundLog, outboundView, DEFAULT_REPORT_URL } from './outbound.mjs';
25
27
  import { language as roomLanguage, setLanguage as setRoomLanguage, t } from './i18n.mjs';
26
28
 
29
+ // One per start of this process: a restart is a new one, a reload of the page is not.
30
+ const BOOT_ID = `${process.pid}-${Date.now().toString(36)}`;
27
31
  const PACKAGE = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8').catch(() => '{}'));
32
+ // The phone is not in the public MADRE until it is stable (2026-10-12): it is offered only on a copy
33
+ // run from its source, where the repository's `.git` sits beside `src/`. What npm installs never
34
+ // carries a `.git` (`files` in package.json), so there no setting and no variable opens it.
35
+ const FROM_SOURCE = existsSync(new URL('../.git', import.meta.url));
28
36
  let crashHandlersInstalled = false;
29
37
 
30
38
  // RIPLEY's bridge, injected into every page it renders: reports the page's path
31
39
  // and title to the viewer and forwards runtime errors and failed resources.
32
40
  // It talks only upward through postMessage; the frame stays an opaque origin.
33
- export const RIPLEY_BRIDGE = '<script data-ripley>(function(){var p=location.pathname;function send(m){try{parent.postMessage(Object.assign({ripley:1},m),"*")}catch(e){}}send({type:"page",path:p,title:document.title});addEventListener("DOMContentLoaded",function(){send({type:"page",path:p,title:document.title})});addEventListener("error",function(e){if(e.target&&e.target!==window&&!e.message){var u=e.target.src||e.target.href||"";send({type:"error",message:"Failed to load "+(u||e.target.tagName.toLowerCase()),source:u,line:0});return}send({type:"error",message:String(e.message||"error"),source:e.filename||"",line:e.lineno||0})},true);addEventListener("unhandledrejection",function(e){var r=e.reason;send({type:"error",message:"Unhandled promise rejection: "+String(r&&r.message||r),source:"",line:0})})})();</script>';
41
+ // The bridge also says, once per page, that the page reached for its storage: the sealed frame
42
+ // has an opaque origin, so localStorage, sessionStorage, IndexedDB and cookies throw or forget, and
43
+ // an app that keeps its data there cannot be tried in here. The access still fails exactly as it
44
+ // did; the viewer is only told, so it can say where the page can be tried with its data. Window is
45
+ // the global interface: browsers put its storage getters on window itself, not on its prototype.
46
+ export const RIPLEY_BRIDGE = '<script data-ripley>(function(){var p=location.pathname;function send(m){try{parent.postMessage(Object.assign({ripley:1},m),"*")}catch(e){}}send({type:"page",path:p,title:document.title});addEventListener("DOMContentLoaded",function(){send({type:"page",path:p,title:document.title})});addEventListener("error",function(e){if(e.target&&e.target!==window&&!e.message){var u=e.target.src||e.target.href||"";send({type:"error",message:"Failed to load "+(u||e.target.tagName.toLowerCase()),source:u,line:0});return}send({type:"error",message:String(e.message||"error"),source:e.filename||"",line:e.lineno||0})},true);addEventListener("unhandledrejection",function(e){var r=e.reason;send({type:"error",message:"Unhandled promise rejection: "+String(r&&r.message||r),source:"",line:0})});var told=0;function storage(n){if(!told){told=1;send({type:"storage",what:n})}}["localStorage","sessionStorage","indexedDB"].forEach(function(n){var d=Object.getOwnPropertyDescriptor(window,n)||Object.getOwnPropertyDescriptor(Window.prototype,n);if(!d||!d.get)return;try{Object.defineProperty(window,n,{configurable:true,get:function(){storage(n);return d.get.call(window)}})}catch(e){}});var c=Object.getOwnPropertyDescriptor(Document.prototype,"cookie");if(c&&c.get&&c.set){try{Object.defineProperty(document,"cookie",{configurable:true,get:function(){storage("cookie");return c.get.call(document)},set:function(v){storage("cookie");c.set.call(document,v)}})}catch(e){}}})();</script>';
34
47
  export function injectRipleyBridge(body) {
35
48
  const text = Buffer.isBuffer(body) ? body.toString('utf8') : String(body);
36
49
  if (text.includes('data-ripley')) return Buffer.from(text);
@@ -44,34 +57,50 @@ export function injectRipleyBridge(body) {
44
57
  import { QuotaMonitor } from './quota-monitor.mjs';
45
58
  import { defaultQuotaSources } from './quota-sources.mjs';
46
59
  import { Room } from './room.mjs';
47
- import { applyConfigToEnv, loadConfig } from './config.mjs';
60
+ import { changeNeedsPhrase, keyHolds, mergeOverride, overrideSettings, phraseHash, publicOverride } from './override.mjs';
48
61
  import { extensionById, findOnPath, runInstaller } from './extensions.mjs';
49
62
  import { accountNoteFor, installPlanFor, looksLikeAdminProblem, loginPlanFor, probeAgentAuth, probeAll, TAKES_KEY } from './auth-probe.mjs';
50
63
  import { applyKey, keyPlanFor } from './credentials.mjs';
51
64
  import { forgetSecret, summary as vaultSummary, writeSecret } from './vault.mjs';
52
- import { loadConfig as readConfig, updateConfig } from './config.mjs';
65
+ import { applyConfigToEnv, loadConfig as readConfig, updateConfig } from './config.mjs';
53
66
  import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
54
- import { checkForUpdate, detectInstall, updateCommand, releaseUrl, applyCommand } from './updates.mjs';
55
- import { bootWhatsNew, markSeen, notesFor, whatsNew } from './whats-new.mjs';
67
+ import { checkForUpdate, detectInstall, updateCommand, releaseUrl, applyCommand, fetchCommand } from './updates.mjs';
68
+ import { bootWhatsNew, markSeen, newestNotes, notesFor, whatsNew } from './whats-new.mjs';
69
+ import { answerAsk, tickAsk } from './review-ask.mjs';
70
+ import { CHAPTERS, HINTS, TOUR_VERSION, cameBefore, changeTour, readTour, recoverProject, roomsWithoutProject } from './tour.mjs';
56
71
 
57
72
  // Where a person reads the module contract, in the language the room is speaking.
58
73
  const SDK_PAGE = { es: 'https://madre.run/sdk/', en: 'https://madre.run/en/sdk/' };
59
74
  import { moduleUpdate } from './modules/updates.mjs';
60
- import { adoptStrays, chatIndex, chatLedger, createChat, deleteChat, isChatId, listChats, openChat as openChatIndex, projectFloor, renameChat, touchChat, MAIN_CHAT } from './chats.mjs';
75
+ import { adoptStrays, chatIndex, chatLedger, createChat, deleteChat, isChatId, listChats, openChat as openChatIndex, projectFloor, renameChat, touchChat } from './chats.mjs';
61
76
  import { spawn } from 'node:child_process';
62
77
  import { totalmem } from 'node:os';
63
78
  import { discoverModels } from './models.mjs';
64
- import { setImageModule } from './capabilities.mjs';
79
+ import { observable } from './modules/intents-observe.mjs';
80
+ import { normalizeMode, setImageModule } from './capabilities.mjs';
65
81
  import { resolveGeminiKey } from './image-studio.mjs';
66
82
  import { commandByName, listCommands, parseCommand } from './commands.mjs';
67
- import { listDirectory, searchFiles, readServable, storeAttachment, resolveInside, MAX_ATTACHMENT_BYTES } from './files.mjs';
68
- import { planFileOp, CREATIONS } from './file-ops.mjs';
83
+ import { listDirectory, searchFiles, readServable, storeAttachment, resolveInside, createEmptyFile, writeEdited, MAX_ATTACHMENT_BYTES, MAX_EDIT_BYTES } from './files.mjs';
84
+ import { planFileOp, freeCopyPath, needsDesignationOnDisk, CREATIONS } from './file-ops.mjs';
85
+ import { ServerSentinel } from './server-sentinel.mjs';
69
86
  import { Eyecat } from './eyecat-watch.mjs';
70
87
  import { economy } from './room/economy.mjs';
71
88
  import { maturity } from './maturity.mjs';
72
89
  import { memoryDashboard } from './memory-dashboard.mjs';
73
90
  import { verdictFor } from './verdict.mjs';
74
91
  import { refuseForeign } from './request-guard.mjs';
92
+ import { restoreGuard } from './room/guard.mjs';
93
+ import { bridgeAnswers, hostBridge } from './bridge/host.mjs';
94
+ import { BRIDGE_PORT } from './bridge/server.mjs';
95
+ import { createInvite, forgetDevice, forgetNetwork, readStore, setEnabled, storeView, trustNetwork } from './bridge/devices.mjs';
96
+ import { currentNetwork, lanAddresses } from './bridge/network.mjs';
97
+ import { PHONE_MAX_MODE, writtenMode } from './bridge/policy.mjs';
98
+ import { qrSvg } from './bridge/qr.mjs';
99
+ import { readHandoff, writeHandoff } from './bridge/handoff.mjs';
100
+ import { changeLooks, readLooks } from './looks.mjs';
101
+ import { askRoom, associateProject, ensureSalasProject, forgetProject, isSalasRoom, knownRooms, listFolders, markProject, projectRoomId, roomComesUp, salasRoomId, startRoom, suggestProjects, universeOf } from './salas.mjs';
102
+ import { parseWork, recentWork, WORK_SYSTEM, workPrompt } from './lobby-work.mjs';
103
+ 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';
75
104
 
76
105
  const sourceDirectory = dirname(fileURLToPath(import.meta.url));
77
106
  const publicDirectory = join(sourceDirectory, '..', 'public');
@@ -81,13 +110,18 @@ function sendJson(response, status, value) {
81
110
  response.end(JSON.stringify(value));
82
111
  }
83
112
 
84
- async function body(request) {
85
- let text = '';
113
+ // A request's JSON, read whole before it is decoded (2026-10-12, SRV-08): a «ñ» split between two
114
+ // packets came out as «��». The limit is in bytes; a route that takes more says so (the editor).
115
+ const BODY_LIMIT = 100_000;
116
+ async function body(request, { limit = BODY_LIMIT } = {}) {
117
+ const chunks = [];
118
+ let size = 0;
86
119
  for await (const chunk of request) {
87
- text += chunk;
88
- if (text.length > 100_000) throw new Error('Request too large.');
120
+ size += chunk.length;
121
+ if (size > limit) throw Object.assign(new Error('Request too large.'), { status: 413 });
122
+ chunks.push(chunk);
89
123
  }
90
- return JSON.parse(text || '{}');
124
+ return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
91
125
  }
92
126
 
93
127
  export function openUrl(url) {
@@ -109,11 +143,20 @@ export function agentTimeoutsFromEnv(env = process.env, agentIds = ['codex', 'cl
109
143
  return timeouts;
110
144
  }
111
145
 
112
- export function projectRoomId(projectRoot) {
113
- const path = resolve(projectRoot);
114
- const digest = createHash('sha256').update(path).digest('hex').slice(0, 16);
115
- return `${basename(path) || 'root'}-${digest}`;
116
- }
146
+ // The id of a room lives beside the rest of what Salas knows of rooms (src/salas.mjs); it is still
147
+ // read from here by everything that always did.
148
+ export { projectRoomId };
149
+
150
+ // What the pages of a project and of Home are sent with (2026-10-12, SRV-04 and WEB-01 of the 1.0
151
+ // review). Only this same page may frame them — the split view frames `/?pane=split` — so another
152
+ // page cannot lay them under its own buttons and borrow a click on RUN or STOP ALL. And images come
153
+ // only from here: a picture in an answer that pointed elsewhere was fetched the moment it was
154
+ // drawn, with whatever its address carried, and never counted as something that left the Mac.
155
+ const PAGE_GUARDS = {
156
+ 'x-frame-options': 'SAMEORIGIN',
157
+ 'content-security-policy': "frame-ancestors 'self'; img-src 'self' data: blob:",
158
+ 'x-content-type-options': 'nosniff',
159
+ };
117
160
 
118
161
  export async function createPulseServer({
119
162
  projectRoot,
@@ -127,6 +170,8 @@ export async function createPulseServer({
127
170
  quotaSources = [],
128
171
  quotaPollIntervalMs = Number(process.env.PULSE_QUOTA_POLL_INTERVAL_MS ?? 60000),
129
172
  broadcastIntervalMs = Number(process.env.PULSE_BROADCAST_INTERVAL_MS ?? 500),
173
+ // How long a conversation opened beside the one on screen waits for a page to start watching it.
174
+ heldGraceMs = 30000,
130
175
  sseMaxBufferedBytes = Number(process.env.PULSE_SSE_MAX_BUFFERED_BYTES ?? 1_048_576),
131
176
  agentTimeouts,
132
177
  maxMessageChars = Number(process.env.PULSE_MAX_MESSAGE_CHARS ?? 20000),
@@ -153,7 +198,7 @@ export async function createPulseServer({
153
198
  // an update has news to show them, or this is a first install with nothing to catch up on.
154
199
  const novelty = await bootWhatsNew({ root, version: PACKAGE.version }).catch(() => ({ returning: false }));
155
200
  // ~/.pulse/config.json fills in whatever the environment did not set.
156
- applyConfigToEnv(await loadConfig(root));
201
+ applyConfigToEnv(await readConfig(root));
157
202
  // What language the screens speak. The prompt the agents read is not one of them, and the
158
203
  // environment wins over the file, as it does for every other setting here.
159
204
  setRoomLanguage(process.env.PULSE_LANGUAGE ?? (await readConfig(root)).language);
@@ -164,6 +209,9 @@ export async function createPulseServer({
164
209
  const agents = providedAgents ?? await detector();
165
210
  const canonicalProjectRoot = await realpath(projectRoot).catch(() => resolve(projectRoot));
166
211
  const roomDir = join(root, 'rooms', projectRoomId(canonicalProjectRoot));
212
+ // Whether this is the room of Salas (docs/MACROVERSO.md, fase 3d): the room whose project is
213
+ // the notebook of Salas, decided once from real paths; the page carries the MACROVERSO key there.
214
+ const salasRoom = isSalasRoom(canonicalProjectRoot, root);
167
215
  // What left this machine. Every fetch this process makes — MADRE's own and any a module makes,
168
216
  // because a module runs in here and cannot opt out — goes through one wrapper and lands in a
169
217
  // line beside the ledger. Bodies, headers and query values are never in it. One room per
@@ -184,20 +232,78 @@ export async function createPulseServer({
184
232
  const openMark = join(roomDir, 'open.json');
185
233
  const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
186
234
  const held = await readFile(openMark, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
187
- if (held && Number.isInteger(held.pid) && held.pid !== process.pid && alive(held.pid)) {
235
+ // A live process is not enough (2026-10-12, LOG-13): after a restart of the Mac the number of the
236
+ // room that died is often some other program's, and the project said «already open» for good. It
237
+ // is taken only if that room also answers on its port.
238
+ const answers = async (port) => Number.isInteger(port) && await fetch(`http://127.0.0.1:${port}/api/version`, { signal: AbortSignal.timeout(1500) }).then((response) => response.ok, () => false);
239
+ if (held && Number.isInteger(held.pid) && held.pid !== process.pid && alive(held.pid) && await answers(held.port)) {
188
240
  const error = new Error(`MADRE is already open for this project at http://127.0.0.1:${held.port} (process ${held.pid}). Close that room first, or open another conversation inside it.`);
189
241
  error.code = 'ROOM_IN_USE';
190
242
  throw error;
191
243
  }
192
- const markOpen = async (port) => { await mkdir(roomDir, { recursive: true }).catch(() => {}); await writeFile(openMark, JSON.stringify({ pid: process.pid, port, at: new Date().toISOString() })).catch(() => {}); };
244
+ // The room is this one's now. A room that died in the middle of a turn left the project's .env
245
+ // files and MADRE's folders read-only (room/guard.mjs): they get their modes back first.
246
+ const unlocked = await restoreGuard(join(roomDir, 'guard.json')).catch(() => []);
247
+ if (unlocked.length) console.error(`MADRE: a room that closed mid-turn had left ${unlocked.length} protected path(s) read-only; they are writable again.`);
248
+ // The mark also says which project and which MADRE, for the bridge's list of rooms on the phone.
249
+ let openPort = null;
250
+ // Where the project is, too (src/salas.mjs): a room that is closed can be opened again from Salas.
251
+ const markOpen = async (port) => { openPort = port; await mkdir(roomDir, { recursive: true }).catch(() => {}); await writeFile(openMark, JSON.stringify({ pid: process.pid, port, at: new Date().toISOString(), project: basename(canonicalProjectRoot), version: PACKAGE.version ?? null })).catch(() => {}); await markProject(roomDir, canonicalProjectRoot).catch(() => {}); };
252
+ // The phone (docs/MOBILE.md) is not offered in MADRE 1.0 (2026-10-11, hidden 2026-10-12): Safari
253
+ // in HTTPS-only mode refuses the bridge's http, and the relay that fixes it is not built. Only a
254
+ // copy run from source (FROM_SOURCE, above) on a Mac that turned it on — `phone.preview` in
255
+ // config.json, or PULSE_PHONE=1 — has it. Everywhere else nothing says it exists: no key on the
256
+ // bar, no place in Settings, no chapter in the tour, and the bridge never opens.
257
+ const phonePreview = async () => FROM_SOURCE && (process.env.PULSE_PHONE === '1' || (await readConfig(root).catch(() => ({}))).phone?.preview === true);
258
+ // This room offers to host the bridge once it is open; see bridge/host.mjs.
259
+ const phone = hostBridge({ root, publicDirectory, allowed: phonePreview });
260
+ async function phoneView() {
261
+ const store = await readStore(root);
262
+ const here = await currentNetwork().catch(() => null);
263
+ return {
264
+ available: Boolean(here),
265
+ network: here ? { id: here.id, gateway: here.gateway, trusted: store.networks.some((one) => one.id === here.id) } : null,
266
+ addresses: here ? lanAddresses({ device: here.device }) : [],
267
+ port: BRIDGE_PORT,
268
+ bridge: { up: await bridgeAnswers(), here: phone.status().hosting },
269
+ ...storeView(store),
270
+ };
271
+ }
193
272
  const releaseOpen = () => { try { rmSync(openMark, { force: true }); } catch { /* it was already gone */ } };
273
+ // How many memories of each kind, and how many travel into turns. Counted, never read out.
274
+ function memoryCensus() {
275
+ const all = memory ? memory.memories({ limit: 20000 }) : [];
276
+ const kinds = {};
277
+ let travelling = 0;
278
+ for (const note of all) {
279
+ kinds[note.kind] = (kinds[note.kind] ?? 0) + 1;
280
+ if (memoryTravels(note)) travelling += 1;
281
+ }
282
+ return { memories: all.length, kinds, travelling };
283
+ }
194
284
  // The human's own modules, from ~/.pulse/modules and <project>/.madre/modules.
195
285
  await loadExternalModules({ stateRoot: root, projectRoot: canonicalProjectRoot }).catch((error) => console.error(`MADRE modules: ${error.message}`));
196
286
  // A project has one memory and many conversations; one of them is open at a time, and the
197
287
  // numbering is the project's, so a citation means the same thing in all of them.
198
288
  await adoptStrays(roomDir).catch(() => null);
199
289
  let chatId = (await chatIndex(roomDir)).active;
200
- let store = await new EventStore(chatLedger(roomDir, chatId), { floor: await projectFloor(roomDir, { except: chatId }) }).initialize();
290
+ // Every record open in this process. Two conversations may write at once (one at work while
291
+ // the human is in another, or both halves of a split window), so the floor a ledger numbers
292
+ // from is read when it writes: what the project had on disk, or what any open ledger has
293
+ // handed out since, whichever is higher.
294
+ const liveStores = new Set();
295
+ let retiredHigh = 0; // the highest a ledger that closed in this process had reached
296
+ const openLedger = async (id) => {
297
+ const base = await projectFloor(roomDir, { except: id });
298
+ const opened = await new EventStore(chatLedger(roomDir, id), { floor: () => Math.max(base, retiredHigh, ...[...liveStores].map((each) => each.lastSequence)) }).initialize();
299
+ // A record with lines that are not events opens anyway (src/event-store.mjs); it is said here,
300
+ // with where the file is, and nothing in it is removed.
301
+ if (opened.damaged) console.error(`MADRE: ${opened.damaged} damaged line(s) in ${chatLedger(roomDir, id)} were stepped over; the file keeps them.`);
302
+ liveStores.add(opened);
303
+ return opened;
304
+ };
305
+ const retireLedger = (closed) => { if (!liveStores.delete(closed)) return; retiredHigh = Math.max(retiredHigh, closed.lastSequence); };
306
+ let store = await openLedger(chatId);
201
307
  let historicalEvents = await store.readAll();
202
308
  // The room's memory: derived from the ledger, rebuilt if missing or stale,
203
309
  // and never a reason for the room not to open.
@@ -227,13 +333,67 @@ export async function createPulseServer({
227
333
  // human message and whenever MU/TH/UR looks: a term named in one room guards them all.
228
334
  const privacy = new Privacy({ ...privacySettings(await readConfig(root)), home: homedir() });
229
335
  const refreshPrivacy = async () => { const { terms, marker, secrets, paths } = privacySettings(await readConfig(root)); privacy.set(terms, marker).setGuards({ secrets, paths }); return privacy; };
336
+ const memoryWasThere = await stat(join(roomDir, 'memory.sqlite')).then(() => true, () => false);
230
337
  const memory = await new RoomMemory(join(roomDir, 'memory.sqlite')).attachPrivacy(privacy).initialize(store)
231
338
  .catch((error) => { console.error(`MADRE memory unavailable, turns get the recent window only: ${error.message}`); return null; });
339
+ // A memory made again from nothing (its file deleted, a copy of the project's state without it)
340
+ // is filled from every conversation of the project, not only the one open (LOG-14 of the 1.0
341
+ // review): the index of the others was lost for good. The words are indexed once; a number
342
+ // already there is left as it is.
343
+ if (memory && !memoryWasThere) {
344
+ for (const chat of (await listChats(roomDir)).chats) {
345
+ if (chat.id === chatId) continue;
346
+ const file = chatLedger(roomDir, chat.id);
347
+ if (await stat(file).then((info) => info.isFile(), () => false)) memory.index(await new EventStore(file).readAll().catch(() => []));
348
+ }
349
+ }
232
350
  // Meaning-aware recall through the user's own Gemini key, when there is one;
233
351
  // and the memory as MCP tools for every agent's turn.
234
352
  // Ollama, when it is there: local embeddings and a local archivist. Probed at
235
353
  // start and again from MODULES; wired live, no restart.
236
354
  let ollama = { running: false, host: null, models: [], embedModel: null, chatModel: null };
355
+ // The lobby's two readings (src/lobby-work.mjs): MADRE's summary of what was worked on last, kept
356
+ // beside this room's record so a restart does not lose it, and the recent decisions of the open
357
+ // rooms, asked of them at most every few minutes (each room works its zones out itself).
358
+ const lobbyWork = { file: join(roomDir, 'lobby-work.json'), loaded: false, key: null, at: 0, tried: 0, bullets: null, model: null, error: null, running: null };
359
+ const lobbyDecisions = { at: 0, list: null, running: null };
360
+ // Every conversation's room other than the open one (see the note above mountChat); declared
361
+ // here because the local wiring below reaches all of them.
362
+ const parked = new Map(); // chatId -> { room, store, off, feed }
363
+ // The records of the project's other conversations: the open store of one held beside, or the
364
+ // file of one closed (never made where none exists). And every event of the project, for what is
365
+ // counted across all of them (the privacy exposure).
366
+ async function otherLedgers() {
367
+ const out = [];
368
+ for (const chat of (await listChats(roomDir)).chats) {
369
+ if (chat.id === chatId) continue;
370
+ const held = parked.get(chat.id);
371
+ if (held?.store) { out.push({ store: held.store, held }); continue; }
372
+ const file = chatLedger(roomDir, chat.id);
373
+ if (await stat(file).then((info) => info.isFile(), () => false)) out.push({ store: new EventStore(file), held: null });
374
+ }
375
+ return out;
376
+ }
377
+ async function projectEvents() {
378
+ const all = await store.readAll();
379
+ for (const { store: other } of await otherLedgers()) all.push(...await other.readAll().catch(() => []));
380
+ return all;
381
+ }
382
+ const opening = new Map(); // chatId -> the promise of its room, while it is being built
383
+ // What the human sets belongs to the project: it reaches the conversation on screen and every
384
+ // one alive beside it. A timeout raised from a conversation in a window used to change only the
385
+ // one on screen, and its retry ran with the old limit.
386
+ const liveRooms = () => [room, ...[...parked.values()].map((held) => held.room)].filter(Boolean);
387
+ // What the local brain lends every room of the project: the archivist that distills in Ollama
388
+ // and @madre. Settled once per wiring and handed to each room as it opens, beside or switched
389
+ // to. A room built with @madre and without the archivist's invoker distilled with the next
390
+ // cheapest crew member: a provider, unannounced, on every conversation but the first.
391
+ let localWiring = { archivist: null, madre: false };
392
+ function wireLocal(target) {
393
+ if (!target) return;
394
+ target.setInvoker('ollama', localWiring.archivist);
395
+ target.setInvoker(MADRE_ADAPTER, localWiring.madre ? madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }) : null);
396
+ }
237
397
  const ollamaSettings = async () => ({ enabled: true, embeddings: true, archivist: true, agent: true, ...((await readConfig(root)).modules?.ollama ?? {}) });
238
398
  // What the TRAIN card needs: where the recipe ships, the room, the model name @madre will pick up, a base that fits this machine.
239
399
  function trainingInfo() {
@@ -255,7 +415,7 @@ export async function createPulseServer({
255
415
  const embedder = createEmbedder({ key, env: embedEnvFor(await memoryConfig()), ollama: useEmbeddings ? ollama : null, ollamaEmbedder });
256
416
  if (room) room.setEmbedder(embedder); else memory.attachEmbedder(embedder);
257
417
  }
258
- if (room) room.setInvoker('ollama', useArchivist ? ollamaInvoker({ host: ollama.host, model: ollama.chatModel }) : null);
418
+ localWiring.archivist = useArchivist ? ollamaInvoker({ host: ollama.host, model: ollama.chatModel, fetchImpl: reportFetch }) : null;
259
419
  // @madre: the fifth agent, present whenever Ollama has a chat model and the human left it on.
260
420
  const fifth = madreAgent({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }, { enabled: settings.enabled && settings.agent !== false });
261
421
  const index = agents.findIndex((agent) => agent.id === MADRE_AGENT_ID);
@@ -263,8 +423,10 @@ export async function createPulseServer({
263
423
  if (fifth.ready && index < 0) { agents.push(fifth); changed = true; }
264
424
  else if (fifth.ready && (agents[index].version !== fifth.version)) { Object.assign(agents[index], fifth); changed = true; }
265
425
  else if (!fifth.ready && index >= 0) { agents.splice(index, 1); changed = true; }
426
+ localWiring.madre = fifth.ready;
427
+ wireLocal(room);
428
+ for (const held of parked.values()) wireLocal(held.room);
266
429
  if (room) {
267
- room.setInvoker(MADRE_ADAPTER, fifth.ready ? madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }) : null);
268
430
  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' });
269
431
  // That a local model is running is not the same as it being of use on this project, and
270
432
  // the second thing is the one a person needs told. Said once per model, in the room, with
@@ -298,25 +460,63 @@ export async function createPulseServer({
298
460
  // file, so a deleted or edited .pulse/mother.env is noticed.
299
461
  const mother = new MotherChannel(join(canonicalProjectRoot, '.pulse', 'mother.env'), { meta: memory ? { get: (key) => memory.metaGet(key), set: (key, value) => memory.metaSet(key, value) } : null });
300
462
  const motherOutcome = await mother.load().catch((error) => { console.error(`MADRE could not open MOTHER's channel: ${error.message}`); return null; });
301
- const startupMemory = await memoryConfig();
463
+ // What the human last set for memory: a conversation opened later distills with it, not with
464
+ // what MADRE booted with.
465
+ let memorySettings = await memoryConfig();
466
+ // Which key unlocks #3 and #4, and for how long an accepted one holds. See src/override.mjs.
467
+ let overrideConfig = overrideSettings((await readConfig(root)).override);
468
+ // The passes the key gives (2026-10-12). The page asks for the key, the project checks it and
469
+ // hands back a pass for that agent and that mode; a message at #3 or #4 goes only with one. Without
470
+ // a remembered permission a pass opens one message; with one, any until it runs out. STOP ALL and
471
+ // a restart take them all back. Wrong keys are counted: a program trying words is slowed down.
472
+ const passes = new Map(); // token -> { agent, mode, until, once }
473
+ const keyMisses = []; // when the last wrong keys were typed
474
+ const PASS_ONCE_MS = 30 * 60 * 1000;
475
+ function issuePass(agent, mode) {
476
+ const minutes = overrideConfig.grace[mode] ?? 0;
477
+ const token = randomBytes(24).toString('hex');
478
+ const pass = { agent, mode, until: Date.now() + (minutes ? minutes * 60000 : PASS_ONCE_MS), once: !minutes };
479
+ passes.set(token, pass);
480
+ return { token, until: new Date(pass.until).toISOString(), once: pass.once };
481
+ }
482
+ function spendPass(token, agent, mode) {
483
+ const pass = typeof token === 'string' ? passes.get(token) : null;
484
+ if (!pass || pass.agent !== agent || pass.mode < mode) return false;
485
+ if (pass.until < Date.now()) { passes.delete(token); return false; }
486
+ if (pass.once) passes.delete(token);
487
+ return true;
488
+ }
302
489
  let listening = null;
303
490
  const listeningPort = () => listening?.address?.()?.port ?? Number(process.env.PULSE_PORT ?? 4317);
304
- const roomOptions = () => ({
491
+ // One CONTROL desk for the whole project, shared by every conversation alive at once: a single
492
+ // holder of the working tree whichever chat asked, and an UNDO that works from any of them.
493
+ const controlDesk = new ControlDesk({ projectRoot, guardRecord: join(roomDir, 'guard.json') });
494
+ // The open conversation's record unless another is named: a conversation held open beside it
495
+ // builds its room the same way, over its own record.
496
+ const roomOptions = (own = {}) => ({
305
497
  store,
306
498
  agents,
307
499
  projectRoot,
500
+ controlDesk,
308
501
  privacy,
309
502
  // Modules may hand tools to a turn; the room asks once per turn, the server knows the port.
310
503
  toolsForTurn: async (turn) => modulesToolsForTurn(await moduleContext(), { ...turn, port: listeningPort(), roomDir }),
504
+ // What BISHOP reads for a turn is recorded in that turn's conversation, not the open one.
505
+ 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,
506
+ // Fase 3e: notes a link with automatic recall brings to a turn. Never in the room of Salas
507
+ // (BISHOP has its own); recorded in the conversation of the turn that read them.
508
+ linkedForTurn: salasRoom ? null : async (turn) => recallForTurn(await moduleContext(), { ...turn, port: listeningPort(), roomDir,
509
+ record: (type, payload) => ((turn.turn && liveRooms().find((one) => one.runningTurn?.(turn.turn))) || room).record(type, payload) }),
311
510
  softTokenBudget,
312
511
  contextMaxChars,
313
- recallShare: Number(process.env.PULSE_RECALL_SHARE ?? startupMemory.recallShare),
314
- cascade: process.env.PULSE_RECALL_CASCADE !== undefined ? process.env.PULSE_RECALL_CASCADE !== '0' : startupMemory.cascade !== false,
512
+ recallShare: Number(process.env.PULSE_RECALL_SHARE ?? memorySettings.recallShare),
513
+ cascade: process.env.PULSE_RECALL_CASCADE !== undefined ? process.env.PULSE_RECALL_CASCADE !== '0' : memorySettings.cascade !== false,
315
514
  distill: {
316
- every: Number(process.env.PULSE_DISTILL_EVERY ?? startupMemory.every),
317
- idleMs: Number(process.env.PULSE_DISTILL_IDLE_MS ?? startupMemory.idleMinutes * 60000),
318
- agent: process.env.PULSE_DISTILL_AGENT ?? (startupMemory.archivist && startupMemory.archivist !== 'auto' ? startupMemory.archivist : null),
319
- allowed: Array.isArray(startupMemory.archivists) && startupMemory.archivists.length ? startupMemory.archivists : null,
515
+ every: Number(process.env.PULSE_DISTILL_EVERY ?? memorySettings.every),
516
+ idleMs: Number(process.env.PULSE_DISTILL_IDLE_MS ?? memorySettings.idleMinutes * 60000),
517
+ agent: process.env.PULSE_DISTILL_AGENT ?? (memorySettings.archivist && memorySettings.archivist !== 'auto' ? memorySettings.archivist : null),
518
+ allowed: Array.isArray(memorySettings.archivists) && memorySettings.archivists.length ? memorySettings.archivists : null,
519
+ ...(typeof memorySettings.enabled === 'boolean' ? { enabled: memorySettings.enabled } : {}),
320
520
  },
321
521
  memory,
322
522
  memoryServer,
@@ -325,8 +525,11 @@ export async function createPulseServer({
325
525
  invokers,
326
526
  agentTimeouts,
327
527
  maxMessageChars,
328
- delegation,
528
+ // S-032: in the room of Salas nobody delegates. BISHOP speaks there, and what it sets in
529
+ // motion goes through its own plans, never through a ```pulse plan of that room.
530
+ delegation: salasRoom ? false : delegation,
329
531
  maxPlanSteps,
532
+ ...own,
330
533
  });
331
534
  room = new Room(roomOptions());
332
535
  await wireOllama({ probe: false });
@@ -360,6 +563,7 @@ export async function createPulseServer({
360
563
  memory: memorySettingsView(),
361
564
  updates: { check: updatesEnabled(), envWins: process.env.PULSE_UPDATE_CHECK !== undefined },
362
565
  privacy: { terms: privacy.terms, marker: privacy.marker, envWins: privacySettings({}, process.env).envWins, ...privacy.guards },
566
+ override: publicOverride(overrideConfig),
363
567
  };
364
568
  }
365
569
  // What MU/TH/UR shows under MEMORY: the live values, who could distil, and what embeds today.
@@ -395,7 +599,7 @@ export async function createPulseServer({
395
599
  }
396
600
  if (patch.room) {
397
601
  config.room = { ...((await readConfig(root)).room ?? {}) };
398
- if (typeof patch.room.delegation === 'boolean') { config.room.delegation = patch.room.delegation; live.delegation = patch.room.delegation; }
602
+ if (typeof patch.room.delegation === 'boolean') { config.room.delegation = patch.room.delegation; live.delegation = salasRoom ? false : patch.room.delegation; }
399
603
  if (Number(patch.room.maxPlanSteps) > 0) { config.room.maxPlanSteps = Number(patch.room.maxPlanSteps); live.maxPlanSteps = Number(patch.room.maxPlanSteps); }
400
604
  if (Number(patch.room.softTokenBudget) > 0) { config.room.softTokenBudget = Number(patch.room.softTokenBudget); live.softTokenBudget = Number(patch.room.softTokenBudget); }
401
605
  }
@@ -412,7 +616,9 @@ export async function createPulseServer({
412
616
  if (typeof m.cascade === 'boolean') next.cascade = m.cascade;
413
617
  if (typeof m.enabled === 'boolean') next.enabled = m.enabled;
414
618
  config.memory = next;
415
- room.configureDistill({ every: next.every, idleMs: next.idleMinutes * 60000, agent: next.archivist === 'auto' ? null : next.archivist, allowed: next.archivists, recallShare: next.recallShare, cascade: next.cascade !== false, ...(typeof next.enabled === 'boolean' ? { enabled: next.enabled } : {}) });
619
+ memorySettings = next;
620
+ const distill = { every: next.every, idleMs: next.idleMinutes * 60000, agent: next.archivist === 'auto' ? null : next.archivist, allowed: next.archivists, recallShare: next.recallShare, cascade: next.cascade !== false, ...(typeof next.enabled === 'boolean' ? { enabled: next.enabled } : {}) };
621
+ for (const each of liveRooms()) each.configureDistill(distill);
416
622
  live.memory = true;
417
623
  }
418
624
  if (patch.scopes && typeof patch.scopes === 'object') {
@@ -426,14 +632,18 @@ export async function createPulseServer({
426
632
  if (Number.isInteger(Number(scopes.maxMode)) && Number(scopes.maxMode) >= 0 && Number(scopes.maxMode) <= 4) { config.scopes[agentId].maxMode = Number(scopes.maxMode); delete config.scopes[agentId].write; }
427
633
  if (Number.isInteger(Number(scopes.defaultMode)) && Number(scopes.defaultMode) >= 1 && Number(scopes.defaultMode) <= 2) { config.scopes[agentId].defaultMode = Number(scopes.defaultMode); delete config.scopes[agentId].alwaysCreate; }
428
634
  }
429
- room.setScopes(config.scopes);
635
+ for (const each of liveRooms()) each.setScopes(config.scopes);
636
+ }
637
+ if (patch.override && typeof patch.override === 'object') {
638
+ overrideConfig = mergeOverride((await readConfig(root)).override, patch.override);
639
+ config.override = overrideConfig;
430
640
  }
431
641
  if (patch.gemini) {
432
642
  config.gemini = { ...((await readConfig(root)).gemini ?? {}) };
433
643
  if (Number(patch.gemini.idleMs) > 0) { config.gemini.idleMs = Number(patch.gemini.idleMs); process.env.PULSE_GEMINI_IDLE_MS = String(Number(patch.gemini.idleMs)); }
434
644
  if (Number.isFinite(Number(patch.gemini.retries))) { config.gemini.retries = Number(patch.gemini.retries); process.env.PULSE_GEMINI_RETRIES = String(Number(patch.gemini.retries)); }
435
645
  }
436
- room.configure(live);
646
+ for (const each of liveRooms()) each.configure(live);
437
647
  await updateConfig(root, config);
438
648
  if (live.memory) await wireOllama({ probe: false });
439
649
  await room.record('room.settings', { changed: Object.keys(config), settings: effectiveSettings() });
@@ -560,6 +770,7 @@ export async function createPulseServer({
560
770
  setImageModule(startupConfig.modules?.imageStudio ?? {});
561
771
  room.setAsh(Boolean(startupConfig.modules?.ash?.enabled));
562
772
  room.setChoices(Boolean(startupConfig.modules?.choices?.enabled));
773
+ room.setRuns(Boolean(startupConfig.modules?.runs?.enabled));
563
774
  const recoveredTurns = await room.reconcile();
564
775
  if (recoveredTurns) console.error(`MADRE recovered ${recoveredTurns} unfinished turn(s) from a previous run.`);
565
776
  const quotaMonitor = new QuotaMonitor({
@@ -570,53 +781,54 @@ export async function createPulseServer({
570
781
  await quotaMonitor.start();
571
782
  // SSE fan-out works from the durable log, not from in-memory emits, so events
572
783
  // appended by another MADRE process on the same room reach open pages too.
573
- // `clients` maps each SSE response to the last sequence it already holds.
574
- const clients = new Map();
575
- let lastBroadcastSequence = historicalEvents.at(-1)?.sequence ?? 0;
576
- // Byte offset of the log already broadcast; the poller only reads past it.
577
- let tailOffset = (await store.tail(0)).offset;
578
- let inFlight = null;
579
- let dirty = false;
784
+ // A feed is one record and the pages watching it: `clients` maps each SSE response to the last
785
+ // sequence it already holds, `sent` is the last sequence broadcast and `offset` the byte offset
786
+ // of the log already read. The open conversation has one; a conversation held open beside it
787
+ // (the window split in two) has its own, so neither page hears the other's record.
788
+ const feedOver = async (source, events) => ({ store: source, clients: new Map(), sent: events.at(-1)?.sequence ?? 0, offset: (await source.tail(0)).offset, inFlight: null, dirty: false });
789
+ const mainFeed = await feedOver(store, historicalEvents);
790
+ const clients = mainFeed.clients;
580
791
  // A client that stops draining is dropped instead of buffering without bound.
581
- const writeEvent = (client, event) => {
792
+ const writeEvent = (client, event, feed = mainFeed) => {
582
793
  // Ghost events have no sequence: they are never in the log, so no `id:`
583
794
  // line, and a reconnecting client will not ask for them again.
584
795
  client.write(`${event.sequence ? `id: ${event.sequence}\n` : ''}data: ${JSON.stringify(event)}\n\n`);
585
796
  if (client.writableLength > sseMaxBufferedBytes) {
586
- clients.delete(client);
797
+ feed.clients.delete(client);
587
798
  client.destroy();
588
799
  return false;
589
800
  }
590
801
  return true;
591
802
  };
592
803
 
593
- function broadcastPending() {
594
- if (inFlight) {
595
- dirty = true;
596
- return inFlight;
804
+ function pump(feed) {
805
+ if (feed.inFlight) {
806
+ feed.dirty = true;
807
+ return feed.inFlight;
597
808
  }
598
- inFlight = (async () => {
809
+ feed.inFlight = (async () => {
599
810
  try {
600
811
  do {
601
- dirty = false;
602
- const { events, offset } = await store.tail(tailOffset);
603
- tailOffset = offset;
812
+ feed.dirty = false;
813
+ const { events, offset } = await feed.store.tail(feed.offset);
814
+ feed.offset = offset;
604
815
  for (const event of events) {
605
- if (event.sequence <= lastBroadcastSequence) continue;
606
- for (const [client, floor] of clients) {
607
- if (event.sequence > floor) writeEvent(client, event);
816
+ if (event.sequence <= feed.sent) continue;
817
+ for (const [client, floor] of feed.clients) {
818
+ if (event.sequence > floor) writeEvent(client, event, feed);
608
819
  }
609
- lastBroadcastSequence = event.sequence;
820
+ feed.sent = event.sequence;
610
821
  }
611
- } while (dirty);
822
+ } while (feed.dirty);
612
823
  } catch (error) {
613
824
  console.error(`MADRE broadcast error: ${error.message}`);
614
825
  } finally {
615
- inFlight = null;
826
+ feed.inFlight = null;
616
827
  }
617
828
  })();
618
- return inFlight;
829
+ return feed.inFlight;
619
830
  }
831
+ const broadcastPending = () => pump(mainFeed);
620
832
  let unsubscribe = () => {};
621
833
 
622
834
  // The error sentinel: failures MU/TH/UR cannot classify, and crashes, become
@@ -664,8 +876,65 @@ export async function createPulseServer({
664
876
  // Everything that listens to the open conversation, attached in one place so that opening
665
877
  // another attaches exactly the same things to it. Live readings go straight to whoever is
666
878
  // watching and are never stored, so a page that opens later simply never sees them.
879
+ // What waits for the human in a conversation (docs/MOBILE.md, MOV-004): a choice an agent offered
880
+ // or a line it asked to run, since the human last spoke; a mode a plan asked for, until it is
881
+ // decided. Kept as the room speaks, so the list of rooms on a phone never reads a ledger to know.
882
+ // What was left unanswered half a day ago is not waiting for anybody any more.
883
+ const asksByRoom = new WeakMap();
884
+ const ASK_FRESH_MS = 12 * 60 * 60 * 1000;
885
+ function trackAsks(target, history = []) {
886
+ const asks = new Map();
887
+ const feed = (event) => {
888
+ const said = event?.payload ?? {};
889
+ // The human answering clears what waited; a message BISHOP's channel brought is not the human.
890
+ if (event?.type === 'message.created' && said.role === 'user' && said.via !== 'bishop') { for (const key of [...asks.keys()]) asks.delete(key); }
891
+ else if (event?.type === 'choice.offered') asks.set(`choice:${said.responseMessageId}`, { kind: 'choice', agent: said.agent, text: said.question ?? null, at: event.timestamp });
892
+ else if (event?.type === 'run.requested') asks.set(`run:${said.responseMessageId}`, { kind: 'run', agent: said.agent, text: said.commands?.[0]?.line ?? null, at: event.timestamp });
893
+ else if (event?.type === 'run.started') asks.delete(`run:${said.responseMessageId}`);
894
+ // BISHOP (fase 4c): a #3/#4 proposal waits for the human's key in this room; in Salas, a
895
+ // plan with something to press waits until it closes.
896
+ else if (event?.type === 'bishop.order.proposed') asks.set(`bishop:${said.plan}:${said.step}`, { kind: 'bishop', agent: said.agent, mode: said.mode, text: said.text ?? null, at: event.timestamp });
897
+ else if (event?.type === 'bishop.plan.proposed' && [...(said.orders ?? []), ...(said.links ?? [])].some((item) => item.status === 'ready')) asks.set(`plan:${said.plan}`, { kind: 'plan', agent: 'bishop', text: said.reason ?? null, at: event.timestamp });
898
+ else if (event?.type === 'bishop.plan.closed') asks.delete(`plan:${said.plan}`);
899
+ };
900
+ for (const event of history) feed(event);
901
+ asksByRoom.set(target, asks);
902
+ return target.subscribe(feed);
903
+ }
904
+ function asksOf(target, now = Date.now()) {
905
+ const modes = (target.pendingModes?.() ?? []).map((asked) => ({ kind: 'mode', agent: asked.agent, mode: asked.mode, text: null }));
906
+ const kept = [...(asksByRoom.get(target)?.values() ?? [])].filter((ask) => now - Date.parse(ask.at ?? 0) < ASK_FRESH_MS);
907
+ return [...modes, ...kept];
908
+ }
909
+ let unsubscribeAsks = () => {};
910
+ // What a conversation's name and preview show of a message. One BISHOP's channel brought starts
911
+ // with the header its agent reads; the person sees the task, as the page shows it.
912
+ const saidText = (payload) => (payload?.via === 'bishop' ? String(payload.text ?? '').replace(/^BISHOP › [^\n]*\n+/, '') : payload?.text);
913
+ // What a module hears of the ledger (`onEvent`, docs/SDK.md; S-027 in docs/MACROVERSO.md): each
914
+ // event of each conversation alive in this room, once, with that conversation's room and id.
915
+ // A module hears only while it is on, gets the same context as everywhere else, and can only
916
+ // record what `record()` already allows (never a message, never a turn). What it does with an
917
+ // event is its own business and never holds up the room: a failure is logged and dropped.
918
+ function wireModules(target, id) {
919
+ return target.subscribe((event) => {
920
+ const listening = MODULES.filter((module) => typeof module.onEvent === 'function');
921
+ if (!listening.length) return;
922
+ void (async () => {
923
+ const ctx = await moduleContext();
924
+ for (const module of listening) {
925
+ const settings = module.settingsFrom(ctx.config);
926
+ if (module.kind === 'builtin' && !settings.enabled) continue;
927
+ await Promise.resolve(module.onEvent({ ...ctx, settings, room: target, chat: id, record: (type, payload) => target.record(type, payload) }, event))
928
+ .catch((error) => console.error(`MADRE module ${module.id}: onEvent failed: ${error.message}`));
929
+ }
930
+ })().catch((error) => console.error(`MADRE: a module could not hear an event: ${error.message}`));
931
+ });
932
+ }
933
+ let unsubscribeModules = () => {};
667
934
  function wireRoom() {
668
935
  unsubscribe = room.subscribe(() => { void broadcastPending(); });
936
+ unsubscribeModules = wireModules(room, chatId);
937
+ unsubscribeAsks = trackAsks(room, historicalEvents);
669
938
  unsubscribeSentinel = room.subscribe((event) => { void sentinel.observe(event).catch(() => null); });
670
939
  unsubscribeEyecat = room.subscribe((event) => { void eyecat.observe(event).catch(() => null); });
671
940
  unsubscribeGhost = room.subscribeGhost((event) => { for (const client of clients.keys()) writeEvent(client, event); });
@@ -675,8 +944,9 @@ export async function createPulseServer({
675
944
  unsubscribeChats = room.subscribe((event) => {
676
945
  if (event?.type !== 'message.created' || event.ghost) return;
677
946
  const human = event.payload?.role === 'user';
678
- chatWrites = chatWrites.then(() => touchChat(roomDir, chatId, { text: human ? event.payload.text : null, counts: human })).catch(() => null);
947
+ chatWrites = chatWrites.then(() => touchChat(roomDir, chatId, { text: human ? saidText(event.payload) : null, counts: human })).catch(() => null);
679
948
  });
949
+ unsubscribeActivity = watchActivity(room, chatId);
680
950
  sentinel.seed(historicalEvents);
681
951
  eyecat.seed(historicalEvents);
682
952
  room.restoreAttachments(historicalEvents
@@ -684,40 +954,235 @@ export async function createPulseServer({
684
954
  .map((event) => ({ ...event.payload, path: join(attachmentsRoot, event.payload.fileName), dir: attachmentsRoot })));
685
955
  }
686
956
  function unwireRoom() {
687
- for (const off of [unsubscribe, unsubscribeSentinel, unsubscribeEyecat, unsubscribeGhost, unsubscribeLive, unsubscribeChats]) { try { off(); } catch { /* already gone */ } }
688
- unsubscribe = unsubscribeSentinel = unsubscribeEyecat = unsubscribeGhost = unsubscribeLive = unsubscribeChats = () => {};
957
+ for (const off of [unsubscribe, unsubscribeSentinel, unsubscribeEyecat, unsubscribeGhost, unsubscribeLive, unsubscribeChats, unsubscribeActivity, unsubscribeAsks, unsubscribeModules]) { try { off(); } catch { /* already gone */ } }
958
+ unsubscribe = unsubscribeSentinel = unsubscribeEyecat = unsubscribeGhost = unsubscribeLive = unsubscribeChats = unsubscribeActivity = unsubscribeModules = () => {};
959
+ }
960
+
961
+ // TURNS: how much the room may do at once. On by default. Several conversations may be at work
962
+ // together (a turn that is taking its time no longer holds the human hostage) and each may run a
963
+ // few turns, both within limits the human sets in MU/TH/UR. Off, the room is what it was: one
964
+ // conversation drives the crew, and switching away from one that is working is refused.
965
+ const turnsPolicy = async () => {
966
+ const saved = (await readConfig(root)).turns ?? {};
967
+ const clamp = (value, low, high, fallback) => (Number.isInteger(Number(value)) ? Math.min(high, Math.max(low, Number(value))) : fallback);
968
+ // Three conversations by default: as many as fit on screen at once (the open one and two beside
969
+ // it, MAX_BESIDE in app.js), so every one the human can see may be at work.
970
+ return { enabled: saved.enabled !== false, chats: clamp(saved.chats, 1, 6, 3), turnsPerChat: clamp(saved.turnsPerChat, 1, 8, 3) };
971
+ };
972
+ // Conversations held open beside the one on screen. Two reasons to hold one: it is still at work
973
+ // after the human moved to another (parked: it keeps writing its record, and the page only hears
974
+ // that it is working and when a turn ends), or a page is watching it in the other half of the
975
+ // window (it has a feed of its own and takes messages addressed to it). One room per record,
976
+ // always: opening a held conversation takes the room that is already there. When nothing is
977
+ // running in it and nobody is watching, it closes itself.
978
+ const busyChats = () => [...parked.entries()].filter(([, held]) => held.room.busy()).map(([id]) => id);
979
+ const sayToAll = (type, payload) => {
980
+ const event = { id: `live-${randomUUID()}`, sequence: null, live: true, timestamp: new Date().toISOString(), type, payload };
981
+ for (const client of clients.keys()) writeEvent(client, event);
982
+ };
983
+ // The servers sentinel: this project's servers, watched while a page is open and RIPLEY is on,
984
+ // with what comes up and what goes down said live to every page and to no ledger.
985
+ const serverSentinel = new ServerSentinel({
986
+ projectRoot: canonicalProjectRoot,
987
+ self: () => listening?.address?.()?.port ?? null,
988
+ listen: async () => (await execFileAsync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], { timeout: 4000, maxBuffer: 2 * 1024 * 1024 })).stdout,
989
+ // lsof exits 1 when one of the processes is already gone, and still prints the others.
990
+ cwdsOf: (pids) => execFileAsync('lsof', ['-a', '-p', pids.join(','), '-d', 'cwd', '-Fpn'], { timeout: 4000, maxBuffer: 1024 * 1024 })
991
+ .then((result) => result.stdout, (error) => String(error.stdout ?? '')),
992
+ probe: (port) => probePort(port, { fetchImpl: reportFetch }),
993
+ isRoom: async (port) => {
994
+ const answer = await reportFetch(`http://127.0.0.1:${port}/api/version`, { signal: AbortSignal.timeout(800) });
995
+ const said = answer.ok ? await answer.json().catch(() => null) : null;
996
+ return said?.name === PACKAGE.name;
997
+ },
998
+ emit: (type, payload) => sayToAll(type, payload),
999
+ active: async () => clients.size > 0 && await ripleyOn(),
1000
+ });
1001
+ if (!testMode) serverSentinel.start();
1002
+ const endClients = (feed) => { for (const client of [...feed.clients.keys()]) { feed.clients.delete(client); try { client.end(); } catch { /* already gone */ } } };
1003
+ async function closeHeld(id) {
1004
+ const held = parked.get(id);
1005
+ if (!held) return;
1006
+ parked.delete(id);
1007
+ held.off();
1008
+ await held.room.shutdown().catch(() => null);
1009
+ retireLedger(held.store);
1010
+ }
1011
+ // A held conversation with no turn running and nobody watching it has no reason to stay open.
1012
+ async function releaseIfIdle(id, target = null) {
1013
+ const held = parked.get(id);
1014
+ if (!held || (target && held.room !== target)) return;
1015
+ if (held.room.busy() || held.room.hasLiveRuns?.() || held.feed?.clients.size) return;
1016
+ await closeHeld(id);
1017
+ }
1018
+ // A conversation's turns starting and ending, said to every page whichever conversation it shows.
1019
+ // The settle after an end is read a moment later: the turn leaves the room after its last event.
1020
+ function watchActivity(target, id) {
1021
+ return target.subscribe((event) => {
1022
+ if (!['agent.started', 'agent.completed', 'message.failed'].includes(event?.type)) return;
1023
+ const started = event.type === 'agent.started';
1024
+ sayToAll('chat.activity', { chatId: id, working: true, ...(started ? {} : { finished: { agent: event.payload?.agent ?? event.payload?.target ?? null, ok: event.type === 'agent.completed' } }) });
1025
+ if (started) return;
1026
+ setTimeout(() => {
1027
+ if (target.busy()) return;
1028
+ sayToAll('chat.activity', { chatId: id, working: false });
1029
+ void releaseIfIdle(id, target);
1030
+ }, 400).unref?.();
1031
+ });
1032
+ }
1033
+ // What a held conversation keeps doing while it is not the one on screen: its list entry follows
1034
+ // what is said in it, and every page hears when a turn in it starts and ends.
1035
+ function hold(id, target, source) {
1036
+ const offTouch = target.subscribe((event) => {
1037
+ if (event?.type !== 'message.created' || event.ghost) return;
1038
+ const human = event.payload?.role === 'user';
1039
+ chatWrites = chatWrites.then(() => touchChat(roomDir, id, { text: human ? saidText(event.payload) : null, counts: human })).catch(() => null);
1040
+ });
1041
+ const offActivity = watchActivity(target, id);
1042
+ const offModules = wireModules(target, id);
1043
+ let offAsks = () => {};
1044
+ void source?.readAll?.().then((history) => { offAsks = trackAsks(target, history); }).catch(() => null);
1045
+ const entry = { room: target, store: source, feed: null, off: () => { offTouch(); offActivity(); offAsks(); offModules(); } };
1046
+ parked.set(id, entry);
1047
+ return entry;
1048
+ }
1049
+ // A page starts watching a held conversation: it gets its own feed, fed by its own room.
1050
+ async function watchHeld(entry) {
1051
+ if (entry.feed) return entry.feed;
1052
+ const feed = await feedOver(entry.store, await entry.store.readAll());
1053
+ if (entry.feed) return entry.feed;
1054
+ entry.feed = feed;
1055
+ const offs = [
1056
+ entry.room.subscribe(() => { void pump(feed); }),
1057
+ entry.room.subscribeGhost((event) => { for (const client of [...feed.clients.keys()]) writeEvent(client, event, feed); }),
1058
+ entry.room.subscribeLive((event) => { for (const client of [...feed.clients.keys()]) writeEvent(client, event, feed); }),
1059
+ ];
1060
+ const before = entry.off;
1061
+ entry.off = () => { before(); for (const off of offs) { try { off(); } catch { /* already gone */ } } endClients(feed); };
1062
+ return feed;
1063
+ }
1064
+ // The conversation a request is about. Without `?chat=`, or naming the one on screen, it is the
1065
+ // open conversation, exactly as before. Naming another opens it beside, without changing which
1066
+ // one is on screen: the same room a parked conversation already has, or a new one built the way
1067
+ // mountChat builds one.
1068
+ // The room on screen, for the places where a route names its own conversation `room`.
1069
+ const openRoom = () => room;
1070
+ async function scopeOf(url) {
1071
+ const asked = url.searchParams.get('chat');
1072
+ if (!asked || asked === chatId) return { id: chatId, room, store, feed: mainFeed, main: true };
1073
+ if (!isChatId(asked)) return { error: `No conversation "${asked}".` };
1074
+ let entry = parked.get(asked);
1075
+ if (!entry) {
1076
+ if (!opening.has(asked)) {
1077
+ opening.set(asked, (async () => {
1078
+ if (!(await listChats(roomDir)).chats.some((chat) => chat.id === asked)) return null;
1079
+ const source = await openLedger(asked);
1080
+ const events = await source.readAll();
1081
+ const opened = new Room(roomOptions({ store: source, historicalEvents: events }));
1082
+ // A conversation opened from disk runs nothing yet: a turn it shows as started was cut
1083
+ // by a room that died, and is closed as one (LOG-14 of the 1.0 review). Only the
1084
+ // conversation open at startup was reconciled, so the others «thought» forever.
1085
+ await opened.reconcile();
1086
+ wireLocal(opened);
1087
+ await applyRoomSettings(opened, room);
1088
+ opened.restoreAttachments(events
1089
+ .filter((event) => event.type === 'attachment.stored')
1090
+ .map((event) => ({ ...event.payload, path: join(attachmentsRoot, event.payload.fileName), dir: attachmentsRoot })));
1091
+ const made = hold(asked, opened, source);
1092
+ // Opened to be looked at; if no page comes to watch it, it closes again.
1093
+ setTimeout(() => { void releaseIfIdle(asked, opened); }, heldGraceMs).unref?.();
1094
+ return made;
1095
+ })().finally(() => opening.delete(asked)));
1096
+ }
1097
+ entry = await opening.get(asked);
1098
+ if (!entry) return { error: `No conversation "${asked}".` };
1099
+ }
1100
+ // It may have become the open one while it was being built.
1101
+ if (asked === chatId) return { id: chatId, room, store, feed: mainFeed, main: true };
1102
+ return { id: asked, room: entry.room, store: entry.store, entry, main: false };
1103
+ }
1104
+ let unsubscribeActivity = () => {};
1105
+ const chatsView = async () => {
1106
+ const list = await listChats(roomDir);
1107
+ const working = new Set([...busyChats(), ...(room?.busy() ? [chatId] : [])]);
1108
+ return { ...list, chats: list.chats.map((chat) => (working.has(chat.id) ? { ...chat, working: true } : chat)) };
1109
+ };
1110
+ // What the human set while the room was open belongs to the project, not to one conversation's
1111
+ // record: a room opened for another chat used to start with no MAX MODE, no Ash, no Choices and
1112
+ // the timeouts MADRE booted with.
1113
+ async function applyRoomSettings(next, previous) {
1114
+ const config = await readConfig(root);
1115
+ next.setScopes(config.scopes ?? {});
1116
+ next.setAsh(Boolean(config.modules?.ash?.enabled));
1117
+ next.setChoices(Boolean(config.modules?.choices?.enabled));
1118
+ next.setRuns(Boolean(config.modules?.runs?.enabled));
1119
+ if (previous) next.configure(previous.settings());
689
1120
  }
690
1121
 
691
1122
  // Opening another conversation. The project keeps its memory, its crew, its modules, its
692
- // privacy list and its numbering; what changes is the record being written and read. One
693
- // conversation drives the crew at a time — two threads editing the same working tree at once
694
- // is not a feature, it is a way to lose work — so a turn in flight is a reason to refuse.
1123
+ // privacy list and its numbering; what changes is the record being shown. With TURNS on, a
1124
+ // conversation still at work is parked, not stopped; with it off, a turn in flight is a reason to
1125
+ // refuse, as it always was.
695
1126
  async function mountChat(id) {
696
1127
  if (!isChatId(id)) return { error: `No conversation "${id}".` };
697
- if (room && room.working()) return { error: t('A turn is running in this conversation. Let it finish, or STOP ALL, and try again.') };
1128
+ const policy = await turnsPolicy();
1129
+ const leaving = room;
1130
+ const leavingBusy = Boolean(leaving?.busy());
1131
+ if (leavingBusy && !policy.enabled) return { error: t('A turn is running in this conversation. Let it finish, or STOP ALL, and try again.') };
698
1132
  const opened = await openChatIndex(roomDir, id);
699
1133
  if (!opened) return { error: `No conversation "${id}".` };
1134
+ // A room being built for it beside is the room it gets; never a second one over its record.
1135
+ await opening.get(id)?.catch(() => null);
700
1136
  unwireRoom();
701
1137
  await chatWrites.catch(() => null);
702
- await room?.shutdown().catch(() => null);
1138
+ // A conversation with a command still alive (a server it started) is parked too, never closed
1139
+ // under the process: its card is where that process is stopped.
1140
+ if (leaving && (leavingBusy || leaving.hasLiveRuns?.()) && chatId !== id) {
1141
+ // Still writing its record and touching the list; only the page stops watching it.
1142
+ hold(chatId, leaving, store);
1143
+ } else if (leaving) {
1144
+ await leaving.shutdown().catch(() => null);
1145
+ retireLedger(store);
1146
+ }
703
1147
  chatId = id;
704
- store = await new EventStore(chatLedger(roomDir, id), { floor: await projectFloor(roomDir, { except: id }) }).initialize();
705
- historicalEvents = await store.readAll();
706
- room = new Room(roomOptions());
707
- room.setInvoker(MADRE_ADAPTER, madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }));
1148
+ const held = parked.get(id);
1149
+ if (held) {
1150
+ // Back to a conversation that kept working, or that was open beside: the same room, not a
1151
+ // second one over its record. A page watching it there comes back to it as the open one.
1152
+ parked.delete(id);
1153
+ held.off();
1154
+ room = held.room;
1155
+ store = held.store;
1156
+ historicalEvents = await store.readAll();
1157
+ } else {
1158
+ store = await openLedger(id);
1159
+ historicalEvents = await store.readAll();
1160
+ room = new Room(roomOptions());
1161
+ // Opened from disk: what it shows as started was cut by a room that died (LOG-14).
1162
+ if (await room.reconcile()) historicalEvents = await store.readAll();
1163
+ wireLocal(room);
1164
+ await applyRoomSettings(room, leaving);
1165
+ }
708
1166
  wireRoom();
709
- // Whoever is watching is watching the wrong record now: their stream ends and they come back
710
- // to the one that is open.
711
- lastBroadcastSequence = historicalEvents.at(-1)?.sequence ?? 0;
712
- tailOffset = (await store.tail(0)).offset;
713
- for (const client of [...clients.keys()]) { clients.delete(client); try { client.end(); } catch { /* already gone */ } }
1167
+ // Whoever is watching the open conversation is watching the wrong record now: their stream
1168
+ // ends and they come back to the one that is open. A page watching another conversation
1169
+ // beside it has a feed of its own and is not touched.
1170
+ mainFeed.store = store;
1171
+ mainFeed.sent = historicalEvents.at(-1)?.sequence ?? 0;
1172
+ mainFeed.offset = (await store.tail(0)).offset;
1173
+ endClients(mainFeed);
714
1174
  return { chat: opened };
715
1175
  }
716
1176
  wireRoom();
717
- const poller = setInterval(() => { void broadcastPending(); }, broadcastIntervalMs);
1177
+ const poller = setInterval(() => {
1178
+ void broadcastPending();
1179
+ for (const held of parked.values()) if (held.feed) void pump(held.feed);
1180
+ }, broadcastIntervalMs);
718
1181
  poller.unref();
719
1182
 
720
1183
  let installing = null;
1184
+ // Intenciones' reading of every conversation, kept per ledger file until that file changes.
1185
+ const observedLedgers = new Map();
721
1186
  // The context a module works in: the project, its settings, the room, and what this server offers.
722
1187
  async function moduleContext() {
723
1188
  const config = await readConfig(root);
@@ -727,8 +1192,68 @@ export async function createPulseServer({
727
1192
  updateConfig: (patch) => updateConfig(root, patch),
728
1193
  record: (type, payload) => room.record(type, payload),
729
1194
  services: {
1195
+ // Into one named conversation of this room, opened beside if it is not alive; the open one
1196
+ // when the name is missing or no longer exists. The same `record()` and the same refusals.
1197
+ recordIn: async (id, type, payload) => {
1198
+ const here = typeof id === 'string' && id ? await scopeOf(new URL(`http://room/?chat=${encodeURIComponent(id)}`)) : { room };
1199
+ return (here.error ? room : here.room).record(type, payload);
1200
+ },
730
1201
  imageKey,
731
1202
  setImageModule,
1203
+ // Intenciones keeps its measurements beside the project's room, reads the open
1204
+ // conversation, and asks agents through the same bench EYECAT and the archivist draw on.
1205
+ intents: {
1206
+ roomDir,
1207
+ events: () => store.readAll(),
1208
+ bench: () => room.bench(),
1209
+ // The questions to calibrate with are the project's, not one conversation's: a new
1210
+ // conversation would otherwise have nothing to ask. Only what was said is read.
1211
+ projectEvents: async () => {
1212
+ const said = [];
1213
+ for (const chat of (await listChats(roomDir)).chats) {
1214
+ const text = await readFile(chatLedger(roomDir, chat.id), 'utf8').catch(() => '');
1215
+ for (const line of text.split('\n')) {
1216
+ if (!line.includes('"message.created"')) continue;
1217
+ try { const event = JSON.parse(line); if (event.type === 'message.created') said.push(event); } catch { /* a torn last line */ }
1218
+ }
1219
+ }
1220
+ return said;
1221
+ },
1222
+ // Every conversation's turns, cut down to what learning from real use reads: who was
1223
+ // asked what, at which effort, how it ended, what it cost and what the human said of it.
1224
+ // A ledger is parsed again only when it changed on disk.
1225
+ ledgers: async () => {
1226
+ const turns = [];
1227
+ for (const chat of (await listChats(roomDir)).chats) {
1228
+ const file = chatLedger(roomDir, chat.id);
1229
+ const seen = await stat(file).catch(() => null);
1230
+ if (!seen) continue;
1231
+ let kept = observedLedgers.get(file);
1232
+ if (!kept || kept.mtimeMs !== seen.mtimeMs || kept.size !== seen.size) {
1233
+ const text = await readFile(file, 'utf8').catch(() => '');
1234
+ const events = [];
1235
+ for (const line of text.split('\n')) {
1236
+ if (!/"(message\.created|message\.failed|message\.rated|usage\.recorded|turn\.stopped)"/.test(line)) continue;
1237
+ try { const one = observable(JSON.parse(line), chat.id); if (one) events.push(one); } catch { /* a torn last line */ }
1238
+ }
1239
+ kept = { mtimeMs: seen.mtimeMs, size: seen.size, events };
1240
+ observedLedgers.set(file, kept);
1241
+ }
1242
+ turns.push(...kept.events);
1243
+ }
1244
+ return turns;
1245
+ },
1246
+ // The effort Codex runs at when told none, from its own config: a turn sent without one
1247
+ // ran there. Claude never says, and its turns stay at «its default».
1248
+ defaults: async () => {
1249
+ const codex = agents.find((agent) => agent.id === 'codex');
1250
+ if (!codex) return {};
1251
+ const found = (await discoverModels({ agents: [codex], config: await readConfig(root) }))?.codex;
1252
+ if (!found) return {};
1253
+ const byModel = Object.fromEntries(Object.entries(found.effortsByModel ?? {}).filter(([, one]) => one?.default).map(([slug, one]) => [slug, one.default]));
1254
+ return { codex: { effort: found.effortDefault ?? null, byModel } };
1255
+ },
1256
+ },
732
1257
  // What a card knows about being up to date. Reading is free: it answers from the day's
733
1258
  // cache. The button on a card is what forces a look outside.
734
1259
  moduleUpdate: (item, { force = false, cacheOnly = !force } = {}) => moduleUpdate(item, {
@@ -771,6 +1296,29 @@ export async function createPulseServer({
771
1296
  await room.record('extension.install.finished', { id: 'ollama', name: 'OLLAMA', ok: status.running, installed: true, detail: status.running ? `running · ${status.chatModel ? `@madre with ${status.chatModel}` : 'no chat model yet'}` : 'Ollama did not answer; open the Ollama app and press RECHECK.', ollama: status });
772
1297
  return status.running ? { ok: true } : { ok: false, error: t('Ollama did not answer. Open the Ollama app, then press RECHECK.') };
773
1298
  },
1299
+ // After an upgrade the binary is new and the server answering on the port is still the old
1300
+ // one, so the card kept reading the old version and the update looked like it did nothing.
1301
+ // An `ollama serve` this user started is stopped and started again from the new binary;
1302
+ // anything else (the app, a service of another user) is named, not touched.
1303
+ async restart() {
1304
+ const binary = await findOnPath('ollama');
1305
+ if (!binary) return { ok: false, error: t('Ollama is not on this computer yet.') };
1306
+ const run = (command, args) => new Promise((resolve) => execFile(command, args, { timeout: 5000 }, (error, stdout, stderr) => resolve(`${stdout ?? ''}${stderr ?? ''}`)));
1307
+ const { client } = ollamaVersions(await run(binary, ['--version']));
1308
+ const status = await wireOllama();
1309
+ if (!status.running) return this.start();
1310
+ if (!client || !status.version || status.version === client) return { ok: true, already: true };
1311
+ const pids = process.platform === 'win32' || typeof process.getuid !== 'function' ? [] : ownOllamaServers(await run('ps', ['-axo', 'pid=,uid=,args=']), process.getuid());
1312
+ if (!pids.length) return { ok: false, error: t('Ollama {client} is installed, but {running} is still the one answering. Quit Ollama (or restart its service) and open it again, then RECHECK.', { client, running: status.version }) };
1313
+ for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ } }
1314
+ let down = status;
1315
+ for (let attempt = 0; attempt < 20 && down.running; attempt += 1) {
1316
+ await new Promise((resolve) => setTimeout(resolve, 500));
1317
+ down = await wireOllama();
1318
+ }
1319
+ if (down.running) return { ok: false, error: t('The old Ollama ({running}) did not stop. Quit it by hand, then press START OLLAMA.', { running: status.version }) };
1320
+ return this.start();
1321
+ },
774
1322
  // Pulls stream into the room like a module install; one at a time.
775
1323
  pull: async (model) => {
776
1324
  if (installing) return { ok: false, error: `Another install is running (${installing}).` };
@@ -802,7 +1350,12 @@ export async function createPulseServer({
802
1350
  const extension = extensionById(id);
803
1351
  if (!extension) return { status: 404, body: { error: `Unknown module: ${id}.` } };
804
1352
  // Built-ins are switches the module itself defines.
805
- if (extension.toggle) return extension.toggle(await moduleContext(), payload);
1353
+ if (extension.toggle) {
1354
+ const toggled = await extension.toggle(await moduleContext(), payload);
1355
+ // The switch flips the open conversation; the ones alive beside it follow.
1356
+ for (const held of parked.values()) await applyRoomSettings(held.room, null);
1357
+ return toggled;
1358
+ }
806
1359
  if (confirm !== true) return { status: 400, body: { error: t('Installing a module writes into the project; send { "confirm": true } to proceed.') } };
807
1360
  if (installing) return { status: 409, body: { error: `Another install is running (${installing}).` } };
808
1361
  const preflight = extension.preflight ? await extension.preflight(canonicalProjectRoot) : { ok: true, problems: [] };
@@ -861,13 +1414,17 @@ export async function createPulseServer({
861
1414
  // not beside the first route that happened to need it: a const sits in its temporal dead zone
862
1415
  // until its own line runs, so any route above it threw instead of refusing.
863
1416
  const designationOk = (given) => typeof given === 'string' && given.trim().toLowerCase() === basename(canonicalProjectRoot).toLowerCase();
1417
+ // A request the bridge passed on from a paired phone. It reaches the room from 127.0.0.1 like
1418
+ // anything on this Mac, so «only from this computer» has to ask this too.
1419
+ const viaPhone = typeof request.headers['x-madre-device'] === 'string';
1420
+ const phoneKind = viaPhone ? String(request.headers['x-madre-device-kind'] ?? 'other').slice(0, 20) : null;
864
1421
  try {
865
1422
  if (request.method === 'GET' && url.pathname === '/') {
866
1423
  const html = await readFile(join(publicDirectory, 'index.html'));
867
- response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
1424
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...PAGE_GUARDS });
868
1425
  return response.end(html);
869
1426
  }
870
- if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js', '/routing.js', '/abduction.js'].includes(url.pathname)) {
1427
+ 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)) {
871
1428
  const js = await readFile(join(publicDirectory, url.pathname.slice(1)));
872
1429
  response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
873
1430
  return response.end(js);
@@ -883,6 +1440,56 @@ export async function createPulseServer({
883
1440
  ['/assets/abduction-cat-control-left.png', 'abduction-cat-control-left.png'],
884
1441
  ['/assets/abduction-cat-control-right.png', 'abduction-cat-control-right.png'],
885
1442
  ]);
1443
+ // The brand: its typefaces (SIL OFL, licences beside them) and the favicon, served from the
1444
+ // package itself. Nothing is fetched from a font service: the room never leaves this machine
1445
+ // to draw a letter.
1446
+ const brandAssets = new Map([
1447
+ ['/assets/brand/Sora-latin.woff2', 'font/woff2'],
1448
+ ['/assets/brand/Sora-latin-ext.woff2', 'font/woff2'],
1449
+ ['/assets/brand/JetBrainsMono-latin.woff2', 'font/woff2'],
1450
+ ['/assets/brand/JetBrainsMono-latin-ext.woff2', 'font/woff2'],
1451
+ ['/assets/brand/Geist-latin.woff2', 'font/woff2'],
1452
+ ['/assets/brand/Geist-latin-ext.woff2', 'font/woff2'],
1453
+ ['/assets/brand/GeistMono-latin.woff2', 'font/woff2'],
1454
+ ['/assets/brand/GeistMono-latin-ext.woff2', 'font/woff2'],
1455
+ ['/assets/brand/OFL-Geist.txt', 'text/plain; charset=utf-8'],
1456
+ ['/assets/brand/OFL-GeistMono.txt', 'text/plain; charset=utf-8'],
1457
+ ['/assets/brand/OFL-Sora.txt', 'text/plain; charset=utf-8'],
1458
+ ['/assets/brand/OFL-JetBrainsMono.txt', 'text/plain; charset=utf-8'],
1459
+ ['/assets/brand/madre-favicon.svg', 'image/svg+xml'],
1460
+ // The conversation's wallpapers (Appearance): twelve pictures, encoded by hand from their
1461
+ // masters (cwebp), whose light and dark, layer or mesh and intensity are layers in the CSS.
1462
+ ['/assets/wallpapers/jarrito.webp', 'image/webp'],
1463
+ ['/assets/wallpapers/canela.webp', 'image/webp'],
1464
+ ['/assets/wallpapers/paraiso.webp', 'image/webp'],
1465
+ ['/assets/wallpapers/ventura.webp', 'image/webp'],
1466
+ ['/assets/wallpapers/magna.webp', 'image/webp'],
1467
+ ['/assets/wallpapers/biznaga.webp', 'image/webp'],
1468
+ ['/assets/wallpapers/oceano.webp', 'image/webp'],
1469
+ ['/assets/wallpapers/quila.webp', 'image/webp'],
1470
+ ['/assets/wallpapers/xico.webp', 'image/webp'],
1471
+ ['/assets/wallpapers/tropical.webp', 'image/webp'],
1472
+ ['/assets/wallpapers/tradicion.webp', 'image/webp'],
1473
+ ['/assets/wallpapers/urbania.webp', 'image/webp'],
1474
+ // The pictures of the «what's new» board, one per thing it tells (src/whats-new.mjs).
1475
+ ['/assets/news/1.0/settings.webp', 'image/webp'],
1476
+ ['/assets/news/1.0/mother.webp', 'image/webp'],
1477
+ ['/assets/news/1.0/intents.webp', 'image/webp'],
1478
+ ['/assets/news/1.0/permissions.webp', 'image/webp'],
1479
+ ['/assets/news/1.0/github.webp', 'image/webp'],
1480
+ ['/assets/news/1.0/bar.webp', 'image/webp'],
1481
+ ['/assets/news/1.0/modules.webp', 'image/webp'],
1482
+ ['/assets/news/1.0/nostromo.webp', 'image/webp'],
1483
+ ['/assets/news/1.0/room.webp', 'image/webp'],
1484
+ ['/assets/news/1.0/looks.webp', 'image/webp'],
1485
+ ['/assets/news/1.0/alerts.webp', 'image/webp'],
1486
+ ['/assets/news/1.0/finder.webp', 'image/webp'],
1487
+ ]);
1488
+ if (request.method === 'GET' && brandAssets.has(url.pathname)) {
1489
+ const file = await readFile(join(publicDirectory, url.pathname.slice(1)));
1490
+ response.writeHead(200, { 'content-type': brandAssets.get(url.pathname), 'content-length': file.length, 'cache-control': 'public, max-age=86400' });
1491
+ return response.end(file);
1492
+ }
886
1493
  if (request.method === 'GET' && abductionAssets.has(url.pathname)) {
887
1494
  const image = await readFile(join(publicDirectory, 'assets', abductionAssets.get(url.pathname)));
888
1495
  response.writeHead(200, {
@@ -892,23 +1499,531 @@ export async function createPulseServer({
892
1499
  });
893
1500
  return response.end(image);
894
1501
  }
1502
+ // ---------- The phone (docs/MOBILE.md) ----------
1503
+ // Pairing and the list of phones are the Mac's alone: nothing that came through the bridge
1504
+ // reaches them.
1505
+ if (url.pathname === '/api/phone' || url.pathname.startsWith('/api/phone/')) {
1506
+ if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
1507
+ if (!(await phonePreview())) {
1508
+ if (request.method === 'GET' && url.pathname === '/api/phone') return sendJson(response, 200, { preview: false });
1509
+ return sendJson(response, 404, { error: t('Not found.') });
1510
+ }
1511
+ if (request.method === 'GET' && url.pathname === '/api/phone') return sendJson(response, 200, { preview: true, ...(await phoneView()) });
1512
+ const payload = request.method === 'POST' ? await body(request).catch(() => ({})) : {};
1513
+ if (request.method === 'POST' && url.pathname === '/api/phone/trust') {
1514
+ const here = await currentNetwork().catch(() => null);
1515
+ if (!here) return sendJson(response, 412, { error: t('MADRE cannot tell which network this computer is on.') });
1516
+ await trustNetwork(root, { id: here.id, name: typeof payload.name === 'string' ? payload.name : null });
1517
+ await phone.tick();
1518
+ return sendJson(response, 200, await phoneView());
1519
+ }
1520
+ if (request.method === 'POST' && url.pathname === '/api/phone/invite') {
1521
+ const here = await currentNetwork().catch(() => null);
1522
+ const store = await readStore(root);
1523
+ if (!here || !store.networks.some((one) => one.id === here.id)) return sendJson(response, 412, { error: t('Mark this network as trusted first.'), trust: true });
1524
+ const address = lanAddresses({ device: here.device })[0];
1525
+ if (!address) return sendJson(response, 412, { error: t('This Mac has no address on the local network.') });
1526
+ if (!store.enabled) await setEnabled(root, true);
1527
+ const invite = await createInvite(root);
1528
+ await phone.tick();
1529
+ // Whichever room hosts it, the bridge has to answer before the code is shown.
1530
+ for (let wait = 0; wait < 10 && !(await bridgeAnswers()); wait += 1) await new Promise((done) => setTimeout(done, 200));
1531
+ if (!(await bridgeAnswers())) return sendJson(response, 503, { error: t('MADRE on your phone did not open on port {port}. Another program may be using it.', { port: BRIDGE_PORT }) });
1532
+ const link = `http://${address}:${BRIDGE_PORT}/bridge/pair?c=${invite.code}`;
1533
+ return sendJson(response, 200, { invite: { id: invite.id, expiresAt: invite.expiresAt }, url: link, qr: qrSvg(link, { label: t('Code to pair your phone') }), ...(await phoneView()) });
1534
+ }
1535
+ if (request.method === 'POST' && url.pathname === '/api/phone/forget') {
1536
+ await forgetDevice(root, String(payload.id ?? ''));
1537
+ await phone.tick();
1538
+ return sendJson(response, 200, await phoneView());
1539
+ }
1540
+ if (request.method === 'POST' && url.pathname === '/api/phone/networks/forget') {
1541
+ await forgetNetwork(root, String(payload.id ?? ''));
1542
+ await phone.tick();
1543
+ return sendJson(response, 200, await phoneView());
1544
+ }
1545
+ if (request.method === 'POST' && url.pathname === '/api/phone/enabled') {
1546
+ await setEnabled(root, payload.on !== false);
1547
+ await phone.tick();
1548
+ return sendJson(response, 200, await phoneView());
1549
+ }
1550
+ return sendJson(response, 404, { error: t('Not found.') });
1551
+ }
1552
+ // The room in one line, for the list of rooms on the phone.
1553
+ if (request.method === 'GET' && url.pathname === '/api/glance') {
1554
+ const { chats } = await listChats(roomDir);
1555
+ const latest = chats[0] ?? null;
1556
+ const working = new Set(room.activeTurns().map((turn) => turn.agent));
1557
+ for (const held of parked.values()) for (const turn of held.room.activeTurns()) working.add(turn.agent);
1558
+ // What waits for the human, and in which conversation, so the phone opens straight to it.
1559
+ const titleOf = (id) => chats.find((one) => one.id === id)?.title ?? null;
1560
+ const waiting = [
1561
+ ...asksOf(room).map((ask) => ({ ...ask, chat: chatId, chatTitle: titleOf(chatId) })),
1562
+ ...[...parked.entries()].flatMap(([id, held]) => asksOf(held.room).map((ask) => ({ ...ask, chat: id, chatTitle: titleOf(id) }))),
1563
+ ];
1564
+ return sendJson(response, 200, {
1565
+ project: basename(canonicalProjectRoot),
1566
+ version: PACKAGE.version ?? null,
1567
+ chats: chats.length,
1568
+ chat: latest ? { id: latest.id, title: latest.title, named: latest.named, preview: latest.preview, at: latest.updatedAt, messages: latest.messages } : null,
1569
+ working: [...working],
1570
+ waiting: waiting.slice(0, 5).map(({ kind, agent, mode, text, chat, chatTitle }) => ({ kind, agent, mode: mode ?? undefined, text: typeof text === 'string' ? text.slice(0, 140) : null, chat, chatTitle })),
1571
+ waitingCount: waiting.length,
1572
+ // What the archive holds, counted, when Salas asks (?census=1): never the words, only how
1573
+ // many of each kind and how many travel into turns.
1574
+ ...(url.searchParams.get('census') === '1' ? { census: memoryCensus() } : {}),
1575
+ });
1576
+ }
1577
+ // ---- Salas on the Mac (src/salas.mjs, docs/MACROVERSO.md). The page speaks only to this
1578
+ // room; this room speaks to the others from here, the way the phone's bridge does. Opening,
1579
+ // closing and stopping rooms is the Mac's: a phone never reaches these.
1580
+ if ((url.pathname === '/salas' || url.pathname.startsWith('/api/salas') || url.pathname === '/api/room/close') && viaPhone) {
1581
+ return sendJson(response, 403, { error: t('This is done on the Mac.') });
1582
+ }
1583
+ if (request.method === 'GET' && url.pathname === '/salas') {
1584
+ const html = await readFile(join(publicDirectory, 'salas-mac.html'));
1585
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache', ...PAGE_GUARDS });
1586
+ return response.end(html);
1587
+ }
1588
+ if (request.method === 'GET' && (url.pathname === '/salas-mac.js' || url.pathname === '/salas-mac.css' || url.pathname === '/macroverso.js')) {
1589
+ const file = await readFile(join(publicDirectory, url.pathname.slice(1)));
1590
+ response.writeHead(200, { 'content-type': url.pathname.endsWith('.js') ? 'text/javascript; charset=utf-8' : 'text/css; charset=utf-8', 'cache-control': 'no-cache' });
1591
+ return response.end(file);
1592
+ }
1593
+ if (request.method === 'GET' && url.pathname === '/api/salas') {
1594
+ const rooms = await knownRooms(root);
1595
+ const here = basename(roomDir);
1596
+ const seen = await Promise.all(rooms.map(async (one) => ({
1597
+ ...one,
1598
+ here: one.id === here,
1599
+ glance: one.open ? await askRoom(one.open.port, '/api/glance?census=1') : null,
1600
+ })));
1601
+ // The links in force (docs/MACROVERSO.md, fase 3c), each with what crossed: counted from the
1602
+ // lines the serving room keeps beside its record, never from a ledger. `salas` is the id of
1603
+ // the room of Salas, whether or not that room exists yet (phase 3d).
1604
+ const salas = salasRoomId(root);
1605
+ 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) })));
1606
+ 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() });
1607
+ }
1608
+ // The open rooms, each in one line (its glance: who works, who waits, its last conversation;
1609
+ // never its census or the links), for the room's switcher (2026-10-10), asked every few
1610
+ // seconds while a room's page is in view. Read only by this Mac's own pages, as the work is.
1611
+ if (request.method === 'GET' && url.pathname === '/api/salas/open') {
1612
+ const host = String(request.headers.host ?? '');
1613
+ const site = request.headers['sec-fetch-site'];
1614
+ if (!/^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host) || (site !== undefined && site !== 'same-origin' && site !== 'none')) return sendJson(response, 403, { error: t('This is done from Home, on this Mac.') });
1615
+ const here = basename(roomDir);
1616
+ const rooms = (await knownRooms(root)).filter((one) => one.open);
1617
+ const seen = await Promise.all(rooms.map(async (one) => ({
1618
+ id: one.id,
1619
+ name: one.name,
1620
+ port: one.open.port,
1621
+ here: one.id === here,
1622
+ glance: one.id === here ? null : await askRoom(one.open.port, '/api/glance'),
1623
+ })));
1624
+ return sendJson(response, 200, { here, salas: salasRoomId(root), rooms: seen });
1625
+ }
1626
+ // One room as the Macroverso draws it: its memories and the veins between them, read from
1627
+ // that room the way its own NOSTROMO reads them (`/api/memory`, with its designation). Without
1628
+ // `full` no words cross: a far universe is drawn from kinds, zones and sizes alone; the chosen
1629
+ // one asks for them. A phone never reaches this (above).
1630
+ if (request.method === 'GET' && url.pathname === '/api/salas/universe') {
1631
+ const id = url.searchParams.get('id') ?? '';
1632
+ const room = (await knownRooms(root)).find((one) => one.id === id);
1633
+ if (!room) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
1634
+ if (!room.open) return sendJson(response, 200, { id, asleep: true });
1635
+ const answer = await askRoom(room.open.port, `/api/memory?designation=${encodeURIComponent(room.name)}`, { timeoutMs: 4000 });
1636
+ if (answer.outdated) return sendJson(response, 200, { id, outdated: true });
1637
+ if (answer.unreachable) return sendJson(response, 200, { id, sealed: answer.status === 423, unreachable: answer.status !== 423 });
1638
+ return sendJson(response, 200, { id, ...universeOf(answer, { full: url.searchParams.get('full') === '1' }) });
1639
+ }
1640
+ if (request.method === 'POST' && url.pathname === '/api/salas/start') {
1641
+ const payload = await body(request).catch(() => ({}));
1642
+ let room = (await knownRooms(root)).find((one) => one.id === payload.room);
1643
+ // BISHOP's own room is MADRE's: talking to it from the lobby wakes it even the first time,
1644
+ // before it was ever opened (its folder is made then, empty, as `madre lobby` makes it).
1645
+ if (!room && payload.room === salasRoomId(root)) {
1646
+ const folder = await ensureSalasProject(root);
1647
+ room = { id: payload.room, root: folder, open: null };
1648
+ }
1649
+ if (!room) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
1650
+ if (room.open) return sendJson(response, 200, { room: room.id, port: room.open.port, already: true });
1651
+ if (!room.root) return sendJson(response, 404, { error: t('The folder of that project is no longer on this Mac.') });
1652
+ startRoom({ projectRoot: room.root, roomDir: join(root, 'rooms', room.id) });
1653
+ const up = await roomComesUp(root, room.id);
1654
+ if (!up) return sendJson(response, 504, { error: t('The project did not open. What it said on the way up is in start.log, beside its record.') });
1655
+ return sendJson(response, 200, { room: up.id, port: up.open.port });
1656
+ }
1657
+ // ---- The lobby adds projects (2026-10-10, src/salas.mjs). Only this page, on this Mac: a
1658
+ // phone was refused above; another site is refused here, whether it tries to read the
1659
+ // folders (Sec-Fetch-Site) or to add one (Origin), and so is any host name but this Mac's
1660
+ // own, so a name pointed at it from outside reaches nothing. Folders are listed inside the
1661
+ // home folder only; what is listed is names, never what a file says.
1662
+ 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') {
1663
+ const host = String(request.headers.host ?? '');
1664
+ const localHost = /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host);
1665
+ const site = request.headers['sec-fetch-site'];
1666
+ if (!localHost || (site !== undefined && site !== 'same-origin' && site !== 'none')) return sendJson(response, 403, { error: t('This is done from Home, on this Mac.') });
1667
+ if (request.method === 'POST' && request.headers.origin !== `http://${host}`) return sendJson(response, 403, { error: t('This is done from Home, on this Mac.') });
1668
+ const home = process.env.PULSE_LOBBY_HOME || homedir();
1669
+ if (request.method === 'GET' && url.pathname === '/api/salas/folders') {
1670
+ const listed = await listFolders({ path: url.searchParams.get('path') || null, home, stateRoot: root });
1671
+ if (listed.error === 'outside') return sendJson(response, 403, { error: t('Only folders inside your home folder.') });
1672
+ if (listed.error) return sendJson(response, 404, { error: t('That folder is not there.') });
1673
+ return sendJson(response, 200, listed);
1674
+ }
1675
+ if (request.method === 'GET' && url.pathname === '/api/salas/suggestions') {
1676
+ return sendJson(response, 200, { suggestions: await suggestProjects({ home, stateRoot: root }) });
1677
+ }
1678
+ // WHAT WAS WORKED ON LAST: the newest conversations of every room the lobby knows (read from
1679
+ // their folders, open or asleep), and MADRE's summary of them with its local model. The
1680
+ // summary is written in the background and kept until what it summarises changes; it is
1681
+ // written again at most every five minutes, or when the person asks (POST). Without a local
1682
+ // model the lobby shows the conversations as they are, and says why.
1683
+ if (url.pathname === '/api/salas/work' && (request.method === 'GET' || request.method === 'POST')) {
1684
+ const salas = salasRoomId(root);
1685
+ const rooms = (await knownRooms(root)).map((one) => ({ id: one.id, name: one.id === salas ? 'Coordinator' : one.name }));
1686
+ const items = await recentWork({ stateRoot: root, rooms, limit: 5 });
1687
+ const sources = items.map(({ room, name, chat, title, at, said }) => ({ room, name, chat, title, at, last: said?.at(-1) ?? null }));
1688
+ if (!items.length) return sendJson(response, 200, { state: 'empty', sources });
1689
+ if (!lobbyWork.loaded) {
1690
+ lobbyWork.loaded = true;
1691
+ const kept = await readFile(lobbyWork.file, 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
1692
+ if (kept?.bullets?.length) Object.assign(lobbyWork, { key: kept.key, at: kept.at, tried: kept.at, bullets: kept.bullets, model: kept.model ?? null });
1693
+ }
1694
+ const settings = await ollamaSettings();
1695
+ const model = ollama.madreModel ?? ollama.chatModel;
1696
+ if (!ollama.running || !model || !settings.enabled || settings.agent === false) return sendJson(response, 200, { state: 'unavailable', sources, bullets: lobbyWork.bullets, at: lobbyWork.at || null, model: lobbyWork.model });
1697
+ const key = createHash('sha256').update(items.map((item) => `${item.room}:${item.chat}:${item.at}:${item.messages}`).join('|')).digest('hex').slice(0, 16);
1698
+ const asked = request.method === 'POST';
1699
+ const due = !lobbyWork.bullets || (lobbyWork.key !== key && Date.now() - lobbyWork.tried > 5 * 60 * 1000);
1700
+ if (!lobbyWork.running && (asked || due)) {
1701
+ lobbyWork.tried = Date.now();
1702
+ const language = roomLanguage() === 'en' ? 'en' : 'es';
1703
+ lobbyWork.running = ollamaGenerate({ host: ollama.host, model, system: WORK_SYSTEM[language], prompt: workPrompt(items, language), fetchImpl: reportFetch, timeoutMs: 120000, temperature: 0.1 })
1704
+ .then(async (answer) => {
1705
+ const bullets = parseWork(answer.text, items);
1706
+ if (!bullets.length) throw new Error('MADRE answered nothing the lobby could check against the conversations.');
1707
+ Object.assign(lobbyWork, { key, at: Date.now(), bullets, model, error: null });
1708
+ await writeFile(lobbyWork.file, JSON.stringify({ key, at: lobbyWork.at, bullets, model })).catch(() => {});
1709
+ })
1710
+ .catch((error) => { lobbyWork.error = String(error?.message ?? error).slice(0, 200); })
1711
+ .finally(() => { lobbyWork.running = null; });
1712
+ }
1713
+ const state = lobbyWork.running ? 'working' : lobbyWork.bullets ? 'ready' : 'failed';
1714
+ return sendJson(response, 200, { state, sources, bullets: lobbyWork.bullets, at: lobbyWork.at || null, model: lobbyWork.model ?? model, stale: Boolean(lobbyWork.bullets) && lobbyWork.key !== key, error: state === 'failed' ? lobbyWork.error : null });
1715
+ }
1716
+ // RECENT DECISIONS: what the open rooms decided last, as each room keeps it (only the notes
1717
+ // that travel into turns: hold and bridge; never med bay or jettisoned), newest first.
1718
+ // Asked of the rooms at most every five minutes; a room asleep, sealed or older says nothing.
1719
+ if (request.method === 'GET' && url.pathname === '/api/salas/decisions') {
1720
+ if (!lobbyDecisions.running && (!lobbyDecisions.list || Date.now() - lobbyDecisions.at > 5 * 60 * 1000)) {
1721
+ const salas = salasRoomId(root);
1722
+ lobbyDecisions.running = (async () => {
1723
+ const open = (await knownRooms(root)).filter((one) => one.open);
1724
+ const lists = await Promise.all(open.map(async (one) => {
1725
+ const answer = await askRoom(one.open.port, `/api/memory?designation=${encodeURIComponent(one.name)}`, { timeoutMs: 6000 });
1726
+ if (!Array.isArray(answer?.memories)) return [];
1727
+ return answer.memories
1728
+ .filter((note) => note.kind === 'decision' && (note.zone === 'hold' || note.zone === 'bridge') && typeof note.text === 'string')
1729
+ .map((note) => ({ room: one.id, name: one.id === salas ? 'Coordinator' : one.name, text: note.text.slice(0, 260), at: note.created ?? null }));
1730
+ }));
1731
+ lobbyDecisions.list = lists.flat().sort((a, b) => String(b.at ?? '').localeCompare(String(a.at ?? ''))).slice(0, 6);
1732
+ lobbyDecisions.at = Date.now();
1733
+ })().catch(() => {}).finally(() => { lobbyDecisions.running = null; });
1734
+ }
1735
+ if (!lobbyDecisions.list) await lobbyDecisions.running;
1736
+ return sendJson(response, 200, { decisions: lobbyDecisions.list ?? [], at: lobbyDecisions.at || null });
1737
+ }
1738
+ if (request.method === 'POST' && url.pathname === '/api/salas/projects') {
1739
+ const payload = await body(request).catch(() => ({}));
1740
+ const roots = Array.isArray(payload.roots) ? payload.roots.filter((one) => typeof one === 'string').slice(0, 50) : [];
1741
+ if (!roots.length) return sendJson(response, 422, { error: t('Choose at least one folder.') });
1742
+ const added = [];
1743
+ const refused = [];
1744
+ for (const one of roots) {
1745
+ const result = await associateProject({ root: one, home, stateRoot: root });
1746
+ if (result.error) refused.push({ root: one, error: result.error === 'madre' ? t('That folder is MADRE\'s own.') : result.error === 'outside' ? t('Only folders inside your home folder.') : t('That folder is not there.') });
1747
+ else added.push(result);
1748
+ }
1749
+ return sendJson(response, added.length ? 200 : 422, { added, refused });
1750
+ }
1751
+ return sendJson(response, 405, { error: 'method' });
1752
+ }
1753
+ if (request.method === 'POST' && url.pathname === '/api/salas/close') {
1754
+ const payload = await body(request).catch(() => ({}));
1755
+ const room = (await knownRooms(root)).find((one) => one.id === payload.room);
1756
+ if (!room?.open) return sendJson(response, 200, { room: payload.room ?? null, closed: true });
1757
+ if (room.id === basename(roomDir)) {
1758
+ sendJson(response, 202, { room: room.id, closing: true, here: true });
1759
+ setTimeout(() => server.emit('madre:close'), 150);
1760
+ return undefined;
1761
+ }
1762
+ const answer = await askRoom(room.open.port, '/api/room/close', { method: 'POST', body: {} });
1763
+ if (answer.outdated) return sendJson(response, 409, { error: t('That project runs an older MADRE. Update it, or close it from its terminal.'), outdated: true });
1764
+ if (answer.unreachable) return sendJson(response, 502, { error: t('That project did not answer.') });
1765
+ return sendJson(response, 202, { room: room.id, closing: true });
1766
+ }
1767
+ if (request.method === 'POST' && url.pathname === '/api/salas/stop-all') {
1768
+ const rooms = (await knownRooms(root)).filter((one) => one.open);
1769
+ 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)));
1770
+ return sendJson(response, 200, { rooms: rooms.length, stopped: stopped.filter(Boolean).length });
1771
+ }
1772
+ // This room, asked to close by Salas: what is running stops and the room closes the way a
1773
+ // signal closes it (startPulse listens). Nothing here ends the process itself.
1774
+ if (request.method === 'POST' && url.pathname === '/api/room/close') {
1775
+ sendJson(response, 202, { closing: true });
1776
+ setTimeout(() => server.emit('madre:close'), 150);
1777
+ return undefined;
1778
+ }
1779
+ // ---------- ENLACES (docs/MACROVERSO.md, fase 3b) ----------
1780
+ // The side that serves a link. Another room on this Mac asks, from its own process, for the
1781
+ // notes a signed contract lets it read; every lock is here, in the room that hands them over,
1782
+ // and in this order: never a phone, never a page, only a process holding the key of this
1783
+ // Mac; one answer for every contract that does not serve this pair (missing, revoked,
1784
+ // expired, facing the other way: the asker is not told which); sealed by CODE000; over its
1785
+ // hourly ceiling. What crosses is what travels into a turn (`travels()`), of the kinds the
1786
+ // contract names, each text through this room's privacy guard, and nothing of the ledger.
1787
+ if (request.method === 'GET' && url.pathname === '/api/enlace/memories') {
1788
+ if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
1789
+ if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot read across a connection; only a project can.') });
1790
+ const key = await channelKey(root).catch(() => null);
1791
+ if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
1792
+ const here = basename(roomDir);
1793
+ const asking = String(url.searchParams.get('asking') ?? '');
1794
+ const contract = activeContracts(await readConfig(root), { salas: salasRoomId(root) }).find((one) => one.id === url.searchParams.get('contract')) ?? null;
1795
+ if (!mayServe({ contract, serving: here, asking }).ok) return sendJson(response, 404, { error: NO_SUCH_LINK });
1796
+ const sealed = room.motherStatus()?.lockedForMs ?? 0;
1797
+ 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 });
1798
+ if (!memory) return sendJson(response, 503, { error: t('The project has no memory.') });
1799
+ const reads = await countRecent(roomDir, contract.id, 'read');
1800
+ if (reads >= contract.orders.perHour) return sendJson(response, 429, { error: t('This connection was read {n} times in the last hour, its limit. Try again later.', { n: reads }) });
1801
+ const q = String(url.searchParams.get('q') ?? '').replace(/\s+/g, ' ').trim().slice(0, 500);
1802
+ const limit = Math.min(ENLACE_MAX_LIMIT, Math.max(1, Math.trunc(Number(url.searchParams.get('limit'))) || ENLACE_MAX_LIMIT));
1803
+ // The kinds asked for, if any, inside the kinds signed: a reading narrows, never widens.
1804
+ const asked = String(url.searchParams.get('kinds') ?? '').split(',').map((kind) => kind.trim()).filter(Boolean);
1805
+ const kinds = asked.length ? contract.kinds.filter((kind) => asked.includes(kind)) : contract.kinds;
1806
+ await refreshPrivacy();
1807
+ // The same search of notes memory_search makes (src/mcp/memory-server.mjs), untracked: a
1808
+ // reading from another room is not this room leaning on a note. Without words, the latest.
1809
+ const rows = q
1810
+ ? memory.recallMemories(q, { limit: ENLACE_MAX_LIMIT * 3, maxChars: ENLACE_MAX_LIMIT * 3 * 300, queryVector: await memory.embedQuery(q).catch(() => null), track: false })
1811
+ : memory.memories({ limit: ENLACE_MAX_LIMIT * 3 });
1812
+ const notes = rows
1813
+ .filter((row) => memoryTravels(row) && kinds.includes(row.kind))
1814
+ .slice(0, limit)
1815
+ .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 }));
1816
+ // Which agent of the asking room read (S-021): that room's word, checked there against a turn
1817
+ // it is running; here it is only kept, shaped like an agent id or not at all.
1818
+ const reader = /^[a-z][a-z0-9_-]{0,30}$/.test(String(url.searchParams.get('agent') ?? '')) ? url.searchParams.get('agent') : null;
1819
+ await room.record('enlace.read', { contract: contract.id, to: asking, count: notes.length, kinds, query: q ? createHash('sha256').update(q).digest('hex').slice(0, 16) : null, ...(reader ? { agent: reader } : {}) });
1820
+ await noteCrossing(roomDir, { kind: 'read', contract: contract.id, other: asking, count: notes.length, agent: reader });
1821
+ return sendJson(response, 200, { contract: contract.id, from: here, notes });
1822
+ }
1823
+ // The side that asks (fase 3c): a tool of this room's own turn asks this room, and this room
1824
+ // asks the room that serves, by the channel. The same three doors as above — never a phone,
1825
+ // never a page, only the key — because the tool is a process of this Mac and nothing else is.
1826
+ // `room` is the id or the name of the room to read from; the contract is the one in force
1827
+ // that lets THIS room read from THAT one. What came back is counted here (`enlace.received`,
1828
+ // with the query as a hash) and the notes are handed on; nothing of them is kept.
1829
+ if (request.method === 'POST' && url.pathname === '/api/enlace/ask') {
1830
+ if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
1831
+ if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot read across a connection; only a project can.') });
1832
+ const key = await channelKey(root).catch(() => null);
1833
+ if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
1834
+ const payload = await body(request).catch(() => ({}));
1835
+ const here = basename(roomDir);
1836
+ // S-021: a reading belongs to a turn. The tool says which turn and which agent; this room
1837
+ // checks that this very turn is running here now, for that agent, above GHOST, and records
1838
+ // the reading in that turn's conversation. A tool that outlived its turn reads nothing.
1839
+ const turnId = String(payload.turn ?? '');
1840
+ const host = turnId ? liveRooms().find((one) => one.runningTurn?.(turnId)) : null;
1841
+ const running = host?.runningTurn(turnId) ?? null;
1842
+ 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.') });
1843
+ const wanted = String(payload.room ?? '').trim();
1844
+ const rooms = await knownRooms(root);
1845
+ const named = rooms.filter((one) => one.id === wanted);
1846
+ if (!named.length) named.push(...rooms.filter((one) => one.name.toLowerCase() === wanted.toLowerCase()));
1847
+ if (!wanted || !named.length) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
1848
+ const readable = readsFrom(activeContracts(await readConfig(root), { salas: salasRoomId(root) }), here);
1849
+ const link = named.map((source) => ({ source, contract: readable.find((one) => one.from === source.id)?.contract ?? null })).find((one) => one.contract) ?? null;
1850
+ if (!link) return sendJson(response, 404, { error: NO_SUCH_LINK });
1851
+ const { source, contract } = link;
1852
+ const asked = Array.isArray(payload.kinds) ? payload.kinds.map(String) : [];
1853
+ const kinds = asked.length ? contract.kinds.filter((kind) => asked.includes(kind)) : contract.kinds;
1854
+ if (!kinds.length) return sendJson(response, 422, { error: t('This connection lets this project read only: {kinds}.', { kinds: contract.kinds.join(', ') }) });
1855
+ if (!source.open) return sendJson(response, 409, { error: t('That project is asleep. Wake it from Home to read from it.') });
1856
+ const q = String(payload.query ?? '').replace(/\s+/g, ' ').trim().slice(0, 500);
1857
+ const answer = await askEnlace({
1858
+ port: source.open.port, contract, asking: here, q, kinds: asked.length ? kinds : null, limit: payload.limit, key, turn: turnId, agent: running.agent,
1859
+ record: (type, event) => host.record(type, { ...event, query: q ? createHash('sha256').update(q).digest('hex').slice(0, 16) : null }),
1860
+ roomDir,
1861
+ });
1862
+ if (answer.notes) return sendJson(response, 200, { from: source.name, contract: contract.id, notes: answer.notes });
1863
+ if (answer.outdated) return sendJson(response, 409, { error: t('That project runs an older MADRE. Update it, or close it from its terminal.') });
1864
+ if (answer.unreachable) return sendJson(response, 502, { error: t('That project did not answer.') });
1865
+ return sendJson(response, answer.status ?? 502, { error: answer.refused ?? t('That project did not answer.') });
1866
+ }
1867
+ // What another room tells this one by the channel: a link of this room's was signed or
1868
+ // revoked. One line in the ledger, never a message; any other type is refused.
1869
+ if (request.method === 'POST' && url.pathname === '/api/enlace/notice') {
1870
+ if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
1871
+ if (request.headers.origin !== undefined) return sendJson(response, 403, { error: t('A page cannot read across a connection; only a project can.') });
1872
+ const key = await channelKey(root).catch(() => null);
1873
+ if (!key || !sameKey(request.headers[CHANNEL_HEADER], key)) return sendJson(response, 403, { error: t('That is not the key of this Mac.') });
1874
+ const payload = await body(request).catch(() => ({}));
1875
+ if (!ENLACE_NOTICES.includes(payload.type) || !payload.payload || typeof payload.payload !== 'object' || Array.isArray(payload.payload)) return sendJson(response, 422, { error: t('That is not a notice a connection sends.') });
1876
+ await room.record(payload.type, payload.payload);
1877
+ return sendJson(response, 202, { accepted: true });
1878
+ }
1879
+ // Signing and revoking, from Salas (fase 3c). A change of state, so it comes only from this
1880
+ // room's own page or a process of this Mac (src/request-guard.mjs) and never from a phone
1881
+ // (above). Both rooms are told, if they are open: this one straight into its ledger, the
1882
+ // other by the channel; a room that is asleep reads the contract when it wakes.
1883
+ const tellRooms = async (type, contract, rooms) => {
1884
+ if (typeof contract?.rooms?.a !== 'string' || typeof contract?.rooms?.b !== 'string') return;
1885
+ const key = await channelKey(root).catch(() => null);
1886
+ const here = basename(roomDir);
1887
+ const [a, b] = [contract.rooms.a, contract.rooms.b].map((id) => rooms.find((one) => one.id === id) ?? { id, name: id, open: null });
1888
+ for (const [me, other] of [[a, b], [b, a]]) {
1889
+ const role = contract.direction === 'both' ? 'both' : servingRooms(contract).includes(me.id) ? 'serves' : 'reads';
1890
+ const payload = type === 'enlace.signed'
1891
+ ? { contract, other: other.name, otherId: other.id, coordinator: other.id === salasRoomId(root), role, kinds: contract.kinds, expires: contract.expires, orders: contract.orders }
1892
+ : { contract, other: other.name, otherId: other.id, coordinator: other.id === salasRoomId(root) };
1893
+ if (me.id === here) await room.record(type, payload);
1894
+ else if (me.open && key) await noticeEnlace({ port: me.open.port, type, payload, key }).catch(() => null);
1895
+ }
1896
+ };
1897
+ if (request.method === 'POST' && url.pathname === '/api/salas/enlaces') {
1898
+ const payload = await body(request).catch(() => ({}));
1899
+ const rooms = await knownRooms(root);
1900
+ for (const id of [payload.a, payload.b]) if (!rooms.some((one) => one.id === id)) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
1901
+ // Two projects are connected once: signContract checks it under the lock (src/enlaces.mjs).
1902
+ let contract;
1903
+ try {
1904
+ // BISHOP signs only from the room's own process (no Origin: never a page), and the contract
1905
+ // itself holds it to a link to read that expires within a week (D-S13, src/enlaces.mjs).
1906
+ const signed = payload.by === 'bishop' && request.headers.origin === undefined ? { by: 'bishop' } : undefined;
1907
+ // «Include always» only from the page (INI-04 of the 1.0 review): its own route refuses a
1908
+ // process, and signing let one set it anyway, so a project got another's notes every turn.
1909
+ const recall = payload.recall === true && !signed && request.headers.origin !== undefined;
1910
+ 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 });
1911
+ } catch (error) {
1912
+ if (error.code === 'DESIGNATION') return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
1913
+ if (error.code === 'ALREADY') return sendJson(response, 409, { error: t(error.message) });
1914
+ return sendJson(response, error.code === 'UNKNOWN_ROOM' ? 404 : 422, { error: t(error.message) });
1915
+ }
1916
+ await tellRooms('enlace.signed', contract, rooms);
1917
+ return sendJson(response, 200, { contract });
1918
+ }
1919
+ // Fase 3e: the box «recall on every turn» of a link, ticked or cleared by the human from Salas
1920
+ // (never a phone, never a process: the page asks). It changes what each turn of the room that
1921
+ // reads is handed, not what that room may read; the contract is otherwise untouched.
1922
+ if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/recall') {
1923
+ const payload = await body(request).catch(() => ({}));
1924
+ if (request.headers.origin === undefined) return sendJson(response, 403, { error: t('This is changed by you, from Home.') });
1925
+ if (typeof payload.recall !== 'boolean') return sendJson(response, 422, { error: t('recall is on or off.') });
1926
+ const contract = await recallContract(root, String(payload.id ?? ''), payload.recall, { salas: salasRoomId(root) });
1927
+ if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
1928
+ return sendJson(response, 200, { contract });
1929
+ }
1930
+ if (request.method === 'POST' && url.pathname === '/api/salas/enlaces/revoke') {
1931
+ const payload = await body(request).catch(() => ({}));
1932
+ const id = String(payload.id ?? '');
1933
+ // Told once: a link revoked twice keeps its first stamp and says nothing new.
1934
+ const already = Boolean((await readConfig(root)).enlaces?.[id]?.revoked);
1935
+ const contract = await revokeContract(root, id);
1936
+ if (!contract) return sendJson(response, 404, { error: NO_SUCH_LINK });
1937
+ if (!already) await tellRooms('enlace.revoked', contract, await knownRooms(root)).catch(() => null);
1938
+ return sendJson(response, 200, { contract });
1939
+ }
1940
+ // Taking a project out of MADRE (2026-10-10), from Home and only from its page on this Mac, as
1941
+ // adding one is. Never the Coordinator's own, never the room that is asked. Open, it is closed
1942
+ // first; its connections end with it, so none is left pointing at a project MADRE no longer
1943
+ // knows; then its record goes aside (forgetProject). Its folder on the Mac is not touched.
1944
+ if (request.method === 'POST' && url.pathname === '/api/salas/forget') {
1945
+ const host = String(request.headers.host ?? '');
1946
+ const site = request.headers['sec-fetch-site'];
1947
+ if (!/^(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('This is done from Home, on this Mac.') });
1948
+ const payload = await body(request).catch(() => ({}));
1949
+ const id = String(payload.room ?? '');
1950
+ const salas = salasRoomId(root);
1951
+ if (id === salas || id === basename(roomDir)) return sendJson(response, 422, { error: t('The Coordinator\'s project stays in MADRE.') });
1952
+ const find = async () => (await knownRooms(root)).find((one) => one.id === id) ?? null;
1953
+ let one = await find();
1954
+ if (!one) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
1955
+ if (one.open) {
1956
+ const answer = await askRoom(one.open.port, '/api/room/close', { method: 'POST', body: {} });
1957
+ if (answer.outdated) return sendJson(response, 409, { error: t('That project runs an older MADRE. Update it, or close it from its terminal.'), outdated: true });
1958
+ if (answer.unreachable) return sendJson(response, 502, { error: t('That project did not answer.') });
1959
+ for (let tries = 0; tries < 40 && one?.open; tries += 1) { await new Promise((resolve) => setTimeout(resolve, 250)); one = await find(); }
1960
+ if (one?.open) return sendJson(response, 504, { error: t('That project did not close. Close it and try again.') });
1961
+ }
1962
+ const ended = [];
1963
+ for (const contract of activeContracts(await readConfig(root), { salas })) {
1964
+ if (contract.rooms.a !== id && contract.rooms.b !== id) continue;
1965
+ const revoked = await revokeContract(root, contract.id);
1966
+ if (!revoked) continue;
1967
+ ended.push(revoked.id);
1968
+ await tellRooms('enlace.revoked', revoked, await knownRooms(root)).catch(() => null);
1969
+ }
1970
+ const forgotten = await forgetProject({ stateRoot: root, id });
1971
+ // A room from before MADRE kept the path has no record to set aside: closed, it is gone.
1972
+ if (forgotten.error && await find()) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
1973
+ return sendJson(response, 200, { id, name: forgotten.name ?? one?.name ?? id, root: forgotten.root ?? null, ended });
1974
+ }
1975
+ // ---------- fin ENLACES ----------
1976
+ // Where the person was, to pick it up on the other side (bridge/handoff.mjs).
1977
+ if (url.pathname === '/api/handoff') {
1978
+ if (request.method === 'GET') return sendJson(response, 200, await readHandoff(root));
1979
+ if (request.method === 'POST') {
1980
+ const payload = await body(request).catch(() => ({}));
1981
+ const { chats } = await listChats(roomDir);
1982
+ const chat = chats.find((one) => one.id === payload.chat) ?? chats.find((one) => one.active) ?? null;
1983
+ const saved = await writeHandoff(root, viaPhone ? 'phone' : 'mac', { room: basename(roomDir), project: basename(canonicalProjectRoot), port: openPort, chat: chat?.id ?? null, chatTitle: chat?.title ?? null, anchor: payload.anchor, draft: payload.draft, kind: phoneKind });
1984
+ return sendJson(response, 200, { saved });
1985
+ }
1986
+ }
895
1987
  if (request.method === 'GET' && url.pathname === '/api/state') {
1988
+ // `?chat=` reads another conversation, held open beside the one on screen; the room-wide
1989
+ // parts are the same, the record and what is running in it are that conversation's.
1990
+ const here = await scopeOf(url);
1991
+ if (here.error) return sendJson(response, 404, here);
1992
+ const room = here.room;
1993
+ const store = here.store;
896
1994
  return sendJson(response, 200, {
1995
+ ...(here.main ? {} : { chat: here.id }),
897
1996
  projectRoot,
1997
+ // The project's name as this system writes paths (WEB-11): the page cut it at «/», and on
1998
+ // Windows the name was the whole path, so the key for #3 never matched.
1999
+ projectName: basename(canonicalProjectRoot),
898
2000
  platform: process.platform,
2001
+ // Behind the bridge, the page is on a phone and knows it. On the Mac it learns whether a
2002
+ // phone is paired, so it keeps its place for the phone only when there is one.
2003
+ device: viaPhone ? { kind: phoneKind } : null,
2004
+ // Whether this Mac offers the phone (a preview in 1.0); behind the bridge, it does.
2005
+ phonePreview: viaPhone ? true : await phonePreview(),
2006
+ // The room of Salas knows it is: true only there, false in every other room.
2007
+ salas: salasRoom,
2008
+ room: basename(roomDir),
2009
+ phones: viaPhone ? undefined : await phonePreview() ? (await readStore(root).catch(() => ({ devices: [] }))).devices.length : 0,
899
2010
  // Which MADRE this is. The page prints it in the corner: the one fact a bug report
900
2011
  // always needs and nobody ever remembers to ask for.
901
2012
  version: PACKAGE.version,
2013
+ // Which start of this process the page is talking to. The page draws its logo when this
2014
+ // changes — MADRE restarted — and not on every reload of the same running room.
2015
+ boot: BOOT_ID,
902
2016
  // What language this machine set MADRE to, for a browser that has never chosen. It is
903
2017
  // the language the room is SPEAKING, not the one written in the file: PULSE_LANGUAGE
904
2018
  // wins over the config here as it does everywhere else, and reading the file again
905
2019
  // would hand the page a different answer from the one the server itself is using.
906
2020
  language: roomLanguage(),
907
2021
  // Which conversation this page is looking at, and the others it could open.
908
- chats: await listChats(roomDir),
2022
+ chats: await chatsView(),
909
2023
  agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })),
910
2024
  ash: { enabled: room.ashEnabled() },
911
2025
  choices: { enabled: room.choicesEnabled() },
2026
+ runs: { enabled: room.runsEnabled() },
912
2027
  // Read live, not from the config this process loaded at boot. A switch the human moves
913
2028
  // in MODULES while the room is open has to be the answer the room gives afterwards —
914
2029
  // otherwise the switch says on and the browser keeps refusing, which is how a setting
@@ -916,6 +2031,7 @@ export async function createPulseServer({
916
2031
  ripleyWeb: Boolean((await readConfig(root)).modules?.ripley?.web),
917
2032
  ripley: { enabled: await ripleyOn() },
918
2033
  softTokenBudget,
2034
+ override: publicOverride(overrideConfig),
919
2035
  timeouts: Object.fromEntries(agents.map((agent) => [agent.id, room.timeoutFor(agent.id)])),
920
2036
  delegation: { enabled: room.settings().delegation, maxPlanSteps: room.settings().maxPlanSteps },
921
2037
  plans: room.activePlans(),
@@ -933,6 +2049,10 @@ export async function createPulseServer({
933
2049
  if (request.method === 'GET' && url.pathname === '/api/events') {
934
2050
  const sinceValue = request.headers['last-event-id'] ?? url.searchParams.get('since');
935
2051
  const since = sinceValue === undefined || sinceValue === null || sinceValue === '' ? null : Number(sinceValue);
2052
+ // `?chat=` watches a conversation held open beside the one on screen, on a feed of its own.
2053
+ const here = await scopeOf(url);
2054
+ if (here.error) return sendJson(response, 404, here);
2055
+ const feed = here.main ? mainFeed : await watchHeld(here.entry);
936
2056
  response.writeHead(200, {
937
2057
  'content-type': 'text/event-stream',
938
2058
  'cache-control': 'no-cache',
@@ -941,17 +2061,22 @@ export async function createPulseServer({
941
2061
  response.write(': connected\n\n');
942
2062
  // Replay what this client missed between /api/state and this connection,
943
2063
  // then register it so the shared cursor delivers everything newer.
944
- const events = Number.isFinite(since) ? await store.readAll() : [];
2064
+ const events = Number.isFinite(since) ? await feed.store.readAll() : [];
945
2065
  let floor = Number.isFinite(since) ? since : 0;
946
2066
  for (const event of events) {
947
- if (event.sequence > floor && event.sequence <= lastBroadcastSequence) {
948
- writeEvent(response, event);
2067
+ if (event.sequence > floor && event.sequence <= feed.sent) {
2068
+ writeEvent(response, event, feed);
949
2069
  floor = event.sequence;
950
2070
  }
951
2071
  }
952
- clients.set(response, floor);
953
- request.on('close', () => clients.delete(response));
954
- void broadcastPending();
2072
+ feed.clients.set(response, floor);
2073
+ request.on('close', () => {
2074
+ feed.clients.delete(response);
2075
+ // The last page watching a held conversation went away: it closes unless it is at work. A
2076
+ // moment later, so a page that is only reconnecting finds the same room.
2077
+ if (!here.main) setTimeout(() => { void releaseIfIdle(here.id, here.room); }, 1500).unref?.();
2078
+ });
2079
+ void pump(feed);
955
2080
  return;
956
2081
  }
957
2082
  // Copying and moving, by the human's own hand. Both ends are resolved inside the project
@@ -959,8 +2084,19 @@ export async function createPulseServer({
959
2084
  // room guards from agents ask for the project's designation first.
960
2085
  if (request.method === 'POST' && url.pathname === '/api/tree/op') {
961
2086
  const payload = await body(request).catch(() => ({}));
2087
+ // Pasting a copy where its name is taken — beside itself, most often — names the copy
2088
+ // instead of refusing. Only a copy, and only when the page asked: a move or a rename onto
2089
+ // a taken name is still a mistake the human should hear about.
2090
+ if (payload.operation === 'copy' && payload.unique) {
2091
+ payload.to = await freeCopyPath(payload.to, { word: t('copy'), taken: async (path) => {
2092
+ const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.';
2093
+ const parent = await resolveInside(canonicalProjectRoot, dir);
2094
+ return Boolean(parent) && access(join(parent, path.slice(path.lastIndexOf('/') + 1))).then(() => true, () => false);
2095
+ } });
2096
+ }
962
2097
  const plan = planFileOp(payload);
963
- if (!plan.ok) return sendJson(response, 400, { error: plan.error });
2098
+ if (!plan.ok) return sendJson(response, 400, { error: t(plan.error) });
2099
+ if (!plan.guarded) plan.guarded = await needsDesignationOnDisk(canonicalProjectRoot, plan.from, plan.to);
964
2100
  if (plan.guarded && !designationOk(payload.designation)) {
965
2101
  return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.'), guarded: true });
966
2102
  }
@@ -980,7 +2116,7 @@ export async function createPulseServer({
980
2116
  else if (plan.operation === 'copy') await cp(source, destination, { recursive: true, errorOnExist: true, force: false });
981
2117
  else await rename(source, destination);
982
2118
  } catch (error) {
983
- return sendJson(response, 500, { error: error.message });
2119
+ return sendJson(response, 500, { error: t('That could not be done: {error}', { error: error.message }) });
984
2120
  }
985
2121
  await room.record(CREATIONS.includes(plan.operation) ? 'project.file.created' : 'project.file.moved', { operation: plan.operation, from: plan.from, to: plan.to, guarded: plan.guarded });
986
2122
  return sendJson(response, 200, { ok: true, operation: plan.operation, from: plan.from, to: plan.to });
@@ -1005,9 +2141,43 @@ export async function createPulseServer({
1005
2141
  'x-content-type-options': 'nosniff',
1006
2142
  'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; img-src data:",
1007
2143
  'content-disposition': `inline; filename="${encodeURIComponent(basename(file.path))}"`,
2144
+ // When the editor opened it: saving checks the file is still the one it opened.
2145
+ 'x-mtime': String(file.mtimeMs ?? ''),
1008
2146
  });
1009
2147
  return response.end(file.body);
1010
2148
  }
2149
+ // The editor saves a project file. The human's own hand, so no mode and no lease — but the
2150
+ // same two fences the file manager keeps: inside the project after following links, and the
2151
+ // zones the room guards (.git, .pulse, .madre, a .env) ask for the project's designation.
2152
+ // A new, empty file from the Finder; guarded paths ask for the designation, like saving.
2153
+ if (request.method === 'POST' && url.pathname === '/api/files') {
2154
+ const payload = await body(request).catch(() => ({}));
2155
+ const relative = String(payload.path ?? '').trim().replace(/^\/+/, '');
2156
+ if (relative && await needsDesignationOnDisk(canonicalProjectRoot, relative) && !designationOk(payload.designation)) {
2157
+ return sendJson(response, 403, { error: t('This file is one the project guards. Type the project name to save it.'), guarded: true });
2158
+ }
2159
+ const result = await createEmptyFile(canonicalProjectRoot, relative);
2160
+ if (result.status !== 201) return sendJson(response, result.status, { error: t(result.error) });
2161
+ await room.record('project.file.created', { path: result.path });
2162
+ return sendJson(response, 201, { ok: true, path: result.path });
2163
+ }
2164
+ if (request.method === 'PUT' && url.pathname === '/api/files') {
2165
+ // The editor saves up to MAX_EDIT_BYTES; written as JSON a file can take a few times its
2166
+ // size (every newline and quote escaped). Too large is said as that, not as «Nothing to save».
2167
+ let payload;
2168
+ try { payload = await body(request, { limit: MAX_EDIT_BYTES * 3 + 65536 }); } catch (error) {
2169
+ return sendJson(response, error.status === 413 ? 413 : 400, { error: error.status === 413 ? t('The editor saves files up to {mb} MB.', { mb: MAX_EDIT_BYTES / 1024 / 1024 }) : t('That could not be read.') });
2170
+ }
2171
+ const relative = String(payload.path ?? '').replace(/^\/+/, '');
2172
+ if (!relative) return sendJson(response, 400, { error: t('Nothing to save.') });
2173
+ if (await needsDesignationOnDisk(canonicalProjectRoot, relative) && !designationOk(payload.designation)) {
2174
+ return sendJson(response, 403, { error: t('This file is one the project guards. Type the project name to save it.'), guarded: true });
2175
+ }
2176
+ const result = await writeEdited(canonicalProjectRoot, relative, payload.content, { mtimeMs: payload.mtimeMs, force: payload.force === true });
2177
+ if (result.status !== 200) return sendJson(response, result.status, { error: result.status === 413 ? t('The editor saves files up to {mb} MB.', { mb: MAX_EDIT_BYTES / 1024 / 1024 }) : t(result.error), changed: result.changed, mtimeMs: result.mtimeMs });
2178
+ await room.record('project.file.saved', { path: relative, bytes: result.bytes });
2179
+ return sendJson(response, 200, { ok: true, bytes: result.bytes, mtimeMs: result.mtimeMs });
2180
+ }
1011
2181
  // RIPLEY: the project rendered as a site, inside a frame that runs scripts but
1012
2182
  // reaches nothing: an opaque origin (sandbox without same-origin), no network
1013
2183
  // (connect-src none), no forms, no top navigation, assets only from MADRE's
@@ -1015,7 +2185,7 @@ export async function createPulseServer({
1015
2185
  // resolve to the same route.
1016
2186
  const previewMatch = request.method === 'GET' && url.pathname.match(/^\/preview\/(project|attachments)\/(.*)$/);
1017
2187
  if (previewMatch) {
1018
- if (!(await ripleyOn())) return sendJson(response, 412, { error: t('RIPLEY is off. Enable it in MODULES to render files.') });
2188
+ if (!(await ripleyOn())) return sendJson(response, 412, { error: t('Preview is off. Enable it in MODULES to render files.') });
1019
2189
  const which = previewMatch[1] === 'attachments' ? attachmentsRoot : canonicalProjectRoot;
1020
2190
  const relative = decodeURIComponent(previewMatch[2]);
1021
2191
  const file = await readServable(which, relative);
@@ -1034,6 +2204,13 @@ export async function createPulseServer({
1034
2204
  'content-disposition': `inline; filename="${encodeURIComponent(basename(file.path))}"`,
1035
2205
  };
1036
2206
  if (isPage) headers['content-security-policy'] = `sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline' ${origin}; style-src 'unsafe-inline' ${origin}; img-src ${origin} data: blob:; font-src ${origin} data:; media-src ${origin} data: blob:; connect-src 'none'; frame-src 'none'; object-src 'none'; form-action 'none'; base-uri 'none'`;
2207
+ // Everything else is sealed too (SRV-02, 2026-10-12): an SVG or an XML file carries scripts,
2208
+ // and opened on its own — a link in an answer, a frame the project's server navigates — it
2209
+ // ran with the project's origin and reached its API. Sealed, it is drawn and runs nothing.
2210
+ // A PDF keeps the browser's own viewer, which a sealed document cannot open; the scripts and
2211
+ // styles a page links are left as they were (opened on their own they are text, and a
2212
+ // worker the page starts would be shut by a policy meant for documents).
2213
+ else if (!/^(application\/pdf|text\/css|(text|application)\/javascript)\b/i.test(file.contentType ?? '')) headers['content-security-policy'] = `sandbox; default-src 'none'; img-src ${origin} data:; style-src 'unsafe-inline'; media-src ${origin} data: blob:`;
1037
2214
  response.writeHead(200, headers);
1038
2215
  return response.end(bodyOut);
1039
2216
  }
@@ -1045,15 +2222,46 @@ export async function createPulseServer({
1045
2222
  return sendJson(response, error.status ?? 400, { error: error.message });
1046
2223
  }
1047
2224
  const name = decodeURIComponent(request.headers['x-pulse-filename'] ?? url.searchParams.get('name') ?? 'attachment');
1048
- const record = await room.registerAttachment(await storeAttachment(attachmentsRoot, { name, bytes }));
2225
+ const here = await scopeOf(url);
2226
+ if (here.error) return sendJson(response, 404, here);
2227
+ const record = await here.room.registerAttachment(await storeAttachment(attachmentsRoot, { name, bytes }));
1049
2228
  return sendJson(response, 201, { attachment: { id: record.id, name: record.name, fileName: record.fileName, size: record.size, contentType: record.contentType, url: `/api/files?root=attachments&path=${encodeURIComponent(record.fileName)}` } });
1050
2229
  }
2230
+ // The key for #3 and #4, checked here (src/override.mjs): what was typed, once or twice, and a
2231
+ // pass back for that agent and mode. Never from a phone: #3 and #4 are opened on the Mac.
2232
+ if (request.method === 'POST' && url.pathname === '/api/override/arm') {
2233
+ if (viaPhone) return sendJson(response, 403, { error: t('#3 EDIT and #4 FULL ACCESS are opened from the Mac.') });
2234
+ const payload = await body(request).catch(() => ({}));
2235
+ const mode = Number(payload.mode);
2236
+ const agent = String(payload.agent ?? '');
2237
+ 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.') });
2238
+ const now = Date.now();
2239
+ while (keyMisses.length && keyMisses[0] < now - 60000) keyMisses.shift();
2240
+ if (keyMisses.length >= 8) return sendJson(response, 429, { error: t('Too many wrong keys. Wait a minute and try again.') });
2241
+ if (!keyHolds(overrideConfig, { mode, words: payload.words, designation: basename(canonicalProjectRoot) })) {
2242
+ keyMisses.push(now);
2243
+ return sendJson(response, 403, { error: t('That is not it. Try again, or cancel.'), denied: true });
2244
+ }
2245
+ return sendJson(response, 200, issuePass(agent, mode));
2246
+ }
1051
2247
  if (request.method === 'GET' && url.pathname === '/api/settings') {
1052
2248
  await refreshPrivacy();
1053
- return sendJson(response, 200, { settings: effectiveSettings(), config: await readConfig(root), sessions, sessionsAt, loggingIn, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })) });
2249
+ const stored = await readConfig(root);
2250
+ if (stored.override) stored.override = publicOverride(stored.override);
2251
+ return sendJson(response, 200, { settings: effectiveSettings(), config: stored, sessions, sessionsAt, loggingIn, agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })) });
1054
2252
  }
1055
2253
  if (request.method === 'POST' && url.pathname === '/api/settings') {
1056
- return sendJson(response, 200, { settings: await applySettings(await body(request)) });
2254
+ const patch = await body(request);
2255
+ // Permissions and the key that opens #3 and #4 are changed on the Mac.
2256
+ if (viaPhone && (patch?.scopes !== undefined || patch?.override !== undefined)) return sendJson(response, 403, { error: t('Permissions are changed on the Mac.') });
2257
+ if (patch?.override && typeof patch.override === 'object') {
2258
+ const proof = patch.override.proof;
2259
+ delete patch.override.proof;
2260
+ if (changeNeedsPhrase(overrideConfig) && !(typeof proof === 'string' && proof.trim() && phraseHash(overrideConfig.phrase.salt, proof) === overrideConfig.phrase.hash)) {
2261
+ return sendJson(response, 403, { error: t('Type your current phrase to change the keys.'), needsPhrase: true });
2262
+ }
2263
+ }
2264
+ return sendJson(response, 200, { settings: await applySettings(patch) });
1057
2265
  }
1058
2266
  // NOSTROMO: the archive is behind the project designation, like CONTROL.
1059
2267
  // Sentinel: reports, settings, the manual road (a prefilled issue) and the automatic one.
@@ -1084,7 +2292,8 @@ export async function createPulseServer({
1084
2292
  const moduleRoute = findModuleRoute(request.method, url.pathname);
1085
2293
  if (moduleRoute) {
1086
2294
  const payload = request.method === 'POST' ? await body(request).catch(() => ({})) : {};
1087
- const result = await moduleRoute.route.handler(await moduleContext(), { request, url, params: moduleRoute.params, payload });
2295
+ // `viaPhone` (encargo §6, excepción 4): a module can refuse the phone by its own hand.
2296
+ const result = await moduleRoute.route.handler(await moduleContext(), { request, url, params: moduleRoute.params, payload, viaPhone });
1088
2297
  return sendJson(response, result.status ?? 200, result.body ?? {});
1089
2298
  }
1090
2299
  // Where this room's tokens go: what each block of the briefing has cost, how much came back
@@ -1111,7 +2320,7 @@ export async function createPulseServer({
1111
2320
  if (!/^https?:$/.test(target.protocol)) return sendJson(response, 400, { error: t('Only http and https.') });
1112
2321
  const local = ['127.0.0.1', 'localhost', '[::1]'].includes(target.hostname);
1113
2322
  if (!local && !(await readConfig(root)).modules?.ripley?.web) {
1114
- return sendJson(response, 403, { error: t('RIPLEY reaches this computer only · turn the web on in MODULES to open an address outside it') });
2323
+ return sendJson(response, 403, { error: t('PREVIEW reaches this computer only · turn the web on in MODULES to open an address outside it') });
1115
2324
  }
1116
2325
  try {
1117
2326
  const controller = new AbortController();
@@ -1124,32 +2333,36 @@ export async function createPulseServer({
1124
2333
  }
1125
2334
  }
1126
2335
  if (request.method === 'GET' && url.pathname === '/api/ports') {
1127
- if (!(await ripleyOn())) return sendJson(response, 412, { error: t('RIPLEY is off. Enable it in MODULES to render files.') });
1128
- let listening = { ports: [], wider: [] };
1129
- try {
1130
- const { stdout } = await execFileAsync('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], { timeout: 4000, maxBuffer: 2 * 1024 * 1024 });
1131
- listening = classifyListening(stdout, { self: request.socket.localPort });
1132
- } catch { return sendJson(response, 200, { ports: [], wider: [], probed: false }); }
1133
- const ports = [];
1134
- for (const found of listening.ports.slice(0, 24)) {
1135
- const probe = await probePort(found.port, { fetchImpl: reportFetch });
1136
- if (probe.ok) ports.push({ ...found, status: probe.status, html: probe.html, contentType: probe.contentType, framable: probe.framable });
1137
- }
1138
- // What is listening on every interface travels too. It is not offered — this room does not
1139
- // help anyone frame something reachable from the network — but saying nothing about it is
1140
- // what made an empty list read as «there is nothing here» when there plainly was.
1141
- return sendJson(response, 200, { ports, wider: listening.wider.slice(0, 8).map(({ port, command }) => ({ port, command })), probed: true });
2336
+ if (!(await ripleyOn())) return sendJson(response, 412, { error: t('Preview is off. Enable it in MODULES to render files.') });
2337
+ // Only this project's servers, read by the servers sentinel: the same reading its watch
2338
+ // makes, so the menu and the live updates never disagree about what is here.
2339
+ const read = await serverSentinel.scan();
2340
+ return sendJson(response, 200, { ...read, wider: read.wider.slice(0, 8) });
1142
2341
  }
1143
2342
  if (request.method === 'GET' && url.pathname === '/api/questions') {
1144
2343
  const limit = Math.min(5, Math.max(1, Number(url.searchParams.get('limit') ?? 3) || 3));
1145
- return sendJson(response, 200, { questions: room.questions({ limit }) });
2344
+ const here = await scopeOf(url);
2345
+ if (here.error) return sendJson(response, 404, here);
2346
+ return sendJson(response, 200, { questions: here.room.questions({ limit }) });
1146
2347
  }
1147
2348
  if (request.method === 'GET' && url.pathname === '/api/decisions') {
1148
2349
  const limit = Math.min(5, Math.max(1, Number(url.searchParams.get('limit') ?? 3) || 3));
1149
- return sendJson(response, 200, { decisions: room.decisions({ limit }) });
2350
+ const here = await scopeOf(url);
2351
+ if (here.error) return sendJson(response, 404, here);
2352
+ return sendJson(response, 200, { decisions: here.room.decisions({ limit }) });
1150
2353
  }
2354
+ // The economy of the room: every conversation's turns, not only the open one's. The card and
2355
+ // the panel say «this room», and they used to weigh one conversation's ledger under that name.
1151
2356
  if (url.pathname === '/api/economy' && request.method === 'GET') {
1152
- return sendJson(response, 200, economy(await store.readAll()));
2357
+ const weighed = [];
2358
+ for (const chat of (await listChats(roomDir)).chats) {
2359
+ const text = await readFile(chatLedger(roomDir, chat.id), 'utf8').catch(() => '');
2360
+ for (const line of text.split('\n')) {
2361
+ if (!line.includes('"turn.cost"')) continue;
2362
+ try { const event = JSON.parse(line); if (event.type === 'turn.cost') weighed.push(event); } catch { /* a torn last line */ }
2363
+ }
2364
+ }
2365
+ return sendJson(response, 200, { ...economy(weighed), conversations: (await listChats(roomDir)).chats.length });
1153
2366
  }
1154
2367
 
1155
2368
  // What left this machine: every address MADRE may reach, whether it is on today, and the
@@ -1189,7 +2402,7 @@ export async function createPulseServer({
1189
2402
  if (eyecatVerdict) {
1190
2403
  const payload = await body(request).catch(() => ({}));
1191
2404
  if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
1192
- if (!memory) return sendJson(response, 503, { error: t('The room has no memory.') });
2405
+ if (!memory) return sendJson(response, 503, { error: t('The project has no memory.') });
1193
2406
  const finding = eyecat.findings().find((item) => item.key === payload.key);
1194
2407
  if (!finding) return sendJson(response, 404, { error: t('EYECAT is not holding that one.') });
1195
2408
  const settled = eyecat.settle(finding.key, { verdict: eyecatVerdict[1] === 'confirm' ? 'aberration' : 'dismissed' });
@@ -1229,6 +2442,13 @@ export async function createPulseServer({
1229
2442
  // PRIVACY: the terms live in config.json only; the ledger records counts, never words.
1230
2443
  // Which language MADRE speaks. The page keeps its own copy so the first paint needs no
1231
2444
  // round trip; this is the durable one, shared by every browser on this machine.
2445
+ // The looks every page shares (src/looks.mjs): Home and each project are different origins,
2446
+ // so the browser alone kept a choice for one page only.
2447
+ if (request.method === 'GET' && url.pathname === '/api/looks') return sendJson(response, 200, await readLooks(root));
2448
+ if (request.method === 'POST' && url.pathname === '/api/looks') {
2449
+ const answer = await changeLooks(root, await body(request).catch(() => null));
2450
+ return sendJson(response, answer.error ? 400 : 200, answer);
2451
+ }
1232
2452
  if (request.method === 'POST' && url.pathname === '/api/language') {
1233
2453
  const payload = await body(request).catch(() => ({}));
1234
2454
  const language = payload.language === 'en' ? 'en' : 'es';
@@ -1238,7 +2458,7 @@ export async function createPulseServer({
1238
2458
  }
1239
2459
  if (request.method === 'GET' && url.pathname === '/api/privacy') {
1240
2460
  await refreshPrivacy();
1241
- return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, ...privacy.guards, exposure: room.privacyExposure(await store.readAll()) });
2461
+ return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, ...privacy.guards, exposure: room.privacyExposure(await projectEvents()) });
1242
2462
  }
1243
2463
  if (request.method === 'POST' && url.pathname === '/api/privacy') {
1244
2464
  const patch = await body(request).catch(() => ({}));
@@ -1252,36 +2472,98 @@ export async function createPulseServer({
1252
2472
  await refreshPrivacy();
1253
2473
  // The room records that the setting changed and what it is now, never the words.
1254
2474
  await room.record('privacy.updated', { terms: privacy.terms.length, marker: privacy.marker, ...privacy.guards });
1255
- return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, ...privacy.guards, exposure: room.privacyExposure(await store.readAll()) });
2475
+ return sendJson(response, 200, { terms: privacy.terms, marker: privacy.marker, ...privacy.guards, exposure: room.privacyExposure(await projectEvents()) });
1256
2476
  }
1257
2477
  if (request.method === 'POST' && url.pathname === '/api/privacy/purge') {
1258
2478
  const payload = await body(request).catch(() => ({}));
1259
2479
  if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
1260
2480
  await refreshPrivacy();
1261
- if (!privacy.enabled) return sendJson(response, 412, { error: t('No private terms are set. Write them first.') });
2481
+ if (!privacy.enabled) return sendJson(response, 412, { error: t('No hidden data is set. Write it first.') });
1262
2482
  const result = await room.purgePrivate();
1263
2483
  // The log was rewritten in place: the broadcaster's byte offset is stale, the sequences are not.
1264
- tailOffset = (await store.tail(0)).offset;
1265
- return sendJson(response, 200, { purged: result, exposure: room.privacyExposure(await store.readAll()) });
2484
+ mainFeed.offset = (await store.tail(0)).offset;
2485
+ // And every other conversation of the project too (LOG-03 of the 1.0 review): the purge
2486
+ // cleaned only the one on screen, the others kept the words in plain text and sent them to
2487
+ // the providers the next time they were opened, while the count said nothing was left.
2488
+ for (const { store: other, held } of await otherLedgers()) {
2489
+ const done = await other.rewrite((event) => {
2490
+ const { value, hits } = privacy.redactDeep(event.payload);
2491
+ return hits ? { ...event, payload: value } : event;
2492
+ });
2493
+ result.events += done.changed;
2494
+ if (held?.feed) held.feed.offset = (await other.tail(0)).offset;
2495
+ }
2496
+ return sendJson(response, 200, { purged: result, exposure: room.privacyExposure(await projectEvents()) });
1266
2497
  }
1267
2498
  // The human's verdict on a reply, for the dataset: good, bad, none.
1268
2499
  if (request.method === 'POST' && url.pathname === '/api/messages/rate') {
1269
2500
  const payload = await body(request).catch(() => ({}));
1270
2501
  try {
1271
- const event = await room.rateMessage(String(payload.messageId ?? ''), String(payload.rating ?? ''));
2502
+ const here = await scopeOf(url);
2503
+ if (here.error) return sendJson(response, 404, here);
2504
+ const event = await here.room.rateMessage(String(payload.messageId ?? ''), String(payload.rating ?? ''));
1272
2505
  return sendJson(response, 200, { rated: event.payload });
1273
2506
  } catch (error) { return sendJson(response, 400, { error: error.message }); }
1274
2507
  }
1275
2508
  // Release channel: current, latest on npm, and the command for how this copy runs.
1276
2509
  // What this version brings, once, to someone who came from an older one.
1277
2510
  if (request.method === 'GET' && url.pathname === '/api/whats-new') {
1278
- const news = await whatsNew({ root, version: PACKAGE.version, returning: novelty.returning, any: url.searchParams.get('any') === '1' });
1279
- return sendJson(response, 200, { ...news, changes: { es: releaseUrl(PACKAGE.repository, PACKAGE.version, 'es'), en: releaseUrl(PACKAGE.repository, PACKAGE.version, 'en') } });
2511
+ // `version`, with `any`: another version's sheet, to look at it before it ships. It reads
2512
+ // and never marks anything as seen; only a sheet with notes can be asked for.
2513
+ const any = url.searchParams.get('any') === '1';
2514
+ const asked = url.searchParams.get('version');
2515
+ const version = any && asked && notesFor(asked) ? asked : PACKAGE.version;
2516
+ const news = await whatsNew({ root, version, returning: novelty.returning, any });
2517
+ return sendJson(response, 200, { ...news, preview: version !== PACKAGE.version, newest: newestNotes(), changes: { es: releaseUrl(PACKAGE.repository, version, 'es'), en: releaseUrl(PACKAGE.repository, version, 'en') } });
1280
2518
  }
1281
2519
  if (request.method === 'POST' && url.pathname === '/api/whats-new/seen') {
1282
2520
  await markSeen(root, PACKAGE.version);
1283
2521
  return sendJson(response, 200, { seen: PACKAGE.version });
1284
2522
  }
2523
+ // Asking for a star or a review (src/review-ask.mjs): a page in view says so once a minute and
2524
+ // hears whether a moment is due; the answer ends the asking or lets that moment pass. A phone
2525
+ // is never asked.
2526
+ if (request.method === 'POST' && url.pathname === '/api/review-ask/tick') {
2527
+ if (viaPhone) return sendJson(response, 200, { due: null });
2528
+ // Never on the day of the bonjour: one welcome is enough for a day.
2529
+ return sendJson(response, 200, await tickAsk(root, Date.now(), { quietSince: (await readTour(root)).bonjourAt }));
2530
+ }
2531
+ if (request.method === 'POST' && url.pathname === '/api/review-ask/answer') {
2532
+ if (viaPhone) return sendJson(response, 403, { error: t('This is done on the Mac.') });
2533
+ const payload = await body(request).catch(() => ({}));
2534
+ const result = await answerAsk(root, { moment: String(payload.moment ?? ''), answer: String(payload.answer ?? '') });
2535
+ if (result.error) return sendJson(response, 422, { error: t('That is not an answer to this question.') });
2536
+ return sendJson(response, 200, result);
2537
+ }
2538
+ // The welcome of 1.0 (src/tour.mjs): where the person is in it, kept in ~/.pulse for every
2539
+ // project and port, and the projects of 0.7.2 brought to Home. Only the pages of this Mac:
2540
+ // a phone never gets the bonjour, and nothing here is changed from anywhere else.
2541
+ if (url.pathname === '/api/tour' || url.pathname.startsWith('/api/tour/')) {
2542
+ const host = String(request.headers.host ?? '');
2543
+ const site = request.headers['sec-fetch-site'];
2544
+ const fromHere = /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host) && (site === undefined || site === 'same-origin' || site === 'none');
2545
+ if (request.method === 'GET' && url.pathname === '/api/tour') {
2546
+ const state = await readTour(root);
2547
+ if (viaPhone || !fromHere) return sendJson(response, 200, { version: TOUR_VERSION, state, owed: false, returning: false, chapters: CHAPTERS, hints: HINTS });
2548
+ return sendJson(response, 200, { version: TOUR_VERSION, state, owed: !state.bonjourAt, returning: state.path ? state.path === 'back' : await cameBefore(root), chapters: CHAPTERS, hints: HINTS });
2549
+ }
2550
+ if (viaPhone || !fromHere || (request.method === 'POST' && request.headers.origin !== `http://${host}`)) return sendJson(response, 403, { error: t('This is done on the Mac.') });
2551
+ if (request.method === 'POST' && url.pathname === '/api/tour') {
2552
+ const changed = await changeTour(root, await body(request).catch(() => null));
2553
+ if (changed.error) return sendJson(response, 422, { error: t('That is not a change to the tour.') });
2554
+ return sendJson(response, 200, { state: changed });
2555
+ }
2556
+ if (request.method === 'GET' && url.pathname === '/api/tour/projects') {
2557
+ return sendJson(response, 200, { rooms: await roomsWithoutProject(root) });
2558
+ }
2559
+ if (request.method === 'POST' && url.pathname === '/api/tour/projects') {
2560
+ const payload = await body(request).catch(() => ({}));
2561
+ const found = await recoverProject({ stateRoot: root, home: process.env.PULSE_LOBBY_HOME || homedir(), id: String(payload.id ?? '') });
2562
+ if (found.error) return sendJson(response, 404, { error: t('MADRE does not know that project on this Mac.') });
2563
+ return sendJson(response, 200, found);
2564
+ }
2565
+ return sendJson(response, 404, { error: 'Not found' });
2566
+ }
1285
2567
  if (request.method === 'GET' && url.pathname === '/api/version') {
1286
2568
  return sendJson(response, 200, await versionView({ force: url.searchParams.get('force') === '1' }));
1287
2569
  }
@@ -1290,16 +2572,32 @@ export async function createPulseServer({
1290
2572
  if (request.method === 'POST' && url.pathname === '/api/updates/apply') {
1291
2573
  const info = await versionView();
1292
2574
  if (!info.available) return sendJson(response, 409, { error: `Nothing to apply: ${info.current} is the latest MADRE knows of.` });
2575
+ if (install === 'source') return sendJson(response, 412, { error: t('This copy runs from source: pull the repository and start it again.') });
1293
2576
  const command = applyCommand({ install, name: PACKAGE.name, version: info.latest, port: request.socket.localPort, projectRoot: canonicalProjectRoot });
1294
- if (!command) return sendJson(response, 412, { error: t('This copy runs from source: pull the repository and start it again.') });
2577
+ const fetching = fetchCommand({ name: PACKAGE.name, version: info.latest });
2578
+ // Only a version semver would write reaches a shell, and the restart never runs without the
2579
+ // download first (src/updates.mjs): an unchecked one used to skip the download and run.
2580
+ if (!command || !fetching) return sendJson(response, 412, { error: t('The version the registry gave ({version}) is not one MADRE installs.', { version: String(info.latest).slice(0, 40) }) });
1295
2581
  if (room.activeTurns().length || room.activePlans().length) return sendJson(response, 409, { error: t('Agents are still working. STOPALL or wait, then update.') });
2582
+ {
2583
+ // npm is a .cmd on Windows and needs a shell there; every argument here is fixed or checked above.
2584
+ const fetched = await new Promise((resolve) => execFile(fetching.command, fetching.args, { cwd: root, timeout: 180000, shell: process.platform === 'win32', windowsHide: true, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => resolve(error ? (String(stderr || error.message).split('\n').map((line) => line.trim()).find((line) => line && !/complete log|^npm (error|ERR!)(\s+code\b.*)?\s*$/i.test(line)) ?? error.message) : null)));
2585
+ if (fetched !== null) {
2586
+ await room.record('room.update.refused', { from: info.current, to: info.latest, error: fetched.slice(0, 300) });
2587
+ return sendJson(response, 502, { error: t('npm does not hand out {version} yet; a new release can take a few minutes. The project stays open: try again in a moment.', { version: info.latest }) });
2588
+ }
2589
+ }
1296
2590
  await room.record('room.updating', { from: info.current, to: info.latest, install, command });
1297
2591
  sendJson(response, 202, { restarting: true, from: info.current, to: info.latest, command });
1298
2592
  setTimeout(() => {
1299
2593
  server.close(() => {
1300
2594
  // Each system has its own shell: the command is one line either way.
1301
2595
  const shell = process.platform === 'win32' ? { file: process.env.COMSPEC ?? 'cmd.exe', args: ['/d', '/s', '/c', command] } : { file: '/bin/sh', args: ['-c', command] };
1302
- const child = spawn(shell.file, shell.args, { cwd: canonicalProjectRoot, detached: process.platform !== 'win32', windowsHide: true, stdio: 'ignore', env: { ...process.env, PULSE_UPDATE_RESTART: '1' } });
2596
+ // What the new copy says on the way up lands beside the ledger: if it never comes back,
2597
+ // this file is the only witness.
2598
+ let log = 'ignore';
2599
+ try { log = openSync(join(roomDir, 'update.log'), 'a'); } catch { /* no witness, still restart */ }
2600
+ const child = spawn(shell.file, shell.args, { cwd: canonicalProjectRoot, detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', log, log], env: { ...process.env, PULSE_UPDATE_RESTART: '1' } });
1303
2601
  child.unref();
1304
2602
  setTimeout(() => process.exit(0), 200);
1305
2603
  });
@@ -1319,14 +2617,14 @@ export async function createPulseServer({
1319
2617
  const payload = await body(request).catch(() => ({}));
1320
2618
  if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
1321
2619
  const result = await room.code000({ strikes: Number(payload.strikes) || CODE000_STRIKES });
1322
- return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: t('MOTHER is silent.') });
2620
+ return result ? sendJson(response, 200, { code: result.code, lockedForMs: result.lockedForMs, n: result.n }) : sendJson(response, 503, { error: t('Security is silent.') });
1323
2621
  }
1324
2622
  if (request.method === 'GET' && url.pathname === '/api/memory') {
1325
2623
  if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
1326
2624
  const sealed = room.motherStatus()?.lockedForMs ?? 0;
1327
2625
  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 });
1328
2626
  const research = room.memoryResearch();
1329
- if (!research) return sendJson(response, 503, { error: t('The room has no memory.') });
2627
+ if (!research) return sendJson(response, 503, { error: t('The project has no memory.') });
1330
2628
  // How grown this archive is, read from the same notes the map draws.
1331
2629
  const grown = maturity({ readiness: datasetReadiness(await store.readAll(), research.memories), notes: research.memories, links: research.links, stats: research.stats });
1332
2630
  return sendJson(response, 200, { ...research, maturity: grown });
@@ -1348,6 +2646,13 @@ export async function createPulseServer({
1348
2646
  }
1349
2647
  // The MEMORY dashboard: what the archive did day by day and what it is made of. Counts
1350
2648
  // only, no text of any memory, so it is read like /api/maturity, without the designation.
2649
+ // What the archive says about something on the screen, for the questions a surface offers.
2650
+ // Memory text, so behind the designation like the archive itself.
2651
+ if (request.method === 'GET' && url.pathname === '/api/memory/about') {
2652
+ if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
2653
+ if ((room.motherStatus()?.lockedForMs ?? 0) > 0) return sendJson(response, 423, { notes: [] });
2654
+ return sendJson(response, 200, { notes: room.notesAbout(url.searchParams.get('text') ?? '', { limit: 3 }) });
2655
+ }
1351
2656
  if (request.method === 'GET' && url.pathname === '/api/memory/dashboard') {
1352
2657
  return sendJson(response, 200, { dashboard: memoryDashboard({ activity: room.memoryActivity(), stats: room.memoryStats() }) });
1353
2658
  }
@@ -1358,6 +2663,9 @@ export async function createPulseServer({
1358
2663
  const mode = Math.min(4, Math.max(0, Number(url.searchParams.get('mode') ?? 1) || 0));
1359
2664
  const text = String(url.searchParams.get('text') ?? '').slice(0, 2000);
1360
2665
  const ash = url.searchParams.get('ash') === 'true';
2666
+ const here = await scopeOf(url);
2667
+ if (here.error) return sendJson(response, 404, here);
2668
+ const room = here.room;
1361
2669
  const briefing = await room.briefing({ agent: asked, mode, text, ash });
1362
2670
  if (!briefing) return sendJson(response, 404, { error: `No agent "${asked}" on this computer.` });
1363
2671
  return sendJson(response, 200, {
@@ -1368,35 +2676,45 @@ export async function createPulseServer({
1368
2676
  }
1369
2677
  // The conversations of this project. One memory, one crew, one numbering; many records.
1370
2678
  if (request.method === 'GET' && url.pathname === '/api/chats') {
1371
- return sendJson(response, 200, await listChats(roomDir));
2679
+ return sendJson(response, 200, await chatsView());
1372
2680
  }
1373
2681
  if (request.method === 'POST' && url.pathname === '/api/chats') {
1374
- if (room.working()) return sendJson(response, 409, { error: t('A turn is running in this conversation. Let it finish, or STOP ALL, and try again.') });
2682
+ if (room.busy() && !(await turnsPolicy()).enabled) return sendJson(response, 409, { error: t('A turn is running in this conversation. Let it finish, or STOP ALL, and try again.') });
1375
2683
  const payload = await body(request).catch(() => ({}));
2684
+ // A phone looks at conversations by their own address and never moves the one the room has
2685
+ // open: the Mac would be left showing one conversation while sending into another.
2686
+ if (viaPhone) {
2687
+ const made = await createChat(roomDir, { title: typeof payload.title === 'string' ? payload.title : null, activate: false });
2688
+ return sendJson(response, 200, { ...(await chatsView()), opened: made.id });
2689
+ }
1376
2690
  const made = await createChat(roomDir, { title: typeof payload.title === 'string' ? payload.title : null });
1377
2691
  const mounted = await mountChat(made.id);
1378
2692
  if (mounted.error) return sendJson(response, 409, mounted);
1379
- return sendJson(response, 200, { ...(await listChats(roomDir)), opened: made.id });
2693
+ return sendJson(response, 200, { ...(await chatsView()), opened: made.id });
1380
2694
  }
1381
2695
  const chatMatch = url.pathname.match(/^\/api\/chats\/([a-z0-9]+)$/);
1382
2696
  if (chatMatch && request.method === 'POST') {
2697
+ if (viaPhone) return sendJson(response, 200, { ...(await chatsView()), opened: chatMatch[1] });
1383
2698
  const mounted = await mountChat(chatMatch[1]);
1384
2699
  if (mounted.error) return sendJson(response, 409, mounted);
1385
- return sendJson(response, 200, { ...(await listChats(roomDir)), opened: chatMatch[1] });
2700
+ return sendJson(response, 200, { ...(await chatsView()), opened: chatMatch[1] });
1386
2701
  }
1387
2702
  if (chatMatch && request.method === 'PATCH') {
1388
2703
  const payload = await body(request).catch(() => ({}));
1389
2704
  const named = await renameChat(roomDir, chatMatch[1], payload.title);
1390
- return named ? sendJson(response, 200, { ...(await listChats(roomDir)) }) : sendJson(response, 404, { error: t('No such conversation.') });
2705
+ return named ? sendJson(response, 200, { ...(await chatsView()) }) : sendJson(response, 404, { error: t('No such conversation.') });
1391
2706
  }
1392
2707
  if (chatMatch && request.method === 'DELETE') {
1393
- if (chatMatch[1] === chatId && room.working()) return sendJson(response, 409, { error: t('A turn is running in this conversation.') });
2708
+ if ((chatMatch[1] === chatId && room.busy()) || parked.get(chatMatch[1])?.room.busy()) return sendJson(response, 409, { error: t('A turn is running in this conversation.') });
2709
+ // Open beside the one on screen and idle: its room closes, and the page watching it hears
2710
+ // its stream end, before its record goes.
2711
+ await closeHeld(chatMatch[1]);
1394
2712
  const gone = await deleteChat(roomDir, chatMatch[1]);
1395
2713
  if (!gone) return sendJson(response, 404, { error: t('No such conversation.') });
1396
2714
  if (gone.error) return sendJson(response, 409, gone);
1397
2715
  // What the archivist distilled from it is the project's memory and stays where it is.
1398
2716
  if (chatMatch[1] === chatId) { const mounted = await mountChat(gone.active); if (mounted.error) return sendJson(response, 409, mounted); }
1399
- return sendJson(response, 200, { ...(await listChats(roomDir)), deleted: gone.deleted });
2717
+ return sendJson(response, 200, { ...(await chatsView()), deleted: gone.deleted });
1400
2718
  }
1401
2719
  // The three tests. Reading is free; running one is the human's call, and the slow one says
1402
2720
  // where it is while it works.
@@ -1481,11 +2799,15 @@ export async function createPulseServer({
1481
2799
  await room.record('extension.install.started', { id: module.id, name: item.name, command: plan.display, platforms: [], alreadyInstalled: true });
1482
2800
  void (async () => {
1483
2801
  const result = await runInstaller({ command: plan.command, args: plan.args, projectRoot: canonicalProjectRoot, timeoutMs: 900000, onLine: (line) => { void room.record('extension.install.output', { id: module.id, lines: [String(line).slice(0, 500)] }); } });
1484
- await plan.after?.().catch(() => null);
2802
+ // What the plan does once the command is done (Ollama: restart the old server). If it could
2803
+ // not, that is the outcome: «now runs <the old version>» would read as an update that worked.
2804
+ const settled = result.code === 0 ? await (async () => plan.after?.())().catch((error) => ({ ok: false, error: error.message })) : null;
1485
2805
  const after = await module.describe(await moduleContext()).catch(() => null);
1486
2806
  await room.record('extension.install.finished', {
1487
- id: module.id, name: item.name, ok: result.code === 0, installed: Boolean(after?.version),
1488
- detail: result.code === 0 ? `${item.name} now runs ${after?.version ?? 'what it found'}` : (result.error ?? `exit ${result.code}`),
2807
+ id: module.id, name: item.name, ok: result.code === 0 && settled?.ok !== false, installed: Boolean(after?.version),
2808
+ detail: result.code !== 0 ? (result.error ?? `exit ${result.code}`)
2809
+ : settled?.ok === false ? settled.error
2810
+ : `${item.name} now runs ${after?.version ?? 'what it found'}`,
1489
2811
  });
1490
2812
  installing = null;
1491
2813
  })();
@@ -1496,7 +2818,7 @@ export async function createPulseServer({
1496
2818
  const payload = await body(request).catch(() => ({}));
1497
2819
  if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
1498
2820
  const done = room.dismissAsk(String(payload.id ?? '').slice(0, 80));
1499
- return done ? sendJson(response, 200, done) : sendJson(response, 404, { error: t('The room has no memory.') });
2821
+ return done ? sendJson(response, 200, done) : sendJson(response, 404, { error: t('The project has no memory.') });
1500
2822
  }
1501
2823
  // One memory's own traffic: who it keeps arriving with, who asked for it, what a
1502
2824
  // refutation did. Read while its card is open, so the card is alive rather than a snapshot.
@@ -1504,7 +2826,7 @@ export async function createPulseServer({
1504
2826
  if (trafficMatch) {
1505
2827
  if (!designationOk(url.searchParams.get('designation'))) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
1506
2828
  const sealed = room.motherStatus()?.lockedForMs ?? 0;
1507
- if (sealed > 0) return sendJson(response, 423, { error: t('CODE000. THE ARCHIVE IS SEALED.'), lockedForMs: sealed });
2829
+ if (sealed > 0) return sendJson(response, 423, { error: t('TEMPORARY LOCK. THE ARCHIVE IS SEALED.'), lockedForMs: sealed });
1508
2830
  const traffic = room.memoryTraffic(Number(trafficMatch[1]));
1509
2831
  return traffic ? sendJson(response, 200, traffic) : sendJson(response, 404, { error: t('No such memory.') });
1510
2832
  }
@@ -1512,7 +2834,7 @@ export async function createPulseServer({
1512
2834
  if (forgetMatch) {
1513
2835
  const payload = await body(request).catch(() => ({}));
1514
2836
  if (!designationOk(payload.designation)) return sendJson(response, 403, { error: t('UNABLE TO COMPUTE. UNABLE TO CLARIFY.') });
1515
- if ((room.motherStatus()?.lockedForMs ?? 0) > 0) return sendJson(response, 423, { error: t('CODE000. THE ARCHIVE IS SEALED.') });
2837
+ if ((room.motherStatus()?.lockedForMs ?? 0) > 0) return sendJson(response, 423, { error: t('TEMPORARY LOCK. THE ARCHIVE IS SEALED.') });
1516
2838
  const row = await room.forgetMemory(forgetMatch[1]);
1517
2839
  return row ? sendJson(response, 200, { forgotten: row, stats: room.memoryStats() }) : sendJson(response, 404, { error: t('No such memory.') });
1518
2840
  }
@@ -1532,7 +2854,7 @@ export async function createPulseServer({
1532
2854
  if (vaultMatch && (request.method === 'POST' || request.method === 'DELETE')) {
1533
2855
  const id = vaultMatch[1];
1534
2856
  const address = request.socket.remoteAddress ?? '';
1535
- if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
2857
+ if (viaPhone || !['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
1536
2858
  return sendJson(response, 403, { error: t('A key is only accepted from this computer, never over the network.') });
1537
2859
  }
1538
2860
  const module = moduleById(id);
@@ -1556,7 +2878,7 @@ export async function createPulseServer({
1556
2878
  if (keyMatch) {
1557
2879
  const id = keyMatch[1];
1558
2880
  const address = request.socket.remoteAddress ?? '';
1559
- if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
2881
+ if (viaPhone || !['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address)) {
1560
2882
  return sendJson(response, 403, { error: t('A key is only accepted from this computer, never over the network.') });
1561
2883
  }
1562
2884
  const agent = agents.find((item) => item.id === id);
@@ -1605,18 +2927,63 @@ export async function createPulseServer({
1605
2927
  // One answer, not the room. The master brake lives next door and means something else.
1606
2928
  if (request.method === 'POST' && url.pathname === '/api/stop-turn') {
1607
2929
  const payload = await body(request).catch(() => ({}));
1608
- const stopped = await room.stopTurn(String(payload.messageId ?? ''));
2930
+ const here = await scopeOf(url);
2931
+ if (here.error) return sendJson(response, 404, here);
2932
+ const stopped = await here.room.stopTurn(String(payload.messageId ?? ''));
1609
2933
  return sendJson(response, stopped ? 200 : 404, stopped ? { stopped: true } : { error: t('That turn is not running any more.') });
1610
2934
  }
2935
+ // TURNS, read and set from MU/TH/UR. The limits apply to the next message; nothing running stops.
2936
+ if (request.method === 'GET' && url.pathname === '/api/turns') {
2937
+ return sendJson(response, 200, { turns: await turnsPolicy(), working: { here: room.turnsRunning(), chats: busyChats().length + (room.busy() ? 1 : 0) } });
2938
+ }
2939
+ if (request.method === 'POST' && url.pathname === '/api/turns') {
2940
+ const patch = await body(request).catch(() => ({}));
2941
+ const current = (await readConfig(root)).turns ?? {};
2942
+ const next = { ...current };
2943
+ if (typeof patch.enabled === 'boolean') next.enabled = patch.enabled;
2944
+ for (const key of ['chats', 'turnsPerChat']) if (Number.isInteger(Number(patch[key])) && Number(patch[key]) >= 1) next[key] = Number(patch[key]);
2945
+ await updateConfig(root, { turns: next });
2946
+ await room.record('room.turns', { turns: await turnsPolicy() });
2947
+ return sendJson(response, 200, { turns: await turnsPolicy() });
2948
+ }
1611
2949
  if (request.method === 'POST' && url.pathname === '/api/stop-all') {
2950
+ // Every conversation at work, not only the one on screen: the brake is the room's, not the chat's.
1612
2951
  const result = await room.stopAll();
2952
+ passes.clear();
2953
+ for (const held of parked.values()) {
2954
+ const more = await held.room.stopAll().catch(() => ({ plans: 0, turns: 0 }));
2955
+ result.plans += more.plans ?? 0;
2956
+ result.turns += more.turns ?? 0;
2957
+ }
1613
2958
  return sendJson(response, 202, { stopped: true, ...result });
1614
2959
  }
1615
2960
  const stopMatch = request.method === 'POST' && url.pathname.match(/^\/api\/plans\/([0-9a-f-]+)\/stop$/);
1616
2961
  if (stopMatch) {
1617
- const stopped = await room.stopPlan(stopMatch[1]);
2962
+ const here = await scopeOf(url);
2963
+ if (here.error) return sendJson(response, 404, here);
2964
+ const stopped = await here.room.stopPlan(stopMatch[1]);
1618
2965
  return sendJson(response, stopped ? 202 : 404, stopped ? { stopped: true } : { error: t('No running plan with that id.') });
1619
2966
  }
2967
+ // One line of a ```pulse-run, pressed by the human. The body names the request and the line;
2968
+ // the command itself is read from the room's record, never from the page.
2969
+ // STOP on one running command's card: a server, or anything still at it.
2970
+ if (request.method === 'POST' && url.pathname === '/api/runs/stop') {
2971
+ const payload = await body(request).catch(() => ({}));
2972
+ const index = Number(payload.index);
2973
+ if (typeof payload.responseMessageId !== 'string' || !Number.isInteger(index) || index < 0) return sendJson(response, 400, { error: t('Name the request and the line to run.') });
2974
+ const here = await scopeOf(url);
2975
+ if (here.error) return sendJson(response, 404, here);
2976
+ return here.room.stopRun({ responseMessageId: payload.responseMessageId, index }) ? sendJson(response, 202, { stopped: true }) : sendJson(response, 404, { error: t('That command is not running.') });
2977
+ }
2978
+ if (request.method === 'POST' && url.pathname === '/api/runs') {
2979
+ const payload = await body(request).catch(() => ({}));
2980
+ const index = Number(payload.index);
2981
+ if (typeof payload.responseMessageId !== 'string' || !Number.isInteger(index) || index < 0) return sendJson(response, 400, { error: t('Name the request and the line to run.') });
2982
+ const here = await scopeOf(url);
2983
+ if (here.error) return sendJson(response, 404, here);
2984
+ const outcome = await here.room.runRequested({ responseMessageId: payload.responseMessageId, index });
2985
+ return sendJson(response, outcome.status, outcome.ok ? outcome.result : { error: outcome.error });
2986
+ }
1620
2987
  if (request.method === 'GET' && url.pathname === '/api/commands') {
1621
2988
  const ctx = await moduleContext();
1622
2989
  const fromModules = await Promise.all(moduleCommands().map(async (command) => {
@@ -1651,7 +3018,9 @@ export async function createPulseServer({
1651
3018
  } catch (error) { result = { ok: false, title: own.title, text: `/${parsed.name} failed: ${error.message}` }; }
1652
3019
  command = own;
1653
3020
  }
1654
- const event = await room.recordCommand({ name: parsed.name, args: parsed.args, ...result });
3021
+ const here = await scopeOf(url);
3022
+ if (here.error) return sendJson(response, 404, here);
3023
+ const event = await here.room.recordCommand({ name: parsed.name, args: parsed.args, ...result });
1655
3024
  return sendJson(response, result.ok ? 200 : 422, { command: parsed.name, title: result.title, ok: result.ok, sequence: event.sequence });
1656
3025
  }
1657
3026
  if (request.method === 'GET' && url.pathname === '/api/extensions') {
@@ -1705,25 +3074,58 @@ export async function createPulseServer({
1705
3074
  }
1706
3075
  const installMatch = request.method === 'POST' && url.pathname.match(/^\/api\/extensions\/([a-z0-9-]+)\/install$/);
1707
3076
  if (installMatch) {
3077
+ // A phone switches MADRE's own modules; installing one runs commands in the project.
3078
+ if (viaPhone && !extensionById(installMatch[1])?.toggle) return sendJson(response, 403, { error: t('This is done on the Mac.') });
1708
3079
  const result = await installExtension(installMatch[1], await body(request));
1709
3080
  return sendJson(response, result.status, result.body);
1710
3081
  }
1711
3082
  if (request.method === 'POST' && /^\/api\/control\/[\w-]+\/undo$/.test(url.pathname)) {
1712
- const result = await room.undoControl(url.pathname.split('/')[3]);
3083
+ const here = await scopeOf(url);
3084
+ if (here.error) return sendJson(response, 404, here);
3085
+ const result = await here.room.undoControl(url.pathname.split('/')[3]);
1713
3086
  return sendJson(response, result.ok ? 200 : result.status ?? 400, result);
1714
3087
  }
1715
3088
  if (request.method === 'POST' && /^\/api\/modes\/[\w-]+\/decide$/.test(url.pathname)) {
1716
3089
  const requestId = url.pathname.split('/')[3];
1717
3090
  const { decision } = await body(request);
1718
- const result = room.decideMode(requestId, decision);
3091
+ const here = await scopeOf(url);
3092
+ if (here.error) return sendJson(response, 404, here);
3093
+ // From a phone a mode is granted up to #2, as it is asked for up to #2.
3094
+ if (viaPhone && decision !== 'deny' && (here.room.modeAsked(requestId) ?? 0) > PHONE_MAX_MODE) return sendJson(response, 403, { error: t('#3 EDIT and #4 FULL ACCESS are opened from the Mac.') });
3095
+ const result = here.room.decideMode(requestId, decision);
1719
3096
  return sendJson(response, result.ok ? 200 : 409, result);
1720
3097
  }
1721
3098
  if (request.method === 'POST' && url.pathname === '/api/messages') {
1722
3099
  const payload = await body(request);
3100
+ // A phone asks up to #2. The bridge already said so; the room says it again, and records
3101
+ // where the message came from. Only the bridge's word counts for that, never the body's.
3102
+ if (viaPhone) {
3103
+ const written = writtenMode(payload.text);
3104
+ // Read the way the room reads it: «"4"» is #4 there, so it is #4 here (TEL-01).
3105
+ if (Math.max(normalizeMode(payload.mode, payload.create === true ? 2 : 1), written) > PHONE_MAX_MODE) return sendJson(response, 403, { error: t('#3 EDIT and #4 FULL ACCESS are opened from the Mac.') });
3106
+ payload.via = phoneKind;
3107
+ } else delete payload.via;
3108
+ // `?chat=` sends into a conversation held open beside the one on screen. TURNS counts it
3109
+ // like any other: the conversation on screen is one of the "others" when it is working.
3110
+ const here = await scopeOf(url);
3111
+ if (here.error) return sendJson(response, 404, here);
3112
+ const room = here.room;
1723
3113
  // Modes are checked before the turn is accepted, so the composer
1724
3114
  // hears "no" with a reason instead of a silent log line.
1725
3115
  const gate = await room.modeCheck(payload);
1726
3116
  if (!gate.ok) return sendJson(response, gate.status ?? 403, { error: gate.error, mode: gate.mode, maxMode: gate.maxMode });
3117
+ // #3 and #4 go with the pass their key gave, for this agent (above).
3118
+ 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 });
3119
+ delete payload.key;
3120
+ const turns = await turnsPolicy();
3121
+ if (turns.enabled) {
3122
+ if (room.turnsRunning() >= turns.turnsPerChat) return sendJson(response, 429, { error: t('This conversation already has {n} turns running, and TURNS allows {max} at a time. Wait for one to finish, open another conversation, or raise it in Diagnostics → TURNS.', { n: room.turnsRunning(), max: turns.turnsPerChat }) });
3123
+ const others = busyChats().filter((id) => id !== here.id).length + (!here.main && openRoom().busy() ? 1 : 0);
3124
+ if (!room.busy() && others + 1 > turns.chats) return sendJson(response, 429, { error: t('{n} other conversations are already working, and TURNS allows {max} at a time. Wait for one to finish, or raise it in Diagnostics → TURNS.', { n: others, max: turns.chats }) });
3125
+ } else if (!here.main && !room.busy() && openRoom().busy()) {
3126
+ // TURNS off: one conversation drives the crew at a time, beside or not.
3127
+ return sendJson(response, 409, { error: t('A turn is running in this conversation. Let it finish, or STOP ALL, and try again.') });
3128
+ }
1727
3129
  await refreshPrivacy();
1728
3130
  void room.send(payload).catch((error) => {
1729
3131
  console.error(`MADRE room error: ${error.message}`);
@@ -1750,26 +3152,29 @@ export async function createPulseServer({
1750
3152
  const nativeClose = server.close.bind(server);
1751
3153
  server.close = (callback) => {
1752
3154
  clearInterval(poller);
3155
+ serverSentinel.stop();
1753
3156
  clearTimeout(embedKick);
1754
3157
  unwatchOutbound();
1755
3158
  // In-flight agent processes are killed and their turns recorded as failed
1756
3159
  // before the SSE clients go away, so open pages see the outcome.
1757
- const shutdown = room.shutdown().catch((error) => console.error(`MADRE shutdown error: ${error.message}`));
1758
- let result;
3160
+ const shutdown = Promise.all([room, ...[...parked.values()].map((held) => held.room)].map((each) => each.shutdown().catch((error) => console.error(`MADRE shutdown error: ${error.message}`))));
3161
+ // off() also ends the streams of pages watching a conversation held open beside.
3162
+ for (const held of parked.values()) held.off();
3163
+ parked.clear();
1759
3164
  shutdown.then(() => {
1760
3165
  void broadcastPending().then(() => chatWrites).then(() => {
1761
3166
  unsubscribe();
1762
3167
  unsubscribeSentinel();
1763
3168
  unsubscribeEyecat();
1764
3169
  unsubscribeLive();
1765
- unsubscribeGhost();
3170
+ unsubscribeGhost();
1766
3171
  for (const client of clients.keys()) client.end();
1767
3172
  clients.clear();
1768
3173
  // The memory file closes last, once nothing else writes to it; WAL and shm go with it.
1769
3174
  try { memory?.close(); } catch { /* already closed */ }
1770
3175
  void outbound.drain();
1771
3176
  releaseOpen();
1772
- result = nativeClose(callback);
3177
+ nativeClose(callback);
1773
3178
  server.closeIdleConnections?.();
1774
3179
  });
1775
3180
  });
@@ -1777,11 +3182,12 @@ export async function createPulseServer({
1777
3182
  };
1778
3183
  server.on('close', () => quotaMonitor.stop());
1779
3184
 
1780
- return { server, store, agents, quotaMonitor, markOpen, releaseOpen };
3185
+ server.on('close', () => { void phone.stop(); });
3186
+ return { server, store, agents, quotaMonitor, markOpen, releaseOpen, phone };
1781
3187
  }
1782
3188
 
1783
- export async function startPulse({ port, projectRoot, openBrowser }) {
1784
- const { server, agents, markOpen } = await createPulseServer({ projectRoot, quotaSources: defaultQuotaSources() });
3189
+ export async function startPulse({ port, projectRoot, openBrowser, openPath = '' }) {
3190
+ const { server, agents, markOpen, phone } = await createPulseServer({ projectRoot, quotaSources: defaultQuotaSources() });
1785
3191
  await new Promise((resolve, reject) => {
1786
3192
  server.once('error', reject);
1787
3193
  server.listen(port, '127.0.0.1', resolve);
@@ -1789,13 +3195,18 @@ export async function startPulse({ port, projectRoot, openBrowser }) {
1789
3195
  const address = server.address();
1790
3196
  // The room is taken from here on, and says so on disk for anyone who tries to open it again.
1791
3197
  await markOpen(address.port);
3198
+ // From here this room offers to host the phone's bridge, when there is a phone to reach it.
3199
+ phone.start();
1792
3200
  const url = `http://127.0.0.1:${address.port}`;
1793
3201
  const ready = agents.filter((agent) => agent.ready).map((agent) => agent.label).join(', ') || 'none';
1794
3202
  const detected = agents.filter((agent) => agent.detected).map((agent) => agent.label).join(', ') || 'none';
1795
- console.log(`\nMADRE is ready\n\n ${url}\n Project: ${projectRoot}\n Ready: ${ready}\n Detected: ${detected}\n`);
1796
- if (openBrowser) openUrl(url);
1797
- for (const signal of ['SIGINT', 'SIGTERM']) {
3203
+ console.log(`\nMADRE is ready\n\n ${url}${openPath}\n Project: ${projectRoot}\n Ready: ${ready}\n Detected: ${detected}\n`);
3204
+ if (openBrowser) openUrl(`${url}${openPath}`);
3205
+ // SIGHUP too: closing the Terminal the room was started in is a way of closing it.
3206
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
1798
3207
  process.once(signal, () => server.close(() => process.exit(0)));
1799
3208
  }
3209
+ // Salas asked this room to close: the same as a signal.
3210
+ server.once('madre:close', () => server.close(() => process.exit(0)));
1800
3211
  return { server, url };
1801
3212
  }