@junghanacs/entwurf 0.12.6 → 0.12.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/AGENTS.md +31 -17
  2. package/BASELINE.md +42 -8
  3. package/CHANGELOG.md +22 -0
  4. package/DELIVERY.md +71 -14
  5. package/README.md +77 -45
  6. package/VERIFY.md +18 -13
  7. package/docs/setup-clean-host.md +80 -22
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
  30. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
  31. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
  33. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
  34. package/mcp/entwurf-bridge/src/index.ts +101 -67
  35. package/mcp/entwurf-bridge/test.sh +1 -1
  36. package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
  37. package/package.json +10 -5
  38. package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
  39. package/pi-extensions/lib/entwurf-self-address.ts +58 -15
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
  41. package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
  42. package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
  43. package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
  44. package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
  45. package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
  46. package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
  48. package/pi-extensions/lib/meta-sender-identity.ts +154 -0
  49. package/pi-extensions/lib/native-push/adapter.ts +255 -0
  50. package/pi-extensions/lib/native-push/register.ts +99 -0
  51. package/run.sh +756 -214
  52. package/scripts/agy-bridge-config.py +446 -0
  53. package/scripts/agy-bridge.sh +359 -0
  54. package/scripts/agy-hooks-bridge.sh +193 -0
  55. package/scripts/agy-hooks-config.py +257 -0
  56. package/scripts/agy-imprint.sh +28 -0
  57. package/scripts/agy-imprint.ts +193 -0
  58. package/scripts/agy-statusline-bridge.sh +176 -0
  59. package/scripts/agy-statusline-config.py +213 -0
  60. package/scripts/agy-statusline.sh +256 -0
  61. package/scripts/build-bridge.sh +20 -0
  62. package/scripts/check-agy-sender-identity.ts +364 -0
  63. package/scripts/check-entwurf-bridge-boot.ts +8 -2
  64. package/scripts/check-entwurf-deliverability.ts +34 -0
  65. package/scripts/check-entwurf-self-address.ts +78 -11
  66. package/scripts/check-entwurf-v2-contract.ts +136 -1
  67. package/scripts/check-entwurf-v2-decider.ts +95 -1
  68. package/scripts/check-entwurf-v2-matrix.ts +14 -3
  69. package/scripts/check-entwurf-v2-native-push.ts +193 -0
  70. package/scripts/check-entwurf-v2-production.ts +68 -1
  71. package/scripts/check-entwurf-v2-runner.ts +58 -0
  72. package/scripts/check-entwurf-v2-surface.ts +35 -0
  73. package/scripts/check-install-surface.ts +357 -0
  74. package/scripts/check-native-push-adapter.ts +319 -0
  75. package/scripts/check-native-push-register.ts +130 -0
  76. package/scripts/dev-bin.sh +195 -0
  77. package/scripts/doctor-pi-provider.ts +140 -0
  78. package/scripts/meta-bridge-doctor.sh +36 -2
  79. package/scripts/register-pi-package.py +37 -3
  80. package/scripts/register-pi-provider.py +287 -0
  81. package/scripts/smoke-agy-hooks-state.sh +172 -0
  82. package/scripts/smoke-agy-install-state.sh +660 -0
  83. package/scripts/smoke-agy-native-push-live.ts +243 -0
  84. package/scripts/smoke-agy-statusline-state.sh +300 -0
  85. package/scripts/smoke-meta-async-drift.sh +9 -2
  86. package/scripts/smoke-meta-install-state.sh +20 -0
  87. package/scripts/smoke-pi-provider-state.sh +182 -0
  88. package/scripts/smoke-user-scope-citizen.sh +62 -0
  89. package/scripts/with-dist-lock.sh +81 -0
  90. package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
  91. package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
  92. package/scripts/__pycache__/register-pi-package.cpython-313.pyc +0 -0
@@ -24,9 +24,22 @@
24
24
  * caller passes watchArmed=false, so meta-self is intentionally
25
25
  * FAIL-CLOSED until slice 2. Slices 1 and 2 close in the SAME release
26
26
  * block, so no intermediate "meta self all-false" state is ever pushed.
27
+ * - meta-session (native-push backend, e.g. antigravity): replyable ⟺ recordBacked AND
28
+ * probeAlive — the SEPARATE native-push axis (봉인 6 / 보정①). A reply to
29
+ * an agy citizen is a direct injection into a live app-server conversation,
30
+ * so what makes it land is an adapter probe finding that conversation, NOT a
31
+ * mailbox watch. Reusing the receiver atom here would smuggle `watchArmed`
32
+ * (a mailbox-only signal) into a domain with no mailbox, and an agy sender
33
+ * — which never arms a watch — would report replyable:false forever.
27
34
  * - external-mcp: never replyable — no authoritative reply address.
35
+ *
36
+ * `origin` stays identity PROVENANCE (where the sender identity came from), never a rail.
37
+ * Which rail a meta citizen's reply rides is a SECOND axis — `metaDeliveryDomain`, derived
38
+ * by the caller from `nativePushSupported(backend)`, not from `wakeMode` (direct-inject also
39
+ * covers codex/pi, which have no native-push adapter). Fail-closed: an unsupplied domain is
40
+ * not replyable.
28
41
  */
29
- import { computeMetaReceiverActive } from "./entwurf-deliverability.js";
42
+ import { computeMetaReceiverActive, nativePushDeliverable } from "./entwurf-deliverability.js";
30
43
  /**
31
44
  * Decide whether a reply to this session is actually deliverable, from injected
32
45
  * facts only. Pure: no env reads, no fs, no probing — the caller gathers the facts
@@ -53,18 +66,41 @@ export function computeSelfAddressability(facts) {
53
66
  };
54
67
  }
55
68
  case "meta-session": {
56
- // Share the active-receiver atom with the deliverability predicate (one
57
- // source of truth for "record backed AND owner alive AND watch armed").
58
- const recv = computeMetaReceiverActive({
59
- recordBacked: facts.recordBacked,
60
- ownerAlive: facts.ownerAlive,
61
- watchArmed: facts.watchArmed,
62
- });
63
- return {
64
- replyable: recv.active,
65
- socketState: "none",
66
- reason: recv.active ? `meta receiver active (${recv.reason})` : `meta receiver inactive — ${recv.reason}`,
67
- };
69
+ // TWO rails, pinned apart (보정①). Each branch composes the predicate that OWNS its
70
+ // axis the mailbox receiver atom and the native-push predicate share nothing, so a
71
+ // mailbox liveness fact can never leak into a backend that has no mailbox.
72
+ switch (facts.metaDeliveryDomain) {
73
+ case "native-push": {
74
+ const push = nativePushDeliverable({ recordBacked: facts.recordBacked, probeAlive: facts.probeAlive });
75
+ return {
76
+ replyable: push.deliverable,
77
+ socketState: "none",
78
+ reason: push.deliverable
79
+ ? `native-push reachable (${push.reason})`
80
+ : `native-push unreachable — ${push.reason}`,
81
+ };
82
+ }
83
+ case "self-fetch": {
84
+ // Share the active-receiver atom with the deliverability predicate (one
85
+ // source of truth for "record backed AND owner alive AND watch armed").
86
+ const recv = computeMetaReceiverActive({
87
+ recordBacked: facts.recordBacked,
88
+ ownerAlive: facts.ownerAlive,
89
+ watchArmed: facts.watchArmed,
90
+ });
91
+ return {
92
+ replyable: recv.active,
93
+ socketState: "none",
94
+ reason: recv.active ? `meta receiver active (${recv.reason})` : `meta receiver inactive — ${recv.reason}`,
95
+ };
96
+ }
97
+ default:
98
+ return {
99
+ replyable: false,
100
+ socketState: "none",
101
+ reason: "meta delivery domain not supplied — cannot say which rail a reply would ride (fail-closed)",
102
+ };
103
+ }
68
104
  }
69
105
  case "external-mcp":
70
106
  return {
@@ -58,16 +58,35 @@ export const FACT_LIVENESSES = ["alive", "dead", "indeterminate", "unsupported"]
58
58
  // handled by the domain guard before the table is consulted.
59
59
  export const DISPATCH_LIVENESSES = ["live", "dormant", "indeterminate"];
60
60
  // ── Backend liveness domain (R1 + F4) ──────────────────────────────────────
61
- // Backends whose liveness predicate is DEFINED. Initial = pi only (control-socket
62
- // connect + RPC `get_info`, entwurf-control.ts). claude-code (self-fetch, no
63
- // socket) and codex/antigravity (direct-inject without a probe surface yet) are
64
- // OUT of domain `unsupported`. Widening this set is a deliberate future
65
- // decision (Stage 1+), gated by a REAL liveness predicate for that backend —
66
- // never by silently mapping its sessions to dead/indeterminate (R1 핵심).
61
+ // Backends whose SOCKET liveness predicate is DEFINED the pi control-socket
62
+ // domain ONLY (connect + RPC `get_info`, entwurf-control.ts). It stays ["pi"].
63
+ // claude-code (self-fetch, no socket) has no liveness predicate at all `unsupported`.
64
+ // codex/antigravity are direct-inject; antigravity's liveness IS measured, but by the
65
+ // SEPARATE native-push adapter rail (a live app-server conversation probe), NOT this
66
+ // pi-socket domain so it must NEVER be added here. Adding it would pull agy into the
67
+ // pi socket table (inspectSocket/probeSocket are socket-only); the fact layer keeps
68
+ // reporting agy `unsupported` = "outside the pi-socket liveness domain", NOT
69
+ // unreachable (the native-push rail measures it — entwurf-v2-decider.ts). Widening
70
+ // THIS set is a deliberate future decision (Stage 1+), gated by a REAL pi-shaped
71
+ // control-socket predicate — never by silently mapping sessions to dead/indeterminate
72
+ // (R1 핵심). check-entwurf-facts pins this == ["pi"] and asserts the native-push
73
+ // domain is disjoint from it.
67
74
  export const LIVENESS_DOMAIN_BACKENDS = ["pi"];
68
75
  export function isLivenessSupported(backend) {
69
76
  return LIVENESS_DOMAIN_BACKENDS.includes(backend);
70
77
  }
78
+ // ── Native-push backend domain (봉인 2/4) ───────────────────────────────────
79
+ // A backend whose liveness is measured by the SEPARATE native-push adapter rail (a
80
+ // live app-server conversation probe — antigravity's LS gRPC), NOT the pi control
81
+ // socket. This domain is DISJOINT from LIVENESS_DOMAIN_BACKENDS (pi socket): an agy
82
+ // session is `unsupported` at the pi-socket FACT level (entwurf_peers) yet fully
83
+ // measured + deliverable on the native-push axis. The two are separate rails on
84
+ // purpose — check-entwurf-facts pins both sets and asserts their intersection is ∅
85
+ // (a backend can never be in both a socket-liveness domain and a native-push domain).
86
+ export const NATIVE_PUSH_BACKENDS = ["antigravity"];
87
+ export function nativePushSupported(backend) {
88
+ return NATIVE_PUSH_BACKENDS.includes(backend);
89
+ }
71
90
  /**
72
91
  * Compose the 4-value FACT liveness from a backend and its socket probe.
73
92
  * Out-of-domain backend → `unsupported` (NOT dead/indeterminate, R1). An
@@ -93,6 +112,9 @@ export const ENTWURF_V2_REJECT_REASONS = [
93
112
  "owned-live-no-autosend", // Q2/F1: owned-outcome to a live target is not an auto-send
94
113
  "backend-liveness-unsupported", // R1: backend has no liveness predicate (e.g. claude-code) — owned-outcome only
95
114
  "mailbox-undeliverable", // F-mailbox: fire-and-forget to an unsupported citizen whose mailbox is not deliverable (fail-closed; future pi-backend non-drainable mailbox)
115
+ "native-push-target-dead", // 봉인 1: fire-and-forget to a native-push (agy) target whose adapter probe found NO live conversation. Post-probe; observedLiveness = dead. NOT `backend-liveness-unsupported` — a native-push backend IS measured, so that name would be a lie.
116
+ "native-push-probe-indeterminate", // 봉인 1: fire-and-forget to a native-push target whose adapter probe was inconclusive (agy alive but no port served the conv, or a probe error). Post-probe; observedLiveness = indeterminate. Never spawns, never coerced to dead.
117
+ "native-push-no-resume-authority", // 봉인 1: owned-outcome to a native-push target (any liveness — single, state-independent). A native-push backend has no resume/spawn authority (there is no pi-child to own), so the caller cannot own its completion; use fire-and-forget. Post-probe; observedLiveness = the measured value.
96
118
  "bad-target", // R2: absent/typo garden-id (no existing citizen); spawn-new out of v2 scope
97
119
  "untrusted-fail-fast", // 동결결정 5: controlled launch into an untrusted cwd
98
120
  "socket-only-no-resume-authority", // A1: a record-less socket-only endpoint resolved to a resume verdict (owned-outcome × dormant), but spawn-bg cannot open into it — no trusted cwd/resume authority. Post-probe guard reject (NOT pre-probe, NOT a table resolver cell): the in-domain probe ran and measured the liveness, then `allowResume:false` refused the resume. Carries the honest measured FactLiveness (non-null), unlike the pre-probe `bad-target` it replaces here — a live/addressable socket-only citizen must NEVER be mislabeled absent.
@@ -160,7 +182,18 @@ export const RESOLVER_REJECT_REASONS = [
160
182
  // `meta-mailbox` (F-mailbox) = liveness-free delivery via the 0.10.0 meta-bridge
161
183
  // mailbox + doorbell. The ack is "enqueued + doorbell rung", NOT a read and NOT a
162
184
  // turn injection — so `mode` (steer/follow_up) is meaningless on this transport.
163
- export const ENTWURF_V2_TRANSPORTS = ["control-socket", "spawn-bg", "tmux-live", "meta-mailbox"];
185
+ // `native-push` (봉인 1) = direct injection into a LIVE native app-server conversation
186
+ // (antigravity `agentapi send-message`). Like meta-mailbox it is a fire-and-forget
187
+ // send arm (ack-only), but it requires a live-probe (NATIVE_PUSH_DISPATCH_TABLE),
188
+ // where meta-mailbox is liveness-free. It is NOT a mailbox enqueue and NOT a pi socket
189
+ // send — it is its own rail.
190
+ export const ENTWURF_V2_TRANSPORTS = [
191
+ "control-socket",
192
+ "spawn-bg",
193
+ "tmux-live",
194
+ "meta-mailbox",
195
+ "native-push",
196
+ ];
164
197
  // Allow-branch facets (exported so the schema↔types gate asserts every enum).
165
198
  export const ENTWURF_V2_ACTIONS = ["send", "resume"];
166
199
  export const ENTWURF_V2_OWNERSHIPS = ["ack-only", "owned"];
@@ -188,10 +221,13 @@ export const DISPATCH_TABLE = {
188
221
  },
189
222
  };
190
223
  // ── The unsupported-backend mailbox mini-table (F-mailbox) ─────────────────
191
- // SEPARATE from the in-domain 6-cell DISPATCH_TABLE (Fable (i)): an `unsupported`
192
- // backend (claude-code self-fetch, codex/agy without a probe surface) has NO
193
- // liveness predicate, so it never enters the liveness-keyed table. Instead the
194
- // domain guard routes it here, keyed on intent alone:
224
+ // SEPARATE from the in-domain 6-cell DISPATCH_TABLE (Fable (i)): a backend with no
225
+ // pi-socket liveness predicate never enters the liveness-keyed table. Instead the
226
+ // domain guard routes it here, keyed on intent alone. Reaches here: claude-code
227
+ // (self-fetch mailbox) and codex (no adapter yet). Does NOT reach here: antigravity —
228
+ // the decider intercepts a native-push backend in its own rail BEFORE this mailbox
229
+ // mini-table (entwurf-v2-decider.ts), so agy is `unsupported` at the fact level yet
230
+ // never falls through to a mailbox it does not have. The cells, keyed on intent alone:
195
231
  // - fire-and-forget needs no liveness — the 0.10.0 meta-bridge mailbox delivers
196
232
  // to any DELIVERABLE citizen. This cell is the deliverable path; resolveDispatch
197
233
  // downgrades it to `mailbox-undeliverable` when the separate mailboxDeliverable
@@ -282,6 +318,63 @@ export function resolveDispatch(intent, liveness, mailboxDeliverable) {
282
318
  observedLiveness: liveness,
283
319
  };
284
320
  }
321
+ // ── The native-push dispatch table (봉인 1/2/4) ─────────────────────────────
322
+ // A THIRD table, distinct from both the pi 6-cell DISPATCH_TABLE and the unsupported
323
+ // mailbox mini-table. Keyed intent × NativePushLiveness (NOT intent-only): a
324
+ // native-push backend (antigravity) IS measured by its adapter probe, so the
325
+ // send/reject decision depends on the probed liveness. The decider intercepts a
326
+ // native-push backend in its own rail (nativePushSupported → probe → this table)
327
+ // BEFORE the unsupported branch, so agy never falls through to a mailbox it lacks.
328
+ //
329
+ // fire-and-forget × alive → native-push send (the ONE allow cell)
330
+ // fire-and-forget × dead → reject native-push-target-dead
331
+ // fire-and-forget × indeterminate → reject native-push-probe-indeterminate
332
+ // owned-outcome × * → reject native-push-no-resume-authority (state-
333
+ // independent: no pi-child to own; `backend-
334
+ // liveness-unsupported` is NOT reused — false name).
335
+ export const NATIVE_PUSH_DISPATCH_TABLE = {
336
+ "fire-and-forget": {
337
+ alive: { action: "send", transport: "native-push", ownership: "ack-only" },
338
+ dead: { action: "reject", reason: "native-push-target-dead" },
339
+ indeterminate: { action: "reject", reason: "native-push-probe-indeterminate" },
340
+ },
341
+ "owned-outcome": {
342
+ alive: { action: "reject", reason: "native-push-no-resume-authority" },
343
+ dead: { action: "reject", reason: "native-push-no-resume-authority" },
344
+ indeterminate: { action: "reject", reason: "native-push-no-resume-authority" },
345
+ },
346
+ };
347
+ // The reasons the native-push resolver emits — a THIRD post-probe reject set, parallel
348
+ // to RESOLVER_REJECT_REASONS (pi/mailbox). All post-probe: resolveNativePushDispatch
349
+ // always has a real probed liveness in hand, so observedLiveness is non-null. None may
350
+ // be pre-probe (they are never in PRE_PROBE_REJECT_REASONS).
351
+ export const NATIVE_PUSH_REJECT_REASONS = [
352
+ "native-push-target-dead",
353
+ "native-push-probe-indeterminate",
354
+ "native-push-no-resume-authority",
355
+ ];
356
+ /**
357
+ * PURE native-push dispatch decision (봉인 4). Given the caller intent and the adapter
358
+ * probe's 3-value liveness, mint the receipt from NATIVE_PUSH_DISPATCH_TABLE. Mirrors
359
+ * resolveDispatch's shape; observedLiveness is ALWAYS the probed value (non-null,
360
+ * post-probe). The decider calls this only AFTER nativePushSupported(backend) gates the
361
+ * backend and the adapter probe returns a liveness — it never touches the pi socket
362
+ * table or the mailbox mini-table (those are other domains). No IO here (the probe is
363
+ * the decider's injected dep); this only maps (intent, liveness) → verdict.
364
+ */
365
+ export function resolveNativePushDispatch(intent, liveness) {
366
+ const cell = NATIVE_PUSH_DISPATCH_TABLE[intent][liveness];
367
+ if (cell.action === "reject") {
368
+ return makeRejectReceipt(cell.reason, liveness);
369
+ }
370
+ return {
371
+ ok: true,
372
+ action: cell.action,
373
+ transport: cell.transport,
374
+ ownership: cell.ownership,
375
+ observedLiveness: liveness,
376
+ };
377
+ }
285
378
  // ── TypeBox schemas ────────────────────────────────────────────────────────
286
379
  // MOVED to `entwurf-v2-contract-schema.ts` (0.12.1 B-1): the pi-ai TypeBox
287
380
  // builders (StringEnum/Type) are a pi-lane dependency, so they cannot live in
@@ -44,7 +44,7 @@
44
44
  * be bypassed.
45
45
  */
46
46
  import { isNonPiGardenIdSocketConflict } from "./entwurf-facts.js";
47
- import { isLivenessSupported, makeRejectReceipt, resolveDispatch, } from "./entwurf-v2-contract.js";
47
+ import { isLivenessSupported, makeRejectReceipt, nativePushSupported, resolveDispatch, resolveNativePushDispatch, } from "./entwurf-v2-contract.js";
48
48
  import { defaultMetaMailboxDir, defaultMetaSessionsDir, metaCapabilityFor, } from "./meta-session.js";
49
49
  import { isValidSessionId } from "./session-id.js";
50
50
  import { controlSocketPath, mapInspectionToLiveness } from "./socket-discovery.js";
@@ -131,6 +131,35 @@ export async function decideDispatch(input, deps) {
131
131
  return reject(makeRejectReceipt("target-address-conflict", null));
132
132
  }
133
133
  const identity = resolution.identity;
134
+ // 2d. native-push rail (봉인 4): a native-push backend (antigravity) is measured by its
135
+ // adapter probe, NOT the pi socket and NOT the mailbox. Intercept it HERE — after identity
136
+ // resolution + the address-conflict guard, but BEFORE the unsupported mailbox branch — so
137
+ // agy routes to native-push and never falls through to a mailbox it does not have. This
138
+ // branch is LOCK-FREE (봉인 4): the pi in-domain lock closes a socket TOCTOU, but a
139
+ // volatile probe route has no lock meaning (a duplicate-send idempotency is a D8 future).
140
+ if (nativePushSupported(identity.backend)) {
141
+ const probe = await deps.nativePushProbe(identity);
142
+ const receipt = resolveNativePushDispatch(input.intent, probe.status);
143
+ if (!receipt.ok)
144
+ return reject(receipt);
145
+ // The ONLY allow cell is fire-and-forget × alive, so an ok receipt ⟹ the probe is
146
+ // alive and carries a route. The narrow is defensive: a contract-breaking probe/table
147
+ // mismatch fails loud rather than planting a routeless send plan.
148
+ if (probe.status !== "alive") {
149
+ throw new Error("entwurf_v2 decider: native-push send verdict without an alive probe route (contract invariant broken).");
150
+ }
151
+ const plan = {
152
+ transport: "native-push",
153
+ action: "send",
154
+ targetGardenId: gardenId,
155
+ backend: identity.backend,
156
+ nativeSessionId: identity.nativeSessionId,
157
+ route: probe.route,
158
+ wantsReply,
159
+ message: input.message,
160
+ };
161
+ return { kind: "execute", receipt, plan, lock: null };
162
+ }
134
163
  // 3. backend.
135
164
  if (!isLivenessSupported(identity.backend)) {
136
165
  // 6. unsupported path — NO lock (?7). Deliverability comes from the REQUIRED seam
@@ -0,0 +1,57 @@
1
+ /**
2
+ * entwurf-v2-native-push — the native-push SEND hand (봉인 3/4, the executor half of the
3
+ * native-push rail). It takes the `native-push` ExecutionPlan the 5b decider ALREADY chose
4
+ * and direct-injects the message into the live conversation via the adapter. It DECIDES NO
5
+ * ROUTING (the NATIVE_PUSH_DISPATCH_TABLE did) and carries NO release seam — a native-push
6
+ * plan is the LOCK-FREE path (봉인 4), so a lock leak is structurally impossible here.
7
+ *
8
+ * This is where the 1-shot retry lives — NOT in the adapter leaf (봉인 3). The decider
9
+ * probed a fresh route and planted it in the plan; the executor sends over it, and on a
10
+ * failure it re-probes ONCE (the volatile LS port may have shifted between decide and
11
+ * execute) and re-sends. A second failure is fail-loud (no infinite loop). This mirrors the
12
+ * control-socket send-fallback pattern: the decider stays pure, the hand owns the retry.
13
+ *
14
+ * A native-push send has NO in-band refuse (there is no live receiver to answer
15
+ * success:false) — like a mailbox enqueue, it either succeeds or THROWS. The hand never
16
+ * folds a throw into {success:false}; the runner maps a thrown error to execution-failed.
17
+ */
18
+ import { resolveNativePushAdapter } from "./native-push/adapter.js";
19
+ /**
20
+ * Deliver `content` into the conversation, owning the 1-shot retry (봉인 3). Send over the
21
+ * planted (decider-probed) route first; on failure re-probe ONCE and re-send over the fresh
22
+ * route; a second failure PROPAGATES (fail-loud). If the re-probe finds the target no longer
23
+ * alive, throw — a failed send into a now-dead conversation is an honest non-delivery, never
24
+ * a silent success. The re-probe is the ONLY re-derivation of the volatile route.
25
+ */
26
+ export async function deliverViaNativePush(adapter, route, nativeSessionId, content) {
27
+ try {
28
+ await adapter.send(route, nativeSessionId, content);
29
+ return { success: true, retried: false };
30
+ }
31
+ catch (firstErr) {
32
+ // 1-shot re-probe → re-send: the volatile LS route may have shifted since the decider
33
+ // probed it. Re-discover it fresh and retry exactly once.
34
+ const reprobe = await adapter.probe(nativeSessionId);
35
+ if (reprobe.status !== "alive") {
36
+ throw new Error(`native-push deliver: first send failed and re-probe found target ${reprobe.status} (${reprobe.reason}) — not retried`, { cause: firstErr });
37
+ }
38
+ // A second failure THROWS out of this call (fail-loud) — no third attempt.
39
+ await adapter.send(reprobe.route, nativeSessionId, content);
40
+ return { success: true, retried: true };
41
+ }
42
+ }
43
+ /**
44
+ * Build the production `sendNativePush(plan, lock)` adapter the runner consumes. It IGNORES
45
+ * `lock` entirely (a native-push plan is lock-free, 봉인 4) — the field exists only to match
46
+ * the DispatchExecutorDeps hand signature. It resolves the adapter from the plan's backend
47
+ * and delivers with the 1-shot retry. A delivery throw surfaces as a REJECTED promise (the
48
+ * runner's try/catch maps it to execution-failed).
49
+ */
50
+ export function makeNativePushSend(deps = {}) {
51
+ const resolveAdapter = deps.resolveAdapter ?? resolveNativePushAdapter;
52
+ // `_lock` is named for the hand contract but NEVER read — native-push owns/releases no lock.
53
+ return async (plan, _lock) => {
54
+ const adapter = resolveAdapter(plan.backend);
55
+ return deliverViaNativePush(adapter, plan.route, plan.nativeSessionId, plan.message);
56
+ };
57
+ }
@@ -32,11 +32,13 @@ import { isLivenessSupported } from "./entwurf-v2-contract.js";
32
32
  import { decideDispatch, } from "./entwurf-v2-decider.js";
33
33
  import { ENTWURF_V2_LOCK_DIR, acquireLock as realAcquireLock, releaseLock as realReleaseLock, } from "./entwurf-v2-lock.js";
34
34
  import { makeProductionSendViaMailbox } from "./entwurf-v2-mailbox.js";
35
+ import { makeNativePushSend } from "./entwurf-v2-native-push.js";
35
36
  import { executeControlSocketSend, } from "./entwurf-v2-send.js";
36
37
  import { resolveDeadControlSendFallback } from "./entwurf-v2-send-fallback.js";
37
38
  import { executeSpawnBgResume } from "./entwurf-v2-spawn.js";
38
39
  import { makeProductionSpawnBgResumeDeps } from "./entwurf-v2-spawn-production.js";
39
40
  import { defaultMetaMailboxDir, defaultMetaSessionsDir, enqueueMetaMessage, metaCapabilityFor, metaRecordExistsByGardenId, readMetaIdentityByGardenId, readMetaReceiverMarker, } from "./meta-session.js";
41
+ import { resolveNativePushAdapter as realResolveNativePushAdapter, } from "./native-push/adapter.js";
40
42
  import { CONTROL_SOCKET_DIR, controlSocketPath, inspectControlSocketPath, inspectTargetControlSocket, isSocketOnlyPiCandidate, } from "./socket-discovery.js";
41
43
  import { classifyConnectError, probeSocketLiveness } from "./socket-probe.js";
42
44
  /**
@@ -96,6 +98,7 @@ export function makeProductionEntwurfV2Deps(opts) {
96
98
  classifyConnect: s.classifyConnect ?? classifyConnectError,
97
99
  sendRpc: s.sendRpc ?? realSendRpc,
98
100
  enqueue: s.enqueue ?? enqueueMetaMessage,
101
+ resolveNativePushAdapter: s.resolveNativePushAdapter ?? realResolveNativePushAdapter,
99
102
  spawnOverrides: s.spawnOverrides ?? {},
100
103
  };
101
104
  // ── ONE lock domain (Q2/QB3): a single lockDir-bound release for ALL hands ─
@@ -168,6 +171,10 @@ export function makeProductionEntwurfV2Deps(opts) {
168
171
  probeSocket,
169
172
  preflightForCwd: (cwd) => io.preflight({ cwd, agentDir: opts.agentDir, prefixRoots: opts.prefixRoots }),
170
173
  mailboxDeliverabilityFor,
174
+ // 봉인 4: resolve the native-push adapter for this backend + probe the conversation.
175
+ // Only reached on a nativePushSupported backend (the decider gates it), so the resolver
176
+ // never throws for a non-native-push backend here.
177
+ nativePushProbe: (identity) => Promise.resolve(io.resolveNativePushAdapter(identity.backend).probe(identity.nativeSessionId)),
171
178
  mailboxDir,
172
179
  sessionsDir,
173
180
  observeTimeoutMs: opts.observeTimeoutMs,
@@ -210,6 +217,9 @@ export function makeProductionEntwurfV2Deps(opts) {
210
217
  releaseFn: release,
211
218
  })),
212
219
  sendMailbox: (plan, _lock) => sendViaMailbox(plan, _lock),
220
+ // native-push (봉인 4): the SAME injected adapter resolver drives the executor send,
221
+ // so the decider's probe and the delivery use one adapter. Lock-free (lock ignored).
222
+ sendNativePush: makeNativePushSend({ resolveAdapter: io.resolveNativePushAdapter }),
213
223
  };
214
224
  return {
215
225
  decide: (input) => decideDispatch(input, deciderDeps),
@@ -62,6 +62,15 @@ export function decideReleasePolicy(plan, lock) {
62
62
  throw new Error("entwurf-v2-release: a meta-mailbox plan must carry no lock (?7 invariant violated).");
63
63
  }
64
64
  return { kind: "no-lock" };
65
+ case "native-push":
66
+ // Native-push is the other lock-free rail (봉인 4) — like meta-mailbox it holds no
67
+ // lock, so a non-null lock is a decider contract violation. (The native-push hand
68
+ // never actually routes through this reducer; the case keeps the switch exhaustive
69
+ // and pins the lock-free invariant.)
70
+ if (lock !== null) {
71
+ throw new Error("entwurf-v2-release: a native-push plan must carry no lock (봉인 4 lock-free rail violated).");
72
+ }
73
+ return { kind: "no-lock" };
65
74
  case "control-socket":
66
75
  assertInDomainLock(plan, lock);
67
76
  return { kind: "release-after-send-final" };
@@ -98,6 +98,27 @@ export async function executeDispatch(decision, deps) {
98
98
  return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
99
99
  }
100
100
  }
101
+ case "native-push": {
102
+ try {
103
+ // lock is null here (lock-free rail, 봉인 4) — passed verbatim; the hand ignores it.
104
+ const r = await deps.sendNativePush(plan, lock);
105
+ // Like meta-mailbox, native-push has NO in-band reject (no live receiver answers
106
+ // success:false). A `success:false` is therefore a CONTRACT VIOLATION — fail loud
107
+ // rather than render a non-delivery as delivered ("Never warn. Throw.").
108
+ if (r.success !== true) {
109
+ throw new Error("entwurf-v2-runner: native-push send returned success:false (contract violation; native-push has no in-band reject).");
110
+ }
111
+ return {
112
+ kind: "executed",
113
+ receipt,
114
+ transport,
115
+ outcome: { transport: "native-push", success: true, retried: r.retried },
116
+ };
117
+ }
118
+ catch (err) {
119
+ return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
120
+ }
121
+ }
101
122
  }
102
123
  }
103
124
  /**
@@ -132,6 +132,11 @@ async function driveDeadFallback(plan, lock, deps) {
132
132
  // contract violation (a send and a spawn are different actions), so fail loud
133
133
  // rather than silently mis-execute.
134
134
  throw new Error("entwurf-v2-send: re-resolve returned a spawn-bg plan for a send fallback (contract violation).");
135
+ case "native-push":
136
+ // The dead-control-socket fallback re-resolves only the pi socket domain
137
+ // (control-socket / mailbox / spawn); it never routes the native-push rail. A
138
+ // native-push rePlan here is structurally impossible — fail loud.
139
+ throw new Error("entwurf-v2-send: re-resolve returned a native-push plan for a send fallback (contract violation).");
135
140
  }
136
141
  }
137
142
  /**
@@ -71,6 +71,16 @@ export function actionableRejectHint(reason) {
71
71
  case "owned-live-no-autosend":
72
72
  // A live target is reachable, but owned-outcome is not an auto-send (Q2/F1).
73
73
  return "target is live — owned-outcome never auto-sends. Use intent: fire-and-forget (with wants_reply if you need a reply).";
74
+ case "native-push-no-resume-authority":
75
+ // A native-push backend (antigravity) has no pi-child to own, so owned-outcome has
76
+ // nothing to own. Delivery is a direct inject via fire-and-forget.
77
+ return "native-push target (e.g. antigravity) has no resume/spawn authority → owned-outcome is unsupported. Use intent: fire-and-forget to direct-inject into the live conversation.";
78
+ case "native-push-target-dead":
79
+ // The adapter probe found no live host process for the conversation.
80
+ return "native-push conversation is not live (no host process found). Re-open the conversation, then retry — there is nothing to inject into.";
81
+ case "native-push-probe-indeterminate":
82
+ // Host up, but no LS port served the conversation — inconclusive, not a hard dead.
83
+ return "native-push host is up but no port served this conversation (probe inconclusive). Retry once the conversation is loaded, or verify the conversation id.";
74
84
  default:
75
85
  return undefined;
76
86
  }
@@ -127,6 +137,13 @@ export function renderEntwurfV2Result(result) {
127
137
  const exit = res.kind === "child-exited" ? ` exitCode=${res.exitCode}` : "";
128
138
  return { text: `entwurf_v2 spawn-bg → ${res.kind}${pid}${exit}, lock released`, isError: false };
129
139
  }
140
+ if (o.transport === "native-push") {
141
+ // direct-inject succeeded; note if the 1-shot re-probe retry fired.
142
+ return {
143
+ text: `entwurf_v2 native-push → delivered${o.retried ? " (after a 1-shot re-probe retry)" : ""}`,
144
+ isError: false,
145
+ };
146
+ }
130
147
  // meta-mailbox
131
148
  return { text: "entwurf_v2 meta-mailbox → enqueued", isError: false };
132
149
  }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * meta-sender-identity — WHO is calling this MCP child?
3
+ *
4
+ * A native backend (Claude Code, agy) spawns the entwurf-bridge MCP server as a child of the
5
+ * same process that runs its session hook. The hook writes a sender marker keyed by ITS parent
6
+ * pid; the child looks a marker up under its own parent. That shared ancestor is the join key —
7
+ * NOT cwd (one repo can hold many sessions) and not a wire field (neither host carries one).
8
+ *
9
+ * Measured 2026-07-13 on both backends: hook.ppid == bridge.ppid == the native host pid, same
10
+ * start-key. The extra `parentPid(ppid)` candidate covers a host that runs its hook through a
11
+ * shell wrapper, which shifts the shared ancestor one step up.
12
+ *
13
+ * Two guards make a marker an IDENTITY rather than a hint, and a candidate is only trusted after
14
+ * BOTH pass:
15
+ * 1. pid + start-key (readMetaSenderMarker): the owner is still the very process that wrote it,
16
+ * so a dead session's pid, reused by something else, cannot inherit its garden-id.
17
+ * 2. the backing meta-record: the record store is the authority — a marker whose record was
18
+ * deleted, or whose backend/nativeSessionId drifted from it, names nobody.
19
+ *
20
+ * Every candidate is collected and validated BEFORE one is chosen. A first-match loop would make
21
+ * the answer depend on which pid or backend happened to be read first; here lookup order carries
22
+ * no meaning, and two live identities are a refusal rather than a race.
23
+ */
24
+ import { parentPid, readMetaIdentityByGardenId, readMetaSenderMarker, } from "./meta-session.js";
25
+ import { resolveNativePushAdapter } from "./native-push/adapter.js";
26
+ /** Every native backend that mints a garden-id from its own hook and writes a sender marker. */
27
+ export const META_SENDER_BACKENDS = ["claude-code", "antigravity"];
28
+ /**
29
+ * Two live native sessions claim this MCP process as their own. We can SEE both identities but
30
+ * cannot say which one is calling, so we send under neither. Choosing by lookup order, backend
31
+ * priority, or recency would attribute a message to a citizen that did not write it; falling back
32
+ * to anonymous would hide an identity we already hold. Both are the SE-1 shape — a layer answering
33
+ * "yes" where the truth is unknown — so this is a hard refusal, not a warning.
34
+ *
35
+ * WHAT THIS CAN AND CANNOT SEE. It fires only when the candidate pids yield MORE THAN ONE marker
36
+ * FILE naming different citizens — two backends on one pid, or the two candidate pids naming
37
+ * different citizens. It CANNOT see two conversations of the same backend racing under one host
38
+ * pid: they share the single marker path `<senders>/<backend>/<pid>.json`, so the later hook write
39
+ * overwrites the earlier one and only one identity is ever on disk. That case is guarded by a
40
+ * runtime invariant instead (a native host serializes its model invocations), not by this error —
41
+ * do not read a green resolver as proof that same-process concurrency is safe.
42
+ */
43
+ export class EntwurfSenderIdentityAmbiguityError extends Error {
44
+ gardenIds;
45
+ constructor(gardenIds) {
46
+ super("entwurf-bridge refused: ambiguous sender identity. This MCP process's owner pid carries live, " +
47
+ `record-backed sender markers for MORE than one garden citizen (${gardenIds.join(", ")}), so which ` +
48
+ "one is calling cannot be determined. A send under the wrong identity is worse than no send — one " +
49
+ "native host process appears to be driving several sessions at once. Report it; do not work around it.");
50
+ this.name = "EntwurfSenderIdentityAmbiguityError";
51
+ this.gardenIds = gardenIds;
52
+ }
53
+ }
54
+ /** The record store is the authority; the marker is only a pid→garden hint it must agree with. */
55
+ function trustMarker(marker) {
56
+ try {
57
+ // dual-read (3D-4 commit1): identity-only check, so it survives the v2 cut.
58
+ const identity = readMetaIdentityByGardenId(marker.gardenId);
59
+ if (identity.backend !== marker.backend || identity.nativeSessionId !== marker.nativeSessionId)
60
+ return null;
61
+ return { marker, identity };
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /**
68
+ * Resolve the ONE identity that owns this MCP process, or refuse.
69
+ *
70
+ * 0 trusted → null (anonymous — or a hard refusal upstream under REQUIRE_META_SENDER).
71
+ * 1 trusted → that identity.
72
+ * 2+ distinct → throw EntwurfSenderIdentityAmbiguityError.
73
+ *
74
+ * Markers that agree on the SAME garden-id are not a conflict: an older release wrote a marker for
75
+ * the parent AND the grandparent, and both can still sit on disk pointing at one citizen.
76
+ */
77
+ export function resolveTrustedMetaSenderIdentity(opts = {}) {
78
+ const markers = [];
79
+ if (opts.markerPath) {
80
+ const marker = readMetaSenderMarker({ markerPath: opts.markerPath });
81
+ if (marker)
82
+ markers.push(marker);
83
+ }
84
+ else {
85
+ const ownerPids = (opts.ownerPids ?? [process.ppid, parentPid(process.ppid) ?? 0]).filter((p) => typeof p === "number" && p > 0);
86
+ for (const ownerPid of [...new Set(ownerPids)]) {
87
+ for (const backend of META_SENDER_BACKENDS) {
88
+ const marker = readMetaSenderMarker({ backend, ownerPid, sendersDir: opts.sendersDir });
89
+ if (marker)
90
+ markers.push(marker);
91
+ }
92
+ }
93
+ }
94
+ const byGardenId = new Map();
95
+ for (const marker of markers) {
96
+ const trusted = trustMarker(marker);
97
+ if (trusted)
98
+ byGardenId.set(trusted.identity.gardenId, trusted);
99
+ }
100
+ const distinct = [...byGardenId.values()];
101
+ if (distinct.length === 0)
102
+ return null;
103
+ if (distinct.length > 1)
104
+ throw new EntwurfSenderIdentityAmbiguityError(distinct.map((t) => t.identity.gardenId));
105
+ return distinct[0];
106
+ }
107
+ /**
108
+ * Can a reply to THIS native-push citizen actually land? Only an adapter probe can say: a reply is
109
+ * a direct injection into a live app-server conversation, and the route is re-discovered on every
110
+ * probe. This is the `replyable` fact for a native-push sender — it must never be inferred from the
111
+ * mailbox axis, which such a backend has no part in (보정①).
112
+ *
113
+ * ERROR POLICY (deliberate, and the reason this is not a `try { … } catch { return false }`): the
114
+ * adapter already expresses every OPERATIONAL outcome as a value — `dead` (no host) and
115
+ * `indeterminate` (host up, no port served it). So a THROW out of here is never a fact about the
116
+ * citizen; it is a defect — an unresolvable adapter for a backend we just confirmed is native-push
117
+ * (a registry bug), or a probe runner that could not run at all (a wiring bug). Folding those into
118
+ * `replyable:false` would tell the receiver a lie about this sender AND bury the defect, which is
119
+ * exactly the Crash-Don't-Warn shape this lane exists to remove. Let them propagate.
120
+ */
121
+ export async function probeNativeSenderAlive(identity, deps = {}) {
122
+ const resolveAdapter = deps.resolveAdapter ?? resolveNativePushAdapter;
123
+ const probe = await resolveAdapter(identity.backend).probe(identity.nativeSessionId);
124
+ return probe.status === "alive";
125
+ }