@atercates/claude-deck 0.2.14 → 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,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/lib/worktrees.ts CHANGED
@@ -111,6 +111,15 @@ export async function createWorktree(
111
111
  { timeout: 30000 }
112
112
  );
113
113
  lastError = null;
114
+
115
+ // Init submodules if present
116
+ if (fs.existsSync(path.join(worktreePath, ".gitmodules"))) {
117
+ await execAsync(
118
+ `git -C "${worktreePath}" submodule update --init --recursive`,
119
+ { timeout: 120000 }
120
+ );
121
+ }
122
+
114
123
  break; // Success!
115
124
  } catch (error: unknown) {
116
125
  lastError = error instanceof Error ? error : new Error(String(error));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atercates/claude-deck",
3
- "version": "0.2.14",
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"