@gakim-digital/dexter-bridge 0.5.14 → 0.5.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.5.14",
3
+ "version": "0.5.17",
4
4
  "description": "Local Companion bridge for the Dexter Framer plugin — runs Codex or Claude Code on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -286,7 +286,7 @@ export function createClaudeAgentSdkAdapter({
286
286
  return {
287
287
  ...inherited,
288
288
  CLAUDE_CONFIG_DIR: configDir,
289
- CLAUDE_AGENT_SDK_CLIENT_APP: 'dexter-hosted-worker/0.2.2',
289
+ CLAUDE_AGENT_SDK_CLIENT_APP: 'dexter-hosted-worker/0.2.4',
290
290
  };
291
291
  }
292
292
 
@@ -2,6 +2,11 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { createJsonRpcClient } from './jsonRpcClient.js';
5
+ import {
6
+ codexTransportOutputSchema,
7
+ codexTransportPrompt,
8
+ decodeCodexTransportOutput,
9
+ } from './codexStructuredOutput.js';
5
10
  import { normalizeCompanionTokenUsage } from '../agentOutput.js';
6
11
  import { BRIDGE_VERSION } from '../config.js';
7
12
 
@@ -13,12 +18,13 @@ import { BRIDGE_VERSION } from '../config.js';
13
18
  * That gives us persistent threads, device-code login, and real plan/rate-limit
14
19
  * data straight from Codex.
15
20
  *
16
- * Protocol verified against codex-cli 0.144.4 (`codex app-server
21
+ * Protocol verified against codex-cli 0.146.0 (`codex app-server
17
22
  * generate-json-schema`): `initialize`, `model/list`, `account/read`,
18
23
  * `account/login/start` (with `{type:'chatgptDeviceCode'}` →
19
24
  * `{loginId,userCode,verificationUrl}`), `thread/start`, `turn/start`,
20
25
  * `turn/interrupt`, `account/logout`; server pushes `item/completed`,
21
- * `turn/completed`, `account/login/completed`, `error`.
26
+ * `thread/tokenUsage/updated`, `turn/completed`,
27
+ * `account/login/completed`, `error`.
22
28
  */
23
29
 
24
30
  const CLIENT_INFO = {
@@ -77,9 +83,6 @@ function codexModelOnlyConfig() {
77
83
  shell_tool: false,
78
84
  skill_mcp_dependency_install: false,
79
85
  },
80
- agents: {
81
- enabled: false,
82
- },
83
86
  memories: {
84
87
  generate_memories: false,
85
88
  use_memories: false,
@@ -132,6 +135,7 @@ export const CODEX_APP_SERVER_METHODS = {
132
135
  export const CODEX_APP_SERVER_NOTIFICATIONS = {
133
136
  loginCompleted: 'account/login/completed',
134
137
  itemCompleted: 'item/completed',
138
+ tokenUsageUpdated: 'thread/tokenUsage/updated',
135
139
  turnCompleted: 'turn/completed',
136
140
  threadStarted: 'thread/started',
137
141
  error: 'error',
@@ -166,9 +170,36 @@ export function normalizeCodexModels(response) {
166
170
  }));
167
171
  }
168
172
 
169
- export function normalizeUsage(turn) {
170
- const usage = turn?.usage || turn?.tokenUsage || {};
171
- const tokenUsage = normalizeCompanionTokenUsage(usage);
173
+ export function codexUsageSince(current, baseline = {}) {
174
+ const currentUsage = normalizeCompanionTokenUsage(current);
175
+ const baselineUsage = normalizeCompanionTokenUsage(baseline);
176
+ const inputTokens = Math.max(0, currentUsage.inputTokens - baselineUsage.inputTokens);
177
+ const outputTokens = Math.max(0, currentUsage.outputTokens - baselineUsage.outputTokens);
178
+ const cachedInputTokens = Math.max(
179
+ 0,
180
+ currentUsage.cachedInputTokens - baselineUsage.cachedInputTokens,
181
+ );
182
+ const cacheWriteInputTokens = Math.max(
183
+ 0,
184
+ currentUsage.cacheWriteInputTokens - baselineUsage.cacheWriteInputTokens,
185
+ );
186
+ const reasoningOutputTokens = Math.max(
187
+ 0,
188
+ currentUsage.reasoningOutputTokens - baselineUsage.reasoningOutputTokens,
189
+ );
190
+ const totalTokens = Math.max(
191
+ 0,
192
+ currentUsage.totalTokens - baselineUsage.totalTokens,
193
+ inputTokens + outputTokens + reasoningOutputTokens,
194
+ );
195
+ const tokenUsage = {
196
+ inputTokens,
197
+ outputTokens,
198
+ cachedInputTokens,
199
+ cacheWriteInputTokens,
200
+ reasoningOutputTokens,
201
+ totalTokens,
202
+ };
172
203
  return {
173
204
  tokenUsage,
174
205
  usageAvailable: tokenUsage.totalTokens > 0,
@@ -217,6 +248,8 @@ export function createCodexAppServerAdapter({
217
248
  );
218
249
  /** threadId per Dexter turn, so relay runs in one turn share Codex context. */
219
250
  const threadsByRun = new Map();
251
+ /** Latest cumulative token totals reported for each persistent Codex thread. */
252
+ const threadUsageTotals = new Map();
220
253
  const listeners = new Set();
221
254
 
222
255
  function emit(event) {
@@ -238,6 +271,15 @@ export function createCodexAppServerAdapter({
238
271
  cwd,
239
272
  onNotification: (message) => {
240
273
  trace?.info('codex_app_server_notification', { method: message.method });
274
+ if (
275
+ message.method === CODEX_APP_SERVER_NOTIFICATIONS.tokenUsageUpdated
276
+ && typeof message.params?.threadId === 'string'
277
+ ) {
278
+ threadUsageTotals.set(
279
+ message.params.threadId,
280
+ normalizeCompanionTokenUsage(message.params?.tokenUsage?.total),
281
+ );
282
+ }
241
283
  emit({ type: 'notification', method: message.method, params: message.params });
242
284
  },
243
285
  // The app server asks for approvals; Dexter runs read-only against Codex,
@@ -256,6 +298,7 @@ export function createCodexAppServerAdapter({
256
298
  onExit: (info) => {
257
299
  initialized = null;
258
300
  threadsByRun.clear();
301
+ threadUsageTotals.clear();
259
302
  trace?.info('codex_app_server_exit', info || {});
260
303
  emit({ type: 'exit', ...info });
261
304
  },
@@ -406,6 +449,7 @@ export function createCodexAppServerAdapter({
406
449
  if (oldest) {
407
450
  const [oldestRunId, oldestThreadId] = oldest;
408
451
  threadsByRun.delete(oldestRunId);
452
+ threadUsageTotals.delete(oldestThreadId);
409
453
  active.request(
410
454
  CODEX_APP_SERVER_METHODS.threadUnsubscribe,
411
455
  { threadId: oldestThreadId },
@@ -433,6 +477,7 @@ export function createCodexAppServerAdapter({
433
477
  const threadId = response?.thread?.id || response?.threadId || response?.id;
434
478
  if (!threadId) throw new Error('Codex app server did not return a thread id.');
435
479
  threadsByRun.set(runId, threadId);
480
+ threadUsageTotals.delete(threadId);
436
481
  return threadId;
437
482
  }
438
483
 
@@ -454,9 +499,20 @@ export function createCodexAppServerAdapter({
454
499
  const threadKey = sessionId || runId;
455
500
  if (!threadKey) throw new Error('Codex model turn requires a session id.');
456
501
  const threadId = await ensureThread(threadKey, { model });
502
+ const baselineUsage = threadUsageTotals.get(threadId) || normalizeCompanionTokenUsage();
503
+ const transportSchema = outputSchema
504
+ ? codexTransportOutputSchema(outputSchema)
505
+ : null;
506
+ const transportPrompt = transportSchema
507
+ ? codexTransportPrompt(prompt)
508
+ : prompt;
457
509
 
458
510
  return new Promise((resolve, reject) => {
459
511
  let lastMessage = '';
512
+ const usageForTurn = () => codexUsageSince(
513
+ threadUsageTotals.get(threadId),
514
+ baselineUsage,
515
+ );
460
516
  const timer = setTimeout(() => {
461
517
  listeners.delete(listener);
462
518
  active.request(
@@ -464,7 +520,9 @@ export function createCodexAppServerAdapter({
464
520
  { threadId },
465
521
  { timeoutMs: 10000 },
466
522
  ).catch(() => undefined);
467
- reject(new Error(`Codex turn timed out after ${timeoutMs}ms.`));
523
+ const error = new Error(`Codex turn timed out after ${timeoutMs}ms.`);
524
+ error.companionUsage = usageForTurn();
525
+ reject(error);
468
526
  }, timeoutMs);
469
527
 
470
528
  function finish(fn, value) {
@@ -491,7 +549,9 @@ export function createCodexAppServerAdapter({
491
549
  // final turn outcome. Let its internal retry finish; the terminal
492
550
  // turn/completed event remains authoritative.
493
551
  if (event.params?.willRetry === true) return;
494
- finish(reject, new Error(codexTurnErrorMessage(event.params)));
552
+ const error = new Error(codexTurnErrorMessage(event.params));
553
+ error.companionUsage = usageForTurn();
554
+ finish(reject, error);
495
555
  return;
496
556
  }
497
557
  if (event.method === CODEX_APP_SERVER_NOTIFICATIONS.turnCompleted) {
@@ -501,15 +561,24 @@ export function createCodexAppServerAdapter({
501
561
  turn,
502
562
  'Codex turn failed without an error message.',
503
563
  ));
504
- error.companionUsage = normalizeUsage(turn);
564
+ error.companionUsage = usageForTurn();
505
565
  finish(reject, error);
506
566
  return;
507
567
  }
568
+ let decodedMessage = lastMessage;
569
+ if (transportSchema) {
570
+ try {
571
+ decodedMessage = decodeCodexTransportOutput(lastMessage);
572
+ } catch (error) {
573
+ finish(reject, error);
574
+ return;
575
+ }
576
+ }
508
577
  finish(resolve, {
509
- text: lastMessage,
578
+ text: decodedMessage,
510
579
  threadId,
511
580
  turnId: turn.id || null,
512
- ...normalizeUsage(turn),
581
+ ...usageForTurn(),
513
582
  });
514
583
  }
515
584
  }
@@ -521,9 +590,9 @@ export function createCodexAppServerAdapter({
521
590
  CODEX_APP_SERVER_METHODS.turnStart,
522
591
  {
523
592
  threadId,
524
- input: [{ type: 'text', text: prompt }],
593
+ input: [{ type: 'text', text: transportPrompt }],
525
594
  ...(model ? { model } : {}),
526
- ...(outputSchema ? { outputSchema } : {}),
595
+ ...(transportSchema ? { outputSchema: transportSchema } : {}),
527
596
  },
528
597
  { timeoutMs: 30000 },
529
598
  )
@@ -545,6 +614,7 @@ export function createCodexAppServerAdapter({
545
614
  const threadId = threadsByRun.get(sessionId);
546
615
  if (!threadId) return;
547
616
  threadsByRun.delete(sessionId);
617
+ threadUsageTotals.delete(threadId);
548
618
  if (!client || client.closed) return;
549
619
  await client.request(
550
620
  CODEX_APP_SERVER_METHODS.threadUnsubscribe,
@@ -559,11 +629,13 @@ export function createCodexAppServerAdapter({
559
629
  await client.request(CODEX_APP_SERVER_METHODS.logout, {}, { timeoutMs: 15000 });
560
630
  } finally {
561
631
  threadsByRun.clear();
632
+ threadUsageTotals.clear();
562
633
  }
563
634
  }
564
635
 
565
636
  function close() {
566
637
  threadsByRun.clear();
638
+ threadUsageTotals.clear();
567
639
  listeners.clear();
568
640
  client?.close();
569
641
  client = null;
@@ -0,0 +1,143 @@
1
+ function isRecord(value) {
2
+ return value && typeof value === 'object' && !Array.isArray(value);
3
+ }
4
+
5
+ function finiteInteger(value) {
6
+ const parsed = Number(value);
7
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
8
+ }
9
+
10
+ function toolCallVariants(outputSchema) {
11
+ const items = outputSchema?.properties?.toolCalls?.items;
12
+ if (!isRecord(items)) return [];
13
+ return Array.isArray(items.anyOf)
14
+ ? items.anyOf.filter(isRecord)
15
+ : [items];
16
+ }
17
+
18
+ function toolNames(outputSchema) {
19
+ return Array.from(new Set(
20
+ toolCallVariants(outputSchema).flatMap((variant) => {
21
+ const names = variant?.properties?.name?.enum;
22
+ return Array.isArray(names)
23
+ ? names.filter((name) => typeof name === 'string' && name)
24
+ : [];
25
+ }),
26
+ ));
27
+ }
28
+
29
+ /**
30
+ * Codex App Server sends outputSchema through OpenAI Structured Outputs, whose
31
+ * supported JSON Schema subset requires closed objects with every property in
32
+ * `required`. Dexter's canonical tool schemas intentionally contain optional
33
+ * and open-ended fields, so they must not be embedded directly in that schema.
34
+ *
35
+ * Keep the strict response format as a transport envelope. The full canonical
36
+ * tool catalog remains in the prompt and the server validates decoded arguments
37
+ * before execution.
38
+ */
39
+ export function codexTransportOutputSchema(outputSchema) {
40
+ if (!isRecord(outputSchema)) {
41
+ throw new Error('Codex model turn requires an output schema.');
42
+ }
43
+
44
+ const sourceToolCalls = outputSchema?.properties?.toolCalls;
45
+ const sourceFinishReason = outputSchema?.properties?.finishReason;
46
+ if (!isRecord(sourceToolCalls) || !isRecord(sourceFinishReason)) {
47
+ throw new Error('Codex model turn output schema is missing its response envelope.');
48
+ }
49
+
50
+ const names = toolNames(outputSchema);
51
+ const minItems = finiteInteger(sourceToolCalls.minItems);
52
+ const maxItems = finiteInteger(sourceToolCalls.maxItems);
53
+ const finishReasonEnum = Array.isArray(sourceFinishReason.enum)
54
+ ? sourceFinishReason.enum.filter((value) => typeof value === 'string')
55
+ : ['tool_calls', 'stop', 'length'];
56
+
57
+ return {
58
+ type: 'object',
59
+ properties: {
60
+ text: { type: 'string' },
61
+ toolCalls: {
62
+ type: 'array',
63
+ ...(minItems === undefined ? {} : { minItems }),
64
+ ...(maxItems === undefined ? {} : { maxItems }),
65
+ items: {
66
+ type: 'object',
67
+ properties: {
68
+ id: { type: 'string' },
69
+ name: {
70
+ type: 'string',
71
+ ...(names.length ? { enum: names } : {}),
72
+ },
73
+ arguments: {
74
+ type: 'string',
75
+ description:
76
+ 'A JSON-encoded object matching the selected Dexter tool schema.',
77
+ },
78
+ },
79
+ required: ['id', 'name', 'arguments'],
80
+ additionalProperties: false,
81
+ },
82
+ },
83
+ finishReason: {
84
+ type: 'string',
85
+ enum: finishReasonEnum.length
86
+ ? finishReasonEnum
87
+ : ['tool_calls', 'stop', 'length'],
88
+ },
89
+ },
90
+ required: ['text', 'toolCalls', 'finishReason'],
91
+ additionalProperties: false,
92
+ };
93
+ }
94
+
95
+ export function codexTransportPrompt(prompt) {
96
+ return [
97
+ String(prompt || ''),
98
+ '',
99
+ 'Codex structured-output transport:',
100
+ 'In the final JSON response, encode each toolCalls[].arguments value as a JSON string.',
101
+ 'The decoded string must be one JSON object matching the exact selected tool schema in the Tools catalog.',
102
+ 'Do not omit the id, name, arguments, text, toolCalls, or finishReason fields.',
103
+ ].join('\n');
104
+ }
105
+
106
+ export function decodeCodexTransportOutput(text) {
107
+ let parsed;
108
+ try {
109
+ parsed = JSON.parse(String(text || '').trim());
110
+ } catch {
111
+ throw new Error('Codex returned malformed structured output.');
112
+ }
113
+ if (!isRecord(parsed) || !Array.isArray(parsed.toolCalls)) {
114
+ throw new Error('Codex returned an invalid model-turn response envelope.');
115
+ }
116
+
117
+ const toolCalls = parsed.toolCalls.map((call, index) => {
118
+ if (!isRecord(call) || typeof call.arguments !== 'string') {
119
+ throw new Error(
120
+ `Codex tool call ${index + 1} did not use the JSON-string arguments transport.`,
121
+ );
122
+ }
123
+
124
+ let args;
125
+ try {
126
+ args = JSON.parse(call.arguments);
127
+ } catch {
128
+ throw new Error(`Codex tool call ${index + 1} contained malformed JSON arguments.`);
129
+ }
130
+ if (!isRecord(args)) {
131
+ throw new Error(`Codex tool call ${index + 1} arguments must decode to an object.`);
132
+ }
133
+ return {
134
+ ...call,
135
+ arguments: args,
136
+ };
137
+ });
138
+
139
+ return JSON.stringify({
140
+ ...parsed,
141
+ toolCalls,
142
+ });
143
+ }