@milpa/comal 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugins.mjs CHANGED
@@ -4,6 +4,7 @@ import { Operation, immutable, toolEffects } from './operations.mjs';
4
4
  import { defaultStateDir } from './compat.mjs';
5
5
  import { readPluginConfig, importPluginEntry, inspectPluginModule } from './plugin-config.mjs';
6
6
  import { normalizeNotice } from './plugin-notices.mjs';
7
+ import { validateCases, contractOf, operationSurface } from './plugin-contract.mjs';
7
8
  import { t, registerMessages } from './i18n/index.mjs';
8
9
 
9
10
  registerMessages({
@@ -13,8 +14,19 @@ registerMessages({
13
14
  // Los errores del runtime pueden quedar en el estado que lee el modelo (PluginStatus):
14
15
  // inglés fijo. Sólo los errores de uso de /plugins pasan por t().
15
16
 
17
+ const blame = (error, plugin) => Object.assign(error, { plugin });
16
18
  const idPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
17
19
  const jsonCopy = value => JSON.parse(JSON.stringify(value));
20
+ // A provided service in the surface: its type and, for an object or function, its own enumerable keys
21
+ // with the type of each, read from property descriptors (no getter runs). Never the values.
22
+ function serviceSurface(name, value) {
23
+ const kind = item => item === null ? 'null' : Array.isArray(item) ? 'array' : typeof item;
24
+ const type = kind(value);
25
+ if (!['object', 'function', 'array'].includes(type)) return { name, type };
26
+ try {
27
+ return { name, type, keys: Object.keys(value).sort().map(key => { const property = Object.getOwnPropertyDescriptor(value, key); return { key, type: !property ? 'missing' : 'get' in property || 'set' in property ? 'accessor' : kind(property.value) }; }) };
28
+ } catch { return { name, type, keys: null }; }
29
+ }
18
30
  const configContract = (id, schema) => new Operation({ name: `${id}_config`, description: 'Plugin configuration', input_schema: schema, run: () => '' });
19
31
 
20
32
  export function parsePluginCommand(text = '') {
@@ -59,8 +71,33 @@ export class PluginRuntime {
59
71
  if (definition.hook_timeout_ms !== undefined && (!Number.isInteger(definition.hook_timeout_ms) || definition.hook_timeout_ms < 10 || definition.hook_timeout_ms > 5000)) throw new Error('hook_timeout_ms requiere 10–5000 ms.');
60
72
  const discovery = definition.discovery;
61
73
  if (discovery !== undefined && (!discovery || typeof discovery !== 'object' || Array.isArray(discovery) || Object.keys(discovery).some(key => !['description', 'tags'].includes(key)) || typeof discovery.description !== 'string' || discovery.description.length > 300 || discovery.tags && (!Array.isArray(discovery.tags) || discovery.tags.length > 12 || discovery.tags.some(tag => typeof tag !== 'string' || tag.length > 40)))) throw new Error('Discovery de plugin inválido.');
62
- 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 });
74
+ // Cases are part of the contract, not of loading: a plugin that loaded before cases existed must still
75
+ // load. A malformed `cases` export leaves the plugin without a statable contract (contract_invalid).
76
+ let cases = [], contractInvalid = null;
77
+ try { cases = validateCases(definition.cases); } catch (error) { contractInvalid = String(error?.message ?? error).slice(0, 2000); }
78
+ 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, cases, contract_invalid: contractInvalid, activate: definition.activate });
79
+ }
80
+ // The contract of a scope: its definition and the operations it actually registered, by the short
81
+ // name the plugin passed to ctx.operation/ctx.command. Trusted: computed in the session's own process.
82
+ // When it cannot be stated (malformed cases, a case naming no operation, a surface that is not plain
83
+ // JSON) the plugin still serves and its contract is { id, invalid: reason }: never kept, never repaired.
84
+ #contractOf(scope) {
85
+ if (scope.contract) return scope.contract;
86
+ const { definition } = scope.item, observers = {};
87
+ for (const { event } of scope.listeners) observers[event] = (observers[event] ?? 0) + 1;
88
+ try {
89
+ const invalid = definition.contract_invalid ?? scope.casesInvalid;
90
+ if (invalid) throw new Error(invalid);
91
+ scope.contract = contractOf({ id: scope.id, version: definition.version, requires: definition.requires, provides: definition.provides, config_schema: definition.config_schema, tool_loading: definition.tool_loading, discovery: definition.discovery, hook_timeout_ms: definition.hook_timeout_ms, cases: definition.cases,
92
+ operations: [...scope.operations].map(([name, op]) => operationSurface(op.contract, scope.shortNames.get(name) ?? name)),
93
+ // A guard is consulted for every operation of its own plugin (#activate), so it covers all of them.
94
+ guards: { count: scope.guards.length, covers: scope.guards.length ? [...scope.shortNames.values()] : [] },
95
+ observers, services: [...scope.services].map(([name, service]) => serviceSurface(name, service.value)) });
96
+ } catch (error) { scope.contract = immutable({ id: scope.id, invalid: String(error?.message ?? error).slice(0, 2000) }); }
97
+ return scope.contract;
63
98
  }
99
+ /** The contract (plugin-contract.mjs) of the active generation of a plugin, or undefined. */
100
+ contract(id) { const scope = this.#instances.get(id); return scope?.active ? this.#contractOf(scope) : undefined; }
64
101
  register(definition, { config = {}, builtin = false, module, fingerprint } = {}) {
65
102
  if (this.#closing) throw new Error('Plugin runtime closed.');
66
103
  const manifest = this.#definition(definition);
@@ -70,7 +107,7 @@ export class PluginRuntime {
70
107
  status() {
71
108
  return { config_path: this.file, services: [...this.#services.keys()], plugins: [...this.#known.values()].map(item => {
72
109
  const scope = this.#instances.get(item.id);
73
- 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 } : {}) };
110
+ 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 } : {}), ...(scope?.active && scope.contract?.invalid ? { contract_invalid: scope.contract.invalid } : {}), 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 } : {}) };
74
111
  }) };
75
112
  }
76
113
  async discover({ missing = true } = {}) {
@@ -112,7 +149,25 @@ export class PluginRuntime {
112
149
  const imported = await importPluginEntry({ ...item.entry, enabled: true }, { expect }); signal?.throwIfAborted();
113
150
  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 };
114
151
  }
115
- async load(id, { signal, expect } = {}) {
152
+ // An import-phase failure is a load failure: it leaves an event naming the plugin. A trust-stale refusal
153
+ // is not one (the host shows it as pending_trust), and neither is a caller's abort.
154
+ async #importing(item, signal, expect) {
155
+ try { return await this.#import(item, signal, expect); }
156
+ catch (error) {
157
+ if (error?.code !== 'PLUGIN_TRUST_STALE' && !signal?.aborted) {
158
+ const live = this.#known.get(item.id);
159
+ if (live && !this.#instances.get(item.id)?.active) { live.state = 'failed'; }
160
+ if (live) live.error = error.message;
161
+ await this.#event({ id: item.id, state: 'failed', phase: 'import', error: error.message });
162
+ }
163
+ throw error;
164
+ }
165
+ }
166
+ // admit(stagedContract) is awaited after staging and before the commit (load with an id, reload):
167
+ // anything but true, or a throw, publishes nothing and rejects with PLUGIN_NOT_ADMITTED. It runs
168
+ // inside the runtime's queue, so it must not call load/enable/reload/disable/close.
169
+ async load(id, { signal, expect, admit } = {}) {
170
+ if (admit !== undefined && (typeof admit !== 'function' || !id)) throw new TypeError('admit requires a plugin id and a function.');
116
171
  return this.#exclusive(async () => {
117
172
  this.#open(); const source = await this.discover();
118
173
  const wanted = new Set(this.desired.entries.filter(entry => entry.enabled).map(entry => entry.id));
@@ -122,8 +177,8 @@ export class PluginRuntime {
122
177
  const changing = new Set([...entries.map(item => item.id), ...retiring.map(scope => scope.id)]);
123
178
  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.`);
124
179
  const candidates = [];
125
- for (const item of entries) { if (id) this.#dependents(item.id); candidates.push(await this.#import(item, signal, id ? expect : undefined)); }
126
- 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();
180
+ for (const item of entries) { if (id) this.#dependents(item.id); candidates.push(await this.#importing(item, signal, id ? expect : undefined)); }
181
+ 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.'); }, admit && { id, admit }); return this.status();
127
182
  });
128
183
  }
129
184
  #open() { if (this.#closing) throw new Error('Plugin runtime closed.'); }
@@ -137,14 +192,15 @@ export class PluginRuntime {
137
192
  if (this.#instances.get(id)?.active) return this.status();
138
193
  const source = item.entry ? await this.discover() : null;
139
194
  if (source && !source.entries.some(entry => entry.id === id)) throw new Error(`Plugin no longer configured: ${id}`);
140
- const candidate = item.entry ? await this.#import(item, signal, expect) : { ...item, disabled: false };
195
+ const candidate = item.entry ? await this.#importing(item, signal, expect) : { ...item, disabled: false };
141
196
  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();
142
197
  });
143
198
  }
144
- reload(id, { signal } = {}) {
199
+ reload(id, { signal, admit } = {}) {
200
+ if (admit !== undefined && typeof admit !== 'function') return Promise.reject(new TypeError('admit must be a function.'));
145
201
  return this.#exclusive(async () => {
146
202
  this.#open(); const item = this.#known.get(id); if (!item) throw new Error(`Unknown plugin: ${id}`);
147
- this.#dependents(id); await this.#activate([await this.#import(item, signal)], signal); return this.status();
203
+ this.#dependents(id); await this.#activate([await this.#importing(item, signal)], signal, [], undefined, admit && { id, admit }); return this.status();
148
204
  });
149
205
  }
150
206
  #plan(candidates, retiring = []) {
@@ -153,21 +209,21 @@ export class PluginRuntime {
153
209
  const availableItems = new Map([...this.#known.values(), ...candidates].map(item => [item.id, item]));
154
210
  for (const item of availableItems.values()) if (item.definition && !retiringIds.has(item.id) && (!item.disabled || selected.has(item.id))) {
155
211
  for (const name of item.definition.provides) {
156
- const live = this.#services.get(name); if (live && !retiringIds.has(live.owner) && live.owner !== item.id && (selected.has(item.id) || !this.#instances.get(item.id)?.active)) throw new Error(`Servicio ocupado: ${name}`);
157
- if (providers.has(name) && providers.get(name).id !== item.id) throw new Error(`Servicio duplicado: ${name}`);
212
+ const live = this.#services.get(name); if (live && !retiringIds.has(live.owner) && live.owner !== item.id && (selected.has(item.id) || !this.#instances.get(item.id)?.active)) throw blame(new Error(`Servicio ocupado: ${name}`), item.id);
213
+ if (providers.has(name) && providers.get(name).id !== item.id) throw blame(new Error(`Servicio duplicado: ${name}`), item.id);
158
214
  providers.set(name, selected.get(item.id) || item);
159
215
  }
160
216
  }
161
217
  const ordered = [], visiting = new Set(), done = new Set();
162
218
  const visit = item => {
163
- if (done.has(item.id)) return; if (visiting.has(item.id)) throw new Error(`Dependencia cíclica de plugins: ${item.id}`);
164
- if (!item.definition) throw new Error(`Plugin sin manifiesto confiado: ${item.id}`);
219
+ if (done.has(item.id)) return; if (visiting.has(item.id)) throw blame(new Error(`Dependencia cíclica de plugins: ${item.id}`), item.id);
220
+ if (!item.definition) throw blame(new Error(`Plugin sin manifiesto confiado: ${item.id}`), item.id);
165
221
  visiting.add(item.id);
166
222
  for (const name of item.definition.requires) {
167
223
  const live = this.#services.get(name);
168
224
  if (live && !retiringIds.has(live.owner) && live.owner !== item.id && !selected.has(live.owner)) continue;
169
225
  const provider = providers.get(name);
170
- if (!provider) throw new Error(`Falta servicio requerido ${name} para ${item.id}.`);
226
+ if (!provider) throw blame(new Error(`Falta servicio requerido ${name} para ${item.id}.`), item.id);
171
227
  visit(provider);
172
228
  }
173
229
  visiting.delete(item.id); done.add(item.id);
@@ -176,7 +232,7 @@ export class PluginRuntime {
176
232
  candidates.forEach(visit); return ordered;
177
233
  }
178
234
  async #stage(item, available, signal) {
179
- 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() };
235
+ const scope = { id: item.id, item, generation: randomUUID(), state: 'loading', active: false, retired: false, leases: 0, waiters: [], operations: new Map(), shortNames: new Map(), services: new Map(), listeners: [], guards: [], removers: [], disposers: [], notices: [], controller: new AbortController(), requiredOwners: new Set() };
180
236
  const abortActivation = () => scope.controller.abort(signal.reason);
181
237
  if (signal?.aborted) abortActivation(); else signal?.addEventListener('abort', abortActivation, { once: true });
182
238
  const alive = () => { if (scope.retired || !['loading', 'active'].includes(scope.state)) throw new Error('Scope de plugin retirado.'); };
@@ -193,6 +249,7 @@ export class PluginRuntime {
193
249
  const name = item.builtin ? definition.name : `plugin__${item.id}__${definition.name}`;
194
250
  if (name.length > 64 || scope.operations.has(name)) throw new Error(`Operación duplicada o nombre demasiado largo: ${name}`);
195
251
  const handler = definition.run, summarize = definition.confirm_summary;
252
+ scope.shortNames.set(name, definition.name);
196
253
  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,
197
254
  ...(definition.confirm ? { confirm: definition.confirm } : {}),
198
255
  // El resumen corre con el mismo lease y la misma señal que la operación: si el plugin se retira, no se muestra.
@@ -206,11 +263,13 @@ export class PluginRuntime {
206
263
  signal: scope.controller.signal,
207
264
  operation: definition => register(definition), command: definition => register(definition, true),
208
265
  service: name => { alive(); if (!item.definition.requires.includes(name) || !available.has(name)) throw new Error(`Servicio no declarado/disponible: ${name}`); return available.get(name).value; },
209
- provide: (name, value) => { staging(); if (!item.definition.provides.includes(name) || scope.services.has(name)) throw new Error(`Servicio no declarado/duplicado: ${name}`); if (available.has(name) && available.get(name).owner !== item.id) throw new Error(`Servicio ocupado: ${name}`); scope.services.set(name, { owner: item.id, value }); },
266
+ provide: (name, value) => { staging(); if (!item.definition.provides.includes(name) || scope.services.has(name)) throw new Error(`Servicio no declarado/duplicado: ${name}`); if (available.has(name) && available.get(name).owner !== item.id) throw blame(new Error(`Servicio ocupado: ${name}`), item.id); scope.services.set(name, { owner: item.id, value }); },
210
267
  defer: disposer => { alive(); if (typeof disposer !== 'function') throw new Error('Cleanup requiere función.'); scope.disposers.push(disposer); },
211
268
  on: (event, listener) => {
212
- alive(); if (!['operation:result', 'operation:decision', 'skill:started', 'skill:completed'].includes(event) || typeof listener !== 'function') throw new Error('Hook de plugin inválido.');
213
- const watch = { event, listener }; scope.listeners.push(watch); if (scope.active) this.#watch(scope, watch, lease);
269
+ // Observers are part of the surface the house reads when it stages the plugin: one added after
270
+ // activation would serve without ever having been in the contract that was compared.
271
+ staging(); if (!['operation:result', 'operation:decision', 'skill:started', 'skill:completed'].includes(event) || typeof listener !== 'function') throw new Error('Hook de plugin inválido.');
272
+ const watch = { event, listener }; scope.listeners.push(watch); scope.contract = undefined;
214
273
  },
215
274
  guard: check => { staging(); if (typeof check !== 'function') throw new Error('Guarda requiere función.'); scope.guards.push(check); },
216
275
  // Aviso a la conversación: información externa, nunca intención humana ni permiso.
@@ -228,6 +287,10 @@ export class PluginRuntime {
228
287
  signal?.throwIfAborted();
229
288
  await item.definition.activate(context, item.config); signal?.throwIfAborted();
230
289
  for (const name of item.definition.provides) if (!scope.services.has(name)) throw new Error(`El plugin no publicó el servicio declarado: ${name}`);
290
+ // Cases name operations: only now are the plugin's operations known. Neither a case that names no
291
+ // operation nor a surface that is not plain JSON refuses the load; the contract then says it is invalid.
292
+ try { validateCases(item.definition.cases, scope.shortNames.values()); } catch (error) { scope.casesInvalid = String(error?.message ?? error).slice(0, 2000); }
293
+ scope.contract = undefined; this.#contractOf(scope);
231
294
  return scope;
232
295
  } catch (error) { await this.#retire(scope).catch(cleanup => error.cleanup = cleanup.message); throw error; }
233
296
  finally { signal?.removeEventListener('abort', abortActivation); }
@@ -247,22 +310,35 @@ export class PluginRuntime {
247
310
  });
248
311
  }));
249
312
  }
250
- async #activate(candidates, signal, retiring = [], checkSource) {
251
- const staged = [], previous = [...retiring], available = new Map(this.#services); let current, committed = false;
313
+ async #activate(candidates, signal, retiring = [], checkSource, admission) {
314
+ const staged = [], previous = [...retiring], available = new Map(this.#services); let current, committed = false, stage = 'plan'; const cleanupIds = [];
252
315
  try {
253
316
  const plan = this.#plan(candidates, retiring);
254
317
  for (const old of retiring) for (const name of old.services.keys()) available.delete(name);
255
318
  for (const item of plan) {
256
319
  const old = this.#instances.get(item.id); if (old?.active) { previous.push(old); for (const name of old.services.keys()) available.delete(name); }
257
320
  }
321
+ stage = 'stage';
258
322
  for (const item of plan) {
259
323
  current = item;
260
324
  const scope = await this.#stage(item, available, signal); staged.push(scope);
261
325
  for (const [name, service] of scope.services) available.set(name, service);
262
326
  }
263
- signal?.throwIfAborted();
327
+ stage = 'source'; signal?.throwIfAborted();
264
328
  if (checkSource) await checkSource();
265
329
  signal?.throwIfAborted();
330
+ if (admission) {
331
+ stage = 'admit'; const target = staged.find(scope => scope.id === admission.id);
332
+ let verdict, refusal;
333
+ // admit sees everything the commit would publish and retire, not only its target.
334
+ const context = Object.freeze({ staged: Object.freeze(staged.map(scope => Object.freeze({ id: scope.id, contract: this.#contractOf(scope) }))), retiring: Object.freeze([...new Set(previous.map(scope => scope.id))].sort()) });
335
+ try { verdict = target ? await admission.admit(this.#contractOf(target), context) : undefined; } catch (error) { refusal = error; }
336
+ if (verdict !== true) throw Object.assign(new Error(`Plugin ${admission.id} was not admitted; nothing was published.`), { code: 'PLUGIN_NOT_ADMITTED', plugin: admission.id, verdict, ...(refusal ? { cause: refusal } : {}) });
337
+ signal?.throwIfAborted();
338
+ // The world may have moved while admit waited (a human reading a card): check the source again.
339
+ stage = 'source'; if (checkSource) await checkSource();
340
+ signal?.throwIfAborted();
341
+ }
266
342
  this.house.registry.replace(previous.flatMap(scope => [...scope.operations.keys()]), staged.flatMap(scope => [...scope.operations.values()]));
267
343
  this.#services = available;
268
344
  for (const old of retiring) { old.item.state = 'disabled'; old.item.disabled = true; }
@@ -276,15 +352,30 @@ export class PluginRuntime {
276
352
  committed = true;
277
353
  const cleanupTasks = previous.map(old => this.#retire(old));
278
354
  const cleanups = await Promise.allSettled(cleanupTasks), failed = cleanups.filter(result => result.status === 'rejected');
355
+ cleanups.forEach((result, index) => { if (result.status === 'rejected' && !cleanupIds.includes(previous[index].id)) cleanupIds.push(previous[index].id); });
279
356
  if (failed.length) throw new AggregateError(failed.map(result => result.reason), 'Falló limpieza de la generación anterior. La nueva sigue activa.');
280
- for (const scope of staged) await this.#event({ id: scope.id, state: 'active', operations: [...scope.operations.keys()] });
357
+ for (const scope of staged) {
358
+ await this.#event({ id: scope.id, state: 'active', operations: [...scope.operations.keys()] });
359
+ if (scope.contract?.invalid) await this.#event({ id: scope.id, state: 'contract_invalid', error: scope.contract.invalid });
360
+ }
281
361
  } catch (error) {
362
+ // An abort is the caller's choice, not a plugin failure: unwind quietly and keep the previous state.
363
+ const aborted = signal?.aborted;
364
+ let blamed;
282
365
  if (!committed) {
283
366
  for (const scope of staged.reverse()) await this.#retire(scope).catch(() => {});
284
- const item = current || candidates[0], live = item && this.#known.get(item.id);
285
- if (live) { live.error = error.message; if (!this.#instances.get(item.id)?.active) live.state = 'failed'; }
367
+ blamed = stage === 'plan' ? error.plugin : stage === 'stage' ? current?.id : undefined;
368
+ const live = blamed && this.#known.get(blamed);
369
+ if (live) { live.error = error.message; if (!this.#instances.get(blamed)?.active) live.state = 'failed'; }
286
370
  }
287
- await this.#event({ id: current?.id || candidates[0]?.id, state: committed ? 'cleanup_failed' : 'failed', error: error.message }); throw error;
371
+ if (aborted && !committed) throw error;
372
+ // A refusal by admit is the caller's verdict, not a plugin failure: journaled, nothing marked failed.
373
+ if (!committed && stage === 'admit' && error?.code === 'PLUGIN_NOT_ADMITTED') { await this.#event({ id: admission.id, state: 'not_admitted' }); throw error; }
374
+ if (committed) {
375
+ const ids = cleanupIds.length ? cleanupIds : [undefined];
376
+ for (const id of ids) await this.#event({ ...(id ? { id } : {}), state: 'cleanup_failed', phase: 'cleanup', error: error.message });
377
+ } else await this.#event({ ...(blamed ? { id: blamed } : {}), state: 'failed', phase: 'activate', error: error.message });
378
+ throw error;
288
379
  }
289
380
  }
290
381
  async #retire(scope) {
@@ -119,6 +119,13 @@ export async function answerFromEitherSurface({ desk, questionId, name, input, c
119
119
  } finally { entry.close(); }
120
120
  }
121
121
 
122
+ /** The terminal card for PermissionPolicy's confirm: shown => ui.confirm(...) on the given signal. It resolves
123
+ * exactly what ui.confirm resolves (a boolean, or { allowed: true, remember } when the policy offered «always»);
124
+ * why the card closed travels out of band, to the asking policy's own onOutcome. */
125
+ export function screenTerminal(ui, name, input, { summary, edit, remember, onOutcome } = {}) {
126
+ return shown => ui.confirm(name, input, shown, { summary, edit, remember, ...(typeof onOutcome === 'function' ? { onOutcome } : {}) });
127
+ }
128
+
122
129
  /** The terminal's line when the M5Stack decided a card. */
123
130
  export const deviceAnswerNotice = ({ allowed, tool }) => t('Answered on the M5Stack: {decision} · {tool}', { decision: t(allowed ? 'allowed once' : 'denied'), tool: String(tool ?? '') });
124
131
 
@@ -1,5 +1,6 @@
1
1
  import {PermissionPolicy,BYPASS_PERMISSION_MODE,PERMISSION_MODES} from './permissions.mjs';
2
2
  import {digest,immutable} from './operations.mjs';
3
+ import path from 'node:path';
3
4
 
4
5
  const fail=message=>Object.assign(new Error(message),{code:'SCOPED_POLICY_INVALID'});
5
6
  const modes=new Set([...PERMISSION_MODES,BYPASS_PERMISSION_MODE]);
@@ -17,7 +18,12 @@ export function scopedPolicy({parent,ceiling=capturePolicyCeiling(parent),cwd,in
17
18
  if(current===BYPASS_PERMISSION_MODE)return prior;
18
19
  return prior==='plan'||current==='plan'?'plan':prior==='default'||current==='default'?'default':prior==='auto'||current==='auto'?'auto':'acceptEdits';
19
20
  };
20
- const initial=mode(),policy=new PermissionPolicy({mode:initial,dangerouslySkipPermissions:initial===BYPASS_PERMISSION_MODE,allowed:original.allowed,denied:original.denied,cwd,onDecision});
21
+ // The parent's private locations (the --state-dir, wherever it is) are closed for the child's file tools too.
22
+ // One exception: a workspace the house created for the child inside such a location (an owned Git worktree
23
+ // under the state directory) is the child's own tree. A child that shares the parent's directory has none.
24
+ // A policy scoped again in the same directory (a stack of bindings) keeps the workspace its parent was given.
25
+ const own=typeof parent.cwd==='string'&&path.resolve(parent.cwd)!==path.resolve(cwd)?cwd:parent.ownWorkspace;
26
+ const initial=mode(),policy=new PermissionPolicy({mode:initial,dangerouslySkipPermissions:initial===BYPASS_PERMISSION_MODE,allowed:original.allowed,denied:original.denied,cwd,onDecision,privateLocations:[...(parent.privateExtra||[])],ownWorkspace:own});
21
27
  Object.defineProperties(policy,{
22
28
  mode:{get:mode},allowed:{get:()=>new Set(original.allowed.filter(name=>parent.allowed.has(name)))},denied:{get:()=>new Set([...original.denied,...parent.denied])},
23
29
  classifier:{get:()=>parent.classifier},confirm:{get:()=>parent.confirm},rules:{get:()=>parent.rules},contractOf:{get:()=>parent.contractOf},ceiling:{value:original},
package/src/sessions.mjs CHANGED
@@ -89,6 +89,8 @@ export class SessionStore {
89
89
  if (rows[0]?.type !== 'session' || rows[0].id !== id || rows[0].cwd !== this.cwd) throw new Error('Invalid session identity for audit.');
90
90
  return { id, cwd: this.cwd, rows };
91
91
  }
92
+ // Optional host observer, called synchronously with (id, sealedRow) after each durable append; it cannot fail the append.
93
+ onAppend = null;
92
94
  getLoadProfile(){return this.lastLoadProfile?structuredClone(this.lastLoadProfile):null;}
93
95
  async append(id, record) {
94
96
  assertClassifierChannels([record]);
@@ -120,7 +122,9 @@ export class SessionStore {
120
122
  const row = sealRecord({ ...record, ...(mutex.recovered?{recovered_append_lock:mutex.recovered}:{}), timestamp: new Date().toISOString() }, sequence, previousHash);
121
123
  const bytes = Buffer.from(JSON.stringify(row) + '\n');
122
124
  for (let offset = 0; offset < bytes.length;) { const written = await file.write(bytes, offset, bytes.length - offset, size + offset); if (!written.bytesWritten) throw new Error('No se pudo completar el asiento del libro.'); offset += written.bytesWritten; }
123
- await file.sync(); return row;
125
+ await file.sync();
126
+ try { this.onAppend?.(id, row); } catch {}
127
+ return row;
124
128
  } finally { await file?.close(); await mutex.release(); }
125
129
  });
126
130
  this.writers.set(id, next);
package/src/subagents.mjs CHANGED
@@ -10,7 +10,7 @@ import { validateInput } from './tool-input.mjs';
10
10
  import { webToolDefinitions } from './web-tools.mjs';
11
11
  import { House, houseSnapshot } from './house.mjs';
12
12
  import { randomUUID } from 'node:crypto';
13
- import { digest,Operation } from './operations.mjs';
13
+ import { digest,Operation,PLUGIN_REPAIR_OPERATION_NAMES } from './operations.mjs';
14
14
  import { processIdentity, processDead, acquireProcessLock } from './process-lock.mjs';
15
15
  import { ProviderScheduler } from './provider-scheduler.mjs';
16
16
  import { RUN_SOURCE, agentRuns, ensureAgentSession, turnOutcome, outcomeDigest, publicAgentRun } from './durable-agent-runs.mjs';
@@ -136,7 +136,7 @@ export class SubagentManager {
136
136
  if (requested.model && parent.model !== requested.model || requested.effort && (parent.effort || 'auto') !== requested.effort) throw Object.assign(new Error('Este host no ofrece un factory para otro modelo/effort hijo.'), { code: 'AGENT_PROVIDER_SCOPE' });
137
137
  return { provider: parent, descriptor: { provider: this.providerName, model: this.provider.model, effort: this.provider.effort || 'auto', contextWindow: this.provider.contextWindow || 45000, maxTokens: this.provider.maxTokens || 8192 }, budget_owner: null };
138
138
  }
139
- toolScope(task) { return (task.tools || []).map(name => { const op = this.house.registry.get(name); if (!op || name.startsWith('Agent') || agentKind(task) !== 'general' && !this.exploreTools.has(name)) throw Object.assign(new Error(`Herramienta hija no admitida por el host: ${name}.`), { code: 'AGENT_TOOL_SCOPE' }); return { name, contract_digest: op.digest }; }); }
139
+ toolScope(task) { return (task.tools || []).map(name => { const op = this.house.registry.get(name); if (!op || name.startsWith('Agent') || PLUGIN_REPAIR_OPERATION_NAMES.includes(name) || agentKind(task) !== 'general' && !this.exploreTools.has(name)) throw Object.assign(new Error(`Herramienta hija no admitida por el host: ${name}.`), { code: 'AGENT_TOOL_SCOPE' }); return { name, contract_digest: op.digest }; }); }
140
140
  async prepareLaunch(name, input, tasks, options, captures) {
141
141
  const parentId = options.sessionId || this.getParentId(), invocationId = options.invocation_id || options.callId || randomUUID();
142
142
  return this.store.workTransaction(parentId, async () => {
@@ -331,7 +331,7 @@ export class SubagentManager {
331
331
  const actor = { id, kind: 'subagent', parent_session_id: parentId }, requested = run.tool_scope || [];
332
332
  for (const tool of requested) if (house.registry.get(tool.name)?.digest !== tool.contract_digest || kind === 'explore' && !this.exploreTools.has(tool.name)) throw Object.assign(new Error(`El contrato o admisión de ${tool.name} cambió; no se ejecutó.`), { code: 'AGENT_TOOL_SCOPE_CHANGED' });
333
333
  const extra = (run.workspace?evidenceOperations({store,house}).filter(tool=>tool.name==='EvidenceRead'):[...webToolDefinitions,...evidenceOperations({store,house})]).filter(tool=>inScope(tool.name));
334
- const child = house.bind({ actor, sessionId: id, policy, onPermission, allowed: [...native, ...extra].map(tool => tool.name).concat(WORK_NAMES, requested.map(tool => tool.name), packs ? ['ToolPack'] : []).filter(inScope), resources: { workspace },
334
+ const child = house.bind({ actor, sessionId: id, policy, onPermission, allowed: [...native, ...extra].map(tool => tool.name).concat(WORK_NAMES, requested.map(tool => tool.name), packs ? ['ToolPack'] : []).filter(inScope).filter(name => !PLUGIN_REPAIR_OPERATION_NAMES.includes(name)), resources: { workspace },
335
335
  select: (name, context) => (!requested.some(tool => tool.name === name) || house.registry.get(name)?.digest === requested.find(tool => tool.name === name).contract_digest) && (!packs || packs.visible(name, id, actor)),
336
336
  frontier: async name => {if(runtime)await runtime.check();return !packs||packs.visible(name,id,actor);}, capture: () => packs?.capture(id, actor), unavailable: (name, context) => packs?.unavailable(name, context) });
337
337
  const setContext = context => policy.setContext({ actions: context.actions });
package/src/tools.mjs CHANGED
@@ -102,14 +102,29 @@ export class WorkspaceTools {
102
102
  catch (err) { if (err.code !== 'ENOENT') throw err; suffix.unshift(path.basename(ancestor)); ancestor = path.dirname(ancestor); }
103
103
  }
104
104
  }
105
+ // An open plugin candidate's tree holds links to host modules (plugin-workbench.mjs): a path that enters
106
+ // the tree must also end in it, or a write through a link would land on the installed file.
107
+ const trees = this.policy?.candidateRoots ?? [];
108
+ if (trees.length) {
109
+ for (let ancestor = candidate; ; ancestor = path.dirname(ancestor)) {
110
+ let real = null; try { real = await realpath(ancestor); } catch {}
111
+ const tree = real && trees.find(root => this.inside(real, root));
112
+ if (tree && !this.inside(canonical, tree)) throw new Error('The path leaves the plugin candidate tree through a link to a host module; host modules are shared with the house and cannot be changed by a repair.');
113
+ if (path.dirname(ancestor) === ancestor) break;
114
+ }
115
+ }
105
116
  const workRoot = this.workRootOf(canonical);
106
117
  if (!this.inside(canonical) && !workRoot && !(readable && this.readRootOf(candidate) && this.inside(canonical, this.readRootOf(candidate)))) throw new Error('The symbolic link leaves the workspace.');
107
118
  if (this.sourceOwnership.contains(canonical)) throw new Error('Native source ownership state is protected.');
108
119
  const first = path.relative(this.cwd, canonical).split(path.sep)[0];
109
- if (['.comal', '.harness'].includes(first)) throw new Error('Comal internal state is protected.');
120
+ // The one exception under Comal state: the exact tree of an open plugin candidate (plugin-repair.mjs).
121
+ const candidateTree = Boolean(workRoot) && this.policy?.candidateRootOf?.(canonical) === workRoot;
122
+ if (['.comal', '.harness'].includes(first) && !candidateTree) throw new Error('Comal internal state is protected.');
110
123
  // An added directory may be another Comal workspace: its state stays off-limits too.
111
124
  if (workRoot && holdsComalState(workRoot, canonical)) throw new Error('Comal internal state is protected.');
112
- if (workRoot && this.policy?.isPrivate?.(canonical)) throw new Error('Path protected: private Comal location or a path an active plugin protects.');
125
+ // Private Comal locations (credentials, trust, rules, the --state-dir under any name) are closed everywhere,
126
+ // in the workspace too, except an open plugin candidate's tree inside the state directory.
127
+ if (this.policy?.isPrivate?.(canonical)) throw new Error('Path protected: private Comal location or a path an active plugin protects.');
113
128
  return canonical;
114
129
  }
115
130
  async text(file) {
@@ -356,6 +356,6 @@ export class InteractiveController {
356
356
  }
357
357
  async exit() {
358
358
  if (this.closed) return; this.closed = true; clearInterval(this.peerTimer);clearTimeout(this.imageTimer);this.runner.stopped = true; this.backend.interrupt();
359
- this.ui.permission?.finish(false); await this.runner.processing;await this.peerProcessing; await this.flushDraft(); await this.queue.save(); this.ui.close();
359
+ this.ui.permission?.finish(false, 'closed'); await this.runner.processing;await this.peerProcessing; await this.flushDraft(); await this.queue.save(); this.ui.close();
360
360
  }
361
361
  }
@@ -148,7 +148,7 @@ function argumentItems(command, args, context) {
148
148
  if (command === 'relay') return named(['status', 'new', 'start', 'stop', 'unlink', 'hub'], t("Swarm project of this repo; every step is a card you confirm"));
149
149
  if (command === 'timing') return named(['on', 'off'], t("Show or hide response timing after each answer"));
150
150
  if (command === 'mcp') return named(['status', 'list', 'config', 'reload', 'reconnect', 'add', 'remove'], t("Action on MCP servers"));
151
- if (command === 'plugins') return named(['status', 'list', 'available', 'add', 'config', 'enable', 'disable', 'reload', 'run', 'allow', 'trust', 'untrust', 'remove'], t("Action on plugins"));
151
+ if (command === 'plugins') return named(['status', 'list', 'available', 'add', 'config', 'enable', 'disable', 'reload', 'run', 'allow', 'trust', 'untrust', 'remove', 'repairs', 'acts', 'revert'], t("Action on plugins"));
152
152
  if (command === 'chrome-workflow') return named(['auto', 'raw'], 'Saved per project; applies to the entry of this session');
153
153
  if (command === 'agent') return named(['explore', 'general','background'], t("Subagent type; complete the task afterwards"));
154
154
  if (command === 'inbox') return named(['status', 'accept', 'hold', 'refuse', 'release'], t("Mailbox control between sessions"));
@@ -157,7 +157,7 @@ function argumentItems(command, args, context) {
157
157
  if (position === 1) {
158
158
  if(command==='agents' && ['status','wait','stop','resume','background'].includes(first))return entries(context.agents,'id');
159
159
  if (command === 'mcp' && ['reconnect', 'remove'].includes(first)) return entries(context.mcpServers);
160
- if (command === 'plugins' && ['enable', 'disable', 'reload', 'run', 'config', 'allow', 'untrust', 'remove'].includes(first)) return entries(context.plugins, 'id');
160
+ if (command === 'plugins' && ['enable', 'disable', 'reload', 'run', 'config', 'allow', 'untrust', 'remove', 'revert'].includes(first)) return entries(context.plugins, 'id');
161
161
  if (command === 'plugins' && first === 'add') return named(['chrome-lab', 'open-relay', 'omarchy'], 'Plugin bundled with Comal');
162
162
  if (command === 'inbox' && first === 'release') return named(['all'], t("Release held messages; does not change hold mode"));
163
163
  if (command === 'queue' && first === 'edit') return named((context.queue?.items || []).map((_, index) => String(index + 1)), t("Edit this pending message"));
@@ -401,13 +401,16 @@ export class TerminalScreen {
401
401
  const options = permission.choices.map((choice, index) => index === permission.choice ? (choice === 'no' ? tone.warning(`❯ ${labels[choice]}`) : tone.good(`❯ ${labels[choice]}`)) : ` ${labels[choice]}`);
402
402
  return [clip(t(' Always = save rule {v0}', { v0: permission.remember.rule }), columns - 1), ' ' + options.join(' '), tone.muted(clip(t(' Enter confirms · y once · {v0}u always for me · n/Esc denies', { v0: permission.choices.includes('project') ? t('p this project · ') : '' }), columns - 1))];
403
403
  }
404
- async confirm(name, input, signal, { summary, remember, edit } = {}) {
405
- if (signal?.aborted || this.closed) return false;
404
+ // onOutcome(why) says why the card closed — 'allowed', 'no' (the human's plain No), 'edit', 'interrupt',
405
+ // 'aborted', 'closed' or 'unknown' — without changing what confirm resolves to.
406
+ async confirm(name, input, signal, { summary, remember, edit, onOutcome } = {}) {
407
+ const tell = why => { try { onOutcome?.(why); } catch {} };
408
+ if (signal?.aborted || this.closed) { tell(signal?.aborted ? 'aborted' : 'closed'); return false; }
406
409
  if (this.selecting) this.setSelection(false);
407
410
  return measureHumanWait(new Promise(resolve => {
408
411
  let settled=false;
409
- const finish = allowed => { if(settled)return;settled=true;signal?.removeEventListener('abort', abort);if(this.permission===question)this.permission = null;this.schedule();resolve(allowed); };
410
- const abort = () => finish(false);
412
+ const finish = (allowed, why = allowed ? 'allowed' : 'unknown') => { if(settled)return;settled=true;signal?.removeEventListener('abort', abort);if(this.permission===question)this.permission = null;this.schedule();tell(why);resolve(allowed); };
413
+ const abort = () => finish(false, 'aborted');
411
414
  // remember: { rule, project } ofrece «sí, siempre»; la regla se muestra antes de elegir.
412
415
  const choices = remember?.rule ? ['once', ...(remember.project ? ['project'] : []), 'user', 'no'] : null;
413
416
  const question = { name, input, summary, edit: edit && typeof edit.field === 'string' && typeof edit.text === 'string' ? { field: edit.field, text: edit.text } : null, allowed: false, scroll: 0, finish, ...(choices ? { remember, choices, choice: choices.length - 1 } : {}) };
@@ -418,7 +421,7 @@ export class TerminalScreen {
418
421
  // pasa al composer con una instrucción. El humano lo corrige y lo envía; el modelo
419
422
  // vuelve a proponer la operación y la tarjeta nueva muestra el texto exacto otra vez.
420
423
  editPermission() {
421
- const { name, edit } = this.permission; this.permission.finish(false);
424
+ const { name, edit } = this.permission; this.permission.finish(false, 'edit');
422
425
  const before = this.composer.text;
423
426
  try {
424
427
  this.composer.setText(before ? before + '\n\n' : '');
@@ -493,15 +496,15 @@ export class TerminalScreen {
493
496
  return;
494
497
  }
495
498
  if (key.ctrl && name === 'c' && this.overlay?.kind === 'palette' && !this.permission) { this.closeOverlay(); return; }
496
- if (key.ctrl && name === 'c') { if (this.permission) this.permission.finish(false); if (this.busy) await this.callbacks.onAction?.('interrupt'); else if (this.composer.text) { this.composer.clear(); this.draftChanged(); } else await this.callbacks.onAction?.('exit'); return; }
499
+ if (key.ctrl && name === 'c') { if (this.permission) this.permission.finish(false, 'interrupt'); if (this.busy) await this.callbacks.onAction?.('interrupt'); else if (this.composer.text) { this.composer.clear(); this.draftChanged(); } else await this.callbacks.onAction?.('exit'); return; }
497
500
  if (this.permission) {
498
501
  const permission = this.permission, answer = choice => choice === 'project' || choice === 'user' ? { allowed: true, remember: choice } : choice === 'once';
499
- if (name === 'escape' || name === 'n') permission.finish(false);
502
+ if (name === 'escape' || name === 'n') permission.finish(false, 'no');
500
503
  else if (name === 'y') permission.finish(true);
501
504
  else if (permission.choices && ((name === 'p' && permission.choices.includes('project')) || name === 'u')) permission.finish(answer(name === 'p' ? 'project' : 'user'));
502
505
  else if (permission.choices && (name === 'left' || name === 'right' || name === 'tab')) permission.choice = (permission.choice + (name === 'left' ? permission.choices.length - 1 : 1)) % permission.choices.length;
503
506
  else if (name === 'left' || name === 'right' || name === 'tab') permission.allowed = !permission.allowed;
504
- else if (name === 'return') permission.finish(permission.choices ? answer(permission.choices[permission.choice]) : permission.allowed);
507
+ else if (name === 'return') { const value = permission.choices ? answer(permission.choices[permission.choice]) : permission.allowed; permission.finish(value, value ? 'allowed' : 'no'); }
505
508
  else if (name === 'pageup') this.permission.scroll = Math.max(0, this.permission.scroll - 10);
506
509
  else if (name === 'pagedown') this.permission.scroll += 10;
507
510
  else if (name === 'r') { this.permission.raw = !(this.permission.raw ?? this.rawView); this.permission.scroll = 0; }
@@ -823,7 +826,7 @@ export class TerminalScreen {
823
826
  if (this.closed) return; this.closed = true;
824
827
  this.agentStatus?.close();
825
828
  this.clearSetup();
826
- this.permission?.finish(false); clearInterval(this.timer); clearTimeout(this.renderTimer); this.decoder?.close();
829
+ this.permission?.finish(false, 'closed'); clearInterval(this.timer); clearTimeout(this.renderTimer); this.decoder?.close();
827
830
  if (this.onData) this.input.off('data', this.onData); if (this.onResize) this.output.off('resize', this.onResize);
828
831
  if (this.started) { this.input.setRawMode?.(this.originalRaw); this.input.pause(); if (!this.suspended) this.output.write('\x1b[0m' + MOUSE_OFF + '\x1b[?2004l\x1b[?25h\x1b[?1049l'); }
829
832
  this.resolveFinished();