@atercates/claude-deck 0.2.3 → 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.
@@ -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
  }
@@ -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(
@@ -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",
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"
package/server.ts CHANGED
@@ -5,6 +5,8 @@ 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";
8
10
  import {
9
11
  validateSession,
10
12
  parseCookies,
@@ -166,7 +168,9 @@ app.prepare().then(async () => {
166
168
  await initDb();
167
169
  console.log("> Database initialized");
168
170
 
171
+ setupHooks();
169
172
  startWatcher();
173
+ startStatusMonitor();
170
174
 
171
175
  server.listen(port, () => {
172
176
  console.log(`> ClaudeDeck ready on http://${hostname}:${port}`);