@junghanacs/entwurf 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -5
- package/CHANGELOG.md +113 -0
- package/DELIVERY.md +73 -3
- package/README.md +55 -4
- package/VERIFY.md +6 -6
- 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/external-mcp-host.md +26 -4
- package/docs/setup-clean-host.md +3 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
- package/mcp/entwurf-bridge/src/index.ts +10 -6
- package/mcp/entwurf-bridge/tsconfig.build.json +17 -6
- package/package.json +23 -12
- package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
- package/pi/entwurf-capabilities.json +1 -0
- package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
- package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
- package/pi-extensions/entwurf-control.ts +17 -8
- package/pi-extensions/lib/acp/backend.ts +229 -9
- package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
- package/pi-extensions/lib/meta-sender-identity.ts +10 -2
- package/pi-extensions/lib/meta-session.ts +102 -29
- package/pi-extensions/lib/mux-fresh-call.ts +82 -11
- package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
- package/run.sh +389 -26
- 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-copilot-birth-hook.ts +497 -0
- package/scripts/check-copilot-launch.ts +395 -0
- package/scripts/check-copilot-receive-arm.ts +734 -0
- package/scripts/check-copilot-statusline.ts +122 -0
- package/scripts/check-entwurf-bridge-boot.ts +28 -0
- package/scripts/check-entwurf-capabilities.ts +25 -6
- package/scripts/check-fresh-cut-gate.sh +92 -23
- package/scripts/check-gate-qualification.ts +10 -3
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-meta-capability-source.ts +89 -5
- package/scripts/check-meta-doctor-oracle.sh +26 -0
- package/scripts/check-meta-manifest-schema.py +38 -0
- package/scripts/check-meta-receiver-marker.ts +25 -2
- package/scripts/check-meta-session.ts +32 -0
- package/scripts/check-probe-bridge-command.ts +201 -0
- package/scripts/check-release-gate-outcomes.ts +54 -1
- package/scripts/copilot-bridge-doctor.sh +209 -0
- package/scripts/copilot-bridge-install.sh +224 -0
- package/scripts/copilot-launch.sh +236 -0
- package/scripts/copilot-mcp-bridge.sh +177 -0
- package/scripts/copilot-mcp-config.py +221 -0
- package/scripts/copilot-receive-bridge.sh +483 -0
- package/scripts/copilot-statusline-bridge.sh +149 -0
- package/scripts/copilot-statusline-config.py +201 -0
- package/scripts/copilot-statusline.sh +68 -0
- package/scripts/dev-bin.sh +4 -1
- package/scripts/doctor-pi-provider.ts +155 -51
- package/scripts/meta-bridge-hook-log.sh +17 -5
- package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
- package/scripts/mutants/bridge-command-boot.json +107 -0
- package/scripts/mutants/capability-cache.json +64 -0
- package/scripts/mutants/copilot-birth.json +155 -0
- package/scripts/mutants/copilot-launch.json +187 -0
- package/scripts/mutants/copilot-receive.json +263 -0
- package/scripts/mutants/fresh-cut.json +17 -0
- package/scripts/mutants/mux-fresh-call.json +216 -2
- package/scripts/mutants/pack-install.json +17 -0
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/probe-bridge-command.ts +330 -0
- package/scripts/raw-async-delivery/README.md +203 -1
- package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
- package/scripts/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-copilot-mcp-state.sh +153 -0
- package/scripts/smoke-copilot-statusline-state.sh +131 -0
- 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 +3 -1
- package/scripts/smoke-mux-lifecycle-live.ts +4 -2
- package/scripts/smoke-pi-provider-state.sh +135 -6
- package/scripts/smoke-resident-garden-guard.sh +2 -2
- package/scripts/tsconfig.json +1 -0
|
@@ -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
|
|
@@ -13,6 +13,26 @@ Codex is split by launch surface:
|
|
|
13
13
|
WebSocket-over-UDS wakes the live thread — **demonstrated, no managed
|
|
14
14
|
standalone, no cloud**.
|
|
15
15
|
|
|
16
|
+
Copilot is no longer blocked on transport, and the probe below is no longer the
|
|
17
|
+
product — it is the receipt the product was built on:
|
|
18
|
+
|
|
19
|
+
- **Copilot CLI 1.0.80 extension**: the CLI forks a first-party extension and
|
|
20
|
+
speaks JSON-RPC over that child's **stdio**, so `fs.watch` -> `session.send()`
|
|
21
|
+
wakes the idle TUI with **no network listener or token-authentication axis**. Idle wake
|
|
22
|
+
and exact-marker attribution were demonstrated; addressed isolation was observed but its
|
|
23
|
+
decisive control log was not preserved (2026-08-23, one Linux workstation). It needs the experimental
|
|
24
|
+
`COPILOT_CLI_ENABLED_FEATURE_FLAGS=EXTENSIONS` flag at launch. The older
|
|
25
|
+
hidden `--ui-server` loopback probe is retired — it found the capability
|
|
26
|
+
through a door that could not pass admission. Undocumented
|
|
27
|
+
`~/.copilot/run/ws.*` is still not a rail.
|
|
28
|
+
- **The MANAGED receiver now lives at `pi/copilot-receive/entwurf-receive/`** and is
|
|
29
|
+
installed with `run.sh install-copilot-receive` (#82 RAIL 5). Read it before reusing
|
|
30
|
+
anything here, because it deliberately differs on three points: it binds to the V3
|
|
31
|
+
record instead of publishing its own `ready.json`; its receiver marker is owned by the
|
|
32
|
+
extension pid, so a crash retires it; and it **announces** the inbox rather than
|
|
33
|
+
sending the body, leaving the drain (and the read-receipt) to `entwurf_inbox_read`.
|
|
34
|
+
The files below stay as the measurement — do not develop the product in them.
|
|
35
|
+
|
|
16
36
|
## TL;DR — three reception paths, ranked
|
|
17
37
|
|
|
18
38
|
| Path | Self-arms idle watch? | Portable / drop-in | Verdict |
|
|
@@ -82,6 +102,9 @@ do not ship that. Proven: deliver to A's sessionId → A wakes (FileChanged), B'
|
|
|
82
102
|
- `raw-agy-send.sh <conv_id> …` — agy parity: PUSH into a live Antigravity session (LS gRPC)
|
|
83
103
|
- `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
104
|
- `codex-local-appserver.sh [sock]` — start a bare local app-server so plain `codex` auto-attaches and becomes addressable
|
|
105
|
+
- `copilot-enqueue-addressed.sh <session_id> …` — Copilot raw sender: write `.msg` + poke one session's signal; refuses a missing marker, but deliberately does not claim production stale-receiver safety
|
|
106
|
+
- `copilot-extension-receive/extension.mjs` — Copilot reception unit: a first-party CLI extension that arms the per-session mailbox and calls `session.send()` (stdio JSON-RPC; no port, no token)
|
|
107
|
+
- `copilot-ui-server-probe.mjs` — RETIRED rail, kept as history; the hidden `--ui-server` loopback path whose authentication could not be established
|
|
85
108
|
|
|
86
109
|
### Reproduction drivers
|
|
87
110
|
- `repro-plugin-idle-wake.sh` — single-session smoke.
|
|
@@ -244,6 +267,170 @@ send-message-v2` spins a fresh thread, not the live one.
|
|
|
244
267
|
4. **Unix-socket transport is WebSocket** (tokio-tungstenite), not newline JSON-RPC,
|
|
245
268
|
and requires **no auth token** on the UDS. A plain WS client suffices.
|
|
246
269
|
|
|
270
|
+
## Copilot CLI delivery status (1.0.80) — idle wake DEMONSTRATED on the extension rail
|
|
271
|
+
|
|
272
|
+
Copilot is a GitHub **harness** (issues, PR, CI, model `auto`, mode `autopilot`,
|
|
273
|
+
remote/delegate), not a second GPT provider next to pi. This makes a native lane
|
|
274
|
+
product-distinct from the declined Codex lane: Copilot can own GitHub work while
|
|
275
|
+
implementation checkpoints arrive from another garden id. That split is operator
|
|
276
|
+
etiquette plus dispatch, not a substrate role system.
|
|
277
|
+
|
|
278
|
+
The cost premise must stay honest. Copilot reports AI Credits and a monthly
|
|
279
|
+
premium-interaction entitlement; model `auto` chooses a path within that budget.
|
|
280
|
+
The reason to use it is GitHub specialization and auto routing, not an unlimited
|
|
281
|
+
subscription claim. Model `auto` and mode `autopilot` are separate axes.
|
|
282
|
+
|
|
283
|
+
### The rail: a first-party CLI extension, stdio only
|
|
284
|
+
|
|
285
|
+
`copilot-extension-receive/extension.mjs` + `copilot-enqueue-addressed.sh`.
|
|
286
|
+
|
|
287
|
+
The CLI forks an extension as its own child and speaks JSON-RPC over that
|
|
288
|
+
child's stdio. `joinSession()` attaches it to the foreground session; from
|
|
289
|
+
there `session.send({mode:"enqueue"})` injects a user message. So the whole
|
|
290
|
+
delivery path is:
|
|
291
|
+
|
|
292
|
+
```
|
|
293
|
+
external file write -> fs.watch in the extension -> session.send() -> the idle session takes a turn
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
`fs.watch` -> `session.send()` is not a mechanism found by inspection: the
|
|
297
|
+
bundled SDK documents it (`copilot-sdk/docs/examples.md`, "Detecting when the
|
|
298
|
+
plan file is created or edited"). The SDK also ships *inside* the CLI package
|
|
299
|
+
(`<platform-pkg>/copilot-sdk/`) and is injected into extension children by
|
|
300
|
+
`preloads/extension_bootstrap.mjs`, so nothing has to be installed and the SDK
|
|
301
|
+
version cannot drift from the CLI.
|
|
302
|
+
|
|
303
|
+
**This is why the rail clears the network-boundary bar the old one could not.**
|
|
304
|
+
The `--ui-server` probe was refused because its loopback RPC authentication was
|
|
305
|
+
not established. An extension has no port, token, or listener, so that network
|
|
306
|
+
authentication axis does not exist. Product admission must instead certify the
|
|
307
|
+
installed extension's provenance and the CLI-owned parent/child lifecycle; the
|
|
308
|
+
fork is the transport boundary, not proof that every permission, liveness, and
|
|
309
|
+
integrity obligation is already closed.
|
|
310
|
+
|
|
311
|
+
### Launch contract
|
|
312
|
+
|
|
313
|
+
Extensions sit behind an experimental feature flag. Without it the CLI never
|
|
314
|
+
scans for extensions and the receiver is inert **with no error at all** —
|
|
315
|
+
budget for that when a receiver appears not to arm:
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
COPILOT_CLI_ENABLED_FEATURE_FLAGS=EXTENSIONS copilot --model auto
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`--experimental` is NOT required (measured: a session launched with the env var
|
|
322
|
+
alone armed its receiver). Discovery scopes are `user`
|
|
323
|
+
(`~/.copilot/extensions/`), `plugin`, `session`, and — interactive mode only —
|
|
324
|
+
`project` (`.github/extensions/`). Prompt mode (`-p`) drops `project` unless
|
|
325
|
+
`GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS=true`. The `plugin` scope matters most
|
|
326
|
+
here: the existing entwurf Copilot plugin is the candidate install scope for
|
|
327
|
+
this receiver, so product design need not start from a per-repository
|
|
328
|
+
`.github/extensions/` directory.
|
|
329
|
+
|
|
330
|
+
### Reproduce
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
mkdir -p /tmp/cop-lab/.github/extensions/entwurf-mailbox
|
|
334
|
+
cp copilot-extension-receive/extension.mjs /tmp/cop-lab/.github/extensions/entwurf-mailbox/
|
|
335
|
+
|
|
336
|
+
# terminal A: a visible session that arms its own receiver
|
|
337
|
+
cd /tmp/cop-lab
|
|
338
|
+
export COPILOT_MAILBOX_ROOT=/tmp/cop-lab/mbx COPILOT_CLI_ENABLED_FEATURE_FLAGS=EXTENSIONS
|
|
339
|
+
copilot --model auto --allow-all-tools # accept folder trust
|
|
340
|
+
|
|
341
|
+
# terminal B: find the armed receiver, then wake it with zero typing
|
|
342
|
+
ls /tmp/cop-lab/mbx/*/ready.json
|
|
343
|
+
COPILOT_MAILBOX_ROOT=/tmp/cop-lab/mbx \
|
|
344
|
+
./copilot-enqueue-addressed.sh <session_id> "Reply with exactly PING and nothing else."
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Measured — 2026-08-23, CLI 1.0.80, model `auto`, one Linux workstation (oracle, arm64)
|
|
348
|
+
|
|
349
|
+
Evidence level **L4 direct-native, one host**. The receiver log lines that still
|
|
350
|
+
travel are pasted here. Scratch was cleaned before the second-session control and
|
|
351
|
+
second-turn lines were copied, so those two observations are explicitly downgraded
|
|
352
|
+
below rather than being laundered into durable receipts:
|
|
353
|
+
|
|
354
|
+
```text
|
|
355
|
+
02:19:33.996 ARMED sessionId=4fc16d8d-473d-4258-a1fd-f99d3cb375e9
|
|
356
|
+
02:20:03.388 DELIVER (signal) 1787451603.msg bytes=96
|
|
357
|
+
02:20:03.422 SENT 1787451603.msg
|
|
358
|
+
02:20:06.141 EVENT user.message {"content":"...Reply with exactly ENTWURF-WAKE-1787451603..."}
|
|
359
|
+
02:20:06.492 EVENT assistant.turn_start {"turnId":"0"}
|
|
360
|
+
02:20:09.935 EVENT assistant.message {"turnId":"0","content":"ENTWURF-WAKE-1787451603"}
|
|
361
|
+
02:20:10.024 EVENT session.idle {}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
- **Idle wake: PASS.** The session had never been typed into — an empty timeline,
|
|
365
|
+
already past any turn boundary. An external file write started a turn. ~2.7 s
|
|
366
|
+
poke -> `user.message`, ~6.5 s poke -> reply. The visible TUI showed the prompt
|
|
367
|
+
and the answer.
|
|
368
|
+
- **Attribution: PASS.** A unique per-run marker went in and came back exactly,
|
|
369
|
+
on one `assistant.turn_start`/`assistant.message` pair.
|
|
370
|
+
- **Addressed routing: reported, receipt not preserved.** The measuring Opus
|
|
371
|
+
reported that a second armed process B stayed at one `ARMED` line across two
|
|
372
|
+
deliveries to A, with no `user.message` or `assistant.*` event. The decisive B
|
|
373
|
+
line and A's second delivery were not copied before scratch cleanup, so this is
|
|
374
|
+
a lead for the admission rerun, not durable D3 acceptance.
|
|
375
|
+
- **Continuity: one turn demonstrated.** The pasted chain shows one joined
|
|
376
|
+
session taking the marker turn and replying; no `-p` process was spawned. A
|
|
377
|
+
second same-session turn was reported but its lines were not preserved, so no
|
|
378
|
+
stronger repeatability claim crosses from this checkpoint.
|
|
379
|
+
|
|
380
|
+
### Not proven — do not describe these as working
|
|
381
|
+
|
|
382
|
+
- **Active-turn delivery.** Every measured send landed on an idle session.
|
|
383
|
+
`mode:"enqueue"` vs `"immediate"` against a busy turn is untested.
|
|
384
|
+
- **Multi-session inside ONE CLI process.** A and B were separate `copilot`
|
|
385
|
+
processes. Whether a *background* session inside one process (sidebar tabs)
|
|
386
|
+
can be woken while another holds the foreground is untested, and
|
|
387
|
+
`joinSession()` attaching to "the foreground session" is the reason to doubt it.
|
|
388
|
+
- **`/clear` and foreground replacement.** The docs say extensions reload there;
|
|
389
|
+
re-arming was not measured. `[문서]`, not `[측정]`.
|
|
390
|
+
- **Flag durability.** `EXTENSIONS` is an experimental flag with
|
|
391
|
+
`experimental`/`staff-or-experimental` availability. It can move or be
|
|
392
|
+
withdrawn between CLI releases; re-verify on upgrade. A flagged surface is an
|
|
393
|
+
admission question for a managed lane even when the transport is sound.
|
|
394
|
+
- **Permission ownership, crash and ordering behavior, delivery under load.**
|
|
395
|
+
|
|
396
|
+
That fence has since been walked, not deleted: `backend:"copilot"` receive capability
|
|
397
|
+
IS now real (`wakeMode: self-fetch` plus a record-bound receiver marker, #82 RAIL 5),
|
|
398
|
+
and it arrived through the managed unit with its own installer, doctor, gate and
|
|
399
|
+
mutants — not by widening anything in this directory. `FRESH_CALL_BACKENDS` now
|
|
400
|
+
includes `copilot` (#82 RAIL 9); that launch path lives in `mux-fresh-call`, not here.
|
|
401
|
+
This probe still does not open a sibling. Waking an already-running Copilot and
|
|
402
|
+
opening a new one remain different capabilities.
|
|
403
|
+
The registry's receive grade moved off D0 later, and NOT because of this probe: a managed
|
|
404
|
+
LIVE wake was observed on 2026-08-23 (garden `20260823T181316-d9f6ba`, CLI 1.0.80,
|
|
405
|
+
doorbell→`lastReadAt` 09:23:41.235Z→09:23:56.480Z) and that is what makes the shipped lane
|
|
406
|
+
D6, with D7 partial and D3 pending. This probe's receipt remains what it always was —
|
|
407
|
+
evidence about the MECHANISM, not about the shipped lane. Keep the two apart when citing
|
|
408
|
+
either.
|
|
409
|
+
|
|
410
|
+
### Retired: the hidden `--ui-server` probe (kept for the lesson)
|
|
411
|
+
|
|
412
|
+
The 2026-08-19 probe reached the TUI through `copilot --ui-server --port 43817`
|
|
413
|
+
and the separately-installed SDK, and it did demonstrate an idle enqueue. It was
|
|
414
|
+
refused admission because the launch flag is hidden from `copilot --help` and its
|
|
415
|
+
loopback RPC authentication could not be established: an unauthenticated client
|
|
416
|
+
connected, while setting `COPILOT_CONNECTION_TOKEN` on the server made a
|
|
417
|
+
token-bearing client fail with `AUTHENTICATION_NOT_CONFIGURED`.
|
|
418
|
+
|
|
419
|
+
The lesson is not "that probe was wrong". It found a real capability through the
|
|
420
|
+
wrong door, and then the door — not the capability — was what failed admission.
|
|
421
|
+
The bundled extension door was in the same package the whole time: the SDK that
|
|
422
|
+
this probe installed from npm also ships inside the CLI, with an `extension.mjs` contract and
|
|
423
|
+
docs beside it. `copilot-ui-server-probe.mjs` remains in this tree as that history.
|
|
424
|
+
Do not revive `--ui-server` as a rail; use the extension.
|
|
425
|
+
|
|
426
|
+
Also still true, and still out of bounds: hooks have no `FileChanged` /
|
|
427
|
+
`watchPaths` / `asyncRewake`, so the Claude mailbox hook mechanism cannot be
|
|
428
|
+
copied; the shell command-hook `sessionStart` input carries no `sessionId`
|
|
429
|
+
(the receiver publishes its own `ready.json` instead of scraping for one);
|
|
430
|
+
`--acp` is a pi-host child path and `--remote` is GitHub web/mobile steering,
|
|
431
|
+
neither of which is a local `entwurf_v2` API; and undocumented
|
|
432
|
+
`~/.copilot/run/ws.*` is not a rail.
|
|
433
|
+
|
|
247
434
|
## Live SSOT for "is the target session alive?"
|
|
248
435
|
|
|
249
436
|
- Claude Code: `~/.claude/sessions/<pid>.json` (pid, sessionId, cwd, status). NOT
|
|
@@ -256,3 +443,18 @@ send-message-v2` spins a fresh thread, not the live one.
|
|
|
256
443
|
`$HOME/.codex/app-server-control/app-server-control.sock` (or any owned 0700
|
|
257
444
|
socket via `codex app-server --listen unix://PATH`) is the delivery surface.
|
|
258
445
|
threadId comes from the newest rollout's `session_meta.id`.
|
|
446
|
+
- Copilot raw probe: `<COPILOT_MAILBOX_ROOT>/<session_id>/ready.json`, written by
|
|
447
|
+
the receiver extension about itself (sessionId, pid, cwd, armedAt). `[번들]`
|
|
448
|
+
`preloads/extension_bootstrap.mjs` monitors the CLI parent, but this is a
|
|
449
|
+
discovery marker for the experiment, **not a production liveness SSOT**: the
|
|
450
|
+
probe sender checks only that the file exists, so a crashed extension can
|
|
451
|
+
leave stale state and produce a false enqueue receipt.
|
|
452
|
+
- Copilot PRODUCT: `<pi-agent-dir>/meta-receivers/<gardenId>.json`, written by the
|
|
453
|
+
managed extension and keyed by the garden id a sender actually targets. Liveness is
|
|
454
|
+
pid + start-key on the EXTENSION child, so a crashed receiver reads as inactive at the
|
|
455
|
+
next read — that is what `ready.json` could not do. Parent monitoring never cleaned the
|
|
456
|
+
probe's marker by itself; the product does not depend on cleanup at all. Nothing
|
|
457
|
+
scrapes `~/.copilot/session-store.db` or the session-state dirs, and the shell
|
|
458
|
+
command-hook `sessionStart` input is NOT
|
|
459
|
+
a source — it carries no `sessionId`. The retired `--ui-server` TCP port was
|
|
460
|
+
never an identity axis.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Deliver an async message INTO one already-running, IDLE Copilot CLI session.
|
|
3
|
+
#
|
|
4
|
+
# Addressed by sessionId: only the named session's receiver sees the poke, so
|
|
5
|
+
# siblings stay idle. Free — it is a file write plus a continuation of a session
|
|
6
|
+
# that is already running; no `copilot -p` spawn is involved.
|
|
7
|
+
#
|
|
8
|
+
# ./copilot-enqueue-addressed.sh <session_id> "your async message"
|
|
9
|
+
#
|
|
10
|
+
# The receiving session must be running the copilot-extension-receive extension
|
|
11
|
+
# and must agree on COPILOT_MAILBOX_ROOT. `ready.json` is raw-probe discovery,
|
|
12
|
+
# not production liveness authority: this script does not close stale pid/PID-reuse.
|
|
13
|
+
# Inspect armed candidates with:
|
|
14
|
+
# ls "${COPILOT_MAILBOX_ROOT:-$HOME/.copilot/mailbox}"/*/ready.json
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
root="${COPILOT_MAILBOX_ROOT:-$HOME/.copilot/mailbox}"
|
|
18
|
+
session_id="${1:?usage: copilot-enqueue-addressed.sh <session_id> <message>}"
|
|
19
|
+
shift
|
|
20
|
+
message="${*:?usage: copilot-enqueue-addressed.sh <session_id> <message>}"
|
|
21
|
+
|
|
22
|
+
box="$root/$session_id"
|
|
23
|
+
# Refuse an address that never armed. A stale marker remains possible in this raw
|
|
24
|
+
# probe; the managed product must add record-backed pid + start-key certification.
|
|
25
|
+
if [[ ! -f "$box/ready.json" ]]; then
|
|
26
|
+
echo "no receiver marker at $box (no ready.json) — not queued" >&2
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
msg="$box/$(date +%s%N).msg"
|
|
31
|
+
printf '%s\n' "$message" > "$msg"
|
|
32
|
+
# The body lands before the doorbell rings, so the receiver never wakes to an
|
|
33
|
+
# empty box.
|
|
34
|
+
printf 'poke\n' >> "$box/inbox.signal"
|
|
35
|
+
echo "queued-and-poked $msg (raw probe; extension log is the delivery receipt)"
|