@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.
- package/dist/chat/bridge.d.ts +43 -0
- package/dist/chat/bridge.d.ts.map +1 -0
- package/dist/chat/bridge.js +239 -0
- package/dist/chat/bridge.js.map +1 -0
- package/dist/chat/panel.d.ts.map +1 -1
- package/dist/chat/panel.js +108 -0
- package/dist/chat/panel.js.map +1 -1
- package/dist/chat/server.d.ts +4 -0
- package/dist/chat/server.d.ts.map +1 -1
- package/dist/chat/server.js +152 -2
- package/dist/chat/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/chat/server.js
CHANGED
|
@@ -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
|
-
|
|
369
|
-
|
|
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();
|