@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,216 @@
1
+ /**
2
+ * entwurf-v2-spawn — the 5c-3a spawn-bg RESUME watcher hand (0.11 Stage 0 step 5c-3a).
3
+ * It WIRES the real spawn + socket-observe IO onto the pure release reducer (5c-1):
4
+ * launch the resume child, watch for the FIRST observable liveness transition, feed
5
+ * that observation to `reduceRelease`, and release the held lock EXACTLY ONCE — or, if
6
+ * NO observation can be obtained, RETAIN the lock and surface the evidence rather than
7
+ * release blind. Every IO seam is an injected dep (the gate fakes it with controlled
8
+ * promises), so the spawn→observe→release ORDERING is gate-provable without a real
9
+ * child, socket, or timer — the same "pure-before-IO, IO-via-dep" discipline 5b/5c-2
10
+ * used.
11
+ *
12
+ * The load-bearing contract (Fable 3, the whole reason 5c is sliced pure-before-IO):
13
+ * TIMEOUT IS NOT A RELEASE EVENT. A spawn-bg dispatch holds its per-gid lock until an
14
+ * OBSERVED transition — `socket-alive` (the resumed child stood its control socket up)
15
+ * or `child-exited` (any code, incl. null = killed by signal). A bare `observeTimeoutMs`
16
+ * expiry proves NOTHING (the child may stand its socket up a moment later), so releasing
17
+ * on it would reopen the exact double-spawn window 5a's lock exists to close. Instead the
18
+ * timeout RESOLVES BY OBSERVATION: kill the child, then wait a BOUNDED `killGraceMs` for
19
+ * the kill to produce a real `child-exited` (or a racing `socket-alive`). Only THAT
20
+ * observation releases. If even the grace elapses with no observation, the hand does NOT
21
+ * release — it returns a `lock-retained` diagnostic (released:false, with pid / socket /
22
+ * lockPath / timeouts) so an operator can SEE the long-held lock, exactly as F2-P2
23
+ * ("관측 가능해야 수용") demands. The function always returns BOUNDED — it never hangs.
24
+ *
25
+ * Post-spawn unexpected dep failure follows the SAME rule (GPT 5c-3a correction): once a
26
+ * child exists, an observation-less release is forbidden. A watch/timer/kill dep that
27
+ * throws is handled by best-effort kill → bounded attempt to OBSERVE the exit → release
28
+ * if observed, else `lock-retained` fail-closed. There is NO direct-release escape hatch:
29
+ * `deps.releaseLock` is reached ONLY through `reduceRelease` on a real observation event.
30
+ *
31
+ * Release authority is ALWAYS the LockClaim the decider handed over; the watcher reads
32
+ * ONLY `plan.expectedSocketPath` and never re-derives a socket path or a lock by gid.
33
+ */
34
+ import { decideReleasePolicy, initialReleaseState, reduceRelease, } from "./entwurf-v2-release.js";
35
+ function tagSocket(p) {
36
+ return p.then(() => ({ tag: "socket" }));
37
+ }
38
+ function tagExit(p) {
39
+ return p.then((code) => ({ tag: "exit", code }));
40
+ }
41
+ function tagTimeout(p) {
42
+ return p.then(() => ({ tag: "timeout" }));
43
+ }
44
+ function errMsg(err) {
45
+ return err instanceof Error ? err.message : String(err);
46
+ }
47
+ /** Swallow a watcher promise's eventual rejection when it is abandoned without a race
48
+ * handler (the S1 sync-throw path tears down before the race attaches one), so an aborted
49
+ * loser cannot surface as an unhandledRejection. A no-op when the promise never started. */
50
+ function defuse(p) {
51
+ if (p)
52
+ void p.catch(() => { });
53
+ }
54
+ /**
55
+ * Launch a spawn-bg resume child and watch for the first observable liveness transition,
56
+ * releasing the held lock EXACTLY ONCE on that observation — or RETAINING the lock (with
57
+ * surfaced evidence) if no observation can be obtained. `lock` MUST be the in-domain claim
58
+ * the decider handed over — `decideReleasePolicy` throws if it is null or paired with the
59
+ * wrong gid (a mis-wire is fail-loud, not a runtime branch). Always returns bounded.
60
+ *
61
+ * A `releaseLock` throw on a RELEASED path propagates honestly (the observation already
62
+ * happened, so the caller must NOT re-spawn; the lock is dirty and needs manual cleanup —
63
+ * 5b masking direction). Such a throw is the ONE non-result exit; every other terminal
64
+ * state is a returned `SpawnBgResumeResult`.
65
+ */
66
+ export async function executeSpawnBgResume(plan, lock, deps) {
67
+ // Throws on a null / mis-paired lock (?7 + gid invariants). After this the spawn-bg
68
+ // policy is release-after-spawn-observation and the lock is non-null.
69
+ const policy = decideReleasePolicy(plan, lock);
70
+ const held = lock;
71
+ let state = initialReleaseState();
72
+ // The ONLY path to deps.releaseLock: fold a real observation event through the reducer
73
+ // and release iff (and exactly once) the reducer says so. No direct-release hatch.
74
+ const fire = (event) => {
75
+ const r = reduceRelease(policy, state, event);
76
+ state = r.state;
77
+ if (r.shouldRelease)
78
+ deps.releaseLock(held);
79
+ };
80
+ // Spawn. A throw here means NO child exists → spawn-start-failed releases (nothing to
81
+ // watch; keeping the lock would pin the gid forever). No child means no lock-leak risk.
82
+ let child;
83
+ try {
84
+ child = await deps.spawnChild(plan);
85
+ }
86
+ catch (err) {
87
+ fire({ kind: "spawn-start-failed", error: errMsg(err) });
88
+ return { kind: "spawn-start-failed", released: true, error: errMsg(err) };
89
+ }
90
+ const controller = new AbortController();
91
+ const { signal } = controller;
92
+ // Created ONCE and reused across the primary race and the kill-grace race; in the
93
+ // timeout branch both are still pending (timeout won), so re-racing them is safe.
94
+ //
95
+ // S1 (Fable 2차): the creation is in its OWN try because a watch dep can throw
96
+ // SYNCHRONOUSLY (a buggy dep that throws where it must RETURN a Promise — the same grade
97
+ // the send hand's case-13 backstop covers). The child already exists, so this is a
98
+ // post-spawn failure and the SAME rule applies: an observation-less release is forbidden.
99
+ // Best-effort kill, defuse any watcher that DID start (so its later rejection is not
100
+ // unhandled), tear down, and fail-closed to a retained diagnostic. We do NOT try to
101
+ // observe the exit — awaitChildExit may itself be the thrower; retained is the honest
102
+ // floor. (The async-rejection path is handled later by the race catch → backstop.)
103
+ let socketP;
104
+ let exitP;
105
+ try {
106
+ socketP = tagSocket(deps.awaitSocketAlive(plan.expectedSocketPath, signal));
107
+ exitP = tagExit(deps.awaitChildExit(child, signal));
108
+ }
109
+ catch (err) {
110
+ try {
111
+ deps.killChild(child);
112
+ }
113
+ catch {
114
+ // best-effort
115
+ }
116
+ controller.abort();
117
+ defuse(socketP);
118
+ defuse(exitP);
119
+ return retained(plan, child, "observe-failed", deps.killGraceMs, held.lockPath, errMsg(err));
120
+ }
121
+ try {
122
+ // Primary observation race. `timeout` participates but is NEVER a release event.
123
+ let first;
124
+ try {
125
+ const timeoutP = tagTimeout(deps.awaitTimeout(plan.observeTimeoutMs, signal));
126
+ first = await Promise.race([socketP, exitP, timeoutP]);
127
+ }
128
+ catch (err) {
129
+ // A watch/timer dep rejected BEFORE any observation → post-spawn backstop.
130
+ return await backstop(plan, deps, child, held.lockPath, exitP, signal, fire, errMsg(err));
131
+ }
132
+ if (first.tag === "socket") {
133
+ fire({ kind: "socket-alive" });
134
+ return { kind: "socket-alive", released: true, pid: child.pid };
135
+ }
136
+ if (first.tag === "exit") {
137
+ fire({ kind: "child-exited", code: first.code });
138
+ return { kind: "child-exited", released: true, exitCode: first.code, pid: child.pid };
139
+ }
140
+ // first.tag === "timeout": NOT a release. Escalate to a kill, then wait a BOUNDED
141
+ // grace for the kill to produce a real child-exited (or a racing socket-alive).
142
+ try {
143
+ deps.killChild(child);
144
+ }
145
+ catch {
146
+ // kill itself threw — still try to observe within the grace; if nothing, retain.
147
+ }
148
+ let second;
149
+ try {
150
+ const graceP = tagTimeout(deps.awaitTimeout(deps.killGraceMs, signal));
151
+ second = await Promise.race([socketP, exitP, graceP]);
152
+ }
153
+ catch (err) {
154
+ // A dep rejected during the grace → cannot confirm; retain the lock, surface it.
155
+ return retained(plan, child, "kill-unconfirmed", deps.killGraceMs, held.lockPath, errMsg(err));
156
+ }
157
+ if (second.tag === "socket") {
158
+ fire({ kind: "socket-alive" });
159
+ return { kind: "socket-alive", released: true, pid: child.pid };
160
+ }
161
+ if (second.tag === "exit") {
162
+ fire({ kind: "child-exited", code: second.code });
163
+ return { kind: "child-exited", released: true, exitCode: second.code, pid: child.pid };
164
+ }
165
+ // Grace elapsed with NO observation → the kill is unconfirmed. Do NOT release blind;
166
+ // retain the lock and surface the evidence (bounded return, never a hang).
167
+ return retained(plan, child, "kill-unconfirmed", deps.killGraceMs, held.lockPath, undefined);
168
+ }
169
+ finally {
170
+ // Tear down the losing watchers (their timers / FS watches) on every exit.
171
+ controller.abort();
172
+ }
173
+ }
174
+ /**
175
+ * Post-spawn backstop (GPT 5c-3a correction): a child exists but a watch/timer dep threw
176
+ * before any observation. An observation-less release is forbidden (it reopens the
177
+ * double-spawn window), so: best-effort kill → BOUNDED attempt to OBSERVE the exit →
178
+ * release iff observed, else `lock-retained` fail-closed. We race ONLY the exit (not the
179
+ * already-suspect socket watcher) plus a fresh grace timer.
180
+ */
181
+ async function backstop(plan, deps, child, lockPath, exitP, signal, fire, originalError) {
182
+ try {
183
+ deps.killChild(child);
184
+ }
185
+ catch {
186
+ // best-effort
187
+ }
188
+ try {
189
+ const graceP = tagTimeout(deps.awaitTimeout(deps.killGraceMs, signal));
190
+ const obs = await Promise.race([exitP, graceP]);
191
+ if (obs.tag === "exit") {
192
+ fire({ kind: "child-exited", code: obs.code });
193
+ return { kind: "child-exited", released: true, exitCode: obs.code, pid: child.pid };
194
+ }
195
+ }
196
+ catch {
197
+ // exit watcher itself was the failing dep (or threw again) → fall through to retain.
198
+ }
199
+ return retained(plan, child, "observe-failed", deps.killGraceMs, lockPath, originalError);
200
+ }
201
+ function retained(plan, child, reason, killGraceMs, lockPath, error) {
202
+ return {
203
+ kind: "lock-retained",
204
+ released: false,
205
+ reason,
206
+ error,
207
+ diagnostic: {
208
+ targetGardenId: plan.targetGardenId,
209
+ pid: child.pid,
210
+ expectedSocketPath: plan.expectedSocketPath,
211
+ lockPath,
212
+ observeTimeoutMs: plan.observeTimeoutMs,
213
+ killGraceMs,
214
+ },
215
+ };
216
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * entwurf-v2-surface — 5d-3a: the ctx-free SURFACE adapter that joins a surface's raw params
3
+ * to `runEntwurfV2` and renders the outcome-rich result back to a human/tool string. It is the
4
+ * ONE place a surface (pi-native `entwurf_v2` tool now; the MCP `entwurf_v2` verb in 5d-3b)
5
+ * crosses into the v2 fence — so `entwurf-control.ts` (a root-tsc, emit-capable surface that
6
+ * CANNOT statically import the `.ts`-extension fence without TS5097) reaches this via a
7
+ * NON-LITERAL dynamic import, and the MCP bridge (already a `.ts`-import consumer) imports it
8
+ * directly. Either way the ctx binding stays OUT of the fence: the caller builds
9
+ * `senderProvider` from its own envelope source and passes it in `opts`.
10
+ *
11
+ * Three exports:
12
+ * - `toDispatchInput` — surface params → `DispatchInput` (the `wants_reply`→`wantsReply`
13
+ * snake→camel bridge; `intent` is REQUIRED and passed through verbatim, never inferred
14
+ * from mode/action — that would blur the F1 ownership contract).
15
+ * - `renderEntwurfV2Result` — the result union → `{ text, isError }`, surfacing the carry-overs
16
+ * the surface must NOT drop: a reject's reason+diagnostic, a control `rejectReason` (N3), a
17
+ * spawn `lock-retained` diagnostic, and the N1 delivered+lock-dirty `execution-failed`.
18
+ * - `runAndRenderEntwurfV2FromSurface` — assemble production deps + run + render, so the root
19
+ * surface never needs to name the `EntwurfV2RunResult` union (it only sees `{text,isError}`).
20
+ */
21
+ import * as path from "node:path";
22
+ import { makeProductionEntwurfV2Deps } from "./entwurf-v2-production.js";
23
+ import { runEntwurfV2 } from "./entwurf-v2-runner.js";
24
+ /** The operator-policy SSOT for v2 dispatch's preflight prefix-auto-approve roots (5d-4b).
25
+ * ONE shared env var feeds BOTH surfaces (pi-native + MCP) — a pi session and an MCP child
26
+ * both inherit it, so there is no per-surface config fork. `prefixRoots` is operator policy,
27
+ * not session-local UX, so it is an env var, not a pi flag. */
28
+ export const ENTWURF_PREFIX_ROOTS_ENV = "ENTWURF_PREFIX_ROOTS";
29
+ /** Parse `ENTWURF_PREFIX_ROOTS` into the preflight's `prefixRoots`. `path.delimiter`-
30
+ * separated (`:` on Linux/macOS); entries are trimmed, empty segments dropped. Unset / empty
31
+ * / delimiters-only ⇒ `[]` (no prefix promotion — frozen decision 7, no package default).
32
+ * It does NOT throw on a nonexistent/typo path: `preflight`'s normalize keeps an absolute
33
+ * fallback, so a bad root simply never matches (a typo must not broaden approve, and must not
34
+ * turn every owned-outcome dispatch into a loud failure). `~` is left for preflight to expand. */
35
+ export function parseEntwurfPrefixRootsEnv(raw = process.env[ENTWURF_PREFIX_ROOTS_ENV]) {
36
+ if (!raw)
37
+ return [];
38
+ return raw
39
+ .split(path.delimiter)
40
+ .map((s) => s.trim())
41
+ .filter((s) => s.length > 0);
42
+ }
43
+ /** Surface params → the runner's `DispatchInput`. `wants_reply`→`wantsReply`; `intent` and
44
+ * `message` pass through; `mode`/`wantsReply` are left undefined when absent so the decider's
45
+ * own defaults (follow_up / false) apply — no double-default. */
46
+ export function toDispatchInput(params) {
47
+ return {
48
+ target: params.target,
49
+ intent: params.intent,
50
+ message: params.message,
51
+ mode: params.mode,
52
+ wantsReply: params.wants_reply,
53
+ };
54
+ }
55
+ /**
56
+ * Detour B (B-a) — actionable rendering of an honest reject. The decider is UNCHANGED:
57
+ * a reject stays a reject (Hard Rule 3), and intent is NEVER auto-converted (Hard Rule 2 —
58
+ * owned→fire-and-forget mailbox fallback would break the F1 ownership contract). This only
59
+ * appends a one-line "what to do instead" to the reject TEXT, so an honest reject stops
60
+ * reading as "delivery impossible". Returns undefined for rejects with no useful next step.
61
+ */
62
+ export function actionableRejectHint(reason) {
63
+ switch (reason) {
64
+ case "backend-liveness-unsupported":
65
+ // A meta-session backend (e.g. claude-code self-fetch) has no liveness predicate, so
66
+ // owned-outcome has nothing to own. Replies go to the mailbox via fire-and-forget.
67
+ return ("meta-session backend has no liveness predicate → owned-outcome is unsupported. " +
68
+ "To reply, dispatch with intent: fire-and-forget — it routes to the meta-mailbox when a " +
69
+ "deliverable/active receiver is armed (else it fail-closes as mailbox-undeliverable). " +
70
+ "(Intent is not auto-converted; you choose it.)");
71
+ case "owned-live-no-autosend":
72
+ // A live target is reachable, but owned-outcome is not an auto-send (Q2/F1).
73
+ return "target is live — owned-outcome never auto-sends. Use intent: fire-and-forget (with wants_reply if you need a reply).";
74
+ default:
75
+ return undefined;
76
+ }
77
+ }
78
+ /** Render the outcome-rich result to `{ text, isError }`. A reject or a thrown/failed/dirty
79
+ * delivery is `isError:true`; a sent/fallback-sent/enqueued/observed delivery is `isError:false`.
80
+ * A control in-band `rejected` is a non-delivery (isError:true) and carries N3 `rejectReason`
81
+ * when present; a spawn `lock-retained` is fail-closed (isError:true) with its diagnostic. */
82
+ export function renderEntwurfV2Result(result) {
83
+ switch (result.kind) {
84
+ case "rejected": {
85
+ const r = result.receipt;
86
+ let text = `entwurf_v2 rejected: ${r.reason} (observed liveness: ${r.observedLiveness ?? "n/a"})`;
87
+ const hint = actionableRejectHint(r.reason);
88
+ if (hint)
89
+ text += `\n → ${hint}`;
90
+ if (result.diagnostic?.kind === "target-locked") {
91
+ const c = result.diagnostic.conflict;
92
+ text +=
93
+ `\n target-locked: ${c.lockPath}` +
94
+ `\n ${c.detail}` +
95
+ (c.holder ? `\n holder: pid ${c.holder.pid} on ${c.holder.hostname} since ${c.holder.createdAt}` : "");
96
+ }
97
+ return { text, isError: true };
98
+ }
99
+ case "executed": {
100
+ const o = result.outcome;
101
+ if (o.transport === "control-socket") {
102
+ const delivered = o.outcome === "sent" || o.outcome === "fallback-sent";
103
+ const reason = o.rejectReason ? ` (reason: ${o.rejectReason})` : "";
104
+ return {
105
+ text: `entwurf_v2 control-socket → ${o.outcome}${reason}`,
106
+ isError: !delivered,
107
+ };
108
+ }
109
+ if (o.transport === "spawn-bg") {
110
+ const res = o.result;
111
+ if (res.kind === "lock-retained") {
112
+ const d = res.diagnostic;
113
+ return {
114
+ text: `entwurf_v2 spawn-bg LOCK RETAINED (${res.reason}) — lock NOT released, operator must clear:` +
115
+ `\n target: ${d.targetGardenId}` +
116
+ `\n lockPath: ${d.lockPath}` +
117
+ `\n expectedSocketPath: ${d.expectedSocketPath}` +
118
+ `\n observeTimeoutMs: ${d.observeTimeoutMs}, killGraceMs: ${d.killGraceMs}` +
119
+ (res.error ? `\n error: ${res.error}` : ""),
120
+ isError: true,
121
+ };
122
+ }
123
+ if (res.kind === "spawn-start-failed") {
124
+ return { text: `entwurf_v2 spawn-bg failed to start: ${res.error}`, isError: true };
125
+ }
126
+ const pid = "pid" in res && res.pid !== undefined ? ` (pid ${res.pid})` : "";
127
+ const exit = res.kind === "child-exited" ? ` exitCode=${res.exitCode}` : "";
128
+ return { text: `entwurf_v2 spawn-bg → ${res.kind}${pid}${exit}, lock released`, isError: false };
129
+ }
130
+ // meta-mailbox
131
+ return { text: "entwurf_v2 meta-mailbox → enqueued", isError: false };
132
+ }
133
+ case "execution-failed": {
134
+ if (result.releaseFailed && result.finalizedOutcome) {
135
+ // N1: the delivery/refusal reached a terminal outcome but releaseLock then threw.
136
+ return {
137
+ text: `entwurf_v2 ${result.transport} DELIVERED (${result.finalizedOutcome}) but the lock is DIRTY ` +
138
+ `(release failed) — do NOT retry, a re-send would double-deliver. Clear the lock by hand.` +
139
+ `\n error: ${result.error}`,
140
+ isError: true,
141
+ };
142
+ }
143
+ return {
144
+ text: `entwurf_v2 ${result.transport} execution failed: ${result.error} (retry-safe: ${result.retrySafe})`,
145
+ isError: true,
146
+ };
147
+ }
148
+ }
149
+ }
150
+ /**
151
+ * Assemble production deps, run the v2 dispatch, and render the result. The root surface only
152
+ * ever sees `{ text, isError }` — it never names the `EntwurfV2RunResult` union — so the v2
153
+ * fence types stay behind this one entry point.
154
+ */
155
+ export async function runAndRenderEntwurfV2FromSurface(params, opts) {
156
+ const prodOpts = {
157
+ senderProvider: opts.senderProvider,
158
+ agentDir: opts.agentDir,
159
+ // Explicit opts win (test / future surface override); otherwise the shared env SSOT.
160
+ prefixRoots: opts.prefixRoots ?? parseEntwurfPrefixRootsEnv(),
161
+ };
162
+ const result = await runEntwurfV2(toDispatchInput(params), makeProductionEntwurfV2Deps(prodOpts));
163
+ return renderEntwurfV2Result(result);
164
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * meta-mailbox-body — the SINGLE source for rendering an entwurf message as a
3
+ * meta-bridge mailbox body. Both transports that can deliver to a garden citizen
4
+ * with no live control socket use this:
5
+ * - the MCP bridge entwurf_v2 (mcp/entwurf-bridge) — external/Claude-host sends
6
+ * - the pi-native entwurf_v2 (pi-extensions/entwurf-control.ts) — pi-session sends
7
+ *
8
+ * The control-socket path carries the sender envelope inside its RPC framing; the
9
+ * mailbox path is just a file, so the envelope must be SERIALIZED INTO the body —
10
+ * else a receiver reading entwurf_inbox_read would not know who sent it, whether
11
+ * the sender is replyable (and at which sessionId), or whether a reply was wanted.
12
+ * The render mirrors the live "[entwurf received ⟵]" header so a transcript reads
13
+ * the same whether the message arrived over a socket or a mailbox.
14
+ *
15
+ * No filesystem/network IO and no mutation — the only ambient read is
16
+ * process.env.HOME for display abbreviation (so not strictly referentially pure,
17
+ * but deterministic per environment). Extracted so the two senders cannot drift
18
+ * in how a mailbox message presents who-sent-it — the field that round-trips
19
+ * garden-id replies.
20
+ */
21
+ /** `~`-abbreviate a home-relative cwd for display. Reads process.env.HOME. */
22
+ function abbreviateHome(cwd) {
23
+ const home = process.env.HOME;
24
+ if (!home)
25
+ return cwd;
26
+ if (cwd === home)
27
+ return "~";
28
+ if (cwd.startsWith(`${home}/`))
29
+ return `~${cwd.slice(home.length)}`;
30
+ return cwd;
31
+ }
32
+ /** Format a UTC ISO timestamp as `YYYY-MM-DD HH:MM:SS KST` (UTC+9, no DST).
33
+ * Returns the raw input when it does not parse. Pure. */
34
+ function formatKstTimestamp(iso) {
35
+ const ms = Date.parse(iso);
36
+ if (Number.isNaN(ms))
37
+ return iso;
38
+ const kst = new Date(ms + 9 * 60 * 60 * 1000);
39
+ const pad = (n) => n.toString().padStart(2, "0");
40
+ return (`${kst.getUTCFullYear()}-${pad(kst.getUTCMonth() + 1)}-${pad(kst.getUTCDate())} ` +
41
+ `${pad(kst.getUTCHours())}:${pad(kst.getUTCMinutes())}:${pad(kst.getUTCSeconds())} KST`);
42
+ }
43
+ /**
44
+ * Render the full mailbox body: header envelope + separator + message. A
45
+ * replyable sender (pi-session, or a trusted meta-session) advertises its
46
+ * sessionId as the reply address; a non-replyable sender says so WITHOUT
47
+ * losing its origin — a record-backed meta-session that is currently inactive
48
+ * renders as `(meta-session, non-replyable)`, not degraded to `external`.
49
+ */
50
+ export function formatMetaMailboxBody(sender, message, wantsReply) {
51
+ const replyable = sender.replyable === true;
52
+ const isMeta = sender.origin === "meta-session";
53
+ const kind = isMeta ? "meta-session, " : "";
54
+ const sessionLine = replyable
55
+ ? `${sender.sessionId} (${kind}replyable — reply via entwurf_v2 to this sessionId, intent=fire-and-forget)`
56
+ : isMeta
57
+ ? `${sender.sessionId} (meta-session, non-replyable)`
58
+ : `${sender.sessionId} (external, non-replyable)`;
59
+ return (`[entwurf received ⟵]\n` +
60
+ ` from: ${sender.agentId} @ ${abbreviateHome(sender.cwd)}\n` +
61
+ ` session: ${sessionLine}\n` +
62
+ ` at: ${formatKstTimestamp(sender.timestamp)}\n` +
63
+ ` wants reply: ${wantsReply ? "yes" : "no"}\n` +
64
+ `────────────────────────────────────────\n` +
65
+ `${message}\n`);
66
+ }