@pixelbyte-software/pixcode 1.50.3 → 1.50.5

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.
@@ -84,6 +84,20 @@ const server = createServer(async (req, res) => {
84
84
  return;
85
85
  }
86
86
 
87
+ if (req.method === 'GET' && url.pathname === '/api/shell/sessions/provider-output') {
88
+ const launchId = Number(url.searchParams.get('launchId') || 0) || null;
89
+ res.end(JSON.stringify({
90
+ active: true,
91
+ provider: url.searchParams.get('provider') || 'codex',
92
+ projectPath,
93
+ launchId,
94
+ terminalState: 'idle',
95
+ isBusy: false,
96
+ output: 'Hermes launched Codex through Pixcode MCP\n\n› ',
97
+ }));
98
+ return;
99
+ }
100
+
87
101
  res.statusCode = 404;
88
102
  res.end(JSON.stringify({ error: url.pathname }));
89
103
  });
@@ -83,6 +83,66 @@ assert.match(
83
83
  /multi-step|piece-by-piece|long-running/i,
84
84
  'Pixcode MCP should tell Hermes to send arbitrary multi-step work as visible provider terminal input.',
85
85
  );
86
+ assert.match(
87
+ pixcodeMcpServer,
88
+ /defaultWaitMs\s*=\s*startupInput \? 180000 : 0/,
89
+ 'Pixcode MCP should wait for visible provider completion by default when startupInput is present.',
90
+ );
91
+ assert.match(
92
+ pixcodeMcpServer,
93
+ /launchId/,
94
+ 'Pixcode MCP should tie provider output readback to the terminal launch id.',
95
+ );
96
+ assert.match(
97
+ serverIndex,
98
+ /requestedLaunchId[\s\S]+session\.hermesLaunchId === requestedLaunchId/,
99
+ 'Provider output API should filter by Hermes terminal launch id when supplied.',
100
+ );
101
+ assert.match(
102
+ serverIndex,
103
+ /lifecycleState/,
104
+ 'Provider output API should expose provider-agnostic PTY lifecycle state instead of relying only on terminal text regex.',
105
+ );
106
+ assert.match(
107
+ serverIndex,
108
+ /terminalFailed/,
109
+ 'Provider output API should expose non-zero visible terminal exits as failures for Hermes readback.',
110
+ );
111
+ assert.match(
112
+ serverIndex,
113
+ /existingSession[\s\S]+existingSession\.pty/,
114
+ 'Completed visible terminal records should not be reattached as live PTYs.',
115
+ );
116
+ assert.match(
117
+ pixcodeMcpServer,
118
+ /terminalFailed/,
119
+ 'Pixcode MCP should tell Hermes when the visible provider terminal failed.',
120
+ );
121
+ assert.match(
122
+ serverIndex,
123
+ /const hermesLaunchId = Number\.isFinite\(Number\(data\.hermesLaunchId\)\)/,
124
+ 'Shell backend should persist Hermes terminal launch ids on PTY sessions.',
125
+ );
126
+ assert.match(
127
+ workbench,
128
+ /terminalHermesLaunchId/,
129
+ 'Workbench CLI panel should pass Hermes launch ids into provider shells.',
130
+ );
131
+ assert.match(
132
+ pixcodeMcpServer,
133
+ /terminalState is busy|terminalState.+busy|terminal to become idle/i,
134
+ 'Pixcode MCP should not summarize the first busy terminal frame as final output.',
135
+ );
136
+ assert.match(
137
+ pixcodeMcpServer,
138
+ /READBACK_IDLE_STABLE_MS/,
139
+ 'Pixcode MCP should require a stable idle readback before reporting provider output as final.',
140
+ );
141
+ assert.match(
142
+ pixcodeMcpServer,
143
+ /readbackStable/,
144
+ 'Pixcode MCP should mark whether a visible provider readback was stable before Hermes summarizes it.',
145
+ );
86
146
  assert.match(
87
147
  pixcodeMcpServer,
88
148
  /startup input typed into the provider CLI/,
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from 'node:assert/strict';
4
+ import { readFileSync } from 'node:fs';
5
+
6
+ const externalAccess = readFileSync('server/services/external-access.js', 'utf8');
7
+ const networkRoutes = readFileSync('server/routes/network.js', 'utf8');
8
+ const serverIndex = readFileSync('server/index.js', 'utf8');
9
+
10
+ assert.match(
11
+ externalAccess,
12
+ /TUNNEL_PERSISTENCE_PATH/,
13
+ 'Tunnel service should persist the user-requested tunnel state outside process memory.',
14
+ );
15
+ assert.match(
16
+ externalAccess,
17
+ /persistTunnelPreference/,
18
+ 'Tunnel service should write tunnel start/stop intent to disk.',
19
+ );
20
+ assert.match(
21
+ externalAccess,
22
+ /desired:\s*true/,
23
+ 'Starting a tunnel should mark tunnel intent as desired until the user stops it.',
24
+ );
25
+ assert.match(
26
+ externalAccess,
27
+ /desired:\s*false/,
28
+ 'Stopping a tunnel should clear persisted tunnel intent.',
29
+ );
30
+ assert.match(
31
+ externalAccess,
32
+ /restoreRequestedTunnel/,
33
+ 'Tunnel service should expose a startup restore hook.',
34
+ );
35
+ assert.match(
36
+ externalAccess,
37
+ /restoring/,
38
+ 'Tunnel restore should distinguish automatic restart attempts from direct user starts.',
39
+ );
40
+ assert.match(
41
+ networkRoutes,
42
+ /persistPreference:\s*true/,
43
+ 'Manual tunnel starts should persist the user preference through the network route.',
44
+ );
45
+ assert.match(
46
+ serverIndex,
47
+ /restoreRequestedTunnel/,
48
+ 'Server startup should restore a requested tunnel after updates/restarts.',
49
+ );
50
+ assert.match(
51
+ serverIndex,
52
+ /restoreRequestedTunnel\(\{ port: Number\(SERVER_PORT\) \}\)/,
53
+ 'Server startup should restore the tunnel against the current backend port.',
54
+ );
55
+
56
+ console.log('tunnel persistence smoke passed');
package/server/index.js CHANGED
@@ -98,6 +98,7 @@ import {
98
98
  } from './modules/orchestration/index.js';
99
99
  import networkRoutes from './routes/network.js';
100
100
  import telegramRoutes from './routes/telegram.js';
101
+ import { restoreRequestedTunnel } from './services/external-access.js';
101
102
  import { restoreBotFromConfig } from './services/telegram/bot.js';
102
103
  import { ensurePortOpen } from './utils/port-access.js';
103
104
  import {
@@ -285,6 +286,7 @@ const server = http.createServer(app);
285
286
 
286
287
  const ptySessionsMap = new Map();
287
288
  const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
289
+ const COMPLETED_PTY_SESSION_TTL = 5 * 60 * 1000;
288
290
  const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
289
291
  const SHELL_CLI_PROVIDERS = new Set(['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode']);
290
292
  import { stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, shouldAutoOpenUrlFromOutput } from './utils/url-detection.js';
@@ -324,6 +326,114 @@ function killProviderPtySessions(projectPath, provider) {
324
326
  return killed;
325
327
  }
326
328
 
329
+ function getLastRegexMatchIndex(text, pattern) {
330
+ let lastIndex = -1;
331
+ for (const match of text.matchAll(pattern)) {
332
+ lastIndex = match.index ?? lastIndex;
333
+ }
334
+ return lastIndex;
335
+ }
336
+
337
+ function detectProviderTerminalState(provider, output) {
338
+ const cleanOutput = String(output || '');
339
+ if (!cleanOutput.trim()) {
340
+ return {
341
+ terminalState: 'unknown',
342
+ isBusy: false,
343
+ terminalStateReason: 'empty_output',
344
+ };
345
+ }
346
+
347
+ if (/Process exited with code/iu.test(cleanOutput)) {
348
+ return {
349
+ terminalState: 'exited',
350
+ isBusy: false,
351
+ terminalStateReason: 'process_exit',
352
+ };
353
+ }
354
+
355
+ const lastBusy = Math.max(
356
+ getLastRegexMatchIndex(cleanOutput, /(?:^|\n)\s*[•*]\s*(?:Working|Running|Thinking)\b/giu),
357
+ getLastRegexMatchIndex(cleanOutput, /\bWorking\s*\([^)]*esc to interrupt[^)]*\)/giu),
358
+ getLastRegexMatchIndex(cleanOutput, /\bmsg=interrupt\b/giu),
359
+ );
360
+
361
+ if (provider === 'codex') {
362
+ const lastPrompt = Math.max(
363
+ getLastRegexMatchIndex(cleanOutput, /(?:^|\n)\s*›(?:\s|$)/gu),
364
+ getLastRegexMatchIndex(cleanOutput, /(?:^|\n)\s*❯(?:\s|$)/gu),
365
+ );
366
+
367
+ if (lastBusy >= 0) {
368
+ const isBusy = lastPrompt <= lastBusy;
369
+ return {
370
+ terminalState: isBusy ? 'busy' : 'idle',
371
+ isBusy,
372
+ terminalStateReason: isBusy ? 'codex_busy_marker_after_prompt' : 'codex_prompt_after_busy_marker',
373
+ };
374
+ }
375
+
376
+ if (lastPrompt >= 0 && /(?:Initialized|Baseline check passed|I did not modify files|Use \/skills)/iu.test(cleanOutput)) {
377
+ return {
378
+ terminalState: 'idle',
379
+ isBusy: false,
380
+ terminalStateReason: 'codex_idle_prompt',
381
+ };
382
+ }
383
+ }
384
+
385
+ if (lastBusy >= 0) {
386
+ return {
387
+ terminalState: 'busy',
388
+ isBusy: true,
389
+ terminalStateReason: 'generic_busy_marker',
390
+ };
391
+ }
392
+
393
+ return {
394
+ terminalState: 'unknown',
395
+ isBusy: false,
396
+ terminalStateReason: 'no_known_marker',
397
+ };
398
+ }
399
+
400
+ function resolveProviderTerminalState(session, provider, output) {
401
+ if (session?.lifecycleState === 'completed' || session?.lifecycleState === 'failed' || session?.lifecycleState === 'exited') {
402
+ const exitCode = typeof session.exitCode === 'number' ? session.exitCode : null;
403
+ const terminalFailed = exitCode !== null ? exitCode !== 0 : Boolean(session.exitSignal);
404
+ return {
405
+ terminalState: terminalFailed ? 'failed' : 'completed',
406
+ lifecycleState: session.lifecycleState,
407
+ isBusy: false,
408
+ terminalFailed,
409
+ exitCode,
410
+ exitSignal: session.exitSignal || null,
411
+ completedAt: session.completedAt || null,
412
+ terminalStateReason: terminalFailed ? 'pty_failed' : 'pty_completed',
413
+ };
414
+ }
415
+
416
+ const detected = detectProviderTerminalState(provider, output);
417
+ return {
418
+ ...detected,
419
+ lifecycleState: session?.lifecycleState || 'running',
420
+ terminalFailed: false,
421
+ exitCode: null,
422
+ exitSignal: null,
423
+ completedAt: null,
424
+ };
425
+ }
426
+
427
+ function appendPtySessionBuffer(session, data) {
428
+ if (!session) return;
429
+ if (session.buffer.length < 5000) {
430
+ session.buffer.push(data);
431
+ } else {
432
+ session.buffer.shift();
433
+ session.buffer.push(data);
434
+ }
435
+ }
436
+
327
437
  function normalizeShellPermissionMode(value) {
328
438
  return typeof value === 'string' ? value.trim() : '';
329
439
  }
@@ -532,6 +642,8 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
532
642
  const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.trim()
533
643
  ? req.query.projectPath.trim()
534
644
  : null;
645
+ const launchId = Number.parseInt(String(req.query.launchId || ''), 10);
646
+ const requestedLaunchId = Number.isFinite(launchId) && launchId > 0 ? launchId : null;
535
647
  const maxChars = Math.min(
536
648
  20000,
537
649
  Math.max(1000, Number.parseInt(String(req.query.maxChars || '12000'), 10) || 12000)
@@ -547,7 +659,8 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
547
659
  if (
548
660
  session?.provider === provider &&
549
661
  !session?.isPlainShell &&
550
- (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath)
662
+ (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath) &&
663
+ (!requestedLaunchId || session.hermesLaunchId === requestedLaunchId)
551
664
  ) {
552
665
  if (!matchedSession || (session.updatedAt || 0) > (matchedSession.updatedAt || 0)) {
553
666
  matchedSession = session;
@@ -560,19 +673,24 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
560
673
  active: false,
561
674
  provider,
562
675
  projectPath: requestedProjectPath,
676
+ launchId: requestedLaunchId,
563
677
  output: '',
564
678
  message: 'No active provider terminal session found for this project.',
565
679
  });
566
680
  }
567
681
 
568
682
  const rawOutput = matchedSession.buffer.join('').slice(-maxChars);
683
+ const output = stripAnsiSequences(rawOutput);
684
+ const terminalState = resolveProviderTerminalState(matchedSession, provider, output);
569
685
  res.json({
570
686
  active: true,
571
687
  provider,
572
688
  projectPath: path.resolve(matchedSession.projectPath || os.homedir()),
573
689
  sessionId: matchedSession.sessionId || null,
690
+ launchId: matchedSession.hermesLaunchId || null,
574
691
  updatedAt: matchedSession.updatedAt || null,
575
- output: stripAnsiSequences(rawOutput),
692
+ ...terminalState,
693
+ output,
576
694
  });
577
695
  });
578
696
 
@@ -2268,6 +2386,9 @@ function handleShellConnection(ws, request) {
2268
2386
  const startupInput = typeof data.startupInput === 'string' && data.startupInput.trim()
2269
2387
  ? data.startupInput.trim()
2270
2388
  : null;
2389
+ const hermesLaunchId = Number.isFinite(Number(data.hermesLaunchId)) && Number(data.hermesLaunchId) > 0
2390
+ ? Number(data.hermesLaunchId)
2391
+ : null;
2271
2392
  const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
2272
2393
  const isHermesCliLaunch = isPlainShell && isHermesCliCommand(initialCommand);
2273
2394
  const forceNewSession = Boolean(data.forceNewSession);
@@ -2328,29 +2449,33 @@ function handleShellConnection(ws, request) {
2328
2449
 
2329
2450
  const existingSession = (isLoginCommand || forceNewSession) ? null : ptySessionsMap.get(ptySessionKey);
2330
2451
  if (existingSession) {
2331
- console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
2332
- shellProcess = existingSession.pty;
2333
-
2334
- clearTimeout(existingSession.timeoutId);
2452
+ if (!existingSession.pty || existingSession.lifecycleState === 'completed' || existingSession.lifecycleState === 'failed') {
2453
+ ptySessionsMap.delete(ptySessionKey);
2454
+ } else {
2455
+ console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
2456
+ shellProcess = existingSession.pty;
2457
+
2458
+ clearTimeout(existingSession.timeoutId);
2459
+
2460
+ ws.send(JSON.stringify({
2461
+ type: 'output',
2462
+ data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
2463
+ }));
2464
+
2465
+ if (existingSession.buffer && existingSession.buffer.length > 0) {
2466
+ console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
2467
+ existingSession.buffer.forEach(bufferedData => {
2468
+ ws.send(JSON.stringify({
2469
+ type: 'output',
2470
+ data: bufferedData
2471
+ }));
2472
+ });
2473
+ }
2335
2474
 
2336
- ws.send(JSON.stringify({
2337
- type: 'output',
2338
- data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
2339
- }));
2475
+ existingSession.ws = ws;
2340
2476
 
2341
- if (existingSession.buffer && existingSession.buffer.length > 0) {
2342
- console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
2343
- existingSession.buffer.forEach(bufferedData => {
2344
- ws.send(JSON.stringify({
2345
- type: 'output',
2346
- data: bufferedData
2347
- }));
2348
- });
2477
+ return;
2349
2478
  }
2350
-
2351
- existingSession.ws = ws;
2352
-
2353
- return;
2354
2479
  }
2355
2480
 
2356
2481
  console.log('[INFO] Starting shell in:', projectPath);
@@ -2548,8 +2673,13 @@ function handleShellConnection(ws, request) {
2548
2673
  timeoutId: null,
2549
2674
  projectPath,
2550
2675
  sessionId,
2676
+ hermesLaunchId,
2551
2677
  provider,
2552
2678
  isPlainShell,
2679
+ lifecycleState: 'running',
2680
+ exitCode: null,
2681
+ exitSignal: null,
2682
+ completedAt: null,
2553
2683
  keepAliveUntilExit: false,
2554
2684
  updatedAt: Date.now(),
2555
2685
  });
@@ -2560,12 +2690,7 @@ function handleShellConnection(ws, request) {
2560
2690
  if (!session) return;
2561
2691
  session.updatedAt = Date.now();
2562
2692
 
2563
- if (session.buffer.length < 5000) {
2564
- session.buffer.push(data);
2565
- } else {
2566
- session.buffer.shift();
2567
- session.buffer.push(data);
2568
- }
2693
+ appendPtySessionBuffer(session, data);
2569
2694
 
2570
2695
  if (session.ws && session.ws.readyState === WebSocket.OPEN) {
2571
2696
  let outputData = data;
@@ -2624,16 +2749,36 @@ function handleShellConnection(ws, request) {
2624
2749
  shellProcess.onExit((exitCode) => {
2625
2750
  console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
2626
2751
  const session = ptySessionsMap.get(ptySessionKey);
2752
+ const exitMessage = `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`;
2753
+ if (session) {
2754
+ session.lifecycleState = exitCode.exitCode === 0 && !exitCode.signal ? 'completed' : 'failed';
2755
+ session.exitCode = typeof exitCode.exitCode === 'number' ? exitCode.exitCode : null;
2756
+ session.exitSignal = exitCode.signal || null;
2757
+ session.completedAt = new Date().toISOString();
2758
+ session.updatedAt = Date.now();
2759
+ session.pty = null;
2760
+ appendPtySessionBuffer(session, exitMessage);
2761
+ }
2627
2762
  if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
2628
2763
  session.ws.send(JSON.stringify({
2629
2764
  type: 'output',
2630
- data: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
2765
+ data: exitMessage
2631
2766
  }));
2632
2767
  }
2633
2768
  if (session && session.timeoutId) {
2634
2769
  clearTimeout(session.timeoutId);
2635
2770
  }
2636
- ptySessionsMap.delete(ptySessionKey);
2771
+ if (session) {
2772
+ session.ws = null;
2773
+ session.timeoutId = setTimeout(() => {
2774
+ const current = ptySessionsMap.get(ptySessionKey);
2775
+ if (current && current.lifecycleState !== 'running') {
2776
+ ptySessionsMap.delete(ptySessionKey);
2777
+ }
2778
+ }, COMPLETED_PTY_SESSION_TTL);
2779
+ } else {
2780
+ ptySessionsMap.delete(ptySessionKey);
2781
+ }
2637
2782
  shellProcess = null;
2638
2783
  });
2639
2784
 
@@ -3430,6 +3575,10 @@ async function startServer() {
3430
3575
  console.log(`${c.dim('[INFO]')} Port-access helper failed: ${err?.message || err}`);
3431
3576
  }
3432
3577
 
3578
+ restoreRequestedTunnel({ port: Number(SERVER_PORT) }).catch((err) => {
3579
+ console.warn('[external-access] tunnel restore failed:', err?.message || err);
3580
+ });
3581
+
3433
3582
  console.log(`${c.tip('[TIP]')} Run "pixcode status" for full configuration details`);
3434
3583
  console.log('');
3435
3584
 
@@ -96,7 +96,7 @@ router.delete('/upnp', (_req, res) => {
96
96
  router.post('/tunnel', async (req, res) => {
97
97
  const port = resolveServerPort();
98
98
  try {
99
- const state = await startTunnel({ port });
99
+ const state = await startTunnel({ port, persistPreference: true });
100
100
  res.json({ success: true, tunnel: state });
101
101
  } catch (error) {
102
102
  console.error('Tunnel start failed:', error);
@@ -114,7 +114,7 @@ router.post('/tunnel', async (req, res) => {
114
114
 
115
115
  router.delete('/tunnel', async (req, res) => {
116
116
  try {
117
- const state = await stopTunnel();
117
+ const state = await stopTunnel({ persistPreference: true });
118
118
  res.json({ success: true, tunnel: state });
119
119
  } catch (error) {
120
120
  console.error('Tunnel stop failed:', error);