@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.
Files changed (183) hide show
  1. package/AGENTS.md +240 -0
  2. package/BASELINE.md +227 -0
  3. package/CHANGELOG.md +1210 -0
  4. package/CONTRIBUTING.md +63 -0
  5. package/DELIVERY.md +209 -0
  6. package/LICENSE +21 -0
  7. package/README.md +504 -0
  8. package/VERIFY.md +260 -0
  9. package/demo/README.md +188 -0
  10. package/demo/demo-baseline.sh +156 -0
  11. package/demo/demo.sh +183 -0
  12. package/docs/assets/entwurf-demo.gif +0 -0
  13. package/docs/assets/entwurf-doomemacs.gif +0 -0
  14. package/docs/assets/entwurf-entwurf.gif +0 -0
  15. package/docs/assets/entwurf-hero.jpg +0 -0
  16. package/docs/setup-clean-host.md +305 -0
  17. package/mcp/entwurf-bridge/src/index.ts +513 -0
  18. package/mcp/entwurf-bridge/start.sh +25 -0
  19. package/mcp/entwurf-bridge/test.sh +54 -0
  20. package/mcp/tsconfig.json +29 -0
  21. package/package.json +130 -0
  22. package/pi/entwurf-capabilities.json +9 -0
  23. package/pi/entwurf-targets.json +20 -0
  24. package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
  25. package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
  26. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
  27. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
  28. package/pi/settings.reference.json +43 -0
  29. package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
  30. package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
  31. package/pi-extensions/acp-provider.ts +63 -0
  32. package/pi-extensions/entwurf-control.ts +1692 -0
  33. package/pi-extensions/lib/acp/acp-client.ts +90 -0
  34. package/pi-extensions/lib/acp/augment.ts +238 -0
  35. package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
  36. package/pi-extensions/lib/acp/backend.ts +873 -0
  37. package/pi-extensions/lib/acp/config.ts +552 -0
  38. package/pi-extensions/lib/acp/context.ts +177 -0
  39. package/pi-extensions/lib/acp/engraving.ts +123 -0
  40. package/pi-extensions/lib/acp/event-mapper.ts +339 -0
  41. package/pi-extensions/lib/acp/models.ts +102 -0
  42. package/pi-extensions/lib/acp/overlay.ts +220 -0
  43. package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
  44. package/pi-extensions/lib/acp/session-store.ts +418 -0
  45. package/pi-extensions/lib/acp/tool-surface.ts +184 -0
  46. package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
  47. package/pi-extensions/lib/entwurf-core.ts +2033 -0
  48. package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
  49. package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
  50. package/pi-extensions/lib/entwurf-facts.ts +251 -0
  51. package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
  52. package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
  53. package/pi-extensions/lib/entwurf-preflight.ts +247 -0
  54. package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
  55. package/pi-extensions/lib/entwurf-self-address.ts +117 -0
  56. package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
  57. package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
  58. package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
  59. package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
  60. package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
  61. package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
  62. package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
  63. package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
  64. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
  65. package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
  66. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
  67. package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
  68. package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
  69. package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
  70. package/pi-extensions/lib/meta-session.ts +1951 -0
  71. package/pi-extensions/lib/project-trust-handler.ts +154 -0
  72. package/pi-extensions/lib/session-id.js +57 -0
  73. package/pi-extensions/lib/socket-discovery.ts +346 -0
  74. package/pi-extensions/lib/socket-probe.ts +92 -0
  75. package/pi-extensions/meta-bridge-hook.ts +250 -0
  76. package/pi-extensions/model-lock.ts +235 -0
  77. package/prompts/engraving.md +27 -0
  78. package/protocol.js +31 -0
  79. package/run.sh +2832 -0
  80. package/scripts/check-acp-backend-preflight.ts +130 -0
  81. package/scripts/check-acp-carrier-augment.ts +297 -0
  82. package/scripts/check-acp-config.ts +322 -0
  83. package/scripts/check-acp-event-mapper.ts +253 -0
  84. package/scripts/check-acp-overlay.ts +154 -0
  85. package/scripts/check-acp-prompt-builder.ts +191 -0
  86. package/scripts/check-acp-provider-surface.ts +159 -0
  87. package/scripts/check-acp-sdk-surface.ts +211 -0
  88. package/scripts/check-acp-session-reuse.ts +756 -0
  89. package/scripts/check-acp-session-store.ts +387 -0
  90. package/scripts/check-acp-tool-surface.ts +159 -0
  91. package/scripts/check-entwurf-bridge-boot.ts +199 -0
  92. package/scripts/check-entwurf-capabilities.ts +123 -0
  93. package/scripts/check-entwurf-control-rpc.ts +187 -0
  94. package/scripts/check-entwurf-deliverability.ts +158 -0
  95. package/scripts/check-entwurf-fact-provider.ts +316 -0
  96. package/scripts/check-entwurf-facts.ts +358 -0
  97. package/scripts/check-entwurf-mailbox-guard.ts +264 -0
  98. package/scripts/check-entwurf-peers-surface.ts +235 -0
  99. package/scripts/check-entwurf-resume-args.ts +149 -0
  100. package/scripts/check-entwurf-self-address.ts +209 -0
  101. package/scripts/check-entwurf-session-identity.ts +703 -0
  102. package/scripts/check-entwurf-v2-contract.ts +536 -0
  103. package/scripts/check-entwurf-v2-decider.ts +795 -0
  104. package/scripts/check-entwurf-v2-lock.ts +368 -0
  105. package/scripts/check-entwurf-v2-mailbox.ts +228 -0
  106. package/scripts/check-entwurf-v2-matrix.ts +437 -0
  107. package/scripts/check-entwurf-v2-production.ts +406 -0
  108. package/scripts/check-entwurf-v2-release.ts +245 -0
  109. package/scripts/check-entwurf-v2-runner.ts +452 -0
  110. package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
  111. package/scripts/check-entwurf-v2-send.ts +454 -0
  112. package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
  113. package/scripts/check-entwurf-v2-spawn.ts +398 -0
  114. package/scripts/check-entwurf-v2-surface.ts +352 -0
  115. package/scripts/check-keyset-overlap.py +120 -0
  116. package/scripts/check-mailbox-receipt-state.ts +170 -0
  117. package/scripts/check-meta-capability-source.ts +112 -0
  118. package/scripts/check-meta-dual-consumers.ts +154 -0
  119. package/scripts/check-meta-dual-read.ts +158 -0
  120. package/scripts/check-meta-listing.ts +138 -0
  121. package/scripts/check-meta-mailbox-state-write.ts +135 -0
  122. package/scripts/check-meta-migration.ts +212 -0
  123. package/scripts/check-meta-receiver-marker.ts +185 -0
  124. package/scripts/check-meta-record-v2.ts +191 -0
  125. package/scripts/check-meta-session.ts +673 -0
  126. package/scripts/check-model-lock.ts +408 -0
  127. package/scripts/check-package-source-routing.ts +253 -0
  128. package/scripts/check-pi-preflight.ts +304 -0
  129. package/scripts/check-project-trust-handler.ts +265 -0
  130. package/scripts/check-shell-quote.ts +121 -0
  131. package/scripts/check-socket-discovery.ts +428 -0
  132. package/scripts/check-socket-probe.ts +106 -0
  133. package/scripts/fixtures/probe-mcp-server.ts +33 -0
  134. package/scripts/gnew-rpc-drive.ts +211 -0
  135. package/scripts/lib/acp-child-cleanup.ts +116 -0
  136. package/scripts/meta-bridge-doctor.sh +315 -0
  137. package/scripts/meta-bridge-hook-log.sh +26 -0
  138. package/scripts/meta-bridge-install.sh +135 -0
  139. package/scripts/meta-bridge-prune.ts +199 -0
  140. package/scripts/meta-bridge-state.py +549 -0
  141. package/scripts/meta-bridge-statusline.sh +192 -0
  142. package/scripts/meta-bridge-store-doctor.ts +64 -0
  143. package/scripts/meta-bridge-uninstall.sh +39 -0
  144. package/scripts/new-session-id.ts +25 -0
  145. package/scripts/postinstall-chmod.cjs +58 -0
  146. package/scripts/raw-async-delivery/README.md +258 -0
  147. package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
  148. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
  149. package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
  150. package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
  151. package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
  152. package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
  153. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
  154. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
  155. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
  156. package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
  157. package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
  158. package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
  159. package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
  160. package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
  161. package/scripts/resolve-acp-bridge.ts +25 -0
  162. package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
  163. package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
  164. package/scripts/smoke-acp-mcp-live.ts +129 -0
  165. package/scripts/smoke-acp-memory-containment-live.ts +389 -0
  166. package/scripts/smoke-acp-overlay-live.ts +314 -0
  167. package/scripts/smoke-acp-provider-live.ts +162 -0
  168. package/scripts/smoke-acp-raw-turn-live.ts +261 -0
  169. package/scripts/smoke-acp-session-reuse-live.ts +172 -0
  170. package/scripts/smoke-acp-skill-live.ts +144 -0
  171. package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
  172. package/scripts/smoke-claude-native-resume-live.sh +198 -0
  173. package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
  174. package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
  175. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
  176. package/scripts/smoke-meta-async-drift.sh +171 -0
  177. package/scripts/smoke-meta-honesty.sh +147 -0
  178. package/scripts/smoke-meta-install-state.sh +403 -0
  179. package/scripts/smoke-meta-keyset-guard.sh +111 -0
  180. package/scripts/smoke-meta-prune.sh +174 -0
  181. package/scripts/smoke-resident-garden-guard.sh +433 -0
  182. package/scripts/smoke-session-id-name.ts +187 -0
  183. package/scripts/tsconfig.json +34 -0
@@ -0,0 +1,250 @@
1
+ /**
2
+ * meta-bridge-hook — the Claude Code native-session entry shell (1.0.0 step 4).
3
+ *
4
+ * THE load-bearing hook. Shipped inside a plugin bundle whose `hooks/hooks.json`
5
+ * wires it to `SessionStart` (and `CwdChanged` / `UserPromptSubmit`). Because a
6
+ * plugin's hooks load at STARTUP (a bare skill's load only on invocation, after
7
+ * SessionStart has passed), this fires on every native Claude Code open and makes
8
+ * that session a garden citizen WITHOUT any pi JSONL of its own:
9
+ *
10
+ * stdin {session_id, transcript_path, cwd} (Claude hook envelope)
11
+ * -> upsertMetaSession(claude-code) idempotent create/attach the record
12
+ * -> gardenId the session's garden address
13
+ * -> arm watchPath <mailbox>/<gardenId>/inbox.signal idle-wake doorbell
14
+ * -> stdout hookSpecificOutput.watchPaths (SessionStart / CwdChanged only)
15
+ *
16
+ * The record is keyed/looked-up by `native_session_id` (THE authority,
17
+ * scanByNativeId); the idle-wake mailbox is keyed by GARDEN id — that is the
18
+ * whole point of the meta-bridge over the raw per-session prototype: one garden
19
+ * address a sender can target, decoupled from the backend's native id grammar.
20
+ *
21
+ * FAILURE POLICY (decided, see NEXT.md step 4): the runtime hook is
22
+ * BEST-EFFORT + LOG. It never screams into the user's terminal and never blocks
23
+ * startup — on any error it appends a line to `<pi-agent-dir>/meta-bridge-hook.log`
24
+ * and emits an empty `{}` (no arm). The fail-LOUD surface is the doctor
25
+ * (step 5), which reads that log + the meta-record dir to catch a silent miss.
26
+ *
27
+ * watchPaths can be emitted only from SessionStart / CwdChanged / FileChanged, so
28
+ * a UserPromptSubmit fire does a degraded RECORD backfill (upsert) but cannot
29
+ * re-arm the idle watch — the record's address is restored, the wake is not.
30
+ *
31
+ * Run: `<node> --experimental-strip-types <plugin-root>/meta-bridge-hook.ts`.
32
+ * Imports `./lib/meta-session.ts` (+ `./lib/session-id.js`); the installer copies
33
+ * that lib dir alongside this file so `${CLAUDE_PLUGIN_ROOT}` self-locates it.
34
+ */
35
+
36
+ import * as fs from "node:fs";
37
+ import * as path from "node:path";
38
+ import {
39
+ defaultMetaMailboxDir,
40
+ defaultMetaSessionsDir,
41
+ type MetaReceiverArmProvenance,
42
+ upsertMetaSession,
43
+ writeMetaReceiverMarker,
44
+ writeMetaSenderMarker,
45
+ } from "./lib/meta-session.ts";
46
+
47
+ /**
48
+ * Append a best-effort diagnostic line; swallow even its own failure (never throw
49
+ * from the hook). Every line carries a LEVEL token so the doctor — the fail-loud
50
+ * surface — can mechanically tell a silent miss from routine noise:
51
+ * - ERROR: this session did NOT become a garden citizen (or lost its wake).
52
+ * The doctor must treat a recent ERROR as a failure (blocker #2).
53
+ * - WARN : the session registered, but something nearby is off (a corrupt
54
+ * neighbour record skipped during scan, or a degraded UserPromptSubmit
55
+ * backfill — note a degraded SessionStart/CwdChanged is ERROR, since
56
+ * those are the events that actually establish/refresh citizenship).
57
+ * - INFO : normal create/attach/arm.
58
+ * The token sits right after the ISO timestamp, so ` ERROR ` is a clean grep.
59
+ */
60
+ type LogLevel = "INFO" | "WARN" | "ERROR";
61
+ function logLine(level: LogLevel, message: string): void {
62
+ try {
63
+ // dirname(meta-sessions) == the pi agent dir — no extra resolver export needed.
64
+ const file = path.join(path.dirname(defaultMetaSessionsDir()), "meta-bridge-hook.log");
65
+ fs.mkdirSync(path.dirname(file), { recursive: true });
66
+ fs.appendFileSync(file, `${new Date().toISOString()} ${level} ${message}\n`);
67
+ } catch {
68
+ /* logging is best-effort; a broken log must not break the session */
69
+ }
70
+ }
71
+
72
+ /** Emit a hook response on stdout and exit 0. `{}` means "did nothing, do not block startup". */
73
+ function emit(payload: Record<string, unknown>): never {
74
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
75
+ process.exit(0);
76
+ }
77
+
78
+ /**
79
+ * Map the hook event to a receiver-marker arm provenance. ONLY the genuinely
80
+ * arm-capable events map; any other (a future/unknown hook event) returns null so we
81
+ * never mint an "active receiver" presence we cannot back — fail-closed, not an
82
+ * optimistic session-start. UserPromptSubmit never reaches here (it early-returns
83
+ * before the arm block).
84
+ */
85
+ function armProvenanceFor(eventName: string): MetaReceiverArmProvenance | null {
86
+ if (eventName === "SessionStart") return "session-start";
87
+ if (eventName === "CwdChanged") return "cwd-changed";
88
+ if (eventName === "FileChanged") return "file-changed";
89
+ return null;
90
+ }
91
+
92
+ function main(): void {
93
+ let raw: string;
94
+ try {
95
+ raw = fs.readFileSync(0, "utf8"); // fd 0 = stdin (the Claude hook envelope)
96
+ } catch (err) {
97
+ logLine("ERROR", `stdin read failed: ${err instanceof Error ? err.message : String(err)}`);
98
+ emit({});
99
+ }
100
+
101
+ let env: Record<string, unknown>;
102
+ try {
103
+ const parsed = JSON.parse(raw);
104
+ if (typeof parsed !== "object" || parsed === null) throw new Error("envelope is not an object");
105
+ env = parsed as Record<string, unknown>;
106
+ } catch (err) {
107
+ logLine("ERROR", `envelope parse failed: ${err instanceof Error ? err.message : String(err)}`);
108
+ emit({});
109
+ }
110
+
111
+ const sessionId = typeof env.session_id === "string" ? env.session_id : "";
112
+ const transcriptPath = typeof env.transcript_path === "string" ? env.transcript_path : "";
113
+ // cwd: prefer the envelope's, fall back to the process cwd (the hook runs in the session's cwd).
114
+ const cwd = typeof env.cwd === "string" && env.cwd.length > 0 ? env.cwd : process.cwd();
115
+ const modelEnvelope = env.model;
116
+ const model =
117
+ typeof modelEnvelope === "object" &&
118
+ modelEnvelope !== null &&
119
+ typeof (modelEnvelope as { id?: unknown }).id === "string"
120
+ ? (modelEnvelope as { id: string }).id
121
+ : typeof env.model_id === "string"
122
+ ? env.model_id
123
+ : undefined;
124
+ const eventName = typeof env.hook_event_name === "string" ? env.hook_event_name : "SessionStart";
125
+
126
+ if (!sessionId || !transcriptPath) {
127
+ // A degraded envelope: cannot mint an honest reference record. Log + no-op
128
+ // rather than write a half-record or guess a transcript path. LEVEL depends
129
+ // on the event: a degraded SessionStart / CwdChanged means the session FAILED
130
+ // to become (or refresh) a garden citizen — that is the silent registration
131
+ // miss the doctor must catch (blocker #2), so ERROR. UserPromptSubmit only
132
+ // ever does a best-effort record backfill, so a degraded one is just WARN.
133
+ const degradedLevel = eventName === "UserPromptSubmit" ? "WARN" : "ERROR";
134
+ logLine(
135
+ degradedLevel,
136
+ `degraded envelope (event=${eventName}, session_id=${sessionId ? "set" : "MISSING"}, transcript_path=${transcriptPath ? "set" : "MISSING"})`,
137
+ );
138
+ emit({});
139
+ }
140
+
141
+ let gardenId: string;
142
+ try {
143
+ const result = upsertMetaSession({
144
+ input: { backend: "claude-code", nativeSessionId: sessionId, transcriptPath, cwd, model },
145
+ onSkip: (filename, e) => logLine("WARN", `scan skipped ${filename}: ${e.message}`),
146
+ });
147
+ gardenId = result.record.gardenId;
148
+ logLine("INFO", `${result.action} record ${path.basename(result.path)} (event=${eventName}, native=${sessionId})`);
149
+ } catch (err) {
150
+ // Best-effort: a broken record store must surface via the doctor, not by
151
+ // breaking the user's session open. Log and continue with no arm. This is
152
+ // the silent-registration-miss (blocker #2): the session opened fine but is
153
+ // NOT a garden citizen — the doctor catches it via this ERROR line.
154
+ logLine(
155
+ "ERROR",
156
+ `upsert failed (event=${eventName}, native=${sessionId}): ${err instanceof Error ? err.message : String(err)}`,
157
+ );
158
+ emit({});
159
+ }
160
+
161
+ // Sender marker, keyed by the shared Claude Code parent pid: the user-scope
162
+ // MCP child (same parent) reads it at entwurf_v2 send time to promote this
163
+ // session from anonymous external-mcp to a REPLYABLE meta-session sender —
164
+ // process ancestry, not cwd inference (same repo + multiple sessions would be
165
+ // ambiguous). Best-effort: a failed marker only costs reply-addressability
166
+ // (WARN), it does not break the session or the receiver path.
167
+ //
168
+ // SE-1/SE-2 (dual-owner fix): write ONLY for the direct parent (process.ppid =
169
+ // the Claude CLI that ran this hook, verified the native tree is direct — the
170
+ // plugin host is not in between). The old code ALSO wrote a marker for the
171
+ // grandparent. That grandparent is the login shell (e.g. bash under ghostty/i3),
172
+ // which OUTLIVES the Claude session: when Claude exits, the grandparent marker's
173
+ // ownerStartKey still matches a live pid, so it passes readMetaSenderMarker's
174
+ // reuse guard and the dead session keeps looking like a live, replyable receiver
175
+ // — a false-positive "active receiver" leak. The owner must be the watchPaths
176
+ // subscriber (the Claude CLI), nothing higher. If a stray topology means the MCP
177
+ // child's shared ancestor is not process.ppid, that resolves to "no marker"
178
+ // (fail-closed, honest) rather than a wrong-but-live grandparent identity.
179
+ const ownerPid = process.ppid;
180
+ if (typeof ownerPid === "number" && ownerPid > 0) {
181
+ try {
182
+ writeMetaSenderMarker({ backend: "claude-code", gardenId, nativeSessionId: sessionId, cwd, ownerPid });
183
+ logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${eventName})`);
184
+ } catch (err) {
185
+ logLine(
186
+ "WARN",
187
+ `sender marker write failed (event=${eventName}, pid=${ownerPid}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`,
188
+ );
189
+ }
190
+ }
191
+
192
+ // watchPaths is emittable only from SessionStart / CwdChanged / FileChanged.
193
+ // UserPromptSubmit reaches here only to backfill the record above; it must NOT
194
+ // claim to arm a watch it cannot (decided).
195
+ if (eventName === "UserPromptSubmit") {
196
+ emit({});
197
+ }
198
+
199
+ try {
200
+ const mailbox = path.join(defaultMetaMailboxDir(), gardenId);
201
+ fs.mkdirSync(mailbox, { recursive: true });
202
+ const signal = path.join(mailbox, "inbox.signal");
203
+ if (!fs.existsSync(signal)) fs.writeFileSync(signal, "", { mode: 0o600 });
204
+ logLine("INFO", `armed watch ${signal}`);
205
+ // Receiver presence marker (SE-2): written on the arm-capable hook path that
206
+ // emits watchPaths, keyed by garden id with the watch owner pid (= the Claude
207
+ // CLI, process.ppid — same single owner as the sender marker, never the
208
+ // grandparent). It records that a LIVE owner reached the watch-arm emit; it is
209
+ // not proof the host ack'd the watch registration. This is what lets a sender
210
+ // tell a live receiver from a terminated one whose record still lingers.
211
+ // Best-effort: a failed/skipped marker only costs deliverability detection
212
+ // (WARN), it does not break the arm. An unknown event maps to null provenance →
213
+ // no marker (fail-closed: never claim an active receiver we cannot back).
214
+ const ownerPid = process.ppid;
215
+ const armProvenance = armProvenanceFor(eventName);
216
+ if (armProvenance === null) {
217
+ logLine("WARN", `receiver marker skipped — non-arm event ${eventName} (garden=${gardenId})`);
218
+ } else if (typeof ownerPid === "number" && ownerPid > 0) {
219
+ try {
220
+ writeMetaReceiverMarker({
221
+ gardenId,
222
+ backend: "claude-code",
223
+ nativeSessionId: sessionId,
224
+ ownerPid,
225
+ armProvenance,
226
+ });
227
+ logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName}`);
228
+ } catch (err) {
229
+ logLine(
230
+ "WARN",
231
+ `receiver marker write failed (event=${eventName}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`,
232
+ );
233
+ }
234
+ }
235
+ emit({
236
+ hookSpecificOutput: {
237
+ hookEventName: eventName,
238
+ watchPaths: [signal],
239
+ },
240
+ });
241
+ } catch (err) {
242
+ logLine(
243
+ "ERROR",
244
+ `arm failed (event=${eventName}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`,
245
+ );
246
+ emit({}); // record landed; only the arm failed — the doctor will flag the missing watch.
247
+ }
248
+ }
249
+
250
+ main();
@@ -0,0 +1,235 @@
1
+ /**
2
+ * model-lock — entwurf session model lock (extension-side revert).
3
+ *
4
+ * ⚠️ v2-only status (doc-truth): the "A (bridge-side)" companion described below
5
+ * — `acp-bridge.ts` / `ensureBridgeSession` — was removed in the ACP purge, so
6
+ * only this B-side hook remains. The `entwurf` provider it guards against is
7
+ * itself routing residue (Phase B: decide remove-vs-redefine model-lock; see
8
+ * NEXT--v2-only.md § Phase B 잔여). The A/B narrative below is retained as
9
+ * historical context pending that decision — read "A" as removed.
10
+ *
11
+ * Companion to the bridge-side guard in `acp-bridge.ts` (ensureBridgeSession
12
+ * reuse-path `ModelSwitchLockedError`). The two work as a pair:
13
+ *
14
+ * - A (bridge-side): live reuse-path mismatch inside a entwurf
15
+ * bridge session is refused at `ensureBridgeSession`. This is the
16
+ * fallback/direct-call boundary: it prevents silent backend handoff
17
+ * and MCP identity drift if the extension hook is absent, disabled,
18
+ * or fails before the revert lands.
19
+ *
20
+ * - B (this hook): cross-provider departure (entwurf/X → native) AND
21
+ * cross-provider entry (native → entwurf/X) — both touch the
22
+ * entwurf boundary and are refused by reverting to the previous
23
+ * model via `pi.setModel(previousModel)`. This is NOT a clean refusal:
24
+ * pi-core has already mutated `agent.state.model` and appended
25
+ * `model_change` to the JSONL before emitting `model_select`. We
26
+ * observe the after-event and revert, which adds a second
27
+ * `model_change` entry (X → Y → X).
28
+ *
29
+ * Why both surfaces (B fires first, A is the fallback):
30
+ * - B observes `model_select` immediately when pi-core emits it,
31
+ * which happens during `AgentSession.setModel()` BEFORE the next
32
+ * prompt reaches any provider. So for the entwurf →
33
+ * entwurf case (and every other touches-entwurf case), B
34
+ * reverts the model first and the next prompt then enters the
35
+ * bridge under the ORIGINAL model. A's reuse-path mismatch check
36
+ * therefore does not fire on the happy path — there is no mismatch
37
+ * left to catch. A only matters if B fails to register, throws
38
+ * before `pi.setModel(from)` completes, or is disabled by an
39
+ * operator overriding the extensions list.
40
+ * - cross-provider departure (entwurf → native) and cross-provider
41
+ * entry (native → entwurf) NEVER reach A at all — the next
42
+ * prompt routes to a different provider, so `ensureBridgeSession`
43
+ * is not called. B is the only surface for those cases.
44
+ * - Wire-evidence captured during the issue #14 investigation: in a
45
+ * native → entwurf entry, pi JSONL continued (hi1, hi2, ...)
46
+ * but a fresh ACP backend session was bootstrapped and the model
47
+ * could not see the pre-switch turn (model replied "현재 세션에서
48
+ * hi2만 보입니다"). Same failure mode as entwurf → native
49
+ * departure, mirrored.
50
+ *
51
+ * Policy:
52
+ * - A entwurf session is locked to its starting model.
53
+ * - Native-to-native switching is free. Once a native session is
54
+ * anchored, switching INTO entwurf is refused because it would
55
+ * create a fresh ACP backend behind a continuous pi transcript.
56
+ * - The lock fires for any in-session `model_select` event whose
57
+ * transition touches the entwurf boundary
58
+ * (`from.provider === "entwurf" || to.provider === "entwurf"`)
59
+ * and whose source is "set" or "cycle" (not "restore").
60
+ *
61
+ * Honest limits:
62
+ * - This is NOT a transcript-clean refusal. The first `model_change`
63
+ * entry (X → Y) is already in the JSONL by the time we observe the
64
+ * event. Our `pi.setModel(from)` adds a second entry (Y → X). For a
65
+ * fully clean refusal, pi-core would need a cancellable
66
+ * `before_model_select` hook that this repo intentionally does not
67
+ * patch (entwurf does not send PRs to pi-core).
68
+ * - On the happy path B is a strict superset of A's coverage. A is the
69
+ * fallback for cases where B did not run (handler registration
70
+ * missing, thrown before the revert lands, extension disabled by an
71
+ * operator override of the extensions list). Smoke `check-model-lock`
72
+ * verifies B's policy logic; `smoke-model-switch` continues to
73
+ * verify A's bridge-side throw as the fallback contract.
74
+ *
75
+ * References:
76
+ * - pi-core setModel: pi-mono/packages/coding-agent/src/core/agent-session.ts:1416
77
+ * - ModelSelectEvent: pi-mono/packages/coding-agent/src/core/extensions/types.ts:711-719
78
+ * - ExtensionAPI.setModel: types.ts:1228 (returns Promise<boolean>; false = auth missing)
79
+ * - ExtensionUIContext.notify: types.ts:135 (sync void)
80
+ * - Wire evidence native → entwurf entry failure mode: GLG live test
81
+ * 2026-05-14 ~13:30 KST, session pi:019e24c0-1251-...
82
+ */
83
+
84
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
85
+
86
+ const ENTWURF_PROVIDER = "entwurf";
87
+
88
+ /**
89
+ * Module-level reentry guard.
90
+ *
91
+ * When our handler calls `pi.setModel(from)` to revert, pi-core's
92
+ * `AgentSession.setModel()` mutates state and emits another `model_select`
93
+ * event (with `source: "set"`). Without this flag, our hook would observe
94
+ * the synthetic emit and try to revert again — infinite recursion. The
95
+ * flag is set BEFORE `pi.setModel` and cleared in a `finally` so any
96
+ * exception path still releases it.
97
+ *
98
+ * Why module-level (not closure-local in the handler): the handler is
99
+ * registered once and shared. A handler-local `let reverting = false` is
100
+ * also module-scoped in practice but module-level makes the lifetime
101
+ * explicit. ctx-local is unavailable — ExtensionContext has no mutable
102
+ * scratch space we are supposed to use.
103
+ */
104
+ let reverting = false;
105
+
106
+ /**
107
+ * Module-level "session has started" gate.
108
+ *
109
+ * Before the operator commits to a turn, model changes are configuration
110
+ * (CLI `--model`, settings.json default, pre-turn model selector) and the
111
+ * lock must NOT fire — locking pre-turn would defeat legitimate setup.
112
+ * Once the session has anchored a model identity (an existing conversation
113
+ * is loaded, or the first agent_start fires), the lock becomes active.
114
+ *
115
+ * The flag is consulted by `model_select` and set by:
116
+ * - `session_start`: per-reason policy (see below)
117
+ * - `agent_start`: always true (first turn = identity anchored)
118
+ *
119
+ * session_start reason behavior:
120
+ * - resume / fork: immediately true (inherited identity)
121
+ * - reload: preserved OR refreshed via getEntries() —
122
+ * guards against module re-import during
123
+ * extension reload that would reset the flag
124
+ * - startup / new (default): true IFF entries already contain a message
125
+ * (saved-session-on-startup path is rare
126
+ * today since pi-mono distinguishes
127
+ * resume/new explicitly, but this is a
128
+ * future-proof guard)
129
+ */
130
+ let sessionLocked = false;
131
+
132
+ /**
133
+ * Probe whether the session has at least one conversational message.
134
+ *
135
+ * `entry.type === "message"` matches `SessionMessageEntry` (real user /
136
+ * assistant / tool-result messages). Other entry types — `model_change`,
137
+ * `thinking_level_change`, `label`, `custom`, `session_info`,
138
+ * `compaction`, `branch_summary` — are not conversational, so our own
139
+ * revert-induced `model_change` entries do NOT register as "started".
140
+ *
141
+ * Defensive false-positive: if `getEntries()` throws at a reload /
142
+ * teardown boundary, we treat the session as started and engage the
143
+ * lock. This is the conservative side because failing-open would let a
144
+ * race window break the lock. The repo's general "throw, don't warn"
145
+ * rule yields here because this is a lock-state probe, not config
146
+ * validation — silent fall-through to "locked" preserves safety.
147
+ */
148
+ function hasStartedConversation(ctx: ExtensionContext): boolean {
149
+ try {
150
+ return ctx.sessionManager.getEntries().some((entry) => entry.type === "message");
151
+ } catch {
152
+ return true;
153
+ }
154
+ }
155
+
156
+ export default function (pi: ExtensionAPI) {
157
+ pi.on("session_start", (event, ctx) => {
158
+ if (event.reason === "resume" || event.reason === "fork") {
159
+ // Resumed/forked sessions inherit a model identity from the
160
+ // session being resumed/forked. Lock immediately.
161
+ sessionLocked = true;
162
+ return;
163
+ }
164
+ if (event.reason === "reload") {
165
+ // Reload happens in-process (extension/settings reattach).
166
+ // If the module was re-imported during reload, our `sessionLocked`
167
+ // resets to false — recover via getEntries(). If the module
168
+ // survived, preserve the existing flag.
169
+ sessionLocked = sessionLocked || hasStartedConversation(ctx);
170
+ return;
171
+ }
172
+ // startup / new — empty session: pre-turn model changes are free.
173
+ // Non-empty session (rare today, but possible if pi-mono ever
174
+ // loads a saved session under `reason: "startup"`): lock.
175
+ sessionLocked = hasStartedConversation(ctx);
176
+ });
177
+
178
+ pi.on("agent_start", () => {
179
+ // First prompt has triggered the agent loop. Model identity is
180
+ // now anchored for this session — lock from here on.
181
+ sessionLocked = true;
182
+ });
183
+
184
+ pi.on("model_select", async (event, ctx) => {
185
+ // Reentry: our own revert call. Skip to avoid infinite loop.
186
+ if (reverting) return;
187
+
188
+ // "restore" fires when pi reloads a saved session's model on
189
+ // startup. Refusing that would either loop or block a legitimate
190
+ // resume. "set" (explicit /model, model selector, Ctrl+P) and
191
+ // "cycle" (next/prev) both reach the user-initiated switch path
192
+ // and are the cases we want to lock.
193
+ if (event.source === "restore") return;
194
+
195
+ // Session not yet started — pre-turn model changes are operator
196
+ // configuration, not a lock violation. CLI --model override,
197
+ // settings.json default load, and pre-turn model selector all
198
+ // reach here under the unlocked state.
199
+ if (!sessionLocked) return;
200
+
201
+ const from = event.previousModel;
202
+ const to = event.model;
203
+
204
+ // First model selection has no previous model to revert to.
205
+ // Lock only applies to in-session switches.
206
+ if (!from) return;
207
+
208
+ // Same model picked again (id + provider identical). Not a switch,
209
+ // just a no-op state set. Skip.
210
+ if (from.provider === to.provider && from.id === to.id) return;
211
+
212
+ // Only act when the transition touches the entwurf boundary.
213
+ // Covers:
214
+ // entwurf → entwurf (id different) — B primary, A fallback
215
+ // entwurf → native — B only (A out of flow)
216
+ // native → entwurf — B only (A not yet engaged)
217
+ // Skips:
218
+ // native → native — out of scope, free
219
+ const touchesEntwurf = from.provider === ENTWURF_PROVIDER || to.provider === ENTWURF_PROVIDER;
220
+ if (!touchesEntwurf) return;
221
+
222
+ reverting = true;
223
+ try {
224
+ const ok = await pi.setModel(from);
225
+ ctx.ui.notify(
226
+ ok
227
+ ? `Session is locked to ${from.provider}/${from.id}; reverted attempted switch to ${to.provider}/${to.id}.`
228
+ : `Session is locked to ${from.provider}/${from.id}; failed to revert from ${to.provider}/${to.id} (auth missing for original model?).`,
229
+ ok ? "warning" : "error",
230
+ );
231
+ } finally {
232
+ reverting = false;
233
+ }
234
+ });
235
+ }
@@ -0,0 +1,27 @@
1
+ <!-- ═══════════════════════════════════════════════════════════════════════
2
+ SAMPLE — operator engraving carrier (entwurf)
3
+
4
+ What this is: the markdown body below this comment is loaded VERBATIM
5
+ into the backend's identity slot (Claude `_meta.systemPrompt`). It is
6
+ the one place an operator stamps a short identity onto every entwurf
7
+ ACP turn — replacing Claude's `claude_code` preset (which also strips
8
+ the preset's auto-memory advertisement, the memory-containment lever).
9
+
10
+ Where it loads from:
11
+ • Runtime default = pi-extensions/lib/acp/prompts/engraving.md
12
+ (ships as the minimal `# Engraving Here` placeholder; a gate pins
13
+ it non-empty so the containment lever stays ON).
14
+ • THIS root copy is a documented sample / starting point. Point the
15
+ runtime at your own file with
16
+ ENTWURF_ACP_ENGRAVING_PATH=/path/to/your.md — it is NOT loaded by
17
+ default (the lib copy above is).
18
+
19
+ Rules:
20
+ • Template variables: {{backend}}, {{mcp_servers}}.
21
+ • Keep it TINY. Do NOT paste AGENTS.md, the bridge narrative, or
22
+ tool catalogs here — a large Claude carrier can route OAuth
23
+ sessions to metered "extra usage" billing.
24
+ • An empty or missing file = opt-out (no engraving). That is fine.
25
+ ═══════════════════════════════════════════════════════════════════════ -->
26
+
27
+ # Engraving Here
package/protocol.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared wire-format constants for entwurf surfaces that must agree
3
+ * across the root bridge and pi-extension / MCP helper code.
4
+ *
5
+ * Keep this file dependency-free. Single source for both runtime paths:
6
+ * - tsc-emit path: typechecked under root tsconfig.json (allowJs: true)
7
+ * and copied through to the .tmp-verify directories by tsc emit.
8
+ * - Node --experimental-strip-types path (mcp bridges): resolves explicit
9
+ * .js imports literally to this file on disk.
10
+ *
11
+ * Why .js and not .ts: strip-types does not substitute .ts source for a
12
+ * literal .js import specifier, and the root config cannot enable
13
+ * allowImportingTsExtensions without losing tsc emit (which check-models
14
+ * relies on). Authoring as .js side-steps the whole drift surface.
15
+ */
16
+
17
+ /**
18
+ * Opening marker for the project-context block inserted by entwurf's
19
+ * `enrichTaskWithProjectContext`. The ACP bridge uses the same marker to
20
+ * detect entwurf-spawned first prompts and remove only the duplicate cwd
21
+ * AGENTS.md section from its own pi-context augment.
22
+ */
23
+ export const ENTWURF_PROJECT_CONTEXT_OPEN_TAG = "<project-context";
24
+
25
+ /**
26
+ * Sender-side UI echo custom message type for successful entwurf_v2 send calls.
27
+ * The bridge emits it, the control renderer draws it, and the provider-level
28
+ * context filter removes it before LLM conversion. Keep as one SSOT: a typo
29
+ * here otherwise becomes a silent UI/context split.
30
+ */
31
+ export const ENTWURF_SENT_MESSAGE_TYPE = "entwurf-sent";