@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.
- package/ADDITIONAL-TERMS.md +93 -0
- package/CHANGELOG.en.md +1945 -0
- package/CHANGELOG.md +78 -1
- package/CONTRIBUTING.md +5 -1
- package/LICENSE +661 -202
- package/NOTICE +34 -3
- package/README.en.md +245 -0
- package/README.md +23 -13
- package/TRADEMARKS.en.md +50 -0
- package/TRADEMARKS.md +82 -0
- package/bin/madre.mjs +2 -0
- package/docs/INTERNALS.md +8 -2
- package/docs/REFERENCE.md +17 -7
- package/docs/en/INTERNALS.md +103 -0
- package/docs/en/REFERENCE.md +339 -0
- package/docs/en/SDK.md +306 -0
- package/docs/en/training/README.md +59 -0
- package/docs/madre-banner.png +0 -0
- package/docs/training/README.md +20 -8
- package/docs/training/train.sh +2 -1
- package/package.json +14 -5
- package/public/abduction.js +2 -0
- package/public/app.js +2066 -488
- package/public/attribution.js +71 -0
- package/public/brands.js +3 -1
- package/public/editor.js +2 -0
- package/public/es.js +1706 -1226
- package/public/i18n.js +25 -1
- package/public/index.html +13 -10
- package/public/inquiry.js +2 -0
- package/public/loader.js +2 -0
- package/public/macroverso.js +24 -2
- package/public/nostromo-engine.js +2 -0
- package/public/resay.js +6 -0
- package/public/review-ask.js +2 -0
- package/public/routing.js +23 -1
- package/public/salas-mac.css +539 -28
- package/public/salas-mac.html +30 -22
- package/public/salas-mac.js +1498 -144
- package/public/salas.css +2 -0
- package/public/salas.html +1 -0
- package/public/salas.js +4 -2
- package/public/styles.css +1123 -20
- package/public/syntax.js +2 -0
- package/public/touch.js +2 -0
- package/public/tour-chapters.js +164 -52
- package/public/tour.js +85 -12
- package/public/troubleshooting.js +20 -18
- package/src/adapters/claude.mjs +2 -0
- package/src/adapters/codex.mjs +2 -0
- package/src/adapters/gemini.mjs +3 -1
- package/src/adapters/madre.mjs +3 -1
- package/src/adapters/opencode.mjs +7 -3
- package/src/adapters/process.mjs +2 -0
- package/src/asking.mjs +2 -0
- package/src/auth-probe.mjs +32 -7
- package/src/bridge/devices.mjs +2 -0
- package/src/bridge/handoff.mjs +2 -0
- package/src/bridge/host.mjs +2 -0
- package/src/bridge/network.mjs +2 -0
- package/src/bridge/policy.mjs +3 -1
- package/src/bridge/qr.mjs +2 -0
- package/src/bridge/server.mjs +2 -0
- package/src/capabilities.mjs +2 -0
- package/src/chats.mjs +2 -0
- package/src/checkpoint.mjs +2 -0
- package/src/choices.mjs +2 -0
- package/src/cli-args.mjs +2 -0
- package/src/cold.mjs +2 -0
- package/src/commands.mjs +2 -0
- package/src/config.mjs +2 -0
- package/src/conversation-context.mjs +15 -1
- package/src/credentials.mjs +112 -3
- package/src/dataset.mjs +2 -0
- package/src/directives.mjs +8 -6
- package/src/distiller.mjs +2 -0
- package/src/embeddings.mjs +2 -0
- package/src/enlaces.mjs +6 -4
- package/src/event-store.mjs +2 -0
- package/src/exam.mjs +6 -4
- package/src/extensions.mjs +33 -10
- package/src/eyecat-watch.mjs +2 -0
- package/src/eyecat.mjs +2 -0
- package/src/file-ops.mjs +2 -0
- package/src/files.mjs +32 -0
- package/src/github.mjs +2 -0
- package/src/heartbeat.mjs +57 -0
- package/src/i18n.mjs +15 -1
- package/src/image-studio.mjs +2 -0
- package/src/json-file.mjs +2 -0
- package/src/launch.mjs +2 -0
- package/src/lease.mjs +5 -1
- package/src/lobby-work.mjs +2 -0
- package/src/local-ai.mjs +50 -0
- package/src/looks.mjs +40 -5
- package/src/maturity.mjs +3 -1
- package/src/mcp/connect.mjs +345 -0
- package/src/mcp/github-server.mjs +2 -0
- package/src/mcp/image-server.mjs +2 -0
- package/src/mcp/memory-server.mjs +2 -0
- package/src/mcp/proxy.mjs +153 -0
- package/src/mcp/smtp-server.mjs +2 -0
- package/src/memory-dashboard.mjs +2 -0
- package/src/memory-tools.mjs +2 -0
- package/src/memory.mjs +2 -0
- package/src/models.mjs +83 -3
- package/src/modules/ahp.mjs +2 -0
- package/src/modules/ash-policy.mjs +2 -0
- package/src/modules/ash.mjs +3 -1
- package/src/modules/bishop-plan.mjs +5 -3
- package/src/modules/bishop.mjs +13 -11
- package/src/modules/choices.mjs +2 -0
- package/src/modules/correo.mjs +2 -0
- package/src/modules/enlaces.mjs +2 -0
- package/src/modules/git-pulse.mjs +2 -0
- package/src/modules/github.mjs +2 -0
- package/src/modules/helpers.mjs +2 -0
- package/src/modules/image-studio.mjs +3 -1
- package/src/modules/index.mjs +4 -1
- package/src/modules/intents-calibration.mjs +2 -0
- package/src/modules/intents-desk.mjs +4 -2
- package/src/modules/intents-observe.mjs +2 -0
- package/src/modules/intents-policy.mjs +4 -1
- package/src/modules/intents-try.mjs +2 -0
- package/src/modules/intents.mjs +8 -6
- package/src/modules/mcp-connect.mjs +293 -0
- package/src/modules/ollama.mjs +17 -3
- package/src/modules/playwright.mjs +25 -10
- package/src/modules/ripley.mjs +2 -0
- package/src/modules/runs.mjs +5 -3
- package/src/modules/sdk.mjs +2 -0
- package/src/modules/updates.mjs +2 -0
- package/src/mother.mjs +2 -0
- package/src/ollama.mjs +2 -0
- package/src/outbound.mjs +24 -5
- package/src/override.mjs +2 -0
- package/src/ports.mjs +2 -0
- package/src/prices.mjs +2 -0
- package/src/privacy.mjs +2 -0
- package/src/quota-monitor.mjs +2 -0
- package/src/quota-sources.mjs +2 -0
- package/src/request-guard.mjs +2 -0
- package/src/review-ask.mjs +2 -0
- package/src/room/archivist.mjs +2 -0
- package/src/room/attachments.mjs +2 -0
- package/src/room/budget.mjs +2 -0
- package/src/room/context.mjs +2 -0
- package/src/room/control.mjs +7 -1
- package/src/room/copilot.mjs +76 -0
- package/src/room/economy.mjs +2 -0
- package/src/room/escalation.mjs +3 -0
- package/src/room/ghost.mjs +2 -0
- package/src/room/guard.mjs +2 -0
- package/src/room/prompt.mjs +13 -4
- package/src/room/returns.mjs +82 -0
- package/src/room/runs.mjs +4 -0
- package/src/room/vectors.mjs +2 -0
- package/src/room.mjs +264 -45
- package/src/router.mjs +2 -0
- package/src/runs.mjs +2 -0
- package/src/runtime-detection.mjs +2 -0
- package/src/salas.mjs +2 -0
- package/src/sentinel-errors.mjs +2 -0
- package/src/server-sentinel.mjs +2 -0
- package/src/server.mjs +485 -55
- package/src/setup.mjs +2 -0
- package/src/smtp.mjs +2 -0
- package/src/tour.mjs +26 -9
- package/src/updates.mjs +2 -0
- package/src/usage-sentinel.mjs +2 -0
- package/src/vault.mjs +2 -0
- package/src/verdict.mjs +3 -1
- package/src/whats-new.mjs +12 -10
- 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}
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
878
|
-
if (this.#controlDesk.holder) return { ok: false, status: 409, error:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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 =
|
|
1840
|
-
const granted = Math.min(asked,
|
|
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
|
|
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
|
|
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
|
-
|
|
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 && (
|
|
1877
|
-
const outcome = await this.#askForMode({ planId, plan, step, index, totalSteps: directives.steps.length +
|
|
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 +
|
|
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
|
|
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:
|
|
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: `${
|
|
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) {
|