Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions .claude/hooks/dist/session-start-continuity.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// src/session-start-continuity.ts
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { execSync } from "child_process";
import { execSync, spawn } from "child_process";
function buildHandoffDirName(sessionName, sessionId) {
const uuidShort = sessionId.replace(/-/g, "").slice(0, 8);
return `${sessionName}-${uuidShort}`;
Expand Down Expand Up @@ -159,6 +160,62 @@ function getUnmarkedHandoffs() {
return [];
}
}
function ensureMemoryDaemon() {
const pidFile = path.join(os.homedir(), ".claude", "memory-daemon.pid");
if (fs.existsSync(pidFile)) {
try {
const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
if (!isNaN(pid)) {
try {
process.kill(pid, 0);
return null;
} catch {
}
}
} catch {
}
try {
fs.unlinkSync(pidFile);
} catch {
}
}
const hookDir = path.dirname(new URL(import.meta.url).pathname);
const possibleLocations = [
// 1. Relative to compiled hook (development: .claude/hooks/dist/ → opc/scripts/core/)
path.resolve(hookDir, "..", "..", "..", "opc", "scripts", "core", "memory_daemon.py"),
// 2. In .claude/scripts/core/ (wizard-installed)
path.resolve(hookDir, "..", "scripts", "core", "memory_daemon.py"),
// 3. Global ~/.claude/scripts/core/
path.join(os.homedir(), ".claude", "scripts", "core", "memory_daemon.py")
];
let daemonScript = null;
for (const loc of possibleLocations) {
if (fs.existsSync(loc)) {
daemonScript = loc;
break;
}
}
if (!daemonScript) {
console.error("Warning: memory_daemon.py not found, cannot auto-start memory daemon");
return null;
}
try {
const cwd = path.resolve(daemonScript, "..", "..", "..");
const logFile = path.join(os.homedir(), ".claude", "memory-daemon.log");
const logFd = fs.openSync(logFile, "a");
const child = spawn("uv", ["run", daemonScript, "start"], {
cwd,
stdio: ["ignore", logFd, logFd],
detached: true
});
child.unref();
fs.closeSync(logFd);
return "Memory daemon: Started";
} catch (e) {
console.error(`Warning: Failed to start memory daemon: ${e}`);
return null;
}
}
async function main() {
const input = JSON.parse(await readStdin());
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
Expand Down Expand Up @@ -374,6 +431,10 @@ All handoffs in ${handoffDir}:
}
}
}
const daemonStatus = ensureMemoryDaemon();
if (daemonStatus) {
console.error(`\u2713 ${daemonStatus}`);
}
const output = { result: "continue" };
if (message) {
output.message = message;
Expand All @@ -388,10 +449,10 @@ All handoffs in ${handoffDir}:
console.log(JSON.stringify(output));
}
async function readStdin() {
return new Promise((resolve) => {
return new Promise((resolve2) => {
let data = "";
process.stdin.on("data", (chunk) => data += chunk);
process.stdin.on("end", () => resolve(data));
process.stdin.on("end", () => resolve2(data));
});
}
main().catch(console.error);
Expand Down
85 changes: 84 additions & 1 deletion .claude/hooks/src/session-start-continuity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { execSync } from 'child_process';
import { execSync, spawn } from 'child_process';

interface SessionStartInput {
type?: 'startup' | 'resume' | 'clear' | 'compact'; // Legacy field
Expand Down Expand Up @@ -308,6 +309,82 @@ function getUnmarkedHandoffs(): UnmarkedHandoff[] {
}
}

/**
* Start global memory extraction daemon if not running.
*
* The memory daemon monitors for stale sessions (heartbeat > 5 min)
* and automatically extracts learnings when sessions end.
*
* Returns status message or null if daemon was already running.
*/
function ensureMemoryDaemon(): string | null {
const pidFile = path.join(os.homedir(), '.claude', 'memory-daemon.pid');

// Check if already running
if (fs.existsSync(pidFile)) {
try {
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
if (!isNaN(pid)) {
// Check if process exists (kill -0)
try {
process.kill(pid, 0);
return null; // Already running
} catch {
// Process not found — stale PID file
}
}
} catch {
// Can't read PID file
}
// Remove stale PID file
try { fs.unlinkSync(pidFile); } catch { /* ignore */ }
}

// Find daemon script
const hookDir = path.dirname(new URL(import.meta.url).pathname);
const possibleLocations = [
// 1. Relative to compiled hook (development: .claude/hooks/dist/ → opc/scripts/core/)
path.resolve(hookDir, '..', '..', '..', 'opc', 'scripts', 'core', 'memory_daemon.py'),
// 2. In .claude/scripts/core/ (wizard-installed)
path.resolve(hookDir, '..', 'scripts', 'core', 'memory_daemon.py'),
// 3. Global ~/.claude/scripts/core/
path.join(os.homedir(), '.claude', 'scripts', 'core', 'memory_daemon.py'),
];

let daemonScript: string | null = null;
for (const loc of possibleLocations) {
if (fs.existsSync(loc)) {
daemonScript = loc;
break;
}
}

if (!daemonScript) {
console.error('Warning: memory_daemon.py not found, cannot auto-start memory daemon');
return null;
}

try {
// cwd = opc/ directory (3 levels up from the script)
const cwd = path.resolve(daemonScript, '..', '..', '..');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

cwd derivation is correct only for path option 1; uv run may fail for wizard-installed/global paths

The comment "3 levels up from the script" is accurate for path 1 (opc/scripts/core/memory_daemon.pyopc/), which is where uv's lockfile likely lives. However for paths 2 and 3 the three-level walk resolves to .claude/ and ~/.claude/ respectively — directories that probably lack a pyproject.toml/uv.lock. Running uv run from those locations may silently fall back to a different Python environment or fail.

Consider deriving the cwd based on which path matched (e.g., always resolving to the directory that contains pyproject.toml), or documenting the expected project structure for wizard and global installs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/hooks/src/session-start-continuity.ts at line 369, The current cwd
calculation (const cwd = path.resolve(daemonScript, '..', '..', '..')) only
works for one install layout and can point to directories without
pyproject.toml/uv.lock; change it to walk up from daemonScript (using the
daemonScript path variable) until you find a directory containing pyproject.toml
or uv.lock and set cwd to that directory (use that as the working dir for uv
run), and only fall back to the three-level-up resolution if no project marker
is found; update any related logic in session-start-continuity.ts that uses
daemonScript and cwd accordingly.

const logFile = path.join(os.homedir(), '.claude', 'memory-daemon.log');

const logFd = fs.openSync(logFile, 'a');
const child = spawn('uv', ['run', daemonScript, 'start'], {
cwd,
stdio: ['ignore', logFd, logFd],
detached: true,
});
child.unref();
fs.closeSync(logFd);
Comment on lines +372 to +379
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Two issues: unhandled error event crashes the hook; logFd leaks if spawn throws

1. Missing error listener (critical): If uv is not in PATH, spawn() emits an asynchronous 'error' event on the child object. Without a listener, Node.js re-throws it as an uncaught exception — this is not caught by the enclosing try/catch (which only catches synchronous throws) and not caught by main().catch() (which only handles rejected promises). The hook process crashes before writing any JSON output.

2. File-descriptor leak (major): logFd is opened before spawn() but fs.closeSync(logFd) is only reachable if spawn() succeeds (does not throw synchronously). Use a finally block.

🐛 Proposed fix
-    const logFd = fs.openSync(logFile, 'a');
-    const child = spawn('uv', ['run', daemonScript, 'start'], {
-      cwd,
-      stdio: ['ignore', logFd, logFd],
-      detached: true,
-    });
-    child.unref();
-    fs.closeSync(logFd);
+    let logFd: number | null = null;
+    try {
+      logFd = fs.openSync(logFile, 'a');
+      const child = spawn('uv', ['run', daemonScript, 'start'], {
+        cwd,
+        stdio: ['ignore', logFd, logFd],
+        detached: true,
+      });
+      child.on('error', (err) => {
+        console.error(`Warning: memory daemon spawn failed (uv not found?): ${err.message}`);
+      });
+      child.unref();
+    } finally {
+      if (logFd !== null) fs.closeSync(logFd);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/hooks/src/session-start-continuity.ts around lines 372 - 379, Wrap
the spawn call in a try/finally so logFd is always closed, and after creating
the child attach an 'error' listener to the spawned process to prevent an
unhandled exception; specifically, when calling spawn('uv', ['run',
daemonScript, 'start'], ...) ensure you add child.on('error', err => { /*
emit/serialize the same JSON failure output and exit gracefully */ }) before
unref(), and move fs.closeSync(logFd) into a finally block so logFd is closed
whether spawn throws synchronously or not (references: logFd, spawn('uv', ...),
child, daemonScript, child.unref(), fs.closeSync).


return 'Memory daemon: Started';
} catch (e) {
console.error(`Warning: Failed to start memory daemon: ${e}`);
return null;
}
}

async function main() {
const input: SessionStartInput = JSON.parse(await readStdin());
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
Expand Down Expand Up @@ -557,6 +634,12 @@ async function main() {
}
}

// Ensure memory daemon is running (auto-extracts learnings from ended sessions)
const daemonStatus = ensureMemoryDaemon();
if (daemonStatus) {
console.error(`✓ ${daemonStatus}`);
}
Comment on lines +637 to +641
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Daemon is never started for new projects — early return at line 517 bypasses this call

The if (!usedHandoffLedger) branch at line 512 contains an unconditional return at line 517 when thoughts/ledgers does not exist (labelled "normal for new projects"). That return exits main() before reaching line 638, so ensureMemoryDaemon() is never invoked for any project without both a handoff ledger and a legacy ledger directory. This directly defeats the PR's stated goal of auto-starting the daemon on every session start.

Move the ensureMemoryDaemon() invocation to before the handoff/ledger conditional blocks (e.g., immediately after reading projectDir), or replace the early return at line 517 with a break/flag and fall through to the daemon call and JSON output.

🐛 Proposed fix — hoist the daemon call
 async function main() {
   const input: SessionStartInput = JSON.parse(await readStdin());
   const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
   const sessionType = input.source || input.type;

+  // Ensure memory daemon is running regardless of ledger state
+  const daemonStatus = ensureMemoryDaemon();
+  if (daemonStatus) {
+    console.error(`✓ ${daemonStatus}`);
+  }
+
   let message = '';
   ...

And remove the duplicate call at line 637–641.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.claude/hooks/src/session-start-continuity.ts around lines 637 - 641, The
ensureMemoryDaemon() call is never reached when main() returns early from the if
(!usedHandoffLedger) branch; move the daemon startup so it always runs: invoke
ensureMemoryDaemon() immediately after projectDir is read (before the
handoff/ledger checks) and remove the duplicate call later, or alternatively
replace the unconditional return in the if (!usedHandoffLedger) block
(referenced by usedHandoffLedger and main()) with a fallthrough mechanism (set a
flag or break out) so execution continues to the ensureMemoryDaemon() call and
subsequent JSON output; ensure the unique function ensureMemoryDaemon() is only
called once.


// Output with proper format per Claude Code docs
const output: Record<string, unknown> = { result: 'continue' };

Expand Down