@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
|
@@ -50,8 +50,11 @@ import { receiverMarkerMatchesIdentity } from "../../../pi-extensions/lib/entwur
|
|
|
50
50
|
import { listEntwurfFacts } from "../../../pi-extensions/lib/entwurf-fact-provider.js";
|
|
51
51
|
import { renderEntwurfPeers } from "../../../pi-extensions/lib/entwurf-peers-render.js";
|
|
52
52
|
import { computeSelfAddressability } from "../../../pi-extensions/lib/entwurf-self-address.js";
|
|
53
|
+
import { nativePushSupported } from "../../../pi-extensions/lib/entwurf-v2-contract.js";
|
|
53
54
|
import { runAndRenderEntwurfV2FromSurface } from "../../../pi-extensions/lib/entwurf-v2-surface.js";
|
|
54
|
-
import {
|
|
55
|
+
import { probeNativeSenderAlive, resolveTrustedMetaSenderIdentity, } from "../../../pi-extensions/lib/meta-sender-identity.js";
|
|
56
|
+
import { defaultMetaMailboxDir, defaultMetaSessionsDir, readMetaInbox, readMetaReceiverMarker, } from "../../../pi-extensions/lib/meta-session.js";
|
|
57
|
+
import { registerNativeConversation } from "../../../pi-extensions/lib/native-push/register.js";
|
|
55
58
|
const HOME = os.homedir();
|
|
56
59
|
const DEFAULT_ENTWURF_DIR = path.join(HOME, ".pi", "entwurf-control");
|
|
57
60
|
const ENTWURF_DIR = process.env.ENTWURF_DIR ?? DEFAULT_ENTWURF_DIR;
|
|
@@ -115,23 +118,6 @@ class EntwurfSenderIdentityError extends Error {
|
|
|
115
118
|
"garden-id is registered, then retry.");
|
|
116
119
|
}
|
|
117
120
|
}
|
|
118
|
-
// Resolve the meta-sender marker for THIS MCP process. ENTWURF_META_SENDER_MARKER is an
|
|
119
|
-
// explicit override (test / wiring). Otherwise try the shared ancestor: process.ppid
|
|
120
|
-
// first, then one step up (Claude may run the hook through a shell wrapper, shifting
|
|
121
|
-
// the shared ancestor). readMetaSenderMarker's pid+start-key guard rejects a
|
|
122
|
-
// dead/reused owner, so a wrong marker is never trusted on any candidate.
|
|
123
|
-
function resolveMetaSenderMarker() {
|
|
124
|
-
const explicit = process.env.ENTWURF_META_SENDER_MARKER?.trim();
|
|
125
|
-
if (explicit)
|
|
126
|
-
return readMetaSenderMarker({ markerPath: explicit });
|
|
127
|
-
const candidates = [process.ppid, parentPid(process.ppid)].filter((p) => typeof p === "number" && p > 0);
|
|
128
|
-
for (const ownerPid of candidates) {
|
|
129
|
-
const marker = readMetaSenderMarker({ backend: "claude-code", ownerPid });
|
|
130
|
-
if (marker)
|
|
131
|
-
return marker;
|
|
132
|
-
}
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
121
|
function buildStrictPiSenderEnvelope() {
|
|
136
122
|
const sessionId = process.env.PI_SESSION_ID?.trim();
|
|
137
123
|
const agentId = process.env.PI_AGENT_ID?.trim();
|
|
@@ -164,49 +150,49 @@ function buildStrictPiSenderEnvelope() {
|
|
|
164
150
|
replyable: self.replyable,
|
|
165
151
|
};
|
|
166
152
|
}
|
|
167
|
-
function buildTrustedMetaSenderEnvelope(cwd = process.cwd()) {
|
|
168
|
-
// No pi-session identity. Try the meta-sender marker: a native backend that
|
|
169
|
-
//
|
|
170
|
-
// shared parent pid — this MCP child's process.ppid IS the
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (!marker)
|
|
176
|
-
return null;
|
|
177
|
-
// Validate the marker against its backing meta-record: a stale marker (record
|
|
178
|
-
// deleted, or backend/nativeSessionId drift) must NOT grant a replyable
|
|
179
|
-
// identity. The record store is the authority; the marker is only a pid→garden
|
|
180
|
-
// hint.
|
|
181
|
-
let identity = null;
|
|
182
|
-
try {
|
|
183
|
-
// dual-read (3D-4 commit1): identity-only check (backend/nativeSessionId), so
|
|
184
|
-
// it survives the v2 cut. Reads both v1 and v2 records.
|
|
185
|
-
const id = readMetaIdentityByGardenId(marker.gardenId);
|
|
186
|
-
if (id.backend === marker.backend && id.nativeSessionId === marker.nativeSessionId)
|
|
187
|
-
identity = id;
|
|
188
|
-
}
|
|
189
|
-
catch {
|
|
190
|
-
identity = null;
|
|
191
|
-
}
|
|
192
|
-
if (!identity)
|
|
193
|
-
return null;
|
|
194
|
-
// SE-2 slice 2e-b: identity is trusted, but `replyable` is a SEPARATE fact — can THIS
|
|
195
|
-
// session's own receiver inbox actually wake? Read the receiver presence marker (slice
|
|
196
|
-
// 2b) and require it to match the identity (the same SSOT helper the mailbox guard uses).
|
|
197
|
-
// recordBacked is true here by construction; ownerAlive+watchArmed BOTH come from the
|
|
198
|
-
// matched receiver marker (readMetaReceiverMarker's verifyOwner folds a dead/reused owner
|
|
199
|
-
// to null, so a match means a live, armed receiver — the sender marker only proves
|
|
200
|
-
// identity, not an armed watch). Inactive → the meta identity is STILL returned (who-sent
|
|
201
|
-
// must survive; degrading to null would erase the sender) but with replyable:false.
|
|
202
|
-
const receiver = readMetaReceiverMarker({ gardenId: identity.gardenId });
|
|
203
|
-
const active = receiverMarkerMatchesIdentity(receiver, identity);
|
|
204
|
-
const self = computeSelfAddressability({
|
|
205
|
-
origin: "meta-session",
|
|
206
|
-
recordBacked: true,
|
|
207
|
-
ownerAlive: active,
|
|
208
|
-
watchArmed: active,
|
|
153
|
+
async function buildTrustedMetaSenderEnvelope(cwd = process.cwd()) {
|
|
154
|
+
// No pi-session identity. Try the meta-sender marker: a native backend that minted a
|
|
155
|
+
// garden-id from its own hook (Claude SessionStart / agy PreInvocation). The marker is
|
|
156
|
+
// keyed by the shared parent pid — this MCP child's process.ppid IS the native host the
|
|
157
|
+
// hook ran under (NOT cwd inference). A trusted marker promotes this process from
|
|
158
|
+
// anonymous external-mcp to a meta-session sender addressed by its garden-id.
|
|
159
|
+
const trusted = resolveTrustedMetaSenderIdentity({
|
|
160
|
+
markerPath: process.env.ENTWURF_META_SENDER_MARKER?.trim() || undefined,
|
|
209
161
|
});
|
|
162
|
+
if (!trusted)
|
|
163
|
+
return null;
|
|
164
|
+
const { marker, identity } = trusted;
|
|
165
|
+
// Identity is trusted — but `replyable` is a SEPARATE fact, and WHICH fact depends on the
|
|
166
|
+
// rail a reply would ride (보정①). The domain comes from nativePushSupported(backend), not
|
|
167
|
+
// from wakeMode: `direct-inject` also covers codex/pi, which have no native-push adapter.
|
|
168
|
+
// self-fetch (claude-code): can this citizen's own inbox wake? → the receiver presence
|
|
169
|
+
// marker (readMetaReceiverMarker folds a dead/reused owner to null, so a match means a
|
|
170
|
+
// live, ARMED receiver — the sender marker proves identity, never an armed watch).
|
|
171
|
+
// native-push (antigravity): there is no inbox and no watch. A reply is injected into a
|
|
172
|
+
// live app-server conversation, so only an adapter probe can answer. Composing the
|
|
173
|
+
// receiver atom here would demand `watchArmed` from a backend that never arms one, and
|
|
174
|
+
// every agy citizen would report replyable:false forever.
|
|
175
|
+
// Either way an inactive/unreachable citizen STILL returns its identity (who-sent must
|
|
176
|
+
// survive; degrading to null would erase the sender) — only with replyable:false.
|
|
177
|
+
const facts = nativePushSupported(identity.backend)
|
|
178
|
+
? {
|
|
179
|
+
origin: "meta-session",
|
|
180
|
+
metaDeliveryDomain: "native-push",
|
|
181
|
+
recordBacked: true,
|
|
182
|
+
probeAlive: await probeNativeSenderAlive(identity),
|
|
183
|
+
}
|
|
184
|
+
: (() => {
|
|
185
|
+
const receiver = readMetaReceiverMarker({ gardenId: identity.gardenId });
|
|
186
|
+
const active = receiverMarkerMatchesIdentity(receiver, identity);
|
|
187
|
+
return {
|
|
188
|
+
origin: "meta-session",
|
|
189
|
+
metaDeliveryDomain: "self-fetch",
|
|
190
|
+
recordBacked: true,
|
|
191
|
+
ownerAlive: active,
|
|
192
|
+
watchArmed: active,
|
|
193
|
+
};
|
|
194
|
+
})();
|
|
195
|
+
const self = computeSelfAddressability(facts);
|
|
210
196
|
return {
|
|
211
197
|
sessionId: identity.gardenId,
|
|
212
198
|
agentId: `meta-session/${identity.backend}`,
|
|
@@ -216,13 +202,15 @@ function buildTrustedMetaSenderEnvelope(cwd = process.cwd()) {
|
|
|
216
202
|
replyable: self.replyable,
|
|
217
203
|
};
|
|
218
204
|
}
|
|
219
|
-
|
|
205
|
+
// async only for the native-push branch's adapter probe: a pi sender and a claude-code
|
|
206
|
+
// sender still resolve from files alone, so their cost is unchanged.
|
|
207
|
+
async function buildAuthoritativeSelfEnvelope() {
|
|
220
208
|
const sessionId = process.env.PI_SESSION_ID?.trim();
|
|
221
209
|
const agentId = process.env.PI_AGENT_ID?.trim();
|
|
222
210
|
const cwd = process.cwd();
|
|
223
211
|
if (sessionId && agentId && cwd)
|
|
224
212
|
return buildStrictPiSenderEnvelope();
|
|
225
|
-
const meta = buildTrustedMetaSenderEnvelope(cwd);
|
|
213
|
+
const meta = await buildTrustedMetaSenderEnvelope(cwd);
|
|
226
214
|
if (meta)
|
|
227
215
|
return meta;
|
|
228
216
|
const missing = [];
|
|
@@ -234,13 +222,13 @@ function buildAuthoritativeSelfEnvelope() {
|
|
|
234
222
|
missing.push("cwd");
|
|
235
223
|
throw new EntwurfEnvelopeWiringError(missing);
|
|
236
224
|
}
|
|
237
|
-
function buildSendSenderEnvelope() {
|
|
225
|
+
async function buildSendSenderEnvelope() {
|
|
238
226
|
const sessionId = process.env.PI_SESSION_ID?.trim();
|
|
239
227
|
const agentId = process.env.PI_AGENT_ID?.trim();
|
|
240
228
|
const cwd = process.cwd();
|
|
241
229
|
if (sessionId && agentId && cwd)
|
|
242
230
|
return buildStrictPiSenderEnvelope();
|
|
243
|
-
const meta = buildTrustedMetaSenderEnvelope(cwd);
|
|
231
|
+
const meta = await buildTrustedMetaSenderEnvelope(cwd);
|
|
244
232
|
if (meta)
|
|
245
233
|
return meta;
|
|
246
234
|
// No marker. Anonymous external is allowed ONLY when not explicitly forbidden.
|
|
@@ -326,7 +314,7 @@ server.tool("entwurf_v2", "CANONICAL DELIVERY SURFACE for garden ids. When you h
|
|
|
326
314
|
try {
|
|
327
315
|
// Resolved ONCE so the dispatch-moment timestamp is fixed and the control RPC sender
|
|
328
316
|
// + the mailbox body sender share one envelope. No replyability gate (see above).
|
|
329
|
-
const sender = buildSendSenderEnvelope();
|
|
317
|
+
const sender = await buildSendSenderEnvelope();
|
|
330
318
|
const rendered = await runAndRenderEntwurfV2FromSurface({ target, intent, message, mode, wants_reply },
|
|
331
319
|
// agentDir / prefixRoots intentionally omitted: runAndRenderEntwurfV2FromSurface falls
|
|
332
320
|
// back to the ENTWURF_PREFIX_ROOTS env SSOT for prefixRoots (5d-4); agentDir stays undefined.
|
|
@@ -344,7 +332,7 @@ server.tool("entwurf_self", "Return this caller's authoritative identity envelop
|
|
|
344
332
|
"(trusted SessionStart sender marker → garden id). Throws for plain anonymous external " +
|
|
345
333
|
"MCP hosts because they have no authoritative reply address.", {}, async () => {
|
|
346
334
|
try {
|
|
347
|
-
const sender = buildAuthoritativeSelfEnvelope();
|
|
335
|
+
const sender = await buildAuthoritativeSelfEnvelope();
|
|
348
336
|
const kst = formatKstTimestamp(sender.timestamp);
|
|
349
337
|
const extra = {};
|
|
350
338
|
const lines = [
|
|
@@ -441,6 +429,42 @@ server.tool("entwurf_inbox_read", "Read (drain) your own meta-bridge inbox and s
|
|
|
441
429
|
return textErr(`entwurf_inbox_read error: ${err instanceof Error ? err.message : String(err)}`);
|
|
442
430
|
}
|
|
443
431
|
});
|
|
432
|
+
server.tool("entwurf_register_native", "Register an ALREADY-RUNNING native conversation as a garden citizen — it does NOT spawn " +
|
|
433
|
+
"a new one (that is a separate, deferred capability; do not use this to create a sibling). " +
|
|
434
|
+
"Give the backend + its native conversation id + the cwd, and this binds them to a garden id " +
|
|
435
|
+
"so entwurf_v2 can reach the conversation (fire-and-forget → native-push). The conversation " +
|
|
436
|
+
"must be LIVE: it is probed first, and a dead/indeterminate probe is refused (no garden id is " +
|
|
437
|
+
"minted for a pointer that does not resolve to a real host). Re-registering the same " +
|
|
438
|
+
"conversation attaches to the SAME garden id and refreshes the cwd. Only 'antigravity' is " +
|
|
439
|
+
"registerable on this lane. No mailbox receiver marker is written — native-push has no " +
|
|
440
|
+
"idle-wake watch; the returned garden id is the reply handle.", {
|
|
441
|
+
backend: z
|
|
442
|
+
.enum(["antigravity"])
|
|
443
|
+
.describe("The native backend hosting the conversation. Only 'antigravity' — codex is a separate lane."),
|
|
444
|
+
nativeSessionId: z
|
|
445
|
+
.string()
|
|
446
|
+
.min(1)
|
|
447
|
+
.describe("The backend's native conversation id (antigravity conversationId) to bind to a garden id."),
|
|
448
|
+
cwd: z
|
|
449
|
+
.string()
|
|
450
|
+
.min(1)
|
|
451
|
+
.describe("The working directory to record for this citizen — REQUIRED (a native conversation's metadata cannot confirm it, so you must state it)."),
|
|
452
|
+
}, async ({ backend, nativeSessionId, cwd }) => {
|
|
453
|
+
try {
|
|
454
|
+
const result = await registerNativeConversation({ backend, nativeSessionId, cwd });
|
|
455
|
+
return textOk(`[entwurf register native ⟶]\n` +
|
|
456
|
+
` backend: ${result.backend}\n` +
|
|
457
|
+
` conversation: ${result.nativeSessionId}\n` +
|
|
458
|
+
` action: ${result.action}\n` +
|
|
459
|
+
` gardenId: ${result.gardenId}\n` +
|
|
460
|
+
` cwd: ${result.cwd}\n\n` +
|
|
461
|
+
`Reach it with entwurf_v2 (target=${result.gardenId}, intent=fire-and-forget). No receiver ` +
|
|
462
|
+
`marker was written (native-push has no idle-wake mailbox watch).`);
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
return textErr(`entwurf_register_native error: ${err instanceof Error ? err.message : String(err)}`);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
444
468
|
// ============================================================================
|
|
445
469
|
// Main
|
|
446
470
|
// ============================================================================
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// ACP client adapter — the ONE place that touches the @agentclientprotocol/sdk
|
|
2
|
+
// 1.1 fluent surface (`client({ name }).connect(stream)`).
|
|
3
|
+
//
|
|
4
|
+
// The wire SDK deprecated the `new ClientSideConnection(toClient, stream)`
|
|
5
|
+
// constructor in favour of the fluent `client()` builder. The two have
|
|
6
|
+
// different shapes: the deprecated class implemented `Agent` directly (so
|
|
7
|
+
// `.initialize()`/`.newSession()`/`.prompt()` were methods on the returned
|
|
8
|
+
// object), while `client(...).connect(stream)` returns a persistent
|
|
9
|
+
// `ClientConnection` whose `.agent` is a `ClientContext` driven by
|
|
10
|
+
// `request(<method>, params)`.
|
|
11
|
+
//
|
|
12
|
+
// `connect()` (NOT `connectWith()`) is the right primitive here: the backend
|
|
13
|
+
// retains the connection on a BridgeSession and reuses it across turns
|
|
14
|
+
// (backend.ts), so the op-scoped close semantics of `connectWith` do not fit.
|
|
15
|
+
//
|
|
16
|
+
// This module owns the `AcpConnectionLike` seam so the backend and the live
|
|
17
|
+
// smokes both drive ONE adapter — the SDK method-name mapping lives here only,
|
|
18
|
+
// and the backend's orchestration + the gate fakes stay untouched.
|
|
19
|
+
import { AGENT_METHODS, CLIENT_METHODS, client } from "@agentclientprotocol/sdk";
|
|
20
|
+
/**
|
|
21
|
+
* Production factory — wrap the SDK 1.1 fluent `client()` into the
|
|
22
|
+
* `AcpConnectionLike` seam the backend (and the live smokes) drive.
|
|
23
|
+
*
|
|
24
|
+
* Client-side handlers register by ACP method name; agent-side calls go through
|
|
25
|
+
* the persistent connection's `ClientContext` (`conn.agent`). Both the params a
|
|
26
|
+
* handler receives (`ctx.params`) and the throw-to-JSON-RPC-error behaviour
|
|
27
|
+
* match the deprecated `ClientSideConnection`, so this is behaviour-preserving.
|
|
28
|
+
*/
|
|
29
|
+
export function connectAcpClient(stream, handlers) {
|
|
30
|
+
const conn = client({ name: "entwurf" })
|
|
31
|
+
.onNotification(CLIENT_METHODS.session_update, (ctx) => handlers.sessionUpdate(ctx.params))
|
|
32
|
+
.onRequest(CLIENT_METHODS.session_request_permission, (ctx) => handlers.requestPermission(ctx.params))
|
|
33
|
+
.onRequest(CLIENT_METHODS.fs_read_text_file, (ctx) => handlers.readTextFile(ctx.params))
|
|
34
|
+
.onRequest(CLIENT_METHODS.fs_write_text_file, (ctx) => handlers.writeTextFile(ctx.params))
|
|
35
|
+
.connect(stream);
|
|
36
|
+
const agent = conn.agent;
|
|
37
|
+
return {
|
|
38
|
+
initialize: (params) => agent.request(AGENT_METHODS.initialize, params),
|
|
39
|
+
newSession: (params) => agent.request(AGENT_METHODS.session_new, params),
|
|
40
|
+
prompt: (params) => agent.request(AGENT_METHODS.session_prompt, params),
|
|
41
|
+
setSessionConfigOption: (params) => agent.request(AGENT_METHODS.session_set_config_option, params),
|
|
42
|
+
close: (error) => {
|
|
43
|
+
// Best-effort by contract (see AcpConnectionLike.close): a teardown-path
|
|
44
|
+
// close that threw would mask the turn's real error and skip the child
|
|
45
|
+
// teardown that runs after it.
|
|
46
|
+
try {
|
|
47
|
+
conn.close(error);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// connection already closed / SDK teardown race — nothing to recover.
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// ACP backend adapter rail — the PRODUCT seam by which a curated model id selects
|
|
2
|
+
// which ACP backend (claude / future backend / …) drives a turn. See docs/acp-backend-rail.md §9.
|
|
3
|
+
//
|
|
4
|
+
// This seam is DISTINCT from `AcpTurnDeps` (backend.ts), which is the test/runtime
|
|
5
|
+
// seam (fake spawn/connection/clock for the gates). The two are kept apart on
|
|
6
|
+
// purpose (GPT-agreed §9-2): merging them would make a fake-deps fixture look like
|
|
7
|
+
// a fake backend and force the adapter to carry clock/sessionDir/createConnection.
|
|
8
|
+
// The wiring is `defaultDeps(adapter)` — the turn loop in backend.ts stays
|
|
9
|
+
// backend-invariant; only these per-backend functions change with `adapter`.
|
|
10
|
+
//
|
|
11
|
+
// STATUS: Step A+B done — backend.ts is wired to this rail. The turn loop in
|
|
12
|
+
// backend.ts delegates every per-backend step through the resolved adapter
|
|
13
|
+
// (resolveLaunch/ensureOverlay/loadCarrier/buildSessionMeta/enforceModel/
|
|
14
|
+
// launchEnvDefaults); there is no private resolveLaunch copy in backend.ts.
|
|
15
|
+
// `resolveClaudeLaunch` below is the single source for the claude launch spec.
|
|
16
|
+
//
|
|
17
|
+
// Fence: imported by the root program with `.js` suffixes, same as the sibling
|
|
18
|
+
// lib/acp modules — no new strip-types fence.
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { createRequire } from "node:module";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { loadEngraving } from "./engraving.js";
|
|
23
|
+
import { curatedClaudeModels, SUPPORTED_ANTHROPIC_MODEL_IDS } from "./models.js";
|
|
24
|
+
import { claudeLaunchEnvDefaults, ensureClaudeConfigOverlay } from "./overlay.js";
|
|
25
|
+
import { buildClaudeSessionMeta } from "./tool-surface.js";
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// claude adapter — the first implementation (the rail's reference backend)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
const SUPPORTED_CLAUDE_IDS = new Set(SUPPORTED_ANTHROPIC_MODEL_IDS);
|
|
30
|
+
/** Resolve the claude-agent-acp launch — package bin (resolve), env override for debug.
|
|
31
|
+
* This is the single source for the claude launch spec; backend.ts holds no private copy. */
|
|
32
|
+
function resolveClaudeLaunch() {
|
|
33
|
+
const override = process.env.CLAUDE_AGENT_ACP_COMMAND?.trim();
|
|
34
|
+
if (override)
|
|
35
|
+
return { command: "bash", args: ["-lc", override] };
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
const pkgJsonPath = require.resolve("@agentclientprotocol/claude-agent-acp/package.json");
|
|
38
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
39
|
+
const binPath = typeof pkgJson.bin === "string" ? pkgJson.bin : pkgJson.bin?.["claude-agent-acp"];
|
|
40
|
+
if (!binPath)
|
|
41
|
+
throw new Error("@agentclientprotocol/claude-agent-acp resolved but exposes no bin entry");
|
|
42
|
+
return { command: process.execPath, args: [join(dirname(pkgJsonPath), binPath)] };
|
|
43
|
+
}
|
|
44
|
+
export const claudeAdapter = {
|
|
45
|
+
backend: "claude",
|
|
46
|
+
// Claude owns its UNPREFIXED curated ids only (GPT-agreed §9-1). The native id
|
|
47
|
+
// equals the curated id — claude carries no curation prefix to strip.
|
|
48
|
+
routeModel(modelId) {
|
|
49
|
+
return SUPPORTED_CLAUDE_IDS.has(modelId) ? { nativeModelId: modelId } : undefined;
|
|
50
|
+
},
|
|
51
|
+
curatedModels() {
|
|
52
|
+
return curatedClaudeModels();
|
|
53
|
+
},
|
|
54
|
+
// Claude carries no backend-specific settings — its entire surface is common
|
|
55
|
+
// config (tools/permissions/settingSources/…). undefined → config.adapterSettings
|
|
56
|
+
// is undefined and no claude method reads it.
|
|
57
|
+
resolveAdapterSettings() {
|
|
58
|
+
return undefined;
|
|
59
|
+
},
|
|
60
|
+
resolveLaunch() {
|
|
61
|
+
return resolveClaudeLaunch();
|
|
62
|
+
},
|
|
63
|
+
launchEnvDefaults() {
|
|
64
|
+
return claudeLaunchEnvDefaults();
|
|
65
|
+
},
|
|
66
|
+
ensureOverlay() {
|
|
67
|
+
// Claude's overlay is constant (no settings dependence), so it ignores params.
|
|
68
|
+
ensureClaudeConfigOverlay();
|
|
69
|
+
// CLAUDE_CONFIG_DIR rides launchEnvDefaults(); the overlay materialization
|
|
70
|
+
// itself contributes no extra spawn env.
|
|
71
|
+
return { envOverrides: {} };
|
|
72
|
+
},
|
|
73
|
+
loadCarrier({ mcpServerNames }) {
|
|
74
|
+
// Claude's carrier is the shipped engraving — it does not read config.
|
|
75
|
+
return loadEngraving({ backend: "claude", mcpServerNames });
|
|
76
|
+
},
|
|
77
|
+
buildSessionMeta({ nativeModelId, config }, carrier) {
|
|
78
|
+
// buildClaudeSessionMeta always returns an object (it only omits the
|
|
79
|
+
// systemPrompt KEY when carrier is absent), so claude never yields undefined.
|
|
80
|
+
return buildClaudeSessionMeta({
|
|
81
|
+
modelId: nativeModelId,
|
|
82
|
+
tools: config.tools,
|
|
83
|
+
permissionAllow: config.permissionAllow,
|
|
84
|
+
disallowedTools: config.disallowedTools,
|
|
85
|
+
settingSources: config.settingSources,
|
|
86
|
+
strictMcpConfig: config.strictMcpConfig,
|
|
87
|
+
skillPlugins: config.skillPlugins,
|
|
88
|
+
}, carrier ?? undefined);
|
|
89
|
+
},
|
|
90
|
+
async enforceModel({ connection, acpSessionId, nativeModelId, modelId }) {
|
|
91
|
+
const setConfig = connection.setSessionConfigOption;
|
|
92
|
+
if (typeof setConfig !== "function") {
|
|
93
|
+
throw new Error(`setSessionConfigOption unsupported — cannot enforce model ${modelId}`);
|
|
94
|
+
}
|
|
95
|
+
await setConfig.call(connection, { sessionId: acpSessionId, configId: "model", value: nativeModelId });
|
|
96
|
+
},
|
|
97
|
+
configSignatureFields(_adapterSettings) {
|
|
98
|
+
// Claude folds no extra backend-specific fields beyond backend + nativeModelId
|
|
99
|
+
// (which backend.ts adds). A future backend reads its own stable id off
|
|
100
|
+
// `_adapterSettings` here.
|
|
101
|
+
return {};
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Registry — modelId → adapter
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
/** Registered adapters. Order carries NO routing authority — routeModel decides.
|
|
108
|
+
* Step A: claude only. A second backend appends here with its reserved prefix
|
|
109
|
+
* (e.g. `<backend>-*`), and the fail-fast below proves no two adapters claim one id. */
|
|
110
|
+
const ADAPTERS = [claudeAdapter];
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the backend adapter that owns `modelId`.
|
|
113
|
+
*
|
|
114
|
+
* GPT-agreed §9-1 fail-fast contract:
|
|
115
|
+
* - 0 matches → throw (unknown model — no silent default).
|
|
116
|
+
* - 2+ matches → throw (prefix collision — a startup-visible registry bug).
|
|
117
|
+
*
|
|
118
|
+
* The thrown native id is recovered from the single matching adapter's routeModel,
|
|
119
|
+
* so callers get `{ adapter, nativeModelId }` and never re-parse the id string.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveAcpBackendAdapter(modelId) {
|
|
122
|
+
const matches = [];
|
|
123
|
+
for (const adapter of ADAPTERS) {
|
|
124
|
+
const routed = adapter.routeModel(modelId);
|
|
125
|
+
if (routed)
|
|
126
|
+
matches.push({ adapter, nativeModelId: routed.nativeModelId });
|
|
127
|
+
}
|
|
128
|
+
if (matches.length === 0) {
|
|
129
|
+
throw new Error(`entwurf: no ACP backend adapter owns model id ${JSON.stringify(modelId)}`);
|
|
130
|
+
}
|
|
131
|
+
if (matches.length > 1) {
|
|
132
|
+
const owners = matches.map((m) => m.adapter.backend).join(", ");
|
|
133
|
+
throw new Error(`entwurf: model id ${JSON.stringify(modelId)} is claimed by multiple adapters (${owners})`);
|
|
134
|
+
}
|
|
135
|
+
return matches[0];
|
|
136
|
+
}
|
|
137
|
+
/** Every curated model row across all registered adapters — for provider registration.
|
|
138
|
+
* GPT-agreed §9-6 registration-time fail-fast: every curated id must route to EXACTLY
|
|
139
|
+
* one adapter and no id may be duplicated across adapters. Catching it here means a
|
|
140
|
+
* prefix-collision / duplicate surfaces at provider registration, not mid-turn. */
|
|
141
|
+
export function allCuratedModels() {
|
|
142
|
+
const rows = ADAPTERS.flatMap((adapter) => adapter.curatedModels());
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
for (const row of rows) {
|
|
145
|
+
if (seen.has(row.id)) {
|
|
146
|
+
throw new Error(`entwurf: duplicate curated model id across ACP backend adapters: ${row.id}`);
|
|
147
|
+
}
|
|
148
|
+
seen.add(row.id);
|
|
149
|
+
// Throws on 0 matches (unowned) or 2+ matches (prefix collision).
|
|
150
|
+
resolveAcpBackendAdapter(row.id);
|
|
151
|
+
}
|
|
152
|
+
return rows;
|
|
153
|
+
}
|