@pixelbyte-software/pixcode 1.49.8 → 1.49.10

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.
@@ -0,0 +1,131 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import {
7
+ ensureHermesGateway,
8
+ runHermesGatewayPrompt,
9
+ stopHermesGateway,
10
+ } from '../../server/services/hermes-gateway.js';
11
+
12
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
13
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pixcode-hermes-chat-api-'));
14
+ const fakeHermes = path.join(tempRoot, 'hermes');
15
+ const projectPath = path.join(tempRoot, 'project');
16
+ const hermesHome = path.join(tempRoot, 'home');
17
+
18
+ await fs.mkdir(projectPath, { recursive: true });
19
+ await fs.writeFile(fakeHermes, `#!/usr/bin/env node
20
+ import http from 'node:http';
21
+
22
+ if (process.argv.includes('--version')) {
23
+ console.log('Hermes Agent v0.0.0 smoke');
24
+ process.exit(0);
25
+ }
26
+
27
+ if (!process.argv.includes('gateway')) {
28
+ console.error('expected gateway');
29
+ process.exit(2);
30
+ }
31
+
32
+ const host = process.env.API_SERVER_HOST || '127.0.0.1';
33
+ const port = Number(process.env.API_SERVER_PORT || 8642);
34
+ const key = process.env.API_SERVER_KEY || '';
35
+ const server = http.createServer(async (req, res) => {
36
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
37
+ res.setHeader('content-type', 'application/json');
38
+ if (url.pathname !== '/health' && req.headers.authorization !== \`Bearer \${key}\`) {
39
+ res.statusCode = 401;
40
+ res.end(JSON.stringify({ error: 'bad auth' }));
41
+ return;
42
+ }
43
+ if (req.method === 'GET' && url.pathname === '/health') {
44
+ res.end(JSON.stringify({ ok: true }));
45
+ return;
46
+ }
47
+ if (req.method === 'GET' && url.pathname === '/v1/capabilities') {
48
+ res.end(JSON.stringify({ capabilities: ['chat'] }));
49
+ return;
50
+ }
51
+ if (req.method === 'GET' && url.pathname === '/v1/models') {
52
+ res.end(JSON.stringify({ data: [{ id: 'hermes-agent' }] }));
53
+ return;
54
+ }
55
+ if (req.method === 'POST' && url.pathname === '/v1/chat/completions') {
56
+ let body = '';
57
+ for await (const chunk of req) body += chunk.toString();
58
+ const parsed = body ? JSON.parse(body) : {};
59
+ res.end(JSON.stringify({
60
+ id: 'chatcmpl-smoke',
61
+ choices: [{
62
+ index: 0,
63
+ message: {
64
+ role: 'assistant',
65
+ content: \`pixcode-hermes-chat-ok via \${parsed.model}\`,
66
+ },
67
+ finish_reason: 'stop',
68
+ }],
69
+ }));
70
+ return;
71
+ }
72
+ if (req.method === 'POST' && url.pathname === '/v1/responses') {
73
+ let body = '';
74
+ for await (const chunk of req) body += chunk.toString();
75
+ const parsed = body ? JSON.parse(body) : {};
76
+ res.end(JSON.stringify({
77
+ id: 'resp-smoke',
78
+ object: 'response',
79
+ status: 'completed',
80
+ model: parsed.model || 'hermes-agent',
81
+ output: [{
82
+ type: 'message',
83
+ role: 'assistant',
84
+ content: [{
85
+ type: 'output_text',
86
+ text: \`pixcode-hermes-rest-ok via \${parsed.model}\`,
87
+ }],
88
+ }],
89
+ }));
90
+ return;
91
+ }
92
+ res.statusCode = 404;
93
+ res.end(JSON.stringify({ error: url.pathname }));
94
+ });
95
+ server.listen(port, host);
96
+ `, { mode: 0o755 });
97
+
98
+ process.env.HERMES_CLI_PATH = fakeHermes;
99
+
100
+ try {
101
+ const gateway = await ensureHermesGateway({
102
+ appRoot: repoRoot,
103
+ projectPath,
104
+ hermesHome,
105
+ pixcodeBaseUrl: 'http://127.0.0.1:9',
106
+ pixcodeApiKey: 'px_chat_api_smoke_key',
107
+ port: 18752,
108
+ allowSmokeHermes: true,
109
+ repairLaunchers: false,
110
+ });
111
+ if (!gateway.running || !gateway.probe?.ok) {
112
+ throw new Error(`Fake Hermes gateway did not start cleanly: ${JSON.stringify(gateway)}`);
113
+ }
114
+
115
+ const run = await runHermesGatewayPrompt(projectPath, {
116
+ input: 'selam',
117
+ timeoutMs: 10000,
118
+ });
119
+ if (!run.ok || run.transport !== 'responses' || !String(run.message || '').includes('pixcode-hermes-rest-ok')) {
120
+ throw new Error(`Hermes REST chat did not use responses: ${JSON.stringify(run)}`);
121
+ }
122
+
123
+ console.log(JSON.stringify({
124
+ ok: true,
125
+ transport: run.transport,
126
+ message: run.message,
127
+ }, null, 2));
128
+ } finally {
129
+ stopHermesGateway(projectPath);
130
+ delete process.env.HERMES_CLI_PATH;
131
+ }
@@ -0,0 +1,41 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import {
5
+ ensureHermesGateway,
6
+ runHermesGatewayPrompt,
7
+ stopHermesGateway,
8
+ } from '../../server/services/hermes-gateway.js';
9
+
10
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
11
+ const projectPath = path.resolve(process.argv[2] || repoRoot);
12
+
13
+ try {
14
+ const gateway = await ensureHermesGateway({
15
+ appRoot: repoRoot,
16
+ projectPath,
17
+ pixcodeBaseUrl: 'http://127.0.0.1:9',
18
+ pixcodeApiKey: 'px_live_chat_smoke_key',
19
+ port: Number(process.env.PIXCODE_HERMES_LIVE_CHAT_PORT || 18652),
20
+ });
21
+ if (!gateway.running || !gateway.probe?.ok) {
22
+ throw new Error(`Hermes gateway did not start cleanly: ${JSON.stringify(gateway)}`);
23
+ }
24
+
25
+ const run = await runHermesGatewayPrompt(projectPath, {
26
+ input: 'Reply with exactly: pixcode-hermes-chat-ok',
27
+ timeoutMs: 90000,
28
+ });
29
+ if (!run.ok || !String(run.message || '').includes('pixcode-hermes-chat-ok')) {
30
+ throw new Error(`Hermes chat did not return the expected response: ${JSON.stringify(run)}`);
31
+ }
32
+
33
+ console.log(JSON.stringify({
34
+ ok: true,
35
+ transport: run.transport,
36
+ status: run.status,
37
+ message: run.message,
38
+ }, null, 2));
39
+ } finally {
40
+ stopHermesGateway(projectPath);
41
+ }
@@ -13,10 +13,15 @@ assert.match(service, /export async function ensureHermesGateway/, 'Pixcode shou
13
13
  assert.match(service, /export async function probeHermesGateway/, 'Pixcode should probe Hermes through its REST API.');
14
14
  assert.match(service, /export async function runHermesGatewayPrompt/, 'Pixcode should submit Hermes prompts through the managed REST gateway.');
15
15
  assert.match(service, /export function stopHermesGateway/, 'Pixcode should be able to stop a managed Hermes gateway process.');
16
+ assert.match(service, /\/v1\/chat\/completions/, 'Hermes UI chat should use the documented OpenAI-compatible chat completions endpoint first.');
17
+ assert.match(service, /\/v1\/responses/, 'Hermes UI chat should use the stateful OpenAI-compatible responses endpoint before legacy chat fallback.');
18
+ assert.match(service, /transport:\s*'responses'/, 'Hermes REST responses should report their transport for terminal proof output.');
19
+ assert.match(service, /gatewayArgs[\s\S]+\['gateway', 'run', '--replace'\]/, 'Pixcode should start Hermes gateway in replace mode so an existing gateway does not crash REST chat.');
20
+ assert.match(service, /gatewayExitMessage/, 'Hermes gateway failures should include recent stderr/stdout instead of only exit code 1.');
16
21
  assert.match(service, /API_SERVER_ENABLED:\s*'true'/, 'Hermes gateway env should enable the API server.');
17
22
  assert.match(service, /API_SERVER_KEY/, 'Hermes gateway env should set a bearer key.');
18
23
  assert.match(service, /API_SERVER_PORT/, 'Hermes gateway env should choose a REST port.');
19
- assert.match(service, /spawn\(installStatus\.command,\s*\['gateway'\]/, 'Pixcode should start Hermes with `hermes gateway` for REST control.');
24
+ assert.match(service, /spawn\(installStatus\.command,\s*gatewayArgs/, 'Pixcode should start Hermes with explicit gateway args for REST control.');
20
25
  assert.match(service, /\/health/, 'Gateway probe should call Hermes health.');
21
26
  assert.match(service, /\/v1\/capabilities/, 'Gateway probe should verify Hermes capabilities.');
22
27
  assert.match(service, /\/v1\/models/, 'Gateway probe should verify OpenAI-compatible model discovery.');
@@ -0,0 +1,34 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import {
7
+ readHermesInstallStatus,
8
+ } from '../../server/services/hermes-install-jobs.js';
9
+
10
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pixcode-hermes-smoke-guard-'));
11
+ const fakeHermes = path.join(tempRoot, 'hermes');
12
+
13
+ await fs.writeFile(fakeHermes, `#!/usr/bin/env bash
14
+ if [ "$1" = "--version" ]; then
15
+ echo "Hermes Agent v0.0.0 smoke"
16
+ exit 0
17
+ fi
18
+ echo "fake smoke hermes should not run"
19
+ exit 2
20
+ `, { mode: 0o755 });
21
+
22
+ const status = readHermesInstallStatus({
23
+ ...process.env,
24
+ HERMES_CLI_PATH: fakeHermes,
25
+ PATH: tempRoot,
26
+ }, {
27
+ repairLaunchers: false,
28
+ });
29
+
30
+ assert.notEqual(status.command, fakeHermes, 'Smoke-test HERMES_CLI_PATH must not be selected as the Hermes command.');
31
+ assert.doesNotMatch(String(status.version || ''), /smoke/i, 'Smoke-test Hermes version output must not be reported as installed.');
32
+ assert.doesNotMatch(String(status.error || ''), /fake smoke hermes should not run/i, 'Smoke launcher should be rejected before any non-version use.');
33
+
34
+ console.log('hermes smoke launcher guard passed');
@@ -67,6 +67,9 @@ assert.match(workbench, /isPlainShell/, 'Bottom terminal should open the selecte
67
67
  assert.doesNotMatch(workbench, /HERMES_AGENT_START_COMMAND/, 'Hermes Agent should not launch from the bottom terminal through a server-side sentinel.');
68
68
  assert.match(workbench, /HermesApiChatPanel/, 'Hermes Agent should render a REST-backed chat panel in the bottom area.');
69
69
  assert.match(workbench, /\/api\/orchestration\/hermes\/gateway\/chat/, 'Hermes chat panel should send prompts through the Pixcode gateway chat API.');
70
+ assert.match(workbench, /HermesTerminalTranscript/, 'Hermes REST panel should render as a terminal transcript, not chat bubbles.');
71
+ assert.match(workbench, /REST POST \//, 'Hermes terminal transcript should show the REST endpoint used for each reply.');
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.');
@@ -85,6 +88,7 @@ assert.doesNotMatch(workbench, /suspendAutoConnect/, 'Right CLI provider starts
85
88
  assert.match(serverIndex, /\/api\/shell\/sessions\/terminate/, 'Backend should expose an authenticated endpoint to terminate cached provider PTYs immediately.');
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.');
91
+ assert.doesNotMatch(serverIndex, /hermesCommand/, 'Provider shell starts should not reference the removed Hermes sentinel variable.');
88
92
  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.');
89
93
  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.');
90
94
  assert.match(hermesInstallJobs, /downloadHermesInstaller/, 'Windows Hermes install should download the installer through backend API code before running it.');
package/server/index.js CHANGED
@@ -2266,7 +2266,7 @@ function handleShellConnection(ws, request) {
2266
2266
  console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
2267
2267
  console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
2268
2268
  if (initialCommand) {
2269
- console.log('⚡ Initial command:', hermesCommand ? hermesCommand : initialCommand);
2269
+ console.log('⚡ Initial command:', initialCommand || 'interactive shell');
2270
2270
  }
2271
2271
 
2272
2272
  // First send a welcome message
@@ -2413,7 +2413,7 @@ function handleShellConnection(ws, request) {
2413
2413
  }
2414
2414
  }
2415
2415
 
2416
- console.log('🔧 Executing shell command:', hermesCommand ? hermesCommand : (shellCommand || 'interactive shell'));
2416
+ console.log('🔧 Executing shell command:', shellCommand || 'interactive shell');
2417
2417
 
2418
2418
  // Use appropriate shell based on platform
2419
2419
  const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
@@ -173,6 +173,64 @@ function extractRunOutput(body) {
173
173
  return null;
174
174
  }
175
175
 
176
+ function extractResponsesOutput(body) {
177
+ if (!body || typeof body !== 'object') return null;
178
+
179
+ const output = Array.isArray(body.output) ? body.output : [];
180
+ for (const item of output) {
181
+ if (!item || typeof item !== 'object') continue;
182
+ if (item.type === 'message' || item.role === 'assistant') {
183
+ const text = extractTextFromValue(item.content);
184
+ if (text) return text;
185
+ }
186
+ const text = extractTextFromValue(item.output_text)
187
+ || extractTextFromValue(item.text)
188
+ || extractTextFromValue(item.message)
189
+ || extractTextFromValue(item.output);
190
+ if (text) return text;
191
+ }
192
+
193
+ return extractTextFromValue(body.output_text)
194
+ || extractTextFromValue(body.message)
195
+ || extractTextFromValue(body.response)
196
+ || null;
197
+ }
198
+
199
+ function extractChatCompletionOutput(body) {
200
+ if (!body || typeof body !== 'object') return null;
201
+ const choices = Array.isArray(body.choices) ? body.choices : [];
202
+ for (const choice of choices) {
203
+ const text = extractTextFromValue(choice?.message?.content)
204
+ || extractTextFromValue(choice?.delta?.content)
205
+ || extractTextFromValue(choice?.text);
206
+ if (text) return text;
207
+ }
208
+ return extractTextFromValue(body.output_text)
209
+ || extractTextFromValue(body.output)
210
+ || extractTextFromValue(body.message)
211
+ || extractTextFromValue(body.response)
212
+ || null;
213
+ }
214
+
215
+ function recentGatewayLogText(gateway) {
216
+ if (!gateway?.logs?.length) return '';
217
+ return gateway.logs
218
+ .slice(-16)
219
+ .map((entry) => String(entry.chunk || '').trim())
220
+ .filter(Boolean)
221
+ .join('\n')
222
+ .trim();
223
+ }
224
+
225
+ function gatewayExitMessage(gateway, fallback = 'Hermes gateway is not running.') {
226
+ if (!gateway) return fallback;
227
+ const exit = gateway.exitSignal
228
+ ? `Hermes gateway exited with signal ${gateway.exitSignal}.`
229
+ : `Hermes gateway exited with code ${gateway.exitCode ?? 'unknown'}.`;
230
+ const logs = recentGatewayLogText(gateway);
231
+ return logs ? `${exit}\n${logs}` : (gateway.error || exit);
232
+ }
233
+
176
234
  function makeRunRequest(options) {
177
235
  const input = String(options.input || '').trim();
178
236
  return {
@@ -186,13 +244,51 @@ function makeRunRequest(options) {
186
244
  };
187
245
  }
188
246
 
247
+ function makeChatCompletionRequest(options) {
248
+ const input = String(options.input || '').trim();
249
+ const messages = Array.isArray(options.messages) ? options.messages : [
250
+ {
251
+ role: 'system',
252
+ content: options.instructions || [
253
+ 'You are Hermes Agent running inside Pixcode.',
254
+ 'Use Pixcode MCP tools when they help inspect projects, launch CLIs, or perform workspace actions.',
255
+ 'Keep answers concise and include concrete next steps when work is blocked.',
256
+ ].join(' '),
257
+ },
258
+ {
259
+ role: 'user',
260
+ content: input,
261
+ },
262
+ ];
263
+ return {
264
+ model: options.model || 'hermes-agent',
265
+ messages,
266
+ stream: false,
267
+ };
268
+ }
269
+
270
+ function makeResponsesRequest(options) {
271
+ const input = String(options.input || '').trim();
272
+ return {
273
+ model: options.model || 'hermes-agent',
274
+ input,
275
+ instructions: options.instructions || [
276
+ 'You are Hermes Agent running inside Pixcode.',
277
+ 'Use Pixcode MCP tools when they help inspect projects, launch CLIs, or perform workspace actions.',
278
+ 'Keep answers concise and include concrete next steps when work is blocked.',
279
+ ].join(' '),
280
+ conversation: options.sessionId || undefined,
281
+ store: true,
282
+ };
283
+ }
284
+
189
285
  async function waitForGatewayReady(gateway) {
190
286
  const started = Date.now();
191
287
  let lastError = null;
192
288
 
193
289
  while (Date.now() - started < STARTUP_TIMEOUT_MS) {
194
290
  if (!isGatewayRunning(gateway)) {
195
- throw new Error(gateway.error || `Hermes gateway exited with code ${gateway.exitCode ?? 'unknown'}.`);
291
+ throw new Error(gatewayExitMessage(gateway));
196
292
  }
197
293
 
198
294
  try {
@@ -319,7 +415,10 @@ export async function ensureHermesGateway(options = {}) {
319
415
  apiServerKey,
320
416
  appRoot,
321
417
  });
322
- const installStatus = readHermesInstallStatus(env);
418
+ const installStatus = readHermesInstallStatus(env, {
419
+ allowSmokeHermes: options.allowSmokeHermes === true,
420
+ repairLaunchers: options.repairLaunchers !== false,
421
+ });
323
422
  if (!installStatus.installed || !installStatus.command) {
324
423
  throw new Error(installStatus.error || 'Hermes Agent CLI is not installed.');
325
424
  }
@@ -345,14 +444,15 @@ export async function ensureHermesGateway(options = {}) {
345
444
 
346
445
  await configurePixcodeMcp({ appRoot, env, gateway });
347
446
 
348
- const child = spawn(installStatus.command, ['gateway'], {
447
+ const gatewayArgs = options.gatewayArgs || ['gateway', 'run', '--replace'];
448
+ const child = spawn(installStatus.command, gatewayArgs, {
349
449
  cwd: projectPath,
350
450
  env,
351
451
  stdio: ['ignore', 'pipe', 'pipe'],
352
452
  windowsHide: true,
353
453
  });
354
454
  gateway.child = child;
355
- appendGatewayLog(gateway, 'meta', `$ ${installStatus.command} gateway\n`);
455
+ appendGatewayLog(gateway, 'meta', `$ ${installStatus.command} ${gatewayArgs.join(' ')}\n`);
356
456
 
357
457
  child.stdout?.on('data', (buf) => appendGatewayLog(gateway, 'stdout', buf.toString()));
358
458
  child.stderr?.on('data', (buf) => appendGatewayLog(gateway, 'stderr', buf.toString()));
@@ -458,13 +558,99 @@ export async function runHermesGatewayPrompt(projectPath, options = {}) {
458
558
  throw new Error('Hermes prompt is required.');
459
559
  }
460
560
 
561
+ const responsesRequest = makeResponsesRequest({ ...options, input });
562
+ const responseRun = await callGateway(gateway, '/v1/responses', {
563
+ method: 'POST',
564
+ body: JSON.stringify(responsesRequest),
565
+ timeoutMs: options.responsesTimeoutMs || options.timeoutMs || RUN_TIMEOUT_MS,
566
+ }).catch((error) => {
567
+ if (!isGatewayRunning(gateway)) {
568
+ throw new Error(gatewayExitMessage(gateway));
569
+ }
570
+ throw error;
571
+ });
572
+
573
+ if (!isGatewayRunning(gateway)) {
574
+ throw new Error(gatewayExitMessage(gateway));
575
+ }
576
+
577
+ if (responseRun.ok) {
578
+ const status = extractRunStatus(responseRun.body) || 'completed';
579
+ const message = extractResponsesOutput(responseRun.body);
580
+ return {
581
+ ok: status === 'completed' || status === 'succeeded',
582
+ projectPath: gateway.projectPath,
583
+ baseUrl: gateway.baseUrl,
584
+ sessionId: options.sessionId || responsesRequest.conversation || null,
585
+ runId: null,
586
+ responseId: responseRun.body?.id || null,
587
+ status,
588
+ message,
589
+ error: (status === 'completed' || status === 'succeeded') ? null : extractTextFromValue(responseRun.body?.error) || message || 'Hermes response failed.',
590
+ raw: responseRun.body,
591
+ transport: 'responses',
592
+ endpoint: '/v1/responses',
593
+ httpStatus: responseRun.status,
594
+ };
595
+ }
596
+
597
+ if (responseRun.status && responseRun.status !== 404 && responseRun.status !== 405) {
598
+ throw new Error(`Hermes /v1/responses failed with HTTP ${responseRun.status}: ${JSON.stringify(responseRun.body)}`);
599
+ }
600
+
601
+ const chatRequest = makeChatCompletionRequest({ ...options, input });
602
+ const chat = await callGateway(gateway, '/v1/chat/completions', {
603
+ method: 'POST',
604
+ body: JSON.stringify(chatRequest),
605
+ timeoutMs: options.chatTimeoutMs || options.timeoutMs || RUN_TIMEOUT_MS,
606
+ }).catch((error) => {
607
+ if (!isGatewayRunning(gateway)) {
608
+ throw new Error(gatewayExitMessage(gateway));
609
+ }
610
+ throw error;
611
+ });
612
+
613
+ if (!isGatewayRunning(gateway)) {
614
+ throw new Error(gatewayExitMessage(gateway));
615
+ }
616
+
617
+ if (chat.ok) {
618
+ const message = extractChatCompletionOutput(chat.body);
619
+ return {
620
+ ok: true,
621
+ projectPath: gateway.projectPath,
622
+ baseUrl: gateway.baseUrl,
623
+ sessionId: options.sessionId || null,
624
+ runId: null,
625
+ status: 'completed',
626
+ message,
627
+ raw: chat.body,
628
+ transport: 'chat.completions',
629
+ endpoint: '/v1/chat/completions',
630
+ httpStatus: chat.status,
631
+ };
632
+ }
633
+
634
+ if (chat.status && chat.status !== 404 && chat.status !== 405) {
635
+ throw new Error(`Hermes /v1/chat/completions failed with HTTP ${chat.status}: ${JSON.stringify(chat.body)}`);
636
+ }
637
+
461
638
  const request = makeRunRequest({ ...options, input });
462
639
  const create = await callGateway(gateway, '/v1/runs', {
463
640
  method: 'POST',
464
641
  body: JSON.stringify(request),
465
642
  timeoutMs: options.createTimeoutMs || 15000,
643
+ }).catch((error) => {
644
+ if (!isGatewayRunning(gateway)) {
645
+ throw new Error(gatewayExitMessage(gateway));
646
+ }
647
+ throw error;
466
648
  });
467
649
 
650
+ if (!isGatewayRunning(gateway)) {
651
+ throw new Error(gatewayExitMessage(gateway));
652
+ }
653
+
468
654
  if (!create.ok) {
469
655
  throw new Error(`Hermes /v1/runs failed with HTTP ${create.status}: ${JSON.stringify(create.body)}`);
470
656
  }
@@ -481,6 +667,9 @@ export async function runHermesGatewayPrompt(projectPath, options = {}) {
481
667
  status: initialStatus || 'completed',
482
668
  message: extractRunOutput(create.body),
483
669
  raw: create.body,
670
+ transport: 'runs',
671
+ endpoint: '/v1/runs',
672
+ httpStatus: create.status,
484
673
  };
485
674
  }
486
675
 
@@ -497,6 +686,9 @@ export async function runHermesGatewayPrompt(projectPath, options = {}) {
497
686
  if (!poll.ok) {
498
687
  throw new Error(`Hermes /v1/runs/${runId} failed with HTTP ${poll.status}: ${JSON.stringify(poll.body)}`);
499
688
  }
689
+ if (!isGatewayRunning(gateway)) {
690
+ throw new Error(gatewayExitMessage(gateway));
691
+ }
500
692
  latest = poll.body;
501
693
  status = extractRunStatus(latest) || status;
502
694
  }
@@ -516,6 +708,9 @@ export async function runHermesGatewayPrompt(projectPath, options = {}) {
516
708
  message,
517
709
  error: status === 'completed' ? null : extractTextFromValue(latest?.error) || message || 'Hermes run failed.',
518
710
  raw: latest,
711
+ transport: 'runs',
712
+ endpoint: '/v1/runs',
713
+ httpStatus: create.status,
519
714
  };
520
715
  }
521
716
 
@@ -144,8 +144,40 @@ function runHermesVersion(candidate, env) {
144
144
  }
145
145
  }
146
146
 
147
- export function isUsableHermesCommand(candidate, env = process.env) {
148
- return runHermesVersion(candidate, buildHermesEnv(env)).ok;
147
+ function isHermesSmokeCommandOutput(output) {
148
+ return /Hermes Agent v0\.0\.0\s+smoke/i.test(String(output || ''))
149
+ || /pixcode-hermes-(?:chat-api|smoke)/i.test(String(output || ''));
150
+ }
151
+
152
+ function isExplicitHermesCliPath(candidate, env = process.env) {
153
+ if (!candidate || !env.HERMES_CLI_PATH || !path.isAbsolute(candidate)) return false;
154
+ try {
155
+ return path.resolve(candidate) === path.resolve(env.HERMES_CLI_PATH);
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
161
+ function isTemporaryHermesLauncher(candidate) {
162
+ if (!candidate || !path.isAbsolute(candidate)) return false;
163
+ const normalized = path.resolve(candidate);
164
+ const tempRoot = path.resolve(os.tmpdir());
165
+ return normalized === tempRoot || normalized.startsWith(`${tempRoot}${path.sep}`);
166
+ }
167
+
168
+ function shouldRepairHermesLauncher(command, env = process.env, options = {}) {
169
+ if (options.repairLaunchers === false) return false;
170
+ if (!command || command === 'hermes') return false;
171
+ if (isExplicitHermesCliPath(command, env)) return false;
172
+ if (isTemporaryHermesLauncher(command)) return false;
173
+ return true;
174
+ }
175
+
176
+ export function isUsableHermesCommand(candidate, env = process.env, options = {}) {
177
+ const result = runHermesVersion(candidate, buildHermesEnv(env));
178
+ if (!result.ok) return false;
179
+ if (!options.allowSmokeHermes && isHermesSmokeCommandOutput(result.output)) return false;
180
+ return true;
149
181
  }
150
182
 
151
183
  function isHermesPythonLauncher(candidate) {
@@ -313,8 +345,9 @@ export function hermesCommandCandidates(env = process.env) {
313
345
  return [...new Set(candidates.filter(Boolean))];
314
346
  }
315
347
 
316
- export function readHermesInstallStatus(env = process.env) {
348
+ export function readHermesInstallStatus(env = process.env, options = {}) {
317
349
  const hermesEnv = buildHermesEnv(env);
350
+ const rejected = [];
318
351
 
319
352
  for (const candidate of hermesCommandCandidates(hermesEnv)) {
320
353
  const isBareCommand = candidate === 'hermes';
@@ -324,8 +357,16 @@ export function readHermesInstallStatus(env = process.env) {
324
357
 
325
358
  const result = runHermesVersion(candidate, hermesEnv);
326
359
  if (result.ok) {
327
- repairHermesCommandLaunchers(candidate, hermesEnv);
328
360
  const version = formatHermesVersionOutput(result.output);
361
+ if (!options.allowSmokeHermes && isHermesSmokeCommandOutput(result.output)) {
362
+ rejected.push(`${candidate} (${version || 'smoke-test Hermes launcher'})`);
363
+ continue;
364
+ }
365
+
366
+ if (shouldRepairHermesLauncher(candidate, hermesEnv, options)) {
367
+ repairHermesCommandLaunchers(candidate, hermesEnv);
368
+ }
369
+
329
370
  return {
330
371
  installed: true,
331
372
  command: candidate,
@@ -339,7 +380,9 @@ export function readHermesInstallStatus(env = process.env) {
339
380
  installed: false,
340
381
  command: null,
341
382
  version: null,
342
- error: 'Hermes Agent CLI is not installed or is not on PATH.',
383
+ error: rejected.length > 0
384
+ ? `Only smoke-test Hermes launchers were found and rejected: ${rejected.join(', ')}. Install or repair Hermes Agent.`
385
+ : 'Hermes Agent CLI is not installed or is not on PATH.',
343
386
  };
344
387
  }
345
388