@milpa/comal 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  4. package/package.json +3 -1
  5. package/skills/mv3/SKILL.md +44 -0
  6. package/skills/mv3/skill.json +4 -0
  7. package/src/agent.mjs +299 -32
  8. package/src/auto-guard.mjs +40 -3
  9. package/src/autocompact.mjs +233 -0
  10. package/src/bash-timeouts.mjs +29 -0
  11. package/src/child-env.mjs +23 -0
  12. package/src/cli.mjs +232 -63
  13. package/src/code-run-artifact.mjs +30 -0
  14. package/src/code-run-journal.mjs +135 -0
  15. package/src/code-run-session-proof.mjs +50 -0
  16. package/src/code-run-session.mjs +97 -0
  17. package/src/code-run-worker.mjs +20 -4
  18. package/src/code-run.mjs +49 -14
  19. package/src/codex-provider.mjs +4 -4
  20. package/src/compaction-notice.mjs +22 -0
  21. package/src/compaction.mjs +162 -23
  22. package/src/connection-settings.mjs +36 -4
  23. package/src/context-pressure.mjs +5 -4
  24. package/src/context.mjs +27 -22
  25. package/src/execution-metrics.mjs +36 -5
  26. package/src/file-recovery-proof.mjs +9 -1
  27. package/src/file-recovery.mjs +36 -3
  28. package/src/gemini-provider.mjs +5 -3
  29. package/src/git-integration-operations.mjs +2 -1
  30. package/src/git-plumbing.mjs +2 -1
  31. package/src/git-source.mjs +2 -1
  32. package/src/git-workspaces.mjs +2 -1
  33. package/src/house.mjs +13 -1
  34. package/src/i18n/cli-es.mjs +12 -1
  35. package/src/i18n/es.mjs +133 -2
  36. package/src/judges.mjs +2 -1
  37. package/src/mcp.mjs +2 -1
  38. package/src/model-controls.mjs +16 -2
  39. package/src/model-deadline.mjs +2 -2
  40. package/src/openai-provider.mjs +8 -4
  41. package/src/operations.mjs +17 -2
  42. package/src/package-identity.mjs +68 -0
  43. package/src/peer-probe.mjs +16 -0
  44. package/src/permissions.mjs +99 -6
  45. package/src/plugin-catalog.mjs +19 -4
  46. package/src/plugin-config.mjs +18 -1
  47. package/src/plugin-controls.mjs +31 -8
  48. package/src/plugin-trust.mjs +15 -6
  49. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  50. package/src/plugins/chrome-lab/client.mjs +242 -12
  51. package/src/plugins/chrome-lab/index.mjs +106 -24
  52. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  53. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  54. package/src/plugins/omarchy/catalog.mjs +96 -0
  55. package/src/plugins/omarchy/environment.mjs +78 -0
  56. package/src/plugins/omarchy/guide.mjs +54 -0
  57. package/src/plugins/omarchy/index.mjs +213 -0
  58. package/src/plugins/omarchy/runner.mjs +62 -0
  59. package/src/plugins/omarchy/text.mjs +26 -0
  60. package/src/plugins/omarchy/tiers.mjs +157 -0
  61. package/src/plugins/open-relay/actions.mjs +212 -16
  62. package/src/plugins/open-relay/client.mjs +93 -35
  63. package/src/plugins/open-relay/decision.mjs +89 -0
  64. package/src/plugins/open-relay/dossier.mjs +23 -5
  65. package/src/plugins/open-relay/index.mjs +68 -28
  66. package/src/plugins/open-relay/lookup.mjs +41 -4
  67. package/src/plugins/open-relay/notices.mjs +48 -4
  68. package/src/plugins/open-relay/panel.mjs +59 -0
  69. package/src/plugins/open-relay/watch.mjs +198 -85
  70. package/src/plugins.mjs +12 -1
  71. package/src/process-lock.mjs +20 -12
  72. package/src/provider-auth.mjs +6 -3
  73. package/src/provider-profiles.mjs +13 -3
  74. package/src/provider.mjs +175 -30
  75. package/src/read-only-check.mjs +12 -4
  76. package/src/relay/broker.mjs +160 -0
  77. package/src/relay/cards.mjs +702 -0
  78. package/src/relay/flow.mjs +886 -0
  79. package/src/relay/follow.mjs +119 -0
  80. package/src/relay/git.mjs +23 -0
  81. package/src/relay/hub-client.mjs +163 -0
  82. package/src/relay/state.mjs +55 -0
  83. package/src/remote-control.mjs +115 -12
  84. package/src/remote-relay.mjs +468 -0
  85. package/src/session-bus.mjs +13 -3
  86. package/src/session-provider-settings.mjs +2 -2
  87. package/src/skill-runtime.mjs +8 -6
  88. package/src/source-ownership.mjs +3 -1
  89. package/src/swarm-view.mjs +53 -0
  90. package/src/tool-manifest.mjs +71 -0
  91. package/src/tool-packs.mjs +22 -6
  92. package/src/tool-schema.mjs +14 -2
  93. package/src/tools.mjs +96 -27
  94. package/src/tui/connection-transition.mjs +10 -0
  95. package/src/tui/controller.mjs +35 -9
  96. package/src/tui/discovery.mjs +13 -8
  97. package/src/tui/inbound-blocks.mjs +5 -1
  98. package/src/tui/screen.mjs +78 -15
  99. package/src/tui/swarm-panel.mjs +217 -0
  100. package/src/turn-timing.mjs +82 -0
  101. package/src/usage-accounting.mjs +12 -1
  102. package/src/vision.mjs +6 -1
  103. package/src/visual/chrome.mjs +379 -0
  104. package/src/visual/fingerprint.mjs +30 -0
  105. package/src/visual/fixtures.mjs +201 -0
  106. package/src/visual/gate.mjs +92 -0
  107. package/src/visual/judge.mjs +114 -0
  108. package/src/visual/ledger.mjs +160 -0
  109. package/src/visual/measure.mjs +145 -0
  110. package/src/visual/names.mjs +7 -0
  111. package/src/visual/operations.mjs +211 -0
  112. package/src/visual/pixel-diff.mjs +96 -0
  113. package/src/visual/png.mjs +103 -0
  114. package/src/visual/registry.mjs +265 -0
  115. package/src/visual/report.mjs +64 -0
  116. package/src/visual/verify.mjs +292 -0
  117. package/src/work-assertions.mjs +15 -4
  118. package/src/work-plan.mjs +166 -30
  119. package/src/work-state.mjs +4 -4
  120. package/src/workflows/chrome-task-adoption.mjs +7 -3
  121. package/src/workspace-serve.mjs +59 -0
@@ -0,0 +1,468 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { line, criteriaWaiting, criteriaWarnings, warningSummary } from './relay/cards.mjs';
6
+ import { swarmFinished, blockedForOperator, blockedKey, blockedReason } from './relay/follow.mjs';
7
+ import { decisionBlock } from './plugins/open-relay/decision.mjs';
8
+ import { normalizeSwarmView, swarmColumn, SWARM_COLUMNS } from './tui/swarm-panel.mjs';
9
+
10
+ // /rc + /relay: proyección del tablero de /swarm y de las tarjetas de /relay hacia el navegador
11
+ // de Remote Control (fase «ver») y la marca de qué tarjetas puede responder (fase «responder»).
12
+ // Es una proyección, no autoridad: ningún evento relay.* habilita nada. Las respuestas del
13
+ // navegador llegan como controles tipados (remote-relay-answers-v1.json) que el host revalida
14
+ // y aplica con las mismas funciones que la TUI (src/relay/broker.mjs). Contrato de eventos:
15
+ // backend/contracts/remote-relay-v1.schema.json (aditivo: criteria, escalation y la columna
16
+ // reviewing sólo salen hacia un backend que admite relay_answers_v1).
17
+ //
18
+ // Lista blanca, nunca copia de la foto del panel: no salen control_dir, issue_command,
19
+ // tokenFile, la raíz del hub, el repo absoluto (sólo su nombre base), la UI local de relayd,
20
+ // el diff, las capturas ni el diseño. El texto de los agentes no es confiable: se limpia con
21
+ // el line() de las tarjetas, se le quitan rutas de casa, direcciones de loopback y cosas con
22
+ // forma de secreto, se acota y viaja marcado `untrusted`; el navegador lo pinta con textContent.
23
+
24
+ export const RELAY_EVENT_BYTES = 36000; // por evento, bajo el techo de 48 KiB con sobre
25
+ export const RELAY_PLAN_PAGES = 5; // relay.card + ≤ 4 relay.card.page
26
+ export const RELAY_BOARD_TASKS = 40; // tareas con detalle en el tablero (en total)
27
+ export const RELAY_BOARD_SWARMS = 4; // enjambres con detalle en el tablero
28
+ export const RELAY_SESSION_EVENTS = 2000; // presupuesto por sesión de /rc
29
+ export const RELAY_SESSION_BYTES = 16 << 20;
30
+ export const RELAY_BOARD_INTERVAL_MS = 3000; // tablero coalescido
31
+
32
+ const sha256 = value => createHash('sha256').update(value).digest('hex');
33
+ /** Contrato navegador → host de /relay (acciones, campos, texto, capacidades y step-up). */
34
+ export const RELAY_ANSWERS = JSON.parse(readFileSync(new URL('../backend/contracts/remote-relay-answers-v1.json', import.meta.url), 'utf8'));
35
+ const SAFE = Object.fromEntries(['project', 'swarm', 'subject'].map(field => [field, new RegExp(RELAY_ANSWERS.control.properties[field].pattern)]));
36
+ export const relayBytes = value => Buffer.byteLength(JSON.stringify(value));
37
+
38
+ // Lo que nunca debe salir aunque lo escriba un agente: rutas de casa, loopback con puerto y
39
+ // formas típicas de credencial (relayd orly_, claves de proveedores, bearer).
40
+ const REDACTIONS = [
41
+ [/(?:\/home|\/Users)\/[^/\s'"`»«)]+/g, '~'],
42
+ [/(?<![\w.-])\/root(?=\/)/g, '~'],
43
+ [/\borly_[A-Za-z0-9_-]{4,}/g, '[redacted]'],
44
+ [/\b(?:sk|rk|pk)-[A-Za-z0-9_-]{16,}/g, '[redacted]'],
45
+ [/\bgh[pousr]_[A-Za-z0-9]{16,}/g, '[redacted]'],
46
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, '[redacted]'],
47
+ [/\bAKIA[0-9A-Z]{16}\b/g, '[redacted]'],
48
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, 'Bearer [redacted]'],
49
+ [/\b(?:https?:\/\/)?(?:127(?:\.\d{1,3}){3}|localhost|\[::1\]|0\.0\.0\.0)(?::\d{1,5})?(?:\/\S*)?/gi, '[local address]'],
50
+ ];
51
+ /** Texto ajeno: sin secretos con forma conocida, limpio (line) y acotado a `limit` caracteres. */
52
+ export function scrub(value, limit) {
53
+ let text = String(value ?? '');
54
+ for (const [pattern, replacement] of REDACTIONS) text = text.replace(pattern, replacement);
55
+ return line(text, limit);
56
+ }
57
+ const txt = (value, limit) => scrub(value, limit);
58
+ const opt = (value, limit) => { const text = value == null ? '' : scrub(value, limit); return text || null; };
59
+ const int = value => { const n = Number(value); return Number.isFinite(n) && n >= 0 ? Math.min(Math.floor(n), 2 ** 31 - 1) : 0; };
60
+ const time = value => { const n = Number(value); return Number.isFinite(n) && n > 0 ? Math.floor(n) : null; };
61
+ const hex = (value, size) => { const text = String(value || '').toLowerCase(); return new RegExp(`^[a-f0-9]{${size}}$`).test(text) ? text : null; };
62
+
63
+ /** Identidad estable de una tarjeta: el mismo plan re-propuesto (otro digest) es otra tarjeta. */
64
+ export const relayCardId = (project, swarm, kind, subject, version = '') => sha256(JSON.stringify(['relay-card/1', project || '', swarm || '', kind, subject || '', version || ''])).slice(0, 32);
65
+ /** Etiqueta de una opción tal como la ve el navegador (el host compara su sha256). */
66
+ export const relayOptionLabel = label => scrub(label, 120);
67
+ /**
68
+ * ¿Puede el navegador responder esta tarjeta? Sólo las clases del contrato de respuestas, sólo
69
+ * hacia un backend con relay_answers_v1, sólo con ids que el control admite (sin rutas) y sólo
70
+ * las acciones cuyas capacidades tiene la delegación (null = desconocidas: decide relayd). El
71
+ * armado local (/rc relay on) no va aquí: el navegador lo ve en vivo en el estado de la sesión
72
+ * y el host lo vuelve a comprobar al reclamar cada control.
73
+ */
74
+ export function relayAnswerable(kind, { answers = false, caps = null, ids = {} } = {}) {
75
+ const names = Object.entries(RELAY_ANSWERS.actions).filter(([, action]) => action.card === kind).map(([name]) => name);
76
+ if (!names.length) return { browser: false, why: 'terminal_only' };
77
+ if (!answers) return { browser: false, why: 'host_legacy' };
78
+ if (!Object.entries(ids).every(([field, value]) => SAFE[field].test(String(value ?? '')))) return { browser: false, why: 'terminal_only' };
79
+ const actions = Array.isArray(caps) ? names.filter(name => RELAY_ANSWERS.actions[name].caps.every(cap => caps.includes(cap))) : names;
80
+ return actions.length ? { browser: true, actions } : { browser: false, why: 'cap_missing' };
81
+ }
82
+
83
+ // ── Tablero ────────────────────────────────────────────────────────────────
84
+ const PRIORITY = ['blocked', 'running', 'reviewing', 'verifying', 'pending', 'other', 'done', 'closed'];
85
+ // La columna «en revisión» de /swarm (revisor, open-relay F7) es aditiva en el contrato: sólo
86
+ // sale hacia un backend que la admite (relay_answers_v1); hacia uno anterior cuenta como
87
+ // `verifying` y la tarea conserva su estado real (`reviewing`).
88
+ const columnsFor = reviewing => SWARM_COLUMNS.filter(column => reviewing || column !== 'reviewing');
89
+
90
+ /** Tablero de /swarm para el navegador: ≤ 4 enjambres con detalle y ≤ 40 tareas, ≤ 36.000 B. */
91
+ export function projectRelayBoard(view, { project = null, repo = null, at = Date.now(), reviewing = false } = {}) {
92
+ const v = normalizeSwarmView(view);
93
+ const rcColumn = state => { const column = swarmColumn(state); return column === 'reviewing' && !reviewing ? 'verifying' : column; };
94
+ const emptyCounts = () => Object.fromEntries(columnsFor(reviewing).map(column => [column, 0]));
95
+ const base = { shape: 'board', version: 1, project: opt(project, 60), repo_name: repo ? opt(path.basename(String(repo)), 120) : null };
96
+ if (!v?.available) return { ...base, available: false, reason: txt(v?.reason || 'unavailable', 40), connection: null, swarms: [], swarms_total: 0, pending: { plans: 0, questions: 0, blocked: 0 }, omitted: { swarms: 0, tasks: 0 }, source: { view_generated_at: time(at) || 1 } };
97
+ const c = v.connection || {};
98
+ const connection = { state: txt(c.state || 'idle', 30), mode: ['cards', 'on', 'off'].includes(c.mode) ? c.mode : 'on', caps: (c.delegate?.caps || []).slice(0, 12).map(cap => txt(cap, 30)), missing_caps: (c.missing_caps || []).slice(0, 8).map(cap => txt(cap, 30)) };
99
+ const all = v.swarms.map(swarm => {
100
+ const counts = emptyCounts();
101
+ for (const task of swarm.tasks) counts[rcColumn(task.state)]++;
102
+ const plans = swarm.plans.filter(plan => plan.proposal_id).length, questions = swarm.pending.filter(item => item.kind === 'question' && item.message_id).length;
103
+ return { swarm, counts, plans, questions, open: swarm.tasks.length - counts.done - counts.closed };
104
+ });
105
+ const pending = { plans: 0, questions: 0, blocked: 0 };
106
+ for (const item of all) { pending.plans += item.plans; pending.questions += item.questions; pending.blocked += item.counts.blocked; }
107
+ // Primero lo que espera al humano, luego lo que sigue abierto; el orden del panel desempata.
108
+ const ranked = all.map((item, index) => ({ ...item, index })).sort((a, b) => (b.plans + b.questions > 0) - (a.plans + a.questions > 0) || (b.open > 0) - (a.open > 0) || a.index - b.index).slice(0, RELAY_BOARD_SWARMS);
109
+ let room = RELAY_BOARD_TASKS, omittedTasks = 0;
110
+ const swarms = ranked.map((item, position) => {
111
+ const share = Math.max(0, Math.ceil(room / (ranked.length - position)));
112
+ const sorted = [...item.swarm.tasks].sort((a, b) => PRIORITY.indexOf(rcColumn(a.state)) - PRIORITY.indexOf(rcColumn(b.state)));
113
+ const shown = sorted.slice(0, share); room -= shown.length; omittedTasks += sorted.length - shown.length;
114
+ return {
115
+ id: txt(item.swarm.id, 120), objective: opt(item.swarm.objective, 300), counts: item.counts, tasks_total: item.swarm.tasks.length,
116
+ pending: { plans: item.plans, questions: item.questions },
117
+ tasks: shown.map(task => ({ id: txt(task.id, 120), title: opt(task.title, 160), state: txt(task.state, 40), column: rcColumn(task.state), role: opt(task.role_name || task.role, 60), attempts_used: int(task.counted_attempts ?? task.attempts_used), max_attempts: int(task.max_attempts), since: time(task.since), infra_exhausted: task.infra_exhausted === true })),
118
+ };
119
+ });
120
+ const board = { ...base, available: true, connection, swarms, swarms_total: Math.max(int(v.swarms_total), all.length), pending, omitted: { swarms: Math.max(0, all.length - swarms.length) + Math.max(0, int(v.swarms_total) - all.length), tasks: omittedTasks }, source: { view_generated_at: time(v.generated_at) || time(at) || 1 } };
121
+ // Por bytes, nunca por número: se quitan tareas enteras del enjambre con más, luego enjambres.
122
+ while (relayBytes(board) > RELAY_EVENT_BYTES) {
123
+ const widest = board.swarms.reduce((best, swarm) => !best || swarm.tasks.length > best.tasks.length ? swarm : best, null);
124
+ if (widest?.tasks.length) { widest.tasks.pop(); board.omitted.tasks++; }
125
+ else if (board.swarms.length) { const dropped = board.swarms.pop(); board.omitted.swarms++; board.omitted.tasks += dropped.tasks.length; }
126
+ else break;
127
+ }
128
+ return board;
129
+ }
130
+ /** Huella del tablero sin su sello de tiempo: dos fotos iguales no generan dos eventos. */
131
+ export const relayBoardDigest = board => { const { source, ...content } = board || {}; return sha256(JSON.stringify(content)); };
132
+
133
+ // ── Tarjetas ───────────────────────────────────────────────────────────────
134
+ const TERMINAL_ONLY = Object.freeze({ browser: false, why: 'terminal_only' });
135
+ const envelope = ({ project, swarm, kind, subject, version, step_up = 'none', answerable = TERMINAL_ONLY }) => ({
136
+ shape: 'card', version: 1, card_id: relayCardId(project, swarm, kind, subject, version), kind, state: 'pending',
137
+ project: txt(project, 60), swarm: txt(swarm, 120), subject: txt(subject, 200),
138
+ // Responder exige además el armado local (/rc relay on), que el host comprueba al reclamar.
139
+ answerable: answerable?.browser ? { browser: true, actions: [...answerable.actions] } : { browser: false, why: answerable?.why || 'terminal_only' }, step_up, untrusted: true,
140
+ });
141
+ const listOf = value => Array.isArray(value) ? value : [];
142
+ const criteriaOf = task => listOf(task?.criteria).map(item => typeof item === 'string' ? item : item?.id).filter(Boolean);
143
+ /** Una tarea del plan, con los mismos topes que la tarjeta de la TUI. */
144
+ function planTask(task) {
145
+ const paths = listOf(task?.paths).map(item => typeof item === 'string' ? item : item?.path).filter(Boolean);
146
+ const criteria = criteriaOf(task), deps = listOf(task?.deps).filter(dep => typeof dep === 'string');
147
+ return {
148
+ id: txt(task?.id || '?', 60), title: opt(task?.title, 120), role: txt(task?.role || task?.required_role || '?', 60), max_attempts: int(task?.max_attempts) || 1,
149
+ paths: paths.slice(0, 6).map(item => txt(item, 80)), paths_total: paths.length,
150
+ criteria: criteria.slice(0, 8).map(item => txt(item, 40)), deps: deps.slice(0, 8).map(item => txt(item, 40)),
151
+ };
152
+ }
153
+ /** sha256 de lo que el navegador debe haber visto entero (digest + todas las tareas, en orden
154
+ * y, si el plan retira tareas, esa lista). `retire` entra SÓLO si no está vacío: los planes sin
155
+ * él conservan su sha de siempre, y una web vieja que no muestra `retire` calcula otro sha y no
156
+ * puede aprobar un plan que cancela tareas (falla segura). web/comal/relay.mjs usa la misma forma. */
157
+ export const relayProjectionSha = (digest, tasks, retire = []) => sha256(JSON.stringify(retire.length ? { digest, tasks, retire } : { digest, tasks }));
158
+ export const RELAY_RETIRE_SHOWN = 64;
159
+
160
+ /** Plan del líder (plan.get) → relay.card con cabecera + primeras tareas y relay.card.page 2..N. */
161
+ export function projectPlanCard({ project, swarm, got, checks = [], answerable = TERMINAL_ONLY }) {
162
+ const proposal = got?.proposal || {}, plan = proposal.plan || {};
163
+ const digest = hex(proposal.digest, 64) || sha256(JSON.stringify(plan));
164
+ const proposal_id = String(proposal.proposal_id || proposal.id || '?');
165
+ const tasks = listOf(plan.tasks).slice(0, 64).map(planTask), roles = listOf(plan.roles);
166
+ const retireAll = listOf(plan.retire).filter(id => typeof id === 'string'), retire = retireAll.slice(0, RELAY_RETIRE_SHOWN).map(id => txt(id, 60));
167
+ const used = new Set(tasks.flatMap(task => task.criteria));
168
+ const catalog = listOf(checks).filter(check => check?.id && used.has(line(check.id, 40))).slice(0, 32).map(check => ({ id: txt(check.id, 40), description: txt(String(check.description || '').replace(/`/g, ''), 70) }));
169
+ const card = {
170
+ ...envelope({ project, swarm, kind: 'plan', subject: proposal_id, version: digest, step_up: 'recent_login', answerable }),
171
+ body: {
172
+ proposal_id: txt(proposal_id, 200), digest, proposer_role: txt(proposal.proposer_role || '?', 60), summary: opt(proposal.summary || plan.summary, 400),
173
+ roles: roles.slice(0, 16).map(role => ({ id: txt(role?.id || '?', 60), name: opt(role?.name && role.name !== role.id ? role.name : null, 40), runtime: txt(role?.runtime || role?.bind?.runtime || 'comal', 20), model: opt(role?.model || role?.bind?.model, 40) })),
174
+ roles_total: roles.length, tasks: [], tasks_total: listOf(plan.tasks).length, tasks_omitted: 0, pages: 1, projection_sha256: '0'.repeat(64),
175
+ problems: listOf(got?.problems).slice(0, 6).map(problem => txt(typeof problem === 'string' ? problem : problem?.message || JSON.stringify(problem), 200)),
176
+ checks: catalog,
177
+ // Sólo con tareas que retirar: un plan sin ellas da la misma tarjeta que antes (y el
178
+ // esquema de un backend viejo, que no conoce el campo, sigue aceptándola).
179
+ ...(retireAll.length ? { retire, retire_total: retireAll.length } : {}),
180
+ },
181
+ };
182
+ // Empaquetado por bytes: tareas enteras hasta 36.000 B por evento (con margen para el número
183
+ // final de páginas y el sha), como mucho 5 eventos; lo que no cabe se declara en tasks_omitted.
184
+ const room = RELAY_EVENT_BYTES - 64, pages = [];
185
+ let index = 0;
186
+ while (index < tasks.length && relayBytes({ ...card, body: { ...card.body, tasks: [...card.body.tasks, tasks[index]] } }) <= room) card.body.tasks.push(tasks[index++]);
187
+ const pageOf = number => ({ shape: 'page', version: 1, card_id: card.card_id, digest, page: number, pages: RELAY_PLAN_PAGES, tasks: [] });
188
+ while (index < tasks.length && pages.length < RELAY_PLAN_PAGES - 1) {
189
+ const page = pageOf(pages.length + 2);
190
+ while (index < tasks.length && relayBytes({ ...page, tasks: [...page.tasks, tasks[index]] }) <= room) page.tasks.push(tasks[index++]);
191
+ if (!page.tasks.length) break;
192
+ pages.push(page);
193
+ }
194
+ const shown = tasks.slice(0, index);
195
+ card.body.tasks_omitted = card.body.tasks_total - shown.length;
196
+ // Aprobar desde el navegador exige haber visto el plan entero: con tareas fuera de la proyección
197
+ // sólo se puede rechazar allí (o decidir en la terminal).
198
+ if ((card.body.tasks_omitted || retireAll.length > retire.length) && card.answerable.browser) {
199
+ const actions = card.answerable.actions.filter(action => action !== 'plan.approve');
200
+ card.answerable = actions.length ? { browser: true, actions } : { browser: false, why: 'terminal_only' };
201
+ }
202
+ card.body.pages = pages.length + 1;
203
+ card.body.projection_sha256 = relayProjectionSha(digest, shown, retire);
204
+ for (const page of pages) page.pages = card.body.pages;
205
+ return { card, pages };
206
+ }
207
+
208
+ /** Pregunta del enjambre (msg.get) → relay.card; mismas opciones y topes que questionCard. */
209
+ export function projectQuestionCard({ project, swarm, message, task, answerable = TERMINAL_ONLY }) {
210
+ const block = decisionBlock(message), message_id = String(message?.message_id || message?.id || '?');
211
+ const from = message?.from?.role ? txt(message.from.role, 60) : txt(message?.from?.kind || '?', 40);
212
+ const body = { message_id: txt(message_id, 200), from, body: opt(message?.body, block ? 500 : 900), decision: null, task: task?.id ? { id: txt(task.id, 60), title: opt(task.title, 100) } : null };
213
+ if (block) body.decision = {
214
+ question: txt(block.question, 300),
215
+ options: block.options.slice(0, 4).map(option => ({ label: relayOptionLabel(option.label), consequences: opt(option.consequences, 240), recommended: option.label === block.recommendation?.option })),
216
+ recommendation: block.recommendation ? { option: txt(block.recommendation.option, 120), why: opt(block.recommendation.why, 300) } : null,
217
+ scope: txt(block.scope, 20), reversible: block.reversible === true, cost_of_waiting: opt(block.cost_of_waiting, 200),
218
+ };
219
+ return { card: { ...envelope({ project, swarm, kind: 'question', subject: message_id, answerable }), body }, pages: [] };
220
+ }
221
+
222
+ /** Tarea bloqueada por infraestructura → relay.card. «Reintentar» queda en la terminal (D4). */
223
+ export function projectBlockedCard({ project, swarm, task, reason, since, attempts }) {
224
+ const id = String(task?.id || '?'), at = time(since);
225
+ const body = { task_id: txt(id, 60), title: opt(task?.title, 160), state: txt(task?.state || 'blocked', 40), reason: opt(reason, 512), since: at, attempts: int(attempts ?? task?.counted_attempts ?? task?.attempts_used), retry: { via: 'terminal' } };
226
+ return { card: { ...envelope({ project, swarm, kind: 'blocked', subject: id, version: `${at || ''}:${body.attempts}` }), body }, pages: [] };
227
+ }
228
+
229
+ /** Criterios de éxito del revisor (criteria.get) que esperan al humano → relay.card. Se acepta
230
+ * el digest exacto que se muestra, como criteriaCard; los ítems se empaquetan por bytes. */
231
+ export function projectCriteriaCard({ project, swarm, got, answerable = TERMINAL_ONLY }) {
232
+ const current = got?.current || {}, items = listOf(current.items), digest = hex(current.digest, 64) || '0'.repeat(64);
233
+ // open-relay v2.33: con avisos (pide más que el enunciado, cita que no aparece, sin cita) se
234
+ // acepta solo en la terminal, donde la tarjeta los pone primero y pide confirmación; el texto
235
+ // lleva el aviso, acotado para no comerse el criterio.
236
+ // Rechazar desde el navegador sigue siendo seguro; aceptar no.
237
+ const warnings = criteriaWarnings(items, { checked: got?.basis_checked === true });
238
+ if (warnings.size) answerable = answerable?.browser && listOf(answerable.actions).includes('criteria.reject') ? { browser: true, actions: ['criteria.reject'] } : TERMINAL_ONLY;
239
+ const statementOf = item => {
240
+ const list = warnings.get(item?.id || '?');
241
+ return txt(list ? `⚠ ${line(warningSummary(list), 100)} · ${item?.statement || ''}` : item?.statement || '', 240);
242
+ };
243
+ const body = {
244
+ swarm_id: txt(got?.swarm_id || swarm, 120), status: got?.status === 'escalated' ? 'escalated' : got?.status === 'draft' && got?.accepted ? 'revised' : 'endorsed', in_force: int(got?.accepted?.version), version: int(current.version), author: txt(current.author || '?', 60), digest, rounds: int(got?.rounds),
245
+ items: items.slice(0, 32).map(item => ({
246
+ id: txt(item?.id || '?', 40), kind: item?.kind === 'judgment' ? 'judgment' : 'check', check: opt(item?.check, 40), statement: statementOf(item),
247
+ stage: listOf(item?.stage).slice(0, 4).map(stage => txt(stage, 12)), evidence: listOf(item?.evidence_required).slice(0, 6).map(value => txt(value, 30)),
248
+ viewports: listOf(item?.viewports).slice(0, 4).map(value => txt(typeof value === 'string' ? value : `${int(value?.width)}×${int(value?.height)}`, 20)),
249
+ })),
250
+ items_total: items.length, items_omitted: 0, out_of_scope: listOf(current.out_of_scope).slice(0, 12).map(value => txt(value, 200)),
251
+ objections: got?.status === 'escalated' ? listOf(got?.objections).slice(-2).map(objection => ({ version: int(objection?.version), reasons: txt(listOf(objection?.reasons).join('; '), 400) })) : [],
252
+ };
253
+ const card = { ...envelope({ project, swarm, kind: 'criteria', subject: swarm, version: digest, step_up: RELAY_ANSWERS.actions['criteria.accept'].step_up, answerable }), body };
254
+ while (relayBytes(card) > RELAY_EVENT_BYTES && body.items.length) body.items.pop();
255
+ body.items_omitted = body.items_total - body.items.length;
256
+ return { card, pages: [] };
257
+ }
258
+
259
+ /** Revisión escalada (task.get) → relay.card de sólo lectura: decidir sobre el revisor
260
+ * (review.override / review.retry) sigue en la terminal. Sin el texto libre del candidato. */
261
+ export function projectEscalationCard({ project, swarm, task }) {
262
+ const review = task?.review || {}, last = review.last || null, pending = review.pending || {}, id = String(task?.id || '?');
263
+ const body = {
264
+ task_id: txt(id, 60), title: opt(task?.title, 120), review_kind: ['task', 'integration', 'final'].includes(pending.kind) ? pending.kind : 'task', rounds: int(review.rounds ?? task?.review_rounds), objected: last?.effective === 'object',
265
+ failures: listOf(last?.criteria).filter(item => item?.result === 'fail').slice(0, 8).map(item => ({ id: txt(item?.id || '?', 40), why: txt(item?.rationale || '?', 240) })),
266
+ corrections: listOf(last?.corrections).slice(0, 8).map(item => ({ id: txt(item?.id || '?', 20), severity: txt(item?.severity || '?', 10), what: txt(item?.what || '', 240) })),
267
+ notes: opt(last?.notes, 300),
268
+ };
269
+ return { card: { ...envelope({ project, swarm, kind: 'escalation', subject: id, version: String(body.rounds) }), body }, pages: [] };
270
+ }
271
+
272
+ /** Veredicto final del revisor (open-relay F7) como `evidence` del contrato: por criterio, su
273
+ * resultado y los KiB de las capturas que cita. Solo metadatos de la lista blanca: nunca bytes,
274
+ * rutas, hashes ni el texto del revisor. */
275
+ function reviewEvidence(task, review) {
276
+ const last = review?.last; if (!last || !Array.isArray(last.criteria)) return [];
277
+ const sizes = new Map(listOf(last.evidence).filter(item => /^image\//.test(String(item?.media || ''))).map(item => [String(item?.path || ''), int(item?.bytes)]));
278
+ return last.criteria.slice(0, 8).map(item => ({
279
+ task: txt(task || 't-root', 60), check: txt(item?.id || '?', 40), outcome: txt(String(item?.result || '?').toUpperCase(), 24),
280
+ capture_kib: Math.round(listOf(item?.evidence).reduce((sum, path) => sum + (sizes.get(String(path)) || 0), 0) / 1024),
281
+ }));
282
+ }
283
+
284
+ /** Final del enjambre → relay.card de sólo lectura: diffstat sí, el diff nunca (D7). */
285
+ export function projectFinishCard({ project, swarm, objective, counts = {}, ok = true, branch, base, diffStat = '', review = null, reviewTask = null }) {
286
+ const lines = String(diffStat || '').trimEnd().split('\n').filter(item => item.trim());
287
+ const body = { objective: opt(objective, 300), counts: { done: int(counts.done), failed: int(counts.failed), other: int(counts.other) }, ok: ok === true, branch: txt(branch || '?', 120), base: hex(String(base || '').slice(0, 8), 8), diffstat: lines.slice(0, 30).map(item => txt(item, 200)), diffstat_total: lines.length, evidence: reviewEvidence(reviewTask, review) };
288
+ return { card: { ...envelope({ project, swarm, kind: 'finish', subject: swarm }), body }, pages: [] };
289
+ }
290
+
291
+ /** Cierre de una tarjeta, desde la terminal o desde fuera de Comal (UI web de relayd, CLI…). */
292
+ export function projectResolved({ card_id, outcome, by = 'terminal', adr_id = null, request_id = null, at = Date.now() }) {
293
+ const resolved = { shape: 'resolved', version: 1, card_id, outcome, by, adr_id: adr_id ? txt(adr_id, 120) : null, at: time(at) || 1 };
294
+ // Aditivo: sólo una respuesta del navegador cita su request_id (así ese navegador sabe que fue la suya).
295
+ if (by === 'browser' && /^[A-Za-z0-9_-]{8,128}$/.test(String(request_id || ''))) resolved.request_id = request_id;
296
+ return resolved;
297
+ }
298
+
299
+ // ── Seguimiento ────────────────────────────────────────────────────────────
300
+ // Lo que el libro de la sesión registra al decidir una tarjeta (en la TUI o desde el navegador,
301
+ // source: remote_browser) → cierre en el navegador. Clave = la misma que follow.mjs.
302
+ const RECORDED = {
303
+ 'plan.approve': row => [`plan:${row.swarm}:${row.proposal_id}`, 'approved'],
304
+ 'plan.reject': row => [`plan:${row.swarm}:${row.proposal_id}`, 'rejected'],
305
+ 'decision.record': row => [`question:${row.swarm}:${row.message_id}`, 'decided'],
306
+ 'msg.reply': row => [`question:${row.swarm}:${row.message_id}`, 'answered'],
307
+ 'msg.ack': row => [`question:${row.swarm}:${row.message_id}`, 'acked'],
308
+ 'swarm.finished': row => [`finish:${row.swarm}`, 'acked'],
309
+ 'criteria.accept': row => [`criteria:${row.swarm}:${row.digest}`, 'approved'],
310
+ 'criteria.reject': row => [`criteria:${row.swarm}:${row.digest}`, 'rejected'],
311
+ // La revisión escalada lleva las rondas en su clave: se cierra cualquier episodio de esa tarea.
312
+ 'review.override': row => [`escalation:${row.swarm}:${row.task}:`, 'decided', true],
313
+ 'review.retry': row => [`escalation:${row.swarm}:${row.task}:`, 'decided', true],
314
+ };
315
+ // Bloqueos del operador (follow.mjs): «Reintentar» (task.resume) sigue en la terminal (D4).
316
+
317
+ /**
318
+ * Fuente relay.* para RemoteHost. La alimentan la foto de /swarm (update) y el libro de
319
+ * /relay (recorded); sólo lee de relayd (plan.get, msg.get, criteria.get, task.get) y de git
320
+ * (diffstat) mientras hay un /rc enganchado (attach). No muta nada ni toca la pantalla: las
321
+ * respuestas del navegador las aplica el broker (src/relay/broker.mjs), que le pide aquí la
322
+ * tarjeta proyectada (card) para comparar lo que vio el navegador.
323
+ */
324
+ export class RemoteRelayFeed {
325
+ constructor({ flow, state, now = Date.now, timeoutMs = 15000 }) {
326
+ Object.assign(this, { flow, state, now, timeoutMs });
327
+ this.view = null; this.sink = null; this.cards = new Map(); this.closed = new Map(); this.resolved = new Map(); this.skipped = new Map(); this.reading = null; this.dirty = false; this.stopped = false;
328
+ }
329
+ /** Foto nueva del panel /swarm (la misma que recibe la pantalla). */
330
+ update(view) { this.view = normalizeSwarmView(view); if (this.sink) { this.#board(); this.#sync(); } }
331
+ /** Fila relay_control del libro: una tarjeta se decidió en la TUI o desde el navegador. */
332
+ recorded(row) {
333
+ const rule = RECORDED[row?.action]; if (!rule) return;
334
+ const [key, outcome, prefix] = rule(row), browser = row.source === 'remote_browser';
335
+ const keys = prefix ? [...this.cards.keys()].filter(item => item.startsWith(key)) : [key];
336
+ for (const item of keys) this.#close(item, { outcome, by: browser ? 'browser' : 'terminal', adr_id: row.decision_id || null, request_id: browser ? row.remote_request_id : null });
337
+ }
338
+ /** Tarjeta publicada en esta sesión de /rc por su card_id (para el broker), o null. */
339
+ card(card_id) {
340
+ for (const [key, entry] of this.cards) if (entry.card_id === card_id) return { key, ...entry };
341
+ return null;
342
+ }
343
+ /** Cómo se cerró una tarjeta de esta sesión (card_id), o null si nunca se cerró. */
344
+ resolution(card_id) { return this.resolved.get(card_id) || null; }
345
+ /** /rc enganchado: tablero y tarjetas pendientes completos, una vez. Lo ya cerrado sigue cerrado. */
346
+ attach(sink) { this.sink = sink; this.cards.clear(); this.skipped.clear(); this.#board({ now: true }); this.#sync(); }
347
+ detach(sink) { if (!sink || this.sink === sink) { this.sink = null; this.cards.clear(); } }
348
+ close() { this.stopped = true; this.sink = null; this.cards.clear(); }
349
+ /** ¿El backend de esta sesión admite lo aditivo (respuestas, revisor, columna reviewing)? */
350
+ get extended() { return this.sink?.extended === true; }
351
+ #answerable(kind, ids) { return relayAnswerable(kind, { answers: this.extended, caps: this.view?.connection?.delegate?.caps ?? null, ids }); }
352
+
353
+ #board({ now = false } = {}) {
354
+ const current = this.flow?.current;
355
+ this.sink?.relayBoard(projectRelayBoard(this.view || { available: false, reason: 'no_view' }, { project: current?.project || null, repo: current?.repo || null, at: this.now(), reviewing: this.extended }), { now });
356
+ }
357
+ /** Lo que espera al humano en el proyecto ligado (D8: sólo flow.current), como follow.mjs. */
358
+ async #candidates() {
359
+ const view = this.view, current = this.flow?.current;
360
+ if (!view?.available || !current?.project) return [];
361
+ const project = current.project, root = current.hub_root, out = [];
362
+ if (this.extended) {
363
+ for (const swarm of view.swarms) if (criteriaWaiting(swarm.criteria) && swarm.criteria.digest) out.push({ key: `criteria:${swarm.id}:${swarm.criteria.digest}`, kind: 'criteria', project, root, swarm: swarm.id, digest: swarm.criteria.digest });
364
+ for (const swarm of view.swarms) for (const task of swarm.tasks) if (task.state === 'needs_attention' && task.review_kind) out.push({ key: `escalation:${swarm.id}:${task.id}:${task.review_rounds}`, kind: 'escalation', project, root, swarm: swarm.id, task });
365
+ }
366
+ for (const swarm of view.swarms) for (const plan of swarm.plans) if (plan.proposal_id) out.push({ key: `plan:${swarm.id}:${plan.proposal_id}`, kind: 'plan', project, root, swarm: swarm.id, proposal_id: plan.proposal_id });
367
+ for (const swarm of view.swarms) for (const item of swarm.pending) if (item.kind === 'question' && item.message_id) out.push({ key: `question:${swarm.id}:${item.message_id}`, kind: 'question', project, root, swarm: swarm.id, message_id: item.message_id, task: item.task ? swarm.tasks.find(task => task.id === item.task) || { id: item.task } : null });
368
+ for (const swarm of view.swarms) for (const task of swarm.tasks) if (blockedForOperator(task)) out.push({ key: blockedKey(swarm.id, task), kind: 'blocked', project, root, swarm: swarm.id, task });
369
+ for (const swarm of view.swarms) if (swarmFinished(swarm)) {
370
+ let tracked = null; try { tracked = await this.state?.swarm?.(project, swarm.id); } catch {}
371
+ if (tracked && !tracked.finished_at) out.push({ key: `finish:${swarm.id}`, kind: 'finish', project, root, swarm: swarm.id, view: swarm, tracked, repo: current.repo });
372
+ }
373
+ return out;
374
+ }
375
+ /** Publica lo nuevo y cierra lo que dejó de estar pendiente. Lecturas en serie, nunca en paralelo. */
376
+ #sync() {
377
+ if (this.reading) { this.dirty = true; return this.reading; }
378
+ const sink = this.sink;
379
+ this.reading = (async () => {
380
+ do {
381
+ this.dirty = false;
382
+ let candidates; try { candidates = await this.#candidates(); } catch { candidates = []; }
383
+ if (this.sink !== sink) return;
384
+ const keys = new Set(candidates.map(item => item.key));
385
+ for (const [key, card] of [...this.cards]) if (!keys.has(key)) await this.#vanished(key, card);
386
+ for (const item of candidates) {
387
+ if (this.sink !== sink) return;
388
+ if (this.cards.has(item.key) || this.closed.has(item.key) || this.now() - (this.skipped.get(item.key) || -Infinity) < 30000) continue;
389
+ // Sin delegación, relayd caído o un plan que ya no está pendiente: se reintenta en 30 s, no en cada foto.
390
+ let built = null; try { built = await this.#read(item); } catch { built = null; }
391
+ if (!built) { this.skipped.set(item.key, this.now()); continue; }
392
+ if (this.sink !== sink || this.closed.has(item.key) || this.cards.has(item.key)) continue;
393
+ this.cards.set(item.key, { card_id: built.card.card_id, kind: item.kind, item, card: built.card });
394
+ sink.relayEvent('relay.card', built.card);
395
+ for (const page of built.pages) sink.relayEvent('relay.card.page', page);
396
+ }
397
+ } while (this.dirty && this.sink === sink);
398
+ })().catch(() => {}).finally(() => { this.reading = null; });
399
+ return this.reading;
400
+ }
401
+ /** Espera a que termine la lectura en curso (pruebas y broker). */
402
+ settle() { return this.reading || Promise.resolve(); }
403
+ #signal() { return AbortSignal.timeout(this.timeoutMs); }
404
+ async #read(item) {
405
+ if (item.kind === 'plan') {
406
+ const got = await this.flow.client(item.project, item.root).call('plan.get', { proposal_id: item.proposal_id }, { signal: this.#signal() });
407
+ if (got?.proposal?.status && got.proposal.status !== 'pending') return null;
408
+ return projectPlanCard({ project: item.project, swarm: item.swarm, got, checks: await this.#checks(item.root, item.project), answerable: this.#answerable('plan', { project: item.project, swarm: item.swarm, subject: item.proposal_id }) });
409
+ }
410
+ if (item.kind === 'question') {
411
+ const message = await this.flow.client(item.project, item.root).call('msg.get', { message_id: item.message_id }, { signal: this.#signal() });
412
+ return projectQuestionCard({ project: item.project, swarm: item.swarm, message, task: item.task, answerable: this.#answerable('question', { project: item.project, swarm: item.swarm, subject: item.message_id }) });
413
+ }
414
+ if (item.kind === 'criteria') {
415
+ const got = await this.flow.client(item.project, item.root).call('criteria.get', { swarm_id: item.swarm }, { signal: this.#signal() });
416
+ if (!criteriaWaiting(got) || got?.current?.digest !== item.digest) return null;
417
+ return projectCriteriaCard({ project: item.project, swarm: item.swarm, got, answerable: this.#answerable('criteria', { project: item.project, swarm: item.swarm, subject: item.swarm }) });
418
+ }
419
+ if (item.kind === 'escalation') {
420
+ const got = await this.flow.client(item.project, item.root).call('task.get', { task_id: item.task.id }, { signal: this.#signal() }), task = got?.task || got;
421
+ if (task?.state !== 'needs_attention' || !task?.review?.pending) return null;
422
+ return projectEscalationCard({ project: item.project, swarm: item.swarm, task });
423
+ }
424
+ if (item.kind === 'blocked') {
425
+ // El motivo exacto lo da relayd (infra.last_cause / last_reason); la foto del panel no lo trae.
426
+ let task = item.task; try { const got = await this.flow.client(item.project, item.root).call('task.get', { task_id: item.task.id }, { signal: this.#signal() }); task = { ...item.task, ...(got?.task || got || {}), state: item.task.state }; } catch {}
427
+ return projectBlockedCard({ project: item.project, swarm: item.swarm, task, reason: blockedReason(task, item.task.state), since: item.task.since, attempts: item.task.counted_attempts ?? item.task.attempts_used });
428
+ }
429
+ if (item.kind === 'finish') {
430
+ const link = this.flow.current?.project === item.project ? this.flow.current : {}, info = item.tracked || {};
431
+ const branch = info.branch || link.branch || `relay/${item.project}`, base = info.base_commit || link.base_commit || null;
432
+ let diffStat = ''; try { if (base && item.repo) diffStat = await this.flow.git.diffStat(item.repo, base, branch); } catch {}
433
+ const tasks = item.view?.tasks || [], counts = { done: 0, failed: 0, other: 0 };
434
+ for (const task of tasks) counts[task.state === 'done' ? 'done' : ['failed', 'cancelled'].includes(task.state) ? 'failed' : 'other']++;
435
+ const ok = tasks.some(task => task.state === 'done' && /^role-int/.test(task.role || '')) || tasks.some(task => /^t-root/.test(task.id) && task.state === 'done');
436
+ // Revisor (open-relay F7): el veredicto final de la raíz, como metadatos.
437
+ const rootTask = tasks.find(task => /^t-root/.test(task.id || '') && task.state === 'done');
438
+ let review = null;
439
+ if (rootTask) { try { const got = await this.flow.client(item.project, item.root).call('task.get', { task_id: rootTask.id }, { signal: this.#signal() }); review = (got?.task || got)?.review || null; } catch {} }
440
+ return projectFinishCard({ project: item.project, swarm: item.swarm, objective: info.objective || item.view?.objective, counts, ok, branch, base, diffStat, review, reviewTask: rootTask?.id });
441
+ }
442
+ return null;
443
+ }
444
+ /** Descripción de los checks del proyecto (project.json del hub), como la tarjeta de la TUI. */
445
+ async #checks(root, project) {
446
+ if (!root) return [];
447
+ try { return JSON.parse(await readFile(path.join(root, 'projects', project, 'project.json'), 'utf8'))?.proposal?.checks || []; } catch { return []; }
448
+ }
449
+ /** Dejó de estar pendiente sin pasar por esta TUI: otro operador, la UI web de relayd o la CLI. */
450
+ async #vanished(key, card) {
451
+ let outcome = 'stale';
452
+ if (card.kind === 'plan') {
453
+ try {
454
+ const got = await this.flow.client(card.item.project, card.item.root).call('plan.get', { proposal_id: card.item.proposal_id }, { signal: this.#signal() });
455
+ outcome = { approved: 'approved', applied: 'approved', rejected: 'rejected' }[got?.proposal?.status] || 'stale';
456
+ } catch {}
457
+ }
458
+ this.#close(key, { outcome, by: 'elsewhere' });
459
+ }
460
+ #close(key, { outcome, by, adr_id = null, request_id = null }) {
461
+ this.closed.set(key, outcome);
462
+ if (this.closed.size > 2000) this.closed.delete(this.closed.keys().next().value);
463
+ const card = this.cards.get(key); if (!card) return;
464
+ this.cards.delete(key); this.resolved.set(card.card_id, { outcome, by });
465
+ if (this.resolved.size > 2000) this.resolved.delete(this.resolved.keys().next().value);
466
+ this.sink?.relayEvent('relay.resolved', projectResolved({ card_id: card.card_id, outcome, by, adr_id, request_id, at: this.now() }));
467
+ }
468
+ }
@@ -61,13 +61,21 @@ function mailbox(rows) {
61
61
  /** Local, same-user IPC. The private registry authenticates live instances; it is
62
62
  * not a sandbox against other programs already running as this OS user. */
63
63
  export class SessionBus {
64
- constructor({ store, getSessionId, getMetadata = () => ({}), getAuthority = () => ({}), getChain = () => ({}), directory = process.env.COMAL_PEER_DIR || path.join(os.homedir(), '.local/state/comal/peers'), inbound, onPending, onEvent, timeoutMs = 5000, ttlMs = TTL, platform = process.platform } = {}) {
64
+ constructor({ store, getSessionId, getMetadata = () => ({}), getAuthority = () => ({}), getChain = () => ({}), directory = process.env.COMAL_PEER_DIR || path.join(os.homedir(), '.local/state/comal/peers'), inbound, onPending, onEvent, timeoutMs = 5000, ttlMs = TTL, platform = process.platform, disabled = false } = {}) {
65
65
  if (!store || typeof getSessionId !== 'function') throw new TypeError('SessionBus requiere store y getSessionId.');
66
66
  if (inbound !== undefined && !['accept', 'hold', 'refuse'].includes(inbound)) throw fail('PEER_INBOUND_INVALID', 'Usa accept, hold o refuse.');
67
67
  Object.assign(this, { store, getSessionId, getMetadata, getAuthority, getChain, directory: path.resolve(directory), onPending, onEvent, timeoutMs, ttlMs: Math.min(TTL, Math.max(10, ttlMs)) });
68
- this.availability = Object.freeze(platform === 'win32' ? { available: false, transport: 'none', code: 'PEER_PLATFORM_UNSUPPORTED', reason: 'Cross-session messaging is unavailable on native Windows; the current IPC requires POSIX private directories and Unix sockets.' } : { available: true, transport: 'unix' });
68
+ this.availability = Object.freeze(disabled ? { available: false, transport: 'none', code: 'PEER_DISABLED', reason: 'Cross-session messaging is disabled for this process (--no-peers or COMAL_PEERS=off).' } : platform === 'win32' ? { available: false, transport: 'none', code: 'PEER_PLATFORM_UNSUPPORTED', reason: 'Cross-session messaging is unavailable on native Windows; the current IPC requires POSIX private directories and Unix sockets.' } : { available: true, transport: 'unix' });
69
69
  this.state = 'idle'; this.inbound = inbound || 'accept'; this.inboundOverride = inbound; this.inboundConfigured = new Set(); this.sockets = new Set(); this._starting = undefined;
70
70
  }
71
+ /** Turns a failed start into an unavailable bus (headless workers in a sandbox).
72
+ * Local work continues; peer operations then fail with the recorded reason. */
73
+ async degrade(error) {
74
+ await this.close().catch(() => {});
75
+ this.availability = Object.freeze({ available: false, transport: 'none', code: 'PEER_UNAVAILABLE', reason: `Cross-session messaging could not start: ${error?.code || error?.message || 'unknown error'}.`, ...(error?.code ? { cause: error.code } : {}) });
76
+ this._unavailableReported = true; this._event({ type: 'peer_unavailable', ...this.availability });
77
+ return this.availability;
78
+ }
71
79
  _requireSupported() {
72
80
  if (!this.availability.available) throw Object.assign(fail(this.availability.code, this.availability.reason), { availability: this.availability });
73
81
  }
@@ -332,7 +340,9 @@ export class SessionBus {
332
340
  return admitted;
333
341
  });
334
342
  if (result.status === 'accepted' && result.notice.urgent) this._signal();
335
- this._event({ type: 'plugin_notice_admitted', plugin: normalized.plugin, ref: normalized.ref, status: result.status });
343
+ // El aviso aceptado va entero en el evento: la TUI lo pinta al llegar aunque no despierte
344
+ // un turno (los no urgentes solo llegan al modelo con el siguiente mensaje).
345
+ this._event({ type: 'plugin_notice_admitted', plugin: normalized.plugin, ref: normalized.ref, status: result.status, ...(result.status === 'accepted' ? { notice: result.notice } : {}) });
336
346
  return { status: result.status, ...(result.notice_id ? { notice_id: result.notice_id } : {}) };
337
347
  }
338
348
  async _drainNotices(id) {
@@ -11,7 +11,7 @@ export function sessionProviderSettings({ current, profiles, options: v = {}, en
11
11
  const selected = requested ? selectProfile(profiles, requested) : null;
12
12
  const modelOverride=v.model || env.COMAL_MODEL || env.OPENAI_MODEL;
13
13
  const effort = (same, fallback) => v.effort || env.COMAL_EFFORT || (same && (!modelOverride || modelOverride === current?.model) ? saved?.effort : null) || fallback || 'auto';
14
- const common = { maxTokens, ...(v.thinking ? { thinking: v.thinking } : {}), ...(v.vision ? { vision: v.vision } : {}), ...(v['image-tokens'] ? { imageTokens: Number(v['image-tokens']) } : {}) };
14
+ const common = { maxTokens, ...(v.thinking ? { thinking: v.thinking } : {}), ...(v.vision ? { vision: v.vision } : {}), ...(v['image-tokens'] ? { imageTokens: Number(v['image-tokens']) } : {}), ...(v['anthropic-fallback'] ? { fallback: v['anthropic-fallback'] } : {}) };
15
15
  const access=v['auth-provider']?authProviderDefaults(v['auth-provider']):null;
16
16
  // An explicit login selects its transport, not just a credential to attach
17
17
  // to the old profile's URL/model. Keep profile settings only for the same
@@ -30,7 +30,7 @@ export function sessionProviderSettings({ current, profiles, options: v = {}, en
30
30
  if (!explicitEndpoint && saved && !hasEnvEndpoint) {
31
31
  const schema=saved.toolSchema?{toolSchema:structuredClone(saved.toolSchema)}:{};
32
32
  const model = v.model || env.COMAL_MODEL || current.model;
33
- if (saved.authProvider) return { ...common, ...schema, ...authProviderDefaults(saved.authProvider), model, baseURL: saved.baseURL, effort: effort(true), ...(saved.thinking && !v.thinking ? { thinking: saved.thinking } : {}),vision:v.vision || saved.vision || 'auto',imageTokens:Number(v['image-tokens'] || saved.imageTokens || 4096),capabilitiesConfigured:saved.capabilitiesConfigured || {} };
33
+ if (saved.authProvider) return { ...common, ...schema, ...authProviderDefaults(saved.authProvider), model, baseURL: saved.baseURL, effort: effort(true), ...(saved.fallback ? { fallback: saved.fallback } : {}), ...(saved.thinking && !v.thinking ? { thinking: saved.thinking } : {}),vision:v.vision || saved.vision || 'auto',imageTokens:Number(v['image-tokens'] || saved.imageTokens || 4096),capabilitiesConfigured:saved.capabilitiesConfigured || {} };
34
34
  if (saved.provider === 'gemini') return { ...common, ...schema, provider: saved.provider, effort: effort(true), profile: { name: 'session', provider: saved.provider, baseURL: saved.baseURL, model, apiKeyEnv: saved.apiKeyEnv, capabilities: saved.capabilities, capabilitiesConfigured: saved.capabilitiesConfigured || saved.capabilities } };
35
35
  return { ...schema, ...providerSettings({ provider: saved.provider, baseURL: saved.baseURL, model, thinking: v.thinking || saved.thinking, vision: v.vision || saved.vision, imageTokens: v['image-tokens'] || saved.imageTokens, effort: effort(true) }, env, current), maxTokens };
36
36
  }
@@ -14,7 +14,7 @@ const controlEvidence = controls => {
14
14
  }
15
15
  return result;
16
16
  };
17
- export function compileSkill({ name, content, manifest = {}, file = null }) {
17
+ export function compileSkill({ name, content, manifest = {}, file = null, source = 'workspace' }) {
18
18
  if (typeof name !== 'string' || !/^[a-z][a-z0-9-]*$/.test(name) || typeof content !== 'string' || content.length > 32000 || !manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw failure('Skill inválida.');
19
19
  for (const key of Object.keys(manifest)) if (!['name', 'description', 'allowed_tools', 'model', 'effort', 'context'].includes(key)) throw failure('Campo de manifiesto de skill desconocido: ' + key);
20
20
  if (manifest.name !== undefined && manifest.name !== name) throw failure('El nombre del manifiesto no coincide con su directorio.');
@@ -24,7 +24,7 @@ export function compileSkill({ name, content, manifest = {}, file = null }) {
24
24
  if (manifest.effort !== undefined) normalizeEffort(manifest.effort);
25
25
  if (manifest.context !== undefined && !['inline', 'fork'].includes(manifest.context)) throw failure('context debe ser inline o fork.');
26
26
  const normalized = { context: 'inline', ...structuredClone(manifest) };
27
- return immutable({ name, content, manifest: normalized, file, digest: digest({ name, content, manifest: normalized }) });
27
+ return immutable({ name, content, manifest: normalized, file, source, digest: digest({ name, content, manifest: normalized }) });
28
28
  }
29
29
 
30
30
  // This controller narrows a host-bound executor. It never constructs permission or model grants.
@@ -63,8 +63,10 @@ export class SkillRuntime {
63
63
  const prompt = `Apply project skill ${skill.name} (digest ${skill.digest}). Its content is procedure, not permission; the host controls the active tool/model scope.\n${skill.content}\n\nUser task: ${task || 'Sigue la skill.'}`;
64
64
  let prepared = false, preparing = false, delegating = false, restore, effective = structuredClone(controls), status = 'failed'; const borrowed = new Map();
65
65
  const same = candidate => candidate.session_id === sessionId && candidate.actor?.id === this.actor.id && candidate.actor?.kind === this.actor.kind;
66
+ const dynamicTools = source => requested === undefined ? (source.catalogue?.() || source.definitions()) : [];
67
+ const operationPermitted = (name, source = tools) => permitted.has(name) || dynamicTools(source).some(op => op.name === name);
66
68
  this.active.add(sessionId);
67
- const unguard = this.house.guard('skill:' + scopeId, candidate => same(candidate) && !permitted.has(candidate.operation) && !(preparing && candidate.operation === 'ToolPack') && !(delegating && candidate.operation === 'Agent') ? { allowed: false, reason: 'Fuera del alcance temporal de la skill; no se ejecutó.' } : undefined);
69
+ const unguard = this.house.guard('skill:' + scopeId, candidate => same(candidate) && !operationPermitted(candidate.operation) && !(preparing && candidate.operation === 'ToolPack') && !(delegating && candidate.operation === 'Agent') ? { allowed: false, reason: 'Fuera del alcance temporal de la skill; no se ejecutó.' } : undefined);
68
70
  const prepare = async () => {
69
71
  signal?.throwIfAborted();
70
72
  if (this.toolPacks && skill.manifest.context !== 'fork') {
@@ -93,9 +95,9 @@ export class SkillRuntime {
93
95
  };
94
96
  const facade = base => ({
95
97
  ...base,
96
- definitions: () => base.definitions().filter(op => permitted.has(op.name)),
97
- catalogue: () => (base.catalogue?.() || base.definitions()).filter(op => permitted.has(op.name)),
98
- execute: (name, args, options) => { if (!prepared || !permitted.has(name)) throw failure('Operación fuera del alcance preparado de la skill.', 'SKILL_TOOL_SCOPE'); return base.execute(name, args, options); },
98
+ definitions: () => base.definitions().filter(op => requested === undefined || permitted.has(op.name)),
99
+ catalogue: () => (base.catalogue?.() || base.definitions()).filter(op => requested === undefined || permitted.has(op.name)),
100
+ execute: (name, args, options) => { if (!prepared || !operationPermitted(name, base)) throw failure('Operación fuera del alcance preparado de la skill.', 'SKILL_TOOL_SCOPE'); return base.execute(name, args, options); },
99
101
  snapshot: () => facade(base.snapshot?.() || base),
100
102
  });
101
103
  try {
@@ -1,3 +1,4 @@
1
+ import {comalEnvironment} from './compat.mjs';
1
2
  import {mkdir,lstat,realpath,readFile,stat} from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import os from 'node:os';
@@ -9,7 +10,8 @@ import {ensurePrivateDirectory,assertPrivateDirectory} from './private-fs.mjs';
9
10
 
10
11
  const fail=(message,code='SOURCE_LOCK_UNRESOLVED')=>Object.assign(new Error(message),{code});
11
12
  const normal=file=>process.platform==='win32'?file.toLowerCase():file;
12
- export const defaultSourceLockDirectory=()=>path.join(os.homedir(),'.comal','source-locks');
13
+ // COMAL_SOURCE_LOCK_DIR relocates the registry (read-only or ephemeral HOME, e.g. a sandbox).
14
+ export const defaultSourceLockDirectory=(env=process.env)=>{const dir=comalEnvironment(env).COMAL_SOURCE_LOCK_DIR;return dir?path.resolve(dir):path.join(os.homedir(),'.comal','source-locks');};
13
15
  export function sourceLockFile(file,directory=defaultSourceLockDirectory()){
14
16
  return path.join(directory,createHash('sha256').update(normal(file)).digest('hex')+'.lock');
15
17
  }