@sdsrs/code-graph 0.134.0 → 0.136.0
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/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/auto-update.js +93 -34
- package/claude-plugin/scripts/cg-answer.js +68 -5
- package/claude-plugin/scripts/doctor.js +98 -18
- package/claude-plugin/scripts/find-binary.js +42 -1
- package/claude-plugin/scripts/hook-fail-open.js +94 -1
- package/claude-plugin/scripts/lifecycle.js +109 -14
- package/claude-plugin/scripts/pre-edit-guide.js +16 -3
- package/claude-plugin/scripts/pre-grep-guide.js +15 -4
- package/claude-plugin/scripts/session-init.js +129 -18
- package/claude-plugin/scripts/version-utils.js +9 -4
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/package.json +6 -6
|
@@ -12,6 +12,27 @@ const PLATFORM = os.platform();
|
|
|
12
12
|
const ARCH = os.arch();
|
|
13
13
|
const CACHE_FILE = path.join(os.homedir(), '.cache', 'code-graph', 'binary-path');
|
|
14
14
|
const BINARY_NAME = PLATFORM === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
|
|
15
|
+
// PATH lookup bound (NEW-07). 2 s matches the sibling `npm root -g` probe; the
|
|
16
|
+
// floor is what a budget-exhausted hook still gives it, because a `which` that
|
|
17
|
+
// cannot answer in 250 ms was not going to rescue this hook anyway.
|
|
18
|
+
const PATH_PROBE_TIMEOUT_MS = 2000;
|
|
19
|
+
const PATH_PROBE_MIN_MS = 250;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How long the PATH probe may run: the hook budget when one is armed, the
|
|
23
|
+
* default otherwise, never 0 and never fractional.
|
|
24
|
+
*
|
|
25
|
+
* Both edges matter. `timeout: 0` is read by node as NO TIMEOUT AT ALL — it
|
|
26
|
+
* would restore the exact unbounded call this replaced — and a fractional value
|
|
27
|
+
* makes `child_process` throw ERR_OUT_OF_RANGE, which the `catch` below would
|
|
28
|
+
* silently turn into "not on PATH" (bugfix #7's shape, in a function that runs
|
|
29
|
+
* before any hook has spent a millisecond).
|
|
30
|
+
*/
|
|
31
|
+
function pathProbeTimeoutMs(remaining = require('./hook-fail-open').remainingMs) {
|
|
32
|
+
const left = remaining(PATH_PROBE_TIMEOUT_MS);
|
|
33
|
+
const ms = left === null ? PATH_PROBE_MIN_MS : Math.floor(left);
|
|
34
|
+
return Math.max(ms, PATH_PROBE_MIN_MS);
|
|
35
|
+
}
|
|
15
36
|
const PLATFORM_PKG = `@sdsrs/code-graph-${PLATFORM}-${ARCH}`;
|
|
16
37
|
|
|
17
38
|
/**
|
|
@@ -471,9 +492,28 @@ function findBinaryUncached() {
|
|
|
471
492
|
}
|
|
472
493
|
|
|
473
494
|
// --- PATH lookup (last resort for intentionally installed binaries) ---
|
|
495
|
+
//
|
|
496
|
+
// BOUNDED. This had no timeout at all, and it runs BEFORE any hook has called
|
|
497
|
+
// `remainingMs` even once — `findBinary()` is the first thing every hook does
|
|
498
|
+
// (audit 2026-09-05 NEW-07). It was called "the only unbounded child on the
|
|
499
|
+
// hook path" when it was fixed; the pre-ship review then found `ps` in
|
|
500
|
+
// `lifecycle.js`, so that was never true. Do not restore the superlative
|
|
501
|
+
// without re-deriving it. A `which` against a wedged PATH entry (a dead NFS mount, an
|
|
502
|
+
// automounter) hangs until Claude Code kills the hook, which surfaces to the
|
|
503
|
+
// user as an error on THEIR tool call.
|
|
504
|
+
//
|
|
505
|
+
// Spends the hook budget when one is armed, floored at 250 ms so the probe is
|
|
506
|
+
// always actually attempted: skipping it outright would make `findBinary`
|
|
507
|
+
// answer "no binary" for one that is on PATH, and a fabricated absence is
|
|
508
|
+
// worse than a probe that ran out of time. Outside a hook `remainingMs`
|
|
509
|
+
// returns the default, so nothing changes for doctor / statusline / the
|
|
510
|
+
// launcher.
|
|
474
511
|
try {
|
|
475
512
|
const which = PLATFORM === 'win32' ? 'where' : 'which';
|
|
476
|
-
const found = execFileSync(which, [BINARY_NAME], hidden({
|
|
513
|
+
const found = execFileSync(which, [BINARY_NAME], hidden({
|
|
514
|
+
timeout: pathProbeTimeoutMs(),
|
|
515
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
516
|
+
}))
|
|
477
517
|
.toString().trim().split('\n')[0];
|
|
478
518
|
const hit = gate.consider(found);
|
|
479
519
|
if (hit) return hit;
|
|
@@ -513,6 +553,7 @@ module.exports = {
|
|
|
513
553
|
getPackageVersion, compareVersions, isCachedBinaryFresh,
|
|
514
554
|
detectLibc, unsupportedPlatformHint,
|
|
515
555
|
CACHE_FILE, BINARY_NAME, PLATFORM_PKG,
|
|
556
|
+
pathProbeTimeoutMs, PATH_PROBE_TIMEOUT_MS, PATH_PROBE_MIN_MS,
|
|
516
557
|
};
|
|
517
558
|
|
|
518
559
|
// Allow direct invocation for testing
|
|
@@ -19,7 +19,94 @@
|
|
|
19
19
|
//
|
|
20
20
|
// EPIPE is silent by design. It means the consumer closed the pipe — Claude
|
|
21
21
|
// Code moved on, or a `| head` upstream exited. There is nobody left to tell.
|
|
22
|
+
// The budget Claude Code kills each hook at, in SECONDS — the single source for
|
|
23
|
+
// both halves of that contract: `lifecycle.js` registers these numbers into
|
|
24
|
+
// settings.json, and `remainingMs` below spends against them. `hooks.test.js`
|
|
25
|
+
// pins both registration sites to this table so a bump in one cannot drift from
|
|
26
|
+
// the other.
|
|
27
|
+
//
|
|
28
|
+
// `session-init.js` is registered from `claude-plugin/hooks/hooks.json` rather
|
|
29
|
+
// than by `lifecycle.js` (SessionStart is the one event Claude Code loads from
|
|
30
|
+
// plugin-cache), so `hooks.test.js` pins that file to this table too. It was
|
|
31
|
+
// the last unclamped hook — 21.5 s of serial children against the 5 s below,
|
|
32
|
+
// the largest overrun of the seven — until audit 2026-09-05 NEW-05 wired it.
|
|
33
|
+
// Its skips are not uniform: see the budget block at the top of
|
|
34
|
+
// `session-init.js` for which children may be dropped silently and which two
|
|
35
|
+
// report a distinct result instead of a fabricated all-clear.
|
|
36
|
+
const HOOK_TIMEOUT_SECONDS = {
|
|
37
|
+
'pre-edit-guide.js': 4,
|
|
38
|
+
'pre-grep-guide.js': 3,
|
|
39
|
+
'pre-read-guide.js': 3,
|
|
40
|
+
'incremental-index.js': 10,
|
|
41
|
+
'post-grep-inject.js': 5,
|
|
42
|
+
'user-prompt-context.js': 5,
|
|
43
|
+
'session-init.js': 5,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Left for the hook to render its answer and exit after the last child returns.
|
|
47
|
+
const WRITE_RESERVE_MS = 400;
|
|
48
|
+
|
|
49
|
+
// Wall-clock instant this process must be finished by, or null when nothing
|
|
50
|
+
// armed one (a hook `require`d by a test, or an unlisted script).
|
|
51
|
+
let deadlineAt = null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Arm the process deadline from the registered budget of `script`.
|
|
55
|
+
*
|
|
56
|
+
* The hooks' internal timeouts were each sized in isolation and run in SERIES:
|
|
57
|
+
* pre-edit-guide alone could spend 5 × 2000 ms of candidate greps plus 2500 ms
|
|
58
|
+
* of impact against a 4 s budget, and post-grep-inject looped callgraph over
|
|
59
|
+
* every symbol in the pattern before its show/grep fallbacks — 2–3× the budget
|
|
60
|
+
* either way. Nothing enforced the sum, so a binary wedged on `index.lock` got
|
|
61
|
+
* the hook killed by Claude Code, which surfaces to the user as a hook error on
|
|
62
|
+
* THEIR tool call (audit 2026-09-05 JS-03).
|
|
63
|
+
*
|
|
64
|
+
* `process.uptime()` is subtracted because the budget starts when Claude Code
|
|
65
|
+
* spawns us, not when this line runs: cold node startup is real time already
|
|
66
|
+
* spent, and on a loaded machine it is hundreds of milliseconds.
|
|
67
|
+
*/
|
|
68
|
+
function armHookDeadline(script) {
|
|
69
|
+
const seconds = HOOK_TIMEOUT_SECONDS[script];
|
|
70
|
+
if (!seconds) return;
|
|
71
|
+
deadlineAt = Math.floor(Date.now() + seconds * 1000 - process.uptime() * 1000 - WRITE_RESERVE_MS);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* How long a child may run: `defaultMs`, or whatever is left of the budget.
|
|
76
|
+
*
|
|
77
|
+
* Returns `null` when there is nothing left — meaning DO NOT RUN, not "run
|
|
78
|
+
* unbounded". Callers must branch on it: node reads `timeout: 0` as no timeout
|
|
79
|
+
* at all, so a numeric zero here would turn the last child into the unbounded
|
|
80
|
+
* one, which is the exact failure this exists to prevent.
|
|
81
|
+
*
|
|
82
|
+
* Always an INTEGER. `child_process` validates `timeout` with
|
|
83
|
+
* `validateTimeout` and throws `ERR_OUT_OF_RANGE` on a fraction — and the one
|
|
84
|
+
* fractional term here (`process.uptime()`) made every spawn throw, which the
|
|
85
|
+
* runners' own try/catch turned into a silent `unavailable`: the hooks stopped
|
|
86
|
+
* answering and still exited 0. Caught by pre-grep-guide's e2e suite.
|
|
87
|
+
*/
|
|
88
|
+
function remainingMs(defaultMs) {
|
|
89
|
+
if (deadlineAt === null) return Math.floor(defaultMs);
|
|
90
|
+
const left = deadlineAt - Date.now();
|
|
91
|
+
if (left <= 0) return null;
|
|
92
|
+
return Math.floor(Math.min(defaultMs, left));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Test seam: drop any armed deadline so one test file can't leak into another.
|
|
97
|
+
*
|
|
98
|
+
* `at` (an absolute epoch ms) arms one directly instead. `armHookDeadline`
|
|
99
|
+
* derives its instant from `process.uptime()`, so a test that wants the
|
|
100
|
+
* budget-EXHAUSTED branch would otherwise have to wait out a real budget — a
|
|
101
|
+
* clock race dressed up as a test. Pass `Date.now() - 1` to make every
|
|
102
|
+
* `remainingMs` return null deterministically.
|
|
103
|
+
*/
|
|
104
|
+
function resetHookDeadline(at = null) {
|
|
105
|
+
deadlineAt = at === null ? null : Math.floor(at);
|
|
106
|
+
}
|
|
107
|
+
|
|
22
108
|
function installHookFailOpen(label) {
|
|
109
|
+
armHookDeadline(require('path').basename(process.argv[1] || ''));
|
|
23
110
|
const bail = (err) => {
|
|
24
111
|
const code = (err && err.code) || (err && err.name) || 'Error';
|
|
25
112
|
if (code !== 'EPIPE') {
|
|
@@ -46,4 +133,10 @@ function installHookFailOpen(label) {
|
|
|
46
133
|
process.on('unhandledRejection', bail);
|
|
47
134
|
}
|
|
48
135
|
|
|
49
|
-
module.exports = {
|
|
136
|
+
module.exports = {
|
|
137
|
+
installHookFailOpen,
|
|
138
|
+
HOOK_TIMEOUT_SECONDS,
|
|
139
|
+
armHookDeadline,
|
|
140
|
+
remainingMs,
|
|
141
|
+
resetHookDeadline,
|
|
142
|
+
};
|
|
@@ -13,6 +13,11 @@ const OLD_PLUGIN_IDS = [
|
|
|
13
13
|
];
|
|
14
14
|
const MARKETPLACE_NAME = 'code-graph-mcp';
|
|
15
15
|
const CACHE_DIR = path.join(os.homedir(), '.cache', 'code-graph');
|
|
16
|
+
// Bound for the `ps` fallback in getActiveCmdlines (pre-ship review 2026-09-06).
|
|
17
|
+
// 2 s matches the other hook-path probes; the floor is what a budget-exhausted
|
|
18
|
+
// hook still gives it, since an empty list degrades to recency-only.
|
|
19
|
+
const PS_PROBE_TIMEOUT_MS = 2000;
|
|
20
|
+
const PS_PROBE_MIN_MS = 250;
|
|
16
21
|
// Always derive from __dirname — CLAUDE_PLUGIN_ROOT env var can leak from other
|
|
17
22
|
// plugins when hooks run in shared process context (e.g. claude-mem-lite sets it
|
|
18
23
|
// to its own marketplace path, polluting all subsequent settings.json hook processes).
|
|
@@ -142,6 +147,41 @@ function readJson(filePath) {
|
|
|
142
147
|
/** How many `.corrupt-*` copies of one file to keep. */
|
|
143
148
|
const MAX_CORRUPT_BACKUPS = 5;
|
|
144
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Owner-only. What every file this module creates under `~/.claude` gets when
|
|
152
|
+
* there is no prior file whose bits to preserve, and what a `.corrupt-*` copy
|
|
153
|
+
* gets unconditionally: settings.json routinely carries an `env` block with API
|
|
154
|
+
* keys, and a backup of one is the same secret in a second file (audit
|
|
155
|
+
* 2026-09-05 JS-01).
|
|
156
|
+
*/
|
|
157
|
+
const SECRET_FILE_MODE = 0o600;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Pin `target` at exactly `mode`, because neither route that creates our files
|
|
161
|
+
* does it on its own: `writeFileSync`'s `mode` option goes to `open(O_CREAT)`
|
|
162
|
+
* and is masked by umask (and ignored outright when the file already exists),
|
|
163
|
+
* and `copyFileSync` carries the SOURCE's bits.
|
|
164
|
+
*
|
|
165
|
+
* Reported, not swallowed: the whole point of the call is that the file may
|
|
166
|
+
* hold a key, so a permission we could not set is exactly the thing the user
|
|
167
|
+
* needs to hear about. The write itself has already succeeded — a failure here
|
|
168
|
+
* is a disclosure, not a reason to unwind it.
|
|
169
|
+
*/
|
|
170
|
+
function restrictMode(target, mode) {
|
|
171
|
+
try {
|
|
172
|
+
fs.chmodSync(target, mode);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// No return value: both call sites act on the stderr line, not on a bool,
|
|
175
|
+
// and a discarded success flag is an invitation to branch on it later
|
|
176
|
+
// without noticing that nothing ever did (pre-ship review 2026-09-05).
|
|
177
|
+
console.error(
|
|
178
|
+
`[code-graph] wrote ${target} but could not set its permissions to ` +
|
|
179
|
+
`0${mode.toString(8)} (${err.code || err.name}). If it contains an API key, ` +
|
|
180
|
+
`it may be readable by other users on this machine.`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
145
185
|
/**
|
|
146
186
|
* Delete all but the newest `MAX_CORRUPT_BACKUPS` copies of `filePath`.
|
|
147
187
|
*
|
|
@@ -180,9 +220,14 @@ function backupCorruptFile(filePath, raw) {
|
|
|
180
220
|
const dest = `${filePath}.corrupt-${stamp}`;
|
|
181
221
|
try {
|
|
182
222
|
// Buffer, not string: see readJsonResult. A string here would re-encode.
|
|
183
|
-
if (Buffer.isBuffer(raw)) fs.writeFileSync(dest, raw);
|
|
184
|
-
else if (typeof raw === 'string')
|
|
185
|
-
|
|
223
|
+
if (Buffer.isBuffer(raw)) fs.writeFileSync(dest, raw, { mode: SECRET_FILE_MODE });
|
|
224
|
+
else if (typeof raw === 'string') {
|
|
225
|
+
fs.writeFileSync(dest, Buffer.from(raw, 'utf8'), { mode: SECRET_FILE_MODE });
|
|
226
|
+
} else fs.copyFileSync(filePath, dest);
|
|
227
|
+
// Owner-only regardless of what the original was: up to MAX_CORRUPT_BACKUPS
|
|
228
|
+
// of these accumulate beside settings.json with its `env` block copied
|
|
229
|
+
// verbatim, so a 0644 original must not mint 0644 duplicates.
|
|
230
|
+
restrictMode(dest, SECRET_FILE_MODE);
|
|
186
231
|
pruneCorruptBackups(filePath);
|
|
187
232
|
return dest;
|
|
188
233
|
} catch {
|
|
@@ -261,11 +306,23 @@ function readSettingsForWrite(pre) {
|
|
|
261
306
|
return { settings: {}, backedUpTo: backup };
|
|
262
307
|
}
|
|
263
308
|
|
|
309
|
+
// Replace-by-rename loses the permission bits unless they are carried over
|
|
310
|
+
// deliberately: the new inode is the TMP file's, created under our umask, so a
|
|
311
|
+
// settings.json the user had put at 0600 came back 0644 on the first
|
|
312
|
+
// install()/update()/cleanupDisabledStatusline() — with its `env` API keys in
|
|
313
|
+
// it, silently, on a file we were only meant to add two hook entries to (audit
|
|
314
|
+
// 2026-09-05 JS-01). Stat the original and restore its exact mode; when there
|
|
315
|
+
// is no original, create owner-only rather than at whatever umask says.
|
|
264
316
|
function writeJsonAtomic(filePath, data) {
|
|
265
317
|
const dir = path.dirname(filePath);
|
|
266
318
|
fs.mkdirSync(dir, { recursive: true });
|
|
319
|
+
let mode = null;
|
|
320
|
+
try { mode = fs.statSync(filePath).mode & 0o777; } catch { /* new file */ }
|
|
267
321
|
const tmp = filePath + '.tmp.' + process.pid;
|
|
268
|
-
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
|
|
322
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: mode ?? SECRET_FILE_MODE });
|
|
323
|
+
// umask can only have cleared bits the original had, so chmod is what makes
|
|
324
|
+
// this preservation rather than tightening.
|
|
325
|
+
restrictMode(tmp, mode ?? SECRET_FILE_MODE);
|
|
269
326
|
fs.renameSync(tmp, filePath);
|
|
270
327
|
}
|
|
271
328
|
|
|
@@ -907,7 +964,13 @@ function removeHooksFromSettings(settings) {
|
|
|
907
964
|
|
|
908
965
|
function buildSettingsHookEntries() {
|
|
909
966
|
const root = PLUGIN_ROOT;
|
|
910
|
-
|
|
967
|
+
// The budget is read from the table the hooks THEMSELVES spend against
|
|
968
|
+
// (hook-fail-open.js), not written twice: a number registered here that the
|
|
969
|
+
// hook does not know about is the JS-03 shape — the hook overruns a limit it
|
|
970
|
+
// cannot see and Claude Code kills it mid-tool-call.
|
|
971
|
+
const { HOOK_TIMEOUT_SECONDS } = require('./hook-fail-open');
|
|
972
|
+
const scriptCmd = (name) => {
|
|
973
|
+
const timeout = HOOK_TIMEOUT_SECONDS[name];
|
|
911
974
|
const script = path.join(root, 'scripts', name);
|
|
912
975
|
// POSIX: existence-guarded. After `/plugin uninstall`, CC may delete the
|
|
913
976
|
// plugin-cache dir before our statusline teardown gets to strip these
|
|
@@ -924,16 +987,16 @@ function buildSettingsHookEntries() {
|
|
|
924
987
|
|
|
925
988
|
return {
|
|
926
989
|
PreToolUse: [
|
|
927
|
-
{ description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Edit', hooks: [scriptCmd('pre-edit-guide.js'
|
|
928
|
-
{ description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Bash', hooks: [scriptCmd('pre-grep-guide.js'
|
|
929
|
-
{ description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Read', hooks: [scriptCmd('pre-read-guide.js'
|
|
990
|
+
{ description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Edit', hooks: [scriptCmd('pre-edit-guide.js')] },
|
|
991
|
+
{ description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Bash', hooks: [scriptCmd('pre-grep-guide.js')] },
|
|
992
|
+
{ description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Read', hooks: [scriptCmd('pre-read-guide.js')] },
|
|
930
993
|
],
|
|
931
994
|
PostToolUse: [
|
|
932
|
-
{ description: SETTINGS_HOOK_DESC.postToolUseEdit, matcher: 'Write|Edit', hooks: [scriptCmd('incremental-index.js'
|
|
933
|
-
{ description: SETTINGS_HOOK_DESC.postToolUseInject, matcher: 'Bash', hooks: [scriptCmd('post-grep-inject.js'
|
|
995
|
+
{ description: SETTINGS_HOOK_DESC.postToolUseEdit, matcher: 'Write|Edit', hooks: [scriptCmd('incremental-index.js')] },
|
|
996
|
+
{ description: SETTINGS_HOOK_DESC.postToolUseInject, matcher: 'Bash', hooks: [scriptCmd('post-grep-inject.js')] },
|
|
934
997
|
],
|
|
935
998
|
UserPromptSubmit: [
|
|
936
|
-
{ description: SETTINGS_HOOK_DESC.userPromptSubmit, matcher: '', hooks: [scriptCmd('user-prompt-context.js'
|
|
999
|
+
{ description: SETTINGS_HOOK_DESC.userPromptSubmit, matcher: '', hooks: [scriptCmd('user-prompt-context.js')] },
|
|
937
1000
|
],
|
|
938
1001
|
};
|
|
939
1002
|
}
|
|
@@ -1589,10 +1652,19 @@ function update() {
|
|
|
1589
1652
|
settingsChanged = true;
|
|
1590
1653
|
}
|
|
1591
1654
|
|
|
1592
|
-
// 1. Update composite command path if version changed
|
|
1655
|
+
// 1. Update composite command path if version changed.
|
|
1656
|
+
//
|
|
1657
|
+
// Same predicate as install()'s heal arm, not a bare string comparison: an
|
|
1658
|
+
// exact mismatch is NOT staleness. Two copies of this plugin (plugin cache +
|
|
1659
|
+
// global npm, or a dev checkout) derive different absolute paths for the SAME
|
|
1660
|
+
// current composite, so rewriting on mismatch makes each surface take the slot
|
|
1661
|
+
// back from the other. install() has carried the guard since that ping-pong
|
|
1662
|
+
// was diagnosed; update() kept the string test, which reopened the pair for
|
|
1663
|
+
// one round on every version bump and walked straight past the
|
|
1664
|
+
// `statuslineDisplaced` stand-down (audit 2026-09-05 JS-08).
|
|
1593
1665
|
if (isOurComposite(settings)) {
|
|
1594
1666
|
const cmd = compositeCommand();
|
|
1595
|
-
if (settings.statusLine.command !== cmd) {
|
|
1667
|
+
if (settings.statusLine.command !== cmd && compositeSlotIsStale(settings.statusLine.command)) {
|
|
1596
1668
|
settings.statusLine.command = cmd;
|
|
1597
1669
|
settingsChanged = true;
|
|
1598
1670
|
}
|
|
@@ -1717,10 +1789,33 @@ function readActiveProcessCmdlines() {
|
|
|
1717
1789
|
} catch { /* fall through to ps */ }
|
|
1718
1790
|
try {
|
|
1719
1791
|
const { execFileSync } = require('child_process');
|
|
1792
|
+
// BOUNDED. Reached in-process from the SessionStart hook —
|
|
1793
|
+
// runSessionInit -> syncLifecycleConfig -> update() ->
|
|
1794
|
+
// cleanupOldCacheVersions -> here, unconditionally, on the first session
|
|
1795
|
+
// after every plugin update, which is precisely the session the hook budget
|
|
1796
|
+
// work targets. It carried no timeout, so a wedged `ps` (a stuck process
|
|
1797
|
+
// table, a paused container) hung the hook until Claude Code killed it —
|
|
1798
|
+
// which the user sees as an error on their own tool call. Found by pre-ship
|
|
1799
|
+
// review 2026-09-06 after find-binary's `which` was fixed and the release
|
|
1800
|
+
// notes called it "the only unbounded child a hook could reach".
|
|
1801
|
+
//
|
|
1802
|
+
// Spends the hook budget when one is armed, floored so the probe is always
|
|
1803
|
+
// attempted.
|
|
1804
|
+
//
|
|
1805
|
+
// Be honest about the cost: an empty answer drops pruning back to
|
|
1806
|
+
// recency-only, which is exactly the state this guard exists to escape —
|
|
1807
|
+
// see cleanupOldCacheVersions, where pruning a version a live process is
|
|
1808
|
+
// bound to breaks `/mcp` reconnect with MODULE_NOT_FOUND. So a timeout is a
|
|
1809
|
+
// NEW route to the guard being off, not a free fallback. It is still the
|
|
1810
|
+
// right trade: a `ps` slow enough to hit this is rare, and hanging the hook
|
|
1811
|
+
// past its budget is a certain failure rather than a possible one.
|
|
1812
|
+
const { remainingMs } = require('./hook-fail-open');
|
|
1813
|
+
const budget = remainingMs(PS_PROBE_TIMEOUT_MS);
|
|
1720
1814
|
return execFileSync('ps', ['-axww', '-o', 'command='], hidden({
|
|
1815
|
+
timeout: budget === null ? PS_PROBE_MIN_MS : Math.max(budget, PS_PROBE_MIN_MS),
|
|
1721
1816
|
encoding: 'utf8', maxBuffer: 8 * 1024 * 1024,
|
|
1722
1817
|
})).split('\n').filter(Boolean);
|
|
1723
|
-
} catch { /* unsupported platform — caller falls back to recency-only */ }
|
|
1818
|
+
} catch { /* unsupported platform, or out of time — caller falls back to recency-only */ }
|
|
1724
1819
|
return [];
|
|
1725
1820
|
}
|
|
1726
1821
|
|
|
@@ -24,6 +24,7 @@ const { recordRecommendation } = require('./recommendation-log');
|
|
|
24
24
|
const { formatCoveringTests } = require('./covering-tests');
|
|
25
25
|
const { emitPreToolContext } = require('./hook-emit');
|
|
26
26
|
const { hidden } = require('./proc-opts');
|
|
27
|
+
const { remainingMs } = require('./hook-fail-open');
|
|
27
28
|
|
|
28
29
|
// v0.49 — walk up from the shell cwd (subdir-cwd fix). The per-cwd index.db
|
|
29
30
|
// gate kept this hook dark for entire sessions after `cd backend/` — daagu
|
|
@@ -114,10 +115,17 @@ if (!symbol || symbol.length < 3) {
|
|
|
114
115
|
const candidates = [...new Set(identifiers)]
|
|
115
116
|
.filter(id => !skipWords.has(id.toLowerCase()))
|
|
116
117
|
.sort((a, b) => b.length - a.length);
|
|
117
|
-
|
|
118
|
+
// Two candidates, not five. They are sorted most-specific-first, so the
|
|
119
|
+
// 3rd–5th were the least likely to resolve AND the ones that pushed this
|
|
120
|
+
// loop past the hook's whole 4 s budget (5 × 2000 ms here + 2500 ms of
|
|
121
|
+
// impact below); the impact query they starve is the hook's actual output
|
|
122
|
+
// (audit 2026-09-05 JS-03).
|
|
123
|
+
for (const candidate of candidates.slice(0, 2)) {
|
|
124
|
+
const budget = remainingMs(2000);
|
|
125
|
+
if (budget === null) break;
|
|
118
126
|
try {
|
|
119
127
|
const raw = execFileSync(binary, ['grep', candidate, filePath, '--json'], hidden({
|
|
120
|
-
cwd, timeout:
|
|
128
|
+
cwd, timeout: budget, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
121
129
|
env: internalEnv,
|
|
122
130
|
}));
|
|
123
131
|
const grepResult = JSON.parse(raw);
|
|
@@ -170,6 +178,11 @@ try {
|
|
|
170
178
|
const editedFile = (input.tool_input && input.tool_input.file_path) || '';
|
|
171
179
|
const relFile = editedFile ? path.relative(cwd, editedFile) : '';
|
|
172
180
|
let jsonResult;
|
|
181
|
+
// Whatever is left of the registered 4 s, capped at this call's own 2500 ms.
|
|
182
|
+
// `null` = the candidate loop above already spent the budget; running anyway is
|
|
183
|
+
// what got the hook killed by Claude Code mid-Edit (audit 2026-09-05 JS-03).
|
|
184
|
+
const impactBudget = remainingMs(2500);
|
|
185
|
+
if (impactBudget === null) process.exit(0);
|
|
173
186
|
try {
|
|
174
187
|
const args = ['impact', symbol, '--json'];
|
|
175
188
|
if (relFile && !relFile.startsWith('..')) args.push('--file', relFile);
|
|
@@ -177,7 +190,7 @@ try {
|
|
|
177
190
|
// diverging from the findBinary() result the rest of the hook trusts).
|
|
178
191
|
const raw = execFileSync(binary, args, hidden({
|
|
179
192
|
cwd,
|
|
180
|
-
timeout:
|
|
193
|
+
timeout: impactBudget,
|
|
181
194
|
encoding: 'utf8',
|
|
182
195
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
183
196
|
env: internalEnv,
|
|
@@ -642,8 +642,14 @@ function buildNoHitsFyi(pattern) {
|
|
|
642
642
|
// only (PreToolUse exit-0 stdout → debug log, never the model); the operative
|
|
643
643
|
// effect at the call site is the ALLOW (no deny emitted) so the raw grep runs
|
|
644
644
|
// intact instead of a static deny that would hand the model nothing.
|
|
645
|
-
function buildUnavailableFyi(pattern, status) {
|
|
646
|
-
|
|
645
|
+
function buildUnavailableFyi(pattern, status, reason) {
|
|
646
|
+
// Three causes, not two. A hook whose budget was already spent at startup
|
|
647
|
+
// (cold node on a loaded machine — see the reserve note in cg-answer.js)
|
|
648
|
+
// deliberately runs no children at all; calling that "ran but failed" blames
|
|
649
|
+
// the binary for something it was never asked to do (audit 2026-09-05 NEW-08).
|
|
650
|
+
const why = status === 'no-binary' ? 'binary not found'
|
|
651
|
+
: reason === 'budget' ? 'no time left in the hook budget'
|
|
652
|
+
: 'ran but failed';
|
|
647
653
|
return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` unavailable (${why}) — raw grep proceeding.`;
|
|
648
654
|
}
|
|
649
655
|
|
|
@@ -777,11 +783,16 @@ function runMain() {
|
|
|
777
783
|
// static deny (the answer never ran → status stays the default 'unavailable')
|
|
778
784
|
// — that path falls through to the v0.46 static deny below.
|
|
779
785
|
if (answer.status !== 'hits' && !isAnswerDisabled()) {
|
|
780
|
-
recordRecommendation(root, {
|
|
786
|
+
recordRecommendation(root, {
|
|
787
|
+
hook: 'grep', action: 'hint', fallthrough: answer.status,
|
|
788
|
+
// So the funnel can tell a starved hook from a broken binary; both
|
|
789
|
+
// arrive as `unavailable` and only one of them means anything is wrong.
|
|
790
|
+
...(answer.reason ? { fallthrough_reason: answer.reason } : {}),
|
|
791
|
+
});
|
|
781
792
|
process.stdout.write(
|
|
782
793
|
(answer.status === 'no-hits'
|
|
783
794
|
? buildNoHitsFyi(pattern)
|
|
784
|
-
: buildUnavailableFyi(pattern, answer.status)) + '\n');
|
|
795
|
+
: buildUnavailableFyi(pattern, answer.status, answer.reason)) + '\n');
|
|
785
796
|
return;
|
|
786
797
|
}
|
|
787
798
|
|