@jossuealcala/madre 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.en.md +76 -1
  2. package/CHANGELOG.md +76 -1
  3. package/README.en.md +11 -15
  4. package/README.md +3 -3
  5. package/bin/madre.cjs +40 -0
  6. package/bin/madre.mjs +5 -0
  7. package/docs/REFERENCE.md +29 -3
  8. package/docs/SDK.md +4 -2
  9. package/docs/en/REFERENCE.md +29 -3
  10. package/docs/en/SDK.md +4 -2
  11. package/docs/training/README.md +28 -1
  12. package/docs/training/train.sh +5 -1
  13. package/package.json +3 -3
  14. package/public/app.js +1635 -159
  15. package/public/attribution.js +44 -1
  16. package/public/brands.js +20 -5
  17. package/public/code-tools.js +143 -0
  18. package/public/editor.js +20 -4
  19. package/public/es.js +395 -26
  20. package/public/hardware.js +50 -0
  21. package/public/index.html +5 -1
  22. package/public/macroverso.js +187 -96
  23. package/public/market-art.js +161 -0
  24. package/public/market-view.js +529 -0
  25. package/public/market.js +189 -0
  26. package/public/neural.js +110 -0
  27. package/public/nostromo-engine.js +412 -47
  28. package/public/power.js +100 -0
  29. package/public/resay.js +5 -0
  30. package/public/salas-mac.css +105 -14
  31. package/public/salas-mac.html +9 -1
  32. package/public/salas-mac.js +350 -31
  33. package/public/styles.css +844 -61
  34. package/public/tour.js +48 -6
  35. package/public/troubleshooting.js +6 -1
  36. package/public/wallpaper.js +275 -0
  37. package/src/adapters/cursor.mjs +96 -0
  38. package/src/adapters/gemini.mjs +10 -3
  39. package/src/adapters/madre.mjs +2 -1
  40. package/src/auth-probe.mjs +143 -12
  41. package/src/bridge/policy.mjs +1 -1
  42. package/src/capabilities.mjs +9 -0
  43. package/src/checkpoint.mjs +16 -1
  44. package/src/credentials.mjs +3 -0
  45. package/src/dataset.mjs +76 -4
  46. package/src/enlaces.mjs +208 -2
  47. package/src/extensions.mjs +11 -3
  48. package/src/i18n.mjs +16 -0
  49. package/src/looks.mjs +125 -2
  50. package/src/market-key.mjs +8 -0
  51. package/src/market-remote.mjs +239 -0
  52. package/src/models.mjs +4 -0
  53. package/src/module-scope.mjs +68 -0
  54. package/src/modules/ahp.mjs +102 -7
  55. package/src/modules/bishop-exam.mjs +122 -0
  56. package/src/modules/bishop.mjs +71 -12
  57. package/src/modules/enlaces.mjs +87 -6
  58. package/src/modules/image-studio.mjs +10 -1
  59. package/src/modules/intents.mjs +3 -1
  60. package/src/modules/local-models.mjs +124 -0
  61. package/src/modules/ollama.mjs +12 -3
  62. package/src/modules/playwright.mjs +73 -15
  63. package/src/modules/sdk.mjs +61 -9
  64. package/src/npm-launch.mjs +18 -0
  65. package/src/ollama.mjs +6 -5
  66. package/src/outbound.mjs +9 -1
  67. package/src/ports.mjs +8 -8
  68. package/src/room/control.mjs +1 -1
  69. package/src/room/prompt.mjs +13 -2
  70. package/src/room.mjs +14 -4
  71. package/src/router.mjs +1 -1
  72. package/src/runs.mjs +4 -1
  73. package/src/runtime-detection.mjs +9 -0
  74. package/src/salas.mjs +26 -0
  75. package/src/server-sentinel.mjs +7 -3
  76. package/src/server.mjs +617 -48
  77. package/src/setup.mjs +1 -1
@@ -11,6 +11,7 @@ import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import { t } from '../i18n.mjs';
13
13
  import { defineModule } from './sdk.mjs';
14
+ import { switchIn } from '../module-scope.mjs';
14
15
  import { packageVersion } from './helpers.mjs';
15
16
  import { terminateProcessTree, lastWords } from '../adapters/process.mjs';
16
17
 
@@ -35,6 +36,51 @@ export async function playwrightVersion({ env = process.env, projectRoot = proce
35
36
  return version;
36
37
  }
37
38
 
39
+ // Why npm stopped, in its own words: the line that says it, not the last one, which only points at
40
+ // a log file («npm error A complete log of this run can be found in …», 2026-10-18).
41
+ export function npmReason(output) {
42
+ const lines = String(output ?? '').split('\n').map((line) => line.trim()).filter(Boolean);
43
+ const said = lines.filter((line) => /^npm (error|ERR!)/i.test(line)).map((line) => line.replace(/^npm (error|ERR!)\s*/i, '')).filter((line) => line && !/complete log of this run|^(code|errno|syscall)\b/i.test(line));
44
+ return said.find((line) => !/^path\b/i.test(line)) ?? said[0] ?? lastWords(output);
45
+ }
46
+
47
+ // One command of the module's own, run to its end with no terminal: getting the server or its
48
+ // browser onto this computer. What it printed last is kept for the case it fails.
49
+ function runToEnd(command, args, { cwd, env, timeoutMs = 900000 } = {}) {
50
+ return new Promise((resolve) => {
51
+ let err = '';
52
+ let child;
53
+ try { child = spawn(command, args, { cwd, env: { ...env, CI: '1', NO_COLOR: '1' }, stdio: ['ignore', 'ignore', 'pipe'], shell: false, detached: true, windowsHide: true }); }
54
+ catch (error) { resolve({ code: 1, said: error.message }); return; }
55
+ const timer = setTimeout(() => terminateProcessTree(child, { graceMs: 1000 }), timeoutMs);
56
+ timer.unref?.();
57
+ child.stderr.setEncoding('utf8');
58
+ child.stderr.on('data', (chunk) => { err = (err + chunk).slice(-4000); });
59
+ child.on('error', (error) => { clearTimeout(timer); resolve({ code: 1, said: error.message }); });
60
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code, said: npmReason(err) }); });
61
+ });
62
+ }
63
+
64
+ // What the switch puts in place by itself before it gives up (2026-10-18): the person turned it
65
+ // on, and a browser to download is MADRE's to fetch, not a line to copy into a terminal. The
66
+ // server, globally (where `npx --no` finds it at turn time), when it is missing; then the browser
67
+ // the server itself downloads for the engine chosen. Asked again after.
68
+ export async function preparePlaywright({ browser = 'chromium', env = process.env, cwd = process.cwd(), run = runToEnd, starts = playwrightStarts, version = playwrightVersion } = {}) {
69
+ const engine = PLAYWRIGHT_BROWSERS.includes(browser) ? browser : 'chromium';
70
+ let installed = await version({ env, projectRoot: cwd, fresh: true });
71
+ const steps = [];
72
+ if (!installed) {
73
+ const server = await run('npm', ['install', '-g', PLAYWRIGHT_PACKAGE], { cwd, env });
74
+ steps.push({ display: `npm install -g ${PLAYWRIGHT_PACKAGE}`, code: server.code });
75
+ installed = await version({ env, projectRoot: cwd, fresh: true });
76
+ if (!installed) return { ok: false, steps, problem: server.said ?? t('the browser server could not be installed') };
77
+ }
78
+ const fetched = await run('npx', ['--no', PLAYWRIGHT_PACKAGE, 'install-browser', engine], { cwd, env });
79
+ steps.push({ display: installBrowserLine(engine), code: fetched.code });
80
+ const check = await starts({ browser, env, cwd });
81
+ return { ...check, steps, ...(check.ok ? {} : { problem: check.problem ?? fetched.said }) };
82
+ }
83
+
38
84
  // Does the browser server start, with its browser? Asked when the switch is turned on, with the
39
85
  // same server a turn gets: the card said «on · chromium» while every turn's server closed before it
40
86
  // spoke, and the agents saw only CONNECTION_CLOSED. It is asked to open about:blank, because a
@@ -93,7 +139,7 @@ export async function playwrightStarts({ browser = 'chromium', env = process.env
93
139
  });
94
140
  child.stderr.on('data', (chunk) => { err += chunk; });
95
141
  child.on('error', (error) => finish({ ok: false, problem: error.message }));
96
- child.on('close', (code) => finish({ ok: false, problem: lastWords(err) ?? t('it exited with code {code} before it spoke', { code }) }));
142
+ child.on('close', (code) => finish({ ok: false, problem: npmReason(err) ?? t('it exited with code {code} before it spoke', { code }) }));
97
143
  send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'madre', version: '1' } } });
98
144
  });
99
145
  }
@@ -143,6 +189,24 @@ export function playwrightServerFor({ port, outputDir, browser = 'chromium', hea
143
189
  };
144
190
  }
145
191
 
192
+ async function playwrightReady(ctx) {
193
+ const browser = ctx.settings.browser ?? 'chromium';
194
+ let check = await playwrightStarts({ browser, env: ctx.env, cwd: ctx.projectRoot });
195
+ // Something missing: MADRE fetches it, then asks again, before saying anything to the person.
196
+ if (!check.ok && process.platform !== 'win32') {
197
+ await ctx.record('extension.preparing', { id: 'playwright', name: 'PLAYWRIGHT', browser });
198
+ check = await preparePlaywright({ browser, env: ctx.env, cwd: ctx.projectRoot });
199
+ }
200
+ forgetPlaywrightHealth(check, browser);
201
+ if (check.ok) return null;
202
+ // The server is there and only the browser is missing: say which one, and the one line that
203
+ // installs it. Without the server, both lines.
204
+ const version = await playwrightVersion({ env: ctx.env, projectRoot: ctx.projectRoot });
205
+ return { status: 412, body: { error: version
206
+ ? t('TEST BROWSER stays off: MADRE downloaded the {browser} browser and it still does not start ({problem}). Check your internet connection and turn it on again, or choose another browser in its settings.', { problem: String(check.problem).replace(/[.\s]+$/, ''), browser })
207
+ : t('TEST BROWSER stays off: MADRE could not install its browser server ({problem}). Check your internet connection and turn it on again.', { problem: String(check.problem).replace(/[.\s]+$/, '') }), code: 'MU-054' } };
208
+ }
209
+
146
210
  export default defineModule({
147
211
  id: 'playwright',
148
212
  name: 'Test browser',
@@ -163,7 +227,7 @@ export default defineModule({
163
227
  return {
164
228
  runs: [{ name: PLAYWRIGHT_PACKAGE, version }],
165
229
  settings: { browser: ctx.settings.browser ?? 'chromium', headless: ctx.settings.headless !== false },
166
- status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? (!version ? t('on · the browser server is not installed') : health?.ok === false ? t('on · the browser does not start: {problem}. Install it with {command}.', { problem: String(health.problem).replace(/[.\s]+$/, '').slice(0, 160), command: installBrowserLine(ctx.settings.browser) }) : `${t('on')} · ${ctx.settings.browser}`) : version ? t('off') : t('off · the browser server is not installed') },
230
+ status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? (!version ? t('on · the browser server is not installed') : health?.ok === false ? t('on · the browser does not start: {problem}. Turn it off and on again: MADRE downloads what is missing.', { problem: String(health.problem).replace(/[.\s]+$/, '').slice(0, 160) }) : `${t('on')} · ${ctx.settings.browser}`) : version ? t('off') : t('off · the browser server is not installed') },
167
231
  preflight: version ? { ok: true, problems: [] } : { ok: false, problems: [`Install the browser server first: npm install -g @playwright/mcp, then its browser: ${installBrowserLine(ctx.settings.browser)}`] },
168
232
  install: { display: ctx.settings.enabled ? 'disable PLAYWRIGHT' : 'enable PLAYWRIGHT (config.json)', platforms: ['codex', 'claude', 'gemini', 'opencode'] },
169
233
  };
@@ -192,21 +256,15 @@ export default defineModule({
192
256
  const outputDir = turn.scratchDir ?? join(turn.roomDir ?? ctx.stateRoot, 'playwright');
193
257
  return [playwrightServerFor({ port: turn.port, outputDir, browser: ctx.settings.browser ?? 'chromium', headless: ctx.settings.headless !== false, servers: turn.servers ?? [] })];
194
258
  },
195
- // The switch, guarded: on only if the server starts and has its browser. Off is always allowed.
259
+ // Before it turns on, in one project or in all: the server starts and has its browser, or MADRE
260
+ // fetches what is missing and asks again. `null` is ready; anything else is the refusal.
261
+ ready: playwrightReady,
262
+ // The switch, guarded: on only if it is ready. Off is always allowed. A project's switch is that
263
+ // project's (module-scope.mjs).
196
264
  async toggle(ctx) {
197
265
  const enabled = !ctx.settings.enabled;
198
- if (enabled) {
199
- const browser = ctx.settings.browser ?? 'chromium';
200
- const check = await playwrightStarts({ browser, env: ctx.env, cwd: ctx.projectRoot });
201
- forgetPlaywrightHealth(check, browser);
202
- // The server is there and only the browser is missing: say which one, and the one line that
203
- // installs it. Without the server, both lines.
204
- const version = await playwrightVersion({ env: ctx.env, projectRoot: ctx.projectRoot });
205
- if (!check.ok) return { status: 412, body: { error: version
206
- ? t('TEST BROWSER did not start, so it stays off: {problem}. The browser server is installed; what is missing is the {browser} browser. Install it with {command} and turn it on again, or choose another browser in its settings.', { problem: String(check.problem).replace(/[.\s]+$/, ''), browser, command: installBrowserLine(browser) })
207
- : t('TEST BROWSER did not start, so it stays off: {problem}. Install the server and its browser ({server}, then {command}) and turn it on again.', { problem: String(check.problem).replace(/[.\s]+$/, ''), server: `npm install -g ${PLAYWRIGHT_PACKAGE}`, command: installBrowserLine(browser) }), code: 'MU-054' } };
208
- }
209
- await ctx.updateConfig({ modules: { playwright: { ...(ctx.config.modules?.playwright ?? {}), enabled } } });
266
+ if (enabled) { const refused = await playwrightReady(ctx); if (refused) return refused; }
267
+ await ctx.updateConfig({ modules: { playwright: switchIn(ctx.config.modules?.playwright ?? {}, ctx.roomId ?? null, enabled) } });
210
268
  await ctx.record('extension.toggled', { id: 'playwright', name: 'PLAYWRIGHT', enabled });
211
269
  return { status: 200, body: { enabled } };
212
270
  },
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { readFile, stat } from 'node:fs/promises';
21
21
  import { forgetSecret, readSecret, summary as vaultSummary, writeSecret } from '../vault.mjs';
22
+ import { moduleOnIn, roomsOf, scopeIn, settingsIn, switchIn } from '../module-scope.mjs';
22
23
 
23
24
  const camel = (id) => id.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
24
25
 
@@ -67,7 +68,7 @@ export function dependencies(runs) {
67
68
  return list.filter((dep) => dep && dep.name).map((dep) => ({ name: String(dep.name), version: dep.version ?? null, target: dep.target ?? null }));
68
69
  }
69
70
 
70
- // Every field this contract answers to. Thirty-four of them and all but two optional, which is
71
+ // Every field this contract answers to. Thirty-eight of them and all but two optional, which is
71
72
  // generous until you misspell one: `sumary` used to install without a word and leave a card with
72
73
  // an empty summary. A key MADRE does not know is not fatal — a module written for a newer MADRE
73
74
  // may carry fields this one has not learned yet — so it is said out loud instead of refused.
@@ -76,10 +77,31 @@ export const MODULE_FIELDS = new Set([
76
77
  'requires', 'models', 'commands', 'card', 'configKey', 'settings', 'controls', 'routes', 'reaches',
77
78
  'conditions', 'slash', 'status', 'preflight', 'detect', 'confirm', 'installCommand', 'secrets',
78
79
  'updatePlan', 'toolsForTurn', 'toggle', 'onToggle', 'onSettings', 'onEvent', 'toggledEvent',
79
- 'toggledBody', 'describe',
80
+ 'toggledBody', 'describe', 'ready', 'footer',
80
81
  ]);
81
82
  export const unknownFields = (spec) => Object.keys(spec ?? {}).filter((key) => !MODULE_FIELDS.has(key));
82
83
 
84
+ // A key beside MADRE's version (2026-10-18): what a module may put on the page, and nothing more.
85
+ // MADRE draws it — one of its own icons, the module's words, an https address that opens in a new
86
+ // tab — so a module never reaches into the page itself.
87
+ export const FOOTER_ICONS = ['heart', 'star', 'link'];
88
+ const footerWords = (value) => {
89
+ if (typeof value === 'string') return value.slice(0, 160);
90
+ if (!value || typeof value !== 'object') return null;
91
+ const out = {};
92
+ for (const lang of ['en', 'es']) if (typeof value[lang] === 'string') out[lang] = value[lang].slice(0, 160);
93
+ return Object.keys(out).length ? out : null;
94
+ };
95
+ function footerKeys(spec) {
96
+ return (Array.isArray(spec.footer) ? spec.footer : []).slice(0, 2).map((key) => {
97
+ let url = null;
98
+ try { url = new URL(String(key?.url ?? '')); } catch { /* said below */ }
99
+ if (url?.protocol !== 'https:') throw new Error(`Module ${spec.id}: a key beside the version needs an https address.`);
100
+ if (!footerWords(key.label)) throw new Error(`Module ${spec.id}: a key beside the version needs a label.`);
101
+ return { icon: FOOTER_ICONS.includes(key.icon) ? key.icon : 'link', label: footerWords(key.label), text: footerWords(key.text), action: footerWords(key.action), url: url.href };
102
+ });
103
+ }
104
+
83
105
  export function defineModule(spec) {
84
106
  if (!spec?.id || !/^[a-z][a-z0-9-]*$/.test(spec.id)) throw new Error(`Module id must be kebab-case: ${spec?.id}`);
85
107
  if (!spec.name) throw new Error(`Module ${spec.id} needs a name.`);
@@ -97,6 +119,8 @@ export function defineModule(spec) {
97
119
  // lives and MADRE can go and get it: the file is fetched, checked the same way an upload is,
98
120
  // and only replaces the one installed if it passes and says it is newer.
99
121
  updates: spec.updates?.url && /^https:\/\//.test(String(spec.updates.url)) ? { url: String(spec.updates.url) } : null,
122
+ // Keys beside MADRE's version, drawn by MADRE while the module is on (footerKeys).
123
+ footer: footerKeys(spec),
100
124
  summary: spec.summary ?? '', creates: spec.creates ?? [], requires: spec.requires ?? [], models: spec.models ?? [], commands: spec.commands ?? (spec.slash?.length ? spec.slash.map((command) => command.usage ?? `/${command.name}`) : undefined),
101
125
  card: spec.card ?? (kind === 'builtin' ? 'switch' : 'installer'),
102
126
  // The settings floor of the card, declared instead of drawn: MADRE renders these and saves
@@ -133,8 +157,14 @@ export function defineModule(spec) {
133
157
  return { name: secret.name, label: secret.label ?? secret.name.toUpperCase(), note: secret.note ?? '', where: secret.where ?? '' };
134
158
  }),
135
159
  };
136
- const settingsFrom = (config) => ({ ...defaults, ...(config?.modules?.[configKey] ?? {}) });
160
+ // A project reads its own word on `enabled` when it said one (module-scope.mjs).
161
+ const settingsFrom = (config, roomId = null) => ({ ...defaults, ...settingsIn(config?.modules?.[configKey] ?? {}, roomId) });
137
162
  const mine = (ctx, settings) => ({ ...ctx, settings, vault: vaultFor(spec.id, ctx.stateRoot) });
163
+ const scopeHere = (ctx) => {
164
+ const block = ctx.config?.modules?.[configKey] ?? {};
165
+ const own = ctx.roomId ? roomsOf(block)[ctx.roomId] : undefined;
166
+ return { all: typeof block.enabled === 'boolean' ? block.enabled : Boolean(defaults.enabled), here: typeof own === 'boolean' ? own : null };
167
+ };
138
168
  const module = {
139
169
  ...base,
140
170
  configKey,
@@ -152,14 +182,14 @@ export function defineModule(spec) {
152
182
  }),
153
183
  // Tools for a turn, only while the module is on. Failures never break a turn.
154
184
  toolsForTurn: spec.toolsForTurn ? async (ctx, turn) => {
155
- const settings = settingsFrom(ctx.config);
185
+ const settings = settingsFrom(ctx.config, ctx.roomId);
156
186
  if (kind === 'builtin' && !settings.enabled) return [];
157
187
  try { return (await spec.toolsForTurn(mine(ctx, settings), turn)) ?? []; } catch (error) { console.error(`MADRE module ${spec.id}: toolsForTurn failed: ${error.message}`); return []; }
158
188
  } : null,
159
189
  // How the outside thing this module drives gets a newer version onto this computer. MADRE
160
190
  // shows the command before it runs and never runs one the human has not read. A module that
161
191
  // cannot update what it drives returns the note that says where to get it instead.
162
- updatePlan: spec.updatePlan ? async (ctx, what) => spec.updatePlan(mine(ctx, settingsFrom(ctx.config)), what) : null,
192
+ updatePlan: spec.updatePlan ? async (ctx, what) => spec.updatePlan(mine(ctx, settingsFrom(ctx.config, ctx.roomId)), what) : null,
163
193
  // Legacy installer hooks, kept on the object so the confirm-and-run path can use them.
164
194
  detect: spec.detect ?? null,
165
195
  preflight: spec.preflight ?? null,
@@ -167,7 +197,7 @@ export function defineModule(spec) {
167
197
 
168
198
  // What MODULES shows: base fields plus status, preflight, install line and whatever the module adds.
169
199
  async describe(ctx) {
170
- const settings = settingsFrom(ctx.config);
200
+ const settings = settingsFrom(ctx.config, ctx.roomId);
171
201
  const own = spec.status ? await spec.status(mine(ctx, settings)) : {};
172
202
  const installed = own.installed ?? (kind === 'builtin' ? Boolean(settings.enabled) : false);
173
203
  const runs = dependencies(own.runs);
@@ -188,6 +218,8 @@ export function defineModule(spec) {
188
218
  status: own.status ?? { installed, detail: own.detail ?? (installed ? 'on' : 'off') },
189
219
  preflight: own.preflight ?? { ok: true, problems: [] },
190
220
  install: own.install ?? (kind === 'builtin' ? { display: installed ? `disable ${base.name}` : `enable ${base.name} (config.json)`, platforms: [] } : { display: '', platforms: [] }),
221
+ // Whether it is on for every project, and this project's own word when it said one.
222
+ ...(kind === 'builtin' ? { scope: scopeHere(ctx) } : {}),
191
223
  };
192
224
  },
193
225
 
@@ -204,7 +236,7 @@ export function defineModule(spec) {
204
236
  } else {
205
237
  next = String(value ?? '').slice(0, 500);
206
238
  }
207
- const settings = { ...settingsFrom(ctx.config), [key]: next };
239
+ const settings = { ...settingsFrom(ctx.config, ctx.roomId), [key]: next };
208
240
  await ctx.updateConfig({ modules: { [configKey]: { ...(ctx.config.modules?.[configKey] ?? {}), [key]: next } } });
209
241
  if (spec.onSettings) await spec.onSettings(mine(ctx, settings), settings);
210
242
  return { status: 200, body: { settings: Object.fromEntries(base.controls.map((known) => [known.key, settings[known.key] ?? null])) } };
@@ -213,15 +245,35 @@ export function defineModule(spec) {
213
245
  // The switch. Default for builtins: flip `enabled`, persist, tell the room.
214
246
  // A module may guard it (`confirm`) or replace it (`toggle`).
215
247
  toggle: kind === 'builtin' || spec.toggle ? async (ctx, payload = {}) => {
216
- const settings = settingsFrom(ctx.config);
248
+ const settings = settingsFrom(ctx.config, ctx.roomId);
217
249
  if (spec.toggle) return spec.toggle(mine(ctx, settings), payload);
218
250
  const enabled = !settings.enabled;
219
251
  if (enabled && spec.confirm && payload.confirm !== true) return { status: 400, body: { error: spec.confirm } };
220
- await ctx.updateConfig({ modules: { [configKey]: { ...(ctx.config.modules?.[configKey] ?? {}), enabled } } });
252
+ if (enabled && spec.ready) { const refused = await spec.ready(mine(ctx, settings)); if (refused) return refused; }
253
+ // A project's switch is that project's (module-scope.mjs); Home's is every project's.
254
+ await ctx.updateConfig({ modules: { [configKey]: switchIn(ctx.config.modules?.[configKey] ?? {}, ctx.roomId ?? null, enabled) } });
221
255
  const after = spec.onToggle ? await spec.onToggle(mine(ctx, { ...settings, enabled }), enabled) : null;
222
256
  await ctx.record('extension.toggled', { id: spec.id, name: base.name, enabled, ...(spec.toggledEvent ?? {}) });
223
257
  return { status: 200, body: { enabled, ...(after ?? {}), ...(spec.toggledBody?.(enabled) ?? {}) } };
224
258
  } : null,
259
+
260
+ // Home's word on which projects (2026-10-18): `{ all }` for every project, `{ rooms: { id: on } }`
261
+ // for some. Turning it on anywhere asks what turning it on asks (`confirm`, `ready`); the
262
+ // project rooms read the new word on their next turn.
263
+ scope: kind === 'builtin' || spec.toggle ? async (ctx, change = {}) => {
264
+ const block = ctx.config.modules?.[configKey] ?? {};
265
+ const next = scopeIn(block, change);
266
+ const fallback = Boolean(defaults.enabled);
267
+ const turningOn = change.all === true || Object.values(change.rooms ?? {}).some((on) => on === true);
268
+ const settings = settingsFrom(ctx.config, ctx.roomId);
269
+ if (turningOn && spec.confirm && change.confirm !== true) return { status: 400, body: { error: spec.confirm } };
270
+ if (turningOn && spec.ready) { const refused = await spec.ready(mine(ctx, settings)); if (refused) return refused; }
271
+ await ctx.updateConfig({ modules: { [configKey]: next } });
272
+ const here = moduleOnIn(next, ctx.roomId ?? null, fallback);
273
+ if (spec.onToggle && here !== Boolean(settings.enabled)) await spec.onToggle(mine(ctx, { ...settings, enabled: here }), here);
274
+ await ctx.record('extension.toggled', { id: spec.id, name: base.name, enabled: here, scope: { all: typeof next.enabled === 'boolean' ? next.enabled : fallback, rooms: roomsOf(next) } });
275
+ return { status: 200, body: { all: typeof next.enabled === 'boolean' ? next.enabled : fallback, rooms: roomsOf(next) } };
276
+ } : null,
225
277
  };
226
278
  return Object.freeze(module);
227
279
  }
@@ -0,0 +1,18 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
3
+ // What `npx` leaves in MADRE's environment about the call that started it, taken away before MADRE
4
+ // runs anything (2026-10-18).
5
+ //
6
+ // npm hands every process it starts the details of its own run: which package it was asked for
7
+ // (npm_config_package), that it may say yes for you (npm_config_yes), the folder it was typed in
8
+ // (npm_config_local_prefix, INIT_CWD), its own command and script. They are about that one call,
9
+ // and every npm or npx MADRE ran after inherited them: Continuity's installer, started in a
10
+ // project, went to install the package MADRE had been launched from, in the project's folder,
11
+ // and failed. The person's npm configuration (cache, registry, prefix, their npmrc) stays.
12
+ const LAUNCH = /^(npm_config_(package|yes|call|local_prefix|npm_version|user_agent)|npm_command|npm_execpath|npm_node_execpath|npm_lifecycle_event|npm_lifecycle_script|npm_package_json|INIT_CWD)$/i;
13
+
14
+ export function forgetNpxLaunch(env = process.env) {
15
+ const gone = [];
16
+ for (const name of Object.keys(env)) if (LAUNCH.test(name)) { delete env[name]; gone.push(name); }
17
+ return gone;
18
+ }
package/src/ollama.mjs CHANGED
@@ -10,10 +10,11 @@ import { t } from './i18n.mjs';
10
10
  export const DEFAULT_OLLAMA_HOST = 'http://127.0.0.1:11434';
11
11
  // Embedding models we know how to talk to, best first.
12
12
  export const EMBED_MODELS = ['nomic-embed-text', 'mxbai-embed-large', 'snowflake-arctic-embed', 'all-minilm', 'bge-m3'];
13
- // Chat models that follow instructions well enough to distil, best first for a 16 GB machine.
14
- // General chat models first: @madre speaks for the room in the human's language, and coder
15
- // models drift into other voices. A coder is taken only when nothing else is there.
16
- export const CHAT_MODELS = ['qwen2.5:7b', 'llama3.1:8b', 'gemma3:4b', 'qwen2.5:3b', 'llama3.2:3b', 'qwen2.5:1.5b', 'llama3.2:1b', 'qwen2.5-coder:7b', 'qwen2.5-coder:1.5b'];
13
+ // Chat models that follow instructions well enough to distil, best first. The larger Qwen2.5
14
+ // sizes lead: the room only offers one to a computer that runs it (src/modules/local-models.mjs),
15
+ // so when it is there it was pulled for this machine. General chat models before coders: @madre
16
+ // speaks for the room in the human's language, and coder models drift into other voices.
17
+ export const CHAT_MODELS = ['qwen2.5:72b', 'qwen2.5:32b', 'qwen2.5:14b', 'qwen2.5:7b', 'llama3.1:8b', 'gemma3:4b', 'qwen2.5:3b', 'llama3.2:3b', 'qwen2.5:1.5b', 'llama3.2:1b', 'qwen2.5-coder:7b', 'qwen2.5-coder:1.5b'];
17
18
  // Ollama's default window is 4k tokens and it drops the OLDEST text when a prompt overflows:
18
19
  // the system prompt goes first. Every MADRE call asks for a wider window.
19
20
  export const DEFAULT_NUM_CTX = 8192;
@@ -60,7 +61,7 @@ export async function probeOllama({ host = ollamaHost(), fetchImpl = globalThis.
60
61
  // What Ollama itself is running. It answers or it does not; either way the probe goes on.
61
62
  const version = await fetchImpl(`${host}/api/version`, { signal: AbortSignal.timeout(timeoutMs) })
62
63
  .then((answer) => (answer.ok ? answer.json() : null)).then((body) => body?.version ?? null).catch(() => null);
63
- const models = (payload.models ?? []).map((model) => ({ name: model.name, size: model.size ?? 0, family: model.details?.family ?? '', details: model.details ?? {} }));
64
+ const models = (payload.models ?? []).map((model) => ({ name: model.name, size: model.size ?? 0, digest: model.digest ?? null, family: model.details?.family ?? '', details: model.details ?? {} }));
64
65
  const embeds = models.filter(isEmbedModel);
65
66
  const chats = models.filter((model) => !isEmbedModel(model));
66
67
  return {
package/src/outbound.mjs CHANGED
@@ -87,6 +87,14 @@ export const DESTINATIONS = [
87
87
  where: 'Always on: it is how the author knows how many people use MADRE',
88
88
  inside: true,
89
89
  },
90
+ {
91
+ // The Market (src/market-remote.mjs): a signed catalog, and the file of a module you get from it.
92
+ id: 'market', host: 'madre.run', match: (url) => url.pathname.startsWith('/mercado/'), to: "madre.run · the Market",
93
+ what: 'Nothing about you: it asks for the catalog of modules, and for the file of a module you got or that has a newer version. Every file is checked against the author\'s signature before it is used.',
94
+ when: 'when MADRE opens, at most every ten minutes, and when you get a module',
95
+ where: 'Settings › Version and reports › Look for new versions · or PULSE_MARKET=0',
96
+ inside: true,
97
+ },
90
98
  {
91
99
  id: 'usage', host: 'madre.run', match: (url) => url.pathname === '/api/usage', to: "madre.run · the author's usage figures",
92
100
  what: "The author's token, to read how many copies are on. Only in the author's own copy, run from source.",
@@ -323,7 +331,7 @@ export class OutboundLog {
323
331
  export function outboundView({ log = null, state = {}, agents = [] } = {}) {
324
332
  const counts = log?.counts() ?? {};
325
333
  // Said in the room's language (2026-10-12): the list read in English inside a Spanish room.
326
- const vendors = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: t('the provider OpenCode is signed in to') };
334
+ const vendors = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: t('the provider OpenCode is signed in to'), cursor: 'Cursor' };
327
335
  const crew = agents.filter((agent) => agent.detected && !agent.local).map((agent) => `@${agent.id} → ${agent.route?.destination ?? vendors[agent.id] ?? t('its own provider')}`);
328
336
  const destinations = DESTINATIONS.map((one) => ({
329
337
  id: one.id,
package/src/ports.mjs CHANGED
@@ -16,16 +16,16 @@
16
16
  const SYSTEM_PORTS = new Set([22, 25, 53, 88, 139, 445, 548, 631, 5353, 7000]);
17
17
  // Processes that always listen and never serve a page worth previewing.
18
18
  // Ollama is here because MADRE itself uses it (the local model, @madre): started from a project
19
- // folder it would pass for that project's server.
20
- const NOISE = /^(rapportd|sharingd|identityservicesd|controlce|remoted|launchd|mDNSResponder|cupsd|Spotify|Dropbox|Docker|com\.docker|ollama)/i;
19
+ // folder it would pass for that project's server. So is its runner, `llama-server` («llama-ser» in
20
+ // lsof's nine letters): each model it loads listens on a port of its own and closes it when the
21
+ // model is unloaded after a few idle minutes, which read as «your server stopped» (2026-10-17).
22
+ const NOISE = /^(rapportd|sharingd|identityservicesd|controlce|remoted|launchd|mDNSResponder|cupsd|Spotify|Dropbox|Docker|com\.docker|ollama|llama-ser)/i;
21
23
 
22
24
  // One `lsof -nP -iTCP -sTCP:LISTEN` table into the ports a human might want to look at.
23
- // Loopback only: a line bound to * or 0.0.0.0 is reachable from the network, and this room does
24
- // not help anyone frame that by accident.
25
- // Everything listening, split by whether RIPLEY may open it. The wider half is NOT a list of
26
- // things to show as openable — it is the answer to «there is nothing here», which was a lie:
27
- // `node server.js` and `python -m http.server` bind every interface by default, so the most
28
- // common case in the world was being discarded in silence and reported as an empty computer.
25
+ // Everything listening, split by where it listens: loopback, or every interface (* or 0.0.0.0),
26
+ // which is reachable from the network too. `node server.js` and `python -m http.server` bind every
27
+ // interface by default. Since 2026-10-18 the servers sentinel offers a project's own server from
28
+ // either half, opened on 127.0.0.1, and says which ones the network can also reach.
29
29
  export function classifyListening(output = '', { self = null } = {}) {
30
30
  const found = new Map();
31
31
  const wider = new Map();
@@ -113,7 +113,7 @@ export class ControlDesk {
113
113
  reverted = [...restored.restored, ...restored.removed];
114
114
  }
115
115
  const kept = diff.files.filter((file) => !toRevert.has(file.path));
116
- const changes = { checkpointId: run.checkpoint.id, agent: run.agent, messageId: run.messageId, mode: run.mode ?? 3, files: kept, stat: diff.stat, forbiddenReverted: [...controls, ...reverted.filter((path) => diff.forbidden.includes(path) && !mine(path))], existingReverted: additive ? reverted.filter((path) => !diff.forbidden.includes(path)) : [], ...(deleted.size ? { deletesReverted: [...deleted] } : {}) };
116
+ const changes = { checkpointId: run.checkpoint.id, agent: run.agent, messageId: run.messageId, mode: run.mode ?? 3, files: kept, stat: diff.stat, ...(diff.afterTree ? { afterTree: diff.afterTree } : {}), forbiddenReverted: [...controls, ...reverted.filter((path) => diff.forbidden.includes(path) && !mine(path))], existingReverted: additive ? reverted.filter((path) => !diff.forbidden.includes(path)) : [], ...(deleted.size ? { deletesReverted: [...deleted] } : {}) };
117
117
  const count = changes.files.length;
118
118
  const notes = [
119
119
  changes.forbiddenReverted.length ? `${changes.forbiddenReverted.length} write(s) into forbidden zones were reverted.` : null,
@@ -74,6 +74,14 @@ export function sparedChars(options) {
74
74
  return full.find((part) => part.id === 'sdk')?.text.length ?? 0;
75
75
  }
76
76
 
77
+ // Codex has no tool of its own to read a file: it reads with the shell (cat, sed, rg). «You cannot
78
+ // run commands» read to it as «you cannot read», and in #3 it answered «I need #4 for 5 min» to
79
+ // open the file it had been asked to edit (manual tests on 1.0.2, 2026-10-17). Claude and OpenCode
80
+ // read with their own tools and get no shell below #4, so the line is drawn for the shell readers
81
+ // only: reading is part of reading; running programs is what stays out.
82
+ const READS_WITH_SHELL = new Set(['codex']);
83
+ const SHELL_READING = 'Reading includes looking at files with read-only shell commands (cat, sed, head, rg, ls, find, git status, git diff, git log); what you cannot run yourself are programs: tests, builds, installs, scripts, servers, or anything that changes files or reaches the network.';
84
+
77
85
  export function promptParts({
78
86
  agent, text, requester, depth, allowDelegation, context, recall = null, memories = null,
79
87
  attachments = [], references = [], lease = null, scopes = null, imageStudio = null,
@@ -82,6 +90,7 @@ export function promptParts({
82
90
  others = [], delegation = true, maxPlanSteps = 4, scopesFor = () => ({}), motherLines = [], memoryServer = null, controlHolder = null, privacyMarker = '[ENTIDAD-ORG]', madreModel = null, sdk = null,
83
91
  }) {
84
92
  const mayDelegate = allowDelegation && delegation && depth === 0 && others.length > 0;
93
+ const shellReader = READS_WITH_SHELL.has(agent.id);
85
94
  const attached = attachments.length
86
95
  ? `The human attached ${attachments.length} file(s); read them if relevant, they are part of this request:\n${attachments.map((file) => `- ${file.path} (${file.contentType}, ${file.size} bytes)`).join('\n')}`
87
96
  : null;
@@ -103,7 +112,7 @@ export function promptParts({
103
112
  : null },
104
113
  // #3 and #4 used to fall into the last branch and read «Read-only» right above the lease that
105
114
  // gave them the project: with #4 open, Codex answered that it would only inspect (2026-10-14).
106
- { id: 'mode', text: `Permission mode for this turn: #${mode} ${MODES[mode]?.label ?? ''}.${mode === 0 ? ' This exchange is off the record: it is not written to the room transcript, no other agent will see it, and nothing you say here can be referred to later. Do not coordinate with other agents.' : mode === 2 ? ' You may create files, only inside the lease directory described below.' : mode === 3 ? ' You may read, create and modify the project\'s files as described below; you cannot run commands yourself.' : mode === 4 ? ' You may modify the project and run commands as described below.' : ' Read-only: you may read the project and coordinate, not create or modify files.'}${mode >= 1 && mode <= 3 && requester === 'you' ? ` If going on needs a higher mode, ask for it in one line with the mode and how long you need it, like "I need ${mode === 3 ? '#4 for 5 min' : '#3 for 15 min'}: <why>" (#3 holds 5, 15 or 30 min; #4 holds 5): the human gets a button for it.` : ''}` },
115
+ { id: 'mode', text: `Permission mode for this turn: #${mode} ${MODES[mode]?.label ?? ''}.${mode === 0 ? ' This exchange is off the record: it is not written to the room transcript, no other agent will see it, and nothing you say here can be referred to later. Do not coordinate with other agents.' : mode === 2 ? ' You may create files, only inside the lease directory described below.' : mode === 3 ? (shellReader ? ` You may read, create and modify the project's files as described below. ${SHELL_READING}` : ' You may read, create and modify the project\'s files as described below; you cannot run commands yourself.') : mode === 4 ? ' You may modify the project and run commands as described below.' : ' Read-only: you may read the project and coordinate, not create or modify files.'}${mode >= 1 && mode <= 3 && requester === 'you' ? ` If going on needs a higher mode, ask for it in one line with the mode and how long you need it, like "I need ${mode === 3 ? '#4 for 5 min' : '#3 for 15 min'}: <why>" (#3 holds 5, 15 or 30 min; #4 holds 5): the human gets a button for it.` : ''}` },
107
116
  { id: 'inspect', text: lease ? (lease.airlock ? 'Inspect the project as needed; the airlock is open for you this turn, as described below.' : lease.control ? 'Inspect the project as needed; you hold it for this turn, as described below.' : lease.create ? 'Inspect the project as needed; you may add new files to it as described below, never change existing ones.' : 'Inspect the project as needed; the only writable place is the creation lease directory below.') : `Inspect the project only as needed. Operate read-only and do not modify files.${scopes?.web ? '' : ' Do not access the web.'}` },
108
117
  { id: 'ash', text: ash ? 'Ash: answer in compact prose. Say it once, drop the preamble and the summary of what you are about to say, and keep names, negation, numbers, paths, safety details and any ```pulse block exactly as they are. Brevity is in how you write, never in what you leave out.' : null },
109
118
  { id: 'mother', text:
@@ -153,7 +162,9 @@ export function promptParts({
153
162
  : null },
154
163
  { id: 'runs', text: runs
155
164
  ? [
156
- 'You cannot run commands in this mode. If your answer depends on one (tests, a build, a linter, a git query), or you hand the human a command to run, end your reply with one fenced block and the human may run it for you; a command in a plain code block gives them no button:',
165
+ shellReader
166
+ ? 'You cannot run programs in this mode; reading files with read-only shell commands (cat, sed, rg, ls, git diff) is fine. If your answer depends on a program (tests, a build, a linter), or you hand the human a command to run, end your reply with one fenced block and the human may run it for you; a command in a plain code block gives them no button:'
167
+ : 'You cannot run commands in this mode. If your answer depends on one (tests, a build, a linter, a git query), or you hand the human a command to run, end your reply with one fenced block and the human may run it for you; a command in a plain code block gives them no button:',
157
168
  '```pulse-run',
158
169
  '<why you need it, one line>',
159
170
  '$ <command>',
package/src/room.mjs CHANGED
@@ -5,6 +5,7 @@ import { t } from './i18n.mjs';
5
5
  import { invokeClaude, buildClaudeArgs } from './adapters/claude.mjs';
6
6
  import { invokeGemini, buildGeminiArgs } from './adapters/gemini.mjs';
7
7
  import { invokeOpenCode, buildOpenCodeArgs } from './adapters/opencode.mjs';
8
+ import { invokeCursor, buildCursorArgs } from './adapters/cursor.mjs';
8
9
  import { parseMessage } from './router.mjs';
9
10
  import { randomUUID } from 'node:crypto';
10
11
  import { basename } from 'node:path';
@@ -64,6 +65,7 @@ const defaultInvokers = {
64
65
  'claude-readonly': invokeClaude,
65
66
  'gemini-readonly': invokeGemini,
66
67
  'opencode-readonly': invokeOpenCode,
68
+ 'cursor-readonly': invokeCursor,
67
69
  };
68
70
 
69
71
  // What a turn costs against MADRE's local budget. Cache reads are close to
@@ -831,6 +833,7 @@ export class Room {
831
833
  if (model) return model;
832
834
  if (agentId === 'codex') return (await readCodexEfforts({ home: this.#codexHome }).catch(() => null))?.model ?? null;
833
835
  if (agentId === 'opencode') return process.env.PULSE_OPENCODE_MODEL || null;
836
+ if (agentId === 'cursor') return process.env.PULSE_CURSOR_MODEL || null;
834
837
  return null;
835
838
  }
836
839
 
@@ -1349,7 +1352,8 @@ export class Room {
1349
1352
  const said = events.find((event) => event.type === 'message.created' && event.payload.role === 'assistant' && event.payload.sender === agent && event.payload.status !== 'delegated');
1350
1353
  if (said) return { agent, sequence: said.sequence, text: said.payload.text ?? '' };
1351
1354
  const failed = events.find((event) => event.type === 'message.failed' && event.payload.target === agent);
1352
- return { agent, failed: true, error: failed ? String(failed.payload.error ?? '').slice(0, 160) : null };
1355
+ // Whole, so the closing turn can say it in the round's language and cut it at a sentence.
1356
+ return { agent, failed: true, error: failed ? String(failed.payload.error ?? '').slice(0, 600) : null };
1353
1357
  });
1354
1358
  return { asked, answers };
1355
1359
  }
@@ -1643,16 +1647,19 @@ export class Room {
1643
1647
  : agent.id === 'claude' ? buildClaudeArgs({ ...shared, attachmentsDir: null, imageStudio: null })
1644
1648
  : agent.id === 'gemini' ? buildGeminiArgs({ ...shared, policyPath: '<written for this turn, deleted after it>', attachmentsDir: null })
1645
1649
  : agent.id === 'opencode' ? buildOpenCodeArgs(shared)
1646
- : null;
1650
+ : agent.id === 'cursor' ? buildCursorArgs(shared)
1651
+ : null;
1647
1652
  if (!args) return { agent: agent.id, label: agent.label, local: false, says: `MADRE has no adapter for @${agent.id}.`, mcpServers: [], env: [], isolation: [] };
1648
1653
  const isolation = {
1649
1654
  codex: ['--ephemeral: the run keeps no session of its own, so nothing said here reaches another project.'],
1650
1655
  claude: ['--no-session-persistence: the run keeps no session of its own.', 'Only the project\'s own CLAUDE.md and MADRE\'s briefing are read; hooks, plugins and outside MCP servers are not.'],
1651
1656
  gemini: ['GEMINI_CLI_HOME points at a temporary home made for this turn and removed after it, so settings, hooks and extensions come from there and not from yours.', 'GEMINI_CLI_NO_RELAUNCH keeps the launcher from spawning a second process MADRE could not stop.'],
1652
1657
  opencode: ['The run is given the model you chose and nothing else of your session.'],
1658
+ cursor: ['--mode ask: Cursor\'s read-only mode, whatever its own settings say; no edits and no commands.', 'CURSOR_CONFIG_DIR points at a configuration made for this turn and removed after it: every command and every write denied. The sign-in stays in the system keychain.'],
1653
1659
  }[agent.id] ?? [];
1654
1660
  const env = {
1655
1661
  gemini: [{ name: 'GEMINI_CLI_HOME', note: 'the temporary home for this turn' }, { name: 'GEMINI_CLI_NO_RELAUNCH', note: 'so the launcher stays killable' }],
1662
+ cursor: [{ name: 'CURSOR_CONFIG_DIR', note: 'the configuration for this turn: read only' }, { name: 'NO_OPEN_BROWSER', note: 'a turn never opens a page' }],
1656
1663
  }[agent.id] ?? [];
1657
1664
  return {
1658
1665
  agent: agent.id, label: agent.label, local: false,
@@ -1875,7 +1882,9 @@ export class Room {
1875
1882
  // not allow simply fails and the model reports the file as created. The room does not read
1876
1883
  // the prose for a claim; it takes the paths the reply names and asks the disk. Only what is
1877
1884
  // named AND absent is said, so an agent talking about files that exist says nothing here.
1878
- if (lease && !lease.control && artifacts.length === 0) {
1885
+ // A reply that opens a plan names the files its steps will make (manual tests, 2026-10-17:
1886
+ // «Claude arma el plan, Codex escribe memoria.html» read as a file Claude failed to write).
1887
+ if (lease && !lease.control && artifacts.length === 0 && !/```pulse\s*\n/.test(result.text ?? '')) {
1879
1888
  const roots = [this.#projectRoot, lease.outDir, lease.realOutDir].filter(Boolean);
1880
1889
  const named = namedPaths(result.text, { roots });
1881
1890
  const missing = [];
@@ -1883,7 +1892,8 @@ export class Room {
1883
1892
  const full = path.startsWith('/') ? path : joinPath(this.#projectRoot, path);
1884
1893
  if (!(await statFile(full).then(() => true, () => false))) missing.push(path);
1885
1894
  }
1886
- if (missing.length) await this.#emit('create.missing', { messageId: responseMessageId, agent: agent.id, planId, files: missing });
1895
+ // `requestId` lets the page put the same request back in the box for another try.
1896
+ if (missing.length) await this.#emit('create.missing', { messageId: responseMessageId, requestId: messageId, agent: agent.id, planId, files: missing });
1887
1897
  }
1888
1898
  // CONTROL: what really changed in the project, forbidden zones reverted on the spot.
1889
1899
  const controlChanges = controlRun ? await this.#controlDesk.settleOrSay(controlRun) : null;
package/src/router.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
3
- const supported = new Set(['codex', 'claude', 'gemini', 'opencode']);
3
+ const supported = new Set(['codex', 'claude', 'gemini', 'opencode', 'cursor']);
4
4
 
5
5
  export function parseMessage(text, fallbackTarget = null) {
6
6
  const value = String(text ?? '').trim();
package/src/runs.mjs CHANGED
@@ -29,7 +29,10 @@ import { join } from 'node:path';
29
29
 
30
30
  export const MAX_COMMANDS = 5;
31
31
  const MAX_REASON = 300;
32
- const MAX_LINE = 500;
32
+ // A line has to be read before it is pressed, and the card shows it whole however long it is
33
+ // (public/app.js, renderRunRequest). The cap is only a guard against a runaway reply: a one-liner a
34
+ // few hundred characters long (`npx tsx -e "…"`, a long `node -p`) is still a command to run.
35
+ const MAX_LINE = 4000;
33
36
 
34
37
  // Shell builtins: without a shell they are not programs, or they are programs that change nothing.
35
38
  const BUILTINS = new Set(['cd', 'export', 'source', '.', 'alias', 'unset', 'set', 'eval', 'exec', 'pushd', 'popd']);
@@ -38,6 +38,15 @@ const definitions = [
38
38
  versionArgs: ['--version'],
39
39
  adapter: 'opencode-readonly',
40
40
  },
41
+ {
42
+ // Cursor's agent CLI (2026-10-17). Its installer puts it in ~/.local/bin, which is not always
43
+ // on the PATH a room is started with.
44
+ id: 'cursor',
45
+ label: 'Cursor',
46
+ candidates: ['cursor-agent', join(homedir(), '.local', 'bin', 'cursor-agent')],
47
+ versionArgs: ['--version'],
48
+ adapter: 'cursor-readonly',
49
+ },
41
50
  ];
42
51
 
43
52
  const OFFICIAL_DESTINATIONS = {
package/src/salas.mjs CHANGED
@@ -189,6 +189,32 @@ export async function associateProject({ root, home, stateRoot, now = new Date()
189
189
  return { id, name: basename(folder), root: folder, already: Boolean(before) };
190
190
  }
191
191
 
192
+ // A new project, from nothing (2026-10-17): a folder made where the person chose, inside the home
193
+ // folder, and added to Home like any other. The name is held to what any system refuses in a folder
194
+ // name, the strictest set (separators, reserved characters, a trailing dot or space, device names),
195
+ // so a name is only ever one folder, never a path, a device or a hidden folder.
196
+ const DEVICE_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
197
+ export function projectNameProblem(name) {
198
+ const text = String(name ?? '');
199
+ if (!text.trim()) return 'empty';
200
+ if (text.length > 120) return 'long';
201
+ if (/[<>:"/\\|?*\u0000-\u001f]/.test(text) || text !== text.trim() || /[. ]$/.test(text) || text.startsWith('.') || DEVICE_NAMES.test(text)) return 'name';
202
+ return null;
203
+ }
204
+ export async function createProject({ name, parent = null, home, stateRoot, now = new Date() }) {
205
+ const problem = projectNameProblem(name);
206
+ if (problem) return { error: problem };
207
+ const top = realFolder(home);
208
+ const place = realFolder(parent || home);
209
+ if (!inside(place, top)) return { error: 'outside' };
210
+ if (inside(place, realFolder(stateRoot))) return { error: 'madre' };
211
+ if (!(await stat(place).catch(() => null))?.isDirectory()) return { error: 'missing' };
212
+ const folder = join(place, String(name));
213
+ if (dirname(folder) !== place) return { error: 'name' };
214
+ try { await mkdir(folder); } catch (error) { return { error: error?.code === 'EEXIST' ? 'exists' : 'missing' }; }
215
+ return { ...(await associateProject({ root: folder, home, stateRoot, now })), created: true };
216
+ }
217
+
192
218
  // Takes a project out of MADRE (2026-10-10): its record of where it is goes aside, so Home no
193
219
  // longer lists it. Nothing in the project's folder is touched, and its conversations and its
194
220
  // memory stay beside the record: added again, the folder gets the same id and finds them there.