@junghanacs/entwurf 0.52.0 → 0.53.1

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 (68) hide show
  1. package/AGENTS.md +1 -1
  2. package/BASELINE.md +116 -397
  3. package/CHANGELOG.md +131 -2
  4. package/CONTRIBUTING.md +4 -4
  5. package/DELIVERY.md +2 -2
  6. package/ENTWURF-CLI.json +1 -1
  7. package/README.md +7 -4
  8. package/VERIFY.md +332 -142
  9. package/demo/demo-baseline.sh +1 -1
  10. package/demo/demo.sh +1 -1
  11. package/docs/acp-backend-rail.md +248 -34
  12. package/docs/setup-clean-host.md +50 -65
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +90 -14
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +4 -9
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +15 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +9 -12
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +41 -56
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +33 -6
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +34 -3
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -1
  23. package/package.json +4 -4
  24. package/pi/settings.reference.json +0 -1
  25. package/pi-extensions/acp-provider.ts +12 -6
  26. package/pi-extensions/lib/acp/acp-client.ts +6 -7
  27. package/pi-extensions/lib/acp/backend-adapter.ts +139 -15
  28. package/pi-extensions/lib/acp/backend.ts +261 -32
  29. package/pi-extensions/lib/acp/config.ts +4 -9
  30. package/pi-extensions/lib/acp/context.ts +17 -0
  31. package/pi-extensions/lib/acp/event-mapper.ts +236 -17
  32. package/pi-extensions/lib/acp/model-ids.js +9 -12
  33. package/pi-extensions/lib/acp/models.ts +46 -57
  34. package/pi-extensions/lib/acp/tool-surface.ts +34 -6
  35. package/pi-extensions/lib/fresh-call-composition.ts +37 -3
  36. package/pi-extensions/lib/herdr-fresh-call.ts +3 -1
  37. package/pi-extensions/lib/mux-fresh-call.ts +3 -1
  38. package/run.sh +25 -86
  39. package/scripts/check-acp-carrier-augment.ts +3 -21
  40. package/scripts/check-acp-prompt-lifecycle.ts +24 -3
  41. package/scripts/check-acp-provider-surface.ts +17 -26
  42. package/scripts/check-acp-session-reuse.ts +23 -4
  43. package/scripts/check-acp-stop-reason.ts +22 -3
  44. package/scripts/check-acp-stream-hooks.ts +24 -3
  45. package/scripts/check-acp-usage-accounting.ts +53 -24
  46. package/scripts/check-copilot-receive-arm.ts +19 -2
  47. package/scripts/check-fresh-call-dispatch.ts +9 -3
  48. package/scripts/check-gate-qualification.ts +5 -5
  49. package/scripts/check-herdr-fresh-call.ts +1 -0
  50. package/scripts/check-install-surface.ts +6 -0
  51. package/scripts/check-probe-ordering.ts +34 -24
  52. package/scripts/check-release-gate-outcomes.ts +9 -23
  53. package/scripts/lib/probe-cli-target.ts +3 -2
  54. package/scripts/mutants/acp-augment.json +0 -26
  55. package/scripts/mutants/acp-cortex.json +10 -183
  56. package/scripts/mutants/acp-foreground-only.json +11 -0
  57. package/scripts/mutants/acp-launch-namespace.json +3 -1
  58. package/scripts/mutants/acp-prompt-lifecycle.json +2 -2
  59. package/scripts/mutants/acp-set-model-readback.json +488 -0
  60. package/scripts/mutants/mux-fresh-call.json +12 -1
  61. package/scripts/mutants/pi-floor.json +2 -2
  62. package/scripts/mutants/release-gate.json +3 -3
  63. package/scripts/raw-acp-compaction-measure/probe.ts +8 -2
  64. package/scripts/smoke-acp-cortex-live.ts +4 -408
  65. package/scripts/smoke-acp-ordering-probe-live.ts +7 -1
  66. package/scripts/smoke-acp-session-reuse-live.ts +11 -2
  67. package/scripts/smoke-herdr-raw-install-live.sh +4 -4
  68. package/scripts/check-acp-cortex.ts +0 -710
@@ -1,18 +1,12 @@
1
- // ACP plugin — curated model surface (S0 loader/fence slice; cortex rows added
2
- // in 0.13).
1
+ // ACP plugin — curated Claude model surface. Cortex ACP is disabled.
3
2
  //
4
3
  // entwurf is an ACP *plugin* on the v2 core, NOT a general-purpose
5
4
  // Anthropic provider. It deliberately does not expose the full pi-ai model
6
5
  // registry — the surface is curated to the ids the plugin commits to driving
7
6
  // through a real ACP backend.
8
7
  //
9
- // Two curated sets live here, one per adapter on the rail
10
- // (backend-adapter.ts): the unprefixed Claude ids (the reference backend) and
11
- // the `cortex-` prefixed Snowflake Cortex Code ids (the second backend, landed
12
- // 0.13.0 — docs/acp-backend-rail.md “Cortex Code audit”). Codex has native
13
- // delivery-probe evidence but no managed citizen lane; neither it nor Gemini is an ACP backend here.
14
- // A third governed backend EXTENDS this file the same way — it does not change
15
- // the pattern.
8
+ // Only the unprefixed Claude IDs are registered. Historical Cortex IDs are
9
+ // reserved refusal inputs; native citizens are independent of this provider.
16
10
  // pi 0.80 migration: the standalone root `getModels()` moved to the deprecated
17
11
  // `@earendil-works/pi-ai/compat` entrypoint (the global-API churn). We import it
18
12
  // from `/compat` directly.
@@ -36,7 +30,7 @@ import { CURATED_ANCHOR_MODEL_ID, SUPPORTED_ANTHROPIC_MODEL_IDS } from "./model-
36
30
  // The provider id and the curated ID lists live in the dependency-free
37
31
  // `model-ids.js` leaf, shared with the launch seams (#128); this file is the only
38
32
  // one that loads the upstream catalog to turn them into registry rows.
39
- export { CORTEX_MODEL_PREFIX, CURATED_ANCHOR_MODEL_ID, PROVIDER_ID, SUPPORTED_ANTHROPIC_MODEL_IDS, SUPPORTED_CORTEX_MODEL_IDS, } from "./model-ids.js";
33
+ export { CORTEX_MODEL_PREFIX, CURATED_ANCHOR_MODEL_ID, LEGACY_CORTEX_MODEL_IDS, PROVIDER_ID, SUPPORTED_ANTHROPIC_MODEL_IDS, } from "./model-ids.js";
40
34
  // #26 auth-boundary sentinel. `pi.registerProvider` requires an apiKey when a
41
35
  // provider defines custom models, but the ACP plugin consumes NO key: backend
42
36
  // auth belongs to the operator's own Claude CLI child process (AGENTS
@@ -70,54 +64,44 @@ function requireRegistryModel(models, id) {
70
64
  function claudeContextWindow(model) {
71
65
  return Math.min(model.contextWindow, CLAUDE_CONTEXT_DEFAULT);
72
66
  }
73
- // ── Cortex (Snowflake Cortex Code) curated surface ──────────────────────────
74
- //
75
- // entwurf's ACP rail is backend-extensible: Cortex is the first NON-claude
76
- // backend to land through it (docs/acp-backend-rail.md, “Shipped adapters”). pi-ai carries no
77
- // snowflake/cortex model source, so this surface is HAND-CURATED. Every id
78
- // carries the reserved `cortex-` prefix: the prefix is the SINGLE routing
79
- // authority (backend-adapter.ts `routeModel`) and keeps the ids off the Claude
80
- // native ids Cortex routes to (`cortex-claude-sonnet-5` vs the unprefixed
81
- // `claude-sonnet-5-5` the claude adapter owns). `routeModel` strips the prefix to
82
- // recover the native id, which is enforced per-turn via
83
- // session/set_config_option — never a launch `-m` pin (CP0-M).
84
- //
85
- // The 4-row set is the GLG-decided curation (2026-07-29) — a subset of the 11
86
- // native ids cortex v1.1.52 exposed live when authenticated. A curated id that
87
- // the running cortex no longer serves fails LOUD at set-model, before the
88
- // prompt (measured: `Unsupported model: …` names the live set). Adding an id
89
- // here is the same verify-both-axes commitment as the Claude set — do not
90
- // extend casually. The prefix and the four ids are declared in model-ids.js.
91
- // Hand-set conservative context window. Cortex reports the LIVE window via its
92
- // own ACP session config, not this curated metadata — this is a floor for the
93
- // registry surface, deliberately not inflated to the Claude 1M.
94
- const CORTEX_CONTEXT_DEFAULT = 200_000;
67
+ // Cortex ACP is disabled; its former IDs are refusal inputs, not model rows.
95
68
  /**
96
- * The curated Cortex model rows handed to the single `entwurf` provider via
97
- * `allCuratedModels()`. The two Claude rows ride their OWN registry base
98
- * (opus-5 / sonnet-5); `auto` (default family Claude) and the GPT row (no pi-ai
99
- * source) ride the sonnet-5 base as a metadata floor. id / name / contextWindow
100
- * are overridden; the `cortex-` prefix keeps them from colliding with the
101
- * Claude curated ids. Same row shape as `curatedClaudeModels()` (AcpModelRow).
69
+ * What an ACP row declares it can take as input: text only. The rail delivers pi's transcript as
70
+ * ACP text blocks; an image becomes the marker `[image omitted: <mime>]` (`context.ts`), and no
71
+ * other path carries image bytes to the backend. pi's catalog lists the Anthropic models as
72
+ * `["text", "image"]` — true of the model behind the API, not of this transport — so copying it
73
+ * would advertise a capability the rail does not deliver (#142 landing review). A backend gains
74
+ * `image` here only together with a real image path and its proof.
102
75
  */
76
+ const ACP_ROW_INPUT = ["text"];
77
+ /** The retired Cortex adapter contributes no supported model rows. */
103
78
  export function curatedCortexModels() {
104
- const sonnetBase = requireRegistryModel(ANTHROPIC_MODELS_ALL, "claude-sonnet-5");
105
- const opusBase = requireRegistryModel(ANTHROPIC_MODELS_ALL, "claude-opus-5");
106
- const row = (id, name, base) => ({
107
- id,
108
- name,
109
- reasoning: base.reasoning,
110
- input: base.input,
111
- cost: base.cost,
112
- contextWindow: CORTEX_CONTEXT_DEFAULT,
113
- maxTokens: base.maxTokens,
114
- });
115
- return [
116
- row("cortex-auto", "Cortex · Auto", sonnetBase),
117
- row("cortex-claude-opus-5", "Cortex · Claude Opus 5", opusBase),
118
- row("cortex-claude-sonnet-5", "Cortex · Claude Sonnet 5", sonnetBase),
119
- row("cortex-openai-gpt-5.4", "Cortex · OpenAI GPT-5.4", sonnetBase),
120
- ];
79
+ return [];
80
+ }
81
+ /**
82
+ * The effort values claude-agent-acp offers on its `effort` config option (category
83
+ * `thought_level`). `[측정 2026-11-13]` 0.106.0, authenticated, no prompt (#142): the option lists
84
+ * `default, low, medium, high, xhigh, max`; `high → low → max` each read back on one live session and
85
+ * `minimal` was refused `-32602`. `default` is the vendor's own choice, so no pi level maps to it.
86
+ */
87
+ export const CLAUDE_ACP_EFFORT_VALUES = ["low", "medium", "high", "xhigh", "max"];
88
+ /**
89
+ * The pi `thinkingLevelMap` an ACP Claude row registers (#142). pi's documented model seam: the
90
+ * value is what the provider sends for each pi level, `null` marks a level unsupported, and xhigh/max
91
+ * appear only when mapped (`getSupportedThinkingLevels`). Each supported pi level maps to the effort
92
+ * value of the same name. `off` and `minimal` have no effort value, so they are `null` and never
93
+ * offered; a level the registry already marks unsupported for this model stays unsupported, and
94
+ * xhigh/max stay hidden where the registry does not map them. Nothing here is a model catalog: the
95
+ * registry decides which levels the model has, this decides which of them ACP can carry.
96
+ */
97
+ export function claudeAcpThinkingLevelMap(registry) {
98
+ const map = { off: null, minimal: null };
99
+ for (const level of CLAUDE_ACP_EFFORT_VALUES) {
100
+ const declared = registry?.[level];
101
+ const unmapped = declared === undefined && (level === "xhigh" || level === "max");
102
+ map[level] = declared === null || unmapped ? null : level;
103
+ }
104
+ return map;
121
105
  }
122
106
  /**
123
107
  * The curated Claude model rows handed to `pi.registerProvider({ models })`.
@@ -130,7 +114,8 @@ export function curatedClaudeModels() {
130
114
  id: m.id,
131
115
  name: m.name,
132
116
  reasoning: m.reasoning,
133
- input: m.input,
117
+ thinkingLevelMap: claudeAcpThinkingLevelMap(m.thinkingLevelMap),
118
+ input: [...ACP_ROW_INPUT],
134
119
  cost: m.cost,
135
120
  contextWindow: claudeContextWindow(m),
136
121
  maxTokens: m.maxTokens,
@@ -12,13 +12,10 @@
12
12
  //
13
13
  // The module's TWO jobs have different backend scopes — do not collapse them:
14
14
  // - `buildClaudeSessionMeta` is claude-only by CONSTRUCTION, with exactly one
15
- // caller (`claudeAdapter.buildSessionMeta`). Cortex exposes no
16
- // `_meta.systemPrompt` and no tools/permission declaration surface (native
17
- // tools + its own overlay mcp.json projection), so its adapter returns
18
- // `undefined` and that function is never reached for a cortex turn.
15
+ // caller (`claudeAdapter.buildSessionMeta`).
19
16
  // - `assertExcludeToolsHonored` is BACKEND-INVARIANT: backend.ts runs it for
20
- // every adapter before any spawn, passing `backend: adapter.backend` and the
21
- // resolved `config.tools`. A cortex turn DOES enter this module through it.
17
+ // every admitted adapter before any spawn, passing `backend: adapter.backend`
18
+ // and the resolved `config.tools`. Cortex ACP is disabled.
22
19
  // That split is why the `backend` field exists: the honest-divergence logic stays
23
20
  // explicit and the matrix gate can exercise both the "claude narrows via tools"
24
21
  // and "native always exposes" branches.
@@ -104,6 +101,28 @@ export function assertExcludeToolsHonored(activeToolNames, resolved) {
104
101
  ". Extension tools (entwurf_v2, entwurf_peers) can be excluded freely — they are pi-side and never reach the backend.");
105
102
  }
106
103
  }
104
+ /**
105
+ * #163: is this Pi's own summarization request — the one tool-less turn the preflight above
106
+ * honours instead of refusing?
107
+ *
108
+ * Pi summarizes for `/compact`, the compaction threshold, overflow recovery and `/tree` branch
109
+ * summaries through one call (pi-coding-agent 1.5.2 `dist/core/compaction/compaction.js:476-489`,
110
+ * `completeSummarization`; branch summaries at `compaction/branch-summarization.js:233-237`). Its
111
+ * context declares no tools (`buildSummarizationContext`, `compaction.js:499-509`) and its options
112
+ * carry `cacheRetention: "none"` (`compaction.js:482`), which an ordinary agent-loop turn does not
113
+ * (pi-agent-core `dist/agent.js:306-330`).
114
+ *
115
+ * BOTH are required. An empty surface alone is also an operator who excluded every tool on an
116
+ * ordinary turn, which keeps today's refusal; `cacheRetention` alone is a request option, not a
117
+ * surface. Neither the summarization prompt's text nor a missing session id is read: Pi fills the
118
+ * id with a fresh `uuidv7()` (`compaction.js:481`), and matching its prompt would copy it.
119
+ * `cacheRetention` is a Pi convention, not a contract — the oracle beside this calls Pi's own
120
+ * `completeSummarization` so a Pi upgrade that drops it turns that test red rather than this
121
+ * path silently back into a refusal.
122
+ */
123
+ export function isPiSummaryRequest(activeToolNames, cacheRetention) {
124
+ return activeToolNames.length === 0 && cacheRetention === "none";
125
+ }
107
126
  /**
108
127
  * Build the `_meta` object handed to `newSession` for a Claude ACP session.
109
128
  *
@@ -144,6 +163,14 @@ export function buildClaudeSessionMeta(params, normalizedSystemPrompt) {
144
163
  // the carrier's "doesn't-know" — keeping both means a future preset/SDK
145
164
  // change cannot silently re-open memory through a channel the strip misses.
146
165
  autoMemoryEnabled: false,
166
+ // No turn of the adapter's own (#142). claude-agent-acp 0.101.0 compacts an idle session
167
+ // before its 1h prompt cache expires by sending `/compact` as a prompt nobody here issued;
168
+ // a retained session's callbacks drop updates that arrive with no active turn, so that
169
+ // model turn would spend quota and replace context invisibly to pi. 0.106.0 (#1555) reads
170
+ // this inline key when no managed tier sets one. Only the IDLE trigger is off:
171
+ // `autoCompactEnabled` stays untouched, so Claude Code's own compaction inside a prompt
172
+ // we sent keeps working.
173
+ idleCompaction: false,
147
174
  },
148
175
  };
149
176
  if (params.skillPlugins.length > 0) {
@@ -22,6 +22,7 @@
22
22
  * (#116 D1), and its two-step launch breaks the ordering argument outright.
23
23
  */
24
24
  import { randomBytes } from "node:crypto";
25
+ import { PROVIDER_ID, SUPPORTED_ANTHROPIC_MODEL_IDS } from "./acp/model-ids.js";
25
26
  /** The backends this rail can open. Fixed set, not a profile — a further one is a decision,
26
27
  * not a config entry. `copilot` was added by #82 RAIL 9, `omp` by #87 Bundle C, `codex`
27
28
  * by #95 after its system birth and app-server rails were measured, and `pi-durable` by #129
@@ -142,6 +143,35 @@ export const FRESH_CALL_TOOL_LOAD_HINT = {
142
143
  codex: [],
143
144
  "pi-durable": [],
144
145
  };
146
+ /**
147
+ * Which dialect table spells the bridge tools for THIS launch. The backend picks the runtime; the
148
+ * model can pick a different tool surface underneath it.
149
+ *
150
+ * `[측정 2026-11-13, D1]` a pi sibling opened on an ACP Claude model (`entwurf/claude-*`) does not
151
+ * see Pi's MCP names: its turns run inside the Claude Agent SDK, which mints the bridge tools as
152
+ * `mcp__entwurf-bridge__*` (the ACP Sonnet interview of 2026-11-13, n=1, called them by the schema
153
+ * it was given, not by the pi spelling its framing named). So the NAMES follow the claude-code
154
+ * table. The load hint stays the pi row (none): in that n=1 the bridge tools were directly callable
155
+ * without loading. Whether ToolSearch exists on this surface, or deferral can occur with more
156
+ * declared servers (`acp/tool-surface.ts` names the SDK's deferred tools), is unmeasured — not a
157
+ * claim that it cannot.
158
+ *
159
+ * Only an exact curated Anthropic id switches. A thinking suffix (`:high`) or fuzzy alias never
160
+ * reaches here through the public verb: the dispatch root refuses any `entwurf/` id that is not
161
+ * exactly curated (`isUncuratedEntwurfPiModel`, #128), and the test pins that alignment. Retired
162
+ * Cortex ids are refused at the dispatch root before framing. The ACP id list arrives from
163
+ * `acp/model-ids.js`, an import-free module.
164
+ */
165
+ export function freshCallToolDialect(backend, model) {
166
+ if (backend !== "pi")
167
+ return backend;
168
+ const trimmed = model.trim();
169
+ const slash = trimmed.indexOf("/");
170
+ if (slash < 0 || trimmed.slice(0, slash) !== PROVIDER_ID)
171
+ return backend;
172
+ const id = trimmed.slice(slash + 1);
173
+ return SUPPORTED_ANTHROPIC_MODEL_IDS.includes(id) ? "claude-code" : backend;
174
+ }
145
175
  /**
146
176
  * The omp bootstrap flag, spelled WITHOUT dashes — the vendor's flag map is keyed by bare
147
177
  * name (`extensions/loader.ts:221-228`) and this composition adds the `--` itself.
@@ -379,9 +409,10 @@ export function composeBackendArgs(backend, composition, model, resolveCodexSock
379
409
  * corroborate the caller instead of an instruction not to look.
380
410
  */
381
411
  export function composeFreshCallFraming(params) {
382
- const birthTool = FRESH_CALL_CALLBACK_TOOL[params.backend];
383
- const deliveryTool = FRESH_CALL_DELIVERY_TOOL[params.backend];
384
- const peersTool = FRESH_CALL_PEERS_TOOL[params.backend];
412
+ const dialect = freshCallToolDialect(params.backend, params.model);
413
+ const birthTool = FRESH_CALL_CALLBACK_TOOL[dialect];
414
+ const deliveryTool = FRESH_CALL_DELIVERY_TOOL[dialect];
415
+ const peersTool = FRESH_CALL_PEERS_TOOL[dialect];
385
416
  // The hint is a per-backend TEMPLATE so the two tool dialects stay the single source of their
386
417
  // own spelling here too — a hint that hard-coded Claude's name would go stale the day a dialect
387
418
  // moves, and it would go stale silently, in the one sentence a stuck child depends on.
@@ -554,6 +554,7 @@ export async function herdrFreshCall(params, run, env, nonce, clock = HERDR_REAL
554
554
  return { ok: false, reason: badCwd };
555
555
  const framing = composeFreshCallFraming({
556
556
  backend,
557
+ model,
557
558
  callerGardenId,
558
559
  nonce,
559
560
  openingLine: HERDR_FRESH_CALL_OPENING_LINE,
@@ -1220,6 +1221,7 @@ async function herdrLayoutFreshCall(params, context, env, nonce, deps) {
1220
1221
  return { ok: false, reason: "herdr-socket-path-missing" };
1221
1222
  const prompt = composeFreshCallPrompt({
1222
1223
  backend: "pi-durable",
1224
+ model,
1223
1225
  task,
1224
1226
  callerGardenId,
1225
1227
  nonce,
@@ -1287,7 +1289,7 @@ export const HERDR_REJECT_HINT = {
1287
1289
  "model-invalid": "the model is not in the accepted grammar (no whitespace, no leading dash)",
1288
1290
  // #128: decided at the dispatch root (`fresh-call-dispatch.ts`), worded here because this rail's
1289
1291
  // import fence admits no ACP module. The curated list itself lives in `acp/model-ids.js`.
1290
- "acp-model-not-curated": "an entwurf model must be one exact curated ACP id — Pi would otherwise pick another row by substring (a retired claude-sonnet-5 lands on cortex-claude-sonnet-5), so nothing was opened and nothing was aliased",
1292
+ "acp-model-not-curated": "an entwurf model must be one exact curated ACP id; Cortex ACP is disabled. Pi may otherwise pick another row by substring, so nothing was opened or aliased",
1291
1293
  "task-empty": "a sibling opened with nothing to do is a window nobody asked for",
1292
1294
  "task-too-long": `the task is over the ${TASK_MAX_CHARS}-character interface bound this verb shares with entwurf_v2`,
1293
1295
  "herdr-context-missing": "HERDR_ENV/HERDR_BIN_PATH are absent, so this process is not inside herdr and this rail does not exist here",
@@ -331,7 +331,7 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
331
331
  const runtime = layout === null ? null : checkPiDurableRuntime(layout);
332
332
  const missing = piDurableFreshPreflight({
333
333
  model,
334
- firstInput: buildFreshCallPrompt({ backend: params.backend, task, callerGardenId, nonce }),
334
+ firstInput: buildFreshCallPrompt({ backend: params.backend, model, task, callerGardenId, nonce }),
335
335
  layout,
336
336
  runtime: runtime === null || runtime.ok ? null : runtime.reason,
337
337
  });
@@ -373,6 +373,7 @@ export function freshCall(params, env = process.env, nonce = mintNonce()) {
373
373
  const composition = {
374
374
  prompt: buildFreshCallPrompt({
375
375
  backend: params.backend,
376
+ model,
376
377
  task,
377
378
  callerGardenId,
378
379
  nonce,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.52.0",
4
- "description": "Visible sibling sessions across independent agent harnesses: launch in Herdr or tmux, retire on request, visibly reopen supported sessions under the same id, and deliver messages by garden id; native bridges and Claude/Cortex ACP adapters.",
3
+ "version": "0.53.1",
4
+ "description": "Visible sibling sessions across independent agent harnesses: launch in Herdr or tmux, retire on request, visibly reopen supported sessions under the same id, and deliver messages by garden id; native bridges and a Claude ACP adapter.",
5
5
  "license": "MIT",
6
6
  "author": {
7
7
  "name": "Junghan Kim",
@@ -102,8 +102,8 @@
102
102
  "claudeCodeFloor": ">=2.1.306"
103
103
  },
104
104
  "dependencies": {
105
- "@agentclientprotocol/claude-agent-acp": "0.90.0",
106
- "@agentclientprotocol/sdk": "1.8.0",
105
+ "@agentclientprotocol/claude-agent-acp": "0.107.0",
106
+ "@agentclientprotocol/sdk": "2.6.0",
107
107
  "@anthropic-ai/sdk": "0.100.1",
108
108
  "@earendil-works/chord": "1.5.2",
109
109
  "@earendil-works/pi-agent-core": "1.5.2",
@@ -32,7 +32,6 @@
32
32
  "WebFetch",
33
33
  "WebSearch"
34
34
  ],
35
- "cortexConnection": "MY_SNOWFLAKE_CONNECTION",
36
35
  "mcpServers": {
37
36
  "entwurf-bridge": {
38
37
  "command": "/path/to/entwurf/mcp/entwurf-bridge/start.sh",
@@ -3,9 +3,8 @@
3
3
  // This is the pi-extension entry point that registers `entwurf` as a pi
4
4
  // session provider/model. It is intentionally THIN: it stands up the provider
5
5
  // surface (every registered adapter's curated rows + the no-auth sentinel) and
6
- // wires streamSimple to the real ACP backend (lib/acp/backend.ts — a
7
- // spawn-per-turn ACP child, `claude-agent-acp` or `cortex acp serve` depending
8
- // on which adapter the model id routes to). It does NOT build a
6
+ // wires streamSimple to the real ACP backend (lib/acp/backend.ts — a Claude
7
+ // ACP child whose lifetime follows pi's lifecycle). It does NOT build a
9
8
  // socket/peers/citizen protocol or touch the v2 core — socket-citizenship is
10
9
  // supplied by the host `--entwurf-control` pi session (AGENTS §ACP Plugin
11
10
  // Boundary).
@@ -13,8 +12,8 @@
13
12
  // The model set comes from `allCuratedModels()` — the union across the adapter
14
13
  // registry (backend-adapter.ts), NOT a per-backend list spelled here. Adding a
15
14
  // backend therefore never edits this file. check-acp-provider-surface pins that
16
- // the compiled entry really registers the EXACT union
17
- // ([QK:CORTEX-PROVIDER-EXACT-ROW-SURFACE]).
15
+ // the compiled entry registers the exact supported Claude-only set
16
+ // ([QK:ACP-PROVIDER-CLAUDE-ONLY]).
18
17
  //
19
18
  // Fence: this entry rides the emit-capable root tsconfig (it is not in the root
20
19
  // `exclude` list); its lib modules are imported with `.js` suffixes (the root
@@ -22,7 +21,7 @@
22
21
  // strip-types fence is introduced.
23
22
 
24
23
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
- import { streamShellAcp } from "./lib/acp/backend.js";
24
+ import { closeRetainedAcpSession, streamShellAcp } from "./lib/acp/backend.js";
26
25
  import { allCuratedModels } from "./lib/acp/backend-adapter.js";
27
26
  import { ENTWURF_ACP_NO_AUTH_SENTINEL, PROVIDER_ID } from "./lib/acp/models.js";
28
27
 
@@ -65,6 +64,13 @@ export default function (pi: ExtensionAPI) {
65
64
  streamSimple: streamShellAcp,
66
65
  });
67
66
 
67
+ // #163, GLG 2026-11-14: a successful compaction closes the Claude child of this Pi session at
68
+ // once. The next turn opens a new child from the compacted transcript (backend.ts
69
+ // `closeRetainedAcpSession`). A session that never used an ACP model has nothing to close.
70
+ pi.on("session_compact", (_event, ctx) => {
71
+ closeRetainedAcpSession(ctx.sessionManager.getSessionId());
72
+ });
73
+
68
74
  // Mark only AFTER a successful registration. If allCuratedModels() (which
69
75
  // fail-fasts on an unowned/duplicate curated id, and whose claude rows carry a
70
76
  // fail-loud anchor check) or registerProvider throws, the runtime is not left
@@ -17,13 +17,12 @@
17
17
  // smokes both drive ONE adapter — the SDK method-name mapping lives here only,
18
18
  // and the backend's orchestration + the gate fakes stay untouched.
19
19
 
20
- import {
21
- type Usage as AcpWireUsage,
22
- AGENT_METHODS,
23
- CLIENT_METHODS,
24
- client,
25
- type Stream,
26
- } from "@agentclientprotocol/sdk";
20
+ import { AGENT_METHODS, CLIENT_METHODS, client, type Stream } from "@agentclientprotocol/sdk";
21
+ // SDK 2.x moved the experimental `Usage` out of the stable root into the `./unstable`
22
+ // entry (`dist/v1/unstable/schema/types.gen.d.ts`, marked UNSTABLE/@experimental). A
23
+ // type-only import: nothing of it reaches the emitted JS, and the wire carries the
24
+ // same `usage` object either way.
25
+ import type { Usage as AcpWireUsage } from "@agentclientprotocol/sdk/unstable";
27
26
  import type { AcpTextBlock } from "./context.js";
28
27
 
29
28
  /**
@@ -27,11 +27,12 @@ import type { AcpConnectionLike } from "./acp-client.js";
27
27
  import { enrichMcpServersWithEnvelope, type ResolvedAcpConfig } from "./config.js";
28
28
  import { loadEngraving } from "./engraving.js";
29
29
  import {
30
+ CLAUDE_ACP_EFFORT_VALUES,
30
31
  CORTEX_MODEL_PREFIX,
31
32
  curatedClaudeModels,
32
33
  curatedCortexModels,
34
+ LEGACY_CORTEX_MODEL_IDS,
33
35
  SUPPORTED_ANTHROPIC_MODEL_IDS,
34
- SUPPORTED_CORTEX_MODEL_IDS,
35
36
  } from "./models.js";
36
37
  import { claudeLaunchEnvDefaults, ensureClaudeConfigOverlay, ensureCortexDualHomeOverlay } from "./overlay.js";
37
38
  import { buildClaudeSessionMeta } from "./tool-surface.js";
@@ -57,7 +58,10 @@ export interface AcpLaunchSpec {
57
58
  }
58
59
 
59
60
  /** A curated provider model row (the shape pi.registerProvider({ models }) takes). */
60
- export type AcpModelRow = ReturnType<typeof curatedClaudeModels>[number];
61
+ /** `thinkingLevelMap` is optional: Claude rows declare the effort levels its driver can set (#142);
62
+ * cortex rows declare none and keep pi's default thinking handling. */
63
+ type ClaudeModelRow = ReturnType<typeof curatedClaudeModels>[number];
64
+ export type AcpModelRow = Omit<ClaudeModelRow, "thinkingLevelMap"> & Partial<Pick<ClaudeModelRow, "thinkingLevelMap">>;
61
65
 
62
66
  /** routeModel result — the backend-native model id with the curation prefix stripped. */
63
67
  export interface AcpRoutedModel {
@@ -112,6 +116,23 @@ export interface AcpEnforceModelParams {
112
116
  nativeModelId: string;
113
117
  }
114
118
 
119
+ /** resolveThinking input — pi's requested level and the registered row's level map. `reasoning`
120
+ * is `undefined` both when the pi thinking level is `off` and when none was given: pi-agent-core
121
+ * sends `thinkingLevel === "off" ? undefined : level`, so the provider cannot tell them apart. */
122
+ export interface AcpResolveThinkingParams {
123
+ modelId: string;
124
+ reasoning: string | undefined;
125
+ thinkingLevelMap: Readonly<Record<string, string | null | undefined>> | undefined;
126
+ }
127
+
128
+ /** enforceThinking input — the live connection + acp session + the resolved backend value. */
129
+ export interface AcpEnforceThinkingParams {
130
+ connection: AcpConnectionLike;
131
+ acpSessionId: string;
132
+ modelId: string;
133
+ value: string;
134
+ }
135
+
115
136
  /** ensureOverlay result — env overrides backend.ts merges into the spawn env. */
116
137
  export interface AcpOverlayResult {
117
138
  envOverrides: Record<string, string>;
@@ -211,6 +232,15 @@ export interface AcpBackendAdapter {
211
232
  * launch-pinned backend: no-op here). backend.ts wraps the call in withTimeout. */
212
233
  enforceModel(params: AcpEnforceModelParams): Promise<void>;
213
234
 
235
+ /** OPTIONAL capability (#142): carry pi's thinking level to the backend. Both or neither.
236
+ * `resolveThinking` is PURE — it maps the requested level to the backend value through the
237
+ * registered row's `thinkingLevelMap` and throws a named refusal when there is none, before
238
+ * any process or session is touched. `enforceThinking` sets that value on the live session
239
+ * and refuses unless the answer reads it back. ABSENT (cortex) keeps the old behaviour: the
240
+ * level is not carried, and nothing claims it is. */
241
+ resolveThinking?(params: AcpResolveThinkingParams): string;
242
+ enforceThinking?(params: AcpEnforceThinkingParams): Promise<void>;
243
+
214
244
  /** Backend-specific fields folded into bridgeConfigSignature (reuse invalidation):
215
245
  * connection/profile/env-derived STABLE ids only — never raw env values / secrets.
216
246
  * Reads ONLY this backend's opaque `adapterSettings` (NOT the whole config), so the
@@ -345,7 +375,7 @@ export const CLAUDE_FOREGROUND_ONLY_ENV = { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS
345
375
  * The set-model answer is read back before the prompt (#128). claude-agent-acp resolves the
346
376
  * requested id onto one of the rows it offers and answers with that row's `value` as the model
347
377
  * option's `currentValue` (0.85.1 `dist/acp-agent.js:5459-5499`). `[측정 2026-10-12]` 0.85.1, no
348
- * prompt: `claude-fable-5-1` answers its own row, while `claude-sonnet-5-5` and `claude-opus-5-5`
378
+ * prompt: `claude-fable-5-1` answers its own row (and, at 0.106.0 #142, so does `claude-fable-5-2`), while `claude-sonnet-5-5` and `claude-opus-5-5`
349
379
  * answer the alias rows `sonnet` and `opus`, named `Sonnet 5.5` and `Opus 5.5`. The id an alias row
350
380
  * resolves to (`ModelInfo.resolvedModel`) is not on the ACP wire, so an alias answer is accepted
351
381
  * only as the exact measured row, value AND name, declared here for the requested id. No other
@@ -385,6 +415,61 @@ export function confirmClaudeSetModelAnswer(response: unknown, nativeModelId: st
385
415
  );
386
416
  }
387
417
 
418
+ const CLAUDE_EFFORT_VALUE_SET: ReadonlySet<string> = new Set(CLAUDE_ACP_EFFORT_VALUES);
419
+
420
+ /** The named refusal for a thinking level ACP Claude cannot carry (#142). The text is fixed and
421
+ * must read as NON-transient to pi's retry and overflow classifiers: replaying the turn cannot
422
+ * change the level that was asked for. */
423
+ export const ACP_THINKING_LEVEL_UNSUPPORTED = "acp-thinking-level-unsupported";
424
+
425
+ /**
426
+ * Map pi's requested level to the claude-agent-acp effort value, or refuse by name (#142).
427
+ * The value is the registered row's `thinkingLevelMap` entry for that level — the documented pi
428
+ * meaning of the map — and must be one of the measured effort values. `undefined` (pi off, or no
429
+ * level) is refused rather than left at the vendor `default`: that would let the turn run at a
430
+ * depth nobody chose while the operator believes a level was applied.
431
+ */
432
+ export function resolveClaudeEffort(
433
+ modelId: string,
434
+ reasoning: string | undefined,
435
+ thinkingLevelMap: Readonly<Record<string, string | null | undefined>> | undefined,
436
+ ): string {
437
+ const value = reasoning === undefined ? undefined : thinkingLevelMap?.[reasoning];
438
+ if (typeof value === "string" && CLAUDE_EFFORT_VALUE_SET.has(value)) return value;
439
+ const asked =
440
+ reasoning === undefined
441
+ ? "no thinking level (pi sends none for `off`, and none when no level is set; the two are indistinguishable here)"
442
+ : `thinking level ${JSON.stringify(reasoning)}`;
443
+ throw new Error(
444
+ `entwurf: ${ACP_THINKING_LEVEL_UNSUPPORTED} — ${modelId} over Claude ACP carries effort ` +
445
+ `${CLAUDE_ACP_EFFORT_VALUES.join("/")} only, and was asked for ${asked}. Choose one of those levels; ` +
446
+ `the turn was not sent, and repeating it at the same level cannot change that.`,
447
+ );
448
+ }
449
+
450
+ function claudeEffortRefusal(modelId: string, value: string, what: string, cause?: unknown): Error {
451
+ const error = new Error(
452
+ `entwurf: claude-agent-acp ${what} for effort ${JSON.stringify(value)} on ${modelId}. Refusing before the prompt ` +
453
+ `so the turn cannot run at an effort nobody chose; repeating the turn cannot change the driver's answer.`,
454
+ );
455
+ if (cause !== undefined) (error as Error & { cause?: unknown }).cause = cause;
456
+ return error;
457
+ }
458
+
459
+ /** Refuse unless the set-effort answer reads back the requested value. */
460
+ export function confirmClaudeEffortAnswer(response: unknown, modelId: string, value: string): void {
461
+ const configOptions = (response as { configOptions?: unknown } | null | undefined)?.configOptions;
462
+ const effort = Array.isArray(configOptions)
463
+ ? (configOptions as Array<Record<string, unknown> | null>).find((option) => option?.id === "effort")
464
+ : undefined;
465
+ if (effort?.currentValue === value) return;
466
+ throw claudeEffortRefusal(
467
+ modelId,
468
+ value,
469
+ effort == null ? "answered with no effort option" : `answered effort ${jsonText(effort.currentValue)}`,
470
+ );
471
+ }
472
+
388
473
  export const claudeAdapter: AcpBackendAdapter = {
389
474
  backend: "claude",
390
475
 
@@ -456,6 +541,24 @@ export const claudeAdapter: AcpBackendAdapter = {
456
541
  confirmClaudeSetModelAnswer(answer, nativeModelId);
457
542
  },
458
543
 
544
+ resolveThinking({ modelId, reasoning, thinkingLevelMap }) {
545
+ return resolveClaudeEffort(modelId, reasoning, thinkingLevelMap);
546
+ },
547
+
548
+ async enforceThinking({ connection, acpSessionId, modelId, value }) {
549
+ const setConfig = connection.setSessionConfigOption;
550
+ if (typeof setConfig !== "function") {
551
+ throw new Error(`setSessionConfigOption unsupported — cannot set effort for ${modelId}`);
552
+ }
553
+ let answer: unknown;
554
+ try {
555
+ answer = await setConfig.call(connection, { sessionId: acpSessionId, configId: "effort", value });
556
+ } catch (err) {
557
+ throw claudeEffortRefusal(modelId, value, "refused the set", err);
558
+ }
559
+ confirmClaudeEffortAnswer(answer, modelId, value);
560
+ },
561
+
459
562
  configSignatureFields(_adapterSettings) {
460
563
  // Claude folds no extra backend-specific fields beyond backend + nativeModelId
461
564
  // (which backend.ts adds). A future backend reads its own stable id off
@@ -480,17 +583,11 @@ export const claudeAdapter: AcpBackendAdapter = {
480
583
  // Registry — modelId → adapter
481
584
  // ---------------------------------------------------------------------------
482
585
 
483
- /** Registered adapters. Order carries NO routing authority — routeModel decides.
484
- * claude (unprefixed ids) + cortex (the `cortex-` prefix). A further backend
485
- * appends here with its own reserved prefix; the fail-fast below proves no two
486
- * adapters claim one id. */
487
- // ---------------------------------------------------------------------------
488
- // cortex adapter — Snowflake Cortex Code, the first non-claude backend on the
489
- // rail (docs/acp-backend-rail.md, “Shipped adapters”). It adds ZERO to the common layer:
490
- // everything cortex-specific lives here + models.ts + overlay.ts + the gates.
491
- // ---------------------------------------------------------------------------
586
+ // The former Cortex implementation is retained privately for historical source
587
+ // continuity only. The exported adapter below denies every side-effecting entry,
588
+ // and the production registry does not contain this implementation.
492
589
 
493
- const SUPPORTED_CORTEX_IDS: ReadonlySet<string> = new Set(SUPPORTED_CORTEX_MODEL_IDS);
590
+ const SUPPORTED_CORTEX_IDS: ReadonlySet<string> = new Set(LEGACY_CORTEX_MODEL_IDS);
494
591
 
495
592
  /** Cortex's OWN settings: a Snowflake connection name, or null. Opaque to
496
593
  * config.ts / backend.ts — only cortexAdapter reads it (casting back). */
@@ -503,7 +600,7 @@ export interface CortexAdapterSettings {
503
600
  * ENTWURF_ACP_* convention (PR #40's legacy PI_SHELL_ACP* cortex-connection var renamed). */
504
601
  export const CORTEX_CONNECTION_ENV = "ENTWURF_ACP_CORTEX_CONNECTION";
505
602
 
506
- export const cortexAdapter: AcpBackendAdapter = {
603
+ const retiredCortexAdapter: AcpBackendAdapter = {
507
604
  backend: "cortex",
508
605
 
509
606
  // Cortex owns the reserved `cortex-` prefix (rail “Adapter contract”). routeModel strips it to the
@@ -649,7 +746,31 @@ export const cortexAdapter: AcpBackendAdapter = {
649
746
  // errorMessage. Borrowing claude's `incomplete_tool_call` reader would be a guess.
650
747
  };
651
748
 
652
- const ADAPTERS: readonly AcpBackendAdapter[] = [claudeAdapter, cortexAdapter];
749
+ const cortexDisabled = (): never => {
750
+ throw new Error("entwurf: cortex-acp-disabled: Cortex ACP is not supported");
751
+ };
752
+
753
+ /** Retained exported shape refuses the retired adapter before a launch or overlay can be made. */
754
+ export const cortexAdapter: AcpBackendAdapter = {
755
+ ...retiredCortexAdapter,
756
+ routeModel: () => undefined,
757
+ curatedModels: () => [],
758
+ resolveAdapterSettings: cortexDisabled,
759
+ resolveLaunch: cortexDisabled,
760
+ launchEnvDefaults: cortexDisabled,
761
+ ensureOverlay: cortexDisabled,
762
+ loadCarrier: cortexDisabled,
763
+ buildSessionMeta: cortexDisabled,
764
+ enforceModel: cortexDisabled,
765
+ configSignatureFields: cortexDisabled,
766
+ };
767
+
768
+ const ADAPTERS: readonly AcpBackendAdapter[] = [claudeAdapter];
769
+
770
+ /** A declared backend is only a guard, never a replacement routing authority. */
771
+ export function assertSupportedDeclaredBackend(backend: string | undefined): void {
772
+ if (backend?.toLowerCase() === "cortex") cortexDisabled();
773
+ }
653
774
 
654
775
  /**
655
776
  * Resolve the backend adapter that owns `modelId`.
@@ -662,6 +783,9 @@ const ADAPTERS: readonly AcpBackendAdapter[] = [claudeAdapter, cortexAdapter];
662
783
  * so callers get `{ adapter, nativeModelId }` and never re-parse the id string.
663
784
  */
664
785
  export function resolveAcpBackendAdapter(modelId: string): { adapter: AcpBackendAdapter; nativeModelId: string } {
786
+ if (modelId.toLowerCase().startsWith(CORTEX_MODEL_PREFIX)) {
787
+ throw new Error(`entwurf: cortex-acp-disabled: ${JSON.stringify(modelId)} is not supported`);
788
+ }
665
789
  const matches: Array<{ adapter: AcpBackendAdapter; nativeModelId: string }> = [];
666
790
  for (const adapter of ADAPTERS) {
667
791
  const routed = adapter.routeModel(modelId);