@junghanacs/entwurf 0.12.5 → 0.12.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/AGENTS.md +31 -17
  2. package/BASELINE.md +42 -8
  3. package/CHANGELOG.md +44 -0
  4. package/DELIVERY.md +71 -14
  5. package/README.md +77 -45
  6. package/VERIFY.md +33 -38
  7. package/docs/setup-clean-host.md +80 -22
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +90 -66
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +54 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +153 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +436 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +157 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +105 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +90 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +194 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +153 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +42 -9
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +49 -13
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +104 -11
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +30 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-native-push.js +57 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +10 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +9 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +21 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +5 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +17 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +125 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/adapter.js +158 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/register.js +61 -0
  30. package/mcp/entwurf-bridge/dist/scripts/agy-imprint.js +166 -0
  31. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +130 -0
  32. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +178 -0
  33. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +24 -0
  34. package/mcp/entwurf-bridge/src/index.ts +101 -67
  35. package/mcp/entwurf-bridge/test.sh +1 -1
  36. package/mcp/entwurf-bridge/tsconfig.build.json +23 -3
  37. package/package.json +10 -6
  38. package/pi-extensions/lib/entwurf-deliverability.ts +62 -9
  39. package/pi-extensions/lib/entwurf-self-address.ts +58 -15
  40. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -12
  41. package/pi-extensions/lib/entwurf-v2-decider.ts +60 -0
  42. package/pi-extensions/lib/entwurf-v2-native-push.ts +86 -0
  43. package/pi-extensions/lib/entwurf-v2-production.ts +20 -0
  44. package/pi-extensions/lib/entwurf-v2-release.ts +9 -0
  45. package/pi-extensions/lib/entwurf-v2-runner.ts +29 -1
  46. package/pi-extensions/lib/entwurf-v2-send.ts +7 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +17 -0
  48. package/pi-extensions/lib/meta-sender-identity.ts +154 -0
  49. package/pi-extensions/lib/native-push/adapter.ts +255 -0
  50. package/pi-extensions/lib/native-push/register.ts +99 -0
  51. package/run.sh +974 -248
  52. package/scripts/agy-bridge-config.py +446 -0
  53. package/scripts/agy-bridge.sh +359 -0
  54. package/scripts/agy-hooks-bridge.sh +193 -0
  55. package/scripts/agy-hooks-config.py +257 -0
  56. package/scripts/agy-imprint.sh +28 -0
  57. package/scripts/agy-imprint.ts +193 -0
  58. package/scripts/agy-statusline-bridge.sh +176 -0
  59. package/scripts/agy-statusline-config.py +213 -0
  60. package/scripts/agy-statusline.sh +256 -0
  61. package/scripts/build-bridge.sh +20 -0
  62. package/scripts/check-agy-sender-identity.ts +364 -0
  63. package/scripts/check-entwurf-bridge-boot.ts +8 -2
  64. package/scripts/check-entwurf-deliverability.ts +34 -0
  65. package/scripts/check-entwurf-self-address.ts +78 -11
  66. package/scripts/check-entwurf-v2-contract.ts +136 -1
  67. package/scripts/check-entwurf-v2-decider.ts +95 -1
  68. package/scripts/check-entwurf-v2-matrix.ts +14 -3
  69. package/scripts/check-entwurf-v2-native-push.ts +193 -0
  70. package/scripts/check-entwurf-v2-production.ts +68 -1
  71. package/scripts/check-entwurf-v2-runner.ts +58 -0
  72. package/scripts/check-entwurf-v2-surface.ts +35 -0
  73. package/scripts/check-install-surface.ts +357 -0
  74. package/scripts/check-native-push-adapter.ts +319 -0
  75. package/scripts/check-native-push-register.ts +130 -0
  76. package/scripts/dev-bin.sh +195 -0
  77. package/scripts/doctor-pi-provider.ts +140 -0
  78. package/scripts/meta-bridge-doctor.sh +47 -9
  79. package/scripts/meta-bridge-install.sh +20 -17
  80. package/scripts/meta-bridge-state.py +40 -2
  81. package/scripts/meta-bridge-uninstall.sh +23 -12
  82. package/scripts/register-pi-package.py +183 -0
  83. package/scripts/register-pi-provider.py +287 -0
  84. package/scripts/smoke-agy-hooks-state.sh +172 -0
  85. package/scripts/smoke-agy-install-state.sh +660 -0
  86. package/scripts/smoke-agy-native-push-live.ts +243 -0
  87. package/scripts/smoke-agy-statusline-state.sh +300 -0
  88. package/scripts/smoke-meta-async-drift.sh +9 -2
  89. package/scripts/smoke-meta-install-state.sh +178 -8
  90. package/scripts/smoke-pi-provider-state.sh +182 -0
  91. package/scripts/smoke-user-scope-citizen.sh +191 -0
  92. package/scripts/with-dist-lock.sh +81 -0
  93. package/scripts/__pycache__/meta-bridge-state.cpython-312.pyc +0 -0
@@ -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 { defaultMetaMailboxDir, defaultMetaSessionsDir, parentPid, readMetaIdentityByGardenId, readMetaInbox, readMetaReceiverMarker, readMetaSenderMarker, } from "../../../pi-extensions/lib/meta-session.js";
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
- // minted a garden-id via its SessionStart hook. The marker is keyed by the
170
- // shared parent pid — this MCP child's process.ppid IS the Claude Code process
171
- // the hook ran under (NOT cwd inference). ENTWURF_META_SENDER_MARKER overrides the
172
- // lookup for explicit wiring / tests. A trusted marker promotes this process to
173
- // a REPLYABLE meta-session sender addressed by its garden-id.
174
- const marker = resolveMetaSenderMarker();
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
- function buildAuthoritativeSelfEnvelope() {
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
+ }