context-mode 1.0.107 → 1.0.109
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +22 -18
- package/build/adapters/claude-code/index.js +26 -9
- package/build/adapters/opencode/index.js +5 -5
- package/build/cli.js +92 -12
- package/build/server.js +45 -3
- package/build/session/analytics.d.ts +7 -0
- package/build/session/analytics.js +75 -15
- package/build/session/db.d.ts +3 -1
- package/build/session/persist-tool-calls.d.ts +54 -0
- package/build/session/persist-tool-calls.js +105 -0
- package/build/session/project-attribution.d.ts +1 -1
- package/cli.bundle.mjs +123 -122
- package/hooks/ensure-deps.mjs +28 -12
- package/hooks/posttooluse.mjs +90 -80
- package/hooks/precompact.mjs +56 -46
- package/hooks/pretooluse.mjs +161 -167
- package/hooks/routing-block.mjs +2 -2
- package/hooks/run-hook.mjs +82 -0
- package/hooks/session-db.bundle.mjs +2 -2
- package/hooks/sessionstart.mjs +187 -155
- package/hooks/userpromptsubmit.mjs +69 -58
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/scripts/heal-better-sqlite3.mjs +108 -0
- package/scripts/postinstall.mjs +27 -0
- package/server.bundle.mjs +88 -88
- package/skills/UPSTREAM-CREDITS.md +51 -0
- package/skills/context-mode-ops/SKILL.md +147 -0
- package/skills/diagnose/SKILL.md +122 -0
- package/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/skills/grill-me/SKILL.md +15 -0
- package/skills/grill-with-docs/ADR-FORMAT.md +47 -0
- package/skills/grill-with-docs/CONTEXT-FORMAT.md +77 -0
- package/skills/grill-with-docs/SKILL.md +93 -0
- package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
- package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/skills/improve-codebase-architecture/SKILL.md +76 -0
- package/skills/tdd/SKILL.md +114 -0
- package/skills/tdd/deep-modules.md +33 -0
- package/skills/tdd/interface-design.md +31 -0
- package/skills/tdd/mocking.md +59 -0
- package/skills/tdd/refactoring.md +10 -0
- package/skills/tdd/tests.md +61 -0
package/hooks/pretooluse.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import "./suppress-stderr.mjs";
|
|
3
2
|
/**
|
|
4
3
|
* Unified PreToolUse hook for context-mode (Claude Code)
|
|
5
4
|
* Redirects data-fetching tools to context-mode MCP tools
|
|
@@ -9,190 +8,185 @@ import "./suppress-stderr.mjs";
|
|
|
9
8
|
* Routing is delegated to core/routing.mjs (shared across platforms).
|
|
10
9
|
* This file retains the Claude Code-specific self-heal block and
|
|
11
10
|
* uses core/formatters.mjs for Claude Code output format.
|
|
11
|
+
*
|
|
12
|
+
* Crash-resilience: wrapped via runHook (#414) — module loads happen
|
|
13
|
+
* dynamically inside the wrapper.
|
|
14
|
+
*
|
|
15
|
+
* #415: the destructive settings.json mutation block (which removed
|
|
16
|
+
* context-mode hook entries when hooks.json was present) was deleted.
|
|
17
|
+
* It deleted user-written hook configs without consent and was the
|
|
18
|
+
* documented cause of the regression.
|
|
12
19
|
*/
|
|
13
20
|
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// ─── Self-heal: rename dir to correct version, fix registry + hooks ───
|
|
35
|
-
try {
|
|
36
|
-
const hookDir = dirname(fileURLToPath(import.meta.url));
|
|
37
|
-
const myRoot = resolve(hookDir, "..");
|
|
38
|
-
const myPkg = JSON.parse(readFileSync(resolve(myRoot, "package.json"), "utf-8"));
|
|
39
|
-
const myVersion = myPkg.version ?? "unknown";
|
|
40
|
-
const myDirName = basename(myRoot);
|
|
41
|
-
const cacheParent = dirname(myRoot);
|
|
42
|
-
const marker = resolve(tmpdir(), `context-mode-healed-${myVersion}`);
|
|
43
|
-
|
|
44
|
-
// Only self-heal inside plugin cache dirs — skip in dev/CI environments
|
|
45
|
-
const isInPluginCache = myRoot.includes("/plugins/cache/") || myRoot.includes("\\plugins\\cache\\");
|
|
46
|
-
if (myVersion !== "unknown" && isInPluginCache && !existsSync(marker)) {
|
|
47
|
-
// 1. If dir name doesn't match version (e.g. "0.7.0" but code is "0.9.12"),
|
|
48
|
-
// create correct dir, copy files, update registry + hooks
|
|
49
|
-
const correctDir = resolve(cacheParent, myVersion);
|
|
50
|
-
if (myDirName !== myVersion && !existsSync(correctDir)) {
|
|
51
|
-
copyDirSync(myRoot, correctDir);
|
|
52
|
-
|
|
53
|
-
// Create start.mjs in new dir if missing
|
|
54
|
-
const startMjs = resolve(correctDir, "start.mjs");
|
|
55
|
-
if (!existsSync(startMjs)) {
|
|
56
|
-
writeFileSync(startMjs, [
|
|
57
|
-
'#!/usr/bin/env node',
|
|
58
|
-
'import { existsSync } from "node:fs";',
|
|
59
|
-
'import { dirname, resolve } from "node:path";',
|
|
60
|
-
'import { fileURLToPath } from "node:url";',
|
|
61
|
-
'const __dirname = dirname(fileURLToPath(import.meta.url));',
|
|
62
|
-
'process.chdir(__dirname);',
|
|
63
|
-
'if (!process.env.CLAUDE_PROJECT_DIR) process.env.CLAUDE_PROJECT_DIR = process.cwd();',
|
|
64
|
-
'if (existsSync(resolve(__dirname, "server.bundle.mjs"))) {',
|
|
65
|
-
' await import("./server.bundle.mjs");',
|
|
66
|
-
'} else if (existsSync(resolve(__dirname, "build", "server.js"))) {',
|
|
67
|
-
' await import("./build/server.js");',
|
|
68
|
-
'}',
|
|
69
|
-
].join("\n"), "utf-8");
|
|
70
|
-
}
|
|
21
|
+
import { runHook } from "./run-hook.mjs";
|
|
22
|
+
|
|
23
|
+
await runHook(async () => {
|
|
24
|
+
const { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, readdirSync } = await import("node:fs");
|
|
25
|
+
const { resolve, dirname, basename } = await import("node:path");
|
|
26
|
+
const { fileURLToPath } = await import("node:url");
|
|
27
|
+
const { tmpdir } = await import("node:os");
|
|
28
|
+
const { readStdin } = await import("./core/stdin.mjs");
|
|
29
|
+
const { routePreToolUse, initSecurity } = await import("./core/routing.mjs");
|
|
30
|
+
const { formatDecision } = await import("./core/formatters.mjs");
|
|
31
|
+
const { parseStdin, getSessionId, resolveConfigDir } = await import("./session-helpers.mjs");
|
|
32
|
+
|
|
33
|
+
// ─── Manual recursive copy (avoids cpSync libuv crash on non-ASCII paths, Windows + Node 24) ───
|
|
34
|
+
function copyDirSync(src, dest) {
|
|
35
|
+
mkdirSync(dest, { recursive: true });
|
|
36
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
37
|
+
const srcPath = resolve(src, entry.name);
|
|
38
|
+
const destPath = resolve(dest, entry.name);
|
|
39
|
+
if (entry.isDirectory()) copyDirSync(srcPath, destPath);
|
|
40
|
+
else copyFileSync(srcPath, destPath);
|
|
71
41
|
}
|
|
42
|
+
}
|
|
72
43
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
44
|
+
// ─── Self-heal: rename dir to correct version, fix registry + hooks ───
|
|
45
|
+
try {
|
|
46
|
+
const hookDir = dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
const myRoot = resolve(hookDir, "..");
|
|
48
|
+
const myPkg = JSON.parse(readFileSync(resolve(myRoot, "package.json"), "utf-8"));
|
|
49
|
+
const myVersion = myPkg.version ?? "unknown";
|
|
50
|
+
const myDirName = basename(myRoot);
|
|
51
|
+
const cacheParent = dirname(myRoot);
|
|
52
|
+
const marker = resolve(tmpdir(), `context-mode-healed-${myVersion}`);
|
|
53
|
+
|
|
54
|
+
// Only self-heal inside plugin cache dirs — skip in dev/CI environments
|
|
55
|
+
const isInPluginCache = myRoot.includes("/plugins/cache/") || myRoot.includes("\\plugins\\cache\\");
|
|
56
|
+
if (myVersion !== "unknown" && isInPluginCache && !existsSync(marker)) {
|
|
57
|
+
// 1. If dir name doesn't match version (e.g. "0.7.0" but code is "0.9.12"),
|
|
58
|
+
// create correct dir, copy files, update registry + hooks
|
|
59
|
+
const correctDir = resolve(cacheParent, myVersion);
|
|
60
|
+
if (myDirName !== myVersion && !existsSync(correctDir)) {
|
|
61
|
+
copyDirSync(myRoot, correctDir);
|
|
62
|
+
|
|
63
|
+
// Create start.mjs in new dir if missing
|
|
64
|
+
const startMjs = resolve(correctDir, "start.mjs");
|
|
65
|
+
if (!existsSync(startMjs)) {
|
|
66
|
+
writeFileSync(startMjs, [
|
|
67
|
+
'#!/usr/bin/env node',
|
|
68
|
+
'import { existsSync } from "node:fs";',
|
|
69
|
+
'import { dirname, resolve } from "node:path";',
|
|
70
|
+
'import { fileURLToPath } from "node:url";',
|
|
71
|
+
'const __dirname = dirname(fileURLToPath(import.meta.url));',
|
|
72
|
+
'process.chdir(__dirname);',
|
|
73
|
+
'if (!process.env.CLAUDE_PROJECT_DIR) process.env.CLAUDE_PROJECT_DIR = process.cwd();',
|
|
74
|
+
'if (existsSync(resolve(__dirname, "server.bundle.mjs"))) {',
|
|
75
|
+
' await import("./server.bundle.mjs");',
|
|
76
|
+
'} else if (existsSync(resolve(__dirname, "build", "server.js"))) {',
|
|
77
|
+
' await import("./build/server.js");',
|
|
78
|
+
'}',
|
|
79
|
+
].join("\n"), "utf-8");
|
|
86
80
|
}
|
|
87
81
|
}
|
|
88
|
-
writeFileSync(ipPath, JSON.stringify(ip, null, 2) + "\n", "utf-8");
|
|
89
|
-
}
|
|
90
82
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (existsSync(hooksJsonPath)) {
|
|
105
|
-
for (const hookType of Object.keys(allHooks)) {
|
|
106
|
-
const entries = allHooks[hookType];
|
|
107
|
-
if (!Array.isArray(entries)) continue;
|
|
108
|
-
const filtered = entries.filter(
|
|
109
|
-
(entry) =>
|
|
110
|
-
!entry.hooks?.some(
|
|
111
|
-
(h) => h.command?.includes(".mjs") && h.command?.includes("context-mode"),
|
|
112
|
-
),
|
|
113
|
-
);
|
|
114
|
-
if (filtered.length !== entries.length) {
|
|
115
|
-
allHooks[hookType] = filtered;
|
|
116
|
-
changed = true;
|
|
83
|
+
const targetDir = existsSync(correctDir) ? correctDir : myRoot;
|
|
84
|
+
|
|
85
|
+
// 2. Update installed_plugins.json → point to correct version dir
|
|
86
|
+
// Skip if not present (e.g. CI / non-Claude-Code environments)
|
|
87
|
+
const ipPath = resolve(resolveConfigDir(), "plugins", "installed_plugins.json");
|
|
88
|
+
if (existsSync(ipPath)) {
|
|
89
|
+
const ip = JSON.parse(readFileSync(ipPath, "utf-8"));
|
|
90
|
+
for (const [key, entries] of Object.entries(ip.plugins || {})) {
|
|
91
|
+
if (!key.toLowerCase().includes("context-mode")) continue;
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
entry.installPath = targetDir;
|
|
94
|
+
entry.version = myVersion;
|
|
95
|
+
entry.lastUpdated = new Date().toISOString();
|
|
117
96
|
}
|
|
118
97
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
for (const hookType of Object.keys(allHooks)) {
|
|
122
|
-
const entries = allHooks[hookType];
|
|
123
|
-
if (!Array.isArray(entries)) continue;
|
|
98
|
+
writeFileSync(ipPath, JSON.stringify(ip, null, 2) + "\n", "utf-8");
|
|
99
|
+
}
|
|
124
100
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
101
|
+
// 3. Legacy: hooks.json absent — rewrite stale paths in settings.json to current version dir.
|
|
102
|
+
// The previous "if hooks.json present, delete settings.json entries" block was REMOVED (#415):
|
|
103
|
+
// it destroyed user-written hook configs without consent. Plugin-system + settings.json
|
|
104
|
+
// coexistence is now Claude Code's responsibility, not ours.
|
|
105
|
+
const settingsPath = resolve(resolveConfigDir(), "settings.json");
|
|
106
|
+
try {
|
|
107
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
108
|
+
const allHooks = settings.hooks || {};
|
|
109
|
+
let changed = false;
|
|
110
|
+
|
|
111
|
+
const hooksJsonPath = resolve(myRoot, "hooks", "hooks.json");
|
|
112
|
+
if (!existsSync(hooksJsonPath)) {
|
|
113
|
+
// Legacy: hooks.json absent — rewrite stale paths to current version dir.
|
|
114
|
+
for (const hookType of Object.keys(allHooks)) {
|
|
115
|
+
const entries = allHooks[hookType];
|
|
116
|
+
if (!Array.isArray(entries)) continue;
|
|
117
|
+
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
// Fix deprecated Task-only matcher (PreToolUse only)
|
|
120
|
+
if (hookType === "PreToolUse" && entry.matcher?.includes("Task") && !entry.matcher.includes("Agent")) {
|
|
121
|
+
entry.matcher = entry.matcher.replace("Task", "Agent|Task");
|
|
122
|
+
changed = true;
|
|
123
|
+
}
|
|
124
|
+
// Rewrite stale context-mode hook paths to point to current version
|
|
125
|
+
for (const h of (entry.hooks || [])) {
|
|
126
|
+
if (h.command && h.command.includes(".mjs") && h.command.includes("context-mode") && !h.command.includes(targetDir)) {
|
|
127
|
+
// Extract the script filename (e.g., sessionstart.mjs, pretooluse.mjs)
|
|
128
|
+
const scriptMatch = h.command.match(/([a-z]+\.mjs)\s*"?\s*$/);
|
|
129
|
+
if (scriptMatch) {
|
|
130
|
+
h.command = "node " + resolve(targetDir, "hooks", scriptMatch[1]);
|
|
131
|
+
changed = true;
|
|
132
|
+
}
|
|
139
133
|
}
|
|
140
134
|
}
|
|
141
135
|
}
|
|
142
136
|
}
|
|
143
137
|
}
|
|
144
|
-
}
|
|
145
138
|
|
|
146
|
-
|
|
147
|
-
|
|
139
|
+
if (changed) writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
140
|
+
} catch { /* skip settings update */ }
|
|
148
141
|
|
|
149
|
-
|
|
150
|
-
|
|
142
|
+
// Old version dirs are cleaned lazily by sessionstart.mjs (age-gated >1h)
|
|
143
|
+
// to avoid breaking active sessions that still reference them (#181).
|
|
151
144
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
} catch { /* best effort — don't block hook */ }
|
|
155
|
-
|
|
156
|
-
// ─── Init security from compiled build ───
|
|
157
|
-
const __hookDir = dirname(fileURLToPath(import.meta.url));
|
|
158
|
-
await initSecurity(resolve(__hookDir, "..", "build"));
|
|
159
|
-
|
|
160
|
-
// ─── Read stdin ───
|
|
161
|
-
const raw = await readStdin();
|
|
162
|
-
const input = parseStdin(raw);
|
|
163
|
-
const tool = input.tool_name ?? "";
|
|
164
|
-
const toolInput = input.tool_input ?? {};
|
|
165
|
-
|
|
166
|
-
// ─── Route and format response ───
|
|
167
|
-
const decision = routePreToolUse(tool, toolInput, process.env.CLAUDE_PROJECT_DIR, "claude-code", getSessionId(input));
|
|
168
|
-
const response = formatDecision("claude-code", decision);
|
|
169
|
-
|
|
170
|
-
// ─── Write latency marker for cross-hook timing (Category 27) ───
|
|
171
|
-
// Marker writes MUST happen before stdout write — stdout is the last action
|
|
172
|
-
// so the process can exit immediately after, avoiding CI test timeouts.
|
|
173
|
-
try {
|
|
174
|
-
const sessionId = getSessionId(input);
|
|
175
|
-
if (tool) {
|
|
176
|
-
const markerPath = resolve(tmpdir(), `context-mode-latency-${sessionId}-${tool}.txt`);
|
|
177
|
-
writeFileSync(markerPath, String(Date.now()), "utf-8");
|
|
178
|
-
}
|
|
179
|
-
} catch { /* latency tracking is best-effort — never block hook */ }
|
|
145
|
+
writeFileSync(marker, Date.now().toString(), "utf-8");
|
|
146
|
+
}
|
|
147
|
+
} catch { /* best effort — don't block hook */ }
|
|
148
|
+
|
|
149
|
+
// ─── Init security from compiled build ───
|
|
150
|
+
const __hookDir = dirname(fileURLToPath(import.meta.url));
|
|
151
|
+
await initSecurity(resolve(__hookDir, "..", "build"));
|
|
152
|
+
|
|
153
|
+
// ─── Read stdin ───
|
|
154
|
+
const raw = await readStdin();
|
|
155
|
+
const input = parseStdin(raw);
|
|
156
|
+
const tool = input.tool_name ?? "";
|
|
157
|
+
const toolInput = input.tool_input ?? {};
|
|
158
|
+
|
|
159
|
+
// ─── Route and format response ───
|
|
160
|
+
const decision = routePreToolUse(tool, toolInput, process.env.CLAUDE_PROJECT_DIR, "claude-code", getSessionId(input));
|
|
161
|
+
const response = formatDecision("claude-code", decision);
|
|
180
162
|
|
|
181
|
-
// ─── Write
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
if (decision && (decision.action === "deny" || decision.action === "modify")) {
|
|
163
|
+
// ─── Write latency marker for cross-hook timing (Category 27) ───
|
|
164
|
+
// Marker writes MUST happen before stdout write — stdout is the last action
|
|
165
|
+
// so the process can exit immediately after, avoiding CI test timeouts.
|
|
185
166
|
try {
|
|
186
167
|
const sessionId = getSessionId(input);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
//
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
168
|
+
if (tool) {
|
|
169
|
+
const markerPath = resolve(tmpdir(), `context-mode-latency-${sessionId}-${tool}.txt`);
|
|
170
|
+
writeFileSync(markerPath, String(Date.now()), "utf-8");
|
|
171
|
+
}
|
|
172
|
+
} catch { /* latency tracking is best-effort — never block hook */ }
|
|
173
|
+
|
|
174
|
+
// ─── Write rejected-approach marker for PostToolUse to pick up ───
|
|
175
|
+
// PreToolUse cannot safely load SessionDB (native module loading breaks hook stdout).
|
|
176
|
+
// Write a marker file instead; PostToolUse reads it and writes the event.
|
|
177
|
+
if (decision && (decision.action === "deny" || decision.action === "modify")) {
|
|
178
|
+
try {
|
|
179
|
+
const sessionId = getSessionId(input);
|
|
180
|
+
const reason = decision.action === "deny"
|
|
181
|
+
? (decision.reason || "denied")
|
|
182
|
+
: "Redirected to context-mode sandbox";
|
|
183
|
+
const markerPath = resolve(tmpdir(), `context-mode-rejected-${sessionId}.txt`);
|
|
184
|
+
writeFileSync(markerPath, `${tool}:${reason}`, "utf-8");
|
|
185
|
+
} catch { /* best-effort — never block hook */ }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── stdout write is the LAST action — process exits immediately after ───
|
|
189
|
+
if (response !== null) {
|
|
190
|
+
process.stdout.write(JSON.stringify(response) + "\n");
|
|
191
|
+
}
|
|
192
|
+
});
|
package/hooks/routing-block.mjs
CHANGED
|
@@ -36,11 +36,11 @@ export function createRoutingBlock(t, options = {}) {
|
|
|
36
36
|
|
|
37
37
|
<forbidden_actions>
|
|
38
38
|
- NO Bash for commands producing >20 lines output.
|
|
39
|
-
- NO Read for analysis — use
|
|
39
|
+
- NO Read for analysis — use ${t("ctx_execute_file")}. Read IS correct for files you intend to Edit.
|
|
40
40
|
- NO WebFetch — use ${t("ctx_fetch_and_index")}.
|
|
41
41
|
- Bash ONLY for git/mkdir/rm/mv/navigation.
|
|
42
42
|
- NO ${t("ctx_execute")} or ${t("ctx_execute_file")} for file creation/modification.
|
|
43
|
-
ctx_execute is for analysis, processing, computation only.
|
|
43
|
+
${t("ctx_execute")} is for analysis, processing, computation only.
|
|
44
44
|
</forbidden_actions>
|
|
45
45
|
|
|
46
46
|
<file_writing_policy>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* run-hook.mjs — Universal crash-resilient wrapper for context-mode hook entries (#414).
|
|
4
|
+
*
|
|
5
|
+
* Why this exists:
|
|
6
|
+
* - hooks/hooks.json declares commands as `node "${CLAUDE_PLUGIN_ROOT}/hooks/X.mjs"`.
|
|
7
|
+
* On Windows shells (Git Bash, cmd.exe) the placeholder may mangle and resolution
|
|
8
|
+
* can fail with `cjs/loader:1479 MODULE_NOT_FOUND` — silent ghost hooks.
|
|
9
|
+
* - Top-level `import "./suppress-stderr.mjs"` style side-effects throw at
|
|
10
|
+
* parse time. A `try {}` inside the same file CANNOT catch a parse-time
|
|
11
|
+
* import failure, and `process.on('uncaughtException')` is also installed
|
|
12
|
+
* too late. The fix is to dynamic-import the side-effects from inside this
|
|
13
|
+
* wrapper, where the handler is guaranteed to be live.
|
|
14
|
+
*
|
|
15
|
+
* Contract:
|
|
16
|
+
* - logs every failure to ~/.claude/context-mode/hook-errors.log
|
|
17
|
+
* - never propagates a non-zero exit (Claude Code surfaces non-zero as a
|
|
18
|
+
* "non-blocking hook error" on every tool call, which spams the user)
|
|
19
|
+
* - one-liner adoption for new hooks:
|
|
20
|
+
* import { runHook } from "./run-hook.mjs";
|
|
21
|
+
* await runHook(async () => { ...body... });
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { resolve } from "node:path";
|
|
26
|
+
import { existsSync, mkdirSync, appendFileSync } from "node:fs";
|
|
27
|
+
|
|
28
|
+
function logError(err) {
|
|
29
|
+
try {
|
|
30
|
+
const dir = resolve(homedir(), ".claude", "context-mode");
|
|
31
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
32
|
+
const line = `[${new Date().toISOString()}] pid=${process.pid} ${err?.stack || err?.message || String(err)}\n`;
|
|
33
|
+
appendFileSync(resolve(dir, "hook-errors.log"), line);
|
|
34
|
+
} catch {
|
|
35
|
+
/* never fail logging */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Install process-level safety nets BEFORE any user code runs.
|
|
40
|
+
// Caveat: these only catch failures inside dynamically-loaded modules
|
|
41
|
+
// (which is precisely what runHook does). Static top-level imports in
|
|
42
|
+
// THIS file would still bypass these — keep this file's imports minimal
|
|
43
|
+
// and fail-safe (only node: built-ins above).
|
|
44
|
+
process.on("uncaughtException", (err) => {
|
|
45
|
+
logError(err);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
});
|
|
48
|
+
process.on("unhandledRejection", (err) => {
|
|
49
|
+
logError(err);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run a hook handler with full crash-resilience.
|
|
55
|
+
*
|
|
56
|
+
* Order of operations:
|
|
57
|
+
* 1. Dynamic-import suppress-stderr.mjs (best-effort — non-fatal)
|
|
58
|
+
* 2. Dynamic-import ensure-deps.mjs (best-effort — non-fatal)
|
|
59
|
+
* 3. Invoke handler — any throw is logged and we exit 0
|
|
60
|
+
*
|
|
61
|
+
* @param {() => Promise<void> | void} handler
|
|
62
|
+
*/
|
|
63
|
+
export async function runHook(handler) {
|
|
64
|
+
try {
|
|
65
|
+
await import("./suppress-stderr.mjs");
|
|
66
|
+
} catch (e) {
|
|
67
|
+
logError(e);
|
|
68
|
+
/* continue — non-fatal */
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await import("./ensure-deps.mjs");
|
|
72
|
+
} catch (e) {
|
|
73
|
+
logError(e);
|
|
74
|
+
/* continue — handler may still work */
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
await handler();
|
|
78
|
+
} catch (e) {
|
|
79
|
+
logError(e);
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{createRequire as I}from"node:module";import{existsSync as U,unlinkSync as v,renameSync as M}from"node:fs";import{tmpdir as x}from"node:os";import{join as F}from"node:path";var g=class{#t;constructor(t){this.#t=t}pragma(t){let s=this.#t.prepare(`PRAGMA ${t}`).all();if(!s||s.length===0)return;if(s.length>1)return s;let r=Object.values(s[0]);return r.length===1?r[0]:s[0]}exec(t){let e="",s=null;for(let o=0;o<t.length;o++){let a=t[o];if(s)e+=a,a===s&&(s=null);else if(a==="'"||a==='"')e+=a,s=a;else if(a===";"){let c=e.trim();c&&this.#t.prepare(c).run(),e=""}else e+=a}let r=e.trim();return r&&this.#t.prepare(r).run(),this}prepare(t){let e=this.#t.prepare(t);return{run:(...s)=>e.run(...s),get:(...s)=>{let r=e.get(...s);return r===null?void 0:r},all:(...s)=>e.all(...s),iterate:(...s)=>e.iterate(...s)}}transaction(t){return this.#t.transaction(t)}close(){this.#t.close()}},h=class{#t;constructor(t){this.#t=t}pragma(t){let s=this.#t.prepare(`PRAGMA ${t}`).all();if(!s||s.length===0)return;if(s.length>1)return s;let r=Object.values(s[0]);return r.length===1?r[0]:s[0]}exec(t){return this.#t.exec(t),this}prepare(t){let e=this.#t.prepare(t);return{run:(...s)=>e.run(...s),get:(...s)=>e.get(...s),all:(...s)=>e.all(...s),iterate:(...s)=>typeof e.iterate=="function"?e.iterate(...s):e.all(...s)[Symbol.iterator]()}}transaction(t){return(...e)=>{this.#t.exec("BEGIN");try{let s=t(...e);return this.#t.exec("COMMIT"),s}catch(s){throw this.#t.exec("ROLLBACK"),s}}}close(){this.#t.close()}},d=null;function B(){if(!d){let i=I(import.meta.url);if(globalThis.Bun){let t=i(["bun","sqlite"].join(":")).Database;d=function(s,r){let o=new t(s,{readonly:r?.readonly,create:!0}),a=new g(o);return r?.timeout&&a.pragma(`busy_timeout = ${r.timeout}`),a}}else if(process.platform==="linux")try{let{DatabaseSync:t}=i(["node","sqlite"].join(":"));d=function(s,r){let o=new t(s,{readOnly:r?.readonly??!1});return new h(o)}}catch{d=i("better-sqlite3")}else d=i("better-sqlite3")}return d}function b(i){i.pragma("journal_mode = WAL"),i.pragma("synchronous = NORMAL");try{i.pragma("mmap_size = 268435456")}catch{}}function N(i){if(!U(i))for(let t of["-wal","-shm"])try{v(i+t)}catch{}}function P(i){for(let t of["","-wal","-shm"])try{v(i+t)}catch{}}function y(i){try{i.pragma("wal_checkpoint(TRUNCATE)")}catch{}try{i.close()}catch{}}function C(i="context-mode"){return F(x(),`${i}-${process.pid}.db`)}function k(i,t=[100,500,2e3]){let e;for(let s=0;s<=t.length;s++)try{return i()}catch(r){let o=r instanceof Error?r.message:String(r);if(!o.includes("SQLITE_BUSY")&&!o.includes("database is locked"))throw r;if(e=r instanceof Error?r:new Error(o),s<t.length){let a=t[s],c=Date.now();for(;Date.now()-c<a;);}}throw new Error(`SQLITE_BUSY: database is locked after ${t.length} retries. Original error: ${e?.message}`)}function j(i){return i.includes("SQLITE_CORRUPT")||i.includes("SQLITE_NOTADB")||i.includes("database disk image is malformed")||i.includes("file is not a database")}function X(i){let t=Date.now();for(let e of["","-wal","-shm"])try{M(i+e,`${i}${e}.corrupt-${t}`)}catch{}}var m=Symbol.for("__context_mode_live_dbs__"),p=(()=>{let i=globalThis;return i[m]||(i[m]=new Set,process.on("exit",()=>{for(let t of i[m])y(t);i[m].clear()})),i[m]})(),T=class{#t;#e;constructor(t){let e=B();this.#t=t,N(t);let s;try{s=new e(t,{timeout:3e4}),b(s)}catch(r){let o=r instanceof Error?r.message:String(r);if(j(o)){X(t),N(t);try{s=new e(t,{timeout:3e4}),b(s)}catch(a){throw new Error(`Failed to create fresh DB after renaming corrupt file: ${a instanceof Error?a.message:String(a)}`)}}else throw r}this.#e=s,p.add(this.#e),this.initSchema(),this.prepareStatements()}get db(){return this.#e}get dbPath(){return this.#t}close(){p.delete(this.#e),y(this.#e)}withRetry(t){return k(t)}cleanup(){p.delete(this.#e),y(this.#e),P(this.#t)}};import{createHash as f}from"node:crypto";import{execFileSync as W}from"node:child_process";var l;function z(){let i=process.env.CONTEXT_MODE_SESSION_SUFFIX,t=process.cwd();if(l&&l.cwd===t&&l.envSuffix===i)return l.suffix;let e="";if(i!==void 0)e=i?`__${i}`:"";else try{let s=W("git",["worktree","list","--porcelain"],{encoding:"utf-8",timeout:2e3,stdio:["ignore","pipe","ignore"]}).split(/\r?\n/).find(r=>r.startsWith("worktree "))?.replace("worktree ","")?.trim();s&&t!==s&&(e=`__${f("sha256").update(t).digest("hex").slice(0,8)}`)}catch{}return l={cwd:t,envSuffix:i,suffix:e},e}function J(){l=void 0}var
|
|
1
|
+
import{createRequire as I}from"node:module";import{existsSync as U,unlinkSync as v,renameSync as M}from"node:fs";import{tmpdir as x}from"node:os";import{join as F}from"node:path";var g=class{#t;constructor(t){this.#t=t}pragma(t){let s=this.#t.prepare(`PRAGMA ${t}`).all();if(!s||s.length===0)return;if(s.length>1)return s;let r=Object.values(s[0]);return r.length===1?r[0]:s[0]}exec(t){let e="",s=null;for(let o=0;o<t.length;o++){let a=t[o];if(s)e+=a,a===s&&(s=null);else if(a==="'"||a==='"')e+=a,s=a;else if(a===";"){let c=e.trim();c&&this.#t.prepare(c).run(),e=""}else e+=a}let r=e.trim();return r&&this.#t.prepare(r).run(),this}prepare(t){let e=this.#t.prepare(t);return{run:(...s)=>e.run(...s),get:(...s)=>{let r=e.get(...s);return r===null?void 0:r},all:(...s)=>e.all(...s),iterate:(...s)=>e.iterate(...s)}}transaction(t){return this.#t.transaction(t)}close(){this.#t.close()}},h=class{#t;constructor(t){this.#t=t}pragma(t){let s=this.#t.prepare(`PRAGMA ${t}`).all();if(!s||s.length===0)return;if(s.length>1)return s;let r=Object.values(s[0]);return r.length===1?r[0]:s[0]}exec(t){return this.#t.exec(t),this}prepare(t){let e=this.#t.prepare(t);return{run:(...s)=>e.run(...s),get:(...s)=>e.get(...s),all:(...s)=>e.all(...s),iterate:(...s)=>typeof e.iterate=="function"?e.iterate(...s):e.all(...s)[Symbol.iterator]()}}transaction(t){return(...e)=>{this.#t.exec("BEGIN");try{let s=t(...e);return this.#t.exec("COMMIT"),s}catch(s){throw this.#t.exec("ROLLBACK"),s}}}close(){this.#t.close()}},d=null;function B(){if(!d){let i=I(import.meta.url);if(globalThis.Bun){let t=i(["bun","sqlite"].join(":")).Database;d=function(s,r){let o=new t(s,{readonly:r?.readonly,create:!0}),a=new g(o);return r?.timeout&&a.pragma(`busy_timeout = ${r.timeout}`),a}}else if(process.platform==="linux")try{let{DatabaseSync:t}=i(["node","sqlite"].join(":"));d=function(s,r){let o=new t(s,{readOnly:r?.readonly??!1});return new h(o)}}catch{d=i("better-sqlite3")}else d=i("better-sqlite3")}return d}function b(i){i.pragma("journal_mode = WAL"),i.pragma("synchronous = NORMAL");try{i.pragma("mmap_size = 268435456")}catch{}}function N(i){if(!U(i))for(let t of["-wal","-shm"])try{v(i+t)}catch{}}function P(i){for(let t of["","-wal","-shm"])try{v(i+t)}catch{}}function y(i){try{i.pragma("wal_checkpoint(TRUNCATE)")}catch{}try{i.close()}catch{}}function C(i="context-mode"){return F(x(),`${i}-${process.pid}.db`)}function k(i,t=[100,500,2e3]){let e;for(let s=0;s<=t.length;s++)try{return i()}catch(r){let o=r instanceof Error?r.message:String(r);if(!o.includes("SQLITE_BUSY")&&!o.includes("database is locked"))throw r;if(e=r instanceof Error?r:new Error(o),s<t.length){let a=t[s],c=Date.now();for(;Date.now()-c<a;);}}throw new Error(`SQLITE_BUSY: database is locked after ${t.length} retries. Original error: ${e?.message}`)}function j(i){return i.includes("SQLITE_CORRUPT")||i.includes("SQLITE_NOTADB")||i.includes("database disk image is malformed")||i.includes("file is not a database")}function X(i){let t=Date.now();for(let e of["","-wal","-shm"])try{M(i+e,`${i}${e}.corrupt-${t}`)}catch{}}var m=Symbol.for("__context_mode_live_dbs__"),p=(()=>{let i=globalThis;return i[m]||(i[m]=new Set,process.on("exit",()=>{for(let t of i[m])y(t);i[m].clear()})),i[m]})(),T=class{#t;#e;constructor(t){let e=B();this.#t=t,N(t);let s;try{s=new e(t,{timeout:3e4}),b(s)}catch(r){let o=r instanceof Error?r.message:String(r);if(j(o)){X(t),N(t);try{s=new e(t,{timeout:3e4}),b(s)}catch(a){throw new Error(`Failed to create fresh DB after renaming corrupt file: ${a instanceof Error?a.message:String(a)}`)}}else throw r}this.#e=s,p.add(this.#e),this.initSchema(),this.prepareStatements()}get db(){return this.#e}get dbPath(){return this.#t}close(){p.delete(this.#e),y(this.#e)}withRetry(t){return k(t)}cleanup(){p.delete(this.#e),y(this.#e),P(this.#t)}};import{createHash as f}from"node:crypto";import{execFileSync as W}from"node:child_process";var l;function z(){let i=process.env.CONTEXT_MODE_SESSION_SUFFIX,t=process.cwd();if(l&&l.cwd===t&&l.envSuffix===i)return l.suffix;let e="";if(i!==void 0)e=i?`__${i}`:"";else try{let s=W("git",["worktree","list","--porcelain"],{encoding:"utf-8",timeout:2e3,stdio:["ignore","pipe","ignore"]}).split(/\r?\n/).find(r=>r.startsWith("worktree "))?.replace("worktree ","")?.trim();s&&t!==s&&(e=`__${f("sha256").update(t).digest("hex").slice(0,8)}`)}catch{}return l={cwd:t,envSuffix:i,suffix:e},e}function J(){l=void 0}var O=1e3,D=5,n={insertEvent:"insertEvent",getEvents:"getEvents",getEventsByType:"getEventsByType",getEventsByPriority:"getEventsByPriority",getEventsByTypeAndPriority:"getEventsByTypeAndPriority",getEventCount:"getEventCount",getLatestAttributedProject:"getLatestAttributedProject",checkDuplicate:"checkDuplicate",evictLowestPriority:"evictLowestPriority",updateMetaLastEvent:"updateMetaLastEvent",ensureSession:"ensureSession",getSessionStats:"getSessionStats",incrementCompactCount:"incrementCompactCount",upsertResume:"upsertResume",getResume:"getResume",markResumeConsumed:"markResumeConsumed",claimLatestUnconsumedResume:"claimLatestUnconsumedResume",deleteEvents:"deleteEvents",deleteMeta:"deleteMeta",deleteResume:"deleteResume",getOldSessions:"getOldSessions",searchEvents:"searchEvents",incrementToolCall:"incrementToolCall",getToolCallTotals:"getToolCallTotals",getToolCallByTool:"getToolCallByTool"},A=class extends T{constructor(t){super(t?.dbPath??C("session"))}stmt(t){return this.stmts.get(t)}initSchema(){try{let e=this.db.pragma("table_xinfo(session_events)").find(s=>s.name==="data_hash");e&&e.hidden!==0&&this.db.exec("DROP TABLE session_events")}catch{}this.db.exec(`
|
|
2
2
|
CREATE TABLE IF NOT EXISTS session_events (
|
|
3
3
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4
4
|
session_id TEXT NOT NULL,
|
|
@@ -107,4 +107,4 @@ import{createRequire as I}from"node:module";import{existsSync as U,unlinkSync as
|
|
|
107
107
|
updated_at = datetime('now')`),t(n.getToolCallTotals,`SELECT COALESCE(SUM(calls), 0) AS calls,
|
|
108
108
|
COALESCE(SUM(bytes_returned), 0) AS bytes_returned
|
|
109
109
|
FROM tool_calls WHERE session_id = ?`),t(n.getToolCallByTool,`SELECT tool, calls, bytes_returned
|
|
110
|
-
FROM tool_calls WHERE session_id = ? ORDER BY calls DESC`)}insertEvent(t,e,s="PostToolUse",r){let o=f("sha256").update(e.data).digest("hex").slice(0,16).toUpperCase(),a=String(r?.projectDir??e.project_dir??"").trim(),c=String(r?.source??e.attribution_source??"unknown"),u=Number(r?.confidence??e.attribution_confidence??0),_=Number.isFinite(u)?Math.max(0,Math.min(1,u)):0,E=this.db.transaction(()=>{if(this.stmt(n.checkDuplicate).get(t,
|
|
110
|
+
FROM tool_calls WHERE session_id = ? ORDER BY calls DESC`)}insertEvent(t,e,s="PostToolUse",r){let o=f("sha256").update(e.data).digest("hex").slice(0,16).toUpperCase(),a=String(r?.projectDir??e.project_dir??"").trim(),c=String(r?.source??e.attribution_source??"unknown"),u=Number(r?.confidence??e.attribution_confidence??0),_=Number.isFinite(u)?Math.max(0,Math.min(1,u)):0,E=this.db.transaction(()=>{if(this.stmt(n.checkDuplicate).get(t,D,e.type,o))return;this.stmt(n.getEventCount).get(t).cnt>=O&&this.stmt(n.evictLowestPriority).run(t),this.stmt(n.insertEvent).run(t,e.type,e.category,e.priority,e.data,a,c,_,s,o),this.stmt(n.updateMetaLastEvent).run(t)});this.withRetry(()=>E())}bulkInsertEvents(t,e,s="PostToolUse",r){if(!e||e.length===0)return;if(e.length===1){this.insertEvent(t,e[0],s,r?.[0]);return}let o=e.map((c,u)=>{let _=f("sha256").update(c.data).digest("hex").slice(0,16).toUpperCase(),E=r?.[u],S=String(E?.projectDir??c.project_dir??"").trim(),L=String(E?.source??c.attribution_source??"unknown"),R=Number(E?.confidence??c.attribution_confidence??0),w=Number.isFinite(R)?Math.max(0,Math.min(1,R)):0;return{event:c,dataHash:_,projectDir:S,attributionSource:L,attributionConfidence:w}}),a=this.db.transaction(()=>{let c=this.stmt(n.getEventCount).get(t).cnt;for(let u of o)this.stmt(n.checkDuplicate).get(t,D,u.event.type,u.dataHash)||(c>=O?this.stmt(n.evictLowestPriority).run(t):c++,this.stmt(n.insertEvent).run(t,u.event.type,u.event.category,u.event.priority,u.event.data,u.projectDir,u.attributionSource,u.attributionConfidence,s,u.dataHash));this.stmt(n.updateMetaLastEvent).run(t)});this.withRetry(()=>a())}getEvents(t,e){let s=e?.limit??1e3,r=e?.type,o=e?.minPriority;return r&&o!==void 0?this.stmt(n.getEventsByTypeAndPriority).all(t,r,o,s):r?this.stmt(n.getEventsByType).all(t,r,s):o!==void 0?this.stmt(n.getEventsByPriority).all(t,o,s):this.stmt(n.getEvents).all(t,s)}getEventCount(t){return this.stmt(n.getEventCount).get(t).cnt}getLatestAttributedProjectDir(t){return this.stmt(n.getLatestAttributedProject).get(t)?.project_dir||null}searchEvents(t,e,s,r){try{let o=t.replace(/[%_]/g,c=>"\\"+c),a=r??null;return this.stmt(n.searchEvents).all(s,o,o,a,a,e)}catch{return[]}}ensureSession(t,e){this.stmt(n.ensureSession).run(t,e)}getSessionStats(t){return this.stmt(n.getSessionStats).get(t)??null}incrementCompactCount(t){this.stmt(n.incrementCompactCount).run(t)}upsertResume(t,e,s){this.stmt(n.upsertResume).run(t,e,s??0)}getResume(t){return this.stmt(n.getResume).get(t)??null}markResumeConsumed(t){this.stmt(n.markResumeConsumed).run(t)}claimLatestUnconsumedResume(t){let e=this.stmt(n.claimLatestUnconsumedResume).get(t);return e?{sessionId:e.session_id,snapshot:e.snapshot}:null}getLatestSessionId(){try{return this.db.prepare("SELECT session_id FROM session_meta ORDER BY started_at DESC LIMIT 1").get()?.session_id??null}catch{return null}}incrementToolCall(t,e,s=0){let r=Number.isFinite(s)&&s>0?Math.round(s):0;try{this.stmt(n.incrementToolCall).run(t,e,r)}catch{}}getToolCallStats(t){try{let e=this.stmt(n.getToolCallTotals).get(t),s=this.stmt(n.getToolCallByTool).all(t),r={};for(let o of s)r[o.tool]={calls:o.calls,bytesReturned:o.bytes_returned};return{totalCalls:e?.calls??0,totalBytesReturned:e?.bytes_returned??0,byTool:r}}catch{return{totalCalls:0,totalBytesReturned:0,byTool:{}}}}deleteSession(t){this.db.transaction(()=>{this.stmt(n.deleteEvents).run(t),this.stmt(n.deleteResume).run(t),this.stmt(n.deleteMeta).run(t)})()}cleanupOldSessions(t=7){let e=`-${t}`,s=this.stmt(n.getOldSessions).all(e);for(let{session_id:r}of s)this.deleteSession(r);return s.length}};export{A as SessionDB,J as _resetWorktreeSuffixCacheForTests,z as getWorktreeSuffix};
|