@jossuealcala/madre 1.0.2 → 1.0.3
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/CHANGELOG.en.md +69 -1
- package/CHANGELOG.md +69 -1
- package/README.en.md +11 -15
- package/README.md +3 -3
- package/bin/madre.cjs +40 -0
- package/bin/madre.mjs +5 -0
- package/docs/REFERENCE.md +29 -3
- package/docs/SDK.md +4 -2
- package/docs/en/REFERENCE.md +29 -3
- package/docs/en/SDK.md +4 -2
- package/docs/training/README.md +28 -1
- package/docs/training/train.sh +5 -1
- package/package.json +3 -3
- package/public/app.js +1626 -151
- package/public/attribution.js +44 -1
- package/public/brands.js +20 -5
- package/public/code-tools.js +143 -0
- package/public/editor.js +20 -4
- package/public/es.js +393 -25
- package/public/hardware.js +50 -0
- package/public/index.html +5 -1
- package/public/macroverso.js +187 -96
- package/public/market-art.js +161 -0
- package/public/market-view.js +529 -0
- package/public/market.js +189 -0
- package/public/neural.js +110 -0
- package/public/nostromo-engine.js +412 -47
- package/public/power.js +100 -0
- package/public/resay.js +5 -0
- package/public/salas-mac.css +98 -14
- package/public/salas-mac.html +9 -1
- package/public/salas-mac.js +307 -30
- package/public/styles.css +838 -61
- package/public/tour.js +48 -6
- package/public/troubleshooting.js +6 -1
- package/public/wallpaper.js +275 -0
- package/src/adapters/cursor.mjs +96 -0
- package/src/adapters/gemini.mjs +10 -3
- package/src/adapters/madre.mjs +2 -1
- package/src/auth-probe.mjs +143 -12
- package/src/bridge/policy.mjs +1 -1
- package/src/capabilities.mjs +9 -0
- package/src/checkpoint.mjs +16 -1
- package/src/credentials.mjs +3 -0
- package/src/dataset.mjs +76 -4
- package/src/enlaces.mjs +208 -2
- package/src/extensions.mjs +11 -3
- package/src/i18n.mjs +16 -0
- package/src/looks.mjs +125 -2
- package/src/market-key.mjs +8 -0
- package/src/market-remote.mjs +239 -0
- package/src/models.mjs +4 -0
- package/src/module-scope.mjs +68 -0
- package/src/modules/ahp.mjs +102 -7
- package/src/modules/bishop-exam.mjs +122 -0
- package/src/modules/bishop.mjs +71 -12
- package/src/modules/enlaces.mjs +87 -6
- package/src/modules/image-studio.mjs +10 -1
- package/src/modules/intents.mjs +3 -1
- package/src/modules/local-models.mjs +124 -0
- package/src/modules/ollama.mjs +12 -3
- package/src/modules/playwright.mjs +73 -15
- package/src/modules/sdk.mjs +61 -9
- package/src/npm-launch.mjs +18 -0
- package/src/ollama.mjs +6 -5
- package/src/outbound.mjs +9 -1
- package/src/ports.mjs +4 -2
- package/src/room/control.mjs +1 -1
- package/src/room/prompt.mjs +13 -2
- package/src/room.mjs +14 -4
- package/src/router.mjs +1 -1
- package/src/runs.mjs +4 -1
- package/src/runtime-detection.mjs +9 -0
- package/src/salas.mjs +26 -0
- package/src/server.mjs +617 -48
- 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:
|
|
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}.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
},
|
package/src/modules/sdk.mjs
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
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,8 +16,10 @@
|
|
|
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
|
-
|
|
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
25
|
// Loopback only: a line bound to * or 0.0.0.0 is reachable from the network, and this room does
|
package/src/room/control.mjs
CHANGED
|
@@ -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,
|
package/src/room/prompt.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|