@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.
- package/README.md +32 -2
- package/package.json +1 -1
- package/registry.json +9 -3
- package/src/adapters/antigravity-stream.js +65 -5
- package/src/adapters/base.js +277 -9
- package/src/adapters/claude.js +152 -21
- package/src/adapters/codex.js +37 -3
- package/src/adapters/cursor-node-entry.js +64 -0
- package/src/adapters/cursor.js +31 -1
- package/src/adapters/hermes.js +354 -58
- package/src/adapters/opencode.js +25 -3
- package/src/adapters/response-artifacts.js +72 -0
- package/src/adapters/thread-history.js +49 -0
- package/src/adapters/workspace-prompt.js +102 -12
- package/src/cli-models.js +244 -0
- package/src/cli.js +69 -13
- package/src/config.js +1 -0
- package/src/daemon.js +153 -15
- package/src/index.js +10 -1
- package/src/installer.js +413 -16
- package/src/mcp-server.js +138 -9
- package/src/node-config.js +6 -4
- package/src/postinstall-stub.js +69 -0
- package/src/tools-v11/brief.js +97 -0
- package/src/tools-v11/index.js +28 -0
- package/src/tools-v11/presence-handoff.js +199 -0
- package/src/workspace-client.js +230 -18
package/src/adapters/hermes.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
216
|
-
workspaceId
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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))
|
|
326
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
375
|
-
this.
|
|
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
|
|
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
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (
|
|
435
|
-
await this.sendResponse(msgChannel,
|
|
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
|
-
|
|
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
|
package/src/adapters/opencode.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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';
|