@junghanacs/entwurf 0.14.1 → 0.14.2

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 (38) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +34 -0
  3. package/DELIVERY.md +57 -0
  4. package/README.md +1 -1
  5. package/VERIFY.md +4 -4
  6. package/demo/README.md +3 -1
  7. package/demo/demo-baseline.sh +12 -1
  8. package/demo/demo.sh +9 -1
  9. package/docs/acp-backend-rail.md +103 -4
  10. package/docs/setup-clean-host.md +3 -3
  11. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
  12. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
  13. package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
  14. package/package.json +9 -9
  15. package/pi-extensions/lib/acp/backend.ts +229 -9
  16. package/run.sh +70 -25
  17. package/scripts/agy-bridge-config.py +47 -13
  18. package/scripts/agy-bridge.sh +73 -23
  19. package/scripts/check-acp-prompt-lifecycle.ts +221 -9
  20. package/scripts/check-entwurf-bridge-boot.ts +28 -0
  21. package/scripts/check-gate-qualification.ts +3 -2
  22. package/scripts/check-probe-bridge-command.ts +201 -0
  23. package/scripts/check-release-gate-outcomes.ts +54 -1
  24. package/scripts/doctor-pi-provider.ts +155 -51
  25. package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
  26. package/scripts/mutants/bridge-command-boot.json +107 -0
  27. package/scripts/mutants/release-gate.json +13 -0
  28. package/scripts/probe-bridge-command.ts +330 -0
  29. package/scripts/raw-async-delivery/README.md +158 -1
  30. package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
  31. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  32. package/scripts/smoke-agy-install-state.sh +76 -2
  33. package/scripts/smoke-entwurf-chain-live.ts +1 -1
  34. package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
  35. package/scripts/smoke-mux-fresh-call-live.ts +1 -1
  36. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  37. package/scripts/smoke-pi-provider-state.sh +135 -6
  38. package/scripts/smoke-resident-garden-guard.sh +2 -2
@@ -0,0 +1,107 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "lane": "bridge-command-boot",
4
+ "mutants": [
5
+ {
6
+ "claim": "PI-DOCTOR-BOOT-NEGATIVE",
7
+ "title": "the pi doctor goes back to blessing a command that merely RESOLVES — the exact #81 false success, where a host ran ACP turns with no mcp__entwurf-bridge__* tool while the doctor printed ok",
8
+ "subject": "scripts/doctor-pi-provider.ts",
9
+ "find": ["\t\tif (probe.ok) {"],
10
+ "replace": ["\t\tif (probe.ok || true) {"],
11
+ "gate": ["bash", "scripts/smoke-pi-provider-state.sh"],
12
+ "timeoutSeconds": 120,
13
+ "signature": "[QK:PI-DOCTOR-BOOT-NEGATIVE]",
14
+ "signatureSource": "scripts/smoke-pi-provider-state.sh"
15
+ },
16
+ {
17
+ "claim": "AGY-DOCTOR-BOOT-NEGATIVE",
18
+ "title": "the agy doctor stops asking whether the configured command boots and reports the old '(resolvable)' green — a registered bridge agy could never actually call",
19
+ "subject": "scripts/agy-bridge.sh",
20
+ "find": [" if command_boots \"$invocation\"; then"],
21
+ "replace": [" if true; then"],
22
+ "gate": ["bash", "scripts/smoke-agy-install-state.sh"],
23
+ "timeoutSeconds": 300,
24
+ "signature": "[QK:AGY-DOCTOR-BOOT-NEGATIVE]",
25
+ "signatureSource": "scripts/smoke-agy-install-state.sh"
26
+ },
27
+ {
28
+ "claim": "PI-DOCTOR-PROBES-OVERRIDE",
29
+ "title": "the pi doctor skips runtime probing for a non-bare override, so a dead command that shadows the stable bridge returns green as an operator choice",
30
+ "subject": "scripts/doctor-pi-provider.ts",
31
+ "find": ["\tif (!resolvable(cmd)) {"],
32
+ "replace": [
33
+ "\tif (!isBare) {\n\t\tlog(` note: skipped runtime for override '${cmd}'`);\n\t} else if (!resolvable(cmd)) {"
34
+ ],
35
+ "gate": ["bash", "scripts/smoke-pi-provider-state.sh"],
36
+ "timeoutSeconds": 120,
37
+ "signature": "[QK:PI-DOCTOR-PROBES-OVERRIDE]",
38
+ "signatureSource": "scripts/smoke-pi-provider-state.sh"
39
+ },
40
+ {
41
+ "claim": "AGY-DOCTOR-PROBES-ARGS",
42
+ "title": "the agy invocation parser drops configured args, so the doctor boots command defaults while agy executes a failing argv",
43
+ "subject": "scripts/agy-bridge-config.py",
44
+ "find": [" args = server.get(\"args\", [])"],
45
+ "replace": [" args = []"],
46
+ "gate": ["bash", "scripts/smoke-agy-install-state.sh"],
47
+ "timeoutSeconds": 300,
48
+ "signature": "[QK:AGY-DOCTOR-PROBES-ARGS]",
49
+ "signatureSource": "scripts/smoke-agy-install-state.sh"
50
+ },
51
+ {
52
+ "claim": "AGY-DOCTOR-PROBES-ENV",
53
+ "title": "the agy invocation parser drops configured environment, so the doctor boots command defaults while agy executes a failing environment",
54
+ "subject": "scripts/agy-bridge-config.py",
55
+ "find": [" env = server.get(\"env\", {})"],
56
+ "replace": [" env = {}"],
57
+ "gate": ["bash", "scripts/smoke-agy-install-state.sh"],
58
+ "timeoutSeconds": 300,
59
+ "signature": "[QK:AGY-DOCTOR-PROBES-ENV]",
60
+ "signatureSource": "scripts/smoke-agy-install-state.sh"
61
+ },
62
+ {
63
+ "claim": "PROBE-REASON-MISSING-VERB",
64
+ "title": "bridge identity shrinks back to a single tool, so a build missing entwurf_self — the measured #81 session shape — reads as a healthy bridge again",
65
+ "subject": "scripts/probe-bridge-command.ts",
66
+ "find": ["\t\t\t\tconst missing = EXPECTED_TOOLS.filter((t) => !served.has(t));"],
67
+ "replace": ["\t\t\t\tconst missing = [\"entwurf_v2\"].filter((t) => !served.has(t));"],
68
+ "gate": ["bash", "run.sh", "check-probe-bridge-command"],
69
+ "timeoutSeconds": 60,
70
+ "signature": "[QK:PROBE-REASON-MISSING-VERB]",
71
+ "signatureSource": "scripts/check-probe-bridge-command.ts"
72
+ },
73
+ {
74
+ "claim": "PROBE-REQUIRES-INITIALIZE",
75
+ "title": "the probe accepts tools/list before the MCP initialize handshake completes — a launcher the harness cannot initialize reads healthy",
76
+ "subject": "scripts/probe-bridge-command.ts",
77
+ "find": ["\t\t\t\tif (!initialized) {"],
78
+ "replace": ["\t\t\t\tif (false) {"],
79
+ "gate": ["bash", "run.sh", "check-probe-bridge-command"],
80
+ "timeoutSeconds": 60,
81
+ "signature": "[QK:PROBE-REQUIRES-INITIALIZE]",
82
+ "signatureSource": "scripts/check-probe-bridge-command.ts"
83
+ },
84
+ {
85
+ "claim": "PROBE-CLEANUP-ESCALATES",
86
+ "title": "the probe stops escalating past SIGTERM, so a launcher that traps TERM outlives the doctor that spawned it — the probe leaks the broken process it exists to detect",
87
+ "subject": "scripts/probe-bridge-command.ts",
88
+ "find": ["\t\t\t\t\t\tchild.kill(\"SIGKILL\");"],
89
+ "replace": ["\t\t\t\t\t\tvoid 0;"],
90
+ "gate": ["bash", "run.sh", "check-probe-bridge-command"],
91
+ "timeoutSeconds": 60,
92
+ "signature": "[QK:PROBE-CLEANUP-ESCALATES]",
93
+ "signatureSource": "scripts/check-probe-bridge-command.ts"
94
+ },
95
+ {
96
+ "claim": "PROBE-STDIN-EPIPE-GUARD",
97
+ "title": "the async EPIPE from a launcher that died on exec goes unlistened again, so the probe dies on an uncaught stream error and the doctor prints a Node stack trace where the LAUNCHER's own stderr belongs — the #81 diagnosis erased in the one cell that needs it",
98
+ "subject": "scripts/probe-bridge-command.ts",
99
+ "find": ["\t\tchild.stdin.on(\"error\", () => {});"],
100
+ "replace": ["\t\tvoid 0;"],
101
+ "gate": ["bash", "run.sh", "check-probe-bridge-command"],
102
+ "timeoutSeconds": 60,
103
+ "signature": "[QK:PROBE-STDIN-EPIPE-GUARD]",
104
+ "signatureSource": "scripts/check-probe-bridge-command.ts"
105
+ }
106
+ ]
107
+ }
@@ -135,6 +135,19 @@
135
135
  "timeoutSeconds": 180,
136
136
  "signature": "[QK:MUX-LIFECYCLE-IS-RELEASE-MUST]",
137
137
  "signatureSource": "scripts/check-release-gate-outcomes.ts"
138
+ },
139
+ {
140
+ "claim": "PI-DOCTOR-IS-RELEASE-MUST",
141
+ "title": "the pi-provider boot doctor is quietly dropped from the gate — the operator's CONFIGURED bridge invocation is never booted, so a 127 launcher (the #81 relocated cmd-shim class) stays invisible until smoke-acp-bundled-mcp-live sixteen LIVE steps and real model spend later",
142
+ "subject": "run.sh",
143
+ "find": [
144
+ " run_step \"doctor-pi-provider (#81: the operator's CONFIGURED bridge invocation actually boots)\" gate bash \"$self\" doctor-pi-provider"
145
+ ],
146
+ "replace": [" : # pi doctor quietly dropped — the configured invocation is never booted"],
147
+ "gate": ["bash", "run.sh", "check-release-gate-outcomes"],
148
+ "timeoutSeconds": 180,
149
+ "signature": "[QK:PI-DOCTOR-IS-RELEASE-MUST]",
150
+ "signatureSource": "scripts/check-release-gate-outcomes.ts"
138
151
  }
139
152
  ]
140
153
  }
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env node
2
+ // probe-bridge-command — does the EFFECTIVE bridge command actually BOOT and serve MCP?
3
+ //
4
+ // WHY THIS EXISTS (#81): every doctor that judged the bridge wiring asked only whether the
5
+ // configured command RESOLVES — `command -v <bare name>` for pi (doctor-pi-provider) and agy
6
+ // (agy-bridge.sh). Resolvability is not bootability, and the gap is not theoretical: on the
7
+ // reference host `~/.local/bin/entwurf-bridge` was a symlink to the canonical pnpm shim, and
8
+ // that shim derives its target from `$0`. Invoked through the relocated link the derived
9
+ // `../global/...` path does not exist, so the launcher exits 127 and the MCP child never boots
10
+ // — while `command -v` kept answering yes and both doctors kept printing ok. The model in a
11
+ // Pi-hosted ACP turn then had no `mcp__entwurf-bridge__*` tool at all, which is a direct
12
+ // violation of #81's minimum core value (explicit Entwurf bridge connectivity).
13
+ //
14
+ // So this leaf asks the only question that carries the claim: run the command, speak MCP to it,
15
+ // and require the entwurf tool surface back. A green here is about the command the harness
16
+ // actually execs — never a recomputed desired one (the same rule meta-bridge-doctor's delivery
17
+ // self-diagnostic already states for the Claude lane).
18
+ //
19
+ // SIDE EFFECTS: none on operator state. Only `initialize` + `tools/list` are sent — never a
20
+ // `tools/call` — so no lock is taken, no record is written, and no message is delivered. The
21
+ // child is killed as soon as the frame arrives or the deadline passes. This is what makes the
22
+ // probe affordable inside a doctor an operator runs repeatedly.
23
+ //
24
+ // USAGE
25
+ // library: const r = await probeBridgeCommand({ command, args }); // doctor-pi-provider.ts
26
+ // CLI: node probe-bridge-command.ts <command> [args...]
27
+ // node probe-bridge-command.ts --invocation-json '{"command":"…","args":[],"env":{}}'
28
+ // exit 0 = booted and served the entwurf tool surface; 1 = did not (reason on stdout).
29
+ import { spawn } from "node:child_process";
30
+ import { basename } from "node:path";
31
+
32
+ /** Why a probe failed. Each value is a DISTINCT operator situation, never a generic "broken". */
33
+ export type BridgeProbeReason =
34
+ | "ok"
35
+ | "spawn-failed" // the command could not be executed at all (ENOENT / EACCES)
36
+ | "exited-before-tools-list" // it ran and DIED — the relocated-shim 127 class lands here
37
+ | "initialize-failed" // it did not complete the MCP initialize handshake before tools/list
38
+ | "timeout" // it stayed up but never answered — a hung/wrong binary
39
+ | "no-tools-array" // it answered id:2 without result.tools — not an MCP server
40
+ | "tool-set-mismatch"; // an MCP server, but not THIS bridge (foreign binary, or a stale build)
41
+
42
+ export interface BridgeProbeResult {
43
+ ok: boolean;
44
+ reason: BridgeProbeReason;
45
+ /** One line, safe to print in a doctor verdict. Carries rc/signal/stderr head when relevant. */
46
+ detail: string;
47
+ }
48
+
49
+ export interface BridgeProbeOptions {
50
+ command: string;
51
+ args?: string[];
52
+ env?: NodeJS.ProcessEnv;
53
+ timeoutMs?: number;
54
+ }
55
+
56
+ // The EXACT public verb set of the current bridge. Identity is the whole set, not one member:
57
+ // the #81 symptom was a session whose schema lacked `entwurf_self` specifically, and a probe that
58
+ // only looked for `entwurf_v2` would have called that host healthy. Exactness also catches the
59
+ // other direction — a STALE build still serving a retired verb, or a foreign binary that happens
60
+ // to expose an `entwurf_v2`. check-entwurf-bridge-boot binds this list to the real runtime
61
+ // tools/list, so a verb added or retired without updating it turns that gate red.
62
+ export const EXPECTED_TOOLS = [
63
+ "entwurf_v2",
64
+ "entwurf_self",
65
+ "entwurf_peers",
66
+ "entwurf_inbox_read",
67
+ "entwurf_register_native",
68
+ "entwurf_fresh_call",
69
+ "entwurf_resume_call",
70
+ ] as const;
71
+ const DEFAULT_TIMEOUT_MS = 10_000;
72
+ /** How long a probed child gets to die politely before it is killed outright. */
73
+ const CLEANUP_GRACE_MS = 500;
74
+
75
+ /** Collapse captured stderr into one printable line — a doctor verdict must stay one line. */
76
+ function head(text: string, limit = 200): string {
77
+ const flat = text.replace(/\s+/g, " ").trim();
78
+ return flat.length > limit ? `${flat.slice(0, limit)}…` : flat;
79
+ }
80
+
81
+ export function probeBridgeCommand(opts: BridgeProbeOptions): Promise<BridgeProbeResult> {
82
+ const { command, args = [], env, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
83
+ return new Promise((resolve) => {
84
+ // No shell: a bare name goes through the normal PATH lookup the harness itself does,
85
+ // and an argument can never be reinterpreted as shell syntax.
86
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env });
87
+ // `write()` reports a synchronous closed pipe by throwing, but a launcher that dies on exec
88
+ // closes stdin under us and the resulting EPIPE arrives ASYNCHRONOUSLY, as an 'error' event on
89
+ // the stream. Unlistened, Node turns that into an uncaught exception, so the probe dies
90
+ // printing its own stack trace INSTEAD of the launcher's stderr: precisely the diagnosis #81
91
+ // exists to surface, destroyed in the one cell that needs it. The window is real, not
92
+ // theoretical — under CPU contention the child can exec, write its stderr and exit between
93
+ // uv_spawn and our first write, which is how CI saw a doctor verdict with no launcher stderr
94
+ // in it. The `close` handler owns the verdict for that child (with captured stderr), so this
95
+ // listener is installed before anything can write and stays deliberately silent — one reason
96
+ // per failure, never an unhandled probe crash.
97
+ child.stdin.on("error", () => {});
98
+ let stdoutPending = "";
99
+ let stderr = "";
100
+ let settled = false;
101
+ let initialized = false;
102
+ let exited: { code: number | null; signal: NodeJS.Signals | null } | undefined;
103
+ let stderrEnded = false;
104
+
105
+ // This probe deliberately runs whatever command the operator configured — including a
106
+ // foreign or broken one — so it owns closing what it opened. Escalate SIGTERM → SIGKILL
107
+ // once, bounded: cleanup for THIS child, not a supervisor (no retries, no watching, no
108
+ // process-tree walking).
109
+ //
110
+ // The reap is AWAITED before the result resolves, and that ordering is load-bearing: the
111
+ // callers are doctors that print a verdict and `process.exit()` immediately, which tears
112
+ // down any still-pending timer. A fire-and-forget SIGTERM plus a deferred SIGKILL would
113
+ // therefore leave a TERM-ignoring launcher running after the doctor is gone — the probe
114
+ // would leak exactly the kind of broken process it exists to detect.
115
+ const reap = (): Promise<void> =>
116
+ new Promise((finished) => {
117
+ if (child.exitCode !== null || child.signalCode !== null) return finished();
118
+ let reaped = false;
119
+ let hard: ReturnType<typeof setTimeout> | undefined;
120
+ const finish = (): void => {
121
+ if (reaped) return;
122
+ reaped = true;
123
+ if (hard) clearTimeout(hard);
124
+ finished();
125
+ };
126
+ child.once("close", finish);
127
+ try {
128
+ child.kill("SIGTERM");
129
+ } catch {
130
+ return finish(); // already gone
131
+ }
132
+ hard = setTimeout(() => {
133
+ try {
134
+ child.kill("SIGKILL");
135
+ } catch {}
136
+ // SIGKILL cannot be trapped, so `close` follows. The outer bound exists only so a
137
+ // child already reaped by someone else can never hang this promise forever.
138
+ setTimeout(finish, CLEANUP_GRACE_MS);
139
+ }, CLEANUP_GRACE_MS);
140
+ });
141
+
142
+ const done = (reason: BridgeProbeReason, detail: string): void => {
143
+ if (settled) return;
144
+ settled = true;
145
+ clearTimeout(timer);
146
+ void reap().then(() => resolve({ ok: reason === "ok", reason, detail }));
147
+ };
148
+
149
+ const timer = setTimeout(() => {
150
+ const waitingFor = initialized ? "tools/list" : "initialize";
151
+ done(
152
+ "timeout",
153
+ `'${command}' stayed up but never answered ${waitingFor} within ${timeoutMs}ms` +
154
+ (stderr.trim() ? ` — stderr: ${head(stderr)}` : ""),
155
+ );
156
+ }, timeoutMs);
157
+
158
+ child.on("error", (err) => {
159
+ done("spawn-failed", `'${command}' could not be executed: ${String(err)}`);
160
+ });
161
+
162
+ // `ChildProcess` close is normally after the stdio pipes close, but under nested shell
163
+ // capture that ordering has raced on this host. Do not mint a no-stderr diagnosis until the
164
+ // stderr reader ended: the launcher's own final diagnostic is part of the operator verdict.
165
+ const reportExited = (): void => {
166
+ if (!exited || !stderrEnded) return;
167
+ // Reaching here un-settled means the process died before answering id:2. This is the
168
+ // cell the relocated pnpm shim lands in (exit 127), and the stderr head is what tells
169
+ // the operator WHICH launcher hop broke.
170
+ done(
171
+ "exited-before-tools-list",
172
+ `'${command}' exited before answering tools/list (code=${exited.code} signal=${String(exited.signal)})` +
173
+ (stderr.trim() ? ` — stderr: ${head(stderr)}` : " — no stderr"),
174
+ );
175
+ };
176
+ child.on("close", (code, signal) => {
177
+ exited = { code, signal };
178
+ reportExited();
179
+ });
180
+
181
+ child.stderr.on("data", (d) => {
182
+ stderr += String(d);
183
+ });
184
+ child.stderr.on("end", () => {
185
+ stderrEnded = true;
186
+ reportExited();
187
+ });
188
+ child.stdout.on("data", (d) => {
189
+ const chunk = String(d);
190
+ stdoutPending += chunk;
191
+ if (settled) return;
192
+ // MCP frames are newline-delimited JSON-RPC. Consume each complete frame exactly once:
193
+ // replaying the accumulated id:1 response on a later data event would send a second
194
+ // tools/list before the first reply and stop being a sequential handshake.
195
+ const frames = stdoutPending.split("\n");
196
+ stdoutPending = frames.pop() ?? "";
197
+ for (const line of frames) {
198
+ const trimmed = line.trim();
199
+ if (!trimmed) continue;
200
+ let msg: {
201
+ id?: unknown;
202
+ error?: unknown;
203
+ result?: { protocolVersion?: unknown; capabilities?: unknown; serverInfo?: unknown; tools?: unknown };
204
+ };
205
+ try {
206
+ msg = JSON.parse(trimmed);
207
+ } catch {
208
+ continue;
209
+ }
210
+ if (msg?.id === 1) {
211
+ const result = msg.result;
212
+ const valid =
213
+ msg.error === undefined &&
214
+ result?.protocolVersion === "2024-11-05" &&
215
+ typeof result.capabilities === "object" &&
216
+ result.capabilities !== null &&
217
+ typeof result.serverInfo === "object" &&
218
+ result.serverInfo !== null;
219
+ if (!valid) {
220
+ done("initialize-failed", `'${command}' returned an invalid MCP initialize response`);
221
+ return;
222
+ }
223
+ initialized = true;
224
+ sendReadyFrames();
225
+ continue;
226
+ }
227
+ if (msg?.id !== 2) continue;
228
+ if (!initialized) {
229
+ done("initialize-failed", `'${command}' answered tools/list before MCP initialize completed`);
230
+ return;
231
+ }
232
+ const tools = msg?.result?.tools;
233
+ if (!Array.isArray(tools)) {
234
+ done("no-tools-array", `'${command}' answered tools/list without result.tools — not an MCP server`);
235
+ return;
236
+ }
237
+ const names = tools
238
+ .map((t) => (t as { name?: unknown })?.name)
239
+ .filter((n): n is string => typeof n === "string");
240
+ const served = new Set(names);
241
+ const missing = EXPECTED_TOOLS.filter((t) => !served.has(t));
242
+ const unexpected = names.filter((n) => !(EXPECTED_TOOLS as readonly string[]).includes(n));
243
+ if (missing.length > 0 || unexpected.length > 0) {
244
+ // Name BOTH directions: a missing verb is the #81 symptom (the model had no
245
+ // entwurf_self), an unexpected one means this is not the bridge we ship.
246
+ const parts: string[] = [];
247
+ if (missing.length > 0) parts.push(`missing ${missing.join(", ")}`);
248
+ if (unexpected.length > 0) parts.push(`unexpected ${unexpected.join(", ")}`);
249
+ done(
250
+ "tool-set-mismatch",
251
+ `'${command}' is an MCP server but does not serve this bridge's verb set — ${parts.join("; ")} (served: ${names.join(", ") || "none"})`,
252
+ );
253
+ return;
254
+ }
255
+ done("ok", `'${command}' booted and served the exact entwurf verb set (${EXPECTED_TOOLS.length} tools)`);
256
+ return;
257
+ }
258
+ });
259
+
260
+ const send = (obj: unknown): void => {
261
+ try {
262
+ child.stdin.write(`${JSON.stringify(obj)}\n`);
263
+ } catch {
264
+ // Synchronous throws (a destroyed stream) land here; async EPIPE lands on the
265
+ // listener above. Both are the same verdict-free silence.
266
+ }
267
+ };
268
+ const sendReadyFrames = (): void => {
269
+ send({ jsonrpc: "2.0", method: "notifications/initialized" });
270
+ send({ jsonrpc: "2.0", id: 2, method: "tools/list" });
271
+ };
272
+ send({
273
+ jsonrpc: "2.0",
274
+ id: 1,
275
+ method: "initialize",
276
+ params: {
277
+ protocolVersion: "2024-11-05",
278
+ capabilities: {},
279
+ clientInfo: { name: "probe-bridge-command", version: "0" },
280
+ },
281
+ });
282
+ });
283
+ }
284
+
285
+ // CLI entrypoint. Kept argv-driven (no env carrier) so `./run.sh probe-bridge-command entwurf-bridge`
286
+ // asks about exactly the name an operator typed, and a doctor can shell out to the same verdict.
287
+ // Match the BASENAME exactly. A suffix test would also fire for `check-probe-bridge-command.ts`,
288
+ // which imports this module — the CLI arm would then hijack that gate's argv and exit 2.
289
+ if (process.argv[1] && /^probe-bridge-command\.(ts|js)$/.test(basename(process.argv[1]))) {
290
+ const argv = process.argv.slice(2);
291
+ let options: BridgeProbeOptions;
292
+ if (argv[0] === "--invocation-json") {
293
+ let raw: unknown;
294
+ try {
295
+ raw = JSON.parse(argv[1] ?? "");
296
+ } catch {
297
+ console.error("probe-bridge-command: --invocation-json must be valid JSON");
298
+ process.exit(2);
299
+ }
300
+ const obj = raw as { command?: unknown; args?: unknown; env?: unknown };
301
+ if (
302
+ typeof obj?.command !== "string" ||
303
+ !obj.command ||
304
+ !Array.isArray(obj.args) ||
305
+ !obj.args.every((arg) => typeof arg === "string") ||
306
+ typeof obj.env !== "object" ||
307
+ obj.env === null ||
308
+ Array.isArray(obj.env) ||
309
+ !Object.values(obj.env).every((value) => typeof value === "string")
310
+ ) {
311
+ console.error("probe-bridge-command: invocation JSON requires string command, string[] args, string-map env");
312
+ process.exit(2);
313
+ }
314
+ options = {
315
+ command: obj.command,
316
+ args: obj.args as string[],
317
+ env: { ...process.env, ...(obj.env as Record<string, string>) },
318
+ };
319
+ } else {
320
+ const [command, ...args] = argv;
321
+ if (!command) {
322
+ console.error("usage: probe-bridge-command <command> [args...]");
323
+ process.exit(2);
324
+ }
325
+ options = { command, args };
326
+ }
327
+ const result = await probeBridgeCommand(options);
328
+ console.log(`[probe-bridge-command] ${result.reason}: ${result.detail}`);
329
+ process.exit(result.ok ? 0 : 1);
330
+ }
@@ -1,4 +1,4 @@
1
- # raw-async-delivery — RAW async message delivery into LIVE Claude Code / agy / Codex sessions
1
+ # raw-async-delivery — RAW async message delivery into LIVE Claude Code / agy / Codex / Copilot sessions
2
2
 
3
3
  Goal: deliver an async message INTO an already-running **subscription** session,
4
4
  free, with no `claude -p` / backend CLI prompt spawn — and in particular wake an
@@ -12,6 +12,12 @@ Codex is split by launch surface:
12
12
  auto-attached to a default-path app-server): raw `turn/start` over
13
13
  WebSocket-over-UDS wakes the live thread — **demonstrated, no managed
14
14
  standalone, no cloud**.
15
+ - **Copilot CLI 1.0.80 TUI+server**: official SDK `1.0.11` can resolve the
16
+ foreground native session and enqueue into that exact idle TUI; D7 was
17
+ demonstrated once (L4 direct-native, one Linux workstation). The launch flag
18
+ `--ui-server` is hidden from CLI help and its loopback RPC authentication is
19
+ not established, so this remains a probe, not a managed lane. Undocumented
20
+ `~/.copilot/run/ws.*` is still not a rail.
15
21
 
16
22
  ## TL;DR — three reception paths, ranked
17
23
 
@@ -82,6 +88,7 @@ do not ship that. Proven: deliver to A's sessionId → A wakes (FileChanged), B'
82
88
  - `raw-agy-send.sh <conv_id> …` — agy parity: PUSH into a live Antigravity session (LS gRPC)
83
89
  - `raw-codex-ws-turn-start.py <sock> <thread_id> …` — Codex parity (no managed standalone, no cloud): PUSH `turn/start` over WebSocket-over-UDS into a bare `app-server --listen` socket
84
90
  - `codex-local-appserver.sh [sock]` — start a bare local app-server so plain `codex` auto-attaches and becomes addressable
91
+ - `copilot-ui-server-probe.mjs` — official-SDK D0 probe by default; `LIVE=1` performs one addressed idle enqueue and prints D0–D8
85
92
 
86
93
  ### Reproduction drivers
87
94
  - `repro-plugin-idle-wake.sh` — single-session smoke.
@@ -244,6 +251,150 @@ send-message-v2` spins a fresh thread, not the live one.
244
251
  4. **Unix-socket transport is WebSocket** (tokio-tungstenite), not newline JSON-RPC,
245
252
  and requires **no auth token** on the UDS. A plain WS client suffices.
246
253
 
254
+ ## Copilot CLI raw delivery status (1.0.80) — positive TUI+server probe, not a managed lane
255
+
256
+ Copilot is a GitHub **harness** (issues, PR, CI, model `auto`, mode `autopilot`,
257
+ remote/delegate), not a second GPT provider next to pi. This makes a native lane
258
+ product-distinct from the declined Codex lane: Copilot can own GitHub work while
259
+ implementation checkpoints arrive from another garden id. That split is operator
260
+ etiquette plus dispatch, not a substrate role system.
261
+
262
+ The cost premise must stay honest. Copilot reports AI Credits and a monthly
263
+ premium-interaction entitlement; model `auto` chooses a path within that budget.
264
+ The reason to use it is GitHub specialization and auto routing, not an unlimited
265
+ subscription claim. Model `auto` and mode `autopilot` are separate axes.
266
+
267
+ ### Positive transport
268
+
269
+ The first-party `@github/copilot-sdk` exposes TUI+server coordination:
270
+ `getForegroundSessionId()`, `getSessionMetadata()`, `resumeSession(id)`, and
271
+ session-scoped `send({mode:"enqueue"|"immediate"})`. The native TUI launch mode is:
272
+
273
+ ```bash
274
+ copilot --ui-server --port 43817 --model auto
275
+ ```
276
+
277
+ `--ui-server` is accepted by CLI 1.0.80 and named by the official SDK API docs,
278
+ but hidden from `copilot --help`. Launch mode is therefore part of the capability,
279
+ and this evidence does not apply to an already-running plain `copilot` TUI.
280
+
281
+ Measured once on 2026-08-19 with SDK 1.0.11, **using the earlier chronological-slice
282
+ probe** (it scored the events following the marker's position in the history). The probe
283
+ in the tree today scores a named turn instead — see "Current probe contract" below — and
284
+ that contract has NOT been run LIVE, so nothing in this list is retroactively evidence
285
+ for it. Evidence level for the list: **L4 direct-native, ONE Linux workstation, one run**;
286
+ the receipt is host-local probe stdout and was NOT archived as a durable artifact, so what
287
+ follows is reproducible by instruction, not citable to a stored file:
288
+
289
+ - protocol v3 ping succeeded;
290
+ - foreground session id joined to metadata containing cwd/git root/branch;
291
+ - `resumeSession(id)` + idle `enqueue` woke the visible TUI with zero typing;
292
+ - the unique body appeared as `user.message` with `delivery:"idle"`;
293
+ - model `auto` selected `gpt-5.6-luna` and returned the exact marker;
294
+ - `assistant.message`, `assistant.turn_end`, and (in the one-session run)
295
+ ephemeral `session.idle` gave completion/reply evidence;
296
+ - a two-session control then targeted A while B received no `user.message`, turn,
297
+ or assistant event, so D3 passed too.
298
+
299
+ D0–D7 passed; D8 is unproven. One robustness defect surfaced in the two-session
300
+ shape: the target visibly replied and persisted `assistant.message` + `turn_end`,
301
+ but that joining SDK client did not receive ephemeral `session.idle`, so SDK
302
+ `sendAndWait()` timed out after 60 seconds. The probe therefore uses `send()` plus
303
+ bounded polling of the official session event-history API (`session.getEvents()` /
304
+ `getMessages()`) and reports completion at `turn_end`. Name that surface at its real
305
+ size: it is the SDK's own full event history — no narrower, no more privileged — and it
306
+ is **not** TUI, file, or database transcript scraping; the probe never reads Copilot's
307
+ storage. This is evidence, not a product retry/polling design.
308
+
309
+ ### Current probe contract — designed, not yet run LIVE
310
+
311
+ Attribution is the load-bearing part, and it runs off the probe's own marker body:
312
+
313
+ ```text
314
+ unique marker body → exactly one user.message
315
+ → its interactionId
316
+ → exactly one assistant.turn_start on that interaction
317
+ → that turn_start's required turnId
318
+ → only assistant.message / assistant.turn_end on that turnId
319
+ ```
320
+
321
+ Every link is required and unambiguous; absent or matched twice, the probe FAILS CLOSED.
322
+ There is no positional fallback and no "the turn after ours" rule, because scoring "the
323
+ newest assistant.message" in a session a human is also typing into is how a probe reports
324
+ someone else's turn as its own delivery.
325
+
326
+ What is deliberately NOT the key: `session.send()` on the bundled CLI 1.0.80 resolves to a
327
+ `Promise<string>` that is the SDK's own submission handle. The server-side `user.message`
328
+ does not carry that string, and it is a different axis from that event's
329
+ `id`/`interactionId` — a join on it cannot hold, so the probe logs it as a diagnostic and
330
+ never matches on it.
331
+
332
+ The D3 control cell uses the same sentence it prints: across `onEvent` and `getEvents`, the
333
+ non-target session received no `user.message` and no `assistant.*` event of any kind.
334
+
335
+ Lifecycle, stated precisely rather than flatteringly: the probe never deletes target
336
+ session A and issues no `A.disconnect()` of its own. `client.stop()` DOES tear down every
337
+ tracked session — A included — as a wire `session.destroy`; because A's foreground
338
+ ownership is re-confirmed immediately before teardown, the TUI keeps A as its foreground
339
+ session, so the net effect on A is detach-equivalent, not removal. The probe deletes only
340
+ the control session it created itself, and reads `client.stop()`'s returned error list so a
341
+ failed teardown cannot exit 0 behind a printed verdict. Reaching past the SDK for a raw
342
+ detach wrapper is out of bounds — it would be a second lifecycle authority.
343
+
344
+ This contract has not been exercised by a LIVE turn. The next LIVE run is what would
345
+ demonstrate it, and it also confirms the real event key names.
346
+
347
+ ### Reproduce with the pinned official SDK
348
+
349
+ The SDK is deliberately not an entwurf production dependency while this is only
350
+ a raw probe. Install it in scratch and point the probe at its ESM entry:
351
+
352
+ ```bash
353
+ mkdir -p /tmp/copilot-sdk-probe
354
+ printf '{"private":true,"type":"module"}\n' >/tmp/copilot-sdk-probe/package.json
355
+ pnpm --dir /tmp/copilot-sdk-probe add --ignore-scripts @github/copilot-sdk@1.0.11
356
+
357
+ # terminal A: visible native session (accept folder trust)
358
+ copilot --ui-server --port 43817 --model auto
359
+
360
+ # terminal B: D0 only, no model call
361
+ COPILOT_SDK_MODULE=/tmp/copilot-sdk-probe/node_modules/@github/copilot-sdk/dist/index.js \
362
+ ./copilot-ui-server-probe.mjs
363
+
364
+ # one paid/subscription turn: addressed idle enqueue through the official SDK
365
+ LIVE=1 COPILOT_SDK_MODULE=/tmp/copilot-sdk-probe/node_modules/@github/copilot-sdk/dist/index.js \
366
+ ./copilot-ui-server-probe.mjs
367
+
368
+ # creates a second no-turn control session and proves only the target receives the marker
369
+ LIVE=1 COPILOT_D3_CONTROL=1 \
370
+ COPILOT_SDK_MODULE=/tmp/copilot-sdk-probe/node_modules/@github/copilot-sdk/dist/index.js \
371
+ ./copilot-ui-server-probe.mjs
372
+ ```
373
+
374
+ ### Admission blockers and negative evidence
375
+
376
+ - **Transport authentication is not established.** The TUI server bound loopback,
377
+ but an SDK client without a token connected. Setting `COPILOT_CONNECTION_TOKEN`
378
+ on the UI server did not enable auth: the unauthenticated client still connected,
379
+ while a token-bearing client received `AUTHENTICATION_NOT_CONFIGURED`. Do not
380
+ ship this as a rail until the supported same-user/fail-closed boundary is proved.
381
+ - Shell command-hook `sessionStart` input is `{timestamp,cwd,source,initialPrompt?}`;
382
+ it does **not** carry `sessionId`. SDK callbacks receive `sessionId` separately in
383
+ their invocation object. The discarded command-hook probe therefore could not
384
+ establish D0, and UUID-only process-log scraping did not satisfy D0's cwd/liveness
385
+ join. Do not revive that path.
386
+ - Hooks have no `FileChanged` / `watchPaths` / `asyncRewake`; the Claude mailbox
387
+ mechanism cannot be copied.
388
+ - `--acp` is a pi-host child path, not this native TUI citizen path. `--remote` is
389
+ GitHub web/mobile steering, not a local `entwurf_v2` API. Undocumented
390
+ `~/.copilot/run/ws.*` remains out of bounds.
391
+
392
+ Do not add `backend:"copilot"`, `FRESH_CALL_BACKENDS`, a schema change, or an OPEN
393
+ issue from these positive probes. Next evidence is active-turn enqueue/immediate
394
+ behavior, TUI-vs-SDK permission ownership, the missing multi-session `session.idle`,
395
+ endpoint staleness/crash behavior, and a supported authenticated or equivalent local
396
+ boundary.
397
+
247
398
  ## Live SSOT for "is the target session alive?"
248
399
 
249
400
  - Claude Code: `~/.claude/sessions/<pid>.json` (pid, sessionId, cwd, status). NOT
@@ -256,3 +407,9 @@ send-message-v2` spins a fresh thread, not the live one.
256
407
  `$HOME/.codex/app-server-control/app-server-control.sock` (or any owned 0700
257
408
  socket via `codex app-server --listen unix://PATH`) is the delivery surface.
258
409
  threadId comes from the newest rollout's `session_meta.id`.
410
+ - Copilot TUI+server probe: SDK `ping` + `getForegroundSessionId()` +
411
+ `getSessionMetadata()` identify the currently displayed native session, and
412
+ the official session event-history API (`getEvents()` / `getMessages()`) reads
413
+ a turn back — no transcript file or database is touched. The TCP port is a
414
+ runtime endpoint, never an identity axis; no managed liveness join exists
415
+ until its authentication and stale-endpoint behavior are proved.