@milpa/comal 0.2.2 → 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 (109) 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 +213 -55
  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 +38 -1
  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 +97 -4
  45. package/src/plugin-config.mjs +18 -1
  46. package/src/plugin-controls.mjs +31 -8
  47. package/src/plugin-trust.mjs +15 -6
  48. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  49. package/src/plugins/chrome-lab/client.mjs +242 -12
  50. package/src/plugins/chrome-lab/index.mjs +106 -24
  51. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  52. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  53. package/src/plugins/open-relay/actions.mjs +60 -9
  54. package/src/plugins/open-relay/client.mjs +3 -3
  55. package/src/plugins/open-relay/decision.mjs +3 -2
  56. package/src/plugins/open-relay/index.mjs +17 -7
  57. package/src/plugins/open-relay/lookup.mjs +41 -4
  58. package/src/plugins/open-relay/notices.mjs +44 -3
  59. package/src/plugins/open-relay/panel.mjs +4 -1
  60. package/src/plugins/open-relay/watch.mjs +27 -8
  61. package/src/process-lock.mjs +20 -12
  62. package/src/provider-auth.mjs +6 -3
  63. package/src/provider-profiles.mjs +13 -3
  64. package/src/provider.mjs +175 -30
  65. package/src/read-only-check.mjs +6 -1
  66. package/src/relay/broker.mjs +160 -0
  67. package/src/relay/cards.mjs +702 -0
  68. package/src/relay/flow.mjs +886 -0
  69. package/src/relay/follow.mjs +119 -0
  70. package/src/relay/git.mjs +23 -0
  71. package/src/relay/hub-client.mjs +163 -0
  72. package/src/relay/state.mjs +55 -0
  73. package/src/remote-control.mjs +115 -12
  74. package/src/remote-relay.mjs +468 -0
  75. package/src/session-bus.mjs +13 -3
  76. package/src/session-provider-settings.mjs +2 -2
  77. package/src/skill-runtime.mjs +8 -6
  78. package/src/source-ownership.mjs +3 -1
  79. package/src/tool-manifest.mjs +71 -0
  80. package/src/tool-packs.mjs +22 -6
  81. package/src/tool-schema.mjs +14 -2
  82. package/src/tools.mjs +96 -27
  83. package/src/tui/controller.mjs +4 -1
  84. package/src/tui/discovery.mjs +11 -7
  85. package/src/tui/inbound-blocks.mjs +5 -1
  86. package/src/tui/screen.mjs +53 -14
  87. package/src/tui/swarm-panel.mjs +19 -8
  88. package/src/turn-timing.mjs +82 -0
  89. package/src/usage-accounting.mjs +12 -1
  90. package/src/vision.mjs +6 -1
  91. package/src/visual/chrome.mjs +379 -0
  92. package/src/visual/fingerprint.mjs +30 -0
  93. package/src/visual/fixtures.mjs +201 -0
  94. package/src/visual/gate.mjs +92 -0
  95. package/src/visual/judge.mjs +114 -0
  96. package/src/visual/ledger.mjs +160 -0
  97. package/src/visual/measure.mjs +145 -0
  98. package/src/visual/names.mjs +7 -0
  99. package/src/visual/operations.mjs +211 -0
  100. package/src/visual/pixel-diff.mjs +96 -0
  101. package/src/visual/png.mjs +103 -0
  102. package/src/visual/registry.mjs +265 -0
  103. package/src/visual/report.mjs +64 -0
  104. package/src/visual/verify.mjs +292 -0
  105. package/src/work-assertions.mjs +15 -4
  106. package/src/work-plan.mjs +166 -30
  107. package/src/work-state.mjs +4 -4
  108. package/src/workflows/chrome-task-adoption.mjs +7 -3
  109. package/src/workspace-serve.mjs +59 -0
@@ -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,20 +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';
13
15
  import { normalizeSwarmView, swarmPanelLines, swarmStripLines, swarmActions } from './swarm-panel.mjs';
16
+ import { formatTurnTiming, measureHumanWait } from '../turn-timing.mjs';
14
17
 
15
18
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
16
19
  const MOUSE_ON = '\x1b[?1000h\x1b[?1006h';
17
20
  const MOUSE_OFF = '\x1b[?1000l\x1b[?1006l';
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.";
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.";
19
22
 
20
23
  export class TerminalScreen {
21
24
  constructor({ input = process.stdin, output = process.stdout, metadata = {}, clipboard = copyText, env = process.env } = {}) {
22
25
  Object.assign(this, { input, output, metadata, env }); this.composer = new Composer(); this.markdown = new MarkdownRenderer(); this.tables = new DataTableParser();
23
26
  this.clipboard = clipboard; this.selecting = false;
24
- 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;
25
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;
26
29
  this.discovery = {}; this.completionIndex = 0; this.brandStarted = Date.now();
27
30
  this.callbacks = {}; this.closed = false; this.suspended = false; this.previous = []; this.goal = null; this.usage = 0; this.turn = 0; this.maxTurns = 12;
@@ -62,9 +65,12 @@ export class TerminalScreen {
62
65
  this.setup = state; this.schedule(); return state;
63
66
  }
64
67
  clearSetup() { this.setup?.finish(null); }
65
- 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 } = {}) {
66
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.'));
67
- 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));
68
74
  }
69
75
  setupItems() {
70
76
  const state=this.setup;
@@ -99,6 +105,8 @@ export class TerminalScreen {
99
105
  if (state.kind === 'status' || state.validating) return;
100
106
  if (state.kind === 'choice') {
101
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; }
102
110
  if (name === 'up') state.index = Math.max(0, state.index - 1);
103
111
  else if (name === 'down') state.index = Math.max(0,Math.min(items.length - 1, state.index + 1));
104
112
  else if (name === 'home') state.index = 0;
@@ -133,7 +141,16 @@ export class TerminalScreen {
133
141
  const counter=state.kind==='choice'?t('{index} of {count}',{index:items.length?state.index+1:0,count:items.length}):'';
134
142
  const title=wrap(state.title,room).map(value=>line(colors.bold(colors.text(value))));
135
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));
136
- 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
+ }
137
154
  let prefix=[...header,'',...title,...(description.length?['',...description]:[]),''];
138
155
  if(state.searchable)prefix.push(line(colors.muted(t('Filter')+': ')+colors.text(state.query || t('Type to search…'))),'');
139
156
  // Artwork yields to the actual decision on short terminals and auth screens.
@@ -144,7 +161,7 @@ export class TerminalScreen {
144
161
  }
145
162
  const budget=Math.max(1,rows-Math.min(prefix.length,rows-4)-2);
146
163
  const cancellation=this.ready?t('Ctrl+C cancels'):t('Ctrl+C exits');
147
- 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;
148
165
  let body=[],cursor=null;
149
166
  if(state.kind==='choice'){
150
167
  const cards=items.map((item,index)=>{
@@ -242,8 +259,10 @@ export class TerminalScreen {
242
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.')); }
243
260
  loadSession(session) {
244
261
  this.cacheReadTokens=null;
245
- 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;
246
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); }
247
266
  for (const [responseId, row] of rows.entries()) {
248
267
  const message = row.message, inbound = inboundBlockFromRow(row);
249
268
  if (inbound) { this.blocks.push(inbound); continue; }
@@ -254,7 +273,10 @@ export class TerminalScreen {
254
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); }
255
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; } }
256
275
  }
276
+ if (timingAfter.has(row)) this.blocks.push({ role: 'timing', timing: timingAfter.get(row) });
257
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); }
258
280
  const history = rows.filter(row => row.message.role === 'user' && row.source !== 'goal' && (typeof row.message.content === 'string' || row.source === 'human_composer')).slice(-100);
259
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'));
260
282
  this.composer.historyAttachments = history.map(row=>row.attachments || []);
@@ -268,7 +290,7 @@ export class TerminalScreen {
268
290
  if (!child) { child = { role: 'subagent', id: row.session_id }; this.subagentBlocks.set(row.session_id, child); this.blocks.push(child); }
269
291
  Object.assign(child, row, { role: 'subagent', state: row.status === 'running' ? 'interrupted' : row.status });
270
292
  }
271
- 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)
272
294
  + childUsage;
273
295
  this.schedule();
274
296
  }
@@ -283,13 +305,14 @@ export class TerminalScreen {
283
305
  if (event.tool_argument_chars) this.phase = t("Preparing tools · {v0} characters", {v0: formatNumber(event.tool_argument_chars)});
284
306
  else if (event.reasoning_chars && !event.text_chars) this.phase = t("Model reasoning · {v0} characters", {v0: formatNumber(event.reasoning_chars)});
285
307
  }
308
+ if (event.type === 'model_stream_phase') this.phase = event.phase === 'reasoning' ? t('Reasoning…') : t('Responding…');
286
309
  if (event.type === 'context_usage') this.context = event;
287
310
  if (event.type === 'vision_status') {
288
311
  const stamp = JSON.stringify([event.supported, event.source, event.images, event.references?.map(ref => ref.sha256)]);
289
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")})); }
290
313
  }
291
314
  if (event.type === 'context_projection_start') this.phase = t("Deriving state from the ledger");
292
- 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})); }
293
316
  if (['model_retry', 'response_discarded'].includes(event.type)) {
294
317
  if (this.streaming) { this.streaming.text += t("\n\n[Incomplete response discarded; its tools were not executed.]"); this.streaming = null; }
295
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}));
@@ -324,7 +347,12 @@ export class TerminalScreen {
324
347
  this.schedule(); return;
325
348
  }
326
349
  if (event.type === 'user' && event.source !== 'goal') this.blocks.push({ role: 'user', text: event.userIntent || event.text });
327
- 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);
328
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 ?? '') }); }
329
357
  else if (event.type === 'text_delta') {
330
358
  if (!this.streaming) { this.streaming = { role: 'assistant', text: '' }; this.blocks.push(this.streaming); }
@@ -336,6 +364,8 @@ export class TerminalScreen {
336
364
  }
337
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}); }
338
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 || '—'}));
339
369
  if (event.type === 'auto_decision') this.blocks.push({ role: 'notice', text: `Auto · ${event.decision}: ${event.reason}`, error: event.decision === 'deny' });
340
370
  if (event.type === 'model_progress') { this.turn = event.turn; this.maxTurns = event.maxTurns; }
341
371
  if (event.type === 'phase') {
@@ -347,7 +377,8 @@ export class TerminalScreen {
347
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)); }
348
378
  this.goal = event.goal;
349
379
  }
350
- 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);
351
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}));
352
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');
353
384
  if (event.type === 'result' && event.work_stop_reason) this.notice(t("Work: {v0}.", {v0: event.work_stop_reason}));
@@ -367,7 +398,7 @@ export class TerminalScreen {
367
398
  async confirm(name, input, signal, { summary, remember, edit } = {}) {
368
399
  if (signal?.aborted || this.closed) return false;
369
400
  if (this.selecting) this.setSelection(false);
370
- return new Promise(resolve => {
401
+ return measureHumanWait(new Promise(resolve => {
371
402
  let settled=false;
372
403
  const finish = allowed => { if(settled)return;settled=true;signal?.removeEventListener('abort', abort);if(this.permission===question)this.permission = null;this.schedule();resolve(allowed); };
373
404
  const abort = () => finish(false);
@@ -375,7 +406,7 @@ export class TerminalScreen {
375
406
  const choices = remember?.rule ? ['once', ...(remember.project ? ['project'] : []), 'user', 'no'] : null;
376
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 } : {}) };
377
408
  this.permission = question;signal?.addEventListener('abort', abort, { once: true });if(signal?.aborted)abort();else this.schedule();
378
- });
409
+ }));
379
410
  }
380
411
  // Editar antes de enviar: la tarjeta se deniega (no se envía nada) y el texto exacto
381
412
  // pasa al composer con una instrucción. El humano lo corrige y lo envía; el modelo
@@ -408,6 +439,8 @@ export class TerminalScreen {
408
439
  // sin turnos del modelo. Se limpia al entrar y se vuelve a pintar sola si está a la vista.
409
440
  setSwarmView(view) { this.swarmView = normalizeSwarmView(view); if (this.overlay?.kind === 'swarm' || this.swarmStrip) this.schedule(); }
410
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.')); }
411
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.')); }
412
445
  // Enter en el panel: prepara el pedido en el composer (no lo envía). El humano lo envía y
413
446
  // las acciones siguen en sus tarjetas; Esc recupera el borrador anterior.
@@ -586,6 +619,12 @@ export class TerminalScreen {
586
619
  else output.push('', tone.accent(t(" COMAL · on the comal one cooks")), '', ...intro);
587
620
  }
588
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
+ }
589
628
  const cacheKey = `${columns}:${this.expandedTools}:${Boolean(this.rawView)}:${block.text || ''}:${block.state || ''}:${block.output || ''}`;
590
629
  if (block.cacheKey === cacheKey) { output.push(...block.cached); continue; }
591
630
  let lines;
@@ -22,7 +22,7 @@ export function normalizeSwarmView(view) {
22
22
  if (view.available !== true) return { available: false, reason: text(view.reason || 'unknown', 40), error: view.error ? text(view.error, 600) : null };
23
23
  const c = view.connection && typeof view.connection === 'object' ? view.connection : {};
24
24
  const connection = {
25
- notify: c.notify === 'off' ? 'off' : 'on', state: text(c.state || 'idle', 30), cursor: c.cursor == null ? null : text(c.cursor, 24),
25
+ notify: c.notify === 'off' ? 'off' : 'on', mode: c.notify === 'off' ? 'off' : ['cards', 'wake'].includes(c.mode) ? c.mode : 'on', state: text(c.state || 'idle', 30), cursor: c.cursor == null ? null : text(c.cursor, 24),
26
26
  events_seen: count(c.events_seen), reconnects: count(c.reconnects),
27
27
  last_event: c.last_event ? { seq: text(c.last_event.seq, 24), type: text(c.last_event.type, 60) } : null,
28
28
  last_error: c.last_error ? { code: text(c.last_error.code, 60), message: text(c.last_error.message, 400) } : null,
@@ -31,30 +31,40 @@ export function normalizeSwarmView(view) {
31
31
  };
32
32
  const swarms = list(view.swarms, LIMITS.swarms).map(swarm => ({
33
33
  id: id(swarm?.id) || '?', objective: swarm?.objective ? text(swarm.objective, 300) : null, root_team: swarm?.root_team ? text(swarm.root_team, 120) : null, truncated: swarm?.truncated === true,
34
- tasks: list(swarm?.tasks, LIMITS.tasks).map(task => ({ id: id(task?.id) || '?', title: task?.title ? text(task.title, 160) : null, state: text(task?.state || '?', 40), team: text(task?.team || '', 120), role: text(task?.role || '', 120), role_name: task?.role_name ? text(task.role_name, 60) : null, attempts_used: count(task?.attempts_used), max_attempts: count(task?.max_attempts), since: time(task?.since), since_exact: task?.since_exact === true, infra_exhausted: task?.infra_exhausted === true })),
34
+ tasks: list(swarm?.tasks, LIMITS.tasks).map(task => ({ id: id(task?.id) || '?', title: task?.title ? text(task.title, 160) : null, state: text(task?.state || '?', 40), team: text(task?.team || '', 120), role: text(task?.role || '', 120), role_name: task?.role_name ? text(task.role_name, 60) : null, attempts_used: count(task?.attempts_used), counted_attempts: count(task?.counted_attempts ?? task?.attempts_used), max_attempts: count(task?.max_attempts), since: time(task?.since), since_exact: task?.since_exact === true, infra_exhausted: task?.infra_exhausted === true, wake_cap: task?.wake_cap === true, review_rounds: count(task?.review_rounds), review_kind: ['task', 'integration', 'final'].includes(task?.review_kind) ? task.review_kind : null })),
35
35
  roles: list(swarm?.roles, LIMITS.roles).map(role => ({ id: text(role?.id || '?', 120), name: text(role?.name || role?.id || '?', 60), team: text(role?.team || '', 120), occupied: role?.occupied === true, model: role?.model ? text(role.model, 60) : null })),
36
36
  pending: list(swarm?.pending, LIMITS.pending).map(item => ({ delivery_id: text(item?.delivery_id || '', 200), message_id: id(item?.message_id), kind: text(item?.kind || 'message', 30), from: text(item?.from || '?', 120), sent_at: time(item?.sent_at), excerpt: item?.excerpt ? text(item.excerpt, 240) : null, task: id(item?.task), decision: item?.decision ? { question: text(item.decision.question, 200), options: list(item.decision.options, 4).map(option => text(option, 120)), recommended: item.decision.recommended ? text(item.decision.recommended, 120) : null } : null })),
37
+ // Revisor (open-relay F7): estado de los criterios de éxito del swarm (criteria.get).
38
+ criteria: swarm?.criteria && typeof swarm.criteria === 'object' ? { status: text(swarm.criteria.status || 'none', 20), version: count(swarm.criteria.version), digest: /^[a-f0-9]{64}$/.test(String(swarm.criteria.digest || '')) ? swarm.criteria.digest : null, rounds: count(swarm.criteria.rounds), renegotiating: Boolean(swarm.criteria.renegotiating), in_force: count(swarm.criteria.in_force) } : null,
37
39
  plans: list(swarm?.plans, LIMITS.plans).map(plan => ({ proposal_id: id(plan?.proposal_id), proposer_role: text(plan?.proposer_role || '?', 120), parent_task: id(plan?.parent_task), summary: plan?.summary ? text(plan.summary, 240) : null, proposed_at: time(plan?.proposed_at), tasks: count(plan?.tasks), roles: count(plan?.roles) })),
38
40
  }));
39
41
  return { available: true, connection, swarms, swarms_total: count(view.swarms_total) || swarms.length, updated_at: time(view.updated_at), generated_at: time(view.generated_at) };
40
42
  }
41
43
 
42
44
  /** Columnas del kanban, en orden. Los estados de relayd caen en una; lo desconocido, en «otros». */
43
- export const SWARM_COLUMNS = Object.freeze(['pending', 'running', 'verifying', 'blocked', 'done', 'closed', 'other']);
45
+ export const SWARM_COLUMNS = Object.freeze(['pending', 'running', 'verifying', 'reviewing', 'blocked', 'done', 'closed', 'other']);
44
46
  export function swarmColumn(state) {
45
47
  if (['draft', 'ready'].includes(state)) return 'pending';
46
48
  if (['assigned', 'in_progress'].includes(state)) return 'running';
47
49
  if (state === 'verifying') return 'verifying';
50
+ // Revisor (open-relay F4–F6): con los checks en verde, esperando el veredicto del revisor.
51
+ if (state === 'reviewing') return 'reviewing';
48
52
  if (state === 'needs_attention' || String(state).startsWith('blocked')) return 'blocked';
49
53
  if (state === 'done') return 'done';
50
54
  if (['failed', 'cancelled'].includes(state)) return 'closed';
51
55
  return 'other';
52
56
  }
53
- const columnLabel = column => ({ pending: t('PENDING'), running: t('IN PROGRESS'), verifying: t('VERIFYING'), blocked: t('BLOCKED'), done: t('DONE'), closed: t('FAILED · CANCELLED'), other: t('OTHER') })[column];
54
- const blockLabel = state => {
57
+ // Intentos que cuentan contra el máximo (counted_attempts de relayd); los excusados por
58
+ // infraestructura o reabiertos tras despertar (attempts_used de más) sólo como nota.
59
+ const attemptsLabel = task => {
60
+ const excused = task.attempts_used - task.counted_attempts;
61
+ return excused > 0 ? t('{used}/{max} attempts (+{excused} excused)', { used: task.counted_attempts, max: task.max_attempts, excused }) : t('{used}/{max} attempts', { used: task.counted_attempts, max: task.max_attempts });
62
+ };
63
+ const columnLabel = column => ({ pending: t('PENDING'), running: t('IN PROGRESS'), verifying: t('VERIFYING'), reviewing: t('IN REVIEW'), blocked: t('BLOCKED'), done: t('DONE'), closed: t('FAILED · CANCELLED'), other: t('OTHER') })[column];
64
+ export const blockLabel = state => {
55
65
  if (state === 'needs_attention') return t('needs attention');
56
66
  const cause = String(state).split(':')[1];
57
- return { question: t('waiting for your answer'), approval: t('waiting for approval'), dependency: t('waiting for a dependency'), capacity: t('no free role'), budget: t('budget exhausted'), children: t('waiting for subtasks'), config: t('dispatch configuration'), environment: t('environment') }[cause] || cause || '';
67
+ return { question: t('waiting for your answer'), approval: t('waiting for approval'), dependency: t('waiting for a dependency'), capacity: t('no free role'), budget: t('budget exhausted'), children: t('waiting for subtasks'), config: t('dispatch configuration'), environment: t('environment'), criteria: t('waiting for the success criteria'), review: t('the reviewer is correcting it') }[cause] || cause || '';
58
68
  };
59
69
  export function formatAge(ms) {
60
70
  if (!Number.isFinite(ms) || ms < 0) return '';
@@ -170,7 +180,8 @@ export function swarmPanelLines(raw, state, columns, height, { now = Date.now(),
170
180
  for (const task of tasks) {
171
181
  lines.push(...wrap(`${task.id}${task.title ? ' · ' + task.title : ''}`, columns - 6).slice(0, 2).map(line => ' ' + line));
172
182
  const role = task.role_name || roleNames.get(task.role), age = task.since ? (task.since_exact ? '' : '≥') + formatAge(now - task.since) : '';
173
- const detail = [role ? `${role}${role !== task.role && task.role ? ` (${task.role})` : ''}` : task.role, task.state, column === 'blocked' ? blockLabel(task.state) : '', task.max_attempts ? t('{used}/{max} attempts', { used: task.attempts_used, max: task.max_attempts }) : '', task.infra_exhausted ? t('infrastructure streak exhausted') : '', age].filter(Boolean).join(' · ');
183
+ const review = task.review_kind ? t({ task: 'task review', integration: 'integration review', final: 'final review' }[task.review_kind]) : '';
184
+ const detail = [role ? `${role}${role !== task.role && task.role ? ` (${task.role})` : ''}` : task.role, task.state, column === 'blocked' ? blockLabel(task.state) : '', review, task.review_rounds ? t('reviewer objections: {n}', { n: task.review_rounds }) : '', task.max_attempts ? attemptsLabel(task) : '', task.infra_exhausted ? t('infrastructure streak exhausted') : '', task.wake_cap ? t('wake cap reached: only you can resume it') : '', age].filter(Boolean).join(' · ');
174
185
  lines.push(...wrap(detail, columns - 8).slice(0, 2).map(line => ' ' + (column === 'blocked' ? tone.warning : tone.muted)(line)));
175
186
  }
176
187
  lines.push('');
@@ -198,7 +209,7 @@ export function swarmStripLines(view, width) {
198
209
  const by = column => swarm.tasks.filter(task => swarmColumn(task.state) === column).length;
199
210
  const questions = swarm.pending.filter(item => item.kind === 'question').length, plans = swarm.plans.length;
200
211
  out.push(clip(' ' + swarm.id, width));
201
- out.push(...wrap(t('{run} run · {blocked} blocked · {done} done', { run: by('running') + by('verifying'), blocked: by('blocked'), done: by('done') }), width - 1).slice(0, 2).map(line => tone.muted(' ' + line)));
212
+ out.push(...wrap(t('{run} run · {blocked} blocked · {done} done', { run: by('running') + by('verifying') + by('reviewing'), blocked: by('blocked'), done: by('done') }), width - 1).slice(0, 2).map(line => tone.muted(' ' + line)));
202
213
  if (questions || plans) out.push(...wrap(t('{q} questions · {p} plans', { q: questions, p: plans }), width - 1).slice(0, 2).map(line => tone.warning(' ' + line)));
203
214
  }
204
215
  out.push(tone.muted(clip(' ' + t('/swarm opens'), width)));
@@ -0,0 +1,82 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { performance } from 'node:perf_hooks';
3
+ import { t } from './i18n/index.mjs';
4
+
5
+ // Per-turn timing on the host's monotonic clock. A turn runs inside its timer's
6
+ // async context, so a permission card shown by any tool of that turn (or of a
7
+ // nested subagent turn) is charged as human wait, never as Comal time.
8
+ const active = new AsyncLocalStorage();
9
+ export const monotonic = () => performance.now();
10
+ export const TURN_TIMING_VERSION = 1;
11
+ const round = value => value == null ? null : Math.round(value * 10) / 10;
12
+
13
+ export class TurnTimer {
14
+ constructor({ clock = monotonic, parent = active.getStore() } = {}) {
15
+ this.clock = clock; this.parent = parent; this.started = clock();
16
+ this.requests = []; this.toolsMs = 0; this.humanMs = 0; this.open = 0; this.openedAt = 0;
17
+ this.firstText = null; this.firstOutput = null;
18
+ }
19
+ now() { return this.clock(); }
20
+ run(fn) { return active.run(this, fn); }
21
+ // Overlapping waits (parallel cards) count once: the union of open intervals.
22
+ humanStart() { if (this.open++ === 0) this.openedAt = this.clock(); this.parent?.humanStart(); }
23
+ humanEnd() { if (this.open > 0 && --this.open === 0) this.humanMs += this.clock() - this.openedAt; this.parent?.humanEnd(); }
24
+ humanTotal() { return this.humanMs + (this.open ? this.clock() - this.openedAt : 0); }
25
+ // Accepts a promise or a function that starts the wait (so the clock starts first).
26
+ async humanWait(wait) { this.humanStart(); try { return await (typeof wait === 'function' ? wait() : wait); } finally { this.humanEnd(); } }
27
+ async tools(run) {
28
+ const start = this.clock(), human = this.humanTotal();
29
+ try { return await run(); } finally { this.toolsMs += Math.max(0, this.clock() - start - (this.humanTotal() - human)); }
30
+ }
31
+ observeText() { this.firstText ??= this.clock() - this.started; this.observeOutput(); }
32
+ observeOutput() { this.firstOutput ??= this.clock() - this.started; }
33
+ record(event) { this.requests.push(event); }
34
+ summary({ status = 'answered' } = {}) {
35
+ const total = this.clock() - this.started, human = this.humanTotal();
36
+ const model = this.requests.reduce((sum, item) => sum + (item.phases?.find(phase => phase.phase === 'provider')?.elapsed_ms || 0), 0);
37
+ const reasoned = this.requests.filter(item => item.reasoning_ms != null);
38
+ return {
39
+ version: TURN_TIMING_VERSION, scope: 'host_monotonic_not_provider_billing', status,
40
+ total_ms: round(total), comal_ms: round(Math.max(0, total - human)), model_ms: round(model),
41
+ reasoning_ms: reasoned.length ? round(reasoned.reduce((sum, item) => sum + item.reasoning_ms, 0)) : null,
42
+ first_text_ms: round(this.firstText), first_output_ms: round(this.firstOutput),
43
+ tools_ms: round(this.toolsMs), human_wait_ms: round(human),
44
+ host_ms: round(Math.max(0, total - human - model - this.toolsMs)), requests: this.requests.length,
45
+ reasoning_requests: reasoned.length,
46
+ reasoning_tokens: this.requests.some(item => Number.isSafeInteger(item.reasoning_tokens)) ? this.requests.reduce((sum, item) => sum + (item.reasoning_tokens || 0), 0) : null
47
+ };
48
+ }
49
+ }
50
+
51
+ export function currentTurnTimer() { return active.getStore(); }
52
+ /** Wraps a wait for a human answer; outside a turn it is a no-op. */
53
+ export function measureHumanWait(wait) { const timer = active.getStore(); return timer ? timer.humanWait(wait) : typeof wait === 'function' ? wait() : wait; }
54
+
55
+ export function formatSeconds(ms) {
56
+ if (ms < 1000) return `${Math.round(ms)} ms`;
57
+ const seconds = ms / 1000;
58
+ if (seconds < 60) return `${seconds.toFixed(1)} s`;
59
+ const minutes = Math.floor(seconds / 60);
60
+ return `${minutes} min ${String(Math.round(seconds - minutes * 60)).padStart(2, '0')} s`;
61
+ }
62
+
63
+ /** One dim line for the TUI. Zero or unknown parts are omitted; when `width`
64
+ * is given, the least important parts are dropped instead of cutting a number. */
65
+ export function formatTurnTiming(timing, { width = Infinity } = {}) {
66
+ if (!timing || !(Math.round(timing.comal_ms) > 0)) return '';
67
+ const parts = [];
68
+ const add = (key, value, label) => { if (Math.round(value) > 0) parts.push({ key, text: t(label, { v0: formatSeconds(value) }) }); };
69
+ add('reasoning', timing.reasoning_ms, 'reasoned {v0}');
70
+ add('first', timing.first_output_ms, 'first response {v0}');
71
+ add('tools', timing.tools_ms, 'tools {v0}');
72
+ add('human', timing.human_wait_ms, 'waiting for you {v0}');
73
+ const line = () => [`⏱ ${formatSeconds(timing.comal_ms)}`, ...parts.map(part => part.text)].join(' · ');
74
+ for (const key of ['first', 'tools', 'reasoning']) { if ([...line()].length <= width) break; const index = parts.findIndex(part => part.key === key); if (index >= 0) parts.splice(index, 1); }
75
+ return line();
76
+ }
77
+
78
+ /** Public JSON contract of the final `result` event. */
79
+ export function resultTiming(summary) {
80
+ const { total_ms, model_ms, reasoning_ms, first_text_ms, tools_ms, human_wait_ms, requests, comal_ms, first_output_ms, host_ms, reasoning_requests, reasoning_tokens } = summary;
81
+ return { total_ms, model_ms, reasoning_ms, first_text_ms, tools_ms, human_wait_ms, requests, comal_ms, first_output_ms, host_ms, reasoning_requests, reasoning_tokens };
82
+ }
@@ -8,11 +8,22 @@ export function normalizeUsage(protocol, raw) {
8
8
  let input=null, fresh=null, read=null, write=null, output=null, reasoning=null, semantics='unknown';
9
9
  const fields={};
10
10
  const take=(name,value)=>{const n=count(value);if(n!==null)fields[name]=n;return n;};
11
- if(protocol==='anthropic') {
11
+ const anthropicIters=protocol==='anthropic'&&Array.isArray(raw.iterations)?raw.iterations.filter(entry=>entry&&typeof entry==='object'):null;
12
+ if(protocol==='anthropic'&&anthropicIters&&anthropicIters.length) {
13
+ // usage.iterations es la verdad de facturación por intento (fallback incluido):
14
+ // se suma por intento para el costo. El model de nivel superior cubre sólo el
15
+ // intento que produjo el mensaje; aquí se agrega la cadena entera.
16
+ const add=(name,pick)=>{const vals=anthropicIters.map(pick).map(count);if(vals.length&&vals.every(value=>value!==null)){fields[name]=vals.reduce((a,b)=>a+b,0);return fields[name];}return null;};
17
+ fresh=add('input_tokens',entry=>entry.input_tokens);read=add('cache_read_input_tokens',entry=>entry.cache_read_input_tokens);write=add('cache_creation_input_tokens',entry=>entry.cache_creation_input_tokens);
18
+ output=add('output_tokens',entry=>entry.output_tokens);reasoning=add('thinking_tokens',entry=>entry.output_tokens_details?.thinking_tokens);
19
+ input=sum([fresh,read,write]);if(fresh!==null && read===null && write===null) input=fresh;semantics='cache_components_separate';
20
+ } else if(protocol==='anthropic') {
12
21
  fresh=take('input_tokens',raw.input_tokens);read=take('cache_read_input_tokens',raw.cache_read_input_tokens);write=take('cache_creation_input_tokens',raw.cache_creation_input_tokens);
13
22
  // Anthropic omits absent cache components on uncached responses; omission is
14
23
  // not observed zero. The base is exact only if all components are reported.
15
24
  input=sum([fresh,read,write]);output=take('output_tokens',raw.output_tokens);semantics='cache_components_separate';
25
+ // Claude 5.x reports thinking inside output_tokens (output_tokens_details.thinking_tokens).
26
+ reasoning=take('thinking_tokens',raw.output_tokens_details?.thinking_tokens);
16
27
  if(fresh!==null && read===null && write===null) input=fresh;
17
28
  } else if(['openai','codex'].includes(protocol)) {
18
29
  input=take(protocol==='openai'?'prompt_tokens':'input_tokens',protocol==='openai'?raw.prompt_tokens:raw.input_tokens);
package/src/vision.mjs CHANGED
@@ -4,10 +4,15 @@ import { workProjection } from './work-plan.mjs';
4
4
  import { comalEnvironment } from './compat.mjs';
5
5
  import { digest } from './operations.mjs';
6
6
  import { humanImageReferences, resolveHumanImage } from './image-attachments.mjs';
7
+ import { VISUAL_IMAGE_OPERATIONS } from './visual/names.mjs';
7
8
 
8
9
  export const IMAGE_LIMIT = 2 * 1024 * 1024;
9
10
  export const IMAGE_COUNT = 2;
10
- const mcpReceipt = row => row.origin?.kind === 'mcp' || row.origin == null && /^mcp__.+__.+$/.test(row.operation || '');
11
+ // Image-bearing receipts: MCP tools, the native Read of an image file, the native visual
12
+ // acceptance operations, and exactly one plugin receipt (chrome-lab Screenshot), which carries
13
+ // the same structured image block.
14
+ const mcpReceipt = row => row.origin?.plugin === 'chrome-lab' && row.operation === 'plugin__chrome-lab__Screenshot'
15
+ || row.origin?.kind === 'mcp' || row.origin == null && (/^mcp__.+__.+$/.test(row.operation || '') || row.operation === 'Read' || VISUAL_IMAGE_OPERATIONS.includes(row.operation));
11
16
  export function visionSettings(options = {}, env = process.env) {
12
17
  env = comalEnvironment(env);
13
18
  const vision = options.vision ?? env.COMAL_VISION ?? 'auto';