@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
@@ -0,0 +1,154 @@
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
+
25
+ import {
26
+ type MetaBackend,
27
+ type MetaIdentity,
28
+ type MetaSenderMarker,
29
+ parentPid,
30
+ readMetaIdentityByGardenId,
31
+ readMetaSenderMarker,
32
+ } from "./meta-session.ts";
33
+ import { type NativePushAdapter, resolveNativePushAdapter } from "./native-push/adapter.ts";
34
+
35
+ /** Every native backend that mints a garden-id from its own hook and writes a sender marker. */
36
+ export const META_SENDER_BACKENDS: readonly MetaBackend[] = ["claude-code", "antigravity"];
37
+
38
+ /** A marker that passed BOTH guards, together with the record that vouches for it. */
39
+ export interface TrustedMetaSender {
40
+ marker: MetaSenderMarker;
41
+ identity: MetaIdentity;
42
+ }
43
+
44
+ /**
45
+ * Two live native sessions claim this MCP process as their own. We can SEE both identities but
46
+ * cannot say which one is calling, so we send under neither. Choosing by lookup order, backend
47
+ * priority, or recency would attribute a message to a citizen that did not write it; falling back
48
+ * to anonymous would hide an identity we already hold. Both are the SE-1 shape — a layer answering
49
+ * "yes" where the truth is unknown — so this is a hard refusal, not a warning.
50
+ *
51
+ * WHAT THIS CAN AND CANNOT SEE. It fires only when the candidate pids yield MORE THAN ONE marker
52
+ * FILE naming different citizens — two backends on one pid, or the two candidate pids naming
53
+ * different citizens. It CANNOT see two conversations of the same backend racing under one host
54
+ * pid: they share the single marker path `<senders>/<backend>/<pid>.json`, so the later hook write
55
+ * overwrites the earlier one and only one identity is ever on disk. That case is guarded by a
56
+ * runtime invariant instead (a native host serializes its model invocations), not by this error —
57
+ * do not read a green resolver as proof that same-process concurrency is safe.
58
+ */
59
+ export class EntwurfSenderIdentityAmbiguityError extends Error {
60
+ readonly gardenIds: string[];
61
+ constructor(gardenIds: string[]) {
62
+ super(
63
+ "entwurf-bridge refused: ambiguous sender identity. This MCP process's owner pid carries live, " +
64
+ `record-backed sender markers for MORE than one garden citizen (${gardenIds.join(", ")}), so which ` +
65
+ "one is calling cannot be determined. A send under the wrong identity is worse than no send — one " +
66
+ "native host process appears to be driving several sessions at once. Report it; do not work around it.",
67
+ );
68
+ this.name = "EntwurfSenderIdentityAmbiguityError";
69
+ this.gardenIds = gardenIds;
70
+ }
71
+ }
72
+
73
+ /** The record store is the authority; the marker is only a pid→garden hint it must agree with. */
74
+ function trustMarker(marker: MetaSenderMarker): TrustedMetaSender | null {
75
+ try {
76
+ // dual-read (3D-4 commit1): identity-only check, so it survives the v2 cut.
77
+ const identity = readMetaIdentityByGardenId(marker.gardenId);
78
+ if (identity.backend !== marker.backend || identity.nativeSessionId !== marker.nativeSessionId) return null;
79
+ return { marker, identity };
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ export interface ResolveTrustedMetaSenderOptions {
86
+ /** Explicit marker file (explicit wiring / gates). Wins over the pid scan, same validation. */
87
+ markerPath?: string;
88
+ /** Candidate owner pids. Defaults to this process's parent and one step above it. */
89
+ ownerPids?: number[];
90
+ /** Marker root (gates isolate it; production reads the ENTWURF_META_SENDERS_DIR SSOT). */
91
+ sendersDir?: string;
92
+ }
93
+
94
+ /**
95
+ * Resolve the ONE identity that owns this MCP process, or refuse.
96
+ *
97
+ * 0 trusted → null (anonymous — or a hard refusal upstream under REQUIRE_META_SENDER).
98
+ * 1 trusted → that identity.
99
+ * 2+ distinct → throw EntwurfSenderIdentityAmbiguityError.
100
+ *
101
+ * Markers that agree on the SAME garden-id are not a conflict: an older release wrote a marker for
102
+ * the parent AND the grandparent, and both can still sit on disk pointing at one citizen.
103
+ */
104
+ export function resolveTrustedMetaSenderIdentity(opts: ResolveTrustedMetaSenderOptions = {}): TrustedMetaSender | null {
105
+ const markers: MetaSenderMarker[] = [];
106
+ if (opts.markerPath) {
107
+ const marker = readMetaSenderMarker({ markerPath: opts.markerPath });
108
+ if (marker) markers.push(marker);
109
+ } else {
110
+ const ownerPids = (opts.ownerPids ?? [process.ppid, parentPid(process.ppid) ?? 0]).filter(
111
+ (p): p is number => typeof p === "number" && p > 0,
112
+ );
113
+ for (const ownerPid of [...new Set(ownerPids)]) {
114
+ for (const backend of META_SENDER_BACKENDS) {
115
+ const marker = readMetaSenderMarker({ backend, ownerPid, sendersDir: opts.sendersDir });
116
+ if (marker) markers.push(marker);
117
+ }
118
+ }
119
+ }
120
+
121
+ const byGardenId = new Map<string, TrustedMetaSender>();
122
+ for (const marker of markers) {
123
+ const trusted = trustMarker(marker);
124
+ if (trusted) byGardenId.set(trusted.identity.gardenId, trusted);
125
+ }
126
+
127
+ const distinct = [...byGardenId.values()];
128
+ if (distinct.length === 0) return null;
129
+ if (distinct.length > 1) throw new EntwurfSenderIdentityAmbiguityError(distinct.map((t) => t.identity.gardenId));
130
+ return distinct[0];
131
+ }
132
+
133
+ /**
134
+ * Can a reply to THIS native-push citizen actually land? Only an adapter probe can say: a reply is
135
+ * a direct injection into a live app-server conversation, and the route is re-discovered on every
136
+ * probe. This is the `replyable` fact for a native-push sender — it must never be inferred from the
137
+ * mailbox axis, which such a backend has no part in (보정①).
138
+ *
139
+ * ERROR POLICY (deliberate, and the reason this is not a `try { … } catch { return false }`): the
140
+ * adapter already expresses every OPERATIONAL outcome as a value — `dead` (no host) and
141
+ * `indeterminate` (host up, no port served it). So a THROW out of here is never a fact about the
142
+ * citizen; it is a defect — an unresolvable adapter for a backend we just confirmed is native-push
143
+ * (a registry bug), or a probe runner that could not run at all (a wiring bug). Folding those into
144
+ * `replyable:false` would tell the receiver a lie about this sender AND bury the defect, which is
145
+ * exactly the Crash-Don't-Warn shape this lane exists to remove. Let them propagate.
146
+ */
147
+ export async function probeNativeSenderAlive(
148
+ identity: Pick<MetaIdentity, "backend" | "nativeSessionId">,
149
+ deps: { resolveAdapter?: (id: string) => Pick<NativePushAdapter, "probe"> } = {},
150
+ ): Promise<boolean> {
151
+ const resolveAdapter = deps.resolveAdapter ?? resolveNativePushAdapter;
152
+ const probe = await resolveAdapter(identity.backend).probe(identity.nativeSessionId);
153
+ return probe.status === "alive";
154
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * native-push adapter rail — the transport LEAF by which a native-push backend
3
+ * (antigravity, the first) is (1) probed for a LIVE conversation and (2) direct-injected
4
+ * with a message. Mirrors the ACP backend-adapter rail (acp/backend-adapter.ts §ADAPTERS
5
+ * /resolveAcpBackendAdapter): one interface, one registry, a fail-fast resolver.
6
+ *
7
+ * Purity contract (봉인 3):
8
+ * - LEAF: this file imports NO entwurf-core / no decider / no meta-session — only node
9
+ * builtins + type-only contract types. So the pi-free MCP bridge (entwurf_register_native)
10
+ * can reach it at boot without re-coupling to pi, and the decider stays pure.
11
+ * - injectable runner: every process call goes through the injected `NativePushRunner`,
12
+ * so `check-native-push-adapter` drives probe/send with a fake — no real agy needed.
13
+ * - VOLATILE route: a probe's `route` (the live LS address serving the conversation) is
14
+ * NEVER stored — every probe re-scans and re-discovers it (the LS port is per-process
15
+ * and shifts). `check-native-push-adapter` asserts a repeated probe re-runs the scan.
16
+ * - NO retry HERE: `send` is a single attempt that throws on failure. The 1-shot
17
+ * re-probe→re-send on failure is the EXECUTOR hand's job (step ⑥ — decider purity /
18
+ * control-socket send-fallback mirror), NOT the adapter's.
19
+ *
20
+ * The probe corrects raw-agy-send.sh:16's `pgrep -x agy | head -1` single-pid assumption:
21
+ * it scans EVERY host pid, since the conversation may be served by any live host process.
22
+ */
23
+
24
+ import { execFile } from "node:child_process";
25
+ import * as os from "node:os";
26
+ import * as path from "node:path";
27
+
28
+ import type { NativePushBackend } from "../entwurf-v2-contract.ts";
29
+
30
+ // ── runner seam (injectable process exec) ───────────────────────────────────
31
+
32
+ export interface NativePushExecResult {
33
+ /** Exit code (0 = success). A spawn error (command not found) surfaces as 127. */
34
+ code: number;
35
+ stdout: string;
36
+ stderr: string;
37
+ }
38
+
39
+ export interface NativePushRunner {
40
+ /**
41
+ * Run `argv[0]` with `argv[1..]`, optionally with an env overlay merged over
42
+ * process.env and a `timeoutMs` bound. Resolves with the exit code + captured output; it
43
+ * NEVER rejects on a non-zero exit — a non-zero code is DATA the adapter interprets (e.g.
44
+ * "no live agy"), not an exception. A genuine spawn failure resolves with code 127; a
45
+ * timeout kill resolves with code 124 (the `timeout(1)` convention), so a stalled LS
46
+ * route / hung `agy agentapi` call can NEVER wedge an entwurf_v2 dispatch (Q12).
47
+ */
48
+ exec(
49
+ argv: readonly string[],
50
+ opts?: { env?: Record<string, string>; timeoutMs?: number },
51
+ ): Promise<NativePushExecResult>;
52
+ }
53
+
54
+ // The agy agentapi calls are bounded so a dead/stalled LS route cannot hang a dispatch
55
+ // (raw-agy-send.sh used `timeout 8` — production had lost that; Q12 restores it). pgrep/ss
56
+ // are fast local scans and stay unbounded.
57
+ export const AGY_METADATA_TIMEOUT_MS = 8000;
58
+ export const AGY_SEND_TIMEOUT_MS = 8000;
59
+
60
+ /** The production runner — `execFile` (no shell), env overlay, bounded, output captured. */
61
+ export const realNativePushRunner: NativePushRunner = {
62
+ exec(argv, opts) {
63
+ return new Promise((resolve) => {
64
+ const [cmd, ...args] = argv;
65
+ execFile(
66
+ cmd ?? "",
67
+ args,
68
+ {
69
+ env: opts?.env ? { ...process.env, ...opts.env } : process.env,
70
+ maxBuffer: 8 * 1024 * 1024,
71
+ timeout: opts?.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : 0,
72
+ },
73
+ (err, stdout, stderr) => {
74
+ const e = err as (NodeJS.ErrnoException & { killed?: boolean }) | null;
75
+ // A timeout kill surfaces as `killed` — map it to 124 (timeout convention) so
76
+ // probe reads it as "no serve" (→ indeterminate) and send reads it as failure.
77
+ const code = e == null ? 0 : e.killed ? 124 : typeof e.code === "number" ? (e.code as number) : 127;
78
+ resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
79
+ },
80
+ );
81
+ });
82
+ },
83
+ };
84
+
85
+ // ── route + probe result ────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * A VOLATILE native-push route — the live LS address (`127.0.0.1:PORT`) a probe found
89
+ * serving the conversation. MUST NOT be stored (봉인 3): it is re-derived every dispatch
90
+ * by a fresh probe (the LS port is per-process and shifts). Carried only within a single
91
+ * `send` call, handed straight from a fresh `probe`.
92
+ */
93
+ export interface NativePushRoute {
94
+ readonly lsAddress: string;
95
+ }
96
+
97
+ /**
98
+ * A probe outcome. `alive` carries the volatile route; `dead`/`indeterminate` carry a
99
+ * human reason. The three `status` values ARE the NativePushLiveness vocabulary, so the
100
+ * decider maps `status` → dispatch verdict directly (no re-derivation).
101
+ */
102
+ export type NativePushProbeResult =
103
+ | { status: "alive"; route: NativePushRoute }
104
+ | { status: "dead" | "indeterminate"; reason: string };
105
+
106
+ // ── the adapter interface ────────────────────────────────────────────────────
107
+
108
+ export interface NativePushAdapter {
109
+ /** Backend discriminator (a member of NATIVE_PUSH_BACKENDS). */
110
+ readonly id: NativePushBackend;
111
+ /**
112
+ * Full-scan probe: find a LIVE route serving `nativeSessionId`, else report
113
+ * dead/indeterminate. Scans EVERY host pid (never `head -1`) and re-discovers the
114
+ * route on every call (no cache — volatile-route discipline, 봉인 3).
115
+ */
116
+ probe(nativeSessionId: string): Promise<NativePushProbeResult>;
117
+ /**
118
+ * Direct-inject `content` into the conversation over `route`. Single attempt: throws
119
+ * on failure (fail-loud). Does NOT probe and does NOT retry — the executor hand owns
120
+ * re-probe/retry (봉인 3), so the adapter can never silently paper over a dead route.
121
+ */
122
+ send(route: NativePushRoute, nativeSessionId: string, content: string): Promise<void>;
123
+ }
124
+
125
+ // ── antigravity adapter ──────────────────────────────────────────────────────
126
+
127
+ export interface AntigravityAdapterDeps {
128
+ runner: NativePushRunner;
129
+ /** Resolved agy binary path (argv[0] for agentapi calls). Default: $AGY_BIN or ~/.local/bin/agy. */
130
+ binary?: string;
131
+ /** Host process name to scan for (`pgrep -x`). Default "agy". */
132
+ processName?: string;
133
+ }
134
+
135
+ /** Resolve the agy binary — $AGY_BIN, else ~/.local/bin/agy (raw-agy-send.sh:14). */
136
+ export function resolveAgyBinary(): string {
137
+ const env = process.env.AGY_BIN?.trim();
138
+ if (env) return env;
139
+ return path.join(os.homedir(), ".local", "bin", "agy");
140
+ }
141
+
142
+ /** Parse `ss -lntp` output into a pid → [127.0.0.1:PORT, …] map (localhost listeners). */
143
+ function parseSsListeners(ssStdout: string): Map<number, string[]> {
144
+ const byPid = new Map<number, string[]>();
145
+ for (const line of ssStdout.split("\n")) {
146
+ const addr = line.match(/127\.0\.0\.1:([0-9]+)/);
147
+ if (!addr) continue;
148
+ // ss -lntp tags the owner as `pid=<n>,`; a line may carry several `pid=` when the
149
+ // socket is shared, so collect them all.
150
+ for (const m of line.matchAll(/pid=([0-9]+),/g)) {
151
+ const pid = Number(m[1]);
152
+ const list = byPid.get(pid) ?? [];
153
+ list.push(`127.0.0.1:${addr[1]}`);
154
+ byPid.set(pid, list);
155
+ }
156
+ }
157
+ return byPid;
158
+ }
159
+
160
+ export function createAntigravityAdapter(deps: AntigravityAdapterDeps): NativePushAdapter {
161
+ const { runner } = deps;
162
+ const binary = deps.binary ?? resolveAgyBinary();
163
+ const processName = deps.processName ?? "agy";
164
+
165
+ async function scanHostPids(): Promise<number[]> {
166
+ // pgrep -x <name> — ALL matching pids (raw-agy-send.sh:16 `head -1` corrected here).
167
+ const r = await runner.exec(["pgrep", "-x", processName]);
168
+ if (r.code !== 0) return []; // pgrep exit != 0 → no matching process
169
+ return r.stdout
170
+ .split("\n")
171
+ .map((s) => s.trim())
172
+ .filter((s) => /^[0-9]+$/.test(s))
173
+ .map((s) => Number(s));
174
+ }
175
+
176
+ async function servesConversation(lsAddress: string, conversationId: string): Promise<boolean> {
177
+ const r = await runner.exec([binary, "agentapi", "get-conversation-metadata", conversationId], {
178
+ env: { ANTIGRAVITY_LS_ADDRESS: lsAddress },
179
+ timeoutMs: AGY_METADATA_TIMEOUT_MS,
180
+ });
181
+ // A non-zero code — not-found, error, OR a timeout kill (124) — means this port does not
182
+ // serve the conversation; the scan moves on (a timeout never blocks the whole probe).
183
+ return r.code === 0 && r.stdout.includes("conversationMetadata");
184
+ }
185
+
186
+ return {
187
+ id: "antigravity",
188
+
189
+ async probe(nativeSessionId) {
190
+ const pids = await scanHostPids();
191
+ if (pids.length === 0) {
192
+ return { status: "dead", reason: `no live ${processName} process (native-push target has no host)` };
193
+ }
194
+ // ONE ss scan per probe (re-derived every call — no cross-dispatch cache).
195
+ const ss = await runner.exec(["ss", "-lntp"]);
196
+ const portsByPid = ss.code === 0 ? parseSsListeners(ss.stdout) : new Map<number, string[]>();
197
+ // Scan EVERY pid's ports — the conversation may be served by any live host, so a
198
+ // head -1 single-pid assumption (raw-agy-send.sh:16) would misroute.
199
+ for (const pid of pids) {
200
+ for (const lsAddress of portsByPid.get(pid) ?? []) {
201
+ if (await servesConversation(lsAddress, nativeSessionId)) {
202
+ return { status: "alive", route: { lsAddress } };
203
+ }
204
+ }
205
+ }
206
+ // Host(s) alive but no LS port served this conversation: INDETERMINATE, not dead
207
+ // (a WAL/loading race or a different host instance). Never coerce absence-of-proof
208
+ // into `dead` — that would be a hard reject on a maybe-live conversation.
209
+ return {
210
+ status: "indeterminate",
211
+ reason: `${processName} live (${pids.length} pid(s)) but no LS port served conversation ${nativeSessionId}`,
212
+ };
213
+ },
214
+
215
+ async send(route, nativeSessionId, content) {
216
+ const r = await runner.exec([binary, "agentapi", "send-message", nativeSessionId, content], {
217
+ env: { ANTIGRAVITY_LS_ADDRESS: route.lsAddress },
218
+ timeoutMs: AGY_SEND_TIMEOUT_MS,
219
+ });
220
+ // A non-zero code — including a timeout kill (124) on a stalled route — THROWS
221
+ // (fail-loud); the executor hand owns the 1-shot re-probe→re-send on that throw.
222
+ if (r.code !== 0) {
223
+ throw new Error(
224
+ `native-push send failed (agentapi send-message exit ${r.code}) via ${route.lsAddress}: ${
225
+ r.stderr.trim() || "(no stderr)"
226
+ }`,
227
+ );
228
+ }
229
+ },
230
+ };
231
+ }
232
+
233
+ /** The production antigravity adapter (real runner + env-resolved binary). */
234
+ export const antigravityAdapter: NativePushAdapter = createAntigravityAdapter({ runner: realNativePushRunner });
235
+
236
+ // ── registry + fail-fast resolver (mirror resolveAcpBackendAdapter) ──────────
237
+
238
+ const ADAPTERS: readonly NativePushAdapter[] = [antigravityAdapter];
239
+
240
+ /**
241
+ * Resolve the native-push adapter that owns backend `id`. Fail-fast, like
242
+ * resolveAcpBackendAdapter: 0 matches → throw (unknown backend, no silent default);
243
+ * 2+ matches → throw (a startup-visible registry bug). A second native-push backend
244
+ * appends to ADAPTERS with its own id and this proves no two adapters claim one id.
245
+ */
246
+ export function resolveNativePushAdapter(id: string): NativePushAdapter {
247
+ const matches = ADAPTERS.filter((a) => a.id === id);
248
+ if (matches.length === 0) {
249
+ throw new Error(`entwurf: no native-push adapter owns backend id ${JSON.stringify(id)}`);
250
+ }
251
+ if (matches.length > 1) {
252
+ throw new Error(`entwurf: backend id ${JSON.stringify(id)} is claimed by multiple native-push adapters`);
253
+ }
254
+ return matches[0];
255
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * native-push/register — the pure-ish core of the `entwurf_register_native` MCP tool
3
+ * (봉인 5). It REGISTERS an already-running native conversation (antigravity) as a garden
4
+ * citizen; it does NOT spawn one (that is the deferred v2 fresh-mint capability — kept
5
+ * distinct so a caller never confuses "bind an existing conversation" with "create a new
6
+ * sibling").
7
+ *
8
+ * Flow (봉인 5):
9
+ * 1. Resolve the native-push adapter for the backend and PROBE the conversation. Only a
10
+ * LIVE, verifiable conversation may be registered — a dead/indeterminate probe throws,
11
+ * so we never engrave a garden id onto a pointer that does not resolve to a real host.
12
+ * 2. Reuse `upsertMetaSession` (scan-by-nativeId → create/attach). Re-registration attaches
13
+ * to the SAME garden id and refreshes the cwd; the meta-record authority rules (duplicate
14
+ * nativeSessionId / backend↔wakeMode contradiction) are inherited unchanged.
15
+ *
16
+ * Receiver-marker abstinence (보정①): this module NEVER writes a receiver marker. That marker
17
+ * means "idle-wake mailbox watch armed" and is a MAILBOX-only atom; a native-push citizen has
18
+ * no mailbox and no watch, so arming one would smuggle native-push liveness into the mailbox
19
+ * deliverability semantics. `check-native-push-register` asserts this file references no
20
+ * receiver-marker writer. (A dedicated register-provenance slot is a future concern.)
21
+ *
22
+ * LEAF-adjacent + pi-free: imports only the native-push adapter (pi-free) and the meta-session
23
+ * upsert (pi-free), so the harness-neutral MCP bridge can reach it at boot.
24
+ */
25
+
26
+ import type { NativePushBackend } from "../entwurf-v2-contract.ts";
27
+ import { type MetaBackendV2, type UpsertAction, upsertMetaSession } from "../meta-session.ts";
28
+ import { type NativePushAdapter, resolveNativePushAdapter } from "./adapter.ts";
29
+
30
+ export interface RegisterNativeConversationInput {
31
+ /** The native backend hosting the conversation. Only antigravity is registerable on this
32
+ * lane (codex is a separate lane). */
33
+ backend: NativePushBackend;
34
+ /** The backend's native conversation id (antigravity conversationId). */
35
+ nativeSessionId: string;
36
+ /** The cwd to record for this citizen — REQUIRED: a native conversation's metadata cannot
37
+ * confirm it, so the caller must state it (봉인 5). */
38
+ cwd: string;
39
+ }
40
+
41
+ export interface RegisterNativeConversationDeps {
42
+ /** Adapter resolver (default: the real native-push registry). Injected so the gate drives
43
+ * the probe with a fake adapter — no live agy. */
44
+ resolveAdapter?: (backend: string) => NativePushAdapter;
45
+ /** Meta-record store dir (default: the real one). Injected so the gate writes to a temp dir. */
46
+ sessionsDir?: string;
47
+ now?: Date;
48
+ }
49
+
50
+ export interface RegisterNativeConversationResult {
51
+ action: UpsertAction;
52
+ gardenId: string;
53
+ backend: string;
54
+ nativeSessionId: string;
55
+ cwd: string;
56
+ }
57
+
58
+ /**
59
+ * Register (or re-attach) a live native conversation as a garden citizen. Throws if the
60
+ * conversation is not live (probe status !== "alive") — a non-live conversation cannot become
61
+ * an addressable citizen. On success returns the garden id + the create/attach action.
62
+ */
63
+ export async function registerNativeConversation(
64
+ input: RegisterNativeConversationInput,
65
+ deps: RegisterNativeConversationDeps = {},
66
+ ): Promise<RegisterNativeConversationResult> {
67
+ const resolveAdapter = deps.resolveAdapter ?? resolveNativePushAdapter;
68
+ const adapter = resolveAdapter(input.backend);
69
+ const probe = await adapter.probe(input.nativeSessionId);
70
+ if (probe.status !== "alive") {
71
+ throw new Error(
72
+ `entwurf_register_native: refusing to register ${input.backend} conversation ${JSON.stringify(
73
+ input.nativeSessionId,
74
+ )} — it is not live (${probe.status}: ${probe.reason}). Only a live, verifiable conversation can be ` +
75
+ `registered as a garden citizen; open/resume it, then retry.`,
76
+ );
77
+ }
78
+ // Reuse the meta-record upsert authority (scan-by-nativeId → create/attach). model /
79
+ // transcriptPath are null: a native app-server conversation exposes neither to us. cwd is
80
+ // the caller-stated value (refreshed on attach). NO receiver marker is written here (보정①).
81
+ const result = upsertMetaSession({
82
+ input: {
83
+ backend: input.backend satisfies MetaBackendV2,
84
+ nativeSessionId: input.nativeSessionId,
85
+ cwd: input.cwd,
86
+ model: null,
87
+ transcriptPath: null,
88
+ },
89
+ dir: deps.sessionsDir,
90
+ now: deps.now,
91
+ });
92
+ return {
93
+ action: result.action,
94
+ gardenId: result.record.gardenId,
95
+ backend: result.record.backend,
96
+ nativeSessionId: result.record.nativeSessionId,
97
+ cwd: result.record.cwd,
98
+ };
99
+ }