@atercates/claude-deck 0.2.2 → 0.2.4

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,278 @@
1
+ /**
2
+ * Session status monitor — hook-based detection.
3
+ *
4
+ * Claude Code hooks write state files to ~/.claude-deck/session-states/.
5
+ * This module reads those files and pushes updates to the frontend via WebSocket.
6
+ *
7
+ * The only periodic work is `tmux list-sessions` every 3s to detect dead sessions.
8
+ * All state transitions are event-driven via Chokidar watching the state files dir.
9
+ */
10
+
11
+ import { exec } from "child_process";
12
+ import { promisify } from "util";
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+ import {
16
+ getManagedSessionPattern,
17
+ getSessionIdFromName,
18
+ getProviderIdFromSessionName,
19
+ } from "./providers/registry";
20
+ import type { AgentType } from "./providers";
21
+ import { broadcast } from "./claude/watcher";
22
+ import { getDb } from "./db";
23
+ import { STATES_DIR } from "./hooks/setup";
24
+ import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
25
+
26
+ const execAsync = promisify(exec);
27
+
28
+ const TICK_INTERVAL_MS = 3000;
29
+ const SESSION_NAME_CACHE_TTL = 10_000;
30
+ const UUID_PATTERN = getManagedSessionPattern();
31
+
32
+ // Cache for session display names (summary from SDK)
33
+ const sessionNameCache = new Map<string, { name: string; cachedAt: number }>();
34
+
35
+ // --- Types ---
36
+
37
+ export type SessionStatus = "running" | "waiting" | "idle" | "dead";
38
+
39
+ interface StateFile {
40
+ status: "running" | "waiting" | "idle";
41
+ lastLine: string;
42
+ waitingContext?: string;
43
+ ts: number;
44
+ }
45
+
46
+ export interface SessionStatusSnapshot {
47
+ sessionName: string;
48
+ status: SessionStatus;
49
+ lastLine: string;
50
+ waitingContext?: string;
51
+ claudeSessionId: string | null;
52
+ agentType: AgentType;
53
+ }
54
+
55
+ // --- State ---
56
+
57
+ let currentSnapshot: Record<string, SessionStatusSnapshot> = {};
58
+ let monitorTimer: ReturnType<typeof setInterval> | null = null;
59
+
60
+ // --- State file reading ---
61
+
62
+ function readStateFile(sessionId: string): StateFile | null {
63
+ try {
64
+ const filePath = path.join(STATES_DIR, `${sessionId}.json`);
65
+ const raw = fs.readFileSync(filePath, "utf-8");
66
+ return JSON.parse(raw);
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function listStateFiles(): Map<string, StateFile> {
73
+ const map = new Map<string, StateFile>();
74
+ try {
75
+ for (const file of fs.readdirSync(STATES_DIR)) {
76
+ if (!file.endsWith(".json")) continue;
77
+ const sessionId = file.replace(".json", "");
78
+ const state = readStateFile(sessionId);
79
+ if (state) map.set(sessionId, state);
80
+ }
81
+ } catch {
82
+ // dir may not exist yet
83
+ }
84
+ return map;
85
+ }
86
+
87
+ // --- tmux ---
88
+
89
+ async function listTmuxSessions(): Promise<Map<string, string>> {
90
+ // Returns Map<sessionId, sessionName>
91
+ try {
92
+ const { stdout } = await execAsync(
93
+ "tmux list-sessions -F '#{session_name}' 2>/dev/null || echo \"\""
94
+ );
95
+ const map = new Map<string, string>();
96
+ for (const name of stdout.trim().split("\n")) {
97
+ if (!name || !UUID_PATTERN.test(name)) continue;
98
+ map.set(getSessionIdFromName(name), name);
99
+ }
100
+ return map;
101
+ } catch {
102
+ return new Map();
103
+ }
104
+ }
105
+
106
+ // --- Session display name resolution ---
107
+
108
+ async function resolveSessionDisplayName(sessionId: string): Promise<string> {
109
+ const cached = sessionNameCache.get(sessionId);
110
+ if (cached && Date.now() - cached.cachedAt < SESSION_NAME_CACHE_TTL) {
111
+ return cached.name;
112
+ }
113
+
114
+ try {
115
+ const info = await getSessionInfo(sessionId);
116
+ if (info) {
117
+ const name = info.customTitle || info.summary || sessionId.slice(0, 8);
118
+ sessionNameCache.set(sessionId, { name, cachedAt: Date.now() });
119
+ return name;
120
+ }
121
+ } catch {
122
+ // SDK lookup failed — use short ID
123
+ }
124
+
125
+ const fallback = sessionId.slice(0, 8);
126
+ sessionNameCache.set(sessionId, { name: fallback, cachedAt: Date.now() });
127
+ return fallback;
128
+ }
129
+
130
+ // --- Snapshot building ---
131
+
132
+ async function buildSnapshot(
133
+ tmuxSessions: Map<string, string>,
134
+ stateFiles: Map<string, StateFile>
135
+ ): Promise<Record<string, SessionStatusSnapshot>> {
136
+ const snap: Record<string, SessionStatusSnapshot> = {};
137
+
138
+ const entries = await Promise.all(
139
+ [...tmuxSessions.entries()].map(async ([sessionId, tmuxName]) => {
140
+ const displayName = await resolveSessionDisplayName(sessionId);
141
+ return { sessionId, tmuxName, displayName };
142
+ })
143
+ );
144
+
145
+ for (const { sessionId, tmuxName, displayName } of entries) {
146
+ const agentType = getProviderIdFromSessionName(tmuxName) || "claude";
147
+ const state = stateFiles.get(sessionId);
148
+
149
+ snap[sessionId] = {
150
+ sessionName: displayName,
151
+ status: state?.status || "idle",
152
+ lastLine: state?.lastLine || "",
153
+ ...(state?.status === "waiting" && state.waitingContext
154
+ ? { waitingContext: state.waitingContext }
155
+ : {}),
156
+ claudeSessionId: sessionId,
157
+ agentType,
158
+ };
159
+ }
160
+
161
+ return snap;
162
+ }
163
+
164
+ function snapshotChanged(
165
+ prev: Record<string, SessionStatusSnapshot>,
166
+ next: Record<string, SessionStatusSnapshot>
167
+ ): boolean {
168
+ const prevKeys = Object.keys(prev);
169
+ const nextKeys = Object.keys(next);
170
+ if (prevKeys.length !== nextKeys.length) return true;
171
+ for (const id of nextKeys) {
172
+ const p = prev[id];
173
+ const n = next[id];
174
+ if (
175
+ !p ||
176
+ p.status !== n.status ||
177
+ p.lastLine !== n.lastLine ||
178
+ p.sessionName !== n.sessionName
179
+ )
180
+ return true;
181
+ }
182
+ return false;
183
+ }
184
+
185
+ function updateDb(
186
+ prev: Record<string, SessionStatusSnapshot>,
187
+ next: Record<string, SessionStatusSnapshot>
188
+ ): void {
189
+ try {
190
+ const db = getDb();
191
+ for (const [id, snap] of Object.entries(next)) {
192
+ if (prev[id]?.status === snap.status) continue;
193
+ db.prepare(
194
+ "UPDATE sessions SET updated_at = datetime('now') WHERE id = ?"
195
+ ).run(id);
196
+ if (snap.claudeSessionId) {
197
+ db.prepare(
198
+ "UPDATE sessions SET claude_session_id = ? WHERE id = ? AND (claude_session_id IS NULL OR claude_session_id != ?)"
199
+ ).run(snap.claudeSessionId, id, snap.claudeSessionId);
200
+ }
201
+ }
202
+ } catch {
203
+ // DB errors shouldn't break the monitor
204
+ }
205
+ }
206
+
207
+ // --- Core tick (only for dead detection + state file sync) ---
208
+
209
+ async function tick(): Promise<void> {
210
+ const tmuxSessions = await listTmuxSessions();
211
+ const stateFiles = listStateFiles();
212
+
213
+ // Clean up state files for sessions that no longer exist in tmux
214
+ for (const sessionId of stateFiles.keys()) {
215
+ if (!tmuxSessions.has(sessionId)) {
216
+ try {
217
+ fs.unlinkSync(path.join(STATES_DIR, `${sessionId}.json`));
218
+ } catch {
219
+ // ignore
220
+ }
221
+ }
222
+ }
223
+
224
+ const newSnapshot = await buildSnapshot(tmuxSessions, stateFiles);
225
+
226
+ if (snapshotChanged(currentSnapshot, newSnapshot)) {
227
+ updateDb(currentSnapshot, newSnapshot);
228
+ currentSnapshot = newSnapshot;
229
+ broadcast({ type: "session-statuses", statuses: newSnapshot });
230
+ }
231
+ }
232
+
233
+ // --- Public API ---
234
+
235
+ export function getStatusSnapshot(): Record<string, SessionStatusSnapshot> {
236
+ return currentSnapshot;
237
+ }
238
+
239
+ export function acknowledge(_sessionName: string): void {
240
+ // With hook-based detection, acknowledge is a no-op.
241
+ // Status is determined by Claude Code's hook events, not by us.
242
+ }
243
+
244
+ /**
245
+ * Called by Chokidar when a state file in ~/.claude-deck/session-states/ changes.
246
+ * Triggers an immediate re-read and broadcast.
247
+ */
248
+ export function onStateFileChange(): void {
249
+ tick().catch(console.error);
250
+ }
251
+
252
+ export function invalidateSessionName(sessionId: string): void {
253
+ sessionNameCache.delete(sessionId);
254
+ }
255
+
256
+ export function startStatusMonitor(): void {
257
+ if (monitorTimer) return;
258
+
259
+ // Ensure states directory exists
260
+ fs.mkdirSync(STATES_DIR, { recursive: true });
261
+
262
+ // Initial tick
263
+ setTimeout(() => tick().catch(console.error), 500);
264
+
265
+ // Periodic fallback (catches tmux session death, missed events)
266
+ monitorTimer = setInterval(() => {
267
+ tick().catch(console.error);
268
+ }, TICK_INTERVAL_MS);
269
+
270
+ console.log("> Status monitor started (hook-based, 3s fallback tick)");
271
+ }
272
+
273
+ export function stopStatusMonitor(): void {
274
+ if (monitorTimer) {
275
+ clearInterval(monitorTimer);
276
+ monitorTimer = null;
277
+ }
278
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atercates/claude-deck",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "claude-deck": "./scripts/claude-deck"
@@ -78,6 +78,7 @@
78
78
  "@xterm/addon-search": "^0.16.0",
79
79
  "@xterm/addon-web-links": "^0.12.0",
80
80
  "@xterm/xterm": "^6.0.0",
81
+ "bcryptjs": "^3.0.3",
81
82
  "better-sqlite3": "^12.8.0",
82
83
  "chokidar": "^5.0.0",
83
84
  "class-variance-authority": "^0.7.1",
@@ -88,6 +89,8 @@
88
89
  "next": "^16.2.3",
89
90
  "next-themes": "^0.4.6",
90
91
  "node-pty": "1.2.0-beta.12",
92
+ "otpauth": "^9.5.0",
93
+ "qrcode": "^1.5.4",
91
94
  "react": "^19.2.5",
92
95
  "react-dom": "^19.2.5",
93
96
  "react-markdown": "^10.1.0",
@@ -107,6 +110,7 @@
107
110
  "@tauri-apps/cli": "^2.10.1",
108
111
  "@types/better-sqlite3": "^7.6.13",
109
112
  "@types/node": "^25.6.0",
113
+ "@types/qrcode": "^1.5.6",
110
114
  "@types/react": "^19.2.14",
111
115
  "@types/react-dom": "^19.2.3",
112
116
  "@types/ws": "^8.18.1",
package/server.ts CHANGED
@@ -5,6 +5,14 @@ import { WebSocketServer, WebSocket } from "ws";
5
5
  import * as pty from "node-pty";
6
6
  import { initDb } from "./lib/db";
7
7
  import { startWatcher, addUpdateClient } from "./lib/claude/watcher";
8
+ import { startStatusMonitor } from "./lib/status-monitor";
9
+ import { setupHooks } from "./lib/hooks/setup";
10
+ import {
11
+ validateSession,
12
+ parseCookies,
13
+ COOKIE_NAME,
14
+ hasUsers,
15
+ } from "./lib/auth";
8
16
 
9
17
  const dev = process.env.NODE_ENV !== "production";
10
18
  const hostname = "0.0.0.0";
@@ -35,6 +43,19 @@ app.prepare().then(async () => {
35
43
  server.on("upgrade", (request, socket, head) => {
36
44
  const { pathname } = parse(request.url || "");
37
45
 
46
+ // Validate auth for WebSocket connections
47
+ if (hasUsers()) {
48
+ const cookies = parseCookies(request.headers.cookie);
49
+ const token = cookies[COOKIE_NAME];
50
+ const user = token ? validateSession(token) : null;
51
+
52
+ if (!user) {
53
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
54
+ socket.destroy();
55
+ return;
56
+ }
57
+ }
58
+
38
59
  if (pathname === "/ws/terminal") {
39
60
  terminalWss.handleUpgrade(request, socket, head, (ws) => {
40
61
  terminalWss.emit("connection", ws, request);
@@ -147,7 +168,9 @@ app.prepare().then(async () => {
147
168
  await initDb();
148
169
  console.log("> Database initialized");
149
170
 
171
+ setupHooks();
150
172
  startWatcher();
173
+ startStatusMonitor();
151
174
 
152
175
  server.listen(port, () => {
153
176
  console.log(`> ClaudeDeck ready on http://${hostname}:${port}`);