diff --git a/embeddings/embed-daemon.js b/embeddings/embed-daemon.js index a81ffbd1..a491a879 100755 --- a/embeddings/embed-daemon.js +++ b/embeddings/embed-daemon.js @@ -2,7 +2,7 @@ // dist/src/embeddings/daemon.js import { createServer } from "node:net"; -import { unlinkSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "node:fs"; +import { unlinkSync, writeFileSync, existsSync, mkdirSync as mkdirSync2, chmodSync } from "node:fs"; // dist/src/embeddings/nomic.js import { createRequire } from "node:module"; @@ -133,8 +133,8 @@ var NomicEmbedder = class { }; // dist/src/utils/debug.js -import { appendFileSync } from "node:fs"; -import { join as join2 } from "node:path"; +import { appendFileSync, mkdirSync } from "node:fs"; +import { dirname, join as join2 } from "node:path"; import { homedir as homedir2 } from "node:os"; var LOG = join2(homedir2(), ".deeplake", "hook-debug.log"); function isDebug() { @@ -143,8 +143,12 @@ function isDebug() { function log(tag, msg) { if (!isDebug()) return; - appendFileSync(LOG, `${(/* @__PURE__ */ new Date()).toISOString()} [${tag}] ${msg} + try { + mkdirSync(dirname(LOG), { recursive: true }); + appendFileSync(LOG, `${(/* @__PURE__ */ new Date()).toISOString()} [${tag}] ${msg} `); + } catch { + } } // dist/src/embeddings/daemon.js @@ -171,7 +175,7 @@ var EmbedDaemon = class { this.daemonPath = opts.daemonPath ?? process.argv[1] ?? ""; } async start() { - mkdirSync(this.socketPath.replace(/\/[^/]+$/, ""), { recursive: true }); + mkdirSync2(this.socketPath.replace(/\/[^/]+$/, ""), { recursive: true }); writeFileSync(this.pidPath, String(process.pid), { mode: 384 }); if (existsSync(this.socketPath)) { try { diff --git a/src/cli/install-codex.ts b/src/cli/install-codex.ts index a0be66cf..8b93d073 100644 --- a/src/cli/install-codex.ts +++ b/src/cli/install-codex.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { join } from "node:path"; import { HOME, pkgRoot, ensureDir, copyDir, writeJson, writeJsonIfChanged, symlinkForce, writeVersionStamp, log, warn } from "./util.js"; @@ -257,6 +257,20 @@ export function installCodex(): void { warn(` Codex skill source missing at ${skillTarget}; skipping symlink`); } + // Link node_modules to embed-deps so graph-on-stop.js (which uses the + // external tree-sitter native module) can resolve it. The capture hook's + // ensurePluginNodeModulesLink skips existing real directories — so we + // replace an empty placeholder dir with a symlink here at install time. + const pluginNm = join(PLUGIN_DIR, "node_modules"); + const embedDepsNm = join(HOME, ".hivemind", "embed-deps", "node_modules"); + if (existsSync(embedDepsNm)) { + try { + const st = lstatSync(pluginNm); + if (st.isDirectory() && !st.isSymbolicLink()) rmSync(pluginNm, { recursive: true }); + } catch { /* not found — ok */ } + symlinkForce(embedDepsNm, pluginNm); + } + writeVersionStamp(PLUGIN_DIR, getVersion()); log(` Codex installed -> ${PLUGIN_DIR}`); } diff --git a/src/cli/install-cursor.ts b/src/cli/install-cursor.ts index 6e72fada..30ce82f1 100644 --- a/src/cli/install-cursor.ts +++ b/src/cli/install-cursor.ts @@ -1,6 +1,6 @@ -import { existsSync, unlinkSync } from "node:fs"; +import { existsSync, lstatSync, rmSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { HOME, pkgRoot, ensureDir, copyDir, readJson, writeJson, writeJsonIfChanged, writeVersionStamp, log } from "./util.js"; +import { HOME, pkgRoot, ensureDir, copyDir, readJson, writeJson, writeJsonIfChanged, symlinkForce, writeVersionStamp, log } from "./util.js"; import { getVersion } from "./version.js"; // Cursor 1.7+ hooks API: https://cursor.com/docs/agent/hooks @@ -113,6 +113,13 @@ export function installCursor(): void { // we don't perturb the hooks.json Cursor/Codex-style trust fingerprints. writeJsonIfChanged(HOOKS_PATH, merged); + const pluginNm = join(PLUGIN_DIR, "node_modules"); + const embedDepsNm = join(HOME, ".hivemind", "embed-deps", "node_modules"); + if (existsSync(embedDepsNm)) { + try { const st = lstatSync(pluginNm); if (st.isDirectory() && !st.isSymbolicLink()) rmSync(pluginNm, { recursive: true }); } catch { /* ok */ } + symlinkForce(embedDepsNm, pluginNm); + } + writeVersionStamp(PLUGIN_DIR, getVersion()); log(` Cursor installed -> ${PLUGIN_DIR}`); } diff --git a/src/cli/install-hermes.ts b/src/cli/install-hermes.ts index 584a9066..15a624f9 100644 --- a/src/cli/install-hermes.ts +++ b/src/cli/install-hermes.ts @@ -1,7 +1,7 @@ -import { existsSync, writeFileSync, readFileSync, rmSync, unlinkSync } from "node:fs"; +import { existsSync, lstatSync, writeFileSync, readFileSync, rmSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import * as yaml from "js-yaml"; -import { HOME, pkgRoot, ensureDir, copyDir, writeVersionStamp, log } from "./util.js"; +import { HOME, pkgRoot, ensureDir, copyDir, symlinkForce, writeVersionStamp, log } from "./util.js"; import { getVersion } from "./version.js"; import { ensureMcpServerInstalled, MCP_SERVER_PATH } from "./install-mcp-shared.js"; @@ -189,6 +189,12 @@ export function installHermes(): void { } ensureDir(HIVEMIND_DIR); copyDir(srcBundle, BUNDLE_DIR); + const pluginNm = join(HIVEMIND_DIR, "node_modules"); + const embedDepsNm = join(HOME, ".hivemind", "embed-deps", "node_modules"); + if (existsSync(embedDepsNm)) { + try { const st = lstatSync(pluginNm); if (st.isDirectory() && !st.isSymbolicLink()) rmSync(pluginNm, { recursive: true }); } catch { /* ok */ } + symlinkForce(embedDepsNm, pluginNm); + } writeVersionStamp(HIVEMIND_DIR, getVersion()); log(` Hermes bundle installed -> ${BUNDLE_DIR}`); diff --git a/src/hooks/capture.ts b/src/hooks/capture.ts index e9314a39..d182ca30 100644 --- a/src/hooks/capture.ts +++ b/src/hooks/capture.ts @@ -28,6 +28,7 @@ import { reactSkillOpt } from "./shared/skillopt-hook.js"; import { EmbedClient } from "../embeddings/client.js"; import { embeddingSqlLiteral } from "../embeddings/sql.js"; import { embeddingsDisabled } from "../embeddings/disable.js"; +import { isHivemindPluginEnabled } from "../utils/plugin-state.js"; import { ensurePluginNodeModulesLink } from "../embeddings/self-heal.js"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; @@ -76,6 +77,7 @@ const CAPTURE = process.env.HIVEMIND_CAPTURE !== "false"; async function main(): Promise { if (!CAPTURE) return; + if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; } if (!entrypointPassesOnlyCliGate()) return; const input = await readStdin(); const config = loadConfig(); diff --git a/src/hooks/codex/capture.ts b/src/hooks/codex/capture.ts index cf43c645..76049c33 100644 --- a/src/hooks/codex/capture.ts +++ b/src/hooks/codex/capture.ts @@ -34,6 +34,7 @@ import { } from "../summary-state.js"; import { bundleDirFromImportMeta, spawnCodexWikiWorker, wikiLog } from "./spawn-wiki-worker.js"; import { getInstalledVersion } from "../../utils/version-check.js"; +import { isHivemindPluginEnabled } from "../../utils/plugin-state.js"; import { reactSkillOpt } from "../shared/skillopt-hook.js"; const log = (msg: string) => _log("codex-capture", msg); @@ -72,6 +73,7 @@ const CAPTURE = process.env.HIVEMIND_CAPTURE !== "false"; async function main(): Promise { if (!CAPTURE) return; + if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; } const input = await readStdin(); const config = loadConfig(); if (!config) { log("no config"); return; } diff --git a/src/hooks/codex/pre-tool-use.ts b/src/hooks/codex/pre-tool-use.ts index cbab5ab1..49fda771 100644 --- a/src/hooks/codex/pre-tool-use.ts +++ b/src/hooks/codex/pre-tool-use.ts @@ -13,7 +13,9 @@ * spawning the bundled script in a subprocess. */ -import { join } from "node:path"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; import { readStdin } from "../../utils/stdin.js"; import { loadConfig } from "../../config.js"; import { DeeplakeApi } from "../../deeplake-api.js"; @@ -38,6 +40,8 @@ import { armSkillOptOnSkillUse } from "../shared/skillopt-hook.js"; export { isSafe, touchesMemory, rewritePaths }; +const __bundleDir = dirname(fileURLToPath(import.meta.url)); + const log = (msg: string) => _log("codex-pre", msg); export interface CodexPreToolUseInput { @@ -351,19 +355,41 @@ export async function processCodexPreToolUse( } } - // Nothing matched: no config, an unhandled command shape, or a direct-query - // error. Do NOT run the command through just-bash or the host shell — it only - // passed isSafe(), and just-bash can invoke real host binaries, which is the - // exact code-execution surface this hook exists to remove. BLOCK (exit 2) so - // the command never reaches the host, and inject the guidance as the result. - // (We can't use "guide" here: guide exits 0, which lets Codex run the original - // command on the host — the very thing we're preventing.) - logFn(`unroutable memory command, blocking with guidance: ${rewritten}`); - return { - action: "block", - output: buildUnsupportedGuidance(), - rewrittenCommand: rewritten, - }; + // Nothing matched by the inline fast-path. Route through the VFS shell bundle + // — a sandboxed Node.js interpreter against the SQL backend, no host access. + // We run it synchronously here (spawnSync) so the output is available before + // returning the decision. + // + // Action choice: + // "guide" (exit 0) — Codex treats the command as successful and also runs + // the original on the host. Safe ONLY for write-redirect patterns + // (echo/printf/tee … > /file) where the side-effect on the real + // ~/.deeplake/memory/ disk dir is harmless — VFS reads always query SQL. + // "block" (exit 2) — Codex treats the command as rejected. Used for + // everything else (pipes, finds, reads) to prevent host execution. + // Safe to return "guide" (Codex also runs original on host) ONLY for pure + // output commands: echo/printf/tee writing to a VFS path. A generic ">>" + // check would match mixed commands like `sort /etc/passwd > /vfs/out` which + // would then execute on the host and read real files. + const isWriteRedirect = /^\s*(echo|printf|tee)\b/.test(rewritten) && /\s>>?\s/.test(rewritten); + const shellBundle = join(__bundleDir, "shell", "deeplake-shell.js"); + logFn(`unroutable memory command, falling back to VFS shell: ${rewritten}`); + try { + const proc = spawnSync("node", [shellBundle, "-c", rewritten], { + encoding: "utf-8", + timeout: 10_000, + }); + if (proc.status === 0 || (proc.stdout && proc.stdout.trim())) { + const output = (proc.stdout?.trim() ?? "") || "(done)"; + // Write redirects: use "guide" so Codex reports success (not "blocked"). + // Other commands: keep "block" so the host shell never runs them. + return { action: isWriteRedirect ? "guide" : "block", output, rewrittenCommand: rewritten }; + } + // Shell exited non-zero (bundle missing or command failed) — fall back to guidance. + return { action: "block", output: buildUnsupportedGuidance(), rewrittenCommand: rewritten }; + } catch { + return { action: "block", output: buildUnsupportedGuidance(), rewrittenCommand: rewritten }; + } } /* c8 ignore start */ diff --git a/src/hooks/codex/stop.ts b/src/hooks/codex/stop.ts index d0dcaa44..26b3fb5b 100644 --- a/src/hooks/codex/stop.ts +++ b/src/hooks/codex/stop.ts @@ -145,6 +145,19 @@ async function main(): Promise { // Coordinate with the periodic worker: if one is already running for this // session, skip. Two workers writing the same summary row trip the // Deeplake UPDATE-coalescing quirk (see CLAUDE.md) and drop one write. + const cwd = input.cwd || process.cwd(); + + // Skillify has its own per-project lock — fire before the wiki-worker lock + // check so a Periodic trigger that already holds the lock doesn't suppress + // skill mining. + forceSessionEndTrigger({ + config, + cwd, + bundleDir: bundleDirFromImportMeta(import.meta.url), + agent: "codex", + sessionId, + }); + if (!tryAcquireLock(sessionId)) { wikiLog(`Stop: periodic worker already running for ${sessionId}, skipping`); return; @@ -155,7 +168,7 @@ async function main(): Promise { spawnCodexWikiWorker({ config, sessionId, - cwd: input.cwd ?? "", + cwd, bundleDir: bundleDirFromImportMeta(import.meta.url), reason: "Stop", }); @@ -171,18 +184,6 @@ async function main(): Promise { } throw e; } - - // Skillify: Codex Stop is the end-of-session signal (no separate SessionEnd - // hook). Always force-fire — same shape as Claude Code's SessionEnd path. - // The forceSessionEndTrigger helper resets the counter internally so the - // mid-session Stop counter doesn't double-fire on the same window. - forceSessionEndTrigger({ - config, - cwd: input.cwd ?? "", - bundleDir: bundleDirFromImportMeta(import.meta.url), - agent: "codex", - sessionId, - }); } main().catch((e) => { log(`fatal: ${e.message}`); process.exit(0); }); diff --git a/src/hooks/cursor/capture.ts b/src/hooks/cursor/capture.ts index e53a1fe3..1b9a2ce7 100644 --- a/src/hooks/cursor/capture.ts +++ b/src/hooks/cursor/capture.ts @@ -37,6 +37,7 @@ import { bundleDirFromImportMeta, spawnCursorWikiWorker, wikiLog } from "./spawn import { tryStopCounterTrigger } from "../../skillify/triggers.js"; import type { Config } from "../../config.js"; import { getInstalledVersion } from "../../utils/version-check.js"; +import { isHivemindPluginEnabled } from "../../utils/plugin-state.js"; const log = (msg: string) => _log("cursor-capture", msg); function resolveEmbedDaemonPath(): string { @@ -87,6 +88,7 @@ function resolveCwd(input: CursorCaptureInput): string { async function main(): Promise { if (!CAPTURE) return; + if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; } const input = await readStdin(); const config = loadConfig(); if (!config) { log("no config"); return; } diff --git a/src/hooks/cursor/session-end.ts b/src/hooks/cursor/session-end.ts index 927eefc7..7b8a4048 100644 --- a/src/hooks/cursor/session-end.ts +++ b/src/hooks/cursor/session-end.ts @@ -33,17 +33,31 @@ async function main(): Promise { const sessionId = input.conversation_id ?? input.session_id ?? ""; log(`session=${sessionId || "?"} reason=${input.reason ?? "?"} status=${input.final_status ?? "?"}`); if (!sessionId) return; + const config = loadConfig(); + if (!config) { wikiLog(`SessionEnd: no config, skipping summary`); return; } + + // Skillify has its own per-project lock — fire before the wiki-worker lock + // check so a Periodic trigger that already holds the lock doesn't suppress + // skill mining. + try { + forceSessionEndTrigger({ + config, + cwd: process.cwd(), + bundleDir: bundleDirFromImportMeta(import.meta.url), + agent: "cursor", + sessionId, + }); + } catch (e: any) { + wikiLog(`SessionEnd: skillify trigger failed: ${e?.message ?? e}`); + } + // Coordinate with the periodic worker: skip the final spawn if a periodic // is mid-flight. Lock TTL covers crashed workers. if (!tryAcquireLock(sessionId)) { wikiLog(`SessionEnd: periodic worker already running for ${sessionId}, skipping final`); return; } - const config = loadConfig(); - if (!config) { wikiLog(`SessionEnd: no config, skipping summary`); return; } - // Spawn the wiki and skillify workers independently — a failure of one - // must not suppress the other. Each is wrapped in its own try. try { spawnCursorWikiWorker({ config, @@ -55,17 +69,6 @@ async function main(): Promise { } catch (e: any) { wikiLog(`SessionEnd: wiki spawn failed: ${e?.message ?? e}`); } - try { - forceSessionEndTrigger({ - config, - cwd: process.cwd(), - bundleDir: bundleDirFromImportMeta(import.meta.url), - agent: "cursor", - sessionId, - }); - } catch (e: any) { - wikiLog(`SessionEnd: skillify trigger failed: ${e?.message ?? e}`); - } } main().catch((e) => { log(`fatal: ${e.message}`); process.exit(0); }); diff --git a/src/hooks/hermes/capture.ts b/src/hooks/hermes/capture.ts index 4a134eec..04df6403 100644 --- a/src/hooks/hermes/capture.ts +++ b/src/hooks/hermes/capture.ts @@ -38,6 +38,7 @@ import { bundleDirFromImportMeta, spawnHermesWikiWorker, wikiLog } from "./spawn import { tryStopCounterTrigger } from "../../skillify/triggers.js"; import type { Config } from "../../config.js"; import { getInstalledVersion } from "../../utils/version-check.js"; +import { isHivemindPluginEnabled } from "../../utils/plugin-state.js"; import { reactSkillOpt } from "../shared/skillopt-hook.js"; const log = (msg: string) => _log("hermes-capture", msg); @@ -76,6 +77,7 @@ function pickString(...candidates: unknown[]): string | undefined { async function main(): Promise { if (!CAPTURE) return; + if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping capture"); return; } const input = await readStdin(); const config = loadConfig(); if (!config) { log("no config"); return; } diff --git a/src/hooks/hermes/session-end.ts b/src/hooks/hermes/session-end.ts index eae598a1..295d44d8 100644 --- a/src/hooks/hermes/session-end.ts +++ b/src/hooks/hermes/session-end.ts @@ -26,37 +26,40 @@ async function main(): Promise { const sessionId = input.session_id ?? ""; log(`session=${sessionId || "?"} cwd=${input.cwd ?? "?"}`); if (!sessionId) return; - if (!tryAcquireLock(sessionId)) { - wikiLog(`SessionEnd: periodic worker already running for ${sessionId}, skipping final`); - return; - } const config = loadConfig(); if (!config) { wikiLog(`SessionEnd: no config, skipping summary`); return; } const cwd = input.cwd ?? process.cwd(); - // Independent try blocks per worker — a failure in wiki spawn must not - // suppress the skillify trigger and vice versa. + // Skillify has its own per-project lock — fire before the wiki-worker lock + // check so a Periodic trigger that already holds the lock doesn't suppress + // skill mining. try { - spawnHermesWikiWorker({ + forceSessionEndTrigger({ config, - sessionId, cwd, bundleDir: bundleDirFromImportMeta(import.meta.url), - reason: "SessionEnd", + agent: "hermes", + sessionId, }); } catch (e: any) { - wikiLog(`SessionEnd: wiki spawn failed: ${e?.message ?? e}`); + wikiLog(`SessionEnd: skillify trigger failed: ${e?.message ?? e}`); + } + + if (!tryAcquireLock(sessionId)) { + wikiLog(`SessionEnd: periodic worker already running for ${sessionId}, skipping final`); + return; } + try { - forceSessionEndTrigger({ + spawnHermesWikiWorker({ config, + sessionId, cwd, bundleDir: bundleDirFromImportMeta(import.meta.url), - agent: "hermes", - sessionId, + reason: "SessionEnd", }); } catch (e: any) { - wikiLog(`SessionEnd: skillify trigger failed: ${e?.message ?? e}`); + wikiLog(`SessionEnd: wiki spawn failed: ${e?.message ?? e}`); } } diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index 0fb109ac..84469577 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -570,14 +570,24 @@ export async function processPreToolUse(input: PreToolUseInput, deps: ClaudePreT logFn(`direct query failed: ${e.message}`); } - // No compiled handler matched (or a direct query failed). Do NOT return null - // here: null hands the ORIGINAL command back to Claude Code's host shell, and - // the command only passed isSafe() — it isn't guaranteed to be confined to - // the (non-existent) virtual paths. `sort /etc/passwd ~/.deeplake/memory/x > - // /tmp/out` would still read/write real files. Replace it with the retry - // guidance so nothing reaches the host shell. - logFn(`unroutable memory command, returning guidance: ${shellCmd}`); - return buildRetryGuidanceDecision(input.tool_name); + // No compiled handler matched (or a direct query failed). Route through the + // VFS shell bundle — it is a sandboxed Node.js interpreter that operates + // entirely against the SQL backend, so no host filesystem access occurs. + // Do NOT return null: that would hand the original command to Claude Code's + // real host shell, which is unsafe. + const shellBundle = join(__bundleDir, "shell", "deeplake-shell.js"); + logFn(`unroutable memory command, falling back to shell: ${shellCmd}`); + // Read needs file_path, not a command-shaped decision. + if (input.tool_name === "Read") { + return buildDenyDecision(MEMORY_RETRY_GUIDANCE, "[DeepLake] memory Read unavailable — use Bash builtins"); + } + // Single-quote both arguments so $(), backticks, and variable expansion + // cannot escape into the host shell before deeplake-shell.js receives them. + const sq = (v: string) => `'${v.replace(/'/g, `'\\''`)}'`; + return buildAllowDecision( + `node ${sq(shellBundle)} -c ${sq(shellCmd)}`, + `[DeepLake shell] ${shellCmd}`, + ); } /* c8 ignore start */ diff --git a/src/hooks/session-end.ts b/src/hooks/session-end.ts index 4eb183c9..97a0dbff 100644 --- a/src/hooks/session-end.ts +++ b/src/hooks/session-end.ts @@ -17,6 +17,7 @@ import { forceSessionEndTrigger } from "../skillify/triggers.js"; import { parseTranscript } from "../notifications/transcript-parser.js"; import { appendUsageRecord } from "../notifications/usage-tracker.js"; import { entrypointPassesOnlyCliGate } from "./shared/capture-gate.js"; +import { isHivemindPluginEnabled } from "../utils/plugin-state.js"; const log = (msg: string) => _log("session-end", msg); @@ -52,6 +53,7 @@ function recordSessionUsage(transcriptPath: string | undefined, sessionId: strin async function main(): Promise { if (process.env.HIVEMIND_WIKI_WORKER === "1") return; if (process.env.HIVEMIND_CAPTURE === "false") return; + if (!isHivemindPluginEnabled()) { log("plugin disabled, skipping session-end"); return; } if (!entrypointPassesOnlyCliGate()) return; const input = await readStdin(); @@ -76,6 +78,18 @@ async function main(): Promise { // (SkillOpt is NOT fired from SessionEnd — it fires immediately on the user's reaction // via UserPromptSubmit, so there's nothing to do at session end.) + // Skillify has its own per-project lock and must fire regardless of whether + // the wiki-worker lock below is already held. Fire it here, before the + // wiki-worker lock check, so a Periodic trigger that acquired the lock first + // doesn't silently suppress skill mining. + forceSessionEndTrigger({ + config, + cwd: cwd || process.cwd(), + bundleDir: bundleDirFromImportMeta(import.meta.url), + agent: "claude_code", + sessionId, + }); + // Coordinate with the periodic worker: if one is already running for this // session, skip. Two workers writing the same summary row trip the // Deeplake UPDATE-coalescing quirk (see CLAUDE.md) and drop one write. @@ -106,13 +120,7 @@ async function main(): Promise { throw e; } - forceSessionEndTrigger({ - config, - cwd, - bundleDir: bundleDirFromImportMeta(import.meta.url), - agent: "claude_code", - sessionId, - }); + // (forceSessionEndTrigger already called above, before the wiki-worker lock check) } main().catch((e) => { log(`fatal: ${e.message}`); process.exit(0); }); diff --git a/src/utils/debug.ts b/src/utils/debug.ts index 5669b422..db9f93c6 100644 --- a/src/utils/debug.ts +++ b/src/utils/debug.ts @@ -1,5 +1,5 @@ -import { appendFileSync } from "node:fs"; -import { join } from "node:path"; +import { appendFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; import { homedir } from "node:os"; const LOG = join(homedir(), ".deeplake", "hook-debug.log"); @@ -21,5 +21,8 @@ export function utcTimestamp(d: Date = new Date()): string { export function log(tag: string, msg: string) { if (!isDebug()) return; - appendFileSync(LOG, `${new Date().toISOString()} [${tag}] ${msg}\n`); + try { + mkdirSync(dirname(LOG), { recursive: true }); + appendFileSync(LOG, `${new Date().toISOString()} [${tag}] ${msg}\n`); + } catch { /* best-effort */ } } diff --git a/src/utils/plugin-state.ts b/src/utils/plugin-state.ts new file mode 100644 index 00000000..f5b2b4d8 --- /dev/null +++ b/src/utils/plugin-state.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +const PLUGIN_ID = "hivemind@hivemind"; + +/** + * Returns false if the user explicitly disabled the hivemind plugin via + * `claude plugin disable hivemind` (which writes enabledPlugins[id]=false to + * ~/.claude/settings.json). Hooks are loaded at SessionStart and remain active + * for the lifetime of the session even after disable; this check lets each + * hook invocation respect a mid-session disable without requiring a restart. + * + * Fails open: if the file is unreadable or unparseable, returns true so that + * a corrupt settings.json doesn't silently drop all captures. + */ +export function isHivemindPluginEnabled(): boolean { + try { + const settingsPath = join(homedir(), ".claude", "settings.json"); + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")); + const enabledPlugins = settings?.enabledPlugins; + if (enabledPlugins && typeof enabledPlugins === "object" && PLUGIN_ID in enabledPlugins) { + return enabledPlugins[PLUGIN_ID] !== false; + } + return true; + } catch { + return true; + } +} diff --git a/tests/claude-code/pre-tool-use-branches.test.ts b/tests/claude-code/pre-tool-use-branches.test.ts index 199d8e88..a4f0588a 100644 --- a/tests/claude-code/pre-tool-use-branches.test.ts +++ b/tests/claude-code/pre-tool-use-branches.test.ts @@ -539,7 +539,7 @@ describe("processPreToolUse: find / grep / fallback", () => { expect(d?.command).not.toContain("RETRY REQUIRED"); }); - it("Bash `find -type d -name ''` falls through to guidance (type filter unsupported)", async () => { + it("Bash `find -type d -name ''` falls through to the VFS shell (type filter not handled inline)", async () => { const findVirtualPathsFn = vi.fn(async () => ["/x.json"]) as any; const d = await processPreToolUse( { session_id: "s", tool_name: "Bash", tool_input: { command: "find ~/.deeplake/memory/sessions -type d -name '*.json'" }, tool_use_id: "t" }, @@ -551,9 +551,11 @@ describe("processPreToolUse: find / grep / fallback", () => { logFn: vi.fn(), }, ); - // Must NOT be served (the -type filter would be silently dropped). + // The inline find handler doesn't match -type d, so it falls through to the + // VFS shell bundle which handles it in the sandboxed interpreter. expect(findVirtualPathsFn).not.toHaveBeenCalled(); - expect(d?.command).toContain("RETRY REQUIRED"); + expect(d?.command).toContain("deeplake-shell.js"); + expect(d?.command).not.toContain("RETRY REQUIRED"); }); it("Bash `find … | wc -l` returns the count", async () => { @@ -589,7 +591,7 @@ describe("processPreToolUse: find / grep / fallback", () => { expect(d?.command).toContain("match line"); }); - it("returns retry guidance (does NOT fall through to the host shell) when the direct-read path throws", async () => { + it("falls back to the VFS shell (does NOT fall through to the host shell) when the direct-read path throws", async () => { const d = await processPreToolUse( { session_id: "s", tool_name: "Bash", tool_input: { command: "cat ~/.deeplake/memory/sessions/a.json" }, tool_use_id: "t" }, { @@ -600,7 +602,9 @@ describe("processPreToolUse: find / grep / fallback", () => { logFn: vi.fn(), }, ); - expect(d?.command).toContain("[RETRY REQUIRED]"); + // Direct query threw → falls through to VFS shell bundle (sandboxed, not the host shell). + expect(d?.command).toContain("deeplake-shell.js"); + expect(d?.command).not.toContain("RETRY REQUIRED"); }); it("returns a not-found result (not retry guidance) for a concrete cat on a missing VFS file", async () => { @@ -619,10 +623,11 @@ describe("processPreToolUse: find / grep / fallback", () => { expect(d?.command).not.toContain("RETRY REQUIRED"); }); - it("returns retry guidance for an isSafe-but-unroutable memory command instead of running it on the host", async () => { - // `sort` passes isSafe() (it's an allowlisted builtin) but no VFS handler - // serves it; it must be rewritten to the harmless echo guidance, not handed - // back to the real shell where it would read /etc/passwd and write /tmp/out. + it("routes an isSafe-but-unroutable memory command to the VFS shell instead of the host shell", async () => { + // `sort` passes isSafe() but no inline VFS handler serves it. It is routed + // to the VFS shell bundle — a sandboxed Node.js interpreter — NOT handed to + // the real host shell. Inside the VFS shell `/etc/passwd` is just a path + // name against the SQL backend (no real file access occurs). const d = await processPreToolUse( { session_id: "s", tool_name: "Bash", tool_input: { command: "sort /etc/passwd ~/.deeplake/memory/index.md > /tmp/out" }, tool_use_id: "t" }, { @@ -632,8 +637,8 @@ describe("processPreToolUse: find / grep / fallback", () => { logFn: vi.fn(), }, ); - expect(d?.command).toContain("[RETRY REQUIRED]"); - expect(d?.command).not.toContain("/etc/passwd"); + expect(d?.command).toContain("deeplake-shell.js"); + expect(d?.command).not.toContain("RETRY REQUIRED"); }); }); diff --git a/tests/claude-code/pre-tool-use.test.ts b/tests/claude-code/pre-tool-use.test.ts index 6de3181c..b988121f 100644 --- a/tests/claude-code/pre-tool-use.test.ts +++ b/tests/claude-code/pre-tool-use.test.ts @@ -141,6 +141,8 @@ describe("pre-tool-use: commands targeting memory are intercepted", () => { // this harness, even otherwise-serviceable shapes land here. ── it("rewrites echo redirect to retry guidance when unconfigured", () => { + // No token in env → loadConfig() returns null → RETRY at the no-config guard, + // before the shell-fallback path is reached. const r = runPreToolUse("Bash", { command: "echo 'hello' > ~/.deeplake/memory/test.md" }); expect(r.empty).toBe(false); if (!r.empty) { @@ -151,6 +153,8 @@ describe("pre-tool-use: commands targeting memory are intercepted", () => { }); it("rewrites jq pipeline to retry guidance when unconfigured", () => { + // No token in env → loadConfig() returns null → RETRY at the no-config guard, + // before the shell-fallback path is reached. const r = runPreToolUse("Bash", { command: "cat ~/.deeplake/memory/data.json | jq '.keys | length'" }); expect(r.empty).toBe(false); if (!r.empty) { diff --git a/tests/claude-code/session-end-hook.test.ts b/tests/claude-code/session-end-hook.test.ts index 08d2955e..0e633b3d 100644 --- a/tests/claude-code/session-end-hook.test.ts +++ b/tests/claude-code/session-end-hook.test.ts @@ -25,9 +25,13 @@ const markSessionEndedMock = vi.fn(); const parseTranscriptMock = vi.fn(); const appendUsageRecordMock = vi.fn(); const debugLogMock = vi.fn(); +const forceSessionEndTriggerMock = vi.fn(); vi.mock("../../src/utils/stdin.js", () => ({ readStdin: (...a: any[]) => stdinMock(...a) })); vi.mock("../../src/config.js", () => ({ loadConfig: (...a: any[]) => loadConfigMock(...a) })); +vi.mock("../../src/skillify/triggers.js", () => ({ + forceSessionEndTrigger: (...a: any[]) => forceSessionEndTriggerMock(...a), +})); vi.mock("../../src/hooks/spawn-wiki-worker.js", () => ({ spawnWikiWorker: (...a: any[]) => spawnMock(...a), wikiLog: (...a: any[]) => wikiLogMock(...a), @@ -75,6 +79,7 @@ beforeEach(() => { parseTranscriptMock.mockReset().mockReturnValue({ memorySearchCount: 0, memorySearchBytes: 0 }); appendUsageRecordMock.mockReset(); debugLogMock.mockReset(); + forceSessionEndTriggerMock.mockReset(); }); afterEach(() => { vi.restoreAllMocks(); }); @@ -120,6 +125,19 @@ describe("session-end hook", () => { ); }); + it("fires skillify trigger even when the wiki-worker lock is already held (lock-contention regression)", async () => { + // Same session-id used by a concurrent/prior conversation that holds the wiki lock. + tryAcquireLockMock.mockReturnValue(false); + await runHook(); + // Wiki worker must NOT spawn (lock held). + expect(spawnMock).not.toHaveBeenCalled(); + // Skillify trigger MUST fire regardless — it has its own lock. + expect(forceSessionEndTriggerMock).toHaveBeenCalledTimes(1); + expect(forceSessionEndTriggerMock).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "sid-1", agent: "claude_code" }), + ); + }); + it("records session usage when the transcript has memory searches", async () => { stdinMock.mockResolvedValue({ session_id: "sid-1", cwd: "/proj", transcript_path: "/t.jsonl" }); parseTranscriptMock.mockReturnValue({ memorySearchCount: 3, memorySearchBytes: 100 }); diff --git a/tests/cli/cli-install-cursor-fs.test.ts b/tests/cli/cli-install-cursor-fs.test.ts index d73e1453..addfafa7 100644 --- a/tests/cli/cli-install-cursor-fs.test.ts +++ b/tests/cli/cli-install-cursor-fs.test.ts @@ -116,6 +116,41 @@ describe("installCursor", () => { const { installCursor } = await importInstaller(); expect(() => installCursor()).toThrow(/Cursor bundle missing/); }); + + it("creates embed-deps symlink when ~/.hivemind/embed-deps/node_modules exists", async () => { + const embedDepsNm = join(tmpHome, ".hivemind", "embed-deps", "node_modules"); + mkdirSync(embedDepsNm, { recursive: true }); + + const { installCursor } = await importInstaller(); + installCursor(); + + const pluginNm = join(tmpHome, ".cursor", "hivemind", "node_modules"); + const { lstatSync } = await import("node:fs"); + expect(lstatSync(pluginNm).isSymbolicLink()).toBe(true); + }); + + it("replaces an existing real directory at pluginNm with a symlink when embed-deps present", async () => { + const embedDepsNm = join(tmpHome, ".hivemind", "embed-deps", "node_modules"); + mkdirSync(embedDepsNm, { recursive: true }); + // Pre-create a real directory where the symlink should go + const pluginNm = join(tmpHome, ".cursor", "hivemind", "node_modules"); + mkdirSync(pluginNm, { recursive: true }); + + const { installCursor } = await importInstaller(); + installCursor(); + + const { lstatSync } = await import("node:fs"); + expect(lstatSync(pluginNm).isSymbolicLink()).toBe(true); + }); + + it("skips embed-deps symlink when ~/.hivemind/embed-deps/node_modules is absent", async () => { + const { installCursor } = await importInstaller(); + installCursor(); + + const pluginNm = join(tmpHome, ".cursor", "hivemind", "node_modules"); + // No symlink should exist — embed-deps dir was never created + expect(existsSync(pluginNm)).toBe(false); + }); }); describe("uninstallCursor", () => { diff --git a/tests/codex/codex-stop-hook.test.ts b/tests/codex/codex-stop-hook.test.ts index 6b745b67..14d1fb71 100644 --- a/tests/codex/codex-stop-hook.test.ts +++ b/tests/codex/codex-stop-hook.test.ts @@ -19,6 +19,7 @@ const tryAcquireLockMock = vi.fn(); const releaseLockMock = vi.fn(); const debugLogMock = vi.fn(); const queryMock = vi.fn(); +const forceSessionEndTriggerMock = vi.fn(); vi.mock("../../src/utils/stdin.js", () => ({ readStdin: (...args: any[]) => stdinMock(...args) })); vi.mock("../../src/config.js", () => ({ loadConfig: (...args: any[]) => loadConfigMock(...args) })); @@ -43,6 +44,9 @@ vi.mock("../../src/embeddings/client.js", () => ({ warmup() { return Promise.resolve(false); } }, })); +vi.mock("../../src/skillify/triggers.js", () => ({ + forceSessionEndTrigger: (...args: any[]) => forceSessionEndTriggerMock(...args), +})); async function runHook(env: Record = {}): Promise { delete process.env.HIVEMIND_WIKI_WORKER; @@ -78,6 +82,7 @@ beforeEach(() => { releaseLockMock.mockReset(); debugLogMock.mockReset(); queryMock.mockReset().mockResolvedValue([]); + forceSessionEndTriggerMock.mockReset(); }); afterEach(() => { @@ -242,6 +247,16 @@ describe("codex stop hook — wiki spawn + lock coordination", () => { ); }); + it("fires skillify trigger even when wiki-worker lock is already held (lock-contention regression)", async () => { + tryAcquireLockMock.mockReturnValue(false); + await runHook(); + expect(spawnMock).not.toHaveBeenCalled(); + expect(forceSessionEndTriggerMock).toHaveBeenCalledTimes(1); + expect(forceSessionEndTriggerMock).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "sid-1", agent: "codex" }), + ); + }); + it("spawns the codex wiki worker on the happy path with the right arguments", async () => { await runHook(); expect(spawnMock).toHaveBeenCalledTimes(1); diff --git a/tests/cursor/cursor-session-end-hook.test.ts b/tests/cursor/cursor-session-end-hook.test.ts index 9293f09d..69e5ba18 100644 --- a/tests/cursor/cursor-session-end-hook.test.ts +++ b/tests/cursor/cursor-session-end-hook.test.ts @@ -6,6 +6,7 @@ const tryAcquireLockMock = vi.fn(); const loadConfigMock = vi.fn(); const spawnCursorWikiWorkerMock = vi.fn(); const wikiLogMock = vi.fn(); +const forceSessionEndTriggerMock = vi.fn(); vi.mock("../../src/utils/stdin.js", () => ({ readStdin: (...a: unknown[]) => stdinMock(...a) })); vi.mock("../../src/utils/debug.js", () => ({ log: (_tag: string, msg: string) => debugLogMock(msg) })); @@ -18,6 +19,9 @@ vi.mock("../../src/hooks/cursor/spawn-wiki-worker.js", () => ({ wikiLog: (...a: unknown[]) => wikiLogMock(...a), bundleDirFromImportMeta: () => "/tmp/bundle", })); +vi.mock("../../src/skillify/triggers.js", () => ({ + forceSessionEndTrigger: (...a: unknown[]) => forceSessionEndTriggerMock(...a), +})); const validConfig = { token: "t", apiUrl: "http://example", orgId: "o", orgName: "acme", @@ -46,6 +50,7 @@ beforeEach(() => { loadConfigMock.mockReset().mockReturnValue(validConfig); spawnCursorWikiWorkerMock.mockReset(); wikiLogMock.mockReset(); + forceSessionEndTriggerMock.mockReset(); }); afterEach(() => { vi.restoreAllMocks(); }); @@ -108,6 +113,17 @@ describe("cursor session-end hook (stub)", () => { expect(wikiLogMock).toHaveBeenCalledWith(expect.stringContaining("periodic worker already running")); }); + it("fires skillify trigger even when wiki-worker lock is already held (lock-contention regression)", async () => { + stdinMock.mockResolvedValue({ conversation_id: "conv-contention" }); + tryAcquireLockMock.mockReturnValue(false); + await runHook(); + expect(spawnCursorWikiWorkerMock).not.toHaveBeenCalled(); + expect(forceSessionEndTriggerMock).toHaveBeenCalledTimes(1); + expect(forceSessionEndTriggerMock).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "conv-contention", agent: "cursor" }), + ); + }); + it("loadConfig returns null → log and skip without crashing", async () => { stdinMock.mockResolvedValue({ conversation_id: "conv-10" }); loadConfigMock.mockReturnValue(null); diff --git a/tests/hermes/hermes-session-end-hook.test.ts b/tests/hermes/hermes-session-end-hook.test.ts index 55f157b8..f5dc82d8 100644 --- a/tests/hermes/hermes-session-end-hook.test.ts +++ b/tests/hermes/hermes-session-end-hook.test.ts @@ -6,6 +6,7 @@ const tryAcquireLockMock = vi.fn(); const loadConfigMock = vi.fn(); const spawnHermesWikiWorkerMock = vi.fn(); const wikiLogMock = vi.fn(); +const forceSessionEndTriggerMock = vi.fn(); vi.mock("../../src/utils/stdin.js", () => ({ readStdin: (...a: unknown[]) => stdinMock(...a) })); vi.mock("../../src/utils/debug.js", () => ({ log: (_tag: string, msg: string) => debugLogMock(msg) })); @@ -18,6 +19,9 @@ vi.mock("../../src/hooks/hermes/spawn-wiki-worker.js", () => ({ wikiLog: (...a: unknown[]) => wikiLogMock(...a), bundleDirFromImportMeta: () => "/tmp/bundle", })); +vi.mock("../../src/skillify/triggers.js", () => ({ + forceSessionEndTrigger: (...a: unknown[]) => forceSessionEndTriggerMock(...a), +})); const validConfig = { token: "t", apiUrl: "http://example", orgId: "o", orgName: "acme", @@ -44,6 +48,7 @@ beforeEach(() => { loadConfigMock.mockReset().mockReturnValue(validConfig); spawnHermesWikiWorkerMock.mockReset(); wikiLogMock.mockReset(); + forceSessionEndTriggerMock.mockReset(); }); afterEach(() => { vi.restoreAllMocks(); }); @@ -93,6 +98,17 @@ describe("hermes session-end hook (stub)", () => { expect(wikiLogMock).toHaveBeenCalledWith(expect.stringContaining("periodic worker already running")); }); + it("fires skillify trigger even when wiki-worker lock is already held (lock-contention regression)", async () => { + stdinMock.mockResolvedValue({ session_id: "ses-contention" }); + tryAcquireLockMock.mockReturnValue(false); + await runHook(); + expect(spawnHermesWikiWorkerMock).not.toHaveBeenCalled(); + expect(forceSessionEndTriggerMock).toHaveBeenCalledTimes(1); + expect(forceSessionEndTriggerMock).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "ses-contention", agent: "hermes" }), + ); + }); + it("loadConfig returns null → log and skip without crashing", async () => { stdinMock.mockResolvedValue({ session_id: "ses-10" }); loadConfigMock.mockReturnValue(null); diff --git a/tests/shared/plugin-state.test.ts b/tests/shared/plugin-state.test.ts new file mode 100644 index 00000000..97063c8c --- /dev/null +++ b/tests/shared/plugin-state.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { writeFileSync, mkdirSync, rmSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { isHivemindPluginEnabled } from "../../src/utils/plugin-state.js"; + +// isHivemindPluginEnabled reads homedir() at call time, so patching +// process.env.HOME redirects it to a temp directory on each invocation. + +function writeSettings(dir: string, content: object) { + const claudeDir = join(dir, ".claude"); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync(join(claudeDir, "settings.json"), JSON.stringify(content)); +} + +describe("isHivemindPluginEnabled", () => { + let tmpDir: string; + let originalHome: string | undefined; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "plugin-state-test-")); + originalHome = process.env.HOME; + process.env.HOME = tmpDir; + }); + + afterEach(() => { + process.env.HOME = originalHome; + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns true when settings.json does not exist", () => { + expect(isHivemindPluginEnabled()).toBe(true); + }); + + it("returns true when enabledPlugins does not mention hivemind", () => { + writeSettings(tmpDir, { enabledPlugins: { "other@plugin": true } }); + expect(isHivemindPluginEnabled()).toBe(true); + }); + + it("returns true when enabledPlugins[hivemind@hivemind] is true", () => { + writeSettings(tmpDir, { enabledPlugins: { "hivemind@hivemind": true } }); + expect(isHivemindPluginEnabled()).toBe(true); + }); + + it("returns false when enabledPlugins[hivemind@hivemind] is false", () => { + writeSettings(tmpDir, { enabledPlugins: { "hivemind@hivemind": false } }); + expect(isHivemindPluginEnabled()).toBe(false); + }); + + it("returns true (fail-open) when settings.json is corrupt", () => { + mkdirSync(join(tmpDir, ".claude"), { recursive: true }); + writeFileSync(join(tmpDir, ".claude", "settings.json"), "{ not valid json }"); + expect(isHivemindPluginEnabled()).toBe(true); + }); +});