@junghanacs/entwurf 0.14.2 → 0.15.1

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.
Files changed (87) hide show
  1. package/AGENTS.md +9 -5
  2. package/CHANGELOG.md +149 -0
  3. package/DELIVERY.md +72 -59
  4. package/README.md +120 -20
  5. package/VERIFY.md +10 -7
  6. package/demo/README.md +1 -1
  7. package/docs/acp-backend-rail.md +2 -2
  8. package/docs/external-mcp-host.md +26 -4
  9. package/docs/setup-clean-host.md +118 -18
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
  11. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
  16. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
  17. package/mcp/entwurf-bridge/src/index.ts +10 -6
  18. package/mcp/entwurf-bridge/tsconfig.build.json +2 -1
  19. package/package.json +22 -11
  20. package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
  21. package/pi/entwurf-capabilities.json +1 -0
  22. package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
  23. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
  24. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
  25. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
  26. package/pi-extensions/entwurf-control.ts +17 -8
  27. package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
  28. package/pi-extensions/lib/meta-sender-identity.ts +10 -2
  29. package/pi-extensions/lib/meta-session.ts +102 -29
  30. package/pi-extensions/lib/mux-fresh-call.ts +82 -11
  31. package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
  32. package/run.sh +1079 -186
  33. package/scripts/agy-bridge.sh +4 -2
  34. package/scripts/check-copilot-birth-hook.ts +1174 -0
  35. package/scripts/check-copilot-launch.ts +395 -0
  36. package/scripts/check-copilot-receive-arm.ts +734 -0
  37. package/scripts/check-copilot-statusline.ts +122 -0
  38. package/scripts/check-entwurf-capabilities.ts +25 -6
  39. package/scripts/check-fresh-cut-gate.sh +104 -27
  40. package/scripts/check-gate-qualification.ts +10 -1
  41. package/scripts/check-install-container.sh +2 -2
  42. package/scripts/check-install-surface.ts +1 -1
  43. package/scripts/check-meta-capability-source.ts +89 -5
  44. package/scripts/check-meta-doctor-oracle.sh +26 -0
  45. package/scripts/check-meta-manifest-schema.py +38 -0
  46. package/scripts/check-meta-receiver-marker.ts +25 -2
  47. package/scripts/check-meta-session.ts +32 -0
  48. package/scripts/check-setup-qualification.sh +126 -0
  49. package/scripts/copilot-bridge-doctor.sh +242 -0
  50. package/scripts/copilot-bridge-install.sh +384 -0
  51. package/scripts/copilot-bridge-oracle.sh +223 -0
  52. package/scripts/copilot-bridge-uninstall.sh +137 -0
  53. package/scripts/copilot-launch.sh +236 -0
  54. package/scripts/copilot-mcp-bridge.sh +177 -0
  55. package/scripts/copilot-mcp-config.py +221 -0
  56. package/scripts/copilot-receive-bridge.sh +483 -0
  57. package/scripts/copilot-statusline-bridge.sh +149 -0
  58. package/scripts/copilot-statusline-config.py +201 -0
  59. package/scripts/copilot-statusline.sh +68 -0
  60. package/scripts/dev-bin.sh +53 -9
  61. package/scripts/fake-copilot-vendor.sh +67 -0
  62. package/scripts/meta-bridge-hook-log.sh +17 -5
  63. package/scripts/mutants/capability-cache.json +64 -0
  64. package/scripts/mutants/copilot-birth.json +244 -0
  65. package/scripts/mutants/copilot-launch.json +187 -0
  66. package/scripts/mutants/copilot-receive.json +263 -0
  67. package/scripts/mutants/fresh-cut.json +17 -0
  68. package/scripts/mutants/mux-fresh-call.json +216 -2
  69. package/scripts/mutants/pack-install.json +17 -0
  70. package/scripts/mutants/pi-package-ownership.json +92 -0
  71. package/scripts/mutants/setup-verdict.json +148 -0
  72. package/scripts/mutants/source-install.json +32 -0
  73. package/scripts/pi_settings_io.py +22 -0
  74. package/scripts/raw-async-delivery/README.md +170 -125
  75. package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
  76. package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
  77. package/scripts/register-pi-package.py +525 -7
  78. package/scripts/register-pi-provider.py +162 -12
  79. package/scripts/smoke-agy-install-state.sh +86 -30
  80. package/scripts/smoke-agy-statusline-state.sh +9 -6
  81. package/scripts/smoke-copilot-mcp-state.sh +153 -0
  82. package/scripts/smoke-copilot-statusline-state.sh +131 -0
  83. package/scripts/smoke-mux-fresh-call-live.ts +2 -0
  84. package/scripts/smoke-mux-lifecycle-live.ts +3 -1
  85. package/scripts/smoke-setup-verdict.sh +291 -0
  86. package/scripts/smoke-user-scope-citizen.sh +388 -1
  87. 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