@nac3/forge-cli 1.0.20 → 1.0.21

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.
@@ -50,7 +50,41 @@ import { beginTurn, endTurn, interruptActiveTurn, isTurnActive } from './turn_co
50
50
  import { manifest as nac3Manifest } from '../nac3/internal_manifest.js';
51
51
  import { buildManifestForPrompt } from '../nac3/manifest_lazy.js';
52
52
  import { popPending, logApproval } from '../nac3/approval_queue.js';
53
+ /**
54
+ * Find the first free TCP port at or above `start` on `host`, so several
55
+ * `yf chat` instances can run side by side: each lands on the next free port
56
+ * instead of failing with EADDRINUSE (Pablo 2026-06-19).
57
+ */
58
+ async function findFreePort(start, host, maxTries = 50) {
59
+ const net = await import('node:net');
60
+ for (let port = start; port < start + maxTries; port += 1) {
61
+ const free = await new Promise((resolve) => {
62
+ const tester = net.createServer();
63
+ tester.once('error', () => resolve(false));
64
+ tester.once('listening', () => tester.close(() => resolve(true)));
65
+ tester.listen(port, host);
66
+ });
67
+ if (free)
68
+ return port;
69
+ }
70
+ throw new Error('No free port found in ' + start + '..' + (start + maxTries));
71
+ }
53
72
  export async function startChatServer(opts) {
73
+ /* Port auto-increment: if the requested port is taken (another yf chat is
74
+ * already there), step up to the next free one. Determined BEFORE anything
75
+ * reads opts.port (panel MCP bridge, relay consumer, url), so every consumer
76
+ * sees the real bound port. */
77
+ {
78
+ const requested = opts.port;
79
+ const free = await findFreePort(requested, '127.0.0.1');
80
+ if (free !== requested) {
81
+ try {
82
+ console.error('[yf] puerto ' + requested + ' ocupado -> usando ' + free);
83
+ }
84
+ catch { /* noop */ }
85
+ opts.port = free;
86
+ }
87
+ }
54
88
  const projectName = await readProjectName(opts.projectRoot);
55
89
  /* alpha.59u.2 -- distinct slug (machine id) vs projectName (display).
56
90
  * Identity queries against the semantic graph use the slug so
@@ -345,6 +379,7 @@ export async function startChatServer(opts) {
345
379
  panelPushToken,
346
380
  reanchor,
347
381
  setBrainProvider,
382
+ bridge: bridgeHandle,
348
383
  });
349
384
  }
350
385
  catch (err) {
@@ -364,9 +399,39 @@ export async function startChatServer(opts) {
364
399
  server.keepAliveTimeout = 1000;
365
400
  server.headersTimeout = 5000;
366
401
  server.requestTimeout = 30000;
402
+ /* Listen with EADDRINUSE retry (Pablo 2026-06-19). The pre-probe above wins
403
+ * the common case; this closes the rare race where two instances start at the
404
+ * same instant and both probed the same port as free. On collision we step
405
+ * to the next free port and update opts.port so relay + bridge + url stay
406
+ * correct. */
367
407
  await new Promise((resolve, reject) => {
368
- server.once('error', reject);
369
- server.listen(opts.port, '127.0.0.1', () => resolve());
408
+ let attempts = 0;
409
+ const tryListen = (p) => {
410
+ const onErr = (err) => {
411
+ server.removeListener('listening', onOk);
412
+ if (err && err.code === 'EADDRINUSE' && attempts < 50) {
413
+ attempts += 1;
414
+ findFreePort(p + 1, '127.0.0.1')
415
+ .then((next) => {
416
+ try {
417
+ console.error('[yf] puerto ' + p + ' ocupado -> usando ' + next);
418
+ }
419
+ catch { /* noop */ }
420
+ opts.port = next;
421
+ tryListen(next);
422
+ })
423
+ .catch(reject);
424
+ }
425
+ else {
426
+ reject(err);
427
+ }
428
+ };
429
+ const onOk = () => { server.removeListener('error', onErr); resolve(); };
430
+ server.once('error', onErr);
431
+ server.once('listening', onOk);
432
+ server.listen(p, '127.0.0.1');
433
+ };
434
+ tryListen(opts.port);
370
435
  });
371
436
  /* P2 -- relay command consumer (inbound phone chat) + the board ctx.
372
437
  * Start the consumer ALWAYS: it self-gates on relayEnabled() every tick,
@@ -379,6 +444,34 @@ export async function startChatServer(opts) {
379
444
  catch {
380
445
  commandConsumerHandle = null;
381
446
  }
447
+ /* Inter-instance coordination bridge (Pablo 2026-06-19). The first yf chat
448
+ * hosts a shared bus on a fixed port; the rest subscribe. Never blocks boot:
449
+ * any failure leaves bridgeHandle null and the panel bridge endpoints report
450
+ * "off". */
451
+ let bridgeHandle = null;
452
+ /* Skip under vitest (cross-process port 4836 contention between test workers)
453
+ * and when explicitly disabled. The bridge has its own dedicated unit test. */
454
+ const bridgeDisabled = process.env.VITEST != null || process.env.YF_BRIDGE_DISABLED === '1';
455
+ if (!bridgeDisabled) {
456
+ try {
457
+ const { startBridge } = await import('./bridge.js');
458
+ const selfId = projectSlug + '-' + opts.port + '-' + process.pid;
459
+ bridgeHandle = await startBridge({
460
+ id: selfId,
461
+ project_name: projectName,
462
+ project_slug: projectSlug,
463
+ panel_port: opts.port,
464
+ pid: process.pid,
465
+ });
466
+ }
467
+ catch (err) {
468
+ bridgeHandle = null;
469
+ try {
470
+ console.error('bridge boot failed (coordination off): ' + (err instanceof Error ? err.message : String(err)));
471
+ }
472
+ catch { /* swallow */ }
473
+ }
474
+ }
382
475
  /* Set the relay board ctx to the active project NOW so a paired phone
383
476
  * shows the project bubble + pizarron immediately -- the ctx used to be
384
477
  * set only on a chat turn or a project switch, so the phone saw nothing
@@ -421,6 +514,7 @@ export async function startChatServer(opts) {
421
514
  return {
422
515
  server,
423
516
  url,
517
+ port: opts.port,
424
518
  store,
425
519
  close: () => new Promise((resolve) => {
426
520
  /* alpha.59w -- stop the component watcher on shutdown so
@@ -435,6 +529,10 @@ export async function startChatServer(opts) {
435
529
  }
436
530
  catch { /* noop */ }
437
531
  }
532
+ /* Leave + tear down the coordination bridge (Pablo 2026-06-19). */
533
+ if (bridgeHandle) {
534
+ Promise.resolve(bridgeHandle.stop()).catch(() => undefined);
535
+ }
438
536
  /* alpha.59z.136 slice 14 -- flush in-flight ingest session
439
537
  * writes BEFORE the server closes so test teardowns that
440
538
  * fs.rm the home directory immediately after do not race
@@ -1201,6 +1299,58 @@ async function route(req, res, ctx) {
1201
1299
  * reads this at boot to decide whether to auto-open the
1202
1300
  * license + brain wizard modal. POST persists "user finished the
1203
1301
  * wizard" or "user dismissed -> do not auto-open again". */
1302
+ /* Inter-instance coordination bridge (Pablo 2026-06-19). The browser polls
1303
+ * /state for the roster + shared messages and POSTs /send to broadcast. */
1304
+ if (req.method === 'GET' && url.pathname === '/api/forge/bridge/state') {
1305
+ if (!ctx.bridge) {
1306
+ sendJson(res, 200, { ok: true, enabled: false });
1307
+ return;
1308
+ }
1309
+ try {
1310
+ const st = await ctx.bridge.state();
1311
+ sendJson(res, 200, {
1312
+ ok: true,
1313
+ enabled: true,
1314
+ role: ctx.bridge.role(),
1315
+ self_id: ctx.bridge.selfId,
1316
+ port: ctx.bridge.port,
1317
+ instances: st.instances,
1318
+ messages: st.messages,
1319
+ });
1320
+ }
1321
+ catch (err) {
1322
+ sendJson(res, 200, { ok: true, enabled: false, error: err instanceof Error ? err.message : String(err) });
1323
+ }
1324
+ return;
1325
+ }
1326
+ if (req.method === 'POST' && url.pathname === '/api/forge/bridge/send') {
1327
+ if (!ctx.bridge) {
1328
+ sendJson(res, 200, { ok: false, enabled: false });
1329
+ return;
1330
+ }
1331
+ const raw = await readBody(req);
1332
+ let body;
1333
+ try {
1334
+ body = JSON.parse(raw);
1335
+ }
1336
+ catch {
1337
+ sendJson(res, 400, { ok: false, error: 'invalid JSON' });
1338
+ return;
1339
+ }
1340
+ const text = String(body.text ?? '').trim();
1341
+ if (!text) {
1342
+ sendJson(res, 400, { ok: false, error: 'empty' });
1343
+ return;
1344
+ }
1345
+ try {
1346
+ await ctx.bridge.send(text);
1347
+ sendJson(res, 200, { ok: true });
1348
+ }
1349
+ catch (err) {
1350
+ sendJson(res, 200, { ok: false, error: err instanceof Error ? err.message : String(err) });
1351
+ }
1352
+ return;
1353
+ }
1204
1354
  if (req.method === 'GET' && url.pathname === '/api/forge/wizard-state') {
1205
1355
  const { readWizardState, markWizardCompleted } = await import('../core/wizard_state.js');
1206
1356
  let state = await readWizardState();