@jossuealcala/madre 0.3.3 → 0.4.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +412 -3
  2. package/CONTRIBUTING.md +3 -1
  3. package/README.md +67 -185
  4. package/SECURITY.md +2 -1
  5. package/bin/madre.mjs +56 -13
  6. package/docs/INTERNALS.md +16 -0
  7. package/docs/REFERENCE.md +249 -0
  8. package/docs/SDK.md +121 -0
  9. package/docs/room.png +0 -0
  10. package/docs/sdk/hello-module.mjs +51 -0
  11. package/package.json +9 -1
  12. package/public/app.js +3825 -851
  13. package/public/es.js +2050 -0
  14. package/public/i18n.js +66 -0
  15. package/public/index.html +94 -13
  16. package/public/inquiry.js +220 -0
  17. package/public/resay.js +77 -0
  18. package/public/styles.css +602 -62
  19. package/public/troubleshooting.js +168 -46
  20. package/src/adapters/claude.mjs +2 -1
  21. package/src/adapters/codex.mjs +2 -1
  22. package/src/adapters/gemini.mjs +6 -5
  23. package/src/adapters/opencode.mjs +2 -1
  24. package/src/adapters/process.mjs +17 -5
  25. package/src/asking.mjs +128 -0
  26. package/src/auth-probe.mjs +58 -1
  27. package/src/chats.mjs +193 -0
  28. package/src/checkpoint.mjs +1 -1
  29. package/src/cold.mjs +56 -0
  30. package/src/commands.mjs +6 -0
  31. package/src/conversation-context.mjs +35 -3
  32. package/src/credentials.mjs +145 -0
  33. package/src/dataset.mjs +56 -4
  34. package/src/distiller.mjs +12 -5
  35. package/src/event-store.mjs +14 -8
  36. package/src/exam.mjs +240 -0
  37. package/src/extensions.mjs +3 -2
  38. package/src/eyecat-watch.mjs +100 -0
  39. package/src/eyecat.mjs +169 -0
  40. package/src/i18n.mjs +47 -0
  41. package/src/image-studio.mjs +2 -0
  42. package/src/launch.mjs +61 -0
  43. package/src/maturity.mjs +94 -0
  44. package/src/mcp/image-server.mjs +12 -1
  45. package/src/mcp/memory-server.mjs +1 -1
  46. package/src/memory.mjs +325 -17
  47. package/src/modules/ahp.mjs +9 -7
  48. package/src/modules/ash.mjs +36 -0
  49. package/src/modules/git-pulse.mjs +5 -3
  50. package/src/modules/helpers.mjs +31 -0
  51. package/src/modules/image-studio.mjs +9 -4
  52. package/src/modules/index.mjs +141 -9
  53. package/src/modules/ollama.mjs +66 -10
  54. package/src/modules/playwright.mjs +36 -19
  55. package/src/modules/ripley.mjs +5 -3
  56. package/src/modules/sdk.mjs +93 -2
  57. package/src/modules/updates.mjs +81 -0
  58. package/src/ollama.mjs +5 -2
  59. package/src/outbound.mjs +292 -0
  60. package/src/privacy.mjs +54 -7
  61. package/src/room/context.mjs +4 -4
  62. package/src/room/economy.mjs +161 -0
  63. package/src/room/prompt.mjs +118 -46
  64. package/src/room.mjs +443 -44
  65. package/src/runtime-detection.mjs +27 -8
  66. package/src/server.mjs +699 -69
  67. package/src/setup.mjs +1 -1
  68. package/src/updates.mjs +4 -2
  69. package/src/usage-sentinel.mjs +13 -8
  70. package/src/verdict.mjs +74 -0
  71. package/src/ashcode.mjs +0 -64
  72. package/src/modules/ashcode.mjs +0 -28
@@ -1,51 +1,49 @@
1
+ import { t } from './i18n.js';
2
+
1
3
  // MU/TH/UR knowledge base: known conditions, how to recognise them in the
2
4
  // room's own failure records, and the remedy as terminal commands per OS.
3
5
  //
4
6
  // Pure module (no DOM) so the catalog can be unit-tested and reused by the CLI.
5
7
 
8
+ // The two MADRE is tested on. Every remedy below is a command written for one of these two
9
+ // shells; a third set that nobody has ever run would be worse than none.
6
10
  export const PLATFORMS = {
7
11
  darwin: { label: 'macOS', shell: 'zsh' },
8
12
  linux: { label: 'Linux', shell: 'bash' },
9
- win32: { label: 'Windows', shell: 'PowerShell' },
10
13
  };
11
14
 
12
15
  const envExport = (name, value) => ({
13
16
  darwin: [`export ${name}="${value}"`, `# persist: echo 'export ${name}="${value}"' >> ~/.zshrc`],
14
- linux: [`export ${name}="${value}"`, `# persist: echo 'export ${name}="${value}"' >> ~/.bashrc`],
15
- win32: [`$env:${name}="${value}"`, `# persist: setx ${name} "${value}"`],
17
+ linux: [`export ${name}="${value}"`, `# persist: echo 'export ${name}="${value}"' >> ~/.bashrc`]
16
18
  });
17
19
 
18
20
  const installAgent = {
19
21
  codex: {
20
22
  darwin: ['npm install -g @openai/codex', '# or install the ChatGPT desktop app, which bundles codex'],
21
- linux: ['npm install -g @openai/codex'],
22
- win32: ['npm install -g @openai/codex'],
23
+ linux: ['npm install -g @openai/codex']
23
24
  },
24
25
  claude: {
25
26
  darwin: ['brew install --cask claude-code', '# or: npm install -g @anthropic-ai/claude-code'],
26
- linux: ['npm install -g @anthropic-ai/claude-code'],
27
- win32: ['npm install -g @anthropic-ai/claude-code'],
27
+ linux: ['npm install -g @anthropic-ai/claude-code']
28
28
  },
29
29
  gemini: {
30
30
  darwin: ['npm install -g @google/gemini-cli'],
31
- linux: ['npm install -g @google/gemini-cli'],
32
- win32: ['npm install -g @google/gemini-cli'],
31
+ linux: ['npm install -g @google/gemini-cli']
33
32
  },
34
33
  opencode: {
35
34
  darwin: ['brew install opencode', '# or: npm install -g opencode-ai'],
36
- linux: ['curl -fsSL https://opencode.ai/install | bash', '# or: npm install -g opencode-ai'],
37
- win32: ['npm install -g opencode-ai'],
35
+ linux: ['curl -fsSL https://opencode.ai/install | bash', '# or: npm install -g opencode-ai']
38
36
  },
39
37
  };
40
38
 
41
39
  const loginAgent = {
42
- codex: { darwin: ['codex login', 'codex login status'], linux: ['codex login', 'codex login status'], win32: ['codex login', 'codex login status'] },
43
- claude: { darwin: ['claude auth login', 'claude auth status --text'], linux: ['claude auth login', 'claude auth status --text'], win32: ['claude auth login', 'claude auth status --text'] },
44
- gemini: { darwin: ['gemini', '# inside gemini: /auth → "Use Gemini API key" or Google login'], linux: ['gemini', '# inside gemini: /auth'], win32: ['gemini', '# inside gemini: /auth'] },
45
- opencode: { darwin: ['opencode auth login', 'opencode auth list'], linux: ['opencode auth login', 'opencode auth list'], win32: ['opencode auth login', 'opencode auth list'] },
40
+ codex: { darwin: ['codex login', 'codex login status'], linux: ['codex login', 'codex login status']},
41
+ claude: { darwin: ['claude auth login', 'claude auth status --text'], linux: ['claude auth login', 'claude auth status --text']},
42
+ gemini: { darwin: ['gemini', '# inside gemini: /auth → "Use Gemini API key" or Google login'], linux: ['gemini', '# inside gemini: /auth']},
43
+ opencode: { darwin: ['opencode auth login', 'opencode auth list'], linux: ['opencode auth login', 'opencode auth list']},
46
44
  };
47
45
 
48
- const same = (commands) => ({ darwin: commands, linux: commands, win32: commands });
46
+ const same = (commands) => ({ darwin: commands, linux: commands });
49
47
 
50
48
  export const CONDITIONS = [
51
49
  {
@@ -58,8 +56,7 @@ export const CONDITIONS = [
58
56
  remedy: 'Switch Gemini to an API key from Google AI Studio. MADRE copies only the auth selection into its isolated home, so the key can stay in the keychain or in ~/.gemini/.env.',
59
57
  fixes: {
60
58
  darwin: ['gemini', '# inside gemini: /auth → "Use Gemini API key" and paste the key from https://aistudio.google.com/app/apikey', '# or, without the prompt:', ...envExport('GEMINI_API_KEY', 'YOUR_KEY').darwin],
61
- linux: ['gemini', '# inside gemini: /auth → "Use Gemini API key"', ...envExport('GEMINI_API_KEY', 'YOUR_KEY').linux],
62
- win32: ['gemini', '# inside gemini: /auth → "Use Gemini API key"', ...envExport('GEMINI_API_KEY', 'YOUR_KEY').win32],
59
+ linux: ['gemini', '# inside gemini: /auth → "Use Gemini API key"', ...envExport('GEMINI_API_KEY', 'YOUR_KEY').linux]
63
60
  },
64
61
  },
65
62
  {
@@ -82,8 +79,7 @@ export const CONDITIONS = [
82
79
  remedy: 'Wait a minute and retry, pick an explicit model in the composer (gemini-3-flash-preview skips the router), or raise the key\'s quota in Google AI Studio.',
83
80
  fixes: {
84
81
  darwin: ['# in the room: click the Gemini sphere twice → choose gemini-3-flash-preview', '# check quota: https://aistudio.google.com/app/apikey', 'gemini --model gemini-3-flash-preview -p "ping"'],
85
- linux: ['gemini --model gemini-3-flash-preview -p "ping"', '# quota: https://aistudio.google.com/app/apikey'],
86
- win32: ['gemini --model gemini-3-flash-preview -p "ping"', '# quota: https://aistudio.google.com/app/apikey'],
82
+ linux: ['gemini --model gemini-3-flash-preview -p "ping"', '# quota: https://aistudio.google.com/app/apikey']
87
83
  },
88
84
  },
89
85
  {
@@ -106,8 +102,7 @@ export const CONDITIONS = [
106
102
  remedy: 'Tell MADRE which provider/model OpenCode should use in the room, or remove the stale credential so the default changes.',
107
103
  fixes: {
108
104
  darwin: ['npx @jossuealcala/madre setup', '# press [m] and pick a provider/model that has a session, e.g. openai/gpt-5.6-sol', '# one-off alternative:', 'PULSE_OPENCODE_MODEL=openai/gpt-5.6-sol npx @jossuealcala/madre start', '# or drop the stale key:', 'opencode auth logout anthropic'],
109
- linux: ['npx @jossuealcala/madre setup', '# press [m] and pick a provider/model that has a session', 'PULSE_OPENCODE_MODEL=openai/gpt-5.6-sol npx @jossuealcala/madre start', 'opencode auth logout anthropic'],
110
- win32: ['npx @jossuealcala/madre setup', '# press [m] and pick a provider/model that has a session', '$env:PULSE_OPENCODE_MODEL="openai/gpt-5.6-sol"; npx @jossuealcala/madre start', 'opencode auth logout anthropic'],
105
+ linux: ['npx @jossuealcala/madre setup', '# press [m] and pick a provider/model that has a session', 'PULSE_OPENCODE_MODEL=openai/gpt-5.6-sol npx @jossuealcala/madre start', 'opencode auth logout anthropic']
111
106
  },
112
107
  },
113
108
  {
@@ -116,7 +111,7 @@ export const CONDITIONS = [
116
111
  title: 'Agent installed but signed out',
117
112
  match: /not logged in|sign in required|Error authenticating|unauthorized|please (?:log|sign) ?in|no credentials/i,
118
113
  diagnosis: 'The CLI is on this computer but has no session for its provider. MADRE never stores credentials; each agent keeps its own.',
119
- remedy: 'Sign in with the agent\'s own command. `madre setup` runs these for you and rescans.',
114
+ remedy: 'Press SIGN IN on the agent\'s card: on first contact the room opens on the bridge, and later the same button lives in ⚙ CONNECTIONS. Codex and Claude sign in with a browser flow MADRE runs for you; Gemini and OpenCode hand you their command. `madre setup` does the same from a terminal.',
120
115
  fixes: same(['npx @jossuealcala/madre setup', '# or directly:', 'codex login', 'claude auth login', 'opencode auth login', 'gemini # then /auth']),
121
116
  perAgent: loginAgent,
122
117
  },
@@ -126,7 +121,7 @@ export const CONDITIONS = [
126
121
  title: 'Agent not found on this computer',
127
122
  match: /is not installed on this computer|not found on this computer|ENOENT.*(codex|claude|gemini|opencode)/i,
128
123
  diagnosis: 'MADRE looks for `codex`, `claude`, `gemini` and `opencode` on PATH plus a few known locations. Nothing answered.',
129
- remedy: 'Install the CLI, then reload the room or run `madre setup` → [r].',
124
+ remedy: 'Press INSTALL on the agent\'s card: MADRE shows the exact command, runs it here and looks again when it finishes, no restart. `madre setup` does the same from a terminal.',
130
125
  fixes: same(['# pick the agent you want; see per-agent commands below']),
131
126
  perAgent: installAgent,
132
127
  },
@@ -158,8 +153,7 @@ export const CONDITIONS = [
158
153
  remedy: 'Press the RAISE button on this card, or type a new number in ⚙ CONNECTIONS (DEFAULT TIMEOUT · SECONDS, or the field on the agent\'s card): fields save the moment you leave them. It applies to the next turn and persists in ~/.pulse/config.json. Environment variables work too, but only for a server started after exporting them; a running room never sees a later export.',
159
154
  fixes: {
160
155
  darwin: ['# live, no restart: ⚙ CONNECTIONS → DEFAULT TIMEOUT · SECONDS → SAVE', '# at launch only (env wins over config.json):', 'PULSE_AGENT_TIMEOUT_MS=300000 PULSE_CLAUDE_TIMEOUT_MS=600000 madre start', '# or ~/.pulse/config.json → {"timeouts":{"default":300000,"claude":600000}}'],
161
- linux: ['# live, no restart: ⚙ CONNECTIONS → DEFAULT TIMEOUT · SECONDS → SAVE', 'PULSE_AGENT_TIMEOUT_MS=300000 PULSE_CLAUDE_TIMEOUT_MS=600000 madre start', '# or ~/.pulse/config.json → {"timeouts":{"default":300000,"claude":600000}}'],
162
- win32: ['# live, no restart: ⚙ CONNECTIONS → DEFAULT TIMEOUT · SECONDS → SAVE', '$env:PULSE_AGENT_TIMEOUT_MS="300000"; $env:PULSE_CLAUDE_TIMEOUT_MS="600000"; madre start', '# or %USERPROFILE%\\.pulse\\config.json → {"timeouts":{"default":300000,"claude":600000}}'],
156
+ linux: ['# live, no restart: ⚙ CONNECTIONS → DEFAULT TIMEOUT · SECONDS → SAVE', 'PULSE_AGENT_TIMEOUT_MS=300000 PULSE_CLAUDE_TIMEOUT_MS=600000 madre start', '# or ~/.pulse/config.json → {"timeouts":{"default":300000,"claude":600000}}']
163
157
  },
164
158
  },
165
159
  {
@@ -180,8 +174,7 @@ export const CONDITIONS = [
180
174
  remedy: 'Split the message, or raise the cap if your CLIs cope.',
181
175
  fixes: {
182
176
  darwin: ['export PULSE_MAX_MESSAGE_CHARS="40000"'],
183
- linux: ['export PULSE_MAX_MESSAGE_CHARS="40000"'],
184
- win32: ['$env:PULSE_MAX_MESSAGE_CHARS="40000"'],
177
+ linux: ['export PULSE_MAX_MESSAGE_CHARS="40000"']
185
178
  },
186
179
  },
187
180
  {
@@ -202,8 +195,7 @@ export const CONDITIONS = [
202
195
  remedy: 'Continue with another agent, press RAISE LOCAL BUDGET on this card, or type a number in ⚙ CONNECTIONS → LOCAL TOKEN BUDGET PER AGENT (saves on leaving the field). The provider\'s real limits show in each sphere\'s popover when the CLI reports them.',
203
196
  fixes: {
204
197
  darwin: ['# live: ⚙ CONNECTIONS → LOCAL TOKEN BUDGET PER AGENT → SAVE', '# at launch: PULSE_SOFT_TOKEN_BUDGET=1000000 madre start', '# or ~/.pulse/config.json → {"room":{"softTokenBudget":1000000}}'],
205
- linux: ['# live: ⚙ CONNECTIONS → LOCAL TOKEN BUDGET PER AGENT → SAVE', 'PULSE_SOFT_TOKEN_BUDGET=1000000 madre start'],
206
- win32: ['# live: ⚙ CONNECTIONS → LOCAL TOKEN BUDGET PER AGENT → SAVE', '$env:PULSE_SOFT_TOKEN_BUDGET="1000000"; madre start'],
198
+ linux: ['# live: ⚙ CONNECTIONS → LOCAL TOKEN BUDGET PER AGENT → SAVE', 'PULSE_SOFT_TOKEN_BUDGET=1000000 madre start']
207
199
  },
208
200
  },
209
201
  {
@@ -215,8 +207,7 @@ export const CONDITIONS = [
215
207
  remedy: 'Use a different port, or find and stop the process holding it.',
216
208
  fixes: {
217
209
  darwin: ['npx @jossuealcala/madre start --port 4318', '# who holds 4317?', 'lsof -nP -iTCP:4317 -sTCP:LISTEN'],
218
- linux: ['npx @jossuealcala/madre start --port 4318', 'ss -ltnp | grep 4317'],
219
- win32: ['npx @jossuealcala/madre start --port 4318', 'netstat -ano | findstr :4317', '# then: taskkill /PID <pid> /F'],
210
+ linux: ['npx @jossuealcala/madre start --port 4318', 'ss -ltnp | grep 4317']
220
211
  },
221
212
  },
222
213
  {
@@ -332,8 +323,7 @@ export const CONDITIONS = [
332
323
  remedy: 'Update Node to the current LTS.',
333
324
  fixes: {
334
325
  darwin: ['node --version', 'brew install node', '# or: nvm install --lts'],
335
- linux: ['node --version', 'nvm install --lts', '# or your distro package for Node ≥ 22.5'],
336
- win32: ['node --version', 'winget install OpenJS.NodeJS.LTS'],
326
+ linux: ['node --version', 'nvm install --lts', '# or your distro package for Node ≥ 22.5']
337
327
  },
338
328
  },
339
329
  {
@@ -345,8 +335,7 @@ export const CONDITIONS = [
345
335
  remedy: 'The index is derived from the ledger: delete memory.sqlite (and its -wal/-shm siblings) and restart; MADRE rebuilds it. Distilled notes live in the same file, so export them from NOSTROMO first if they matter.',
346
336
  fixes: {
347
337
  darwin: ['node --version # must be ≥ 22.5', 'ls ~/.pulse/rooms/*/memory.sqlite*', 'rm ~/.pulse/rooms/<room>/memory.sqlite* # rebuilt on next start'],
348
- linux: ['node --version # must be ≥ 22.5', 'ls ~/.pulse/rooms/*/memory.sqlite*', 'rm ~/.pulse/rooms/<room>/memory.sqlite* # rebuilt on next start'],
349
- win32: ['node --version # must be ≥ 22.5', 'dir $env:USERPROFILE\\.pulse\\rooms', 'Remove-Item $env:USERPROFILE\\.pulse\\rooms\\<room>\\memory.sqlite*'],
338
+ linux: ['node --version # must be ≥ 22.5', 'ls ~/.pulse/rooms/*/memory.sqlite*', 'rm ~/.pulse/rooms/<room>/memory.sqlite* # rebuilt on next start']
350
339
  },
351
340
  },
352
341
  {
@@ -376,8 +365,7 @@ export const CONDITIONS = [
376
365
  remedy: 'Sign the Gemini CLI in with an API key (/auth) or export GEMINI_API_KEY, then restart. If you do not want embeddings at all, set PULSE_EMBED=0 and the pause message stops.',
377
366
  fixes: {
378
367
  darwin: ['gemini # /auth → "Use Gemini API key"', ...envExport('GEMINI_API_KEY', 'YOUR_KEY').darwin, 'PULSE_EMBED=0 madre start # lexical only, no messages'],
379
- linux: ['gemini # /auth → "Use Gemini API key"', ...envExport('GEMINI_API_KEY', 'YOUR_KEY').linux, 'PULSE_EMBED=0 madre start'],
380
- win32: ['gemini # /auth → "Use Gemini API key"', ...envExport('GEMINI_API_KEY', 'YOUR_KEY').win32, '$env:PULSE_EMBED="0"; madre start'],
368
+ linux: ['gemini # /auth → "Use Gemini API key"', ...envExport('GEMINI_API_KEY', 'YOUR_KEY').linux, 'PULSE_EMBED=0 madre start']
381
369
  },
382
370
  },
383
371
  {
@@ -389,8 +377,7 @@ export const CONDITIONS = [
389
377
  remedy: 'Update the CLI, then check that it sees the server. Codex can list servers from a config override; Claude accepts an inline --mcp-config. If a CLI keeps failing, PULSE_MEMORY_TOOLS=0 removes the tools for everyone and the room continues on automatic recall.',
390
378
  fixes: {
391
379
  darwin: ['codex mcp list', 'claude --version', 'gemini --version', 'PULSE_MEMORY_TOOLS=0 madre start # tools off, recall stays'],
392
- linux: ['codex mcp list', 'claude --version', 'gemini --version', 'PULSE_MEMORY_TOOLS=0 madre start'],
393
- win32: ['codex mcp list', 'claude --version', 'gemini --version', '$env:PULSE_MEMORY_TOOLS="0"; madre start'],
380
+ linux: ['codex mcp list', 'claude --version', 'gemini --version', 'PULSE_MEMORY_TOOLS=0 madre start']
394
381
  },
395
382
  },
396
383
  {
@@ -429,8 +416,7 @@ export const CONDITIONS = [
429
416
  remedy: 'Install Ollama (ollama.com), start it, then in MODULES press RECHECK and PULL the models it suggests. Set PULSE_OLLAMA_MODEL or PULSE_OLLAMA_EMBED_MODEL to prefer others; PULSE_EMBED_PROVIDER=gemini keeps embeddings on Gemini even with Ollama running.',
430
417
  fixes: {
431
418
  darwin: ['brew install ollama', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b'],
432
- linux: ['curl -fsSL https://ollama.com/install.sh | sh', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b'],
433
- win32: ['winget install Ollama.Ollama', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b'],
419
+ linux: ['curl -fsSL https://ollama.com/install.sh | sh', 'ollama serve', 'ollama pull nomic-embed-text', 'ollama pull qwen2.5:3b']
434
420
  },
435
421
  },
436
422
  {
@@ -442,6 +428,18 @@ export const CONDITIONS = [
442
428
  remedy: 'Start Ollama and PULL a chat model in MODULES → OLLAMA; the switch @MADRE IN THE ROOM turns the agent off if you do not want it. Ask it "what did we decide about …" or "did we ever discuss …".',
443
429
  fixes: same(['ollama serve', 'ollama pull qwen2.5:3b', '@madre what did we decide about the webhook?']),
444
430
  },
431
+ {
432
+ id: 'install-needs-admin',
433
+ severity: 'informational',
434
+ title: 'Installing a CLI: npm cannot write to the system folder',
435
+ match: /EACCES|EPERM|permission denied|Missing write access|npm ERR!.*sudo|administrator/i,
436
+ diagnosis: 'A global npm install writes into a folder that belongs to the system. With Node installed from its own installer that folder needs an administrator, so npm stops with a permission error. MADRE does not ask for your password: when it sees that wall it installs the CLI into a folder of its own, ~/.pulse/tools, and looks there as well as along PATH. The agent works the same; only the file lives somewhere else.',
437
+ remedy: 'Nothing to do: press INSTALL again and MADRE takes the second way by itself. If you would rather have the CLI everywhere in your terminal, install it yourself with your package manager, or give npm a folder of your own.',
438
+ fixes: {
439
+ darwin: ['# MADRE does this for you; these are the alternatives:', 'brew install --cask claude-code', 'npm config set prefix ~/.npm-global # then add ~/.npm-global/bin to your PATH'],
440
+ linux: ['# MADRE does this for you; these are the alternatives:', 'npm config set prefix ~/.npm-global # then add ~/.npm-global/bin to your PATH']
441
+ },
442
+ },
445
443
  {
446
444
  id: 'privacy-leak',
447
445
  severity: 'warning',
@@ -460,12 +458,124 @@ export const CONDITIONS = [
460
458
  remedy: 'Read the list under the reply before moving on. If the change is wrong press UNDO; if the agent should not have had it, lower its MAX MODE in ⚙ CONNECTIONS.',
461
459
  fixes: same(['git log --oneline -5 # checkpoints are ordinary commits on a side ref', 'git stash list', '# room → UNDO under the CONTROL notice']),
462
460
  },
461
+ {
462
+ id: 'conversations',
463
+ severity: 'informational',
464
+ title: 'Conversations: many per project, one memory, one at a time',
465
+ match: /conversation|already open for this project|ROOM_IN_USE|a turn is running in this conversation|new conversation|chats\.json/i,
466
+ diagnosis: 'A project has one memory and many conversations. The archive, the crew, the modules and the privacy list belong to the project and do not start over when you open another thread; a conversation is only the record of one line of work, and everything said in any of them feeds the same archive. The first conversation is the ledger that was always there, so a room that existed before opens exactly as it did. The numbering is the project\'s, not the thread\'s: a new conversation starts where the project got to, which is why a citation like #1411 means the same exchange in all of them. One conversation drives the crew at a time — two threads editing the same working tree is a way to lose work — so opening another while a turn runs is refused until it finishes. And one server per project: a second `madre start` on the same project is refused with the address of the room that is already open, because two servers would hand out the same sequence twice and the archive would quietly keep one of them.',
467
+ remedy: 'The panel on the left of the canvas lists them: open one, or start another at the bottom. Deleting one takes its transcript and nothing else — what the archivist distilled from it is the project\'s memory and stays. If MADRE says the project is already open, use that room and open a conversation inside it instead of starting a second server.',
468
+ fixes: same(['# room → the handle under the bar, on the left → NEW CONVERSATION', '# the address of the open room is in the refusal itself', 'ls ~/.pulse/rooms/*/chats.json # their names and which one is open']),
469
+ },
470
+ {
471
+ id: 'memory-aberrations',
472
+ severity: 'informational',
473
+ title: 'Aberrations: what the room established is false',
474
+ match: /aberration|aberraci|eyecat|contradict|refut|false claim|hallucinat|alucina/i,
475
+ diagnosis: 'An aberration is a claim the room established is false, kept on purpose rather than deleted: it is what the local model learns not to repeat. It never travels into a turn, and the note it refutes is taken out of circulation with it — a refuted memory is never recalled again, by words or by meaning. EYECAT watches for two agents saying opposite things about the same subject and raises the pair for a third agent to judge, one that took no part in either side. Confirming files the aberration and quarantines what it refutes; dismissing says the room was right and the pair is never raised again. Forgetting an aberration gives its note back.',
476
+ remedy: 'NOSTROMO shows them as collapsed bodies: open one to read what it took down, or clear it if the room was wrong about being wrong. The three tests report open contradictions under CONSISTENCY.',
477
+ fixes: same(['# room → ◉ NOSTROMO → the dark bodies are aberrations', '# ⚙ CONNECTIONS → MEMORY → THE THREE TESTS → CONSISTENCY']),
478
+ },
479
+ {
480
+ id: 'memory-cascade',
481
+ severity: 'informational',
482
+ title: 'Memory used · N by association',
483
+ match: /by association|cascade|spreading activation|keeps arriving with|memory used/i,
484
+ diagnosis: 'Under a reply, MADRE says what the archive handed that turn. Most of it was matched to what you asked, by words and by meaning. A note marked "by association" came along for a different reason: this room keeps carrying it in the same turn as one of the others, so the archive brings it too, however differently it reads. The strength is a ratio over the turns where each was found on its own merits — a memory the room reaches for constantly does not end up attached to everything, and a pair that stops meeting fades on its own. Association adds and never displaces: recall keeps two slots for it and hands back the ones it does not use.',
485
+ remedy: 'Click a note to see it in NOSTROMO, where the wires it travels on are drawn. It is the one part of recall that owes nothing to how a memory reads, so it can be switched off in MEMORY.',
486
+ fixes: same(['# ⚙ CONNECTIONS → MEMORY → CARRY WHAT A MEMORY KEEPS ARRIVING WITH', 'PULSE_RECALL_CASCADE=0 npx @jossuealcala/madre start']),
487
+ },
488
+ {
489
+ id: 'memory-cold',
490
+ severity: 'informational',
491
+ title: 'Cold memories: what the archive has had its chances with',
492
+ match: /cold memor|cold zone|never once carried|had its chances/i,
493
+ diagnosis: 'A memory is cold when three things are true at once: no turn has ever carried it, it shares a subject with nothing so nothing can reach it sideways either, and the archive has been opened at least a dozen times since it was written. The third is what makes the label worth acting on — never-recalled is what every memory is on the day it is written. Chances are counted in turns that actually reached into the archive, and only from the day the room started keeping that trail, so what was never written down is not held against the note.',
494
+ remedy: 'NOSTROMO shows COLD · N in its header when there are any; pressing it rings each one on the map. Open one and its card says the number plainly, with FORGET one press away. Asking about it is the other way out: the room writes that question for you under ASK.',
495
+ fixes: same(['# room → ◉ NOSTROMO → COLD', '# room → ◉ NOSTROMO → ASK → PUT IN THE COMPOSER']),
496
+ },
497
+ {
498
+ id: 'maturity-tests',
499
+ severity: 'informational',
500
+ title: 'The three tests: whether the archive works',
501
+ match: /three tests|coverage|consistency|held-out|match test|maturity|madurez|ready to be worked in/i,
502
+ diagnosis: 'The six readings count what the archive is made of; they cannot say whether it works, because nothing about a pile of notes says whether the right one comes back when it is needed. COVERAGE takes real questions from this room, runs recall at the exact point each was asked, and scores what came back against the reply that was actually given. CONSISTENCY reads the contradictions EYECAT still holds and whether aberrations are being filed more often lately. MATCH puts real questions a frontier CLI answered back to the local model with this archive behind it. All three measure against a control, because everything in one room is about the same handful of subjects and any two pieces of it read as close to an embedder: to count, what the archive handed over has to beat what it would have handed over for a different question.',
503
+ remedy: 'Read the one sentence and the one instruction at the top of MEMORY; the readings and the tests are a fold below it. The free test is read as the panel opens, the cheap one keeps itself fresh once a day while the embeddings are local, and the slow one is yours to start and to stop. None of them spends a provider turn.',
504
+ fixes: same(['# ⚙ CONNECTIONS → MEMORY → THE THREE TESTS', '# MATCH needs Ollama running with a chat model, and embeddings on']),
505
+ },
506
+ {
507
+ id: 'module-update',
508
+ severity: 'common',
509
+ title: 'Updating what a module drives',
510
+ match: /EACCES|permission denied.*npm|npm ERR!.*EACCES|update to \d|newest is|brew upgrade|install -g @playwright/i,
511
+ diagnosis: 'When something newer exists and the module knows how to fetch it, the button is beside the version on its card. It asks twice on purpose: the first press answers with the command, the second runs exactly that, and the output lands in the room line by line. Nothing installs by itself — putting software on your computer unannounced would break the one thing MADRE promises, that a command is seen before it runs. The usual failure is npm refusing a global install with EACCES, which means the global prefix belongs to root; sudo works and leaves you with root-owned files, so a user-owned prefix or a version manager is the better answer.',
512
+ remedy: 'Press the button beside the version, read the command, run it. If npm refuses with EACCES, point npm at a prefix you own or use a version manager, then press it again.',
513
+ fixes: {
514
+ darwin: ['npm config get prefix', 'npm config set prefix ~/.npm-global', 'echo \'export PATH="$HOME/.npm-global/bin:$PATH"\' >> ~/.zshrc', '# or: brew install node # a Homebrew node owns its own prefix'],
515
+ linux: ['npm config get prefix', 'npm config set prefix ~/.npm-global', 'echo \'export PATH="$HOME/.npm-global/bin:$PATH"\' >> ~/.bashrc', '# or use nvm, which owns the prefix it installs into'],
516
+ },
517
+ },
518
+ {
519
+ id: 'module-add',
520
+ severity: 'common',
521
+ title: 'Installing a module somebody else wrote',
522
+ match: /already taken|must live under \/api\/x\/|not a module|default export must be a module|module file|\.module\.mjs|add a module/i,
523
+ diagnosis: 'MODULES has + ADD A MODULE for a .mjs somebody wrote. It comes through the same door as everything else: written to a scratch copy, imported there, checked against the house rules, and installed only if it passes. Three things get a file refused, each with the reason: an id that belongs to a module MADRE ships with, a route outside its own corner of the API (/api/x/<id>/), and a file that will not load at all. Said plainly, because it is what it is: a module runs inside MADRE, with your permissions, on this computer. MADRE checks that it loads and stays in its corral; what the code intends is the one thing nobody can check for you.',
524
+ remedy: 'Install it only if you trust where it came from. A module you wrote can also be updated from where you publish it: declare updates: { url } in the file, or install it from a file and MADRE remembers which, and its card carries GET A NEWER FILE.',
525
+ fixes: same(['# MODULES → + ADD A MODULE', 'ls ~/.pulse/modules # every module you installed, and where it came from', '# docs/SDK.md is the whole contract, and docs/sdk/hello-module.mjs is one that works']),
526
+ },
527
+ {
528
+ id: 'local-ready',
529
+ severity: 'common',
530
+ title: 'Is the local model ready to be worked in?',
531
+ match: /is .*(local|madre).*(ready|good|any good)|ready to be worked in|measured against this (room|project)|check it against this room|local\.present|not measured/i,
532
+ diagnosis: 'Running is not the same as being of use. @madre being in the room means Ollama has a chat model; whether that model is worth your work on THIS project is a different question, and the room measures it: twelve real exchanges from this room are put back to the local model, and each answer is scored against what the crew actually answered that day — against a control, so that talking about the same project does not count. It takes minutes and spends nothing, because all of it runs on your machine.',
533
+ remedy: 'The room says which of the two it has, once per model, in the line that begins "local · @madre". If nobody has measured it, that line carries CHECK IT AGAINST THIS ROOM; the answer lands in the room when it finishes, with the one thing to do about it. A model that does not pass is not broken — the archive is still thin, and it fills where the work happens. The @madre sphere keeps the reading and its date after the line has scrolled away, and the same test lives in NOSTROMO → THE THREE TESTS.',
534
+ fixes: same([
535
+ '# the line in the room: local · @madre → CHECK IT AGAINST THIS ROOM',
536
+ '# or: NOSTROMO → THE THREE TESTS → the third one',
537
+ '# it needs embeddings on and some turns answered by a CLI agent, which is what it compares against',
538
+ ]),
539
+ },
540
+ {
541
+ id: 'code000',
542
+ severity: 'blocking',
543
+ title: 'CODE000: the archive is sealed',
544
+ match: /CODE000|THE ARCHIVE IS SEALED|UNABLE TO COMPUTE\. UNABLE TO CLARIFY|designation|intruder/i,
545
+ diagnosis: 'Two doors ask for the project designation before doing something that cannot be undone: boarding NOSTROMO, and purging every private term already recorded. Getting that name wrong is never part of doing the work — you either know the project you are standing in or you are trying names — so MU/TH/UR counts it. Eight wrong names inside five minutes and CODE000 comes down: the archive is sealed for ten minutes, the crew is told in code, and NOSTROMO asks for the designation again.',
546
+ remedy: 'The designation is the name of the project folder this room was opened in, lowercase or not. Nothing was lost: the seal is a refusal to change the archive for ten minutes, not a deletion, and it lifts on its own. Reading the core is not one of those doors and never counts — it is the way in.',
547
+ fixes: same(['basename "$PWD" # the designation this room asks for', '# the seal lifts on its own; MU/TH/UR › NOSTROMO asks for the name again after it does']),
548
+ },
549
+ {
550
+ id: 'core-console',
551
+ severity: 'common',
552
+ title: 'The core closed while I was typing in it',
553
+ match: /unable to compute|interface closed|core clos|three strikes|inquiry|order 937|mu\/th\/ur 6000/i,
554
+ diagnosis: 'Inside the core there is a console: MU/TH/UR answers about this room from what the page already has — the blocks of the next briefing and their words, the command that would run it, what has left this machine, how many terms privacy is protecting, where the archive stands. Nothing is asked of the crew and nothing is sent. She answers or she says she cannot, and three inquiries she cannot parse close the frame. The count is on screen from the first one and every refusal names what she would have taken.',
555
+ remedy: 'Nothing happened to the archive: reading what the room says in your name is not an attempt on it. Open the core again — click the star at the centre of NOSTROMO — and the count starts over. HELP lists every inquiry; READ <BLOCK> prints one block of the briefing word for word.',
556
+ fixes: same(['# NOSTROMO → click the core → type HELP', '# READ MEMORIES prints the block the next turn would carry', '# SPECIAL ORDER 937 is answered too']),
557
+ },
558
+ {
559
+ id: 'outbound',
560
+ severity: 'common',
561
+ title: 'What leaves this computer, and how to stop each thing that does',
562
+ match: /what left this machine|outbound|leaves? (this )?(computer|machine)|sends? (my )?(code|data)|telemetry|phone home|privacy/i,
563
+ diagnosis: 'MADRE runs here and keeps what it knows in a file you own, and the list that qualifies that sentence is inside the core, under WHAT LEFT THIS MACHINE. Seven addresses, each saying what it carries and where it is switched off: the briefing, which goes to whoever runs the agent you sent the turn to; Gemini embeddings, which carry the text of your memories; the image model, which carries the prompt an agent wrote; the npm registry and GitHub, which get a package name and nothing else; the error collector, which gets a redacted condition and only with your press; and Anthropic, for how much of your Claude plan is left. Ollama is on the list to be seen staying here.',
564
+ remedy: 'Under the list is the log itself: every request this process made, MADRE\'s own and any a module made, because a module runs inside MADRE and cannot opt out of the wrapper. No body, no header and no query value is ever written there — only which parameters were set. An address nothing declares is reported as exactly that.',
565
+ fixes: same([
566
+ '# NOSTROMO → click the core → WHAT LEFT THIS MACHINE',
567
+ 'cat ~/.pulse/rooms/*/outbound.jsonl | tail -20 # the same log, on disk',
568
+ '# embeddings, to keep memories on this computer: MU/TH/UR → MEMORY → EMBEDDINGS → OLLAMA',
569
+ 'PULSE_UPDATE_CHECK=0 madre start # no version checks at all',
570
+ ]),
571
+ },
463
572
  ];
464
573
 
574
+ // Which of the two sets of commands to show first. Anything else gets macOS, and the panel says
575
+ // which shells these are written for rather than guessing on the reader's behalf.
465
576
  export function detectPlatform(nav = globalThis.navigator) {
466
577
  const hint = `${nav?.userAgentData?.platform ?? ''} ${nav?.platform ?? ''} ${nav?.userAgent ?? ''}`.toLowerCase();
467
578
  if (/mac|iphone|ipad|darwin/.test(hint)) return 'darwin';
468
- if (/win/.test(hint)) return 'win32';
469
579
  if (/linux|android|x11/.test(hint)) return 'linux';
470
580
  return 'darwin';
471
581
  }
@@ -474,13 +584,25 @@ export function detectPlatform(nav = globalThis.navigator) {
474
584
  // only match their own agent; generic ones match anyone.
475
585
  export function diagnose(errorText, agent = null) {
476
586
  const text = String(errorText ?? '');
477
- return CONDITIONS.filter((condition) => (!condition.agent || !agent || condition.agent === agent) && condition.match.test(text));
587
+ return CONDITIONS.filter((condition) => (!condition.agent || !agent || condition.agent === agent) && condition.match.test(text)).map(said);
588
+ }
589
+
590
+ // A condition reads in the room's language; what it MATCHES does not. The `match` patterns are
591
+ // tested against what a CLI printed, and a CLI prints English whatever the room speaks — so the
592
+ // patterns stay exactly as they are, and only the words a person reads go through the catalogue.
593
+ // Said at call time, because the table is built when this file is imported and the room learns
594
+ // its language after.
595
+ export function said(condition) {
596
+ return { ...condition, title: t(condition.title), diagnosis: t(condition.diagnosis), remedy: t(condition.remedy) };
478
597
  }
598
+ export const allConditions = () => CONDITIONS.map(said);
479
599
 
480
600
  export function searchConditions(query) {
481
601
  const needle = String(query ?? '').trim().toLowerCase();
482
- if (!needle) return CONDITIONS;
483
- return CONDITIONS.filter((condition) => [condition.id, condition.title, condition.diagnosis, condition.remedy, condition.agent ?? '']
602
+ const all = allConditions();
603
+ if (!needle) return all;
604
+ // Searched in the language it is read in, plus the id and the agent, which are never translated.
605
+ return all.filter((condition) => [condition.id, condition.title, condition.diagnosis, condition.remedy, condition.agent ?? '']
484
606
  .join(' ').toLowerCase().includes(needle));
485
607
  }
486
608
 
@@ -101,8 +101,9 @@ export function parseClaudeOutput(output) {
101
101
  }
102
102
  }
103
103
 
104
- export function invokeClaude({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] }) {
104
+ export function invokeClaude({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [], onProgress = null }) {
105
105
  return runReadonlyProcess({
106
+ onProgress: onProgress ? (out) => onProgress({ chars: (() => { try { return String(JSON.parse(out)?.result ?? '').length; } catch { return 0; } })() }) : null,
106
107
  executable,
107
108
  args: buildClaudeArgs({ prompt, model, attachmentsDir: attachments[0]?.dir ?? null, lease, scopes, imageStudio, memoryServer, mcpServers }),
108
109
  cwd: projectRoot,
@@ -73,8 +73,9 @@ export function parseCodexOutput(output) {
73
73
  return { text: text.trim(), usage };
74
74
  }
75
75
 
76
- export function invokeCodex({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, memoryServer = null, mcpServers = [] }) {
76
+ export function invokeCodex({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, memoryServer = null, mcpServers = [], onProgress = null }) {
77
77
  return runReadonlyProcess({
78
+ onProgress: onProgress ? (out) => onProgress({ chars: parseCodexOutput(out).text.length }) : null,
78
79
  executable,
79
80
  args: buildCodexArgs({ projectRoot, prompt, model, attachments, lease, scopes, memoryServer, mcpServers }),
80
81
  cwd: lease ? lease.outDir : projectRoot,
@@ -1,4 +1,5 @@
1
1
  import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { t } from '../i18n.mjs';
2
3
  import { homedir, tmpdir } from 'node:os';
3
4
  import { join } from 'node:path';
4
5
  import { runReadonlyProcess } from './process.mjs';
@@ -215,7 +216,7 @@ export function parseGeminiOutput(output) {
215
216
  export async function cleanupRuntimeRoot(runtimeRoot, { attempts = 6, delayMs = 250 } = {}) {
216
217
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
217
218
  try {
218
- await rm(runtimeRoot, { recursive: true, force: true });
219
+ await rm(runtimeRoot, { recursive: true, force: true, maxRetries: 6, retryDelay: 60 });
219
220
  return true;
220
221
  } catch (error) {
221
222
  if (attempt === attempts) {
@@ -234,21 +235,21 @@ export async function cleanupRuntimeRoot(runtimeRoot, { attempts = 6, delayMs =
234
235
  export function diagnoseGeminiStderr(stderr) {
235
236
  const text = String(stderr ?? '');
236
237
  if (/prepayment credits are depleted/i.test(text)) {
237
- return { code: 'CREDITS_DEPLETED', message: 'Google says the AI Studio project behind this Gemini key has no prepaid credits left; every request is refused (HTTP 429) until it is topped up.', hint: 'Add credits at https://ai.studio/projects, or switch the Gemini CLI to another key.' };
238
+ return { code: 'CREDITS_DEPLETED', message: t('Google says the AI Studio project behind this Gemini key has no prepaid credits left; every request is refused (HTTP 429) until it is topped up.'), hint: t('Add credits at https://ai.studio/projects, or switch the Gemini CLI to another key.') };
238
239
  }
239
240
  if (/status:\s*429|\b429\b|RESOURCE_EXHAUSTED|rate ?limit|quota exceeded/i.test(text)) {
240
241
  const router = /ClassifierStrategy|\.route\b/.test(text);
241
242
  return {
242
243
  code: 'RATE_LIMITED',
243
244
  message: `Google is rate-limiting this Gemini key (HTTP 429)${router ? ' while its "auto" router picked a model' : ''}; the CLI kept retrying with backoff.`,
244
- hint: 'Wait a minute, or pick an explicit model such as gemini-3-flash-preview to skip the router; check the key\'s quota at aistudio.google.com.',
245
+ hint: t('Wait a minute, or pick an explicit model such as gemini-3-flash-preview to skip the router; check the key\'s quota at aistudio.google.com.'),
245
246
  };
246
247
  }
247
248
  if (/status:?\s*503|UNAVAILABLE|high demand/i.test(text)) {
248
- return { code: 'UNAVAILABLE', message: 'Google reported the model as unavailable (HTTP 503) and the CLI kept retrying.', hint: 'Try again shortly or choose another model.' };
249
+ return { code: 'UNAVAILABLE', message: t('Google reported the model as unavailable (HTTP 503) and the CLI kept retrying.'), hint: t('Try again shortly or choose another model.') };
249
250
  }
250
251
  if (/status:\s*40[13]|PERMISSION_DENIED|API key not valid|IneligibleTierError/i.test(text)) {
251
- return { code: 'AUTH', message: 'Google rejected the Gemini credentials.', hint: 'Run `gemini` and use /auth, or check GEMINI_API_KEY.' };
252
+ return { code: 'AUTH', message: t('Google rejected the Gemini credentials.'), hint: t('Run `gemini` and use /auth, or check GEMINI_API_KEY.') };
252
253
  }
253
254
  return null;
254
255
  }
@@ -154,8 +154,9 @@ export function parseOpenCodeOutput(output) {
154
154
  return { text: text.join('').trim(), usage, ...(error ? { error } : {}) };
155
155
  }
156
156
 
157
- export function invokeOpenCode({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] }) {
157
+ export function invokeOpenCode({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [], onProgress = null }) {
158
158
  return runReadonlyProcess({
159
+ onProgress: onProgress ? (out) => onProgress({ chars: out.length }) : null,
159
160
  executable,
160
161
  args: buildOpenCodeArgs({ projectRoot, prompt, attachments, ...(model ? { model } : {}) }),
161
162
  cwd: projectRoot,
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { t } from '../i18n.mjs';
2
3
 
3
4
  function signalProcessGroup(child, signal) {
4
5
  if (!child.pid) return false;
@@ -41,10 +42,14 @@ export function runReadonlyProcess({
41
42
  label,
42
43
  parse,
43
44
  signal,
45
+ // Called as output arrives, with everything received so far. The adapter decides what that
46
+ // means: a CLI that streams its answer can be read as it writes, one that hands over a single
47
+ // blob at the end will simply say nothing until then, and saying nothing is the honest answer.
48
+ onProgress = null,
44
49
  }) {
45
50
  return new Promise((resolve, reject) => {
46
51
  if (signal?.aborted) {
47
- reject(new Error(`${label} was interrupted before it started: ${typeof signal.reason === 'string' ? signal.reason : 'MADRE is shutting down'}.`));
52
+ reject(new Error(t('{label} was interrupted before it started: {why}.', { label, why: typeof signal.reason === 'string' ? signal.reason : t('MADRE is shutting down') })));
48
53
  return;
49
54
  }
50
55
  const child = spawn(executable, args, {
@@ -77,8 +82,8 @@ export function runReadonlyProcess({
77
82
 
78
83
  const onAbort = () => {
79
84
  terminateProcessTree(child, { graceMs: killGraceMs });
80
- const reason = typeof signal?.reason === 'string' ? signal.reason : 'MADRE is shutting down';
81
- finish(() => reject(new Error(`${label} was interrupted: ${reason}.`)));
85
+ const reason = typeof signal?.reason === 'string' ? signal.reason : t('MADRE is shutting down');
86
+ finish(() => reject(new Error(t('{label} was interrupted: {why}.', { label, why: reason }))));
82
87
  };
83
88
  const finish = (operation) => {
84
89
  if (settled) return;
@@ -92,7 +97,7 @@ export function runReadonlyProcess({
92
97
  terminateProcessTree(child, { graceMs: killGraceMs });
93
98
  // The last thing the agent said is usually the reason it was slow.
94
99
  const lastLine = `${stderr}\n${stdout}`.split('\n').map((line) => line.trim()).filter(Boolean).at(-1);
95
- const error = new Error(`${label} did not respond before the timeout (${Math.round(timeoutMs / 1000)}s).${lastLine ? ` Last output: ${lastLine.slice(0, 200)}` : ''}`);
100
+ const error = new Error(t('{label} did not respond before the timeout ({seconds}s).', { label, seconds: Math.round(timeoutMs / 1000) }) + (lastLine ? t(' Last output: {output}', { output: lastLine.slice(0, 200) }) : ''));
96
101
  error.code = 'TIMEOUT';
97
102
  error.partialOutput = stdout;
98
103
  error.partialStderr = stderr.slice(-2000);
@@ -103,7 +108,14 @@ export function runReadonlyProcess({
103
108
  // Only complete stdout lines count as activity: a CLI's stderr spinner or
104
109
  // progress noise must not keep a silent model alive past the idle limit.
105
110
  // Blank keep-alive lines are not activity either.
106
- child.stdout.on('data', (chunk) => { stdout += chunk; if (/\S/.test(String(chunk)) && String(chunk).includes('\n')) lastActivity = Date.now(); });
111
+ let toldAt = 0;
112
+ child.stdout.on('data', (chunk) => {
113
+ stdout += chunk;
114
+ if (/\S/.test(String(chunk)) && String(chunk).includes('\n')) lastActivity = Date.now();
115
+ // Throttled: a chatty CLI can produce hundreds of chunks a second and nobody needs to see
116
+ // a number move that fast.
117
+ if (onProgress && Date.now() - toldAt > 400) { toldAt = Date.now(); try { onProgress(stdout); } catch { /* a meter must never break a turn */ } }
118
+ });
107
119
  child.stderr.on('data', (chunk) => {
108
120
  stderr += chunk;
109
121
  if (!watchStderr || settled) return;