@nac3/forge-cli 1.17.0 → 1.18.0

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.
@@ -55,7 +55,7 @@ import { relayPoll } from '../relay/relay_client.js';
55
55
  import { startCommandConsumer } from '../relay/command_consumer.js';
56
56
  import { getSessionStore, DEFAULT_SESSION_ID } from './session_store.js';
57
57
  import { getSessionProject, setSessionProject } from './session_projects.js';
58
- import { beginTurn, endTurn, interruptActiveTurn, isTurnActive, activeTurnId } from './turn_control.js';
58
+ import { beginTurn, endTurn, interruptActiveTurn, interruptAllTurns, isTurnActive, activeTurnId, activeTurns, } from './turn_control.js';
59
59
  import { openSteering, closeSteering, drainSteers, formatSteer, queueSteer, pendingSteers, } from './turn_steering.js';
60
60
  import { manifest as nac3Manifest } from '../nac3/internal_manifest.js';
61
61
  import { buildManifestForPrompt } from '../nac3/manifest_lazy.js';
@@ -334,9 +334,11 @@ export async function startChatServer(opts) {
334
334
  * claude_cli_timeout_ms <ms>` or the panel. */
335
335
  try {
336
336
  const { readLimitsConfig } = await import('../core/limits_config.js');
337
- cliOpts['timeoutMs'] = (await readLimitsConfig()).claude_cli_timeout_ms;
337
+ const _lim = await readLimitsConfig();
338
+ cliOpts['timeoutMs'] = _lim.claude_cli_timeout_ms;
339
+ cliOpts['silenceMs'] = _lim.claude_cli_silence_ms;
338
340
  }
339
- catch { /* fall back to the ClaudeCliClient default (30 min) */ }
341
+ catch { /* fall back to the ClaudeCliClient defaults (30 min / 15 min) */ }
340
342
  /* Wire the panel MCP bridge: the subprocess gets the forge_panel
341
343
  * MCP server so it can create pizarron tabs. */
342
344
  cliOpts['panelBridge'] = { port: opts.port, token: panelPushToken };
@@ -1394,6 +1396,36 @@ function stopRelayHeartbeat() {
1394
1396
  _relayHeartbeat = null;
1395
1397
  }
1396
1398
  }
1399
+ /**
1400
+ * El turno que esta corriendo pero que el anuncio del relay no cubre.
1401
+ *
1402
+ * `setInFlightTurn` se saltea A PROPOSITO los turnos de origen mobile y
1403
+ * bridge: el telefono ya pinta su propia burbuja y anunciarlos de vuelta la
1404
+ * duplicaba. El efecto secundario se vio el 14-oct-2026: un turno pedido
1405
+ * DESDE el telefono es invisible para el panel del escritorio. Pablo mando
1406
+ * "Adelante", el subproceso corrio veinte minutos escribiendo en el arbol, y
1407
+ * la ventana del panel contestaba `in_flight: null` todo el tiempo.
1408
+ *
1409
+ * El registro de turnos vivos (turn_control) si los tiene a todos, sin
1410
+ * importar de donde vinieron. Cuando el anuncio no tiene nada, se cae ahi y
1411
+ * se reporta el MAS VIEJO: entre varios, el que lleva mas rato sin que nadie
1412
+ * lo vea es el que hay que mostrar.
1413
+ */
1414
+ function turnoVivoSinAnunciar() {
1415
+ try {
1416
+ const t = activeTurns()[0];
1417
+ if (!t)
1418
+ return null;
1419
+ return {
1420
+ id: t.id,
1421
+ user: t.user ?? '',
1422
+ started_at: new Date(t.startedAt).toISOString(),
1423
+ };
1424
+ }
1425
+ catch {
1426
+ return null;
1427
+ }
1428
+ }
1397
1429
  function startRelayHeartbeat() {
1398
1430
  stopRelayHeartbeat();
1399
1431
  _relayHeartbeat = setInterval(() => {
@@ -1588,10 +1620,24 @@ async function route(req, res, ctx) {
1588
1620
  * abort the active turn's signal so the caller unwinds (claude.ts
1589
1621
  * breaks at the next round boundary; claude_cli kills `claude -p`).
1590
1622
  * Idempotent: returns active:false when nothing is running. */
1623
+ /* `?all=1` corta TODOS los turnos vivos, no solo el ultimo. Es la salida
1624
+ * para el turno que se perdio de vista: el 14-oct un turno del telefono
1625
+ * quedo corriendo 20 minutos detras de otro mas nuevo, y "Detener" no lo
1626
+ * alcanzaba porque apuntaba al de arriba. `live` viaja siempre para que
1627
+ * quien llama pueda DECIR cuantos hay en vez de adivinar. */
1591
1628
  if (req.method === 'POST' && url.pathname === '/api/chat/interrupt') {
1629
+ const vivos = activeTurns();
1592
1630
  const wasActive = isTurnActive();
1593
- const r = interruptActiveTurn();
1594
- sendJson(res, 200, { ok: true, active: wasActive, interrupted: r.ok, turn_id: r.id });
1631
+ const todos = url.searchParams.get('all') === '1';
1632
+ const r = todos ? interruptAllTurns() : interruptActiveTurn();
1633
+ sendJson(res, 200, {
1634
+ ok: true,
1635
+ active: wasActive,
1636
+ interrupted: r.ok,
1637
+ turn_id: 'id' in r ? r.id : undefined,
1638
+ turn_ids: 'ids' in r ? r.ids : undefined,
1639
+ live: vivos.length,
1640
+ });
1595
1641
  return;
1596
1642
  }
1597
1643
  /* Steering mid-turn (2026-09-29) -- el usuario escribio con un turno en
@@ -2234,7 +2280,7 @@ async function route(req, res, ctx) {
2234
2280
  const { redactDeep } = await import('../vault/redactor.js');
2235
2281
  sendJson(res, 200, construirRestore({
2236
2282
  messages: store.messages(),
2237
- inFlight: inFlightTurn(),
2283
+ inFlight: inFlightTurn() ?? turnoVivoSinAnunciar(),
2238
2284
  projectRoot: ctx.projectRoot,
2239
2285
  sessionStartedAt: store.startedAt(),
2240
2286
  redactar: (s) => String(redactDeep(s)),
@@ -5707,6 +5753,33 @@ async function handleChat(req, res, ctx) {
5707
5753
  const pilotState = await readPilotState(ctx.projectRoot);
5708
5754
  const lastUserMsg = normalized.filter((m) => m.role === 'user').slice(-1)[0];
5709
5755
  const lastUserText = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
5756
+ /* El pedido entra al transcript AHORA, no al final del turno (Pablo,
5757
+ * 14-oct-2026).
5758
+ *
5759
+ * Antes el mensaje del usuario se grababa recien despues de tener la
5760
+ * respuesta, junto con ella. Un turno que no llegaba a contestar no
5761
+ * dejaba NADA: ni el pedido. El 14-oct un "Adelante" del telefono corrio
5762
+ * veinte minutos y despues se perdio sin rastro -- `turn_routing.jsonl`
5763
+ * tenia seis turnos ese dia y el transcript cinco mensajes de usuario, y
5764
+ * el que faltaba era justo el que habia fallado. Para saber que existio
5765
+ * hubo que cruzar tres logs.
5766
+ *
5767
+ * Un pedido sin respuesta es un transcript honesto: dice que se pidio y
5768
+ * que no se contesto. Perderlo es mentir por omision. El append de abajo
5769
+ * (el del cierre del turno) sigue estando y queda como no-op por su
5770
+ * propia guarda de duplicado; es la red para cualquier camino que no
5771
+ * pase por aca. */
5772
+ {
5773
+ const _nuevo = normalized[normalized.length - 1];
5774
+ if (_nuevo && _nuevo.role === 'user') {
5775
+ const _previos = sessionStore.messages();
5776
+ const _cola = _previos[_previos.length - 1];
5777
+ if (!_cola || _cola.role !== 'user' || _cola.content !== _nuevo.content) {
5778
+ sessionStore.append(_nuevo);
5779
+ void sessionStore.flush();
5780
+ }
5781
+ }
5782
+ }
5710
5783
  /* Immediate cross-device ack (PND -- "duda si esta trabajando"). The
5711
5784
  * RECEIVING device must see the incoming message + a working state the
5712
5785
  * instant the turn STARTS, not when it finishes. Shared id correlates
@@ -6106,11 +6179,14 @@ async function handleChat(req, res, ctx) {
6106
6179
  /* Register THIS turn as the active one right before the model
6107
6180
  * call. beginTurn returns the AbortSignal the callers honor
6108
6181
  * (claude.ts at round boundary, claude_cli kills the subprocess).
6109
- * A concurrent turn (desktop + mobile own-session) replaces the
6110
- * registry entry; interrupt then targets the latest -- acceptable
6111
- * until the queue (task #28) serializes turns. */
6182
+ * Un turno concurrente (desktop + sesion propia del movil) ya NO pisa
6183
+ * al anterior en el registro: los dos quedan vivos y `interrupt` apunta
6184
+ * al mas reciente, pero el viejo sigue alcanzable (interruptAllTurns).
6185
+ * Pisarlo era lo que dejaba subprocesos huerfanos el 14-oct-2026. */
6112
6186
  turnId = sessionId + ':' + String(Date.now());
6113
- const turnSignal = beginTurn(turnId);
6187
+ /* El texto viaja al registro para que un turno que se pierda de vista se
6188
+ * pueda MOSTRAR y no solo contar (ver turnoVivoSinAnunciar). */
6189
+ const turnSignal = beginTurn(turnId, undefined, lastUserText);
6114
6190
  /* Steering mid-turn: desde aca el panel puede depositar correcciones
6115
6191
  * que el turno retira en su proxima frontera de ronda. */
6116
6192
  openSteering(turnId);
@@ -6223,6 +6299,11 @@ async function handleChat(req, res, ctx) {
6223
6299
  * seguia avisando 5000000. Sale del mismo `_limits` que ya se lee cada
6224
6300
  * turno para max_tokens_per_turn. Los clientes HTTP lo ignoran. */
6225
6301
  timeoutMs: _limits.claude_cli_timeout_ms,
6302
+ /* Tope de SILENCIO, tambien por turno. El de reloj esta puesto alto a
6303
+ * proposito (turnos autonomos de horas); este es el que corta al hijo
6304
+ * que se quedo mudo sin dejar cuatro horas de proceso colgado en el
6305
+ * arbol del proyecto (Pablo, 14-oct-2026). */
6306
+ silenceMs: _limits.claude_cli_silence_ms,
6226
6307
  /* Plan/CLI uses Claude Code's native tools (FORGE_TOOL_SPECS = just
6227
6308
  * nac3_invoke via the MCP bridge); the HTTP path (BYOK/Console + managed)
6228
6309
  * gets direct native file tools + nac3_invoke so the model is fluent with
@@ -6564,10 +6645,10 @@ async function handleChat(req, res, ctx) {
6564
6645
  }
6565
6646
  catch { /* best-effort */ }
6566
6647
  }
6567
- // Append the latest user message + the assistant's reply to
6568
- // the persistent transcript. Older messages may already be
6569
- // present from previous turns; we only need to capture the
6570
- // *new* turn so the on-disk file matches conversation order.
6648
+ // El mensaje del usuario YA se grabo al entrar el turno (ver el bloque
6649
+ // "El pedido entra al transcript AHORA" mas arriba), asi que este append
6650
+ // normalmente no hace nada: queda como red para cualquier camino que no
6651
+ // haya pasado por alla. Lo que si se graba aca es la respuesta.
6571
6652
  const lastUser = normalized[normalized.length - 1];
6572
6653
  if (lastUser && lastUser.role === 'user') {
6573
6654
  // Only append if this user message isn't already the last