@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
|
@@ -161,6 +161,35 @@ interface AcpBridgeEvent {
|
|
|
161
161
|
decision?: "approved" | "cancelled";
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* A one-shot latch for "the child has ended", created at spawn and settled by
|
|
166
|
+
* `onChildGone`. `settled` answers the question with no waiting at all; the
|
|
167
|
+
* promise is only for the bounded post-mortem window (settleChildEnd).
|
|
168
|
+
*/
|
|
169
|
+
interface ChildEndLatch {
|
|
170
|
+
settled: boolean;
|
|
171
|
+
promise: Promise<void>;
|
|
172
|
+
/** Called exactly once by onChildGone; cleared so a second end is a no-op. */
|
|
173
|
+
settle?: () => void;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function makeChildEndLatch(): ChildEndLatch {
|
|
177
|
+
let settle!: () => void;
|
|
178
|
+
const promise = new Promise<void>((resolve) => {
|
|
179
|
+
settle = resolve;
|
|
180
|
+
});
|
|
181
|
+
const latch: ChildEndLatch = {
|
|
182
|
+
settled: false,
|
|
183
|
+
promise,
|
|
184
|
+
settle: () => {
|
|
185
|
+
latch.settled = true;
|
|
186
|
+
latch.settle = undefined;
|
|
187
|
+
settle();
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
return latch;
|
|
191
|
+
}
|
|
192
|
+
|
|
164
193
|
interface BridgeSession {
|
|
165
194
|
key: string;
|
|
166
195
|
cwd: string;
|
|
@@ -183,8 +212,36 @@ interface BridgeSession {
|
|
|
183
212
|
stderrTail: string[];
|
|
184
213
|
/** How the child ended, once it has — folded into the prompt-phase error. */
|
|
185
214
|
exit?: { code: number | null; signal: NodeJS.Signals | null };
|
|
215
|
+
/**
|
|
216
|
+
* LATCH for the child's end, readable at ANY time — deliberately not a
|
|
217
|
+
* callback.
|
|
218
|
+
*
|
|
219
|
+
* `notifyChildGone` lives only between the two lines of
|
|
220
|
+
* `awaitAcpPromptTurn`'s try/finally, so it can carry the exit status only
|
|
221
|
+
* when the child's `exit` event wins the race against the transport — and on
|
|
222
|
+
* the shape this backend actually runs, it does not. With piped stdio on
|
|
223
|
+
* Linux the child's stdout EOF was measured landing about a millisecond BEFORE
|
|
224
|
+
* node emits `exit`, for a clean exit and for SIGKILL alike (issue #72). The
|
|
225
|
+
* SDK's generic "ACP connection closed" therefore settles the race first,
|
|
226
|
+
* `finally` clears the callback, and the exit status arriving one tick later
|
|
227
|
+
* has nowhere to go — which is how #72's field sample reached the operator
|
|
228
|
+
* naming neither exit code nor signal. The opposite order stays possible and
|
|
229
|
+
* is still handled (notifyChildGone), so both are covered by the gate.
|
|
230
|
+
*
|
|
231
|
+
* The latch outlives the race: `settled` is the durable fact and `promise`
|
|
232
|
+
* lets a failing turn wait a BOUNDED moment for a late end (settleChildEnd).
|
|
233
|
+
*/
|
|
234
|
+
childEnd: ChildEndLatch;
|
|
186
235
|
/** Set while a prompt is in flight so a child death can close it (awaitAcpPromptTurn). */
|
|
187
236
|
notifyChildGone?: (err: Error) => void;
|
|
237
|
+
/**
|
|
238
|
+
* This TURN owns reporting the child's end, so the next turn must not also
|
|
239
|
+
* announce it. Raised at the top of a failure path — BEFORE the bounded
|
|
240
|
+
* settle and before any teardown — so an end observed during that window is
|
|
241
|
+
* still a natural one (we have not signalled anything yet) but is reported
|
|
242
|
+
* exactly once, by the turn that failed on it.
|
|
243
|
+
*/
|
|
244
|
+
reporting?: boolean;
|
|
188
245
|
/**
|
|
189
246
|
* We are tearing this child down ON PURPOSE (turn-scoped teardown, config
|
|
190
247
|
* drift, error/abort cleanup). Its exit is then expected, not news: without
|
|
@@ -229,10 +286,13 @@ function onChildGone(session: BridgeSession, exit?: { code: number | null; signa
|
|
|
229
286
|
if (exit) session.exit = exit;
|
|
230
287
|
if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
|
|
231
288
|
retainedChildren.delete(session.child);
|
|
289
|
+
// Close the latch FIRST and unconditionally: it is the durable fact, and a
|
|
290
|
+
// failing turn may already be waiting on it inside its bounded settle window.
|
|
291
|
+
session.childEnd.settle?.();
|
|
232
292
|
if (session.notifyChildGone) {
|
|
233
293
|
// A turn was waiting on this child — it reports the death itself.
|
|
234
294
|
session.notifyChildGone(childEndedError(session));
|
|
235
|
-
} else if (!session.retiring) {
|
|
295
|
+
} else if (!session.retiring && !session.reporting) {
|
|
236
296
|
// Died BETWEEN turns with no turn to fail and nobody tearing it down, so
|
|
237
297
|
// nobody has seen it. Without this the next turn would silently open a
|
|
238
298
|
// fresh child and read as an ordinary cold start, hiding that the backend
|
|
@@ -281,6 +341,139 @@ function childEndedError(session: BridgeSession): Error {
|
|
|
281
341
|
);
|
|
282
342
|
}
|
|
283
343
|
|
|
344
|
+
/**
|
|
345
|
+
* The BOUNDED post-mortem window a turn that ALREADY failed may wait for the
|
|
346
|
+
* child's exit status.
|
|
347
|
+
*
|
|
348
|
+
* This is NOT a prompt deadline and must never become one: nothing here can end
|
|
349
|
+
* a running turn. It opens only after the prompt has already settled as a
|
|
350
|
+
* transport closure, and it closes on the child's own `exit` — which follows the
|
|
351
|
+
* stdout EOF by about a millisecond, so this is headroom, not a felt wait.
|
|
352
|
+
*/
|
|
353
|
+
const CHILD_END_SETTLE_MS = 500;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The SDK's exact words for "the transport ended under a pending request"
|
|
357
|
+
* (`@agentclientprotocol/sdk` jsonrpc.js / acp.js:
|
|
358
|
+
* `closeSignal.reason ?? new Error("ACP connection closed")`).
|
|
359
|
+
*
|
|
360
|
+
* Matched EXACTLY, not by substring: this text appears only when the close
|
|
361
|
+
* carried NO reason — a clean stdout EOF rather than an errored stream — which
|
|
362
|
+
* is the one failure shape that arrives with no lifecycle facts of its own. A
|
|
363
|
+
* close that DID carry a reason already explains itself and must not be given a
|
|
364
|
+
* settle delay, and our own `childEndedError` already names the exit status.
|
|
365
|
+
* Widening this to a substring test would put that delay on errors that do not
|
|
366
|
+
* need it.
|
|
367
|
+
*/
|
|
368
|
+
const ACP_CONNECTION_CLOSED_TEXT = "ACP connection closed";
|
|
369
|
+
|
|
370
|
+
function isAcpConnectionClosure(err: unknown): boolean {
|
|
371
|
+
const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
|
372
|
+
return message.trim() === ACP_CONNECTION_CLOSED_TEXT;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Wait a BOUNDED moment for a child end that the transport already implied.
|
|
377
|
+
*
|
|
378
|
+
* Resolves immediately when the latch is already closed (the common case once
|
|
379
|
+
* the ~1ms gap has passed) and never rejects. The timer is deliberately NOT
|
|
380
|
+
* unref'd: it is the only thing that ends this wait, so letting the loop drain
|
|
381
|
+
* past it would leave a failing turn unsealed — the opposite of the honesty this
|
|
382
|
+
* exists for. It is cleared on both exits, so it holds nothing open.
|
|
383
|
+
*/
|
|
384
|
+
async function settleChildEnd(latch: ChildEndLatch, ms: number): Promise<void> {
|
|
385
|
+
if (latch.settled) return;
|
|
386
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
387
|
+
try {
|
|
388
|
+
await Promise.race([
|
|
389
|
+
latch.promise,
|
|
390
|
+
new Promise<void>((resolve) => {
|
|
391
|
+
timer = setTimeout(resolve, ms);
|
|
392
|
+
}),
|
|
393
|
+
]);
|
|
394
|
+
} finally {
|
|
395
|
+
if (timer) clearTimeout(timer);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The lifecycle line appended to a transport-closure failure, so the operator
|
|
401
|
+
* reads WHICH phase died and HOW the child ended.
|
|
402
|
+
*
|
|
403
|
+
* Wording is load-bearing for the same reason `childEndedError`'s is: pi
|
|
404
|
+
* classifies a failed assistant message against `RETRYABLE_PROVIDER_ERROR_PATTERN`
|
|
405
|
+
* (@earendil-works/pi-ai `utils/retry`), whose terms include "timed out",
|
|
406
|
+
* "timeout", "terminated", "connection lost" and "ended without". None of those
|
|
407
|
+
* may appear here, or a dead child would put a full cold prompt replay back on
|
|
408
|
+
* the table — with the tool side effects this turn already produced.
|
|
409
|
+
*
|
|
410
|
+
* That pattern is a bare substring alternation (`new RegExp(terms.join("|"), "i")`
|
|
411
|
+
* — NO word boundaries) and its terms enumerate the HTTP statuses "429", "500",
|
|
412
|
+
* "502", "503", "504" and "524". So the settle bound is deliberately NOT interpolated: rendering
|
|
413
|
+
* CHILD_END_SETTLE_MS made this line say "within 500ms", which pi read as an
|
|
414
|
+
* HTTP 500 and classified as transient. Naming the window in prose keeps a later
|
|
415
|
+
* change of that constant from silently re-arming the replay — do not "improve"
|
|
416
|
+
* this by putting the number back; the bound belongs in the code and the gate.
|
|
417
|
+
*
|
|
418
|
+
* The three endings are kept DISTINCT on purpose (issue #72 Done-when): an
|
|
419
|
+
* exit code, a signal, and "we waited and it never said" are three different
|
|
420
|
+
* facts, and collapsing them is what made the original sample unreadable.
|
|
421
|
+
*/
|
|
422
|
+
function childEndLifecycleLine(opts: {
|
|
423
|
+
/** "pre-prompt" covers a new turn's bootstrap AND a reuse turn's pre-send steps. */
|
|
424
|
+
phase: "pre-prompt" | "prompt";
|
|
425
|
+
exit?: { code: number | null; signal: NodeJS.Signals | null };
|
|
426
|
+
ended: boolean;
|
|
427
|
+
}): string {
|
|
428
|
+
const where =
|
|
429
|
+
opts.phase === "prompt"
|
|
430
|
+
? "the ACP backend connection closed while the prompt was still in flight"
|
|
431
|
+
: "the ACP backend connection closed before this turn's prompt was sent";
|
|
432
|
+
const how = opts.ended
|
|
433
|
+
? `the child ended (${describeChildEnd(opts.exit)})`
|
|
434
|
+
: "the child reported no exit status within the bounded post-mortem window";
|
|
435
|
+
return `[acp] lifecycle: ${where} — ${how}; this turn has no answer`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The failure path's post-mortem: enrich ONLY the transport-closure shape, and
|
|
440
|
+
* only after waiting a bounded moment for the exit status the close implies.
|
|
441
|
+
*
|
|
442
|
+
* Returns `undefined` for every other failure — an abort, a bootstrap throw, or
|
|
443
|
+
* a child death our own `notifyChildGone` already diagnosed — so no other error
|
|
444
|
+
* pays a delay and none is double-reported.
|
|
445
|
+
*
|
|
446
|
+
* Callers MUST run this BEFORE tearing the child down: our teardown SIGTERMs the
|
|
447
|
+
* process group, so an exit read after it would be OUR signal recorded as the
|
|
448
|
+
* child's cause of death.
|
|
449
|
+
*
|
|
450
|
+
* The stderr claim is deliberately NARROW. Running before teardown means the tail
|
|
451
|
+
* already collected is not cut short by our own cleanup, and stderr arriving
|
|
452
|
+
* during this window still lands in it. It does NOT promise the child's last
|
|
453
|
+
* words: node's `exit` can precede the stdio drain, and nothing here waits for
|
|
454
|
+
* the stderr pipe to close. Draining it is a separate lever, not this one.
|
|
455
|
+
*/
|
|
456
|
+
async function diagnoseTransportClosure(opts: {
|
|
457
|
+
err: unknown;
|
|
458
|
+
session: BridgeSession | undefined;
|
|
459
|
+
phase: "pre-prompt" | "prompt";
|
|
460
|
+
aborted: boolean;
|
|
461
|
+
}): Promise<string | undefined> {
|
|
462
|
+
if (opts.aborted || !opts.session) return undefined;
|
|
463
|
+
if (!isAcpConnectionClosure(opts.err)) return undefined;
|
|
464
|
+
const session = opts.session;
|
|
465
|
+
// This turn owns the announcement from here on, so the next turn must not
|
|
466
|
+
// repeat it. Raised BEFORE the wait and before any signal of ours, so an end
|
|
467
|
+
// observed inside the window is still a natural one.
|
|
468
|
+
session.reporting = true;
|
|
469
|
+
await settleChildEnd(session.childEnd, CHILD_END_SETTLE_MS);
|
|
470
|
+
return childEndLifecycleLine({
|
|
471
|
+
phase: opts.phase,
|
|
472
|
+
exit: session.exit,
|
|
473
|
+
ended: session.childEnd.settled,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
284
477
|
// ---------------------------------------------------------------------------
|
|
285
478
|
// timeout / launch / permission / stopReason / teardown helpers
|
|
286
479
|
// ---------------------------------------------------------------------------
|
|
@@ -728,12 +921,16 @@ export function streamAcpTurn(
|
|
|
728
921
|
stream.end();
|
|
729
922
|
}
|
|
730
923
|
|
|
731
|
-
function finishError(err: unknown, aborted: boolean, stderrTail?: string[]): void {
|
|
924
|
+
function finishError(err: unknown, aborted: boolean, stderrTail?: string[], lifecycle?: string): void {
|
|
732
925
|
finalizeAcpStreamState(state);
|
|
733
926
|
state.output.stopReason = aborted ? "aborted" : "error";
|
|
734
927
|
const base = err instanceof Error ? err.message : String(err);
|
|
928
|
+
// The FIRST failure stays first and verbatim (it is what the backend
|
|
929
|
+
// actually said); the lifecycle line is added, never substituted, so a
|
|
930
|
+
// reader can still match the transport's own text.
|
|
931
|
+
const diagnosed = lifecycle ? `${base}\n${lifecycle}` : base;
|
|
735
932
|
const tail = (stderrTail ?? []).join("").trim().slice(-1_000);
|
|
736
|
-
const full = tail ? `${
|
|
933
|
+
const full = tail ? `${diagnosed}\n--- backend stderr (tail) ---\n${tail}` : diagnosed;
|
|
737
934
|
// A-c: a real failure (not an abort) that looks like a context-window
|
|
738
935
|
// overflow gets an actionable hint appended, so "API Error" stops hiding
|
|
739
936
|
// the turn-scoped full-transcript-replay cause.
|
|
@@ -924,6 +1121,8 @@ export function streamAcpTurn(
|
|
|
924
1121
|
let child: AcpChildLike | undefined;
|
|
925
1122
|
let session: BridgeSession | undefined;
|
|
926
1123
|
let onAbort: (() => void) | undefined;
|
|
1124
|
+
/** Which phase a failure belongs to — flipped once the prompt is on the wire. */
|
|
1125
|
+
let phase: "pre-prompt" | "prompt" = "pre-prompt";
|
|
927
1126
|
const stderrTail: string[] = [];
|
|
928
1127
|
const sessionKey = resolveSessionKey(opts, cwd);
|
|
929
1128
|
try {
|
|
@@ -1012,6 +1211,9 @@ export function streamAcpTurn(
|
|
|
1012
1211
|
// this turn with the session, so a later reuse turn can still report
|
|
1013
1212
|
// the child's dying words.
|
|
1014
1213
|
stderrTail,
|
|
1214
|
+
// Armed at spawn, before ANY turn can fail on this child — the latch
|
|
1215
|
+
// must already exist when the `exit` listener below can fire.
|
|
1216
|
+
childEnd: makeChildEndLatch(),
|
|
1015
1217
|
};
|
|
1016
1218
|
const sess = session;
|
|
1017
1219
|
spawned.once("exit", (...args: unknown[]) =>
|
|
@@ -1102,6 +1304,9 @@ export function streamAcpTurn(
|
|
|
1102
1304
|
signal.removeEventListener("abort", onAbort);
|
|
1103
1305
|
onAbort = undefined;
|
|
1104
1306
|
}
|
|
1307
|
+
// From here the prompt is on the wire: a transport closure now is a
|
|
1308
|
+
// PROMPT-phase failure, and the catch says so.
|
|
1309
|
+
phase = "prompt";
|
|
1105
1310
|
const promptResult = await awaitAcpPromptTurn(session, wireParams, {
|
|
1106
1311
|
signal,
|
|
1107
1312
|
graceMs: deps.abortGraceMs ?? ABORT_CANCEL_GRACE_MS,
|
|
@@ -1134,6 +1339,13 @@ export function streamAcpTurn(
|
|
|
1134
1339
|
session.busy = false;
|
|
1135
1340
|
if (bridgeSessions.get(sessionKey) === session) bridgeSessions.delete(sessionKey);
|
|
1136
1341
|
}
|
|
1342
|
+
// ORDER IS THE CONTRACT (#72): natural settle → seal → teardown.
|
|
1343
|
+
// The bounded wait for the child's own exit status runs BEFORE we signal
|
|
1344
|
+
// anything, so what we report is how the child actually ended and not our
|
|
1345
|
+
// own SIGTERM; the tail collected by then is sealed before our cleanup
|
|
1346
|
+
// touches the stderr pipe.
|
|
1347
|
+
const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
|
|
1348
|
+
finishError(err, aborted, session?.stderrTail ?? stderrTail, lifecycle);
|
|
1137
1349
|
// error/abort → drop the (uncertain) session and close its child; an
|
|
1138
1350
|
// uncertain connection must never be reused (GPT ④).
|
|
1139
1351
|
if (child) {
|
|
@@ -1142,7 +1354,6 @@ export function streamAcpTurn(
|
|
|
1142
1354
|
session?.connection.close?.(err);
|
|
1143
1355
|
teardownChild(child);
|
|
1144
1356
|
}
|
|
1145
|
-
finishError(err, aborted, session?.stderrTail ?? stderrTail);
|
|
1146
1357
|
} finally {
|
|
1147
1358
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
1148
1359
|
}
|
|
@@ -1150,6 +1361,8 @@ export function streamAcpTurn(
|
|
|
1150
1361
|
|
|
1151
1362
|
// --- reuse: send only the latest user delta to the live ACP session
|
|
1152
1363
|
async function runReuseTurn(session: BridgeSession, ctxSigs: string[]): Promise<void> {
|
|
1364
|
+
/** Same phase discipline as a new turn — reuse just has no bootstrap to lose. */
|
|
1365
|
+
let phase: "pre-prompt" | "prompt" = "pre-prompt";
|
|
1153
1366
|
try {
|
|
1154
1367
|
if (signal?.aborted) throw new Error("aborted before prompt");
|
|
1155
1368
|
|
|
@@ -1171,6 +1384,7 @@ export function streamAcpTurn(
|
|
|
1171
1384
|
// ahead of the wire write; the prompt driver then owns the abort surface.
|
|
1172
1385
|
const wireParams = await applyProviderPayloadHook(options, { sessionId: session.acpSessionId, prompt }, model);
|
|
1173
1386
|
if (signal?.aborted) throw new Error("aborted during payload hook");
|
|
1387
|
+
phase = "prompt";
|
|
1174
1388
|
const promptResult = await awaitAcpPromptTurn(session, wireParams, {
|
|
1175
1389
|
signal,
|
|
1176
1390
|
graceMs: deps.abortGraceMs ?? ABORT_CANCEL_GRACE_MS,
|
|
@@ -1188,16 +1402,22 @@ export function streamAcpTurn(
|
|
|
1188
1402
|
const aborted = Boolean(signal?.aborted);
|
|
1189
1403
|
session.activePromptHandler = undefined;
|
|
1190
1404
|
session.busy = false;
|
|
1191
|
-
// error/abort on a reused session → drop it and close the child (GPT ④).
|
|
1192
1405
|
if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
|
|
1406
|
+
// The SAME diagnostics a new turn reports, in the SAME order (#72):
|
|
1407
|
+
// natural settle → seal → teardown. This is the path the field sample
|
|
1408
|
+
// took — a retained child that died after its tool phase — so the
|
|
1409
|
+
// ordering matters most here: teardown first would have overwritten the
|
|
1410
|
+
// child's own exit status with our SIGTERM.
|
|
1411
|
+
const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
|
|
1412
|
+
// Without the session-scoped tail a mid-turn child death on a resident
|
|
1413
|
+
// session surfaced as a bare "ACP connection closed" with nothing to read
|
|
1414
|
+
// it by.
|
|
1415
|
+
finishError(err, aborted, session.stderrTail, lifecycle);
|
|
1416
|
+
// error/abort on a reused session → drop it and close the child (GPT ④).
|
|
1193
1417
|
session.retiring = true;
|
|
1194
1418
|
retainedChildren.delete(session.child);
|
|
1195
1419
|
session.connection.close?.(err);
|
|
1196
1420
|
teardownChild(session.child);
|
|
1197
|
-
// The SAME diagnostics a new turn reports. Without the session-scoped
|
|
1198
|
-
// tail a mid-turn child death on a resident session surfaced as a bare
|
|
1199
|
-
// "ACP connection closed" with nothing to read it by.
|
|
1200
|
-
finishError(err, aborted, session.stderrTail);
|
|
1201
1421
|
}
|
|
1202
1422
|
}
|
|
1203
1423
|
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* copilot-fresh-preflight — the pre-mutation capability check a Copilot fresh call needs
|
|
3
|
+
* (#82 RAIL 9, `docs/adding-a-harness.md` step 9 clauses 3 and 4).
|
|
4
|
+
*
|
|
5
|
+
* ── Why this is a leaf and not a doctor ──
|
|
6
|
+
*
|
|
7
|
+
* A doctor answers "is this host correctly wired?" and is allowed to be slow, to spawn the
|
|
8
|
+
* vendor CLI, and to read live processes. This is not that. It answers ONE narrower question
|
|
9
|
+
* at ONE moment: *before* `mux-fresh-call` mutates the operator's tmux session, are the four
|
|
10
|
+
* things the fresh contract requires actually in place on this filesystem?
|
|
11
|
+
*
|
|
12
|
+
* birth — without the birth plugin the session mints no record, so the callback
|
|
13
|
+
* would carry no garden id and the sibling never becomes addressable.
|
|
14
|
+
* MCP hand — without the bridge server the callback tool does not exist in that
|
|
15
|
+
* session, and the first turn has nothing to call.
|
|
16
|
+
* receive — without the receiver unit the sibling can be launched and can call
|
|
17
|
+
* home, and then nothing can ever be delivered TO it.
|
|
18
|
+
* visible identity — without the custom footer the citizen has a garden id nobody can see,
|
|
19
|
+
* which step 4 refuses to call lifecycle parity.
|
|
20
|
+
*
|
|
21
|
+
* ── Ordering is the whole point ──
|
|
22
|
+
*
|
|
23
|
+
* `scripts/copilot-launch.sh` already fails closed on the receiver, and that check is kept as
|
|
24
|
+
* it is: it guards every manual `entwurf copilot`, whose accepted contract stays receive-only.
|
|
25
|
+
* But a launcher runs INSIDE the window tmux just made, so its refusal leaves a dead window
|
|
26
|
+
* behind and the caller reads a launch receipt for a sibling that never was. `mux-fresh-call`'s
|
|
27
|
+
* rule is that nothing above the single mutation may leave a window behind, so the fresh lane
|
|
28
|
+
* decides the same facts one layer earlier. The overlap on the receiver axis is deliberate and
|
|
29
|
+
* each side names the other; the other three axes are the FRESH lane's requirement only.
|
|
30
|
+
*
|
|
31
|
+
* ── What this deliberately does NOT claim ──
|
|
32
|
+
*
|
|
33
|
+
* Ownership/configuration truth only: entwurf's units are installed and the settings the
|
|
34
|
+
* vendor will read say what they must say. It does NOT prove the Copilot CLI loaded the
|
|
35
|
+
* plugin, connected the MCP server, scanned the extension, or rendered a garden id in its
|
|
36
|
+
* footer — that is runtime truth, and it belongs to `doctor-copilot-*` (which may spawn the
|
|
37
|
+
* vendor) and to the step 9 clause 7 LIVE receipt. A green preflight is a statement about this
|
|
38
|
+
* filesystem, not a prediction about the next process.
|
|
39
|
+
*
|
|
40
|
+
* Every predicate below MIRRORS the shipped adapter that owns that file, deliberately
|
|
41
|
+
* including its environment seams — a preflight that resolved a path its own installer never
|
|
42
|
+
* writes would refuse a correctly installed host and send the operator hunting.
|
|
43
|
+
*
|
|
44
|
+
* No vendor spawn, no network, no await, no mutation.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { accessSync, constants, existsSync, lstatSync, readFileSync, statSync } from "node:fs";
|
|
48
|
+
import * as path from "node:path";
|
|
49
|
+
|
|
50
|
+
/** One reason per capability. These strings are stable contract — they cross the public
|
|
51
|
+
* surfaces as `entwurf_fresh_call` refusals, so a caller can act on them. */
|
|
52
|
+
export type CopilotPreflightRejectReason =
|
|
53
|
+
| "copilot-birth-unit-missing"
|
|
54
|
+
| "copilot-mcp-hand-missing"
|
|
55
|
+
| "copilot-receive-unit-missing"
|
|
56
|
+
| "copilot-visible-identity-missing";
|
|
57
|
+
|
|
58
|
+
const BIRTH_PLUGIN = "entwurf-meta-receive-copilot";
|
|
59
|
+
const RECEIVE_UNIT = "entwurf-receive";
|
|
60
|
+
const MCP_SERVER_KEY = "entwurf-bridge";
|
|
61
|
+
/** Same default and same env seam as `scripts/copilot-statusline-bridge.sh`. */
|
|
62
|
+
const DEFAULT_STATUSLINE_COMMAND = "entwurf-copilot-statusline";
|
|
63
|
+
|
|
64
|
+
function dataHome(env: NodeJS.ProcessEnv): string | null {
|
|
65
|
+
const xdg = env.XDG_DATA_HOME;
|
|
66
|
+
if (typeof xdg === "string" && xdg.length > 0) return xdg;
|
|
67
|
+
const home = env.HOME;
|
|
68
|
+
if (typeof home === "string" && home.length > 0) return path.join(home, ".local", "share");
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A JSON object or nothing. Failure to read, parse, or find an object is the SAME answer
|
|
73
|
+
* here — absent — because the caller's next move is identical in all of them: run the
|
|
74
|
+
* installer. Telling a corrupt state file apart from a missing one is the doctor's job. */
|
|
75
|
+
function readJsonObject(file: string): Record<string, unknown> | null {
|
|
76
|
+
try {
|
|
77
|
+
const parsed: unknown = JSON.parse(readFileSync(file, "utf8"));
|
|
78
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
79
|
+
? (parsed as Record<string, unknown>)
|
|
80
|
+
: null;
|
|
81
|
+
} catch {
|
|
82
|
+
// Bounded environment probing, Hard Rule 15's stated exception.
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isDir(p: string): boolean {
|
|
88
|
+
try {
|
|
89
|
+
return statSync(p).isDirectory();
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isSymlink(p: string): boolean {
|
|
96
|
+
try {
|
|
97
|
+
return lstatSync(p).isSymbolicLink();
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Same DEST the managed launcher will arm from (`scripts/copilot-launch.sh`).
|
|
104
|
+
* `COPILOT_EXTENSIONS_DIR` wins; otherwise `$HOME/.copilot/extensions/<unit>`.
|
|
105
|
+
* String equality, not realpath: the launcher compares with `[ = ]`. */
|
|
106
|
+
function receiveDest(env: NodeJS.ProcessEnv): string | null {
|
|
107
|
+
const override = env.COPILOT_EXTENSIONS_DIR;
|
|
108
|
+
if (typeof override === "string" && override.length > 0) {
|
|
109
|
+
return path.join(override, RECEIVE_UNIT);
|
|
110
|
+
}
|
|
111
|
+
const home = env.HOME;
|
|
112
|
+
if (typeof home !== "string" || home.length === 0) return null;
|
|
113
|
+
return path.join(home, ".copilot", "extensions", RECEIVE_UNIT);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isExecutableFile(p: string): boolean {
|
|
117
|
+
try {
|
|
118
|
+
// `X_OK` for THIS user, which is what `command -v` answers and what Copilot will need.
|
|
119
|
+
// A mode-bit test would call a root-owned 0700 binary executable for everyone.
|
|
120
|
+
accessSync(p, constants.X_OK);
|
|
121
|
+
return statSync(p).isFile();
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The statusline command as the shipped adapter resolves it: a value containing a separator is
|
|
129
|
+
* a path and must be executable; a bare name is looked up on PATH. This is a small local
|
|
130
|
+
* search rather than `mux-launch`'s resolver because the import fence keeps `mux-launch` to
|
|
131
|
+
* exactly two importers — a third would be a decision, and this leaf does not need one.
|
|
132
|
+
*/
|
|
133
|
+
function commandResolvable(command: string, env: NodeJS.ProcessEnv): boolean {
|
|
134
|
+
if (command.includes("/")) return isExecutableFile(command);
|
|
135
|
+
const raw = env.PATH;
|
|
136
|
+
if (typeof raw !== "string" || raw.length === 0) return false;
|
|
137
|
+
for (const dir of raw.split(path.delimiter)) {
|
|
138
|
+
if (dir.length === 0) continue;
|
|
139
|
+
if (isExecutableFile(path.join(dir, command))) return true;
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The visible-identity axis, step 9 clause 4. The subject is the EFFECTIVE settings the vendor
|
|
146
|
+
* will read, not entwurf's ownership record: a host can carry a correct footer configuration
|
|
147
|
+
* with no install-state (measured on the acceptance host, 2026-08-24 — `doctor-copilot-statusline`
|
|
148
|
+
* reported `settings: configured (resolvable)` / `state: absent` / rc=0), and refusing that host
|
|
149
|
+
* would be refusing a working visible identity because of a missing receipt.
|
|
150
|
+
*
|
|
151
|
+
* The install-state, when present, is checked for the ONE thing it can still contradict: that
|
|
152
|
+
* it manages the very file Copilot reads. A state pointing somewhere else means two settings
|
|
153
|
+
* files disagree about who owns the footer, and which one wins is not decidable from here.
|
|
154
|
+
*/
|
|
155
|
+
function visibleIdentityMissing(env: NodeJS.ProcessEnv, data: string): boolean {
|
|
156
|
+
const command = env.COPILOT_STATUSLINE_COMMAND || DEFAULT_STATUSLINE_COMMAND;
|
|
157
|
+
const configured = env.COPILOT_SETTINGS_CONFIG;
|
|
158
|
+
let settingsPath: string;
|
|
159
|
+
if (typeof configured === "string" && configured.length > 0) {
|
|
160
|
+
settingsPath = configured;
|
|
161
|
+
} else {
|
|
162
|
+
const home = env.HOME;
|
|
163
|
+
if (typeof home !== "string" || home.length === 0) return true;
|
|
164
|
+
settingsPath = path.join(home, ".copilot", "settings.json");
|
|
165
|
+
}
|
|
166
|
+
// A symlinked settings file is somebody else's SSOT; the adapter refuses to touch it and
|
|
167
|
+
// this refuses to certify it.
|
|
168
|
+
if (isSymlink(settingsPath)) return true;
|
|
169
|
+
const settings = readJsonObject(settingsPath);
|
|
170
|
+
if (settings === null) return true;
|
|
171
|
+
const statusLine = settings.statusLine;
|
|
172
|
+
if (typeof statusLine !== "object" || statusLine === null || Array.isArray(statusLine)) return true;
|
|
173
|
+
if ((statusLine as Record<string, unknown>).command !== command) return true;
|
|
174
|
+
const footer = settings.footer;
|
|
175
|
+
if (typeof footer !== "object" || footer === null || Array.isArray(footer)) return true;
|
|
176
|
+
// `showCustom` must be exactly true. A truthy string would render nothing.
|
|
177
|
+
if ((footer as Record<string, unknown>).showCustom !== true) return true;
|
|
178
|
+
if (!commandResolvable(command, env)) return true;
|
|
179
|
+
|
|
180
|
+
const state = readJsonObject(path.join(data, "entwurf", "copilot-statusline", "install-state.json"));
|
|
181
|
+
if (state === null) return false; // absent state is not drift — see the doc comment above
|
|
182
|
+
const managed = state.managedSettingsPath;
|
|
183
|
+
if (typeof managed !== "string" || !path.isAbsolute(managed)) return true;
|
|
184
|
+
return path.resolve(managed) !== path.resolve(settingsPath);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Answer for ONE fresh Copilot launch. `null` means every required capability is in place on
|
|
189
|
+
* this filesystem; anything else is the first missing one, in the order the fresh contract
|
|
190
|
+
* consumes them: be born, hold the hand, be reachable, be visible.
|
|
191
|
+
*/
|
|
192
|
+
export function copilotFreshPreflight(env: NodeJS.ProcessEnv = process.env): CopilotPreflightRejectReason | null {
|
|
193
|
+
const data = dataHome(env);
|
|
194
|
+
// With no HOME and no XDG_DATA_HOME there is no place any of these could be installed.
|
|
195
|
+
// Report the FIRST capability rather than inventing a fifth reason for "no home".
|
|
196
|
+
if (data === null) return "copilot-birth-unit-missing";
|
|
197
|
+
|
|
198
|
+
// 1. Birth. The assembly is what the installer bakes and what the birth doctor certifies
|
|
199
|
+
// statically; `hooks.json` is what makes it a hook unit rather than a directory. There is
|
|
200
|
+
// no install-state for this unit — the artifact IS its ownership record.
|
|
201
|
+
const birthUnit = path.join(data, "entwurf", "meta-bridge-copilot", ".assembled", BIRTH_PLUGIN);
|
|
202
|
+
if (!isDir(birthUnit) || !existsSync(path.join(birthUnit, "hooks", "hooks.json"))) {
|
|
203
|
+
return "copilot-birth-unit-missing";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 2. MCP hand. The install-state names the config it owns, so the config path is READ from
|
|
207
|
+
// ownership truth rather than re-derived — a preflight that guessed would refuse a
|
|
208
|
+
// correctly installed host whose config lives somewhere else.
|
|
209
|
+
const mcpState = readJsonObject(path.join(data, "entwurf", "copilot-mcp", "install-state.json"));
|
|
210
|
+
if (mcpState === null || mcpState.serverKey !== MCP_SERVER_KEY) return "copilot-mcp-hand-missing";
|
|
211
|
+
const managedConfigPath = mcpState.managedConfigPath;
|
|
212
|
+
if (typeof managedConfigPath !== "string" || !path.isAbsolute(managedConfigPath)) return "copilot-mcp-hand-missing";
|
|
213
|
+
const servers = readJsonObject(managedConfigPath)?.mcpServers;
|
|
214
|
+
if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return "copilot-mcp-hand-missing";
|
|
215
|
+
// The state can be current while the config drifted (hand-edited, restored from a backup,
|
|
216
|
+
// replaced by another tool). The server key present in the file the CLI actually reads is
|
|
217
|
+
// the fact the first turn depends on.
|
|
218
|
+
if (!Object.hasOwn(servers as Record<string, unknown>, MCP_SERVER_KEY)) return "copilot-mcp-hand-missing";
|
|
219
|
+
|
|
220
|
+
// 3. Receive. The same facts `scripts/copilot-launch.sh` checks — unit name, recorded
|
|
221
|
+
// path, DEST equality against this env's extensions root, then the entry file.
|
|
222
|
+
const recvState = readJsonObject(path.join(data, "entwurf", "copilot-receive", "install-state.json"));
|
|
223
|
+
if (recvState === null || recvState.unit !== RECEIVE_UNIT) return "copilot-receive-unit-missing";
|
|
224
|
+
const recvPath = recvState.path;
|
|
225
|
+
if (typeof recvPath !== "string" || !path.isAbsolute(recvPath)) return "copilot-receive-unit-missing";
|
|
226
|
+
// Mirror the launcher's path-mismatch predicate PRE-MUTATION. A state that names a
|
|
227
|
+
// real unit in a different extensions root than this env will scan still opens a
|
|
228
|
+
// window today if we only check that the files exist — then `entwurf copilot`
|
|
229
|
+
// refuses inside it and the caller holds a launch receipt for a dead sibling.
|
|
230
|
+
const dest = receiveDest(env);
|
|
231
|
+
if (dest === null || recvPath !== dest) return "copilot-receive-unit-missing";
|
|
232
|
+
if (!isDir(recvPath) || !existsSync(path.join(recvPath, "extension.mjs"))) return "copilot-receive-unit-missing";
|
|
233
|
+
|
|
234
|
+
// 4. Visible identity.
|
|
235
|
+
if (visibleIdentityMissing(env, data)) return "copilot-visible-identity-missing";
|
|
236
|
+
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Repair text, one line per reason. A reason a caller cannot act on is a reason they will
|
|
241
|
+
* guess about — each names the exact command that installs the missing capability. */
|
|
242
|
+
export const COPILOT_PREFLIGHT_HINT: Record<CopilotPreflightRejectReason, string> = {
|
|
243
|
+
"copilot-birth-unit-missing":
|
|
244
|
+
"the Copilot BIRTH plugin is not installed here, so the sibling would mint no record and its callback would carry no garden id — run: entwurf install-copilot-bridge",
|
|
245
|
+
"copilot-mcp-hand-missing":
|
|
246
|
+
"the entwurf-bridge MCP server is not registered in the Copilot config this host owns, so the callback tool would not exist in that session — run: entwurf install-copilot-mcp",
|
|
247
|
+
"copilot-receive-unit-missing":
|
|
248
|
+
"the Copilot RECEIVER extension is not installed here, so the sibling could call home but nothing could ever be delivered to it — run: entwurf install-copilot-receive",
|
|
249
|
+
"copilot-visible-identity-missing":
|
|
250
|
+
"Copilot's custom footer is not configured to entwurf's resolvable statusline command (or an install-state manages a different settings file), so the sibling's garden id would be visible nowhere — run: entwurf doctor-copilot-statusline, then entwurf install-copilot-statusline",
|
|
251
|
+
};
|
|
@@ -48,8 +48,16 @@ import {
|
|
|
48
48
|
} from "./meta-session.ts";
|
|
49
49
|
import { type NativePushAdapter, resolveNativePushAdapter } from "./native-push/adapter.ts";
|
|
50
50
|
|
|
51
|
-
/** Every native backend that mints a garden-id from its own hook and writes a sender marker.
|
|
52
|
-
|
|
51
|
+
/** Every native backend that mints a garden-id from its own hook and writes a sender marker.
|
|
52
|
+
*
|
|
53
|
+
* WRITER AND READER OPEN TOGETHER OR NOT AT ALL. A backend listed here whose hook writes no
|
|
54
|
+
* marker costs one wasted directory read; a backend whose hook writes a marker but is absent
|
|
55
|
+
* here is INVISIBLE — the bridge holds the owner pid, never looks in that directory, and the
|
|
56
|
+
* citizen's sends are refused as anonymous for a reason nothing in the log names. That was
|
|
57
|
+
* exactly the #46 defect on agy, and copilot joined the list only once its own hook wrote one
|
|
58
|
+
* (#82 RAIL 5b). Membership says a marker may EXIST, never that a reply can land: the reply
|
|
59
|
+
* rail is chosen from `nativePushSupported` at the bridge, not from this list. */
|
|
60
|
+
export const META_SENDER_BACKENDS: readonly MetaBackend[] = ["claude-code", "antigravity", "copilot"];
|
|
53
61
|
|
|
54
62
|
/** A marker that passed BOTH guards, together with the record that vouches for it. */
|
|
55
63
|
export interface TrustedMetaSender {
|