Skip to content

Commit 15597cf

Browse files
authored
fix: 🐛 Revert memory MCP to per-session stdio (Cowork regression) (#14)
Reverts the memory MCP from the shared HTTP server (0.10.0) back to a per-session stdio server, restoring memory-vault access in Claude Cowork's sandbox (which cannot reach a localhost HTTP server). Complete but reversible — shared-server code retained, auto-start disabled. - Transport: plugin.json memory MCP → stdio command launcher (mcp-memory.sh) - Restore race-guarded index VACUUM on the stdio launcher (mkdir lock) - Stop shared-server lazy-start (unwire hook + warmup probe); code kept, re-enable documented - Docs repointed to per-session stdio (README, memory-status, setup) - Version → 0.13.0
1 parent a6340a4 commit 15597cf

14 files changed

Lines changed: 395 additions & 269 deletions

.claude-plugin/plugin.json

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
{
22
"name": "workbench-core",
33
"description": "Core infrastructure for Claude Code: persistent agent identity, session lifecycle hooks, operational memory, and meta skills. Provides a customizable framework where users configure the agent name, memory path, and persona files.",
4-
"version": "0.12.0",
4+
"version": "0.13.0",
55
"author": {
66
"name": "Mike Bronner",
77
"url": "https://github.com/mike-bronner"
88
},
99
"repository": "https://github.com/mike-bronner/core",
1010
"mcpServers": {
1111
"memory": {
12-
"type": "http",
13-
"url": "http://127.0.0.1:${WORKBENCH_MEMORY_PORT:-8765}/mcp",
14-
"headers": {
15-
"Authorization": "Bearer ${WORKBENCH_MEMORY_TOKEN}"
16-
}
12+
"command": "bash",
13+
"args": [
14+
"${CLAUDE_PLUGIN_ROOT}/hooks/mcp-memory.sh"
15+
]
1716
}
1817
}
1918
}

README.md

Lines changed: 33 additions & 35 deletions
Large diffs are not rendered by default.

hooks/hooks.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,6 @@
1515
{
1616
"matcher": "",
1717
"hooks": [
18-
{
19-
"type": "command",
20-
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/memory-server-up.sh\""
21-
},
2218
{
2319
"type": "command",
2420
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/session-warmup.sh\""

hooks/lib/memory-vacuum.sh

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,62 @@ memory_vacuum() {
9191
# Default logger: a plain stderr line. Callers with their own _log (the
9292
# supervisor) pass it as $2 so vacuum output joins the server log.
9393
_memory_vacuum_log() { echo "memory-vacuum: $*" >&2; }
94+
95+
# memory_vacuum_locked: race-safe wrapper around memory_vacuum for the
96+
# per-session stdio launcher (hooks/mcp-memory.sh).
97+
#
98+
# WHY a lock here but not in the supervisor: the shared-server supervisor holds
99+
# the spawn lock and VACUUMs in a guaranteed "no server alive" window, so it
100+
# calls memory_vacuum directly. The stdio launcher has no such window — it runs
101+
# once per session and sibling sessions' servers may be live. This wrapper adds
102+
# a NON-BLOCKING atomic-mkdir lock so that among concurrently starting launchers
103+
# exactly one attempts the VACUUM and the rest skip immediately (one session
104+
# VACUUMs, others skip if held) rather than piling onto the same index.
105+
#
106+
# The lock only dedups concurrent *launchers*. A VACUUM that still contends with
107+
# a live sibling server's writes is handled one layer down: memory_vacuum sets a
108+
# SQLite busy timeout and treats a busy/locked index as skip-and-continue, so it
109+
# degrades safely — no corruption, no blocking. That SQLite-level busy handler is
110+
# the true safety net; this mkdir lock is a cheap best-effort dedup on top of it.
111+
# Skipping when a sibling server holds the index is the honest multi-session
112+
# tradeoff the Mac knowingly re-accepts by choosing per-session stdio.
113+
#
114+
# Staleness is PID-liveness, never wall-clock (mirrors memory-server-up.sh's
115+
# lock): a crashed launcher's lock is stolen once when its pid is dead. Because
116+
# the SQLite busy handler already prevents corruption, a lost steal-race just
117+
# means a redundant skip — so this deliberately omits the heavier lock-generation
118+
# nonce the spawn lock carries. Never fails the caller; releases the lock inline
119+
# (no EXIT trap — the launcher execs the server after this, where a trap would
120+
# fire in the wrong process).
121+
#
122+
# Args: $1 = index sqlite path (as memory_vacuum). $2 = optional logger name.
123+
memory_vacuum_locked() {
124+
local index_path="$1"
125+
local logger="${2:-_memory_vacuum_log}"
126+
local lock_dir="${index_path%/*}/vacuum.lock"
127+
local pid_file="$lock_dir/pid"
128+
129+
# Non-blocking claim: mkdir is atomic, so exactly one concurrent launcher wins.
130+
if ! mkdir "$lock_dir" 2>/dev/null; then
131+
local holder=""
132+
[ -f "$pid_file" ] && holder="$(cat "$pid_file" 2>/dev/null)"
133+
if [ -n "$holder" ] && kill -0 "$holder" 2>/dev/null; then
134+
"$logger" "vacuum: lock held by live pid $holder; skipping"
135+
return 0
136+
fi
137+
# Stale lock (holder dead or never stamped) — steal once and retry the claim.
138+
"$logger" "vacuum: stealing stale lock (holder '${holder:-none}' not alive)"
139+
rm -rf "$lock_dir" 2>/dev/null || true
140+
if ! mkdir "$lock_dir" 2>/dev/null; then
141+
"$logger" "vacuum: lost re-claim race; another launcher owns the lock; skipping"
142+
return 0
143+
fi
144+
fi
145+
146+
# We hold the lock. Stamp our pid for the liveness check above, run the gated
147+
# VACUUM, then always release (rm -rf, since the dir holds pid).
148+
echo "$$" > "$pid_file" 2>/dev/null || true
149+
memory_vacuum "$index_path" "$logger"
150+
rm -rf "$lock_dir" 2>/dev/null || true
151+
return 0
152+
}

hooks/mcp-memory.sh

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,23 @@ HOOKS_DIR="${HOOKS_DIR:-$SCRIPT_DIR}"
4141
. "$HOOKS_DIR/lib/memory-env.sh"
4242
memory_load_env
4343

44-
# --- Index reclamation moved out of this launcher ----------------------------
45-
# The gated full VACUUM used to run here on every per-session connect, which
46-
# could race a sibling session holding the same index. Under the shared-server
47-
# model index maintenance is the lazy-start supervisor's job: it runs the gated
48-
# VACUUM (hooks/lib/memory-vacuum.sh) at a confirmed cold start, inside the spawn
49-
# lock, with no server alive — so there is no writer to race. This stdio launcher
50-
# is the escape hatch / in-flight-old-session path; it no longer VACUUMs.
44+
# --- Out-of-band index reclamation: race-guarded gated VACUUM ----------------
45+
# Per-session stdio means N sessions can start concurrently, and a sibling
46+
# session's server may already hold the index. memory_vacuum_locked serializes
47+
# the attempt with a non-blocking mkdir lock — one launcher VACUUMs, the rest
48+
# skip immediately — and, one layer down, memory_vacuum's SQLite busy timeout
49+
# makes a VACUUM that still contends with a live sibling writer skip safely
50+
# rather than block or corrupt. Gated (freelist threshold) + cooled down
51+
# (once/day) inside the lib, so most boots skip with near-zero added latency.
52+
# Reuses the same lib the shared-server supervisor uses — no reinvention.
53+
#
54+
# Hard rules carry over from the old inline VACUUM: this step must NEVER fail the
55+
# launcher and must NEVER touch stdout (the MCP stdio channel). memory_vacuum
56+
# routes all output through the _log function (stderr) passed here and treats
57+
# every error (busy/locked, missing sqlite3, absent file) as skip-and-continue.
58+
# shellcheck source=hooks/lib/memory-vacuum.sh
59+
. "$HOOKS_DIR/lib/memory-vacuum.sh"
60+
memory_vacuum_locked "$MARKDOWN_VAULT_MCP_INDEX_PATH" _log
5161
# -----------------------------------------------------------------------------
5262

5363
# --- Resolve/install the server binary via the shared install library --------

hooks/session-warmup.sh

Lines changed: 21 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,16 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
2525
HOOKS_DIR="${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/hooks}"
2626
HOOKS_DIR="${HOOKS_DIR:-$SCRIPT_DIR}"
2727

28-
# Memory path/cache/mcp-name/port come from the shared resolver (precedence:
29-
# WORKBENCH_* override → config.json → default). It also exports the full
30-
# MARKDOWN_VAULT_MCP_* set, harmless here. memory_load_env sets MEMORY_PATH,
31-
# CACHE_PATH, MCP_NAME, MEMORY_PORT.
28+
# Memory path/cache come from the shared resolver (precedence: WORKBENCH_*
29+
# override → config.json → default). It also exports the full MARKDOWN_VAULT_MCP_*
30+
# set, harmless here. memory_load_env sets MEMORY_PATH and CACHE_PATH, both used
31+
# below. memory-probe.sh is deliberately NOT sourced any more: the shared-server
32+
# health check was disabled when the memory transport reverted to per-session
33+
# stdio (v0.13.0) — there is no external server to probe. See the breadcrumb
34+
# where that block used to live, further down.
3235
# shellcheck source=hooks/lib/memory-env.sh
3336
. "$HOOKS_DIR/lib/memory-env.sh"
34-
# shellcheck source=hooks/lib/memory-probe.sh
35-
. "$HOOKS_DIR/lib/memory-probe.sh"
3637
memory_load_env
37-
MCP_SERVER_NAME="$MCP_NAME"
3838

3939
# Config resolution for warmup-only fields (agent_name, identity_files).
4040
# Prefer the current data dir; fall back to the pre-rename location so users
@@ -398,41 +398,20 @@ if [ "$SOURCE" = "startup" ]; then
398398
find "$CACHE_PATH" -name "summary-writer-*.log" -delete 2>/dev/null
399399
fi
400400

401-
# ──────────── Memory server health check (startup only) ────────────
402-
# The memory server is now a shared HTTP server, lazy-started by the
403-
# memory-server-up hook that runs just before this warmup. Probe its actual
404-
# health (identity-checked) rather than guessing from the index file's presence.
405-
# Only surface a notice when something is wrong — a healthy/coming-up server is
406-
# the common case and needs no words.
407-
if [ "$SOURCE" = "startup" ]; then
408-
case "$(memory_probe)" in
409-
UP|BUILDING)
410-
: # serving (BUILDING = bound, index still building, search available) — quiet.
411-
;;
412-
PORT_DRIFT)
413-
printf '## ⚠ Memory server port drift\n\n'
414-
printf 'The running memory server bound a different port than configured (port `%s`).\n' "$MEMORY_PORT"
415-
printf 'This usually means `WORKBENCH_MEMORY_PORT` in `~/.claude/settings.json` and the\n'
416-
printf 'recorded `%s/server.port` disagree. Reconcile them (or run `/workbench-core:memory-status`), then restart Claude Code.\n\n' "$CACHE_PATH"
417-
;;
418-
DOWN_FOREIGN)
419-
printf '## ⚠ Memory server port conflict\n\n'
420-
printf 'Another process is listening on memory port `%s` that is not the `%s` vault.\n' "$MEMORY_PORT" "$MCP_SERVER_NAME"
421-
printf 'The shared server was not started to avoid a conflict. Free the port or set a different\n'
422-
printf '`WORKBENCH_MEMORY_PORT`, then restart. See `/workbench-core:memory-status`.\n\n'
423-
;;
424-
DOWN_FAILED)
425-
printf '## ⚠ Memory server failed to start\n\n'
426-
printf 'The last attempt to start the shared memory server failed (see `%s/server.log`).\n' "$CACHE_PATH"
427-
printf 'Memory search and write will fail until it comes up. Run `/workbench-core:memory-status` to diagnose.\n\n'
428-
;;
429-
*) # DOWN_NONE — not up yet; the up-hook just kicked a spawn that binds in ~2s.
430-
printf '## ℹ Memory server starting\n\n'
431-
printf 'The shared memory server is starting in the background (binds in ~2s; the client retries the connection).\n'
432-
printf 'If memory tools are unavailable this turn, they should work shortly — or next session.\n\n'
433-
;;
434-
esac
435-
fi
401+
# ──────────── Memory server health check — disabled (per-session stdio) ────────
402+
# A shared-HTTP health probe lived here through v0.12: it ran memory_probe and
403+
# surfaced port-drift / conflict / failed / starting notices for the lazy-started
404+
# shared server. It was removed in v0.13.0 when the transport reverted to a
405+
# per-session stdio server: stdio spawns the server in-process per session, so
406+
# there is NO external listener to probe — the probe returned DOWN_NONE every
407+
# startup and printed a phantom "Memory server starting" notice. The MCP host's
408+
# own connect error is the real signal for a broken stdio launcher now.
409+
#
410+
# To RE-ENABLE the shared HTTP server, restore this block and the memory-probe.sh
411+
# source near the top of this file (both are in git history), re-add the
412+
# memory-server-up.sh SessionStart hook in hooks/hooks.json, and switch
413+
# plugin.json's memory MCP back to the http transport. See README, section
414+
# "Memory server transport — re-enabling the shared HTTP server (optional)".
436415

437416
# ──────────── Recall-hook liveness check (startup only) ────────────
438417
# memory-recall.sh stamps last-attempt on every substantive prompt. A stamp

hooks/test-memory-status.sh

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#!/bin/bash
2+
# Tests for scripts/memory-status.sh — the per-session stdio status report.
3+
# Run directly: ./test-memory-status.sh
4+
# Drives the script in a sandbox (fake vault/cache, no real config) and asserts
5+
# it reports the stdio transport, resolved paths, and launcher presence, carries
6+
# no leftover HTTP/port/token vocabulary, and always exits 0. No server, no net.
7+
8+
set -u
9+
HOOKS="$(cd "$(dirname "$0")" && pwd)"
10+
REPO_ROOT="$(cd "$HOOKS/.." && pwd)"
11+
STATUS="$REPO_ROOT/scripts/memory-status.sh"
12+
PASS=0
13+
FAIL=0
14+
15+
SANDBOX=$(mktemp -d)
16+
trap 'rm -rf "$SANDBOX"' EXIT
17+
mkdir -p "$SANDBOX/vault" "$SANDBOX/cache"
18+
19+
run_status() {
20+
env WORKBENCH_MEMORY_PATH="$SANDBOX/vault" \
21+
WORKBENCH_MEMORY_CACHE="$SANDBOX/cache" \
22+
WORKBENCH_MCP_SERVER_NAME="test-vault" \
23+
WORKBENCH_CONFIG_FILE="$SANDBOX/nope.json" \
24+
CLAUDE_PLUGIN_ROOT="$REPO_ROOT" \
25+
bash "$STATUS" "$@" 2>&1
26+
}
27+
28+
assert_contains() {
29+
local desc="$1" output="$2" needle="$3"
30+
if printf '%s' "$output" | grep -qF "$needle"; then
31+
PASS=$((PASS + 1)); echo "$desc"
32+
else
33+
FAIL=$((FAIL + 1)); echo "$desc — expected: $needle"
34+
fi
35+
}
36+
37+
assert_missing() {
38+
local desc="$1" output="$2" needle="$3"
39+
if printf '%s' "$output" | grep -qiF "$needle"; then
40+
FAIL=$((FAIL + 1)); echo "$desc — should NOT contain: $needle"
41+
else
42+
PASS=$((PASS + 1)); echo "$desc"
43+
fi
44+
}
45+
46+
echo "status: reports the stdio transport and resolved facts, exits 0:"
47+
OUT=$(run_status); RC=$?
48+
assert_contains "reports per-session stdio transport" "$OUT" "per-session stdio"
49+
assert_contains "reports the vault path" "$OUT" "$SANDBOX/vault"
50+
assert_contains "reports the cache path" "$OUT" "$SANDBOX/cache"
51+
assert_contains "reports the configured server name" "$OUT" "test-vault"
52+
assert_contains "reports the launcher present" "$OUT" "mcp-memory.sh (present)"
53+
assert_contains "says there is no shared server to start/stop" "$OUT" "no shared server to start or stop"
54+
[ "$RC" -eq 0 ] && { PASS=$((PASS+1)); echo " ✅ exits 0"; } || { FAIL=$((FAIL+1)); echo " ❌ non-zero exit ($RC)"; }
55+
56+
echo "no leftover shared-HTTP vocabulary in the report:"
57+
assert_missing "no bearer-token wording" "$OUT" "bearer"
58+
assert_missing "no hardcoded 8765 port" "$OUT" "8765"
59+
60+
echo "start/stop: prints the not-applicable note and still shows status:"
61+
OUT=$(run_status stop)
62+
assert_contains "explains stop is N/A under stdio" "$OUT" "does not apply to the per-session stdio server"
63+
assert_contains "still shows status after the note" "$OUT" "per-session stdio"
64+
65+
echo
66+
echo "$PASS passed, $FAIL failed"
67+
[ "$FAIL" -eq 0 ]

hooks/test-memory-vacuum.sh

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,28 @@ assert_no_file() {
5959
else FAIL=$((FAIL + 1)); echo "$desc — should be absent: $path"; fi
6060
}
6161

62+
assert_no_dir() {
63+
local desc="$1" path="$2"
64+
if [ ! -d "$path" ]; then PASS=$((PASS + 1)); echo "$desc"
65+
else FAIL=$((FAIL + 1)); echo "$desc — dir should be absent: $path"; fi
66+
}
67+
68+
# run_vacuum_locked <index> [env...] — call the race-guarded wrapper the stdio
69+
# launcher uses, capturing its stderr log.
70+
run_vacuum_locked() {
71+
local index="$1"; shift
72+
env "$@" bash -c '. "'"$LIB"'"; memory_vacuum_locked "'"$index"'"' 2>&1
73+
}
74+
75+
assert_no_run() {
76+
local desc="$1" output="$2"
77+
if printf '%s' "$output" | grep -qF "running VACUUM"; then
78+
FAIL=$((FAIL + 1)); echo "$desc — VACUUM ran but should have been skipped"
79+
else
80+
PASS=$((PASS + 1)); echo "$desc"
81+
fi
82+
}
83+
6284
echo "freelist over threshold → VACUUM runs, file shrinks, stamp written:"
6385
IDX="$SANDBOX/over/vault-index.sqlite"; mkdir -p "$SANDBOX/over"
6486
build_index "$IDX"
@@ -113,6 +135,38 @@ RC=$?
113135
assert_contains "logs sqlite3-missing skip" "$OUT" "sqlite3 not on PATH"
114136
[ "$RC" -eq 0 ] && { PASS=$((PASS+1)); echo " ✅ returns 0 when sqlite3 absent"; } || { FAIL=$((FAIL+1)); echo " ❌ non-zero when sqlite3 absent"; }
115137

138+
echo "locked wrapper: free lock → VACUUM runs and the lock is released:"
139+
IDX="$SANDBOX/lockfree/vault-index.sqlite"; mkdir -p "$SANDBOX/lockfree"
140+
build_index "$IDX"
141+
OUT=$(run_vacuum_locked "$IDX" WORKBENCH_MEMORY_VACUUM_THRESHOLD_MB=1)
142+
assert_contains "runs VACUUM when the lock is free" "$OUT" "running VACUUM"
143+
assert_no_dir "lock dir released after the run" "$SANDBOX/lockfree/vacuum.lock"
144+
145+
echo "locked wrapper: lock held by a LIVE pid → skip, holder untouched:"
146+
IDX="$SANDBOX/lockheld/vault-index.sqlite"; mkdir -p "$SANDBOX/lockheld"
147+
build_index "$IDX"
148+
mkdir -p "$SANDBOX/lockheld/vacuum.lock"
149+
sleep 60 & HOLDER=$!
150+
disown 2>/dev/null || true # silence the job-control "Terminated" notice on kill
151+
echo "$HOLDER" > "$SANDBOX/lockheld/vacuum.lock/pid"
152+
OUT=$(run_vacuum_locked "$IDX" WORKBENCH_MEMORY_VACUUM_THRESHOLD_MB=1)
153+
kill "$HOLDER" 2>/dev/null
154+
assert_contains "logs the live-holder skip" "$OUT" "held by live pid"
155+
assert_no_run "does not VACUUM under a live lock" "$OUT"
156+
assert_file "live holder's lock left intact" "$SANDBOX/lockheld/vacuum.lock/pid"
157+
158+
echo "locked wrapper: STALE lock (dead pid) → stolen, VACUUM runs, lock released:"
159+
IDX="$SANDBOX/lockstale/vault-index.sqlite"; mkdir -p "$SANDBOX/lockstale"
160+
build_index "$IDX"
161+
mkdir -p "$SANDBOX/lockstale/vacuum.lock"
162+
# A guaranteed-dead pid: spawn a trivial child, then reap it so kill -0 fails.
163+
sh -c 'exit 0' & DEAD=$!; wait "$DEAD" 2>/dev/null
164+
echo "$DEAD" > "$SANDBOX/lockstale/vacuum.lock/pid"
165+
OUT=$(run_vacuum_locked "$IDX" WORKBENCH_MEMORY_VACUUM_THRESHOLD_MB=1)
166+
assert_contains "steals the stale lock" "$OUT" "stealing stale lock"
167+
assert_contains "runs VACUUM after the steal" "$OUT" "running VACUUM"
168+
assert_no_dir "lock released after stolen run" "$SANDBOX/lockstale/vacuum.lock"
169+
116170
echo
117171
echo "$PASS passed, $FAIL failed"
118172
[ "$FAIL" -eq 0 ]

0 commit comments

Comments
 (0)