@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,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-peers-render — the PURE render/payload layer for the MCP `entwurf_peers`
|
|
3
|
+
* surface (0.11 Stage 0 step 4, slice 4c). The MCP handler stays thin: it does IO
|
|
4
|
+
* (readdir the meta-store + probe sockets via `listEntwurfFacts`) and then calls
|
|
5
|
+
* THIS to shape the text + JSON. No IO here, so the gate drives it without a
|
|
6
|
+
* filesystem and the SAME facts can feed pi-native / doctor / v2 dispatch later
|
|
7
|
+
* (a handler that did its own brain work would deny them that reuse).
|
|
8
|
+
*
|
|
9
|
+
* Three hard rules carried from the frozen contract (동결결정 10):
|
|
10
|
+
* - FACTS ONLY, NO VERB-ROUTING. Neither the payload nor the text may carry a
|
|
11
|
+
* `sendable`/`resumable`/`dispatch`/`action`/`transport`/`mailboxDeliverable`
|
|
12
|
+
* field or word. Whether a target is sent-to or resumed is computed at call
|
|
13
|
+
* time by the entwurf_v2 dispatch table from `liveness` — baking it into the
|
|
14
|
+
* listing is exactly what makes `entwurf_peers` lie. The gate scans both the
|
|
15
|
+
* JSON keys AND the text for the forbidden words (a section title like
|
|
16
|
+
* "resumable peers" leaks routing that a key scan would miss).
|
|
17
|
+
* - THREE SECTIONS, NEVER MERGED. `peers` (citizens, 4-value liveness) and
|
|
18
|
+
* `socketOnly` (record-less sockets, 3-value liveness) are DISTINCT subjects
|
|
19
|
+
* (slice 2's two-array split); `diagnostics` is a third. Merging them into one
|
|
20
|
+
* array collapses the subject separation at the surface.
|
|
21
|
+
* - LEGACY `sessions` IS A PROJECTION OF FACTS, not a second scan. We do NOT
|
|
22
|
+
* re-run the old `getLiveSessions` (a separate live-socket scan would bypass
|
|
23
|
+
* the provider's quarantine — a non-pi citizen colliding with a socket, which
|
|
24
|
+
* `listEntwurfFacts` removes from BOTH normal arrays, could reappear in
|
|
25
|
+
* `sessions`). `sessions` is derived from the SAME facts: alive pi citizens +
|
|
26
|
+
* alive socket-only entries. Its socketPath is built by `controlSocketPath`
|
|
27
|
+
* (the SSOT helper), never re-concatenated, so the filename↔gardenId
|
|
28
|
+
* correlation authority (동결결정3) cannot drift between scan and render.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { EntwurfDiagnostic, EntwurfFactsResult } from "./entwurf-fact-provider.ts";
|
|
32
|
+
import type { PeerFact, SocketOnlyFact } from "./entwurf-facts.ts";
|
|
33
|
+
import { controlSocketPath } from "./socket-discovery.ts";
|
|
34
|
+
|
|
35
|
+
/** The legacy-compatible active-session shape (sessionId + socketPath), retained
|
|
36
|
+
* for old consumers. A PROJECTION of the facts (alive only), not a second scan. */
|
|
37
|
+
export interface LegacySession {
|
|
38
|
+
sessionId: string;
|
|
39
|
+
socketPath: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The full `entwurf_peers` JSON payload. `sessions` is the legacy projection;
|
|
43
|
+
* `peers`/`socketOnly`/`diagnostics` are the additive facts surface. NO
|
|
44
|
+
* verb-routing field anywhere (the gate enforces this by deep key scan). */
|
|
45
|
+
export interface EntwurfPeersPayload {
|
|
46
|
+
controlDir: string;
|
|
47
|
+
count: number;
|
|
48
|
+
sessions: LegacySession[];
|
|
49
|
+
peers: PeerFact[];
|
|
50
|
+
socketOnly: SocketOnlyFact[];
|
|
51
|
+
diagnostics: EntwurfDiagnostic[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface EntwurfPeersRender {
|
|
55
|
+
text: string;
|
|
56
|
+
payload: EntwurfPeersPayload;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Derive the legacy `sessions` projection from the facts: an active session is an
|
|
61
|
+
* alive pi citizen OR an alive record-less socket. `peers` and `socketOnly` are
|
|
62
|
+
* gid-disjoint (resolveFactList guarantees a gid is in one or the other, never
|
|
63
|
+
* both), so the concatenation needs no dedup. socketPath via `controlSocketPath`
|
|
64
|
+
* (SSOT). Sorted by sessionId for determinism.
|
|
65
|
+
*/
|
|
66
|
+
function deriveSessions(peers: PeerFact[], socketOnly: SocketOnlyFact[], controlDir: string): LegacySession[] {
|
|
67
|
+
const sessions: LegacySession[] = [];
|
|
68
|
+
for (const p of peers) {
|
|
69
|
+
if (p.backend === "pi" && p.liveness === "alive") {
|
|
70
|
+
sessions.push({ sessionId: p.gardenId, socketPath: controlSocketPath(p.gardenId, controlDir) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const s of socketOnly) {
|
|
74
|
+
if (s.liveness === "alive") {
|
|
75
|
+
sessions.push({ sessionId: s.gardenId, socketPath: controlSocketPath(s.gardenId, controlDir) });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
sessions.sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
|
|
79
|
+
return sessions;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderPeerLine(p: PeerFact): string {
|
|
83
|
+
const model = p.model ?? "(unknown)";
|
|
84
|
+
const entwurf = p.isEntwurf ? " entwurf" : "";
|
|
85
|
+
return `- ${p.gardenId} backend=${p.backend} liveness=${p.liveness} cwd=${p.cwd} model=${model}${entwurf}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function renderSocketOnlyLine(s: SocketOnlyFact): string {
|
|
89
|
+
// Null enrich is "(not enriched)" — NOT "(unknown)", which would read as
|
|
90
|
+
// identity-unknown rather than not-yet-fetched / not available for this socket.
|
|
91
|
+
const cwd = s.cwd ?? "(not enriched)";
|
|
92
|
+
const model = s.model ?? "(not enriched)";
|
|
93
|
+
const idle = s.idle === null ? "" : ` idle=${s.idle ? "yes" : "no"}`;
|
|
94
|
+
const infoError = s.infoError === null ? "" : ` infoError=${s.infoError}`;
|
|
95
|
+
return `- ${s.gardenId} liveness=${s.liveness} cwd=${cwd} model=${model}${idle}${infoError}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function renderDiagnosticLine(d: EntwurfDiagnostic): string {
|
|
99
|
+
switch (d.kind) {
|
|
100
|
+
case "meta-record-read-error":
|
|
101
|
+
return `- meta-record-read-error ${d.filename}: ${d.message}`;
|
|
102
|
+
case "garden-id-socket-conflict":
|
|
103
|
+
return `- garden-id-socket-conflict ${d.gardenId} (backend=${d.backend}): ${d.message}`;
|
|
104
|
+
case "socket-symlink-rejected":
|
|
105
|
+
return `- socket-symlink-rejected ${d.gardenId}: ${d.message}`;
|
|
106
|
+
case "malformed-socket-name":
|
|
107
|
+
return `- malformed-socket-name ${d.name}: ${d.message}`;
|
|
108
|
+
case "socket-dir-read-error":
|
|
109
|
+
return `- socket-dir-read-error: ${d.message}`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function compactLines(lines: string[], max: number = 32): string[] {
|
|
114
|
+
if (lines.length <= max) return lines;
|
|
115
|
+
const omitted = lines.length - max;
|
|
116
|
+
return [` … (${omitted} older entries omitted; showing latest ${max})`, ...lines.slice(-max)];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function section(title: string, lines: string[], opts: { compact?: boolean } = {}): string {
|
|
120
|
+
// Empty sections render "(none)" — hiding them would erase the honesty the
|
|
121
|
+
// listing exists to provide (especially diagnostics: "(none)" is a trust
|
|
122
|
+
// signal, and an `unsupported` peer must never be silently dropped).
|
|
123
|
+
const rendered = opts.compact ? compactLines(lines) : lines;
|
|
124
|
+
return rendered.length > 0 ? `${title}\n${rendered.join("\n")}` : `${title}\n (none)`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Shape the facts into the `entwurf_peers` text + JSON. Pure over its inputs.
|
|
129
|
+
* `controlDir` is the same directory the socket scan used — passing it here (not
|
|
130
|
+
* re-deriving) keeps the socketPath SSOT.
|
|
131
|
+
*/
|
|
132
|
+
export function renderEntwurfPeers(result: EntwurfFactsResult, controlDir: string): EntwurfPeersRender {
|
|
133
|
+
const { peers, socketOnly } = result.facts;
|
|
134
|
+
const { diagnostics } = result;
|
|
135
|
+
const sessions = deriveSessions(peers, socketOnly, controlDir);
|
|
136
|
+
|
|
137
|
+
const text = [
|
|
138
|
+
section("Garden citizens (meta-record):", peers.map(renderPeerLine), { compact: true }),
|
|
139
|
+
"",
|
|
140
|
+
section("Socket-only control sockets (no meta-record):", socketOnly.map(renderSocketOnlyLine), { compact: true }),
|
|
141
|
+
"",
|
|
142
|
+
section("Diagnostics:", diagnostics.map(renderDiagnosticLine)),
|
|
143
|
+
].join("\n");
|
|
144
|
+
|
|
145
|
+
const payload: EntwurfPeersPayload = {
|
|
146
|
+
controlDir,
|
|
147
|
+
count: sessions.length,
|
|
148
|
+
sessions,
|
|
149
|
+
peers,
|
|
150
|
+
socketOnly,
|
|
151
|
+
diagnostics,
|
|
152
|
+
};
|
|
153
|
+
return { text, payload };
|
|
154
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-preflight — 0.11 Stage 0 (2): the SINGLE trust/launch decision owner.
|
|
3
|
+
*
|
|
4
|
+
* The controlled-launch surface, the global `project_trust` handler, and any
|
|
5
|
+
* MCP fact tool ALL consume this module's outcome — nobody else re-derives a
|
|
6
|
+
* prefix, re-reads `trust.json`, or re-probes trust inputs. pi's raw trust
|
|
7
|
+
* semantics are followed by importing pi's PUBLIC root exports directly (frozen
|
|
8
|
+
* decision 9, 재구현 금지): `ProjectTrustStore` (the canonical `trust.json`
|
|
9
|
+
* reader, which itself canonicalizes the cwd and takes a `proper-lockfile` on
|
|
10
|
+
* every read) and `hasTrustRequiringProjectResources` (the trust-input probe).
|
|
11
|
+
* We never copy pi's trust detail — if pi changes it, this import tracks it.
|
|
12
|
+
*
|
|
13
|
+
* The returned `PreflightOutcome` is deliberately RICH, not just {kind,reason}:
|
|
14
|
+
* a fact tool must explain *why* a cwd is approved and *what* it may load
|
|
15
|
+
* without re-running the probe, and an error/handler must name the matched root
|
|
16
|
+
* or the trust-store value. Thin outcomes would push callers to recompute, which
|
|
17
|
+
* is exactly the re-derivation this module exists to prevent.
|
|
18
|
+
*
|
|
19
|
+
* trust ≠ discovery: this decision touches the store for a SINGLE launch-time
|
|
20
|
+
* cwd only. `peers`/`who-can` discovery does not call here (frozen decision 4).
|
|
21
|
+
*
|
|
22
|
+
* Precedence (frozen decision 8) — saved distrust is stronger than a prefix
|
|
23
|
+
* allow; a prefix only promotes the UNDECIDED (null) case; no-trust-inputs is
|
|
24
|
+
* trusted but needs no launch arg; everything else is fail-fast:
|
|
25
|
+
*
|
|
26
|
+
* saved === false → deny (explicit distrust; store wins)
|
|
27
|
+
* saved === true → approve (saved trust → internal --approve)
|
|
28
|
+
* null + prefix match → approve (operator prefix promotes null→yes)
|
|
29
|
+
* null + no trust inputs → trusted-no-arg (no trust-gated input — pi 0.79.x
|
|
30
|
+
* excludes AGENTS.md/CLAUDE.md, so
|
|
31
|
+
* context files may still be loaded)
|
|
32
|
+
* else (null + inputs) → fail-fast (unknown/untrusted controlled launch)
|
|
33
|
+
*
|
|
34
|
+
* Injection (frozen decision 4): `agentDir` defaults to `getAgentDir()` but is
|
|
35
|
+
* overridable so tests point `ProjectTrustStore` at a temp dir (or set
|
|
36
|
+
* `PI_CODING_AGENT_DIR`, same isolation as 0.10.0) and never read or dirty the
|
|
37
|
+
* operator's real `~/.pi/agent/trust.json`. `prefixRoots` is an OPERATOR-policy
|
|
38
|
+
* input with NO package default (frozen decision 7): a public package must not
|
|
39
|
+
* hardcode a broad auto-approve, so an empty roots list means "no prefix
|
|
40
|
+
* promotion" — the caller injects the operator's roots (e.g. `~/repos/gh`).
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { realpathSync } from "node:fs";
|
|
44
|
+
import { homedir } from "node:os";
|
|
45
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
46
|
+
import {
|
|
47
|
+
getAgentDir,
|
|
48
|
+
hasTrustRequiringProjectResources,
|
|
49
|
+
type ProjectTrustDecision,
|
|
50
|
+
ProjectTrustStore,
|
|
51
|
+
} from "@earendil-works/pi-coding-agent";
|
|
52
|
+
|
|
53
|
+
export interface PreflightInput {
|
|
54
|
+
/** The single launch-time cwd whose trust is being decided. */
|
|
55
|
+
cwd: string;
|
|
56
|
+
/**
|
|
57
|
+
* pi agent dir holding `trust.json`. Defaults to `getAgentDir()` (which
|
|
58
|
+
* honors `PI_CODING_AGENT_DIR`). Override to a temp dir for isolated tests.
|
|
59
|
+
*/
|
|
60
|
+
agentDir?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Operator-policy auto-approve roots. NO package default (frozen decision 7).
|
|
63
|
+
* Roots may be `~`-relative (`~/repos/gh`) or relative; they are normalized
|
|
64
|
+
* the same way as the cwd. A cwd at or under one of these (canonical path +
|
|
65
|
+
* separator boundary) promotes an UNDECIDED trust into approve. Empty ⇒ no
|
|
66
|
+
* prefix promotion.
|
|
67
|
+
*/
|
|
68
|
+
prefixRoots?: readonly string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Fields present on every outcome — the fact/handler/error evidence. */
|
|
72
|
+
interface PreflightEvidence {
|
|
73
|
+
/**
|
|
74
|
+
* Args the launcher must add. `["--approve"]` when approving, `[]` otherwise.
|
|
75
|
+
* Frozen decision 6: never `--no-approve` (that is a silent degraded launch).
|
|
76
|
+
*/
|
|
77
|
+
readonly launchArgs: readonly string[];
|
|
78
|
+
/**
|
|
79
|
+
* The trust decision that applies to this cwd: `ProjectTrustStore.getEntry(cwd)
|
|
80
|
+
* ?.decision ?? null`. Identical value to the old `.get(cwd)`, but sourced from
|
|
81
|
+
* `getEntry` so the deciding entry's path/inheritance survive (below).
|
|
82
|
+
*/
|
|
83
|
+
readonly trustStoreDecision: ProjectTrustDecision;
|
|
84
|
+
/**
|
|
85
|
+
* The pi-canonical path of the trust-store entry that decided this cwd, or
|
|
86
|
+
* undefined when neither the cwd nor any ancestor carries a decision
|
|
87
|
+
* (trustStoreDecision === null). On 0.79.x `getEntry` walks up to the nearest
|
|
88
|
+
* ancestor with an explicit decision and returns ITS path — so this is the
|
|
89
|
+
* source a deny message must name (N3b `inheritedFrom`). pi-canonical =
|
|
90
|
+
* `canonicalizePath(resolvePath())`, the same realpath axis as `canonicalCwd`.
|
|
91
|
+
*/
|
|
92
|
+
readonly trustStoreEntryPath?: string;
|
|
93
|
+
/**
|
|
94
|
+
* True when the deciding entry is an ANCESTOR, not the cwd itself — the
|
|
95
|
+
* decision is INHERITED (0.79.x nearest-ancestor walk-up). False for a direct
|
|
96
|
+
* decision on the cwd or for no decision at all. The inherited-false case is
|
|
97
|
+
* the one that silently blocks a human's active prompt (Trust 2층): the
|
|
98
|
+
* handler defers `undecided`, then the store's inherited false wins anyway, so
|
|
99
|
+
* only an active prompt → `{trusted:"yes", remember:true}` escapes it.
|
|
100
|
+
*/
|
|
101
|
+
readonly trustStoreInherited: boolean;
|
|
102
|
+
/** `hasTrustRequiringProjectResources(cwd)` — computed even when a prefix already won. */
|
|
103
|
+
readonly hasTrustInputs: boolean;
|
|
104
|
+
/** The canonical operator root that matched, if the decision is prefix-driven. */
|
|
105
|
+
readonly matchedPrefixRoot?: string;
|
|
106
|
+
/** The cwd after tilde-expand → resolve → realpath (raw-resolved fallback). */
|
|
107
|
+
readonly canonicalCwd: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Controlled-launch decision. The launcher maps:
|
|
112
|
+
* approve → spawn child with `launchArgs` (`--approve`; load project files)
|
|
113
|
+
* trusted-no-arg → spawn child, no `--approve` needed (no project files)
|
|
114
|
+
* deny → refuse to spawn (throw); never a silent `--no-approve`
|
|
115
|
+
*/
|
|
116
|
+
export type PreflightOutcome =
|
|
117
|
+
| (PreflightEvidence & { readonly kind: "approve"; readonly reason: "saved-true" | "prefix-match" })
|
|
118
|
+
| (PreflightEvidence & { readonly kind: "trusted-no-arg"; readonly reason: "no-trust-inputs" })
|
|
119
|
+
| (PreflightEvidence & { readonly kind: "deny"; readonly reason: "saved-false" | "fail-fast" });
|
|
120
|
+
|
|
121
|
+
/** A deny outcome — the only shape `formatPreflightDenial` accepts. */
|
|
122
|
+
export type PreflightDenial = Extract<PreflightOutcome, { kind: "deny" }>;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render the human-facing reason a controlled launch was refused (N3b). This is
|
|
126
|
+
* a PURE formatter over a deny outcome — it does NOT touch a launcher, a socket,
|
|
127
|
+
* or pi; wiring it into the controlled-launch surface is bucket B (step 5), not
|
|
128
|
+
* here. The launcher/handler/error layers all call this so the refusal text is
|
|
129
|
+
* identical everywhere and always sourced from F5a evidence.
|
|
130
|
+
*
|
|
131
|
+
* The inherited-false branch is the one that matters: an operator distrust on an
|
|
132
|
+
* ANCESTOR (e.g. `~/repos/gh`) silently denies a child cwd, and an agent CANNOT
|
|
133
|
+
* lift it — that is an intended security property (N3a: a controlled launch
|
|
134
|
+
* short-circuits on `trustOverride` and never reaches the human-only active
|
|
135
|
+
* prompt). So the message must (1) name the inherited source (`inheritedFrom`)
|
|
136
|
+
* and (2) give the only real remedy: open an interactive pi AT the cwd and
|
|
137
|
+
* approve, which writes a direct child trust that beats the inherited decision
|
|
138
|
+
* (the "escape direction" proven in check-pi-preflight #13b).
|
|
139
|
+
*/
|
|
140
|
+
export function formatPreflightDenial(outcome: PreflightDenial): string {
|
|
141
|
+
const cwd = outcome.canonicalCwd;
|
|
142
|
+
const openHere = `open an interactive pi at ${cwd} and approve when prompted`;
|
|
143
|
+
if (outcome.reason === "saved-false") {
|
|
144
|
+
if (outcome.trustStoreInherited && outcome.trustStoreEntryPath !== undefined) {
|
|
145
|
+
return (
|
|
146
|
+
`Controlled launch refused: ${cwd} is distrusted by inheritance from ${outcome.trustStoreEntryPath} ` +
|
|
147
|
+
`(an ancestor carries a saved "no"). An agent cannot self-promote trust — this is an intended ` +
|
|
148
|
+
`security property. To trust THIS cwd only, ${openHere}; that writes a direct decision for ${cwd} ` +
|
|
149
|
+
`which overrides the inherited one.`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return `Controlled launch refused: ${cwd} is explicitly distrusted (a saved "no" on this directory). To change it, ${openHere}.`;
|
|
153
|
+
}
|
|
154
|
+
// fail-fast: undecided + trust inputs + no operator prefix root.
|
|
155
|
+
return (
|
|
156
|
+
`Controlled launch refused: ${cwd} is untrusted — it has trust inputs but no saved decision and no ` +
|
|
157
|
+
`operator prefix root. Refusing a silent degraded launch. Either add ${cwd} under an operator prefix ` +
|
|
158
|
+
`root, or ${openHere}.`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Normalize a path the way pi resolves one before the trust store sees it:
|
|
164
|
+
* expand a leading `~`, make it absolute (`path.resolve`), then `realpathSync`;
|
|
165
|
+
* on a resolve failure fall back to the RESOLVED absolute path (not the raw
|
|
166
|
+
* input), so a not-yet-existing root still compares on an absolute basis.
|
|
167
|
+
*/
|
|
168
|
+
function normalizePath(p: string): string {
|
|
169
|
+
let expanded = p;
|
|
170
|
+
if (p === "~") {
|
|
171
|
+
expanded = homedir();
|
|
172
|
+
} else if (p.startsWith("~/")) {
|
|
173
|
+
expanded = join(homedir(), p.slice(2));
|
|
174
|
+
}
|
|
175
|
+
const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
176
|
+
try {
|
|
177
|
+
return realpathSync(abs);
|
|
178
|
+
} catch {
|
|
179
|
+
return abs;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Return the canonical operator root that contains `canonicalCwd`, by canonical
|
|
185
|
+
* path + separator boundary (frozen decision 7). `/org` matches `/org/a` but NOT
|
|
186
|
+
* `/org2` — never a bare `startsWith`. Roots are normalized the same as the cwd.
|
|
187
|
+
*/
|
|
188
|
+
function matchedPrefixRoot(canonicalCwd: string, roots: readonly string[]): string | undefined {
|
|
189
|
+
for (const root of roots) {
|
|
190
|
+
const r = normalizePath(root);
|
|
191
|
+
if (canonicalCwd === r || canonicalCwd.startsWith(r + sep)) {
|
|
192
|
+
return r;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Decide trust for a single controlled-launch cwd. See module header. */
|
|
199
|
+
export function preflight(input: PreflightInput): PreflightOutcome {
|
|
200
|
+
const agentDir = input.agentDir ?? getAgentDir();
|
|
201
|
+
const prefixRoots = input.prefixRoots ?? [];
|
|
202
|
+
|
|
203
|
+
const canonicalCwd = normalizePath(input.cwd);
|
|
204
|
+
const store = new ProjectTrustStore(agentDir);
|
|
205
|
+
// getEntry, not get: get() throws away which path decided. getEntry returns
|
|
206
|
+
// `{ path, decision } | null` — the nearest ancestor (or the cwd itself)
|
|
207
|
+
// carrying an explicit decision. We recover the same decision value AND the
|
|
208
|
+
// deciding path, so the fact/handler/error layers can name an inherited
|
|
209
|
+
// source without re-walking the store. entry.path is pi-canonical, the same
|
|
210
|
+
// realpath axis as `canonicalCwd`, so an entry on the cwd ITSELF compares
|
|
211
|
+
// equal (= direct) and an ancestor compares unequal (= inherited).
|
|
212
|
+
const entry = store.getEntry(input.cwd);
|
|
213
|
+
const trustStoreDecision: ProjectTrustDecision = entry?.decision ?? null;
|
|
214
|
+
const trustStoreInherited = entry !== null && entry.path !== canonicalCwd;
|
|
215
|
+
// Computed unconditionally: a fact tool must report what a prefix-approved
|
|
216
|
+
// cwd could load, so the probe runs even when a prefix already decides.
|
|
217
|
+
const hasTrustInputs = hasTrustRequiringProjectResources(input.cwd);
|
|
218
|
+
const matched = matchedPrefixRoot(canonicalCwd, prefixRoots);
|
|
219
|
+
|
|
220
|
+
const evidence: PreflightEvidence = {
|
|
221
|
+
launchArgs: [],
|
|
222
|
+
trustStoreDecision,
|
|
223
|
+
trustStoreInherited,
|
|
224
|
+
hasTrustInputs,
|
|
225
|
+
canonicalCwd,
|
|
226
|
+
...(entry !== null ? { trustStoreEntryPath: entry.path } : {}),
|
|
227
|
+
...(matched !== undefined ? { matchedPrefixRoot: matched } : {}),
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Explicit distrust wins over everything, including a prefix match.
|
|
231
|
+
if (trustStoreDecision === false) {
|
|
232
|
+
return { ...evidence, kind: "deny", reason: "saved-false" };
|
|
233
|
+
}
|
|
234
|
+
if (trustStoreDecision === true) {
|
|
235
|
+
return { ...evidence, kind: "approve", reason: "saved-true", launchArgs: ["--approve"] };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// trustStoreDecision === null (undecided): a prefix promotes it; otherwise the
|
|
239
|
+
// absence of trust inputs makes it trusted-but-no-arg; otherwise fail-fast.
|
|
240
|
+
if (matched !== undefined) {
|
|
241
|
+
return { ...evidence, kind: "approve", reason: "prefix-match", launchArgs: ["--approve"] };
|
|
242
|
+
}
|
|
243
|
+
if (!hasTrustInputs) {
|
|
244
|
+
return { ...evidence, kind: "trusted-no-arg", reason: "no-trust-inputs" };
|
|
245
|
+
}
|
|
246
|
+
return { ...evidence, kind: "deny", reason: "fail-fast" };
|
|
247
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-resume-args — the SINGLE source of truth for the `pi` argv a resume spawn is
|
|
3
|
+
* launched with (0.11 Stage 0 step 5c-3b). Two variants share this builder so the legacy
|
|
4
|
+
* async-entwurf worker and the v2 spawn-bg resident citizen can NEVER drift in their launch
|
|
5
|
+
* shape — the one place the `--no-extensions` / `--entwurf-control` decision lives.
|
|
6
|
+
*
|
|
7
|
+
* The load-bearing difference (A1):
|
|
8
|
+
* - legacy — a one-shot async worker. `pi -p <prompt>` runs the resume turn and EXITS;
|
|
9
|
+
* `proc.on('close')` then delivers the followUp. It is launched `--no-extensions` AND
|
|
10
|
+
* WITHOUT `--entwurf-control` precisely BECAUSE a control-socket server would keep
|
|
11
|
+
* `pi -p` from exiting (entwurf.ts:22 — that keep-alive was a bug for a one-shot worker).
|
|
12
|
+
* - v2-control — a RESIDENT, addressable garden citizen. The resume turn STILL runs via
|
|
13
|
+
* `-p <prompt>` (the prompt-as-turn authority is unchanged), but the child is launched
|
|
14
|
+
* WITH `--entwurf-control` and WITHOUT `--no-extensions`, so the very keep-alive legacy
|
|
15
|
+
* avoided is now the GOAL: the resumed session stands its control socket up and stays
|
|
16
|
+
* live. The 5c-3a watcher's `socket-alive` is exactly that "resumed citizen is up and
|
|
17
|
+
* addressable" observation (→ release the per-gid lock, child lives on); `child-exited`
|
|
18
|
+
* is the early-exit/failure observation. `plan.launchArgs` (`--approve` or empty, from
|
|
19
|
+
* the decider's preflight) rides along as pi flags before the prompt.
|
|
20
|
+
*
|
|
21
|
+
* Provider/model identity is the caller's existing authority (readSessionIdentity /
|
|
22
|
+
* getEntwurfExplicitExtensions) — this builder only LAYS OUT argv, it never resolves
|
|
23
|
+
* identity. `explicitExtensionArgs` is preserved verbatim in BOTH variants: a recorded
|
|
24
|
+
* `provider=entwurf` resume needs the bridge re-injected to resolve the provider, and
|
|
25
|
+
* dropping it when `--no-extensions` is removed would re-introduce the "Unknown provider"
|
|
26
|
+
* footgun (#29). (A future slice may dedup against settings-loaded extensions; not here.)
|
|
27
|
+
*
|
|
28
|
+
* This module is import-free on purpose: the legacy launcher (entwurf-async.ts) is
|
|
29
|
+
* root-typechecked and imports it as `./entwurf-resume-args.js`, while the v2 adapter and
|
|
30
|
+
* the gate import the same source — a self-contained string builder keeps both configs happy.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export type ResumeArgsVariant = "legacy" | "v2-control";
|
|
34
|
+
|
|
35
|
+
export interface ResumePiArgsInput {
|
|
36
|
+
/** legacy = one-shot worker (`--no-extensions`, no control socket); v2-control =
|
|
37
|
+
* resident citizen (`--entwurf-control`, extensions loaded). */
|
|
38
|
+
variant: ResumeArgsVariant;
|
|
39
|
+
/** The garden id; `pi --session-id <gid>` resumes the existing JSONL AND, under
|
|
40
|
+
* `--entwurf-control`, derives the control socket at ~/.pi/entwurf-control/<gid>.sock. */
|
|
41
|
+
sessionId: string;
|
|
42
|
+
/** The explicit `--extension …` re-injection (ACP bridge / provider resolution).
|
|
43
|
+
* Preserved verbatim in BOTH variants — load-bearing for a entwurf resume. */
|
|
44
|
+
explicitExtensionArgs: readonly string[];
|
|
45
|
+
/** Recorded provider (may be null/undefined — then no `--provider` flag is emitted). */
|
|
46
|
+
provider: string | null | undefined;
|
|
47
|
+
/** The resolved launch model (caller applies `modelOverride ?? resumeModel`). */
|
|
48
|
+
model: string;
|
|
49
|
+
/** The resume prompt — the final positional, run as the model turn under `-p`. */
|
|
50
|
+
prompt: string;
|
|
51
|
+
/** v2-control ONLY: the decider's `plan.launchArgs` (`["--approve"]` or `[]`). Ignored
|
|
52
|
+
* for legacy (the legacy path computes its own preflight elsewhere). */
|
|
53
|
+
launchArgs?: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the `pi` argv for a resume spawn. The SHARED prefix is `--mode json -p` (headless
|
|
58
|
+
* JSON child, prompt-as-turn); the variant then chooses the extension/socket posture; the
|
|
59
|
+
* SHARED suffix is `[…ext args] --session-id <gid> [--provider <p>] --model <m> <prompt>`.
|
|
60
|
+
*
|
|
61
|
+
* Invariants the gate pins:
|
|
62
|
+
* - legacy carries `--no-extensions` and NO `--entwurf-control`.
|
|
63
|
+
* - v2-control carries `--entwurf-control` and NO `--no-extensions`, plus `-p` + prompt.
|
|
64
|
+
* - `explicitExtensionArgs` appears exactly once in both.
|
|
65
|
+
* - `launchArgs` is included for v2-control (before the suffix) and ignored for legacy.
|
|
66
|
+
* - provider/model/prompt identity is laid out identically in both.
|
|
67
|
+
*/
|
|
68
|
+
export function buildResumePiArgs(input: ResumePiArgsInput): string[] {
|
|
69
|
+
const args: string[] = ["--mode", "json", "-p"];
|
|
70
|
+
|
|
71
|
+
if (input.variant === "legacy") {
|
|
72
|
+
// One-shot worker: no extensions, no control socket (so `pi -p` can exit).
|
|
73
|
+
args.push("--no-extensions");
|
|
74
|
+
} else {
|
|
75
|
+
// Resident citizen: stand the control socket up (A1) and keep extensions loaded.
|
|
76
|
+
// The keep-alive legacy avoided is the goal here. `--approve`/launchArgs ride along.
|
|
77
|
+
args.push("--entwurf-control");
|
|
78
|
+
args.push(...(input.launchArgs ?? []));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Shared suffix — identical identity layout in both variants.
|
|
82
|
+
args.push(...input.explicitExtensionArgs);
|
|
83
|
+
args.push("--session-id", input.sessionId);
|
|
84
|
+
if (input.provider) args.push("--provider", input.provider);
|
|
85
|
+
args.push("--model", input.model, input.prompt);
|
|
86
|
+
|
|
87
|
+
return args;
|
|
88
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-self-address — the PURE self-addressability predicate (SE-1/SE-2 slice 1).
|
|
3
|
+
*
|
|
4
|
+
* "Can a reply to THIS session actually land where its model will see it?"
|
|
5
|
+
*
|
|
6
|
+
* Today both the MCP bridge (buildStrictPiSenderEnvelope /
|
|
7
|
+
* buildTrustedMetaSenderEnvelope / entwurf_self) and pi-native answer that from env
|
|
8
|
+
* presence alone and hardcode `replyable: true`: a pi session with no
|
|
9
|
+
* --entwurf-control socket, or a meta citizen whose owner has exited / whose
|
|
10
|
+
* idle-watch was never armed, all claim replyable while delivery silently fails
|
|
11
|
+
* (SE-1: "all layers say yes, only delivery says no" = a Crash-Don't-Warn
|
|
12
|
+
* violation). This module is the single truth-table both surfaces compute from,
|
|
13
|
+
* with every fact INJECTED (no IO) so the gate can pin each row.
|
|
14
|
+
*
|
|
15
|
+
* Axes by origin:
|
|
16
|
+
* - pi-session: replyable ⟺ a live control socket exists at the canonical path.
|
|
17
|
+
* socketState distinguishes alive / expected (path computable but no
|
|
18
|
+
* live socket) / none (no session id to even compute a path).
|
|
19
|
+
* - meta-session (self-fetch backend, e.g. claude-code): replyable ⟺ the 3-conjunct
|
|
20
|
+
* deliverability — recordBacked AND ownerAlive AND watchArmed (Q4-1
|
|
21
|
+
* lock: self-fetch(static) ∧ ownerPid-startKey-alive(runtime) ∧
|
|
22
|
+
* watch-armed(runtime)). The watchArmed FACT is sourced from the
|
|
23
|
+
* slice-2 meta-receiver presence marker; until that wiring lands the
|
|
24
|
+
* caller passes watchArmed=false, so meta-self is intentionally
|
|
25
|
+
* FAIL-CLOSED until slice 2. Slices 1 and 2 close in the SAME release
|
|
26
|
+
* block, so no intermediate "meta self all-false" state is ever pushed.
|
|
27
|
+
* - external-mcp: never replyable — no authoritative reply address.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { computeMetaReceiverActive } from "./entwurf-deliverability.ts";
|
|
31
|
+
|
|
32
|
+
export type SelfOrigin = "pi-session" | "meta-session" | "external-mcp";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* pi control-socket reachability for a reply addressed back to this session.
|
|
36
|
+
*
|
|
37
|
+
* NOTE on "alive": at this slice the caller establishes it with an existsSync on the
|
|
38
|
+
* canonical socket path — it means "the canonical control socket FILE is present",
|
|
39
|
+
* not "a listener is accepting connections". A stale socket file with a dead listener
|
|
40
|
+
* still reads as `alive` here. A real connect/probeSocketLiveness check is deliberate
|
|
41
|
+
* future hardening (a separate slice), kept out per the slice-1 existsSync-level
|
|
42
|
+
* agreement to avoid over-reach. Do not read `alive` as proven liveness.
|
|
43
|
+
*/
|
|
44
|
+
export type SocketState = "alive" | "expected" | "none";
|
|
45
|
+
|
|
46
|
+
export interface SelfAddressabilityFacts {
|
|
47
|
+
origin: SelfOrigin;
|
|
48
|
+
/** pi-session: a live control socket exists at the canonical path (existsSync today). */
|
|
49
|
+
socketAlive?: boolean;
|
|
50
|
+
/** pi-session: a session id is present so the canonical socket path is computable. */
|
|
51
|
+
socketPathComputable?: boolean;
|
|
52
|
+
/** meta-session: the sender marker's identity is backed by a live meta-record. */
|
|
53
|
+
recordBacked?: boolean;
|
|
54
|
+
/** meta-session: the marker's owner pid is still the same process (start-key match). */
|
|
55
|
+
ownerAlive?: boolean;
|
|
56
|
+
/** meta-session: the idle-wake watch is armed (slice-2 presence marker; fail-closed until then). */
|
|
57
|
+
watchArmed?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SelfAddressabilityResult {
|
|
61
|
+
replyable: boolean;
|
|
62
|
+
socketState: SocketState;
|
|
63
|
+
reason: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Decide whether a reply to this session is actually deliverable, from injected
|
|
68
|
+
* facts only. Pure: no env reads, no fs, no probing — the caller gathers the facts
|
|
69
|
+
* (existsSync the socket, validate the marker against its record, read the presence
|
|
70
|
+
* marker) and hands them in, so every row is gate-pinnable.
|
|
71
|
+
*/
|
|
72
|
+
export function computeSelfAddressability(facts: SelfAddressabilityFacts): SelfAddressabilityResult {
|
|
73
|
+
switch (facts.origin) {
|
|
74
|
+
case "pi-session": {
|
|
75
|
+
if (facts.socketAlive === true) {
|
|
76
|
+
return { replyable: true, socketState: "alive", reason: "pi control socket alive at canonical path" };
|
|
77
|
+
}
|
|
78
|
+
if (facts.socketPathComputable === true) {
|
|
79
|
+
return {
|
|
80
|
+
replyable: false,
|
|
81
|
+
socketState: "expected",
|
|
82
|
+
reason: "pi control socket not found at expected path (session not run with --entwurf-control)",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
replyable: false,
|
|
87
|
+
socketState: "none",
|
|
88
|
+
reason: "no pi session id — cannot compute a control socket path",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
case "meta-session": {
|
|
92
|
+
// Share the active-receiver atom with the deliverability predicate (one
|
|
93
|
+
// source of truth for "record backed AND owner alive AND watch armed").
|
|
94
|
+
const recv = computeMetaReceiverActive({
|
|
95
|
+
recordBacked: facts.recordBacked,
|
|
96
|
+
ownerAlive: facts.ownerAlive,
|
|
97
|
+
watchArmed: facts.watchArmed,
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
replyable: recv.active,
|
|
101
|
+
socketState: "none",
|
|
102
|
+
reason: recv.active ? `meta receiver active (${recv.reason})` : `meta receiver inactive — ${recv.reason}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
case "external-mcp":
|
|
106
|
+
return {
|
|
107
|
+
replyable: false,
|
|
108
|
+
socketState: "none",
|
|
109
|
+
reason: "external MCP host has no authoritative reply address",
|
|
110
|
+
};
|
|
111
|
+
default: {
|
|
112
|
+
// exhaustiveness — an unknown origin is a wiring bug; fail-closed.
|
|
113
|
+
const never: never = facts.origin;
|
|
114
|
+
return { replyable: false, socketState: "none", reason: `unknown origin: ${String(never)}` };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|