@pixelbyte-software/pixcode 1.49.7 → 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/dist/assets/index-Bw6PxVkB.css +32 -0
- package/dist/assets/{index-BqgTbW4j.js → index-Cwsu2tLq.js} +138 -138
- package/dist/index.html +2 -2
- package/dist-server/server/index.js +3 -122
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.js +128 -7
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.js.map +1 -1
- package/dist-server/server/services/hermes-gateway.js +123 -0
- package/dist-server/server/services/hermes-gateway.js.map +1 -1
- package/package.json +1 -1
- package/scripts/smoke/hermes-api-install.mjs +5 -13
- package/scripts/smoke/hermes-rest-gateway.mjs +3 -0
- package/scripts/smoke/pixcode-workbench-1-48.mjs +14 -9
- package/scripts/smoke/vscode-workbench-polish.mjs +8 -2
- package/server/index.js +3 -127
- package/server/modules/orchestration/hermes/hermes.routes.ts +128 -6
- package/server/services/hermes-gateway.js +137 -0
- package/dist/assets/index-DjKDBqln.css +0 -32
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; Write-Host "Hermes Agent is starting..."; Invoke-PixcodeHermesConfigure; & $script:HermesCmd chat`;
|
|
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 && printf "Hermes Agent is starting...\\n" && { node ${shellQuotePosix(configureScript)} || echo "Pixcode MCP configure failed; starting Hermes anyway."; } && exec "$HERMES_CMD" chat`)}`;
|
|
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
|
-
|
|
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
|
-
?
|
|
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:
|
|
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
|
|
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
|
-
|
|
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)
|