@junghanacs/entwurf 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- package/scripts/tsconfig.json +34 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-v2-decider — the PURE dispatch decider for the unified `entwurf_v2` verb
|
|
3
|
+
* (0.11 Stage 0 step 5b). It turns a caller's (target, intent, …) request plus
|
|
4
|
+
* already-resolvable facts into a single `DispatchDecision`: either a reject
|
|
5
|
+
* receipt, or an execute receipt + an `ExecutionPlan` + (for in-domain backends) a
|
|
6
|
+
* held `LockClaim`. It performs NO transport: no send, no enqueue, no spawn, no
|
|
7
|
+
* release-watcher. Step 5c executes the chosen plan; step 5d registers the MCP
|
|
8
|
+
* surface. The decider only DECIDES.
|
|
9
|
+
*
|
|
10
|
+
* Why a separate pure module (step 4 discipline = gate-first → pure-before-IO →
|
|
11
|
+
* wire): every IO surface the decision needs — the target lookup, the per-gid lock,
|
|
12
|
+
* the lstat/connect socket inspection, the trust preflight, the mailbox-deliverability
|
|
13
|
+
* seam — is INJECTED via `DispatchDeciderDeps`, so the gate drives every branch with
|
|
14
|
+
* fakes and the live wrappers wire the real fns. The plan is shaped so 5c's
|
|
15
|
+
* transport hand consumes it WITHOUT re-deriving any path/arg (socketPath,
|
|
16
|
+
* mailboxDir, sessionsDir, launchArgs are all planted here once — 4c "재유도 금지"):
|
|
17
|
+
* the hand is a plan-keyed dispatcher, never a second brain. The two deliberate
|
|
18
|
+
* exceptions (GPT힣 1차 검수): the spawned child's runtime pid is NOT in the plan
|
|
19
|
+
* (it is born during 5c execution → it is the watcher's release-context, not a plan
|
|
20
|
+
* input), and the launch identity (provider/model) is read by the 5c launcher from
|
|
21
|
+
* the saved session JSONL (its existing authority) — putting that read in the
|
|
22
|
+
* decider would make it impure. So the plan carries no provider/model.
|
|
23
|
+
*
|
|
24
|
+
* The frozen 7-step order (NEXT.md "통합 decider 순서"):
|
|
25
|
+
* 1. requireGardenId — runtime guard BEFORE any path is built (F2-P1; closes the
|
|
26
|
+
* MCP-schema bypass for pi-native/internal callers).
|
|
27
|
+
* 2. resolveTarget — no citizen → bad-target; quarantined (non-pi gid sharing
|
|
28
|
+
* a socket/symlink) → target-address-conflict. PROBE-FREE.
|
|
29
|
+
* 3. backend → isLivenessSupported.
|
|
30
|
+
* 4. acquireLock — IN-DOMAIN ONLY (?7), BEFORE lstat/connect, so the probe
|
|
31
|
+
* happens under the lock (the TOCTOU 5a's lock closes).
|
|
32
|
+
* 5. in-domain — inspectTargetControlSocket (lstat-then-connect, ?2) →
|
|
33
|
+
* resolveDispatch → on a resume verdict, preflight the target cwd (1B: deny →
|
|
34
|
+
* nonce-owned release → untrusted-fail-fast) → plan.
|
|
35
|
+
* 6. unsupported — NO lock; deps.mailboxDeliverabilityFor (REQUIRED seam: wake-mode
|
|
36
|
+
* capability AND a live active-receiver, fail-closed) → resolveDispatch → meta-mailbox
|
|
37
|
+
* plan or reject. SE-2 2d-3: a terminated/drifted self-fetch citizen is refused, never
|
|
38
|
+
* enqueued as mailbox garbage.
|
|
39
|
+
* 7. send-fail fallback is 5c's job (the decider decides ONCE; the held lock nonce
|
|
40
|
+
* is what lets 5c re-resolve at most once under the same claim).
|
|
41
|
+
*
|
|
42
|
+
* Every reject is minted through `makeRejectReceipt` (?6 chokepoint) — the decider
|
|
43
|
+
* never hand-assembles a `{ok:false, …}` literal, so the pre-probe-null rule cannot
|
|
44
|
+
* be bypassed.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import type { MailboxDeliverabilityResult } from "./entwurf-deliverability.ts";
|
|
48
|
+
import { isNonPiGardenIdSocketConflict } from "./entwurf-facts.ts";
|
|
49
|
+
import type { PreflightOutcome } from "./entwurf-preflight.ts";
|
|
50
|
+
import {
|
|
51
|
+
type EntwurfIntent,
|
|
52
|
+
type EntwurfV2Receipt,
|
|
53
|
+
isLivenessSupported,
|
|
54
|
+
makeRejectReceipt,
|
|
55
|
+
resolveDispatch,
|
|
56
|
+
} from "./entwurf-v2-contract.ts";
|
|
57
|
+
import type { AcquireLockResult, LockClaim, LockConflict } from "./entwurf-v2-lock.ts";
|
|
58
|
+
import {
|
|
59
|
+
defaultMetaMailboxDir,
|
|
60
|
+
defaultMetaSessionsDir,
|
|
61
|
+
type MetaBackendV2,
|
|
62
|
+
type MetaCapability,
|
|
63
|
+
type MetaIdentity,
|
|
64
|
+
metaCapabilityFor,
|
|
65
|
+
} from "./meta-session.ts";
|
|
66
|
+
import { isValidSessionId } from "./session-id.js";
|
|
67
|
+
import { controlSocketPath, mapInspectionToLiveness, type TargetSocketInspection } from "./socket-discovery.ts";
|
|
68
|
+
import type { SocketLiveness } from "./socket-probe.ts";
|
|
69
|
+
|
|
70
|
+
// Re-export the shared conflict predicate so producers of a TargetResolution have a
|
|
71
|
+
// single import site for it (it is the SAME fn the fact-provider listing uses).
|
|
72
|
+
export { isNonPiGardenIdSocketConflict };
|
|
73
|
+
|
|
74
|
+
// ── observe timeout (?3) ───────────────────────────────────────────────────
|
|
75
|
+
// The bounded wait 5c's release-watcher gives a spawned child to surface its
|
|
76
|
+
// control socket / exit. Planted into the resume plan so the hand does not invent
|
|
77
|
+
// a timeout. A standalone constant + env override (NOT a multiple of the probe
|
|
78
|
+
// timeout — a different concern); 30s initial, tunable to 45s after live data.
|
|
79
|
+
export const ENTWURF_V2_OBSERVE_TIMEOUT_MS = ((): number => {
|
|
80
|
+
const raw = process.env.ENTWURF_V2_OBSERVE_TIMEOUT_MS;
|
|
81
|
+
const n = raw !== undefined && raw !== "" ? Number(raw) : Number.NaN;
|
|
82
|
+
return Number.isFinite(n) && n > 0 ? n : 30_000;
|
|
83
|
+
})();
|
|
84
|
+
|
|
85
|
+
export const ENTWURF_V2_MODE_DEFAULT = "follow_up" as const;
|
|
86
|
+
|
|
87
|
+
// ── receipt branch aliases ──────────────────────────────────────────────────
|
|
88
|
+
export type SuccessReceipt = Extract<EntwurfV2Receipt, { ok: true }>;
|
|
89
|
+
export type RejectReceipt = Extract<EntwurfV2Receipt, { ok: false }>;
|
|
90
|
+
export type EntwurfV2Mode = "steer" | "follow_up";
|
|
91
|
+
|
|
92
|
+
// ── ExecutionPlan (5c-consumable, no re-derivation) ─────────────────────────
|
|
93
|
+
// Each plan kind carries every value 5c's transport fn needs, planted once by the
|
|
94
|
+
// decider. control-socket send and meta-mailbox send carry the message; spawn-bg
|
|
95
|
+
// resume carries the launch inputs MINUS provider/model (D4: 5c-owned identity
|
|
96
|
+
// read) and MINUS the child pid (born at execution, watcher's release-context).
|
|
97
|
+
export type ExecutionPlan =
|
|
98
|
+
| {
|
|
99
|
+
transport: "control-socket";
|
|
100
|
+
action: "send";
|
|
101
|
+
targetGardenId: string;
|
|
102
|
+
socketPath: string;
|
|
103
|
+
mode: EntwurfV2Mode;
|
|
104
|
+
wantsReply: boolean;
|
|
105
|
+
message: string;
|
|
106
|
+
}
|
|
107
|
+
| {
|
|
108
|
+
transport: "meta-mailbox";
|
|
109
|
+
action: "send";
|
|
110
|
+
targetGardenId: string;
|
|
111
|
+
mailboxDir: string;
|
|
112
|
+
sessionsDir: string;
|
|
113
|
+
wantsReply: boolean;
|
|
114
|
+
message: string;
|
|
115
|
+
}
|
|
116
|
+
| {
|
|
117
|
+
transport: "spawn-bg";
|
|
118
|
+
action: "resume";
|
|
119
|
+
targetGardenId: string;
|
|
120
|
+
sessionId: string;
|
|
121
|
+
cwd: string;
|
|
122
|
+
prompt: string;
|
|
123
|
+
launchArgs: readonly string[];
|
|
124
|
+
expectedSocketPath: string;
|
|
125
|
+
observeTimeoutMs: number;
|
|
126
|
+
releaseWhen: "socket-alive-or-child-exited";
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// ── DispatchDecision (the decider's only output) ────────────────────────────
|
|
130
|
+
// reject ⇒ NO plan AND NO retained lock (any acquired lock was released before
|
|
131
|
+
// return). execute ⇒ a plan + a receipt whose transport matches plan.transport;
|
|
132
|
+
// `lock` is non-null for an in-domain execute (control-socket send OR spawn-bg
|
|
133
|
+
// resume — both keep the claim so 5c's at-most-once re-resolve runs under the same
|
|
134
|
+
// nonce) and null for the lock-free meta-mailbox path (?7).
|
|
135
|
+
//
|
|
136
|
+
// A reject's optional machine-readable diagnostic. Only `target-locked` carries one:
|
|
137
|
+
// the `LockConflict` (holder pid/host/createdAt, lockPath, human detail) the lock
|
|
138
|
+
// primitive produced on contention. It rides ALONGSIDE the receipt — the receipt
|
|
139
|
+
// schema is unchanged; 5d's surface renders it onto the reject. (B3: without this the
|
|
140
|
+
// holder evidence was dropped at the decider boundary, so a PID-reuse permanent lock
|
|
141
|
+
// could not be observed/cleared — F2-P2 "관측 가능해야 수용".)
|
|
142
|
+
export type RejectDiagnostic = { kind: "target-locked"; conflict: LockConflict };
|
|
143
|
+
|
|
144
|
+
export type DispatchDecision =
|
|
145
|
+
| { kind: "reject"; receipt: RejectReceipt; diagnostic?: RejectDiagnostic }
|
|
146
|
+
| { kind: "execute"; receipt: SuccessReceipt; plan: ExecutionPlan; lock: LockClaim | null };
|
|
147
|
+
|
|
148
|
+
// ── target resolution (E: single-target, not a whole-store scan) ────────────
|
|
149
|
+
// A non-null identity is an existing citizen. `preProbeAddressConflict` is the
|
|
150
|
+
// PROBE-FREE, record-side conflict (non-pi gid sharing a real/symlinked socket —
|
|
151
|
+
// isNonPiGardenIdSocketConflict). The production wrapper computes it with a single
|
|
152
|
+
// readMetaIdentityByGardenId + a target socket/symlink check; the gate injects it.
|
|
153
|
+
// Do NOT call listEntwurfFacts here to find the target — its socket probe would run
|
|
154
|
+
// before the lock (the 1C TOCTOU). The shared predicate is the only thing the
|
|
155
|
+
// listing and the dispatch share.
|
|
156
|
+
export interface TargetResolution {
|
|
157
|
+
identity: MetaIdentity | null;
|
|
158
|
+
preProbeAddressConflict: boolean;
|
|
159
|
+
/**
|
|
160
|
+
* A1 narrow (0.11.0): a record-LESS but live-control-socket-present pi endpoint.
|
|
161
|
+
* `identity` is null (there is NO meta-record citizen), yet a gid-shaped non-symlink
|
|
162
|
+
* control socket exists, so the gid is an addressable socket-only pi endpoint. The decider
|
|
163
|
+
* routes EVERY intent through the same in-domain probe table under `allowResume:false` (it
|
|
164
|
+
* does NOT pre-reject non-fire-and-forget — a live, addressable citizen must never be the
|
|
165
|
+
* `bad-target` "absent" lie). With no record there is no cwd/resume authority, so spawn-bg
|
|
166
|
+
* can never open: owned-outcome × live → owned-live-no-autosend (honest table verdict),
|
|
167
|
+
* owned-outcome × dormant → socket-only-no-resume-authority (the post-probe allowResume:false
|
|
168
|
+
* guard). PROBE-FREE presence hint — the decider still does the real under-lock
|
|
169
|
+
* `inspectSocket`. Only meaningful when `identity === null`; a record-backed citizen never sets it.
|
|
170
|
+
*/
|
|
171
|
+
socketOnlyPi?: boolean;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface DispatchInput {
|
|
175
|
+
target: string;
|
|
176
|
+
intent: EntwurfIntent;
|
|
177
|
+
mode?: EntwurfV2Mode;
|
|
178
|
+
wantsReply?: boolean;
|
|
179
|
+
message: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Every IO seam is a REQUIRED dep (no default): the decider performs ZERO IO of its
|
|
183
|
+
// own. The live wrappers (5c) wire the real fns; the gate injects fakes. This is what
|
|
184
|
+
// makes "pure decider" honest — there is no hidden default that touches `~/.pi`. (B1:
|
|
185
|
+
// the removed `acquireLock` default hardcoded `{ dir: undefined }` → it ignored any
|
|
186
|
+
// injected lock dir and leaked the per-gid lock to the real `~/.pi/entwurf-v2-locks`
|
|
187
|
+
// whenever a caller/test wired the other dirs but relied on the lock default.) The
|
|
188
|
+
// mailbox-deliverability seam is required too (SE-2 2d-3): it carries the active-receiver
|
|
189
|
+
// judgement, so leaving a wake-mode-only default would let a caller skip it and re-open the
|
|
190
|
+
// gap. Only pure config (plan-planted dirs, the observe timeout) keeps a default — it is
|
|
191
|
+
// data, not an IO seam.
|
|
192
|
+
export interface DispatchDeciderDeps {
|
|
193
|
+
resolveTarget: (gardenId: string) => TargetResolution | Promise<TargetResolution>;
|
|
194
|
+
acquireLock: (gardenId: string) => AcquireLockResult;
|
|
195
|
+
releaseLock: (claim: LockClaim) => unknown;
|
|
196
|
+
inspectSocket: (gardenId: string) => Promise<TargetSocketInspection>;
|
|
197
|
+
probeSocket: (socketPath: string) => Promise<SocketLiveness>;
|
|
198
|
+
preflightForCwd: (cwd: string) => PreflightOutcome;
|
|
199
|
+
/**
|
|
200
|
+
* SE-2 slice 2d-3: the REQUIRED mailbox-deliverability seam (no default). The decider
|
|
201
|
+
* does NOT judge deliverability itself — it asks this injected fn, which combines the
|
|
202
|
+
* backend wake-mode capability (only self-fetch has a drainable mailbox) with the LIVE
|
|
203
|
+
* active-receiver check (a presence marker that matches the target identity). Making it
|
|
204
|
+
* required is the whole point: every call site is forced by the compiler to wire the
|
|
205
|
+
* active-receiver axis, so no future caller can silently fall back to wake-mode-only and
|
|
206
|
+
* reopen the SE-2 "enqueue garbage into a terminated session's mailbox" gap.
|
|
207
|
+
*/
|
|
208
|
+
mailboxDeliverabilityFor: (
|
|
209
|
+
identity: MetaIdentity,
|
|
210
|
+
) => MailboxDeliverabilityResult | Promise<MailboxDeliverabilityResult>;
|
|
211
|
+
mailboxDir?: string;
|
|
212
|
+
sessionsDir?: string;
|
|
213
|
+
observeTimeoutMs?: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* F2-P1 defense in depth: never build a lock/socket path from an unvalidated gid.
|
|
218
|
+
* The MCP TypeBox pattern guards that one surface; a pi-native / internal caller
|
|
219
|
+
* bypasses the schema, so the decider re-validates as its very first step.
|
|
220
|
+
*/
|
|
221
|
+
function requireGardenId(target: string): string {
|
|
222
|
+
if (!isValidSessionId(target)) {
|
|
223
|
+
throw new Error(`entwurf_v2 decider: refusing to dispatch to an invalid garden id (${JSON.stringify(target)}).`);
|
|
224
|
+
}
|
|
225
|
+
return target;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Mailbox WAKE-MODE capability (?0 frozen): ONLY a self-fetch backend (claude-code) has a
|
|
230
|
+
* drainable meta-bridge mailbox. direct-inject backends (codex/agy/pi) are fail-closed —
|
|
231
|
+
* that is intended, not a gap (the 0.10.0 mailbox + doorbell is a self-fetch drain;
|
|
232
|
+
* direct-inject drain is an unproven capability). Do NOT widen by deliveryLevel — only by
|
|
233
|
+
* a real per-backend predicate.
|
|
234
|
+
*
|
|
235
|
+
* This is the CAPABILITY HALF of deliverability only. Full mailbox deliverability ALSO
|
|
236
|
+
* requires a live active-receiver (a presence marker matching the identity) — that
|
|
237
|
+
* conjunction lives in the required `mailboxDeliverabilityFor` seam (SE-2 slice 2d-3). The
|
|
238
|
+
* decider NEVER calls this helper directly: deliverability flows exclusively through the
|
|
239
|
+
* seam so the active-receiver axis can never be skipped. Kept as a named, gate-pinned
|
|
240
|
+
* helper for the production seam to compose and for capability-only call sites.
|
|
241
|
+
*/
|
|
242
|
+
export function resolveMailboxWakeModeCapability(
|
|
243
|
+
identity: MetaIdentity,
|
|
244
|
+
capabilityFor: (backend: MetaBackendV2) => MetaCapability = metaCapabilityFor,
|
|
245
|
+
): boolean {
|
|
246
|
+
return capabilityFor(identity.backend).wakeMode === "self-fetch";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The pure dispatch decider. See the module header for the 7-step contract. Async
|
|
251
|
+
* only because the socket inspection/probe are async; it touches the filesystem
|
|
252
|
+
* ONLY through injected deps.
|
|
253
|
+
*/
|
|
254
|
+
export async function decideDispatch(input: DispatchInput, deps: DispatchDeciderDeps): Promise<DispatchDecision> {
|
|
255
|
+
const mailboxDir = deps.mailboxDir ?? defaultMetaMailboxDir();
|
|
256
|
+
const sessionsDir = deps.sessionsDir ?? defaultMetaSessionsDir();
|
|
257
|
+
const observeTimeoutMs = deps.observeTimeoutMs ?? ENTWURF_V2_OBSERVE_TIMEOUT_MS;
|
|
258
|
+
const mode: EntwurfV2Mode = input.mode ?? ENTWURF_V2_MODE_DEFAULT;
|
|
259
|
+
const wantsReply = input.wantsReply ?? false;
|
|
260
|
+
const ctx: InDomainCtx = { mode, wantsReply, observeTimeoutMs };
|
|
261
|
+
|
|
262
|
+
const reject = (receipt: RejectReceipt, diagnostic?: RejectDiagnostic): DispatchDecision =>
|
|
263
|
+
diagnostic ? { kind: "reject", receipt, diagnostic } : { kind: "reject", receipt };
|
|
264
|
+
|
|
265
|
+
// 1. requireGardenId — BEFORE any path is built.
|
|
266
|
+
const gardenId = requireGardenId(input.target);
|
|
267
|
+
|
|
268
|
+
// 2. resolveTarget — probe-free. no citizen → bad-target; quarantined → conflict.
|
|
269
|
+
const resolution = await deps.resolveTarget(gardenId);
|
|
270
|
+
|
|
271
|
+
// 2b. A1 narrow (0.11.0): a record-LESS live pi control socket — a socket-only pi
|
|
272
|
+
// endpoint (no citizen identity, but an addressable control socket). It is a REAL,
|
|
273
|
+
// addressable citizen, so it runs the SAME in-domain probe table as a record-backed pi
|
|
274
|
+
// (lock → inspect → table verdict) under `allowResume:false`. We DELIBERATELY do NOT
|
|
275
|
+
// short-circuit non-fire-and-forget here: rejecting owned-outcome pre-probe with
|
|
276
|
+
// `bad-target` would be a category lie (a LIVE, addressable peer classified as
|
|
277
|
+
// absent/typo). The frozen table is the honest authority instead —
|
|
278
|
+
// owned-outcome × live → `owned-live-no-autosend` (use fire-and-forget for a live peer)
|
|
279
|
+
// owned-outcome × dormant → resume verdict, but `allowResume:false` refuses it with
|
|
280
|
+
// `socket-only-no-resume-authority` (no trusted cwd to spawn-bg)
|
|
281
|
+
// fire-and-forget × live → control-socket send (unchanged)
|
|
282
|
+
// `allowResume:false` is what keeps spawn-bg from EVER opening into a record-less
|
|
283
|
+
// endpoint, regardless of intent — so routing every intent through is safe.
|
|
284
|
+
if (resolution.identity === null && resolution.socketOnlyPi === true) {
|
|
285
|
+
return decideInDomain(gardenId, input, deps, ctx, { allowResume: false });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 2c. no citizen → bad-target; quarantined → conflict.
|
|
289
|
+
if (resolution.identity === null) {
|
|
290
|
+
return reject(makeRejectReceipt("bad-target", null));
|
|
291
|
+
}
|
|
292
|
+
if (resolution.preProbeAddressConflict) {
|
|
293
|
+
return reject(makeRejectReceipt("target-address-conflict", null));
|
|
294
|
+
}
|
|
295
|
+
const identity = resolution.identity;
|
|
296
|
+
|
|
297
|
+
// 3. backend.
|
|
298
|
+
if (!isLivenessSupported(identity.backend)) {
|
|
299
|
+
// 6. unsupported path — NO lock (?7). Deliverability comes from the REQUIRED seam
|
|
300
|
+
// (wake-mode capability AND a live active-receiver marker matching this identity),
|
|
301
|
+
// NOT a wake-mode-only helper — so a terminated self-fetch citizen's mailbox is
|
|
302
|
+
// fail-closed (SE-2 2d-3). resolveDispatch then routes intent × deliverable.
|
|
303
|
+
const deliverability = await deps.mailboxDeliverabilityFor(identity);
|
|
304
|
+
const receipt = resolveDispatch(input.intent, "unsupported", deliverability.deliverable);
|
|
305
|
+
if (!receipt.ok) return reject(receipt);
|
|
306
|
+
// the only allow cell here is fire-and-forget → meta-mailbox send.
|
|
307
|
+
const plan: ExecutionPlan = {
|
|
308
|
+
transport: "meta-mailbox",
|
|
309
|
+
action: "send",
|
|
310
|
+
targetGardenId: gardenId,
|
|
311
|
+
mailboxDir,
|
|
312
|
+
sessionsDir,
|
|
313
|
+
wantsReply,
|
|
314
|
+
message: input.message,
|
|
315
|
+
};
|
|
316
|
+
return { kind: "execute", receipt, plan, lock: null };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 4-5. in-domain (record-backed pi): lock → inspect → route (resume allowed, cwd from record).
|
|
320
|
+
return decideInDomain(gardenId, input, deps, ctx, { allowResume: true, cwd: identity.cwd });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── in-domain probe (steps 4-5), shared by the record-backed pi path and the A1-narrow
|
|
324
|
+
// socket-only pi path ────────────────────────────────────────────────────────────────────
|
|
325
|
+
// The lock lifecycle (B2) lives here: acquire BEFORE lstat/connect, every reject path
|
|
326
|
+
// releases explicitly (rejectAfterRelease), every execute path that keeps the lock sets
|
|
327
|
+
// retainLock=true, and a thrown IO error releases the still-held lock before rethrowing so
|
|
328
|
+
// the long-lived MCP bridge never pins a gid. `resume.allowResume` gates the ONLY branch
|
|
329
|
+
// that reads a target cwd and launches a child: a socket-only endpoint passes `false`, so
|
|
330
|
+
// even though the resume verdict is structurally unreachable for it (fire-and-forget never
|
|
331
|
+
// yields `resume`), spawn-bg can never open into a record-less endpoint.
|
|
332
|
+
type InDomainCtx = { mode: EntwurfV2Mode; wantsReply: boolean; observeTimeoutMs: number };
|
|
333
|
+
type ResumePolicy = { allowResume: true; cwd: string } | { allowResume: false };
|
|
334
|
+
|
|
335
|
+
async function decideInDomain(
|
|
336
|
+
gardenId: string,
|
|
337
|
+
input: DispatchInput,
|
|
338
|
+
deps: DispatchDeciderDeps,
|
|
339
|
+
ctx: InDomainCtx,
|
|
340
|
+
resume: ResumePolicy,
|
|
341
|
+
): Promise<DispatchDecision> {
|
|
342
|
+
const { acquireLock, releaseLock, inspectSocket, probeSocket } = deps;
|
|
343
|
+
|
|
344
|
+
// 4. acquire the per-gid lock BEFORE lstat/connect.
|
|
345
|
+
const acq = acquireLock(gardenId);
|
|
346
|
+
if (!acq.ok) {
|
|
347
|
+
// B3: carry the lock's holder evidence (pid/host/createdAt + lockPath) as a
|
|
348
|
+
// diagnostic so a permanently-held gid is observable/clearable. The receipt
|
|
349
|
+
// stays pre-probe-null; the conflict rides alongside it.
|
|
350
|
+
return {
|
|
351
|
+
kind: "reject",
|
|
352
|
+
receipt: makeRejectReceipt("target-locked", null),
|
|
353
|
+
diagnostic: { kind: "target-locked", conflict: acq.conflict },
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
const lock = acq.claim;
|
|
357
|
+
|
|
358
|
+
const rejectAfterRelease = (receipt: RejectReceipt): DispatchDecision => {
|
|
359
|
+
releaseLock(lock);
|
|
360
|
+
return { kind: "reject", receipt };
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
let retainLock = false;
|
|
364
|
+
try {
|
|
365
|
+
// 5. under the lock: inspect the socket (lstat-then-connect), then route.
|
|
366
|
+
const inspection = await inspectSocket(gardenId);
|
|
367
|
+
const mapped = await mapInspectionToLiveness(inspection, probeSocket);
|
|
368
|
+
if ("addressConflict" in mapped) {
|
|
369
|
+
return rejectAfterRelease(makeRejectReceipt("target-address-conflict", null));
|
|
370
|
+
}
|
|
371
|
+
const { liveness, socketPath } = mapped;
|
|
372
|
+
|
|
373
|
+
const receipt = resolveDispatch(input.intent, liveness, false);
|
|
374
|
+
if (!receipt.ok) {
|
|
375
|
+
// resolver reject (owned-live-no-autosend / indeterminate-no-spawn / …) — the
|
|
376
|
+
// lock was for an in-domain probe that yielded no execute, so release it.
|
|
377
|
+
return rejectAfterRelease(receipt);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (receipt.action === "resume") {
|
|
381
|
+
if (!resume.allowResume) {
|
|
382
|
+
// A1 narrow guard: the resume verdict is owned-outcome × dormant ONLY. A
|
|
383
|
+
// socket-only pi endpoint now routes ALL intents through here under
|
|
384
|
+
// `allowResume:false` (2b no longer pre-rejects non-fire-and-forget), so this
|
|
385
|
+
// guard IS reachable — it is the honest home of "owned-outcome to a DORMANT
|
|
386
|
+
// record-less socket". REFUSE: a record-less endpoint has no trusted cwd/resume
|
|
387
|
+
// authority, so spawn-bg must never open into it. This is a POST-probe guard
|
|
388
|
+
// (we measured `liveness` above), so it carries the honest measured liveness —
|
|
389
|
+
// NOT the pre-probe `bad-target` lie that would mislabel the citizen absent.
|
|
390
|
+
return rejectAfterRelease(makeRejectReceipt("socket-only-no-resume-authority", liveness));
|
|
391
|
+
}
|
|
392
|
+
// 1B: preflight runs ONLY here (the sole branch that launches a child into a
|
|
393
|
+
// target cwd). deny → nonce-owned release → untrusted-fail-fast, with the
|
|
394
|
+
// honest measured liveness (dormant = the `dead` we just probed).
|
|
395
|
+
const outcome = deps.preflightForCwd(resume.cwd);
|
|
396
|
+
if (outcome.kind === "deny") {
|
|
397
|
+
return rejectAfterRelease(makeRejectReceipt("untrusted-fail-fast", liveness));
|
|
398
|
+
}
|
|
399
|
+
const plan: ExecutionPlan = {
|
|
400
|
+
transport: "spawn-bg",
|
|
401
|
+
action: "resume",
|
|
402
|
+
targetGardenId: gardenId,
|
|
403
|
+
sessionId: gardenId, // D3: gid is the pi resume authority, not nativeSessionId.
|
|
404
|
+
cwd: resume.cwd,
|
|
405
|
+
prompt: input.message,
|
|
406
|
+
launchArgs: outcome.launchArgs,
|
|
407
|
+
expectedSocketPath: socketPath,
|
|
408
|
+
observeTimeoutMs: ctx.observeTimeoutMs,
|
|
409
|
+
releaseWhen: "socket-alive-or-child-exited",
|
|
410
|
+
};
|
|
411
|
+
retainLock = true;
|
|
412
|
+
return { kind: "execute", receipt, plan, lock };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// receipt.action === "send" → control-socket send (lock kept for 5c re-resolve).
|
|
416
|
+
const plan: ExecutionPlan = {
|
|
417
|
+
transport: "control-socket",
|
|
418
|
+
action: "send",
|
|
419
|
+
targetGardenId: gardenId,
|
|
420
|
+
socketPath,
|
|
421
|
+
mode: ctx.mode,
|
|
422
|
+
wantsReply: ctx.wantsReply,
|
|
423
|
+
message: input.message,
|
|
424
|
+
};
|
|
425
|
+
retainLock = true;
|
|
426
|
+
return { kind: "execute", receipt, plan, lock };
|
|
427
|
+
} catch (err) {
|
|
428
|
+
if (!retainLock) {
|
|
429
|
+
try {
|
|
430
|
+
releaseLock(lock);
|
|
431
|
+
} catch {
|
|
432
|
+
// best-effort: a release failure must NOT mask the original throw.
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
throw err;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The canonical control-socket path for a target — re-exported so a production
|
|
440
|
+
* resolveTarget/wrapper plants the SAME path the decider/plan use (no drift). */
|
|
441
|
+
export { controlSocketPath };
|