@milpa/comal 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
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', 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), max_attempts: count(task?.max_attempts), since: time(task?.since), since_exact: task?.since_exact === true, infra_exhausted: task?.infra_exhausted === true })),
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
+ 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
+ }));
39
+ 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
+ }
41
+
42
+ /** 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']);
44
+ export function swarmColumn(state) {
45
+ if (['draft', 'ready'].includes(state)) return 'pending';
46
+ if (['assigned', 'in_progress'].includes(state)) return 'running';
47
+ if (state === 'verifying') return 'verifying';
48
+ if (state === 'needs_attention' || String(state).startsWith('blocked')) return 'blocked';
49
+ if (state === 'done') return 'done';
50
+ if (['failed', 'cancelled'].includes(state)) return 'closed';
51
+ return 'other';
52
+ }
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 => {
55
+ if (state === 'needs_attention') return t('needs attention');
56
+ 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 || '';
58
+ };
59
+ export function formatAge(ms) {
60
+ if (!Number.isFinite(ms) || ms < 0) return '';
61
+ const s = Math.floor(ms / 1000); if (s < 60) return `${s}s`;
62
+ const m = Math.floor(s / 60); if (m < 60) return `${m}m`;
63
+ const h = Math.floor(m / 60); if (h < 48) return `${h}h`;
64
+ return `${Math.floor(h / 24)}d`;
65
+ }
66
+
67
+ /** Lo pendiente para el humano en un swarm, con el pedido que prepara Enter. Sin ids válidos no hay pedido. */
68
+ export function swarmActions(view, index = 0) {
69
+ const swarm = view?.available ? view.swarms[index] : null; if (!swarm) return [];
70
+ const items = [];
71
+ for (const item of swarm.pending) {
72
+ if (!item.message_id) continue;
73
+ const question = item.kind === 'question', structured = Boolean(item.decision);
74
+ items.push({ kind: structured ? 'decision' : question ? 'question' : 'message', id: item.message_id, item,
75
+ 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'),
76
+ 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 })
77
+ : 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 })
78
+ : 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 }) });
79
+ }
80
+ for (const plan of swarm.plans) {
81
+ if (!plan.proposal_id) continue;
82
+ items.push({ kind: 'plan', id: plan.proposal_id, item: plan, hint: t('Plan, then ApprovePlan or RejectPlan: you confirm the card'),
83
+ 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 }) });
84
+ }
85
+ return items;
86
+ }
87
+
88
+ function connectionLines(view, columns) {
89
+ const c = view.connection, out = [];
90
+ if (c.notify === 'off') out.push(tone.warning(clip(' ' + t('Live watch is off (notify=off): /plugins config open-relay notify=on'), columns)));
91
+ else {
92
+ 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');
93
+ const where = c.cursor != null ? t('cursor {cursor} · {events} events', { cursor: c.cursor, events: c.events_seen }) : '';
94
+ 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')] };
95
+ const [paint, label] = states[c.state] || [tone.muted, c.state];
96
+ 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)));
97
+ }
98
+ 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)));
99
+ 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)));
100
+ 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)));
101
+ return out;
102
+ }
103
+
104
+ function unavailableLines(view, columns) {
105
+ 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')],
106
+ disabled: [t('The open-relay plugin is disabled.'), t('/plugins enable open-relay turns it on (you review and trust it).')],
107
+ pending_trust: [t('The open-relay plugin is waiting for your trust.'), t('/plugins enable open-relay reviews it.')],
108
+ loading: [t('The open-relay plugin is starting…')],
109
+ failed: [t('The open-relay plugin could not start.')],
110
+ no_service: [t('This open-relay version does not publish the swarm panel. Reload it: /plugins reload open-relay')],
111
+ invalid: [t('The plugin sent a view that is not valid; nothing is shown.')] }[view.reason] || [t('The swarm panel has no data yet.')];
112
+ const out = [];
113
+ for (const [index, line] of lines.entries()) out.push(...wrap(line, columns - 4).map(part => ' ' + (index ? tone.muted : tone.warning)(part)));
114
+ if (view.error) out.push('', ...wrap(view.error, columns - 6).slice(0, 6).map(line => ' ' + tone.muted(line)));
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * Líneas del panel (overlay, como el tablero F3). state: { swarm, index, scroll }.
120
+ * Ajusta state.scroll para que la elección quede a la vista.
121
+ */
122
+ export function swarmPanelLines(raw, state, columns, height, { now = Date.now(), busy = false } = {}) {
123
+ const view = raw?.available === true || raw?.available === false ? raw : null;
124
+ // Un turno (p. ej. despertado por un aviso urgente) corre con el panel abierto: se escribe en
125
+ // la conversación, detrás; el encabezado lo dice para que el humano vuelva a verlo.
126
+ 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));
127
+ if (!view) return [tone.accent(t(' Swarm · open-relay')), hint, '', ' ' + tone.muted(t('Waiting for the open-relay plugin…'))].slice(0, height);
128
+ 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); }
129
+ state.swarm = Math.max(0, Math.min(state.swarm || 0, view.swarms.length - 1));
130
+ const swarm = view.swarms[state.swarm], actions = swarmActions(view, state.swarm);
131
+ state.index = Math.max(0, Math.min(state.index || 0, actions.length - 1));
132
+ 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)];
133
+ if (!swarm) {
134
+ 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)));
135
+ state.scroll = Math.min(state.scroll || 0, Math.max(0, lines.length - height)); return lines.slice(state.scroll, state.scroll + height);
136
+ }
137
+ if (swarm.objective) lines.push(...wrap(swarm.objective, columns - 4).slice(0, 3).map(line => ' ' + line));
138
+ const teams = [...new Set([swarm.root_team, ...swarm.roles.map(role => role.team), ...swarm.tasks.map(task => task.team)].filter(Boolean))];
139
+ const roleNames = new Map(swarm.roles.map(role => [role.id, role.name]));
140
+ if (teams.length) lines.push(...wrap(t('Teams: {teams}', { teams: teams.join(', ') }), columns - 4).slice(0, 2).map(line => ' ' + tone.muted(line)));
141
+ 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)));
142
+ lines.push('');
143
+ // Lo que espera al humano: preguntas (marcando las estructuradas) y planes por aprobar.
144
+ let selected = -1;
145
+ if (actions.length) {
146
+ lines.push(tone.warning(t(' FOR YOU ({n}) · agent text, untrusted', { n: actions.length })));
147
+ actions.forEach((action, index) => {
148
+ const mine = index === state.index, mark = mine ? '❯' : ' ', paint = mine ? tone.accent : String;
149
+ if (mine) selected = lines.length;
150
+ if (action.kind === 'plan') {
151
+ const plan = action.item, age = plan.proposed_at ? formatAge(now - plan.proposed_at) : '';
152
+ 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)));
153
+ if (plan.summary) lines.push(...wrap(`«${plan.summary}»`, columns - 8).slice(0, 2).map(line => ' ' + tone.muted(line)));
154
+ } else {
155
+ const item = action.item, age = item.sent_at ? formatAge(now - item.sent_at) : '';
156
+ const label = action.kind === 'decision' ? t('Structured question') : action.kind === 'question' ? t('Question') : t('Message ({kind})', { kind: item.kind });
157
+ 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)));
158
+ 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)));
159
+ else if (item.excerpt) lines.push(...wrap(`«${item.excerpt}»`, columns - 8).slice(0, 2).map(line => ' ' + tone.muted(line)));
160
+ }
161
+ if (mine) lines.push(...wrap('→ ' + action.hint, columns - 8).map(line => ' ' + tone.accent(line)));
162
+ });
163
+ lines.push('');
164
+ } else lines.push(tone.muted(t(' Nothing waiting for you in this swarm.')), '');
165
+ // Kanban: columnas como secciones, igual que el tablero F3.
166
+ for (const column of SWARM_COLUMNS) {
167
+ const tasks = swarm.tasks.filter(task => swarmColumn(task.state) === column);
168
+ if (!tasks.length) continue;
169
+ lines.push(tone.accent(` ${columnLabel(column)} (${tasks.length})`));
170
+ for (const task of tasks) {
171
+ lines.push(...wrap(`${task.id}${task.title ? ' · ' + task.title : ''}`, columns - 6).slice(0, 2).map(line => ' ' + line));
172
+ 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(' · ');
174
+ lines.push(...wrap(detail, columns - 8).slice(0, 2).map(line => ' ' + (column === 'blocked' ? tone.warning : tone.muted)(line)));
175
+ }
176
+ lines.push('');
177
+ }
178
+ if (!swarm.tasks.length) lines.push(tone.muted(t(' No tasks in this swarm yet.')), '');
179
+ 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.')));
180
+ 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(' · ');
181
+ lines.push(...wrap(footer, columns - 4).map(line => ' ' + tone.muted(line)));
182
+ // Desplazamiento: la elección siempre a la vista (como la cola F4); PgUp/PgDn mueve libre.
183
+ const max = Math.max(0, lines.length - height);
184
+ 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; }
185
+ state.scroll = Math.max(0, Math.min(state.scroll || 0, max));
186
+ return lines.slice(state.scroll, state.scroll + height);
187
+ }
188
+
189
+ /** Franja lateral compacta (opcional, /swarm strip): resumen siempre visible. */
190
+ export function swarmStripLines(view, width) {
191
+ if (!view) return [];
192
+ const head = tone.accent(clip(' ' + t('Swarm'), width));
193
+ if (!view.available) return ['', head, tone.muted(clip(' ' + t('unavailable · /swarm'), width))];
194
+ const c = view.connection, out = ['', head];
195
+ 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');
196
+ out.push((c.state === 'waiting' ? tone.good : c.state === 'needs_human' || c.state === 'stopped' ? tone.bad : tone.muted)(clip(' ' + state, width)));
197
+ for (const swarm of view.swarms.slice(0, 3)) {
198
+ const by = column => swarm.tasks.filter(task => swarmColumn(task.state) === column).length;
199
+ const questions = swarm.pending.filter(item => item.kind === 'question').length, plans = swarm.plans.length;
200
+ 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)));
202
+ 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
+ }
204
+ out.push(tone.muted(clip(' ' + t('/swarm opens'), width)));
205
+ return out;
206
+ }
@@ -3,6 +3,8 @@ $ErrorActionPreference = 'Stop'
3
3
  [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
4
4
  $currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User
5
5
  $trusted = @($currentSid.Value, 'S-1-5-18', 'S-1-5-32-544')
6
+ [Console]::Out.WriteLine('{"ready":true}')
7
+ [Console]::Out.Flush()
6
8
 
7
9
  function Assert-Private([string] $path, [bool] $directory) {
8
10
  $attributes = [IO.File]::GetAttributes($path)