@jossuealcala/madre 0.7.2 → 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 (163) hide show
  1. package/CHANGELOG.md +773 -1
  2. package/README.md +92 -28
  3. package/bin/madre.mjs +30 -10
  4. package/docs/REFERENCE.md +157 -81
  5. package/docs/SDK.md +23 -19
  6. package/package.json +1 -1
  7. package/public/app.js +11207 -4196
  8. package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
  9. package/public/assets/brand/Geist-latin.woff2 +0 -0
  10. package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
  11. package/public/assets/brand/GeistMono-latin.woff2 +0 -0
  12. package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
  13. package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
  14. package/public/assets/brand/OFL-Geist.txt +93 -0
  15. package/public/assets/brand/OFL-GeistMono.txt +93 -0
  16. package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
  17. package/public/assets/brand/OFL-Sora.txt +93 -0
  18. package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
  19. package/public/assets/brand/Sora-latin.woff2 +0 -0
  20. package/public/assets/brand/madre-app-180.png +0 -0
  21. package/public/assets/brand/madre-app-192.png +0 -0
  22. package/public/assets/brand/madre-app-512.png +0 -0
  23. package/public/assets/brand/madre-app.svg +1 -0
  24. package/public/assets/brand/madre-favicon.svg +1 -0
  25. package/public/assets/news/1.0/alerts.webp +0 -0
  26. package/public/assets/news/1.0/bar.webp +0 -0
  27. package/public/assets/news/1.0/finder.webp +0 -0
  28. package/public/assets/news/1.0/github.webp +0 -0
  29. package/public/assets/news/1.0/intents.webp +0 -0
  30. package/public/assets/news/1.0/looks.webp +0 -0
  31. package/public/assets/news/1.0/modules.webp +0 -0
  32. package/public/assets/news/1.0/mother.webp +0 -0
  33. package/public/assets/news/1.0/nostromo.webp +0 -0
  34. package/public/assets/news/1.0/permissions.webp +0 -0
  35. package/public/assets/news/1.0/room.webp +0 -0
  36. package/public/assets/news/1.0/settings.webp +0 -0
  37. package/public/assets/wallpapers/biznaga.webp +0 -0
  38. package/public/assets/wallpapers/canela.webp +0 -0
  39. package/public/assets/wallpapers/jarrito.webp +0 -0
  40. package/public/assets/wallpapers/magna.webp +0 -0
  41. package/public/assets/wallpapers/oceano.webp +0 -0
  42. package/public/assets/wallpapers/paraiso.webp +0 -0
  43. package/public/assets/wallpapers/quila.webp +0 -0
  44. package/public/assets/wallpapers/tradicion.webp +0 -0
  45. package/public/assets/wallpapers/tropical.webp +0 -0
  46. package/public/assets/wallpapers/urbania.webp +0 -0
  47. package/public/assets/wallpapers/ventura.webp +0 -0
  48. package/public/assets/wallpapers/xico.webp +0 -0
  49. package/public/brands.js +28 -2
  50. package/public/editor.js +744 -0
  51. package/public/es.js +2784 -1026
  52. package/public/i18n.js +1 -1
  53. package/public/index.html +201 -175
  54. package/public/inquiry.js +11 -11
  55. package/public/loader.js +79 -0
  56. package/public/macroverso.js +1497 -0
  57. package/public/manifest.webmanifest +14 -0
  58. package/public/nostromo-engine.js +2222 -0
  59. package/public/resay.js +24 -0
  60. package/public/review-ask.js +85 -0
  61. package/public/salas-mac.css +787 -0
  62. package/public/salas-mac.html +154 -0
  63. package/public/salas-mac.js +3264 -0
  64. package/public/salas.css +85 -0
  65. package/public/salas.html +47 -0
  66. package/public/salas.js +200 -0
  67. package/public/styles.css +6207 -425
  68. package/public/touch.js +83 -0
  69. package/public/tour-chapters.js +342 -0
  70. package/public/tour.js +510 -0
  71. package/public/troubleshooting.js +159 -140
  72. package/src/adapters/codex.mjs +46 -6
  73. package/src/adapters/gemini.mjs +30 -10
  74. package/src/adapters/madre.mjs +110 -18
  75. package/src/adapters/process.mjs +42 -2
  76. package/src/auth-probe.mjs +2 -2
  77. package/src/bridge/devices.mjs +202 -0
  78. package/src/bridge/handoff.mjs +45 -0
  79. package/src/bridge/host.mjs +82 -0
  80. package/src/bridge/network.mjs +133 -0
  81. package/src/bridge/policy.mjs +89 -0
  82. package/src/bridge/qr.mjs +287 -0
  83. package/src/bridge/server.mjs +269 -0
  84. package/src/capabilities.mjs +5 -9
  85. package/src/chats.mjs +59 -22
  86. package/src/checkpoint.mjs +137 -35
  87. package/src/choices.mjs +18 -3
  88. package/src/cli-args.mjs +12 -1
  89. package/src/commands.mjs +15 -15
  90. package/src/config.mjs +73 -9
  91. package/src/conversation-context.mjs +5 -2
  92. package/src/dataset.mjs +3 -1
  93. package/src/directives.mjs +0 -5
  94. package/src/enlaces.mjs +451 -0
  95. package/src/event-store.mjs +55 -10
  96. package/src/exam.mjs +5 -5
  97. package/src/extensions.mjs +0 -2
  98. package/src/file-ops.mjs +23 -0
  99. package/src/files.mjs +52 -2
  100. package/src/github.mjs +407 -0
  101. package/src/json-file.mjs +63 -0
  102. package/src/lease.mjs +2 -2
  103. package/src/lobby-work.mjs +103 -0
  104. package/src/looks.mjs +55 -0
  105. package/src/maturity.mjs +3 -3
  106. package/src/mcp/github-server.mjs +164 -0
  107. package/src/mcp/image-server.mjs +1 -1
  108. package/src/mcp/memory-server.mjs +11 -3
  109. package/src/memory.mjs +38 -12
  110. package/src/models.mjs +89 -5
  111. package/src/modules/ahp.mjs +4 -4
  112. package/src/modules/ash.mjs +1 -1
  113. package/src/modules/bishop-plan.mjs +207 -0
  114. package/src/modules/bishop.mjs +645 -0
  115. package/src/modules/choices.mjs +2 -2
  116. package/src/modules/correo.mjs +1 -1
  117. package/src/modules/enlaces.mjs +255 -0
  118. package/src/modules/git-pulse.mjs +2 -2
  119. package/src/modules/github.mjs +322 -0
  120. package/src/modules/image-studio.mjs +3 -3
  121. package/src/modules/index.mjs +18 -4
  122. package/src/modules/intents-calibration.mjs +338 -0
  123. package/src/modules/intents-desk.mjs +481 -0
  124. package/src/modules/intents-observe.mjs +266 -0
  125. package/src/modules/intents-policy.mjs +414 -0
  126. package/src/modules/intents-try.mjs +91 -0
  127. package/src/modules/intents.mjs +145 -0
  128. package/src/modules/ollama.mjs +4 -4
  129. package/src/modules/playwright.mjs +121 -11
  130. package/src/modules/ripley.mjs +1 -1
  131. package/src/modules/runs.mjs +2 -2
  132. package/src/modules/sdk.mjs +10 -4
  133. package/src/ollama.mjs +6 -1
  134. package/src/outbound.mjs +19 -17
  135. package/src/override.mjs +100 -0
  136. package/src/ports.mjs +23 -1
  137. package/src/prices.mjs +72 -0
  138. package/src/privacy.mjs +30 -11
  139. package/src/quota-sources.mjs +0 -8
  140. package/src/request-guard.mjs +1 -1
  141. package/src/review-ask.mjs +111 -0
  142. package/src/room/archivist.mjs +55 -37
  143. package/src/room/budget.mjs +17 -5
  144. package/src/room/control.mjs +70 -7
  145. package/src/room/economy.mjs +35 -7
  146. package/src/room/escalation.mjs +2 -0
  147. package/src/room/guard.mjs +26 -2
  148. package/src/room/prompt.mjs +22 -15
  149. package/src/room/runs.mjs +84 -21
  150. package/src/room/vectors.mjs +19 -4
  151. package/src/room.mjs +351 -65
  152. package/src/router.mjs +3 -1
  153. package/src/runs.mjs +23 -5
  154. package/src/salas.mjs +313 -0
  155. package/src/sentinel-errors.mjs +1 -1
  156. package/src/server-sentinel.mjs +124 -0
  157. package/src/server.mjs +1526 -163
  158. package/src/setup.mjs +1 -0
  159. package/src/tour.mjs +159 -0
  160. package/src/updates.mjs +48 -8
  161. package/src/usage-sentinel.mjs +1 -1
  162. package/src/verdict.mjs +7 -7
  163. package/src/whats-new.mjs +160 -0
package/src/room.mjs CHANGED
@@ -6,16 +6,16 @@ import { invokeOpenCode, buildOpenCodeArgs } from './adapters/opencode.mjs';
6
6
  import { parseMessage } from './router.mjs';
7
7
  import { randomUUID } from 'node:crypto';
8
8
  import { basename } from 'node:path';
9
+ import { homedir } from 'node:os';
9
10
  import { UsageSentinel } from './usage-sentinel.mjs';
10
11
  import { BLOCK_SOURCES, buildPrompt, promptParts, sparedChars } from './room/prompt.mjs';
11
12
  import { turnCost, observedRate } from './room/economy.mjs';
12
13
  import { contextFor } from './room/context.mjs';
13
- import { VERDICTS } from './memory.mjs';
14
+ import { VERDICTS, travels } from './memory.mjs';
14
15
  import { coldNotes, coldReading } from './cold.mjs';
15
16
  import { questionsFor } from './asking.mjs';
16
17
  import { coverageExam, consistencyExam, matchExam, exchanges, MATCH_SAMPLE, saysFor } from './exam.mjs';
17
18
  import { MADRE_ADAPTER, MADRE_AGENT_ID } from './adapters/madre.mjs';
18
- import { verdictFor } from './verdict.mjs';
19
19
  import { ControlDesk } from './room/control.mjs';
20
20
  import { RunDesk } from './room/runs.mjs';
21
21
  import { Attachments } from './room/attachments.mjs';
@@ -28,19 +28,23 @@ import { OLLAMA_ARCHIVIST } from './distiller.mjs';
28
28
  import { memoryServerForTurn } from './memory-tools.mjs';
29
29
  import { CODE000_STRIKES } from './mother.mjs';
30
30
  import { parseDirectives } from './directives.mjs';
31
- import { isValidModelName, isValidEffort } from './models.mjs';
31
+ import { isValidModelName, isValidEffort, belongsElsewhere, MODEL_NOT_FOUND, codexTakes, readCodexEfforts } from './models.mjs';
32
+ import { withListPrice } from './prices.mjs';
32
33
  import { MODES, SCOPES, SCOPE_LABELS, capabilitySummary, normalizeMode, resolveScopes } from './capabilities.mjs';
33
34
  import { createLease, diffSnapshots, namedPaths, snapshot } from './lease.mjs';
34
- import { stat as statFile } from 'node:fs/promises';
35
+ import { stat as statFile, readdir, copyFile, readFile as readFileBytes } from 'node:fs/promises';
35
36
  import { basename as baseName, join as joinPath } from 'node:path';
36
- import { contentTypeFor } from './files.mjs';
37
+ import { declaredTypeFor } from './files.mjs';
37
38
  import { isModuleFile, sdkPaths } from './modules/index.mjs';
38
39
  import { validateAshReply } from './modules/ash-policy.mjs';
40
+ import { PHONE_MAX_MODE } from './bridge/policy.mjs';
39
41
  import { parseChoice, withoutChoice } from './choices.mjs';
40
- import { parseRunRequest, withoutRunRequest, whyText } from './runs.mjs';
42
+ import { parseRunRequest, withoutRunRequest, whyText, expandHome } from './runs.mjs';
41
43
  import { imageStudioFor } from './image-studio.mjs';
42
44
  import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
43
45
  import { resolveReferences } from './files.mjs';
46
+
47
+ const VIA_KINDS = new Set(['iphone', 'ipad', 'android', 'android-tablet', 'other']);
44
48
  import { redactArgs } from './launch.mjs';
45
49
 
46
50
  // Adapters can fail with multi-line stderr or stack traces. The room keeps only
@@ -67,14 +71,19 @@ export { budgetTokens };
67
71
  // languages the crew actually speaks here.
68
72
  const CREATION_VERB = /\b(genera(r|d)?|crea(r|d)?|escrib(e|a|ir)|guarda(r)?|produce|producir|exporta(r)?|redacta(r)?|construye|create|generate|write|save|export|produce|build|render|draw|make)\b/i;
69
73
  const CREATION_OBJECT = /\b(archivo|fichero|file|pdf|md|markdown|imagen|image|png|jpe?g|svg|documento|document|docx?|csv|json|xlsx?|pptx?|html|script|carpeta|folder|reporte|report|informe|entregable|poster|logo|diagrama|diagram)\b/i;
70
- export const looksLikeCreation = (text) => CREATION_VERB.test(String(text ?? '')) && CREATION_OBJECT.test(String(text ?? ''));
74
+ // Unless it says, in so many words, not to write yet: «dame un plan antes de escribir código» was
75
+ // told it had no CREATE lease, for asking for exactly the thing that needs none.
76
+ const NOT_YET = /\b(antes de|sin|todav[ií]a no|a[uú]n no)\s+(escribir|crear|generar|tocar|cambiar|modificar|programar|codificar)\b|\bno\s+(escribas|crees|generes|toques|cambies|modifiques|programes|edites)\b|\b(before|without)\s+(writing|creating|coding|touching|changing|editing)\b|\b(don'?t|do not|not yet)\s+(write|create|code|touch|change|edit)\b/i;
77
+ export const looksLikeCreation = (text) => !NOT_YET.test(String(text ?? '')) && CREATION_VERB.test(String(text ?? '')) && CREATION_OBJECT.test(String(text ?? ''));
71
78
 
72
79
  // Which decisions the project is standing on, out of what the archive holds. A refuted one is
73
80
  // kept by the archive — what was once decided is part of the record — and left out here, because
74
81
  // a strip that says "decided" cannot carry something the room has since established is false.
75
82
  export function settledDecisions(notes = [], { limit = 3 } = {}) {
76
83
  return notes
77
- .filter((note) => note && !note.refutedBy)
84
+ // Only what still travels: a note the human judged bad, or one set aside, is not «decided»
85
+ // (LOG-10 of the 1.0 review; `travels` is the archive's own rule, verdicts included).
86
+ .filter((note) => note && !note.refutedBy && travels(note))
78
87
  .slice(0, limit)
79
88
  .map((note) => ({ id: note.id, text: note.text, created: note.created, agent: note.agent, fromSequence: note.fromSequence }));
80
89
  }
@@ -91,12 +100,16 @@ export function settledDecisions(notes = [], { limit = 3 } = {}) {
91
100
  export function openQuestions(notes = [], { dismissed = [], limit = 3 } = {}) {
92
101
  const skip = new Set(dismissed ?? []);
93
102
  return notes
94
- .filter((note) => note && note.kind === 'question' && !note.refutedBy && !skip.has(`open:${note.id}`))
103
+ .filter((note) => note && note.kind === 'question' && !note.refutedBy && travels(note) && !skip.has(`open:${note.id}`))
95
104
  .sort((a, b) => (Number(b.recalled ?? 0) - Number(a.recalled ?? 0)) || (b.id - a.id))
96
105
  .slice(0, limit)
97
106
  .map((note) => ({ id: note.id, text: note.text, carried: Number(note.recalled ?? 0) }));
98
107
  }
99
108
 
109
+ // A turn's time limit when the human set none: reading answers, writing builds.
110
+ export const READING_TIMEOUT_MS = 180000;
111
+ export const WRITING_TIMEOUT_MS = 600000;
112
+
100
113
  export class Room {
101
114
  #store;
102
115
  #agents;
@@ -116,6 +129,7 @@ export class Room {
116
129
  #escalation; // room/escalation: plan steps waiting for the human
117
130
  #ghost = new GhostLedger();
118
131
  #liveListeners = new Set();
132
+ #quotaSaid = new Map(); // agent + source -> the last reading written to the record
119
133
  #memoryServer; // MCP descriptor handed to every turn so the agent can query the memory itself
120
134
  #mother = null; // MotherChannel: her coded words to the crew
121
135
  #shuttingDown = false;
@@ -131,6 +145,11 @@ export class Room {
131
145
  #delegation;
132
146
  #privacy = null;
133
147
  #toolsForTurn = null;
148
+ #bishopForTurn = null;
149
+ #linkedForTurn = null;
150
+ // The turns running now, by the id of the answer they will write: which agent, at which mode.
151
+ // What a tool of a turn says it is (S-021) is checked against this, never taken on its word.
152
+ #liveTurns = new Map();
134
153
  #maxPlanSteps;
135
154
  #maxConcurrentTurns;
136
155
  #planMaxAgeMs;
@@ -154,6 +173,8 @@ export class Room {
154
173
  mother = null,
155
174
  privacy = null,
156
175
  toolsForTurn = null, // async ({ agent, mode, lease, scratchDir }) => MCP server specs from the modules
176
+ bishopForTurn = null, // async ({ agent, mode, text, preview, turn }) => bounded BISHOP context in Salas
177
+ linkedForTurn = null, // async ({ agent, mode, text, preview, turn }) => { text, dynamic } notes a link recalls (3e)
157
178
  historicalEvents = [],
158
179
  invokers = defaultInvokers,
159
180
  agentTimeouts = {},
@@ -162,8 +183,17 @@ export class Room {
162
183
  maxPlanSteps = 4,
163
184
  maxConcurrentTurns = 3,
164
185
  planMaxAgeMs = 300000,
186
+ // The project's one CONTROL desk, when the server keeps several conversations alive at once:
187
+ // one holder of the working tree across all of them, and an UNDO that works from any.
188
+ controlDesk = null,
165
189
  escalationMs = Number(process.env.PULSE_ESCALATION_MS ?? 180000),
190
+ // Where Codex keeps its config and its models' cache: the levels each model takes.
191
+ codexHome = homedir(),
192
+ // The desk that runs what the human pressed; tests hand in one that sees ports they choose.
193
+ runDesk = null,
166
194
  }) {
195
+ this.#codexHome = codexHome;
196
+ if (runDesk) this.#runDesk = runDesk;
167
197
  this.#store = store;
168
198
  this.#agents = agents;
169
199
  this.#projectRoot = projectRoot;
@@ -175,6 +205,8 @@ export class Room {
175
205
  this.#mother = mother;
176
206
  this.#privacy = privacy;
177
207
  this.#toolsForTurn = toolsForTurn;
208
+ this.#bishopForTurn = bishopForTurn;
209
+ this.#linkedForTurn = linkedForTurn;
178
210
  this.#invokers = invokers;
179
211
  this.#agentTimeouts = agentTimeouts;
180
212
  this.#maxMessageChars = maxMessageChars;
@@ -183,7 +215,7 @@ export class Room {
183
215
  this.#maxConcurrentTurns = maxConcurrentTurns;
184
216
  this.#planMaxAgeMs = planMaxAgeMs;
185
217
  this.#escalation = new Escalation({ escalationMs });
186
- this.#controlDesk = new ControlDesk({ projectRoot });
218
+ this.#controlDesk = controlDesk ?? new ControlDesk({ projectRoot });
187
219
  this.#budget = new Budget({ softBudget: softTokenBudget });
188
220
  this.#budget.seed(historicalEvents, this.#sentinel);
189
221
  // What this room has been charged per character, from its own past turns.
@@ -228,6 +260,7 @@ export class Room {
228
260
  // because the tokens were really spent.
229
261
  // CONTROL (#3): one holder per room, every turn between two checkpoints.
230
262
  #controlDesk;
263
+ #codexHome;
231
264
  control() { return this.#controlDesk.status(); }
232
265
  async undoControl(checkpointId) {
233
266
  const result = await this.#controlDesk.undo(checkpointId);
@@ -254,7 +287,12 @@ export class Room {
254
287
  const scopes = this.scopesFor(step.agent);
255
288
  const enabled = Object.fromEntries(SCOPES.map((scope) => [scope, scopes[scope].enabled && scopes[scope].wired]));
256
289
  const lease = await this.#projectLease({ leaseId: randomUUID(), messageId: parentMessageId });
257
- lease.scopes = decision.decision === 'plan' ? Object.fromEntries(SCOPES.map((scope) => [scope, true])) : enabled;
290
+ // The turn reads its scopes from the ceiling, as every lease does (#runTurn); a lease granted
291
+ // here had none, so every scope arrived off — no images — while the room had announced them on
292
+ // (LOG-05 of the 1.0 review). What the human granted is the ceiling; the agent's own switches
293
+ // still decide below it.
294
+ lease.scopeCeiling = { write: true, imageGen: true, web: true };
295
+ lease.scopes = enabled;
258
296
  await this.#emit('lease.granted', {
259
297
  escalated: decision.decision,
260
298
  leaseId: lease.leaseId,
@@ -271,6 +309,9 @@ export class Room {
271
309
  }
272
310
  // The human's answer to a pending request: 'once', 'plan' or 'deny'.
273
311
  decideMode(requestId, decision) { return this.#escalation.decide(requestId, decision); }
312
+ modeAsked(requestId) { return this.#escalation.modeOf(requestId); }
313
+ // The modes plans are waiting for the human to grant, as asked.
314
+ pendingModes() { return this.#escalation.pending(); }
274
315
 
275
316
  subscribeGhost(listener) { return this.#ghost.subscribe(listener); }
276
317
 
@@ -419,6 +460,19 @@ export class Room {
419
460
  // Is anything happening in here right now? Asked before a conversation is closed and another
420
461
  // opened: a turn in flight is a reason to wait, not something to race.
421
462
  working() { return this.#turns.size > 0; }
463
+ // TURNS asks two things: how many turns this conversation has in flight, and whether it is still
464
+ // at work at all — a plan between two of its steps has no turn running and is not finished.
465
+ turnsRunning() { return this.#turns.size; }
466
+ busy() { return this.#turns.size > 0 || this.#inflight.size > 0 || this.#plans.size > 0; }
467
+ // A turn running now, by the id of the answer it will write (S-021): its agent and its mode, or
468
+ // null. What a tool of that turn reports about itself is checked against this.
469
+ runningTurn(responseMessageId) {
470
+ const turn = this.#liveTurns.get(String(responseMessageId ?? ''));
471
+ return turn ? { agent: turn.agent, mode: turn.mode } : null;
472
+ }
473
+ // A command the human ran is still alive here (a server, say). Not «working», but a reason to
474
+ // keep this room open rather than close it under the process.
475
+ hasLiveRuns() { return this.#runDesk.busy || this.#runDesk.serving().length > 0; }
422
476
 
423
477
  // The decisions this project is standing on, newest first.
424
478
  //
@@ -440,6 +494,16 @@ export class Room {
440
494
  return settledDecisions(this.#memory.memories({ kind: 'decision', limit: limit * 4 }), { limit });
441
495
  }
442
496
 
497
+ // The notes the archive holds about something on the human's screen — the lines they selected,
498
+ // the page they pointed at — so a question can be offered from what the room already knows.
499
+ // Matched by words only: opening a menu sends nothing to an embedding provider. Read without
500
+ // counting as a recall, because nothing was recalled into a turn.
501
+ notesAbout(text, { limit = 3 } = {}) {
502
+ if (!this.#memory || !String(text ?? '').trim()) return [];
503
+ const notes = this.#memory.recallMemories(String(text).slice(0, 2000), { limit, maxChars: 900, queryVector: null, fallback: false, track: false, cascade: false });
504
+ return notes.filter((note) => note.kind !== 'aberration').map((note) => ({ id: note.id, kind: note.kind, text: note.text }));
505
+ }
506
+
443
507
  memoryResearch() {
444
508
  if (!this.#memory) return null;
445
509
  const memories = this.#memory.memories({ limit: 500 });
@@ -508,7 +572,7 @@ export class Room {
508
572
  // a real question at a time and takes minutes, so it runs in the background and reports where
509
573
  // it is. Nothing here writes to the ledger or spends a provider turn.
510
574
  async runExam(which, { findings = [] } = {}) {
511
- if (!this.#memory) return { error: 'The room has no memory.' };
575
+ if (!this.#memory) return { error: 'The project has no memory.' };
512
576
  if (this.#examRunning) return { error: `${this.#examRunning.toUpperCase()} is already running.` };
513
577
  const events = await this.#store.readAll();
514
578
  if (which === 'consistency') {
@@ -528,7 +592,7 @@ export class Room {
528
592
  }
529
593
  if (which === 'match') {
530
594
  const invoke = this.#invokers[MADRE_ADAPTER];
531
- if (!invoke) return { error: 'The local model is not running. Open MODULES → OLLAMA.' };
595
+ if (!invoke) return { error: 'The local model is not running. Start Ollama, or open Ollama in Modules.' };
532
596
  this.#examRunning = 'match';
533
597
  this.#examProgress = { done: 0, total: 0 };
534
598
  // Answered in the background: the human keeps the room while it runs.
@@ -658,13 +722,14 @@ export class Room {
658
722
  const parsed = parseMessage(text ?? '', target);
659
723
  const wanted = normalizeMode(mode, create === true ? 2 : 1);
660
724
  if (!parsed.target) return { ok: true, mode: wanted };
661
- const scopes = this.scopesFor(parsed.target);
725
+ const agent = parsed.target;
726
+ const scopes = this.scopesFor(agent);
662
727
  if (wanted >= 3) {
663
728
  // #3 CONTROL and #4 AIRLOCK: the ceiling must allow it, and there is one holder at a time.
664
729
  // A project without git still checkpoints, in MADRE's shadow repository.
665
- if (scopes.maxMode < wanted) return { ok: false, status: 403, mode: wanted, maxMode: scopes.maxMode, error: `@${parsed.target} is capped at #${scopes.maxMode} ${MODES[scopes.maxMode].label}; raise its MAX MODE to #${wanted} in CONNECTIONS to arm ${MODES[wanted].label}.` };
730
+ if (scopes.maxMode < wanted) return { ok: false, status: 403, mode: wanted, maxMode: scopes.maxMode, error: `@${parsed.target} is capped at #${scopes.maxMode} ${MODES[scopes.maxMode].label}; raise its max mode to #${wanted} in Settings › Permissions to arm ${MODES[wanted].label}.` };
666
731
  if (this.#controlDesk.holder) return { ok: false, status: 409, mode: wanted, maxMode: scopes.maxMode, error: `@${this.#controlDesk.holder.agent} already holds ${MODES[this.#controlDesk.holder.mode ?? 3].label} of this project; one holder at a time. STOPALL revokes it.` };
667
- return { ok: true, mode: wanted, maxMode: scopes.maxMode };
732
+ return { ok: true, mode: wanted, maxMode: scopes.maxMode, agent };
668
733
  }
669
734
  // #2 above the agent's ceiling still goes out: the room answers read-only
670
735
  // and says why (lease.refused), the way CREATE has always behaved.
@@ -696,7 +761,17 @@ export class Room {
696
761
  observedAt: observedAt ?? undefined,
697
762
  official: true,
698
763
  };
699
- await this.#emit('quota.updated', report);
764
+ // Written to the record only when it says something new (LOG-12 of the 1.0 review): the poll
765
+ // asks every minute, and an unchanged reading still became a durable event — some 1,440 a day
766
+ // per project, each spending a number of the project's count and making every write read a
767
+ // longer file. An unchanged one goes to whoever is watching, and nowhere else.
768
+ const said = JSON.stringify([Math.round(report.usedPercent * 10), report.resetAt ?? null, Boolean(report.stale), report.windows ?? null]);
769
+ const key = `${agent}\n${source}`;
770
+ if (this.#quotaSaid.get(key) === said) this.#say('quota.updated', report);
771
+ else {
772
+ this.#quotaSaid.set(key, said);
773
+ await this.#emit('quota.updated', report);
774
+ }
700
775
  return this.reportLimit(report);
701
776
  }
702
777
 
@@ -716,12 +791,23 @@ export class Room {
716
791
  // Each bill teaches the room what its own words cost, so the next turn can say what it is
717
792
  // about to spend before it spends it.
718
793
  if (cost.charsPerInputToken) this.#rate = this.#rate ? this.#rate * 0.7 + cost.charsPerInputToken * 0.3 : cost.charsPerInputToken;
719
- await this.#emit('turn.cost', { agent, mode, responseMessageId, ash: Boolean(cost.blocks.ash), spared: shape.spared, ...cost });
794
+ // Which MCP servers rode along: their tool lists go into every call the CLI makes inside the
795
+ // turn, so the economy can set turns with a server against turns without it.
796
+ await this.#emit('turn.cost', { agent, mode, responseMessageId, ash: Boolean(cost.blocks.ash), spared: shape.spared, servers: shape.servers ?? [], ...cost });
720
797
  } catch (error) {
721
798
  console.error(`MADRE could not weigh a turn: ${error.message}`);
722
799
  }
723
800
  }
724
801
 
802
+ // The model a turn ran on, for its price: the one chosen for it, or the CLI's own default when
803
+ // MADRE can read it (Codex's config; OpenCode's PULSE_OPENCODE_MODEL). Unknown is null: no price.
804
+ async #pricedModel(agentId, model) {
805
+ if (model) return model;
806
+ if (agentId === 'codex') return (await readCodexEfforts({ home: this.#codexHome }).catch(() => null))?.model ?? null;
807
+ if (agentId === 'opencode') return process.env.PULSE_OPENCODE_MODEL || null;
808
+ return null;
809
+ }
810
+
725
811
  async #recordUsage(agent, usage, { messageId = null, responseMessageId = null } = {}) {
726
812
  if (!usage) return;
727
813
  const charged = this.#budget.record(agent, usage);
@@ -788,7 +874,7 @@ export class Room {
788
874
  const command = request?.commands?.[index];
789
875
  if (!command) return { ok: false, status: 404, error: 'That command request is not in this room.' };
790
876
  if (!command.argv) return { ok: false, status: 422, error: `That line does not run: it ${whyText(command)}.` };
791
- if (this.#runBusy) return { ok: false, status: 409, error: 'A command is already running; wait for it or STOPALL.' };
877
+ if (this.#runBusy || this.#runDesk.busy) return { ok: false, status: 409, error: 'A command is already running; wait for it or STOPALL.' };
792
878
  if (this.#controlDesk.holder) return { ok: false, status: 409, error: `@${this.#controlDesk.holder.agent} holds ${MODES[this.#controlDesk.holder.mode ?? 3].label} of this project; run it when the turn ends.` };
793
879
  // Taken before the first await below: two presses in a row must not both get through.
794
880
  this.#runBusy = true;
@@ -802,18 +888,36 @@ export class Room {
802
888
  try {
803
889
  checkpoint = await this.#controlDesk.photograph({ id: `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-run-${responseMessageId.slice(0, 8)}-${index}`, label: `MADRE run @${request.agent}`, command: command.line });
804
890
  } catch { /* no photograph, no UNDO: the command still runs, and the card says so */ }
805
- const result = await this.#runDesk.run({ argv: command.argv, cwd: this.#projectRoot });
806
- const changes = checkpoint ? await this.#controlDesk.changesSince(checkpoint).catch(() => null) : null;
807
- const status = result.stoppedBy ? `stopped (${result.stoppedBy})` : result.exitCode === null ? 'did not start' : `exit ${result.exitCode}`;
808
- const event = await this.#emit('command.output', {
809
- name: 'run',
810
- title: `$ ${command.line} · ${status} · ${(result.durationMs / 1000).toFixed(1)}s · asked by @${request.agent}`,
811
- text: `${result.text || '(no output)'}${changes?.files.length ? `\n\nFiles changed: ${changes.files.map((file) => `${file.status} ${file.path}`).join(', ')}` : ''}`,
812
- ok: result.ok,
813
- args: [],
814
- run: { ...run, exitCode: result.exitCode, durationMs: result.durationMs, stoppedBy: result.stoppedBy, checkpointId: checkpoint?.id, files: changes?.files.length ? changes.files.slice(0, 40) : undefined },
815
- });
816
- return { ok: true, status: 200, result: { ok: result.ok, exitCode: result.exitCode, sequence: event.sequence } };
891
+ // The first of two things: the command ends, or it opens a port and becomes a server. A server
892
+ // answers the press at once and keeps running; its output card comes when it is stopped.
893
+ let opened = () => {};
894
+ const serving = new Promise((resolve) => { opened = resolve; });
895
+ const finished = this.#runDesk.run({ id: `${responseMessageId}:${index}`, argv: expandHome(command), cwd: this.#projectRoot, onServing: (where) => opened(where) })
896
+ .then(async (result) => {
897
+ const changes = checkpoint ? await this.#controlDesk.changesSince(checkpoint).catch(() => null) : null;
898
+ const status = result.stoppedBy ? `stopped (${result.stoppedBy})` : result.exitCode === null ? 'did not start' : `exit ${result.exitCode}`;
899
+ const event = await this.#emit('command.output', {
900
+ name: 'run',
901
+ title: `$ ${command.line} · ${status} · ${(result.durationMs / 1000).toFixed(1)}s · asked by @${request.agent}`,
902
+ text: `${result.text || '(no output)'}${changes?.files.length ? `\n\nFiles changed: ${changes.files.map((file) => `${file.status} ${file.path}`).join(', ')}` : ''}`,
903
+ ok: result.ok,
904
+ args: [],
905
+ run: { ...run, exitCode: result.exitCode, durationMs: result.durationMs, stoppedBy: result.stoppedBy, served: result.serving ?? undefined, checkpointId: checkpoint?.id, files: changes?.files.length ? changes.files.slice(0, 40) : undefined },
906
+ });
907
+ return { ...result, sequence: event.sequence };
908
+ });
909
+ const first = await Promise.race([finished.then((ended) => ({ ended })), serving.then((where) => ({ where }))]);
910
+ if (first.where) {
911
+ await this.#emit('run.serving', { ...run, ...first.where });
912
+ finished.catch(() => null);
913
+ return { ok: true, status: 200, result: { serving: first.where } };
914
+ }
915
+ return { ok: true, status: 200, result: { ok: first.ended.ok, exitCode: first.ended.exitCode, sequence: first.ended.sequence } };
916
+ }
917
+
918
+ // The human pressed STOP on one running command's card.
919
+ stopRun({ responseMessageId, index }) {
920
+ return this.#runDesk.stop('STOP', `${responseMessageId}:${Number(index)}`);
817
921
  }
818
922
 
819
923
  ashEnabled() {
@@ -844,8 +948,11 @@ export class Room {
844
948
  };
845
949
  }
846
950
 
847
- timeoutFor(agentId) {
848
- return this.#agentTimeouts[agentId] ?? this.#agentTimeouts.default ?? 180000;
951
+ // How long a turn may run. One that writes gets longer unless the human said otherwise: the first
952
+ // «write me the app» in an empty project ran past 180 s and was cut with two files of three on
953
+ // disk. What the human set, for one agent or for all of them, always wins.
954
+ timeoutFor(agentId, { writing = false } = {}) {
955
+ return this.#agentTimeouts[agentId] ?? this.#agentTimeouts.default ?? (writing ? WRITING_TIMEOUT_MS : READING_TIMEOUT_MS);
849
956
  }
850
957
 
851
958
  // A process that died mid-turn leaves `agent.started` without a closing
@@ -880,6 +987,8 @@ export class Room {
880
987
  this.#archivist.stop();
881
988
  this.#vectors.stop();
882
989
  this.#shuttingDown = true;
990
+ // A server a command started does not outlive the room that started it.
991
+ this.#runDesk.stop('MADRE is shutting down');
883
992
  this.#escalation.settleAll(null, 'stopped');
884
993
  for (const plan of this.#plans.values()) plan.stopped = 'MADRE is shutting down';
885
994
  for (const { controller } of this.#turns.values()) controller.abort('MADRE is shutting down');
@@ -892,13 +1001,36 @@ export class Room {
892
1001
  this.#vectors.stop();
893
1002
  }
894
1003
 
895
- async send({ text, target, model = null, effort = null, attachments = [], create = false, ash = false, mode = undefined }) {
1004
+ async send(options = {}) {
1005
+ await this.#accept(options, null);
1006
+ }
1007
+
1008
+ // An order from BISHOP (docs/MACROVERSO.md, S-028, D-S11). The same door as the human's message
1009
+ // — the mode gate, the lease, the guard and the limits all run as they do for a message — with
1010
+ // three things fixed here and never read from a request: the origin is `bishop`, the turn runs at
1011
+ // depth 1 and it may not delegate, so a room that receives an order cannot open a plan or pass
1012
+ // anything on. Only #0–#2 come in this way; #3 and #4 are armed by the human in this room. The
1013
+ // caller (the BISHOP module, after it checked the contract) gets back the reply or the failure.
1014
+ async order({ text, target, mode = 1 } = {}) {
1015
+ const wanted = Number(mode);
1016
+ if (!Number.isInteger(wanted) || wanted < 0 || wanted > 2) throw new Error('An order comes in at #2 at most; #3 and #4 are armed by the human in this room.');
1017
+ const accepted = await this.#accept({ text, target, mode: wanted }, { via: 'bishop', depth: 1 });
1018
+ const events = await this.#store.readAll();
1019
+ const reply = accepted?.responseMessageId ? events.find((event) => event.type === 'message.created' && event.payload?.messageId === accepted.responseMessageId)?.payload : null;
1020
+ const failed = accepted?.messageId ? events.findLast((event) => event.type === 'message.failed' && event.payload?.messageId === accepted.messageId)?.payload : null;
1021
+ return { messageId: accepted?.messageId ?? null, responseMessageId: accepted?.responseMessageId ?? null, text: reply?.text ?? null, error: reply ? null : failed?.error ?? 'The turn ended without a reply.' };
1022
+ }
1023
+
1024
+ async #accept({ text, target, model = null, effort = null, attachments = [], create = false, ash = false, mode = undefined, draft = false, via = null }, order) {
896
1025
  const parsed = parseMessage(text, target);
897
1026
  const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
898
1027
  // This is the decision the human made when pressing Send. Capture it before modeCheck() —
899
1028
  // the first await — so a concurrent module toggle cannot change the turn after that click.
900
1029
  const ashRequested = ash === true;
901
1030
  const ashActive = ashRequested && this.#ashEnabled;
1031
+ // A message that came from a phone never goes above #2, whatever reached this far (TEL-01): the
1032
+ // bridge and the route say so first; the room says it last, for any road added later.
1033
+ if (via && requestedMode > PHONE_MAX_MODE) throw new Error('#3 EDIT and #4 FULL ACCESS are opened from the Mac.');
902
1034
  const gate = await this.modeCheck({ target, text, mode: requestedMode });
903
1035
  if (!gate.ok) throw new Error(gate.error);
904
1036
  create = requestedMode === 2;
@@ -908,10 +1040,22 @@ export class Room {
908
1040
  if (!parsed.text && !files.length) throw new Error('Write a message first.');
909
1041
  if (!parsed.target) throw new Error('Choose an agent or begin with @agent.');
910
1042
  if (model !== null && model !== undefined && model !== '' && !isValidModelName(model)) throw new Error('Model name is not valid.');
911
- const chosenModel = model || null;
1043
+ // A model chosen for one agent never travels to another. Two ways it used to: a message that
1044
+ // opened with @gemini while the composer pointed at @codex took Codex's model along, and a
1045
+ // name that is plainly another vendor's reached the CLI and came back as an API error that
1046
+ // read like anything but «wrong model». Either way the agent answers on its own default.
1047
+ const redirected = Boolean(target) && parsed.target !== target;
1048
+ const modelIgnored = model && (redirected || belongsElsewhere(parsed.target, model)) ? model : null;
1049
+ const chosenModel = modelIgnored ? null : (model || null);
912
1050
  // An effort the target's CLI does not take is dropped, not refused: the composer remembers it
913
- // per agent, so a stale one should cost the human nothing but the flag.
914
- const chosenEffort = isValidEffort(parsed.target, effort) ? effort : null;
1051
+ // per agent, so a stale one should cost the human nothing but the flag. Codex's levels are the
1052
+ // model's, so a level its model does not take is dropped the same way, and the room says so.
1053
+ let chosenEffort = !redirected && isValidEffort(parsed.target, effort) ? effort : null;
1054
+ let effortIgnored = null;
1055
+ if (chosenEffort && parsed.target === 'codex' && !(await codexTakes(chosenModel, chosenEffort, { home: this.#codexHome }))) {
1056
+ effortIgnored = chosenEffort;
1057
+ chosenEffort = null;
1058
+ }
915
1059
  // What the human wrote, sent as they wrote it. Ash used to rewrite this line before it left
916
1060
  // the room; it does not any more, so there is no original to keep beside an abbreviation.
917
1061
  const text2 = parsed.text || `(${files.length} attached file${files.length === 1 ? '' : 's'})`;
@@ -927,12 +1071,17 @@ export class Room {
927
1071
  mode: ghost ? 0 : control ? requestedMode : (standing || create ? 2 : 1),
928
1072
  role: 'user',
929
1073
  sender: 'you',
1074
+ // Written from a paired phone: which kind, said by the bridge (docs/MOBILE.md, MOV-008). An
1075
+ // order says `bishop`, set by `order()` in this process and never taken from a request.
1076
+ via: order ? order.via : VIA_KINDS.has(via) ? via : undefined,
930
1077
  target: parsed.target,
931
1078
  text: text2,
932
1079
  ash: ashRequested ? { requested: true, active: ashActive } : undefined,
933
1080
  status: 'sent',
934
1081
  model: chosenModel,
1082
+ modelIgnored: modelIgnored ?? undefined,
935
1083
  effort: chosenEffort ?? undefined,
1084
+ effortIgnored: effortIgnored ?? undefined,
936
1085
  attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
937
1086
  references: references.length ? references.map(({ excerpt, ...reference }) => reference) : undefined,
938
1087
  create: create === true ? true : undefined,
@@ -958,20 +1107,25 @@ export class Room {
958
1107
  await this.#emit('lease.refused', {
959
1108
  messageId,
960
1109
  agent: parsed.target,
961
- reason: !scopes.write.capable ? `@${parsed.target} cannot create files from its CLI` : scopes.maxMode < 2 ? `@${parsed.target} is capped at #${scopes.maxMode} ${MODES[scopes.maxMode].label} in CONNECTIONS` : `file creation is switched off for @${parsed.target}`,
1110
+ reason: !scopes.write.capable ? `@${parsed.target} cannot create files from its CLI` : scopes.maxMode < 2 ? `@${parsed.target} is capped at #${scopes.maxMode} ${MODES[scopes.maxMode].label} in Settings › Permissions` : `file creation is switched off for @${parsed.target}`,
962
1111
  unavailable,
963
1112
  disabled,
964
1113
  alternatives,
965
- message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (enable it in CONNECTIONS)' : 'its CLI cannot create files'}${unavailableSaid.length ? `; it cannot ${unavailableSaid.join(' or ')}` : ''}.${alternatives.length ? ` For creation ask ${alternatives.join(' or ')}.` : ''}`,
1114
+ message: `@${parsed.target} will answer read-only: ${scopes.write.capable ? 'file creation is switched off for it (turn it on in Settings › Agents)' : 'its CLI cannot create files'}${unavailableSaid.length ? `; it cannot ${unavailableSaid.join(' or ')}` : ''}.${alternatives.length ? ` For creation ask ${alternatives.join(' or ')}.` : ''}`,
966
1115
  });
967
1116
  } else {
968
- lease = await this.#projectLease({ leaseId: randomUUID(), messageId });
1117
+ // `/module` asks for less than #2: one file in its own draft folder. Only a #2 message can
1118
+ // narrow itself this way; a default-#2 lease stays what it is.
1119
+ lease = draft === true && create === true
1120
+ ? await this.#draftLease({ leaseId: randomUUID(), messageId })
1121
+ : await this.#projectLease({ leaseId: randomUUID(), messageId });
969
1122
  // CREATE authorizes the plan to use each delegate's enabled creation
970
1123
  // scopes; a default-#2 lease authorizes files only.
971
1124
  lease.scopeCeiling = { write: true, imageGen: !standing, web: true };
972
1125
  lease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, enabled.includes(scope) && (!standing || scope === 'write')]));
973
1126
  await this.#emit('lease.granted', {
974
1127
  standing: standing || undefined,
1128
+ draft: lease.draft || undefined,
975
1129
  leaseId: lease.leaseId,
976
1130
  messageId,
977
1131
  agent: parsed.target,
@@ -990,14 +1144,16 @@ export class Room {
990
1144
  target: parsed.target,
991
1145
  error: `Message is too long (${parsed.text.length} characters); the limit is ${this.#maxMessageChars}.`,
992
1146
  });
993
- return;
1147
+ return { messageId, responseMessageId: null };
994
1148
  }
995
- let allowDelegation = true;
996
- if (this.#plans.size > 0) {
1149
+ // An order never delegates, so a plan already running here has nothing to warn about.
1150
+ let allowDelegation = !order;
1151
+ if (!order && this.#plans.size > 0) {
997
1152
  allowDelegation = false;
998
1153
  await this.#alert('plan-active', `A plan by @${[...this.#plans.values()][0].orchestrator} is still running. Your message will be answered, but no second plan will start. Type STOPALL to halt every agent.`, `plan-active:${messageId}`);
999
1154
  }
1000
- await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, effort: chosenEffort, attachments: files, references, lease, ash: ashActive, mode: ghost ? 0 : control ? requestedMode : (lease ? 2 : 1) }));
1155
+ const responseMessageId = await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: order ? order.depth : 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, effort: chosenEffort, attachments: files, references, lease, ash: ashActive, mode: ghost ? 0 : control ? requestedMode : (lease ? 2 : 1) }));
1156
+ return { messageId, responseMessageId };
1001
1157
  }
1002
1158
 
1003
1159
  // A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
@@ -1008,13 +1164,66 @@ export class Room {
1008
1164
  return { leaseId, messageId, outDir: this.#projectRoot, relativeDir: '.', scratchDir: scratch.relativeDir, create: true };
1009
1165
  }
1010
1166
 
1167
+ // A lease for one folder of its own and nothing else: `/module` writes one file for the human to
1168
+ // read before installing it, and needs no more than that. Every CLI is told the folder and held
1169
+ // to it; nothing in the project can be touched, so there is no checkpoint to take and no other
1170
+ // turn to wait for.
1171
+ async #draftLease({ leaseId, messageId }) {
1172
+ const draft = await createLease({ projectRoot: this.#projectRoot, leaseId });
1173
+ return { ...draft, messageId, draft: true };
1174
+ }
1175
+
1011
1176
  // Artifacts from a #2 turn: the files the checkpoint saw appear, as the room shows them.
1177
+ // Said once, when a writing turn has to wait for the one already writing in the project.
1178
+ async #sayWaitingToWrite({ messageId, agent }) {
1179
+ const ahead = this.#controlDesk.writer();
1180
+ if (ahead) await this.#emit('write.waiting', { messageId, agent: agent.id, behind: ahead.agent ?? null });
1181
+ }
1182
+
1183
+ // A round's answers, for the local model's closing turn: who was asked, what each one said and
1184
+ // under which number, or that it did not answer. Handed the transcript instead, a 7B model gave
1185
+ // one agent's point to another, left one out and quoted itself.
1186
+ async #roundAnswers(planId) {
1187
+ const events = (await this.#store.readAll()).filter((event) => event.payload?.planId === planId);
1188
+ const asked = [...new Set(events.filter((event) => event.type === 'message.created' && event.payload.status === 'delegated' && event.payload.target !== event.payload.sender).map((event) => event.payload.target))];
1189
+ const answers = asked.map((agent) => {
1190
+ const said = events.find((event) => event.type === 'message.created' && event.payload.role === 'assistant' && event.payload.sender === agent && event.payload.status !== 'delegated');
1191
+ if (said) return { agent, sequence: said.sequence, text: said.payload.text ?? '' };
1192
+ const failed = events.find((event) => event.type === 'message.failed' && event.payload.target === agent);
1193
+ return { agent, failed: true, error: failed ? String(failed.payload.error ?? '').slice(0, 160) : null };
1194
+ });
1195
+ return { asked, answers };
1196
+ }
1197
+
1198
+ // The bytes of files the turn created, for telling a copy from a new file. Images only, and only
1199
+ // a few: this compares what an image tool made, not the project.
1200
+ async #bytesOf(paths) {
1201
+ const images = paths.filter((path) => /\.(png|jpe?g|webp|gif)$/i.test(path)).slice(0, 16);
1202
+ return (await Promise.all(images.map((path) => readFileBytes(joinPath(this.#projectRoot, path)).catch(() => null)))).filter(Boolean);
1203
+ }
1204
+
1205
+ async #draftedIn(scratchDir, { depth = 2 } = {}) {
1206
+ const found = [];
1207
+ const walk = async (relative, level) => {
1208
+ const entries = await readdir(joinPath(this.#projectRoot, relative), { withFileTypes: true }).catch(() => []);
1209
+ for (const entry of entries) {
1210
+ const path = `${relative}/${entry.name}`;
1211
+ if (entry.isDirectory()) { if (level < depth) await walk(path, level + 1); continue; }
1212
+ if (!entry.isFile()) continue;
1213
+ const info = await statFile(joinPath(this.#projectRoot, path)).catch(() => null);
1214
+ found.push({ name: baseName(path), path, size: info?.size ?? 0, contentType: declaredTypeFor(path), status: 'created' });
1215
+ }
1216
+ };
1217
+ await walk(String(scratchDir).replace(/\/$/, ''), 0);
1218
+ return found.sort((a, b) => a.path.localeCompare(b.path));
1219
+ }
1220
+
1012
1221
  async #artifactsFrom(files) {
1013
1222
  const out = [];
1014
1223
  for (const file of files) {
1015
1224
  if (file.status !== 'A') continue;
1016
1225
  const info = await statFile(joinPath(this.#projectRoot, file.path)).catch(() => null);
1017
- out.push({ name: baseName(file.path), path: file.path, size: info?.size ?? 0, contentType: contentTypeFor(file.path), status: 'created' });
1226
+ out.push({ name: baseName(file.path), path: file.path, size: info?.size ?? 0, contentType: declaredTypeFor(file.path), status: 'created' });
1018
1227
  }
1019
1228
  return out.sort((a, b) => a.path.localeCompare(b.path));
1020
1229
  }
@@ -1093,7 +1302,12 @@ export class Room {
1093
1302
  if (this.#shuttingDown) return null;
1094
1303
  const agent = this.#agents.find((item) => item.id === targetId);
1095
1304
  if (!agent?.detected) {
1096
- await this.#emit('message.failed', { messageId, target: targetId, planId, error: `${targetId} is not installed on this computer.` });
1305
+ // @madre is not a CLI to install: it is the room's local agent, here while Ollama runs with a
1306
+ // chat model. Saying "not installed" sent people to install something that does not exist.
1307
+ const error = agent?.local || targetId === 'madre'
1308
+ ? `@${targetId} left the room: Ollama is not running. Start Ollama, or open Ollama in Modules.`
1309
+ : `${targetId} is not installed on this computer.`;
1310
+ await this.#emit('message.failed', { messageId, target: targetId, planId, error });
1097
1311
  return null;
1098
1312
  }
1099
1313
  if (!agent.ready) {
@@ -1143,7 +1357,9 @@ export class Room {
1143
1357
  await this.#alert('plan-long', `The plan by @${plan.orchestrator} has been running for ${Math.round((Date.now() - plan.startedAt) / 60000)} min. STOPALL halts it.`, `plan-long:${plan.planId}`);
1144
1358
  }
1145
1359
  }
1146
- await this.#emit('agent.started', { messageId, agent: agent.id, handoffId, planId });
1360
+ // The limit this turn will be held to, said with its start so the room counts down from the
1361
+ // real number and not from a default it guessed.
1362
+ await this.#emit('agent.started', { messageId, agent: agent.id, handoffId, planId, timeoutMs: this.timeoutFor(agent.id, { writing: Number(mode) >= 2 || Boolean(lease) }) });
1147
1363
  const controller = new AbortController();
1148
1364
  const turn = { controller, promise: null, planId, agent: agent.id, startedAt: Date.now() };
1149
1365
  turn.promise = this.#runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall, memories, handoffId, signal: controller.signal, model, effort, attachments, references, lease, ash, mode, escalation });
@@ -1163,10 +1379,9 @@ export class Room {
1163
1379
  return outcome?.responseMessageId ?? null;
1164
1380
  }
1165
1381
 
1166
- // What the next turn to an agent would carry, built exactly the way a turn builds it and sent
1167
- // nowhere. Nothing here counts: a memory read for this was not recalled, the window does not
1168
- // move, and no process is started. It is the document MADRE writes in the human's name, which
1169
- // until now existed only for the instant a CLI was reading it.
1382
+ // What the next turn to an agent would carry, built without starting a process or counting a
1383
+ // memory read. Signed linked notes for BISHOP are fetched only on send, so the preview marks
1384
+ // that one dynamic part instead of spending a contract's hourly reading allowance here.
1170
1385
  async briefing({ agent: agentId, mode = 1, text = '', ash = false } = {}) {
1171
1386
  const agent = this.#agents.find((one) => one.id === agentId && one.detected);
1172
1387
  if (!agent) return null;
@@ -1180,6 +1395,9 @@ export class Room {
1180
1395
  const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
1181
1396
  ? await this.#toolsForTurn({ agent: agent.id, mode, lease: null, scratchDir: null }).catch(() => [])
1182
1397
  : [];
1398
+ const bishop = this.#bishopForTurn ? await this.#bishopForTurn({ agent: agent.id, mode, text, preview: true }).catch(() => null) : null;
1399
+ // Recall from links reads the other room when the turn runs; the preview only says it will.
1400
+ const linkedPreview = this.#linkedForTurn && agent.adapter !== 'madre-local' && mode !== 0 ? await this.#linkedForTurn({ agent: agent.id, mode, text, preview: true }).catch(() => null) : null;
1183
1401
  const scopes = this.scopesFor(agent.id);
1184
1402
  // The lease a turn at this mode would be given, shaped exactly as the turn shapes it but
1185
1403
  // creating nothing: no folder is made, no checkpoint is taken. Without it the preview would
@@ -1202,7 +1420,7 @@ export class Room {
1202
1420
  context: built.context, recall: built.recall, memories: built.memories,
1203
1421
  attachments: [], references: [], lease,
1204
1422
  scopes: { web: scopes.web.enabled && scopes.web.wired, imageGen: scopes.imageGen.enabled && scopes.imageGen.wired },
1205
- ash: ash === true && this.ashEnabled(), choices: this.#choicesEnabled && mode !== 0, runs: this.#runsEnabled && mode !== 0 && mode < 4, mode, escalation: null, mcpServers,
1423
+ ash: ash === true && this.ashEnabled(), choices: this.#choicesEnabled && mode !== 0, runs: this.#runsEnabled && mode !== 0 && mode < 4, mode, escalation: null, mcpServers, bishop,
1206
1424
  });
1207
1425
  // Each block with what put it here and what would take it away, so "read, never written" can
1208
1426
  // be checked one block at a time instead of believed.
@@ -1219,6 +1437,7 @@ export class Room {
1219
1437
  window: { from: built.context?.firstSequence ?? null, through: built.context?.throughSequence ?? null, omitted: built.context?.omittedMessages ?? 0, carried: built.context?.messages?.length ?? 0 },
1220
1438
  recalled: built.memories?.length ?? 0,
1221
1439
  quoted: built.recall?.entries?.length ?? 0,
1440
+ dynamic: Boolean(bishop && agent.adapter === 'madre-local') || Boolean(linkedPreview?.dynamic),
1222
1441
  };
1223
1442
  }
1224
1443
 
@@ -1321,7 +1540,9 @@ export class Room {
1321
1540
  if (this.#controlDesk.holder) {
1322
1541
  await this.#alert('control-busy', `@${this.#controlDesk.holder.agent} still holds CONTROL; @${agent.id} answers read-only this turn.`, `control-busy:${agent.id}`);
1323
1542
  } else {
1324
- const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode });
1543
+ await this.#sayWaitingToWrite({ messageId, agent });
1544
+ const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode, signal }).catch((error) => (error?.code === 'ABORT_ERR' ? null : Promise.reject(error)));
1545
+ if (!seat) { await this.#emit('message.failed', { messageId, target: agent.id, planId, error: `Stopped while waiting for the project: another turn was writing in it.`, stopped: true }); return null; }
1325
1546
  controlRun = seat.run;
1326
1547
  lease = seat.lease;
1327
1548
  await this.#emit('control.started', seat.announcement);
@@ -1350,8 +1571,11 @@ export class Room {
1350
1571
  });
1351
1572
  }
1352
1573
  if (lease?.create && !controlRun) {
1353
- // CREATE: photograph the project now; after the turn only what appeared stays.
1354
- const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode: 2 });
1574
+ // CREATE: photograph the project now; after the turn only what appeared stays. Another turn
1575
+ // writing in the project, in this conversation or another, is waited for first.
1576
+ await this.#sayWaitingToWrite({ messageId, agent });
1577
+ const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode: 2, signal }).catch((error) => (error?.code === 'ABORT_ERR' ? null : Promise.reject(error)));
1578
+ if (!seat) { await this.#emit('message.failed', { messageId, target: agent.id, planId, error: `Stopped while waiting for the project: another turn was writing in it.`, stopped: true }); return null; }
1355
1579
  createRun = seat.run;
1356
1580
  // The folder this turn was told to draft in, so settling it does not undo what it was
1357
1581
  // invited to write. Only this turn's own folder; every other `.pulse` path stays forbidden.
@@ -1385,17 +1609,24 @@ export class Room {
1385
1609
  if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
1386
1610
  const before = lease && !lease.control && !lease.create ? await snapshot(lease.outDir) : null;
1387
1611
  responseMessageId = randomUUID();
1612
+ this.#liveTurns.set(responseMessageId, { agent: agent.id, mode: turnMode });
1388
1613
  const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
1389
1614
  const others = this.delegatesFor(agent.id);
1390
1615
  const mayDelegate = allowDelegation && this.#delegation && depth === 0;
1391
1616
  // Tools the modules hand to this turn (a browser, say), attached to the CLI for this run only.
1392
1617
  const mcpServers = this.#toolsForTurn && agent.adapter !== 'madre-local'
1393
- ? await this.#toolsForTurn({ agent: agent.id, mode: turnMode, lease, scratchDir: lease?.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : null }).catch(() => [])
1618
+ ? await this.#toolsForTurn({ agent: agent.id, mode: turnMode, text, turn: responseMessageId, lease, scratchDir: lease?.scratchDir ? joinPath(this.#projectRoot, lease.scratchDir) : null, servers: this.#runDesk.serving().filter((one) => !one.network).map((one) => one.port) }).catch(() => [])
1394
1619
  : [];
1620
+ const bishop = this.#bishopForTurn ? await this.#bishopForTurn({ agent: agent.id, mode: turnMode, text, turn: responseMessageId }).catch(() => null) : null;
1621
+ // Notes a link with automatic recall brings to this turn (fase 3e): never in GHOST, never for
1622
+ // @madre (its briefing is its own), bounded by the module and counted as readings.
1623
+ const linked = this.#linkedForTurn && agent.adapter !== 'madre-local' && turnMode !== 0
1624
+ ? (await this.#linkedForTurn({ agent: agent.id, mode: turnMode, text, turn: responseMessageId }).catch(() => null))?.text ?? null
1625
+ : null;
1395
1626
  // The prompt and the shape it was built from: one is sent, the other is kept until the CLI
1396
1627
  // says what it cost, so the bill can be attributed to the blocks that caused it.
1397
- const shaped = this.#promptFor({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ash, choices: this.#choicesEnabled && turnMode !== 0, runs: this.#runsEnabled && turnMode !== 0 && turnMode < 4, mode: turnMode, escalation, mcpServers, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null });
1398
- this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared });
1628
+ const shaped = this.#promptFor({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ash, choices: this.#choicesEnabled && turnMode !== 0, runs: this.#runsEnabled && turnMode !== 0 && turnMode < 4, mode: turnMode, escalation, mcpServers, bishop, linked, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null });
1629
+ this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared, servers: [this.#memoryServer?.name, ...mcpServers.map((server) => server.name)].filter(Boolean) });
1399
1630
  // What this turn is about to read, said while it is still reading it. The size is exact;
1400
1631
  // only the conversion to tokens is an estimate, and it is made at this room's own rate.
1401
1632
  const reading = this.#reading(shaped.parts);
@@ -1415,7 +1646,7 @@ export class Room {
1415
1646
  projectRoot: this.#projectRoot,
1416
1647
  text,
1417
1648
  prompt: shaped.text,
1418
- timeoutMs: this.timeoutFor(agent.id),
1649
+ timeoutMs: this.timeoutFor(agent.id, { writing: Boolean(lease) }),
1419
1650
  signal,
1420
1651
  model,
1421
1652
  effort,
@@ -1425,19 +1656,39 @@ export class Room {
1425
1656
  imageStudio,
1426
1657
  memoryServer: memoryServerForTurn(this.#memoryServer, { agent: agent.id, messageId: responseMessageId, mode: turnMode }),
1427
1658
  mcpServers,
1659
+ bishop,
1428
1660
  // For @madre: who asks, who it may convene, and whether a plan would run at all.
1429
1661
  requester,
1430
1662
  crew: others,
1431
1663
  delegation: mayDelegate,
1432
1664
  maxSteps: this.#maxPlanSteps,
1665
+ // And for its closing turn of a round: what each agent asked actually said, with its number.
1666
+ round: agent.id === MADRE_AGENT_ID && planId && /this is your closing turn/i.test(text) ? await this.#roundAnswers(planId) : undefined,
1433
1667
  });
1434
1668
  // Privacy: a private term in a reply is replaced before the ledger, the plan parser,
1435
1669
  // the archivist or any other agent can see it. The reply says how many, not which.
1436
1670
  const guarded = this.#privacy?.redact(result.text ?? '') ?? { text: result.text, hits: 0 };
1437
1671
  if (guarded.hits) result.text = guarded.text;
1438
1672
  // CREATE: what appeared stays and is shown; what existed before is put back and said.
1439
- const createChanges = createRun ? await this.#controlDesk.settle(createRun) : null;
1673
+ const createChanges = createRun ? await this.#controlDesk.settleOrSay(createRun) : null;
1674
+ // Images an agent's own tool made outside the project (Codex keeps them under its home) come
1675
+ // into this turn's draft folder, so they are kept and shown like anything the turn wrote. One
1676
+ // the agent already copied into the project is shown there, not twice.
1677
+ if (createRun?.scratchDir && Array.isArray(result.generatedImages)) {
1678
+ const placed = await this.#bytesOf((createChanges?.files ?? []).filter((file) => file.status === 'A').map((file) => file.path));
1679
+ for (const image of result.generatedImages.slice(0, 8)) {
1680
+ const bytes = await readFileBytes(image).catch(() => null);
1681
+ if (!bytes || placed.some((one) => one.equals(bytes))) continue;
1682
+ await copyFile(image, joinPath(this.#projectRoot, createRun.scratchDir, baseName(image))).catch(() => null);
1683
+ }
1684
+ }
1440
1685
  const artifacts = createChanges ? await this.#artifactsFrom(createChanges.files) : lease && !lease.control ? diffSnapshots(before, await snapshot(lease.outDir), { relativeDir: lease.relativeDir }) : [];
1686
+ // What the turn wrote in its own draft folder. The diff cannot see it in a project that ignores
1687
+ // `.pulse/` (most do, the moment a .gitignore names it), so `/module` wrote its file there and
1688
+ // no install card ever came. The folder is new for this turn: all of it is this turn's.
1689
+ if (createRun?.scratchDir) {
1690
+ for (const drafted of await this.#draftedIn(createRun.scratchDir)) if (!artifacts.some((one) => one.path === drafted.path)) artifacts.push(drafted);
1691
+ }
1441
1692
  // A turn that could write, wrote nothing, and answers naming files. That is the shape of a
1442
1693
  // write refused without a word — a headless CLI cannot stop to ask, so what the policy does
1443
1694
  // not allow simply fails and the model reports the file as created. The room does not read
@@ -1454,7 +1705,7 @@ export class Room {
1454
1705
  if (missing.length) await this.#emit('create.missing', { messageId: responseMessageId, agent: agent.id, planId, files: missing });
1455
1706
  }
1456
1707
  // CONTROL: what really changed in the project, forbidden zones reverted on the spot.
1457
- const controlChanges = controlRun ? await this.#controlDesk.settle(controlRun) : null;
1708
+ const controlChanges = controlRun ? await this.#controlDesk.settleOrSay(controlRun) : null;
1458
1709
  const directives = mayDelegate
1459
1710
  ? parseDirectives(result.text, { self: agent.id, available: others, maxSteps: this.#maxPlanSteps })
1460
1711
  : { steps: [], closing: null, ignored: [] };
@@ -1463,7 +1714,9 @@ export class Room {
1463
1714
  // human sees a plan-shaped reply and nothing happening.
1464
1715
  await this.#emit('plan.ignored', { orchestrator: agent.id, responseMessageId, reasons: directives.ignored.map((item) => item.reason) });
1465
1716
  }
1466
- if (depth > 0 && /```pulse/i.test(result.text)) {
1717
+ // Only a ```pulse fence is a plan, read the way src/directives.mjs reads it; ```pulse-run,
1718
+ // ```pulse-ask and ```pulse-salas are not (S-029 in docs/MACROVERSO.md).
1719
+ if (depth > 0 && /```pulse\s*\n/i.test(result.text)) {
1467
1720
  await this.#alert('nested-delegation', `@${agent.id} tried to open a plan from inside a plan. It was ignored; the sequence stays under @${requester}. STOPALL if the room drifts.`, `nested:${agent.id}`);
1468
1721
  }
1469
1722
  // A question the agent put to the human: it leaves the text and comes back as buttons.
@@ -1498,9 +1751,9 @@ export class Room {
1498
1751
  const validation = validateAshReply({ request: text, response: result.text });
1499
1752
  await this.#emit('ash.validated', { messageId, responseMessageId, agent: agent.id, ...validation });
1500
1753
  }
1501
- if (asked) await this.#emit('choice.offered', { messageId, responseMessageId, agent: agent.id, question: asked.question, options: asked.options, recommended: asked.recommended ?? undefined });
1754
+ if (asked) await this.#emit('choice.offered', { messageId, responseMessageId, agent: agent.id, question: asked.question, options: asked.options, recommended: asked.recommended ?? undefined, modes: asked.modes ?? undefined });
1502
1755
  if (requested) await this.#emit('run.requested', { messageId, responseMessageId, agent: agent.id, reason: requested.reason, commands: requested.commands });
1503
- if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, marker: this.#privacy.marker });
1756
+ if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, kinds: guarded.kinds, marker: this.#privacy.marker });
1504
1757
  if (artifacts.length) {
1505
1758
  await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
1506
1759
  // A <id>.module.mjs is an agent proposing a module: the human installs it from the room, or not.
@@ -1520,11 +1773,17 @@ export class Room {
1520
1773
  }
1521
1774
  }
1522
1775
  if (controlChanges) await this.#emit('control.changed', { ...controlChanges, responseMessageId });
1776
+ // Codex and OpenCode give tokens, not dollars: the turn is priced at the list price of the
1777
+ // model it ran on (src/prices.mjs), an estimate that says it is one. Gemini prices itself in
1778
+ // its adapter, where its report names each model it used.
1779
+ if (result.usage) result.usage = withListPrice(result.usage, await this.#pricedModel(agent.id, model));
1523
1780
  await this.#recordUsage(agent.id, result.usage, { messageId, responseMessageId });
1524
1781
  await this.#recordCost(agent.id, responseMessageId, result.usage, turnMode);
1782
+ this.#liveTurns.delete(responseMessageId);
1525
1783
  await this.#emit('agent.completed', { messageId, agent: agent.id, handoffId, planId });
1526
1784
  return { responseMessageId, directives };
1527
1785
  } catch (error) {
1786
+ if (responseMessageId) this.#liveTurns.delete(responseMessageId);
1528
1787
  if (responseMessageId && this.#promptShape.has(responseMessageId)) {
1529
1788
  const shape = this.#promptShape.get(responseMessageId);
1530
1789
  this.#promptShape.delete(responseMessageId);
@@ -1533,7 +1792,34 @@ export class Room {
1533
1792
  await this.#emit('turn.cost', { agent: agent.id, mode: turnMode, responseMessageId, ash: Boolean(cost.blocks.ash), failed: true, spared: shape.spared, ...cost });
1534
1793
  }
1535
1794
  }
1536
- await this.#emit('message.failed', { messageId, target: agent.id, planId, error: this.#privacy ? this.#privacy.redact(failureMessage(error)).text : failureMessage(error), ...(this.#turns.get(messageId)?.stoppedByHuman ? { stopped: true } : {}) });
1795
+ // A CLI that does not know its model says so in its vendor's words, which read like an outage
1796
+ // or a permission problem. MADRE says what it is first; the CLI's own line stays after it,
1797
+ // word for word, so it can still be searched.
1798
+ const said = failureMessage(error);
1799
+ const plain = MODEL_NOT_FOUND.test(String(error?.message ?? error ?? ''))
1800
+ ? `${model ? `${agent.label} does not know the model \`${model}\`.` : `${agent.label} does not know the model it was asked to use.`} Choose another on the model chip beside TO @${agent.id}, or the default; permissions and modes have nothing to do with it. CLI: ${said}`
1801
+ : said;
1802
+ // A turn cut short (the time limit, STOP ALL, a crash) may still have written. What it left is
1803
+ // read and kept the way any turn's is, CREATE still putting back what existed before, and the
1804
+ // failure carries the list and its checkpoint: the first «write the app» was cut at 180 s
1805
+ // with two files of three on disk, and the room said only that Claude did not answer.
1806
+ let partial = null;
1807
+ const writer = controlRun ?? createRun;
1808
+ if (writer) {
1809
+ const changes = await this.#controlDesk.settleOrSay(writer);
1810
+ if (changes.files.length || changes.unreadable || changes.existingReverted.length) {
1811
+ const created = await this.#artifactsFrom(changes.files);
1812
+ partial = { checkpointId: changes.checkpointId, mode: changes.mode, files: changes.files, created, existingReverted: changes.existingReverted, unreadable: changes.unreadable ?? undefined };
1813
+ }
1814
+ }
1815
+ const cutByClock = ['TIMEOUT', 'IDLE'].includes(error?.code);
1816
+ await this.#emit('message.failed', {
1817
+ messageId, target: agent.id, planId,
1818
+ error: this.#privacy ? this.#privacy.redact(plain).text : plain,
1819
+ ...(this.#turns.get(messageId)?.stoppedByHuman ? { stopped: true } : {}),
1820
+ ...(cutByClock ? { timeout: { ms: this.timeoutFor(agent.id, { writing: Boolean(lease) }), writing: Boolean(lease), idle: error.code === 'IDLE' } } : {}),
1821
+ ...(partial ? { partial } : {}),
1822
+ });
1537
1823
  return null;
1538
1824
  } finally {
1539
1825
  // Release CONTROL whichever way the turn ended; the checkpoint stays for UNDO.