@gakim-digital/dexter-bridge 0.5.3 → 0.5.7

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.7",
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,25 @@ 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
+ './api.js',
17
+ './cli.js',
18
+ './config.js',
19
+ './protocol.js',
20
+ ].map((relativePath) => {
21
+ const url = new URL(relativePath, import.meta.url);
22
+ return `${relativePath}\u0000${fs.readFileSync(url, 'utf8')}`;
23
+ }).join('\u0000'))
24
+ .digest('hex');
9
25
  export const BRIDGE_CAPABILITIES = [
10
26
  'model-turn-v1',
27
+ 'build-fingerprint-v1',
28
+ 'tool-schema-parity-v1',
11
29
  ];
12
30
  // Codex is the default local agent: driving Claude Code from a user's Claude.ai
13
31
  // subscription needs prior written approval from Anthropic for commercial use, so
@@ -30,10 +48,10 @@ export const COMPANION_MODEL_DEFINITIONS = [
30
48
  id: 'claude-code:opus',
31
49
  agent: 'claude-code',
32
50
  provider: 'anthropic',
33
- displayName: 'Claude Opus 4.8',
34
- invocationName: 'claude-opus-4-8',
51
+ displayName: 'Claude Opus 5',
52
+ invocationName: 'claude-opus-5',
35
53
  costTier: '$$$',
36
- description: 'Latest Opus-class model via Claude Code.',
54
+ description: 'Powerful reasoning and long-horizon coding via Claude Code.',
37
55
  },
38
56
  {
39
57
  id: 'claude-code:sonnet',
package/src/protocol.js CHANGED
@@ -1,27 +1,73 @@
1
+ import { createHash } from 'node:crypto';
2
+
1
3
  function isRecord(value) {
2
4
  return value && typeof value === 'object' && !Array.isArray(value);
3
5
  }
4
6
 
5
- function shortText(value, max = 360) {
6
- const text = typeof value === 'string' ? value : JSON.stringify(value ?? '');
7
- return text.length > max ? `${text.slice(0, max)}...[truncated ${text.length - max} chars]` : text;
7
+ function toolCatalogDigest(tools) {
8
+ return createHash('sha256')
9
+ .update(JSON.stringify(tools))
10
+ .digest('hex')
11
+ .slice(0, 24);
8
12
  }
9
13
 
10
- function compactSchema(schema, depth = 0) {
11
- if (!isRecord(schema) || depth > 4) return schema;
12
- const result = {};
13
- for (const key of ['type', 'enum', 'required', 'additionalProperties']) {
14
- if (schema[key] !== undefined) result[key] = schema[key];
14
+ export function assertToolCatalogParity(modelTurn = {}) {
15
+ const tools = Array.isArray(modelTurn.tools) ? modelTurn.tools : [];
16
+ const expected = modelTurn?.session?.toolCatalogHash;
17
+ if (!expected) return toolCatalogDigest(tools);
18
+ const actual = toolCatalogDigest(tools);
19
+ if (actual !== expected) {
20
+ throw Object.assign(
21
+ new Error(
22
+ `Dexter tool schema parity failed: expected ${expected}, received ${actual}.`,
23
+ ),
24
+ {
25
+ code: 'TOOL_SCHEMA_PARITY_ERROR',
26
+ expectedToolCatalogHash: expected,
27
+ actualToolCatalogHash: actual,
28
+ },
29
+ );
15
30
  }
16
- if (isRecord(schema.properties)) {
17
- result.properties = Object.fromEntries(
18
- Object.entries(schema.properties)
19
- .slice(0, 40)
20
- .map(([key, value]) => [key, compactSchema(value, depth + 1)]),
31
+ return actual;
32
+ }
33
+
34
+ function toolParameters(tool) {
35
+ return isRecord(tool?.parameters)
36
+ ? tool.parameters
37
+ : {
38
+ type: 'object',
39
+ properties: {},
40
+ additionalProperties: false,
41
+ };
42
+ }
43
+
44
+ function nestedToolParameters(schema, namespace, outputDefinitions) {
45
+ const source = JSON.parse(JSON.stringify(schema));
46
+ delete source.$schema;
47
+ const localDefinitions = {
48
+ ...(isRecord(source.definitions) ? source.definitions : {}),
49
+ ...(isRecord(source.$defs) ? source.$defs : {}),
50
+ };
51
+ delete source.definitions;
52
+ delete source.$defs;
53
+ const prefix = `${namespace}__`;
54
+ const rewrite = (value) => {
55
+ if (Array.isArray(value)) return value.map(rewrite);
56
+ if (!isRecord(value)) return value;
57
+ return Object.fromEntries(
58
+ Object.entries(value).map(([key, nested]) => {
59
+ if (key === '$ref' && typeof nested === 'string') {
60
+ const match = nested.match(/^#\/(?:definitions|\$defs)\/(.+)$/);
61
+ if (match) return [key, `#/$defs/${prefix}${match[1]}`];
62
+ }
63
+ return [key, rewrite(nested)];
64
+ }),
21
65
  );
66
+ };
67
+ for (const [name, definition] of Object.entries(localDefinitions)) {
68
+ outputDefinitions[`${prefix}${name}`] = rewrite(definition);
22
69
  }
23
- if (schema.items !== undefined) result.items = compactSchema(schema.items, depth + 1);
24
- return result;
70
+ return rewrite(source);
25
71
  }
26
72
 
27
73
  function compactModelTurnContent(content) {
@@ -65,8 +111,8 @@ export function compactToolCatalog(tools = []) {
65
111
  .slice(0, 40)
66
112
  .map((tool) => ({
67
113
  name: typeof tool.name === 'string' ? tool.name : '',
68
- description: shortText(tool.description || '', 360),
69
- parameters: compactSchema(tool.parameters),
114
+ description: typeof tool.description === 'string' ? tool.description : '',
115
+ parameters: toolParameters(tool),
70
116
  }))
71
117
  .filter((tool) => tool.name);
72
118
  }
@@ -82,23 +128,48 @@ function compactMessages(messages = [], limit = 64) {
82
128
  : [];
83
129
  }
84
130
 
85
- function modelTurnInstructions() {
131
+ function normalizedToolChoice(modelTurn = {}) {
132
+ const toolChoice = modelTurn?.toolChoice;
133
+ if (toolChoice === 'required') {
134
+ return { required: true, toolName: null };
135
+ }
136
+ if (
137
+ toolChoice
138
+ && typeof toolChoice === 'object'
139
+ && typeof toolChoice.name === 'string'
140
+ && toolChoice.name.trim()
141
+ ) {
142
+ return {
143
+ required: true,
144
+ toolName: toolChoice.name.trim(),
145
+ };
146
+ }
147
+ return { required: false, toolName: null };
148
+ }
149
+
150
+ function modelTurnInstructions(modelTurn = {}) {
151
+ const toolChoice = normalizedToolChoice(modelTurn);
86
152
  return [
87
153
  'You are the model engine for Dexter. The server owns the agent loop and executes all tools.',
88
154
  'Return exactly one JSON object and no markdown.',
89
155
  'Allowed response:',
90
- '{"text":"optional assistant text","toolCalls":[{"id":"stable-id","name":"toolName","arguments":"{\\"key\\":\\"value\\"}"}],"finishReason":"tool_calls|stop|length"}',
91
- 'Each toolCalls[].arguments value must be a JSON-encoded string whose decoded value is an object.',
156
+ '{"text":"optional assistant text","toolCalls":[{"id":"stable-id","name":"toolName","arguments":{"key":"value"}}],"finishReason":"tool_calls|stop|length"}',
157
+ 'Each toolCalls[].arguments value must be an object matching the exact selected tool schema.',
92
158
  '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".',
159
+ toolChoice.required
160
+ ? toolChoice.toolName
161
+ ? `This turn must call the "${toolChoice.toolName}" tool. Return at least one tool call and finishReason:"tool_calls".`
162
+ : 'This turn requires a structured tool decision. Return at least one tool call and finishReason:"tool_calls"; do not return plain text only.'
163
+ : 'When the task is complete, return toolCalls:[] and finishReason:"stop".',
94
164
  ];
95
165
  }
96
166
 
97
167
  export function buildModelTurnPrompt(modelTurn = {}) {
168
+ assertToolCatalogParity(modelTurn);
98
169
  const messages = compactMessages(modelTurn.messages, 64);
99
170
  const tools = compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : []);
100
171
  return [
101
- ...modelTurnInstructions(),
172
+ ...modelTurnInstructions(modelTurn),
102
173
  '',
103
174
  `Tools:\n${JSON.stringify(tools)}`,
104
175
  '',
@@ -107,15 +178,24 @@ export function buildModelTurnPrompt(modelTurn = {}) {
107
178
  }
108
179
 
109
180
  export function buildModelTurnDeltaPrompt(modelTurn = {}) {
181
+ assertToolCatalogParity(modelTurn);
110
182
  const messages = compactMessages(modelTurn.messages, 24);
111
183
  const includeTools = modelTurn?.session?.toolCatalogChanged === true;
112
184
  const tools = includeTools
113
185
  ? compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : [])
114
186
  : [];
187
+ const toolChoice = normalizedToolChoice(modelTurn);
115
188
  return [
116
189
  'Continue the existing Dexter model session. The server has already supplied the doctrine, goal, prior messages, and tool catalog.',
117
190
  'Apply only the new canonical messages/state changes below.',
118
191
  'Return exactly one JSON object using the previously established response contract.',
192
+ ...(toolChoice.required
193
+ ? [
194
+ toolChoice.toolName
195
+ ? `This turn must call the "${toolChoice.toolName}" tool and finish with "tool_calls".`
196
+ : 'This turn requires at least one structured tool call and must finish with "tool_calls".',
197
+ ]
198
+ : []),
119
199
  ...(includeTools ? ['', `Updated tools:\n${JSON.stringify(tools)}`] : []),
120
200
  '',
121
201
  `New messages:\n${JSON.stringify(messages)}`,
@@ -135,40 +215,69 @@ export function buildModelTurnFallbackPrompt(modelTurn = {}) {
135
215
  }
136
216
 
137
217
  export function modelTurnOutputSchema(modelTurn = {}) {
218
+ assertToolCatalogParity(modelTurn);
138
219
  const tools = compactToolCatalog(Array.isArray(modelTurn.tools) ? modelTurn.tools : []);
139
- const toolNames = tools.map((tool) => tool.name);
140
- return {
220
+ const toolChoice = normalizedToolChoice(modelTurn);
221
+ const selectedTools =
222
+ toolChoice.toolName
223
+ ? tools.filter((tool) => tool.name === toolChoice.toolName)
224
+ : tools;
225
+ const definitions = {};
226
+ const toolCallVariants = selectedTools.map((tool, index) => ({
227
+ type: 'object',
228
+ properties: {
229
+ id: { type: 'string' },
230
+ name: { type: 'string', enum: [tool.name] },
231
+ arguments: nestedToolParameters(
232
+ tool.parameters,
233
+ `tool_${index}_${tool.name.replace(/[^A-Za-z0-9_]/g, '_')}`,
234
+ definitions,
235
+ ),
236
+ },
237
+ required: ['id', 'name', 'arguments'],
238
+ additionalProperties: false,
239
+ }));
240
+ const emptyToolCall = {
241
+ type: 'object',
242
+ properties: {
243
+ id: { type: 'string' },
244
+ name: { type: 'string' },
245
+ arguments: {
246
+ type: 'object',
247
+ properties: {},
248
+ additionalProperties: false,
249
+ },
250
+ },
251
+ required: ['id', 'name', 'arguments'],
252
+ additionalProperties: false,
253
+ };
254
+ const output = {
141
255
  type: 'object',
142
256
  properties: {
143
257
  text: { type: 'string' },
144
258
  toolCalls: {
145
259
  type: 'array',
146
- maxItems: toolNames.length ? 12 : 0,
147
- items: {
148
- type: 'object',
149
- properties: {
150
- id: { type: 'string' },
151
- name: toolNames.length
152
- ? { type: 'string', enum: toolNames }
153
- : { type: 'string' },
154
- // Structured Outputs requires every object schema to declare
155
- // additionalProperties:false. Tool argument shapes differ per
156
- // selected tool, so encode them at this boundary and validate the
157
- // decoded object before returning the completion to Dexter.
158
- arguments: { type: 'string' },
159
- },
160
- required: ['id', 'name', 'arguments'],
161
- additionalProperties: false,
162
- },
260
+ ...(toolChoice.required ? { minItems: 1 } : {}),
261
+ maxItems: selectedTools.length ? 12 : 0,
262
+ items:
263
+ toolCallVariants.length === 1
264
+ ? toolCallVariants[0]
265
+ : toolCallVariants.length > 1
266
+ ? { anyOf: toolCallVariants }
267
+ : emptyToolCall,
163
268
  },
164
269
  finishReason: {
165
270
  type: 'string',
166
- enum: ['tool_calls', 'stop', 'length'],
271
+ enum: toolChoice.required
272
+ ? ['tool_calls']
273
+ : ['tool_calls', 'stop', 'length'],
167
274
  },
168
275
  },
169
276
  required: ['text', 'toolCalls', 'finishReason'],
170
277
  additionalProperties: false,
171
278
  };
279
+ if (Object.keys(definitions).length) output.$defs = definitions;
280
+ return output;
172
281
  }
173
282
 
174
283
  export function normalizeModelTurnCompletion(raw, fallbackModel = 'local-companion') {
@@ -181,16 +290,9 @@ export function normalizeModelTurnCompletion(raw, fallbackModel = 'local-compani
181
290
  if (!isRecord(call) || typeof call.name !== 'string' || !call.name.trim()) {
182
291
  throw new Error(`Model turn tool call ${index + 1} is missing a name.`);
183
292
  }
184
- let toolArguments = call.arguments;
185
- if (typeof toolArguments === 'string') {
186
- try {
187
- toolArguments = JSON.parse(toolArguments);
188
- } catch {
189
- throw new Error(`Model turn tool call ${index + 1} arguments must contain valid JSON.`);
190
- }
191
- }
293
+ const toolArguments = call.arguments;
192
294
  if (toolArguments !== undefined && !isRecord(toolArguments)) {
193
- throw new Error(`Model turn tool call ${index + 1} arguments must decode to an object.`);
295
+ throw new Error(`Model turn tool call ${index + 1} arguments must be an object.`);
194
296
  }
195
297
  return {
196
298
  id: typeof call.id === 'string' && call.id.trim() ? call.id.slice(0, 160) : `local_tool_${index + 1}`,