@jossuealcala/madre 1.0.0 → 1.0.2

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 (174) hide show
  1. package/ADDITIONAL-TERMS.md +93 -0
  2. package/CHANGELOG.en.md +1945 -0
  3. package/CHANGELOG.md +78 -1
  4. package/CONTRIBUTING.md +5 -1
  5. package/LICENSE +661 -202
  6. package/NOTICE +34 -3
  7. package/README.en.md +245 -0
  8. package/README.md +23 -13
  9. package/TRADEMARKS.en.md +50 -0
  10. package/TRADEMARKS.md +82 -0
  11. package/bin/madre.mjs +2 -0
  12. package/docs/INTERNALS.md +8 -2
  13. package/docs/REFERENCE.md +17 -7
  14. package/docs/en/INTERNALS.md +103 -0
  15. package/docs/en/REFERENCE.md +339 -0
  16. package/docs/en/SDK.md +306 -0
  17. package/docs/en/training/README.md +59 -0
  18. package/docs/madre-banner.png +0 -0
  19. package/docs/training/README.md +20 -8
  20. package/docs/training/train.sh +2 -1
  21. package/package.json +14 -5
  22. package/public/abduction.js +2 -0
  23. package/public/app.js +2066 -488
  24. package/public/attribution.js +71 -0
  25. package/public/brands.js +3 -1
  26. package/public/editor.js +2 -0
  27. package/public/es.js +1706 -1226
  28. package/public/i18n.js +25 -1
  29. package/public/index.html +13 -10
  30. package/public/inquiry.js +2 -0
  31. package/public/loader.js +2 -0
  32. package/public/macroverso.js +24 -2
  33. package/public/nostromo-engine.js +2 -0
  34. package/public/resay.js +6 -0
  35. package/public/review-ask.js +2 -0
  36. package/public/routing.js +23 -1
  37. package/public/salas-mac.css +539 -28
  38. package/public/salas-mac.html +30 -22
  39. package/public/salas-mac.js +1498 -144
  40. package/public/salas.css +2 -0
  41. package/public/salas.html +1 -0
  42. package/public/salas.js +4 -2
  43. package/public/styles.css +1123 -20
  44. package/public/syntax.js +2 -0
  45. package/public/touch.js +2 -0
  46. package/public/tour-chapters.js +164 -52
  47. package/public/tour.js +85 -12
  48. package/public/troubleshooting.js +20 -18
  49. package/src/adapters/claude.mjs +2 -0
  50. package/src/adapters/codex.mjs +2 -0
  51. package/src/adapters/gemini.mjs +3 -1
  52. package/src/adapters/madre.mjs +3 -1
  53. package/src/adapters/opencode.mjs +7 -3
  54. package/src/adapters/process.mjs +2 -0
  55. package/src/asking.mjs +2 -0
  56. package/src/auth-probe.mjs +32 -7
  57. package/src/bridge/devices.mjs +2 -0
  58. package/src/bridge/handoff.mjs +2 -0
  59. package/src/bridge/host.mjs +2 -0
  60. package/src/bridge/network.mjs +2 -0
  61. package/src/bridge/policy.mjs +3 -1
  62. package/src/bridge/qr.mjs +2 -0
  63. package/src/bridge/server.mjs +2 -0
  64. package/src/capabilities.mjs +2 -0
  65. package/src/chats.mjs +2 -0
  66. package/src/checkpoint.mjs +2 -0
  67. package/src/choices.mjs +2 -0
  68. package/src/cli-args.mjs +2 -0
  69. package/src/cold.mjs +2 -0
  70. package/src/commands.mjs +2 -0
  71. package/src/config.mjs +2 -0
  72. package/src/conversation-context.mjs +15 -1
  73. package/src/credentials.mjs +112 -3
  74. package/src/dataset.mjs +2 -0
  75. package/src/directives.mjs +8 -6
  76. package/src/distiller.mjs +2 -0
  77. package/src/embeddings.mjs +2 -0
  78. package/src/enlaces.mjs +6 -4
  79. package/src/event-store.mjs +2 -0
  80. package/src/exam.mjs +6 -4
  81. package/src/extensions.mjs +33 -10
  82. package/src/eyecat-watch.mjs +2 -0
  83. package/src/eyecat.mjs +2 -0
  84. package/src/file-ops.mjs +2 -0
  85. package/src/files.mjs +32 -0
  86. package/src/github.mjs +2 -0
  87. package/src/heartbeat.mjs +57 -0
  88. package/src/i18n.mjs +15 -1
  89. package/src/image-studio.mjs +2 -0
  90. package/src/json-file.mjs +2 -0
  91. package/src/launch.mjs +2 -0
  92. package/src/lease.mjs +5 -1
  93. package/src/lobby-work.mjs +2 -0
  94. package/src/local-ai.mjs +50 -0
  95. package/src/looks.mjs +40 -5
  96. package/src/maturity.mjs +3 -1
  97. package/src/mcp/connect.mjs +345 -0
  98. package/src/mcp/github-server.mjs +2 -0
  99. package/src/mcp/image-server.mjs +2 -0
  100. package/src/mcp/memory-server.mjs +2 -0
  101. package/src/mcp/proxy.mjs +153 -0
  102. package/src/mcp/smtp-server.mjs +2 -0
  103. package/src/memory-dashboard.mjs +2 -0
  104. package/src/memory-tools.mjs +2 -0
  105. package/src/memory.mjs +2 -0
  106. package/src/models.mjs +83 -3
  107. package/src/modules/ahp.mjs +2 -0
  108. package/src/modules/ash-policy.mjs +2 -0
  109. package/src/modules/ash.mjs +3 -1
  110. package/src/modules/bishop-plan.mjs +5 -3
  111. package/src/modules/bishop.mjs +13 -11
  112. package/src/modules/choices.mjs +2 -0
  113. package/src/modules/correo.mjs +2 -0
  114. package/src/modules/enlaces.mjs +2 -0
  115. package/src/modules/git-pulse.mjs +2 -0
  116. package/src/modules/github.mjs +2 -0
  117. package/src/modules/helpers.mjs +2 -0
  118. package/src/modules/image-studio.mjs +3 -1
  119. package/src/modules/index.mjs +4 -1
  120. package/src/modules/intents-calibration.mjs +2 -0
  121. package/src/modules/intents-desk.mjs +4 -2
  122. package/src/modules/intents-observe.mjs +2 -0
  123. package/src/modules/intents-policy.mjs +4 -1
  124. package/src/modules/intents-try.mjs +2 -0
  125. package/src/modules/intents.mjs +8 -6
  126. package/src/modules/mcp-connect.mjs +293 -0
  127. package/src/modules/ollama.mjs +17 -3
  128. package/src/modules/playwright.mjs +25 -10
  129. package/src/modules/ripley.mjs +2 -0
  130. package/src/modules/runs.mjs +5 -3
  131. package/src/modules/sdk.mjs +2 -0
  132. package/src/modules/updates.mjs +2 -0
  133. package/src/mother.mjs +2 -0
  134. package/src/ollama.mjs +2 -0
  135. package/src/outbound.mjs +24 -5
  136. package/src/override.mjs +2 -0
  137. package/src/ports.mjs +2 -0
  138. package/src/prices.mjs +2 -0
  139. package/src/privacy.mjs +2 -0
  140. package/src/quota-monitor.mjs +2 -0
  141. package/src/quota-sources.mjs +2 -0
  142. package/src/request-guard.mjs +2 -0
  143. package/src/review-ask.mjs +2 -0
  144. package/src/room/archivist.mjs +2 -0
  145. package/src/room/attachments.mjs +2 -0
  146. package/src/room/budget.mjs +2 -0
  147. package/src/room/context.mjs +2 -0
  148. package/src/room/control.mjs +7 -1
  149. package/src/room/copilot.mjs +76 -0
  150. package/src/room/economy.mjs +2 -0
  151. package/src/room/escalation.mjs +3 -0
  152. package/src/room/ghost.mjs +2 -0
  153. package/src/room/guard.mjs +2 -0
  154. package/src/room/prompt.mjs +13 -4
  155. package/src/room/returns.mjs +82 -0
  156. package/src/room/runs.mjs +4 -0
  157. package/src/room/vectors.mjs +2 -0
  158. package/src/room.mjs +264 -45
  159. package/src/router.mjs +2 -0
  160. package/src/runs.mjs +2 -0
  161. package/src/runtime-detection.mjs +2 -0
  162. package/src/salas.mjs +2 -0
  163. package/src/sentinel-errors.mjs +2 -0
  164. package/src/server-sentinel.mjs +2 -0
  165. package/src/server.mjs +485 -55
  166. package/src/setup.mjs +2 -0
  167. package/src/smtp.mjs +2 -0
  168. package/src/tour.mjs +26 -9
  169. package/src/updates.mjs +2 -0
  170. package/src/usage-sentinel.mjs +2 -0
  171. package/src/vault.mjs +2 -0
  172. package/src/verdict.mjs +3 -1
  173. package/src/whats-new.mjs +12 -10
  174. package/docs/madre-banner.svg +0 -25
package/src/room.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  import { invokeCodex, buildCodexArgs } from './adapters/codex.mjs';
2
4
  import { t } from './i18n.mjs';
3
5
  import { invokeClaude, buildClaudeArgs } from './adapters/claude.mjs';
@@ -34,12 +36,14 @@ import { MODES, SCOPES, SCOPE_LABELS, capabilitySummary, normalizeMode, resolveS
34
36
  import { createLease, diffSnapshots, namedPaths, snapshot } from './lease.mjs';
35
37
  import { stat as statFile, readdir, copyFile, readFile as readFileBytes } from 'node:fs/promises';
36
38
  import { basename as baseName, join as joinPath } from 'node:path';
37
- import { declaredTypeFor } from './files.mjs';
39
+ import { declaredTypeFor, filesChangedSince } from './files.mjs';
38
40
  import { isModuleFile, sdkPaths } from './modules/index.mjs';
39
41
  import { validateAshReply } from './modules/ash-policy.mjs';
40
42
  import { PHONE_MAX_MODE } from './bridge/policy.mjs';
41
43
  import { parseChoice, withoutChoice } from './choices.mjs';
42
44
  import { parseRunRequest, withoutRunRequest, whyText, expandHome } from './runs.mjs';
45
+ import { runSettlement, returnedBlock } from './room/returns.mjs';
46
+ import { copilotSafe, copilotBrief, COPILOT_MAX_ROUNDS } from './room/copilot.mjs';
43
47
  import { imageStudioFor } from './image-studio.mjs';
44
48
  import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
45
49
  import { resolveReferences } from './files.mjs';
@@ -134,6 +138,18 @@ export class Room {
134
138
  #mother = null; // MotherChannel: her coded words to the crew
135
139
  #shuttingDown = false;
136
140
  #inflight = new Set(); // top-level dispatches, from send() and MOTHER, until they settle
141
+ // Commands an agent asked for, handed back to it once they ran (room/returns.mjs): the ones
142
+ // waiting for the agent to be free, the ones already handed back, and the ones being handed now.
143
+ #returns = new Map(); // responseMessageId -> { partial }
144
+ #returned = new Set();
145
+ #dropped = new Set(); // stopped by STOP ALL: they go back only when the human hands them back
146
+ #returning = new Map(); // responseMessageId -> the look in progress, so two never cross
147
+ #returnCap = new Map(); // responseMessageId -> the highest mode its results may go back at
148
+ // The copilot (src/room/copilot.mjs): the server's window, the command requests its turns left
149
+ // to run once the turn has let go of the project, and the rounds run by themselves in a row.
150
+ #copilotNow = () => null;
151
+ #copilotRuns = [];
152
+ #copilotRounds = 0;
137
153
  #invokers;
138
154
  #agentTimeouts;
139
155
  #maxMessageChars;
@@ -175,6 +191,7 @@ export class Room {
175
191
  toolsForTurn = null, // async ({ agent, mode, lease, scratchDir }) => MCP server specs from the modules
176
192
  bishopForTurn = null, // async ({ agent, mode, text, preview, turn }) => bounded BISHOP context in Salas
177
193
  linkedForTurn = null, // async ({ agent, mode, text, preview, turn }) => { text, dynamic } notes a link recalls (3e)
194
+ copilot = null, // () => { until } while the human's copilot window is open (src/room/copilot.mjs)
178
195
  historicalEvents = [],
179
196
  invokers = defaultInvokers,
180
197
  agentTimeouts = {},
@@ -215,6 +232,7 @@ export class Room {
215
232
  this.#maxConcurrentTurns = maxConcurrentTurns;
216
233
  this.#planMaxAgeMs = planMaxAgeMs;
217
234
  this.#escalation = new Escalation({ escalationMs });
235
+ this.#copilotNow = typeof copilot === 'function' ? copilot : () => null;
218
236
  this.#controlDesk = controlDesk ?? new ControlDesk({ projectRoot });
219
237
  this.#budget = new Budget({ softBudget: softTokenBudget });
220
238
  this.#budget.seed(historicalEvents, this.#sentinel);
@@ -276,14 +294,19 @@ export class Room {
276
294
  async #askForMode({ planId, plan, step, index, totalSteps, orchestrator, parentMessageId, mode = 2 }) {
277
295
  const requestId = randomUUID();
278
296
  const expiresAt = new Date(Date.now() + this.#escalation.ms).toISOString();
279
- const payload = { requestId, planId, agent: step.agent, orchestrator, mode, step: index + 1, totalSteps, text: step.text, expiresAt, message: `@${step.agent} needs #${mode} ${MODES[mode].label} for step ${index + 1}: the plan runs at #1. Grant it once, for the whole plan, or deny.` };
297
+ const payload = { requestId, planId, agent: step.agent, orchestrator, mode, step: index + 1, totalSteps, text: step.text, expiresAt, message: `@${step.agent} needs #${mode} ${MODES[mode].label} for step ${index + 1}. Grant it once, for the whole plan, or deny.` };
298
+ const pending = this.#escalation.wait(requestId, payload, plan);
280
299
  await this.#emit('mode.requested', payload);
281
- const decision = await this.#escalation.wait(requestId, payload, plan);
300
+ const decision = await pending;
282
301
  if (decision.decision === 'deny' || plan.stopped) {
283
302
  const reason = plan.stopped ? 'stopped' : decision.reason ?? 'denied';
284
- await this.#emit('mode.denied', { requestId, planId, agent: step.agent, mode, step: index + 1, reason, message: reason === 'timeout' ? `No answer in ${Math.round(this.#escalation.ms / 1000)}s: @${step.agent} runs step ${index + 1} at #1.` : reason === 'stopped' ? `The plan was stopped while @${step.agent} waited for #${mode}.` : `Denied: @${step.agent} runs step ${index + 1} at #1 and will say what it could not create.` });
303
+ await this.#emit('mode.denied', { requestId, planId, agent: step.agent, mode, step: index + 1, reason, message: reason === 'timeout' ? `No answer in ${Math.round(this.#escalation.ms / 1000)}s: @${step.agent} runs step ${index + 1} at #1.` : reason === 'stopped' ? `The plan was stopped while @${step.agent} waited for #${mode}.` : `Denied: @${step.agent} runs step ${index + 1} at #1 and will say what it could not do.` });
285
304
  return { scope: null, reason };
286
305
  }
306
+ if (mode >= 3) {
307
+ await this.#emit('mode.granted', { requestId, planId, agent: step.agent, mode, step: index + 1, scope: decision.decision, message: `#${mode} granted to @${step.agent}${decision.decision === 'plan' ? ' for the rest of the plan' : ' for this step'}.` });
308
+ return { scope: decision.decision, mode };
309
+ }
287
310
  const scopes = this.scopesFor(step.agent);
288
311
  const enabled = Object.fromEntries(SCOPES.map((scope) => [scope, scopes[scope].enabled && scopes[scope].wired]));
289
312
  const lease = await this.#projectLease({ leaseId: randomUUID(), messageId: parentMessageId });
@@ -310,6 +333,7 @@ export class Room {
310
333
  // The human's answer to a pending request: 'once', 'plan' or 'deny'.
311
334
  decideMode(requestId, decision) { return this.#escalation.decide(requestId, decision); }
312
335
  modeAsked(requestId) { return this.#escalation.modeOf(requestId); }
336
+ modeRequest(requestId) { return this.#escalation.requestOf(requestId); }
313
337
  // The modes plans are waiting for the human to grant, as asked.
314
338
  pendingModes() { return this.#escalation.pending(); }
315
339
 
@@ -718,7 +742,7 @@ export class Room {
718
742
  settleDistillation() { return this.#archivist.settle(); }
719
743
 
720
744
  // Can this message go out at this mode? Answered before the turn exists.
721
- async modeCheck({ target, text, mode, create = false } = {}) {
745
+ async modeCheck({ target, text, mode, create = false, copilot = false } = {}) {
722
746
  const parsed = parseMessage(text ?? '', target);
723
747
  const wanted = normalizeMode(mode, create === true ? 2 : 1);
724
748
  if (!parsed.target) return { ok: true, mode: wanted };
@@ -726,8 +750,10 @@ export class Room {
726
750
  const scopes = this.scopesFor(agent);
727
751
  if (wanted >= 3) {
728
752
  // #3 CONTROL and #4 AIRLOCK: the ceiling must allow it, and there is one holder at a time.
729
- // A project without git still checkpoints, in MADRE's shadow repository.
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}.` };
753
+ // A project without git still checkpoints, in MADRE's shadow repository. The copilot's window
754
+ // is the human's own #3 for a while: it reaches any agent whose CLI can write.
755
+ const byCopilot = copilot === true && wanted === 3 && this.copilotOn() && Boolean(scopes.write?.capable);
756
+ if (scopes.maxMode < wanted && !byCopilot) 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 unlock ${MODES[wanted].label}.` };
731
757
  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.` };
732
758
  return { ok: true, mode: wanted, maxMode: scopes.maxMode, agent };
733
759
  }
@@ -867,22 +893,26 @@ export class Room {
867
893
  // The human pressed one line of a ```pulse-run an agent wrote. The client names the request and
868
894
  // the line, never the command: what runs is read back from the room's own record of what the
869
895
  // agent asked, so a page cannot run anything that was not shown to the human as a button.
870
- async runRequested({ responseMessageId, index }) {
871
- if (!this.#runsEnabled) return { ok: false, status: 412, error: 'Command requests are off. Enable them in MODULES.' };
896
+ async runRequested({ responseMessageId, index, returnCap = 3, auto = false }) {
897
+ if (!this.#runsEnabled) return { ok: false, status: 412, error: t('Command requests are off. Enable them in MODULES.') };
872
898
  const events = await this.#store.readAll();
873
899
  const request = events.find((event) => event.type === 'run.requested' && event.payload.responseMessageId === responseMessageId)?.payload;
874
900
  const command = request?.commands?.[index];
875
- if (!command) return { ok: false, status: 404, error: 'That command request is not in this room.' };
901
+ if (!command) return { ok: false, status: 404, error: t('That command request is not in this project.') };
876
902
  if (!command.argv) return { ok: false, status: 422, error: `That line does not run: it ${whyText(command)}.` };
877
- if (this.#runBusy || this.#runDesk.busy) return { ok: false, status: 409, error: 'A command is already running; wait for it or STOPALL.' };
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.` };
903
+ if (this.#runBusy || this.#runDesk.busy) return { ok: false, status: 409, error: t('A command is already running; wait for it to finish or press STOP ALL.') };
904
+ if (this.#controlDesk.holder) return { ok: false, status: 409, error: t('@{agent} is working in {mode} on this project; run it when its turn ends.', { agent: this.#controlDesk.holder.agent, mode: MODES[this.#controlDesk.holder.mode ?? 3].label }) };
905
+ // A line pressed from a phone hands its results back at #2 at most, the phone's own ceiling.
906
+ this.#returnCap.set(responseMessageId, Math.min(this.#returnCap.get(responseMessageId) ?? 3, Number(returnCap) || 1));
879
907
  // Taken before the first await below: two presses in a row must not both get through.
880
908
  this.#runBusy = true;
881
- try { return await this.#runCommand({ request, command, responseMessageId, index }); } finally { this.#runBusy = false; }
909
+ try { return await this.#runCommand({ request, command, responseMessageId, index, auto }); } finally { this.#runBusy = false; }
882
910
  }
883
911
 
884
- async #runCommand({ request, command, responseMessageId, index }) {
885
- const run = { responseMessageId, index, agent: request.agent, line: command.line };
912
+ async #runCommand({ request, command, responseMessageId, index, auto = false }) {
913
+ // `auto`: the copilot ran it, nobody pressed it; the card says so.
914
+ const run = { responseMessageId, index, agent: request.agent, line: command.line, ...(auto ? { auto: 'copilot' } : {}) };
915
+ const startedAt = Date.now();
886
916
  await this.#emit('run.started', run);
887
917
  let checkpoint = null;
888
918
  try {
@@ -895,6 +925,9 @@ export class Room {
895
925
  const finished = this.#runDesk.run({ id: `${responseMessageId}:${index}`, argv: expandHome(command), cwd: this.#projectRoot, onServing: (where) => opened(where) })
896
926
  .then(async (result) => {
897
927
  const changes = checkpoint ? await this.#controlDesk.changesSince(checkpoint).catch(() => null) : null;
928
+ // What git cannot see — a folder the project ignores — found by its time on disk.
929
+ const listed = new Set((changes?.files ?? []).map((file) => file.path));
930
+ const touched = (await filesChangedSince(this.#projectRoot, startedAt).catch(() => [])).filter((path) => !listed.has(path));
898
931
  const status = result.stoppedBy ? `stopped (${result.stoppedBy})` : result.exitCode === null ? 'did not start' : `exit ${result.exitCode}`;
899
932
  const event = await this.#emit('command.output', {
900
933
  name: 'run',
@@ -902,13 +935,16 @@ export class Room {
902
935
  text: `${result.text || '(no output)'}${changes?.files.length ? `\n\nFiles changed: ${changes.files.map((file) => `${file.status} ${file.path}`).join(', ')}` : ''}`,
903
936
  ok: result.ok,
904
937
  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 },
938
+ 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, touched: touched.length ? touched : undefined },
906
939
  });
940
+ // The last line of the request answered: the agent that asked hears how it went.
941
+ void this.#maybeReturnRun(responseMessageId).catch((error) => console.error(`MADRE could not hand the commands back: ${error.message}`));
907
942
  return { ...result, sequence: event.sequence };
908
943
  });
909
944
  const first = await Promise.race([finished.then((ended) => ({ ended })), serving.then((where) => ({ where }))]);
910
945
  if (first.where) {
911
946
  await this.#emit('run.serving', { ...run, ...first.where });
947
+ void this.#maybeReturnRun(responseMessageId).catch((error) => console.error(`MADRE could not hand the commands back: ${error.message}`));
912
948
  finished.catch(() => null);
913
949
  return { ok: true, status: 200, result: { serving: first.where } };
914
950
  }
@@ -920,6 +956,120 @@ export class Room {
920
956
  return this.#runDesk.stop('STOP', `${responseMessageId}:${Number(index)}`);
921
957
  }
922
958
 
959
+ // Whether the copilot's window is open now: what the server says, nothing kept here.
960
+ copilotOn() { return Boolean(this.#copilotNow()); }
961
+
962
+ // The human ran some of the lines and not the rest, and hands back what there is.
963
+ returnRun({ responseMessageId, returnCap = 3 } = {}) {
964
+ const id = String(responseMessageId ?? '');
965
+ this.#returnCap.set(id, Math.min(this.#returnCap.get(id) ?? 3, Number(returnCap) || 1));
966
+ return this.#maybeReturnRun(id, { partial: true });
967
+ }
968
+
969
+ // The commands an agent asked for go back to it, once: when every line that can run has an
970
+ // answer, or when the human hands back what ran. If that agent is still answering, or a plan is
971
+ // running, it waits for the room to be free (#flushReturns). The turn it starts is the human's —
972
+ // they pressed the lines — at the mode the asking turn had, never above #3: #4 runs its own
973
+ // commands and never asks.
974
+ async #maybeReturnRun(responseMessageId, options = {}) {
975
+ // One look at a time per request: a line that ends while the human presses «hand back» waits
976
+ // for the other look instead of being told it was already done.
977
+ const ahead = this.#returning.get(responseMessageId);
978
+ const look = (ahead ? ahead.catch(() => null) : Promise.resolve()).then(() => this.#returnRunNow(responseMessageId, options));
979
+ this.#returning.set(responseMessageId, look);
980
+ try { return await look; } finally { if (this.#returning.get(responseMessageId) === look) this.#returning.delete(responseMessageId); }
981
+ }
982
+
983
+ async #returnRunNow(responseMessageId, { partial = false } = {}) {
984
+ if (!responseMessageId || this.#shuttingDown) return { ok: false, status: 409, error: t('MADRE is closing.') };
985
+ if (this.#returned.has(responseMessageId)) return { ok: false, status: 409, error: t('Those results were already handed back.') };
986
+ if (partial) this.#dropped.delete(responseMessageId);
987
+ else if (this.#dropped.has(responseMessageId)) return { ok: false, status: 409, error: t('STOP ALL held these results; hand them back from their card.') };
988
+ {
989
+ const events = await this.#store.readAll();
990
+ if (events.some((event) => event.type === 'message.created' && event.payload?.returns === responseMessageId)) {
991
+ this.#returned.add(responseMessageId);
992
+ return { ok: false, status: 409, error: t('Those results were already handed back.') };
993
+ }
994
+ const settlement = runSettlement(events, responseMessageId);
995
+ if (!settlement.request) return { ok: false, status: 404, error: t('That command request is not in this project.') };
996
+ if (!settlement.ran) return { ok: false, status: 409, error: t('Nothing of that request has run yet.') };
997
+ if (!settlement.done && !partial) return { ok: true, status: 202, waiting: true };
998
+ if (this.#runDesk.runningFor(`${responseMessageId}:`)) return { ok: false, status: 409, error: t('A command of that request is still running; wait for it to end.') };
999
+ const agentId = settlement.request.agent;
1000
+ // STOP ALL may have come while the ledger was read.
1001
+ if (!partial && this.#dropped.has(responseMessageId)) return { ok: false, status: 409, error: t('STOP ALL held these results; hand them back from their card.') };
1002
+ if (this.#plans.size > 0 || [...this.#turns.values()].some((turn) => turn.agent === agentId)) {
1003
+ this.#returns.set(responseMessageId, { partial: partial || Boolean(this.#returns.get(responseMessageId)?.partial) });
1004
+ return { ok: true, status: 202, queued: true };
1005
+ }
1006
+ this.#returns.delete(responseMessageId);
1007
+ this.#returned.add(responseMessageId);
1008
+ const asked = events.find((event) => event.type === 'message.created' && event.payload?.messageId === settlement.request.messageId)?.payload ?? null;
1009
+ const reply = events.find((event) => event.type === 'message.created' && event.payload?.messageId === responseMessageId);
1010
+ const wanted = Math.min(this.#returnCap.get(responseMessageId) ?? 3, Math.max(1, normalizeMode(settlement.request.mode ?? asked?.mode, 1)));
1011
+ // The mode the asking turn had, if the project still allows it and the agent can take it; a
1012
+ // step down otherwise. A CLI that cannot write goes back at #1, not to a refused #2.
1013
+ // A copilot's request goes back under the copilot while the window is open: #3 for any agent
1014
+ // whose CLI writes.
1015
+ const underCopilot = settlement.request.copilot === true && this.copilotOn() && wanted === 3 && Boolean(this.scopesFor(agentId).write?.capable);
1016
+ let mode = underCopilot ? 3 : Math.min(wanted, Math.max(1, this.scopesFor(agentId).maxMode ?? 1));
1017
+ while (mode > 1 && !(await this.modeCheck({ target: agentId, text: '', mode, copilot: underCopilot && mode === 3 })).ok) mode -= 1;
1018
+ const sameAgent = asked?.target === agentId;
1019
+ const quoted = String(settlement.request.reason ?? '').replace(/\s+/g, ' ').trim();
1020
+ const n = settlement.ran;
1021
+ const total = settlement.runnable.length;
1022
+ const said = settlement.done
1023
+ ? n === 1 ? t('The command you asked for ran. Go on with what you proposed.') : t('The {n} commands you asked for ran. Go on with what you proposed.', { n })
1024
+ : t('{n} of the {total} commands you asked for ran. Go on with what you proposed.', { n, total });
1025
+ const text = `↩ @${agentId}${reply?.sequence ? ` #${reply.sequence}` : ''}: “${quoted.length > 120 ? `${quoted.slice(0, 119)}…` : quoted}”\n${said}`;
1026
+ const returned = returnedBlock({ settlement, asking: asked?.text ?? null, said: reply?.payload?.text ?? null, replySequence: reply?.sequence ?? null });
1027
+ await this.#accept({
1028
+ text, target: agentId, mode,
1029
+ model: sameAgent ? asked?.model ?? null : null,
1030
+ effort: sameAgent ? asked?.effort ?? null : null,
1031
+ ash: Boolean(asked?.ash?.requested),
1032
+ followUp: { kind: 'runs', returns: responseMessageId, block: returned },
1033
+ copilot: underCopilot && mode === 3,
1034
+ }, null);
1035
+ return { ok: true, status: 200, returned: true, mode };
1036
+ }
1037
+ }
1038
+
1039
+ // The safe lines a copilot turn asked for, run one after another once that turn let go of the
1040
+ // project; the rest of a request waits for the human, as always. Bounded: after a run of rounds
1041
+ // nobody typed between, the room stops and says so instead of looping on a bill.
1042
+ async #flushCopilotRuns() {
1043
+ while (this.#copilotRuns.length && !this.#shuttingDown) {
1044
+ const responseMessageId = this.#copilotRuns.shift();
1045
+ if (!this.copilotOn() || this.#controlDesk.holder) return;
1046
+ if (this.#copilotRounds >= COPILOT_MAX_ROUNDS) {
1047
+ await this.#alert('copilot-rounds', `The copilot ran commands by itself ${COPILOT_MAX_ROUNDS} times in a row; the next ones wait for you. Write to it to go on.`, `copilot-rounds:${responseMessageId}`);
1048
+ return;
1049
+ }
1050
+ const request = (await this.#store.readAll()).find((event) => event.type === 'run.requested' && event.payload?.responseMessageId === responseMessageId)?.payload;
1051
+ const lines = (request?.commands ?? []).map((command, index) => ({ command, index })).filter(({ command }) => command?.argv && copilotSafe(command.argv));
1052
+ if (!lines.length) continue;
1053
+ this.#copilotRounds += 1;
1054
+ for (const { index } of lines) {
1055
+ if (!this.copilotOn()) return;
1056
+ const ran = await this.runRequested({ responseMessageId, index, returnCap: 3, auto: true });
1057
+ // A line that failed to start or ended badly is still an answer: the next one runs, and
1058
+ // the agent reads both when the request goes back.
1059
+ if (!ran.ok && ran.status === 409) return;
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ // What waited for the room to be free goes now, oldest first, one at a time.
1065
+ #flushReturns() {
1066
+ if (!this.#returns.size || this.#shuttingDown || this.#plans.size > 0) return;
1067
+ for (const [responseMessageId, { partial }] of this.#returns) {
1068
+ void this.#track(this.#maybeReturnRun(responseMessageId, { partial })).catch((error) => console.error(`MADRE could not hand the commands back: ${error.message}`));
1069
+ return;
1070
+ }
1071
+ }
1072
+
923
1073
  ashEnabled() {
924
1074
  return this.#ashEnabled;
925
1075
  }
@@ -1021,7 +1171,7 @@ export class Room {
1021
1171
  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
1172
  }
1023
1173
 
1024
- async #accept({ text, target, model = null, effort = null, attachments = [], create = false, ash = false, mode = undefined, draft = false, via = null }, order) {
1174
+ async #accept({ text, target, model = null, effort = null, attachments = [], create = false, ash = false, mode = undefined, draft = false, via = null, followUp = null, copilot = false }, order) {
1025
1175
  const parsed = parseMessage(text, target);
1026
1176
  const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
1027
1177
  // This is the decision the human made when pressing Send. Capture it before modeCheck() —
@@ -1031,8 +1181,13 @@ export class Room {
1031
1181
  // A message that came from a phone never goes above #2, whatever reached this far (TEL-01): the
1032
1182
  // bridge and the route say so first; the room says it last, for any road added later.
1033
1183
  if (via && requestedMode > PHONE_MAX_MODE) throw new Error('#3 EDIT and #4 FULL ACCESS are opened from the Mac.');
1034
- const gate = await this.modeCheck({ target, text, mode: requestedMode });
1184
+ // A copilot message: #3, from this computer, while the window is open. Anything else is a message
1185
+ // like any other, at the mode it was sent with.
1186
+ const asCopilot = copilot === true && requestedMode === 3 && !via && !order && this.copilotOn();
1187
+ const gate = await this.modeCheck({ target, text, mode: requestedMode, copilot: asCopilot });
1035
1188
  if (!gate.ok) throw new Error(gate.error);
1189
+ // Rounds a copilot ran by itself count from the last thing the human wrote.
1190
+ if (!followUp) this.#copilotRounds = 0;
1036
1191
  create = requestedMode === 2;
1037
1192
  const ghost = requestedMode === 0;
1038
1193
  const control = requestedMode >= 3;
@@ -1085,6 +1240,10 @@ export class Room {
1085
1240
  attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
1086
1241
  references: references.length ? references.map(({ excerpt, ...reference }) => reference) : undefined,
1087
1242
  create: create === true ? true : undefined,
1243
+ // Written by MADRE on the human's behalf: the results of commands an agent asked for.
1244
+ auto: followUp?.kind ?? undefined,
1245
+ returns: followUp?.returns ?? undefined,
1246
+ copilot: asCopilot || undefined,
1088
1247
  });
1089
1248
  // The human may say what they like; the room only says so, because every agent will read it.
1090
1249
  const exposed = this.#privacy?.hits(text2) ?? 0;
@@ -1152,7 +1311,7 @@ export class Room {
1152
1311
  allowDelegation = false;
1153
1312
  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}`);
1154
1313
  }
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) }));
1314
+ 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), returned: followUp?.block ?? null, copilot: asCopilot }));
1156
1315
  return { messageId, responseMessageId };
1157
1316
  }
1158
1317
 
@@ -1247,6 +1406,9 @@ export class Room {
1247
1406
  const turns = this.#turns.size;
1248
1407
  for (const plan of this.#plans.values()) plan.stopped = reason;
1249
1408
  this.#escalation.settleAll(null, 'stopped');
1409
+ // Results waiting to go back stay with the human: STOP ALL means nothing starts by itself.
1410
+ for (const id of [...this.#returns.keys(), ...this.#returning.keys()]) this.#dropped.add(id);
1411
+ this.#returns.clear();
1250
1412
  for (const turn of this.#turns.values()) turn.controller.abort(reason);
1251
1413
  this.#runDesk.stop('STOPALL');
1252
1414
  await this.#emit('room.stopped', { reason, plans, turns, agents: [...new Set([...this.#turns.values()].map((turn) => turn.agent))] });
@@ -1298,14 +1460,14 @@ export class Room {
1298
1460
 
1299
1461
  // One room turn for one agent. `requester` is who asked ('you' or an
1300
1462
  // orchestrating agent); `depth` 0 turns may delegate, deeper ones may not.
1301
- async #dispatch({ messageId, targetId, text, requester, depth, planId = null, allowDelegation = true, model = null, effort = null, attachments = [], references = [], lease = null, ash = false, mode = 1, escalation = null }) {
1463
+ async #dispatch({ messageId, targetId, text, requester, depth, planId = null, allowDelegation = true, deferPlan = false, model = null, effort = null, attachments = [], references = [], lease = null, ash = false, mode = 1, escalation = null, returned = null, copilot = false }) {
1302
1464
  if (this.#shuttingDown) return null;
1303
1465
  const agent = this.#agents.find((item) => item.id === targetId);
1304
1466
  if (!agent?.detected) {
1305
1467
  // @madre is not a CLI to install: it is the room's local agent, here while Ollama runs with a
1306
1468
  // chat model. Saying "not installed" sent people to install something that does not exist.
1307
1469
  const error = agent?.local || targetId === 'madre'
1308
- ? `@${targetId} left the room: Ollama is not running. Start Ollama, or open Ollama in Modules.`
1470
+ ? `@${targetId} left the project: Ollama is not running. Start Ollama, or open Local AI in Modules.`
1309
1471
  : `${targetId} is not installed on this computer.`;
1310
1472
  await this.#emit('message.failed', { messageId, target: targetId, planId, error });
1311
1473
  return null;
@@ -1362,7 +1524,7 @@ export class Room {
1362
1524
  await this.#emit('agent.started', { messageId, agent: agent.id, handoffId, planId, timeoutMs: this.timeoutFor(agent.id, { writing: Number(mode) >= 2 || Boolean(lease) }) });
1363
1525
  const controller = new AbortController();
1364
1526
  const turn = { controller, promise: null, planId, agent: agent.id, startedAt: Date.now() };
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 });
1527
+ 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, returned, copilot });
1366
1528
  this.#turns.set(messageId, turn);
1367
1529
  let outcome = null;
1368
1530
  try {
@@ -1370,11 +1532,25 @@ export class Room {
1370
1532
  } finally {
1371
1533
  this.#turns.delete(messageId);
1372
1534
  if (!this.#turns.size && mode !== 0) this.#archivist.schedule();
1535
+ // What a copilot turn asked to run, now that it has let go of the project.
1536
+ void this.#track(this.#flushCopilotRuns()).catch((error) => console.error(`MADRE copilot could not run: ${error.message}`));
1537
+ this.#flushReturns();
1373
1538
  }
1374
1539
  // The orchestrator's turn is over before its plan starts, so it is never
1375
1540
  // counted as in flight while the others work.
1376
- if (outcome?.directives?.steps.length) {
1377
- await this.#runPlan({ orchestrator: agent.id, parentMessageId: outcome.responseMessageId, directives: outcome.directives, lease, ash, mode });
1541
+ if (outcome?.directives?.steps.length && !deferPlan) {
1542
+ let next = outcome.directives;
1543
+ let parent = outcome.responseMessageId;
1544
+ const grants = new Map();
1545
+ // The orchestrator may form another wave after seeing the crew's replies. Three waves
1546
+ // bound cost and time; a grant for the plan carries forward, only for its authorized scope.
1547
+ for (let round = 0; next?.steps.length && round < 3; round += 1) {
1548
+ const continued = await this.#runPlan({ orchestrator: agent.id, parentMessageId: parent, directives: next, lease, ash, mode, grants, copilot: copilot && this.copilotOn() });
1549
+ next = continued?.directives ?? null;
1550
+ parent = continued?.responseMessageId ?? parent;
1551
+ lease = continued?.lease ?? lease;
1552
+ }
1553
+ if (next?.steps.length) await this.#emit('plan.ignored', { orchestrator: agent.id, responseMessageId: parent, reasons: ['The plan reached its three-round limit. Continue with a new message if needed.'] });
1378
1554
  }
1379
1555
  return outcome?.responseMessageId ?? null;
1380
1556
  }
@@ -1520,7 +1696,7 @@ export class Room {
1520
1696
  });
1521
1697
  }
1522
1698
 
1523
- async #runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall = null, memories = null, handoffId, signal, model = null, effort = null, attachments = [], references = [], lease: sharedLease = null, ash = false, mode = 1, escalation = null }) {
1699
+ async #runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall = null, memories = null, handoffId, signal, model = null, effort = null, attachments = [], references = [], lease: sharedLease = null, ash = false, mode = 1, escalation = null, returned = null, copilot = false }) {
1524
1700
  // The lease is the human's; what each agent may do inside it is that
1525
1701
  // agent's own enabled scopes. A delegate without file creation runs
1526
1702
  // read-only even while the plan holds a lease.
@@ -1533,17 +1709,21 @@ export class Room {
1533
1709
  let controlRun = null;
1534
1710
  let createRun = null;
1535
1711
  let responseMessageId = null;
1536
- if (mode >= 3 && (requester === 'you' || planId) && depth <= 1 && agentScopes.maxMode >= mode && enabled.write) {
1712
+ // The copilot's #3 reaches past an agent's max mode, never past what its CLI can write.
1713
+ const asCopilot = copilot === true && mode === 3 && this.copilotOn() && Boolean(agentScopes.write?.capable);
1714
+ if (mode >= 3 && (requester === 'you' || planId) && depth <= 1 && (agentScopes.maxMode >= mode || asCopilot) && (enabled.write || asCopilot)) {
1537
1715
  // CONTROL: the project itself is the writable root, and a checkpoint
1538
1716
  // taken now makes every change of this turn reversible. A plan step gets
1539
1717
  // it when the orchestrator, itself in #3, named #3 for that step.
1540
1718
  if (this.#controlDesk.holder) {
1541
- await this.#alert('control-busy', `@${this.#controlDesk.holder.agent} still holds CONTROL; @${agent.id} answers read-only this turn.`, `control-busy:${agent.id}`);
1719
+ await this.#alert('control-busy', `@${this.#controlDesk.holder.agent} still holds EDIT; @${agent.id} answers read-only this turn.`, `control-busy:${agent.id}`);
1542
1720
  } else {
1543
1721
  await this.#sayWaitingToWrite({ messageId, agent });
1544
1722
  const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode, signal }).catch((error) => (error?.code === 'ABORT_ERR' ? null : Promise.reject(error)));
1545
1723
  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; }
1546
1724
  controlRun = seat.run;
1725
+ // A copilot does not delete: what it removes is put back when the turn settles.
1726
+ if (asCopilot) controlRun.noDeletes = true;
1547
1727
  lease = seat.lease;
1548
1728
  await this.#emit('control.started', seat.announcement);
1549
1729
  }
@@ -1608,6 +1788,7 @@ export class Room {
1608
1788
  const invoke = this.#invokers[agent.adapter];
1609
1789
  if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
1610
1790
  const before = lease && !lease.control && !lease.create ? await snapshot(lease.outDir) : null;
1791
+ const writingSince = Date.now();
1611
1792
  responseMessageId = randomUUID();
1612
1793
  this.#liveTurns.set(responseMessageId, { agent: agent.id, mode: turnMode });
1613
1794
  const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
@@ -1625,7 +1806,7 @@ export class Room {
1625
1806
  : null;
1626
1807
  // The prompt and the shape it was built from: one is sent, the other is kept until the CLI
1627
1808
  // says what it cost, so the bill can be attributed to the blocks that caused it.
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 });
1809
+ 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, returned, copilot: asCopilot && controlRun ? copilotBrief(this.#copilotNow()) : null, 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
1810
  this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared, servers: [this.#memoryServer?.name, ...mcpServers.map((server) => server.name)].filter(Boolean) });
1630
1811
  // What this turn is about to read, said while it is still reading it. The size is exact;
1631
1812
  // only the conversion to tokens is an estimate, and it is made at this room's own rate.
@@ -1717,7 +1898,7 @@ export class Room {
1717
1898
  // Only a ```pulse fence is a plan, read the way src/directives.mjs reads it; ```pulse-run,
1718
1899
  // ```pulse-ask and ```pulse-salas are not (S-029 in docs/MACROVERSO.md).
1719
1900
  if (depth > 0 && /```pulse\s*\n/i.test(result.text)) {
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}`);
1901
+ 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 project drifts.`, `nested:${agent.id}`);
1721
1902
  }
1722
1903
  // A question the agent put to the human: it leaves the text and comes back as buttons.
1723
1904
  const asked = this.#choicesEnabled && turnMode !== 0 ? parseChoice(result.text) : null;
@@ -1743,6 +1924,7 @@ export class Room {
1743
1924
  synthetic: result.synthetic || undefined,
1744
1925
  redacted: guarded.hits || undefined,
1745
1926
  mode: turnMode,
1927
+ copilot: asCopilot && controlRun ? true : undefined,
1746
1928
  leaseId: lease?.leaseId,
1747
1929
  artifacts: artifacts.length ? artifacts : undefined,
1748
1930
  delegates: directives.steps.length ? directives.steps.map((step) => step.agent) : undefined,
@@ -1752,7 +1934,10 @@ export class Room {
1752
1934
  await this.#emit('ash.validated', { messageId, responseMessageId, agent: agent.id, ...validation });
1753
1935
  }
1754
1936
  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 });
1755
- if (requested) await this.#emit('run.requested', { messageId, responseMessageId, agent: agent.id, reason: requested.reason, commands: requested.commands });
1937
+ if (requested) {
1938
+ await this.#emit('run.requested', { messageId, responseMessageId, agent: agent.id, reason: requested.reason, commands: requested.commands, mode: turnMode, copilot: asCopilot && controlRun ? true : undefined });
1939
+ if (asCopilot && controlRun) this.#copilotRuns.push(responseMessageId);
1940
+ }
1756
1941
  if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, kinds: guarded.kinds, marker: this.#privacy.marker });
1757
1942
  if (artifacts.length) {
1758
1943
  await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
@@ -1773,6 +1958,12 @@ export class Room {
1773
1958
  }
1774
1959
  }
1775
1960
  if (controlChanges) await this.#emit('control.changed', { ...controlChanges, responseMessageId });
1961
+ // A writing turn may also have written where git does not look; the Finder hears about it.
1962
+ if (lease && turnMode >= 2) {
1963
+ const listed = new Set([...artifacts.map((file) => file.path), ...(controlChanges?.files ?? []).map((file) => file.path)]);
1964
+ const touched = (await filesChangedSince(this.#projectRoot, writingSince).catch(() => [])).filter((path) => !listed.has(path));
1965
+ if (touched.length) await this.#emit('files.touched', { agent: agent.id, messageId, responseMessageId, files: touched });
1966
+ }
1776
1967
  // Codex and OpenCode give tokens, not dollars: the turn is priced at the list price of the
1777
1968
  // model it ran on (src/prices.mjs), an estimate that says it is one. Gemini prices itself in
1778
1969
  // its adapter, where its report names each model it used.
@@ -1828,30 +2019,42 @@ export class Room {
1828
2019
  }
1829
2020
  }
1830
2021
 
1831
- async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ash = false, mode = 1 }) {
2022
+ async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ash = false, mode = 1, grants = new Map(), copilot = false }) {
1832
2023
  const planId = randomUUID();
2024
+ const closing = directives.closing ?? 'Review the delegated answers and the work completed. If more work is needed within the permission already granted by the human, finish your reply with one new pulse plan. If a command requested from the human has not run, say what is waiting. Ask the human only for a decision or a new #3/#4 key.';
1833
2025
  // The plan's ceiling is the human's mode: #3 when the orchestrator held CONTROL, #2 while a
1834
2026
  // lease exists, #1 otherwise. A step may ask for a mode ("@codex #2: …"); it gets the lowest
1835
- // of what it asked, the ceiling and its own MAX MODE. Without a number it inherits the plan.
2027
+ // of what it asked, the ceiling and its own MAX MODE. Without a number it reads, or creates
2028
+ // while a lease exists: as in 1.0, #3 has to be asked for by name.
1836
2029
  const ceiling = mode >= 3 ? mode : lease ? 2 : 1;
2030
+ // A key for one delegate does not authorize every other provider in the plan, and #4 is never
2031
+ // reached through the human's own #4: a delegate gets it only with its own key.
2032
+ const planGrants = grants;
2033
+ const reach = (agent, asked) => Math.max(asked >= 4 ? Math.min(ceiling, 3) : ceiling, planGrants.get(agent) ?? 0);
2034
+ const askedOf = (step) => normalizeMode(step.mode, lease ? 2 : 1);
2035
+ // Under the copilot the plan gives each step up to #3 whatever its agent's max mode, as long as
2036
+ // its CLI can write; #4 stays the human's.
2037
+ const underCopilot = () => copilot === true && this.copilotOn();
2038
+ const capOf = (scopes) => (underCopilot() && scopes.write?.capable ? Math.max(scopes.maxMode, 3) : scopes.maxMode);
1837
2039
  const stepMode = (step) => {
1838
2040
  const scopes = this.scopesFor(step.agent);
1839
- const asked = normalizeMode(step.mode, lease ? 2 : 1);
1840
- const granted = Math.min(asked, ceiling, scopes.maxMode);
1841
- return granted >= 2 && !scopes.write.enabled ? 1 : granted;
2041
+ const asked = askedOf(step);
2042
+ const granted = Math.min(asked, reach(step.agent, asked), capOf(scopes));
2043
+ return granted >= 2 && !(scopes.write.enabled || (underCopilot() && scopes.write?.capable)) ? 1 : granted;
1842
2044
  };
1843
- const plan = { planId, orchestrator, steps: directives.steps, closing: directives.closing, step: 0, stopped: null, startedAt: Date.now() };
2045
+ const plan = { planId, orchestrator, steps: directives.steps, closing, step: 0, stopped: null, startedAt: Date.now() };
1844
2046
  this.#plans.set(planId, plan);
1845
2047
  await this.#emit('plan.created', {
1846
2048
  planId,
1847
2049
  orchestrator,
1848
2050
  parentMessageId,
1849
2051
  steps: directives.steps.map((step) => ({ ...step, mode: stepMode(step) })),
1850
- closing: directives.closing,
2052
+ closing,
1851
2053
  ignored: directives.ignored,
1852
2054
  mode: ceiling,
1853
2055
  leaseId: lease?.leaseId ?? null,
1854
2056
  });
2057
+ let continuation = null;
1855
2058
  try {
1856
2059
  for (const [index, step] of directives.steps.entries()) {
1857
2060
  if (plan.stopped) break;
@@ -1864,7 +2067,18 @@ export class Room {
1864
2067
  let stepLease = lease;
1865
2068
  let escalation = null;
1866
2069
  const stepScopes = this.scopesFor(step.agent);
1867
- const wanted = stepMode(step);
2070
+ let wanted = stepMode(step);
2071
+ const askedMode = askedOf(step);
2072
+ // #3/#4 are never granted by an agent's text alone. The room waits for the human's
2073
+ // existing key, even when a lower-mode plan asked for a stronger delegate.
2074
+ if (askedMode >= 3 && (askedMode > reach(step.agent, askedMode) || askedMode > capOf(stepScopes))) {
2075
+ const outcome = await this.#askForMode({ planId, plan, step, index, totalSteps: directives.steps.length + 1, orchestrator, parentMessageId, mode: askedMode });
2076
+ if (outcome.scope === 'plan') planGrants.set(step.agent, askedMode);
2077
+ if (outcome.scope) wanted = Math.min(askedMode, this.scopesFor(step.agent).maxMode);
2078
+ // Denied or unanswered, the step reads, as the line in the room says.
2079
+ else { escalation = outcome.reason; wanted = 1; }
2080
+ if (plan.stopped) break;
2081
+ }
1868
2082
  // Under a #3 ceiling the orchestrator's word is enough: a #2 step gets its project lease,
1869
2083
  // a #3 step gets CONTROL for its turn. Under #1 a creation step still asks the human.
1870
2084
  if (ceiling >= 3 && !stepLease && wanted === 2) {
@@ -1873,8 +2087,8 @@ export class Room {
1873
2087
  stepLease.scopes = Object.fromEntries(SCOPES.map((scope) => [scope, stepScopes[scope].enabled && stepScopes[scope].wired]));
1874
2088
  await this.#emit('lease.granted', { delegated: true, leaseId: stepLease.leaseId, messageId, agent: step.agent, outDir: '.', scratchDir: stepLease.scratchDir, grantedBy: orchestrator, scopes: SCOPES.filter((scope) => stepLease.scopes[scope]), unavailable: [], planId });
1875
2089
  }
1876
- if (ceiling < 3 && !lease && !stepScopes.write.always && (wanted >= 2 || looksLikeCreation(step.text)) && stepScopes.maxMode >= 2 && stepScopes.write.enabled) {
1877
- const outcome = await this.#askForMode({ planId, plan, step, index, totalSteps: directives.steps.length + (directives.closing ? 1 : 0), orchestrator, parentMessageId, mode: 2 });
2090
+ if (ceiling < 3 && !lease && !stepScopes.write.always && askedMode < 3 && (askedMode >= 2 || looksLikeCreation(step.text)) && stepScopes.maxMode >= 2 && stepScopes.write.enabled) {
2091
+ const outcome = await this.#askForMode({ planId, plan, step, index, totalSteps: directives.steps.length + 1, orchestrator, parentMessageId, mode: 2 });
1878
2092
  if (outcome.scope === 'plan') lease = outcome.lease;
1879
2093
  if (outcome.scope) stepLease = outcome.lease; else escalation = outcome.reason;
1880
2094
  if (plan.stopped) break;
@@ -1891,20 +2105,21 @@ export class Room {
1891
2105
  mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1,
1892
2106
  escalation: escalation ?? undefined,
1893
2107
  step: index + 1,
1894
- totalSteps: directives.steps.length + (directives.closing ? 1 : 0),
2108
+ totalSteps: directives.steps.length + 1,
1895
2109
  });
1896
- await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: wanted >= 3 ? null : stepLease, ash, mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1, escalation });
2110
+ await this.#dispatch({ messageId, targetId: step.agent, text: stepText, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease: wanted >= 3 ? null : stepLease, ash, mode: wanted >= 3 ? wanted : stepLease && stepScopes.write.enabled ? 2 : 1, escalation, copilot: wanted === 3 && underCopilot() });
1897
2111
  }
1898
- if (!plan.stopped && directives.closing) {
2112
+ if (!plan.stopped) {
1899
2113
  plan.step = directives.steps.length + 1;
1900
2114
  const messageId = randomUUID();
2115
+ // A delegate's newly granted #3/#4 never raises the orchestrator's own mode.
1901
2116
  const closingMode = ceiling >= 3 ? ceiling : lease ? 2 : 1;
1902
2117
  await this.#emit('message.created', {
1903
2118
  messageId,
1904
2119
  role: 'assistant',
1905
2120
  sender: orchestrator,
1906
2121
  target: orchestrator,
1907
- text: directives.closing,
2122
+ text: closing,
1908
2123
  ash: ash ? { active: true } : undefined,
1909
2124
  mode: closingMode,
1910
2125
  status: 'delegated',
@@ -1912,7 +2127,10 @@ export class Room {
1912
2127
  step: plan.step,
1913
2128
  totalSteps: plan.step,
1914
2129
  });
1915
- await this.#dispatch({ messageId, targetId: orchestrator, text: `${directives.closing}\n(The delegated agents have answered above; this is your closing turn.)`, requester: orchestrator, depth: 1, planId, allowDelegation: false, lease, ash, mode: closingMode });
2130
+ const responseMessageId = await this.#dispatch({ messageId, targetId: orchestrator, text: `${closing}\n(The delegated agents have answered above; this is your closing turn.)`, requester: orchestrator, depth: 0, planId, allowDelegation: true, deferPlan: true, lease, ash, mode: closingMode });
2131
+ const reply = responseMessageId ? (await this.#store.readAll()).find((event) => event.type === 'message.created' && event.payload?.messageId === responseMessageId)?.payload?.text : null;
2132
+ const parsed = reply ? parseDirectives(reply, { self: orchestrator, available: this.delegatesFor(orchestrator), maxSteps: this.#maxPlanSteps }) : null;
2133
+ if (parsed?.steps.length && JSON.stringify(parsed.steps) !== JSON.stringify(directives.steps)) continuation = { responseMessageId, directives: parsed, lease };
1916
2134
  }
1917
2135
  } finally {
1918
2136
  this.#plans.delete(planId);
@@ -1923,5 +2141,6 @@ export class Room {
1923
2141
  reason: plan.stopped ?? null,
1924
2142
  });
1925
2143
  }
2144
+ return continuation;
1926
2145
  }
1927
2146
  }
package/src/router.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
1
3
  const supported = new Set(['codex', 'claude', 'gemini', 'opencode']);
2
4
 
3
5
  export function parseMessage(text, fallbackTarget = null) {