@junghanacs/entwurf 0.51.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 (88) hide show
  1. package/AGENTS.md +1 -1
  2. package/BASELINE.md +116 -397
  3. package/CHANGELOG.md +191 -2
  4. package/CONTRIBUTING.md +4 -4
  5. package/DELIVERY.md +2 -2
  6. package/ENTWURF-CLI.json +1 -1
  7. package/ENTWURF.json +10 -4
  8. package/README.md +11 -8
  9. package/VERIFY.md +333 -143
  10. package/demo/README.md +1 -1
  11. package/demo/demo-baseline.sh +1 -1
  12. package/demo/demo.sh +1 -1
  13. package/docs/acp-backend-rail.md +248 -34
  14. package/docs/setup-clean-host.md +75 -80
  15. package/mcp/entwurf-bridge/dist/ENTWURF.json +10 -4
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +90 -14
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +4 -9
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +15 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +9 -12
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +41 -56
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +33 -6
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +4 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +34 -3
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +1 -1
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -1
  28. package/package.json +14 -14
  29. package/pi/pi-durable/carrier/harness-setup.js +1 -1
  30. package/pi/pi-durable/carrier/subagent.js +1 -1
  31. package/pi/pi-durable/overlay/upstream-pin.json +15 -15
  32. package/pi/settings.reference.json +0 -1
  33. package/pi-extensions/acp-provider.ts +12 -6
  34. package/pi-extensions/lib/acp/acp-client.ts +6 -7
  35. package/pi-extensions/lib/acp/backend-adapter.ts +139 -15
  36. package/pi-extensions/lib/acp/backend.ts +261 -32
  37. package/pi-extensions/lib/acp/config.ts +4 -9
  38. package/pi-extensions/lib/acp/context.ts +17 -0
  39. package/pi-extensions/lib/acp/event-mapper.ts +236 -17
  40. package/pi-extensions/lib/acp/model-ids.js +9 -12
  41. package/pi-extensions/lib/acp/models.ts +46 -57
  42. package/pi-extensions/lib/acp/tool-surface.ts +34 -6
  43. package/pi-extensions/lib/entwurf-control-rpc.ts +5 -1
  44. package/pi-extensions/lib/fresh-call-composition.ts +37 -3
  45. package/pi-extensions/lib/herdr-fresh-call.ts +3 -1
  46. package/pi-extensions/lib/meta-mailbox-body.ts +1 -1
  47. package/pi-extensions/lib/mux-fresh-call.ts +3 -1
  48. package/pi-extensions/meta-bridge-pi-durable.ts +1 -1
  49. package/run.sh +62 -120
  50. package/scripts/check-acp-carrier-augment.ts +3 -21
  51. package/scripts/check-acp-prompt-lifecycle.ts +24 -3
  52. package/scripts/check-acp-provider-surface.ts +17 -26
  53. package/scripts/check-acp-session-reuse.ts +23 -4
  54. package/scripts/check-acp-stop-reason.ts +22 -3
  55. package/scripts/check-acp-stream-hooks.ts +24 -3
  56. package/scripts/check-acp-usage-accounting.ts +53 -24
  57. package/scripts/check-copilot-receive-arm.ts +19 -2
  58. package/scripts/check-entwurf-v2-surface.ts +7 -21
  59. package/scripts/check-fresh-call-dispatch.ts +9 -3
  60. package/scripts/check-gate-qualification.ts +6 -6
  61. package/scripts/check-herdr-fresh-call.ts +1 -0
  62. package/scripts/check-install-surface.ts +6 -0
  63. package/scripts/check-pi-durable-send.ts +4 -1
  64. package/scripts/check-probe-ordering.ts +34 -24
  65. package/scripts/check-release-gate-outcomes.ts +9 -23
  66. package/scripts/lib/codex-fresh-source-receipts.ts +12 -6
  67. package/scripts/lib/probe-cli-target.ts +3 -2
  68. package/scripts/mutants/acp-augment.json +0 -26
  69. package/scripts/mutants/acp-cortex.json +10 -183
  70. package/scripts/mutants/acp-foreground-only.json +11 -0
  71. package/scripts/mutants/acp-launch-namespace.json +3 -1
  72. package/scripts/mutants/acp-prompt-lifecycle.json +2 -2
  73. package/scripts/mutants/acp-set-model-readback.json +488 -0
  74. package/scripts/mutants/agent-guidance.json +2 -2
  75. package/scripts/mutants/codex-native.json +2 -2
  76. package/scripts/mutants/control-send-receipt.json +47 -7
  77. package/scripts/mutants/mux-fresh-call.json +12 -1
  78. package/scripts/mutants/pack-install.json +4 -4
  79. package/scripts/mutants/pi-floor.json +8 -10
  80. package/scripts/mutants/release-gate.json +3 -3
  81. package/scripts/mutants/v2-surface.json +4 -6
  82. package/scripts/raw-acp-compaction-measure/probe.ts +8 -2
  83. package/scripts/smoke-acp-cortex-live.ts +4 -408
  84. package/scripts/smoke-acp-ordering-probe-live.ts +7 -1
  85. package/scripts/smoke-acp-session-reuse-live.ts +11 -2
  86. package/scripts/smoke-acp-v2-send-live.ts +1 -1
  87. package/scripts/smoke-herdr-raw-install-live.sh +4 -4
  88. package/scripts/check-acp-cortex.ts +0 -710
@@ -28,6 +28,9 @@ import type { AssistantMessage, AssistantMessageEventStream } from "@earendil-wo
28
28
 
29
29
  const NOTICE_TITLE_MAX = 80;
30
30
  const NOTICE_SUMMARY_MAX = 160;
31
+ /** #163 M2: a result preview shows at most this many lines and UTF-8 bytes of output. */
32
+ const PREVIEW_MAX_LINES = 5;
33
+ const PREVIEW_MAX_BYTES = 400;
31
34
 
32
35
  /** Identity fields for the running assistant message. */
33
36
  export interface AcpStreamIdentity {
@@ -39,6 +42,8 @@ export interface AcpStreamIdentity {
39
42
  type ObservedToolState = {
40
43
  title: string;
41
44
  status?: string;
45
+ /** #163 M3: the compaction line for this call was already shown. */
46
+ compactionShown?: boolean;
42
47
  };
43
48
 
44
49
  export type AcpPiStreamState = {
@@ -48,6 +53,13 @@ export type AcpPiStreamState = {
48
53
  openThinkingIndex?: number;
49
54
  /** When false, tool/permission notices are suppressed (kept terse for smokes). */
50
55
  showToolNotifications?: boolean;
56
+ /**
57
+ * #163: no notice of any kind — tool, permission, display-only or lifecycle. Set only for
58
+ * Pi's one-shot summary turn. Pi stores that summary as `contentText(response.content)`,
59
+ * which joins every text block (pi-ai `dist/utils/text.js`) without reading a signature,
60
+ * so a notice here would become part of the summary the session file keeps.
61
+ */
62
+ quiet?: boolean;
51
63
  observedTools?: Map<string, ObservedToolState>;
52
64
  /**
53
65
  * RAW, UNINTERPRETED observations from this turn's `usage_update` notifications
@@ -162,7 +174,7 @@ function ensureThinkingBlock(state: AcpPiStreamState): number {
162
174
  * permission events — informational, NOT structured tool calls.
163
175
  */
164
176
  function pushNotice(state: AcpPiStreamState, text: string): void {
165
- if (!state.showToolNotifications || !text.trim()) return;
177
+ if (state.quiet || !state.showToolNotifications || !text.trim()) return;
166
178
  closeThinkingBlock(state);
167
179
  closeTextBlock(state);
168
180
  const index = state.output.content.length;
@@ -172,17 +184,60 @@ function pushNotice(state: AcpPiStreamState, text: string): void {
172
184
  state.stream.push({ type: "text_end", contentIndex: index, content: text, partial: state.output });
173
185
  }
174
186
 
187
+ // #163: terminal escape sequences, the same range Pi strips from tool output before it shows it
188
+ // (pi-coding-agent 1.5.2 `dist/utils/ansi.js` `stripAnsi`, derived from chalk/ansi-regex, MIT,
189
+ // Copyright (c) Sindre Sorhus). OSC runs to its first terminator (BEL, ESC \\ or 0x9C); CSI and
190
+ // its relatives take optional parameters and one final byte. Pi does not export it from its
191
+ // package root, and this bridge imports Pi only by the root (`check-pi-import-surface`).
192
+ const ANSI_SEQUENCE = new RegExp(
193
+ "(?:\\u001B\\][\\s\\S]*?(?:\\u0007|\\u001B\\u005C|\\u009C))|" +
194
+ "(?:(?:\\u001B\\[|\\u009B)[<=>]|[\\u001B\\u009B][[\\]()#;?]*)(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]",
195
+ "g",
196
+ );
197
+ // What is left that a terminal would act on: C0 except tab, LF and CR; DEL and C1; and
198
+ // U+FFF9..U+FFFB, which Pi also drops because they crash width measurement
199
+ // (`dist/utils/shell.js` `sanitizeBinaryOutput`).
200
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching these characters is the point.
201
+ const CONTROL_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uFFF9-\uFFFB]/g;
202
+
203
+ /**
204
+ * #163: no text that a backend or a tool's output supplies reaches the operator's terminal as a
205
+ * control sequence. A window-title OSC 0 or a clipboard-write OSC 52 in a command's output would
206
+ * otherwise pass straight through the notice line and the pi-tui renderer. Every notice text
207
+ * goes through here: title, summary, preview and permission.
208
+ */
209
+ function stripTerminalControls(text: string): string {
210
+ return text.replace(ANSI_SEQUENCE, "").replace(CONTROL_CHARS, "");
211
+ }
212
+
213
+ /** Neutralize backtick fences (which would swallow following lines in chat renderers) and clip with an ellipsis. */
214
+ function fenceSafeClip(text: string, max: number): string {
215
+ const fenceSafe = text.replace(/`{3,}/g, "[fence]").replace(/`/g, "'");
216
+ if (fenceSafe.length <= max) return fenceSafe;
217
+ return `${fenceSafe.slice(0, max - 1)}…`;
218
+ }
219
+
175
220
  /**
176
221
  * Sanitize an inline fragment for the one-line `[tool:*]` / `[permission:*]`
177
- * notice surface: collapse whitespace, neutralize backtick fences (which would
178
- * otherwise swallow following lines in chat renderers), truncate with ellipsis.
222
+ * notice surface: strip terminal controls, collapse whitespace, neutralize
223
+ * backtick fences, truncate with ellipsis.
179
224
  */
180
225
  export function sanitizeNoticeFragment(text: string | null | undefined, max: number): string {
181
226
  if (!text) return "";
182
- const collapsed = text.replace(/\s+/g, " ").trim();
183
- const fenceSafe = collapsed.replace(/`{3,}/g, "[fence]").replace(/`/g, "'");
184
- if (fenceSafe.length <= max) return fenceSafe;
185
- return `${fenceSafe.slice(0, max - 1)}…`;
227
+ return fenceSafeClip(stripTerminalControls(text).replace(/\s+/g, " ").trim(), max);
228
+ }
229
+
230
+ /**
231
+ * #163: one line of an output preview. Unlike a notice fragment it keeps its indentation, so a
232
+ * stack trace, YAML or a diff reads as it does in Pi: controls stripped, CR dropped and tabs
233
+ * expanded to three spaces as Pi's tool renderer does (`dist/core/tools/render-utils.js`
234
+ * `replaceTabs`), trailing space trimmed.
235
+ */
236
+ function sanitizePreviewLine(line: string): string {
237
+ return fenceSafeClip(
238
+ stripTerminalControls(line).replace(/\r/g, "").replace(/\t/g, " ").trimEnd(),
239
+ PREVIEW_MAX_BYTES,
240
+ );
186
241
  }
187
242
 
188
243
  function firstTextItem(arr: unknown[]): string | undefined {
@@ -229,6 +284,24 @@ function titleForTool(update: Record<string, unknown>, previousTitle?: string):
229
284
  return String(update?.title ?? previousTitle ?? meta?.claudeCode?.toolName ?? update?.toolCallId ?? "Tool");
230
285
  }
231
286
 
287
+ function isTerminalToolStatus(status: string | undefined): boolean {
288
+ return status === "completed" || status === "failed" || status === "cancelled";
289
+ }
290
+
291
+ /** The adapter's progress-beat elapsed seconds, or undefined when this update is not a beat. */
292
+ function elapsedSecondsOf(update: Record<string, unknown>): number | undefined {
293
+ const meta = update?._meta as { claudeCode?: { toolResponse?: { elapsedTimeSeconds?: unknown } } } | undefined;
294
+ const value = meta?.claudeCode?.toolResponse?.elapsedTimeSeconds;
295
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
296
+ }
297
+
298
+ function formatElapsed(seconds: number): string {
299
+ const whole = Math.floor(seconds);
300
+ if (whole < 60) return `${whole}s`;
301
+ const rest = whole % 60;
302
+ return rest === 0 ? `${Math.floor(whole / 60)}m` : `${Math.floor(whole / 60)}m${rest}s`;
303
+ }
304
+
232
305
  /**
233
306
  * Render an ACP tool_call / tool_call_update as a text notice (NEVER a
234
307
  * structured toolcall — the ACP child already executed it).
@@ -243,22 +316,51 @@ function renderToolUpdate(state: AcpPiStreamState, update: Record<string, unknow
243
316
  const updateContent = Array.isArray(update?.content) ? (update.content as unknown[]) : undefined;
244
317
 
245
318
  if (update.sessionUpdate === "tool_call") {
246
- observedTools.set(toolCallId, { title, status });
319
+ const observed: ObservedToolState = { title, status };
320
+ observedTools.set(toolCallId, observed);
247
321
  pushNotice(state, `\n[tool:start] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}\n`);
322
+ // The adapter opens a compaction it saw only at its end as one terminal `tool_call`.
323
+ if (isTerminalToolStatus(status)) pushCompactionLine(state, update, observed);
248
324
  return;
249
325
  }
250
326
 
251
- // tool_call_update. There is deliberately no mid-flight `[tool:running]`
252
- // notice: it would have to be driven by the adapter's `_meta.terminal_output`,
253
- // and that meta is gated upstream on `clientCapabilities._meta.terminal_output
254
- // === true` while backend.ts initializes with `clientCapabilities: {}`. So the
255
- // branch could never fire for any backend we ship — it claimed a transcript
256
- // line the operator was never going to see. Declaring the terminal capability
257
- // is a separate axis, not a one-line re-enable: the adapter would then send
258
- // terminal widgets/metas this mapper cannot render honestly into a transcript.
327
+ // tool_call_update. A running tool's only mid-flight signal is the SDK's
328
+ // `tool_progress` beat, which the adapter forwards WITHOUT any client
329
+ // capability as `_meta.claudeCode.toolResponse.elapsedTimeSeconds` (#162,
330
+ // measured 2026-11-13 on claude-agent-acp 0.106.0 with `clientCapabilities: {}`:
331
+ // a 120 s foreground Bash produced beats at 30/60/90 s, the first one also
332
+ // carrying `status: "in_progress"`). It carries no stdout: the adapter sends a
333
+ // foreground command's output only with its completion, even to a client that
334
+ // declares `_meta.terminal_output` (measured the same day). So the honest
335
+ // mid-flight line is liveness plus elapsed time, never partial output. It is
336
+ // display-only, like a lifecycle notice: elapsed beats depend on wall time, so
337
+ // they stay out of transcript replay and the reuse-compat signature.
338
+ const elapsed = elapsedSecondsOf(update);
339
+ const running = !isTerminalToolStatus(status) && !isTerminalToolStatus(previous?.status);
340
+ // #163 M1: what is running, once it is known. The first `tool_call` is sent before the input
341
+ // has streamed, so a Bash call opens as "Terminal" (claude-agent-acp 0.107.0
342
+ // `tool-calls/reporters/bash.js:22`) and the command arrives later as a title-only update.
343
+ // One display-only line names it. A beat in the same update already names the title, so
344
+ // that update shows the beat alone; a repeated title shows nothing.
345
+ if (
346
+ running &&
347
+ elapsed === undefined &&
348
+ previous !== undefined &&
349
+ typeof update.title === "string" &&
350
+ sanitizeNoticeFragment(update.title, NOTICE_TITLE_MAX) !== sanitizeNoticeFragment(previous.title, NOTICE_TITLE_MAX)
351
+ ) {
352
+ pushDisplayOnlyNotice(state, `\n[tool:running] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}\n`);
353
+ }
354
+ if (elapsed !== undefined && running) {
355
+ pushDisplayOnlyNotice(
356
+ state,
357
+ `\n[tool:running] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)} — ${formatElapsed(elapsed)}\n`,
358
+ );
359
+ }
259
360
  if (status && status !== previous?.status) {
260
361
  const summary = firstTextContent(update?.rawOutput) ?? firstTextContent(updateContent);
261
362
  const suffix = summary ? ` — ${sanitizeNoticeFragment(summary, NOTICE_SUMMARY_MAX)}` : "";
363
+ const failed = status !== "completed" || rawOutputHasError(update?.rawOutput);
262
364
  if (status === "completed") {
263
365
  const label = rawOutputHasError(update?.rawOutput) ? "tool:failed" : "tool:done";
264
366
  pushNotice(state, `\n[${label}] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}${suffix}\n`);
@@ -267,9 +369,109 @@ function renderToolUpdate(state: AcpPiStreamState, update: Record<string, unknow
267
369
  } else if (status === "cancelled") {
268
370
  pushNotice(state, `\n[tool:cancelled] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}${suffix}\n`);
269
371
  }
372
+ if (isTerminalToolStatus(status) && summary) {
373
+ const preview = previewOf(summary, failed);
374
+ if (preview) {
375
+ pushDisplayOnlyNotice(
376
+ state,
377
+ `\n[tool:output] ${sanitizeNoticeFragment(title, NOTICE_TITLE_MAX)}\n${preview}\n`,
378
+ );
379
+ }
380
+ }
381
+ }
382
+
383
+ const observed: ObservedToolState = { title, status, compactionShown: previous?.compactionShown };
384
+ observedTools.set(toolCallId, observed);
385
+ if (isTerminalToolStatus(status)) pushCompactionLine(state, update, observed);
386
+ }
387
+
388
+ /** The output inside one wrapping code fence; the adapter sends a command's output as "```console\n…\n```". */
389
+ function unfence(text: string): string {
390
+ const fenced = /^```[^\n]*\n([\s\S]*?)\n?```\s*$/.exec(text);
391
+ return fenced ? fenced[1] : text;
392
+ }
393
+
394
+ /** Keep `text` within `maxBytes` UTF-8 bytes, cutting at a character boundary from the head or the tail. */
395
+ function clipBytes(text: string, maxBytes: number, fromTail: boolean): string {
396
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
397
+ const chars = Array.from(text);
398
+ if (fromTail) chars.reverse();
399
+ const kept: string[] = [];
400
+ let bytes = Buffer.byteLength("…", "utf8");
401
+ for (const ch of chars) {
402
+ const size = Buffer.byteLength(ch, "utf8");
403
+ if (bytes + size > maxBytes) break;
404
+ kept.push(ch);
405
+ bytes += size;
406
+ }
407
+ if (fromTail) kept.reverse();
408
+ return fromTail ? `…${kept.join("")}` : `${kept.join("")}…`;
409
+ }
410
+
411
+ /**
412
+ * #163 M2: a short look at what a finished tool returned, as Pi shows a tool's output. At most
413
+ * PREVIEW_MAX_LINES lines and PREVIEW_MAX_BYTES bytes, each line passed through the notice
414
+ * sanitizer. A failure shows the TAIL, where a command's stderr and its exit status end up
415
+ * (`stdout` then `stderr`, `reporters/bash.js`); a success shows the head. Nothing when the whole
416
+ * output already fits on the terminal notice's one line.
417
+ */
418
+ function previewOf(raw: string, failed: boolean): string | undefined {
419
+ const lines = unfence(raw)
420
+ .split("\n")
421
+ .map((line) => sanitizePreviewLine(line));
422
+ while (lines.length > 0 && lines[0] === "") lines.shift();
423
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
424
+ if (lines.length === 0) return undefined;
425
+ if (lines.length === 1 && lines[0].length <= NOTICE_SUMMARY_MAX) return undefined;
426
+ const omitted = lines.length > PREVIEW_MAX_LINES;
427
+ const shown = failed ? lines.slice(-PREVIEW_MAX_LINES) : lines.slice(0, PREVIEW_MAX_LINES);
428
+ if (omitted) {
429
+ if (failed) shown.unshift("…");
430
+ else shown.push("…");
270
431
  }
432
+ return clipBytes(shown.join("\n"), PREVIEW_MAX_BYTES, failed);
433
+ }
271
434
 
272
- observedTools.set(toolCallId, { title, status });
435
+ function formatTokens(n: number): string {
436
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
437
+ if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
438
+ return String(n);
439
+ }
440
+
441
+ /**
442
+ * #163 M3: the backend's own compaction, in numbers. claude-agent-acp reports it as a synthetic
443
+ * tool call whose `_meta.claudeCode.toolName` is "compact", and gives a client without the AIR
444
+ * extension the facts as `rawOutput: {trigger, preTokens, postTokens?, durationMs?, error?}`
445
+ * (0.107.0 `tool-calls/reporters/compaction.js` `finished`, `context-compaction.js:366-376`). One
446
+ * lifecycle line per compaction, whichever terminal update carries the facts first; a field that
447
+ * is absent is left out. Shown like the other `[acp: …]` lines, whatever the tool-notice setting.
448
+ */
449
+ function pushCompactionLine(
450
+ state: AcpPiStreamState,
451
+ update: Record<string, unknown>,
452
+ observed: ObservedToolState,
453
+ ): void {
454
+ if (observed.compactionShown) return;
455
+ const meta = update?._meta as { claudeCode?: { toolName?: unknown } } | undefined;
456
+ if (meta?.claudeCode?.toolName !== "compact") return;
457
+ const facts = update?.rawOutput as
458
+ | { trigger?: unknown; preTokens?: unknown; postTokens?: unknown; durationMs?: unknown; error?: unknown }
459
+ | undefined;
460
+ if (!facts || typeof facts !== "object" || Array.isArray(facts)) return;
461
+ const parts = ["compaction"];
462
+ if (typeof facts.trigger === "string" && facts.trigger) parts.push(facts.trigger);
463
+ if (facts.error !== undefined && facts.error !== null && facts.error !== "") parts.push("failed");
464
+ const pre = typeof facts.preTokens === "number" ? formatTokens(facts.preTokens) : undefined;
465
+ const post = typeof facts.postTokens === "number" ? formatTokens(facts.postTokens) : undefined;
466
+ if (pre && post) parts.push(`${pre}→${post}`);
467
+ else if (pre) parts.push(pre);
468
+ if (typeof facts.durationMs === "number" && facts.durationMs >= 0) {
469
+ const seconds = facts.durationMs / 1000;
470
+ parts.push(`in ${seconds < 60 ? `${seconds.toFixed(1)}s` : formatElapsed(seconds)}`);
471
+ }
472
+ if (parts.length === 1) return;
473
+ observed.compactionShown = true;
474
+ pushAcpLifecycleNotice(state, parts.join(" "));
273
475
  }
274
476
 
275
477
  /** Push a permission-decision notice (informational text, not a tool call). */
@@ -286,6 +488,22 @@ export function pushPermissionNotice(state: AcpPiStreamState, title: string, dec
286
488
  */
287
489
  export const LIFECYCLE_NOTICE_SIGNATURE = "entwurf:lifecycle-notice-v1";
288
490
 
491
+ /**
492
+ * A tool notice that is display-only: suppressed with the tool stream
493
+ * (`showToolNotifications`), and stamped with LIFECYCLE_NOTICE_SIGNATURE so it
494
+ * never replays into an ACP prompt nor perturbs the reuse-compat signature.
495
+ */
496
+ function pushDisplayOnlyNotice(state: AcpPiStreamState, text: string): void {
497
+ if (state.quiet || !state.showToolNotifications) return;
498
+ closeThinkingBlock(state);
499
+ closeTextBlock(state);
500
+ const index = state.output.content.length;
501
+ state.output.content.push({ type: "text", text, textSignature: LIFECYCLE_NOTICE_SIGNATURE });
502
+ state.stream.push({ type: "text_start", contentIndex: index, partial: state.output });
503
+ state.stream.push({ type: "text_delta", contentIndex: index, delta: text, partial: state.output });
504
+ state.stream.push({ type: "text_end", contentIndex: index, content: text, partial: state.output });
505
+ }
506
+
289
507
  /**
290
508
  * Push a one-line ACP turn-lifecycle progress notice (`[acp: …]`) as its own
291
509
  * text block, stamped with LIFECYCLE_NOTICE_SIGNATURE. Two ways it differs from
@@ -297,6 +515,7 @@ export const LIFECYCLE_NOTICE_SIGNATURE = "entwurf:lifecycle-notice-v1";
297
515
  * reuse-compat signature (the two consumers filter on the signature).
298
516
  */
299
517
  export function pushAcpLifecycleNotice(state: AcpPiStreamState, text: string): void {
518
+ if (state.quiet) return;
300
519
  const line = `\n[acp: ${sanitizeNoticeFragment(text, NOTICE_TITLE_MAX)}]\n`;
301
520
  closeThinkingBlock(state);
302
521
  closeTextBlock(state);
@@ -12,12 +12,9 @@
12
12
  // would need a `.ts` specifier the root config cannot allow.
13
13
  //
14
14
  // WHY THE LAUNCH SEAMS ASK AT ALL. Pi's `--model` resolver falls back to a
15
- // case-insensitive substring match among the provider's rows and prefers the
16
- // lexically later id. Under one `entwurf` provider a retired `claude-sonnet-5`
17
- // is a substring of `cortex-claude-sonnet-5`, so a launch Entwurf builds would
18
- // silently open on the Snowflake Cortex rail instead of the Claude subscription.
19
- // An exact match against this list is the only admission; nothing is aliased,
20
- // substituted or rewritten.
15
+ // case-insensitive substring match among provider rows. The old multi-backend
16
+ // surface could silently match a retired Claude id to a Cortex row. Cortex is
17
+ // now disabled; exact membership remains the admission, with no substitution.
21
18
 
22
19
  /** The single ACP provider id under which every curated row is registered. */
23
20
  export const PROVIDER_ID = "entwurf";
@@ -28,15 +25,16 @@ export const SUPPORTED_ANTHROPIC_MODEL_IDS = /** @type {const} */ ([
28
25
  "claude-sonnet-5-5",
29
26
  "claude-opus-5-5",
30
27
  "claude-fable-5-1",
28
+ "claude-fable-5-2",
31
29
  ]);
32
30
 
33
31
  /** The anchor model whose absence is a hard registry regression, not a soft skip. */
34
32
  export const CURATED_ANCHOR_MODEL_ID = "claude-opus-5-5";
35
33
 
36
- // Cortex ids carry the reserved `cortex-` prefix; the rest of each id is the
37
- // Snowflake-native model name, which is why Cortex still says `claude-opus-5`.
34
+ // These former IDs are retained only as historical refusal inputs. The prefix
35
+ // is reserved so a disabled Cortex request never falls through to Claude.
38
36
  export const CORTEX_MODEL_PREFIX = "cortex-";
39
- export const SUPPORTED_CORTEX_MODEL_IDS = /** @type {const} */ ([
37
+ export const LEGACY_CORTEX_MODEL_IDS = /** @type {const} */ ([
40
38
  "cortex-auto",
41
39
  "cortex-claude-opus-5",
42
40
  "cortex-claude-sonnet-5",
@@ -44,7 +42,7 @@ export const SUPPORTED_CORTEX_MODEL_IDS = /** @type {const} */ ([
44
42
  ]);
45
43
 
46
44
  /** @type {ReadonlySet<string>} */
47
- const CURATED_ACP_MODEL_IDS = new Set([...SUPPORTED_ANTHROPIC_MODEL_IDS, ...SUPPORTED_CORTEX_MODEL_IDS]);
45
+ const CURATED_ACP_MODEL_IDS = new Set(SUPPORTED_ANTHROPIC_MODEL_IDS);
48
46
 
49
47
  /**
50
48
  * Exact membership only — no trim, no case folding, no prefix.
@@ -60,8 +58,7 @@ export const ACP_MODEL_NOT_CURATED = /** @type {const} */ ("acp-model-not-curate
60
58
 
61
59
  export const ACP_MODEL_NOT_CURATED_HINT =
62
60
  `an ${PROVIDER_ID} model must be one exact curated ACP id (${[...CURATED_ACP_MODEL_IDS].join(", ")}); ` +
63
- "Pi would otherwise pick another row by substring — a retired claude-sonnet-5 lands on cortex-claude-sonnet-5 — " +
64
- "so nothing was opened, and nothing is aliased or rewritten";
61
+ "Cortex ACP is disabled; Pi may otherwise pick another row by substring, so nothing was opened and nothing is aliased or rewritten";
65
62
 
66
63
  /** Carries `reason` so callers match a FIELD, never the wording. */
67
64
  export class AcpModelNotCuratedError extends Error {
@@ -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
 
17
11
  // pi 0.80 migration: the standalone root `getModels()` moved to the deprecated
18
12
  // `@earendil-works/pi-ai/compat` entrypoint (the global-API churn). We import it
@@ -41,9 +35,9 @@ import { CURATED_ANCHOR_MODEL_ID, SUPPORTED_ANTHROPIC_MODEL_IDS } from "./model-
41
35
  export {
42
36
  CORTEX_MODEL_PREFIX,
43
37
  CURATED_ANCHOR_MODEL_ID,
38
+ LEGACY_CORTEX_MODEL_IDS,
44
39
  PROVIDER_ID,
45
40
  SUPPORTED_ANTHROPIC_MODEL_IDS,
46
- SUPPORTED_CORTEX_MODEL_IDS,
47
41
  } from "./model-ids.js";
48
42
 
49
43
  // #26 auth-boundary sentinel. `pi.registerProvider` requires an apiKey when a
@@ -84,56 +78,50 @@ function claudeContextWindow(model: { id: string; contextWindow: number }): numb
84
78
  return Math.min(model.contextWindow, CLAUDE_CONTEXT_DEFAULT);
85
79
  }
86
80
 
87
- // ── Cortex (Snowflake Cortex Code) curated surface ──────────────────────────
88
- //
89
- // entwurf's ACP rail is backend-extensible: Cortex is the first NON-claude
90
- // backend to land through it (docs/acp-backend-rail.md, “Shipped adapters”). pi-ai carries no
91
- // snowflake/cortex model source, so this surface is HAND-CURATED. Every id
92
- // carries the reserved `cortex-` prefix: the prefix is the SINGLE routing
93
- // authority (backend-adapter.ts `routeModel`) and keeps the ids off the Claude
94
- // native ids Cortex routes to (`cortex-claude-sonnet-5` vs the unprefixed
95
- // `claude-sonnet-5-5` the claude adapter owns). `routeModel` strips the prefix to
96
- // recover the native id, which is enforced per-turn via
97
- // session/set_config_option — never a launch `-m` pin (CP0-M).
98
- //
99
- // The 4-row set is the GLG-decided curation (2026-07-29) — a subset of the 11
100
- // native ids cortex v1.1.52 exposed live when authenticated. A curated id that
101
- // the running cortex no longer serves fails LOUD at set-model, before the
102
- // prompt (measured: `Unsupported model: …` names the live set). Adding an id
103
- // here is the same verify-both-axes commitment as the Claude set — do not
104
- // extend casually. The prefix and the four ids are declared in model-ids.js.
81
+ // Cortex ACP is disabled; its former IDs are refusal inputs, not model rows.
82
+
83
+ /**
84
+ * What an ACP row declares it can take as input: text only. The rail delivers pi's transcript as
85
+ * ACP text blocks; an image becomes the marker `[image omitted: <mime>]` (`context.ts`), and no
86
+ * other path carries image bytes to the backend. pi's catalog lists the Anthropic models as
87
+ * `["text", "image"]` — true of the model behind the API, not of this transport — so copying it
88
+ * would advertise a capability the rail does not deliver (#142 landing review). A backend gains
89
+ * `image` here only together with a real image path and its proof.
90
+ */
91
+ const ACP_ROW_INPUT: ("text" | "image")[] = ["text"];
92
+
93
+ /** The retired Cortex adapter contributes no supported model rows. */
94
+ export function curatedCortexModels(): [] {
95
+ return [];
96
+ }
97
+
98
+ /**
99
+ * The effort values claude-agent-acp offers on its `effort` config option (category
100
+ * `thought_level`). `[측정 2026-11-13]` 0.106.0, authenticated, no prompt (#142): the option lists
101
+ * `default, low, medium, high, xhigh, max`; `high → low → max` each read back on one live session and
102
+ * `minimal` was refused `-32602`. `default` is the vendor's own choice, so no pi level maps to it.
103
+ */
104
+ export const CLAUDE_ACP_EFFORT_VALUES = ["low", "medium", "high", "xhigh", "max"] as const;
105
105
 
106
- // Hand-set conservative context window. Cortex reports the LIVE window via its
107
- // own ACP session config, not this curated metadata — this is a floor for the
108
- // registry surface, deliberately not inflated to the Claude 1M.
109
- const CORTEX_CONTEXT_DEFAULT = 200_000;
106
+ type ThinkingLevelMap = NonNullable<RegistryModel["thinkingLevelMap"]>;
110
107
 
111
108
  /**
112
- * The curated Cortex model rows handed to the single `entwurf` provider via
113
- * `allCuratedModels()`. The two Claude rows ride their OWN registry base
114
- * (opus-5 / sonnet-5); `auto` (default family Claude) and the GPT row (no pi-ai
115
- * source) ride the sonnet-5 base as a metadata floor. id / name / contextWindow
116
- * are overridden; the `cortex-` prefix keeps them from colliding with the
117
- * Claude curated ids. Same row shape as `curatedClaudeModels()` (AcpModelRow).
109
+ * The pi `thinkingLevelMap` an ACP Claude row registers (#142). pi's documented model seam: the
110
+ * value is what the provider sends for each pi level, `null` marks a level unsupported, and xhigh/max
111
+ * appear only when mapped (`getSupportedThinkingLevels`). Each supported pi level maps to the effort
112
+ * value of the same name. `off` and `minimal` have no effort value, so they are `null` and never
113
+ * offered; a level the registry already marks unsupported for this model stays unsupported, and
114
+ * xhigh/max stay hidden where the registry does not map them. Nothing here is a model catalog: the
115
+ * registry decides which levels the model has, this decides which of them ACP can carry.
118
116
  */
119
- export function curatedCortexModels() {
120
- const sonnetBase = requireRegistryModel(ANTHROPIC_MODELS_ALL, "claude-sonnet-5");
121
- const opusBase = requireRegistryModel(ANTHROPIC_MODELS_ALL, "claude-opus-5");
122
- const row = (id: string, name: string, base: RegistryModel) => ({
123
- id,
124
- name,
125
- reasoning: base.reasoning,
126
- input: base.input,
127
- cost: base.cost,
128
- contextWindow: CORTEX_CONTEXT_DEFAULT,
129
- maxTokens: base.maxTokens,
130
- });
131
- return [
132
- row("cortex-auto", "Cortex · Auto", sonnetBase),
133
- row("cortex-claude-opus-5", "Cortex · Claude Opus 5", opusBase),
134
- row("cortex-claude-sonnet-5", "Cortex · Claude Sonnet 5", sonnetBase),
135
- row("cortex-openai-gpt-5.4", "Cortex · OpenAI GPT-5.4", sonnetBase),
136
- ];
117
+ export function claudeAcpThinkingLevelMap(registry: RegistryModel["thinkingLevelMap"]): ThinkingLevelMap {
118
+ const map: ThinkingLevelMap = { off: null, minimal: null };
119
+ for (const level of CLAUDE_ACP_EFFORT_VALUES) {
120
+ const declared = registry?.[level];
121
+ const unmapped = declared === undefined && (level === "xhigh" || level === "max");
122
+ map[level] = declared === null || unmapped ? null : level;
123
+ }
124
+ return map;
137
125
  }
138
126
 
139
127
  /**
@@ -147,7 +135,8 @@ export function curatedClaudeModels() {
147
135
  id: m.id,
148
136
  name: m.name,
149
137
  reasoning: m.reasoning,
150
- input: m.input,
138
+ thinkingLevelMap: claudeAcpThinkingLevelMap(m.thinkingLevelMap),
139
+ input: [...ACP_ROW_INPUT],
151
140
  cost: m.cost,
152
141
  contextWindow: claudeContextWindow(m),
153
142
  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.
@@ -120,6 +117,29 @@ export function assertExcludeToolsHonored(activeToolNames: readonly string[], re
120
117
  }
121
118
  }
122
119
 
120
+ /**
121
+ * #163: is this Pi's own summarization request — the one tool-less turn the preflight above
122
+ * honours instead of refusing?
123
+ *
124
+ * Pi summarizes for `/compact`, the compaction threshold, overflow recovery and `/tree` branch
125
+ * summaries through one call (pi-coding-agent 1.5.2 `dist/core/compaction/compaction.js:476-489`,
126
+ * `completeSummarization`; branch summaries at `compaction/branch-summarization.js:233-237`). Its
127
+ * context declares no tools (`buildSummarizationContext`, `compaction.js:499-509`) and its options
128
+ * carry `cacheRetention: "none"` (`compaction.js:482`), which an ordinary agent-loop turn does not
129
+ * (pi-agent-core `dist/agent.js:306-330`).
130
+ *
131
+ * BOTH are required. An empty surface alone is also an operator who excluded every tool on an
132
+ * ordinary turn, which keeps today's refusal; `cacheRetention` alone is a request option, not a
133
+ * surface. Neither the summarization prompt's text nor a missing session id is read: Pi fills the
134
+ * id with a fresh `uuidv7()` (`compaction.js:481`), and matching its prompt would copy it.
135
+ * `cacheRetention` is a Pi convention, not a contract — the oracle beside this calls Pi's own
136
+ * `completeSummarization` so a Pi upgrade that drops it turns that test red rather than this
137
+ * path silently back into a refusal.
138
+ */
139
+ export function isPiSummaryRequest(activeToolNames: readonly string[], cacheRetention: unknown): boolean {
140
+ return activeToolNames.length === 0 && cacheRetention === "none";
141
+ }
142
+
123
143
  /** Inputs to the Claude `_meta` builder. */
124
144
  export interface ClaudeSessionMetaParams {
125
145
  modelId?: string;
@@ -174,6 +194,14 @@ export function buildClaudeSessionMeta(
174
194
  // the carrier's "doesn't-know" — keeping both means a future preset/SDK
175
195
  // change cannot silently re-open memory through a channel the strip misses.
176
196
  autoMemoryEnabled: false,
197
+ // No turn of the adapter's own (#142). claude-agent-acp 0.101.0 compacts an idle session
198
+ // before its 1h prompt cache expires by sending `/compact` as a prompt nobody here issued;
199
+ // a retained session's callbacks drop updates that arrive with no active turn, so that
200
+ // model turn would spend quota and replace context invisibly to pi. 0.106.0 (#1555) reads
201
+ // this inline key when no managed tier sets one. Only the IDLE trigger is off:
202
+ // `autoCompactEnabled` stays untouched, so Claude Code's own compaction inside a prompt
203
+ // we sent keeps working.
204
+ idleCompaction: false,
177
205
  },
178
206
  };
179
207
  if (params.skillPlugins.length > 0) {
@@ -50,7 +50,11 @@ export interface SenderEnvelope {
50
50
  * renders nothing (etiquette marker, not transport contract).
51
51
  */
52
52
  export function formatSenderInfoBlock(sender: SenderEnvelope, wantsReply = false): string {
53
- return `\n\n<sender_info>${JSON.stringify({
53
+ const replyGuidance =
54
+ sender.replyable === true
55
+ ? "\n\n[entwurf: When a reply is requested, send the result via entwurf_v2 to the requested recipient (default: sender_info.sessionId); a local final is not delivery. Honor routing and no-send instructions; an absent or false wants_reply does not cancel a request. A replyable address alone needs no acknowledgment.]"
56
+ : "";
57
+ return `${replyGuidance}\n\n<sender_info>${JSON.stringify({
54
58
  sessionId: sender.sessionId,
55
59
  agentId: sender.agentId,
56
60
  cwd: sender.cwd,