@junghanacs/entwurf 0.12.0 → 0.12.2

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 (49) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +88 -28
  3. package/docs/setup-clean-host.md +117 -219
  4. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +454 -0
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +111 -0
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +1683 -0
  7. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +121 -0
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +155 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +119 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +160 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +63 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +81 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +290 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +254 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +365 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +64 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +218 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +108 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-resume-marker.js +33 -0
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +116 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +125 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +184 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +237 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +216 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +164 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +66 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +1502 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +50 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +259 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +81 -0
  32. package/mcp/entwurf-bridge/dist/protocol.js +29 -0
  33. package/mcp/entwurf-bridge/start.sh +49 -7
  34. package/mcp/entwurf-bridge/test.sh +12 -3
  35. package/mcp/entwurf-bridge/tsconfig.build.json +42 -0
  36. package/package.json +30 -9
  37. package/pi/meta-bridge/.claude-plugin/marketplace.json +0 -1
  38. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +101 -0
  39. package/pi-extensions/lib/entwurf-v2-contract.ts +10 -78
  40. package/pi-extensions/lib/entwurf-v2-decider.ts +6 -2
  41. package/pi-extensions/lib/entwurf-v2-production.ts +26 -4
  42. package/run.sh +150 -15
  43. package/scripts/check-entwurf-bridge-pi-free.ts +146 -0
  44. package/scripts/check-entwurf-v2-contract.ts +6 -4
  45. package/scripts/check-meta-manifest-schema.py +145 -0
  46. package/scripts/meta-bridge-install.sh +17 -3
  47. package/scripts/meta-bridge-state.py +37 -10
  48. package/scripts/smoke-acp-bundled-mcp-live.ts +13 -2
  49. package/scripts/smoke-acp-carrier-augment-live.ts +35 -19
@@ -0,0 +1,50 @@
1
+ /**
2
+ * session-id — garden-native session id SSOT (`YYYYMMDDTHHMMSS-[0-9a-f]{6}`).
3
+ *
4
+ * Authored as `.js` for the exact reason protocol.js is (see its header): this
5
+ * leaf is imported from BOTH runtime paths —
6
+ * - tsc-emit path: pi-extension `.ts` files under the root tsconfig (allowJs),
7
+ * - `node --experimental-strip-types` path: pure unit gates (check-meta-session,
8
+ * new-session-id) that import a lib with a literal specifier.
9
+ * strip-types does not substitute `.ts` for a literal `.js` import specifier, and
10
+ * the root config cannot enable `allowImportingTsExtensions` without losing the
11
+ * tsc emit that check-models relies on. A real `.js` leaf resolves identically in
12
+ * every path, so the id grammar has ONE definition instead of one-per-importer.
13
+ *
14
+ * Keep dependency-free except `node:crypto`. The validator/grammar here is the
15
+ * same one the 0.9.0 resident garden guard and entwurf spawn collision pre-check
16
+ * enforce — do NOT fork it.
17
+ */
18
+ import { randomBytes } from "node:crypto";
19
+ /** `YYYYMMDDTHHMMSS-[0-9a-f]{6}`. Anchored; no surrounding slop. */
20
+ export const SESSION_ID_RE = /^\d{8}T\d{6}-[0-9a-f]{6}$/;
21
+ /**
22
+ * @param {unknown} value
23
+ * @returns {value is string}
24
+ */
25
+ export function isValidSessionId(value) {
26
+ return typeof value === "string" && SESSION_ID_RE.test(value);
27
+ }
28
+ /**
29
+ * Local (KST on operator machines) denote-style timestamp `YYYYMMDDTHHMMSS`.
30
+ * Garden sort sense. Local components on purpose — the denote corpus is local.
31
+ *
32
+ * @param {Date} [now]
33
+ * @returns {string}
34
+ */
35
+ export function formatSessionTimestamp(now = new Date()) {
36
+ const p = (n, w = 2) => String(n).padStart(w, "0");
37
+ return (`${p(now.getFullYear(), 4)}${p(now.getMonth() + 1)}${p(now.getDate())}` +
38
+ `T${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`);
39
+ }
40
+ /**
41
+ * Durable garden sessionId minted at the session's true birth. 6 hex suffix
42
+ * defeats same-second parallel-spawn collision; callers that spawn still
43
+ * header-scan pre-check (assertSessionIdAvailableForSpawn).
44
+ *
45
+ * @param {Date} [now]
46
+ * @returns {string}
47
+ */
48
+ export function generateSessionId(now = new Date()) {
49
+ return `${formatSessionTimestamp(now)}-${randomBytes(3).toString("hex")}`;
50
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * socket-discovery — the SOCKET-axis wiring for the fact-provider (0.11 Stage 0
3
+ * step 4, slice 3). Turns the control-socket directory + the in-domain citizen
4
+ * list into the `SocketProbe[]` that `resolveFactList` (slice 2) consumes.
5
+ *
6
+ * Why a probe per in-domain citizen, not just a directory listing: slice 2's
7
+ * frozen invariant is that EVERY in-domain (pi) citizen must arrive PROBED — a
8
+ * dormant citizen whose socket file is gone must read as `dead` (ENOENT =
9
+ * positive proof of absence) so it routes dormant→resumable, never as an
10
+ * unprobed `null`/`indeterminate` that would strand it (resolveFactList throws
11
+ * on an unprobed in-domain citizen). So we probe the union of
12
+ * (sockets present in the dir) ∪ (every in-domain citizen's canonical path):
13
+ * a dir-present socket yields alive / indeterminate / dead; a citizen with no
14
+ * file yields `dead` via ENOENT. Three-valued throughout (`probeSocketLiveness`)
15
+ * — an indeterminate stall is NEVER folded to dead (F3). This is exactly why we
16
+ * cannot reuse the legacy `getLiveSessions` (alive-only listing): folding the
17
+ * hidden indeterminate/dead sockets into "absent" would resurrect the F3 split.
18
+ *
19
+ * This slice fills the LIVENESS axis and, for live sockets, best-effort runtime
20
+ * enrich via the control RPC `get_info` (cwd / model / idle). `SocketProbe`'s
21
+ * enrich fields remain nullable-by-design: a dead/indeterminate socket or a
22
+ * failed enrich is HONEST, not synthetic, and carries `infoError` when known.
23
+ *
24
+ * Three socket-axis hazards are surfaced (slice 4c, Fable 검수), never swallowed:
25
+ * - SYMLINK (P1, security): a `<gid>.sock` that is a symlink can redirect to
26
+ * another session's listener, so gid X would probe ALIVE on Y's socket — a
27
+ * forgery of 동결결정3's correlation authority (the socket filename = the gid).
28
+ * The legacy bridge `getLiveSessions` guarded this (`entry.isSymbolicLink()`);
29
+ * deriving the listing from facts would drop that guard unless we re-assert it
30
+ * here. A symlinked socket is NEVER probed: a citizen owning one is forced to
31
+ * `dead` (→ dormant → resume a fresh process, never SEND to a hijacked
32
+ * listener); a record-less one is quarantined out of the listing entirely.
33
+ * Both surface as `symlinkedGardenIds`.
34
+ * - MALFORMED NAME (P3): a `*.sock` whose stem is not a garden id has no citizen
35
+ * to correlate to and is dropped — but VISIBLY (`malformedNames`), not
36
+ * silently (the legacy path listed any non-empty name; a silent regex drop
37
+ * would violate "no silent drops").
38
+ * - DIR-READ ERROR (P2e②): a missing dir (ENOENT) is the normal fresh-install
39
+ * empty; ANY OTHER readdir failure (EACCES, …) is asymmetric loss of the whole
40
+ * socket axis and is surfaced as `dirError`, not catch-all'd to empty (which
41
+ * would silently vanish every socket-only session). When the dir is untrusted
42
+ * this way, in-domain citizens are NOT probed (a non-ENOENT readdir failure
43
+ * means we cannot confirm the canonical path is not a symlink, and `connect()`
44
+ * would follow one) — they are reported `indeterminate` (liveness unknown),
45
+ * held not stranded: once the dir reads again they route normally (GPi Q2/P1).
46
+ * The provider (slice 4b) folds these three into kind-tagged `EntwurfDiagnostic`s;
47
+ * this lib only reports the raw facts so the import stays one-way (provider →
48
+ * socket-discovery, never back).
49
+ *
50
+ * Deps (dir / readdir / probe) are injectable so the gate drives it without IO.
51
+ */
52
+ import * as fs from "node:fs/promises";
53
+ import * as os from "node:os";
54
+ import * as path from "node:path";
55
+ import { fetchControlSocketRuntimeInfo, formatRuntimeModel } from "./entwurf-control-rpc.js";
56
+ import { SESSION_ID_RE } from "./session-id.js";
57
+ import { probeSocketLiveness } from "./socket-probe.js";
58
+ /** Canonical control-socket directory; the socket filename IS the gardenId
59
+ * (동결결정3 correlation authority). */
60
+ export const CONTROL_SOCKET_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
61
+ export const SOCKET_SUFFIX = ".sock";
62
+ // A control-socket filename is a bare garden id. We reuse the repo-wide
63
+ // `SESSION_ID_RE` SSOT (not a local copy): 동결결정3 makes the socket filename the
64
+ // correlation authority, which only holds if the socket axis and the meta-record
65
+ // axis speak the SAME id grammar — a drifted local regex would silently drop a
66
+ // legitimate gid's socket from the scan. A malformed name has no citizen to
67
+ // correlate to and is ignored.
68
+ export function controlSocketPath(gardenId, dir = CONTROL_SOCKET_DIR) {
69
+ return path.join(dir, `${gardenId}${SOCKET_SUFFIX}`);
70
+ }
71
+ /**
72
+ * A1 narrow (0.11.0): does this PROBE-FREE single-lstat inspection of a gid's canonical
73
+ * control socket mean a record-LESS pi endpoint is addressable as a socket-only target?
74
+ * TRUE only for a confirmed NON-SYMLINK socket file (`socket-file`); a symlinked /
75
+ * absent / not-socket / `indeterminate` path is conservatively NOT promoted (never trust a
76
+ * symlink, never claim a target on an unprovable lstat). Shared by the v2 production
77
+ * `resolveTarget` so the socket-only acceptance uses the SAME lstat classification the
78
+ * listing/conflict paths use — listing↔dispatch cannot drift on what counts as a real
79
+ * control socket. The decider still does its own under-lock `inspectSocket` probe; this is
80
+ * only the presence hint that promotes `bad-target` → fire-and-forget socket-only pi.
81
+ */
82
+ export function isSocketOnlyPiCandidate(inspection) {
83
+ return inspection.kind === "socket-file";
84
+ }
85
+ /**
86
+ * Inspect the EXACT control-socket path given (no gid re-derivation) and classify it by
87
+ * lstat alone. This is the path-addressed core of the inspection: the 5c-3 spawn-bg watcher
88
+ * observes `plan.expectedSocketPath` and MUST inspect that exact path (its contract forbids
89
+ * re-deriving a path from the gid), so the path-taking form is the SSOT and
90
+ * `inspectTargetControlSocket` is the thin gid→path wrapper over it. `lstatFn` is injectable
91
+ * so the gate drives every branch without a real filesystem; the default is `fs.lstat`
92
+ * (which, unlike connect, does NOT follow the final symlink — that is the whole point: a
93
+ * symlink is caught as an address-conflict and never connected, P1).
94
+ */
95
+ export async function inspectControlSocketPath(socketPath, lstatFn = (p) => fs.lstat(p)) {
96
+ let st;
97
+ try {
98
+ st = await lstatFn(socketPath);
99
+ }
100
+ catch (err) {
101
+ const code = err.code;
102
+ if (code === "ENOENT")
103
+ return { kind: "absent", socketPath };
104
+ // EACCES / unknown: not provably absent, so never treat as dead-and-spawn.
105
+ return { kind: "indeterminate", socketPath, error: code ?? "unknown lstat error" };
106
+ }
107
+ // lstat does NOT dereference the final component — a symlink is caught HERE and
108
+ // never connected (P1). A non-socket regular file / dir / fifo at the canonical
109
+ // path is address corruption, not a live socket.
110
+ if (st.isSymbolicLink())
111
+ return { kind: "address-conflict", socketPath, reason: "symlink" };
112
+ if (st.isSocket())
113
+ return { kind: "socket-file", socketPath };
114
+ return { kind: "address-conflict", socketPath, reason: "not-socket" };
115
+ }
116
+ /**
117
+ * The gid-addressed inspection (?2 — lstat-then-connect, v2 decider helper): derive the
118
+ * canonical control-socket path for `gardenId` and inspect it. A thin wrapper over
119
+ * `inspectControlSocketPath` so the decider's gid-keyed path and the watcher's exact-path
120
+ * observation share ONE lstat classifier (no drift in the P1 symlink guard).
121
+ */
122
+ export async function inspectTargetControlSocket(gardenId, dir = CONTROL_SOCKET_DIR, lstatFn = (p) => fs.lstat(p)) {
123
+ return inspectControlSocketPath(controlSocketPath(gardenId, dir), lstatFn);
124
+ }
125
+ /**
126
+ * Map a target's socket inspection to a measured `SocketLiveness` (to feed
127
+ * resolveDispatch) or a pre-probe address-conflict signal. `absent` (ENOENT only) is
128
+ * the honest `dead` (the citizen is dormant; its canonical socket is the path a resume
129
+ * will create). `socket-file` is the only case that connects. `address-conflict`
130
+ * (symlink / not-a-socket) and `indeterminate` never connect.
131
+ *
132
+ * Shared SSOT for the v2 decider (5b, decideDispatch) AND the dead-control-send
133
+ * fallback resolver (5c-2b): both must map an inspection the SAME way, or one could
134
+ * route a stalled socket where the other reclaims it — exactly the F3 split this lib
135
+ * exists to prevent. A per-caller copy would drift; this is the single mapper.
136
+ */
137
+ export async function mapInspectionToLiveness(inspection, probeSocket) {
138
+ switch (inspection.kind) {
139
+ case "absent":
140
+ return { liveness: "dead", socketPath: inspection.socketPath };
141
+ case "socket-file": {
142
+ const liveness = await probeSocket(inspection.socketPath);
143
+ return { liveness, socketPath: inspection.socketPath };
144
+ }
145
+ case "indeterminate":
146
+ return { liveness: "indeterminate", socketPath: inspection.socketPath };
147
+ case "address-conflict":
148
+ return { addressConflict: true };
149
+ }
150
+ }
151
+ /**
152
+ * Probe the union of (control sockets present in `dir`) ∪ (`piCitizenGardenIds`)
153
+ * and return one `SocketProbe` per gardenId (liveness + live get_info enrich), plus
154
+ * the three surfaced hazards. A missing directory (ENOENT) is the normal empty
155
+ * (`dirError=null`) — the in-domain citizens are still probed (their absent
156
+ * canonical paths read `dead`); any OTHER readdir failure sets `dirError`. A
157
+ * symlinked `*.sock` is never probed (P1): a citizen owning one is forced `dead`,
158
+ * a record-less one is dropped from `probes` entirely. Output sorted by gardenId.
159
+ */
160
+ export async function scanSocketProbes(piCitizenGardenIds, deps = {}) {
161
+ const dir = deps.dir ?? CONTROL_SOCKET_DIR;
162
+ const readdir = deps.readdir ??
163
+ (async (d) => {
164
+ const dirents = await fs.readdir(d, { withFileTypes: true });
165
+ return dirents.map((e) => ({ name: e.name, isSymbolicLink: e.isSymbolicLink() }));
166
+ });
167
+ const probe = deps.probe ?? ((p) => probeSocketLiveness(p));
168
+ // Deterministic gates commonly inject fake readdir/probe over fake paths. In that
169
+ // case, default enrich must stay no-op unless the test explicitly injects getInfo.
170
+ // Real production calls inject neither readdir nor probe, so they get live RPC
171
+ // enrich by default.
172
+ const getInfo = deps.getInfo ??
173
+ (deps.readdir || deps.probe
174
+ ? async () => ({ cwd: null, model: null, idle: null })
175
+ : getRuntimeInfoOverControlSocket);
176
+ let entries = [];
177
+ let dirError = null;
178
+ try {
179
+ entries = await readdir(dir);
180
+ }
181
+ catch (err) {
182
+ // ENOENT = fresh install / no sessions yet = the normal empty. Anything else
183
+ // (EACCES, EIO, …) is real loss of the socket axis — surface it, don't hide it.
184
+ const code = err?.code;
185
+ if (code !== "ENOENT") {
186
+ dirError = err instanceof Error ? err.message : String(err);
187
+ }
188
+ entries = [];
189
+ }
190
+ const socketGids = new Set();
191
+ const symlinkedGardenIds = [];
192
+ const malformedNames = [];
193
+ for (const entry of entries) {
194
+ if (!entry.name.endsWith(SOCKET_SUFFIX))
195
+ continue;
196
+ const gid = entry.name.slice(0, -SOCKET_SUFFIX.length);
197
+ if (!SESSION_ID_RE.test(gid)) {
198
+ malformedNames.push(entry.name);
199
+ continue;
200
+ }
201
+ if (entry.isSymbolicLink) {
202
+ // Never trust a symlinked socket: it can point at another session's
203
+ // listener and forge an `alive` for this gid (동결결정3 authority forgery).
204
+ symlinkedGardenIds.push(gid);
205
+ continue;
206
+ }
207
+ socketGids.add(gid);
208
+ }
209
+ const symlinkSet = new Set(symlinkedGardenIds);
210
+ // A non-ENOENT readdir failure means the dir is untrusted: we could not enumerate
211
+ // it, so we cannot confirm a canonical path is not a symlink. connect() follows
212
+ // symlinks, so probing here would defeat the P1 guard — hold every citizen at
213
+ // `indeterminate` instead (the socket-dir-read-error diagnostic carries the why).
214
+ const dirUntrusted = dirError !== null;
215
+ const allGids = [...new Set([...socketGids, ...piCitizenGardenIds])].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
216
+ const probes = [];
217
+ for (const gardenId of allGids) {
218
+ // A citizen whose canonical socket is a symlink is forced `dead` (→ dormant →
219
+ // resume a fresh process) rather than probed through the untrusted link. A
220
+ // record-less symlink gid is not in `allGids` at all (dropped above).
221
+ let liveness;
222
+ if (symlinkSet.has(gardenId)) {
223
+ liveness = "dead";
224
+ }
225
+ else if (dirUntrusted) {
226
+ liveness = "indeterminate";
227
+ }
228
+ else {
229
+ liveness = await probe(controlSocketPath(gardenId, dir));
230
+ }
231
+ let cwd = null;
232
+ let model = null;
233
+ let idle = null;
234
+ let infoError = null;
235
+ if (liveness === "alive") {
236
+ try {
237
+ const info = await getInfo(controlSocketPath(gardenId, dir));
238
+ cwd = info.cwd;
239
+ model = info.model;
240
+ idle = info.idle;
241
+ }
242
+ catch (err) {
243
+ infoError = err instanceof Error ? err.message : String(err);
244
+ }
245
+ }
246
+ probes.push({ gardenId, liveness, cwd, model, idle, infoError });
247
+ }
248
+ symlinkedGardenIds.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
249
+ malformedNames.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
250
+ return { probes, symlinkedGardenIds, malformedNames, dirError };
251
+ }
252
+ async function getRuntimeInfoOverControlSocket(socketPath) {
253
+ const info = await fetchControlSocketRuntimeInfo(socketPath, { timeout: 1500 });
254
+ return {
255
+ cwd: info.cwd ?? null,
256
+ model: formatRuntimeModel(info) ?? null,
257
+ idle: info.idle ?? null,
258
+ };
259
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * socket-probe — single source of truth for control-socket liveness.
3
+ *
4
+ * Why this is a shared lib (not a per-file copy): the entwurf-control
5
+ * extension (`pi-extensions/entwurf-control.ts`) AND the MCP bridge
6
+ * (`mcp/entwurf-bridge/src/index.ts`) both probe `~/.pi/entwurf-control/*.sock`.
7
+ * They used to carry independent `isSocketAlive` copies. If only one side
8
+ * learned the three-valued classification the two probes would diverge — the
9
+ * bridge routes a timeout target one way (mailbox fallback) while the
10
+ * extension's GC reclaims the same socket. Both now consume this module so the
11
+ * liveness semantics are identical on every surface. (0.11 Stage 0 step 4
12
+ * plans a probe lib extraction anyway; this is its first slice.)
13
+ *
14
+ * Three-valued, not boolean — the F3 fix. A connect probe can mean three
15
+ * different things, and collapsing them to a boolean is what let
16
+ * `gcStaleSockets` permanently unlink a *live* socket that merely stalled
17
+ * under load (→ every later probe sees it as dormant → live-session resume =
18
+ * identity split). The cure: never destroy a socket we do not understand.
19
+ * - alive → a listener accepted the connection (positive proof)
20
+ * - dead → ECONNREFUSED / ENOENT only (positive proof of absence)
21
+ * - indeterminate → timeout, EACCES, or any other/unknown error
22
+ * (no proof either way — keep the file, hide from listing)
23
+ */
24
+ import * as net from "node:net";
25
+ export const DEFAULT_PROBE_TIMEOUT_MS = 300;
26
+ /**
27
+ * Pure classification of a connect-time error code into liveness. Only the two
28
+ * codes that unambiguously mean "no live listener at this path" are dead:
29
+ * ECONNREFUSED (socket file exists, nothing listening) and ENOENT (no socket
30
+ * file). EVERYTHING else — EACCES, ETIMEDOUT, an undefined code, an unknown
31
+ * code — is indeterminate. The default direction matters: when we don't know,
32
+ * we do not destroy. (동결결정: "ECONNREFUSED/ENOENT만 dead", taken literally.)
33
+ */
34
+ export function classifyConnectError(code) {
35
+ return code === "ECONNREFUSED" || code === "ENOENT" ? "dead" : "indeterminate";
36
+ }
37
+ /**
38
+ * GC policy: a stale-socket sweep may reclaim ONLY a demonstrably dead socket.
39
+ * An indeterminate probe (timeout / unknown error) must survive the sweep —
40
+ * reclaiming it is exactly the F3 live-session split. Alive obviously survives.
41
+ */
42
+ export function shouldUnlinkOnGc(liveness) {
43
+ return liveness === "dead";
44
+ }
45
+ /**
46
+ * Listing policy: a session appears in the live listing only on a positive
47
+ * connect. Indeterminate is hidden from the listing (preserving the prior
48
+ * boolean listing semantics) but — unlike GC — is NOT unlinked. dead is hidden
49
+ * too. This keeps "what GC reclaims" strictly narrower than "what is listed".
50
+ */
51
+ export function shouldListAsLive(liveness) {
52
+ return liveness === "alive";
53
+ }
54
+ /**
55
+ * Probe a control socket and classify it. Positive connect → alive; a
56
+ * connect-time error is routed through `classifyConnectError`; a connect that
57
+ * neither connects nor errors within `timeoutMs` → indeterminate (the load
58
+ * stall case F3 is about — never coerced to dead).
59
+ */
60
+ export async function probeSocketLiveness(socketPath, opts = {}) {
61
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
62
+ return await new Promise((resolve) => {
63
+ const socket = net.createConnection(socketPath);
64
+ const timer = setTimeout(() => {
65
+ socket.destroy();
66
+ resolve("indeterminate");
67
+ }, timeoutMs);
68
+ const settle = (liveness) => {
69
+ clearTimeout(timer);
70
+ socket.removeAllListeners();
71
+ resolve(liveness);
72
+ };
73
+ socket.once("connect", () => {
74
+ socket.end();
75
+ settle("alive");
76
+ });
77
+ socket.once("error", (err) => {
78
+ settle(classifyConnectError(err.code));
79
+ });
80
+ });
81
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared wire-format constants for entwurf surfaces that must agree
3
+ * across the root bridge and pi-extension / MCP helper code.
4
+ *
5
+ * Keep this file dependency-free. Single source for both runtime paths:
6
+ * - tsc-emit path: typechecked under root tsconfig.json (allowJs: true)
7
+ * and copied through to the .tmp-verify directories by tsc emit.
8
+ * - Node --experimental-strip-types path (mcp bridges): resolves explicit
9
+ * .js imports literally to this file on disk.
10
+ *
11
+ * Why .js and not .ts: strip-types does not substitute .ts source for a
12
+ * literal .js import specifier, and the root config cannot enable
13
+ * allowImportingTsExtensions without losing tsc emit (which check-models
14
+ * relies on). Authoring as .js side-steps the whole drift surface.
15
+ */
16
+ /**
17
+ * Opening marker for the project-context block inserted by entwurf's
18
+ * `enrichTaskWithProjectContext`. The ACP bridge uses the same marker to
19
+ * detect entwurf-spawned first prompts and remove only the duplicate cwd
20
+ * AGENTS.md section from its own pi-context augment.
21
+ */
22
+ export const ENTWURF_PROJECT_CONTEXT_OPEN_TAG = "<project-context";
23
+ /**
24
+ * Sender-side UI echo custom message type for successful entwurf_v2 send calls.
25
+ * The bridge emits it, the control renderer draws it, and the provider-level
26
+ * context filter removes it before LLM conversion. Keep as one SSOT: a typo
27
+ * here otherwise becomes a silent UI/context split.
28
+ */
29
+ export const ENTWURF_SENT_MESSAGE_TYPE = "entwurf-sent";
@@ -1,8 +1,19 @@
1
1
  #!/usr/bin/env bash
2
- # entwurf-bridge MCP server launcher.
2
+ # entwurf-bridge MCP server launcher (dual-mode, chosen by LOCATION).
3
3
  #
4
- # Runs src/index.ts directly via Node's --experimental-strip-types; no separate
5
- # build step. Node >= 22.6 (engines.node in ../../package.json).
4
+ # PUBLISHED / INSTALLED (path is under node_modules): runs the prebuilt JS at
5
+ # dist/mcp/entwurf-bridge/src/index.js with plain `node`. This path exists
6
+ # because Node's --experimental-strip-types REFUSES `.ts` under node_modules
7
+ # (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so the 0.12.0 strip-types
8
+ # launcher broke the moment the package was npm-installed. `prepack`
9
+ # (pnpm run build-bridge → rm -rf dist && tsc -p tsconfig.build.json) emits a
10
+ # clean dist/ into the tarball; the package `files` allowlist ("mcp/") carries it.
11
+ #
12
+ # DEV CLONE (path is NOT under node_modules): runs src/index.ts via
13
+ # --experimental-strip-types. The clone lives outside node_modules so strip-types
14
+ # is allowed, edits are picked up with no build step, and a leftover dist/ (e.g.
15
+ # emitted by `prepack` during a `pnpm check` pack gate) can never silently shadow
16
+ # the source. Node >= 22.6 (engines.node in ../../package.json).
6
17
  #
7
18
  # Env file loading is strictly opt-in — the launcher never reads any dotfile
8
19
  # unless ENTWURF_BRIDGE_ENV_FILE points at one. Rationale: entwurf is a
@@ -10,7 +21,16 @@
10
21
  # bleed the original author's dotfile habits into every consumer's shell.
11
22
  set -euo pipefail
12
23
 
13
- HERE="$(cd "$(dirname "$0")" && pwd)"
24
+ SOURCE="${BASH_SOURCE[0]}"
25
+ while [ -L "$SOURCE" ]; do
26
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
27
+ TARGET="$(readlink "$SOURCE")"
28
+ case "$TARGET" in
29
+ /*) SOURCE="$TARGET" ;;
30
+ *) SOURCE="$DIR/$TARGET" ;;
31
+ esac
32
+ done
33
+ HERE="$(cd -P "$(dirname "$SOURCE")" && pwd)"
14
34
 
15
35
  if [ -n "${ENTWURF_BRIDGE_ENV_FILE:-}" ] && [ -f "$ENTWURF_BRIDGE_ENV_FILE" ]; then
16
36
  set -a
@@ -19,7 +39,29 @@ if [ -n "${ENTWURF_BRIDGE_ENV_FILE:-}" ] && [ -f "$ENTWURF_BRIDGE_ENV_FILE" ]; t
19
39
  set +a
20
40
  fi
21
41
 
22
- # All chatter goes to stderr so it never confuses an MCP client reading
23
- # JSON-RPC frames from stdout.
42
+ # rootDir is the repo root (the bridge graph spans mcp/ and pi-extensions/),
43
+ # so tsc mirrors the source tree under dist/ — hence the nested entry path.
44
+ DIST_ENTRY="$HERE/dist/mcp/entwurf-bridge/src/index.js"
45
+ SRC_ENTRY="$HERE/src/index.ts"
46
+
47
+ # Mode is decided by LOCATION, not by "does dist happen to exist". A dev clone
48
+ # lives outside node_modules and ALWAYS runs the TS source via strip-types, so an
49
+ # edit is picked up immediately and a stale dist (e.g. left behind by a prior
50
+ # `npm pack`/`prepack` during `pnpm check`) can never silently shadow the source.
51
+ # An installed package lives under node_modules, where Node refuses strip-types,
52
+ # so it MUST run the prebuilt dist. All chatter goes to stderr so it never
53
+ # confuses an MCP client reading JSON-RPC frames from stdout.
54
+ case "$HERE" in
55
+ */node_modules/*)
56
+ if [ ! -f "$DIST_ENTRY" ]; then
57
+ echo "entwurf-bridge: installed under node_modules but the prebuilt dist is missing:" >&2
58
+ echo " $DIST_ENTRY" >&2
59
+ echo " The published tarball ships dist via prepack; reinstall @junghanacs/entwurf." >&2
60
+ exit 1
61
+ fi
62
+ exec node "$DIST_ENTRY"
63
+ ;;
64
+ esac
65
+
24
66
  exec node --experimental-strip-types --disable-warning=ExperimentalWarning \
25
- "$HERE/src/index.ts"
67
+ "$SRC_ENTRY"
@@ -2,8 +2,17 @@
2
2
  set -euo pipefail
3
3
 
4
4
  # entwurf-bridge smoke (v2-only): tool registration + a few no-side-effect calls.
5
- ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
6
- BRIDGE="$ROOT_DIR/mcp/entwurf-bridge/src/index.ts"
5
+ SOURCE="${BASH_SOURCE[0]}"
6
+ while [ -L "$SOURCE" ]; do
7
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
8
+ TARGET="$(readlink "$SOURCE")"
9
+ case "$TARGET" in
10
+ /*) SOURCE="$TARGET" ;;
11
+ *) SOURCE="$DIR/$TARGET" ;;
12
+ esac
13
+ done
14
+ ROOT_DIR="$(cd -P "$(dirname "$SOURCE")/../.." && pwd)"
15
+ BRIDGE_LAUNCHER="$ROOT_DIR/mcp/entwurf-bridge/start.sh"
7
16
 
8
17
  fail() { echo "FAIL: $*" >&2; exit 1; }
9
18
  ok() { echo "ok: $*"; }
@@ -18,7 +27,7 @@ jsonrpc() {
18
27
  -u ENTWURF_META_SENDER_MARKER \
19
28
  -u ENTWURF_BRIDGE_REQUIRE_META_SENDER \
20
29
  ENTWURF_META_SENDERS_DIR="$tmp_meta_senders" \
21
- node --experimental-strip-types "$BRIDGE" <<'EOF'
30
+ bash "$BRIDGE_LAUNCHER" <<'EOF'
22
31
  {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}
23
32
  {"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
24
33
  {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
@@ -0,0 +1,42 @@
1
+ {
2
+ // Emit config for the entwurf-bridge MCP server (0.12.1 C — install fix).
3
+ //
4
+ // WHY THIS EXISTS: Node's `--experimental-strip-types` REFUSES `.ts` files
5
+ // under `node_modules` (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING). So the
6
+ // 0.12.0 launcher (`node --experimental-strip-types src/index.ts`) works from a
7
+ // dev clone but breaks the moment the package is installed under node_modules
8
+ // (every npm/pnpm consumer). This config tsc-EMITS the bridge boot closure to a
9
+ // plain-JS tree (`dist/`) that boots with vanilla `node` from anywhere. start.sh
10
+ // runs dist/ when its path is under node_modules (installed) and runs the
11
+ // strip-types src/ otherwise (dev clone).
12
+ //
13
+ // FENCE NOTE: the bridge graph is the SAME `.ts`-extension fence the root
14
+ // tsconfig excludes (root is emit-capable and can't allow `.ts` imports). This
15
+ // config pairs `allowImportingTsExtensions` with `rewriteRelativeImportExtensions`
16
+ // (TS 5.7+) instead of `noEmit`, so emitted JS references `.js` paths, including
17
+ // the lazy `await import("./entwurf-preflight.ts")` → `./entwurf-preflight.js`.
18
+ // That dynamic import stays dynamic in the output, so the eager boot closure
19
+ // remains pi-free (check-entwurf-bridge-pi-free) while the owned-outcome resume
20
+ // branch still lazy-loads the pi-backed preflight. This is an EMIT pass, not a
21
+ // typecheck pass — the noEmit typecheck fence (scripts/tsconfig.json) is
22
+ // untouched, so every `.ts` is still typechecked by exactly one `tsc --noEmit`.
23
+ "extends": "../../tsconfig.json",
24
+ "compilerOptions": {
25
+ "allowImportingTsExtensions": true,
26
+ "rewriteRelativeImportExtensions": true,
27
+ "noEmit": false,
28
+ "declaration": false,
29
+ "sourceMap": false,
30
+ "outDir": "./dist",
31
+ "rootDir": "../.."
32
+ },
33
+ // Clear the root `exclude` (it filters out every `.ts`-extension lib file the
34
+ // bridge graph needs). Only node_modules must stay excluded.
35
+ "exclude": ["../../node_modules"],
36
+ // Just the bridge entry (+ the dependency-free protocol.js it reaches via an
37
+ // explicit `.js` import). tsc auto-follows the import graph — STATIC imports
38
+ // AND the lazy `await import("./entwurf-preflight.ts")` — and emits exactly the
39
+ // boot closure, nothing more. This tracks graph drift automatically and keeps
40
+ // the pi-lane-only acp/* (unreached at boot) out of the tarball.
41
+ "include": ["./src/index.ts", "../../protocol.js"]
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Garden-citizen dispatch substrate and meta-bridge for Claude Code, Codex, Antigravity, and pi harnesses.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -19,17 +19,17 @@
19
19
  "access": "public"
20
20
  },
21
21
  "keywords": [
22
- "pi",
23
- "pi-package",
24
- "pi-extension",
25
- "pi-coding-agent",
26
- "garden-session",
27
22
  "entwurf",
23
+ "garden-id",
24
+ "garden-citizen",
28
25
  "dispatch",
29
26
  "meta-bridge",
27
+ "mcp",
30
28
  "claude-code",
31
29
  "codex",
32
- "mcp"
30
+ "antigravity",
31
+ "pi-coding-agent",
32
+ "pi-extension"
33
33
  ],
34
34
  "type": "module",
35
35
  "files": [
@@ -89,8 +89,10 @@
89
89
  "typescript": "^6.0.2"
90
90
  },
91
91
  "scripts": {
92
- "prepare": "husky 2>/dev/null || true",
92
+ "prepare": "husky 2>/dev/null || true; npm run --silent build-bridge",
93
93
  "postinstall": "node scripts/postinstall-chmod.cjs",
94
+ "build-bridge": "rm -rf mcp/entwurf-bridge/dist && tsc -p mcp/entwurf-bridge/tsconfig.build.json",
95
+ "prepack": "pnpm --silent run build-bridge",
94
96
  "prepublishOnly": "pnpm run check && pnpm run check-pack-install",
95
97
  "setup": "./run.sh setup",
96
98
  "check-shell-quote": "./run.sh check-shell-quote",
@@ -116,8 +118,9 @@
116
118
  "typecheck": "tsc --noEmit && tsc -p mcp/tsconfig.json && tsc -p scripts/tsconfig.json",
117
119
  "lint": "biome check .",
118
120
  "format": "biome check --write .",
121
+ "check-meta-manifest-schema": "./run.sh check-meta-manifest-schema",
119
122
  "check-package-source-routing": "./run.sh check-package-source-routing",
120
- "check": "pnpm lint && pnpm typecheck && ./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-record-v2 && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-meta-dual-read && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-migration && ./run.sh check-meta-dual-consumers && ./run.sh check-meta-capability-source && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh smoke-meta-install-state && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-package-source-routing && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-install-preflight && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-lock && ./run.sh check-entwurf-v2-decider && ./run.sh check-entwurf-v2-matrix && ./run.sh check-entwurf-v2-release && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-send-fallback && ./run.sh check-entwurf-v2-mailbox && ./run.sh check-entwurf-v2-runner && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-boot && ./run.sh check-entwurf-v2-spawn && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-spawn-production && ./run.sh check-entwurf-facts && ./run.sh check-socket-discovery && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-entwurf-mailbox-guard && ./run.sh check-auth-boundary && ./run.sh check-acp-provider-surface && ./run.sh check-acp-sdk-surface && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-acp-carrier-augment && ./run.sh check-pack"
123
+ "check": "pnpm lint && pnpm typecheck && ./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-record-v2 && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-meta-dual-read && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-migration && ./run.sh check-meta-dual-consumers && ./run.sh check-meta-capability-source && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh smoke-meta-install-state && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-package-source-routing && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-install-preflight && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-lock && ./run.sh check-entwurf-v2-decider && ./run.sh check-entwurf-v2-matrix && ./run.sh check-entwurf-v2-release && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-send-fallback && ./run.sh check-entwurf-v2-mailbox && ./run.sh check-entwurf-v2-runner && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-boot && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-entwurf-v2-spawn && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-spawn-production && ./run.sh check-entwurf-facts && ./run.sh check-socket-discovery && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-entwurf-mailbox-guard && ./run.sh check-auth-boundary && ./run.sh check-acp-provider-surface && ./run.sh check-acp-sdk-surface && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-acp-carrier-augment && ./run.sh check-pack"
121
124
  },
122
125
  "pi": {
123
126
  "extensions": [
@@ -126,5 +129,23 @@
126
129
  "./pi-extensions/model-lock.ts"
127
130
  ],
128
131
  "image": "https://raw.githubusercontent.com/junghan0611/entwurf/main/docs/assets/entwurf-hero.jpg"
132
+ },
133
+ "bin": {
134
+ "entwurf": "./run.sh",
135
+ "entwurf-bridge": "./mcp/entwurf-bridge/start.sh"
136
+ },
137
+ "peerDependenciesMeta": {
138
+ "@earendil-works/pi-ai": {
139
+ "optional": true
140
+ },
141
+ "@earendil-works/pi-coding-agent": {
142
+ "optional": true
143
+ },
144
+ "@earendil-works/pi-tui": {
145
+ "optional": true
146
+ },
147
+ "typebox": {
148
+ "optional": true
149
+ }
129
150
  }
130
151
  }