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