@plur-ai/cli 0.9.9 → 0.9.11

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/README.md CHANGED
@@ -21,7 +21,7 @@ npx @plur-ai/cli@0.9.4 status
21
21
  ## Quick Start
22
22
 
23
23
  ```bash
24
- # Install Claude Code hooks (automatic memory injection)
24
+ # Install Claude Code hooks + local hook binary (automatic memory injection)
25
25
  plur init
26
26
 
27
27
  # Store a learning
@@ -59,7 +59,8 @@ plur forget ENG-2026-0329-001
59
59
  | `plur sync` | Cross-device sync via git |
60
60
  | `plur packs list` | List installed engram packs |
61
61
  | `plur packs install <source>` | Install an engram pack |
62
- | `plur init` | Install Claude Code hooks for automatic injection |
62
+ | `plur init` | Install Claude Code hooks + local hook binary for automatic injection |
63
+ | `plur doctor` | Diagnose installation health (hooks, MCP, shim, embedder) |
63
64
 
64
65
  ## Global Flags
65
66
 
@@ -10,23 +10,50 @@ import {
10
10
  outputText,
11
11
  shouldOutputJson
12
12
  } from "./chunk-7U4W4J3G.js";
13
- import {
14
- createPlur
15
- } from "./chunk-O6WTH7H7.js";
16
13
 
17
14
  // src/commands/doctor.ts
18
15
  import { spawn } from "child_process";
16
+ import { existsSync, readFileSync, realpathSync } from "fs";
17
+ import { join, extname } from "path";
18
+ import { homedir, platform } from "os";
19
19
  function hasAnyPlurHook(config) {
20
20
  const hooks = config.hooks ?? {};
21
21
  for (const entries of Object.values(hooks)) {
22
22
  for (const entry of entries) {
23
23
  for (const h of entry.hooks ?? []) {
24
- if (h.command && h.command.includes("@plur-ai/cli")) return true;
24
+ if (h.command && (h.command.includes("@plur-ai/cli") || h.command.includes(".plur/bin/plur-hook"))) return true;
25
+ }
26
+ }
27
+ }
28
+ return false;
29
+ }
30
+ function hasStaleNpxHooks(config) {
31
+ const hooks = config.hooks ?? {};
32
+ for (const entries of Object.values(hooks)) {
33
+ for (const entry of entries) {
34
+ for (const h of entry.hooks ?? []) {
35
+ if (h.command && h.command.includes("npx") && h.command.includes("@plur-ai/cli")) return true;
25
36
  }
26
37
  }
27
38
  }
28
39
  return false;
29
40
  }
41
+ function validateHookShim() {
42
+ const name = platform() === "win32" ? "plur-hook.cmd" : "plur-hook";
43
+ const path = join(homedir(), ".plur", "bin", name);
44
+ if (!existsSync(path)) {
45
+ return { valid: false, shimPath: path, error: "shim not found \u2014 run `plur init` to create it" };
46
+ }
47
+ const content = readFileSync(path, "utf-8");
48
+ const match = content.match(/"([^"]+index\.js)"/);
49
+ if (!match) {
50
+ return { valid: false, shimPath: path, error: "shim has unexpected format" };
51
+ }
52
+ if (!existsSync(match[1])) {
53
+ return { valid: false, shimPath: path, error: `entrypoint missing: ${match[1]} \u2014 run \`plur init\` to fix` };
54
+ }
55
+ return { valid: true, shimPath: path };
56
+ }
30
57
  function inspectConfigs() {
31
58
  return knownConfigFiles().map((cf) => {
32
59
  if (!cf.exists) {
@@ -121,46 +148,111 @@ async function mcpHandshake(timeoutMs = 2e4) {
121
148
  proc.stdin?.write(JSON.stringify(initRequest) + "\n");
122
149
  });
123
150
  }
124
- async function checkEmbedder(flags) {
151
+ function resolveCliJsEntry() {
152
+ const argv1 = process.argv[1];
153
+ if (!argv1) return null;
154
+ let resolved;
125
155
  try {
126
- const plur = createPlur(flags);
127
- const preStatus = plur.embedderStatus();
128
- if (!preStatus.disabled) {
129
- plur.resetEmbedder();
130
- try {
131
- await plur.recallSemantic("plur doctor probe", { limit: 1 });
132
- } catch {
133
- }
134
- }
135
- const status = plur.embedderStatus();
136
- return {
137
- available: status.available,
138
- loaded: status.loaded,
139
- lastError: status.lastError,
140
- modelLoaded: status.available && status.loaded,
141
- disabled: status.disabled,
142
- disabledReason: status.disabledReason
143
- };
144
- } catch (err) {
145
- return {
156
+ resolved = realpathSync(argv1);
157
+ } catch {
158
+ return null;
159
+ }
160
+ const ext = extname(resolved).toLowerCase();
161
+ if (ext !== ".js" && ext !== ".mjs" && ext !== ".cjs") return null;
162
+ return resolved;
163
+ }
164
+ async function checkEmbedder(_flags, timeoutMs = 1e4) {
165
+ return new Promise((resolve) => {
166
+ const fallback = (lastError) => ({
146
167
  available: false,
147
168
  loaded: false,
148
- lastError: err instanceof Error ? err.message : String(err),
169
+ lastError,
149
170
  modelLoaded: false,
150
171
  disabled: false,
151
172
  disabledReason: null
173
+ });
174
+ const cliEntry = resolveCliJsEntry();
175
+ if (!cliEntry) {
176
+ resolve(fallback("embedder probe skipped: CLI entry is not a JS file (compiled binary?)"));
177
+ return;
178
+ }
179
+ let resolved = false;
180
+ const finish = (result) => {
181
+ if (resolved) return;
182
+ resolved = true;
183
+ try {
184
+ proc.kill();
185
+ } catch {
186
+ }
187
+ resolve(result);
152
188
  };
153
- }
189
+ let proc;
190
+ try {
191
+ proc = spawn(process.execPath, [cliEntry, "_embedder-probe"], {
192
+ stdio: ["ignore", "pipe", "pipe"],
193
+ // Mark the subprocess as the parent-spawned probe — the probe checks
194
+ // this and refuses to run if invoked directly by a curious user.
195
+ env: { ...process.env, PLUR_INTERNAL_PROBE: "1" }
196
+ });
197
+ } catch (err) {
198
+ finish(fallback(`spawn failed: ${err.message}`));
199
+ return;
200
+ }
201
+ const timeout = setTimeout(() => {
202
+ finish(fallback(`probe timeout after ${timeoutMs}ms`));
203
+ }, timeoutMs);
204
+ let stdoutBuf = "";
205
+ let stderrBuf = "";
206
+ proc.stdout?.on("data", (chunk) => {
207
+ stdoutBuf += chunk.toString("utf8");
208
+ });
209
+ proc.stderr?.on("data", (chunk) => {
210
+ stderrBuf += chunk.toString("utf8");
211
+ });
212
+ proc.on("error", (err) => {
213
+ clearTimeout(timeout);
214
+ finish(fallback(`probe spawn error: ${err.message}`));
215
+ });
216
+ proc.on("exit", (code, signal) => {
217
+ clearTimeout(timeout);
218
+ const lines = stdoutBuf.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("{"));
219
+ const resultLine = lines[lines.length - 1];
220
+ if (resultLine) {
221
+ try {
222
+ const parsed = JSON.parse(resultLine);
223
+ finish({
224
+ available: !!parsed.available,
225
+ loaded: !!parsed.loaded,
226
+ lastError: parsed.lastError ?? null,
227
+ modelLoaded: !!parsed.modelLoaded,
228
+ disabled: !!parsed.disabled,
229
+ disabledReason: parsed.disabledReason ?? null
230
+ });
231
+ return;
232
+ } catch {
233
+ }
234
+ }
235
+ const detail = signal ? `signal ${signal}` : `exit ${code}`;
236
+ const stderrTrim = stderrBuf.trim().slice(0, 200);
237
+ finish(fallback(`embedder probe failed (${detail})${stderrTrim ? `: ${stderrTrim}` : ""}`));
238
+ });
239
+ });
154
240
  }
155
241
  function buildReport(skipHandshake, flags) {
156
242
  const configs = inspectConfigs();
157
243
  const hooksInstalled = configs.some((c) => c.hasPlurHooks);
158
244
  const mcpRegistered = configs.some((c) => c.hasPlurMcp);
159
245
  const datacoreCollision = configs.some((c) => c.hasDatacoreMcp);
246
+ const staleNpxHooks = configs.some((c) => {
247
+ if (!c.exists) return false;
248
+ const config = readConfig(c.path);
249
+ return hasStaleNpxHooks(config);
250
+ });
251
+ const hookShim = validateHookShim();
160
252
  const handshakePromise = skipHandshake ? Promise.resolve({ ok: false, error: "skipped (--no-handshake)" }) : mcpHandshake();
161
253
  return Promise.all([handshakePromise, checkEmbedder(flags)]).then(([handshake, embedder]) => {
162
254
  const overall = hooksInstalled && mcpRegistered && (skipHandshake || handshake.ok) ? "ok" : "fail";
163
- return { configs, hooksInstalled, mcpRegistered, datacoreCollision, handshake, embedder, overall };
255
+ return { configs, hooksInstalled, mcpRegistered, datacoreCollision, staleNpxHooks, hookShim, handshake, embedder, overall };
164
256
  });
165
257
  }
166
258
  function printText(report) {
@@ -192,6 +284,17 @@ function printText(report) {
192
284
  outputText(" If your agent confuses them, this is the cause.");
193
285
  }
194
286
  outputText("");
287
+ if (report.hookShim.valid) {
288
+ outputText(`\u2713 Hook shim: ${report.hookShim.shimPath}`);
289
+ } else {
290
+ outputText(`\u2717 Hook shim: ${report.hookShim.error}`);
291
+ }
292
+ if (report.staleNpxHooks) {
293
+ outputText("");
294
+ outputText("\u26A0 Hooks still use npx \u2014 slow (200-2000ms per hook) and vulnerable to cache corruption.");
295
+ outputText(" Fix: run `plur init` to migrate to the local hook binary (<5ms per hook).");
296
+ }
297
+ outputText("");
195
298
  if (report.handshake.ok) {
196
299
  outputText(`\u2713 MCP handshake: ${report.handshake.serverName} v${report.handshake.serverVersion}`);
197
300
  } else {
@@ -0,0 +1,47 @@
1
+ import {
2
+ createPlur
3
+ } from "./chunk-O6WTH7H7.js";
4
+
5
+ // src/commands/embedder-probe.ts
6
+ async function run(_args, flags) {
7
+ if (process.env.PLUR_INTERNAL_PROBE !== "1") {
8
+ process.stderr.write(
9
+ "_embedder-probe is an internal subcommand spawned by `plur doctor`. Run `plur doctor` instead.\n"
10
+ );
11
+ process.exit(1);
12
+ }
13
+ try {
14
+ const plur = createPlur(flags);
15
+ const preStatus = plur.embedderStatus();
16
+ if (!preStatus.disabled) {
17
+ plur.resetEmbedder();
18
+ try {
19
+ await plur.recallSemantic("plur doctor probe", { limit: 1 });
20
+ } catch {
21
+ }
22
+ }
23
+ const status = plur.embedderStatus();
24
+ process.stdout.write(JSON.stringify({
25
+ available: status.available,
26
+ loaded: status.loaded,
27
+ lastError: status.lastError,
28
+ modelLoaded: status.available && status.loaded,
29
+ disabled: status.disabled,
30
+ disabledReason: status.disabledReason
31
+ }) + "\n");
32
+ process.exit(0);
33
+ } catch (err) {
34
+ process.stdout.write(JSON.stringify({
35
+ available: false,
36
+ loaded: false,
37
+ lastError: err instanceof Error ? err.message : String(err),
38
+ modelLoaded: false,
39
+ disabled: false,
40
+ disabledReason: null
41
+ }) + "\n");
42
+ process.exit(0);
43
+ }
44
+ }
45
+ export {
46
+ run
47
+ };
@@ -3,30 +3,165 @@ import {
3
3
  } from "./chunk-O6WTH7H7.js";
4
4
 
5
5
  // src/commands/hook-inject.ts
6
- import { existsSync, writeFileSync, readFileSync, mkdirSync, readSync, statSync } from "fs";
7
- import { join } from "path";
8
- import { tmpdir } from "os";
6
+ import { existsSync, writeFileSync, readFileSync, appendFileSync, mkdirSync, readSync, statSync } from "fs";
7
+ import { dirname, join, resolve } from "path";
8
+ import { tmpdir, homedir } from "os";
9
9
  import { randomUUID } from "crypto";
10
+ var MAX_REMOTE_TASK_CHARS = 1e3;
11
+ var MAX_REMOTE_RESPONSE_BYTES = 128 * 1024;
12
+ var REMOTE_TIMEOUT_MS = 1500;
13
+ var REMOTE_INJECT_LOG_DIR = join(homedir(), ".plur", "logs");
14
+ function remoteInjectLogPath() {
15
+ return join(REMOTE_INJECT_LOG_DIR, `remote-inject-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.jsonl`);
16
+ }
17
+ function logRemoteAttempt(entry) {
18
+ try {
19
+ mkdirSync(REMOTE_INJECT_LOG_DIR, { recursive: true });
20
+ appendFileSync(remoteInjectLogPath(), JSON.stringify(entry) + "\n");
21
+ } catch {
22
+ }
23
+ }
10
24
  var REMINDER_INTERVAL_MS = 10 * 60 * 1e3;
25
+ function findProjectConfigPath(startDir = process.cwd()) {
26
+ const home = resolve(homedir());
27
+ let dir = resolve(startDir);
28
+ const MAX_DEPTH = 12;
29
+ for (let depth = 0; depth < MAX_DEPTH; depth++) {
30
+ if (dir !== home) {
31
+ const candidate = join(dir, ".plur.yaml");
32
+ if (existsSync(candidate)) return candidate;
33
+ }
34
+ if (existsSync(join(dir, ".git"))) return null;
35
+ if (dir === home || dir === "/" || dir === ".") return null;
36
+ const parent = dirname(dir);
37
+ if (parent === dir) return null;
38
+ dir = parent;
39
+ }
40
+ return null;
41
+ }
42
+ function unquoteYamlValue(v) {
43
+ return v.replace(/^(['"])(.*)\1$/, "$2");
44
+ }
11
45
  function readProjectConfig() {
12
- const configPath = join(process.cwd(), ".plur.yaml");
13
- if (!existsSync(configPath)) return {};
46
+ const configPath = findProjectConfigPath();
47
+ if (!configPath) return {};
14
48
  try {
15
- const content = readFileSync(configPath, "utf8");
49
+ const content = readFileSync(configPath, "utf8").replace(/^/, "");
16
50
  const config = {};
17
- for (const line of content.split("\n")) {
51
+ let inListKey = null;
52
+ let listAcc = [];
53
+ const finishList = () => {
54
+ if (inListKey === "remote_scopes") {
55
+ config.remote_scopes = listAcc;
56
+ }
57
+ inListKey = null;
58
+ listAcc = [];
59
+ };
60
+ for (const rawLine of content.split("\n")) {
61
+ const line = rawLine.replace(/\r$/, "");
18
62
  const trimmed = line.trim();
19
63
  if (trimmed.startsWith("#") || !trimmed) continue;
20
- const [key, ...rest] = trimmed.split(":");
21
- const value = rest.join(":").trim();
22
- if (key === "domain") config.domain = value;
23
- if (key === "scope") config.scope = value;
64
+ if (inListKey === "remote_scopes" && trimmed.startsWith("-")) {
65
+ listAcc.push(unquoteYamlValue(trimmed.slice(1).trim()));
66
+ continue;
67
+ }
68
+ if (inListKey) finishList();
69
+ const colonIdx = trimmed.indexOf(":");
70
+ if (colonIdx < 0) continue;
71
+ const key = trimmed.slice(0, colonIdx).trim();
72
+ const value = unquoteYamlValue(trimmed.slice(colonIdx + 1).trim());
73
+ switch (key) {
74
+ case "domain":
75
+ config.domain = value;
76
+ break;
77
+ case "scope":
78
+ config.scope = value;
79
+ break;
80
+ case "remote_url":
81
+ config.remote_url = value;
82
+ break;
83
+ case "remote_token":
84
+ config.remote_token = value;
85
+ break;
86
+ case "remote_scopes":
87
+ if (value === "" || value === "|" || value === ">") {
88
+ inListKey = "remote_scopes";
89
+ listAcc = [];
90
+ } else {
91
+ config.remote_scopes = value.split(",").map((s) => unquoteYamlValue(s.trim())).filter(Boolean);
92
+ }
93
+ break;
94
+ }
24
95
  }
96
+ finishList();
25
97
  return config;
26
98
  } catch {
27
99
  return {};
28
100
  }
29
101
  }
102
+ async function tryRemoteInject(config, task) {
103
+ if (!config.remote_url || !config.remote_token) return null;
104
+ const startTs = Date.now();
105
+ let base;
106
+ try {
107
+ base = new URL(config.remote_url).origin;
108
+ } catch {
109
+ logRemoteAttempt({ ts: (/* @__PURE__ */ new Date()).toISOString(), url: config.remote_url ?? "?", outcome: "bad_response", ms: 0, detail: "invalid URL" });
110
+ return null;
111
+ }
112
+ const url = `${base}/api/v1/inject`;
113
+ const truncatedTask = task.length > MAX_REMOTE_TASK_CHARS ? task.slice(0, MAX_REMOTE_TASK_CHARS) : task;
114
+ const body = { task: truncatedTask };
115
+ if (config.remote_scopes && config.remote_scopes.length > 0) {
116
+ body.scopes = config.remote_scopes;
117
+ }
118
+ const ctrl = new AbortController();
119
+ const t = setTimeout(() => ctrl.abort(), REMOTE_TIMEOUT_MS);
120
+ try {
121
+ const r = await fetch(url, {
122
+ method: "POST",
123
+ signal: ctrl.signal,
124
+ headers: {
125
+ "authorization": `Bearer ${config.remote_token}`,
126
+ "content-type": "application/json"
127
+ },
128
+ body: JSON.stringify(body)
129
+ });
130
+ if (!r.ok) {
131
+ logRemoteAttempt({ ts: (/* @__PURE__ */ new Date()).toISOString(), url, outcome: "http_error", ms: Date.now() - startTs, http: r.status });
132
+ return null;
133
+ }
134
+ const contentLength = r.headers.get("content-length");
135
+ if (contentLength && parseInt(contentLength, 10) > MAX_REMOTE_RESPONSE_BYTES) {
136
+ logRemoteAttempt({ ts: (/* @__PURE__ */ new Date()).toISOString(), url, outcome: "oversize", ms: Date.now() - startTs, http: r.status, detail: `content-length=${contentLength}` });
137
+ return null;
138
+ }
139
+ const data = await r.json();
140
+ if (typeof data.text !== "string" || !data.text.trim()) {
141
+ logRemoteAttempt({ ts: (/* @__PURE__ */ new Date()).toISOString(), url, outcome: "bad_response", ms: Date.now() - startTs, http: r.status, detail: "empty or non-string text field" });
142
+ return null;
143
+ }
144
+ const count = typeof data.count === "number" ? data.count : 0;
145
+ logRemoteAttempt({ ts: (/* @__PURE__ */ new Date()).toISOString(), url, outcome: "ok", ms: Date.now() - startTs, http: r.status, engrams: count });
146
+ return {
147
+ text: data.text,
148
+ count,
149
+ injectedIds: Array.isArray(data.injected_ids) ? data.injected_ids : []
150
+ };
151
+ } catch (err) {
152
+ const isAbort = err instanceof Error && err.name === "AbortError";
153
+ logRemoteAttempt({
154
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
155
+ url,
156
+ outcome: isAbort ? "timeout" : "network_error",
157
+ ms: Date.now() - startTs,
158
+ detail: err instanceof Error ? err.message.slice(0, 120) : void 0
159
+ });
160
+ return null;
161
+ } finally {
162
+ clearTimeout(t);
163
+ }
164
+ }
30
165
  function sessionDir() {
31
166
  const dir = join(tmpdir(), "plur-sessions");
32
167
  mkdirSync(dir, { recursive: true });
@@ -177,25 +312,36 @@ ${parts2.join("\n")}` };
177
312
  const injectOpts = projectConfig.scope ? { scope: projectConfig.scope } : void 0;
178
313
  let context = null;
179
314
  let count = 0;
180
- try {
181
- const result = await plur.injectHybrid(task, injectOpts);
182
- if (result.count > 0) {
183
- const parts2 = [];
184
- if (result.directives) parts2.push(result.directives);
185
- if (result.constraints) parts2.push(result.constraints);
186
- if (result.consider) parts2.push(result.consider);
187
- context = parts2.join("\n");
188
- count = result.count;
315
+ let remoteUsed = false;
316
+ if (projectConfig.remote_url && projectConfig.remote_token) {
317
+ const remote = await tryRemoteInject(projectConfig, task);
318
+ if (remote && remote.count > 0) {
319
+ context = remote.text;
320
+ count = remote.count;
321
+ remoteUsed = true;
189
322
  }
190
- } catch {
191
- const result = plur.inject(task, injectOpts);
192
- if (result.count > 0) {
193
- const parts2 = [];
194
- if (result.directives) parts2.push(result.directives);
195
- if (result.constraints) parts2.push(result.constraints);
196
- if (result.consider) parts2.push(result.consider);
197
- context = parts2.join("\n");
198
- count = result.count;
323
+ }
324
+ if (!remoteUsed) {
325
+ try {
326
+ const result = await plur.injectHybrid(task, injectOpts);
327
+ if (result.count > 0) {
328
+ const parts2 = [];
329
+ if (result.directives) parts2.push(result.directives);
330
+ if (result.constraints) parts2.push(result.constraints);
331
+ if (result.consider) parts2.push(result.consider);
332
+ context = parts2.join("\n");
333
+ count = result.count;
334
+ }
335
+ } catch {
336
+ const result = plur.inject(task, injectOpts);
337
+ if (result.count > 0) {
338
+ const parts2 = [];
339
+ if (result.directives) parts2.push(result.directives);
340
+ if (result.constraints) parts2.push(result.constraints);
341
+ if (result.consider) parts2.push(result.consider);
342
+ context = parts2.join("\n");
343
+ count = result.count;
344
+ }
199
345
  }
200
346
  }
201
347
  const parts = [];
@@ -205,10 +351,11 @@ ${parts2.join("\n")}` };
205
351
  sessionId = markerData.sessionId;
206
352
  } catch {
207
353
  }
354
+ const sourceLabel = remoteUsed ? " (Enterprise)" : "";
208
355
  if (isRehydrate) {
209
- parts.push(`[PLUR Memory \u2014 rehydrated after compaction, ${count} engrams]`);
356
+ parts.push(`[PLUR Memory${sourceLabel} \u2014 rehydrated after compaction, ${count} engrams]`);
210
357
  } else {
211
- parts.push(`[PLUR Memory \u2014 session started, ${count} engrams injected]`);
358
+ parts.push(`[PLUR Memory${sourceLabel} \u2014 session started, ${count} engrams injected]`);
212
359
  if (sessionId) parts.push(`Session ID: ${sessionId}`);
213
360
  if (projectConfig.domain) parts.push(`Project domain: ${projectConfig.domain}`);
214
361
  if (projectConfig.scope) parts.push(`Project scope: ${projectConfig.scope} \u2014 use this scope for plur_learn calls`);
@@ -3,13 +3,14 @@ import {
3
3
  } from "./chunk-6RANUJMM.js";
4
4
 
5
5
  // src/commands/hook-session-guard.ts
6
- import { readSync, existsSync } from "fs";
6
+ import { readSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
7
7
  import { join } from "path";
8
8
  import { tmpdir } from "os";
9
9
  var EXEMPT_TOOLS = /* @__PURE__ */ new Set([
10
10
  "mcp__plur__plur_session_start",
11
11
  "ToolSearch"
12
12
  ]);
13
+ var MAX_BLOCKS_BEFORE_FALLBACK = 5;
13
14
  function readStdinRaw() {
14
15
  try {
15
16
  const chunks = [];
@@ -31,6 +32,22 @@ function readStdinRaw() {
31
32
  function sentinelPath(sessionId) {
32
33
  return join(tmpdir(), `plur-session-${sessionId}`);
33
34
  }
35
+ function blockCountPath(sessionId) {
36
+ const dir = join(tmpdir(), "plur-sessions");
37
+ mkdirSync(dir, { recursive: true });
38
+ return join(dir, `${sessionId}.guard-count`);
39
+ }
40
+ function incrementBlockCount(sessionId) {
41
+ const path = blockCountPath(sessionId);
42
+ let count = 0;
43
+ try {
44
+ count = parseInt(readFileSync(path, "utf8"), 10) || 0;
45
+ } catch {
46
+ }
47
+ count++;
48
+ writeFileSync(path, String(count));
49
+ return count;
50
+ }
34
51
  async function run(_args, _flags) {
35
52
  if (!isPlurConfigured()) return;
36
53
  const raw = readStdinRaw();
@@ -45,6 +62,14 @@ async function run(_args, _flags) {
45
62
  if (EXEMPT_TOOLS.has(toolName)) return;
46
63
  if (!sessionId) return;
47
64
  if (existsSync(sentinelPath(sessionId))) return;
65
+ const blockCount = incrementBlockCount(sessionId);
66
+ if (blockCount > MAX_BLOCKS_BEFORE_FALLBACK) {
67
+ process.stderr.write(
68
+ `[plur] WARNING: session guard gave up after ${MAX_BLOCKS_BEFORE_FALLBACK} blocked calls. The plur MCP server may not be running. Run \`plur doctor\` to diagnose.
69
+ `
70
+ );
71
+ return;
72
+ }
48
73
  const output = {
49
74
  hookSpecificOutput: {
50
75
  hookEventName: "PreToolUse",