@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,418 @@
1
+ // ACP plugin — session store / signature / bootstrap decision (S2d-1b-1).
2
+ //
3
+ // This is the DETERMINISTIC half of session reuse. It owns three pure concerns
4
+ // and the record persistence around them, with NO child/connection lifecycle
5
+ // (that is S2d-1b-2 live wiring):
6
+ //
7
+ // 1. bridgeConfigSignature — a stable hash of the config that, if it
8
+ // drifts, must invalidate a reused session (NEXT oracle C / 핀1). During
9
+ // S2d-1b the carrier (appendSystemPrompt) is ABSENT, so the signature is a
10
+ // per-model constant; engraving fills it in S2d-1c.
11
+ // 2. contextMessageSignatures — one signature per pi message (role + content
12
+ // shape), ported from 0.11.0 index.ts:432. Reuse is only safe when the
13
+ // existing session's signatures are a PREFIX of the new turn's — i.e. the
14
+ // new context continues the old one rather than diverging.
15
+ // 3. decideBootstrap — maps (params, in-memory existing, persisted
16
+ // record) to an AcpBootstrapPath, with bootstrapPath (history source) and
17
+ // lifecyclePolicy (whether the child survives the turn) kept ORTHOGONAL
18
+ // (GPT 73b44d). `turn-scoped` (pi -p one-shot) is always `new` — never an
19
+ // in-memory child reuse, because a surviving child's stdio handle pins pi's
20
+ // exit (the S2c hang). Only a long-lived `process-scoped` session reuses.
21
+ //
22
+ // 핀4 tie-in: the chosen path feeds buildAcpPrompt(context, path) so reuse sends
23
+ // a delta while new sends the full transcript. The prefix-compat gate here is
24
+ // what makes delta-only SAFE — a mismatch falls back to `new` + full transcript.
25
+
26
+ import { createHash } from "node:crypto";
27
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { join } from "node:path";
30
+ import type { Context, Message, ToolResultMessage } from "@earendil-works/pi-ai";
31
+ import type { AcpBootstrapPath } from "./context.js";
32
+
33
+ // MUST equal event-mapper.ts `LIFECYCLE_NOTICE_SIGNATURE` (the SSOT/producer).
34
+ // Mirrored, not imported — the strip-types gates load this file by its `.ts`
35
+ // source and cannot resolve a cross-sibling VALUE import; lib modules share
36
+ // TYPES only. check-acp-session-reuse enforces equality behaviorally (a drift
37
+ // would let display-only notices perturb the reuse-compat signature).
38
+ const LIFECYCLE_NOTICE_SIGNATURE = "entwurf:lifecycle-notice-v1";
39
+
40
+ /** sha256 hex digest. Used so on-disk records carry digests, never raw prompt text. */
41
+ function sha256(value: string): string {
42
+ return createHash("sha256").update(value).digest("hex");
43
+ }
44
+
45
+ export const SESSION_RECORD_VERSION = 1;
46
+ export const SESSION_RECORD_PROVIDER = "entwurf" as const;
47
+
48
+ /** Where the child survives relative to a turn — ORTHOGONAL to bootstrapPath. */
49
+ export type LifecyclePolicy = "process-scoped" | "turn-scoped";
50
+
51
+ /** The exact argv token that marks a long-lived resident pi process. */
52
+ export const ENTWURF_CONTROL_FLAG = "--entwurf-control";
53
+
54
+ /**
55
+ * Decide whether this pi process may keep an ACP child alive across turns. Only
56
+ * an explicit long-lived resident (`--entwurf-control`) is `process-scoped`;
57
+ * everything else — `pi -p` one-shot AND plain interactive — is `turn-scoped`
58
+ * (new + teardown each turn), the S2c hang-safe default (a surviving child's
59
+ * stdio handle would pin a one-shot pi's exit).
60
+ *
61
+ * Keyed on the resident flag, NEVER on `-p`: a resident may ALSO carry `-p`
62
+ * (fire a first prompt, then stay alive), so a `-p` test would kill real
63
+ * resident reuse. Exact-token match, not substring (`--not-entwurf-control`
64
+ * must NOT qualify). A future explicit pi lifecycle/mode hint would take
65
+ * precedence over argv, but argv is the most honest marker available today.
66
+ */
67
+ export function resolveLifecyclePolicy(argv: readonly string[] = process.argv): LifecyclePolicy {
68
+ return argv.includes(ENTWURF_CONTROL_FLAG) ? "process-scoped" : "turn-scoped";
69
+ }
70
+
71
+ /**
72
+ * Inputs to the config signature. Order is FIXED so the serialized hash is
73
+ * stable across turns (a drifting key order would force a rebuild every turn).
74
+ * `appendSystemPrompt` is the carrier slot — the rendered engraving string
75
+ * (empty when absent). `mcpServersHash` is the sha256 of the NORMALIZED server
76
+ * list (NOT just names — S2g/GPT `…2f9325`), so a change to a server's
77
+ * command/args/env/url/headers invalidates a reused session. The per-session
78
+ * envelope (PI_SESSION_ID/PI_AGENT_ID) is deliberately NOT here: it is runtime
79
+ * wiring injected after this hash is taken, so a new session id alone never
80
+ * forces a rebuild.
81
+ */
82
+ export interface BridgeConfigInput {
83
+ backend: string;
84
+ modelId: string;
85
+ /** Backend-native model id (curation prefix stripped); claude: equals modelId. */
86
+ nativeModelId: string;
87
+ appendSystemPrompt: string;
88
+ /** Backend-specific stable signature fields (e.g. a backend connection/profile id). Never secrets. */
89
+ extra?: Record<string, unknown>;
90
+ mcpServersHash: string;
91
+ settingSources: string[];
92
+ strictMcpConfig: boolean;
93
+ tools: string[];
94
+ skillPlugins: string[];
95
+ permissionAllow: string[];
96
+ disallowedTools: string[];
97
+ }
98
+
99
+ /** A persisted ACP session record (one file per sessionKey). */
100
+ export interface SessionRecord {
101
+ version: number;
102
+ provider: typeof SESSION_RECORD_PROVIDER;
103
+ sessionKey: string;
104
+ acpSessionId: string;
105
+ cwd: string;
106
+ modelId: string;
107
+ bridgeConfigSignature: string;
108
+ contextMessageSignatures: string[];
109
+ updatedAt: string;
110
+ }
111
+
112
+ /** The compat-relevant facts of a candidate session (in-memory or persisted). */
113
+ export interface SessionCompatFacts {
114
+ cwd: string;
115
+ modelId: string;
116
+ bridgeConfigSignature: string;
117
+ contextMessageSignatures: string[];
118
+ }
119
+
120
+ /** An in-memory live session candidate (reuse path). */
121
+ export interface ExistingSession extends SessionCompatFacts {
122
+ alive: boolean;
123
+ }
124
+
125
+ export interface BootstrapParams extends SessionCompatFacts {
126
+ lifecyclePolicy: LifecyclePolicy;
127
+ }
128
+
129
+ export interface BootstrapDecision {
130
+ path: AcpBootstrapPath;
131
+ /** The ACP session id to resume/load (persisted paths only). */
132
+ acpSessionId?: string;
133
+ /** The persisted record must be deleted (it is stale/incompatible). */
134
+ invalidatePersisted?: boolean;
135
+ reason: string;
136
+ }
137
+
138
+ /** Thrown when a live session is asked to switch the model it is locked to. */
139
+ export class SessionModelLockedError extends Error {
140
+ readonly fromModel: string;
141
+ readonly toModel: string;
142
+ constructor(fromModel: string, toModel: string) {
143
+ super(`entwurf session is locked to model ${fromModel}; refusing switch to ${toModel}`);
144
+ this.name = "SessionModelLockedError";
145
+ this.fromModel = fromModel;
146
+ this.toModel = toModel;
147
+ }
148
+ }
149
+
150
+ const SESSION_CACHE_DIR = join(homedir(), ".pi", "agent", "cache", "entwurf", "sessions");
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // 1) signatures
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Stable config hash. Pure — no clock/random/env. The serialized input is a
158
+ * SMALL object with a FIXED key order so `JSON.stringify` is deterministic
159
+ * across turns (a drifting order would force a rebuild every turn). The digest
160
+ * keeps the carrier text (appendSystemPrompt) out of the on-disk record (GPT
161
+ * `c617cb` hardening).
162
+ */
163
+ export function bridgeConfigSignature(input: BridgeConfigInput): string {
164
+ return sha256(
165
+ JSON.stringify({
166
+ backend: input.backend,
167
+ modelId: input.modelId,
168
+ nativeModelId: input.nativeModelId,
169
+ appendSystemPrompt: input.appendSystemPrompt,
170
+ mcpServersHash: input.mcpServersHash,
171
+ settingSources: [...input.settingSources],
172
+ strictMcpConfig: input.strictMcpConfig,
173
+ tools: [...input.tools],
174
+ skillPlugins: [...input.skillPlugins],
175
+ permissionAllow: [...input.permissionAllow],
176
+ disallowedTools: [...input.disallowedTools],
177
+ extra: input.extra ?? {},
178
+ }),
179
+ );
180
+ }
181
+
182
+ /** One signature per content block — role-agnostic shape fingerprint. */
183
+ function messageContentSignature(content: unknown): string {
184
+ if (typeof content === "string") return `text:${content}`;
185
+ if (!Array.isArray(content)) return "";
186
+ return (
187
+ content
188
+ // Exclude S2f lifecycle progress notices ENTIRELY (not as an empty entry) so
189
+ // the per-message signature is byte-identical whether or not display-only
190
+ // `[acp: …]` blocks were appended to this assistant message. A null/empty
191
+ // map entry would shift the `|`-join and perturb the prefix-compat check.
192
+ .filter(
193
+ (block) =>
194
+ !(
195
+ block &&
196
+ typeof block === "object" &&
197
+ (block as { textSignature?: unknown }).textSignature === LIFECYCLE_NOTICE_SIGNATURE
198
+ ),
199
+ )
200
+ .map((block: Record<string, unknown>) => {
201
+ if (!block || typeof block !== "object") return "";
202
+ switch (block.type) {
203
+ case "text":
204
+ return `text:${String(block.text ?? "")}`;
205
+ case "image":
206
+ return `image:${String(block.mimeType ?? "")}`;
207
+ case "thinking":
208
+ return `thinking:${String(block.thinking ?? "")}`;
209
+ case "toolCall":
210
+ return `tool:${String(block.name ?? "")}:${JSON.stringify(block.arguments ?? {})}`;
211
+ default:
212
+ return `${String(block.type ?? "unknown")}:${JSON.stringify(block)}`;
213
+ }
214
+ })
215
+ .join("|")
216
+ );
217
+ }
218
+
219
+ /**
220
+ * The pre-hash, human-readable signature of one message. A toolResult also
221
+ * folds in `toolName` + `isError` so a same-text result from a different tool
222
+ * (or a success vs an error) breaks the prefix (GPT `c617cb`).
223
+ */
224
+ function rawMessageSignature(message: Message): string {
225
+ const contentSig = messageContentSignature((message as { content: unknown }).content);
226
+ if (message.role === "toolResult") {
227
+ const tr = message as ToolResultMessage;
228
+ return `toolResult:${tr.toolName ?? ""}:${tr.isError ? "1" : "0"}:${contentSig}`;
229
+ }
230
+ return `${message.role}:${contentSig}`;
231
+ }
232
+
233
+ /**
234
+ * Per-message signatures: reuse is safe only when the existing list is a prefix
235
+ * of the new turn's. Each entry is sha256(rawMessageSignature) so the persisted
236
+ * record never stores raw prompt/tool text — the prefix check works the same on
237
+ * the digest array (GPT `c617cb` hardening).
238
+ */
239
+ export function contextMessageSignatures(context: Context): string[] {
240
+ return context.messages.map((m: Message) => sha256(rawMessageSignature(m)));
241
+ }
242
+
243
+ /** True when `existing` is a (possibly equal) PREFIX of `params`. */
244
+ export function hasPrefix(existing: string[], params: string[]): boolean {
245
+ if (existing.length > params.length) return false;
246
+ for (let i = 0; i < existing.length; i++) {
247
+ if (existing[i] !== params[i]) return false;
248
+ }
249
+ return true;
250
+ }
251
+
252
+ /**
253
+ * Compatible = same cwd + same model + same config signature + the candidate's
254
+ * message signatures are a prefix of the new turn's. A carrier drift changes the
255
+ * signature → incompatible. An edited/compacted/reordered history breaks the
256
+ * prefix → incompatible (so delta-only is never sent against divergent history).
257
+ */
258
+ export function isCompatible(candidate: SessionCompatFacts, params: SessionCompatFacts): boolean {
259
+ return (
260
+ candidate.cwd === params.cwd &&
261
+ candidate.modelId === params.modelId &&
262
+ candidate.bridgeConfigSignature === params.bridgeConfigSignature &&
263
+ hasPrefix(candidate.contextMessageSignatures, params.contextMessageSignatures)
264
+ );
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // 2) bootstrap decision (bootstrapPath ⟂ lifecyclePolicy)
269
+ // ---------------------------------------------------------------------------
270
+
271
+ /**
272
+ * Decide the bootstrap path. Pure except for the fail-loud model-lock throw.
273
+ *
274
+ * - `turn-scoped` (pi -p one-shot): ALWAYS `new`. Never an in-memory reuse (a
275
+ * surviving child would pin pi's exit), and — for the first cut — never a
276
+ * persisted resume/load either (a turn-scoped persisted path is a later lane).
277
+ * - `process-scoped` (long-lived): in-memory reuse if compatible+alive; else a
278
+ * compatible persisted record resumes/loads (by capability); else `new`. A
279
+ * live model mismatch is a fail-loud throw at the bridge boundary (a second
280
+ * guard beyond v2 model-lock). An incompatible existing also invalidates the
281
+ * persisted record (it can no longer be trusted to continue this session).
282
+ */
283
+ export function decideBootstrap(
284
+ params: BootstrapParams,
285
+ sources: {
286
+ existing?: ExistingSession;
287
+ persisted?: SessionRecord;
288
+ capabilities?: { resumeSession: boolean; loadSession: boolean };
289
+ } = {},
290
+ ): BootstrapDecision {
291
+ const { existing, persisted, capabilities } = sources;
292
+
293
+ // Fail-loud model lock: a LIVE session never silently re-targets its model.
294
+ if (existing?.alive && existing.modelId !== params.modelId) {
295
+ throw new SessionModelLockedError(existing.modelId, params.modelId);
296
+ }
297
+
298
+ if (params.lifecyclePolicy === "turn-scoped") {
299
+ return { path: "new", reason: "turn_scoped_one_shot" };
300
+ }
301
+
302
+ // process-scoped
303
+ if (existing?.alive && isCompatible(existing, params)) {
304
+ return { path: "reuse", reason: "in_memory_compatible" };
305
+ }
306
+
307
+ // An incompatible existing (alive but drifted) can no longer anchor this
308
+ // session — drop its persisted record too. A dead-but-compatible existing
309
+ // leaves the persisted record intact so it can still resume.
310
+ const existingIncompatible = !!existing && !isCompatible(existing, params);
311
+
312
+ if (!existingIncompatible && persisted && isCompatible(persisted, params)) {
313
+ if (capabilities?.resumeSession) {
314
+ return { path: "resume", acpSessionId: persisted.acpSessionId, reason: "persisted_compatible_resume" };
315
+ }
316
+ if (capabilities?.loadSession) {
317
+ return { path: "load", acpSessionId: persisted.acpSessionId, reason: "persisted_compatible_load" };
318
+ }
319
+ return { path: "new", reason: "persisted_compatible_no_capability" };
320
+ }
321
+
322
+ return {
323
+ path: "new",
324
+ invalidatePersisted: existingIncompatible || (!!persisted && !isCompatible(persisted, params)),
325
+ reason: existingIncompatible
326
+ ? "existing_incompatible"
327
+ : persisted
328
+ ? "persisted_incompatible"
329
+ : "no_compatible_source",
330
+ };
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // 3) record build + persistence (clock injected — pure build, fs I/O thin)
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /** Build a record from compat facts. `now` is injected so the build is pure. */
338
+ export function buildSessionRecord(
339
+ input: { sessionKey: string; acpSessionId: string } & SessionCompatFacts,
340
+ now: string,
341
+ ): SessionRecord {
342
+ return {
343
+ version: SESSION_RECORD_VERSION,
344
+ provider: SESSION_RECORD_PROVIDER,
345
+ sessionKey: input.sessionKey,
346
+ acpSessionId: input.acpSessionId,
347
+ cwd: input.cwd,
348
+ modelId: input.modelId,
349
+ bridgeConfigSignature: input.bridgeConfigSignature,
350
+ contextMessageSignatures: [...input.contextMessageSignatures],
351
+ updatedAt: now,
352
+ };
353
+ }
354
+
355
+ function isNonEmptyString(v: unknown): v is string {
356
+ return typeof v === "string" && v.length > 0;
357
+ }
358
+
359
+ /** Validate a parsed JSON object into a SessionRecord, or undefined if invalid. */
360
+ export function parseSessionRecord(raw: unknown, sessionKey: string): SessionRecord | undefined {
361
+ if (!raw || typeof raw !== "object") return undefined;
362
+ const r = raw as Record<string, unknown>;
363
+ if (r.version !== SESSION_RECORD_VERSION) return undefined;
364
+ if (r.provider !== SESSION_RECORD_PROVIDER) return undefined;
365
+ if (r.sessionKey !== sessionKey) return undefined;
366
+ if (!isNonEmptyString(r.acpSessionId)) return undefined;
367
+ if (!isNonEmptyString(r.cwd)) return undefined;
368
+ if (!isNonEmptyString(r.modelId)) return undefined;
369
+ if (!isNonEmptyString(r.bridgeConfigSignature)) return undefined;
370
+ if (!Array.isArray(r.contextMessageSignatures) || !r.contextMessageSignatures.every((s) => typeof s === "string")) {
371
+ return undefined;
372
+ }
373
+ if (!isNonEmptyString(r.updatedAt)) return undefined;
374
+ return {
375
+ version: SESSION_RECORD_VERSION,
376
+ provider: SESSION_RECORD_PROVIDER,
377
+ sessionKey,
378
+ acpSessionId: r.acpSessionId,
379
+ cwd: r.cwd,
380
+ modelId: r.modelId,
381
+ bridgeConfigSignature: r.bridgeConfigSignature,
382
+ contextMessageSignatures: [...(r.contextMessageSignatures as string[])],
383
+ updatedAt: r.updatedAt,
384
+ };
385
+ }
386
+
387
+ export function sessionRecordPath(sessionKey: string, dir: string = SESSION_CACHE_DIR): string {
388
+ // sessionKey is a pi-supplied id; sha256 it so the filename is fixed-length
389
+ // and safe regardless of cwd length / path chars (GPT `c617cb` hardening —
390
+ // the old encodeURIComponent was safe but unbounded in length).
391
+ return join(dir, `${sha256(sessionKey)}.json`);
392
+ }
393
+
394
+ /** Read + validate a persisted record. A corrupt/incompatible file is deleted. */
395
+ export function readSessionRecord(sessionKey: string, dir: string = SESSION_CACHE_DIR): SessionRecord | undefined {
396
+ const filePath = sessionRecordPath(sessionKey, dir);
397
+ if (!existsSync(filePath)) return undefined;
398
+ try {
399
+ const record = parseSessionRecord(JSON.parse(readFileSync(filePath, "utf8")), sessionKey);
400
+ if (!record) {
401
+ deleteSessionRecord(sessionKey, dir);
402
+ return undefined;
403
+ }
404
+ return record;
405
+ } catch {
406
+ deleteSessionRecord(sessionKey, dir);
407
+ return undefined;
408
+ }
409
+ }
410
+
411
+ export function writeSessionRecord(record: SessionRecord, dir: string = SESSION_CACHE_DIR): void {
412
+ mkdirSync(dir, { recursive: true });
413
+ writeFileSync(sessionRecordPath(record.sessionKey, dir), `${JSON.stringify(record, null, 2)}\n`);
414
+ }
415
+
416
+ export function deleteSessionRecord(sessionKey: string, dir: string = SESSION_CACHE_DIR): void {
417
+ rmSync(sessionRecordPath(sessionKey, dir), { force: true });
418
+ }
@@ -0,0 +1,184 @@
1
+ // ACP plugin — Claude tool surface + exclude-tools truthfulness preflight (S2b).
2
+ //
3
+ // Two jobs:
4
+ // 1. The curated Claude tool surface the bridge declares to claude-agent-acp
5
+ // via `_meta.claudeCode.options` (tools / permission allow / disallowed),
6
+ // built by `buildClaudeSessionMeta`.
7
+ // 2. `assertExcludeToolsHonored` — a PREFLIGHT truthfulness check, NOT a wire
8
+ // read. There is no stable ACP surface that reports the backend's ACTUAL
9
+ // tool list (GPT S2b Q2), so the effective config WE supply is the SSOT:
10
+ // if pi has excluded a built-in the backend will still expose, the declared
11
+ // surface diverges from the actual one — we fail fast rather than lie.
12
+ //
13
+ // Claude-only scope (NEXT §스코프). The function keeps a `backend` field so the
14
+ // honest-divergence logic stays explicit and the matrix gate can exercise both
15
+ // the "claude narrows via tools" and "native always exposes" branches, but the
16
+ // only backend this lane supplies is claude.
17
+ //
18
+ // Carrier guard (NEXT §S2-scout 핀1): `buildClaudeSessionMeta` only attaches
19
+ // `_meta.systemPrompt` when a caller passes one. The shipped S2d path passes the
20
+ // tiny non-empty engraving carrier (v1 preset replacement / memory containment);
21
+ // an absent argument remains a true opt-out branch. Rich context is never carried
22
+ // here — it rides the S2d first-user-message augment.
23
+
24
+ /**
25
+ * pi baseline tools (Claude-capitalized) — mirrors what pi advertises as
26
+ * "Available tools:". Lowercase pi names map 1:1; keeping them aligned is the
27
+ * whole point of the truthfulness constraint.
28
+ */
29
+ export const DEFAULT_CLAUDE_TOOLS: readonly string[] = ["Read", "Bash", "Edit", "Write"];
30
+
31
+ /** Permission allow list = the baseline surface + `mcp__*` (bridge MCP auto-allow). */
32
+ export const DEFAULT_CLAUDE_PERMISSION_ALLOW: readonly string[] = [
33
+ "Read(*)",
34
+ "Bash(*)",
35
+ "Edit(*)",
36
+ "Write(*)",
37
+ "mcp__*",
38
+ ];
39
+
40
+ /**
41
+ * Deferred/extra tools the SDK advertises (via the ToolSearch system-reminder)
42
+ * that pi does NOT advertise in its fixed baseline — disallow them so the
43
+ * declared-vs-actual surfaces match. pi's own equivalents cover each
44
+ * (Cron→/schedule, Web→brave-search/summarize, Task+RemoteTrigger→entwurf, …).
45
+ * When the SDK adds a deferred tool, this list must follow.
46
+ */
47
+ export const DEFAULT_CLAUDE_DISALLOWED_TOOLS: readonly string[] = [
48
+ "AskUserQuestion",
49
+ "CronCreate",
50
+ "CronDelete",
51
+ "CronList",
52
+ "EnterPlanMode",
53
+ "EnterWorktree",
54
+ "ExitPlanMode",
55
+ "ExitWorktree",
56
+ "Monitor",
57
+ "NotebookEdit",
58
+ "PushNotification",
59
+ "RemoteTrigger",
60
+ "TaskCreate",
61
+ "TaskGet",
62
+ "TaskList",
63
+ "TaskOutput",
64
+ "TaskStop",
65
+ "TaskUpdate",
66
+ "WebFetch",
67
+ "WebSearch",
68
+ ];
69
+
70
+ /**
71
+ * pi built-in tool names (lowercase) that map 1:1 onto a backend capability the
72
+ * ACP child ALWAYS provides. Extension tools (entwurf_v2, entwurf_peers, …) are
73
+ * pi-side and never reach the backend — excluding THEM is honest, so they are
74
+ * deliberately not listed here.
75
+ */
76
+ export const PI_BUILTIN_BACKED_TOOLS: readonly string[] = ["read", "bash", "edit", "write"];
77
+
78
+ /** The effective backend tool surface — the SSOT for the truthfulness preflight. */
79
+ export interface ResolvedToolSurface {
80
+ backend: "claude" | (string & {});
81
+ /** What the backend will receive (Claude-capitalized for the claude backend). */
82
+ tools: readonly string[];
83
+ }
84
+
85
+ /**
86
+ * Fail-fast on a tool-surface lie. If pi has excluded a built-in the backend
87
+ * will still expose, the declared surface diverges from the actual one — reject
88
+ * up front instead of telling the model a tool is gone while the backend can
89
+ * still run it. Pure function (no IO).
90
+ *
91
+ * - Claude: backend builtins = `resolved.tools` ∩ the pi-backed builtin set
92
+ * (lowercased). Drop `Read` from `tools` and excluding `read` becomes honest.
93
+ * - Other backends: assumed to always expose the full builtin set natively.
94
+ */
95
+ export function assertExcludeToolsHonored(activeToolNames: readonly string[], resolved: ResolvedToolSurface): void {
96
+ const active = new Set(activeToolNames);
97
+ const backendBuiltins =
98
+ resolved.backend === "claude"
99
+ ? resolved.tools.map((t) => t.toLowerCase()).filter((t) => PI_BUILTIN_BACKED_TOOLS.includes(t))
100
+ : [...PI_BUILTIN_BACKED_TOOLS];
101
+ const unhonored = backendBuiltins.filter((t) => !active.has(t));
102
+ if (unhonored.length > 0) {
103
+ const many = unhonored.length > 1;
104
+ throw new Error(
105
+ `entwurf cannot honor --exclude-tools (${unhonored.join(", ")}) on the ${resolved.backend} backend: ` +
106
+ `the backend CLI still exposes ${many ? "these capabilities" : "this capability"} natively, so excluding ` +
107
+ `${many ? "them" : "it"} from pi's surface would make the declared tool set diverge from what the backend can ` +
108
+ `actually do. Restrict ${many ? "them" : "it"} via the backend's own tool config instead` +
109
+ (resolved.backend === "claude" ? " (provider settings 'tools' / 'disallowedTools')" : "") +
110
+ ". Extension tools (entwurf_v2, entwurf_peers) can be excluded freely — they are pi-side and never reach the backend.",
111
+ );
112
+ }
113
+ }
114
+
115
+ /** Inputs to the Claude `_meta` builder. */
116
+ export interface ClaudeSessionMetaParams {
117
+ modelId?: string;
118
+ tools: readonly string[];
119
+ permissionAllow: readonly string[];
120
+ disallowedTools: readonly string[];
121
+ settingSources: readonly string[];
122
+ strictMcpConfig: boolean;
123
+ skillPlugins: readonly string[];
124
+ }
125
+
126
+ /**
127
+ * Build the `_meta` object handed to `newSession` for a Claude ACP session.
128
+ *
129
+ * `normalizedSystemPrompt` is OPTIONAL. The SHIPPED default supplies it (the
130
+ * non-empty v1 engraving): a string carrier makes claude-agent-acp REPLACE its
131
+ * `claude_code` preset, stripping auto-memory (see engraving.ts). When absent
132
+ * (operator opt-out — emptied engraving), the result carries NO `systemPrompt`
133
+ * key and claude-agent-acp keeps its default preset. Either way the carrier stays
134
+ * SHORT so it never grows past the SDK-default size (NEXT §S2-scout 핀1). Rich
135
+ * identity/context rides a first-user-message prepend, never this carrier.
136
+ */
137
+ export function buildClaudeSessionMeta(
138
+ params: ClaudeSessionMetaParams,
139
+ normalizedSystemPrompt?: string,
140
+ ): Record<string, unknown> {
141
+ const claudeCodeOptions: Record<string, unknown> = {
142
+ ...(params.modelId ? { model: params.modelId } : {}),
143
+ tools: [...params.tools],
144
+ settingSources: [...params.settingSources],
145
+ settings: {
146
+ permissions: {
147
+ allow: [...params.permissionAllow],
148
+ },
149
+ // Auto-memory containment, defense-in-depth (Detour C). The overlay's
150
+ // settings.json also pins `autoMemoryEnabled:false`, but production runs the
151
+ // query in SDK filesystem-isolation mode (`settingSources: []`), so that
152
+ // on-disk copy is never loaded — claude-agent-acp forwards our options
153
+ // verbatim (acp-agent.js: `...userProvidedOptions`) and the SDK skips
154
+ // ~/.claude/settings.json. This INLINE settings layer is independent of
155
+ // `settingSources`, so it IS honored — the live seal. Backstop only: the
156
+ // primary write-containment lever is the non-empty engraving carrier
157
+ // replacing the claude_code preset (which strips the auto-memory
158
+ // advertisement the model would otherwise act on). "knows-but-can't" here vs
159
+ // the carrier's "doesn't-know" — keeping both means a future preset/SDK
160
+ // change cannot silently re-open memory through a channel the strip misses.
161
+ autoMemoryEnabled: false,
162
+ },
163
+ };
164
+ if (params.skillPlugins.length > 0) {
165
+ claudeCodeOptions.plugins = params.skillPlugins.map((path) => ({ type: "local", path }));
166
+ }
167
+ // Only emit when non-empty so `disallowedTools: []` opts fully out of the
168
+ // bridge's deferred-tool muting (the agent's own AskUserQuestion mute still
169
+ // applies — that is claude-agent-acp's call, not ours).
170
+ if (params.disallowedTools.length > 0) {
171
+ claudeCodeOptions.disallowedTools = [...params.disallowedTools];
172
+ }
173
+ if (params.strictMcpConfig) {
174
+ claudeCodeOptions.extraArgs = { "strict-mcp-config": null };
175
+ }
176
+
177
+ const meta: Record<string, unknown> = {
178
+ claudeCode: { options: claudeCodeOptions },
179
+ };
180
+ if (normalizedSystemPrompt) {
181
+ meta.systemPrompt = normalizedSystemPrompt;
182
+ }
183
+ return meta;
184
+ }