prism-mcp-server 20.21.11 → 20.21.13

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
@@ -70,6 +70,13 @@ Prism works locally without an account, API key, or cloud subscription. Add a
70
70
  Synalux subscription when you want cloud memory, paid-tier skills, or team
71
71
  features.
72
72
 
73
+ To check your plan, open **⚙ Account & Settings → Account** in the dashboard.
74
+ Free users see **Start 14-day trial**; paid users see **Manage subscription**.
75
+ During a trial, the same panel shows the paid tier and exact end date. No card
76
+ is required to start. Add payment details before the end date to continue; if
77
+ you do not, the subscription cancels and local Prism Free remains available.
78
+ You can also compare plans directly at [synalux.ai/pricing#prism-plans](https://synalux.ai/pricing#prism-plans).
79
+
73
80
  After a few sessions, ask what it's been worth:
74
81
 
75
82
  ```bash
@@ -151,6 +158,27 @@ or by re-enabling after each run.
151
158
  <details>
152
159
  <summary>Release history (optional)</summary>
153
160
 
161
+ ## What's New in v20.21.13
162
+
163
+ ### Paid plans and trials are clear in Account & Settings
164
+
165
+ - Free users can open the Prism plan comparison and start a 14-day trial.
166
+ - Trial users see their paid tier, exact end date, and the action to add payment
167
+ details before automatic cancellation.
168
+ - Active and payment-recovery states lead to the correct hosted billing action;
169
+ an unavailable verification is labeled instead of being presented as paid.
170
+ - Local Prism Free remains available without an account or subscription.
171
+
172
+ ## What's New in v20.21.12
173
+
174
+ ### Multiple MCP hosts keep a working dashboard link
175
+
176
+ - Each running Prism host now registers its local dashboard independently.
177
+ - `prism dashboard` and the startup message verify the newest candidates and
178
+ fall back to an older live dashboard when a newer host stops.
179
+ - Instance cleanup cannot remove another host's registration, and the open
180
+ message preserves the linked account and plan description.
181
+
154
182
  ## What's New in v20.21.11
155
183
 
156
184
  ### Linked accounts persist across dashboard refreshes
@@ -1301,7 +1329,11 @@ All on-device models are free to run locally via Ollama on every tier. A subscri
1301
1329
  | Analytics dashboard | -- | ✅ | ✅ | ✅ |
1302
1330
  | HIPAA BAA | -- | -- | -- | ✅ |
1303
1331
 
1304
- 14-day free trial on paid plans. 25+ seats: [contact sales](https://synalux.ai/support)
1332
+ Paid plans start with a 14-day trial. No card is required to start; add payment
1333
+ details before the trial ends to continue, or the subscription cancels
1334
+ automatically and local Prism Free remains available. View the exact deadline
1335
+ under **⚙ Account & Settings → Account** in `prism dashboard`. 25+ seats:
1336
+ [contact sales](https://synalux.ai/support)
1305
1337
 
1306
1338
  ---
1307
1339
 
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import { runBrowserCli } from './browserCli.js';
15
15
  import { filterPrismMemoryContext } from './utils/memoryQuality.js';
16
16
  import { isRecoverableStartupStorageError } from './utils/startupRecovery.js';
17
17
  import { verifyBehaviorHandler } from './tools/behavioralVerifierHandler.js';
18
- import { isLocalDashboardRunning, openDashboardUrl, readDashboardAccessState, } from './dashboard/dashboardAccess.js';
18
+ import { dashboardOpenSuccessMessage, findRunningDashboardAccessState, openDashboardUrl, } from './dashboard/dashboardAccess.js';
19
19
  const program = new Command();
20
20
  /** Build the stable `prism load --json` envelope from depth-specific context. */
21
21
  export function buildLoadJsonOutput(project, data, level, metadata) {
@@ -121,16 +121,13 @@ program
121
121
  .option('--print', 'Print the current local dashboard link instead of opening a browser')
122
122
  .action(async (options) => {
123
123
  try {
124
- const { url, probeKey } = readDashboardAccessState();
125
- if (!(await isLocalDashboardRunning(url, probeKey))) {
126
- throw new Error('The recorded Prism dashboard is not running. Restart your connected MCP host first.');
127
- }
124
+ const { url } = await findRunningDashboardAccessState();
128
125
  if (options.print) {
129
126
  console.log(url);
130
127
  return;
131
128
  }
132
129
  openDashboardUrl(url);
133
- console.log('Opened the local Prism Free dashboard. No Synalux account is required.');
130
+ console.log(dashboardOpenSuccessMessage());
134
131
  }
135
132
  catch (error) {
136
133
  console.error(`Dashboard unavailable: ${error instanceof Error ? error.message : String(error)}`);
@@ -6,6 +6,11 @@ import { upgradeInsecureCloudUrl } from "../utils/secureUrl.js";
6
6
  const DEFAULT_PORTAL_URL = "https://synalux.ai";
7
7
  const MAX_BODY_BYTES = 16 * 1024;
8
8
  const VALID_PLANS = new Set(["free", "standard", "advanced", "enterprise"]);
9
+ const VALID_BILLING_STATUSES = new Set([
10
+ "free", "trialing", "active", "past_due", "unpaid", "canceled",
11
+ "incomplete", "incomplete_expired", "paused", "managed", "unknown", "sync_pending",
12
+ ]);
13
+ const VALID_PAID_PLANS = new Set(["standard", "advanced", "enterprise"]);
9
14
  let accountMutationQueue = Promise.resolve();
10
15
  async function serializeAccountMutation(operation) {
11
16
  const previous = accountMutationQueue;
@@ -94,7 +99,10 @@ function signedOutAccount(origin) {
94
99
  name: null,
95
100
  role_key: null,
96
101
  plan: "free",
97
- billing: { action: "upgrade", url: `${origin}/pricing` },
102
+ subscription_plan: null,
103
+ billing_status: "free",
104
+ trial_ends_at: null,
105
+ billing: { action: "upgrade", url: `${origin}/pricing#prism-plans` },
98
106
  auth_url: authUrl(origin),
99
107
  };
100
108
  }
@@ -112,13 +120,28 @@ function normalizeAccount(value, origin) {
112
120
  const url = typeof billingRaw.url === "string" && isAllowedBillingUrl(billingRaw.url, origin)
113
121
  ? billingRaw.url
114
122
  : null;
123
+ const rawBillingStatus = typeof raw.billing_status === "string" && VALID_BILLING_STATUSES.has(raw.billing_status)
124
+ ? raw.billing_status
125
+ : plan === "free" ? "free" : "unknown";
126
+ const subscriptionPlan = typeof raw.subscription_plan === "string" && VALID_PAID_PLANS.has(raw.subscription_plan)
127
+ ? raw.subscription_plan
128
+ : null;
129
+ const billingStatus = rawBillingStatus === "sync_pending" && subscriptionPlan === null
130
+ ? "unknown"
131
+ : rawBillingStatus;
132
+ const trialEndsAt = typeof raw.trial_ends_at === "string" && Number.isFinite(Date.parse(raw.trial_ends_at))
133
+ ? new Date(raw.trial_ends_at).toISOString()
134
+ : null;
115
135
  return {
116
136
  signed_in: raw.signed_in === true,
117
137
  configured: true,
118
138
  name: typeof raw.name === "string" ? raw.name.slice(0, 160) : null,
119
139
  role_key: typeof raw.role_key === "string" ? raw.role_key.slice(0, 80) : null,
120
140
  plan: plan,
141
+ subscription_plan: subscriptionPlan,
121
142
  plan_source: raw.plan_source === "managed" ? "managed" : "stripe",
143
+ billing_status: billingStatus,
144
+ trial_ends_at: billingStatus === "trialing" || billingStatus === "sync_pending" ? trialEndsAt : null,
122
145
  billing: { action, url },
123
146
  auth_url: authUrl(origin),
124
147
  };
@@ -230,7 +253,7 @@ export async function openDashboardBilling(overrides = {}) {
230
253
  const deps = { ...defaultDeps, ...overrides };
231
254
  const origin = await portalOrigin(deps);
232
255
  if (await applyStoredSignOut(deps) || !deps.usablePortalKey()) {
233
- return { url: `${origin}/pricing`, action: "upgrade" };
256
+ return { url: `${origin}/pricing#prism-plans`, action: "upgrade" };
234
257
  }
235
258
  let jwt = await deps.getJwt();
236
259
  if (!jwt || isSynaluxSignedOut())
@@ -1,14 +1,19 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { chmodSync, closeSync, constants as fsConstants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { chmodSync, closeSync, constants as fsConstants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
- import { join } from "node:path";
5
+ import { basename, join } from "node:path";
6
6
  import { createDashboardProbeRequest, DASHBOARD_PROBE_PATH, generateDashboardProbeKey, validateDashboardProbeKey, verifyDashboardProbeResponse, } from "./dashboardProbe.js";
7
7
  const DASHBOARD_URL_FILE = "dashboard.url";
8
+ const DASHBOARD_REGISTRY_DIRECTORY = "dashboard-instances";
8
9
  const MAX_DASHBOARD_URL_BYTES = 4096;
10
+ const DASHBOARD_INSTANCE_ID = /^[a-f0-9]{32}$/;
9
11
  export function dashboardAccessUrlPath(home = homedir()) {
10
12
  return join(home, ".prism-mcp", DASHBOARD_URL_FILE);
11
13
  }
14
+ export function dashboardAccessRegistryPath(home = homedir()) {
15
+ return join(home, ".prism-mcp", DASHBOARD_REGISTRY_DIRECTORY);
16
+ }
12
17
  function dashboardAccessDirectory(home) {
13
18
  const directory = join(home, ".prism-mcp");
14
19
  mkdirSync(directory, { recursive: true, mode: 0o700 });
@@ -20,6 +25,18 @@ function dashboardAccessDirectory(home) {
20
25
  chmodSync(directory, 0o700);
21
26
  return directory;
22
27
  }
28
+ function dashboardRegistryDirectory(home) {
29
+ dashboardAccessDirectory(home);
30
+ const directory = dashboardAccessRegistryPath(home);
31
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
32
+ const stat = lstatSync(directory);
33
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
34
+ throw new Error("Local dashboard registry is not a regular directory");
35
+ }
36
+ if (process.platform !== "win32")
37
+ chmodSync(directory, 0o700);
38
+ return directory;
39
+ }
23
40
  function validateDashboardUrl(raw) {
24
41
  const value = raw.trim();
25
42
  if (!value || Buffer.byteLength(value, "utf8") > MAX_DASHBOARD_URL_BYTES) {
@@ -49,22 +66,11 @@ function validateDashboardUrl(raw) {
49
66
  }
50
67
  return parsed.toString();
51
68
  }
52
- /**
53
- * Persist the current local dashboard link for the `prism dashboard` command.
54
- * The token is a localhost capability, so the file is owner-only and symlinks
55
- * present at validation time are rejected rather than followed. This is not a
56
- * boundary against another process running as the same OS user: that process
57
- * can already read or replace owner-only Prism state.
58
- */
59
- export function writeDashboardAccessUrl(url, home = homedir(), probeKey = generateDashboardProbeKey()) {
60
- const validated = validateDashboardUrl(url);
61
- const validatedProbeKey = validateDashboardProbeKey(probeKey);
62
- const directory = dashboardAccessDirectory(home);
63
- const filePath = dashboardAccessUrlPath(home);
69
+ function writeOwnerOnlyJson(filePath, directory, value, label) {
64
70
  try {
65
71
  const existing = lstatSync(filePath);
66
72
  if (!existing.isFile() || existing.isSymbolicLink()) {
67
- throw new Error("Local dashboard link path is not a regular file");
73
+ throw new Error(`${label} path is not a regular file`);
68
74
  }
69
75
  }
70
76
  catch (error) {
@@ -77,14 +83,14 @@ export function writeDashboardAccessUrl(url, home = homedir(), probeKey = genera
77
83
  // symlink cannot redirect the token write even on platforms without
78
84
  // O_NOFOLLOW. The containing directory follows the same-OS-user trust model
79
85
  // documented above.
80
- const tempPath = join(directory, `.${DASHBOARD_URL_FILE}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
86
+ const tempPath = join(directory, `.${basename(filePath)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
81
87
  const noFollow = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW;
82
88
  const fd = openSync(tempPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, 0o600);
83
89
  let tempExists = true;
84
90
  try {
85
91
  if (process.platform !== "win32")
86
92
  fchmodSync(fd, 0o600);
87
- writeFileSync(fd, `${JSON.stringify({ version: 1, url: validated, probe_key: validatedProbeKey })}\n`, "utf8");
93
+ writeFileSync(fd, `${JSON.stringify(value)}\n`, "utf8");
88
94
  }
89
95
  finally {
90
96
  closeSync(fd);
@@ -105,9 +111,7 @@ export function writeDashboardAccessUrl(url, home = homedir(), probeKey = genera
105
111
  }
106
112
  return filePath;
107
113
  }
108
- export function readDashboardAccessState(home = homedir()) {
109
- dashboardAccessDirectory(home);
110
- const filePath = dashboardAccessUrlPath(home);
114
+ function readOwnerOnlyJson(filePath, label, missingMessage) {
111
115
  const noFollow = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW;
112
116
  let fd;
113
117
  try {
@@ -115,11 +119,10 @@ export function readDashboardAccessState(home = homedir()) {
115
119
  }
116
120
  catch (error) {
117
121
  const code = error.code;
118
- if (code === "ENOENT") {
119
- throw new Error("No current local dashboard link. Restart your connected MCP host first.");
120
- }
122
+ if (code === "ENOENT" && missingMessage)
123
+ throw new Error(missingMessage);
121
124
  if (code === "ELOOP")
122
- throw new Error("Local dashboard link path is not a regular file");
125
+ throw new Error(`${label} path is not a regular file`);
123
126
  throw error;
124
127
  }
125
128
  try {
@@ -129,40 +132,215 @@ export function readDashboardAccessState(home = homedir()) {
129
132
  // race on POSIX without broadening the same-OS-user trust boundary.
130
133
  const stat = fstatSync(fd);
131
134
  if (!stat.isFile())
132
- throw new Error("Local dashboard link path is not a regular file");
135
+ throw new Error(`${label} path is not a regular file`);
133
136
  if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
134
- throw new Error("Local dashboard link file permissions are unsafe");
137
+ throw new Error(`${label} file permissions are unsafe`);
135
138
  }
136
139
  const raw = readFileSync(fd, "utf8");
137
140
  if (!raw || Buffer.byteLength(raw, "utf8") > MAX_DASHBOARD_URL_BYTES) {
138
- throw new Error("Local dashboard link is missing or invalid");
141
+ throw new Error(`${label} is missing or invalid`);
139
142
  }
140
- let parsed;
141
143
  try {
142
- parsed = JSON.parse(raw);
144
+ return JSON.parse(raw);
143
145
  }
144
146
  catch {
145
- throw new Error("Local dashboard link is invalid");
146
- }
147
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
148
- throw new Error("Local dashboard link is invalid");
149
- }
150
- const state = parsed;
151
- if (state.version !== 1 || typeof state.url !== "string" || typeof state.probe_key !== "string") {
152
- throw new Error("Local dashboard link is invalid");
147
+ throw new Error(`${label} is invalid`);
153
148
  }
154
- return {
155
- url: validateDashboardUrl(state.url),
156
- probeKey: validateDashboardProbeKey(state.probe_key),
157
- };
158
149
  }
159
150
  finally {
160
151
  closeSync(fd);
161
152
  }
162
153
  }
154
+ function parseDashboardAccessState(parsed, label) {
155
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
156
+ throw new Error(`${label} is invalid`);
157
+ }
158
+ const state = parsed;
159
+ if (state.version !== 1 || typeof state.url !== "string" || typeof state.probe_key !== "string") {
160
+ throw new Error(`${label} is invalid`);
161
+ }
162
+ return {
163
+ url: validateDashboardUrl(state.url),
164
+ probeKey: validateDashboardProbeKey(state.probe_key),
165
+ };
166
+ }
167
+ /**
168
+ * Persist the latest local dashboard link for older Prism clients. Current
169
+ * clients discover live dashboards from the per-instance registry below.
170
+ * The token is a localhost capability, so the file is owner-only and symlinks
171
+ * present at validation time are rejected rather than followed. This is not a
172
+ * boundary against another process running as the same OS user: that process
173
+ * can already read or replace owner-only Prism state.
174
+ */
175
+ export function writeDashboardAccessUrl(url, home = homedir(), probeKey = generateDashboardProbeKey()) {
176
+ const validated = validateDashboardUrl(url);
177
+ const validatedProbeKey = validateDashboardProbeKey(probeKey);
178
+ const directory = dashboardAccessDirectory(home);
179
+ return writeOwnerOnlyJson(dashboardAccessUrlPath(home), directory, { version: 1, url: validated, probe_key: validatedProbeKey }, "Local dashboard link");
180
+ }
181
+ export function readDashboardAccessState(home = homedir()) {
182
+ dashboardAccessDirectory(home);
183
+ return parseDashboardAccessState(readOwnerOnlyJson(dashboardAccessUrlPath(home), "Local dashboard link", "No current local dashboard link. Restart your connected MCP host first."), "Local dashboard link");
184
+ }
163
185
  export function readDashboardAccessUrl(home = homedir()) {
164
186
  return readDashboardAccessState(home).url;
165
187
  }
188
+ function validateDashboardInstanceId(value) {
189
+ if (!DASHBOARD_INSTANCE_ID.test(value)) {
190
+ throw new Error("Local dashboard instance ID is invalid");
191
+ }
192
+ return value;
193
+ }
194
+ function validateRegistrationInteger(value, label, allowZero = false) {
195
+ if (!Number.isSafeInteger(value) || (allowZero ? value < 0 : value < 1)) {
196
+ throw new Error(`Local dashboard ${label} is invalid`);
197
+ }
198
+ return value;
199
+ }
200
+ function parseDashboardAccessRecord(parsed, recordPath, expectedInstanceId) {
201
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
202
+ throw new Error("Local dashboard registry record is invalid");
203
+ }
204
+ const record = parsed;
205
+ if (record.version !== 1 ||
206
+ typeof record.instance_id !== "string" ||
207
+ typeof record.pid !== "number" ||
208
+ typeof record.registered_at_ms !== "number" ||
209
+ typeof record.url !== "string" ||
210
+ typeof record.probe_key !== "string") {
211
+ throw new Error("Local dashboard registry record is invalid");
212
+ }
213
+ const instanceId = validateDashboardInstanceId(record.instance_id);
214
+ if (instanceId !== expectedInstanceId) {
215
+ throw new Error("Local dashboard registry record is invalid");
216
+ }
217
+ return {
218
+ instanceId,
219
+ pid: validateRegistrationInteger(record.pid, "process ID"),
220
+ registeredAtMs: validateRegistrationInteger(record.registered_at_ms, "registration time", true),
221
+ url: validateDashboardUrl(record.url),
222
+ probeKey: validateDashboardProbeKey(record.probe_key),
223
+ recordPath,
224
+ };
225
+ }
226
+ function readDashboardAccessRecord(recordPath, instanceId) {
227
+ return parseDashboardAccessRecord(readOwnerOnlyJson(recordPath, "Local dashboard registry record"), recordPath, instanceId);
228
+ }
229
+ function unregisterDashboardAccessRecord(record) {
230
+ let current;
231
+ try {
232
+ current = readDashboardAccessRecord(record.recordPath, record.instanceId);
233
+ }
234
+ catch (error) {
235
+ if (error.code === "ENOENT")
236
+ return;
237
+ return;
238
+ }
239
+ if (current.pid !== record.pid ||
240
+ current.registeredAtMs !== record.registeredAtMs ||
241
+ current.url !== record.url ||
242
+ current.probeKey !== record.probeKey) {
243
+ return;
244
+ }
245
+ try {
246
+ unlinkSync(record.recordPath);
247
+ }
248
+ catch {
249
+ // Best effort: an unreadable registry must not block another live instance.
250
+ }
251
+ }
252
+ function isDashboardProcessAlive(pid) {
253
+ try {
254
+ process.kill(pid, 0);
255
+ return true;
256
+ }
257
+ catch (error) {
258
+ // EPERM proves the process exists but is owned by a different principal.
259
+ return error.code === "EPERM";
260
+ }
261
+ }
262
+ /** Register one running dashboard without replacing records for other hosts. */
263
+ export function registerDashboardAccessUrl(url, home = homedir(), probeKey = generateDashboardProbeKey(), options = {}) {
264
+ const validatedUrl = validateDashboardUrl(url);
265
+ const validatedProbeKey = validateDashboardProbeKey(probeKey);
266
+ const instanceId = validateDashboardInstanceId(options.instanceId ?? randomBytes(16).toString("hex"));
267
+ const pid = validateRegistrationInteger(options.pid ?? process.pid, "process ID");
268
+ const registeredAtMs = validateRegistrationInteger(options.registeredAtMs ?? Date.now(), "registration time", true);
269
+ // Preserve the singleton file for clients released before the registry.
270
+ writeDashboardAccessUrl(validatedUrl, home, validatedProbeKey);
271
+ const directory = dashboardRegistryDirectory(home);
272
+ const recordPath = join(directory, `${instanceId}.json`);
273
+ const record = {
274
+ instanceId,
275
+ pid,
276
+ registeredAtMs,
277
+ url: validatedUrl,
278
+ probeKey: validatedProbeKey,
279
+ recordPath,
280
+ };
281
+ writeOwnerOnlyJson(recordPath, directory, {
282
+ version: 1,
283
+ instance_id: instanceId,
284
+ pid,
285
+ registered_at_ms: registeredAtMs,
286
+ url: validatedUrl,
287
+ probe_key: validatedProbeKey,
288
+ }, "Local dashboard registry record");
289
+ return {
290
+ recordPath,
291
+ state: { url: validatedUrl, probeKey: validatedProbeKey },
292
+ unregister: () => unregisterDashboardAccessRecord(record),
293
+ };
294
+ }
295
+ function readDashboardAccessRecords(home) {
296
+ const directory = dashboardRegistryDirectory(home);
297
+ const records = [];
298
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
299
+ const match = /^([a-f0-9]{32})\.json$/.exec(entry.name);
300
+ if (!match || !entry.isFile() || entry.isSymbolicLink())
301
+ continue;
302
+ try {
303
+ records.push(readDashboardAccessRecord(join(directory, entry.name), match[1]));
304
+ }
305
+ catch {
306
+ // A malformed or unsafe record can never become a dashboard capability.
307
+ }
308
+ }
309
+ return records.sort((left, right) => right.registeredAtMs - left.registeredAtMs || right.instanceId.localeCompare(left.instanceId));
310
+ }
311
+ /**
312
+ * Return the newest cryptographically verified dashboard that is still alive.
313
+ * Dead registry entries are removed one at a time; a surviving instance is
314
+ * never deleted just because a newer process stopped.
315
+ */
316
+ export async function findRunningDashboardAccessState(home = homedir(), fetcher = fetch) {
317
+ const records = readDashboardAccessRecords(home);
318
+ const probed = new Set();
319
+ for (const record of records) {
320
+ const key = `${record.url}\n${record.probeKey}`;
321
+ probed.add(key);
322
+ if (await isLocalDashboardRunning(record.url, record.probeKey, fetcher)) {
323
+ return { url: record.url, probeKey: record.probeKey };
324
+ }
325
+ // One timeout is not proof that a live host is dead. Remove the record only
326
+ // when the owning PID is also gone; otherwise preserve it for a later retry.
327
+ if (!isDashboardProcessAlive(record.pid))
328
+ unregisterDashboardAccessRecord(record);
329
+ }
330
+ // A singleton-only record was written by Prism versions before this registry.
331
+ try {
332
+ const legacy = readDashboardAccessState(home);
333
+ const key = `${legacy.url}\n${legacy.probeKey}`;
334
+ if (!probed.has(key) && await isLocalDashboardRunning(legacy.url, legacy.probeKey, fetcher)) {
335
+ return legacy;
336
+ }
337
+ }
338
+ catch (error) {
339
+ if (records.length === 0)
340
+ throw error;
341
+ }
342
+ throw new Error("No registered Prism dashboard is running. Restart your connected MCP host first.");
343
+ }
166
344
  export async function isLocalDashboardRunning(dashboardUrl, probeKey, fetcher = fetch) {
167
345
  const validated = validateDashboardUrl(dashboardUrl);
168
346
  const challenge = createDashboardProbeRequest(probeKey);
@@ -190,6 +368,9 @@ export async function isLocalDashboardRunning(dashboardUrl, probeKey, fetcher =
190
368
  return false;
191
369
  }
192
370
  }
371
+ export function dashboardOpenSuccessMessage() {
372
+ return "Opened the current local Prism dashboard in your default browser. Your Synalux account and plan are unchanged.";
373
+ }
193
374
  export function dashboardOpenCommand(url, platform = process.platform) {
194
375
  const validated = validateDashboardUrl(url);
195
376
  if (platform === "darwin")
@@ -26,7 +26,7 @@ import { activeStorageBackend } from "../storage/index.js";
26
26
  import { readDashboardLedger } from "./ledgerReader.js";
27
27
  import { PRISM_USER_ID, SERVER_CONFIG } from "../config.js";
28
28
  import { renderDashboardHTML, renderDashboardLocalOpenHTML } from "./ui.js";
29
- import { writeDashboardAccessUrl } from "./dashboardAccess.js";
29
+ import { registerDashboardAccessUrl } from "./dashboardAccess.js";
30
30
  import { createDashboardProbeResponse, DASHBOARD_PROBE_PATH, generateDashboardProbeKey, verifyDashboardProbeRequest, } from "./dashboardProbe.js";
31
31
  import { computeIntentHealth } from "./intentHealth.js";
32
32
  import { getAllSettings, setSetting, getSetting, getSettingSync } from "../storage/configStorage.js";
@@ -1488,7 +1488,13 @@ self.addEventListener('message', (e) => {
1488
1488
  // Non-fatal — just means the user has to know the port
1489
1489
  }
1490
1490
  try {
1491
- writeDashboardAccessUrl(dashboardUrl, os.homedir(), DASHBOARD_PROBE_KEY);
1491
+ const registration = registerDashboardAccessUrl(dashboardUrl, os.homedir(), DASHBOARD_PROBE_KEY);
1492
+ const unregister = registration.unregister;
1493
+ httpServer.once("close", () => {
1494
+ unregister();
1495
+ process.removeListener("exit", unregister);
1496
+ });
1497
+ process.once("exit", unregister);
1492
1498
  }
1493
1499
  catch (error) {
1494
1500
  console.error(`[Dashboard] Could not save local opener link: ${error instanceof Error ? error.message : String(error)}`);
@@ -449,6 +449,8 @@ export function renderDashboardHTML(version) {
449
449
  .identity-chip .role-icon { font-size: 0.9rem; }
450
450
  .identity-chip .identity-label { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
451
451
  .identity-chip .plan-mini { font-size: 0.58rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent-green); }
452
+ .identity-chip .plan-mini.trial { color: var(--accent-amber); }
453
+ .identity-chip .plan-mini.attention { color: #fb7185; }
452
454
  .main-tabs { overflow-x: auto; scrollbar-width: thin; }
453
455
  .main-tabs .s-tab { flex: 0 0 auto; }
454
456
  /* Settings modal tab bar */
@@ -549,6 +551,8 @@ export function renderDashboardHTML(version) {
549
551
  }
550
552
  .account-badge.role { color: var(--accent-cyan); background: rgba(6,182,212,0.1); border-color: rgba(6,182,212,0.22); }
551
553
  .account-badge.managed { color: var(--accent-amber); background: rgba(245,158,11,0.1); border-color: rgba(245,158,11,0.22); }
554
+ .account-badge.trial { color: var(--accent-green); background: rgba(34,197,94,0.1); border-color: rgba(34,197,94,0.3); }
555
+ .account-badge.attention { color: #fb7185; background: rgba(244,63,94,0.1); border-color: rgba(244,63,94,0.3); }
552
556
  .account-name { font-size: 1.35rem; line-height: 1.2; font-weight: 700; color: var(--text-primary); }
553
557
  .account-summary { margin-top: 0.45rem; color: var(--text-secondary); font-size: 0.82rem; line-height: 1.55; max-width: 540px; }
554
558
  .account-actions { display: flex; flex-wrap: wrap; gap: 0.65rem; margin-top: 1rem; }
@@ -2118,6 +2122,49 @@ function accountPlanSummary(plan) {
2118
2122
  };
2119
2123
  return summaries[plan] || summaries.free;
2120
2124
  }
2125
+ function accountTrialEndLabel(value) {
2126
+ if (!value) return '';
2127
+ var date = new Date(value);
2128
+ if (isNaN(date.getTime())) return '';
2129
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
2130
+ }
2131
+ function accountBillingBadge(account) {
2132
+ if (account.billing_status === 'trialing') return '<span class="account-badge trial">Trial active</span>';
2133
+ if (account.billing_status === 'sync_pending') return '<span class="account-badge attention">Access update pending</span>';
2134
+ if (['past_due', 'unpaid', 'incomplete', 'paused'].indexOf(account.billing_status) >= 0) {
2135
+ return '<span class="account-badge attention">Payment needs attention</span>';
2136
+ }
2137
+ if (account.billing_status === 'unknown') return '<span class="account-badge attention">Billing status unavailable</span>';
2138
+ if (account.billing_status === 'active') return '<span class="account-badge trial">Paid</span>';
2139
+ return '';
2140
+ }
2141
+ function accountSubscriptionSummary(account) {
2142
+ if (account.billing_status === 'trialing') {
2143
+ var end = accountTrialEndLabel(account.trial_ends_at);
2144
+ return accountPlanLabel(account.plan) + ' trial is active' + (end ? ' through ' + end : '') +
2145
+ '. Add payment details before the trial ends to continue; otherwise it cancels automatically.';
2146
+ }
2147
+ if (['past_due', 'unpaid', 'incomplete', 'paused'].indexOf(account.billing_status) >= 0) {
2148
+ var billedPlan = account.subscription_plan
2149
+ ? accountPlanLabel(account.subscription_plan) + ' plan'
2150
+ : account.plan === 'free' ? 'paid subscription' : accountPlanLabel(account.plan) + ' plan';
2151
+ return 'Your ' + billedPlan + ' needs billing attention. Update payment details to restore or keep cloud features active.';
2152
+ }
2153
+ if (account.billing_status === 'sync_pending') {
2154
+ var syncingPlan = accountPlanLabel(account.subscription_plan || account.plan);
2155
+ var syncTrialEnd = accountTrialEndLabel(account.trial_ends_at);
2156
+ return 'Stripe confirms your ' + syncingPlan + ' subscription' +
2157
+ (syncTrialEnd ? ' with a trial through ' + syncTrialEnd : '') +
2158
+ '. Synalux is updating access; current access remains ' + accountPlanLabel(account.plan) + '. Retry shortly or manage the subscription.';
2159
+ }
2160
+ if (account.billing_status === 'canceled' || account.billing_status === 'incomplete_expired') {
2161
+ return 'This paid subscription is no longer active. Local Prism Free remains available.';
2162
+ }
2163
+ if (account.billing_status === 'unknown') {
2164
+ return 'Prism could not verify the current trial or payment status. Open billing to review the subscription; local features remain available.';
2165
+ }
2166
+ return accountPlanSummary(account.plan);
2167
+ }
2121
2168
  function renderPlanLadder(plan) {
2122
2169
  var tiers = [
2123
2170
  ['free', 'Free', 'Local'],
@@ -2132,14 +2179,29 @@ function renderPlanLadder(plan) {
2132
2179
  }
2133
2180
  function renderAccountChip(account, error) {
2134
2181
  var chip = document.getElementById('identityChip');
2182
+ var planStatus;
2183
+ var planStatusClass;
2135
2184
  if (!chip)
2136
2185
  return;
2137
2186
  if (error) {
2138
2187
  chip.innerHTML = '<span class="role-icon">⚠️</span><span class="identity-label">Account</span>';
2139
2188
  }
2140
2189
  else if (account && account.signed_in) {
2190
+ planStatus = account.billing_status === 'sync_pending'
2191
+ ? accountPlanLabel(account.subscription_plan || account.plan) + ' syncing'
2192
+ : account.billing_status === 'trialing' ? ' trial' :
2193
+ ['past_due', 'unpaid', 'incomplete', 'paused'].indexOf(account.billing_status) >= 0
2194
+ ? (account.plan === 'free' ? 'Payment due' : ' payment due') :
2195
+ account.billing_status === 'unknown' ? ' status unavailable' : '';
2196
+ planStatusClass = account.billing_status === 'trialing' ? ' trial' :
2197
+ ['past_due', 'unpaid', 'incomplete', 'paused', 'unknown', 'sync_pending'].indexOf(account.billing_status) >= 0 ? ' attention' : '';
2198
+ var chipPlanLabel = account.billing_status === 'sync_pending'
2199
+ ? planStatus
2200
+ : account.plan === 'free' && planStatus === 'Payment due'
2201
+ ? planStatus
2202
+ : accountPlanLabel(account.plan) + planStatus;
2141
2203
  chip.innerHTML = '<span class="role-icon">🤖</span><span class="identity-label">' + escapeHtml(account.name || 'Synalux user') + '</span>' +
2142
- '<span class="plan-mini">' + escapeHtml(accountPlanLabel(account.plan)) + '</span>';
2204
+ '<span class="plan-mini' + planStatusClass + '">' + escapeHtml(chipPlanLabel) + '</span>';
2143
2205
  }
2144
2206
  else {
2145
2207
  chip.innerHTML = '<span class="role-icon">🆓</span><span class="identity-label">Free</span>';
@@ -2180,14 +2242,17 @@ function renderAccountPanel(account, error) {
2180
2242
  return;
2181
2243
  }
2182
2244
  role = account.role_key || 'user';
2183
- billingLabel = account.billing && account.billing.action === 'manage' ? 'Manage subscription' :
2184
- account.billing && account.billing.action === 'included' ? 'View plans' : 'Upgrade plan';
2245
+ billingLabel = account.billing_status === 'trialing' ? 'Add payment details' :
2246
+ account.billing_status === 'sync_pending' ? 'Manage subscription' :
2247
+ ['past_due', 'unpaid', 'incomplete', 'paused'].indexOf(account.billing_status) >= 0 ? 'Update payment details' :
2248
+ account.billing && account.billing.action === 'manage' ? 'Manage subscription' :
2249
+ account.billing && account.billing.action === 'included' ? 'View plans' : 'Start 14-day trial';
2185
2250
  managedBadge = account.plan_source === 'managed' ? '<span class="account-badge managed">Managed</span>' : '';
2186
2251
  panel.innerHTML = '<div class="account-card"><div class="account-card-head"><div>' +
2187
2252
  '<div class="account-badges"><span class="account-badge">' + escapeHtml(accountPlanLabel(account.plan)) + '</span>' +
2188
- '<span class="account-badge role">' + escapeHtml(role) + '</span>' + managedBadge + '</div>' +
2253
+ '<span class="account-badge role">' + escapeHtml(role) + '</span>' + managedBadge + accountBillingBadge(account) + '</div>' +
2189
2254
  '<div class="account-name">' + escapeHtml(account.name || 'Synalux user') + '</div>' +
2190
- '<div class="account-summary">' + accountPlanSummary(account.plan) + '</div></div></div>' +
2255
+ '<div class="account-summary">' + escapeHtml(accountSubscriptionSummary(account)) + '</div></div></div>' +
2191
2256
  '<div class="account-actions"><button class="account-action" id="accountBillingButton" onclick="openAccountBilling()">' + billingLabel + '</button>' +
2192
2257
  '<button class="account-action danger" id="accountSignOutButton" onclick="signOutAccount()">Sign out</button></div>' +
2193
2258
  '<div class="account-status" id="accountStatus"></div></div>' + renderPlanLadder(account.plan);
@@ -36,6 +36,7 @@ import { renderFloorDigestBlock, skillDigestFromSource, splitSkillFrontmatter }
36
36
  import { mergeHandoff, dbToHandoffSchema, sanitizeForMerge } from "../utils/crdtMerge.js";
37
37
  import { resolveProject } from "../utils/projectResolver.js";
38
38
  import { getUpdateNotice } from "../updateNotice.js";
39
+ import { findRunningDashboardAccessState } from "../dashboard/dashboardAccess.js";
39
40
  // The running server's own version, for the update-available notice. A read
40
41
  // failure must never affect startup: an empty string fails the notice's
41
42
  // semver gate, so no notice renders rather than a bogus "running 0.0.0".
@@ -610,14 +611,29 @@ function freeTierUpgradeLine(tier) {
610
611
  * a private localhost capability, so startup advertises the local CLI opener
611
612
  * rather than copying that link into agent context.
612
613
  */
613
- async function readDashboardUrl() {
614
+ export async function readDashboardUrlForStartup(home = os.homedir()) {
615
+ const explicitPort = (process.env.PRISM_DASHBOARD_PORT || "").trim();
616
+ // Current Prism versions keep one signed record per dashboard instance. The
617
+ // newest record may belong to a host that just stopped, so use the same
618
+ // verified fallback selection as `prism dashboard` before consulting legacy
619
+ // singleton state. An explicit port remains authoritative for this process.
620
+ if (!explicitPort) {
621
+ try {
622
+ const state = await findRunningDashboardAccessState(home);
623
+ return new URL(state.url).origin;
624
+ }
625
+ catch {
626
+ // Continue into the legacy port probe for older installations and the
627
+ // startup instant before this process has written its registry record.
628
+ }
629
+ }
614
630
  // Precedence: explicit env override > recorded port file > default. The
615
631
  // file is written by whatever dashboard ran last and persists across boots,
616
632
  // so it must never outrank configuration the operator set for THIS process.
617
- let port = (process.env.PRISM_DASHBOARD_PORT || "").trim();
633
+ let port = explicitPort;
618
634
  if (!port) {
619
635
  try {
620
- const recorded = fs.readFileSync(nodePath.join(os.homedir(), ".prism-mcp", "dashboard.port"), "utf8").trim();
636
+ const recorded = fs.readFileSync(nodePath.join(home, ".prism-mcp", "dashboard.port"), "utf8").trim();
621
637
  if (/^\d{2,5}$/.test(recorded))
622
638
  port = recorded;
623
639
  }
@@ -2338,7 +2354,7 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
2338
2354
  ? greeting
2339
2355
  : `${greeting}\n\n${identityBlock}${updateNotice ? `\n${updateNotice}` : ""}`;
2340
2356
  if (projects.length === 0) {
2341
- const dashboardUrl = await readDashboardUrl();
2357
+ const dashboardUrl = await readDashboardUrlForStartup();
2342
2358
  const dashboardLine = dashboardUrl
2343
2359
  ? `- 🎛️ **Dashboard:** run \`prism dashboard\` — opens locally with no Synalux account required`
2344
2360
  : `- 🎛️ **Dashboard:** not running — start Prism's dashboard to configure projects, identity, and context depth`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.21.11",
3
+ "version": "20.21.13",
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",