libfx 0.0.13 → 0.0.14

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/node.js CHANGED
@@ -2,14 +2,20 @@ import { access, readFile } from "node:fs/promises";
2
2
  import { closeSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import { Socket } from "node:net";
5
- import { homedir } from "node:os";
5
+ import { homedir, tmpdir } from "node:os";
6
6
  import { isAbsolute, resolve } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import { CoreOutput } from "./core-output.js";
9
9
  import { loadModule, withModuleFailure } from "./wasm-module.js";
10
+ import { fetchCleanupAction, cleanupTimeoutMs, cleanupByteLimit } from "./fetch-cleanup.js";
11
+ import { createDurableAgentFactory, memory, wakeHandler } from "./durable.js";
12
+ import { fxHarness } from "./fx-harness.js";
10
13
  import {
11
- createFxAgent as createWasmAgent,
14
+ createFxEngine as createWasmAgent,
12
15
  createFxTerminal as createWasmTerminal,
16
+ createMemoryPersistence,
17
+ FxFencedError,
18
+ FxJournalVersionError,
13
19
  encodeXtermKeyEvent,
14
20
  fxSdkApiVersion,
15
21
  listModels,
@@ -18,8 +24,8 @@ import {
18
24
  xtermAdapter,
19
25
  } from "./fx-sdk.js";
20
26
 
21
- export { encodeXtermKeyEvent, fxSdkApiVersion, listModels, supportsJspi, xtermAdapter };
22
- export const libfxApiVersion = 2;
27
+ export { createMemoryPersistence, encodeXtermKeyEvent, FxFencedError, FxJournalVersionError, fxSdkApiVersion, listModels, memory, supportsJspi, wakeHandler, xtermAdapter };
28
+ export const libfxApiVersion = 3;
23
29
  const nativeCoreApiVersion = 4;
24
30
 
25
31
  const fetchOperationStale = 0;
@@ -346,14 +352,22 @@ export async function getBackendInfo(value = {}) {
346
352
  }
347
353
 
348
354
  function createNativeCoreRuntime(addon, options) {
349
- const { apiKey, model, effort, fast, ultrafast, gatewayChatUrl } = options;
355
+ const { apiKey, providerName: provider, customProvider, model, effort, fast, ultrafast, gatewayChatUrl } = options;
350
356
  if (ultrafast !== undefined && addon.supportsUltrafast !== true) {
351
357
  const error = new Error("native addon does not support the ultrafast option");
352
358
  error.code = "LIBFX_NATIVE_CAPABILITY_UNAVAILABLE";
353
359
  throw error;
354
360
  }
361
+ const capability = customProvider ? "supportsConfiguredProvider" : { codex: "supportsCodex", grok: "supportsGrok" }[provider];
362
+ if (capability && addon[capability] !== true) {
363
+ const error = new Error(`native addon does not support the ${provider} provider`);
364
+ error.code = "LIBFX_NATIVE_CAPABILITY_UNAVAILABLE";
365
+ throw error;
366
+ }
355
367
  const core = addon.createCore({
356
- apiKey,
368
+ ...(apiKey === undefined ? {} : { apiKey }),
369
+ ...(provider === undefined ? {} : { provider }),
370
+ ...(customProvider === undefined ? {} : { configuredProviders: customProvider.json }),
357
371
  home: options.home ?? homedir(),
358
372
  workspaceRoot: options.workspaceRoot ?? process.cwd(),
359
373
  ...(model === undefined ? {} : { model }),
@@ -383,76 +397,154 @@ function createNativeCoreRuntime(addon, options) {
383
397
  let settled = false;
384
398
  let fetchState = null;
385
399
  const exited = new Promise((resolve) => { exitedResolve = resolve; });
400
+ function refreshFetch(state) {
401
+ if (settled) return;
402
+ if (typeof addon.coreFetchDisposition !== "function") {
403
+ state.active = addon.coreFetchActive(core, state.handle);
404
+ return;
405
+ }
406
+ // One native lock observes consumption and retirement together.
407
+ const disposition = addon.coreFetchDisposition(core, state.handle);
408
+ if (disposition !== 0 && disposition !== 1 && disposition !== 2) {
409
+ throw new Error(`invalid native fetch disposition ${disposition}`);
410
+ }
411
+ state.consumed ||= disposition === 2;
412
+ state.active = disposition === 1;
413
+ }
414
+ function cleanupAction(state) {
415
+ return fetchCleanupAction({ ...state, alive: !settled,
416
+ age: state.drainStarted === null ? 0 : performance.now() - state.drainStarted });
417
+ }
418
+ function beginDrain(state) {
419
+ if (state.drainStarted !== null) return;
420
+ state.drainStarted = performance.now();
421
+ state.timer = setTimeout(() => state.controller.abort(), cleanupTimeoutMs);
422
+ }
386
423
  const abortHostEffects = () => {
387
- fetchState?.controller.abort();
388
- try { addon.abortCoreFetch(core); } catch {}
424
+ if (fetchState) {
425
+ fetchState.canceled = true;
426
+ fetchState.controller.abort();
427
+ }
428
+ if (!settled) { try { addon.abortCoreFetch(core); } catch {} }
389
429
  };
390
430
  const finish = (code, error) => {
391
431
  if (settled) return;
432
+ const state = fetchState;
433
+ if (state) {
434
+ refreshFetch(state);
435
+ state.closing = true;
436
+ state.failed ||= code !== 0 || error !== undefined;
437
+ }
392
438
  settled = true;
393
439
  outputError = error;
394
440
  output.close();
395
- abortHostEffects();
396
- try { addon.destroyCore(core); } catch {}
397
- readySocket.destroy();
398
- void readyClosed.then(() => exitedResolve(code));
441
+ const destroy = () => {
442
+ try { addon.destroyCore(core); } catch {}
443
+ readySocket.destroy();
444
+ void readyClosed.then(() => exitedResolve(code));
445
+ };
446
+ if (state && cleanupAction(state) === "drain") {
447
+ beginDrain(state);
448
+ void state.done.then(destroy);
449
+ } else {
450
+ if (state && cleanupAction(state) !== "done") state.controller.abort();
451
+ destroy();
452
+ }
399
453
  };
400
454
  const pumpFetch = async (request, body) => {
401
455
  const controller = new AbortController();
402
- const state = { handle: request.handle, controller };
456
+ let complete, reader;
457
+ const state = { handle: request.handle, controller, consumed: false, active: true,
458
+ canceled: false, failed: false, closing: false, eof: false, discarded: 0,
459
+ drainStarted: null, timer: null, done: new Promise(resolve => { complete = resolve; }) };
403
460
  fetchState = state;
461
+ let onAbort;
462
+ const aborted = new Promise((_, reject) => {
463
+ onAbort = () => reject(new DOMException("Aborted", "AbortError"));
464
+ controller.signal.addEventListener("abort", onAbort, { once: true });
465
+ });
466
+ void aborted.catch(() => {});
467
+ const wait = task => Promise.race([task, aborted]);
404
468
  try {
405
- const response = await (options.fetch ?? globalThis.fetch)(request.url, {
469
+ const responseTask = Promise.resolve().then(() => (options.fetch ?? globalThis.fetch)(request.url, {
406
470
  method: request.method,
407
471
  headers: new Headers(JSON.parse(request.headers).map(({ name, value }) => [name, value])),
408
472
  body: body.length ? body : undefined,
409
473
  signal: controller.signal,
474
+ })).then(response => {
475
+ if (controller.signal.aborted) {
476
+ void response.body?.cancel().catch(() => {});
477
+ throw new DOMException("Aborted", "AbortError");
478
+ }
479
+ return response;
410
480
  });
411
- const started = addon.startCoreFetchResponse(core, state.handle, response.status);
412
- if (started === fetchOperationStale) return;
413
- if (started !== fetchOperationApplied) throw new Error(`invalid native fetch start result ${started}`);
414
- if (response.body) {
415
- for await (const chunk of response.body) {
416
- const buffer = Buffer.from(chunk);
417
- options.onTransportChunk?.(buffer.length);
418
- let offset = 0;
419
- while (offset < buffer.length) {
420
- const end = Math.min(offset + 64 * 1024, buffer.length);
421
- const pushed = addon.pushCoreFetchResponse(core, state.handle, buffer.subarray(offset, end));
422
- if (pushed === fetchOperationApplied) {
423
- offset = end;
424
- continue;
425
- }
426
- if (pushed === fetchOperationStale) return;
427
- if (pushed !== fetchOperationBackpressure) throw new Error(`invalid native fetch push result ${pushed}`);
428
- await new Promise((resolve) => setTimeout(resolve, 2));
481
+ const response = await wait(responseTask);
482
+ if (settled && !state.consumed) throw new DOMException("Aborted", "AbortError");
483
+ const started = settled ? fetchOperationStale : addon.startCoreFetchResponse(core, state.handle, response.status);
484
+ if (started !== fetchOperationApplied && started !== fetchOperationStale) throw new Error(`invalid native fetch start result ${started}`);
485
+ state.active = started === fetchOperationApplied;
486
+ reader = response.body?.getReader();
487
+ while (reader) {
488
+ refreshFetch(state);
489
+ const action = cleanupAction(state);
490
+ if (action === "abort") { controller.abort(); throw new DOMException("Aborted", "AbortError"); }
491
+ if (action === "drain") beginDrain(state);
492
+ const { done, value } = await wait(reader.read());
493
+ if (done) break;
494
+ const buffer = Buffer.from(value);
495
+ options.onTransportChunk?.(buffer.length);
496
+ let offset = 0;
497
+ while (offset < buffer.length) {
498
+ refreshFetch(state);
499
+ const next = cleanupAction(state);
500
+ if (next === "drain") {
501
+ beginDrain(state);
502
+ state.discarded += buffer.length - offset;
503
+ if (state.discarded >= cleanupByteLimit) controller.abort();
504
+ break;
505
+ }
506
+ if (next !== "forward") { controller.abort(); throw new DOMException("Aborted", "AbortError"); }
507
+ const end = Math.min(offset + 64 * 1024, buffer.length);
508
+ const pushed = addon.pushCoreFetchResponse(core, state.handle, buffer.subarray(offset, end));
509
+ if (pushed === fetchOperationApplied) { offset = end; continue; }
510
+ if (pushed === fetchOperationStale) {
511
+ refreshFetch(state);
512
+ if (cleanupAction(state) === "drain") continue;
513
+ controller.abort(); throw new DOMException("Aborted", "AbortError");
429
514
  }
515
+ if (pushed !== fetchOperationBackpressure) throw new Error(`invalid native fetch push result ${pushed}`);
516
+ await wait(new Promise(resolve => setTimeout(resolve, 2)));
430
517
  }
431
518
  }
432
- const finished = addon.finishCoreFetch(core, state.handle);
433
- if (finished !== fetchOperationApplied && finished !== fetchOperationStale) {
434
- throw new Error(`invalid native fetch finish result ${finished}`);
519
+ state.eof = true;
520
+ if (!settled && state.active) {
521
+ const finished = addon.finishCoreFetch(core, state.handle);
522
+ if (finished !== fetchOperationApplied && finished !== fetchOperationStale) throw new Error(`invalid native fetch finish result ${finished}`);
435
523
  }
436
524
  } catch (error) {
437
- if (error?.name !== "AbortError" || !controller.signal.aborted) {
438
- try {
439
- if (addon.coreFetchActive(core, state.handle)) addon.failCoreFetch(core, state.handle);
440
- } catch {}
525
+ if (!settled && !controller.signal.aborted) {
526
+ state.failed = true;
527
+ try { if (addon.coreFetchActive(core, state.handle)) addon.failCoreFetch(core, state.handle); } catch {}
441
528
  }
442
529
  } finally {
443
- if (fetchState === state) {
444
- fetchState = null;
445
- queueMicrotask(drainReady);
530
+ clearTimeout(state.timer);
531
+ controller.signal.removeEventListener("abort", onAbort);
532
+ if (reader) {
533
+ if (!state.eof) { try { void reader.cancel().catch(() => {}); } catch {} }
534
+ try { reader.releaseLock(); } catch {}
446
535
  }
536
+ complete();
537
+ if (fetchState === state) { fetchState = null; queueMicrotask(drainReady); }
447
538
  }
448
539
  };
449
540
  function drainReady() {
450
541
  if (settled) return;
451
542
  try {
452
543
  if (fetchState) {
453
- if (!fetchState.controller.signal.aborted && !addon.coreFetchActive(core, fetchState.handle)) {
454
- fetchState.controller.abort();
455
- }
544
+ refreshFetch(fetchState);
545
+ const action = cleanupAction(fetchState);
546
+ if (action === "drain") beginDrain(fetchState);
547
+ if (action === "abort") fetchState.controller.abort();
456
548
  } else {
457
549
  // The core hands over JSON metadata and the raw request body separately.
458
550
  const fetchRequest = addon.takeCoreFetch(core);
@@ -546,7 +638,8 @@ async function createWithFallback(surface, nativeMethod, wasmFactory, defaultWas
546
638
  } catch (error) {
547
639
  nativeError = error;
548
640
  if (backend === "native" || error?.code === "LIBFX_MODEL_UNSUPPORTED_FAST" ||
549
- error?.code === "LIBFX_MODEL_UNSUPPORTED_EFFORT") throw error;
641
+ error?.code === "LIBFX_MODEL_UNSUPPORTED_EFFORT" ||
642
+ typeof error?.code === "string" && error.code.startsWith("LIBFX_MODEL_METADATA_")) throw error;
550
643
  }
551
644
  }
552
645
  if (backend === "native") {
@@ -564,7 +657,12 @@ async function createWithFallback(surface, nativeMethod, wasmFactory, defaultWas
564
657
  return wasmFactory({ ...runtimeOptions, wasm: await wasmInput(wasmSource) });
565
658
  }
566
659
 
567
- export async function createFxAgent(options = {}) {
660
+ /**
661
+ * One fx session on the native core, or on WebAssembly when the native
662
+ * addon is unavailable, with no durability of its own. `createFxAgent`
663
+ * runs durable sessions on these.
664
+ */
665
+ export async function createFxEngine(options = {}) {
568
666
  return createWithFallback(
569
667
  "agent",
570
668
  "createCore",
@@ -574,6 +672,56 @@ export async function createFxAgent(options = {}) {
574
672
  );
575
673
  }
576
674
 
675
+ // The durability a session gets when the caller names none: Vercel's World
676
+ // on Vercel, otherwise files in the temporary directory. Each is its own
677
+ // module, loaded only here: a bundler copies the file the URL names and
678
+ // leaves the import itself to Node.
679
+ // Agents created without a durability share one for each place sessions
680
+ // live, as agents passed one durability do, so they can send each other's
681
+ // prompts on.
682
+ const environments = new Map();
683
+ function environmentDurability() {
684
+ const dir = process.env.VERCEL ? null : process.env.FX_SESSIONS_DIR || resolve(tmpdir(), "libfx", "sessions");
685
+ const key = dir ?? "vercel";
686
+ if (!environments.has(key)) {
687
+ environments.set(key, chooseEnvironmentDurability(dir).catch((error) => {
688
+ environments.delete(key);
689
+ throw error;
690
+ }));
691
+ }
692
+ return environments.get(key);
693
+ }
694
+
695
+ async function chooseEnvironmentDurability(dir) {
696
+ const load = (asset) => import(/* webpackIgnore: true */ /* turbopackIgnore: true */ (asset.protocol === "" ? bundledAssetUrl(asset) : asset).href);
697
+ if (dir === null) {
698
+ const { vercel } = await load(new URL("./durable/vercel.mjs", import.meta.url));
699
+ return vercel();
700
+ }
701
+ const { local } = await load(new URL("./durable/local.mjs", import.meta.url));
702
+ return local({ dir });
703
+ }
704
+
705
+ /**
706
+ * An agent whose sessions survive crashes, timeouts and redeployments. It
707
+ * does no I/O until a session is used.
708
+ */
709
+ export const createFxAgent = createDurableAgentFactory({
710
+ name: "createFxAgent",
711
+ label: "fx agent",
712
+ defaultDurability: environmentDurability,
713
+ // One durability per environment, so agents created without one share it.
714
+ defaultShared: true,
715
+ harness: fxHarness({
716
+ createEngine: createFxEngine,
717
+ // An explicit key, then the durability's own credential, such as a
718
+ // Vercel deployment's OIDC token, then the one in the environment.
719
+ defaultApiKey: async (durability) => process.env.AI_GATEWAY_API_KEY ||
720
+ (await Promise.resolve(durability?.gatewayKey?.()).catch(() => undefined)) ||
721
+ process.env.VERCEL_OIDC_TOKEN || undefined,
722
+ }),
723
+ });
724
+
577
725
  export function createFxTerminal(options = {}) {
578
726
  return createWithFallback("terminal", "createFxTerminal", createWasmTerminal, defaultTermWasm, options);
579
727
  }
package/oauth.js ADDED
@@ -0,0 +1,64 @@
1
+ // Pieces the subscription sign-ins share: PKCE secrets, reading a pasted
2
+ // redirect address, and waiting between device code polls.
3
+
4
+ const maxAddressBytes = 16 * 1024;
5
+ const encoder = new TextEncoder();
6
+
7
+ export function signInError(code, message, fields = {}) {
8
+ const error = new Error(message);
9
+ error.code = code;
10
+ return Object.assign(error, fields);
11
+ }
12
+
13
+ const base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
14
+
15
+ /** A random URL-safe secret, for a PKCE verifier or an OAuth state. */
16
+ export const randomSecret = () => base64url(crypto.getRandomValues(new Uint8Array(32)));
17
+
18
+ /** The S256 challenge for a PKCE verifier. */
19
+ export async function pkceChallenge(verifier) {
20
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(verifier))));
21
+ }
22
+
23
+ export const hostFetch = (fetch) => fetch ?? globalThis.fetch?.bind(globalThis);
24
+
25
+ /**
26
+ * Reads the sign-in result from a pasted address: the whole address, its query,
27
+ * or either wrapped in quotes, backticks or angle brackets. `prefix` names the
28
+ * error codes, `issuer` the sign-in page, and `redirectUri` the address the
29
+ * user should have copied.
30
+ */
31
+ export function readSignInAddress(text, { prefix, issuer, redirectUri }) {
32
+ if (typeof text !== "string" || encoder.encode(text).length > maxAddressBytes) {
33
+ throw signInError(`${prefix}_SIGN_IN_INVALID_ADDRESS`, `Paste the address of the page ${issuer} opened after you signed in.`);
34
+ }
35
+ const trimmed = text.trim().replace(/^[`'"<\s]+|[`'">\s]+$/g, "");
36
+ let params;
37
+ try {
38
+ params = new URL(trimmed).searchParams;
39
+ } catch {
40
+ params = new URLSearchParams(trimmed.includes("?") ? trimmed.slice(trimmed.indexOf("?") + 1) : trimmed);
41
+ }
42
+ const error = params.get("error");
43
+ if (error) {
44
+ throw signInError(`${prefix}_SIGN_IN_DENIED`, `${issuer} did not sign you in: ${params.get("error_description") || error}`, { reason: error });
45
+ }
46
+ const code = params.get("code");
47
+ if (!code) {
48
+ throw signInError(
49
+ `${prefix}_SIGN_IN_INVALID_ADDRESS`,
50
+ `That address has no sign-in code. Copy the whole address of the page ${issuer} opened after you signed in; it starts with ${redirectUri}?code=`,
51
+ );
52
+ }
53
+ return { code, state: params.get("state") };
54
+ }
55
+
56
+ /** Resolves after `ms`, or rejects when `signal` aborts first. */
57
+ export function wait(ms, signal) {
58
+ return new Promise((resolve, reject) => {
59
+ if (signal?.aborted) return reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
60
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms);
61
+ const onAbort = () => { clearTimeout(timer); reject(signal.reason ?? new DOMException("Aborted", "AbortError")); };
62
+ signal?.addEventListener("abort", onAbort, { once: true });
63
+ });
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libfx",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "Embed fx agents and terminals in JavaScript hosts",
5
5
  "type": "module",
6
6
  "repository": {
@@ -18,21 +18,74 @@
18
18
  "exports": {
19
19
  ".": {
20
20
  "node": {
21
- "import": "./node.js",
22
- "require": "./node.cjs"
21
+ "import": {
22
+ "types": "./types/node.d.ts",
23
+ "default": "./node.js"
24
+ },
25
+ "require": {
26
+ "types": "./types/node.d.cts",
27
+ "default": "./node.cjs"
28
+ }
23
29
  },
24
- "browser": "./browser.js",
25
- "default": "./browser.js"
30
+ "browser": {
31
+ "types": "./types/browser.d.ts",
32
+ "default": "./browser.js"
33
+ },
34
+ "default": {
35
+ "types": "./types/browser.d.ts",
36
+ "default": "./browser.js"
37
+ }
26
38
  },
27
39
  "./node": {
28
- "import": "./node.js",
29
- "require": "./node.cjs"
30
- },
31
- "./browser": "./browser.js",
32
- "./wasm": "./fx-sdk.js",
33
- "./mcp": "./mcp.js",
34
- "./skills": "./skills.js",
35
- "./skills/node": "./skills-node.js"
40
+ "import": {
41
+ "types": "./types/node.d.ts",
42
+ "default": "./node.js"
43
+ },
44
+ "require": {
45
+ "types": "./types/node.d.cts",
46
+ "default": "./node.cjs"
47
+ }
48
+ },
49
+ "./browser": {
50
+ "types": "./types/browser.d.ts",
51
+ "default": "./browser.js"
52
+ },
53
+ "./wasm": {
54
+ "types": "./types/wasm.d.ts",
55
+ "default": "./fx-sdk.js"
56
+ },
57
+ "./durable-local": {
58
+ "types": "./types/durable-local.d.ts",
59
+ "default": "./durable/local.mjs"
60
+ },
61
+ "./durable-vercel": {
62
+ "types": "./types/durable-vercel.d.ts",
63
+ "default": "./durable/vercel.mjs"
64
+ },
65
+ "./durable-world": {
66
+ "types": "./types/durable-world.d.ts",
67
+ "default": "./durable/world.mjs"
68
+ },
69
+ "./chatgpt": {
70
+ "types": "./types/chatgpt.d.ts",
71
+ "default": "./chatgpt-sign-in.js"
72
+ },
73
+ "./grok": {
74
+ "types": "./types/grok.d.ts",
75
+ "default": "./grok-sign-in.js"
76
+ },
77
+ "./mcp": {
78
+ "types": "./types/mcp.d.ts",
79
+ "default": "./mcp.js"
80
+ },
81
+ "./skills": {
82
+ "types": "./types/skills.d.ts",
83
+ "default": "./skills.js"
84
+ },
85
+ "./skills/node": {
86
+ "types": "./types/skills-node.d.ts",
87
+ "default": "./skills-node.js"
88
+ }
36
89
  },
37
90
  "engines": {
38
91
  "node": ">=20"