@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.
Files changed (27) hide show
  1. package/dist/assets/index-DjKDBqln.css +32 -0
  2. package/dist/assets/{index-DzGkH0cd.js → index-Q-GU9EZQ.js} +127 -127
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/index.js +34 -1
  5. package/dist-server/server/index.js.map +1 -1
  6. package/dist-server/server/modules/orchestration/hermes/hermes.routes.js +11 -4
  7. package/dist-server/server/modules/orchestration/hermes/hermes.routes.js.map +1 -1
  8. package/dist-server/server/services/hermes-gateway.js +202 -4
  9. package/dist-server/server/services/hermes-gateway.js.map +1 -1
  10. package/dist-server/server/services/hermes-install-jobs.js +60 -6
  11. package/dist-server/server/services/hermes-install-jobs.js.map +1 -1
  12. package/package.json +1 -1
  13. package/scripts/hermes/configure-pixcode-mcp.mjs +36 -1
  14. package/scripts/hermes/pixcode-mcp-server.mjs +63 -5
  15. package/scripts/smoke/hermes-api-install.mjs +2 -0
  16. package/scripts/smoke/hermes-gateway-persistence.mjs +104 -0
  17. package/scripts/smoke/hermes-mcp-pixcode-roundtrip.mjs +27 -0
  18. package/scripts/smoke/hermes-rest-chat-api.mjs +24 -2
  19. package/scripts/smoke/hermes-rest-chat-live.mjs +4 -0
  20. package/scripts/smoke/hermes-rest-gateway.mjs +12 -1
  21. package/scripts/smoke/hermes-smoke-launcher-guard.mjs +34 -0
  22. package/scripts/smoke/pixcode-workbench-1-48.mjs +14 -3
  23. package/server/index.js +40 -1
  24. package/server/modules/orchestration/hermes/hermes.routes.ts +12 -4
  25. package/server/services/hermes-gateway.js +219 -4
  26. package/server/services/hermes-install-jobs.js +59 -6
  27. 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.match(workbench, /HermesApiChatPanel/, 'Hermes Agent should render a REST-backed chat panel in the bottom area.');
69
- assert.match(workbench, /\/api\/orchestration\/hermes\/gateway\/chat/, 'Hermes chat panel should send prompts through the Pixcode gateway chat API.');
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 the bottom panel.');
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 = initialCommand || null;
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: options.resolvePublicBaseUrl?.(req) ?? `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`,
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: options.resolvePublicBaseUrl?.(req) ?? `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`,
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: options.resolvePublicBaseUrl?.(req) ?? `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`,
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: options.resolvePublicBaseUrl?.(req) ?? `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`,
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 child = spawn(installStatus.command, ['gateway'], {
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} gateway\n`);
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: 5000,
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
- export function isUsableHermesCommand(candidate, env = process.env) {
148
- return runHermesVersion(candidate, buildHermesEnv(env)).ok;
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: 'Hermes Agent CLI is not installed or is not on PATH.',
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