@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/README.md +1 -1
- package/package.json +1 -1
- package/src/agent-status.mjs +106 -0
- package/src/agent.mjs +8 -2
- package/src/cli.mjs +65 -11
- package/src/finding-recorder.mjs +126 -0
- package/src/findings.mjs +63 -0
- package/src/house.mjs +16 -5
- package/src/i18n/cli-es.mjs +1 -1
- package/src/i18n/es.mjs +23 -1
- package/src/operations.mjs +10 -0
- package/src/permission-rules.mjs +3 -2
- package/src/permissions.mjs +75 -10
- package/src/plugin-candidate-child.mjs +204 -0
- package/src/plugin-candidate-runner.mjs +419 -0
- package/src/plugin-config.mjs +34 -5
- package/src/plugin-contract.mjs +309 -0
- package/src/plugin-controls.mjs +238 -32
- package/src/plugin-repair.mjs +981 -0
- package/src/plugin-trust.mjs +14 -4
- package/src/plugin-workbench.mjs +675 -0
- package/src/plugins.mjs +115 -24
- package/src/relay/flow.mjs +14 -14
- package/src/remote-permissions.mjs +7 -0
- package/src/scoped-policy.mjs +7 -1
- package/src/sessions.mjs +5 -1
- package/src/subagents.mjs +3 -3
- package/src/tools.mjs +17 -2
- package/src/tui/controller.mjs +2 -2
- package/src/tui/discovery.mjs +2 -2
- package/src/tui/screen.mjs +33 -14
- package/src/work-plan.mjs +77 -3
- package/src/workflows/chrome-page-task.mjs +8 -3
- package/src/workflows/chrome-repair.mjs +88 -0
- package/src/workflows/chrome-task.mjs +14 -5
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
213
|
-
|
|
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)
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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) {
|
package/src/relay/flow.mjs
CHANGED
|
@@ -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
|
|
package/src/scoped-policy.mjs
CHANGED
|
@@ -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
|
-
|
|
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();
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/tui/controller.mjs
CHANGED
|
@@ -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
|
}
|
package/src/tui/discovery.mjs
CHANGED
|
@@ -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"));
|