@milpa/comal 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  4. package/package.json +3 -1
  5. package/skills/mv3/SKILL.md +44 -0
  6. package/skills/mv3/skill.json +4 -0
  7. package/src/agent.mjs +299 -32
  8. package/src/auto-guard.mjs +40 -3
  9. package/src/autocompact.mjs +233 -0
  10. package/src/bash-timeouts.mjs +29 -0
  11. package/src/child-env.mjs +23 -0
  12. package/src/cli.mjs +232 -63
  13. package/src/code-run-artifact.mjs +30 -0
  14. package/src/code-run-journal.mjs +135 -0
  15. package/src/code-run-session-proof.mjs +50 -0
  16. package/src/code-run-session.mjs +97 -0
  17. package/src/code-run-worker.mjs +20 -4
  18. package/src/code-run.mjs +49 -14
  19. package/src/codex-provider.mjs +4 -4
  20. package/src/compaction-notice.mjs +22 -0
  21. package/src/compaction.mjs +162 -23
  22. package/src/connection-settings.mjs +36 -4
  23. package/src/context-pressure.mjs +5 -4
  24. package/src/context.mjs +27 -22
  25. package/src/execution-metrics.mjs +36 -5
  26. package/src/file-recovery-proof.mjs +9 -1
  27. package/src/file-recovery.mjs +36 -3
  28. package/src/gemini-provider.mjs +5 -3
  29. package/src/git-integration-operations.mjs +2 -1
  30. package/src/git-plumbing.mjs +2 -1
  31. package/src/git-source.mjs +2 -1
  32. package/src/git-workspaces.mjs +2 -1
  33. package/src/house.mjs +13 -1
  34. package/src/i18n/cli-es.mjs +12 -1
  35. package/src/i18n/es.mjs +133 -2
  36. package/src/judges.mjs +2 -1
  37. package/src/mcp.mjs +2 -1
  38. package/src/model-controls.mjs +16 -2
  39. package/src/model-deadline.mjs +2 -2
  40. package/src/openai-provider.mjs +8 -4
  41. package/src/operations.mjs +17 -2
  42. package/src/package-identity.mjs +68 -0
  43. package/src/peer-probe.mjs +16 -0
  44. package/src/permissions.mjs +99 -6
  45. package/src/plugin-catalog.mjs +19 -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/omarchy/catalog.mjs +96 -0
  55. package/src/plugins/omarchy/environment.mjs +78 -0
  56. package/src/plugins/omarchy/guide.mjs +54 -0
  57. package/src/plugins/omarchy/index.mjs +213 -0
  58. package/src/plugins/omarchy/runner.mjs +62 -0
  59. package/src/plugins/omarchy/text.mjs +26 -0
  60. package/src/plugins/omarchy/tiers.mjs +157 -0
  61. package/src/plugins/open-relay/actions.mjs +212 -16
  62. package/src/plugins/open-relay/client.mjs +93 -35
  63. package/src/plugins/open-relay/decision.mjs +89 -0
  64. package/src/plugins/open-relay/dossier.mjs +23 -5
  65. package/src/plugins/open-relay/index.mjs +68 -28
  66. package/src/plugins/open-relay/lookup.mjs +41 -4
  67. package/src/plugins/open-relay/notices.mjs +48 -4
  68. package/src/plugins/open-relay/panel.mjs +59 -0
  69. package/src/plugins/open-relay/watch.mjs +198 -85
  70. package/src/plugins.mjs +12 -1
  71. package/src/process-lock.mjs +20 -12
  72. package/src/provider-auth.mjs +6 -3
  73. package/src/provider-profiles.mjs +13 -3
  74. package/src/provider.mjs +175 -30
  75. package/src/read-only-check.mjs +12 -4
  76. package/src/relay/broker.mjs +160 -0
  77. package/src/relay/cards.mjs +702 -0
  78. package/src/relay/flow.mjs +886 -0
  79. package/src/relay/follow.mjs +119 -0
  80. package/src/relay/git.mjs +23 -0
  81. package/src/relay/hub-client.mjs +163 -0
  82. package/src/relay/state.mjs +55 -0
  83. package/src/remote-control.mjs +115 -12
  84. package/src/remote-relay.mjs +468 -0
  85. package/src/session-bus.mjs +13 -3
  86. package/src/session-provider-settings.mjs +2 -2
  87. package/src/skill-runtime.mjs +8 -6
  88. package/src/source-ownership.mjs +3 -1
  89. package/src/swarm-view.mjs +53 -0
  90. package/src/tool-manifest.mjs +71 -0
  91. package/src/tool-packs.mjs +22 -6
  92. package/src/tool-schema.mjs +14 -2
  93. package/src/tools.mjs +96 -27
  94. package/src/tui/connection-transition.mjs +10 -0
  95. package/src/tui/controller.mjs +35 -9
  96. package/src/tui/discovery.mjs +13 -8
  97. package/src/tui/inbound-blocks.mjs +5 -1
  98. package/src/tui/screen.mjs +78 -15
  99. package/src/tui/swarm-panel.mjs +217 -0
  100. package/src/turn-timing.mjs +82 -0
  101. package/src/usage-accounting.mjs +12 -1
  102. package/src/vision.mjs +6 -1
  103. package/src/visual/chrome.mjs +379 -0
  104. package/src/visual/fingerprint.mjs +30 -0
  105. package/src/visual/fixtures.mjs +201 -0
  106. package/src/visual/gate.mjs +92 -0
  107. package/src/visual/judge.mjs +114 -0
  108. package/src/visual/ledger.mjs +160 -0
  109. package/src/visual/measure.mjs +145 -0
  110. package/src/visual/names.mjs +7 -0
  111. package/src/visual/operations.mjs +211 -0
  112. package/src/visual/pixel-diff.mjs +96 -0
  113. package/src/visual/png.mjs +103 -0
  114. package/src/visual/registry.mjs +265 -0
  115. package/src/visual/report.mjs +64 -0
  116. package/src/visual/verify.mjs +292 -0
  117. package/src/work-assertions.mjs +15 -4
  118. package/src/work-plan.mjs +166 -30
  119. package/src/work-state.mjs +4 -4
  120. package/src/workflows/chrome-task-adoption.mjs +7 -3
  121. package/src/workspace-serve.mjs +59 -0
@@ -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
  }
@@ -1,15 +1,135 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
- import { randomUUID } from 'node:crypto';
3
+ import { randomUUID, createHash } from 'node:crypto';
4
+ import { performance } from 'node:perf_hooks';
5
+ import {AsyncLocalStorage} from 'node:async_hooks';
4
6
  import { remoteBridge } from './bridge.mjs';
5
7
  import { OPTIONAL_PHASE_MS, PUBLIC_DIAGNOSTIC_BYTES, diagnosticEnvelope } from './diagnostics.mjs';
8
+ import { parseNativeReply } from './native-reply.mjs';
9
+ import {legacyFill,takeLegacyFillOrigin} from './legacy-fill.mjs';
10
+ const nativeSignalAborted=Object.getOwnPropertyDescriptor(AbortSignal.prototype,'aborted').get;
11
+ const originalSignalState=signal=>{if(signal===undefined)return false;try{return nativeSignalAborted.call(signal);}catch{return null;}};
6
12
 
7
13
  export class ChromeLabClient {
14
+ #nativeReplies = new WeakMap();
15
+ #pendingReplies = new Map();
16
+ #replyConnectionGeneration = 0;
17
+ #replySessionGenerations = new Map();
18
+ #requestSequence = 0;
19
+ #replyAttachments = new Map();
20
+ #recentRequests = new Map();
21
+ #fillCompletions = new WeakMap();
22
+ #fillObservations = new WeakMap();
23
+ #fillOrder = 0;
24
+ #fillOwner = new AsyncLocalStorage();
25
+ async fillLegacy(input,options) {
26
+ const originalOptions=Object.freeze({signal:options.signal,sessionId:options.sessionId});
27
+ const frame=Object.freeze({owner:Object.freeze({}),start:this.#requestSequence,order:++this.#fillOrder,signal:originalOptions.signal});
28
+ return this.#fillOwner.run(frame,async()=>{
29
+ const result=await legacyFill(this,input,originalOptions),origin=takeLegacyFillOrigin(result);
30
+ if(origin)this.#recordFillCompletion(result,origin,frame);
31
+ return result;
32
+ });
33
+ }
34
+ #recordFillCompletion(result,origin,frame) {
35
+ if(origin.client!==this || typeof origin.input.selector!=='string'||origin.options.signal!==frame.signal||originalSignalState(frame.signal)!==false)return;
36
+ const a=this.#nativeReplies.get(origin.activation),f=this.#nativeReplies.get(origin.error);
37
+ const activation=this.nativeReplyProvenance(origin.activation),focus=this.nativeReplyProvenance(origin.error);
38
+ if(!a||!f||!activation||!focus||activation.method!=='Page.bringToFront'||activation.outcome!=='native_success_reply'||
39
+ focus.method!=='DOM.focus'||focus.outcome!=='native_error_reply')return;
40
+ const error=JSON.parse(focus.reply_json).error;
41
+ if(error.code!==-32000||error.message!=='Element is not focusable')return;
42
+ const first=a.capture.guard.id,last=f.capture.guard.id;
43
+ if(first!==frame.start+1||last!==first+3||last!==this.#requestSequence)return;
44
+ const captures=Array.from({length:4},(_,i)=>this.#recentRequests.get(first+i));
45
+ const methods=['Page.bringToFront','DOM.getDocument','DOM.querySelector','DOM.focus'];
46
+ if(captures.some((c,i)=>!c||c.guard.fillOwner!==frame.owner||c.guard.method!==methods[i]||c.guard.action!=='cdp'||!c.state.settled||!c.state.observedReply||c.state.nativeFailed!==(i===3)||
47
+ c.state.writeState!=='acknowledged'||c.state.failed!==(i===3)||
48
+ originalSignalState(c.guard.signal)!==false||c.guard.sessionId!==origin.options.sessionId||
49
+ !c.guard.binding||Object.keys(c.guard.binding).some(k=>c.guard.binding[k]!==a.capture.guard.binding[k])))return;
50
+ if(captures[0]!==a.capture||captures[3]!==f.capture||activation.target_id!==origin.input.target_id||focus.target_id!==origin.input.target_id)return;
51
+ const root=captures[1].state.nodeId,node=captures[2].state.nodeId;
52
+ if(!Number.isSafeInteger(root)||root<1||!Number.isSafeInteger(node)||node<1)return;
53
+ const params=[{}, {depth:0}, {nodeId:root,selector:origin.input.selector}, {nodeId:node}];
54
+ if(captures.some((c,i)=>c.guard.paramsSha256!==createHash('sha256').update(JSON.stringify(params[i])).digest('hex')))return;
55
+ Object.freeze(result.observation.cdp_error);Object.freeze(result.observation);Object.freeze(result);
56
+ this.#fillCompletions.set(result,{origin,captures,last,frame});
57
+ }
58
+ beginLegacyFillObservation(signal) {
59
+ if(signal===undefined||originalSignalState(signal)!==false)throw new Error('A live original handler signal is required for input observation.');
60
+ const token=Object.freeze({});this.#fillObservations.set(token,Object.freeze({signal,start:this.#requestSequence,order:++this.#fillOrder}));return token;
61
+ }
62
+ closeLegacyFillObservation(token){this.#fillObservations.delete(token);}
63
+ takeLegacyFillCompletion(result,observation) {
64
+ let boundary=null;
65
+ if(arguments.length>1){boundary=this.#fillObservations.get(observation);this.#fillObservations.delete(observation);if(!boundary||originalSignalState(boundary.signal)!==false)return null;}
66
+ const record=result&&typeof result==='object'?this.#fillCompletions.get(result):null;
67
+ if(!record)return null;this.#fillCompletions.delete(result);
68
+ const {origin,captures,last,frame}=record,activation=this.nativeReplyProvenance(origin.activation),focus=this.nativeReplyProvenance(origin.error);
69
+ if(boundary&&(frame.signal!==boundary.signal||frame.start<boundary.start||frame.order<=boundary.order))return null;
70
+ if(originalSignalState(frame.signal)!==false||last!==this.#requestSequence||!activation||!focus||captures.some(c=>c.state.writeState!=='acknowledged'||
71
+ originalSignalState(c.guard.signal)!==false||performance.now()>=c.guard.requestDeadline))return null;
72
+ return Object.freeze({format:'comal-legacy-fill-completion/1',outcome:'completed_blocked_before_keyboard',
73
+ target_id:origin.input.target_id,selector:origin.input.selector,
74
+ request_ids:Object.freeze(captures.map(c=>c.guard.id)),activation,focus,input_commands_started:0,
75
+ original_handler_signal_bound:frame.signal!==undefined,
76
+ native_boundary_bound:boundary!==null,
77
+ authority:false,behavior_acceptance:false,retry_authorized:false,remote_nonexecution_proved:false,phase_lifetime_bound:false});
78
+ }
79
+ #replyBinding(sessionId) {
80
+ const targets = [...this.sessions].filter(([, id]) => id === sessionId);
81
+ if (this.closed || this.failure || !this.connectionId || !this.info?.browser_endpoint_id ||
82
+ typeof sessionId !== 'string' || !sessionId || targets.length !== 1) return null;
83
+ const attached=this.#replyAttachments.get(sessionId);
84
+ if(!attached || attached.targetId!==targets[0][0] || attached.connectionId!==this.connectionId ||
85
+ attached.browserEndpointId!==this.info.browser_endpoint_id || attached.generation!==this.#replyConnectionGeneration)return null;
86
+ if(!this.#replySessionGenerations.has(sessionId))this.#replySessionGenerations.set(sessionId,Object.freeze({}));
87
+ return {connectionId:this.connectionId, browserEndpointId:this.info.browser_endpoint_id,
88
+ sessionId, targetId:targets[0][0], contextEpoch:this.collections.get(sessionId)?.epoch ?? null,
89
+ connectionGeneration:this.#replyConnectionGeneration,sessionGeneration:this.#replySessionGenerations.get(sessionId)};
90
+ }
91
+ #recordNativeReply(message, entry, error, result) {
92
+ const capture=this.#pendingReplies.get(message.id),guard=capture?.guard;
93
+ if (!guard || guard.id!==message.id || capture.entry!==entry || capture.state.settled || guard.action !== 'cdp')return;
94
+ capture.state.observedReply=true;capture.state.nativeFailed=Boolean(error);
95
+ capture.state.nodeId=guard.method==='DOM.getDocument'?result?.root?.nodeId:guard.method==='DOM.querySelector'?result?.nodeId:null;
96
+ const current=guard.connectionId===this.connectionId && guard.browserEndpointId===this.info?.browser_endpoint_id && guard.connectionGeneration===this.#replyConnectionGeneration;
97
+ if(current && !error && guard.method==='Target.attachToTarget' && guard.attachTargetId &&
98
+ result && Object.keys(result).join()==='sessionId' && typeof result.sessionId==='string' && result.sessionId) {
99
+ this.#replySessionGenerations.delete(result.sessionId);
100
+ this.#replyAttachments.set(result.sessionId,this.#replyAttachments.has(result.sessionId)?null:Object.freeze({targetId:guard.attachTargetId,
101
+ connectionId:guard.connectionId,browserEndpointId:guard.browserEndpointId,generation:guard.connectionGeneration}));
102
+ }
103
+ if(current && !error && guard.method==='Target.detachFromTarget'){this.#replyAttachments.delete(guard.detachSessionId);this.#replySessionGenerations.delete(guard.detachSessionId);}
104
+ if (!guard.binding ||
105
+ !Number.isFinite(guard.requestDeadline) || !Number.isSafeInteger(guard.requestBudgetMs) ||
106
+ guard.requestBudgetMs<1 || guard.requestBudgetMs>2147483647) return;
107
+ const proof = parseNativeReply(message.native_reply, {id:guard.id, method:guard.method,
108
+ sessionId:guard.sessionId, paramsSha256:guard.paramsSha256,
109
+ browserEndpointId:guard.binding.browserEndpointId, error, cdpError:error?.cdpError, result});
110
+ const value = error || result;
111
+ if (proof && value && typeof value === 'object') this.#nativeReplies.set(value, {capture, proof});
112
+ }
113
+ nativeReplyProvenance(value) {
114
+ const record = value && typeof value === 'object' ? this.#nativeReplies.get(value) : null;
115
+ if (!record) return null;
116
+ const {capture, proof} = record,{guard,state}=capture,binding = this.#replyBinding(guard.sessionId);
117
+ if (state.writeState !== 'acknowledged' || !state.settled || originalSignalState(guard.signal)!==false ||
118
+ performance.now() >= guard.requestDeadline || !binding ||
119
+ Object.keys(binding).some(k => binding[k] !== guard.binding[k])) return null;
120
+ // This is an observation of this original object, never a live host-phase
121
+ // capability. The admitting House invocation/deadline must be bound separately.
122
+ return Object.freeze({...proof, target_id:binding.targetId, connection_id:binding.connectionId,
123
+ original_signal_bound:guard.signal!==undefined});
124
+ }
8
125
  constructor(config, { spawnProcess = spawn } = {}) {
9
126
  this.config = config; this.spawnProcess = spawnProcess; this.pending = new Map(); this.sessions = new Map(); this.attaching = new Map(); this.targetInfo = new Map(); this.contexts = new Map(); this.collections = new Map(); this.events = []; this.sequence = 0; this.closed = false; this.attachDiagnostics = new Map();
127
+ // Ownership recorded by this connection: only these are cleaned up in relay mode.
128
+ this.openedTargets = new Set(); this.deployedProjects = new Set(); this.lastCleanup = null;
10
129
  }
11
130
  async connect(signal) {
12
131
  signal?.throwIfAborted(); if (this.closed) throw new Error('Plugin Chrome Lab cerrado.');
132
+ if (this.preempted() && !this.pending.size && !this.attaching.size && !this.reconnecting) this.forgetPreemptedTransport();
13
133
  if (!this.connection) this.connection = this.start();
14
134
  if (!signal) return this.connection;
15
135
  return new Promise((resolve, reject) => {
@@ -19,17 +139,31 @@ export class ChromeLabClient {
19
139
  if (signal.aborted) abort();
20
140
  });
21
141
  }
142
+ // Relay mode: relayd cut the SSH bridge because another attempt or check was waiting for the
143
+ // host (it says so on stderr) and closed this attempt's tabs. That is not an uncertain
144
+ // transport: whatever was pending already failed, so the next operation is new and may open
145
+ // another connection, which waits for its turn in relayd's lease queue. Nothing is replayed.
146
+ preempted() {
147
+ return Boolean(this.failure) && this.failureReason === 'connection_lost' && Boolean(process.env.RELAY_REMOTE_NAMESPACE) && /relay-ssh: LEASE_EXPIRED/.test(this.transportStderr?.() || '');
148
+ }
149
+ forgetPreemptedTransport() {
150
+ for (const record of this.attachDiagnostics.values()) this.retireDiagnostic(record.session_id, 'lease_preempted');
151
+ this.child = undefined; this.connection = undefined; this.failure = undefined; this.failureReason = undefined; this.info = undefined; this.transportStderr = undefined;
152
+ this.attachDiagnostics.clear(); this.sessions.clear(); this.contexts.clear(); this.collections.clear(); this.targetInfo.clear(); this.events = []; this.openedTargets.clear();
153
+ }
22
154
  start() {
155
+ this.#replyConnectionGeneration++;
23
156
  this.connectionId = randomUUID();
24
157
  return new Promise((resolve, reject) => {
25
158
  let ready = false, stderr = '';
159
+ this.transportStderr = () => stderr;
26
160
  const bootstrap = "const r=require('readline').createInterface({input:process.stdin});r.once('line',l=>{const p=JSON.parse(l);eval(p.source)(p.config,r).catch(e=>{console.log(JSON.stringify({fatal:e.message}));process.exit(1)})})";
27
161
  const child = this.child = this.spawnProcess('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'ClearAllForwardings=yes', '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=2', this.config.sshHost, `node -e "${bootstrap}"`], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
28
162
  const timer = setTimeout(() => { fail(new Error('Timeout iniciando Chrome Lab por SSH.')); child.kill(); }, this.config.timeoutMs);
29
- const fail = (error,reason='transport_uncertain') => { clearTimeout(timer); if (this.child !== child) return; for(const record of this.attachDiagnostics.values()) this.retireDiagnostic(record.session_id,reason); this.failure = error; if (!ready) reject(error); for (const entry of this.pending.values()) entry.finish(error); this.pending.clear(); };
163
+ const fail = (error,reason='transport_uncertain') => { clearTimeout(timer); if (this.child !== child) return; this.#replyConnectionGeneration++;this.#replyAttachments.clear();for(const record of this.attachDiagnostics.values()) this.retireDiagnostic(record.session_id,reason); this.failure = error; this.failureReason = reason; if (!ready) reject(error); for (const entry of this.pending.values()) entry.finish(error); this.pending.clear(); };
30
164
  child.on('error', fail); child.stdin.on('error', fail);
31
165
  child.stderr.on('data', chunk => { stderr = (stderr + chunk.toString()).slice(-1500); });
32
- child.on('exit', (code, sig) => fail(new Error(`Chrome Lab desconectado (${code ?? sig}); no se reintentaron efectos pendientes. ${stderr}`),'connection_lost'));
166
+ child.on('exit', (code, sig) => fail(new Error(`Chrome Lab desconectado (${code ?? sig}); no se reintentaron efectos pendientes. ${stderr}${process.env.RELAY_REMOTE_NAMESPACE ? ' Si relayd cedió el host (LEASE_EXPIRED), la siguiente operación reconecta y espera su turno; tus pestañas se cerraron, vuelve a abrirlas.' : ''}`),'connection_lost'));
33
167
  const lines = createInterface({ input: child.stdout });
34
168
  lines.on('line', line => {
35
169
  if (this.child !== child) return;
@@ -39,7 +173,7 @@ export class ChromeLabClient {
39
173
  if (message.fatal) { fail(new Error(message.fatal)); child.kill(); }
40
174
  else if (message.ready) { ready = true; clearTimeout(timer); this.info = message.ready; resolve(message.ready); }
41
175
  else if (message.event) this.observe(message.event);
42
- else { const entry = this.pending.get(message.id); if (entry) { const error = message.error ? new Error(message.error) : null; if(error && entry.action==='cdp' && message.cdp_error?.method===entry.method && Number.isInteger(message.cdp_error.code) && typeof message.cdp_error.message==='string')error.cdpError={method:entry.method,code:message.cdp_error.code,message:message.cdp_error.message.slice(0,1000)};let result=message.result; if(!error && entry.attachDiagnostic) diagnosticEnvelope(result,{method:entry.method,sessionId:entry.sessionId,timeout_ms:entry.attachDiagnostic.timeout_ms}); entry.finish(error,result); } }
176
+ else { const entry = this.pending.get(message.id); if (entry) { const error = message.error ? new Error(message.error) : null; if(error && entry.action==='cdp' && message.cdp_error?.method===entry.method && Number.isInteger(message.cdp_error.code) && typeof message.cdp_error.message==='string')error.cdpError={method:entry.method,code:message.cdp_error.code,message:message.cdp_error.message.slice(0,1000)};let result=message.result; if(!error && entry.attachDiagnostic) diagnosticEnvelope(result,{method:entry.method,sessionId:entry.sessionId,timeout_ms:entry.attachDiagnostic.timeout_ms}); this.#recordNativeReply(message,entry,error,result); entry.finish(error,result); } }
43
177
  } catch (error) { fail(error); child.kill(); }
44
178
  });
45
179
  child.stdin.write(JSON.stringify({ source: `(${remoteBridge.toString()})`, config: this.config }) + '\n');
@@ -47,6 +181,9 @@ export class ChromeLabClient {
47
181
  }
48
182
  observe(event) {
49
183
  const { method, params = {}, sessionId } = event;
184
+ const retiredSession=method==='Target.detachedFromTarget'?params.sessionId:sessionId;
185
+ if(['Target.detachedFromTarget','Inspector.detached'].includes(method)){this.#replySessionGenerations.delete(retiredSession);this.#replyAttachments.delete(retiredSession);}
186
+ else if(['Runtime.executionContextCreated','Runtime.executionContextDestroyed','Runtime.executionContextsCleared'].includes(method)&&this.#replySessionGenerations.has(sessionId))this.#replySessionGenerations.set(sessionId,Object.freeze({}));
50
187
  if (method === 'Target.detachedFromTarget') { this.retireDiagnostic(params.sessionId); for (const [target, session] of this.sessions) if (session === params.sessionId) this.sessions.delete(target); this.contexts.delete(params.sessionId); }
51
188
  if (method === 'Inspector.detached') { this.retireDiagnostic(sessionId); for (const [target, session] of this.sessions) if (session === sessionId) this.sessions.delete(target); this.contexts.delete(sessionId); }
52
189
  if (method === 'Runtime.executionContextCreated') {
@@ -74,18 +211,54 @@ export class ChromeLabClient {
74
211
  if (this.events.length > 200) this.events.shift();
75
212
  }
76
213
  async request(action, params, { signal, expectedBinding, onDispatch, ...extra } = {}) {
214
+ if(['id','action','params','toJSON'].some(key=>Object.hasOwn(extra,key)))throw Object.assign(new Error('Reserved Chrome Lab request option.'),{code:'CHROME_REQUEST_OPTIONS'});
215
+ const fillFrame=this.#fillOwner.getStore();
216
+ // Preserve the private handler signal even when an instrumentation layer
217
+ // adds its original expiry signal or supplies a different request signal.
218
+ if(fillFrame?.signal!==undefined && signal!==fillFrame.signal)signal=AbortSignal.any(signal===undefined?[fillFrame.signal]:[fillFrame.signal,signal]);
77
219
  // Optional original transport ownership, checked again after connect's async boundary.
78
220
  // This protects a pinned caller from known local replacement before the write.
79
221
  const checkBinding=()=>{if(expectedBinding&&(this.closed||this.failure||this.connectionId!==expectedBinding.connectionId||this.info?.browser_endpoint_id!==expectedBinding.browserEndpointId||extra.sessionId!==expectedBinding.sessionId||this.sessions.get(expectedBinding.targetId)!==expectedBinding.sessionId))throw Object.assign(new Error('Original CDP connection/session binding changed before dispatch.'),{code:'CHROME_CDP_BINDING_CHANGED',details:{remote_dispatched:false}});};
80
222
  checkBinding();await this.connect(signal); signal?.throwIfAborted();checkBinding();if (this.failure) throw this.failure;
81
223
  return new Promise((resolve, reject) => {
82
- const id = ++this.sequence;
224
+ const id = ++this.#requestSequence;this.sequence=id;
225
+ if(!Number.isSafeInteger(id))throw new Error('Chrome Lab request sequence exhausted.');
226
+ const requestBudgetMs=this.config.timeoutMs+2000,requestDeadline=performance.now()+requestBudgetMs;
83
227
  const abort = () => finish(new Error('Operación abortada tras despacho; el efecto remoto puede haber ocurrido. Verifica antes de repetir.'));
84
- const timer = setTimeout(() => finish(new Error('Timeout Chrome Lab; efecto remoto desconocido, no se reintentó.')), this.config.timeoutMs + 2000);
85
- const finish = (error, value) => { clearTimeout(timer); signal?.removeEventListener('abort', abort); this.pending.delete(id); error ? reject(error) : resolve(value); };
86
- this.pending.set(id, { finish, action, method: extra.method, sessionId:extra.sessionId, attachDiagnostic:extra.attachDiagnostic }); signal?.addEventListener('abort', abort, { once: true });
228
+ const timer = setTimeout(() => finish(new Error('Timeout Chrome Lab; efecto remoto desconocido, no se reintentó.')), requestBudgetMs);
229
+ const entry = {action, method:extra.method, sessionId:extra.sessionId, attachDiagnostic:extra.attachDiagnostic};
230
+ // The public pending registry remains useful transport bookkeeping. Its
231
+ // entries never own provenance's original signal, deadline or binding.
232
+ const capture={entry,guard:null,state:{settled:false,writeState:'not_sent'}};
233
+ const finish = (error, value) => { if(capture.state.settled)return;capture.state.settled=true;capture.state.failed=Boolean(error);clearTimeout(timer); signal?.removeEventListener('abort', abort); this.pending.delete(id);this.#pendingReplies.delete(id);
234
+ // Open/Preview targets and deployed projects stay owned by this connection.
235
+ if (!error) { if (action === 'open' && typeof value?.targetId === 'string' && value.targetId) this.openedTargets.add(value.targetId); if (action === 'deploy' && typeof value?.project === 'string' && value.project) this.deployedProjects.add(value.project); if (action === 'unload' && typeof params?.project === 'string') this.deployedProjects.delete(params.project); }
236
+ error ? reject(error) : resolve(value); };
237
+ this.#pendingReplies.set(id,capture);
238
+ entry.finish=finish;this.pending.set(id, entry); signal?.addEventListener('abort', abort, { once: true });
87
239
  try { onDispatch?.(); } catch(error) { finish(error); return; }
88
- this.child.stdin.write(JSON.stringify({ id, action, params, ...extra }) + '\n', error => { if (error) finish(error); });
240
+ try {
241
+ signal?.throwIfAborted();if(capture.state.settled)return;checkBinding();
242
+ const envelope=Object.assign(Object.create(null),extra,{id,action,params});
243
+ const wire=JSON.stringify(envelope),sent=JSON.parse(wire);
244
+ if(!sent || Array.isArray(sent) || sent.id!==id || sent.action!==action)throw Object.assign(new Error('Chrome Lab serialized request identity changed.'),{code:'CHROME_REQUEST_OPTIONS'});
245
+ entry.method=sent.method;entry.sessionId=sent.sessionId;
246
+ const binding=this.#replyBinding(sent.sessionId);
247
+ capture.guard=Object.freeze({id,action:sent.action,method:sent.method,sessionId:sent.sessionId,fillOwner:fillFrame?.owner??null,
248
+ connectionId:this.connectionId,browserEndpointId:this.info?.browser_endpoint_id,connectionGeneration:this.#replyConnectionGeneration,
249
+ attachTargetId:sent.method==='Target.attachToTarget'&&typeof sent.params?.targetId==='string'?sent.params.targetId:null,
250
+ detachSessionId:sent.method==='Target.detachFromTarget'?sent.params?.sessionId:null,
251
+ binding:binding?Object.freeze(binding):null,signal,requestDeadline,requestBudgetMs,
252
+ paramsSha256:createHash('sha256').update(JSON.stringify(sent.params ?? {})).digest('hex')});
253
+ this.#recentRequests.set(id,capture);if(this.#recentRequests.size>32)this.#recentRequests.delete(this.#recentRequests.keys().next().value);
254
+ capture.state.writeState='pending';
255
+ this.child.stdin.write(wire + '\n', error => {
256
+ if(error)capture.state.writeState='failed';
257
+ else if(capture.state.writeState==='pending')capture.state.writeState='acknowledged';
258
+ if(error)finish(error);
259
+ });
260
+ }
261
+ catch(error){capture.state.writeState='failed';finish(error);}
89
262
  });
90
263
  }
91
264
  cdp(method, params = {}, { signal, sessionId, expectedBinding, attachDiagnostic, onDispatch } = {}) { return this.request('cdp', params, { signal, method, sessionId, expectedBinding, onDispatch, ...(attachDiagnostic !== undefined ? {attachDiagnostic} : {}) }); }
@@ -112,6 +285,27 @@ export class ChromeLabClient {
112
285
  if (!this.connectionId || !this.info?.browser_endpoint_id || !session || !collection || !Number.isInteger(context.id) || context.id < 1 || typeof context.uniqueId !== 'string' || !context.uniqueId || context.origin !== target.url) return null;
113
286
  return { realm: 'extension_service_worker', target_id: targetId, url: target.url, connection_id: this.connectionId, browser_endpoint_id: this.info.browser_endpoint_id, session_id: session, context_unique_id: context.uniqueId, context_id: context.id, context_epoch: collection.epoch, collection_start: collection.started_at };
114
287
  }
288
+ // A page this connection opened stops being owned once CloseTab closed it (or found it gone).
289
+ forgetTarget(targetId) { this.openedTargets.delete(targetId); }
290
+ // Relay mode: the SSH bridge is what holds relayd's lease on the remote host. Once this
291
+ // connection owns nothing (every own tab closed, every own extension unloaded) and nothing
292
+ // is in flight, close the transport so relayd can free the host after its idle grace; the
293
+ // next operation reconnects lazily. Nothing is replayed because nothing is pending. Local
294
+ // mode keeps the connection as before.
295
+ async releaseIfIdle() {
296
+ if (!process.env.RELAY_REMOTE_NAMESPACE) return { released: false, reason: 'not_relay_mode' };
297
+ const child = this.child;
298
+ if (this.closed || this.reconnecting || this.failure || !child || child.exitCode !== null || child.signalCode !== null) return { released: false, reason: 'no_live_connection' };
299
+ if (this.openedTargets.size || this.deployedProjects.size) return { released: false, reason: 'still_owned', open_targets: this.openedTargets.size, deployed_projects: this.deployedProjects.size };
300
+ if (this.pending.size || this.attaching.size) return { released: false, reason: 'busy' };
301
+ // Detach first: the exit handler ignores a child that is no longer this.child, so the
302
+ // release is not recorded as a transport failure.
303
+ for (const record of this.attachDiagnostics.values()) this.retireDiagnostic(record.session_id, 'connection_released');
304
+ this.child = undefined; this.connection = undefined; this.failure = undefined; this.info = undefined;
305
+ this.attachDiagnostics.clear(); this.sessions.clear(); this.contexts.clear(); this.collections.clear(); this.targetInfo.clear(); this.events = [];
306
+ await new Promise(resolve => { const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(); }, 1500); child.once('exit', () => { clearTimeout(timer); resolve(); }); child.stdin.end(); });
307
+ return { released: true, reason: 'nothing_owned' };
308
+ }
115
309
  // Explicit recovery only. A new connection does not replay any request or resolve its effect.
116
310
  async reconnect(signal) {
117
311
  signal?.throwIfAborted();
@@ -143,14 +337,18 @@ export class ChromeLabClient {
143
337
  const current=()=>!record.retired && this.attachDiagnostics.get(targetId)===record && !this.closed && !this.failure && this.connectionId===binding.connectionId && this.info?.browser_endpoint_id===binding.browserEndpointId && this.sessions.get(targetId)===sessionId;
144
338
  const check=()=>{signal?.throwIfAborted();if(!current())throw Object.assign(new Error('Original attach diagnostic session retired.'),{code:'CHROME_CDP_BINDING_CHANGED'});};
145
339
  const started=performance.now(), budget=Math.min(OPTIONAL_PHASE_MS,this.config.timeoutMs);
340
+ let phaseExhausted=false;
146
341
  for(const domain of record.domains) {
147
342
  check();const remaining=Math.floor(budget-(performance.now()-started));
148
- if(remaining < 1) {domain.completed_at=new Date().toISOString();continue;}
343
+ if(phaseExhausted || remaining < 1) {domain.completed_at=new Date().toISOString();continue;}
149
344
  domain.timeout_ms=remaining;const begin=performance.now();
150
345
  try {
151
346
  const value=await this.cdp(domain.method,domain.method==='Log.enable'?{}:{maxTotalBufferSize:100000,maxResourceBufferSize:10000},{signal,sessionId,expectedBinding:binding,attachDiagnostic:{timeout_ms:remaining},onDispatch:()=>{check();if(performance.now()-started>=budget)throw Object.assign(new Error('Optional phase expired before dispatch.'),{code:'CHROME_DIAGNOSTIC_BUDGET'});domain.command_count=1;domain.started_at=new Date().toISOString();}});
152
347
  check();const result=diagnosticEnvelope(value,{method:domain.method,sessionId,timeout_ms:remaining});
153
348
  Object.assign(domain,{state:result.state,reason:result.reason,remote_dispatched:result.remote_dispatched,bridge_elapsed_ms:result.elapsed_ms,elapsed_ms:performance.now()-begin,completed_at:new Date().toISOString(),...(result.error?{error:result.error}: {})});
349
+ // A wait for the whole remaining phase has timed out. Do not spend a
350
+ // timer-rounding residue on another command in the other process.
351
+ if(result.reason==='command_timeout')phaseExhausted=true;
154
352
  if(result.state==='enabled')domain.enabled_acknowledged_at=domain.completed_at;
155
353
  } catch(error) {
156
354
  if(error.code==='CHROME_DIAGNOSTIC_BUDGET' && domain.command_count===0) {Object.assign(domain,{reason:'phase_budget_exhausted',completed_at:new Date().toISOString(),elapsed_ms:performance.now()-begin});continue;}
@@ -193,9 +391,41 @@ export class ChromeLabClient {
193
391
  if (serialized.length > 24000) return { ok: true, truncated: true, preview: serialized.subarray(0, 24000).toString('utf8') };
194
392
  return { ok: true, value };
195
393
  }
394
+ // Relay mode owns a per-attempt remoteRoot: closing that connection closes only the
395
+ // targets it opened (Open/Preview) and unloads the extensions the bridge reports as
396
+ // own (same ownExtensions rule). Foreign targets/extensions are never selected.
397
+ async relayCleanup() {
398
+ const namespace = process.env.RELAY_REMOTE_NAMESPACE;
399
+ if (!namespace) return { mode: 'local', cleaned: false, reason: 'not_relay_mode' };
400
+ if (this.reconnecting) return { mode: 'relay', namespace, cleaned: false, reason: 'reconnect' };
401
+ if (this.failure || !this.child || this.child.exitCode !== null || this.child.signalCode !== null) return { mode: 'relay', namespace, cleaned: false, reason: 'no_live_connection' };
402
+ if (!this.openedTargets.size && !this.deployedProjects.size) return { mode: 'relay', namespace, cleaned: false, reason: 'nothing_owned' };
403
+ const closed_targets = [], already_closed = [], unloaded = [], errors = [];
404
+ for (const targetId of [...this.openedTargets]) {
405
+ try { await this.cdp('Target.closeTarget', { targetId }); closed_targets.push(targetId); }
406
+ catch (error) { if (/No target|not found/i.test(error.message)) already_closed.push(targetId); else errors.push({ target_id: targetId, error: error.message }); }
407
+ }
408
+ let other_extensions_untouched = null;
409
+ try {
410
+ const status = await this.request('status', {});
411
+ other_extensions_untouched = status.other_extensions_count ?? null;
412
+ for (const extension of status.extensions) {
413
+ try { const result = await this.request('unload', { project: extension.project }); unloaded.push({ project: extension.project, extension_id: result.extension_id ?? extension.id }); }
414
+ catch (error) { errors.push({ project: extension.project, error: error.message }); }
415
+ }
416
+ } catch (error) { errors.push({ status: error.message }); }
417
+ return { mode: 'relay', namespace, cleaned: true, closed_targets, already_closed, unloaded, other_extensions_untouched, errors,
418
+ note: 'Cierre en modo relay: sólo los targets abiertos por esta conexión y las extensiones propias bajo su remoteRoot.' };
419
+ }
196
420
  async close() {
197
- if (this.closed) return; this.closed = true; for(const record of this.attachDiagnostics.values())this.retireDiagnostic(record.session_id);
198
- if (!this.child || this.child.exitCode !== null || this.child.signalCode !== null) return;
421
+ if (this.closed) return this.lastCleanup;
422
+ // Cleanup happens before the transport is marked closed: it is the only chance to
423
+ // ask the bridge which extensions are still owned under remoteRoot. A cleanup
424
+ // failure is reported in the result, never silently dropped and never fatal.
425
+ const cleanup = await this.relayCleanup().catch(error => ({ mode: process.env.RELAY_REMOTE_NAMESPACE ? 'relay' : 'local', cleaned: false, errors: [{ cleanup: error.message }] }));
426
+ this.lastCleanup = cleanup; this.closed = true;this.#replyConnectionGeneration++;this.#replySessionGenerations.clear();this.#replyAttachments.clear();for(const record of this.attachDiagnostics.values())this.retireDiagnostic(record.session_id);
427
+ if (!this.child || this.child.exitCode !== null || this.child.signalCode !== null) return cleanup;
199
428
  await new Promise(resolve => { const timer = setTimeout(() => { this.child.kill('SIGKILL'); resolve(); }, 1500); this.child.once('exit', () => { clearTimeout(timer); resolve(); }); this.child.stdin.end(); });
429
+ return cleanup;
200
430
  }
201
431
  }