@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.
- package/dist/assets/{index-DpdiWohD.js → index-BSxc8Vid.js} +91 -91
- package/dist/index.html +1 -1
- package/dist-server/server/index.js +160 -28
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/routes/network.js +2 -2
- package/dist-server/server/routes/network.js.map +1 -1
- package/dist-server/server/services/external-access.js +193 -11
- package/dist-server/server/services/external-access.js.map +1 -1
- package/package.json +1 -1
- package/scripts/hermes/pixcode-mcp-server.mjs +148 -14
- package/scripts/smoke/hermes-mcp-pixcode-roundtrip.mjs +69 -3
- package/scripts/smoke/hermes-rest-codex-launch.mjs +14 -0
- package/scripts/smoke/hermes-settings-commands.mjs +60 -0
- package/scripts/smoke/tunnel-persistence.mjs +56 -0
- package/server/index.js +179 -30
- package/server/routes/network.js +2 -2
- package/server/services/external-access.js +199 -11
|
@@ -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
|
-
|
|
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
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
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
|
-
|
|
2337
|
-
type: 'output',
|
|
2338
|
-
data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
|
|
2339
|
-
}));
|
|
2475
|
+
existingSession.ws = ws;
|
|
2340
2476
|
|
|
2341
|
-
|
|
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
|
-
|
|
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:
|
|
2765
|
+
data: exitMessage
|
|
2631
2766
|
}));
|
|
2632
2767
|
}
|
|
2633
2768
|
if (session && session.timeoutId) {
|
|
2634
2769
|
clearTimeout(session.timeoutId);
|
|
2635
2770
|
}
|
|
2636
|
-
|
|
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
|
|
package/server/routes/network.js
CHANGED
|
@@ -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);
|