@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.
- package/AGENTS.md +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- 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
|
+
}
|