@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,13 +24,29 @@
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
42
 
30
- import { computeMetaReceiverActive } from "./entwurf-deliverability.ts";
43
+ import { computeMetaReceiverActive, nativePushDeliverable } from "./entwurf-deliverability.ts";
31
44
 
32
45
  export type SelfOrigin = "pi-session" | "meta-session" | "external-mcp";
33
46
 
47
+ /** Which delivery rail carries a reply back to a meta citizen (the second axis, never `origin`). */
48
+ export type MetaDeliveryDomain = "self-fetch" | "native-push";
49
+
34
50
  /**
35
51
  * pi control-socket reachability for a reply addressed back to this session.
36
52
  *
@@ -49,12 +65,16 @@ export interface SelfAddressabilityFacts {
49
65
  socketAlive?: boolean;
50
66
  /** pi-session: a session id is present so the canonical socket path is computable. */
51
67
  socketPathComputable?: boolean;
68
+ /** meta-session: which rail a reply rides — from nativePushSupported(backend), NOT wakeMode. */
69
+ metaDeliveryDomain?: MetaDeliveryDomain;
52
70
  /** meta-session: the sender marker's identity is backed by a live meta-record. */
53
71
  recordBacked?: boolean;
54
- /** meta-session: the marker's owner pid is still the same process (start-key match). */
72
+ /** meta-session (self-fetch): the marker's owner pid is still the same process (start-key match). */
55
73
  ownerAlive?: boolean;
56
- /** meta-session: the idle-wake watch is armed (slice-2 presence marker; fail-closed until then). */
74
+ /** meta-session (self-fetch): the idle-wake watch is armed (slice-2 presence marker; fail-closed until then). */
57
75
  watchArmed?: boolean;
76
+ /** meta-session (native-push): an adapter probe found this citizen's live native conversation. */
77
+ probeAlive?: boolean;
58
78
  }
59
79
 
60
80
  export interface SelfAddressabilityResult {
@@ -89,18 +109,41 @@ export function computeSelfAddressability(facts: SelfAddressabilityFacts): SelfA
89
109
  };
90
110
  }
91
111
  case "meta-session": {
92
- // Share the active-receiver atom with the deliverability predicate (one
93
- // source of truth for "record backed AND owner alive AND watch armed").
94
- const recv = computeMetaReceiverActive({
95
- recordBacked: facts.recordBacked,
96
- ownerAlive: facts.ownerAlive,
97
- watchArmed: facts.watchArmed,
98
- });
99
- return {
100
- replyable: recv.active,
101
- socketState: "none",
102
- reason: recv.active ? `meta receiver active (${recv.reason})` : `meta receiver inactive — ${recv.reason}`,
103
- };
112
+ // TWO rails, pinned apart (보정①). Each branch composes the predicate that OWNS its
113
+ // axis the mailbox receiver atom and the native-push predicate share nothing, so a
114
+ // mailbox liveness fact can never leak into a backend that has no mailbox.
115
+ switch (facts.metaDeliveryDomain) {
116
+ case "native-push": {
117
+ const push = nativePushDeliverable({ recordBacked: facts.recordBacked, probeAlive: facts.probeAlive });
118
+ return {
119
+ replyable: push.deliverable,
120
+ socketState: "none",
121
+ reason: push.deliverable
122
+ ? `native-push reachable (${push.reason})`
123
+ : `native-push unreachable — ${push.reason}`,
124
+ };
125
+ }
126
+ case "self-fetch": {
127
+ // Share the active-receiver atom with the deliverability predicate (one
128
+ // source of truth for "record backed AND owner alive AND watch armed").
129
+ const recv = computeMetaReceiverActive({
130
+ recordBacked: facts.recordBacked,
131
+ ownerAlive: facts.ownerAlive,
132
+ watchArmed: facts.watchArmed,
133
+ });
134
+ return {
135
+ replyable: recv.active,
136
+ socketState: "none",
137
+ reason: recv.active ? `meta receiver active (${recv.reason})` : `meta receiver inactive — ${recv.reason}`,
138
+ };
139
+ }
140
+ default:
141
+ return {
142
+ replyable: false,
143
+ socketState: "none",
144
+ reason: "meta delivery domain not supplied — cannot say which rail a reply would ride (fail-closed)",
145
+ };
146
+ }
104
147
  }
105
148
  case "external-mcp":
106
149
  return {
@@ -67,12 +67,19 @@ export const DISPATCH_LIVENESSES = ["live", "dormant", "indeterminate"] as const
67
67
  export type DispatchLiveness = (typeof DISPATCH_LIVENESSES)[number];
68
68
 
69
69
  // ── Backend liveness domain (R1 + F4) ──────────────────────────────────────
70
- // Backends whose liveness predicate is DEFINED. Initial = pi only (control-socket
71
- // connect + RPC `get_info`, entwurf-control.ts). claude-code (self-fetch, no
72
- // socket) and codex/antigravity (direct-inject without a probe surface yet) are
73
- // OUT of domain `unsupported`. Widening this set is a deliberate future
74
- // decision (Stage 1+), gated by a REAL liveness predicate for that backend —
75
- // never by silently mapping its sessions to dead/indeterminate (R1 핵심).
70
+ // Backends whose SOCKET liveness predicate is DEFINED the pi control-socket
71
+ // domain ONLY (connect + RPC `get_info`, entwurf-control.ts). It stays ["pi"].
72
+ // claude-code (self-fetch, no socket) has no liveness predicate at all `unsupported`.
73
+ // codex/antigravity are direct-inject; antigravity's liveness IS measured, but by the
74
+ // SEPARATE native-push adapter rail (a live app-server conversation probe), NOT this
75
+ // pi-socket domain so it must NEVER be added here. Adding it would pull agy into the
76
+ // pi socket table (inspectSocket/probeSocket are socket-only); the fact layer keeps
77
+ // reporting agy `unsupported` = "outside the pi-socket liveness domain", NOT
78
+ // unreachable (the native-push rail measures it — entwurf-v2-decider.ts). Widening
79
+ // THIS set is a deliberate future decision (Stage 1+), gated by a REAL pi-shaped
80
+ // control-socket predicate — never by silently mapping sessions to dead/indeterminate
81
+ // (R1 핵심). check-entwurf-facts pins this == ["pi"] and asserts the native-push
82
+ // domain is disjoint from it.
76
83
  export const LIVENESS_DOMAIN_BACKENDS = ["pi"] as const;
77
84
  export type LivenessDomainBackend = (typeof LIVENESS_DOMAIN_BACKENDS)[number];
78
85
 
@@ -80,6 +87,30 @@ export function isLivenessSupported(backend: string): boolean {
80
87
  return (LIVENESS_DOMAIN_BACKENDS as readonly string[]).includes(backend);
81
88
  }
82
89
 
90
+ // ── Native-push backend domain (봉인 2/4) ───────────────────────────────────
91
+ // A backend whose liveness is measured by the SEPARATE native-push adapter rail (a
92
+ // live app-server conversation probe — antigravity's LS gRPC), NOT the pi control
93
+ // socket. This domain is DISJOINT from LIVENESS_DOMAIN_BACKENDS (pi socket): an agy
94
+ // session is `unsupported` at the pi-socket FACT level (entwurf_peers) yet fully
95
+ // measured + deliverable on the native-push axis. The two are separate rails on
96
+ // purpose — check-entwurf-facts pins both sets and asserts their intersection is ∅
97
+ // (a backend can never be in both a socket-liveness domain and a native-push domain).
98
+ export const NATIVE_PUSH_BACKENDS = ["antigravity"] as const;
99
+ export type NativePushBackend = (typeof NATIVE_PUSH_BACKENDS)[number];
100
+
101
+ export function nativePushSupported(backend: string): backend is NativePushBackend {
102
+ return (NATIVE_PUSH_BACKENDS as readonly string[]).includes(backend);
103
+ }
104
+
105
+ // NativePushLiveness = the 3-value liveness the native-push adapter probe yields.
106
+ // The SAME three values as SocketLiveness, reused so there is ONE 3-value liveness
107
+ // vocabulary — but these are NOT socket-bound (봉인 2: "주석만 socket-전용 오독 정정"):
108
+ // the value is a live-app-server-conversation probe result (agentapi
109
+ // get-conversation-metadata answered = alive; no live port served the conv = dead;
110
+ // probe error/ambiguity = indeterminate). All three are valid FactLiveness values, so
111
+ // a native-push receipt stamps observedLiveness ∈ {alive, dead, indeterminate}.
112
+ export type NativePushLiveness = SocketLiveness;
113
+
83
114
  /**
84
115
  * Compose the 4-value FACT liveness from a backend and its socket probe.
85
116
  * Out-of-domain backend → `unsupported` (NOT dead/indeterminate, R1). An
@@ -106,6 +137,9 @@ export const ENTWURF_V2_REJECT_REASONS = [
106
137
  "owned-live-no-autosend", // Q2/F1: owned-outcome to a live target is not an auto-send
107
138
  "backend-liveness-unsupported", // R1: backend has no liveness predicate (e.g. claude-code) — owned-outcome only
108
139
  "mailbox-undeliverable", // F-mailbox: fire-and-forget to an unsupported citizen whose mailbox is not deliverable (fail-closed; future pi-backend non-drainable mailbox)
140
+ "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.
141
+ "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.
142
+ "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.
109
143
  "bad-target", // R2: absent/typo garden-id (no existing citizen); spawn-new out of v2 scope
110
144
  "untrusted-fail-fast", // 동결결정 5: controlled launch into an untrusted cwd
111
145
  "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.
@@ -183,7 +217,18 @@ export const RESOLVER_REJECT_REASONS = [
183
217
  // `meta-mailbox` (F-mailbox) = liveness-free delivery via the 0.10.0 meta-bridge
184
218
  // mailbox + doorbell. The ack is "enqueued + doorbell rung", NOT a read and NOT a
185
219
  // turn injection — so `mode` (steer/follow_up) is meaningless on this transport.
186
- export const ENTWURF_V2_TRANSPORTS = ["control-socket", "spawn-bg", "tmux-live", "meta-mailbox"] as const;
220
+ // `native-push` (봉인 1) = direct injection into a LIVE native app-server conversation
221
+ // (antigravity `agentapi send-message`). Like meta-mailbox it is a fire-and-forget
222
+ // send arm (ack-only), but it requires a live-probe (NATIVE_PUSH_DISPATCH_TABLE),
223
+ // where meta-mailbox is liveness-free. It is NOT a mailbox enqueue and NOT a pi socket
224
+ // send — it is its own rail.
225
+ export const ENTWURF_V2_TRANSPORTS = [
226
+ "control-socket",
227
+ "spawn-bg",
228
+ "tmux-live",
229
+ "meta-mailbox",
230
+ "native-push",
231
+ ] as const;
187
232
  export type EntwurfV2Transport = (typeof ENTWURF_V2_TRANSPORTS)[number];
188
233
 
189
234
  // Allow-branch facets (exported so the schema↔types gate asserts every enum).
@@ -196,7 +241,7 @@ export const ENTWURF_V2_OWNERSHIPS = ["ack-only", "owned"] as const;
196
241
  export const ENTWURF_V2_MODES = ["steer", "follow_up"] as const;
197
242
 
198
243
  export type DispatchVerdict =
199
- | { action: "send"; transport: "control-socket" | "meta-mailbox"; ownership: "ack-only" }
244
+ | { action: "send"; transport: "control-socket" | "meta-mailbox" | "native-push"; ownership: "ack-only" }
200
245
  | { action: "resume"; transport: "spawn-bg" | "tmux-live"; ownership: "owned" }
201
246
  | { action: "reject"; reason: EntwurfV2RejectReason };
202
247
 
@@ -220,10 +265,13 @@ export const DISPATCH_TABLE: Record<EntwurfIntent, Record<DispatchLiveness, Disp
220
265
  };
221
266
 
222
267
  // ── The unsupported-backend mailbox mini-table (F-mailbox) ─────────────────
223
- // SEPARATE from the in-domain 6-cell DISPATCH_TABLE (Fable (i)): an `unsupported`
224
- // backend (claude-code self-fetch, codex/agy without a probe surface) has NO
225
- // liveness predicate, so it never enters the liveness-keyed table. Instead the
226
- // domain guard routes it here, keyed on intent alone:
268
+ // SEPARATE from the in-domain 6-cell DISPATCH_TABLE (Fable (i)): a backend with no
269
+ // pi-socket liveness predicate never enters the liveness-keyed table. Instead the
270
+ // domain guard routes it here, keyed on intent alone. Reaches here: claude-code
271
+ // (self-fetch mailbox) and codex (no adapter yet). Does NOT reach here: antigravity —
272
+ // the decider intercepts a native-push backend in its own rail BEFORE this mailbox
273
+ // mini-table (entwurf-v2-decider.ts), so agy is `unsupported` at the fact level yet
274
+ // never falls through to a mailbox it does not have. The cells, keyed on intent alone:
227
275
  // - fire-and-forget needs no liveness — the 0.10.0 meta-bridge mailbox delivers
228
276
  // to any DELIVERABLE citizen. This cell is the deliverable path; resolveDispatch
229
277
  // downgrades it to `mailbox-undeliverable` when the separate mailboxDeliverable
@@ -351,6 +399,66 @@ export function resolveDispatch(
351
399
  };
352
400
  }
353
401
 
402
+ // ── The native-push dispatch table (봉인 1/2/4) ─────────────────────────────
403
+ // A THIRD table, distinct from both the pi 6-cell DISPATCH_TABLE and the unsupported
404
+ // mailbox mini-table. Keyed intent × NativePushLiveness (NOT intent-only): a
405
+ // native-push backend (antigravity) IS measured by its adapter probe, so the
406
+ // send/reject decision depends on the probed liveness. The decider intercepts a
407
+ // native-push backend in its own rail (nativePushSupported → probe → this table)
408
+ // BEFORE the unsupported branch, so agy never falls through to a mailbox it lacks.
409
+ //
410
+ // fire-and-forget × alive → native-push send (the ONE allow cell)
411
+ // fire-and-forget × dead → reject native-push-target-dead
412
+ // fire-and-forget × indeterminate → reject native-push-probe-indeterminate
413
+ // owned-outcome × * → reject native-push-no-resume-authority (state-
414
+ // independent: no pi-child to own; `backend-
415
+ // liveness-unsupported` is NOT reused — false name).
416
+ export const NATIVE_PUSH_DISPATCH_TABLE: Record<EntwurfIntent, Record<NativePushLiveness, DispatchVerdict>> = {
417
+ "fire-and-forget": {
418
+ alive: { action: "send", transport: "native-push", ownership: "ack-only" },
419
+ dead: { action: "reject", reason: "native-push-target-dead" },
420
+ indeterminate: { action: "reject", reason: "native-push-probe-indeterminate" },
421
+ },
422
+ "owned-outcome": {
423
+ alive: { action: "reject", reason: "native-push-no-resume-authority" },
424
+ dead: { action: "reject", reason: "native-push-no-resume-authority" },
425
+ indeterminate: { action: "reject", reason: "native-push-no-resume-authority" },
426
+ },
427
+ };
428
+
429
+ // The reasons the native-push resolver emits — a THIRD post-probe reject set, parallel
430
+ // to RESOLVER_REJECT_REASONS (pi/mailbox). All post-probe: resolveNativePushDispatch
431
+ // always has a real probed liveness in hand, so observedLiveness is non-null. None may
432
+ // be pre-probe (they are never in PRE_PROBE_REJECT_REASONS).
433
+ export const NATIVE_PUSH_REJECT_REASONS = [
434
+ "native-push-target-dead",
435
+ "native-push-probe-indeterminate",
436
+ "native-push-no-resume-authority",
437
+ ] as const satisfies readonly EntwurfV2RejectReason[];
438
+
439
+ /**
440
+ * PURE native-push dispatch decision (봉인 4). Given the caller intent and the adapter
441
+ * probe's 3-value liveness, mint the receipt from NATIVE_PUSH_DISPATCH_TABLE. Mirrors
442
+ * resolveDispatch's shape; observedLiveness is ALWAYS the probed value (non-null,
443
+ * post-probe). The decider calls this only AFTER nativePushSupported(backend) gates the
444
+ * backend and the adapter probe returns a liveness — it never touches the pi socket
445
+ * table or the mailbox mini-table (those are other domains). No IO here (the probe is
446
+ * the decider's injected dep); this only maps (intent, liveness) → verdict.
447
+ */
448
+ export function resolveNativePushDispatch(intent: EntwurfIntent, liveness: NativePushLiveness): EntwurfV2Receipt {
449
+ const cell = NATIVE_PUSH_DISPATCH_TABLE[intent][liveness];
450
+ if (cell.action === "reject") {
451
+ return makeRejectReceipt(cell.reason, liveness);
452
+ }
453
+ return {
454
+ ok: true,
455
+ action: cell.action,
456
+ transport: cell.transport,
457
+ ownership: cell.ownership,
458
+ observedLiveness: liveness,
459
+ };
460
+ }
461
+
354
462
  // ── TypeBox schemas ────────────────────────────────────────────────────────
355
463
  // MOVED to `entwurf-v2-contract-schema.ts` (0.12.1 B-1): the pi-ai TypeBox
356
464
  // builders (StringEnum/Type) are a pi-lane dependency, so they cannot live in
@@ -52,7 +52,10 @@ import {
52
52
  type EntwurfV2Receipt,
53
53
  isLivenessSupported,
54
54
  makeRejectReceipt,
55
+ type NativePushBackend,
56
+ nativePushSupported,
55
57
  resolveDispatch,
58
+ resolveNativePushDispatch,
56
59
  } from "./entwurf-v2-contract.ts";
57
60
  import type { AcquireLockResult, LockClaim, LockConflict } from "./entwurf-v2-lock.ts";
58
61
  import {
@@ -63,6 +66,7 @@ import {
63
66
  type MetaIdentity,
64
67
  metaCapabilityFor,
65
68
  } from "./meta-session.ts";
69
+ import type { NativePushProbeResult, NativePushRoute } from "./native-push/adapter.ts";
66
70
  import { isValidSessionId } from "./session-id.js";
67
71
  import { controlSocketPath, mapInspectionToLiveness, type TargetSocketInspection } from "./socket-discovery.ts";
68
72
  import type { SocketLiveness } from "./socket-probe.ts";
@@ -124,6 +128,21 @@ export type ExecutionPlan =
124
128
  expectedSocketPath: string;
125
129
  observeTimeoutMs: number;
126
130
  releaseWhen: "socket-alive-or-child-exited";
131
+ }
132
+ // native-push send (봉인 4): direct-inject into a live app-server conversation. LOCK-FREE
133
+ // (the DispatchDecision carries lock:null). Carries the decider-probed VOLATILE route so
134
+ // the executor sends without re-deriving it (봉인 3 "used within the same dispatch"); the
135
+ // executor still owns the 1-shot re-probe→re-send on failure. `backend` lets the executor
136
+ // resolve the adapter for that re-probe.
137
+ | {
138
+ transport: "native-push";
139
+ action: "send";
140
+ targetGardenId: string;
141
+ backend: NativePushBackend;
142
+ nativeSessionId: string;
143
+ route: NativePushRoute;
144
+ wantsReply: boolean;
145
+ message: string;
127
146
  };
128
147
 
129
148
  // ── DispatchDecision (the decider's only output) ────────────────────────────
@@ -212,6 +231,16 @@ export interface DispatchDeciderDeps {
212
231
  mailboxDeliverabilityFor: (
213
232
  identity: MetaIdentity,
214
233
  ) => MailboxDeliverabilityResult | Promise<MailboxDeliverabilityResult>;
234
+ /**
235
+ * 봉인 4: the native-push liveness+route probe seam (REQUIRED, no default). Called ONLY on
236
+ * the native-push branch (a nativePushSupported backend, e.g. antigravity), it returns the
237
+ * adapter probe result — the 3-value liveness the NATIVE_PUSH table routes on PLUS the
238
+ * volatile route the executor sends over. The decider does NOT probe itself (purity); the
239
+ * production wrapper resolves the native-push adapter and calls its probe. Making it
240
+ * required forces every construction site to wire it, so a native-push dispatch can never
241
+ * silently fall through to the pi-socket / mailbox path.
242
+ */
243
+ nativePushProbe: (identity: MetaIdentity) => NativePushProbeResult | Promise<NativePushProbeResult>;
215
244
  mailboxDir?: string;
216
245
  sessionsDir?: string;
217
246
  observeTimeoutMs?: number;
@@ -298,6 +327,37 @@ export async function decideDispatch(input: DispatchInput, deps: DispatchDecider
298
327
  }
299
328
  const identity = resolution.identity;
300
329
 
330
+ // 2d. native-push rail (봉인 4): a native-push backend (antigravity) is measured by its
331
+ // adapter probe, NOT the pi socket and NOT the mailbox. Intercept it HERE — after identity
332
+ // resolution + the address-conflict guard, but BEFORE the unsupported mailbox branch — so
333
+ // agy routes to native-push and never falls through to a mailbox it does not have. This
334
+ // branch is LOCK-FREE (봉인 4): the pi in-domain lock closes a socket TOCTOU, but a
335
+ // volatile probe route has no lock meaning (a duplicate-send idempotency is a D8 future).
336
+ if (nativePushSupported(identity.backend)) {
337
+ const probe = await deps.nativePushProbe(identity);
338
+ const receipt = resolveNativePushDispatch(input.intent, probe.status);
339
+ if (!receipt.ok) return reject(receipt);
340
+ // The ONLY allow cell is fire-and-forget × alive, so an ok receipt ⟹ the probe is
341
+ // alive and carries a route. The narrow is defensive: a contract-breaking probe/table
342
+ // mismatch fails loud rather than planting a routeless send plan.
343
+ if (probe.status !== "alive") {
344
+ throw new Error(
345
+ "entwurf_v2 decider: native-push send verdict without an alive probe route (contract invariant broken).",
346
+ );
347
+ }
348
+ const plan: ExecutionPlan = {
349
+ transport: "native-push",
350
+ action: "send",
351
+ targetGardenId: gardenId,
352
+ backend: identity.backend,
353
+ nativeSessionId: identity.nativeSessionId,
354
+ route: probe.route,
355
+ wantsReply,
356
+ message: input.message,
357
+ };
358
+ return { kind: "execute", receipt, plan, lock: null };
359
+ }
360
+
301
361
  // 3. backend.
302
362
  if (!isLivenessSupported(identity.backend)) {
303
363
  // 6. unsupported path — NO lock (?7). Deliverability comes from the REQUIRED seam
@@ -0,0 +1,86 @@
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
+
19
+ import type { ExecutionPlan } from "./entwurf-v2-decider.ts";
20
+ import type { LockClaim } from "./entwurf-v2-lock.ts";
21
+ import { type NativePushAdapter, type NativePushRoute, resolveNativePushAdapter } from "./native-push/adapter.ts";
22
+
23
+ /** The native-push slice of the ExecutionPlan union (the decider plants it, this consumes it). */
24
+ export type NativePushPlan = Extract<ExecutionPlan, { transport: "native-push" }>;
25
+
26
+ export interface NativePushSendResult {
27
+ success: true;
28
+ /** Whether the 1-shot re-probe→re-send fired (the first send over the planted route failed). */
29
+ retried: boolean;
30
+ }
31
+
32
+ /**
33
+ * Deliver `content` into the conversation, owning the 1-shot retry (봉인 3). Send over the
34
+ * planted (decider-probed) route first; on failure re-probe ONCE and re-send over the fresh
35
+ * route; a second failure PROPAGATES (fail-loud). If the re-probe finds the target no longer
36
+ * alive, throw — a failed send into a now-dead conversation is an honest non-delivery, never
37
+ * a silent success. The re-probe is the ONLY re-derivation of the volatile route.
38
+ */
39
+ export async function deliverViaNativePush(
40
+ adapter: NativePushAdapter,
41
+ route: NativePushRoute,
42
+ nativeSessionId: string,
43
+ content: string,
44
+ ): Promise<NativePushSendResult> {
45
+ try {
46
+ await adapter.send(route, nativeSessionId, content);
47
+ return { success: true, retried: false };
48
+ } catch (firstErr) {
49
+ // 1-shot re-probe → re-send: the volatile LS route may have shifted since the decider
50
+ // probed it. Re-discover it fresh and retry exactly once.
51
+ const reprobe = await adapter.probe(nativeSessionId);
52
+ if (reprobe.status !== "alive") {
53
+ throw new Error(
54
+ `native-push deliver: first send failed and re-probe found target ${reprobe.status} (${reprobe.reason}) — not retried`,
55
+ { cause: firstErr },
56
+ );
57
+ }
58
+ // A second failure THROWS out of this call (fail-loud) — no third attempt.
59
+ await adapter.send(reprobe.route, nativeSessionId, content);
60
+ return { success: true, retried: true };
61
+ }
62
+ }
63
+
64
+ /** Deps for the production native-push send hand — the adapter resolver (default: the real
65
+ * registry). Injected so the 5d gate proves the wiring with a fake adapter. */
66
+ export interface NativePushSendDeps {
67
+ resolveAdapter?: (backend: string) => NativePushAdapter;
68
+ }
69
+
70
+ /**
71
+ * Build the production `sendNativePush(plan, lock)` adapter the runner consumes. It IGNORES
72
+ * `lock` entirely (a native-push plan is lock-free, 봉인 4) — the field exists only to match
73
+ * the DispatchExecutorDeps hand signature. It resolves the adapter from the plan's backend
74
+ * and delivers with the 1-shot retry. A delivery throw surfaces as a REJECTED promise (the
75
+ * runner's try/catch maps it to execution-failed).
76
+ */
77
+ export function makeNativePushSend(
78
+ deps: NativePushSendDeps = {},
79
+ ): (plan: NativePushPlan, lock: LockClaim | null) => Promise<NativePushSendResult> {
80
+ const resolveAdapter = deps.resolveAdapter ?? resolveNativePushAdapter;
81
+ // `_lock` is named for the hand contract but NEVER read — native-push owns/releases no lock.
82
+ return async (plan: NativePushPlan, _lock: LockClaim | null): Promise<NativePushSendResult> => {
83
+ const adapter = resolveAdapter(plan.backend);
84
+ return deliverViaNativePush(adapter, plan.route, plan.nativeSessionId, plan.message);
85
+ };
86
+ }
@@ -60,6 +60,7 @@ import {
60
60
  releaseLock as realReleaseLock,
61
61
  } from "./entwurf-v2-lock.ts";
62
62
  import { makeProductionSendViaMailbox } from "./entwurf-v2-mailbox.ts";
63
+ import { makeNativePushSend } from "./entwurf-v2-native-push.ts";
63
64
  import type { DispatchExecutorDeps, EntwurfV2RunDeps } from "./entwurf-v2-runner.ts";
64
65
  import {
65
66
  type ControlSocketPlan,
@@ -85,6 +86,11 @@ import {
85
86
  readMetaIdentityByGardenId,
86
87
  readMetaReceiverMarker,
87
88
  } from "./meta-session.ts";
89
+ import {
90
+ type NativePushAdapter,
91
+ type NativePushProbeResult,
92
+ resolveNativePushAdapter as realResolveNativePushAdapter,
93
+ } from "./native-push/adapter.ts";
88
94
  import {
89
95
  CONTROL_SOCKET_DIR,
90
96
  controlSocketPath,
@@ -121,6 +127,11 @@ export interface ProductionEntwurfV2Seams {
121
127
  classifyConnect: (code: string | undefined) => "dead" | "indeterminate";
122
128
  sendRpc: (socketPath: string, command: RpcCommand, options?: RpcClientOptions) => Promise<{ response: RpcResponse }>;
123
129
  enqueue: (opts: EnqueueMetaMessageOptions) => EnqueueMetaMessageResult;
130
+ /** Resolve the native-push adapter for a backend id (봉인 4). The SAME resolver feeds the
131
+ * decider's `nativePushProbe` AND the executor's `sendNativePush`, so a single injected
132
+ * fake adapter drives both the probe (routing decision) and the send (delivery + retry).
133
+ * Default: the real registry resolver. */
134
+ resolveNativePushAdapter: (backend: string) => NativePushAdapter;
124
135
  /** Extra spawn-factory overrides (timers/spawnChild/probe) for a deterministic spawn gate.
125
136
  * `releaseFn` is NOT overridable here — the factory injects the shared `release` (QB3). */
126
137
  spawnOverrides: Omit<ProductionSpawnOpts, "releaseFn">;
@@ -211,6 +222,7 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
211
222
  classifyConnect: s.classifyConnect ?? classifyConnectError,
212
223
  sendRpc: s.sendRpc ?? realSendRpc,
213
224
  enqueue: s.enqueue ?? enqueueMetaMessage,
225
+ resolveNativePushAdapter: s.resolveNativePushAdapter ?? realResolveNativePushAdapter,
214
226
  spawnOverrides: s.spawnOverrides ?? {},
215
227
  };
216
228
 
@@ -290,6 +302,11 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
290
302
  preflightForCwd: (cwd: string): PreflightOutcome | Promise<PreflightOutcome> =>
291
303
  io.preflight({ cwd, agentDir: opts.agentDir, prefixRoots: opts.prefixRoots }),
292
304
  mailboxDeliverabilityFor,
305
+ // 봉인 4: resolve the native-push adapter for this backend + probe the conversation.
306
+ // Only reached on a nativePushSupported backend (the decider gates it), so the resolver
307
+ // never throws for a non-native-push backend here.
308
+ nativePushProbe: (identity: MetaIdentity): Promise<NativePushProbeResult> =>
309
+ Promise.resolve(io.resolveNativePushAdapter(identity.backend).probe(identity.nativeSessionId)),
293
310
  mailboxDir,
294
311
  sessionsDir,
295
312
  observeTimeoutMs: opts.observeTimeoutMs,
@@ -339,6 +356,9 @@ export function makeProductionEntwurfV2Deps(opts: ProductionEntwurfV2Opts): Entw
339
356
  }),
340
357
  ),
341
358
  sendMailbox: (plan, _lock) => sendViaMailbox(plan as MetaMailboxPlan, _lock as LockClaim),
359
+ // native-push (봉인 4): the SAME injected adapter resolver drives the executor send,
360
+ // so the decider's probe and the delivery use one adapter. Lock-free (lock ignored).
361
+ sendNativePush: makeNativePushSend({ resolveAdapter: io.resolveNativePushAdapter }),
342
362
  };
343
363
 
344
364
  return {
@@ -96,6 +96,15 @@ export function decideReleasePolicy(plan: ExecutionPlan, lock: LockClaim | null)
96
96
  throw new Error("entwurf-v2-release: a meta-mailbox plan must carry no lock (?7 invariant violated).");
97
97
  }
98
98
  return { kind: "no-lock" };
99
+ case "native-push":
100
+ // Native-push is the other lock-free rail (봉인 4) — like meta-mailbox it holds no
101
+ // lock, so a non-null lock is a decider contract violation. (The native-push hand
102
+ // never actually routes through this reducer; the case keeps the switch exhaustive
103
+ // and pins the lock-free invariant.)
104
+ if (lock !== null) {
105
+ throw new Error("entwurf-v2-release: a native-push plan must carry no lock (봉인 4 lock-free rail violated).");
106
+ }
107
+ return { kind: "no-lock" };
99
108
  case "control-socket":
100
109
  assertInDomainLock(plan, lock);
101
110
  return { kind: "release-after-send-final" };
@@ -38,6 +38,7 @@ import type {
38
38
  SuccessReceipt,
39
39
  } from "./entwurf-v2-decider.ts";
40
40
  import type { LockClaim } from "./entwurf-v2-lock.ts";
41
+ import type { NativePushPlan, NativePushSendResult } from "./entwurf-v2-native-push.ts";
41
42
  import {
42
43
  type ControlSocketPlan,
43
44
  type ControlSocketSendResult,
@@ -58,6 +59,9 @@ export interface DispatchExecutorDeps {
58
59
  sendControl: (plan: ControlSocketPlan, lock: LockClaim | null) => Promise<ControlSocketSendResult>;
59
60
  resumeSpawnBg: (plan: SpawnBgPlan, lock: LockClaim | null) => Promise<SpawnBgResumeResult>;
60
61
  sendMailbox: (plan: MetaMailboxPlan, lock: LockClaim | null) => Promise<RpcSendResult>;
62
+ // native-push (봉인 4): lock-free like meta-mailbox — the runner passes the null lock
63
+ // verbatim and the hand ignores it. Owns the 1-shot re-probe→re-send retry internally.
64
+ sendNativePush: (plan: NativePushPlan, lock: LockClaim | null) => Promise<NativePushSendResult>;
61
65
  }
62
66
 
63
67
  /** The per-transport success outcome, discriminated by transport so the surface renders
@@ -68,7 +72,9 @@ export interface DispatchExecutorDeps {
68
72
  export type ExecutedOutcome =
69
73
  | { transport: "control-socket"; outcome: SendFinalOutcome; rejectReason?: string }
70
74
  | { transport: "spawn-bg"; result: SpawnBgResumeResult }
71
- | { transport: "meta-mailbox"; success: true };
75
+ | { transport: "meta-mailbox"; success: true }
76
+ // native-push carries `retried` so the surface can note the 1-shot re-probe retry fired.
77
+ | { transport: "native-push"; success: true; retried: boolean };
72
78
 
73
79
  /** The single outcome-rich result the 5d surface renders. `rejected` = the decider
74
80
  * refused (no execution). `executed` = a hand ran to a terminal result. `execution-failed`
@@ -164,6 +170,28 @@ export async function executeDispatch(
164
170
  return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
165
171
  }
166
172
  }
173
+ case "native-push": {
174
+ try {
175
+ // lock is null here (lock-free rail, 봉인 4) — passed verbatim; the hand ignores it.
176
+ const r = await deps.sendNativePush(plan, lock);
177
+ // Like meta-mailbox, native-push has NO in-band reject (no live receiver answers
178
+ // success:false). A `success:false` is therefore a CONTRACT VIOLATION — fail loud
179
+ // rather than render a non-delivery as delivered ("Never warn. Throw.").
180
+ if (r.success !== true) {
181
+ throw new Error(
182
+ "entwurf-v2-runner: native-push send returned success:false (contract violation; native-push has no in-band reject).",
183
+ );
184
+ }
185
+ return {
186
+ kind: "executed",
187
+ receipt,
188
+ transport,
189
+ outcome: { transport: "native-push", success: true, retried: r.retried },
190
+ };
191
+ } catch (err) {
192
+ return { kind: "execution-failed", receipt, transport, error: errorMessage(err), retrySafe: false };
193
+ }
194
+ }
167
195
  }
168
196
  }
169
197
 
@@ -217,6 +217,13 @@ async function driveDeadFallback(
217
217
  // contract violation (a send and a spawn are different actions), so fail loud
218
218
  // rather than silently mis-execute.
219
219
  throw new Error("entwurf-v2-send: re-resolve returned a spawn-bg plan for a send fallback (contract violation).");
220
+ case "native-push":
221
+ // The dead-control-socket fallback re-resolves only the pi socket domain
222
+ // (control-socket / mailbox / spawn); it never routes the native-push rail. A
223
+ // native-push rePlan here is structurally impossible — fail loud.
224
+ throw new Error(
225
+ "entwurf-v2-send: re-resolve returned a native-push plan for a send fallback (contract violation).",
226
+ );
220
227
  }
221
228
  }
222
229
 
@@ -106,6 +106,16 @@ export function actionableRejectHint(reason: string): string | undefined {
106
106
  case "owned-live-no-autosend":
107
107
  // A live target is reachable, but owned-outcome is not an auto-send (Q2/F1).
108
108
  return "target is live — owned-outcome never auto-sends. Use intent: fire-and-forget (with wants_reply if you need a reply).";
109
+ case "native-push-no-resume-authority":
110
+ // A native-push backend (antigravity) has no pi-child to own, so owned-outcome has
111
+ // nothing to own. Delivery is a direct inject via fire-and-forget.
112
+ 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.";
113
+ case "native-push-target-dead":
114
+ // The adapter probe found no live host process for the conversation.
115
+ return "native-push conversation is not live (no host process found). Re-open the conversation, then retry — there is nothing to inject into.";
116
+ case "native-push-probe-indeterminate":
117
+ // Host up, but no LS port served the conversation — inconclusive, not a hard dead.
118
+ 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.";
109
119
  default:
110
120
  return undefined;
111
121
  }
@@ -163,6 +173,13 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
163
173
  const exit = res.kind === "child-exited" ? ` exitCode=${res.exitCode}` : "";
164
174
  return { text: `entwurf_v2 spawn-bg → ${res.kind}${pid}${exit}, lock released`, isError: false };
165
175
  }
176
+ if (o.transport === "native-push") {
177
+ // direct-inject succeeded; note if the 1-shot re-probe retry fired.
178
+ return {
179
+ text: `entwurf_v2 native-push → delivered${o.retried ? " (after a 1-shot re-probe retry)" : ""}`,
180
+ isError: false,
181
+ };
182
+ }
166
183
  // meta-mailbox
167
184
  return { text: "entwurf_v2 meta-mailbox → enqueued", isError: false };
168
185
  }