@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
@@ -1,37 +1,138 @@
1
- import { reconnectable } from './client.mjs';
2
- import { deliveryNotice, planNotice, taskAlert, jobAlert } from './notices.mjs';
1
+ import { needsHuman } from './client.mjs';
2
+ import { deliveryNotice, planNotice, taskAlert, jobAlert, reviewAlert, correctionNotice } from './notices.mjs';
3
+ import { clean, excerpt } from './bound.mjs';
4
+ import { decisionBlock } from './decision.mjs';
3
5
 
4
- // Vigilancia del enjambre, como `relay msg watch --operator`: una conexión
5
- // dedicada por swarm para `msg.wait` (espera larga), otra para `swarm.status`
6
- // con `if_changed`, descubrimiento de swarms cada pocos segundos y sondeo de jobs.
7
- // No hay push por socket. Deduplica por ref y guarda el cursor del buzón por swarm.
6
+ // Vigilancia del enjambre sin sondeo (fase 3, open-relay ≥ 2.8): una conexión dedicada
7
+ // hace `events.wait {after, timeout_ms}` en bucle sobre el log de relayd. Al conectar
8
+ // por primera vez se toma el `head_seq` y se revisa lo que ya estaba pendiente (buzón
9
+ // del operador, tareas, planes y jobs); después sólo se mira lo que tocan los eventos:
10
+ // `message_sent` al operador → buzón de ese swarm; `task:*` → foto del swarm (alertas y
11
+ // estados); `plan:*` → planes pendientes; `job:*` → jobs. Si la espera vence
12
+ // (`timed_out`), se repite con el mismo `after`. Ante un corte (relayd reiniciado,
13
+ // UNAVAILABLE) reconecta con espera creciente y sigue desde su cursor: lo que pasó
14
+ // mientras tanto llega en la siguiente espera. Deduplica por ref.
15
+ // Fase 4: con lo que ya lee, guarda además una foto ACOTADA y limpia para el panel /swarm
16
+ // (tareas, roles, preguntas y planes pendientes) y avisa con onChange; no hace llamadas
17
+ // extra salvo releer el buzón cuando relayd registra un ack o vencimiento de una entrega del
18
+ // operador, y la foto del swarm cuando cambian sus roles o equipos.
8
19
  const sleep = (ms, signal) => new Promise(resolve => { const timer = setTimeout(done, ms); function done() { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); } signal?.addEventListener('abort', done, { once: true }); });
9
- const SEEN_LIMIT = 4000, PAGE = 200;
20
+ const SEEN_LIMIT = 4000, PAGE = 200, TASKS_SHOWN = 40;
21
+ // Topes de la foto del panel (texto de agentes: limpio y acotado ya aquí).
22
+ const VIEW = Object.freeze({ swarms: 16, tasks: 80, roles: 40, pending: 30, plans: 20, title: 160, objective: 300, excerpt: 240, id: 120 });
23
+ const short = (value, limit = VIEW.id) => { const text = clean(value).replace(/\s+/g, ' ').trim(); return text.length > limit ? text.slice(0, limit) + '…' : text; };
24
+ const stamp = value => { const number = Number(value); return Number.isFinite(number) && number > 0 ? number : null; };
25
+ const splitAggregate = value => { const text = String(value || ''), at = text.indexOf(':'); return at < 0 ? [text, ''] : [text.slice(0, at), text.slice(at + 1)]; };
10
26
 
11
27
  export class RelayWatcher {
12
- constructor({ client, notify, swarms = null, waitMs = 30000, discoverMs = 3000, jobsMs = 5000, questionGraceMs = 1200, maxBackoffMs = 5000, onError = () => {} }) {
13
- Object.assign(this, { client, notify, filter: swarms && swarms.length ? new Set(swarms) : null, waitMs, discoverMs, jobsMs, questionGraceMs, maxBackoffMs, onError });
14
- this.controller = new AbortController(); this.workers = new Map(); this.cursors = new Map(); this.digests = new Map();
15
- this.seen = new Set(); this.retry = []; this.connections = new Set(); this.sent = 0; this.lastError = null; this.started = false;
28
+ constructor({ client, notify, swarms = null, waitMs = 30000, questionGraceMs = 1200, maxBackoffMs = 5000, humanBackoffMs = 30000, batch = 200, onError = () => {}, onChange = () => {}, now = Date.now }) {
29
+ Object.assign(this, { client, notify, filter: swarms && swarms.length ? new Set(swarms) : null, waitMs, questionGraceMs, maxBackoffMs, humanBackoffMs, batch, onError, onChange, now });
30
+ // Foto del panel: por swarm, resumen, tareas (con desde cuándo se ve su estado), roles,
31
+ // entregas pendientes del operador y planes pendientes. Todo limpio y acotado.
32
+ this.info = new Map(); this.board = new Map(); this.since = new Map(); this.pending = new Map(); this.proposals = new Map(); this.criteria = new Map();
33
+ this.questionTask = new Map(); this.messageSwarm = new Map(); this.roleSwarm = new Map(); this.updatedAt = null;
34
+ this.controller = new AbortController(); this.known = new Set(); this.taskStates = new Map(); this.taskSwarm = new Map();
35
+ this.seen = new Set(); this.retry = []; this.connection = null; this.sent = 0; this.lastError = null; this.started = false;
36
+ this.cursor = null; this.events = 0; this.waits = 0; this.opened = 0; this.lastEvent = null; this.state = 'idle'; this.loop = null;
16
37
  }
17
38
  get signal() { return this.controller.signal; }
18
39
  status() {
19
- return { watching: [...this.workers.keys()], filter: this.filter ? [...this.filter] : null, cursors: Object.fromEntries(this.cursors), notices_sent: this.sent, pending_retry: this.retry.length, reconnects: Math.max(0, this.client.connects - 1), last_error: this.lastError };
20
- }
21
- start() {
22
- if (this.started) return; this.started = true;
23
- this.#loop('discover', () => this.#discover(), this.discoverMs);
24
- this.#loop('jobs', () => this.#jobs(), this.jobsMs);
25
- }
26
- async stop() { this.controller.abort(new Error('Watch stopped.')); for (const connection of this.connections) connection.close(); this.connections.clear(); await Promise.allSettled([...this.workers.values()].flat()); }
27
- #error(where, error) { if (this.signal.aborted) return; this.lastError = { where, code: error.code || 'ERROR', message: String(error.message).slice(0, 300), at: new Date().toISOString() }; this.onError(this.lastError); }
28
- #loop(name, step, every) {
29
- (async () => {
30
- while (!this.signal.aborted) {
31
- try { await step(); } catch (error) { this.#error(name, error); }
32
- await sleep(every, this.signal);
40
+ const tasks = {};
41
+ for (const [swarm, states] of this.taskStates) tasks[swarm] = Object.fromEntries([...states].slice(-TASKS_SHOWN));
42
+ return { state: this.state, watching: [...this.known], filter: this.filter ? [...this.filter] : null, cursor: this.cursor, events_seen: this.events, waits: this.waits, last_event: this.lastEvent, tasks, notices_sent: this.sent, pending_retry: this.retry.length, reconnects: Math.max(0, this.opened - 1), last_error: this.lastError };
43
+ }
44
+ start() { if (this.started) return; this.started = true; this.loop = this.#run(); }
45
+ #state(state) { if (this.state === state) return; this.state = state; this.#changed(); }
46
+ #changed() { this.updatedAt = this.now(); try { this.onChange(); } catch {} }
47
+ /** Foto para el panel /swarm: datos ya limpios y acotados, sin tokens ni rutas del token. */
48
+ view() {
49
+ const swarms = [];
50
+ for (const id of [...this.known].slice(0, VIEW.swarms)) {
51
+ const board = this.board.get(id) || { tasks: [], roles: [], truncated: false };
52
+ swarms.push({ id: short(id), ...(this.info.get(id) || {}), tasks: board.tasks.map(task => ({ ...task, ...(this.since.get(`${id}:${task.id}`) || { since: null, since_exact: false }) })), roles: board.roles, pending: this.pending.get(id) || [], plans: this.proposals.get(id) || [], criteria: this.criteria.get(id) || null, truncated: board.truncated || false });
53
+ }
54
+ return { swarms, swarms_total: this.known.size, updated_at: this.updatedAt };
55
+ }
56
+ /** Corta la espera en curso (cerrar la conexión cancela events.wait en relayd) y espera al bucle. */
57
+ async stop() { this.controller.abort(new Error('Watch stopped.')); this.connection?.close(); await this.loop?.catch(() => {}); this.state = 'stopped'; this.#changed(); }
58
+ #watched(swarm) { return typeof swarm === 'string' && (!this.filter || this.filter.has(swarm)); }
59
+ #error(where, error) { if (this.signal.aborted) return; this.lastError = { where, code: error.code || 'ERROR', message: String(error.message).slice(0, 600), at: new Date().toISOString() }; this.onError(this.lastError); this.#changed(); }
60
+ async #run() {
61
+ let backoff = 250;
62
+ while (!this.signal.aborted) {
63
+ let connection;
64
+ try {
65
+ this.#state('connecting');
66
+ connection = this.connection = await this.client.dedicated(this.signal); this.opened++;
67
+ backoff = 250;
68
+ if (this.cursor === null) { this.#state('catching_up'); await this.#catchUp(connection); }
69
+ this.lastError = null; if (this.state === 'waiting') this.#changed(); else this.#state('waiting');
70
+ await this.#follow(connection);
71
+ } catch (error) {
72
+ if (this.signal.aborted) return;
73
+ this.#error(this.state, error);
74
+ // Sin la capacidad read no hay nada que vigilar: hace falta otra delegación.
75
+ if (error.code === 'NOT_DELEGATED') { this.#state('stopped'); return; }
76
+ // Token ausente, revocado o vencido: espera larga (el humano lo reemite) sin martillar.
77
+ const wait = needsHuman(error) ? this.humanBackoffMs : backoff;
78
+ this.#state(needsHuman(error) ? 'needs_human' : 'backoff');
79
+ await sleep(wait, this.signal); backoff = Math.min(this.maxBackoffMs, backoff * 2);
80
+ } finally { connection?.close(); if (this.connection === connection) this.connection = null; }
81
+ }
82
+ }
83
+ /** Primera conexión: cursor = head_seq y revisión de lo que ya estaba pendiente. */
84
+ async #catchUp(connection) {
85
+ const { head_seq } = await connection.call('daemon.status', {}, { signal: this.signal });
86
+ const { swarms = [] } = await connection.call('swarm.list', {}, { signal: this.signal });
87
+ for (const swarm of swarms) if (this.#watched(swarm.id)) { this.known.add(swarm.id); this.#summary(swarm.id, swarm); }
88
+ for (const swarm of this.known) { await this.#status(connection, swarm); await this.#plans(connection, swarm); await this.#inbox(connection, swarm); }
89
+ await this.#jobs(connection);
90
+ this.cursor = String(head_seq ?? '0');
91
+ }
92
+ async #follow(connection) {
93
+ while (!this.signal.aborted) {
94
+ for (const notice of this.retry.splice(0)) await this.#send(notice);
95
+ const result = await connection.call('events.wait', { after: this.cursor, timeout_ms: String(this.waitMs), limit: this.batch }, { signal: this.signal, timeoutMs: this.waitMs + 15000 });
96
+ this.waits++;
97
+ const events = result?.events || [];
98
+ if (result?.timed_out || !events.length) continue;
99
+ await this.#apply(connection, events);
100
+ this.cursor = result.next_after || events.at(-1).seq || this.cursor;
101
+ this.#changed();
102
+ }
103
+ }
104
+ async #apply(connection, events) {
105
+ const statuses = new Set(), inboxes = new Set(), plans = new Set(), criteria = new Set(); let jobs = false;
106
+ for (const event of events) {
107
+ this.events++;
108
+ const [kind, id] = splitAggregate(event.aggregate_id);
109
+ this.lastEvent = { seq: event.seq, type: event.type, aggregate_id: event.aggregate_id, actor: event.actor, ...(event.via ? { via: event.via } : {}) };
110
+ if (event.type === 'swarm_created' && this.#watched(id)) { this.known.add(id); statuses.add(id); }
111
+ else if (event.type === 'message_sent') { const message = event.payload?.message; if (message?.to?.kind === 'operator' && this.#watched(message.swarm)) { this.known.add(message.swarm); inboxes.add(message.swarm); } }
112
+ else if (kind === 'task') { const swarm = this.taskSwarm.get(id); if (swarm) statuses.add(swarm); else for (const item of this.known) statuses.add(item); }
113
+ // Roles y equipos: ocupación y equipos del panel.
114
+ else if (kind === 'role' || kind === 'team') { const swarm = this.roleSwarm.get(id); if (swarm) statuses.add(swarm); else for (const item of this.known) statuses.add(item); }
115
+ // Ack o vencimiento de una entrega del operador: deja de estar pendiente en el panel.
116
+ else if (kind === 'delivery' && id.endsWith('@operator')) { const swarm = this.messageSwarm.get(id.slice(0, id.lastIndexOf('.'))); if (swarm) inboxes.add(swarm); else for (const item of this.known) inboxes.add(item); }
117
+ else if (kind === 'plan') plans.add(id);
118
+ // Revisor (open-relay F7): los criterios de éxito son eventos del swarm.
119
+ else if (kind === 'swarm' && /^criteria_/.test(event.type || '') && this.known.has(id)) criteria.add(id);
120
+ else if (kind === 'job') jobs = true;
121
+ }
122
+ for (const swarm of statuses) await this.#status(connection, swarm);
123
+ for (const swarm of criteria) if (!statuses.has(swarm)) await this.#criteria(connection, swarm);
124
+ for (const id of plans) {
125
+ try {
126
+ const got = await connection.call('plan.get', { proposal_id: id }, { signal: this.signal }), swarm = got?.proposal?.swarm_id;
127
+ if (!this.#watched(swarm)) continue;
128
+ // Revisor (F6): su plan de corrección se aprueba solo (nunca queda pendiente): se avisa al aplicarse.
129
+ const correction = correctionNotice(swarm, got.proposal); if (correction) await this.#send(correction);
130
+ await this.#plans(connection, swarm);
33
131
  }
34
- })();
132
+ catch (error) { if (!error.rpc) throw error; this.#error(`plan:${id}`, error); }
133
+ }
134
+ for (const swarm of inboxes) await this.#inbox(connection, swarm);
135
+ if (jobs) await this.#jobs(connection);
35
136
  }
36
137
  /** Envía un aviso una sola vez por ref. Si el host no lo admite por ahora, queda para reintento. */
37
138
  async #send(notice) {
@@ -44,83 +145,95 @@ export class RelayWatcher {
44
145
  } else if (!this.retry.some(item => item.ref === notice.ref)) this.retry.push(notice);
45
146
  if (this.retry.length > 64) this.retry.shift();
46
147
  }
47
- async #discover() {
48
- for (const notice of this.retry.splice(0)) await this.#send(notice);
49
- const { swarms = [] } = await this.client.call('swarm.list', {}, { signal: this.signal });
50
- for (const swarm of swarms) {
51
- if (typeof swarm.id !== 'string' || this.workers.has(swarm.id) || this.filter && !this.filter.has(swarm.id)) continue;
52
- this.workers.set(swarm.id, [this.#follow(swarm.id, 'inbox', connection => this.#inbox(swarm.id, connection)), this.#follow(swarm.id, 'status', connection => this.#status(swarm.id, connection))]);
148
+ async #inbox(connection, swarm) {
149
+ let after = null; const pending = [];
150
+ for (let page = 0; page < 20; page++) {
151
+ const result = await connection.call('msg.inbox', { operator: true, swarm_id: swarm, pending_only: true, limit: PAGE, ...(after ? { after } : {}) }, { signal: this.signal });
152
+ for (const delivery of result.deliveries || []) if (delivery.state === 'pending') { await this.#delivery(connection, swarm, delivery); pending.push(delivery); }
153
+ if (!result.deliveries?.length || !result.next_after || result.next_after === after) break;
154
+ after = result.next_after;
53
155
  }
156
+ this.pending.set(swarm, pending.slice(-VIEW.pending).map(delivery => this.#pendingItem(swarm, delivery)));
54
157
  }
55
- /** Una conexión dedicada por bucle; reconecta con espera creciente y relee el token. */
56
- async #follow(swarm, name, body) {
57
- let backoff = 250;
58
- while (!this.signal.aborted) {
59
- let connection;
60
- try {
61
- connection = await this.client.dedicated(this.signal); this.connections.add(connection);
62
- backoff = 250; await body(connection);
63
- } catch (error) {
64
- if (this.signal.aborted) return;
65
- this.#error(`${name}:${swarm}`, error);
66
- if (error.rpc && !reconnectable(error) && error.code === 'INVALID_PARAMS') return; // swarm inexistente
67
- await sleep(backoff, this.signal); backoff = Math.min(this.maxBackoffMs, backoff * 2);
68
- } finally { connection?.close(); this.connections.delete(connection); }
69
- }
158
+ /** Una entrega pendiente para el panel: quién, qué, desde cuándo y el bloque decision, limpios. */
159
+ #pendingItem(swarm, delivery) {
160
+ const message = delivery.message || {}, structured = decisionBlock(message);
161
+ if (typeof message.id === 'string') { this.messageSwarm.set(message.id, swarm); if (this.messageSwarm.size > SEEN_LIMIT) this.messageSwarm.delete(this.messageSwarm.keys().next().value); }
162
+ return {
163
+ delivery_id: short(delivery.delivery_id), message_id: short(message.id), kind: short(message.kind || 'message', 40),
164
+ from: message.from?.role ? `role:${short(message.from.role)}` : short(message.from?.kind || 'operator', 40),
165
+ sent_at: stamp(message.sent_at), excerpt: message.pruned ? null : excerpt(message.body || '', VIEW.excerpt),
166
+ task: this.questionTask.get(message.id) || null,
167
+ decision: structured ? { question: structured.question, options: structured.options.map(item => item.label), recommended: structured.recommendation?.option || null, scope: structured.scope, reversible: structured.reversible } : null,
168
+ };
70
169
  }
71
- async #inbox(swarm, connection) {
72
- if (!this.cursors.has(swarm)) {
73
- // Primero las entregas pendientes que ya había (paginado), luego lo que llegue.
74
- let after = 0n;
75
- for (;;) {
76
- const page = await connection.call('msg.inbox', { operator: true, swarm_id: swarm, after: String(after), limit: PAGE }, { signal: this.signal });
77
- for (const delivery of page.deliveries || []) if (delivery.state === 'pending') await this.#delivery(swarm, delivery);
78
- const next = page.next_after ? BigInt(page.next_after) : after;
79
- if (!page.deliveries?.length || next === after) break;
80
- after = next;
81
- }
82
- this.cursors.set(swarm, String(after));
83
- }
84
- while (!this.signal.aborted) {
85
- const result = await connection.call('msg.wait', { operator: true, swarm_id: swarm, after: this.cursors.get(swarm), timeout_ms: String(this.waitMs) }, { signal: this.signal, timeoutMs: this.waitMs + 15000 });
86
- for (const delivery of result.deliveries || []) if (delivery.state === 'pending') await this.#delivery(swarm, delivery);
87
- if (result.next_after) this.cursors.set(swarm, result.next_after);
88
- }
170
+ #summary(swarm, data = {}) {
171
+ const old = this.info.get(swarm) || {};
172
+ this.info.set(swarm, { objective: data.objective !== undefined ? excerpt(data.objective, VIEW.objective) : old.objective ?? null, root_team: data.root_team !== undefined ? short(data.root_team) : old.root_team ?? null });
89
173
  }
90
- async #delivery(swarm, delivery) {
174
+ async #delivery(connection, swarm, delivery) {
91
175
  const ref = `delivery:${swarm}:${delivery.delivery_id}`;
92
176
  if (this.seen.has(ref)) return;
93
177
  let task = null;
94
178
  if (delivery.message?.kind === 'question' && delivery.message.from?.role) {
95
179
  // msg.ask bloquea la tarea en un commit posterior al envío: un margen breve.
96
- task = await this.#blockedBy(swarm, delivery.message.id);
97
- if (!task) { await sleep(this.questionGraceMs, this.signal); task = await this.#blockedBy(swarm, delivery.message.id); }
180
+ task = await this.#blockedBy(connection, swarm, delivery.message.id);
181
+ if (!task) { await sleep(this.questionGraceMs, this.signal); task = await this.#blockedBy(connection, swarm, delivery.message.id); }
98
182
  }
99
183
  await this.#send(deliveryNotice(delivery, { task }));
100
184
  }
101
- async #blockedBy(swarm, messageId) {
185
+ async #blockedBy(connection, swarm, messageId) {
102
186
  try {
103
- const status = await this.client.call('swarm.status', { swarm_id: swarm }, { signal: this.signal });
187
+ const status = await connection.call('swarm.status', { swarm_id: swarm }, { signal: this.signal });
104
188
  for (const task of (status.tasks || []).filter(item => item.state === 'blocked:question').slice(0, 20)) {
105
- const detail = await this.client.call('task.get', { task_id: task.id }, { signal: this.signal });
106
- if (detail.park?.reason?.kind === 'question' && detail.park.reason.message_id === messageId) return { id: task.id, state: task.state, title: task.title };
189
+ const detail = await connection.call('task.get', { task_id: task.id }, { signal: this.signal });
190
+ if (detail.park?.reason?.kind === 'question' && detail.park.reason.message_id === messageId) { this.questionTask.set(messageId, short(task.id)); return { id: task.id, state: task.state, title: task.title }; }
107
191
  }
108
- } catch (error) { this.#error(`question:${swarm}`, error); }
192
+ } catch (error) { if (!error.rpc) throw error; this.#error(`question:${swarm}`, error); }
109
193
  return null;
110
194
  }
111
- async #status(swarm, connection) {
112
- while (!this.signal.aborted) {
113
- const previous = this.digests.get(swarm);
114
- const status = await connection.call('swarm.status', { swarm_id: swarm, ...(previous ? { if_changed: previous, timeout_ms: String(this.waitMs) } : {}) }, { signal: this.signal, timeoutMs: this.waitMs + 15000 });
115
- if (previous && status.changed === false) continue;
116
- this.digests.set(swarm, status.digest);
117
- for (const task of status.tasks || []) { const alert = taskAlert(swarm, task); if (alert) await this.#send(alert); }
118
- const plans = await connection.call('plan.list', { swarm_id: swarm, status: 'pending' }, { signal: this.signal });
119
- for (const proposal of plans.proposals || []) if (proposal.status === 'pending') await this.#send(planNotice(swarm, proposal));
120
- }
195
+ /** Foto del swarm: estados de tareas (para status()) y alertas de infraestructura. */
196
+ async #status(connection, swarm) {
197
+ let status;
198
+ try { status = await connection.call('swarm.status', { swarm_id: swarm }, { signal: this.signal }); }
199
+ catch (error) { if (!error.rpc) throw error; this.#error(`status:${swarm}`, error); return; }
200
+ const states = new Map(), previous = this.taskStates.get(swarm), first = previous === undefined, at = this.now();
201
+ for (const task of status.tasks || []) { if (typeof task.id !== 'string') continue; states.set(task.id, task.state); this.taskSwarm.set(task.id, swarm); }
202
+ // Desde cuándo se ve cada estado: exacto si el cambio llegó por un evento; en la primera
203
+ // foto sólo se sabe que ya estaba así («al menos»).
204
+ for (const [task, state] of states) if (previous?.get(task) !== state || !this.since.has(`${swarm}:${task}`)) this.since.set(`${swarm}:${task}`, { since: at, since_exact: !first });
205
+ for (const task of previous?.keys() || []) if (!states.has(task)) this.since.delete(`${swarm}:${task}`);
206
+ this.taskStates.set(swarm, states);
207
+ if (status.swarm) this.#summary(swarm, status.swarm);
208
+ const roles = Array.isArray(status.roles) ? status.roles : [], tasks = Array.isArray(status.tasks) ? status.tasks : [];
209
+ for (const role of roles) if (typeof role?.id === 'string') this.roleSwarm.set(role.id, swarm);
210
+ for (const role of roles) if (typeof role?.team === 'string') this.roleSwarm.set(role.team, swarm);
211
+ const names = new Map(roles.map(role => [role?.id, role?.name]));
212
+ this.board.set(swarm, {
213
+ truncated: tasks.length > VIEW.tasks || roles.length > VIEW.roles,
214
+ tasks: tasks.filter(task => typeof task?.id === 'string').slice(0, VIEW.tasks).map(task => ({ id: short(task.id), title: task.title ? excerpt(task.title, VIEW.title) : null, state: short(task.state, 40), team: short(task.team), role: short(task.required_role), role_name: names.get(task.required_role) ? short(names.get(task.required_role), 60) : null, attempts_used: Number(task.attempts_used) || 0, counted_attempts: task.counted_attempts == null ? Number(task.attempts_used) || 0 : Number(task.counted_attempts) || 0, max_attempts: Number(task.max_attempts) || 0, ...(task.infra?.exhausted ? { infra_exhausted: true } : {}), ...(task.wake_cap === true ? { wake_cap: true } : {}), ...(Number(task.review_rounds) > 0 ? { review_rounds: Number(task.review_rounds) } : {}), ...(['task', 'integration', 'final'].includes(task.review_kind) ? { review_kind: task.review_kind } : {}) })),
215
+ roles: roles.slice(0, VIEW.roles).map(role => ({ id: short(role.id), name: short(role.name || role.id, 60), team: short(role.team), occupied: Boolean(role.occupant), load: Number(role.load) || 0, capacity: Number(role.capacity) || 0, model: role.model ? short(role.model, 60) : null })),
216
+ });
217
+ for (const task of status.tasks || []) { const alert = taskAlert(swarm, task) || reviewAlert(swarm, task); if (alert) await this.#send(alert); }
218
+ // Revisor (open-relay F7): un swarm con revisor tiene criterios de éxito que acepta el humano.
219
+ if (tasks.some(task => /^t-review/.test(task?.id || ''))) await this.#criteria(connection, swarm);
220
+ }
221
+ async #criteria(connection, swarm) {
222
+ try {
223
+ const got = await connection.call('criteria.get', { swarm_id: swarm }, { signal: this.signal });
224
+ this.criteria.set(swarm, { status: short(got?.status || 'none', 20), version: Number(got?.current?.version) || 0, digest: /^[a-f0-9]{64}$/.test(String(got?.current?.digest || '')) ? got.current.digest : null, rounds: Number(got?.rounds) || 0, renegotiating: Boolean(got?.accepted), in_force: Number(got?.accepted?.version) || 0 });
225
+ } catch (error) { if (!error.rpc) throw error; this.#error(`criteria:${swarm}`, error); }
226
+ }
227
+ async #plans(connection, swarm) {
228
+ const plans = await connection.call('plan.list', { swarm_id: swarm, status: 'pending' }, { signal: this.signal });
229
+ // Revisor (open-relay F3/F8): un plan que espera al revisor todavía no es decidible por el
230
+ // humano (relayd respondería REVIEW_PENDING): no hay aviso ni tarjeta hasta que lo revise.
231
+ const pending = (plans.proposals || []).filter(proposal => proposal.status === 'pending' && proposal.review?.status !== 'awaiting');
232
+ for (const proposal of pending) await this.#send(planNotice(swarm, proposal));
233
+ this.proposals.set(swarm, pending.slice(0, VIEW.plans).map(proposal => ({ proposal_id: short(proposal.proposal_id), proposer_role: short(proposal.proposer_role), parent_task: proposal.parent_task ? short(proposal.parent_task) : null, summary: excerpt(proposal.summary || '', VIEW.excerpt), proposed_at: stamp(proposal.proposed_at), tasks: Number(proposal.tasks) || 0, roles: Number(proposal.roles) || 0 })));
121
234
  }
122
- async #jobs() {
123
- const result = await this.client.call('job.list', {}, { signal: this.signal });
235
+ async #jobs(connection) {
236
+ const result = await connection.call('job.list', {}, { signal: this.signal });
124
237
  if (!result.available) return;
125
238
  for (const job of result.jobs || []) { const alert = jobAlert(job); if (alert) await this.#send(alert); }
126
239
  }
package/src/plugins.mjs CHANGED
@@ -33,6 +33,7 @@ export class PluginRuntime {
33
33
  #services = new Map();
34
34
  #queue = Promise.resolve();
35
35
  #closing = false;
36
+ #serviceWatchers = new Set();
36
37
  // notify(notice): el host encola avisos de plugins (plugin-notices.mjs); sin él, ctx.notify responde unavailable.
37
38
  constructor({ cwd, house, services = {}, configPath, onEvent = () => {}, notify }) {
38
39
  Object.assign(this, { cwd, house, onEvent, notify });
@@ -41,7 +42,16 @@ export class PluginRuntime {
41
42
  }
42
43
  async #notify(notice) { if (typeof this.notify !== 'function') return { status: 'unavailable' }; try { return await this.notify(notice); } catch (error) { return { status: 'failed', error: String(error.message).slice(0, 200) }; } }
43
44
  #exclusive(task) { const next = this.#queue.catch(() => {}).then(task); this.#queue = next; return next; }
44
- async #event(event) { try { await this.onEvent({ type: 'plugin_status', ...event }); } catch {} }
45
+ async #event(event) { try { await this.onEvent({ type: 'plugin_status', ...event }); } catch {} this.#servicesChanged(); }
46
+ #servicesChanged() { for (const listener of [...this.#serviceWatchers]) try { listener(); } catch {} }
47
+ /**
48
+ * Lectura del HOST de un servicio publicado (ctx.provide), p. ej. para un panel de la TUI.
49
+ * Devuelve { owner, value } o null; el host debe comprobar el dueño y tratar los datos
50
+ * como no confiables. No concede nada al plugin ni al modelo.
51
+ */
52
+ service(name) { const entry = this.#services.get(name); return entry ? Object.freeze({ owner: entry.owner, value: entry.value }) : null; }
53
+ /** Avisa (sin argumentos) cuando cambia el estado de los plugins o sus servicios. Devuelve la baja. */
54
+ watchServices(listener) { if (typeof listener !== 'function') throw new TypeError('watchServices requires a function.'); this.#serviceWatchers.add(listener); return () => this.#serviceWatchers.delete(listener); }
45
55
  #definition(definition) {
46
56
  if (!definition || !idPattern.test(definition.id) || definition.id.length > 24 || typeof definition.version !== 'string' || !definition.version || typeof definition.activate !== 'function') throw new Error('Manifiesto de plugin inválido: requiere id, version y activate.');
47
57
  const names = list => { if (!Array.isArray(list) || list.length > 32 || list.some(name => typeof name !== 'string' || !name || name.length > 128) || new Set(list).size !== list.length) throw new Error('requires/provides de plugin inválidos.'); return [...list]; };
@@ -309,6 +319,7 @@ export class PluginRuntime {
309
319
  this.house.registry.replace(scopes.flatMap(scope => [...scope.operations.keys()]), []);
310
320
  for (const scope of scopes) { this.#known.get(scope.id).state = 'disabled'; try { await this.#retire(scope); } catch (error) { errors.push(error); } }
311
321
  for (const [name, service] of this.#services) if (service.owner !== 'host') this.#services.delete(name);
322
+ this.#servicesChanged(); this.#serviceWatchers.clear();
312
323
  if (errors.length) throw new AggregateError(errors, 'Falló limpieza de plugins.');
313
324
  });
314
325
  }
@@ -1,5 +1,5 @@
1
1
  import { constants, readFileSync, readlinkSync } from 'node:fs';
2
- import { open, lstat, unlink } from 'node:fs/promises';
2
+ import { open, lstat, unlink, link } from 'node:fs/promises';
3
3
  import { hostname } from 'node:os';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
@@ -18,6 +18,7 @@ export function processDead(owner) {
18
18
  const current=start(owner.pid);
19
19
  return Boolean(owner.start && current && owner.start!==current);
20
20
  }
21
+ const STALE_EMPTY_MS = 30000;
21
22
  const fail = (message,code='SESSION_IN_USE') => Object.assign(new Error(message),{code});
22
23
  async function inspect(name,fault=async()=>{}) {
23
24
  const named=await lstat(name,{bigint:true});if(named.isSymbolicLink()||!named.isFile())throw fail('Unsafe lock type; no lock was removed.','LOCK_UNSAFE');
@@ -34,6 +35,9 @@ async function inspect(name,fault=async()=>{}) {
34
35
  if(!await same(name,info))throw fail('Lock changed during private inspection; no owner was assumed.','LOCK_CHANGED');
35
36
  const bytes=Buffer.alloc(4097),{bytesRead}=await file.read(bytes,0,bytes.length,0);if(bytesRead>4096)throw fail('Lock exceeds its owner-record bound.','LOCK_UNSAFE');
36
37
  const text=bytes.subarray(0,bytesRead).toString('utf8').trim();let owner;
38
+ // An empty lock is a writer that died between creating the file and writing its owner
39
+ // (older Comal created first and wrote later). Fresh, it may still be mid-write; stale, nobody owns it.
40
+ if(!text && Date.now()-Number(info.mtimeMs)>STALE_EMPTY_MS){if(!await same(name,info))throw fail('Lock changed while reading its owner; no owner was assumed.','LOCK_CHANGED');return {info,owner:{empty:true}};}
37
41
  try{owner=JSON.parse(text);}catch{throw fail('Lock sin propietario verificable; no se retiró.','LOCK_UNKNOWN');}
38
42
  if(typeof owner==='number')owner={pid:owner}; // Legacy PID: only ESRCH proves death.
39
43
  if(!owner || !Number.isSafeInteger(owner.pid) || owner.pid<=0)throw fail('Lock sin propietario verificable; no se retiró.','LOCK_UNKNOWN');
@@ -43,39 +47,43 @@ async function inspect(name,fault=async()=>{}) {
43
47
  }
44
48
  async function same(name,info){try{const current=await lstat(name,{bigint:true});return !current.isSymbolicLink() && current.ino===info.ino && current.dev===info.dev;}catch(e){if(e.code==='ENOENT')return false;throw e;}}
45
49
 
50
+ // Claim `name` with its owner record already written: the record goes to a private temporary
51
+ // file that is then hard-linked into place, so the lock path never exists empty. null if held.
52
+ async function claim(name,owner){
53
+ const temp=`${name}.${owner.token}.tmp`,file=await open(temp,'wx',0o600);
54
+ try{await file.writeFile(JSON.stringify(owner));await file.sync();await link(temp,name);}
55
+ catch(error){await file.close();await unlink(temp).catch(()=>{});if(error.code==='EEXIST')return null;throw error;}
56
+ await unlink(temp);return file;
57
+ }
58
+
46
59
  // Only this exclusion gate may reclaim a dead owner. It is never automatically
47
60
  // reclaimed itself: two reapers must not unlink a replacement lock concurrently.
48
61
  export async function acquireProcessLock(name,{waitMs=0,validate=async()=>{},resource,fault=async()=>{}}={}) {
49
62
  const until=Date.now()+waitMs;
50
63
  for(;;){
51
- let file, recovered;
52
- try{file=await open(name,'wx',0o600);}
53
- catch(error){
54
- if(error.code!=='EEXIST')throw error;
64
+ let recovered;const owner={...processIdentity,token:randomUUID(),...(resource?{resource:structuredClone(resource)}:{})};
65
+ let file=await claim(name,owner);
66
+ if(!file){
55
67
  let old;
56
68
  try{old=await inspect(name,fault);}catch(e){if(e.code==='ENOENT')continue;if(['LOCK_UNKNOWN','LOCK_CHANGED'].includes(e.code)&&Date.now()<until){await delay(10);continue;}if(e.code==='LOCK_CHANGED')throw fail('The lock changed during observation; no owner was assumed or removed.');throw e;}
57
- if(processDead(old.owner)){
69
+ if(old.owner.empty || processDead(old.owner)){
58
70
  let gate;
59
71
  try{gate=await open(`${name}.reclaim`,'wx',0o600);}
60
72
  catch(e){if(e.code!=='EEXIST')throw e;if(Date.now()>=until)throw fail('Recuperación de lock en uso o interrumpida; no se retiró.','LOCK_RECOVERY_BUSY');}
61
73
  if(gate)try{
62
74
  await gate.writeFile(JSON.stringify(processIdentity));await gate.sync();
63
75
  const current=await inspect(name,fault).catch(e=>{if(['ENOENT','LOCK_CHANGED'].includes(e.code))return null;throw e;});
64
- if(current && processDead(current.owner) && await same(name,current.info)){
76
+ if(current && (current.owner.empty || processDead(current.owner)) && await same(name,current.info)){
65
77
  await validate(current.owner);
66
78
  if(await same(name,current.info)){
67
79
  await unlink(name);
68
- try{file=await open(name,'wx',0o600);recovered={path:name,owner:current.owner};}
69
- catch(e){if(e.code!=='EEXIST')throw e;}
80
+ file=await claim(name,owner);if(file)recovered={path:name,owner:current.owner};
70
81
  }
71
82
  }
72
83
  } finally{const info=await gate.stat({bigint:true});if(await same(`${name}.reclaim`,info))await unlink(`${name}.reclaim`);await gate.close();}
73
84
  }
74
85
  if(!file){if(Date.now()>=until)throw fail('Sesión en uso o propietario no recuperable; no se retiró el lock.');await delay(10);continue;}
75
86
  }
76
- const owner={...processIdentity,token:randomUUID(),...(resource?{resource:structuredClone(resource)}:{})};
77
- try{await file.writeFile(JSON.stringify(owner));await file.sync();}
78
- catch(error){const info=await file.stat({bigint:true});if(await same(name,info))await unlink(name);await file.close();throw error;}
79
87
  return {file,recovered,release:async()=>{const info=await file.stat({bigint:true});try{if(await same(name,info))await unlink(name);}finally{await file.close();}}};
80
88
  }
81
89
  }
@@ -13,14 +13,17 @@ export const AUTH_PROVIDERS = Object.freeze([
13
13
  { id: 'grok', label: 'Grok / xAI API', method: 'api_key', subscription: false, protocol: 'openai', baseURL: 'https://api.x.ai/v1', apiKeyEnv: 'XAI_API_KEY', documentation: 'https://docs.x.ai/overview' },
14
14
  // Ollama Cloud expone una API compatible con OpenAI en /v1 y /api/show para
15
15
  // descubrir la ventana. La llave sólo viaja en el header hacia ese origen.
16
- { id: 'ollama', label: 'Ollama Cloud', method: 'api_key', subscription: false, protocol: 'openai', baseURL: 'https://ollama.com/', apiKeyEnv: 'OLLAMA_API_KEY', documentation: 'https://docs.ollama.com/cloud' }
16
+ { id: 'ollama', label: 'Ollama Cloud', method: 'api_key', subscription: false, protocol: 'openai', baseURL: 'https://ollama.com/', apiKeyEnv: 'OLLAMA_API_KEY', documentation: 'https://docs.ollama.com/cloud' },
17
+ // Anthropic API (Claude): llave de la Console, facturación por API. /v1/models lista
18
+ // los modelos y /v1/models/{id} anuncia ventana, salida máxima y niveles de effort.
19
+ { id: 'anthropic', label: 'Anthropic API (Claude)', method: 'api_key', subscription: false, protocol: 'anthropic', baseURL: 'https://api.anthropic.com', model: 'claude-sonnet-5-5', apiKeyEnv: 'ANTHROPIC_API_KEY', documentation: 'https://docs.claude.com/en/api/overview' }
17
20
  ].map(Object.freeze));
18
- const ALIASES = Object.freeze({ grokia: 'grok', xai: 'grok', chatgpt: 'codex', 'kimi-code': 'kimi', 'ollama-cloud': 'ollama' });
21
+ const ALIASES = Object.freeze({ grokia: 'grok', xai: 'grok', chatgpt: 'codex', 'kimi-code': 'kimi', 'ollama-cloud': 'ollama', claude: 'anthropic' });
19
22
  const failure = (message, code = 'PROVIDER_AUTH_FAILED') => Object.assign(new Error(message), { code });
20
23
  function definition(name) {
21
24
  const id = String(name || '').toLowerCase();
22
25
  const found = AUTH_PROVIDERS.find(item => item.id === (Object.hasOwn(ALIASES, id) ? ALIASES[id] : id));
23
- if (!found) throw failure('Unknown login provider: codex, kimi, minimax, grok or ollama.', 'PROVIDER_AUTH_UNKNOWN');
26
+ if (!found) throw failure('Unknown login provider: codex, kimi, minimax, grok, ollama or anthropic.', 'PROVIDER_AUTH_UNKNOWN');
24
27
  return found;
25
28
  }
26
29
  export function authProviderDefinition(name) { return definition(name); }
@@ -1,3 +1,4 @@
1
+ import { registerSecretEnv } from './child-env.mjs';
1
2
  import { readFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { OpenAIProvider, DEFAULT_LOCAL_URL } from './openai-provider.mjs';
@@ -5,11 +6,12 @@ import { AnthropicProvider } from './provider.mjs';
5
6
  import { GeminiProvider } from './gemini-provider.mjs';
6
7
  import { authProviderDefaults } from './provider-auth.mjs';
7
8
  import { normalizeEffort } from './model-controls.mjs';
9
+ import { normalizeFallbackSetting } from './provider.mjs';
8
10
  import {normalizeToolSchemaCapabilities} from './tool-schema.mjs';
9
11
 
10
12
  export const PROVIDER_CAPABILITIES = Object.freeze(['text', 'tools', 'thinking', 'vision', 'video', 'web_search']);
11
13
  const PROTOCOLS = ['openai', 'anthropic', 'gemini'];
12
- const KEYS = new Set(['name', 'provider', 'protocol', 'model', 'baseURL', 'apiKeyEnv', 'capabilities', 'capabilitiesConfigured', 'description', 'effort', 'authProvider','toolSchema']);
14
+ const KEYS = new Set(['name', 'provider', 'protocol', 'model', 'baseURL', 'apiKeyEnv', 'capabilities', 'capabilitiesConfigured', 'description', 'effort', 'authProvider','toolSchema', 'maxTokens', 'contextWindow', 'fallback']);
13
15
  const error = message => Object.assign(new Error(message), { code: 'PROVIDER_PROFILE_INVALID' });
14
16
 
15
17
  export function normalizeCapabilities(value = {}, { protocol = 'openai' } = {}) {
@@ -31,6 +33,7 @@ export function normalizeProviderProfile(input, name = input?.name) {
31
33
  if (input.name && input.name !== name) throw error('Conflicting profile name.');
32
34
  if (input.authProvider !== undefined && !['codex', 'kimi', 'minimax', 'grok'].includes(input.authProvider)) throw error('Invalid authProvider.');
33
35
  if (input.effort !== undefined) normalizeEffort(input.effort);
36
+ if (input.fallback !== undefined) normalizeFallbackSetting(input.fallback);
34
37
  const toolSchema=input.toolSchema===undefined?undefined:normalizeToolSchemaCapabilities(input.toolSchema);
35
38
  const access=input.authProvider?authProviderDefaults(input.authProvider):null;
36
39
  const configuredCapabilities = input.capabilitiesConfigured || input.capabilities || {};
@@ -47,11 +50,17 @@ export function normalizeProviderProfile(input, name = input?.name) {
47
50
  }
48
51
  if (input.apiKeyEnv !== undefined && (typeof input.apiKeyEnv !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(input.apiKeyEnv))) throw error('apiKeyEnv must name an environment variable.');
49
52
  if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 1024)) throw error('Invalid description.');
53
+ // Output and window budgets are startup defaults for this endpoint; --max-tokens,
54
+ // --context-window, their variables and connection.json still win.
55
+ if (input.maxTokens !== undefined && (!Number.isSafeInteger(input.maxTokens) || input.maxTokens < 64 || input.maxTokens > 1000000)) throw error('maxTokens must be an integer between 64 and 1000000.');
56
+ if (input.contextWindow !== undefined && (!Number.isSafeInteger(input.contextWindow) || input.contextWindow < 4096 || input.contextWindow > 2000000)) throw error('contextWindow must be an integer between 4096 and 2000000.');
57
+ if (input.maxTokens !== undefined && input.contextWindow !== undefined && input.contextWindow < input.maxTokens * 2) throw error('contextWindow must be at least twice maxTokens.');
50
58
  return Object.freeze({ name, provider: protocol, protocol, ...((input.model || access?.model) ? { model: input.model || access.model } : {}),
51
59
  baseURL: input.baseURL || access?.baseURL || ({ openai: DEFAULT_LOCAL_URL, anthropic: 'https://api.anthropic.com', gemini: 'https://generativelanguage.googleapis.com/v1beta' })[protocol],
52
60
  apiKeyEnv: input.apiKeyEnv || ({ openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', gemini: 'GEMINI_API_KEY' })[protocol],
53
61
  capabilities: normalizeCapabilities(input.capabilities, { protocol }), capabilitiesConfigured: Object.freeze({ ...configuredCapabilities }),...(toolSchema?{toolSchema:Object.freeze(toolSchema)}:{}),
54
- ...(input.effort !== undefined ? { effort: input.effort } : {}), ...(input.authProvider ? { authProvider: input.authProvider } : {}), ...(input.description ? { description: input.description } : {}) });
62
+ ...(input.effort !== undefined ? { effort: input.effort } : {}), ...(input.authProvider ? { authProvider: input.authProvider } : {}), ...(input.description ? { description: input.description } : {}),
63
+ ...(input.maxTokens !== undefined ? { maxTokens: input.maxTokens } : {}), ...(input.contextWindow !== undefined ? { contextWindow: input.contextWindow } : {}), ...(input.fallback !== undefined ? { fallback: input.fallback } : {}) });
55
64
  }
56
65
 
57
66
  // Configuration is data only. Secrets are resolved in createProfileProvider and
@@ -92,11 +101,12 @@ export function selectProfile(registry, name) {
92
101
 
93
102
  export function createProfileProvider(profile, { env = process.env, ...overrides } = {}) {
94
103
  profile = normalizeProviderProfile(profile);
104
+ registerSecretEnv(profile.apiKeyEnv);
95
105
  const capabilities = { ...profile.capabilities };
96
106
  if (overrides.vision === 'on') capabilities.vision = true;
97
107
  else if (overrides.vision === 'off') capabilities.vision = false;
98
108
  const options = { ...overrides, apiKey: overrides.apiKey ?? env[profile.apiKeyEnv], model: overrides.model || profile.model, baseURL: overrides.baseURL || profile.baseURL,
99
- capabilities, capabilitiesConfigured: profile.capabilitiesConfigured, effort: overrides.effort ?? profile.effort ?? 'auto', vision: overrides.vision || (Object.hasOwn(profile.capabilitiesConfigured, 'vision') ? capabilities.vision ? 'on' : 'off' : 'auto') };
109
+ capabilities, capabilitiesConfigured: profile.capabilitiesConfigured, effort: overrides.effort ?? profile.effort ?? 'auto', fallback: overrides.fallback ?? profile.fallback ?? 'default', vision: overrides.vision || (Object.hasOwn(profile.capabilitiesConfigured, 'vision') ? capabilities.vision ? 'on' : 'off' : 'auto') };
100
110
  if (profile.protocol !== 'openai' && !options.apiKey) throw new Error(`Set the profile apiKeyEnv variable ${profile.apiKeyEnv}.`);
101
111
  const provider = profile.protocol === 'gemini' ? new GeminiProvider(options) : profile.protocol === 'anthropic' ? new AnthropicProvider(options) : new OpenAIProvider(options);
102
112
  const complete = provider.complete.bind(provider);