prism-mcp-server 20.12.0 → 20.12.1

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
@@ -116,6 +116,16 @@ or by re-enabling after each run.
116
116
  <details>
117
117
  <summary>Release history (optional)</summary>
118
118
 
119
+ ## What's New in v20.12.1
120
+
121
+ - **`prism connect --refresh` now converges every registration it owns**, not
122
+ just the top-level one — directory-scoped entries could otherwise keep
123
+ launching an old build indefinitely.
124
+ - **`prism update` checks the installed package**, not the CLI that happens to
125
+ be running, so it can no longer report "current" while the install is stale.
126
+ - **The opt-in scheduled updater can actually start** — the LaunchAgent now
127
+ carries a PATH that includes node and npm.
128
+
119
129
  ## What's New in v20.12.0
120
130
 
121
131
  - **Prism now tells you when it's out of date.** Session startup shows a
@@ -31,6 +31,22 @@ function defaultFetchLatest() {
31
31
  timeout: 15_000,
32
32
  }).trim();
33
33
  }
34
+ /** Read the version of the globally installed package. npm puts it under
35
+ * <prefix>/lib/node_modules on POSIX and <prefix>/node_modules on Windows. */
36
+ function defaultInstalledVersion() {
37
+ const prefix = execFileSync("npm", ["prefix", "-g"], { encoding: "utf8", timeout: 15_000 }).trim();
38
+ for (const candidate of [
39
+ join(prefix, "lib", "node_modules", PACKAGE, "package.json"),
40
+ join(prefix, "node_modules", PACKAGE, "package.json"),
41
+ ]) {
42
+ if (existsSync(candidate)) {
43
+ const version = JSON.parse(readFileSync(candidate, "utf8"))?.version;
44
+ if (typeof version === "string" && version.trim())
45
+ return version.trim();
46
+ }
47
+ }
48
+ return "";
49
+ }
34
50
  function defaultInstall(version) {
35
51
  execFileSync("npm", ["install", "-g", `${PACKAGE}@${version}`], {
36
52
  stdio: "inherit",
@@ -95,9 +111,6 @@ export function runPackageUpdate(deps) {
95
111
  if ((env.VITEST || env.NODE_ENV === "test") && !deps.fetchLatest) {
96
112
  return { action: "skipped", detail: "test environment" };
97
113
  }
98
- if (deps.currentVersion.includes("-")) {
99
- return { action: "skipped", detail: `dev build ${deps.currentVersion} — not touching it` };
100
- }
101
114
  if (deps.ifIdle) {
102
115
  let running;
103
116
  try {
@@ -116,6 +129,28 @@ export function runPackageUpdate(deps) {
116
129
  };
117
130
  }
118
131
  }
132
+ // The version that matters is the INSTALLED one; the running CLI may be a
133
+ // checkout, a shim, or an older global. Probing shells out to npm, so tests
134
+ // reach it only through an injected dep.
135
+ let targetVersion = deps.currentVersion;
136
+ // Test detection must read the REAL process env: suites pass env: {} to
137
+ // exercise the policy guards, and that would otherwise let this probe shell
138
+ // out to npm from inside the test run (caught by a 57ms test that suddenly
139
+ // consulted the machine's actual install).
140
+ const inTest = Boolean(env.VITEST || env.NODE_ENV === "test" ||
141
+ process.env.VITEST || process.env.NODE_ENV === "test");
142
+ const mayProbe = Boolean(deps.installedVersion) || !inTest;
143
+ if (mayProbe) {
144
+ try {
145
+ const installed = (deps.installedVersion ?? defaultInstalledVersion)().trim();
146
+ if (installed)
147
+ targetVersion = installed;
148
+ }
149
+ catch { /* not installed / npm unavailable — compare the running version */ }
150
+ }
151
+ if (targetVersion.includes("-")) {
152
+ return { action: "skipped", detail: `dev build ${targetVersion} — not touching it` };
153
+ }
119
154
  const release = (deps.acquireLock ?? defaultAcquireLock)();
120
155
  if (!release) {
121
156
  return { action: "locked", detail: "another prism update is already running" };
@@ -131,10 +166,10 @@ export function runPackageUpdate(deps) {
131
166
  if (!SEMVER.test(latest)) {
132
167
  return { action: "failed", detail: `registry returned unexpected version "${latest}"` };
133
168
  }
134
- if (!isNewer(deps.currentVersion, latest)) {
135
- return { action: "current", detail: `${deps.currentVersion} is current`, latest };
169
+ if (!isNewer(targetVersion, latest)) {
170
+ return { action: "current", detail: `installed package ${targetVersion} is current`, latest };
136
171
  }
137
- log(`prism ${deps.currentVersion} → ${latest}: updating the global package …`);
172
+ log(`prism ${targetVersion} → ${latest}: updating the global package …`);
138
173
  try {
139
174
  (deps.install ?? defaultInstall)(latest);
140
175
  }
@@ -151,8 +186,27 @@ export function runPackageUpdate(deps) {
151
186
  export function autoupdatePlistPath() {
152
187
  return join(homedir(), "Library", "LaunchAgents", `${AUTOUPDATE_LABEL}.plist`);
153
188
  }
189
+ function xmlEscape(value) {
190
+ return value
191
+ .replace(/&/g, "&amp;")
192
+ .replace(/</g, "&lt;")
193
+ .replace(/>/g, "&gt;");
194
+ }
195
+ /** The PATH a scheduled run needs. launchd hands an agent a minimal
196
+ * PATH (/usr/bin:/bin:/usr/sbin:/sbin) that excludes /usr/local/bin and
197
+ * /opt/homebrew/bin — where node and npm live on a standard macOS install.
198
+ * Measured 2026-08-14: without this the agent died at `env: node: No such
199
+ * file or directory` before running a single line of Prism. The directory
200
+ * of the interpreter running this code leads, because that is provably the
201
+ * node the operator uses. */
202
+ export function schedulerPath(execPath = process.execPath) {
203
+ const lastSlash = execPath.lastIndexOf("/");
204
+ const nodeDir = lastSlash > 0 ? execPath.slice(0, lastSlash) : "/usr/local/bin";
205
+ const defaults = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
206
+ return [nodeDir, ...defaults.filter((dir) => dir !== nodeDir)].join(":");
207
+ }
154
208
  /** Daily 03:30 local, catch-up on wake (LaunchAgents coalesce missed runs). */
155
- export function buildAutoupdatePlist(prismBin) {
209
+ export function buildAutoupdatePlist(prismBin, pathEnv = schedulerPath()) {
156
210
  return `<?xml version="1.0" encoding="UTF-8"?>
157
211
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
158
212
  <plist version="1.0">
@@ -160,10 +214,14 @@ export function buildAutoupdatePlist(prismBin) {
160
214
  <key>Label</key><string>${AUTOUPDATE_LABEL}</string>
161
215
  <key>ProgramArguments</key>
162
216
  <array>
163
- <string>${prismBin}</string>
217
+ <string>${xmlEscape(prismBin)}</string>
164
218
  <string>update</string>
165
219
  <string>--if-idle</string>
166
220
  </array>
221
+ <key>EnvironmentVariables</key>
222
+ <dict>
223
+ <key>PATH</key><string>${xmlEscape(pathEnv)}</string>
224
+ </dict>
167
225
  <key>StartCalendarInterval</key>
168
226
  <dict>
169
227
  <key>Hour</key><integer>3</integer>
@@ -181,28 +239,62 @@ export function autoupdateStatus() {
181
239
  return { supported: false, enabled: false, plistPath, detail: "scheduled updates are macOS-only for now (LaunchAgent)" };
182
240
  }
183
241
  const enabled = existsSync(plistPath);
242
+ if (!enabled) {
243
+ return { supported: true, enabled, plistPath, detail: "disabled" };
244
+ }
245
+ // 20.12.0 wrote a plist with no PATH. launchd hands an agent
246
+ // /usr/bin:/bin:/usr/sbin:/sbin, which excludes the directories holding node
247
+ // and npm on a standard macOS install, so that generation could never run —
248
+ // and it failed into a log file nobody reads. Say so instead of reporting a
249
+ // confident "enabled".
250
+ let healthy = false;
251
+ try {
252
+ healthy = readFileSync(plistPath, "utf8").includes("<key>PATH</key>");
253
+ }
254
+ catch { /* unreadable — treat as needing repair */ }
184
255
  return {
185
256
  supported: true,
186
257
  enabled,
187
258
  plistPath,
188
- detail: enabled ? `enabled — daily 03:30, log: /tmp/${AUTOUPDATE_LABEL}.log` : "disabled",
259
+ detail: healthy
260
+ ? `enabled — daily 03:30, log: /tmp/${AUTOUPDATE_LABEL}.log`
261
+ // Deliberately "may not run", not "cannot": launchd's default PATH does
262
+ // contain /usr/bin, so an operator whose node lives there is fine. On a
263
+ // standard install (Homebrew, /usr/local) it never runs. Claiming a
264
+ // certain failure we have not measured on THIS machine would be the same
265
+ // overclaim in the other direction.
266
+ : "enabled, but this agent predates the PATH fix and may not run (launchd's default PATH omits /usr/local/bin and /opt/homebrew/bin) — re-run `prism autoupdate enable` to repair",
189
267
  };
190
268
  }
191
- /** Resolve the `prism` bin the LaunchAgent should run. Warns (does not
192
- * refuse) when it resolves outside node_modules — a checkout CLI still
193
- * updates the global package, but the operator should know which code
194
- * their scheduler runs. */
195
- export function resolvePrismBin(log) {
196
- const bin = execFileSync("which", ["prism"], { encoding: "utf8", timeout: 5_000 }).trim();
197
- if (!bin)
198
- throw new Error("`prism` not found on PATH — install with: npm install -g prism-mcp-server");
269
+ export function resolvePrismBin(log, deps = {}) {
270
+ const exists = deps.exists ?? existsSync;
271
+ let globalBin;
199
272
  try {
200
- const real = execFileSync("readlink", ["-f", bin], { encoding: "utf8", timeout: 5_000 }).trim();
201
- if (real && !real.includes("node_modules")) {
202
- log(`⚠ ${bin} resolves to a source checkout (${real}); the scheduled update will run that CLI (it still updates only the global package)`);
273
+ const prefix = (deps.npmPrefix ?? (() => execFileSync("npm", ["prefix", "-g"], { encoding: "utf8", timeout: 15_000 })))().trim();
274
+ if (prefix) {
275
+ const candidate = join(prefix, "bin", "prism");
276
+ if (exists(candidate))
277
+ globalBin = candidate;
203
278
  }
204
279
  }
205
- catch { /* readlink unavailable — proceed with the raw path */ }
280
+ catch { /* npm unavailable — fall back to PATH lookup */ }
281
+ if (globalBin)
282
+ return globalBin;
283
+ let bin = "";
284
+ try {
285
+ bin = (deps.whichPrism ?? (() => execFileSync("which", ["prism"], { encoding: "utf8", timeout: 5_000 })))().trim();
286
+ }
287
+ catch { /* not on PATH */ }
288
+ if (!bin)
289
+ throw new Error("`prism` not found — install it with: npm install -g prism-mcp-server");
290
+ let real = bin;
291
+ try {
292
+ real = (deps.readlink ?? ((p) => execFileSync("readlink", ["-f", p], { encoding: "utf8", timeout: 5_000 })))(bin).trim() || bin;
293
+ }
294
+ catch { /* keep bin */ }
295
+ if (!real.includes("node_modules") && /\/dist\/[^/]+$/.test(real)) {
296
+ log(`⚠ ${bin} resolves to a source checkout (${real}); the scheduled job will run that CLI — it still updates only the global package`);
297
+ }
206
298
  return bin;
207
299
  }
208
300
  export function enableAutoupdate(log) {
package/dist/cli.js CHANGED
@@ -11,7 +11,7 @@ import { getSetting } from './storage/configStorage.js';
11
11
  import { PRISM_USER_ID, SERVER_CONFIG } from './config.js';
12
12
  import { getCurrentGitState } from './utils/git.js';
13
13
  import { sessionBootstrapHandler, sessionLoadContextHandler, sessionSaveLedgerHandler, sessionSaveHandoffHandler, } from './tools/ledgerHandlers.js';
14
- import { configureClaudeAgentPolicy, configureClaudeNativeStartup, configureCodexAgentPolicy, configureCodexNativeStartup, configureGeminiAgentPolicy, configureGeminiNativeStartup, connectHosts, migrateLegacyClaudeHooks, migrateLegacyClaudeInstructions, migrateLegacyClaudeManagedStartup, migrateLegacyClaudeProjectMcp, normalizeHostName, } from './connect.js';
14
+ import { configureClaudeAgentPolicy, configureClaudeNativeStartup, configureCodexAgentPolicy, configureCodexNativeStartup, configureGeminiAgentPolicy, configureGeminiNativeStartup, connectHosts, migrateLegacyClaudeHooks, migrateLegacyClaudeInstructions, migrateLegacyClaudeManagedStartup, migrateLegacyClaudeProjectMcp, connectResultLine, normalizeHostName, } from './connect.js';
15
15
  import { runBrowserCli } from './browserCli.js';
16
16
  import { filterPrismMemoryContext } from './utils/memoryQuality.js';
17
17
  import { isRecoverableStartupStorageError } from './utils/startupRecovery.js';
@@ -261,24 +261,12 @@ program
261
261
  return;
262
262
  }
263
263
  for (const result of summary.results) {
264
- if (result.status === 'registered') {
265
- console.log(`✓ ${result.label}: registered (${result.path})`);
266
- }
267
- else if (result.status === 'would-register') {
268
- console.log(`• ${result.label}: would register (${result.path})`);
269
- }
270
- else if (result.status === 'refreshed') {
271
- console.log(`✓ ${result.label}: Prism-managed entry refreshed (${result.path})`);
272
- }
273
- else if (result.status === 'would-refresh') {
274
- console.log(`• ${result.label}: would refresh Prism-managed entry (${result.path})`);
275
- }
276
- else if (result.status === 'existing') {
277
- console.log(`− ${result.label}: already registered — untouched (${result.path})`);
264
+ if (result.status === 'error') {
265
+ console.error(connectResultLine(result));
266
+ process.exitCode = 1;
278
267
  }
279
268
  else {
280
- console.error(`✗ ${result.label}: ${result.message || 'registration failed'} (${result.path})`);
281
- process.exitCode = 1;
269
+ console.log(connectResultLine(result));
282
270
  }
283
271
  }
284
272
  const connectedClaude = summary.results.some((result) => result.host === 'claude-code' && result.status !== 'error' && result.startupCompatible);
package/dist/connect.js CHANGED
@@ -1220,36 +1220,63 @@ function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
1220
1220
  : Object.prototype.hasOwnProperty.call(mcpServers, "prism")
1221
1221
  ? "prism"
1222
1222
  : undefined;
1223
+ // Claude Code keeps ADDITIONAL, directory-scoped registrations under
1224
+ // projects["<dir>"].mcpServers, and the scoped one wins for sessions started
1225
+ // in that directory. Refreshing only the top-level entry left those pinned to
1226
+ // a stale server path forever — measured live 2026-08-14 on a machine
1227
+ // carrying three registrations, where `--refresh` converged exactly one and
1228
+ // two directories kept launching an old build indefinitely. Only entries
1229
+ // Prism itself created are eligible, and only under --refresh, so this
1230
+ // cannot reach a hand-rolled entry. Hosts without a `projects` map are
1231
+ // unaffected: the collector returns nothing.
1232
+ const pendingProjects = refresh ? collectProjectScopedRefreshes(config, entry) : [];
1233
+ const scopedCount = pendingProjects.length;
1234
+ const scopedNoun = `${scopedCount} project-scoped ${scopedCount === 1 ? "entry" : "entries"}`;
1235
+ const writeConfig = (status, message, compatible) => {
1236
+ try {
1237
+ writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
1238
+ return result(definition, status, message, compatible);
1239
+ }
1240
+ catch (error) {
1241
+ return result(definition, "error", error instanceof Error ? error.message : String(error));
1242
+ }
1243
+ };
1223
1244
  if (existingKey) {
1224
1245
  const existingEntry = mcpServers[existingKey];
1225
1246
  const startupCompatible = existingKey === "prism-mcp"
1226
1247
  && isManagedPrismEntry(existingEntry)
1227
1248
  && isDeepStrictEqual(refreshManagedEntry(existingEntry, entry), existingEntry);
1228
1249
  if (!refresh || existingKey !== "prism-mcp" || !isManagedPrismEntry(existingEntry)) {
1229
- return result(definition, "existing", "Prism is already registered; existing entry left untouched", startupCompatible);
1250
+ if (scopedCount === 0) {
1251
+ return result(definition, "existing", "Prism is already registered; existing entry left untouched", startupCompatible);
1252
+ }
1253
+ if (dryRun) {
1254
+ return result(definition, "would-refresh", `top-level entry left untouched; would refresh ${scopedNoun}`, startupCompatible);
1255
+ }
1256
+ applyProjectScopedRefreshes(config, pendingProjects);
1257
+ return writeConfig("refreshed", `top-level entry left untouched; refreshed ${scopedNoun}`, startupCompatible);
1230
1258
  }
1231
1259
  const refreshedEntry = refreshManagedEntry(existingEntry, entry);
1232
- if (JSON.stringify(refreshedEntry) === JSON.stringify(existingEntry)) {
1260
+ const topLevelStale = JSON.stringify(refreshedEntry) !== JSON.stringify(existingEntry);
1261
+ if (!topLevelStale && scopedCount === 0) {
1233
1262
  return result(definition, "existing", "Prism-managed entry is already current", true);
1234
1263
  }
1235
1264
  if (dryRun) {
1236
- return result(definition, "would-refresh", undefined, true);
1265
+ return result(definition, "would-refresh", scopedCount > 0 ? `also ${scopedNoun}` : undefined, true);
1237
1266
  }
1238
- mcpServers[existingKey] = refreshedEntry;
1239
- config.mcpServers = mcpServers;
1240
- try {
1241
- writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
1242
- return result(definition, "refreshed", undefined, true);
1243
- }
1244
- catch (error) {
1245
- return result(definition, "error", error instanceof Error ? error.message : String(error));
1267
+ if (topLevelStale) {
1268
+ mcpServers[existingKey] = refreshedEntry;
1269
+ config.mcpServers = mcpServers;
1246
1270
  }
1271
+ applyProjectScopedRefreshes(config, pendingProjects);
1272
+ return writeConfig("refreshed", scopedCount > 0 ? `also refreshed ${scopedNoun}` : undefined, true);
1247
1273
  }
1248
1274
  if (dryRun) {
1249
- return result(definition, "would-register", undefined, true);
1275
+ return result(definition, "would-register", scopedCount > 0 ? `also ${scopedNoun}` : undefined, true);
1250
1276
  }
1251
1277
  mcpServers["prism-mcp"] = entry;
1252
1278
  config.mcpServers = mcpServers;
1279
+ applyProjectScopedRefreshes(config, pendingProjects);
1253
1280
  try {
1254
1281
  writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
1255
1282
  return result(definition, "registered", undefined, true);
@@ -1563,6 +1590,22 @@ function result(definition, status, message, startupCompatible = false) {
1563
1590
  message,
1564
1591
  };
1565
1592
  }
1593
+ /** One operator-facing line per host result.
1594
+ * The `message` a host writer attaches (e.g. "also refreshed 2 project-scoped
1595
+ * entries") MUST survive to stdout: the earlier printer used canned per-status
1596
+ * text and dropped it, so a converged directory-scoped registration was
1597
+ * invisible to the person who asked for it. */
1598
+ export function connectResultLine(result) {
1599
+ const detail = result.message ? ` — ${result.message}` : "";
1600
+ switch (result.status) {
1601
+ case "registered": return `✓ ${result.label}: registered${detail} (${result.path})`;
1602
+ case "would-register": return `• ${result.label}: would register${detail} (${result.path})`;
1603
+ case "refreshed": return `✓ ${result.label}: Prism-managed entry refreshed${detail} (${result.path})`;
1604
+ case "would-refresh": return `• ${result.label}: would refresh Prism-managed entry${detail} (${result.path})`;
1605
+ case "existing": return `− ${result.label}: already registered — untouched (${result.path})`;
1606
+ default: return `✗ ${result.label}: ${result.message || "registration failed"} (${result.path})`;
1607
+ }
1608
+ }
1566
1609
  function isJsonObject(value) {
1567
1610
  return typeof value === "object" && value !== null && !Array.isArray(value);
1568
1611
  }
@@ -1576,6 +1619,46 @@ function isManagedPrismEntry(value) {
1576
1619
  && isJsonObject(value.env)
1577
1620
  && value.env.PRISM_INSTANCE === "prism-mcp";
1578
1621
  }
1622
+ /** Prism-managed, directory-scoped registrations that are out of date.
1623
+ * Claude Code stores these under projects["<dir>"].mcpServers; other hosts
1624
+ * have no `projects` map, so this returns nothing for them. */
1625
+ function collectProjectScopedRefreshes(config, desired) {
1626
+ const projects = config.projects;
1627
+ if (!isJsonObject(projects))
1628
+ return [];
1629
+ const pending = [];
1630
+ for (const [project, projectConfig] of Object.entries(projects)) {
1631
+ if (!isJsonObject(projectConfig))
1632
+ continue;
1633
+ const servers = projectConfig.mcpServers;
1634
+ if (!isJsonObject(servers))
1635
+ continue;
1636
+ const existing = servers["prism-mcp"];
1637
+ if (!isManagedPrismEntry(existing))
1638
+ continue; // hand-rolled entries stay untouched
1639
+ const refreshed = refreshManagedEntry(existing, desired);
1640
+ if (JSON.stringify(refreshed) !== JSON.stringify(existing)) {
1641
+ pending.push({ project, entry: refreshed });
1642
+ }
1643
+ }
1644
+ return pending;
1645
+ }
1646
+ function applyProjectScopedRefreshes(config, pending) {
1647
+ if (pending.length === 0)
1648
+ return;
1649
+ const projects = config.projects;
1650
+ if (!isJsonObject(projects))
1651
+ return;
1652
+ for (const { project, entry } of pending) {
1653
+ const projectConfig = projects[project];
1654
+ if (!isJsonObject(projectConfig))
1655
+ continue;
1656
+ const servers = projectConfig.mcpServers;
1657
+ if (!isJsonObject(servers))
1658
+ continue;
1659
+ servers["prism-mcp"] = entry;
1660
+ }
1661
+ }
1579
1662
  function refreshManagedEntry(existing, desired) {
1580
1663
  const existingEnv = isJsonObject(existing.env) ? existing.env : {};
1581
1664
  const desiredEnv = isJsonObject(desired.env) ? desired.env : {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.12.0",
3
+ "version": "20.12.1",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
6
6
  "module": "index.ts",