@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.
- package/AGENTS.md +31 -17
- package/BASELINE.md +42 -8
- package/CHANGELOG.md +44 -0
- package/DELIVERY.md +71 -14
- package/README.md +77 -45
- package/VERIFY.md +33 -38
- package/docs/setup-clean-host.md +80 -22
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
- package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
- package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
- package/mcp/entwurf-bridge/src/index.ts +101 -67
- package/mcp/entwurf-bridge/test.sh +1 -1
- package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
- package/package.json +10 -6
- package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
- package/pi-extensions/lib/entwurf-self-address.ts +58 -15
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
- package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
- package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
- package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
- package/pi-extensions/lib/meta-sender-identity.ts +154 -0
- package/pi-extensions/lib/native-push/adapter.ts +255 -0
- package/pi-extensions/lib/native-push/register.ts +99 -0
- package/run.sh +974 -248
- package/scripts/agy-bridge-config.py +446 -0
- package/scripts/agy-bridge.sh +359 -0
- package/scripts/agy-hooks-bridge.sh +193 -0
- package/scripts/agy-hooks-config.py +257 -0
- package/scripts/agy-imprint.sh +28 -0
- package/scripts/agy-imprint.ts +193 -0
- package/scripts/agy-statusline-bridge.sh +176 -0
- package/scripts/agy-statusline-config.py +213 -0
- package/scripts/agy-statusline.sh +256 -0
- package/scripts/build-bridge.sh +20 -0
- package/scripts/check-agy-sender-identity.ts +364 -0
- package/scripts/check-entwurf-bridge-boot.ts +8 -2
- package/scripts/check-entwurf-deliverability.ts +34 -0
- package/scripts/check-entwurf-self-address.ts +78 -11
- package/scripts/check-entwurf-v2-contract.ts +136 -1
- package/scripts/check-entwurf-v2-decider.ts +95 -1
- package/scripts/check-entwurf-v2-matrix.ts +14 -3
- package/scripts/check-entwurf-v2-native-push.ts +193 -0
- package/scripts/check-entwurf-v2-production.ts +68 -1
- package/scripts/check-entwurf-v2-runner.ts +58 -0
- package/scripts/check-entwurf-v2-surface.ts +35 -0
- package/scripts/check-install-surface.ts +357 -0
- package/scripts/check-native-push-adapter.ts +319 -0
- package/scripts/check-native-push-register.ts +130 -0
- package/scripts/dev-bin.sh +195 -0
- package/scripts/doctor-pi-provider.ts +140 -0
- package/scripts/meta-bridge-doctor.sh +47 -9
- package/scripts/meta-bridge-install.sh +20 -17
- package/scripts/meta-bridge-state.py +40 -2
- package/scripts/meta-bridge-uninstall.sh +23 -12
- package/scripts/register-pi-package.py +183 -0
- package/scripts/register-pi-provider.py +287 -0
- package/scripts/smoke-agy-hooks-state.sh +172 -0
- package/scripts/smoke-agy-install-state.sh +660 -0
- package/scripts/smoke-agy-native-push-live.ts +243 -0
- package/scripts/smoke-agy-statusline-state.sh +300 -0
- package/scripts/smoke-meta-async-drift.sh +9 -2
- package/scripts/smoke-meta-install-state.sh +178 -8
- package/scripts/smoke-pi-provider-state.sh +182 -0
- package/scripts/smoke-user-scope-citizen.sh +191 -0
- package/scripts/with-dist-lock.sh +81 -0
- 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
|
+
}
|