@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +34 -0
- package/DELIVERY.md +57 -0
- package/README.md +1 -1
- package/VERIFY.md +4 -4
- package/demo/README.md +3 -1
- package/demo/demo-baseline.sh +12 -1
- package/demo/demo.sh +9 -1
- package/docs/acp-backend-rail.md +103 -4
- package/docs/setup-clean-host.md +3 -3
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
- package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
- package/package.json +9 -9
- package/pi-extensions/lib/acp/backend.ts +229 -9
- package/run.sh +70 -25
- package/scripts/agy-bridge-config.py +47 -13
- package/scripts/agy-bridge.sh +73 -23
- package/scripts/check-acp-prompt-lifecycle.ts +221 -9
- package/scripts/check-entwurf-bridge-boot.ts +28 -0
- package/scripts/check-gate-qualification.ts +3 -2
- package/scripts/check-probe-bridge-command.ts +201 -0
- package/scripts/check-release-gate-outcomes.ts +54 -1
- package/scripts/doctor-pi-provider.ts +155 -51
- package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
- package/scripts/mutants/bridge-command-boot.json +107 -0
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/probe-bridge-command.ts +330 -0
- package/scripts/raw-async-delivery/README.md +158 -1
- package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +76 -2
- package/scripts/smoke-entwurf-chain-live.ts +1 -1
- package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
- package/scripts/smoke-mux-fresh-call-live.ts +1 -1
- package/scripts/smoke-mux-lifecycle-live.ts +1 -1
- package/scripts/smoke-pi-provider-state.sh +135 -6
- 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.
|