@pixelbyte-software/pixcode 1.50.4 → 1.50.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.
- package/dist/assets/{index-DYQjBZrd.js → index-DVEXTVKy.js} +3 -3
- package/dist/index.html +1 -1
- package/dist-server/server/index.js +106 -35
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.js +2 -0
- package/dist-server/server/modules/orchestration/hermes/hermes.routes.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/configure-pixcode-mcp.mjs +8 -5
- package/scripts/hermes/pixcode-mcp-server.mjs +85 -14
- package/scripts/smoke/hermes-api-install.mjs +2 -1
- package/scripts/smoke/hermes-mcp-pixcode-roundtrip.mjs +10 -3
- package/scripts/smoke/hermes-rest-codex-launch.mjs +4 -2
- package/scripts/smoke/hermes-settings-commands.mjs +71 -6
- package/scripts/smoke/pixcode-workbench-1-48.mjs +3 -1
- package/scripts/smoke/tunnel-persistence.mjs +56 -0
- package/scripts/smoke/vscode-workbench-polish.mjs +8 -2
- package/server/index.js +110 -38
- package/server/modules/orchestration/hermes/hermes.routes.ts +3 -0
- package/server/routes/network.js +2 -2
- package/server/services/external-access.js +199 -11
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';
|
|
@@ -350,11 +352,12 @@ function detectProviderTerminalState(provider, output) {
|
|
|
350
352
|
};
|
|
351
353
|
}
|
|
352
354
|
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
+
const lastWeakBusy = getLastRegexMatchIndex(cleanOutput, /(?:^|\n)\s*[•*]\s*(?:Working|Running|Thinking)\b/giu);
|
|
356
|
+
const lastStrongBusy = Math.max(
|
|
355
357
|
getLastRegexMatchIndex(cleanOutput, /\bWorking\s*\([^)]*esc to interrupt[^)]*\)/giu),
|
|
356
358
|
getLastRegexMatchIndex(cleanOutput, /\bmsg=interrupt\b/giu),
|
|
357
359
|
);
|
|
360
|
+
const lastBusy = Math.max(lastWeakBusy, lastStrongBusy);
|
|
358
361
|
|
|
359
362
|
if (provider === 'codex') {
|
|
360
363
|
const lastPrompt = Math.max(
|
|
@@ -362,20 +365,20 @@ function detectProviderTerminalState(provider, output) {
|
|
|
362
365
|
getLastRegexMatchIndex(cleanOutput, /(?:^|\n)\s*❯(?:\s|$)/gu),
|
|
363
366
|
);
|
|
364
367
|
|
|
365
|
-
if (
|
|
366
|
-
const isBusy =
|
|
368
|
+
if (lastPrompt >= 0) {
|
|
369
|
+
const isBusy = lastStrongBusy > lastPrompt;
|
|
367
370
|
return {
|
|
368
371
|
terminalState: isBusy ? 'busy' : 'idle',
|
|
369
372
|
isBusy,
|
|
370
|
-
terminalStateReason: isBusy ? '
|
|
373
|
+
terminalStateReason: isBusy ? 'codex_strong_busy_marker_after_prompt' : 'codex_prompt_after_busy_marker',
|
|
371
374
|
};
|
|
372
375
|
}
|
|
373
376
|
|
|
374
|
-
if (
|
|
377
|
+
if (lastBusy >= 0) {
|
|
375
378
|
return {
|
|
376
|
-
terminalState: '
|
|
377
|
-
isBusy:
|
|
378
|
-
terminalStateReason: '
|
|
379
|
+
terminalState: 'busy',
|
|
380
|
+
isBusy: true,
|
|
381
|
+
terminalStateReason: 'codex_busy_marker_without_prompt',
|
|
379
382
|
};
|
|
380
383
|
}
|
|
381
384
|
}
|
|
@@ -395,6 +398,43 @@ function detectProviderTerminalState(provider, output) {
|
|
|
395
398
|
};
|
|
396
399
|
}
|
|
397
400
|
|
|
401
|
+
function resolveProviderTerminalState(session, provider, output) {
|
|
402
|
+
if (session?.lifecycleState === 'completed' || session?.lifecycleState === 'failed' || session?.lifecycleState === 'exited') {
|
|
403
|
+
const exitCode = typeof session.exitCode === 'number' ? session.exitCode : null;
|
|
404
|
+
const terminalFailed = exitCode !== null ? exitCode !== 0 : Boolean(session.exitSignal);
|
|
405
|
+
return {
|
|
406
|
+
terminalState: terminalFailed ? 'failed' : 'completed',
|
|
407
|
+
lifecycleState: session.lifecycleState,
|
|
408
|
+
isBusy: false,
|
|
409
|
+
terminalFailed,
|
|
410
|
+
exitCode,
|
|
411
|
+
exitSignal: session.exitSignal || null,
|
|
412
|
+
completedAt: session.completedAt || null,
|
|
413
|
+
terminalStateReason: terminalFailed ? 'pty_failed' : 'pty_completed',
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const detected = detectProviderTerminalState(provider, output);
|
|
418
|
+
return {
|
|
419
|
+
...detected,
|
|
420
|
+
lifecycleState: session?.lifecycleState || 'running',
|
|
421
|
+
terminalFailed: false,
|
|
422
|
+
exitCode: null,
|
|
423
|
+
exitSignal: null,
|
|
424
|
+
completedAt: null,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function appendPtySessionBuffer(session, data) {
|
|
429
|
+
if (!session) return;
|
|
430
|
+
if (session.buffer.length < 5000) {
|
|
431
|
+
session.buffer.push(data);
|
|
432
|
+
} else {
|
|
433
|
+
session.buffer.shift();
|
|
434
|
+
session.buffer.push(data);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
398
438
|
function normalizeShellPermissionMode(value) {
|
|
399
439
|
return typeof value === 'string' ? value.trim() : '';
|
|
400
440
|
}
|
|
@@ -642,7 +682,7 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, (req, res) =>
|
|
|
642
682
|
|
|
643
683
|
const rawOutput = matchedSession.buffer.join('').slice(-maxChars);
|
|
644
684
|
const output = stripAnsiSequences(rawOutput);
|
|
645
|
-
const terminalState =
|
|
685
|
+
const terminalState = resolveProviderTerminalState(matchedSession, provider, output);
|
|
646
686
|
res.json({
|
|
647
687
|
active: true,
|
|
648
688
|
provider,
|
|
@@ -2410,29 +2450,33 @@ function handleShellConnection(ws, request) {
|
|
|
2410
2450
|
|
|
2411
2451
|
const existingSession = (isLoginCommand || forceNewSession) ? null : ptySessionsMap.get(ptySessionKey);
|
|
2412
2452
|
if (existingSession) {
|
|
2413
|
-
|
|
2414
|
-
|
|
2453
|
+
if (!existingSession.pty || existingSession.lifecycleState === 'completed' || existingSession.lifecycleState === 'failed') {
|
|
2454
|
+
ptySessionsMap.delete(ptySessionKey);
|
|
2455
|
+
} else {
|
|
2456
|
+
console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
|
|
2457
|
+
shellProcess = existingSession.pty;
|
|
2458
|
+
|
|
2459
|
+
clearTimeout(existingSession.timeoutId);
|
|
2460
|
+
|
|
2461
|
+
ws.send(JSON.stringify({
|
|
2462
|
+
type: 'output',
|
|
2463
|
+
data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
|
|
2464
|
+
}));
|
|
2465
|
+
|
|
2466
|
+
if (existingSession.buffer && existingSession.buffer.length > 0) {
|
|
2467
|
+
console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
|
|
2468
|
+
existingSession.buffer.forEach(bufferedData => {
|
|
2469
|
+
ws.send(JSON.stringify({
|
|
2470
|
+
type: 'output',
|
|
2471
|
+
data: bufferedData
|
|
2472
|
+
}));
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2415
2475
|
|
|
2416
|
-
|
|
2476
|
+
existingSession.ws = ws;
|
|
2417
2477
|
|
|
2418
|
-
|
|
2419
|
-
type: 'output',
|
|
2420
|
-
data: `\x1b[36m[Reconnected to existing session]\x1b[0m\r\n`
|
|
2421
|
-
}));
|
|
2422
|
-
|
|
2423
|
-
if (existingSession.buffer && existingSession.buffer.length > 0) {
|
|
2424
|
-
console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
|
|
2425
|
-
existingSession.buffer.forEach(bufferedData => {
|
|
2426
|
-
ws.send(JSON.stringify({
|
|
2427
|
-
type: 'output',
|
|
2428
|
-
data: bufferedData
|
|
2429
|
-
}));
|
|
2430
|
-
});
|
|
2478
|
+
return;
|
|
2431
2479
|
}
|
|
2432
|
-
|
|
2433
|
-
existingSession.ws = ws;
|
|
2434
|
-
|
|
2435
|
-
return;
|
|
2436
2480
|
}
|
|
2437
2481
|
|
|
2438
2482
|
console.log('[INFO] Starting shell in:', projectPath);
|
|
@@ -2633,6 +2677,10 @@ function handleShellConnection(ws, request) {
|
|
|
2633
2677
|
hermesLaunchId,
|
|
2634
2678
|
provider,
|
|
2635
2679
|
isPlainShell,
|
|
2680
|
+
lifecycleState: 'running',
|
|
2681
|
+
exitCode: null,
|
|
2682
|
+
exitSignal: null,
|
|
2683
|
+
completedAt: null,
|
|
2636
2684
|
keepAliveUntilExit: false,
|
|
2637
2685
|
updatedAt: Date.now(),
|
|
2638
2686
|
});
|
|
@@ -2643,12 +2691,7 @@ function handleShellConnection(ws, request) {
|
|
|
2643
2691
|
if (!session) return;
|
|
2644
2692
|
session.updatedAt = Date.now();
|
|
2645
2693
|
|
|
2646
|
-
|
|
2647
|
-
session.buffer.push(data);
|
|
2648
|
-
} else {
|
|
2649
|
-
session.buffer.shift();
|
|
2650
|
-
session.buffer.push(data);
|
|
2651
|
-
}
|
|
2694
|
+
appendPtySessionBuffer(session, data);
|
|
2652
2695
|
|
|
2653
2696
|
if (session.ws && session.ws.readyState === WebSocket.OPEN) {
|
|
2654
2697
|
let outputData = data;
|
|
@@ -2707,16 +2750,41 @@ function handleShellConnection(ws, request) {
|
|
|
2707
2750
|
shellProcess.onExit((exitCode) => {
|
|
2708
2751
|
console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
|
|
2709
2752
|
const session = ptySessionsMap.get(ptySessionKey);
|
|
2753
|
+
if (session?.pty && session.pty !== shellProcess) {
|
|
2754
|
+
console.log('↩️ Ignoring stale PTY exit for replacement session:', ptySessionKey);
|
|
2755
|
+
return;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
const exitMessage = `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`;
|
|
2759
|
+
if (session) {
|
|
2760
|
+
session.lifecycleState = exitCode.exitCode === 0 && !exitCode.signal ? 'completed' : 'failed';
|
|
2761
|
+
session.exitCode = typeof exitCode.exitCode === 'number' ? exitCode.exitCode : null;
|
|
2762
|
+
session.exitSignal = exitCode.signal || null;
|
|
2763
|
+
session.completedAt = new Date().toISOString();
|
|
2764
|
+
session.updatedAt = Date.now();
|
|
2765
|
+
session.pty = null;
|
|
2766
|
+
appendPtySessionBuffer(session, exitMessage);
|
|
2767
|
+
}
|
|
2710
2768
|
if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {
|
|
2711
2769
|
session.ws.send(JSON.stringify({
|
|
2712
2770
|
type: 'output',
|
|
2713
|
-
data:
|
|
2771
|
+
data: exitMessage
|
|
2714
2772
|
}));
|
|
2715
2773
|
}
|
|
2716
2774
|
if (session && session.timeoutId) {
|
|
2717
2775
|
clearTimeout(session.timeoutId);
|
|
2718
2776
|
}
|
|
2719
|
-
|
|
2777
|
+
if (session) {
|
|
2778
|
+
session.ws = null;
|
|
2779
|
+
session.timeoutId = setTimeout(() => {
|
|
2780
|
+
const current = ptySessionsMap.get(ptySessionKey);
|
|
2781
|
+
if (current && current.lifecycleState !== 'running') {
|
|
2782
|
+
ptySessionsMap.delete(ptySessionKey);
|
|
2783
|
+
}
|
|
2784
|
+
}, COMPLETED_PTY_SESSION_TTL);
|
|
2785
|
+
} else {
|
|
2786
|
+
ptySessionsMap.delete(ptySessionKey);
|
|
2787
|
+
}
|
|
2720
2788
|
shellProcess = null;
|
|
2721
2789
|
});
|
|
2722
2790
|
|
|
@@ -3513,6 +3581,10 @@ async function startServer() {
|
|
|
3513
3581
|
console.log(`${c.dim('[INFO]')} Port-access helper failed: ${err?.message || err}`);
|
|
3514
3582
|
}
|
|
3515
3583
|
|
|
3584
|
+
restoreRequestedTunnel({ port: Number(SERVER_PORT) }).catch((err) => {
|
|
3585
|
+
console.warn('[external-access] tunnel restore failed:', err?.message || err);
|
|
3586
|
+
});
|
|
3587
|
+
|
|
3516
3588
|
console.log(`${c.tip('[TIP]')} Run "pixcode status" for full configuration details`);
|
|
3517
3589
|
console.log('');
|
|
3518
3590
|
|
|
@@ -30,6 +30,7 @@ type HermesTerminalLaunchEvent = {
|
|
|
30
30
|
projectPath: string | null;
|
|
31
31
|
prompt: string | null;
|
|
32
32
|
startupInput: string | null;
|
|
33
|
+
forceNewSession: boolean;
|
|
33
34
|
permissionMode: string | null;
|
|
34
35
|
skipPermissions: boolean;
|
|
35
36
|
bypassPermissions: boolean;
|
|
@@ -473,6 +474,7 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
|
|
|
473
474
|
const prompt = readTrimmedString(body.prompt ?? body.reason);
|
|
474
475
|
const requestedStartupInput = readTrimmedString(body.startupInput ?? body.input);
|
|
475
476
|
const startupInput = requestedStartupInput ?? (isLegacyPromptLikelyStartupInput(prompt) ? prompt : null);
|
|
477
|
+
const forceNewSession = readBoolean(body.forceNewSession ?? body.newSession ?? body.freshSession);
|
|
476
478
|
const bypassPermissions = readBoolean(body.bypassPermissions);
|
|
477
479
|
const skipPermissions = readBoolean(body.skipPermissions) || bypassPermissions;
|
|
478
480
|
const requestedPermissionMode = readTrimmedString(body.permissionMode);
|
|
@@ -484,6 +486,7 @@ export function createHermesRouter(options: HermesRouterOptions = {}): Router {
|
|
|
484
486
|
projectPath,
|
|
485
487
|
prompt,
|
|
486
488
|
startupInput,
|
|
489
|
+
forceNewSession,
|
|
487
490
|
permissionMode,
|
|
488
491
|
skipPermissions,
|
|
489
492
|
bypassPermissions,
|
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);
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* External-access service.
|
|
@@ -31,16 +34,62 @@ export const getUpnpState = () => UPNP_UNAVAILABLE;
|
|
|
31
34
|
// stops the previous one to avoid dangling child processes.
|
|
32
35
|
// ============================================================================
|
|
33
36
|
|
|
37
|
+
export const TUNNEL_PERSISTENCE_PATH = process.env.PIXCODE_TUNNEL_STATE_PATH
|
|
38
|
+
|| path.join(os.homedir(), '.pixcode', 'external-access.json');
|
|
39
|
+
|
|
34
40
|
let tunnelProc = null;
|
|
41
|
+
let suppressNextTunnelRestore = false;
|
|
42
|
+
let restoreTimer = null;
|
|
43
|
+
let restoreInFlight = null;
|
|
35
44
|
let tunnelState = {
|
|
36
45
|
running: false,
|
|
37
46
|
binary: null, // 'cloudflared' | 'ngrok'
|
|
38
47
|
url: null,
|
|
39
48
|
error: null,
|
|
40
49
|
installHint: null,
|
|
50
|
+
desired: false,
|
|
51
|
+
restoring: false,
|
|
41
52
|
log: [],
|
|
42
53
|
};
|
|
43
54
|
|
|
55
|
+
const DEFAULT_TUNNEL_PREFERENCE = Object.freeze({
|
|
56
|
+
desired: false,
|
|
57
|
+
port: null,
|
|
58
|
+
provider: null,
|
|
59
|
+
lastUrl: null,
|
|
60
|
+
lastStartedAt: null,
|
|
61
|
+
lastStoppedAt: null,
|
|
62
|
+
updatedAt: null,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const readTunnelPreference = async () => {
|
|
66
|
+
try {
|
|
67
|
+
const raw = await fs.readFile(TUNNEL_PERSISTENCE_PATH, 'utf8');
|
|
68
|
+
const parsed = JSON.parse(raw);
|
|
69
|
+
return {
|
|
70
|
+
...DEFAULT_TUNNEL_PREFERENCE,
|
|
71
|
+
...(parsed && typeof parsed === 'object' ? parsed : {}),
|
|
72
|
+
};
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error?.code !== 'ENOENT') {
|
|
75
|
+
console.warn('[external-access] Failed to read tunnel preference:', error?.message || error);
|
|
76
|
+
}
|
|
77
|
+
return { ...DEFAULT_TUNNEL_PREFERENCE };
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const persistTunnelPreference = async (patch) => {
|
|
82
|
+
const current = await readTunnelPreference();
|
|
83
|
+
const next = {
|
|
84
|
+
...current,
|
|
85
|
+
...patch,
|
|
86
|
+
updatedAt: new Date().toISOString(),
|
|
87
|
+
};
|
|
88
|
+
await fs.mkdir(path.dirname(TUNNEL_PERSISTENCE_PATH), { recursive: true });
|
|
89
|
+
await fs.writeFile(TUNNEL_PERSISTENCE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
90
|
+
return next;
|
|
91
|
+
};
|
|
92
|
+
|
|
44
93
|
const appendLog = (line) => {
|
|
45
94
|
// Tunnels can be noisy. Cap the tail we retain so a long-running tunnel
|
|
46
95
|
// doesn't grow the log into an OOM risk.
|
|
@@ -94,17 +143,34 @@ const extractUrl = (binary, text) => {
|
|
|
94
143
|
return null;
|
|
95
144
|
};
|
|
96
145
|
|
|
97
|
-
export const startTunnel = async ({ port }) => {
|
|
146
|
+
export const startTunnel = async ({ port, persistPreference = false, restoring = false } = {}) => {
|
|
98
147
|
if (tunnelProc) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
148
|
+
if (persistPreference) {
|
|
149
|
+
await persistTunnelPreference({
|
|
150
|
+
desired: true,
|
|
151
|
+
port,
|
|
152
|
+
provider: tunnelState.binary,
|
|
153
|
+
lastUrl: tunnelState.url,
|
|
154
|
+
});
|
|
155
|
+
tunnelState = { ...tunnelState, desired: true, restoring: false };
|
|
156
|
+
}
|
|
157
|
+
return tunnelState;
|
|
102
158
|
}
|
|
159
|
+
suppressNextTunnelRestore = false;
|
|
103
160
|
|
|
104
161
|
const binary = await detectBinary();
|
|
105
162
|
if (!binary) {
|
|
106
163
|
const installHint = createTunnelInstallHint();
|
|
107
|
-
tunnelState = {
|
|
164
|
+
tunnelState = {
|
|
165
|
+
running: false,
|
|
166
|
+
binary: null,
|
|
167
|
+
url: null,
|
|
168
|
+
error: 'No tunnel binary found',
|
|
169
|
+
installHint,
|
|
170
|
+
desired: Boolean(persistPreference || restoring),
|
|
171
|
+
restoring,
|
|
172
|
+
log: [],
|
|
173
|
+
};
|
|
108
174
|
const err = new Error('No tunnel binary found (tried cloudflared, ngrok)');
|
|
109
175
|
err.code = 'ENOENT_TUNNEL';
|
|
110
176
|
err.installHint = installHint;
|
|
@@ -114,7 +180,16 @@ export const startTunnel = async ({ port }) => {
|
|
|
114
180
|
const args = buildTunnelArgs(binary, port);
|
|
115
181
|
const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
116
182
|
tunnelProc = child;
|
|
117
|
-
tunnelState = {
|
|
183
|
+
tunnelState = {
|
|
184
|
+
running: true,
|
|
185
|
+
binary,
|
|
186
|
+
url: null,
|
|
187
|
+
error: null,
|
|
188
|
+
installHint: null,
|
|
189
|
+
desired: Boolean(persistPreference || restoring),
|
|
190
|
+
restoring,
|
|
191
|
+
log: [],
|
|
192
|
+
};
|
|
118
193
|
|
|
119
194
|
const handleChunk = (chunk) => {
|
|
120
195
|
const text = chunk.toString();
|
|
@@ -135,8 +210,26 @@ export const startTunnel = async ({ port }) => {
|
|
|
135
210
|
url: null,
|
|
136
211
|
error: code === 0 ? null : `Tunnel exited with code ${code}`,
|
|
137
212
|
installHint: null,
|
|
213
|
+
desired: tunnelState.desired,
|
|
214
|
+
restoring: false,
|
|
138
215
|
log: tunnelState.log,
|
|
139
216
|
};
|
|
217
|
+
|
|
218
|
+
if (suppressNextTunnelRestore) {
|
|
219
|
+
suppressNextTunnelRestore = false;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
void readTunnelPreference().then((preference) => {
|
|
224
|
+
if (!preference.desired) return;
|
|
225
|
+
if (restoreTimer) clearTimeout(restoreTimer);
|
|
226
|
+
restoreTimer = setTimeout(() => {
|
|
227
|
+
restoreTimer = null;
|
|
228
|
+
restoreRequestedTunnel({ port: Number(preference.port || port) || port }).catch((error) => {
|
|
229
|
+
console.warn('[external-access] Tunnel restore failed after exit:', error?.message || error);
|
|
230
|
+
});
|
|
231
|
+
}, 3000);
|
|
232
|
+
});
|
|
140
233
|
});
|
|
141
234
|
|
|
142
235
|
// Wait up to 15s for the public URL to appear in the log. We don't block
|
|
@@ -144,7 +237,19 @@ export const startTunnel = async ({ port }) => {
|
|
|
144
237
|
// a clear failure instead of a spinner that never resolves.
|
|
145
238
|
const start = Date.now();
|
|
146
239
|
while (Date.now() - start < 15000) {
|
|
147
|
-
if (tunnelState.url)
|
|
240
|
+
if (tunnelState.url) {
|
|
241
|
+
if (persistPreference || restoring) {
|
|
242
|
+
await persistTunnelPreference({
|
|
243
|
+
desired: true,
|
|
244
|
+
port,
|
|
245
|
+
provider: binary,
|
|
246
|
+
lastUrl: tunnelState.url,
|
|
247
|
+
lastStartedAt: new Date().toISOString(),
|
|
248
|
+
});
|
|
249
|
+
tunnelState = { ...tunnelState, desired: true, restoring: false };
|
|
250
|
+
}
|
|
251
|
+
return tunnelState;
|
|
252
|
+
}
|
|
148
253
|
if (!tunnelProc) break; // process died early
|
|
149
254
|
|
|
150
255
|
await new Promise((r) => setTimeout(r, 250));
|
|
@@ -152,18 +257,40 @@ export const startTunnel = async ({ port }) => {
|
|
|
152
257
|
|
|
153
258
|
if (!tunnelState.url) {
|
|
154
259
|
// If we never captured a URL, kill the child so we don't leak it.
|
|
260
|
+
suppressNextTunnelRestore = true;
|
|
155
261
|
try { child.kill(); } catch { /* ignore */ }
|
|
156
262
|
tunnelProc = null;
|
|
157
|
-
tunnelState = { ...tunnelState, running: false, error: 'Tunnel did not report a public URL', installHint: null };
|
|
263
|
+
tunnelState = { ...tunnelState, running: false, error: 'Tunnel did not report a public URL', installHint: null, restoring: false };
|
|
158
264
|
throw new Error(tunnelState.error);
|
|
159
265
|
}
|
|
160
266
|
|
|
161
267
|
return tunnelState;
|
|
162
268
|
};
|
|
163
269
|
|
|
164
|
-
export const stopTunnel = async () => {
|
|
270
|
+
export const stopTunnel = async ({ persistPreference = true } = {}) => {
|
|
271
|
+
suppressNextTunnelRestore = Boolean(tunnelProc);
|
|
272
|
+
if (restoreTimer) {
|
|
273
|
+
clearTimeout(restoreTimer);
|
|
274
|
+
restoreTimer = null;
|
|
275
|
+
}
|
|
276
|
+
if (persistPreference) {
|
|
277
|
+
await persistTunnelPreference({
|
|
278
|
+
desired: false,
|
|
279
|
+
lastStoppedAt: new Date().toISOString(),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
165
282
|
if (!tunnelProc) {
|
|
166
|
-
|
|
283
|
+
suppressNextTunnelRestore = false;
|
|
284
|
+
tunnelState = {
|
|
285
|
+
running: false,
|
|
286
|
+
binary: null,
|
|
287
|
+
url: null,
|
|
288
|
+
error: null,
|
|
289
|
+
installHint: null,
|
|
290
|
+
desired: false,
|
|
291
|
+
restoring: false,
|
|
292
|
+
log: [],
|
|
293
|
+
};
|
|
167
294
|
return tunnelState;
|
|
168
295
|
}
|
|
169
296
|
try {
|
|
@@ -172,10 +299,71 @@ export const stopTunnel = async () => {
|
|
|
172
299
|
// already dead
|
|
173
300
|
}
|
|
174
301
|
tunnelProc = null;
|
|
175
|
-
tunnelState = {
|
|
302
|
+
tunnelState = {
|
|
303
|
+
running: false,
|
|
304
|
+
binary: null,
|
|
305
|
+
url: null,
|
|
306
|
+
error: null,
|
|
307
|
+
installHint: null,
|
|
308
|
+
desired: false,
|
|
309
|
+
restoring: false,
|
|
310
|
+
log: [],
|
|
311
|
+
};
|
|
176
312
|
return tunnelState;
|
|
177
313
|
};
|
|
178
314
|
|
|
315
|
+
export const restoreRequestedTunnel = async ({ port } = {}) => {
|
|
316
|
+
if (restoreInFlight) return restoreInFlight;
|
|
317
|
+
|
|
318
|
+
restoreInFlight = (async () => {
|
|
319
|
+
const preference = await readTunnelPreference();
|
|
320
|
+
if (!preference.desired) {
|
|
321
|
+
tunnelState = { ...tunnelState, desired: false, restoring: false };
|
|
322
|
+
return tunnelState;
|
|
323
|
+
}
|
|
324
|
+
if (tunnelProc) {
|
|
325
|
+
tunnelState = { ...tunnelState, desired: true, restoring: false };
|
|
326
|
+
return tunnelState;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const restorePort = Number(port || preference.port);
|
|
330
|
+
if (!Number.isFinite(restorePort) || restorePort <= 0) {
|
|
331
|
+
tunnelState = {
|
|
332
|
+
...tunnelState,
|
|
333
|
+
running: false,
|
|
334
|
+
desired: true,
|
|
335
|
+
restoring: false,
|
|
336
|
+
error: 'Tunnel restore skipped: no valid server port',
|
|
337
|
+
};
|
|
338
|
+
return tunnelState;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
return await startTunnel({
|
|
343
|
+
port: restorePort,
|
|
344
|
+
persistPreference: true,
|
|
345
|
+
restoring: true,
|
|
346
|
+
});
|
|
347
|
+
} catch (error) {
|
|
348
|
+
tunnelState = {
|
|
349
|
+
...tunnelState,
|
|
350
|
+
running: false,
|
|
351
|
+
desired: true,
|
|
352
|
+
restoring: false,
|
|
353
|
+
error: `Tunnel restore failed: ${error?.message || error}`,
|
|
354
|
+
installHint: error?.installHint || tunnelState.installHint || null,
|
|
355
|
+
};
|
|
356
|
+
return tunnelState;
|
|
357
|
+
}
|
|
358
|
+
})();
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
return await restoreInFlight;
|
|
362
|
+
} finally {
|
|
363
|
+
restoreInFlight = null;
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
179
367
|
export const getTunnelState = () => tunnelState;
|
|
180
368
|
|
|
181
369
|
// Explicit cleanup so the server process can shut down without leaking the
|