@openagents-org/agent-launcher 0.2.185 → 0.2.187
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 +2 -2
- package/registry.json +13 -8
- package/src/adapters/cline.js +34 -38
- package/src/adapters/codebuddy.js +0 -8
- package/src/adapters/codex.js +246 -66
- package/src/adapters/commandcode.js +0 -8
- package/src/adapters/copilot.js +13 -5
- package/src/adapters/hermes.js +40 -0
- package/src/adapters/llm-direct.js +2 -1
- package/src/adapters/opencode.js +57 -14
- package/src/adapters/openworker.js +0 -8
- package/src/adapters/utils.js +7 -4
- package/src/daemon.js +81 -1
- package/src/installer.js +232 -59
- package/src/model-list.js +100 -0
- package/src/paths.js +102 -0
- package/src/probe.js +23 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openagents-org/agent-launcher",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "OpenAgents Launcher
|
|
3
|
+
"version": "0.2.187",
|
|
4
|
+
"description": "OpenAgents Launcher \u2014 install, configure, and run AI coding agents from your terminal",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"agn": "bin/agent-connector.js",
|
package/registry.json
CHANGED
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"models": {
|
|
69
69
|
"provider": "anthropic"
|
|
70
70
|
},
|
|
71
|
+
"workspace_model": true,
|
|
71
72
|
"vendor": "Anthropic",
|
|
72
73
|
"tagline": "The gold standard for agentic coding, right in your terminal.",
|
|
73
74
|
"protocol": "anthropic",
|
|
@@ -268,6 +269,7 @@
|
|
|
268
269
|
"models": {
|
|
269
270
|
"provider": "openai"
|
|
270
271
|
},
|
|
272
|
+
"workspace_model": true,
|
|
271
273
|
"resolve_env": {
|
|
272
274
|
"rules": [
|
|
273
275
|
{
|
|
@@ -896,13 +898,13 @@
|
|
|
896
898
|
],
|
|
897
899
|
"models": [
|
|
898
900
|
{
|
|
899
|
-
"id": "claude-4
|
|
900
|
-
"label": "Claude 4.6
|
|
901
|
+
"id": "claude-sonnet-4-6",
|
|
902
|
+
"label": "Claude Sonnet 4.6",
|
|
901
903
|
"category": "chat"
|
|
902
904
|
},
|
|
903
905
|
{
|
|
904
|
-
"id": "claude-4
|
|
905
|
-
"label": "Claude 4.5
|
|
906
|
+
"id": "claude-sonnet-4-5",
|
|
907
|
+
"label": "Claude Sonnet 4.5",
|
|
906
908
|
"category": "chat"
|
|
907
909
|
},
|
|
908
910
|
{
|
|
@@ -1167,7 +1169,7 @@
|
|
|
1167
1169
|
"category": "chat"
|
|
1168
1170
|
},
|
|
1169
1171
|
{
|
|
1170
|
-
"id": "claude-opus-4
|
|
1172
|
+
"id": "claude-opus-4-8",
|
|
1171
1173
|
"label": "Claude Opus 4.8",
|
|
1172
1174
|
"category": "chat"
|
|
1173
1175
|
},
|
|
@@ -1925,7 +1927,8 @@
|
|
|
1925
1927
|
"to": "COMMANDCODE_MODEL"
|
|
1926
1928
|
}
|
|
1927
1929
|
]
|
|
1928
|
-
}
|
|
1930
|
+
},
|
|
1931
|
+
"workspace_model": true
|
|
1929
1932
|
},
|
|
1930
1933
|
{
|
|
1931
1934
|
"name": "openworker",
|
|
@@ -2077,7 +2080,8 @@
|
|
|
2077
2080
|
"to": "OPENWORKER_BASE_URL"
|
|
2078
2081
|
}
|
|
2079
2082
|
]
|
|
2080
|
-
}
|
|
2083
|
+
},
|
|
2084
|
+
"workspace_model": true
|
|
2081
2085
|
},
|
|
2082
2086
|
{
|
|
2083
2087
|
"name": "codebuddy",
|
|
@@ -2220,7 +2224,8 @@
|
|
|
2220
2224
|
"to": "CODEBUDDY_MODEL"
|
|
2221
2225
|
}
|
|
2222
2226
|
]
|
|
2223
|
-
}
|
|
2227
|
+
},
|
|
2228
|
+
"workspace_model": true
|
|
2224
2229
|
},
|
|
2225
2230
|
{
|
|
2226
2231
|
"name": "codearts",
|
package/src/adapters/cline.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
const fs = require('fs');
|
|
27
27
|
const os = require('os');
|
|
28
28
|
const path = require('path');
|
|
29
|
-
const { execSync, execFile } = require('child_process');
|
|
29
|
+
const { execSync, execFileSync, execFile } = require('child_process');
|
|
30
30
|
// spawn() here is the WSL bridge from ../wsl: same signature as
|
|
31
31
|
// child_process.spawn, and a straight pass-through unless the resolved CLI
|
|
32
32
|
// lives on the other side of the Windows/WSL boundary.
|
|
@@ -34,7 +34,13 @@ const { spawn } = require('../wsl');
|
|
|
34
34
|
|
|
35
35
|
const BaseAdapter = require('./base');
|
|
36
36
|
const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
|
|
37
|
-
const {
|
|
37
|
+
const {
|
|
38
|
+
defaultAgentWorkdir,
|
|
39
|
+
whichBinary,
|
|
40
|
+
whereBinary,
|
|
41
|
+
resolveManagedNpmBinary,
|
|
42
|
+
isNodeShebangScript,
|
|
43
|
+
} = require('../paths');
|
|
38
44
|
const {
|
|
39
45
|
ClineStreamParser,
|
|
40
46
|
interpretClineEnvelope,
|
|
@@ -274,47 +280,24 @@ class ClineAdapter extends BaseAdapter {
|
|
|
274
280
|
return null;
|
|
275
281
|
}
|
|
276
282
|
|
|
277
|
-
/**
|
|
283
|
+
/**
|
|
284
|
+
* True when a file begins with a `#!...node` shebang. Delegates to the core
|
|
285
|
+
* helper so the daemon classifies a package bin exactly as win-exec.ts does
|
|
286
|
+
* for the launcher.
|
|
287
|
+
*/
|
|
278
288
|
_isNodeShebangScript(filePath) {
|
|
279
|
-
|
|
280
|
-
const fd = fs.openSync(filePath, 'r');
|
|
281
|
-
try {
|
|
282
|
-
const buf = Buffer.alloc(64);
|
|
283
|
-
const n = fs.readSync(fd, buf, 0, 64, 0);
|
|
284
|
-
const head = buf.slice(0, n).toString('utf-8');
|
|
285
|
-
return head.startsWith('#!') && /\bnode\b/.test(head.split('\n')[0]);
|
|
286
|
-
} finally {
|
|
287
|
-
fs.closeSync(fd);
|
|
288
|
-
}
|
|
289
|
-
} catch {
|
|
290
|
-
return false;
|
|
291
|
-
}
|
|
289
|
+
return isNodeShebangScript(filePath);
|
|
292
290
|
}
|
|
293
291
|
|
|
294
292
|
_findClineBinary() {
|
|
295
293
|
const home = os.homedir();
|
|
296
294
|
const ext = IS_WINDOWS ? '.cmd' : '';
|
|
297
295
|
|
|
298
|
-
// Tier 0:
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const portable = path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `cline${ext}`);
|
|
304
|
-
if (fs.existsSync(portable)) return portable;
|
|
305
|
-
|
|
306
|
-
// Tier 0c: the package's OWN bin. npm does not create a node_modules/.bin
|
|
307
|
-
// shim for Cline (its `bin` is "./bin/cline"), so a local prefix install
|
|
308
|
-
// leaves no `.bin/cline` — but the package bin is always present and is a
|
|
309
|
-
// Node script we run via _resolveToNodeCmd. Check the runtime then legacy
|
|
310
|
-
// prefix.
|
|
311
|
-
for (const root of [
|
|
312
|
-
path.join(home, '.openagents', 'runtimes', 'cline', 'node_modules', 'cline'),
|
|
313
|
-
path.join(home, '.openagents', 'nodejs', 'node_modules', 'cline'),
|
|
314
|
-
]) {
|
|
315
|
-
const pkgBin = path.join(root, 'bin', 'cline');
|
|
316
|
-
if (fs.existsSync(pkgBin)) return pkgBin;
|
|
317
|
-
}
|
|
296
|
+
// Tier 0: the OpenAgents-managed copy. This is shared with Installer so the
|
|
297
|
+
// launcher and daemon cannot disagree when HOME differs from os.homedir(),
|
|
298
|
+
// or when npm installs only the package's extensionless `bin/cline`.
|
|
299
|
+
const managed = resolveManagedNpmBinary('cline', 'cline', 'cline');
|
|
300
|
+
if (managed) return managed;
|
|
318
301
|
|
|
319
302
|
// Tier 1: PATH search via a codepage-safe lookup (whereBinary forces UTF-8
|
|
320
303
|
// output + verifies existence so a non-ASCII/Chinese username isn't mangled
|
|
@@ -357,9 +340,22 @@ class ClineAdapter extends BaseAdapter {
|
|
|
357
340
|
// Version preflight (cached; HARD minimum 3.0.0)
|
|
358
341
|
// ------------------------------------------------------------------
|
|
359
342
|
|
|
360
|
-
/**
|
|
343
|
+
/**
|
|
344
|
+
* Run `cline --version` and return its raw output. Isolated for testing.
|
|
345
|
+
*
|
|
346
|
+
* Spawned through _spawnableCmd (no shell) rather than a quoted execSync
|
|
347
|
+
* string: when Cline resolves to npm's extensionless package bin, cmd.exe
|
|
348
|
+
* cannot run it and prints "is not recognized as an internal or external
|
|
349
|
+
* command" to the daemon's stderr on every version check.
|
|
350
|
+
*/
|
|
361
351
|
_readClineVersionRaw(clineBin) {
|
|
362
|
-
|
|
352
|
+
const [cmd, ...args] = this._spawnableCmd(clineBin, ['--version']);
|
|
353
|
+
return execFileSync(cmd, args, {
|
|
354
|
+
encoding: 'utf-8',
|
|
355
|
+
timeout: 8000,
|
|
356
|
+
windowsHide: true,
|
|
357
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
358
|
+
}).trim();
|
|
363
359
|
}
|
|
364
360
|
|
|
365
361
|
/**
|
|
@@ -76,7 +76,6 @@ const IS_WINDOWS = process.platform === 'win32';
|
|
|
76
76
|
|
|
77
77
|
// Max wall-clock for a single headless run, after which the process group is
|
|
78
78
|
// killed and the turn reported as interrupted.
|
|
79
|
-
const TIMEOUT_MS = 600000; // 10 minutes
|
|
80
79
|
|
|
81
80
|
// Idle watchdog: with no stdout frame for this long we nudge the channel, and
|
|
82
81
|
// after MAX consecutive silences we kill a run that is probably wedged.
|
|
@@ -615,7 +614,6 @@ class CodeBuddyAdapter extends BaseAdapter {
|
|
|
615
614
|
if (settled) return;
|
|
616
615
|
settled = true;
|
|
617
616
|
clearInterval(watchdog);
|
|
618
|
-
clearTimeout(timeout);
|
|
619
617
|
if (this._channelProcesses[channel] === proc) delete this._channelProcesses[channel];
|
|
620
618
|
resolve(payload);
|
|
621
619
|
};
|
|
@@ -721,12 +719,6 @@ class CodeBuddyAdapter extends BaseAdapter {
|
|
|
721
719
|
}
|
|
722
720
|
}, WATCHDOG_INTERVAL_MS);
|
|
723
721
|
|
|
724
|
-
const timeout = setTimeout(() => {
|
|
725
|
-
this._log(`Run exceeded ${TIMEOUT_MS}ms — killing`);
|
|
726
|
-
killedByWatchdog = true;
|
|
727
|
-
void this._stopProcess(proc);
|
|
728
|
-
}, TIMEOUT_MS);
|
|
729
|
-
|
|
730
722
|
try {
|
|
731
723
|
proc.stdin.on('error', () => {});
|
|
732
724
|
proc.stdin.write(prompt, 'utf-8');
|
package/src/adapters/codex.js
CHANGED
|
@@ -25,11 +25,22 @@ const https = require('https');
|
|
|
25
25
|
|
|
26
26
|
const { whereBinary } = require('../paths');
|
|
27
27
|
const BaseAdapter = require('./base');
|
|
28
|
-
const { redactSecrets } = require('./utils');
|
|
28
|
+
const { redactSecrets, formatAttachmentsForPrompt } = require('./utils');
|
|
29
29
|
const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
|
|
30
30
|
|
|
31
31
|
const IS_WINDOWS = process.platform === 'win32';
|
|
32
32
|
const MAX_HISTORY_ENTRIES = 50;
|
|
33
|
+
// Direct API mode has no tools, so an attached file reaches the model only as
|
|
34
|
+
// text inside the message. This caps how much goes in: a spec fits, and a log
|
|
35
|
+
// dump doesn't push the rest of the conversation out of the context window.
|
|
36
|
+
const MAX_INLINE_ATTACHMENT_CHARS = 50000;
|
|
37
|
+
// A completions request needs its own deadlines. Node's `timeout` option only
|
|
38
|
+
// emits an event, it never aborts, so a relay that accepted the request and
|
|
39
|
+
// then went quiet left the run hanging forever with nothing in the log.
|
|
40
|
+
// Overridable per instance (_directIdleTimeoutMs / _directTotalTimeoutMs).
|
|
41
|
+
const DIRECT_IDLE_TIMEOUT_MS = 120000;
|
|
42
|
+
const DIRECT_TOTAL_TIMEOUT_MS = 300000;
|
|
43
|
+
const TEXT_FILE_RE = /\.(md|markdown|txt|text|csv|tsv|json|jsonl|ya?ml|toml|ini|xml|html?|log|sql|sh|py|js|mjs|cjs|ts|tsx|jsx|java|go|rs|rb|php|c|h|cc|cpp|hpp|cs|swift|kt)$/i;
|
|
33
44
|
|
|
34
45
|
class CodexAdapter extends BaseAdapter {
|
|
35
46
|
/**
|
|
@@ -253,19 +264,23 @@ class CodexAdapter extends BaseAdapter {
|
|
|
253
264
|
|
|
254
265
|
async _handleMessage(msg) {
|
|
255
266
|
const content = (msg.content || '').trim();
|
|
256
|
-
|
|
267
|
+
const attachments = msg.attachments || [];
|
|
268
|
+
// A file sent with no text is still a message. The web UI fills content
|
|
269
|
+
// with the filename, but a task or API caller may leave it empty.
|
|
270
|
+
if (!content && attachments.length === 0) return;
|
|
257
271
|
|
|
258
272
|
const msgChannel = msg.sessionId || this.channelName;
|
|
259
273
|
const sender = msg.senderName || msg.senderType || 'user';
|
|
260
|
-
|
|
274
|
+
const summary = content || attachments.map((a) => a.filename).join(', ');
|
|
275
|
+
this._log(`Processing message from ${sender} in ${msgChannel}: ${summary.slice(0, 80)}...`);
|
|
261
276
|
|
|
262
|
-
await this._autoTitleChannel(msgChannel,
|
|
277
|
+
await this._autoTitleChannel(msgChannel, summary);
|
|
263
278
|
await this.sendStatus(msgChannel, 'thinking...');
|
|
264
279
|
|
|
265
280
|
if (this._useCliMode) {
|
|
266
|
-
await this._handleViaSubprocess(content, msgChannel);
|
|
281
|
+
await this._handleViaSubprocess(content, msgChannel, attachments);
|
|
267
282
|
} else if (this._directMode) {
|
|
268
|
-
await this._handleViaDirectApi(content, msgChannel);
|
|
283
|
+
await this._handleViaDirectApi(content, msgChannel, attachments);
|
|
269
284
|
} else {
|
|
270
285
|
await this.sendError(msgChannel, 'codex CLI not found. Install with: npm install -g @openai/codex\n\nOr configure OPENAI_API_KEY + OPENAI_BASE_URL for direct API mode.');
|
|
271
286
|
}
|
|
@@ -275,32 +290,33 @@ class CodexAdapter extends BaseAdapter {
|
|
|
275
290
|
// CLI subprocess mode (primary)
|
|
276
291
|
// ------------------------------------------------------------------
|
|
277
292
|
|
|
278
|
-
|
|
293
|
+
/** The workspace picker can change the model while this adapter is running. */
|
|
294
|
+
_effectiveModel() {
|
|
295
|
+
const workspaceModel = String(this.workspaceModel || '').trim();
|
|
296
|
+
return workspaceModel || String(this._directModel || '').trim();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async _handleViaSubprocess(content, msgChannel, attachments = []) {
|
|
279
300
|
const env = { ...(this.agentEnv || process.env) };
|
|
301
|
+
const effectiveModel = this._effectiveModel();
|
|
280
302
|
|
|
281
303
|
// Set model via env if configured
|
|
282
|
-
if (
|
|
304
|
+
if (effectiveModel) env.CODEX_MODEL = effectiveModel;
|
|
283
305
|
if (this._directApiKey) env.OPENAI_API_KEY = this._directApiKey;
|
|
284
306
|
if (this._directBaseUrl) env.OPENAI_BASE_URL = this._directBaseUrl;
|
|
285
307
|
|
|
286
308
|
const context = this._buildSystemContext(msgChannel);
|
|
287
|
-
const fullPrompt = `${context}\n\n---\n\nUser message:\n${content}`;
|
|
309
|
+
const fullPrompt = `${context}\n\n---\n\nUser message:\n${content}${this._attachmentInstructions(attachments)}`;
|
|
288
310
|
|
|
289
311
|
// Run up to 2 attempts: first with resume, then fresh if stale
|
|
290
312
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
291
313
|
const cmd = [this._codexBin, 'exec'];
|
|
292
314
|
|
|
293
|
-
// Resume existing thread for this channel
|
|
294
|
-
const threadId = this._channelThreads[msgChannel];
|
|
295
|
-
if (threadId && attempt === 0) {
|
|
296
|
-
cmd.push('resume', threadId);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
315
|
cmd.push('--json', '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check');
|
|
300
316
|
|
|
301
317
|
// Model override
|
|
302
|
-
if (
|
|
303
|
-
cmd.push('-m',
|
|
318
|
+
if (effectiveModel) {
|
|
319
|
+
cmd.push('-m', effectiveModel);
|
|
304
320
|
}
|
|
305
321
|
|
|
306
322
|
// Working directory
|
|
@@ -308,7 +324,18 @@ class CodexAdapter extends BaseAdapter {
|
|
|
308
324
|
cmd.push('-C', this.workingDir);
|
|
309
325
|
}
|
|
310
326
|
|
|
311
|
-
|
|
327
|
+
// Resume goes LAST, after the exec options. `codex exec resume` takes
|
|
328
|
+
// -m, --json, --skip-git-repo-check and the bypass flags, but not -C,
|
|
329
|
+
// so the old order died with "unexpected argument '-C'" on every
|
|
330
|
+
// follow-up. That read as a stale thread, the id was dropped and the
|
|
331
|
+
// turn reran fresh, which is why a CLI-mode agent never remembered
|
|
332
|
+
// anything past its first message. Verified against codex-cli 0.154.0.
|
|
333
|
+
const threadId = this._channelThreads[msgChannel];
|
|
334
|
+
if (threadId && attempt === 0) {
|
|
335
|
+
cmd.push('resume', threadId);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
this._log(`Spawning: codex ${cmd.slice(1).join(' ')}`);
|
|
312
339
|
|
|
313
340
|
try {
|
|
314
341
|
const result = await this._spawnCodex(cmd, env, msgChannel, fullPrompt);
|
|
@@ -464,6 +491,76 @@ class CodexAdapter extends BaseAdapter {
|
|
|
464
491
|
});
|
|
465
492
|
}
|
|
466
493
|
|
|
494
|
+
// ------------------------------------------------------------------
|
|
495
|
+
// Attachments
|
|
496
|
+
// ------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* How the CLI fetches each attached file. Without this it saw only the
|
|
500
|
+
* filename and went looking for it on local disk.
|
|
501
|
+
*
|
|
502
|
+
* The curl form, not the MCP one: Codex runs with no MCP config, so
|
|
503
|
+
* `workspace_read_file` names a tool it doesn't have. The token goes in
|
|
504
|
+
* literally, as it already does in the system prompt, because `$TOKEN` is
|
|
505
|
+
* unset in Codex's shell and every download would 401. The URL is rebuilt
|
|
506
|
+
* from this agent's endpoint: the one the browser attached points at the
|
|
507
|
+
* browser's API origin and carries the viewer's token.
|
|
508
|
+
*/
|
|
509
|
+
_attachmentInstructions(attachments) {
|
|
510
|
+
const forAgent = (attachments || []).map((att) => ({ ...att, url: undefined }));
|
|
511
|
+
return formatAttachmentsForPrompt(forAgent, 'skills', IS_WINDOWS, {
|
|
512
|
+
tokenExpr: this.token, endpoint: this.endpoint,
|
|
513
|
+
}) || '';
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Direct API mode has no tools, so a file reaches the model only if its text
|
|
518
|
+
* is in the message. Text files are inlined up to MAX_INLINE_ATTACHMENT_CHARS.
|
|
519
|
+
* Anything else is named, so the model says it can't open the file rather
|
|
520
|
+
* than answering as if it had read it.
|
|
521
|
+
*/
|
|
522
|
+
async _inlineAttachments(attachments) {
|
|
523
|
+
if (!attachments || attachments.length === 0) return '';
|
|
524
|
+
const parts = [];
|
|
525
|
+
let budget = MAX_INLINE_ATTACHMENT_CHARS;
|
|
526
|
+
for (const att of attachments) {
|
|
527
|
+
const name = att.filename || att.fileId || 'file';
|
|
528
|
+
if (!CodexAdapter._looksLikeText(att)) {
|
|
529
|
+
parts.push(`[Attached file: ${name} (${att.contentType || 'unknown type'}) — its contents can't be read in this mode]`);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (budget <= 0) {
|
|
533
|
+
parts.push(`[Attached file: ${name} — left out, the attachments already reached the size limit]`);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
let buf;
|
|
537
|
+
try {
|
|
538
|
+
buf = await this.client.readFile(this.workspaceId, this.token, att.fileId);
|
|
539
|
+
} catch (e) {
|
|
540
|
+
parts.push(`[Attached file: ${name} — download failed: ${CodexAdapter._redact(e.message).slice(0, 200)}]`);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (buf.includes(0)) {
|
|
544
|
+
parts.push(`[Attached file: ${name} — binary content, can't be read in this mode]`);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const text = buf.toString('utf-8');
|
|
548
|
+
const body = text.slice(0, budget);
|
|
549
|
+
budget -= body.length;
|
|
550
|
+
const note = body.length < text.length ? ` — only the first ${body.length} characters` : '';
|
|
551
|
+
parts.push(`[Attached file: ${name}${note}]\n${body}\n[End of ${name}]`);
|
|
552
|
+
}
|
|
553
|
+
return `\n\n${parts.join('\n\n')}`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Whether an attachment is worth decoding as text. Browsers upload .md as octet-stream. */
|
|
557
|
+
static _looksLikeText({ contentType, filename } = {}) {
|
|
558
|
+
const type = String(contentType || '').split(';')[0].trim().toLowerCase();
|
|
559
|
+
if (type.startsWith('text/')) return true;
|
|
560
|
+
if (/^application\/(json|xml|yaml|x-yaml|toml|javascript|x-sh|sql)$/.test(type)) return true;
|
|
561
|
+
return TEXT_FILE_RE.test(String(filename || ''));
|
|
562
|
+
}
|
|
563
|
+
|
|
467
564
|
// ------------------------------------------------------------------
|
|
468
565
|
// Failure reporting
|
|
469
566
|
// ------------------------------------------------------------------
|
|
@@ -541,22 +638,21 @@ class CodexAdapter extends BaseAdapter {
|
|
|
541
638
|
// Direct HTTP mode (fallback when CLI not available)
|
|
542
639
|
// ------------------------------------------------------------------
|
|
543
640
|
|
|
544
|
-
async _handleViaDirectApi(content, msgChannel) {
|
|
641
|
+
async _handleViaDirectApi(content, msgChannel, attachments = []) {
|
|
545
642
|
try {
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
} else {
|
|
555
|
-
await this._sendRunFailure(msgChannel, {});
|
|
643
|
+
const userMessage = (content + await this._inlineAttachments(attachments)).trim();
|
|
644
|
+
// Resolves with text or rejects with the reason there is none.
|
|
645
|
+
const responseText = await this._callCompletionApi(userMessage, msgChannel);
|
|
646
|
+
// The file text stays in history so a follow-up question about it still has it.
|
|
647
|
+
this._conversationHistory.push({ role: 'user', content: userMessage });
|
|
648
|
+
this._conversationHistory.push({ role: 'assistant', content: responseText });
|
|
649
|
+
if (this._conversationHistory.length > MAX_HISTORY_ENTRIES * 2) {
|
|
650
|
+
this._conversationHistory = this._conversationHistory.slice(-MAX_HISTORY_ENTRIES * 2);
|
|
556
651
|
}
|
|
652
|
+
await this.sendResponse(msgChannel, responseText);
|
|
557
653
|
} catch (e) {
|
|
558
654
|
this._log(`Error in direct API: ${e.message}`);
|
|
559
|
-
await this.
|
|
655
|
+
await this._sendRunFailure(msgChannel, { errorMessage: e.message });
|
|
560
656
|
}
|
|
561
657
|
}
|
|
562
658
|
|
|
@@ -568,7 +664,7 @@ class CodexAdapter extends BaseAdapter {
|
|
|
568
664
|
|
|
569
665
|
const url = `${this._directBaseUrl}/chat/completions`;
|
|
570
666
|
const payload = JSON.stringify({
|
|
571
|
-
model: this.
|
|
667
|
+
model: this._effectiveModel() || 'gpt-4o',
|
|
572
668
|
messages,
|
|
573
669
|
stream: true,
|
|
574
670
|
});
|
|
@@ -576,6 +672,28 @@ class CodexAdapter extends BaseAdapter {
|
|
|
576
672
|
return new Promise((resolve, reject) => {
|
|
577
673
|
const parsed = new URL(url);
|
|
578
674
|
const mod = parsed.protocol === 'https:' ? https : http;
|
|
675
|
+
const idleMs = this._directIdleTimeoutMs || DIRECT_IDLE_TIMEOUT_MS;
|
|
676
|
+
const totalMs = this._directTotalTimeoutMs || DIRECT_TOTAL_TIMEOUT_MS;
|
|
677
|
+
const started = Date.now();
|
|
678
|
+
// What arrived before things stopped, so a stalled relay can be told
|
|
679
|
+
// apart from one that answered in a shape we don't read.
|
|
680
|
+
const progress = { status: 0, bytes: 0, events: 0 };
|
|
681
|
+
const seconds = () => Math.round((Date.now() - started) / 1000);
|
|
682
|
+
const note = () => `HTTP ${progress.status || 'no response'}, ${progress.bytes} bytes, `
|
|
683
|
+
+ `${progress.events} events, ${seconds()}s`;
|
|
684
|
+
|
|
685
|
+
let settled = false;
|
|
686
|
+
let deadline = null;
|
|
687
|
+
const finish = (err, text) => {
|
|
688
|
+
if (settled) return;
|
|
689
|
+
settled = true;
|
|
690
|
+
clearTimeout(deadline);
|
|
691
|
+
if (!err) { resolve(text); return; }
|
|
692
|
+
this._log(`Direct API failed after ${seconds()}s, ${err.message}`);
|
|
693
|
+
reject(err);
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
this._log(`Direct API request started, model ${this._directModel || 'gpt-4o'} to ${parsed.host}${parsed.pathname}`);
|
|
579
697
|
const req = mod.request(parsed, {
|
|
580
698
|
method: 'POST',
|
|
581
699
|
headers: {
|
|
@@ -583,62 +701,124 @@ class CodexAdapter extends BaseAdapter {
|
|
|
583
701
|
'Authorization': `Bearer ${this._directApiKey}`,
|
|
584
702
|
'Content-Length': Buffer.byteLength(payload),
|
|
585
703
|
},
|
|
586
|
-
timeout:
|
|
704
|
+
timeout: idleMs,
|
|
587
705
|
}, (res) => {
|
|
706
|
+
progress.status = res.statusCode;
|
|
707
|
+
this._log(`Direct API response ${res.statusCode} after ${seconds()}s`);
|
|
708
|
+
// Decode across chunk boundaries: a CJK character split between two
|
|
709
|
+
// chunks came out as replacement characters when each was decoded alone.
|
|
710
|
+
res.setEncoding('utf8');
|
|
588
711
|
if (res.statusCode !== 200) {
|
|
589
712
|
let body = '';
|
|
590
713
|
res.on('data', (d) => { body += d; });
|
|
591
|
-
res.on('end', () =>
|
|
714
|
+
res.on('end', () => finish(new Error(`LLM API returned ${res.statusCode}: ${body.slice(0, 300)}`)));
|
|
592
715
|
return;
|
|
593
716
|
}
|
|
594
717
|
|
|
595
|
-
|
|
596
|
-
let
|
|
718
|
+
const reply = { text: '', reasoning: '', toolArgs: '', error: '', finishReason: '' };
|
|
719
|
+
let streamed = false;
|
|
720
|
+
let body = ''; // the raw response, kept until it turns out to be an SSE stream
|
|
597
721
|
let buffer = '';
|
|
722
|
+
const takeLine = (line) => {
|
|
723
|
+
const trimmed = line.trim();
|
|
724
|
+
if (!trimmed.startsWith('data:')) return;
|
|
725
|
+
streamed = true;
|
|
726
|
+
progress.events += 1;
|
|
727
|
+
const data = trimmed.slice(5).trim();
|
|
728
|
+
if (!data || data === '[DONE]') return;
|
|
729
|
+
try { CodexAdapter._collectCompletion(reply, JSON.parse(data), 'delta'); } catch {}
|
|
730
|
+
};
|
|
598
731
|
res.on('data', (chunk) => {
|
|
599
|
-
|
|
732
|
+
progress.bytes += chunk.length;
|
|
733
|
+
if (!streamed) body += chunk;
|
|
734
|
+
buffer += chunk;
|
|
600
735
|
const lines = buffer.split('\n');
|
|
601
736
|
buffer = lines.pop();
|
|
602
|
-
for (const line of lines)
|
|
603
|
-
const trimmed = line.trim();
|
|
604
|
-
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
|
605
|
-
const data = trimmed.slice(6);
|
|
606
|
-
if (data === '[DONE]') continue;
|
|
607
|
-
try {
|
|
608
|
-
const parsed = JSON.parse(data);
|
|
609
|
-
const choices = parsed.choices || [];
|
|
610
|
-
if (choices.length > 0) {
|
|
611
|
-
const delta = choices[0].delta || {};
|
|
612
|
-
if (delta.content) fullText += delta.content;
|
|
613
|
-
if (delta.tool_calls) {
|
|
614
|
-
for (const tc of delta.tool_calls) {
|
|
615
|
-
if (tc.function && tc.function.arguments) {
|
|
616
|
-
toolCallText += tc.function.arguments;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
} catch {}
|
|
622
|
-
}
|
|
737
|
+
for (const line of lines) takeLine(line);
|
|
623
738
|
});
|
|
624
739
|
res.on('end', () => {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
} catch {
|
|
630
|
-
fullText = toolCallText;
|
|
631
|
-
}
|
|
740
|
+
takeLine(buffer); // a stream whose last event has no trailing newline
|
|
741
|
+
if (!streamed) {
|
|
742
|
+
// Some relays ignore stream:true and answer with one JSON body.
|
|
743
|
+
try { CodexAdapter._collectCompletion(reply, JSON.parse(body), 'message'); } catch {}
|
|
632
744
|
}
|
|
633
|
-
|
|
745
|
+
const text = CodexAdapter._completionText(reply);
|
|
746
|
+
if (text) finish(null, text);
|
|
747
|
+
else finish(new Error(CodexAdapter._emptyCompletionReason(reply, { streamed, body })));
|
|
634
748
|
});
|
|
749
|
+
res.on('aborted', () => finish(new Error(
|
|
750
|
+
`The LLM API closed the connection mid-response (${note()})`)));
|
|
751
|
+
res.on('error', (err) => finish(new Error(
|
|
752
|
+
`The LLM API connection failed, ${err.message} (${note()})`)));
|
|
635
753
|
});
|
|
636
754
|
|
|
637
|
-
|
|
755
|
+
// Settle first, then destroy: the abort that follows would otherwise
|
|
756
|
+
// report itself as a dropped connection and hide the real reason.
|
|
757
|
+
req.on('timeout', () => {
|
|
758
|
+
finish(new Error(
|
|
759
|
+
`The LLM API stopped responding, no data for ${Math.round(idleMs / 1000)}s (${note()})`));
|
|
760
|
+
req.destroy();
|
|
761
|
+
});
|
|
762
|
+
deadline = setTimeout(() => {
|
|
763
|
+
finish(new Error(
|
|
764
|
+
`The LLM API request went past ${Math.round(totalMs / 1000)}s with no usable reply (${note()})`));
|
|
765
|
+
req.destroy();
|
|
766
|
+
}, totalMs);
|
|
767
|
+
if (deadline.unref) deadline.unref();
|
|
768
|
+
|
|
769
|
+
req.on('error', (err) => finish(err));
|
|
638
770
|
req.write(payload);
|
|
639
771
|
req.end();
|
|
640
772
|
});
|
|
641
773
|
}
|
|
774
|
+
|
|
775
|
+
/** Fold one streamed chunk (`delta`) or one whole response (`message`) into `reply`. */
|
|
776
|
+
static _collectCompletion(reply, parsed, key) {
|
|
777
|
+
if (!parsed || typeof parsed !== 'object') return;
|
|
778
|
+
if (parsed.error) reply.error = CodexAdapter._unwrap(JSON.stringify(parsed));
|
|
779
|
+
const choice = Array.isArray(parsed.choices) ? parsed.choices[0] : null;
|
|
780
|
+
if (!choice) return;
|
|
781
|
+
const part = choice[key] || {};
|
|
782
|
+
if (typeof part.content === 'string') reply.text += part.content;
|
|
783
|
+
const reasoning = part.reasoning_content || part.reasoning;
|
|
784
|
+
if (typeof reasoning === 'string') reply.reasoning += reasoning;
|
|
785
|
+
for (const tc of part.tool_calls || []) {
|
|
786
|
+
if (tc.function && tc.function.arguments) reply.toolArgs += tc.function.arguments;
|
|
787
|
+
}
|
|
788
|
+
if (choice.finish_reason) reply.finishReason = choice.finish_reason;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** The reply text, falling back to a tool call's arguments when the model answered with one. */
|
|
792
|
+
static _completionText({ text, toolArgs }) {
|
|
793
|
+
if (text.trim()) return text.trim();
|
|
794
|
+
if (!toolArgs) return '';
|
|
795
|
+
try {
|
|
796
|
+
const args = JSON.parse(toolArgs);
|
|
797
|
+
return String(args.command || args.input || args.content || args.text || toolArgs).trim();
|
|
798
|
+
} catch {
|
|
799
|
+
return toolArgs.trim();
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Why a 200 from the completions endpoint carried no reply. Each of these
|
|
805
|
+
* used to resolve to '' and reach the user as "finished without producing a
|
|
806
|
+
* reply", which is what a relay's in-band error looked like.
|
|
807
|
+
*/
|
|
808
|
+
static _emptyCompletionReason({ error, reasoning, finishReason }, { streamed, body } = {}) {
|
|
809
|
+
if (error) return error;
|
|
810
|
+
if (reasoning) {
|
|
811
|
+
return finishReason === 'length'
|
|
812
|
+
? 'The model ran out of output tokens while still reasoning, before it wrote an answer.'
|
|
813
|
+
: 'The model returned reasoning but no answer.';
|
|
814
|
+
}
|
|
815
|
+
if (finishReason) return `The model returned no text (finish_reason: ${finishReason}).`;
|
|
816
|
+
if (streamed) return 'The LLM API stream ended without any reply text.';
|
|
817
|
+
const snippet = String(body || '').trim().slice(0, 200);
|
|
818
|
+
return snippet
|
|
819
|
+
? `The LLM API response had no reply in it: ${snippet}`
|
|
820
|
+
: 'The LLM API returned an empty response.';
|
|
821
|
+
}
|
|
642
822
|
}
|
|
643
823
|
|
|
644
824
|
module.exports = CodexAdapter;
|
|
@@ -71,7 +71,6 @@ const IS_WINDOWS = process.platform === 'win32';
|
|
|
71
71
|
|
|
72
72
|
// Max wall-clock for a single headless run, after which the process group is
|
|
73
73
|
// killed and the turn reported as interrupted.
|
|
74
|
-
const TIMEOUT_MS = 600000; // 10 minutes
|
|
75
74
|
|
|
76
75
|
// Idle watchdog: with no stdout frame for this long we nudge the channel, and
|
|
77
76
|
// after MAX consecutive silences we kill a run that is probably wedged.
|
|
@@ -567,7 +566,6 @@ class CommandCodeAdapter extends BaseAdapter {
|
|
|
567
566
|
if (settled) return;
|
|
568
567
|
settled = true;
|
|
569
568
|
clearInterval(watchdog);
|
|
570
|
-
clearTimeout(timeout);
|
|
571
569
|
if (this._channelProcesses[channel] === proc) delete this._channelProcesses[channel];
|
|
572
570
|
resolve(payload);
|
|
573
571
|
};
|
|
@@ -653,12 +651,6 @@ class CommandCodeAdapter extends BaseAdapter {
|
|
|
653
651
|
}
|
|
654
652
|
}, WATCHDOG_INTERVAL_MS);
|
|
655
653
|
|
|
656
|
-
const timeout = setTimeout(() => {
|
|
657
|
-
this._log(`Run exceeded ${TIMEOUT_MS}ms — killing`);
|
|
658
|
-
killedByWatchdog = true;
|
|
659
|
-
void this._stopProcess(proc);
|
|
660
|
-
}, TIMEOUT_MS);
|
|
661
|
-
|
|
662
654
|
// Feed the prompt. stdin is closed immediately: Command Code times out a
|
|
663
655
|
// piped stdin after 30s of silence, so an unclosed pipe would stall.
|
|
664
656
|
try {
|