prism-mcp-server 20.6.0 → 20.7.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.
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Agent Definition Sync — materializes portal-served agent definitions
3
+ * (subagent routing policy: model tier, effort cap, tool surface) into
4
+ * Claude Code's agent root (~/.claude/agents/<name>.md).
5
+ *
6
+ * Deliberately separate from skillManifestSync's directory-package engine:
7
+ * agent definitions are SINGLE FILES, so this module implements file-level
8
+ * semantics with the same two protective properties the skill engine pins:
9
+ *
10
+ * 1. A hand-edited or foreign file is NEVER overwritten or deleted — it is
11
+ * reported as a conflict and left byte-identical on disk.
12
+ * 2. Ownership is provable, not assumed: a file is "ours" only when its
13
+ * current content digest matches the digest recorded in the sidecar
14
+ * index at the time we last wrote it.
15
+ *
16
+ * The server contract (portal /api/v1/prism/skill-manifest) ships agents
17
+ * under separate `agents` + `agents_generation` fields precisely so that
18
+ * clients which predate this module ignore them; `generation` remains a
19
+ * skills-only hash. Do not fold agents into the skills generation on either
20
+ * side.
21
+ */
22
+ import { createHash, randomUUID } from "node:crypto";
23
+ import { link, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+ const OWNER = "prism-mcp";
27
+ const INDEX = ".prism-agents.json";
28
+ const SAFE_NAME = /^[A-Za-z0-9_-]+$/;
29
+ const SHA256 = /^[a-f0-9]{64}$/i;
30
+ const MAX_AGENTS = 64;
31
+ const MAX_AGENT_BYTES = 256 * 1024;
32
+ export function parseAgentDefinition(content) {
33
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
34
+ if (!match)
35
+ return { fields: {}, body: content };
36
+ const fields = {};
37
+ for (const line of match[1].split("\n")) {
38
+ const entry = line.match(/^([A-Za-z_-]+):\s*(.*)$/);
39
+ if (entry)
40
+ fields[entry[1]] = entry[2].trim();
41
+ }
42
+ return { fields, body: content.slice(match[0].length) };
43
+ }
44
+ // ─── Host renderers ──────────────────────────────────────────
45
+ //
46
+ // The canonical format is Claude Code's agent markdown; other hosts receive a
47
+ // translation of the universally-expressible subset. Model tier is
48
+ // DELIBERATELY not translated: model namespaces are host-specific and rotate
49
+ // (gpt-5.6-* today), so foreign hosts keep their own configured defaults
50
+ // (codex: default_subagent_model) and only the proven effort/tool-surface
51
+ // levers travel.
52
+ /** Claude Code: the canonical content verbatim. */
53
+ export const renderClaudeAgent = (agent) => ({
54
+ file: `${agent.name}.md`,
55
+ content: agent.content,
56
+ });
57
+ const CODEX_EFFORT = {
58
+ low: "low", medium: "medium", high: "high", xhigh: "high", max: "high",
59
+ };
60
+ function tomlString(value) {
61
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
62
+ }
63
+ function tomlMultiline(value) {
64
+ // Escaping every backslash and quote keeps any body (including """ runs)
65
+ // valid inside a multiline basic string while preserving literal newlines.
66
+ return `"""\n${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"""`;
67
+ }
68
+ /**
69
+ * Codex: ~/.codex/agents/<name>.toml. Keys verified against codex 0.146.0:
70
+ * name, description, developer_instructions, model_reasoning_effort,
71
+ * sandbox_mode. A Bash-bearing tool surface implies the agent must execute
72
+ * reproductions, so it gets workspace-write; otherwise read-only.
73
+ */
74
+ export const renderCodexAgent = (agent) => {
75
+ const { fields, body } = parseAgentDefinition(agent.content);
76
+ const effort = CODEX_EFFORT[fields.effort ?? ""];
77
+ const tools = (fields.tools ?? "").split(",").map((tool) => tool.trim());
78
+ const sandbox = tools.includes("Bash") ? "workspace-write" : "read-only";
79
+ const lines = [
80
+ `name = ${tomlString(agent.name)}`,
81
+ `description = ${tomlString(fields.description ?? agent.name)}`,
82
+ ...(effort ? [`model_reasoning_effort = ${tomlString(effort)}`] : []),
83
+ `sandbox_mode = ${tomlString(sandbox)}`,
84
+ `developer_instructions = ${tomlMultiline(body)}`,
85
+ ];
86
+ return { file: `${agent.name}.toml`, content: `${lines.join("\n")}\n` };
87
+ };
88
+ /**
89
+ * Gemini CLI: ~/.gemini/agents/<name>.md (verified loadAgentsFromDirectory:
90
+ * .md files, "_"-prefixed skipped). Frontmatter is reduced to the fields
91
+ * gemini understands; Claude-specific keys (tools/model/effort) are dropped
92
+ * rather than risked against a stricter parser. Coexists with the
93
+ * prism-connect agent policy that owns ~/.gemini/settings.json overrides.
94
+ */
95
+ export const renderGeminiAgent = (agent) => {
96
+ const { fields, body } = parseAgentDefinition(agent.content);
97
+ const frontmatter = [
98
+ "---",
99
+ `name: ${agent.name}`,
100
+ `description: ${fields.description ?? agent.name}`,
101
+ "---",
102
+ ].join("\n");
103
+ return { file: `${agent.name}.md`, content: `${frontmatter}\n\n${body}` };
104
+ };
105
+ function sha256(value) {
106
+ return createHash("sha256").update(value).digest("hex");
107
+ }
108
+ /**
109
+ * Parse the optional agents section of a skill-manifest payload.
110
+ * Returns null when the server predates agents (fields absent).
111
+ * Throws on a malformed section — the caller decides whether that is fatal;
112
+ * it must never be silently treated as "no agents", because that would turn
113
+ * contract drift into an invisible prune signal.
114
+ */
115
+ export function validateAgentSection(payload) {
116
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
117
+ return null;
118
+ const value = payload;
119
+ if (value.agents === undefined && value.agents_generation === undefined)
120
+ return null;
121
+ if (!Array.isArray(value.agents) || typeof value.agents_generation !== "string" ||
122
+ !SHA256.test(value.agents_generation)) {
123
+ throw new Error("invalid agent section");
124
+ }
125
+ if (value.agents.length > MAX_AGENTS)
126
+ throw new Error("agent section exceeds bounds");
127
+ const names = new Set();
128
+ const agents = value.agents.map((raw, index) => {
129
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
130
+ throw new Error(`invalid agent at index ${index}`);
131
+ const agent = raw;
132
+ if (typeof agent.name !== "string" || !SAFE_NAME.test(agent.name))
133
+ throw new Error(`invalid agent name at index ${index}`);
134
+ const folded = agent.name.toLocaleLowerCase("en-US");
135
+ if (names.has(folded))
136
+ throw new Error(`duplicate agent name: ${agent.name}`);
137
+ names.add(folded);
138
+ if (typeof agent.content !== "string" || !agent.content.trim())
139
+ throw new Error(`empty agent content: ${agent.name}`);
140
+ if (Buffer.byteLength(agent.content, "utf8") > MAX_AGENT_BYTES)
141
+ throw new Error(`agent definition too large: ${agent.name}`);
142
+ if (typeof agent.digest !== "string" || !SHA256.test(agent.digest) ||
143
+ sha256(Buffer.from(agent.content, "utf8")) !== agent.digest.toLowerCase()) {
144
+ throw new Error(`agent digest mismatch: ${agent.name}`);
145
+ }
146
+ return { name: agent.name, content: agent.content, digest: agent.digest.toLowerCase() };
147
+ });
148
+ return { agents, generation: value.agents_generation.toLowerCase() };
149
+ }
150
+ /**
151
+ * Claude Code's agent-definition root, mirroring the skill sync's detection
152
+ * guard: auto-detect only in the production default configuration so tests
153
+ * and custom-root callers stay isolated.
154
+ */
155
+ export async function resolveClaudeAgentsDir(options = {}) {
156
+ if (options.claudeCodeAgentsDir === false)
157
+ return null;
158
+ if (typeof options.claudeCodeAgentsDir === "string")
159
+ return options.claudeCodeAgentsDir;
160
+ if (options.agentsSkillsDir !== undefined)
161
+ return null;
162
+ const claudeHome = join(options.homeDir ?? homedir(), ".claude");
163
+ try {
164
+ const stat = await lstat(claudeHome);
165
+ if (!stat.isDirectory() || stat.isSymbolicLink())
166
+ return null;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ return join(claudeHome, "agents");
172
+ }
173
+ async function detectHostAgentsDir(hostHome) {
174
+ try {
175
+ const stat = await lstat(hostHome);
176
+ if (!stat.isDirectory() || stat.isSymbolicLink())
177
+ return null;
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ return join(hostHome, "agents");
183
+ }
184
+ /** Codex agent root (~/.codex/agents or $CODEX_HOME/agents), same guards. */
185
+ export async function resolveCodexAgentsDir(options = {}) {
186
+ if (options.codexAgentsDir === false)
187
+ return null;
188
+ if (typeof options.codexAgentsDir === "string")
189
+ return options.codexAgentsDir;
190
+ if (options.agentsSkillsDir !== undefined)
191
+ return null;
192
+ const configured = (options.env ?? process.env).CODEX_HOME?.trim();
193
+ const codexHome = configured || join(options.homeDir ?? homedir(), ".codex");
194
+ return detectHostAgentsDir(codexHome);
195
+ }
196
+ /** Gemini CLI agent root (~/.gemini/agents), same guards. */
197
+ export async function resolveGeminiAgentsDir(options = {}) {
198
+ if (options.geminiAgentsDir === false)
199
+ return null;
200
+ if (typeof options.geminiAgentsDir === "string")
201
+ return options.geminiAgentsDir;
202
+ if (options.agentsSkillsDir !== undefined)
203
+ return null;
204
+ return detectHostAgentsDir(join(options.homeDir ?? homedir(), ".gemini"));
205
+ }
206
+ const SAFE_FILE = /^[A-Za-z0-9_-]+\.(md|toml)$/;
207
+ async function readIndex(path) {
208
+ try {
209
+ const parsed = JSON.parse(await readFile(path, "utf8"));
210
+ if (parsed?.owner !== OWNER || typeof parsed.files !== "object" || !parsed.files)
211
+ return null;
212
+ const files = {};
213
+ for (const [name, entry] of Object.entries(parsed.files)) {
214
+ if (!SAFE_NAME.test(name) || !entry || typeof entry !== "object")
215
+ continue;
216
+ const { digest, file } = entry;
217
+ if (typeof digest === "string" && SHA256.test(digest) && typeof file === "string" && SAFE_FILE.test(file)) {
218
+ files[name] = { digest: digest.toLowerCase(), file };
219
+ }
220
+ }
221
+ return { owner: OWNER, generation: typeof parsed.generation === "string" ? parsed.generation : "", files };
222
+ }
223
+ catch {
224
+ return null;
225
+ }
226
+ }
227
+ async function currentDigest(path) {
228
+ try {
229
+ const stat = await lstat(path);
230
+ if (!stat.isFile() || stat.isSymbolicLink())
231
+ return null;
232
+ return sha256(await readFile(path));
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ }
238
+ /**
239
+ * Exclusive install: hard-link the temp file into place. Unlike rename(2),
240
+ * link(2) fails with EEXIST instead of overwriting, so a file that appears
241
+ * between our existence check and the install cannot be clobbered (TOCTOU).
242
+ * Returns false when the target already exists.
243
+ */
244
+ async function installExclusive(dir, target, content) {
245
+ const temp = join(dir, `.prism-agent-${randomUUID()}.tmp`);
246
+ await writeFile(temp, content, { mode: 0o600 });
247
+ try {
248
+ await link(temp, target);
249
+ return true;
250
+ }
251
+ catch (error) {
252
+ if (isErrno(error, "EEXIST"))
253
+ return false;
254
+ // Filesystems without hard-link support (exFAT/FAT32, several SMB and
255
+ // container-volume mounts) reject link(2) outright. Without this fallback
256
+ // those users receive ZERO agent definitions and only a stderr line —
257
+ // the exclusivity hardening would have silently disabled the feature.
258
+ // rename(2) is marginally weaker against a concurrent writer inside the
259
+ // check→act window; delivering nothing is strictly worse.
260
+ if (isErrno(error, "EPERM") || isErrno(error, "ENOTSUP") ||
261
+ isErrno(error, "EOPNOTSUPP") || isErrno(error, "EXDEV")) {
262
+ if (await currentDigest(target) !== null)
263
+ return false;
264
+ await rename(temp, target);
265
+ return true;
266
+ }
267
+ throw error;
268
+ }
269
+ finally {
270
+ await rm(temp, { force: true });
271
+ }
272
+ }
273
+ /**
274
+ * Claim-then-verify mutation: atomically rename the target out of the root,
275
+ * re-hash the CLAIMED bytes, and only act when they still match the recorded
276
+ * digest. A mismatch (someone edited the file inside the check→act window)
277
+ * restores the file byte-identical and reports a conflict. This closes the
278
+ * check-then-act race CodeQL flags (js/file-system-race): after the rename we
279
+ * operate on a path no other writer targets, and the decision digest is
280
+ * computed from those exact bytes.
281
+ */
282
+ async function claimVerified(dir, target, expectedDigest) {
283
+ const claimPath = join(dir, `.prism-agent-claim-${randomUUID()}.tmp`);
284
+ try {
285
+ await rename(target, claimPath);
286
+ }
287
+ catch (error) {
288
+ if (isErrno(error, "ENOENT"))
289
+ return "gone";
290
+ throw error;
291
+ }
292
+ const bytes = await readFile(claimPath);
293
+ if (sha256(bytes) !== expectedDigest) {
294
+ await rename(claimPath, target); // restore byte-identical
295
+ return "mismatch";
296
+ }
297
+ return { claimed: claimPath };
298
+ }
299
+ function isErrno(error, code) {
300
+ return typeof error === "object" && error !== null && "code" in error
301
+ && error.code === code;
302
+ }
303
+ /**
304
+ * Apply the agent section to a host agent root. Every write is an atomic
305
+ * tmp+rename of a single small file; the index commits last, so a crash
306
+ * mid-run leaves files that the next run re-proves ownership of by digest.
307
+ */
308
+ export async function materializeAgentDefinitions(section, targetDir, render = renderClaudeAgent) {
309
+ await mkdir(targetDir, { recursive: true, mode: 0o700 });
310
+ const rootStat = await lstat(targetDir);
311
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
312
+ throw new Error("agent root must be a real directory");
313
+ }
314
+ const indexPath = join(targetDir, INDEX);
315
+ const index = await readIndex(indexPath);
316
+ const owned = index?.files ?? {};
317
+ const incoming = new Map();
318
+ for (const agent of section.agents) {
319
+ const rendered = render(agent);
320
+ if (rendered === null)
321
+ continue;
322
+ if (!SAFE_FILE.test(rendered.file))
323
+ throw new Error(`unsafe rendered agent file: ${rendered.file}`);
324
+ incoming.set(agent.name, rendered);
325
+ }
326
+ const installed = [];
327
+ const updated = [];
328
+ const pruned = [];
329
+ const conflicts = [];
330
+ const finalFiles = {};
331
+ // Downgrades first, mirroring the skill engine's ordering: entitlement
332
+ // removal must not depend on the success of later installs. Deletion is
333
+ // claim-then-verify: the digest that authorizes the rm is computed from the
334
+ // bytes AFTER they leave the discovery root, so an edit racing the check
335
+ // can never be destroyed.
336
+ for (const [name, entry] of Object.entries(owned)) {
337
+ if (incoming.has(name))
338
+ continue;
339
+ const target = join(targetDir, entry.file);
340
+ const claim = await claimVerified(targetDir, target, entry.digest);
341
+ if (claim === "gone")
342
+ continue; // already gone
343
+ if (claim === "mismatch") {
344
+ // Hand-edited content survives; we merely stop claiming it.
345
+ conflicts.push(name);
346
+ continue;
347
+ }
348
+ await rm(claim.claimed, { force: true });
349
+ pruned.push(name);
350
+ }
351
+ for (const [name, rendered] of incoming) {
352
+ const target = join(targetDir, rendered.file);
353
+ const renderedDigest = sha256(Buffer.from(rendered.content, "utf8"));
354
+ const digestOnDisk = await currentDigest(target);
355
+ if (digestOnDisk === null) {
356
+ // Exclusive install: a file appearing inside the window makes link()
357
+ // fail instead of being overwritten; re-judge it as foreign content.
358
+ if (await installExclusive(targetDir, target, rendered.content)) {
359
+ installed.push(name);
360
+ finalFiles[name] = { digest: renderedDigest, file: rendered.file };
361
+ }
362
+ else if (await currentDigest(target) === renderedDigest) {
363
+ finalFiles[name] = { digest: renderedDigest, file: rendered.file };
364
+ }
365
+ else {
366
+ conflicts.push(name);
367
+ }
368
+ continue;
369
+ }
370
+ const record = owned[name];
371
+ const pristine = record !== undefined && record.file === rendered.file && digestOnDisk === record.digest;
372
+ if (!pristine) {
373
+ if (digestOnDisk === renderedDigest) {
374
+ // Byte-identical foreign file: adopt without writing. Content equality
375
+ // is the strongest ownership evidence available for a single file.
376
+ finalFiles[name] = { digest: renderedDigest, file: rendered.file };
377
+ }
378
+ else {
379
+ conflicts.push(name);
380
+ }
381
+ continue;
382
+ }
383
+ if (digestOnDisk === renderedDigest) {
384
+ finalFiles[name] = { digest: renderedDigest, file: rendered.file };
385
+ continue;
386
+ }
387
+ // Update = claim the old version out (verifying the recorded digest on
388
+ // the claimed bytes), then exclusively install the new render. A racer
389
+ // in either window wins the file and we report a conflict instead of
390
+ // clobbering; the claimed old version is ours and safe to discard.
391
+ const claim = await claimVerified(targetDir, target, record.digest);
392
+ if (claim === "mismatch") {
393
+ conflicts.push(name);
394
+ continue;
395
+ }
396
+ const installedNow = await installExclusive(targetDir, target, rendered.content);
397
+ if (claim !== "gone")
398
+ await rm(claim.claimed, { force: true });
399
+ if (installedNow) {
400
+ updated.push(name);
401
+ finalFiles[name] = { digest: renderedDigest, file: rendered.file };
402
+ }
403
+ else {
404
+ conflicts.push(name);
405
+ }
406
+ }
407
+ const nextIndex = { owner: OWNER, generation: section.generation, files: finalFiles };
408
+ const tempIndex = join(targetDir, `${INDEX}.${randomUUID()}.tmp`);
409
+ await writeFile(tempIndex, `${JSON.stringify(nextIndex, null, 2)}\n`, { mode: 0o600 });
410
+ await rename(tempIndex, indexPath);
411
+ return {
412
+ installed: installed.sort(),
413
+ updated: updated.sort(),
414
+ pruned: pruned.sort(),
415
+ conflicts: [...new Set(conflicts)].sort(),
416
+ };
417
+ }
@@ -4,6 +4,7 @@ import { access, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realp
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { applyManagedSkillManifest, getSetting, refreshConfigStorageCache, } from "./storage/configStorage.js";
7
+ import { materializeAgentDefinitions, renderClaudeAgent, renderCodexAgent, renderGeminiAgent, resolveClaudeAgentsDir, resolveCodexAgentsDir, resolveGeminiAgentsDir, validateAgentSection, } from "./agentManifestSync.js";
7
8
  import { FREE_NATIVE_SKILL_NAMES, REQUIRED_NATIVE_SKILL_NAMES } from "./tools/skillRouting.js";
8
9
  import { getSynaluxJwt, invalidateSynaluxJwt } from "./utils/synaluxJwt.js";
9
10
  const OWNER = "prism-skill-sync-v1";
@@ -763,6 +764,10 @@ async function fetchManifest(options) {
763
764
  if (!headers.Authorization && manifest.tier !== "free") {
764
765
  throw new Error("unauthenticated skill manifest must be free tier");
765
766
  }
767
+ // Agents ride the same response under separate fields. A malformed section
768
+ // is contract drift and must fail the fetch loudly — treating it as "no
769
+ // agents" would convert server corruption into a silent prune signal.
770
+ manifest.agentSection = validateAgentSection(payload);
766
771
  return manifest;
767
772
  }
768
773
  async function acquireSyncLock(agentsSkillsDir, waitMs = LOCK_WAIT_MS) {
@@ -851,6 +856,35 @@ async function acquireSyncLock(agentsSkillsDir, waitMs = LOCK_WAIT_MS) {
851
856
  }
852
857
  };
853
858
  }
859
+ /**
860
+ * Converge every detected host's agent root onto the portal-validated agent
861
+ * section. Prefixed outcomes per host; a failure on one host degrades to a
862
+ * conflict rather than aborting the others or the skill result.
863
+ */
864
+ async function materializeAgentsAcrossHosts(section, options) {
865
+ const result = { installed: [], updated: [], pruned: [], conflicts: [] };
866
+ const hostTargets = [
867
+ { prefix: "agent", dir: await resolveClaudeAgentsDir(options), render: renderClaudeAgent },
868
+ { prefix: "agent-codex", dir: await resolveCodexAgentsDir(options), render: renderCodexAgent },
869
+ { prefix: "agent-gemini", dir: await resolveGeminiAgentsDir(options), render: renderGeminiAgent },
870
+ ];
871
+ for (const host of hostTargets) {
872
+ if (!host.dir)
873
+ continue;
874
+ try {
875
+ const outcome = await materializeAgentDefinitions(section, host.dir, host.render);
876
+ result.installed.push(...outcome.installed.map((name) => `${host.prefix}:${name}`));
877
+ result.updated.push(...outcome.updated.map((name) => `${host.prefix}:${name}`));
878
+ result.pruned.push(...outcome.pruned.map((name) => `${host.prefix}:${name}`));
879
+ result.conflicts.push(...outcome.conflicts.map((name) => `${host.prefix}:${name}`));
880
+ }
881
+ catch (error) {
882
+ console.error(`[Prism Skill Sync] ${host.prefix} materialization failed: ${error instanceof Error ? error.message : String(error)}`);
883
+ result.conflicts.push(`${host.prefix}:sync-failed`);
884
+ }
885
+ }
886
+ return result;
887
+ }
854
888
  export async function synchronizeSkillManifest(options = {}) {
855
889
  const empty = { installed: [], updated: [], pruned: [], conflicts: [] };
856
890
  let nativeSkillsDirs = [];
@@ -890,6 +924,17 @@ export async function synchronizeSkillManifest(options = {}) {
890
924
  nativeResults.push(await materializeNative(manifest, nativeSkillsDir, options));
891
925
  }
892
926
  const native = mergeNativeResults(nativeResults);
927
+ // Agent definitions are additive: they piggyback on the manifest with
928
+ // their own generation, and each host's outcome reports under its own
929
+ // prefix. A failure on one host never rolls back skill state or the
930
+ // other hosts — it surfaces as a conflict instead.
931
+ if (manifest.agentSection) {
932
+ const outcome = await materializeAgentsAcrossHosts(manifest.agentSection, options);
933
+ native.installed.push(...outcome.installed);
934
+ native.updated.push(...outcome.updated);
935
+ native.pruned.push(...outcome.pruned);
936
+ native.conflicts.push(...outcome.conflicts);
937
+ }
893
938
  const status = native.installed.length || native.updated.length || native.pruned.length ? "applied" : "unchanged";
894
939
  return {
895
940
  status,
@@ -915,6 +960,19 @@ export async function synchronizeSkillManifest(options = {}) {
915
960
  catch (enforcement) {
916
961
  enforcementErrors.push(`${nativeSkillsDir}: ${enforcement instanceof Error ? enforcement.message : String(enforcement)}`);
917
962
  }
963
+ // Agent definitions need the SAME downgrade guarantee as skills. Without
964
+ // this, a tier downgrade whose DB apply throws prunes paid skills but
965
+ // leaves paid agent definitions in every host root until some later
966
+ // successful sync — exactly the local-fault-becomes-entitlement-bypass
967
+ // the skill path above exists to prevent.
968
+ if (manifest.agentSection) {
969
+ try {
970
+ await materializeAgentsAcrossHosts(manifest.agentSection, options);
971
+ }
972
+ catch (enforcement) {
973
+ enforcementErrors.push(`agents: ${enforcement instanceof Error ? enforcement.message : String(enforcement)}`);
974
+ }
975
+ }
918
976
  enforcementError = enforcementErrors.length > 0
919
977
  ? `; entitlement cleanup failed: ${enforcementErrors.join(", ")}`
920
978
  : "";
@@ -158,12 +158,36 @@ export async function getStorage() {
158
158
  return storageInstance;
159
159
  }
160
160
  export async function closeStorage() {
161
- if (storageInstance) {
162
- await storageInstance.close();
161
+ if (!storageInstance)
162
+ return;
163
+ const closing = storageInstance;
164
+ // Clear the slot in `finally`: if close() throws, the previous code left a
165
+ // DEAD instance installed as the singleton, and every later getStorage()
166
+ // handed that broken connection to callers. An empty slot is strictly
167
+ // safer — the next getStorage() re-opens cleanly. The error still
168
+ // propagates, so a caller like restoreFromBackup can abort before swapping
169
+ // the database file.
170
+ try {
171
+ await closing.close();
172
+ }
173
+ finally {
163
174
  storageInstance = null;
164
175
  }
165
176
  }
166
- /** Test-only: inject a pre-initialized storage instance into the singleton slot. */
177
+ /**
178
+ * Test-only: inject a pre-initialized storage instance into the singleton slot.
179
+ *
180
+ * CONTRACT: the CALLER owns the lifecycle of what it injects. This function
181
+ * deliberately does NOT close the instance it replaces — callers pair the
182
+ * injection with their own cleanup (see createTestDb().cleanup), so closing
183
+ * here would double-close a storage the test still owns.
184
+ *
185
+ * Worth knowing when auditing handle leaks: an instance dropped without
186
+ * close() keeps its sqlite lock, and on Windows that lock blocks unlink of
187
+ * the database file until the process exits (libsql close() does not release
188
+ * it either — tursodatabase/libsql-js#228 — so closing is hygiene, not a
189
+ * guarantee).
190
+ */
167
191
  export function _setStorageForTesting(instance) {
168
192
  storageInstance = instance;
169
193
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as nodePath from "node:path";
3
3
  import * as os from "node:os";
4
+ import * as http from "node:http";
4
5
  import { randomUUID } from "node:crypto";
5
6
  import { redactSettings, toMarkdown } from "./commonHelpers.js";
6
7
  import { scanAndRedactPHI } from "../utils/phiGuard.js";
@@ -24,11 +25,12 @@ import { buildVaultDirectory } from "../utils/vaultExporter.js";
24
25
  * ═══════════════════════════════════════════════════════════════════
25
26
  */
26
27
  import { debugLog } from "../utils/logger.js";
28
+ import { FREE_ENTITLEMENTS } from "../utils/entitlements.js";
27
29
  import { getStorage, activeStorageBackend } from "../storage/index.js";
28
30
  import { toKeywordArray } from "../utils/keywordExtractor.js";
29
31
  import { getLLMProvider } from "../utils/llm/factory.js";
30
32
  import { getCurrentGitState, getGitDrift } from "../utils/git.js";
31
- import { getSetting, getAllSettings, refreshConfigStorageCache } from "../storage/configStorage.js";
33
+ import { getSetting, setSetting, getAllSettings, refreshConfigStorageCache } from "../storage/configStorage.js";
32
34
  import { mergeHandoff, dbToHandoffSchema, sanitizeForMerge } from "../utils/crdtMerge.js";
33
35
  import { resolveProject } from "../utils/projectResolver.js";
34
36
  import { isRecoverableStartupStorageError, LOCAL_STARTUP_FALLBACK_NOTICE, } from "../utils/startupRecovery.js";
@@ -300,7 +302,8 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
300
302
  `> - 🛠️ **Other tier entitlements:** ${formatBoundedSkillNames(otherTierSkills, "entitled")}\n` +
301
303
  `> - 🧠 **Context depth:** ${depth}\n` +
302
304
  `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · native materialization incomplete${conflictSuffix}` +
303
- conflictWarning;
305
+ conflictWarning +
306
+ freeTierUpgradeLine(snapshot.tier);
304
307
  }
305
308
  if (snapshot.source === "tier-fallback") {
306
309
  return `> **Prism System Ready**\n>\n` +
@@ -308,7 +311,8 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
308
311
  `> - 🛡️ **Fallback skill names:** ${formatBoundedSkillNames(snapshot.names, "fallback")}\n` +
309
312
  `> - 🧠 **Context depth:** ${depth}\n` +
310
313
  `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · no committed manifest${conflictSuffix}` +
311
- conflictWarning;
314
+ conflictWarning +
315
+ freeTierUpgradeLine(snapshot.tier);
312
316
  }
313
317
  return `> **Prism System Ready**\n>\n` +
314
318
  `> - 🪪 **Subscription tier:** ${snapshot.tier}\n` +
@@ -318,7 +322,62 @@ async function buildNativeSystemReadyBlock(snapshot, depth) {
318
322
  `> - 🛠️ **Other tier skills provisioned:** ${formatBoundedSkillNames(otherTierSkills, "provisioned")}\n` +
319
323
  `> - 🧠 **Context depth:** ${depth}\n` +
320
324
  `> - 🔄 **Skill sync:** ${SKILL_SYNC_STATUS_LABELS[snapshot.syncStatus]} · committed manifest${conflictSuffix}` +
321
- conflictWarning;
325
+ conflictWarning +
326
+ freeTierUpgradeLine(snapshot.tier);
327
+ }
328
+ /**
329
+ * The paid funnel's one startup line. Before 2026-08-05 the upgrade_url was
330
+ * surfaced only AFTER a user hit an entitlement gate (sessionDriftHandler,
331
+ * queryMemoryNaturalHandler); the startup path — the only guaranteed
332
+ * impression — referenced it zero times.
333
+ */
334
+ function freeTierUpgradeLine(tier) {
335
+ if (tier !== "free")
336
+ return "";
337
+ return `\n> - 💎 **Free tier:** paid plans unlock the full skill library, ` +
338
+ `super-skills, and agent routing → ${FREE_ENTITLEMENTS.upgrade_url}`;
339
+ }
340
+ /**
341
+ * Dashboard URL for startup output. The bound port is announced on stderr
342
+ * only (MCP stdio owns stdout), so users never saw it; the dashboard also
343
+ * writes the port to ~/.prism-mcp/dashboard.port — read that, then the env
344
+ * override, then the default.
345
+ */
346
+ async function readDashboardUrl() {
347
+ // Precedence: explicit env override > recorded port file > default. The
348
+ // file is written by whatever dashboard ran last and persists across boots,
349
+ // so it must never outrank configuration the operator set for THIS process.
350
+ let port = (process.env.PRISM_DASHBOARD_PORT || "").trim();
351
+ if (!port) {
352
+ try {
353
+ const recorded = fs.readFileSync(nodePath.join(os.homedir(), ".prism-mcp", "dashboard.port"), "utf8").trim();
354
+ if (/^\d{2,5}$/.test(recorded))
355
+ port = recorded;
356
+ }
357
+ catch {
358
+ // port file absent — dashboard not started yet this boot; default holds
359
+ }
360
+ }
361
+ if (!port)
362
+ port = "3000";
363
+ // The port file persists across boots and is never cleaned up, so it is
364
+ // evidence of a PREVIOUS dashboard, not a running one. Advertising a dead
365
+ // URL as the first-run headline action is worse than omitting it.
366
+ //
367
+ // A TCP connect is NOT sufficient: it proves something is listening, not
368
+ // that it is Prism. The default is 3000 — the single most commonly occupied
369
+ // port on a developer machine — so a bare liveness check would confidently
370
+ // point a first-run user at their own dev server. Hit the dashboard's
371
+ // /api/health instead, so identity is verified rather than assumed.
372
+ const healthy = await new Promise((resolveProbe) => {
373
+ const request = http.get({ host: "127.0.0.1", port: Number(port), path: "/api/health", timeout: 300 }, (response) => {
374
+ response.resume(); // drain so the socket can close
375
+ resolveProbe(response.statusCode === 200);
376
+ });
377
+ request.once("timeout", () => { request.destroy(); resolveProbe(false); });
378
+ request.once("error", () => resolveProbe(false));
379
+ });
380
+ return healthy ? `http://localhost:${port}` : null;
322
381
  }
323
382
  function capNativeStartupText(text, level, requestedMaxChars, suffix = "") {
324
383
  const maxChars = effectiveNativeBudget(level, requestedMaxChars);
@@ -1668,15 +1727,57 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
1668
1727
  const role = sanitizeNativeIdentity(defaultRole) || "global";
1669
1728
  const manifestSnapshot = await resolveNativeSkillManifestSnapshot(skillSyncResult);
1670
1729
  const systemReadyBlock = await buildNativeSystemReadyBlock(manifestSnapshot, depth);
1671
- const greeting = `👋 Welcome back, ${greetingName}. Prism is loading ${depth} context.`;
1730
+ // First run = the dashboard has never been touched: no agent identity AND no
1731
+ // projects. Measured 2026-08-05: a brand-new free-tier install was greeted
1732
+ // with "Welcome back", three "Not loaded" rows, a warning, and three
1733
+ // statements of what it doesn't have — an all-absence payload with no next
1734
+ // step, no dashboard URL (stderr-only), and no path to the paid tier.
1735
+ // First run must mean NEW, not merely unconfigured: a user with saved
1736
+ // sessions who never set a name or projects would otherwise be told "first
1737
+ // run detected" every single session. A durable marker is written after the
1738
+ // first bootstrap, so this is decisive rather than heuristic.
1739
+ const bootstrapSeen = (await getSetting("first_bootstrap_at", "")).trim();
1740
+ const isFirstRun = projects.length === 0 && !configuredGreetingName && !bootstrapSeen;
1741
+ if (!bootstrapSeen) {
1742
+ try {
1743
+ await setSetting("first_bootstrap_at", new Date().toISOString());
1744
+ }
1745
+ catch {
1746
+ // Marker is an optimization; a write failure must never block startup.
1747
+ }
1748
+ }
1749
+ const greeting = isFirstRun
1750
+ ? `👋 Welcome to Prism — first run detected. Let's get you productive in a few minutes.`
1751
+ : `👋 Welcome back, ${greetingName}. Prism is loading ${depth} context.`;
1672
1752
  const identityBlock = `- 🤖 **Agent Identity:** ${escapeNativeMarkdown(compactWithOmissionCount(role, 80))} — ${greetingName}`;
1673
- const startupHeader = `${greeting}\n\n${identityBlock}`;
1753
+ const startupHeader = isFirstRun ? greeting : `${greeting}\n\n${identityBlock}`;
1674
1754
  if (projects.length === 0) {
1755
+ const dashboardUrl = await readDashboardUrl();
1756
+ const dashboardLine = dashboardUrl
1757
+ ? `- 🎛️ **Dashboard:** ${dashboardUrl} — configure projects, identity, and context depth`
1758
+ : `- 🎛️ **Dashboard:** not running — start Prism's dashboard to configure projects, identity, and context depth`;
1759
+ if (isFirstRun) {
1760
+ // Action-first instead of absence-first: every line is a capability or
1761
+ // a next step. The wizard exists and is well-built; route to it.
1762
+ const firstRunText = `${greeting}\n\n` +
1763
+ `- 🚀 **Get started:** run the \`onboarding_wizard\` tool (guided setup, ~3 minutes)\n` +
1764
+ `${dashboardLine}\n` +
1765
+ `- 💾 **Already working?** \`session_save_ledger\` records this session; the next one resumes with full context\n\n` +
1766
+ `${systemReadyBlock}`;
1767
+ return {
1768
+ content: [{
1769
+ type: "text",
1770
+ text: capNativeStartupText(firstRunText, depth),
1771
+ }],
1772
+ isError: false,
1773
+ structuredContent: { conversation_id: conversationId, projects: [], depth, first_run: true },
1774
+ };
1775
+ }
1675
1776
  const unconfiguredState = (depth === "quick" ? "" : `\n- 📝 **Last Session Summary:** Not loaded`) +
1676
1777
  `\n- ✅ **Open TODOs:** Not loaded` +
1677
1778
  `\n- 🔄 **Session Version:** Not loaded`;
1678
1779
  const noProjectsText = `${startupHeader}${unconfiguredState}\n\n` +
1679
- `⚠️ No Auto-Load Projects are configured in the Prism dashboard.\n\n${systemReadyBlock}`;
1780
+ `⚠️ No Auto-Load Projects are configured in the Prism dashboard.\n${dashboardLine}\n\n${systemReadyBlock}`;
1680
1781
  return {
1681
1782
  content: [{
1682
1783
  type: "text",
@@ -1462,8 +1462,8 @@ export const ONBOARDING_WIZARD_TOOL = {
1462
1462
  description: "Interactive setup wizard for new Prism users. Provides a step-by-step " +
1463
1463
  "guided experience to get productive in under 3 minutes.\n\n" +
1464
1464
  "**Actions:**\n" +
1465
- "- `start` — Begin the wizard from step 1\n" +
1466
- "- `next` — Advance to the next step\n" +
1465
+ "- `start` (default when omitted) — Begin the wizard from step 1\n" +
1466
+ "- `next` — Advance past `step` (pass the step number you are on)\n" +
1467
1467
  "- `status` — Check current wizard progress\n" +
1468
1468
  "- `skip` — Skip to completion\n\n" +
1469
1469
  "Each step returns instructions, code snippets, and progress percentage.",
@@ -1473,7 +1473,12 @@ export const ONBOARDING_WIZARD_TOOL = {
1473
1473
  action: {
1474
1474
  type: "string",
1475
1475
  enum: ["start", "next", "status", "skip"],
1476
- description: "Wizard action to perform.",
1476
+ description: "Wizard action to perform. Omitted = start.",
1477
+ },
1478
+ step: {
1479
+ type: "integer",
1480
+ minimum: 0,
1481
+ description: "The step_index from the previous response; used by `next` and `status`.",
1477
1482
  },
1478
1483
  project_name: {
1479
1484
  type: "string",
@@ -1485,16 +1490,21 @@ export const ONBOARDING_WIZARD_TOOL = {
1485
1490
  description: "IDE client for config generation.",
1486
1491
  },
1487
1492
  },
1488
- required: ["action"],
1489
1493
  },
1490
1494
  };
1491
1495
  export function isOnboardingWizardArgs(args) {
1492
1496
  if (typeof args !== "object" || args === null)
1493
1497
  return false;
1494
1498
  const a = args;
1495
- if (typeof a.action !== "string")
1496
- return false;
1497
- if (!["start", "next", "status", "skip"].includes(a.action))
1499
+ // A bare call is the front door for brand-new users (the startup greeting
1500
+ // routes here) — it must work, defaulting to `start`. Measured 2026-08-05:
1501
+ // requiring `action` made the tool the first thing a new user touches AND
1502
+ // the first error they see.
1503
+ if (a.action !== undefined &&
1504
+ (typeof a.action !== "string" || !["start", "next", "status", "skip"].includes(a.action))) {
1505
+ return false;
1506
+ }
1507
+ if (a.step !== undefined && (!Number.isInteger(a.step) || a.step < 0))
1498
1508
  return false;
1499
1509
  if (a.project_name !== undefined && typeof a.project_name !== "string")
1500
1510
  return false;
@@ -11,7 +11,6 @@
11
11
  */
12
12
  import { debugLog } from "../utils/logger.js";
13
13
  import { isOnboardingWizardArgs, isExtractEntitiesArgs, isBackupDatabaseArgs, isConfigureNotificationsArgs, } from "./sessionMemoryDefinitions.js";
14
- // ─── Onboarding Wizard Handler ───────────────────────────────
15
14
  export async function onboardingWizardHandler(args) {
16
15
  if (!isOnboardingWizardArgs(args)) {
17
16
  return {
@@ -19,7 +18,18 @@ export async function onboardingWizardHandler(args) {
19
18
  isError: true,
20
19
  };
21
20
  }
22
- const { step, responses } = args;
21
+ // The schema advertises action-based navigation while this handler
22
+ // historically read `{step, responses}` — fields the validator never
23
+ // passed, so `next`/`status`/`skip` all silently rendered step 1 and a
24
+ // bare call was rejected outright (measured 2026-08-05). Map the
25
+ // advertised contract onto the stateless wizard: the client carries the
26
+ // step number between calls.
27
+ const action = args.action ?? "start";
28
+ const currentStep = typeof args.step === "number" ? args.step : 0;
29
+ const step = action === "start" ? undefined
30
+ : action === "next" ? currentStep + 1
31
+ : action === "status" ? currentStep
32
+ : Number.MAX_SAFE_INTEGER; // skip → past the final step = completion
23
33
  try {
24
34
  const wizard = await import("../onboarding/wizard.js");
25
35
  if (step === undefined || step === null) {
@@ -32,6 +42,7 @@ export async function onboardingWizardHandler(args) {
32
42
  text: JSON.stringify({
33
43
  status: "in_progress",
34
44
  current_step: state.currentStep,
45
+ step_index: 0,
35
46
  total_steps: 8,
36
47
  step: content,
37
48
  summary: wizard.getWizardSummary(state),
@@ -39,12 +50,15 @@ export async function onboardingWizardHandler(args) {
39
50
  }],
40
51
  };
41
52
  }
42
- // Advance to next step
53
+ // Advance to the requested step position. Wizard steps are NAMED
54
+ // ("welcome", "storage", …); the numeric step_index in every response
55
+ // is what clients echo back for `next`/`status`.
43
56
  const state = wizard.createWizardState();
44
- // Advance to the requested step position
45
57
  let currentState = state;
46
- for (let i = 0; i < step; i++) {
58
+ let stepIndex = 0;
59
+ for (let i = 0; i < step && !wizard.isWizardComplete(currentState); i++) {
47
60
  currentState = wizard.advanceWizard(currentState);
61
+ stepIndex += 1;
48
62
  }
49
63
  if (wizard.isWizardComplete(currentState)) {
50
64
  return {
@@ -66,6 +80,7 @@ export async function onboardingWizardHandler(args) {
66
80
  text: JSON.stringify({
67
81
  status: "in_progress",
68
82
  current_step: currentState.currentStep,
83
+ step_index: stepIndex,
69
84
  step: content,
70
85
  summary: wizard.getWizardSummary(currentState),
71
86
  }, null, 2),
@@ -50,6 +50,16 @@ async function ensureTable() {
50
50
  }
51
51
  /** Reset DB connection and in-memory buffer (for tests). */
52
52
  export function _resetDb() {
53
+ // Close before dropping the reference. Nulling alone leaks the underlying
54
+ // sqlite handle: harmless on POSIX (unlink works on open files), but on
55
+ // Windows the file stays locked, so a suite that resets between cases
56
+ // accumulates locks on the analytics DB and cannot clean up its temp dir.
57
+ try {
58
+ _db?.close();
59
+ }
60
+ catch {
61
+ // A close failure must never break a test reset.
62
+ }
53
63
  _db = null;
54
64
  _tableReady = false;
55
65
  BUFFER.length = 0;
@@ -165,7 +165,24 @@ export async function restoreFromBackup(dbPath, backupPath) {
165
165
  // Create pre-restore backup
166
166
  const preRestoreResult = await createBackup(dbPath);
167
167
  debugLog(`Pre-restore backup: ${preRestoreResult.success ? "OK" : "FAILED"}`);
168
- // Copy backup over current database
168
+ // Release the live connection BEFORE swapping the file underneath it.
169
+ //
170
+ // NOT a platform workaround. Measured on windows-x64 (2026-08-05):
171
+ // overwriting the database while a connection is open SUCCEEDS on
172
+ // every platform — SQLite shares the file for reading and writing,
173
+ // and only unlink/rename are blocked. So restore was never broken.
174
+ //
175
+ // The reason is correctness: replacing a database file beneath a live
176
+ // connection leaves that connection pointing at bytes it did not
177
+ // read, with a stale page cache and a WAL that no longer describes
178
+ // the file. SQLite documents this as unsafe. closeStorage() makes the
179
+ // swap atomic from the connection's point of view — the next
180
+ // getStorage() opens the restored file cleanly.
181
+ const { closeStorage } = await import("../storage/index.js");
182
+ await closeStorage();
183
+ // Copy backup over current database. closeStorage() cleared the
184
+ // singleton, so the next getStorage() re-opens against the restored
185
+ // file and no caller retains a handle to the replaced one.
169
186
  copyFileSync(backupPath, dbPath);
170
187
  return {
171
188
  success: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.6.0",
3
+ "version": "20.7.1",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Prism Coder \u2014 Cognitive memory + tool-calling intelligence for AI agents. Mind Palace persistent memory (BFCL Gold Certified, 100% Tool-Call Accuracy, 114 Agent Skills, PHI Guard, Tier Enforcement, Prompt-Based Skill Routing, Zero-Search HDC/HRR retrieval, HRR Semantic Drift Detection across BCBA/Coding/AAC domains, HIPAA-hardened local or subscription-gated Synalux storage, SLERP-optimized GRPO alignment) plus the prism-coder 1.7B\u201332B open-weights LLM fleet.",
6
6
  "module": "index.ts",