@gakim-digital/dexter-bridge 0.5.1 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -3
- package/package.json +1 -1
- package/src/agent.js +371 -18
- package/src/agentOutput.js +122 -16
- package/src/cli.js +69 -12
- package/src/config.js +21 -4
- package/src/protocol.js +45 -5
package/README.md
CHANGED
|
@@ -75,14 +75,30 @@ The CLI stores the device token in `~/.dexter-bridge/config.json` with mode
|
|
|
75
75
|
credits are not charged for companion runs; the backend records reported usage
|
|
76
76
|
for visibility.
|
|
77
77
|
|
|
78
|
-
Claude Code
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
Claude Code is launched as a model-only engine: local tools, slash commands,
|
|
79
|
+
MCP integrations, browser access, and project-agent context are disabled. The
|
|
80
|
+
bridge supplies a strict JSON schema and a system prompt that requires Dexter
|
|
81
|
+
tool requests to be returned as structured output instead of being executed
|
|
82
|
+
inside Claude Code.
|
|
83
|
+
|
|
84
|
+
Claude Code runs use streaming JSON and Codex runs use JSONL so the bridge can
|
|
85
|
+
report input, output, cache, and reasoning tokens even when a turn is
|
|
86
|
+
interrupted. Claude's reported `total_cost_usd` is displayed as an estimated
|
|
81
87
|
API-equivalent cost, not an amount Dexter charged. If an older or customized
|
|
82
88
|
CLI does not support structured output, set `DEXTER_BRIDGE_STRUCTURED_USAGE=false`;
|
|
83
89
|
the run will continue, but its token usage will be marked unavailable rather
|
|
84
90
|
than estimated from text.
|
|
85
91
|
|
|
92
|
+
`DEXTER_BRIDGE_AGENT_TIMEOUT_MS` is the inactivity limit and resets whenever
|
|
93
|
+
the local model produces output. `DEXTER_BRIDGE_AGENT_MAX_DURATION_MS` is the
|
|
94
|
+
hard wall-clock ceiling; the API may supply a smaller per-turn ceiling from the
|
|
95
|
+
remaining run budget. Claude defaults to low effort for the intent-planner lane
|
|
96
|
+
and follow-up/recovery turns, and medium effort for the opening design turn.
|
|
97
|
+
Override these with `DEXTER_BRIDGE_CLAUDE_PLANNER_EFFORT`,
|
|
98
|
+
`DEXTER_BRIDGE_CLAUDE_MAIN_EFFORT`,
|
|
99
|
+
`DEXTER_BRIDGE_CLAUDE_FOLLOWUP_EFFORT`, or
|
|
100
|
+
`DEXTER_BRIDGE_CLAUDE_EFFORT`.
|
|
101
|
+
|
|
86
102
|
## Debugging
|
|
87
103
|
|
|
88
104
|
The Dexter Bridge terminal is the shell where `dexter-bridge start` is running.
|
package/package.json
CHANGED
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,11 +180,21 @@ function argsWithRequiredAgentFlags(args, definition) {
|
|
|
173
180
|
|
|
174
181
|
const CLAUDE_CLI_MODEL_IDS = {
|
|
175
182
|
fable: 'claude-fable-5',
|
|
176
|
-
opus: 'claude-opus-
|
|
183
|
+
opus: 'claude-opus-5',
|
|
177
184
|
sonnet: 'claude-sonnet-5',
|
|
178
185
|
haiku: 'claude-haiku-4-5-20251001',
|
|
179
186
|
};
|
|
180
187
|
|
|
188
|
+
const CLAUDE_MODEL_ENGINE_SYSTEM_PROMPT = [
|
|
189
|
+
'You are a model-only completion engine embedded inside Dexter.',
|
|
190
|
+
'The user prompt contains Dexter messages and a catalog of remote tools as data.',
|
|
191
|
+
'Never execute, simulate, or emit native Claude Code tool calls for those tool names.',
|
|
192
|
+
'The only allowed tool is StructuredOutput, supplied by the JSON schema.',
|
|
193
|
+
'Encode requested Dexter actions only inside StructuredOutput.toolCalls.',
|
|
194
|
+
'Do not inspect the filesystem, project, shell, plugins, skills, MCP servers, or browser.',
|
|
195
|
+
'Be concise: reason only as much as needed to choose the next remote Dexter action.',
|
|
196
|
+
].join(' ');
|
|
197
|
+
|
|
181
198
|
export function mapClaudeCliModelId(value) {
|
|
182
199
|
const raw = String(value || '').trim();
|
|
183
200
|
return CLAUDE_CLI_MODEL_IDS[raw.toLowerCase()] || raw;
|
|
@@ -195,7 +212,16 @@ function argsWithSelectedModel(args, modelDefinition, definition) {
|
|
|
195
212
|
function argsWithStructuredOutput(args, definition, env = process.env) {
|
|
196
213
|
if (!structuredUsageEnabled(env)) return args;
|
|
197
214
|
if (definition.id === 'claude-code') {
|
|
198
|
-
|
|
215
|
+
const streamed = [
|
|
216
|
+
...argsWithoutFlagValue(args, '--output-format'),
|
|
217
|
+
'--output-format',
|
|
218
|
+
'stream-json',
|
|
219
|
+
];
|
|
220
|
+
if (!argsIncludeFlag(streamed, '--include-partial-messages')) {
|
|
221
|
+
streamed.push('--include-partial-messages');
|
|
222
|
+
}
|
|
223
|
+
if (!argsIncludeFlag(streamed, '--verbose')) streamed.push('--verbose');
|
|
224
|
+
return streamed;
|
|
199
225
|
}
|
|
200
226
|
if (definition.id === 'codex') {
|
|
201
227
|
return argsIncludeFlag(args, '--json') ? args : [...args, '--json'];
|
|
@@ -214,6 +240,41 @@ function argsWithClaudeIsolation(args, definition) {
|
|
|
214
240
|
return isolated;
|
|
215
241
|
}
|
|
216
242
|
|
|
243
|
+
function normalizeClaudeEffort(value, fallback = 'medium') {
|
|
244
|
+
const effort = String(value || '').trim().toLowerCase();
|
|
245
|
+
return ['low', 'medium', 'high', 'xhigh', 'max'].includes(effort)
|
|
246
|
+
? effort
|
|
247
|
+
: fallback;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function claudeEffortForStep(step, env = process.env) {
|
|
251
|
+
if (env.DEXTER_BRIDGE_CLAUDE_EFFORT) {
|
|
252
|
+
return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_EFFORT);
|
|
253
|
+
}
|
|
254
|
+
if (Number(step) < 0) {
|
|
255
|
+
return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_PLANNER_EFFORT, 'low');
|
|
256
|
+
}
|
|
257
|
+
if (Number(step) > 0) {
|
|
258
|
+
return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_FOLLOWUP_EFFORT, 'low');
|
|
259
|
+
}
|
|
260
|
+
return normalizeClaudeEffort(env.DEXTER_BRIDGE_CLAUDE_MAIN_EFFORT, 'medium');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function argsWithClaudeModelEngine(args, definition, options = {}, env = process.env) {
|
|
264
|
+
if (definition.id !== 'claude-code') return args;
|
|
265
|
+
let isolated = argsWithoutFlagValue(args, '--system-prompt');
|
|
266
|
+
isolated = argsWithoutFlagValue(isolated, '--append-system-prompt');
|
|
267
|
+
isolated = argsWithoutFlagValue(isolated, '--agent');
|
|
268
|
+
isolated = argsWithoutFlagValue(isolated, '--effort');
|
|
269
|
+
return [
|
|
270
|
+
...isolated,
|
|
271
|
+
'--system-prompt',
|
|
272
|
+
CLAUDE_MODEL_ENGINE_SYSTEM_PROMPT,
|
|
273
|
+
'--effort',
|
|
274
|
+
claudeEffortForStep(options.step, env),
|
|
275
|
+
];
|
|
276
|
+
}
|
|
277
|
+
|
|
217
278
|
function argsWithoutFlagValue(args, flag) {
|
|
218
279
|
const filtered = [];
|
|
219
280
|
for (let index = 0; index < args.length; index += 1) {
|
|
@@ -316,7 +377,8 @@ export function buildAgentArgs(definition, modelDefinition, env = process.env, o
|
|
|
316
377
|
const modelArgs = argsWithSelectedModel(requiredArgs, modelDefinition, definition);
|
|
317
378
|
const structuredArgs = argsWithStructuredOutput(modelArgs, definition, env);
|
|
318
379
|
const isolatedArgs = argsWithClaudeIsolation(structuredArgs, definition);
|
|
319
|
-
const
|
|
380
|
+
const modelEngineArgs = argsWithClaudeModelEngine(isolatedArgs, definition, options, env);
|
|
381
|
+
const schemaArgs = argsWithOutputSchema(modelEngineArgs, definition, options.outputSchema);
|
|
320
382
|
const resumedArgs = argsWithResumedSession(schemaArgs, definition, options.resumeSessionId);
|
|
321
383
|
return argsWithPromptInput(resumedArgs, definition);
|
|
322
384
|
}
|
|
@@ -593,12 +655,27 @@ export function agentFailureMessage(command, code, stdout = '', stderr = '') {
|
|
|
593
655
|
|
|
594
656
|
function runProcess(command, args, stdin, {
|
|
595
657
|
timeoutMs = 120000,
|
|
658
|
+
maxDurationMs,
|
|
596
659
|
trace,
|
|
597
660
|
childEnv: providedChildEnv,
|
|
598
661
|
platform = process.platform,
|
|
662
|
+
signal,
|
|
663
|
+
killGraceMs = 250,
|
|
599
664
|
} = {}) {
|
|
600
665
|
return new Promise((resolve, reject) => {
|
|
666
|
+
if (signal?.aborted) {
|
|
667
|
+
const error = signal.reason instanceof Error
|
|
668
|
+
? signal.reason
|
|
669
|
+
: new Error('The model process was cancelled.');
|
|
670
|
+
error.code = error.code || 'RUN_CANCELLED';
|
|
671
|
+
reject(error);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
601
674
|
const started = Date.now();
|
|
675
|
+
// timeoutMs is an inactivity timeout (reset whenever the child produces
|
|
676
|
+
// output) so slow-but-streaming model turns are not killed mid-generation;
|
|
677
|
+
// hardDeadlineMs bounds total wall-clock time regardless of activity.
|
|
678
|
+
const hardDeadlineMs = Math.max(maxDurationMs || timeoutMs * 5, 10);
|
|
602
679
|
const childEnv = providedChildEnv || processEnvWithCliPath(platform);
|
|
603
680
|
const cwd = resolveAgentCwd(childEnv, process.cwd(), platform);
|
|
604
681
|
const invocation = processInvocation(command, args, childEnv, platform);
|
|
@@ -610,43 +687,109 @@ function runProcess(command, args, stdin, {
|
|
|
610
687
|
env: processEnvSummary(childEnv),
|
|
611
688
|
stdinChars: stdin.length,
|
|
612
689
|
timeoutMs,
|
|
690
|
+
maxDurationMs: hardDeadlineMs,
|
|
613
691
|
});
|
|
614
692
|
const child = spawn(invocation.command, invocation.args, {
|
|
615
693
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
616
694
|
env: childEnv,
|
|
617
695
|
cwd,
|
|
618
696
|
windowsHide: invocation.windowsHide,
|
|
697
|
+
detached: platform !== 'win32',
|
|
619
698
|
});
|
|
620
699
|
let stdout = '';
|
|
621
700
|
let stderr = '';
|
|
622
701
|
let settled = false;
|
|
623
|
-
|
|
702
|
+
let inactivityTimer = null;
|
|
703
|
+
let forceKillTimer = null;
|
|
704
|
+
const killChildTree = (killSignal) => {
|
|
705
|
+
if (platform !== 'win32' && child.pid) {
|
|
706
|
+
try {
|
|
707
|
+
process.kill(-child.pid, killSignal);
|
|
708
|
+
return;
|
|
709
|
+
} catch {
|
|
710
|
+
// Fall back to the direct child when the process group is already gone.
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
child.kill(killSignal);
|
|
715
|
+
} catch {
|
|
716
|
+
// The child already exited.
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
const clearTimers = () => {
|
|
720
|
+
clearTimeout(inactivityTimer);
|
|
721
|
+
clearTimeout(deadlineTimer);
|
|
722
|
+
};
|
|
723
|
+
const terminateChildTree = () => {
|
|
724
|
+
killChildTree('SIGTERM');
|
|
725
|
+
forceKillTimer = setTimeout(() => killChildTree('SIGKILL'), killGraceMs);
|
|
726
|
+
forceKillTimer.unref?.();
|
|
727
|
+
};
|
|
728
|
+
const abort = () => {
|
|
624
729
|
if (settled) return;
|
|
625
730
|
settled = true;
|
|
626
|
-
|
|
627
|
-
|
|
731
|
+
clearTimers();
|
|
732
|
+
terminateChildTree();
|
|
733
|
+
const error = signal?.reason instanceof Error
|
|
734
|
+
? signal.reason
|
|
735
|
+
: new Error('The model process was cancelled.');
|
|
736
|
+
error.code = error.code || 'RUN_CANCELLED';
|
|
737
|
+
error.stdout = stdout;
|
|
738
|
+
error.stderr = stderr;
|
|
739
|
+
trace?.info('agent_process_cancelled', {
|
|
740
|
+
command,
|
|
741
|
+
durationMs: Date.now() - started,
|
|
742
|
+
});
|
|
743
|
+
reject(error);
|
|
744
|
+
};
|
|
745
|
+
const timeOut = (message) => {
|
|
746
|
+
if (settled) return;
|
|
747
|
+
settled = true;
|
|
748
|
+
clearTimers();
|
|
749
|
+
terminateChildTree();
|
|
750
|
+
const error = new Error(message);
|
|
751
|
+
error.code = 'AGENT_TIMEOUT';
|
|
752
|
+
error.stdout = stdout;
|
|
753
|
+
error.stderr = stderr;
|
|
754
|
+
error.timedOut = true;
|
|
628
755
|
trace?.error('agent_process_timeout', {
|
|
629
756
|
command,
|
|
630
757
|
durationMs: Date.now() - started,
|
|
631
758
|
timeoutMs,
|
|
759
|
+
maxDurationMs: hardDeadlineMs,
|
|
632
760
|
stdoutChars: stdout.length,
|
|
633
761
|
stderrChars: stderr.length,
|
|
634
762
|
stdoutExcerpt: clip(stdout, 1000),
|
|
635
763
|
stderrExcerpt: clip(stderr, 1000),
|
|
636
764
|
});
|
|
637
765
|
reject(error);
|
|
638
|
-
}
|
|
766
|
+
};
|
|
767
|
+
const armInactivityTimer = () => {
|
|
768
|
+
clearTimeout(inactivityTimer);
|
|
769
|
+
inactivityTimer = setTimeout(() => {
|
|
770
|
+
timeOut(`${command} produced no output for ${timeoutMs}ms.`);
|
|
771
|
+
}, timeoutMs);
|
|
772
|
+
};
|
|
773
|
+
const deadlineTimer = setTimeout(() => {
|
|
774
|
+
timeOut(`${command} timed out after ${hardDeadlineMs}ms.`);
|
|
775
|
+
}, hardDeadlineMs);
|
|
776
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
777
|
+
armInactivityTimer();
|
|
639
778
|
|
|
640
779
|
child.stdout.on('data', (chunk) => {
|
|
641
780
|
stdout += chunk.toString('utf8');
|
|
781
|
+
armInactivityTimer();
|
|
642
782
|
});
|
|
643
783
|
child.stderr.on('data', (chunk) => {
|
|
644
784
|
stderr += chunk.toString('utf8');
|
|
785
|
+
armInactivityTimer();
|
|
645
786
|
});
|
|
646
787
|
child.on('error', (error) => {
|
|
647
788
|
if (settled) return;
|
|
648
789
|
settled = true;
|
|
649
|
-
|
|
790
|
+
clearTimers();
|
|
791
|
+
clearTimeout(forceKillTimer);
|
|
792
|
+
signal?.removeEventListener('abort', abort);
|
|
650
793
|
trace?.error('agent_process_error', {
|
|
651
794
|
command,
|
|
652
795
|
durationMs: Date.now() - started,
|
|
@@ -657,7 +800,9 @@ function runProcess(command, args, stdin, {
|
|
|
657
800
|
child.on('close', (code) => {
|
|
658
801
|
if (settled) return;
|
|
659
802
|
settled = true;
|
|
660
|
-
|
|
803
|
+
clearTimers();
|
|
804
|
+
clearTimeout(forceKillTimer);
|
|
805
|
+
signal?.removeEventListener('abort', abort);
|
|
661
806
|
const meta = {
|
|
662
807
|
command,
|
|
663
808
|
code,
|
|
@@ -742,7 +887,7 @@ class CompanionRunCancelledError extends Error {
|
|
|
742
887
|
async function callProviderAdapter(adapter, input, {
|
|
743
888
|
send,
|
|
744
889
|
trace,
|
|
745
|
-
controlPollMs =
|
|
890
|
+
controlPollMs = 250,
|
|
746
891
|
} = {}) {
|
|
747
892
|
if (!adapter || typeof adapter.runModelTurn !== 'function') {
|
|
748
893
|
throw new Error('The selected provider adapter cannot execute model turns.');
|
|
@@ -827,6 +972,83 @@ export async function resolveAgentRuntime(definition, modelDefinition, options =
|
|
|
827
972
|
return selectAgentRuntime(inspections, definition, modelDefinition);
|
|
828
973
|
}
|
|
829
974
|
|
|
975
|
+
function parseClaudeAuthenticationStatus(output) {
|
|
976
|
+
const text = String(output || '').trim();
|
|
977
|
+
if (!text) return null;
|
|
978
|
+
try {
|
|
979
|
+
const parsed = JSON.parse(text);
|
|
980
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
981
|
+
return {
|
|
982
|
+
loggedIn: parsed.loggedIn === true,
|
|
983
|
+
authMethod: typeof parsed.authMethod === 'string' ? parsed.authMethod : null,
|
|
984
|
+
apiProvider: typeof parsed.apiProvider === 'string' ? parsed.apiProvider : null,
|
|
985
|
+
};
|
|
986
|
+
} catch {
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async function inspectClaudeAuthentication(command, { childEnv, platform } = {}) {
|
|
992
|
+
try {
|
|
993
|
+
const result = await runProcess(command, ['auth', 'status', '--json'], '', {
|
|
994
|
+
timeoutMs: 10000,
|
|
995
|
+
childEnv,
|
|
996
|
+
platform,
|
|
997
|
+
});
|
|
998
|
+
return parseClaudeAuthenticationStatus(result.stdout || result.stderr);
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
const parsed = parseClaudeAuthenticationStatus(error?.stdout || error?.stderr);
|
|
1001
|
+
if (parsed) return parsed;
|
|
1002
|
+
throw error;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
export async function checkAgentAuthentication(agent, runtime, options = {}) {
|
|
1007
|
+
const normalizedAgent = normalizeAgentName(agent);
|
|
1008
|
+
if (normalizedAgent !== 'claude-code') {
|
|
1009
|
+
return {
|
|
1010
|
+
ok: true,
|
|
1011
|
+
signedIn: true,
|
|
1012
|
+
status: 'ready',
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
if (!runtime?.ok || !runtime.command) {
|
|
1016
|
+
return {
|
|
1017
|
+
ok: false,
|
|
1018
|
+
signedIn: false,
|
|
1019
|
+
status: 'unavailable',
|
|
1020
|
+
code: runtime?.code,
|
|
1021
|
+
error: runtime?.error || 'Claude Code is not available.',
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const platform = options.platform || process.platform;
|
|
1026
|
+
const childEnv = options.env || processEnvWithCliPath(platform);
|
|
1027
|
+
const inspect = options.inspect || inspectClaudeAuthentication;
|
|
1028
|
+
try {
|
|
1029
|
+
const authentication = await inspect(runtime.command, { childEnv, platform });
|
|
1030
|
+
if (authentication?.loggedIn === true) {
|
|
1031
|
+
return {
|
|
1032
|
+
ok: true,
|
|
1033
|
+
signedIn: true,
|
|
1034
|
+
status: 'ready',
|
|
1035
|
+
authMethod: authentication.authMethod || null,
|
|
1036
|
+
apiProvider: authentication.apiProvider || null,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
} catch {
|
|
1040
|
+
// A failed or unreadable auth-status probe is not healthy enough to start a run.
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return {
|
|
1044
|
+
ok: false,
|
|
1045
|
+
signedIn: false,
|
|
1046
|
+
status: 'authentication_required',
|
|
1047
|
+
code: AGENT_AUTHENTICATION_REQUIRED_CODE,
|
|
1048
|
+
error: CLAUDE_AUTHENTICATION_REQUIRED_MESSAGE,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
830
1052
|
async function callLocalJsonAgent(agent, prompt, options = {}) {
|
|
831
1053
|
const definition = definitionForAgent(agent);
|
|
832
1054
|
const command = options.runtime?.command || commandFromEnv(definition.commandEnv, definition.fallbackCommand);
|
|
@@ -834,8 +1056,14 @@ async function callLocalJsonAgent(agent, prompt, options = {}) {
|
|
|
834
1056
|
const args = buildAgentArgs(definition, modelDefinition, process.env, {
|
|
835
1057
|
resumeSessionId: options.resumeSessionId,
|
|
836
1058
|
outputSchema: options.outputSchema,
|
|
1059
|
+
step: options.step,
|
|
837
1060
|
});
|
|
838
1061
|
const timeoutMs = options.timeoutMs || Number(process.env.DEXTER_BRIDGE_AGENT_TIMEOUT_MS || 120000);
|
|
1062
|
+
const maxDurationMs = boundedDurationMs(
|
|
1063
|
+
options.maxDurationMs ?? process.env.DEXTER_BRIDGE_AGENT_MAX_DURATION_MS,
|
|
1064
|
+
Math.max(timeoutMs * 5, 600000),
|
|
1065
|
+
10,
|
|
1066
|
+
);
|
|
839
1067
|
options.trace?.info('agent_step_invoke', {
|
|
840
1068
|
agent: definition.id,
|
|
841
1069
|
command,
|
|
@@ -847,13 +1075,29 @@ async function callLocalJsonAgent(agent, prompt, options = {}) {
|
|
|
847
1075
|
promptChars: prompt.length,
|
|
848
1076
|
resumeSessionId: options.resumeSessionId,
|
|
849
1077
|
timeoutMs,
|
|
1078
|
+
maxDurationMs,
|
|
850
1079
|
});
|
|
851
1080
|
return runProcess(command, args, prompt, {
|
|
852
1081
|
timeoutMs,
|
|
1082
|
+
maxDurationMs,
|
|
853
1083
|
trace: options.trace,
|
|
1084
|
+
signal: options.signal,
|
|
854
1085
|
});
|
|
855
1086
|
}
|
|
856
1087
|
|
|
1088
|
+
function agentErrorWithOutputUsage(agent, error) {
|
|
1089
|
+
const failure = error instanceof Error
|
|
1090
|
+
? error
|
|
1091
|
+
: new Error(String(error || 'The companion model call failed.'));
|
|
1092
|
+
const parsed = parseAgentOutput(agent, failure.stdout || '');
|
|
1093
|
+
if (!parsed.usageAvailable) return failure;
|
|
1094
|
+
const accumulator = createCompanionUsageAccumulator(agent);
|
|
1095
|
+
accumulator.add(failure.companionUsage || {});
|
|
1096
|
+
accumulator.add(parsed);
|
|
1097
|
+
failure.companionUsage = accumulator.snapshot();
|
|
1098
|
+
return failure;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
857
1101
|
async function executeModelTurnRun(run, send, agent, options = {}) {
|
|
858
1102
|
if (normalizeAgentName(agent) === 'dry-run') {
|
|
859
1103
|
await send('done', {
|
|
@@ -952,15 +1196,22 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
|
|
|
952
1196
|
120000,
|
|
953
1197
|
1000,
|
|
954
1198
|
),
|
|
1199
|
+
maxDurationMs: boundedDurationMs(
|
|
1200
|
+
run?.modelTurn?.maxDurationMs
|
|
1201
|
+
?? options.maxDurationMs
|
|
1202
|
+
?? options.env?.DEXTER_BRIDGE_AGENT_MAX_DURATION_MS,
|
|
1203
|
+
600000,
|
|
1204
|
+
1000,
|
|
1205
|
+
),
|
|
955
1206
|
};
|
|
956
1207
|
const adapterOptions = {
|
|
957
1208
|
send,
|
|
958
1209
|
trace: options.trace,
|
|
959
1210
|
controlPollMs: boundedDurationMs(
|
|
960
1211
|
options.controlPollMs ?? options.env?.DEXTER_BRIDGE_CONTROL_POLL_MS,
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1212
|
+
250,
|
|
1213
|
+
50,
|
|
1214
|
+
1000,
|
|
964
1215
|
),
|
|
965
1216
|
};
|
|
966
1217
|
let result;
|
|
@@ -987,7 +1238,7 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
|
|
|
987
1238
|
resultText = result?.text;
|
|
988
1239
|
providerSessionId = result?.threadId || result?.sessionId;
|
|
989
1240
|
} else {
|
|
990
|
-
const runtime = await resolveAgentRuntime(definition, selectedModel, {
|
|
1241
|
+
const runtime = options.runtime || await resolveAgentRuntime(definition, selectedModel, {
|
|
991
1242
|
env: options.env,
|
|
992
1243
|
});
|
|
993
1244
|
if (!runtime.ok) {
|
|
@@ -998,6 +1249,41 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
|
|
|
998
1249
|
&& agentSessionResumeEnabled(definition, options.env)
|
|
999
1250
|
? rememberedSession?.providerSessionId
|
|
1000
1251
|
: undefined;
|
|
1252
|
+
const cliAbort = new AbortController();
|
|
1253
|
+
const monitorAbort = new AbortController();
|
|
1254
|
+
const controlPollMs = boundedDurationMs(
|
|
1255
|
+
options.controlPollMs ?? options.env?.DEXTER_BRIDGE_CONTROL_POLL_MS,
|
|
1256
|
+
250,
|
|
1257
|
+
50,
|
|
1258
|
+
1000,
|
|
1259
|
+
);
|
|
1260
|
+
let cliFinished = false;
|
|
1261
|
+
let cliCancelled = false;
|
|
1262
|
+
const controlMonitor = (async () => {
|
|
1263
|
+
while (!cliFinished && !monitorAbort.signal.aborted) {
|
|
1264
|
+
try {
|
|
1265
|
+
await waitForControl(controlPollMs, monitorAbort.signal);
|
|
1266
|
+
if (cliFinished || monitorAbort.signal.aborted) return;
|
|
1267
|
+
const response = await send('activity', {
|
|
1268
|
+
stage: 'model_turn',
|
|
1269
|
+
message: `${definition.label} is still generating the next Dexter action.`,
|
|
1270
|
+
});
|
|
1271
|
+
if (controlRequestsCancellation(response)) {
|
|
1272
|
+
cliCancelled = true;
|
|
1273
|
+
cliAbort.abort(Object.assign(
|
|
1274
|
+
new Error('The Dexter companion model turn was cancelled.'),
|
|
1275
|
+
{ code: 'RUN_CANCELLED' },
|
|
1276
|
+
));
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
if (monitorAbort.signal.aborted) return;
|
|
1281
|
+
options.trace?.warn('cli_control_poll_failed', {
|
|
1282
|
+
error: errorMeta(error),
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
})();
|
|
1001
1287
|
let result;
|
|
1002
1288
|
try {
|
|
1003
1289
|
result = await callLocalJsonAgent(definition.id, prompt, {
|
|
@@ -1006,15 +1292,23 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
|
|
|
1006
1292
|
runtime,
|
|
1007
1293
|
resumeSessionId,
|
|
1008
1294
|
outputSchema: modelTurnOutputSchema(run.modelTurn),
|
|
1295
|
+
step: run?.modelTurn?.step,
|
|
1296
|
+
maxDurationMs: run?.modelTurn?.maxDurationMs ?? options.maxDurationMs,
|
|
1297
|
+
signal: cliAbort.signal,
|
|
1009
1298
|
});
|
|
1010
1299
|
} catch (error) {
|
|
1011
|
-
if (
|
|
1300
|
+
if (cliCancelled || error?.code === 'RUN_CANCELLED') {
|
|
1301
|
+
throw new CompanionRunCancelledError();
|
|
1302
|
+
}
|
|
1303
|
+
const failure = agentErrorWithOutputUsage(definition.id, error);
|
|
1304
|
+
if (callContextMode !== 'delta' || !resumeFailure(failure)) throw failedModelCall(failure);
|
|
1305
|
+
usageAccumulator.add(failure.companionUsage || {});
|
|
1012
1306
|
fallbackAfterResumeFailure = true;
|
|
1013
1307
|
callContextMode = 'full';
|
|
1014
1308
|
prompt = buildModelTurnFallbackPrompt(run.modelTurn);
|
|
1015
1309
|
options.trace?.warn('agent_cli_resume_fallback', {
|
|
1016
1310
|
resumeSessionId,
|
|
1017
|
-
error: errorMeta(
|
|
1311
|
+
error: errorMeta(failure),
|
|
1018
1312
|
fallbackPromptChars: prompt.length,
|
|
1019
1313
|
});
|
|
1020
1314
|
result = await callLocalJsonAgent(definition.id, prompt, {
|
|
@@ -1023,9 +1317,19 @@ async function executeModelTurnRun(run, send, agent, options = {}) {
|
|
|
1023
1317
|
runtime,
|
|
1024
1318
|
resumeSessionId: undefined,
|
|
1025
1319
|
outputSchema: modelTurnOutputSchema(run.modelTurn),
|
|
1320
|
+
step: run?.modelTurn?.step,
|
|
1321
|
+
maxDurationMs: run?.modelTurn?.maxDurationMs ?? options.maxDurationMs,
|
|
1322
|
+
signal: cliAbort.signal,
|
|
1026
1323
|
}).catch((error) => {
|
|
1027
|
-
|
|
1324
|
+
if (cliCancelled || error?.code === 'RUN_CANCELLED') {
|
|
1325
|
+
throw new CompanionRunCancelledError();
|
|
1326
|
+
}
|
|
1327
|
+
throw failedModelCall(agentErrorWithOutputUsage(definition.id, error));
|
|
1028
1328
|
});
|
|
1329
|
+
} finally {
|
|
1330
|
+
cliFinished = true;
|
|
1331
|
+
monitorAbort.abort();
|
|
1332
|
+
await controlMonitor.catch(() => undefined);
|
|
1029
1333
|
}
|
|
1030
1334
|
const parsed = parseAgentOutput(definition.id, result.stdout);
|
|
1031
1335
|
usageAccumulator.add(parsed);
|
|
@@ -1092,6 +1396,7 @@ export async function executeRun(run, {
|
|
|
1092
1396
|
adapterOptions,
|
|
1093
1397
|
env = process.env,
|
|
1094
1398
|
controlPollMs,
|
|
1399
|
+
inspectAgentAuthentication,
|
|
1095
1400
|
} = {}) {
|
|
1096
1401
|
if (!run?.runId) throw new Error('Companion run payload is missing runId.');
|
|
1097
1402
|
if (run?.protocol?.version !== 'dexter-companion-v4') {
|
|
@@ -1149,10 +1454,33 @@ export async function executeRun(run, {
|
|
|
1149
1454
|
});
|
|
1150
1455
|
|
|
1151
1456
|
try {
|
|
1457
|
+
let runtime;
|
|
1458
|
+
if (!activeAdapter && normalizedAgent !== 'dry-run') {
|
|
1459
|
+
const definition = definitionForAgent(normalizedAgent);
|
|
1460
|
+
const selectedModelDefinition = companionModelDefinition(runModel, normalizedAgent);
|
|
1461
|
+
runtime = await resolveAgentRuntime(definition, selectedModelDefinition, { env });
|
|
1462
|
+
if (!runtime.ok) {
|
|
1463
|
+
throw Object.assign(
|
|
1464
|
+
new Error(runtime.error || `${definition.label} is not available.`),
|
|
1465
|
+
{ code: runtime.code },
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
const authentication = await checkAgentAuthentication(normalizedAgent, runtime, {
|
|
1469
|
+
env,
|
|
1470
|
+
inspect: inspectAgentAuthentication,
|
|
1471
|
+
});
|
|
1472
|
+
if (!authentication.ok) {
|
|
1473
|
+
throw Object.assign(new Error(authentication.error), {
|
|
1474
|
+
code: authentication.code,
|
|
1475
|
+
status: authentication.status,
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1152
1479
|
await executeModelTurnRun(run, send, normalizedAgent, {
|
|
1153
1480
|
model: runModel || selectedModel,
|
|
1154
1481
|
selectedModel: runModel || selectedModel,
|
|
1155
1482
|
providerAdapter: activeAdapter,
|
|
1483
|
+
runtime,
|
|
1156
1484
|
trace,
|
|
1157
1485
|
env,
|
|
1158
1486
|
controlPollMs,
|
|
@@ -1205,21 +1533,46 @@ export async function checkAgentAvailability(agent, model, options = {}) {
|
|
|
1205
1533
|
command: 'dry-run',
|
|
1206
1534
|
output: 'debug mode',
|
|
1207
1535
|
models: ['dry-run:default'],
|
|
1536
|
+
installed: true,
|
|
1537
|
+
signedIn: true,
|
|
1538
|
+
status: 'ready',
|
|
1208
1539
|
};
|
|
1209
1540
|
}
|
|
1210
1541
|
const definition = definitionForAgent(normalizedAgent);
|
|
1211
1542
|
const selectedModel = model ? companionModelDefinition(model?.id || model, normalizedAgent) : undefined;
|
|
1212
1543
|
const runtime = await resolveAgentRuntime(definition, selectedModel, {
|
|
1213
1544
|
env: options.env,
|
|
1545
|
+
platform: options.platform,
|
|
1546
|
+
existsSync: options.existsSync,
|
|
1547
|
+
inspect: options.inspectRuntime,
|
|
1214
1548
|
});
|
|
1215
1549
|
const supportedModels = companionModelsForAgent(normalizedAgent)
|
|
1216
1550
|
.filter((candidate) => modelSupportsAgentVersion(candidate, runtime.version))
|
|
1217
1551
|
.map((candidate) => candidate.id);
|
|
1552
|
+
if (!runtime.ok) {
|
|
1553
|
+
return {
|
|
1554
|
+
...runtime,
|
|
1555
|
+
agent: definition.id,
|
|
1556
|
+
label: definition.label,
|
|
1557
|
+
models: supportedModels,
|
|
1558
|
+
installed: false,
|
|
1559
|
+
signedIn: false,
|
|
1560
|
+
status: 'unavailable',
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
const authentication = await checkAgentAuthentication(normalizedAgent, runtime, {
|
|
1564
|
+
env: options.env,
|
|
1565
|
+
platform: options.platform,
|
|
1566
|
+
inspect: options.inspectAuthentication,
|
|
1567
|
+
});
|
|
1218
1568
|
return {
|
|
1219
1569
|
...runtime,
|
|
1570
|
+
...authentication,
|
|
1571
|
+
ok: runtime.ok && authentication.ok,
|
|
1220
1572
|
agent: definition.id,
|
|
1221
1573
|
label: definition.label,
|
|
1222
1574
|
models: supportedModels,
|
|
1575
|
+
installed: true,
|
|
1223
1576
|
};
|
|
1224
1577
|
}
|
|
1225
1578
|
|
package/src/agentOutput.js
CHANGED
|
@@ -79,11 +79,25 @@ function parseWholeJson(stdout) {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
function parseJsonLines(stdout) {
|
|
83
|
+
return String(stdout || '')
|
|
84
|
+
.split(/\r?\n/)
|
|
85
|
+
.map((line) => line.trim())
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.flatMap((line) => {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(line);
|
|
90
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? [parsed] : [];
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseClaudeEnvelope(parsed, fallbackText = '') {
|
|
84
98
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
85
99
|
return {
|
|
86
|
-
resultText: String(
|
|
100
|
+
resultText: String(fallbackText || '').trim(),
|
|
87
101
|
tokenUsage: normalizeCompanionTokenUsage(),
|
|
88
102
|
modelUsage: [],
|
|
89
103
|
usageAvailable: false,
|
|
@@ -101,7 +115,7 @@ export function parseClaudeOutput(stdout) {
|
|
|
101
115
|
|| parsed.modelUsage;
|
|
102
116
|
if (!hasEnvelope) {
|
|
103
117
|
return {
|
|
104
|
-
resultText: String(
|
|
118
|
+
resultText: String(fallbackText || '').trim(),
|
|
105
119
|
tokenUsage: normalizeCompanionTokenUsage(),
|
|
106
120
|
modelUsage: [],
|
|
107
121
|
usageAvailable: false,
|
|
@@ -136,19 +150,111 @@ export function parseClaudeOutput(stdout) {
|
|
|
136
150
|
};
|
|
137
151
|
}
|
|
138
152
|
|
|
139
|
-
function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
153
|
+
function mergeMaximumUsage(target, rawUsage) {
|
|
154
|
+
const usage = normalizeCompanionTokenUsage(rawUsage || {});
|
|
155
|
+
for (const field of TOKEN_FIELDS) target[field] = Math.max(target[field], usage[field]);
|
|
156
|
+
target.totalTokens = target.inputTokens + target.outputTokens + target.reasoningOutputTokens;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseClaudeStreamUsage(events) {
|
|
160
|
+
const turns = new Map();
|
|
161
|
+
let activeTurnKey;
|
|
162
|
+
let anonymousSequence = 0;
|
|
163
|
+
let sessionId;
|
|
164
|
+
let partialText = '';
|
|
165
|
+
|
|
166
|
+
const ensureTurn = (key, model) => {
|
|
167
|
+
const resolvedKey = key || activeTurnKey || `turn-${++anonymousSequence}`;
|
|
168
|
+
if (!turns.has(resolvedKey)) {
|
|
169
|
+
turns.set(resolvedKey, {
|
|
170
|
+
model: typeof model === 'string' && model.trim() ? model.trim() : undefined,
|
|
171
|
+
usage: normalizeCompanionTokenUsage(),
|
|
172
|
+
});
|
|
173
|
+
} else if (model && !turns.get(resolvedKey).model) {
|
|
174
|
+
turns.get(resolvedKey).model = model;
|
|
175
|
+
}
|
|
176
|
+
activeTurnKey = resolvedKey;
|
|
177
|
+
return turns.get(resolvedKey);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
for (const row of events) {
|
|
181
|
+
if (
|
|
182
|
+
(row.type === 'system' || row.type === 'init')
|
|
183
|
+
&& typeof (row.session_id || row.sessionId) === 'string'
|
|
184
|
+
) {
|
|
185
|
+
sessionId = row.session_id || row.sessionId;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (row.type === 'assistant' && row.message && typeof row.message === 'object') {
|
|
189
|
+
const message = row.message;
|
|
190
|
+
const turn = ensureTurn(message.id, message.model);
|
|
191
|
+
mergeMaximumUsage(turn.usage, message.usage);
|
|
192
|
+
for (const part of Array.isArray(message.content) ? message.content : []) {
|
|
193
|
+
if (part?.type === 'text' && typeof part.text === 'string') partialText += part.text;
|
|
150
194
|
}
|
|
151
|
-
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (row.type !== 'stream_event' || !row.event || typeof row.event !== 'object') continue;
|
|
198
|
+
const event = row.event;
|
|
199
|
+
if (event.type === 'message_start' && event.message && typeof event.message === 'object') {
|
|
200
|
+
const turn = ensureTurn(event.message.id, event.message.model);
|
|
201
|
+
mergeMaximumUsage(turn.usage, event.message.usage);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (event.type === 'message_delta') {
|
|
205
|
+
const turn = ensureTurn(activeTurnKey);
|
|
206
|
+
mergeMaximumUsage(turn.usage, event.usage);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (
|
|
210
|
+
event.type === 'content_block_delta'
|
|
211
|
+
&& event.delta?.type === 'text_delta'
|
|
212
|
+
&& typeof event.delta.text === 'string'
|
|
213
|
+
) {
|
|
214
|
+
partialText += event.delta.text;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const tokenUsage = normalizeCompanionTokenUsage();
|
|
219
|
+
const modelTotals = new Map();
|
|
220
|
+
for (const turn of turns.values()) {
|
|
221
|
+
for (const field of TOKEN_FIELDS) tokenUsage[field] += turn.usage[field];
|
|
222
|
+
tokenUsage.totalTokens += turn.usage.totalTokens;
|
|
223
|
+
if (!turn.model) continue;
|
|
224
|
+
const current = modelTotals.get(turn.model) || {
|
|
225
|
+
model: turn.model,
|
|
226
|
+
...normalizeCompanionTokenUsage(),
|
|
227
|
+
};
|
|
228
|
+
for (const field of TOKEN_FIELDS) current[field] += turn.usage[field];
|
|
229
|
+
current.totalTokens += turn.usage.totalTokens;
|
|
230
|
+
modelTotals.set(turn.model, current);
|
|
231
|
+
}
|
|
232
|
+
const usageAvailable = usageHasReportedTokens(tokenUsage);
|
|
233
|
+
return {
|
|
234
|
+
resultText: partialText.trim(),
|
|
235
|
+
tokenUsage,
|
|
236
|
+
modelUsage: Array.from(modelTotals.values()),
|
|
237
|
+
sessionId,
|
|
238
|
+
usageAvailable,
|
|
239
|
+
usageSource: 'claude-code',
|
|
240
|
+
usageAccuracy: usageAvailable ? 'reported' : 'unavailable',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function parseClaudeOutput(stdout) {
|
|
245
|
+
const whole = parseWholeJson(stdout);
|
|
246
|
+
if (whole && typeof whole === 'object' && !Array.isArray(whole)) {
|
|
247
|
+
return parseClaudeEnvelope(whole, stdout);
|
|
248
|
+
}
|
|
249
|
+
const events = parseJsonLines(stdout);
|
|
250
|
+
const resultEnvelope = [...events].reverse().find((event) =>
|
|
251
|
+
event.type === 'result'
|
|
252
|
+
|| event.structured_output !== undefined
|
|
253
|
+
|| event.structuredOutput !== undefined
|
|
254
|
+
|| typeof event.result === 'string');
|
|
255
|
+
if (resultEnvelope) return parseClaudeEnvelope(resultEnvelope, stdout);
|
|
256
|
+
if (events.length) return parseClaudeStreamUsage(events);
|
|
257
|
+
return parseClaudeEnvelope(null, stdout);
|
|
152
258
|
}
|
|
153
259
|
|
|
154
260
|
export function parseCodexOutput(stdout) {
|
package/src/cli.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import readline from 'node:readline/promises';
|
|
3
3
|
import {
|
|
4
|
+
BRIDGE_BUILD_FINGERPRINT,
|
|
4
5
|
BRIDGE_CAPABILITIES,
|
|
6
|
+
BRIDGE_VERSION,
|
|
5
7
|
clearConfig,
|
|
6
8
|
defaultConfigDir,
|
|
7
9
|
normalizeAgentName,
|
|
@@ -12,7 +14,12 @@ import {
|
|
|
12
14
|
saveConfigPatch,
|
|
13
15
|
} from './config.js';
|
|
14
16
|
import { checkAllAgents, executeRun } from './agent.js';
|
|
15
|
-
import {
|
|
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,11 +1,28 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
|
|
5
6
|
export const DEFAULT_API_BASE_URL = 'http://localhost:3800/iwm-api/0.0.1';
|
|
6
|
-
export const BRIDGE_VERSION =
|
|
7
|
+
export const BRIDGE_VERSION = JSON.parse(
|
|
8
|
+
fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
9
|
+
).version;
|
|
10
|
+
export const BRIDGE_BUILD_FINGERPRINT = crypto
|
|
11
|
+
.createHash('sha256')
|
|
12
|
+
.update([
|
|
13
|
+
'../package.json',
|
|
14
|
+
'./agent.js',
|
|
15
|
+
'./agentOutput.js',
|
|
16
|
+
'./cli.js',
|
|
17
|
+
'./config.js',
|
|
18
|
+
].map((relativePath) => {
|
|
19
|
+
const url = new URL(relativePath, import.meta.url);
|
|
20
|
+
return `${relativePath}\u0000${fs.readFileSync(url, 'utf8')}`;
|
|
21
|
+
}).join('\u0000'))
|
|
22
|
+
.digest('hex');
|
|
7
23
|
export const BRIDGE_CAPABILITIES = [
|
|
8
24
|
'model-turn-v1',
|
|
25
|
+
'build-fingerprint-v1',
|
|
9
26
|
];
|
|
10
27
|
// Codex is the default local agent: driving Claude Code from a user's Claude.ai
|
|
11
28
|
// subscription needs prior written approval from Anthropic for commercial use, so
|
|
@@ -28,10 +45,10 @@ export const COMPANION_MODEL_DEFINITIONS = [
|
|
|
28
45
|
id: 'claude-code:opus',
|
|
29
46
|
agent: 'claude-code',
|
|
30
47
|
provider: 'anthropic',
|
|
31
|
-
displayName: 'Claude Opus
|
|
32
|
-
invocationName: 'claude-opus-
|
|
48
|
+
displayName: 'Claude Opus 5',
|
|
49
|
+
invocationName: 'claude-opus-5',
|
|
33
50
|
costTier: '$$$',
|
|
34
|
-
description: '
|
|
51
|
+
description: 'Powerful reasoning and long-horizon coding via Claude Code.',
|
|
35
52
|
},
|
|
36
53
|
{
|
|
37
54
|
id: 'claude-code:sonnet',
|
package/src/protocol.js
CHANGED
|
@@ -82,7 +82,27 @@ function compactMessages(messages = [], limit = 64) {
|
|
|
82
82
|
: [];
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
function
|
|
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'],
|