@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,83 @@
1
+ import { randomBytes } from "crypto";
2
+ import { queries } from "@/lib/db";
3
+ import type { User } from "@/lib/db";
4
+
5
+ const SESSION_DURATION_DAYS = 30;
6
+ const SESSION_TOKEN_BYTES = 32;
7
+
8
+ export function createSession(userId: string): {
9
+ token: string;
10
+ expiresAt: string;
11
+ } {
12
+ const id = randomBytes(16).toString("hex");
13
+ const token = randomBytes(SESSION_TOKEN_BYTES).toString("hex");
14
+ const expiresAt = new Date(
15
+ Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000
16
+ ).toISOString();
17
+
18
+ queries.createAuthSession(id, token, userId, expiresAt);
19
+
20
+ return { token, expiresAt };
21
+ }
22
+
23
+ export function validateSession(token: string): User | null {
24
+ if (!token || token.length !== SESSION_TOKEN_BYTES * 2) return null;
25
+
26
+ const session = queries.getAuthSessionByToken(token);
27
+ if (!session) return null;
28
+
29
+ if (new Date(session.expires_at) < new Date()) {
30
+ queries.deleteAuthSession(token);
31
+ return null;
32
+ }
33
+
34
+ const user = queries.getUserById(session.user_id);
35
+ if (!user) {
36
+ queries.deleteAuthSession(token);
37
+ return null;
38
+ }
39
+
40
+ return user;
41
+ }
42
+
43
+ export function renewSession(token: string): void {
44
+ const expiresAt = new Date(
45
+ Date.now() + SESSION_DURATION_DAYS * 24 * 60 * 60 * 1000
46
+ ).toISOString();
47
+ queries.renewAuthSession(token, expiresAt);
48
+ }
49
+
50
+ export function deleteSession(token: string): void {
51
+ queries.deleteAuthSession(token);
52
+ }
53
+
54
+ export function cleanupExpiredSessions(): void {
55
+ queries.deleteExpiredAuthSessions();
56
+ }
57
+
58
+ export const COOKIE_NAME = "claude_deck_session";
59
+
60
+ export function buildSessionCookie(token: string): string {
61
+ const maxAge = SESSION_DURATION_DAYS * 24 * 60 * 60;
62
+ return `${COOKIE_NAME}=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
63
+ }
64
+
65
+ export function buildClearCookie(): string {
66
+ return `${COOKIE_NAME}=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`;
67
+ }
68
+
69
+ export function parseCookies(
70
+ cookieHeader: string | undefined
71
+ ): Record<string, string> {
72
+ if (!cookieHeader) return {};
73
+ return Object.fromEntries(
74
+ cookieHeader.split(";").map((c) => {
75
+ const [key, ...rest] = c.trim().split("=");
76
+ return [key, rest.join("=")];
77
+ })
78
+ );
79
+ }
80
+
81
+ export function hasUsers(): boolean {
82
+ return queries.getUserCount() > 0;
83
+ }
@@ -0,0 +1,36 @@
1
+ import { TOTP, Secret } from "otpauth";
2
+
3
+ const ISSUER = "ClaudeDeck";
4
+
5
+ export function generateTotpSecret(username: string): {
6
+ secret: string;
7
+ uri: string;
8
+ } {
9
+ const secret = new Secret({ size: 20 });
10
+ const totp = new TOTP({
11
+ issuer: ISSUER,
12
+ label: username,
13
+ algorithm: "SHA1",
14
+ digits: 6,
15
+ period: 30,
16
+ secret,
17
+ });
18
+
19
+ return {
20
+ secret: secret.base32,
21
+ uri: totp.toString(),
22
+ };
23
+ }
24
+
25
+ export function verifyTotpCode(secret: string, code: string): boolean {
26
+ const totp = new TOTP({
27
+ issuer: ISSUER,
28
+ algorithm: "SHA1",
29
+ digits: 6,
30
+ period: 30,
31
+ secret: Secret.fromBase32(secret),
32
+ });
33
+
34
+ const delta = totp.validate({ token: code, window: 1 });
35
+ return delta !== null;
36
+ }
@@ -3,6 +3,8 @@ 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";
7
+ import { STATES_DIR } from "../hooks/setup";
6
8
 
7
9
  const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
8
10
 
@@ -13,7 +15,7 @@ export function addUpdateClient(ws: WebSocket): void {
13
15
  ws.on("close", () => updateClients.delete(ws));
14
16
  }
15
17
 
16
- function broadcast(msg: object): void {
18
+ export function broadcast(msg: object): void {
17
19
  const data = JSON.stringify(msg);
18
20
  for (const ws of updateClients) {
19
21
  if (ws.readyState === WebSocket.OPEN) {
@@ -44,14 +46,21 @@ function handleFileChange(filePath: string): void {
44
46
 
45
47
  export function startWatcher(): void {
46
48
  try {
47
- const watcher = watch(CLAUDE_PROJECTS_DIR, {
49
+ // Watch Claude projects for session list updates
50
+ const projectsWatcher = watch(CLAUDE_PROJECTS_DIR, {
48
51
  ignoreInitial: true,
49
52
  depth: 2,
50
53
  ignored: [/node_modules/, /\.git/, /subagents/],
51
54
  });
52
55
 
53
- watcher.on("change", handleFileChange);
54
- watcher.on("add", (fp) => {
56
+ projectsWatcher.on("change", (fp) => {
57
+ handleFileChange(fp);
58
+ if (fp.endsWith(".jsonl")) {
59
+ const sessionId = path.basename(fp, ".jsonl");
60
+ invalidateSessionName(sessionId);
61
+ }
62
+ });
63
+ projectsWatcher.on("add", (fp) => {
55
64
  handleFileChange(fp);
56
65
  const relative = path.relative(CLAUDE_PROJECTS_DIR, fp);
57
66
  if (!relative.includes(path.sep)) {
@@ -59,12 +68,26 @@ export function startWatcher(): void {
59
68
  broadcast({ type: "projects-changed" });
60
69
  }
61
70
  });
62
- watcher.on("addDir", () => {
71
+ projectsWatcher.on("addDir", () => {
63
72
  invalidateAll();
64
73
  broadcast({ type: "projects-changed" });
65
74
  });
66
75
 
67
76
  console.log("> File watcher started on ~/.claude/projects/");
77
+
78
+ // Watch session state files written by hooks
79
+ const statesWatcher = watch(STATES_DIR, {
80
+ ignoreInitial: true,
81
+ depth: 0,
82
+ });
83
+
84
+ statesWatcher.on("change", onStateFileChange);
85
+ statesWatcher.on("add", onStateFileChange);
86
+ statesWatcher.on("unlink", onStateFileChange);
87
+
88
+ console.log(
89
+ "> State file watcher started on ~/.claude-deck/session-states/"
90
+ );
68
91
  } catch (err) {
69
92
  console.error("Failed to start file watcher:", err);
70
93
  }
package/lib/db/queries.ts CHANGED
@@ -6,6 +6,8 @@ import type {
6
6
  ProjectDevServer,
7
7
  ProjectRepository,
8
8
  DevServer,
9
+ User,
10
+ AuthSession,
9
11
  } from "./types";
10
12
 
11
13
  function query<T>(sql: string, params: unknown[] = []): T[] {
@@ -457,4 +459,66 @@ export const queries = {
457
459
  itemType,
458
460
  itemId,
459
461
  ]),
462
+
463
+ getUserCount(): number {
464
+ return (
465
+ queryOne<{ count: number }>("SELECT COUNT(*) as count FROM users") ?? {
466
+ count: 0,
467
+ }
468
+ ).count;
469
+ },
470
+
471
+ getUserByUsername(username: string): User | null {
472
+ return queryOne<User>("SELECT * FROM users WHERE username = ?", [username]);
473
+ },
474
+
475
+ getUserById(id: string): User | null {
476
+ return queryOne<User>("SELECT * FROM users WHERE id = ?", [id]);
477
+ },
478
+
479
+ createUser(
480
+ id: string,
481
+ username: string,
482
+ passwordHash: string,
483
+ totpSecret: string | null
484
+ ): void {
485
+ execute(
486
+ "INSERT INTO users (id, username, password_hash, totp_secret) VALUES (?, ?, ?, ?)",
487
+ [id, username, passwordHash, totpSecret]
488
+ );
489
+ },
490
+
491
+ getAuthSessionByToken(token: string): AuthSession | null {
492
+ return queryOne<AuthSession>(
493
+ "SELECT * FROM auth_sessions WHERE token = ?",
494
+ [token]
495
+ );
496
+ },
497
+
498
+ createAuthSession(
499
+ id: string,
500
+ token: string,
501
+ userId: string,
502
+ expiresAt: string
503
+ ): void {
504
+ execute(
505
+ "INSERT INTO auth_sessions (id, token, user_id, expires_at) VALUES (?, ?, ?, ?)",
506
+ [id, token, userId, expiresAt]
507
+ );
508
+ },
509
+
510
+ renewAuthSession(token: string, expiresAt: string): void {
511
+ execute("UPDATE auth_sessions SET expires_at = ? WHERE token = ?", [
512
+ expiresAt,
513
+ token,
514
+ ]);
515
+ },
516
+
517
+ deleteAuthSession(token: string): void {
518
+ execute("DELETE FROM auth_sessions WHERE token = ?", [token]);
519
+ },
520
+
521
+ deleteExpiredAuthSessions(): void {
522
+ execute("DELETE FROM auth_sessions WHERE expires_at < datetime('now')");
523
+ },
460
524
  };
package/lib/db/schema.ts CHANGED
@@ -110,5 +110,24 @@ export function createSchema(db: Database.Database): void {
110
110
 
111
111
  INSERT OR IGNORE INTO projects (id, name, working_directory, is_uncategorized, sort_order)
112
112
  VALUES ('uncategorized', 'Uncategorized', '~', 1, 999999);
113
+
114
+ CREATE TABLE IF NOT EXISTS users (
115
+ id TEXT PRIMARY KEY,
116
+ username TEXT NOT NULL UNIQUE,
117
+ password_hash TEXT NOT NULL,
118
+ totp_secret TEXT,
119
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
120
+ );
121
+
122
+ CREATE TABLE IF NOT EXISTS auth_sessions (
123
+ id TEXT PRIMARY KEY,
124
+ token TEXT NOT NULL UNIQUE,
125
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
126
+ expires_at TEXT NOT NULL,
127
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
128
+ );
129
+
130
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_token ON auth_sessions(token);
131
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);
113
132
  `);
114
133
  }
package/lib/db/types.ts CHANGED
@@ -90,3 +90,19 @@ export interface DevServer {
90
90
  created_at: string;
91
91
  updated_at: string;
92
92
  }
93
+
94
+ export interface User {
95
+ id: string;
96
+ username: string;
97
+ password_hash: string;
98
+ totp_secret: string | null;
99
+ created_at: string;
100
+ }
101
+
102
+ export interface AuthSession {
103
+ id: string;
104
+ token: string;
105
+ user_id: string;
106
+ expires_at: string;
107
+ created_at: string;
108
+ }
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claude Code hook reporter.
4
+ *
5
+ * Invoked by Claude Code hooks on state transitions. Reads JSON from stdin
6
+ * and writes a session state file to ~/.claude-deck/session-states/{session_id}.json.
7
+ *
8
+ * Installed to ~/.claude-deck/hooks/state-reporter by the setup module.
9
+ */
10
+
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+ import * as os from "os";
14
+
15
+ const STATES_DIR = path.join(os.homedir(), ".claude-deck", "session-states");
16
+
17
+ interface HookInput {
18
+ session_id: string;
19
+ hook_event_name: string;
20
+ tool_name?: string;
21
+ tool_input?: unknown;
22
+ last_assistant_message?: string;
23
+ stop_hook_active?: boolean;
24
+ reason?: string;
25
+ }
26
+
27
+ interface StateFile {
28
+ status: "running" | "waiting" | "idle";
29
+ lastLine: string;
30
+ waitingContext?: string;
31
+ ts: number;
32
+ }
33
+
34
+ function readStdin(): Promise<string> {
35
+ return new Promise((resolve) => {
36
+ let data = "";
37
+ process.stdin.setEncoding("utf-8");
38
+ process.stdin.on("data", (chunk) => (data += chunk));
39
+ process.stdin.on("end", () => resolve(data));
40
+ // Safety timeout — don't hang if stdin never closes
41
+ setTimeout(() => resolve(data), 1000);
42
+ });
43
+ }
44
+
45
+ function getStatePath(sessionId: string): string {
46
+ return path.join(STATES_DIR, `${sessionId}.json`);
47
+ }
48
+
49
+ function writeState(sessionId: string, state: StateFile): void {
50
+ fs.mkdirSync(STATES_DIR, { recursive: true });
51
+ fs.writeFileSync(getStatePath(sessionId), JSON.stringify(state));
52
+ }
53
+
54
+ function deleteState(sessionId: string): void {
55
+ try {
56
+ fs.unlinkSync(getStatePath(sessionId));
57
+ } catch {
58
+ // file may not exist
59
+ }
60
+ }
61
+
62
+ async function main(): Promise<void> {
63
+ const raw = await readStdin();
64
+ if (!raw.trim()) return;
65
+
66
+ let input: HookInput;
67
+ try {
68
+ input = JSON.parse(raw);
69
+ } catch {
70
+ return;
71
+ }
72
+
73
+ const { session_id, hook_event_name } = input;
74
+ if (!session_id) return;
75
+
76
+ switch (hook_event_name) {
77
+ case "SessionEnd":
78
+ deleteState(session_id);
79
+ break;
80
+
81
+ case "PermissionRequest":
82
+ writeState(session_id, {
83
+ status: "waiting",
84
+ lastLine: `Waiting: ${input.tool_name || "permission"}`,
85
+ waitingContext: input.tool_name
86
+ ? `Permission requested for ${input.tool_name}`
87
+ : "Permission requested",
88
+ ts: Date.now(),
89
+ });
90
+ break;
91
+
92
+ case "Stop":
93
+ // Only transition to idle if not in a stop-hook re-run loop
94
+ if (!input.stop_hook_active) {
95
+ writeState(session_id, {
96
+ status: "idle",
97
+ lastLine: input.last_assistant_message?.slice(0, 200) || "",
98
+ ts: Date.now(),
99
+ });
100
+ }
101
+ break;
102
+
103
+ default:
104
+ // UserPromptSubmit, SessionStart, PreToolUse, PostToolUse, PermissionDenied
105
+ writeState(session_id, {
106
+ status: "running",
107
+ lastLine: input.tool_name
108
+ ? `Running: ${input.tool_name}`
109
+ : "Running...",
110
+ ts: Date.now(),
111
+ });
112
+ break;
113
+ }
114
+ }
115
+
116
+ main().catch(() => process.exit(0));
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Hooks setup module.
3
+ *
4
+ * Installs the state-reporter script to ~/.claude-deck/hooks/ and
5
+ * merges hook configuration into ~/.claude/settings.json idempotently.
6
+ */
7
+
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ import * as os from "os";
11
+
12
+ const CLAUDE_DECK_DIR = path.join(os.homedir(), ".claude-deck");
13
+ const HOOKS_DIR = path.join(CLAUDE_DECK_DIR, "hooks");
14
+ const STATES_DIR = path.join(CLAUDE_DECK_DIR, "session-states");
15
+ const REPORTER_PATH = path.join(HOOKS_DIR, "state-reporter");
16
+ const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
17
+
18
+ // Events we hook into and whether they run async
19
+ const HOOK_EVENTS: Array<{ event: string; async: boolean }> = [
20
+ { event: "UserPromptSubmit", async: true },
21
+ { event: "PreToolUse", async: true },
22
+ { event: "PermissionRequest", async: false },
23
+ { event: "Elicitation", async: false },
24
+ { event: "Stop", async: true },
25
+ { event: "SessionStart", async: true },
26
+ { event: "SessionEnd", async: true },
27
+ ];
28
+
29
+ // The reporter is a self-contained Node.js script (no tsx, no ESM, no deps)
30
+ const REPORTER_SCRIPT = `#!/usr/bin/env node
31
+ "use strict";
32
+ var fs = require("fs");
33
+ var path = require("path");
34
+ var os = require("os");
35
+ var STATES_DIR = path.join(os.homedir(), ".claude-deck", "session-states");
36
+
37
+ var data = "";
38
+ process.stdin.setEncoding("utf-8");
39
+ process.stdin.on("data", function(c) { data += c; });
40
+ process.stdin.on("end", function() {
41
+ try {
42
+ var input = JSON.parse(data);
43
+ var id = input.session_id;
44
+ if (!id) return;
45
+ fs.mkdirSync(STATES_DIR, { recursive: true });
46
+ var fp = path.join(STATES_DIR, id + ".json");
47
+ var evt = input.hook_event_name;
48
+
49
+ if (evt === "SessionEnd") {
50
+ try { fs.unlinkSync(fp); } catch(e) {}
51
+ return;
52
+ }
53
+
54
+ var status = "running";
55
+ var lastLine = input.tool_name ? "Running: " + input.tool_name : "Running...";
56
+ var state = { status: status, lastLine: lastLine, ts: Date.now() };
57
+
58
+ if (evt === "SessionStart") {
59
+ state.status = "idle";
60
+ state.lastLine = "";
61
+ } else if (evt === "PermissionRequest" || evt === "Elicitation") {
62
+ state.status = "waiting";
63
+ state.lastLine = "Waiting: " + (input.tool_name || "input required");
64
+ state.waitingContext = input.tool_name
65
+ ? "Permission requested for " + input.tool_name
66
+ : "Input required";
67
+ } else if (evt === "Stop" && !input.stop_hook_active) {
68
+ state.status = "idle";
69
+ state.lastLine = (input.last_assistant_message || "").slice(0, 200);
70
+ }
71
+
72
+ fs.writeFileSync(fp, JSON.stringify(state));
73
+ } catch(e) {}
74
+ });
75
+ setTimeout(function() { process.exit(0); }, 2000);
76
+ `;
77
+
78
+ function installReporterScript(): void {
79
+ fs.mkdirSync(HOOKS_DIR, { recursive: true });
80
+ fs.mkdirSync(STATES_DIR, { recursive: true });
81
+ fs.writeFileSync(REPORTER_PATH, REPORTER_SCRIPT, { mode: 0o755 });
82
+ }
83
+
84
+ interface HookCommand {
85
+ type: "command";
86
+ command: string;
87
+ async?: boolean;
88
+ }
89
+
90
+ interface HookMatcher {
91
+ matcher?: string;
92
+ hooks: HookCommand[];
93
+ }
94
+
95
+ type SettingsHooks = Record<string, HookMatcher[]>;
96
+
97
+ interface Settings {
98
+ hooks?: SettingsHooks;
99
+ [key: string]: unknown;
100
+ }
101
+
102
+ function isOurHook(hook: HookCommand): boolean {
103
+ return hook.command === REPORTER_PATH;
104
+ }
105
+
106
+ function mergeHooksIntoSettings(): void {
107
+ let settings: Settings = {};
108
+
109
+ try {
110
+ const raw = fs.readFileSync(SETTINGS_PATH, "utf-8");
111
+ settings = JSON.parse(raw);
112
+ } catch {
113
+ // File doesn't exist or is invalid
114
+ }
115
+
116
+ if (!settings.hooks) {
117
+ settings.hooks = {};
118
+ }
119
+
120
+ let changed = false;
121
+
122
+ for (const { event, async: isAsync } of HOOK_EVENTS) {
123
+ if (!settings.hooks[event]) {
124
+ settings.hooks[event] = [];
125
+ }
126
+
127
+ const eventHooks = settings.hooks[event];
128
+ const alreadyInstalled = eventHooks.some((matcher) =>
129
+ matcher.hooks?.some((h) => isOurHook(h))
130
+ );
131
+
132
+ if (!alreadyInstalled) {
133
+ const hookDef: HookCommand = {
134
+ type: "command",
135
+ command: REPORTER_PATH,
136
+ };
137
+ if (isAsync) {
138
+ hookDef.async = true;
139
+ }
140
+ eventHooks.push({ hooks: [hookDef] });
141
+ changed = true;
142
+ }
143
+ }
144
+
145
+ if (changed) {
146
+ fs.mkdirSync(path.dirname(SETTINGS_PATH), { recursive: true });
147
+ fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
148
+ console.log("> Hooks configured in ~/.claude/settings.json");
149
+ }
150
+ }
151
+
152
+ export function setupHooks(): void {
153
+ try {
154
+ installReporterScript();
155
+ mergeHooksIntoSettings();
156
+ console.log(
157
+ "> Hook reporter installed at ~/.claude-deck/hooks/state-reporter"
158
+ );
159
+ } catch (err) {
160
+ console.error("Failed to setup hooks:", err);
161
+ }
162
+ }
163
+
164
+ export { STATES_DIR };
@@ -12,7 +12,8 @@ import { queries, type Session } from "./db";
12
12
  import { createWorktree, deleteWorktree } from "./worktrees";
13
13
  import { setupWorktree } from "./env-setup";
14
14
  import { type AgentType, getProvider } from "./providers";
15
- import { statusDetector } from "./status-detector";
15
+ import { getStatusSnapshot } from "./status-monitor";
16
+ import { getSessionIdFromName } from "./providers/registry";
16
17
  import { wrapWithBanner } from "./banner";
17
18
  import { runInBackground } from "./async-operations";
18
19
 
@@ -273,13 +274,10 @@ export async function getWorkers(
273
274
  const provider = getProvider(worker.agent_type || "claude");
274
275
  const tmuxSessionName = worker.tmux_name || `${provider.id}-${worker.id}`;
275
276
 
276
- // Get live status from tmux
277
- let liveStatus: string;
278
- try {
279
- liveStatus = await statusDetector.getStatus(tmuxSessionName);
280
- } catch {
281
- liveStatus = "dead";
282
- }
277
+ // Get live status from cached monitor snapshot
278
+ const sessionId = getSessionIdFromName(tmuxSessionName);
279
+ const snapshot = getStatusSnapshot();
280
+ const liveStatus = snapshot[sessionId]?.status || "dead";
283
281
 
284
282
  // Combine DB status with live status
285
283
  let status: WorkerInfo["status"];
@@ -53,7 +53,7 @@ export function isValidProviderId(value: string): value is ProviderId {
53
53
  }
54
54
 
55
55
  export function getManagedSessionPattern(): RegExp {
56
- return /^claude-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
56
+ return /^claude-(new-)?[0-9a-z]{4,}/i;
57
57
  }
58
58
 
59
59
  export function getProviderIdFromSessionName(