diff --git a/src/bin.ts b/src/bin.ts
index 26dad55..354cf2d 100644
--- a/src/bin.ts
+++ b/src/bin.ts
@@ -3,6 +3,7 @@ import * as http from "node:http";
import * as net from "node:net";
import { installHooks, removeHooks } from "./hooks.ts";
import { install, readLockFile, removeLockFile, uninstall, writeLockFile } from "./installer.ts";
+import { createLogger } from "./logging.ts";
import { createServer } from "./server.ts";
import { createStore } from "./state.ts";
@@ -257,6 +258,7 @@ function main(): void {
function startDashboard(port: number, noHooks: boolean, noOpen: boolean): void {
const store = createStore();
+ const logger = createLogger();
let cleanedUp = false;
function cleanup() {
@@ -276,6 +278,7 @@ function startDashboard(port: number, noHooks: boolean, noOpen: boolean): void {
const dashboard = createServer({
store,
+ logger,
onShutdown() {
cleanup();
process.exit(0);
diff --git a/src/dashboard.ts b/src/dashboard.ts
index 71aa85c..a66fe1f 100644
--- a/src/dashboard.ts
+++ b/src/dashboard.ts
@@ -442,6 +442,13 @@ export function getDashboardHtml(): string {
@@ -455,6 +462,7 @@ export function getDashboardHtml(): string {
var btnRestart = document.getElementById('btnRestart');
var notifToggle = document.getElementById('notifToggle');
var notifBanner = document.getElementById('notifBanner');
+ var logToggle = document.getElementById('logToggle');
var notificationsEnabled = localStorage.getItem('notificationsEnabled') !== 'false';
var sessions = [];
var previousStatuses = {};
@@ -720,6 +728,34 @@ export function getDashboardHtml(): string {
};
}
+ // Logging toggle
+ function fetchLoggingStatus() {
+ var req = new XMLHttpRequest();
+ req.open('GET', '/api/logging', true);
+ req.onload = function() {
+ if (req.status === 200) {
+ var data = JSON.parse(req.responseText);
+ logToggle.checked = data.enabled;
+ }
+ };
+ req.send();
+ }
+
+ logToggle.addEventListener('change', function() {
+ var req = new XMLHttpRequest();
+ req.open('POST', '/api/logging', true);
+ req.setRequestHeader('Content-Type', 'application/json');
+ req.onload = function() {
+ if (req.status === 200) {
+ var data = JSON.parse(req.responseText);
+ logToggle.checked = data.enabled;
+ }
+ };
+ req.send(JSON.stringify({ enabled: logToggle.checked }));
+ });
+
+ fetchLoggingStatus();
+
// Update time-ago values every 10 seconds
setInterval(render, 10000);
diff --git a/src/hooks.test.ts b/src/hooks.test.ts
index f7e5314..6e3a2a2 100644
--- a/src/hooks.test.ts
+++ b/src/hooks.test.ts
@@ -37,7 +37,7 @@ describe("installHooks", () => {
assert.ok(settings.hooks);
});
- it("creates all 7 hook events including PreToolUse, PermissionRequest, and PostToolUse", () => {
+ it("creates all 9 hook events including PreToolUse, PermissionRequest, PostToolUse, Notification, and SubagentStop", () => {
installHooks(8377, tmpDir);
const settings = readSettings() as { hooks: Record };
assert.ok(settings.hooks.SessionStart);
@@ -47,6 +47,8 @@ describe("installHooks", () => {
assert.ok(settings.hooks.PreToolUse);
assert.ok(settings.hooks.PermissionRequest);
assert.ok(settings.hooks.PostToolUse);
+ assert.ok(settings.hooks.Notification);
+ assert.ok(settings.hooks.SubagentStop);
});
it("PreToolUse hook captures all tools without matcher", () => {
diff --git a/src/hooks.ts b/src/hooks.ts
index 1fc9855..806b6f9 100644
--- a/src/hooks.ts
+++ b/src/hooks.ts
@@ -13,6 +13,8 @@ const HOOK_EVENTS = [
"SessionEnd",
"PermissionRequest",
"PostToolUse",
+ "Notification",
+ "SubagentStop",
] as const;
interface HookEntry {
diff --git a/src/logging.test.ts b/src/logging.test.ts
new file mode 100644
index 0000000..a989b63
--- /dev/null
+++ b/src/logging.test.ts
@@ -0,0 +1,146 @@
+import assert from "node:assert/strict";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { afterEach, beforeEach, describe, it } from "node:test";
+import { createLogger } from "./logging.ts";
+
+let tmpDir: string;
+let logDir: string;
+
+beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccd-log-test-"));
+ logDir = path.join(tmpDir, "logs");
+});
+
+afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+describe("createLogger", () => {
+ it("starts disabled by default", () => {
+ const logger = createLogger(logDir);
+ assert.equal(logger.isEnabled(), false);
+ });
+
+ it("can be enabled and disabled", () => {
+ const logger = createLogger(logDir);
+ logger.setEnabled(true);
+ assert.equal(logger.isEnabled(), true);
+ logger.setEnabled(false);
+ assert.equal(logger.isEnabled(), false);
+ });
+
+ it("does not write logs when disabled", () => {
+ const logger = createLogger(logDir);
+ logger.logEvent(
+ { session_id: "s1", hook_event_name: "SessionStart", cwd: "/test" },
+ {
+ sessionId: "s1",
+ status: "done",
+ cwd: "/test",
+ lastEvent: "SessionStart",
+ updatedAt: Date.now(),
+ startedAt: Date.now(),
+ },
+ );
+ assert.equal(fs.existsSync(logDir), false);
+ });
+
+ it("writes log file when enabled", () => {
+ const logger = createLogger(logDir);
+ logger.setEnabled(true);
+ logger.logEvent(
+ { session_id: "s1", hook_event_name: "SessionStart", cwd: "/test" },
+ {
+ sessionId: "s1",
+ status: "done",
+ cwd: "/test",
+ lastEvent: "SessionStart",
+ updatedAt: Date.now(),
+ startedAt: Date.now(),
+ },
+ );
+
+ assert.ok(fs.existsSync(logDir));
+
+ const files = fs.readdirSync(logDir);
+ assert.equal(files.length, 1);
+ assert.ok(files[0].endsWith(".log"));
+
+ const content = fs.readFileSync(path.join(logDir, files[0]), "utf-8");
+ const entry = JSON.parse(content.trim());
+ assert.equal(entry.hook_event_name, "SessionStart");
+ assert.equal(entry.session_id, "s1");
+ assert.equal(entry.status, "done");
+ assert.ok(entry.timestamp);
+ assert.ok(entry.payload);
+ });
+
+ it("logs null session for SessionEnd events", () => {
+ const logger = createLogger(logDir);
+ logger.setEnabled(true);
+ logger.logEvent({ session_id: "s1", hook_event_name: "SessionEnd" }, null);
+
+ const files = fs.readdirSync(logDir);
+ const content = fs.readFileSync(path.join(logDir, files[0]), "utf-8");
+ const entry = JSON.parse(content.trim());
+ assert.equal(entry.status, "n/a");
+ assert.equal(entry.hook_event_name, "SessionEnd");
+ });
+
+ it("appends multiple entries to same log file", () => {
+ const logger = createLogger(logDir);
+ logger.setEnabled(true);
+ logger.logEvent(
+ { session_id: "s1", hook_event_name: "SessionStart", cwd: "/test" },
+ {
+ sessionId: "s1",
+ status: "done",
+ cwd: "/test",
+ lastEvent: "SessionStart",
+ updatedAt: Date.now(),
+ startedAt: Date.now(),
+ },
+ );
+ logger.logEvent(
+ { session_id: "s1", hook_event_name: "UserPromptSubmit", cwd: "/test" },
+ {
+ sessionId: "s1",
+ status: "running",
+ cwd: "/test",
+ lastEvent: "UserPromptSubmit",
+ updatedAt: Date.now(),
+ startedAt: Date.now(),
+ },
+ );
+
+ const files = fs.readdirSync(logDir);
+ assert.equal(files.length, 1);
+
+ const content = fs.readFileSync(path.join(logDir, files[0]), "utf-8");
+ const lines = content.trim().split("\n");
+ assert.equal(lines.length, 2);
+ });
+
+ it("includes full payload with extra fields like tool_name", () => {
+ const logger = createLogger(logDir);
+ logger.setEnabled(true);
+ logger.logEvent(
+ { session_id: "s1", hook_event_name: "PreToolUse", tool_name: "Bash", cwd: "/test" },
+ {
+ sessionId: "s1",
+ status: "running",
+ cwd: "/test",
+ lastEvent: "Bash",
+ updatedAt: Date.now(),
+ startedAt: Date.now(),
+ },
+ );
+
+ const files = fs.readdirSync(logDir);
+ const content = fs.readFileSync(path.join(logDir, files[0]), "utf-8");
+ const entry = JSON.parse(content.trim());
+ assert.equal(entry.payload.tool_name, "Bash");
+ });
+});
diff --git a/src/logging.ts b/src/logging.ts
new file mode 100644
index 0000000..e5d7d71
--- /dev/null
+++ b/src/logging.ts
@@ -0,0 +1,61 @@
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import type { HookPayload, Session } from "./state.ts";
+
+export interface Logger {
+ isEnabled(): boolean;
+ setEnabled(enabled: boolean): void;
+ logEvent(payload: HookPayload, session: Session | null): void;
+}
+
+function getLogDir(logDir?: string): string {
+ return logDir ?? path.join(os.homedir(), ".claude-code-dashboard", "logs");
+}
+
+function getLogFilePath(logDir?: string): string {
+ const date = new Date().toISOString().slice(0, 10);
+ return path.join(getLogDir(logDir), `${date}.log`);
+}
+
+function formatLogEntry(payload: HookPayload, session: Session | null): string {
+ const timestamp = new Date().toISOString();
+ const status = session ? session.status : "n/a";
+ const entry = {
+ timestamp,
+ status,
+ hook_event_name: payload.hook_event_name,
+ session_id: payload.session_id,
+ payload,
+ };
+ return JSON.stringify(entry);
+}
+
+export function createLogger(logDir?: string): Logger {
+ let enabled = false;
+
+ return {
+ isEnabled() {
+ return enabled;
+ },
+
+ setEnabled(value: boolean) {
+ enabled = value;
+ },
+
+ logEvent(payload: HookPayload, session: Session | null) {
+ if (!enabled) return;
+
+ try {
+ const dir = getLogDir(logDir);
+ fs.mkdirSync(dir, { recursive: true });
+
+ const logFile = getLogFilePath(logDir);
+ const line = `${formatLogEntry(payload, session)}\n`;
+ fs.appendFileSync(logFile, line, "utf-8");
+ } catch {
+ // Silently ignore logging errors to avoid disrupting the dashboard
+ }
+ },
+ };
+}
diff --git a/src/server.test.ts b/src/server.test.ts
index ab7da86..fc76169 100644
--- a/src/server.test.ts
+++ b/src/server.test.ts
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import * as http from "node:http";
import { afterEach, describe, it } from "node:test";
+import { createLogger } from "./logging.ts";
import { createServer, type DashboardServer, type ServerOptions } from "./server.ts";
import { createStore } from "./state.ts";
@@ -11,8 +12,10 @@ function startServer(
): Promise<{ port: number; dashboard: DashboardServer }> {
return new Promise((resolve) => {
const store = createStore();
+ const logger = createLogger();
const d = createServer({
store,
+ logger,
onShutdown: opts?.onShutdown,
onRestart: opts?.onRestart,
idleTimeoutMs: opts?.idleTimeoutMs,
diff --git a/src/server.ts b/src/server.ts
index 785f400..586ed50 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -1,5 +1,6 @@
import * as http from "node:http";
import { getDashboardHtml } from "./dashboard.ts";
+import type { Logger } from "./logging.ts";
import type { HookPayload, Store } from "./state.ts";
export interface DashboardServer {
@@ -10,6 +11,7 @@ export interface DashboardServer {
export interface ServerOptions {
store: Store;
+ logger: Logger;
idleTimeoutMs?: number;
cleanupIntervalMs?: number;
onShutdown?: () => void;
@@ -17,7 +19,7 @@ export interface ServerOptions {
}
export function createServer(options: ServerOptions): DashboardServer {
- const { store, onShutdown, onRestart } = options;
+ const { store, logger, onShutdown, onRestart } = options;
const idleTimeoutMs = options.idleTimeoutMs ?? 5 * 60 * 1000;
const cleanupIntervalMs = options.cleanupIntervalMs ?? 60_000;
const sseClients = new Set();
@@ -55,7 +57,8 @@ export function createServer(options: ServerOptions): DashboardServer {
res.end(JSON.stringify({ error: "Missing session_id or hook_event_name" }));
return;
}
- store.handleEvent(payload);
+ const session = store.handleEvent(payload);
+ logger.logEvent(payload, session);
if (payload.hook_event_name !== "Ping") {
broadcast();
}
@@ -92,6 +95,36 @@ export function createServer(options: ServerOptions): DashboardServer {
return;
}
+ if (req.method === "GET" && pathname === "/api/logging") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ enabled: logger.isEnabled() }));
+ return;
+ }
+
+ if (req.method === "POST" && pathname === "/api/logging") {
+ let body = "";
+ req.on("data", (chunk: Buffer) => {
+ body += chunk.toString();
+ });
+ req.on("end", () => {
+ try {
+ const { enabled } = JSON.parse(body);
+ if (typeof enabled !== "boolean") {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Missing boolean 'enabled'" }));
+ return;
+ }
+ logger.setEnabled(enabled);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ enabled: logger.isEnabled() }));
+ } catch {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
+ }
+ });
+ return;
+ }
+
if (req.method === "POST" && pathname === "/api/shutdown") {
broadcastEvent("shutdown", JSON.stringify({ ok: true }));
res.writeHead(200, { "Content-Type": "application/json" });