@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/CHANGELOG.md +28 -0
- package/CONTRIBUTING.md +3 -0
- package/README.md +15 -11
- package/docs/INTERNALS.md +7 -1
- package/package.json +1 -1
- package/public/app.js +146 -69
- package/public/index.html +1 -1
- package/public/styles.css +26 -8
- package/public/troubleshooting.js +8 -8
- package/src/adapters/claude.mjs +16 -7
- package/src/adapters/codex.mjs +16 -13
- package/src/adapters/gemini.mjs +27 -14
- package/src/adapters/opencode.mjs +36 -16
- package/src/capabilities.mjs +12 -9
- package/src/checkpoint.mjs +118 -21
- package/src/commands.mjs +25 -3
- package/src/directives.mjs +7 -4
- package/src/lease.mjs +15 -8
- package/src/modules/git-pulse.mjs +3 -3
- package/src/modules/index.mjs +7 -1
- package/src/modules/playwright.mjs +73 -0
- package/src/modules/sdk.mjs +11 -0
- package/src/room/control.mjs +24 -12
- package/src/room/prompt.mjs +6 -3
- package/src/room.mjs +101 -37
- package/src/server.mjs +31 -3
- package/src/updates.mjs +13 -0
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
|
-
|
|
570
|
-
.update-pill
|
|
571
|
-
.update-pill
|
|
572
|
-
|
|
573
|
-
|
|
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
|
|
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-
|
|
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
|
|
305
|
-
remedy: 'Pick the mode in the chip, or type #0..#
|
|
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
|
|
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
|
|
315
|
-
fixes: same(['# once: composer → CREATE (lock) → send, or /create <request>', '# always: ⚙ CONNECTIONS → agent card →
|
|
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',
|
package/src/adapters/claude.mjs
CHANGED
|
@@ -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,
|
package/src/adapters/codex.mjs
CHANGED
|
@@ -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(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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,
|
package/src/adapters/gemini.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
38
|
-
|
|
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:
|
|
45
|
-
? 'Answer the user directly.
|
|
46
|
-
:
|
|
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:
|
|
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
|
-
|
|
96
|
+
for (const server of [memoryServer, ...mcpServers].filter(Boolean)) {
|
|
77
97
|
config = {
|
|
78
98
|
...config,
|
|
79
|
-
mcp: { ...(config.mcp ?? {}), [
|
|
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
|
-
[`${
|
|
86
|
-
...Object.fromEntries(
|
|
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',
|
package/src/capabilities.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
//
|
|
112
|
-
//
|
|
113
|
-
scopes.write.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|