@milpa/comal 0.2.0 → 0.2.2
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/cli.mjs +23 -12
- package/src/i18n/es.mjs +96 -2
- package/src/permissions.mjs +2 -2
- package/src/plugin-catalog.mjs +19 -4
- package/src/plugins/omarchy/catalog.mjs +96 -0
- package/src/plugins/omarchy/environment.mjs +78 -0
- package/src/plugins/omarchy/guide.mjs +54 -0
- package/src/plugins/omarchy/index.mjs +213 -0
- package/src/plugins/omarchy/runner.mjs +62 -0
- package/src/plugins/omarchy/text.mjs +26 -0
- package/src/plugins/omarchy/tiers.mjs +157 -0
- package/src/plugins/open-relay/actions.mjs +155 -10
- package/src/plugins/open-relay/client.mjs +93 -35
- package/src/plugins/open-relay/decision.mjs +88 -0
- package/src/plugins/open-relay/dossier.mjs +23 -5
- package/src/plugins/open-relay/index.mjs +55 -25
- package/src/plugins/open-relay/notices.mjs +4 -1
- package/src/plugins/open-relay/panel.mjs +56 -0
- package/src/plugins/open-relay/watch.mjs +179 -85
- package/src/plugins.mjs +12 -1
- package/src/private-fs.mjs +11 -5
- package/src/read-only-check.mjs +6 -3
- package/src/source-ownership.mjs +4 -1
- package/src/swarm-view.mjs +53 -0
- package/src/tui/connection-transition.mjs +10 -0
- package/src/tui/controller.mjs +32 -9
- package/src/tui/discovery.mjs +4 -3
- package/src/tui/screen.mjs +26 -2
- package/src/tui/swarm-panel.mjs +206 -0
- package/src/windows-private.ps1 +2 -0
package/src/plugins.mjs
CHANGED
|
@@ -33,6 +33,7 @@ export class PluginRuntime {
|
|
|
33
33
|
#services = new Map();
|
|
34
34
|
#queue = Promise.resolve();
|
|
35
35
|
#closing = false;
|
|
36
|
+
#serviceWatchers = new Set();
|
|
36
37
|
// notify(notice): el host encola avisos de plugins (plugin-notices.mjs); sin él, ctx.notify responde unavailable.
|
|
37
38
|
constructor({ cwd, house, services = {}, configPath, onEvent = () => {}, notify }) {
|
|
38
39
|
Object.assign(this, { cwd, house, onEvent, notify });
|
|
@@ -41,7 +42,16 @@ export class PluginRuntime {
|
|
|
41
42
|
}
|
|
42
43
|
async #notify(notice) { if (typeof this.notify !== 'function') return { status: 'unavailable' }; try { return await this.notify(notice); } catch (error) { return { status: 'failed', error: String(error.message).slice(0, 200) }; } }
|
|
43
44
|
#exclusive(task) { const next = this.#queue.catch(() => {}).then(task); this.#queue = next; return next; }
|
|
44
|
-
async #event(event) { try { await this.onEvent({ type: 'plugin_status', ...event }); } catch {} }
|
|
45
|
+
async #event(event) { try { await this.onEvent({ type: 'plugin_status', ...event }); } catch {} this.#servicesChanged(); }
|
|
46
|
+
#servicesChanged() { for (const listener of [...this.#serviceWatchers]) try { listener(); } catch {} }
|
|
47
|
+
/**
|
|
48
|
+
* Lectura del HOST de un servicio publicado (ctx.provide), p. ej. para un panel de la TUI.
|
|
49
|
+
* Devuelve { owner, value } o null; el host debe comprobar el dueño y tratar los datos
|
|
50
|
+
* como no confiables. No concede nada al plugin ni al modelo.
|
|
51
|
+
*/
|
|
52
|
+
service(name) { const entry = this.#services.get(name); return entry ? Object.freeze({ owner: entry.owner, value: entry.value }) : null; }
|
|
53
|
+
/** Avisa (sin argumentos) cuando cambia el estado de los plugins o sus servicios. Devuelve la baja. */
|
|
54
|
+
watchServices(listener) { if (typeof listener !== 'function') throw new TypeError('watchServices requires a function.'); this.#serviceWatchers.add(listener); return () => this.#serviceWatchers.delete(listener); }
|
|
45
55
|
#definition(definition) {
|
|
46
56
|
if (!definition || !idPattern.test(definition.id) || definition.id.length > 24 || typeof definition.version !== 'string' || !definition.version || typeof definition.activate !== 'function') throw new Error('Manifiesto de plugin inválido: requiere id, version y activate.');
|
|
47
57
|
const names = list => { if (!Array.isArray(list) || list.length > 32 || list.some(name => typeof name !== 'string' || !name || name.length > 128) || new Set(list).size !== list.length) throw new Error('requires/provides de plugin inválidos.'); return [...list]; };
|
|
@@ -309,6 +319,7 @@ export class PluginRuntime {
|
|
|
309
319
|
this.house.registry.replace(scopes.flatMap(scope => [...scope.operations.keys()]), []);
|
|
310
320
|
for (const scope of scopes) { this.#known.get(scope.id).state = 'disabled'; try { await this.#retire(scope); } catch (error) { errors.push(error); } }
|
|
311
321
|
for (const [name, service] of this.#services) if (service.owner !== 'host') this.#services.delete(name);
|
|
322
|
+
this.#servicesChanged(); this.#serviceWatchers.clear();
|
|
312
323
|
if (errors.length) throw new AggregateError(errors, 'Falló limpieza de plugins.');
|
|
313
324
|
});
|
|
314
325
|
}
|
package/src/private-fs.mjs
CHANGED
|
@@ -5,21 +5,27 @@ import path from 'node:path';
|
|
|
5
5
|
|
|
6
6
|
const fail=message=>Object.assign(new Error(message),{code:'PRIVATE_STATE_UNSAFE'});
|
|
7
7
|
let broker,starting,sequence=0;
|
|
8
|
+
// PowerShell puede tardar en arrancar (antivirus, runners en frío): su arranque tiene su propio plazo.
|
|
9
|
+
const STARTUP_MS=60000,REQUEST_MS=15000;
|
|
8
10
|
async function windowsBroker(){
|
|
9
11
|
if(broker)return broker;if(starting)return starting;
|
|
10
12
|
starting=(async()=>{
|
|
11
13
|
const script=await readFile(new URL('./windows-private.ps1',import.meta.url),'utf8'),program=path.join(process.env.SystemRoot||'C:\\Windows','System32','WindowsPowerShell','v1.0','powershell.exe');
|
|
12
14
|
const child=spawn(program,['-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-EncodedCommand',Buffer.from(script,'utf16le').toString('base64')],{stdio:['pipe','pipe','pipe'],windowsHide:true});
|
|
13
|
-
const pending=new Map(),lines=createInterface({input:child.stdout});let diagnostic='';
|
|
15
|
+
const pending=new Map(),lines=createInterface({input:child.stdout});let diagnostic='',ready;
|
|
16
|
+
const readiness=new Promise((resolve,reject)=>{ready={resolve,reject};});
|
|
17
|
+
const startup=setTimeout(()=>{ready.reject(fail('Windows ACL helper did not start within its deadline. '+diagnostic.slice(0,300)));child.kill();},STARTUP_MS);
|
|
14
18
|
const references=active=>{const method=active?'ref':'unref';child[method]();for(const stream of[child.stdin,child.stdout,child.stderr])stream[method]?.();};
|
|
15
19
|
child.stderr.on('data',data=>{if(diagnostic.length<2000)diagnostic+=data;});child.stdin.on('error',()=>{});
|
|
16
|
-
const stopped=()=>{for(const job of pending.values()){clearTimeout(job.timer);job.reject(fail('Windows private-state inspection stopped. '+diagnostic.slice(0,300)));}pending.clear();if(broker?.child===child)broker=undefined;};
|
|
20
|
+
const stopped=()=>{clearTimeout(startup);ready.reject(fail('Windows private-state inspection stopped. '+diagnostic.slice(0,300)));for(const job of pending.values()){clearTimeout(job.timer);job.reject(fail('Windows private-state inspection stopped. '+diagnostic.slice(0,300)));}pending.clear();if(broker?.child===child)broker=undefined;};
|
|
17
21
|
child.on('error',stopped);child.on('close',stopped);
|
|
18
|
-
lines.on('line',line=>{let result;try{result=JSON.parse(line);}catch{stopped();child.kill();return;}const job=pending.get(result.id);if(!job)return;clearTimeout(job.timer);pending.delete(result.id);if(!pending.size)references(false);result.ok?job.resolve():job.reject(fail(result.error||'Windows private-state ACL does not verify.'));});
|
|
22
|
+
lines.on('line',line=>{let result;try{result=JSON.parse(line);}catch{stopped();child.kill();return;}if(result?.ready===true){clearTimeout(startup);ready.resolve();return;}const job=pending.get(result.id);if(!job)return;clearTimeout(job.timer);pending.delete(result.id);if(!pending.size)references(false);result.ok?job.resolve():job.reject(fail(result.error||'Windows private-state ACL does not verify.'));});
|
|
19
23
|
const result={child,request:(action,file)=>new Promise((resolve,reject)=>{
|
|
20
|
-
const id=++sequence;references(true);const timer=setTimeout(()=>{pending.delete(id);reject(fail('Windows ACL inspection exceeded its deadline.'));if(!pending.size)references(false);},
|
|
24
|
+
const id=++sequence;references(true);const timer=setTimeout(()=>{pending.delete(id);reject(fail('Windows ACL inspection exceeded its deadline.'));if(!pending.size)references(false);},REQUEST_MS);pending.set(id,{resolve,reject,timer});
|
|
21
25
|
child.stdin.write(JSON.stringify({id,action,path:path.resolve(file)})+'\n',error=>{if(error){clearTimeout(timer);pending.delete(id);reject(fail('Windows ACL inspection could not be sent.'));if(!pending.size)references(false);}});
|
|
22
|
-
})};
|
|
26
|
+
})};
|
|
27
|
+
try{await readiness;}catch(error){if(broker?.child===child)broker=undefined;throw error;}
|
|
28
|
+
references(false);broker=result;return result;
|
|
23
29
|
})();try{return await starting;}finally{starting=undefined;}
|
|
24
30
|
}
|
|
25
31
|
export async function ensurePrivateDirectory(directory){
|
package/src/read-only-check.mjs
CHANGED
|
@@ -33,10 +33,13 @@ export function readOnlyBashCheck(command) {
|
|
|
33
33
|
// Rechecking still goes through the live contract, guard and permission judge.
|
|
34
34
|
const CHROME_LAB_READS = new Set(['Status', 'Deployment', 'Targets', 'ReadDOM', 'Assert', 'Contexts', 'Console', 'WorkerDiagnostics'].map(name => `plugin__chrome-lab__${name}`));
|
|
35
35
|
export const chromeLabObservation = operation => CHROME_LAB_READS.has(operation);
|
|
36
|
-
// open-relay: lecturas del canal admin (sin mutación). Las acciones
|
|
37
|
-
//
|
|
38
|
-
const OPEN_RELAY_READS = new Set(['Status', 'Swarms', 'Swarm', 'Task', 'Inbox', 'Message', 'Plans', 'Plan', 'Jobs', 'Job', 'JobLogs', 'Receipt', 'Events', 'Dossier'].map(name => `plugin__open-relay__${name}`));
|
|
36
|
+
// open-relay: lecturas del canal admin (sin mutación). Las acciones (Reply, Ack,
|
|
37
|
+
// ApprovePlan, RejectPlan y Decide, que registra ADR) no son observaciones: pasan por la tarjeta.
|
|
38
|
+
const OPEN_RELAY_READS = new Set(['Status', 'Swarms', 'Swarm', 'Task', 'Inbox', 'Message', 'Plans', 'Plan', 'Jobs', 'Job', 'JobLogs', 'Receipt', 'Events', 'Dossier', 'Decisions', 'Decision'].map(name => `plugin__open-relay__${name}`));
|
|
39
39
|
export const openRelayObservation = operation => OPEN_RELAY_READS.has(operation);
|
|
40
|
+
// omarchy: catálogo, clasificación, lecturas revisadas y guía. Change y SystemChange no son observaciones.
|
|
41
|
+
const OMARCHY_READS = new Set(['Status', 'Commands', 'Classify', 'Read', 'Guide'].map(name => `plugin__omarchy__${name}`));
|
|
42
|
+
export const omarchyObservation = operation => OMARCHY_READS.has(operation);
|
|
40
43
|
export const readOnlyWorkCheck = (operation, args) => ['Read', 'Glob', 'Grep', 'ListDirectory', 'FileInfo', 'ChromeVerify','SourceVerify'].includes(operation) || operation==='GitInspect'&&Boolean(args)&&Object.keys(args).length===0 || CHROME_LAB_READS.has(operation) || (operation === 'Bash' && Boolean(readOnlyBashCheck(args?.command)));
|
|
41
44
|
export const RUNTIME_CHECK_IDS = Object.freeze(['target_id','pageId','page_id','context_id','contextId']);
|
|
42
45
|
export const runtimeBoundReadCheck = (operation, args) => readOnlyWorkCheck(operation,args) && (RUNTIME_CHECK_IDS.some(key => Object.hasOwn(args || {},key)) || operation==='plugin__chrome-lab__WorkerDiagnostics'&&Object.hasOwn(args||{},'connection_id')&&Object.hasOwn(args||{},'browser_endpoint_id'));
|
package/src/source-ownership.mjs
CHANGED
|
@@ -22,8 +22,11 @@ export class SourceOwnership {
|
|
|
22
22
|
}
|
|
23
23
|
contains(file){const r=path.relative(this.directory,file);return r===''||r!=='..'&&!r.startsWith('..'+path.sep)&&!path.isAbsolute(r);}
|
|
24
24
|
async prepareDirectory(){
|
|
25
|
+
await mkdir(path.dirname(this.directory),{recursive:true,mode:0o700});
|
|
26
|
+
// El HOME puede llegar por un enlace del sistema (macOS: /tmp, /var → /private/…).
|
|
27
|
+
// Se resuelve el antecesor del registro; el padre (~/.comal) sigue sin poder ser enlace.
|
|
28
|
+
if(!this.canonical){const grand=await realpath(path.dirname(path.dirname(this.directory)));this.directory=path.join(grand,path.basename(path.dirname(this.directory)),path.basename(this.directory));this.canonical=true;}
|
|
25
29
|
const parent=path.dirname(this.directory);
|
|
26
|
-
await mkdir(parent,{recursive:true,mode:0o700});
|
|
27
30
|
if(await realpath(parent)!==parent)throw fail('Native source lock registry has a noncanonical parent.');
|
|
28
31
|
try{await ensurePrivateDirectory(this.directory);}catch(error){if(error.code==='PRIVATE_STATE_UNSAFE')throw fail('Native source lock registry is not private.');throw error;}
|
|
29
32
|
const info=await lstat(this.directory);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Fase 4 de open-relay: el host lleva al panel /swarm de la TUI la foto que el plugin
|
|
2
|
+
// publica con ctx.provide('open-relay.swarm-view'). Sin turnos del modelo ni herramientas:
|
|
3
|
+
// el plugin avisa (su vigilancia de events.wait vio un cambio), el host copia la foto como
|
|
4
|
+
// datos planos y la pantalla la vuelve a limpiar al pintarla. Sólo se acepta el servicio si
|
|
5
|
+
// su dueño es el plugin open-relay. Si el plugin no está, está apagado o falló, la vista lo
|
|
6
|
+
// dice para que el panel explique por qué y cómo configurarlo.
|
|
7
|
+
import { SWARM_VIEW_SERVICE } from './plugins/open-relay/panel.mjs';
|
|
8
|
+
export { SWARM_VIEW_SERVICE };
|
|
9
|
+
export const SWARM_VIEW_PLUGIN = 'open-relay';
|
|
10
|
+
const MAX_BYTES = 512 * 1024;
|
|
11
|
+
|
|
12
|
+
function plain(view) {
|
|
13
|
+
if (!view || typeof view !== 'object' || Array.isArray(view)) return null;
|
|
14
|
+
let text; try { text = JSON.stringify(view); } catch { return null; }
|
|
15
|
+
if (typeof text !== 'string' || Buffer.byteLength(text) > MAX_BYTES) return null;
|
|
16
|
+
return JSON.parse(text);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Por qué no hay foto, según el estado del plugin en el runtime. */
|
|
20
|
+
export function swarmViewUnavailable(status, plugin = SWARM_VIEW_PLUGIN) {
|
|
21
|
+
const item = status?.plugins?.find(entry => entry.id === plugin);
|
|
22
|
+
if (!item) return { available: false, reason: 'not_configured' };
|
|
23
|
+
const reason = { disabled: 'disabled', pending_trust: 'pending_trust', failed: 'failed', loading: 'loading', registered: 'disabled', active: 'no_service' }[item.state] || 'disabled';
|
|
24
|
+
return { available: false, reason, ...(item.error ? { error: String(item.error).slice(0, 600) } : {}) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Conecta el servicio con onView(view). Devuelve { refresh, close }. onView recibe
|
|
29
|
+
* { available:true, ...foto } o { available:false, reason, error? }.
|
|
30
|
+
*/
|
|
31
|
+
export function connectSwarmView({ plugins, onView, plugin = SWARM_VIEW_PLUGIN }) {
|
|
32
|
+
let bound, unsubscribe = null, closed = false, last = null;
|
|
33
|
+
const emit = view => { if (closed) return; const key = JSON.stringify(view); if (key === last) return; last = key; try { onView(view); } catch {} };
|
|
34
|
+
const bind = () => {
|
|
35
|
+
if (closed) return;
|
|
36
|
+
const service = plugins.service(SWARM_VIEW_SERVICE), value = service?.owner === plugin ? service.value : null;
|
|
37
|
+
if (value && value === bound) return;
|
|
38
|
+
try { unsubscribe?.(); } catch {}
|
|
39
|
+
unsubscribe = null; bound = value;
|
|
40
|
+
if (!value || typeof value.subscribe !== 'function' || typeof value.snapshot !== 'function') { emit(swarmViewUnavailable(plugins.status(), plugin)); return; }
|
|
41
|
+
const deliver = view => { if (closed || bound !== value) return; const copy = plain(view); emit(copy ? { ...copy, available: true } : { available: false, reason: 'invalid' }); };
|
|
42
|
+
try { unsubscribe = value.subscribe(deliver); deliver(value.snapshot()); }
|
|
43
|
+
catch (error) { emit({ available: false, reason: 'failed', error: String(error?.message || error).slice(0, 600) }); }
|
|
44
|
+
};
|
|
45
|
+
const stop = plugins.watchServices(bind);
|
|
46
|
+
bind();
|
|
47
|
+
return {
|
|
48
|
+
refresh: bind,
|
|
49
|
+
/** La foto actual, sin esperar al próximo aviso (p. ej. /swarm fuera de la TUI). */
|
|
50
|
+
current() { const service = plugins.service(SWARM_VIEW_SERVICE); if (service?.owner !== plugin) return swarmViewUnavailable(plugins.status(), plugin); try { const copy = plain(service.value.snapshot()); return copy ? { ...copy, available: true } : { available: false, reason: 'invalid' }; } catch (error) { return { available: false, reason: 'failed', error: String(error?.message || error).slice(0, 600) }; } },
|
|
51
|
+
close() { closed = true; stop(); try { unsubscribe?.(); } catch {} unsubscribe = null; },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -14,6 +14,16 @@ const MESSAGES = Object.freeze({
|
|
|
14
14
|
uncertain: 'Connection change could not be confirmed. Check the active connection before trying again.',
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
// Next step shown with a failed change. Built from the classified reason only,
|
|
18
|
+
// never from the upstream error text.
|
|
19
|
+
export const CONNECTION_REASON_HINTS = Object.freeze({
|
|
20
|
+
authentication: 'Sign in or check the configured credential environment variable.',
|
|
21
|
+
timeout: 'The model server timed out.',
|
|
22
|
+
unavailable: 'The model server could not be reached.',
|
|
23
|
+
unsupported: 'The active model does not support this setting.',
|
|
24
|
+
unknown: 'The change could not be verified.',
|
|
25
|
+
});
|
|
26
|
+
|
|
17
27
|
function label(value) {
|
|
18
28
|
if (typeof value !== 'string') return '';
|
|
19
29
|
// Never render terminal escapes or URL credentials/query strings in a status.
|
package/src/tui/controller.mjs
CHANGED
|
@@ -11,9 +11,9 @@ import { Composer } from './composer.mjs';
|
|
|
11
11
|
import { isRemoteCommand } from '../remote-commands.mjs';
|
|
12
12
|
import { randomUUID } from 'node:crypto';
|
|
13
13
|
import { connectionOptions } from './connection-options.mjs';
|
|
14
|
-
import { ConnectionTransitions, connectionAction, connectionFailureReason } from './connection-transition.mjs';
|
|
14
|
+
import { ConnectionTransitions, connectionAction, connectionFailureReason, CONNECTION_REASON_HINTS } from './connection-transition.mjs';
|
|
15
15
|
|
|
16
|
-
const IMMEDIATE = new Set(['raw', 'cloud', 'remote-control', 'rc', 'help', 'copy', 'select', 'status', 'context', 'tools', 'house', 'state', 'audit', 'board', 'tasks', 'work', 'operations', 'plugins', 'sessions', 'resume', 'clear', 'model', 'models', 'effort', 'capabilities', 'login', 'logout', 'permissions', 'autonomy', 'rename', 'table', 'queue', 'agents', 'mcp','list-agents','inbox','providers','provider','config','max-tokens','context-window', 'exit', 'quit']);
|
|
16
|
+
const IMMEDIATE = new Set(['raw', 'cloud', 'remote-control', 'rc', 'help', 'copy', 'select', 'status', 'context', 'tools', 'house', 'state', 'audit', 'board', 'tasks', 'work', 'swarm', 'operations', 'plugins', 'sessions', 'resume', 'clear', 'model', 'models', 'effort', 'capabilities', 'login', 'logout', 'permissions', 'autonomy', 'rename', 'table', 'queue', 'agents', 'mcp','list-agents','inbox','providers','provider','config','max-tokens','context-window', 'exit', 'quit']);
|
|
17
17
|
|
|
18
18
|
export class InteractiveController {
|
|
19
19
|
constructor(ui, backend) { this.ui = ui; this.backend = backend; this.closed = false; this.switching = false; this.draftTimer = null; this.pendingDraft = null; }
|
|
@@ -27,6 +27,9 @@ export class InteractiveController {
|
|
|
27
27
|
return this.ui.finished;
|
|
28
28
|
}
|
|
29
29
|
modalOverlay() { return this.ui.hasModalOverlay?.() ?? Boolean(this.ui.overlay); }
|
|
30
|
+
// Despertar por avisos/mensajes entre sesiones: el panel /swarm (sólo lectura) no lo bloquea;
|
|
31
|
+
// el resto de superposiciones modales sí, como siempre. El turno se pinta detrás del panel.
|
|
32
|
+
wakeBlocked() { return this.modalOverlay() && this.ui.overlay?.kind !== 'swarm'; }
|
|
30
33
|
refreshDiscovery() { const context = this.backend.discovery?.(); if (context) this.ui.setDiscovery?.(context); }
|
|
31
34
|
attachSession(session) {
|
|
32
35
|
this.session = session; this.ui.loadSession(session); this.ui.updateMetadata(this.backend.metadata());
|
|
@@ -55,7 +58,7 @@ export class InteractiveController {
|
|
|
55
58
|
if (this.queue.items.length || this.queue.interrupted.length) this.ui.notice(t("The recovered queue is paused. Check F4 before resuming; interrupted messages were not resent."));
|
|
56
59
|
}
|
|
57
60
|
kickPeers(){
|
|
58
|
-
if(this.peerProcessing || this.ui.busy || this.runner.processing || this.queue.paused || this.queue.items.length || this.
|
|
61
|
+
if(this.peerProcessing || this.ui.busy || this.runner.processing || this.queue.paused || this.queue.items.length || this.wakeBlocked() || this.ui.selecting || this.ui.editingQueue || this.switching || this.closed)return;
|
|
59
62
|
this.peerProcessing=(async()=>{
|
|
60
63
|
if(!await this.backend.hasPeerMessages() || this.closed)return;
|
|
61
64
|
this.ui.setBusy(true);
|
|
@@ -191,6 +194,14 @@ export class InteractiveController {
|
|
|
191
194
|
}
|
|
192
195
|
if(command==='work'&&(arg.trim()==='recover'||arg.trim().startsWith('review '))){await this.requireIdle();const output=await this.backend.control(command,arg);if(output)this.showOutput(command,arg,output);return;}
|
|
193
196
|
if (['board', 'tasks', 'work'].includes(command)) { this.ui.showBoard(JSON.parse(await this.backend.control('board', ''))); return; }
|
|
197
|
+
// Panel /swarm: sólo presentación; los datos llegan solos del plugin open-relay (ctx.provide).
|
|
198
|
+
if (command === 'swarm') {
|
|
199
|
+
const value = arg.trim();
|
|
200
|
+
if (!value) { this.ui.toggleSwarm(); return; }
|
|
201
|
+
const strip = /^strip(?:\s+(on|off))?$/.exec(value);
|
|
202
|
+
if (!strip) throw new Error(t('Use /swarm or /swarm strip [on|off].'));
|
|
203
|
+
this.ui.setSwarmStrip(strip[1] ? strip[1] === 'on' : undefined); return;
|
|
204
|
+
}
|
|
194
205
|
if(connectionAction(`/${command} ${arg}`)){await this.requireIdle();return this.connectionControl(command,arg);}
|
|
195
206
|
if (['cloud', 'model', 'effort', 'provider', 'login', 'logout'].includes(command) && (arg.trim() || ['login', 'logout'].includes(command))) await this.requireIdle();
|
|
196
207
|
if (['max-tokens', 'context-window'].includes(command) && arg.trim() || command === 'config' && /^(?:set|save)\b/.test(arg.trim())) await this.requireIdle();
|
|
@@ -228,13 +239,13 @@ export class InteractiveController {
|
|
|
228
239
|
this.connections.admit(id,requested);const attempt=this.connections.start(id);this.switching=true;this.syncConnectionStatus();
|
|
229
240
|
let failure;
|
|
230
241
|
try{
|
|
231
|
-
const output=await this.backend.control(command,argument);if(output)this.
|
|
242
|
+
const output=await this.backend.control(command,argument);if(output)this.showOutput(command,argument,output);
|
|
232
243
|
this.connections.complete(id,attempt,{outcome:'applied',active:this.backend.metadata()});
|
|
233
244
|
try{await this.backend.refreshConnections?.();}catch{}
|
|
234
245
|
}catch(error){
|
|
235
246
|
const reason=connectionFailureReason(error);this.connections.complete(id,attempt,{outcome:reason==='cancelled'?'cancelled':'failed',active:this.backend.metadata(),unchanged:before===this.connectionIdentity() && ['model','provider','effort'].includes(command),error});failure=error;
|
|
236
247
|
}finally{this.switching=false;this.ui.updateMetadata(this.backend.metadata());this.refreshDiscovery();this.syncConnectionStatus();this.runner.kick();}
|
|
237
|
-
if(failure){const state=this.connections.get(id);this.ui.notice(
|
|
248
|
+
if(failure){const state=this.connections.get(id);this.ui.notice(this.connectionFailureText(state,requested),true);await this.recoverConnection(id,requested);}
|
|
238
249
|
}
|
|
239
250
|
scheduleConnectionRecovery(id,action){
|
|
240
251
|
const runner=this.runner;
|
|
@@ -244,16 +255,28 @@ export class InteractiveController {
|
|
|
244
255
|
if(this.runner===runner)setImmediate(()=>this.recoverConnection(id,action).catch(()=>{}));
|
|
245
256
|
});
|
|
246
257
|
}
|
|
258
|
+
// Login provider behind a /provider or /login target, if the session knows it.
|
|
259
|
+
connectionLoginProvider(action){
|
|
260
|
+
if(action.command==='login')return action.argument.split(/\s+/)[0];
|
|
261
|
+
if(action.command!=='provider')return null;
|
|
262
|
+
const discovery=this.backend.discovery?.() || {},profile=discovery.profiles?.find(p=>p.name===action.argument);
|
|
263
|
+
return profile?.authProvider || (discovery.authProviders?.some(p=>p.id===action.argument)?action.argument:null);
|
|
264
|
+
}
|
|
265
|
+
// One line: what was tried, that nothing changed, and the next step.
|
|
266
|
+
connectionFailureText(state,action){
|
|
267
|
+
const login=state.reason==='authentication'?this.connectionLoginProvider(action):null;
|
|
268
|
+
const hint=login?t('Sign in first with /login {v0}.',{v0:login}):CONNECTION_REASON_HINTS[state.reason]?t(CONNECTION_REASON_HINTS[state.reason]):'';
|
|
269
|
+
return `/${action.command} ${state.target}: ${t(state.message)}${hint?' '+hint:''}`;
|
|
270
|
+
}
|
|
247
271
|
async recoverConnection(id,action){
|
|
248
272
|
if(this.closed || this.ui.busy || this.runner.processing || this.modalOverlay() || this.ui.permission)return;
|
|
249
273
|
const state=this.connections.get(id);if(state?.state!=='failed')return;
|
|
250
274
|
const items=[{id:'keep',label:t('Keep the active connection'),description:t('Return to your draft without changing the connection.')}];
|
|
251
275
|
if(state.retryable)items.push({id:'retry',label:t('Retry this selection'),description:`/${action.command} ${state.target}`});
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
if(state.reason==='authentication' && action.command==='provider' && loginProvider)items.push({id:'login',label:t('Sign in to the selected provider'),description:loginProvider});
|
|
276
|
+
const loginProvider=action.command==='provider'?this.connectionLoginProvider(action):null;
|
|
277
|
+
if(state.reason==='authentication' && loginProvider)items.push({id:'login',label:t('Sign in to the selected provider'),description:loginProvider});
|
|
255
278
|
items.push({id:'choose',label:t('Choose another connection'),description:t('Open the provider and workspace profile selector.')});
|
|
256
|
-
const reason=
|
|
279
|
+
const reason=CONNECTION_REASON_HINTS[state.reason];
|
|
257
280
|
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});
|
|
258
281
|
if(this.closed)return;
|
|
259
282
|
if(choice==='retry' && this.connections.canRetry(id))await this.submit(`/${action.command} ${action.argument}`);
|
package/src/tui/discovery.mjs
CHANGED
|
@@ -16,7 +16,7 @@ const COMMANDS = [
|
|
|
16
16
|
['compact', '', "Derive ledger state without an LLM"], ['context', '', "Check the context budget"],
|
|
17
17
|
['state', '', "Request and current job status"], ['audit', '', "Verify ledger integrity"],
|
|
18
18
|
['board', '', "Open the task board"], ['work', 'resume|recover|review', "Continue work, recover Chrome or review a native failure locally"],
|
|
19
|
-
['tasks', '', "Open the task board"], ['model', 'ID', "Check or change the model"],
|
|
19
|
+
['tasks', '', "Open the task board"], ['swarm', '[strip [on|off]]', "Open the open-relay swarm panel"], ['model', 'ID', "Check or change the model"],
|
|
20
20
|
['models', '', "Choose a model from the current provider"],
|
|
21
21
|
['effort', "LEVEL", "Check or change the effort announced by the model"],
|
|
22
22
|
['capabilities', '[refresh]', "Support and provenance of capabilities"],
|
|
@@ -84,7 +84,7 @@ const COMMAND_KEYWORDS = Object.freeze({
|
|
|
84
84
|
operations: "contracts effects tools catalog catalog contratos efectos herramientas catálogo catalogo",
|
|
85
85
|
sessions: "sessions history resume recover sesiones historial retomar recuperar", resume: "sessions history resume recover continue sesiones historial retomar recuperar continuar",
|
|
86
86
|
compact: "memory projection projection derive summary context memoria proyección proyeccion derivar resumen contexto",
|
|
87
|
-
board: "kanban pending tasks progress kanban pendientes tareas progreso", work: "kanban checkpoint continue resume kanban checkpoint continuar retomar",
|
|
87
|
+
board: "kanban pending tasks progress kanban pendientes tareas progreso", swarm: "open-relay relay swarm kanban agents questions plans enjambre agentes preguntas planes", work: "kanban checkpoint continue resume kanban checkpoint continuar retomar",
|
|
88
88
|
effort: 'esfuerzo razonamiento pensar thinking', capabilities: "capabilities vision vision video images images support capacidades visión vision video imágenes imagenes soporte",
|
|
89
89
|
permissions: "modes security permissions authorize plan automatic automatic modos seguridad permisos autorizar plan automático automatico",
|
|
90
90
|
goal: "goal autonomous autonomous continue persistent objetivo autónomo autonomo continuar persistente", queue: "queue messages pending pause reorder cola mensajes pendientes pausar reordenar",
|
|
@@ -136,6 +136,7 @@ function argumentItems(command, args, context) {
|
|
|
136
136
|
if (command === 'work') return named(['resume', 'recover', 'review'], t("Continue work, recover Chrome or review a native failure locally"));
|
|
137
137
|
if (command === 'goal') return named(['status', 'pause', 'resume', 'clear'], t("Target control; you can also write a condition"));
|
|
138
138
|
if (command === 'queue') return named(['pause', 'resume', 'clear', 'edit'], t("Action on the queue"));
|
|
139
|
+
if (command === 'swarm') return named(['strip'], t("Toggle the compact swarm summary next to the conversation"));
|
|
139
140
|
if (command === 'mcp') return named(['status', 'list', 'config', 'reload', 'reconnect', 'add', 'remove'], t("Action on MCP servers"));
|
|
140
141
|
if (command === 'plugins') return named(['status', 'list', 'available', 'add', 'config', 'enable', 'disable', 'reload', 'run', 'allow', 'trust', 'untrust', 'remove'], t("Action on plugins"));
|
|
141
142
|
if (command === 'chrome-workflow') return named(['auto', 'raw'], 'Saved per project; applies to the entry of this session');
|
|
@@ -147,7 +148,7 @@ function argumentItems(command, args, context) {
|
|
|
147
148
|
if(command==='agents' && ['status','wait','stop','resume','background'].includes(first))return entries(context.agents,'id');
|
|
148
149
|
if (command === 'mcp' && ['reconnect', 'remove'].includes(first)) return entries(context.mcpServers);
|
|
149
150
|
if (command === 'plugins' && ['enable', 'disable', 'reload', 'run', 'config', 'allow', 'untrust', 'remove'].includes(first)) return entries(context.plugins, 'id');
|
|
150
|
-
if (command === 'plugins' && first === 'add') return named(['chrome-lab', 'open-relay'], 'Plugin bundled with Comal');
|
|
151
|
+
if (command === 'plugins' && first === 'add') return named(['chrome-lab', 'open-relay', 'omarchy'], 'Plugin bundled with Comal');
|
|
151
152
|
if (command === 'inbox' && first === 'release') return named(['all'], t("Release held messages; does not change hold mode"));
|
|
152
153
|
if (command === 'queue' && first === 'edit') return named((context.queue?.items || []).map((_, index) => String(index + 1)), t("Edit this pending message"));
|
|
153
154
|
if (command === 'login' && first === 'status') return entries(context.authProviders || ['codex', 'kimi', 'minimax', 'grok', 'ollama'], 'id');
|
package/src/tui/screen.mjs
CHANGED
|
@@ -10,11 +10,12 @@ import { brandingWelcome, brandingStatus, brandMotionEnabled, BRAND_FRAME_INTERV
|
|
|
10
10
|
import { commandCatalog, commandSuggestions, applySuggestion, contextualTip } from './discovery.mjs';
|
|
11
11
|
import { inboundBlockFromRow, inboundBlockFromEvent, inboundLines } from './inbound-blocks.mjs';
|
|
12
12
|
import { presentResultBlock, presentToolBlock, presentPermission } from './present.mjs';
|
|
13
|
+
import { normalizeSwarmView, swarmPanelLines, swarmStripLines, swarmActions } from './swarm-panel.mjs';
|
|
13
14
|
|
|
14
15
|
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
15
16
|
const MOUSE_ON = '\x1b[?1000h\x1b[?1006h';
|
|
16
17
|
const MOUSE_OFF = '\x1b[?1000l\x1b[?1006l';
|
|
17
|
-
const HELP = "# Comal · keyboard\n\n| Action | Key |\n| --- | --- |\n| Discover commands and tools | F6 |\n| Complete command or argument | / and Tab; ↑/↓ chooses; Esc closes |\n| Send / enqueue | Enter (suggestions only complete) |\n| New line | Alt+Enter or Ctrl+J |\n| Switch mode | Shift+Tab |\n| Edit with $VISUAL/$EDITOR | Ctrl+G |\n| Work board | F3 or /board |\n| Continue from checkpoint | /work resume |\n| Sessions / resume | F2 or Ctrl+S |\n| Message queue | F4 or Ctrl+Q |\n| Expand tool results | Ctrl+O |\n| Scroll history | Mouse wheel / PgUp / PgDn |\n| Select text with mouse | F5 or /select; F5 or Esc returns |\n| Copy last full response | Alt+C or /copy |\n| Go to end | Ctrl+End |\n| Composer history | ↑/↓ on first/last line |\n| Start / end of line | Home / End or Ctrl+A / Ctrl+E |\n| Delete word / start / end | Ctrl+W / Ctrl+U / Ctrl+K |\n| Cancel turn / pause goal | Ctrl+C or Esc while working |\n| Exit | Ctrl+D with empty composer or /exit |\n\nYou can keep typing during streaming. Sent messages run in order when the current turn ends. F4 lets you pause, edit, remove, and reorder the queue.\n\n/context shows the context budget. /compact derives state without LLM and waits its turn if there is active work. /state shows request and state; /audit verifies the ledger. The original history is preserved.\n/house shows execution receipts and interrupted operations; /operations shows contracts and declared effects. Execution success does not certify the human goal.\n/plugins shows plugins, dependencies, and operations; enable/disable/reload wait their turn and only the user can change their lifecycle.\n/model ID and /effort LEVEL change the next call; during work they stay queued. /models lists models and /capabilities shows support, provenance, and levels. E indicates effort; Ctx shows the effective window (k/M tokens); ~ indicates a configured limit, ? unknown/fallback capability. /context shows exact values, reserve, and source. /login PROVIDER authenticates; /logout PROVIDER removes credentials from Comal.\n\n**Help keeps the composer active.** You can type while viewing it; PgUp/PgDn scrolls it and Esc/F1 closes it. Sending returns to the conversation. The wheel scrolls the conversation; the arrows edit the draft. F5 releases the mouse and freezes the view: drag to select and use your terminal's copy action (usually Ctrl+Shift+C). Work continues and the view updates when you return. /copy preserves Markdown and code blocks, without the screen line wrapping. Multi-line paste is not sent automatically.";
|
|
18
|
+
const HELP = "# Comal · keyboard\n\n| Action | Key |\n| --- | --- |\n| Discover commands and tools | F6 |\n| Complete command or argument | / and Tab; ↑/↓ chooses; Esc closes |\n| Send / enqueue | Enter (suggestions only complete) |\n| New line | Alt+Enter or Ctrl+J |\n| Switch mode | Shift+Tab |\n| Edit with $VISUAL/$EDITOR | Ctrl+G |\n| Work board | F3 or /board |\n| Swarm panel (open-relay) | /swarm · /swarm strip |\n| Continue from checkpoint | /work resume |\n| Sessions / resume | F2 or Ctrl+S |\n| Message queue | F4 or Ctrl+Q |\n| Expand tool results | Ctrl+O |\n| Scroll history | Mouse wheel / PgUp / PgDn |\n| Select text with mouse | F5 or /select; F5 or Esc returns |\n| Copy last full response | Alt+C or /copy |\n| Go to end | Ctrl+End |\n| Composer history | ↑/↓ on first/last line |\n| Start / end of line | Home / End or Ctrl+A / Ctrl+E |\n| Delete word / start / end | Ctrl+W / Ctrl+U / Ctrl+K |\n| Cancel turn / pause goal | Ctrl+C or Esc while working |\n| Exit | Ctrl+D with empty composer or /exit |\n\nYou can keep typing during streaming. Sent messages run in order when the current turn ends. F4 lets you pause, edit, remove, and reorder the queue.\n\n/context shows the context budget. /compact derives state without LLM and waits its turn if there is active work. /state shows request and state; /audit verifies the ledger. The original history is preserved.\n/house shows execution receipts and interrupted operations; /operations shows contracts and declared effects. Execution success does not certify the human goal.\n/plugins shows plugins, dependencies, and operations; enable/disable/reload wait their turn and only the user can change their lifecycle.\n/model ID and /effort LEVEL change the next call; during work they stay queued. /models lists models and /capabilities shows support, provenance, and levels. E indicates effort; Ctx shows the effective window (k/M tokens); ~ indicates a configured limit, ? unknown/fallback capability. /context shows exact values, reserve, and source. /login PROVIDER authenticates; /logout PROVIDER removes credentials from Comal.\n\n**Help keeps the composer active.** You can type while viewing it; PgUp/PgDn scrolls it and Esc/F1 closes it. Sending returns to the conversation. The wheel scrolls the conversation; the arrows edit the draft. F5 releases the mouse and freezes the view: drag to select and use your terminal's copy action (usually Ctrl+Shift+C). Work continues and the view updates when you return. /copy preserves Markdown and code blocks, without the screen line wrapping. Multi-line paste is not sent automatically.";
|
|
18
19
|
|
|
19
20
|
export class TerminalScreen {
|
|
20
21
|
constructor({ input = process.stdin, output = process.stdout, metadata = {}, clipboard = copyText, env = process.env } = {}) {
|
|
@@ -403,6 +404,18 @@ export class TerminalScreen {
|
|
|
403
404
|
try { this.composer.insert(text); this.draftChanged(); } catch (e) { this.notice(e.message, true); }
|
|
404
405
|
}
|
|
405
406
|
showBoard(board = this.work) { this.work = board; this.overlay = { kind: 'board', scroll: 0 }; this.schedule(); }
|
|
407
|
+
// Panel /swarm (open-relay, fase 4): la foto llega del host (plugin → ctx.provide → host),
|
|
408
|
+
// sin turnos del modelo. Se limpia al entrar y se vuelve a pintar sola si está a la vista.
|
|
409
|
+
setSwarmView(view) { this.swarmView = normalizeSwarmView(view); if (this.overlay?.kind === 'swarm' || this.swarmStrip) this.schedule(); }
|
|
410
|
+
toggleSwarm() { if (this.overlay?.kind === 'swarm') { this.closeOverlay(); return; } this.overlay = { kind: 'swarm', scroll: 0, swarm: 0, index: 0, follow: true }; this.schedule(); }
|
|
411
|
+
setSwarmStrip(value = !this.swarmStrip) { this.swarmStrip = Boolean(value); this.notice(this.swarmStrip ? t('Swarm strip on: a compact summary next to the conversation (terminals 110+ columns wide). /swarm strip turns it off.') : t('Swarm strip off.')); }
|
|
412
|
+
// Enter en el panel: prepara el pedido en el composer (no lo envía). El humano lo envía y
|
|
413
|
+
// las acciones siguen en sus tarjetas; Esc recupera el borrador anterior.
|
|
414
|
+
prepareSwarmRequest() {
|
|
415
|
+
const action = swarmActions(this.swarmView, this.overlay.swarm)[this.overlay.index]; if (!action) return;
|
|
416
|
+
if (!this.preparedDraft) this.preparedDraft = { ...this.composer.snapshot(), sessionId: this.metadata.sessionId };
|
|
417
|
+
this.composer.setText(action.request); this.closeOverlay(); this.draftChanged();
|
|
418
|
+
}
|
|
406
419
|
setSelection(value = !this.selecting) {
|
|
407
420
|
if (this.closed || this.suspended || this.permission) return;
|
|
408
421
|
this.selecting = value; clearTimeout(this.renderTimer); this.renderTimer = null;
|
|
@@ -422,7 +435,7 @@ export class TerminalScreen {
|
|
|
422
435
|
if (this.setup) { if (this.setup.kind === 'choice') this.setup.index = Math.max(0, Math.min(this.setupItems().length - 1, this.setup.index + event.delta)); this.schedule(); return; }
|
|
423
436
|
const lines = event.delta * 3;
|
|
424
437
|
if (this.permission) this.permission.scroll = Math.max(0, this.permission.scroll + lines);
|
|
425
|
-
else if (['help', 'board'].includes(this.overlay?.kind)) this.overlay.scroll = Math.max(0, this.overlay.scroll + lines);
|
|
438
|
+
else if (['help', 'board', 'swarm'].includes(this.overlay?.kind)) this.overlay.scroll = Math.max(0, this.overlay.scroll + lines);
|
|
426
439
|
else if (this.overlay?.kind === 'sessions' || this.overlay?.kind === 'queue') {
|
|
427
440
|
const length = this.overlay.kind === 'sessions' ? this.filteredSessions().length : this.queue.items.length;
|
|
428
441
|
this.overlay.index = Math.max(0, Math.min(Math.max(0, length - 1), this.overlay.index + event.delta));
|
|
@@ -487,6 +500,15 @@ export class TerminalScreen {
|
|
|
487
500
|
if (this.hasModalOverlay()) {
|
|
488
501
|
if (name === 'escape') { this.closeOverlay(); return; }
|
|
489
502
|
if (['help', 'board'].includes(this.overlay.kind)) { if (name === 'pageup' || name === 'up') this.overlay.scroll = Math.max(0, this.overlay.scroll - 5); if (name === 'pagedown' || name === 'down') this.overlay.scroll += 5; this.schedule(); return; }
|
|
503
|
+
if (this.overlay.kind === 'swarm') {
|
|
504
|
+
const swarms = this.swarmView?.available ? this.swarmView.swarms.length : 0, actions = swarmActions(this.swarmView, this.overlay.swarm).length;
|
|
505
|
+
if (name === 'up' || name === 'down') { this.overlay.index = Math.max(0, Math.min(Math.max(0, actions - 1), this.overlay.index + (name === 'up' ? -1 : 1))); this.overlay.follow = true; }
|
|
506
|
+
else if (name === 'pageup') this.overlay.scroll = Math.max(0, this.overlay.scroll - 5);
|
|
507
|
+
else if (name === 'pagedown') this.overlay.scroll += 5;
|
|
508
|
+
else if ((name === 'tab' || name === 'right' || name === 'left') && swarms > 1) { this.overlay.swarm = (this.overlay.swarm + (name === 'left' || key.shift ? swarms - 1 : 1)) % swarms; this.overlay.index = 0; this.overlay.scroll = 0; this.overlay.follow = true; }
|
|
509
|
+
else if (name === 'return' && actions) { this.prepareSwarmRequest(); return; }
|
|
510
|
+
this.schedule(); return;
|
|
511
|
+
}
|
|
490
512
|
if (this.overlay.kind === 'sessions') {
|
|
491
513
|
const filtered = this.filteredSessions();
|
|
492
514
|
if (name === 'up') this.overlay.index = Math.max(0, this.overlay.index - 1);
|
|
@@ -612,6 +634,7 @@ export class TerminalScreen {
|
|
|
612
634
|
return (selected ? tone.accent : String)(clip(line, columns));
|
|
613
635
|
}), ...(!items.length ? [tone.muted(t(" No matches. Change section with Tab."))] : [])];
|
|
614
636
|
}
|
|
637
|
+
if (this.overlay?.kind === 'swarm' && !this.permission) return swarmPanelLines(this.swarmView, this.overlay, columns, height, { busy: this.busy });
|
|
615
638
|
if (this.overlay?.kind === 'board' && !this.permission) {
|
|
616
639
|
const board = this.work, lines = [tone.accent(t(" Work · plan v{v0}", {v0: board?.plan?.version || 0})), ...wrap(safeText(board?.plan?.objective || t("No plan. Ask for a task with steps and checks.")), columns - 4).map(line => ' ' + line), tone.muted(t(" /work resume continues · wheel/PgUp/PgDn scrolls · Esc returns")), ''];
|
|
617
640
|
const labels = { pending: t("PENDING"), in_progress: t("IN PROGRESS"), blocked: t("BLOCKED"), done: t("DONE · checks recorded"), needs_verification: t("TO VERIFY") };
|
|
@@ -686,6 +709,7 @@ export class TerminalScreen {
|
|
|
686
709
|
const side = [tone.accent(t(" Queue · {v0}{v1}", {v0: this.queue.items.length, v1: this.queue.paused ? t(" paused") : ''})), tone.muted(t(" F4 manage")), ''];
|
|
687
710
|
if (this.queue.running) side.push(tone.muted(t(" Running")), ...wrap(safeText(this.queue.running.text), sideWidth - 2).slice(0, 2).map(line => ' ' + line), '');
|
|
688
711
|
this.queue.items.slice(0, 6).forEach((item, i) => side.push(tone.accent(` ${i + 1}. `) + clip(safeText(item.text).replace(/\n/g, ' ↵ '), sideWidth - 5), ''));
|
|
712
|
+
if (this.swarmStrip) side.push(...swarmStripLines(this.swarmView, sideWidth));
|
|
689
713
|
body = body.map((line, i) => pad(line, bodyWidth) + tone.muted(' │') + clip(side[i] || '', sideWidth));
|
|
690
714
|
}
|
|
691
715
|
const elapsed = this.startedAt ? ((Date.now() - this.startedAt) / 1000).toFixed(1) : '0.0';
|