@pixelbyte-software/pixcode 1.49.6 → 1.49.8

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/server/index.js CHANGED
@@ -32,10 +32,6 @@ const SERVER_VERSION = (() => {
32
32
  return '0.0.0';
33
33
  }
34
34
  })();
35
- const HERMES_SHELL_COMMANDS = new Set([
36
- 'pixcode:hermes:start',
37
- 'pixcode:hermes:install',
38
- ]);
39
35
  const DAEMON_COMMAND_CONTEXT = {
40
36
  appRoot: APP_ROOT,
41
37
  cliEntry: path.join(APP_ROOT, 'server', 'cli.js'),
@@ -328,14 +324,6 @@ function killProviderPtySessions(projectPath, provider) {
328
324
  return killed;
329
325
  }
330
326
 
331
- function shellQuotePosix(value) {
332
- return `'${String(value).replace(/'/g, `'\\''`)}'`;
333
- }
334
-
335
- function shellQuotePowerShell(value) {
336
- return `'${String(value).replace(/'/g, "''")}'`;
337
- }
338
-
339
327
  function normalizeShellPermissionMode(value) {
340
328
  return typeof value === 'string' ? value.trim() : '';
341
329
  }
@@ -421,104 +409,6 @@ function getOrCreateHermesApiKey(userId) {
421
409
  ]).apiKey;
422
410
  }
423
411
 
424
- function buildHermesShellCommand(kind, env) {
425
- const configureScript = path.join(APP_ROOT, 'scripts', 'hermes', 'configure-pixcode-mcp.mjs');
426
- const isWindows = os.platform() === 'win32';
427
- const quote = isWindows ? shellQuotePowerShell : shellQuotePosix;
428
-
429
- if (isWindows) {
430
- const setEnv = [
431
- `$env:PIXCODE_BASE_URL=${quote(env.PIXCODE_BASE_URL)}`,
432
- `$env:PIXCODE_API_KEY=${quote(env.PIXCODE_API_KEY)}`,
433
- `$env:PIXCODE_APP_ROOT=${quote(env.PIXCODE_APP_ROOT)}`,
434
- ].join('; ');
435
- const resolveHermesCommand = [
436
- 'function Test-HermesCommand($candidate) {',
437
- 'if (-not $candidate) { return $false; }',
438
- 'try {',
439
- '& $candidate --version *> $null;',
440
- 'return $LASTEXITCODE -eq 0;',
441
- '} catch { return $false; }',
442
- '}',
443
- 'function Resolve-HermesCommand {',
444
- '$candidates = @(',
445
- '$env:HERMES_CLI_PATH,',
446
- '(Join-Path $env:LOCALAPPDATA "hermes\\bin\\hermes.cmd"),',
447
- '(Join-Path $env:LOCALAPPDATA "hermes\\bin\\hermes.bat"),',
448
- '(Join-Path $env:LOCALAPPDATA "hermes\\bin\\hermes.exe"),',
449
- '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\venv\\Scripts\\hermes.cmd"),',
450
- '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\venv\\Scripts\\hermes.bat"),',
451
- '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\venv\\Scripts\\hermes.exe"),',
452
- '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\.venv\\Scripts\\hermes.cmd"),',
453
- '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\.venv\\Scripts\\hermes.bat"),',
454
- '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\.venv\\Scripts\\hermes.exe"),',
455
- '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\hermes.cmd"),',
456
- '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\hermes.bat"),',
457
- '(Join-Path $env:LOCALAPPDATA "hermes\\hermes-agent\\hermes.exe")',
458
- ');',
459
- 'foreach ($candidate in $candidates) { if ($candidate -and (Test-Path $candidate) -and (Test-HermesCommand $candidate)) { return $candidate; } }',
460
- '$cmd = Get-Command hermes -ErrorAction SilentlyContinue;',
461
- 'if ($cmd -and (Test-HermesCommand $cmd.Source)) { return $cmd.Source; }',
462
- 'return $null;',
463
- '}',
464
- ].join(' ');
465
- const configure = [
466
- 'function Invoke-PixcodeHermesConfigure {',
467
- `& node ${quote(configureScript)};`,
468
- 'if ($LASTEXITCODE -ne 0) { Write-Warning "Pixcode MCP configure failed; starting Hermes anyway."; $global:LASTEXITCODE = 0; }',
469
- '}',
470
- ].join(' ');
471
- const installHermesIfMissing = [
472
- 'function Install-HermesIfMissing {',
473
- '$script:HermesCmd = Resolve-HermesCommand;',
474
- 'if ($script:HermesCmd) { & $script:HermesCmd --version *> $null; return; }',
475
- '$installer = Join-Path $env:TEMP "pixcode-hermes-install.ps1";',
476
- 'Invoke-WebRequest -Uri "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1" -UseBasicParsing -OutFile $installer;',
477
- '& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installer -SkipSetup -Branch main;',
478
- 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE; }',
479
- '$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + $env:Path;',
480
- '$script:HermesCmd = Resolve-HermesCommand;',
481
- 'if (-not $script:HermesCmd) { throw "Hermes installed, but the hermes command could not be found. Restart Pixcode or add Hermes to PATH."; }',
482
- '}',
483
- ].join(' ');
484
- if (kind === 'pixcode:hermes:install') {
485
- return `${setEnv}; ${resolveHermesCommand}; ${configure}; ${installHermesIfMissing}; Install-HermesIfMissing; Invoke-PixcodeHermesConfigure`;
486
- }
487
- return `${setEnv}; ${resolveHermesCommand}; ${configure}; ${installHermesIfMissing}; Install-HermesIfMissing; Invoke-PixcodeHermesConfigure; & $script:HermesCmd`;
488
- }
489
-
490
- const setEnv = [
491
- `PIXCODE_BASE_URL=${quote(env.PIXCODE_BASE_URL)}`,
492
- `PIXCODE_API_KEY=${quote(env.PIXCODE_API_KEY)}`,
493
- `PIXCODE_APP_ROOT=${quote(env.PIXCODE_APP_ROOT)}`,
494
- ].join(' ');
495
- const resolveHermesCommand = [
496
- 'testHermesCommand() {',
497
- '[ -n "$1" ] && [ -x "$1" ] && "$1" --version >/dev/null 2>&1;',
498
- '}',
499
- 'resolveHermesCommand() {',
500
- 'for candidate in "${HERMES_CLI_PATH:-}" "$HOME/.local/bin/hermes" "$HOME/.hermes/hermes-agent/venv/bin/hermes" "$HOME/.hermes/hermes-agent/.venv/bin/hermes" "/usr/local/bin/hermes" "/usr/local/lib/hermes-agent/venv/bin/hermes"; do',
501
- 'if testHermesCommand "$candidate"; then printf "%s\\n" "$candidate"; return 0; fi;',
502
- 'done;',
503
- 'candidate="$(command -v hermes 2>/dev/null || true)";',
504
- 'if testHermesCommand "$candidate"; then printf "%s\\n" "$candidate"; return 0; fi;',
505
- 'return 1;',
506
- '}',
507
- ].join(' ');
508
- const installHermesIfMissing = [
509
- 'installHermesIfMissing() {',
510
- 'HERMES_CMD="$(resolveHermesCommand 2>/dev/null || true)";',
511
- 'if [ -n "$HERMES_CMD" ]; then "$HERMES_CMD" --version >/dev/null 2>&1 || true; return 0; fi;',
512
- 'echo "Hermes is not installed. Use Pixcode Settings > Hermes Agent > Install or repair, then start again." >&2;',
513
- 'exit 127;',
514
- '}',
515
- ].join(' ');
516
- if (kind === 'pixcode:hermes:install') {
517
- return `${setEnv} sh -lc ${quote(`${resolveHermesCommand} ${installHermesIfMissing} installHermesIfMissing && { node ${shellQuotePosix(configureScript)} || echo "Pixcode MCP configure failed; continuing."; }`)}`;
518
- }
519
- return `${setEnv} sh -lc ${quote(`${resolveHermesCommand} ${installHermesIfMissing} installHermesIfMissing && { node ${shellQuotePosix(configureScript)} || echo "Pixcode MCP configure failed; starting Hermes anyway."; } && "$HERMES_CMD"`)}`;
520
- }
521
-
522
412
  // Single WebSocket server that handles both paths
523
413
  const wss = new WebSocketServer({
524
414
  server,
@@ -2290,21 +2180,7 @@ function handleShellConnection(ws, request) {
2290
2180
  const sessionId = data.sessionId;
2291
2181
  const hasSession = data.hasSession;
2292
2182
  const provider = data.provider || 'claude';
2293
- let initialCommand = data.initialCommand;
2294
- const hermesCommand = HERMES_SHELL_COMMANDS.has(initialCommand) ? initialCommand : null;
2295
- const isHermesShellSession = Boolean(hermesCommand);
2296
- if (hermesCommand) {
2297
- const apiKey = getOrCreateHermesApiKey(request?.user?.id);
2298
- if (!apiKey) {
2299
- ws.send(JSON.stringify({ type: 'error', message: 'Hermes MCP could not create a Pixcode API key for this user.' }));
2300
- return;
2301
- }
2302
- initialCommand = buildHermesShellCommand(hermesCommand, {
2303
- PIXCODE_BASE_URL: resolvePublicBaseUrl(request),
2304
- PIXCODE_API_KEY: apiKey,
2305
- PIXCODE_APP_ROOT: APP_ROOT,
2306
- });
2307
- }
2183
+ const initialCommand = data.initialCommand;
2308
2184
  const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
2309
2185
  const forceNewSession = Boolean(data.forceNewSession);
2310
2186
  const shellPermissionMode = normalizeShellPermissionMode(data.permissionMode);
@@ -2329,7 +2205,7 @@ function handleShellConnection(ws, request) {
2329
2205
 
2330
2206
  // Include command hash in session key so different commands get separate sessions
2331
2207
  const commandSuffix = isPlainShell && initialCommand
2332
- ? (isHermesShellSession ? '_hermes' : `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`)
2208
+ ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`
2333
2209
  : '';
2334
2210
  // Include provider in the key so a fresh "new session" in OpenCode
2335
2211
  // doesn't reattach to a cached Claude PTY for the same project (or
@@ -2574,7 +2450,7 @@ function handleShellConnection(ws, request) {
2574
2450
  sessionId,
2575
2451
  provider,
2576
2452
  isPlainShell,
2577
- keepAliveUntilExit: isHermesShellSession,
2453
+ keepAliveUntilExit: false,
2578
2454
  });
2579
2455
 
2580
2456
  // Handle data output
@@ -1,4 +1,5 @@
1
1
  import os from 'node:os';
2
+ import { EventEmitter } from 'node:events';
2
3
 
3
4
  import express, { type Request, type Response, type Router } from 'express';
4
5
 
@@ -15,11 +16,13 @@ import {
15
16
  ensureHermesGateway,
16
17
  getHermesGatewayStatus,
17
18
  probeHermesGateway,
19
+ runHermesGatewayPrompt,
18
20
  stopHermesGateway,
19
21
  } from '@/services/hermes-gateway.js';
20
22
 
21
23
  const HERMES_TERMINAL_LAUNCH_LIMIT = 100;
22
24
  const HERMES_TERMINAL_LAUNCH_PROVIDERS = new Set(['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode']);
25
+ const HERMES_TERMINAL_LAUNCH_STREAM_HEARTBEAT_MS = 25000;
23
26
 
24
27
  type HermesTerminalLaunchEvent = {
25
28
  id: number;
@@ -45,6 +48,8 @@ type PixcodeRequest = Request & {
45
48
 
46
49
  let nextHermesTerminalLaunchId = 1;
47
50
  const hermesTerminalLaunches: HermesTerminalLaunchEvent[] = [];
51
+ const hermesTerminalLaunchEmitter = new EventEmitter();
52
+ hermesTerminalLaunchEmitter.setMaxListeners(200);
48
53
 
49
54
  function writeSse(res: Response, event: string, payload: unknown) {
50
55
  res.write(`event: ${event}\n`);
@@ -55,6 +60,19 @@ function readUserId(req: PixcodeRequest) {
55
60
  return req.user?.id ?? req.user?.userId ?? null;
56
61
  }
57
62
 
63
+ function readAfterId(req: Request) {
64
+ const after = Number.parseInt(typeof req.query.after === 'string' ? req.query.after : '0', 10);
65
+ return Number.isFinite(after) ? after : 0;
66
+ }
67
+
68
+ function rememberHermesTerminalLaunch(event: HermesTerminalLaunchEvent) {
69
+ hermesTerminalLaunches.push(event);
70
+ if (hermesTerminalLaunches.length > HERMES_TERMINAL_LAUNCH_LIMIT) {
71
+ hermesTerminalLaunches.splice(0, hermesTerminalLaunches.length - HERMES_TERMINAL_LAUNCH_LIMIT);
72
+ }
73
+ hermesTerminalLaunchEmitter.emit('terminal-launch', event);
74
+ }
75
+
58
76
  export function createHermesRouter(options: HermesRouterOptions = {}): Router {
59
77
  const router = express.Router();
60
78
 
@@ -171,6 +189,66 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
171
189
  }
172
190
  });
173
191
 
192
+ router.post('/gateway/chat', async (req: PixcodeRequest, res) => {
193
+ const body = (req.body ?? {}) as Record<string, unknown>;
194
+ const projectPath = typeof body.projectPath === 'string' && body.projectPath.trim()
195
+ ? body.projectPath.trim()
196
+ : undefined;
197
+ const input = typeof body.input === 'string' ? body.input.trim() : '';
198
+ const sessionId = typeof body.sessionId === 'string' && body.sessionId.trim()
199
+ ? body.sessionId.trim()
200
+ : undefined;
201
+
202
+ if (!input) {
203
+ res.status(400).json({
204
+ error: {
205
+ code: 'HERMES_PROMPT_REQUIRED',
206
+ message: 'Hermes prompt is required.',
207
+ },
208
+ });
209
+ return;
210
+ }
211
+
212
+ const apiKey = options.createHermesApiKey?.(readUserId(req)) ?? null;
213
+ if (!apiKey) {
214
+ res.status(500).json({
215
+ error: {
216
+ code: 'HERMES_API_KEY_UNAVAILABLE',
217
+ message: 'Pixcode could not create a Hermes MCP API key for this user.',
218
+ },
219
+ });
220
+ return;
221
+ }
222
+
223
+ try {
224
+ const gateway = await ensureHermesGateway({
225
+ appRoot: options.appRoot ?? process.cwd(),
226
+ pixcodeApiKey: apiKey,
227
+ pixcodeBaseUrl: options.resolvePublicBaseUrl?.(req) ?? `http://127.0.0.1:${process.env.SERVER_PORT ?? process.env.PORT ?? '3001'}`,
228
+ projectPath,
229
+ });
230
+ const run = await runHermesGatewayPrompt(projectPath, {
231
+ input,
232
+ sessionId,
233
+ });
234
+
235
+ res.status(run.ok ? 200 : 502).json({
236
+ ok: run.ok,
237
+ gateway,
238
+ run,
239
+ message: run.message,
240
+ error: run.error ?? null,
241
+ });
242
+ } catch (error) {
243
+ res.status(500).json({
244
+ error: {
245
+ code: 'HERMES_GATEWAY_CHAT_FAILED',
246
+ message: error instanceof Error ? error.message : String(error),
247
+ },
248
+ });
249
+ }
250
+ });
251
+
174
252
  router.post('/gateway/stop', (req, res) => {
175
253
  const body = (req.body ?? {}) as Record<string, unknown>;
176
254
  const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
@@ -297,13 +375,60 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
297
375
  });
298
376
 
299
377
  router.get('/terminal-launches', (req, res) => {
300
- const after = Number.parseInt(typeof req.query.after === 'string' ? req.query.after : '0', 10);
301
- const afterId = Number.isFinite(after) ? after : 0;
378
+ const afterId = readAfterId(req);
302
379
  res.json({
303
380
  events: hermesTerminalLaunches.filter((event) => event.id > afterId),
304
381
  });
305
382
  });
306
383
 
384
+ router.get('/terminal-launches/stream', (req, res) => {
385
+ const afterId = readAfterId(req);
386
+
387
+ res.setHeader('Content-Type', 'text/event-stream');
388
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
389
+ res.setHeader('Connection', 'keep-alive');
390
+ res.setHeader('X-Accel-Buffering', 'no');
391
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
392
+ try {
393
+ (res.socket as NodeJS.Socket & { setNoDelay?: (on: boolean) => void })?.setNoDelay?.(true);
394
+ } catch { /* noop */ }
395
+
396
+ let closed = false;
397
+ const safeWrite = (event: string, payload: unknown) => {
398
+ if (closed) return;
399
+ try { writeSse(res, event, payload); } catch { /* socket gone */ }
400
+ };
401
+
402
+ try { res.write(': start\n\n'); } catch { /* noop */ }
403
+ const replayed = hermesTerminalLaunches.filter((event) => event.id > afterId);
404
+ for (const event of replayed) {
405
+ safeWrite('terminal-launch', event);
406
+ }
407
+ safeWrite('ready', {
408
+ latestId: hermesTerminalLaunches[hermesTerminalLaunches.length - 1]?.id ?? afterId,
409
+ replayed: replayed.length,
410
+ });
411
+
412
+ const heartbeat = setInterval(() => {
413
+ if (closed) return;
414
+ try { res.write(': ping\n\n'); } catch { /* noop */ }
415
+ }, HERMES_TERMINAL_LAUNCH_STREAM_HEARTBEAT_MS);
416
+
417
+ const onTerminalLaunch = (event: HermesTerminalLaunchEvent) => {
418
+ safeWrite('terminal-launch', event);
419
+ };
420
+ hermesTerminalLaunchEmitter.on('terminal-launch', onTerminalLaunch);
421
+
422
+ const cleanup = () => {
423
+ if (closed) return;
424
+ closed = true;
425
+ clearInterval(heartbeat);
426
+ hermesTerminalLaunchEmitter.off('terminal-launch', onTerminalLaunch);
427
+ };
428
+
429
+ req.on('close', cleanup);
430
+ });
431
+
307
432
  router.post('/terminal-launches', (req, res) => {
308
433
  const body = (req.body ?? {}) as Record<string, unknown>;
309
434
  const provider = typeof body.provider === 'string' ? body.provider.trim() : '';
@@ -328,10 +453,7 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
328
453
  createdAt: new Date().toISOString(),
329
454
  };
330
455
  nextHermesTerminalLaunchId += 1;
331
- hermesTerminalLaunches.push(event);
332
- if (hermesTerminalLaunches.length > HERMES_TERMINAL_LAUNCH_LIMIT) {
333
- hermesTerminalLaunches.splice(0, hermesTerminalLaunches.length - HERMES_TERMINAL_LAUNCH_LIMIT);
334
- }
456
+ rememberHermesTerminalLaunch(event);
335
457
 
336
458
  res.status(201).json({ event });
337
459
  });
@@ -15,6 +15,8 @@ const DEFAULT_PORT = 8642;
15
15
  const PORT_SCAN_LIMIT = 80;
16
16
  const STARTUP_TIMEOUT_MS = 30000;
17
17
  const FETCH_TIMEOUT_MS = 5000;
18
+ const RUN_TIMEOUT_MS = 120000;
19
+ const RUN_POLL_INTERVAL_MS = 1000;
18
20
  const LOG_LIMIT = 800;
19
21
 
20
22
  const gateways = new Map();
@@ -47,6 +49,10 @@ function makeApiServerKey() {
47
49
  return `pixcode-hermes-${randomBytes(24).toString('hex')}`;
48
50
  }
49
51
 
52
+ function sleep(ms) {
53
+ return new Promise((resolve) => setTimeout(resolve, ms));
54
+ }
55
+
50
56
  export function buildHermesGatewayEnv(baseEnv = process.env, options = {}) {
51
57
  const host = options.host || DEFAULT_HOST;
52
58
  const port = String(options.port || DEFAULT_PORT);
@@ -124,6 +130,62 @@ async function callGateway(gateway, endpoint, options = {}) {
124
130
  });
125
131
  }
126
132
 
133
+ function extractRunId(body) {
134
+ if (!body || typeof body !== 'object') return null;
135
+ return body.run_id || body.runId || body.id || body.run?.id || null;
136
+ }
137
+
138
+ function extractRunStatus(body) {
139
+ if (!body || typeof body !== 'object') return null;
140
+ return body.status || body.state || body.run?.status || body.run?.state || null;
141
+ }
142
+
143
+ function extractTextFromValue(value) {
144
+ if (typeof value === 'string') return value;
145
+ if (!value) return null;
146
+
147
+ if (Array.isArray(value)) {
148
+ return value
149
+ .map(extractTextFromValue)
150
+ .filter(Boolean)
151
+ .join('\n')
152
+ .trim() || null;
153
+ }
154
+
155
+ if (typeof value === 'object') {
156
+ for (const key of ['text', 'content', 'message', 'output', 'response', 'result', 'final']) {
157
+ const text = extractTextFromValue(value[key]);
158
+ if (text) return text;
159
+ }
160
+ }
161
+
162
+ return null;
163
+ }
164
+
165
+ function extractRunOutput(body) {
166
+ if (!body || typeof body !== 'object') return null;
167
+
168
+ for (const key of ['output_text', 'output', 'response', 'result', 'message', 'messages', 'events', 'final']) {
169
+ const text = extractTextFromValue(body[key]);
170
+ if (text) return text;
171
+ }
172
+
173
+ return null;
174
+ }
175
+
176
+ function makeRunRequest(options) {
177
+ const input = String(options.input || '').trim();
178
+ return {
179
+ session_id: options.sessionId || `pixcode-hermes-chat-${Date.now()}-${randomBytes(4).toString('hex')}`,
180
+ input,
181
+ instructions: options.instructions || [
182
+ 'You are Hermes Agent running inside Pixcode.',
183
+ 'Use Pixcode MCP tools when they help inspect projects, launch CLIs, or perform workspace actions.',
184
+ 'Keep answers concise and include concrete next steps when work is blocked.',
185
+ ].join(' '),
186
+ };
187
+ }
188
+
127
189
  async function waitForGatewayReady(gateway) {
128
190
  const started = Date.now();
129
191
  let lastError = null;
@@ -382,6 +444,81 @@ export async function probeHermesGateway(projectPath, options = {}) {
382
444
  return result;
383
445
  }
384
446
 
447
+ export async function runHermesGatewayPrompt(projectPath, options = {}) {
448
+ const gateway = projectPath
449
+ ? gateways.get(normalizeProjectPath(projectPath))
450
+ : Array.from(gateways.values()).find(isGatewayRunning);
451
+
452
+ if (!isGatewayRunning(gateway)) {
453
+ throw new Error('Hermes gateway is not running.');
454
+ }
455
+
456
+ const input = String(options.input || '').trim();
457
+ if (!input) {
458
+ throw new Error('Hermes prompt is required.');
459
+ }
460
+
461
+ const request = makeRunRequest({ ...options, input });
462
+ const create = await callGateway(gateway, '/v1/runs', {
463
+ method: 'POST',
464
+ body: JSON.stringify(request),
465
+ timeoutMs: options.createTimeoutMs || 15000,
466
+ });
467
+
468
+ if (!create.ok) {
469
+ throw new Error(`Hermes /v1/runs failed with HTTP ${create.status}: ${JSON.stringify(create.body)}`);
470
+ }
471
+
472
+ const runId = extractRunId(create.body);
473
+ const initialStatus = extractRunStatus(create.body);
474
+ if (!runId) {
475
+ return {
476
+ ok: true,
477
+ projectPath: gateway.projectPath,
478
+ baseUrl: gateway.baseUrl,
479
+ sessionId: request.session_id,
480
+ runId: null,
481
+ status: initialStatus || 'completed',
482
+ message: extractRunOutput(create.body),
483
+ raw: create.body,
484
+ };
485
+ }
486
+
487
+ const terminalStatuses = new Set(['completed', 'failed', 'cancelled', 'canceled']);
488
+ const started = Date.now();
489
+ let latest = create.body;
490
+ let status = initialStatus || 'queued';
491
+
492
+ while (!terminalStatuses.has(String(status)) && Date.now() - started < (options.timeoutMs || RUN_TIMEOUT_MS)) {
493
+ await sleep(options.pollIntervalMs || RUN_POLL_INTERVAL_MS);
494
+ const poll = await callGateway(gateway, `/v1/runs/${encodeURIComponent(runId)}`, {
495
+ timeoutMs: options.pollTimeoutMs || 15000,
496
+ });
497
+ if (!poll.ok) {
498
+ throw new Error(`Hermes /v1/runs/${runId} failed with HTTP ${poll.status}: ${JSON.stringify(poll.body)}`);
499
+ }
500
+ latest = poll.body;
501
+ status = extractRunStatus(latest) || status;
502
+ }
503
+
504
+ if (!terminalStatuses.has(String(status))) {
505
+ throw new Error(`Hermes run did not finish within ${Math.round((options.timeoutMs || RUN_TIMEOUT_MS) / 1000)}s: ${runId}`);
506
+ }
507
+
508
+ const message = extractRunOutput(latest);
509
+ return {
510
+ ok: status === 'completed',
511
+ projectPath: gateway.projectPath,
512
+ baseUrl: gateway.baseUrl,
513
+ sessionId: request.session_id,
514
+ runId,
515
+ status,
516
+ message,
517
+ error: status === 'completed' ? null : extractTextFromValue(latest?.error) || message || 'Hermes run failed.',
518
+ raw: latest,
519
+ };
520
+ }
521
+
385
522
  export function stopHermesGateway(projectPath) {
386
523
  const targets = projectPath
387
524
  ? [gateways.get(normalizeProjectPath(projectPath))].filter(Boolean)