@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,483 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# copilot-receive-bridge.sh — install / uninstall / doctor for the Copilot RECEIVER
|
|
3
|
+
# extension (#82 RAIL 5). The fourth Copilot surface, and deliberately its own:
|
|
4
|
+
# birth (plugin hook), statusline, MCP hand and receive are four independent atoms
|
|
5
|
+
# with four independent failure modes, and one installer that owned all of them
|
|
6
|
+
# would make a broken statusline able to withhold a doorbell.
|
|
7
|
+
#
|
|
8
|
+
# WHY A DIFFERENT DISCOVERY SURFACE FROM THE BIRTH PLUGIN. Birth is a Copilot PLUGIN
|
|
9
|
+
# (a marketplace unit with hooks.json). This is a Copilot EXTENSION: a forked child
|
|
10
|
+
# process that speaks JSON-RPC over stdio. The vendor discovers extensions by scanning
|
|
11
|
+
# `.github/extensions/` (project, interactive only) and the user extensions directory —
|
|
12
|
+
# `~/.copilot/extensions/<name>/extension.mjs` — so USER scope is the one an installer
|
|
13
|
+
# can own for every cwd and hand back cleanly. A per-repository `.github/extensions/`
|
|
14
|
+
# would arm in one checkout and silently not arm anywhere else.
|
|
15
|
+
#
|
|
16
|
+
# WHAT IT OWNS, AND WHAT IT CANNOT. It owns the artifact (its own directory, recorded
|
|
17
|
+
# in an install-state file, removed only from that record) and it CHECKS the launch
|
|
18
|
+
# flag. It cannot set that flag from here: `COPILOT_CLI_ENABLED_FEATURE_FLAGS=EXTENSIONS`
|
|
19
|
+
# is read from the environment of the `copilot` launch itself, and an installer is not a
|
|
20
|
+
# launch. The managed launch `run.sh copilot` sets it for one invocation; a session started
|
|
21
|
+
# any other way without it never scans for extensions and never says so — which is exactly
|
|
22
|
+
# why the doctor reads the live CLI processes instead of trusting a green artifact.
|
|
23
|
+
#
|
|
24
|
+
# COMPILED JS ONLY. The extension is executed by the CLI's own Node, whose version and
|
|
25
|
+
# type-stripping support are not ours to assume, so the install copies the tsc-emitted
|
|
26
|
+
# closure. A dev clone that has not built the bridge is refused with the build command.
|
|
27
|
+
set -euo pipefail
|
|
28
|
+
|
|
29
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
30
|
+
REPO_DIR="$(cd "$HERE/.." && pwd)"
|
|
31
|
+
UNIT="entwurf-receive"
|
|
32
|
+
SRC="$REPO_DIR/pi/copilot-receive/$UNIT"
|
|
33
|
+
EXT_ROOT="${COPILOT_EXTENSIONS_DIR:-$HOME/.copilot/extensions}"
|
|
34
|
+
DEST="$EXT_ROOT/$UNIT"
|
|
35
|
+
STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/copilot-receive"
|
|
36
|
+
STATE_FILE="$STATE_DIR/install-state.json"
|
|
37
|
+
AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
|
|
38
|
+
RECEIVE_LOG="$AGENT_DIR/meta-bridge-receive-copilot.log"
|
|
39
|
+
RECEIVERS_DIR="$AGENT_DIR/meta-receivers"
|
|
40
|
+
FLAG_ENV="COPILOT_CLI_ENABLED_FEATURE_FLAGS"
|
|
41
|
+
FLAG_VALUE="EXTENSIONS"
|
|
42
|
+
|
|
43
|
+
log() { printf '%s\n' "$*"; }
|
|
44
|
+
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
|
45
|
+
|
|
46
|
+
# The compiled closure the unit carries. Same layout split as the birth installer: an
|
|
47
|
+
# installed package ships dist beside the sources; a dev clone builds it with
|
|
48
|
+
# `pnpm run build-bridge`. Both resolve to the SAME dist path, so this is one branch
|
|
49
|
+
# fewer than birth needs (birth can fall back to `.ts` under the operator's own node;
|
|
50
|
+
# here the runtime is the vendor's).
|
|
51
|
+
#
|
|
52
|
+
# `ENTWURF_COPILOT_RECEIVE_LIB_DIR` exists for ONE caller, for one structural reason:
|
|
53
|
+
# dist is gitignored, and the gate-qualification snapshot replicates only the git
|
|
54
|
+
# surface — so inside that snapshot there IS no dist, and a gate that needed one would
|
|
55
|
+
# be red for every mutant regardless of the mutation. check-copilot-receive-arm emits
|
|
56
|
+
# the two-file closure into a temp dir and points this at it. It never changes WHAT the
|
|
57
|
+
# installer does, only where it reads the compiled writer from.
|
|
58
|
+
LIB_DIR="${ENTWURF_COPILOT_RECEIVE_LIB_DIR:-$REPO_DIR/mcp/entwurf-bridge/dist/pi-extensions/lib}"
|
|
59
|
+
|
|
60
|
+
do_install() {
|
|
61
|
+
log "[copilot-receive install]"
|
|
62
|
+
log " source: $SRC"
|
|
63
|
+
log " target: $DEST"
|
|
64
|
+
log " state: $STATE_FILE"
|
|
65
|
+
[ -f "$SRC/extension.mjs" ] || fail "receiver source missing: $SRC/extension.mjs"
|
|
66
|
+
[ -f "$LIB_DIR/meta-session.js" ] || fail "compiled lib missing: $LIB_DIR/meta-session.js — run 'pnpm run build-bridge' in a dev clone, or reinstall the package."
|
|
67
|
+
[ -f "$LIB_DIR/session-id.js" ] || fail "compiled lib missing: $LIB_DIR/session-id.js (same build-bridge dist closure)."
|
|
68
|
+
[ -f "$REPO_DIR/pi/entwurf-capabilities.json" ] || fail "capability registry missing: $REPO_DIR/pi/entwurf-capabilities.json"
|
|
69
|
+
|
|
70
|
+
# OWNERSHIP BEFORE WRITING. A directory we did not install is somebody else's
|
|
71
|
+
# extension that happens to share our name; overwriting it would delete their unit
|
|
72
|
+
# and then hand them ours. Only an install-state that names THIS path licenses a
|
|
73
|
+
# replacement (the same rule the MCP adapter holds for a config key).
|
|
74
|
+
if [ -L "$DEST" ]; then
|
|
75
|
+
fail "$DEST is a SYMLINK. Refusing to write through it — remove it by hand if it is yours."
|
|
76
|
+
fi
|
|
77
|
+
if [ -e "$DEST" ] && [ ! -f "$STATE_FILE" ]; then
|
|
78
|
+
fail "$DEST already exists and no entwurf install-state claims it. Refusing to overwrite an extension this installer did not put there."
|
|
79
|
+
fi
|
|
80
|
+
if [ -e "$DEST" ] && [ -f "$STATE_FILE" ]; then
|
|
81
|
+
local claimed
|
|
82
|
+
claimed="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("path",""))' "$STATE_FILE" 2>/dev/null || true)"
|
|
83
|
+
[ "$claimed" = "$DEST" ] || fail "install-state claims '$claimed' but the target is '$DEST' — refusing to overwrite an unclaimed path."
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
# ATOMIC: stage beside the target and swap, so a failure never leaves a half-copied
|
|
87
|
+
# extension that the CLI would fork on the operator's next launch.
|
|
88
|
+
local stage="$DEST.staging.$$"
|
|
89
|
+
rm -rf "$stage"
|
|
90
|
+
trap 'rm -rf "$stage"' EXIT
|
|
91
|
+
mkdir -p "$stage/lib"
|
|
92
|
+
cp "$SRC/extension.mjs" "$stage/extension.mjs"
|
|
93
|
+
cp "$LIB_DIR/meta-session.js" "$stage/lib/meta-session.js"
|
|
94
|
+
cp "$LIB_DIR/session-id.js" "$stage/lib/session-id.js"
|
|
95
|
+
# The registry must sit at the unit ROOT: metaCapabilitiesFilePath() resolves it via
|
|
96
|
+
# `../` from lib/ in the bundle layout. Without it every record read throws.
|
|
97
|
+
cp "$REPO_DIR/pi/entwurf-capabilities.json" "$stage/entwurf-capabilities.json"
|
|
98
|
+
|
|
99
|
+
mkdir -p "$EXT_ROOT"
|
|
100
|
+
local prev=""
|
|
101
|
+
if [ -e "$DEST" ]; then
|
|
102
|
+
prev="$DEST.previous.$$"
|
|
103
|
+
mv "$DEST" "$prev" || fail "could not move the previous receiver aside ($DEST)."
|
|
104
|
+
fi
|
|
105
|
+
if ! mv "$stage" "$DEST"; then
|
|
106
|
+
[ -n "$prev" ] && mv "$prev" "$DEST"
|
|
107
|
+
fail "could not publish the staged receiver into $DEST (previous unit restored)."
|
|
108
|
+
fi
|
|
109
|
+
trap - EXIT
|
|
110
|
+
[ -n "$prev" ] && rm -rf "$prev"
|
|
111
|
+
|
|
112
|
+
mkdir -p "$STATE_DIR"
|
|
113
|
+
# The LIB digest is recorded beside the entry's, and it is not bookkeeping: the unit
|
|
114
|
+
# carries a COPY of the compiled writer, so an entwurf upgrade that is not followed by
|
|
115
|
+
# a reinstall leaves a receiver running last release's writer. Measured while building
|
|
116
|
+
# this lane — a unit holding a pre-`extension-join` lib threw on every arm and the only
|
|
117
|
+
# symptom was a session that never became deliverable. The doctor compares both.
|
|
118
|
+
DEST_PATH="$DEST" UNIT_NAME="$UNIT" STATE_PATH="$STATE_FILE" python3 - <<'PY'
|
|
119
|
+
import hashlib, json, os, pathlib
|
|
120
|
+
dest = os.environ["DEST_PATH"]
|
|
121
|
+
def sha(rel):
|
|
122
|
+
return hashlib.sha256(pathlib.Path(dest, rel).read_bytes()).hexdigest()
|
|
123
|
+
state = {
|
|
124
|
+
"schemaVersion": 1,
|
|
125
|
+
"unit": os.environ["UNIT_NAME"],
|
|
126
|
+
"path": dest,
|
|
127
|
+
"entrySha256": sha("extension.mjs"),
|
|
128
|
+
"libSha256": sha("lib/meta-session.js"),
|
|
129
|
+
"installedAt": __import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
130
|
+
}
|
|
131
|
+
pathlib.Path(os.environ["STATE_PATH"]).write_text(json.dumps(state, indent=2) + "\n")
|
|
132
|
+
PY
|
|
133
|
+
log " ok: installed the receiver unit at $DEST"
|
|
134
|
+
log ""
|
|
135
|
+
log " LAUNCH FLAG — this install does NOT arm anything by itself. Copilot scans for"
|
|
136
|
+
log " extensions only when the CLI is launched with:"
|
|
137
|
+
log ""
|
|
138
|
+
log " $FLAG_ENV=$FLAG_VALUE copilot"
|
|
139
|
+
log ""
|
|
140
|
+
log " Without it the scan is skipped SILENTLY (no error, no log line, no receiver)."
|
|
141
|
+
log " A session already open when this ran is not armed either; it arms on its next"
|
|
142
|
+
log " launch, and only once it has been born (first prompt)."
|
|
143
|
+
log " Verify with: ./run.sh doctor-copilot-receive"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
do_uninstall() {
|
|
147
|
+
log "[copilot-receive uninstall]"
|
|
148
|
+
if [ ! -f "$STATE_FILE" ]; then
|
|
149
|
+
log " note: no install-state at $STATE_FILE — nothing this installer owns to remove."
|
|
150
|
+
return 0
|
|
151
|
+
fi
|
|
152
|
+
local claimed
|
|
153
|
+
claimed="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("path",""))' "$STATE_FILE" 2>/dev/null || true)"
|
|
154
|
+
[ -n "$claimed" ] || fail "install-state at $STATE_FILE names no path — refusing to guess what to remove."
|
|
155
|
+
if [ -L "$claimed" ]; then
|
|
156
|
+
fail "$claimed is a SYMLINK; the install never creates one. Refusing to follow it."
|
|
157
|
+
fi
|
|
158
|
+
if [ -d "$claimed" ]; then
|
|
159
|
+
# Remove what we installed, identified by our own entry file — never a bare
|
|
160
|
+
# recursive delete of a path a state file happens to name.
|
|
161
|
+
[ -f "$claimed/extension.mjs" ] || fail "$claimed does not hold extension.mjs — refusing to remove a directory that is no longer our unit."
|
|
162
|
+
rm -rf "$claimed"
|
|
163
|
+
log " ok: removed $claimed"
|
|
164
|
+
else
|
|
165
|
+
log " note: $claimed is already gone"
|
|
166
|
+
fi
|
|
167
|
+
rm -f "$STATE_FILE"
|
|
168
|
+
log " ok: install-state cleared. Sessions launched from here on will not arm a receiver."
|
|
169
|
+
log " Already-running sessions keep their armed marker until the extension exits."
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
do_doctor() {
|
|
173
|
+
local fail_flag=0
|
|
174
|
+
digest() { python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$1" 2>/dev/null || true; }
|
|
175
|
+
ok() { echo " ok $*"; }
|
|
176
|
+
bad() { echo " FAIL $*"; fail_flag=1; }
|
|
177
|
+
note() { echo " note $*"; }
|
|
178
|
+
|
|
179
|
+
echo "[copilot-receive-doctor] artifact"
|
|
180
|
+
if [ -f "$STATE_FILE" ]; then
|
|
181
|
+
ok "install-state present at $STATE_FILE"
|
|
182
|
+
else
|
|
183
|
+
# Same rule as the Copilot MCP doctor: with nothing installed there is nothing to
|
|
184
|
+
# certify, and a red here would train the operator to ignore the doctor.
|
|
185
|
+
note "no install-state at $STATE_FILE — the receiver is not installed on this host."
|
|
186
|
+
note "Install it with: ./run.sh install-copilot-receive"
|
|
187
|
+
echo
|
|
188
|
+
echo "[copilot-receive-doctor] PASS (nothing installed)"
|
|
189
|
+
return 0
|
|
190
|
+
fi
|
|
191
|
+
local claimed
|
|
192
|
+
claimed="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("path",""))' "$STATE_FILE" 2>/dev/null || true)"
|
|
193
|
+
[ -n "$claimed" ] && ok "install-state names $claimed" || bad "install-state names no path"
|
|
194
|
+
if [ -d "$claimed" ]; then
|
|
195
|
+
ok "unit directory present"
|
|
196
|
+
[ -f "$claimed/extension.mjs" ] && ok "extension.mjs present (the vendor's required entry name)" \
|
|
197
|
+
|| bad "extension.mjs missing — Copilot discovers a unit by that exact file name"
|
|
198
|
+
[ -f "$claimed/lib/meta-session.js" ] && ok "compiled meta-session lib travels with the unit" \
|
|
199
|
+
|| bad "lib/meta-session.js missing — the extension cannot resolve a garden id without it"
|
|
200
|
+
[ -f "$claimed/entwurf-capabilities.json" ] && ok "capability registry travels at the unit root" \
|
|
201
|
+
|| bad "entwurf-capabilities.json missing at the unit root — every record read would throw"
|
|
202
|
+
if [ -f "$claimed/extension.mjs" ]; then
|
|
203
|
+
local want have
|
|
204
|
+
want="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("entrySha256",""))' "$STATE_FILE" 2>/dev/null || true)"
|
|
205
|
+
have="$(digest "$claimed/extension.mjs")"
|
|
206
|
+
if [ -n "$want" ] && [ "$want" = "$have" ]; then
|
|
207
|
+
ok "installed entry matches the install-state digest"
|
|
208
|
+
else
|
|
209
|
+
bad "installed entry does NOT match the install-state digest — it was edited or half-replaced; reinstall"
|
|
210
|
+
fi
|
|
211
|
+
fi
|
|
212
|
+
# STALE DEPLOYMENT — the failure this section exists for. The unit carries a COPY of
|
|
213
|
+
# the compiled writer, so upgrading entwurf without reinstalling the receiver leaves
|
|
214
|
+
# a session running the previous release's writer. It fails at ARM time, inside the
|
|
215
|
+
# extension, where the only symptom is a citizen that never becomes deliverable.
|
|
216
|
+
if [ -f "$claimed/lib/meta-session.js" ] && [ -f "$LIB_DIR/meta-session.js" ]; then
|
|
217
|
+
local dep cur
|
|
218
|
+
dep="$(digest "$claimed/lib/meta-session.js")"
|
|
219
|
+
cur="$(digest "$LIB_DIR/meta-session.js")"
|
|
220
|
+
if [ "$dep" = "$cur" ]; then
|
|
221
|
+
ok "the deployed writer matches this checkout's compiled writer (${dep:0:12})"
|
|
222
|
+
else
|
|
223
|
+
bad "the deployed writer is STALE: unit has ${dep:0:12}, this checkout builds ${cur:0:12}. An arm can fail on a contract the deployed copy does not know, and the only symptom is a session that never becomes deliverable. Re-run ./run.sh install-copilot-receive"
|
|
224
|
+
fi
|
|
225
|
+
elif [ ! -f "$LIB_DIR/meta-session.js" ]; then
|
|
226
|
+
note "no compiled writer in this checkout ($LIB_DIR) — cannot judge whether the deployed one is stale. Build it with 'pnpm run build-bridge'"
|
|
227
|
+
fi
|
|
228
|
+
else
|
|
229
|
+
bad "unit directory missing at $claimed — run ./run.sh install-copilot-receive"
|
|
230
|
+
fi
|
|
231
|
+
|
|
232
|
+
echo "[copilot-receive-doctor] launch flag on live sessions"
|
|
233
|
+
# THE INVISIBLE FAILURE. A Copilot launched without the flag never scans for
|
|
234
|
+
# extensions and prints nothing at all, so a perfectly installed unit stays inert
|
|
235
|
+
# with no symptom anywhere. Reading the live processes' own environment is the only
|
|
236
|
+
# place that silence becomes visible. Linux /proc only; elsewhere it is a note.
|
|
237
|
+
#
|
|
238
|
+
# WHY NOT `pgrep -x copilot` (what this used to do). It matched nothing, ever, and
|
|
239
|
+
# reported that as the benign "no live copilot" note — so the detector built to break
|
|
240
|
+
# a silence was itself silent. Measured 2026-08-23: `type -P copilot` is a pnpm shim,
|
|
241
|
+
# a POSIX shell script whose every branch ends `exec node …/@github/copilot/
|
|
242
|
+
# npm-loader.js "$@"`. `exec` REPLACES the process image, so the thing that survives
|
|
243
|
+
# is node and the comm can never read `copilot`.
|
|
244
|
+
#
|
|
245
|
+
# AND `comm` IS NOT THE FIX EITHER. On the same host `ps -eo comm=` showed 44
|
|
246
|
+
# processes named `MainThread` — that is just what nodejs-slim 24 calls its main
|
|
247
|
+
# thread, shared by the entwurf MCP bridge child and by 42 stub extension children a
|
|
248
|
+
# gate had left behind; other node builds report plain `node`. A cell asserting
|
|
249
|
+
# "comm == MainThread means native Copilot" would be green today and quietly false
|
|
250
|
+
# after a node bump, so comm is used NOWHERE below, as predicate or as claim.
|
|
251
|
+
#
|
|
252
|
+
# WHAT IDENTIFIES A NATIVE CLI is its argv: the vendor entry `@github/copilot/*.js`
|
|
253
|
+
# it was exec'd with, or an argv[0] the operator invoked as `copilot`. What must be
|
|
254
|
+
# EXCLUDED is the vendor's own extension children — they are node processes launched
|
|
255
|
+
# from OUR unit and carry `COPILOT_EXTENSION_PARENT_PID`; counting one as a session
|
|
256
|
+
# would report a receiver as its own missing session.
|
|
257
|
+
if [ ! -d /proc ]; then
|
|
258
|
+
note "no /proc on this platform — cannot read the live CLI environments"
|
|
259
|
+
else
|
|
260
|
+
local pid_seam="" scan_json
|
|
261
|
+
# WHICH PROCESSES ARE CANDIDATES. Normally every pid under /proc.
|
|
262
|
+
# `ENTWURF_COPILOT_RECEIVE_PIDS` (set, even to empty) narrows the candidate set so
|
|
263
|
+
# the gate can hand this branch REAL processes it launched and read their real
|
|
264
|
+
# `/proc/<pid>/{cmdline,environ}`. It narrows candidates ONLY — the identity and
|
|
265
|
+
# flag predicates below are the same production code either way, so the seam can
|
|
266
|
+
# never certify a process the real scan would have rejected.
|
|
267
|
+
if [ -n "${ENTWURF_COPILOT_RECEIVE_PIDS+x}" ]; then
|
|
268
|
+
pid_seam="$ENTWURF_COPILOT_RECEIVE_PIDS"
|
|
269
|
+
fi
|
|
270
|
+
# OWNED FAILURE. Under `set -euo pipefail` a bare `x="$(python3 …)"` hands the
|
|
271
|
+
# interpreter's exit status to the assignment, and `-e` ends the script THERE — before
|
|
272
|
+
# any verdict line, so the operator sees a doctor that stopped mid-section and cannot
|
|
273
|
+
# tell "fine" from "crashed". Same shape as the clean-log grep defect this lane already
|
|
274
|
+
# stepped on once. The status is captured instead, and a broken scan becomes a verdict.
|
|
275
|
+
local scan_rc=0
|
|
276
|
+
scan_json="$(ENTWURF_PID_SEAM="${pid_seam}" ENTWURF_PID_SEAM_SET="${ENTWURF_COPILOT_RECEIVE_PIDS+1}" FLAG_ENV="$FLAG_ENV" FLAG_VALUE="$FLAG_VALUE" python3 - <<'PY'
|
|
277
|
+
import os, pathlib
|
|
278
|
+
|
|
279
|
+
flag_env = os.environ["FLAG_ENV"]
|
|
280
|
+
flag_value = os.environ["FLAG_VALUE"]
|
|
281
|
+
|
|
282
|
+
if os.environ.get("ENTWURF_PID_SEAM_SET"):
|
|
283
|
+
candidates = [p for p in os.environ["ENTWURF_PID_SEAM"].split() if p.isdigit()]
|
|
284
|
+
else:
|
|
285
|
+
candidates = [p.name for p in pathlib.Path("/proc").iterdir() if p.name.isdigit()]
|
|
286
|
+
|
|
287
|
+
def read_nul(pid, what):
|
|
288
|
+
try:
|
|
289
|
+
return pathlib.Path(f"/proc/{pid}/{what}").read_bytes().split(b"\0")
|
|
290
|
+
except OSError:
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
armed_ok, armed_missing, unreadable, contaminated = 0, 0, 0, 0
|
|
294
|
+
missing_pids, unreadable_pids, contaminated_pids = [], [], []
|
|
295
|
+
|
|
296
|
+
for pid in candidates:
|
|
297
|
+
argv = read_nul(pid, "cmdline")
|
|
298
|
+
# No cmdline at all is a kernel thread or a process that exited mid-scan; neither is
|
|
299
|
+
# a Copilot session and neither is evidence of anything.
|
|
300
|
+
if not argv:
|
|
301
|
+
continue
|
|
302
|
+
argv = [a.decode("utf-8", "replace") for a in argv if a != b""]
|
|
303
|
+
if not argv:
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
# EXCLUDE the vendor's extension children first, before any positive match: they are
|
|
307
|
+
# node processes whose argv names an `extension.mjs`. That entry name is the vendor's
|
|
308
|
+
# REQUIRED one — it is what the CLI scans for — so argv alone is load-bearing here and
|
|
309
|
+
# no second signal is read. (The bootstrap also hands these children a
|
|
310
|
+
# COPILOT_EXTENSION_PARENT_PID, but this predicate does not consult it; do not describe
|
|
311
|
+
# an exclusion the code does not perform.)
|
|
312
|
+
if any(a.endswith("extension.mjs") for a in argv):
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
# The positive identity. `@github/copilot/<entry>.js` is the vendor package entry the
|
|
316
|
+
# launcher execs; an argv[0] basename of `copilot` covers an install that ships a
|
|
317
|
+
# real binary under that name instead of a node shim.
|
|
318
|
+
is_native = any("/@github/copilot/" in a and a.endswith(".js") for a in argv)
|
|
319
|
+
if not is_native and os.path.basename(argv[0]) == "copilot":
|
|
320
|
+
is_native = True
|
|
321
|
+
if not is_native:
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
env = read_nul(pid, "environ")
|
|
325
|
+
if env is None:
|
|
326
|
+
# FAIL-CLOSED: we identified a native CLI but cannot read its environment, so its
|
|
327
|
+
# flag state is unknown. That is reported, never rounded to armed.
|
|
328
|
+
unreadable += 1
|
|
329
|
+
unreadable_pids.append(pid)
|
|
330
|
+
continue
|
|
331
|
+
env_map = {}
|
|
332
|
+
for item in env:
|
|
333
|
+
if b"=" in item:
|
|
334
|
+
k, _, v = item.partition(b"=")
|
|
335
|
+
env_map[k.decode("utf-8", "replace")] = v.decode("utf-8", "replace")
|
|
336
|
+
# FOREIGN IDENTITY CARRIERS. The MCP bridge believes a complete PI_SESSION_ID +
|
|
337
|
+
# PI_AGENT_ID pair before it looks for a native sender marker, so a Copilot CLI that
|
|
338
|
+
# inherited them from a pi citizen's bash speaks under the PARENT's garden id. The
|
|
339
|
+
# marker would still be there and correct — which is exactly why this is reported
|
|
340
|
+
# instead of being silently out-voted by it. A managed launch unsets both before exec;
|
|
341
|
+
# seeing them here means this session was NOT started that way.
|
|
342
|
+
if "PI_SESSION_ID" in env_map or "PI_AGENT_ID" in env_map:
|
|
343
|
+
contaminated += 1
|
|
344
|
+
contaminated_pids.append(pid)
|
|
345
|
+
|
|
346
|
+
# An absent flag and a flag without the token are the same failure: no scan happened.
|
|
347
|
+
tokens = [t.strip() for t in (env_map.get(flag_env) or "").split(",")]
|
|
348
|
+
if flag_value in tokens:
|
|
349
|
+
armed_ok += 1
|
|
350
|
+
else:
|
|
351
|
+
armed_missing += 1
|
|
352
|
+
missing_pids.append(pid)
|
|
353
|
+
|
|
354
|
+
print(f"{armed_ok} {armed_missing} {unreadable} {contaminated}")
|
|
355
|
+
print(" ".join(missing_pids))
|
|
356
|
+
print(" ".join(unreadable_pids))
|
|
357
|
+
print(" ".join(contaminated_pids))
|
|
358
|
+
PY
|
|
359
|
+
)" || scan_rc=$?
|
|
360
|
+
local counts armed_ok armed_missing unreadable contaminated missing_pids unreadable_pids contaminated_pids
|
|
361
|
+
counts="$(printf '%s\n' "$scan_json" | sed -n '1p')"
|
|
362
|
+
missing_pids="$(printf '%s\n' "$scan_json" | sed -n '2p')"
|
|
363
|
+
unreadable_pids="$(printf '%s\n' "$scan_json" | sed -n '3p')"
|
|
364
|
+
contaminated_pids="$(printf '%s\n' "$scan_json" | sed -n '4p')"
|
|
365
|
+
armed_ok="$(printf '%s' "$counts" | awk '{print $1}')"
|
|
366
|
+
armed_missing="$(printf '%s' "$counts" | awk '{print $2}')"
|
|
367
|
+
unreadable="$(printf '%s' "$counts" | awk '{print $3}')"
|
|
368
|
+
contaminated="$(printf '%s' "$counts" | awk '{print $4}')"
|
|
369
|
+
if [ "$scan_rc" -ne 0 ] || [ -z "$armed_ok" ]; then
|
|
370
|
+
bad "the /proc scan for live Copilot CLIs FAILED (exit $scan_rc) — the flag axis is UNKNOWN, so an inert session cannot be ruled out. This is a broken doctor, not a clean host."
|
|
371
|
+
elif [ "$armed_ok" -eq 0 ] && [ "$armed_missing" -eq 0 ] && [ "$unreadable" -eq 0 ]; then
|
|
372
|
+
note "no live GitHub Copilot CLI process — start one with 'entwurf copilot' (or $FLAG_ENV=$FLAG_VALUE copilot) to arm a receiver"
|
|
373
|
+
else
|
|
374
|
+
[ "$armed_ok" -gt 0 ] && ok "$armed_ok live Copilot CLI process(es) carry $FLAG_ENV=$FLAG_VALUE"
|
|
375
|
+
if [ "$unreadable" -gt 0 ]; then
|
|
376
|
+
# RED, not a note. These ARE Copilot CLIs — identity already succeeded — and the one
|
|
377
|
+
# thing this section exists to decide about them is unknown. A note would let the
|
|
378
|
+
# doctor end in PASS while a session that can never arm is running, which is the
|
|
379
|
+
# exact false-success the section was written to break.
|
|
380
|
+
bad "$unreadable live Copilot CLI process(es) have an unreadable environment (pids: $unreadable_pids) — their $FLAG_ENV state is UNKNOWN and is NOT assumed armed. Re-run this doctor as the user that owns those sessions."
|
|
381
|
+
fi
|
|
382
|
+
if [ "$armed_missing" -gt 0 ]; then
|
|
383
|
+
bad "$armed_missing live Copilot CLI process(es) lack $FLAG_ENV=$FLAG_VALUE while the receiver is installed (pids: $missing_pids) — relaunch them with 'entwurf copilot', or uninstall the receiver so nothing promises a doorbell"
|
|
384
|
+
fi
|
|
385
|
+
if [ "$contaminated" -gt 0 ]; then
|
|
386
|
+
# RED, and it is a DIFFERENT failure from the flag axis: such a session can be
|
|
387
|
+
# perfectly armed and still send under somebody else's garden id, because the
|
|
388
|
+
# bridge prefers a complete PI carrier pair over the native marker. Naming it is
|
|
389
|
+
# the whole point — a doctor that silently trusted the marker would hide it.
|
|
390
|
+
bad "$contaminated live Copilot CLI process(es) inherited PI_SESSION_ID/PI_AGENT_ID (pids: $contaminated_pids) — they can speak under the PARENT pi citizen's garden id even with a correct native marker. Relaunch them with 'entwurf copilot', which unsets both before exec; a raw 'copilot' started from a pi citizen's shell is unsupported."
|
|
391
|
+
fi
|
|
392
|
+
fi
|
|
393
|
+
fi
|
|
394
|
+
|
|
395
|
+
echo "[copilot-receive-doctor] armed receivers"
|
|
396
|
+
# The production predicate, not a re-implementation: readMetaReceiverMarker folds a
|
|
397
|
+
# dead owner / reused pid to null, so what is counted here is what a sender would
|
|
398
|
+
# actually be allowed to deliver to.
|
|
399
|
+
if [ -d "$RECEIVERS_DIR" ]; then
|
|
400
|
+
local armed_json
|
|
401
|
+
armed_json="$(RECEIVERS_DIR="$RECEIVERS_DIR" LIB="$LIB_DIR/meta-session.js" node --input-type=module -e '
|
|
402
|
+
const { readMetaReceiverMarker } = await import(process.env.LIB);
|
|
403
|
+
const fs = await import("node:fs");
|
|
404
|
+
const path = await import("node:path");
|
|
405
|
+
const dir = process.env.RECEIVERS_DIR;
|
|
406
|
+
let live = 0, stale = 0;
|
|
407
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".json"))) {
|
|
408
|
+
const raw = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
409
|
+
if (raw.backend !== "copilot") continue;
|
|
410
|
+
const m = readMetaReceiverMarker({ markerPath: path.join(dir, f) });
|
|
411
|
+
if (m && m.ownerKind === "copilot-extension") live++; else stale++;
|
|
412
|
+
}
|
|
413
|
+
console.log(JSON.stringify({ live, stale }));
|
|
414
|
+
' 2>/dev/null || echo '{"live":-1,"stale":-1}')"
|
|
415
|
+
local live stale
|
|
416
|
+
live="$(python3 -c 'import json,sys;print(json.loads(sys.argv[1])["live"])' "$armed_json" 2>/dev/null || echo -1)"
|
|
417
|
+
stale="$(python3 -c 'import json,sys;print(json.loads(sys.argv[1])["stale"])' "$armed_json" 2>/dev/null || echo -1)"
|
|
418
|
+
if [ "$live" = "-1" ]; then
|
|
419
|
+
bad "could not read the receiver markers with the production reader (is the compiled lib present at $LIB_DIR?)"
|
|
420
|
+
elif [ "$live" -gt 0 ]; then
|
|
421
|
+
ok "$live live Copilot receiver marker(s) — those citizens are deliverable right now"
|
|
422
|
+
[ "$stale" -gt 0 ] && note "$stale Copilot marker(s) whose owner is gone — retired by the start-key guard, not an error"
|
|
423
|
+
else
|
|
424
|
+
note "no live Copilot receiver marker. A session arms only after it is BORN (first prompt)"
|
|
425
|
+
note "and only when its CLI was launched with $FLAG_ENV=$FLAG_VALUE."
|
|
426
|
+
[ "$stale" -gt 0 ] && note "$stale historical Copilot marker(s) present; all owners are gone"
|
|
427
|
+
fi
|
|
428
|
+
else
|
|
429
|
+
note "no receiver directory yet at $RECEIVERS_DIR (nothing has armed on this host)"
|
|
430
|
+
fi
|
|
431
|
+
|
|
432
|
+
echo "[copilot-receive-doctor] receiver log"
|
|
433
|
+
if [ -f "$RECEIVE_LOG" ]; then
|
|
434
|
+
# Same recovery rule as the birth doctor: this log is append-only, so an ERROR
|
|
435
|
+
# that a later successful arm followed is history, not a live fault.
|
|
436
|
+
#
|
|
437
|
+
# WHY THE `{ grep … || [ "$?" -eq 1 ]; }` GUARD, AND WHY NOT `|| true`: the whole
|
|
438
|
+
# script runs under `set -euo pipefail` (install/uninstall lean on -e for their
|
|
439
|
+
# fail-loud writes), and a no-match grep exits 1 — pipefail turns that into the
|
|
440
|
+
# assignment's status and -e kills the doctor MID-SECTION on a perfectly healthy
|
|
441
|
+
# log, printing no verdict at all (measured on this lane). The guard admits
|
|
442
|
+
# exactly exit 1 ("no match" — an ordinary answer here, kept one line per
|
|
443
|
+
# assignment like the birth doctor's rule) and lets every other grep status
|
|
444
|
+
# propagate: a real read error stays loud and still ends the doctor with a
|
|
445
|
+
# nameable failure.
|
|
446
|
+
local last_err last_ok total_err
|
|
447
|
+
last_err="$({ grep -n ' ERROR \[copilot-receive\] ' "$RECEIVE_LOG" 2>/dev/null || [ "$?" -eq 1 ]; } | tail -1 | cut -d: -f1)"
|
|
448
|
+
last_ok="$({ grep -n ' INFO \[copilot-receive\] armed ' "$RECEIVE_LOG" 2>/dev/null || [ "$?" -eq 1 ]; } | tail -1 | cut -d: -f1)"
|
|
449
|
+
total_err="$({ grep -c ' ERROR \[copilot-receive\] ' "$RECEIVE_LOG" 2>/dev/null || [ "$?" -eq 1 ]; } | head -1)"
|
|
450
|
+
total_err="${total_err:-0}"
|
|
451
|
+
if [ -z "$last_err" ]; then
|
|
452
|
+
ok "no ERROR lines in $RECEIVE_LOG"
|
|
453
|
+
elif [ -n "$last_ok" ] && [ "$last_ok" -gt "$last_err" ]; then
|
|
454
|
+
note "$total_err historical ERROR line(s), all followed by a successful arm (line $last_ok > $last_err) — recovered, not red"
|
|
455
|
+
else
|
|
456
|
+
bad "the newest receiver line in $RECEIVE_LOG is an unrecovered ERROR:"
|
|
457
|
+
grep ' ERROR \[copilot-receive\] ' "$RECEIVE_LOG" | tail -3 | sed 's/^/ /'
|
|
458
|
+
fi
|
|
459
|
+
local refused
|
|
460
|
+
refused="$({ grep -c ' WARN \[copilot-receive\] arm-refused ' "$RECEIVE_LOG" 2>/dev/null || [ "$?" -eq 1 ]; } | head -1)"
|
|
461
|
+
if [ "${refused:-0}" -gt 0 ]; then
|
|
462
|
+
note "${refused} arm refusal(s) — fail-closed answers (id drift or an untrusted parent), not faults:"
|
|
463
|
+
grep ' WARN \[copilot-receive\] arm-refused ' "$RECEIVE_LOG" | tail -2 | sed 's/^/ /'
|
|
464
|
+
fi
|
|
465
|
+
else
|
|
466
|
+
note "no receiver log yet at $RECEIVE_LOG — no extension has joined a session on this host"
|
|
467
|
+
fi
|
|
468
|
+
|
|
469
|
+
echo
|
|
470
|
+
if [ "$fail_flag" -ne 0 ]; then
|
|
471
|
+
echo "[copilot-receive-doctor] FAIL"
|
|
472
|
+
return 1
|
|
473
|
+
fi
|
|
474
|
+
echo "[copilot-receive-doctor] PASS"
|
|
475
|
+
return 0
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
case "${1:-}" in
|
|
479
|
+
install) shift; do_install "$@" ;;
|
|
480
|
+
uninstall) shift; do_uninstall "$@" ;;
|
|
481
|
+
doctor) shift; do_doctor "$@" ;;
|
|
482
|
+
*) fail "usage: copilot-receive-bridge.sh {install|uninstall|doctor}" ;;
|
|
483
|
+
esac
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Copilot custom-footer state adapter; plugin birth and personal settings stay separate.
|
|
3
|
+
# Owns statusLine whole plus footer.showCustom only; dev-bin/npm resolves the bare command.
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
CONFIG_PY="$HERE/copilot-statusline-config.py"
|
|
7
|
+
SETTINGS="${COPILOT_SETTINGS_CONFIG:-$HOME/.copilot/settings.json}"
|
|
8
|
+
COMMAND="${COPILOT_STATUSLINE_COMMAND:-entwurf-copilot-statusline}"
|
|
9
|
+
STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/entwurf/copilot-statusline"
|
|
10
|
+
STATE_FILE="$STATE_DIR/install-state.json"
|
|
11
|
+
log() { printf '%s\n' "$*"; }
|
|
12
|
+
fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
|
|
13
|
+
command_resolvable() {
|
|
14
|
+
case "$1" in
|
|
15
|
+
*/*) [ -x "$1" ] ;;
|
|
16
|
+
*) command -v "$1" >/dev/null 2>&1 ;;
|
|
17
|
+
esac
|
|
18
|
+
}
|
|
19
|
+
run_config() {
|
|
20
|
+
local out rc
|
|
21
|
+
set +e
|
|
22
|
+
out="$(python3 "$CONFIG_PY" "$@" 2>&1)"
|
|
23
|
+
rc=$?
|
|
24
|
+
set -e
|
|
25
|
+
printf '%s\n' "$out"
|
|
26
|
+
return "$rc"
|
|
27
|
+
}
|
|
28
|
+
do_install() {
|
|
29
|
+
log "[copilot-statusline install]"
|
|
30
|
+
log " target: $SETTINGS"
|
|
31
|
+
log " command: $COMMAND"
|
|
32
|
+
log " state: $STATE_FILE"
|
|
33
|
+
if ! command_resolvable "$COMMAND"; then
|
|
34
|
+
log " note: '$COMMAND' is not currently resolvable; install records it and doctor stays red"
|
|
35
|
+
fi
|
|
36
|
+
local out rc
|
|
37
|
+
set +e
|
|
38
|
+
out="$(run_config install "$SETTINGS" "$COMMAND" "$STATE_FILE")"
|
|
39
|
+
rc=$?
|
|
40
|
+
set -e
|
|
41
|
+
case "$rc" in
|
|
42
|
+
0) log " ok: $out" ;;
|
|
43
|
+
3) fail "refused (symlink) — $out" ;;
|
|
44
|
+
4) fail "invalid settings/state — $out" ;;
|
|
45
|
+
*) fail "install error (rc=$rc) — $out" ;;
|
|
46
|
+
esac
|
|
47
|
+
log " installed. Verify with: ./run.sh doctor-copilot-statusline"
|
|
48
|
+
}
|
|
49
|
+
do_uninstall() {
|
|
50
|
+
log "[copilot-statusline uninstall]"
|
|
51
|
+
local out rc
|
|
52
|
+
set +e
|
|
53
|
+
out="$(run_config uninstall "$STATE_FILE")"
|
|
54
|
+
rc=$?
|
|
55
|
+
set -e
|
|
56
|
+
case "$rc" in
|
|
57
|
+
0) log " ok: $out" ;;
|
|
58
|
+
2) log " note: $out" ;;
|
|
59
|
+
3) fail "refused (symlink) — $out" ;;
|
|
60
|
+
*) fail "uninstall error (rc=$rc) — $out" ;;
|
|
61
|
+
esac
|
|
62
|
+
}
|
|
63
|
+
state_target() {
|
|
64
|
+
python3 - "$STATE_FILE" <<'PY'
|
|
65
|
+
import json, os, sys
|
|
66
|
+
try:
|
|
67
|
+
state = json.load(open(sys.argv[1]))
|
|
68
|
+
required = ("settingsExistedBefore", "statusLineExisted", "statusLinePreimage", "footerExisted", "showCustomExisted", "showCustomPreimage")
|
|
69
|
+
if state.get("schemaVersion") != 1 or any(key not in state for key in required): raise ValueError()
|
|
70
|
+
value = state.get("managedSettingsPath")
|
|
71
|
+
if not isinstance(value, str) or not os.path.isabs(value): raise ValueError()
|
|
72
|
+
print(os.path.abspath(value))
|
|
73
|
+
except Exception:
|
|
74
|
+
raise SystemExit(1)
|
|
75
|
+
PY
|
|
76
|
+
}
|
|
77
|
+
do_doctor() {
|
|
78
|
+
log "[copilot-statusline doctor]"
|
|
79
|
+
local hard_fail=0 status out rc
|
|
80
|
+
set +e
|
|
81
|
+
out="$(run_config doctor-static "$SETTINGS" "$COMMAND")"
|
|
82
|
+
rc=$?
|
|
83
|
+
set -e
|
|
84
|
+
status="${out##*$'\n'}"
|
|
85
|
+
if [ "$rc" -ne 0 ]; then
|
|
86
|
+
log " settings: unexpected config reader failure (rc=$rc): $out"
|
|
87
|
+
hard_fail=1
|
|
88
|
+
else
|
|
89
|
+
case "$status" in
|
|
90
|
+
configured\ *)
|
|
91
|
+
if command_resolvable "$COMMAND"; then
|
|
92
|
+
log " settings: configured → '$COMMAND' (resolvable; render receipt not claimed)"
|
|
93
|
+
else
|
|
94
|
+
log " settings: configured → '$COMMAND' DANGLING (not on PATH / not executable)"
|
|
95
|
+
hard_fail=1
|
|
96
|
+
fi ;;
|
|
97
|
+
custom-disabled)
|
|
98
|
+
log " settings: statusLine is ours but footer.showCustom is not true"
|
|
99
|
+
hard_fail=1 ;;
|
|
100
|
+
symlink)
|
|
101
|
+
log " settings: REFUSED symlink (someone else's SSOT)"
|
|
102
|
+
hard_fail=1 ;;
|
|
103
|
+
invalid-json)
|
|
104
|
+
log " settings: INVALID JSON"
|
|
105
|
+
hard_fail=1 ;;
|
|
106
|
+
file-absent|not-ours)
|
|
107
|
+
log " settings: $status (not owned; run install-copilot-statusline to adopt)" ;;
|
|
108
|
+
*)
|
|
109
|
+
log " settings: unexpected status '$out'"
|
|
110
|
+
hard_fail=1 ;;
|
|
111
|
+
esac
|
|
112
|
+
fi
|
|
113
|
+
if [ -f "$STATE_FILE" ]; then
|
|
114
|
+
local managed expected managed_status
|
|
115
|
+
expected="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$SETTINGS")"
|
|
116
|
+
if ! managed="$(state_target)"; then
|
|
117
|
+
log " state: CORRUPT — $STATE_FILE has no absolute managedSettingsPath"
|
|
118
|
+
hard_fail=1
|
|
119
|
+
elif [ "$managed" != "$expected" ]; then
|
|
120
|
+
log " state: FOREIGN TARGET — state manages '$managed', Copilot reads '$expected'"
|
|
121
|
+
hard_fail=1
|
|
122
|
+
else
|
|
123
|
+
managed_status="$(run_config doctor-static "$managed" "$COMMAND" | tail -1)"
|
|
124
|
+
case "$managed_status" in
|
|
125
|
+
configured\ *) log " state: install-state present; managed settings remain configured." ;;
|
|
126
|
+
file-absent)
|
|
127
|
+
log " state: ORPHANED — managed settings disappeared; removing stale state."
|
|
128
|
+
rm -f "$STATE_FILE" ;;
|
|
129
|
+
*)
|
|
130
|
+
log " state: DRIFT — managed settings no longer satisfy the footer contract ($managed_status)."
|
|
131
|
+
hard_fail=1 ;;
|
|
132
|
+
esac
|
|
133
|
+
fi
|
|
134
|
+
else
|
|
135
|
+
log " state: absent (no settings ownership recorded)"
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
if [ "$hard_fail" -ne 0 ]; then
|
|
139
|
+
fail "doctor found a broken Copilot statusLine configuration."
|
|
140
|
+
fi
|
|
141
|
+
log "doctor: ok."
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
case "${1:-}" in
|
|
145
|
+
install) do_install ;;
|
|
146
|
+
uninstall) do_uninstall ;;
|
|
147
|
+
doctor) do_doctor ;;
|
|
148
|
+
*) echo "usage: copilot-statusline-bridge.sh <install|uninstall|doctor>" >&2; exit 2 ;;
|
|
149
|
+
esac
|