@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 +1 -1
- package/src/agent.js +250 -14
- package/src/cli.js +69 -12
- package/src/config.js +18 -3
- package/src/protocol.js +45 -5
package/package.json
CHANGED
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?.
|
|
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-
|
|
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
|
-
|
|
689
|
-
|
|
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
|
-
|
|
731
|
-
clearTimeout(
|
|
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
|
-
|
|
743
|
-
clearTimeout(
|
|
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 =
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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 {
|
|
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: {
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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__ = {
|
|
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
|
|
34
|
-
invocationName: 'claude-opus-
|
|
48
|
+
displayName: 'Claude Opus 5',
|
|
49
|
+
invocationName: 'claude-opus-5',
|
|
35
50
|
costTier: '$$$',
|
|
36
|
-
description: '
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
204
|
+
enum: toolChoice.required
|
|
205
|
+
? ['tool_calls']
|
|
206
|
+
: ['tool_calls', 'stop', 'length'],
|
|
167
207
|
},
|
|
168
208
|
},
|
|
169
209
|
required: ['text', 'toolCalls', 'finishReason'],
|