@gakim-digital/dexter-bridge 0.5.1 → 0.5.6

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.5.1",
3
+ "version": "0.5.6",
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": {
package/src/agent.js CHANGED
@@ -45,7 +45,10 @@ function deltaModelSessionsEnabled(env = process.env) {
45
45
  }
46
46
 
47
47
  function companionSessionKey(run) {
48
- return run?.modelTurn?.session?.sessionId || run?.turnId || run?.runId;
48
+ return run?.modelTurn?.session?.goalSessionId
49
+ || run?.modelTurn?.session?.sessionId
50
+ || run?.turnId
51
+ || run?.runId;
49
52
  }
50
53
 
51
54
  function rememberedCompanionSession(key) {
@@ -97,6 +100,10 @@ export const AGENT_DEFINITIONS = {
97
100
  },
98
101
  };
99
102
 
103
+ export const AGENT_AUTHENTICATION_REQUIRED_CODE = 'DEXTER_AGENT_AUTHENTICATION_REQUIRED';
104
+ export const CLAUDE_AUTHENTICATION_REQUIRED_MESSAGE =
105
+ 'Claude Code sign-in has expired. Run `claude auth login` on this Mac, complete sign-in, then try again.';
106
+
100
107
  function nowIso() {
101
108
  return new Date().toISOString();
102
109
  }
@@ -173,11 +180,21 @@ function argsWithRequiredAgentFlags(args, definition) {
173
180
 
174
181
  const CLAUDE_CLI_MODEL_IDS = {
175
182
  fable: 'claude-fable-5',
176
- opus: 'claude-opus-4-8',
183
+ opus: 'claude-opus-5',
177
184
  sonnet: 'claude-sonnet-5',
178
185
  haiku: 'claude-haiku-4-5-20251001',
179
186
  };
180
187
 
188
+ const CLAUDE_MODEL_ENGINE_SYSTEM_PROMPT = [
189
+ 'You are a model-only completion engine embedded inside Dexter.',
190
+ 'The user prompt contains Dexter messages and a catalog of remote tools as data.',
191
+ 'Never execute, simulate, or emit native Claude Code tool calls for those tool names.',
192
+ 'The only allowed tool is StructuredOutput, supplied by the JSON schema.',
193
+ 'Encode requested Dexter actions only inside StructuredOutput.toolCalls.',
194
+ 'Do not inspect the filesystem, project, shell, plugins, skills, MCP servers, or browser.',
195
+ 'Be concise: reason only as much as needed to choose the next remote Dexter action.',
196
+ ].join(' ');
197
+
181
198
  export function mapClaudeCliModelId(value) {
182
199
  const raw = String(value || '').trim();
183
200
  return CLAUDE_CLI_MODEL_IDS[raw.toLowerCase()] || raw;
@@ -195,7 +212,16 @@ function argsWithSelectedModel(args, modelDefinition, definition) {
195
212
  function argsWithStructuredOutput(args, definition, env = process.env) {
196
213
  if (!structuredUsageEnabled(env)) return args;
197
214
  if (definition.id === 'claude-code') {
198
- return [...argsWithoutFlagValue(args, '--output-format'), '--output-format', 'json'];
215
+ const streamed = [
216
+ ...argsWithoutFlagValue(args, '--output-format'),
217
+ '--output-format',
218
+ 'stream-json',
219
+ ];
220
+ if (!argsIncludeFlag(streamed, '--include-partial-messages')) {
221
+ streamed.push('--include-partial-messages');
222
+ }
223
+ if (!argsIncludeFlag(streamed, '--verbose')) streamed.push('--verbose');
224
+ return streamed;
199
225
  }
200
226
  if (definition.id === 'codex') {
201
227
  return argsIncludeFlag(args, '--json') ? args : [...args, '--json'];
@@ -214,6 +240,41 @@ function argsWithClaudeIsolation(args, definition) {
214
240
  return isolated;
215
241
  }
216
242
 
243
+ function normalizeClaudeEffort(value, fallback = 'medium') {
244
+ const effort = String(value || '').trim().toLowerCase();
245
+ return ['low', 'medium', 'high', 'xhigh', 'max'].includes(effort)
246
+ ? effort
247
+ : fallback;
248
+ }
249
+
250
+ function claudeEffortForStep(step, env = process.env) {
251
+ if (env.DEXTER_BRIDGE_CLAUDE_EFFORT) {
252
+ return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_EFFORT);
253
+ }
254
+ if (Number(step) < 0) {
255
+ return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_PLANNER_EFFORT, 'low');
256
+ }
257
+ if (Number(step) > 0) {
258
+ return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_FOLLOWUP_EFFORT, 'low');
259
+ }
260
+ return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_MAIN_EFFORT, 'medium');
261
+ }
262
+
263
+ function argsWithClaudeModelEngine(args, definition, options = {}, env = process.env) {
264
+ if (definition.id !== 'claude-code') return args;
265
+ let isolated = argsWithoutFlagValue(args, '--system-prompt');
266
+ isolated = argsWithoutFlagValue(isolated, '--append-system-prompt');
267
+ isolated = argsWithoutFlagValue(isolated, '--agent');
268
+ isolated = argsWithoutFlagValue(isolated, '--effort');
269
+ return [
270
+ ...isolated,
271
+ '--system-prompt',
272
+ CLAUDE_MODEL_ENGINE_SYSTEM_PROMPT,
273
+ '--effort',
274
+ claudeEffortForStep(options.step, env),
275
+ ];
276
+ }
277
+
217
278
  function argsWithoutFlagValue(args, flag) {
218
279
  const filtered = [];
219
280
  for (let index = 0; index < args.length; index += 1) {
@@ -316,7 +377,8 @@ export function buildAgentArgs(definition, modelDefinition, env = process.env, o
316
377
  const modelArgs = argsWithSelectedModel(requiredArgs, modelDefinition, definition);
317
378
  const structuredArgs = argsWithStructuredOutput(modelArgs, definition, env);
318
379
  const isolatedArgs = argsWithClaudeIsolation(structuredArgs, definition);
319
- const schemaArgs = argsWithOutputSchema(isolatedArgs, definition, options.outputSchema);
380
+ const modelEngineArgs = argsWithClaudeModelEngine(isolatedArgs, definition, options, env);
381
+ const schemaArgs = argsWithOutputSchema(modelEngineArgs, definition, options.outputSchema);
320
382
  const resumedArgs = argsWithResumedSession(schemaArgs, definition, options.resumeSessionId);
321
383
  return argsWithPromptInput(resumedArgs, definition);
322
384
  }
@@ -593,12 +655,27 @@ export function agentFailureMessage(command, code, stdout = '', stderr = '') {
593
655
 
594
656
  function runProcess(command, args, stdin, {
595
657
  timeoutMs = 120000,
658
+ maxDurationMs,
596
659
  trace,
597
660
  childEnv: providedChildEnv,
598
661
  platform = process.platform,
662
+ signal,
663
+ killGraceMs = 250,
599
664
  } = {}) {
600
665
  return new Promise((resolve, reject) => {
666
+ if (signal?.aborted) {
667
+ const error = signal.reason instanceof Error
668
+ ? signal.reason
669
+ : new Error('The model process was cancelled.');
670
+ error.code = error.code || 'RUN_CANCELLED';
671
+ reject(error);
672
+ return;
673
+ }
601
674
  const started = Date.now();
675
+ // timeoutMs is an inactivity timeout (reset whenever the child produces
676
+ // output) so slow-but-streaming model turns are not killed mid-generation;
677
+ // hardDeadlineMs bounds total wall-clock time regardless of activity.
678
+ const hardDeadlineMs = Math.max(maxDurationMs || timeoutMs * 5, 10);
602
679
  const childEnv = providedChildEnv || processEnvWithCliPath(platform);
603
680
  const cwd = resolveAgentCwd(childEnv, process.cwd(), platform);
604
681
  const invocation = processInvocation(command, args, childEnv, platform);
@@ -610,43 +687,109 @@ function runProcess(command, args, stdin, {
610
687
  env: processEnvSummary(childEnv),
611
688
  stdinChars: stdin.length,
612
689
  timeoutMs,
690
+ maxDurationMs: hardDeadlineMs,
613
691
  });
614
692
  const child = spawn(invocation.command, invocation.args, {
615
693
  stdio: ['pipe', 'pipe', 'pipe'],
616
694
  env: childEnv,
617
695
  cwd,
618
696
  windowsHide: invocation.windowsHide,
697
+ detached: platform !== 'win32',
619
698
  });
620
699
  let stdout = '';
621
700
  let stderr = '';
622
701
  let settled = false;
623
- const timer = setTimeout(() => {
702
+ let inactivityTimer = null;
703
+ let forceKillTimer = null;
704
+ const killChildTree = (killSignal) => {
705
+ if (platform !== 'win32' && child.pid) {
706
+ try {
707
+ process.kill(-child.pid, killSignal);
708
+ return;
709
+ } catch {
710
+ // Fall back to the direct child when the process group is already gone.
711
+ }
712
+ }
713
+ try {
714
+ child.kill(killSignal);
715
+ } catch {
716
+ // The child already exited.
717
+ }
718
+ };
719
+ const clearTimers = () => {
720
+ clearTimeout(inactivityTimer);
721
+ clearTimeout(deadlineTimer);
722
+ };
723
+ const terminateChildTree = () => {
724
+ killChildTree('SIGTERM');
725
+ forceKillTimer = setTimeout(() => killChildTree('SIGKILL'), killGraceMs);
726
+ forceKillTimer.unref?.();
727
+ };
728
+ const abort = () => {
624
729
  if (settled) return;
625
730
  settled = true;
626
- child.kill('SIGTERM');
627
- const error = new Error(`${command} timed out after ${timeoutMs}ms.`);
731
+ clearTimers();
732
+ terminateChildTree();
733
+ const error = signal?.reason instanceof Error
734
+ ? signal.reason
735
+ : new Error('The model process was cancelled.');
736
+ error.code = error.code || 'RUN_CANCELLED';
737
+ error.stdout = stdout;
738
+ error.stderr = stderr;
739
+ trace?.info('agent_process_cancelled', {
740
+ command,
741
+ durationMs: Date.now() - started,
742
+ });
743
+ reject(error);
744
+ };
745
+ const timeOut = (message) => {
746
+ if (settled) return;
747
+ settled = true;
748
+ clearTimers();
749
+ terminateChildTree();
750
+ const error = new Error(message);
751
+ error.code = 'AGENT_TIMEOUT';
752
+ error.stdout = stdout;
753
+ error.stderr = stderr;
754
+ error.timedOut = true;
628
755
  trace?.error('agent_process_timeout', {
629
756
  command,
630
757
  durationMs: Date.now() - started,
631
758
  timeoutMs,
759
+ maxDurationMs: hardDeadlineMs,
632
760
  stdoutChars: stdout.length,
633
761
  stderrChars: stderr.length,
634
762
  stdoutExcerpt: clip(stdout, 1000),
635
763
  stderrExcerpt: clip(stderr, 1000),
636
764
  });
637
765
  reject(error);
638
- }, timeoutMs);
766
+ };
767
+ const armInactivityTimer = () => {
768
+ clearTimeout(inactivityTimer);
769
+ inactivityTimer = setTimeout(() => {
770
+ timeOut(`${command} produced no output for ${timeoutMs}ms.`);
771
+ }, timeoutMs);
772
+ };
773
+ const deadlineTimer = setTimeout(() => {
774
+ timeOut(`${command} timed out after ${hardDeadlineMs}ms.`);
775
+ }, hardDeadlineMs);
776
+ signal?.addEventListener('abort', abort, { once: true });
777
+ armInactivityTimer();
639
778
 
640
779
  child.stdout.on('data', (chunk) => {
641
780
  stdout += chunk.toString('utf8');
781
+ armInactivityTimer();
642
782
  });
643
783
  child.stderr.on('data', (chunk) => {
644
784
  stderr += chunk.toString('utf8');
785
+ armInactivityTimer();
645
786
  });
646
787
  child.on('error', (error) => {
647
788
  if (settled) return;
648
789
  settled = true;
649
- clearTimeout(timer);
790
+ clearTimers();
791
+ clearTimeout(forceKillTimer);
792
+ signal?.removeEventListener('abort', abort);
650
793
  trace?.error('agent_process_error', {
651
794
  command,
652
795
  durationMs: Date.now() - started,
@@ -657,7 +800,9 @@ function runProcess(command, args, stdin, {
657
800
  child.on('close', (code) => {
658
801
  if (settled) return;
659
802
  settled = true;
660
- clearTimeout(timer);
803
+ clearTimers();
804
+ clearTimeout(forceKillTimer);
805
+ signal?.removeEventListener('abort', abort);
661
806
  const meta = {
662
807
  command,
663
808
  code,
@@ -742,7 +887,7 @@ class CompanionRunCancelledError extends Error {
742
887
  async function callProviderAdapter(adapter, input, {
743
888
  send,
744
889
  trace,
745
- controlPollMs = 5000,
890
+ controlPollMs = 250,
746
891
  } = {}) {
747
892
  if (!adapter || typeof adapter.runModelTurn !== 'function') {
748
893
  throw new Error('The selected provider adapter cannot execute model turns.');
@@ -827,6 +972,83 @@ export async function resolveAgentRuntime(definition, modelDefinition, options =
827
972
  return selectAgentRuntime(inspections, definition, modelDefinition);
828
973
  }
829
974
 
975
+ function parseClaudeAuthenticationStatus(output) {
976
+ const text = String(output || '').trim();
977
+ if (!text) return null;
978
+ try {
979
+ const parsed = JSON.parse(text);
980
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
981
+ return {
982
+ loggedIn: parsed.loggedIn === true,
983
+ authMethod: typeof parsed.authMethod === 'string' ? parsed.authMethod : null,
984
+ apiProvider: typeof parsed.apiProvider === 'string' ? parsed.apiProvider : null,
985
+ };
986
+ } catch {
987
+ return null;
988
+ }
989
+ }
990
+
991
+ async function inspectClaudeAuthentication(command, { childEnv, platform } = {}) {
992
+ try {
993
+ const result = await runProcess(command, ['auth', 'status', '--json'], '', {
994
+ timeoutMs: 10000,
995
+ childEnv,
996
+ platform,
997
+ });
998
+ return parseClaudeAuthenticationStatus(result.stdout || result.stderr);
999
+ } catch (error) {
1000
+ const parsed = parseClaudeAuthenticationStatus(error?.stdout || error?.stderr);
1001
+ if (parsed) return parsed;
1002
+ throw error;
1003
+ }
1004
+ }
1005
+
1006
+ export async function checkAgentAuthentication(agent, runtime, options = {}) {
1007
+ const normalizedAgent = normalizeAgentName(agent);
1008
+ if (normalizedAgent !== 'claude-code') {
1009
+ return {
1010
+ ok: true,
1011
+ signedIn: true,
1012
+ status: 'ready',
1013
+ };
1014
+ }
1015
+ if (!runtime?.ok || !runtime.command) {
1016
+ return {
1017
+ ok: false,
1018
+ signedIn: false,
1019
+ status: 'unavailable',
1020
+ code: runtime?.code,
1021
+ error: runtime?.error || 'Claude Code is not available.',
1022
+ };
1023
+ }
1024
+
1025
+ const platform = options.platform || process.platform;
1026
+ const childEnv = options.env || processEnvWithCliPath(platform);
1027
+ const inspect = options.inspect || inspectClaudeAuthentication;
1028
+ try {
1029
+ const authentication = await inspect(runtime.command, { childEnv, platform });
1030
+ if (authentication?.loggedIn === true) {
1031
+ return {
1032
+ ok: true,
1033
+ signedIn: true,
1034
+ status: 'ready',
1035
+ authMethod: authentication.authMethod || null,
1036
+ apiProvider: authentication.apiProvider || null,
1037
+ };
1038
+ }
1039
+ } catch {
1040
+ // A failed or unreadable auth-status probe is not healthy enough to start a run.
1041
+ }
1042
+
1043
+ return {
1044
+ ok: false,
1045
+ signedIn: false,
1046
+ status: 'authentication_required',
1047
+ code: AGENT_AUTHENTICATION_REQUIRED_CODE,
1048
+ error: CLAUDE_AUTHENTICATION_REQUIRED_MESSAGE,
1049
+ };
1050
+ }
1051
+
830
1052
  async function callLocalJsonAgent(agent, prompt, options = {}) {
831
1053
  const definition = definitionForAgent(agent);
832
1054
  const command = options.runtime?.command || commandFromEnv(definition.commandEnv, definition.fallbackCommand);
@@ -834,8 +1056,14 @@ async function callLocalJsonAgent(agent, prompt, options = {}) {
834
1056
  const args = buildAgentArgs(definition, modelDefinition, process.env, {
835
1057
  resumeSessionId: options.resumeSessionId,
836
1058
  outputSchema: options.outputSchema,
1059
+ step: options.step,
837
1060
  });
838
1061
  const timeoutMs = options.timeoutMs || Number(process.env.DEXTER_BRIDGE_AGENT_TIMEOUT_MS || 120000);
1062
+ const maxDurationMs = boundedDurationMs(
1063
+ options.maxDurationMs ?? process.env.DEXTER_BRIDGE_AGENT_MAX_DURATION_MS,
1064
+ Math.max(timeoutMs * 5, 600000),
1065
+ 10,
1066
+ );
839
1067
  options.trace?.info('agent_step_invoke', {
840
1068
  agent: definition.id,
841
1069
  command,
@@ -847,13 +1075,29 @@ async function callLocalJsonAgent(agent, prompt, options = {}) {
847
1075
  promptChars: prompt.length,
848
1076
  resumeSessionId: options.resumeSessionId,
849
1077
  timeoutMs,
1078
+ maxDurationMs,
850
1079
  });
851
1080
  return runProcess(command, args, prompt, {
852
1081
  timeoutMs,
1082
+ maxDurationMs,
853
1083
  trace: options.trace,
1084
+ signal: options.signal,
854
1085
  });
855
1086
  }
856
1087
 
1088
+ function agentErrorWithOutputUsage(agent, error) {
1089
+ const failure = error instanceof Error
1090
+ ? error
1091
+ : new Error(String(error || 'The companion model call failed.'));
1092
+ const parsed = parseAgentOutput(agent, failure.stdout || '');
1093
+ if (!parsed.usageAvailable) return failure;
1094
+ const accumulator = createCompanionUsageAccumulator(agent);
1095
+ accumulator.add(failure.companionUsage || {});
1096
+ accumulator.add(parsed);
1097
+ failure.companionUsage = accumulator.snapshot();
1098
+ return failure;
1099
+ }
1100
+
857
1101
  async function executeModelTurnRun(run, send, agent, options = {}) {
858
1102
  if (normalizeAgentName(agent) === 'dry-run') {
859
1103
  await send('done', {
@@ -952,15 +1196,22 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
952
1196
  120000,
953
1197
  1000,
954
1198
  ),
1199
+ maxDurationMs: boundedDurationMs(
1200
+ run?.modelTurn?.maxDurationMs
1201
+ ?? options.maxDurationMs
1202
+ ?? options.env?.DEXTER_BRIDGE_AGENT_MAX_DURATION_MS,
1203
+ 600000,
1204
+ 1000,
1205
+ ),
955
1206
  };
956
1207
  const adapterOptions = {
957
1208
  send,
958
1209
  trace: options.trace,
959
1210
  controlPollMs: boundedDurationMs(
960
1211
  options.controlPollMs ?? options.env?.DEXTER_BRIDGE_CONTROL_POLL_MS,
961
- 5000,
962
- 10,
963
- 30000,
1212
+ 250,
1213
+ 50,
1214
+ 1000,
964
1215
  ),
965
1216
  };
966
1217
  let result;
@@ -987,7 +1238,7 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
987
1238
  resultText = result?.text;
988
1239
  providerSessionId = result?.threadId || result?.sessionId;
989
1240
  } else {
990
- const runtime = await resolveAgentRuntime(definition, selectedModel, {
1241
+ const runtime = options.runtime || await resolveAgentRuntime(definition, selectedModel, {
991
1242
  env: options.env,
992
1243
  });
993
1244
  if (!runtime.ok) {
@@ -998,6 +1249,41 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
998
1249
  && agentSessionResumeEnabled(definition, options.env)
999
1250
  ? rememberedSession?.providerSessionId
1000
1251
  : undefined;
1252
+ const cliAbort = new AbortController();
1253
+ const monitorAbort = new AbortController();
1254
+ const controlPollMs = boundedDurationMs(
1255
+ options.controlPollMs ?? options.env?.DEXTER_BRIDGE_CONTROL_POLL_MS,
1256
+ 250,
1257
+ 50,
1258
+ 1000,
1259
+ );
1260
+ let cliFinished = false;
1261
+ let cliCancelled = false;
1262
+ const controlMonitor = (async () => {
1263
+ while (!cliFinished && !monitorAbort.signal.aborted) {
1264
+ try {
1265
+ await waitForControl(controlPollMs, monitorAbort.signal);
1266
+ if (cliFinished || monitorAbort.signal.aborted) return;
1267
+ const response = await send('activity', {
1268
+ stage: 'model_turn',
1269
+ message: `${definition.label} is still generating the next Dexter action.`,
1270
+ });
1271
+ if (controlRequestsCancellation(response)) {
1272
+ cliCancelled = true;
1273
+ cliAbort.abort(Object.assign(
1274
+ new Error('The Dexter companion model turn was cancelled.'),
1275
+ { code: 'RUN_CANCELLED' },
1276
+ ));
1277
+ return;
1278
+ }
1279
+ } catch (error) {
1280
+ if (monitorAbort.signal.aborted) return;
1281
+ options.trace?.warn('cli_control_poll_failed', {
1282
+ error: errorMeta(error),
1283
+ });
1284
+ }
1285
+ }
1286
+ })();
1001
1287
  let result;
1002
1288
  try {
1003
1289
  result = await callLocalJsonAgent(definition.id, prompt, {
@@ -1006,15 +1292,23 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
1006
1292
  runtime,
1007
1293
  resumeSessionId,
1008
1294
  outputSchema: modelTurnOutputSchema(run.modelTurn),
1295
+ step: run?.modelTurn?.step,
1296
+ maxDurationMs: run?.modelTurn?.maxDurationMs ?? options.maxDurationMs,
1297
+ signal: cliAbort.signal,
1009
1298
  });
1010
1299
  } catch (error) {
1011
- if (callContextMode !== 'delta' || !resumeFailure(error)) throw failedModelCall(error);
1300
+ if (cliCancelled || error?.code === 'RUN_CANCELLED') {
1301
+ throw new CompanionRunCancelledError();
1302
+ }
1303
+ const failure = agentErrorWithOutputUsage(definition.id, error);
1304
+ if (callContextMode !== 'delta' || !resumeFailure(failure)) throw failedModelCall(failure);
1305
+ usageAccumulator.add(failure.companionUsage || {});
1012
1306
  fallbackAfterResumeFailure = true;
1013
1307
  callContextMode = 'full';
1014
1308
  prompt = buildModelTurnFallbackPrompt(run.modelTurn);
1015
1309
  options.trace?.warn('agent_cli_resume_fallback', {
1016
1310
  resumeSessionId,
1017
- error: errorMeta(error),
1311
+ error: errorMeta(failure),
1018
1312
  fallbackPromptChars: prompt.length,
1019
1313
  });
1020
1314
  result = await callLocalJsonAgent(definition.id, prompt, {
@@ -1023,9 +1317,19 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
1023
1317
  runtime,
1024
1318
  resumeSessionId: undefined,
1025
1319
  outputSchema: modelTurnOutputSchema(run.modelTurn),
1320
+ step: run?.modelTurn?.step,
1321
+ maxDurationMs: run?.modelTurn?.maxDurationMs ?? options.maxDurationMs,
1322
+ signal: cliAbort.signal,
1026
1323
  }).catch((error) => {
1027
- throw failedModelCall(error);
1324
+ if (cliCancelled || error?.code === 'RUN_CANCELLED') {
1325
+ throw new CompanionRunCancelledError();
1326
+ }
1327
+ throw failedModelCall(agentErrorWithOutputUsage(definition.id, error));
1028
1328
  });
1329
+ } finally {
1330
+ cliFinished = true;
1331
+ monitorAbort.abort();
1332
+ await controlMonitor.catch(() => undefined);
1029
1333
  }
1030
1334
  const parsed = parseAgentOutput(definition.id, result.stdout);
1031
1335
  usageAccumulator.add(parsed);
@@ -1092,6 +1396,7 @@ export async function executeRun(run, {
1092
1396
  adapterOptions,
1093
1397
  env = process.env,
1094
1398
  controlPollMs,
1399
+ inspectAgentAuthentication,
1095
1400
  } = {}) {
1096
1401
  if (!run?.runId) throw new Error('Companion run payload is missing runId.');
1097
1402
  if (run?.protocol?.version !== 'dexter-companion-v4') {
@@ -1149,10 +1454,33 @@ export async function executeRun(run, {
1149
1454
  });
1150
1455
 
1151
1456
  try {
1457
+ let runtime;
1458
+ if (!activeAdapter && normalizedAgent !== 'dry-run') {
1459
+ const definition = definitionForAgent(normalizedAgent);
1460
+ const selectedModelDefinition = companionModelDefinition(runModel, normalizedAgent);
1461
+ runtime = await resolveAgentRuntime(definition, selectedModelDefinition, { env });
1462
+ if (!runtime.ok) {
1463
+ throw Object.assign(
1464
+ new Error(runtime.error || `${definition.label} is not available.`),
1465
+ { code: runtime.code },
1466
+ );
1467
+ }
1468
+ const authentication = await checkAgentAuthentication(normalizedAgent, runtime, {
1469
+ env,
1470
+ inspect: inspectAgentAuthentication,
1471
+ });
1472
+ if (!authentication.ok) {
1473
+ throw Object.assign(new Error(authentication.error), {
1474
+ code: authentication.code,
1475
+ status: authentication.status,
1476
+ });
1477
+ }
1478
+ }
1152
1479
  await executeModelTurnRun(run, send, normalizedAgent, {
1153
1480
  model: runModel || selectedModel,
1154
1481
  selectedModel: runModel || selectedModel,
1155
1482
  providerAdapter: activeAdapter,
1483
+ runtime,
1156
1484
  trace,
1157
1485
  env,
1158
1486
  controlPollMs,
@@ -1205,21 +1533,46 @@ export async function checkAgentAvailability(agent, model, options = {}) {
1205
1533
  command: 'dry-run',
1206
1534
  output: 'debug mode',
1207
1535
  models: ['dry-run:default'],
1536
+ installed: true,
1537
+ signedIn: true,
1538
+ status: 'ready',
1208
1539
  };
1209
1540
  }
1210
1541
  const definition = definitionForAgent(normalizedAgent);
1211
1542
  const selectedModel = model ? companionModelDefinition(model?.id || model, normalizedAgent) : undefined;
1212
1543
  const runtime = await resolveAgentRuntime(definition, selectedModel, {
1213
1544
  env: options.env,
1545
+ platform: options.platform,
1546
+ existsSync: options.existsSync,
1547
+ inspect: options.inspectRuntime,
1214
1548
  });
1215
1549
  const supportedModels = companionModelsForAgent(normalizedAgent)
1216
1550
  .filter((candidate) => modelSupportsAgentVersion(candidate, runtime.version))
1217
1551
  .map((candidate) => candidate.id);
1552
+ if (!runtime.ok) {
1553
+ return {
1554
+ ...runtime,
1555
+ agent: definition.id,
1556
+ label: definition.label,
1557
+ models: supportedModels,
1558
+ installed: false,
1559
+ signedIn: false,
1560
+ status: 'unavailable',
1561
+ };
1562
+ }
1563
+ const authentication = await checkAgentAuthentication(normalizedAgent, runtime, {
1564
+ env: options.env,
1565
+ platform: options.platform,
1566
+ inspect: options.inspectAuthentication,
1567
+ });
1218
1568
  return {
1219
1569
  ...runtime,
1570
+ ...authentication,
1571
+ ok: runtime.ok && authentication.ok,
1220
1572
  agent: definition.id,
1221
1573
  label: definition.label,
1222
1574
  models: supportedModels,
1575
+ installed: true,
1223
1576
  };
1224
1577
  }
1225
1578
 
@@ -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,
@@ -101,7 +115,7 @@ export function parseClaudeOutput(stdout) {
101
115
  || parsed.modelUsage;
102
116
  if (!hasEnvelope) {
103
117
  return {
104
- resultText: String(stdout || '').trim(),
118
+ resultText: String(fallbackText || '').trim(),
105
119
  tokenUsage: normalizeCompanionTokenUsage(),
106
120
  modelUsage: [],
107
121
  usageAvailable: false,
@@ -136,19 +150,111 @@ export function parseClaudeOutput(stdout) {
136
150
  };
137
151
  }
138
152
 
139
- function parseJsonLines(stdout) {
140
- return String(stdout || '')
141
- .split(/\r?\n/)
142
- .map((line) => line.trim())
143
- .filter(Boolean)
144
- .flatMap((line) => {
145
- try {
146
- const parsed = JSON.parse(line);
147
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? [parsed] : [];
148
- } catch {
149
- 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;
150
194
  }
151
- });
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);
152
258
  }
153
259
 
154
260
  export function parseCodexOutput(stdout) {
package/src/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import os from 'node:os';
2
2
  import readline from 'node:readline/promises';
3
3
  import {
4
+ BRIDGE_BUILD_FINGERPRINT,
4
5
  BRIDGE_CAPABILITIES,
6
+ BRIDGE_VERSION,
5
7
  clearConfig,
6
8
  defaultConfigDir,
7
9
  normalizeAgentName,
@@ -12,7 +14,12 @@ import {
12
14
  saveConfigPatch,
13
15
  } from './config.js';
14
16
  import { checkAllAgents, executeRun } from './agent.js';
15
- import { claimPairing, heartbeat, pollRun } from './api.js';
17
+ import {
18
+ claimPairing,
19
+ DexterBridgeApiError,
20
+ heartbeat,
21
+ pollRun,
22
+ } from './api.js';
16
23
  import {
17
24
  createRunLogger,
18
25
  errorMeta,
@@ -106,6 +113,30 @@ function wait(delayMs) {
106
113
  return new Promise((resolve) => setTimeout(resolve, delayMs));
107
114
  }
108
115
 
116
+ function isInvalidPairingError(error) {
117
+ return error instanceof DexterBridgeApiError
118
+ && error.status === 401
119
+ && (
120
+ error.body?.code === 'FRAMER_COMPANION_NOT_PAIRED'
121
+ || /not paired|pair again/i.test(error.message)
122
+ );
123
+ }
124
+
125
+ function clearInvalidPairing(configDir, cause) {
126
+ saveConfigPatch({
127
+ deviceToken: null,
128
+ device: null,
129
+ pairedAt: null,
130
+ }, configDir);
131
+ const error = new Error(
132
+ 'This Dexter Bridge pairing was disconnected. Reopen Dexter and run the new pairing command.',
133
+ { cause },
134
+ );
135
+ error.code = 'FRAMER_COMPANION_NOT_PAIRED';
136
+ error.exitCode = 2;
137
+ return error;
138
+ }
139
+
109
140
  function agentEnvironment(config = {}, baseEnv = process.env) {
110
141
  const env = { ...baseEnv };
111
142
  const commands = config.agentCommands && typeof config.agentCommands === 'object'
@@ -130,6 +161,8 @@ async function inspectAvailability(config = {}) {
130
161
  availableModels: available.flatMap((check) => check.models || []).join(','),
131
162
  agentVersions: available.map((check) => `${check.agent}=${check.version || 'unknown'}`).join(','),
132
163
  bridgeCapabilities: BRIDGE_CAPABILITIES.join(','),
164
+ bridgeVersion: BRIDGE_VERSION,
165
+ bridgeBuildFingerprint: BRIDGE_BUILD_FINGERPRINT,
133
166
  },
134
167
  agentCommands: Object.fromEntries(
135
168
  available
@@ -139,7 +172,11 @@ async function inspectAvailability(config = {}) {
139
172
  };
140
173
  } catch {
141
174
  return {
142
- metadata: { bridgeCapabilities: BRIDGE_CAPABILITIES.join(',') },
175
+ metadata: {
176
+ bridgeCapabilities: BRIDGE_CAPABILITIES.join(','),
177
+ bridgeVersion: BRIDGE_VERSION,
178
+ bridgeBuildFingerprint: BRIDGE_BUILD_FINGERPRINT,
179
+ },
143
180
  agentCommands: {},
144
181
  };
145
182
  }
@@ -178,17 +215,23 @@ async function pairCommand({ apiBaseUrl, args, flags, configDir }) {
178
215
  console.log(`API: ${apiBaseUrl}`);
179
216
  }
180
217
 
181
- async function statusCommand({ apiBaseUrl, config }) {
218
+ async function statusCommand({ apiBaseUrl, config, configDir }) {
182
219
  const deviceToken = requireDeviceToken(config);
183
220
  const agent = resolveAgentName({ config });
184
221
  const model = resolveCompanionModelName({ config, agent });
185
- const result = await heartbeat(apiBaseUrl, {
186
- deviceToken,
187
- status: 'ready',
188
- agent,
189
- model,
190
- metadata: await availabilityMetadata(config),
191
- });
222
+ let result;
223
+ try {
224
+ result = await heartbeat(apiBaseUrl, {
225
+ deviceToken,
226
+ status: 'ready',
227
+ agent,
228
+ model,
229
+ metadata: await availabilityMetadata(config),
230
+ });
231
+ } catch (error) {
232
+ if (isInvalidPairingError(error)) throw clearInvalidPairing(configDir, error);
233
+ throw error;
234
+ }
192
235
  console.log(`Status: ${result.device?.online ? 'online' : 'paired'}`);
193
236
  console.log(`Device: ${result.device?.name || 'Dexter Bridge'}`);
194
237
  console.log(`Model: ${result.device?.model?.displayName || model}`);
@@ -240,6 +283,13 @@ async function startCommand({ apiBaseUrl, config, flags, configDir }) {
240
283
  poll = await pollRun(apiBaseUrl, { deviceToken, waitMs, agent, model, metadata });
241
284
  pollFailureCount = 0;
242
285
  } catch (error) {
286
+ if (isInvalidPairingError(error)) {
287
+ pollLogger.warn('pairing_invalidated', {
288
+ status: error.status,
289
+ code: error.body?.code || null,
290
+ });
291
+ throw clearInvalidPairing(configDir, error);
292
+ }
243
293
  if (once) throw error;
244
294
  pollFailureCount += 1;
245
295
  const retryInMs = pollBackoffMs(pollFailureCount);
@@ -320,7 +370,7 @@ export async function runCli(argv) {
320
370
  await startCommand({ apiBaseUrl, config: { ...config, apiBaseUrl }, flags: parsed.flags, configDir });
321
371
  return;
322
372
  case 'status':
323
- await statusCommand({ apiBaseUrl, config });
373
+ await statusCommand({ apiBaseUrl, config, configDir });
324
374
  return;
325
375
  case 'doctor':
326
376
  await doctorCommand();
@@ -334,4 +384,11 @@ export async function runCli(argv) {
334
384
  }
335
385
  }
336
386
 
337
- export const __private__ = { agentEnvironment, parseArgv, pollBackoffMs, usage };
387
+ export const __private__ = {
388
+ agentEnvironment,
389
+ clearInvalidPairing,
390
+ isInvalidPairingError,
391
+ parseArgv,
392
+ pollBackoffMs,
393
+ usage,
394
+ };
package/src/config.js CHANGED
@@ -1,11 +1,28 @@
1
1
  import fs from 'node:fs';
2
+ import crypto from 'node:crypto';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
 
5
6
  export const DEFAULT_API_BASE_URL = 'http://localhost:3800/iwm-api/0.0.1';
6
- export const BRIDGE_VERSION = '0.5.1';
7
+ export const BRIDGE_VERSION = JSON.parse(
8
+ fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
9
+ ).version;
10
+ export const BRIDGE_BUILD_FINGERPRINT = crypto
11
+ .createHash('sha256')
12
+ .update([
13
+ '../package.json',
14
+ './agent.js',
15
+ './agentOutput.js',
16
+ './cli.js',
17
+ './config.js',
18
+ ].map((relativePath) => {
19
+ const url = new URL(relativePath, import.meta.url);
20
+ return `${relativePath}\u0000${fs.readFileSync(url, 'utf8')}`;
21
+ }).join('\u0000'))
22
+ .digest('hex');
7
23
  export const BRIDGE_CAPABILITIES = [
8
24
  'model-turn-v1',
25
+ 'build-fingerprint-v1',
9
26
  ];
10
27
  // Codex is the default local agent: driving Claude Code from a user's Claude.ai
11
28
  // subscription needs prior written approval from Anthropic for commercial use, so
@@ -28,10 +45,10 @@ export const COMPANION_MODEL_DEFINITIONS = [
28
45
  id: 'claude-code:opus',
29
46
  agent: 'claude-code',
30
47
  provider: 'anthropic',
31
- displayName: 'Claude Opus 4.8',
32
- invocationName: 'claude-opus-4-8',
48
+ displayName: 'Claude Opus 5',
49
+ invocationName: 'claude-opus-5',
33
50
  costTier: '$$$',
34
- description: 'Latest Opus-class model via Claude Code.',
51
+ description: 'Powerful reasoning and long-horizon coding via Claude Code.',
35
52
  },
36
53
  {
37
54
  id: 'claude-code:sonnet',
package/src/protocol.js CHANGED
@@ -82,7 +82,27 @@ function compactMessages(messages = [], limit = 64) {
82
82
  : [];
83
83
  }
84
84
 
85
- function modelTurnInstructions() {
85
+ function normalizedToolChoice(modelTurn = {}) {
86
+ const toolChoice = modelTurn?.toolChoice;
87
+ if (toolChoice === 'required') {
88
+ return { required: true, toolName: null };
89
+ }
90
+ if (
91
+ toolChoice
92
+ && typeof toolChoice === 'object'
93
+ && typeof toolChoice.name === 'string'
94
+ && toolChoice.name.trim()
95
+ ) {
96
+ return {
97
+ required: true,
98
+ toolName: toolChoice.name.trim(),
99
+ };
100
+ }
101
+ return { required: false, toolName: null };
102
+ }
103
+
104
+ function modelTurnInstructions(modelTurn = {}) {
105
+ const toolChoice = normalizedToolChoice(modelTurn);
86
106
  return [
87
107
  'You are the model engine for Dexter. The server owns the agent loop and executes all tools.',
88
108
  'Return exactly one JSON object and no markdown.',
@@ -90,7 +110,11 @@ function modelTurnInstructions() {
90
110
  '{"text":"optional assistant text","toolCalls":[{"id":"stable-id","name":"toolName","arguments":"{\\"key\\":\\"value\\"}"}],"finishReason":"tool_calls|stop|length"}',
91
111
  'Each toolCalls[].arguments value must be a JSON-encoded string whose decoded value is an object.',
92
112
  'Use only tools listed below. Do not claim a tool executed; only request it.',
93
- 'When the task is complete, return toolCalls:[] and finishReason:"stop".',
113
+ toolChoice.required
114
+ ? toolChoice.toolName
115
+ ? `This turn must call the "${toolChoice.toolName}" tool. Return at least one tool call and finishReason:"tool_calls".`
116
+ : 'This turn requires a structured tool decision. Return at least one tool call and finishReason:"tool_calls"; do not return plain text only.'
117
+ : 'When the task is complete, return toolCalls:[] and finishReason:"stop".',
94
118
  ];
95
119
  }
96
120
 
@@ -98,7 +122,7 @@ export function buildModelTurnPrompt(modelTurn = {}) {
98
122
  const messages = compactMessages(modelTurn.messages, 64);
99
123
  const tools = compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : []);
100
124
  return [
101
- ...modelTurnInstructions(),
125
+ ...modelTurnInstructions(modelTurn),
102
126
  '',
103
127
  `Tools:\n${JSON.stringify(tools)}`,
104
128
  '',
@@ -112,10 +136,18 @@ export function buildModelTurnDeltaPrompt(modelTurn = {}) {
112
136
  const tools = includeTools
113
137
  ? compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : [])
114
138
  : [];
139
+ const toolChoice = normalizedToolChoice(modelTurn);
115
140
  return [
116
141
  'Continue the existing Dexter model session. The server has already supplied the doctrine, goal, prior messages, and tool catalog.',
117
142
  'Apply only the new canonical messages/state changes below.',
118
143
  'Return exactly one JSON object using the previously established response contract.',
144
+ ...(toolChoice.required
145
+ ? [
146
+ toolChoice.toolName
147
+ ? `This turn must call the "${toolChoice.toolName}" tool and finish with "tool_calls".`
148
+ : 'This turn requires at least one structured tool call and must finish with "tool_calls".',
149
+ ]
150
+ : []),
119
151
  ...(includeTools ? ['', `Updated tools:\n${JSON.stringify(tools)}`] : []),
120
152
  '',
121
153
  `New messages:\n${JSON.stringify(messages)}`,
@@ -136,13 +168,19 @@ export function buildModelTurnFallbackPrompt(modelTurn = {}) {
136
168
 
137
169
  export function modelTurnOutputSchema(modelTurn = {}) {
138
170
  const tools = compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : []);
139
- const toolNames = tools.map((tool) => tool.name);
171
+ const toolChoice = normalizedToolChoice(modelTurn);
172
+ const availableToolNames = tools.map((tool) => tool.name);
173
+ const toolNames =
174
+ toolChoice.toolName && availableToolNames.includes(toolChoice.toolName)
175
+ ? [toolChoice.toolName]
176
+ : availableToolNames;
140
177
  return {
141
178
  type: 'object',
142
179
  properties: {
143
180
  text: { type: 'string' },
144
181
  toolCalls: {
145
182
  type: 'array',
183
+ ...(toolChoice.required ? { minItems: 1 } : {}),
146
184
  maxItems: toolNames.length ? 12 : 0,
147
185
  items: {
148
186
  type: 'object',
@@ -163,7 +201,9 @@ export function modelTurnOutputSchema(modelTurn = {}) {
163
201
  },
164
202
  finishReason: {
165
203
  type: 'string',
166
- enum: ['tool_calls', 'stop', 'length'],
204
+ enum: toolChoice.required
205
+ ? ['tool_calls']
206
+ : ['tool_calls', 'stop', 'length'],
167
207
  },
168
208
  },
169
209
  required: ['text', 'toolCalls', 'finishReason'],