@commonlyai/cli 0.1.188 → 0.1.190
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.
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
/** Hermes 0.22
|
|
1
|
+
/** Hermes 0.22 local CLI wrapper. Hermes owns provider I/O; this file only spawns it. */
|
|
2
2
|
import { spawn as childSpawn, spawnSync } from 'child_process';
|
|
3
|
-
import { createHash } from 'crypto';
|
|
4
|
-
import { mkdir } from 'fs/promises';
|
|
3
|
+
import { createHash, randomBytes } from 'crypto';
|
|
4
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'fs/promises';
|
|
5
5
|
import { homedir } from 'os';
|
|
6
6
|
import { join } from 'path';
|
|
7
7
|
import { StringDecoder } from 'string_decoder';
|
|
@@ -9,11 +9,64 @@ import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
|
9
9
|
import { requireSeatModel } from '../seat-model.js';
|
|
10
10
|
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
11
11
|
|
|
12
|
-
export const
|
|
12
|
+
export const SUPPORTED_HERMES_VERSION_RANGE = '>=0.22.5 <0.23.0';
|
|
13
|
+
const supportsHermesVersion = (version) => {
|
|
14
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version || '');
|
|
15
|
+
if (!match) return false;
|
|
16
|
+
return Number(match[1]) === 0 && Number(match[2]) === 22 && Number(match[3]) >= 5;
|
|
17
|
+
};
|
|
13
18
|
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
14
19
|
const VERSION_TIMEOUT_MS = 15000;
|
|
15
20
|
const MAX_STDERR_CHARS = 4096;
|
|
16
21
|
const MAX_LINE_CHARS = 1024 * 1024;
|
|
22
|
+
// Keys the wrapper owns in the profile's .env. Every other line is the user's.
|
|
23
|
+
const PROFILE_ENV_KEYS = ['OPENROUTER_API_KEY', 'OPENROUTER_BASE_URL'];
|
|
24
|
+
const PLAIN_ENV_VALUE = /^[A-Za-z0-9_\-.:/+=@%~]+$/;
|
|
25
|
+
|
|
26
|
+
const definedKey = (line) => {
|
|
27
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
28
|
+
return match ? match[1] : null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// From 0.22.5 the detached gateway serves a profiles/<hash> home with a child
|
|
32
|
+
// env scrubbed of credentials and overlaid with that profile's own .env
|
|
33
|
+
// (tools/environments/local.py, served_profile_child_env), so a key in the
|
|
34
|
+
// spawn env never reaches the model. Hermes documents <profile>/.env as where
|
|
35
|
+
// a profile's API keys live (website/docs/user-guide/profiles.md). The file
|
|
36
|
+
// stays after the turn: the gateway outlives the spawn and rereads it.
|
|
37
|
+
export const syncProfileEnv = async (home, env) => {
|
|
38
|
+
const owned = PROFILE_ENV_KEYS.filter((key) => env[key]);
|
|
39
|
+
for (const key of owned) {
|
|
40
|
+
if (!PLAIN_ENV_VALUE.test(env[key])) {
|
|
41
|
+
throw new Error(`${key} contains characters a Hermes .env line cannot carry unquoted; refusing to write it`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const path = join(home, '.env');
|
|
45
|
+
let current = '';
|
|
46
|
+
try {
|
|
47
|
+
current = await readFile(path, 'utf8');
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error?.code !== 'ENOENT') throw new Error(`Cannot read the Hermes profile .env (${error?.code || 'read failed'})`);
|
|
50
|
+
}
|
|
51
|
+
const kept = current.split(/\r?\n/).filter((line, index, lines) => (
|
|
52
|
+
!owned.includes(definedKey(line)) && !(index === lines.length - 1 && line === '')
|
|
53
|
+
));
|
|
54
|
+
const next = `${[...kept, ...owned.map((key) => `${key}=${env[key]}`)].join('\n')}\n`;
|
|
55
|
+
await chmod(home, 0o700);
|
|
56
|
+
if (next === current) {
|
|
57
|
+
await chmod(path, 0o600);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const temp = join(home, `.env.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
61
|
+
try {
|
|
62
|
+
await writeFile(temp, next, { mode: 0o600, flag: 'wx' });
|
|
63
|
+
await chmod(temp, 0o600);
|
|
64
|
+
await rename(temp, path);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
await rm(temp, { force: true });
|
|
67
|
+
throw new Error(`Cannot write the Hermes profile .env (${error?.code || 'write failed'})`);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
17
70
|
|
|
18
71
|
const readVersion = (env, spawnSyncImpl = spawnSync) => {
|
|
19
72
|
let probe;
|
|
@@ -24,8 +77,8 @@ const readVersion = (env, spawnSyncImpl = spawnSync) => {
|
|
|
24
77
|
if (probe.error?.code === 'ENOENT') return null;
|
|
25
78
|
if (probe.error || probe.status !== 0) continue;
|
|
26
79
|
const version = String(probe.stdout || '').match(/Hermes Agent v([^\s]+)/i)?.[1];
|
|
27
|
-
if (version
|
|
28
|
-
throw new Error(`Hermes ${version || 'unknown'} is unsupported; this adapter
|
|
80
|
+
if (!supportsHermesVersion(version)) {
|
|
81
|
+
throw new Error(`Hermes ${version || 'unknown'} is unsupported; this adapter supports ${SUPPORTED_HERMES_VERSION_RANGE}. Run hermes update if yours is older than 0.22.5; releases 0.23 and later are not supported yet`);
|
|
29
82
|
}
|
|
30
83
|
return version;
|
|
31
84
|
}
|
|
@@ -182,13 +235,18 @@ export const createHermesAdapter = ({
|
|
|
182
235
|
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
183
236
|
const env = { ...(ctx.env || process.env), HERMES_HOME: home };
|
|
184
237
|
if (!readVersion(env, ctx._spawnSyncImpl)) {
|
|
185
|
-
throw new Error(
|
|
238
|
+
throw new Error(`Hermes Agent ${SUPPORTED_HERMES_VERSION_RANGE} is not on PATH; install for your OS: https://hermes-agent.nousresearch.com/docs/getting-started/installation`);
|
|
186
239
|
}
|
|
187
240
|
if (!env.OPENROUTER_API_KEY) {
|
|
188
241
|
throw new Error('Supply the operator OPENROUTER_API_KEY through Secret Manager before running Hermes');
|
|
189
242
|
}
|
|
243
|
+
await syncProfileEnv(home, env);
|
|
244
|
+
const cwd = ctx.cwd || process.cwd();
|
|
245
|
+
// 0.22.5's gateway rejects --no-restore-cwd; --in pins the workspace on
|
|
246
|
+
// both fresh and resumed turns. 0.22.2-0.22.4 are refused above, so no
|
|
247
|
+
// version-keyed flag is needed.
|
|
190
248
|
const args = ['chat', '--oneshot', '--query-file', '-', '--format', 'stream-json',
|
|
191
|
-
'--provider', 'openrouter', '--source', 'tool', '--
|
|
249
|
+
'--provider', 'openrouter', '--source', 'tool', '--in', cwd];
|
|
192
250
|
if (seatModel) args.push('--model', seatModel);
|
|
193
251
|
if (ctx.sessionId) args.push('--resume', ctx.sessionId);
|
|
194
252
|
const fullPrompt = memoryPreamble(prompt, ctx.memoryLongTerm, {
|
|
@@ -198,8 +256,8 @@ export const createHermesAdapter = ({
|
|
|
198
256
|
const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0
|
|
199
257
|
? Math.min(requestedTimeout, DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
|
|
200
258
|
return runHermes({
|
|
201
|
-
args, prompt: fullPrompt, cwd
|
|
202
|
-
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args, { modelFlag: '--model', valueFlags: ['--query-file', '--format', '--provider', '--source', '--resume'] }),
|
|
259
|
+
args, prompt: fullPrompt, cwd, env, timeoutMs,
|
|
260
|
+
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args, { modelFlag: '--model', valueFlags: ['--query-file', '--format', '--provider', '--source', '--in', '--resume'] }),
|
|
203
261
|
credentials: spawnCredentials(ctx, ['OPENROUTER_API_KEY']),
|
|
204
262
|
});
|
|
205
263
|
},
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
|
5
5
|
import { effectiveSandboxTrust } from '../environment.js';
|
|
6
6
|
import { seatModelRequired } from './seat-model.js';
|
|
7
7
|
|
|
8
|
-
export {
|
|
8
|
+
export { SUPPORTED_HERMES_VERSION_RANGE, readHermesJsonl, syncProfileEnv } from '@commonlyai/seats/adapters/hermes';
|
|
9
9
|
|
|
10
10
|
// Commonly's policy for the generic Hermes adapter: its memory preamble, its
|
|
11
11
|
// legacy sandbox-trust names, the fleet seat-model switch, and the profile
|
|
@@ -301,12 +301,19 @@ const validateProviderEnvironment = async (environment, workspacePath) => {
|
|
|
301
301
|
return resolveProviderKeyFile(environment.provider, workspacePath);
|
|
302
302
|
};
|
|
303
303
|
|
|
304
|
+
const STALE_SESSION_PATTERN = /session not found/i;
|
|
305
|
+
// Set by runOpenCode only when the rejected run produced no turn output, so a
|
|
306
|
+
// phrase that shows up after the model acted cannot replay the turn.
|
|
307
|
+
const isStaleSessionError = (err) => err?.staleSession === true;
|
|
308
|
+
|
|
304
309
|
const makeEventParser = () => {
|
|
305
310
|
let buffer = '';
|
|
306
311
|
let sessionId = null;
|
|
307
312
|
let text = '';
|
|
308
313
|
let usage = null;
|
|
309
314
|
let errorMessage = null;
|
|
315
|
+
// Any text, tool call or step result means the model acted this run.
|
|
316
|
+
let turnOutput = false;
|
|
310
317
|
// Every tool call that came back as an error, for the public-seat check in
|
|
311
318
|
// spawn (publicToolFailure).
|
|
312
319
|
const toolErrors = [];
|
|
@@ -315,11 +322,15 @@ const makeEventParser = () => {
|
|
|
315
322
|
try {
|
|
316
323
|
const event = JSON.parse(line);
|
|
317
324
|
if (event.sessionID) sessionId = event.sessionID;
|
|
325
|
+
// Any step event means the run started a turn, so it is not safe to replay.
|
|
326
|
+
if (String(event.type || '').startsWith('step_') || event.type === 'tool_use') turnOutput = true;
|
|
318
327
|
if (event.type === 'text' && typeof event.part?.text === 'string') {
|
|
328
|
+
turnOutput = true;
|
|
319
329
|
text += event.part.text;
|
|
320
330
|
} else if (event.type === 'step_finish' && event.part?.tokens) {
|
|
321
331
|
usage = event.part.tokens;
|
|
322
|
-
}
|
|
332
|
+
}
|
|
333
|
+
if (event.type === 'tool_use' && event.part?.state?.status === 'error') {
|
|
323
334
|
const { error } = event.part.state;
|
|
324
335
|
toolErrors.push({
|
|
325
336
|
tool: String(event.part.tool || ''),
|
|
@@ -334,6 +345,7 @@ const makeEventParser = () => {
|
|
|
334
345
|
}
|
|
335
346
|
};
|
|
336
347
|
return {
|
|
348
|
+
get turnOutput() { return turnOutput; },
|
|
337
349
|
consume(chunk) {
|
|
338
350
|
buffer += chunk.toString();
|
|
339
351
|
let newline;
|
|
@@ -428,14 +440,18 @@ const runOpenCode = ({ cmd, args, cwd, env, timeoutMs, credentials, spawnImpl =
|
|
|
428
440
|
events.flush();
|
|
429
441
|
if (timedOut) return reject(new Error(`opencode timed out after ${timeoutMs}ms`));
|
|
430
442
|
if (events.errorMessage) {
|
|
431
|
-
|
|
443
|
+
const err = adapterFailure('opencode turn failed', events.errorMessage, {
|
|
432
444
|
credentials, limit: 2000,
|
|
433
|
-
})
|
|
445
|
+
});
|
|
446
|
+
err.staleSession = !events.turnOutput && STALE_SESSION_PATTERN.test(events.errorMessage);
|
|
447
|
+
return reject(err);
|
|
434
448
|
}
|
|
435
449
|
if (code !== 0) {
|
|
436
|
-
|
|
450
|
+
const err = adapterFailure('opencode', stderr.trim(), {
|
|
437
451
|
credentials, exitCode: code, limit: 500,
|
|
438
|
-
})
|
|
452
|
+
});
|
|
453
|
+
err.staleSession = !events.turnOutput && STALE_SESSION_PATTERN.test(stderr);
|
|
454
|
+
return reject(err);
|
|
439
455
|
}
|
|
440
456
|
resolve({
|
|
441
457
|
sessionId: events.sessionId, text: events.text, usage: events.usage, toolErrors: events.toolErrors,
|
|
@@ -650,6 +666,18 @@ export default {
|
|
|
650
666
|
},
|
|
651
667
|
|
|
652
668
|
async spawn(prompt, rawCtx = {}) {
|
|
669
|
+
try {
|
|
670
|
+
return await this.spawnTurn(prompt, rawCtx);
|
|
671
|
+
} catch (err) {
|
|
672
|
+
if (!rawCtx.sessionId || !isStaleSessionError(err)) throw err;
|
|
673
|
+
// The saved id is one OpenCode does not know: a seat switched from another
|
|
674
|
+
// adapter carries a Claude id, and a pruned session fails the same way. Nothing
|
|
675
|
+
// ran, so one fresh turn is safe (pi #1726 does the same fallback).
|
|
676
|
+
return this.spawnTurn(prompt, { ...rawCtx, sessionId: null });
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
|
|
680
|
+
async spawnTurn(prompt, rawCtx = {}) {
|
|
653
681
|
const seatModel = requireSeatModel(rawCtx.environment, 'opencode');
|
|
654
682
|
const sandbox = normalizeSandboxTrust(rawCtx.environment?.sandbox);
|
|
655
683
|
refuseWorkspaceTrust(sandbox, 'opencode');
|