@milpa/comal 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-device-v1.json +423 -0
  4. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  5. package/package.json +4 -1
  6. package/skills/mv3/SKILL.md +44 -0
  7. package/skills/mv3/skill.json +4 -0
  8. package/src/agent.mjs +299 -32
  9. package/src/auto-guard.mjs +40 -3
  10. package/src/autocompact.mjs +233 -0
  11. package/src/bash-timeouts.mjs +29 -0
  12. package/src/child-env.mjs +23 -0
  13. package/src/cli.mjs +261 -62
  14. package/src/code-run-artifact.mjs +30 -0
  15. package/src/code-run-journal.mjs +135 -0
  16. package/src/code-run-session-proof.mjs +50 -0
  17. package/src/code-run-session.mjs +97 -0
  18. package/src/code-run-worker.mjs +20 -4
  19. package/src/code-run.mjs +49 -14
  20. package/src/codex-provider.mjs +4 -4
  21. package/src/compaction-notice.mjs +22 -0
  22. package/src/compaction.mjs +162 -23
  23. package/src/connection-settings.mjs +36 -4
  24. package/src/context-pressure.mjs +5 -4
  25. package/src/context.mjs +27 -22
  26. package/src/execution-metrics.mjs +36 -5
  27. package/src/file-recovery-proof.mjs +9 -1
  28. package/src/file-recovery.mjs +36 -3
  29. package/src/gemini-provider.mjs +5 -3
  30. package/src/git-integration-operations.mjs +2 -1
  31. package/src/git-plumbing.mjs +2 -1
  32. package/src/git-source.mjs +2 -1
  33. package/src/git-workspaces.mjs +2 -1
  34. package/src/house.mjs +13 -1
  35. package/src/i18n/cli-es.mjs +12 -1
  36. package/src/i18n/es.mjs +38 -1
  37. package/src/judges.mjs +2 -1
  38. package/src/mcp.mjs +2 -1
  39. package/src/model-controls.mjs +16 -2
  40. package/src/model-deadline.mjs +2 -2
  41. package/src/openai-provider.mjs +8 -4
  42. package/src/operations.mjs +17 -2
  43. package/src/package-identity.mjs +68 -0
  44. package/src/peer-probe.mjs +16 -0
  45. package/src/permissions.mjs +97 -4
  46. package/src/plugin-config.mjs +18 -1
  47. package/src/plugin-controls.mjs +31 -8
  48. package/src/plugin-trust.mjs +15 -6
  49. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  50. package/src/plugins/chrome-lab/client.mjs +242 -12
  51. package/src/plugins/chrome-lab/index.mjs +106 -24
  52. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  53. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  54. package/src/plugins/open-relay/actions.mjs +60 -9
  55. package/src/plugins/open-relay/client.mjs +3 -3
  56. package/src/plugins/open-relay/decision.mjs +3 -2
  57. package/src/plugins/open-relay/index.mjs +17 -7
  58. package/src/plugins/open-relay/lookup.mjs +41 -4
  59. package/src/plugins/open-relay/notices.mjs +44 -3
  60. package/src/plugins/open-relay/panel.mjs +4 -1
  61. package/src/plugins/open-relay/watch.mjs +27 -8
  62. package/src/process-lock.mjs +20 -12
  63. package/src/provider-auth.mjs +6 -3
  64. package/src/provider-profiles.mjs +13 -3
  65. package/src/provider.mjs +175 -30
  66. package/src/qr-scan.mjs +240 -0
  67. package/src/read-only-check.mjs +6 -1
  68. package/src/relay/broker.mjs +173 -0
  69. package/src/relay/cards.mjs +702 -0
  70. package/src/relay/flow.mjs +886 -0
  71. package/src/relay/follow.mjs +119 -0
  72. package/src/relay/git.mjs +23 -0
  73. package/src/relay/hub-client.mjs +163 -0
  74. package/src/relay/state.mjs +55 -0
  75. package/src/remote-control.mjs +283 -15
  76. package/src/remote-device-card.mjs +111 -0
  77. package/src/remote-device.mjs +92 -0
  78. package/src/remote-permissions.mjs +159 -0
  79. package/src/remote-relay.mjs +468 -0
  80. package/src/remote-stats.mjs +205 -0
  81. package/src/session-bus.mjs +13 -3
  82. package/src/session-provider-settings.mjs +2 -2
  83. package/src/skill-runtime.mjs +8 -6
  84. package/src/source-ownership.mjs +3 -1
  85. package/src/tool-manifest.mjs +71 -0
  86. package/src/tool-packs.mjs +22 -6
  87. package/src/tool-schema.mjs +14 -2
  88. package/src/tools.mjs +96 -27
  89. package/src/tui/controller.mjs +4 -1
  90. package/src/tui/discovery.mjs +17 -7
  91. package/src/tui/inbound-blocks.mjs +5 -1
  92. package/src/tui/screen.mjs +53 -14
  93. package/src/tui/swarm-panel.mjs +19 -8
  94. package/src/turn-timing.mjs +82 -0
  95. package/src/usage-accounting.mjs +12 -1
  96. package/src/vision.mjs +6 -1
  97. package/src/visual/chrome.mjs +379 -0
  98. package/src/visual/fingerprint.mjs +30 -0
  99. package/src/visual/fixtures.mjs +201 -0
  100. package/src/visual/gate.mjs +92 -0
  101. package/src/visual/judge.mjs +114 -0
  102. package/src/visual/ledger.mjs +160 -0
  103. package/src/visual/measure.mjs +145 -0
  104. package/src/visual/names.mjs +7 -0
  105. package/src/visual/operations.mjs +211 -0
  106. package/src/visual/pixel-diff.mjs +96 -0
  107. package/src/visual/png.mjs +103 -0
  108. package/src/visual/registry.mjs +265 -0
  109. package/src/visual/report.mjs +64 -0
  110. package/src/visual/verify.mjs +292 -0
  111. package/src/work-assertions.mjs +15 -4
  112. package/src/work-plan.mjs +166 -30
  113. package/src/work-state.mjs +4 -4
  114. package/src/workflows/chrome-task-adoption.mjs +7 -3
  115. package/src/workspace-serve.mjs +59 -0
@@ -17,6 +17,21 @@ export function toolEffects(name) {
17
17
  return { workspace: 'unknown', external: 'unknown', process: 'unknown', authority: 'none' };
18
18
  }
19
19
 
20
+ // A model often sends an array/object field as a string holding JSON (sometimes broken JSON). Say so
21
+ // precisely instead of only "must be array", so the next call can be a real correction.
22
+ function stringValueHint(error, actual) {
23
+ const expected = [].concat(error.params?.type || []);
24
+ if (error.keyword !== 'type' || typeof actual !== 'string' || !expected.some(type => type === 'array' || type === 'object')) return {};
25
+ try {
26
+ const parsed = JSON.parse(actual), parsedType = Array.isArray(parsed) ? 'array' : parsed === null ? 'null' : typeof parsed;
27
+ return { string_value: { valid_json: true, parsed_type: parsedType, hint: expected.includes(parsedType) ? `This field arrived as a string that contains JSON. Send the ${parsedType} itself as the field value, not a string.` : `This field arrived as a string whose JSON is a ${parsedType}, not ${expected.join(' or ')}.` } };
28
+ } catch (parseError) {
29
+ const position = Number(/position (\d+)/.exec(parseError.message)?.[1]);
30
+ const near = Number.isSafeInteger(position) ? `${position > 60 ? '…' : ''}${actual.slice(Math.max(0, position - 60), position + 40)}${position + 40 < actual.length ? '…' : ''}` : actual.slice(0, 100);
31
+ return { string_value: { valid_json: false, error: String(parseError.message).slice(0, 200), near, hint: `This field arrived as a string, and that string is not valid JSON either. Send the ${expected.join(' or ')} itself as the field value, with balanced brackets and escaped quotes.` } };
32
+ }
33
+ }
34
+
20
35
  export class Operation {
21
36
  #validate;
22
37
  #validateOutput;
@@ -47,9 +62,9 @@ export class Operation {
47
62
  const issues = this.#validate.errors.slice(0, 12).map(error => {
48
63
  const pointer = error.instancePath || '';
49
64
  let actual = input; for (const part of pointer.split('/').slice(1)) actual = actual?.[part.replace(/~1/g, '/').replace(/~0/g, '~')];
50
- return { path: pointer || '/', rule: error.keyword, message: error.message, ...error.params, ...(['maxItems', 'minItems'].includes(error.keyword) && Array.isArray(actual) ? { actual_count: actual.length } : {}) };
65
+ return { path: pointer || '/', rule: error.keyword, message: error.message, ...error.params, ...(['maxItems', 'minItems'].includes(error.keyword) && Array.isArray(actual) ? { actual_count: actual.length } : {}), ...stringValueHint(error, actual) };
51
66
  });
52
- throw Object.assign(new Error(`Input inválido para ${this.contract.name}: ${this.#validate.errors.map(error => `${error.instancePath || 'input'} ${error.message}`).join('; ')}.`), {
67
+ throw Object.assign(new Error(`Input inválido para ${this.contract.name}: ${this.#validate.errors.map(error => `${error.instancePath || 'input'} ${error.message}${error.params?.additionalProperty ? ` (${error.params.additionalProperty})` : ''}`).join('; ')}.`), {
53
68
  code: 'HOUSE_INPUT_SCHEMA', details: { operation: this.contract.name, executed: false, issues, next_action: 'Correct the named fields using the live tool schema; preserve all intended work. Do not repeat unchanged arguments, invent operation names, query the board, or infer that any effect ran.' },
54
69
  });
55
70
  }
@@ -0,0 +1,68 @@
1
+ import { readFile, readdir, lstat } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ // Identidad del paquete INSTALADO de Comal: nombre, versión e integridad. La integridad es
7
+ // el digest de {ruta → sha256} de todos los archivos que publica el paquete (los `files` de
8
+ // su package.json: bin, src, contratos, docs), calculado por Comal sobre sus propios bytes
9
+ // una vez por proceso. Sirve para que la confianza en los plugins INCLUIDOS se ate al
10
+ // paquete (se pide una vez por versión) y no a cada archivo ni a la ruta donde se instaló.
11
+ // Una copia de desarrollo (package.json con `private: true`, como el del repo) no tiene
12
+ // identidad de paquete: ahí la confianza sigue atada a la huella del código, como siempre.
13
+ const ROOT = fileURLToPath(new URL('../', import.meta.url));
14
+ const MAX_FILES = 20000, MAX_BYTES = 256 << 20;
15
+ const sha = value => createHash('sha256').update(value).digest('hex');
16
+ const posix = file => file.split(path.sep).join('/');
17
+
18
+ /** Digest canónico de {ruta → sha256}: lo que identifica el contenido del paquete. */
19
+ export const manifestIntegrity = files => sha(JSON.stringify(Object.keys(files).sort().map(key => [key, files[key]])));
20
+
21
+ async function hashTree(root, entries) {
22
+ const files = {}; let count = 0, bytes = 0;
23
+ const visit = async relative => {
24
+ const absolute = path.join(root, relative), info = await lstat(absolute).catch(() => null);
25
+ if (!info || info.isSymbolicLink()) return;
26
+ if (info.isDirectory()) { for (const name of (await readdir(absolute)).sort()) if (name !== 'node_modules' && !name.startsWith('.')) await visit(path.join(relative, name)); return; }
27
+ if (!info.isFile()) return;
28
+ if (++count > MAX_FILES || (bytes += info.size) > MAX_BYTES) throw new Error('package too large');
29
+ files[posix(relative)] = sha(await readFile(absolute));
30
+ };
31
+ for (const entry of new Set(['package.json', ...entries])) await visit(entry);
32
+ return files;
33
+ }
34
+
35
+ const cache = new Map();
36
+ /**
37
+ * {root, name, version, integrity, files} del paquete instalado en `root` (por omisión, esta
38
+ * instalación) o null si es una copia de desarrollo o no se puede leer.
39
+ */
40
+ export function packageIdentity(root = ROOT) {
41
+ const key = path.resolve(root);
42
+ if (!cache.has(key)) cache.set(key, (async () => {
43
+ try {
44
+ const pkg = JSON.parse(await readFile(path.join(key, 'package.json'), 'utf8'));
45
+ if (pkg.private === true || typeof pkg.name !== 'string' || typeof pkg.version !== 'string' || !Array.isArray(pkg.files) || !pkg.files.length) return null;
46
+ const entries = pkg.files.filter(item => typeof item === 'string' && !path.isAbsolute(item) && !item.split(/[\\/]/).includes('..'));
47
+ const files = await hashTree(key, entries);
48
+ return Object.freeze({ root: key, name: pkg.name, version: pkg.version, integrity: manifestIntegrity(files), files: Object.freeze(files) });
49
+ } catch { return null; }
50
+ })());
51
+ return cache.get(key);
52
+ }
53
+
54
+ /**
55
+ * Si TODOS los archivos dados ({file absoluto, sha256}) son del paquete y coinciden con los
56
+ * que se midieron para su integridad, devuelve {name, version, integrity}; si no (fuera del
57
+ * paquete o cambiados desde entonces), null: la confianza vuelve a la huella del código.
58
+ */
59
+ export function packageCoverage(identity, files) {
60
+ if (!identity || !Array.isArray(files) || !files.length) return null;
61
+ for (const { file, sha256 } of files) {
62
+ const relative = posix(path.relative(identity.root, file));
63
+ if (!relative || relative.startsWith('../') || path.isAbsolute(relative) || identity.files[relative] !== sha256) return null;
64
+ }
65
+ return { name: identity.name, version: identity.version, integrity: identity.integrity };
66
+ }
67
+
68
+ export const samePackage = (a, b) => Boolean(a && b && a.name === b.name && a.version === b.version && a.integrity === b.integrity);
@@ -0,0 +1,16 @@
1
+ // Sondeo de mensajes para el peer de la TUI (cada 300 ms). Avisos de plugins, mensajes entre sesiones y
2
+ // resultados de subagentes viven en el libro de la sesión: si el libro no cambió desde el último «no hay
3
+ // nada», la respuesta tampoco. Así una sesión grande no se relee entera en cada tick (gate «extensión de
4
+ // bóveda»: ~760 ms por sondeo con 6 MB, peerProcessing siempre activo y ~120 % de CPU en reposo). Un
5
+ // sondeo completo cada `maxAgeMs` cubre lo que no deja rastro en el libro (p. ej. un subagente que murió).
6
+ export function memoizedPeerProbe({ stamp, probe, maxAgeMs = 5000, now = Date.now }) {
7
+ let last = null; // { stamp, at } del último sondeo completo que dijo «nada»
8
+ return async () => {
9
+ let current = null;
10
+ try { current = await stamp(); } catch { current = null; }
11
+ if (current !== null && last && last.stamp === current && now() - last.at < maxAgeMs) return false;
12
+ const answer = Boolean(await probe());
13
+ last = !answer && current !== null ? { stamp: current, at: now() } : null;
14
+ return answer;
15
+ };
16
+ }
@@ -1,10 +1,79 @@
1
1
  // Work controls write only the session ledger; nested effects still use the house judge.
2
2
  const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'McpStatus', 'HouseContext', 'HouseSnapshot', 'OperationCatalog', 'ReadTable', 'PluginStatus', 'WorkSnapshot', 'AuditStatus', 'EvidenceRead', 'WorkPlanSet', 'WorkCheckRepair', 'WorkTaskUpdate', 'WorkStepRun', 'WorkDraft', 'WorkBoard']);
3
3
  const EDIT_TOOLS = new Set(['Write', 'Edit', 'McpConfigure','GitIntegrationPrepare','GitIntegrationApply']);
4
- import { inspectAutoAction, intentCoversAction } from './auto-guard.mjs';
4
+ import { inspectAutoAction, intentCoversAction, isProtectedPath } from './auto-guard.mjs';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { readOnlyBashCheck, chromeLabObservation, openRelayObservation, omarchyObservation } from './read-only-check.mjs';
7
7
  import { describeRule, suggestRule } from './permission-rules.mjs';
8
+ import { measureHumanWait } from './turn-timing.mjs';
9
+ import { realpath, stat } from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ const within = (root, file) => { const relative = path.relative(root, file); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); };
14
+
15
+ /** realpath of the nearest existing ancestor plus the rest: a location not created yet still compares canonically. */
16
+ async function canonicalish(file) {
17
+ let ancestor = path.resolve(file), suffix = [];
18
+ while (true) {
19
+ try { return path.join(await realpath(ancestor), ...suffix); }
20
+ catch { const parent = path.dirname(ancestor); if (parent === ancestor) return path.resolve(file); suffix.unshift(path.basename(ancestor)); ancestor = parent; }
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Comal's private configuration and state: saved permission rules, plugin trust, credentials, peer tokens,
26
+ * the visual-approval key, relay tokens, session books. A writable or readable copy of any of them would let
27
+ * the model grant itself authority or take secrets, so no /add-dir may contain or sit inside one.
28
+ */
29
+ export function privateLocations({ env = process.env, home = env.HOME || os.homedir(), extra = [] } = {}) {
30
+ const list = [
31
+ env.XDG_CONFIG_HOME && path.join(env.XDG_CONFIG_HOME, 'comal'), path.join(home, '.config', 'comal'),
32
+ env.XDG_STATE_HOME && path.join(env.XDG_STATE_HOME, 'comal'), path.join(home, '.local', 'state', 'comal'),
33
+ path.join(home, '.comal'), path.join(home, '.relay-hub'),
34
+ ...['COMAL_AUTH_DIR', 'COMAL_CLOUD_CONFIG', 'COMAL_PEER_DIR', 'COMAL_VISUAL_KEY_DIR', 'COMAL_CONTEXT_STATS_DIR', 'COMAL_PLUGIN_CONFIG', 'COMAL_PROVIDERS_CONFIG', 'RELAY_HUB_ROOT'].map(name => env[name]),
35
+ ...extra,
36
+ ];
37
+ return [...new Set(list.filter(item => typeof item === 'string' && item).map(item => path.resolve(item)))];
38
+ }
39
+ const STATE_NAMES = ['.comal', '.harness'];
40
+ export const holdsComalState = (root, file) => path.relative(root, file).split(path.sep).some(segment => STATE_NAMES.includes(segment));
41
+
42
+ /**
43
+ * /add-dir: directories the human opens mid-session for reading AND writing. Canonical and existing; never
44
+ * the filesystem root, a world-writable directory, Comal state (.comal/.harness), nor anything that contains
45
+ * or sits inside a private Comal location (see privateLocations).
46
+ */
47
+ export async function workRootsFrom(paths = [], { env = process.env, home, private: extra = [] } = {}) {
48
+ const locations = [];
49
+ for (const location of privateLocations({ env, home, extra })) locations.push([location, await canonicalish(location)]);
50
+ const out = [];
51
+ for (const raw of paths) {
52
+ let canonical;
53
+ try { canonical = await realpath(raw); } catch { throw new Error(`/add-dir ${raw}: does not exist.`); }
54
+ const info = await stat(canonical);
55
+ if (!info.isDirectory()) throw new Error(`/add-dir ${raw}: not a directory.`);
56
+ if (canonical === path.parse(canonical).root) throw new Error(`/add-dir ${raw}: the filesystem root cannot be opened for writing.`);
57
+ if (holdsComalState(path.parse(canonical).root, canonical)) throw new Error(`/add-dir ${raw}: it is Comal state (.comal/.harness).`);
58
+ if (info.mode & 0o002) throw new Error(`/add-dir ${raw}: it is world-writable; another user could swap files under Comal. Choose a private directory.`);
59
+ const hit = locations.find(([location, real]) => [location, real].some(item => within(canonical, item) || within(item, canonical)));
60
+ if (hit) throw new Error(`/add-dir ${raw}: it touches a private Comal location (${hit[0]}); Comal configuration, credentials and session state stay out of reach. Choose a narrower directory.`);
61
+ if (!out.includes(canonical)) out.push(canonical);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /** --add-dir: directorios de solo lectura elegidos por el humano al arrancar; canónicos y existentes. */
67
+ export async function readRootsFrom(paths = []) {
68
+ const out = [];
69
+ for (const raw of paths) {
70
+ let canonical;
71
+ try { canonical = await realpath(raw); } catch { throw new Error(`--add-dir ${raw}: no existe.`); }
72
+ if (!(await stat(canonical)).isDirectory()) throw new Error(`--add-dir ${raw}: no es un directorio.`);
73
+ if (!out.includes(canonical)) out.push(canonical);
74
+ }
75
+ return out;
76
+ }
8
77
  READ_TOOLS.add('ToolPack');
9
78
  for (const name of ['ArtifactAccount','ArtifactCapabilities','ArtifactList','ArtifactInspect']) READ_TOOLS.add(name);
10
79
  for (const name of ['ListDirectory', 'FileInfo', 'SourceRead', 'GitInspect','GitIntegrationStatus','GitIntegrationEvidence']) READ_TOOLS.add(name);
@@ -51,13 +120,36 @@ export class PermissionPolicy {
51
120
  // rules: almacén de reglas guardadas (permission-rules.mjs) con decide(name, input) → {list, rule, scope}.
52
121
  // contractOf(name): contrato vivo de la operación (house.registry.get(name)?.contract); de ahí sale
53
122
  // el techo duro confirm:'always', que sólo el host lee y el modelo no puede cambiar.
54
- constructor({ mode = 'default', dangerouslySkipPermissions = false, allowed = [], denied = [], confirm, classifier, cwd, rules, contractOf, onDecision = () => {} } = {}) {
123
+ #workRoots = []; #private = []; #privateExtra;
124
+ constructor({ mode = 'default', dangerouslySkipPermissions = false, allowed = [], denied = [], confirm, classifier, cwd, rules, contractOf, onDecision = () => {}, readRoots = [], privateLocations: privateExtra = [] } = {}) {
125
+ this.#privateExtra = [...privateExtra];
55
126
  if (!PERMISSION_MODES.includes(mode) && !(dangerouslySkipPermissions === true && mode === BYPASS_PERMISSION_MODE)) throw new Error('Invalid permission mode. Use default, plan, acceptEdits or auto; bypass requires the startup flag.');
56
127
  Object.defineProperty(this, 'dangerouslySkipPermissions', { value: dangerouslySkipPermissions === true, enumerable: true });
57
128
  this.#mode = this.dangerouslySkipPermissions ? BYPASS_PERMISSION_MODE : mode;
58
129
  this.allowed = new Set(allowed); this.denied = new Set(denied); this.confirm = confirm;
59
130
  this.classifier = classifier; this.cwd = cwd; this.rules = rules; this.contractOf = contractOf; this.onDecision = onDecision; this.context = { intentions: [], actions: [] };
131
+ // --add-dir: solo lectura (Read, FileInfo, ListDirectory, Grep, Glob); la escritura sigue en el workspace.
132
+ Object.defineProperty(this, 'readRoots', { value: Object.freeze([...readRoots]), enumerable: true });
133
+ }
134
+ /** /add-dir: read+write directories the human opened this session (a frozen copy; only addWorkRoot changes it). */
135
+ get workRoots() { return Object.freeze([...this.#workRoots]); }
136
+ /** Host-only: the human's /add-dir. Writes there still go through this policy like workspace writes. */
137
+ async addWorkRoot(dir, options = {}) {
138
+ const extra = [...this.#privateExtra, ...(options.private || [])];
139
+ const [canonical] = await workRootsFrom([dir], { ...options, private: extra });
140
+ // exact: restoring a recorded path; if it now resolves elsewhere (a swapped symlink), nothing opens.
141
+ if (options.exact && canonical !== dir) throw new Error(`${dir} now resolves to ${canonical}.`);
142
+ this.#private = [];
143
+ for (const location of privateLocations({ env: options.env, home: options.home, extra })) this.#private.push(location, await canonicalish(location));
144
+ if (this.cwd) { let workspace = this.cwd; try { workspace = await realpath(this.cwd); } catch {} if (within(workspace, canonical)) return { added: false, path: canonical, reason: 'inside_workspace' }; }
145
+ if (this.#workRoots.includes(canonical)) return { added: false, path: canonical, reason: 'already_added' };
146
+ this.#workRoots.push(canonical);
147
+ return { added: true, path: canonical };
60
148
  }
149
+ /** Host-only: another session was resumed in this process; its own /add-dir rows are restored next. */
150
+ clearWorkRoots() { this.#workRoots = []; }
151
+ /** A private Comal location or a path an active plugin protects: closed even under an added directory. */
152
+ isPrivate(file) { return this.#private.some(location => within(location, file)) || isProtectedPath(file); }
61
153
  get mode() { return this.#mode; }
62
154
  set mode(mode) {
63
155
  if (this.dangerouslySkipPermissions) throw new Error(BYPASS_LOCKED_MESSAGE);
@@ -137,7 +229,8 @@ export class PermissionPolicy {
137
229
  if(this.confirm!==confirm || stamp!==permissionStamp(this))return this.confirm===confirm && visible===houseStamp(this) ? { allowed: false, stale: true } : { allowed: false };
138
230
  await onWaiting?.({type:'house_waiting',question_id:questionId});
139
231
  displayed=true;signal?.throwIfAborted();
140
- const answer=normalizeAnswer(await waitWithAbort(confirm(name,input,{signal,questionId,...(offer?{remember:offer}:{})}),signal),offer);
232
+ // The visible card is human time: excluded from the turn's Comal time.
233
+ const answer=normalizeAnswer(await measureHumanWait(()=>waitWithAbort(confirm(name,input,{signal,questionId,...(offer?{remember:offer}:{})}),signal)),offer);
141
234
  signal?.throwIfAborted();
142
235
  allowed=answer.allowed && this.confirm===confirm && (stamp===permissionStamp(this) || visible===houseStamp(this) && this.rules?.decide(name,input)?.list!=='deny');
143
236
  if(!allowed || !answer.remember)return { allowed };
@@ -153,7 +246,7 @@ export class PermissionPolicy {
153
246
  }
154
247
  async authorizeAuto(name, input, signal, onWaiting, invocation, hostFacts, rule = this.rules?.decide(name, input)) {
155
248
  const inspection = inspectAutoAction(name, input, this.cwd || process.cwd());
156
- const data = { workspace: this.cwd, human_requests: this.context.intentions, previous_tool_calls: this.context.actions, proposed_action: { name, input } };
249
+ const data = { workspace: this.cwd, ...(this.#workRoots.length ? { extra_work_directories: [...this.#workRoots] } : {}), human_requests: this.context.intentions, previous_tool_calls: this.context.actions, proposed_action: { name, input } };
157
250
  if(hostFacts)data.host_verified_facts=hostFacts;
158
251
  let verdict;
159
252
  if (inspection.hard) verdict = { decision: 'deny', risk: 'destructive', reason: inspection.hard, intent_quote: '', source: 'guard' };
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
5
  import { t, registerMessages } from './i18n/index.mjs';
6
+ import { packageIdentity, packageCoverage } from './package-identity.mjs';
7
+ import { access } from 'node:fs/promises';
6
8
 
7
9
  registerMessages({'Invalid plugin JSON: {detail}': 'Configuración de plugins JSON inválida: {detail}'});
8
10
 
@@ -108,7 +110,22 @@ export async function inspectPluginModule(requested) {
108
110
  }
109
111
  }
110
112
  const list = [...files].map(([file, sha256]) => ({ path: path.relative(root, file).split(path.sep).join('/'), sha256 })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
111
- return { module: entry, code_fingerprint: hash(Buffer.from(JSON.stringify(list))), files: list, external: [...external].sort() };
113
+ // Plugins que viajan dentro de un paquete instalado (Comal): su identidad es la del paquete
114
+ // si todos sus archivos coinciden con los que se midieron para su integridad (package-identity.mjs).
115
+ const owner = await packageRootOf(entry), pkg = owner && packageCoverage(await packageIdentity(owner), [...files].filter(([, sha256]) => sha256).map(([file, sha256]) => ({ file, sha256 })));
116
+ return { module: entry, code_fingerprint: hash(Buffer.from(JSON.stringify(list))), files: list, external: [...external].sort(), ...(pkg ? { package: pkg } : {}) };
117
+ }
118
+
119
+ // El directorio más cercano (hasta 8 niveles arriba) con package.json. La identidad es de
120
+ // contenido (digest de los sha256 de los archivos del paquete), así que otro paquete sólo
121
+ // coincide con uno confiado si sus bytes son los mismos.
122
+ async function packageRootOf(file) {
123
+ let dir = path.dirname(file);
124
+ for (let level = 0; level < 8; level++) {
125
+ try { await access(path.join(dir, 'package.json')); return dir; } catch {}
126
+ const parent = path.dirname(dir); if (parent === dir) return null; dir = parent;
127
+ }
128
+ return null;
112
129
  }
113
130
 
114
131
  /** Import executes trusted host code. Call only after explicit human plugin trust. */
@@ -139,7 +139,14 @@ export class PluginControls {
139
139
  }
140
140
  get project() { return this.runtime.file; }
141
141
  /** Callback de arranque: decide si la confianza registrada sigue vigente para el código actual. */
142
- trusted = async (entry, code) => trustVerdict(this.store.find(this.project, entry.id), { project: this.project, entry, code });
142
+ trusted = async (entry, code) => { const verdict = trustVerdict(this.store.find(this.project, entry.id), { project: this.project, entry, code }); await this.#follow(entry.id, verdict, code); return verdict; };
143
+ // Confianza de paquete (mismo Comal, otra ruta o huella): el registro sigue al código vivo
144
+ // para que las reglas allow (codeTrusted) apliquen. No amplía nada: es el mismo contenido.
145
+ async #follow(id, verdict, code) {
146
+ const saved = this.store.find(this.project, id);
147
+ if (verdict.status !== 'trusted' || !saved || (saved.module === code.module && saved.code_fingerprint === code.code_fingerprint)) return;
148
+ try { await this.store.update(this.project, id, () => ({ module: code.module, code_fingerprint: code.code_fingerprint, files: code.files })); } catch {}
149
+ }
143
150
  async initialize({ trust = false, signal } = {}) {
144
151
  await this.store.load(); await this.rules.load();
145
152
  for (const warning of [this.store.warning, this.rules.warning]) if (warning) this.notice(warning);
@@ -166,22 +173,23 @@ export class PluginControls {
166
173
  return name === 'PluginTrust' && this.trustByFlag;
167
174
  }
168
175
  #live(id) { return this.runtime.status().plugins.find(plugin => plugin.id === id); }
169
- async #trustAndEnable(id, options = {}, { recommend = false } = {}) {
176
+ async #trustAndEnable(id, options = {}, { recommend = false, preapproved = false } = {}) {
170
177
  const { entry, code } = await this.runtime.inspect(id), saved = this.store.find(this.project, id);
171
178
  const verdict = trustVerdict(saved, { project: this.project, entry, code }), live = this.#live(id), active = live?.state === 'active';
172
179
  const expect = { module: code.module, code_fingerprint: code.code_fingerprint };
173
180
  if (verdict.status === 'trusted') {
181
+ await this.#follow(id, verdict, code);
174
182
  if (!active) await this.runtime.enable(id, { signal: options.signal, expect });
175
183
  else if (live.code_fingerprint !== code.code_fingerprint) await this.runtime.load(id, { signal: options.signal, expect });
176
184
  return t('{id}: trust current · active · {grants}', { id, grants: this.#grantSummary(id) });
177
185
  }
178
- if (!this.confirm && !this.trustByFlag) {
186
+ if (!preapproved && !this.confirm && !this.trustByFlag) {
179
187
  // Sin TUI ni --trust-plugins: la orden humana carga sólo esta sesión, como siempre.
180
188
  // Conserva la salida JSON de estado que ya consumen scripts de -p.
181
189
  const status = active ? this.runtime.status() : await this.runtime.enable(id, { signal: options.signal });
182
190
  return JSON.stringify({ ...status, note: t('{id}: active only in this session (no remembered trust). In the TUI, /plugins enable {id} asks for confirmation and remembers it; in -p, add --trust-plugins.', { id }) });
183
191
  }
184
- const accepted = await this.#ask('PluginTrust', {
192
+ const accepted = preapproved || await this.#ask('PluginTrust', {
185
193
  plugin: id, project: this.project, module: code.module, code_fingerprint: code.code_fingerprint, files: code.files.map(file => file.path), ...(code.external.length ? { uncovered_packages: code.external } : {}), config: entry.config,
186
194
  ...(verdict.status === 'stale' ? { changed: verdict.reasons } : {}),
187
195
  effect: t('Comal will import and run this local Node code with your user permissions, now and on later startups while module, fingerprint and configuration do not change.'),
@@ -193,7 +201,7 @@ export class PluginControls {
193
201
  const lines = [t('{id}: trust remembered ({count} files, fingerprint {fingerprint}) · active.', { id, count: code.files.length, fingerprint: code.code_fingerprint.slice(0, 12) })];
194
202
  const split = pluginOperations(id);
195
203
  if (recommend && split && !this.rules.list().some(this.#pluginAllow(id))) {
196
- const allow = await this.#ask('PluginAllow', { plugin: id, allow: split.observations.map(short), still_ask: split.actions.map(short), note: t('Recommended: authorize only observations. Actions will keep asking; /plugins allow {id} all authorizes all of them. Explicit denials still win.', { id }) }, options);
204
+ const allow = preapproved || await this.#ask('PluginAllow', { plugin: id, allow: split.observations.map(short), still_ask: split.actions.map(short), note: t('Recommended: authorize only observations. Actions will keep asking; /plugins allow {id} all authorizes all of them. Explicit denials still win.', { id }) }, options);
197
205
  if (allow) await this.rules.update({ add: [{ list: 'allow', rule: `plugin:${id}:observe`, scope: this.rules.scope }] });
198
206
  else lines.push(t('No allow rule saved. Recommended: /plugins allow {id} observe.', { id }));
199
207
  }
@@ -224,10 +232,18 @@ export class PluginControls {
224
232
  }).join('\n\n');
225
233
  }
226
234
  #split(id, set) { const { workflow, ...config } = set || {}; if (workflow !== undefined && !bundledPlugin(id)?.host?.workflow?.includes(workflow)) throw new Error(t('workflow does not apply to {id} or is not auto|raw.', { id })); return { workflow, config }; }
227
- async add({ id, set }, options) {
235
+ async add({ id, set }, options) { return this.#add({ id, set }, options); }
236
+ /**
237
+ * Alta, confianza y lecturas sin preguntar confirmadas por una tarjeta del HOST que ya las
238
+ * mostró (la de /relay: versión de Comal, proyecto, permisos de la delegación). No vuelve a
239
+ * preguntar PluginTrust ni PluginAllow. Sólo plugins incluidos. No es una operación: ni el
240
+ * modelo ni /plugins la invocan; la llama el host después de la tarjeta del humano.
241
+ */
242
+ async link({ id, set }, options = {}) { return this.#add({ id, set }, options, { preapproved: true, replace: true }); }
243
+ async #add({ id, set }, options, { preapproved = false, replace = false } = {}) {
228
244
  const item = bundledPlugin(id); if (!item) throw new Error(t('{id} is not a bundled plugin. Use /plugins available; for others, edit {file}.', { id, file: this.project }));
229
245
  const { workflow, config } = this.#split(id, set), raw = await readRaw(this.project), existing = raw.plugins.find(entry => entry.id === id);
230
- const next = { id, module: item.module, enabled: true, config: validateBundledConfig(id, { ...item.defaults, ...(existing?.config || {}), ...config }) };
246
+ const next = { id, module: item.module, enabled: true, config: validateBundledConfig(id, { ...item.defaults, ...(replace ? {} : existing?.config || {}), ...config }) };
231
247
  if (this.#live(id)?.state === 'active' && existing && existing.module !== item.module) throw new Error(t('{id} is active with another module; disable it before replacing it.', { id }));
232
248
  raw.plugins = existing ? raw.plugins.map(entry => entry.id === id ? next : entry) : [...raw.plugins, next];
233
249
  if (workflow) raw.chrome_workflow = workflow;
@@ -235,9 +251,16 @@ export class PluginControls {
235
251
  if (workflow) await this.onWorkflow(workflow);
236
252
  const lines = [t(existing ? 'Updated {id} in {file}' : 'Added {id} in {file}', { id, file: this.project }), t(' module: {module}', { module: item.module }), t(' config: {config}', { config: Object.entries(next.config).map(([key, value]) => `${key}=${value}`).join(' ') })];
237
253
  if (this.#live(id)?.state === 'active' && existing && configDigest(existing.config) !== configDigest(next.config)) lines.push(await this.#reconfigure(id, options));
238
- lines.push(await this.#trustAndEnable(id, options, { recommend: true }));
254
+ lines.push(await this.#trustAndEnable(id, options, { recommend: true, preapproved }));
239
255
  return lines.join('\n');
240
256
  }
257
+ /** Estado de un plugin incluido en este proyecto: config guardada, confianza vigente y estado vivo. */
258
+ async describe(id) {
259
+ const raw = await readRaw(this.project), entry = raw.plugins.find(item => item.id === id), live = this.#live(id);
260
+ let trust = { status: 'untrusted', reasons: ['no record'] }, code = null;
261
+ if (entry) { try { const inspected = await this.runtime.inspect(id); code = inspected.code; trust = trustVerdict(this.store.find(this.project, id), { project: this.project, entry: inspected.entry, code }); } catch (error) { trust = { status: 'error', reasons: [error.message] }; } }
262
+ return { configured: Boolean(entry), config: entry?.config || null, state: live?.state || null, error: live?.error || null, trust, package: code?.package || null, code_fingerprint: code?.code_fingerprint || null };
263
+ }
241
264
  async #reconfigure(id, options) {
242
265
  const saved = this.store.find(this.project, id), { entry } = await this.runtime.inspect(id);
243
266
  if (saved) await this.store.update(this.project, id, () => ({ config_sha256: configDigest(entry.config) }));
@@ -3,6 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { readPrivateJson, writePrivateJson } from './permission-rules.mjs';
5
5
  import { t, registerMessages } from './i18n/index.mjs';
6
+ import { samePackage } from './package-identity.mjs';
6
7
 
7
8
  registerMessages({
8
9
  'Invalid trust record.': 'Registro de confianza inválido.',
@@ -11,6 +12,7 @@ registerMessages({
11
12
  'Trust record: invalid id or fingerprints.': 'Registro de confianza: id o huellas inválidas.',
12
13
  'Trust record: invalid files.': 'Registro de confianza: archivos inválidos.',
13
14
  'Trust record: invalid date.': 'Registro de confianza: fecha inválida.',
15
+ 'Trust record: invalid package.': 'Registro de confianza: paquete inválido.',
14
16
  'Unrecognized trust registry format.': 'Formato de registro de confianza no reconocido.',
15
17
  'Trust registry ignored: {reason}': 'Registro de confianza ignorado: {reason}',
16
18
  'No recorded trust for {id} in this project. Use /plugins enable {id}.': 'No hay confianza registrada para {id} en este proyecto. Usa /plugins enable {id}.',
@@ -32,25 +34,32 @@ export function pluginTrustFile(env = process.env) {
32
34
 
33
35
  function record(value) {
34
36
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw fail(t('Invalid trust record.'));
35
- const keys = ['project', 'id', 'module', 'code_fingerprint', 'config_sha256', 'files', 'trusted_at'];
37
+ const keys = ['project', 'id', 'module', 'code_fingerprint', 'config_sha256', 'files', 'trusted_at', 'package'];
36
38
  for (const key of Object.keys(value)) if (!keys.includes(key)) throw fail(t('Trust record: unknown field {key}', { key }));
37
39
  if (typeof value.project !== 'string' || !path.isAbsolute(value.project) || typeof value.module !== 'string' || !path.isAbsolute(value.module)) throw fail(t('Trust record: absolute paths required.'));
38
40
  if (!ID.test(value.id || '') || !SHA.test(value.code_fingerprint || '') || !SHA.test(value.config_sha256 || '')) throw fail(t('Trust record: invalid id or fingerprints.'));
39
41
  if (!Array.isArray(value.files) || value.files.length > 256 || value.files.some(file => !file || typeof file.path !== 'string' || !(file.sha256 === null || SHA.test(file.sha256)))) throw fail(t('Trust record: invalid files.'));
40
42
  if (typeof value.trusted_at !== 'string') throw fail(t('Trust record: invalid date.'));
41
- return { project: value.project, id: value.id, module: value.module, code_fingerprint: value.code_fingerprint, config_sha256: value.config_sha256, files: value.files.map(file => ({ path: file.path, sha256: file.sha256 })), trusted_at: value.trusted_at };
43
+ const pkg = value.package;
44
+ if (pkg !== undefined && (!pkg || typeof pkg.name !== 'string' || pkg.name.length > 214 || typeof pkg.version !== 'string' || pkg.version.length > 64 || !SHA.test(pkg.integrity || '') || Object.keys(pkg).some(key => !['name', 'version', 'integrity'].includes(key)))) throw fail(t('Trust record: invalid package.'));
45
+ return { project: value.project, id: value.id, module: value.module, code_fingerprint: value.code_fingerprint, config_sha256: value.config_sha256, files: value.files.map(file => ({ path: file.path, sha256: file.sha256 })), trusted_at: value.trusted_at, ...(pkg ? { package: { name: pkg.name, version: pkg.version, integrity: pkg.integrity } } : {}) };
42
46
  }
43
47
 
44
48
  /**
45
49
  * Juzga una entrada configurada contra el registro. Sólo hay confianza vigente
46
50
  * si coinciden proyecto, id, módulo real, huella del código y digest de config.
51
+ * Plugins incluidos en un paquete con manifiesto (Comal instalado): la confianza es del
52
+ * PAQUETE (nombre, versión e integridad), así que vuelve a preguntarse una vez por versión
53
+ * y no depende de la ruta donde se instaló; el contenido sigue siendo el mismo porque la
54
+ * integridad es el digest de los archivos. Sin manifiesto (copia de desarrollo) vale la huella.
47
55
  */
48
56
  // Los motivos van también en el estado que lee el modelo (PluginStatus): inglés fijo, sin t().
49
57
  export function trustVerdict(saved, { project, entry, code }) {
50
58
  if (!saved) return { status: 'untrusted', reasons: ['no record'] };
51
- const reasons = [];
52
- if (saved.module !== code.module) reasons.push('module path');
53
- if (saved.code_fingerprint !== code.code_fingerprint) {
59
+ const reasons = [], pkg = samePackage(saved.package, code.package);
60
+ if (saved.module !== code.module && !pkg) reasons.push('module path');
61
+ if (saved.package && code.package && !pkg) reasons.push(`Comal package (${saved.package.version} → ${code.package.version})`);
62
+ else if (saved.code_fingerprint !== code.code_fingerprint && !pkg) {
54
63
  const before = new Map(saved.files.map(file => [file.path, file.sha256])), changed = code.files.filter(file => before.get(file.path) !== file.sha256).map(file => file.path);
55
64
  const removed = saved.files.filter(file => !code.files.some(next => next.path === file.path)).map(file => file.path);
56
65
  reasons.push(`code fingerprint (${[...changed, ...removed].slice(0, 6).join(', ') || 'files'})`);
@@ -85,7 +94,7 @@ export class PluginTrustStore {
85
94
  }
86
95
  async trust({ project, entry, code }) {
87
96
  await this.#ready();
88
- const next = record({ project, id: entry.id, module: code.module, code_fingerprint: code.code_fingerprint, config_sha256: configDigest(entry.config), files: code.files, trusted_at: new Date().toISOString() });
97
+ const next = record({ project, id: entry.id, module: code.module, code_fingerprint: code.code_fingerprint, config_sha256: configDigest(entry.config), files: code.files, trusted_at: new Date().toISOString(), ...(code.package ? { package: code.package } : {}) });
89
98
  await this.#save([...this.#records.filter(item => !(item.project === project && item.id === entry.id)), next]);
90
99
  return structuredClone(next);
91
100
  }
@@ -22,14 +22,14 @@ export async function remoteBridge(config, lines) {
22
22
  if (!valid) throw new Error('Invalid private attach diagnostic arguments.');
23
23
  return option;
24
24
  };
25
- const cdp = (method, params = {}, sessionId, diagnostic) => new Promise((resolve, reject) => {
25
+ const cdp = (method, params = {}, sessionId, diagnostic, replyOwner) => new Promise((resolve, reject) => {
26
26
  const id = ++sequence, started = performance.now();
27
27
  const outcome = (state,reason,error) => ({diagnostic:{version:1,method,session_id:sessionId,state,reason,remote_dispatched:true,timeout_ms:diagnostic.timeout_ms,elapsed_ms:performance.now()-started,...(error?{error}: {})}});
28
28
  const timer = setTimeout(() => {
29
29
  pending.delete(id);
30
30
  diagnostic ? resolve(outcome('unknown','command_timeout')) : reject(new Error(`CDP timeout: ${method}; no se reintentó.`));
31
31
  }, diagnostic?.timeout_ms ?? config.timeoutMs);
32
- pending.set(id, { resolve, reject, timer, method, params, diagnostic, outcome });
32
+ pending.set(id, { resolve, reject, timer, method, params, sessionId, diagnostic, outcome, replyOwner });
33
33
  try { socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }
34
34
  catch(error) { pending.delete(id); clearTimeout(timer); reject(error); }
35
35
  });
@@ -60,6 +60,19 @@ export async function remoteBridge(config, lines) {
60
60
  await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', () => reject(new Error('No se pudo abrir WebSocket CDP.')), { once: true }); });
61
61
  socket.addEventListener('message', event => {
62
62
  const message = JSON.parse(event.data);
63
+ const matched = pending.get(message.id);
64
+ // Only the actual matched browser response can populate this lexical owner.
65
+ // Caller-shaped Errors, optional diagnostics and internal bridge commands
66
+ // never supply native reply provenance. Keep the untruncated raw response.
67
+ if (matched?.replyOwner && ['Page.bringToFront','DOM.focus'].includes(matched.method) &&
68
+ typeof event.data === 'string' && Buffer.byteLength(event.data) <= 8192) {
69
+ matched.replyOwner.nativeReply = {format:'comal-cdp-native-reply/1',
70
+ outer_request_id:matched.replyOwner.outerId, cdp_request_id:message.id,
71
+ method:matched.method, session_id:matched.sessionId,
72
+ params_sha256:digest(JSON.stringify(matched.params)),
73
+ browser_endpoint_id:/^\/devtools\/browser\/([^/]+)$/.exec(endpoint.pathname)?.[1] || null,
74
+ remote_dispatched:true, reply_json:event.data};
75
+ }
63
76
  if (message.id) { const entry = pending.get(message.id); if (!entry) return; pending.delete(message.id); clearTimeout(entry.timer); if (!message.error) { if (entry.method === 'Target.attachToTarget' && message.result?.sessionId) { attached.add(message.result.sessionId); if(entry.params.flatten === true) flattened.add(message.result.sessionId); } if (entry.method === 'Target.detachFromTarget') { attached.delete(entry.params.sessionId); flattened.delete(entry.params.sessionId); } } if(entry.diagnostic) { if(message.error && (!Number.isInteger(message.error.code) || typeof message.error.message !== 'string')) entry.reject(new Error('Malformed diagnostic CDP error.')); else entry.resolve(message.error ? entry.outcome('rejected','command_error',{code:message.error.code,message:message.error.message.slice(0,1000)}) : entry.outcome('enabled','command_reply')); } else message.error ? entry.reject(Object.assign(new Error(`${message.error.code}: ${message.error.message}`), { cdpError: { method: entry.method, code: message.error.code, message: String(message.error.message).slice(0, 1000) } })) : entry.resolve(message.result); }
64
77
  else if (['Runtime.consoleAPICalled', 'Runtime.exceptionThrown', 'Runtime.executionContextCreated', 'Runtime.executionContextDestroyed', 'Runtime.executionContextsCleared', 'Log.entryAdded', 'Network.loadingFailed', 'Inspector.detached', 'Target.detachedFromTarget'].includes(message.method)) {
65
78
  if(message.method === 'Target.detachedFromTarget') { attached.delete(message.params?.sessionId); flattened.delete(message.params?.sessionId); }
@@ -204,12 +217,12 @@ export async function remoteBridge(config, lines) {
204
217
  return { url: `http://127.0.0.1:${server.address().port}/${file.split('/').map(encodeURIComponent).join('/')}`, lifetime: 'plugin_connection' };
205
218
  };
206
219
  lines.on('line', async line => {
207
- let request;
220
+ let request, replyOwner;
208
221
  try {
209
222
  if (line.length > config.maxDeployBytes * 1.5 + 1000000) throw new Error('Mensaje demasiado grande.');
210
223
  request = JSON.parse(line); let result;
211
224
  const diagnostic = Object.hasOwn(request,'attachDiagnostic') ? diagnosticPolicy(request) : undefined;
212
- if (request.action === 'cdp') result = await cdp(request.method, request.params, request.sessionId, diagnostic);
225
+ if (request.action === 'cdp') { replyOwner={outerId:request.id};result = await cdp(request.method, request.params, request.sessionId, diagnostic, replyOwner); }
213
226
  else if (request.action === 'status') result = await ownExtensions();
214
227
  else if (request.action === 'checkTarget') result = await checkTarget(request.params.targetId);
215
228
  else if (request.action === 'open') result = await cdp('Target.createTarget', { url: await checkURL(request.params.url) });
@@ -223,10 +236,10 @@ export async function remoteBridge(config, lines) {
223
236
  else if (request.action === 'preview') result = await preview(request.params);
224
237
  else if (request.action === 'unload') { const extension = await extensionFor(request.params.project); await cdp('Extensions.uninstall', { id: extension.id }); result = { extension_id: extension.id, unloaded: true }; }
225
238
  else throw new Error('Acción del bridge desconocida.');
226
- const output = JSON.stringify({ id: request.id, result });
239
+ const output = JSON.stringify({ id: request.id, result, ...(replyOwner?.nativeReply ? {native_reply:replyOwner.nativeReply} : {}) });
227
240
  if (output.length > 12000000) throw new Error('Respuesta excesiva; usa consultas acotadas.');
228
241
  process.stdout.write(output + '\n');
229
- } catch (error) { send({ id: request?.id, error: error.message, ...(error.cdpError ? { cdp_error: error.cdpError } : {}) }); }
242
+ } catch (error) { send({ id: request?.id, error: error.message, ...(error.cdpError ? { cdp_error: error.cdpError } : {}), ...(replyOwner?.nativeReply ? {native_reply:replyOwner.nativeReply} : {}) }); }
230
243
  });
231
244
  send({ ready: { browser: version.Browser, browser_endpoint_id: /^\/devtools\/browser\/([^/]+)$/.exec(endpoint.pathname)?.[1] || null, protocol_version: version['Protocol-Version'], extensions: protocol.domains.find(domain => domain.domain === 'Extensions')?.commands.map(command => command.name) || [], remote_root: root, node: process.version } });
232
245
  }