@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,158 @@
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
+ import { execFile } from "node:child_process";
24
+ import * as os from "node:os";
25
+ import * as path from "node:path";
26
+ // The agy agentapi calls are bounded so a dead/stalled LS route cannot hang a dispatch
27
+ // (raw-agy-send.sh used `timeout 8` — production had lost that; Q12 restores it). pgrep/ss
28
+ // are fast local scans and stay unbounded.
29
+ export const AGY_METADATA_TIMEOUT_MS = 8000;
30
+ export const AGY_SEND_TIMEOUT_MS = 8000;
31
+ /** The production runner — `execFile` (no shell), env overlay, bounded, output captured. */
32
+ export const realNativePushRunner = {
33
+ exec(argv, opts) {
34
+ return new Promise((resolve) => {
35
+ const [cmd, ...args] = argv;
36
+ execFile(cmd ?? "", args, {
37
+ env: opts?.env ? { ...process.env, ...opts.env } : process.env,
38
+ maxBuffer: 8 * 1024 * 1024,
39
+ timeout: opts?.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : 0,
40
+ }, (err, stdout, stderr) => {
41
+ const e = err;
42
+ // A timeout kill surfaces as `killed` — map it to 124 (timeout convention) so
43
+ // probe reads it as "no serve" (→ indeterminate) and send reads it as failure.
44
+ const code = e == null ? 0 : e.killed ? 124 : typeof e.code === "number" ? e.code : 127;
45
+ resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
46
+ });
47
+ });
48
+ },
49
+ };
50
+ /** Resolve the agy binary — $AGY_BIN, else ~/.local/bin/agy (raw-agy-send.sh:14). */
51
+ export function resolveAgyBinary() {
52
+ const env = process.env.AGY_BIN?.trim();
53
+ if (env)
54
+ return env;
55
+ return path.join(os.homedir(), ".local", "bin", "agy");
56
+ }
57
+ /** Parse `ss -lntp` output into a pid → [127.0.0.1:PORT, …] map (localhost listeners). */
58
+ function parseSsListeners(ssStdout) {
59
+ const byPid = new Map();
60
+ for (const line of ssStdout.split("\n")) {
61
+ const addr = line.match(/127\.0\.0\.1:([0-9]+)/);
62
+ if (!addr)
63
+ continue;
64
+ // ss -lntp tags the owner as `pid=<n>,`; a line may carry several `pid=` when the
65
+ // socket is shared, so collect them all.
66
+ for (const m of line.matchAll(/pid=([0-9]+),/g)) {
67
+ const pid = Number(m[1]);
68
+ const list = byPid.get(pid) ?? [];
69
+ list.push(`127.0.0.1:${addr[1]}`);
70
+ byPid.set(pid, list);
71
+ }
72
+ }
73
+ return byPid;
74
+ }
75
+ export function createAntigravityAdapter(deps) {
76
+ const { runner } = deps;
77
+ const binary = deps.binary ?? resolveAgyBinary();
78
+ const processName = deps.processName ?? "agy";
79
+ async function scanHostPids() {
80
+ // pgrep -x <name> — ALL matching pids (raw-agy-send.sh:16 `head -1` corrected here).
81
+ const r = await runner.exec(["pgrep", "-x", processName]);
82
+ if (r.code !== 0)
83
+ return []; // pgrep exit != 0 → no matching process
84
+ return r.stdout
85
+ .split("\n")
86
+ .map((s) => s.trim())
87
+ .filter((s) => /^[0-9]+$/.test(s))
88
+ .map((s) => Number(s));
89
+ }
90
+ async function servesConversation(lsAddress, conversationId) {
91
+ const r = await runner.exec([binary, "agentapi", "get-conversation-metadata", conversationId], {
92
+ env: { ANTIGRAVITY_LS_ADDRESS: lsAddress },
93
+ timeoutMs: AGY_METADATA_TIMEOUT_MS,
94
+ });
95
+ // A non-zero code — not-found, error, OR a timeout kill (124) — means this port does not
96
+ // serve the conversation; the scan moves on (a timeout never blocks the whole probe).
97
+ return r.code === 0 && r.stdout.includes("conversationMetadata");
98
+ }
99
+ return {
100
+ id: "antigravity",
101
+ async probe(nativeSessionId) {
102
+ const pids = await scanHostPids();
103
+ if (pids.length === 0) {
104
+ return { status: "dead", reason: `no live ${processName} process (native-push target has no host)` };
105
+ }
106
+ // ONE ss scan per probe (re-derived every call — no cross-dispatch cache).
107
+ const ss = await runner.exec(["ss", "-lntp"]);
108
+ const portsByPid = ss.code === 0 ? parseSsListeners(ss.stdout) : new Map();
109
+ // Scan EVERY pid's ports — the conversation may be served by any live host, so a
110
+ // head -1 single-pid assumption (raw-agy-send.sh:16) would misroute.
111
+ for (const pid of pids) {
112
+ for (const lsAddress of portsByPid.get(pid) ?? []) {
113
+ if (await servesConversation(lsAddress, nativeSessionId)) {
114
+ return { status: "alive", route: { lsAddress } };
115
+ }
116
+ }
117
+ }
118
+ // Host(s) alive but no LS port served this conversation: INDETERMINATE, not dead
119
+ // (a WAL/loading race or a different host instance). Never coerce absence-of-proof
120
+ // into `dead` — that would be a hard reject on a maybe-live conversation.
121
+ return {
122
+ status: "indeterminate",
123
+ reason: `${processName} live (${pids.length} pid(s)) but no LS port served conversation ${nativeSessionId}`,
124
+ };
125
+ },
126
+ async send(route, nativeSessionId, content) {
127
+ const r = await runner.exec([binary, "agentapi", "send-message", nativeSessionId, content], {
128
+ env: { ANTIGRAVITY_LS_ADDRESS: route.lsAddress },
129
+ timeoutMs: AGY_SEND_TIMEOUT_MS,
130
+ });
131
+ // A non-zero code — including a timeout kill (124) on a stalled route — THROWS
132
+ // (fail-loud); the executor hand owns the 1-shot re-probe→re-send on that throw.
133
+ if (r.code !== 0) {
134
+ throw new Error(`native-push send failed (agentapi send-message exit ${r.code}) via ${route.lsAddress}: ${r.stderr.trim() || "(no stderr)"}`);
135
+ }
136
+ },
137
+ };
138
+ }
139
+ /** The production antigravity adapter (real runner + env-resolved binary). */
140
+ export const antigravityAdapter = createAntigravityAdapter({ runner: realNativePushRunner });
141
+ // ── registry + fail-fast resolver (mirror resolveAcpBackendAdapter) ──────────
142
+ const ADAPTERS = [antigravityAdapter];
143
+ /**
144
+ * Resolve the native-push adapter that owns backend `id`. Fail-fast, like
145
+ * resolveAcpBackendAdapter: 0 matches → throw (unknown backend, no silent default);
146
+ * 2+ matches → throw (a startup-visible registry bug). A second native-push backend
147
+ * appends to ADAPTERS with its own id and this proves no two adapters claim one id.
148
+ */
149
+ export function resolveNativePushAdapter(id) {
150
+ const matches = ADAPTERS.filter((a) => a.id === id);
151
+ if (matches.length === 0) {
152
+ throw new Error(`entwurf: no native-push adapter owns backend id ${JSON.stringify(id)}`);
153
+ }
154
+ if (matches.length > 1) {
155
+ throw new Error(`entwurf: backend id ${JSON.stringify(id)} is claimed by multiple native-push adapters`);
156
+ }
157
+ return matches[0];
158
+ }
@@ -0,0 +1,61 @@
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
+ import { upsertMetaSession } from "../meta-session.js";
26
+ import { resolveNativePushAdapter } from "./adapter.js";
27
+ /**
28
+ * Register (or re-attach) a live native conversation as a garden citizen. Throws if the
29
+ * conversation is not live (probe status !== "alive") — a non-live conversation cannot become
30
+ * an addressable citizen. On success returns the garden id + the create/attach action.
31
+ */
32
+ export async function registerNativeConversation(input, deps = {}) {
33
+ const resolveAdapter = deps.resolveAdapter ?? resolveNativePushAdapter;
34
+ const adapter = resolveAdapter(input.backend);
35
+ const probe = await adapter.probe(input.nativeSessionId);
36
+ if (probe.status !== "alive") {
37
+ throw new Error(`entwurf_register_native: refusing to register ${input.backend} conversation ${JSON.stringify(input.nativeSessionId)} — it is not live (${probe.status}: ${probe.reason}). Only a live, verifiable conversation can be ` +
38
+ `registered as a garden citizen; open/resume it, then retry.`);
39
+ }
40
+ // Reuse the meta-record upsert authority (scan-by-nativeId → create/attach). model /
41
+ // transcriptPath are null: a native app-server conversation exposes neither to us. cwd is
42
+ // the caller-stated value (refreshed on attach). NO receiver marker is written here (보정①).
43
+ const result = upsertMetaSession({
44
+ input: {
45
+ backend: input.backend,
46
+ nativeSessionId: input.nativeSessionId,
47
+ cwd: input.cwd,
48
+ model: null,
49
+ transcriptPath: null,
50
+ },
51
+ dir: deps.sessionsDir,
52
+ now: deps.now,
53
+ });
54
+ return {
55
+ action: result.action,
56
+ gardenId: result.record.gardenId,
57
+ backend: result.record.backend,
58
+ nativeSessionId: result.record.nativeSessionId,
59
+ cwd: result.record.cwd,
60
+ };
61
+ }
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * entwurf-agy-imprint — Antigravity PreInvocation birth hook.
4
+ *
5
+ * Reads agy's camelCase PreInvocation payload from stdin, idempotently upserts an
6
+ * antigravity meta-session by conversationId, writes the sender marker that lets the
7
+ * entwurf-bridge MCP child name this conversation as the caller, and ALWAYS prints exactly
8
+ * the PreInvocation neutral response so the agy loop keeps running.
9
+ *
10
+ * Still deliberately thin: no transcript hydration, no cwd guessing from process.cwd(), and
11
+ * NO receiver marker — that is a mailbox atom, and agy is a native-push citizen with no
12
+ * mailbox (보정①). The record body is the authority; the sender marker is only a pid→garden
13
+ * hint the bridge re-validates against it.
14
+ */
15
+ import * as fs from "node:fs";
16
+ import * as os from "node:os";
17
+ import * as path from "node:path";
18
+ import { parentPid, processStartKey, upsertMetaSession, writeMetaSenderMarker, } from "../pi-extensions/lib/meta-session.js";
19
+ const NEUTRAL_RESPONSE = '{"injectSteps":[]}';
20
+ function logPath() {
21
+ const root = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
22
+ return path.join(root, "entwurf", "agy-imprint.log");
23
+ }
24
+ function logLine(message) {
25
+ try {
26
+ const file = logPath();
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ fs.appendFileSync(file, `${new Date().toISOString()} ${message}\n`, { encoding: "utf8", mode: 0o600 });
29
+ }
30
+ catch {
31
+ // Logging is best-effort; never break agy's PreInvocation loop.
32
+ }
33
+ }
34
+ /**
35
+ * OPT-IN owner-topology trace (`ENTWURF_AGY_TRACE_OWNER=1`).
36
+ *
37
+ * The whole sender lane rests on one measured fact: this hook's parent IS the process that also
38
+ * parents the MCP child (both are the agy host). When a send from a birthed agy still arrives
39
+ * anonymous, this trace is the only thing that can say WHY — a plugin host in between, a per-turn
40
+ * worker, a shell wrapper. Compare it with the live bridge's own ancestry (`/proc/<pid>/stat`).
41
+ *
42
+ * OFF by default, and it must stay off: PreInvocation runs before EVERY model turn, so leaving it
43
+ * on would append a line and walk /proc six times per turn, forever — an unbounded trace in a log
44
+ * whose job is to hold birth evidence. The normal log already records the marker write (pid → gid),
45
+ * which is what a healthy host needs.
46
+ */
47
+ function comm(pid) {
48
+ try {
49
+ return fs.readFileSync(`/proc/${pid}/comm`, "utf8").trim() || "?";
50
+ }
51
+ catch {
52
+ return "?";
53
+ }
54
+ }
55
+ function ancestry(startPid, depth = 6) {
56
+ const steps = [];
57
+ let pid = startPid;
58
+ for (let i = 0; i < depth && typeof pid === "number" && pid > 1; i++) {
59
+ steps.push(`${pid}:${comm(pid)}:${processStartKey(pid) || "-"}`);
60
+ pid = parentPid(pid);
61
+ }
62
+ return steps.join(" < ");
63
+ }
64
+ function traceOwnerTopology(conversationId) {
65
+ if (process.env.ENTWURF_AGY_TRACE_OWNER !== "1")
66
+ return;
67
+ logLine(`pids conversationId=${conversationId} hookPid=${process.pid} ppid=${process.ppid} chain=${ancestry(process.pid)}`);
68
+ }
69
+ function readStdin() {
70
+ try {
71
+ return fs.readFileSync(0, "utf8");
72
+ }
73
+ catch (err) {
74
+ logLine(`read-stdin-failed ${err instanceof Error ? err.message : String(err)}`);
75
+ return "";
76
+ }
77
+ }
78
+ function firstWorkspace(value) {
79
+ if (!Array.isArray(value))
80
+ return null;
81
+ const first = value[0];
82
+ return typeof first === "string" && first.trim() ? first : null;
83
+ }
84
+ function optionalString(value) {
85
+ return typeof value === "string" && value.trim() ? value : null;
86
+ }
87
+ function imprint(raw) {
88
+ let payload;
89
+ try {
90
+ const parsed = JSON.parse(raw || "{}");
91
+ payload = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
92
+ }
93
+ catch (err) {
94
+ logLine(`skip invalid-json ${err instanceof Error ? err.message : String(err)}`);
95
+ return;
96
+ }
97
+ const conversationId = optionalString(payload.conversationId);
98
+ if (!conversationId) {
99
+ logLine("skip missing-conversationId");
100
+ return;
101
+ }
102
+ const cwd = firstWorkspace(payload.workspacePaths);
103
+ if (!cwd) {
104
+ logLine(`skip missing-workspacePaths conversationId=${conversationId}`);
105
+ return;
106
+ }
107
+ traceOwnerTopology(conversationId);
108
+ try {
109
+ const result = upsertMetaSession({
110
+ input: {
111
+ backend: "antigravity",
112
+ nativeSessionId: conversationId,
113
+ cwd,
114
+ model: optionalString(payload.modelName),
115
+ transcriptPath: optionalString(payload.transcriptPath),
116
+ },
117
+ });
118
+ logLine(`ok ${result.action} gardenId=${result.record.gardenId} conversationId=${conversationId} cwd=${cwd}`);
119
+ // Sender marker — what lets the entwurf-bridge MCP child name WHO is calling it. The
120
+ // bridge and this hook are both children of the same agy process (measured: hook.ppid ==
121
+ // bridge.ppid == the `agy` pid, identical start-key), so that pid is the join key; the
122
+ // bridge looks a marker up under its own parent, finds this one, and the send goes out as
123
+ // a replyable garden citizen instead of anonymous external-mcp.
124
+ //
125
+ // The marker binds the pid to the conversation it is CURRENTLY invoking — not to one
126
+ // conversation forever. PreInvocation runs synchronously before every model turn, so a
127
+ // `/new` (same pid, new conversationId) rebinds the marker before that conversation can
128
+ // make its first tool call. Overwriting is a binding refresh, not an identity mutation:
129
+ // both conversations keep their meta-records.
130
+ //
131
+ // THE LIMIT, stated plainly: one pid has exactly ONE marker file per backend
132
+ // (`<senders>/antigravity/<pid>.json`). Two conversations invoking CONCURRENTLY under one
133
+ // agy process would therefore NOT leave two markers for the bridge to refuse — the second
134
+ // hook write silently overwrites the first, and a tool call still in flight from the first
135
+ // would be attributed to the second. Nothing downstream can detect that. So this binding
136
+ // rests on a runtime invariant: an agy process serializes its model invocations (its hook
137
+ // runs synchronously before each turn). Same-process concurrency is NOT supported, and
138
+ // there is no fail-closed guard for it — if agy ever gains it, the pid stops identifying
139
+ // the caller and the join key must change.
140
+ //
141
+ // Written only after the upsert above: the record store is the identity authority, and a
142
+ // marker pointing at a garden-id with no record would be a window of un-backed identity.
143
+ // A failed marker costs reply-addressability, never the session — log and move on.
144
+ const ownerPid = process.ppid;
145
+ if (typeof ownerPid === "number" && ownerPid > 0) {
146
+ try {
147
+ writeMetaSenderMarker({
148
+ backend: "antigravity",
149
+ gardenId: result.record.gardenId,
150
+ nativeSessionId: conversationId,
151
+ cwd,
152
+ ownerPid,
153
+ });
154
+ logLine(`sender marker ${ownerPid} -> ${result.record.gardenId} conversationId=${conversationId}`);
155
+ }
156
+ catch (err) {
157
+ logLine(`sender-marker-failed pid=${ownerPid} gardenId=${result.record.gardenId} ${err instanceof Error ? err.message : String(err)}`);
158
+ }
159
+ }
160
+ }
161
+ catch (err) {
162
+ logLine(`upsert-failed conversationId=${conversationId} ${err instanceof Error ? err.stack || err.message : String(err)}`);
163
+ }
164
+ }
165
+ imprint(readStdin());
166
+ process.stdout.write(`${NEUTRAL_RESPONSE}\n`);
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ // doctor-pi-provider — fail-loud doctor for the pi provider (entwurfProvider.mcpServers.
3
+ // entwurf-bridge) ownership (#46 Task 2). Side-effect FREE (read-only). Uses the config.ts SSOT
4
+ // `readProviderSettingsFile` so the effective (shadow-resolved) view matches what pi actually
5
+ // loads — NOT a re-implemented merge (GPT D: a python re-impl drifts into "doctor green, runtime
6
+ // red"). Reports user / project / EFFECTIVE command (project shadows user per-name, the
7
+ // resolveProviderConfig rule), plus install-state ownership, and gates on stable-bin resolvability.
8
+ //
9
+ // Env overrides (for the hermetic smoke):
10
+ // PI_PROVIDER_GLOBAL_SETTINGS default: $PI_CODING_AGENT_DIR/settings.json or ~/.pi/agent/settings.json
11
+ // PI_PROVIDER_PROJECT_SETTINGS default: <cwd>/.pi/settings.json
12
+ // PI_PROVIDER_STATE default: $XDG_DATA_HOME/entwurf/pi-provider/install-state.json
13
+ //
14
+ // Exit: 0 ok (incl. honest "never installed / unowned" notes) · 1 hard fail (malformed settings /
15
+ // state-owned-but-drifted / stable bin dangling).
16
+ import { execSync } from "node:child_process";
17
+ import { existsSync, constants as FS, readFileSync, statSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { readProviderSettingsFile } from "../pi-extensions/lib/acp/config.js";
21
+ const BARE = "entwurf-bridge";
22
+ const KEY = "entwurf-bridge";
23
+ const home = homedir();
24
+ const agentDir = process.env.PI_CODING_AGENT_DIR || join(home, ".pi", "agent");
25
+ const globalPath = process.env.PI_PROVIDER_GLOBAL_SETTINGS || join(agentDir, "settings.json");
26
+ const projectPath = process.env.PI_PROVIDER_PROJECT_SETTINGS || join(process.cwd(), ".pi", "settings.json");
27
+ const xdg = process.env.XDG_DATA_HOME || join(home, ".local", "share");
28
+ const statePath = process.env.PI_PROVIDER_STATE || join(xdg, "entwurf", "pi-provider", "install-state.json");
29
+ let hardFail = 0;
30
+ const log = (s) => process.stdout.write(s + "\n");
31
+ function commandOf(settings) {
32
+ const entry = settings.mcpServers?.[KEY];
33
+ if (entry && typeof entry === "object" && typeof entry.command === "string") {
34
+ return entry.command;
35
+ }
36
+ return undefined;
37
+ }
38
+ // Does the command resolve in the environment (best local proxy for "where pi/agy runs")?
39
+ // A bare name is looked up on PATH; a path must be an executable file.
40
+ function resolvable(cmd) {
41
+ if (cmd.includes("/")) {
42
+ try {
43
+ statSync(cmd);
44
+ // eslint-disable-next-line no-bitwise
45
+ return (statSync(cmd).mode & FS.S_IXUSR) !== 0;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ try {
52
+ // `command -v` is a POSIX sh builtin; use the default /bin/sh (NixOS has no /bin/bash).
53
+ execSync(`command -v ${cmd}`, { stdio: "ignore" });
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ log("[pi-provider doctor]");
61
+ // Read via the SSOT — a malformed settings file THROWS here (fail-loud, named file).
62
+ let userCmd;
63
+ let projCmd;
64
+ try {
65
+ userCmd = commandOf(readProviderSettingsFile(globalPath).settings);
66
+ projCmd = commandOf(readProviderSettingsFile(projectPath).settings);
67
+ }
68
+ catch (err) {
69
+ log(` FAIL: ${err instanceof Error ? err.message : String(err)}`);
70
+ process.exit(1);
71
+ }
72
+ // EFFECTIVE = project shadows user per-name (the resolveProviderConfig merge rule).
73
+ const effectiveCmd = projCmd ?? userCmd;
74
+ const effectiveScope = projCmd !== undefined ? "project" : userCmd !== undefined ? "user(global)" : "none";
75
+ log("── scopes (project shadows user per-name)");
76
+ log(` user(global) ${globalPath}: ${userCmd ? `'${userCmd}'` : "entwurf-bridge NOT configured"}`);
77
+ log(` project ${projectPath}: ${projCmd ? `'${projCmd}'` : "entwurf-bridge NOT configured"}`);
78
+ log(` EFFECTIVE (${effectiveScope}): ${effectiveCmd ? `'${effectiveCmd}'` : "none"}`);
79
+ // install-state ownership (user scope). absent state on a configured effective is either a
80
+ // pre-Task-2 install or a user-override we deliberately did not own.
81
+ let ownership;
82
+ if (existsSync(statePath)) {
83
+ try {
84
+ const st = JSON.parse(readFileSync(statePath, "utf8"));
85
+ ownership = typeof st.ownership === "string" ? st.ownership : undefined;
86
+ log(` state: install-state present (ownership=${ownership}).`);
87
+ }
88
+ catch {
89
+ log(` state: FAIL — install-state ${statePath} is unreadable/corrupt.`);
90
+ hardFail = 1;
91
+ }
92
+ }
93
+ else {
94
+ log(" state: no user-scope install-state.");
95
+ }
96
+ log("── verdict");
97
+ if (effectiveCmd === undefined) {
98
+ log(" note: no entwurfProvider.mcpServers.entwurf-bridge in any scope (never installed — this is the '?'; run ./run.sh setup).");
99
+ }
100
+ else if (effectiveCmd === BARE) {
101
+ if (resolvable(effectiveCmd)) {
102
+ log(` ok: effective command is the bare stable bin '${BARE}' and it RESOLVES.`);
103
+ }
104
+ else {
105
+ log(` FAIL: effective command is '${BARE}' but it does NOT resolve (run ./run.sh expose-dev-bin / npm bin-link).`);
106
+ hardFail = 1;
107
+ }
108
+ }
109
+ else {
110
+ // effective is NOT the bare bin. If state says we own it → drift (FAIL). Otherwise classify
111
+ // the effective command honestly: our OWN legacy repo start.sh (not yet adopted) is NOT a
112
+ // user override — say so distinctly so "run setup" is the clear next step. A truly foreign
113
+ // command is an unowned override left as the operator's choice. Neither is a hard fail.
114
+ const isLegacyManaged = effectiveCmd.endsWith("/entwurf/mcp/entwurf-bridge/start.sh");
115
+ if (ownership && ownership !== "user-override") {
116
+ log(` FAIL: state owns entwurf-bridge (ownership=${ownership}) but the effective command drifted to '${effectiveCmd}'.`);
117
+ hardFail = 1;
118
+ }
119
+ else if (isLegacyManaged) {
120
+ log(` note: effective is our LEGACY managed repo path ('${effectiveCmd}'), not yet adopted to the bare stable bin. Run ./run.sh setup to normalize (this is the pre-Task-2 '?').`);
121
+ }
122
+ else {
123
+ log(` note: entwurf-bridge is an UNOWNED override ('${effectiveCmd}') — effective is not the stable bin. Left as the operator's choice (run ./run.sh setup to adopt the bare bin).`);
124
+ }
125
+ }
126
+ if (hardFail) {
127
+ log("pi-provider doctor: FAIL.");
128
+ process.exit(1);
129
+ }
130
+ log("pi-provider doctor: ok.");