@pcircle/memesh 4.8.1 → 4.8.3

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 (149) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/mcp.json +7 -0
  4. package/.codex-plugin/plugin.json +6 -0
  5. package/AGENTS.md +13 -2
  6. package/README.de.md +11 -10
  7. package/README.md +23 -11
  8. package/README.zh-TW.md +23 -11
  9. package/dashboard/dist/index.html +9 -9
  10. package/dist/core/agent-message-inbox.d.ts +9 -0
  11. package/dist/core/agent-message-inbox.d.ts.map +1 -0
  12. package/dist/core/agent-message-inbox.js +31 -0
  13. package/dist/core/agent-message-inbox.js.map +1 -0
  14. package/dist/core/agent-messaging.d.ts +7 -0
  15. package/dist/core/agent-messaging.d.ts.map +1 -1
  16. package/dist/core/agent-messaging.js +28 -9
  17. package/dist/core/agent-messaging.js.map +1 -1
  18. package/dist/core/agent-router.d.ts +27 -20
  19. package/dist/core/agent-router.d.ts.map +1 -1
  20. package/dist/core/agent-router.js +114 -43
  21. package/dist/core/agent-router.js.map +1 -1
  22. package/dist/core/briefing.d.ts +1 -1
  23. package/dist/core/briefing.d.ts.map +1 -1
  24. package/dist/core/briefing.js +22 -6
  25. package/dist/core/briefing.js.map +1 -1
  26. package/dist/core/citation-rule.d.ts.map +1 -1
  27. package/dist/core/citation-rule.js +5 -2
  28. package/dist/core/citation-rule.js.map +1 -1
  29. package/dist/core/config.d.ts +4 -1
  30. package/dist/core/config.d.ts.map +1 -1
  31. package/dist/core/config.js +12 -3
  32. package/dist/core/config.js.map +1 -1
  33. package/dist/core/doctor.d.ts +8 -0
  34. package/dist/core/doctor.d.ts.map +1 -1
  35. package/dist/core/doctor.js +314 -12
  36. package/dist/core/doctor.js.map +1 -1
  37. package/dist/core/dreamer.d.ts +2 -0
  38. package/dist/core/dreamer.d.ts.map +1 -1
  39. package/dist/core/dreamer.js +7 -1
  40. package/dist/core/dreamer.js.map +1 -1
  41. package/dist/core/install-channel.d.ts +3 -0
  42. package/dist/core/install-channel.d.ts.map +1 -1
  43. package/dist/core/install-channel.js +79 -2
  44. package/dist/core/install-channel.js.map +1 -1
  45. package/dist/core/install-hooks.d.ts.map +1 -1
  46. package/dist/core/install-hooks.js +10 -6
  47. package/dist/core/install-hooks.js.map +1 -1
  48. package/dist/core/lesson-engine.d.ts +2 -1
  49. package/dist/core/lesson-engine.d.ts.map +1 -1
  50. package/dist/core/lesson-engine.js +5 -2
  51. package/dist/core/lesson-engine.js.map +1 -1
  52. package/dist/core/lesson-slug.d.ts +2 -0
  53. package/dist/core/lesson-slug.d.ts.map +1 -0
  54. package/dist/core/lesson-slug.js +14 -0
  55. package/dist/core/lesson-slug.js.map +1 -0
  56. package/dist/core/llm-client.d.ts.map +1 -1
  57. package/dist/core/llm-client.js +4 -1
  58. package/dist/core/llm-client.js.map +1 -1
  59. package/dist/core/llm-validator.d.ts +4 -1
  60. package/dist/core/llm-validator.d.ts.map +1 -1
  61. package/dist/core/llm-validator.js +62 -42
  62. package/dist/core/llm-validator.js.map +1 -1
  63. package/dist/core/ollama-host.d.ts +6 -0
  64. package/dist/core/ollama-host.d.ts.map +1 -0
  65. package/dist/core/ollama-host.js +30 -0
  66. package/dist/core/ollama-host.js.map +1 -0
  67. package/dist/core/operations.d.ts +9 -3
  68. package/dist/core/operations.d.ts.map +1 -1
  69. package/dist/core/operations.js +18 -14
  70. package/dist/core/operations.js.map +1 -1
  71. package/dist/core/paths.d.ts.map +1 -1
  72. package/dist/core/paths.js +2 -3
  73. package/dist/core/paths.js.map +1 -1
  74. package/dist/core/schema-export.d.ts.map +1 -1
  75. package/dist/core/schema-export.js +9 -7
  76. package/dist/core/schema-export.js.map +1 -1
  77. package/dist/core/transcript-extractor.d.ts.map +1 -1
  78. package/dist/core/transcript-extractor.js +2 -2
  79. package/dist/core/transcript-extractor.js.map +1 -1
  80. package/dist/core/work-topology.d.ts +3 -0
  81. package/dist/core/work-topology.d.ts.map +1 -1
  82. package/dist/core/work-topology.js +24 -2
  83. package/dist/core/work-topology.js.map +1 -1
  84. package/dist/db.d.ts.map +1 -1
  85. package/dist/db.js +12 -0
  86. package/dist/db.js.map +1 -1
  87. package/dist/host-adapters/claude-channel.d.ts +0 -58
  88. package/dist/host-adapters/claude-channel.d.ts.map +1 -1
  89. package/dist/host-adapters/claude-channel.js +0 -242
  90. package/dist/host-adapters/claude-channel.js.map +1 -1
  91. package/dist/host-adapters/codex-cli-queue.d.ts.map +1 -1
  92. package/dist/host-adapters/codex-cli-queue.js +15 -12
  93. package/dist/host-adapters/codex-cli-queue.js.map +1 -1
  94. package/dist/host-runtime/acp.d.ts +2 -0
  95. package/dist/host-runtime/acp.d.ts.map +1 -1
  96. package/dist/host-runtime/acp.js +4 -0
  97. package/dist/host-runtime/acp.js.map +1 -1
  98. package/dist/host-runtime/claude.d.ts +2 -0
  99. package/dist/host-runtime/claude.d.ts.map +1 -1
  100. package/dist/host-runtime/claude.js +6 -5
  101. package/dist/host-runtime/claude.js.map +1 -1
  102. package/dist/host-runtime/codex-session.d.ts +3 -1
  103. package/dist/host-runtime/codex-session.d.ts.map +1 -1
  104. package/dist/host-runtime/codex-session.js +7 -5
  105. package/dist/host-runtime/codex-session.js.map +1 -1
  106. package/dist/host-runtime/codex.d.ts +2 -0
  107. package/dist/host-runtime/codex.d.ts.map +1 -1
  108. package/dist/host-runtime/codex.js +4 -0
  109. package/dist/host-runtime/codex.js.map +1 -1
  110. package/dist/host-runtime/router-client.d.ts +2 -0
  111. package/dist/host-runtime/router-client.d.ts.map +1 -1
  112. package/dist/host-runtime/router-client.js.map +1 -1
  113. package/dist/skills-manifest.json +30 -15
  114. package/dist/storage/graph-repairs.d.ts +12 -0
  115. package/dist/storage/graph-repairs.d.ts.map +1 -0
  116. package/dist/storage/graph-repairs.js +242 -0
  117. package/dist/storage/graph-repairs.js.map +1 -0
  118. package/dist/transports/agent-messaging.d.ts +10 -1
  119. package/dist/transports/agent-messaging.d.ts.map +1 -1
  120. package/dist/transports/agent-messaging.js +92 -9
  121. package/dist/transports/agent-messaging.js.map +1 -1
  122. package/dist/transports/cli/cli.d.ts +4 -0
  123. package/dist/transports/cli/cli.d.ts.map +1 -1
  124. package/dist/transports/cli/cli.js +65 -26
  125. package/dist/transports/cli/cli.js.map +1 -1
  126. package/dist/transports/http/server.d.ts.map +1 -1
  127. package/dist/transports/http/server.js +117 -7
  128. package/dist/transports/http/server.js.map +1 -1
  129. package/dist/transports/mcp/handlers.d.ts +10 -6
  130. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  131. package/dist/transports/mcp/handlers.js +21 -9
  132. package/dist/transports/mcp/handlers.js.map +1 -1
  133. package/dist/transports/schemas.d.ts +5 -0
  134. package/dist/transports/schemas.d.ts.map +1 -1
  135. package/dist/transports/schemas.js +8 -1
  136. package/dist/transports/schemas.js.map +1 -1
  137. package/docs/platforms/README.md +1 -1
  138. package/docs/platforms/agent-messaging.md +89 -36
  139. package/llms-install.md +29 -6
  140. package/package.json +17 -3
  141. package/scripts/hooks/_generated/agent-message-inbox.js +38 -0
  142. package/scripts/hooks/_generated/citation-rule.js +5 -2
  143. package/scripts/hooks/_generated/core-paths.js +2 -3
  144. package/scripts/hooks/_generated/work-topology.js +24 -2
  145. package/scripts/hooks/_shared.js +1 -1
  146. package/scripts/hooks/session-start.js +59 -18
  147. package/scripts/hooks/session-summary.js +40 -1
  148. package/scripts/upgrade-plugin.sh +531 -81
  149. package/skills/memesh/SKILL.md +36 -5
@@ -5,14 +5,22 @@
5
5
  # bash scripts/upgrade-plugin.sh
6
6
  #
7
7
  # What it does:
8
- # 1. Fast-forwards the marketplace cache (~/.claude/plugins/marketplaces/pcircle-memesh)
8
+ # 1. Fast-forwards the marketplace cache under
9
+ # ${CLAUDE_CONFIG_DIR:-~/.claude}/plugins/marketplaces/pcircle-memesh
9
10
  # against origin.
10
- # 2. Reads the new version from .claude-plugin/marketplace.json.
11
- # 3. Stages a new install cache at ~/.claude/plugins/cache/pcircle-memesh/memesh/<new-version>/.
11
+ # 2. Reads the new version from .claude-plugin/marketplace.json at the exact
12
+ # marketplace commit that will be installed.
13
+ # 3. Stages a new install cache under the same Claude config root at
14
+ # plugins/cache/pcircle-memesh/memesh/<new-version>/,
15
+ # from `git archive` of the exact recorded commit (never the marketplace
16
+ # checkout's working tree, which can hold uncommitted or tampered files).
12
17
  # 4. Installs runtime deps inside that cache (npm install --omit=dev).
13
- # 5. Patches ~/.claude/plugins/installed_plugins.json to point at the
14
- # new version + path.
15
- # 6. Leaves the previous version on disk (you can delete it manually if you want).
18
+ # 5. Patches plugins/installed_plugins.json under that config root to point at the
19
+ # new version + path, rolling the cache swap back if the write fails,
20
+ # the registry changed underneath it, or a signal interrupts the swap.
21
+ # A same-version refresh (marketplace moved, version did not) is staged next
22
+ # to the live cache and swapped in only after npm install succeeded.
23
+ # The whole run holds a lock directory so two invocations cannot race.
16
24
  #
17
25
  # Why this exists:
18
26
  # Claude Code's plugin marketplace pins versions at install time and
@@ -22,9 +30,19 @@
22
30
 
23
31
  set -uo pipefail
24
32
 
25
- MARKETPLACE_DIR="$HOME/.claude/plugins/marketplaces/pcircle-memesh"
26
- INSTALL_REGISTRY="$HOME/.claude/plugins/installed_plugins.json"
27
- CACHE_ROOT="$HOME/.claude/plugins/cache/pcircle-memesh/memesh"
33
+ if [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
34
+ CLAUDE_CONFIG_ROOT="$CLAUDE_CONFIG_DIR"
35
+ elif [ -n "${HOME:-}" ]; then
36
+ CLAUDE_CONFIG_ROOT="$HOME/.claude"
37
+ else
38
+ echo "ERROR: neither CLAUDE_CONFIG_DIR nor HOME is set, so the Claude Code config root cannot be resolved safely." >&2
39
+ echo " Set CLAUDE_CONFIG_DIR to the active Claude Code config directory, then retry." >&2
40
+ exit 1
41
+ fi
42
+ MARKETPLACE_DIR="$CLAUDE_CONFIG_ROOT/plugins/marketplaces/pcircle-memesh"
43
+ INSTALL_REGISTRY="$CLAUDE_CONFIG_ROOT/plugins/installed_plugins.json"
44
+ CACHE_ROOT="$CLAUDE_CONFIG_ROOT/plugins/cache/pcircle-memesh/memesh"
45
+ LOCK_DIR="$CACHE_ROOT.lock"
28
46
 
29
47
  # ─── Pre-flight ────────────────────────────────────────────────────────────
30
48
  if [ ! -d "$MARKETPLACE_DIR" ]; then
@@ -32,6 +50,11 @@ if [ ! -d "$MARKETPLACE_DIR" ]; then
32
50
  echo " Install the plugin from the Claude Code /plugin UI first." >&2
33
51
  exit 1
34
52
  fi
53
+ if [ -L "$INSTALL_REGISTRY" ]; then
54
+ echo "ERROR: installed_plugins.json is a symlink at $INSTALL_REGISTRY — refusing to replace a host-owned link." >&2
55
+ echo " Restore a regular Claude Code registry file, then retry." >&2
56
+ exit 1
57
+ fi
35
58
  if [ ! -f "$INSTALL_REGISTRY" ]; then
36
59
  echo "ERROR: installed_plugins.json not found at $INSTALL_REGISTRY" >&2
37
60
  echo " Install the plugin from the Claude Code /plugin UI first." >&2
@@ -45,134 +68,561 @@ if ! command -v npm >/dev/null 2>&1; then
45
68
  echo "ERROR: npm is not on PATH (needed to install runtime deps)." >&2
46
69
  exit 1
47
70
  fi
48
- if ! command -v rsync >/dev/null 2>&1; then
49
- echo "ERROR: rsync is not on PATH." >&2
71
+ if ! command -v git >/dev/null 2>&1; then
72
+ echo "ERROR: git is not on PATH (needed to fetch and archive the marketplace commit)." >&2
73
+ exit 1
74
+ fi
75
+ if ! command -v tar >/dev/null 2>&1; then
76
+ echo "ERROR: tar is not on PATH (needed to unpack the exact marketplace commit)." >&2
77
+ exit 1
78
+ fi
79
+
80
+ # ─── Lock: one upgrade at a time ───────────────────────────────────────────
81
+ # `mkdir` is atomic on POSIX. Refuse immediately instead of waiting on an
82
+ # ownerless directory whose process identity cannot be proven.
83
+ STAGE_PATH=""
84
+ PREVIOUS_PATH=""
85
+ NEW_INSTALL_PATH=""
86
+ HAD_LIVE_CACHE=0
87
+ SWAP_PENDING=0
88
+ LOCK_HELD=0
89
+ cleanup_on_exit() {
90
+ exit_status=$?
91
+ trap - EXIT INT TERM HUP
92
+ if [ "$SWAP_PENDING" = 1 ]; then
93
+ should_rollback=0
94
+ if [ -e "$PREVIOUS_PATH" ] || [ -L "$PREVIOUS_PATH" ]; then
95
+ should_rollback=1
96
+ elif [ "$HAD_LIVE_CACHE" = 0 ] && { [ -e "$NEW_INSTALL_PATH" ] || [ -L "$NEW_INSTALL_PATH" ]; }; then
97
+ should_rollback=1
98
+ fi
99
+ if [ "$should_rollback" = 1 ]; then
100
+ echo "ERROR: upgrade interrupted during the cache swap — restoring the previous state." >&2
101
+ SWAP_PENDING=0
102
+ if ! rollback_swap; then
103
+ echo "ERROR: automatic rollback failed; follow the recovery instructions above." >&2
104
+ fi
105
+ fi
106
+ fi
107
+ [ -n "$STAGE_PATH" ] && rm -rf "$STAGE_PATH"
108
+ [ "$LOCK_HELD" = 1 ] && rmdir "$LOCK_DIR" 2>/dev/null
109
+ exit "$exit_status"
110
+ }
111
+ handle_signal() { exit "$1"; }
112
+ install_signal_traps() {
113
+ trap 'handle_signal 130' INT
114
+ trap 'handle_signal 143' TERM
115
+ trap 'handle_signal 129' HUP
116
+ }
117
+ trap cleanup_on_exit EXIT
118
+ install_signal_traps
119
+
120
+ if ! mkdir "$LOCK_DIR" 2>/dev/null; then
121
+ if [ -d "$LOCK_DIR" ]; then
122
+ echo "ERROR: could not acquire the upgrade lock at $LOCK_DIR — another upgrade may be running." >&2
123
+ echo " If nothing else is actually running (a previous run crashed and left the lock behind): rmdir \"$LOCK_DIR\"" >&2
124
+ else
125
+ echo "ERROR: could not create the upgrade lock at $LOCK_DIR." >&2
126
+ echo " Its parent must exist and be writable: ${LOCK_DIR%/*}" >&2
127
+ fi
50
128
  exit 1
51
129
  fi
130
+ LOCK_HELD=1
52
131
 
53
132
  # ─── 1. Refresh marketplace cache ─────────────────────────────────────────
54
133
  echo "==> Fetching latest from marketplace origin..."
55
134
  (
56
135
  cd "$MARKETPLACE_DIR" || exit 1
57
- CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)"
58
- git fetch origin "$CURRENT_BRANCH" --quiet || {
136
+ CURRENT_BRANCH="$(git symbolic-ref --quiet --short HEAD 2>/dev/null)" || {
137
+ echo "ERROR: marketplace cache is on a detached HEAD — refusing to guess which branch to update." >&2
138
+ exit 1
139
+ }
140
+ git fetch --quiet origin "refs/heads/$CURRENT_BRANCH:refs/remotes/origin/$CURRENT_BRANCH" || {
59
141
  echo "ERROR: git fetch failed in $MARKETPLACE_DIR" >&2
60
142
  exit 1
61
143
  }
62
- git merge --ff-only "origin/$CURRENT_BRANCH" --quiet || {
63
- echo "ERROR: marketplace cache has local commits refusing fast-forward." >&2
64
- echo " Reset it: cd $MARKETPLACE_DIR && git reset --hard origin/$CURRENT_BRANCH" >&2
144
+ MERGE_ERROR="$(git merge --ff-only "origin/$CURRENT_BRANCH" --quiet 2>&1)" || {
145
+ AHEAD_COUNT="$(git rev-list --count "origin/$CURRENT_BRANCH..HEAD" 2>/dev/null || echo unknown)"
146
+ if [[ "$AHEAD_COUNT" =~ ^[0-9]+$ ]] && [ "$AHEAD_COUNT" -gt 0 ]; then
147
+ echo "ERROR: marketplace cache has $AHEAD_COUNT local commit(s), so it cannot fast-forward." >&2
148
+ echo " Reconcile those commits with origin/$CURRENT_BRANCH, then retry." >&2
149
+ else
150
+ echo "ERROR: marketplace fast-forward failed; the checkout may be dirty or its history may not match origin/$CURRENT_BRANCH." >&2
151
+ [ -n "$MERGE_ERROR" ] && echo " $MERGE_ERROR" >&2
152
+ echo " Inspect it without discarding work: git -C \"$MARKETPLACE_DIR\" status --short" >&2
153
+ fi
65
154
  exit 1
66
155
  }
67
156
  ) || exit 1
68
157
 
69
- # ─── 2. Read new version ──────────────────────────────────────────────────
70
- # Pass paths via env vars so bash variables never become JS string literals
71
- # quote-safety + injection-safety in one move. Same pattern as section 5.
72
- NEW_VERSION="$(MARKETPLACE_DIR="$MARKETPLACE_DIR" node -e "
158
+ # ─── 2. Bind the exact marketplace commit and read its version ────────────
159
+ MARKETPLACE_SHA="$(git -C "$MARKETPLACE_DIR" rev-parse HEAD 2>/dev/null)" || {
160
+ echo "ERROR: could not read the marketplace checkout's commit ($MARKETPLACE_DIR)" >&2
161
+ exit 1
162
+ }
163
+ if ! [[ "$MARKETPLACE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
164
+ echo "ERROR: marketplace HEAD is not a full 40-hex commit: $MARKETPLACE_SHA" >&2
165
+ exit 1
166
+ fi
167
+
168
+ # Read from the same immutable commit that section 3 archives. A dirty tracked
169
+ # marketplace.json must never redirect committed bytes into a different
170
+ # version directory or produce a version/commit pair that never coexisted.
171
+ NEW_VERSION="$(
172
+ git -C "$MARKETPLACE_DIR" show "$MARKETPLACE_SHA:.claude-plugin/marketplace.json" |
173
+ node -e "
73
174
  const fs = require('fs');
74
- const p = process.env.MARKETPLACE_DIR + '/.claude-plugin/marketplace.json';
75
- const j = JSON.parse(fs.readFileSync(p, 'utf8'));
175
+ const j = JSON.parse(fs.readFileSync(0, 'utf8'));
76
176
  const e = (j.plugins || []).find(x => x && x.name === 'memesh');
77
177
  if (!e || typeof e.version !== 'string') { process.exit(2); }
178
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(e.version);
179
+ const prerelease = match && match[4] ? match[4].split('.') : [];
180
+ if (!match || prerelease.some(x => /^\d+$/.test(x) && x.length > 1 && x.startsWith('0'))) {
181
+ console.error('ERROR: marketplace.json reported an unsafe version string: ' + e.version);
182
+ process.exit(3);
183
+ }
78
184
  process.stdout.write(e.version);
79
- ")" || {
80
- echo "ERROR: could not read memesh version from marketplace.json" >&2
185
+ "
186
+ )" || {
187
+ echo "ERROR: could not read memesh version from marketplace.json at commit $MARKETPLACE_SHA" >&2
81
188
  exit 1
82
189
  }
83
190
 
84
- if ! [[ "$NEW_VERSION" =~ ^[0-9A-Za-z.+-]+$ ]]; then
85
- echo "ERROR: marketplace.json reported an unsafe version string: $NEW_VERSION" >&2
86
- exit 1
87
- fi
88
-
89
191
  echo "==> Target version: $NEW_VERSION"
192
+ NEW_INSTALL_PATH="$CACHE_ROOT/$NEW_VERSION"
90
193
 
91
- CURRENT_VERSION="$(INSTALL_REGISTRY="$INSTALL_REGISTRY" node -e "
92
- const fs = require('fs');
93
- const j = JSON.parse(fs.readFileSync(process.env.INSTALL_REGISTRY, 'utf8'));
94
- const entries = (j.plugins && j.plugins['memesh@pcircle-memesh']) || [];
194
+ # Which registry entry is THIS install? Claude Code keeps one entry per scope
195
+ # (user / project / local); reading entries[0] on a machine with two of them
196
+ # reports another cache's version and sha, and section 5 would then rewrite
197
+ # the wrong entry. Pick the entry whose installPath sits under this cache
198
+ # root; fall back only to a sole legacy entry with no installPath; refuse when
199
+ # a path points elsewhere or the registry is ambiguous. Resolve authority and
200
+ # capture its index/version/sha and the complete registry digest in this ONE
201
+ # read. The index is used later only after the registry's exact bytes still
202
+ # match this digest, so a concurrent reorder cannot redirect the write.
203
+ ENTRY_SNAPSHOT="$(INSTALL_REGISTRY="$INSTALL_REGISTRY" CACHE_ROOT="$CACHE_ROOT" NEW_VERSION="$NEW_VERSION" node -e "
204
+ const fs = require('fs'), path = require('path'), crypto = require('crypto');
205
+ const registryPath = process.env.INSTALL_REGISTRY;
206
+ let before, fd;
207
+ try {
208
+ before = fs.lstatSync(registryPath, { bigint: true });
209
+ if (!before.isFile() || before.isSymbolicLink()) {
210
+ process.stdout.write('identity-changed');
211
+ process.exit(0);
212
+ }
213
+ fd = fs.openSync(registryPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
214
+ } catch (error) {
215
+ const raceCodes = new Set(['ELOOP', 'ENOENT', 'ENOTDIR']);
216
+ process.stdout.write(raceCodes.has(error && error.code) ? 'identity-changed' : 'unreadable');
217
+ process.exit(0);
218
+ }
219
+ let opened, registryText;
220
+ try {
221
+ opened = fs.fstatSync(fd, { bigint: true });
222
+ registryText = fs.readFileSync(fd, 'utf8');
223
+ } finally {
224
+ fs.closeSync(fd);
225
+ }
226
+ const after = fs.lstatSync(registryPath, { bigint: true });
227
+ const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino;
228
+ if (!before.isFile() || before.isSymbolicLink() || !sameFile(before, opened) || !sameFile(opened, after)) {
229
+ process.stdout.write('identity-changed');
230
+ process.exit(0);
231
+ }
232
+ const registrySha256 = crypto.createHash('sha256').update(registryText).digest('hex');
233
+ const j = JSON.parse(registryText);
234
+ const raw = j && j.plugins ? j.plugins['memesh@pcircle-memesh'] : undefined;
235
+ const entries = raw === undefined ? [] : raw;
236
+ const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value);
237
+ if (!Array.isArray(entries) || entries.some(entry => !isRecord(entry))) {
238
+ process.stdout.write('malformed');
239
+ process.exit(0);
240
+ }
95
241
  if (entries.length === 0) { process.stdout.write('none'); process.exit(0); }
96
- process.stdout.write(entries[0].version || 'unknown');
242
+ const root = path.resolve(process.env.CACHE_ROOT) + path.sep;
243
+ const underRoot = entries
244
+ .map((e, i) => i)
245
+ .filter(i => {
246
+ const e = entries[i];
247
+ return typeof e.installPath === 'string'
248
+ && path.isAbsolute(e.installPath)
249
+ && (path.resolve(e.installPath) + path.sep).startsWith(root);
250
+ });
251
+ // Exactly one entry lives under this cache root: use it. A sole legacy
252
+ // entry with no usable installPath is also identifiable. Anything else — none
253
+ // matching with several entries present, or more than one matching — is
254
+ // ambiguous; guessing which scope to touch is how the wrong one gets
255
+ // rewritten.
256
+ let index;
257
+ if (underRoot.length === 1) index = underRoot[0];
258
+ const sole = entries.length === 1 ? entries[0] : null;
259
+ const soleHasPath = sole && typeof sole.installPath === 'string' && sole.installPath.length > 0;
260
+ if (underRoot.length === 0 && sole && !soleHasPath) index = 0;
261
+ if (underRoot.length === 0 && soleHasPath) { process.stdout.write('outside-root'); process.exit(0); }
262
+ // Two different, accurate refusals: several entries live under this root
263
+ // (which one is THIS install?) vs. none do and there is more than one
264
+ // entry (nothing to disambiguate by at all). Collapsing both into one
265
+ // 'ambiguous' value produced a message that said 'none of them lives
266
+ // under \$CACHE_ROOT' even when the real problem was the opposite —
267
+ // several of them did.
268
+ if (index === undefined) {
269
+ process.stdout.write(underRoot.length > 1 ? 'ambiguous-multiple' : 'ambiguous-none');
270
+ process.exit(0);
271
+ }
272
+ const entry = entries[index];
273
+ const version = typeof entry.version === 'string' && !/[\\r\\n\\t]/.test(entry.version)
274
+ ? entry.version
275
+ : 'unknown';
276
+ const sha = typeof entry.gitCommitSha === 'string' && /^[0-9a-f]{40}$/.test(entry.gitCommitSha)
277
+ ? entry.gitCommitSha
278
+ : 'unknown';
279
+ const expectedPath = path.join(path.resolve(process.env.CACHE_ROOT), process.env.NEW_VERSION);
280
+ const installPathState = typeof entry.installPath === 'string'
281
+ && path.isAbsolute(entry.installPath)
282
+ && path.resolve(entry.installPath) === expectedPath
283
+ ? 'canonical'
284
+ : 'noncanonical';
285
+ process.stdout.write(['selected', index, version, sha, registrySha256, installPathState, String(opened.dev), String(opened.ino)].join('\\t'));
97
286
  ")" || {
98
- # Its sibling twelve lines up has this guard; this read did not, so an
99
- # unreadable or malformed installed_plugins.json made CURRENT_VERSION the
100
- # empty string. That compares unequal to every target, so the script
101
- # reported an upgrade from "" and carried on — on a registry it had just
102
- # failed to parse.
103
- echo "ERROR: could not read the installed memesh version from $INSTALL_REGISTRY" >&2
287
+ echo "ERROR: could not read the installed memesh entries from $INSTALL_REGISTRY" >&2
104
288
  exit 1
105
289
  }
290
+ IFS=$'\t' read -r ENTRY_STATE ENTRY_INDEX CURRENT_VERSION INSTALLED_SHA ORIGINAL_REGISTRY_SHA256 INSTALL_PATH_STATE ORIGINAL_REGISTRY_DEV ORIGINAL_REGISTRY_INO <<< "$ENTRY_SNAPSHOT"
291
+ case "$ENTRY_STATE" in
292
+ identity-changed)
293
+ echo "ERROR: installed_plugins.json changed file identity while this upgrade was reading it — refusing to continue." >&2
294
+ exit 1
295
+ ;;
296
+ unreadable)
297
+ echo "ERROR: installed_plugins.json could not be opened safely at $INSTALL_REGISTRY — refusing to continue." >&2
298
+ exit 1
299
+ ;;
300
+ ambiguous-multiple)
301
+ echo "ERROR: installed_plugins.json lists several memesh entries under $CACHE_ROOT — refusing to guess which one to upgrade." >&2
302
+ exit 1
303
+ ;;
304
+ ambiguous-none)
305
+ echo "ERROR: installed_plugins.json lists several memesh entries and none of them lives under $CACHE_ROOT — refusing to guess which one to upgrade." >&2
306
+ exit 1
307
+ ;;
308
+ outside-root)
309
+ echo "ERROR: the only memesh entry in installed_plugins.json lives outside $CACHE_ROOT — refusing to guess which scope to upgrade." >&2
310
+ exit 1
311
+ ;;
312
+ malformed)
313
+ echo "ERROR: installed_plugins.json has a malformed memesh@pcircle-memesh entry — refusing to guess which scope to upgrade." >&2
314
+ exit 1
315
+ ;;
316
+ none)
317
+ # Refuse before touching the filesystem: section 6 has no entry to update.
318
+ echo "ERROR: installed_plugins.json has no memesh@pcircle-memesh entry — nothing to upgrade in place." >&2
319
+ echo " Reinstall the plugin from the Claude Code /plugin UI." >&2
320
+ exit 1
321
+ ;;
322
+ esac
323
+ if [ "$ENTRY_STATE" != "selected" ] \
324
+ || ! [[ "$ENTRY_INDEX" =~ ^[0-9]+$ ]] \
325
+ || ! [[ "$ORIGINAL_REGISTRY_SHA256" =~ ^[0-9a-f]{64}$ ]] \
326
+ || ! [[ "$ORIGINAL_REGISTRY_DEV" =~ ^[0-9]+$ ]] \
327
+ || ! [[ "$ORIGINAL_REGISTRY_INO" =~ ^[0-9]+$ ]] \
328
+ || { [ "$INSTALL_PATH_STATE" != "canonical" ] && [ "$INSTALL_PATH_STATE" != "noncanonical" ]; }; then
329
+ echo "ERROR: could not select one installed memesh entry from $INSTALL_REGISTRY" >&2
330
+ exit 1
331
+ fi
106
332
 
107
333
  echo "==> Currently installed: $CURRENT_VERSION"
108
334
 
335
+ # Same version is NOT "same code". Claude Code keys the plugin cache by
336
+ # version, and so did this script — which is exactly how a machine that
337
+ # auto-updated between the `release: prepare v4.8.2` commit and the two fix
338
+ # PRs that merged under the same version kept serving a 4.8.2 that lacked
339
+ # them, and was told "Already at 4.8.2 — nothing to do." The registry
340
+ # records the commit the cache was staged from (section 5 below); compare
341
+ # that, not the version string. CURRENT_VERSION and INSTALLED_SHA came from
342
+ # the same authority-bound registry snapshot above.
343
+
109
344
  if [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
110
- echo "==> Already at $NEW_VERSION nothing to do."
111
- exit 0
345
+ if [ "$INSTALLED_SHA" = "$MARKETPLACE_SHA" ] \
346
+ && [ "$INSTALL_PATH_STATE" = "canonical" ] \
347
+ && [ -d "$NEW_INSTALL_PATH" ] \
348
+ && [ ! -L "$NEW_INSTALL_PATH" ]; then
349
+ echo "==> Already at $NEW_VERSION (commit ${MARKETPLACE_SHA:0:8}) — nothing to do."
350
+ exit 0
351
+ fi
352
+ if [ "$INSTALLED_SHA" != "$MARKETPLACE_SHA" ]; then
353
+ echo "==> $NEW_VERSION is installed, but the plugin cache was built from commit ${INSTALLED_SHA:0:8}"
354
+ echo " and the marketplace is at ${MARKETPLACE_SHA:0:8} — refreshing the cache in place."
355
+ elif [ "$INSTALL_PATH_STATE" != "canonical" ]; then
356
+ echo "==> $NEW_VERSION is recorded at a noncanonical cache path — repairing it at $NEW_INSTALL_PATH."
357
+ else
358
+ echo "==> $NEW_VERSION is recorded at $NEW_INSTALL_PATH, but that path is missing or not a real directory — repairing it."
359
+ fi
112
360
  fi
113
361
 
114
- # ─── 3. Stage new install cache ───────────────────────────────────────────
115
- NEW_INSTALL_PATH="$CACHE_ROOT/$NEW_VERSION"
116
- echo "==> Staging $NEW_INSTALL_PATH..."
117
- mkdir -p "$NEW_INSTALL_PATH"
118
- rsync -a --delete \
119
- --exclude 'node_modules' \
120
- --exclude '.git' \
121
- --exclude 'tests' \
122
- --exclude 'benchmarks' \
123
- --exclude 'docs/plans' \
124
- "$MARKETPLACE_DIR/" "$NEW_INSTALL_PATH/" || {
125
- echo "ERROR: rsync failed" >&2
362
+ # ─── 3. Stage into a sibling, never into the live cache ───────────────────
363
+ # A same-version refresh targets the directory Claude Code is running from.
364
+ # Staging straight into it and then a failed npm install would leave new
365
+ # code with old or missing dependencies and the old registry sha — a broken
366
+ # install that the next restart loads. So: build the whole thing next to the
367
+ # live cache, and only swap once every step has succeeded.
368
+ STAGE_PATH="$CACHE_ROOT/.staging-$NEW_VERSION-$$"
369
+ PREVIOUS_PATH="$CACHE_ROOT/.previous-$NEW_VERSION-$$"
370
+
371
+ # Remove the failed candidate before restoring the previous cache. Never move
372
+ # the previous cache while the failed live path still exists: `mv` would nest
373
+ # it below that directory and make the postcondition look successful.
374
+ rollback_swap() {
375
+ # Cache removal + restoration is one short recovery transaction. A second
376
+ # signal after the failed candidate is removed but before the previous cache
377
+ # is restored must not strand the install with only .previous-* left behind.
378
+ trap '' INT TERM HUP
379
+ rm -rf "$NEW_INSTALL_PATH" 2>/dev/null
380
+ if [ -e "$NEW_INSTALL_PATH" ] || [ -L "$NEW_INSTALL_PATH" ]; then
381
+ echo "ERROR: could not remove the broken cache at $NEW_INSTALL_PATH." >&2
382
+ if [ -e "$PREVIOUS_PATH" ] || [ -L "$PREVIOUS_PATH" ]; then
383
+ echo " The previous cache is still intact at $PREVIOUS_PATH; installed_plugins.json remains unchanged." >&2
384
+ echo " Remove the broken cache, then restore the previous cache manually:" >&2
385
+ echo " rm -rf \"$NEW_INSTALL_PATH\"" >&2
386
+ echo " mv \"$PREVIOUS_PATH\" \"$NEW_INSTALL_PATH\"" >&2
387
+ else
388
+ echo " installed_plugins.json was not updated to point at it, so it is orphaned. Remove it manually:" >&2
389
+ echo " rm -rf \"$NEW_INSTALL_PATH\"" >&2
390
+ fi
391
+ return 1
392
+ fi
393
+ if [ -e "$PREVIOUS_PATH" ] || [ -L "$PREVIOUS_PATH" ]; then
394
+ mv "$PREVIOUS_PATH" "$NEW_INSTALL_PATH" 2>/dev/null
395
+ if { [ -e "$NEW_INSTALL_PATH" ] || [ -L "$NEW_INSTALL_PATH" ]; } \
396
+ && [ ! -e "$PREVIOUS_PATH" ] \
397
+ && [ ! -L "$PREVIOUS_PATH" ]; then
398
+ echo "Rollback succeeded: restored the previous cache; installed_plugins.json remains unchanged." >&2
399
+ return 0
400
+ fi
401
+ echo "ERROR: could not restore the previous cache." >&2
402
+ if [ -e "$PREVIOUS_PATH" ] || [ -L "$PREVIOUS_PATH" ]; then
403
+ echo " It is still intact at $PREVIOUS_PATH — move it back manually:" >&2
404
+ echo " mv \"$PREVIOUS_PATH\" \"$NEW_INSTALL_PATH\"" >&2
405
+ else
406
+ echo " It is gone from $PREVIOUS_PATH and $NEW_INSTALL_PATH is also missing. This should not happen — check $CACHE_ROOT by hand." >&2
407
+ fi
408
+ return 1
409
+ fi
410
+ echo "Cleanup succeeded: no failed cache remains; installed_plugins.json remains unchanged." >&2
411
+ return 0
412
+ }
413
+
414
+ echo "==> Staging $NEW_VERSION next to the cache..."
415
+ if ! rm -rf "$STAGE_PATH"; then
416
+ echo "ERROR: could not clear the staging path at $STAGE_PATH — the live cache was not touched." >&2
417
+ exit 1
418
+ fi
419
+ if ! mkdir -p "$STAGE_PATH"; then
420
+ echo "ERROR: could not create the staging directory at $STAGE_PATH — the live cache was not touched." >&2
421
+ exit 1
422
+ fi
423
+ # `git archive`, not `rsync` of the working tree: rsync would copy whatever
424
+ # is sitting in $MARKETPLACE_DIR right now, including files nobody committed
425
+ # — so a machine with a tampered or half-merged marketplace checkout could
426
+ # have those bytes installed while MARKETPLACE_SHA (recorded above, and
427
+ # written into the registry in section 6) truthfully names a clean commit
428
+ # that never contained them. `git archive` extracts exactly the tree at
429
+ # $MARKETPLACE_SHA; nothing untracked or uncommitted can reach the cache.
430
+ # node_modules and .git are never tracked, so they never appear in the
431
+ # archive; tests/benchmarks/docs/plans are tracked (real source) and are
432
+ # removed after extraction to keep the shipped cache the same shape as
433
+ # before.
434
+ git -C "$MARKETPLACE_DIR" archive "$MARKETPLACE_SHA" | tar -x -C "$STAGE_PATH" || {
435
+ echo "ERROR: git archive failed — the live cache at $NEW_INSTALL_PATH was not touched" >&2
126
436
  exit 1
127
437
  }
438
+ if ! rm -rf "$STAGE_PATH/tests" "$STAGE_PATH/benchmarks" "$STAGE_PATH/docs/plans"; then
439
+ echo "ERROR: could not remove development-only files from the staging copy — the live cache was not touched." >&2
440
+ exit 1
441
+ fi
128
442
 
129
- # ─── 4. Install runtime deps ──────────────────────────────────────────────
443
+ # ─── 4. Install runtime deps in the staging copy ──────────────────────────
130
444
  echo "==> Installing runtime deps (this may take a minute)..."
131
445
  (
132
- cd "$NEW_INSTALL_PATH" || exit 1
446
+ cd "$STAGE_PATH" || exit 1
133
447
  npm install --omit=dev --no-audit --no-fund --silent
134
448
  ) || {
135
- echo "ERROR: npm install failed in $NEW_INSTALL_PATH" >&2
449
+ echo "ERROR: npm install failed in the staging copy — the live cache at $NEW_INSTALL_PATH was not touched" >&2
136
450
  exit 1
137
451
  }
138
452
 
139
- # ─── 5. Patch installed_plugins.json ─────────────────────────────────────
453
+ # ─── 5. Swap the staged copy in ───────────────────────────────────────────
454
+ if [ -e "$NEW_INSTALL_PATH" ] || [ -L "$NEW_INSTALL_PATH" ]; then
455
+ HAD_LIVE_CACHE=1
456
+ fi
457
+ SWAP_PENDING=1
458
+ if [ "$HAD_LIVE_CACHE" = 1 ]; then
459
+ mv "$NEW_INSTALL_PATH" "$PREVIOUS_PATH" || {
460
+ SWAP_PENDING=0
461
+ echo "ERROR: could not move the live cache aside — nothing was changed" >&2
462
+ exit 1
463
+ }
464
+ fi
465
+ mv "$STAGE_PATH" "$NEW_INSTALL_PATH" || {
466
+ echo "ERROR: could not move the staged copy into place." >&2
467
+ SWAP_PENDING=0
468
+ rollback_swap
469
+ exit 1
470
+ }
471
+
472
+ # ─── 6. Patch installed_plugins.json (last: the registry names a cache that exists) ──
473
+ # Compare the complete registry snapshot captured during authority selection,
474
+ # then write and rename in one Node process. Re-check the snapshot immediately
475
+ # before rename to detect host writes observed up to that point. Ignore signals
476
+ # only for this short registry critical section;
477
+ # before it, the signal trap restores the cache, and after it the registry and
478
+ # live path already agree.
140
479
  echo "==> Updating installed_plugins.json..."
480
+ trap '' INT TERM HUP
141
481
  NEW_INSTALL_PATH="$NEW_INSTALL_PATH" \
142
482
  NEW_VERSION="$NEW_VERSION" \
143
483
  INSTALL_REGISTRY="$INSTALL_REGISTRY" \
144
- MARKETPLACE_DIR="$MARKETPLACE_DIR" \
484
+ MARKETPLACE_SHA="$MARKETPLACE_SHA" \
485
+ ORIGINAL_REGISTRY_SHA256="$ORIGINAL_REGISTRY_SHA256" \
486
+ ORIGINAL_REGISTRY_DEV="$ORIGINAL_REGISTRY_DEV" \
487
+ ORIGINAL_REGISTRY_INO="$ORIGINAL_REGISTRY_INO" \
488
+ ENTRY_INDEX="$ENTRY_INDEX" \
145
489
  node -e "
146
- const fs = require('fs');
147
- const path = process.env.INSTALL_REGISTRY;
148
- const j = JSON.parse(fs.readFileSync(path, 'utf8'));
149
- if (!j.plugins || !Array.isArray(j.plugins['memesh@pcircle-memesh'])) {
150
- console.error('installed_plugins.json missing memesh@pcircle-memesh entry');
490
+ const fs = require('fs'), path = require('path'), crypto = require('crypto');
491
+ const registryPath = process.env.INSTALL_REGISTRY;
492
+ const sha256 = text => crypto.createHash('sha256').update(text).digest('hex');
493
+ const expectedDev = BigInt(process.env.ORIGINAL_REGISTRY_DEV);
494
+ const expectedIno = BigInt(process.env.ORIGINAL_REGISTRY_INO);
495
+ const assertOriginalFile = stat => {
496
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.dev !== expectedDev || stat.ino !== expectedIno) {
497
+ throw new Error('registry file identity changed since this upgrade started');
498
+ }
499
+ };
500
+ const readOriginalFile = () => {
501
+ const before = fs.lstatSync(registryPath, { bigint: true });
502
+ assertOriginalFile(before);
503
+ const fd = fs.openSync(registryPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
504
+ try {
505
+ const opened = fs.fstatSync(fd, { bigint: true });
506
+ assertOriginalFile(opened);
507
+ const text = fs.readFileSync(fd, 'utf8');
508
+ assertOriginalFile(fs.lstatSync(registryPath, { bigint: true }));
509
+ return { text, mode: Number(opened.mode & 0o777n) };
510
+ } finally {
511
+ fs.closeSync(fd);
512
+ }
513
+ };
514
+ const original = readOriginalFile();
515
+ const registryText = original.text;
516
+ if (sha256(registryText) !== process.env.ORIGINAL_REGISTRY_SHA256) {
517
+ console.error('the complete registry changed since this upgrade started');
518
+ process.exit(2);
519
+ }
520
+ const j = JSON.parse(registryText);
521
+ const registryMode = original.mode;
522
+ const entries = j.plugins && j.plugins['memesh@pcircle-memesh'];
523
+ const idx = Number(process.env.ENTRY_INDEX);
524
+ const entry = Array.isArray(entries) ? entries[idx] : undefined;
525
+ if (!Number.isSafeInteger(idx) || idx < 0 || entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
526
+ console.error('the authority-selected registry entry is no longer structurally valid');
151
527
  process.exit(2);
152
528
  }
153
- // Read the actual commit sha so future doctor calls match the install.
154
- let sha = 'unknown';
155
- try {
156
- sha = require('child_process')
157
- .execFileSync('git', ['-C', process.env.MARKETPLACE_DIR, 'rev-parse', 'HEAD'], { encoding: 'utf8' })
158
- .trim();
159
- } catch {}
160
- const entry = j.plugins['memesh@pcircle-memesh'][0] || {};
161
529
  entry.installPath = process.env.NEW_INSTALL_PATH;
162
530
  entry.version = process.env.NEW_VERSION;
163
531
  entry.lastUpdated = new Date().toISOString();
164
- entry.gitCommitSha = sha;
165
- j.plugins['memesh@pcircle-memesh'][0] = entry;
166
- fs.writeFileSync(path, JSON.stringify(j, null, 4) + '\n');
167
- " || {
168
- echo "ERROR: failed to patch installed_plugins.json" >&2
532
+ // Read once above (MARKETPLACE_SHA); the staleness check compares against it next run.
533
+ entry.gitCommitSha = process.env.MARKETPLACE_SHA;
534
+ entries[idx] = entry;
535
+ const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino;
536
+ let tmpDir, tmpDirIdentity, tmpPath, tmpFd, tmpIdentity;
537
+ const cleanupTemp = () => {
538
+ if (tmpFd !== undefined) {
539
+ try { fs.closeSync(tmpFd); } catch {}
540
+ tmpFd = undefined;
541
+ }
542
+ if (tmpPath && tmpIdentity) {
543
+ try {
544
+ const named = fs.lstatSync(tmpPath, { bigint: true });
545
+ if (named.isFile() && !named.isSymbolicLink() && sameFile(named, tmpIdentity)) {
546
+ fs.unlinkSync(tmpPath);
547
+ }
548
+ } catch {}
549
+ }
550
+ if (tmpDir && tmpDirIdentity) {
551
+ try {
552
+ const named = fs.lstatSync(tmpDir, { bigint: true });
553
+ if (named.isDirectory() && !named.isSymbolicLink() && sameFile(named, tmpDirIdentity)) {
554
+ fs.rmdirSync(tmpDir);
555
+ }
556
+ } catch {}
557
+ }
558
+ };
559
+ const assertTempDir = () => {
560
+ const named = fs.lstatSync(tmpDir, { bigint: true });
561
+ if (!named.isDirectory() || named.isSymbolicLink() || !sameFile(named, tmpDirIdentity)) {
562
+ throw new Error('registry temporary directory identity changed before the atomic write');
563
+ }
564
+ };
565
+ const assertTempFile = stat => {
566
+ if (!stat.isFile() || stat.isSymbolicLink() || !sameFile(stat, tmpIdentity)) {
567
+ throw new Error('registry temporary file identity changed before the atomic write');
568
+ }
569
+ };
570
+ try {
571
+ const registryDir = path.dirname(registryPath);
572
+ tmpDir = fs.mkdtempSync(path.join(registryDir, '.' + path.basename(registryPath) + '.tmp-'));
573
+ tmpDirIdentity = fs.lstatSync(tmpDir, { bigint: true });
574
+ if (!tmpDirIdentity.isDirectory() || tmpDirIdentity.isSymbolicLink()) {
575
+ throw new Error('registry temporary location is not a real directory');
576
+ }
577
+ tmpPath = path.join(tmpDir, 'registry.json');
578
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL
579
+ | (fs.constants.O_NOFOLLOW || 0);
580
+ tmpFd = fs.openSync(tmpPath, flags, registryMode);
581
+ tmpIdentity = fs.fstatSync(tmpFd, { bigint: true });
582
+ assertTempDir();
583
+ assertTempFile(fs.lstatSync(tmpPath, { bigint: true }));
584
+ fs.writeFileSync(tmpFd, JSON.stringify(j, null, 4) + '\n', 'utf8');
585
+ fs.fchmodSync(tmpFd, registryMode);
586
+ fs.fsyncSync(tmpFd);
587
+ assertTempFile(fs.fstatSync(tmpFd, { bigint: true }));
588
+ assertTempFile(fs.lstatSync(tmpPath, { bigint: true }));
589
+ fs.closeSync(tmpFd);
590
+ tmpFd = undefined;
591
+ if (sha256(readOriginalFile().text) !== process.env.ORIGINAL_REGISTRY_SHA256) {
592
+ throw new Error('the complete registry changed immediately before the atomic write');
593
+ }
594
+ assertOriginalFile(fs.lstatSync(registryPath, { bigint: true }));
595
+ assertTempDir();
596
+ assertTempFile(fs.lstatSync(tmpPath, { bigint: true }));
597
+ fs.renameSync(tmpPath, registryPath);
598
+ // rename is the commit point. Cleanup after it must never turn an applied
599
+ // registry update into a reported failure and trigger a cache rollback.
600
+ try { fs.rmdirSync(tmpDir); } catch {}
601
+ } catch (err) {
602
+ cleanupTemp();
603
+ throw err;
604
+ }
605
+ "
606
+ REGISTRY_STATUS=$?
607
+ if [ "$REGISTRY_STATUS" = 0 ]; then
608
+ SWAP_PENDING=0
609
+ fi
610
+ install_signal_traps
611
+ if [ "$REGISTRY_STATUS" != 0 ]; then
612
+ echo "ERROR: failed to update installed_plugins.json." >&2
613
+ SWAP_PENDING=0
614
+ rollback_swap
169
615
  exit 1
170
- }
616
+ fi
617
+ rm -rf "$PREVIOUS_PATH" 2>/dev/null
618
+ if [ -e "$PREVIOUS_PATH" ] || [ -L "$PREVIOUS_PATH" ]; then
619
+ echo "WARNING: upgrade succeeded, but the previous cache could not be removed at $PREVIOUS_PATH." >&2
620
+ echo " Remove it manually when no Claude Code process is using it: rm -rf \"$PREVIOUS_PATH\"" >&2
621
+ fi
171
622
 
172
- # ─── 6. Done ─────────────────────────────────────────────────────────────
623
+ # ─── 7. Done ─────────────────────────────────────────────────────────────
173
624
  echo ""
174
- echo "✓ MeMesh upgraded: $CURRENT_VERSION -> $NEW_VERSION"
625
+ echo "✓ MeMesh upgraded: $CURRENT_VERSION (${INSTALLED_SHA:0:8}) -> $NEW_VERSION (${MARKETPLACE_SHA:0:8})"
175
626
  echo " Install path: $NEW_INSTALL_PATH"
176
627
  echo ""
177
628
  echo "Next step: restart Claude Code so the new MCP server picks up."
178
- echo "Old version still on disk at $CACHE_ROOT/$CURRENT_VERSION (safe to delete once verified)."