mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
259 lines
12 KiB
Bash
Executable file
259 lines
12 KiB
Bash
Executable file
#!/bin/sh
|
|
# In-Obsidian "Claude is testing" banner + countdown, so the user gets a loud,
|
|
# in-app heads-up even when they're not watching the Claude chat — AND a
|
|
# machine-readable lock so a SECOND Claude session can detect that the vault is
|
|
# already being driven and back off instead of colliding.
|
|
#
|
|
# Why: automated Obsidian looks identical to a normal one. Two risks:
|
|
# (a) the USER pokes the UI mid-test → chat 🔴/✅ + the loud banner cover this.
|
|
# (b) ANOTHER Claude chat drives the same instance at the same time → the
|
|
# banner alone doesn't stop that (a human isn't reading it). So the banner
|
|
# now has a data twin: window.__claudeVaultLock, an advisory mutex every
|
|
# driving session checks first. Banner = human-facing; lock = machine-facing.
|
|
#
|
|
# Usage:
|
|
# scripts/obs-test-guard start [seconds] # claim lock, show banner, wait N s, then "testing now"
|
|
# scripts/obs-test-guard ensure # INSTANT re-claim + banner (no wait) before a driving eval
|
|
# scripts/obs-test-guard end # release MY lock, clear banner, show "safe to use"
|
|
# scripts/obs-test-guard status # read-only: who (if anyone) holds the lock right now
|
|
# scripts/obs-test-guard steal # force-claim a lock you're SURE is stale (crashed session)
|
|
# scripts/obs-test-guard note 'message' # ad-hoc persistent banner with a custom message
|
|
#
|
|
# PROTOCOL: run `start` ONCE at the beginning of a testing session and `end`
|
|
# ONLY when you're completely done and handing control back. Keep the banner up
|
|
# across EVERY intervening eval/screenshot/reload — do NOT `end` then keep
|
|
# testing. If you already ended (or aren't sure), call `ensure` (instant, no
|
|
# countdown — the user was already warned) before the next driving eval.
|
|
#
|
|
# THE LOCK (layer 1 — same-instance advisory mutex):
|
|
# window.__claudeVaultLock = { owner, label, startedAt, expiresAt }
|
|
# * owner = $CLAUDE_CODE_SESSION_ID — stable per chat, unique across chats.
|
|
# * `start`/`ensure`/`steal` refresh expiresAt = now + TTL (default 300s,
|
|
# override with $OBS_GUARD_TTL). A crashed/forgotten session's lock therefore
|
|
# self-expires within TTL and the next session can claim it cleanly.
|
|
# * `start`/`ensure` ABORT (exit 3) if a DIFFERENT, non-expired owner holds it.
|
|
# * `end` clears the lock ONLY if it's mine — it never clobbers another chat's.
|
|
# Scope + caveats: this coordinates multiple drivers of the SAME renderer
|
|
# (e.g. two chats both hitting the shared obs-dev / CLI instance) — the real
|
|
# collision case. It canNOT see a different Obsidian instance (obs-dev has its
|
|
# own renderer, isolated by design) or sense the user's own clicks; the banner
|
|
# + chat handshake still cover those. It's advisory, not a hard OS lock.
|
|
#
|
|
# The banner Notice is persistent (duration 0) and tracked on
|
|
# window.__claudeTestBanner so `end` can dismiss it. `start` does the wait in the
|
|
# SHELL (not inside eval — obs eval has a ~1s ceiling).
|
|
|
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
OBS="$DIR/obs"
|
|
|
|
# Owner identity: the chat's session id (stable across this script's separate
|
|
# start/ensure/end invocations, distinct from any other chat). Fall back to a
|
|
# per-process token if the harness ever doesn't export it (degrades gracefully —
|
|
# worst case a session fails to recognize its own lock and refuses, i.e. safe).
|
|
OWNER="${CLAUDE_CODE_SESSION_ID:-nosession-$$}"
|
|
LOCK_TTL="${OBS_GUARD_TTL:-300}"
|
|
|
|
show() {
|
|
# show <js-string-literal-message> — (re)uses the single tracked banner Notice.
|
|
"$OBS" eval code="(() => {
|
|
const msg = $1;
|
|
if (window.__claudeTestBanner && typeof window.__claudeTestBanner.setMessage === 'function') {
|
|
window.__claudeTestBanner.setMessage(msg);
|
|
} else {
|
|
window.__claudeTestBanner = new Notice(msg, 0);
|
|
}
|
|
return 'shown';
|
|
})()" >/dev/null 2>&1
|
|
}
|
|
|
|
# claim <label> — try to acquire/refresh the lock for OWNER. Echoes JSON:
|
|
# {"ok":true,...} acquired or refreshed (it was free/mine/stale)
|
|
# {"ok":false,"owner":..,"label":.., held by a DIFFERENT live session — caller must abort
|
|
# "heldForSec":N,"expiresInSec":N}
|
|
claim() {
|
|
"$OBS" eval code="(() => {
|
|
const now = Date.now();
|
|
const mine = '$OWNER';
|
|
const ttl = ${LOCK_TTL} * 1000;
|
|
const cur = window.__claudeVaultLock;
|
|
if (cur && cur.owner && cur.owner !== mine && cur.expiresAt > now) {
|
|
return JSON.stringify({
|
|
ok: false, owner: cur.owner, label: cur.label || '',
|
|
heldForSec: Math.round((now - (cur.startedAt || now)) / 1000),
|
|
expiresInSec: Math.round((cur.expiresAt - now) / 1000)
|
|
});
|
|
}
|
|
const startedAt = (cur && cur.owner === mine && cur.startedAt) ? cur.startedAt : now;
|
|
window.__claudeVaultLock = { owner: mine, label: '$1', startedAt: startedAt, expiresAt: now + ttl };
|
|
return JSON.stringify({ ok: true });
|
|
})()" 2>/dev/null
|
|
}
|
|
|
|
# release — clear the lock, but only if it's mine (never clobber another session).
|
|
release() {
|
|
"$OBS" eval code="(() => {
|
|
const mine = '$OWNER';
|
|
const cur = window.__claudeVaultLock;
|
|
if (cur && cur.owner && cur.owner !== mine) {
|
|
return JSON.stringify({ cleared: false, owner: cur.owner });
|
|
}
|
|
window.__claudeVaultLock = null;
|
|
return JSON.stringify({ cleared: true });
|
|
})()" 2>/dev/null
|
|
}
|
|
|
|
# jsonstr <field> — pull a string field's value from the last JSON blob on stdin.
|
|
jsonstr() { sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p"; }
|
|
# jsonnum <field> — pull a numeric field's value.
|
|
jsonnum() { sed -n "s/.*\"$1\":\([0-9-]*\).*/\1/p"; }
|
|
|
|
# abort_if_held <claim-output> — print a loud abort + exit 3 if the claim was refused.
|
|
abort_if_held() {
|
|
case "$1" in
|
|
*'"ok":true'*) return 0 ;;
|
|
*)
|
|
own=$(printf '%s' "$1" | jsonstr owner)
|
|
lbl=$(printf '%s' "$1" | jsonstr label)
|
|
held=$(printf '%s' "$1" | jsonnum heldForSec)
|
|
exp=$(printf '%s' "$1" | jsonnum expiresInSec)
|
|
if [ -z "$own" ]; then
|
|
echo "✋ GUARD ABORT: couldn't read the vault lock (no response from the CLI)." >&2
|
|
echo " Is an Obsidian instance running? Fix that before driving." >&2
|
|
else
|
|
echo "✋ GUARD ABORT: another Claude session is already testing this vault." >&2
|
|
echo " Held by session ${own} (${lbl:-no label}), started ~${held:-?}s ago; lock expires in ~${exp:-?}s." >&2
|
|
echo " Do NOT drive. Coordinate with that chat, wait for its all-clear, or —" >&2
|
|
echo " if you're CERTAIN it crashed/was abandoned — run: scripts/obs-test-guard steal" >&2
|
|
fi
|
|
exit 3 ;;
|
|
esac
|
|
}
|
|
|
|
# verify — confirm the CLI is talking to the THROWAWAY dev vault before any
|
|
# driving/destructive test, so a misrouted CLI (wrong window focused, multiple
|
|
# vaults open) can never run against a real vault. Two independent checks:
|
|
# 1. vault name === "Claude Dev Vault"
|
|
# 2. the sentinel note exists AND carries its unique token
|
|
# The sentinel makes this robust even if the vault is renamed again — a real
|
|
# vault would have to deliberately contain the token to be mistaken for this one.
|
|
# Prints "verify: OK" + exits 0 on match; LOUD error + exits 1 otherwise.
|
|
EXPECT_VAULT="Claude Dev Vault"
|
|
SENTINEL_TOKEN="claude-dev-vault-7f3a9c2e-do-not-delete"
|
|
verify() {
|
|
out="$("$OBS" eval code="(async () => {
|
|
const name = app.vault.getName();
|
|
let token = '';
|
|
try { token = await app.vault.adapter.read('CLAUDE-DEV-VAULT-SENTINEL.md'); } catch (e) {}
|
|
const ok = name === '$EXPECT_VAULT' && token.indexOf('$SENTINEL_TOKEN') !== -1;
|
|
return JSON.stringify({ ok, name, sentinel: token.indexOf('$SENTINEL_TOKEN') !== -1 });
|
|
})()" 2>/dev/null)"
|
|
case "$out" in
|
|
*'"ok":true'*) echo "verify: OK — $EXPECT_VAULT (name + sentinel confirmed)"; return 0 ;;
|
|
*)
|
|
echo "✋ GUARD ABORT: the running Obsidian is NOT the throwaway dev vault." >&2
|
|
echo " Expected \"$EXPECT_VAULT\" + sentinel note; got: ${out:-<no response from CLI>}" >&2
|
|
echo " Refusing to drive — focus the Claude Dev Vault window (or restore the sentinel) and retry." >&2
|
|
return 1 ;;
|
|
esac
|
|
}
|
|
|
|
case "$1" in
|
|
verify)
|
|
verify; exit $?
|
|
;;
|
|
status)
|
|
# Read-only: report the current lock holder without touching it. Safe to run
|
|
# from any chat BEFORE deciding to start, so you can see if someone's testing.
|
|
out="$("$OBS" eval code="(() => {
|
|
const now = Date.now();
|
|
const cur = window.__claudeVaultLock;
|
|
if (!cur || !cur.owner || cur.expiresAt <= now) return JSON.stringify({ held: false });
|
|
return JSON.stringify({
|
|
held: true, owner: cur.owner, label: cur.label || '', mine: cur.owner === '$OWNER',
|
|
heldForSec: Math.round((now - (cur.startedAt || now)) / 1000),
|
|
expiresInSec: Math.round((cur.expiresAt - now) / 1000)
|
|
});
|
|
})()" 2>/dev/null)"
|
|
case "$out" in
|
|
*'"held":false'*) echo "lock: FREE — no session is testing this vault." ;;
|
|
*'"held":true'*)
|
|
own=$(printf '%s' "$out" | jsonstr owner)
|
|
lbl=$(printf '%s' "$out" | jsonstr label)
|
|
held=$(printf '%s' "$out" | jsonnum heldForSec)
|
|
exp=$(printf '%s' "$out" | jsonnum expiresInSec)
|
|
case "$out" in
|
|
*'"mine":true'*) whose="ME (this chat)" ;;
|
|
*) whose="another chat" ;;
|
|
esac
|
|
echo "lock: HELD by ${whose} — session ${own} (${lbl:-no label}), ~${held:-?}s ago, expires in ~${exp:-?}s." ;;
|
|
*)
|
|
echo "lock: UNKNOWN — no response from the CLI (is an Obsidian instance running?)." >&2
|
|
exit 1 ;;
|
|
esac
|
|
;;
|
|
start)
|
|
verify || exit 1
|
|
out="$(claim 'start')"
|
|
abort_if_held "$out"
|
|
secs="${2:-5}"
|
|
show "'🤖 Claude is about to run an automated Stashpad test.\n✋ Please STOP interacting — starting in about ${secs} seconds…'"
|
|
i="$secs"
|
|
while [ "$i" -gt 0 ]; do
|
|
sleep 1
|
|
i=$((i - 1))
|
|
show "'🤖 Claude is testing Stashpad.\n✋ Please don\\'t touch — starting in ${i}s…'"
|
|
done
|
|
show "'🤖 Claude is TESTING Stashpad now.\n✋ Please don\\'t touch until you see the all-clear.'"
|
|
echo "guard: lock acquired (owner ${OWNER}), banner up, ${secs}s grace elapsed — safe to drive"
|
|
;;
|
|
ensure)
|
|
# Instant re-raise (no countdown) — for follow-up driving evals when the
|
|
# banner may have been ended. The user was already warned by the session's
|
|
# initial `start`, so no grace period is needed. Re-verifies the vault AND
|
|
# re-claims/refreshes the lock (aborts if another chat grabbed it meanwhile).
|
|
verify || exit 1
|
|
out="$(claim 'ensure')"
|
|
abort_if_held "$out"
|
|
show "'🤖 Claude is TESTING Stashpad now.\n✋ Please don\\'t touch until you see the all-clear.'"
|
|
echo "guard: lock refreshed, banner ensured (instant)"
|
|
;;
|
|
steal)
|
|
# Force-claim a lock you're CERTAIN is stale (the holding chat crashed or was
|
|
# abandoned and you don't want to wait out the TTL). Overwrites ownership.
|
|
verify || exit 1
|
|
"$OBS" eval code="(() => {
|
|
const now = Date.now();
|
|
window.__claudeVaultLock = { owner: '$OWNER', label: 'steal', startedAt: now, expiresAt: now + ${LOCK_TTL} * 1000 };
|
|
return 'stolen';
|
|
})()" >/dev/null 2>&1
|
|
show "'🤖 Claude is TESTING Stashpad now.\n✋ Please don\\'t touch until you see the all-clear.'"
|
|
echo "guard: lock forcibly claimed (steal), banner up"
|
|
;;
|
|
end)
|
|
rel="$(release)"
|
|
case "$rel" in
|
|
*'"cleared":false'*)
|
|
echo "guard: NOTE — the lock is held by another session; left it alone (only cleared the banner)." >&2 ;;
|
|
esac
|
|
"$OBS" eval code="(() => {
|
|
if (window.__claudeTestBanner && typeof window.__claudeTestBanner.hide === 'function') {
|
|
try { window.__claudeTestBanner.hide(); } catch (e) {}
|
|
}
|
|
window.__claudeTestBanner = null;
|
|
new Notice('✅ Safe to use Stashpad again — Claude has finished testing.', 0);
|
|
return 'cleared';
|
|
})()" >/dev/null 2>&1
|
|
echo "guard: lock released, banner cleared, all-clear shown"
|
|
;;
|
|
note)
|
|
[ -n "$2" ] || { echo "usage: obs-test-guard note 'message'" >&2; exit 1; }
|
|
# Escape single quotes in the user message for the JS string literal.
|
|
esc="$(printf '%s' "$2" | sed "s/'/\\\\'/g")"
|
|
show "'$esc'"
|
|
echo "guard: custom banner shown"
|
|
;;
|
|
*)
|
|
echo "usage: obs-test-guard {verify|status|start [seconds]|ensure|steal|end|note 'message'}" >&2
|
|
exit 1
|
|
;;
|
|
esac
|