@pixelbyte-software/pixcode 1.49.9 → 1.49.11
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/dist/assets/index-DjKDBqln.css +32 -0
- package/dist/assets/{index-DzGkH0cd.js → index-Q-GU9EZQ.js} +127 -127
- package/dist/index.html +2 -2
- package/dist-server/server/index.js +34 -1
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.js +11 -4
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.js.map +1 -1
- package/dist-server/server/services/hermes-gateway.js +202 -4
- package/dist-server/server/services/hermes-gateway.js.map +1 -1
- package/dist-server/server/services/hermes-install-jobs.js +60 -6
- package/dist-server/server/services/hermes-install-jobs.js.map +1 -1
- package/package.json +1 -1
- package/scripts/hermes/configure-pixcode-mcp.mjs +36 -1
- package/scripts/hermes/pixcode-mcp-server.mjs +63 -5
- package/scripts/smoke/hermes-api-install.mjs +2 -0
- package/scripts/smoke/hermes-gateway-persistence.mjs +104 -0
- package/scripts/smoke/hermes-mcp-pixcode-roundtrip.mjs +27 -0
- package/scripts/smoke/hermes-rest-chat-api.mjs +24 -2
- package/scripts/smoke/hermes-rest-chat-live.mjs +4 -0
- package/scripts/smoke/hermes-rest-gateway.mjs +12 -1
- package/scripts/smoke/hermes-smoke-launcher-guard.mjs +34 -0
- package/scripts/smoke/pixcode-workbench-1-48.mjs +14 -3
- package/server/index.js +40 -1
- package/server/modules/orchestration/hermes/hermes.routes.ts +12 -4
- package/server/services/hermes-gateway.js +219 -4
- package/server/services/hermes-install-jobs.js +59 -6
- package/dist/assets/index-Bw6PxVkB.css +0 -32
|
@@ -65,8 +65,11 @@ assert.match(workbench, /BOTTOM_TERMINAL_MIN_HEIGHT/, 'Bottom terminal should su
|
|
|
65
65
|
assert.match(workbench, /isBottomTerminalMinimized/, 'Bottom terminal should support minimizing without closing.');
|
|
66
66
|
assert.match(workbench, /isPlainShell/, 'Bottom terminal should open the selected project folder without starting the selected AI CLI.');
|
|
67
67
|
assert.doesNotMatch(workbench, /HERMES_AGENT_START_COMMAND/, 'Hermes Agent should not launch from the bottom terminal through a server-side sentinel.');
|
|
68
|
-
assert.
|
|
69
|
-
assert.
|
|
68
|
+
assert.doesNotMatch(workbench, /HermesApiChatPanel|HermesTerminalTranscript/, 'Hermes Agent should use the real PTY terminal UI, not a custom REST chat transcript.');
|
|
69
|
+
assert.doesNotMatch(workbench, /REST POST \/|transport=|response=|gateway=http/, 'Hermes terminal UI must not expose REST debug internals to the user.');
|
|
70
|
+
assert.match(workbench, /command="hermes"/, 'Hermes Agent bottom panel should launch the actual `hermes` CLI in a PTY.');
|
|
71
|
+
assert.match(workbench, /Pixcode MCP Live/, 'Hermes terminal should show a user-facing Pixcode MCP live badge.');
|
|
72
|
+
assert.doesNotMatch(workbench, /ml-auto border-blue-500\/40 bg-blue-500\/10/, 'Hermes REST panel must not use right-aligned chat bubbles.');
|
|
70
73
|
assert.match(workbench, /terminal-launches\/stream/, 'Hermes CLI launch requests should arrive through an EventSource stream.');
|
|
71
74
|
assert.doesNotMatch(workbench, /HERMES_TERMINAL_LAUNCH_POLL_MS|setInterval\([\s\S]*terminal-launches/, 'Hermes CLI launch requests should not be polled every few seconds.');
|
|
72
75
|
assert.doesNotMatch(workbench, /Project-scoped agent terminal\. Installs Hermes when missing/, 'Right CLI panel should not show the old Hermes card.');
|
|
@@ -86,6 +89,8 @@ assert.match(serverIndex, /\/api\/shell\/sessions\/terminate/, 'Backend should e
|
|
|
86
89
|
assert.match(serverIndex, /isPlainShell && !initialCommand/, 'Backend should spawn an interactive plain shell when no terminal command is provided.');
|
|
87
90
|
assert.doesNotMatch(serverIndex, /pixcode:hermes:start/, 'Backend should not need a Hermes terminal sentinel for the workbench Hermes panel.');
|
|
88
91
|
assert.doesNotMatch(serverIndex, /hermesCommand/, 'Provider shell starts should not reference the removed Hermes sentinel variable.');
|
|
92
|
+
assert.match(serverIndex, /configure-pixcode-mcp\.mjs/, 'Hermes PTY launches should configure Pixcode MCP before starting the CLI.');
|
|
93
|
+
assert.match(serverIndex, /resolveHermesMcpBaseUrl/, 'Hermes MCP should use the local Pixcode API base URL from the host process.');
|
|
89
94
|
assert.doesNotMatch(hermesInstallJobs, /iex \(irm https:\/\/raw\.githubusercontent\.com\/NousResearch\/hermes-agent\/main\/scripts\/install\.ps1\)/, 'Windows Hermes install should avoid the old inline iex pattern.');
|
|
90
95
|
assert.doesNotMatch(hermesInstallJobs, /scriptblock\]::Create\(\(irm https:\/\/raw\.githubusercontent\.com\/NousResearch\/hermes-agent\/main\/scripts\/install\.ps1\)\)/, 'Windows Hermes install should avoid scriptblock Invoke-RestMethod eval patterns.');
|
|
91
96
|
assert.match(hermesInstallJobs, /downloadHermesInstaller/, 'Windows Hermes install should download the installer through backend API code before running it.');
|
|
@@ -115,12 +120,18 @@ assert.match(hermesRoutes, /createHermesRouter/, 'Hermes should have a dedicated
|
|
|
115
120
|
assert.match(hermesRoutes, /terminal-launches/, 'Hermes MCP should be able to request visible Pixcode CLI terminal launches.');
|
|
116
121
|
assert.match(hermesRoutes, /terminal-launches\/stream/, 'Hermes MCP terminal launch requests should stream to the workbench over SSE.');
|
|
117
122
|
assert.match(hermesRoutes, /hermesTerminalLaunchEmitter/, 'Hermes terminal launch stream should broadcast new events instead of relying on polling.');
|
|
118
|
-
assert.match(hermesRoutes, /router\.post\('\/gateway\/chat'/, 'Hermes should expose a REST chat endpoint for
|
|
123
|
+
assert.match(hermesRoutes, /router\.post\('\/gateway\/chat'/, 'Hermes should still expose a REST chat endpoint for health checks and integrations.');
|
|
119
124
|
assert.match(hermesRoutes, /install-status/, 'Hermes settings and terminal UI should have an install-status endpoint.');
|
|
120
125
|
assert.match(hermesRoutes, /router\.post\('\/install'/, 'Hermes should install through the backend API instead of terminal command paste.');
|
|
121
126
|
assert.match(read('scripts/smoke/hermes-api-install.mjs'), /hermes API install smoke passed/, 'Hermes API install behavior should have a focused smoke test.');
|
|
122
127
|
assert.match(workbench, /terminalStartupInput/, 'Hermes terminal launch prompts should be passed into the selected CLI.');
|
|
123
128
|
assert.match(read('src/components/shell/hooks/useShellConnection.ts'), /startupInputRef/, 'Shell connections should support one-shot startup input for Hermes-triggered CLI work.');
|
|
129
|
+
assert.match(read('src/components/shell/hooks/useShellConnection.ts'), /isCliReadyForStartupInput/, 'Hermes-triggered CLI input should wait until the provider TUI is ready.');
|
|
130
|
+
assert.doesNotMatch(read('src/components/shell/hooks/useShellConnection.ts'), /TERMINAL_INIT_DELAY_MS \* 3/, 'Hermes-triggered CLI input should not be sent on a blind fixed delay.');
|
|
131
|
+
assert.match(shellTerminal, /handleTerminalPaste/, 'Terminal should support browser paste events.');
|
|
132
|
+
assert.match(shellTerminal, /handleCopyPasteShortcut/, 'Terminal should normalize Ctrl/Cmd copy and paste shortcuts.');
|
|
133
|
+
assert.match(shellTerminal, /event\.shiftKey/, 'Terminal should support Ctrl+Shift+C/V style shortcuts.');
|
|
134
|
+
assert.match(shellTerminal, /copyTerminalSelection/, 'Terminal should copy selected terminal text through shortcuts.');
|
|
124
135
|
assert.match(serverIndex, /forceNewSession/, 'Shell backend should support explicit fresh-session launches from the workbench.');
|
|
125
136
|
assert.match(serverIndex, /killProviderPtySessions/, 'Shell backend should terminate old provider PTYs when a fresh CLI session is requested.');
|
|
126
137
|
|
package/server/index.js
CHANGED
|
@@ -390,6 +390,34 @@ function resolvePublicBaseUrl(request) {
|
|
|
390
390
|
return `${proto}://${String(host).split(',')[0].trim()}`;
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
+
function resolveHermesMcpBaseUrl() {
|
|
394
|
+
const configured = process.env.PIXCODE_INTERNAL_BASE_URL || process.env.PIXCODE_HERMES_BASE_URL;
|
|
395
|
+
if (configured) return configured.replace(/\/$/, '');
|
|
396
|
+
|
|
397
|
+
return `http://127.0.0.1:${process.env.SERVER_PORT || process.env.PORT || '3001'}`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function quoteBashArg(value) {
|
|
401
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function quotePowerShellArg(value) {
|
|
405
|
+
return `"${String(value).replace(/`/g, '``').replace(/\$/g, '`$').replace(/"/g, '`"')}"`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function isHermesCliCommand(command) {
|
|
409
|
+
return typeof command === 'string' && command.trim() === 'hermes';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function buildHermesCliCommand(command) {
|
|
413
|
+
const configureScript = path.join(APP_ROOT, 'scripts', 'hermes', 'configure-pixcode-mcp.mjs');
|
|
414
|
+
if (os.platform() === 'win32') {
|
|
415
|
+
return `& ${quotePowerShellArg(process.execPath)} ${quotePowerShellArg(configureScript)} *> $null; ${command}`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return `${quoteBashArg(process.execPath)} ${quoteBashArg(configureScript)} >/dev/null 2>&1; exec ${command}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
393
421
|
function getOrCreateHermesApiKey(userId) {
|
|
394
422
|
if (!userId) return null;
|
|
395
423
|
|
|
@@ -2182,10 +2210,14 @@ function handleShellConnection(ws, request) {
|
|
|
2182
2210
|
const provider = data.provider || 'claude';
|
|
2183
2211
|
const initialCommand = data.initialCommand;
|
|
2184
2212
|
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
|
2213
|
+
const isHermesCliLaunch = isPlainShell && isHermesCliCommand(initialCommand);
|
|
2185
2214
|
const forceNewSession = Boolean(data.forceNewSession);
|
|
2186
2215
|
const shellPermissionMode = normalizeShellPermissionMode(data.permissionMode);
|
|
2187
2216
|
const shellSkipPermissions = Boolean(data.skipPermissions);
|
|
2188
2217
|
const shellPermissionFlags = buildProviderShellPermissionFlags(provider, shellPermissionMode, shellSkipPermissions);
|
|
2218
|
+
const hermesApiKey = isHermesCliLaunch
|
|
2219
|
+
? getOrCreateHermesApiKey(request.user?.id ?? request.user?.userId ?? null)
|
|
2220
|
+
: null;
|
|
2189
2221
|
urlDetectionBuffer = '';
|
|
2190
2222
|
announcedAuthUrls.clear();
|
|
2191
2223
|
|
|
@@ -2314,7 +2346,9 @@ function handleShellConnection(ws, request) {
|
|
|
2314
2346
|
let shellCommand;
|
|
2315
2347
|
if (isPlainShell) {
|
|
2316
2348
|
// Plain shell mode without an initial command must stay interactive.
|
|
2317
|
-
shellCommand =
|
|
2349
|
+
shellCommand = isHermesCliLaunch
|
|
2350
|
+
? buildHermesCliCommand(initialCommand)
|
|
2351
|
+
: initialCommand || null;
|
|
2318
2352
|
} else if (provider === 'cursor') {
|
|
2319
2353
|
const command = buildProviderShellCommand('cursor-agent', shellPermissionFlags);
|
|
2320
2354
|
if (hasSession && sessionId) {
|
|
@@ -2429,6 +2463,11 @@ function handleShellConnection(ws, request) {
|
|
|
2429
2463
|
TERM: 'xterm-256color',
|
|
2430
2464
|
COLORTERM: 'truecolor',
|
|
2431
2465
|
FORCE_COLOR: '3',
|
|
2466
|
+
...(isHermesCliLaunch ? {
|
|
2467
|
+
PIXCODE_BASE_URL: resolveHermesMcpBaseUrl(),
|
|
2468
|
+
PIXCODE_API_KEY: hermesApiKey || '',
|
|
2469
|
+
PIXCODE_APP_ROOT: APP_ROOT,
|
|
2470
|
+
} : {}),
|
|
2432
2471
|
});
|
|
2433
2472
|
|
|
2434
2473
|
shellProcess = pty.spawn(shell, shellArgs, {
|
|
@@ -60,6 +60,13 @@ function readUserId(req: PixcodeRequest) {
|
|
|
60
60
|
return req.user?.id ?? req.user?.userId ?? null;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function resolveHermesMcpBaseUrl() {
|
|
64
|
+
const configured = process.env.PIXCODE_INTERNAL_BASE_URL || process.env.PIXCODE_HERMES_BASE_URL;
|
|
65
|
+
if (configured) return configured.replace(/\/$/, '');
|
|
66
|
+
|
|
67
|
+
return `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
63
70
|
function readAfterId(req: Request) {
|
|
64
71
|
const after = Number.parseInt(typeof req.query.after === 'string' ? req.query.after : '0', 10);
|
|
65
72
|
return Number.isFinite(after) ? after : 0;
|
|
@@ -137,7 +144,7 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
|
|
|
137
144
|
const gateway = await ensureHermesGateway({
|
|
138
145
|
appRoot: options.appRoot ?? process.cwd(),
|
|
139
146
|
pixcodeApiKey: apiKey,
|
|
140
|
-
pixcodeBaseUrl:
|
|
147
|
+
pixcodeBaseUrl: resolveHermesMcpBaseUrl(),
|
|
141
148
|
projectPath: typeof body.projectPath === 'string' ? body.projectPath : undefined,
|
|
142
149
|
});
|
|
143
150
|
res.status(202).json(gateway);
|
|
@@ -172,7 +179,7 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
|
|
|
172
179
|
await ensureHermesGateway({
|
|
173
180
|
appRoot: options.appRoot ?? process.cwd(),
|
|
174
181
|
pixcodeApiKey: apiKey,
|
|
175
|
-
pixcodeBaseUrl:
|
|
182
|
+
pixcodeBaseUrl: resolveHermesMcpBaseUrl(),
|
|
176
183
|
projectPath: projectPath ?? undefined,
|
|
177
184
|
});
|
|
178
185
|
}
|
|
@@ -224,8 +231,9 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
|
|
|
224
231
|
const gateway = await ensureHermesGateway({
|
|
225
232
|
appRoot: options.appRoot ?? process.cwd(),
|
|
226
233
|
pixcodeApiKey: apiKey,
|
|
227
|
-
pixcodeBaseUrl:
|
|
234
|
+
pixcodeBaseUrl: resolveHermesMcpBaseUrl(),
|
|
228
235
|
projectPath,
|
|
236
|
+
probeExisting: false,
|
|
229
237
|
});
|
|
230
238
|
const run = await runHermesGatewayPrompt(projectPath, {
|
|
231
239
|
input,
|
|
@@ -272,7 +280,7 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
|
|
|
272
280
|
appRoot: options.appRoot ?? process.cwd(),
|
|
273
281
|
force: Boolean(body.force),
|
|
274
282
|
pixcodeApiKey: apiKey,
|
|
275
|
-
pixcodeBaseUrl:
|
|
283
|
+
pixcodeBaseUrl: resolveHermesMcpBaseUrl(),
|
|
276
284
|
skipBrowser: body.skipBrowser !== false,
|
|
277
285
|
});
|
|
278
286
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
2
3
|
import net from 'node:net';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
@@ -18,6 +19,27 @@ const FETCH_TIMEOUT_MS = 5000;
|
|
|
18
19
|
const RUN_TIMEOUT_MS = 120000;
|
|
19
20
|
const RUN_POLL_INTERVAL_MS = 1000;
|
|
20
21
|
const LOG_LIMIT = 800;
|
|
22
|
+
const PIXCODE_MANAGED_HERMES_ENV_PREFIXES = [
|
|
23
|
+
'API_SERVER_',
|
|
24
|
+
'BLUEBUBBLES_',
|
|
25
|
+
'DINGTALK_',
|
|
26
|
+
'DISCORD_',
|
|
27
|
+
'EMAIL_',
|
|
28
|
+
'FEISHU_',
|
|
29
|
+
'MATTERMOST_',
|
|
30
|
+
'MATRIX_',
|
|
31
|
+
'MSGRAPH_',
|
|
32
|
+
'QQ_',
|
|
33
|
+
'SIGNAL_',
|
|
34
|
+
'SLACK_',
|
|
35
|
+
'SMS_',
|
|
36
|
+
'TELEGRAM_',
|
|
37
|
+
'TWILIO_',
|
|
38
|
+
'WECOM_',
|
|
39
|
+
'WEIXIN_',
|
|
40
|
+
'WHATSAPP_',
|
|
41
|
+
'YUANBAO_',
|
|
42
|
+
];
|
|
21
43
|
|
|
22
44
|
const gateways = new Map();
|
|
23
45
|
|
|
@@ -53,6 +75,96 @@ function sleep(ms) {
|
|
|
53
75
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
76
|
}
|
|
55
77
|
|
|
78
|
+
function resolveSourceHermesHome(env = process.env) {
|
|
79
|
+
if (env.HERMES_HOME?.trim()) {
|
|
80
|
+
return path.resolve(env.HERMES_HOME);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const defaultHome = path.join(os.homedir(), '.hermes');
|
|
84
|
+
try {
|
|
85
|
+
const activeProfile = fs.readFileSync(path.join(defaultHome, 'active_profile'), 'utf8').trim();
|
|
86
|
+
if (activeProfile && activeProfile !== 'default' && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(activeProfile)) {
|
|
87
|
+
return path.join(defaultHome, 'profiles', activeProfile);
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Default Hermes profile is fine when no sticky active profile exists.
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return defaultHome;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveHermesGatewayHome(env = process.env, options = {}) {
|
|
97
|
+
const configured = options.hermesHome || env.PIXCODE_HERMES_GATEWAY_HOME;
|
|
98
|
+
if (configured) {
|
|
99
|
+
return path.resolve(configured);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return path.join(os.homedir(), '.hermes', 'profiles', 'pixcode');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function copyHermesProfileFile(sourceHome, targetHome, fileName, options = {}) {
|
|
106
|
+
const source = path.join(sourceHome, fileName);
|
|
107
|
+
const target = path.join(targetHome, fileName);
|
|
108
|
+
if (!fs.existsSync(source)) return false;
|
|
109
|
+
if (!options.overwrite && fs.existsSync(target)) return false;
|
|
110
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
111
|
+
fs.copyFileSync(source, target);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function shouldStripManagedGatewayEnvLine(line) {
|
|
116
|
+
const match = String(line || '').match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=/);
|
|
117
|
+
if (!match) return false;
|
|
118
|
+
return PIXCODE_MANAGED_HERMES_ENV_PREFIXES.some((prefix) => match[1].startsWith(prefix));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function copyHermesProfileEnv(sourceHome, targetHome) {
|
|
122
|
+
const source = path.join(sourceHome, '.env');
|
|
123
|
+
const target = path.join(targetHome, '.env');
|
|
124
|
+
if (!fs.existsSync(source)) return false;
|
|
125
|
+
|
|
126
|
+
const sourceText = fs.readFileSync(source, 'utf8');
|
|
127
|
+
const sanitized = sourceText
|
|
128
|
+
.split(/\r?\n/)
|
|
129
|
+
.filter((line) => !shouldStripManagedGatewayEnvLine(line))
|
|
130
|
+
.join('\n')
|
|
131
|
+
.replace(/\s*$/, '\n');
|
|
132
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
133
|
+
fs.writeFileSync(target, sanitized);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function seedHermesGatewayHome({ sourceHome, targetHome, gateway }) {
|
|
138
|
+
fs.mkdirSync(targetHome, { recursive: true });
|
|
139
|
+
if (path.resolve(sourceHome) === path.resolve(targetHome)) {
|
|
140
|
+
appendGatewayLog(gateway, 'meta', `Using Hermes gateway profile at ${targetHome}\n`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const copied = [];
|
|
145
|
+
for (const file of ['config.yaml', 'SOUL.md']) {
|
|
146
|
+
if (copyHermesProfileFile(sourceHome, targetHome, file, { overwrite: false })) {
|
|
147
|
+
copied.push(file);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (copyHermesProfileEnv(sourceHome, targetHome)) {
|
|
151
|
+
copied.push('.env (without messaging platform credentials)');
|
|
152
|
+
}
|
|
153
|
+
for (const file of ['auth.json']) {
|
|
154
|
+
if (copyHermesProfileFile(sourceHome, targetHome, file, { overwrite: true })) {
|
|
155
|
+
copied.push(file);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
appendGatewayLog(
|
|
160
|
+
gateway,
|
|
161
|
+
'meta',
|
|
162
|
+
copied.length > 0
|
|
163
|
+
? `Seeded Pixcode Hermes gateway profile from ${sourceHome}: ${copied.join(', ')}\n`
|
|
164
|
+
: `Using Pixcode Hermes gateway profile at ${targetHome}\n`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
56
168
|
export function buildHermesGatewayEnv(baseEnv = process.env, options = {}) {
|
|
57
169
|
const host = options.host || DEFAULT_HOST;
|
|
58
170
|
const port = String(options.port || DEFAULT_PORT);
|
|
@@ -173,6 +285,29 @@ function extractRunOutput(body) {
|
|
|
173
285
|
return null;
|
|
174
286
|
}
|
|
175
287
|
|
|
288
|
+
function extractResponsesOutput(body) {
|
|
289
|
+
if (!body || typeof body !== 'object') return null;
|
|
290
|
+
|
|
291
|
+
const output = Array.isArray(body.output) ? body.output : [];
|
|
292
|
+
for (const item of output) {
|
|
293
|
+
if (!item || typeof item !== 'object') continue;
|
|
294
|
+
if (item.type === 'message' || item.role === 'assistant') {
|
|
295
|
+
const text = extractTextFromValue(item.content);
|
|
296
|
+
if (text) return text;
|
|
297
|
+
}
|
|
298
|
+
const text = extractTextFromValue(item.output_text)
|
|
299
|
+
|| extractTextFromValue(item.text)
|
|
300
|
+
|| extractTextFromValue(item.message)
|
|
301
|
+
|| extractTextFromValue(item.output);
|
|
302
|
+
if (text) return text;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return extractTextFromValue(body.output_text)
|
|
306
|
+
|| extractTextFromValue(body.message)
|
|
307
|
+
|| extractTextFromValue(body.response)
|
|
308
|
+
|| null;
|
|
309
|
+
}
|
|
310
|
+
|
|
176
311
|
function extractChatCompletionOutput(body) {
|
|
177
312
|
if (!body || typeof body !== 'object') return null;
|
|
178
313
|
const choices = Array.isArray(body.choices) ? body.choices : [];
|
|
@@ -244,6 +379,21 @@ function makeChatCompletionRequest(options) {
|
|
|
244
379
|
};
|
|
245
380
|
}
|
|
246
381
|
|
|
382
|
+
function makeResponsesRequest(options) {
|
|
383
|
+
const input = String(options.input || '').trim();
|
|
384
|
+
return {
|
|
385
|
+
model: options.model || 'hermes-agent',
|
|
386
|
+
input,
|
|
387
|
+
instructions: options.instructions || [
|
|
388
|
+
'You are Hermes Agent running inside Pixcode.',
|
|
389
|
+
'Use Pixcode MCP tools when they help inspect projects, launch CLIs, or perform workspace actions.',
|
|
390
|
+
'Keep answers concise and include concrete next steps when work is blocked.',
|
|
391
|
+
].join(' '),
|
|
392
|
+
conversation: options.sessionId || undefined,
|
|
393
|
+
store: true,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
247
397
|
async function waitForGatewayReady(gateway) {
|
|
248
398
|
const started = Date.now();
|
|
249
399
|
let lastError = null;
|
|
@@ -307,6 +457,7 @@ function snapshotGateway(gateway) {
|
|
|
307
457
|
running: false,
|
|
308
458
|
projectPath: null,
|
|
309
459
|
baseUrl: null,
|
|
460
|
+
hermesHome: null,
|
|
310
461
|
host: null,
|
|
311
462
|
port: null,
|
|
312
463
|
pid: null,
|
|
@@ -324,6 +475,7 @@ function snapshotGateway(gateway) {
|
|
|
324
475
|
running: isGatewayRunning(gateway),
|
|
325
476
|
projectPath: gateway.projectPath,
|
|
326
477
|
baseUrl: gateway.baseUrl,
|
|
478
|
+
hermesHome: gateway.hermesHome,
|
|
327
479
|
host: gateway.host,
|
|
328
480
|
port: gateway.port,
|
|
329
481
|
pid: gateway.child?.pid ?? null,
|
|
@@ -353,16 +505,24 @@ export async function ensureHermesGateway(options = {}) {
|
|
|
353
505
|
const projectPath = normalizeProjectPath(options.projectPath);
|
|
354
506
|
const existing = gateways.get(projectPath);
|
|
355
507
|
if (isGatewayRunning(existing)) {
|
|
508
|
+
if (options.probeExisting !== true) {
|
|
509
|
+
return {
|
|
510
|
+
...snapshotGateway(existing),
|
|
511
|
+
probe: existing.lastProbe,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
356
515
|
const probe = await probeHermesGateway(projectPath, { requireRunning: true }).catch((error) => ({
|
|
357
516
|
ok: false,
|
|
358
517
|
error: error instanceof Error ? error.message : String(error),
|
|
359
518
|
}));
|
|
360
|
-
if (probe.ok) {
|
|
519
|
+
if (probe.ok || options.replaceUnhealthy !== true) {
|
|
361
520
|
return {
|
|
362
521
|
...snapshotGateway(existing),
|
|
363
522
|
probe,
|
|
364
523
|
};
|
|
365
524
|
}
|
|
525
|
+
|
|
366
526
|
stopHermesGateway(projectPath);
|
|
367
527
|
}
|
|
368
528
|
|
|
@@ -370,14 +530,20 @@ export async function ensureHermesGateway(options = {}) {
|
|
|
370
530
|
const port = await findAvailablePort(Number(options.port || process.env.HERMES_API_SERVER_PORT || DEFAULT_PORT), host);
|
|
371
531
|
const apiServerKey = options.apiServerKey || makeApiServerKey();
|
|
372
532
|
const appRoot = options.appRoot || process.cwd();
|
|
533
|
+
const sourceHermesHome = options.sourceHermesHome || resolveSourceHermesHome(process.env);
|
|
534
|
+
const hermesHome = resolveHermesGatewayHome(process.env, options);
|
|
373
535
|
const env = buildHermesGatewayEnv(process.env, {
|
|
374
536
|
...options,
|
|
375
537
|
host,
|
|
376
538
|
port,
|
|
377
539
|
apiServerKey,
|
|
378
540
|
appRoot,
|
|
541
|
+
hermesHome,
|
|
542
|
+
});
|
|
543
|
+
const installStatus = readHermesInstallStatus(env, {
|
|
544
|
+
allowSmokeHermes: options.allowSmokeHermes === true,
|
|
545
|
+
repairLaunchers: options.repairLaunchers !== false,
|
|
379
546
|
});
|
|
380
|
-
const installStatus = readHermesInstallStatus(env);
|
|
381
547
|
if (!installStatus.installed || !installStatus.command) {
|
|
382
548
|
throw new Error(installStatus.error || 'Hermes Agent CLI is not installed.');
|
|
383
549
|
}
|
|
@@ -388,6 +554,7 @@ export async function ensureHermesGateway(options = {}) {
|
|
|
388
554
|
host,
|
|
389
555
|
port,
|
|
390
556
|
baseUrl: gatewayBaseUrl(host, port),
|
|
557
|
+
hermesHome,
|
|
391
558
|
apiServerKey,
|
|
392
559
|
command: installStatus.command,
|
|
393
560
|
child: null,
|
|
@@ -401,16 +568,18 @@ export async function ensureHermesGateway(options = {}) {
|
|
|
401
568
|
};
|
|
402
569
|
gateways.set(projectPath, gateway);
|
|
403
570
|
|
|
571
|
+
seedHermesGatewayHome({ sourceHome: sourceHermesHome, targetHome: hermesHome, gateway });
|
|
404
572
|
await configurePixcodeMcp({ appRoot, env, gateway });
|
|
405
573
|
|
|
406
|
-
const
|
|
574
|
+
const gatewayArgs = options.gatewayArgs || ['gateway', 'run', '--replace'];
|
|
575
|
+
const child = spawn(installStatus.command, gatewayArgs, {
|
|
407
576
|
cwd: projectPath,
|
|
408
577
|
env,
|
|
409
578
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
410
579
|
windowsHide: true,
|
|
411
580
|
});
|
|
412
581
|
gateway.child = child;
|
|
413
|
-
appendGatewayLog(gateway, 'meta', `$ ${installStatus.command}
|
|
582
|
+
appendGatewayLog(gateway, 'meta', `$ ${installStatus.command} ${gatewayArgs.join(' ')}\n`);
|
|
414
583
|
|
|
415
584
|
child.stdout?.on('data', (buf) => appendGatewayLog(gateway, 'stdout', buf.toString()));
|
|
416
585
|
child.stderr?.on('data', (buf) => appendGatewayLog(gateway, 'stderr', buf.toString()));
|
|
@@ -516,6 +685,46 @@ export async function runHermesGatewayPrompt(projectPath, options = {}) {
|
|
|
516
685
|
throw new Error('Hermes prompt is required.');
|
|
517
686
|
}
|
|
518
687
|
|
|
688
|
+
const responsesRequest = makeResponsesRequest({ ...options, input });
|
|
689
|
+
const responseRun = await callGateway(gateway, '/v1/responses', {
|
|
690
|
+
method: 'POST',
|
|
691
|
+
body: JSON.stringify(responsesRequest),
|
|
692
|
+
timeoutMs: options.responsesTimeoutMs || options.timeoutMs || RUN_TIMEOUT_MS,
|
|
693
|
+
}).catch((error) => {
|
|
694
|
+
if (!isGatewayRunning(gateway)) {
|
|
695
|
+
throw new Error(gatewayExitMessage(gateway));
|
|
696
|
+
}
|
|
697
|
+
throw error;
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
if (!isGatewayRunning(gateway)) {
|
|
701
|
+
throw new Error(gatewayExitMessage(gateway));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (responseRun.ok) {
|
|
705
|
+
const status = extractRunStatus(responseRun.body) || 'completed';
|
|
706
|
+
const message = extractResponsesOutput(responseRun.body);
|
|
707
|
+
return {
|
|
708
|
+
ok: status === 'completed' || status === 'succeeded',
|
|
709
|
+
projectPath: gateway.projectPath,
|
|
710
|
+
baseUrl: gateway.baseUrl,
|
|
711
|
+
sessionId: options.sessionId || responsesRequest.conversation || null,
|
|
712
|
+
runId: null,
|
|
713
|
+
responseId: responseRun.body?.id || null,
|
|
714
|
+
status,
|
|
715
|
+
message,
|
|
716
|
+
error: (status === 'completed' || status === 'succeeded') ? null : extractTextFromValue(responseRun.body?.error) || message || 'Hermes response failed.',
|
|
717
|
+
raw: responseRun.body,
|
|
718
|
+
transport: 'responses',
|
|
719
|
+
endpoint: '/v1/responses',
|
|
720
|
+
httpStatus: responseRun.status,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (responseRun.status && responseRun.status !== 404 && responseRun.status !== 405) {
|
|
725
|
+
throw new Error(`Hermes /v1/responses failed with HTTP ${responseRun.status}: ${JSON.stringify(responseRun.body)}`);
|
|
726
|
+
}
|
|
727
|
+
|
|
519
728
|
const chatRequest = makeChatCompletionRequest({ ...options, input });
|
|
520
729
|
const chat = await callGateway(gateway, '/v1/chat/completions', {
|
|
521
730
|
method: 'POST',
|
|
@@ -544,6 +753,8 @@ export async function runHermesGatewayPrompt(projectPath, options = {}) {
|
|
|
544
753
|
message,
|
|
545
754
|
raw: chat.body,
|
|
546
755
|
transport: 'chat.completions',
|
|
756
|
+
endpoint: '/v1/chat/completions',
|
|
757
|
+
httpStatus: chat.status,
|
|
547
758
|
};
|
|
548
759
|
}
|
|
549
760
|
|
|
@@ -584,6 +795,8 @@ export async function runHermesGatewayPrompt(projectPath, options = {}) {
|
|
|
584
795
|
message: extractRunOutput(create.body),
|
|
585
796
|
raw: create.body,
|
|
586
797
|
transport: 'runs',
|
|
798
|
+
endpoint: '/v1/runs',
|
|
799
|
+
httpStatus: create.status,
|
|
587
800
|
};
|
|
588
801
|
}
|
|
589
802
|
|
|
@@ -623,6 +836,8 @@ export async function runHermesGatewayPrompt(projectPath, options = {}) {
|
|
|
623
836
|
error: status === 'completed' ? null : extractTextFromValue(latest?.error) || message || 'Hermes run failed.',
|
|
624
837
|
raw: latest,
|
|
625
838
|
transport: 'runs',
|
|
839
|
+
endpoint: '/v1/runs',
|
|
840
|
+
httpStatus: create.status,
|
|
626
841
|
};
|
|
627
842
|
}
|
|
628
843
|
|
|
@@ -12,6 +12,7 @@ const POSIX_INSTALLER_URL = 'https://raw.githubusercontent.com/NousResearch/herm
|
|
|
12
12
|
const WINDOWS_INSTALLER_URL = 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1';
|
|
13
13
|
const FINISHED_TTL_MS = 10 * 60 * 1000;
|
|
14
14
|
const HARD_TIMEOUT_MS = 20 * 60 * 1000;
|
|
15
|
+
const HERMES_VERSION_TIMEOUT_MS = 20 * 1000;
|
|
15
16
|
const jobs = new Map();
|
|
16
17
|
|
|
17
18
|
function pathSeparator() {
|
|
@@ -125,13 +126,22 @@ function pushHermesCommandFiles(candidates, dir) {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
function hermesVersionTimeoutMs(env = process.env) {
|
|
130
|
+
const configured = Number(env.HERMES_VERSION_TIMEOUT_MS);
|
|
131
|
+
if (Number.isFinite(configured) && configured >= 1000) {
|
|
132
|
+
return configured;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return HERMES_VERSION_TIMEOUT_MS;
|
|
136
|
+
}
|
|
137
|
+
|
|
128
138
|
function runHermesVersion(candidate, env) {
|
|
129
139
|
try {
|
|
130
140
|
const result = spawn.sync(candidate, ['--version'], {
|
|
131
141
|
encoding: 'utf8',
|
|
132
142
|
env,
|
|
133
143
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
134
|
-
timeout:
|
|
144
|
+
timeout: hermesVersionTimeoutMs(env),
|
|
135
145
|
windowsHide: true,
|
|
136
146
|
});
|
|
137
147
|
if (result.error || result.status !== 0) {
|
|
@@ -144,8 +154,40 @@ function runHermesVersion(candidate, env) {
|
|
|
144
154
|
}
|
|
145
155
|
}
|
|
146
156
|
|
|
147
|
-
|
|
148
|
-
return
|
|
157
|
+
function isHermesSmokeCommandOutput(output) {
|
|
158
|
+
return /Hermes Agent v0\.0\.0\s+smoke/i.test(String(output || ''))
|
|
159
|
+
|| /pixcode-hermes-(?:chat-api|smoke)/i.test(String(output || ''));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isExplicitHermesCliPath(candidate, env = process.env) {
|
|
163
|
+
if (!candidate || !env.HERMES_CLI_PATH || !path.isAbsolute(candidate)) return false;
|
|
164
|
+
try {
|
|
165
|
+
return path.resolve(candidate) === path.resolve(env.HERMES_CLI_PATH);
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isTemporaryHermesLauncher(candidate) {
|
|
172
|
+
if (!candidate || !path.isAbsolute(candidate)) return false;
|
|
173
|
+
const normalized = path.resolve(candidate);
|
|
174
|
+
const tempRoot = path.resolve(os.tmpdir());
|
|
175
|
+
return normalized === tempRoot || normalized.startsWith(`${tempRoot}${path.sep}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function shouldRepairHermesLauncher(command, env = process.env, options = {}) {
|
|
179
|
+
if (options.repairLaunchers === false) return false;
|
|
180
|
+
if (!command || command === 'hermes') return false;
|
|
181
|
+
if (isExplicitHermesCliPath(command, env)) return false;
|
|
182
|
+
if (isTemporaryHermesLauncher(command)) return false;
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function isUsableHermesCommand(candidate, env = process.env, options = {}) {
|
|
187
|
+
const result = runHermesVersion(candidate, buildHermesEnv(env));
|
|
188
|
+
if (!result.ok) return false;
|
|
189
|
+
if (!options.allowSmokeHermes && isHermesSmokeCommandOutput(result.output)) return false;
|
|
190
|
+
return true;
|
|
149
191
|
}
|
|
150
192
|
|
|
151
193
|
function isHermesPythonLauncher(candidate) {
|
|
@@ -313,8 +355,9 @@ export function hermesCommandCandidates(env = process.env) {
|
|
|
313
355
|
return [...new Set(candidates.filter(Boolean))];
|
|
314
356
|
}
|
|
315
357
|
|
|
316
|
-
export function readHermesInstallStatus(env = process.env) {
|
|
358
|
+
export function readHermesInstallStatus(env = process.env, options = {}) {
|
|
317
359
|
const hermesEnv = buildHermesEnv(env);
|
|
360
|
+
const rejected = [];
|
|
318
361
|
|
|
319
362
|
for (const candidate of hermesCommandCandidates(hermesEnv)) {
|
|
320
363
|
const isBareCommand = candidate === 'hermes';
|
|
@@ -324,8 +367,16 @@ export function readHermesInstallStatus(env = process.env) {
|
|
|
324
367
|
|
|
325
368
|
const result = runHermesVersion(candidate, hermesEnv);
|
|
326
369
|
if (result.ok) {
|
|
327
|
-
repairHermesCommandLaunchers(candidate, hermesEnv);
|
|
328
370
|
const version = formatHermesVersionOutput(result.output);
|
|
371
|
+
if (!options.allowSmokeHermes && isHermesSmokeCommandOutput(result.output)) {
|
|
372
|
+
rejected.push(`${candidate} (${version || 'smoke-test Hermes launcher'})`);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (shouldRepairHermesLauncher(candidate, hermesEnv, options)) {
|
|
377
|
+
repairHermesCommandLaunchers(candidate, hermesEnv);
|
|
378
|
+
}
|
|
379
|
+
|
|
329
380
|
return {
|
|
330
381
|
installed: true,
|
|
331
382
|
command: candidate,
|
|
@@ -339,7 +390,9 @@ export function readHermesInstallStatus(env = process.env) {
|
|
|
339
390
|
installed: false,
|
|
340
391
|
command: null,
|
|
341
392
|
version: null,
|
|
342
|
-
error:
|
|
393
|
+
error: rejected.length > 0
|
|
394
|
+
? `Only smoke-test Hermes launchers were found and rejected: ${rejected.join(', ')}. Install or repair Hermes Agent.`
|
|
395
|
+
: 'Hermes Agent CLI is not installed or is not on PATH.',
|
|
343
396
|
};
|
|
344
397
|
}
|
|
345
398
|
|