@junghanacs/entwurf 0.12.5 → 0.12.7

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 (93) hide show
  1. package/AGENTS.md +31 -17
  2. package/BASELINE.md +42 -8
  3. package/CHANGELOG.md +44 -0
  4. package/DELIVERY.md +71 -14
  5. package/README.md +77 -45
  6. package/VERIFY.md +33 -38
  7. package/docs/setup-clean-host.md +80 -22
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
  30. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
  31. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
  33. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
  34. package/mcp/entwurf-bridge/src/index.ts +101 -67
  35. package/mcp/entwurf-bridge/test.sh +1 -1
  36. package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
  37. package/package.json +10 -6
  38. package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
  39. package/pi-extensions/lib/entwurf-self-address.ts +58 -15
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
  41. package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
  42. package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
  43. package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
  44. package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
  45. package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
  46. package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
  48. package/pi-extensions/lib/meta-sender-identity.ts +154 -0
  49. package/pi-extensions/lib/native-push/adapter.ts +255 -0
  50. package/pi-extensions/lib/native-push/register.ts +99 -0
  51. package/run.sh +974 -248
  52. package/scripts/agy-bridge-config.py +446 -0
  53. package/scripts/agy-bridge.sh +359 -0
  54. package/scripts/agy-hooks-bridge.sh +193 -0
  55. package/scripts/agy-hooks-config.py +257 -0
  56. package/scripts/agy-imprint.sh +28 -0
  57. package/scripts/agy-imprint.ts +193 -0
  58. package/scripts/agy-statusline-bridge.sh +176 -0
  59. package/scripts/agy-statusline-config.py +213 -0
  60. package/scripts/agy-statusline.sh +256 -0
  61. package/scripts/build-bridge.sh +20 -0
  62. package/scripts/check-agy-sender-identity.ts +364 -0
  63. package/scripts/check-entwurf-bridge-boot.ts +8 -2
  64. package/scripts/check-entwurf-deliverability.ts +34 -0
  65. package/scripts/check-entwurf-self-address.ts +78 -11
  66. package/scripts/check-entwurf-v2-contract.ts +136 -1
  67. package/scripts/check-entwurf-v2-decider.ts +95 -1
  68. package/scripts/check-entwurf-v2-matrix.ts +14 -3
  69. package/scripts/check-entwurf-v2-native-push.ts +193 -0
  70. package/scripts/check-entwurf-v2-production.ts +68 -1
  71. package/scripts/check-entwurf-v2-runner.ts +58 -0
  72. package/scripts/check-entwurf-v2-surface.ts +35 -0
  73. package/scripts/check-install-surface.ts +357 -0
  74. package/scripts/check-native-push-adapter.ts +319 -0
  75. package/scripts/check-native-push-register.ts +130 -0
  76. package/scripts/dev-bin.sh +195 -0
  77. package/scripts/doctor-pi-provider.ts +140 -0
  78. package/scripts/meta-bridge-doctor.sh +47 -9
  79. package/scripts/meta-bridge-install.sh +20 -17
  80. package/scripts/meta-bridge-state.py +40 -2
  81. package/scripts/meta-bridge-uninstall.sh +23 -12
  82. package/scripts/register-pi-package.py +183 -0
  83. package/scripts/register-pi-provider.py +287 -0
  84. package/scripts/smoke-agy-hooks-state.sh +172 -0
  85. package/scripts/smoke-agy-install-state.sh +660 -0
  86. package/scripts/smoke-agy-native-push-live.ts +243 -0
  87. package/scripts/smoke-agy-statusline-state.sh +300 -0
  88. package/scripts/smoke-meta-async-drift.sh +9 -2
  89. package/scripts/smoke-meta-install-state.sh +178 -8
  90. package/scripts/smoke-pi-provider-state.sh +182 -0
  91. package/scripts/smoke-user-scope-citizen.sh +191 -0
  92. package/scripts/with-dist-lock.sh +81 -0
  93. package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
@@ -0,0 +1,436 @@
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
+ import { createHash } from "node:crypto";
36
+ import { existsSync, readFileSync, statSync } from "node:fs";
37
+ import { homedir } from "node:os";
38
+ import { isAbsolute, join } from "node:path";
39
+ // Defaults are mirrored as local constants (NOT imported from tool-surface.ts):
40
+ // the strip-types gate loads this file by its `.ts` source and cannot resolve a
41
+ // cross-sibling VALUE import. check-acp-config + check-acp-tool-surface keep the
42
+ // two in lockstep behaviorally; a drift would diverge the resolved default
43
+ // surface from the declared one.
44
+ const DEFAULT_TOOLS = ["Read", "Bash", "Edit", "Write"];
45
+ const DEFAULT_PERMISSION_ALLOW = ["Read(*)", "Bash(*)", "Edit(*)", "Write(*)", "mcp__*"];
46
+ const DEFAULT_DISALLOWED_TOOLS = [
47
+ "AskUserQuestion",
48
+ "CronCreate",
49
+ "CronDelete",
50
+ "CronList",
51
+ "EnterPlanMode",
52
+ "EnterWorktree",
53
+ "ExitPlanMode",
54
+ "ExitWorktree",
55
+ "Monitor",
56
+ "NotebookEdit",
57
+ "PushNotification",
58
+ "RemoteTrigger",
59
+ "TaskCreate",
60
+ "TaskGet",
61
+ "TaskList",
62
+ "TaskOutput",
63
+ "TaskStop",
64
+ "TaskUpdate",
65
+ "WebFetch",
66
+ "WebSearch",
67
+ ];
68
+ const GLOBAL_SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
69
+ /** Thrown when one or more `mcpServers` entries are structurally invalid. */
70
+ export class McpServerConfigError extends Error {
71
+ issues;
72
+ constructor(issues) {
73
+ super(`Invalid entwurfProvider.mcpServers:\n${issues.map((i) => ` - ${i.server}: ${i.reason}`).join("\n")}`);
74
+ this.name = "McpServerConfigError";
75
+ this.issues = issues;
76
+ }
77
+ }
78
+ /** A settings-file validation error (non-mcpServers fields). */
79
+ export function settingsConfigError(filePath, message) {
80
+ return new Error(`${filePath}: invalid entwurfProvider settings: ${message}`);
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // mcpServers normalization (pure)
84
+ // ---------------------------------------------------------------------------
85
+ function validateKvEntries(server, field, input, issues) {
86
+ if (input === undefined)
87
+ return [];
88
+ const entries = [];
89
+ if (Array.isArray(input)) {
90
+ for (let i = 0; i < input.length; i++) {
91
+ const kv = input[i];
92
+ if (!kv || typeof kv !== "object" || Array.isArray(kv)) {
93
+ issues.push({ server, reason: `"${field}[${i}]" must be an object` });
94
+ return undefined;
95
+ }
96
+ const pair = kv;
97
+ if (typeof pair.name !== "string" || typeof pair.value !== "string") {
98
+ issues.push({ server, reason: `"${field}[${i}]" must have string "name" and "value"` });
99
+ return undefined;
100
+ }
101
+ entries.push({ name: pair.name, value: pair.value });
102
+ }
103
+ }
104
+ else if (typeof input === "object") {
105
+ for (const [name, value] of Object.entries(input)) {
106
+ if (typeof value !== "string") {
107
+ issues.push({ server, reason: `"${field}.${name}" must be a string` });
108
+ return undefined;
109
+ }
110
+ entries.push({ name, value });
111
+ }
112
+ }
113
+ else {
114
+ issues.push({ server, reason: `"${field}" must be an object or array of {name,value}` });
115
+ return undefined;
116
+ }
117
+ // Sort by name so a caller-side ordering difference never drifts the hash.
118
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
119
+ return entries;
120
+ }
121
+ function normalizeMcpServerEntry(name, raw, issues) {
122
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
123
+ issues.push({ server: name, reason: "server entry must be an object" });
124
+ return undefined;
125
+ }
126
+ const obj = raw;
127
+ const declaredType = obj.type;
128
+ let type;
129
+ if (declaredType === undefined) {
130
+ type = "stdio";
131
+ }
132
+ else if (declaredType === "stdio" || declaredType === "http" || declaredType === "sse") {
133
+ type = declaredType;
134
+ }
135
+ else {
136
+ issues.push({
137
+ server: name,
138
+ reason: `unsupported "type" ${JSON.stringify(declaredType)} (expected "stdio" | "http" | "sse")`,
139
+ });
140
+ return undefined;
141
+ }
142
+ if (type === "http" || type === "sse") {
143
+ const url = obj.url;
144
+ if (typeof url !== "string" || url.length === 0) {
145
+ issues.push({ server: name, reason: `${type} server requires non-empty "url"` });
146
+ return undefined;
147
+ }
148
+ const headers = validateKvEntries(name, "headers", obj.headers, issues);
149
+ if (headers === undefined)
150
+ return undefined;
151
+ return { type, name, url, headers };
152
+ }
153
+ const command = obj.command;
154
+ if (typeof command !== "string" || command.length === 0) {
155
+ issues.push({ server: name, reason: `stdio server requires non-empty "command"` });
156
+ return undefined;
157
+ }
158
+ let args = [];
159
+ if (obj.args !== undefined) {
160
+ if (!Array.isArray(obj.args)) {
161
+ issues.push({ server: name, reason: `"args" must be a string array` });
162
+ return undefined;
163
+ }
164
+ for (let i = 0; i < obj.args.length; i++) {
165
+ if (typeof obj.args[i] !== "string") {
166
+ issues.push({ server: name, reason: `"args[${i}]" must be a string` });
167
+ return undefined;
168
+ }
169
+ }
170
+ args = obj.args;
171
+ }
172
+ const env = validateKvEntries(name, "env", obj.env, issues);
173
+ if (env === undefined)
174
+ return undefined;
175
+ return { name, command, args, env };
176
+ }
177
+ /**
178
+ * Normalize an `mcpServers` map into a SORTED, validated server list plus a
179
+ * sha256 hash of its canonical JSON. Pure. Throws McpServerConfigError listing
180
+ * every bad entry (so a typo names the offending server, not just "invalid").
181
+ */
182
+ export function normalizeMcpServers(input) {
183
+ if (input === undefined || input === null) {
184
+ return { servers: [], hash: createHash("sha256").update("[]").digest("hex") };
185
+ }
186
+ if (typeof input !== "object" || Array.isArray(input)) {
187
+ throw new McpServerConfigError([
188
+ {
189
+ server: "<root>",
190
+ reason: `mcpServers must be an object (got ${Array.isArray(input) ? "array" : typeof input})`,
191
+ },
192
+ ]);
193
+ }
194
+ const issues = [];
195
+ const servers = [];
196
+ for (const name of Object.keys(input).sort()) {
197
+ const entry = normalizeMcpServerEntry(name, input[name], issues);
198
+ if (entry)
199
+ servers.push(entry);
200
+ }
201
+ if (issues.length > 0)
202
+ throw new McpServerConfigError(issues);
203
+ const canonical = JSON.stringify(servers);
204
+ return { servers, hash: createHash("sha256").update(canonical).digest("hex") };
205
+ }
206
+ /**
207
+ * Inject the per-session entwurf envelope (PI_SESSION_ID + PI_AGENT_ID) into the
208
+ * `entwurf-bridge` stdio MCP entry so the bridge's MCP child can resolve the
209
+ * caller identity (entwurf_self / entwurf_v2). Runs AFTER the config hash is
210
+ * taken — this is runtime wiring, not config, so a new session id alone must not
211
+ * invalidate a reused session. http/sse have no env carrier; other stdio servers
212
+ * are left untouched. Pre-existing PI_SESSION_ID/PI_AGENT_ID env are filtered so
213
+ * the bridge-supplied values always win (mid-session model switch surfaces the
214
+ * new PI_AGENT_ID at the next spawn).
215
+ */
216
+ export function enrichMcpServersWithEnvelope(servers, envelope) {
217
+ const piAgentId = envelope.modelId ? `entwurf/${envelope.modelId}` : undefined;
218
+ const piSessionId = envelope.piSessionId;
219
+ if (!piSessionId && !piAgentId)
220
+ return [...servers];
221
+ return servers.map((s) => {
222
+ if ("type" in s && (s.type === "http" || s.type === "sse"))
223
+ return s;
224
+ if (s.name !== "entwurf-bridge")
225
+ return s;
226
+ const stdio = s;
227
+ const baseEnv = stdio.env.filter((e) => e.name !== "PI_SESSION_ID" && e.name !== "PI_AGENT_ID");
228
+ const extras = [];
229
+ if (piSessionId)
230
+ extras.push({ name: "PI_SESSION_ID", value: piSessionId });
231
+ if (piAgentId)
232
+ extras.push({ name: "PI_AGENT_ID", value: piAgentId });
233
+ return { ...stdio, env: [...baseEnv, ...extras] };
234
+ });
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // settings file parse + validate
238
+ // ---------------------------------------------------------------------------
239
+ function assertOptionalBoolean(settings, key, filePath) {
240
+ const value = settings[key];
241
+ if (value === undefined)
242
+ return undefined;
243
+ if (typeof value !== "boolean")
244
+ throw settingsConfigError(filePath, `${key} must be a boolean`);
245
+ return value;
246
+ }
247
+ function parseStringArray(settings, key, filePath) {
248
+ const value = settings[key];
249
+ if (value === undefined)
250
+ return undefined;
251
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
252
+ throw settingsConfigError(filePath, `${key} must be an array of strings`);
253
+ }
254
+ return value;
255
+ }
256
+ /**
257
+ * Validate `skillPlugins` paths: each must be an absolute path to an existing
258
+ * directory containing `.claude-plugin/plugin.json`. The Claude Agent SDK
259
+ * silently drops anything else at spawn time — leaving the operator's skill
260
+ * invisible with no failure signal — so we fail fast here instead (oracle G).
261
+ */
262
+ export function validateSkillPluginPaths(paths, filePath) {
263
+ for (let index = 0; index < paths.length; index++) {
264
+ const pluginPath = paths[index];
265
+ const label = `skillPlugins[${index}]`;
266
+ if (!isAbsolute(pluginPath)) {
267
+ throw settingsConfigError(filePath, `${label} must be an absolute path (got ${JSON.stringify(pluginPath)})`);
268
+ }
269
+ let isDir = false;
270
+ try {
271
+ isDir = statSync(pluginPath).isDirectory();
272
+ }
273
+ catch {
274
+ throw settingsConfigError(filePath, `${label} does not exist: ${pluginPath}`);
275
+ }
276
+ if (!isDir)
277
+ throw settingsConfigError(filePath, `${label} must point at a directory: ${pluginPath}`);
278
+ const manifestPath = join(pluginPath, ".claude-plugin", "plugin.json");
279
+ if (!existsSync(manifestPath)) {
280
+ throw settingsConfigError(filePath, `${label} is missing .claude-plugin/plugin.json — expected ${manifestPath}. ` +
281
+ `See README §Custom Skills for the minimum plugin shape.`);
282
+ }
283
+ }
284
+ }
285
+ /**
286
+ * Read + validate the `entwurfProvider` block of one settings file. Returns BOTH
287
+ * the typed (common) `settings` and the `raw` untyped block — the raw block feeds
288
+ * `adapter.resolveAdapterSettings` so a backend can read its OWN keys (which the
289
+ * typed ProviderSettings deliberately drops). Missing file or absent block → empty
290
+ * both. Malformed JSON / wrong shapes throw a settingsConfigError naming the file.
291
+ */
292
+ export function readProviderSettingsFile(filePath) {
293
+ if (!existsSync(filePath))
294
+ return { settings: {}, raw: {} };
295
+ let parsed;
296
+ try {
297
+ parsed = JSON.parse(readFileSync(filePath, "utf8"));
298
+ }
299
+ catch (error) {
300
+ throw settingsConfigError(filePath, `malformed JSON (${error instanceof Error ? error.message : String(error)})`);
301
+ }
302
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
303
+ throw settingsConfigError(filePath, "settings file root must be an object");
304
+ }
305
+ const block = parsed.entwurfProvider;
306
+ if (block === undefined)
307
+ return { settings: {}, raw: {} };
308
+ if (!block || typeof block !== "object" || Array.isArray(block)) {
309
+ throw settingsConfigError(filePath, "entwurfProvider must be an object");
310
+ }
311
+ const settings = block;
312
+ // `backend` is the operator's DECLARED backend — a diagnostic guard, NOT the
313
+ // routing authority (the curated model-id prefix is; see backend-adapter.ts).
314
+ // config.ts validates only that it is a string and does NOT whitelist values:
315
+ // the adapter registry owns the set of valid backends, so a new backend never
316
+ // needs a config.ts edit. The semantic cross-check (declared backend must match
317
+ // the adapter the model id routes to) lives at the routing site in backend.ts —
318
+ // keeping config a pure syntactic parser and the model id the SINGLE routing
319
+ // authority (no duplicate authority).
320
+ const backendRaw = settings.backend;
321
+ if (backendRaw !== undefined && typeof backendRaw !== "string") {
322
+ throw settingsConfigError(filePath, "backend must be a string");
323
+ }
324
+ const settingSourcesRaw = settings.settingSources;
325
+ let settingSources;
326
+ if (settingSourcesRaw !== undefined) {
327
+ if (!Array.isArray(settingSourcesRaw))
328
+ throw settingsConfigError(filePath, "settingSources must be an array");
329
+ if (!settingSourcesRaw.every((v) => v === "user" || v === "project" || v === "local")) {
330
+ throw settingsConfigError(filePath, "settingSources entries must be one of: user, project, local");
331
+ }
332
+ settingSources = settingSourcesRaw;
333
+ }
334
+ const mcpServersRaw = settings.mcpServers;
335
+ let mcpServers;
336
+ if (mcpServersRaw !== undefined) {
337
+ if (!mcpServersRaw || typeof mcpServersRaw !== "object" || Array.isArray(mcpServersRaw)) {
338
+ throw settingsConfigError(filePath, "mcpServers must be an object");
339
+ }
340
+ mcpServers = mcpServersRaw;
341
+ }
342
+ const skillPlugins = parseStringArray(settings, "skillPlugins", filePath);
343
+ if (skillPlugins)
344
+ validateSkillPluginPaths(skillPlugins, filePath);
345
+ return {
346
+ settings: {
347
+ backend: backendRaw?.trim() || undefined,
348
+ appendSystemPrompt: assertOptionalBoolean(settings, "appendSystemPrompt", filePath),
349
+ settingSources,
350
+ strictMcpConfig: assertOptionalBoolean(settings, "strictMcpConfig", filePath),
351
+ showToolNotifications: assertOptionalBoolean(settings, "showToolNotifications", filePath),
352
+ mcpServers,
353
+ tools: parseStringArray(settings, "tools", filePath),
354
+ skillPlugins,
355
+ permissionAllow: parseStringArray(settings, "permissionAllow", filePath),
356
+ disallowedTools: parseStringArray(settings, "disallowedTools", filePath),
357
+ },
358
+ raw: settings,
359
+ };
360
+ }
361
+ /**
362
+ * Resolve the effective Claude provider config from global + project settings.
363
+ *
364
+ * Merge: project overrides global, but ONLY for keys the project actually sets
365
+ * (an absent key must not nuke the global value). `mcpServers` is a special
366
+ * shallow MERGE — `{...global, ...project}` — so a project adds/overrides
367
+ * individual servers without dropping the global set.
368
+ *
369
+ * Fail-loud: `appendSystemPrompt: true` and `strictMcpConfig: false` are
370
+ * unsupported on this lane (see file header).
371
+ */
372
+ export function resolveProviderConfig(params) {
373
+ const globalPath = params.globalSettingsPath ?? GLOBAL_SETTINGS_PATH;
374
+ const projectPath = params.projectSettingsPath ?? join(params.cwd, ".pi", "settings.json");
375
+ const { settings: globalSettings, raw: globalRaw } = readProviderSettingsFile(globalPath);
376
+ const { settings: projectSettings, raw: projectRaw } = readProviderSettingsFile(projectPath);
377
+ // Project overrides global only for keys it actually defines (undefined =
378
+ // "unset", which JS spread would otherwise treat as an override).
379
+ const projectDefined = Object.fromEntries(Object.entries(projectSettings).filter(([, v]) => v !== undefined));
380
+ const merged = { ...globalSettings, ...projectDefined };
381
+ if (merged.appendSystemPrompt === true) {
382
+ throw settingsConfigError(projectSettings.appendSystemPrompt !== undefined ? projectPath : globalPath, "appendSystemPrompt: true is unsupported on acp-on-v2 — rich context rides the first-user augment and the " +
383
+ "tiny system-prompt carrier is the engraving (ENTWURF_ACP_ENGRAVING_PATH). Remove the key.");
384
+ }
385
+ const strictMcpConfig = merged.strictMcpConfig ?? true;
386
+ if (strictMcpConfig === false) {
387
+ throw settingsConfigError(projectSettings.strictMcpConfig !== undefined ? projectPath : globalPath, "strictMcpConfig: false is unsupported on acp-on-v2 — ambient MCP inheritance (~/.mcp.json, project .mcp.json, " +
388
+ "~/.claude settings) is disallowed (Hard Rule #4). Declare every server explicitly under mcpServers.");
389
+ }
390
+ const settingSources = merged.settingSources ?? [];
391
+ const showToolNotifications = merged.showToolNotifications ?? true;
392
+ const skillPlugins = merged.skillPlugins ?? [];
393
+ const baseTools = merged.tools ?? [...DEFAULT_TOOLS];
394
+ const baseAllow = merged.permissionAllow ?? [...DEFAULT_PERMISSION_ALLOW];
395
+ // When skillPlugins is non-empty the SDK's skill-listing emitter is gated on
396
+ // `tools.some(name === "Skill")` — without it the listing returns empty and
397
+ // skills never reach the system prompt, even though the plugin loaded them.
398
+ // Auto-allow `Skill(*)` too so the listing surface is not denied at the
399
+ // permission layer (oracle G, verified against claude-agent-sdk 0.2.114/119).
400
+ const tools = skillPlugins.length > 0 && !baseTools.includes("Skill") ? [...baseTools, "Skill"] : baseTools;
401
+ const permissionAllow = skillPlugins.length > 0 && !baseAllow.includes("Skill(*)") ? [...baseAllow, "Skill(*)"] : baseAllow;
402
+ const disallowedTools = merged.disallowedTools ?? [...DEFAULT_DISALLOWED_TOOLS];
403
+ // mcpServers: shallow per-name merge across global + project, then normalize.
404
+ const mergedMcpServersRaw = {
405
+ ...(globalSettings.mcpServers ?? {}),
406
+ ...(projectSettings.mcpServers ?? {}),
407
+ };
408
+ const { servers: mcpServers, hash: mcpServersHash } = normalizeMcpServers(mergedMcpServersRaw);
409
+ // Backend-OWNED settings seam: the routed adapter parses its own keys off the RAW
410
+ // blocks (project-over-global merge). The result is opaque to config.ts and backend.ts
411
+ // — only the adapter's own methods read it. claude returns undefined (no own settings).
412
+ const adapterSettings = params.adapter.resolveAdapterSettings({
413
+ globalBlock: globalRaw,
414
+ projectBlock: projectRaw,
415
+ mergedBlock: { ...globalRaw, ...projectRaw },
416
+ globalPath,
417
+ projectPath,
418
+ });
419
+ return {
420
+ backend: merged.backend,
421
+ settingSources,
422
+ strictMcpConfig,
423
+ showToolNotifications,
424
+ mcpServers,
425
+ mcpServersHash,
426
+ tools,
427
+ skillPlugins,
428
+ permissionAllow,
429
+ disallowedTools,
430
+ adapterSettings,
431
+ };
432
+ }
433
+ /** The MCP server names exposed to a session (for engraving + augment). */
434
+ export function mcpServerNames(config) {
435
+ return config.mcpServers.map((s) => s.name);
436
+ }
@@ -0,0 +1,157 @@
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
+ // MUST equal event-mapper.ts `LIFECYCLE_NOTICE_SIGNATURE` (the SSOT/producer).
20
+ // It is mirrored, not imported: the strip-types deterministic gates load these
21
+ // lib files by their `.ts` source and cannot resolve a cross-sibling VALUE
22
+ // import (`./event-mapper.js`) — the lib modules share TYPES only. The mirror is
23
+ // kept honest behaviorally by check-acp-session-reuse (a drift would leave the
24
+ // `[acp: …]` notices in the captured ACP prompt and fail the gate).
25
+ const LIFECYCLE_NOTICE_SIGNATURE = "entwurf:lifecycle-notice-v1";
26
+ function textFromUserOrToolContent(content) {
27
+ if (typeof content === "string")
28
+ return content;
29
+ // Render text verbatim; images are NOT dropped silently — they leave a text
30
+ // marker so the transcript honestly records an attachment the text-only S2c
31
+ // transcript cannot carry (real ACP image passthrough is a later lane).
32
+ return content
33
+ .map((c) => {
34
+ if (c.type === "text")
35
+ return c.text;
36
+ if (c.type === "image")
37
+ return `[image omitted: ${c.mimeType ?? "unknown"}]`;
38
+ return "";
39
+ })
40
+ .filter((s) => s !== "")
41
+ .join("\n");
42
+ }
43
+ function textFromAssistantContent(content) {
44
+ // Assistant text only — thinking is omitted and tool calls are not replayed
45
+ // (the ACP child executes its own tools; replaying structured calls would be
46
+ // a lie). Tool RESULTS still appear via their own toolResult message below.
47
+ return (content
48
+ .filter((c) => c.type === "text")
49
+ // Drop S2f lifecycle progress notices (`[acp: …]`): display-only, stamped
50
+ // with LIFECYCLE_NOTICE_SIGNATURE. Replaying them into a `new` rebuild's
51
+ // full transcript would inject bridge-internal chatter into the ACP prompt.
52
+ .filter((c) => c.textSignature !== LIFECYCLE_NOTICE_SIGNATURE)
53
+ .map((c) => c.text)
54
+ .join(""));
55
+ }
56
+ /** Render one pi message as a transcript line, or undefined to skip it. */
57
+ function renderMessage(message) {
58
+ switch (message.role) {
59
+ case "user": {
60
+ const text = textFromUserOrToolContent(message.content).trim();
61
+ return text ? `User: ${text}` : undefined;
62
+ }
63
+ case "assistant": {
64
+ const text = textFromAssistantContent(message.content).trim();
65
+ return text ? `Assistant: ${text}` : undefined;
66
+ }
67
+ case "toolResult": {
68
+ const text = textFromUserOrToolContent(message.content).trim();
69
+ const tag = message.isError ? "Tool error" : "Tool result";
70
+ return text ? `${tag} (${message.toolName}): ${text}` : undefined;
71
+ }
72
+ default:
73
+ return undefined;
74
+ }
75
+ }
76
+ /**
77
+ * Flatten a pi Context into a single transcript string. Excludes
78
+ * `context.systemPrompt` and `context.tools` by construction.
79
+ */
80
+ export function contextTranscript(context) {
81
+ const lines = [];
82
+ for (const message of context.messages) {
83
+ const line = renderMessage(message);
84
+ if (line)
85
+ lines.push(line);
86
+ }
87
+ return lines.join("\n\n");
88
+ }
89
+ /**
90
+ * Convert a pi Context into the ACP `prompt` array (a single text block holding
91
+ * the flattened transcript). Empty history yields an empty array — the caller
92
+ * decides whether that is a hard error.
93
+ */
94
+ export function contextToAcpPrompt(context) {
95
+ const transcript = contextTranscript(context);
96
+ if (!transcript)
97
+ return [];
98
+ return [{ type: "text", text: transcript }];
99
+ }
100
+ /**
101
+ * The latest user turn only — the first user message AFTER the last assistant
102
+ * message. Ported from 0.11.0 `index.ts:732 extractPromptBlocks`: taking the
103
+ * FIRST user of the trailing group (not `reverse().find()`) skips the
104
+ * SessionStart hook user-message (`device=…, time_kst=…`) that pi appends AFTER
105
+ * the real prompt. Images leave a text marker (S2c decision — real ACP image
106
+ * passthrough is a later lane), never raw data.
107
+ *
108
+ * This is the prompt scope for a session that ALREADY holds the prior turns
109
+ * (reuse/resume/load): re-sending the whole transcript there would duplicate
110
+ * history the backend already remembers.
111
+ */
112
+ export function latestUserDelta(context) {
113
+ let lastAssistantIdx = -1;
114
+ for (let i = context.messages.length - 1; i >= 0; i--) {
115
+ if (context.messages[i].role === "assistant") {
116
+ lastAssistantIdx = i;
117
+ break;
118
+ }
119
+ }
120
+ const latestUser = context.messages.slice(lastAssistantIdx + 1).find((m) => m.role === "user");
121
+ if (!latestUser)
122
+ return [];
123
+ // The delta IS the user's actual prompt — preserve its body verbatim (0.11.0
124
+ // extractPromptBlocks sent it near-raw). Only the EMPTINESS test trims, so a
125
+ // whitespace-only turn yields no block.
126
+ const raw = textFromUserOrToolContent(latestUser.content);
127
+ return raw.trim() ? [{ type: "text", text: raw }] : [];
128
+ }
129
+ /**
130
+ * Build the ACP prompt array for a turn, scoping it by bootstrapPath (핀4):
131
+ * - `"new"` (incompatible rebuild included): a fresh ACP session holds NO
132
+ * history, so the whole transcript is the only history carrier (same as the
133
+ * S2c spawn-per-turn path). Delta-only here would lose history on
134
+ * rebuild/compaction/edited-history.
135
+ * - `"reuse" | "resume" | "load"`: the stateful ACP session already holds the
136
+ * prior turns, so send only the latest user delta — the whole transcript
137
+ * would duplicate remembered history.
138
+ *
139
+ * The delta-only SAFETY for resume/load is owned by the caller's
140
+ * `contextMessageSignatures` prefix-compat gate (mismatch → fall back to
141
+ * `"new"` + full transcript); this pure function only splits the scope.
142
+ */
143
+ export function buildAcpPrompt(context, bootstrapPath) {
144
+ switch (bootstrapPath) {
145
+ case "new":
146
+ return contextToAcpPrompt(context);
147
+ case "reuse":
148
+ case "resume":
149
+ case "load":
150
+ return latestUserDelta(context);
151
+ default:
152
+ // Fail-loud (핀4): a bad/unknown bootstrapPath must CRASH, never fall
153
+ // through to delta-only. A silent delta on a path that should carry the
154
+ // full transcript loses history — fail-OPEN toward the dangerous side.
155
+ throw new Error(`buildAcpPrompt: unknown bootstrapPath ${JSON.stringify(bootstrapPath)}`);
156
+ }
157
+ }