@milpa/comal 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/backend/contracts/remote-commands-v1.json +0 -12
- package/backend/contracts/remote-relay-answers-v1.json +209 -0
- package/package.json +3 -1
- package/skills/mv3/SKILL.md +44 -0
- package/skills/mv3/skill.json +4 -0
- package/src/agent.mjs +299 -32
- package/src/auto-guard.mjs +40 -3
- package/src/autocompact.mjs +233 -0
- package/src/bash-timeouts.mjs +29 -0
- package/src/child-env.mjs +23 -0
- package/src/cli.mjs +213 -55
- package/src/code-run-artifact.mjs +30 -0
- package/src/code-run-journal.mjs +135 -0
- package/src/code-run-session-proof.mjs +50 -0
- package/src/code-run-session.mjs +97 -0
- package/src/code-run-worker.mjs +20 -4
- package/src/code-run.mjs +49 -14
- package/src/codex-provider.mjs +4 -4
- package/src/compaction-notice.mjs +22 -0
- package/src/compaction.mjs +162 -23
- package/src/connection-settings.mjs +36 -4
- package/src/context-pressure.mjs +5 -4
- package/src/context.mjs +27 -22
- package/src/execution-metrics.mjs +36 -5
- package/src/file-recovery-proof.mjs +9 -1
- package/src/file-recovery.mjs +36 -3
- package/src/gemini-provider.mjs +5 -3
- package/src/git-integration-operations.mjs +2 -1
- package/src/git-plumbing.mjs +2 -1
- package/src/git-source.mjs +2 -1
- package/src/git-workspaces.mjs +2 -1
- package/src/house.mjs +13 -1
- package/src/i18n/cli-es.mjs +12 -1
- package/src/i18n/es.mjs +38 -1
- package/src/judges.mjs +2 -1
- package/src/mcp.mjs +2 -1
- package/src/model-controls.mjs +16 -2
- package/src/model-deadline.mjs +2 -2
- package/src/openai-provider.mjs +8 -4
- package/src/operations.mjs +17 -2
- package/src/package-identity.mjs +68 -0
- package/src/peer-probe.mjs +16 -0
- package/src/permissions.mjs +97 -4
- package/src/plugin-config.mjs +18 -1
- package/src/plugin-controls.mjs +31 -8
- package/src/plugin-trust.mjs +15 -6
- package/src/plugins/chrome-lab/bridge.mjs +19 -6
- package/src/plugins/chrome-lab/client.mjs +242 -12
- package/src/plugins/chrome-lab/index.mjs +106 -24
- package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
- package/src/plugins/chrome-lab/native-reply.mjs +31 -0
- package/src/plugins/open-relay/actions.mjs +60 -9
- package/src/plugins/open-relay/client.mjs +3 -3
- package/src/plugins/open-relay/decision.mjs +3 -2
- package/src/plugins/open-relay/index.mjs +17 -7
- package/src/plugins/open-relay/lookup.mjs +41 -4
- package/src/plugins/open-relay/notices.mjs +44 -3
- package/src/plugins/open-relay/panel.mjs +4 -1
- package/src/plugins/open-relay/watch.mjs +27 -8
- package/src/process-lock.mjs +20 -12
- package/src/provider-auth.mjs +6 -3
- package/src/provider-profiles.mjs +13 -3
- package/src/provider.mjs +175 -30
- package/src/read-only-check.mjs +6 -1
- package/src/relay/broker.mjs +160 -0
- package/src/relay/cards.mjs +702 -0
- package/src/relay/flow.mjs +886 -0
- package/src/relay/follow.mjs +119 -0
- package/src/relay/git.mjs +23 -0
- package/src/relay/hub-client.mjs +163 -0
- package/src/relay/state.mjs +55 -0
- package/src/remote-control.mjs +115 -12
- package/src/remote-relay.mjs +468 -0
- package/src/session-bus.mjs +13 -3
- package/src/session-provider-settings.mjs +2 -2
- package/src/skill-runtime.mjs +8 -6
- package/src/source-ownership.mjs +3 -1
- package/src/tool-manifest.mjs +71 -0
- package/src/tool-packs.mjs +22 -6
- package/src/tool-schema.mjs +14 -2
- package/src/tools.mjs +96 -27
- package/src/tui/controller.mjs +4 -1
- package/src/tui/discovery.mjs +11 -7
- package/src/tui/inbound-blocks.mjs +5 -1
- package/src/tui/screen.mjs +53 -14
- package/src/tui/swarm-panel.mjs +19 -8
- package/src/turn-timing.mjs +82 -0
- package/src/usage-accounting.mjs +12 -1
- package/src/vision.mjs +6 -1
- package/src/visual/chrome.mjs +379 -0
- package/src/visual/fingerprint.mjs +30 -0
- package/src/visual/fixtures.mjs +201 -0
- package/src/visual/gate.mjs +92 -0
- package/src/visual/judge.mjs +114 -0
- package/src/visual/ledger.mjs +160 -0
- package/src/visual/measure.mjs +145 -0
- package/src/visual/names.mjs +7 -0
- package/src/visual/operations.mjs +211 -0
- package/src/visual/pixel-diff.mjs +96 -0
- package/src/visual/png.mjs +103 -0
- package/src/visual/registry.mjs +265 -0
- package/src/visual/report.mjs +64 -0
- package/src/visual/verify.mjs +292 -0
- package/src/work-assertions.mjs +15 -4
- package/src/work-plan.mjs +166 -30
- package/src/work-state.mjs +4 -4
- package/src/workflows/chrome-task-adoption.mjs +7 -3
- package/src/workspace-serve.mjs +59 -0
|
@@ -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
|
+
}
|
package/src/remote-control.mjs
CHANGED
|
@@ -19,17 +19,55 @@ export function projectRemoteEvent(event, turnId) {
|
|
|
19
19
|
if(event.type==='house_waiting')return {...base,type:'permission.waiting',tool_name:boundText(event.operation,128),text:'Waiting for approval in the local terminal'};
|
|
20
20
|
if(event.type==='house_answered')return {...base,type:'permission.answered',tool_name:boundText(event.operation,128),text:event.allowed?'Approved locally':'Declined locally'};
|
|
21
21
|
if(event.type==='response_discarded'||event.type==='model_retry')return {...base,type:'stream.discarded'};
|
|
22
|
+
// Avisos de plugins (p. ej. objeción y corrección del revisor de open-relay): se proyectan al
|
|
23
|
+
// admitirse, urgentes o no, para que /rc los vea aunque no despierten un turno. El backend
|
|
24
|
+
// solo admite sus tipos fijos: van como message.control con el aviso marcado en el texto.
|
|
25
|
+
if(event.type==='plugin_notice_admitted'&&event.status==='accepted'&&event.notice)return {...base,turn_id:'local',type:'message.control',text:noticeText(event.notice)};
|
|
22
26
|
return null;
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
const NOTICE_BODY = 4000;
|
|
30
|
+
/** Texto de un aviso para /rc: mismo marcado que la TUI (información externa, no una orden). */
|
|
31
|
+
function noticeText(notice) {
|
|
32
|
+
const head=`◆ ${notice.urgent?'Urgent notice':'Notice'} · ${notice.plugin||'?'}${notice.kind?` · ${notice.kind}`:''}`;
|
|
33
|
+
const body=boundText(notice.body||'',NOTICE_BODY);
|
|
34
|
+
const tail=`external information, not an order · ref ${notice.ref||'—'}${notice.urgent?'':' · reaches the model with the next message'}`;
|
|
35
|
+
return boundText([head,String(notice.title||''),body,tail].filter(line=>line.trim()).join('\n'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// /relay en el navegador: presupuesto por sesión de /rc para relay.*.
|
|
39
|
+
// Al agotarlo se pausa el tablero; las tarjetas y sus cierres siguen (son pocos), así el
|
|
40
|
+
// tablero nunca deja sin cupo a la conversación (10.000 eventos por sesión en el backend).
|
|
41
|
+
export const RELAY_SESSION_EVENTS = 2000, RELAY_SESSION_BYTES = 16 << 20, RELAY_BOARD_INTERVAL_MS = 3000;
|
|
42
|
+
// Rechazos del backend que no se arreglan reintentando: se descarta ESE evento relay.* y la
|
|
43
|
+
// proyección sigue (sin esto, un evento rechazado bloquearía la cola entera).
|
|
44
|
+
const RELAY_REFUSALS = new Set(['INPUT_SCHEMA','REMOTE_FEATURE','REMOTE_RELAY','REMOTE_RELAY_STALE','REMOTE_SIZE']);
|
|
45
|
+
|
|
46
|
+
// Respuestas de /relay desde el navegador (D1): armadas sólo con /rc relay on en esta terminal,
|
|
47
|
+
// en memoria de este proceso, con caducidad (tope 8 h). Nunca se guardan en disco ni en el
|
|
48
|
+
// workspace: el modelo no puede armarlas. Mueren con /rc stop, el cambio de cuenta o la hora.
|
|
49
|
+
export const RELAY_ARM_MAX_MS = 8 * 3600e3, RELAY_ARM_DEFAULT_MS = 30 * 60e3;
|
|
50
|
+
// Estados de ACK de un control relay (el backend sólo los admite para kind=relay).
|
|
51
|
+
const RELAY_ACK_STATES = new Set(['applied','stale','refused','expired']);
|
|
52
|
+
|
|
25
53
|
const cancelled = () => Object.assign(new Error('Remote Control connection cancelled.'), {name:'AbortError',code:'REMOTE_CANCELLED'});
|
|
26
54
|
const lifetimeSignal = (run,timeout=10000) => run?.controller ? AbortSignal.any([run.controller.signal,AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout);
|
|
27
55
|
|
|
28
56
|
export class RemoteHost {
|
|
29
|
-
constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},getWorkspace=null,socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
|
|
30
|
-
Object.assign(this,{client,onInput,onStop,onNotice,getWorkspace,socketFactory});this.pending=[];this.turnId=null;this.stopped=true;this.transport='disconnected';this.processing=null;this.sending=null;this.lifecycle=null;this.starting=null;
|
|
57
|
+
constructor({client=new CloudClient(),onInput=async()=>{throw Error('Local queue unavailable.');},onStop=async()=>false,onNotice=()=>{},getWorkspace=null,relay=null,onRelay=null,now=Date.now,socketFactory=(url,protocol)=>new WebSocket(url,protocol)}={}) {
|
|
58
|
+
Object.assign(this,{client,onInput,onStop,onNotice,getWorkspace,relay,onRelay,now,socketFactory});this.answersUntil=0;this.pending=[];this.turnId=null;this.stopped=true;this.transport='disconnected';this.processing=null;this.sending=null;this.lifecycle=null;this.starting=null;
|
|
59
|
+
}
|
|
60
|
+
status(){const run=this.lifecycle;return this.session?{enabled:!this.stopped,url:this.url,session:this.session.id,transport:this.transport,permissions:'local_terminal_only',relay:run?.relay?(run.answers?'answers':'read_only'):'off',...(run?.answers?{relay_answers:{armed:this.armed(),until:this.armed()?this.answersUntil:null}}:{})}:{enabled:false};}
|
|
61
|
+
/** ¿Puede esta sesión de /rc responder tarjetas de /relay? (backend con relay_answers_v1). */
|
|
62
|
+
answersAvailable(){return Boolean(this.current(this.lifecycle)&&this.lifecycle.answers);}
|
|
63
|
+
armed(){return this.answersAvailable()&&this.answersUntil>this.now();}
|
|
64
|
+
/** /rc relay on [duración]: arma las respuestas del navegador hasta now+ms (tope 8 h). */
|
|
65
|
+
arm(ms=RELAY_ARM_DEFAULT_MS){
|
|
66
|
+
if(!this.answersAvailable())throw Error(this.lifecycle?.relay?'This Comal backend does not accept /relay answers from the browser yet.':'Open /rc in a session linked to /relay first.');
|
|
67
|
+
const span=Math.min(Math.max(60e3,Math.floor(Number(ms)||RELAY_ARM_DEFAULT_MS)),RELAY_ARM_MAX_MS);
|
|
68
|
+
this.answersUntil=this.now()+span;this.pulse().catch(()=>{});return this.answersUntil;
|
|
31
69
|
}
|
|
32
|
-
|
|
70
|
+
disarm(){this.answersUntil=0;if(this.current(this.lifecycle))this.pulse().catch(()=>{});}
|
|
33
71
|
current(run,{starting=false}={}) {return this.lifecycle===run && (starting || !this.stopped) && !run?.controller?.signal.aborted;}
|
|
34
72
|
assertCurrent(run,options){if(!this.current(run,options))throw cancelled();}
|
|
35
73
|
async revoke(run) {
|
|
@@ -49,8 +87,21 @@ export class RemoteHost {
|
|
|
49
87
|
if(!account)throw Error('Sign in with comal cloud login before enabling Remote Control.');
|
|
50
88
|
run.account={origin:account.origin,user_id:account.user_id};run.enablingToken=account.token;
|
|
51
89
|
const workspace=this.getWorkspace?await this.getWorkspace(session):null;this.assertCurrent(run,{starting:true});
|
|
52
|
-
const
|
|
90
|
+
const features=['commands_v1','thinking_v1',...(this.getWorkspace?['workspace_v1']:[]),...(this.relay?['relay_v1']:[]),...(this.relay&&this.onRelay?['relay_answers_v1']:[])];
|
|
91
|
+
const open=list=>this.client.invoke('remote.sessions.open',{local_session_id:session.id,host_id:randomUUID(),label:boundText(session.name || 'Comal',80),features:list},{expectedAccount:run.account,signal:run.controller.signal});
|
|
92
|
+
// Un backend anterior rechaza la capacidad desconocida en el esquema de Milpa (no se crea
|
|
93
|
+
// sesión): se reintenta sin relay_answers_v1 (backend de la fase «ver») y después sin
|
|
94
|
+
// relay_v1 (backend anterior a /relay). /rc se abre igual con lo que el backend admite.
|
|
95
|
+
const attempts=[features,features.filter(item=>item!=='relay_answers_v1'),features.filter(item=>!item.startsWith('relay_'))].filter((list,index,all)=>index===0||list.length<all[index-1].length);
|
|
96
|
+
let opened,used=attempts[0];
|
|
97
|
+
for(const [index,list] of attempts.entries()){
|
|
98
|
+
try{used=list;opened=await open(list);break;}
|
|
99
|
+
catch(error){if(error.code!=='INPUT_SCHEMA'||index===attempts.length-1)throw error;this.assertCurrent(run,{starting:true});}
|
|
100
|
+
}
|
|
101
|
+
run.relayRefused=Boolean(this.relay)&&!used.includes('relay_v1');
|
|
53
102
|
run.session=opened.session;
|
|
103
|
+
const granted=feature=>used.includes(feature)&&(!Array.isArray(opened.session?.features)||opened.session.features.includes(feature));
|
|
104
|
+
run.relay=Boolean(this.relay)&&granted('relay_v1');run.answers=run.relay&&Boolean(this.onRelay)&&granted('relay_answers_v1');this.answersUntil=0;
|
|
54
105
|
// Abort can race a successful response. Revoke a late-created grant even
|
|
55
106
|
// when the local stop completed before its identifier became available.
|
|
56
107
|
if(!this.current(run,{starting:true})){await this.revoke(run);throw cancelled();}
|
|
@@ -58,12 +109,13 @@ export class RemoteHost {
|
|
|
58
109
|
await this.authorize(run);this.assertCurrent(run);await this.pulse(run);this.assertCurrent(run);
|
|
59
110
|
this.transport='https-polling';this.connectSocket(run);
|
|
60
111
|
if(workspace)this.publishWorkspace(workspace,run);
|
|
112
|
+
if(run.relay){run.relaySink={extended:run.answers,relayBoard:(board,options)=>this.relayBoard(board,options,run),relayEvent:(type,relay)=>this.relayEvent(type,relay,run)};this.relay.attach(run.relaySink);}
|
|
61
113
|
this.timer=setInterval(()=>this.tick(run),1000);this.timer.unref();
|
|
62
114
|
for(const message of (session.historyMessages || []).slice(-20)){
|
|
63
115
|
const event=message.role==='user'&&(typeof message.content==='string'||Array.isArray(message.content)&&message.content.some(block=>block.type==='text'))?{type:'user',text:typeof message.content==='string'?message.content:message.content.filter(block=>block.type==='text').map(block=>block.text).join('\n')}:message.role==='assistant'?{type:'assistant',message}:null;
|
|
64
116
|
if(event)this.observe(event);
|
|
65
117
|
}
|
|
66
|
-
this.onNotice('Remote Control: '+this.url+' · transcript stored on your Comal backend; permissions stay in this terminal.');
|
|
118
|
+
this.onNotice('Remote Control: '+this.url+' · transcript stored on your Comal backend; permissions stay in this terminal.'+(run.answers?' /relay is visible there; to answer its cards from the browser run /rc relay on here (off by default).':run.relay?' /relay is visible there read-only; answer its cards here.':run.relayRefused?' This backend does not show /relay yet.':''));
|
|
67
119
|
return this.status();
|
|
68
120
|
} catch(error) {
|
|
69
121
|
if(this.lifecycle===run)await this.stop();else await this.revoke(run);
|
|
@@ -113,6 +165,41 @@ export class RemoteHost {
|
|
|
113
165
|
this.pending.push({event_id:randomUUID(),event:{type:'workspace.snapshot',text:'',turn_id:this.turnId||'local',workspace}});
|
|
114
166
|
run.workspaceDigest=key;this.flush(run).catch(()=>{});
|
|
115
167
|
}
|
|
168
|
+
/** Tablero de /swarm: coalescido (≥ 3 s), sin repetir huella y «el último gana» en el búfer. */
|
|
169
|
+
relayBoard(board,{now=false}={},run=this.lifecycle) {
|
|
170
|
+
if(!this.current(run)||!run.relay)return;
|
|
171
|
+
const {source,...content}=board,key=digest(content);
|
|
172
|
+
run.boardNext=key===run.boardDigest?null:board;run.boardNextKey=key;
|
|
173
|
+
if(!run.boardNext)return;
|
|
174
|
+
const wait=now?0:Math.max(0,(run.boardAt||0)+RELAY_BOARD_INTERVAL_MS-Date.now());
|
|
175
|
+
if(!wait){clearTimeout(run.boardTimer);run.boardTimer=null;this.pushRelayBoard(run);return;}
|
|
176
|
+
if(!run.boardTimer){run.boardTimer=setTimeout(()=>{run.boardTimer=null;this.pushRelayBoard(run);},wait);run.boardTimer.unref?.();}
|
|
177
|
+
}
|
|
178
|
+
pushRelayBoard(run=this.lifecycle) {
|
|
179
|
+
if(!this.current(run)||!run.boardNext||run.boardPaused)return;
|
|
180
|
+
let board=run.boardNext;const key=run.boardNextKey;run.boardNext=null;
|
|
181
|
+
if((run.relayEvents||0)>=RELAY_SESSION_EVENTS||(run.relayBytes||0)>=RELAY_SESSION_BYTES){
|
|
182
|
+
run.boardPaused=true;board={...board,swarms:[],paused:true,omitted:{swarms:board.swarms_total||0,tasks:0}};
|
|
183
|
+
this.onNotice('Remote /relay board paused: this /rc session reached its relay budget. Cards still reach the browser; /swarm here keeps the live board.');
|
|
184
|
+
}
|
|
185
|
+
// El backend no admite un tablero que retroceda en el tiempo de su foto.
|
|
186
|
+
const seen=Math.max(Number(board.source?.view_generated_at)||1,run.boardSeen||1);board={...board,source:{...board.source,view_generated_at:seen}};run.boardSeen=seen;
|
|
187
|
+
const entry={event_id:randomUUID(),event:{type:'relay.board',text:'',turn_id:'local',relay:board}};
|
|
188
|
+
if(Buffer.byteLength(JSON.stringify(board))>36000)return;
|
|
189
|
+
const index=this.pending.findIndex((item,position)=>item.event.type==='relay.board'&&(position>0||!this.sending));
|
|
190
|
+
if(index>=0)this.pending[index]=entry;
|
|
191
|
+
else{if(this.pending.length>=512)return;this.pending.push(entry);}
|
|
192
|
+
run.boardDigest=key;run.boardAt=Date.now();this.relayAccount(run,entry.event);this.flush(run).catch(()=>{});
|
|
193
|
+
}
|
|
194
|
+
/** relay.card, relay.card.page y relay.resolved: siempre salen (dentro del búfer de 512). */
|
|
195
|
+
relayEvent(type,relay,run=this.lifecycle) {
|
|
196
|
+
if(!this.current(run)||!run.relay)return false;
|
|
197
|
+
if(Buffer.byteLength(JSON.stringify(relay))>36000)return false;
|
|
198
|
+
if(this.pending.length>=512){this.onNotice('Remote /relay card skipped: outbound buffer full. The card stays in this terminal.');return false;}
|
|
199
|
+
const entry={event_id:randomUUID(),event:{type,text:'',turn_id:'local',relay}};
|
|
200
|
+
this.pending.push(entry);this.relayAccount(run,entry.event);this.flush(run).catch(()=>{});return true;
|
|
201
|
+
}
|
|
202
|
+
relayAccount(run,event){run.relayEvents=(run.relayEvents||0)+1;run.relayBytes=(run.relayBytes||0)+Buffer.byteLength(JSON.stringify(event));}
|
|
116
203
|
invalidateWorkspace() {
|
|
117
204
|
const run=this.lifecycle;if(!this.current(run)||!this.getWorkspace)return;
|
|
118
205
|
run.workspaceDirty=true;
|
|
@@ -157,10 +244,15 @@ export class RemoteHost {
|
|
|
157
244
|
async flush(run=this.lifecycle){
|
|
158
245
|
if(!this.current(run))return;
|
|
159
246
|
if(this.sending)return this.sending;
|
|
160
|
-
const work=(async()=>{while(this.current(run)&&this.pending.length){
|
|
247
|
+
const work=(async()=>{while(this.current(run)&&this.pending.length){
|
|
248
|
+
const item=this.pending[0];
|
|
249
|
+
try{await this.call('remote.events.publish',item,run);}
|
|
250
|
+
catch(error){if(!this.current(run)||!String(item.event?.type).startsWith('relay.')||!RELAY_REFUSALS.has(error.code))throw error;this.onNotice('Remote /relay event refused by the backend ('+error.code+'); /relay and /swarm in this terminal are unaffected.');}
|
|
251
|
+
this.assertCurrent(run);if(this.pending[0]===item)this.pending.shift();else{const index=this.pending.indexOf(item);if(index>=0)this.pending.splice(index,1);}
|
|
252
|
+
}})();
|
|
161
253
|
this.sending=work;try{return await work;}finally{if(this.sending===work)this.sending=null;}
|
|
162
254
|
}
|
|
163
|
-
pulse(run=this.lifecycle){const work=(this.pulsing || Promise.resolve()).catch(()=>{}).then(()=>{if(this.current(run))return this.call('remote.host.pulse',{status:this.turnId?'busy':'idle',turn_id:this.turnId},run);});this.pulsing=work;return work;}
|
|
255
|
+
pulse(run=this.lifecycle){const work=(this.pulsing || Promise.resolve()).catch(()=>{}).then(()=>{if(!this.current(run))return;const armed=run.answers&&this.answersUntil>this.now();return this.call('remote.host.pulse',{status:this.turnId?'busy':'idle',turn_id:this.turnId,...(run.answers?{relay_answers:{armed,until:armed?Math.floor(this.answersUntil/1000):0}}:{})},run);});this.pulsing=work;return work;}
|
|
164
256
|
async beginTurn(){this.turnId=randomUUID();if(!this.stopped)this.pulse().catch(error=>{if(error.code!=='REMOTE_CANCELLED')this.onNotice('Remote state unavailable: '+error.message);});}
|
|
165
257
|
async endTurn(){
|
|
166
258
|
this.invalidateWorkspace();
|
|
@@ -179,13 +271,14 @@ export class RemoteHost {
|
|
|
179
271
|
await this.pulse(run);this.assertCurrent(run);await this.flush(run);this.assertCurrent(run);
|
|
180
272
|
const page=await this.call('remote.inputs.claim',{},run);this.assertCurrent(run);
|
|
181
273
|
for(const input of page.inputs){
|
|
182
|
-
this.assertCurrent(run);let state='accepted_locally',queue_item_id='';
|
|
274
|
+
this.assertCurrent(run);let state='accepted_locally',queue_item_id='',outcome=null;
|
|
183
275
|
// Only admission errors are rejection. A lost ACK must redeliver the same input.
|
|
184
|
-
|
|
276
|
+
if(input.kind==='relay'){({state,outcome}=await this.relayAnswer(input,run));}
|
|
277
|
+
else try {
|
|
185
278
|
if(input.kind==='stop'){if(input.turn_id!==this.turnId||!await this.onStop(input.turn_id))throw Error('Stale turn.');queue_item_id='stop:'+input.turn_id;}
|
|
186
279
|
else {const accepted=await this.onInput(input,{session:this.session.id,request:input.request_id});queue_item_id=accepted.id;}
|
|
187
280
|
}catch{state='rejected';}
|
|
188
|
-
this.assertCurrent(run);await this.call('remote.inputs.ack',{request_id:input.request_id,state,queue_item_id},run);
|
|
281
|
+
this.assertCurrent(run);await this.call('remote.inputs.ack',{request_id:input.request_id,state,queue_item_id,...(outcome?{outcome}:{})},run);
|
|
189
282
|
}
|
|
190
283
|
if(!this.socket&&Date.now()>this.nextSocketAt)this.connectSocket(run);
|
|
191
284
|
})().catch(error=>{
|
|
@@ -195,10 +288,20 @@ export class RemoteHost {
|
|
|
195
288
|
});
|
|
196
289
|
this.processing=work;try{return await work;}finally{if(this.processing===work)this.processing=null;}
|
|
197
290
|
}
|
|
291
|
+
/** Control tipado del navegador sobre una tarjeta de /relay (kind=relay). Nunca entra en la cola
|
|
292
|
+
* del modelo: lo revalida y aplica el broker local con las funciones de la TUI. */
|
|
293
|
+
async relayAnswer(input,run=this.lifecycle){
|
|
294
|
+
if(!run?.answers||!this.onRelay)return {state:'refused',outcome:'host_legacy'};
|
|
295
|
+
try{
|
|
296
|
+
const result=await this.onRelay(input,{session:this.session.id,epoch:this.session.host_epoch,armed:()=>this.armed(),validate:()=>this.validate({remote:{session:this.session.id,request:input.request_id}})});
|
|
297
|
+
const state=RELAY_ACK_STATES.has(result?.state)?result.state:'refused';
|
|
298
|
+
return {state,outcome:/^[a-z_]{1,64}$/.test(String(result?.outcome||''))?result.outcome:'host_error'};
|
|
299
|
+
}catch(error){if(!this.current(run))throw error;return {state:'refused',outcome:'host_error'};}
|
|
300
|
+
}
|
|
198
301
|
async stop(){
|
|
199
302
|
const run=this.lifecycle || {session:this.session,account:this.account,enablingToken:this.enablingToken};
|
|
200
|
-
this.lifecycle=null;this.starting=null;run.controller?.abort();clearTimeout(run.workspaceTimer);
|
|
201
|
-
this.stopped=true;clearInterval(this.timer);clearTimeout(this.deltaTimer);clearTimeout(this.socketTimeout);this.socket?.close();this.socket=null;this.delta='';this.transport='disconnected';
|
|
303
|
+
this.lifecycle=null;this.starting=null;run.controller?.abort();clearTimeout(run.workspaceTimer);clearTimeout(run.boardTimer);if(run.relaySink)this.relay?.detach(run.relaySink);
|
|
304
|
+
this.stopped=true;this.answersUntil=0;clearInterval(this.timer);clearTimeout(this.deltaTimer);clearTimeout(this.socketTimeout);this.socket?.close();this.socket=null;this.delta='';this.transport='disconnected';
|
|
202
305
|
this.access=null;this.enablingToken=null;this.pending=[];this.turnId=null;this.processing=null;this.sending=null;this.pulsing=null;this.connecting=null;
|
|
203
306
|
await this.revoke(run);return this.status();
|
|
204
307
|
}
|