@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.
- package/app/api/sessions/status/acknowledge/route.ts +8 -0
- package/app/api/sessions/status/route.ts +2 -233
- package/app/page.tsx +34 -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/claude/watcher.ts +28 -5
- 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 +1 -1
- package/server.ts +4 -0
- package/lib/status-detector.ts +0 -375
package/lib/claude/watcher.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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 };
|
package/lib/orchestration.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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-
|
|
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
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}`);
|