@milpa/comal 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/backend/contracts/remote-commands-v1.json +0 -12
- package/backend/contracts/remote-device-v1.json +423 -0
- package/backend/contracts/remote-relay-answers-v1.json +209 -0
- package/package.json +4 -1
- package/skills/mv3/SKILL.md +44 -0
- package/skills/mv3/skill.json +4 -0
- package/src/agent.mjs +299 -32
- package/src/auto-guard.mjs +40 -3
- package/src/autocompact.mjs +233 -0
- package/src/bash-timeouts.mjs +29 -0
- package/src/child-env.mjs +23 -0
- package/src/cli.mjs +261 -62
- package/src/code-run-artifact.mjs +30 -0
- package/src/code-run-journal.mjs +135 -0
- package/src/code-run-session-proof.mjs +50 -0
- package/src/code-run-session.mjs +97 -0
- package/src/code-run-worker.mjs +20 -4
- package/src/code-run.mjs +49 -14
- package/src/codex-provider.mjs +4 -4
- package/src/compaction-notice.mjs +22 -0
- package/src/compaction.mjs +162 -23
- package/src/connection-settings.mjs +36 -4
- package/src/context-pressure.mjs +5 -4
- package/src/context.mjs +27 -22
- package/src/execution-metrics.mjs +36 -5
- package/src/file-recovery-proof.mjs +9 -1
- package/src/file-recovery.mjs +36 -3
- package/src/gemini-provider.mjs +5 -3
- package/src/git-integration-operations.mjs +2 -1
- package/src/git-plumbing.mjs +2 -1
- package/src/git-source.mjs +2 -1
- package/src/git-workspaces.mjs +2 -1
- package/src/house.mjs +13 -1
- package/src/i18n/cli-es.mjs +12 -1
- package/src/i18n/es.mjs +38 -1
- package/src/judges.mjs +2 -1
- package/src/mcp.mjs +2 -1
- package/src/model-controls.mjs +16 -2
- package/src/model-deadline.mjs +2 -2
- package/src/openai-provider.mjs +8 -4
- package/src/operations.mjs +17 -2
- package/src/package-identity.mjs +68 -0
- package/src/peer-probe.mjs +16 -0
- package/src/permissions.mjs +97 -4
- package/src/plugin-config.mjs +18 -1
- package/src/plugin-controls.mjs +31 -8
- package/src/plugin-trust.mjs +15 -6
- package/src/plugins/chrome-lab/bridge.mjs +19 -6
- package/src/plugins/chrome-lab/client.mjs +242 -12
- package/src/plugins/chrome-lab/index.mjs +106 -24
- package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
- package/src/plugins/chrome-lab/native-reply.mjs +31 -0
- package/src/plugins/open-relay/actions.mjs +60 -9
- package/src/plugins/open-relay/client.mjs +3 -3
- package/src/plugins/open-relay/decision.mjs +3 -2
- package/src/plugins/open-relay/index.mjs +17 -7
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +44 -3
- package/src/plugins/open-relay/panel.mjs +4 -1
- package/src/plugins/open-relay/watch.mjs +27 -8
- package/src/process-lock.mjs +20 -12
- package/src/provider-auth.mjs +6 -3
- package/src/provider-profiles.mjs +13 -3
- package/src/provider.mjs +175 -30
- package/src/qr-scan.mjs +240 -0
- package/src/read-only-check.mjs +6 -1
- package/src/relay/broker.mjs +173 -0
- package/src/relay/cards.mjs +702 -0
- package/src/relay/flow.mjs +886 -0
- package/src/relay/follow.mjs +119 -0
- package/src/relay/git.mjs +23 -0
- package/src/relay/hub-client.mjs +163 -0
- package/src/relay/state.mjs +55 -0
- package/src/remote-control.mjs +283 -15
- package/src/remote-device-card.mjs +111 -0
- package/src/remote-device.mjs +92 -0
- package/src/remote-permissions.mjs +159 -0
- package/src/remote-relay.mjs +468 -0
- package/src/remote-stats.mjs +205 -0
- package/src/session-bus.mjs +13 -3
- package/src/session-provider-settings.mjs +2 -2
- package/src/skill-runtime.mjs +8 -6
- package/src/source-ownership.mjs +3 -1
- package/src/tool-manifest.mjs +71 -0
- package/src/tool-packs.mjs +22 -6
- package/src/tool-schema.mjs +14 -2
- package/src/tools.mjs +96 -27
- package/src/tui/controller.mjs +4 -1
- package/src/tui/discovery.mjs +17 -7
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +53 -14
- package/src/tui/swarm-panel.mjs +19 -8
- package/src/turn-timing.mjs +82 -0
- package/src/usage-accounting.mjs +12 -1
- package/src/vision.mjs +6 -1
- package/src/visual/chrome.mjs +379 -0
- package/src/visual/fingerprint.mjs +30 -0
- package/src/visual/fixtures.mjs +201 -0
- package/src/visual/gate.mjs +92 -0
- package/src/visual/judge.mjs +114 -0
- package/src/visual/ledger.mjs +160 -0
- package/src/visual/measure.mjs +145 -0
- package/src/visual/names.mjs +7 -0
- package/src/visual/operations.mjs +211 -0
- package/src/visual/pixel-diff.mjs +96 -0
- package/src/visual/png.mjs +103 -0
- package/src/visual/registry.mjs +265 -0
- package/src/visual/report.mjs +64 -0
- package/src/visual/verify.mjs +292 -0
- package/src/work-assertions.mjs +15 -4
- package/src/work-plan.mjs +166 -30
- package/src/work-state.mjs +4 -4
- package/src/workflows/chrome-task-adoption.mjs +7 -3
- package/src/workspace-serve.mjs +59 -0
package/src/tui/screen.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' &&
|
|
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;
|
package/src/tui/swarm-panel.mjs
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
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
|
|
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
|
+
}
|
package/src/usage-accounting.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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';
|