@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/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
  }
@@ -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);},15000);pending.set(id,{resolve,reject,timer});
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
- })};references(false);broker=result;return result;
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){
@@ -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 de la fase 2
37
- // (Reply, Ack, ApprovePlan, RejectPlan) 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'].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'));
@@ -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.
@@ -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.modalOverlay() || this.ui.selecting || this.ui.editingQueue || this.switching || this.closed)return;
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.ui.notice(output);
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(t(state.message),true);await this.recoverConnection(id,requested);}
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 discovery=this.backend.discovery?.() || {},profile=discovery.profiles?.find(p=>p.name===action.argument);
253
- const loginProvider=profile?.authProvider || (discovery.authProviders?.some(p=>p.id===action.argument)?action.argument:null);
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={authentication:'Sign in or check the configured credential environment variable.',timeout:'The model server timed out.',unavailable:'The model server could not be reached.',unsupported:'The active model does not support this setting.',unknown:'The change could not be verified.'}[state.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}`);
@@ -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');
@@ -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';