@atercates/claude-deck 0.2.15 → 0.2.16

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,57 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { isCloudflaredInstalled, startTunnel, stopTunnel } from "@/lib/tunnels";
3
+
4
+ export async function POST(
5
+ _request: NextRequest,
6
+ { params }: { params: Promise<{ port: string }> }
7
+ ) {
8
+ try {
9
+ const { port: portStr } = await params;
10
+ const port = parseInt(portStr, 10);
11
+ if (isNaN(port) || port <= 0) {
12
+ return NextResponse.json({ error: "Invalid port" }, { status: 400 });
13
+ }
14
+
15
+ const installed = await isCloudflaredInstalled();
16
+ if (!installed) {
17
+ return NextResponse.json(
18
+ {
19
+ error: "cloudflared is not installed",
20
+ code: "CLOUDFLARED_NOT_FOUND",
21
+ },
22
+ { status: 400 }
23
+ );
24
+ }
25
+
26
+ const result = await startTunnel(port);
27
+ return NextResponse.json(result);
28
+ } catch (error) {
29
+ console.error("Error starting tunnel:", error);
30
+ return NextResponse.json(
31
+ { error: "Failed to start tunnel" },
32
+ { status: 500 }
33
+ );
34
+ }
35
+ }
36
+
37
+ export async function DELETE(
38
+ _request: NextRequest,
39
+ { params }: { params: Promise<{ port: string }> }
40
+ ) {
41
+ try {
42
+ const { port: portStr } = await params;
43
+ const port = parseInt(portStr, 10);
44
+ if (isNaN(port) || port <= 0) {
45
+ return NextResponse.json({ error: "Invalid port" }, { status: 400 });
46
+ }
47
+
48
+ await stopTunnel(port);
49
+ return NextResponse.json({ ok: true });
50
+ } catch (error) {
51
+ console.error("Error stopping tunnel:", error);
52
+ return NextResponse.json(
53
+ { error: "Failed to stop tunnel" },
54
+ { status: 500 }
55
+ );
56
+ }
57
+ }
@@ -0,0 +1,18 @@
1
+ import { NextResponse } from "next/server";
2
+ import { isCloudflaredInstalled, getTunnelsList } from "@/lib/tunnels";
3
+
4
+ export async function GET() {
5
+ try {
6
+ const [installed, tunnels] = await Promise.all([
7
+ isCloudflaredInstalled(),
8
+ Promise.resolve(getTunnelsList()),
9
+ ]);
10
+ return NextResponse.json({ installed, tunnels });
11
+ } catch (error) {
12
+ console.error("Error getting tunnels:", error);
13
+ return NextResponse.json(
14
+ { error: "Failed to get tunnels" },
15
+ { status: 500 }
16
+ );
17
+ }
18
+ }
@@ -1,9 +1,25 @@
1
1
  "use client";
2
2
 
3
- import { useMemo, useState, useEffect } from "react";
3
+ import { useMemo, useState, useEffect, useCallback } from "react";
4
4
  import { cn } from "@/lib/utils";
5
- import { ChevronRight, Activity, AlertCircle, Moon } from "lucide-react";
5
+ import {
6
+ ChevronRight,
7
+ Activity,
8
+ AlertCircle,
9
+ Moon,
10
+ Globe,
11
+ Share2,
12
+ X,
13
+ Loader2,
14
+ Copy,
15
+ Check,
16
+ } from "lucide-react";
6
17
  import type { SessionStatus } from "@/components/views/types";
18
+ import {
19
+ useCloudflaredStatus,
20
+ useStartTunnel,
21
+ useStopTunnel,
22
+ } from "@/data/tunnels/queries";
7
23
 
8
24
  interface ActiveSessionsSectionProps {
9
25
  sessionStatuses: Record<string, SessionStatus>;
@@ -37,11 +53,13 @@ export function ActiveSessionsSection({
37
53
  const hasWaiting = activeSessions.some((s) => s.status === "waiting");
38
54
  const [expanded, setExpanded] = useState(hasWaiting);
39
55
 
40
- // Auto-expand when a session starts waiting
41
56
  useEffect(() => {
42
57
  if (hasWaiting) setExpanded(true);
43
58
  }, [hasWaiting]);
44
59
 
60
+ const { data: cfStatus } = useCloudflaredStatus();
61
+ const cloudflaredInstalled = cfStatus?.installed ?? false;
62
+
45
63
  if (activeSessions.length === 0) return null;
46
64
 
47
65
  return (
@@ -92,6 +110,19 @@ export function ActiveSessionsSection({
92
110
  {session.lastLine}
93
111
  </span>
94
112
  )}
113
+ {session.listeningPorts &&
114
+ session.listeningPorts.length > 0 && (
115
+ <div className="mt-0.5 space-y-0.5">
116
+ {session.listeningPorts.map((port) => (
117
+ <PortBadge
118
+ key={port}
119
+ port={port}
120
+ tunnelUrl={session.tunnelUrls?.[port]}
121
+ cloudflaredInstalled={cloudflaredInstalled}
122
+ />
123
+ ))}
124
+ </div>
125
+ )}
95
126
  </div>
96
127
  </button>
97
128
  ))}
@@ -101,6 +132,112 @@ export function ActiveSessionsSection({
101
132
  );
102
133
  }
103
134
 
135
+ function PortBadge({
136
+ port,
137
+ tunnelUrl,
138
+ cloudflaredInstalled,
139
+ }: {
140
+ port: number;
141
+ tunnelUrl?: string;
142
+ cloudflaredInstalled: boolean;
143
+ }) {
144
+ const startTunnel = useStartTunnel();
145
+ const stopTunnel = useStopTunnel();
146
+ const [copied, setCopied] = useState(false);
147
+
148
+ const isStarting = startTunnel.isPending;
149
+ const hasTunnel = !!tunnelUrl;
150
+
151
+ const handleShare = useCallback(
152
+ (e: React.MouseEvent) => {
153
+ e.stopPropagation();
154
+ if (hasTunnel) {
155
+ stopTunnel.mutate(port);
156
+ } else {
157
+ startTunnel.mutate(port);
158
+ }
159
+ },
160
+ [hasTunnel, port, startTunnel, stopTunnel]
161
+ );
162
+
163
+ const handleCopy = useCallback(
164
+ (e: React.MouseEvent) => {
165
+ e.stopPropagation();
166
+ if (tunnelUrl) {
167
+ navigator.clipboard.writeText(tunnelUrl);
168
+ setCopied(true);
169
+ setTimeout(() => setCopied(false), 2000);
170
+ }
171
+ },
172
+ [tunnelUrl]
173
+ );
174
+
175
+ return (
176
+ <div className="flex flex-col gap-0.5">
177
+ <div className="flex items-center gap-1">
178
+ <a
179
+ href={`http://localhost:${port}`}
180
+ target="_blank"
181
+ rel="noopener noreferrer"
182
+ onClick={(e) => e.stopPropagation()}
183
+ className="inline-flex items-center gap-0.5 rounded bg-sky-500/15 px-1.5 py-0.5 font-mono text-[10px] text-sky-400 transition-colors hover:bg-sky-500/25"
184
+ >
185
+ <Globe className="h-2.5 w-2.5" />
186
+ {port}
187
+ </a>
188
+ {cloudflaredInstalled && (
189
+ <button
190
+ onClick={handleShare}
191
+ disabled={isStarting}
192
+ className={cn(
193
+ "rounded p-0.5 transition-colors",
194
+ hasTunnel
195
+ ? "text-emerald-400 hover:bg-emerald-500/20"
196
+ : "text-muted-foreground hover:bg-muted hover:text-foreground"
197
+ )}
198
+ title={hasTunnel ? "Stop sharing" : "Share via tunnel"}
199
+ >
200
+ {isStarting ? (
201
+ <Loader2 className="h-3 w-3 animate-spin" />
202
+ ) : hasTunnel ? (
203
+ <X className="h-3 w-3" />
204
+ ) : (
205
+ <Share2 className="h-3 w-3" />
206
+ )}
207
+ </button>
208
+ )}
209
+ </div>
210
+ {tunnelUrl && (
211
+ <div className="flex items-center gap-1">
212
+ <a
213
+ href={tunnelUrl}
214
+ target="_blank"
215
+ rel="noopener noreferrer"
216
+ onClick={(e) => e.stopPropagation()}
217
+ className="inline-flex max-w-[180px] items-center gap-0.5 truncate rounded bg-emerald-500/15 px-1.5 py-0.5 font-mono text-[10px] text-emerald-400 transition-colors hover:bg-emerald-500/25"
218
+ >
219
+ <Globe className="h-2.5 w-2.5 flex-shrink-0" />
220
+ {tunnelUrl
221
+ .replace("https://", "")
222
+ .replace(".trycloudflare.com", "")}
223
+ </a>
224
+ <button
225
+ onClick={handleCopy}
226
+ className="text-muted-foreground hover:text-foreground rounded p-0.5 transition-colors"
227
+ title="Copy URL"
228
+ >
229
+ {copied ? (
230
+ <Check className="h-3 w-3 text-emerald-400" />
231
+ ) : (
232
+ <Copy className="h-3 w-3" />
233
+ )}
234
+ </button>
235
+ </div>
236
+ )}
237
+ </div>
238
+ );
239
+ }
240
+
104
241
  function StatusIcon({ status }: { status: string }) {
105
242
  if (status === "running") {
106
243
  return (
@@ -34,7 +34,14 @@ export function createWebSocketConnection(
34
34
  intentionalCloseRef: React.MutableRefObject<boolean>
35
35
  ): WebSocketManager {
36
36
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
37
- const ws = new WebSocket(`${protocol}//${window.location.host}/ws/terminal`);
37
+ let ptyId: string | null = null;
38
+
39
+ function buildWsUrl() {
40
+ const base = `${protocol}//${window.location.host}/ws/terminal`;
41
+ return ptyId ? `${base}?ptyId=${ptyId}` : base;
42
+ }
43
+
44
+ const ws = new WebSocket(buildWsUrl());
38
45
  wsRef.current = ws;
39
46
 
40
47
  const sendResize = (cols: number, rows: number) => {
@@ -93,10 +100,11 @@ export function createWebSocketConnection(
93
100
  callbacks.onConnectionStateChange("reconnecting");
94
101
  reconnectDelayRef.current = WS_RECONNECT_BASE_DELAY;
95
102
 
96
- // Create fresh connection with saved handlers
97
- const newWs = new WebSocket(
98
- `${protocol}//${window.location.host}/ws/terminal`
99
- );
103
+ // Reattach to the same PTY if we have an ID
104
+ if (ptyId) {
105
+ term.clear();
106
+ }
107
+ const newWs = new WebSocket(buildWsUrl());
100
108
  wsRef.current = newWs;
101
109
  newWs.onopen = savedHandlers.onopen;
102
110
  newWs.onmessage = savedHandlers.onmessage;
@@ -135,6 +143,10 @@ export function createWebSocketConnection(
135
143
  resetInactivityTimer();
136
144
  try {
137
145
  const msg = JSON.parse(event.data);
146
+ if (msg.type === "pty-id") {
147
+ ptyId = msg.ptyId;
148
+ return;
149
+ }
138
150
  if (msg.type === "output") {
139
151
  const buffer = term.buffer.active;
140
152
  const scrollYBefore = buffer.viewportY;
@@ -10,6 +10,8 @@ export interface SessionStatus {
10
10
  lastLine?: string;
11
11
  waitingContext?: string;
12
12
  claudeSessionId?: string | null;
13
+ listeningPorts?: number[];
14
+ tunnelUrls?: Record<number, string>;
13
15
  }
14
16
 
15
17
  export interface ViewProps {
@@ -0,0 +1,51 @@
1
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
+
3
+ const tunnelKeys = {
4
+ status: () => ["tunnels", "status"] as const,
5
+ };
6
+
7
+ export function useCloudflaredStatus() {
8
+ return useQuery({
9
+ queryKey: tunnelKeys.status(),
10
+ queryFn: async () => {
11
+ const res = await fetch("/api/tunnels");
12
+ if (!res.ok) return { installed: false, tunnels: [] };
13
+ return res.json() as Promise<{
14
+ installed: boolean;
15
+ tunnels: { port: number; url: string | null }[];
16
+ }>;
17
+ },
18
+ staleTime: 60_000,
19
+ });
20
+ }
21
+
22
+ export function useStartTunnel() {
23
+ const queryClient = useQueryClient();
24
+ return useMutation({
25
+ mutationFn: async (port: number) => {
26
+ const res = await fetch(`/api/tunnels/${port}`, { method: "POST" });
27
+ if (!res.ok) {
28
+ const data = await res.json();
29
+ throw new Error(data.error || "Failed to start tunnel");
30
+ }
31
+ return res.json() as Promise<{ port: number; url: string | null }>;
32
+ },
33
+ onSuccess: () => {
34
+ queryClient.invalidateQueries({ queryKey: tunnelKeys.status() });
35
+ },
36
+ });
37
+ }
38
+
39
+ export function useStopTunnel() {
40
+ const queryClient = useQueryClient();
41
+ return useMutation({
42
+ mutationFn: async (port: number) => {
43
+ const res = await fetch(`/api/tunnels/${port}`, { method: "DELETE" });
44
+ if (!res.ok) throw new Error("Failed to stop tunnel");
45
+ return res.json();
46
+ },
47
+ onSuccess: () => {
48
+ queryClient.invalidateQueries({ queryKey: tunnelKeys.status() });
49
+ },
50
+ });
51
+ }
@@ -3,7 +3,11 @@ import path from "path";
3
3
  import os from "os";
4
4
  import { WebSocket } from "ws";
5
5
  import { invalidateProject, invalidateAll } from "./jsonl-cache";
6
- import { onStateFileChange, invalidateSessionName } from "../status-monitor";
6
+ import {
7
+ onStateFileChange,
8
+ invalidateSessionName,
9
+ getStatusSnapshot,
10
+ } from "../status-monitor";
7
11
  import { STATES_DIR } from "../hooks/setup";
8
12
 
9
13
  const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
@@ -13,6 +17,11 @@ const updateClients = new Set<WebSocket>();
13
17
  export function addUpdateClient(ws: WebSocket): void {
14
18
  updateClients.add(ws);
15
19
  ws.on("close", () => updateClients.delete(ws));
20
+
21
+ const snapshot = getStatusSnapshot();
22
+ if (Object.keys(snapshot).length > 0) {
23
+ ws.send(JSON.stringify({ type: "session-statuses", statuses: snapshot }));
24
+ }
16
25
  }
17
26
 
18
27
  export function broadcast(msg: object): void {
@@ -6,7 +6,17 @@ interface Migration {
6
6
  up: (db: Database.Database) => void;
7
7
  }
8
8
 
9
- const migrations: Migration[] = [];
9
+ const migrations: Migration[] = [
10
+ {
11
+ id: 1,
12
+ name: "add_session_listening_ports",
13
+ up: (db) => {
14
+ db.exec(
15
+ "ALTER TABLE sessions ADD COLUMN listening_ports TEXT NOT NULL DEFAULT '[]'"
16
+ );
17
+ },
18
+ },
19
+ ];
10
20
 
11
21
  export function runMigrations(db: Database.Database): void {
12
22
  db.exec(`
package/lib/db/types.ts CHANGED
@@ -29,6 +29,7 @@ export interface Session {
29
29
  conductor_session_id: string | null;
30
30
  worker_task: string | null;
31
31
  worker_status: "pending" | "running" | "completed" | "failed" | null;
32
+ listening_ports: string;
32
33
  }
33
34
 
34
35
  export interface Project {
@@ -2,7 +2,12 @@ import { spawn, exec } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import fs from "fs";
4
4
  import path from "path";
5
- import { queries, type DevServer, type DevServerType, type DevServerStatus } from "./db";
5
+ import {
6
+ queries,
7
+ type DevServer,
8
+ type DevServerType,
9
+ type DevServerStatus,
10
+ } from "./db";
6
11
 
7
12
  const execAsync = promisify(exec);
8
13
 
@@ -49,53 +54,60 @@ async function isPidRunning(pid: number): Promise<boolean> {
49
54
  }
50
55
  }
51
56
 
52
- // Check if a port is in use
53
- async function isPortInUse(port: number): Promise<boolean> {
54
- try {
55
- const { stdout } = await execAsync(
56
- `lsof -i :${port} -t 2>/dev/null || true`
57
- );
58
- return stdout.trim().length > 0;
59
- } catch {
60
- return false;
61
- }
62
- }
63
-
64
- // Get PID using a port
65
- async function getPidOnPort(port: number): Promise<number | null> {
66
- try {
67
- const { stdout } = await execAsync(
68
- `lsof -i :${port} -t 2>/dev/null | head -1`
69
- );
70
- const pid = parseInt(stdout.trim(), 10);
71
- return isNaN(pid) ? null : pid;
72
- } catch {
73
- return null;
57
+ // Detect which TCP ports a process is listening on via lsof
58
+ async function detectListeningPorts(
59
+ pid: number,
60
+ retries = 5
61
+ ): Promise<number[]> {
62
+ for (let i = 0; i < retries; i++) {
63
+ try {
64
+ const { stdout } = await execAsync(
65
+ `lsof -P -iTCP -sTCP:LISTEN -a -p ${pid} -Fn 2>/dev/null || true`
66
+ );
67
+ const ports = [
68
+ ...new Set(
69
+ stdout
70
+ .split("\n")
71
+ .filter((line) => line.startsWith("n"))
72
+ .map((line) => parseInt(line.slice(line.lastIndexOf(":") + 1), 10))
73
+ .filter((port) => !isNaN(port) && port > 0)
74
+ ),
75
+ ].sort((a, b) => a - b);
76
+
77
+ if (ports.length > 0) return ports;
78
+ } catch {
79
+ // lsof failed, retry
80
+ }
81
+ if (i < retries - 1) {
82
+ await new Promise((resolve) => setTimeout(resolve, 1000));
83
+ }
74
84
  }
85
+ return [];
75
86
  }
76
87
 
77
88
  // Check Node.js server status
78
89
  async function checkNodeStatus(server: DevServer): Promise<DevServerStatus> {
79
- if (server.pid) {
80
- const running = await isPidRunning(server.pid);
81
- if (running) return "running";
82
- }
90
+ if (!server.pid) return "stopped";
83
91
 
84
- // Check if any of its ports are in use
85
- const ports: number[] = JSON.parse(server.ports || "[]");
86
- for (const port of ports) {
87
- if (await isPortInUse(port)) {
88
- // Port is in use, try to get the PID
89
- const pid = await getPidOnPort(port);
90
- if (pid) {
91
- // Update PID in database
92
- await queries.updateDevServerPid(pid, "running", server.id);
93
- return "running";
94
- }
92
+ const running = await isPidRunning(server.pid);
93
+ if (!running) return "stopped";
94
+
95
+ const ports = await detectListeningPorts(server.pid, 1);
96
+ if (ports.length > 0) {
97
+ const current = JSON.stringify(ports);
98
+ if (current !== server.ports) {
99
+ await queries.updateDevServer(
100
+ "running",
101
+ server.pid,
102
+ null,
103
+ current,
104
+ server.id
105
+ );
106
+ server.ports = current;
95
107
  }
96
108
  }
97
109
 
98
- return "stopped";
110
+ return "running";
99
111
  }
100
112
 
101
113
  // Check Docker service status
@@ -269,7 +281,13 @@ export async function startServer(
269
281
  opts.command,
270
282
  opts.workingDirectory
271
283
  );
272
- await queries.updateDevServer("running", null, containerId, JSON.stringify(ports), id);
284
+ await queries.updateDevServer(
285
+ "running",
286
+ null,
287
+ containerId,
288
+ JSON.stringify(ports),
289
+ id
290
+ );
273
291
  } else {
274
292
  const { pid } = await spawnNodeServer(
275
293
  id,
@@ -277,7 +295,15 @@ export async function startServer(
277
295
  opts.workingDirectory,
278
296
  ports
279
297
  );
280
- await queries.updateDevServer("running", pid, null, JSON.stringify(ports), id);
298
+ const detectedPorts = await detectListeningPorts(pid);
299
+ const resolvedPorts = detectedPorts.length > 0 ? detectedPorts : ports;
300
+ await queries.updateDevServer(
301
+ "running",
302
+ pid,
303
+ null,
304
+ JSON.stringify(resolvedPorts),
305
+ id
306
+ );
281
307
  }
282
308
  } catch (error) {
283
309
  await queries.updateDevServerStatus("failed", id);
@@ -314,19 +340,6 @@ export async function stopServer(id: string): Promise<void> {
314
340
  // Process may already be dead
315
341
  }
316
342
  }
317
-
318
- // Also check ports and kill anything on them
319
- const ports: number[] = JSON.parse(server.ports || "[]");
320
- for (const port of ports) {
321
- const pid = await getPidOnPort(port);
322
- if (pid) {
323
- try {
324
- process.kill(pid, "SIGTERM");
325
- } catch {
326
- // Ignore
327
- }
328
- }
329
- }
330
343
  }
331
344
 
332
345
  await queries.updateDevServerStatus("stopped", id);
@@ -345,7 +358,13 @@ export async function restartServer(id: string): Promise<DevServer> {
345
358
  server.command,
346
359
  server.working_directory
347
360
  );
348
- await queries.updateDevServer("running", null, containerId, server.ports, id);
361
+ await queries.updateDevServer(
362
+ "running",
363
+ null,
364
+ containerId,
365
+ server.ports,
366
+ id
367
+ );
349
368
  } else {
350
369
  const ports: number[] = JSON.parse(server.ports || "[]");
351
370
  const { pid } = await spawnNodeServer(
@@ -354,7 +373,15 @@ export async function restartServer(id: string): Promise<DevServer> {
354
373
  server.working_directory,
355
374
  ports
356
375
  );
357
- await queries.updateDevServer("running", pid, null, server.ports, id);
376
+ const detectedPorts = await detectListeningPorts(pid);
377
+ const resolvedPorts = detectedPorts.length > 0 ? detectedPorts : ports;
378
+ await queries.updateDevServer(
379
+ "running",
380
+ pid,
381
+ null,
382
+ JSON.stringify(resolvedPorts),
383
+ id
384
+ );
358
385
  }
359
386
 
360
387
  return (await queries.getDevServer(id))!;
@@ -415,23 +442,14 @@ export async function detectNodeServer(
415
442
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
416
443
  const scripts = packageJson.scripts || {};
417
444
 
418
- // Look for common dev server scripts
419
445
  const devScripts = ["dev", "start", "serve", "develop"];
420
446
  for (const script of devScripts) {
421
447
  if (scripts[script]) {
422
- // Try to detect port from script
423
- const scriptContent = scripts[script];
424
- let port = 3000; // default
425
- const portMatch = scriptContent.match(/(?:port|PORT)[=\s]+(\d+)/i);
426
- if (portMatch) {
427
- port = parseInt(portMatch[1], 10);
428
- }
429
-
430
448
  return {
431
449
  type: "node",
432
450
  name: packageJson.name || path.basename(workingDir),
433
451
  command: `npm run ${script}`,
434
- ports: [port],
452
+ ports: [],
435
453
  };
436
454
  }
437
455
  }
@@ -20,6 +20,7 @@ import {
20
20
  import type { AgentType } from "./providers";
21
21
  import { broadcast } from "./claude/watcher";
22
22
  import { getDb } from "./db";
23
+ import { getTunnelUrls } from "./tunnels";
23
24
  import { STATES_DIR } from "./hooks/setup";
24
25
  import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
25
26
 
@@ -54,6 +55,8 @@ export interface SessionStatusSnapshot {
54
55
  waitingContext?: string;
55
56
  claudeSessionId: string | null;
56
57
  agentType: AgentType;
58
+ listeningPorts: number[];
59
+ tunnelUrls: Record<number, string>;
57
60
  }
58
61
 
59
62
  // --- State ---
@@ -88,6 +91,88 @@ function listStateFiles(): Map<string, StateFile> {
88
91
  return map;
89
92
  }
90
93
 
94
+ // --- Port detection ---
95
+
96
+ interface ListeningProcess {
97
+ pid: string;
98
+ port: number;
99
+ cwd: string;
100
+ }
101
+
102
+ let cachedListeners: { data: ListeningProcess[]; ts: number } | null = null;
103
+ const LISTENER_CACHE_TTL = 2500;
104
+
105
+ async function getListeningProcesses(): Promise<ListeningProcess[]> {
106
+ const now = Date.now();
107
+ if (cachedListeners && now - cachedListeners.ts < LISTENER_CACHE_TTL) {
108
+ return cachedListeners.data;
109
+ }
110
+
111
+ try {
112
+ const { stdout } = await execAsync(
113
+ `lsof -P -iTCP -sTCP:LISTEN -Fn 2>/dev/null || true`
114
+ );
115
+
116
+ // Parse lsof output: lines alternate between p (pid) and n (name)
117
+ const results: ListeningProcess[] = [];
118
+ let currentPid = "";
119
+ for (const line of stdout.split("\n")) {
120
+ if (line.startsWith("p")) {
121
+ currentPid = line.slice(1);
122
+ } else if (line.startsWith("n") && currentPid) {
123
+ const port = parseInt(line.slice(line.lastIndexOf(":") + 1), 10);
124
+ if (!isNaN(port) && port > 0) {
125
+ results.push({ pid: currentPid, port, cwd: "" });
126
+ }
127
+ }
128
+ }
129
+
130
+ // Deduplicate by pid+port
131
+ const unique = [
132
+ ...new Map(results.map((r) => [`${r.pid}:${r.port}`, r])).values(),
133
+ ];
134
+
135
+ // Batch-resolve cwds
136
+ if (unique.length > 0) {
137
+ const pids = [...new Set(unique.map((r) => r.pid))].join(",");
138
+ try {
139
+ const { stdout: cwdOut } = await execAsync(
140
+ `lsof -a -p ${pids} -d cwd -Fpn 2>/dev/null || true`
141
+ );
142
+ const cwdMap = new Map<string, string>();
143
+ let pid = "";
144
+ for (const line of cwdOut.split("\n")) {
145
+ if (line.startsWith("p")) pid = line.slice(1);
146
+ else if (line.startsWith("n") && pid) cwdMap.set(pid, line.slice(1));
147
+ }
148
+ for (const entry of unique) {
149
+ entry.cwd = cwdMap.get(entry.pid) || "";
150
+ }
151
+ } catch {
152
+ // cwd resolution failed, proceed without
153
+ }
154
+ }
155
+
156
+ cachedListeners = { data: unique, ts: now };
157
+ return unique;
158
+ } catch {
159
+ return [];
160
+ }
161
+ }
162
+
163
+ async function detectSessionPorts(
164
+ sessionCwd: string | null
165
+ ): Promise<number[]> {
166
+ if (!sessionCwd) return [];
167
+
168
+ const listeners = await getListeningProcesses();
169
+ return [
170
+ ...new Set(
171
+ listeners.filter((l) => l.cwd.startsWith(sessionCwd)).map((l) => l.port)
172
+ ),
173
+ ].sort((a, b) => a - b);
174
+ }
175
+
91
176
  // --- tmux ---
92
177
 
93
178
  async function listTmuxSessions(): Promise<Map<string, string>> {
@@ -149,14 +234,22 @@ async function buildSnapshot(
149
234
  const entries = await Promise.all(
150
235
  [...tmuxSessions.entries()].map(async ([sessionId, tmuxName]) => {
151
236
  const meta = await resolveSessionMeta(sessionId);
152
- return { sessionId, tmuxName, ...meta };
237
+ const ports = await detectSessionPorts(meta.cwd);
238
+ return { sessionId, tmuxName, ports, ...meta };
153
239
  })
154
240
  );
155
241
 
156
- for (const { sessionId, tmuxName, name, cwd } of entries) {
242
+ const db = getDb();
243
+ const allTunnels = getTunnelUrls();
244
+ for (const { sessionId, tmuxName, name, cwd, ports } of entries) {
157
245
  const agentType = getProviderIdFromSessionName(tmuxName) || "claude";
158
246
  const state = stateFiles.get(sessionId);
159
247
 
248
+ const tunnelUrls: Record<number, string> = {};
249
+ for (const port of ports) {
250
+ if (allTunnels[port]) tunnelUrls[port] = allTunnels[port];
251
+ }
252
+
160
253
  snap[sessionId] = {
161
254
  sessionName: name,
162
255
  cwd,
@@ -167,12 +260,21 @@ async function buildSnapshot(
167
260
  : {}),
168
261
  claudeSessionId: sessionId,
169
262
  agentType,
263
+ listeningPorts: ports,
264
+ tunnelUrls,
170
265
  };
266
+
267
+ try {
268
+ db.prepare(
269
+ "UPDATE sessions SET listening_ports = ? WHERE id = ? OR claude_session_id = ?"
270
+ ).run(JSON.stringify(ports), sessionId, sessionId);
271
+ } catch {
272
+ // DB update failure shouldn't break the monitor
273
+ }
171
274
  }
172
275
 
173
276
  // Filter out hidden sessions
174
277
  try {
175
- const db = getDb();
176
278
  const hiddenRows = db
177
279
  .prepare("SELECT item_id FROM hidden_items WHERE item_type = 'session'")
178
280
  .all() as { item_id: string }[];
@@ -200,7 +302,9 @@ function snapshotChanged(
200
302
  !p ||
201
303
  p.status !== n.status ||
202
304
  p.lastLine !== n.lastLine ||
203
- p.sessionName !== n.sessionName
305
+ p.sessionName !== n.sessionName ||
306
+ JSON.stringify(p.listeningPorts) !== JSON.stringify(n.listeningPorts) ||
307
+ JSON.stringify(p.tunnelUrls) !== JSON.stringify(n.tunnelUrls)
204
308
  )
205
309
  return true;
206
310
  }
package/lib/tunnels.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { spawn, exec, type ChildProcess } from "child_process";
2
+ import { promisify } from "util";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { broadcast } from "./claude/watcher";
6
+
7
+ const execAsync = promisify(exec);
8
+ const LOGS_DIR = path.join(process.env.HOME || "~", ".claude-deck", "logs");
9
+
10
+ const URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
11
+ const URL_TIMEOUT_MS = 20000;
12
+
13
+ interface Tunnel {
14
+ port: number;
15
+ pid: number;
16
+ url: string | null;
17
+ process: ChildProcess;
18
+ }
19
+
20
+ // Use globalThis to share the Map across Next.js module boundaries
21
+ // (API routes and the custom server may load this module separately)
22
+ const GLOBAL_KEY = "__claudeDeckTunnels" as const;
23
+ const activeTunnels: Map<number, Tunnel> =
24
+ ((globalThis as Record<string, unknown>)[GLOBAL_KEY] as Map<
25
+ number,
26
+ Tunnel
27
+ >) ??
28
+ (() => {
29
+ const map = new Map<number, Tunnel>();
30
+ (globalThis as Record<string, unknown>)[GLOBAL_KEY] = map;
31
+ return map;
32
+ })();
33
+
34
+ export async function isCloudflaredInstalled(): Promise<boolean> {
35
+ try {
36
+ await execAsync("cloudflared --version 2>/dev/null");
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ export async function startTunnel(
44
+ port: number
45
+ ): Promise<{ port: number; url: string | null }> {
46
+ const existing = activeTunnels.get(port);
47
+ if (existing) return { port: existing.port, url: existing.url };
48
+
49
+ const logPath = path.join(LOGS_DIR, `tunnel_${port}.log`);
50
+ const logStream = fs.createWriteStream(logPath, { flags: "a" });
51
+
52
+ const child = spawn(
53
+ "cloudflared",
54
+ [
55
+ "tunnel",
56
+ "--url",
57
+ `http://localhost:${port}`,
58
+ "--http-host-header",
59
+ `localhost:${port}`,
60
+ ],
61
+ {
62
+ stdio: ["ignore", "pipe", "pipe"],
63
+ detached: false,
64
+ }
65
+ );
66
+
67
+ const tunnel: Tunnel = {
68
+ port,
69
+ pid: child.pid || 0,
70
+ url: null,
71
+ process: child,
72
+ };
73
+ activeTunnels.set(port, tunnel);
74
+
75
+ const url = await new Promise<string | null>((resolve) => {
76
+ const timeout = setTimeout(() => resolve(null), URL_TIMEOUT_MS);
77
+ let resolved = false;
78
+
79
+ const handleData = (chunk: Buffer) => {
80
+ const text = chunk.toString();
81
+ logStream.write(text);
82
+ if (resolved) return;
83
+ const match = text.match(URL_PATTERN);
84
+ if (match) {
85
+ resolved = true;
86
+ clearTimeout(timeout);
87
+ resolve(match[0]);
88
+ }
89
+ };
90
+
91
+ child.stdout?.on("data", handleData);
92
+ child.stderr?.on("data", handleData);
93
+
94
+ child.on("exit", () => {
95
+ if (!resolved) {
96
+ clearTimeout(timeout);
97
+ resolve(null);
98
+ }
99
+ });
100
+ });
101
+
102
+ tunnel.url = url;
103
+
104
+ child.on("exit", () => {
105
+ activeTunnels.delete(port);
106
+ logStream.end();
107
+ broadcastTunnelUpdate();
108
+ });
109
+
110
+ broadcastTunnelUpdate();
111
+ return { port, url };
112
+ }
113
+
114
+ export async function stopTunnel(port: number): Promise<void> {
115
+ const tunnel = activeTunnels.get(port);
116
+ if (!tunnel) return;
117
+
118
+ try {
119
+ tunnel.process.kill("SIGTERM");
120
+ await new Promise((resolve) => setTimeout(resolve, 1000));
121
+ if (!tunnel.process.killed) {
122
+ tunnel.process.kill("SIGKILL");
123
+ }
124
+ } catch {
125
+ // Process may already be dead
126
+ }
127
+
128
+ activeTunnels.delete(port);
129
+ broadcastTunnelUpdate();
130
+ }
131
+
132
+ export function getTunnelUrls(): Record<number, string> {
133
+ const result: Record<number, string> = {};
134
+ for (const [port, tunnel] of activeTunnels) {
135
+ if (tunnel.url) result[port] = tunnel.url;
136
+ }
137
+ return result;
138
+ }
139
+
140
+ export function getTunnelsList(): { port: number; url: string | null }[] {
141
+ return [...activeTunnels.values()].map((t) => ({ port: t.port, url: t.url }));
142
+ }
143
+
144
+ export function stopAllTunnels(): void {
145
+ for (const [, tunnel] of activeTunnels) {
146
+ try {
147
+ tunnel.process.kill("SIGTERM");
148
+ } catch {
149
+ // ignore
150
+ }
151
+ }
152
+ activeTunnels.clear();
153
+ }
154
+
155
+ function broadcastTunnelUpdate(): void {
156
+ broadcast({ type: "tunnels-updated", tunnels: getTunnelUrls() });
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atercates/claude-deck",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "claude-deck": "./scripts/claude-deck"
@@ -388,6 +388,72 @@ install_ripgrep() {
388
388
  esac
389
389
  }
390
390
 
391
+ check_cloudflared() {
392
+ if command -v cloudflared &> /dev/null; then
393
+ local cf_path=$(command -v cloudflared)
394
+ local cf_version=$(cloudflared --version 2>/dev/null | awk '{print $2}' || echo "unknown")
395
+ log_success "Found cloudflared $cf_version at $cf_path"
396
+ return 0
397
+ fi
398
+
399
+ source_node_managers
400
+
401
+ if command -v cloudflared &> /dev/null; then
402
+ local cf_path=$(command -v cloudflared)
403
+ local cf_version=$(cloudflared --version 2>/dev/null | awk '{print $2}' || echo "unknown")
404
+ log_success "Found cloudflared $cf_version at $cf_path"
405
+ return 0
406
+ fi
407
+
408
+ return 1
409
+ }
410
+
411
+ install_cloudflared() {
412
+ if command -v cloudflared &> /dev/null; then
413
+ return 0
414
+ fi
415
+
416
+ log_info "Installing cloudflared (Cloudflare Tunnel)..."
417
+
418
+ case "$OS" in
419
+ macos)
420
+ if ! groups | grep -q admin; then
421
+ log_info "Non-admin user - downloading pre-built cloudflared binary"
422
+ local arch
423
+ arch=$(uname -m)
424
+ local url
425
+ if [[ "$arch" == "arm64" ]]; then
426
+ url="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz"
427
+ else
428
+ url="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz"
429
+ fi
430
+
431
+ mkdir -p "$HOME/.local/bin"
432
+ curl -fsSL "$url" | tar -xz -C "$HOME/.local/bin"
433
+ chmod +x "$HOME/.local/bin/cloudflared"
434
+ export PATH="$HOME/.local/bin:$PATH"
435
+ log_success "cloudflared installed to ~/.local/bin/cloudflared"
436
+ else
437
+ install_homebrew
438
+ brew install cloudflared
439
+ fi
440
+ ;;
441
+ debian)
442
+ curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
443
+ echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/cloudflared.list
444
+ sudo apt-get update
445
+ sudo apt-get install -y cloudflared
446
+ ;;
447
+ redhat)
448
+ sudo rpm -i "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-x86_64.rpm" 2>/dev/null || true
449
+ ;;
450
+ *)
451
+ log_warn "Please install cloudflared manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/"
452
+ return 1
453
+ ;;
454
+ esac
455
+ }
456
+
391
457
  check_and_install_prerequisites() {
392
458
  log_info "Checking prerequisites..."
393
459
 
@@ -415,30 +481,39 @@ check_and_install_prerequisites() {
415
481
 
416
482
  if [[ ${#missing[@]} -eq 0 ]]; then
417
483
  log_success "All prerequisites met"
418
- return 0
419
- fi
484
+ else
485
+ log_warn "Missing prerequisites: ${missing[*]}"
420
486
 
421
- log_warn "Missing prerequisites: ${missing[*]}"
422
-
423
- if is_interactive; then
424
- if ! prompt_yn "Install missing prerequisites?"; then
425
- log_error "Please install manually: ${missing[*]}"
426
- exit 1
487
+ if is_interactive; then
488
+ if ! prompt_yn "Install missing prerequisites?"; then
489
+ log_error "Please install manually: ${missing[*]}"
490
+ exit 1
491
+ fi
427
492
  fi
428
- fi
429
493
 
430
- for dep in "${missing[@]}"; do
431
- case "$dep" in
432
- node) install_node ;;
433
- git) install_git ;;
434
- tmux) install_tmux ;;
435
- ripgrep) install_ripgrep ;;
436
- esac
437
- done
494
+ for dep in "${missing[@]}"; do
495
+ case "$dep" in
496
+ node) install_node ;;
497
+ git) install_git ;;
498
+ tmux) install_tmux ;;
499
+ ripgrep) install_ripgrep ;;
500
+ esac
501
+ done
438
502
 
439
- log_success "Prerequisites installed"
503
+ log_success "Prerequisites installed"
504
+ fi
440
505
 
441
506
  configure_tmux
507
+
508
+ # Optional: cloudflared for tunnel sharing
509
+ if ! check_cloudflared; then
510
+ log_info "cloudflared not found (optional - enables port sharing via Cloudflare Tunnel)"
511
+ if is_interactive; then
512
+ if prompt_yn "Install cloudflared for tunnel sharing?"; then
513
+ install_cloudflared
514
+ fi
515
+ fi
516
+ fi
442
517
  }
443
518
 
444
519
  configure_tmux() {
package/server.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  COOKIE_NAME,
14
14
  hasUsers,
15
15
  } from "./lib/auth";
16
+ import { stopAllTunnels } from "./lib/tunnels";
16
17
 
17
18
  const dev = process.env.NODE_ENV !== "production";
18
19
  const hostname = process.env.HOST || (dev ? "localhost" : "0.0.0.0");
@@ -89,65 +90,86 @@ app.prepare().then(async () => {
89
90
  ws.on("close", () => clearInterval(interval));
90
91
  }
91
92
 
92
- // Terminal connections
93
- terminalWss.on("connection", (ws: WebSocket) => {
94
- setupHeartbeat(ws);
95
- let ptyProcess: pty.IPty;
96
- try {
97
- const shell = process.env.SHELL || "/bin/zsh";
98
- // Use minimal env - only essentials for shell to work
99
- // This lets Next.js/Vite/etc load .env.local without interference from parent process env
100
- const minimalEnv: { [key: string]: string } = {
101
- PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
102
- HOME: process.env.HOME || "/",
103
- USER: process.env.USER || "",
104
- SHELL: shell,
105
- TERM: "xterm-256color",
106
- COLORTERM: "truecolor",
107
- LANG: process.env.LANG || "en_US.UTF-8",
108
- };
109
-
110
- ptyProcess = pty.spawn(shell, [], {
111
- name: "xterm-256color",
112
- cols: 80,
113
- rows: 24,
114
- cwd: process.env.HOME || "/",
115
- env: minimalEnv,
116
- });
117
- } catch (err) {
118
- console.error("Failed to spawn pty:", err);
119
- ws.send(
120
- JSON.stringify({ type: "error", message: "Failed to start terminal" })
121
- );
122
- ws.close();
123
- return;
124
- }
93
+ // --- Persistent PTY pool ---
94
+ // PTYs survive WebSocket disconnects. Clients attach/reattach by ptyId.
95
+ interface PtyEntry {
96
+ process: pty.IPty;
97
+ ws: WebSocket | null;
98
+ buffer: string[];
99
+ }
100
+ const ptyPool = new Map<string, PtyEntry>();
101
+ const MAX_SCROLLBACK_BUFFER = 50000;
102
+
103
+ function spawnPty(): { id: string; entry: PtyEntry } {
104
+ const shell = process.env.SHELL || "/bin/zsh";
105
+ const minimalEnv: { [key: string]: string } = {
106
+ PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
107
+ HOME: process.env.HOME || "/",
108
+ USER: process.env.USER || "",
109
+ SHELL: shell,
110
+ TERM: "xterm-256color",
111
+ COLORTERM: "truecolor",
112
+ LANG: process.env.LANG || "en_US.UTF-8",
113
+ };
114
+
115
+ const proc = pty.spawn(shell, [], {
116
+ name: "xterm-256color",
117
+ cols: 80,
118
+ rows: 24,
119
+ cwd: process.env.HOME || "/",
120
+ env: minimalEnv,
121
+ });
122
+
123
+ const id = `pty_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
124
+ const entry: PtyEntry = { process: proc, ws: null, buffer: [] };
125
125
 
126
- ptyProcess.onData((data: string) => {
127
- if (ws.readyState === WebSocket.OPEN) {
128
- ws.send(JSON.stringify({ type: "output", data }));
126
+ proc.onData((data: string) => {
127
+ entry.buffer.push(data);
128
+ if (entry.buffer.length > MAX_SCROLLBACK_BUFFER) {
129
+ entry.buffer.splice(0, entry.buffer.length - MAX_SCROLLBACK_BUFFER);
130
+ }
131
+ if (entry.ws?.readyState === WebSocket.OPEN) {
132
+ entry.ws.send(JSON.stringify({ type: "output", data }));
129
133
  }
130
134
  });
131
135
 
132
- ptyProcess.onExit(({ exitCode }) => {
133
- if (ws.readyState === WebSocket.OPEN) {
134
- ws.send(JSON.stringify({ type: "exit", code: exitCode }));
135
- ws.close();
136
+ proc.onExit(({ exitCode }) => {
137
+ if (entry.ws?.readyState === WebSocket.OPEN) {
138
+ entry.ws.send(JSON.stringify({ type: "exit", code: exitCode }));
136
139
  }
140
+ ptyPool.delete(id);
137
141
  });
138
142
 
143
+ ptyPool.set(id, entry);
144
+ return { id, entry };
145
+ }
146
+
147
+ function attachWsToPty(ws: WebSocket, entry: PtyEntry, _ptyId: string) {
148
+ // Detach previous WebSocket if any
149
+ if (entry.ws && entry.ws !== ws && entry.ws.readyState === WebSocket.OPEN) {
150
+ entry.ws.onclose = null;
151
+ entry.ws.onerror = null;
152
+ entry.ws.close(1000, "Replaced by new connection");
153
+ }
154
+ entry.ws = ws;
155
+
156
+ // Replay buffered output so the client sees prior terminal state
157
+ if (entry.buffer.length > 0) {
158
+ ws.send(JSON.stringify({ type: "output", data: entry.buffer.join("") }));
159
+ }
160
+
139
161
  ws.on("message", (message: Buffer) => {
140
162
  try {
141
163
  const msg = JSON.parse(message.toString());
142
164
  switch (msg.type) {
143
165
  case "input":
144
- ptyProcess.write(msg.data);
166
+ entry.process.write(msg.data);
145
167
  break;
146
168
  case "resize":
147
- ptyProcess.resize(msg.cols, msg.rows);
169
+ entry.process.resize(msg.cols, msg.rows);
148
170
  break;
149
171
  case "command":
150
- ptyProcess.write(msg.data + "\r");
172
+ entry.process.write(msg.data + "\r");
151
173
  break;
152
174
  }
153
175
  } catch (err) {
@@ -156,13 +178,42 @@ app.prepare().then(async () => {
156
178
  });
157
179
 
158
180
  ws.on("close", () => {
159
- ptyProcess.kill();
181
+ if (entry.ws === ws) entry.ws = null;
182
+ // PTY stays alive — no kill
160
183
  });
161
184
 
162
- ws.on("error", (err) => {
163
- console.error("WebSocket error:", err);
164
- ptyProcess.kill();
185
+ ws.on("error", () => {
186
+ if (entry.ws === ws) entry.ws = null;
165
187
  });
188
+ }
189
+
190
+ // Terminal connections
191
+ terminalWss.on("connection", (ws: WebSocket, request) => {
192
+ setupHeartbeat(ws);
193
+
194
+ const { query } = parse(request.url || "", true);
195
+ const requestedPtyId = typeof query.ptyId === "string" ? query.ptyId : null;
196
+
197
+ // Try to reattach to existing PTY
198
+ if (requestedPtyId && ptyPool.has(requestedPtyId)) {
199
+ const entry = ptyPool.get(requestedPtyId)!;
200
+ ws.send(JSON.stringify({ type: "pty-id", ptyId: requestedPtyId }));
201
+ attachWsToPty(ws, entry, requestedPtyId);
202
+ return;
203
+ }
204
+
205
+ // Spawn new PTY
206
+ try {
207
+ const { id, entry } = spawnPty();
208
+ ws.send(JSON.stringify({ type: "pty-id", ptyId: id }));
209
+ attachWsToPty(ws, entry, id);
210
+ } catch (err) {
211
+ console.error("Failed to spawn pty:", err);
212
+ ws.send(
213
+ JSON.stringify({ type: "error", message: "Failed to start terminal" })
214
+ );
215
+ ws.close();
216
+ }
166
217
  });
167
218
 
168
219
  await initDb();
@@ -172,6 +223,13 @@ app.prepare().then(async () => {
172
223
  startWatcher();
173
224
  startStatusMonitor();
174
225
 
226
+ const shutdown = () => {
227
+ stopAllTunnels();
228
+ process.exit(0);
229
+ };
230
+ process.on("SIGINT", shutdown);
231
+ process.on("SIGTERM", shutdown);
232
+
175
233
  server.listen(port, () => {
176
234
  console.log(`> ClaudeDeck ready on http://${hostname}:${port}`);
177
235
  });