@pixelbyte-software/pixcode 1.49.4 → 1.49.6

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,400 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import net from 'node:net';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import spawn from 'cross-spawn';
7
+
8
+ import {
9
+ buildHermesPathEnv,
10
+ readHermesInstallStatus,
11
+ } from './hermes-install-jobs.js';
12
+
13
+ const DEFAULT_HOST = '127.0.0.1';
14
+ const DEFAULT_PORT = 8642;
15
+ const PORT_SCAN_LIMIT = 80;
16
+ const STARTUP_TIMEOUT_MS = 30000;
17
+ const FETCH_TIMEOUT_MS = 5000;
18
+ const LOG_LIMIT = 800;
19
+
20
+ const gateways = new Map();
21
+
22
+ function nowIso() {
23
+ return new Date().toISOString();
24
+ }
25
+
26
+ function normalizeProjectPath(projectPath) {
27
+ return path.resolve(projectPath || os.homedir());
28
+ }
29
+
30
+ function appendGatewayLog(gateway, stream, chunk) {
31
+ const entry = { stream, chunk: String(chunk || ''), at: Date.now() };
32
+ gateway.logs.push(entry);
33
+ if (gateway.logs.length > LOG_LIMIT) {
34
+ gateway.logs.splice(0, gateway.logs.length - LOG_LIMIT);
35
+ }
36
+ }
37
+
38
+ function isGatewayRunning(gateway) {
39
+ return Boolean(gateway?.child && gateway.exitCode === null && gateway.exitSignal === null);
40
+ }
41
+
42
+ function gatewayBaseUrl(host, port) {
43
+ return `http://${host}:${port}`;
44
+ }
45
+
46
+ function makeApiServerKey() {
47
+ return `pixcode-hermes-${randomBytes(24).toString('hex')}`;
48
+ }
49
+
50
+ export function buildHermesGatewayEnv(baseEnv = process.env, options = {}) {
51
+ const host = options.host || DEFAULT_HOST;
52
+ const port = String(options.port || DEFAULT_PORT);
53
+ return buildHermesPathEnv(baseEnv, {
54
+ API_SERVER_ENABLED: 'true',
55
+ API_SERVER_HOST: host,
56
+ API_SERVER_PORT: port,
57
+ API_SERVER_KEY: options.apiServerKey || makeApiServerKey(),
58
+ API_SERVER_CORS_ORIGINS: options.corsOrigins || options.pixcodeBaseUrl || '',
59
+ PIXCODE_BASE_URL: options.pixcodeBaseUrl || '',
60
+ PIXCODE_API_KEY: options.pixcodeApiKey || '',
61
+ PIXCODE_APP_ROOT: options.appRoot || process.cwd(),
62
+ HERMES_HOME: options.hermesHome || '',
63
+ HERMES_INSTALL_DIR: options.installDir || '',
64
+ });
65
+ }
66
+
67
+ function isPortAvailable(port, host) {
68
+ return new Promise((resolve) => {
69
+ const server = net.createServer();
70
+ server.once('error', () => resolve(false));
71
+ server.once('listening', () => {
72
+ server.close(() => resolve(true));
73
+ });
74
+ server.listen(port, host);
75
+ });
76
+ }
77
+
78
+ async function findAvailablePort(preferredPort, host) {
79
+ const start = Number.isFinite(preferredPort) ? preferredPort : DEFAULT_PORT;
80
+ for (let offset = 0; offset < PORT_SCAN_LIMIT; offset += 1) {
81
+ const port = start + offset;
82
+ if (await isPortAvailable(port, host)) {
83
+ return port;
84
+ }
85
+ }
86
+ throw new Error(`No available Hermes API server port found from ${start} to ${start + PORT_SCAN_LIMIT - 1}.`);
87
+ }
88
+
89
+ function fetchJson(url, options = {}) {
90
+ const controller = new AbortController();
91
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs || FETCH_TIMEOUT_MS);
92
+ return fetch(url, {
93
+ ...options,
94
+ signal: controller.signal,
95
+ headers: {
96
+ accept: 'application/json',
97
+ ...(options.headers || {}),
98
+ },
99
+ }).then(async (response) => {
100
+ const text = await response.text();
101
+ let body = null;
102
+ try {
103
+ body = text ? JSON.parse(text) : null;
104
+ } catch {
105
+ body = text;
106
+ }
107
+
108
+ return {
109
+ ok: response.ok,
110
+ status: response.status,
111
+ body,
112
+ };
113
+ }).finally(() => clearTimeout(timeout));
114
+ }
115
+
116
+ async function callGateway(gateway, endpoint, options = {}) {
117
+ return fetchJson(`${gateway.baseUrl}${endpoint}`, {
118
+ ...options,
119
+ headers: {
120
+ Authorization: `Bearer ${gateway.apiServerKey}`,
121
+ 'content-type': 'application/json',
122
+ ...(options.headers || {}),
123
+ },
124
+ });
125
+ }
126
+
127
+ async function waitForGatewayReady(gateway) {
128
+ const started = Date.now();
129
+ let lastError = null;
130
+
131
+ while (Date.now() - started < STARTUP_TIMEOUT_MS) {
132
+ if (!isGatewayRunning(gateway)) {
133
+ throw new Error(gateway.error || `Hermes gateway exited with code ${gateway.exitCode ?? 'unknown'}.`);
134
+ }
135
+
136
+ try {
137
+ const probe = await probeHermesGateway(gateway.projectPath, { requireRunning: true });
138
+ if (probe.ok) {
139
+ return probe;
140
+ }
141
+ lastError = probe.error || 'Gateway probe failed.';
142
+ } catch (error) {
143
+ lastError = error instanceof Error ? error.message : String(error);
144
+ }
145
+
146
+ await new Promise((resolve) => setTimeout(resolve, 500));
147
+ }
148
+
149
+ throw new Error(`Hermes gateway did not become ready within ${STARTUP_TIMEOUT_MS / 1000}s: ${lastError || 'no response'}`);
150
+ }
151
+
152
+ function runProcess(command, args, options, onData) {
153
+ return new Promise((resolve, reject) => {
154
+ const child = spawn(command, args, {
155
+ ...options,
156
+ stdio: ['ignore', 'pipe', 'pipe'],
157
+ windowsHide: true,
158
+ });
159
+ child.stdout?.on('data', (buf) => onData?.('stdout', buf.toString()));
160
+ child.stderr?.on('data', (buf) => onData?.('stderr', buf.toString()));
161
+ child.on('error', reject);
162
+ child.on('close', (code, signal) => {
163
+ if (signal) {
164
+ reject(new Error(`${command} killed by ${signal}`));
165
+ return;
166
+ }
167
+ resolve(code ?? 0);
168
+ });
169
+ });
170
+ }
171
+
172
+ async function configurePixcodeMcp({ appRoot, env, gateway }) {
173
+ const configureScript = path.join(appRoot, 'scripts', 'hermes', 'configure-pixcode-mcp.mjs');
174
+ const code = await runProcess(process.execPath, [configureScript], {
175
+ cwd: appRoot,
176
+ env,
177
+ }, (stream, chunk) => appendGatewayLog(gateway, stream, chunk));
178
+
179
+ if (code !== 0) {
180
+ throw new Error(`Pixcode MCP configuration exited with code ${code}`);
181
+ }
182
+ }
183
+
184
+ function snapshotGateway(gateway) {
185
+ if (!gateway) {
186
+ return {
187
+ running: false,
188
+ projectPath: null,
189
+ baseUrl: null,
190
+ host: null,
191
+ port: null,
192
+ pid: null,
193
+ startedAt: null,
194
+ exitedAt: null,
195
+ exitCode: null,
196
+ exitSignal: null,
197
+ error: null,
198
+ lastProbe: null,
199
+ logs: [],
200
+ };
201
+ }
202
+
203
+ return {
204
+ running: isGatewayRunning(gateway),
205
+ projectPath: gateway.projectPath,
206
+ baseUrl: gateway.baseUrl,
207
+ host: gateway.host,
208
+ port: gateway.port,
209
+ pid: gateway.child?.pid ?? null,
210
+ startedAt: gateway.startedAt,
211
+ exitedAt: gateway.exitedAt,
212
+ exitCode: gateway.exitCode,
213
+ exitSignal: gateway.exitSignal,
214
+ error: gateway.error,
215
+ lastProbe: gateway.lastProbe,
216
+ logs: gateway.logs.slice(-80),
217
+ };
218
+ }
219
+
220
+ export function getHermesGatewayStatus(projectPath) {
221
+ if (projectPath) {
222
+ return snapshotGateway(gateways.get(normalizeProjectPath(projectPath)));
223
+ }
224
+
225
+ const active = Array.from(gateways.values()).filter(isGatewayRunning);
226
+ return {
227
+ running: active.length > 0,
228
+ gateways: Array.from(gateways.values()).map(snapshotGateway),
229
+ };
230
+ }
231
+
232
+ export async function ensureHermesGateway(options = {}) {
233
+ const projectPath = normalizeProjectPath(options.projectPath);
234
+ const existing = gateways.get(projectPath);
235
+ if (isGatewayRunning(existing)) {
236
+ const probe = await probeHermesGateway(projectPath, { requireRunning: true }).catch((error) => ({
237
+ ok: false,
238
+ error: error instanceof Error ? error.message : String(error),
239
+ }));
240
+ if (probe.ok) {
241
+ return {
242
+ ...snapshotGateway(existing),
243
+ probe,
244
+ };
245
+ }
246
+ stopHermesGateway(projectPath);
247
+ }
248
+
249
+ const host = options.host || DEFAULT_HOST;
250
+ const port = await findAvailablePort(Number(options.port || process.env.HERMES_API_SERVER_PORT || DEFAULT_PORT), host);
251
+ const apiServerKey = options.apiServerKey || makeApiServerKey();
252
+ const appRoot = options.appRoot || process.cwd();
253
+ const env = buildHermesGatewayEnv(process.env, {
254
+ ...options,
255
+ host,
256
+ port,
257
+ apiServerKey,
258
+ appRoot,
259
+ });
260
+ const installStatus = readHermesInstallStatus(env);
261
+ if (!installStatus.installed || !installStatus.command) {
262
+ throw new Error(installStatus.error || 'Hermes Agent CLI is not installed.');
263
+ }
264
+
265
+ const gateway = {
266
+ id: `${projectPath}:${port}`,
267
+ projectPath,
268
+ host,
269
+ port,
270
+ baseUrl: gatewayBaseUrl(host, port),
271
+ apiServerKey,
272
+ command: installStatus.command,
273
+ child: null,
274
+ startedAt: nowIso(),
275
+ exitedAt: null,
276
+ exitCode: null,
277
+ exitSignal: null,
278
+ error: null,
279
+ lastProbe: null,
280
+ logs: [],
281
+ };
282
+ gateways.set(projectPath, gateway);
283
+
284
+ await configurePixcodeMcp({ appRoot, env, gateway });
285
+
286
+ const child = spawn(installStatus.command, ['gateway'], {
287
+ cwd: projectPath,
288
+ env,
289
+ stdio: ['ignore', 'pipe', 'pipe'],
290
+ windowsHide: true,
291
+ });
292
+ gateway.child = child;
293
+ appendGatewayLog(gateway, 'meta', `$ ${installStatus.command} gateway\n`);
294
+
295
+ child.stdout?.on('data', (buf) => appendGatewayLog(gateway, 'stdout', buf.toString()));
296
+ child.stderr?.on('data', (buf) => appendGatewayLog(gateway, 'stderr', buf.toString()));
297
+ child.on('error', (error) => {
298
+ gateway.error = error instanceof Error ? error.message : String(error);
299
+ appendGatewayLog(gateway, 'stderr', `${gateway.error}\n`);
300
+ });
301
+ child.on('exit', (code, signal) => {
302
+ gateway.exitCode = code;
303
+ gateway.exitSignal = signal;
304
+ gateway.exitedAt = nowIso();
305
+ appendGatewayLog(gateway, 'meta', `Hermes gateway exited with code ${code}${signal ? ` (${signal})` : ''}\n`);
306
+ });
307
+
308
+ const probe = await waitForGatewayReady(gateway);
309
+ return {
310
+ ...snapshotGateway(gateway),
311
+ probe,
312
+ };
313
+ }
314
+
315
+ export async function probeHermesGateway(projectPath, options = {}) {
316
+ const gateway = projectPath
317
+ ? gateways.get(normalizeProjectPath(projectPath))
318
+ : Array.from(gateways.values()).find(isGatewayRunning);
319
+
320
+ if (!isGatewayRunning(gateway)) {
321
+ const result = {
322
+ ok: false,
323
+ error: 'Hermes gateway is not running.',
324
+ projectPath: projectPath ? normalizeProjectPath(projectPath) : null,
325
+ baseUrl: null,
326
+ checks: {},
327
+ };
328
+ if (options.requireRunning) return result;
329
+ return result;
330
+ }
331
+
332
+ const checks = {};
333
+ try {
334
+ checks.health = await fetchJson(`${gateway.baseUrl}/health`);
335
+ } catch (error) {
336
+ checks.health = { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
337
+ }
338
+
339
+ try {
340
+ checks.capabilities = await callGateway(gateway, '/v1/capabilities');
341
+ } catch (error) {
342
+ checks.capabilities = { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
343
+ }
344
+
345
+ try {
346
+ checks.models = await callGateway(gateway, '/v1/models');
347
+ } catch (error) {
348
+ checks.models = { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
349
+ }
350
+
351
+ if (typeof options.input === 'string' && options.input.trim()) {
352
+ try {
353
+ checks.run = await callGateway(gateway, '/v1/runs', {
354
+ method: 'POST',
355
+ body: JSON.stringify({
356
+ input: options.input.trim(),
357
+ session_id: options.sessionId || `pixcode-${Date.now()}`,
358
+ instructions: options.instructions || 'Respond briefly for a Pixcode REST integration check.',
359
+ }),
360
+ timeoutMs: options.runTimeoutMs || 15000,
361
+ });
362
+ } catch (error) {
363
+ checks.run = { ok: false, status: 0, error: error instanceof Error ? error.message : String(error) };
364
+ }
365
+ }
366
+
367
+ const ok = Boolean(
368
+ checks.health?.ok &&
369
+ checks.capabilities?.ok &&
370
+ checks.models?.ok &&
371
+ (!checks.run || checks.run.ok),
372
+ );
373
+ const result = {
374
+ ok,
375
+ projectPath: gateway.projectPath,
376
+ baseUrl: gateway.baseUrl,
377
+ checkedAt: nowIso(),
378
+ checks,
379
+ error: ok ? null : 'One or more Hermes REST checks failed.',
380
+ };
381
+ gateway.lastProbe = result;
382
+ return result;
383
+ }
384
+
385
+ export function stopHermesGateway(projectPath) {
386
+ const targets = projectPath
387
+ ? [gateways.get(normalizeProjectPath(projectPath))].filter(Boolean)
388
+ : Array.from(gateways.values());
389
+ let stopped = 0;
390
+ for (const gateway of targets) {
391
+ if (!isGatewayRunning(gateway)) continue;
392
+ try {
393
+ gateway.child.kill();
394
+ stopped += 1;
395
+ } catch (error) {
396
+ gateway.error = error instanceof Error ? error.message : String(error);
397
+ }
398
+ }
399
+ return { stopped };
400
+ }
@@ -455,7 +455,39 @@ function scheduleCleanup(job) {
455
455
  }, FINISHED_TTL_MS);
456
456
  }
457
457
 
458
- function spawnLogged(job, command, args, options) {
458
+ function isWindowsEpermSpawnError(error) {
459
+ return process.platform === 'win32' && (
460
+ error?.code === 'EPERM' ||
461
+ /spawn\s+EPERM/i.test(String(error?.message || ''))
462
+ );
463
+ }
464
+
465
+ function isWindowsPowerShellCommand(command) {
466
+ if (process.platform !== 'win32') return false;
467
+ const name = path.basename(String(command || '')).toLowerCase();
468
+ return name === 'powershell.exe' || name === 'powershell' || name === 'pwsh.exe' || name === 'pwsh';
469
+ }
470
+
471
+ function quoteWindowsCmdArg(value) {
472
+ const text = String(value ?? '');
473
+ if (!text) return '""';
474
+ if (!/[\s"&|<>^()]/.test(text)) return text;
475
+ return `"${text.replace(/"/g, '\\"')}"`;
476
+ }
477
+
478
+ function windowsCmdFallbackCommand(command, args) {
479
+ return {
480
+ command: process.env.ComSpec || 'cmd.exe',
481
+ args: [
482
+ '/d',
483
+ '/s',
484
+ '/c',
485
+ [command, ...args].map(quoteWindowsCmdArg).join(' '),
486
+ ],
487
+ };
488
+ }
489
+
490
+ function spawnLoggedOnce(job, command, args, options) {
459
491
  appendLog(job, 'meta', `$ ${command} ${args.join(' ')}\n`);
460
492
  const child = spawn(command, args, {
461
493
  ...options,
@@ -463,8 +495,8 @@ function spawnLogged(job, command, args, options) {
463
495
  windowsHide: true,
464
496
  });
465
497
  job.child = child;
466
- child.stdout.on('data', (buf) => appendLog(job, 'stdout', buf.toString()));
467
- child.stderr.on('data', (buf) => appendLog(job, 'stderr', buf.toString()));
498
+ child.stdout?.on('data', (buf) => appendLog(job, 'stdout', buf.toString()));
499
+ child.stderr?.on('data', (buf) => appendLog(job, 'stderr', buf.toString()));
468
500
  return new Promise((resolve, reject) => {
469
501
  child.on('error', reject);
470
502
  child.on('close', (code, signal) => {
@@ -477,6 +509,20 @@ function spawnLogged(job, command, args, options) {
477
509
  });
478
510
  }
479
511
 
512
+ async function spawnLogged(job, command, args, options) {
513
+ try {
514
+ return await spawnLoggedOnce(job, command, args, options);
515
+ } catch (error) {
516
+ if (!isWindowsEpermSpawnError(error) || !isWindowsPowerShellCommand(command)) {
517
+ throw error;
518
+ }
519
+
520
+ appendLog(job, 'stderr', 'PowerShell direct launch was blocked by Windows (EPERM); retrying through cmd.exe without elevation.\n');
521
+ const fallback = windowsCmdFallbackCommand(command, args);
522
+ return spawnLoggedOnce(job, fallback.command, fallback.args, options);
523
+ }
524
+ }
525
+
480
526
  async function runConfigureScript(job, env, appRoot) {
481
527
  const configureScript = path.join(appRoot, 'scripts', 'hermes', 'configure-pixcode-mcp.mjs');
482
528
  if (!fs.existsSync(configureScript)) {