nanocodex 0.2.0 → 0.3.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.
package/internal.mjs CHANGED
@@ -1,5 +1,6 @@
1
- const AGENT_STATE = Symbol("nanocodex.agent");
2
- const TURN_STATE = Symbol("nanocodex.turn");
1
+ const agentStates = new WeakMap();
2
+ const turnStates = new WeakMap();
3
+ const resultStates = new WeakMap();
3
4
  const hostSessions = new Map();
4
5
  const hostConnections = new Map();
5
6
  let activeHost;
@@ -41,12 +42,18 @@ export function prompt(agent, options) {
41
42
 
42
43
  export function getTurnResult(turn) {
43
44
  const state = turnState(turn);
44
- state.result ||= Promise.resolve().then(() => state.raw.result());
45
+ state.result ||= Promise.resolve()
46
+ .then(() => state.raw.result())
47
+ .then(createTurnResult);
45
48
  return state.result;
46
49
  }
47
50
 
48
- export function getTurnSnapshot(turn) {
49
- return JSON.parse(turnState(turn).raw.snapshot());
51
+ export function getTurnSnapshot(result) {
52
+ return resultState(result).snapshot();
53
+ }
54
+
55
+ export function getTurnUsage(result) {
56
+ return resultState(result).usage();
50
57
  }
51
58
 
52
59
  export function steer(turn, options) {
@@ -66,7 +73,7 @@ export async function fork(agent, options) {
66
73
  const at = options?.at;
67
74
  const raw = at === undefined
68
75
  ? await state.raw.fork()
69
- : await state.raw.forkFrom(turnState(at).raw);
76
+ : await state.raw.forkFrom(resultState(at).raw);
70
77
  return createAgent(raw, state.runtime);
71
78
  }
72
79
 
@@ -83,7 +90,23 @@ export function setFastMode(agent, enabled) {
83
90
  return agentState(agent).raw.setFastMode(enabled);
84
91
  }
85
92
 
86
- export function subscribeAgentEvents(agent, listener, options = {}) {
93
+ export function compact(agent) {
94
+ return agentState(agent).raw.compact();
95
+ }
96
+
97
+ export async function shutdown(agent) {
98
+ const state = knownAgentState(agent);
99
+ if (state.shutdownPromise) return state.shutdownPromise;
100
+ if (state.disposed) throw new Error("the Nanocodex agent has been disposed");
101
+ if (typeof state.raw.shutdown !== "function") {
102
+ throw new Error("this Nanocodex runtime does not expose graceful shutdown");
103
+ }
104
+ state.disposed = true;
105
+ state.shutdownPromise = joinAgentShutdown(state);
106
+ return state.shutdownPromise;
107
+ }
108
+
109
+ export function subscribeAgentEvents(agent, listener, options = {}, onRelease) {
87
110
  const state = agentState(agent);
88
111
  if (typeof state.runtime.subscribe !== "function") {
89
112
  throw new Error("this Nanocodex runtime does not expose agent events");
@@ -91,11 +114,25 @@ export function subscribeAgentEvents(agent, listener, options = {}) {
91
114
  if (typeof listener !== "function") {
92
115
  throw new TypeError("watchAgentEvents requires a listener");
93
116
  }
94
- return state.runtime.subscribe((event) => {
117
+ const unsubscribe = state.runtime.subscribe((event, encodedLength) => {
95
118
  if (options.includeAllSessions || !event?.request_id || event.request_id === agent.sessionId) {
96
- listener(event);
119
+ listener(event, encodedLength);
97
120
  }
98
121
  });
122
+ let active = true;
123
+ const subscription = {
124
+ close(notify) {
125
+ if (!active) return;
126
+ active = false;
127
+ state.subscriptions.delete(subscription);
128
+ const errors = [];
129
+ runCleanup(errors, () => unsubscribe?.());
130
+ if (notify) runCleanup(errors, () => onRelease?.());
131
+ throwCleanupErrors(errors);
132
+ },
133
+ };
134
+ state.subscriptions.add(subscription);
135
+ return () => subscription.close(false);
99
136
  }
100
137
 
101
138
  export function toWasmConfig(options = {}) {
@@ -111,6 +148,7 @@ export function toWasmConfig(options = {}) {
111
148
  copy(config, "api_base_url", options.apiBaseUrl);
112
149
  copy(config, "instructions", options.instructions);
113
150
  copy(config, "session_id", options.sessionId);
151
+ copy(config, "workspace", options.workspace);
114
152
  copy(config, "resume", options.resume);
115
153
  return config;
116
154
  }
@@ -119,8 +157,10 @@ export function createEventChannel() {
119
157
  const listeners = new Set();
120
158
  return Object.freeze({
121
159
  emit(eventJson) {
160
+ if (!listeners.size) return;
122
161
  const event = typeof eventJson === "string" ? JSON.parse(eventJson) : eventJson;
123
- for (const listener of listeners) listener(event);
162
+ const encodedLength = typeof eventJson === "string" ? eventJson.length : undefined;
163
+ for (const listener of listeners) listener(event, encodedLength);
124
164
  },
125
165
  subscribe(listener) {
126
166
  listeners.add(listener);
@@ -150,9 +190,18 @@ export function releaseHostSession(host, sessionId) {
150
190
  }
151
191
 
152
192
  const hostBridge = Object.freeze({
153
- async connect(endpoint, apiKey, sessionId) {
193
+ async connect(endpoint, apiKey, accountId, fedramp, sessionId, turnState) {
154
194
  const host = requiredSessionHost(sessionId);
155
- const result = JSON.parse(await host.connect(endpoint, apiKey, sessionId));
195
+ let result;
196
+ try {
197
+ result = JSON.parse(await host.connect(endpoint, apiKey, sessionId, {
198
+ accountId: accountId ?? undefined,
199
+ fedramp,
200
+ turnState: turnState ?? undefined,
201
+ }));
202
+ } catch (error) {
203
+ throw JSON.stringify(connectFailure(error));
204
+ }
156
205
  const handle = nextHostConnection++;
157
206
  hostConnections.set(handle, { host, handle: result.handle });
158
207
  hostSessions.set(sessionId, host);
@@ -176,8 +225,12 @@ const hostBridge = Object.freeze({
176
225
  hostConnections.delete(handle);
177
226
  connection.host.close(connection.handle);
178
227
  },
179
- sleep(milliseconds) {
180
- return new Promise((resolve) => setTimeout(resolve, milliseconds));
228
+ sleep(sessionId, milliseconds) {
229
+ const host = requiredSessionHost(sessionId);
230
+ if (typeof host.sleep !== "function") {
231
+ throw new TypeError("the selected Nanocodex host must define sleep(milliseconds)");
232
+ }
233
+ return host.sleep(milliseconds);
181
234
  },
182
235
  executeCode(source, sessionId, callId) {
183
236
  return requiredSessionHost(sessionId).executeCode(source, sessionId, callId);
@@ -202,6 +255,9 @@ function createAgent(raw, runtime) {
202
255
  raw,
203
256
  runtime,
204
257
  disposed: false,
258
+ released: false,
259
+ shutdownPromise: undefined,
260
+ subscriptions: new Set(),
205
261
  sessionId: raw.sessionId,
206
262
  uid: `agent-${nextAgentUid++}`,
207
263
  };
@@ -234,14 +290,12 @@ function agentView(state, extensions) {
234
290
  return agentView(state, deepMerge(extensions, extension));
235
291
  },
236
292
  dispose() {
237
- if (state.disposed) return;
238
- state.disposed = true;
239
- state.runtime.release?.(state.raw);
240
- state.runtime.dispose(state.raw);
293
+ if (state.shutdownPromise) return;
294
+ releaseAgentState(state);
241
295
  },
242
296
  };
243
297
  agent = Object.assign(base, extensions);
244
- Object.defineProperty(agent, AGENT_STATE, { value: state });
298
+ agentStates.set(agent, state);
245
299
  return agent;
246
300
  }
247
301
 
@@ -251,11 +305,87 @@ function requiredSessionHost(sessionId) {
251
305
  return host;
252
306
  }
253
307
 
308
+ function releaseAgentState(state) {
309
+ if (state.released) return;
310
+ state.disposed = true;
311
+ state.released = true;
312
+ const errors = [];
313
+ for (const subscription of [...state.subscriptions]) {
314
+ runCleanup(errors, () => subscription.close(true));
315
+ }
316
+ runCleanup(errors, () => state.runtime.release?.(state.raw));
317
+ runCleanup(errors, () => state.runtime.dispose(state.raw));
318
+ throwCleanupErrors(errors);
319
+ }
320
+
321
+ async function joinAgentShutdown(state) {
322
+ await Promise.resolve();
323
+ let shutdownFailed = false;
324
+ let shutdownError;
325
+ try {
326
+ await state.raw.shutdown();
327
+ } catch (error) {
328
+ shutdownFailed = true;
329
+ shutdownError = error;
330
+ }
331
+
332
+ let cleanupFailed = false;
333
+ let cleanupError;
334
+ try {
335
+ releaseAgentState(state);
336
+ } catch (error) {
337
+ cleanupFailed = true;
338
+ cleanupError = error;
339
+ }
340
+
341
+ if (shutdownFailed && cleanupFailed) {
342
+ const cleanupErrors = cleanupError instanceof AggregateError
343
+ ? cleanupError.errors
344
+ : [cleanupError];
345
+ throw new AggregateError(
346
+ [shutdownError, ...cleanupErrors],
347
+ "Nanocodex driver shutdown and resource release both failed",
348
+ );
349
+ }
350
+ if (shutdownFailed) throw shutdownError;
351
+ if (cleanupFailed) throw cleanupError;
352
+ }
353
+
254
354
  function requiredActiveHost() {
255
355
  if (!activeHost) throw new Error("no Nanocodex host is active");
256
356
  return activeHost;
257
357
  }
258
358
 
359
+ function connectFailure(error) {
360
+ if (typeof error === "string") {
361
+ try {
362
+ const encoded = JSON.parse(error);
363
+ if (encoded?.kind === "handshake_rejected" || encoded?.kind === "transport") {
364
+ return encoded;
365
+ }
366
+ } catch {}
367
+ }
368
+ const status = Number(error?.status);
369
+ if (Number.isInteger(status) && status >= 100 && status <= 599) {
370
+ const retryAfter = Number(error?.retryAfter);
371
+ return {
372
+ kind: "handshake_rejected",
373
+ status,
374
+ body: typeof error?.body === "string" ? error.body : errorDetail(error),
375
+ ...(Number.isFinite(retryAfter) && retryAfter >= 0 ? { retry_after: retryAfter } : {}),
376
+ };
377
+ }
378
+ return {
379
+ kind: "transport",
380
+ detail: errorDetail(error),
381
+ reconnectable: true,
382
+ };
383
+ }
384
+
385
+ function errorDetail(error) {
386
+ return error && (error.stack || error.message) || String(error);
387
+ }
388
+
259
389
  function createTurn(raw, agent) {
260
390
  if (!raw || typeof raw.result !== "function") {
261
391
  throw new TypeError("the runtime returned an invalid Nanocodex turn handle");
@@ -264,7 +394,6 @@ function createTurn(raw, agent) {
264
394
  const turn = {
265
395
  get agent() { return state.agent; },
266
396
  result: () => getTurnResult(turn),
267
- snapshot: () => getTurnSnapshot(turn),
268
397
  steer: (input) => steer(turn, input),
269
398
  cancel: () => cancel(turn),
270
399
  dispose() {
@@ -273,24 +402,67 @@ function createTurn(raw, agent) {
273
402
  state.raw.free();
274
403
  },
275
404
  };
276
- Object.defineProperty(turn, TURN_STATE, { value: state });
405
+ turnStates.set(turn, state);
277
406
  return Object.freeze(turn);
278
407
  }
279
408
 
409
+ function createTurnResult(raw) {
410
+ if (
411
+ !raw
412
+ || typeof raw.finalMessage !== "string"
413
+ || typeof raw.snapshot !== "function"
414
+ || typeof raw.usage !== "function"
415
+ ) {
416
+ raw?.free?.();
417
+ throw new TypeError("the runtime returned an invalid Nanocodex turn result");
418
+ }
419
+ const state = {
420
+ raw,
421
+ snapshotValue: undefined,
422
+ usageValue: undefined,
423
+ snapshot() {
424
+ state.snapshotValue ||= freezeJson(JSON.parse(raw.snapshot()));
425
+ return state.snapshotValue;
426
+ },
427
+ usage() {
428
+ state.usageValue ||= freezeJson(JSON.parse(raw.usage()));
429
+ return state.usageValue;
430
+ },
431
+ };
432
+ const result = {
433
+ finalMessage: raw.finalMessage,
434
+ get snapshot() { return state.snapshot(); },
435
+ get usage() { return state.usage(); },
436
+ };
437
+ resultStates.set(result, state);
438
+ return Object.freeze(result);
439
+ }
440
+
280
441
  function agentState(agent) {
281
- const state = agent?.[AGENT_STATE];
282
- if (!state) throw new TypeError("expected a Nanocodex agent");
442
+ const state = knownAgentState(agent);
283
443
  if (state.disposed) throw new Error("the Nanocodex agent has been disposed");
284
444
  return state;
285
445
  }
286
446
 
447
+ function knownAgentState(agent) {
448
+ const state = agentStates.get(agent);
449
+ if (!state) throw new TypeError("expected a Nanocodex agent");
450
+ return state;
451
+ }
452
+
287
453
  function turnState(turn) {
288
- const state = turn?.[TURN_STATE];
454
+ const state = turnStates.get(turn);
289
455
  if (!state) throw new TypeError("expected a Nanocodex turn");
290
456
  if (state.disposed) throw new Error("the Nanocodex turn has been disposed");
291
457
  return state;
292
458
  }
293
459
 
460
+ function resultState(result) {
461
+ const state = resultStates.get(result);
462
+ if (!state) throw new TypeError("expected a completed Nanocodex turn result");
463
+ return state;
464
+ }
465
+
294
466
  function actionInput(options) {
295
467
  const input = options?.input;
296
468
  if (typeof input !== "string" && !Array.isArray(input)) {
@@ -316,3 +488,32 @@ function isObject(value) {
316
488
  function copy(target, key, value) {
317
489
  if (value !== undefined) target[key] = value;
318
490
  }
491
+
492
+ function runCleanup(errors, cleanup) {
493
+ try {
494
+ cleanup();
495
+ } catch (error) {
496
+ errors.push(error);
497
+ }
498
+ }
499
+
500
+ function throwCleanupErrors(errors) {
501
+ if (errors.length === 1) throw errors[0];
502
+ if (errors.length > 1) {
503
+ throw new AggregateError(errors, "multiple Nanocodex resources failed to release");
504
+ }
505
+ }
506
+
507
+ function freezeJson(value) {
508
+ if (!value || typeof value !== "object") return value;
509
+ const pending = [value];
510
+ while (pending.length) {
511
+ const current = pending.pop();
512
+ if (Object.isFrozen(current)) continue;
513
+ for (const child of Object.values(current)) {
514
+ if (child && typeof child === "object" && !Object.isFrozen(child)) pending.push(child);
515
+ }
516
+ Object.freeze(current);
517
+ }
518
+ return value;
519
+ }
package/node/Agent.d.mts CHANGED
@@ -12,12 +12,7 @@ export function create(options: create.Options): Promise<create.ReturnType>;
12
12
  export declare namespace create {
13
13
  type Options = AgentOptions & ({ apiKey: string; mpp?: never } | { apiKey?: never; mpp: MppSession }) & {
14
14
  apiBaseUrl?: string | undefined;
15
- connectTimeoutMs?: number | undefined;
16
- maxFrameBytes?: number | undefined;
17
- maxQueuedBytes?: number | undefined;
18
- maxQueuedMessages?: number | undefined;
19
15
  module?: unknown;
20
- sendTimeoutMs?: number | undefined;
21
16
  tools?: ToolMap | undefined;
22
17
  websocketUrl?: string | undefined;
23
18
  };
package/node/Agent.mjs CHANGED
@@ -23,19 +23,25 @@ export function create(options = {}) {
23
23
  fastMode,
24
24
  instructions,
25
25
  sessionId,
26
+ workspace,
26
27
  resume,
27
28
  apiKey,
28
29
  mpp,
29
30
  websocketUrl,
30
31
  apiBaseUrl,
31
32
  module,
32
- ...hostOptions
33
+ tools,
33
34
  } = options;
34
35
  const events = createEventChannel();
35
36
  if (mpp !== undefined && apiKey !== undefined) {
36
37
  throw new TypeError("apiKey and mpp are mutually exclusive");
37
38
  }
38
- const host = createNodeHost({ ...hostOptions, mpp, onEvent: events.emit });
39
+ const host = createNodeHost({
40
+ mpp,
41
+ onEvent: events.emit,
42
+ tools,
43
+ workspace: workspace ?? resume?.workspace,
44
+ });
39
45
  activateHost(host);
40
46
  const runtime = defineRuntime({
41
47
  key: "node-wasm",
@@ -67,6 +73,7 @@ export function create(options = {}) {
67
73
  fastMode,
68
74
  instructions,
69
75
  sessionId,
76
+ workspace,
70
77
  resume,
71
78
  });
72
79
  }
package/node/host.mjs CHANGED
@@ -1,10 +1,13 @@
1
1
  import { Console } from "node:console";
2
2
  import { createRequire } from "node:module";
3
+ import { resolve } from "node:path";
3
4
  import WebSocket from "ws";
5
+ import packageMetadata from "../package.json" with { type: "json" };
4
6
 
5
7
  import { createCodeRuntime } from "../runtime/code-runtime.mjs";
6
8
 
7
9
  const RESPONSES_WEBSOCKETS_BETA = "responses_websockets=2026-02-06";
10
+ const USER_AGENT = `nanocodex-wasm/${packageMetadata.version}`;
8
11
  const DEFAULT_MAX_QUEUED_MESSAGES = 4_096;
9
12
  const DEFAULT_MAX_QUEUED_BYTES = 32 * 1024 * 1024;
10
13
  const DEFAULT_MAX_FRAME_BYTES = 16 * 1024 * 1024;
@@ -12,7 +15,7 @@ const DEFAULT_MAX_FRAME_BYTES = 16 * 1024 * 1024;
12
15
  export function createNodeHost(options = {}) {
13
16
  const connections = new Map();
14
17
  const code = createCodeRuntime(options.tools, {
15
- require: createRequire(`${process.cwd()}/`),
18
+ require: createRequire(resolve(options.workspace ?? process.cwd(), ".nanocodex-code-mode.cjs")),
16
19
  console: new Console({ stdout: process.stderr, stderr: process.stderr }),
17
20
  });
18
21
  const onEvent = options.onEvent || (() => {});
@@ -23,29 +26,47 @@ export function createNodeHost(options = {}) {
23
26
  const maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES;
24
27
  let nextHandle = 1;
25
28
 
26
- function connect(endpoint, apiKey, sessionId) {
29
+ function connect(endpoint, apiKey, sessionId, metadata = {}) {
27
30
  if (options.mpp) return connectMpp(endpoint);
28
31
  return new Promise((resolve, reject) => {
29
32
  let settled = false;
30
33
  let upgradeResponse;
34
+ const headers = {
35
+ Authorization: `Bearer ${apiKey}`,
36
+ "OpenAI-Beta": RESPONSES_WEBSOCKETS_BETA,
37
+ "x-openai-internal-codex-responses-lite": "true",
38
+ "session-id": sessionId,
39
+ "thread-id": sessionId,
40
+ "x-client-request-id": sessionId,
41
+ "x-responsesapi-include-timing-metrics": "true",
42
+ "User-Agent": USER_AGENT,
43
+ };
44
+ if (metadata.accountId) headers["ChatGPT-Account-ID"] = metadata.accountId;
45
+ if (metadata.fedramp) headers["X-OpenAI-Fedramp"] = "true";
46
+ if (metadata.turnState) headers["x-codex-turn-state"] = metadata.turnState;
31
47
  const socket = new WebSocket(endpoint, {
32
48
  handshakeTimeout: connectTimeoutMs,
33
49
  maxPayload: maxFrameBytes,
34
- headers: {
35
- Authorization: `Bearer ${apiKey}`,
36
- "OpenAI-Beta": RESPONSES_WEBSOCKETS_BETA,
37
- "x-openai-internal-codex-responses-lite": "true",
38
- "session-id": sessionId,
39
- "thread-id": sessionId,
40
- "x-client-request-id": sessionId,
41
- "x-responsesapi-include-timing-metrics": "true",
42
- "User-Agent": "nanocodex-wasm/0.1.0",
43
- },
50
+ headers,
44
51
  });
45
52
  const handle = nextHandle++;
46
53
  const connection = queueState(socket);
47
54
 
48
55
  socket.on("upgrade", (response) => { upgradeResponse = response; });
56
+ socket.on("unexpected-response", (_request, response) => {
57
+ if (settled) return;
58
+ settled = true;
59
+ const chunks = [];
60
+ response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
61
+ response.on("end", () => {
62
+ const error = new Error(`WebSocket handshake was rejected with HTTP ${response.statusCode}`);
63
+ error.status = response.statusCode;
64
+ error.body = chunks.length ? Buffer.concat(chunks).toString("utf8") : "empty response body";
65
+ const retryAfter = Number(header(response.headers, "retry-after"));
66
+ if (Number.isFinite(retryAfter) && retryAfter >= 0) error.retryAfter = retryAfter;
67
+ reject(error);
68
+ });
69
+ });
49
70
  socket.on("open", () => {
50
71
  settled = true;
51
72
  connections.set(handle, connection);
package/node/index.d.mts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { Actions } from "../index.mjs";
2
2
  export type {
3
3
  AgentEvent,
4
+ CostStatus,
5
+ EstimatedUsdCost,
4
6
  PromptInput,
5
7
  PromptItem,
6
8
  ReasoningMode,
@@ -10,5 +12,7 @@ export type {
10
12
  ToolContext,
11
13
  ToolMap,
12
14
  Turn,
15
+ TurnResult,
16
+ TurnUsage,
13
17
  } from "../types.mjs";
14
18
  export * as Agent from "./Agent.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nanocodex",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Viem-style Node and browser bindings for the Nanocodex WASM agent",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -25,7 +25,6 @@
25
25
  "pkg-node",
26
26
  "pkg-web",
27
27
  "runtime",
28
- "scripts/check-package.mjs",
29
28
  "internal.mjs",
30
29
  "index.d.mts",
31
30
  "index.mjs",
@@ -39,7 +38,8 @@
39
38
  "scripts": {
40
39
  "check:package": "node scripts/check-package.mjs",
41
40
  "prepack": "npm run check:package",
42
- "test": "node --test test/*.test.mjs && npm run test:typecheck && npm run check:package",
41
+ "test": "node --test test/*.test.mjs && npm run test:performance && npm run test:typecheck && npm run check:package",
42
+ "test:performance": "node --test test/performance.bench.mjs",
43
43
  "test:typecheck": "tsc -p test/tsconfig.json"
44
44
  },
45
45
  "dependencies": {