@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-launcher",
3
- "version": "0.2.185",
4
- "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
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.6-sonnet",
900
- "label": "Claude 4.6 Sonnet",
901
+ "id": "claude-sonnet-4-6",
902
+ "label": "Claude Sonnet 4.6",
901
903
  "category": "chat"
902
904
  },
903
905
  {
904
- "id": "claude-4.5-sonnet",
905
- "label": "Claude 4.5 Sonnet",
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.8",
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",
@@ -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 { defaultAgentWorkdir, whichBinary, whereBinary } = require('../paths');
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
- /** True when a file begins with a `#!...node` shebang. */
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
- try {
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: isolated runtime prefix (~/.openagents/runtimes/cline/)
299
- const runtimeCandidate = path.join(home, '.openagents', 'runtimes', 'cline', 'node_modules', '.bin', `cline${ext}`);
300
- if (fs.existsSync(runtimeCandidate)) return runtimeCandidate;
301
-
302
- // Tier 0b: legacy portable install
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
- /** Run `cline --version` and return its raw output. Isolated for testing. */
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
- return execSync(`"${clineBin}" --version`, { encoding: 'utf-8', timeout: 8000, windowsHide: true }).trim();
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');
@@ -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
- if (!content) return;
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
- this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`);
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, content);
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
- async _handleViaSubprocess(content, msgChannel) {
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 (this._directModel) env.CODEX_MODEL = this._directModel;
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 (this._directModel) {
303
- cmd.push('-m', this._directModel);
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
- this._log(`Spawning: codex exec ${threadId && attempt === 0 ? `resume ${threadId} ` : ''}--json --full-auto -m ${this._directModel || 'default'}`);
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 responseText = await this._callCompletionApi(content, msgChannel);
547
- if (responseText) {
548
- this._conversationHistory.push({ role: 'user', content });
549
- this._conversationHistory.push({ role: 'assistant', content: responseText });
550
- if (this._conversationHistory.length > MAX_HISTORY_ENTRIES * 2) {
551
- this._conversationHistory = this._conversationHistory.slice(-MAX_HISTORY_ENTRIES * 2);
552
- }
553
- await this.sendResponse(msgChannel, responseText);
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.sendError(msgChannel, `⚠️ **Codex couldn't run** — ${CodexAdapter._redact(e.message)}`);
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._directModel || 'gpt-4o',
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: 300000,
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', () => reject(new Error(`LLM API returned ${res.statusCode}: ${body.slice(0, 300)}`)));
714
+ res.on('end', () => finish(new Error(`LLM API returned ${res.statusCode}: ${body.slice(0, 300)}`)));
592
715
  return;
593
716
  }
594
717
 
595
- let fullText = '';
596
- let toolCallText = '';
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
- buffer += chunk.toString('utf-8');
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
- if (!fullText && toolCallText) {
626
- try {
627
- const args = JSON.parse(toolCallText);
628
- fullText = args.command || args.input || args.content || args.text || toolCallText;
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
- resolve(fullText.trim());
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
- req.on('error', reject);
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 {