@jossuealcala/madre 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/styles.css CHANGED
@@ -39,6 +39,7 @@
39
39
  --mode-1: var(--phosphor); --mode-1-ink: var(--phosphor-ink); /* EXCHANGE · phosphor */
40
40
  --mode-2: #ffb000; --mode-2-ink: #1a1000; /* CREATE · amber phosphor */
41
41
  --mode-3: var(--terror); --mode-3-ink: #fff3f1; /* CONTROL · the red screen */
42
+ --mode-4: #8fd3ff; --mode-4-ink: #04131c; /* AIRLOCK · the cold outside */
42
43
  }
43
44
  @media (prefers-color-scheme: light) {
44
45
  :root:not([data-theme="dark"]) {
@@ -65,6 +66,7 @@
65
66
  --mode-0: #6f786e; --mode-0-ink: #ffffff;
66
67
  --mode-2: #a06e00; --mode-2-ink: #ffffff;
67
68
  --mode-3: #d81b12; --mode-3-ink: #ffffff;
69
+ --mode-4: #0b6fa8; --mode-4-ink: #ffffff;
68
70
  }
69
71
  }
70
72
  :root[data-theme="light"] {
@@ -395,6 +397,7 @@ textarea::placeholder { color: var(--text-3); }
395
397
  .composer.m0 { --mode: var(--mode-0); --mode-ink: var(--mode-0-ink); }
396
398
  .composer.m2 { --mode: var(--mode-2); --mode-ink: var(--mode-2-ink); }
397
399
  .composer.m3 { --mode: var(--mode-3); --mode-ink: var(--mode-3-ink); }
400
+ .composer.m4 { --mode: var(--mode-4); --mode-ink: var(--mode-4-ink); }
398
401
  .field button[type="submit"] { width: 34px; height: 34px; padding: 0; border: 0; border-radius: 50%; background: var(--mode, var(--phosphor)); color: var(--mode-ink, var(--phosphor-ink)); cursor: pointer; display: grid; place-items: center; box-shadow: 0 0 18px color-mix(in srgb, var(--mode, var(--phosphor)) 45%, transparent); transition: background .25s, box-shadow .25s; }
399
402
  .field button[type="submit"] svg { width: 18px; height: 18px; }
400
403
  .field button[type="submit"]:hover { filter: brightness(1.06); }
@@ -566,11 +569,15 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
566
569
  .stop-all.armed { opacity: 1; cursor: pointer; border-color: var(--terror); background: color-mix(in srgb, var(--terror) 12%, transparent); color: var(--terror); text-shadow: 0 0 8px color-mix(in srgb, var(--terror) 60%, transparent); animation: blink 1.4s infinite; }
567
570
  .stop-all.armed:hover { background: var(--terror); color: #fff; text-shadow: none; animation: none; }
568
571
  .stop-all-glyph { width: 8px; height: 8px; background: currentColor; }
569
- .update-pill { opacity: 1; cursor: pointer; border-color: var(--warn); background: color-mix(in srgb, var(--warn) 12%, transparent); color: var(--warn); text-shadow: 0 0 8px color-mix(in srgb, var(--warn) 60%, transparent); animation: update-breathe 2.8s ease-in-out infinite; }
570
- .update-pill:hover { background: var(--warn); color: #050605; text-shadow: none; animation: none; }
571
- .update-pill .update-glyph { border-radius: 50%; }
572
- @keyframes update-breathe { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--warn) 0%, transparent); } 50% { box-shadow: 0 0 14px 0 color-mix(in srgb, var(--warn) 45%, transparent); } }
573
- @media (prefers-reduced-motion: reduce) { .update-pill { animation: none; } }
572
+ /* The version alert: an amber hairline, a soft fill, no glow. It should read as information, not alarm. */
573
+ .update-pill { opacity: 1; cursor: pointer; border-color: color-mix(in srgb, var(--warn) 45%, transparent); background: color-mix(in srgb, var(--warn) 9%, transparent); color: var(--warn); text-shadow: none; transition: background-color .22s ease, border-color .22s ease, color .22s ease, transform .16s ease; }
574
+ .update-pill:hover { background: color-mix(in srgb, var(--warn) 16%, transparent); border-color: color-mix(in srgb, var(--warn) 70%, transparent); }
575
+ .update-pill:active { transform: scale(.98); }
576
+ .update-pill:focus-visible { outline: 2px solid color-mix(in srgb, var(--warn) 60%, transparent); outline-offset: 2px; }
577
+ .update-pill .update-glyph { border-radius: 50%; opacity: .9; }
578
+ .update-restart { display: inline-flex; align-items: center; height: 30px; padding: 0 14px; margin: 4px 0 10px; border: 1px solid var(--ph); background: color-mix(in srgb, var(--ph) 12%, transparent); color: var(--ph); font: inherit; font-size: 10px; letter-spacing: .18em; cursor: pointer; transition: background-color .22s ease, color .22s ease, opacity .22s ease; }
579
+ .update-restart:hover { background: color-mix(in srgb, var(--ph) 22%, transparent); }
580
+ .update-restart:disabled { opacity: .55; cursor: default; }
574
581
  .system.alert { color: var(--terror); text-shadow: 0 0 10px color-mix(in srgb, var(--terror) 70%, transparent); letter-spacing: .14em; animation: terror-in 1.4s steps(3, end) 1; }
575
582
  .system.alert b { color: var(--terror); }
576
583
  .system.alert .cmd { display: inline-block; margin-left: 8px; padding: 1px 8px; border: 1px solid var(--terror); letter-spacing: .2em; }
@@ -643,6 +650,7 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
643
650
  .picker .mode-chip.m1 { --mode: var(--mode-1); }
644
651
  .picker .mode-chip.m2 { --mode: var(--mode-2); }
645
652
  .picker .mode-chip.m3 { --mode: var(--mode-3); }
653
+ .picker .mode-chip.m4 { --mode: var(--mode-4); }
646
654
  .mode-menu { width: min(380px, calc(100vw - 24px)); padding: 12px; }
647
655
  .mode-menu .model-menu-title { margin: 4px 10px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
648
656
  .mode-menu .mode-ladder { display: grid; gap: 2px; }
@@ -653,6 +661,7 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
653
661
  .mode-menu .mode-option.o1.current { --mode: var(--mode-1); }
654
662
  .mode-menu .mode-option.o2.current { --mode: var(--mode-2); }
655
663
  .mode-menu .mode-option.o3 { --mode: var(--mode-3); }
664
+ .mode-menu .mode-option.o4 { --mode: var(--mode-4); }
656
665
  .mode-menu .mode-option.locked { opacity: .45; }
657
666
  .mode-menu .mode-option.locked:hover { opacity: 1; }
658
667
  .mode-menu .mode-option:disabled { opacity: .3; cursor: default; }
@@ -661,11 +670,11 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
661
670
  .mode-menu .mode-option .body { display: grid; gap: 3px; min-width: 0; }
662
671
  .mode-menu .mode-option .name { font-size: 11px; font-weight: 700; letter-spacing: .14em; color: var(--mode); }
663
672
  .mode-menu .mode-option.current .name, .mode-menu .mode-option:hover .name { color: var(--mode); }
664
- .mode-menu .mode-option:not(.current):not(.o3) .name { color: var(--text); }
673
+ .mode-menu .mode-option:not(.current):not(.o3):not(.o4) .name { color: var(--text); }
665
674
  .mode-menu .mode-option .body { gap: 5px; }
666
675
  .mode-menu .mode-option .hint { display: block; font-size: 10.5px; line-height: 1.5; letter-spacing: 0; color: var(--text-2); text-transform: none; font-family: var(--sans, inherit); }
667
676
  .mode-menu .mode-option .tag { display: inline-flex; align-items: center; gap: 7px; font-size: 8.5px; font-weight: 700; letter-spacing: .16em; color: var(--mode); white-space: nowrap; align-self: center; margin-left: 4px; }
668
- .mode-menu .mode-option .tag.raise { border: 1px solid var(--mode-3); border-radius: 999px; padding: 3px 9px; }
677
+ .mode-menu .mode-option .tag.raise { border: 1px solid var(--mode); border-radius: 999px; padding: 3px 9px; }
669
678
  .mode-menu .mode-option .tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--mode); box-shadow: 0 0 8px var(--mode); animation: mode-dot 1.4s ease-in-out infinite; }
670
679
  @keyframes mode-dot { 0%, 100% { opacity: 1; } 50% { opacity: .15; } }
671
680
  @media (prefers-reduced-motion: reduce) { .mode-menu .mode-option .tag .dot { animation: none; } }
@@ -677,10 +686,13 @@ dialog.mother::backdrop { background: rgba(0, 0, 0, .82); backdrop-filter: blur(
677
686
  .composer.control { width: min(1180px, 100% - 40px); }
678
687
  .composer.control .field { border-color: var(--terror); box-shadow: 0 0 0 3px color-mix(in srgb, var(--terror) 18%, transparent), 0 0 30px color-mix(in srgb, var(--terror) 20%, transparent); }
679
688
  .composer.control .crew-label { color: var(--terror); text-shadow: 0 0 8px color-mix(in srgb, var(--terror) 60%, transparent); }
689
+ /* AIRLOCK: the same width as CONTROL, a cold ring instead of the red one. */
690
+ .composer.airlock .field { border-color: var(--mode-4); box-shadow: 0 0 0 3px color-mix(in srgb, var(--mode-4) 18%, transparent), 0 0 30px color-mix(in srgb, var(--mode-4) 16%, transparent); }
691
+ .composer.airlock .crew-label { color: var(--mode-4); text-shadow: 0 0 8px color-mix(in srgb, var(--mode-4) 55%, transparent); }
680
692
  .composer.intruder .crew-label { color: var(--terror); text-shadow: 0 0 8px color-mix(in srgb, var(--terror) 60%, transparent); animation: blink 1.6s steps(1) infinite; }
681
693
  /* Mode badges on bubbles and plan steps */
682
694
  .who .badge.mode { font-weight: 700; letter-spacing: .1em; }
683
- .who .badge.mode.m0 { color: var(--mode-0); border-color: color-mix(in srgb, var(--mode-0) 50%, transparent); } .who .badge.mode.m1 { color: var(--mode-1); border-color: color-mix(in srgb, var(--mode-1) 45%, transparent); } .who .badge.mode.m2 { color: var(--mode-2); border-color: color-mix(in srgb, var(--mode-2) 50%, transparent); } .who .badge.mode.m3 { color: var(--mode-3); border-color: var(--mode-3); }
695
+ .who .badge.mode.m0 { color: var(--mode-0); border-color: color-mix(in srgb, var(--mode-0) 50%, transparent); } .who .badge.mode.m1 { color: var(--mode-1); border-color: color-mix(in srgb, var(--mode-1) 45%, transparent); } .who .badge.mode.m2 { color: var(--mode-2); border-color: color-mix(in srgb, var(--mode-2) 50%, transparent); } .who .badge.mode.m3 { color: var(--mode-3); border-color: var(--mode-3); } .who .badge.mode.m4 { color: var(--mode-4); border-color: var(--mode-4); }
684
696
  .row.ghost .bubble { border: 1px dashed var(--line-2); background: transparent; opacity: .85; }
685
697
  .row.ghost.user .bubble { background: transparent; color: var(--text); border-color: var(--text-3); }
686
698
  .row.ghost .stamp::after { content: ' · not recorded'; color: var(--text-3); }
@@ -1040,6 +1052,7 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1040
1052
  :root[data-scheme="light"] .field button[type="submit"] { box-shadow: 0 2px 8px rgba(0, 0, 0, .14); }
1041
1053
  :root[data-scheme="light"] .user .bubble { box-shadow: 0 3px 12px rgba(0, 0, 0, .08); }
1042
1054
  :root[data-scheme="light"] .composer.control .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--terror) 30%, transparent); }
1055
+ :root[data-scheme="light"] .composer.airlock .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--mode-4) 35%, transparent); }
1043
1056
  :root[data-scheme="light"] .composer.creating .field, :root[data-scheme="light"] .composer.creating.ordering .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--mode-2) 30%, transparent); }
1044
1057
  :root[data-scheme="light"] .composer.ordering .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--brew) 22%, transparent); }
1045
1058
  :root[data-scheme="light"] .composer.dropping .field { box-shadow: 0 0 0 2px color-mix(in srgb, var(--phosphor) 35%, transparent); }
@@ -1068,3 +1081,8 @@ dialog.viewer::backdrop { background: rgba(0,0,0,.7); backdrop-filter: blur(4px)
1068
1081
  .module-card .actions .toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 9.5px; letter-spacing: .14em; color: var(--ph-dim); cursor: pointer; }
1069
1082
  .module-card .actions .toggle input { accent-color: #9bff66; }
1070
1083
  .mother-section.sentinel .note, .mother-section.update .note { margin: 0 0 10px; font-size: 10px; letter-spacing: .12em; line-height: 1.7; color: var(--ph-dim); }
1084
+
1085
+ /* Motion of controls: every colour change eases over 220 ms. Nothing snaps. */
1086
+ .mother-button, .tree-button, .stop-all, .mother-close, .mother-section button, .seg-option, .mode-chip, .model-chip, .bubble-actions .act, .update-command button, .codeblock .copy { transition: background-color .22s ease, color .22s ease, border-color .22s ease, box-shadow .22s ease, opacity .22s ease, transform .16s ease; }
1087
+ .mother-button:active, .tree-button:active, .mother-close:active, .mother-section button:active, .update-command button:active { transform: scale(.98); }
1088
+ @media (prefers-reduced-motion: reduce) { .mother-button, .tree-button, .stop-all, .mother-close, .mother-section button, .seg-option, .mode-chip, .model-chip, .bubble-actions .act, .update-command button, .update-pill, .update-restart { transition: none; } }
@@ -299,20 +299,20 @@ export const CONDITIONS = [
299
299
  {
300
300
  id: 'modes',
301
301
  severity: 'informational',
302
- title: 'Permission modes: #0 GHOST · #1 EXCHANGE · #2 CREATE · #3 CONTROL',
303
- match: /mode|ghost|exchange|control|override|designation|max mode|#[0-3]\b/i,
304
- diagnosis: 'Every message goes out at a mode, chosen in the chip after TO @agent (or typed as #2 in the text). #0 GHOST is off the record: nothing is saved, no other agent remembers it, gone on reload, no delegation. #1 EXCHANGE is the default: read and coordinate. #2 CREATE is the creation lease: files and images inside .pulse/out/. #3 CONTROL puts one agent in command of the project itself: a git checkpoint is taken first, every change is listed afterwards, writes into .git, .pulse or .env files are reverted on the spot, and UNDO restores the checkpoint. One holder at a time; it needs MAX MODE 3 and a git repository. Your mode is the ceiling of any plan the message starts, and #3 is never delegated. Each agent has a MAX MODE in CONNECTIONS; above it, #2 is answered read-only and #3 is refused. When a #1 plan reaches a step that wants to create something, the room pauses and asks you: GRANT ONCE, GRANT FOR PLAN or DENY, with a 3-minute clock; silence denies.',
305
- remedy: 'Pick the mode in the chip, or type #0..#3 in the message. Raise an agent\'s MAX MODE in ⚙ CONNECTIONS. CONTROL asks for the project designation (the folder name) in the override before arming.',
306
- fixes: same(['# chip: TO @codex #1 EXCHANGE ▾ → choose', '@codex #2 create the poster', '# ⚙ CONNECTIONS → agent card → MAX MODE']),
302
+ title: 'Permission modes: #0 GHOST · #1 EXCHANGE · #2 CREATE · #3 CONTROL · #4 AIRLOCK',
303
+ match: /mode|ghost|exchange|control|airlock|deploy|despleg|producci|override|designation|max mode|#[0-4]\b/i,
304
+ diagnosis: 'Every message goes out at a mode, chosen in the chip after TO @agent (or typed as #2 in the text). #0 GHOST is off the record: nothing is saved, no other agent remembers it, gone on reload, no delegation. #1 EXCHANGE is the default: read and coordinate. #2 CREATE lets the agent add new files and folders anywhere in the project, where they belong; whatever existed before is put back after the turn and the room says so. #3 CONTROL puts one agent in command of the project itself: a git checkpoint is taken first, every change is listed afterwards, writes into .git, .pulse or .env files are reverted on the spot, and UNDO restores the checkpoint. One holder at a time; it needs MAX MODE 3 and a git repository. Your mode is the ceiling of any plan the message starts, and #3 is never delegated. Each agent has a MAX MODE in CONNECTIONS; above it, #2 is answered read-only and #3 is refused. When a #1 plan reaches a step that wants to create something, the room pauses and asks you: GRANT ONCE, GRANT FOR PLAN or DENY, with a 3-minute clock; silence denies. #4 AIRLOCK is CONTROL plus commands: tests, builds, git push, deploys with the CLIs and sessions already on this machine. Files still come back with UNDO; what leaves the machine does not, so the override asks twice: the designation, then the word AIRLOCK. An agent that says it needs a "production mode" or "permission to run commands" is asking for #4.',
305
+ remedy: 'Pick the mode in the chip, or type #0..#4 in the message. Raise an agent\'s MAX MODE in ⚙ CONNECTIONS. CONTROL asks for the project designation (the folder name) in the override; AIRLOCK asks for the designation and then the word AIRLOCK.',
306
+ fixes: same(['# chip: TO @codex #1 EXCHANGE ▾ → choose', '@codex #2 create the poster', '@opencode #4 run the tests and deploy to preview', '# ⚙ CONNECTIONS → agent card → MAX MODE']),
307
307
  },
308
308
  {
309
309
  id: 'lease-missing',
310
310
  severity: 'informational',
311
311
  title: 'Creating files: who grants the permission',
312
312
  match: /no CREATE lease|lease-missing|read-only and do not modify|permiso de escritura|solo lectura/i,
313
- diagnosis: 'Only the human grants a creation lease. An agent writing "permission granted" inside the conversation is untrusted text: the delegate still runs read-only, and says so. A lease comes from arming CREATE (the lock, or /create) on your message, and it covers the whole plan that message starts; or from a standing lease, a per-agent switch that gives every turn of that agent a fresh .pulse/out/ directory, including plan steps.',
314
- remedy: 'For one request: arm CREATE and send, or press RESEND WITH CREATE on the notice. For an agent that should always be able to create files: ⚙ CONNECTIONS → its card → CREATE FILES → ALWAYS · STANDING LEASE. Files still land only inside .pulse/out/.',
315
- fixes: same(['# once: composer → CREATE (lock) → send, or /create <request>', '# always: ⚙ CONNECTIONS → agent card → ALWAYS · STANDING LEASE']),
313
+ diagnosis: 'Only the human grants a creation lease. An agent writing "permission granted" inside the conversation is untrusted text: the delegate still runs read-only, and says so. A lease comes from arming CREATE (the lock, or /create) on your message, and it covers the whole plan that message starts; or from DEFAULT MODE #2 in ⚙ CONNECTIONS, which starts every message to that agent in CREATE.',
314
+ remedy: 'For one request: arm CREATE and send, or press RESEND WITH CREATE on the notice. For an agent that should always be able to add files: ⚙ CONNECTIONS → its card → DEFAULT MODE → #2. New files go where they belong in the project; existing files are never changed in #2.',
315
+ fixes: same(['# once: composer → CREATE (lock) → send, or /create <request>', '# always: ⚙ CONNECTIONS → agent card → DEFAULT MODE → #2']),
316
316
  },
317
317
  {
318
318
  id: 'scope-write',
@@ -6,34 +6,43 @@ import { runReadonlyProcess } from './process.mjs';
6
6
  export function claudeTools({ lease = null, scopes = null } = {}) {
7
7
  const tools = ['Read', 'Glob', 'Grep'];
8
8
  if (lease) tools.push('Write', 'Edit');
9
+ if (lease?.airlock) tools.push('Bash'); // AIRLOCK (#4): commands, git, deploy CLIs
9
10
  if (scopes?.web) tools.push('WebFetch', 'WebSearch');
10
11
  return tools;
11
12
  }
12
13
 
13
- export function mcpServersFor({ imageStudio = null, memoryServer = null } = {}) {
14
+ export function mcpServersFor({ imageStudio = null, memoryServer = null, mcpServers = [] } = {}) {
14
15
  const servers = {};
15
16
  if (memoryServer) servers[memoryServer.name] = { command: memoryServer.command, args: memoryServer.args, env: memoryServer.env };
16
17
  if (imageStudio) servers[imageStudio.name] = { command: imageStudio.command, args: imageStudio.args, env: imageStudio.env };
18
+ for (const server of mcpServers) servers[server.name] = { command: server.command, args: server.args ?? [], env: server.env ?? {} };
17
19
  return servers;
18
20
  }
21
+ // Claude's permission names for a module's server: each tool, or the whole server when the list is open.
22
+ export const claudeMcpTools = (server) => (server.tools?.length ? server.tools.map((tool) => `mcp__${server.name}__${tool}`) : [`mcp__${server.name}`]);
19
23
 
20
- export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, lease = null, scopes = null, imageStudio = null, memoryServer = null }) {
24
+ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] }) {
21
25
  const tools = claudeTools({ lease, scopes });
22
26
  const mcpTools = [
23
27
  ...(memoryServer ? memoryServer.tools.map((tool) => `mcp__${memoryServer.name}__${tool}`) : []),
24
28
  ...(imageStudio ? [`mcp__${imageStudio.name}__${imageStudio.tool}`] : []),
29
+ ...mcpServers.flatMap(claudeMcpTools),
25
30
  ];
26
31
  const allowed = [
27
32
  'Read', 'Glob', 'Grep',
28
33
  // Claude Code reads `/path` as relative to the project and `//path` as an absolute path.
34
+ // Claude Code grants Write only when Edit is allowed on the same paths (verified with the real
35
+ // CLI: a lone Write rule is denied under dontAsk), so both come together; in CREATE the room
36
+ // restores existing files after the turn.
29
37
  ...(lease ? [`Write(//${lease.outDir}/**)`, `Edit(//${lease.outDir}/**)`] : []),
38
+ ...(lease?.airlock ? ['Bash'] : []),
30
39
  ...(scopes?.web ? ['WebFetch', 'WebSearch'] : []),
31
40
  ...mcpTools,
32
41
  ];
33
42
  // Only MADRE's own MCP servers ever reach Claude here; --strict-mcp-config
34
43
  // keeps the user's servers out of the isolated run.
35
- const anyMcp = Boolean(imageStudio || memoryServer);
36
- const mcpConfig = JSON.stringify({ mcpServers: mcpServersFor({ imageStudio, memoryServer }) });
44
+ const anyMcp = Boolean(imageStudio || memoryServer || mcpServers.length);
45
+ const mcpConfig = JSON.stringify({ mcpServers: mcpServersFor({ imageStudio, memoryServer, mcpServers }) });
37
46
  return [
38
47
  '-p',
39
48
  ...(model ? ['--model', model] : []),
@@ -44,7 +53,7 @@ export function buildClaudeArgs({ prompt, model = null, attachmentsDir = null, l
44
53
  '--tools', tools.join(','),
45
54
  ...(lease || scopes?.web || anyMcp ? ['--allowedTools', allowed.join(',')] : []),
46
55
  // CONTROL: the whole project is writable except MADRE's forbidden zones.
47
- ...(lease?.control ? ['--disallowedTools', ['.git/**', '.pulse/**', '.env', '.env.*', '**/.env', '**/.env.*'].flatMap((glob) => [`Write(//${lease.outDir}/${glob})`, `Edit(//${lease.outDir}/${glob})`]).join(',')] : []),
56
+ ...(lease?.control || lease?.create ? ['--disallowedTools', ['.git/**', '.pulse/**', '.madre/**', '.env', '.env.*', '**/.env', '**/.env.*', '.claude/settings.local.json'].flatMap((glob) => [`Write(//${lease.outDir}/${glob})`, `Edit(//${lease.outDir}/${glob})`]).join(',')] : []),
48
57
  // --safe-mode disables every MCP server, ours included. With a MADRE server
49
58
  // attached we drop it and instead load no setting sources at all: no user
50
59
  // hooks, plugins or MCP servers, only the project's CLAUDE.md and ours.
@@ -92,10 +101,10 @@ export function parseClaudeOutput(output) {
92
101
  }
93
102
  }
94
103
 
95
- export function invokeClaude({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null }) {
104
+ export function invokeClaude({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] }) {
96
105
  return runReadonlyProcess({
97
106
  executable,
98
- args: buildClaudeArgs({ prompt, model, attachmentsDir: attachments[0]?.dir ?? null, lease, scopes, imageStudio, memoryServer }),
107
+ args: buildClaudeArgs({ prompt, model, attachmentsDir: attachments[0]?.dir ?? null, lease, scopes, imageStudio, memoryServer, mcpServers }),
99
108
  cwd: projectRoot,
100
109
  env: process.env,
101
110
  timeoutMs,
@@ -12,25 +12,28 @@ export function tomlValue(value) {
12
12
  // The room's memory as an MCP server for this run only: Codex reads
13
13
  // mcp_servers.<name> from its config, and -c overrides it on the command line
14
14
  // without touching the user's config.toml.
15
- export function codexMcpOverrides(memoryServer) {
16
- if (!memoryServer) return [];
17
- const key = `mcp_servers.${memoryServer.name}`;
18
- return [
19
- '-c', `${key}.command=${tomlValue(memoryServer.command)}`,
20
- '-c', `${key}.args=${tomlValue(memoryServer.args)}`,
21
- '-c', `${key}.env=${tomlValue(memoryServer.env ?? {})}`,
22
- ];
15
+ export function codexMcpOverrides(servers) {
16
+ const list = (Array.isArray(servers) ? servers : [servers]).filter(Boolean);
17
+ return list.flatMap((server) => {
18
+ const key = `mcp_servers.${server.name}`;
19
+ return [
20
+ '-c', `${key}.command=${tomlValue(server.command)}`,
21
+ '-c', `${key}.args=${tomlValue(server.args ?? [])}`,
22
+ '-c', `${key}.env=${tomlValue(server.env ?? {})}`,
23
+ ];
24
+ });
23
25
  }
24
26
 
25
- export function buildCodexArgs({ projectRoot, prompt, model = null, attachments = [], lease = null, scopes = null, memoryServer = null }) {
27
+ export function buildCodexArgs({ projectRoot, prompt, model = null, attachments = [], lease = null, scopes = null, memoryServer = null, mcpServers = [] }) {
26
28
  const images = attachments.filter((file) => /^image\//.test(file.contentType ?? ''));
27
29
  return [
28
- '--sandbox', lease ? 'workspace-write' : 'read-only',
30
+ // AIRLOCK (#4): commands with network, so pushes and deploys can leave; otherwise the sandbox.
31
+ '--sandbox', lease?.airlock ? 'danger-full-access' : lease ? 'workspace-write' : 'read-only',
29
32
  '--ask-for-approval', 'never',
30
33
  // Live web search is a global Codex flag; the human's web scope decides.
31
34
  ...(scopes?.web ? ['--search'] : []),
32
35
  '-C', lease ? lease.outDir : projectRoot,
33
- ...codexMcpOverrides(memoryServer),
36
+ ...codexMcpOverrides([memoryServer, ...mcpServers]),
34
37
  // Image generation is a Codex feature; the human's scope decides per turn.
35
38
  ...(lease && (scopes?.imageGen === false || lease.scopes?.imageGen === false) ? ['-c', 'features.image_generation=false'] : []),
36
39
  'exec',
@@ -70,10 +73,10 @@ export function parseCodexOutput(output) {
70
73
  return { text: text.trim(), usage };
71
74
  }
72
75
 
73
- export function invokeCodex({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, memoryServer = null }) {
76
+ export function invokeCodex({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, memoryServer = null, mcpServers = [] }) {
74
77
  return runReadonlyProcess({
75
78
  executable,
76
- args: buildCodexArgs({ projectRoot, prompt, model, attachments, lease, scopes, memoryServer }),
79
+ args: buildCodexArgs({ projectRoot, prompt, model, attachments, lease, scopes, memoryServer, mcpServers }),
77
80
  cwd: lease ? lease.outDir : projectRoot,
78
81
  env: process.env,
79
82
  timeoutMs,
@@ -27,19 +27,30 @@ export const geminiCredentialFiles = ['oauth_creds.json', 'google_accounts.json'
27
27
  // whose file_path argument starts with the lease directory. Plan mode would
28
28
  // block every write regardless of policy, so a lease uses approval "default":
29
29
  // headless Gemini cannot prompt, so anything the policy does not allow fails.
30
- export function geminiLeasePolicy(outDir, { control = false } = {}) {
30
+ export function geminiLeasePolicy(outDir, { control = false, create = false, airlock = false } = {}) {
31
31
  const escaped = outDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
- const forbidden = control ? `
32
+ // The whole project is the lease in #2 and #3: MADRE's zones stay out of reach.
33
+ const forbidden = control || create ? `
33
34
  [[rule]]
34
35
  toolName = ["write_file", "replace", "edit", "run_shell_command"]
35
- argsPattern = '${escaped}/(\\.git|\\.pulse|\\.env)'
36
+ argsPattern = '${escaped}/(\\.git|\\.pulse|\\.madre|\\.env|\\.claude/settings\\.local\\.json)'
36
37
  decision = "deny"
37
38
  priority = 1100
38
39
  interactive = false
39
40
  ` : '';
40
- return `${geminiReadonlyPolicy}
41
+ // CREATE (#2) adds files: write_file only; CONTROL (#3) also replaces and edits.
42
+ const tools = create ? '["write_file"]' : '["write_file", "replace", "edit"]';
43
+ // AIRLOCK (#4): commands too; the forbidden-zone rule above still outranks this one.
44
+ const commands = airlock ? `
41
45
  [[rule]]
42
- toolName = ["write_file", "replace", "edit"]
46
+ toolName = "run_shell_command"
47
+ decision = "allow"
48
+ priority = 1000
49
+ interactive = false
50
+ ` : '';
51
+ return `${geminiReadonlyPolicy}${commands}
52
+ [[rule]]
53
+ toolName = ${tools}
43
54
  argsPattern = '"file_path"\\s*:\\s*"${escaped}/'
44
55
  decision = "allow"
45
56
  priority = 1000
@@ -55,12 +66,13 @@ priority = 999
55
66
  interactive = false
56
67
  `;
57
68
 
58
- export function geminiPolicy({ lease = null, scopes = null, imageStudio = null, memoryServer = null } = {}) {
59
- return `${lease ? geminiLeasePolicy(lease.outDir, { control: Boolean(lease.control) }) : geminiReadonlyPolicy}${scopes?.web ? geminiWebPolicy : ''}${imageStudio && lease ? geminiImagePolicy(imageStudio) : ''}${memoryServer ? geminiMemoryPolicy(memoryServer) : ''}`;
69
+ export function geminiPolicy({ lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] } = {}) {
70
+ return `${lease ? geminiLeasePolicy(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create), airlock: Boolean(lease.airlock) }) : geminiReadonlyPolicy}${scopes?.web ? geminiWebPolicy : ''}${imageStudio && lease ? geminiImagePolicy(imageStudio) : ''}${memoryServer ? geminiMemoryPolicy(memoryServer) : ''}${mcpServers.map(geminiMemoryPolicy).join('')}`;
60
71
  }
61
72
 
73
+ // Allow rules for a server's tools, by bare name and by server-prefixed name; an open tool list allows the server's prefix.
62
74
  export function geminiMemoryPolicy(memoryServer) {
63
- const names = memoryServer.tools.flatMap((tool) => [tool, `${memoryServer.name}__${tool}`]);
75
+ const names = memoryServer.tools?.length ? memoryServer.tools.flatMap((tool) => [tool, `${memoryServer.name}__${tool}`]) : [`${memoryServer.name}__*`];
64
76
  return `
65
77
  [[rule]]
66
78
  toolName = [${names.map((name) => `"${name}"`).join(', ')}]
@@ -109,10 +121,10 @@ export function buildGeminiEnvironment({ runtimeRoot, environment = process.env
109
121
  };
110
122
  }
111
123
 
112
- export function isolateGeminiSettings(settings, { imageStudio = null, memoryServer = null } = {}) {
124
+ export function isolateGeminiSettings(settings, { imageStudio = null, memoryServer = null, mcpServers = [] } = {}) {
113
125
  const auth = settings?.security?.auth;
114
126
  const isolated = auth ? { security: { auth } } : {};
115
- for (const server of [memoryServer, imageStudio]) {
127
+ for (const server of [memoryServer, imageStudio, ...mcpServers]) {
116
128
  if (!server) continue;
117
129
  isolated.mcpServers ??= {};
118
130
  isolated.mcpServers[server.name] = { command: server.command, args: server.args, env: server.env, trust: true };
@@ -130,7 +142,7 @@ interactive = false
130
142
  `;
131
143
  }
132
144
 
133
- export async function prepareGeminiHome({ runtimeRoot, sourceHome = join(homedir(), '.gemini'), imageStudio = null, memoryServer = null }) {
145
+ export async function prepareGeminiHome({ runtimeRoot, sourceHome = join(homedir(), '.gemini'), imageStudio = null, memoryServer = null, mcpServers = [] }) {
134
146
  const geminiDir = join(runtimeRoot, '.gemini');
135
147
  await mkdir(geminiDir, { recursive: true, mode: 0o700 });
136
148
  for (const name of geminiCredentialFiles) {
@@ -144,7 +156,7 @@ export async function prepareGeminiHome({ runtimeRoot, sourceHome = join(homedir
144
156
  } catch (error) {
145
157
  if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
146
158
  }
147
- await writeFile(join(geminiDir, 'settings.json'), JSON.stringify(isolateGeminiSettings(settings, { imageStudio, memoryServer })), { mode: 0o600 });
159
+ await writeFile(join(geminiDir, 'settings.json'), JSON.stringify(isolateGeminiSettings(settings, { imageStudio, memoryServer, mcpServers })), { mode: 0o600 });
148
160
  return geminiDir;
149
161
  }
150
162
 
@@ -256,6 +268,7 @@ export async function invokeGemini({
256
268
  scopes = null,
257
269
  imageStudio = null,
258
270
  memoryServer = null,
271
+ mcpServers = [],
259
272
  idleTimeoutMs = Number(process.env.PULSE_GEMINI_IDLE_MS ?? 90000),
260
273
  retries = Number(process.env.PULSE_GEMINI_RETRIES ?? 1),
261
274
  fallbackModel = process.env.PULSE_GEMINI_FALLBACK_MODEL ?? 'gemini-2.5-flash',
@@ -265,8 +278,8 @@ export async function invokeGemini({
265
278
  const runtimeRoot = await mkdtemp(join(tmpdir(), 'pulse-gemini-'));
266
279
  const policyPath = join(runtimeRoot, 'readonly.toml');
267
280
  try {
268
- await writeFile(policyPath, geminiPolicy({ lease, scopes, imageStudio: lease ? imageStudio : null, memoryServer }), { mode: 0o600 });
269
- await prepareGeminiHome({ runtimeRoot, imageStudio: lease ? imageStudio : null, memoryServer });
281
+ await writeFile(policyPath, geminiPolicy({ lease, scopes, imageStudio: lease ? imageStudio : null, memoryServer, mcpServers }), { mode: 0o600 });
282
+ await prepareGeminiHome({ runtimeRoot, imageStudio: lease ? imageStudio : null, memoryServer, mcpServers });
270
283
  let attempt = 0;
271
284
  let currentModel = model;
272
285
  let switched = false;
@@ -34,27 +34,47 @@ export function buildOpenCodeArgs({ projectRoot, prompt, model = process.env.PUL
34
34
  ];
35
35
  }
36
36
 
37
- export function leaseConfig(outDir, { control = false } = {}) {
38
- const forbidden = control ? Object.fromEntries(['.git/**', '.pulse/**', '.env', '.env.*', '**/.env', '**/.env.*'].map((glob) => [`${outDir}/${glob}`, 'deny'])) : {};
37
+ // OpenCode matches permission patterns against paths relative to `--dir`, last matching rule
38
+ // wins, and creating a file is the `write` tool while changing one is `edit`: both need a rule.
39
+ // Verified against opencode 1.18.4: absolute patterns never match, so the lease is named by
40
+ // its directory relative to the project.
41
+ export const FORBIDDEN_GLOBS = ['.git/**', '.pulse/**', '.madre/**', '.env', '.env.*', '**/.env', '**/.env.*', '.claude/settings.local.json'];
42
+ export function writeRules(relativeDir, { control = false, create = false } = {}) {
43
+ if (control || create) return { '*': 'allow', ...Object.fromEntries(FORBIDDEN_GLOBS.map((glob) => [glob, 'deny'])) };
44
+ const dir = String(relativeDir ?? '.').replace(/^\.\/+/, '').replace(/\/+$/, '');
45
+ return { '*': 'deny', [dir && dir !== '.' ? `${dir}/**` : '**']: 'allow' };
46
+ }
47
+
48
+ export function leaseConfig(outDir, { control = false, create = false, airlock = false, relativeDir = null } = {}) {
49
+ const rules = writeRules(relativeDir ?? outDir, { control, create });
50
+ // OpenCode gates its `write` tool behind the `edit` permission as well (denying edit leaves "no
51
+ // file-writing tool"), so CREATE allows both and the room restores existing files afterwards.
52
+ const editRules = rules;
39
53
  return {
40
54
  ...readonlyConfig,
41
55
  agent: {
42
56
  'pulse-readonly': {
43
57
  ...readonlyConfig.agent['pulse-readonly'],
44
- prompt: control
45
- ? 'Answer the user directly. You are in CONTROL of this project: create and edit files anywhere inside it except .git, .pulse and .env files. Do not run commands, browse the web, or launch subagents.'
46
- : 'Answer the user directly. Inspect project files when necessary. You may create or edit files only inside the creation lease directory named in the request; never elsewhere. Do not run commands, browse the web, or launch subagents.',
58
+ prompt: airlock
59
+ ? 'Answer the user directly. AIRLOCK: you are in command of this project and may run commands in it, including git and deploy CLIs with the sessions already on this machine. Say what will leave the machine before it does. Never print secrets. Do not launch subagents.'
60
+ : control
61
+ ? 'Answer the user directly. You are in CONTROL of this project: create and edit files anywhere inside it except .git, .pulse, .madre and .env files. Do not run commands, browse the web, or launch subagents.'
62
+ : create
63
+ ? 'Answer the user directly. You may create new files and folders anywhere in this project where they belong; do not modify or delete existing files. Do not run commands, browse the web, or launch subagents.'
64
+ : 'Answer the user directly. Inspect project files when necessary. You may create or edit files only inside the creation lease directory named in the request; never elsewhere. Do not run commands, browse the web, or launch subagents.',
47
65
  permission: {
48
66
  ...readonlyConfig.agent['pulse-readonly'].permission,
49
- edit: { '*': 'deny', [`${outDir}/**`]: 'allow', ...forbidden },
67
+ edit: editRules,
68
+ write: rules,
69
+ ...(airlock ? { bash: 'allow' } : {}), // AIRLOCK (#4): commands, git, deploy CLIs
50
70
  },
51
71
  },
52
72
  },
53
73
  };
54
74
  }
55
75
 
56
- export function openCodeConfig({ lease = null, scopes = null, imageStudio = null, memoryServer = null } = {}) {
57
- let config = lease ? leaseConfig(lease.outDir, { control: Boolean(lease.control) }) : readonlyConfig;
76
+ export function openCodeConfig({ lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] } = {}) {
77
+ let config = lease ? leaseConfig(lease.outDir, { control: Boolean(lease.control), create: Boolean(lease.create), airlock: Boolean(lease.airlock), relativeDir: lease.relativeDir ?? null }) : readonlyConfig;
58
78
  if (scopes?.web) {
59
79
  config = {
60
80
  ...config,
@@ -73,17 +93,17 @@ export function openCodeConfig({ lease = null, scopes = null, imageStudio = null
73
93
  mcp: { ...(config.mcp ?? {}), [imageStudio.name]: { type: 'local', command: [imageStudio.command, ...imageStudio.args], environment: imageStudio.env, enabled: true } },
74
94
  };
75
95
  }
76
- if (memoryServer) {
96
+ for (const server of [memoryServer, ...mcpServers].filter(Boolean)) {
77
97
  config = {
78
98
  ...config,
79
- mcp: { ...(config.mcp ?? {}), [memoryServer.name]: { type: 'local', command: [memoryServer.command, ...memoryServer.args], environment: memoryServer.env, enabled: true } },
99
+ mcp: { ...(config.mcp ?? {}), [server.name]: { type: 'local', command: [server.command, ...(server.args ?? [])], environment: server.env ?? {}, enabled: true } },
80
100
  agent: {
81
101
  'pulse-readonly': {
82
102
  ...config.agent['pulse-readonly'],
83
103
  permission: {
84
104
  ...config.agent['pulse-readonly'].permission,
85
- [`${memoryServer.name}*`]: 'allow',
86
- ...Object.fromEntries(memoryServer.tools.map((tool) => [`${memoryServer.name}_${tool}`, 'allow'])),
105
+ [`${server.name}*`]: 'allow',
106
+ ...Object.fromEntries((server.tools ?? []).map((tool) => [`${server.name}_${tool}`, 'allow'])),
87
107
  },
88
108
  },
89
109
  },
@@ -92,11 +112,11 @@ export function openCodeConfig({ lease = null, scopes = null, imageStudio = null
92
112
  return config;
93
113
  }
94
114
 
95
- export function openCodeEnvironment(environment = process.env, { lease = null, scopes = null, imageStudio = null, memoryServer = null } = {}) {
115
+ export function openCodeEnvironment(environment = process.env, { lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] } = {}) {
96
116
  return {
97
117
  ...environment,
98
118
  OPENCODE_AUTO_SHARE: 'false',
99
- OPENCODE_CONFIG_CONTENT: JSON.stringify(openCodeConfig({ lease, scopes, imageStudio, memoryServer })),
119
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(openCodeConfig({ lease, scopes, imageStudio, memoryServer, mcpServers })),
100
120
  OPENCODE_DISABLE_AUTOUPDATE: 'true',
101
121
  };
102
122
  }
@@ -134,12 +154,12 @@ export function parseOpenCodeOutput(output) {
134
154
  return { text: text.join('').trim(), usage, ...(error ? { error } : {}) };
135
155
  }
136
156
 
137
- export function invokeOpenCode({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null }) {
157
+ export function invokeOpenCode({ executable, projectRoot, prompt, timeoutMs = 120000, signal, model = null, attachments = [], lease = null, scopes = null, imageStudio = null, memoryServer = null, mcpServers = [] }) {
138
158
  return runReadonlyProcess({
139
159
  executable,
140
160
  args: buildOpenCodeArgs({ projectRoot, prompt, attachments, ...(model ? { model } : {}) }),
141
161
  cwd: projectRoot,
142
- env: openCodeEnvironment(process.env, { lease, scopes, imageStudio, memoryServer }),
162
+ env: openCodeEnvironment(process.env, { lease, scopes, imageStudio, memoryServer, mcpServers }),
143
163
  timeoutMs,
144
164
  signal,
145
165
  label: 'OpenCode',
@@ -87,8 +87,9 @@ export const MODES = {
87
87
  1: { key: 'exchange', label: 'EXCHANGE', hint: 'read the project and coordinate · default' },
88
88
  2: { key: 'create', label: 'CREATE', hint: 'create files and images inside .pulse/out/' },
89
89
  3: { key: 'control', label: 'CONTROL', hint: 'modify the project itself · only this agent · needs the override' },
90
+ 4: { key: 'airlock', label: 'AIRLOCK', hint: 'run commands, push, deploy · what leaves the ship does not come back · override, twice' },
90
91
  };
91
- export const MODE_MAX = 3;
92
+ export const MODE_MAX = 4;
92
93
  export const normalizeMode = (value, fallback = 1) => {
93
94
  const mode = Number(value);
94
95
  return Number.isInteger(mode) && mode >= 0 && mode <= MODE_MAX ? mode : fallback;
@@ -108,14 +109,16 @@ export function resolveScopes(agentId, configured = {}) {
108
109
  const wanted = configured[scope] ?? (scope !== 'web');
109
110
  scopes[scope] = { capable, enabled: capable && wanted, wired: true };
110
111
  }
111
- // A standing lease: every turn of this agent may create files inside
112
- // .pulse/out/ without the human arming CREATE each time. Opt-in per agent.
113
- scopes.write.always = Boolean(configured.alwaysCreate) && scopes.write.enabled;
114
- // The ceiling: how far the composer may take this agent. Writing needs the
115
- // CLI to be able to write; CONTROL is capped at 2 until phase C wires it.
116
- const wanted = normalizeMode(configured.maxMode, scopes.write.enabled ? 2 : 1);
117
- scopes.maxMode = Math.min(wanted, scopes.write.capable ? 3 : 1);
118
- scopes.defaultMode = scopes.write.always && scopes.maxMode >= 2 ? 2 : 1;
112
+ // One ceiling per agent: MAX MODE. Writing follows from it: an agent capped at #1 never
113
+ // writes, one allowed to #2 or #3 does. A `write: false` from an older config reads as #1.
114
+ const capableMax = scopes.write.capable ? 4 : 1;
115
+ const legacyCap = configured.write === false ? 1 : 4;
116
+ scopes.maxMode = Math.min(normalizeMode(configured.maxMode, scopes.write.capable ? 2 : 1), legacyCap, capableMax);
117
+ scopes.write.enabled = scopes.write.capable && scopes.maxMode >= 2;
118
+ // One start per agent: DEFAULT MODE, #1 or #2, never above the ceiling. An older
119
+ // `alwaysCreate` reads as #2. `write.always` keeps its name for the room's code.
120
+ scopes.defaultMode = Math.min(normalizeMode(configured.defaultMode, configured.alwaysCreate ? 2 : 1), 2, Math.max(1, scopes.maxMode));
121
+ scopes.write.always = scopes.defaultMode >= 2 && scopes.write.enabled;
119
122
  return scopes;
120
123
  }
121
124