@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,552 @@
1
+ // ACP plugin — operator provider-config loader (S2g).
2
+ //
3
+ // THE BASELINE FIX. Before S2g, backend.ts hardcoded `mcpServers:[]`,
4
+ // `settingSources:[]`, `tools:DEFAULT_CLAUDE_TOOLS`, `skillPlugins:[]` — so an
5
+ // operator who wrote `entwurfProvider.mcpServers` / `skillPlugins` into their
6
+ // `.pi/settings.json` (the very surface `./run.sh install` wires up) saw the ACP
7
+ // model boot with 4 tools and no MCP/skills. This module reads that operator
8
+ // config and hands it to the backend so the documented passthrough actually
9
+ // reaches `newSession`.
10
+ //
11
+ // Claude-only scope (NEXT §스코프). Ported from the v0.11.0 behavior oracle
12
+ // (index.ts `loadProviderSettings`/`readSettingsFile` + acp-bridge.ts
13
+ // `normalizeMcpServers`/`enrichMcpServersWithEnvelope`) — structure new, behavior
14
+ // preserved. Codex/Gemini fields (codexDisabledFeatures, …) are out of scope and
15
+ // deliberately NOT carried.
16
+ //
17
+ // PURITY / SIGNATURE contract (NEXT oracle C / 핀1 / GPT `…2f9325` boost):
18
+ // - `normalizeMcpServers` is pure: a SORTED, validated server list + a sha256
19
+ // `hash` of its canonical JSON. The HASH (not the name list) is what feeds
20
+ // `bridgeConfigSignature`, so a change to a server's command/args/env/url/
21
+ // headers — not just its name — invalidates a reused session.
22
+ // - `enrichMcpServersWithEnvelope` injects the per-session PI_SESSION_ID /
23
+ // PI_AGENT_ID into the entwurf-bridge stdio entry. It runs AFTER the hash is
24
+ // taken (runtime wiring, not config), so a new session id alone never forces
25
+ // a rebuild.
26
+ //
27
+ // FAIL-LOUD (GPT `…2f9325`): a config the bridge cannot honor must error before
28
+ // the session spawns, never silently degrade (the "warnings make agents flail"
29
+ // anti-pattern):
30
+ // - bad mcpServers entry → McpServerConfigError (names the server)
31
+ // - bad skillPlugins path/manifest → settingsConfigError
32
+ // - appendSystemPrompt: true → unsupported (rich context = first-user
33
+ // augment; tiny carrier = engraving)
34
+ // - strictMcpConfig: false → unsupported (Hard Rule #4: no ambient MCP)
35
+
36
+ import { createHash } from "node:crypto";
37
+ import { existsSync, readFileSync, statSync } from "node:fs";
38
+ import { homedir } from "node:os";
39
+ import { isAbsolute, join } from "node:path";
40
+
41
+ import type { AcpBackendAdapter } from "./backend-adapter.js";
42
+
43
+ /** A normalized key/value pair (env var or HTTP header) — ACP wire shape. */
44
+ export interface AcpKeyValue {
45
+ name: string;
46
+ value: string;
47
+ }
48
+
49
+ /** A normalized MCP server entry — structurally the ACP SDK `McpServer`. */
50
+ export type AcpMcpServer =
51
+ | { name: string; command: string; args: string[]; env: AcpKeyValue[] }
52
+ | { type: "http"; name: string; url: string; headers: AcpKeyValue[] }
53
+ | { type: "sse"; name: string; url: string; headers: AcpKeyValue[] };
54
+
55
+ export type ClaudeSettingSource = "user" | "project" | "local";
56
+
57
+ /** The raw, parsed `entwurfProvider` block (every field optional). */
58
+ export interface ProviderSettings {
59
+ /** Operator-declared backend — a DIAGNOSTIC guard, not the routing authority.
60
+ * The curated model-id prefix routes (backend-adapter.ts); this is only
61
+ * cross-checked against the routed adapter in backend.ts. */
62
+ backend?: string;
63
+ appendSystemPrompt?: boolean;
64
+ settingSources?: ClaudeSettingSource[];
65
+ strictMcpConfig?: boolean;
66
+ showToolNotifications?: boolean;
67
+ mcpServers?: Record<string, unknown>;
68
+ tools?: string[];
69
+ skillPlugins?: string[];
70
+ permissionAllow?: string[];
71
+ disallowedTools?: string[];
72
+ }
73
+
74
+ /** The fully-resolved Claude provider config the backend hands to newSession. */
75
+ export interface ResolvedAcpConfig {
76
+ /** Operator-declared backend (diagnostic guard only — see ProviderSettings.backend).
77
+ * backend.ts asserts this matches the adapter the model id routes to. */
78
+ backend?: string;
79
+ settingSources: ClaudeSettingSource[];
80
+ strictMcpConfig: boolean;
81
+ showToolNotifications: boolean;
82
+ /** Normalized + SORTED MCP servers (NOT envelope-enriched — that is runtime). */
83
+ mcpServers: AcpMcpServer[];
84
+ /** sha256 of the canonical normalized server list — feeds the config signature. */
85
+ mcpServersHash: string;
86
+ tools: string[];
87
+ skillPlugins: string[];
88
+ permissionAllow: string[];
89
+ disallowedTools: string[];
90
+ /** Opaque, backend-OWNED settings produced by `adapter.resolveAdapterSettings`.
91
+ * backend.ts NEVER inspects this; only the routed adapter's methods read it
92
+ * (casting their own type). This is the ONE seam that keeps backend-specific
93
+ * keys (e.g. a connection id) OFF the common config. `undefined` for a backend
94
+ * with no own settings (e.g. claude). */
95
+ adapterSettings: unknown;
96
+ }
97
+
98
+ // Defaults are mirrored as local constants (NOT imported from tool-surface.ts):
99
+ // the strip-types gate loads this file by its `.ts` source and cannot resolve a
100
+ // cross-sibling VALUE import. check-acp-config + check-acp-tool-surface keep the
101
+ // two in lockstep behaviorally; a drift would diverge the resolved default
102
+ // surface from the declared one.
103
+ const DEFAULT_TOOLS: readonly string[] = ["Read", "Bash", "Edit", "Write"];
104
+ const DEFAULT_PERMISSION_ALLOW: readonly string[] = ["Read(*)", "Bash(*)", "Edit(*)", "Write(*)", "mcp__*"];
105
+ const DEFAULT_DISALLOWED_TOOLS: readonly string[] = [
106
+ "AskUserQuestion",
107
+ "CronCreate",
108
+ "CronDelete",
109
+ "CronList",
110
+ "EnterPlanMode",
111
+ "EnterWorktree",
112
+ "ExitPlanMode",
113
+ "ExitWorktree",
114
+ "Monitor",
115
+ "NotebookEdit",
116
+ "PushNotification",
117
+ "RemoteTrigger",
118
+ "TaskCreate",
119
+ "TaskGet",
120
+ "TaskList",
121
+ "TaskOutput",
122
+ "TaskStop",
123
+ "TaskUpdate",
124
+ "WebFetch",
125
+ "WebSearch",
126
+ ];
127
+
128
+ const GLOBAL_SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // errors
132
+ // ---------------------------------------------------------------------------
133
+
134
+ export interface McpServerConfigIssue {
135
+ server: string;
136
+ reason: string;
137
+ }
138
+
139
+ /** Thrown when one or more `mcpServers` entries are structurally invalid. */
140
+ export class McpServerConfigError extends Error {
141
+ readonly issues: McpServerConfigIssue[];
142
+ constructor(issues: McpServerConfigIssue[]) {
143
+ super(`Invalid entwurfProvider.mcpServers:\n${issues.map((i) => ` - ${i.server}: ${i.reason}`).join("\n")}`);
144
+ this.name = "McpServerConfigError";
145
+ this.issues = issues;
146
+ }
147
+ }
148
+
149
+ /** A settings-file validation error (non-mcpServers fields). */
150
+ export function settingsConfigError(filePath: string, message: string): Error {
151
+ return new Error(`${filePath}: invalid entwurfProvider settings: ${message}`);
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // mcpServers normalization (pure)
156
+ // ---------------------------------------------------------------------------
157
+
158
+ function validateKvEntries(
159
+ server: string,
160
+ field: "env" | "headers",
161
+ input: unknown,
162
+ issues: McpServerConfigIssue[],
163
+ ): AcpKeyValue[] | undefined {
164
+ if (input === undefined) return [];
165
+ const entries: AcpKeyValue[] = [];
166
+ if (Array.isArray(input)) {
167
+ for (let i = 0; i < input.length; i++) {
168
+ const kv = input[i];
169
+ if (!kv || typeof kv !== "object" || Array.isArray(kv)) {
170
+ issues.push({ server, reason: `"${field}[${i}]" must be an object` });
171
+ return undefined;
172
+ }
173
+ const pair = kv as { name?: unknown; value?: unknown };
174
+ if (typeof pair.name !== "string" || typeof pair.value !== "string") {
175
+ issues.push({ server, reason: `"${field}[${i}]" must have string "name" and "value"` });
176
+ return undefined;
177
+ }
178
+ entries.push({ name: pair.name, value: pair.value });
179
+ }
180
+ } else if (typeof input === "object") {
181
+ for (const [name, value] of Object.entries(input as Record<string, unknown>)) {
182
+ if (typeof value !== "string") {
183
+ issues.push({ server, reason: `"${field}.${name}" must be a string` });
184
+ return undefined;
185
+ }
186
+ entries.push({ name, value });
187
+ }
188
+ } else {
189
+ issues.push({ server, reason: `"${field}" must be an object or array of {name,value}` });
190
+ return undefined;
191
+ }
192
+ // Sort by name so a caller-side ordering difference never drifts the hash.
193
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
194
+ return entries;
195
+ }
196
+
197
+ function normalizeMcpServerEntry(name: string, raw: unknown, issues: McpServerConfigIssue[]): AcpMcpServer | undefined {
198
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
199
+ issues.push({ server: name, reason: "server entry must be an object" });
200
+ return undefined;
201
+ }
202
+ const obj = raw as Record<string, unknown>;
203
+ const declaredType = obj.type;
204
+ let type: "stdio" | "http" | "sse";
205
+ if (declaredType === undefined) {
206
+ type = "stdio";
207
+ } else if (declaredType === "stdio" || declaredType === "http" || declaredType === "sse") {
208
+ type = declaredType;
209
+ } else {
210
+ issues.push({
211
+ server: name,
212
+ reason: `unsupported "type" ${JSON.stringify(declaredType)} (expected "stdio" | "http" | "sse")`,
213
+ });
214
+ return undefined;
215
+ }
216
+
217
+ if (type === "http" || type === "sse") {
218
+ const url = obj.url;
219
+ if (typeof url !== "string" || url.length === 0) {
220
+ issues.push({ server: name, reason: `${type} server requires non-empty "url"` });
221
+ return undefined;
222
+ }
223
+ const headers = validateKvEntries(name, "headers", obj.headers, issues);
224
+ if (headers === undefined) return undefined;
225
+ return { type, name, url, headers };
226
+ }
227
+
228
+ const command = obj.command;
229
+ if (typeof command !== "string" || command.length === 0) {
230
+ issues.push({ server: name, reason: `stdio server requires non-empty "command"` });
231
+ return undefined;
232
+ }
233
+ let args: string[] = [];
234
+ if (obj.args !== undefined) {
235
+ if (!Array.isArray(obj.args)) {
236
+ issues.push({ server: name, reason: `"args" must be a string array` });
237
+ return undefined;
238
+ }
239
+ for (let i = 0; i < obj.args.length; i++) {
240
+ if (typeof obj.args[i] !== "string") {
241
+ issues.push({ server: name, reason: `"args[${i}]" must be a string` });
242
+ return undefined;
243
+ }
244
+ }
245
+ args = obj.args as string[];
246
+ }
247
+ const env = validateKvEntries(name, "env", obj.env, issues);
248
+ if (env === undefined) return undefined;
249
+ return { name, command, args, env };
250
+ }
251
+
252
+ /**
253
+ * Normalize an `mcpServers` map into a SORTED, validated server list plus a
254
+ * sha256 hash of its canonical JSON. Pure. Throws McpServerConfigError listing
255
+ * every bad entry (so a typo names the offending server, not just "invalid").
256
+ */
257
+ export function normalizeMcpServers(input: Record<string, unknown> | undefined): {
258
+ servers: AcpMcpServer[];
259
+ hash: string;
260
+ } {
261
+ if (input === undefined || input === null) {
262
+ return { servers: [], hash: createHash("sha256").update("[]").digest("hex") };
263
+ }
264
+ if (typeof input !== "object" || Array.isArray(input)) {
265
+ throw new McpServerConfigError([
266
+ {
267
+ server: "<root>",
268
+ reason: `mcpServers must be an object (got ${Array.isArray(input) ? "array" : typeof input})`,
269
+ },
270
+ ]);
271
+ }
272
+ const issues: McpServerConfigIssue[] = [];
273
+ const servers: AcpMcpServer[] = [];
274
+ for (const name of Object.keys(input).sort()) {
275
+ const entry = normalizeMcpServerEntry(name, (input as Record<string, unknown>)[name], issues);
276
+ if (entry) servers.push(entry);
277
+ }
278
+ if (issues.length > 0) throw new McpServerConfigError(issues);
279
+ const canonical = JSON.stringify(servers);
280
+ return { servers, hash: createHash("sha256").update(canonical).digest("hex") };
281
+ }
282
+
283
+ /**
284
+ * Inject the per-session entwurf envelope (PI_SESSION_ID + PI_AGENT_ID) into the
285
+ * `entwurf-bridge` stdio MCP entry so the bridge's MCP child can resolve the
286
+ * caller identity (entwurf_self / entwurf_v2). Runs AFTER the config hash is
287
+ * taken — this is runtime wiring, not config, so a new session id alone must not
288
+ * invalidate a reused session. http/sse have no env carrier; other stdio servers
289
+ * are left untouched. Pre-existing PI_SESSION_ID/PI_AGENT_ID env are filtered so
290
+ * the bridge-supplied values always win (mid-session model switch surfaces the
291
+ * new PI_AGENT_ID at the next spawn).
292
+ */
293
+ export function enrichMcpServersWithEnvelope(
294
+ servers: readonly AcpMcpServer[],
295
+ envelope: { modelId?: string; piSessionId?: string },
296
+ ): AcpMcpServer[] {
297
+ const piAgentId = envelope.modelId ? `entwurf/${envelope.modelId}` : undefined;
298
+ const piSessionId = envelope.piSessionId;
299
+ if (!piSessionId && !piAgentId) return [...servers];
300
+ return servers.map((s) => {
301
+ if ("type" in s && (s.type === "http" || s.type === "sse")) return s;
302
+ if (s.name !== "entwurf-bridge") return s;
303
+ const stdio = s as { name: string; command: string; args: string[]; env: AcpKeyValue[] };
304
+ const baseEnv = stdio.env.filter((e) => e.name !== "PI_SESSION_ID" && e.name !== "PI_AGENT_ID");
305
+ const extras: AcpKeyValue[] = [];
306
+ if (piSessionId) extras.push({ name: "PI_SESSION_ID", value: piSessionId });
307
+ if (piAgentId) extras.push({ name: "PI_AGENT_ID", value: piAgentId });
308
+ return { ...stdio, env: [...baseEnv, ...extras] };
309
+ });
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // settings file parse + validate
314
+ // ---------------------------------------------------------------------------
315
+
316
+ function assertOptionalBoolean(settings: Record<string, unknown>, key: string, filePath: string): boolean | undefined {
317
+ const value = settings[key];
318
+ if (value === undefined) return undefined;
319
+ if (typeof value !== "boolean") throw settingsConfigError(filePath, `${key} must be a boolean`);
320
+ return value;
321
+ }
322
+
323
+ function parseStringArray(settings: Record<string, unknown>, key: string, filePath: string): string[] | undefined {
324
+ const value = settings[key];
325
+ if (value === undefined) return undefined;
326
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
327
+ throw settingsConfigError(filePath, `${key} must be an array of strings`);
328
+ }
329
+ return value as string[];
330
+ }
331
+
332
+ /**
333
+ * Validate `skillPlugins` paths: each must be an absolute path to an existing
334
+ * directory containing `.claude-plugin/plugin.json`. The Claude Agent SDK
335
+ * silently drops anything else at spawn time — leaving the operator's skill
336
+ * invisible with no failure signal — so we fail fast here instead (oracle G).
337
+ */
338
+ export function validateSkillPluginPaths(paths: readonly string[], filePath: string): void {
339
+ for (let index = 0; index < paths.length; index++) {
340
+ const pluginPath = paths[index];
341
+ const label = `skillPlugins[${index}]`;
342
+ if (!isAbsolute(pluginPath)) {
343
+ throw settingsConfigError(filePath, `${label} must be an absolute path (got ${JSON.stringify(pluginPath)})`);
344
+ }
345
+ let isDir = false;
346
+ try {
347
+ isDir = statSync(pluginPath).isDirectory();
348
+ } catch {
349
+ throw settingsConfigError(filePath, `${label} does not exist: ${pluginPath}`);
350
+ }
351
+ if (!isDir) throw settingsConfigError(filePath, `${label} must point at a directory: ${pluginPath}`);
352
+ const manifestPath = join(pluginPath, ".claude-plugin", "plugin.json");
353
+ if (!existsSync(manifestPath)) {
354
+ throw settingsConfigError(
355
+ filePath,
356
+ `${label} is missing .claude-plugin/plugin.json — expected ${manifestPath}. ` +
357
+ `See README §Custom Skills for the minimum plugin shape.`,
358
+ );
359
+ }
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Read + validate the `entwurfProvider` block of one settings file. Returns BOTH
365
+ * the typed (common) `settings` and the `raw` untyped block — the raw block feeds
366
+ * `adapter.resolveAdapterSettings` so a backend can read its OWN keys (which the
367
+ * typed ProviderSettings deliberately drops). Missing file or absent block → empty
368
+ * both. Malformed JSON / wrong shapes throw a settingsConfigError naming the file.
369
+ */
370
+ export function readProviderSettingsFile(filePath: string): {
371
+ settings: ProviderSettings;
372
+ raw: Record<string, unknown>;
373
+ } {
374
+ if (!existsSync(filePath)) return { settings: {}, raw: {} };
375
+ let parsed: unknown;
376
+ try {
377
+ parsed = JSON.parse(readFileSync(filePath, "utf8"));
378
+ } catch (error) {
379
+ throw settingsConfigError(filePath, `malformed JSON (${error instanceof Error ? error.message : String(error)})`);
380
+ }
381
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
382
+ throw settingsConfigError(filePath, "settings file root must be an object");
383
+ }
384
+ const block = (parsed as Record<string, unknown>).entwurfProvider;
385
+ if (block === undefined) return { settings: {}, raw: {} };
386
+ if (!block || typeof block !== "object" || Array.isArray(block)) {
387
+ throw settingsConfigError(filePath, "entwurfProvider must be an object");
388
+ }
389
+ const settings = block as Record<string, unknown>;
390
+
391
+ // `backend` is the operator's DECLARED backend — a diagnostic guard, NOT the
392
+ // routing authority (the curated model-id prefix is; see backend-adapter.ts).
393
+ // config.ts validates only that it is a string and does NOT whitelist values:
394
+ // the adapter registry owns the set of valid backends, so a new backend never
395
+ // needs a config.ts edit. The semantic cross-check (declared backend must match
396
+ // the adapter the model id routes to) lives at the routing site in backend.ts —
397
+ // keeping config a pure syntactic parser and the model id the SINGLE routing
398
+ // authority (no duplicate authority).
399
+ const backendRaw = settings.backend;
400
+ if (backendRaw !== undefined && typeof backendRaw !== "string") {
401
+ throw settingsConfigError(filePath, "backend must be a string");
402
+ }
403
+
404
+ const settingSourcesRaw = settings.settingSources;
405
+ let settingSources: ClaudeSettingSource[] | undefined;
406
+ if (settingSourcesRaw !== undefined) {
407
+ if (!Array.isArray(settingSourcesRaw)) throw settingsConfigError(filePath, "settingSources must be an array");
408
+ if (!settingSourcesRaw.every((v) => v === "user" || v === "project" || v === "local")) {
409
+ throw settingsConfigError(filePath, "settingSources entries must be one of: user, project, local");
410
+ }
411
+ settingSources = settingSourcesRaw as ClaudeSettingSource[];
412
+ }
413
+
414
+ const mcpServersRaw = settings.mcpServers;
415
+ let mcpServers: Record<string, unknown> | undefined;
416
+ if (mcpServersRaw !== undefined) {
417
+ if (!mcpServersRaw || typeof mcpServersRaw !== "object" || Array.isArray(mcpServersRaw)) {
418
+ throw settingsConfigError(filePath, "mcpServers must be an object");
419
+ }
420
+ mcpServers = mcpServersRaw as Record<string, unknown>;
421
+ }
422
+
423
+ const skillPlugins = parseStringArray(settings, "skillPlugins", filePath);
424
+ if (skillPlugins) validateSkillPluginPaths(skillPlugins, filePath);
425
+
426
+ return {
427
+ settings: {
428
+ backend: backendRaw?.trim() || undefined,
429
+ appendSystemPrompt: assertOptionalBoolean(settings, "appendSystemPrompt", filePath),
430
+ settingSources,
431
+ strictMcpConfig: assertOptionalBoolean(settings, "strictMcpConfig", filePath),
432
+ showToolNotifications: assertOptionalBoolean(settings, "showToolNotifications", filePath),
433
+ mcpServers,
434
+ tools: parseStringArray(settings, "tools", filePath),
435
+ skillPlugins,
436
+ permissionAllow: parseStringArray(settings, "permissionAllow", filePath),
437
+ disallowedTools: parseStringArray(settings, "disallowedTools", filePath),
438
+ },
439
+ raw: settings,
440
+ };
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // resolve (merge global + project, apply defaults, fail-loud on unsupported)
445
+ // ---------------------------------------------------------------------------
446
+
447
+ export interface ResolveProviderConfigParams {
448
+ cwd: string;
449
+ modelId: string;
450
+ /** The adapter the model id already routed to (resolved ONCE at turn entry in
451
+ * backend.ts and threaded here — config.ts never re-routes, so the model id stays
452
+ * the single routing authority). Used only to parse this backend's own settings. */
453
+ adapter: AcpBackendAdapter;
454
+ /** Override the global settings path (tests). Defaults to ~/.pi/agent/settings.json. */
455
+ globalSettingsPath?: string;
456
+ /** Override the project settings path (tests). Defaults to <cwd>/.pi/settings.json. */
457
+ projectSettingsPath?: string;
458
+ }
459
+
460
+ /**
461
+ * Resolve the effective Claude provider config from global + project settings.
462
+ *
463
+ * Merge: project overrides global, but ONLY for keys the project actually sets
464
+ * (an absent key must not nuke the global value). `mcpServers` is a special
465
+ * shallow MERGE — `{...global, ...project}` — so a project adds/overrides
466
+ * individual servers without dropping the global set.
467
+ *
468
+ * Fail-loud: `appendSystemPrompt: true` and `strictMcpConfig: false` are
469
+ * unsupported on this lane (see file header).
470
+ */
471
+ export function resolveProviderConfig(params: ResolveProviderConfigParams): ResolvedAcpConfig {
472
+ const globalPath = params.globalSettingsPath ?? GLOBAL_SETTINGS_PATH;
473
+ const projectPath = params.projectSettingsPath ?? join(params.cwd, ".pi", "settings.json");
474
+ const { settings: globalSettings, raw: globalRaw } = readProviderSettingsFile(globalPath);
475
+ const { settings: projectSettings, raw: projectRaw } = readProviderSettingsFile(projectPath);
476
+
477
+ // Project overrides global only for keys it actually defines (undefined =
478
+ // "unset", which JS spread would otherwise treat as an override).
479
+ const projectDefined = Object.fromEntries(
480
+ Object.entries(projectSettings).filter(([, v]) => v !== undefined),
481
+ ) as ProviderSettings;
482
+ const merged = { ...globalSettings, ...projectDefined };
483
+
484
+ if (merged.appendSystemPrompt === true) {
485
+ throw settingsConfigError(
486
+ projectSettings.appendSystemPrompt !== undefined ? projectPath : globalPath,
487
+ "appendSystemPrompt: true is unsupported on acp-on-v2 — rich context rides the first-user augment and the " +
488
+ "tiny system-prompt carrier is the engraving (ENTWURF_ACP_ENGRAVING_PATH). Remove the key.",
489
+ );
490
+ }
491
+
492
+ const strictMcpConfig = merged.strictMcpConfig ?? true;
493
+ if (strictMcpConfig === false) {
494
+ throw settingsConfigError(
495
+ projectSettings.strictMcpConfig !== undefined ? projectPath : globalPath,
496
+ "strictMcpConfig: false is unsupported on acp-on-v2 — ambient MCP inheritance (~/.mcp.json, project .mcp.json, " +
497
+ "~/.claude settings) is disallowed (Hard Rule #4). Declare every server explicitly under mcpServers.",
498
+ );
499
+ }
500
+
501
+ const settingSources = merged.settingSources ?? [];
502
+ const showToolNotifications = merged.showToolNotifications ?? true;
503
+ const skillPlugins = merged.skillPlugins ?? [];
504
+ const baseTools = merged.tools ?? [...DEFAULT_TOOLS];
505
+ const baseAllow = merged.permissionAllow ?? [...DEFAULT_PERMISSION_ALLOW];
506
+ // When skillPlugins is non-empty the SDK's skill-listing emitter is gated on
507
+ // `tools.some(name === "Skill")` — without it the listing returns empty and
508
+ // skills never reach the system prompt, even though the plugin loaded them.
509
+ // Auto-allow `Skill(*)` too so the listing surface is not denied at the
510
+ // permission layer (oracle G, verified against claude-agent-sdk 0.2.114/119).
511
+ const tools = skillPlugins.length > 0 && !baseTools.includes("Skill") ? [...baseTools, "Skill"] : baseTools;
512
+ const permissionAllow =
513
+ skillPlugins.length > 0 && !baseAllow.includes("Skill(*)") ? [...baseAllow, "Skill(*)"] : baseAllow;
514
+ const disallowedTools = merged.disallowedTools ?? [...DEFAULT_DISALLOWED_TOOLS];
515
+
516
+ // mcpServers: shallow per-name merge across global + project, then normalize.
517
+ const mergedMcpServersRaw: Record<string, unknown> = {
518
+ ...(globalSettings.mcpServers ?? {}),
519
+ ...(projectSettings.mcpServers ?? {}),
520
+ };
521
+ const { servers: mcpServers, hash: mcpServersHash } = normalizeMcpServers(mergedMcpServersRaw);
522
+
523
+ // Backend-OWNED settings seam: the routed adapter parses its own keys off the RAW
524
+ // blocks (project-over-global merge). The result is opaque to config.ts and backend.ts
525
+ // — only the adapter's own methods read it. claude returns undefined (no own settings).
526
+ const adapterSettings = params.adapter.resolveAdapterSettings({
527
+ globalBlock: globalRaw,
528
+ projectBlock: projectRaw,
529
+ mergedBlock: { ...globalRaw, ...projectRaw },
530
+ globalPath,
531
+ projectPath,
532
+ });
533
+
534
+ return {
535
+ backend: merged.backend,
536
+ settingSources,
537
+ strictMcpConfig,
538
+ showToolNotifications,
539
+ mcpServers,
540
+ mcpServersHash,
541
+ tools,
542
+ skillPlugins,
543
+ permissionAllow,
544
+ disallowedTools,
545
+ adapterSettings,
546
+ };
547
+ }
548
+
549
+ /** The MCP server names exposed to a session (for engraving + augment). */
550
+ export function mcpServerNames(config: ResolvedAcpConfig): string[] {
551
+ return config.mcpServers.map((s) => s.name);
552
+ }