@gakim-digital/dexter-bridge 0.5.0 → 0.5.3

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/README.md CHANGED
@@ -75,14 +75,30 @@ The CLI stores the device token in `~/.dexter-bridge/config.json` with mode
75
75
  credits are not charged for companion runs; the backend records reported usage
76
76
  for visibility.
77
77
 
78
- Claude Code runs use `--output-format json` and Codex runs use `--json` so the
79
- bridge can report input, output, cache, and reasoning tokens when the local CLI
80
- provides them. Claude's reported `total_cost_usd` is displayed as an estimated
78
+ Claude Code is launched as a model-only engine: local tools, slash commands,
79
+ MCP integrations, browser access, and project-agent context are disabled. The
80
+ bridge supplies a strict JSON schema and a system prompt that requires Dexter
81
+ tool requests to be returned as structured output instead of being executed
82
+ inside Claude Code.
83
+
84
+ Claude Code runs use streaming JSON and Codex runs use JSONL so the bridge can
85
+ report input, output, cache, and reasoning tokens even when a turn is
86
+ interrupted. Claude's reported `total_cost_usd` is displayed as an estimated
81
87
  API-equivalent cost, not an amount Dexter charged. If an older or customized
82
88
  CLI does not support structured output, set `DEXTER_BRIDGE_STRUCTURED_USAGE=false`;
83
89
  the run will continue, but its token usage will be marked unavailable rather
84
90
  than estimated from text.
85
91
 
92
+ `DEXTER_BRIDGE_AGENT_TIMEOUT_MS` is the inactivity limit and resets whenever
93
+ the local model produces output. `DEXTER_BRIDGE_AGENT_MAX_DURATION_MS` is the
94
+ hard wall-clock ceiling; the API may supply a smaller per-turn ceiling from the
95
+ remaining run budget. Claude defaults to low effort for the intent-planner lane
96
+ and follow-up/recovery turns, and medium effort for the opening design turn.
97
+ Override these with `DEXTER_BRIDGE_CLAUDE_PLANNER_EFFORT`,
98
+ `DEXTER_BRIDGE_CLAUDE_MAIN_EFFORT`,
99
+ `DEXTER_BRIDGE_CLAUDE_FOLLOWUP_EFFORT`, or
100
+ `DEXTER_BRIDGE_CLAUDE_EFFORT`.
101
+
86
102
  ## Debugging
87
103
 
88
104
  The Dexter Bridge terminal is the shell where `dexter-bridge start` is running.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
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": {
7
- "dexter-bridge": "./bin/dexter-bridge.js"
7
+ "dexter-bridge": "bin/dexter-bridge.js"
8
8
  },
9
9
  "files": [
10
10
  "bin",
package/src/agent.js CHANGED
@@ -12,8 +12,11 @@ import {
12
12
  normalizeCompanionModelName,
13
13
  } from './config.js';
14
14
  import {
15
+ buildModelTurnDeltaPrompt,
16
+ buildModelTurnFallbackPrompt,
15
17
  buildModelTurnPrompt,
16
18
  extractJsonObject,
19
+ modelTurnOutputSchema,
17
20
  normalizeModelTurnCompletion,
18
21
  runSummary,
19
22
  } from './protocol.js';
@@ -27,6 +30,48 @@ import {
27
30
  } from './logger.js';
28
31
  import { createLocalAgentAdapter } from './providers/index.js';
29
32
 
33
+ const companionModelSessions = new Map();
34
+ const sharedProviderAdapters = new Map();
35
+ const MAX_COMPANION_MODEL_SESSIONS = 64;
36
+
37
+ function enabledFlag(value, fallback = false) {
38
+ const raw = String(value ?? '').trim().toLowerCase();
39
+ if (!raw) return fallback;
40
+ return !['0', 'false', 'no', 'off', 'disabled'].includes(raw);
41
+ }
42
+
43
+ function deltaModelSessionsEnabled(env = process.env) {
44
+ return enabledFlag(env.DEXTER_DELTA_MODEL_SESSIONS, true);
45
+ }
46
+
47
+ function companionSessionKey(run) {
48
+ return run?.modelTurn?.session?.sessionId || run?.turnId || run?.runId;
49
+ }
50
+
51
+ function rememberedCompanionSession(key) {
52
+ const session = key ? companionModelSessions.get(key) : null;
53
+ if (!session) return null;
54
+ companionModelSessions.delete(key);
55
+ companionModelSessions.set(key, session);
56
+ return session;
57
+ }
58
+
59
+ function rememberCompanionSession(key, session) {
60
+ if (!key) return;
61
+ companionModelSessions.delete(key);
62
+ companionModelSessions.set(key, session);
63
+ while (companionModelSessions.size > MAX_COMPANION_MODEL_SESSIONS) {
64
+ const oldest = companionModelSessions.keys().next().value;
65
+ if (!oldest) break;
66
+ companionModelSessions.delete(oldest);
67
+ }
68
+ }
69
+
70
+ function resumeFailure(error) {
71
+ const message = error?.message || String(error || '');
72
+ return /resume|session|thread|conversation|not found|unknown id|expired/i.test(message);
73
+ }
74
+
30
75
  export const AGENT_DEFINITIONS = {
31
76
  'claude-code': {
32
77
  id: 'claude-code',
@@ -133,6 +178,16 @@ const CLAUDE_CLI_MODEL_IDS = {
133
178
  haiku: 'claude-haiku-4-5-20251001',
134
179
  };
135
180
 
181
+ const CLAUDE_MODEL_ENGINE_SYSTEM_PROMPT = [
182
+ 'You are a model-only completion engine embedded inside Dexter.',
183
+ 'The user prompt contains Dexter messages and a catalog of remote tools as data.',
184
+ 'Never execute, simulate, or emit native Claude Code tool calls for those tool names.',
185
+ 'The only allowed tool is StructuredOutput, supplied by the JSON schema.',
186
+ 'Encode requested Dexter actions only inside StructuredOutput.toolCalls.',
187
+ 'Do not inspect the filesystem, project, shell, plugins, skills, MCP servers, or browser.',
188
+ 'Be concise: reason only as much as needed to choose the next remote Dexter action.',
189
+ ].join(' ');
190
+
136
191
  export function mapClaudeCliModelId(value) {
137
192
  const raw = String(value || '').trim();
138
193
  return CLAUDE_CLI_MODEL_IDS[raw.toLowerCase()] || raw;
@@ -150,7 +205,16 @@ function argsWithSelectedModel(args, modelDefinition, definition) {
150
205
  function argsWithStructuredOutput(args, definition, env = process.env) {
151
206
  if (!structuredUsageEnabled(env)) return args;
152
207
  if (definition.id === 'claude-code') {
153
- return argsIncludeFlag(args, '--output-format') ? args : [...args, '--output-format', 'json'];
208
+ const streamed = [
209
+ ...argsWithoutFlagValue(args, '--output-format'),
210
+ '--output-format',
211
+ 'stream-json',
212
+ ];
213
+ if (!argsIncludeFlag(streamed, '--include-partial-messages')) {
214
+ streamed.push('--include-partial-messages');
215
+ }
216
+ if (!argsIncludeFlag(streamed, '--verbose')) streamed.push('--verbose');
217
+ return streamed;
154
218
  }
155
219
  if (definition.id === 'codex') {
156
220
  return argsIncludeFlag(args, '--json') ? args : [...args, '--json'];
@@ -158,6 +222,89 @@ function argsWithStructuredOutput(args, definition, env = process.env) {
158
222
  return args;
159
223
  }
160
224
 
225
+ function argsWithClaudeIsolation(args, definition) {
226
+ if (definition.id !== 'claude-code') return args;
227
+ const isolated = argsWithoutVariadicFlag(args, '--tools');
228
+ isolated.push('--tools', '');
229
+ if (!argsIncludeFlag(isolated, '--disable-slash-commands')) isolated.push('--disable-slash-commands');
230
+ if (!argsIncludeFlag(isolated, '--safe-mode')) isolated.push('--safe-mode');
231
+ if (!argsIncludeFlag(isolated, '--strict-mcp-config')) isolated.push('--strict-mcp-config');
232
+ if (!argsIncludeFlag(isolated, '--no-chrome')) isolated.push('--no-chrome');
233
+ return isolated;
234
+ }
235
+
236
+ function normalizeClaudeEffort(value, fallback = 'medium') {
237
+ const effort = String(value || '').trim().toLowerCase();
238
+ return ['low', 'medium', 'high', 'xhigh', 'max'].includes(effort)
239
+ ? effort
240
+ : fallback;
241
+ }
242
+
243
+ function claudeEffortForStep(step, env = process.env) {
244
+ if (env.DEXTER_BRIDGE_CLAUDE_EFFORT) {
245
+ return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_EFFORT);
246
+ }
247
+ if (Number(step) < 0) {
248
+ return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_PLANNER_EFFORT, 'low');
249
+ }
250
+ if (Number(step) > 0) {
251
+ return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_FOLLOWUP_EFFORT, 'low');
252
+ }
253
+ return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_MAIN_EFFORT, 'medium');
254
+ }
255
+
256
+ function argsWithClaudeModelEngine(args, definition, options = {}, env = process.env) {
257
+ if (definition.id !== 'claude-code') return args;
258
+ let isolated = argsWithoutFlagValue(args, '--system-prompt');
259
+ isolated = argsWithoutFlagValue(isolated, '--append-system-prompt');
260
+ isolated = argsWithoutFlagValue(isolated, '--agent');
261
+ isolated = argsWithoutFlagValue(isolated, '--effort');
262
+ return [
263
+ ...isolated,
264
+ '--system-prompt',
265
+ CLAUDE_MODEL_ENGINE_SYSTEM_PROMPT,
266
+ '--effort',
267
+ claudeEffortForStep(options.step, env),
268
+ ];
269
+ }
270
+
271
+ function argsWithoutFlagValue(args, flag) {
272
+ const filtered = [];
273
+ for (let index = 0; index < args.length; index += 1) {
274
+ const arg = args[index];
275
+ if (arg === flag) {
276
+ index += 1;
277
+ continue;
278
+ }
279
+ if (arg.startsWith(`${flag}=`)) continue;
280
+ filtered.push(arg);
281
+ }
282
+ return filtered;
283
+ }
284
+
285
+ function argsWithoutVariadicFlag(args, flag) {
286
+ const filtered = [];
287
+ for (let index = 0; index < args.length; index += 1) {
288
+ const arg = args[index];
289
+ if (arg === flag) {
290
+ while (index + 1 < args.length && !String(args[index + 1]).startsWith('-')) index += 1;
291
+ continue;
292
+ }
293
+ if (arg.startsWith(`${flag}=`)) continue;
294
+ filtered.push(arg);
295
+ }
296
+ return filtered;
297
+ }
298
+
299
+ function argsWithOutputSchema(args, definition, outputSchema) {
300
+ if (definition.id !== 'claude-code' || !outputSchema) return args;
301
+ return [
302
+ ...argsWithoutFlagValue(args, '--json-schema'),
303
+ '--json-schema',
304
+ JSON.stringify(outputSchema),
305
+ ];
306
+ }
307
+
161
308
  function argsWithPromptInput(args, definition) {
162
309
  if (definition.id !== 'codex' || args.includes('-')) return args;
163
310
  return [...args, '-'];
@@ -222,7 +369,10 @@ export function buildAgentArgs(definition, modelDefinition, env = process.env, o
222
369
  const requiredArgs = argsWithRequiredAgentFlags(baseArgs, definition);
223
370
  const modelArgs = argsWithSelectedModel(requiredArgs, modelDefinition, definition);
224
371
  const structuredArgs = argsWithStructuredOutput(modelArgs, definition, env);
225
- const resumedArgs = argsWithResumedSession(structuredArgs, definition, options.resumeSessionId);
372
+ const isolatedArgs = argsWithClaudeIsolation(structuredArgs, definition);
373
+ const modelEngineArgs = argsWithClaudeModelEngine(isolatedArgs, definition, options, env);
374
+ const schemaArgs = argsWithOutputSchema(modelEngineArgs, definition, options.outputSchema);
375
+ const resumedArgs = argsWithResumedSession(schemaArgs, definition, options.resumeSessionId);
226
376
  return argsWithPromptInput(resumedArgs, definition);
227
377
  }
228
378
 
@@ -498,12 +648,17 @@ export function agentFailureMessage(command, code, stdout = '', stderr = '') {
498
648
 
499
649
  function runProcess(command, args, stdin, {
500
650
  timeoutMs = 120000,
651
+ maxDurationMs,
501
652
  trace,
502
653
  childEnv: providedChildEnv,
503
654
  platform = process.platform,
504
655
  } = {}) {
505
656
  return new Promise((resolve, reject) => {
506
657
  const started = Date.now();
658
+ // timeoutMs is an inactivity timeout (reset whenever the child produces
659
+ // output) so slow-but-streaming model turns are not killed mid-generation;
660
+ // hardDeadlineMs bounds total wall-clock time regardless of activity.
661
+ const hardDeadlineMs = Math.max(maxDurationMs || timeoutMs * 5, 10);
507
662
  const childEnv = providedChildEnv || processEnvWithCliPath(platform);
508
663
  const cwd = resolveAgentCwd(childEnv, process.cwd(), platform);
509
664
  const invocation = processInvocation(command, args, childEnv, platform);
@@ -515,6 +670,7 @@ function runProcess(command, args, stdin, {
515
670
  env: processEnvSummary(childEnv),
516
671
  stdinChars: stdin.length,
517
672
  timeoutMs,
673
+ maxDurationMs: hardDeadlineMs,
518
674
  });
519
675
  const child = spawn(invocation.command, invocation.args, {
520
676
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -525,33 +681,54 @@ function runProcess(command, args, stdin, {
525
681
  let stdout = '';
526
682
  let stderr = '';
527
683
  let settled = false;
528
- const timer = setTimeout(() => {
684
+ let inactivityTimer = null;
685
+ const timeOut = (message) => {
529
686
  if (settled) return;
530
687
  settled = true;
688
+ clearTimeout(inactivityTimer);
689
+ clearTimeout(deadlineTimer);
531
690
  child.kill('SIGTERM');
532
- const error = new Error(`${command} timed out after ${timeoutMs}ms.`);
691
+ const error = new Error(message);
692
+ error.code = 'AGENT_TIMEOUT';
693
+ error.stdout = stdout;
694
+ error.stderr = stderr;
695
+ error.timedOut = true;
533
696
  trace?.error('agent_process_timeout', {
534
697
  command,
535
698
  durationMs: Date.now() - started,
536
699
  timeoutMs,
700
+ maxDurationMs: hardDeadlineMs,
537
701
  stdoutChars: stdout.length,
538
702
  stderrChars: stderr.length,
539
703
  stdoutExcerpt: clip(stdout, 1000),
540
704
  stderrExcerpt: clip(stderr, 1000),
541
705
  });
542
706
  reject(error);
543
- }, timeoutMs);
707
+ };
708
+ const armInactivityTimer = () => {
709
+ clearTimeout(inactivityTimer);
710
+ inactivityTimer = setTimeout(() => {
711
+ timeOut(`${command} produced no output for ${timeoutMs}ms.`);
712
+ }, timeoutMs);
713
+ };
714
+ const deadlineTimer = setTimeout(() => {
715
+ timeOut(`${command} timed out after ${hardDeadlineMs}ms.`);
716
+ }, hardDeadlineMs);
717
+ armInactivityTimer();
544
718
 
545
719
  child.stdout.on('data', (chunk) => {
546
720
  stdout += chunk.toString('utf8');
721
+ armInactivityTimer();
547
722
  });
548
723
  child.stderr.on('data', (chunk) => {
549
724
  stderr += chunk.toString('utf8');
725
+ armInactivityTimer();
550
726
  });
551
727
  child.on('error', (error) => {
552
728
  if (settled) return;
553
729
  settled = true;
554
- clearTimeout(timer);
730
+ clearTimeout(inactivityTimer);
731
+ clearTimeout(deadlineTimer);
555
732
  trace?.error('agent_process_error', {
556
733
  command,
557
734
  durationMs: Date.now() - started,
@@ -562,7 +739,8 @@ function runProcess(command, args, stdin, {
562
739
  child.on('close', (code) => {
563
740
  if (settled) return;
564
741
  settled = true;
565
- clearTimeout(timer);
742
+ clearTimeout(inactivityTimer);
743
+ clearTimeout(deadlineTimer);
566
744
  const meta = {
567
745
  command,
568
746
  code,
@@ -738,8 +916,15 @@ async function callLocalJsonAgent(agent, prompt, options = {}) {
738
916
  const modelDefinition = companionModelDefinition(options.model, definition.id);
739
917
  const args = buildAgentArgs(definition, modelDefinition, process.env, {
740
918
  resumeSessionId: options.resumeSessionId,
919
+ outputSchema: options.outputSchema,
920
+ step: options.step,
741
921
  });
742
922
  const timeoutMs = options.timeoutMs || Number(process.env.DEXTER_BRIDGE_AGENT_TIMEOUT_MS || 120000);
923
+ const maxDurationMs = boundedDurationMs(
924
+ options.maxDurationMs ?? process.env.DEXTER_BRIDGE_AGENT_MAX_DURATION_MS,
925
+ Math.max(timeoutMs * 5, 600000),
926
+ 10,
927
+ );
743
928
  options.trace?.info('agent_step_invoke', {
744
929
  agent: definition.id,
745
930
  command,
@@ -751,13 +936,28 @@ async function callLocalJsonAgent(agent, prompt, options = {}) {
751
936
  promptChars: prompt.length,
752
937
  resumeSessionId: options.resumeSessionId,
753
938
  timeoutMs,
939
+ maxDurationMs,
754
940
  });
755
941
  return runProcess(command, args, prompt, {
756
942
  timeoutMs,
943
+ maxDurationMs,
757
944
  trace: options.trace,
758
945
  });
759
946
  }
760
947
 
948
+ function agentErrorWithOutputUsage(agent, error) {
949
+ const failure = error instanceof Error
950
+ ? error
951
+ : new Error(String(error || 'The companion model call failed.'));
952
+ const parsed = parseAgentOutput(agent, failure.stdout || '');
953
+ if (!parsed.usageAvailable) return failure;
954
+ const accumulator = createCompanionUsageAccumulator(agent);
955
+ accumulator.add(failure.companionUsage || {});
956
+ accumulator.add(parsed);
957
+ failure.companionUsage = accumulator.snapshot();
958
+ return failure;
959
+ }
960
+
761
961
  async function executeModelTurnRun(run, send, agent, options = {}) {
762
962
  if (normalizeAgentName(agent) === 'dry-run') {
763
963
  await send('done', {
@@ -781,7 +981,20 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
781
981
  ? requestedModel.trim()
782
982
  : selectedModel?.id || definition.model;
783
983
  const adapter = options.providerAdapter || null;
784
- const prompt = buildModelTurnPrompt(run.modelTurn);
984
+ const sessionKey = companionSessionKey(run);
985
+ const rememberedSession = rememberedCompanionSession(sessionKey);
986
+ const deltaRequested =
987
+ deltaModelSessionsEnabled(options.env)
988
+ && run?.modelTurn?.session?.contextMode === 'delta';
989
+ let callContextMode = deltaRequested && rememberedSession ? 'delta' : 'full';
990
+ let prompt = callContextMode === 'delta'
991
+ ? buildModelTurnDeltaPrompt(run.modelTurn)
992
+ : deltaRequested
993
+ ? buildModelTurnFallbackPrompt(run.modelTurn)
994
+ : buildModelTurnPrompt(run.modelTurn);
995
+ const callStartedAt = Date.now();
996
+ let providerSessionId;
997
+ let fallbackAfterResumeFailure = false;
785
998
  const statusResponse = await send('status', {
786
999
  stage: 'model_turn',
787
1000
  message: `${definition.label} is generating the next Dexter action.`,
@@ -791,10 +1004,39 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
791
1004
  throw new CompanionRunCancelledError();
792
1005
  }
793
1006
  const usageAccumulator = createCompanionUsageAccumulator(definition.id);
1007
+ const failedModelCall = (error) => {
1008
+ const failure = error instanceof Error
1009
+ ? error
1010
+ : new Error(String(error || 'The companion model call failed.'));
1011
+ usageAccumulator.add(failure.companionUsage || {});
1012
+ const snapshot = usageAccumulator.snapshot();
1013
+ failure.companionUsage = {
1014
+ ...snapshot,
1015
+ modelCalls: [{
1016
+ callId: run.runId,
1017
+ step: Number(run?.modelTurn?.step ?? 0),
1018
+ model: selectedModelId,
1019
+ status: 'failed',
1020
+ durationMs: Date.now() - callStartedAt,
1021
+ promptChars: prompt.length,
1022
+ requestedContextMode: deltaRequested ? 'delta' : 'full',
1023
+ contextMode: callContextMode,
1024
+ resumed: callContextMode === 'delta',
1025
+ resumeSessionAvailable: Boolean(rememberedSession),
1026
+ fallbackAfterResumeFailure,
1027
+ ...snapshot.tokenUsage,
1028
+ estimatedCostUSD: snapshot.estimatedCostUSD,
1029
+ }],
1030
+ };
1031
+ return failure;
1032
+ };
794
1033
  let resultText;
795
1034
  if (adapter) {
796
- const sessionId = adapterSessionId(run);
1035
+ const sessionId = sessionKey || adapterSessionId(run);
797
1036
  const invocationModel = adapterInvocationModel(run, selectedModel, definition.id);
1037
+ if (callContextMode === 'full' && rememberedSession) {
1038
+ await adapter.resetSession?.(sessionId);
1039
+ }
798
1040
  options.trace?.info('agent_adapter_invoke', {
799
1041
  adapter: adapter.id,
800
1042
  agent: definition.id,
@@ -803,17 +1045,26 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
803
1045
  invocationModel,
804
1046
  promptChars: prompt.length,
805
1047
  });
806
- const result = await callProviderAdapter(adapter, {
1048
+ const adapterInput = {
807
1049
  runId: run.runId,
808
1050
  sessionId,
809
1051
  prompt,
810
1052
  model: invocationModel,
1053
+ outputSchema: modelTurnOutputSchema(run.modelTurn),
811
1054
  timeoutMs: boundedDurationMs(
812
1055
  options.timeoutMs ?? options.env?.DEXTER_BRIDGE_AGENT_TIMEOUT_MS,
813
1056
  120000,
814
1057
  1000,
815
1058
  ),
816
- }, {
1059
+ maxDurationMs: boundedDurationMs(
1060
+ run?.modelTurn?.maxDurationMs
1061
+ ?? options.maxDurationMs
1062
+ ?? options.env?.DEXTER_BRIDGE_AGENT_MAX_DURATION_MS,
1063
+ 600000,
1064
+ 1000,
1065
+ ),
1066
+ };
1067
+ const adapterOptions = {
817
1068
  send,
818
1069
  trace: options.trace,
819
1070
  controlPollMs: boundedDurationMs(
@@ -822,9 +1073,30 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
822
1073
  10,
823
1074
  30000,
824
1075
  ),
825
- });
1076
+ };
1077
+ let result;
1078
+ try {
1079
+ result = await callProviderAdapter(adapter, adapterInput, adapterOptions);
1080
+ } catch (error) {
1081
+ if (callContextMode !== 'delta' || !resumeFailure(error)) throw failedModelCall(error);
1082
+ usageAccumulator.add(error?.companionUsage || {});
1083
+ fallbackAfterResumeFailure = true;
1084
+ callContextMode = 'full';
1085
+ prompt = buildModelTurnFallbackPrompt(run.modelTurn);
1086
+ await adapter.resetSession?.(sessionId);
1087
+ options.trace?.warn('agent_adapter_resume_fallback', {
1088
+ sessionId,
1089
+ error: errorMeta(error),
1090
+ fallbackPromptChars: prompt.length,
1091
+ });
1092
+ result = await callProviderAdapter(adapter, { ...adapterInput, prompt }, adapterOptions)
1093
+ .catch((error) => {
1094
+ throw failedModelCall(error);
1095
+ });
1096
+ }
826
1097
  usageAccumulator.add(result);
827
1098
  resultText = result?.text;
1099
+ providerSessionId = result?.threadId || result?.sessionId;
828
1100
  } else {
829
1101
  const runtime = await resolveAgentRuntime(definition, selectedModel, {
830
1102
  env: options.env,
@@ -832,25 +1104,96 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
832
1104
  if (!runtime.ok) {
833
1105
  throw new Error(runtime.error || `${definition.label} is not available.`);
834
1106
  }
835
- const result = await callLocalJsonAgent(definition.id, prompt, {
836
- ...options,
837
- model: selectedModelId,
838
- runtime,
839
- });
1107
+ const resumeSessionId =
1108
+ callContextMode === 'delta'
1109
+ && agentSessionResumeEnabled(definition, options.env)
1110
+ ? rememberedSession?.providerSessionId
1111
+ : undefined;
1112
+ let result;
1113
+ try {
1114
+ result = await callLocalJsonAgent(definition.id, prompt, {
1115
+ ...options,
1116
+ model: selectedModelId,
1117
+ runtime,
1118
+ resumeSessionId,
1119
+ outputSchema: modelTurnOutputSchema(run.modelTurn),
1120
+ step: run?.modelTurn?.step,
1121
+ maxDurationMs: run?.modelTurn?.maxDurationMs ?? options.maxDurationMs,
1122
+ });
1123
+ } catch (error) {
1124
+ const failure = agentErrorWithOutputUsage(definition.id, error);
1125
+ if (callContextMode !== 'delta' || !resumeFailure(failure)) throw failedModelCall(failure);
1126
+ usageAccumulator.add(failure.companionUsage || {});
1127
+ fallbackAfterResumeFailure = true;
1128
+ callContextMode = 'full';
1129
+ prompt = buildModelTurnFallbackPrompt(run.modelTurn);
1130
+ options.trace?.warn('agent_cli_resume_fallback', {
1131
+ resumeSessionId,
1132
+ error: errorMeta(failure),
1133
+ fallbackPromptChars: prompt.length,
1134
+ });
1135
+ result = await callLocalJsonAgent(definition.id, prompt, {
1136
+ ...options,
1137
+ model: selectedModelId,
1138
+ runtime,
1139
+ resumeSessionId: undefined,
1140
+ outputSchema: modelTurnOutputSchema(run.modelTurn),
1141
+ step: run?.modelTurn?.step,
1142
+ maxDurationMs: run?.modelTurn?.maxDurationMs ?? options.maxDurationMs,
1143
+ }).catch((error) => {
1144
+ throw failedModelCall(agentErrorWithOutputUsage(definition.id, error));
1145
+ });
1146
+ }
840
1147
  const parsed = parseAgentOutput(definition.id, result.stdout);
841
1148
  usageAccumulator.add(parsed);
842
1149
  resultText = parsed.resultText;
1150
+ providerSessionId = parsed.sessionId;
1151
+ }
1152
+ if (providerSessionId || adapter) {
1153
+ rememberCompanionSession(sessionKey, {
1154
+ providerSessionId: providerSessionId || rememberedSession?.providerSessionId || sessionKey,
1155
+ agent: definition.id,
1156
+ updatedAt: Date.now(),
1157
+ });
1158
+ }
1159
+ const usageSnapshot = usageAccumulator.snapshot();
1160
+ const callTelemetry = {
1161
+ callId: run.runId,
1162
+ step: Number(run?.modelTurn?.step ?? 0),
1163
+ model: selectedModelId,
1164
+ durationMs: Date.now() - callStartedAt,
1165
+ promptChars: prompt.length,
1166
+ requestedContextMode: deltaRequested ? 'delta' : 'full',
1167
+ contextMode: callContextMode,
1168
+ resumed: callContextMode === 'delta',
1169
+ resumeSessionAvailable: Boolean(rememberedSession),
1170
+ fallbackAfterResumeFailure,
1171
+ ...usageSnapshot.tokenUsage,
1172
+ estimatedCostUSD: usageSnapshot.estimatedCostUSD,
1173
+ };
1174
+ const usage = {
1175
+ ...usageSnapshot,
1176
+ modelCalls: [{ ...callTelemetry, status: 'succeeded' }],
1177
+ };
1178
+ let completion;
1179
+ try {
1180
+ completion = normalizeModelTurnCompletion(
1181
+ extractJsonObject(resultText),
1182
+ selectedModelId,
1183
+ );
1184
+ } catch (error) {
1185
+ error.companionUsage = {
1186
+ ...usage,
1187
+ modelCalls: [{ ...callTelemetry, status: 'rejected' }],
1188
+ };
1189
+ throw error;
843
1190
  }
844
- const completion = normalizeModelTurnCompletion(
845
- extractJsonObject(resultText),
846
- selectedModelId,
847
- );
848
1191
  await send('done', {
849
1192
  operationType: 'chat',
850
1193
  outcome: 'answer',
851
1194
  completion,
852
1195
  model: selectedModelId,
853
- ...usageAccumulator.snapshot(),
1196
+ ...usage,
854
1197
  });
855
1198
  }
856
1199
 
@@ -885,14 +1228,26 @@ export async function executeRun(run, {
885
1228
  const runModel = run?.companion?.model?.id || run?.model || selectedModel;
886
1229
  const usageAccumulator = createCompanionUsageAccumulator(normalizedAgent);
887
1230
  const adapterProvided = providerAdapter !== undefined;
888
- const activeAdapter = adapterProvided
889
- ? providerAdapter
890
- : createLocalAgentAdapter(normalizedAgent, {
1231
+ const shareAdapter = !adapterProvided && deltaModelSessionsEnabled(env);
1232
+ let activeAdapter = adapterProvided ? providerAdapter : null;
1233
+ if (!adapterProvided && shareAdapter) {
1234
+ activeAdapter = sharedProviderAdapters.get(normalizedAgent) || null;
1235
+ if (!activeAdapter) {
1236
+ activeAdapter = createLocalAgentAdapter(normalizedAgent, {
1237
+ ...adapterOptions,
1238
+ env: adapterOptions?.env || env,
1239
+ trace,
1240
+ });
1241
+ if (activeAdapter) sharedProviderAdapters.set(normalizedAgent, activeAdapter);
1242
+ }
1243
+ } else if (!adapterProvided) {
1244
+ activeAdapter = createLocalAgentAdapter(normalizedAgent, {
891
1245
  ...adapterOptions,
892
1246
  env: adapterOptions?.env || env,
893
1247
  trace,
894
1248
  });
895
- const ownsAdapter = !adapterProvided && Boolean(activeAdapter);
1249
+ }
1250
+ const ownsAdapter = !adapterProvided && !shareAdapter && Boolean(activeAdapter);
896
1251
  if (
897
1252
  activeAdapter?.id
898
1253
  && activeAdapter.id !== normalizedAgent
@@ -945,6 +1300,12 @@ export async function executeRun(run, {
945
1300
  }
946
1301
  }
947
1302
 
1303
+ export function __resetAgentSessionsForTests() {
1304
+ companionModelSessions.clear();
1305
+ for (const adapter of sharedProviderAdapters.values()) adapter?.close?.();
1306
+ sharedProviderAdapters.clear();
1307
+ }
1308
+
948
1309
  export function checkCommand(command, args = ['--version'], timeoutMs = 10000) {
949
1310
  return runProcess(command, args, '', { timeoutMs })
950
1311
  .then((result) => ({ ok: true, command, output: (result.stdout || result.stderr || '').trim() }))
@@ -79,11 +79,25 @@ function parseWholeJson(stdout) {
79
79
  }
80
80
  }
81
81
 
82
- export function parseClaudeOutput(stdout) {
83
- const parsed = parseWholeJson(stdout);
82
+ function parseJsonLines(stdout) {
83
+ return String(stdout || '')
84
+ .split(/\r?\n/)
85
+ .map((line) => line.trim())
86
+ .filter(Boolean)
87
+ .flatMap((line) => {
88
+ try {
89
+ const parsed = JSON.parse(line);
90
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? [parsed] : [];
91
+ } catch {
92
+ return [];
93
+ }
94
+ });
95
+ }
96
+
97
+ function parseClaudeEnvelope(parsed, fallbackText = '') {
84
98
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
85
99
  return {
86
- resultText: String(stdout || '').trim(),
100
+ resultText: String(fallbackText || '').trim(),
87
101
  tokenUsage: normalizeCompanionTokenUsage(),
88
102
  modelUsage: [],
89
103
  usageAvailable: false,
@@ -92,10 +106,16 @@ export function parseClaudeOutput(stdout) {
92
106
  };
93
107
  }
94
108
 
95
- const hasEnvelope = typeof parsed.result === 'string' || parsed.usage || parsed.total_cost_usd !== undefined || parsed.modelUsage;
109
+ const structuredOutput = parsed.structured_output ?? parsed.structuredOutput;
110
+ const hasEnvelope =
111
+ typeof parsed.result === 'string'
112
+ || structuredOutput !== undefined
113
+ || parsed.usage
114
+ || parsed.total_cost_usd !== undefined
115
+ || parsed.modelUsage;
96
116
  if (!hasEnvelope) {
97
117
  return {
98
- resultText: String(stdout || '').trim(),
118
+ resultText: String(fallbackText || '').trim(),
99
119
  tokenUsage: normalizeCompanionTokenUsage(),
100
120
  modelUsage: [],
101
121
  usageAvailable: false,
@@ -108,7 +128,13 @@ export function parseClaudeOutput(stdout) {
108
128
  const modelUsage = normalizeModelUsage(parsed.modelUsage || parsed.model_usage);
109
129
  const estimatedCostUSD = optionalNonNegativeNumber(parsed.total_cost_usd ?? parsed.totalCostUsd);
110
130
  return {
111
- resultText: typeof parsed.result === 'string' ? parsed.result.trim() : '',
131
+ resultText: structuredOutput && typeof structuredOutput === 'object'
132
+ ? JSON.stringify(structuredOutput)
133
+ : typeof structuredOutput === 'string'
134
+ ? structuredOutput.trim()
135
+ : typeof parsed.result === 'string'
136
+ ? parsed.result.trim()
137
+ : '',
112
138
  tokenUsage,
113
139
  model: typeof parsed.model === 'string' ? parsed.model : undefined,
114
140
  sessionId: typeof parsed.session_id === 'string'
@@ -124,19 +150,111 @@ export function parseClaudeOutput(stdout) {
124
150
  };
125
151
  }
126
152
 
127
- function parseJsonLines(stdout) {
128
- return String(stdout || '')
129
- .split(/\r?\n/)
130
- .map((line) => line.trim())
131
- .filter(Boolean)
132
- .flatMap((line) => {
133
- try {
134
- const parsed = JSON.parse(line);
135
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? [parsed] : [];
136
- } catch {
137
- return [];
153
+ function mergeMaximumUsage(target, rawUsage) {
154
+ const usage = normalizeCompanionTokenUsage(rawUsage || {});
155
+ for (const field of TOKEN_FIELDS) target[field] = Math.max(target[field], usage[field]);
156
+ target.totalTokens = target.inputTokens + target.outputTokens + target.reasoningOutputTokens;
157
+ }
158
+
159
+ function parseClaudeStreamUsage(events) {
160
+ const turns = new Map();
161
+ let activeTurnKey;
162
+ let anonymousSequence = 0;
163
+ let sessionId;
164
+ let partialText = '';
165
+
166
+ const ensureTurn = (key, model) => {
167
+ const resolvedKey = key || activeTurnKey || `turn-${++anonymousSequence}`;
168
+ if (!turns.has(resolvedKey)) {
169
+ turns.set(resolvedKey, {
170
+ model: typeof model === 'string' && model.trim() ? model.trim() : undefined,
171
+ usage: normalizeCompanionTokenUsage(),
172
+ });
173
+ } else if (model && !turns.get(resolvedKey).model) {
174
+ turns.get(resolvedKey).model = model;
175
+ }
176
+ activeTurnKey = resolvedKey;
177
+ return turns.get(resolvedKey);
178
+ };
179
+
180
+ for (const row of events) {
181
+ if (
182
+ (row.type === 'system' || row.type === 'init')
183
+ && typeof (row.session_id || row.sessionId) === 'string'
184
+ ) {
185
+ sessionId = row.session_id || row.sessionId;
186
+ }
187
+
188
+ if (row.type === 'assistant' && row.message && typeof row.message === 'object') {
189
+ const message = row.message;
190
+ const turn = ensureTurn(message.id, message.model);
191
+ mergeMaximumUsage(turn.usage, message.usage);
192
+ for (const part of Array.isArray(message.content) ? message.content : []) {
193
+ if (part?.type === 'text' && typeof part.text === 'string') partialText += part.text;
138
194
  }
139
- });
195
+ }
196
+
197
+ if (row.type !== 'stream_event' || !row.event || typeof row.event !== 'object') continue;
198
+ const event = row.event;
199
+ if (event.type === 'message_start' && event.message && typeof event.message === 'object') {
200
+ const turn = ensureTurn(event.message.id, event.message.model);
201
+ mergeMaximumUsage(turn.usage, event.message.usage);
202
+ continue;
203
+ }
204
+ if (event.type === 'message_delta') {
205
+ const turn = ensureTurn(activeTurnKey);
206
+ mergeMaximumUsage(turn.usage, event.usage);
207
+ continue;
208
+ }
209
+ if (
210
+ event.type === 'content_block_delta'
211
+ && event.delta?.type === 'text_delta'
212
+ && typeof event.delta.text === 'string'
213
+ ) {
214
+ partialText += event.delta.text;
215
+ }
216
+ }
217
+
218
+ const tokenUsage = normalizeCompanionTokenUsage();
219
+ const modelTotals = new Map();
220
+ for (const turn of turns.values()) {
221
+ for (const field of TOKEN_FIELDS) tokenUsage[field] += turn.usage[field];
222
+ tokenUsage.totalTokens += turn.usage.totalTokens;
223
+ if (!turn.model) continue;
224
+ const current = modelTotals.get(turn.model) || {
225
+ model: turn.model,
226
+ ...normalizeCompanionTokenUsage(),
227
+ };
228
+ for (const field of TOKEN_FIELDS) current[field] += turn.usage[field];
229
+ current.totalTokens += turn.usage.totalTokens;
230
+ modelTotals.set(turn.model, current);
231
+ }
232
+ const usageAvailable = usageHasReportedTokens(tokenUsage);
233
+ return {
234
+ resultText: partialText.trim(),
235
+ tokenUsage,
236
+ modelUsage: Array.from(modelTotals.values()),
237
+ sessionId,
238
+ usageAvailable,
239
+ usageSource: 'claude-code',
240
+ usageAccuracy: usageAvailable ? 'reported' : 'unavailable',
241
+ };
242
+ }
243
+
244
+ export function parseClaudeOutput(stdout) {
245
+ const whole = parseWholeJson(stdout);
246
+ if (whole && typeof whole === 'object' && !Array.isArray(whole)) {
247
+ return parseClaudeEnvelope(whole, stdout);
248
+ }
249
+ const events = parseJsonLines(stdout);
250
+ const resultEnvelope = [...events].reverse().find((event) =>
251
+ event.type === 'result'
252
+ || event.structured_output !== undefined
253
+ || event.structuredOutput !== undefined
254
+ || typeof event.result === 'string');
255
+ if (resultEnvelope) return parseClaudeEnvelope(resultEnvelope, stdout);
256
+ if (events.length) return parseClaudeStreamUsage(events);
257
+ return parseClaudeEnvelope(null, stdout);
140
258
  }
141
259
 
142
260
  export function parseCodexOutput(stdout) {
package/src/config.js CHANGED
@@ -3,7 +3,9 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
 
5
5
  export const DEFAULT_API_BASE_URL = 'http://localhost:3800/iwm-api/0.0.1';
6
- export const BRIDGE_VERSION = '0.5.0';
6
+ export const BRIDGE_VERSION = JSON.parse(
7
+ fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
8
+ ).version;
7
9
  export const BRIDGE_CAPABILITIES = [
8
10
  'model-turn-v1',
9
11
  ];
package/src/protocol.js CHANGED
@@ -71,16 +71,18 @@ export function compactToolCatalog(tools = []) {
71
71
  .filter((tool) => tool.name);
72
72
  }
73
73
 
74
- export function buildModelTurnPrompt(modelTurn = {}) {
75
- const messages = Array.isArray(modelTurn.messages)
76
- ? modelTurn.messages.slice(-64).map((message) => ({
74
+ function compactMessages(messages = [], limit = 64) {
75
+ return Array.isArray(messages)
76
+ ? messages.slice(-limit).map((message) => ({
77
77
  role: message?.role,
78
78
  content: compactModelTurnContent(message?.content),
79
79
  toolCalls: message?.toolCalls,
80
80
  toolCallId: message?.toolCallId,
81
81
  }))
82
82
  : [];
83
- const tools = compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : []);
83
+ }
84
+
85
+ function modelTurnInstructions() {
84
86
  return [
85
87
  'You are the model engine for Dexter. The server owns the agent loop and executes all tools.',
86
88
  'Return exactly one JSON object and no markdown.',
@@ -89,13 +91,49 @@ export function buildModelTurnPrompt(modelTurn = {}) {
89
91
  'Each toolCalls[].arguments value must be a JSON-encoded string whose decoded value is an object.',
90
92
  'Use only tools listed below. Do not claim a tool executed; only request it.',
91
93
  'When the task is complete, return toolCalls:[] and finishReason:"stop".',
92
- '',
93
- `Messages:\n${JSON.stringify(messages)}`,
94
+ ];
95
+ }
96
+
97
+ export function buildModelTurnPrompt(modelTurn = {}) {
98
+ const messages = compactMessages(modelTurn.messages, 64);
99
+ const tools = compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : []);
100
+ return [
101
+ ...modelTurnInstructions(),
94
102
  '',
95
103
  `Tools:\n${JSON.stringify(tools)}`,
104
+ '',
105
+ `Messages:\n${JSON.stringify(messages)}`,
96
106
  ].join('\n');
97
107
  }
98
108
 
109
+ export function buildModelTurnDeltaPrompt(modelTurn = {}) {
110
+ const messages = compactMessages(modelTurn.messages, 24);
111
+ const includeTools = modelTurn?.session?.toolCatalogChanged === true;
112
+ const tools = includeTools
113
+ ? compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : [])
114
+ : [];
115
+ return [
116
+ 'Continue the existing Dexter model session. The server has already supplied the doctrine, goal, prior messages, and tool catalog.',
117
+ 'Apply only the new canonical messages/state changes below.',
118
+ 'Return exactly one JSON object using the previously established response contract.',
119
+ ...(includeTools ? ['', `Updated tools:\n${JSON.stringify(tools)}`] : []),
120
+ '',
121
+ `New messages:\n${JSON.stringify(messages)}`,
122
+ modelTurn?.session?.lastStateDigest
123
+ ? `State digest: ${modelTurn.session.lastStateDigest}`
124
+ : '',
125
+ ].filter(Boolean).join('\n');
126
+ }
127
+
128
+ export function buildModelTurnFallbackPrompt(modelTurn = {}) {
129
+ return buildModelTurnPrompt({
130
+ ...modelTurn,
131
+ messages: Array.isArray(modelTurn.fallbackMessages)
132
+ ? modelTurn.fallbackMessages
133
+ : modelTurn.messages,
134
+ });
135
+ }
136
+
99
137
  export function modelTurnOutputSchema(modelTurn = {}) {
100
138
  const tools = compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : []);
101
139
  const toolNames = tools.map((tool) => tool.name);
@@ -1,5 +1,6 @@
1
1
  import { createJsonRpcClient } from './jsonRpcClient.js';
2
2
  import { normalizeCompanionTokenUsage } from '../agentOutput.js';
3
+ import { BRIDGE_VERSION } from '../config.js';
3
4
 
4
5
  /**
5
6
  * Codex App Server adapter — the primary local-agent path.
@@ -20,7 +21,7 @@ import { normalizeCompanionTokenUsage } from '../agentOutput.js';
20
21
  const CLIENT_INFO = {
21
22
  name: 'dexter_bridge',
22
23
  title: 'Dexter Bridge',
23
- version: '0.5.0',
24
+ version: BRIDGE_VERSION,
24
25
  };
25
26
 
26
27
  export const CODEX_APP_SERVER_METHODS = {
@@ -382,10 +383,12 @@ export function createCodexAppServerAdapter({
382
383
  if (event.method === CODEX_APP_SERVER_NOTIFICATIONS.turnCompleted) {
383
384
  const turn = event.params?.turn || {};
384
385
  if (turn.status === 'failed') {
385
- finish(reject, new Error(codexTurnErrorMessage(
386
+ const error = new Error(codexTurnErrorMessage(
386
387
  turn,
387
388
  'Codex turn failed without an error message.',
388
- )));
389
+ ));
390
+ error.companionUsage = normalizeUsage(turn);
391
+ finish(reject, error);
389
392
  return;
390
393
  }
391
394
  finish(resolve, {
@@ -424,6 +427,18 @@ export function createCodexAppServerAdapter({
424
427
  }
425
428
  }
426
429
 
430
+ async function resetSession(sessionId) {
431
+ const threadId = threadsByRun.get(sessionId);
432
+ if (!threadId) return;
433
+ threadsByRun.delete(sessionId);
434
+ if (!client || client.closed) return;
435
+ await client.request(
436
+ CODEX_APP_SERVER_METHODS.threadUnsubscribe,
437
+ { threadId },
438
+ { timeoutMs: 10000 },
439
+ ).catch(() => undefined);
440
+ }
441
+
427
442
  async function logout() {
428
443
  if (!client || client.closed) return;
429
444
  try {
@@ -451,6 +466,7 @@ export function createCodexAppServerAdapter({
451
466
  usage,
452
467
  runModelTurn,
453
468
  cancel,
469
+ resetSession,
454
470
  logout,
455
471
  close,
456
472
  };