@jossuealcala/madre 0.7.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +833 -1
- package/README.md +92 -28
- package/SECURITY.md +1 -0
- package/bin/madre.mjs +30 -10
- package/docs/REFERENCE.md +158 -79
- package/docs/SDK.md +23 -19
- package/package.json +1 -1
- package/public/app.js +11599 -4186
- package/public/assets/brand/Geist-latin-ext.woff2 +0 -0
- package/public/assets/brand/Geist-latin.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/GeistMono-latin.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin-ext.woff2 +0 -0
- package/public/assets/brand/JetBrainsMono-latin.woff2 +0 -0
- package/public/assets/brand/OFL-Geist.txt +93 -0
- package/public/assets/brand/OFL-GeistMono.txt +93 -0
- package/public/assets/brand/OFL-JetBrainsMono.txt +93 -0
- package/public/assets/brand/OFL-Sora.txt +93 -0
- package/public/assets/brand/Sora-latin-ext.woff2 +0 -0
- package/public/assets/brand/Sora-latin.woff2 +0 -0
- package/public/assets/brand/madre-app-180.png +0 -0
- package/public/assets/brand/madre-app-192.png +0 -0
- package/public/assets/brand/madre-app-512.png +0 -0
- package/public/assets/brand/madre-app.svg +1 -0
- package/public/assets/brand/madre-favicon.svg +1 -0
- package/public/assets/news/1.0/alerts.webp +0 -0
- package/public/assets/news/1.0/bar.webp +0 -0
- package/public/assets/news/1.0/finder.webp +0 -0
- package/public/assets/news/1.0/github.webp +0 -0
- package/public/assets/news/1.0/intents.webp +0 -0
- package/public/assets/news/1.0/looks.webp +0 -0
- package/public/assets/news/1.0/modules.webp +0 -0
- package/public/assets/news/1.0/mother.webp +0 -0
- package/public/assets/news/1.0/nostromo.webp +0 -0
- package/public/assets/news/1.0/permissions.webp +0 -0
- package/public/assets/news/1.0/room.webp +0 -0
- package/public/assets/news/1.0/settings.webp +0 -0
- package/public/assets/wallpapers/biznaga.webp +0 -0
- package/public/assets/wallpapers/canela.webp +0 -0
- package/public/assets/wallpapers/jarrito.webp +0 -0
- package/public/assets/wallpapers/magna.webp +0 -0
- package/public/assets/wallpapers/oceano.webp +0 -0
- package/public/assets/wallpapers/paraiso.webp +0 -0
- package/public/assets/wallpapers/quila.webp +0 -0
- package/public/assets/wallpapers/tradicion.webp +0 -0
- package/public/assets/wallpapers/tropical.webp +0 -0
- package/public/assets/wallpapers/urbania.webp +0 -0
- package/public/assets/wallpapers/ventura.webp +0 -0
- package/public/assets/wallpapers/xico.webp +0 -0
- package/public/brands.js +28 -2
- package/public/editor.js +744 -0
- package/public/es.js +2863 -1008
- package/public/i18n.js +1 -1
- package/public/index.html +201 -175
- package/public/inquiry.js +11 -11
- package/public/loader.js +79 -0
- package/public/macroverso.js +1497 -0
- package/public/manifest.webmanifest +14 -0
- package/public/nostromo-engine.js +2222 -0
- package/public/resay.js +24 -0
- package/public/review-ask.js +85 -0
- package/public/salas-mac.css +787 -0
- package/public/salas-mac.html +154 -0
- package/public/salas-mac.js +3264 -0
- package/public/salas.css +85 -0
- package/public/salas.html +47 -0
- package/public/salas.js +200 -0
- package/public/styles.css +6303 -414
- package/public/touch.js +83 -0
- package/public/tour-chapters.js +342 -0
- package/public/tour.js +510 -0
- package/public/troubleshooting.js +159 -140
- package/src/adapters/claude.mjs +4 -3
- package/src/adapters/codex.mjs +49 -8
- package/src/adapters/gemini.mjs +30 -10
- package/src/adapters/madre.mjs +110 -18
- package/src/adapters/process.mjs +42 -2
- package/src/auth-probe.mjs +2 -2
- package/src/bridge/devices.mjs +202 -0
- package/src/bridge/handoff.mjs +45 -0
- package/src/bridge/host.mjs +82 -0
- package/src/bridge/network.mjs +133 -0
- package/src/bridge/policy.mjs +89 -0
- package/src/bridge/qr.mjs +287 -0
- package/src/bridge/server.mjs +269 -0
- package/src/capabilities.mjs +5 -9
- package/src/chats.mjs +59 -22
- package/src/checkpoint.mjs +137 -35
- package/src/choices.mjs +45 -6
- package/src/cli-args.mjs +12 -1
- package/src/commands.mjs +15 -15
- package/src/config.mjs +73 -9
- package/src/conversation-context.mjs +5 -2
- package/src/dataset.mjs +3 -1
- package/src/directives.mjs +0 -5
- package/src/enlaces.mjs +451 -0
- package/src/event-store.mjs +55 -10
- package/src/exam.mjs +5 -5
- package/src/extensions.mjs +0 -2
- package/src/file-ops.mjs +42 -0
- package/src/files.mjs +52 -2
- package/src/github.mjs +407 -0
- package/src/json-file.mjs +63 -0
- package/src/lease.mjs +2 -2
- package/src/lobby-work.mjs +103 -0
- package/src/looks.mjs +55 -0
- package/src/maturity.mjs +3 -3
- package/src/mcp/github-server.mjs +164 -0
- package/src/mcp/image-server.mjs +1 -1
- package/src/mcp/memory-server.mjs +11 -3
- package/src/memory.mjs +38 -12
- package/src/models.mjs +109 -1
- package/src/modules/ahp.mjs +4 -4
- package/src/modules/ash.mjs +1 -1
- package/src/modules/bishop-plan.mjs +207 -0
- package/src/modules/bishop.mjs +645 -0
- package/src/modules/choices.mjs +2 -2
- package/src/modules/correo.mjs +1 -1
- package/src/modules/enlaces.mjs +255 -0
- package/src/modules/git-pulse.mjs +2 -2
- package/src/modules/github.mjs +322 -0
- package/src/modules/image-studio.mjs +3 -3
- package/src/modules/index.mjs +19 -4
- package/src/modules/intents-calibration.mjs +338 -0
- package/src/modules/intents-desk.mjs +481 -0
- package/src/modules/intents-observe.mjs +266 -0
- package/src/modules/intents-policy.mjs +414 -0
- package/src/modules/intents-try.mjs +91 -0
- package/src/modules/intents.mjs +145 -0
- package/src/modules/ollama.mjs +9 -5
- package/src/modules/playwright.mjs +121 -11
- package/src/modules/ripley.mjs +1 -1
- package/src/modules/runs.mjs +34 -0
- package/src/modules/sdk.mjs +10 -4
- package/src/ollama.mjs +25 -1
- package/src/outbound.mjs +19 -17
- package/src/override.mjs +100 -0
- package/src/ports.mjs +23 -1
- package/src/prices.mjs +72 -0
- package/src/privacy.mjs +30 -11
- package/src/quota-sources.mjs +0 -8
- package/src/request-guard.mjs +1 -1
- package/src/review-ask.mjs +111 -0
- package/src/room/archivist.mjs +55 -37
- package/src/room/budget.mjs +17 -5
- package/src/room/control.mjs +88 -8
- package/src/room/economy.mjs +35 -7
- package/src/room/escalation.mjs +2 -0
- package/src/room/guard.mjs +26 -2
- package/src/room/prompt.mjs +32 -13
- package/src/room/runs.mjs +151 -0
- package/src/room/vectors.mjs +19 -4
- package/src/room.mjs +402 -54
- package/src/router.mjs +3 -1
- package/src/runs.mjs +137 -0
- package/src/salas.mjs +313 -0
- package/src/sentinel-errors.mjs +1 -1
- package/src/server-sentinel.mjs +124 -0
- package/src/server.mjs +1578 -167
- package/src/setup.mjs +1 -0
- package/src/tour.mjs +159 -0
- package/src/updates.mjs +48 -8
- package/src/usage-sentinel.mjs +1 -1
- package/src/verdict.mjs +7 -7
- package/src/whats-new.mjs +160 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// The QR a phone scans to pair with this Mac: text in, a grid of dark and light modules out, and
|
|
2
|
+
// an SVG of that grid the room sizes and colours with CSS.
|
|
3
|
+
//
|
|
4
|
+
// Zero dependencies because MADRE ships none. Byte mode only (UTF-8), versions 1–40, levels
|
|
5
|
+
// L/M/Q/H, the smallest version that fits and the mask with the lowest penalty. The symbol is the
|
|
6
|
+
// one the `qrcode` package draws, module for module, mask choice included: the tests pin it.
|
|
7
|
+
|
|
8
|
+
// The two bits each level writes into the format information.
|
|
9
|
+
const LEVELS = { L: 1, M: 0, Q: 3, H: 2 };
|
|
10
|
+
|
|
11
|
+
// By level, for versions 1–40: error-correction codewords per block, and how many blocks.
|
|
12
|
+
const EC_PER_BLOCK = {
|
|
13
|
+
L: [7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
|
|
14
|
+
M: [10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
|
|
15
|
+
Q: [13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
|
|
16
|
+
H: [17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
|
|
17
|
+
};
|
|
18
|
+
const BLOCKS = {
|
|
19
|
+
L: [1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
|
|
20
|
+
M: [1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
|
|
21
|
+
Q: [1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
|
|
22
|
+
H: [1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// GF(256) over x^8 + x^4 + x^3 + x^2 + 1, for Reed–Solomon.
|
|
26
|
+
const EXP = new Uint8Array(512);
|
|
27
|
+
const LOG = new Uint8Array(256);
|
|
28
|
+
for (let i = 0, x = 1; i < 255; i++, x = (x << 1) ^ (x & 0x80 ? 0x11d : 0)) {
|
|
29
|
+
EXP[i] = EXP[i + 255] = x;
|
|
30
|
+
LOG[x] = i;
|
|
31
|
+
}
|
|
32
|
+
const mul = (a, b) => (a && b ? EXP[LOG[a] + LOG[b]] : 0);
|
|
33
|
+
|
|
34
|
+
const MASKS = [
|
|
35
|
+
(r, c) => (r + c) % 2 === 0,
|
|
36
|
+
(r) => r % 2 === 0,
|
|
37
|
+
(r, c) => c % 3 === 0,
|
|
38
|
+
(r, c) => (r + c) % 3 === 0,
|
|
39
|
+
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
|
|
40
|
+
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
|
|
41
|
+
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
|
|
42
|
+
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const sideOf = (version) => version * 4 + 17;
|
|
46
|
+
|
|
47
|
+
// Every codeword the symbol holds, data and error correction: what is left once the function
|
|
48
|
+
// patterns, format and version information have taken their modules.
|
|
49
|
+
function rawCodewords(version) {
|
|
50
|
+
let bits = (16 * version + 128) * version + 64;
|
|
51
|
+
if (version >= 2) {
|
|
52
|
+
const n = Math.floor(version / 7) + 2;
|
|
53
|
+
bits -= (25 * n - 10) * n - 55;
|
|
54
|
+
if (version >= 7) bits -= 36;
|
|
55
|
+
}
|
|
56
|
+
return bits >> 3;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const dataCodewords = (version, level) => rawCodewords(version) - EC_PER_BLOCK[level][version - 1] * BLOCKS[level][version - 1];
|
|
60
|
+
|
|
61
|
+
function alignmentCentres(version) {
|
|
62
|
+
if (version === 1) return [];
|
|
63
|
+
const n = Math.floor(version / 7) + 2;
|
|
64
|
+
const step = Math.floor((version * 8 + n * 3 + 5) / (n * 4 - 4)) * 2;
|
|
65
|
+
const centres = [6];
|
|
66
|
+
for (let at = sideOf(version) - 7; centres.length < n; at -= step) centres.splice(1, 0, at);
|
|
67
|
+
return centres;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The generator polynomial of the given degree, leading 1 left out.
|
|
71
|
+
function generator(degree) {
|
|
72
|
+
const poly = new Uint8Array(degree);
|
|
73
|
+
poly[degree - 1] = 1;
|
|
74
|
+
for (let i = 0, root = 1; i < degree; i++, root = mul(root, 2)) {
|
|
75
|
+
for (let j = 0; j < degree; j++) poly[j] = mul(poly[j], root) ^ (j + 1 < degree ? poly[j + 1] : 0);
|
|
76
|
+
}
|
|
77
|
+
return poly;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function remainder(data, poly) {
|
|
81
|
+
const out = new Uint8Array(poly.length);
|
|
82
|
+
for (const byte of data) {
|
|
83
|
+
const factor = byte ^ out[0];
|
|
84
|
+
out.copyWithin(0, 1);
|
|
85
|
+
out[out.length - 1] = 0;
|
|
86
|
+
for (let i = 0; i < poly.length; i++) out[i] ^= mul(poly[i], factor);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Mode, count, the bytes, terminator and padding; then split into blocks, each with its error
|
|
92
|
+
// correction, and interleaved.
|
|
93
|
+
function codewords(bytes, version, level) {
|
|
94
|
+
const capacity = dataCodewords(version, level);
|
|
95
|
+
const bits = [];
|
|
96
|
+
const put = (value, length) => {
|
|
97
|
+
for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1);
|
|
98
|
+
};
|
|
99
|
+
put(0b0100, 4);
|
|
100
|
+
put(bytes.length, version < 10 ? 8 : 16);
|
|
101
|
+
for (const byte of bytes) put(byte, 8);
|
|
102
|
+
put(0, Math.min(4, capacity * 8 - bits.length));
|
|
103
|
+
put(0, (8 - (bits.length % 8)) % 8);
|
|
104
|
+
const data = new Uint8Array(capacity);
|
|
105
|
+
for (let i = 0; i < bits.length; i++) data[i >> 3] |= bits[i] << (7 - (i & 7));
|
|
106
|
+
for (let i = bits.length >> 3, pad = 0xec; i < capacity; i++, pad ^= 0xec ^ 0x11) data[i] = pad;
|
|
107
|
+
|
|
108
|
+
const blocks = BLOCKS[level][version - 1];
|
|
109
|
+
const ecLength = EC_PER_BLOCK[level][version - 1];
|
|
110
|
+
const raw = rawCodewords(version);
|
|
111
|
+
const shortBlocks = blocks - (raw % blocks);
|
|
112
|
+
const shortLength = Math.floor(raw / blocks) - ecLength;
|
|
113
|
+
const poly = generator(ecLength);
|
|
114
|
+
const dataBlocks = [];
|
|
115
|
+
const ecBlocks = [];
|
|
116
|
+
for (let b = 0, at = 0; b < blocks; b++) {
|
|
117
|
+
const block = data.subarray(at, (at += shortLength + (b < shortBlocks ? 0 : 1)));
|
|
118
|
+
dataBlocks.push(block);
|
|
119
|
+
ecBlocks.push(remainder(block, poly));
|
|
120
|
+
}
|
|
121
|
+
const out = [];
|
|
122
|
+
for (let i = 0; i <= shortLength; i++) for (const block of dataBlocks) if (i < block.length) out.push(block[i]);
|
|
123
|
+
for (let i = 0; i < ecLength; i++) for (const block of ecBlocks) out.push(block[i]);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The penalty the `qrcode` package scores a mask with: runs of five or more (N1), 2×2 blocks
|
|
128
|
+
// (N2), finder-like runs inside the symbol (N3) and the share of dark modules in 5% steps (N4).
|
|
129
|
+
function penalty(dark, size) {
|
|
130
|
+
let score = 0;
|
|
131
|
+
for (let a = 0; a < size; a++) {
|
|
132
|
+
for (const [start, step] of [[a * size, 1], [a, size]]) {
|
|
133
|
+
let run = 0;
|
|
134
|
+
let last = -1;
|
|
135
|
+
let window = 0;
|
|
136
|
+
for (let b = 0; b < size; b++) {
|
|
137
|
+
const on = dark[start + b * step];
|
|
138
|
+
if (on === last) run++;
|
|
139
|
+
else {
|
|
140
|
+
if (run >= 5) score += run - 2;
|
|
141
|
+
last = on;
|
|
142
|
+
run = 1;
|
|
143
|
+
}
|
|
144
|
+
window = ((window << 1) & 0x7ff) | on;
|
|
145
|
+
if (b >= 10 && (window === 0x5d0 || window === 0x05d)) score += 40;
|
|
146
|
+
}
|
|
147
|
+
if (run >= 5) score += run - 2;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
for (let r = 0; r < size - 1; r++) {
|
|
151
|
+
for (let c = 0; c < size - 1; c++) {
|
|
152
|
+
const at = r * size + c;
|
|
153
|
+
const sum = dark[at] + dark[at + 1] + dark[at + size] + dark[at + size + 1];
|
|
154
|
+
if (sum === 0 || sum === 4) score += 3;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
let total = 0;
|
|
158
|
+
for (const on of dark) total += on;
|
|
159
|
+
return score + Math.abs(Math.ceil(total * 100 / dark.length / 5) - 10) * 10;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function qrMatrix(text, { ecc = 'M' } = {}) {
|
|
163
|
+
const level = String(ecc).toUpperCase();
|
|
164
|
+
if (!Object.hasOwn(LEVELS, level)) throw new Error(`QR: unknown error correction level ${ecc}`);
|
|
165
|
+
const bytes = new TextEncoder().encode(String(text ?? ''));
|
|
166
|
+
let version = 1;
|
|
167
|
+
while (version <= 40 && 4 + (version < 10 ? 8 : 16) + bytes.length * 8 > dataCodewords(version, level) * 8) version++;
|
|
168
|
+
if (version > 40) throw new Error(`QR: ${bytes.length} bytes do not fit at level ${level}`);
|
|
169
|
+
|
|
170
|
+
const size = sideOf(version);
|
|
171
|
+
const dark = new Uint8Array(size * size);
|
|
172
|
+
const fixed = new Uint8Array(size * size);
|
|
173
|
+
const set = (r, c, on) => {
|
|
174
|
+
dark[r * size + c] = on ? 1 : 0;
|
|
175
|
+
fixed[r * size + c] = 1;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Finders with their light separators: rings at distance 2 and 4 from the centre are light.
|
|
179
|
+
for (const [top, left] of [[0, 0], [0, size - 7], [size - 7, 0]]) {
|
|
180
|
+
for (let r = -1; r <= 7; r++) {
|
|
181
|
+
for (let c = -1; c <= 7; c++) {
|
|
182
|
+
if (top + r < 0 || top + r >= size || left + c < 0 || left + c >= size) continue;
|
|
183
|
+
const ring = Math.max(Math.abs(r - 3), Math.abs(c - 3));
|
|
184
|
+
set(top + r, left + c, ring !== 2 && ring !== 4);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (let i = 8; i < size - 8; i++) {
|
|
189
|
+
set(i, 6, i % 2 === 0);
|
|
190
|
+
set(6, i, i % 2 === 0);
|
|
191
|
+
}
|
|
192
|
+
const centres = alignmentCentres(version);
|
|
193
|
+
const last = centres.length - 1;
|
|
194
|
+
for (let i = 0; i <= last; i++) {
|
|
195
|
+
for (let j = 0; j <= last; j++) {
|
|
196
|
+
if ((i === 0 && (j === 0 || j === last)) || (i === last && j === 0)) continue;
|
|
197
|
+
for (let r = -2; r <= 2; r++) {
|
|
198
|
+
for (let c = -2; c <= 2; c++) set(centres[i] + r, centres[j] + c, Math.max(Math.abs(r), Math.abs(c)) !== 1);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Format information: level and mask with a BCH(15,5) check, twice, plus the one dark module.
|
|
204
|
+
const format = (mask) => {
|
|
205
|
+
const data = (LEVELS[level] << 3) | mask;
|
|
206
|
+
let rem = data;
|
|
207
|
+
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
|
208
|
+
const bits = ((data << 10) | rem) ^ 0x5412;
|
|
209
|
+
for (let i = 0; i < 15; i++) {
|
|
210
|
+
const on = (bits >>> i) & 1;
|
|
211
|
+
set(i < 6 ? i : i < 8 ? i + 1 : size - 15 + i, 8, on);
|
|
212
|
+
set(8, i < 8 ? size - 1 - i : i === 8 ? 7 : 14 - i, on);
|
|
213
|
+
}
|
|
214
|
+
set(size - 8, 8, 1);
|
|
215
|
+
};
|
|
216
|
+
format(0);
|
|
217
|
+
|
|
218
|
+
// Version information from 7 up: the version with a Golay(18,6) check, in both corners.
|
|
219
|
+
if (version >= 7) {
|
|
220
|
+
let rem = version;
|
|
221
|
+
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
|
|
222
|
+
const bits = (version << 12) | rem;
|
|
223
|
+
for (let i = 0; i < 18; i++) {
|
|
224
|
+
const on = (bits >>> i) & 1;
|
|
225
|
+
const a = Math.floor(i / 3);
|
|
226
|
+
const b = size - 11 + (i % 3);
|
|
227
|
+
set(a, b, on);
|
|
228
|
+
set(b, a, on);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Codewords zigzag up and down two columns at a time from the right, around the timing column.
|
|
233
|
+
const words = codewords(bytes, version, level);
|
|
234
|
+
for (let right = size - 1, i = 0; right >= 1; right -= 2) {
|
|
235
|
+
if (right === 6) right = 5;
|
|
236
|
+
const upward = ((right + 1) & 2) === 0;
|
|
237
|
+
for (let step = 0; step < size; step++) {
|
|
238
|
+
const r = upward ? size - 1 - step : step;
|
|
239
|
+
for (let c = right; c > right - 2; c--) {
|
|
240
|
+
if (fixed[r * size + c]) continue;
|
|
241
|
+
if (i < words.length * 8) dark[r * size + c] = (words[i >> 3] >>> (7 - (i & 7))) & 1;
|
|
242
|
+
i++;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const applyMask = (mask) => {
|
|
248
|
+
for (let r = 0; r < size; r++) {
|
|
249
|
+
for (let c = 0; c < size; c++) if (!fixed[r * size + c] && MASKS[mask](r, c)) dark[r * size + c] ^= 1;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
let best = 0;
|
|
253
|
+
let lowest = Infinity;
|
|
254
|
+
for (let mask = 0; mask < 8; mask++) {
|
|
255
|
+
format(mask);
|
|
256
|
+
applyMask(mask);
|
|
257
|
+
const score = penalty(dark, size);
|
|
258
|
+
applyMask(mask);
|
|
259
|
+
if (score < lowest) [best, lowest] = [mask, score];
|
|
260
|
+
}
|
|
261
|
+
format(best);
|
|
262
|
+
applyMask(best);
|
|
263
|
+
|
|
264
|
+
const modules = [];
|
|
265
|
+
for (let r = 0; r < size; r++) modules.push(Array.from(dark.subarray(r * size, (r + 1) * size), Boolean));
|
|
266
|
+
return { size, modules };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const escapeAttribute = (value) => String(value).replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
|
|
270
|
+
|
|
271
|
+
// One path for every dark module, a row's runs merged into one rectangle each. No size and no
|
|
272
|
+
// background: the page decides how big it is and what it sits on.
|
|
273
|
+
export function qrSvg(text, { ecc = 'M', margin = 2, label = 'QR' } = {}) {
|
|
274
|
+
const { size, modules } = qrMatrix(text, { ecc });
|
|
275
|
+
const pad = Math.max(0, Math.trunc(margin) || 0);
|
|
276
|
+
let d = '';
|
|
277
|
+
modules.forEach((row, y) => {
|
|
278
|
+
for (let x = 0; x < size; x++) {
|
|
279
|
+
if (!row[x]) continue;
|
|
280
|
+
const start = x;
|
|
281
|
+
while (x < size && row[x]) x++;
|
|
282
|
+
d += `M${start + pad} ${y + pad}h${x - start}v1h-${x - start}z`;
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
const side = size + pad * 2;
|
|
286
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${side} ${side}" role="img" aria-label="${escapeAttribute(label)}"><path fill="currentColor" shape-rendering="crispEdges" d="${d}"/></svg>`;
|
|
287
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// The bridge: the one thing on this Mac a phone can reach (docs/MOBILE.md, MOV-001).
|
|
2
|
+
//
|
|
3
|
+
// Rooms keep listening on 127.0.0.1 and keep refusing anything that does not call them by that
|
|
4
|
+
// name (src/request-guard.mjs). The bridge listens on the local network, lets in only a phone that
|
|
5
|
+
// was paired with a QR, and passes its requests on to the room it chose, as 127.0.0.1, under the
|
|
6
|
+
// phone's policy (policy.mjs). The room's own page is served as it is: the room it belongs to is
|
|
7
|
+
// picked by a cookie, so every absolute path in it keeps working behind the bridge.
|
|
8
|
+
//
|
|
9
|
+
// Before anything else a request has to come from a private address and name this Mac by one of
|
|
10
|
+
// its own names. Everything a phone sends that changes something has to come from the bridge's own
|
|
11
|
+
// pages. A phone that is not paired gets a page that says how to pair it, and nothing else.
|
|
12
|
+
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import { hostname } from 'node:os';
|
|
15
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
16
|
+
import { extname, join, normalize, sep } from 'node:path';
|
|
17
|
+
import { language, t } from '../i18n.mjs';
|
|
18
|
+
import { readHandoff } from './handoff.mjs';
|
|
19
|
+
import { deviceForToken, readStore, redeemInvite } from './devices.mjs';
|
|
20
|
+
import { isPrivateAddress, lanAddresses } from './network.mjs';
|
|
21
|
+
import { inspects, phonePolicy } from './policy.mjs';
|
|
22
|
+
import { salasRoomId } from '../salas.mjs';
|
|
23
|
+
|
|
24
|
+
export const BRIDGE_PORT = 4316;
|
|
25
|
+
const PHONE_COOKIE = 'madre_phone';
|
|
26
|
+
const ROOM_COOKIE = 'madre_room';
|
|
27
|
+
const MONTH_S = 30 * 24 * 60 * 60;
|
|
28
|
+
const MESSAGE_LIMIT = 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.webmanifest': 'application/manifest+json' };
|
|
31
|
+
// The bridge's own files, served from the package of the room that hosts it.
|
|
32
|
+
const OWN = { '/salas': 'salas.html', '/bridge/salas.js': 'salas.js', '/bridge/salas.css': 'salas.css', '/bridge/styles.css': 'styles.css', '/bridge/i18n.js': 'i18n.js', '/bridge/es.js': 'es.js', '/bridge/touch.js': 'touch.js', '/bridge/manifest.webmanifest': 'manifest.webmanifest' };
|
|
33
|
+
const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'cookie', 'origin', 'referer']);
|
|
34
|
+
|
|
35
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
36
|
+
|
|
37
|
+
// Every room open on this Mac, from the marks the rooms leave (src/server.mjs, open.json).
|
|
38
|
+
export async function roomsOnThisMac(root) {
|
|
39
|
+
const dir = join(root, 'rooms');
|
|
40
|
+
const names = await readdir(dir).catch(() => []);
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const id of names) {
|
|
43
|
+
const mark = await readFile(join(dir, id, 'open.json'), 'utf8').then((raw) => JSON.parse(raw)).catch(() => null);
|
|
44
|
+
if (!mark || !Number.isInteger(mark.pid) || !Number.isInteger(mark.port) || mark.port < 1 || mark.port > 65535 || !alive(mark.pid)) continue;
|
|
45
|
+
out.push({ id, port: mark.port, project: typeof mark.project === 'string' ? mark.project : id.replace(/-[0-9a-f]{16}$/, ''), version: mark.version ?? null, at: mark.at ?? null });
|
|
46
|
+
}
|
|
47
|
+
return out.sort((a, b) => a.project.localeCompare(b.project));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseCookies(header = '') {
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const part of String(header).split(';')) {
|
|
53
|
+
const at = part.indexOf('=');
|
|
54
|
+
if (at < 1) continue;
|
|
55
|
+
const name = part.slice(0, at).trim();
|
|
56
|
+
if (!Object.hasOwn(out, name)) out[name] = decodeURIComponent(part.slice(at + 1).trim());
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const cookie = (name, value, maxAge) => `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Strict`;
|
|
62
|
+
|
|
63
|
+
// The names a phone may use for this Mac: its addresses on the network, its own name, and loopback.
|
|
64
|
+
export function ownHosts(port, { addresses = lanAddresses(), name = hostname() } = {}) {
|
|
65
|
+
const hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
|
|
66
|
+
for (const address of addresses) hosts.add(`${address}:${port}`);
|
|
67
|
+
if (name) {
|
|
68
|
+
const bare = name.replace(/\.local$/i, '');
|
|
69
|
+
hosts.add(`${bare}.local:${port}`.toLowerCase());
|
|
70
|
+
hosts.add(`${bare}:${port}`.toLowerCase());
|
|
71
|
+
}
|
|
72
|
+
return hosts;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SECURITY = { 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer', 'x-frame-options': 'DENY' };
|
|
76
|
+
const OWN_CSP = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
|
|
77
|
+
|
|
78
|
+
function sendJson(response, status, body, headers = {}) {
|
|
79
|
+
const text = JSON.stringify(body);
|
|
80
|
+
response.writeHead(status, { ...SECURITY, 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...headers });
|
|
81
|
+
response.end(text);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const escapeHtml = (text) => String(text).replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
|
85
|
+
|
|
86
|
+
// A page of one sentence, for a phone that cannot go further.
|
|
87
|
+
function sendNote(response, status, title, sentence) {
|
|
88
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>MADRE</title><link rel="stylesheet" href="/bridge/styles.css"><link rel="stylesheet" href="/bridge/salas.css"></head><body class="salas note"><main><h1>${escapeHtml(title)}</h1><p>${escapeHtml(sentence)}</p></main></body></html>`;
|
|
89
|
+
response.writeHead(status, { ...SECURITY, 'content-security-policy': OWN_CSP, 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
90
|
+
response.end(html);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const wantsPage = (request, pathname) => request.method === 'GET' && (pathname === '/' || pathname === '/salas' || String(request.headers.accept ?? '').includes('text/html'));
|
|
94
|
+
|
|
95
|
+
function readBody(request, limit) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const chunks = [];
|
|
98
|
+
let size = 0;
|
|
99
|
+
request.on('data', (chunk) => {
|
|
100
|
+
size += chunk.length;
|
|
101
|
+
if (size > limit) { reject(Object.assign(new Error('too large'), { status: 413 })); request.destroy(); return; }
|
|
102
|
+
chunks.push(chunk);
|
|
103
|
+
});
|
|
104
|
+
request.on('end', () => resolve(Buffer.concat(chunks)));
|
|
105
|
+
request.on('error', reject);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A short memory of who knocked, so a phone cannot be guessed into by trying codes or tokens.
|
|
110
|
+
function limiter({ perMinute }) {
|
|
111
|
+
const seen = new Map();
|
|
112
|
+
return (key, now = Date.now()) => {
|
|
113
|
+
const bucket = seen.get(key) ?? { at: now, n: 0 };
|
|
114
|
+
if (now - bucket.at > 60_000) { bucket.at = now; bucket.n = 0; }
|
|
115
|
+
bucket.n += 1;
|
|
116
|
+
seen.set(key, bucket);
|
|
117
|
+
if (seen.size > 2000) seen.clear();
|
|
118
|
+
return bucket.n <= perMinute;
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// What a room says about itself in one line, for the list of rooms on the phone.
|
|
123
|
+
async function glance(room, device) {
|
|
124
|
+
try {
|
|
125
|
+
// The archive counted too (docs/MACROVERSO.md): on a phone the macroverse is this one line per room.
|
|
126
|
+
const response = await fetch(`http://127.0.0.1:${room.port}/api/glance?census=1`, { headers: { 'x-madre-device': device.id, 'x-madre-device-kind': device.kind }, signal: AbortSignal.timeout(1500) });
|
|
127
|
+
if (response.status === 404) return { outdated: true };
|
|
128
|
+
if (!response.ok) return { unreachable: true };
|
|
129
|
+
return await response.json();
|
|
130
|
+
} catch {
|
|
131
|
+
return { unreachable: true };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createBridge({ root, port = BRIDGE_PORT, publicDirectory, hosts = () => ownHosts(port), now = () => Date.now() }) {
|
|
136
|
+
const strangers = limiter({ perMinute: 30 });
|
|
137
|
+
const phones = limiter({ perMinute: 900 });
|
|
138
|
+
|
|
139
|
+
async function serveOwn(response, pathname) {
|
|
140
|
+
const file = OWN[pathname];
|
|
141
|
+
const body = await readFile(join(publicDirectory, file));
|
|
142
|
+
response.writeHead(200, { ...SECURITY, 'content-security-policy': OWN_CSP, 'content-type': MIME[extname(file)] ?? 'application/octet-stream', 'cache-control': 'no-cache' });
|
|
143
|
+
response.end(body);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The package's own pictures and fonts, when this copy has them; otherwise the room's.
|
|
147
|
+
async function serveAsset(response, pathname) {
|
|
148
|
+
const relative = normalize(decodeURIComponent(pathname)).replace(/^[/\\]+/, '');
|
|
149
|
+
const file = join(publicDirectory, relative);
|
|
150
|
+
if (!file.startsWith(join(publicDirectory, 'assets') + sep)) return false;
|
|
151
|
+
const body = await readFile(file).catch(() => null);
|
|
152
|
+
if (!body) return false;
|
|
153
|
+
response.writeHead(200, { ...SECURITY, 'content-type': MIME[extname(file).toLowerCase()] ?? 'application/octet-stream', 'cache-control': 'private, max-age=3600' });
|
|
154
|
+
response.end(body);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function pass(request, response, url, room, device) {
|
|
159
|
+
let buffered = null;
|
|
160
|
+
let body = null;
|
|
161
|
+
if (inspects(request.method, url.pathname)) {
|
|
162
|
+
buffered = await readBody(request, MESSAGE_LIMIT);
|
|
163
|
+
try { body = JSON.parse(buffered.toString('utf8') || '{}'); } catch { return sendJson(response, 400, { error: t('The message could not be read.') }); }
|
|
164
|
+
}
|
|
165
|
+
const verdict = phonePolicy({ method: request.method, pathname: url.pathname, body });
|
|
166
|
+
if (!verdict.allow) return sendJson(response, verdict.status, { error: verdict.error, phone: true });
|
|
167
|
+
const headers = {};
|
|
168
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
169
|
+
if (!HOP.has(name) && !name.startsWith('x-madre-')) headers[name] = value;
|
|
170
|
+
}
|
|
171
|
+
headers.host = `127.0.0.1:${room.port}`;
|
|
172
|
+
if (request.headers.origin) headers.origin = `http://127.0.0.1:${room.port}`;
|
|
173
|
+
headers['x-madre-device'] = device.id;
|
|
174
|
+
headers['x-madre-device-kind'] = device.kind;
|
|
175
|
+
if (buffered) headers['content-length'] = String(buffered.length);
|
|
176
|
+
const upstream = http.request({ host: '127.0.0.1', port: room.port, method: request.method, path: url.pathname + url.search, headers }, (answer) => {
|
|
177
|
+
const out = { ...SECURITY };
|
|
178
|
+
for (const [name, value] of Object.entries(answer.headers)) if (!HOP.has(name) && name !== 'set-cookie') out[name] = value;
|
|
179
|
+
// Two rooms may be two versions of MADRE behind the same address: nothing of one is reused for the other.
|
|
180
|
+
if (!url.pathname.startsWith('/assets/')) out['cache-control'] = 'no-store';
|
|
181
|
+
out.vary = 'Cookie';
|
|
182
|
+
response.writeHead(answer.statusCode ?? 502, out);
|
|
183
|
+
answer.pipe(response);
|
|
184
|
+
});
|
|
185
|
+
upstream.on('error', () => { if (!response.headersSent) sendJson(response, 502, { error: t('That project did not answer. It may have just closed on your Mac.') }); else response.end(); });
|
|
186
|
+
// A phone that leaves (a stream it stops reading) takes its request to the room with it.
|
|
187
|
+
response.on('close', () => upstream.destroy());
|
|
188
|
+
if (buffered) upstream.end(buffered);
|
|
189
|
+
else request.pipe(upstream);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function handle(request, response) {
|
|
193
|
+
const remote = request.socket.remoteAddress ?? '';
|
|
194
|
+
if (!isPrivateAddress(remote)) { request.socket.destroy(); return; }
|
|
195
|
+
const host = String(request.headers.host ?? '').toLowerCase();
|
|
196
|
+
if (!hosts().has(host)) return sendJson(response, 421, { error: t('MADRE answers only when it is called by this computer\'s name (127.0.0.1 or localhost).') });
|
|
197
|
+
const url = new URL(request.url ?? '/', `http://${host}`);
|
|
198
|
+
|
|
199
|
+
if (request.method === 'GET' && url.pathname === '/bridge/ping') return sendJson(response, 200, { madre: true });
|
|
200
|
+
|
|
201
|
+
if (request.method === 'GET' && url.pathname === '/bridge/pair') {
|
|
202
|
+
if (!strangers(`pair:${remote}`)) return sendNote(response, 429, t('Too many tries'), t('Wait a minute and scan the code again.'));
|
|
203
|
+
const paired = await redeemInvite(root, url.searchParams.get('c') ?? '', { userAgent: request.headers['user-agent'] ?? '', now: now() });
|
|
204
|
+
if (!paired) return sendNote(response, 410, t('This code no longer works'), t('Each code works once and for ten minutes. Make a new one on your Mac: MADRE on your phone, in the bar.'));
|
|
205
|
+
response.writeHead(303, { ...SECURITY, location: '/salas?listo=1', 'set-cookie': cookie(PHONE_COOKIE, paired.token, MONTH_S), 'cache-control': 'no-store' });
|
|
206
|
+
return response.end();
|
|
207
|
+
}
|
|
208
|
+
// The product's own stylesheets, for the page that tells an unpaired phone how to pair.
|
|
209
|
+
if (request.method === 'GET' && (url.pathname === '/bridge/salas.css' || url.pathname === '/bridge/styles.css')) return serveOwn(response, url.pathname);
|
|
210
|
+
|
|
211
|
+
const cookies = parseCookies(request.headers.cookie);
|
|
212
|
+
const store = await readStore(root);
|
|
213
|
+
const device = store.enabled ? await deviceForToken(root, cookies[PHONE_COOKIE], { now: now(), store }) : null;
|
|
214
|
+
if (!device) {
|
|
215
|
+
if (!strangers(`token:${remote}`)) return sendJson(response, 429, { error: t('Too many tries') });
|
|
216
|
+
if (wantsPage(request, url.pathname)) return sendNote(response, 401, t('This phone is not paired'), t('On your Mac, in the bar, press MADRE on your phone and scan the code with this phone\'s camera.'));
|
|
217
|
+
return sendJson(response, 401, { error: t('This phone is not paired.'), pair: true });
|
|
218
|
+
}
|
|
219
|
+
if (!phones(device.id)) return sendJson(response, 429, { error: t('Too many requests from this phone. Wait a moment.') });
|
|
220
|
+
|
|
221
|
+
// What changes something has to come from these pages.
|
|
222
|
+
if (!['GET', 'HEAD'].includes(request.method)) {
|
|
223
|
+
const site = request.headers['sec-fetch-site'];
|
|
224
|
+
const origin = request.headers.origin;
|
|
225
|
+
if ((site && site !== 'same-origin') || (origin && origin !== `http://${host}`)) return sendJson(response, 403, { error: t('A page from somewhere else cannot act in this project.') });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (request.method === 'GET' && OWN[url.pathname]) {
|
|
229
|
+
if (url.pathname === '/salas') response.setHeader('set-cookie', cookie(PHONE_COOKIE, cookies[PHONE_COOKIE], MONTH_S));
|
|
230
|
+
return serveOwn(response, url.pathname);
|
|
231
|
+
}
|
|
232
|
+
if (request.method === 'GET' && url.pathname === '/bridge/rooms') {
|
|
233
|
+
const rooms = await roomsOnThisMac(root);
|
|
234
|
+
// The Coordinator's own project says so: the phone names it as Home on the Mac does.
|
|
235
|
+
const coordinator = salasRoomId(root);
|
|
236
|
+
const seen = await Promise.all(rooms.map(async (room) => ({ id: room.id, project: room.project, ...(room.id === coordinator ? { coordinator: true } : {}), ...(await glance(room, device)) })));
|
|
237
|
+
// Where the person stood on the Mac, offered when it is newer than where they stood here.
|
|
238
|
+
const where = await readHandoff(root);
|
|
239
|
+
const carry = where.mac && (!where.phone || where.mac.at > where.phone.at) && now() - where.mac.at < 12 * 60 * 60 * 1000 ? where.mac : null;
|
|
240
|
+
return sendJson(response, 200, { mac: hostname().replace(/\.local$/i, ''), language: language(), device, rooms: seen, chosen: cookies[ROOM_COOKIE] ?? null, handoff: carry });
|
|
241
|
+
}
|
|
242
|
+
if (request.method === 'POST' && url.pathname === '/bridge/open') {
|
|
243
|
+
const payload = await readBody(request, 4096).then((raw) => JSON.parse(raw.toString('utf8') || '{}')).catch(() => ({}));
|
|
244
|
+
const room = (await roomsOnThisMac(root)).find((one) => one.id === payload.room);
|
|
245
|
+
if (!room) return sendJson(response, 404, { error: t('That project is closed on your Mac.') });
|
|
246
|
+
return sendJson(response, 200, { ok: true, room: room.id }, { 'set-cookie': cookie(ROOM_COOKIE, room.id, MONTH_S) });
|
|
247
|
+
}
|
|
248
|
+
if (request.method === 'POST' && url.pathname === '/bridge/stop-all') {
|
|
249
|
+
const rooms = await roomsOnThisMac(root);
|
|
250
|
+
const stopped = await Promise.all(rooms.map((room) => fetch(`http://127.0.0.1:${room.port}/api/stop-all`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-madre-device': device.id, 'x-madre-device-kind': device.kind }, body: '{}', signal: AbortSignal.timeout(3000) }).then((answer) => answer.ok).catch(() => false)));
|
|
251
|
+
return sendJson(response, 200, { rooms: rooms.length, stopped: stopped.filter(Boolean).length });
|
|
252
|
+
}
|
|
253
|
+
if (request.method === 'GET' && url.pathname.startsWith('/assets/') && await serveAsset(response, url.pathname)) return;
|
|
254
|
+
|
|
255
|
+
const room = (await roomsOnThisMac(root)).find((one) => one.id === cookies[ROOM_COOKIE]);
|
|
256
|
+
if (!room) {
|
|
257
|
+
if (wantsPage(request, url.pathname)) { response.writeHead(303, { ...SECURITY, location: '/salas', 'cache-control': 'no-store' }); return response.end(); }
|
|
258
|
+
return sendJson(response, 409, { error: t('That project is closed on your Mac.'), salas: true });
|
|
259
|
+
}
|
|
260
|
+
return pass(request, response, url, room, device);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return http.createServer((request, response) => {
|
|
264
|
+
handle(request, response).catch((error) => {
|
|
265
|
+
if (response.headersSent) { response.end(); return; }
|
|
266
|
+
sendJson(response, error.status ?? 500, { error: error.status === 413 ? t('That is too large to send from the phone.') : t('MADRE on your phone could not do that.') });
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
package/src/capabilities.mjs
CHANGED
|
@@ -17,28 +17,28 @@ export const CAPABILITIES = {
|
|
|
17
17
|
imageIn: { how: '-i <file>', note: 'Images attach to the prompt directly.' },
|
|
18
18
|
write: { how: '-C <outdir> --sandbox workspace-write', note: 'Reads the whole project, writes only inside the lease directory.' },
|
|
19
19
|
imageGen: { how: 'features.image_generation (stable, enabled)', note: 'Generated with the ChatGPT account; saved into the lease directory.' },
|
|
20
|
-
web: { how: '--search (live web_search tool)', note: 'Off until
|
|
20
|
+
web: { how: '--search (live web_search tool)', note: 'Off until turned on in Settings › Agents › Web access.' },
|
|
21
21
|
},
|
|
22
22
|
claude: {
|
|
23
23
|
read: true,
|
|
24
24
|
imageIn: { how: 'Read tool on the attachment path', note: 'The attachments folder is added with --add-dir.' },
|
|
25
25
|
write: { how: '--tools Write,Edit + permission rules', note: 'Write/Edit allowed only under the lease directory.' },
|
|
26
26
|
imageGen: false,
|
|
27
|
-
web: { how: '--tools WebFetch,WebSearch', note: 'Off until
|
|
27
|
+
web: { how: '--tools WebFetch,WebSearch', note: 'Off until turned on in Settings › Agents › Web access.' },
|
|
28
28
|
},
|
|
29
29
|
gemini: {
|
|
30
30
|
read: true,
|
|
31
31
|
imageIn: { how: 'read_file on the attachment path', note: 'The attachments folder is added with --include-directories.' },
|
|
32
32
|
write: { how: 'policy: allow write_file/edit with argsPattern', note: 'Paths outside the lease directory stay denied.' },
|
|
33
33
|
imageGen: false,
|
|
34
|
-
web: { how: 'policy: allow google_web_search, web_fetch', note: 'Off until
|
|
34
|
+
web: { how: 'policy: allow google_web_search, web_fetch', note: 'Off until turned on in Settings › Agents › Web access.' },
|
|
35
35
|
},
|
|
36
36
|
opencode: {
|
|
37
37
|
read: true,
|
|
38
38
|
imageIn: { how: '-f <file>', note: 'Files attach to the message.' },
|
|
39
39
|
write: { how: 'permission.edit patterns', note: 'Edit allowed only under the lease directory.' },
|
|
40
40
|
imageGen: false,
|
|
41
|
-
web: { how: 'permission.webfetch / websearch = allow', note: 'Off until
|
|
41
|
+
web: { how: 'permission.webfetch / websearch = allow', note: 'Off until turned on in Settings › Agents › Web access.' },
|
|
42
42
|
},
|
|
43
43
|
};
|
|
44
44
|
|
|
@@ -72,12 +72,8 @@ export function capabilitySummary(agentId) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
// Agents that have a capability, for routing hints in the delegation help.
|
|
75
|
-
export function agentsWith(capability, agentIds) {
|
|
76
|
-
return agentIds.filter((id) => Boolean(capabilityOf(id)[capability]));
|
|
77
|
-
}
|
|
78
|
-
|
|
79
75
|
// Permission modes, chosen per message in the composer and capped per agent
|
|
80
|
-
// in
|
|
76
|
+
// in Settings › Permissions. The human's mode is the ceiling of any plan it starts.
|
|
81
77
|
// #0 GHOST off the record: nothing enters the log or anyone's context
|
|
82
78
|
// #1 EXCHANGE read the project, coordinate with the others (default)
|
|
83
79
|
// #2 CREATE create files and images inside the turn's .pulse/out/ lease
|