@jossuealcala/madre 0.7.0 → 0.7.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/src/models.mjs CHANGED
@@ -26,6 +26,25 @@ export const KNOWN_MODELS = {
26
26
  },
27
27
  };
28
28
 
29
+ // How hard the model thinks before it answers, for the CLIs that take it per run. Claude Code
30
+ // reads --effort; Codex reads model_reasoning_effort as a config override. Gemini and OpenCode
31
+ // have no per-run knob MADRE can name truthfully (OpenCode's --variant means something different
32
+ // for each provider), so they get no chooser at all rather than one that guesses.
33
+ //
34
+ // Ordered lightest first: the composer draws them as a ladder, and the order is the meaning.
35
+ export const EFFORTS = {
36
+ claude: ['low', 'medium', 'high', 'xhigh', 'max'],
37
+ codex: ['minimal', 'low', 'medium', 'high', 'xhigh'],
38
+ };
39
+
40
+ export function isValidEffort(agentId, effort) {
41
+ return typeof effort === 'string' && (EFFORTS[agentId] ?? []).includes(effort);
42
+ }
43
+
44
+ export function parseCodexDefaultEffort(toml) {
45
+ return String(toml ?? '').match(/^\s*model_reasoning_effort\s*=\s*"([^"]+)"/m)?.[1] ?? null;
46
+ }
47
+
29
48
  export function parseCodexModelCache(json) {
30
49
  try {
31
50
  const data = JSON.parse(json);
@@ -58,9 +77,12 @@ export async function discoverModels({ agents = [], config = {}, home = homedir(
58
77
  const known = KNOWN_MODELS[agent.id] ?? { defaults: [], note: '' };
59
78
  let discovered = [];
60
79
  let cliDefault = null;
80
+ let effortDefault = null;
61
81
  if (agent.id === 'codex') {
62
82
  discovered = parseCodexModelCache(await readFile(join(home, '.codex', 'models_cache.json'), 'utf8').catch(() => ''));
63
- cliDefault = parseCodexDefaultModel(await readFile(join(home, '.codex', 'config.toml'), 'utf8').catch(() => ''));
83
+ const toml = await readFile(join(home, '.codex', 'config.toml'), 'utf8').catch(() => '');
84
+ cliDefault = parseCodexDefaultModel(toml);
85
+ effortDefault = parseCodexDefaultEffort(toml);
64
86
  }
65
87
  if (agent.id === 'opencode' && agent.detected) {
66
88
  discovered = await listOpenCode(agent).catch(() => []);
@@ -71,6 +93,8 @@ export async function discoverModels({ agents = [], config = {}, home = homedir(
71
93
  models: unique([...custom, ...discovered, ...known.defaults]),
72
94
  default: cliDefault,
73
95
  note: known.note,
96
+ efforts: EFFORTS[agent.id] ?? [],
97
+ effortDefault: isValidEffort(agent.id, effortDefault) ? effortDefault : null,
74
98
  source: discovered.length ? 'discovered' : 'known',
75
99
  };
76
100
  }
@@ -15,6 +15,7 @@ import imageStudio from './image-studio.mjs';
15
15
  import gitPulse from './git-pulse.mjs';
16
16
  import ash from './ash.mjs';
17
17
  import choices from './choices.mjs';
18
+ import runs from './runs.mjs';
18
19
  import ripley from './ripley.mjs';
19
20
  import ollama from './ollama.mjs';
20
21
  import playwright from './playwright.mjs';
@@ -27,7 +28,7 @@ import { defineModule, matchRoute } from './sdk.mjs';
27
28
  // so a module of somebody's own cannot take one and collide with it the day it comes out.
28
29
  export const LAB_MODULES = [correo];
29
30
  const labsAsked = new Set(String(process.env.PULSE_LABS ?? '').split(',').map((id) => id.trim()).filter(Boolean));
30
- export const MODULES = [ahp, imageStudio, gitPulse, ash, choices, ripley, ollama, playwright, ...LAB_MODULES.filter((module) => labsAsked.has(module.id))];
31
+ export const MODULES = [ahp, imageStudio, gitPulse, ash, choices, runs, ripley, ollama, playwright, ...LAB_MODULES.filter((module) => labsAsked.has(module.id))];
31
32
  export const BUILTIN_IDS = new Set([...MODULES, ...LAB_MODULES].map((module) => module.id));
32
33
  export const loadFailures = []; // { file, error } for MODULES to show
33
34
  export const moduleFolders = ({ stateRoot, projectRoot }) => ({ user: join(stateRoot, 'modules'), project: join(projectRoot, '.madre', 'modules') });
@@ -75,7 +75,11 @@ export default defineModule({
75
75
  },
76
76
  async updatePlan(ctx) {
77
77
  const { findOnPath } = await import('./helpers.mjs');
78
- return ollamaUpdatePlan({ brew: await findOnPath('brew') });
78
+ const plan = ollamaUpdatePlan({ brew: await findOnPath('brew') });
79
+ if (!plan.command) return plan;
80
+ // Upgrading replaces the binary, not the server already running: until it restarts, the old
81
+ // version keeps answering. Said before the button is pressed, done after the command.
82
+ return { ...plan, note: `${plan.note} If an older Ollama is still the one answering, MADRE restarts it so the new one does.`, after: () => ctx.services.ollama.restart?.() ?? null };
79
83
  },
80
84
  async toggle(ctx) {
81
85
  const enabled = !(ctx.settings.enabled ?? true);
@@ -0,0 +1,34 @@
1
+ // Runs: letting an agent ask the human to run a command instead of asking them to copy it.
2
+ //
3
+ // Below #4 AIRLOCK an agent cannot run anything, and some answers turn on a command anyway: the
4
+ // tests after a fix, a build, a linter. This gives that request a shape — a fenced block the room
5
+ // turns into one button per command — and brings the output back to the room as a card every
6
+ // agent reads on its next turn.
7
+ //
8
+ // Nothing an agent writes runs by itself. Each line is pressed by the human, runs alone from the
9
+ // project root with no shell, and is photographed first so UNDO can put the files back. What a
10
+ // command sends off this machine does not come back with UNDO, and the button says what it runs
11
+ // before it is pressed.
12
+ //
13
+ // It has a switch because it is not free: the instruction that teaches the block rides in every
14
+ // prompt below AIRLOCK while this is on, measured in the economy as `runs`.
15
+
16
+ import { defineModule } from './sdk.mjs';
17
+ import { t } from '../i18n.mjs';
18
+
19
+ export default defineModule({
20
+ id: 'runs',
21
+ configKey: 'runs',
22
+ name: 'Runs',
23
+ vendor: 'MADRE',
24
+ version: '1.0.0',
25
+ summary: 'Lets an agent ask you to run a command it cannot run in its mode (tests, a build, a linter). Each command is a button: nothing runs until you press it, it runs from the project root with no shell, a checkpoint is taken first, and the output comes back to the room. Costs about 190 tokens of instruction per turn below AIRLOCK while it is on.',
26
+ creates: ['nothing in the project', 'a switch in ~/.pulse/config.json'],
27
+ async status(ctx) {
28
+ return {
29
+ status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? t('on · agents may ask you to run commands') : t('off · agents describe the command in prose') },
30
+ install: { display: ctx.settings.enabled ? 'stop offering commands' : 'let agents ask for commands', platforms: [] },
31
+ };
32
+ },
33
+ async onToggle(ctx, enabled) { ctx.room.setRuns(enabled); return null; },
34
+ });
package/src/ollama.mjs CHANGED
@@ -28,6 +28,25 @@ function pick(models, preferred, env) {
28
28
  return names[0] ?? null;
29
29
  }
30
30
 
31
+ // Two versions, and they are not the same thing. `ollama --version` names the server that answers
32
+ // on the port and, when it differs, adds a warning with the client's own: that second one is what
33
+ // is installed. An upgrade replaces the binary and leaves the old server running — and that old
34
+ // server then starts the NEW runner, which is how a model load ends in `invalid argument`.
35
+ export function ollamaVersions(output) {
36
+ const text = String(output ?? '');
37
+ const server = text.match(/ollama version is\s+(\S+)/i)?.[1] ?? null;
38
+ const client = text.match(/client version is\s+(\S+)/i)?.[1] ?? server;
39
+ return { server, client };
40
+ }
41
+
42
+ // The `ollama serve` processes this user owns. Only those: an Ollama app, or a system service
43
+ // running as another user, is not MADRE's to stop, and the room says so instead.
44
+ export function ownOllamaServers(psOutput, uid) {
45
+ return String(psOutput ?? '').split('\n').map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/)).filter(Boolean)
46
+ .filter(([, , owner, args]) => Number(owner) === uid && /(^|\/)ollama serve$/.test(args.trim()))
47
+ .map(([, pid]) => Number(pid));
48
+ }
49
+
31
50
  // Is Ollama there, and what can it do? Quick, never throws.
32
51
  export async function probeOllama({ host = ollamaHost(), fetchImpl = globalThis.fetch, timeoutMs = 1500, env = process.env } = {}) {
33
52
  try {
@@ -75,11 +75,28 @@ export class ControlDesk {
75
75
  if (this.#holder === run) this.#holder = null;
76
76
  }
77
77
 
78
+ // A photograph with no seat and no locks, for a command the human runs by hand: the human may
79
+ // write anywhere, so nothing is guarded and nothing is reverted, but UNDO still has a place to
80
+ // go back to. The same map as CONTROL's, so the same UNDO restores it.
81
+ async photograph({ id, label, by = 'you', command = null }) {
82
+ const checkpoint = await createCheckpoint(this.#projectRoot, { id, label });
83
+ checkpoint.agent = by;
84
+ checkpoint.command = command;
85
+ this.#checkpoints.set(checkpoint.id, checkpoint);
86
+ return checkpoint;
87
+ }
88
+
89
+ async changesSince(checkpoint) {
90
+ const { files, stat } = await diffCheckpoint(this.#projectRoot, checkpoint);
91
+ return { files, stat };
92
+ }
93
+
78
94
  async undo(checkpointId) {
79
95
  const checkpoint = this.#checkpoints.get(checkpointId);
80
96
  if (!checkpoint) return { ok: false, status: 404, error: 'That checkpoint is not known to this room.' };
81
97
  if (this.#holder?.checkpoint.id === checkpointId) return { ok: false, status: 409, error: 'That CONTROL turn is still running; STOPALL first.' };
82
98
  const result = await restoreCheckpoint(this.#projectRoot, checkpoint);
83
- return { ok: true, checkpoint, ...result, message: `Project restored to the checkpoint taken before @${checkpoint.agent}'s CONTROL turn: ${result.restored.length} file(s) restored, ${result.removed.length} removed.` };
99
+ const before = checkpoint.command ? `\`${checkpoint.command}\`` : `@${checkpoint.agent}'s CONTROL turn`;
100
+ return { ok: true, checkpoint, ...result, message: `Project restored to the checkpoint taken before ${before}: ${result.restored.length} file(s) restored, ${result.removed.length} removed.` };
84
101
  }
85
102
  }
@@ -40,13 +40,15 @@ export const BLOCK_SOURCES = {
40
40
  recall: { when: 'older exchanges match the request and fit in the budget', where: '⚙ CONNECTIONS → MEMORY · RECALL · % OF CONTEXT' },
41
41
  context: { when: 'the room has a transcript', where: 'PULSE_CONTEXT_MAX_CHARS sets the budget; RECALL · % OF CONTEXT splits it' },
42
42
  delegation: { when: 'this turn may open a plan', where: '⚙ CONNECTIONS → ROOM SETTINGS → DELEGATION' },
43
+ choices: { when: 'Choices is on and the turn is not GHOST', where: 'MODULES → CHOICES' },
44
+ runs: { when: 'Runs is on and the turn is #1 to #3', where: 'MODULES → RUNS' },
43
45
  abilities: { when: 'this turn may open a plan', where: '⚙ CONNECTIONS → each agent\'s abilities and MAX MODE' },
44
46
  attachments: { when: 'the message carries attached files', where: null },
45
47
  references: { when: 'the message points at project files with !', where: null },
46
48
  ask: { when: 'the human is the one asking', where: null },
47
49
  };
48
50
 
49
- export const PROMPT_BLOCKS = ['room', 'who', 'style', 'privacy', 'madre', 'memory-server', 'mode', 'inspect', 'ash', 'mother', 'mcp', 'sdk', 'lease', 'control-held', 'escalation', 'web', 'shared-lease', 'memories', 'recall', 'context', 'delegation', 'abilities', 'attachments', 'references', 'ask'];
51
+ export const PROMPT_BLOCKS = ['room', 'who', 'style', 'privacy', 'madre', 'memory-server', 'mode', 'inspect', 'ash', 'mother', 'mcp', 'sdk', 'lease', 'control-held', 'escalation', 'web', 'shared-lease', 'memories', 'recall', 'context', 'delegation', 'choices', 'runs', 'abilities', 'attachments', 'references', 'ask'];
50
52
 
51
53
  // Whether this turn could plausibly produce a MADRE module. The SDK block is long and is only
52
54
  // ever of use when the human is asking for one, so it is not carried by every leased turn. The
@@ -69,7 +71,7 @@ export function sparedChars(options) {
69
71
  export function promptParts({
70
72
  agent, text, requester, depth, allowDelegation, context, recall = null, memories = null,
71
73
  attachments = [], references = [], lease = null, scopes = null, imageStudio = null,
72
- sharedLeaseHint = null, ash = false, choices = false, mode = 1, escalation = null, mcpServers = [],
74
+ sharedLeaseHint = null, ash = false, choices = false, runs = false, mode = 1, escalation = null, mcpServers = [],
73
75
  // What the room adds:
74
76
  others = [], delegation = true, maxPlanSteps = 4, scopesFor = () => ({}), motherLines = [], memoryServer = null, controlHolder = null, privacyMarker = '[ENTIDAD-ORG]', madreModel = null, sdk = null,
75
77
  }) {
@@ -128,11 +130,21 @@ export function promptParts({
128
130
  'If answering well means the human has to pick between three or more real options, end your reply with one fenced block:',
129
131
  '```pulse-ask',
130
132
  '<the question, one line>',
133
+ '- <the option you would pick> (recommended)',
131
134
  '- <option>',
132
135
  '- <option>',
133
- '- <option>',
134
136
  '```',
135
- 'Three to six options, the question first, nothing after the block. MADRE shows them as buttons and pressing one writes it into the composer; nothing runs and no turn is spent until the human presses. Not for yes/no, and never instead of a recommendation you could simply give.',
137
+ 'Three to six options, the question first, nothing after the block. Exactly one option ends with "(recommended)": the one you would pick, listed first. Write each option whole, as the sentence the human would send; they are shown in full. MADRE shows them as buttons and pressing one writes it into the composer; nothing runs and no turn is spent until the human presses. Not for yes/no, and never instead of a recommendation you could simply give.',
138
+ ].join('\n')
139
+ : null },
140
+ { id: 'runs', text: runs
141
+ ? [
142
+ 'You cannot run commands in this mode. If your answer depends on one (tests, a build, a linter, a git query), end your reply with one fenced block and the human may run it for you:',
143
+ '```pulse-run',
144
+ '<why you need it, one line>',
145
+ '$ <command>',
146
+ '```',
147
+ `The reason first, then one command per line, each starting with "$", at most 5, nothing after the block. There is no shell: each line runs alone from the project root, so no pipes, &&, redirections, $VARIABLES, globs, ~ or cd. Quote arguments with spaces. MADRE shows each line as a button; nothing runs until the human presses it, and its output comes back to the room for your next turn. Ask only for what you cannot learn by reading the files, and never for something that changes what lives outside this machine.`,
136
148
  ].join('\n')
137
149
  : null },
138
150
  { id: 'abilities', text: mayDelegate ? `Abilities right now (route each step to an agent that can do it):\n${[agent.id, ...others].map((id) => abilityLine(id, scopesFor(id))).join('\n')}` : null },
@@ -0,0 +1,88 @@
1
+ // The human's hands for a command an agent asked for. One command at a time, in the project
2
+ // root, with no shell: the argv the parser split is the argv that runs (see src/runs.mjs).
3
+ //
4
+ // stdin is closed, so a program that wants to ask something reads end-of-file and gives up
5
+ // instead of hanging on a prompt nobody can see. The output is one stream, stdout and stderr
6
+ // interleaved the way a terminal shows them, and what is kept of a long one is its start and its
7
+ // end: a test run says what it is at the top and how it went at the bottom.
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import { terminateProcessTree } from '../adapters/process.mjs';
11
+
12
+ export const RUN_TIMEOUT_MS = 10 * 60 * 1000;
13
+ const HEAD_CHARS = 4000;
14
+ const TAIL_CHARS = 14000;
15
+
16
+ export function keepEnds(text, { head = HEAD_CHARS, tail = TAIL_CHARS } = {}) {
17
+ if (text.length <= head + tail) return text;
18
+ const dropped = text.length - head - tail;
19
+ return `${text.slice(0, head)}\n… ${dropped} characters of output left out …\n${text.slice(-tail)}`;
20
+ }
21
+
22
+ export class RunDesk {
23
+ #child = null;
24
+ #stoppedBy = null;
25
+
26
+ get busy() { return Boolean(this.#child); }
27
+
28
+ // Resolves with what happened; never rejects. A program that could not start is an answer too.
29
+ run({ argv, cwd, timeoutMs = RUN_TIMEOUT_MS, env = process.env }) {
30
+ return new Promise((resolve) => {
31
+ const started = Date.now();
32
+ // 'error' and 'close' can both arrive for one failed spawn; the first one is the answer.
33
+ let settled = false;
34
+ const done = (result) => {
35
+ if (settled) return;
36
+ settled = true;
37
+ this.#child = null;
38
+ resolve({ ...result, durationMs: Date.now() - started });
39
+ };
40
+ let child;
41
+ try {
42
+ child = spawn(argv[0], argv.slice(1), {
43
+ cwd,
44
+ env: { ...env, NO_COLOR: '1', FORCE_COLOR: '0', GIT_PAGER: 'cat', PAGER: 'cat' },
45
+ stdio: ['ignore', 'pipe', 'pipe'],
46
+ shell: false,
47
+ detached: process.platform !== 'win32',
48
+ windowsHide: true,
49
+ });
50
+ } catch (error) {
51
+ done({ ok: false, exitCode: null, text: couldNotRun(argv[0], error) });
52
+ return;
53
+ }
54
+ this.#child = child;
55
+ this.#stoppedBy = null;
56
+ let output = '';
57
+ // Bounded while it runs, not only at the end: a program that prints without stopping must
58
+ // not grow the server's memory for ten minutes.
59
+ const take = (chunk) => {
60
+ output += chunk;
61
+ if (output.length > (HEAD_CHARS + TAIL_CHARS) * 2) output = keepEnds(output);
62
+ };
63
+ child.stdout.on('data', take);
64
+ child.stderr.on('data', take);
65
+ const timer = setTimeout(() => { this.#stoppedBy = `timeout (${Math.round(timeoutMs / 1000)}s)`; terminateProcessTree(child); }, timeoutMs);
66
+ timer.unref?.();
67
+ child.on('error', (error) => { clearTimeout(timer); done({ ok: false, exitCode: null, text: couldNotRun(argv[0], error) }); });
68
+ child.on('close', (code, signal) => {
69
+ clearTimeout(timer);
70
+ const stoppedBy = this.#stoppedBy;
71
+ const tail = stoppedBy ? `\n[stopped: ${stoppedBy}]` : signal ? `\n[ended on ${signal}]` : code !== 0 ? `\n[exit code ${code}]` : '';
72
+ done({ ok: code === 0 && !stoppedBy, exitCode: code, signal: signal ?? undefined, stoppedBy: stoppedBy ?? undefined, text: `${keepEnds(output).trimEnd()}${tail}`.trim() });
73
+ });
74
+ });
75
+ }
76
+
77
+ stop(reason = 'STOPALL') {
78
+ if (!this.#child) return false;
79
+ this.#stoppedBy = reason;
80
+ terminateProcessTree(this.#child);
81
+ return true;
82
+ }
83
+ }
84
+
85
+ function couldNotRun(program, error) {
86
+ if (error?.code === 'ENOENT') return `${program} could not start: it is not installed, or not on the PATH MADRE was started with.`;
87
+ return `${program} could not start: ${error?.message ?? error}`;
88
+ }
package/src/room.mjs CHANGED
@@ -17,6 +17,7 @@ import { coverageExam, consistencyExam, matchExam, exchanges, MATCH_SAMPLE, says
17
17
  import { MADRE_ADAPTER, MADRE_AGENT_ID } from './adapters/madre.mjs';
18
18
  import { verdictFor } from './verdict.mjs';
19
19
  import { ControlDesk } from './room/control.mjs';
20
+ import { RunDesk } from './room/runs.mjs';
20
21
  import { Attachments } from './room/attachments.mjs';
21
22
  import { GhostLedger } from './room/ghost.mjs';
22
23
  import { Escalation } from './room/escalation.mjs';
@@ -27,7 +28,7 @@ import { OLLAMA_ARCHIVIST } from './distiller.mjs';
27
28
  import { memoryServerForTurn } from './memory-tools.mjs';
28
29
  import { CODE000_STRIKES } from './mother.mjs';
29
30
  import { parseDirectives } from './directives.mjs';
30
- import { isValidModelName } from './models.mjs';
31
+ import { isValidModelName, isValidEffort } from './models.mjs';
31
32
  import { MODES, SCOPES, SCOPE_LABELS, capabilitySummary, normalizeMode, resolveScopes } from './capabilities.mjs';
32
33
  import { createLease, diffSnapshots, namedPaths, snapshot } from './lease.mjs';
33
34
  import { stat as statFile } from 'node:fs/promises';
@@ -36,6 +37,7 @@ import { contentTypeFor } from './files.mjs';
36
37
  import { isModuleFile, sdkPaths } from './modules/index.mjs';
37
38
  import { validateAshReply } from './modules/ash-policy.mjs';
38
39
  import { parseChoice, withoutChoice } from './choices.mjs';
40
+ import { parseRunRequest, withoutRunRequest, whyText } from './runs.mjs';
39
41
  import { imageStudioFor } from './image-studio.mjs';
40
42
  import { CAPABILITIES, imageModuleState } from './capabilities.mjs';
41
43
  import { resolveReferences } from './files.mjs';
@@ -134,6 +136,9 @@ export class Room {
134
136
  #planMaxAgeMs;
135
137
  #ashEnabled = false;
136
138
  #choicesEnabled = false;
139
+ #runsEnabled = false;
140
+ #runDesk = new RunDesk();
141
+ #runBusy = false;
137
142
 
138
143
  constructor({
139
144
  store,
@@ -228,7 +233,7 @@ export class Room {
228
233
  const result = await this.#controlDesk.undo(checkpointId);
229
234
  if (!result.ok) return result;
230
235
  const { checkpoint, message, ...rest } = result;
231
- await this.#emit('control.reverted', { checkpointId, agent: checkpoint.agent, removed: rest.removed, restored: rest.restored, message });
236
+ await this.#emit('control.reverted', { checkpointId, agent: checkpoint.agent, command: checkpoint.command ?? undefined, removed: rest.removed, restored: rest.restored, message });
232
237
  return { ok: true, ...rest };
233
238
  }
234
239
 
@@ -764,6 +769,53 @@ export class Room {
764
769
  return this.#choicesEnabled;
765
770
  }
766
771
 
772
+ setRuns(enabled) {
773
+ this.#runsEnabled = Boolean(enabled);
774
+ return this.#runsEnabled;
775
+ }
776
+
777
+ runsEnabled() {
778
+ return this.#runsEnabled;
779
+ }
780
+
781
+ // The human pressed one line of a ```pulse-run an agent wrote. The client names the request and
782
+ // the line, never the command: what runs is read back from the room's own record of what the
783
+ // agent asked, so a page cannot run anything that was not shown to the human as a button.
784
+ async runRequested({ responseMessageId, index }) {
785
+ if (!this.#runsEnabled) return { ok: false, status: 412, error: 'Command requests are off. Enable them in MODULES.' };
786
+ const events = await this.#store.readAll();
787
+ const request = events.find((event) => event.type === 'run.requested' && event.payload.responseMessageId === responseMessageId)?.payload;
788
+ const command = request?.commands?.[index];
789
+ if (!command) return { ok: false, status: 404, error: 'That command request is not in this room.' };
790
+ if (!command.argv) return { ok: false, status: 422, error: `That line does not run: it ${whyText(command)}.` };
791
+ if (this.#runBusy) return { ok: false, status: 409, error: 'A command is already running; wait for it or STOPALL.' };
792
+ if (this.#controlDesk.holder) return { ok: false, status: 409, error: `@${this.#controlDesk.holder.agent} holds ${MODES[this.#controlDesk.holder.mode ?? 3].label} of this project; run it when the turn ends.` };
793
+ // Taken before the first await below: two presses in a row must not both get through.
794
+ this.#runBusy = true;
795
+ try { return await this.#runCommand({ request, command, responseMessageId, index }); } finally { this.#runBusy = false; }
796
+ }
797
+
798
+ async #runCommand({ request, command, responseMessageId, index }) {
799
+ const run = { responseMessageId, index, agent: request.agent, line: command.line };
800
+ await this.#emit('run.started', run);
801
+ let checkpoint = null;
802
+ try {
803
+ checkpoint = await this.#controlDesk.photograph({ id: `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-run-${responseMessageId.slice(0, 8)}-${index}`, label: `MADRE run @${request.agent}`, command: command.line });
804
+ } catch { /* no photograph, no UNDO: the command still runs, and the card says so */ }
805
+ const result = await this.#runDesk.run({ argv: command.argv, cwd: this.#projectRoot });
806
+ const changes = checkpoint ? await this.#controlDesk.changesSince(checkpoint).catch(() => null) : null;
807
+ const status = result.stoppedBy ? `stopped (${result.stoppedBy})` : result.exitCode === null ? 'did not start' : `exit ${result.exitCode}`;
808
+ const event = await this.#emit('command.output', {
809
+ name: 'run',
810
+ title: `$ ${command.line} · ${status} · ${(result.durationMs / 1000).toFixed(1)}s · asked by @${request.agent}`,
811
+ text: `${result.text || '(no output)'}${changes?.files.length ? `\n\nFiles changed: ${changes.files.map((file) => `${file.status} ${file.path}`).join(', ')}` : ''}`,
812
+ ok: result.ok,
813
+ args: [],
814
+ run: { ...run, exitCode: result.exitCode, durationMs: result.durationMs, stoppedBy: result.stoppedBy, checkpointId: checkpoint?.id, files: changes?.files.length ? changes.files.slice(0, 40) : undefined },
815
+ });
816
+ return { ok: true, status: 200, result: { ok: result.ok, exitCode: result.exitCode, sequence: event.sequence } };
817
+ }
818
+
767
819
  ashEnabled() {
768
820
  return this.#ashEnabled;
769
821
  }
@@ -840,7 +892,7 @@ export class Room {
840
892
  this.#vectors.stop();
841
893
  }
842
894
 
843
- async send({ text, target, model = null, attachments = [], create = false, ash = false, mode = undefined }) {
895
+ async send({ text, target, model = null, effort = null, attachments = [], create = false, ash = false, mode = undefined }) {
844
896
  const parsed = parseMessage(text, target);
845
897
  const requestedMode = normalizeMode(mode, create === true ? 2 : 1);
846
898
  // This is the decision the human made when pressing Send. Capture it before modeCheck() —
@@ -857,6 +909,9 @@ export class Room {
857
909
  if (!parsed.target) throw new Error('Choose an agent or begin with @agent.');
858
910
  if (model !== null && model !== undefined && model !== '' && !isValidModelName(model)) throw new Error('Model name is not valid.');
859
911
  const chosenModel = model || null;
912
+ // An effort the target's CLI does not take is dropped, not refused: the composer remembers it
913
+ // per agent, so a stale one should cost the human nothing but the flag.
914
+ const chosenEffort = isValidEffort(parsed.target, effort) ? effort : null;
860
915
  // What the human wrote, sent as they wrote it. Ash used to rewrite this line before it left
861
916
  // the room; it does not any more, so there is no original to keep beside an abbreviation.
862
917
  const text2 = parsed.text || `(${files.length} attached file${files.length === 1 ? '' : 's'})`;
@@ -877,6 +932,7 @@ export class Room {
877
932
  ash: ashRequested ? { requested: true, active: ashActive } : undefined,
878
933
  status: 'sent',
879
934
  model: chosenModel,
935
+ effort: chosenEffort ?? undefined,
880
936
  attachments: files.length ? files.map((file) => ({ id: file.id, name: file.name, fileName: file.fileName, size: file.size, contentType: file.contentType })) : undefined,
881
937
  references: references.length ? references.map(({ excerpt, ...reference }) => reference) : undefined,
882
938
  create: create === true ? true : undefined,
@@ -941,7 +997,7 @@ export class Room {
941
997
  allowDelegation = false;
942
998
  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}`);
943
999
  }
944
- await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, attachments: files, references, lease, ash: ashActive, mode: ghost ? 0 : control ? requestedMode : (lease ? 2 : 1) }));
1000
+ await this.#track(this.#dispatch({ messageId, targetId: parsed.target, text: text2, requester: 'you', depth: 0, allowDelegation: allowDelegation && !ghost, model: chosenModel, effort: chosenEffort, attachments: files, references, lease, ash: ashActive, mode: ghost ? 0 : control ? requestedMode : (lease ? 2 : 1) }));
945
1001
  }
946
1002
 
947
1003
  // A #2 lease: the project itself is where new files go, and MADRE keeps a scratch folder
@@ -983,6 +1039,7 @@ export class Room {
983
1039
  for (const plan of this.#plans.values()) plan.stopped = reason;
984
1040
  this.#escalation.settleAll(null, 'stopped');
985
1041
  for (const turn of this.#turns.values()) turn.controller.abort(reason);
1042
+ this.#runDesk.stop('STOPALL');
986
1043
  await this.#emit('room.stopped', { reason, plans, turns, agents: [...new Set([...this.#turns.values()].map((turn) => turn.agent))] });
987
1044
  await Promise.allSettled([...this.#turns.values()].map((turn) => turn.promise));
988
1045
  return { plans, turns };
@@ -1032,7 +1089,7 @@ export class Room {
1032
1089
 
1033
1090
  // One room turn for one agent. `requester` is who asked ('you' or an
1034
1091
  // orchestrating agent); `depth` 0 turns may delegate, deeper ones may not.
1035
- async #dispatch({ messageId, targetId, text, requester, depth, planId = null, allowDelegation = true, model = null, attachments = [], references = [], lease = null, ash = false, mode = 1, escalation = null }) {
1092
+ 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 }) {
1036
1093
  if (this.#shuttingDown) return null;
1037
1094
  const agent = this.#agents.find((item) => item.id === targetId);
1038
1095
  if (!agent?.detected) {
@@ -1089,7 +1146,7 @@ export class Room {
1089
1146
  await this.#emit('agent.started', { messageId, agent: agent.id, handoffId, planId });
1090
1147
  const controller = new AbortController();
1091
1148
  const turn = { controller, promise: null, planId, agent: agent.id, startedAt: Date.now() };
1092
- turn.promise = this.#runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall, memories, handoffId, signal: controller.signal, model, attachments, references, lease, ash, mode, escalation });
1149
+ 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 });
1093
1150
  this.#turns.set(messageId, turn);
1094
1151
  let outcome = null;
1095
1152
  try {
@@ -1145,7 +1202,7 @@ export class Room {
1145
1202
  context: built.context, recall: built.recall, memories: built.memories,
1146
1203
  attachments: [], references: [], lease,
1147
1204
  scopes: { web: scopes.web.enabled && scopes.web.wired, imageGen: scopes.imageGen.enabled && scopes.imageGen.wired },
1148
- ash: ash === true && this.ashEnabled(), mode, escalation: null, mcpServers,
1205
+ ash: ash === true && this.ashEnabled(), choices: this.#choicesEnabled && mode !== 0, runs: this.#runsEnabled && mode !== 0 && mode < 4, mode, escalation: null, mcpServers,
1149
1206
  });
1150
1207
  // Each block with what put it here and what would take it away, so "read, never written" can
1151
1208
  // be checked one block at a time instead of believed.
@@ -1244,7 +1301,7 @@ export class Room {
1244
1301
  });
1245
1302
  }
1246
1303
 
1247
- async #runTurn({ messageId, agent, text, requester, depth, planId, allowDelegation, context, recall = null, memories = null, handoffId, signal, model = null, attachments = [], references = [], lease: sharedLease = null, ash = false, mode = 1, escalation = null }) {
1304
+ 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 }) {
1248
1305
  // The lease is the human's; what each agent may do inside it is that
1249
1306
  // agent's own enabled scopes. A delegate without file creation runs
1250
1307
  // read-only even while the plan holds a lease.
@@ -1337,7 +1394,7 @@ export class Room {
1337
1394
  : [];
1338
1395
  // The prompt and the shape it was built from: one is sent, the other is kept until the CLI
1339
1396
  // says what it cost, so the bill can be attributed to the blocks that caused it.
1340
- const shaped = this.#promptFor({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ash, choices: this.#choicesEnabled, mode: turnMode, escalation, mcpServers, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null });
1397
+ const shaped = this.#promptFor({ agent, text, requester, depth, allowDelegation, context, recall, memories, attachments, references, lease, scopes: turnScopes, imageStudio, ash, choices: this.#choicesEnabled && turnMode !== 0, runs: this.#runsEnabled && turnMode !== 0 && turnMode < 4, mode: turnMode, escalation, mcpServers, sharedLeaseHint: sharedLease && !lease ? 'A creation lease is active for this plan, but file creation is not enabled for you: answer without creating files and say so if asked to create one.' : null });
1341
1398
  this.#promptShape.set(responseMessageId, { parts: shaped.parts, spared: shaped.spared });
1342
1399
  // What this turn is about to read, said while it is still reading it. The size is exact;
1343
1400
  // only the conversion to tokens is an estimate, and it is made at this room's own rate.
@@ -1361,6 +1418,7 @@ export class Room {
1361
1418
  timeoutMs: this.timeoutFor(agent.id),
1362
1419
  signal,
1363
1420
  model,
1421
+ effort,
1364
1422
  attachments,
1365
1423
  lease,
1366
1424
  scopes: turnScopes,
@@ -1410,13 +1468,15 @@ export class Room {
1410
1468
  }
1411
1469
  // A question the agent put to the human: it leaves the text and comes back as buttons.
1412
1470
  const asked = this.#choicesEnabled && turnMode !== 0 ? parseChoice(result.text) : null;
1471
+ // Commands it wants the human to run: the same, as buttons. AIRLOCK runs its own.
1472
+ const requested = !asked && this.#runsEnabled && turnMode !== 0 && turnMode < 4 ? parseRunRequest(result.text) : null;
1413
1473
  await this.#emit('message.created', {
1414
1474
  messageId: responseMessageId,
1415
1475
  parentMessageId: messageId,
1416
1476
  role: 'assistant',
1417
1477
  sender: agent.id,
1418
1478
  target: requester,
1419
- text: asked ? withoutChoice(result.text) : result.text,
1479
+ text: asked ? withoutChoice(result.text) : requested ? withoutRunRequest(result.text) : result.text,
1420
1480
  // What the archive handed this turn, so the room can see its own memory working. The
1421
1481
  // whole point of remembering is invisible until the moment it is used.
1422
1482
  recalled: memories?.length
@@ -1426,6 +1486,7 @@ export class Room {
1426
1486
  status: 'completed',
1427
1487
  planId,
1428
1488
  model,
1489
+ effort: effort ?? undefined,
1429
1490
  synthetic: result.synthetic || undefined,
1430
1491
  redacted: guarded.hits || undefined,
1431
1492
  mode: turnMode,
@@ -1437,7 +1498,8 @@ export class Room {
1437
1498
  const validation = validateAshReply({ request: text, response: result.text });
1438
1499
  await this.#emit('ash.validated', { messageId, responseMessageId, agent: agent.id, ...validation });
1439
1500
  }
1440
- if (asked) await this.#emit('choice.offered', { messageId, responseMessageId, agent: agent.id, question: asked.question, options: asked.options });
1501
+ if (asked) await this.#emit('choice.offered', { messageId, responseMessageId, agent: agent.id, question: asked.question, options: asked.options, recommended: asked.recommended ?? undefined });
1502
+ if (requested) await this.#emit('run.requested', { messageId, responseMessageId, agent: agent.id, reason: requested.reason, commands: requested.commands });
1441
1503
  if (guarded.hits) await this.#emit('privacy.redacted', { agent: agent.id, messageId, responseMessageId, hits: guarded.hits, marker: this.#privacy.marker });
1442
1504
  if (artifacts.length) {
1443
1505
  await this.#emit('artifacts.created', { leaseId: lease.leaseId, messageId, responseMessageId, agent: agent.id, outDir: lease.relativeDir, files: artifacts });