@milpa/comal 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +19 -1
  2. package/backend/contracts/remote-commands-v1.json +12 -0
  3. package/backend/contracts/remote-workspace-v1.schema.json +287 -0
  4. package/governance/chrome-task-v10.json +323 -0
  5. package/governance/chrome-task-v11.json +355 -0
  6. package/governance/chrome-task-v12.json +387 -0
  7. package/governance/chrome-task-v13.json +419 -0
  8. package/governance/chrome-task-v3.json +99 -0
  9. package/governance/chrome-task-v4.json +131 -0
  10. package/governance/chrome-task-v5.json +163 -0
  11. package/governance/chrome-task-v6.json +195 -0
  12. package/governance/chrome-task-v7.json +227 -0
  13. package/governance/chrome-task-v8.json +259 -0
  14. package/governance/chrome-task-v9.json +291 -0
  15. package/governance/source-task-v1.json +1 -0
  16. package/governance/source-task-v2.json +1 -0
  17. package/package.json +15 -1
  18. package/src/agent-recovery-proof.mjs +12 -1
  19. package/src/agent.mjs +94 -30
  20. package/src/audit.mjs +21 -6
  21. package/src/auto-guard.mjs +48 -1
  22. package/src/chrome-candidate-recovery-proof.mjs +47 -0
  23. package/src/chrome-recovery-proof.mjs +6 -2
  24. package/src/chrome-source-syntax.mjs +93 -0
  25. package/src/cli.mjs +161 -56
  26. package/src/codex-provider.mjs +3 -2
  27. package/src/connection-settings.mjs +204 -0
  28. package/src/context-pressure.mjs +2 -1
  29. package/src/durable-agent-runs.mjs +5 -11
  30. package/src/event-channels.mjs +8 -0
  31. package/src/file-recovery-proof.mjs +11 -3
  32. package/src/file-recovery.mjs +24 -0
  33. package/src/gemini-provider.mjs +6 -1
  34. package/src/git-agent-revision.mjs +12 -0
  35. package/src/git-agent-workspaces.mjs +143 -0
  36. package/src/git-integration-lineage.mjs +20 -0
  37. package/src/git-integration-operations.mjs +200 -0
  38. package/src/git-integration-recovery-proof.mjs +70 -0
  39. package/src/git-page-cli-bridge.mjs +61 -0
  40. package/src/git-page-evidence.mjs +30 -0
  41. package/src/git-page-identity.mjs +10 -0
  42. package/src/git-page-proof.mjs +96 -0
  43. package/src/git-page-settlement.mjs +21 -0
  44. package/src/git-page-verification.mjs +134 -0
  45. package/src/git-plumbing.mjs +212 -0
  46. package/src/git-process-worker.mjs +50 -0
  47. package/src/git-source.mjs +110 -0
  48. package/src/git-workspaces.mjs +519 -0
  49. package/src/goals.mjs +13 -7
  50. package/src/house.mjs +246 -20
  51. package/src/i18n/cli-es.mjs +4 -0
  52. package/src/i18n/es.mjs +35 -0
  53. package/src/judges.mjs +27 -11
  54. package/src/mcp-runtime.mjs +15 -3
  55. package/src/model-controls.mjs +11 -1
  56. package/src/model-deadline.mjs +4 -3
  57. package/src/native-refusal.mjs +38 -0
  58. package/src/onboarding.mjs +88 -24
  59. package/src/openai-provider.mjs +14 -7
  60. package/src/operations.mjs +9 -2
  61. package/src/output-recovery.mjs +48 -0
  62. package/src/peer-tools.mjs +18 -8
  63. package/src/permission-controls.mjs +232 -0
  64. package/src/permission-rules.mjs +312 -0
  65. package/src/permissions.mjs +113 -30
  66. package/src/plugin-catalog.mjs +84 -0
  67. package/src/plugin-config.mjs +48 -6
  68. package/src/plugin-controls.mjs +332 -0
  69. package/src/plugin-notices.mjs +88 -0
  70. package/src/plugin-trust.mjs +104 -0
  71. package/src/plugins/chrome-lab/bridge.mjs +38 -12
  72. package/src/plugins/chrome-lab/client.mjs +103 -25
  73. package/src/plugins/chrome-lab/diagnostics.mjs +19 -0
  74. package/src/plugins/chrome-lab/index.mjs +116 -26
  75. package/src/plugins/chrome-lab/project.mjs +30 -2
  76. package/src/plugins/chrome-lab/scoped-input.mjs +135 -0
  77. package/src/plugins/chrome-lab/worker-diagnostics.mjs +51 -0
  78. package/src/plugins/open-relay/actions.mjs +205 -0
  79. package/src/plugins/open-relay/bound.mjs +47 -0
  80. package/src/plugins/open-relay/client.mjs +158 -0
  81. package/src/plugins/open-relay/dossier.mjs +119 -0
  82. package/src/plugins/open-relay/index.mjs +153 -0
  83. package/src/plugins/open-relay/lookup.mjs +45 -0
  84. package/src/plugins/open-relay/notices.mjs +79 -0
  85. package/src/plugins/open-relay/watch.mjs +127 -0
  86. package/src/plugins.mjs +72 -29
  87. package/src/private-fs.mjs +40 -0
  88. package/src/process-lock.mjs +24 -13
  89. package/src/provider-auth.mjs +14 -3
  90. package/src/provider.mjs +11 -2
  91. package/src/read-only-check.mjs +7 -3
  92. package/src/remote-control.mjs +29 -4
  93. package/src/remote-workspace.mjs +50 -0
  94. package/src/scope-ceiling.mjs +56 -0
  95. package/src/scoped-policy.mjs +39 -0
  96. package/src/secret-login.mjs +68 -0
  97. package/src/session-bus.mjs +41 -7
  98. package/src/session-projections-candidate.mjs +119 -0
  99. package/src/session-projections.mjs +119 -0
  100. package/src/session-read-cache.mjs +23 -3
  101. package/src/sessions.mjs +35 -11
  102. package/src/source-editor-role.mjs +13 -0
  103. package/src/source-ownership.mjs +73 -0
  104. package/src/source-proposal-proof.mjs +55 -0
  105. package/src/source-proposals.mjs +118 -0
  106. package/src/source-requests.mjs +148 -0
  107. package/src/source-task-proof.mjs +24 -0
  108. package/src/source-task-revision-proof.mjs +44 -0
  109. package/src/subagents.mjs +187 -87
  110. package/src/tool-packs.mjs +5 -5
  111. package/src/tools.mjs +82 -15
  112. package/src/trusted-context.mjs +5 -1
  113. package/src/tui/connection-transition.mjs +1 -1
  114. package/src/tui/controller.mjs +8 -4
  115. package/src/tui/discovery.mjs +24 -12
  116. package/src/tui/inbound-blocks.mjs +55 -0
  117. package/src/tui/present.mjs +504 -0
  118. package/src/tui/screen.mjs +49 -13
  119. package/src/windows-private.ps1 +61 -0
  120. package/src/work-assertions.mjs +6 -2
  121. package/src/work-plan.mjs +34 -8
  122. package/src/work-state.mjs +15 -9
  123. package/src/workflows/chrome-page-cases.mjs +156 -0
  124. package/src/workflows/chrome-page-checkpoint-reader.mjs +10 -0
  125. package/src/workflows/chrome-page-diagnostics.mjs +113 -0
  126. package/src/workflows/chrome-page-law.mjs +48 -0
  127. package/src/workflows/chrome-page-recovery-host-v13.mjs +14 -0
  128. package/src/workflows/chrome-page-recovery-v13.mjs +104 -0
  129. package/src/workflows/chrome-page-task.mjs +429 -0
  130. package/src/workflows/chrome-page-verifier.mjs +123 -0
  131. package/src/workflows/chrome-source.mjs +73 -0
  132. package/src/workflows/chrome-supplied-inputs.mjs +43 -0
  133. package/src/workflows/chrome-task-adoption.mjs +122 -0
  134. package/src/workflows/chrome-task.mjs +379 -74
  135. package/src/workflows/source-task-v1.mjs +124 -0
  136. package/src/workflows/source-task-v2.mjs +227 -0
  137. package/src/workflows/source-task.mjs +13 -0
package/src/plugins.mjs CHANGED
@@ -2,7 +2,16 @@ import path from 'node:path';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { Operation, immutable, toolEffects } from './operations.mjs';
4
4
  import { defaultStateDir } from './compat.mjs';
5
- import { readPluginConfig, importPluginEntry } from './plugin-config.mjs';
5
+ import { readPluginConfig, importPluginEntry, inspectPluginModule } from './plugin-config.mjs';
6
+ import { normalizeNotice } from './plugin-notices.mjs';
7
+ import { t, registerMessages } from './i18n/index.mjs';
8
+
9
+ registerMessages({
10
+ 'Plugin arguments must be a JSON object.': 'Los argumentos de plugin deben ser un objeto JSON.',
11
+ 'Use /plugins [list|enable ID|disable ID|reload [ID]|run ID OPERATION {JSON}].': 'Usa /plugins [list|enable ID|disable ID|reload [ID]|run ID OPERACIÓN {JSON}].',
12
+ });
13
+ // Los errores del runtime pueden quedar en el estado que lee el modelo (PluginStatus):
14
+ // inglés fijo. Sólo los errores de uso de /plugins pasan por t().
6
15
 
7
16
  const idPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
8
17
  const jsonCopy = value => JSON.parse(JSON.stringify(value));
@@ -14,8 +23,8 @@ export function parsePluginCommand(text = '') {
14
23
  if (action === 'reload' && !rest) return { name: 'PluginReload', input: {} };
15
24
  if (['enable', 'disable', 'reload'].includes(action) && idPattern.test(rest)) return { name: { enable: 'PluginEnable', disable: 'PluginDisable', reload: 'PluginReload' }[action], input: { id: rest } };
16
25
  const run = /^(\S+)\s+(\S+)(?:\s+([\s\S]+))?$/.exec(rest);
17
- if (action === 'run' && run) { const input = JSON.parse(run[3] || '{}'); if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error('Los argumentos de plugin deben ser un objeto JSON.'); return { name: 'PluginRun', input: { id: run[1], operation: run[2], arguments: input } }; }
18
- throw new Error('Usa /plugins [list|enable ID|disable ID|reload [ID]|run ID OPERACIÓN {JSON}].');
26
+ if (action === 'run' && run) { const input = JSON.parse(run[3] || '{}'); if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error(t('Plugin arguments must be a JSON object.')); return { name: 'PluginRun', input: { id: run[1], operation: run[2], arguments: input } }; }
27
+ throw new Error(t('Use /plugins [list|enable ID|disable ID|reload [ID]|run ID OPERATION {JSON}].'));
19
28
  }
20
29
 
21
30
  export class PluginRuntime {
@@ -24,11 +33,13 @@ export class PluginRuntime {
24
33
  #services = new Map();
25
34
  #queue = Promise.resolve();
26
35
  #closing = false;
27
- constructor({ cwd, house, services = {}, configPath, onEvent = () => {} }) {
28
- Object.assign(this, { cwd, house, onEvent });
36
+ // notify(notice): el host encola avisos de plugins (plugin-notices.mjs); sin él, ctx.notify responde unavailable.
37
+ constructor({ cwd, house, services = {}, configPath, onEvent = () => {}, notify }) {
38
+ Object.assign(this, { cwd, house, onEvent, notify });
29
39
  this.file = path.resolve(configPath || path.join(defaultStateDir(cwd), 'plugins.json'));
30
40
  for (const [name, value] of Object.entries(services)) this.#services.set(name, { owner: 'host', value });
31
41
  }
42
+ async #notify(notice) { if (typeof this.notify !== 'function') return { status: 'unavailable' }; try { return await this.notify(notice); } catch (error) { return { status: 'failed', error: String(error.message).slice(0, 200) }; } }
32
43
  #exclusive(task) { const next = this.#queue.catch(() => {}).then(task); this.#queue = next; return next; }
33
44
  async #event(event) { try { await this.onEvent({ type: 'plugin_status', ...event }); } catch {} }
34
45
  #definition(definition) {
@@ -41,7 +52,7 @@ export class PluginRuntime {
41
52
  return immutable({ id: definition.id, version: definition.version, requires: names(definition.requires || []), provides: names(definition.provides || []), tool_loading: definition.tool_loading || 'eager', hook_timeout_ms: definition.hook_timeout_ms ?? 1000, discovery: discovery ? structuredClone(discovery) : null, config_schema: definition.config_schema ? structuredClone(definition.config_schema) : null, activate: definition.activate });
42
53
  }
43
54
  register(definition, { config = {}, builtin = false, module, fingerprint } = {}) {
44
- if (this.#closing) throw new Error('Runtime de plugins cerrado.');
55
+ if (this.#closing) throw new Error('Plugin runtime closed.');
45
56
  const manifest = this.#definition(definition);
46
57
  if (this.#known.has(manifest.id)) throw new Error(`Plugin duplicado: ${manifest.id}`);
47
58
  this.#known.set(manifest.id, { id: manifest.id, definition: manifest, config: immutable(jsonCopy(config)), builtin, module, fingerprint, state: 'registered', disabled: false });
@@ -49,7 +60,7 @@ export class PluginRuntime {
49
60
  status() {
50
61
  return { config_path: this.file, services: [...this.#services.keys()], plugins: [...this.#known.values()].map(item => {
51
62
  const scope = this.#instances.get(item.id);
52
- return { id: item.id, version: item.definition?.version, builtin: item.builtin || false, state: item.state, module: item.module || item.entry?.module, fingerprint: item.fingerprint, tool_loading: item.definition?.tool_loading || 'eager', operations: scope?.active ? [...scope.operations.keys()] : [], requires: item.definition?.requires || [], provides: item.definition?.provides || [], active_calls: scope?.leases || 0, ...(item.error ? { error: item.error } : {}) };
63
+ return { id: item.id, version: item.definition?.version, builtin: item.builtin || false, state: item.state, module: item.module || item.entry?.module, fingerprint: item.fingerprint, ...(item.code_fingerprint ? { code_fingerprint: item.code_fingerprint } : {}), ...(item.trust ? { trust: item.trust } : {}), tool_loading: item.definition?.tool_loading || 'eager', operations: scope?.active ? [...scope.operations.keys()] : [], requires: item.definition?.requires || [], provides: item.definition?.provides || [], active_calls: scope?.leases || 0, ...(item.error ? { error: item.error } : {}) };
53
64
  }) };
54
65
  }
55
66
  async discover({ missing = true } = {}) {
@@ -63,49 +74,66 @@ export class PluginRuntime {
63
74
  }
64
75
  this.desired = source; return source;
65
76
  }
66
- async initialize({ trust = false, signal } = {}) {
77
+ // trusted(entry, inspection) is the host's remembered human trust. It reads code, never imports it.
78
+ async initialize({ trust = false, trusted, signal } = {}) {
67
79
  await this.discover();
68
80
  if (trust) await this.load(undefined, { signal });
81
+ else if (trusted) for (const entry of this.desired.entries.filter(entry => entry.enabled)) {
82
+ const item = this.#known.get(entry.id); if (!item || item.builtin || this.#instances.get(entry.id)?.active) continue;
83
+ let verdict;
84
+ try { verdict = await trusted(entry, await inspectPluginModule(entry.module)); }
85
+ catch (error) { verdict = { status: 'unavailable', reasons: [error.message] }; }
86
+ item.trust = verdict;
87
+ if (verdict?.status !== 'trusted') { if (verdict?.status === 'stale') await this.#event({ id: entry.id, state: 'pending_trust', trust: verdict.status, error: `Trust no longer current (${verdict.reasons.join(', ')}). Use /plugins enable ${entry.id} to review it.` }); continue; }
88
+ try { await this.load(entry.id, { signal, expect: verdict.expect }); }
89
+ catch (error) { if (error.code === 'PLUGIN_TRUST_STALE') { item.state = 'pending_trust'; item.error = null; item.trust = { status: 'stale', reasons: ['code'] }; await this.#event({ id: entry.id, state: 'pending_trust', trust: 'stale', error: error.message }); } else if (signal?.aborted) throw error; }
90
+ }
69
91
  return this.status();
70
92
  }
71
- async #import(item, signal) {
93
+ /** Inspect configured local code for a human trust decision. Never imports it. */
94
+ async inspect(id) {
95
+ await this.discover(); const entry = this.desired.entries.find(entry => entry.id === id);
96
+ if (!entry) throw new Error(`Local plugin not configured: ${id}`);
97
+ return { entry, code: await inspectPluginModule(entry.module) };
98
+ }
99
+ async #import(item, signal, expect) {
72
100
  signal?.throwIfAborted();
73
101
  if (!item.entry) return item;
74
- const imported = await importPluginEntry({ ...item.entry, enabled: true }); signal?.throwIfAborted();
75
- return { ...item, definition: this.#definition(imported.definition), config: immutable(jsonCopy(item.entry.config)), module: imported.module, fingerprint: imported.fingerprint };
102
+ const imported = await importPluginEntry({ ...item.entry, enabled: true }, { expect }); signal?.throwIfAborted();
103
+ return { ...item, definition: this.#definition(imported.definition), config: immutable(jsonCopy(item.entry.config)), module: imported.module, fingerprint: imported.fingerprint, code_fingerprint: imported.code_fingerprint, trust: expect ? { status: 'trusted' } : item.trust };
76
104
  }
77
- async load(id, { signal } = {}) {
105
+ async load(id, { signal, expect } = {}) {
78
106
  return this.#exclusive(async () => {
79
107
  this.#open(); const source = await this.discover();
80
108
  const wanted = new Set(this.desired.entries.filter(entry => entry.enabled).map(entry => entry.id));
81
109
  const entries = id ? [this.desired.entries.some(entry => entry.id === id) ? this.#known.get(id) : undefined] : this.desired.entries.filter(entry => entry.enabled).map(entry => this.#known.get(entry.id));
82
- if (entries.some(item => !item || item.builtin)) throw new Error('Plugin local no configurado o reservado.');
110
+ if (entries.some(item => !item || item.builtin)) throw new Error('Local plugin not configured or reserved.');
83
111
  const retiring = id ? [] : [...this.#instances.values()].filter(scope => scope.active && scope.item.entry && !wanted.has(scope.id));
84
112
  const changing = new Set([...entries.map(item => item.id), ...retiring.map(scope => scope.id)]);
85
- for (const scope of this.#instances.values()) if (scope.active && !changing.has(scope.id) && [...scope.requiredOwners].some(owner => changing.has(owner))) throw new Error(`Plugin ${scope.id} depende de un proveedor que cambiará. Desactívalo primero.`);
113
+ for (const scope of this.#instances.values()) if (scope.active && !changing.has(scope.id) && [...scope.requiredOwners].some(owner => changing.has(owner))) throw new Error(`Plugin ${scope.id} depends on a provider that will change. Disable it first.`);
86
114
  const candidates = [];
87
- for (const item of entries) { if (id) this.#dependents(item.id); candidates.push(await this.#import(item, signal)); }
88
- await this.#activate(candidates, signal, retiring, async () => { if ((await readPluginConfig(this.file, { missing: true })).hash !== source.hash) throw new Error('La configuración de plugins cambió durante la activación; no se publicó.'); }); return this.status();
115
+ for (const item of entries) { if (id) this.#dependents(item.id); candidates.push(await this.#import(item, signal, id ? expect : undefined)); }
116
+ await this.#activate(candidates, signal, retiring, async () => { if ((await readPluginConfig(this.file, { missing: true })).hash !== source.hash) throw new Error('The plugin configuration changed during activation; nothing was published.'); }); return this.status();
89
117
  });
90
118
  }
91
- #open() { if (this.#closing) throw new Error('Runtime de plugins cerrado.'); }
119
+ #open() { if (this.#closing) throw new Error('Plugin runtime closed.'); }
92
120
  #dependents(id) {
93
121
  const dependents = [...this.#instances.values()].filter(scope => scope.active && scope.id !== id && scope.requiredOwners.has(id));
94
- if (dependents.length) throw new Error(`Plugin ${id} tiene consumidores activos: ${dependents.map(scope => scope.id).join(', ')}. Desactívalos primero.`);
122
+ if (dependents.length) throw new Error(`Plugin ${id} has active consumers: ${dependents.map(scope => scope.id).join(', ')}. Disable them first.`);
95
123
  }
96
- enable(id, { signal } = {}) {
124
+ enable(id, { signal, expect } = {}) {
97
125
  return this.#exclusive(async () => {
98
- this.#open(); const item = this.#known.get(id); if (!item) throw new Error(`Plugin desconocido: ${id}`);
126
+ this.#open(); const item = this.#known.get(id); if (!item) throw new Error(`Unknown plugin: ${id}`);
99
127
  if (this.#instances.get(id)?.active) return this.status();
100
128
  const source = item.entry ? await this.discover() : null;
101
- if (source && !source.entries.some(entry => entry.id === id)) throw new Error(`Plugin ya no configurado: ${id}`);
102
- const candidate = item.entry ? await this.#import(item, signal) : { ...item, disabled: false };
103
- await this.#activate([candidate], signal, [], source ? async () => { if ((await readPluginConfig(this.file, { missing: true })).hash !== source.hash) throw new Error('La configuración de plugins cambió durante la activación; no se publicó.'); } : undefined); return this.status();
129
+ if (source && !source.entries.some(entry => entry.id === id)) throw new Error(`Plugin no longer configured: ${id}`);
130
+ const candidate = item.entry ? await this.#import(item, signal, expect) : { ...item, disabled: false };
131
+ await this.#activate([candidate], signal, [], source ? async () => { if ((await readPluginConfig(this.file, { missing: true })).hash !== source.hash) throw new Error('The plugin configuration changed during activation; nothing was published.'); } : undefined); return this.status();
104
132
  });
105
133
  }
106
134
  reload(id, { signal } = {}) {
107
135
  return this.#exclusive(async () => {
108
- this.#open(); const item = this.#known.get(id); if (!item) throw new Error(`Plugin desconocido: ${id}`);
136
+ this.#open(); const item = this.#known.get(id); if (!item) throw new Error(`Unknown plugin: ${id}`);
109
137
  this.#dependents(id); await this.#activate([await this.#import(item, signal)], signal); return this.status();
110
138
  });
111
139
  }
@@ -138,7 +166,7 @@ export class PluginRuntime {
138
166
  candidates.forEach(visit); return ordered;
139
167
  }
140
168
  async #stage(item, available, signal) {
141
- const scope = { id: item.id, item, generation: randomUUID(), state: 'loading', active: false, retired: false, leases: 0, waiters: [], operations: new Map(), services: new Map(), listeners: [], guards: [], removers: [], disposers: [], controller: new AbortController(), requiredOwners: new Set() };
169
+ const scope = { id: item.id, item, generation: randomUUID(), state: 'loading', active: false, retired: false, leases: 0, waiters: [], operations: new Map(), services: new Map(), listeners: [], guards: [], removers: [], disposers: [], notices: [], controller: new AbortController(), requiredOwners: new Set() };
142
170
  const abortActivation = () => scope.controller.abort(signal.reason);
143
171
  if (signal?.aborted) abortActivation(); else signal?.addEventListener('abort', abortActivation, { once: true });
144
172
  const alive = () => { if (scope.retired || !['loading', 'active'].includes(scope.state)) throw new Error('Scope de plugin retirado.'); };
@@ -146,15 +174,22 @@ export class PluginRuntime {
146
174
  const lease = async callback => { if (!scope.active || scope.retired) throw new Error('El plugin fue retirado; no se ejecutó.'); scope.leases++; try { return await callback(); } finally { if (!--scope.leases) scope.waiters.splice(0).forEach(resolve => resolve()); } };
147
175
  const register = (definition, human = false) => {
148
176
  staging();
149
- for (const key of Object.keys(definition)) if (!['name', 'description', 'input_schema', 'output_schema', 'effects', 'run', ...(item.builtin ? ['permission'] : [])].includes(key)) throw new Error(`Contrato reservado al host: ${key}`);
177
+ for (const key of Object.keys(definition)) if (!['name', 'description', 'input_schema', 'output_schema', 'effects', 'run', 'confirm', 'confirm_summary', ...(item.builtin ? ['permission'] : [])].includes(key)) throw new Error(`Contrato reservado al host: ${key}`);
178
+ // confirm:'always' sólo puede subir el techo (pedir siempre al humano), nunca bajarlo.
179
+ if (Object.hasOwn(definition, 'confirm') && definition.confirm !== 'always') throw new Error("confirm de plugin sólo admite 'always'.");
180
+ if (Object.hasOwn(definition, 'confirm_summary') && typeof definition.confirm_summary !== 'function') throw new Error('confirm_summary de plugin debe ser una función.');
150
181
  if (Object.hasOwn(definition, 'permission') && (typeof definition.permission !== 'string' || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(definition.permission))) throw new Error('Un builtin requiere un permiso nominal válido; no puede omitir el juicio.');
151
182
  if (!/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(definition.name) || typeof definition.run !== 'function') throw new Error('Nombre/handler de operación de plugin inválido.');
152
183
  const name = item.builtin ? definition.name : `plugin__${item.id}__${definition.name}`;
153
184
  if (name.length > 64 || scope.operations.has(name)) throw new Error(`Operación duplicada o nombre demasiado largo: ${name}`);
154
- const handler = definition.run;
185
+ const handler = definition.run, summarize = definition.confirm_summary;
155
186
  scope.operations.set(name, new Operation({ name, description: definition.description, input_schema: definition.input_schema, output_schema: definition.output_schema, revision: scope.generation, origin: { plugin: item.id, version: item.definition.version, generation: scope.generation, builtin: item.builtin || false, module_sha256: item.fingerprint || null, ...(!human && item.definition.tool_loading === 'deferred' ? { tool_loading: 'deferred', discovery: item.definition.discovery } : {}) }, effects: definition.effects || (item.builtin ? toolEffects(name) : toolEffects('__unknown_plugin')), audience: human ? 'human' : 'any', surfaces: human ? ['cli', 'tui'] : ['cli', 'tui', 'agent'], permission: item.builtin ? definition.permission ?? name : name, steps: false,
187
+ ...(definition.confirm ? { confirm: definition.confirm } : {}),
188
+ // El resumen corre con el mismo lease y la misma señal que la operación: si el plugin se retira, no se muestra.
189
+ ...(summarize ? { confirm_summary: (input, options = {}) => lease(() => summarize(immutable(jsonCopy(input)), Object.freeze({ signal: options.signal ? AbortSignal.any([options.signal, scope.controller.signal]) : scope.controller.signal }))) } : {}),
156
190
  run: (input, options) => lease(() => handler(immutable(jsonCopy(input)), Object.freeze({ signal: options.signal ? AbortSignal.any([options.signal, scope.controller.signal]) : scope.controller.signal,
157
- ...(item.builtin ? { invocation_id: options.invocation_id, sessionId: options.sessionId, actor: options.actor ? immutable(jsonCopy(options.actor)) : null } : {}) }))) }));
191
+ ...(item.builtin ? { invocation_id: options.invocation_id, sessionId: options.sessionId, actor: options.actor ? immutable(jsonCopy(options.actor)) : null } : {}),
192
+ ...(item.builtin&&item.id==='delegation'&&['Agent','AgentBatch','AgentContinue','AgentResume'].includes(name)&&typeof options.revalidate==='function'?{revalidate:async request=>{alive();scope.controller.signal.throwIfAborted();return options.revalidate(request);}}:{}) }))) }));
158
193
  return name;
159
194
  };
160
195
  const context = Object.freeze({
@@ -168,6 +203,12 @@ export class PluginRuntime {
168
203
  const watch = { event, listener }; scope.listeners.push(watch); if (scope.active) this.#watch(scope, watch, lease);
169
204
  },
170
205
  guard: check => { staging(); if (typeof check !== 'function') throw new Error('Guarda requiere función.'); scope.guards.push(check); },
206
+ // Aviso a la conversación: información externa, nunca intención humana ni permiso.
207
+ notify: async input => {
208
+ alive(); const notice = normalizeNotice(input, { plugin: item.id, generation: scope.generation });
209
+ if (!scope.active) { if (scope.notices.length >= 16) return { status: 'full' }; return new Promise(resolve => scope.notices.push({ notice, resolve })); }
210
+ return this.#notify(notice);
211
+ },
171
212
  });
172
213
  scope.lease = lease;
173
214
  try {
@@ -220,6 +261,7 @@ export class PluginRuntime {
220
261
  this.#known.set(scope.id, scope.item); this.#instances.set(scope.id, scope);
221
262
  for (const watch of scope.listeners) this.#watch(scope, watch, scope.lease);
222
263
  for (const check of scope.guards) scope.removers.push(this.house.guard(`${scope.id}:${scope.generation}`, view => scope.active && scope.operations.has(view.operation) ? check(view) : undefined));
264
+ for (const { notice, resolve } of scope.notices.splice(0)) this.#notify(notice).then(resolve);
223
265
  }
224
266
  committed = true;
225
267
  const cleanupTasks = previous.map(old => this.#retire(old));
@@ -237,6 +279,7 @@ export class PluginRuntime {
237
279
  }
238
280
  async #retire(scope) {
239
281
  if (scope.retirement) return scope.retirement; scope.retired = true; scope.active = false; scope.state = 'unloading'; scope.controller.abort(new Error('Plugin retirado.'));
282
+ for (const { resolve } of scope.notices.splice(0)) resolve({ status: 'retired' });
240
283
  scope.removers.splice(0).forEach(remove => remove());
241
284
  scope.retirement = this.#cleanup(scope); return scope.retirement;
242
285
  }
@@ -249,7 +292,7 @@ export class PluginRuntime {
249
292
  }
250
293
  disable(id) {
251
294
  return this.#exclusive(async () => {
252
- this.#open(); this.#dependents(id); const item = this.#known.get(id); if (!item) throw new Error(`Plugin desconocido: ${id}`);
295
+ this.#open(); this.#dependents(id); const item = this.#known.get(id); if (!item) throw new Error(`Unknown plugin: ${id}`);
253
296
  const scope = this.#instances.get(id); item.disabled = true; item.state = 'disabled';
254
297
  if (scope?.active) {
255
298
  this.house.registry.replace([...scope.operations.keys()], []);
@@ -0,0 +1,40 @@
1
+ import {mkdir,lstat,readFile,realpath} from 'node:fs/promises';
2
+ import {spawn} from 'node:child_process';
3
+ import {createInterface} from 'node:readline';
4
+ import path from 'node:path';
5
+
6
+ const fail=message=>Object.assign(new Error(message),{code:'PRIVATE_STATE_UNSAFE'});
7
+ let broker,starting,sequence=0;
8
+ async function windowsBroker(){
9
+ if(broker)return broker;if(starting)return starting;
10
+ starting=(async()=>{
11
+ const script=await readFile(new URL('./windows-private.ps1',import.meta.url),'utf8'),program=path.join(process.env.SystemRoot||'C:\\Windows','System32','WindowsPowerShell','v1.0','powershell.exe');
12
+ const child=spawn(program,['-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-EncodedCommand',Buffer.from(script,'utf16le').toString('base64')],{stdio:['pipe','pipe','pipe'],windowsHide:true});
13
+ const pending=new Map(),lines=createInterface({input:child.stdout});let diagnostic='';
14
+ const references=active=>{const method=active?'ref':'unref';child[method]();for(const stream of[child.stdin,child.stdout,child.stderr])stream[method]?.();};
15
+ child.stderr.on('data',data=>{if(diagnostic.length<2000)diagnostic+=data;});child.stdin.on('error',()=>{});
16
+ const stopped=()=>{for(const job of pending.values()){clearTimeout(job.timer);job.reject(fail('Windows private-state inspection stopped. '+diagnostic.slice(0,300)));}pending.clear();if(broker?.child===child)broker=undefined;};
17
+ child.on('error',stopped);child.on('close',stopped);
18
+ lines.on('line',line=>{let result;try{result=JSON.parse(line);}catch{stopped();child.kill();return;}const job=pending.get(result.id);if(!job)return;clearTimeout(job.timer);pending.delete(result.id);if(!pending.size)references(false);result.ok?job.resolve():job.reject(fail(result.error||'Windows private-state ACL does not verify.'));});
19
+ const result={child,request:(action,file)=>new Promise((resolve,reject)=>{
20
+ const id=++sequence;references(true);const timer=setTimeout(()=>{pending.delete(id);reject(fail('Windows ACL inspection exceeded its deadline.'));if(!pending.size)references(false);},15000);pending.set(id,{resolve,reject,timer});
21
+ child.stdin.write(JSON.stringify({id,action,path:path.resolve(file)})+'\n',error=>{if(error){clearTimeout(timer);pending.delete(id);reject(fail('Windows ACL inspection could not be sent.'));if(!pending.size)references(false);}});
22
+ })};references(false);broker=result;return result;
23
+ })();try{return await starting;}finally{starting=undefined;}
24
+ }
25
+ export async function ensurePrivateDirectory(directory){
26
+ if(process.platform==='win32')await(await windowsBroker()).request('ensure',directory);
27
+ else await mkdir(directory,{recursive:true,mode:0o700});
28
+ }
29
+ export async function assertPrivateDirectory(directory,info){
30
+ info ||= await lstat(directory);
31
+ if(!info.isDirectory()||info.isSymbolicLink()||await realpath(directory)!==path.resolve(directory))throw fail('Private state is not a canonical directory.');
32
+ if(process.platform==='win32')await(await windowsBroker()).request('directory',directory);
33
+ else if(info.mode&0o077||process.getuid&&info.uid!==process.getuid())throw fail('Private directory permissions or owner do not verify.');
34
+ }
35
+ export async function assertPrivateFile(file,info){
36
+ info ||= await lstat(file);
37
+ if(!info.isFile()||info.isSymbolicLink())throw fail('Private state is not a regular file.');
38
+ if(process.platform==='win32')await(await windowsBroker()).request('file',file);
39
+ else if(info.mode&0o077||process.getuid&&info.uid!==process.getuid())throw fail('Private file permissions or owner do not verify.');
40
+ }
@@ -3,6 +3,7 @@ import { open, lstat, unlink } from 'node:fs/promises';
3
3
  import { hostname } from 'node:os';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
+ import { assertPrivateFile } from './private-fs.mjs';
6
7
 
7
8
  const optional = file => { try { return readFileSync(file,'utf8').trim(); } catch { return null; } };
8
9
  const start = pid => optional(`/proc/${pid}/stat`)?.split(') ').slice(1).join(') ').split(' ')[19] || null;
@@ -18,23 +19,33 @@ export function processDead(owner) {
18
19
  return Boolean(owner.start && current && owner.start!==current);
19
20
  }
20
21
  const fail = (message,code='SESSION_IN_USE') => Object.assign(new Error(message),{code});
21
- async function inspect(name) {
22
+ async function inspect(name,fault=async()=>{}) {
23
+ const named=await lstat(name,{bigint:true});if(named.isSymbolicLink()||!named.isFile())throw fail('Unsafe lock type; no lock was removed.','LOCK_UNSAFE');
24
+ await fault('named',{path:name});
22
25
  const file=await open(name,constants.O_RDONLY | constants.O_NOFOLLOW);
23
26
  try {
24
- const info=await file.stat();
25
- if(!info.isFile() || info.size>4096 || info.mode & 0o077 || process.getuid && info.uid!==process.getuid())throw fail('Lock inseguro; no se retiró.','LOCK_UNSAFE');
26
- const text=(await file.readFile('utf8')).trim(); let owner;
27
+ const info=await file.stat({bigint:true});await fault('opened',{path:name});
28
+ if(info.ino!==named.ino||info.dev!==named.dev||!await same(name,info))throw fail('Lock changed during observation; no owner was assumed.','LOCK_CHANGED');
29
+ if(!info.isFile() || info.size>4096n)throw fail('Unsafe lock object or size; no lock was removed.','LOCK_UNSAFE');
30
+ try{await assertPrivateFile(name,await file.stat());}catch{
31
+ if(!await same(name,info))throw fail('Lock changed during private inspection; no owner was assumed.','LOCK_CHANGED');
32
+ throw fail('Lock privacy does not verify; no lock was removed.','LOCK_UNSAFE');
33
+ }
34
+ if(!await same(name,info))throw fail('Lock changed during private inspection; no owner was assumed.','LOCK_CHANGED');
35
+ const bytes=Buffer.alloc(4097),{bytesRead}=await file.read(bytes,0,bytes.length,0);if(bytesRead>4096)throw fail('Lock exceeds its owner-record bound.','LOCK_UNSAFE');
36
+ const text=bytes.subarray(0,bytesRead).toString('utf8').trim();let owner;
27
37
  try{owner=JSON.parse(text);}catch{throw fail('Lock sin propietario verificable; no se retiró.','LOCK_UNKNOWN');}
28
38
  if(typeof owner==='number')owner={pid:owner}; // Legacy PID: only ESRCH proves death.
29
39
  if(!owner || !Number.isSafeInteger(owner.pid) || owner.pid<=0)throw fail('Lock sin propietario verificable; no se retiró.','LOCK_UNKNOWN');
40
+ if(!await same(name,info))throw fail('Lock changed while reading its owner; no owner was assumed.','LOCK_CHANGED');
30
41
  return {info,owner};
31
42
  } finally{await file.close();}
32
43
  }
33
- async function same(name,info){try{const current=await lstat(name);return !current.isSymbolicLink() && current.ino===info.ino && current.dev===info.dev;}catch(e){if(e.code==='ENOENT')return false;throw e;}}
44
+ async function same(name,info){try{const current=await lstat(name,{bigint:true});return !current.isSymbolicLink() && current.ino===info.ino && current.dev===info.dev;}catch(e){if(e.code==='ENOENT')return false;throw e;}}
34
45
 
35
46
  // Only this exclusion gate may reclaim a dead owner. It is never automatically
36
47
  // reclaimed itself: two reapers must not unlink a replacement lock concurrently.
37
- export async function acquireProcessLock(name,{waitMs=0,validate=async()=>{}}={}) {
48
+ export async function acquireProcessLock(name,{waitMs=0,validate=async()=>{},resource,fault=async()=>{}}={}) {
38
49
  const until=Date.now()+waitMs;
39
50
  for(;;){
40
51
  let file, recovered;
@@ -42,29 +53,29 @@ export async function acquireProcessLock(name,{waitMs=0,validate=async()=>{}}={}
42
53
  catch(error){
43
54
  if(error.code!=='EEXIST')throw error;
44
55
  let old;
45
- try{old=await inspect(name);}catch(e){if(e.code==='ENOENT')continue;if(e.code==='LOCK_UNKNOWN' && Date.now()<until){await delay(10);continue;}throw e;}
56
+ try{old=await inspect(name,fault);}catch(e){if(e.code==='ENOENT')continue;if(['LOCK_UNKNOWN','LOCK_CHANGED'].includes(e.code)&&Date.now()<until){await delay(10);continue;}if(e.code==='LOCK_CHANGED')throw fail('The lock changed during observation; no owner was assumed or removed.');throw e;}
46
57
  if(processDead(old.owner)){
47
58
  let gate;
48
59
  try{gate=await open(`${name}.reclaim`,'wx',0o600);}
49
60
  catch(e){if(e.code!=='EEXIST')throw e;if(Date.now()>=until)throw fail('Recuperación de lock en uso o interrumpida; no se retiró.','LOCK_RECOVERY_BUSY');}
50
61
  if(gate)try{
51
62
  await gate.writeFile(JSON.stringify(processIdentity));await gate.sync();
52
- const current=await inspect(name).catch(e=>{if(e.code==='ENOENT')return null;throw e;});
63
+ const current=await inspect(name,fault).catch(e=>{if(['ENOENT','LOCK_CHANGED'].includes(e.code))return null;throw e;});
53
64
  if(current && processDead(current.owner) && await same(name,current.info)){
54
- await validate();
65
+ await validate(current.owner);
55
66
  if(await same(name,current.info)){
56
67
  await unlink(name);
57
68
  try{file=await open(name,'wx',0o600);recovered={path:name,owner:current.owner};}
58
69
  catch(e){if(e.code!=='EEXIST')throw e;}
59
70
  }
60
71
  }
61
- } finally{const info=await gate.stat();if(await same(`${name}.reclaim`,info))await unlink(`${name}.reclaim`);await gate.close();}
72
+ } finally{const info=await gate.stat({bigint:true});if(await same(`${name}.reclaim`,info))await unlink(`${name}.reclaim`);await gate.close();}
62
73
  }
63
74
  if(!file){if(Date.now()>=until)throw fail('Sesión en uso o propietario no recuperable; no se retiró el lock.');await delay(10);continue;}
64
75
  }
65
- const owner={...processIdentity,token:randomUUID()};
76
+ const owner={...processIdentity,token:randomUUID(),...(resource?{resource:structuredClone(resource)}:{})};
66
77
  try{await file.writeFile(JSON.stringify(owner));await file.sync();}
67
- catch(error){const info=await file.stat();if(await same(name,info))await unlink(name);await file.close();throw error;}
68
- return {file,recovered,release:async()=>{const info=await file.stat();try{if(await same(name,info))await unlink(name);}finally{await file.close();}}};
78
+ catch(error){const info=await file.stat({bigint:true});if(await same(name,info))await unlink(name);await file.close();throw error;}
79
+ return {file,recovered,release:async()=>{const info=await file.stat({bigint:true});try{if(await same(name,info))await unlink(name);}finally{await file.close();}}};
69
80
  }
70
81
  }
@@ -10,15 +10,26 @@ export const AUTH_PROVIDERS = Object.freeze([
10
10
  { id: 'codex', label: 'Codex / ChatGPT', method: 'device_code', subscription: true, protocol: 'openai', baseURL: 'https://chatgpt.com/backend-api/codex', documentation: 'https://learn.chatgpt.com/docs/auth' },
11
11
  { id: 'kimi', label: 'Kimi Code', method: 'device_code', experimental: true, thirdPartyMethod: 'subscription_key', subscription: true, protocol: 'openai', baseURL: 'https://api.kimi.com/coding/v1', apiKeyEnv: 'KIMI_CODE_API_KEY', documentation: 'https://www.kimi.com/en/help/kimi-code/membership-guide' },
12
12
  { id: 'minimax', label: 'MiniMax M Plan', method: 'subscription_key', subscription: true, protocol: 'anthropic', baseURL: 'https://api.minimax.io/anthropic', model: 'MiniMax-M3', apiKeyEnv: 'MINIMAX_SUBSCRIPTION_KEY', documentation: 'https://platform.minimax.io/docs/m-plan/quickstart' },
13
- { id: 'grok', label: 'Grok / xAI API', method: 'api_key', subscription: false, protocol: 'openai', baseURL: 'https://api.x.ai/v1', apiKeyEnv: 'XAI_API_KEY', documentation: 'https://docs.x.ai/overview' }
13
+ { id: 'grok', label: 'Grok / xAI API', method: 'api_key', subscription: false, protocol: 'openai', baseURL: 'https://api.x.ai/v1', apiKeyEnv: 'XAI_API_KEY', documentation: 'https://docs.x.ai/overview' },
14
+ // Ollama Cloud expone una API compatible con OpenAI en /v1 y /api/show para
15
+ // descubrir la ventana. La llave sólo viaja en el header hacia ese origen.
16
+ { id: 'ollama', label: 'Ollama Cloud', method: 'api_key', subscription: false, protocol: 'openai', baseURL: 'https://ollama.com/', apiKeyEnv: 'OLLAMA_API_KEY', documentation: 'https://docs.ollama.com/cloud' }
14
17
  ].map(Object.freeze));
18
+ const ALIASES = Object.freeze({ grokia: 'grok', xai: 'grok', chatgpt: 'codex', 'kimi-code': 'kimi', 'ollama-cloud': 'ollama' });
15
19
  const failure = (message, code = 'PROVIDER_AUTH_FAILED') => Object.assign(new Error(message), { code });
16
20
  function definition(name) {
17
21
  const id = String(name || '').toLowerCase();
18
- const found = AUTH_PROVIDERS.find(item => item.id === ({ grokia: 'grok', xai: 'grok', chatgpt: 'codex', 'kimi-code': 'kimi' }[id] || id));
19
- if (!found) throw failure('Unknown login provider: codex, kimi, minimax or grok.', 'PROVIDER_AUTH_UNKNOWN');
22
+ const found = AUTH_PROVIDERS.find(item => item.id === (Object.hasOwn(ALIASES, id) ? ALIASES[id] : id));
23
+ if (!found) throw failure('Unknown login provider: codex, kimi, minimax, grok or ollama.', 'PROVIDER_AUTH_UNKNOWN');
20
24
  return found;
21
25
  }
26
+ export function authProviderDefinition(name) { return definition(name); }
27
+ // Proveedores cuya credencial es una llave que el humano puede escribir en el
28
+ // campo oculto de la TUI. Codex sólo admite su login de dispositivo.
29
+ export function acceptsSecretLogin(name) {
30
+ const p = definition(name);
31
+ return p.id !== 'codex' && (p.method !== 'device_code' || Boolean(p.thirdPartyMethod));
32
+ }
22
33
  export function authProviderDefaults(name) {
23
34
  const p = definition(name);
24
35
  return { authProvider: p.id, provider: p.protocol, protocol: p.protocol, baseURL: p.baseURL, ...(p.model ? { model: p.model } : {}) };
package/src/provider.mjs CHANGED
@@ -53,9 +53,18 @@ export async function analyzeImages(provider, { prompt, media, signal, maxTokens
53
53
  return { answer: result.content.filter(block => block.type === 'text').map(block => block.text).join(''), usage: result.usage, model: provider.model };
54
54
  }
55
55
 
56
+ // Sólo conteos y el nombre de la herramienta que estaba escribiéndose al cortarse.
57
+ function truncationDetails(content = []) {
58
+ const blocks = content.filter(block => block && typeof block === 'object'), last = blocks.at(-1);
59
+ const sum = pick => blocks.reduce((total, block) => total + pick(block), 0);
60
+ return { text_chars: sum(block => block.type === 'text' ? (block.text || '').length : 0), reasoning_chars: sum(block => block.type === 'thinking' ? (block.thinking || '').length : 0),
61
+ tool_argument_chars: sum(block => block.type !== 'tool_use' ? 0 : block.truncated_input_chars ?? JSON.stringify(block.input ?? {}).length),
62
+ ...(last?.type === 'tool_use' && typeof last.name === 'string' && last.name ? { tool_name: last.name } : {}) };
63
+ }
64
+
56
65
  function validateMessage(message) {
57
66
  if (!message || !Array.isArray(message.content) || !message.stop_reason) throw new Error('Incomplete model response.');
58
- if(message.stop_reason==='max_tokens')throw Object.assign(new Error('Anthropic response truncated; no tools were executed.'),{code:'OUTPUT_LENGTH',usage:message.usage,usage_accounting:normalizeUsage('anthropic',message.usage)});
67
+ if(message.stop_reason==='max_tokens')throw Object.assign(new Error('Anthropic response truncated; no tools were executed.'),{code:'OUTPUT_LENGTH',usage:message.usage,usage_accounting:normalizeUsage('anthropic',message.usage),details:truncationDetails(message.content)});
59
68
  const ids = new Set();
60
69
  for (const block of message.content) {
61
70
  if (!block || typeof block.type !== 'string') throw new Error('Invalid content block.');
@@ -135,7 +144,7 @@ export class AnthropicProvider {
135
144
  else if (d.type === 'signature_delta') block.signature = (block.signature || '') + d.signature;
136
145
  } else if (event.type === 'content_block_stop') {
137
146
  if (!blocks.has(event.index)) throw new Error('Unknown block closure.');
138
- if (partial.has(event.index)) blocks.get(event.index).input = JSON.parse(partial.get(event.index));
147
+ if (partial.has(event.index)) { const block = blocks.get(event.index), raw = partial.get(event.index); try { block.input = JSON.parse(raw); } catch { delete block.input; block.truncated_input_chars = raw.length; } }
139
148
  closed.add(event.index);
140
149
  } else if (event.type === 'message_delta') {
141
150
  if (!message) throw new Error('Delta without a message.');
@@ -31,8 +31,12 @@ export function readOnlyBashCheck(command) {
31
31
 
32
32
  // Known shipped observation implementations only, not arbitrary plugin/MCP hints.
33
33
  // Rechecking still goes through the live contract, guard and permission judge.
34
- const CHROME_LAB_READS = new Set(['Status', 'Deployment', 'Targets', 'ReadDOM', 'Assert', 'Contexts', 'Console'].map(name => `plugin__chrome-lab__${name}`));
34
+ const CHROME_LAB_READS = new Set(['Status', 'Deployment', 'Targets', 'ReadDOM', 'Assert', 'Contexts', 'Console', 'WorkerDiagnostics'].map(name => `plugin__chrome-lab__${name}`));
35
35
  export const chromeLabObservation = operation => CHROME_LAB_READS.has(operation);
36
- export const readOnlyWorkCheck = (operation, args) => ['Read', 'Glob', 'Grep', 'ListDirectory', 'FileInfo', 'ChromeVerify'].includes(operation) || CHROME_LAB_READS.has(operation) || (operation === 'Bash' && Boolean(readOnlyBashCheck(args?.command)));
36
+ // open-relay: lecturas del canal admin (sin mutación). Las acciones de la fase 2
37
+ // (Reply, Ack, ApprovePlan, RejectPlan) no son observaciones: pasan por la tarjeta.
38
+ const OPEN_RELAY_READS = new Set(['Status', 'Swarms', 'Swarm', 'Task', 'Inbox', 'Message', 'Plans', 'Plan', 'Jobs', 'Job', 'JobLogs', 'Receipt', 'Events', 'Dossier'].map(name => `plugin__open-relay__${name}`));
39
+ export const openRelayObservation = operation => OPEN_RELAY_READS.has(operation);
40
+ export const readOnlyWorkCheck = (operation, args) => ['Read', 'Glob', 'Grep', 'ListDirectory', 'FileInfo', 'ChromeVerify','SourceVerify'].includes(operation) || operation==='GitInspect'&&Boolean(args)&&Object.keys(args).length===0 || CHROME_LAB_READS.has(operation) || (operation === 'Bash' && Boolean(readOnlyBashCheck(args?.command)));
37
41
  export const RUNTIME_CHECK_IDS = Object.freeze(['target_id','pageId','page_id','context_id','contextId']);
38
- export const runtimeBoundReadCheck = (operation, args) => readOnlyWorkCheck(operation,args) && RUNTIME_CHECK_IDS.some(key => Object.hasOwn(args || {},key));
42
+ export const runtimeBoundReadCheck = (operation, args) => readOnlyWorkCheck(operation,args) && (RUNTIME_CHECK_IDS.some(key => Object.hasOwn(args || {},key)) || operation==='plugin__chrome-lab__WorkerDiagnostics'&&Object.hasOwn(args||{},'connection_id')&&Object.hasOwn(args||{},'browser_endpoint_id'));
@@ -26,8 +26,8 @@ const cancelled = () => Object.assign(new Error('Remote Control connection cance
26
26
  const lifetimeSignal = (run,timeout=10000) => run?.controller ? AbortSignal.any([run.controller.signal,AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout);
27
27
 
28
28
  export class RemoteHost {
29
- constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
30
- Object.assign(this,{client,onInput,onStop,onNotice,socketFactory});this.pending=[];this.turnId=null;this.stopped=true;this.transport='disconnected';this.processing=null;this.sending=null;this.lifecycle=null;this.starting=null;
29
+ constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},getWorkspace=null,socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
30
+ Object.assign(this,{client,onInput,onStop,onNotice,getWorkspace,socketFactory});this.pending=[];this.turnId=null;this.stopped=true;this.transport='disconnected';this.processing=null;this.sending=null;this.lifecycle=null;this.starting=null;
31
31
  }
32
32
  status(){return this.session?{enabled:!this.stopped,url:this.url,session:this.session.id,transport:this.transport,permissions:'local_terminal_only'}:{enabled:false};}
33
33
  current(run,{starting=false}={}) {return this.lifecycle===run && (starting || !this.stopped) && !run?.controller?.signal.aborted;}
@@ -48,7 +48,8 @@ export class RemoteHost {
48
48
  const account=await this.client.credentials();this.assertCurrent(run,{starting:true});
49
49
  if(!account)throw Error('Sign in with comal cloud login before enabling Remote Control.');
50
50
  run.account={origin:account.origin,user_id:account.user_id};run.enablingToken=account.token;
51
- const opened=await this.client.invoke('remote.sessions.open',{local_session_id:session.id,host_id:randomUUID(),label:boundText(session.name || 'Comal',80),features:['commands_v1','thinking_v1']},{expectedAccount:run.account,signal:run.controller.signal});
51
+ const workspace=this.getWorkspace?await this.getWorkspace(session):null;this.assertCurrent(run,{starting:true});
52
+ const opened=await this.client.invoke('remote.sessions.open',{local_session_id:session.id,host_id:randomUUID(),label:boundText(session.name || 'Comal',80),features:['commands_v1','thinking_v1',...(this.getWorkspace?['workspace_v1']:[])]},{expectedAccount:run.account,signal:run.controller.signal});
52
53
  run.session=opened.session;
53
54
  // Abort can race a successful response. Revoke a late-created grant even
54
55
  // when the local stop completed before its identifier became available.
@@ -56,6 +57,7 @@ export class RemoteHost {
56
57
  this.account=run.account;this.enablingToken=run.enablingToken;this.session=opened.session;this.url=opened.url;this.stopped=false;
57
58
  await this.authorize(run);this.assertCurrent(run);await this.pulse(run);this.assertCurrent(run);
58
59
  this.transport='https-polling';this.connectSocket(run);
60
+ if(workspace)this.publishWorkspace(workspace,run);
59
61
  this.timer=setInterval(()=>this.tick(run),1000);this.timer.unref();
60
62
  for(const message of (session.historyMessages || []).slice(-20)){
61
63
  const event=message.role==='user'&&(typeof message.content==='string'||Array.isArray(message.content)&&message.content.some(block=>block.type==='text'))?{type:'user',text:typeof message.content==='string'?message.content:message.content.filter(block=>block.type==='text').map(block=>block.text).join('\n')}:message.role==='assistant'?{type:'assistant',message}:null;
@@ -103,8 +105,30 @@ export class RemoteHost {
103
105
  }catch{/* HTTPS is already active. A later tick retries WSS. */}
104
106
  finally{if(this.connecting===marker){this.connecting=null;if(this.current(run))this.nextSocketAt=Date.now()+10000;}}
105
107
  }
108
+ publishWorkspace(workspace,run=this.lifecycle) {
109
+ if(!this.current(run))return;
110
+ const {source,...content}=workspace,key=digest(content);
111
+ if(run.workspaceDigest===key)return;
112
+ if(this.pending.length>=512)throw Error('Remote workspace buffer full.');
113
+ this.pending.push({event_id:randomUUID(),event:{type:'workspace.snapshot',text:'',turn_id:this.turnId||'local',workspace}});
114
+ run.workspaceDigest=key;this.flush(run).catch(()=>{});
115
+ }
116
+ invalidateWorkspace() {
117
+ const run=this.lifecycle;if(!this.current(run)||!this.getWorkspace)return;
118
+ run.workspaceDirty=true;
119
+ if(run.workspaceTimer||run.workspaceLoading)return;
120
+ run.workspaceTimer=setTimeout(()=>{run.workspaceTimer=null;this.refreshWorkspace(run);},750);run.workspaceTimer.unref();
121
+ }
122
+ async refreshWorkspace(run=this.lifecycle) {
123
+ if(!this.current(run)||!this.getWorkspace||run.workspaceLoading)return;
124
+ run.workspaceLoading=true;run.workspaceDirty=false;
125
+ try {const workspace=await this.getWorkspace();if(this.current(run))this.publishWorkspace(workspace,run);}
126
+ catch {if(this.current(run))this.onNotice('Remote work state unavailable; local execution continues.');}
127
+ finally {run.workspaceLoading=false;if(this.current(run)&&run.workspaceDirty)this.invalidateWorkspace();}
128
+ }
106
129
  observe(event) {
107
130
  if(this.stopped)return;
131
+ if(['tool_result','house_waiting','house_answered','result'].includes(event.type))this.invalidateWorkspace();
108
132
  const append=projected=>this.pending.push({event_id:randomUUID(),event:projected});
109
133
  if(event.type==='model_stream_progress'&&event.reasoning_chars>0&&!this.thinkingActive){
110
134
  this.thinkingActive=true;append({type:'thinking.started',turn_id:this.turnId||'local',text:'Thinking…'});this.flush().catch(()=>{});return;
@@ -139,6 +163,7 @@ export class RemoteHost {
139
163
  pulse(run=this.lifecycle){const work=(this.pulsing || Promise.resolve()).catch(()=>{}).then(()=>{if(this.current(run))return this.call('remote.host.pulse',{status:this.turnId?'busy':'idle',turn_id:this.turnId},run);});this.pulsing=work;return work;}
140
164
  async beginTurn(){this.turnId=randomUUID();if(!this.stopped)this.pulse().catch(error=>{if(error.code!=='REMOTE_CANCELLED')this.onNotice('Remote state unavailable: '+error.message);});}
141
165
  async endTurn(){
166
+ this.invalidateWorkspace();
142
167
  if(!this.stopped){this.pending.push({event_id:randomUUID(),event:{type:'turn.settled',text:'Local turn settled.',turn_id:this.turnId || 'local'}});this.flush().catch(()=>{});}
143
168
  clearTimeout(this.deltaTimer);this.deltaTimer=null;this.delta='';this.thinkingActive=false;this.turnId=null;this.pulse().catch(()=>{});
144
169
  }
@@ -172,7 +197,7 @@ export class RemoteHost {
172
197
  }
173
198
  async stop(){
174
199
  const run=this.lifecycle || {session:this.session,account:this.account,enablingToken:this.enablingToken};
175
- this.lifecycle=null;this.starting=null;run.controller?.abort();
200
+ this.lifecycle=null;this.starting=null;run.controller?.abort();clearTimeout(run.workspaceTimer);
176
201
  this.stopped=true;clearInterval(this.timer);clearTimeout(this.deltaTimer);clearTimeout(this.socketTimeout);this.socket?.close();this.socket=null;this.delta='';this.transport='disconnected';
177
202
  this.access=null;this.enablingToken=null;this.pending=[];this.turnId=null;this.processing=null;this.sending=null;this.pulsing=null;this.connecting=null;
178
203
  await this.revoke(run);return this.status();
@@ -0,0 +1,50 @@
1
+ import { foldWorkBook } from './work-plan.mjs';
2
+ import { auditJournal } from './audit.mjs';
3
+
4
+ const text = (value, bytes) => Buffer.from(String(value ?? '')).subarray(0, bytes).toString('utf8').replace(/\uFFFD$/, '');
5
+ const reasons = {
6
+ 'Una comprobación perdió vigencia en el libro.':'A recorded check is no longer current.',
7
+ 'La evidencia de una dependencia cambió; vuelve a comprobar esta tarea.':'Dependency evidence changed; verify this task again.',
8
+ 'Una dependencia perdió su cierre vigente.':'A dependency no longer has current completion evidence.',
9
+ 'Espera una respuesta humana.':'Waiting for an answer in the local terminal.',
10
+ };
11
+ const states = new Set(['pending', 'in_progress', 'blocked', 'done', 'needs_verification']);
12
+ const checkStates = new Set(['running', 'failed', 'refused', 'interrupted', 'not_dispatched']);
13
+
14
+ /** Host projection only: no arguments, assertions, file contents, drafts or raw outputs. */
15
+ export function projectRemoteWorkspace(session, context = {}) {
16
+ const rows = session.rows || [], journal = auditJournal(rows), book = foldWorkBook(session, context);
17
+ const counts = Object.fromEntries([...states].map(status => [status, book.tasks.filter(task => task.status === status).length]));
18
+ const tasks = [...book.tasks].sort((a, b) => ['in_progress', 'blocked', 'needs_verification', 'pending', 'done'].indexOf(a.status) - ['in_progress', 'blocked', 'needs_verification', 'pending', 'done'].indexOf(b.status)).slice(0, 24).map(task => ({
19
+ id: task.id, title: text(task.title, 180), criterion: text(task.criterion, 400),
20
+ status: journal.verified ? task.status : task.status === 'done' ? 'needs_verification' : task.status,
21
+ declared_status: states.has(task.declared_status) ? task.declared_status : 'pending',
22
+ phase: task.phase || 'implementation', reason: text(reasons[task.reason] || task.reason, 240), held_by: task.held_by.slice(0, 24),
23
+ checks_total: task.checks.length,
24
+ checks: task.checks.slice(0, 4).map(check => {
25
+ const step = book.steps.find(step => step.task_id === task.id && step.step_id === check.step_id);
26
+ const receipt = book.receipts.get(step?.receipt_id);
27
+ const status = journal.verified && book.checkValid(task, check, step) ? 'passed'
28
+ : step?.preflight_failure ? 'not_dispatched'
29
+ : step?.status === 'returned' ? 'stale'
30
+ : checkStates.has(step?.status) ? step.status : 'pending';
31
+ return { step_id: check.step_id, operation: text(check.operation, 128), status,
32
+ ...(receipt ? { receipt_id: receipt.invocation_id, receipt_row: receipt.evidence_row } : {}) };
33
+ }),
34
+ }));
35
+ const references = new Set([...book.steps.map(step => step.receipt_id), ...book.tasks.flatMap(task => task.receipt_ids || [])]);
36
+ const receipts = [...book.receipts.values()].filter(receipt => references.has(receipt.invocation_id)).slice(-8).reverse().map(receipt => ({
37
+ id: receipt.invocation_id, operation: text(receipt.operation, 128), status: receipt.status, row: receipt.evidence_row,
38
+ }));
39
+ if (!journal.verified) { const previousDone=counts.done; counts.done=0; counts.needs_verification+=previousDone; }
40
+ const result = {
41
+ version: 1, scope: 'recorded_work_execution', asked: 'unjudged',
42
+ source: { session_id: session.id, row: Math.max(0, rows.length - 1), sha256: journal.tip_sha256 || '0'.repeat(64), integrity: journal.verified ? 'verified_hash_chain' : journal.consistent ? 'legacy' : 'invalid' },
43
+ plan: book.plan ? { version: book.plan.version, objective: text(book.plan.objective, 1000) } : null,
44
+ counts, tasks_total: book.tasks.length, tasks, receipts,
45
+ };
46
+ // The stream has a 48 KiB event ceiling, including its transport envelope.
47
+ // Keep whole task/check records and disclose omissions through total counts.
48
+ while (Buffer.byteLength(JSON.stringify(result)) > 36000 && result.tasks.length) result.tasks.pop();
49
+ return result;
50
+ }