@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.
- package/app/api/auth/login/route.ts +57 -0
- package/app/api/auth/logout/route.ts +13 -0
- package/app/api/auth/session/route.ts +29 -0
- package/app/api/auth/setup/route.ts +67 -0
- package/app/api/sessions/status/acknowledge/route.ts +8 -0
- package/app/api/sessions/status/route.ts +2 -233
- package/app/login/page.tsx +192 -0
- package/app/page.tsx +34 -0
- package/app/setup/page.tsx +279 -0
- package/components/Pane/DesktopTabBar.tsx +62 -28
- package/components/Pane/index.tsx +5 -0
- package/components/QuickSwitcher.tsx +63 -11
- package/components/SessionList/ActiveSessionsSection.tsx +116 -0
- package/components/SessionList/index.tsx +9 -1
- package/components/SessionStatusBar.tsx +155 -0
- package/components/WaitingBanner.tsx +122 -0
- package/components/views/DesktopView.tsx +32 -8
- package/components/views/types.ts +2 -0
- package/data/statuses/queries.ts +68 -34
- package/lib/auth/index.ts +15 -0
- package/lib/auth/password.ts +14 -0
- package/lib/auth/rate-limit.ts +40 -0
- package/lib/auth/session.ts +83 -0
- package/lib/auth/totp.ts +36 -0
- package/lib/claude/watcher.ts +28 -5
- package/lib/db/queries.ts +64 -0
- package/lib/db/schema.ts +19 -0
- package/lib/db/types.ts +16 -0
- package/lib/hooks/reporter.ts +116 -0
- package/lib/hooks/setup.ts +164 -0
- package/lib/orchestration.ts +6 -8
- package/lib/providers/registry.ts +1 -1
- package/lib/status-monitor.ts +278 -0
- package/package.json +5 -1
- package/server.ts +23 -0
- package/lib/status-detector.ts +0 -375
|
@@ -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.
|
|
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}`);
|