prism-mcp-server 20.21.11 → 20.21.12

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
@@ -151,6 +151,16 @@ or by re-enabling after each run.
151
151
  <details>
152
152
  <summary>Release history (optional)</summary>
153
153
 
154
+ ## What's New in v20.21.12
155
+
156
+ ### Multiple MCP hosts keep a working dashboard link
157
+
158
+ - Each running Prism host now registers its local dashboard independently.
159
+ - `prism dashboard` and the startup message verify the newest candidates and
160
+ fall back to an older live dashboard when a newer host stops.
161
+ - Instance cleanup cannot remove another host's registration, and the open
162
+ message preserves the linked account and plan description.
163
+
154
164
  ## What's New in v20.21.11
155
165
 
156
166
  ### Linked accounts persist across dashboard refreshes
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)}`);
@@ -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)}`);
@@ -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.12",
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",