@milpa/comal 0.2.7 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- 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 +21 -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/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 +1 -1
- package/src/tui/discovery.mjs +2 -2
- package/src/tui/screen.mjs +12 -9
- 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) {
|
|
@@ -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
|
@@ -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"));
|
package/src/tui/screen.mjs
CHANGED
|
@@ -401,13 +401,16 @@ export class TerminalScreen {
|
|
|
401
401
|
const options = permission.choices.map((choice, index) => index === permission.choice ? (choice === 'no' ? tone.warning(`❯ ${labels[choice]}`) : tone.good(`❯ ${labels[choice]}`)) : ` ${labels[choice]}`);
|
|
402
402
|
return [clip(t(' Always = save rule {v0}', { v0: permission.remember.rule }), columns - 1), ' ' + options.join(' '), tone.muted(clip(t(' Enter confirms · y once · {v0}u always for me · n/Esc denies', { v0: permission.choices.includes('project') ? t('p this project · ') : '' }), columns - 1))];
|
|
403
403
|
}
|
|
404
|
-
|
|
405
|
-
|
|
404
|
+
// onOutcome(why) says why the card closed — 'allowed', 'no' (the human's plain No), 'edit', 'interrupt',
|
|
405
|
+
// 'aborted', 'closed' or 'unknown' — without changing what confirm resolves to.
|
|
406
|
+
async confirm(name, input, signal, { summary, remember, edit, onOutcome } = {}) {
|
|
407
|
+
const tell = why => { try { onOutcome?.(why); } catch {} };
|
|
408
|
+
if (signal?.aborted || this.closed) { tell(signal?.aborted ? 'aborted' : 'closed'); return false; }
|
|
406
409
|
if (this.selecting) this.setSelection(false);
|
|
407
410
|
return measureHumanWait(new Promise(resolve => {
|
|
408
411
|
let settled=false;
|
|
409
|
-
const finish = allowed => { if(settled)return;settled=true;signal?.removeEventListener('abort', abort);if(this.permission===question)this.permission = null;this.schedule();resolve(allowed); };
|
|
410
|
-
const abort = () => finish(false);
|
|
412
|
+
const finish = (allowed, why = allowed ? 'allowed' : 'unknown') => { if(settled)return;settled=true;signal?.removeEventListener('abort', abort);if(this.permission===question)this.permission = null;this.schedule();tell(why);resolve(allowed); };
|
|
413
|
+
const abort = () => finish(false, 'aborted');
|
|
411
414
|
// remember: { rule, project } ofrece «sí, siempre»; la regla se muestra antes de elegir.
|
|
412
415
|
const choices = remember?.rule ? ['once', ...(remember.project ? ['project'] : []), 'user', 'no'] : null;
|
|
413
416
|
const question = { name, input, summary, edit: edit && typeof edit.field === 'string' && typeof edit.text === 'string' ? { field: edit.field, text: edit.text } : null, allowed: false, scroll: 0, finish, ...(choices ? { remember, choices, choice: choices.length - 1 } : {}) };
|
|
@@ -418,7 +421,7 @@ export class TerminalScreen {
|
|
|
418
421
|
// pasa al composer con una instrucción. El humano lo corrige y lo envía; el modelo
|
|
419
422
|
// vuelve a proponer la operación y la tarjeta nueva muestra el texto exacto otra vez.
|
|
420
423
|
editPermission() {
|
|
421
|
-
const { name, edit } = this.permission; this.permission.finish(false);
|
|
424
|
+
const { name, edit } = this.permission; this.permission.finish(false, 'edit');
|
|
422
425
|
const before = this.composer.text;
|
|
423
426
|
try {
|
|
424
427
|
this.composer.setText(before ? before + '\n\n' : '');
|
|
@@ -493,15 +496,15 @@ export class TerminalScreen {
|
|
|
493
496
|
return;
|
|
494
497
|
}
|
|
495
498
|
if (key.ctrl && name === 'c' && this.overlay?.kind === 'palette' && !this.permission) { this.closeOverlay(); return; }
|
|
496
|
-
if (key.ctrl && name === 'c') { if (this.permission) this.permission.finish(false); if (this.busy) await this.callbacks.onAction?.('interrupt'); else if (this.composer.text) { this.composer.clear(); this.draftChanged(); } else await this.callbacks.onAction?.('exit'); return; }
|
|
499
|
+
if (key.ctrl && name === 'c') { if (this.permission) this.permission.finish(false, 'interrupt'); if (this.busy) await this.callbacks.onAction?.('interrupt'); else if (this.composer.text) { this.composer.clear(); this.draftChanged(); } else await this.callbacks.onAction?.('exit'); return; }
|
|
497
500
|
if (this.permission) {
|
|
498
501
|
const permission = this.permission, answer = choice => choice === 'project' || choice === 'user' ? { allowed: true, remember: choice } : choice === 'once';
|
|
499
|
-
if (name === 'escape' || name === 'n') permission.finish(false);
|
|
502
|
+
if (name === 'escape' || name === 'n') permission.finish(false, 'no');
|
|
500
503
|
else if (name === 'y') permission.finish(true);
|
|
501
504
|
else if (permission.choices && ((name === 'p' && permission.choices.includes('project')) || name === 'u')) permission.finish(answer(name === 'p' ? 'project' : 'user'));
|
|
502
505
|
else if (permission.choices && (name === 'left' || name === 'right' || name === 'tab')) permission.choice = (permission.choice + (name === 'left' ? permission.choices.length - 1 : 1)) % permission.choices.length;
|
|
503
506
|
else if (name === 'left' || name === 'right' || name === 'tab') permission.allowed = !permission.allowed;
|
|
504
|
-
else if (name === 'return') permission.
|
|
507
|
+
else if (name === 'return') { const value = permission.choices ? answer(permission.choices[permission.choice]) : permission.allowed; permission.finish(value, value ? 'allowed' : 'no'); }
|
|
505
508
|
else if (name === 'pageup') this.permission.scroll = Math.max(0, this.permission.scroll - 10);
|
|
506
509
|
else if (name === 'pagedown') this.permission.scroll += 10;
|
|
507
510
|
else if (name === 'r') { this.permission.raw = !(this.permission.raw ?? this.rawView); this.permission.scroll = 0; }
|
|
@@ -823,7 +826,7 @@ export class TerminalScreen {
|
|
|
823
826
|
if (this.closed) return; this.closed = true;
|
|
824
827
|
this.agentStatus?.close();
|
|
825
828
|
this.clearSetup();
|
|
826
|
-
this.permission?.finish(false); clearInterval(this.timer); clearTimeout(this.renderTimer); this.decoder?.close();
|
|
829
|
+
this.permission?.finish(false, 'closed'); clearInterval(this.timer); clearTimeout(this.renderTimer); this.decoder?.close();
|
|
827
830
|
if (this.onData) this.input.off('data', this.onData); if (this.onResize) this.output.off('resize', this.onResize);
|
|
828
831
|
if (this.started) { this.input.setRawMode?.(this.originalRaw); this.input.pause(); if (!this.suspended) this.output.write('\x1b[0m' + MOUSE_OFF + '\x1b[?2004l\x1b[?25h\x1b[?1049l'); }
|
|
829
832
|
this.resolveFinished();
|