@gakim-digital/dexter-bridge 0.5.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gakim-digital/dexter-bridge",
3
- "version": "0.5.3",
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,7 +180,7 @@ 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
  };
@@ -652,8 +659,18 @@ function runProcess(command, args, stdin, {
652
659
  trace,
653
660
  childEnv: providedChildEnv,
654
661
  platform = process.platform,
662
+ signal,
663
+ killGraceMs = 250,
655
664
  } = {}) {
656
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
+ }
657
674
  const started = Date.now();
658
675
  // timeoutMs is an inactivity timeout (reset whenever the child produces
659
676
  // output) so slow-but-streaming model turns are not killed mid-generation;
@@ -677,17 +694,59 @@ function runProcess(command, args, stdin, {
677
694
  env: childEnv,
678
695
  cwd,
679
696
  windowsHide: invocation.windowsHide,
697
+ detached: platform !== 'win32',
680
698
  });
681
699
  let stdout = '';
682
700
  let stderr = '';
683
701
  let settled = false;
684
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 = () => {
729
+ if (settled) return;
730
+ settled = true;
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
+ };
685
745
  const timeOut = (message) => {
686
746
  if (settled) return;
687
747
  settled = true;
688
- clearTimeout(inactivityTimer);
689
- clearTimeout(deadlineTimer);
690
- child.kill('SIGTERM');
748
+ clearTimers();
749
+ terminateChildTree();
691
750
  const error = new Error(message);
692
751
  error.code = 'AGENT_TIMEOUT';
693
752
  error.stdout = stdout;
@@ -714,6 +773,7 @@ function runProcess(command, args, stdin, {
714
773
  const deadlineTimer = setTimeout(() => {
715
774
  timeOut(`${command} timed out after ${hardDeadlineMs}ms.`);
716
775
  }, hardDeadlineMs);
776
+ signal?.addEventListener('abort', abort, { once: true });
717
777
  armInactivityTimer();
718
778
 
719
779
  child.stdout.on('data', (chunk) => {
@@ -727,8 +787,9 @@ function runProcess(command, args, stdin, {
727
787
  child.on('error', (error) => {
728
788
  if (settled) return;
729
789
  settled = true;
730
- clearTimeout(inactivityTimer);
731
- clearTimeout(deadlineTimer);
790
+ clearTimers();
791
+ clearTimeout(forceKillTimer);
792
+ signal?.removeEventListener('abort', abort);
732
793
  trace?.error('agent_process_error', {
733
794
  command,
734
795
  durationMs: Date.now() - started,
@@ -739,8 +800,9 @@ function runProcess(command, args, stdin, {
739
800
  child.on('close', (code) => {
740
801
  if (settled) return;
741
802
  settled = true;
742
- clearTimeout(inactivityTimer);
743
- clearTimeout(deadlineTimer);
803
+ clearTimers();
804
+ clearTimeout(forceKillTimer);
805
+ signal?.removeEventListener('abort', abort);
744
806
  const meta = {
745
807
  command,
746
808
  code,
@@ -825,7 +887,7 @@ class CompanionRunCancelledError extends Error {
825
887
  async function callProviderAdapter(adapter, input, {
826
888
  send,
827
889
  trace,
828
- controlPollMs = 5000,
890
+ controlPollMs = 250,
829
891
  } = {}) {
830
892
  if (!adapter || typeof adapter.runModelTurn !== 'function') {
831
893
  throw new Error('The selected provider adapter cannot execute model turns.');
@@ -910,6 +972,83 @@ export async function resolveAgentRuntime(definition, modelDefinition, options =
910
972
  return selectAgentRuntime(inspections, definition, modelDefinition);
911
973
  }
912
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
+
913
1052
  async function callLocalJsonAgent(agent, prompt, options = {}) {
914
1053
  const definition = definitionForAgent(agent);
915
1054
  const command = options.runtime?.command || commandFromEnv(definition.commandEnv, definition.fallbackCommand);
@@ -942,6 +1081,7 @@ async function callLocalJsonAgent(agent, prompt, options = {}) {
942
1081
  timeoutMs,
943
1082
  maxDurationMs,
944
1083
  trace: options.trace,
1084
+ signal: options.signal,
945
1085
  });
946
1086
  }
947
1087
 
@@ -1069,9 +1209,9 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
1069
1209
  trace: options.trace,
1070
1210
  controlPollMs: boundedDurationMs(
1071
1211
  options.controlPollMs ?? options.env?.DEXTER_BRIDGE_CONTROL_POLL_MS,
1072
- 5000,
1073
- 10,
1074
- 30000,
1212
+ 250,
1213
+ 50,
1214
+ 1000,
1075
1215
  ),
1076
1216
  };
1077
1217
  let result;
@@ -1098,7 +1238,7 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
1098
1238
  resultText = result?.text;
1099
1239
  providerSessionId = result?.threadId || result?.sessionId;
1100
1240
  } else {
1101
- const runtime = await resolveAgentRuntime(definition, selectedModel, {
1241
+ const runtime = options.runtime || await resolveAgentRuntime(definition, selectedModel, {
1102
1242
  env: options.env,
1103
1243
  });
1104
1244
  if (!runtime.ok) {
@@ -1109,6 +1249,41 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
1109
1249
  && agentSessionResumeEnabled(definition, options.env)
1110
1250
  ? rememberedSession?.providerSessionId
1111
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
+ })();
1112
1287
  let result;
1113
1288
  try {
1114
1289
  result = await callLocalJsonAgent(definition.id, prompt, {
@@ -1119,8 +1294,12 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
1119
1294
  outputSchema: modelTurnOutputSchema(run.modelTurn),
1120
1295
  step: run?.modelTurn?.step,
1121
1296
  maxDurationMs: run?.modelTurn?.maxDurationMs ?? options.maxDurationMs,
1297
+ signal: cliAbort.signal,
1122
1298
  });
1123
1299
  } catch (error) {
1300
+ if (cliCancelled || error?.code === 'RUN_CANCELLED') {
1301
+ throw new CompanionRunCancelledError();
1302
+ }
1124
1303
  const failure = agentErrorWithOutputUsage(definition.id, error);
1125
1304
  if (callContextMode !== 'delta' || !resumeFailure(failure)) throw failedModelCall(failure);
1126
1305
  usageAccumulator.add(failure.companionUsage || {});
@@ -1140,9 +1319,17 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
1140
1319
  outputSchema: modelTurnOutputSchema(run.modelTurn),
1141
1320
  step: run?.modelTurn?.step,
1142
1321
  maxDurationMs: run?.modelTurn?.maxDurationMs ?? options.maxDurationMs,
1322
+ signal: cliAbort.signal,
1143
1323
  }).catch((error) => {
1324
+ if (cliCancelled || error?.code === 'RUN_CANCELLED') {
1325
+ throw new CompanionRunCancelledError();
1326
+ }
1144
1327
  throw failedModelCall(agentErrorWithOutputUsage(definition.id, error));
1145
1328
  });
1329
+ } finally {
1330
+ cliFinished = true;
1331
+ monitorAbort.abort();
1332
+ await controlMonitor.catch(() => undefined);
1146
1333
  }
1147
1334
  const parsed = parseAgentOutput(definition.id, result.stdout);
1148
1335
  usageAccumulator.add(parsed);
@@ -1209,6 +1396,7 @@ export async function executeRun(run, {
1209
1396
  adapterOptions,
1210
1397
  env = process.env,
1211
1398
  controlPollMs,
1399
+ inspectAgentAuthentication,
1212
1400
  } = {}) {
1213
1401
  if (!run?.runId) throw new Error('Companion run payload is missing runId.');
1214
1402
  if (run?.protocol?.version !== 'dexter-companion-v4') {
@@ -1266,10 +1454,33 @@ export async function executeRun(run, {
1266
1454
  });
1267
1455
 
1268
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
+ }
1269
1479
  await executeModelTurnRun(run, send, normalizedAgent, {
1270
1480
  model: runModel || selectedModel,
1271
1481
  selectedModel: runModel || selectedModel,
1272
1482
  providerAdapter: activeAdapter,
1483
+ runtime,
1273
1484
  trace,
1274
1485
  env,
1275
1486
  controlPollMs,
@@ -1322,21 +1533,46 @@ export async function checkAgentAvailability(agent, model, options = {}) {
1322
1533
  command: 'dry-run',
1323
1534
  output: 'debug mode',
1324
1535
  models: ['dry-run:default'],
1536
+ installed: true,
1537
+ signedIn: true,
1538
+ status: 'ready',
1325
1539
  };
1326
1540
  }
1327
1541
  const definition = definitionForAgent(normalizedAgent);
1328
1542
  const selectedModel = model ? companionModelDefinition(model?.id || model, normalizedAgent) : undefined;
1329
1543
  const runtime = await resolveAgentRuntime(definition, selectedModel, {
1330
1544
  env: options.env,
1545
+ platform: options.platform,
1546
+ existsSync: options.existsSync,
1547
+ inspect: options.inspectRuntime,
1331
1548
  });
1332
1549
  const supportedModels = companionModelsForAgent(normalizedAgent)
1333
1550
  .filter((candidate) => modelSupportsAgentVersion(candidate, runtime.version))
1334
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
+ });
1335
1568
  return {
1336
1569
  ...runtime,
1570
+ ...authentication,
1571
+ ok: runtime.ok && authentication.ok,
1337
1572
  agent: definition.id,
1338
1573
  label: definition.label,
1339
1574
  models: supportedModels,
1575
+ installed: true,
1340
1576
  };
1341
1577
  }
1342
1578
 
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,4 +1,5 @@
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
 
@@ -6,8 +7,22 @@ export const DEFAULT_API_BASE_URL = 'http://localhost:3800/iwm-api/0.0.1';
6
7
  export const BRIDGE_VERSION = JSON.parse(
7
8
  fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
8
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');
9
23
  export const BRIDGE_CAPABILITIES = [
10
24
  'model-turn-v1',
25
+ 'build-fingerprint-v1',
11
26
  ];
12
27
  // Codex is the default local agent: driving Claude Code from a user's Claude.ai
13
28
  // subscription needs prior written approval from Anthropic for commercial use, so
@@ -30,10 +45,10 @@ export const COMPANION_MODEL_DEFINITIONS = [
30
45
  id: 'claude-code:opus',
31
46
  agent: 'claude-code',
32
47
  provider: 'anthropic',
33
- displayName: 'Claude Opus 4.8',
34
- invocationName: 'claude-opus-4-8',
48
+ displayName: 'Claude Opus 5',
49
+ invocationName: 'claude-opus-5',
35
50
  costTier: '$$$',
36
- description: 'Latest Opus-class model via Claude Code.',
51
+ description: 'Powerful reasoning and long-horizon coding via Claude Code.',
37
52
  },
38
53
  {
39
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'],