@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.
- package/AGENTS.md +31 -17
- package/BASELINE.md +42 -8
- package/CHANGELOG.md +22 -0
- package/DELIVERY.md +71 -14
- package/README.md +77 -45
- package/VERIFY.md +18 -13
- package/docs/setup-clean-host.md +80 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
- package/mcp/entwurf-bridge/src/index.ts +101 -67
- package/mcp/entwurf-bridge/test.sh +1 -1
- package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
- package/package.json +10 -5
- package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
- package/pi-extensions/lib/entwurf-self-address.ts +58 -15
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
- package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
- package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
- package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
- package/pi-extensions/lib/meta-sender-identity.ts +154 -0
- package/pi-extensions/lib/native-push/adapter.ts +255 -0
- package/pi-extensions/lib/native-push/register.ts +99 -0
- package/run.sh +756 -214
- package/scripts/agy-bridge-config.py +446 -0
- package/scripts/agy-bridge.sh +359 -0
- package/scripts/agy-hooks-bridge.sh +193 -0
- package/scripts/agy-hooks-config.py +257 -0
- package/scripts/agy-imprint.sh +28 -0
- package/scripts/agy-imprint.ts +193 -0
- package/scripts/agy-statusline-bridge.sh +176 -0
- package/scripts/agy-statusline-config.py +213 -0
- package/scripts/agy-statusline.sh +256 -0
- package/scripts/build-bridge.sh +20 -0
- package/scripts/check-agy-sender-identity.ts +364 -0
- package/scripts/check-entwurf-bridge-boot.ts +8 -2
- package/scripts/check-entwurf-deliverability.ts +34 -0
- package/scripts/check-entwurf-self-address.ts +78 -11
- package/scripts/check-entwurf-v2-contract.ts +136 -1
- package/scripts/check-entwurf-v2-decider.ts +95 -1
- package/scripts/check-entwurf-v2-matrix.ts +14 -3
- package/scripts/check-entwurf-v2-native-push.ts +193 -0
- package/scripts/check-entwurf-v2-production.ts +68 -1
- package/scripts/check-entwurf-v2-runner.ts +58 -0
- package/scripts/check-entwurf-v2-surface.ts +35 -0
- package/scripts/check-install-surface.ts +357 -0
- package/scripts/check-native-push-adapter.ts +319 -0
- package/scripts/check-native-push-register.ts +130 -0
- package/scripts/dev-bin.sh +195 -0
- package/scripts/doctor-pi-provider.ts +140 -0
- package/scripts/meta-bridge-doctor.sh +36 -2
- package/scripts/register-pi-package.py +37 -3
- package/scripts/register-pi-provider.py +287 -0
- package/scripts/smoke-agy-hooks-state.sh +172 -0
- package/scripts/smoke-agy-install-state.sh +660 -0
- package/scripts/smoke-agy-native-push-live.ts +243 -0
- package/scripts/smoke-agy-statusline-state.sh +300 -0
- package/scripts/smoke-meta-async-drift.sh +9 -2
- package/scripts/smoke-meta-install-state.sh +20 -0
- package/scripts/smoke-pi-provider-state.sh +182 -0
- package/scripts/smoke-user-scope-citizen.sh +62 -0
- package/scripts/with-dist-lock.sh +81 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
- package/scripts/__pycache__/meta-bridge-state.cpython-313.pyc +0 -0
- 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.");
|