@nac3/forge-cli 1.0.34 → 1.0.38

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.
@@ -1918,6 +1918,28 @@ async function route(req, res, ctx) {
1918
1918
  await handleBrainConfigSet(req, res, ctx);
1919
1919
  return;
1920
1920
  }
1921
+ /* F4 brain federation -- connect to another Forge's brain, mint
1922
+ authorize/route codes, host the answer loop, all from the panel. */
1923
+ if (req.method === 'GET' && url.pathname === '/api/forge/federation') {
1924
+ await handleFederationGet(req, res);
1925
+ return;
1926
+ }
1927
+ if (req.method === 'POST' && url.pathname === '/api/forge/federation/join') {
1928
+ await handleFederationJoin(req, res);
1929
+ return;
1930
+ }
1931
+ if (req.method === 'POST' && url.pathname === '/api/forge/federation/leave') {
1932
+ await handleFederationLeave(req, res);
1933
+ return;
1934
+ }
1935
+ if (req.method === 'POST' && url.pathname === '/api/forge/federation/invite') {
1936
+ await handleFederationInvite(req, res);
1937
+ return;
1938
+ }
1939
+ if (req.method === 'POST' && url.pathname === '/api/forge/federation/host') {
1940
+ await handleFederationHost(req, res);
1941
+ return;
1942
+ }
1921
1943
  /* PND-069 -- modelos disponibles por provider (API en vivo con
1922
1944
  fallback estatico). */
1923
1945
  if (req.method === 'GET' && url.pathname === '/api/forge/brain-models') {
@@ -3208,6 +3230,192 @@ async function handleBrainConfigSet(req, res, ctx) {
3208
3230
  restart_needed_for: applied_live ? [] : ['anthropic_mode'],
3209
3231
  });
3210
3232
  }
3233
+ /* ============================================================
3234
+ * F4 brain federation -- settings-panel handlers (2026-06-21).
3235
+ *
3236
+ * The federation protocol (client/host/broker/CLI) already exists;
3237
+ * this exposes it in the panel so a non-terminal user can: connect
3238
+ * to another Forge's brain (child join), mint an authorize/route
3239
+ * code (parent invite), see status, leave, and toggle hosting. The
3240
+ * host answer-loop runs INSIDE the panel process while the toggle is
3241
+ * on; closing the panel (process exit) stops it.
3242
+ *
3243
+ * Routing is automatic: once federation.json is active, cost_router's
3244
+ * maybeApplyFederation() points every turn at the parent's brain, so
3245
+ * join only has to write the config + vault key (CLI parity).
3246
+ * ============================================================ */
3247
+ const federationHostRuntime = {
3248
+ running: false, controller: null,
3249
+ };
3250
+ /** Mask the child id in a fed handle for display: fed:<parent>:<id> -> fed:<parent>:<id6>... */
3251
+ function maskFedHandle(fedHandle) {
3252
+ const i = fedHandle.lastIndexOf(':');
3253
+ if (i < 0)
3254
+ return fedHandle;
3255
+ return fedHandle.slice(0, i + 1) + fedHandle.slice(i + 1, i + 7) + '...';
3256
+ }
3257
+ /** GET /api/forge/federation -- current child link, parent invite
3258
+ * capability, and host-loop state. */
3259
+ async function handleFederationGet(_req, res) {
3260
+ const { readFederationConfig, getFederationKey } = await import('../core/federation_config.js');
3261
+ const { resolveParentAuth } = await import('../llm/federation_host.js');
3262
+ const cfg = await readFederationConfig();
3263
+ const keyPresent = (await getFederationKey()) !== null;
3264
+ const auth = await resolveParentAuth();
3265
+ sendJson(res, 200, {
3266
+ ok: true,
3267
+ child: cfg ? {
3268
+ active: cfg.active,
3269
+ parent_handle: cfg.parent_handle,
3270
+ fed_handle: maskFedHandle(cfg.fed_handle),
3271
+ base_url: cfg.base_url,
3272
+ joined_at: cfg.joined_at,
3273
+ key_present: keyPresent,
3274
+ } : null,
3275
+ parent: { can_invite: !!auth, handle: auth ? auth.handle : null },
3276
+ host: { running: federationHostRuntime.running },
3277
+ });
3278
+ }
3279
+ /** POST /api/forge/federation/join { token } -- redeem a parent's
3280
+ * code; from now on every turn routes to the parent's brain. */
3281
+ async function handleFederationJoin(req, res) {
3282
+ const { setFederationKey, writeFederationConfig } = await import('../core/federation_config.js');
3283
+ const { HITO4_BASE_URL } = await import('../license/hito4_client.js');
3284
+ let body;
3285
+ try {
3286
+ body = JSON.parse(await readBody(req));
3287
+ }
3288
+ catch {
3289
+ sendJson(res, 400, { ok: false, error: 'invalid json body' });
3290
+ return;
3291
+ }
3292
+ const token = typeof body.token === 'string' ? body.token.trim() : '';
3293
+ if (!token) {
3294
+ sendJson(res, 400, { ok: false, error: 'falta el codigo de federacion' });
3295
+ return;
3296
+ }
3297
+ const baseUrl = (typeof body.base_url === 'string' && body.base_url ? body.base_url : HITO4_BASE_URL).replace(/\/+$/, '');
3298
+ let r;
3299
+ try {
3300
+ r = await fetch(baseUrl + '/v1/federation/redeem', {
3301
+ method: 'POST', headers: { 'content-type': 'application/json' },
3302
+ body: JSON.stringify({ token }),
3303
+ });
3304
+ }
3305
+ catch (e) {
3306
+ sendJson(res, 502, { ok: false, error: 'no pude contactar el broker: ' + (e instanceof Error ? e.message : String(e)) });
3307
+ return;
3308
+ }
3309
+ const j = await r.json().catch(() => ({}));
3310
+ if (!r.ok || !j.ok || !j.fed_handle || !j.fed_key || !j.parent_handle) {
3311
+ sendJson(res, r.status === 400 ? 400 : 502, {
3312
+ ok: false,
3313
+ error: (j.error || ('HTTP ' + r.status)) + (r.status === 400 ? ' (codigo invalido o vencido)' : ''),
3314
+ });
3315
+ return;
3316
+ }
3317
+ await setFederationKey(j.fed_key);
3318
+ await writeFederationConfig({
3319
+ active: true,
3320
+ parent_handle: j.parent_handle,
3321
+ fed_handle: j.fed_handle,
3322
+ base_url: baseUrl,
3323
+ joined_at: new Date().toISOString(),
3324
+ });
3325
+ sendJson(res, 200, { ok: true, parent_handle: j.parent_handle });
3326
+ }
3327
+ /** POST /api/forge/federation/leave -- drop the link; route locally. */
3328
+ async function handleFederationLeave(_req, res) {
3329
+ const { readFederationConfig, clearFederationConfig } = await import('../core/federation_config.js');
3330
+ const cfg = await readFederationConfig();
3331
+ await clearFederationConfig();
3332
+ sendJson(res, 200, { ok: true, was_federated: !!cfg, parent_handle: cfg ? cfg.parent_handle : null });
3333
+ }
3334
+ /** POST /api/forge/federation/invite { ttl_seconds? } -- PARENT mints a
3335
+ * single-use authorize/route code to lend its brain to a child. */
3336
+ async function handleFederationInvite(req, res) {
3337
+ const { resolveParentAuth } = await import('../llm/federation_host.js');
3338
+ const { HITO4_BASE_URL, signBearer } = await import('../license/hito4_client.js');
3339
+ const auth = await resolveParentAuth();
3340
+ if (!auth) {
3341
+ sendJson(res, 400, { ok: false, error: 'Necesitas una licencia activa (handle provisto) para prestar tu brain.' });
3342
+ return;
3343
+ }
3344
+ let body = {};
3345
+ try {
3346
+ body = JSON.parse(await readBody(req));
3347
+ }
3348
+ catch { /* empty body ok */ }
3349
+ const baseUrl = (typeof body.base_url === 'string' && body.base_url ? body.base_url : HITO4_BASE_URL).replace(/\/+$/, '');
3350
+ const payload = {};
3351
+ if (Number.isFinite(Number(body.ttl_seconds)))
3352
+ payload.ttl_seconds = Number(body.ttl_seconds);
3353
+ let r;
3354
+ try {
3355
+ r = await fetch(baseUrl + '/v1/federation/invite', {
3356
+ method: 'POST',
3357
+ headers: {
3358
+ 'authorization': 'Bearer ' + signBearer(auth.secret, auth.handle),
3359
+ 'x-yf-user-handle': auth.handle,
3360
+ 'content-type': 'application/json',
3361
+ },
3362
+ body: JSON.stringify(payload),
3363
+ });
3364
+ }
3365
+ catch (e) {
3366
+ sendJson(res, 502, { ok: false, error: 'no pude contactar el broker: ' + (e instanceof Error ? e.message : String(e)) });
3367
+ return;
3368
+ }
3369
+ const j = await r.json().catch(() => ({}));
3370
+ if (!r.ok || !j.ok || !j.token) {
3371
+ sendJson(res, 502, { ok: false, error: j.error || ('HTTP ' + r.status) });
3372
+ return;
3373
+ }
3374
+ sendJson(res, 200, { ok: true, token: j.token, expires_in: j.expires_in ?? 1800 });
3375
+ }
3376
+ /** POST /api/forge/federation/host { enabled } -- start/stop the answer
3377
+ * loop inside the panel process so children get this brain's replies. */
3378
+ async function handleFederationHost(req, res) {
3379
+ let body = {};
3380
+ try {
3381
+ body = JSON.parse(await readBody(req));
3382
+ }
3383
+ catch { /* default */ }
3384
+ const enable = body.enabled === true;
3385
+ if (enable) {
3386
+ if (federationHostRuntime.running) {
3387
+ sendJson(res, 200, { ok: true, running: true });
3388
+ return;
3389
+ }
3390
+ const { resolveParentAuth, runFederationHost } = await import('../llm/federation_host.js');
3391
+ const auth = await resolveParentAuth();
3392
+ if (!auth) {
3393
+ sendJson(res, 400, { ok: false, error: 'Necesitas una licencia activa para hospedar.' });
3394
+ return;
3395
+ }
3396
+ const controller = new AbortController();
3397
+ federationHostRuntime.controller = controller;
3398
+ federationHostRuntime.running = true;
3399
+ /* Fire-and-forget: the loop runs until aborted (panel close or toggle
3400
+ * off). Reset the flag if it ever returns/throws on its own. */
3401
+ void runFederationHost({ signal: controller.signal })
3402
+ .catch(() => undefined)
3403
+ .finally(() => {
3404
+ if (federationHostRuntime.controller === controller) {
3405
+ federationHostRuntime.running = false;
3406
+ federationHostRuntime.controller = null;
3407
+ }
3408
+ });
3409
+ sendJson(res, 200, { ok: true, running: true });
3410
+ return;
3411
+ }
3412
+ /* Disable. */
3413
+ if (federationHostRuntime.controller)
3414
+ federationHostRuntime.controller.abort();
3415
+ federationHostRuntime.running = false;
3416
+ federationHostRuntime.controller = null;
3417
+ sendJson(res, 200, { ok: true, running: false });
3418
+ }
3211
3419
  /** PND-064 -- POST /api/forge/panel-invoke. Paridad total de tools
3212
3420
  * en modo Plan: el MCP server local reenvia aca cada nac3_invoke
3213
3421
  * del subproceso `claude -p`. Corre el MISMO dispatcher que el
@@ -4714,6 +4922,19 @@ async function handleChat(req, res, ctx) {
4714
4922
  const chatRequest = {
4715
4923
  messages: turnMessages,
4716
4924
  signal: turnSignal,
4925
+ /* Path A (2026-06-22) -- thread the SSE emitter so the brain client
4926
+ * streams the model's inter-round prose (assistant_round_text) to the
4927
+ * panel as a running commentary. The CLI path already gets this via
4928
+ * the callerForChat wrapper; this closes the parity gap for the HTTP/
4929
+ * unified (Console BYOK + managed) path. wrap() forwards it to the
4930
+ * underlying ClaudeClient; non-Anthropic clients simply ignore it. */
4931
+ onStreamEvent: sseEmit,
4932
+ /* Pendiente B (2026-06-22) -- token streaming. Only when the panel
4933
+ * opened an SSE stream (streamMode); the brain client then emits
4934
+ * assistant_delta per fragment so the panel writes token-by-token.
4935
+ * Non-stream callers (mobile loopback, internal) keep the buffered
4936
+ * path. The CLI provider ignores it (it streams via the subprocess). */
4937
+ stream: streamMode,
4717
4938
  /* Pablo 2026-06-15 -- the model MUST follow the brain config in
4718
4939
  * BOTH paths. Console (unified client) already bakes it into the
4719
4940
  * client; Plan (`claude -p`) reads it per turn -> without this the