@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/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 lastBusy = Math.max(
354
- getLastRegexMatchIndex(cleanOutput, /(?:^|\n)\s*[•*]\s*(?:Working|Running|Thinking)\b/giu),
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 (lastBusy >= 0) {
366
- const isBusy = lastPrompt <= lastBusy;
368
+ if (lastPrompt >= 0) {
369
+ const isBusy = lastStrongBusy > lastPrompt;
367
370
  return {
368
371
  terminalState: isBusy ? 'busy' : 'idle',
369
372
  isBusy,
370
- terminalStateReason: isBusy ? 'codex_busy_marker_after_prompt' : 'codex_prompt_after_busy_marker',
373
+ terminalStateReason: isBusy ? 'codex_strong_busy_marker_after_prompt' : 'codex_prompt_after_busy_marker',
371
374
  };
372
375
  }
373
376
 
374
- if (lastPrompt >= 0 && /(?:Initialized|Baseline check passed|I did not modify files|Use \/skills)/iu.test(cleanOutput)) {
377
+ if (lastBusy >= 0) {
375
378
  return {
376
- terminalState: 'idle',
377
- isBusy: false,
378
- terminalStateReason: 'codex_idle_prompt',
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 = detectProviderTerminalState(provider, output);
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
- console.log('♻️ Reconnecting to existing PTY session:', ptySessionKey);
2414
- shellProcess = existingSession.pty;
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
- clearTimeout(existingSession.timeoutId);
2476
+ existingSession.ws = ws;
2417
2477
 
2418
- ws.send(JSON.stringify({
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
- if (session.buffer.length < 5000) {
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: `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`
2771
+ data: exitMessage
2714
2772
  }));
2715
2773
  }
2716
2774
  if (session && session.timeoutId) {
2717
2775
  clearTimeout(session.timeoutId);
2718
2776
  }
2719
- ptySessionsMap.delete(ptySessionKey);
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,
@@ -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
- // Already running — tell the caller to stop it first rather than silently
100
- // replacing, which would orphan the old child and lie about state.
101
- throw new Error('Tunnel already running; stop it first');
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 = { running: false, binary: null, url: null, error: 'No tunnel binary found', installHint, log: [] };
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 = { running: true, binary, url: null, error: null, installHint: null, log: [] };
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) return tunnelState;
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
- tunnelState = { running: false, binary: null, url: null, error: null, installHint: null, log: [] };
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 = { running: false, binary: null, url: null, error: null, installHint: null, log: [] };
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