prism-mcp-server 20.11.1 → 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,54 @@ 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
+
129
+ ## What's New in v20.12.0
130
+
131
+ - **Prism now tells you when it's out of date.** Session startup shows a
132
+ one-line update notice when a newer release exists — cache-backed, at most
133
+ one registry check per day, silent offline. `PRISM_NO_UPDATE_CHECK=1`
134
+ opts out.
135
+ - **Hands-free updates, if you want them.** `prism autoupdate enable` sets up
136
+ a daily `prism update --if-idle`: it updates only the global npm package,
137
+ defers while any Prism server is running, and never touches host
138
+ configuration — that stays behind a visible `prism connect`.
139
+
140
+ ## What's New in v20.11.1
141
+
142
+ - **Saving memory never gets refused.** The save path used to reject
143
+ `session_save_ledger`/`save_handoff` calls when its path-to-project
144
+ heuristic disagreed with the project you declared — and the registry the
145
+ heuristic trusted could contain junk from earlier auto-registration, so
146
+ legitimate sessions ended unsaved. Your declaration now always wins; the
147
+ disagreement is returned as an advisory warning, and auto-registration
148
+ only accepts real repository roots.
149
+ - **Screenshots are evidence again.** `prism browser` captures on macOS were
150
+ silently *upscaled* to the size cap, so a screenshot no longer showed what
151
+ actually rendered. Only genuinely oversized captures are resized now, and
152
+ the cap no longer clips a standard 1920-wide viewport.
153
+
154
+ ## What's New in v20.10.0 – v20.11.0
155
+
156
+ - **Skill routing now works mid-session.** New prompts are matched on-device
157
+ as the conversation moves — not just on turn one — and injected within each
158
+ host's real context limits (Claude Code caps hook output at 10k chars;
159
+ Codex truncates by default), with pointer-first delivery when a payload
160
+ can't fit inline.
161
+ - **`prism connect` is a converge command.** It self-updates first, re-execs,
162
+ then reconciles MCP registration, skills, and hooks — no more
163
+ "fresh config, stale code" machines.
164
+ - **Scoped skills route on prompts too**, and startup output survives hosts
165
+ that discard structured tool content.
166
+
119
167
  ## What's New in v20.9.0 – v20.9.3
120
168
 
121
169
  - **Your skills follow your account.** `skill_save` stores a skill at the
@@ -0,0 +1,325 @@
1
+ /**
2
+ * `prism update` / `prism autoupdate` — unattended-safe package updates.
3
+ *
4
+ * Why this is NOT `prism connect` on a timer (design review 2026-08-14):
5
+ * connect writes host configuration and expects hosts to be closed — a
6
+ * condition no scheduler can guarantee. This module's whole authority is
7
+ * `npm install -g prism-mcp-server@<latest>`:
8
+ * - host configuration, hooks, and hook trust are untouchable from here —
9
+ * there is no code path to them;
10
+ * - running server processes keep the code they booted with; the next
11
+ * process start picks up the new release (global registrations resolve
12
+ * through the bin symlink);
13
+ * - `--if-idle` defers entirely while any Prism MCP server process is
14
+ * alive, and an UNVERIFIABLE process list counts as busy — fail safe;
15
+ * - a single-instance lock prevents overlapping npm runs (a scheduler
16
+ * firing while an operator updates by hand).
17
+ *
18
+ * Configuration migrations stay behind a visible `prism connect` run.
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+ import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { isNewer } from "./selfUpdate.js";
25
+ const PACKAGE = "prism-mcp-server";
26
+ const SEMVER = /^\d+\.\d+\.\d+$/;
27
+ export const AUTOUPDATE_LABEL = "com.synalux.prism.autoupdate";
28
+ function defaultFetchLatest() {
29
+ return execFileSync("npm", ["view", PACKAGE, "version"], {
30
+ encoding: "utf8",
31
+ timeout: 15_000,
32
+ }).trim();
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
+ }
50
+ function defaultInstall(version) {
51
+ execFileSync("npm", ["install", "-g", `${PACKAGE}@${version}`], {
52
+ stdio: "inherit",
53
+ timeout: 300_000,
54
+ });
55
+ }
56
+ /** Lines for live Prism MCP server processes. Throws when `ps` is unusable —
57
+ * the caller treats that as busy, never as idle. */
58
+ export function defaultListPrismProcesses() {
59
+ const out = execFileSync("ps", ["-axo", "pid=,command="], {
60
+ encoding: "utf8",
61
+ timeout: 10_000,
62
+ });
63
+ return out
64
+ .split("\n")
65
+ .filter((line) => /server\.js/.test(line) && /prism/i.test(line))
66
+ .map((line) => line.trim());
67
+ }
68
+ const LOCK_DIR = () => join(homedir(), ".prism-mcp");
69
+ const LOCK_FILE = () => join(LOCK_DIR(), "update.lock");
70
+ /** O_EXCL lockfile with stale-holder recovery (dead pid → reclaim once). */
71
+ export function defaultAcquireLock() {
72
+ mkdirSync(LOCK_DIR(), { recursive: true });
73
+ for (let attempt = 0; attempt < 2; attempt++) {
74
+ try {
75
+ const fd = openSync(LOCK_FILE(), "wx");
76
+ writeFileSync(fd, String(process.pid));
77
+ closeSync(fd);
78
+ return () => { try {
79
+ rmSync(LOCK_FILE(), { force: true });
80
+ }
81
+ catch { /* released is released */ } };
82
+ }
83
+ catch {
84
+ try {
85
+ const holder = parseInt(readFileSync(LOCK_FILE(), "utf8").trim(), 10);
86
+ if (Number.isInteger(holder)) {
87
+ try {
88
+ process.kill(holder, 0); // alive → genuinely locked
89
+ return null;
90
+ }
91
+ catch {
92
+ rmSync(LOCK_FILE(), { force: true }); // stale → reclaim and retry
93
+ continue;
94
+ }
95
+ }
96
+ rmSync(LOCK_FILE(), { force: true }); // unreadable holder → reclaim
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+ export function runPackageUpdate(deps) {
106
+ const env = deps.env ?? process.env;
107
+ const log = deps.log ?? (() => { });
108
+ if (env.PRISM_NO_SELF_UPDATE === "1") {
109
+ return { action: "skipped", detail: "PRISM_NO_SELF_UPDATE=1" };
110
+ }
111
+ if ((env.VITEST || env.NODE_ENV === "test") && !deps.fetchLatest) {
112
+ return { action: "skipped", detail: "test environment" };
113
+ }
114
+ if (deps.ifIdle) {
115
+ let running;
116
+ try {
117
+ running = (deps.listPrismProcesses ?? defaultListPrismProcesses)();
118
+ }
119
+ catch (error) {
120
+ return {
121
+ action: "deferred",
122
+ detail: `cannot verify idleness (${error instanceof Error ? error.message.split("\n")[0] : String(error)}) — deferring`,
123
+ };
124
+ }
125
+ if (running.length > 0) {
126
+ return {
127
+ action: "deferred",
128
+ detail: `${running.length} Prism MCP process(es) running — deferred until idle`,
129
+ };
130
+ }
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
+ }
154
+ const release = (deps.acquireLock ?? defaultAcquireLock)();
155
+ if (!release) {
156
+ return { action: "locked", detail: "another prism update is already running" };
157
+ }
158
+ try {
159
+ let latest;
160
+ try {
161
+ latest = (deps.fetchLatest ?? defaultFetchLatest)();
162
+ }
163
+ catch (error) {
164
+ return { action: "failed", detail: `registry unreachable (${error instanceof Error ? error.message.split("\n")[0] : String(error)})` };
165
+ }
166
+ if (!SEMVER.test(latest)) {
167
+ return { action: "failed", detail: `registry returned unexpected version "${latest}"` };
168
+ }
169
+ if (!isNewer(targetVersion, latest)) {
170
+ return { action: "current", detail: `installed package ${targetVersion} is current`, latest };
171
+ }
172
+ log(`prism ${targetVersion} → ${latest}: updating the global package …`);
173
+ try {
174
+ (deps.install ?? defaultInstall)(latest);
175
+ }
176
+ catch (error) {
177
+ return { action: "failed", detail: `npm install -g failed (${error instanceof Error ? error.message.split("\n")[0] : String(error)})`, latest };
178
+ }
179
+ return { action: "updated", detail: `global package now ${latest}; running servers pick it up on their next start`, latest };
180
+ }
181
+ finally {
182
+ release();
183
+ }
184
+ }
185
+ // ─── LaunchAgent (macOS) scheduling ──────────────────────────────
186
+ export function autoupdatePlistPath() {
187
+ return join(homedir(), "Library", "LaunchAgents", `${AUTOUPDATE_LABEL}.plist`);
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
+ }
208
+ /** Daily 03:30 local, catch-up on wake (LaunchAgents coalesce missed runs). */
209
+ export function buildAutoupdatePlist(prismBin, pathEnv = schedulerPath()) {
210
+ return `<?xml version="1.0" encoding="UTF-8"?>
211
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
212
+ <plist version="1.0">
213
+ <dict>
214
+ <key>Label</key><string>${AUTOUPDATE_LABEL}</string>
215
+ <key>ProgramArguments</key>
216
+ <array>
217
+ <string>${xmlEscape(prismBin)}</string>
218
+ <string>update</string>
219
+ <string>--if-idle</string>
220
+ </array>
221
+ <key>EnvironmentVariables</key>
222
+ <dict>
223
+ <key>PATH</key><string>${xmlEscape(pathEnv)}</string>
224
+ </dict>
225
+ <key>StartCalendarInterval</key>
226
+ <dict>
227
+ <key>Hour</key><integer>3</integer>
228
+ <key>Minute</key><integer>30</integer>
229
+ </dict>
230
+ <key>StandardOutPath</key><string>/tmp/${AUTOUPDATE_LABEL}.log</string>
231
+ <key>StandardErrorPath</key><string>/tmp/${AUTOUPDATE_LABEL}.log</string>
232
+ </dict>
233
+ </plist>
234
+ `;
235
+ }
236
+ export function autoupdateStatus() {
237
+ const plistPath = autoupdatePlistPath();
238
+ if (process.platform !== "darwin") {
239
+ return { supported: false, enabled: false, plistPath, detail: "scheduled updates are macOS-only for now (LaunchAgent)" };
240
+ }
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 */ }
255
+ return {
256
+ supported: true,
257
+ enabled,
258
+ plistPath,
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",
267
+ };
268
+ }
269
+ export function resolvePrismBin(log, deps = {}) {
270
+ const exists = deps.exists ?? existsSync;
271
+ let globalBin;
272
+ try {
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;
278
+ }
279
+ }
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
+ }
298
+ return bin;
299
+ }
300
+ export function enableAutoupdate(log) {
301
+ if (process.platform !== "darwin")
302
+ return autoupdateStatus();
303
+ const plistPath = autoupdatePlistPath();
304
+ const bin = resolvePrismBin(log);
305
+ mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
306
+ writeFileSync(plistPath, buildAutoupdatePlist(bin));
307
+ // Reload cleanly whether or not a previous generation was loaded.
308
+ try {
309
+ execFileSync("launchctl", ["unload", plistPath], { timeout: 10_000, stdio: "ignore" });
310
+ }
311
+ catch { /* not loaded */ }
312
+ execFileSync("launchctl", ["load", "-w", plistPath], { timeout: 10_000 });
313
+ return autoupdateStatus();
314
+ }
315
+ export function disableAutoupdate() {
316
+ if (process.platform !== "darwin")
317
+ return autoupdateStatus();
318
+ const plistPath = autoupdatePlistPath();
319
+ try {
320
+ execFileSync("launchctl", ["unload", plistPath], { timeout: 10_000, stdio: "ignore" });
321
+ }
322
+ catch { /* not loaded */ }
323
+ rmSync(plistPath, { force: true });
324
+ return autoupdateStatus();
325
+ }
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';
@@ -164,6 +164,51 @@ program
164
164
  program
165
165
  .command('browser')
166
166
  .description('Run the packaged local Prism Browser automation CLI');
167
+ // ─── prism update / prism autoupdate ──────────────────────────
168
+ // Unattended-safe package updates. Deliberately NOT connect-on-a-timer:
169
+ // connect writes host configuration and expects hosts to be closed, which
170
+ // a scheduler cannot guarantee. `update` touches only the global npm
171
+ // package; running servers pick the release up on their next start.
172
+ program
173
+ .command('update')
174
+ .description('Update the global prism-mcp-server package (never touches host configuration)')
175
+ .option('--if-idle', 'Only update while no Prism MCP server process is running (scheduler-safe)')
176
+ .action(async (options) => {
177
+ const { runPackageUpdate } = await import('./autoUpdate.js');
178
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
179
+ const result = runPackageUpdate({
180
+ currentVersion: pkg.version,
181
+ ifIdle: options.ifIdle,
182
+ log: (l) => console.log(l),
183
+ });
184
+ console.log(`${result.action}: ${result.detail}`);
185
+ process.exit(result.action === 'failed' ? 1 : 0);
186
+ });
187
+ program
188
+ .command('autoupdate <action>')
189
+ .description('Scheduled daily `prism update --if-idle` — enable | disable | status (opt-in, macOS)')
190
+ .action(async (action) => {
191
+ const { enableAutoupdate, disableAutoupdate, autoupdateStatus } = await import('./autoUpdate.js');
192
+ const log = (l) => console.log(l);
193
+ let status;
194
+ if (action === 'enable')
195
+ status = enableAutoupdate(log);
196
+ else if (action === 'disable')
197
+ status = disableAutoupdate();
198
+ else if (action === 'status')
199
+ status = autoupdateStatus();
200
+ else {
201
+ console.error(`unknown action "${action}" — use enable, disable, or status`);
202
+ process.exit(1);
203
+ }
204
+ if (!status.supported) {
205
+ console.log(`− ${status.detail}`);
206
+ process.exit(action === 'status' ? 0 : 1);
207
+ }
208
+ console.log(status.enabled
209
+ ? `✓ autoupdate ${status.detail}\n agent: ${status.plistPath}`
210
+ : `− autoupdate ${status.detail}`);
211
+ });
167
212
  // ─── prism connect ────────────────────────────────────────────
168
213
  // Registers this installed package with supported MCP hosts. The
169
214
  // merge is additive: an existing `prism` or `prism-mcp` entry is
@@ -216,24 +261,12 @@ program
216
261
  return;
217
262
  }
218
263
  for (const result of summary.results) {
219
- if (result.status === 'registered') {
220
- console.log(`✓ ${result.label}: registered (${result.path})`);
221
- }
222
- else if (result.status === 'would-register') {
223
- console.log(`• ${result.label}: would register (${result.path})`);
224
- }
225
- else if (result.status === 'refreshed') {
226
- console.log(`✓ ${result.label}: Prism-managed entry refreshed (${result.path})`);
227
- }
228
- else if (result.status === 'would-refresh') {
229
- console.log(`• ${result.label}: would refresh Prism-managed entry (${result.path})`);
230
- }
231
- else if (result.status === 'existing') {
232
- console.log(`− ${result.label}: already registered — untouched (${result.path})`);
264
+ if (result.status === 'error') {
265
+ console.error(connectResultLine(result));
266
+ process.exitCode = 1;
233
267
  }
234
268
  else {
235
- console.error(`✗ ${result.label}: ${result.message || 'registration failed'} (${result.path})`);
236
- process.exitCode = 1;
269
+ console.log(connectResultLine(result));
237
270
  }
238
271
  }
239
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/dist/server.js CHANGED
@@ -1293,6 +1293,21 @@ export async function startServer() {
1293
1293
  runSkillManifestSync();
1294
1294
  const skillManifestRefresh = setInterval(runSkillManifestSync, 5 * 60 * 1000);
1295
1295
  skillManifestRefresh.unref();
1296
+ // Update-check refresh: 30s AFTER startup on an unref'd timer, so the
1297
+ // registry ping is never on the prompt-critical path and never interleaves
1298
+ // with first-boot storage initialization (a boot-time kick raced the
1299
+ // first-run demo seed on CI — same-instant sqlite init from two paths).
1300
+ // Short-lived spawns (codex exec) exit before the timer fires and never
1301
+ // ping npm; long-lived hosts refresh 30s in, ample for a 24h TTL.
1302
+ // session_bootstrap renders from the persisted cache only.
1303
+ const updateCheckTimer = setTimeout(() => {
1304
+ void (async () => {
1305
+ const { refreshUpdateCache } = await import("./updateNotice.js");
1306
+ const { getSetting, setSetting } = await import("./storage/configStorage.js");
1307
+ await refreshUpdateCache({ getSetting, setSetting });
1308
+ })().catch(() => { });
1309
+ }, 30_000);
1310
+ updateCheckTimer.unref();
1296
1311
  // Register graceful shutdown handlers (SIGTERM, SIGINT, SIGHUP, stdin close).
1297
1312
  // The stdin close handler is critical — when MCP clients disconnect, they
1298
1313
  // often just close the pipe without sending a signal, leaving zombie processes.
@@ -34,6 +34,18 @@ import { getSetting, setSetting, getAllSettings, refreshConfigStorageCache } fro
34
34
  import { MATERIALIZED_GENERATION_KEY } from "../skillManifestSync.js";
35
35
  import { mergeHandoff, dbToHandoffSchema, sanitizeForMerge } from "../utils/crdtMerge.js";
36
36
  import { resolveProject } from "../utils/projectResolver.js";
37
+ import { getUpdateNotice } from "../updateNotice.js";
38
+ // The running server's own version, for the update-available notice. A read
39
+ // failure must never affect startup: an empty string fails the notice's
40
+ // semver gate, so no notice renders rather than a bogus "running 0.0.0".
41
+ const SERVER_PACKAGE_VERSION = (() => {
42
+ try {
43
+ return JSON.parse(fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version ?? "";
44
+ }
45
+ catch {
46
+ return "";
47
+ }
48
+ })();
37
49
  import { isRecoverableStartupStorageError, LOCAL_STARTUP_FALLBACK_NOTICE, } from "../utils/startupRecovery.js";
38
50
  import { PRISM_USER_ID, PRISM_AUTO_CAPTURE, PRISM_CAPTURE_PORTS } from "../config.js";
39
51
  import { captureLocalEnvironment } from "../utils/autoCapture.js";
@@ -2026,7 +2038,17 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
2026
2038
  ? `👋 Welcome to Prism — first run detected. Let's get you productive in a few minutes.`
2027
2039
  : `👋 Welcome back, ${greetingName}. Prism is loading ${depth} context.`;
2028
2040
  const identityBlock = `- 🤖 **Agent Identity:** ${escapeNativeMarkdown(compactWithOmissionCount(role, 80))} — ${greetingName}`;
2029
- const startupHeader = isFirstRun ? greeting : `${greeting}\n\n${identityBlock}`;
2041
+ // Cache-only by contract: server startup refreshes the cache asynchronously;
2042
+ // this call never touches the npm registry. First run skips the notice — a
2043
+ // just-installed machine is current, and that screen is action-first.
2044
+ const updateNotice = isFirstRun ? "" : await getUpdateNotice({
2045
+ currentVersion: SERVER_PACKAGE_VERSION,
2046
+ getSetting,
2047
+ runningFrom: process.argv[1],
2048
+ });
2049
+ const startupHeader = isFirstRun
2050
+ ? greeting
2051
+ : `${greeting}\n\n${identityBlock}${updateNotice ? `\n${updateNotice}` : ""}`;
2030
2052
  if (projects.length === 0) {
2031
2053
  const dashboardUrl = await readDashboardUrl();
2032
2054
  const dashboardLine = dashboardUrl
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Update-available notice — the trigger half of self-update.
3
+ *
4
+ * `prism connect` can converge a machine to the latest release, but nothing
5
+ * ever *ran* it: no launch agent, cron, or background updater exists, so
6
+ * releases only reached machines whose operator happened to re-run connect.
7
+ * The notice closes that gap at the only place every user reliably looks —
8
+ * the first turn of a session.
9
+ *
10
+ * Split enforced by design review (2026-08-14):
11
+ * - `getUpdateNotice` is CACHE-ONLY. It runs on the prompt-critical path
12
+ * (session_bootstrap) and must never touch the npm registry.
13
+ * - `refreshUpdateCache` is the async half. Server startup kicks it
14
+ * fire-and-forget after initialization; it respects a 24-hour TTL so a
15
+ * machine pings the registry at most once a day no matter how many
16
+ * short-lived server processes spawn (codex exec starts one per run).
17
+ * - Offline is silent. A registry answer that is not plain semver is
18
+ * discarded, never cached, never rendered — the cached value ends up
19
+ * inside model-facing markdown, so it is validated on write AND on read.
20
+ */
21
+ import { execFile } from "node:child_process";
22
+ import { realpathSync } from "node:fs";
23
+ import { isNewer } from "./selfUpdate.js";
24
+ export const UPDATE_CHECK_KEY = "update_check";
25
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
26
+ const SEMVER = /^\d+\.\d+\.\d+$/;
27
+ const PACKAGE = "prism-mcp-server";
28
+ async function readCache(getSetting) {
29
+ try {
30
+ const parsed = JSON.parse(await getSetting(UPDATE_CHECK_KEY, "{}"));
31
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
32
+ }
33
+ catch {
34
+ return {};
35
+ }
36
+ }
37
+ function cacheIsFresh(cache, now) {
38
+ return typeof cache.checked_at === "number" &&
39
+ now() - cache.checked_at < CACHE_TTL_MS &&
40
+ now() - cache.checked_at >= 0; // a future timestamp is corruption, not freshness
41
+ }
42
+ /** Cache-only: renders the notice line, or "" — never touches the network. */
43
+ export async function getUpdateNotice(deps) {
44
+ const env = deps.env ?? process.env;
45
+ if (env.PRISM_NO_UPDATE_CHECK === "1")
46
+ return "";
47
+ // A caller that could not determine its own version renders nothing —
48
+ // a comparison against "" or "0.0.0" would call every release an update.
49
+ if (!SEMVER.test(deps.currentVersion))
50
+ return "";
51
+ const now = deps.now ?? Date.now;
52
+ const cache = await readCache(deps.getSetting);
53
+ if (!cacheIsFresh(cache, now))
54
+ return "";
55
+ const latest = typeof cache.latest === "string" && SEMVER.test(cache.latest) ? cache.latest : "";
56
+ if (!latest || !isNewer(deps.currentVersion, latest))
57
+ return "";
58
+ let fromCheckout = false;
59
+ if (deps.runningFrom) {
60
+ let resolved = deps.runningFrom;
61
+ try {
62
+ resolved = realpathSync(deps.runningFrom);
63
+ }
64
+ catch { /* keep raw */ }
65
+ fromCheckout = !resolved.includes("node_modules");
66
+ }
67
+ const command = fromCheckout ? "prism connect --refresh" : "prism connect";
68
+ return `- ⬆️ **Update available:** Prism ${latest} (running ${deps.currentVersion}) — run \`${command}\``;
69
+ }
70
+ function defaultFetchLatest() {
71
+ return new Promise((resolve, reject) => {
72
+ const child = execFile("npm", ["view", PACKAGE, "version"], { encoding: "utf8", timeout: 10_000 }, (error, stdout) => (error ? reject(error) : resolve(stdout.trim())));
73
+ // A short-lived server process (codex exec) must be able to exit without
74
+ // waiting on this check.
75
+ child.unref?.();
76
+ });
77
+ }
78
+ /** Async half: refresh the persisted cache when the TTL has lapsed.
79
+ * Never throws — offline machines boot silently. */
80
+ export async function refreshUpdateCache(deps) {
81
+ const env = deps.env ?? process.env;
82
+ if (env.PRISM_NO_UPDATE_CHECK === "1")
83
+ return;
84
+ if (env.VITEST || env.NODE_ENV === "test") {
85
+ // Test runners never reach the network — same rule as self-update —
86
+ // EXCEPT through an explicitly injected fetcher, which is the test.
87
+ if (!deps.fetchLatest)
88
+ return;
89
+ }
90
+ const now = deps.now ?? Date.now;
91
+ try {
92
+ const cache = await readCache(deps.getSetting);
93
+ if (cacheIsFresh(cache, now))
94
+ return;
95
+ const latest = (await (deps.fetchLatest ?? defaultFetchLatest)()).trim();
96
+ if (!SEMVER.test(latest))
97
+ return;
98
+ await deps.setSetting(UPDATE_CHECK_KEY, JSON.stringify({ checked_at: now(), latest }));
99
+ }
100
+ catch {
101
+ // Offline, registry down, npm missing: silence. The notice simply
102
+ // does not render until a later successful check.
103
+ }
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.11.1",
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",