@nac3/forge-cli 1.23.2 → 1.23.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/chat/server.d.ts.map +1 -1
  2. package/dist/chat/server.js +108 -27
  3. package/dist/chat/server.js.map +1 -1
  4. package/dist/core/error_catalog.d.ts +27 -0
  5. package/dist/core/error_catalog.d.ts.map +1 -1
  6. package/dist/core/error_catalog.js +55 -0
  7. package/dist/core/error_catalog.js.map +1 -1
  8. package/dist/core/error_formatter.d.ts +12 -0
  9. package/dist/core/error_formatter.d.ts.map +1 -1
  10. package/dist/core/error_formatter.js +19 -0
  11. package/dist/core/error_formatter.js.map +1 -1
  12. package/dist/relay/command_consumer.d.ts +10 -0
  13. package/dist/relay/command_consumer.d.ts.map +1 -1
  14. package/dist/relay/command_consumer.js +10 -1
  15. package/dist/relay/command_consumer.js.map +1 -1
  16. package/dist/relay/in_flight_turn.d.ts +52 -11
  17. package/dist/relay/in_flight_turn.d.ts.map +1 -1
  18. package/dist/relay/in_flight_turn.js +98 -30
  19. package/dist/relay/in_flight_turn.js.map +1 -1
  20. package/dist/relay/loopback_turn.d.ts +82 -0
  21. package/dist/relay/loopback_turn.d.ts.map +1 -0
  22. package/dist/relay/loopback_turn.js +171 -0
  23. package/dist/relay/loopback_turn.js.map +1 -0
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/package.json +1 -1
  27. package/src/i18n/catalogs/ar.json +7 -0
  28. package/src/i18n/catalogs/de.json +7 -0
  29. package/src/i18n/catalogs/en.json +7 -0
  30. package/src/i18n/catalogs/es.json +7 -0
  31. package/src/i18n/catalogs/fr.json +7 -0
  32. package/src/i18n/catalogs/hi.json +7 -0
  33. package/src/i18n/catalogs/it.json +7 -0
  34. package/src/i18n/catalogs/ja.json +7 -0
  35. package/src/i18n/catalogs/pt.json +7 -0
  36. package/src/i18n/catalogs/zh.json +7 -0
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/chat/server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/chat/server.ts"],"names":[],"mappings":"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"}
@@ -58,6 +58,9 @@ import { getInstanceIdentity } from '../relay/instance_identity.js';
58
58
  import { isMobilePaired, relayGate } from '../core/mobile_pairing_state.js';
59
59
  import { relayPoll } from '../relay/relay_client.js';
60
60
  import { startCommandConsumer } from '../relay/command_consumer.js';
61
+ import { postChatTurn } from '../relay/loopback_turn.js';
62
+ import { forgeErrorOf } from '../core/error_catalog.js';
63
+ import { formatForgeErrorText } from '../core/error_formatter.js';
61
64
  import { getSessionStore, DEFAULT_SESSION_ID } from './session_store.js';
62
65
  import { getSessionProject, setSessionProject } from './session_projects.js';
63
66
  import { beginTurn, endTurn, interruptActiveTurn, interruptAllTurns, isTurnActive, activeTurnId, activeTurns, } from './turn_control.js';
@@ -1163,10 +1166,15 @@ function startDesktopCommandConsumer(port, coord = {}) {
1163
1166
  void pub.flush(); /* push the reply promptly, do not wait the window */
1164
1167
  };
1165
1168
  const runTurn = async (text, sessionId, projectSlug) => {
1166
- const resp = await fetch('http://127.0.0.1:' + port + '/api/chat', {
1167
- method: 'POST',
1168
- headers: { 'content-type': 'application/json' },
1169
- body: JSON.stringify({
1169
+ /* El POST va por node:http y no por fetch, y con un presupuesto de turno
1170
+ * y no de llamada corta: las cabeceras de esta respuesta recien salen
1171
+ * cuando el turno TERMINO, y el limite que fetch hereda de undici son
1172
+ * cinco minutos medidos, asi que todo turno mas largo moria con "fetch
1173
+ * failed" aunque el escritorio lo terminara bien (PND-234). El porque
1174
+ * completo, con la medicion, esta en src/relay/loopback_turn.ts. */
1175
+ const resp = await postChatTurn({
1176
+ port,
1177
+ payload: {
1170
1178
  messages: [{ role: 'user', content: text }],
1171
1179
  ...(sessionId ? { session_id: sessionId } : {}),
1172
1180
  /* Bind the turn to the project the phone composed it against
@@ -1178,13 +1186,33 @@ function startDesktopCommandConsumer(port, coord = {}) {
1178
1186
  * the unified-thread ones to the panel (the laptop sees what was
1179
1187
  * asked from the phone). */
1180
1188
  origin: 'mobile',
1181
- }),
1189
+ },
1182
1190
  });
1183
- const j = await resp.json();
1191
+ /* Un cuerpo que no es JSON no se puede reportar como "turn failed": eso
1192
+ * esconderia un 500 con una pagina de error adentro. Se dice que la
1193
+ * respuesta no se pudo leer y se deja el status, que es lo que permite
1194
+ * diagnosticarlo. */
1195
+ let j;
1196
+ try {
1197
+ j = JSON.parse(resp.body);
1198
+ }
1199
+ catch {
1200
+ throw new Error('respuesta no interpretable del servidor de chat (HTTP '
1201
+ + resp.status + ')');
1202
+ }
1184
1203
  if (!j || j.ok === false)
1185
1204
  throw new Error(j?.error || 'turn failed');
1186
1205
  return String(j.message?.text ?? '');
1187
1206
  };
1207
+ /* Como se le cuenta al telefono un turno que fallo. El consumidor no
1208
+ * importa el catalogo de i18n a proposito (no toca el disco, para poder
1209
+ * probarse sin red ni servidor), asi que el renderizado se inyecta desde
1210
+ * aca, que ya lo tiene cargado. Devolver null significa "no tengo nada
1211
+ * mejor": el consumidor cae a su texto de siempre. */
1212
+ const describeError = (e) => {
1213
+ const forge = forgeErrorOf(e);
1214
+ return forge ? formatForgeErrorText(forge) : null;
1215
+ };
1188
1216
  const onControl = async (action, args) => {
1189
1217
  /* Control commands from the phone (tasks #33, #29) go through the
1190
1218
  * same loopback the chat turns use -- no extra coupling. */
@@ -1313,7 +1341,7 @@ function startDesktopCommandConsumer(port, coord = {}) {
1313
1341
  * slug names a peer's project (board.activeProject races between instances),
1314
1342
  * so no instance ran it and the phone hung on "working". */
1315
1343
  accept: (c) => !c.control && (!!c.targetInstance || ownsChatCommand(c.projectSlug)),
1316
- pollCommands, runTurn, reply,
1344
+ pollCommands, runTurn, reply, describeError,
1317
1345
  ackImmediate: true, /* confirm receipt before the turn runs */
1318
1346
  onError: () => { },
1319
1347
  });
@@ -1321,7 +1349,7 @@ function startDesktopCommandConsumer(port, coord = {}) {
1321
1349
  enabled: relayEnabled, intervalMs: 1200,
1322
1350
  instanceId: selfInstanceId,
1323
1351
  accept: (c) => !!c.control,
1324
- pollCommands, runTurn, onControl, reply,
1352
+ pollCommands, runTurn, onControl, reply, describeError,
1325
1353
  onError: () => { },
1326
1354
  });
1327
1355
  /* Item 2 (cross-machine): advertise this instance's presence so the phone
@@ -1571,11 +1599,25 @@ async function handleForgeTaskDelete(req, res, ctx) {
1571
1599
  * is thinking (Pablo 2026-09-02). Replaces the per-tool activity stream, which
1572
1600
  * was the bulk of the relay's Cloudflare appends and only calmed anxiety. The
1573
1601
  * pizarron (board) still refreshes -- THAT is the signal that adds value. */
1574
- let _relayHeartbeat = null;
1575
- function stopRelayHeartbeat() {
1576
- if (_relayHeartbeat) {
1577
- clearInterval(_relayHeartbeat);
1578
- _relayHeartbeat = null;
1602
+ /* UN latido POR TURNO, no uno por proceso (PND-237). Era un solo timer de
1603
+ * modulo y `startRelayHeartbeat()` arrancaba con un `stop()`, asi que con
1604
+ * turnos solapados -- que es lo normal en un panel -- el arranque de B mataba
1605
+ * el latido de A, y el cierre de A mataba el de B. Un turno sin latido deja de
1606
+ * dar senal de vida, y como la senal es lo unico que lo sostiene en el
1607
+ * registro, el anuncio quedaba a merced de cual timer sobrevivio. */
1608
+ const _relayHeartbeats = new Map();
1609
+ /** Para el latido de `id`; sin `id`, TODOS (apagado del servidor). */
1610
+ function stopRelayHeartbeat(id) {
1611
+ if (!id) {
1612
+ for (const t of _relayHeartbeats.values())
1613
+ clearInterval(t);
1614
+ _relayHeartbeats.clear();
1615
+ return;
1616
+ }
1617
+ const t = _relayHeartbeats.get(id);
1618
+ if (t) {
1619
+ clearInterval(t);
1620
+ _relayHeartbeats.delete(id);
1579
1621
  }
1580
1622
  }
1581
1623
  /**
@@ -1608,19 +1650,28 @@ function turnoVivoSinAnunciar() {
1608
1650
  return null;
1609
1651
  }
1610
1652
  }
1611
- function startRelayHeartbeat() {
1612
- stopRelayHeartbeat();
1613
- _relayHeartbeat = setInterval(() => {
1653
+ /**
1654
+ * Arranca el latido DE ESTE turno. El id no es decorativo: es lo que ata la
1655
+ * senal de vida a quien la produce (PND-237). Sin turno identificado no hay
1656
+ * latido -- un pulso anonimo es justo el que mantenia vivo el anuncio de un
1657
+ * turno ajeno.
1658
+ */
1659
+ function startRelayHeartbeat(turnId) {
1660
+ if (!turnId)
1661
+ return;
1662
+ stopRelayHeartbeat(turnId);
1663
+ const t = setInterval(() => {
1614
1664
  /* SENAL DE VIDA del turno, antes que cualquier otra cosa. Este timer corre
1615
- * SOLO entre turn_start y turn_complete, asi que su pulso ES la prueba de
1616
- * que el turno sigue vivo -- y es lo que hace que un turno de tres horas se
1617
- * siga anunciando. Va primero y fuera del try del publisher: que no haya
1618
- * telefono pareado no significa que el turno este muerto. */
1619
- touchInFlightTurn();
1665
+ * SOLO entre el turn_start y el turn_complete DE ESTE turno, asi que su
1666
+ * pulso ES la prueba de que el turno sigue vivo -- y es lo que hace que un
1667
+ * turno de tres horas se siga anunciando. Va primero y fuera del try del
1668
+ * publisher: que no haya telefono pareado no significa que el turno este
1669
+ * muerto. */
1670
+ touchInFlightTurn(turnId);
1620
1671
  try {
1621
1672
  const pub = getRelayPublisher();
1622
1673
  if (!pub) {
1623
- stopRelayHeartbeat();
1674
+ stopRelayHeartbeat(turnId);
1624
1675
  return;
1625
1676
  }
1626
1677
  pub.activity('remote running...', { kind: 'heartbeat' });
@@ -1628,8 +1679,9 @@ function startRelayHeartbeat() {
1628
1679
  }
1629
1680
  catch { /* best-effort */ }
1630
1681
  }, PULSO_DE_PROGRESO_MS);
1631
- if (typeof _relayHeartbeat.unref === 'function')
1632
- _relayHeartbeat.unref();
1682
+ _relayHeartbeats.set(turnId, t);
1683
+ if (typeof t.unref === 'function')
1684
+ t.unref();
1633
1685
  }
1634
1686
  function relayForward(event, data) {
1635
1687
  const pub = getRelayPublisher();
@@ -1637,19 +1689,24 @@ function relayForward(event, data) {
1637
1689
  return;
1638
1690
  try {
1639
1691
  const d = (data && typeof data === 'object') ? data : {};
1692
+ /* El id del turno viaja en el evento (PND-237): el latido y el registro de
1693
+ * turnos vivos son POR TURNO, asi que hay que saber de cual se habla. Un
1694
+ * evento sin id no toca ningun timer -- antes que matar el latido de otro
1695
+ * turno, preferimos dejar que este venza por silencio. */
1696
+ const tid = typeof d.turn_id === 'string' ? d.turn_id : '';
1640
1697
  /* tool_round_start: intentionally NO relay activity anymore -- the granular
1641
1698
  * per-tool feed was the append-heavy part; the phone now gets a heartbeat. */
1642
1699
  if (event === 'turn_start') {
1643
1700
  pub.status('working', { tier: d.tier, provider: d.provider, model: d.model });
1644
1701
  pub.activity('remote running...', { kind: 'heartbeat' }); /* first line now; then every 60s */
1645
- startRelayHeartbeat();
1702
+ startRelayHeartbeat(tid);
1646
1703
  /* El turno en vuelo viaja DENTRO del latido (in_flight_turn.ts): hay que
1647
1704
  * publicarlo ya, sin esperar el tick, o el telefono no sabe que hay algo
1648
1705
  * corriendo hasta la proxima evaluacion. */
1649
1706
  forcePresenceBeat();
1650
1707
  }
1651
1708
  else if (event === 'turn_complete') {
1652
- stopRelayHeartbeat();
1709
+ stopRelayHeartbeat(tid);
1653
1710
  pub.status('round_done');
1654
1711
  /* Simetrico: el latido tiene que dejar de anunciar el turno, o el
1655
1712
  * telefono sigue mostrando la burbuja "pensando" hasta el proximo. */
@@ -1659,7 +1716,7 @@ function relayForward(event, data) {
1659
1716
  publishBoardSnapshot();
1660
1717
  }
1661
1718
  else if (event === 'error') {
1662
- stopRelayHeartbeat();
1719
+ stopRelayHeartbeat(tid);
1663
1720
  pub.status('error', { message: d.message ?? d.error ?? '' });
1664
1721
  }
1665
1722
  }
@@ -6119,6 +6176,11 @@ async function handleChat(req, res, ctx) {
6119
6176
  * del turno en vuelo -- y lo que se declara dentro del try no se ve desde
6120
6177
  * ahi. Mismo motivo por el que turnId vive en este nivel. */
6121
6178
  let turnEcho = null;
6179
+ /* El id del turno para el LATIDO, en este nivel por el mismo motivo que
6180
+ * turnEcho: el finally tiene que poder apagarlo y `_xTurnId` se declara
6181
+ * dentro del try. Y no alcanza con turnEcho: ese se saltea los turnos de
6182
+ * origen mobile, pero el latido se arranca para TODOS (PND-237). */
6183
+ let latidoTurnId = null;
6122
6184
  /* alpha.59e -- SSE streaming mode. Client opts in via
6123
6185
  * body.stream=true. We keep the response open + emit events
6124
6186
  * during the tool loop instead of bundling everything in a
@@ -6294,6 +6356,7 @@ async function handleChat(req, res, ctx) {
6294
6356
  * the END remoteTurn (same id) fills the reply. Old phones ignore
6295
6357
  * the extra fields and keep their current end-of-turn behavior. */
6296
6358
  const _xTurnId = (body.origin === 'mobile' ? 'mb-' : body.origin === 'bridge' ? 'br-' : 'dt-') + Date.now().toString(36);
6359
+ latidoTurnId = _xTurnId;
6297
6360
  /* Queda registrado como ESTADO para que el latido de presencia lo
6298
6361
  * repita: ver el comentario largo en RelayPublisher.presence().
6299
6362
  *
@@ -6460,6 +6523,11 @@ async function handleChat(req, res, ctx) {
6460
6523
  /* PND-132 -- so the relay activity feed shows what is being processed
6461
6524
  * (a per-turn heartbeat), not just tool rounds. */
6462
6525
  user_text: lastUserText.slice(0, 80),
6526
+ /* PND-237 -- QUIEN arranca. El latido del relay y el registro de turnos
6527
+ * vivos son por turno; sin el id, relayForward no sabe a cual atar el
6528
+ * pulso y el panel vuelve a tener un unico timer que los turnos
6529
+ * solapados se pisan entre si. */
6530
+ turn_id: _xTurnId,
6463
6531
  });
6464
6532
  }
6465
6533
  /* alpha.59e -- loop detection counter. We track (verb_id +
@@ -7446,6 +7514,10 @@ async function handleChat(req, res, ctx) {
7446
7514
  const responsePayload = {
7447
7515
  ok: true,
7448
7516
  message: { role: 'assistant', text: reply.text },
7517
+ /* PND-237 -- QUIEN termina. Simetrico al turn_start: relayForward para el
7518
+ * latido DE ESTE turno y no el del turno de al lado, que es lo que pasaba
7519
+ * con un unico timer de modulo. */
7520
+ turn_id: _xTurnId,
7449
7521
  /* Interrupt (task #27): tell the panel the turn was stopped
7450
7522
  * mid-flight so it can render the partial answer with a
7451
7523
  * "(interrumpido)" marker instead of treating it as complete. */
@@ -7604,6 +7676,15 @@ async function handleChat(req, res, ctx) {
7604
7676
  * de un turno que ya arranco despues. */
7605
7677
  if (turnEcho)
7606
7678
  clearInFlightTurn(turnEcho.id);
7679
+ /* Y el LATIDO de este turno, por el mismo motivo y en el mismo lugar
7680
+ * (PND-237). Va aca y no en cada rama de error -- son cinco emisores de
7681
+ * `error` y la quinta se iba a olvidar -- porque el finally es el unico
7682
+ * punto por el que pasan TODAS las salidas. Un latido que sobrevive a su
7683
+ * turno sigue dando senal de vida por el, y entonces el tope de silencio
7684
+ * nunca lo saca del registro: es la mitad que convierte un turno colgado
7685
+ * en un turno que se anuncia para siempre. */
7686
+ if (latidoTurnId)
7687
+ stopRelayHeartbeat(latidoTurnId);
7607
7688
  /* Interrupt (task #27): always clear this turn from the active
7608
7689
  * registry, no matter how the turn exits. endTurn is a no-op if a
7609
7690
  * newer turn has already replaced us, so it never clobbers a