@gakim-digital/dexter-bridge 0.5.15 → 0.5.19

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.15",
3
+ "version": "0.5.19",
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.3',
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 = {
@@ -129,6 +135,7 @@ export const CODEX_APP_SERVER_METHODS = {
129
135
  export const CODEX_APP_SERVER_NOTIFICATIONS = {
130
136
  loginCompleted: 'account/login/completed',
131
137
  itemCompleted: 'item/completed',
138
+ tokenUsageUpdated: 'thread/tokenUsage/updated',
132
139
  turnCompleted: 'turn/completed',
133
140
  threadStarted: 'thread/started',
134
141
  error: 'error',
@@ -163,9 +170,36 @@ export function normalizeCodexModels(response) {
163
170
  }));
164
171
  }
165
172
 
166
- export function normalizeUsage(turn) {
167
- const usage = turn?.usage || turn?.tokenUsage || {};
168
- 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
+ };
169
203
  return {
170
204
  tokenUsage,
171
205
  usageAvailable: tokenUsage.totalTokens > 0,
@@ -214,6 +248,8 @@ export function createCodexAppServerAdapter({
214
248
  );
215
249
  /** threadId per Dexter turn, so relay runs in one turn share Codex context. */
216
250
  const threadsByRun = new Map();
251
+ /** Latest cumulative token totals reported for each persistent Codex thread. */
252
+ const threadUsageTotals = new Map();
217
253
  const listeners = new Set();
218
254
 
219
255
  function emit(event) {
@@ -235,6 +271,15 @@ export function createCodexAppServerAdapter({
235
271
  cwd,
236
272
  onNotification: (message) => {
237
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
+ }
238
283
  emit({ type: 'notification', method: message.method, params: message.params });
239
284
  },
240
285
  // The app server asks for approvals; Dexter runs read-only against Codex,
@@ -253,6 +298,7 @@ export function createCodexAppServerAdapter({
253
298
  onExit: (info) => {
254
299
  initialized = null;
255
300
  threadsByRun.clear();
301
+ threadUsageTotals.clear();
256
302
  trace?.info('codex_app_server_exit', info || {});
257
303
  emit({ type: 'exit', ...info });
258
304
  },
@@ -403,6 +449,7 @@ export function createCodexAppServerAdapter({
403
449
  if (oldest) {
404
450
  const [oldestRunId, oldestThreadId] = oldest;
405
451
  threadsByRun.delete(oldestRunId);
452
+ threadUsageTotals.delete(oldestThreadId);
406
453
  active.request(
407
454
  CODEX_APP_SERVER_METHODS.threadUnsubscribe,
408
455
  { threadId: oldestThreadId },
@@ -430,6 +477,7 @@ export function createCodexAppServerAdapter({
430
477
  const threadId = response?.thread?.id || response?.threadId || response?.id;
431
478
  if (!threadId) throw new Error('Codex app server did not return a thread id.');
432
479
  threadsByRun.set(runId, threadId);
480
+ threadUsageTotals.delete(threadId);
433
481
  return threadId;
434
482
  }
435
483
 
@@ -451,9 +499,20 @@ export function createCodexAppServerAdapter({
451
499
  const threadKey = sessionId || runId;
452
500
  if (!threadKey) throw new Error('Codex model turn requires a session id.');
453
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;
454
509
 
455
510
  return new Promise((resolve, reject) => {
456
511
  let lastMessage = '';
512
+ const usageForTurn = () => codexUsageSince(
513
+ threadUsageTotals.get(threadId),
514
+ baselineUsage,
515
+ );
457
516
  const timer = setTimeout(() => {
458
517
  listeners.delete(listener);
459
518
  active.request(
@@ -461,7 +520,9 @@ export function createCodexAppServerAdapter({
461
520
  { threadId },
462
521
  { timeoutMs: 10000 },
463
522
  ).catch(() => undefined);
464
- 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);
465
526
  }, timeoutMs);
466
527
 
467
528
  function finish(fn, value) {
@@ -488,7 +549,9 @@ export function createCodexAppServerAdapter({
488
549
  // final turn outcome. Let its internal retry finish; the terminal
489
550
  // turn/completed event remains authoritative.
490
551
  if (event.params?.willRetry === true) return;
491
- finish(reject, new Error(codexTurnErrorMessage(event.params)));
552
+ const error = new Error(codexTurnErrorMessage(event.params));
553
+ error.companionUsage = usageForTurn();
554
+ finish(reject, error);
492
555
  return;
493
556
  }
494
557
  if (event.method === CODEX_APP_SERVER_NOTIFICATIONS.turnCompleted) {
@@ -498,15 +561,24 @@ export function createCodexAppServerAdapter({
498
561
  turn,
499
562
  'Codex turn failed without an error message.',
500
563
  ));
501
- error.companionUsage = normalizeUsage(turn);
564
+ error.companionUsage = usageForTurn();
502
565
  finish(reject, error);
503
566
  return;
504
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
+ }
505
577
  finish(resolve, {
506
- text: lastMessage,
578
+ text: decodedMessage,
507
579
  threadId,
508
580
  turnId: turn.id || null,
509
- ...normalizeUsage(turn),
581
+ ...usageForTurn(),
510
582
  });
511
583
  }
512
584
  }
@@ -518,9 +590,9 @@ export function createCodexAppServerAdapter({
518
590
  CODEX_APP_SERVER_METHODS.turnStart,
519
591
  {
520
592
  threadId,
521
- input: [{ type: 'text', text: prompt }],
593
+ input: [{ type: 'text', text: transportPrompt }],
522
594
  ...(model ? { model } : {}),
523
- ...(outputSchema ? { outputSchema } : {}),
595
+ ...(transportSchema ? { outputSchema: transportSchema } : {}),
524
596
  },
525
597
  { timeoutMs: 30000 },
526
598
  )
@@ -542,6 +614,7 @@ export function createCodexAppServerAdapter({
542
614
  const threadId = threadsByRun.get(sessionId);
543
615
  if (!threadId) return;
544
616
  threadsByRun.delete(sessionId);
617
+ threadUsageTotals.delete(threadId);
545
618
  if (!client || client.closed) return;
546
619
  await client.request(
547
620
  CODEX_APP_SERVER_METHODS.threadUnsubscribe,
@@ -556,11 +629,13 @@ export function createCodexAppServerAdapter({
556
629
  await client.request(CODEX_APP_SERVER_METHODS.logout, {}, { timeoutMs: 15000 });
557
630
  } finally {
558
631
  threadsByRun.clear();
632
+ threadUsageTotals.clear();
559
633
  }
560
634
  }
561
635
 
562
636
  function close() {
563
637
  threadsByRun.clear();
638
+ threadUsageTotals.clear();
564
639
  listeners.clear();
565
640
  client?.close();
566
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
+ }