@milpa/comal 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/backend/contracts/remote-commands-v1.json +0 -12
- package/backend/contracts/remote-relay-answers-v1.json +209 -0
- package/package.json +3 -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 +232 -63
- 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 +133 -2
- 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 +99 -6
- package/src/plugin-catalog.mjs +19 -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/omarchy/catalog.mjs +96 -0
- package/src/plugins/omarchy/environment.mjs +78 -0
- package/src/plugins/omarchy/guide.mjs +54 -0
- package/src/plugins/omarchy/index.mjs +213 -0
- package/src/plugins/omarchy/runner.mjs +62 -0
- package/src/plugins/omarchy/text.mjs +26 -0
- package/src/plugins/omarchy/tiers.mjs +157 -0
- package/src/plugins/open-relay/actions.mjs +212 -16
- package/src/plugins/open-relay/client.mjs +93 -35
- package/src/plugins/open-relay/decision.mjs +89 -0
- package/src/plugins/open-relay/dossier.mjs +23 -5
- package/src/plugins/open-relay/index.mjs +68 -28
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +48 -4
- package/src/plugins/open-relay/panel.mjs +59 -0
- package/src/plugins/open-relay/watch.mjs +198 -85
- package/src/plugins.mjs +12 -1
- 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/read-only-check.mjs +12 -4
- package/src/relay/broker.mjs +160 -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 +115 -12
- package/src/remote-relay.mjs +468 -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/swarm-view.mjs +53 -0
- 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/connection-transition.mjs +10 -0
- package/src/tui/controller.mjs +35 -9
- package/src/tui/discovery.mjs +13 -8
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +78 -15
- package/src/tui/swarm-panel.mjs +217 -0
- 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
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { t } from '../i18n/index.mjs';
|
|
2
|
+
import { tone, safeText, wrap, clip } from './text.mjs';
|
|
3
|
+
|
|
4
|
+
// Panel /swarm (fase 4 de open-relay): presentación pura, como el tablero F3. Entra una
|
|
5
|
+
// foto (la que el plugin publica con ctx.provide y el host copia) y salen líneas. Todo
|
|
6
|
+
// texto que viene de relayd o de los agentes es NO confiable: se vuelve a limpiar aquí
|
|
7
|
+
// (escapes de terminal, controles, bidi), se acota y nunca se interpreta. El panel no
|
|
8
|
+
// ejecuta nada: para actuar prepara un pedido en el composer que el humano envía, y las
|
|
9
|
+
// acciones (Reply, Decide, ApprovePlan, RejectPlan) siguen en sus tarjetas confirmadas.
|
|
10
|
+
const BIDI = /[---]/g;
|
|
11
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,199}$/;
|
|
12
|
+
const LIMITS = Object.freeze({ swarms: 16, tasks: 80, roles: 40, pending: 30, plans: 20 });
|
|
13
|
+
const text = (value, limit = 200) => { const clean = safeText(value).replace(BIDI, '').replace(/\s+/g, ' ').trim(); const chars = [...clean]; return chars.length > limit ? chars.slice(0, limit).join('') + '…' : clean; };
|
|
14
|
+
const id = value => { const clean = text(value, 200); return ID.test(clean) ? clean : null; };
|
|
15
|
+
const count = value => { const number = Number(value); return Number.isFinite(number) && number >= 0 ? Math.floor(number) : 0; };
|
|
16
|
+
const time = value => { const number = Number(value); return Number.isFinite(number) && number > 0 ? number : null; };
|
|
17
|
+
const list = (value, limit) => Array.isArray(value) ? value.slice(0, limit) : [];
|
|
18
|
+
|
|
19
|
+
/** Limpia y acota una foto del host. Lo que no es válido se descarta, no se pinta. */
|
|
20
|
+
export function normalizeSwarmView(view) {
|
|
21
|
+
if (!view || typeof view !== 'object') return null;
|
|
22
|
+
if (view.available !== true) return { available: false, reason: text(view.reason || 'unknown', 40), error: view.error ? text(view.error, 600) : null };
|
|
23
|
+
const c = view.connection && typeof view.connection === 'object' ? view.connection : {};
|
|
24
|
+
const connection = {
|
|
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
|
+
events_seen: count(c.events_seen), reconnects: count(c.reconnects),
|
|
27
|
+
last_event: c.last_event ? { seq: text(c.last_event.seq, 24), type: text(c.last_event.type, 60) } : null,
|
|
28
|
+
last_error: c.last_error ? { code: text(c.last_error.code, 60), message: text(c.last_error.message, 400) } : null,
|
|
29
|
+
delegate: c.delegate ? { principal: text(c.delegate.principal, 80), client: text(c.delegate.client, 40), caps: list(c.delegate.caps, 12).map(cap => text(cap, 30)) } : null,
|
|
30
|
+
missing_caps: list(c.missing_caps, 8).map(cap => text(cap, 30)), issue_command: c.issue_command ? text(c.issue_command, 600) : null,
|
|
31
|
+
};
|
|
32
|
+
const swarms = list(view.swarms, LIMITS.swarms).map(swarm => ({
|
|
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), 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
|
+
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
|
+
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,
|
|
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) })),
|
|
40
|
+
}));
|
|
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) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Columnas del kanban, en orden. Los estados de relayd caen en una; lo desconocido, en «otros». */
|
|
45
|
+
export const SWARM_COLUMNS = Object.freeze(['pending', 'running', 'verifying', 'reviewing', 'blocked', 'done', 'closed', 'other']);
|
|
46
|
+
export function swarmColumn(state) {
|
|
47
|
+
if (['draft', 'ready'].includes(state)) return 'pending';
|
|
48
|
+
if (['assigned', 'in_progress'].includes(state)) return 'running';
|
|
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';
|
|
52
|
+
if (state === 'needs_attention' || String(state).startsWith('blocked')) return 'blocked';
|
|
53
|
+
if (state === 'done') return 'done';
|
|
54
|
+
if (['failed', 'cancelled'].includes(state)) return 'closed';
|
|
55
|
+
return 'other';
|
|
56
|
+
}
|
|
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 => {
|
|
65
|
+
if (state === 'needs_attention') return t('needs attention');
|
|
66
|
+
const cause = String(state).split(':')[1];
|
|
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 || '';
|
|
68
|
+
};
|
|
69
|
+
export function formatAge(ms) {
|
|
70
|
+
if (!Number.isFinite(ms) || ms < 0) return '';
|
|
71
|
+
const s = Math.floor(ms / 1000); if (s < 60) return `${s}s`;
|
|
72
|
+
const m = Math.floor(s / 60); if (m < 60) return `${m}m`;
|
|
73
|
+
const h = Math.floor(m / 60); if (h < 48) return `${h}h`;
|
|
74
|
+
return `${Math.floor(h / 24)}d`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Lo pendiente para el humano en un swarm, con el pedido que prepara Enter. Sin ids válidos no hay pedido. */
|
|
78
|
+
export function swarmActions(view, index = 0) {
|
|
79
|
+
const swarm = view?.available ? view.swarms[index] : null; if (!swarm) return [];
|
|
80
|
+
const items = [];
|
|
81
|
+
for (const item of swarm.pending) {
|
|
82
|
+
if (!item.message_id) continue;
|
|
83
|
+
const question = item.kind === 'question', structured = Boolean(item.decision);
|
|
84
|
+
items.push({ kind: structured ? 'decision' : question ? 'question' : 'message', id: item.message_id, item,
|
|
85
|
+
hint: structured ? t('Dossier, then Decide: you choose the option and confirm the card') : question ? t('Dossier, then Reply: you confirm the card') : t('Message, then Reply or Ack'),
|
|
86
|
+
request: structured ? t('Open the decision dossier for the open-relay structured question {id} (plugin__open-relay__Dossier {"message_id":"{id}"}) and present it to me with its options. I will choose the option myself; then propose Decide so I can confirm it on the card.', { id: item.message_id })
|
|
87
|
+
: question ? t('Open the decision dossier for the open-relay question {id} (plugin__open-relay__Dossier {"message_id":"{id}"}) and present it to me. Do not reply yet: I will decide the answer and confirm it on the card.', { id: item.message_id })
|
|
88
|
+
: t('Show me the open-relay message {id} (plugin__open-relay__Message {"message_id":"{id}"}). I will decide whether to reply or acknowledge it.', { id: item.message_id }) });
|
|
89
|
+
}
|
|
90
|
+
for (const plan of swarm.plans) {
|
|
91
|
+
if (!plan.proposal_id) continue;
|
|
92
|
+
items.push({ kind: 'plan', id: plan.proposal_id, item: plan, hint: t('Plan, then ApprovePlan or RejectPlan: you confirm the card'),
|
|
93
|
+
request: t('Show me the open-relay plan proposal {id} (plugin__open-relay__Plan {"proposal_id":"{id}"}) with its tasks, roles and digest. I will decide whether to approve or reject it and confirm it on the card.', { id: plan.proposal_id }) });
|
|
94
|
+
}
|
|
95
|
+
return items;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function connectionLines(view, columns) {
|
|
99
|
+
const c = view.connection, out = [];
|
|
100
|
+
if (c.notify === 'off') out.push(tone.warning(clip(' ' + t('Live watch is off (notify=off): /plugins config open-relay notify=on'), columns)));
|
|
101
|
+
else {
|
|
102
|
+
const who = c.delegate ? t('delegate {principal} via {client} · caps {caps}', { principal: c.delegate.principal, client: c.delegate.client, caps: c.delegate.caps.join(',') || '—' }) : t('delegate not confirmed yet');
|
|
103
|
+
const where = c.cursor != null ? t('cursor {cursor} · {events} events', { cursor: c.cursor, events: c.events_seen }) : '';
|
|
104
|
+
const states = { waiting: [tone.good, t('● connected · live (events.wait)')], catching_up: [tone.accent, t('◌ catching up with relayd')], connecting: [tone.accent, t('◌ connecting')], backoff: [tone.warning, t('◌ relayd unavailable · reconnecting')], needs_human: [tone.bad, t('✗ needs you: the delegate session is missing, revoked or invalid')], stopped: [tone.bad, t('✗ watch stopped')], idle: [tone.muted, t('◌ starting')] };
|
|
105
|
+
const [paint, label] = states[c.state] || [tone.muted, c.state];
|
|
106
|
+
out.push(...wrap([label, ...(c.state === 'waiting' || c.delegate ? [who] : []), where, c.reconnects ? t('{n} reconnections', { n: c.reconnects }) : ''].filter(Boolean).join(' · '), columns - 4).map(line => ' ' + paint(line)));
|
|
107
|
+
}
|
|
108
|
+
if (c.missing_caps.length) out.push(...wrap(t('Missing capabilities: {caps} (the matching actions will be refused)', { caps: c.missing_caps.join(', ') }), columns - 4).map(line => ' ' + tone.warning(line)));
|
|
109
|
+
if (c.last_error && c.state !== 'waiting') out.push(...wrap(`${c.last_error.code}: ${c.last_error.message}`, columns - 6).slice(0, 4).map(line => ' ' + tone.muted(line)));
|
|
110
|
+
if (c.issue_command && (c.state === 'needs_human' || c.missing_caps.length)) out.push(tone.muted(' ' + t('Issue it once (it never prints the token):')), ...wrap(c.issue_command, columns - 6).map(line => ' ' + tone.accent(line)));
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function unavailableLines(view, columns) {
|
|
115
|
+
const lines = { not_configured: [t('The open-relay plugin is not configured in this project.'), t('1. Issue Comal\'s delegate session once: relay --control-dir DIR session issue-delegate --principal YOU --client comal --caps read,converse,plans,decide --ttl 30d --out DIR/comal.token'), t('2. /plugins add open-relay controlDir=DIR'), t('Guide: docs/open-relay-operator.md')],
|
|
116
|
+
disabled: [t('The open-relay plugin is disabled.'), t('/plugins enable open-relay turns it on (you review and trust it).')],
|
|
117
|
+
pending_trust: [t('The open-relay plugin is waiting for your trust.'), t('/plugins enable open-relay reviews it.')],
|
|
118
|
+
loading: [t('The open-relay plugin is starting…')],
|
|
119
|
+
failed: [t('The open-relay plugin could not start.')],
|
|
120
|
+
no_service: [t('This open-relay version does not publish the swarm panel. Reload it: /plugins reload open-relay')],
|
|
121
|
+
invalid: [t('The plugin sent a view that is not valid; nothing is shown.')] }[view.reason] || [t('The swarm panel has no data yet.')];
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const [index, line] of lines.entries()) out.push(...wrap(line, columns - 4).map(part => ' ' + (index ? tone.muted : tone.warning)(part)));
|
|
124
|
+
if (view.error) out.push('', ...wrap(view.error, columns - 6).slice(0, 6).map(line => ' ' + tone.muted(line)));
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Líneas del panel (overlay, como el tablero F3). state: { swarm, index, scroll }.
|
|
130
|
+
* Ajusta state.scroll para que la elección quede a la vista.
|
|
131
|
+
*/
|
|
132
|
+
export function swarmPanelLines(raw, state, columns, height, { now = Date.now(), busy = false } = {}) {
|
|
133
|
+
const view = raw?.available === true || raw?.available === false ? raw : null;
|
|
134
|
+
// Un turno (p. ej. despertado por un aviso urgente) corre con el panel abierto: se escribe en
|
|
135
|
+
// la conversación, detrás; el encabezado lo dice para que el humano vuelva a verlo.
|
|
136
|
+
const hint = busy ? tone.warning(clip(' ' + t('Comal is working · Esc to see the conversation'), columns)) : tone.muted(clip(' ' + t('↑/↓ choose · Enter prepares the request (does not send) · Tab swarm · PgUp/PgDn · Esc returns'), columns));
|
|
137
|
+
if (!view) return [tone.accent(t(' Swarm · open-relay')), hint, '', ' ' + tone.muted(t('Waiting for the open-relay plugin…'))].slice(0, height);
|
|
138
|
+
if (!view.available) { const lines = [tone.accent(t(' Swarm · open-relay')), busy ? hint : tone.muted(clip(' ' + t('Esc returns'), columns)), '', ...unavailableLines(view, columns)]; state.scroll = Math.min(state.scroll || 0, Math.max(0, lines.length - height)); return lines.slice(state.scroll, state.scroll + height); }
|
|
139
|
+
state.swarm = Math.max(0, Math.min(state.swarm || 0, view.swarms.length - 1));
|
|
140
|
+
const swarm = view.swarms[state.swarm], actions = swarmActions(view, state.swarm);
|
|
141
|
+
state.index = Math.max(0, Math.min(state.index || 0, actions.length - 1));
|
|
142
|
+
const lines = [tone.accent(clip(swarm ? t(' Swarm · {id} ({n}/{total})', { id: swarm.id, n: state.swarm + 1, total: view.swarms.length }) : t(' Swarm · open-relay'), columns)), hint, ...connectionLines(view, columns)];
|
|
143
|
+
if (!swarm) {
|
|
144
|
+
lines.push('', ...wrap(view.connection.notify === 'off' ? t('Without the live watch there is no panel data.') : view.connection.state === 'waiting' ? t('relayd has no swarms visible to this delegation yet.') : t('No swarm data yet.'), columns - 4).map(line => ' ' + tone.muted(line)));
|
|
145
|
+
state.scroll = Math.min(state.scroll || 0, Math.max(0, lines.length - height)); return lines.slice(state.scroll, state.scroll + height);
|
|
146
|
+
}
|
|
147
|
+
if (swarm.objective) lines.push(...wrap(swarm.objective, columns - 4).slice(0, 3).map(line => ' ' + line));
|
|
148
|
+
const teams = [...new Set([swarm.root_team, ...swarm.roles.map(role => role.team), ...swarm.tasks.map(task => task.team)].filter(Boolean))];
|
|
149
|
+
const roleNames = new Map(swarm.roles.map(role => [role.id, role.name]));
|
|
150
|
+
if (teams.length) lines.push(...wrap(t('Teams: {teams}', { teams: teams.join(', ') }), columns - 4).slice(0, 2).map(line => ' ' + tone.muted(line)));
|
|
151
|
+
if (swarm.roles.length) lines.push(...wrap(t('Roles: {roles}', { roles: swarm.roles.map(role => `${role.name}${role.name !== role.id ? ` (${role.id})` : ''}${role.occupied ? '' : ' · ' + t('vacant')}${role.model ? ' · ' + role.model : ''}`).join(' · ') }), columns - 4).slice(0, 3).map(line => ' ' + tone.muted(line)));
|
|
152
|
+
lines.push('');
|
|
153
|
+
// Lo que espera al humano: preguntas (marcando las estructuradas) y planes por aprobar.
|
|
154
|
+
let selected = -1;
|
|
155
|
+
if (actions.length) {
|
|
156
|
+
lines.push(tone.warning(t(' FOR YOU ({n}) · agent text, untrusted', { n: actions.length })));
|
|
157
|
+
actions.forEach((action, index) => {
|
|
158
|
+
const mine = index === state.index, mark = mine ? '❯' : ' ', paint = mine ? tone.accent : String;
|
|
159
|
+
if (mine) selected = lines.length;
|
|
160
|
+
if (action.kind === 'plan') {
|
|
161
|
+
const plan = action.item, age = plan.proposed_at ? formatAge(now - plan.proposed_at) : '';
|
|
162
|
+
lines.push(paint(clip(` ${mark} ◇ ${t('Plan {id} · by {role}{age} · {tasks} tasks, {roles} roles', { id: plan.proposal_id, role: plan.proposer_role, age: age ? ' · ' + age : '', tasks: plan.tasks, roles: plan.roles })}${plan.parent_task ? ' · ' + t('under {task}', { task: plan.parent_task }) : ''}`, columns)));
|
|
163
|
+
if (plan.summary) lines.push(...wrap(`«${plan.summary}»`, columns - 8).slice(0, 2).map(line => ' ' + tone.muted(line)));
|
|
164
|
+
} else {
|
|
165
|
+
const item = action.item, age = item.sent_at ? formatAge(now - item.sent_at) : '';
|
|
166
|
+
const label = action.kind === 'decision' ? t('Structured question') : action.kind === 'question' ? t('Question') : t('Message ({kind})', { kind: item.kind });
|
|
167
|
+
lines.push(paint(clip(` ${mark} ${action.kind === 'message' ? '·' : '?'} ${label} ${item.message_id} · ${item.from}${age ? ' · ' + age : ''}${item.task ? ' · ' + t('blocks {task}', { task: item.task }) : ''}`, columns)));
|
|
168
|
+
if (item.decision) lines.push(...wrap(t('«{question}» · options: {options}', { question: item.decision.question, options: item.decision.options.map(option => `«${option}»${option === item.decision.recommended ? ' ' + t('(agent recommends)') : ''}`).join(' | ') }), columns - 8).slice(0, 3).map(line => ' ' + tone.muted(line)));
|
|
169
|
+
else if (item.excerpt) lines.push(...wrap(`«${item.excerpt}»`, columns - 8).slice(0, 2).map(line => ' ' + tone.muted(line)));
|
|
170
|
+
}
|
|
171
|
+
if (mine) lines.push(...wrap('→ ' + action.hint, columns - 8).map(line => ' ' + tone.accent(line)));
|
|
172
|
+
});
|
|
173
|
+
lines.push('');
|
|
174
|
+
} else lines.push(tone.muted(t(' Nothing waiting for you in this swarm.')), '');
|
|
175
|
+
// Kanban: columnas como secciones, igual que el tablero F3.
|
|
176
|
+
for (const column of SWARM_COLUMNS) {
|
|
177
|
+
const tasks = swarm.tasks.filter(task => swarmColumn(task.state) === column);
|
|
178
|
+
if (!tasks.length) continue;
|
|
179
|
+
lines.push(tone.accent(` ${columnLabel(column)} (${tasks.length})`));
|
|
180
|
+
for (const task of tasks) {
|
|
181
|
+
lines.push(...wrap(`${task.id}${task.title ? ' · ' + task.title : ''}`, columns - 6).slice(0, 2).map(line => ' ' + line));
|
|
182
|
+
const role = task.role_name || roleNames.get(task.role), age = task.since ? (task.since_exact ? '' : '≥') + formatAge(now - task.since) : '';
|
|
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(' · ');
|
|
185
|
+
lines.push(...wrap(detail, columns - 8).slice(0, 2).map(line => ' ' + (column === 'blocked' ? tone.warning : tone.muted)(line)));
|
|
186
|
+
}
|
|
187
|
+
lines.push('');
|
|
188
|
+
}
|
|
189
|
+
if (!swarm.tasks.length) lines.push(tone.muted(t(' No tasks in this swarm yet.')), '');
|
|
190
|
+
if (swarm.truncated) lines.push(tone.muted(t(' Some tasks or roles are not shown (panel limit). Ask for plugin__open-relay__Swarm for the full list.')));
|
|
191
|
+
const footer = [view.connection.last_event ? t('last event {seq} · {type}', { seq: view.connection.last_event.seq, type: view.connection.last_event.type }) : '', view.updated_at ? t('updated {time}', { time: new Date(view.updated_at).toTimeString().slice(0, 8) }) : '', t('ages: since Comal saw the state (≥ before the watch)')].filter(Boolean).join(' · ');
|
|
192
|
+
lines.push(...wrap(footer, columns - 4).map(line => ' ' + tone.muted(line)));
|
|
193
|
+
// Desplazamiento: la elección siempre a la vista (como la cola F4); PgUp/PgDn mueve libre.
|
|
194
|
+
const max = Math.max(0, lines.length - height);
|
|
195
|
+
if (state.follow && selected >= 0) { if (selected < state.scroll + 2) state.scroll = Math.max(0, selected - 2); else if (selected >= state.scroll + height - 3) state.scroll = selected - height + 4; state.follow = false; }
|
|
196
|
+
state.scroll = Math.max(0, Math.min(state.scroll || 0, max));
|
|
197
|
+
return lines.slice(state.scroll, state.scroll + height);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Franja lateral compacta (opcional, /swarm strip): resumen siempre visible. */
|
|
201
|
+
export function swarmStripLines(view, width) {
|
|
202
|
+
if (!view) return [];
|
|
203
|
+
const head = tone.accent(clip(' ' + t('Swarm'), width));
|
|
204
|
+
if (!view.available) return ['', head, tone.muted(clip(' ' + t('unavailable · /swarm'), width))];
|
|
205
|
+
const c = view.connection, out = ['', head];
|
|
206
|
+
const state = c.notify === 'off' ? t('watch off') : { waiting: t('● live'), needs_human: t('✗ needs you'), backoff: t('◌ reconnecting'), stopped: t('✗ stopped') }[c.state] || t('◌ connecting');
|
|
207
|
+
out.push((c.state === 'waiting' ? tone.good : c.state === 'needs_human' || c.state === 'stopped' ? tone.bad : tone.muted)(clip(' ' + state, width)));
|
|
208
|
+
for (const swarm of view.swarms.slice(0, 3)) {
|
|
209
|
+
const by = column => swarm.tasks.filter(task => swarmColumn(task.state) === column).length;
|
|
210
|
+
const questions = swarm.pending.filter(item => item.kind === 'question').length, plans = swarm.plans.length;
|
|
211
|
+
out.push(clip(' ' + swarm.id, width));
|
|
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)));
|
|
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)));
|
|
214
|
+
}
|
|
215
|
+
out.push(tone.muted(clip(' ' + t('/swarm opens'), width)));
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
@@ -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';
|