@milpa/comal 0.2.1 → 0.2.3

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.
Files changed (121) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  4. package/package.json +3 -1
  5. package/skills/mv3/SKILL.md +44 -0
  6. package/skills/mv3/skill.json +4 -0
  7. package/src/agent.mjs +299 -32
  8. package/src/auto-guard.mjs +40 -3
  9. package/src/autocompact.mjs +233 -0
  10. package/src/bash-timeouts.mjs +29 -0
  11. package/src/child-env.mjs +23 -0
  12. package/src/cli.mjs +232 -63
  13. package/src/code-run-artifact.mjs +30 -0
  14. package/src/code-run-journal.mjs +135 -0
  15. package/src/code-run-session-proof.mjs +50 -0
  16. package/src/code-run-session.mjs +97 -0
  17. package/src/code-run-worker.mjs +20 -4
  18. package/src/code-run.mjs +49 -14
  19. package/src/codex-provider.mjs +4 -4
  20. package/src/compaction-notice.mjs +22 -0
  21. package/src/compaction.mjs +162 -23
  22. package/src/connection-settings.mjs +36 -4
  23. package/src/context-pressure.mjs +5 -4
  24. package/src/context.mjs +27 -22
  25. package/src/execution-metrics.mjs +36 -5
  26. package/src/file-recovery-proof.mjs +9 -1
  27. package/src/file-recovery.mjs +36 -3
  28. package/src/gemini-provider.mjs +5 -3
  29. package/src/git-integration-operations.mjs +2 -1
  30. package/src/git-plumbing.mjs +2 -1
  31. package/src/git-source.mjs +2 -1
  32. package/src/git-workspaces.mjs +2 -1
  33. package/src/house.mjs +13 -1
  34. package/src/i18n/cli-es.mjs +12 -1
  35. package/src/i18n/es.mjs +133 -2
  36. package/src/judges.mjs +2 -1
  37. package/src/mcp.mjs +2 -1
  38. package/src/model-controls.mjs +16 -2
  39. package/src/model-deadline.mjs +2 -2
  40. package/src/openai-provider.mjs +8 -4
  41. package/src/operations.mjs +17 -2
  42. package/src/package-identity.mjs +68 -0
  43. package/src/peer-probe.mjs +16 -0
  44. package/src/permissions.mjs +99 -6
  45. package/src/plugin-catalog.mjs +19 -4
  46. package/src/plugin-config.mjs +18 -1
  47. package/src/plugin-controls.mjs +31 -8
  48. package/src/plugin-trust.mjs +15 -6
  49. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  50. package/src/plugins/chrome-lab/client.mjs +242 -12
  51. package/src/plugins/chrome-lab/index.mjs +106 -24
  52. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  53. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  54. package/src/plugins/omarchy/catalog.mjs +96 -0
  55. package/src/plugins/omarchy/environment.mjs +78 -0
  56. package/src/plugins/omarchy/guide.mjs +54 -0
  57. package/src/plugins/omarchy/index.mjs +213 -0
  58. package/src/plugins/omarchy/runner.mjs +62 -0
  59. package/src/plugins/omarchy/text.mjs +26 -0
  60. package/src/plugins/omarchy/tiers.mjs +157 -0
  61. package/src/plugins/open-relay/actions.mjs +212 -16
  62. package/src/plugins/open-relay/client.mjs +93 -35
  63. package/src/plugins/open-relay/decision.mjs +89 -0
  64. package/src/plugins/open-relay/dossier.mjs +23 -5
  65. package/src/plugins/open-relay/index.mjs +68 -28
  66. package/src/plugins/open-relay/lookup.mjs +41 -4
  67. package/src/plugins/open-relay/notices.mjs +48 -4
  68. package/src/plugins/open-relay/panel.mjs +59 -0
  69. package/src/plugins/open-relay/watch.mjs +198 -85
  70. package/src/plugins.mjs +12 -1
  71. package/src/process-lock.mjs +20 -12
  72. package/src/provider-auth.mjs +6 -3
  73. package/src/provider-profiles.mjs +13 -3
  74. package/src/provider.mjs +175 -30
  75. package/src/read-only-check.mjs +12 -4
  76. package/src/relay/broker.mjs +160 -0
  77. package/src/relay/cards.mjs +702 -0
  78. package/src/relay/flow.mjs +886 -0
  79. package/src/relay/follow.mjs +119 -0
  80. package/src/relay/git.mjs +23 -0
  81. package/src/relay/hub-client.mjs +163 -0
  82. package/src/relay/state.mjs +55 -0
  83. package/src/remote-control.mjs +115 -12
  84. package/src/remote-relay.mjs +468 -0
  85. package/src/session-bus.mjs +13 -3
  86. package/src/session-provider-settings.mjs +2 -2
  87. package/src/skill-runtime.mjs +8 -6
  88. package/src/source-ownership.mjs +3 -1
  89. package/src/swarm-view.mjs +53 -0
  90. package/src/tool-manifest.mjs +71 -0
  91. package/src/tool-packs.mjs +22 -6
  92. package/src/tool-schema.mjs +14 -2
  93. package/src/tools.mjs +96 -27
  94. package/src/tui/connection-transition.mjs +10 -0
  95. package/src/tui/controller.mjs +35 -9
  96. package/src/tui/discovery.mjs +13 -8
  97. package/src/tui/inbound-blocks.mjs +5 -1
  98. package/src/tui/screen.mjs +78 -15
  99. package/src/tui/swarm-panel.mjs +217 -0
  100. package/src/turn-timing.mjs +82 -0
  101. package/src/usage-accounting.mjs +12 -1
  102. package/src/vision.mjs +6 -1
  103. package/src/visual/chrome.mjs +379 -0
  104. package/src/visual/fingerprint.mjs +30 -0
  105. package/src/visual/fixtures.mjs +201 -0
  106. package/src/visual/gate.mjs +92 -0
  107. package/src/visual/judge.mjs +114 -0
  108. package/src/visual/ledger.mjs +160 -0
  109. package/src/visual/measure.mjs +145 -0
  110. package/src/visual/names.mjs +7 -0
  111. package/src/visual/operations.mjs +211 -0
  112. package/src/visual/pixel-diff.mjs +96 -0
  113. package/src/visual/png.mjs +103 -0
  114. package/src/visual/registry.mjs +265 -0
  115. package/src/visual/report.mjs +64 -0
  116. package/src/visual/verify.mjs +292 -0
  117. package/src/work-assertions.mjs +15 -4
  118. package/src/work-plan.mjs +166 -30
  119. package/src/work-state.mjs +4 -4
  120. package/src/workflows/chrome-task-adoption.mjs +7 -3
  121. package/src/workspace-serve.mjs +59 -0
@@ -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(['visual', 'raw', 'timing', 'cloud', 'remote-control', 'rc', 'help', 'copy', 'select', 'status', 'context', 'tools', 'house', 'state', 'audit', 'board', 'tasks', 'work', 'swarm', 'relay', 'operations', 'plugins', 'sessions', 'resume', 'clear', 'model', 'models', 'effort', 'capabilities', 'login', 'logout', 'permissions', 'autonomy', 'rename', 'add-dir', 'table', 'queue', 'agents', 'mcp','list-agents','inbox','providers','provider','config','max-tokens','context-window','autocompact', '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);
@@ -171,6 +174,7 @@ export class InteractiveController {
171
174
  }
172
175
  if (['rc','remote-control'].includes(command) && ['', 'start'].includes(arg.trim()))await this.requireIdle();
173
176
  if (command === 'help') { this.ui.showHelp(); return; }
177
+ if (command === 'timing') { const value = /^(?:(on|off))?$/.exec(arg.trim()); if (!value) throw new Error(t('Use /timing [on|off].')); this.ui.setTiming?.(value[1] ? value[1] === 'on' : undefined); return; }
174
178
  if (command === 'raw') { if (arg.trim()) throw new Error(t('Use /raw without arguments.')); this.ui.toggleRaw?.(); return; }
175
179
  if (command==='models' || ['model','login','provider','effort'].includes(command) && !arg.trim()) return this.chooseConnection(command,arg);
176
180
  if (command === 'sessions' || (command === 'resume' && !arg)) { this.ui.showSessions(await this.backend.listSessions()); return; }
@@ -191,6 +195,16 @@ export class InteractiveController {
191
195
  }
192
196
  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
197
  if (['board', 'tasks', 'work'].includes(command)) { this.ui.showBoard(JSON.parse(await this.backend.control('board', ''))); return; }
198
+ // Panel /swarm: sólo presentación; los datos llegan solos del plugin open-relay (ctx.provide).
199
+ if (command === 'swarm') {
200
+ const value = arg.trim();
201
+ if (!value) { this.ui.toggleSwarm(); return; }
202
+ const strip = /^strip(?:\s+(on|off))?$/.exec(value);
203
+ if (!strip) throw new Error(t('Use /swarm or /swarm strip [on|off].'));
204
+ this.ui.setSwarmStrip(strip[1] ? strip[1] === 'on' : undefined); return;
205
+ }
206
+ // /relay: tarjetas humanas del host (proyecto, confianza, arranque, plan, preguntas, final).
207
+ if (command === 'relay') { const output = await this.backend.control('relay', arg); if (output) this.showOutput(command, arg, output); return; }
194
208
  if(connectionAction(`/${command} ${arg}`)){await this.requireIdle();return this.connectionControl(command,arg);}
195
209
  if (['cloud', 'model', 'effort', 'provider', 'login', 'logout'].includes(command) && (arg.trim() || ['login', 'logout'].includes(command))) await this.requireIdle();
196
210
  if (['max-tokens', 'context-window'].includes(command) && arg.trim() || command === 'config' && /^(?:set|save)\b/.test(arg.trim())) await this.requireIdle();
@@ -228,13 +242,13 @@ export class InteractiveController {
228
242
  this.connections.admit(id,requested);const attempt=this.connections.start(id);this.switching=true;this.syncConnectionStatus();
229
243
  let failure;
230
244
  try{
231
- const output=await this.backend.control(command,argument);if(output)this.ui.notice(output);
245
+ const output=await this.backend.control(command,argument);if(output)this.showOutput(command,argument,output);
232
246
  this.connections.complete(id,attempt,{outcome:'applied',active:this.backend.metadata()});
233
247
  try{await this.backend.refreshConnections?.();}catch{}
234
248
  }catch(error){
235
249
  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
250
  }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);}
251
+ if(failure){const state=this.connections.get(id);this.ui.notice(this.connectionFailureText(state,requested),true);await this.recoverConnection(id,requested);}
238
252
  }
239
253
  scheduleConnectionRecovery(id,action){
240
254
  const runner=this.runner;
@@ -244,16 +258,28 @@ export class InteractiveController {
244
258
  if(this.runner===runner)setImmediate(()=>this.recoverConnection(id,action).catch(()=>{}));
245
259
  });
246
260
  }
261
+ // Login provider behind a /provider or /login target, if the session knows it.
262
+ connectionLoginProvider(action){
263
+ if(action.command==='login')return action.argument.split(/\s+/)[0];
264
+ if(action.command!=='provider')return null;
265
+ const discovery=this.backend.discovery?.() || {},profile=discovery.profiles?.find(p=>p.name===action.argument);
266
+ return profile?.authProvider || (discovery.authProviders?.some(p=>p.id===action.argument)?action.argument:null);
267
+ }
268
+ // One line: what was tried, that nothing changed, and the next step.
269
+ connectionFailureText(state,action){
270
+ const login=state.reason==='authentication'?this.connectionLoginProvider(action):null;
271
+ const hint=login?t('Sign in first with /login {v0}.',{v0:login}):CONNECTION_REASON_HINTS[state.reason]?t(CONNECTION_REASON_HINTS[state.reason]):'';
272
+ return `/${action.command} ${state.target}: ${t(state.message)}${hint?' '+hint:''}`;
273
+ }
247
274
  async recoverConnection(id,action){
248
275
  if(this.closed || this.ui.busy || this.runner.processing || this.modalOverlay() || this.ui.permission)return;
249
276
  const state=this.connections.get(id);if(state?.state!=='failed')return;
250
277
  const items=[{id:'keep',label:t('Keep the active connection'),description:t('Return to your draft without changing the connection.')}];
251
278
  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});
279
+ const loginProvider=action.command==='provider'?this.connectionLoginProvider(action):null;
280
+ if(state.reason==='authentication' && loginProvider)items.push({id:'login',label:t('Sign in to the selected provider'),description:loginProvider});
255
281
  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];
282
+ const reason=CONNECTION_REASON_HINTS[state.reason];
257
283
  const choice=await this.ui.setupChoice({title:t(state.confirmedUnchanged?'Connection change not applied':'Connection status needs review'),description:t(state.message)+(reason?'\n\n'+t(reason):''),items});
258
284
  if(this.closed)return;
259
285
  if(choice==='retry' && this.connections.canRetry(id))await this.submit(`/${action.command} ${action.argument}`);
@@ -6,23 +6,24 @@ import { graphemes, safeText } from './text.mjs';
6
6
 
7
7
  const COMMANDS = [
8
8
  ['cloud', '[login|status|logout]', 'Connect your Comal account for Artifacts and Remote Control'],
9
- ['remote-control', '[start|status|stop]', 'Open this local session on the web'], ['rc', '[start|status|stop]', 'Remote Control shortcut'],
9
+ ['remote-control', '[start|status|stop|relay on|off]', 'Open this local session on the web'], ['rc', '[start|status|stop|relay on|off]', 'Remote Control shortcut'],
10
10
  ['help', '', "Help and keyboard"], ['status', '', "Session, provider and budget"],
11
11
  ['tools', '', "Tools available at this time"], ['house', '', "Receipts and interrupted operations"],
12
12
  ['operations', '', "Contracts and effects of operations"], ['sessions', '', "Open the session selector"],
13
13
  ['resume', 'UUID', "Resume a saved session"], ['clear', '', "Create a new conversation"],
14
14
  ['copy', '', "Copy the last complete response"], ['select', '', "Select text with the terminal"],
15
15
  ['raw', '', "Toggle the exact JSON of results and permissions"],
16
- ['compact', '', "Derive ledger state without an LLM"], ['context', '', "Check the context budget"],
16
+ ['compact', '[focus TEXT]', "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"], ['relay', '[status|new [NAME]|start|stop|unlink|hub ROOT]', "Create or open the swarm project of this repo and start a swarm (human cards)"], ['timing', '[on|off]', "Show or hide response timing after each answer"], ['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"],
23
23
  ['permissions', "[MODE|allow|ask|deny|remove RULE]", "Show or change the permission mode and saved rules"], ['autonomy', "[observe|assist|edit|autonomous]", "Show or set the autonomy level"], ['goal', "[condition|status|pause|resume|clear]", "Define or control a persistent goal"],
24
24
  ['queue', '[pause|resume|clear|edit N]', "Manage queued messages"],
25
- ['rename', "NAME", "Change the visible name of this session"], ['table', "FILE", "Display a CSV, TSV, JSON, or Markdown table"],
25
+ ['rename', "NAME", "Change the visible name of this session"], ['add-dir', "[PATH]", "Open another directory for reading and writing, or list the open ones"], ['table', "FILE", "Display a CSV, TSV, JSON, or Markdown table"],
26
+ ['visual', '[status [C]|approve C FIXTURE STATE|confirm C CRITERION|revoke C reference|criterion KEY]', "Visual acceptance: approve references and confirm criteria"],
26
27
  ['mcp', "[action]", "View or configure MCP servers"], ['plugins', "[action]", "View plugins and control their lifecycle"], ['chrome-workflow', '[auto|raw]', 'Chrome entry workflow saved for this project'],
27
28
  ['agent', "[explore|general|background] TASK", "Delegate a task to a subagent"], ['agents', '[UUID|status|wait|stop|resume|background|continue] [RUN]', "View and control child runs"],
28
29
  ['search', "QUERY", "Search the web"], ['list-agents', '', "Discover other live sessions"],
@@ -30,7 +31,7 @@ const COMMANDS = [
30
31
  ['providers', '', "View profiles and login providers"], ['provider', "PROFILE", "Switch to the configured profile"],
31
32
  ['login', "PROVIDER [--key]", "Authenticate a provider; keys go in a hidden field"], ['logout', "PROVIDER", "Remove Comal credentials"],
32
33
  ['config', '[set KEY VALUE|save]', "Connection, limits, permissions and config files"],
33
- ['max-tokens', 'N', "Output reserve for this and later sessions"], ['context-window', 'auto|N', "Context window for this and later sessions"],
34
+ ['max-tokens', 'N', "Output reserve for this and later sessions"], ['autocompact', 'N|Nk|auto|off|default', 'Compaction window per model (N, Nk, auto, off)'], ['context-window', 'auto|N', "Context window for this and later sessions"],
34
35
  ['exit', '', "Save and exit"], ['quit', '', "Save and exit"],
35
36
  ].map(([name, args, description]) => Object.freeze({ name, args, description }));
36
37
  export const COMMAND_CATALOG = Object.freeze(COMMANDS);
@@ -84,7 +85,7 @@ const COMMAND_KEYWORDS = Object.freeze({
84
85
  operations: "contracts effects tools catalog catalog contratos efectos herramientas catálogo catalogo",
85
86
  sessions: "sessions history resume recover sesiones historial retomar recuperar", resume: "sessions history resume recover continue sesiones historial retomar recuperar continuar",
86
87
  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",
88
+ board: "kanban pending tasks progress kanban pendientes tareas progreso", timing: "time elapsed latency reasoning duration tiempo latencia razonamiento duración", swarm: "open-relay relay swarm kanban agents questions plans enjambre agentes preguntas planes", relay: "open-relay relay-hub swarm project start objective team agents enjambre proyecto arrancar objetivo equipo agentes", work: "kanban checkpoint continue resume kanban checkpoint continuar retomar",
88
89
  effort: 'esfuerzo razonamiento pensar thinking', capabilities: "capabilities vision vision video images images support capacidades visión vision video imágenes imagenes soporte",
89
90
  permissions: "modes security permissions authorize plan automatic automatic modos seguridad permisos autorizar plan automático automatico",
90
91
  goal: "goal autonomous autonomous continue persistent objetivo autónomo autonomo continuar persistente", queue: "queue messages pending pause reorder cola mensajes pendientes pausar reordenar",
@@ -121,8 +122,9 @@ export function commandCatalog(context = {}) {
121
122
 
122
123
  function argumentItems(command, args, context) {
123
124
  const first = args[0] || '', position = args.length - 1;
125
+ if (position === 1 && (command === 'rc' || command === 'remote-control') && first === 'relay') return named(['on', 'off'], 'on [30m|2h|8h] arms in memory for this /rc session; off disarms');
124
126
  if (position === 0) {
125
- if (command === 'rc' || command === 'remote-control')return named(['start','status','stop'],'Remote session projection; local tools and permissions');
127
+ if (command === 'rc' || command === 'remote-control')return [...named(['start','status','stop'],'Remote session projection; local tools and permissions'), ...named(['relay'], 'Answers to /relay cards from the browser (off by default)')];
126
128
  if (command === 'cloud') return named(['login','status','logout'],'Comal account; separate from the model provider');
127
129
  if (command === 'permissions') return context.mode === BYPASS_PERMISSION_MODE ? [] : [...named(['default', 'plan', 'acceptEdits', 'auto'], t("Permissions mode; does not change the model")), ...named(['allow', 'ask', 'deny', 'remove'], t("Saved permission rule; deny wins"))];
128
130
  if (command === 'autonomy') return context.mode === BYPASS_PERMISSION_MODE ? [] : named(languageInfo().language === 'es' ? ['observar', 'asistir', 'editar', 'autonomo'] : ['observe', 'assist', 'edit', 'autonomous'], t("Autonomy level; never enables the startup bypass"));
@@ -136,6 +138,9 @@ function argumentItems(command, args, context) {
136
138
  if (command === 'work') return named(['resume', 'recover', 'review'], t("Continue work, recover Chrome or review a native failure locally"));
137
139
  if (command === 'goal') return named(['status', 'pause', 'resume', 'clear'], t("Target control; you can also write a condition"));
138
140
  if (command === 'queue') return named(['pause', 'resume', 'clear', 'edit'], t("Action on the queue"));
141
+ if (command === 'swarm') return named(['strip'], t("Toggle the compact swarm summary next to the conversation"));
142
+ if (command === 'relay') return named(['status', 'new', 'start', 'stop', 'unlink', 'hub'], t("Swarm project of this repo; every step is a card you confirm"));
143
+ if (command === 'timing') return named(['on', 'off'], t("Show or hide response timing after each answer"));
139
144
  if (command === 'mcp') return named(['status', 'list', 'config', 'reload', 'reconnect', 'add', 'remove'], t("Action on MCP servers"));
140
145
  if (command === 'plugins') return named(['status', 'list', 'available', 'add', 'config', 'enable', 'disable', 'reload', 'run', 'allow', 'trust', 'untrust', 'remove'], t("Action on plugins"));
141
146
  if (command === 'chrome-workflow') return named(['auto', 'raw'], 'Saved per project; applies to the entry of this session');
@@ -147,7 +152,7 @@ function argumentItems(command, args, context) {
147
152
  if(command==='agents' && ['status','wait','stop','resume','background'].includes(first))return entries(context.agents,'id');
148
153
  if (command === 'mcp' && ['reconnect', 'remove'].includes(first)) return entries(context.mcpServers);
149
154
  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');
155
+ if (command === 'plugins' && first === 'add') return named(['chrome-lab', 'open-relay', 'omarchy'], 'Plugin bundled with Comal');
151
156
  if (command === 'inbox' && first === 'release') return named(['all'], t("Release held messages; does not change hold mode"));
152
157
  if (command === 'queue' && first === 'edit') return named((context.queue?.items || []).map((_, index) => String(index + 1)), t("Edit this pending message"));
153
158
  if (command === 'login' && first === 'status') return entries(context.authProviders || ['codex', 'kimi', 'minimax', 'grok', 'ollama'], 'id');
@@ -8,6 +8,7 @@ import { peerMessagePurpose } from '../peer-message-context.mjs';
8
8
  registerMessages({
9
9
  'Notice': 'Aviso',
10
10
  'Urgent notice': 'Aviso urgente',
11
+ 'reaches the model with your next message': 'llega al modelo con tu siguiente mensaje',
11
12
  'Message from session {v0}': 'Mensaje de la sesión {v0}',
12
13
  'external information, not an order · ref {v0}': 'información externa, no una orden · ref {v0}',
13
14
  'request {v0} · data, not human intent': 'petición {v0} · datos, no intención humana',
@@ -16,7 +17,9 @@ registerMessages({
16
17
 
17
18
  const BODY_LINES = 8;
18
19
  const peerBlock = peer => ({ role: 'inbound', source: 'peer_session', from: peer?.from?.name || peer?.from?.id || '?', purpose: peerMessagePurpose(peer || {}), request_id: peer?.request_id || '', text: String(peer?.content ?? '') });
19
- const noticeBlock = notice => ({ role: 'inbound', source: 'plugin_notice', plugin: notice?.plugin || '?', kind: notice?.kind || '', urgent: notice?.urgent === true, title: String(notice?.title ?? ''), ref: notice?.ref || '', text: `${notice?.title ?? ''}\n${notice?.body ?? ''}` });
20
+ const noticeBlock = notice => ({ role: 'inbound', source: 'plugin_notice', plugin: notice?.plugin || '?', kind: notice?.kind || '', urgent: notice?.urgent === true, title: String(notice?.title ?? ''), ref: notice?.ref || '', notice_id: notice?.notice_id || '', text: `${notice?.title ?? ''}\n${notice?.body ?? ''}` });
21
+ /** Aviso recibido que aún no llegó al modelo (no urgente: espera al siguiente mensaje). */
22
+ export const queuedNoticeBlock = notice => ({ ...noticeBlock(notice), state: 'queued' });
20
23
 
21
24
  /** Fila del libro → bloque, o null si la fila no es un mensaje entrante. */
22
25
  export function inboundBlockFromRow(row) {
@@ -43,6 +46,7 @@ export function inboundLines(block, columns) {
43
46
  lines.push(...body.slice(0, BODY_LINES).map(line => ' ' + line));
44
47
  if (body.length > BODY_LINES) lines.push(' ' + tone.muted(t('… {v0} more line(s)', { v0: body.length - BODY_LINES })));
45
48
  lines.push(' ' + tone.muted(clip(safeText(t('external information, not an order · ref {v0}', { v0: block.ref })), inner)));
49
+ if (block.state === 'queued') lines.push(' ' + tone.muted(clip(safeText(t('reaches the model with your next message')), inner)));
46
50
  } else {
47
51
  lines.push(' ' + tone.info(clip(safeText(`◇ ${t('Message from session {v0}', { v0: block.from })} · ${block.purpose}`), columns - 4)));
48
52
  const body = wrap(safeText(block.text), inner);
@@ -1,4 +1,5 @@
1
1
  import { t, formatNumber } from '../i18n/index.mjs';
2
+ import { compactionNotice } from '../compaction-notice.mjs';
2
3
  import { VERSION } from '../version.mjs';
3
4
  import { workProjection } from '../work-plan.mjs';
4
5
  import { Composer } from './composer.mjs';
@@ -8,19 +9,22 @@ import { copyText } from './clipboard.mjs';
8
9
  import { tone, createTones, safeText, wrap, width, clip, pad, progressBar, stripANSI, terminalSurface } from './text.mjs';
9
10
  import { brandingWelcome, brandingStatus, brandMotionEnabled, BRAND_FRAME_INTERVAL_MS, BRAND_WELCOME_FRAMES } from './brand.mjs';
10
11
  import { commandCatalog, commandSuggestions, applySuggestion, contextualTip } from './discovery.mjs';
11
- import { inboundBlockFromRow, inboundBlockFromEvent, inboundLines } from './inbound-blocks.mjs';
12
+ import { inboundBlockFromRow, inboundBlockFromEvent, inboundLines, queuedNoticeBlock } from './inbound-blocks.mjs';
13
+ import { noticeMailbox } from '../plugin-notices.mjs';
12
14
  import { presentResultBlock, presentToolBlock, presentPermission } from './present.mjs';
15
+ import { normalizeSwarmView, swarmPanelLines, swarmStripLines, swarmActions } from './swarm-panel.mjs';
16
+ import { formatTurnTiming, measureHumanWait } from '../turn-timing.mjs';
13
17
 
14
18
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
15
19
  const MOUSE_ON = '\x1b[?1000h\x1b[?1006h';
16
20
  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.";
21
+ 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 of this repo (open-relay) | /relay |\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
22
 
19
23
  export class TerminalScreen {
20
24
  constructor({ input = process.stdin, output = process.stdout, metadata = {}, clipboard = copyText, env = process.env } = {}) {
21
25
  Object.assign(this, { input, output, metadata, env }); this.composer = new Composer(); this.markdown = new MarkdownRenderer(); this.tables = new DataTableParser();
22
26
  this.clipboard = clipboard; this.selecting = false;
23
- this.blocks = []; this.toolBlocks = new Map(); this.subagentBlocks = new Map(); this.countedChildResults=new Set(); this.queue = { items: [], paused: false, interrupted: [] }; this.busy = false; this.ready = false;
27
+ this.blocks = []; this.toolBlocks = new Map(); this.subagentBlocks = new Map(); this.noticeBlocks = new Map(); this.countedChildResults=new Set(); this.queue = { items: [], paused: false, interrupted: [] }; this.busy = false; this.ready = false; this.showTiming = true;
24
28
  this.mode = metadata.mode || 'default'; this.phase = t("Connecting to the model"); this.frame = 0; this.scroll = 0; this.lastContentLength = 0; this.expandedTools = false;
25
29
  this.discovery = {}; this.completionIndex = 0; this.brandStarted = Date.now();
26
30
  this.callbacks = {}; this.closed = false; this.suspended = false; this.previous = []; this.goal = null; this.usage = 0; this.turn = 0; this.maxTurns = 12;
@@ -61,9 +65,12 @@ export class TerminalScreen {
61
65
  this.setup = state; this.schedule(); return state;
62
66
  }
63
67
  clearSetup() { this.setup?.finish(null); }
64
- setupChoice({ title, description = '', items, signal, searchable = false, selected } = {}) {
68
+ // keys: atajos de una letra → id de opción (p. ej. {e:'edit', r:'reject'}), sólo sin filtro.
69
+ // hint: línea de ayuda propia. Una descripción larga (tarjetas de /relay) se desplaza con PgUp/PgDn.
70
+ setupChoice({ title, description = '', items, signal, searchable = false, selected, keys, hint } = {}) {
65
71
  if (!Array.isArray(items) || !items.length || items.length > 1024 || items.some(item => typeof item?.id !== 'string' || !item.id || typeof item.label !== 'string') || new Set(items.map(item => item.id)).size !== items.length) throw new Error(t('Choose between 1 and 1024 distinct options.'));
66
- return new Promise(resolve => this.beginSetup({ kind: 'choice', title: safeText(title || t('Choose a connection')), description: safeText(description), items: items.map(item => ({ id: item.id, label: safeText(item.label), description: safeText(item.description || ''), badge: safeText(item.badge || '') })), index: Math.max(0,items.findIndex(item=>item.id===selected)), searchable, query: '' }, signal, resolve));
72
+ const shortcuts = !searchable && keys && typeof keys === 'object' ? Object.fromEntries(Object.entries(keys).filter(([key, id]) => /^[a-z]$/.test(key) && items.some(item => item.id === id))) : null;
73
+ return new Promise(resolve => this.beginSetup({ kind: 'choice', title: safeText(title || t('Choose a connection')), description: safeText(description), items: items.map(item => ({ id: item.id, label: safeText(item.label), description: safeText(item.description || ''), badge: safeText(item.badge || '') })), index: Math.max(0,items.findIndex(item=>item.id===selected)), searchable, query: '', keys: shortcuts, hint: hint ? safeText(hint) : null, scroll: 0 }, signal, resolve));
67
74
  }
68
75
  setupItems() {
69
76
  const state=this.setup;
@@ -98,6 +105,8 @@ export class TerminalScreen {
98
105
  if (state.kind === 'status' || state.validating) return;
99
106
  if (state.kind === 'choice') {
100
107
  const items=this.setupItems();
108
+ if (state.keys && !key.ctrl && !key.meta && text && state.keys[text]) { state.finish(state.keys[text]); return; }
109
+ if (name === 'pageup' || name === 'pagedown') { state.scroll = Math.max(0, (state.scroll || 0) + (name === 'pageup' ? -5 : 5)); this.schedule(); return; }
101
110
  if (name === 'up') state.index = Math.max(0, state.index - 1);
102
111
  else if (name === 'down') state.index = Math.max(0,Math.min(items.length - 1, state.index + 1));
103
112
  else if (name === 'home') state.index = 0;
@@ -132,7 +141,16 @@ export class TerminalScreen {
132
141
  const counter=state.kind==='choice'?t('{index} of {count}',{index:items.length?state.index+1:0,count:items.length}):'';
133
142
  const title=wrap(state.title,room).map(value=>line(colors.bold(colors.text(value))));
134
143
  if(room>=40 && title.length===1 && counter)title[0]=line(pad(colors.bold(colors.text(state.title)),room-width(counter)-1)+' '+colors.muted(counter));
135
- const description=state.description?wrap(state.description,room).map(value=>line(colors.muted(value))):[];
144
+ let description=state.description?wrap(state.description,room).map(value=>line(colors.muted(value))):[];
145
+ // Descripción larga (tarjetas): deja sitio a las opciones y se desplaza con PgUp/PgDn.
146
+ if(state.kind==='choice' && description.length){
147
+ const choices=Math.min(12,(state.items?.length || 1)*2+1),room2=Math.max(3,rows-header.length-title.length-choices-6);
148
+ if(description.length>room2){
149
+ const max=description.length-(room2-1);state.scroll=Math.min(state.scroll || 0,max);
150
+ const from=state.scroll,shown=description.slice(from,from+room2-1);
151
+ description=[...shown,line(colors.secondary(t('PgUp/PgDn scrolls · lines {from}–{to} of {count}',{from:from+1,to:from+shown.length,count:description.length})))];
152
+ }
153
+ }
136
154
  let prefix=[...header,'',...title,...(description.length?['',...description]:[]),''];
137
155
  if(state.searchable)prefix.push(line(colors.muted(t('Filter')+': ')+colors.text(state.query || t('Type to search…'))),'');
138
156
  // Artwork yields to the actual decision on short terminals and auth screens.
@@ -143,7 +161,7 @@ export class TerminalScreen {
143
161
  }
144
162
  const budget=Math.max(1,rows-Math.min(prefix.length,rows-4)-2);
145
163
  const cancellation=this.ready?t('Ctrl+C cancels'):t('Ctrl+C exits');
146
- const hint=(state.kind==='choice'?(state.searchable?t('Type to filter · ↑/↓ choose · Enter selects · Esc returns'):t('↑/↓ choose · Enter continues · Esc back')):state.kind==='input'?t('Enter continues · Esc back'):t('Esc back'))+' · '+cancellation;
164
+ const hint=state.hint?state.hint+' · '+cancellation:(state.kind==='choice'?(state.searchable?t('Type to filter · ↑/↓ choose · Enter selects · Esc returns'):t('↑/↓ choose · Enter continues · Esc back')):state.kind==='input'?t('Enter continues · Esc back'):t('Esc back'))+' · '+cancellation;
147
165
  let body=[],cursor=null;
148
166
  if(state.kind==='choice'){
149
167
  const cards=items.map((item,index)=>{
@@ -241,8 +259,10 @@ export class TerminalScreen {
241
259
  toggleRaw() { this.rawView = !this.rawView; this.notice(this.rawView ? t('Raw view on: results and permissions show the exact JSON. /raw returns to the readable view.') : t('Readable view on. /raw shows the exact JSON again.')); }
242
260
  loadSession(session) {
243
261
  this.cacheReadTokens=null;
244
- this.preparedDraft = null; this.dismissedCompletion = null; this.blocks = []; this.toolBlocks.clear(); this.subagentBlocks.clear(); this.streaming = null; this.scroll = 0; this.lastContentLength = 0; this.goal = session.goal;
262
+ this.preparedDraft = null; this.dismissedCompletion = null; this.blocks = []; this.toolBlocks.clear(); this.subagentBlocks.clear(); this.noticeBlocks.clear(); this.streaming = null; this.scroll = 0; this.lastContentLength = 0; this.goal = session.goal;
245
263
  const rows = session.rows.filter(row => row.type === 'message').slice(-200);
264
+ const timingAfter = new Map(); let lastMessage;
265
+ for (const row of session.rows) { if (row.type === 'message') lastMessage = row; else if (row.type === 'result' && row.timing && lastMessage) timingAfter.set(lastMessage, row.timing); }
246
266
  for (const [responseId, row] of rows.entries()) {
247
267
  const message = row.message, inbound = inboundBlockFromRow(row);
248
268
  if (inbound) { this.blocks.push(inbound); continue; }
@@ -253,7 +273,10 @@ export class TerminalScreen {
253
273
  if (message.role === 'assistant' && block.type === 'tool_use') { const item = { role: 'tool', id: block.id, name: block.name, input: block.input, state: 'interrupted' }; this.blocks.push(item); this.toolBlocks.set(block.id, item); }
254
274
  if (message.role === 'user' && block.type === 'tool_result') { const item = this.toolBlocks.get(block.tool_use_id); if (item) { item.state = block.is_error ? 'error' : 'done'; item.output = block.content; } }
255
275
  }
276
+ if (timingAfter.has(row)) this.blocks.push({ role: 'timing', timing: timingAfter.get(row) });
256
277
  }
278
+ // Avisos recibidos que aún no llegaron al modelo: también se ven al recargar.
279
+ for (const notice of noticeMailbox(session.rows).ready) { const block = queuedNoticeBlock(notice); this.noticeBlocks.set(block.notice_id, block); this.blocks.push(block); }
257
280
  const history = rows.filter(row => row.message.role === 'user' && row.source !== 'goal' && (typeof row.message.content === 'string' || row.source === 'human_composer')).slice(-100);
258
281
  this.composer.history = history.map(row => typeof row.message.content === 'string' ? row.message.content : row.message.content.filter(block=>block.type==='text').map(block=>block.text).join('\n'));
259
282
  this.composer.historyAttachments = history.map(row=>row.attachments || []);
@@ -267,7 +290,7 @@ export class TerminalScreen {
267
290
  if (!child) { child = { role: 'subagent', id: row.session_id }; this.subagentBlocks.set(row.session_id, child); this.blocks.push(child); }
268
291
  Object.assign(child, row, { role: 'subagent', state: row.status === 'running' ? 'interrupted' : row.status });
269
292
  }
270
- this.usage = session.rows.filter(row => row.type === 'result' && !row.usage_includes_subagents).reduce((n, row) => n + (row.usage?.input_tokens || 0) + (row.usage?.output_tokens || 0), 0)
293
+ this.usage = session.rows.filter(row => (row.type === 'result' && !row.usage_includes_subagents) || (row.type === 'error' && row.usage)).reduce((n, row) => n + (row.usage?.input_tokens || 0) + (row.usage?.output_tokens || 0), 0)
271
294
  + childUsage;
272
295
  this.schedule();
273
296
  }
@@ -282,13 +305,14 @@ export class TerminalScreen {
282
305
  if (event.tool_argument_chars) this.phase = t("Preparing tools · {v0} characters", {v0: formatNumber(event.tool_argument_chars)});
283
306
  else if (event.reasoning_chars && !event.text_chars) this.phase = t("Model reasoning · {v0} characters", {v0: formatNumber(event.reasoning_chars)});
284
307
  }
308
+ if (event.type === 'model_stream_phase') this.phase = event.phase === 'reasoning' ? t('Reasoning…') : t('Responding…');
285
309
  if (event.type === 'context_usage') this.context = event;
286
310
  if (event.type === 'vision_status') {
287
311
  const stamp = JSON.stringify([event.supported, event.source, event.images, event.references?.map(ref => ref.sha256)]);
288
312
  if (stamp !== this.lastVisionStamp) { this.lastVisionStamp = stamp; this.notice(event.images ? t("Vision · {v0} images in the request · reserve ~{v1} tokens.", {v0: event.images, v1: event.token_reserve}) : t("Screenshot saved as a reference · {v0}.", {v0: event.supported ? t("no space for pixels in the context") : t("the provider does not advertise vision")})); }
289
313
  }
290
314
  if (event.type === 'context_projection_start') this.phase = t("Deriving state from the ledger");
291
- if (event.type === 'context_projection') this.notice(t("State derived \u00b7 ~{v0} tokens \u00b7 0 LLM summary calls \u00b7 evidence saved.", {v0: event.after_tokens}));
315
+ if (event.type === 'context_projection') { const automatic = event.compaction_trigger !== 'manual' && compactionNotice(event); this.notice(automatic || t("State derived \u00b7 ~{v0} tokens \u00b7 0 LLM summary calls \u00b7 evidence saved.", {v0: event.after_tokens})); }
292
316
  if (['model_retry', 'response_discarded'].includes(event.type)) {
293
317
  if (this.streaming) { this.streaming.text += t("\n\n[Incomplete response discarded; its tools were not executed.]"); this.streaming = null; }
294
318
  if (event.type === 'model_retry') this.notice(t("Response discarded ({v0}) · bounded retry · maximum output {v1} tokens.", {v0: event.reason, v1: event.max_tokens}));
@@ -323,7 +347,12 @@ export class TerminalScreen {
323
347
  this.schedule(); return;
324
348
  }
325
349
  if (event.type === 'user' && event.source !== 'goal') this.blocks.push({ role: 'user', text: event.userIntent || event.text });
326
- const inbound = inboundBlockFromEvent(event); if (inbound) this.blocks.push(inbound);
350
+ // Un aviso no urgente no despierta un turno: se pinta al llegar y, cuando por fin se
351
+ // entrega al modelo, el mismo bloque deja de estar en espera (no se repite).
352
+ if (event.type === 'plugin_notice_admitted' && event.status === 'accepted' && event.notice?.notice_id && !event.notice.urgent && !this.noticeBlocks.has(event.notice.notice_id)) { const block = queuedNoticeBlock(event.notice); this.noticeBlocks.set(block.notice_id, block); this.blocks.push(block); }
353
+ const queued = event.type === 'plugin_notice' && this.noticeBlocks.get(event.notice?.notice_id);
354
+ if (queued) { delete queued.state; queued.cacheKey = null; this.noticeBlocks.delete(event.notice.notice_id); }
355
+ const inbound = !queued && inboundBlockFromEvent(event); if (inbound) this.blocks.push(inbound);
327
356
  if (event.type === 'text_delta' && this.pendingCommand && !this.streaming) { const { command, arg } = this.pendingCommand; this.pendingCommand = null; this.blocks.push({ role: 'result', command, arg, text: String(event.text ?? '') }); }
328
357
  else if (event.type === 'text_delta') {
329
358
  if (!this.streaming) { this.streaming = { role: 'assistant', text: '' }; this.blocks.push(this.streaming); }
@@ -335,6 +364,8 @@ export class TerminalScreen {
335
364
  }
336
365
  if (event.type === 'tool_use') { const item = { role: 'tool', id: event.id, name: event.name, input: event.input, state: 'running' }; this.blocks.push(item); this.toolBlocks.set(event.id, item); this.phase = t("Executing {v0}", {v0: event.name}); }
337
366
  if (event.type === 'tool_result') { const item = this.toolBlocks.get(event.tool_use_id); if (item) { item.state = event.is_error ? 'error' : 'done'; item.output = event.content; } }
367
+ if (event.type === 'refusal') this.notice(t("Anthropic declined (category: {v0}).{v1}", {v0: event.category || t('unspecified'), v1: event.fallback_ran ? t(' Fallback also declined.') : ''}), true);
368
+ if (event.type === 'model_fallback') this.notice(t("Served by fallback model {v0}.", {v0: event.served_by || '—'}));
338
369
  if (event.type === 'auto_decision') this.blocks.push({ role: 'notice', text: `Auto · ${event.decision}: ${event.reason}`, error: event.decision === 'deny' });
339
370
  if (event.type === 'model_progress') { this.turn = event.turn; this.maxTurns = event.maxTurns; }
340
371
  if (event.type === 'phase') {
@@ -346,7 +377,8 @@ export class TerminalScreen {
346
377
  if (event.goal.status !== 'active' && key !== this.lastGoalNotice) { this.lastGoalNotice = key; this.notice(`◎ /goal ${event.goal.status}: ${event.goal.lastReason}`, ['failed', 'budget'].includes(event.goal.status)); }
347
378
  this.goal = event.goal;
348
379
  }
349
- if (event.type === 'result' && !event.usage_includes_subagents) this.usage += (event.usage?.input_tokens || 0) + (event.usage?.output_tokens || 0);
380
+ if (event.type === 'result' && event.timing) this.blocks.push({ role: 'timing', timing: event.timing });
381
+ if (event.type === 'turn_usage' || (event.type === 'result' && !event.usage_includes_subagents)) this.usage += (event.usage?.input_tokens || 0) + (event.usage?.output_tokens || 0);
350
382
  if (event.type === 'work_continuation') this.notice(t("Continues {v0} · {v1}/{v2} calls · checkpoint preserved.", {v0: event.task_id, v1: event.calls, v2: event.limit}));
351
383
  if(event.type==='peer_reply')this.notice(event.status==='failed'?t("The house could not send the report: {v0}", {v0: event.error}):t("The house returned the report to mailbox {v0} · {v1}.", {v0: event.to.slice(0,8), v1: event.status}),event.status==='failed');
352
384
  if (event.type === 'result' && event.work_stop_reason) this.notice(t("Work: {v0}.", {v0: event.work_stop_reason}));
@@ -366,7 +398,7 @@ export class TerminalScreen {
366
398
  async confirm(name, input, signal, { summary, remember, edit } = {}) {
367
399
  if (signal?.aborted || this.closed) return false;
368
400
  if (this.selecting) this.setSelection(false);
369
- return new Promise(resolve => {
401
+ return measureHumanWait(new Promise(resolve => {
370
402
  let settled=false;
371
403
  const finish = allowed => { if(settled)return;settled=true;signal?.removeEventListener('abort', abort);if(this.permission===question)this.permission = null;this.schedule();resolve(allowed); };
372
404
  const abort = () => finish(false);
@@ -374,7 +406,7 @@ export class TerminalScreen {
374
406
  const choices = remember?.rule ? ['once', ...(remember.project ? ['project'] : []), 'user', 'no'] : null;
375
407
  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 } : {}) };
376
408
  this.permission = question;signal?.addEventListener('abort', abort, { once: true });if(signal?.aborted)abort();else this.schedule();
377
- });
409
+ }));
378
410
  }
379
411
  // Editar antes de enviar: la tarjeta se deniega (no se envía nada) y el texto exacto
380
412
  // pasa al composer con una instrucción. El humano lo corrige y lo envía; el modelo
@@ -403,6 +435,20 @@ export class TerminalScreen {
403
435
  try { this.composer.insert(text); this.draftChanged(); } catch (e) { this.notice(e.message, true); }
404
436
  }
405
437
  showBoard(board = this.work) { this.work = board; this.overlay = { kind: 'board', scroll: 0 }; this.schedule(); }
438
+ // Panel /swarm (open-relay, fase 4): la foto llega del host (plugin → ctx.provide → host),
439
+ // sin turnos del modelo. Se limpia al entrar y se vuelve a pintar sola si está a la vista.
440
+ setSwarmView(view) { this.swarmView = normalizeSwarmView(view); if (this.overlay?.kind === 'swarm' || this.swarmStrip) this.schedule(); }
441
+ toggleSwarm() { if (this.overlay?.kind === 'swarm') { this.closeOverlay(); return; } this.overlay = { kind: 'swarm', scroll: 0, swarm: 0, index: 0, follow: true }; this.schedule(); }
442
+ // /timing: one dim line after each final answer; default on, not saved (like /swarm strip).
443
+ setTiming(value = !this.showTiming) { this.showTiming = Boolean(value); this.notice(this.showTiming ? t('Response timing on: a dim line after each answer. /timing off hides it.') : t('Response timing off. /timing on shows it again.')); }
444
+ 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.')); }
445
+ // Enter en el panel: prepara el pedido en el composer (no lo envía). El humano lo envía y
446
+ // las acciones siguen en sus tarjetas; Esc recupera el borrador anterior.
447
+ prepareSwarmRequest() {
448
+ const action = swarmActions(this.swarmView, this.overlay.swarm)[this.overlay.index]; if (!action) return;
449
+ if (!this.preparedDraft) this.preparedDraft = { ...this.composer.snapshot(), sessionId: this.metadata.sessionId };
450
+ this.composer.setText(action.request); this.closeOverlay(); this.draftChanged();
451
+ }
406
452
  setSelection(value = !this.selecting) {
407
453
  if (this.closed || this.suspended || this.permission) return;
408
454
  this.selecting = value; clearTimeout(this.renderTimer); this.renderTimer = null;
@@ -422,7 +468,7 @@ export class TerminalScreen {
422
468
  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
469
  const lines = event.delta * 3;
424
470
  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);
471
+ else if (['help', 'board', 'swarm'].includes(this.overlay?.kind)) this.overlay.scroll = Math.max(0, this.overlay.scroll + lines);
426
472
  else if (this.overlay?.kind === 'sessions' || this.overlay?.kind === 'queue') {
427
473
  const length = this.overlay.kind === 'sessions' ? this.filteredSessions().length : this.queue.items.length;
428
474
  this.overlay.index = Math.max(0, Math.min(Math.max(0, length - 1), this.overlay.index + event.delta));
@@ -487,6 +533,15 @@ export class TerminalScreen {
487
533
  if (this.hasModalOverlay()) {
488
534
  if (name === 'escape') { this.closeOverlay(); return; }
489
535
  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; }
536
+ if (this.overlay.kind === 'swarm') {
537
+ const swarms = this.swarmView?.available ? this.swarmView.swarms.length : 0, actions = swarmActions(this.swarmView, this.overlay.swarm).length;
538
+ 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; }
539
+ else if (name === 'pageup') this.overlay.scroll = Math.max(0, this.overlay.scroll - 5);
540
+ else if (name === 'pagedown') this.overlay.scroll += 5;
541
+ 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; }
542
+ else if (name === 'return' && actions) { this.prepareSwarmRequest(); return; }
543
+ this.schedule(); return;
544
+ }
490
545
  if (this.overlay.kind === 'sessions') {
491
546
  const filtered = this.filteredSessions();
492
547
  if (name === 'up') this.overlay.index = Math.max(0, this.overlay.index - 1);
@@ -564,6 +619,12 @@ export class TerminalScreen {
564
619
  else output.push('', tone.accent(t(" COMAL · on the comal one cooks")), '', ...intro);
565
620
  }
566
621
  for (const block of this.blocks) {
622
+ if (block.role === 'timing') {
623
+ // Rendered each frame (language can change); it sits right under the answer.
624
+ const line = this.showTiming ? formatTurnTiming(block.timing, { width: columns - 3 }) : '';
625
+ if (line) { if (output.at(-1) === '') output.pop(); output.push(tone.muted(clip(' ' + line, columns - 1)), ''); }
626
+ continue;
627
+ }
567
628
  const cacheKey = `${columns}:${this.expandedTools}:${Boolean(this.rawView)}:${block.text || ''}:${block.state || ''}:${block.output || ''}`;
568
629
  if (block.cacheKey === cacheKey) { output.push(...block.cached); continue; }
569
630
  let lines;
@@ -612,6 +673,7 @@ export class TerminalScreen {
612
673
  return (selected ? tone.accent : String)(clip(line, columns));
613
674
  }), ...(!items.length ? [tone.muted(t(" No matches. Change section with Tab."))] : [])];
614
675
  }
676
+ if (this.overlay?.kind === 'swarm' && !this.permission) return swarmPanelLines(this.swarmView, this.overlay, columns, height, { busy: this.busy });
615
677
  if (this.overlay?.kind === 'board' && !this.permission) {
616
678
  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
679
  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 +748,7 @@ export class TerminalScreen {
686
748
  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
749
  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
750
  this.queue.items.slice(0, 6).forEach((item, i) => side.push(tone.accent(` ${i + 1}. `) + clip(safeText(item.text).replace(/\n/g, ' ↵ '), sideWidth - 5), ''));
751
+ if (this.swarmStrip) side.push(...swarmStripLines(this.swarmView, sideWidth));
689
752
  body = body.map((line, i) => pad(line, bodyWidth) + tone.muted(' │') + clip(side[i] || '', sideWidth));
690
753
  }
691
754
  const elapsed = this.startedAt ? ((Date.now() - this.startedAt) / 1000).toFixed(1) : '0.0';