Files
esh-pfi-infrastructure/scripts/deploy-althing.sh
T
vh af5582c96d docs(althing): a sixth deploy surface, and it lives outside the repo
Claude Code holds an inbound cross-session message unless the sender's
permission-mode class matches the receiver's, and holds any sender that
asserts no class. The herald is a daemon and asserts none, so on a
default-configured seat every cc poke is held at first contact and the
notice goes to whoever is watching the pane rather than to the session.

The seat still reports declared, reachable and healthy throughout — the
same failure shape as the SessionStart hook that was never deployed, where
every check was green and nothing arrived. That is what makes it worth a
surface of its own rather than a footnote.

deploy-althing.sh now reads crossSessionInbound from ~/.claude/settings.json
and reports it on every run and on --check. It deliberately does NOT set it:
that file is the operator's permission configuration, and a deploy script
that edits it is a deploy script granting itself trust.

Also records surface 5 (the per-seat route declare), which had been implicit,
and why no attestation exists for the herald to send instead — CC reads a
sender's live runtime permission mode from the session registry, which a
daemon does not appear in.
2026-09-02 09:30:48 -07:00

196 lines
9.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# Deploy althing on this box. FOUR surfaces, not three — the fourth is the one
# that had no step and drifted for five days without anyone noticing.
#
# scripts/deploy-althing.sh deploy
# scripts/deploy-althing.sh --check report drift, change nothing
#
# ─────────────────────────────────────────────────────────────────────────────
# WHY FOUR — AND WHY THE COUNT IS NOW SIX
#
# 1. uv tool install --force . the 7 binaries (postbox, althing-listen,
# althing-route, althing-po-herald, ...)
# 2. herald restart guards live in the herald; nothing new takes
# effect until it restarts
# 3. scripts/sync_skill.sh the machine-global participant skill
# 4. PLUGIN repo plugin/ -> marketplace dir -> CC cache
#
# Surface 4 is two hops and NEITHER was automated. The marketplace directory
# was a frozen copy from 2026-08-28 carrying plugin 0.0.1 with ONLY the
# UserPromptSubmit hook — no SessionStart, no SessionEnd, no pane-route.sh.
# Consequence: "CC seats re-declare their pane route automatically at next
# SessionStart" was never true on this box, which is why every seat had to be
# hand-declared with a pid someone measured by hand.
#
# ⚠ `uv tool install .` WITHOUT --force IS A SILENT NO-OP. uv matches the
# source SPEC, not its contents: on a box that already installed from this
# path it prints "already installed" and exits 0 having done nothing.
#
# ⚠ The plugin cache update needs a Claude Code RESTART to take effect. The
# hooks are read at session start; a running session keeps the old ones.
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
REPO="${ALTHING_REPO:-$HOME/development/althing}"
MARKET="${ALTHING_MARKETPLACE_DIR:-$HOME/.local/share/althing-plugin}"
CHECK=0
[[ "${1:-}" == "--check" ]] && CHECK=1
say() { printf '%s\n' "$*"; }
step() { printf '\n── %s\n' "$*"; }
[[ -d "$REPO/.git" ]] || { say "✗ no althing checkout at $REPO"; exit 2; }
repo_plugin_version() {
python3 -c "import json;print(json.load(open('$REPO/plugin/.claude-plugin/plugin.json'))['version'])"
}
market_plugin_version() {
python3 -c "import json;print(json.load(open('$MARKET/.claude-plugin/plugin.json'))['version'])" 2>/dev/null || echo "absent"
}
cache_versions() {
ls -1 "$HOME/.claude/plugins/cache/althing/althing/" 2>/dev/null | tr '\n' ' ' || echo "absent"
}
# ⚠ COMPARE CONTENT, NOT THE VERSION STRING.
#
# `claude plugin update` matches on the version in plugin.json and declines
# when it is unchanged — so a release that edits hook or script CONTENT without
# bumping the plugin version leaves the Claude Code cache stale while every
# version check in this script reports success. Measured on the 3.3.0 deploy
# (2026-09-02): marketplace and cache both read 0.1.1, update said "already at
# the latest version", and pane-route.sh + README differed. That delta was
# documentation-only and harmless — the next one need not be.
#
# The 2026-09-01 lesson was "compare the hook list, not the version string".
# This is the same lesson one turn deeper: the hook list was identical too.
plugin_content_drift() {
# Only the cache dir matching the marketplace version is live. Older dirs
# carry a .orphaned_at marker and Claude Code no longer loads them; they
# differ by construction and saying so every run is noise.
local live="$HOME/.claude/plugins/cache/althing/althing/$(market_plugin_version)"
local c
for c in "$HOME"/.claude/plugins/cache/althing/althing/*/; do
[[ -d "$c" && -f "$c/.orphaned_at" ]] && say " · cache $(basename "$c") orphaned, ignored"
done
if [[ ! -d "$live" ]]; then
say " ⚠ no cache dir for the marketplace version $(market_plugin_version)"
return 1
fi
if diff -rq --exclude marketplace.json "$MARKET" "$live" >/dev/null 2>&1; then
say " ✓ live cache $(basename "$live") matches the marketplace tree"
return 0
fi
say " ⚠ live cache $(basename "$live") DIFFERS from the marketplace tree:"
diff -rq --exclude marketplace.json "$MARKET" "$live" 2>&1 | sed 's/^/ /'
return 1
}
# ── SURFACE 6: ~/.claude/settings.json crossSessionInbound ────────────────────
#
# The only surface that is NOT in the althing repo, so a version check cannot
# reach it. Claude Code holds an inbound cross-session message unless the
# sender's permission-mode class matches yours, and a sender that asserts no
# class is held. The herald is a daemon and asserts none — deliberately — so on
# a default-configured seat a cc poke is HELD and the notice goes to whoever is
# looking at the pane instead of to the session.
#
# The failure shape is the expensive one: the seat is declared, reachable and
# green, and nothing arrives. Same family as the SessionStart hook that was
# never deployed.
#
# ⚠ REPORT ONLY, NEVER SET. This file is the operator's permission
# configuration; a deploy script that edits it is a deploy script that grants
# itself trust. Print what is there and let a human decide.
settings_inbound() {
python3 - <<'EOF' 2>/dev/null || echo "unreadable"
import json, pathlib
p = pathlib.Path.home() / ".claude/settings.json"
try:
print(json.loads(p.read_text()).get("crossSessionInbound", "UNSET"))
except Exception:
print("unreadable")
EOF
}
step "state"
say " repo $(git -C "$REPO" describe --tags --always) $(git -C "$REPO" diff --quiet && echo clean || echo DIRTY)"
say " installed tool $(uv tool list 2>/dev/null | awk '/^althing-core/{print $2}')"
say " repo plugin $(repo_plugin_version)"
say " marketplace $(market_plugin_version)"
say " CC cache $(cache_versions)"
plugin_content_drift || true
inbound="$(settings_inbound)"
say " cc inbound crossSessionInbound=$inbound (~/.claude/settings.json)"
if [[ "$inbound" != "accept" ]]; then
say " ⚠ cc pokes to a seat on this box are HELD at first contact, not delivered."
say " The seat still looks declared, reachable and healthy. Set"
say " crossSessionInbound: \"accept\" BY HAND — this script will not"
say " edit the operator's permission configuration."
fi
if (( CHECK )); then
say ""
say "--check: nothing changed."
exit 0
fi
step "1/4 uv tool install --force (NEVER without --force: silent no-op)"
( cd "$REPO" && uv tool install --force . 2>&1 | tail -2 )
step "2/4 restart the herald"
systemctl --user restart althing-po-herald
sleep 2
say " herald: $(systemctl --user is-active althing-po-herald)"
step "3/4 sync the machine-global skill"
"$REPO/scripts/sync_skill.sh" >/dev/null
"$REPO/scripts/sync_skill.sh" --check | sed 's/^/ /'
step "4/4 plugin — repo -> marketplace -> Claude Code cache"
if [[ -d "$MARKET" ]]; then
cp -a "$MARKET" "$MARKET.bak-$(date -u +%Y%m%dT%H%M%SZ)"
say " backed up the marketplace dir"
fi
mkdir -p "$MARKET/.claude-plugin"
# marketplace.json lives ONLY in the deployed tree (the repo keeps its own at
# .claude-plugin/marketplace.json pointing at ./plugin). Preserve and re-stamp.
tmp=$(mktemp)
[[ -f "$MARKET/.claude-plugin/marketplace.json" ]] && cp -a "$MARKET/.claude-plugin/marketplace.json" "$tmp"
rsync -a --delete --exclude marketplace.json "$REPO/plugin/" "$MARKET/"
[[ -s "$tmp" ]] && cp -a "$tmp" "$MARKET/.claude-plugin/marketplace.json"
rm -f "$tmp"
python3 - "$REPO" "$MARKET" <<'PY'
import json, pathlib, sys
repo, market = sys.argv[1], sys.argv[2]
src = json.loads(pathlib.Path(f"{repo}/plugin/.claude-plugin/plugin.json").read_text())
p = pathlib.Path(f"{market}/.claude-plugin/marketplace.json")
d = json.loads(p.read_text())
d["plugins"][0]["version"] = src["version"]
d["plugins"][0]["description"] = src["description"]
p.write_text(json.dumps(d, indent=2) + "\n")
print(f" marketplace.json stamped {src['version']}")
PY
say " updating the Claude Code plugin cache ..."
# `claude plugin update` is the SUPPORTED path. Do NOT hand-edit
# ~/.claude/plugins/installed_plugins.json or fabricate a cache directory —
# that is Claude Code's own bookkeeping and a subtle mistake there breaks the
# plugin in a way that looks like an upstream bug.
claude plugin update althing 2>&1 | sed 's/^/ /' || say " (update reported nothing to do)"
step "verify"
say " installed tool $(uv tool list 2>/dev/null | awk '/^althing-core/{print $2}')"
say " marketplace $(market_plugin_version)"
say " CC cache $(cache_versions)"
if ! plugin_content_drift; then
say ""
say " ⚠ The cache did NOT take the new plugin content. This happens when the"
say " release changed hook/script content without bumping the plugin version"
say " — `claude plugin update` matches on version and declines. Ask the"
say " althing maintainer for a version bump; do NOT hand-edit the cache or"
say " ~/.claude/plugins/installed_plugins.json, which is Claude Code's own"
say " bookkeeping. Check the diff above for whether it is load-bearing."
fi
say " herald $(systemctl --user is-active althing-po-herald)"
say " cc inbound crossSessionInbound=$(settings_inbound)"
say ""
say "⚠ RESTART Claude Code to load the new plugin hooks. A running session keeps the old ones."