@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.
Files changed (183) hide show
  1. package/AGENTS.md +240 -0
  2. package/BASELINE.md +227 -0
  3. package/CHANGELOG.md +1210 -0
  4. package/CONTRIBUTING.md +63 -0
  5. package/DELIVERY.md +209 -0
  6. package/LICENSE +21 -0
  7. package/README.md +504 -0
  8. package/VERIFY.md +260 -0
  9. package/demo/README.md +188 -0
  10. package/demo/demo-baseline.sh +156 -0
  11. package/demo/demo.sh +183 -0
  12. package/docs/assets/entwurf-demo.gif +0 -0
  13. package/docs/assets/entwurf-doomemacs.gif +0 -0
  14. package/docs/assets/entwurf-entwurf.gif +0 -0
  15. package/docs/assets/entwurf-hero.jpg +0 -0
  16. package/docs/setup-clean-host.md +305 -0
  17. package/mcp/entwurf-bridge/src/index.ts +513 -0
  18. package/mcp/entwurf-bridge/start.sh +25 -0
  19. package/mcp/entwurf-bridge/test.sh +54 -0
  20. package/mcp/tsconfig.json +29 -0
  21. package/package.json +130 -0
  22. package/pi/entwurf-capabilities.json +9 -0
  23. package/pi/entwurf-targets.json +20 -0
  24. package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
  25. package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
  26. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
  27. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
  28. package/pi/settings.reference.json +43 -0
  29. package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
  30. package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
  31. package/pi-extensions/acp-provider.ts +63 -0
  32. package/pi-extensions/entwurf-control.ts +1692 -0
  33. package/pi-extensions/lib/acp/acp-client.ts +90 -0
  34. package/pi-extensions/lib/acp/augment.ts +238 -0
  35. package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
  36. package/pi-extensions/lib/acp/backend.ts +873 -0
  37. package/pi-extensions/lib/acp/config.ts +552 -0
  38. package/pi-extensions/lib/acp/context.ts +177 -0
  39. package/pi-extensions/lib/acp/engraving.ts +123 -0
  40. package/pi-extensions/lib/acp/event-mapper.ts +339 -0
  41. package/pi-extensions/lib/acp/models.ts +102 -0
  42. package/pi-extensions/lib/acp/overlay.ts +220 -0
  43. package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
  44. package/pi-extensions/lib/acp/session-store.ts +418 -0
  45. package/pi-extensions/lib/acp/tool-surface.ts +184 -0
  46. package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
  47. package/pi-extensions/lib/entwurf-core.ts +2033 -0
  48. package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
  49. package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
  50. package/pi-extensions/lib/entwurf-facts.ts +251 -0
  51. package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
  52. package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
  53. package/pi-extensions/lib/entwurf-preflight.ts +247 -0
  54. package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
  55. package/pi-extensions/lib/entwurf-self-address.ts +117 -0
  56. package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
  57. package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
  58. package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
  59. package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
  60. package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
  61. package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
  62. package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
  63. package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
  64. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
  65. package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
  66. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
  67. package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
  68. package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
  69. package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
  70. package/pi-extensions/lib/meta-session.ts +1951 -0
  71. package/pi-extensions/lib/project-trust-handler.ts +154 -0
  72. package/pi-extensions/lib/session-id.js +57 -0
  73. package/pi-extensions/lib/socket-discovery.ts +346 -0
  74. package/pi-extensions/lib/socket-probe.ts +92 -0
  75. package/pi-extensions/meta-bridge-hook.ts +250 -0
  76. package/pi-extensions/model-lock.ts +235 -0
  77. package/prompts/engraving.md +27 -0
  78. package/protocol.js +31 -0
  79. package/run.sh +2832 -0
  80. package/scripts/check-acp-backend-preflight.ts +130 -0
  81. package/scripts/check-acp-carrier-augment.ts +297 -0
  82. package/scripts/check-acp-config.ts +322 -0
  83. package/scripts/check-acp-event-mapper.ts +253 -0
  84. package/scripts/check-acp-overlay.ts +154 -0
  85. package/scripts/check-acp-prompt-builder.ts +191 -0
  86. package/scripts/check-acp-provider-surface.ts +159 -0
  87. package/scripts/check-acp-sdk-surface.ts +211 -0
  88. package/scripts/check-acp-session-reuse.ts +756 -0
  89. package/scripts/check-acp-session-store.ts +387 -0
  90. package/scripts/check-acp-tool-surface.ts +159 -0
  91. package/scripts/check-entwurf-bridge-boot.ts +199 -0
  92. package/scripts/check-entwurf-capabilities.ts +123 -0
  93. package/scripts/check-entwurf-control-rpc.ts +187 -0
  94. package/scripts/check-entwurf-deliverability.ts +158 -0
  95. package/scripts/check-entwurf-fact-provider.ts +316 -0
  96. package/scripts/check-entwurf-facts.ts +358 -0
  97. package/scripts/check-entwurf-mailbox-guard.ts +264 -0
  98. package/scripts/check-entwurf-peers-surface.ts +235 -0
  99. package/scripts/check-entwurf-resume-args.ts +149 -0
  100. package/scripts/check-entwurf-self-address.ts +209 -0
  101. package/scripts/check-entwurf-session-identity.ts +703 -0
  102. package/scripts/check-entwurf-v2-contract.ts +536 -0
  103. package/scripts/check-entwurf-v2-decider.ts +795 -0
  104. package/scripts/check-entwurf-v2-lock.ts +368 -0
  105. package/scripts/check-entwurf-v2-mailbox.ts +228 -0
  106. package/scripts/check-entwurf-v2-matrix.ts +437 -0
  107. package/scripts/check-entwurf-v2-production.ts +406 -0
  108. package/scripts/check-entwurf-v2-release.ts +245 -0
  109. package/scripts/check-entwurf-v2-runner.ts +452 -0
  110. package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
  111. package/scripts/check-entwurf-v2-send.ts +454 -0
  112. package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
  113. package/scripts/check-entwurf-v2-spawn.ts +398 -0
  114. package/scripts/check-entwurf-v2-surface.ts +352 -0
  115. package/scripts/check-keyset-overlap.py +120 -0
  116. package/scripts/check-mailbox-receipt-state.ts +170 -0
  117. package/scripts/check-meta-capability-source.ts +112 -0
  118. package/scripts/check-meta-dual-consumers.ts +154 -0
  119. package/scripts/check-meta-dual-read.ts +158 -0
  120. package/scripts/check-meta-listing.ts +138 -0
  121. package/scripts/check-meta-mailbox-state-write.ts +135 -0
  122. package/scripts/check-meta-migration.ts +212 -0
  123. package/scripts/check-meta-receiver-marker.ts +185 -0
  124. package/scripts/check-meta-record-v2.ts +191 -0
  125. package/scripts/check-meta-session.ts +673 -0
  126. package/scripts/check-model-lock.ts +408 -0
  127. package/scripts/check-package-source-routing.ts +253 -0
  128. package/scripts/check-pi-preflight.ts +304 -0
  129. package/scripts/check-project-trust-handler.ts +265 -0
  130. package/scripts/check-shell-quote.ts +121 -0
  131. package/scripts/check-socket-discovery.ts +428 -0
  132. package/scripts/check-socket-probe.ts +106 -0
  133. package/scripts/fixtures/probe-mcp-server.ts +33 -0
  134. package/scripts/gnew-rpc-drive.ts +211 -0
  135. package/scripts/lib/acp-child-cleanup.ts +116 -0
  136. package/scripts/meta-bridge-doctor.sh +315 -0
  137. package/scripts/meta-bridge-hook-log.sh +26 -0
  138. package/scripts/meta-bridge-install.sh +135 -0
  139. package/scripts/meta-bridge-prune.ts +199 -0
  140. package/scripts/meta-bridge-state.py +549 -0
  141. package/scripts/meta-bridge-statusline.sh +192 -0
  142. package/scripts/meta-bridge-store-doctor.ts +64 -0
  143. package/scripts/meta-bridge-uninstall.sh +39 -0
  144. package/scripts/new-session-id.ts +25 -0
  145. package/scripts/postinstall-chmod.cjs +58 -0
  146. package/scripts/raw-async-delivery/README.md +258 -0
  147. package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
  148. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
  149. package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
  150. package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
  151. package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
  152. package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
  153. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
  154. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
  155. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
  156. package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
  157. package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
  158. package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
  159. package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
  160. package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
  161. package/scripts/resolve-acp-bridge.ts +25 -0
  162. package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
  163. package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
  164. package/scripts/smoke-acp-mcp-live.ts +129 -0
  165. package/scripts/smoke-acp-memory-containment-live.ts +389 -0
  166. package/scripts/smoke-acp-overlay-live.ts +314 -0
  167. package/scripts/smoke-acp-provider-live.ts +162 -0
  168. package/scripts/smoke-acp-raw-turn-live.ts +261 -0
  169. package/scripts/smoke-acp-session-reuse-live.ts +172 -0
  170. package/scripts/smoke-acp-skill-live.ts +144 -0
  171. package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
  172. package/scripts/smoke-claude-native-resume-live.sh +198 -0
  173. package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
  174. package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
  175. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
  176. package/scripts/smoke-meta-async-drift.sh +171 -0
  177. package/scripts/smoke-meta-honesty.sh +147 -0
  178. package/scripts/smoke-meta-install-state.sh +403 -0
  179. package/scripts/smoke-meta-keyset-guard.sh +111 -0
  180. package/scripts/smoke-meta-prune.sh +174 -0
  181. package/scripts/smoke-resident-garden-guard.sh +433 -0
  182. package/scripts/smoke-session-id-name.ts +187 -0
  183. package/scripts/tsconfig.json +34 -0
@@ -0,0 +1,90 @@
1
+ // ACP client adapter — the ONE place that touches the @agentclientprotocol/sdk
2
+ // 0.29 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
+
20
+ import { AGENT_METHODS, CLIENT_METHODS, client, type Stream } from "@agentclientprotocol/sdk";
21
+ import type { AcpTextBlock } from "./context.js";
22
+
23
+ /** The subset of the ACP agent connection the backend drives (real or fake). */
24
+ export interface AcpConnectionLike {
25
+ initialize(params: unknown): Promise<unknown>;
26
+ newSession(params: unknown): Promise<{ sessionId?: string }>;
27
+ prompt(params: { sessionId: string; prompt: AcpTextBlock[] }): Promise<{ stopReason?: string }>;
28
+ setSessionConfigOption?(params: unknown): Promise<unknown>;
29
+ /**
30
+ * Closes the underlying SDK connection before child process teardown. With
31
+ * the fluent SDK connection this is load-bearing: otherwise a successful
32
+ * live turn can print PASS but keep Node's event loop alive.
33
+ *
34
+ * Implementations MUST be best-effort (never throw): callers invoke it on
35
+ * success, error, and reuse-error teardown paths, so a close failure must not
36
+ * mask the turn's real outcome nor skip the child teardown that follows it.
37
+ */
38
+ close?(error?: unknown): void;
39
+ }
40
+
41
+ /** The ACP client-side callbacks. They delegate to the session's mutable handler. */
42
+ export interface AcpClientHandlers {
43
+ sessionUpdate(notification: { update?: Record<string, unknown>; sessionId?: string }): Promise<void>;
44
+ requestPermission(request: { options?: Array<{ optionId: string; kind?: string }> }): Promise<{
45
+ outcome: { outcome: "selected"; optionId: string } | { outcome: "cancelled" };
46
+ }>;
47
+ readTextFile(request: { path: string }): Promise<{ content: string }>;
48
+ // `void` (not `never`): the backend's handler denies by throwing, but the
49
+ // shared smokes legitimately return a write response ({}); both satisfy the
50
+ // SDK's `WriteTextFileResponse | void` handler contract.
51
+ writeTextFile(request: unknown): Promise<void>;
52
+ }
53
+
54
+ /**
55
+ * Production factory — wrap the SDK 0.29 fluent `client()` into the
56
+ * `AcpConnectionLike` seam the backend (and the live smokes) drive.
57
+ *
58
+ * Client-side handlers register by ACP method name; agent-side calls go through
59
+ * the persistent connection's `ClientContext` (`conn.agent`). Both the params a
60
+ * handler receives (`ctx.params`) and the throw-to-JSON-RPC-error behaviour
61
+ * match the deprecated `ClientSideConnection`, so this is behaviour-preserving.
62
+ */
63
+ export function connectAcpClient(stream: Stream, handlers: AcpClientHandlers): AcpConnectionLike {
64
+ const conn = client({ name: "entwurf" })
65
+ .onNotification(CLIENT_METHODS.session_update, (ctx) => handlers.sessionUpdate(ctx.params as never))
66
+ .onRequest(CLIENT_METHODS.session_request_permission, (ctx) => handlers.requestPermission(ctx.params as never))
67
+ .onRequest(CLIENT_METHODS.fs_read_text_file, (ctx) => handlers.readTextFile(ctx.params as never))
68
+ .onRequest(CLIENT_METHODS.fs_write_text_file, (ctx) => handlers.writeTextFile(ctx.params as never))
69
+ .connect(stream);
70
+
71
+ const agent = conn.agent;
72
+ return {
73
+ initialize: (params) => agent.request(AGENT_METHODS.initialize, params as never),
74
+ newSession: (params) =>
75
+ agent.request(AGENT_METHODS.session_new, params as never) as Promise<{ sessionId?: string }>,
76
+ prompt: (params) =>
77
+ agent.request(AGENT_METHODS.session_prompt, params as never) as Promise<{ stopReason?: string }>,
78
+ setSessionConfigOption: (params) => agent.request(AGENT_METHODS.session_set_config_option, params as never),
79
+ close: (error) => {
80
+ // Best-effort by contract (see AcpConnectionLike.close): a teardown-path
81
+ // close that threw would mask the turn's real error and skip the child
82
+ // teardown that runs after it.
83
+ try {
84
+ conn.close(error);
85
+ } catch {
86
+ // connection already closed / SDK teardown race — nothing to recover.
87
+ }
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,238 @@
1
+ // ACP plugin — first-user-message context augment + entwurf de-dup (S2d-1c).
2
+ //
3
+ // The rich context the ACP-side agent needs — bridge identity, the
4
+ // task-execution stance, pi's base intro + tool surface, the operator's
5
+ // `~/AGENTS.md`, the project's `cwd/AGENTS.md`, date + cwd — is delivered as a
6
+ // text block PREPENDED to the first prompt of a `bootstrapPath="new"` session,
7
+ // NOT via `_meta.systemPrompt`.
8
+ //
9
+ // Why first-user-message and not the carrier (NEXT §S2-scout 핀1, oracle A):
10
+ // growing `_meta.systemPrompt` materially past the SDK-default size reclassifies a
11
+ // subscription call as metered → HTTP 400. A long FIRST USER MESSAGE is
12
+ // structurally identical to any other user prompt and does not touch billing
13
+ // classification, so the rich context rides here while engraving.ts keeps the
14
+ // carrier tiny (a small non-empty string whose real job is replacing the preset
15
+ // to strip auto-memory, not carrying context).
16
+ //
17
+ // once-only (NEXT §S2d gate ②, GPT c32a6c8): this augment is prepended to the
18
+ // `new` prompt ONLY. `new` happens exactly once per ACP session lifecycle
19
+ // (subsequent turns are `reuse` delta-only; an incompatible drift opens a
20
+ // genuinely new ACP session that correctly gets the augment again). The augment
21
+ // is prepended at the WIRE level (to the AcpTextBlock[]), never mutated into the
22
+ // pi Context, so `contextMessageSignatures(context)` never sees it — that is what
23
+ // keeps the augment out of the reuse-compat signature with no extra bookkeeping.
24
+ //
25
+ // Entwurf de-dup: when an entwurf-spawned task is the first user message,
26
+ // `enrichTaskWithProjectContext` (entwurf-core.ts) has already prepended a
27
+ // `<project-context path="${cwd}/AGENTS.md">` block carrying the cwd AGENTS.md.
28
+ // Augmenting that prompt with the SAME cwd AGENTS.md section would duplicate it,
29
+ // so we drop only the cwd-AGENTS section and keep the rest of the augment.
30
+
31
+ import { existsSync, readFileSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { join } from "node:path";
34
+ import { ENTWURF_PROJECT_CONTEXT_OPEN_TAG } from "../../../protocol.js";
35
+ import type { AcpTextBlock } from "./context.js";
36
+
37
+ const MAX_AUGMENT_BYTES = 50 * 1024;
38
+
39
+ export interface PiContextAugmentParams {
40
+ /** Claude-only this cut; kept as a field so the narrative names the backend. */
41
+ backend: string;
42
+ cwd: string;
43
+ mcpServerNames: readonly string[];
44
+ emacsAgentSocket?: string;
45
+ /** Home dir to read `~/AGENTS.md` from. Defaults to os.homedir() (gate seam). */
46
+ homeDir?: string;
47
+ }
48
+
49
+ /**
50
+ * Build the augment text to prepend to the first user message of a NEW ACP
51
+ * session. Day-granularity date so two prompts on the same calendar day produce
52
+ * the same augment. Truncated to 50KB. Pure w.r.t. (cwd, backend, sorted
53
+ * mcpServerNames, AGENTS.md contents, calendar day).
54
+ */
55
+ export function buildPiContextAugment(params: PiContextAugmentParams): string {
56
+ const names = [...params.mcpServerNames].sort();
57
+ const mcpList = names.length > 0 ? names.join(", ") : "(none registered)";
58
+
59
+ const sections: string[] = [];
60
+
61
+ // Bridge identity + caller-side sibling stance. The closing line is a
62
+ // caller-perspective statement: when THIS session throws an entwurf, the peer
63
+ // on the other end is a sibling, not a worker.
64
+ sections.push(
65
+ [
66
+ "You are operating through entwurf, an ACP bridge between pi (the harness) and the underlying model.",
67
+ `Backend: ${params.backend}.`,
68
+ `Connected MCP servers: ${mcpList}.`,
69
+ "When entwurf is invoked, you do not spawn workers — you summon sibling agents through this bridge.",
70
+ ].join("\n"),
71
+ );
72
+
73
+ // Receiver-side task-execution stance. A SEPARATE paragraph so the caller
74
+ // stance above does not bleed into how incoming tasks are read.
75
+ sections.push(
76
+ "A task is a task: execute what you receive, regardless of transport (spawn/resume/send) or source (GLG or sibling). The only thing you do not do on your own is forward your task to another sibling — if you cannot or should not do it, say so to GLG instead.",
77
+ );
78
+
79
+ sections.push(
80
+ [
81
+ "You are an expert coding assistant operating inside pi, a coding agent harness.",
82
+ "You help users by reading files, executing commands, editing code, and writing new files.",
83
+ "",
84
+ "Tool surface:",
85
+ "- Treat the actual callable function/tool schema exposed in this session as the source of truth.",
86
+ "- Do not assume a tool exists only because this context or AGENTS.md mentions it.",
87
+ "- Pi-level work generally includes reading files, running shell commands, editing files, and writing files; concrete tool names differ by backend.",
88
+ "- Native pi may expose read/bash/edit/write; Claude ACP may expose Read/Bash/Edit/Write/Skill; Codex ACP may expose exec_command/apply_patch/write_stdin/update_plan.",
89
+ "- MCP/custom tools are usable only when they appear in the actual tool schema for this session.",
90
+ ].join("\n"),
91
+ );
92
+
93
+ if (params.emacsAgentSocket) {
94
+ sections.push(
95
+ [
96
+ "Emacs integration:",
97
+ `- Agent Emacs socket: ${params.emacsAgentSocket}`,
98
+ '- When using emacsclient from shell/Bash, prefer `emacsclient -s "$PI_EMACS_AGENT_SOCKET" --eval ...`.',
99
+ "- Do not hardcode `-s server` unless the user explicitly asks for that socket.",
100
+ ].join("\n"),
101
+ );
102
+ }
103
+
104
+ const projectContextParts: string[] = [];
105
+ const homeBase = params.homeDir ?? homedir();
106
+ const homeAgents = join(homeBase, "AGENTS.md");
107
+ const cwdAgents = join(params.cwd, "AGENTS.md");
108
+
109
+ if (existsSync(homeAgents)) {
110
+ const content = readAgents(homeAgents);
111
+ if (content) projectContextParts.push(`## ${homeAgents}\n\n${content}`);
112
+ }
113
+ if (existsSync(cwdAgents) && cwdAgents !== homeAgents) {
114
+ const content = readAgents(cwdAgents);
115
+ if (content) projectContextParts.push(`## ${cwdAgents}\n\n${content}`);
116
+ }
117
+
118
+ if (projectContextParts.length > 0) {
119
+ sections.push(
120
+ ["# Project Context", "", "Project-specific instructions and guidelines:", "", ...projectContextParts].join("\n"),
121
+ );
122
+ }
123
+
124
+ const currentDate = new Intl.DateTimeFormat("en-CA", {
125
+ timeZone: "Asia/Seoul",
126
+ year: "numeric",
127
+ month: "2-digit",
128
+ day: "2-digit",
129
+ }).format(new Date());
130
+ sections.push([`Current date: ${currentDate}`, `Current working directory: ${params.cwd}`].join("\n"));
131
+
132
+ return truncateAugment(sections.join("\n\n"));
133
+ }
134
+
135
+ function readAgents(filePath: string): string {
136
+ try {
137
+ return readFileSync(filePath, "utf8").trim();
138
+ } catch (error) {
139
+ throw new Error(
140
+ `Failed to read AGENTS.md at ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
141
+ );
142
+ }
143
+ }
144
+
145
+ function truncateAugment(text: string): string {
146
+ if (Buffer.byteLength(text, "utf8") <= MAX_AUGMENT_BYTES) return text;
147
+ const marker = `\n\n[entwurf: context augment truncated to ${MAX_AUGMENT_BYTES} bytes; read AGENTS.md files directly if more detail is needed.]`;
148
+ const markerBytes = Buffer.byteLength(marker, "utf8");
149
+ let end = text.length;
150
+ while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") + markerBytes > MAX_AUGMENT_BYTES) {
151
+ end = Math.max(0, end - 1024);
152
+ }
153
+ while (end < text.length && Buffer.byteLength(text.slice(0, end + 1), "utf8") + markerBytes <= MAX_AUGMENT_BYTES) {
154
+ end++;
155
+ }
156
+ return `${text.slice(0, end).trimEnd()}${marker}`;
157
+ }
158
+
159
+ /** trimEnd + a trailing blank line so the augment block reads as its own unit. */
160
+ function ensurePromptSeparator(text: string): string {
161
+ const trimmed = text.trimEnd();
162
+ return trimmed.length > 0 ? `${trimmed}\n\n` : trimmed;
163
+ }
164
+
165
+ /**
166
+ * The exact opening tag `enrichTaskWithProjectContext` emits for THIS cwd. The
167
+ * path is included so the check cannot false-match a project-context block for a
168
+ * different directory.
169
+ */
170
+ function entwurfCwdContextOpenTag(cwd: string): string {
171
+ return `${ENTWURF_PROJECT_CONTEXT_OPEN_TAG} path="${join(cwd, "AGENTS.md")}">`;
172
+ }
173
+
174
+ /**
175
+ * True when the prompt we are about to send already carries the entwurf
176
+ * project-context block for this cwd (so the augment's cwd-AGENTS section would
177
+ * duplicate it). In this cut the `new` prompt is the whole flattened transcript,
178
+ * so the marker can sit anywhere inside it — `includes`, not `startsWith`.
179
+ */
180
+ export function promptCarriesEntwurfCwdContext(promptText: string, cwd: string): boolean {
181
+ return promptText.includes(entwurfCwdContextOpenTag(cwd));
182
+ }
183
+
184
+ /**
185
+ * Remove ONLY the `## ${cwd}/AGENTS.md` subsection from the augment, keeping the
186
+ * bridge narrative, pi base, home AGENTS.md, and date/cwd. If dropping it leaves
187
+ * the "# Project Context" section with no remaining subsection, drop the empty
188
+ * header too. Ported verbatim from 0.11.0 acp-bridge.ts.
189
+ */
190
+ export function removeCwdAgentsSectionFromAugment(text: string, cwd: string): string {
191
+ const heading = `## ${join(cwd, "AGENTS.md")}\n\n`;
192
+ const start = text.indexOf(heading);
193
+ if (start < 0) return text;
194
+
195
+ const afterHeading = start + heading.length;
196
+ const nextProjectHeading = text.indexOf("\n\n## ", afterHeading);
197
+ const currentDateSection = text.indexOf("\n\nCurrent date:", afterHeading);
198
+ const candidates = [nextProjectHeading, currentDateSection].filter((idx) => idx >= 0);
199
+ const end = candidates.length > 0 ? Math.min(...candidates) : text.length;
200
+ let result = `${text.slice(0, start).trimEnd()}${text.slice(end)}`;
201
+
202
+ const projectHeader = "# Project Context\n\nProject-specific instructions and guidelines:";
203
+ const projectStart = result.indexOf(projectHeader);
204
+ if (projectStart >= 0) {
205
+ const projectEnd = result.indexOf("\n\nCurrent date:", projectStart + projectHeader.length);
206
+ const projectBody =
207
+ projectEnd >= 0
208
+ ? result.slice(projectStart + projectHeader.length, projectEnd)
209
+ : result.slice(projectStart + projectHeader.length);
210
+ if (!projectBody.includes("\n## ")) {
211
+ result =
212
+ projectEnd >= 0
213
+ ? `${result.slice(0, projectStart).trimEnd()}${result.slice(projectEnd)}`
214
+ : result.slice(0, projectStart).trimEnd();
215
+ }
216
+ }
217
+
218
+ return result.trim();
219
+ }
220
+
221
+ /**
222
+ * Prepend the first-user-message augment to a `new` session's prompt blocks.
223
+ *
224
+ * De-dup: if `promptBlocks` already carry the entwurf project-context for this
225
+ * cwd, the augment's cwd-AGENTS section is dropped first. The pi Context is NEVER
226
+ * touched — the augment lives only on the wire, so it stays out of
227
+ * `contextMessageSignatures`. A whitespace-only augment is omitted entirely.
228
+ */
229
+ export function prependNewPromptAugment(promptBlocks: AcpTextBlock[], params: PiContextAugmentParams): AcpTextBlock[] {
230
+ const promptText = promptBlocks.map((b) => b.text).join("\n");
231
+ let augment = buildPiContextAugment(params);
232
+ if (promptCarriesEntwurfCwdContext(promptText, params.cwd)) {
233
+ augment = removeCwdAgentsSectionFromAugment(augment, params.cwd);
234
+ }
235
+ const separated = ensurePromptSeparator(augment);
236
+ if (!separated) return promptBlocks;
237
+ return [{ type: "text", text: separated }, ...promptBlocks];
238
+ }
@@ -0,0 +1,319 @@
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
+
20
+ import { readFileSync } from "node:fs";
21
+ import { createRequire } from "node:module";
22
+ import { dirname, join } from "node:path";
23
+
24
+ import type { AcpConnectionLike } from "./acp-client.js";
25
+ import type { ResolvedAcpConfig } from "./config.js";
26
+ import { loadEngraving } from "./engraving.js";
27
+ import { curatedClaudeModels, SUPPORTED_ANTHROPIC_MODEL_IDS } from "./models.js";
28
+ import { claudeLaunchEnvDefaults, ensureClaudeConfigOverlay } from "./overlay.js";
29
+ import { buildClaudeSessionMeta } from "./tool-surface.js";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Shared shapes
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** Launch spec for the ACP server child process. */
36
+ export interface AcpLaunchSpec {
37
+ command: string;
38
+ args: string[];
39
+ }
40
+
41
+ /** A curated provider model row (the shape pi.registerProvider({ models }) takes). */
42
+ export type AcpModelRow = ReturnType<typeof curatedClaudeModels>[number];
43
+
44
+ /** routeModel result — the backend-native model id with the curation prefix stripped. */
45
+ export interface AcpRoutedModel {
46
+ nativeModelId: string;
47
+ }
48
+
49
+ /** resolveLaunch input — native (prefix-stripped) model id + resolved operator config. */
50
+ export interface AcpLaunchParams {
51
+ cwd: string;
52
+ modelId: string;
53
+ nativeModelId: string;
54
+ config: ResolvedAcpConfig;
55
+ }
56
+
57
+ /** loadCarrier input — the mcp server names plus the resolved config (so a backend
58
+ * whose carrier depends on its own `config.adapterSettings` can read it). backend.ts
59
+ * still never inspects config — it just passes it through. */
60
+ export interface AcpCarrierParams {
61
+ mcpServerNames: string[];
62
+ config: ResolvedAcpConfig;
63
+ }
64
+
65
+ /** ensureOverlay input — cwd + (native) model id + the resolved config. A backend
66
+ * whose overlay/env depends on its OWN settings reads them off `config.adapterSettings`
67
+ * here; backend.ts never inspects config. Same shape as AcpLaunchParams (overlay and
68
+ * launch are distinct phases, so they keep distinct names). */
69
+ export interface AcpOverlayParams {
70
+ cwd: string;
71
+ modelId: string;
72
+ nativeModelId: string;
73
+ config: ResolvedAcpConfig;
74
+ }
75
+
76
+ /** buildSessionMeta input — mirrors the newSession `_meta` inputs. */
77
+ export interface AcpSessionMetaParams {
78
+ modelId: string;
79
+ nativeModelId: string;
80
+ config: ResolvedAcpConfig;
81
+ }
82
+
83
+ /** enforceModel input — the live connection + acp session + native id. */
84
+ export interface AcpEnforceModelParams {
85
+ connection: AcpConnectionLike;
86
+ acpSessionId: string;
87
+ modelId: string;
88
+ nativeModelId: string;
89
+ }
90
+
91
+ /** ensureOverlay result — env overrides backend.ts merges into the spawn env. */
92
+ export interface AcpOverlayResult {
93
+ envOverrides: Record<string, string>;
94
+ }
95
+
96
+ /** resolveAdapterSettings input — the RAW (untyped) `entwurfProvider` blocks plus
97
+ * their file paths. This is the ONE seam by which a backend reads its OWN settings
98
+ * (e.g. a connection id, a profile/tenant, a state-home path) WITHOUT those
99
+ * backend-specific keys ever touching the common ResolvedAcpConfig (fat-bridge
100
+ * regression). `mergedBlock` is
101
+ * the project-over-global merge (project keys win); the per-file blocks + paths are
102
+ * for error attribution. A backend with no own settings returns `undefined`. */
103
+ export interface AcpAdapterSettingsParams {
104
+ globalBlock: Record<string, unknown>;
105
+ projectBlock: Record<string, unknown>;
106
+ mergedBlock: Record<string, unknown>;
107
+ globalPath: string;
108
+ projectPath: string;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // The adapter interface
113
+ // ---------------------------------------------------------------------------
114
+
115
+ export interface AcpBackendAdapter {
116
+ /** Discriminator. backend.ts stores this on BridgeSession/configSig so reuse
117
+ * and diagnostics never re-parse the model-id string. */
118
+ readonly backend: string;
119
+
120
+ /** Does this adapter own `modelId`? If so, return the backend-native id (prefix
121
+ * stripped); else undefined. Returning the native id here (vs a bare boolean)
122
+ * keeps the registry from leaning on adapter order and gives resolveLaunch /
123
+ * enforceModel the value they must actually send to the backend. */
124
+ routeModel(modelId: string): AcpRoutedModel | undefined;
125
+
126
+ /** Curated model rows this backend contributes to the single `entwurf` provider. */
127
+ curatedModels(): AcpModelRow[];
128
+
129
+ /** Parse this backend's OWN settings from the raw entwurfProvider blocks, returning
130
+ * an opaque value config.ts stores on `ResolvedAcpConfig.adapterSettings`. backend.ts
131
+ * NEVER inspects the result; only this adapter's other methods read it (casting their
132
+ * own type back). A backend with no own settings returns `undefined`. This keeps the
133
+ * common config free of backend-named fields (see AcpAdapterSettingsParams). */
134
+ resolveAdapterSettings(params: AcpAdapterSettingsParams): unknown;
135
+
136
+ /** Resolve the ACP server launch (command + args), honoring an env override. */
137
+ resolveLaunch(params: AcpLaunchParams): AcpLaunchSpec;
138
+
139
+ /** Static launch env defaults merged over process.env at spawn. */
140
+ launchEnvDefaults(): Record<string, string>;
141
+
142
+ /** Materialize the config overlay (auth passthrough + state hiding) and return
143
+ * the env overrides to merge at spawn. A no-op backend returns { envOverrides: {} }.
144
+ * Receives the resolved config so a settings-dependent overlay can read its own
145
+ * `config.adapterSettings`; settings-derived spawn env rides the returned
146
+ * `envOverrides` (launchEnvDefaults stays static). */
147
+ ensureOverlay(params: AcpOverlayParams): AcpOverlayResult;
148
+
149
+ /** Render the optional short operator carrier (engraving). Kept SEPARATE from
150
+ * buildSessionMeta so backend.ts can load it ONCE and fold the same value into
151
+ * both the config signature and the session meta (they must agree). A carrier-
152
+ * less backend returns null WITHOUT calling loadEngraving, so it never trips the
153
+ * shipped-engraving / appendSystemPrompt signature. */
154
+ loadCarrier(params: AcpCarrierParams): string | null;
155
+
156
+ /** Build the `_meta` handed to newSession. `undefined` → backend.ts omits the
157
+ * `_meta` key entirely (carrier-less backend). Rich operator context rides the
158
+ * first-user augment regardless, never this carrier. */
159
+ buildSessionMeta(params: AcpSessionMetaParams, carrier: string | null): Record<string, unknown> | undefined;
160
+
161
+ /** Enforce the requested model on the live ACP session. Single method absorbs
162
+ * the per-backend difference (claude: per-turn session/set_config_option; a
163
+ * launch-pinned backend: no-op here). backend.ts wraps the call in withTimeout. */
164
+ enforceModel(params: AcpEnforceModelParams): Promise<void>;
165
+
166
+ /** Backend-specific fields folded into bridgeConfigSignature (reuse invalidation):
167
+ * connection/profile/env-derived STABLE ids only — never raw env values / secrets.
168
+ * Reads ONLY this backend's opaque `adapterSettings` (NOT the whole config), so the
169
+ * signature contract can never accidentally fold a common field. MUST be a flat,
170
+ * sorted-stable primitive map (JSON.stringify determinism — no nested objects /
171
+ * non-deterministic order). `backend` + `nativeModelId` are added by backend.ts. */
172
+ configSignatureFields(adapterSettings: unknown): Record<string, unknown>;
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // claude adapter — the first implementation (the rail's reference backend)
177
+ // ---------------------------------------------------------------------------
178
+
179
+ const SUPPORTED_CLAUDE_IDS: ReadonlySet<string> = new Set(SUPPORTED_ANTHROPIC_MODEL_IDS);
180
+
181
+ /** Resolve the claude-agent-acp launch — package bin (resolve), env override for debug.
182
+ * This is the single source for the claude launch spec; backend.ts holds no private copy. */
183
+ function resolveClaudeLaunch(): AcpLaunchSpec {
184
+ const override = process.env.CLAUDE_AGENT_ACP_COMMAND?.trim();
185
+ if (override) return { command: "bash", args: ["-lc", override] };
186
+ const require = createRequire(import.meta.url);
187
+ const pkgJsonPath = require.resolve("@agentclientprotocol/claude-agent-acp/package.json");
188
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8")) as { bin?: string | Record<string, string> };
189
+ const binPath = typeof pkgJson.bin === "string" ? pkgJson.bin : pkgJson.bin?.["claude-agent-acp"];
190
+ if (!binPath) throw new Error("@agentclientprotocol/claude-agent-acp resolved but exposes no bin entry");
191
+ return { command: process.execPath, args: [join(dirname(pkgJsonPath), binPath)] };
192
+ }
193
+
194
+ export const claudeAdapter: AcpBackendAdapter = {
195
+ backend: "claude",
196
+
197
+ // Claude owns its UNPREFIXED curated ids only (GPT-agreed §9-1). The native id
198
+ // equals the curated id — claude carries no curation prefix to strip.
199
+ routeModel(modelId) {
200
+ return SUPPORTED_CLAUDE_IDS.has(modelId) ? { nativeModelId: modelId } : undefined;
201
+ },
202
+
203
+ curatedModels() {
204
+ return curatedClaudeModels();
205
+ },
206
+
207
+ // Claude carries no backend-specific settings — its entire surface is common
208
+ // config (tools/permissions/settingSources/…). undefined → config.adapterSettings
209
+ // is undefined and no claude method reads it.
210
+ resolveAdapterSettings() {
211
+ return undefined;
212
+ },
213
+
214
+ resolveLaunch() {
215
+ return resolveClaudeLaunch();
216
+ },
217
+
218
+ launchEnvDefaults() {
219
+ return claudeLaunchEnvDefaults();
220
+ },
221
+
222
+ ensureOverlay() {
223
+ // Claude's overlay is constant (no settings dependence), so it ignores params.
224
+ ensureClaudeConfigOverlay();
225
+ // CLAUDE_CONFIG_DIR rides launchEnvDefaults(); the overlay materialization
226
+ // itself contributes no extra spawn env.
227
+ return { envOverrides: {} };
228
+ },
229
+
230
+ loadCarrier({ mcpServerNames }) {
231
+ // Claude's carrier is the shipped engraving — it does not read config.
232
+ return loadEngraving({ backend: "claude", mcpServerNames });
233
+ },
234
+
235
+ buildSessionMeta({ nativeModelId, config }, carrier) {
236
+ // buildClaudeSessionMeta always returns an object (it only omits the
237
+ // systemPrompt KEY when carrier is absent), so claude never yields undefined.
238
+ return buildClaudeSessionMeta(
239
+ {
240
+ modelId: nativeModelId,
241
+ tools: config.tools,
242
+ permissionAllow: config.permissionAllow,
243
+ disallowedTools: config.disallowedTools,
244
+ settingSources: config.settingSources,
245
+ strictMcpConfig: config.strictMcpConfig,
246
+ skillPlugins: config.skillPlugins,
247
+ },
248
+ carrier ?? undefined,
249
+ );
250
+ },
251
+
252
+ async enforceModel({ connection, acpSessionId, nativeModelId, modelId }) {
253
+ const setConfig = connection.setSessionConfigOption;
254
+ if (typeof setConfig !== "function") {
255
+ throw new Error(`setSessionConfigOption unsupported — cannot enforce model ${modelId}`);
256
+ }
257
+ await setConfig.call(connection, { sessionId: acpSessionId, configId: "model", value: nativeModelId });
258
+ },
259
+
260
+ configSignatureFields(_adapterSettings) {
261
+ // Claude folds no extra backend-specific fields beyond backend + nativeModelId
262
+ // (which backend.ts adds). A future backend reads its own stable id off
263
+ // `_adapterSettings` here.
264
+ return {};
265
+ },
266
+ };
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Registry — modelId → adapter
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /** Registered adapters. Order carries NO routing authority — routeModel decides.
273
+ * Step A: claude only. A second backend appends here with its reserved prefix
274
+ * (e.g. `<backend>-*`), and the fail-fast below proves no two adapters claim one id. */
275
+ const ADAPTERS: readonly AcpBackendAdapter[] = [claudeAdapter];
276
+
277
+ /**
278
+ * Resolve the backend adapter that owns `modelId`.
279
+ *
280
+ * GPT-agreed §9-1 fail-fast contract:
281
+ * - 0 matches → throw (unknown model — no silent default).
282
+ * - 2+ matches → throw (prefix collision — a startup-visible registry bug).
283
+ *
284
+ * The thrown native id is recovered from the single matching adapter's routeModel,
285
+ * so callers get `{ adapter, nativeModelId }` and never re-parse the id string.
286
+ */
287
+ export function resolveAcpBackendAdapter(modelId: string): { adapter: AcpBackendAdapter; nativeModelId: string } {
288
+ const matches: Array<{ adapter: AcpBackendAdapter; nativeModelId: string }> = [];
289
+ for (const adapter of ADAPTERS) {
290
+ const routed = adapter.routeModel(modelId);
291
+ if (routed) matches.push({ adapter, nativeModelId: routed.nativeModelId });
292
+ }
293
+ if (matches.length === 0) {
294
+ throw new Error(`entwurf: no ACP backend adapter owns model id ${JSON.stringify(modelId)}`);
295
+ }
296
+ if (matches.length > 1) {
297
+ const owners = matches.map((m) => m.adapter.backend).join(", ");
298
+ throw new Error(`entwurf: model id ${JSON.stringify(modelId)} is claimed by multiple adapters (${owners})`);
299
+ }
300
+ return matches[0];
301
+ }
302
+
303
+ /** Every curated model row across all registered adapters — for provider registration.
304
+ * GPT-agreed §9-6 registration-time fail-fast: every curated id must route to EXACTLY
305
+ * one adapter and no id may be duplicated across adapters. Catching it here means a
306
+ * prefix-collision / duplicate surfaces at provider registration, not mid-turn. */
307
+ export function allCuratedModels(): AcpModelRow[] {
308
+ const rows = ADAPTERS.flatMap((adapter) => adapter.curatedModels());
309
+ const seen = new Set<string>();
310
+ for (const row of rows) {
311
+ if (seen.has(row.id)) {
312
+ throw new Error(`entwurf: duplicate curated model id across ACP backend adapters: ${row.id}`);
313
+ }
314
+ seen.add(row.id);
315
+ // Throws on 0 matches (unowned) or 2+ matches (prefix collision).
316
+ resolveAcpBackendAdapter(row.id);
317
+ }
318
+ return rows;
319
+ }