@gakim-digital/dexter-bridge 0.5.0
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/LICENSE +16 -0
- package/README.md +154 -0
- package/bin/dexter-bridge.js +10 -0
- package/package.json +33 -0
- package/src/agent.js +998 -0
- package/src/agentOutput.js +232 -0
- package/src/api.js +165 -0
- package/src/cli.js +337 -0
- package/src/config.js +207 -0
- package/src/logger.js +183 -0
- package/src/protocol.js +190 -0
- package/src/providers/claudeAgentSdk.js +508 -0
- package/src/providers/codexAppServer.js +457 -0
- package/src/providers/index.js +55 -0
- package/src/providers/jsonRpcClient.js +172 -0
package/src/agent.js
ADDED
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { postRunEvent } from './api.js';
|
|
6
|
+
import { createCompanionUsageAccumulator, parseAgentOutput } from './agentOutput.js';
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_BRIDGE_AGENT,
|
|
9
|
+
companionModelDefinition,
|
|
10
|
+
companionModelsForAgent,
|
|
11
|
+
normalizeAgentName,
|
|
12
|
+
normalizeCompanionModelName,
|
|
13
|
+
} from './config.js';
|
|
14
|
+
import {
|
|
15
|
+
buildModelTurnPrompt,
|
|
16
|
+
extractJsonObject,
|
|
17
|
+
normalizeModelTurnCompletion,
|
|
18
|
+
runSummary,
|
|
19
|
+
} from './protocol.js';
|
|
20
|
+
import {
|
|
21
|
+
clip,
|
|
22
|
+
createRunLogger,
|
|
23
|
+
errorMeta,
|
|
24
|
+
logLocationHint,
|
|
25
|
+
summarizePayload,
|
|
26
|
+
summarizeToolResult,
|
|
27
|
+
} from './logger.js';
|
|
28
|
+
import { createLocalAgentAdapter } from './providers/index.js';
|
|
29
|
+
|
|
30
|
+
export const AGENT_DEFINITIONS = {
|
|
31
|
+
'claude-code': {
|
|
32
|
+
id: 'claude-code',
|
|
33
|
+
label: 'Claude Code',
|
|
34
|
+
commandEnv: 'DEXTER_BRIDGE_CLAUDE_BIN',
|
|
35
|
+
fallbackCommand: 'claude',
|
|
36
|
+
argsEnv: 'DEXTER_BRIDGE_CLAUDE_ARGS',
|
|
37
|
+
defaultArgs: '-p',
|
|
38
|
+
promptMode: 'stdin',
|
|
39
|
+
model: 'claude-code:sonnet',
|
|
40
|
+
statusStage: 'claude_start',
|
|
41
|
+
},
|
|
42
|
+
codex: {
|
|
43
|
+
id: 'codex',
|
|
44
|
+
label: 'Codex',
|
|
45
|
+
commandEnv: 'DEXTER_BRIDGE_CODEX_BIN',
|
|
46
|
+
fallbackCommand: 'codex',
|
|
47
|
+
argsEnv: 'DEXTER_BRIDGE_CODEX_ARGS',
|
|
48
|
+
defaultArgs: 'exec --sandbox read-only --skip-git-repo-check',
|
|
49
|
+
promptMode: 'stdin',
|
|
50
|
+
model: 'codex:gpt-5.5',
|
|
51
|
+
statusStage: 'codex_start',
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function nowIso() {
|
|
56
|
+
return new Date().toISOString();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createEventPoster({ apiBaseUrl, deviceToken, run, fetchImpl, trace }) {
|
|
60
|
+
return async function send(type, payload = {}) {
|
|
61
|
+
const eventId = `${type}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
62
|
+
const started = Date.now();
|
|
63
|
+
trace?.info('event_post_start', {
|
|
64
|
+
type,
|
|
65
|
+
eventId,
|
|
66
|
+
payload: summarizePayload(payload),
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
const response = await postRunEvent(apiBaseUrl, {
|
|
70
|
+
deviceToken,
|
|
71
|
+
runId: run.runId,
|
|
72
|
+
fetchImpl,
|
|
73
|
+
event: {
|
|
74
|
+
type,
|
|
75
|
+
eventId,
|
|
76
|
+
payload,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
trace?.info('event_post_done', {
|
|
80
|
+
type,
|
|
81
|
+
eventId,
|
|
82
|
+
durationMs: Date.now() - started,
|
|
83
|
+
accepted: response?.accepted,
|
|
84
|
+
status: response?.status,
|
|
85
|
+
hasToolResult: Boolean(response?.toolResult),
|
|
86
|
+
toolResult: summarizeToolResult(response?.toolResult),
|
|
87
|
+
});
|
|
88
|
+
return response;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
trace?.error('event_post_failed', {
|
|
91
|
+
type,
|
|
92
|
+
eventId,
|
|
93
|
+
durationMs: Date.now() - started,
|
|
94
|
+
error: errorMeta(error),
|
|
95
|
+
});
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function commandFromEnv(name, fallback, env = process.env) {
|
|
102
|
+
return env[name] || fallback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseExtraArgs(value, fallback = '') {
|
|
106
|
+
const source = value === undefined || value === null || value === '' ? fallback : value;
|
|
107
|
+
if (!source) return [];
|
|
108
|
+
return String(source).split(/\s+/).map((item) => item.trim()).filter(Boolean);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function argsAlreadySelectModel(args) {
|
|
112
|
+
return args.some((arg) => arg === '--model' || arg === '-m' || arg.startsWith('--model='));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function argsIncludeFlag(args, flag) {
|
|
116
|
+
return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function structuredUsageEnabled(env = process.env) {
|
|
120
|
+
return String(env.DEXTER_BRIDGE_STRUCTURED_USAGE || 'true').trim().toLowerCase() !== 'false';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function argsWithRequiredAgentFlags(args, definition) {
|
|
124
|
+
if (definition.id !== 'codex') return args;
|
|
125
|
+
if (argsIncludeFlag(args, '--skip-git-repo-check')) return args;
|
|
126
|
+
return [...args, '--skip-git-repo-check'];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const CLAUDE_CLI_MODEL_IDS = {
|
|
130
|
+
fable: 'claude-fable-5',
|
|
131
|
+
opus: 'claude-opus-4-8',
|
|
132
|
+
sonnet: 'claude-sonnet-5',
|
|
133
|
+
haiku: 'claude-haiku-4-5-20251001',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export function mapClaudeCliModelId(value) {
|
|
137
|
+
const raw = String(value || '').trim();
|
|
138
|
+
return CLAUDE_CLI_MODEL_IDS[raw.toLowerCase()] || raw;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function argsWithSelectedModel(args, modelDefinition, definition) {
|
|
142
|
+
if (!modelDefinition?.invocationName || modelDefinition.invocationName === 'dry-run') return args;
|
|
143
|
+
if (argsAlreadySelectModel(args)) return args;
|
|
144
|
+
const invocationName = definition.id === 'claude-code'
|
|
145
|
+
? mapClaudeCliModelId(modelDefinition.invocationName)
|
|
146
|
+
: modelDefinition.invocationName;
|
|
147
|
+
return [...args, '--model', invocationName];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function argsWithStructuredOutput(args, definition, env = process.env) {
|
|
151
|
+
if (!structuredUsageEnabled(env)) return args;
|
|
152
|
+
if (definition.id === 'claude-code') {
|
|
153
|
+
return argsIncludeFlag(args, '--output-format') ? args : [...args, '--output-format', 'json'];
|
|
154
|
+
}
|
|
155
|
+
if (definition.id === 'codex') {
|
|
156
|
+
return argsIncludeFlag(args, '--json') ? args : [...args, '--json'];
|
|
157
|
+
}
|
|
158
|
+
return args;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function argsWithPromptInput(args, definition) {
|
|
162
|
+
if (definition.id !== 'codex' || args.includes('-')) return args;
|
|
163
|
+
return [...args, '-'];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function argsWithResumedSession(args, definition, sessionId) {
|
|
167
|
+
if (!sessionId) return args;
|
|
168
|
+
if (definition.id === 'claude-code') {
|
|
169
|
+
const withoutExistingResume = [];
|
|
170
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
171
|
+
if (args[index] === '--resume' || args[index] === '-r' || args[index] === '--session-id') {
|
|
172
|
+
index += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
withoutExistingResume.push(args[index]);
|
|
176
|
+
}
|
|
177
|
+
return [...withoutExistingResume, '--resume', sessionId];
|
|
178
|
+
}
|
|
179
|
+
if (definition.id !== 'codex') return args;
|
|
180
|
+
|
|
181
|
+
const resumeArgs = ['exec', 'resume'];
|
|
182
|
+
const optionsWithValues = new Set([
|
|
183
|
+
'--config',
|
|
184
|
+
'-c',
|
|
185
|
+
'--enable',
|
|
186
|
+
'--disable',
|
|
187
|
+
'--model',
|
|
188
|
+
'-m',
|
|
189
|
+
'--profile',
|
|
190
|
+
'-p',
|
|
191
|
+
'--output-schema',
|
|
192
|
+
'--output-last-message',
|
|
193
|
+
'-o',
|
|
194
|
+
]);
|
|
195
|
+
const initialOnlyWithValues = new Set(['--sandbox', '-s', '--cd', '-C', '--add-dir', '--image', '-i']);
|
|
196
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
197
|
+
const arg = args[index];
|
|
198
|
+
if (arg === 'exec' || arg === 'resume' || arg === '-' || arg === '--ephemeral') continue;
|
|
199
|
+
if (initialOnlyWithValues.has(arg)) {
|
|
200
|
+
index += 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
resumeArgs.push(arg);
|
|
204
|
+
if (optionsWithValues.has(arg) && index + 1 < args.length) {
|
|
205
|
+
resumeArgs.push(args[index + 1]);
|
|
206
|
+
index += 1;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return [...resumeArgs, sessionId, '-'];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function agentSessionResumeEnabled(definition, env = process.env) {
|
|
213
|
+
if (String(env.DEXTER_BRIDGE_SESSION_RESUME || 'true').trim().toLowerCase() === 'false') return false;
|
|
214
|
+
const baseArgs = parseExtraArgs(env[definition.argsEnv], definition.defaultArgs);
|
|
215
|
+
if (definition.id === 'claude-code' && argsIncludeFlag(baseArgs, '--no-session-persistence')) return false;
|
|
216
|
+
if (definition.id === 'codex' && argsIncludeFlag(baseArgs, '--ephemeral')) return false;
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function buildAgentArgs(definition, modelDefinition, env = process.env, options = {}) {
|
|
221
|
+
const baseArgs = parseExtraArgs(env[definition.argsEnv], definition.defaultArgs);
|
|
222
|
+
const requiredArgs = argsWithRequiredAgentFlags(baseArgs, definition);
|
|
223
|
+
const modelArgs = argsWithSelectedModel(requiredArgs, modelDefinition, definition);
|
|
224
|
+
const structuredArgs = argsWithStructuredOutput(modelArgs, definition, env);
|
|
225
|
+
const resumedArgs = argsWithResumedSession(structuredArgs, definition, options.resumeSessionId);
|
|
226
|
+
return argsWithPromptInput(resumedArgs, definition);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function parseEnvOutput(output) {
|
|
230
|
+
const result = {};
|
|
231
|
+
for (const line of String(output || '').split(/\r?\n/)) {
|
|
232
|
+
const index = line.indexOf('=');
|
|
233
|
+
if (index <= 0) continue;
|
|
234
|
+
const key = line.slice(0, index);
|
|
235
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
236
|
+
result[key] = line.slice(index + 1);
|
|
237
|
+
}
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let cachedLoginShellEnv;
|
|
242
|
+
let cachedLoginShellEnvMeta = { attempted: false, loaded: false, keys: 0 };
|
|
243
|
+
|
|
244
|
+
function loginShellEnv() {
|
|
245
|
+
if (process.env.DEXTER_BRIDGE_DISABLE_LOGIN_SHELL_ENV === '1' || process.platform === 'win32') {
|
|
246
|
+
cachedLoginShellEnvMeta = { attempted: false, loaded: false, keys: 0, disabled: true };
|
|
247
|
+
return {};
|
|
248
|
+
}
|
|
249
|
+
if (cachedLoginShellEnv) return cachedLoginShellEnv;
|
|
250
|
+
cachedLoginShellEnvMeta = { attempted: true, loaded: false, keys: 0 };
|
|
251
|
+
const shell = process.env.SHELL || '/bin/zsh';
|
|
252
|
+
const shellArgVariants = [
|
|
253
|
+
['-lic', 'env'],
|
|
254
|
+
['-lc', 'env'],
|
|
255
|
+
];
|
|
256
|
+
try {
|
|
257
|
+
let output = '';
|
|
258
|
+
let usedArgs = shellArgVariants[0];
|
|
259
|
+
let lastError;
|
|
260
|
+
for (const args of shellArgVariants) {
|
|
261
|
+
try {
|
|
262
|
+
output = execFileSync(shell, args, {
|
|
263
|
+
encoding: 'utf8',
|
|
264
|
+
env: process.env,
|
|
265
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
266
|
+
timeout: 3000,
|
|
267
|
+
});
|
|
268
|
+
usedArgs = args;
|
|
269
|
+
lastError = undefined;
|
|
270
|
+
break;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
lastError = error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (lastError) throw lastError;
|
|
276
|
+
cachedLoginShellEnv = parseEnvOutput(output);
|
|
277
|
+
cachedLoginShellEnvMeta = {
|
|
278
|
+
attempted: true,
|
|
279
|
+
loaded: true,
|
|
280
|
+
shell,
|
|
281
|
+
shellArgs: usedArgs.join(' '),
|
|
282
|
+
keys: Object.keys(cachedLoginShellEnv).length,
|
|
283
|
+
};
|
|
284
|
+
return cachedLoginShellEnv;
|
|
285
|
+
} catch (error) {
|
|
286
|
+
cachedLoginShellEnv = {};
|
|
287
|
+
cachedLoginShellEnvMeta = {
|
|
288
|
+
attempted: true,
|
|
289
|
+
loaded: false,
|
|
290
|
+
shell,
|
|
291
|
+
keys: 0,
|
|
292
|
+
error: error?.message || String(error || ''),
|
|
293
|
+
};
|
|
294
|
+
return cachedLoginShellEnv;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function mergePathEntries(paths, platform = process.platform) {
|
|
299
|
+
const delimiter = platform === 'win32' ? ';' : ':';
|
|
300
|
+
return paths
|
|
301
|
+
.flatMap((value) => String(value || '').split(delimiter))
|
|
302
|
+
.map((item) => item.trim())
|
|
303
|
+
.filter(Boolean)
|
|
304
|
+
.filter((item, index, list) => list.indexOf(item) === index)
|
|
305
|
+
.join(delimiter);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function processEnvWithCliPath(platform = process.platform) {
|
|
309
|
+
const fallbackPath = platform === 'win32'
|
|
310
|
+
? [path.join(process.env.APPDATA || '', 'npm'), path.join(process.env.LOCALAPPDATA || '', 'Programs')]
|
|
311
|
+
: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'];
|
|
312
|
+
const shellEnv = loginShellEnv();
|
|
313
|
+
const existingPath = String(process.env.PATH || '');
|
|
314
|
+
const mergedPath = mergePathEntries([shellEnv.PATH, existingPath, ...fallbackPath], platform);
|
|
315
|
+
return {
|
|
316
|
+
...shellEnv,
|
|
317
|
+
...process.env,
|
|
318
|
+
PATH: mergedPath,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function resolveAgentCwd(env = process.env, currentWorkingDirectory = process.cwd(), platform = process.platform) {
|
|
323
|
+
const explicit = String(env.DEXTER_BRIDGE_AGENT_CWD || env.DEXTER_BRIDGE_WORKDIR || '').trim();
|
|
324
|
+
if (explicit) return explicit;
|
|
325
|
+
const pathApi = platform === 'win32' ? path.win32 : path.posix;
|
|
326
|
+
const root = currentWorkingDirectory ? pathApi.parse(currentWorkingDirectory).root : '';
|
|
327
|
+
if (currentWorkingDirectory && currentWorkingDirectory !== root) return currentWorkingDirectory;
|
|
328
|
+
return os.homedir() || currentWorkingDirectory || root || '/';
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function processEnvSummary(env, platform = process.platform) {
|
|
332
|
+
const delimiter = platform === 'win32' ? ';' : ':';
|
|
333
|
+
const pathEntries = String(env.PATH || '').split(delimiter).filter(Boolean);
|
|
334
|
+
return {
|
|
335
|
+
hasAzureOpenAiApiKey: Boolean(env.AZURE_OPENAI_API_KEY),
|
|
336
|
+
hasOpenAiApiKey: Boolean(env.OPENAI_API_KEY),
|
|
337
|
+
hasAnthropicApiKey: Boolean(env.ANTHROPIC_API_KEY),
|
|
338
|
+
hasShell: Boolean(env.SHELL),
|
|
339
|
+
pathEntryCount: pathEntries.length,
|
|
340
|
+
pathHead: pathEntries.slice(0, 8),
|
|
341
|
+
loginShellEnv: cachedLoginShellEnvMeta,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function executableCandidates(command, env = process.env, platform = process.platform, existsSync = fs.existsSync) {
|
|
346
|
+
const raw = String(command || '').trim();
|
|
347
|
+
if (!raw) return [];
|
|
348
|
+
if (/[\\/]/.test(raw)) return [raw];
|
|
349
|
+
const delimiter = platform === 'win32' ? ';' : ':';
|
|
350
|
+
const pathApi = platform === 'win32' ? path.win32 : path.posix;
|
|
351
|
+
const pathEntries = String(env.PATH || '').split(delimiter).filter(Boolean);
|
|
352
|
+
const extensions = platform === 'win32'
|
|
353
|
+
? path.win32.extname(raw)
|
|
354
|
+
? ['']
|
|
355
|
+
: String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
|
|
356
|
+
: [''];
|
|
357
|
+
const candidates = [];
|
|
358
|
+
for (const directory of pathEntries) {
|
|
359
|
+
for (const extension of extensions) {
|
|
360
|
+
const candidate = pathApi.join(directory, `${raw}${extension.toLowerCase()}`);
|
|
361
|
+
if (existsSync(candidate)) candidates.push(candidate);
|
|
362
|
+
if (platform === 'win32') {
|
|
363
|
+
const upperCandidate = path.win32.join(directory, `${raw}${extension.toUpperCase()}`);
|
|
364
|
+
if (upperCandidate !== candidate && existsSync(upperCandidate)) candidates.push(upperCandidate);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return [...new Set(candidates.length ? candidates : [raw])];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function resolveExecutableCommand(command, env = process.env, platform = process.platform, existsSync = fs.existsSync) {
|
|
372
|
+
return executableCandidates(command, env, platform, existsSync)[0] || String(command || '').trim();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function parseAgentVersion(output) {
|
|
376
|
+
const match = String(output || '').match(/(?:^|[^\d])v?(\d+)\.(\d+)\.(\d+)(?:[^\d]|$)/i);
|
|
377
|
+
return match ? `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}` : null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function versionParts(value) {
|
|
381
|
+
const match = String(value || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
|
|
382
|
+
return match ? match.slice(1).map(Number) : null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function compareAgentVersions(left, right) {
|
|
386
|
+
const a = versionParts(left);
|
|
387
|
+
const b = versionParts(right);
|
|
388
|
+
if (!a && !b) return 0;
|
|
389
|
+
if (!a) return -1;
|
|
390
|
+
if (!b) return 1;
|
|
391
|
+
for (let index = 0; index < 3; index += 1) {
|
|
392
|
+
if (a[index] > b[index]) return 1;
|
|
393
|
+
if (a[index] < b[index]) return -1;
|
|
394
|
+
}
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function modelSupportsAgentVersion(modelDefinition, version) {
|
|
399
|
+
if (!modelDefinition?.minimumAgentVersion) return true;
|
|
400
|
+
return compareAgentVersions(version, modelDefinition.minimumAgentVersion) >= 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function selectAgentRuntime(inspections, definition, modelDefinition) {
|
|
404
|
+
const successful = inspections.filter((inspection) => inspection?.ok);
|
|
405
|
+
if (!successful.length) {
|
|
406
|
+
const first = inspections[0];
|
|
407
|
+
return {
|
|
408
|
+
ok: false,
|
|
409
|
+
command: first?.command || definition.fallbackCommand,
|
|
410
|
+
error: first?.error || `${definition.label} is not installed or could not be started.`,
|
|
411
|
+
candidates: inspections,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const ranked = successful
|
|
416
|
+
.map((inspection, index) => ({ ...inspection, discoveryIndex: index }))
|
|
417
|
+
.sort((left, right) => compareAgentVersions(right.version, left.version) || left.discoveryIndex - right.discoveryIndex);
|
|
418
|
+
const compatible = ranked.find((inspection) => modelSupportsAgentVersion(modelDefinition, inspection.version));
|
|
419
|
+
if (compatible) {
|
|
420
|
+
return {
|
|
421
|
+
...compatible,
|
|
422
|
+
ok: true,
|
|
423
|
+
candidates: inspections,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const found = ranked[0];
|
|
428
|
+
const requiredVersion = modelDefinition?.minimumAgentVersion;
|
|
429
|
+
const modelLabel = modelDefinition?.displayName || modelDefinition?.id || 'The selected model';
|
|
430
|
+
const foundVersion = found.version || 'an unknown version';
|
|
431
|
+
return {
|
|
432
|
+
...found,
|
|
433
|
+
ok: false,
|
|
434
|
+
code: 'DEXTER_AGENT_MODEL_VERSION_UNSUPPORTED',
|
|
435
|
+
error: `${modelLabel} requires ${definition.label} ${requiredVersion} or newer. Dexter found ${foundVersion} at ${found.command}. Update ${definition.label} or set ${definition.commandEnv} to the full path of a compatible installation.`,
|
|
436
|
+
candidates: inspections,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function quoteWindowsCommandArg(value) {
|
|
441
|
+
return `"${String(value).replace(/%/g, '%%').replace(/(["^&|<>])/g, '^$1')}"`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function processInvocation(command, args, env = process.env, platform = process.platform) {
|
|
445
|
+
const resolvedCommand = resolveExecutableCommand(command, env, platform);
|
|
446
|
+
if (platform === 'win32' && /\.(cmd|bat)$/i.test(resolvedCommand)) {
|
|
447
|
+
const commandLine = [resolvedCommand, ...args].map(quoteWindowsCommandArg).join(' ');
|
|
448
|
+
return {
|
|
449
|
+
command: env.ComSpec || env.COMSPEC || 'cmd.exe',
|
|
450
|
+
args: ['/d', '/s', '/c', commandLine],
|
|
451
|
+
resolvedCommand,
|
|
452
|
+
windowsHide: true,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
return { command: resolvedCommand, args, resolvedCommand, windowsHide: platform === 'win32' };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function safeCommandArgs(args) {
|
|
459
|
+
return args.map((arg) => {
|
|
460
|
+
const text = String(arg);
|
|
461
|
+
if (text.length > 300) return `[long-arg ${text.length} chars]`;
|
|
462
|
+
return text;
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function normalizeDiagnosticLine(line) {
|
|
467
|
+
return String(line || '')
|
|
468
|
+
.trim()
|
|
469
|
+
.replace(/^ERROR:\s*/i, '')
|
|
470
|
+
.replace(/\s+/g, ' ');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function agentFailureMessage(command, code, stdout = '', stderr = '') {
|
|
474
|
+
const combined = [stderr, stdout].filter(Boolean).join('\n').trim();
|
|
475
|
+
const lines = combined
|
|
476
|
+
.split(/\r?\n/)
|
|
477
|
+
.map(normalizeDiagnosticLine)
|
|
478
|
+
.filter(Boolean);
|
|
479
|
+
const diagnosticPatterns = [
|
|
480
|
+
/missing environment variable/i,
|
|
481
|
+
/not inside a trusted directory/i,
|
|
482
|
+
/api key/i,
|
|
483
|
+
/authentication/i,
|
|
484
|
+
/unauthorized/i,
|
|
485
|
+
/permission denied/i,
|
|
486
|
+
/command not found/i,
|
|
487
|
+
/not found/i,
|
|
488
|
+
/rate limit/i,
|
|
489
|
+
/timed out/i,
|
|
490
|
+
/^failed\b/i,
|
|
491
|
+
];
|
|
492
|
+
const diagnostics = lines.filter((line) => diagnosticPatterns.some((pattern) => pattern.test(line)));
|
|
493
|
+
const detail = diagnostics.length
|
|
494
|
+
? diagnostics.slice(-3).join(' ')
|
|
495
|
+
: clip(combined, 1000);
|
|
496
|
+
return `${command} exited with code ${code}.${detail ? ` ${clip(detail, 1000)}` : ''}`.trim();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function runProcess(command, args, stdin, {
|
|
500
|
+
timeoutMs = 120000,
|
|
501
|
+
trace,
|
|
502
|
+
childEnv: providedChildEnv,
|
|
503
|
+
platform = process.platform,
|
|
504
|
+
} = {}) {
|
|
505
|
+
return new Promise((resolve, reject) => {
|
|
506
|
+
const started = Date.now();
|
|
507
|
+
const childEnv = providedChildEnv || processEnvWithCliPath(platform);
|
|
508
|
+
const cwd = resolveAgentCwd(childEnv, process.cwd(), platform);
|
|
509
|
+
const invocation = processInvocation(command, args, childEnv, platform);
|
|
510
|
+
trace?.info('agent_process_spawn', {
|
|
511
|
+
command,
|
|
512
|
+
resolvedCommand: invocation.resolvedCommand,
|
|
513
|
+
args: safeCommandArgs(args),
|
|
514
|
+
cwd,
|
|
515
|
+
env: processEnvSummary(childEnv),
|
|
516
|
+
stdinChars: stdin.length,
|
|
517
|
+
timeoutMs,
|
|
518
|
+
});
|
|
519
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
520
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
521
|
+
env: childEnv,
|
|
522
|
+
cwd,
|
|
523
|
+
windowsHide: invocation.windowsHide,
|
|
524
|
+
});
|
|
525
|
+
let stdout = '';
|
|
526
|
+
let stderr = '';
|
|
527
|
+
let settled = false;
|
|
528
|
+
const timer = setTimeout(() => {
|
|
529
|
+
if (settled) return;
|
|
530
|
+
settled = true;
|
|
531
|
+
child.kill('SIGTERM');
|
|
532
|
+
const error = new Error(`${command} timed out after ${timeoutMs}ms.`);
|
|
533
|
+
trace?.error('agent_process_timeout', {
|
|
534
|
+
command,
|
|
535
|
+
durationMs: Date.now() - started,
|
|
536
|
+
timeoutMs,
|
|
537
|
+
stdoutChars: stdout.length,
|
|
538
|
+
stderrChars: stderr.length,
|
|
539
|
+
stdoutExcerpt: clip(stdout, 1000),
|
|
540
|
+
stderrExcerpt: clip(stderr, 1000),
|
|
541
|
+
});
|
|
542
|
+
reject(error);
|
|
543
|
+
}, timeoutMs);
|
|
544
|
+
|
|
545
|
+
child.stdout.on('data', (chunk) => {
|
|
546
|
+
stdout += chunk.toString('utf8');
|
|
547
|
+
});
|
|
548
|
+
child.stderr.on('data', (chunk) => {
|
|
549
|
+
stderr += chunk.toString('utf8');
|
|
550
|
+
});
|
|
551
|
+
child.on('error', (error) => {
|
|
552
|
+
if (settled) return;
|
|
553
|
+
settled = true;
|
|
554
|
+
clearTimeout(timer);
|
|
555
|
+
trace?.error('agent_process_error', {
|
|
556
|
+
command,
|
|
557
|
+
durationMs: Date.now() - started,
|
|
558
|
+
error: errorMeta(error),
|
|
559
|
+
});
|
|
560
|
+
reject(error);
|
|
561
|
+
});
|
|
562
|
+
child.on('close', (code) => {
|
|
563
|
+
if (settled) return;
|
|
564
|
+
settled = true;
|
|
565
|
+
clearTimeout(timer);
|
|
566
|
+
const meta = {
|
|
567
|
+
command,
|
|
568
|
+
code,
|
|
569
|
+
durationMs: Date.now() - started,
|
|
570
|
+
stdoutChars: stdout.length,
|
|
571
|
+
stderrChars: stderr.length,
|
|
572
|
+
stdoutExcerpt: clip(stdout, 2000),
|
|
573
|
+
stderrExcerpt: clip(stderr, 2000),
|
|
574
|
+
};
|
|
575
|
+
if (code === 0) {
|
|
576
|
+
trace?.info('agent_process_exit', meta);
|
|
577
|
+
resolve({ stdout, stderr, code });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const failureMessage = agentFailureMessage(command, code, stdout, stderr);
|
|
581
|
+
trace?.error('agent_process_exit_nonzero', { ...meta, failureMessage });
|
|
582
|
+
const error = new Error(failureMessage);
|
|
583
|
+
error.stdout = stdout;
|
|
584
|
+
error.stderr = stderr;
|
|
585
|
+
error.exitCode = code;
|
|
586
|
+
reject(error);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
child.stdin.end(stdin);
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function definitionForAgent(agent) {
|
|
594
|
+
return AGENT_DEFINITIONS[normalizeAgentName(agent)] || AGENT_DEFINITIONS[DEFAULT_BRIDGE_AGENT];
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function adapterSessionId(run) {
|
|
598
|
+
return run?.turnId || run?.runId;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function adapterInvocationModel(run, selectedModel, agent) {
|
|
602
|
+
const explicit = run?.companion?.model?.invocationName;
|
|
603
|
+
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
|
|
604
|
+
if (selectedModel?.invocationName) return selectedModel.invocationName;
|
|
605
|
+
const id = run?.companion?.model?.id || run?.model;
|
|
606
|
+
const raw = typeof id === 'string' ? id.trim() : '';
|
|
607
|
+
const prefix = `${normalizeAgentName(agent)}:`;
|
|
608
|
+
return raw.startsWith(prefix) ? raw.slice(prefix.length) : raw || undefined;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function controlRequestsCancellation(response) {
|
|
612
|
+
return Boolean(
|
|
613
|
+
response?.control?.cancelRequested
|
|
614
|
+
|| response?.status === 'cancelled'
|
|
615
|
+
|| response?.status === 'expired',
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function boundedDurationMs(value, fallback, minimum = 10, maximum = 60 * 60 * 1000) {
|
|
620
|
+
const parsed = Number(value);
|
|
621
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
622
|
+
return Math.max(minimum, Math.min(maximum, parsed));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function waitForControl(delayMs, signal) {
|
|
626
|
+
return new Promise((resolve, reject) => {
|
|
627
|
+
if (signal?.aborted) {
|
|
628
|
+
reject(signal.reason || new Error('Control monitor stopped.'));
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const timer = setTimeout(resolve, delayMs);
|
|
632
|
+
signal?.addEventListener('abort', () => {
|
|
633
|
+
clearTimeout(timer);
|
|
634
|
+
reject(signal.reason || new Error('Control monitor stopped.'));
|
|
635
|
+
}, { once: true });
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
class CompanionRunCancelledError extends Error {
|
|
640
|
+
constructor(message = 'The Dexter companion model turn was cancelled.') {
|
|
641
|
+
super(message);
|
|
642
|
+
this.name = 'CompanionRunCancelledError';
|
|
643
|
+
this.code = 'RUN_CANCELLED';
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function callProviderAdapter(adapter, input, {
|
|
648
|
+
send,
|
|
649
|
+
trace,
|
|
650
|
+
controlPollMs = 5000,
|
|
651
|
+
} = {}) {
|
|
652
|
+
if (!adapter || typeof adapter.runModelTurn !== 'function') {
|
|
653
|
+
throw new Error('The selected provider adapter cannot execute model turns.');
|
|
654
|
+
}
|
|
655
|
+
const monitorAbort = new AbortController();
|
|
656
|
+
let finished = false;
|
|
657
|
+
let cancelRequested = false;
|
|
658
|
+
let cancelSent = false;
|
|
659
|
+
|
|
660
|
+
async function requestCancel() {
|
|
661
|
+
cancelRequested = true;
|
|
662
|
+
if (cancelSent) return;
|
|
663
|
+
cancelSent = true;
|
|
664
|
+
await adapter.cancel?.(input.sessionId);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function monitorControl() {
|
|
668
|
+
while (!finished && !monitorAbort.signal.aborted) {
|
|
669
|
+
try {
|
|
670
|
+
await waitForControl(controlPollMs, monitorAbort.signal);
|
|
671
|
+
if (finished || monitorAbort.signal.aborted) return;
|
|
672
|
+
const response = await send('activity', {
|
|
673
|
+
stage: 'model_turn',
|
|
674
|
+
message: `${adapter.label || adapter.id || 'Provider'} is still generating the next Dexter action.`,
|
|
675
|
+
});
|
|
676
|
+
if (controlRequestsCancellation(response)) await requestCancel();
|
|
677
|
+
} catch (error) {
|
|
678
|
+
if (monitorAbort.signal.aborted) return;
|
|
679
|
+
trace?.warn('adapter_control_poll_failed', { error: errorMeta(error) });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const controlMonitor = monitorControl();
|
|
685
|
+
try {
|
|
686
|
+
const result = await adapter.runModelTurn(input);
|
|
687
|
+
if (cancelRequested) throw new CompanionRunCancelledError();
|
|
688
|
+
return result;
|
|
689
|
+
} catch (error) {
|
|
690
|
+
if (cancelRequested) throw new CompanionRunCancelledError();
|
|
691
|
+
throw error;
|
|
692
|
+
} finally {
|
|
693
|
+
finished = true;
|
|
694
|
+
monitorAbort.abort();
|
|
695
|
+
await controlMonitor.catch(() => undefined);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
export async function resolveAgentRuntime(definition, modelDefinition, options = {}) {
|
|
700
|
+
const platform = options.platform || process.platform;
|
|
701
|
+
const childEnv = options.env || processEnvWithCliPath(platform);
|
|
702
|
+
const configuredCommand = commandFromEnv(definition.commandEnv, definition.fallbackCommand, childEnv);
|
|
703
|
+
const candidates = executableCandidates(
|
|
704
|
+
configuredCommand,
|
|
705
|
+
childEnv,
|
|
706
|
+
platform,
|
|
707
|
+
options.existsSync || fs.existsSync,
|
|
708
|
+
);
|
|
709
|
+
const inspect = options.inspect || (async (command) => {
|
|
710
|
+
try {
|
|
711
|
+
const result = await runProcess(command, ['--version'], '', {
|
|
712
|
+
timeoutMs: 10000,
|
|
713
|
+
childEnv,
|
|
714
|
+
platform,
|
|
715
|
+
});
|
|
716
|
+
const output = (result.stdout || result.stderr || '').trim();
|
|
717
|
+
return {
|
|
718
|
+
ok: true,
|
|
719
|
+
command,
|
|
720
|
+
output,
|
|
721
|
+
version: parseAgentVersion(output),
|
|
722
|
+
};
|
|
723
|
+
} catch (error) {
|
|
724
|
+
return {
|
|
725
|
+
ok: false,
|
|
726
|
+
command,
|
|
727
|
+
error: error?.message || String(error || ''),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
const inspections = await Promise.all(candidates.map((candidate) => inspect(candidate)));
|
|
732
|
+
return selectAgentRuntime(inspections, definition, modelDefinition);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function callLocalJsonAgent(agent, prompt, options = {}) {
|
|
736
|
+
const definition = definitionForAgent(agent);
|
|
737
|
+
const command = options.runtime?.command || commandFromEnv(definition.commandEnv, definition.fallbackCommand);
|
|
738
|
+
const modelDefinition = companionModelDefinition(options.model, definition.id);
|
|
739
|
+
const args = buildAgentArgs(definition, modelDefinition, process.env, {
|
|
740
|
+
resumeSessionId: options.resumeSessionId,
|
|
741
|
+
});
|
|
742
|
+
const timeoutMs = options.timeoutMs || Number(process.env.DEXTER_BRIDGE_AGENT_TIMEOUT_MS || 120000);
|
|
743
|
+
options.trace?.info('agent_step_invoke', {
|
|
744
|
+
agent: definition.id,
|
|
745
|
+
command,
|
|
746
|
+
model: modelDefinition?.id,
|
|
747
|
+
invocationModel: modelDefinition?.invocationName,
|
|
748
|
+
resolvedCommand: command,
|
|
749
|
+
agentVersion: options.runtime?.version,
|
|
750
|
+
promptMode: definition.promptMode,
|
|
751
|
+
promptChars: prompt.length,
|
|
752
|
+
resumeSessionId: options.resumeSessionId,
|
|
753
|
+
timeoutMs,
|
|
754
|
+
});
|
|
755
|
+
return runProcess(command, args, prompt, {
|
|
756
|
+
timeoutMs,
|
|
757
|
+
trace: options.trace,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function executeModelTurnRun(run, send, agent, options = {}) {
|
|
762
|
+
if (normalizeAgentName(agent) === 'dry-run') {
|
|
763
|
+
await send('done', {
|
|
764
|
+
operationType: 'chat',
|
|
765
|
+
outcome: 'answer',
|
|
766
|
+
completion: {
|
|
767
|
+
text: 'Dry-run model turn completed.',
|
|
768
|
+
toolCalls: [],
|
|
769
|
+
finishReason: 'stop',
|
|
770
|
+
model: 'dexter-bridge-dry-run',
|
|
771
|
+
},
|
|
772
|
+
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
773
|
+
model: 'dexter-bridge-dry-run',
|
|
774
|
+
});
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const definition = definitionForAgent(agent);
|
|
778
|
+
const requestedModel = run?.companion?.model?.id || run?.model || options.selectedModel;
|
|
779
|
+
const selectedModel = companionModelDefinition(requestedModel, definition.id);
|
|
780
|
+
const selectedModelId = typeof requestedModel === 'string' && requestedModel.trim()
|
|
781
|
+
? requestedModel.trim()
|
|
782
|
+
: selectedModel?.id || definition.model;
|
|
783
|
+
const adapter = options.providerAdapter || null;
|
|
784
|
+
const prompt = buildModelTurnPrompt(run.modelTurn);
|
|
785
|
+
const statusResponse = await send('status', {
|
|
786
|
+
stage: 'model_turn',
|
|
787
|
+
message: `${definition.label} is generating the next Dexter action.`,
|
|
788
|
+
});
|
|
789
|
+
if (controlRequestsCancellation(statusResponse)) {
|
|
790
|
+
if (adapter) await adapter.cancel?.(adapterSessionId(run));
|
|
791
|
+
throw new CompanionRunCancelledError();
|
|
792
|
+
}
|
|
793
|
+
const usageAccumulator = createCompanionUsageAccumulator(definition.id);
|
|
794
|
+
let resultText;
|
|
795
|
+
if (adapter) {
|
|
796
|
+
const sessionId = adapterSessionId(run);
|
|
797
|
+
const invocationModel = adapterInvocationModel(run, selectedModel, definition.id);
|
|
798
|
+
options.trace?.info('agent_adapter_invoke', {
|
|
799
|
+
adapter: adapter.id,
|
|
800
|
+
agent: definition.id,
|
|
801
|
+
sessionId,
|
|
802
|
+
model: selectedModelId,
|
|
803
|
+
invocationModel,
|
|
804
|
+
promptChars: prompt.length,
|
|
805
|
+
});
|
|
806
|
+
const result = await callProviderAdapter(adapter, {
|
|
807
|
+
runId: run.runId,
|
|
808
|
+
sessionId,
|
|
809
|
+
prompt,
|
|
810
|
+
model: invocationModel,
|
|
811
|
+
timeoutMs: boundedDurationMs(
|
|
812
|
+
options.timeoutMs ?? options.env?.DEXTER_BRIDGE_AGENT_TIMEOUT_MS,
|
|
813
|
+
120000,
|
|
814
|
+
1000,
|
|
815
|
+
),
|
|
816
|
+
}, {
|
|
817
|
+
send,
|
|
818
|
+
trace: options.trace,
|
|
819
|
+
controlPollMs: boundedDurationMs(
|
|
820
|
+
options.controlPollMs ?? options.env?.DEXTER_BRIDGE_CONTROL_POLL_MS,
|
|
821
|
+
5000,
|
|
822
|
+
10,
|
|
823
|
+
30000,
|
|
824
|
+
),
|
|
825
|
+
});
|
|
826
|
+
usageAccumulator.add(result);
|
|
827
|
+
resultText = result?.text;
|
|
828
|
+
} else {
|
|
829
|
+
const runtime = await resolveAgentRuntime(definition, selectedModel, {
|
|
830
|
+
env: options.env,
|
|
831
|
+
});
|
|
832
|
+
if (!runtime.ok) {
|
|
833
|
+
throw new Error(runtime.error || `${definition.label} is not available.`);
|
|
834
|
+
}
|
|
835
|
+
const result = await callLocalJsonAgent(definition.id, prompt, {
|
|
836
|
+
...options,
|
|
837
|
+
model: selectedModelId,
|
|
838
|
+
runtime,
|
|
839
|
+
});
|
|
840
|
+
const parsed = parseAgentOutput(definition.id, result.stdout);
|
|
841
|
+
usageAccumulator.add(parsed);
|
|
842
|
+
resultText = parsed.resultText;
|
|
843
|
+
}
|
|
844
|
+
const completion = normalizeModelTurnCompletion(
|
|
845
|
+
extractJsonObject(resultText),
|
|
846
|
+
selectedModelId,
|
|
847
|
+
);
|
|
848
|
+
await send('done', {
|
|
849
|
+
operationType: 'chat',
|
|
850
|
+
outcome: 'answer',
|
|
851
|
+
completion,
|
|
852
|
+
model: selectedModelId,
|
|
853
|
+
...usageAccumulator.snapshot(),
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export async function executeRun(run, {
|
|
858
|
+
apiBaseUrl,
|
|
859
|
+
deviceToken,
|
|
860
|
+
agent = process.env.DEXTER_BRIDGE_AGENT || DEFAULT_BRIDGE_AGENT,
|
|
861
|
+
fetchImpl,
|
|
862
|
+
log = console.log,
|
|
863
|
+
logDir,
|
|
864
|
+
selectedModel,
|
|
865
|
+
providerAdapter,
|
|
866
|
+
adapterOptions,
|
|
867
|
+
env = process.env,
|
|
868
|
+
controlPollMs,
|
|
869
|
+
} = {}) {
|
|
870
|
+
if (!run?.runId) throw new Error('Companion run payload is missing runId.');
|
|
871
|
+
if (run?.protocol?.version !== 'dexter-companion-v4') {
|
|
872
|
+
throw new Error(`Unsupported Dexter Companion protocol ${run.protocol.version}. Update Dexter Bridge and reconnect.`);
|
|
873
|
+
}
|
|
874
|
+
if (run?.kind !== 'model_turn' || run?.protocol?.mode !== 'model_turn') {
|
|
875
|
+
throw new Error('Dexter Companion v4 only accepts server-owned model_turn runs.');
|
|
876
|
+
}
|
|
877
|
+
const writeLine = (message) => {
|
|
878
|
+
if (typeof log !== 'function') return;
|
|
879
|
+
if (log.length >= 3) log('info', 'run_message', { message });
|
|
880
|
+
else log(message);
|
|
881
|
+
};
|
|
882
|
+
const trace = createRunLogger({ runId: run.runId, logDir, mirror: log });
|
|
883
|
+
const send = createEventPoster({ apiBaseUrl, deviceToken, run, fetchImpl, trace });
|
|
884
|
+
const normalizedAgent = normalizeAgentName(run?.companion?.agent || agent);
|
|
885
|
+
const runModel = run?.companion?.model?.id || run?.model || selectedModel;
|
|
886
|
+
const usageAccumulator = createCompanionUsageAccumulator(normalizedAgent);
|
|
887
|
+
const adapterProvided = providerAdapter !== undefined;
|
|
888
|
+
const activeAdapter = adapterProvided
|
|
889
|
+
? providerAdapter
|
|
890
|
+
: createLocalAgentAdapter(normalizedAgent, {
|
|
891
|
+
...adapterOptions,
|
|
892
|
+
env: adapterOptions?.env || env,
|
|
893
|
+
trace,
|
|
894
|
+
});
|
|
895
|
+
const ownsAdapter = !adapterProvided && Boolean(activeAdapter);
|
|
896
|
+
if (
|
|
897
|
+
activeAdapter?.id
|
|
898
|
+
&& activeAdapter.id !== normalizedAgent
|
|
899
|
+
) {
|
|
900
|
+
if (ownsAdapter) activeAdapter.close?.();
|
|
901
|
+
throw new Error(`Provider adapter ${activeAdapter.id} cannot execute ${normalizedAgent} runs.`);
|
|
902
|
+
}
|
|
903
|
+
writeLine(`Running Dexter companion run ${run.runId} (${normalizedAgent}, ${runModel || 'default model'}).`);
|
|
904
|
+
writeLine(`Dexter Bridge run log: ${trace.filePath || logLocationHint(logDir)}`);
|
|
905
|
+
trace.info('run_start', {
|
|
906
|
+
apiBaseUrl,
|
|
907
|
+
agent: normalizedAgent,
|
|
908
|
+
model: runModel || null,
|
|
909
|
+
summary: runSummary(run),
|
|
910
|
+
logFile: trace.filePath,
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
try {
|
|
914
|
+
await executeModelTurnRun(run, send, normalizedAgent, {
|
|
915
|
+
model: runModel || selectedModel,
|
|
916
|
+
selectedModel: runModel || selectedModel,
|
|
917
|
+
providerAdapter: activeAdapter,
|
|
918
|
+
trace,
|
|
919
|
+
env,
|
|
920
|
+
controlPollMs,
|
|
921
|
+
});
|
|
922
|
+
trace.info('run_done', { status: 'model_turn_sent' });
|
|
923
|
+
return { cancelled: false };
|
|
924
|
+
} catch (error) {
|
|
925
|
+
if (error?.code === 'RUN_CANCELLED') {
|
|
926
|
+
trace.info('run_cancelled', { message: error.message });
|
|
927
|
+
await send('error', {
|
|
928
|
+
operationType: 'cancelled',
|
|
929
|
+
outcome: 'cancelled',
|
|
930
|
+
message: error.message,
|
|
931
|
+
code: 'RUN_CANCELLED',
|
|
932
|
+
}).catch(() => undefined);
|
|
933
|
+
return { cancelled: true };
|
|
934
|
+
}
|
|
935
|
+
trace.error('run_failed', { error: errorMeta(error) });
|
|
936
|
+
await send('error', {
|
|
937
|
+
stage: 'agent',
|
|
938
|
+
message: error?.message || 'Local Companion failed.',
|
|
939
|
+
code: error?.code,
|
|
940
|
+
...(error?.companionUsage || usageAccumulator.snapshot()),
|
|
941
|
+
}).catch(() => undefined);
|
|
942
|
+
throw error;
|
|
943
|
+
} finally {
|
|
944
|
+
if (ownsAdapter) activeAdapter?.close?.();
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
export function checkCommand(command, args = ['--version'], timeoutMs = 10000) {
|
|
949
|
+
return runProcess(command, args, '', { timeoutMs })
|
|
950
|
+
.then((result) => ({ ok: true, command, output: (result.stdout || result.stderr || '').trim() }))
|
|
951
|
+
.catch((error) => ({ ok: false, command, error: error.message }));
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export async function checkAgentAvailability(agent, model, options = {}) {
|
|
955
|
+
const normalizedAgent = normalizeAgentName(agent);
|
|
956
|
+
if (normalizedAgent === 'dry-run') {
|
|
957
|
+
return {
|
|
958
|
+
ok: true,
|
|
959
|
+
agent: 'dry-run',
|
|
960
|
+
label: 'Dry run',
|
|
961
|
+
command: 'dry-run',
|
|
962
|
+
output: 'debug mode',
|
|
963
|
+
models: ['dry-run:default'],
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
const definition = definitionForAgent(normalizedAgent);
|
|
967
|
+
const selectedModel = model ? companionModelDefinition(model?.id || model, normalizedAgent) : undefined;
|
|
968
|
+
const runtime = await resolveAgentRuntime(definition, selectedModel, {
|
|
969
|
+
env: options.env,
|
|
970
|
+
});
|
|
971
|
+
const supportedModels = companionModelsForAgent(normalizedAgent)
|
|
972
|
+
.filter((candidate) => modelSupportsAgentVersion(candidate, runtime.version))
|
|
973
|
+
.map((candidate) => candidate.id);
|
|
974
|
+
return {
|
|
975
|
+
...runtime,
|
|
976
|
+
agent: definition.id,
|
|
977
|
+
label: definition.label,
|
|
978
|
+
models: supportedModels,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
export async function checkAllAgents(options = {}) {
|
|
983
|
+
const agents = await Promise.all([
|
|
984
|
+
checkAgentAvailability('claude-code', undefined, options),
|
|
985
|
+
checkAgentAvailability('codex', undefined, options),
|
|
986
|
+
]);
|
|
987
|
+
return {
|
|
988
|
+
agents,
|
|
989
|
+
dryRun: {
|
|
990
|
+
ok: true,
|
|
991
|
+
agent: 'dry-run',
|
|
992
|
+
label: 'Dry run',
|
|
993
|
+
command: 'dry-run',
|
|
994
|
+
output: 'debug mode',
|
|
995
|
+
models: ['dry-run:default'],
|
|
996
|
+
},
|
|
997
|
+
};
|
|
998
|
+
}
|