@openagents-org/agent-launcher 0.2.193 → 0.2.194

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.
@@ -10,14 +10,23 @@
10
10
  * - profile auto-detection from the agent name (falls back to 'default')
11
11
  * - workspace context injection (identity + recent history + agent roster)
12
12
  * - subprocess isolation (hermes manages its own HERMES_HOME per profile)
13
+ *
14
+ * One workspace thread is one Hermes session, resumed with `--resume` on every
15
+ * later message. Two rules keep that session — and the provider's prompt cache
16
+ * behind it — worth resuming:
17
+ * - a session is only given up when Hermes says it no longer exists. A turn
18
+ * that fails, runs out of iterations or is stopped still belongs to it;
19
+ * - the workspace instructions ride alongside the conversation as a prefill
20
+ * message instead of being written into it again with every user message.
13
21
  */
14
22
 
15
23
  'use strict';
16
24
 
25
+ const crypto = require('crypto');
17
26
  const fs = require('fs');
18
27
  const os = require('os');
19
28
  const path = require('path');
20
- const { execSync } = require('child_process');
29
+ const { execSync, execFile } = require('child_process');
21
30
  // spawn() here is the WSL bridge from ../wsl: same signature as
22
31
  // child_process.spawn, and a straight pass-through unless the resolved CLI
23
32
  // lives on the other side of the Windows/WSL boundary.
@@ -25,6 +34,7 @@ const { spawn, bridgeSpawn } = require('../wsl');
25
34
 
26
35
  const BaseAdapter = require('./base');
27
36
  const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
37
+ const { isRecapEligible } = require('./decision-log');
28
38
  const { REASON } = require('./health-status');
29
39
  const { whichBinary, whereBinary } = require('../paths');
30
40
 
@@ -33,7 +43,43 @@ const HERMES_INSTALL_HINT = IS_WINDOWS
33
43
  ? 'powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1 | iex"'
34
44
  : 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash';
35
45
  const SESSION_ID_RE = /session_id:\s*(\S+)/;
46
+ const SESSION_LINE_RE = /^session_id:\s*\S+$/;
47
+ // What Hermes prints, in place of a session_id, when `--resume` names a
48
+ // session it no longer has. Upstream localises that line
49
+ // (t("cli.resume.session_not_found")), so this only matches the English text;
50
+ // under another locale a lost session is not recognised as lost.
51
+ const SESSION_NOT_FOUND_RE = /session not found/i;
36
52
  const MAX_HISTORY_ENTRIES = 12;
53
+ const OPENAGENTS_RULES = [
54
+ '\n## OpenAgents-specific Rules',
55
+ '- Your final text response is posted back to the workspace automatically.',
56
+ '- If you need to ask the user something, ask in normal text. Do not try to open an interactive prompt.',
57
+ '- Do not reveal secrets, tokens, raw auth headers, or internal command lines.',
58
+ '- Keep status concise. Focus on useful output over theatre.',
59
+ ];
60
+ // Hermes sends the messages in this file at the head of every model request
61
+ // without storing them in the session.
62
+ const PREFILL_ENV = 'HERMES_PREFILL_MESSAGES_FILE';
63
+ // The CLI has read that variable since release 0.16.0 of 2026.6.5; 0.15.1
64
+ // (2026.5.29) still took the path from config.yaml only. `hermes --version`
65
+ // prints the release date as "(YYYY.M.D)"; desktop-app installs print only the
66
+ // version ("Hermes Agent v0.21.6 · stable").
67
+ const PREFILL_ENV_SINCE = [2026, 6, 5];
68
+ const PREFILL_ENV_SINCE_VERSION = [0, 16, 0];
69
+ const RELEASE_DATE_RE = /\((\d{4})\.(\d{1,2})\.(\d{1,2})\)/;
70
+ const SEMVER_RE = /\bv(\d+)\.(\d+)\.(\d+)\b/;
71
+ // Our prefill files, in os.tmpdir(); they carry the workspace token. One left
72
+ // behind by a killed daemon is removed at the next start once this old.
73
+ const PREFILL_FILE_RE = /^openagents-hermes-[0-9a-f]+\.json$/;
74
+ const STALE_PREFILL_MS = 60 * 60 * 1000;
75
+ // `prefill_messages_file:` in config.yaml, at top level or under `agent:`.
76
+ const CONFIG_PREFILL_RE = /^[ \t]*prefill_messages_file[ \t]*:(.*)$/gm;
77
+ const PREFILL_CLOSING = [
78
+ '',
79
+ '---',
80
+ '',
81
+ 'The text above is standing context from the OpenAgents workspace, not a message to answer. The conversation follows.',
82
+ ];
37
83
 
38
84
  class HermesAdapter extends BaseAdapter {
39
85
  /**
@@ -56,11 +102,14 @@ class HermesAdapter extends BaseAdapter {
56
102
 
57
103
  this._channelSessions = {};
58
104
  this._channelProcesses = {};
105
+ // { bin, ok }: whether the resolved hermes reads PREFILL_ENV.
106
+ this._prefillEnvSupport = null;
59
107
  this._sessionsFile = path.join(
60
108
  os.homedir(), '.openagents', 'sessions',
61
109
  `${this.workspaceId}_${this.agentName}_hermes.json`,
62
110
  );
63
111
  this._loadSessions();
112
+ HermesAdapter.sweepStalePrefillFiles();
64
113
 
65
114
  this._hermesBin = this._findHermesBinary();
66
115
  if (this._hermesBin) {
@@ -187,6 +236,12 @@ class HermesAdapter extends BaseAdapter {
187
236
  } catch {}
188
237
  }
189
238
 
239
+ _forgetSession(channelName) {
240
+ if (!(channelName in this._channelSessions)) return;
241
+ delete this._channelSessions[channelName];
242
+ this._saveSessions();
243
+ }
244
+
190
245
  // ------------------------------------------------------------------
191
246
  // Prompt assembly
192
247
  // ------------------------------------------------------------------
@@ -210,32 +265,38 @@ class HermesAdapter extends BaseAdapter {
210
265
  }
211
266
  }
212
267
 
213
- async _getRecentHistoryText(channelName) {
268
+ /**
269
+ * What was said in this channel before the message being answered — for a
270
+ * Hermes session that starts with nothing: a thread the agent joins midway,
271
+ * or one whose earlier session can no longer be resumed. A resumed session
272
+ * holds its own history and is never given this.
273
+ *
274
+ * Bounded, because the whole prompt travels as a single argv entry.
275
+ */
276
+ async _getRecentHistoryText(channelName, currentMessage) {
214
277
  try {
215
- const messages = await this.client.pollMessages({
216
- workspaceId: this.workspaceId,
217
- channelName,
218
- token: this.token,
219
- limit: MAX_HISTORY_ENTRIES,
220
- });
221
- if (!Array.isArray(messages) || messages.length === 0) return '';
222
- const lines = messages
223
- .filter((m) => m.messageType !== 'status')
278
+ const messages = await this.client.getRecentMessages(
279
+ this.workspaceId, channelName, this.token, 30,
280
+ );
281
+ const lines = (messages || [])
282
+ .filter((m) => isRecapEligible(m, currentMessage))
283
+ .slice(-MAX_HISTORY_ENTRIES)
224
284
  .map((m) => {
225
285
  const sender = m.senderName || m.senderType || 'unknown';
226
- const content = (m.content || '').trim();
227
- if (!content) return null;
228
- return `- ${sender}: ${content.slice(0, 400)}`;
229
- })
230
- .filter(Boolean);
286
+ return `- ${sender}: ${m.content.trim().slice(0, 400)}`;
287
+ });
231
288
  return lines.length ? `## Recent Workspace Messages\n${lines.join('\n')}` : '';
232
289
  } catch {
233
290
  return '';
234
291
  }
235
292
  }
236
293
 
237
- async _buildContextPrefix(channelName) {
238
- const parts = [
294
+ /**
295
+ * The workspace's standing instructions for a channel: who the agent is
296
+ * here, the mode, the workspace API, the pinned decisions and glossary.
297
+ */
298
+ _buildWorkspaceInstructions(channelName) {
299
+ return [
239
300
  buildOpenclawSystemPrompt({
240
301
  agentName: this.agentName,
241
302
  workspaceId: this.workspaceId,
@@ -247,20 +308,150 @@ class HermesAdapter extends BaseAdapter {
247
308
  disabledModules: this.disabledModules,
248
309
  ...this.pinnedPromptOpts(channelName),
249
310
  }),
250
- '\n## OpenAgents-specific Rules',
251
- '- Your final text response is posted back to the workspace automatically.',
252
- '- If you need to ask the user something, ask in normal text. Do not try to open an interactive prompt.',
253
- '- Do not reveal secrets, tokens, raw auth headers, or internal command lines.',
254
- '- Keep status concise. Focus on useful output over theatre.',
255
- ];
311
+ ...OPENAGENTS_RULES,
312
+ ].join('\n').trim();
313
+ }
256
314
 
257
- const [agentsText, historyText] = await Promise.all([
258
- this._getAgentsText(),
259
- this._getRecentHistoryText(channelName),
260
- ]);
261
- if (agentsText) parts.push('\n' + agentsText);
262
- if (historyText) parts.push('\n' + historyText);
263
- return parts.join('\n').trim();
315
+ /**
316
+ * The prompt for one turn.
317
+ *
318
+ * A session that is starting out is told who else is in the workspace and
319
+ * what was said before; a resumed one has its own history and gets the
320
+ * user's message as it is. `instructions` is only passed when they could not
321
+ * go out as a prefill and have to travel in the message instead.
322
+ */
323
+ async _buildPrompt(channelName, content, resumeId, instructions = '') {
324
+ const parts = [];
325
+ if (instructions) parts.push(instructions);
326
+ if (!resumeId) {
327
+ const [agentsText, historyText] = await Promise.all([
328
+ this._getAgentsText(),
329
+ this._getRecentHistoryText(channelName, content),
330
+ ]);
331
+ if (agentsText) parts.push('\n' + agentsText);
332
+ if (historyText) parts.push('\n' + historyText);
333
+ }
334
+ const context = parts.join('\n').trim();
335
+ if (context) return `${context}\n\n---\n\nUser message:\n${content}`;
336
+ // The prompt is the value of `-q`; one starting with a dash would be read
337
+ // as another flag.
338
+ return content.startsWith('-') ? `User message:\n${content}` : content;
339
+ }
340
+
341
+ // ------------------------------------------------------------------
342
+ // Workspace instructions as a Hermes prefill
343
+ // ------------------------------------------------------------------
344
+
345
+ /** What `hermes --version` prints; it opens with e.g. "Hermes Agent v0.21.4 (2026.9.24)". */
346
+ _hermesVersionText() {
347
+ const { file, args } = bridgeSpawn(this._hermesBin, ['--version']);
348
+ return new Promise((resolve, reject) => {
349
+ execFile(file, args, {
350
+ env: { ...(this.agentEnv || process.env) },
351
+ timeout: 20000,
352
+ windowsHide: true,
353
+ }, (err, stdout) => (err ? reject(err) : resolve(String(stdout))));
354
+ });
355
+ }
356
+
357
+ /** True when a `hermes --version` output names a release that reads PREFILL_ENV. */
358
+ _readsPrefillEnv(versionText) {
359
+ const text = String(versionText || '');
360
+ const atLeast = (have, want) => {
361
+ const i = have.findIndex((n, k) => n !== want[k]);
362
+ return i === -1 || have[i] > want[i];
363
+ };
364
+ const date = RELEASE_DATE_RE.exec(text);
365
+ if (date) return atLeast(date.slice(1, 4).map(Number), PREFILL_ENV_SINCE);
366
+ const ver = SEMVER_RE.exec(text);
367
+ // The old calendar tags (v2026.3.12) are dates, not versions.
368
+ if (ver && Number(ver[1]) < 1000) return atLeast(ver.slice(1, 4).map(Number), PREFILL_ENV_SINCE_VERSION);
369
+ return false;
370
+ }
371
+
372
+ /**
373
+ * True when the user already points Hermes at a prefill file of their own,
374
+ * through the variable or `prefill_messages_file` in config.yaml. Setting
375
+ * the variable would replace theirs, so the instructions go in the message.
376
+ */
377
+ _userHasPrefill() {
378
+ const env = this.agentEnv || process.env;
379
+ if (String(env[PREFILL_ENV] || '').trim()) return true;
380
+ const homeEnv = String(env.HERMES_HOME || '').trim();
381
+ const home = homeEnv || (IS_WINDOWS
382
+ ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'hermes')
383
+ : path.join(os.homedir(), '.hermes'));
384
+ const configs = [path.join(home, 'config.yaml')];
385
+ if (this.hermesProfile && this.hermesProfile !== 'default') {
386
+ configs.push(path.join(home, 'profiles', this.hermesProfile, 'config.yaml'));
387
+ }
388
+ return configs.some((file) => {
389
+ let text;
390
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return false; }
391
+ return HermesAdapter.configHasPrefill(text);
392
+ });
393
+ }
394
+
395
+ /** True when a config.yaml text sets `prefill_messages_file` to something. */
396
+ static configHasPrefill(text) {
397
+ for (const m of String(text || '').matchAll(CONFIG_PREFILL_RE)) {
398
+ const value = m[1].replace(/\s#.*$/, '').trim().replace(/^(["'])(.*)\1$/, '$2').trim();
399
+ if (value && value !== '~' && value.toLowerCase() !== 'null') return true;
400
+ }
401
+ return false;
402
+ }
403
+
404
+ /**
405
+ * Whether this hermes takes its prefill file from the environment. One that
406
+ * does not would simply start without the workspace instructions, so a
407
+ * version that cannot be read counts as too old. Asked once per binary.
408
+ */
409
+ async _supportsPrefillEnv() {
410
+ const known = this._prefillEnvSupport;
411
+ if (known && known.bin === this._hermesBin) return known.ok;
412
+ let ok = false;
413
+ try { ok = this._readsPrefillEnv(await this._hermesVersionText()); } catch {}
414
+ if (!ok) this._log(`This hermes does not read ${PREFILL_ENV}; sending the workspace instructions with each message`);
415
+ this._prefillEnvSupport = { bin: this._hermesBin, ok };
416
+ return ok;
417
+ }
418
+
419
+ /**
420
+ * Write the instructions where Hermes will pick them up as a prefill: one
421
+ * user message, placed ahead of the conversation on every model request and
422
+ * never stored in the session.
423
+ *
424
+ * Prepending them to each user message, as this adapter used to, wrote a
425
+ * fresh ~18 KB copy into the session every turn. The provider's prompt cache
426
+ * had never seen that copy, so it was new input each time, and it grew the
427
+ * conversation by that much per message however short the message — towards
428
+ * the point where Hermes compresses the conversation, which rewrites the
429
+ * cached prefix as well. Sending them once per session is not an option
430
+ * either: that compression folds old messages into a "reference only"
431
+ * summary, instructions included. As a prefill they are always there,
432
+ * always current, and identical from one request to the next.
433
+ *
434
+ * Returns the file's path, or null when they have to go in the message.
435
+ */
436
+ async _writePrefill(instructions) {
437
+ if (!(await this._supportsPrefillEnv())) return null;
438
+ if (this._userHasPrefill()) {
439
+ if (!this._userPrefillLogged) {
440
+ this._userPrefillLogged = true;
441
+ this._log('Hermes already has a prefill_messages_file; keeping it and sending the workspace instructions with each message');
442
+ }
443
+ return null;
444
+ }
445
+ const file = path.join(os.tmpdir(), `openagents-hermes-${crypto.randomBytes(8).toString('hex')}.json`);
446
+ const content = [instructions, ...PREFILL_CLOSING].join('\n');
447
+ try {
448
+ // Owner-only: the instructions carry the workspace token.
449
+ fs.writeFileSync(file, JSON.stringify([{ role: 'user', content }]), { mode: 0o600 });
450
+ return file;
451
+ } catch (e) {
452
+ this._log(`Could not write the Hermes prefill file (${e.message}); sending workspace instructions with the message`);
453
+ return null;
454
+ }
264
455
  }
265
456
 
266
457
  // ------------------------------------------------------------------
@@ -296,6 +487,18 @@ class HermesAdapter extends BaseAdapter {
296
487
  return { text: lines.join('\n').trim(), sessionId };
297
488
  }
298
489
 
490
+ /** Hermes's stderr without the session bookkeeping it prints on every run. */
491
+ _stderrDetail(stderr) {
492
+ return String(stderr || '')
493
+ .split(/\r?\n/)
494
+ .filter((line) => {
495
+ const stripped = line.trim();
496
+ return stripped && !SESSION_LINE_RE.test(stripped) && !stripped.startsWith('↻ Resumed session ');
497
+ })
498
+ .join('\n')
499
+ .trim();
500
+ }
501
+
299
502
  // ------------------------------------------------------------------
300
503
  // Subprocess lifecycle
301
504
  // ------------------------------------------------------------------
@@ -320,14 +523,27 @@ class HermesAdapter extends BaseAdapter {
320
523
  return args;
321
524
  }
322
525
 
323
- async _runHermes(prompt, channelName) {
526
+ /**
527
+ * Run one Hermes turn and report what became of it.
528
+ *
529
+ * `sessionId` is what Hermes itself reported for this run. It prints one
530
+ * whenever the turn got as far as the session — finished or not — and none
531
+ * when the turn never began. `detail` is its stderr without that
532
+ * bookkeeping; `stopped` says the user stopped the turn.
533
+ *
534
+ * @returns {Promise<{stopped: boolean, exitCode: number|null, text: string,
535
+ * sessionId: string|null, detail: string}>}
536
+ */
537
+ async _runHermes(prompt, channelName, resumeId = null, prefillFile = null) {
324
538
  // Stopped while this turn was being prepared: start nothing (no tokens).
325
- if (this._stoppedBeforeStart(channelName)) return '';
326
- const resumeId = this._channelSessions[channelName];
539
+ if (this._stoppedBeforeStart(channelName)) {
540
+ return { stopped: true, exitCode: null, text: '', sessionId: null, detail: '' };
541
+ }
327
542
  const args = this._buildHermesCmd(prompt, resumeId);
328
543
  this._log(`Running hermes (profile=${this.hermesProfile}, channel=${channelName}, resume=${!!resumeId})`);
329
544
 
330
545
  const env = { ...(this.agentEnv || process.env) };
546
+ if (prefillFile) env[PREFILL_ENV] = prefillFile;
331
547
 
332
548
  // A hermes that lives inside WSL is rewritten to `wsl.exe -e <path> …` by
333
549
  // the bridge in ../wsl (which also forces detached off, there being no
@@ -347,35 +563,88 @@ class HermesAdapter extends BaseAdapter {
347
563
 
348
564
  let stdout = '';
349
565
  let stderr = '';
566
+ let spawnError = null;
350
567
  proc.stdout.on('data', (d) => { stdout += d.toString('utf-8'); });
351
568
  proc.stderr.on('data', (d) => { stderr += d.toString('utf-8'); });
352
569
 
570
+ // 'exit' can fire with output still in the pipes, and the session id is
571
+ // the last thing Hermes prints. 'close' means they are drained — but it
572
+ // never comes while a process the agent left running holds them open, so
573
+ // it is only waited for briefly.
353
574
  const exitCode = await new Promise((resolve) => {
354
- proc.on('exit', resolve);
355
- proc.on('error', () => resolve(-1));
575
+ let code = null;
576
+ let drain = null;
577
+ const done = () => { clearTimeout(drain); resolve(code); };
578
+ proc.on('exit', (c) => { code = c; drain = setTimeout(done, 1000); });
579
+ proc.on('close', done);
580
+ proc.on('error', (e) => { spawnError = e; code = -1; done(); });
356
581
  });
357
582
  delete this._channelProcesses[channelName];
358
583
 
359
- if (exitCode !== 0) {
360
- if (resumeId) {
361
- // Resume may have failed because the session was deleted — drop it and retry fresh
362
- this._log(`Hermes resume failed (code=${exitCode}), retrying without resume`);
363
- delete this._channelSessions[channelName];
364
- this._saveSessions();
365
- return this._runHermes(prompt, channelName);
366
- }
367
- const detail = (stderr || stdout).trim().slice(0, 600);
368
- throw new Error(`hermes exited with code ${exitCode}: ${detail}`);
369
- }
370
-
371
584
  // Hermes -Q mode: response body on stdout, session metadata
372
585
  // (session_id:, resume banner) on stderr — parse both streams.
373
586
  const { text, sessionId } = this._parseHermesOutput(stdout, stderr);
374
- if (sessionId) {
375
- this._channelSessions[channelName] = sessionId;
587
+ return {
588
+ stopped: this._stopRequestedDuringTurn(channelName),
589
+ exitCode,
590
+ text,
591
+ sessionId,
592
+ detail: spawnError ? spawnError.message : this._stderrDetail(stderr),
593
+ };
594
+ }
595
+
596
+ /**
597
+ * One prompt-and-run against a given session (null starts a new one), and
598
+ * the bookkeeping that follows from it.
599
+ */
600
+ async _attemptTurn(channelName, content, resumeId) {
601
+ const instructions = this._buildWorkspaceInstructions(channelName);
602
+ const prefillFile = await this._writePrefill(instructions);
603
+ let run;
604
+ try {
605
+ const prompt = await this._buildPrompt(channelName, content, resumeId, prefillFile ? '' : instructions);
606
+ run = await this._runHermes(prompt, channelName, resumeId, prefillFile);
607
+ } finally {
608
+ // Hermes reads it once, at startup.
609
+ if (prefillFile) try { fs.unlinkSync(prefillFile); } catch {}
610
+ }
611
+
612
+ // Whatever the exit code, a reported session exists and holds this turn.
613
+ if (run.sessionId && run.sessionId !== this._channelSessions[channelName]) {
614
+ this._channelSessions[channelName] = run.sessionId;
376
615
  this._saveSessions();
377
616
  }
378
- return text;
617
+ return run;
618
+ }
619
+
620
+ /**
621
+ * Answer one message in the channel's Hermes session, starting a new session
622
+ * only when there is none to resume.
623
+ *
624
+ * Hermes exits non-zero for every turn that does not finish: a provider
625
+ * error, the iteration limit, a user stop. Each of those still prints the
626
+ * session id — the session is intact and the turn is in it — so none of them
627
+ * is a reason to let go of it. Reading any non-zero exit as "the resume
628
+ * failed" used to drop the session and run the same prompt again in an empty
629
+ * one: the conversation was lost, the work was done twice (tool calls
630
+ * included, for a turn that had spent its iterations), and the user was
631
+ * shown the second failure instead of Hermes's account of the first.
632
+ *
633
+ * What does call for a new session is a resumed turn that never began — no
634
+ * session id comes back, typically with "Session not found". The saved id is
635
+ * only replaced once the new run reports one of its own, or dropped when
636
+ * Hermes said outright that it is gone: a Hermes that cannot start at all
637
+ * (no credentials, say) says nothing about the session either.
638
+ */
639
+ async _converse(channelName, content) {
640
+ const resumeId = this._channelSessions[channelName] || null;
641
+ const run = await this._attemptTurn(channelName, content, resumeId);
642
+ const neverBegan = !run.stopped && run.exitCode !== 0 && !run.sessionId;
643
+ if (!resumeId || !neverBegan) return run;
644
+
645
+ this._log(`Hermes could not resume session ${resumeId} (code=${run.exitCode}), starting a new one`);
646
+ if (SESSION_NOT_FOUND_RE.test(run.detail)) this._forgetSession(channelName);
647
+ return this._attemptTurn(channelName, content, null);
379
648
  }
380
649
 
381
650
  async _stopProcess(proc) {
@@ -427,14 +696,20 @@ class HermesAdapter extends BaseAdapter {
427
696
  await this.sendStatus(msgChannel, 'thinking...');
428
697
 
429
698
  try {
430
- const context = await this._buildContextPrefix(msgChannel);
431
- const prompt = context ? `${context}\n\n---\n\nUser message:\n${content}` : content;
432
- const responseText = await this._runHermes(prompt, msgChannel);
433
-
434
- if (responseText) {
435
- await this.sendResponse(msgChannel, responseText);
699
+ const run = await this._converse(msgChannel, content);
700
+ // The stop handler has already told the channel.
701
+ if (run.stopped) return;
702
+
703
+ if (run.exitCode === 0) {
704
+ await this.sendResponse(msgChannel, run.text || 'No response generated. Please try again.');
705
+ } else if (run.text) {
706
+ // The turn did not finish, and stdout is Hermes's own account of why,
707
+ // after whatever answer it had reached. Worth more to the user than
708
+ // an exit code.
709
+ this._log(`Hermes turn did not finish (code=${run.exitCode})`);
710
+ await this.sendError(msgChannel, run.text);
436
711
  } else {
437
- await this.sendResponse(msgChannel, 'No response generated. Please try again.');
712
+ throw new Error(`hermes exited with code ${run.exitCode}: ${run.detail.slice(0, 600)}`);
438
713
  }
439
714
  } catch (e) {
440
715
  this._log(`Hermes adapter error: ${e.message}`);
@@ -446,6 +721,27 @@ class HermesAdapter extends BaseAdapter {
446
721
  // Static: configure Hermes's native model config from LLM env vars
447
722
  // ------------------------------------------------------------------
448
723
 
724
+ /**
725
+ * Remove prefill files a killed daemon left in `dir`: only ours, only once
726
+ * older than `maxAgeMs`. Best effort. Returns how many were removed.
727
+ */
728
+ static sweepStalePrefillFiles(dir = os.tmpdir(), maxAgeMs = STALE_PREFILL_MS, now = Date.now()) {
729
+ let removed = 0;
730
+ let names = [];
731
+ try { names = fs.readdirSync(dir); } catch { return 0; }
732
+ for (const name of names) {
733
+ if (!PREFILL_FILE_RE.test(name)) continue;
734
+ const file = path.join(dir, name);
735
+ try {
736
+ const st = fs.lstatSync(file);
737
+ if (!st.isFile() || now - st.mtimeMs <= maxAgeMs) continue;
738
+ fs.unlinkSync(file);
739
+ removed++;
740
+ } catch {}
741
+ }
742
+ return removed;
743
+ }
744
+
449
745
  /**
450
746
  * Point Hermes at a custom OpenAI-compatible endpoint from user-provided
451
747
  * LLM_API_KEY / LLM_BASE_URL / LLM_MODEL values. Called by saveAgentEnv when
@@ -24,6 +24,7 @@ const BaseAdapter = require('./base');
24
24
  const { formatAttachmentsForPrompt, redactSecrets } = require('./utils');
25
25
  const { buildOpenCodeSkillMd, buildOpenCodeSystemPrompt, workspaceSkillName } = require('./workspace-prompt');
26
26
  const { whichBinary, whereBinary, getEnhancedEnv } = require('../paths');
27
+ const { isPostinstallStub, isPostinstallStubOutput } = require('../postinstall-stub');
27
28
 
28
29
  const IS_WINDOWS = process.platform === 'win32';
29
30
 
@@ -68,6 +69,7 @@ const CUSTOM_PROVIDER_ID = 'openagents-gateway';
68
69
  const FAILURE_MESSAGES = {
69
70
  cli_not_found: `OpenCode CLI not found. Install it (\`npm install -g opencode-ai@${OPENCODE_PINNED_VERSION}\`) and try again.`,
70
71
  cli_not_executable: 'OpenCode CLI was found but could not be started. Reinstall it, then retry.',
72
+ install_incomplete: `OpenCode is installed, but its setup never finished: the opencode-ai package's postinstall script did not run (npm \`ignore-scripts\`, or a pnpm/bun install), so the \`opencode\` command is only a placeholder. Reinstall OpenCode from the launcher — or, for a copy you installed yourself, run \`npm install -g opencode-ai@${OPENCODE_PINNED_VERSION} --ignore-scripts=false\` — then retry.`,
71
73
  unsupported_version: `This OpenCode CLI version is not supported (requires >= ${OPENCODE_MIN_VERSION}). Reinstall \`opencode-ai@${OPENCODE_PINNED_VERSION}\` and retry.`,
72
74
  model_missing: "No model is configured for OpenCode. Set a model (LLM_MODEL, e.g. `gpt-4o`) in this agent's configuration, then retry.",
73
75
  credential_missing: 'OpenCode has no API key or sign-in configured. Add an API key (LLM_API_KEY) or run `opencode auth login`, then retry.',
@@ -1063,7 +1065,18 @@ class OpenCodeAdapter extends BaseAdapter {
1063
1065
  if (binary) this._opencodeBinary = binary;
1064
1066
  if (!binary) return { ok: false, category: 'cli_not_found' };
1065
1067
 
1068
+ // Before every other check: the placeholder a skipped postinstall leaves
1069
+ // behind has no version and runs no model, so each later check would blame
1070
+ // the wrong thing — "no model configured" first, then an unexplained exit 1
1071
+ // on every message once a model is set.
1072
+ if (isPostinstallStub(binary)) {
1073
+ return { ok: false, category: 'install_incomplete', diagnostic: `${binary} is the package's postinstall placeholder` };
1074
+ }
1075
+
1066
1076
  const probe = this._detectCliVersion(binary);
1077
+ if (probe.stub) {
1078
+ return { ok: false, category: 'install_incomplete', diagnostic: `${binary} --version answered as the package's postinstall placeholder` };
1079
+ }
1067
1080
  if (!probe.executable) {
1068
1081
  return { ok: false, category: 'cli_not_executable', diagnostic: 'opencode --version did not run' };
1069
1082
  }
@@ -1097,7 +1110,9 @@ class OpenCodeAdapter extends BaseAdapter {
1097
1110
  /**
1098
1111
  * Probe the CLI's version and executability, cached briefly so preflight never
1099
1112
  * spawns `--version` more than once per VERSION_PROBE_TTL_MS across channels.
1100
- * Returns { version: string|null, executable: boolean }.
1113
+ * Returns { version: string|null, executable: boolean, stub: boolean } —
1114
+ * `stub` when what answered was the package's postinstall placeholder, which
1115
+ * a wrapper script in front of it (pnpm, bun) hides from a look at the file.
1101
1116
  */
1102
1117
  _detectCliVersion(binary) {
1103
1118
  const now = Date.now();
@@ -1106,6 +1121,7 @@ class OpenCodeAdapter extends BaseAdapter {
1106
1121
  }
1107
1122
  let version = null;
1108
1123
  let executable = false;
1124
+ let stub = false;
1109
1125
  try {
1110
1126
  const raw = execSync(`"${binary}" --version`, {
1111
1127
  encoding: 'utf-8',
@@ -1126,9 +1142,13 @@ class OpenCodeAdapter extends BaseAdapter {
1126
1142
  const out = String((e && (e.stdout || e.stderr)) || '');
1127
1143
  const m = out.match(/(\d+\.\d+\.\d+)/);
1128
1144
  version = m ? m[1] : null;
1145
+ stub = isPostinstallStubOutput(`${(e && e.stdout) || ''}\n${(e && e.stderr) || ''}`);
1129
1146
  }
1130
1147
  }
1131
- this._versionProbe = { version, executable, ts: now };
1148
+ // A placeholder is not cached: the fix is a reinstall, and the message
1149
+ // right after it must not still be answered from the broken copy.
1150
+ if (stub) return { version, executable, stub, ts: now };
1151
+ this._versionProbe = { version, executable, stub, ts: now };
1132
1152
  return this._versionProbe;
1133
1153
  }
1134
1154
 
@@ -1301,7 +1321,9 @@ class OpenCodeAdapter extends BaseAdapter {
1301
1321
  const has = (re) => re.test(hay);
1302
1322
 
1303
1323
  let category;
1304
- if (has(/\b(401|403)\b/) || has(/unauthor|invalid api key|invalid_api_key|authentication|auth(entication)? failed|forbidden|invalid token|no api key|missing api key|permission denied/)) {
1324
+ if (isPostinstallStubOutput(hay)) {
1325
+ category = 'install_incomplete';
1326
+ } else if (has(/\b(401|403)\b/) || has(/unauthor|invalid api key|invalid_api_key|authentication|auth(entication)? failed|forbidden|invalid token|no api key|missing api key|permission denied/)) {
1305
1327
  category = 'auth_failed';
1306
1328
  } else if (has(/\b429\b/) || has(/rate.?limit|too many requests|quota|overloaded/)) {
1307
1329
  category = 'rate_limited';