@milpa/comal 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-device-v1.json +423 -0
  4. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  5. package/package.json +4 -1
  6. package/skills/mv3/SKILL.md +44 -0
  7. package/skills/mv3/skill.json +4 -0
  8. package/src/agent.mjs +299 -32
  9. package/src/auto-guard.mjs +40 -3
  10. package/src/autocompact.mjs +233 -0
  11. package/src/bash-timeouts.mjs +29 -0
  12. package/src/child-env.mjs +23 -0
  13. package/src/cli.mjs +261 -62
  14. package/src/code-run-artifact.mjs +30 -0
  15. package/src/code-run-journal.mjs +135 -0
  16. package/src/code-run-session-proof.mjs +50 -0
  17. package/src/code-run-session.mjs +97 -0
  18. package/src/code-run-worker.mjs +20 -4
  19. package/src/code-run.mjs +49 -14
  20. package/src/codex-provider.mjs +4 -4
  21. package/src/compaction-notice.mjs +22 -0
  22. package/src/compaction.mjs +162 -23
  23. package/src/connection-settings.mjs +36 -4
  24. package/src/context-pressure.mjs +5 -4
  25. package/src/context.mjs +27 -22
  26. package/src/execution-metrics.mjs +36 -5
  27. package/src/file-recovery-proof.mjs +9 -1
  28. package/src/file-recovery.mjs +36 -3
  29. package/src/gemini-provider.mjs +5 -3
  30. package/src/git-integration-operations.mjs +2 -1
  31. package/src/git-plumbing.mjs +2 -1
  32. package/src/git-source.mjs +2 -1
  33. package/src/git-workspaces.mjs +2 -1
  34. package/src/house.mjs +13 -1
  35. package/src/i18n/cli-es.mjs +12 -1
  36. package/src/i18n/es.mjs +38 -1
  37. package/src/judges.mjs +2 -1
  38. package/src/mcp.mjs +2 -1
  39. package/src/model-controls.mjs +16 -2
  40. package/src/model-deadline.mjs +2 -2
  41. package/src/openai-provider.mjs +8 -4
  42. package/src/operations.mjs +17 -2
  43. package/src/package-identity.mjs +68 -0
  44. package/src/peer-probe.mjs +16 -0
  45. package/src/permissions.mjs +97 -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/open-relay/actions.mjs +60 -9
  55. package/src/plugins/open-relay/client.mjs +3 -3
  56. package/src/plugins/open-relay/decision.mjs +3 -2
  57. package/src/plugins/open-relay/index.mjs +17 -7
  58. package/src/plugins/open-relay/lookup.mjs +41 -4
  59. package/src/plugins/open-relay/notices.mjs +44 -3
  60. package/src/plugins/open-relay/panel.mjs +4 -1
  61. package/src/plugins/open-relay/watch.mjs +27 -8
  62. package/src/process-lock.mjs +20 -12
  63. package/src/provider-auth.mjs +6 -3
  64. package/src/provider-profiles.mjs +13 -3
  65. package/src/provider.mjs +175 -30
  66. package/src/qr-scan.mjs +240 -0
  67. package/src/read-only-check.mjs +6 -1
  68. package/src/relay/broker.mjs +173 -0
  69. package/src/relay/cards.mjs +702 -0
  70. package/src/relay/flow.mjs +886 -0
  71. package/src/relay/follow.mjs +119 -0
  72. package/src/relay/git.mjs +23 -0
  73. package/src/relay/hub-client.mjs +163 -0
  74. package/src/relay/state.mjs +55 -0
  75. package/src/remote-control.mjs +283 -15
  76. package/src/remote-device-card.mjs +111 -0
  77. package/src/remote-device.mjs +92 -0
  78. package/src/remote-permissions.mjs +159 -0
  79. package/src/remote-relay.mjs +468 -0
  80. package/src/remote-stats.mjs +205 -0
  81. package/src/session-bus.mjs +13 -3
  82. package/src/session-provider-settings.mjs +2 -2
  83. package/src/skill-runtime.mjs +8 -6
  84. package/src/source-ownership.mjs +3 -1
  85. package/src/tool-manifest.mjs +71 -0
  86. package/src/tool-packs.mjs +22 -6
  87. package/src/tool-schema.mjs +14 -2
  88. package/src/tools.mjs +96 -27
  89. package/src/tui/controller.mjs +4 -1
  90. package/src/tui/discovery.mjs +17 -7
  91. package/src/tui/inbound-blocks.mjs +5 -1
  92. package/src/tui/screen.mjs +53 -14
  93. package/src/tui/swarm-panel.mjs +19 -8
  94. package/src/turn-timing.mjs +82 -0
  95. package/src/usage-accounting.mjs +12 -1
  96. package/src/vision.mjs +6 -1
  97. package/src/visual/chrome.mjs +379 -0
  98. package/src/visual/fingerprint.mjs +30 -0
  99. package/src/visual/fixtures.mjs +201 -0
  100. package/src/visual/gate.mjs +92 -0
  101. package/src/visual/judge.mjs +114 -0
  102. package/src/visual/ledger.mjs +160 -0
  103. package/src/visual/measure.mjs +145 -0
  104. package/src/visual/names.mjs +7 -0
  105. package/src/visual/operations.mjs +211 -0
  106. package/src/visual/pixel-diff.mjs +96 -0
  107. package/src/visual/png.mjs +103 -0
  108. package/src/visual/registry.mjs +265 -0
  109. package/src/visual/report.mjs +64 -0
  110. package/src/visual/verify.mjs +292 -0
  111. package/src/work-assertions.mjs +15 -4
  112. package/src/work-plan.mjs +166 -30
  113. package/src/work-state.mjs +4 -4
  114. package/src/workflows/chrome-task-adoption.mjs +7 -3
  115. package/src/workspace-serve.mjs +59 -0
@@ -0,0 +1,119 @@
1
+ import { t, registerMessages } from '../i18n/index.mjs';
2
+ import { normalizeSwarmView } from '../tui/swarm-panel.mjs';
3
+ import { criteriaWaiting } from './cards.mjs';
4
+
5
+ // Seguimiento de /relay: con el plugin open-relay en modo `notify=cards`, lo que espera al
6
+ // humano en el proyecto ligado (planes del líder, preguntas del enjambre) y el final de los
7
+ // enjambres que arrancó desde Comal o que se vieron vivos al seguir el proyecto (arrancados
8
+ // por CLI u otra sesión) se presentan como TARJETAS del host, no como avisos al modelo.
9
+ // Los datos llegan de la misma foto que alimenta /swarm (events.wait, sin sondeo); la
10
+ // tarjeta vuelve a leer relayd justo antes de mostrarse. Una tarjeta sale sólo cuando la
11
+ // pantalla está libre (sin otra tarjeta, permiso ni superposición modal); Esc la deja para
12
+ // después y /relay la vuelve a ofrecer.
13
+ registerMessages({
14
+ '{what} is waiting for you: /relay shows it again.': '{what} te espera: /relay lo vuelve a mostrar.',
15
+ 'A plan': 'Un plan', 'A question from the swarm': 'Una pregunta del enjambre', 'The swarm result': 'El resultado del enjambre',
16
+ 'The success criteria': 'Los criterios de éxito', 'An escalated review': 'Una revisión escalada', 'A blocked task': 'Una tarea bloqueada',
17
+ '/relay card failed: {error}': 'La tarjeta de /relay falló: {error}',
18
+ 'wake cap reached (policy.max_wakes): its wait is over and it does not wake on its own': 'tope de despertares agotado (policy.max_wakes): su espera ya terminó y no se despierta sola',
19
+ });
20
+
21
+ const TERMINAL = new Set(['done', 'failed', 'cancelled']);
22
+ // Criterios que esperan la decisión del humano: respaldados por el líder o escalados.
23
+ const DECIDERS = { plan: 'planDecision', question: 'questionDecision', finish: 'finishDecision', criteria: 'criteriaDecision', escalation: 'escalationDecision', blocked: 'blockedDecision' };
24
+ const WAITING = { plan: 'A plan', question: 'A question from the swarm', finish: 'The swarm result', criteria: 'The success criteria', escalation: 'An escalated review', blocked: 'A blocked task' };
25
+ // Bloqueos que esperan al operador (no se resuelven solos): configuración, entorno, presupuesto
26
+ // o reintentos de infraestructura agotados. Los demás (pregunta, aprobación, dependencia,
27
+ // subtareas, criterios, revisión, capacidad libre) ya tienen su tarjeta o avanzan solos.
28
+ const OPERATOR_BLOCKS = new Set(['blocked:config', 'blocked:environment', 'blocked:budget']);
29
+ // relayd (gate OR-6): `wake_cap` es una tarea aparcada por encima de policy.max_wakes cuya
30
+ // espera ya se resolvió: el scheduler no la despierta, sólo el operador (task resume).
31
+ export const blockedForOperator = task => OPERATOR_BLOCKS.has(task?.state) || (String(task?.state || '').startsWith('blocked') && (task?.infra_exhausted === true || task?.wake_cap === true));
32
+ /** Un episodio de bloqueo por tarjeta: otro bloqueo de la misma tarea (otro `since`) es otra tarjeta. */
33
+ export const blockedKey = (swarm, task) => `blocked:${swarm}:${task.id}:${task.since || ''}`;
34
+ /** Motivo de un bloqueo según relayd (task.get): causa y motivo de infraestructura, exactos. */
35
+ export const blockedReason = (task, state) => (task?.wake_cap === true ? t('wake cap reached (policy.max_wakes): its wait is over and it does not wake on its own') : null) || [task?.infra?.last_cause, task?.infra?.last_reason].filter(Boolean).join(': ') || String(state || task?.state || '').split(':')[1] || null;
36
+ const isRoot = task => /^t-root/.test(task.id || '');
37
+ /** ¿Terminó el enjambre? Nada espera al humano, toda subtarea cerró y el líder acabó o se integró. */
38
+ export function swarmFinished(swarm) {
39
+ const tasks = swarm?.tasks || [];
40
+ if (!tasks.length || (swarm.plans || []).length || (swarm.pending || []).some(item => item.kind === 'question')) return false;
41
+ const children = tasks.filter(task => !isRoot(task)), roots = tasks.filter(isRoot);
42
+ if (!children.length || !children.every(task => TERMINAL.has(task.state))) return false;
43
+ // Con revisor (open-relay F5) el final es la raíz cerrada tras su veredicto: sin el atajo de
44
+ // «alguna integración done», aunque la tarea del revisor ya no esté abierta.
45
+ const reviewed = tasks.some(task => /^t-review/.test(task.id || '') || /^role-review/.test(task.role || ''));
46
+ const integrated = !reviewed && children.some(task => task.state === 'done' && /^role-int/.test(task.role || ''));
47
+ return roots.every(task => TERMINAL.has(task.state)) || integrated;
48
+ }
49
+
50
+ /** Enjambres del proyecto ligado que se siguen aunque no se arrancaran desde Comal (CLI, otra
51
+ * sesión, otra máquina): el primero que se ve VIVO (con tareas y sin terminar) queda en el estado
52
+ * de /relay como si se hubiera arrancado aquí, así su final sale como tarjeta una sola vez. Uno que
53
+ * ya había terminado cuando se vio por primera vez no se adopta: sin tarjetas de enjambres viejos. */
54
+ export async function adoptLiveSwarms(state, view, current) {
55
+ if (!state?.setSwarm || !view?.available || !current?.project) return [];
56
+ const adopted = [];
57
+ for (const swarm of view.swarms || []) {
58
+ if (!swarm?.id || !(swarm.tasks || []).length || swarmFinished(swarm)) continue;
59
+ if (await state.swarm(current.project, swarm.id)) continue;
60
+ await state.setSwarm(current.project, swarm.id, { objective: swarm.objective || null, adopted_at: new Date().toISOString(), base_commit: current.base_commit || null, branch: current.branch || null });
61
+ adopted.push(swarm.id);
62
+ }
63
+ return adopted;
64
+ }
65
+
66
+ export class RelayFollower {
67
+ constructor({ flow, ui, state, intervalMs = 1000, againMs = 50, now = Date.now }) {
68
+ Object.assign(this, { flow, ui, state, intervalMs, againMs, now });
69
+ this.view = null; this.handled = new Set(); this.deferred = new Set(); this.running = null; this.timer = null; this.closed = false;
70
+ }
71
+ start() { if (this.timer || this.closed) return this; this.timer = setInterval(() => this.pump(), this.intervalMs); this.timer.unref?.(); return this; }
72
+ close() { this.closed = true; clearInterval(this.timer); this.timer = null; }
73
+ update(view) { this.view = normalizeSwarmView(view); this.flow.setView?.(view); this.pump(); }
74
+ /** /relay vuelve a ofrecer lo que se dejó para después. */
75
+ retry() { this.deferred.clear(); this.pump(); }
76
+ idle() {
77
+ const ui = this.ui;
78
+ return !this.closed && !ui.closed && !ui.setup && !ui.permission && !ui.selecting && !ui.editingQueue && (!ui.overlay || ['swarm', 'help'].includes(ui.overlay.kind)) && !this.flow.busy;
79
+ }
80
+ /** Lo que espera al humano en el proyecto ligado, en orden: criterios de éxito y revisiones
81
+ * escaladas (revisor, open-relay F7), planes, preguntas, bloqueos del operador, finales. */
82
+ async candidates() {
83
+ const view = this.view, current = this.flow.current;
84
+ if (!view?.available || view.connection?.mode !== 'cards' || !current?.project) return [];
85
+ const root = current.hub_root, project = current.project, out = [];
86
+ try { await adoptLiveSwarms(this.state, view, current); } catch {}
87
+ 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 });
88
+ 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 });
89
+ 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 });
90
+ for (const swarm of view.swarms) for (const item of swarm.pending || []) if (item.kind === 'question' && item.message_id) {
91
+ const task = item.task ? swarm.tasks.find(entry => entry.id === item.task) || { id: item.task } : null;
92
+ out.push({ key: `question:${swarm.id}:${item.message_id}`, kind: 'question', project, root, swarm: swarm.id, message_id: item.message_id, task });
93
+ }
94
+ 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 });
95
+ for (const swarm of view.swarms) if (swarmFinished(swarm)) {
96
+ const tracked = await this.state.swarm(project, swarm.id);
97
+ if (tracked && !tracked.finished_at) out.push({ key: `finish:${swarm.id}`, kind: 'finish', project, root, swarm: swarm.id, view: swarm, repo: current.repo });
98
+ }
99
+ return out;
100
+ }
101
+ pump() {
102
+ if (this.running || !this.idle()) return this.running;
103
+ let shown = false;
104
+ this.running = (async () => {
105
+ const next = (await this.candidates()).find(item => !this.handled.has(item.key) && !this.deferred.has(item.key));
106
+ if (!next || !this.idle()) return;
107
+ shown = true;
108
+ this.flow.busy = true;
109
+ let result;
110
+ try {
111
+ result = await this.flow[DECIDERS[next.kind]](next);
112
+ } catch (error) { result = 'later'; this.ui.notice(t('/relay card failed: {error}', { error: error.message }), true); }
113
+ finally { this.flow.busy = false; if (this.ui.setup?.kind === 'status') this.ui.clearSetup(); }
114
+ if (result === 'decided') this.handled.add(next.key);
115
+ else { this.deferred.add(next.key); this.ui.notice(t('{what} is waiting for you: /relay shows it again.', { what: t(WAITING[next.kind]) })); }
116
+ })().finally(() => { this.running = null; if (shown && !this.closed && this.againMs !== null) setTimeout(() => this.pump(), this.againMs).unref?.(); });
117
+ return this.running;
118
+ }
119
+ }
@@ -0,0 +1,23 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { realpath } from 'node:fs/promises';
3
+
4
+ // Lecturas de git para /relay: raíz del repo y cambios de la rama del enjambre. Sólo
5
+ // lectura (rev-parse, merge-base, diff); nunca toca el árbol de trabajo ni las ramas.
6
+ const run = (cwd, args, { maxBuffer = 4 << 20 } = {}) => new Promise((resolve, reject) => {
7
+ execFile('git', ['-C', cwd, ...args], { maxBuffer, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } }, (error, stdout, stderr) => {
8
+ if (error) reject(Object.assign(new Error(`git ${args[0]}: ${String(stderr || error.message).trim().slice(0, 400)}`), { code: 'RELAY_GIT' })); else resolve(stdout);
9
+ });
10
+ });
11
+ const REF = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,199}$/;
12
+ const ref = value => { if (!REF.test(String(value || '')) || String(value).includes('..')) throw Object.assign(new Error(`Invalid git ref: ${value}`), { code: 'RELAY_GIT' }); return value; };
13
+
14
+ export const relayGit = {
15
+ async toplevel(cwd) { return realpath((await run(cwd, ['rev-parse', '--show-toplevel'])).trim()); },
16
+ async commit(repo, name) { return (await run(repo, ['rev-parse', '--verify', '--quiet', `${ref(name)}^{commit}`])).trim(); },
17
+ async mergeBase(repo, a, b) { return (await run(repo, ['merge-base', ref(a), ref(b)])).trim(); },
18
+ async diffStat(repo, base, branch) { return run(repo, ['diff', '--stat=120', `${ref(base)}..${ref(branch)}`], { maxBuffer: 1 << 20 }); },
19
+ async diff(repo, base, branch, { maxLines = 400 } = {}) {
20
+ const text = await run(repo, ['diff', `${ref(base)}..${ref(branch)}`], { maxBuffer: 16 << 20 }), lines = text.split('\n');
21
+ return lines.length > maxLines ? { text: lines.slice(0, maxLines).join('\n'), truncated: lines.length - maxLines } : { text, truncated: 0 };
22
+ },
23
+ };
@@ -0,0 +1,163 @@
1
+ import { open, readFile, stat, access } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { spawn as spawnProcess } from 'node:child_process';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ // Cliente de la API local de relay-hub (open-relay v2.10): `POST /v1/rpc {method, params}`
8
+ // en loopback con `Authorization: Bearer <raíz>/hub.token`. Es una herramienta del HUMANO:
9
+ // sólo la usa el comando /relay del host (tarjetas confirmadas por la persona); ninguna
10
+ // operación del modelo llega aquí. El token se lee en cada llamada (es nuevo en cada
11
+ // `relay-hub serve`) y nunca sale de este módulo.
12
+ // open-relay 2.12 añade `project.attach` (diseño de referencia). Aquí no hay lista blanca de
13
+ // métodos: /relay sólo ofrece esa tarjeta si el hub declara la capacidad (`attach` en
14
+ // project.detect) o si su /v1/health publica versión >= 2.12, y la llama antes de swarm.start.
15
+ export const HUB_TOKEN = 'hub.token';
16
+ export const HUB_CONFIG = 'hub.json';
17
+ export const DEFAULT_LISTEN = '127.0.0.1:7379';
18
+ export const HUB_UNIT = 'relay-hub';
19
+ const TOKEN = /^[A-Za-z0-9_-]{16,256}$/;
20
+ const LOOPBACK = /^(?:127\.0\.0\.1|localhost|\[::1\]):(\d{1,5})$/;
21
+ const fail = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
22
+ const expand = value => value === '~' ? os.homedir() : value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value;
23
+ const shellWord = value => /^[A-Za-z0-9_./:=@%+-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
24
+
25
+ /** Raíz del hub: $RELAY_HUB_ROOT, la guardada con /relay hub RAÍZ o ~/.relay-hub (la de relay-hub). */
26
+ export function hubRoot({ env = process.env, saved } = {}) {
27
+ if (env.RELAY_HUB_ROOT) return { root: path.resolve(expand(env.RELAY_HUB_ROOT)), source: 'env' };
28
+ if (saved) return { root: path.resolve(expand(saved)), source: 'saved' };
29
+ return { root: path.join(os.homedir(), '.relay-hub'), source: 'default' };
30
+ }
31
+
32
+ /** Lee el token del hub: archivo regular del usuario, 0600, sin seguir symlinks. */
33
+ export async function readHubToken(file) {
34
+ let handle;
35
+ try {
36
+ handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
37
+ const info = await handle.stat(), uid = process.getuid?.();
38
+ if (!info.isFile() || info.size > 1024 || (uid !== undefined && info.uid !== uid) || (info.mode & 0o077)) throw fail('RELAY_HUB_TOKEN_UNSAFE', `${file} is not a private (0600) file owned by the user.`);
39
+ const token = (await handle.readFile('utf8')).trim();
40
+ if (!TOKEN.test(token)) throw fail('RELAY_HUB_TOKEN_INVALID', `${file} does not contain a hub token.`);
41
+ return token;
42
+ } catch (error) {
43
+ if (error.code === 'ENOENT') throw fail('RELAY_HUB_DOWN', `relay-hub is not running (no ${file}).`);
44
+ if (error.code === 'ELOOP') throw fail('RELAY_HUB_TOKEN_UNSAFE', `${file} is a symlink.`);
45
+ throw error;
46
+ } finally { await handle?.close(); }
47
+ }
48
+
49
+ async function executable(file) { try { await access(file, constants.X_OK); return (await stat(file)).isFile(); } catch { return false; } }
50
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
51
+
52
+ export class HubClient {
53
+ constructor({ root, bin, env = process.env, fetch = globalThis.fetch, spawn = spawnProcess, wait = sleep, startTimeoutMs = 20000 } = {}) {
54
+ Object.assign(this, { root: path.resolve(root || hubRoot({ env }).root), bin, env, fetch, spawn, wait, startTimeoutMs });
55
+ }
56
+ get tokenFile() { return path.join(this.root, HUB_TOKEN); }
57
+ get configFile() { return path.join(this.root, HUB_CONFIG); }
58
+ /** `hub.json` de `relay-hub init`. Sin él, el error trae el comando para crearlo. */
59
+ async config() {
60
+ let text;
61
+ try { text = await readFile(this.configFile, 'utf8'); }
62
+ catch (error) {
63
+ if (error.code === 'ENOENT') throw fail('RELAY_HUB_NOT_INITIALIZED', `No relay-hub at ${this.root} (missing ${HUB_CONFIG}). Create it once: relay-hub --root ${shellWord(this.root)} init --open-relay <checkout> --comal <bin/comal.mjs> --node-dir <node/bin> --credential-file <0600 key file>. Or point Comal to an existing hub: /relay hub <root> (or RELAY_HUB_ROOT).`);
64
+ throw error;
65
+ }
66
+ let config; try { config = JSON.parse(text); } catch { throw fail('RELAY_HUB_CONFIG', `${this.configFile} is not JSON.`); }
67
+ if (!config || typeof config !== 'object') throw fail('RELAY_HUB_CONFIG', `${this.configFile} is not an object.`);
68
+ return config;
69
+ }
70
+ /** Dirección de la API: sólo loopback (como la exige relay-hub). */
71
+ async listen() {
72
+ const config = await this.config().catch(() => ({}));
73
+ const listen = typeof config.listen === 'string' ? config.listen : DEFAULT_LISTEN;
74
+ const match = LOOPBACK.exec(listen);
75
+ if (!match || Number(match[1]) < 1 || Number(match[1]) > 65535) throw fail('RELAY_HUB_CONFIG', `The hub API must listen on loopback, not ${listen}.`);
76
+ return listen;
77
+ }
78
+ async health({ signal } = {}) {
79
+ try {
80
+ const response = await this.fetch(`http://${await this.listen()}/v1/health`, { signal: AbortSignal.any([signal, AbortSignal.timeout(3000)].filter(Boolean)) });
81
+ if (!response.ok) return null;
82
+ const body = await response.json();
83
+ return body?.ok ? body : null;
84
+ } catch (error) { if (signal?.aborted) throw error; return null; }
85
+ }
86
+ async ready({ signal } = {}) {
87
+ const health = await this.health({ signal }); if (!health) return null;
88
+ try { await readHubToken(this.tokenFile); return health; } catch { return null; }
89
+ }
90
+ /** Una llamada a la API; con 401 relee el token una vez (el hub pudo reiniciarse). */
91
+ async call(method, params = {}, { signal, timeoutMs = 150000 } = {}) {
92
+ for (let attempt = 0; ; attempt++) {
93
+ const token = await readHubToken(this.tokenFile), url = `http://${await this.listen()}/v1/rpc`;
94
+ let response;
95
+ try {
96
+ response = await this.fetch(url, {
97
+ method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
98
+ body: JSON.stringify({ method, params }), signal: AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)].filter(Boolean)),
99
+ });
100
+ } catch (error) {
101
+ if (signal?.aborted) throw error;
102
+ throw fail('RELAY_HUB_DOWN', `relay-hub did not answer ${method} (${error.cause?.code || error.name || error.message}).`);
103
+ }
104
+ let body; try { body = await response.json(); } catch { throw fail('RELAY_HUB_PROTOCOL', `relay-hub answered ${method} with HTTP ${response.status} and no JSON.`); }
105
+ if (body?.error) {
106
+ if (response.status === 401 && !attempt) continue;
107
+ throw fail(String(body.error.code || 'RELAY_HUB_ERROR'), String(body.error.message || 'relay-hub error').slice(0, 2000), { hub: true, status: response.status, method });
108
+ }
109
+ if (!response.ok) throw fail('RELAY_HUB_PROTOCOL', `relay-hub answered ${method} with HTTP ${response.status}.`);
110
+ return body.result;
111
+ }
112
+ }
113
+ /** El binario relay-hub: RELAY_HUB_BIN, el guardado, PATH o el checkout de open-relay de hub.json. */
114
+ async binary(config) {
115
+ const candidates = [this.env.RELAY_HUB_BIN, this.bin];
116
+ for (const dir of String(this.env.PATH || '').split(path.delimiter).filter(Boolean)) candidates.push(path.join(dir, 'relay-hub'));
117
+ if (config?.bin_dir) candidates.push(path.join(expand(config.bin_dir), 'relay-hub'));
118
+ if (config?.open_relay) candidates.push(path.join(expand(config.open_relay), 'target', 'release', 'relay-hub'));
119
+ for (const candidate of candidates.filter(Boolean)) if (path.isAbsolute(candidate) && await executable(candidate)) return candidate;
120
+ throw fail('RELAY_HUB_BINARY', `relay-hub not found (RELAY_HUB_BIN, PATH or ${config?.open_relay ? path.join(config.open_relay, 'target/release') : 'the open-relay checkout of hub.json'}).`);
121
+ }
122
+ /**
123
+ * Asegura la API: si no responde, arranca `relay-hub --root <raíz> serve` como unidad
124
+ * transitoria de systemd de usuario (`relay-hub`, loopback, sin reinicio) o, sin systemd,
125
+ * como proceso desacoplado. Devuelve {started, unit?, listen}.
126
+ */
127
+ async ensure({ signal } = {}) {
128
+ const listen = await this.listen();
129
+ if (await this.ready({ signal })) return { started: false, listen };
130
+ const config = await this.config(), bin = await this.binary(config);
131
+ const args = ['--root', this.root, 'serve'];
132
+ const launched = await this.#launch(bin, args, signal);
133
+ for (const deadline = Date.now() + this.startTimeoutMs; Date.now() < deadline;) {
134
+ signal?.throwIfAborted();
135
+ if (await this.ready({ signal })) return { started: true, listen, bin, ...launched };
136
+ await this.wait(200);
137
+ }
138
+ throw fail('RELAY_HUB_START', `relay-hub did not start on ${listen} within ${Math.round(this.startTimeoutMs / 1000)} s${launched.unit ? ` (journalctl --user -u ${launched.unit})` : ''}.`);
139
+ }
140
+ async #run(command, args) {
141
+ return new Promise(resolve => {
142
+ let child; try { child = this.spawn(command, args, { stdio: ['ignore', 'ignore', 'pipe'] }); } catch (error) { resolve({ code: -1, error }); return; }
143
+ let stderr = ''; child.stderr?.on('data', chunk => { stderr = (stderr + chunk).slice(-2000); });
144
+ child.once('error', error => resolve({ code: -1, error, stderr }));
145
+ child.once('exit', code => resolve({ code, stderr }));
146
+ });
147
+ }
148
+ async #launch(bin, args) {
149
+ const unit = await this.#run('systemd-run', ['--user', `--unit=${HUB_UNIT}`, '--collect', '--quiet', '-p', 'Restart=no', '--', bin, ...args]);
150
+ if (unit.code === 0) return { unit: HUB_UNIT };
151
+ if (unit.error?.code !== 'ENOENT') {
152
+ // Una unidad vieja con el mismo nombre (fallida): se limpia y se reintenta una vez.
153
+ await this.#run('systemctl', ['--user', 'reset-failed', `${HUB_UNIT}.service`]);
154
+ const retry = await this.#run('systemd-run', ['--user', `--unit=${HUB_UNIT}`, '--collect', '--quiet', '-p', 'Restart=no', '--', bin, ...args]);
155
+ if (retry.code === 0) return { unit: HUB_UNIT };
156
+ if (/already (exists|loaded)/i.test(retry.stderr || '')) return { unit: HUB_UNIT, existing: true };
157
+ throw fail('RELAY_HUB_START', `systemd-run could not start relay-hub: ${(retry.stderr || unit.stderr || '').trim().slice(0, 400)}`);
158
+ }
159
+ // Sin systemd (p. ej. macOS): proceso desacoplado, que sigue vivo al salir de Comal.
160
+ const child = this.spawn(bin, args, { detached: true, stdio: 'ignore' }); child.unref?.();
161
+ return { pid: child.pid };
162
+ }
163
+ }
@@ -0,0 +1,55 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { readPrivateJson, writePrivateJson } from '../permission-rules.mjs';
4
+
5
+ // Estado de /relay por usuario, fuera de cualquier workspace (como plugin-trust.json): qué
6
+ // proyecto del hub está ligado a cada repo, la raíz del hub elegida y los swarms que el
7
+ // humano arrancó desde Comal o que Comal adoptó vivos al seguir el proyecto (`adopted_at`),
8
+ // para la tarjeta final, una sola vez. Nada de tokens.
9
+ const VERSION = 1, MAX_LINKS = 256, MAX_SWARMS = 512;
10
+ const NAME = /^[a-z][a-z0-9-]{0,39}$/, SWARM = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
11
+
12
+ export function relayStateFile(env = process.env) {
13
+ return path.resolve(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'comal', 'relay.json');
14
+ }
15
+ const empty = () => ({ version: VERSION, hub_root: null, links: {}, swarms: {} });
16
+ const text = (value, limit) => typeof value === 'string' ? value.slice(0, limit) : null;
17
+
18
+ function clean(data) {
19
+ const out = empty();
20
+ if (!data || typeof data !== 'object' || data.version !== VERSION) return out;
21
+ if (typeof data.hub_root === 'string' && path.isAbsolute(data.hub_root)) out.hub_root = data.hub_root;
22
+ for (const [repo, link] of Object.entries(data.links || {}).slice(-MAX_LINKS)) {
23
+ if (!path.isAbsolute(repo) || !link || !NAME.test(link.project || '')) continue;
24
+ out.links[repo] = { project: link.project, hub_root: text(link.hub_root, 1024), branch: text(link.branch, 200), base_commit: text(link.base_commit, 64), linked_at: text(link.linked_at, 40) };
25
+ }
26
+ for (const [key, swarm] of Object.entries(data.swarms || {}).slice(-MAX_SWARMS)) {
27
+ const [project, id] = key.split('/');
28
+ if (!NAME.test(project || '') || !SWARM.test(id || '') || !swarm) continue;
29
+ out.swarms[key] = { objective: text(swarm.objective, 4000), started_at: text(swarm.started_at, 40), base_commit: text(swarm.base_commit, 64), branch: text(swarm.branch, 200), finished_at: text(swarm.finished_at, 40), ...(swarm.adopted_at ? { adopted_at: text(swarm.adopted_at, 40) } : {}) };
30
+ }
31
+ return out;
32
+ }
33
+
34
+ export class RelayState {
35
+ constructor({ file = relayStateFile(), workspace } = {}) { this.file = path.resolve(file); this.workspace = workspace; this.data = empty(); this.warning = null; this.loaded = false; }
36
+ async load() {
37
+ this.warning = null;
38
+ try { this.data = clean(await readPrivateJson(this.file, { workspace: this.workspace })); }
39
+ catch (error) { this.data = empty(); this.warning = error.message; }
40
+ this.loaded = true; return this;
41
+ }
42
+ async #ready() { if (!this.loaded) await this.load(); }
43
+ async save() {
44
+ if (this.warning) throw Object.assign(new Error(`${this.warning} Fix or delete ${this.file}.`), { code: 'RELAY_STATE_UNSAFE' });
45
+ await writePrivateJson(this.file, this.data, { workspace: this.workspace });
46
+ }
47
+ async hubRoot() { await this.#ready(); return this.data.hub_root; }
48
+ async setHubRoot(root) { await this.#ready(); this.data.hub_root = root ? path.resolve(root) : null; await this.save(); }
49
+ async link(repo) { await this.#ready(); return this.data.links[repo] ? { ...this.data.links[repo] } : null; }
50
+ async setLink(repo, link) { await this.#ready(); delete this.data.links[repo]; this.data.links[repo] = { ...link, linked_at: new Date().toISOString() }; this.data = clean(this.data); await this.save(); }
51
+ async unlink(repo) { await this.#ready(); if (!this.data.links[repo]) return false; delete this.data.links[repo]; await this.save(); return true; }
52
+ async swarm(project, id) { await this.#ready(); const item = this.data.swarms[`${project}/${id}`]; return item ? { ...item } : null; }
53
+ async swarms(project) { await this.#ready(); return Object.entries(this.data.swarms).filter(([key]) => key.startsWith(`${project}/`)).map(([key, value]) => ({ id: key.slice(project.length + 1), ...value })); }
54
+ async setSwarm(project, id, value) { await this.#ready(); const key = `${project}/${id}`; this.data.swarms[key] = { ...(this.data.swarms[key] || {}), ...value }; this.data = clean(this.data); await this.save(); }
55
+ }