@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,177 @@
1
+ // ACP plugin — pi Context → ACP prompt conversion (S2c).
2
+ //
3
+ // S2c is spawn-per-turn: every streamSimple call spawns a fresh ACP session, so
4
+ // the backend has NO memory of prior turns. Sending only the last user message
5
+ // would silently drop multi-turn history — that is context loss, not a thin
6
+ // substrate. So this flattens the whole pi conversation into ONE text transcript
7
+ // and sends it as a single ACP user prompt block.
8
+ //
9
+ // S2c/S2d boundary (GPT S2c Q2): this is CONVERSATION TRANSCRIPT PASSTHROUGH, not
10
+ // rich-carrier identity injection. Deliberately EXCLUDED here (all S2d):
11
+ // - `context.systemPrompt` — never read into the prompt or `_meta.systemPrompt`
12
+ // (the billing carrier stays absent — NEXT §S2-scout 핀1);
13
+ // - `~/AGENTS.md` / cwd AGENTS / bridge identity narrative;
14
+ // - first-user-message augment + project-context de-dup;
15
+ // - `context.tools` — the ACP child tool surface is the S2b
16
+ // `_meta.claudeCode.options` SSOT, never re-sent here.
17
+ // Structured tool replay is also excluded: tool calls/results render as plain
18
+ // transcript text, never as ACP tool invocations (the child runs its own tools).
19
+
20
+ import type { AssistantMessage, Context, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
21
+
22
+ // MUST equal event-mapper.ts `LIFECYCLE_NOTICE_SIGNATURE` (the SSOT/producer).
23
+ // It is mirrored, not imported: the strip-types deterministic gates load these
24
+ // lib files by their `.ts` source and cannot resolve a cross-sibling VALUE
25
+ // import (`./event-mapper.js`) — the lib modules share TYPES only. The mirror is
26
+ // kept honest behaviorally by check-acp-session-reuse (a drift would leave the
27
+ // `[acp: …]` notices in the captured ACP prompt and fail the gate).
28
+ const LIFECYCLE_NOTICE_SIGNATURE = "entwurf:lifecycle-notice-v1";
29
+
30
+ /** An ACP text content block. */
31
+ export interface AcpTextBlock {
32
+ type: "text";
33
+ text: string;
34
+ }
35
+
36
+ function textFromUserOrToolContent(content: UserMessage["content"] | ToolResultMessage["content"]): string {
37
+ if (typeof content === "string") return content;
38
+ // Render text verbatim; images are NOT dropped silently — they leave a text
39
+ // marker so the transcript honestly records an attachment the text-only S2c
40
+ // transcript cannot carry (real ACP image passthrough is a later lane).
41
+ return content
42
+ .map((c) => {
43
+ if (c.type === "text") return c.text;
44
+ if (c.type === "image") return `[image omitted: ${c.mimeType ?? "unknown"}]`;
45
+ return "";
46
+ })
47
+ .filter((s) => s !== "")
48
+ .join("\n");
49
+ }
50
+
51
+ function textFromAssistantContent(content: AssistantMessage["content"]): string {
52
+ // Assistant text only — thinking is omitted and tool calls are not replayed
53
+ // (the ACP child executes its own tools; replaying structured calls would be
54
+ // a lie). Tool RESULTS still appear via their own toolResult message below.
55
+ return (
56
+ content
57
+ .filter((c): c is { type: "text"; text: string; textSignature?: string } => c.type === "text")
58
+ // Drop S2f lifecycle progress notices (`[acp: …]`): display-only, stamped
59
+ // with LIFECYCLE_NOTICE_SIGNATURE. Replaying them into a `new` rebuild's
60
+ // full transcript would inject bridge-internal chatter into the ACP prompt.
61
+ .filter((c) => c.textSignature !== LIFECYCLE_NOTICE_SIGNATURE)
62
+ .map((c) => c.text)
63
+ .join("")
64
+ );
65
+ }
66
+
67
+ /** Render one pi message as a transcript line, or undefined to skip it. */
68
+ function renderMessage(message: Message): string | undefined {
69
+ switch (message.role) {
70
+ case "user": {
71
+ const text = textFromUserOrToolContent(message.content).trim();
72
+ return text ? `User: ${text}` : undefined;
73
+ }
74
+ case "assistant": {
75
+ const text = textFromAssistantContent(message.content).trim();
76
+ return text ? `Assistant: ${text}` : undefined;
77
+ }
78
+ case "toolResult": {
79
+ const text = textFromUserOrToolContent(message.content).trim();
80
+ const tag = message.isError ? "Tool error" : "Tool result";
81
+ return text ? `${tag} (${message.toolName}): ${text}` : undefined;
82
+ }
83
+ default:
84
+ return undefined;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Flatten a pi Context into a single transcript string. Excludes
90
+ * `context.systemPrompt` and `context.tools` by construction.
91
+ */
92
+ export function contextTranscript(context: Context): string {
93
+ const lines: string[] = [];
94
+ for (const message of context.messages) {
95
+ const line = renderMessage(message);
96
+ if (line) lines.push(line);
97
+ }
98
+ return lines.join("\n\n");
99
+ }
100
+
101
+ /**
102
+ * Convert a pi Context into the ACP `prompt` array (a single text block holding
103
+ * the flattened transcript). Empty history yields an empty array — the caller
104
+ * decides whether that is a hard error.
105
+ */
106
+ export function contextToAcpPrompt(context: Context): AcpTextBlock[] {
107
+ const transcript = contextTranscript(context);
108
+ if (!transcript) return [];
109
+ return [{ type: "text", text: transcript }];
110
+ }
111
+
112
+ /**
113
+ * ACP session bootstrap path — the input that decides the prompt SCOPE (NEXT
114
+ * §S2-scout 핀4). S2c was spawn-per-turn (always a fresh session), so this stays
115
+ * `"new"` until S2d wires the session store that can actually produce the reuse
116
+ * paths.
117
+ */
118
+ export type AcpBootstrapPath = "new" | "reuse" | "resume" | "load";
119
+
120
+ /**
121
+ * The latest user turn only — the first user message AFTER the last assistant
122
+ * message. Ported from 0.11.0 `index.ts:732 extractPromptBlocks`: taking the
123
+ * FIRST user of the trailing group (not `reverse().find()`) skips the
124
+ * SessionStart hook user-message (`device=…, time_kst=…`) that pi appends AFTER
125
+ * the real prompt. Images leave a text marker (S2c decision — real ACP image
126
+ * passthrough is a later lane), never raw data.
127
+ *
128
+ * This is the prompt scope for a session that ALREADY holds the prior turns
129
+ * (reuse/resume/load): re-sending the whole transcript there would duplicate
130
+ * history the backend already remembers.
131
+ */
132
+ export function latestUserDelta(context: Context): AcpTextBlock[] {
133
+ let lastAssistantIdx = -1;
134
+ for (let i = context.messages.length - 1; i >= 0; i--) {
135
+ if (context.messages[i].role === "assistant") {
136
+ lastAssistantIdx = i;
137
+ break;
138
+ }
139
+ }
140
+ const latestUser = context.messages.slice(lastAssistantIdx + 1).find((m): m is UserMessage => m.role === "user");
141
+ if (!latestUser) return [];
142
+ // The delta IS the user's actual prompt — preserve its body verbatim (0.11.0
143
+ // extractPromptBlocks sent it near-raw). Only the EMPTINESS test trims, so a
144
+ // whitespace-only turn yields no block.
145
+ const raw = textFromUserOrToolContent(latestUser.content);
146
+ return raw.trim() ? [{ type: "text", text: raw }] : [];
147
+ }
148
+
149
+ /**
150
+ * Build the ACP prompt array for a turn, scoping it by bootstrapPath (핀4):
151
+ * - `"new"` (incompatible rebuild included): a fresh ACP session holds NO
152
+ * history, so the whole transcript is the only history carrier (same as the
153
+ * S2c spawn-per-turn path). Delta-only here would lose history on
154
+ * rebuild/compaction/edited-history.
155
+ * - `"reuse" | "resume" | "load"`: the stateful ACP session already holds the
156
+ * prior turns, so send only the latest user delta — the whole transcript
157
+ * would duplicate remembered history.
158
+ *
159
+ * The delta-only SAFETY for resume/load is owned by the caller's
160
+ * `contextMessageSignatures` prefix-compat gate (mismatch → fall back to
161
+ * `"new"` + full transcript); this pure function only splits the scope.
162
+ */
163
+ export function buildAcpPrompt(context: Context, bootstrapPath: AcpBootstrapPath): AcpTextBlock[] {
164
+ switch (bootstrapPath) {
165
+ case "new":
166
+ return contextToAcpPrompt(context);
167
+ case "reuse":
168
+ case "resume":
169
+ case "load":
170
+ return latestUserDelta(context);
171
+ default:
172
+ // Fail-loud (핀4): a bad/unknown bootstrapPath must CRASH, never fall
173
+ // through to delta-only. A silent delta on a path that should carry the
174
+ // full transcript loses history — fail-OPEN toward the dangerous side.
175
+ throw new Error(`buildAcpPrompt: unknown bootstrapPath ${JSON.stringify(bootstrapPath)}`);
176
+ }
177
+ }
@@ -0,0 +1,123 @@
1
+ // ACP plugin — billing carrier (engraving) loader (S2d-1c).
2
+ //
3
+ // The engraving is the OPERATOR surface for the Claude `_meta.systemPrompt`
4
+ // carrier — short, personal additions an operator wants attached to every ACP
5
+ // session's system prompt. It is NOT the bridge-identity / AGENTS / pi-base
6
+ // narrative: that rich context rides the first-user-message augment (augment.ts)
7
+ // because the system-prompt carrier MUST stay tiny.
8
+ //
9
+ // Why a SMALL but NON-EMPTY default (the v1 memory-containment lever, restored):
10
+ // shipping a non-empty string here makes claude-agent-acp REPLACE its
11
+ // `claude_code` preset with this string (acp-agent.js: string-form
12
+ // `_meta.systemPrompt` → full preset replacement). That replacement strips the
13
+ // preset's auto-memory section, so the ACP model never learns it has a per-session
14
+ // memory store — the containment the operator baseline depends on. An EMPTY
15
+ // carrier keeps the preset and re-leaks auto-memory (the model writes memory/*.md
16
+ // via Write): that regression is exactly what a non-empty default fixes.
17
+ //
18
+ // Billing axis is SIZE, not SHAPE (NEXT §S2-scout 핀1, oracle A): Anthropic
19
+ // subscription billing (Claude Code OAuth, 정액제) reclassifies a call as metered
20
+ // "extra usage" — HTTP 400 for users with no metered balance — when the carrier
21
+ // materially GROWS past the SDK-default size (e.g. by injecting AGENTS.md or the
22
+ // pi base prompt). A tiny placeholder string is shape-deviant yet v1-production-
23
+ // safe, so the rule is keep the carrier SHORT, never "absent". Rich context still
24
+ // rides the first-user-message augment (augment.ts), never this carrier.
25
+ //
26
+ // Stability contract (NEXT oracle C / 핀1): the rendered output MUST be a pure
27
+ // function of (template content on disk, backend, mcpServerNames). No clock /
28
+ // random / env-time. `bridgeConfigSignature` folds this string into its
29
+ // `appendSystemPrompt` slot — if the rendered carrier drifted turn-to-turn, the
30
+ // signature would drift and entwurf would rebuild the ACP session every
31
+ // turn. The default-path source is cached once for exactly this reason, so a
32
+ // resident's carrier never drifts mid-session. The env-override path instead
33
+ // re-reads on EVERY call: editing that file mid-session INTENTIONALLY drifts the
34
+ // rendered carrier → bridgeConfigSignature changes → the live session is judged
35
+ // incompatible and the next turn opens a fresh ACP session with the new carrier.
36
+ // That per-turn rebuild is the accepted cost of the A/B opt-in surface, never the
37
+ // shipped default (which stays cached precisely so a resident never rebuilds).
38
+
39
+ import { readFileSync } from "node:fs";
40
+ import { dirname, join, resolve } from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+
43
+ const HERE = dirname(fileURLToPath(import.meta.url));
44
+ const DEFAULT_ENGRAVING_PATH = join(HERE, "prompts", "engraving.md");
45
+
46
+ export interface EngravingParams {
47
+ /** Claude-only this cut; kept as a field so the `{{backend}}` token interpolates. */
48
+ backend: string;
49
+ /** MCP server names exposed to the session. SORTED before render for determinism. */
50
+ mcpServerNames: readonly string[];
51
+ }
52
+
53
+ type CachedSource = { filePath: string; content: string };
54
+ let cached: CachedSource | null = null;
55
+
56
+ /** Point the loader at an alternate engraving file (A/B); bypasses the cache. */
57
+ function resolveEngravingPath(): string {
58
+ const envPath = process.env.ENTWURF_ACP_ENGRAVING_PATH?.trim();
59
+ return envPath ? resolve(envPath) : DEFAULT_ENGRAVING_PATH;
60
+ }
61
+
62
+ function loadSource(filePath: string): string {
63
+ // Env-override path → always re-read (A/B experimentation). Default path →
64
+ // cache once so a mid-session operator edit cannot drift the carrier (and thus
65
+ // bridgeConfigSignature) between turns of a resident.
66
+ if (filePath !== DEFAULT_ENGRAVING_PATH) {
67
+ return readFileSync(filePath, "utf8");
68
+ }
69
+ if (!cached || cached.filePath !== filePath) {
70
+ cached = { filePath, content: readFileSync(filePath, "utf8") };
71
+ }
72
+ return cached.content;
73
+ }
74
+
75
+ function interpolate(template: string, params: EngravingParams): string {
76
+ // Sort so a caller-side ordering difference can never drift the rendered text
77
+ // (and therefore the config signature) — GPT c32a6c8 determinism guard.
78
+ const names = [...params.mcpServerNames].sort();
79
+ const mcpList = names.length > 0 ? names.join(", ") : "(none registered)";
80
+ return template.replace(/\{\{backend\}\}/g, params.backend).replace(/\{\{mcp_servers\}\}/g, mcpList);
81
+ }
82
+
83
+ /**
84
+ * The rendered engraving carrier, or null when an ENV-OVERRIDE engraving file
85
+ * (`ENTWURF_ACP_ENGRAVING_PATH`) is empty, whitespace-only, missing, or
86
+ * unreadable — that null is the operator opt-out. The SHIPPED default, by
87
+ * contrast, IS the auto-memory containment lever (its non-empty carrier replaces
88
+ * the claude_code preset, stripping the auto-memory advertisement) and MUST be
89
+ * present + non-empty: if the shipped default is missing/unpackaged/empty this
90
+ * THROWS (fail-loud, Detour C) rather than silently shipping with the carrier
91
+ * strip off. To opt the carrier out, point the env override at an empty file.
92
+ * Callers MUST treat null as "no carrier configured" and omit `_meta.systemPrompt`
93
+ * entirely (passing "" as the `appendSystemPrompt` signature input) so
94
+ * subscription billing is never reclassified.
95
+ */
96
+ export function loadEngraving(params: EngravingParams): string | null {
97
+ const filePath = resolveEngravingPath();
98
+ const isShippedDefault = filePath === DEFAULT_ENGRAVING_PATH;
99
+ let source: string;
100
+ try {
101
+ source = loadSource(filePath);
102
+ } catch (err) {
103
+ if (isShippedDefault) {
104
+ throw new Error(
105
+ `entwurf: shipped engraving carrier unreadable at ${filePath} — it is the auto-memory ` +
106
+ `containment lever; refusing to proceed with containment silently degraded. (${(err as Error).message})`,
107
+ );
108
+ }
109
+ return null;
110
+ }
111
+ const rendered = interpolate(source, params).trim();
112
+ if (rendered.length === 0) {
113
+ if (isShippedDefault) {
114
+ throw new Error(
115
+ `entwurf: shipped engraving carrier at ${filePath} is empty — it is the auto-memory ` +
116
+ `containment lever; refusing to proceed with the carrier strip silently off. ` +
117
+ `(opt out via an empty ENTWURF_ACP_ENGRAVING_PATH file instead)`,
118
+ );
119
+ }
120
+ return null;
121
+ }
122
+ return rendered;
123
+ }
@@ -0,0 +1,339 @@
1
+ // ACP plugin — ACP session_notification → pi event stream mapper (S2c).
2
+ //
3
+ // Translates the ACP backend's streaming notifications into pi's
4
+ // AssistantMessageEvent protocol (text/thinking blocks + tool/permission
5
+ // notices + usage), maintaining a running `partial: AssistantMessage`.
6
+ //
7
+ // Claude-only collapse (NEXT §스코프 + oracle F): the 0.11.0 event-mapper
8
+ // reconciled three backend dialects (Claude rawOutput=array / Codex
9
+ // CallToolResult / Gemini content[]) plus an entwurf_v2 sent-box custom promotion.
10
+ // This lane drives ONLY claude-agent-acp, so the dialect collapses to one
11
+ // (rawOutput=array) and the entwurf/gemini/codex special-casing is dropped.
12
+ //
13
+ // CRITICAL boundary (GPT S2c Q3): an ACP `tool_call` / `tool_call_update` is
14
+ // rendered as an INFORMATIONAL TEXT NOTICE, never a structured pi `toolcall_*`
15
+ // event. The ACP child already executes its own tools (Claude Code side); a
16
+ // structured pi ToolCall would signal pi's agent loop to RE-EXECUTE it. Tools
17
+ // surface honestly in the transcript as `[tool:*]` notices instead. Thinking
18
+ // (`agent_thought_chunk`) IS structured — pi never executes thinking.
19
+
20
+ import type { AssistantMessage, AssistantMessageEventStream } from "@earendil-works/pi-ai";
21
+
22
+ const NOTICE_TITLE_MAX = 80;
23
+ const NOTICE_SUMMARY_MAX = 160;
24
+
25
+ /** Identity fields for the running assistant message. */
26
+ export interface AcpStreamIdentity {
27
+ api: string;
28
+ provider: string;
29
+ model: string;
30
+ }
31
+
32
+ type ObservedToolState = {
33
+ title: string;
34
+ status?: string;
35
+ notifiedRunning?: boolean;
36
+ };
37
+
38
+ export type AcpPiStreamState = {
39
+ stream: AssistantMessageEventStream;
40
+ output: AssistantMessage;
41
+ openTextIndex?: number;
42
+ openThinkingIndex?: number;
43
+ /** When false, tool/permission notices are suppressed (kept terse for smokes). */
44
+ showToolNotifications?: boolean;
45
+ observedTools?: Map<string, ObservedToolState>;
46
+ };
47
+
48
+ /** A zeroed pi Usage block. */
49
+ function zeroUsage(): AssistantMessage["usage"] {
50
+ return {
51
+ input: 0,
52
+ output: 0,
53
+ cacheRead: 0,
54
+ cacheWrite: 0,
55
+ totalTokens: 0,
56
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Build a fresh stream state with an empty running AssistantMessage. The caller
62
+ * (streamSimple) pushes the `start` event, drives notifications through
63
+ * applyAcpSessionUpdate, then finalize + done.
64
+ */
65
+ export function createAcpStreamState(
66
+ stream: AssistantMessageEventStream,
67
+ identity: AcpStreamIdentity,
68
+ opts?: { showToolNotifications?: boolean; timestamp?: number },
69
+ ): AcpPiStreamState {
70
+ const output: AssistantMessage = {
71
+ role: "assistant",
72
+ content: [],
73
+ api: identity.api,
74
+ provider: identity.provider,
75
+ model: identity.model,
76
+ usage: zeroUsage(),
77
+ stopReason: "stop",
78
+ timestamp: opts?.timestamp ?? Date.now(),
79
+ };
80
+ return {
81
+ stream,
82
+ output,
83
+ showToolNotifications: opts?.showToolNotifications ?? true,
84
+ };
85
+ }
86
+
87
+ function getObservedTools(state: AcpPiStreamState): Map<string, ObservedToolState> {
88
+ if (!state.observedTools) state.observedTools = new Map();
89
+ return state.observedTools;
90
+ }
91
+
92
+ function closeThinkingBlock(state: AcpPiStreamState): void {
93
+ if (state.openThinkingIndex == null) return;
94
+ const index = state.openThinkingIndex;
95
+ const block = state.output.content[index] as { thinking?: string };
96
+ state.stream.push({
97
+ type: "thinking_end",
98
+ contentIndex: index,
99
+ content: block?.thinking ?? "",
100
+ partial: state.output,
101
+ });
102
+ state.openThinkingIndex = undefined;
103
+ }
104
+
105
+ function closeTextBlock(state: AcpPiStreamState): void {
106
+ if (state.openTextIndex == null) return;
107
+ const index = state.openTextIndex;
108
+ const block = state.output.content[index] as { text?: string };
109
+ state.stream.push({ type: "text_end", contentIndex: index, content: block?.text ?? "", partial: state.output });
110
+ state.openTextIndex = undefined;
111
+ }
112
+
113
+ function ensureTextBlock(state: AcpPiStreamState): number {
114
+ if (state.openTextIndex != null) return state.openTextIndex;
115
+ closeThinkingBlock(state);
116
+ const index = state.output.content.length;
117
+ state.output.content.push({ type: "text", text: "" });
118
+ state.openTextIndex = index;
119
+ state.stream.push({ type: "text_start", contentIndex: index, partial: state.output });
120
+ return index;
121
+ }
122
+
123
+ function ensureThinkingBlock(state: AcpPiStreamState): number {
124
+ if (state.openThinkingIndex != null) return state.openThinkingIndex;
125
+ closeTextBlock(state);
126
+ const index = state.output.content.length;
127
+ state.output.content.push({ type: "thinking", thinking: "", thinkingSignature: "" });
128
+ state.openThinkingIndex = index;
129
+ state.stream.push({ type: "thinking_start", contentIndex: index, partial: state.output });
130
+ return index;
131
+ }
132
+
133
+ /**
134
+ * Emit a standalone one-line notice as its own text block. Used for tool /
135
+ * permission events — informational, NOT structured tool calls.
136
+ */
137
+ function pushNotice(state: AcpPiStreamState, text: string): void {
138
+ if (!state.showToolNotifications || !text.trim()) return;
139
+ closeThinkingBlock(state);
140
+ closeTextBlock(state);
141
+ const index = state.output.content.length;
142
+ state.output.content.push({ type: "text", text });
143
+ state.stream.push({ type: "text_start", contentIndex: index, partial: state.output });
144
+ state.stream.push({ type: "text_delta", contentIndex: index, delta: text, partial: state.output });
145
+ state.stream.push({ type: "text_end", contentIndex: index, content: text, partial: state.output });
146
+ }
147
+
148
+ /**
149
+ * Sanitize an inline fragment for the one-line `[tool:*]` / `[permission:*]`
150
+ * notice surface: collapse whitespace, neutralize backtick fences (which would
151
+ * otherwise swallow following lines in chat renderers), truncate with ellipsis.
152
+ */
153
+ export function sanitizeNoticeFragment(text: string | null | undefined, max: number): string {
154
+ if (!text) return "";
155
+ const collapsed = text.replace(/\s+/g, " ").trim();
156
+ const fenceSafe = collapsed.replace(/`{3,}/g, "[fence]").replace(/`/g, "'");
157
+ if (fenceSafe.length <= max) return fenceSafe;
158
+ return `${fenceSafe.slice(0, max - 1)}…`;
159
+ }
160
+
161
+ function firstTextItem(arr: unknown[]): string | undefined {
162
+ for (const item of arr) {
163
+ if (item && typeof item === "object" && (item as { type?: string }).type === "text") {
164
+ const text = String((item as { text?: unknown }).text ?? "").trim();
165
+ if (text) return text;
166
+ }
167
+ // ACP-normalized shape: { type:"content", content:{ type:"text", text } }
168
+ if (item && typeof item === "object" && (item as { type?: string }).type === "content") {
169
+ const inner = (item as { content?: { type?: string; text?: unknown } }).content;
170
+ if (inner && typeof inner === "object" && inner.type === "text") {
171
+ const text = String(inner.text ?? "").trim();
172
+ if (text) return text;
173
+ }
174
+ }
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ /** Claude ACP rawOutput is an array of text items; tolerate a CallToolResult body too. */
180
+ function firstTextContent(value: unknown): string | undefined {
181
+ if (Array.isArray(value)) {
182
+ const text = firstTextItem(value);
183
+ if (text) return text;
184
+ }
185
+ if (!value || typeof value !== "object") return undefined;
186
+ const inner = (value as { content?: unknown }).content;
187
+ if (Array.isArray(inner)) {
188
+ const text = firstTextItem(inner);
189
+ if (text) return text;
190
+ }
191
+ return undefined;
192
+ }
193
+
194
+ /** MCP-level error flag on a CallToolResult-shaped rawOutput. */
195
+ function rawOutputHasError(rawOutput: unknown): boolean {
196
+ if (!rawOutput || typeof rawOutput !== "object") return false;
197
+ return (rawOutput as { isError?: unknown }).isError === true;
198
+ }
199
+
200
+ function titleForTool(update: Record<string, unknown>, previousTitle?: string): string {
201
+ const meta = update?._meta as { claudeCode?: { toolName?: string } } | undefined;
202
+ return String(update?.title ?? previousTitle ?? meta?.claudeCode?.toolName ?? update?.toolCallId ?? "Tool");
203
+ }
204
+
205
+ /**
206
+ * Render an ACP tool_call / tool_call_update as a text notice (NEVER a
207
+ * structured toolcall — the ACP child already executed it).
208
+ */
209
+ function renderToolUpdate(state: AcpPiStreamState, update: Record<string, unknown>): void {
210
+ const toolCallId = String(update?.toolCallId ?? "");
211
+ if (!toolCallId) return;
212
+ const observedTools = getObservedTools(state);
213
+ const previous = observedTools.get(toolCallId);
214
+ const title = titleForTool(update, previous?.title);
215
+ const status = typeof update?.status === "string" ? (update.status as string) : previous?.status;
216
+ const updateContent = Array.isArray(update?.content) ? (update.content as unknown[]) : undefined;
217
+ const meta = update?._meta as { terminal_output?: unknown } | undefined;
218
+
219
+ let notifiedRunning = previous?.notifiedRunning;
220
+
221
+ if (update.sessionUpdate === "tool_call") {
222
+ observedTools.set(toolCallId, { title, status, notifiedRunning });
223
+ pushNotice(state, `\n[tool:start] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}\n`);
224
+ return;
225
+ }
226
+
227
+ // tool_call_update
228
+ if (meta?.terminal_output && !notifiedRunning) {
229
+ notifiedRunning = true;
230
+ pushNotice(state, `\n[tool:running] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}\n`);
231
+ }
232
+
233
+ if (status && status !== previous?.status) {
234
+ const summary = firstTextContent(update?.rawOutput) ?? firstTextContent(updateContent);
235
+ const suffix = summary ? ` — ${sanitizeNoticeFragment(summary, NOTICE_SUMMARY_MAX)}` : "";
236
+ if (status === "completed") {
237
+ const label = rawOutputHasError(update?.rawOutput) ? "tool:failed" : "tool:done";
238
+ pushNotice(state, `\n[${label}] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}${suffix}\n`);
239
+ } else if (status === "failed") {
240
+ pushNotice(state, `\n[tool:failed] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}${suffix}\n`);
241
+ } else if (status === "cancelled") {
242
+ pushNotice(state, `\n[tool:cancelled] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}${suffix}\n`);
243
+ }
244
+ }
245
+
246
+ observedTools.set(toolCallId, { title, status, notifiedRunning });
247
+ }
248
+
249
+ /** Push a permission-decision notice (informational text, not a tool call). */
250
+ export function pushPermissionNotice(state: AcpPiStreamState, title: string, decision: string): void {
251
+ pushNotice(state, `\n[permission:${decision}] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}\n`);
252
+ }
253
+
254
+ /**
255
+ * The textSignature marker stamped on lifecycle progress notices (S2f). It is
256
+ * what lets the transcript flatten (context.ts) and the reuse-compat signature
257
+ * (session-store.ts) EXCLUDE these blocks: a lifecycle notice is display-only —
258
+ * it must never replay into an ACP prompt nor perturb a reuse signature, whether
259
+ * present or absent. Without the marker the "output-side only" claim is L0 hope.
260
+ */
261
+ export const LIFECYCLE_NOTICE_SIGNATURE = "entwurf:lifecycle-notice-v1";
262
+
263
+ /**
264
+ * Push a one-line ACP turn-lifecycle progress notice (`[acp: …]`) as its own
265
+ * text block, stamped with LIFECYCLE_NOTICE_SIGNATURE. Two ways it differs from
266
+ * tool/permission notices:
267
+ * 1. It IGNORES `showToolNotifications`. Turn progress is ALWAYS visible — a
268
+ * silent bootstrap (overlay → spawn → init → newSession → setModel → first
269
+ * token) reads as a hang. Only the verbose tool stream is suppressible.
270
+ * 2. The marker keeps it display-only — out of the transcript replay and the
271
+ * reuse-compat signature (the two consumers filter on the signature).
272
+ */
273
+ export function pushAcpLifecycleNotice(state: AcpPiStreamState, text: string): void {
274
+ const line = `\n[acp: ${sanitizeNoticeFragment(text, NOTICE_TITLE_MAX)}]\n`;
275
+ closeThinkingBlock(state);
276
+ closeTextBlock(state);
277
+ const index = state.output.content.length;
278
+ state.output.content.push({ type: "text", text: line, textSignature: LIFECYCLE_NOTICE_SIGNATURE });
279
+ state.stream.push({ type: "text_start", contentIndex: index, partial: state.output });
280
+ state.stream.push({ type: "text_delta", contentIndex: index, delta: line, partial: state.output });
281
+ state.stream.push({ type: "text_end", contentIndex: index, content: line, partial: state.output });
282
+ }
283
+
284
+ /**
285
+ * Apply one ACP `session_notification` update to the stream state. Unknown
286
+ * update kinds are ignored (forward-compatible).
287
+ */
288
+ export function applyAcpSessionUpdate(
289
+ state: AcpPiStreamState,
290
+ update: Record<string, unknown> | null | undefined,
291
+ ): void {
292
+ if (!update || typeof update !== "object") return;
293
+
294
+ switch (update.sessionUpdate) {
295
+ case "agent_message_chunk": {
296
+ const content = update.content as { type?: string; text?: unknown } | undefined;
297
+ if (content?.type !== "text") return;
298
+ const delta = String(content.text ?? "");
299
+ if (!delta) return;
300
+ const index = ensureTextBlock(state);
301
+ (state.output.content[index] as { text: string }).text += delta;
302
+ state.stream.push({ type: "text_delta", contentIndex: index, delta, partial: state.output });
303
+ break;
304
+ }
305
+ case "agent_thought_chunk": {
306
+ const content = update.content as { type?: string; text?: unknown } | undefined;
307
+ if (content?.type !== "text") return;
308
+ const delta = String(content.text ?? "");
309
+ if (!delta) return;
310
+ const index = ensureThinkingBlock(state);
311
+ (state.output.content[index] as { thinking: string }).thinking += delta;
312
+ state.stream.push({ type: "thinking_delta", contentIndex: index, delta, partial: state.output });
313
+ break;
314
+ }
315
+ case "tool_call":
316
+ case "tool_call_update": {
317
+ renderToolUpdate(state, update);
318
+ break;
319
+ }
320
+ case "usage_update": {
321
+ // S2c maps COARSE ACP usage only: `used` is occupancy-shaped and does
322
+ // not split cleanly into pi's input/output/cache fields, so we fill
323
+ // totalTokens + cost.total and leave the rest zero. Richer accounting
324
+ // is a later lane (S2e/PR-polish).
325
+ if (typeof update.used === "number") state.output.usage.totalTokens = update.used;
326
+ const cost = update.cost as { amount?: unknown } | undefined;
327
+ if (typeof cost?.amount === "number") state.output.usage.cost.total = cost.amount;
328
+ break;
329
+ }
330
+ default:
331
+ break;
332
+ }
333
+ }
334
+
335
+ /** Close any open text/thinking block. Call before pushing the terminal done. */
336
+ export function finalizeAcpStreamState(state: AcpPiStreamState): void {
337
+ closeThinkingBlock(state);
338
+ closeTextBlock(state);
339
+ }