@nac3/forge-cli 1.21.3 → 1.23.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.
Files changed (137) hide show
  1. package/dist/chat/bridge.d.ts +43 -1
  2. package/dist/chat/bridge.d.ts.map +1 -1
  3. package/dist/chat/bridge.js +176 -31
  4. package/dist/chat/bridge.js.map +1 -1
  5. package/dist/chat/bridge_inbox.d.ts +143 -0
  6. package/dist/chat/bridge_inbox.d.ts.map +1 -0
  7. package/dist/chat/bridge_inbox.js +219 -0
  8. package/dist/chat/bridge_inbox.js.map +1 -0
  9. package/dist/chat/claude.d.ts +10 -0
  10. package/dist/chat/claude.d.ts.map +1 -1
  11. package/dist/chat/claude.js.map +1 -1
  12. package/dist/chat/panel.d.ts.map +1 -1
  13. package/dist/chat/panel.js +148 -16
  14. package/dist/chat/panel.js.map +1 -1
  15. package/dist/chat/recent_sessions_recap.d.ts.map +1 -1
  16. package/dist/chat/recent_sessions_recap.js +18 -6
  17. package/dist/chat/recent_sessions_recap.js.map +1 -1
  18. package/dist/chat/server.d.ts +1 -1
  19. package/dist/chat/server.d.ts.map +1 -1
  20. package/dist/chat/server.js +346 -53
  21. package/dist/chat/server.js.map +1 -1
  22. package/dist/chat/tools/bridge_verbs.d.ts +10 -0
  23. package/dist/chat/tools/bridge_verbs.d.ts.map +1 -1
  24. package/dist/chat/tools/bridge_verbs.js +66 -3
  25. package/dist/chat/tools/bridge_verbs.js.map +1 -1
  26. package/dist/chat/tools/nac3_invoke.d.ts.map +1 -1
  27. package/dist/chat/tools/nac3_invoke.js +20 -3
  28. package/dist/chat/tools/nac3_invoke.js.map +1 -1
  29. package/dist/chat/tools/pending.d.ts.map +1 -1
  30. package/dist/chat/tools/pending.js +27 -4
  31. package/dist/chat/tools/pending.js.map +1 -1
  32. package/dist/chat/tools/urazuke.d.ts.map +1 -1
  33. package/dist/chat/tools/urazuke.js +7 -3
  34. package/dist/chat/tools/urazuke.js.map +1 -1
  35. package/dist/chat/tools.d.ts +6 -0
  36. package/dist/chat/tools.d.ts.map +1 -1
  37. package/dist/chat/tools.js +4 -0
  38. package/dist/chat/tools.js.map +1 -1
  39. package/dist/chat/turn_control.d.ts +2 -0
  40. package/dist/chat/turn_control.d.ts.map +1 -1
  41. package/dist/chat/turn_control.js +30 -1
  42. package/dist/chat/turn_control.js.map +1 -1
  43. package/dist/commands/pending.d.ts.map +1 -1
  44. package/dist/commands/pending.js +4 -2
  45. package/dist/commands/pending.js.map +1 -1
  46. package/dist/commands/urazuke.d.ts.map +1 -1
  47. package/dist/commands/urazuke.js +206 -16
  48. package/dist/commands/urazuke.js.map +1 -1
  49. package/dist/core/cost_router.d.ts +7 -0
  50. package/dist/core/cost_router.d.ts.map +1 -1
  51. package/dist/core/cost_router.js +8 -5
  52. package/dist/core/cost_router.js.map +1 -1
  53. package/dist/core/limits_config.d.ts +37 -6
  54. package/dist/core/limits_config.d.ts.map +1 -1
  55. package/dist/core/limits_config.js +74 -8
  56. package/dist/core/limits_config.js.map +1 -1
  57. package/dist/core/whats_new.d.ts.map +1 -1
  58. package/dist/core/whats_new.js +12 -0
  59. package/dist/core/whats_new.js.map +1 -1
  60. package/dist/nac3/danger_zone.d.ts +39 -0
  61. package/dist/nac3/danger_zone.d.ts.map +1 -0
  62. package/dist/nac3/danger_zone.js +31 -0
  63. package/dist/nac3/danger_zone.js.map +1 -0
  64. package/dist/nac3/internal_manifest.d.ts.map +1 -1
  65. package/dist/nac3/internal_manifest.js +33 -0
  66. package/dist/nac3/internal_manifest.js.map +1 -1
  67. package/dist/pending/store.d.ts +7 -0
  68. package/dist/pending/store.d.ts.map +1 -1
  69. package/dist/pending/store.js +41 -0
  70. package/dist/pending/store.js.map +1 -1
  71. package/dist/urazuke/catalog.d.ts +1 -1
  72. package/dist/urazuke/catalog.d.ts.map +1 -1
  73. package/dist/urazuke/catalog.js.map +1 -1
  74. package/dist/urazuke/current.d.ts +2 -2
  75. package/dist/urazuke/current.d.ts.map +1 -1
  76. package/dist/urazuke/current.js +32 -12
  77. package/dist/urazuke/current.js.map +1 -1
  78. package/dist/urazuke/gate.d.ts +45 -7
  79. package/dist/urazuke/gate.d.ts.map +1 -1
  80. package/dist/urazuke/gate.js +190 -20
  81. package/dist/urazuke/gate.js.map +1 -1
  82. package/dist/urazuke/gates_data.d.ts +170 -0
  83. package/dist/urazuke/gates_data.d.ts.map +1 -1
  84. package/dist/urazuke/gates_data.js +98 -5
  85. package/dist/urazuke/gates_data.js.map +1 -1
  86. package/dist/urazuke/gates_ledger.d.ts +75 -0
  87. package/dist/urazuke/gates_ledger.d.ts.map +1 -0
  88. package/dist/urazuke/gates_ledger.js +183 -0
  89. package/dist/urazuke/gates_ledger.js.map +1 -0
  90. package/dist/urazuke/gates_pull.d.ts +97 -0
  91. package/dist/urazuke/gates_pull.d.ts.map +1 -0
  92. package/dist/urazuke/gates_pull.js +303 -0
  93. package/dist/urazuke/gates_pull.js.map +1 -0
  94. package/dist/urazuke/heuristics.d.ts +108 -0
  95. package/dist/urazuke/heuristics.d.ts.map +1 -0
  96. package/dist/urazuke/heuristics.js +248 -0
  97. package/dist/urazuke/heuristics.js.map +1 -0
  98. package/dist/urazuke/manual_cases.d.ts +31 -0
  99. package/dist/urazuke/manual_cases.d.ts.map +1 -0
  100. package/dist/urazuke/manual_cases.js +87 -0
  101. package/dist/urazuke/manual_cases.js.map +1 -0
  102. package/dist/urazuke/project_config.d.ts +25 -0
  103. package/dist/urazuke/project_config.d.ts.map +1 -0
  104. package/dist/urazuke/project_config.js +65 -0
  105. package/dist/urazuke/project_config.js.map +1 -0
  106. package/dist/urazuke/quality.d.ts +0 -8
  107. package/dist/urazuke/quality.d.ts.map +1 -1
  108. package/dist/urazuke/quality.js +4 -29
  109. package/dist/urazuke/quality.js.map +1 -1
  110. package/dist/urazuke/retro.d.ts.map +1 -1
  111. package/dist/urazuke/retro.js +4 -1
  112. package/dist/urazuke/retro.js.map +1 -1
  113. package/dist/urazuke/scoreboard.d.ts +85 -0
  114. package/dist/urazuke/scoreboard.d.ts.map +1 -0
  115. package/dist/urazuke/scoreboard.js +260 -0
  116. package/dist/urazuke/scoreboard.js.map +1 -0
  117. package/dist/urazuke/section.d.ts +2 -2
  118. package/dist/urazuke/section.d.ts.map +1 -1
  119. package/dist/urazuke/section.js +31 -6
  120. package/dist/urazuke/section.js.map +1 -1
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/dist/workflow/orchestrator.d.ts +9 -0
  124. package/dist/workflow/orchestrator.d.ts.map +1 -1
  125. package/dist/workflow/orchestrator.js +17 -16
  126. package/dist/workflow/orchestrator.js.map +1 -1
  127. package/package.json +1 -1
  128. package/src/i18n/catalogs/ar.json +558 -550
  129. package/src/i18n/catalogs/de.json +558 -550
  130. package/src/i18n/catalogs/en.json +558 -550
  131. package/src/i18n/catalogs/es.json +558 -550
  132. package/src/i18n/catalogs/fr.json +558 -550
  133. package/src/i18n/catalogs/hi.json +558 -550
  134. package/src/i18n/catalogs/it.json +558 -550
  135. package/src/i18n/catalogs/ja.json +558 -550
  136. package/src/i18n/catalogs/pt.json +558 -550
  137. package/src/i18n/catalogs/zh.json +558 -550
@@ -784,6 +784,28 @@ export async function startChatServer(opts) {
784
784
  catch { /* swallow */ }
785
785
  }
786
786
  }
787
+ /* PND-222 -- el drenaje de la casilla del canal se engancha al CIERRE DE
788
+ * TURNO, no a un temporizador. Es el unico momento en que un entrante puede
789
+ * correr sin pelearse con el turno vivo, y el unico que lo conoce es
790
+ * turn_control. Registrado una sola vez por proceso. */
791
+ if (!bridgeDisabled) {
792
+ try {
793
+ const { onTurnsIdle } = await import('./turn_control.js');
794
+ onTurnsIdle(() => {
795
+ void drainBridgeInbox({
796
+ projectRoot: session.projectRoot,
797
+ projectName: session.projectName,
798
+ projectSlug: session.projectSlug,
799
+ port: opts.port,
800
+ claude,
801
+ voice,
802
+ store,
803
+ bridge: bridgeHandle,
804
+ });
805
+ });
806
+ }
807
+ catch { /* el canal nunca puede trabar el boot */ }
808
+ }
787
809
  /* Set the relay board ctx to the active project NOW so a paired phone
788
810
  * shows the project bubble + pizarron immediately -- the ctx used to be
789
811
  * set only on a chat turn or a project switch, so the phone saw nothing
@@ -884,6 +906,122 @@ export async function startChatServer(opts) {
884
906
  }),
885
907
  };
886
908
  }
909
+ /** Entrantes esperando el modal del dueno (modo 'ask'), por delivery_id. */
910
+ const bridgeAsks = new Map();
911
+ /** delivery_id local -> delivery_id del expediente en el host del canal, para
912
+ * poder devolverle la transicion al emisor. */
913
+ const bridgeHostDeliveryIds = new Map();
914
+ function hostIdFor(localId) {
915
+ return bridgeHostDeliveryIds.get(localId) || localId;
916
+ }
917
+ /** Lanza el turno inyectado por el MISMO camino que un turno tipeado. */
918
+ function runInjectedTurn(ctx, turn, localId) {
919
+ const hostId = hostIdFor(localId);
920
+ void (async () => {
921
+ try {
922
+ await ctx.bridge?.reportDelivery(hostId, 'running');
923
+ }
924
+ catch { /* */ }
925
+ try {
926
+ await fetch('http://127.0.0.1:' + ctx.port + '/api/chat', {
927
+ method: 'POST',
928
+ headers: { 'content-type': 'application/json' },
929
+ body: JSON.stringify(turn),
930
+ });
931
+ try {
932
+ await ctx.bridge?.reportDelivery(hostId, 'done');
933
+ }
934
+ catch { /* */ }
935
+ }
936
+ catch {
937
+ try {
938
+ await ctx.bridge?.reportDelivery(hostId, 'refused', 'local turn failed to start');
939
+ }
940
+ catch { /* */ }
941
+ }
942
+ })();
943
+ }
944
+ let draining = false;
945
+ /**
946
+ * Tomar el proximo entrante y hacer lo que la postura del dueno diga. Se llama
947
+ * al recibir (si no hay turno) y al cerrar cada turno.
948
+ */
949
+ async function drainBridgeInbox(ctx) {
950
+ if (draining)
951
+ return;
952
+ draining = true;
953
+ try {
954
+ const { isTurnActive } = await import('./turn_control.js');
955
+ if (isTurnActive())
956
+ return;
957
+ if (bridgeAsks.size > 0)
958
+ return; /* un modal abierto manda; no apilamos dos */
959
+ const inbox = await import('./bridge_inbox.js');
960
+ /* Lo vencido se DICE, no se descarta: el emisor lo lee en su expediente. */
961
+ for (const v of inbox.dropExpired()) {
962
+ await inbox.logBridgeDelivery({
963
+ ts: new Date().toISOString(), delivery_id: hostIdFor(v.delivery_id), direction: 'in',
964
+ from: v.from_name, from_port: v.from_port, hop: v.hop, disposition: 'expired',
965
+ note: 'espero mas de ' + Math.round(inbox.INBOX_TTL_MS / 60000) + ' min sin poder correr',
966
+ text_len: v.text.length,
967
+ });
968
+ try {
969
+ await ctx.bridge?.reportDelivery(hostIdFor(v.delivery_id), 'expired', 'ttl');
970
+ }
971
+ catch { /* */ }
972
+ }
973
+ const msg = inbox.takeNextDirected();
974
+ if (!msg)
975
+ return;
976
+ const { readLimitsConfig } = await import('../core/limits_config.js');
977
+ const cfg = (await readLimitsConfig()).bridge;
978
+ const plan = inbox.planDelivery(msg, cfg);
979
+ if (plan.action === 'refuse') {
980
+ try {
981
+ await ctx.bridge?.reportDelivery(hostIdFor(msg.delivery_id), 'refused', 'disabled');
982
+ }
983
+ catch { /* */ }
984
+ return;
985
+ }
986
+ if (plan.action === 'ask') {
987
+ bridgeAsks.set(msg.delivery_id, msg);
988
+ /* EL MODAL ES LA PREGUNTA (R8): emisor, puerto, salto y el texto
989
+ * LITERAL, sin recortarlo a un resumen. */
990
+ broadcastToPanel('approval_required', {
991
+ kind: 'bridge_message',
992
+ trace_id: msg.delivery_id,
993
+ bridge: {
994
+ delivery_id: msg.delivery_id,
995
+ from_name: msg.from_name,
996
+ from_port: msg.from_port,
997
+ hop: msg.hop,
998
+ text: msg.text,
999
+ received_at: new Date(msg.received_at).toISOString(),
1000
+ },
1001
+ });
1002
+ await inbox.logBridgeDelivery({
1003
+ ts: new Date().toISOString(), delivery_id: hostIdFor(msg.delivery_id), direction: 'in',
1004
+ from: msg.from_name, from_port: msg.from_port, hop: msg.hop,
1005
+ disposition: 'awaiting_approval', mode: cfg.directed_mode, text_len: msg.text.length,
1006
+ });
1007
+ try {
1008
+ await ctx.bridge?.reportDelivery(hostIdFor(msg.delivery_id), 'awaiting_approval');
1009
+ }
1010
+ catch { /* */ }
1011
+ return;
1012
+ }
1013
+ await inbox.logBridgeDelivery({
1014
+ ts: new Date().toISOString(), delivery_id: hostIdFor(msg.delivery_id), direction: 'in',
1015
+ from: msg.from_name, from_port: msg.from_port, hop: msg.hop,
1016
+ disposition: 'running', mode: cfg.directed_mode, text_len: msg.text.length,
1017
+ });
1018
+ runInjectedTurn(ctx, plan.turn, msg.delivery_id);
1019
+ }
1020
+ catch { /* el drenaje nunca puede tirar el servidor */ }
1021
+ finally {
1022
+ draining = false;
1023
+ }
1024
+ }
887
1025
  /* P2 (mobile remote control) -- the desktop mirrors its Coding-feed
888
1026
  * activity to the worker relay so a paired phone can watch the work.
889
1027
  * AUTOMATIC: on as soon as a phone is paired (the pairing drops a marker);
@@ -1975,7 +2113,13 @@ async function route(req, res, ctx) {
1975
2113
  };
1976
2114
  const fields = LIMITS_FIELD_PATHS.map((f) => resolveField(f, 'number'));
1977
2115
  const booleans = LIMITS_BOOLEAN_FIELD_PATHS.map((f) => resolveField(f, 'boolean'));
1978
- sendJson(res, 200, { ok: true, fields, booleans, config: cfg });
2116
+ /* PND-221 -- los ajustes de tres estados. El panel pinta el select desde
2117
+ * esta lista; los valores validos salen del mismo lugar que los valida. */
2118
+ const { LIMITS_ENUM_FIELD_PATHS } = await import('../core/limits_config.js');
2119
+ const enums = LIMITS_ENUM_FIELD_PATHS.map((e) => ({
2120
+ ...resolveField(e.field, 'boolean'), kind: 'enum', values: e.values,
2121
+ }));
2122
+ sendJson(res, 200, { ok: true, fields, booleans, enums, config: cfg });
1979
2123
  return;
1980
2124
  }
1981
2125
  if (req.method === 'POST' && url.pathname === '/api/forge/limits') {
@@ -1988,7 +2132,7 @@ async function route(req, res, ctx) {
1988
2132
  sendJson(res, 400, { ok: false, error: 'invalid JSON' });
1989
2133
  return;
1990
2134
  }
1991
- const { setLimitsField, setLimitsBooleanField, isLimitsBooleanFieldPath, resetLimitsConfig } = await import('../core/limits_config.js');
2135
+ const { setLimitsField, setLimitsBooleanField, isLimitsBooleanFieldPath, resetLimitsConfig, setLimitsEnumField, isLimitsEnumFieldPath, } = await import('../core/limits_config.js');
1992
2136
  if (body.reset === true) {
1993
2137
  const cfg = await resetLimitsConfig();
1994
2138
  sendJson(res, 200, { ok: true, config: cfg });
@@ -1998,6 +2142,21 @@ async function route(req, res, ctx) {
1998
2142
  sendJson(res, 400, { ok: false, error: 'expected {field: string, value: number|boolean} or {reset: true}' });
1999
2143
  return;
2000
2144
  }
2145
+ /* PND-221 -- campos de enumeracion (bridge.directed_mode, bridge.tier). */
2146
+ if (isLimitsEnumFieldPath(body.field)) {
2147
+ if (typeof body.value !== 'string') {
2148
+ sendJson(res, 400, { ok: false, error: 'enum field requires a string value' });
2149
+ return;
2150
+ }
2151
+ try {
2152
+ const cfg = await setLimitsEnumField(body.field, body.value);
2153
+ sendJson(res, 200, { ok: true, config: cfg });
2154
+ }
2155
+ catch (err) {
2156
+ sendJson(res, 400, { ok: false, error: err instanceof Error ? err.message : String(err) });
2157
+ }
2158
+ return;
2159
+ }
2001
2160
  /* alpha.59d -- accept booleans too (Danger Zone toggle +
2002
2161
  * cwd_boundary_strict). Dispatch by field-path type. */
2003
2162
  if (isLimitsBooleanFieldPath(body.field)) {
@@ -2239,11 +2398,20 @@ async function route(req, res, ctx) {
2239
2398
  }
2240
2399
  return;
2241
2400
  }
2242
- /* Inbox for DIRECTED bridge messages (Pablo 2026-06-19). The bridge host
2243
- * POSTs here (localhost-only) to push a directed message into this instance.
2244
- * We gate on the user toggle (limits.bridge.accept_directed) + the busy guard
2245
- * (the host retries 'busy'), then inject it as an eco-tier chat turn tagged
2246
- * as coordination, carrying bridge_hop for the loop cap. */
2401
+ /* Inbox for DIRECTED bridge messages (Pablo 2026-06-19; reescrito 2026-10-27
2402
+ * por PND-221..227). El bridge host POSTea aca (solo localhost) para empujar
2403
+ * un mensaje dirigido a esta instancia.
2404
+ *
2405
+ * TRES COSAS CAMBIARON, y las tres venian de la misma raiz -- el canal
2406
+ * trataba distinto al mensaje de otra instancia que al que el dueno tipea:
2407
+ *
2408
+ * 1. Ya NO contesta 'busy'. Siempre acepta y ENCOLA (bridge_inbox.ts); el
2409
+ * drenaje lo dispara el cierre del turno. La ventana de doce segundos
2410
+ * que perdio seis de diez mensajes el 27-oct desaparece.
2411
+ * 2. Ya NO fuerza 'eco' ni reescribe el prompt. El texto del emisor llega
2412
+ * caracter por caracter y la procedencia viaja en metadatos.
2413
+ * 3. El interruptor dejo de ser binario: off / ask / auto, y 'ask' abre el
2414
+ * modal con el texto literal. */
2247
2415
  if (req.method === 'POST' && url.pathname === '/api/forge/bridge/inbox') {
2248
2416
  const raw = await readBody(req);
2249
2417
  let body;
@@ -2259,57 +2427,121 @@ async function route(req, res, ctx) {
2259
2427
  sendJson(res, 400, { ok: false, accepted: false, reason: 'empty' });
2260
2428
  return;
2261
2429
  }
2262
- let accept = true;
2430
+ const { enqueueDirected, logBridgeDelivery, senderPort } = await import('./bridge_inbox.js');
2431
+ const fromName = String(body.from_name || 'otra instancia');
2432
+ const fromId = String(body.from || '');
2433
+ const hop = Math.max(0, Math.min(10, Number(body.hop) || 0));
2434
+ const hostDeliveryId = body.delivery_id ? String(body.delivery_id) : '';
2435
+ let mode = 'ask';
2263
2436
  try {
2264
2437
  const { readLimitsConfig } = await import('../core/limits_config.js');
2265
- accept = (await readLimitsConfig()).bridge.accept_directed;
2438
+ mode = (await readLimitsConfig()).bridge.directed_mode;
2266
2439
  }
2267
2440
  catch {
2268
- accept = true;
2441
+ mode = 'ask';
2269
2442
  }
2270
- const fromNameEarly = String(body.from_name || 'otra instancia');
2271
- if (!accept) {
2272
- try {
2273
- console.error('[bridge] directed message from ' + fromNameEarly + ' DROPPED (accept_directed=off)');
2274
- }
2275
- catch { /* */ }
2443
+ if (mode === 'off') {
2444
+ await logBridgeDelivery({
2445
+ ts: new Date().toISOString(), delivery_id: hostDeliveryId || '-', direction: 'in',
2446
+ from: fromName, from_port: senderPort(fromId), hop, disposition: 'refused',
2447
+ mode, note: 'directed_mode=off', text_len: text.length,
2448
+ });
2276
2449
  sendJson(res, 200, { ok: true, accepted: false, reason: 'disabled' });
2277
2450
  return;
2278
2451
  }
2279
- if (isTurnActive()) {
2452
+ const q = enqueueDirected({ from: fromId, from_name: fromName, text, hop });
2453
+ if (hostDeliveryId)
2454
+ bridgeHostDeliveryIds.set(q.delivery_id, hostDeliveryId);
2455
+ await logBridgeDelivery({
2456
+ ts: new Date().toISOString(), delivery_id: hostDeliveryId || '-', direction: 'in',
2457
+ from: fromName, from_port: senderPort(fromId), hop,
2458
+ disposition: q.duplicate_of ? 'duplicate' : 'queued',
2459
+ mode, note: q.evicted ? 'evicted ' + q.evicted : undefined, text_len: text.length,
2460
+ });
2461
+ sendJson(res, 200, {
2462
+ ok: true, accepted: true, queued: true, position: q.position,
2463
+ status: 'queued', delivery_id: hostDeliveryId || q.delivery_id,
2464
+ ...(q.duplicate_of ? { duplicate_of: q.duplicate_of } : {}),
2465
+ });
2466
+ /* Si no hay turno vivo, drenar YA; si hay, lo drena onTurnsIdle. */
2467
+ void drainBridgeInbox(ctx);
2468
+ return;
2469
+ }
2470
+ /* El dueno resolvio el modal de un entrante (modo 'ask'). trust=true suma el
2471
+ * par al allowlist para que el proximo del MISMO emisor no pregunte. */
2472
+ if (req.method === 'POST' && url.pathname === '/api/forge/bridge/approve') {
2473
+ const raw = await readBody(req);
2474
+ let body;
2475
+ try {
2476
+ body = JSON.parse(raw);
2477
+ }
2478
+ catch {
2479
+ sendJson(res, 400, { ok: false, error: 'invalid JSON' });
2480
+ return;
2481
+ }
2482
+ const id = String(body.delivery_id || '');
2483
+ const row = bridgeAsks.get(id);
2484
+ if (!row) {
2485
+ sendJson(res, 404, { ok: false, error: 'no pending bridge message with that id' });
2486
+ return;
2487
+ }
2488
+ bridgeAsks.delete(id);
2489
+ if (body.trust === true) {
2280
2490
  try {
2281
- console.error('[bridge] directed message from ' + fromNameEarly + ' deferred (busy; host will retry)');
2491
+ const { setBridgeAutoFrom } = await import('../core/limits_config.js');
2492
+ const { senderIdentity } = await import('./bridge_inbox.js');
2493
+ await setBridgeAutoFrom(senderIdentity(row.from, row.from_name), true);
2282
2494
  }
2283
- catch { /* */ }
2284
- sendJson(res, 200, { ok: true, accepted: false, reason: 'busy' });
2495
+ catch { /* la confianza es un extra; la ejecucion no depende de ella */ }
2496
+ }
2497
+ const { logBridgeDelivery, composeInjectedTurn } = await import('./bridge_inbox.js');
2498
+ let tier = 'default';
2499
+ try {
2500
+ const { readLimitsConfig } = await import('../core/limits_config.js');
2501
+ tier = (await readLimitsConfig()).bridge.tier;
2502
+ }
2503
+ catch { /* default */ }
2504
+ await logBridgeDelivery({
2505
+ ts: new Date().toISOString(), delivery_id: id, direction: 'in',
2506
+ from: row.from_name, from_port: row.from_port, hop: row.hop,
2507
+ disposition: 'running', mode: 'ask', approved_by: 'owner (panel modal)',
2508
+ text_len: row.text.length,
2509
+ });
2510
+ runInjectedTurn(ctx, composeInjectedTurn(row, tier), id);
2511
+ sendJson(res, 200, { ok: true, running: true });
2512
+ return;
2513
+ }
2514
+ if (req.method === 'POST' && url.pathname === '/api/forge/bridge/reject') {
2515
+ const raw = await readBody(req);
2516
+ let body;
2517
+ try {
2518
+ body = JSON.parse(raw);
2519
+ }
2520
+ catch {
2521
+ sendJson(res, 400, { ok: false, error: 'invalid JSON' });
2285
2522
  return;
2286
2523
  }
2287
- const fromName = fromNameEarly;
2288
- const fromId = String(body.from || '');
2289
- const m = fromId.match(/-(\d+)-\d+$/);
2290
- const fromPort = m ? m[1] : '';
2291
- const hop = Math.max(0, Math.min(10, Number(body.hop) || 0));
2524
+ const id = String(body.delivery_id || '');
2525
+ const row = bridgeAsks.get(id);
2526
+ if (!row) {
2527
+ sendJson(res, 404, { ok: false, error: 'no pending bridge message with that id' });
2528
+ return;
2529
+ }
2530
+ bridgeAsks.delete(id);
2531
+ const { logBridgeDelivery } = await import('./bridge_inbox.js');
2532
+ await logBridgeDelivery({
2533
+ ts: new Date().toISOString(), delivery_id: id, direction: 'in',
2534
+ from: row.from_name, from_port: row.from_port, hop: row.hop,
2535
+ disposition: 'refused', mode: 'ask', approved_by: 'owner (panel modal)',
2536
+ note: body.reason ? String(body.reason).slice(0, 200) : 'rejected in modal',
2537
+ text_len: row.text.length,
2538
+ });
2292
2539
  try {
2293
- console.error('[bridge] injecting directed message from ' + fromName + (fromPort ? ' (:' + fromPort + ')' : '') + ' as a chat turn (hop=' + hop + ')');
2540
+ await ctx.bridge?.reportDelivery(hostIdFor(id), 'refused', 'rejected by owner');
2294
2541
  }
2295
- catch { /* */ }
2296
- const tagged = '[Coordinacion entre instancias] Mensaje DIRIGIDO de Forge "' + fromName + '"'
2297
- + (fromPort ? ' (:' + fromPort + ')' : '') + ':\n\n' + text
2298
- + '\n\n(Responde en el chat. Si necesitas contestarle por el canal usa yujin.bridge.say con to:"'
2299
- + (fromPort || fromName) + '". NO reabras el canal salvo que aportes algo nuevo, para no entrar en loop.)';
2300
- /* Fire-and-forget: the turn runs + streams to the panel via the normal SSE.
2301
- * eco tier + origin tag + hop. */
2302
- void fetch('http://127.0.0.1:' + ctx.port + '/api/chat', {
2303
- method: 'POST',
2304
- headers: { 'content-type': 'application/json' },
2305
- body: JSON.stringify({
2306
- messages: [{ role: 'user', content: tagged }],
2307
- origin: 'bridge',
2308
- brain_tier_override: 'eco',
2309
- bridge_hop: hop,
2310
- }),
2311
- }).catch(() => undefined);
2312
- sendJson(res, 200, { ok: true, accepted: true });
2542
+ catch { /* best-effort */ }
2543
+ void drainBridgeInbox(ctx);
2544
+ sendJson(res, 200, { ok: true });
2313
2545
  return;
2314
2546
  }
2315
2547
  if (req.method === 'GET' && url.pathname === '/api/forge/wizard-state') {
@@ -6037,7 +6269,16 @@ async function handleChat(req, res, ctx) {
6037
6269
  const _previos = sessionStore.messages();
6038
6270
  const _cola = _previos[_previos.length - 1];
6039
6271
  if (!_cola || _cola.role !== 'user' || _cola.content !== _nuevo.content) {
6040
- sessionStore.append(_nuevo);
6272
+ /* PND-224 -- la procedencia queda en el transcript como CAMPO. Hasta
6273
+ * hoy el unico rastro de que un turno venia de otra instancia era el
6274
+ * preambulo que el receptor le pegaba al prompt, y el recap de
6275
+ * arranque lo reconocia por esa cadena. Al dejar de editar el prompt,
6276
+ * la marca tiene que ser estructural o el recap empieza a contar las
6277
+ * coordinaciones como pedidos del dueno. No viaja al proveedor: cada
6278
+ * cliente mapea {role, content}. */
6279
+ const _origen = body.origin === 'bridge' || body.origin === 'mobile'
6280
+ ? String(body.origin) : null;
6281
+ sessionStore.append(_origen ? { ..._nuevo, origin: _origen } : _nuevo);
6041
6282
  void sessionStore.flush();
6042
6283
  }
6043
6284
  }
@@ -6054,9 +6295,16 @@ async function handleChat(req, res, ctx) {
6054
6295
  * the extra fields and keep their current end-of-turn behavior. */
6055
6296
  const _xTurnId = (body.origin === 'mobile' ? 'mb-' : body.origin === 'bridge' ? 'br-' : 'dt-') + Date.now().toString(36);
6056
6297
  /* Queda registrado como ESTADO para que el latido de presencia lo
6057
- * repita: ver el comentario largo en RelayPublisher.presence(). */
6058
- if (sessionId === DEFAULT_SESSION_ID && lastUserText
6059
- && body.origin !== 'mobile' && body.origin !== 'bridge') {
6298
+ * repita: ver el comentario largo en RelayPublisher.presence().
6299
+ *
6300
+ * PND-225 -- EL ORIGEN BRIDGE ENTRA ACA. Antes se excluia junto con
6301
+ * 'mobile', y las dos exclusiones NO tenian la misma razon: el telefono se
6302
+ * excluye porque YA muestra su propia burbuja (el turno nacio ahi), pero un
6303
+ * turno pedido por otra instancia no se mostraba en NINGUNA parte -- ni
6304
+ * como turno en vuelo en el panel, ni en el latido de presencia que lee el
6305
+ * telefono. Un turno al que no se le puede llegar es peor que un turno que
6306
+ * falla (es la misma leccion del mapa de turnos activos). */
6307
+ if (sessionId === DEFAULT_SESSION_ID && lastUserText && body.origin !== 'mobile') {
6060
6308
  setInFlightTurn({ id: _xTurnId, user: lastUserText, started_at: new Date().toISOString() });
6061
6309
  turnEcho = { id: _xTurnId, user: lastUserText };
6062
6310
  }
@@ -6064,9 +6312,29 @@ async function handleChat(req, res, ctx) {
6064
6312
  if (body.origin === 'mobile' || body.origin === 'bridge') {
6065
6313
  try {
6066
6314
  const { enqueuePanelDispatch } = await import('./panel_queue.js');
6067
- enqueuePanelDispatch('system.remote-turn', { source: String(body.origin), id: _xTurnId, user: lastUserText, reply: '' }, new Date().toISOString());
6315
+ enqueuePanelDispatch('system.remote-turn', {
6316
+ source: String(body.origin), id: _xTurnId, user: lastUserText, reply: '',
6317
+ /* La procedencia como dato: el panel puede rotular la burbuja con
6318
+ * el emisor en vez de dejarla como un pedido anonimo. */
6319
+ ...(body.origin === 'bridge' ? {
6320
+ bridge_from: body.bridge_from ? String(body.bridge_from) : undefined,
6321
+ bridge_from_port: body.bridge_from_port ? String(body.bridge_from_port) : undefined,
6322
+ } : {}),
6323
+ }, new Date().toISOString());
6068
6324
  }
6069
6325
  catch { /* best-effort */ }
6326
+ /* Y ADEMAS el latido, para el origen bridge: el panel recibe la
6327
+ * burbuja por la cola de arriba, el telefono necesita el estado. */
6328
+ if (body.origin === 'bridge') {
6329
+ try {
6330
+ const pub = getRelayPublisher();
6331
+ if (pub) {
6332
+ pub.status('working', { user: lastUserText, turn_id: _xTurnId });
6333
+ void pub.flush();
6334
+ }
6335
+ }
6336
+ catch { /* best-effort */ }
6337
+ }
6070
6338
  }
6071
6339
  else {
6072
6340
  try {
@@ -6118,6 +6386,13 @@ async function handleChat(req, res, ctx) {
6118
6386
  catch { /* missing yujin.forge.json -> phase_i, no-op */ }
6119
6387
  const brainSelection = await selectBrainForTurn({
6120
6388
  user_override: brainOverride,
6389
+ /* PND-223 -- quien pidio el override, para que el log no se lo atribuya
6390
+ * a un /eco que nadie tipeo. */
6391
+ override_source: brainOverride
6392
+ ? (body.origin === 'bridge' ? 'bridge_preference'
6393
+ : body.origin === 'mobile' ? 'api_caller'
6394
+ : 'slash_command')
6395
+ : undefined,
6121
6396
  caller_hint: 'handleChat',
6122
6397
  user_text: lastUserText,
6123
6398
  history: historyForGate,
@@ -6540,7 +6815,7 @@ async function handleChat(req, res, ctx) {
6540
6815
  * the next turn. Reaches every brain via systemDynamic. */
6541
6816
  const _autoApprove = !!(_limits.danger_zone && _limits.danger_zone.auto_approve_all === true);
6542
6817
  const _cwdStrict = !!(_limits.shell && _limits.shell.cwd_boundary_strict === true);
6543
- const approvalPostureSection = buildApprovalPostureSection(_autoApprove, _cwdStrict);
6818
+ const approvalPostureSection = buildApprovalPostureSection(_autoApprove, _cwdStrict, body.origin === 'bridge' ? 'bridge' : body.origin === 'mobile' ? 'mobile' : 'panel');
6544
6819
  /* Auto-recall (Pablo 2026-06-21) -- inject a recap of the recent WORK
6545
6820
  * SESSIONS for this project so the model boots knowing what we did the
6546
6821
  * last few days, not only general project knowledge. No LLM call; built
@@ -6709,6 +6984,11 @@ async function handleChat(req, res, ctx) {
6709
6984
  projectSlug: ctx.projectSlug,
6710
6985
  bridge: ctx.bridge,
6711
6986
  bridgeHop,
6987
+ /* PND-221 -- el origen del turno viaja hasta el dispatcher porque la
6988
+ * zona de peligro NO se aplica a lo que pidio otro proceso. Ver
6989
+ * resolveDangerZone en src/nac3/danger_zone.ts. */
6990
+ turnOrigin: (body.origin === 'bridge' || body.origin === 'mobile'
6991
+ ? String(body.origin) : 'panel'),
6712
6992
  ...(sseEmit ? { streamEmit: sseEmit } : {}),
6713
6993
  /* per-session reanchor for non-default sessions; global for notebook */
6714
6994
  ...(effReanchor ? { reanchor: effReanchor } : {}),
@@ -7964,21 +8244,34 @@ multiagentEnabled = false) {
7964
8244
  * this reflects the ACTUAL danger_zone.auto_approve_all + shell.cwd_boundary_
7965
8245
  * strict config so the model only mentions approval when a modal will really
7966
8246
  * appear. Read per-turn -> toggling the switch takes effect next turn. */
7967
- export function buildApprovalPostureSection(autoApprove, cwdStrict) {
8247
+ export function buildApprovalPostureSection(autoApprove, cwdStrict, turnOrigin) {
7968
8248
  /* Prompt audit E (2026-09-09): only inject a posture block when the LIVE config
7969
8249
  * DEVIATES from the static R8 default (destructive verbs open a modal; shell
7970
8250
  * free). When it matches R8 -- the common case -- say nothing: repeating "a
7971
8251
  * modal opens" and "this OVERRIDES R8" every turn just diluted R8 and burned
7972
8252
  * ~120 tok/turn. The block appears only for the real overrides. */
7973
- if (!autoApprove && !cwdStrict)
8253
+ const fromBridge = turnOrigin === 'bridge';
8254
+ if (!autoApprove && !cwdStrict && !fromBridge)
7974
8255
  return '';
7975
8256
  const lines = ['\n\nRUNTIME APPROVAL POSTURE (live config -- deviates from the R8 default):'];
7976
- if (autoApprove) {
8257
+ if (autoApprove && !fromBridge) {
7977
8258
  lines.push('- danger_zone.auto_approve_all = TRUE (autonomous). Destructive verbs '
7978
8259
  + '(git push/commit, rm, fs.write-project-file, shell.exec, ...) EXECUTE '
7979
8260
  + 'IMMEDIATELY with NO approval modal -- this SUSPENDS R8. Do NOT tell the user '
7980
8261
  + 'to approve or say "a modal appears"; just invoke and report.');
7981
8262
  }
8263
+ if (fromBridge) {
8264
+ /* PND-221 -- decirselo al cerebro en el mismo turno, porque si no narra
8265
+ * "ya lo ejecute" sobre algo que quedo esperando un modal. */
8266
+ lines.push('- THIS TURN CAME FROM ANOTHER FORGE INSTANCE (bridge). R8 is IN FORCE '
8267
+ + 'for it'
8268
+ + (autoApprove
8269
+ ? ' even though danger_zone.auto_approve_all = TRUE: that switch is the owner\'s '
8270
+ + 'decision ABOUT HIMSELF and does not extend to what another process asks. '
8271
+ : '. ')
8272
+ + 'Every destructive / approval-required verb opens the modal and waits. Do NOT '
8273
+ + 'report such an action as done until the modal comes back APPROVED.');
8274
+ }
7982
8275
  if (cwdStrict) {
7983
8276
  lines.push('- shell.cwd_boundary_strict = TRUE. Shell is confined to the project '
7984
8277
  + 'root; commands targeting paths outside it are blocked.');