@milpa/comal 0.2.6 → 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) {
@@ -558,7 +558,7 @@ export class RelayFlow {
558
558
  if (got?.proposal?.status && got.proposal.status !== 'pending') { this.ui.notice(t('Plan {id} is already {status}.', { id: proposal_id, status: got.proposal.status })); return 'decided'; }
559
559
  // Cómo se comprueba cada tarea: la descripción de los checks del proyecto (project.json del hub).
560
560
  const checks = (await this.#projectFile({ root }, project))?.checks || [];
561
- const choice = await this.ui.setupChoice({ ...planCard({ got, checks }), signal: card.signal });
561
+ const choice = await this.ui.setupChoice({ waits: true, ...planCard({ got, checks }), signal: card.signal });
562
562
  if (this.#elsewhere(key)) return 'decided';
563
563
  const digest = got.proposal.digest, apply = extra => () => this.applyPlan({ project, root, swarm, proposal_id, digest, ...extra });
564
564
  const done = result => {
@@ -569,7 +569,7 @@ export class RelayFlow {
569
569
  };
570
570
  if (choice === 'approve') return await this.#confirm(key, apply({ choice: 'approve' }), done);
571
571
  if (choice === 'reject') {
572
- const reason = await this.ui.setupInput({ title: t('Reason for the lead'), description: t('The lead is woken up with exactly this text and proposes again.'), signal: card.signal });
572
+ const reason = await this.ui.setupInput({ waits: true, title: t('Reason for the lead'), description: t('The lead is woken up with exactly this text and proposes again.'), signal: card.signal });
573
573
  if (this.#elsewhere(key)) return 'decided';
574
574
  if (!reason?.trim()) return 'later';
575
575
  return await this.#confirm(key, apply({ choice: 'reject', reason: reason.trim() }), done);
@@ -618,7 +618,7 @@ export class RelayFlow {
618
618
  if (this.#elsewhere(key)) return 'decided';
619
619
  const card = this.broker.open(key, this.signal);
620
620
  try {
621
- const choice = await this.ui.setupChoice({ ...criteriaCard({ got, swarm }), signal: card.signal });
621
+ const choice = await this.ui.setupChoice({ waits: true, ...criteriaCard({ got, swarm }), signal: card.signal });
622
622
  if (this.#elsewhere(key)) return 'decided';
623
623
  const done = result => {
624
624
  if (result.outcome === 'approved') this.ui.notice(t('Success criteria v{version} accepted ({status}): the lead can plan.', { version: got.current.version, status: result.status }));
@@ -630,14 +630,14 @@ export class RelayFlow {
630
630
  // open-relay v2.33: con avisos, aceptar se confirma aparte (por omisión, volver).
631
631
  const warnings = criteriaWarnings(got.current.items, { checked: got.basis_checked === true });
632
632
  if (warnings.size) {
633
- const sure = await this.ui.setupChoice({ ...criteriaConfirmCard({ got, swarm, warnings }), signal: card.signal });
633
+ const sure = await this.ui.setupChoice({ waits: true, ...criteriaConfirmCard({ got, swarm, warnings }), signal: card.signal });
634
634
  if (this.#elsewhere(key)) return 'decided';
635
635
  if (sure !== 'accept') return 'later';
636
636
  }
637
637
  return await this.#confirm(key, () => this.applyCriteria({ project, root, swarm, digest, choice: 'accept' }), done);
638
638
  }
639
639
  if (choice === 'reject') {
640
- const reason = await this.ui.setupInput({ title: t('Reason for the reviewer'), description: t('The reviewer is woken up with exactly this text and proposes again.'), signal: card.signal });
640
+ const reason = await this.ui.setupInput({ waits: true, title: t('Reason for the reviewer'), description: t('The reviewer is woken up with exactly this text and proposes again.'), signal: card.signal });
641
641
  if (this.#elsewhere(key)) return 'decided';
642
642
  if (!reason?.trim()) return 'later';
643
643
  return await this.#confirm(key, () => this.applyCriteria({ project, root, swarm, digest, choice: 'reject', reason: reason.trim() }), done);
@@ -653,7 +653,7 @@ export class RelayFlow {
653
653
  const { call, mutate } = this.#calls(project, root);
654
654
  const got = await call('criteria.get', { swarm_id: swarm }, this.signal);
655
655
  if (got?.status !== 'accepted' || got?.current?.digest !== digest) { this.ui.notice(t('Criteria of {swarm} are already {status}.', { swarm, status: got?.status || '?' })); return 'decided'; }
656
- const reason = await this.ui.setupInput({ title: t('Why reopen the criteria'), description: t('The reviewer is woken up with exactly this text and proposes a new version; v{version} stays in force until you accept the new one.', { version: got.current.version }), signal: this.signal });
656
+ const reason = await this.ui.setupInput({ waits: true, title: t('Why reopen the criteria'), description: t('The reviewer is woken up with exactly this text and proposes a new version; v{version} stays in force until you accept the new one.', { version: got.current.version }), signal: this.signal });
657
657
  if (!reason?.trim()) return 'later';
658
658
  const result = await reopenCriteria(mutate, { swarm_id: swarm, digest, reason: reason.trim() }, this.signal);
659
659
  await this.record({ action: 'criteria.reopen', project, swarm, digest, version: got.current.version, reason: reason.trim(), status: result.status, idempotency_key: result.idempotency_key });
@@ -683,9 +683,9 @@ export class RelayFlow {
683
683
  const { call, mutate } = this.#calls(project, root);
684
684
  const got = await call('task.get', { task_id: brief?.id }, this.signal), task = got?.task || got;
685
685
  if (task?.state !== 'needs_attention' || !task?.review?.pending) { this.ui.notice(t('The review of {task} no longer waits for you.', { task: line(brief?.id || '?', 60) })); return 'decided'; }
686
- const choice = await this.ui.setupChoice({ ...escalationCard({ task }), signal: this.signal });
686
+ const choice = await this.ui.setupChoice({ waits: true, ...escalationCard({ task }), signal: this.signal });
687
687
  if (choice === 'retry') {
688
- const why = await this.ui.setupInput({ title: t('Why another review'), description: t('Recorded in relayd as your decision (review.retry) with exactly this text; the reviewer gets a new review task.'), signal: this.signal });
688
+ const why = await this.ui.setupInput({ waits: true, title: t('Why another review'), description: t('Recorded in relayd as your decision (review.retry) with exactly this text; the reviewer gets a new review task.'), signal: this.signal });
689
689
  if (!why?.trim()) return 'later';
690
690
  const retried = await retryReview(mutate, { task_id: task.id, reason: why.trim() }, this.signal);
691
691
  await this.record({ action: 'review.retry', project, swarm, task: task.id, reason: why.trim(), status: retried.status, idempotency_key: retried.idempotency_key });
@@ -693,7 +693,7 @@ export class RelayFlow {
693
693
  return 'decided';
694
694
  }
695
695
  if (choice !== 'override') return 'later';
696
- const reason = await this.ui.setupInput({ title: t('Why you accept over the reviewer'), description: t('Recorded in relayd as your decision (review.override) with exactly this text.'), signal: this.signal });
696
+ const reason = await this.ui.setupInput({ waits: true, title: t('Why you accept over the reviewer'), description: t('Recorded in relayd as your decision (review.override) with exactly this text.'), signal: this.signal });
697
697
  if (!reason?.trim()) return 'later';
698
698
  const result = await overrideReview(mutate, { task_id: task.id, reason: reason.trim() }, this.signal);
699
699
  await this.record({ action: 'review.override', project, swarm, task: task.id, reason: reason.trim(), status: result.status, idempotency_key: result.idempotency_key });
@@ -723,7 +723,7 @@ export class RelayFlow {
723
723
  try {
724
724
  const { call } = this.#calls(project, root);
725
725
  const message = await call('msg.get', { message_id }, card.signal), block = decisionBlock(message);
726
- const choice = await this.ui.setupChoice({ ...questionCard({ message, block, task }), signal: card.signal });
726
+ const choice = await this.ui.setupChoice({ waits: true, ...questionCard({ message, block, task }), signal: card.signal });
727
727
  if (this.#elsewhere(key)) return 'decided';
728
728
  const done = result => {
729
729
  if (result.outcome === 'decided') this.ui.notice(t('Decision recorded as {adr} and sent to {to}.', { adr: result.adr || '?', to: result.to }));
@@ -733,14 +733,14 @@ export class RelayFlow {
733
733
  };
734
734
  if (choice?.startsWith('option:') && block) {
735
735
  const index = Number(choice.slice(7)), option = block.options[index]?.label; if (!option) return 'later';
736
- const note = await this.ui.setupInput({ title: t('Note for the ADR (optional)'), description: t('Why you choose «{option}». Enter with nothing skips it; it goes into the ADR decision.', { option }), signal: card.signal });
736
+ const note = await this.ui.setupInput({ waits: true, title: t('Note for the ADR (optional)'), description: t('Why you choose «{option}». Enter with nothing skips it; it goes into the ADR decision.', { option }), signal: card.signal });
737
737
  if (this.#elsewhere(key)) return 'decided';
738
738
  if (note === null || note === undefined) return 'later';
739
739
  return await this.#confirm(key, () => this.applyQuestion({ project, root, swarm, message_id, mode: 'option', option_index: index, text: note }), done);
740
740
  }
741
741
  if (choice === 'reply') {
742
742
  const to = message?.from?.role ? `role ${line(message.from.role, 60)}` : line(message?.from?.kind || '?', 40);
743
- const body = await this.ui.setupInput({ title: t('Your answer'), description: t('Exact text sent to {to} (msg.reply).', { to }), signal: card.signal });
743
+ const body = await this.ui.setupInput({ waits: true, title: t('Your answer'), description: t('Exact text sent to {to} (msg.reply).', { to }), signal: card.signal });
744
744
  if (this.#elsewhere(key)) return 'decided';
745
745
  if (!body?.trim()) return 'later';
746
746
  return await this.#confirm(key, () => this.applyQuestion({ project, root, swarm, message_id, mode: 'reply', text: body }), done);
@@ -856,7 +856,7 @@ export class RelayFlow {
856
856
  const id = line(task?.id || '?', 60), controlDir = path.join(hubRoot || '.', 'projects', project, 'ctl');
857
857
  const command = `relay --control-dir ${shellWord(controlDir)} task resume ${shellWord(String(task?.id || '?'))}`;
858
858
  const why = String(detail?.state || '').startsWith('blocked') || detail?.state === 'needs_attention' ? blockLabel(detail.state) : null;
859
- const choice = await this.ui.setupChoice({ ...blockedCard({ task: detail, reason, check, at: at ?? task?.since, why, retry: { command } }), signal: this.signal });
859
+ const choice = await this.ui.setupChoice({ waits: true, ...blockedCard({ task: detail, reason, check, at: at ?? task?.since, why, retry: { command } }), signal: this.signal });
860
860
  if (choice !== 'retry') return 'later';
861
861
  this.ui.notice(t('Retry it yourself in your terminal, exactly: {command}', { command }));
862
862
  await this.record({ action: 'blocked.shown', project, swarm, task: task?.id || null, check: check || null, reason: line(reason || '', 200) });
@@ -876,7 +876,7 @@ export class RelayFlow {
876
876
  const review = await this.#finalReview({ root: root || this.current?.hub_root, project, tasks });
877
877
  await this.state.setSwarm(project, swarm, { finished_at: new Date().toISOString() });
878
878
  await this.record({ action: 'swarm.finished', project, swarm, branch, base_commit: base || null, counts, ok, evidence: evidence.length });
879
- const choice = await this.ui.setupChoice({ ...finishCard({ swarm, objective: info.objective || view?.objective, branch, counts, diffStat: stat, base: base || '?', ok, evidence, review }), signal: this.signal });
879
+ const choice = await this.ui.setupChoice({ waits: true, ...finishCard({ swarm, objective: info.objective || view?.objective, branch, counts, diffStat: stat, base: base || '?', ok, evidence, review }), signal: this.signal });
880
880
  if (choice === 'diff' && base) {
881
881
  const diff = await this.git.diff(repo, base, branch);
882
882
  this.ui.commandResult?.('relay', 'diff', (diff.truncated ? t('Diff of {branch} ({count} more lines not shown; git diff {base}..{branch})', { branch, count: diff.truncated, base: base.slice(0, 8) }) + '\n\n' : '') + diff.text);
@@ -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) {
@@ -280,7 +280,7 @@ export class InteractiveController {
280
280
  if(state.reason==='authentication' && loginProvider)items.push({id:'login',label:t('Sign in to the selected provider'),description:loginProvider});
281
281
  items.push({id:'choose',label:t('Choose another connection'),description:t('Open the provider and workspace profile selector.')});
282
282
  const reason=CONNECTION_REASON_HINTS[state.reason];
283
- const choice=await this.ui.setupChoice({title:t(state.confirmedUnchanged?'Connection change not applied':'Connection status needs review'),description:t(state.message)+(reason?'\n\n'+t(reason):''),items});
283
+ const choice=await this.ui.setupChoice({waits:true,title:t(state.confirmedUnchanged?'Connection change not applied':'Connection status needs review'),description:t(state.message)+(reason?'\n\n'+t(reason):''),items});
284
284
  if(this.closed)return;
285
285
  if(choice==='retry' && this.connections.canRetry(id))await this.submit(`/${action.command} ${action.argument}`);
286
286
  else if(choice==='login')await this.submit(`/login ${loginProvider}`);
@@ -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"));