@jossuealcala/madre 1.0.1 → 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 (172) hide show
  1. package/ADDITIONAL-TERMS.md +93 -0
  2. package/CHANGELOG.en.md +1945 -0
  3. package/CHANGELOG.md +34 -1
  4. package/CONTRIBUTING.md +1 -1
  5. package/LICENSE +661 -202
  6. package/NOTICE +26 -14
  7. package/README.en.md +245 -0
  8. package/README.md +19 -10
  9. package/TRADEMARKS.en.md +50 -0
  10. package/TRADEMARKS.md +33 -16
  11. package/bin/madre.mjs +2 -0
  12. package/docs/INTERNALS.md +6 -0
  13. package/docs/REFERENCE.md +11 -2
  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/training/README.md +20 -8
  19. package/docs/training/train.sh +2 -1
  20. package/package.json +11 -3
  21. package/public/abduction.js +2 -0
  22. package/public/app.js +1549 -409
  23. package/public/attribution.js +71 -0
  24. package/public/brands.js +3 -1
  25. package/public/editor.js +2 -0
  26. package/public/es.js +620 -400
  27. package/public/i18n.js +25 -1
  28. package/public/index.html +13 -10
  29. package/public/inquiry.js +2 -0
  30. package/public/loader.js +2 -0
  31. package/public/macroverso.js +2 -0
  32. package/public/nostromo-engine.js +2 -0
  33. package/public/resay.js +6 -0
  34. package/public/review-ask.js +2 -0
  35. package/public/routing.js +23 -1
  36. package/public/salas-mac.css +21 -4
  37. package/public/salas-mac.html +5 -1
  38. package/public/salas-mac.js +53 -28
  39. package/public/salas.css +2 -0
  40. package/public/salas.html +1 -0
  41. package/public/salas.js +4 -2
  42. package/public/styles.css +957 -18
  43. package/public/syntax.js +2 -0
  44. package/public/touch.js +2 -0
  45. package/public/tour-chapters.js +2 -0
  46. package/public/tour.js +37 -0
  47. package/public/troubleshooting.js +18 -16
  48. package/src/adapters/claude.mjs +2 -0
  49. package/src/adapters/codex.mjs +2 -0
  50. package/src/adapters/gemini.mjs +3 -1
  51. package/src/adapters/madre.mjs +3 -1
  52. package/src/adapters/opencode.mjs +7 -3
  53. package/src/adapters/process.mjs +2 -0
  54. package/src/asking.mjs +2 -0
  55. package/src/auth-probe.mjs +3 -1
  56. package/src/bridge/devices.mjs +2 -0
  57. package/src/bridge/handoff.mjs +2 -0
  58. package/src/bridge/host.mjs +2 -0
  59. package/src/bridge/network.mjs +2 -0
  60. package/src/bridge/policy.mjs +3 -1
  61. package/src/bridge/qr.mjs +2 -0
  62. package/src/bridge/server.mjs +2 -0
  63. package/src/capabilities.mjs +2 -0
  64. package/src/chats.mjs +2 -0
  65. package/src/checkpoint.mjs +2 -0
  66. package/src/choices.mjs +2 -0
  67. package/src/cli-args.mjs +2 -0
  68. package/src/cold.mjs +2 -0
  69. package/src/commands.mjs +2 -0
  70. package/src/config.mjs +2 -0
  71. package/src/conversation-context.mjs +15 -1
  72. package/src/credentials.mjs +32 -3
  73. package/src/dataset.mjs +2 -0
  74. package/src/directives.mjs +2 -0
  75. package/src/distiller.mjs +2 -0
  76. package/src/embeddings.mjs +2 -0
  77. package/src/enlaces.mjs +6 -4
  78. package/src/event-store.mjs +2 -0
  79. package/src/exam.mjs +6 -4
  80. package/src/extensions.mjs +2 -0
  81. package/src/eyecat-watch.mjs +2 -0
  82. package/src/eyecat.mjs +2 -0
  83. package/src/file-ops.mjs +2 -0
  84. package/src/files.mjs +32 -0
  85. package/src/github.mjs +2 -0
  86. package/src/heartbeat.mjs +2 -0
  87. package/src/i18n.mjs +15 -1
  88. package/src/image-studio.mjs +2 -0
  89. package/src/json-file.mjs +2 -0
  90. package/src/launch.mjs +2 -0
  91. package/src/lease.mjs +5 -1
  92. package/src/lobby-work.mjs +2 -0
  93. package/src/local-ai.mjs +2 -0
  94. package/src/looks.mjs +2 -0
  95. package/src/maturity.mjs +3 -1
  96. package/src/mcp/connect.mjs +345 -0
  97. package/src/mcp/github-server.mjs +2 -0
  98. package/src/mcp/image-server.mjs +2 -0
  99. package/src/mcp/memory-server.mjs +2 -0
  100. package/src/mcp/proxy.mjs +153 -0
  101. package/src/mcp/smtp-server.mjs +2 -0
  102. package/src/memory-dashboard.mjs +2 -0
  103. package/src/memory-tools.mjs +2 -0
  104. package/src/memory.mjs +2 -0
  105. package/src/models.mjs +45 -3
  106. package/src/modules/ahp.mjs +2 -0
  107. package/src/modules/ash-policy.mjs +2 -0
  108. package/src/modules/ash.mjs +3 -1
  109. package/src/modules/bishop-plan.mjs +5 -3
  110. package/src/modules/bishop.mjs +13 -11
  111. package/src/modules/choices.mjs +2 -0
  112. package/src/modules/correo.mjs +2 -0
  113. package/src/modules/enlaces.mjs +2 -0
  114. package/src/modules/git-pulse.mjs +2 -0
  115. package/src/modules/github.mjs +2 -0
  116. package/src/modules/helpers.mjs +2 -0
  117. package/src/modules/image-studio.mjs +3 -1
  118. package/src/modules/index.mjs +4 -1
  119. package/src/modules/intents-calibration.mjs +2 -0
  120. package/src/modules/intents-desk.mjs +4 -2
  121. package/src/modules/intents-observe.mjs +2 -0
  122. package/src/modules/intents-policy.mjs +4 -1
  123. package/src/modules/intents-try.mjs +2 -0
  124. package/src/modules/intents.mjs +8 -6
  125. package/src/modules/mcp-connect.mjs +293 -0
  126. package/src/modules/ollama.mjs +3 -1
  127. package/src/modules/playwright.mjs +25 -10
  128. package/src/modules/ripley.mjs +2 -0
  129. package/src/modules/runs.mjs +5 -3
  130. package/src/modules/sdk.mjs +2 -0
  131. package/src/modules/updates.mjs +2 -0
  132. package/src/mother.mjs +2 -0
  133. package/src/ollama.mjs +2 -0
  134. package/src/outbound.mjs +7 -5
  135. package/src/override.mjs +2 -0
  136. package/src/ports.mjs +2 -0
  137. package/src/prices.mjs +2 -0
  138. package/src/privacy.mjs +2 -0
  139. package/src/quota-monitor.mjs +2 -0
  140. package/src/quota-sources.mjs +2 -0
  141. package/src/request-guard.mjs +2 -0
  142. package/src/review-ask.mjs +2 -0
  143. package/src/room/archivist.mjs +2 -0
  144. package/src/room/attachments.mjs +2 -0
  145. package/src/room/budget.mjs +2 -0
  146. package/src/room/context.mjs +2 -0
  147. package/src/room/control.mjs +7 -1
  148. package/src/room/copilot.mjs +76 -0
  149. package/src/room/economy.mjs +2 -0
  150. package/src/room/escalation.mjs +2 -0
  151. package/src/room/ghost.mjs +2 -0
  152. package/src/room/guard.mjs +2 -0
  153. package/src/room/prompt.mjs +13 -4
  154. package/src/room/returns.mjs +82 -0
  155. package/src/room/runs.mjs +4 -0
  156. package/src/room/vectors.mjs +2 -0
  157. package/src/room.mjs +209 -31
  158. package/src/router.mjs +2 -0
  159. package/src/runs.mjs +2 -0
  160. package/src/runtime-detection.mjs +2 -0
  161. package/src/salas.mjs +2 -0
  162. package/src/sentinel-errors.mjs +2 -0
  163. package/src/server-sentinel.mjs +2 -0
  164. package/src/server.mjs +141 -34
  165. package/src/setup.mjs +2 -0
  166. package/src/smtp.mjs +2 -0
  167. package/src/tour.mjs +2 -0
  168. package/src/updates.mjs +2 -0
  169. package/src/usage-sentinel.mjs +2 -0
  170. package/src/vault.mjs +2 -0
  171. package/src/verdict.mjs +3 -1
  172. package/src/whats-new.mjs +2 -0
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);
@@ -724,7 +742,7 @@ export class Room {
724
742
  settleDistillation() { return this.#archivist.settle(); }
725
743
 
726
744
  // Can this message go out at this mode? Answered before the turn exists.
727
- async modeCheck({ target, text, mode, create = false } = {}) {
745
+ async modeCheck({ target, text, mode, create = false, copilot = false } = {}) {
728
746
  const parsed = parseMessage(text ?? '', target);
729
747
  const wanted = normalizeMode(mode, create === true ? 2 : 1);
730
748
  if (!parsed.target) return { ok: true, mode: wanted };
@@ -732,8 +750,10 @@ export class Room {
732
750
  const scopes = this.scopesFor(agent);
733
751
  if (wanted >= 3) {
734
752
  // #3 CONTROL and #4 AIRLOCK: the ceiling must allow it, and there is one holder at a time.
735
- // A project without git still checkpoints, in MADRE's shadow repository.
736
- 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}.` };
737
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.` };
738
758
  return { ok: true, mode: wanted, maxMode: scopes.maxMode, agent };
739
759
  }
@@ -873,22 +893,26 @@ export class Room {
873
893
  // The human pressed one line of a ```pulse-run an agent wrote. The client names the request and
874
894
  // the line, never the command: what runs is read back from the room's own record of what the
875
895
  // agent asked, so a page cannot run anything that was not shown to the human as a button.
876
- async runRequested({ responseMessageId, index }) {
877
- 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.') };
878
898
  const events = await this.#store.readAll();
879
899
  const request = events.find((event) => event.type === 'run.requested' && event.payload.responseMessageId === responseMessageId)?.payload;
880
900
  const command = request?.commands?.[index];
881
- 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.') };
882
902
  if (!command.argv) return { ok: false, status: 422, error: `That line does not run: it ${whyText(command)}.` };
883
- if (this.#runBusy || this.#runDesk.busy) return { ok: false, status: 409, error: 'A command is already running; wait for it or STOPALL.' };
884
- 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));
885
907
  // Taken before the first await below: two presses in a row must not both get through.
886
908
  this.#runBusy = true;
887
- 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; }
888
910
  }
889
911
 
890
- async #runCommand({ request, command, responseMessageId, index }) {
891
- 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();
892
916
  await this.#emit('run.started', run);
893
917
  let checkpoint = null;
894
918
  try {
@@ -901,6 +925,9 @@ export class Room {
901
925
  const finished = this.#runDesk.run({ id: `${responseMessageId}:${index}`, argv: expandHome(command), cwd: this.#projectRoot, onServing: (where) => opened(where) })
902
926
  .then(async (result) => {
903
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));
904
931
  const status = result.stoppedBy ? `stopped (${result.stoppedBy})` : result.exitCode === null ? 'did not start' : `exit ${result.exitCode}`;
905
932
  const event = await this.#emit('command.output', {
906
933
  name: 'run',
@@ -908,13 +935,16 @@ export class Room {
908
935
  text: `${result.text || '(no output)'}${changes?.files.length ? `\n\nFiles changed: ${changes.files.map((file) => `${file.status} ${file.path}`).join(', ')}` : ''}`,
909
936
  ok: result.ok,
910
937
  args: [],
911
- 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 },
912
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}`));
913
942
  return { ...result, sequence: event.sequence };
914
943
  });
915
944
  const first = await Promise.race([finished.then((ended) => ({ ended })), serving.then((where) => ({ where }))]);
916
945
  if (first.where) {
917
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}`));
918
948
  finished.catch(() => null);
919
949
  return { ok: true, status: 200, result: { serving: first.where } };
920
950
  }
@@ -926,6 +956,120 @@ export class Room {
926
956
  return this.#runDesk.stop('STOP', `${responseMessageId}:${Number(index)}`);
927
957
  }
928
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
+
929
1073
  ashEnabled() {
930
1074
  return this.#ashEnabled;
931
1075
  }
@@ -1027,7 +1171,7 @@ export class Room {
1027
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.' };
1028
1172
  }
1029
1173
 
1030
- 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) {
1031
1175
  const parsed = parseMessage(text, target);
1032
1176
  const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
1033
1177
  // This is the decision the human made when pressing Send. Capture it before modeCheck() —
@@ -1037,8 +1181,13 @@ export class Room {
1037
1181
  // A message that came from a phone never goes above #2, whatever reached this far (TEL-01): the
1038
1182
  // bridge and the route say so first; the room says it last, for any road added later.
1039
1183
  if (via && requestedMode > PHONE_MAX_MODE) throw new Error('#3 EDIT and #4 FULL ACCESS are opened from the Mac.');
1040
- 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 });
1041
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;
1042
1191
  create = requestedMode === 2;
1043
1192
  const ghost = requestedMode === 0;
1044
1193
  const control = requestedMode >= 3;
@@ -1091,6 +1240,10 @@ export class Room {
1091
1240
  attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
1092
1241
  references: references.length ? references.map(({ excerpt, ...reference }) => reference) : undefined,
1093
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,
1094
1247
  });
1095
1248
  // The human may say what they like; the room only says so, because every agent will read it.
1096
1249
  const exposed = this.#privacy?.hits(text2) ?? 0;
@@ -1158,7 +1311,7 @@ export class Room {
1158
1311
  allowDelegation = false;
1159
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}`);
1160
1313
  }
1161
- 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 }));
1162
1315
  return { messageId, responseMessageId };
1163
1316
  }
1164
1317
 
@@ -1253,6 +1406,9 @@ export class Room {
1253
1406
  const turns = this.#turns.size;
1254
1407
  for (const plan of this.#plans.values()) plan.stopped = reason;
1255
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();
1256
1412
  for (const turn of this.#turns.values()) turn.controller.abort(reason);
1257
1413
  this.#runDesk.stop('STOPALL');
1258
1414
  await this.#emit('room.stopped', { reason, plans, turns, agents: [...new Set([...this.#turns.values()].map((turn) => turn.agent))] });
@@ -1304,14 +1460,14 @@ export class Room {
1304
1460
 
1305
1461
  // One room turn for one agent. `requester` is who asked ('you' or an
1306
1462
  // orchestrating agent); `depth` 0 turns may delegate, deeper ones may not.
1307
- 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 }) {
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 }) {
1308
1464
  if (this.#shuttingDown) return null;
1309
1465
  const agent = this.#agents.find((item) => item.id === targetId);
1310
1466
  if (!agent?.detected) {
1311
1467
  // @madre is not a CLI to install: it is the room's local agent, here while Ollama runs with a
1312
1468
  // chat model. Saying "not installed" sent people to install something that does not exist.
1313
1469
  const error = agent?.local || targetId === 'madre'
1314
- ? `@${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.`
1315
1471
  : `${targetId} is not installed on this computer.`;
1316
1472
  await this.#emit('message.failed', { messageId, target: targetId, planId, error });
1317
1473
  return null;
@@ -1368,7 +1524,7 @@ export class Room {
1368
1524
  await this.#emit('agent.started', { messageId, agent: agent.id, handoffId, planId, timeoutMs: this.timeoutFor(agent.id, { writing: Number(mode) >= 2 || Boolean(lease) }) });
1369
1525
  const controller = new AbortController();
1370
1526
  const turn = { controller, promise: null, planId, agent: agent.id, startedAt: Date.now() };
1371
- 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 });
1372
1528
  this.#turns.set(messageId, turn);
1373
1529
  let outcome = null;
1374
1530
  try {
@@ -1376,6 +1532,9 @@ export class Room {
1376
1532
  } finally {
1377
1533
  this.#turns.delete(messageId);
1378
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();
1379
1538
  }
1380
1539
  // The orchestrator's turn is over before its plan starts, so it is never
1381
1540
  // counted as in flight while the others work.
@@ -1386,7 +1545,7 @@ export class Room {
1386
1545
  // The orchestrator may form another wave after seeing the crew's replies. Three waves
1387
1546
  // bound cost and time; a grant for the plan carries forward, only for its authorized scope.
1388
1547
  for (let round = 0; next?.steps.length && round < 3; round += 1) {
1389
- const continued = await this.#runPlan({ orchestrator: agent.id, parentMessageId: parent, directives: next, lease, ash, mode, grants });
1548
+ const continued = await this.#runPlan({ orchestrator: agent.id, parentMessageId: parent, directives: next, lease, ash, mode, grants, copilot: copilot && this.copilotOn() });
1390
1549
  next = continued?.directives ?? null;
1391
1550
  parent = continued?.responseMessageId ?? parent;
1392
1551
  lease = continued?.lease ?? lease;
@@ -1537,7 +1696,7 @@ export class Room {
1537
1696
  });
1538
1697
  }
1539
1698
 
1540
- 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 }) {
1541
1700
  // The lease is the human's; what each agent may do inside it is that
1542
1701
  // agent's own enabled scopes. A delegate without file creation runs
1543
1702
  // read-only even while the plan holds a lease.
@@ -1550,17 +1709,21 @@ export class Room {
1550
1709
  let controlRun = null;
1551
1710
  let createRun = null;
1552
1711
  let responseMessageId = null;
1553
- 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)) {
1554
1715
  // CONTROL: the project itself is the writable root, and a checkpoint
1555
1716
  // taken now makes every change of this turn reversible. A plan step gets
1556
1717
  // it when the orchestrator, itself in #3, named #3 for that step.
1557
1718
  if (this.#controlDesk.holder) {
1558
- 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}`);
1559
1720
  } else {
1560
1721
  await this.#sayWaitingToWrite({ messageId, agent });
1561
1722
  const seat = await this.#controlDesk.begin({ agent, messageId, enabledScopes: enabled, mode, signal }).catch((error) => (error?.code === 'ABORT_ERR' ? null : Promise.reject(error)));
1562
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; }
1563
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;
1564
1727
  lease = seat.lease;
1565
1728
  await this.#emit('control.started', seat.announcement);
1566
1729
  }
@@ -1625,6 +1788,7 @@ export class Room {
1625
1788
  const invoke = this.#invokers[agent.adapter];
1626
1789
  if (!invoke) throw new Error(`${agent.label} does not have a supported MADRE adapter.`);
1627
1790
  const before = lease && !lease.control && !lease.create ? await snapshot(lease.outDir) : null;
1791
+ const writingSince = Date.now();
1628
1792
  responseMessageId = randomUUID();
1629
1793
  this.#liveTurns.set(responseMessageId, { agent: agent.id, mode: turnMode });
1630
1794
  const notesBefore = this.#memory ? this.#memory.maxMemoryId() : 0;
@@ -1642,7 +1806,7 @@ export class Room {
1642
1806
  : null;
1643
1807
  // The prompt and the shape it was built from: one is sent, the other is kept until the CLI
1644
1808
  // says what it cost, so the bill can be attributed to the blocks that caused it.
1645
- 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 });
1646
1810
  this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared, servers: [this.#memoryServer?.name, ...mcpServers.map((server) => server.name)].filter(Boolean) });
1647
1811
  // What this turn is about to read, said while it is still reading it. The size is exact;
1648
1812
  // only the conversion to tokens is an estimate, and it is made at this room's own rate.
@@ -1734,7 +1898,7 @@ export class Room {
1734
1898
  // Only a ```pulse fence is a plan, read the way src/directives.mjs reads it; ```pulse-run,
1735
1899
  // ```pulse-ask and ```pulse-salas are not (S-029 in docs/MACROVERSO.md).
1736
1900
  if (depth > 0 && /```pulse\s*\n/i.test(result.text)) {
1737
- 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}`);
1738
1902
  }
1739
1903
  // A question the agent put to the human: it leaves the text and comes back as buttons.
1740
1904
  const asked = this.#choicesEnabled && turnMode !== 0 ? parseChoice(result.text) : null;
@@ -1760,6 +1924,7 @@ export class Room {
1760
1924
  synthetic: result.synthetic || undefined,
1761
1925
  redacted: guarded.hits || undefined,
1762
1926
  mode: turnMode,
1927
+ copilot: asCopilot && controlRun ? true : undefined,
1763
1928
  leaseId: lease?.leaseId,
1764
1929
  artifacts: artifacts.length ? artifacts : undefined,
1765
1930
  delegates: directives.steps.length ? directives.steps.map((step) => step.agent) : undefined,
@@ -1769,7 +1934,10 @@ export class Room {
1769
1934
  await this.#emit('ash.validated', { messageId, responseMessageId, agent: agent.id, ...validation });
1770
1935
  }
1771
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 });
1772
- 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
+ }
1773
1941
  if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, kinds: guarded.kinds, marker: this.#privacy.marker });
1774
1942
  if (artifacts.length) {
1775
1943
  await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });
@@ -1790,6 +1958,12 @@ export class Room {
1790
1958
  }
1791
1959
  }
1792
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
+ }
1793
1967
  // Codex and OpenCode give tokens, not dollars: the turn is priced at the list price of the
1794
1968
  // model it ran on (src/prices.mjs), an estimate that says it is one. Gemini prices itself in
1795
1969
  // its adapter, where its report names each model it used.
@@ -1845,7 +2019,7 @@ export class Room {
1845
2019
  }
1846
2020
  }
1847
2021
 
1848
- async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ash = false, mode = 1, grants = new Map() }) {
2022
+ async #runPlan({ orchestrator, parentMessageId, directives, lease = null, ash = false, mode = 1, grants = new Map(), copilot = false }) {
1849
2023
  const planId = randomUUID();
1850
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.';
1851
2025
  // The plan's ceiling is the human's mode: #3 when the orchestrator held CONTROL, #2 while a
@@ -1858,11 +2032,15 @@ export class Room {
1858
2032
  const planGrants = grants;
1859
2033
  const reach = (agent, asked) => Math.max(asked >= 4 ? Math.min(ceiling, 3) : ceiling, planGrants.get(agent) ?? 0);
1860
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);
1861
2039
  const stepMode = (step) => {
1862
2040
  const scopes = this.scopesFor(step.agent);
1863
2041
  const asked = askedOf(step);
1864
- const granted = Math.min(asked, reach(step.agent, asked), scopes.maxMode);
1865
- return granted >= 2 && !scopes.write.enabled ? 1 : granted;
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;
1866
2044
  };
1867
2045
  const plan = { planId, orchestrator, steps: directives.steps, closing, step: 0, stopped: null, startedAt: Date.now() };
1868
2046
  this.#plans.set(planId, plan);
@@ -1893,7 +2071,7 @@ export class Room {
1893
2071
  const askedMode = askedOf(step);
1894
2072
  // #3/#4 are never granted by an agent's text alone. The room waits for the human's
1895
2073
  // existing key, even when a lower-mode plan asked for a stronger delegate.
1896
- if (askedMode >= 3 && (askedMode > reach(step.agent, askedMode) || askedMode > stepScopes.maxMode)) {
2074
+ if (askedMode >= 3 && (askedMode > reach(step.agent, askedMode) || askedMode > capOf(stepScopes))) {
1897
2075
  const outcome = await this.#askForMode({ planId, plan, step, index, totalSteps: directives.steps.length + 1, orchestrator, parentMessageId, mode: askedMode });
1898
2076
  if (outcome.scope === 'plan') planGrants.set(step.agent, askedMode);
1899
2077
  if (outcome.scope) wanted = Math.min(askedMode, this.scopesFor(step.agent).maxMode);
@@ -1929,7 +2107,7 @@ export class Room {
1929
2107
  step: index + 1,
1930
2108
  totalSteps: directives.steps.length + 1,
1931
2109
  });
1932
- 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() });
1933
2111
  }
1934
2112
  if (!plan.stopped) {
1935
2113
  plan.step = directives.steps.length + 1;
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) {
package/src/runs.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
  // An agent asking the human to run a command.
2
4
  //
3
5
  // Below #4 AIRLOCK no agent runs anything: it reads, it writes where its mode allows, and that is
@@ -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 { constants } from 'node:fs';
2
4
  import { access } from 'node:fs/promises';
3
5
  import { homedir } from 'node:os';
package/src/salas.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
  // Salas on the Mac (docs/MACROVERSO.md, phase 1): every room this Mac knows, open or asleep, and
2
4
  // what one room may ask of another — to open, to close. The list is read from what each room
3
5
  // leaves beside its record; nothing here touches a room's ledger or its memory.
@@ -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
  // The error sentinel: what MADRE tells its author when something goes wrong
2
4
  // that MU/TH/UR cannot explain. A failure that matches no known condition, or a
3
5
  // crash of the process, becomes a report: the error redacted (no home paths,
@@ -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
  // The servers sentinel: what this project has listening, kept watch over while the room is open.
2
4
  //
3
5
  // RIPLEY opens a project's own dev server; this is what knows which servers those are. Only the