@pcircle/memesh 4.9.0 → 4.9.4

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.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.de.md +1 -1
  5. package/README.md +2 -2
  6. package/README.zh-TW.md +2 -2
  7. package/dashboard/dist/index.html +10 -10
  8. package/dist/core/doctor-fixes.d.ts +19 -0
  9. package/dist/core/doctor-fixes.d.ts.map +1 -0
  10. package/dist/core/doctor-fixes.js +104 -0
  11. package/dist/core/doctor-fixes.js.map +1 -0
  12. package/dist/core/doctor.d.ts +1 -1
  13. package/dist/core/doctor.d.ts.map +1 -1
  14. package/dist/core/doctor.js +3 -3
  15. package/dist/core/doctor.js.map +1 -1
  16. package/dist/core/operations.d.ts.map +1 -1
  17. package/dist/core/operations.js +2 -2
  18. package/dist/core/operations.js.map +1 -1
  19. package/dist/core/schema-export.js +1 -1
  20. package/dist/core/schema-export.js.map +1 -1
  21. package/dist/knowledge-graph.d.ts.map +1 -1
  22. package/dist/knowledge-graph.js +30 -10
  23. package/dist/knowledge-graph.js.map +1 -1
  24. package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
  25. package/dist/mcp/server.js +45 -16
  26. package/dist/mcp/server.js.map +1 -1
  27. package/dist/skills-manifest.json +17 -12
  28. package/dist/transports/agent-messaging.d.ts +4 -0
  29. package/dist/transports/agent-messaging.d.ts.map +1 -1
  30. package/dist/transports/agent-messaging.js +12 -2
  31. package/dist/transports/agent-messaging.js.map +1 -1
  32. package/dist/transports/cli/cli.d.ts.map +1 -1
  33. package/dist/transports/cli/cli.js +60735 -1770
  34. package/dist/transports/cli/cli.js.map +6 -1
  35. package/dist/transports/http/server.d.ts.map +1 -1
  36. package/dist/transports/http/server.js +32 -1
  37. package/dist/transports/http/server.js.map +1 -1
  38. package/dist/transports/mcp/handlers.d.ts +3 -3
  39. package/dist/transports/mcp/handlers.js +3 -3
  40. package/dist/transports/mcp/handlers.js.map +1 -1
  41. package/docs/platforms/agent-messaging.md +10 -1
  42. package/hooks/hooks.json +1 -1
  43. package/package.json +6 -3
  44. package/scripts/check-plugin-hook-artifact.mjs +212 -0
  45. package/scripts/hooks/_shared.js +150 -1
  46. package/scripts/hooks/session-start.js +90 -4
  47. package/scripts/hooks/session-summary.js +17 -4
  48. package/scripts/hooks/user-prompt-intent.js +71 -11
  49. package/scripts/lib/npm-bin.mjs +123 -0
  50. package/scripts/upgrade-plugin.sh +24 -0
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ /** Verify plugin hooks and host entrypoints before a cache/artifact swap. */
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { npmSync, envWithNpmCache } from './lib/npm-bin.mjs';
8
+
9
+ export const CLAUDE_PLUGIN_ROOT_PREFIX = '${CLAUDE_PLUGIN_ROOT}/';
10
+
11
+ function isSubpath(parent, child) {
12
+ const relative = path.relative(parent, child);
13
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
14
+ }
15
+
16
+ export function hookTargetsFromManifest(manifest) {
17
+ const targets = [];
18
+ for (const [event, entries] of Object.entries(manifest?.hooks ?? {})) {
19
+ if (!Array.isArray(entries)) throw new Error(`${event} is not an array in hooks/hooks.json`);
20
+ for (const entry of entries) {
21
+ for (const hook of entry?.hooks ?? []) {
22
+ if (hook?.type !== 'command') continue;
23
+ if (typeof hook.command !== 'string' || !hook.command.startsWith(CLAUDE_PLUGIN_ROOT_PREFIX)) {
24
+ throw new Error(`${event} command must start with ${CLAUDE_PLUGIN_ROOT_PREFIX}`);
25
+ }
26
+ const relative = hook.command.slice(CLAUDE_PLUGIN_ROOT_PREFIX.length);
27
+ if (!relative || /\s/.test(relative) || path.posix.normalize(relative) !== relative || relative.startsWith('../')) {
28
+ throw new Error(`${event} command is not a single safe plugin-relative path: ${hook.command}`);
29
+ }
30
+ targets.push({ event, relative });
31
+ }
32
+ }
33
+ }
34
+ if (targets.length === 0) throw new Error('hooks/hooks.json declares no command hooks');
35
+ return targets;
36
+ }
37
+
38
+ export function readHookManifest(root) {
39
+ return JSON.parse(fs.readFileSync(path.join(root, 'hooks', 'hooks.json'), 'utf8'));
40
+ }
41
+
42
+ export function validateHookTargets(root, manifest = readHookManifest(root)) {
43
+ const rootPath = path.resolve(root);
44
+ const targets = hookTargetsFromManifest(manifest);
45
+ const missing = [];
46
+ for (const target of targets) {
47
+ const resolved = path.resolve(rootPath, target.relative);
48
+ try {
49
+ if (isContainedRegularFile(rootPath, resolved)) continue;
50
+ } catch {
51
+ // Report the same bounded finding for missing and unreadable targets.
52
+ }
53
+ missing.push({ ...target, resolved });
54
+ }
55
+ return { targets, missing, ok: missing.length === 0 };
56
+ }
57
+
58
+ /**
59
+ * True only for a regular file whose every path component lives inside root.
60
+ * `lstat` rejects a symlink as the last component; `realpathSync` on both
61
+ * sides rejects a symlinked directory ABOVE it — `scripts/hooks -> /elsewhere`
62
+ * passed the lstat-only check and would have let a staged cache execute
63
+ * ambient bytes. Throws on a missing path, like the fs calls it wraps.
64
+ */
65
+ function isContainedRegularFile(rootPath, resolved) {
66
+ if (!isSubpath(rootPath, resolved) || !fs.lstatSync(resolved).isFile()) return false;
67
+ return isSubpath(fs.realpathSync(rootPath), fs.realpathSync(resolved));
68
+ }
69
+
70
+ function safeRelativePath(value, label) {
71
+ if (typeof value !== 'string' || !value.startsWith('./')) {
72
+ throw new Error(`${label} must be a ./ relative path`);
73
+ }
74
+ const relative = value.slice(2);
75
+ if (!relative || path.posix.normalize(relative) !== relative || relative.startsWith('../') || path.isAbsolute(relative)) {
76
+ throw new Error(`${label} is not a safe plugin-relative path: ${value}`);
77
+ }
78
+ return relative;
79
+ }
80
+
81
+ function regularFile(root, relative, label) {
82
+ const rootPath = path.resolve(root);
83
+ const resolved = path.resolve(rootPath, relative);
84
+ if (!isSubpath(rootPath, resolved)) throw new Error(`${label} escapes plugin root: ${relative}`);
85
+ try {
86
+ if (isContainedRegularFile(rootPath, resolved)) return relative;
87
+ } catch {
88
+ // Fall through to one bounded diagnostic.
89
+ }
90
+ throw new Error(`${label} is missing or not a regular file: ${relative}`);
91
+ }
92
+
93
+ export function validatePluginEntrypoints(root) {
94
+ const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
95
+ const targets = [];
96
+ for (const [relativeManifest, expectedPrefix] of [
97
+ ['.claude-plugin/plugin.json', '${CLAUDE_PLUGIN_ROOT}/'],
98
+ ['.codex-plugin/plugin.json', './'],
99
+ ]) {
100
+ regularFile(root, relativeManifest, relativeManifest);
101
+ const plugin = JSON.parse(fs.readFileSync(path.join(root, relativeManifest), 'utf8'));
102
+ if (plugin.name !== 'memesh') throw new Error(`${relativeManifest} has unexpected plugin name`);
103
+ if (plugin.version !== packageJson.version) throw new Error(`${relativeManifest} version does not match package.json`);
104
+ const mcpManifest = safeRelativePath(plugin.mcpServers, `${relativeManifest} mcpServers`);
105
+ regularFile(root, mcpManifest, `${relativeManifest} mcpServers`);
106
+ targets.push({ kind: 'manifest', relative: relativeManifest });
107
+ targets.push({ kind: 'manifest', relative: mcpManifest });
108
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, mcpManifest), 'utf8'));
109
+ const entry = manifest.mcpServers?.memesh;
110
+ if (!entry || entry.command !== 'node' || !Array.isArray(entry.args) || typeof entry.args[0] !== 'string') {
111
+ throw new Error(`${mcpManifest} has no valid mcpServers.memesh node entry`);
112
+ }
113
+ const rawTarget = entry.args[0];
114
+ if (!rawTarget.startsWith(expectedPrefix)) {
115
+ throw new Error(`${mcpManifest} entry must start with ${expectedPrefix}`);
116
+ }
117
+ const target = rawTarget.slice(expectedPrefix.length);
118
+ if (path.posix.normalize(target) !== target || target.startsWith('../') || path.isAbsolute(target)) {
119
+ throw new Error(`${mcpManifest} entry is not a safe plugin-relative path: ${rawTarget}`);
120
+ }
121
+ regularFile(root, target, `${mcpManifest} entrypoint`);
122
+ targets.push({ kind: 'entrypoint', relative: target });
123
+ }
124
+ return { targets };
125
+ }
126
+
127
+ export function validateArtifactPaths(targets, files) {
128
+ const shipped = new Set(files.map((file) => typeof file === 'string' ? file : file.path));
129
+ const missing = targets.filter((target) => !shipped.has(target.relative));
130
+ return { missing, ok: missing.length === 0 };
131
+ }
132
+
133
+ function packFiles(root) {
134
+ const npmCache = fs.mkdtempSync(path.join(os.tmpdir(), 'memesh-pack-cache-'));
135
+ try {
136
+ const stdout = npmSync(['pack', '--dry-run', '--json', '--ignore-scripts'], {
137
+ cwd: root,
138
+ encoding: 'utf8',
139
+ env: envWithNpmCache(npmCache),
140
+ });
141
+ let report;
142
+ try { report = JSON.parse(String(stdout)); } catch { throw new Error('npm pack --dry-run did not return valid JSON'); }
143
+ const entry = Array.isArray(report) ? report[0] : report;
144
+ if (!entry || !Array.isArray(entry.files)) throw new Error('npm pack --dry-run returned no file list');
145
+ return entry.files;
146
+ } catch (error) {
147
+ if (error instanceof Error && /npm pack --dry-run (?:did not return|returned no)/.test(error.message)) throw error;
148
+ const record = error && typeof error === 'object' ? /** @type {Record<string, unknown>} */ (error) : {};
149
+ const status = typeof record.status === 'number' ? record.status : 'unknown';
150
+ const stderr = typeof record.stderr === 'string' ? record.stderr.trim() : '';
151
+ throw new Error(
152
+ `npm pack --dry-run failed (exit ${status}): ${stderr || (error instanceof Error ? error.message : String(error))}`,
153
+ { cause: error },
154
+ );
155
+ } finally {
156
+ fs.rmSync(npmCache, { recursive: true, force: true });
157
+ }
158
+ }
159
+
160
+ export function checkPluginHookArtifact(root, { checkPack = true } = {}) {
161
+ const source = validateHookTargets(root);
162
+ const plugin = validatePluginEntrypoints(root);
163
+ const allTargets = [...source.targets, ...plugin.targets];
164
+ if (!checkPack) return { ...source, plugin, artifact: null };
165
+ return { ...source, plugin, artifact: validateArtifactPaths(allTargets, packFiles(root)) };
166
+ }
167
+
168
+ function main(argv) {
169
+ const rootIndex = argv.indexOf('--root');
170
+ if (rootIndex !== -1 && (!argv[rootIndex + 1] || argv[rootIndex + 1].startsWith('--'))) {
171
+ console.error('plugin hook integrity: --root requires a directory argument');
172
+ process.exit(2);
173
+ }
174
+ const root = path.resolve(rootIndex === -1 ? process.cwd() : argv[rootIndex + 1]);
175
+ const checkPack = !argv.includes('--skip-pack');
176
+ try {
177
+ const result = checkPluginHookArtifact(root, { checkPack });
178
+ if (!result.ok) {
179
+ for (const item of result.missing) console.error(`Missing hook target: ${item.event} -> ${item.resolved}`);
180
+ process.exit(1);
181
+ }
182
+ if (result.artifact && !result.artifact.ok) {
183
+ for (const item of result.artifact.missing) console.error(`Target omitted from npm artifact: ${item.event ?? item.kind} -> ${item.relative}`);
184
+ process.exit(1);
185
+ }
186
+ console.log(`plugin artifact integrity: PASS (${result.targets.length} hook targets, ${result.plugin.targets.length} plugin/MCP targets${checkPack ? ', npm artifact checked' : ''})`);
187
+ } catch (error) {
188
+ console.error(`plugin hook integrity: ${error instanceof Error ? error.message : String(error)}`);
189
+ process.exit(1);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Run `main` only when this file is the entrypoint. Both sides go through
195
+ * realpath: Node resolves symlinks in `import.meta.url` but not in argv[1],
196
+ * so `node /var/folders/.../check-plugin-hook-artifact.mjs` (macOS tmp is a
197
+ * symlink) compared unequal, skipped `main`, and exited 0 with no output — a
198
+ * silent PASS from a checker that had checked nothing. Pinned by the tarball
199
+ * test in tests/plugin-hook-artifact.test.ts, which asserts on the output.
200
+ */
201
+ function isEntrypoint(argv1) {
202
+ if (!argv1) return false;
203
+ const self = fileURLToPath(import.meta.url);
204
+ const given = path.resolve(argv1);
205
+ if (self === given) return true;
206
+ // A realpath failure must not become "not the entrypoint": that is exit 0
207
+ // with nothing checked, the fail-closed guard's caller only reads the exit
208
+ // code, and a staged plugin would swap in unverified. Let it throw.
209
+ return fs.realpathSync(self) === fs.realpathSync(given);
210
+ }
211
+
212
+ if (isEntrypoint(process.argv[1])) main(process.argv.slice(2));
@@ -1,4 +1,5 @@
1
- import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'fs';
1
+ import { appendFileSync, chmodSync, closeSync, constants as fsConstants, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
2
+ import { createHash } from 'crypto';
2
3
  import { spawn } from 'child_process';
3
4
  import { MemeshDatabase } from './_generated/sqlite.js';
4
5
  import { dirname, join } from 'path';
@@ -827,6 +828,154 @@ const POLICY_RANK = { off: 0, patch: 1, minor: 2, major: 3 };
827
828
  const BUMP_RANK = { patch: 1, minor: 2, major: 3 };
828
829
  const AUTO_UPDATE_CACHE_FRESHNESS_MS = 24 * 60 * 60 * 1000;
829
830
 
831
+ function autoUpdateConsentPath(sessionId, currentVersion, latestVersion, channel = 'unknown') {
832
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId === 'unknown') return null;
833
+ const key = createHash('sha256')
834
+ .update(`${sessionId}\0${currentVersion}\0${latestVersion}\0${channel}`)
835
+ .digest('hex');
836
+ return join(memeshDir(), 'update-consent', `${key}.json`);
837
+ }
838
+
839
+ function updatePromptClaimPath(sessionId, currentVersion, latestVersion) {
840
+ if (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId === 'unknown') return null;
841
+ const key = createHash('sha256')
842
+ .update(`${sessionId}\0${currentVersion}\0${latestVersion}`)
843
+ .digest('hex');
844
+ return join(memeshDir(), 'update-prompt-claims', `${key}.json`);
845
+ }
846
+
847
+ /**
848
+ * Atomically claim the one first-use update notice for a session/version.
849
+ * Separate host hooks can start at the same time; a read-then-write pending
850
+ * marker lets both print the prompt. O_EXCL makes the claim the decision.
851
+ */
852
+ export function claimUpdatePrompt(sessionId, currentVersion, latestVersion, channel) {
853
+ const path = updatePromptClaimPath(sessionId, currentVersion, latestVersion);
854
+ if (!path || typeof channel !== 'string' || channel.length === 0) return false;
855
+ try {
856
+ ensurePrivateDir(join(memeshDir(), 'update-prompt-claims'));
857
+ // A crash can leave a pending claim before the hook emits its output.
858
+ // Reclaim only when that owner process is definitely gone; emitted
859
+ // claims remain session-scoped and continue suppressing duplicates.
860
+ try {
861
+ const existing = JSON.parse(readFileSync(path, 'utf8'));
862
+ const ownerPid = Number(existing?.ownerPid);
863
+ if (existing?.decision === 'pending' && Number.isInteger(ownerPid) && ownerPid > 0) {
864
+ try {
865
+ process.kill(ownerPid, 0);
866
+ } catch (err) {
867
+ if (err?.code === 'ESRCH') unlinkSync(path);
868
+ }
869
+ }
870
+ } catch {
871
+ // A corrupt claim fails closed at the O_EXCL step below.
872
+ }
873
+ const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
874
+ try {
875
+ writeFileSync(fd, JSON.stringify({
876
+ sessionId,
877
+ currentVersion,
878
+ latestVersion,
879
+ channel,
880
+ decision: 'pending',
881
+ ownerPid: process.pid,
882
+ recordedAt: new Date().toISOString(),
883
+ }));
884
+ } finally {
885
+ closeSync(fd);
886
+ }
887
+ return true;
888
+ } catch {
889
+ return false;
890
+ }
891
+ }
892
+
893
+ export function finalizeUpdatePromptClaim(sessionId, currentVersion, latestVersion) {
894
+ const path = updatePromptClaimPath(sessionId, currentVersion, latestVersion);
895
+ if (!path || !existsSync(path)) return false;
896
+ try {
897
+ const value = JSON.parse(readFileSync(path, 'utf8'));
898
+ if (value?.decision !== 'pending' || Number(value.ownerPid) !== process.pid) return false;
899
+ writePrivateJson(path, { ...value, decision: 'emitted', emittedAt: new Date().toISOString() });
900
+ return true;
901
+ } catch {
902
+ return false;
903
+ }
904
+ }
905
+
906
+ export function readUpdatePromptClaim(sessionId, currentVersion, latestVersion) {
907
+ const path = updatePromptClaimPath(sessionId, currentVersion, latestVersion);
908
+ if (!path || !existsSync(path)) return null;
909
+ try {
910
+ const value = JSON.parse(readFileSync(path, 'utf8'));
911
+ return value && typeof value === 'object' ? value : null;
912
+ } catch {
913
+ return null;
914
+ }
915
+ }
916
+
917
+ export function readAutoUpdateConsent(sessionId, currentVersion, latestVersion, channel = 'unknown') {
918
+ const path = autoUpdateConsentPath(sessionId, currentVersion, latestVersion, channel);
919
+ if (!path || !existsSync(path)) return null;
920
+ try {
921
+ const value = JSON.parse(readFileSync(path, 'utf8'));
922
+ return value && typeof value === 'object' ? value : null;
923
+ } catch {
924
+ return null;
925
+ }
926
+ }
927
+
928
+ /**
929
+ * @param {string} sessionId
930
+ * @param {string} currentVersion
931
+ * @param {string} latestVersion
932
+ * @param {string|null} [channel=null]
933
+ */
934
+ export function findAutoUpdateConsent(sessionId, currentVersion, latestVersion, channel = null) {
935
+ if (typeof sessionId !== 'string' || !sessionId || sessionId === 'unknown') return null;
936
+ try {
937
+ const dir = join(memeshDir(), 'update-consent');
938
+ for (const file of readdirSync(dir)) {
939
+ if (!file.endsWith('.json')) continue;
940
+ try {
941
+ const value = JSON.parse(readFileSync(join(dir, file), 'utf8'));
942
+ if (value?.sessionId === sessionId
943
+ && value?.currentVersion === currentVersion
944
+ && value?.latestVersion === latestVersion
945
+ && (channel === null || value?.channel === channel)) return value;
946
+ } catch { /* ignore one corrupt marker */ }
947
+ }
948
+ } catch { /* missing/unreadable consent dir */ }
949
+ return null;
950
+ }
951
+
952
+ export function writeAutoUpdateConsent(sessionId, currentVersion, latestVersion, channel, decision) {
953
+ const path = autoUpdateConsentPath(sessionId, currentVersion, latestVersion, channel);
954
+ if (!path || !['pending', 'approved', 'declined'].includes(decision)) return false;
955
+ try {
956
+ ensurePrivateDir(join(memeshDir(), 'update-consent'));
957
+ writePrivateJson(path, {
958
+ sessionId,
959
+ currentVersion,
960
+ latestVersion,
961
+ channel,
962
+ decision,
963
+ recordedAt: new Date().toISOString(),
964
+ });
965
+ return true;
966
+ } catch {
967
+ return false;
968
+ }
969
+ }
970
+
971
+ export function parseAutoUpdateConsent(prompt) {
972
+ if (typeof prompt !== 'string') return null;
973
+ const value = prompt.trim().toLowerCase().replace(/[.!?。!?]+$/u, '');
974
+ if (/^(?:yes|y|upgrade|update|install(?: it)?|go ahead|是|好|升級|更新|安裝)$/.test(value)) return 'approved';
975
+ if (/^(?:no|n|not now|later|不要|不用|稍後|暫時不要)$/.test(value)) return 'declined';
976
+ return null;
977
+ }
978
+
830
979
  export function decideAutoUpdateHook(currentVersion, cache, policy) {
831
980
  if (!cache || cache.currentVersion !== currentVersion) return { run: false };
832
981
  const latest = cache.latestVersion;
@@ -26,6 +26,10 @@ import {
26
26
  memeshDir as memeshHomeDir,
27
27
  parseTaskState,
28
28
  readRepoState,
29
+ readAutoUpdateConsent,
30
+ claimUpdatePrompt,
31
+ finalizeUpdatePromptClaim,
32
+ readUpdatePromptClaim,
29
33
  readUpdateCheckCache,
30
34
  repoStateLines,
31
35
  resolvePluginRoot,
@@ -34,6 +38,7 @@ import {
34
38
  homeDir,
35
39
  taskStateName,
36
40
  writeCitationRule,
41
+ writeAutoUpdateConsent,
37
42
  writePrivateJson,
38
43
  } from './_shared.js';
39
44
  import { MemeshDatabase } from './_generated/sqlite.js';
@@ -326,6 +331,43 @@ function detectInstallChannelHook(pluginRoot) {
326
331
  }
327
332
  }
328
333
 
334
+ function buildUpdateConsentPrompt(sessionId, currentVersion, cache, channel) {
335
+ if (!sessionId || sessionId === 'unknown' || !cache || cache.currentVersion !== currentVersion) return null;
336
+ if (!cache.latestVersion || !isStrictlyOlder(currentVersion, cache.latestVersion)) return null;
337
+ const existing = readAutoUpdateConsent(sessionId, currentVersion, cache.latestVersion, channel);
338
+ if (existing?.decision) return null;
339
+ // Claim the session-level notice before emitting it. A resumed hook or a
340
+ // concurrent host process (including a second host channel) sees the
341
+ // atomic claim and does not duplicate the ask.
342
+ if (readUpdatePromptClaim(sessionId, currentVersion, cache.latestVersion)
343
+ || !claimUpdatePrompt(sessionId, currentVersion, cache.latestVersion, channel)) return null;
344
+ const target = channel === 'plugin-marketplace'
345
+ ? 'the installed marketplace plugin'
346
+ : channel === 'npm-global' ? 'the global memesh installation' : 'this MeMesh installation';
347
+ if (channel !== 'npm-global') {
348
+ const pluginRoot = resolvePluginRoot(import.meta.url);
349
+ const action = channel === 'plugin-marketplace'
350
+ ? pluginUpgradeLine(pluginRoot)
351
+ : channel === 'source-checkout'
352
+ ? ' Source checkout: pull and rebuild (`git pull && npm install && npm run build`).'
353
+ : channel === 'npm-local'
354
+ ? ' Project-local install: run `npm install @pcircle/memesh@latest` in the project that installed it.'
355
+ : ' Update it through the tool or package manager that installed MeMesh.';
356
+ return {
357
+ system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. This installation cannot be upgraded automatically from this session.\n${action}`,
358
+ context: `MeMesh ${cache.latestVersion} is available for ${target}, but this channel has no safe in-session installer. Show the user the channel-specific update action and do not claim that an Upgrade reply will install it.`,
359
+ };
360
+ }
361
+ // The npm-global channel is the only hook-owned installer. Record a
362
+ // channel-specific pending consent for the Stop hook after the global
363
+ // session notice has been claimed.
364
+ if (!writeAutoUpdateConsent(sessionId, currentVersion, cache.latestVersion, channel, 'pending')) return null;
365
+ return {
366
+ system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. Reply “Upgrade” to install it, or “Not now” to skip for this session.`,
367
+ context: `MeMesh update consent is pending for this session. Ask the user whether to upgrade from ${currentVersion} to ${cache.latestVersion} for the ${target}. Wait for an explicit Upgrade or Not now response; do not install without affirmative consent.`,
368
+ };
369
+ }
370
+
329
371
  /**
330
372
  * Which plugin runtime owns this copy: 'claude-code', 'codex', or null.
331
373
  *
@@ -503,6 +545,15 @@ function runPostBannerUpdateTasks() {
503
545
  if (__postBannerRan) return;
504
546
  __postBannerRan = true;
505
547
  try {
548
+ // A first-use session has no database yet. Starting the detached
549
+ // `memesh status` refresh in that state makes status create/migrate the
550
+ // database while the next SessionStart may already be opening it
551
+ // read-only. SQLite can expose that window as a partially-created schema
552
+ // (for example, `entities` exists while `tags` does not), turning a
553
+ // harmless update notice into "memories not loaded". The next session
554
+ // after the first capture will refresh the cache once the database is
555
+ // fully established; consent itself was already emitted above.
556
+ if (!existsSync(dbPath)) return;
506
557
  let installedVersion = null;
507
558
  try {
508
559
  const pluginRoot = resolvePluginRoot(import.meta.url);
@@ -679,7 +730,21 @@ process.stdin.on('end', async () => {
679
730
  // With no database there is nothing to recall either — the warning IS
680
731
  // the whole truth, and "memories will be created as you work" would
681
732
  // contradict it one line later.
682
- output(combineWithBanner(captureWarning ?? '◉ MeMesh ready · no database yet, memories will be created as you work'));
733
+ const emptySummary = combineWithBanner(captureWarning ?? '◉ MeMesh ready · no database yet, memories will be created as you work');
734
+ let consent = null;
735
+ let consentVersion = null;
736
+ let consentCache = null;
737
+ try {
738
+ const pluginRoot = resolvePluginRoot(import.meta.url);
739
+ const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
740
+ consentVersion = typeof pkg.version === 'string' ? pkg.version : null;
741
+ consentCache = readUpdateCheckCache(consentVersion);
742
+ const channel = detectInstallChannelHook(pluginRoot);
743
+ consent = buildUpdateConsentPrompt(data.session_id, consentVersion, consentCache, channel);
744
+ } catch { /* best-effort */ }
745
+ output(consent ? `${consent.system}\n${emptySummary}` : emptySummary,
746
+ consent ? `${consent.context}\n\n${workPackageGuidance}` : workPackageGuidance);
747
+ if (consent) finalizeUpdatePromptClaim(data.session_id, consentVersion, consentCache?.latestVersion);
683
748
  return;
684
749
  }
685
750
 
@@ -1150,20 +1215,41 @@ process.stdin.on('end', async () => {
1150
1215
  }
1151
1216
  const updateCache = readUpdateCheckCache(installedVersion);
1152
1217
  let bannerLines = [];
1218
+ let updateConsentContext = null;
1153
1219
  if (installedVersion) {
1154
1220
  const deprecation = buildDeprecationBanner(installedVersion, updateCache);
1155
1221
  if (deprecation.length > 0) {
1156
1222
  bannerLines = deprecation;
1157
1223
  } else {
1158
- bannerLines = buildUpdateAvailableBanner(installedVersion, updateCache,
1159
- () => detectInstallChannelHook(resolvePluginRoot(import.meta.url)));
1224
+ const channel = detectInstallChannelHook(resolvePluginRoot(import.meta.url));
1225
+ const consent = buildUpdateConsentPrompt(data.session_id, installedVersion, updateCache, channel);
1226
+ if (consent) {
1227
+ // The first-use notice is the authoritative update message for
1228
+ // this session. Do not append the softer 24h banner as well; a
1229
+ // source/plugin user would otherwise see two contradictory
1230
+ // update instructions in one SessionStart payload.
1231
+ bannerLines = [consent.system];
1232
+ updateConsentContext = consent.context;
1233
+ } else if (readUpdatePromptClaim(data.session_id, installedVersion, updateCache?.latestVersion)) {
1234
+ // A first-use notice was already shown (and may have been
1235
+ // answered by UserPromptSubmit). Suppress the routine banner for
1236
+ // the rest of this session as well.
1237
+ bannerLines = [];
1238
+ } else {
1239
+ bannerLines = buildUpdateAvailableBanner(installedVersion, updateCache, () => channel);
1240
+ }
1160
1241
  }
1161
1242
  }
1162
1243
  const finalMessage = bannerLines.length > 0
1163
1244
  ? [...bannerLines.filter(l => l.length > 0), '', summary].join('\n')
1164
1245
  : summary;
1165
1246
 
1166
- output(withCaptureWarning(finalMessage), memoryContext);
1247
+ output(withCaptureWarning(finalMessage), updateConsentContext
1248
+ ? `${updateConsentContext}\n\n${memoryContext}`
1249
+ : memoryContext);
1250
+ if (updateConsentContext) {
1251
+ finalizeUpdatePromptClaim(data.session_id, installedVersion, updateCache?.latestVersion);
1252
+ }
1167
1253
 
1168
1254
  // Pre-read the noise-compression throttle on the handle we already
1169
1255
  // hold. compressWeeklyNoise() re-checks under its own connection, but
@@ -15,6 +15,7 @@ import {
15
15
  extractCitedMemoryIds,
16
16
  getMemeshDirFromDbPath,
17
17
  getProjectName,
18
+ findAutoUpdateConsent,
18
19
  isAutoCaptureEnabled,
19
20
  openHookDb,
20
21
  readUpdateCheckCache,
@@ -40,7 +41,7 @@ try {
40
41
  // Best-effort: source checkouts may not have built dist output yet.
41
42
  }
42
43
 
43
- async function runAutoUpdateAtStop() {
44
+ async function runAutoUpdateAtStop(sessionId) {
44
45
  try {
45
46
  const pluginRoot = resolvePluginRoot(import.meta.url);
46
47
  const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
@@ -50,7 +51,14 @@ async function runAutoUpdateAtStop() {
50
51
  const cache = readUpdateCheckCache(installedVersion);
51
52
  const policy = resolveAutoUpdatePolicy(process.env);
52
53
  const decision = decideAutoUpdateHook(installedVersion, cache, policy);
53
- if (decision.run) await spawnAutoUpdate(decision.latest, installChannel);
54
+ let channel = 'unknown';
55
+ try { channel = installChannel?.getCurrentInstallChannel({ packageRoot: pluginRoot }) ?? 'unknown'; } catch { /* best-effort */ }
56
+ const consent = decision.run
57
+ ? findAutoUpdateConsent(sessionId, installedVersion, decision.latest, channel)
58
+ : null;
59
+ if (decision.run && consent?.decision === 'approved') {
60
+ await spawnAutoUpdate(decision.latest, installChannel);
61
+ }
54
62
  } catch {
55
63
  // Best-effort: update failures must never break session capture.
56
64
  }
@@ -204,6 +212,7 @@ let input = '';
204
212
  process.stdin.setEncoding('utf8');
205
213
  process.stdin.on('data', (chunk) => { input += chunk; });
206
214
  process.stdin.on('end', async () => {
215
+ let sessionId = 'unknown';
207
216
  try {
208
217
  if (!input.trim()) return exit0();
209
218
 
@@ -224,7 +233,7 @@ process.stdin.on('end', async () => {
224
233
  return exit0();
225
234
  }
226
235
 
227
- const sessionId = inputData.session_id || 'unknown';
236
+ sessionId = inputData.session_id || 'unknown';
228
237
  const transcriptPath = inputData.transcript_path;
229
238
 
230
239
  // `cwd` decides the project tag, and the project tag decides which
@@ -337,6 +346,10 @@ process.stdin.on('end', async () => {
337
346
  ).get(`session-${sessionId}-files`, `session-${sessionId}-fixes`, `session-${sessionId}-summary`);
338
347
  if (alreadyCaptured) {
339
348
  recordHookRun(db, 'session-summary');
349
+ // A duplicate capture is still a completed Stop lifecycle. Update
350
+ // consent is session-scoped and must not be skipped merely because
351
+ // the same transcript was observed twice (a common host retry).
352
+ await runAutoUpdateAtStop(sessionId);
340
353
  return exit0();
341
354
  }
342
355
 
@@ -587,7 +600,7 @@ process.stdin.on('end', async () => {
587
600
 
588
601
  // Update only after all session work so installed files cannot change while
589
602
  // this hook is still reading them.
590
- await runAutoUpdateAtStop();
603
+ await runAutoUpdateAtStop(sessionId);
591
604
 
592
605
  // Emit NOTHING on success — not `{"suppressOutput": true}`.
593
606
  //