@junghanacs/entwurf 0.14.2 → 0.15.0
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.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +79 -0
- package/DELIVERY.md +72 -59
- package/README.md +55 -4
- package/VERIFY.md +2 -2
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +1 -1
- package/docs/external-mcp-host.md +26 -4
- package/docs/setup-clean-host.md +3 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
- package/mcp/entwurf-bridge/src/index.ts +10 -6
- package/mcp/entwurf-bridge/tsconfig.build.json +2 -1
- package/package.json +22 -11
- package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
- package/pi-extensions/entwurf-control.ts +17 -8
- package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
- package/pi-extensions/lib/meta-sender-identity.ts +10 -2
- package/pi-extensions/lib/meta-session.ts +102 -29
- package/pi-extensions/lib/mux-fresh-call.ts +82 -11
- package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
- package/run.sh +334 -16
- package/scripts/check-copilot-birth-hook.ts +497 -0
- package/scripts/check-copilot-launch.ts +395 -0
- package/scripts/check-copilot-receive-arm.ts +734 -0
- package/scripts/check-copilot-statusline.ts +122 -0
- package/scripts/check-entwurf-capabilities.ts +25 -6
- package/scripts/check-fresh-cut-gate.sh +92 -23
- package/scripts/check-gate-qualification.ts +7 -1
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-meta-capability-source.ts +89 -5
- package/scripts/check-meta-doctor-oracle.sh +26 -0
- package/scripts/check-meta-manifest-schema.py +38 -0
- package/scripts/check-meta-receiver-marker.ts +25 -2
- package/scripts/check-meta-session.ts +32 -0
- package/scripts/copilot-bridge-doctor.sh +209 -0
- package/scripts/copilot-bridge-install.sh +224 -0
- package/scripts/copilot-launch.sh +236 -0
- package/scripts/copilot-mcp-bridge.sh +177 -0
- package/scripts/copilot-mcp-config.py +221 -0
- package/scripts/copilot-receive-bridge.sh +483 -0
- package/scripts/copilot-statusline-bridge.sh +149 -0
- package/scripts/copilot-statusline-config.py +201 -0
- package/scripts/copilot-statusline.sh +68 -0
- package/scripts/dev-bin.sh +4 -1
- package/scripts/meta-bridge-hook-log.sh +17 -5
- package/scripts/mutants/capability-cache.json +64 -0
- package/scripts/mutants/copilot-birth.json +155 -0
- package/scripts/mutants/copilot-launch.json +187 -0
- package/scripts/mutants/copilot-receive.json +263 -0
- package/scripts/mutants/fresh-cut.json +17 -0
- package/scripts/mutants/mux-fresh-call.json +216 -2
- package/scripts/mutants/pack-install.json +17 -0
- package/scripts/raw-async-delivery/README.md +170 -125
- package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
- package/scripts/smoke-copilot-mcp-state.sh +153 -0
- package/scripts/smoke-copilot-statusline-state.sh +131 -0
- package/scripts/smoke-mux-fresh-call-live.ts +2 -0
- package/scripts/smoke-mux-lifecycle-live.ts +3 -1
- package/scripts/tsconfig.json +1 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# copilot-bridge-doctor.sh — the FAIL-LOUD surface for the Copilot birth plugin (#82).
|
|
3
|
+
#
|
|
4
|
+
# WHY IT IS NOT `doctor-meta-bridge --copilot`. The Claude doctor's red conditions
|
|
5
|
+
# certify Claude's receiver marker and FileChanged/asyncRewake hook rail. Copilot has a
|
|
6
|
+
# receiver too since #82 RAIL 5, but it is a different mechanism owned by a different
|
|
7
|
+
# process (a forked extension) and certified by its own doctor,
|
|
8
|
+
# `doctor-copilot-receive`. This BIRTH doctor certifies birth and who-sent. A shared
|
|
9
|
+
# doctor would make one rail pass on evidence owned by another, which is a branch, not a
|
|
10
|
+
# shared surface.
|
|
11
|
+
#
|
|
12
|
+
# (Until #82 RAIL 5b it had none of the four, and this comment said so. The sender
|
|
13
|
+
# marker joined because who-sent needs a shared parent, not a doorbell — the two facts
|
|
14
|
+
# had been merged under one absence.)
|
|
15
|
+
#
|
|
16
|
+
# WHAT A PASS FROM THIS DOCTOR MEANS, AND WHAT IT DOES NOT. Copilot exposes no plugin
|
|
17
|
+
# load or hook-execution receipt, so this doctor can prove the artifact is correct and
|
|
18
|
+
# REGISTERED, never that Copilot loaded it. A PASS with zero records is therefore
|
|
19
|
+
# consistent with two different worlds — a session not yet spoken to, and a unit Copilot
|
|
20
|
+
# silently never invokes. Only a real first prompt separates them, and that receipt (a
|
|
21
|
+
# record in the store) is what closes admission — not this doctor (cross-review, terra).
|
|
22
|
+
#
|
|
23
|
+
# THE ONE HONEST DIFFERENCE FROM THE CLAUDE DOCTOR. There, a plugin installed with
|
|
24
|
+
# zero meta-records is a SILENT MISS and exits non-zero, because a Claude session
|
|
25
|
+
# mints at session open — so zero records means something ate the hook. A Copilot
|
|
26
|
+
# session is born on its FIRST PROMPT (measured: opening the TUI fires no hook at
|
|
27
|
+
# all), so "installed, zero records" is the ordinary state of a Copilot that has been
|
|
28
|
+
# opened and not yet spoken to. Calling that red would train the operator to ignore
|
|
29
|
+
# the doctor. It is reported as NOT-YET, by name, and the red condition is a hook that
|
|
30
|
+
# RAN and FAILED — an ERROR line this unit wrote to the shared hook log.
|
|
31
|
+
set -uo pipefail
|
|
32
|
+
|
|
33
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
34
|
+
REPO="$(cd "$HERE/.." && pwd)"
|
|
35
|
+
MKT_NAME="meta-bridge-copilot-local"
|
|
36
|
+
PLUGIN="entwurf-meta-receive-copilot"
|
|
37
|
+
# QUALIFIED ids throughout: `copilot plugin list` prints `plugin@marketplace` (measured
|
|
38
|
+
# 2026-08-21), and a bare name would both accept a same-named plugin from somebody
|
|
39
|
+
# else's marketplace as ours and flag theirs as our stale unit (cross-review, terra).
|
|
40
|
+
QUALIFIED="$PLUGIN@$MKT_NAME"
|
|
41
|
+
STALE_CLAUDE_UNIT="entwurf-meta-receive@meta-bridge-local"
|
|
42
|
+
ASM="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge-copilot/.assembled"
|
|
43
|
+
AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
|
|
44
|
+
SESSIONS_DIR="$AGENT_DIR/meta-sessions"
|
|
45
|
+
HOOK_LOG="$AGENT_DIR/meta-bridge-hook.log"
|
|
46
|
+
|
|
47
|
+
fail=0
|
|
48
|
+
ok() { echo " ok $*"; }
|
|
49
|
+
bad() { echo " FAIL $*"; fail=1; }
|
|
50
|
+
note() { echo " note $*"; }
|
|
51
|
+
|
|
52
|
+
echo "[copilot-bridge-doctor] toolchain"
|
|
53
|
+
command -v copilot >/dev/null 2>&1 && ok "copilot CLI on PATH" || bad "copilot CLI missing from PATH"
|
|
54
|
+
command -v node >/dev/null 2>&1 && ok "node on PATH" || bad "node missing from PATH"
|
|
55
|
+
|
|
56
|
+
echo "[copilot-bridge-doctor] assembled artifact"
|
|
57
|
+
if [ -d "$ASM/$PLUGIN" ]; then
|
|
58
|
+
ok "assembly present at $ASM"
|
|
59
|
+
else
|
|
60
|
+
bad "assembly missing at $ASM — run ./run.sh install-copilot-bridge"
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
LAUNCHER="$ASM/$PLUGIN/scripts/copilot-hook-launch.sh"
|
|
64
|
+
HOOKS="$ASM/$PLUGIN/hooks/hooks.json"
|
|
65
|
+
if [ -x "$LAUNCHER" ]; then
|
|
66
|
+
ok "launcher present and executable"
|
|
67
|
+
# The exec form names the launcher as THE executable — a lost +x bit is ENOEXEC at
|
|
68
|
+
# first prompt, not a degraded path.
|
|
69
|
+
if grep -q "__NODE_BIN__\|__HOOK_ENTRY__" "$LAUNCHER"; then
|
|
70
|
+
bad "launcher still carries an installer placeholder (bake did not run)"
|
|
71
|
+
else
|
|
72
|
+
ok "launcher is baked (no placeholders)"
|
|
73
|
+
BAKED_NODE="$(sed -n 's/^NODE_BIN="\(.*\)"$/\1/p' "$LAUNCHER" | head -1)"
|
|
74
|
+
[ -x "$BAKED_NODE" ] && ok "baked node exists: $BAKED_NODE" \
|
|
75
|
+
|| bad "baked node is missing or not executable: ${BAKED_NODE:-(unparsed)} — node moved (NixOS store churn?); reinstall"
|
|
76
|
+
BAKED_ENTRY="$(sed -n 's|^HOOK_ENTRY="\$PLUGIN_ROOT/\(.*\)"$|\1|p' "$LAUNCHER" | head -1)"
|
|
77
|
+
[ -n "$BAKED_ENTRY" ] && [ -f "$ASM/$PLUGIN/$BAKED_ENTRY" ] && ok "hook entry present: $BAKED_ENTRY" \
|
|
78
|
+
|| bad "hook entry missing beside the launcher: ${BAKED_ENTRY:-(unparsed)}"
|
|
79
|
+
fi
|
|
80
|
+
else
|
|
81
|
+
bad "launcher missing or not executable: $LAUNCHER"
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
[ -f "$ASM/$PLUGIN/entwurf-capabilities.json" ] \
|
|
85
|
+
&& ok "capability registry travels at the plugin root (every mint reads it)" \
|
|
86
|
+
|| bad "capability registry missing at the plugin root — every mint would throw"
|
|
87
|
+
|
|
88
|
+
if [ -f "$HOOKS" ]; then
|
|
89
|
+
HOOKS_PATH="$HOOKS" ASM_LAUNCHER="$LAUNCHER" python3 - <<'PY'
|
|
90
|
+
import json, os, sys
|
|
91
|
+
from pathlib import Path
|
|
92
|
+
hooks = json.loads(Path(os.environ["HOOKS_PATH"]).read_text(encoding="utf-8"))
|
|
93
|
+
launcher = os.environ["ASM_LAUNCHER"]
|
|
94
|
+
problems = []
|
|
95
|
+
if hooks.get("version") != 1:
|
|
96
|
+
problems.append("hooks.json root `version` must be the literal 1 (Copilot rejects the plugin otherwise)")
|
|
97
|
+
events = hooks.get("hooks") or {}
|
|
98
|
+
if set(events) != {"sessionStart", "userPromptSubmitted"}:
|
|
99
|
+
problems.append(f"hook events drifted: {sorted(events)} — want exactly ['sessionStart', 'userPromptSubmitted']")
|
|
100
|
+
for name, entries in events.items():
|
|
101
|
+
for i, entry in enumerate(entries if isinstance(entries, list) else []):
|
|
102
|
+
exec_value = entry.get("exec")
|
|
103
|
+
if not isinstance(exec_value, str):
|
|
104
|
+
problems.append(f"hooks.{name}[{i}].exec must be a STRING (an array is rejected at plugin load)")
|
|
105
|
+
elif exec_value != launcher:
|
|
106
|
+
problems.append(f"hooks.{name}[{i}].exec does not point at the assembled launcher: {exec_value}")
|
|
107
|
+
if "args" in entry:
|
|
108
|
+
problems.append(f"hooks.{name}[{i}] carries `args`, which Copilot's schema has no key for")
|
|
109
|
+
for p in problems:
|
|
110
|
+
print(f" FAIL {p}")
|
|
111
|
+
sys.exit(1 if problems else 0)
|
|
112
|
+
PY
|
|
113
|
+
[ $? -eq 0 ] && ok "hooks.json is the Copilot native form, baked at the assembled launcher" || fail=1
|
|
114
|
+
else
|
|
115
|
+
bad "hooks.json missing: $HOOKS"
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
echo "[copilot-bridge-doctor] copilot wiring"
|
|
119
|
+
PLUGIN_LIST="$(copilot plugin list 2>/dev/null)"
|
|
120
|
+
case "$PLUGIN_LIST" in
|
|
121
|
+
*"$QUALIFIED"*)
|
|
122
|
+
# WHAT THIS DOES NOT PROVE. Copilot exposes no load/execution receipt, so a listing
|
|
123
|
+
# says the plugin is REGISTERED, never that Copilot loaded this unit or will invoke
|
|
124
|
+
# its hook. Only a real first prompt settles that — see the birth-evidence section.
|
|
125
|
+
ok "$QUALIFIED is registered in Copilot (registration, not proof it is loaded)" ;;
|
|
126
|
+
*) bad "$QUALIFIED is NOT installed in Copilot — run ./run.sh install-copilot-bridge" ;;
|
|
127
|
+
esac
|
|
128
|
+
case "$PLUGIN_LIST" in
|
|
129
|
+
*"$STALE_CLAUDE_UNIT"*)
|
|
130
|
+
bad "the Claude unit '$STALE_CLAUDE_UNIT' is still installed in Copilot — it fires on every prompt and exits 1 before node starts (Copilot's schema has no args key). Re-run the installer without --keep-stale-claude-unit." ;;
|
|
131
|
+
*) ok "the Claude unit is not installed in Copilot" ;;
|
|
132
|
+
esac
|
|
133
|
+
|
|
134
|
+
echo "[copilot-bridge-doctor] birth evidence"
|
|
135
|
+
COPILOT_RECORDS=0
|
|
136
|
+
if [ -d "$SESSIONS_DIR" ]; then
|
|
137
|
+
COPILOT_RECORDS="$(grep -l '"backend": "copilot"' "$SESSIONS_DIR"/*.meta.json 2>/dev/null | wc -l | tr -d ' ')"
|
|
138
|
+
fi
|
|
139
|
+
if [ "$COPILOT_RECORDS" -gt 0 ]; then
|
|
140
|
+
ok "$COPILOT_RECORDS copilot meta-record(s) in $SESSIONS_DIR — the citizen exists"
|
|
141
|
+
else
|
|
142
|
+
# NOT a failure. See the header: a Copilot session mints on its first prompt, so an
|
|
143
|
+
# opened-but-unspoken session legitimately has no record yet.
|
|
144
|
+
note "no copilot meta-record yet. A Copilot session is born on its FIRST PROMPT, not when"
|
|
145
|
+
note "the window opens — open Copilot, send one prompt, then re-run this doctor."
|
|
146
|
+
note "NOT-YET is only meaningful BEFORE that first prompt. If a Copilot session has"
|
|
147
|
+
note "already been prompted on this host and this still says zero, the unit is not"
|
|
148
|
+
note "being invoked and that IS the failure — Copilot gives no load receipt to tell"
|
|
149
|
+
note "the two apart from here."
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
if [ -f "$HOOK_LOG" ]; then
|
|
153
|
+
# RECOVERY RULE. The hook log is append-only, so a repaired install would otherwise
|
|
154
|
+
# stay red forever on errors it has already outgrown (cross-review, terra). What is
|
|
155
|
+
# red is an ERROR with NO successful mint after it: a failure the unit never recovered
|
|
156
|
+
# from. An ERROR followed by a create/attach is history, and is reported as such.
|
|
157
|
+
#
|
|
158
|
+
# grep -c prints 0 AND exits 1 with no match, so a `|| echo 0` fallback would append a
|
|
159
|
+
# SECOND line and every numeric test below would die on "0\n0". Keep it to one line.
|
|
160
|
+
#
|
|
161
|
+
# MINT ERRORS ONLY. Since #82 RAIL 5b this unit also writes a SENDER marker, and a
|
|
162
|
+
# failed marker write is an ERROR that lands AFTER the successful mint line — so the
|
|
163
|
+
# order rule above would read it as "no successful mint after the error" and print a
|
|
164
|
+
# sentence that is simply false (the record IS there). The two failures are separated
|
|
165
|
+
# here rather than downgraded in the payload, because a marker write that keeps
|
|
166
|
+
# breaking must stay loud somewhere; it just is not a birth failure.
|
|
167
|
+
MINT_ERRORS=' ERROR \[copilot\] (?!sender-marker-)'
|
|
168
|
+
LAST_ERROR_LINE="$(grep -nP "$MINT_ERRORS" "$HOOK_LOG" 2>/dev/null | tail -1 | cut -d: -f1)"
|
|
169
|
+
LAST_OK_LINE="$(grep -n ' INFO \[copilot\] \(create\|attach\) ' "$HOOK_LOG" 2>/dev/null | tail -1 | cut -d: -f1)"
|
|
170
|
+
TOTAL_ERRORS="$(grep -cP "$MINT_ERRORS" "$HOOK_LOG" 2>/dev/null | head -1)"
|
|
171
|
+
TOTAL_ERRORS="${TOTAL_ERRORS:-0}"
|
|
172
|
+
if [ -z "$LAST_ERROR_LINE" ]; then
|
|
173
|
+
ok "no copilot ERROR lines in $HOOK_LOG"
|
|
174
|
+
elif [ -n "$LAST_OK_LINE" ] && [ "$LAST_OK_LINE" -gt "$LAST_ERROR_LINE" ]; then
|
|
175
|
+
note "$TOTAL_ERRORS historical copilot ERROR line(s) in $HOOK_LOG, all followed by a successful mint (line $LAST_OK_LINE > $LAST_ERROR_LINE) — recovered, not red"
|
|
176
|
+
else
|
|
177
|
+
bad "the newest copilot line in $HOOK_LOG is an unrecovered ERROR — the hook RAN and did not mint:"
|
|
178
|
+
grep -P "$MINT_ERRORS" "$HOOK_LOG" | tail -3 | sed 's/^/ /'
|
|
179
|
+
fi
|
|
180
|
+
|
|
181
|
+
# WHO-SENT, judged on its own axis. Both outcomes leave a citizen that EXISTS and can
|
|
182
|
+
# be addressed by others; what is missing is only its ability to send under its own
|
|
183
|
+
# garden id. So a REFUSAL is a note (fail-closed by design — a session opened before
|
|
184
|
+
# the current install reaches the payload without launch provenance and correctly
|
|
185
|
+
# claims no owner), while a failed WRITE is red (we tried and the store would not take
|
|
186
|
+
# it, and nothing downstream will say why).
|
|
187
|
+
MARKER_FAILED="$(grep -c ' ERROR \[copilot\] sender-marker-failed ' "$HOOK_LOG" 2>/dev/null | head -1)"
|
|
188
|
+
MARKER_REFUSED="$(grep -c ' WARN \[copilot\] sender-marker-refused ' "$HOOK_LOG" 2>/dev/null | head -1)"
|
|
189
|
+
MARKER_OK="$(grep -c ' INFO \[copilot\] sender marker ' "$HOOK_LOG" 2>/dev/null | head -1)"
|
|
190
|
+
if [ "${MARKER_FAILED:-0}" -gt 0 ]; then
|
|
191
|
+
bad "${MARKER_FAILED} sender-marker WRITE failure(s) in $HOOK_LOG — those citizens exist but cannot send under their own garden id:"
|
|
192
|
+
grep ' ERROR \[copilot\] sender-marker-failed ' "$HOOK_LOG" | tail -3 | sed 's/^/ /'
|
|
193
|
+
elif [ "${MARKER_REFUSED:-0}" -gt 0 ]; then
|
|
194
|
+
note "${MARKER_REFUSED} sender-marker refusal(s) and ${MARKER_OK:-0} armed — a refusal is fail-closed, not a fault. A session that predates this install reaches the hook without launch provenance; RESTART it to arm who-sent"
|
|
195
|
+
elif [ "${MARKER_OK:-0}" -gt 0 ]; then
|
|
196
|
+
ok "${MARKER_OK} sender marker(s) armed — these citizens send under their own garden id"
|
|
197
|
+
else
|
|
198
|
+
note "no sender-marker lines yet in $HOOK_LOG (nothing has fired since who-sent landed)"
|
|
199
|
+
fi
|
|
200
|
+
else
|
|
201
|
+
note "no hook log yet at $HOOK_LOG (nothing has fired on this host)"
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
echo
|
|
205
|
+
if [ "$fail" -ne 0 ]; then
|
|
206
|
+
echo "[copilot-bridge-doctor] FAIL"
|
|
207
|
+
exit 1
|
|
208
|
+
fi
|
|
209
|
+
echo "[copilot-bridge-doctor] PASS"
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# copilot-bridge-install.sh — operator-grade global installer for the Copilot BIRTH
|
|
3
|
+
# plugin (#82). The Copilot sibling of meta-bridge-install.sh, deliberately NOT a
|
|
4
|
+
# mode of it.
|
|
5
|
+
#
|
|
6
|
+
# WHY A SEPARATE INSTALLER (§4-5, and one measured blocker)
|
|
7
|
+
#
|
|
8
|
+
# 1. The Claude installer assembles ONE marketplace root and copies exactly one
|
|
9
|
+
# plugin out of it (`cp -r "$SRC/$PLUGIN"`). Adding the Copilot unit to that
|
|
10
|
+
# same marketplace.json would publish a manifest whose second `source` is not
|
|
11
|
+
# in the assembly, and `claude plugin validate` runs over that root before any
|
|
12
|
+
# user config is touched. Two marketplace roots, two assemblies (cross-review,
|
|
13
|
+
# terra, 2026-08-20).
|
|
14
|
+
# 2. The Claude doctor certifies Claude's own receiver marker and
|
|
15
|
+
# FileChanged/asyncRewake doorbell. Copilot's receiver is a forked first-party
|
|
16
|
+
# extension with its own installer and its own doctor. Sharing a doctor would
|
|
17
|
+
# make one rail certify evidence owned by another.
|
|
18
|
+
#
|
|
19
|
+
# WHAT THIS INSTALLS. One plugin whose managed job is to mint a meta-record on the
|
|
20
|
+
# first prompt of a Copilot session and name that citizen as this host's sender. MCP
|
|
21
|
+
# wiring has its own installer, and so does the RECEIVER: the doorbell is a forked
|
|
22
|
+
# first-party extension, installed by `run.sh install-copilot-receive`, which also owns
|
|
23
|
+
# the launch-flag check. Four surfaces, four installers, four failure modes.
|
|
24
|
+
#
|
|
25
|
+
# Platform: Linux only, same fence as the Claude installer.
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
|
|
28
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
29
|
+
REPO="$(cd "$HERE/.." && pwd)"
|
|
30
|
+
MKT_NAME="meta-bridge-copilot-local"
|
|
31
|
+
PLUGIN="entwurf-meta-receive-copilot"
|
|
32
|
+
SRC="$REPO/pi/meta-bridge-copilot"
|
|
33
|
+
# `ENTWURF_COPILOT_ASM` exists for ONE caller: check-copilot-birth-hook drives this
|
|
34
|
+
# script for real, into a temp dir, with --assemble-only. A gate that re-implemented
|
|
35
|
+
# the bake would be testing its own copy of the logic instead of the shipped one.
|
|
36
|
+
ASM="${ENTWURF_COPILOT_ASM:-${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/meta-bridge-copilot/.assembled}"
|
|
37
|
+
# The Claude unit that is currently installed INTO Copilot. It fires there and then
|
|
38
|
+
# dies in hook-launch.sh's no-argv refusal on every prompt, because Copilot's schema
|
|
39
|
+
# has no `args` (measured: that refusal is why copilot held 0 of 409 meta-records).
|
|
40
|
+
#
|
|
41
|
+
# QUALIFIED, never bare. `copilot plugin uninstall` accepts `plugin-name` or
|
|
42
|
+
# `plugin-name@marketplace-name` (measured 2026-08-21 from `plugin uninstall --help`),
|
|
43
|
+
# and `copilot plugin list` prints the qualified id. A bare name would also match a
|
|
44
|
+
# same-named plugin from somebody else's marketplace — this installer may only remove
|
|
45
|
+
# the unit THIS repo installed (cross-review, terra).
|
|
46
|
+
STALE_CLAUDE_UNIT="entwurf-meta-receive@meta-bridge-local"
|
|
47
|
+
QUALIFIED="$PLUGIN@$MKT_NAME"
|
|
48
|
+
KEEP_STALE=0
|
|
49
|
+
# --assemble-only stops after the assembly, before the Copilot CLI is touched. It is
|
|
50
|
+
# how the gate reaches the real bake on a host (and in CI) that has no Copilot.
|
|
51
|
+
ASSEMBLE_ONLY=0
|
|
52
|
+
|
|
53
|
+
die() { echo "[copilot-bridge-install] $*" >&2; exit 1; }
|
|
54
|
+
|
|
55
|
+
for arg in "$@"; do
|
|
56
|
+
case "$arg" in
|
|
57
|
+
--keep-stale-claude-unit) KEEP_STALE=1 ;;
|
|
58
|
+
--assemble-only) ASSEMBLE_ONLY=1 ;;
|
|
59
|
+
*) die "unknown argument: $arg (--keep-stale-claude-unit, --assemble-only)" ;;
|
|
60
|
+
esac
|
|
61
|
+
done
|
|
62
|
+
|
|
63
|
+
[ "$(uname -s)" = "Linux" ] || die "Linux only; $(uname -s) is not a certified axis for this install."
|
|
64
|
+
if [ "$ASSEMBLE_ONLY" -eq 0 ]; then
|
|
65
|
+
command -v copilot >/dev/null 2>&1 || die "the 'copilot' CLI is not on PATH."
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
# PREFLIGHT, and all of it BEFORE the first destructive step. The assembly is removed
|
|
69
|
+
# and rebuilt in place further down; discovering a missing python3 or an under-floor
|
|
70
|
+
# node AFTER that would leave the operator with no assembly at all (cross-review, terra).
|
|
71
|
+
command -v python3 >/dev/null 2>&1 || die "python3 is required (both bakes run through it) and is not on PATH."
|
|
72
|
+
NODE_BIN="$(command -v node)" || die "node is not on PATH."
|
|
73
|
+
[ -x "$NODE_BIN" ] || die "resolved node is not executable: $NODE_BIN"
|
|
74
|
+
NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
|
|
75
|
+
# The dev-clone shape bakes the raw `.ts` entry and relies on strip-types, so an
|
|
76
|
+
# under-floor node does not fail at install — it fails at the operator's first prompt,
|
|
77
|
+
# inside Copilot, where nothing surfaces it. Refuse here instead.
|
|
78
|
+
[ "$NODE_MAJOR" -ge 24 ] 2>/dev/null || die "node >= 24 is required (package engines); resolved $NODE_BIN reports major '$NODE_MAJOR'."
|
|
79
|
+
|
|
80
|
+
# Same install-shape split as the Claude installer: an installed package lives below
|
|
81
|
+
# node_modules where Node refuses strip-types on `.ts`, so it runs the tsc-emitted
|
|
82
|
+
# closure; a dev clone (and the XDG artifact) runs the `.ts` source.
|
|
83
|
+
case "$REPO" in
|
|
84
|
+
*/node_modules/@junghanacs/entwurf)
|
|
85
|
+
HOOK_ENTRY="meta-bridge-hook-copilot.js"
|
|
86
|
+
HOOK_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js"
|
|
87
|
+
LIB_SRC="$REPO/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js"
|
|
88
|
+
LIB_EXT="js" ;;
|
|
89
|
+
*)
|
|
90
|
+
HOOK_ENTRY="meta-bridge-hook-copilot.ts"
|
|
91
|
+
HOOK_SRC="$REPO/pi-extensions/meta-bridge-hook-copilot.ts"
|
|
92
|
+
LIB_SRC="$REPO/pi-extensions/lib/meta-session.ts"
|
|
93
|
+
LIB_EXT="ts" ;;
|
|
94
|
+
esac
|
|
95
|
+
|
|
96
|
+
# --- 1. assemble a self-contained, baked plugin -----------------------------
|
|
97
|
+
[ -f "$HOOK_SRC" ] || die "hook artifact missing: $HOOK_SRC (run 'pnpm run build-bridge' in a dev clone, or reinstall the package)."
|
|
98
|
+
[ -f "$LIB_SRC" ] || die "hook lib artifact missing: $LIB_SRC (same build-bridge dist closure)."
|
|
99
|
+
|
|
100
|
+
# ATOMIC: assemble beside the live artifact and swap only once it is complete. The
|
|
101
|
+
# previous shape removed the live assembly first, so any failure between there and the
|
|
102
|
+
# marketplace re-registration left the INSTALLED plugin pointing at a launcher that no
|
|
103
|
+
# longer existed — a working hook broken by a failed upgrade (cross-review, terra).
|
|
104
|
+
STAGE="$ASM.staging.$$"
|
|
105
|
+
rm -rf "$STAGE"
|
|
106
|
+
trap 'rm -rf "$STAGE"' EXIT
|
|
107
|
+
mkdir -p "$STAGE"
|
|
108
|
+
cp -r "$SRC/.claude-plugin" "$STAGE/.claude-plugin"
|
|
109
|
+
cp -r "$SRC/$PLUGIN" "$STAGE/$PLUGIN"
|
|
110
|
+
cp "$HOOK_SRC" "$STAGE/$PLUGIN/$HOOK_ENTRY"
|
|
111
|
+
mkdir -p "$STAGE/$PLUGIN/lib"
|
|
112
|
+
cp "$LIB_SRC" "$STAGE/$PLUGIN/lib/meta-session.$LIB_EXT"
|
|
113
|
+
cp "$REPO/pi-extensions/lib/session-id.js" "$STAGE/$PLUGIN/lib/session-id.js"
|
|
114
|
+
# The capability registry must travel at the plugin ROOT: metaCapabilitiesFilePath()
|
|
115
|
+
# resolves it via `../` from lib/ in the bundle layout. Without it every mint throws.
|
|
116
|
+
cp "$REPO/pi/entwurf-capabilities.json" "$STAGE/$PLUGIN/entwurf-capabilities.json"
|
|
117
|
+
|
|
118
|
+
LAUNCHER="$STAGE/$PLUGIN/scripts/copilot-hook-launch.sh"
|
|
119
|
+
# What gets BAKED into hooks.json is the FINAL path, not the staging one: the manifest
|
|
120
|
+
# has to name where the launcher will live after the swap.
|
|
121
|
+
FINAL_LAUNCHER="$ASM/$PLUGIN/scripts/copilot-hook-launch.sh"
|
|
122
|
+
chmod +x "$LAUNCHER"
|
|
123
|
+
|
|
124
|
+
# Bake node + entry into the LAUNCHER (not into hooks.json): Copilot's `exec` is a
|
|
125
|
+
# single string with no argv beside it, so the payload coordinates cannot ride the
|
|
126
|
+
# manifest the way Claude's `args` array carries them.
|
|
127
|
+
LAUNCHER_PATH="$LAUNCHER" NODE_PATH_TO_BAKE="$NODE_BIN" HOOK_ENTRY_TO_BAKE="$HOOK_ENTRY" python3 - <<'PY'
|
|
128
|
+
from pathlib import Path
|
|
129
|
+
import os
|
|
130
|
+
launcher = Path(os.environ["LAUNCHER_PATH"])
|
|
131
|
+
node = os.environ["NODE_PATH_TO_BAKE"]
|
|
132
|
+
hook_entry = os.environ["HOOK_ENTRY_TO_BAKE"]
|
|
133
|
+
text = launcher.read_text(encoding="utf-8")
|
|
134
|
+
for placeholder in ("__NODE_BIN__", "__HOOK_ENTRY__"):
|
|
135
|
+
if placeholder not in text:
|
|
136
|
+
raise SystemExit(f"launcher bake failed before replacement ({placeholder} absent in {launcher})")
|
|
137
|
+
launcher.write_text(text.replace("__NODE_BIN__", node).replace("__HOOK_ENTRY__", hook_entry), encoding="utf-8")
|
|
138
|
+
PY
|
|
139
|
+
for placeholder in "__NODE_BIN__" "__HOOK_ENTRY__"; do
|
|
140
|
+
grep -q "$placeholder" "$LAUNCHER" && die "launcher bake failed ($placeholder still present in $LAUNCHER)."
|
|
141
|
+
done
|
|
142
|
+
|
|
143
|
+
# Bake the launcher's ABSOLUTE path into hooks.json. `${COPILOT_PLUGIN_ROOT}` and
|
|
144
|
+
# `${CLAUDE_PLUGIN_ROOT}` both appear as recognized names in the shipped runtime, but
|
|
145
|
+
# whether an `exec` string is substituted was never measured — and the assembly path
|
|
146
|
+
# is stable and outside the checkout, so baking needs no such bet.
|
|
147
|
+
HOOKS="$STAGE/$PLUGIN/hooks/hooks.json"
|
|
148
|
+
HOOKS_PATH="$HOOKS" LAUNCHER_TO_BAKE="$FINAL_LAUNCHER" python3 - <<'PY'
|
|
149
|
+
from pathlib import Path
|
|
150
|
+
import os
|
|
151
|
+
hooks = Path(os.environ["HOOKS_PATH"])
|
|
152
|
+
launcher = os.environ["LAUNCHER_TO_BAKE"]
|
|
153
|
+
text = hooks.read_text(encoding="utf-8")
|
|
154
|
+
if "__COPILOT_LAUNCHER__" not in text:
|
|
155
|
+
raise SystemExit(f"hooks bake failed before replacement (__COPILOT_LAUNCHER__ absent in {hooks})")
|
|
156
|
+
hooks.write_text(text.replace("__COPILOT_LAUNCHER__", launcher), encoding="utf-8")
|
|
157
|
+
PY
|
|
158
|
+
grep -q "__COPILOT_LAUNCHER__" "$HOOKS" && die "hooks bake failed (__COPILOT_LAUNCHER__ still present in $HOOKS)."
|
|
159
|
+
|
|
160
|
+
# SWAP. Move the old assembly aside rather than deleting it, so a failed rename can be
|
|
161
|
+
# undone; only then drop it.
|
|
162
|
+
PREV=""
|
|
163
|
+
if [ -d "$ASM" ]; then
|
|
164
|
+
PREV="$ASM.previous.$$"
|
|
165
|
+
mv "$ASM" "$PREV" || die "could not move the previous assembly aside ($ASM)."
|
|
166
|
+
fi
|
|
167
|
+
mkdir -p "$(dirname "$ASM")"
|
|
168
|
+
if ! mv "$STAGE" "$ASM"; then
|
|
169
|
+
[ -n "$PREV" ] && mv "$PREV" "$ASM"
|
|
170
|
+
die "could not publish the staged assembly into $ASM (previous assembly restored)."
|
|
171
|
+
fi
|
|
172
|
+
trap - EXIT
|
|
173
|
+
[ -n "$PREV" ] && rm -rf "$PREV"
|
|
174
|
+
|
|
175
|
+
echo "[copilot-bridge-install] assembled $ASM (birth plugin only; MCP and receive are separate surfaces)"
|
|
176
|
+
|
|
177
|
+
if [ "$ASSEMBLE_ONLY" -eq 1 ]; then
|
|
178
|
+
echo "[copilot-bridge-install] --assemble-only: stopping before the Copilot CLI is touched"
|
|
179
|
+
exit 0
|
|
180
|
+
fi
|
|
181
|
+
|
|
182
|
+
# --- 2. retire the stale Claude unit from Copilot ---------------------------
|
|
183
|
+
# It is ours, it fires on every Copilot prompt, and it exits 1 before node starts.
|
|
184
|
+
# Leaving it installed means the operator keeps paying for a hook that cannot work.
|
|
185
|
+
if [ "$KEEP_STALE" -eq 0 ]; then
|
|
186
|
+
# ASK FIRST, then act. Treating every nonzero uninstall as "not present" would report
|
|
187
|
+
# a CLI, config or permission failure as an absence and then install on top of a unit
|
|
188
|
+
# that is still firing (cross-review, terra). `copilot plugin list` prints the
|
|
189
|
+
# QUALIFIED id, which is also what distinguishes our unit from a same-named plugin
|
|
190
|
+
# somebody else installed from another marketplace.
|
|
191
|
+
# The LIST failing is not an empty list. `|| true` would hand the "nothing installed"
|
|
192
|
+
# arm a broken, unauthenticated or permission-denied Copilot CLI and then install on
|
|
193
|
+
# top of whatever is really there (cross-review, terra). The assignment IS the
|
|
194
|
+
# condition so `set -e` cannot kill us before we can say why.
|
|
195
|
+
if ! INSTALLED_LIST="$(copilot plugin list 2>/dev/null)"; then
|
|
196
|
+
die "'copilot plugin list' failed, so this host's installed plugins are UNKNOWN. An unknown list is not an empty one — the stale Claude unit may still be firing. Fix the Copilot CLI error, or re-run with --keep-stale-claude-unit to skip this check deliberately."
|
|
197
|
+
fi
|
|
198
|
+
case "$INSTALLED_LIST" in
|
|
199
|
+
*"$STALE_CLAUDE_UNIT"*)
|
|
200
|
+
copilot plugin uninstall "$STALE_CLAUDE_UNIT" >/dev/null \
|
|
201
|
+
|| die "the stale Claude unit '$STALE_CLAUDE_UNIT' is installed in Copilot but could not be removed. It fires on every prompt and exits 1 before node starts, so installing on top of it would leave that noise in place. Fix the Copilot CLI error above, or re-run with --keep-stale-claude-unit to proceed deliberately."
|
|
202
|
+
echo "[copilot-bridge-install] removed the stale Claude unit '$STALE_CLAUDE_UNIT' from Copilot (it exits 1 on every prompt there)"
|
|
203
|
+
;;
|
|
204
|
+
*)
|
|
205
|
+
echo "[copilot-bridge-install] stale Claude unit '$STALE_CLAUDE_UNIT' is not installed in Copilot (nothing to remove)"
|
|
206
|
+
;;
|
|
207
|
+
esac
|
|
208
|
+
fi
|
|
209
|
+
|
|
210
|
+
# --- 3. (re)register the marketplace and install ----------------------------
|
|
211
|
+
copilot plugin uninstall "$QUALIFIED" >/dev/null 2>&1 || true
|
|
212
|
+
copilot plugin marketplace remove "$MKT_NAME" >/dev/null 2>&1 || true
|
|
213
|
+
copilot plugin marketplace add "$ASM" >/dev/null || die "'copilot plugin marketplace add $ASM' failed."
|
|
214
|
+
copilot plugin install "$QUALIFIED" >/dev/null || die "'copilot plugin install $QUALIFIED' failed."
|
|
215
|
+
|
|
216
|
+
# --- 4. evidence ------------------------------------------------------------
|
|
217
|
+
echo "--- copilot plugin list ---"
|
|
218
|
+
# The qualified id, not the bare name: a bare grep would be satisfied by a same-named
|
|
219
|
+
# plugin from another marketplace and report an install that never happened.
|
|
220
|
+
copilot plugin list 2>/dev/null | grep -F "$QUALIFIED" || die "post-install: $QUALIFIED is not in 'copilot plugin list' (install did not take)."
|
|
221
|
+
echo
|
|
222
|
+
echo "[copilot-bridge-install] DONE. A Copilot session becomes a citizen on its FIRST PROMPT,"
|
|
223
|
+
echo "not when the window opens — sessionStart is deferred to the first prompt (measured)."
|
|
224
|
+
echo "Verify with: ./run.sh doctor-copilot-bridge"
|