@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,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
|
+
}
|
package/lib/auth/totp.ts
ADDED
|
@@ -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
|
+
}
|
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
|
}
|
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 };
|
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(
|