@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.
- package/CHANGELOG.md +40 -0
- package/README.md +88 -28
- package/docs/setup-clean-host.md +117 -219
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +454 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +111 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +1683 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +121 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +155 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +119 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +160 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +63 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +81 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +290 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +254 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +365 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +64 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +218 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +108 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-resume-marker.js +33 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +116 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +184 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +237 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +216 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +164 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +66 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +1502 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +50 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +259 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +81 -0
- package/mcp/entwurf-bridge/dist/protocol.js +29 -0
- package/mcp/entwurf-bridge/start.sh +49 -7
- package/mcp/entwurf-bridge/test.sh +12 -3
- package/mcp/entwurf-bridge/tsconfig.build.json +42 -0
- package/package.json +30 -9
- package/pi/meta-bridge/.claude-plugin/marketplace.json +0 -1
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +101 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +10 -78
- package/pi-extensions/lib/entwurf-v2-decider.ts +6 -2
- package/pi-extensions/lib/entwurf-v2-production.ts +26 -4
- package/run.sh +150 -15
- package/scripts/check-entwurf-bridge-pi-free.ts +146 -0
- package/scripts/check-entwurf-v2-contract.ts +6 -4
- package/scripts/check-meta-manifest-schema.py +145 -0
- package/scripts/meta-bridge-install.sh +17 -3
- package/scripts/meta-bridge-state.py +37 -10
- package/scripts/smoke-acp-bundled-mcp-live.ts +13 -2
- 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
|
+
}
|