@jossuealcala/madre 0.4.0 → 0.4.1

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.
@@ -46,8 +46,39 @@ const loginAgent = {
46
46
  const same = (commands) => ({ darwin: commands, linux: commands });
47
47
 
48
48
  export const CONDITIONS = [
49
+ {
50
+ id: 'claude-api-key-shadows-plan',
51
+ code: 'MU-001',
52
+ agent: 'claude',
53
+ severity: 'common',
54
+ title: 'ANTHROPIC_API_KEY takes precedence over the claude.ai plan',
55
+ match: /connectors are disabled because ANTHROPIC_API_KEY|another auth source is set and takes precedence|takes precedence over your claude\.ai login/i,
56
+ diagnosis: 'Claude Code found an API key in the environment as well as the claude.ai login, and the key wins. MADRE hands the CLI the environment exactly as this machine has it, so whatever is exported in your shell profile reaches it. The turn is billed to the API account, not to the plan, and your organisation connectors do not load.',
57
+ remedy: 'Decide which one you mean. To work on the plan, unset the key where your shell exports it and open the room again; to work on the API, this message is only a notice and can be ignored.',
58
+ fixes: {
59
+ darwin: ['grep -rn ANTHROPIC_API_KEY ~/.zshrc ~/.zprofile ~/.zshenv 2>/dev/null', '# remove or comment the line it finds, then:', 'unset ANTHROPIC_API_KEY', '# and start MADRE from a new terminal'],
60
+ linux: ['grep -rn ANTHROPIC_API_KEY ~/.bashrc ~/.profile ~/.bash_profile 2>/dev/null', 'unset ANTHROPIC_API_KEY', '# and start MADRE from a new terminal'],
61
+ },
62
+ },
63
+ {
64
+ id: 'codex-reading-stdin',
65
+ code: 'MU-002',
66
+ solvedIn: 'connections',
67
+ agent: 'codex',
68
+ severity: 'informational',
69
+ title: 'Codex says it is reading additional input from stdin',
70
+ match: /Reading additional input from stdin/i,
71
+ diagnosis: 'A notice Codex prints, not a failure. MADRE runs it with stdin closed, so the line is harmless and the turn is decided by what Codex returned. If the turn failed, the reason is the line above this one — the exit code, or a clean exit with no answer.',
72
+ remedy: 'Nothing to do about the notice itself. If turns keep failing, check that this Codex is recent enough for the flags MADRE passes: `codex --version`, then update it.',
73
+ fixes: {
74
+ darwin: ['codex --version', 'npm install -g @openai/codex'],
75
+ linux: ['codex --version', 'npm install -g @openai/codex'],
76
+ },
77
+ },
49
78
  {
50
79
  id: 'gemini-ineligible-tier',
80
+ code: 'MU-003',
81
+ solvedIn: 'connections',
51
82
  agent: 'gemini',
52
83
  severity: 'blocking',
53
84
  title: 'Gemini refuses the personal Google login',
@@ -61,6 +92,8 @@ export const CONDITIONS = [
61
92
  },
62
93
  {
63
94
  id: 'gemini-credits-depleted',
95
+ code: 'MU-004',
96
+ solvedIn: 'connections',
64
97
  agent: 'gemini',
65
98
  severity: 'blocking',
66
99
  title: 'Gemini key out of prepaid credits',
@@ -71,6 +104,8 @@ export const CONDITIONS = [
71
104
  },
72
105
  {
73
106
  id: 'gemini-rate-limited',
107
+ code: 'MU-005',
108
+ solvedIn: 'connections',
74
109
  agent: 'gemini',
75
110
  severity: 'transient',
76
111
  title: 'Gemini key rate-limited by Google (HTTP 429)',
@@ -84,6 +119,7 @@ export const CONDITIONS = [
84
119
  },
85
120
  {
86
121
  id: 'gemini-high-demand',
122
+ code: 'MU-006',
87
123
  agent: 'gemini',
88
124
  severity: 'transient',
89
125
  title: 'Gemini model under high demand (503)',
@@ -94,6 +130,8 @@ export const CONDITIONS = [
94
130
  },
95
131
  {
96
132
  id: 'opencode-default-provider',
133
+ code: 'MU-007',
134
+ solvedIn: 'connections',
97
135
  agent: 'opencode',
98
136
  severity: 'blocking',
99
137
  title: 'OpenCode picked a provider without a valid session',
@@ -107,6 +145,8 @@ export const CONDITIONS = [
107
145
  },
108
146
  {
109
147
  id: 'not-signed-in',
148
+ code: 'MU-008',
149
+ solvedIn: 'connections',
110
150
  severity: 'blocking',
111
151
  title: 'Agent installed but signed out',
112
152
  match: /not logged in|sign in required|Error authenticating|unauthorized|please (?:log|sign) ?in|no credentials/i,
@@ -117,6 +157,7 @@ export const CONDITIONS = [
117
157
  },
118
158
  {
119
159
  id: 'not-installed',
160
+ code: 'MU-009',
120
161
  severity: 'blocking',
121
162
  title: 'Agent not found on this computer',
122
163
  match: /is not installed on this computer|not found on this computer|ENOENT.*(codex|claude|gemini|opencode)/i,
@@ -127,6 +168,7 @@ export const CONDITIONS = [
127
168
  },
128
169
  {
129
170
  id: 'adapter-pending',
171
+ code: 'MU-010',
130
172
  severity: 'blocking',
131
173
  title: 'Agent detected, adapter not enabled',
132
174
  match: /adapter is not enabled yet|does not have a supported MADRE adapter/i,
@@ -136,6 +178,7 @@ export const CONDITIONS = [
136
178
  },
137
179
  {
138
180
  id: 'claude-args',
181
+ code: 'MU-011',
139
182
  agent: 'claude',
140
183
  severity: 'fixed',
141
184
  title: 'Claude read the prompt as an MCP config path',
@@ -146,6 +189,7 @@ export const CONDITIONS = [
146
189
  },
147
190
  {
148
191
  id: 'timeout',
192
+ code: 'MU-012',
149
193
  severity: 'tunable',
150
194
  title: 'Agent did not respond before the timeout',
151
195
  match: /did not respond before the timeout|went silent for/i,
@@ -158,6 +202,7 @@ export const CONDITIONS = [
158
202
  },
159
203
  {
160
204
  id: 'interrupted',
205
+ code: 'MU-013',
161
206
  severity: 'informational',
162
207
  title: 'Turn interrupted by a MADRE restart',
163
208
  match: /interrupted because MADRE is shutting down|MADRE stopped while|turn was not completed/i,
@@ -167,6 +212,7 @@ export const CONDITIONS = [
167
212
  },
168
213
  {
169
214
  id: 'message-too-long',
215
+ code: 'MU-014',
170
216
  severity: 'tunable',
171
217
  title: 'Message rejected for length',
172
218
  match: /Message is too long/i,
@@ -179,6 +225,7 @@ export const CONDITIONS = [
179
225
  },
180
226
  {
181
227
  id: 'limits-real',
228
+ code: 'MU-015',
182
229
  severity: 'informational',
183
230
  title: 'Where the rings get their numbers',
184
231
  match: /ring|quota|limit window|resets|rollout|oauth usage|window reset|limits-real/i,
@@ -188,6 +235,7 @@ export const CONDITIONS = [
188
235
  },
189
236
  {
190
237
  id: 'budget-exhausted',
238
+ code: 'MU-016',
191
239
  severity: 'tunable',
192
240
  title: 'Local token budget exhausted for an agent',
193
241
  match: /MADRE exhausted|local room token budget/i,
@@ -200,6 +248,7 @@ export const CONDITIONS = [
200
248
  },
201
249
  {
202
250
  id: 'port-in-use',
251
+ code: 'MU-017',
203
252
  severity: 'blocking',
204
253
  title: 'Port already in use',
205
254
  match: /EADDRINUSE|already in use/i,
@@ -212,6 +261,7 @@ export const CONDITIONS = [
212
261
  },
213
262
  {
214
263
  id: 'stream-reconnecting',
264
+ code: 'MU-018',
215
265
  severity: 'blocking',
216
266
  title: 'Live stream keeps reconnecting',
217
267
  match: /reconnecting|EventSource|ECONNREFUSED/i,
@@ -221,6 +271,7 @@ export const CONDITIONS = [
221
271
  },
222
272
  {
223
273
  id: 'opencode-server-error',
274
+ code: 'MU-019',
224
275
  agent: 'opencode',
225
276
  severity: 'transient',
226
277
  title: 'OpenCode reported an unexpected server error',
@@ -231,6 +282,7 @@ export const CONDITIONS = [
231
282
  },
232
283
  {
233
284
  id: 'runaway-room',
285
+ code: 'MU-020',
234
286
  severity: 'blocking',
235
287
  title: 'Too many agents working at once',
236
288
  match: /agents are working at once|already answering another turn|no second plan will start|has been running for/i,
@@ -241,6 +293,7 @@ export const CONDITIONS = [
241
293
  // ---- capability assistance: how an agent could gain a scope it lacks ----
242
294
  {
243
295
  id: 'scope-gemini-imageGen',
296
+ code: 'MU-051',
244
297
  agent: 'gemini',
245
298
  severity: 'informational',
246
299
  title: 'Gemini: generating images',
@@ -251,6 +304,7 @@ export const CONDITIONS = [
251
304
  },
252
305
  {
253
306
  id: 'scope-claude-imageGen',
307
+ code: 'MU-052',
254
308
  agent: 'claude',
255
309
  severity: 'informational',
256
310
  title: 'Claude Code: generating images',
@@ -261,6 +315,7 @@ export const CONDITIONS = [
261
315
  },
262
316
  {
263
317
  id: 'scope-opencode-imageGen',
318
+ code: 'MU-053',
264
319
  agent: 'opencode',
265
320
  severity: 'informational',
266
321
  title: 'OpenCode: generating images',
@@ -271,6 +326,7 @@ export const CONDITIONS = [
271
326
  },
272
327
  {
273
328
  id: 'scope-web',
329
+ code: 'MU-021',
274
330
  severity: 'informational',
275
331
  title: 'Web access for an agent',
276
332
  match: /scope-[a-z]+-web/,
@@ -280,6 +336,7 @@ export const CONDITIONS = [
280
336
  },
281
337
  {
282
338
  id: 'slash-commands',
339
+ code: 'MU-022',
283
340
  severity: 'informational',
284
341
  title: 'Commands and mentions in the field box',
285
342
  match: /slash|command|\/git|\/ahp|mention|@agent/i,
@@ -289,6 +346,7 @@ export const CONDITIONS = [
289
346
  },
290
347
  {
291
348
  id: 'modes',
349
+ code: 'MU-023',
292
350
  severity: 'informational',
293
351
  title: 'Permission modes: #0 GHOST · #1 EXCHANGE · #2 CREATE · #3 CONTROL · #4 AIRLOCK',
294
352
  match: /mode|ghost|exchange|control|airlock|deploy|despleg|producci|override|designation|max mode|#[0-4]\b/i,
@@ -298,6 +356,7 @@ export const CONDITIONS = [
298
356
  },
299
357
  {
300
358
  id: 'lease-missing',
359
+ code: 'MU-024',
301
360
  severity: 'informational',
302
361
  title: 'Creating files: who grants the permission',
303
362
  match: /no CREATE lease|lease-missing|read-only and do not modify|permiso de escritura|solo lectura/i,
@@ -307,6 +366,7 @@ export const CONDITIONS = [
307
366
  },
308
367
  {
309
368
  id: 'scope-write',
369
+ code: 'MU-025',
310
370
  severity: 'informational',
311
371
  title: 'Creating files with an agent',
312
372
  match: /scope-[a-z]+-write/,
@@ -316,6 +376,7 @@ export const CONDITIONS = [
316
376
  },
317
377
  {
318
378
  id: 'node-version',
379
+ code: 'MU-026',
319
380
  severity: 'blocking',
320
381
  title: 'Node.js too old',
321
382
  match: /SyntaxError: Unexpected token|ERR_REQUIRE_ESM|engines|Unsupported engine/i,
@@ -328,6 +389,7 @@ export const CONDITIONS = [
328
389
  },
329
390
  {
330
391
  id: 'memory-unavailable',
392
+ code: 'MU-027',
331
393
  severity: 'degraded',
332
394
  title: 'Room memory unavailable: turns get only the recent window',
333
395
  match: /memory unavailable|memory index failed|memory recall failed|node:sqlite|SQLITE_|database disk image is malformed|memory\.sqlite/i,
@@ -340,6 +402,7 @@ export const CONDITIONS = [
340
402
  },
341
403
  {
342
404
  id: 'memory-recall',
405
+ code: 'MU-028',
343
406
  severity: 'informational',
344
407
  title: 'Room memory: what an agent remembers and how',
345
408
  match: /\brecall\b|recuerd|\bremember\b|memoria de la sala|<memory>|<memories>/i,
@@ -349,6 +412,7 @@ export const CONDITIONS = [
349
412
  },
350
413
  {
351
414
  id: 'memory-distill',
415
+ code: 'MU-029',
352
416
  severity: 'degraded',
353
417
  title: 'Distilled memories: the archivist did not run, or failed',
354
418
  match: /could not distil|distill|destil|batch skipped|archivist|memory\.distilled|memories? (were|was) not/i,
@@ -358,6 +422,8 @@ export const CONDITIONS = [
358
422
  },
359
423
  {
360
424
  id: 'memory-embeddings',
425
+ code: 'MU-030',
426
+ solvedIn: 'connections',
361
427
  severity: 'degraded',
362
428
  title: 'Embeddings paused: recall is lexical only',
363
429
  match: /embeddings paused|embedding timed out|Gemini embeddings HTTP|batchEmbedContents|LINKS NEED EMBEDDINGS/i,
@@ -370,6 +436,8 @@ export const CONDITIONS = [
370
436
  },
371
437
  {
372
438
  id: 'memory-tools',
439
+ code: 'MU-031',
440
+ solvedIn: 'connections',
373
441
  severity: 'degraded',
374
442
  title: 'An agent says it cannot search the memory (pulse-memory MCP)',
375
443
  match: /pulse-memory|memory_search|memory_recall|memory_notes|memory_timeline|memory_note|mcp.*(failed|error|not found|unavailable)|MCP server/i,
@@ -382,6 +450,7 @@ export const CONDITIONS = [
382
450
  },
383
451
  {
384
452
  id: 'memory-note',
453
+ code: 'MU-032',
385
454
  severity: 'informational',
386
455
  title: 'Saving a memory on request; GHOST refuses',
387
456
  match: /save (a |the |this )?memory|guarda.*memoria|memory saved|off the record.*nothing can be saved|remember this|memory_note/i,
@@ -391,6 +460,7 @@ export const CONDITIONS = [
391
460
  },
392
461
  {
393
462
  id: 'nostromo-access',
463
+ code: 'MU-033',
394
464
  severity: 'informational',
395
465
  title: 'NOSTROMO: boarding, reading and forgetting',
396
466
  match: /nostromo|designation|UNABLE TO COMPUTE|ARCHIVE IS SEALED|forget this memory/i,
@@ -400,6 +470,7 @@ export const CONDITIONS = [
400
470
  },
401
471
  {
402
472
  id: 'ripley',
473
+ code: 'MU-034',
403
474
  severity: 'informational',
404
475
  title: 'RIPLEY: rendering HTML, SVG and Markdown in the viewer',
405
476
  match: /ripley|RIPLEY is off|renders \.html|render(ed|ing)? (the )?(html|svg|markdown)/i,
@@ -409,6 +480,7 @@ export const CONDITIONS = [
409
480
  },
410
481
  {
411
482
  id: 'ollama',
483
+ code: 'MU-035',
412
484
  severity: 'informational',
413
485
  title: 'Ollama: local embeddings and a local archivist',
414
486
  match: /ollama|11434|local model|nomic-embed|qwen|llama3/i,
@@ -421,6 +493,7 @@ export const CONDITIONS = [
421
493
  },
422
494
  {
423
495
  id: 'madre-agent',
496
+ code: 'MU-036',
424
497
  severity: 'informational',
425
498
  title: '@madre: the room\'s own memory, as an agent',
426
499
  match: /@madre|madre-local|needs Ollama running with a chat model/i,
@@ -430,6 +503,7 @@ export const CONDITIONS = [
430
503
  },
431
504
  {
432
505
  id: 'install-needs-admin',
506
+ code: 'MU-037',
433
507
  severity: 'informational',
434
508
  title: 'Installing a CLI: npm cannot write to the system folder',
435
509
  match: /EACCES|EPERM|permission denied|Missing write access|npm ERR!.*sudo|administrator/i,
@@ -442,6 +516,7 @@ export const CONDITIONS = [
442
516
  },
443
517
  {
444
518
  id: 'privacy-leak',
519
+ code: 'MU-038',
445
520
  severity: 'warning',
446
521
  title: 'Privacy: an agent brought its own configuration into the room',
447
522
  match: /privacy\.redacted|privacy\.purged|private terms?|\[ENTIDAD-ORG\]|organi[sz]ation(al)? instructions|leak(ed)? (a|the) (name|company|domain)/i,
@@ -451,6 +526,7 @@ export const CONDITIONS = [
451
526
  },
452
527
  {
453
528
  id: 'control-changes',
529
+ code: 'MU-039',
454
530
  severity: 'informational',
455
531
  title: 'CONTROL: what changed, what was reverted, UNDO',
456
532
  match: /control\.changed|forbidden zones?|reverted|UNDO|checkpoint|changed \d+ file/i,
@@ -460,6 +536,7 @@ export const CONDITIONS = [
460
536
  },
461
537
  {
462
538
  id: 'conversations',
539
+ code: 'MU-040',
463
540
  severity: 'informational',
464
541
  title: 'Conversations: many per project, one memory, one at a time',
465
542
  match: /conversation|already open for this project|ROOM_IN_USE|a turn is running in this conversation|new conversation|chats\.json/i,
@@ -469,6 +546,7 @@ export const CONDITIONS = [
469
546
  },
470
547
  {
471
548
  id: 'memory-aberrations',
549
+ code: 'MU-041',
472
550
  severity: 'informational',
473
551
  title: 'Aberrations: what the room established is false',
474
552
  match: /aberration|aberraci|eyecat|contradict|refut|false claim|hallucinat|alucina/i,
@@ -478,6 +556,7 @@ export const CONDITIONS = [
478
556
  },
479
557
  {
480
558
  id: 'memory-cascade',
559
+ code: 'MU-042',
481
560
  severity: 'informational',
482
561
  title: 'Memory used · N by association',
483
562
  match: /by association|cascade|spreading activation|keeps arriving with|memory used/i,
@@ -487,6 +566,7 @@ export const CONDITIONS = [
487
566
  },
488
567
  {
489
568
  id: 'memory-cold',
569
+ code: 'MU-043',
490
570
  severity: 'informational',
491
571
  title: 'Cold memories: what the archive has had its chances with',
492
572
  match: /cold memor|cold zone|never once carried|had its chances/i,
@@ -496,6 +576,7 @@ export const CONDITIONS = [
496
576
  },
497
577
  {
498
578
  id: 'maturity-tests',
579
+ code: 'MU-044',
499
580
  severity: 'informational',
500
581
  title: 'The three tests: whether the archive works',
501
582
  match: /three tests|coverage|consistency|held-out|match test|maturity|madurez|ready to be worked in/i,
@@ -505,6 +586,7 @@ export const CONDITIONS = [
505
586
  },
506
587
  {
507
588
  id: 'module-update',
589
+ code: 'MU-045',
508
590
  severity: 'common',
509
591
  title: 'Updating what a module drives',
510
592
  match: /EACCES|permission denied.*npm|npm ERR!.*EACCES|update to \d|newest is|brew upgrade|install -g @playwright/i,
@@ -517,6 +599,7 @@ export const CONDITIONS = [
517
599
  },
518
600
  {
519
601
  id: 'module-add',
602
+ code: 'MU-046',
520
603
  severity: 'common',
521
604
  title: 'Installing a module somebody else wrote',
522
605
  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,
@@ -526,6 +609,7 @@ export const CONDITIONS = [
526
609
  },
527
610
  {
528
611
  id: 'local-ready',
612
+ code: 'MU-047',
529
613
  severity: 'common',
530
614
  title: 'Is the local model ready to be worked in?',
531
615
  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,
@@ -539,6 +623,7 @@ export const CONDITIONS = [
539
623
  },
540
624
  {
541
625
  id: 'code000',
626
+ code: 'MU-048',
542
627
  severity: 'blocking',
543
628
  title: 'CODE000: the archive is sealed',
544
629
  match: /CODE000|THE ARCHIVE IS SEALED|UNABLE TO COMPUTE\. UNABLE TO CLARIFY|designation|intruder/i,
@@ -548,6 +633,7 @@ export const CONDITIONS = [
548
633
  },
549
634
  {
550
635
  id: 'core-console',
636
+ code: 'MU-049',
551
637
  severity: 'common',
552
638
  title: 'The core closed while I was typing in it',
553
639
  match: /unable to compute|interface closed|core clos|three strikes|inquiry|order 937|mu\/th\/ur 6000/i,
@@ -557,6 +643,7 @@ export const CONDITIONS = [
557
643
  },
558
644
  {
559
645
  id: 'outbound',
646
+ code: 'MU-050',
560
647
  severity: 'common',
561
648
  title: 'What leaves this computer, and how to stop each thing that does',
562
649
  match: /what left this machine|outbound|leaves? (this )?(computer|machine)|sends? (my )?(code|data)|telemetry|phone home|privacy/i,
@@ -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',
@@ -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
  });
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 = [