@jossuealcala/madre 0.4.0 → 0.5.0

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.
@@ -25,6 +25,36 @@ export function terminateProcessTree(child, { graceMs = 2000 } = {}) {
25
25
  child.once('close', () => clearTimeout(escalation));
26
26
  }
27
27
 
28
+ // A number is not a reason. A process that was signalled reports it two ways — node hands the
29
+ // signal itself when it killed the child, and a CLI that handles SIGTERM exits 128+n on its own —
30
+ // and both used to surface as a bare `exited with code 143`, which reads like a crash and is not
31
+ // one: something STOPPED it. Naming the signal is the difference between looking for a bug in the
32
+ // agent and looking for whoever pulled the plug.
33
+ const SIGNAL_MEANING = {
34
+ SIGTERM: 'was stopped (SIGTERM) — something asked it to quit: STOP ALL, a timeout, or the room shutting down',
35
+ SIGKILL: 'was killed outright (SIGKILL) — usually this machine running out of memory',
36
+ SIGINT: 'was interrupted (SIGINT) — a Ctrl+C reached it',
37
+ SIGHUP: 'lost its terminal (SIGHUP)',
38
+ };
39
+ function endedBy(code, closedBy) {
40
+ const name = closedBy ?? (Number.isInteger(code) && code > 128 ? Object.keys(SIGNAL_MEANING).find((key) => code === 128 + { SIGHUP: 1, SIGINT: 2, SIGKILL: 9, SIGTERM: 15 }[key]) : null);
41
+ if (name && SIGNAL_MEANING[name]) return `${SIGNAL_MEANING[name]}.`;
42
+ if (name) return `ended on ${name}.`;
43
+ return `exited with code ${code}.`;
44
+ }
45
+
46
+ // One explanation for a process that never ran, whichever way spawn refused. ENOENT is the
47
+ // ambiguous one: node says it for a missing binary and for a missing working directory alike,
48
+ // so the sentence names both instead of guessing which.
49
+ function couldNotStart(error, { label, executable, cwd }) {
50
+ if (error?.code === 'ENOENT') {
51
+ error.message = `${label} could not start: ${error.message}. Check that the project folder ${cwd} exists and that ${executable} is still installed — a CLI that updates itself can move out from under a room that is already open, and reopening it finds the new one.`;
52
+ } else if (error && !String(error.message ?? '').startsWith(label)) {
53
+ error.message = `${label} could not start: ${error.message}`;
54
+ }
55
+ return error;
56
+ }
57
+
28
58
  export function runReadonlyProcess({
29
59
  executable,
30
60
  args,
@@ -52,12 +82,23 @@ export function runReadonlyProcess({
52
82
  reject(new Error(t('{label} was interrupted before it started: {why}.', { label, why: typeof signal.reason === 'string' ? signal.reason : t('MADRE is shutting down') })));
53
83
  return;
54
84
  }
55
- const child = spawn(executable, args, {
56
- cwd,
57
- env,
58
- stdio: ['ignore', 'pipe', 'pipe'],
59
- detached: process.platform !== 'win32',
60
- });
85
+ // spawn fails two ways, and only one of them used to be explained. The asynchronous one
86
+ // raises 'error' on the child; the synchronous one throws right here, before there is a
87
+ // child to listen to — a binary replaced underneath a running room by an app that updates
88
+ // itself, an option the platform refuses. That path handed the human a bare `spawn … ENOENT`
89
+ // with nothing to act on, which is how a self-updating CLI looked like a broken product.
90
+ let child;
91
+ try {
92
+ child = spawn(executable, args, {
93
+ cwd,
94
+ env,
95
+ stdio: ['ignore', 'pipe', 'pipe'],
96
+ detached: process.platform !== 'win32',
97
+ });
98
+ } catch (error) {
99
+ reject(couldNotStart(error, { label, executable, cwd }));
100
+ return;
101
+ }
61
102
  let stdout = '';
62
103
  let stderr = '';
63
104
  let settled = false;
@@ -128,17 +169,23 @@ export function runReadonlyProcess({
128
169
  finish(() => reject(verdict));
129
170
  });
130
171
  armIdle();
131
- child.on('error', (error) => finish(() => {
132
- // spawn reports ENOENT for a missing cwd as well as a missing binary.
133
- if (error.code === 'ENOENT') {
134
- error.message = `${label} could not start: ${error.message}. Check that the project folder ${cwd} exists and that ${executable} is still installed.`;
135
- }
136
- reject(error);
137
- }));
138
- child.on('close', (code) => finish(() => {
172
+ child.on('error', (error) => finish(() => reject(couldNotStart(error, { label, executable, cwd }))));
173
+ child.on('close', (code, closedBy) => finish(() => {
139
174
  const response = parse(stdout);
140
175
  if (code === 0 && response.text) return resolve(response);
141
- reject(new Error(stderr.trim() || response.error || `${label} exited with code ${code}.`));
176
+ // What happened first, what the CLI printed second, and never one dressed as the other.
177
+ // This used to be `stderr.trim() || …`, so anything a CLI wrote on the way out became the
178
+ // reason: a notice about an unrelated setting, a line saying it was reading stdin. The
179
+ // facts that actually diagnose the turn — the exit code, or a clean exit with nothing to
180
+ // show — were discarded by the `||` and never reached the human. A warning is not a cause.
181
+ const printed = stderr.trim();
182
+ const why = response.error ?? (code === 0
183
+ ? `${label} exited cleanly without an answer.`
184
+ : `${label} ${endedBy(code, closedBy)}`);
185
+ const error = new Error(printed ? `${why}\n\n${label} printed:\n${printed.slice(-600)}` : why);
186
+ error.partialStderr = printed.slice(-2000);
187
+ error.exitCode = code;
188
+ reject(error);
142
189
  }));
143
190
  });
144
191
  }
@@ -9,12 +9,13 @@
9
9
  // Environment:
10
10
  // PULSE_IMAGE_OUT_DIR required, absolute lease directory
11
11
  // PULSE_IMAGE_MODEL default gemini-2.5-flash-image
12
- // GEMINI_API_KEY optional; otherwise the macOS keychain entry the
12
+ // GEMINI_API_KEY optional; otherwise ~/.gemini/.env, then the macOS keychain entry the
13
13
  // Gemini CLI stores (service gemini-cli-api-key)
14
14
  // PULSE_IMAGE_FAKE=1 write a 1×1 PNG without calling Google (tests)
15
15
 
16
16
  import { execFile } from 'node:child_process';
17
- import { mkdir, writeFile, realpath } from 'node:fs/promises';
17
+ import { mkdir, readFile, writeFile, realpath } from 'node:fs/promises';
18
+ import { homedir } from 'node:os';
18
19
  import { basename, extname, join, resolve, sep } from 'node:path';
19
20
  import { promisify } from 'node:util';
20
21
  import { OutboundLog } from '../outbound.mjs';
@@ -25,9 +26,30 @@ const SERVER_VERSION = '0.1.0';
25
26
  const DEFAULT_MODEL = 'gemini-2.5-flash-image';
26
27
  const FAKE_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
27
28
 
29
+ // Where a Gemini key can be, in the order the room should trust them: what this process was
30
+ // given, then the file the CLI reads on every run — which is also the file MADRE itself writes
31
+ // when a key is pasted into CONNECTIONS — and last the macOS keychain, which only exists when
32
+ // the CLI's own sign-in put it there. Leaving the file out meant MADRE saved a key in a place it
33
+ // then refused to look, and on Linux, where there is no keychain, that was the only place it
34
+ // could have been: Image Studio could never find a key there at all.
35
+ async function keyFromGeminiEnvFile(env) {
36
+ const home = env.GEMINI_CLI_HOME ?? homedir();
37
+ let text;
38
+ try { text = await readFile(join(home, '.gemini', '.env'), 'utf8'); } catch { return null; }
39
+ for (const line of text.split('\n')) {
40
+ const match = line.match(/^\s*(?:export\s+)?(GEMINI_API_KEY|GOOGLE_API_KEY)\s*=\s*(.*)$/);
41
+ if (!match) continue;
42
+ const value = match[2].trim().replace(/^(['"])(.*)\1$/, '$2').trim();
43
+ if (value) return value;
44
+ }
45
+ return null;
46
+ }
47
+
28
48
  export async function resolveGeminiKey(env = process.env) {
29
49
  if (env.GEMINI_API_KEY) return env.GEMINI_API_KEY;
30
50
  if (env.GOOGLE_API_KEY) return env.GOOGLE_API_KEY;
51
+ const fromFile = await keyFromGeminiEnvFile(env);
52
+ if (fromFile) return fromFile;
31
53
  if (process.platform !== 'darwin') return null;
32
54
  // The keychain occasionally answers empty under concurrent reads; one retry.
33
55
  for (let attempt = 0; attempt < 2; attempt += 1) {
@@ -13,8 +13,9 @@ export default defineModule({
13
13
  vendor: 'MADRE · Gemini API',
14
14
  version: '1.0.0',
15
15
  summary: 'Gives Gemini CLI, Claude Code and OpenCode an image tool, through a MADRE-owned MCP server on the Gemini image models and your own key and credits.',
16
- creates: ['nothing in the project \u00b7 images land in the lease folder', 'an entry in ~/.pulse/config.json', 'an MCP server per turn, started and stopped by the room', 'attached only inside a creation lease with the image scope on'],
17
- requires: ['a Gemini API key with credits (the one the Gemini CLI stores, or GEMINI_API_KEY)'],
16
+ creates: ['nothing in the project \u00b7 images land in the lease folder', 'an entry in ~/.pulse/config.json', 'an MCP server per turn, started and stopped by the room'],
17
+ // Not something it writes: a condition for it to attach at all, which is what REQUIRES is for.
18
+ requires: ['a Gemini API key with credits (the one the Gemini CLI stores, or GEMINI_API_KEY)', 'attached only inside a creation lease with the image scope on'],
18
19
  models: MODELS,
19
20
  settings: { enabled: false, model: MODELS[0] },
20
21
  card: 'image-studio',
@@ -79,8 +79,20 @@ export async function verifyModuleText({ text, name = null, replace = true }) {
79
79
  try {
80
80
  const probe = join(scratch, name && /\.m?js$/.test(name) ? basename(name) : 'candidate.mjs');
81
81
  await writeFile(probe, text);
82
- const module = checkExternal(await importModuleFile(probe), { replace });
83
- return { id: module.id, name: module.name, vendor: module.vendor, version: module.version ?? null, summary: module.summary ?? '', updates: module.updates ?? null };
82
+ let module;
83
+ try {
84
+ module = checkExternal(await importModuleFile(probe), { replace });
85
+ } catch (error) {
86
+ // The one mistake that looks right and never works: MADRE's own modules import the SDK by
87
+ // relative path because they live inside the package, and anyone — an agent most of all —
88
+ // reading one of them copies that line. Here the file is alone in a scratch folder, so the
89
+ // import cannot resolve and node says only that it cannot find a file. Say what to do.
90
+ if (error?.code === 'ERR_MODULE_NOT_FOUND' || /Cannot find (module|package)/.test(error?.message ?? '')) {
91
+ throw new Error(`${error.message.split('\n')[0]} — a module imports nothing: MADRE loads it from a folder of its own to check it, where no package resolves. Export a plain object, or a function ({ defineModule }) => defineModule({ … }) and MADRE hands you the SDK.`);
92
+ }
93
+ throw error;
94
+ }
95
+ return { id: module.id, name: module.name, vendor: module.vendor, version: module.version ?? null, summary: module.summary ?? '', updates: module.updates ?? null, unknown: module.unknown ?? [] };
84
96
  } finally { await rm(scratch, { recursive: true, force: true, maxRetries: 6, retryDelay: 60 }); }
85
97
  }
86
98
 
@@ -95,7 +107,7 @@ export async function installModuleText({ text, name = null, scope = 'user', sta
95
107
  // Where it came from, so the same place can be asked for a newer one later.
96
108
  if (source) await writeFile(join(dir, `${module.id}.source.json`), JSON.stringify({ ...source, at: new Date().toISOString(), version: module.version ?? null }, null, 2)).catch(() => {});
97
109
  await loadExternalModules({ stateRoot, projectRoot });
98
- return { id: module.id, name: module.name, version: module.version ?? null, file: target, origin: scope === 'project' ? 'project' : 'user', replaced };
110
+ return { id: module.id, name: module.name, version: module.version ?? null, file: target, origin: scope === 'project' ? 'project' : 'user', replaced, unknown: module.unknown ?? [] };
99
111
  }
100
112
 
101
113
  // The same thing from a file already on this computer. `source` must be inside the project or the
@@ -122,9 +134,15 @@ export async function removeExternalModule({ id, stateRoot, projectRoot }) {
122
134
  const module = MODULES.find((known) => known.id === id);
123
135
  if (!module) throw new Error(`No module "${id}".`);
124
136
  if (!module.external) throw new Error(`${module.name} ships with MADRE and cannot be removed; switch it off instead.`);
125
- await unlink(module.file).catch(() => {});
137
+ // The origin record goes first: if it cannot be removed, the executable module file remains
138
+ // installed rather than leaving a live registry entry whose file has already disappeared.
139
+ const files = [...new Set([module.file.replace(/\.(?:mjs|js)$/, '.source.json'), module.file])];
140
+ const removedFiles = [];
141
+ for (const file of files) {
142
+ try { await unlink(file); removedFiles.push(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
143
+ }
126
144
  await loadExternalModules({ stateRoot, projectRoot });
127
- return { id, name: module.name, file: module.file };
145
+ return { id, name: module.name, file: module.file, removedFiles };
128
146
  }
129
147
 
130
148
  export const moduleById = (id) => MODULES.find((module) => module.id === id) ?? null;
@@ -29,7 +29,10 @@ export function playwrightServerFor({ port, outputDir, browser = 'chromium', hea
29
29
  return {
30
30
  name: PLAYWRIGHT_SERVER_NAME,
31
31
  command: 'npx',
32
- args: ['--no', '@playwright/mcp', ...(headless ? ['--headless'] : []), '--isolated', '--browser', browser, '--allowed-origins', `http://127.0.0.1:${port};http://localhost:${port}`, '--blocked-origins', '*', '--output-dir', outputDir, '--no-sandbox'],
32
+ // `--` or npm eats them: without the separator npm takes --headless, --isolated and the
33
+ // rest as its own config, hands the server only their bare values, and the server exits with
34
+ // "too many arguments" before it speaks a word of MCP. The agent sees CONNECTION_CLOSED.
35
+ args: ['--no', '@playwright/mcp', '--', ...(headless ? ['--headless'] : []), '--isolated', '--browser', browser, '--allowed-origins', `http://127.0.0.1:${port};http://localhost:${port}`, '--blocked-origins', '*', '--output-dir', outputDir, '--no-sandbox'],
33
36
  env: {},
34
37
  tools: PLAYWRIGHT_TOOLS,
35
38
  brief: `a headless browser that reaches only this MADRE at http://127.0.0.1:${port}. Open a project file rendered by RIPLEY at http://127.0.0.1:${port}/preview/project/<path>, click, read the console and network, take screenshots (they land in ${outputDir}). Nothing else on the network is reachable through it.`,
@@ -56,7 +59,7 @@ export default defineModule({
56
59
  runs: [{ name: PLAYWRIGHT_PACKAGE, version }],
57
60
  settings: { browser: ctx.settings.browser ?? 'chromium', headless: ctx.settings.headless !== false },
58
61
  status: { installed: Boolean(ctx.settings.enabled), detail: ctx.settings.enabled ? (version ? `${t('on')} · ${ctx.settings.browser}` : t('on · the browser server is not installed')) : version ? t('off') : t('off · the browser server is not installed') },
59
- preflight: version ? { ok: true, problems: [] } : { ok: false, problems: ['Install the browser server first: npm install -g @playwright/mcp && npx playwright install chromium'] },
62
+ preflight: version ? { ok: true, problems: [] } : { ok: false, problems: ['Install the browser server first: npm install -g @playwright/mcp, then its browser: npx @playwright/mcp install-browser chrome-for-testing'] },
60
63
  install: { display: ctx.settings.enabled ? 'disable PLAYWRIGHT' : 'enable PLAYWRIGHT (config.json)', platforms: ['codex', 'claude', 'gemini', 'opencode'] },
61
64
  };
62
65
  },
@@ -80,11 +83,12 @@ export default defineModule({
80
83
  },
81
84
  conditions: [{
82
85
  id: 'playwright-missing',
86
+ code: 'MU-054',
83
87
  severity: 'informational',
84
88
  title: 'PLAYWRIGHT: the browser server is not installed',
85
89
  match: /@playwright\/mcp|playwright.*not (found|installed)|browser server/i,
86
- diagnosis: 'The PLAYWRIGHT module runs @playwright/mcp per turn. It is on, or you tried to open it, but npx cannot find the package without downloading, or no browser is installed.',
87
- remedy: 'Install it once, globally, then RECHECK in MODULES.',
88
- fixes: { darwin: ['npm install -g @playwright/mcp', 'npx playwright install chromium'], linux: ['npm install -g @playwright/mcp', 'npx playwright install --with-deps chromium'] },
90
+ diagnosis: 'The PLAYWRIGHT module runs @playwright/mcp per turn. Two different things have to be on this computer and only one of them is the package: the server, and the browser build that this version of it expects. A server that starts without its browser answers every tool call with "Browser … is not installed", and one that cannot start at all reaches the agent as CONNECTION_CLOSED.',
91
+ remedy: 'Install the server once, globally, then its browser — the browser is downloaded by the server itself, not by the playwright CLI — and RECHECK in MODULES.',
92
+ fixes: { darwin: ['npm install -g @playwright/mcp', 'npx @playwright/mcp install-browser chrome-for-testing'], linux: ['npm install -g @playwright/mcp', 'npx @playwright/mcp install-browser chrome-for-testing'] },
89
93
  }],
90
94
  });
@@ -51,6 +51,19 @@ export function dependencies(runs) {
51
51
  return list.filter((dep) => dep && dep.name).map((dep) => ({ name: String(dep.name), version: dep.version ?? null, target: dep.target ?? null }));
52
52
  }
53
53
 
54
+ // Every field this contract answers to. Thirty-four of them and all but two optional, which is
55
+ // generous until you misspell one: `sumary` used to install without a word and leave a card with
56
+ // an empty summary. A key MADRE does not know is not fatal — a module written for a newer MADRE
57
+ // may carry fields this one has not learned yet — so it is said out loud instead of refused.
58
+ export const MODULE_FIELDS = new Set([
59
+ 'id', 'kind', 'name', 'vendor', 'package', 'version', 'tracks', 'updates', 'summary', 'creates',
60
+ 'requires', 'models', 'commands', 'card', 'configKey', 'settings', 'controls', 'routes',
61
+ 'conditions', 'slash', 'status', 'preflight', 'detect', 'confirm', 'installCommand',
62
+ 'updatePlan', 'toolsForTurn', 'toggle', 'onToggle', 'onSettings', 'onEvent', 'toggledEvent',
63
+ 'toggledBody', 'describe',
64
+ ]);
65
+ export const unknownFields = (spec) => Object.keys(spec ?? {}).filter((key) => !MODULE_FIELDS.has(key));
66
+
54
67
  export function defineModule(spec) {
55
68
  if (!spec?.id || !/^[a-z][a-z0-9-]*$/.test(spec.id)) throw new Error(`Module id must be kebab-case: ${spec?.id}`);
56
69
  if (!spec.name) throw new Error(`Module ${spec.id} needs a name.`);
@@ -59,6 +72,8 @@ export function defineModule(spec) {
59
72
  const defaults = { ...(kind === 'builtin' ? { enabled: false } : {}), ...(spec.settings ?? {}) };
60
73
  const base = {
61
74
  id: spec.id, kind, name: spec.name, vendor: spec.vendor ?? 'MADRE', package: spec.package ?? null, version: spec.version ?? null,
75
+ // Keys this contract does not answer to. Carried so whoever installs the module is told.
76
+ unknown: unknownFields(spec),
62
77
  // What this module's version follows, when it is not its own: { name, npm } or { name, github }.
63
78
  // MADRE reads the version from there and looks for a newer one on its own, once a day.
64
79
  tracks: spec.tracks ? { name: spec.tracks.name ?? spec.tracks.npm ?? spec.tracks.github ?? null, npm: spec.tracks.npm ?? null, github: spec.tracks.github ?? null } : null,
package/src/outbound.mjs CHANGED
@@ -30,7 +30,12 @@ const LOCAL = new Set(['127.0.0.1', 'localhost', '::1', '0.0.0.0', '[::1]']);
30
30
 
31
31
  // Where an unknown condition goes when somebody presses SEND. It lives here because this is the
32
32
  // file that has to name every address MADRE can reach; the room reads it from here.
33
- export const DEFAULT_REPORT_URL = 'https://madre-reports.jossue-alcala-o.workers.dev/v1/reports';
33
+ // The collector, under the product's own name. The address a human reads in the outbound log is
34
+ // part of what they are being asked to trust, and a workers.dev subdomain with an account name in
35
+ // it reads like somebody's scratch endpoint. The old address must keep answering: every room on
36
+ // 0.4.0 and earlier carries it compiled in, and turning it off would stop their reports silently.
37
+ export const DEFAULT_REPORT_URL = 'https://madre.run/v1/reports';
38
+ export const LEGACY_REPORT_URL = 'https://madre-reports.jossue-alcala-o.workers.dev/v1/reports';
34
39
 
35
40
  // The declaration. `what` is the honest sentence: what a request carries, not what it is called.
36
41
  export const DESTINATIONS = [
@@ -264,7 +269,7 @@ export class OutboundLog {
264
269
  export function outboundView({ log = null, state = {}, agents = [] } = {}) {
265
270
  const counts = log?.counts() ?? {};
266
271
  const vendors = { codex: 'OpenAI', claude: 'Anthropic', gemini: 'Google', opencode: 'the provider OpenCode is signed in to' };
267
- const crew = agents.filter((agent) => agent.detected && !agent.local).map((agent) => `@${agent.id} → ${vendors[agent.id] ?? 'its own provider'}`);
272
+ const crew = agents.filter((agent) => agent.detected && !agent.local).map((agent) => `@${agent.id} → ${agent.route?.destination ?? vendors[agent.id] ?? 'its own provider'}`);
268
273
  const destinations = DESTINATIONS.map((one) => ({
269
274
  id: one.id,
270
275
  to: one.id === 'crew' && crew.length ? crew.join(' · ') : one.to,
package/src/room.mjs CHANGED
@@ -945,6 +945,18 @@ export class Room {
945
945
  await this.#emit('message.failed', { messageId, target: targetId, planId, error: `${agent.label} was detected, but its MADRE adapter is not enabled yet.` });
946
946
  return null;
947
947
  }
948
+ if (agent.route?.custom && !agent.route.allowed) {
949
+ const destination = agent.route.host ?? 'an unrecognised host';
950
+ const source = agent.route.source ?? 'a custom endpoint setting';
951
+ const official = agent.id === 'codex' ? 'OpenAI' : agent.id === 'claude' ? 'Anthropic' : 'the agent\'s official service';
952
+ await this.#emit('message.failed', {
953
+ messageId,
954
+ target: targetId,
955
+ planId,
956
+ error: `MADRE blocked @${agent.id} before sending the briefing: ${source} points to ${destination}, not ${official}. Remove that override to use the official service, or set PULSE_ALLOW_CUSTOM_AGENT_ENDPOINTS=${agent.id} only if you trust that endpoint.`,
957
+ });
958
+ return null;
959
+ }
948
960
 
949
961
  const priorEvents = await this.#store.readAll();
950
962
  // @madre never reads its own canned replies back: a small model would echo them.
@@ -38,6 +38,55 @@ const definitions = [
38
38
  },
39
39
  ];
40
40
 
41
+ const OFFICIAL_DESTINATIONS = {
42
+ codex: { label: 'OpenAI', hosts: ['api.openai.com'], variable: 'OPENAI_BASE_URL' },
43
+ claude: { label: 'Anthropic', hosts: ['api.anthropic.com'], variable: 'ANTHROPIC_BASE_URL' },
44
+ };
45
+
46
+ const enabled = (value) => /^(1|true|yes)$/i.test(String(value ?? '').trim());
47
+
48
+ function customRoutesAllowed(agentId, env) {
49
+ const raw = String(env.PULSE_ALLOW_CUSTOM_AGENT_ENDPOINTS ?? '').trim().toLowerCase();
50
+ if (/^(1|true|all)$/.test(raw)) return true;
51
+ return raw.split(',').map((item) => item.trim()).includes(agentId);
52
+ }
53
+
54
+ function routeHost(value) {
55
+ try { return new URL(value).hostname.toLowerCase(); } catch { return null; }
56
+ }
57
+
58
+ // A CLI owns its network connection, but MADRE owns the document handed to that CLI. Name the
59
+ // actual destination without ever returning a credential, path or query string. Unknown base URLs
60
+ // are held at the door until the human explicitly trusts that agent's endpoint.
61
+ export function agentRouteFromEnvironment(agentId, env = process.env) {
62
+ if (agentId === 'claude' && enabled(env.CLAUDE_CODE_USE_BEDROCK)) {
63
+ return { kind: 'provider', destination: 'Amazon Bedrock', source: 'CLAUDE_CODE_USE_BEDROCK', host: null, custom: false, allowed: true };
64
+ }
65
+ if (agentId === 'claude' && enabled(env.CLAUDE_CODE_USE_VERTEX)) {
66
+ return { kind: 'provider', destination: 'Google Vertex AI', source: 'CLAUDE_CODE_USE_VERTEX', host: null, custom: false, allowed: true };
67
+ }
68
+ if (agentId === 'claude' && enabled(env.CLAUDE_CODE_USE_FOUNDRY)) {
69
+ return { kind: 'provider', destination: 'Microsoft Foundry', source: 'CLAUDE_CODE_USE_FOUNDRY', host: null, custom: false, allowed: true };
70
+ }
71
+
72
+ const official = OFFICIAL_DESTINATIONS[agentId];
73
+ if (!official) return null;
74
+ const raw = String(env[official.variable] ?? '').trim();
75
+ if (!raw) return { kind: 'official', destination: official.label, source: null, host: null, custom: false, allowed: true };
76
+ const host = routeHost(raw);
77
+ const isOfficial = Boolean(host && official.hosts.some((candidate) => host === candidate || host.endsWith(`.${candidate}`)));
78
+ if (isOfficial) return { kind: 'official', destination: official.label, source: official.variable, host, custom: false, allowed: true };
79
+ const allowed = customRoutesAllowed(agentId, env);
80
+ return {
81
+ kind: 'custom',
82
+ destination: host ? `custom endpoint · ${host}` : 'custom endpoint',
83
+ source: official.variable,
84
+ host,
85
+ custom: true,
86
+ allowed,
87
+ };
88
+ }
89
+
41
90
  async function executable(path) {
42
91
  try {
43
92
  await access(path, constants.X_OK);
@@ -99,6 +148,7 @@ export async function detectAgents({ env = process.env } = {}) {
99
148
  adapter: definition.adapter ?? null,
100
149
  path,
101
150
  version: path ? await readVersion(path, definition.versionArgs) : null,
151
+ route: agentRouteFromEnvironment(definition.id, env),
102
152
  };
103
153
  }));
104
154
  }
@@ -10,6 +10,12 @@
10
10
  import { createHash } from 'node:crypto';
11
11
  import { homedir, platform, arch, release } from 'node:os';
12
12
  import { diagnose } from '../public/troubleshooting.js';
13
+ import { language } from './i18n.mjs';
14
+
15
+ // The product's own house. The collector lives under it too, so the line a human reads in the
16
+ // outbound log names the product instead of a personal endpoint on someone's cloud account.
17
+ export const HOME_SITE = 'https://madre.run';
18
+ export const REPORT_FORM = { es: `${HOME_SITE}/reportar`, en: `${HOME_SITE}/report` };
13
19
 
14
20
  export const REPORT_WINDOW_MS = 24 * 3600 * 1000;
15
21
  const MAX_ERROR_CHARS = 4000;
@@ -71,7 +77,7 @@ export class ErrorSentinel {
71
77
  this.#save = save;
72
78
  }
73
79
 
74
- settings() { return { ...this.#settings, canSend: Boolean(this.#settings.reportUrl), repo: repoFromPackage(this.#pkg) }; }
80
+ settings() { return { ...this.#settings, canSend: Boolean(this.#settings.reportUrl), repo: repoFromPackage(this.#pkg), home: HOME_SITE }; }
75
81
  async setSettings(patch = {}) {
76
82
  if (typeof patch.autoReport === 'boolean') this.#settings.autoReport = patch.autoReport;
77
83
  if (typeof patch.reportUrl === 'string') this.#settings.reportUrl = patch.reportUrl.trim();
@@ -174,6 +180,18 @@ export class ErrorSentinel {
174
180
  return `https://github.com/${repo}/issues/new?${new URLSearchParams({ title, body, labels: 'sentinel' })}`;
175
181
  }
176
182
 
183
+ // Where a human goes to say something. GitHub is the better home for a report — it is
184
+ // searchable, it deduplicates, and the answer helps whoever hits it next — but it asks for an
185
+ // account first, and somebody who arrived from a post in Spanish will not make one to say "it
186
+ // does not detect Codex". They get the form; the other road stays one click away, both ways.
187
+ formUrl({ about = '' } = {}) {
188
+ const base = REPORT_FORM[language()] ?? REPORT_FORM.en;
189
+ const env = this.environment();
190
+ const query = new URLSearchParams({ v: env.madre, node: env.node, os: env.platform });
191
+ if (about) query.set('about', about.slice(0, 120));
192
+ return `${base}?${query}`;
193
+ }
194
+
177
195
  feedbackUrl({ about = '' } = {}) {
178
196
  const repo = repoFromPackage(this.#pkg);
179
197
  if (!repo) return null;
package/src/server.mjs CHANGED
@@ -18,7 +18,7 @@ import { moduleById, describeModules, findModuleRoute, toolsForTurn as modulesTo
18
18
  import { madreAgent, madreInvoker, MADRE_AGENT_ID, MADRE_ADAPTER } from './adapters/madre.mjs';
19
19
  import { exportDataset, readiness as datasetReadiness } from './dataset.mjs';
20
20
  import { OutboundLog, outboundView, DEFAULT_REPORT_URL } from './outbound.mjs';
21
- import { setLanguage as setRoomLanguage, t } from './i18n.mjs';
21
+ import { language as roomLanguage, setLanguage as setRoomLanguage, t } from './i18n.mjs';
22
22
 
23
23
  const PACKAGE = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8').catch(() => '{}'));
24
24
  let crashHandlersInstalled = false;
@@ -47,6 +47,9 @@ import { applyKey, keyPlanFor } from './credentials.mjs';
47
47
  import { loadConfig as readConfig, updateConfig } from './config.mjs';
48
48
  import { Privacy, normalizeTerms, privacySettings } from './privacy.mjs';
49
49
  import { checkForUpdate, detectInstall, updateCommand, releaseUrl, applyCommand } from './updates.mjs';
50
+
51
+ // Where a person reads the module contract, in the language the room is speaking.
52
+ const SDK_PAGE = { es: 'https://madre.run/sdk/', en: 'https://madre.run/en/sdk/' };
50
53
  import { moduleUpdate } from './modules/updates.mjs';
51
54
  import { adoptStrays, chatIndex, chatLedger, createChat, deleteChat, isChatId, listChats, openChat as openChatIndex, projectFloor, renameChat, touchChat, MAIN_CHAT } from './chats.mjs';
52
55
  import { spawn } from 'node:child_process';
@@ -193,7 +196,7 @@ export async function createPulseServer({
193
196
  const install = detectInstall({ projectRoot: canonicalProjectRoot });
194
197
  async function versionView({ force = false } = {}) {
195
198
  const check = await checkForUpdate({ name: PACKAGE.name, current: PACKAGE.version, cacheFile: join(root, 'updates.json'), fetchImpl: reportFetch, enabled: updatesEnabled(), force });
196
- return { project: canonicalProjectRoot, ...check, name: PACKAGE.name, install, command: updateCommand(install, PACKAGE.name, check.latest ?? 'latest'), release: check.latest ? releaseUrl(PACKAGE.repository, check.latest) : null, envWins: process.env.PULSE_UPDATE_CHECK !== undefined };
199
+ return { project: canonicalProjectRoot, ...check, name: PACKAGE.name, install, command: updateCommand(install, PACKAGE.name, check.latest ?? 'latest'), release: check.latest ? releaseUrl(PACKAGE.repository, check.latest, roomLanguage()) : null, envWins: process.env.PULSE_UPDATE_CHECK !== undefined };
197
200
  }
198
201
  // Keeping the day's cache warm. MODULES is served from what is already on disk, so the looking
199
202
  // happens after the screen is answered, never in front of it: at most once an hour per room,
@@ -250,7 +253,7 @@ export async function createPulseServer({
250
253
  else if (!fifth.ready && index >= 0) { agents.splice(index, 1); changed = true; }
251
254
  if (room) {
252
255
  room.setInvoker(MADRE_ADAPTER, fifth.ready ? madreInvoker({ memory, ollama: () => ({ ...ollama, chatModel: ollama.madreModel ?? ollama.chatModel }), fetchImpl: reportFetch }) : null);
253
- if (changed) await room.record('agents.updated', { agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local) })), removed: fifth.ready ? [] : [MADRE_AGENT_ID], reason: fifth.ready ? `@madre is in the room · ${fifth.version}` : '@madre left the room: Ollama has no chat model running' });
256
+ if (changed) await room.record('agents.updated', { agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local), route: agent.route ?? null })), removed: fifth.ready ? [] : [MADRE_AGENT_ID], reason: fifth.ready ? `@madre is in the room · ${fifth.version}` : '@madre left the room: Ollama has no chat model running' });
254
257
  // That a local model is running is not the same as it being of use on this project, and
255
258
  // the second thing is the one a person needs told. Said once per model, in the room, with
256
259
  // the one button that settles it — the crew line above fires only when the crew CHANGES,
@@ -465,7 +468,7 @@ export async function createPulseServer({
465
468
  const index = agents.findIndex((item) => item.id === next.id);
466
469
  if (index === -1) { agents.push(next); changed = true; continue; }
467
470
  const before = agents[index];
468
- if (before.detected !== next.detected || before.ready !== next.ready || before.version !== next.version || before.path !== next.path) {
471
+ if (before.detected !== next.detected || before.ready !== next.ready || before.version !== next.version || before.path !== next.path || JSON.stringify(before.route ?? null) !== JSON.stringify(next.route ?? null)) {
469
472
  agents[index] = { ...before, ...next };
470
473
  changed = true;
471
474
  }
@@ -473,7 +476,7 @@ export async function createPulseServer({
473
476
  await refreshSessions();
474
477
  if (changed && announce) {
475
478
  await room.record('agents.updated', {
476
- agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local) })),
479
+ agents: agents.map((agent) => ({ id: agent.id, label: agent.label, detected: agent.detected, ready: agent.ready, version: agent.version, local: Boolean(agent.local), route: agent.route ?? null })),
477
480
  removed: [],
478
481
  reason: 'the room looked again for the agents on this computer',
479
482
  });
@@ -846,7 +849,7 @@ export async function createPulseServer({
846
849
  response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
847
850
  return response.end(html);
848
851
  }
849
- if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js'].includes(url.pathname)) {
852
+ if (request.method === 'GET' && ['/app.js', '/brands.js', '/troubleshooting.js', '/inquiry.js', '/i18n.js', '/es.js', '/resay.js', '/syntax.js'].includes(url.pathname)) {
850
853
  const js = await readFile(join(publicDirectory, url.pathname.slice(1)));
851
854
  response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' });
852
855
  return response.end(js);
@@ -860,8 +863,11 @@ export async function createPulseServer({
860
863
  return sendJson(response, 200, {
861
864
  projectRoot,
862
865
  platform: process.platform,
863
- // What language this machine set MADRE to, for a browser that has never chosen.
864
- language: (await readConfig(root)).language === 'en' ? 'en' : 'es',
866
+ // What language this machine set MADRE to, for a browser that has never chosen. It is
867
+ // the language the room is SPEAKING, not the one written in the file: PULSE_LANGUAGE
868
+ // wins over the config here as it does everywhere else, and reading the file again
869
+ // would hand the page a different answer from the one the server itself is using.
870
+ language: roomLanguage(),
865
871
  // Which conversation this page is looking at, and the others it could open.
866
872
  chats: await listChats(roomDir),
867
873
  agents: agents.map((agent) => ({ ...agent, login: loginPlanFor(agent), install: installPlanFor(agent), key: keyPlanFor(agent.id), ...(accountNoteFor(agent.id) ?? {}) })),
@@ -980,7 +986,7 @@ export async function createPulseServer({
980
986
  const designationOk = (given) => typeof given === 'string' && given.trim().toLowerCase() === basename(canonicalProjectRoot).toLowerCase();
981
987
  // Sentinel: reports, settings, the manual road (a prefilled issue) and the automatic one.
982
988
  if (request.method === 'GET' && url.pathname === '/api/sentinel') {
983
- return sendJson(response, 200, { reports: sentinel.reports(), settings: sentinel.settings(), environment: sentinel.environment(), feedbackUrl: sentinel.feedbackUrl() });
989
+ return sendJson(response, 200, { reports: sentinel.reports(), settings: sentinel.settings(), environment: sentinel.environment(), feedbackUrl: sentinel.feedbackUrl(), formUrl: sentinel.formUrl() });
984
990
  }
985
991
  if (request.method === 'POST' && url.pathname === '/api/sentinel/settings') {
986
992
  const patch = await body(request).catch(() => ({}));
@@ -991,8 +997,13 @@ export async function createPulseServer({
991
997
  const sentinelMatch = url.pathname.match(/^\/api\/sentinel\/([a-f0-9]{10})\/(issue|send)$/);
992
998
  if (sentinelMatch && request.method === (sentinelMatch[2] === 'issue' ? 'GET' : 'POST')) {
993
999
  if (sentinelMatch[2] === 'issue') {
994
- const issue = sentinel.issueUrl(sentinelMatch[1]);
995
- return issue ? sendJson(response, 200, { url: issue }) : sendJson(response, 404, { error: t('No such report, or no repository to file it in.') });
1000
+ // Both roads, always: the issue for whoever has an account, the form for whoever does
1001
+ // not. Nobody should hit a login wall while holding a bug report. A report that does
1002
+ // not exist still gets neither: the form does not need the report to be built, so it
1003
+ // would otherwise hand out a link for nothing.
1004
+ const known = sentinel.reports().some((one) => one.id === sentinelMatch[1]);
1005
+ const issue = known ? sentinel.issueUrl(sentinelMatch[1]) : null;
1006
+ return known ? sendJson(response, 200, { url: issue, form: sentinel.formUrl({ about: sentinelMatch[1] }) }) : sendJson(response, 404, { error: t('No such report, or no repository to file it in.') });
996
1007
  }
997
1008
  const outcome = await sentinel.send(sentinelMatch[1]);
998
1009
  return sendJson(response, outcome.ok ? 200 : (outcome.status === 404 || outcome.status === 412 ? outcome.status : 502), outcome);
@@ -1466,7 +1477,7 @@ export async function createPulseServer({
1466
1477
  if (request.method === 'GET' && url.pathname === '/api/extensions') {
1467
1478
  const extensions = await describeModules(await moduleContext());
1468
1479
  void warmModuleUpdates(extensions);
1469
- return sendJson(response, 200, { installing, extensions, failures: loadFailures, folders: moduleFolders({ stateRoot: root, projectRoot: canonicalProjectRoot }), sdk: 'https://github.com/jossuealcacao-exe/madre/blob/main/docs/SDK.md' });
1480
+ return sendJson(response, 200, { installing, extensions, failures: loadFailures, folders: moduleFolders({ stateRoot: root, projectRoot: canonicalProjectRoot }), sdk: SDK_PAGE[roomLanguage()] ?? SDK_PAGE.es });
1470
1481
  }
1471
1482
  // The human installs a module file an agent wrote (or they did): checked first, then copied into the chosen folder.
1472
1483
  if (request.method === 'POST' && url.pathname === '/api/extensions/install-file') {
package/src/updates.mjs CHANGED
@@ -51,7 +51,15 @@ export function applyCommand({ install, name, version, port, projectRoot }) {
51
51
  }
52
52
  }
53
53
 
54
- export function releaseUrl(repository, version) {
54
+ // Where a person reads what a version brought. The product's own house, in the language the room
55
+ // is speaking — a release page on a code host is written for people who already work there, and
56
+ // whoever pressed this button only wanted to know what changed. The repository stays the fallback
57
+ // for a build with no house to point at.
58
+ export const CHANGES_PAGE = { es: 'https://madre.run/cambios/', en: 'https://madre.run/en/changelog/' };
59
+
60
+ export function releaseUrl(repository, version, language = 'es') {
61
+ const page = CHANGES_PAGE[language] ?? CHANGES_PAGE.es;
62
+ if (page) return version ? `${page}#v${version}` : page;
55
63
  const url = String(repository?.url ?? repository ?? '').replace(/^git\+/, '').replace(/\.git$/, '');
56
64
  return url ? `${url}/releases/tag/v${version}` : null;
57
65
  }