@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.
- package/AGENTS.md +1 -1
- package/BASELINE.md +116 -397
- package/CHANGELOG.md +191 -2
- package/CONTRIBUTING.md +4 -4
- package/DELIVERY.md +2 -2
- package/ENTWURF-CLI.json +1 -1
- package/ENTWURF.json +10 -4
- package/README.md +11 -8
- package/VERIFY.md +333 -143
- package/demo/README.md +1 -1
- package/demo/demo-baseline.sh +1 -1
- package/demo/demo.sh +1 -1
- package/docs/acp-backend-rail.md +248 -34
- package/docs/setup-clean-host.md +75 -80
- package/mcp/entwurf-bridge/dist/ENTWURF.json +10 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +90 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +4 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +15 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +9 -12
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +41 -56
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +33 -6
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +4 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +34 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -1
- package/package.json +14 -14
- package/pi/pi-durable/carrier/harness-setup.js +1 -1
- package/pi/pi-durable/carrier/subagent.js +1 -1
- package/pi/pi-durable/overlay/upstream-pin.json +15 -15
- package/pi/settings.reference.json +0 -1
- package/pi-extensions/acp-provider.ts +12 -6
- package/pi-extensions/lib/acp/acp-client.ts +6 -7
- package/pi-extensions/lib/acp/backend-adapter.ts +139 -15
- package/pi-extensions/lib/acp/backend.ts +261 -32
- package/pi-extensions/lib/acp/config.ts +4 -9
- package/pi-extensions/lib/acp/context.ts +17 -0
- package/pi-extensions/lib/acp/event-mapper.ts +236 -17
- package/pi-extensions/lib/acp/model-ids.js +9 -12
- package/pi-extensions/lib/acp/models.ts +46 -57
- package/pi-extensions/lib/acp/tool-surface.ts +34 -6
- package/pi-extensions/lib/entwurf-control-rpc.ts +5 -1
- package/pi-extensions/lib/fresh-call-composition.ts +37 -3
- package/pi-extensions/lib/herdr-fresh-call.ts +3 -1
- package/pi-extensions/lib/meta-mailbox-body.ts +1 -1
- package/pi-extensions/lib/mux-fresh-call.ts +3 -1
- package/pi-extensions/meta-bridge-pi-durable.ts +1 -1
- package/run.sh +62 -120
- package/scripts/check-acp-carrier-augment.ts +3 -21
- package/scripts/check-acp-prompt-lifecycle.ts +24 -3
- package/scripts/check-acp-provider-surface.ts +17 -26
- package/scripts/check-acp-session-reuse.ts +23 -4
- package/scripts/check-acp-stop-reason.ts +22 -3
- package/scripts/check-acp-stream-hooks.ts +24 -3
- package/scripts/check-acp-usage-accounting.ts +53 -24
- package/scripts/check-copilot-receive-arm.ts +19 -2
- package/scripts/check-entwurf-v2-surface.ts +7 -21
- package/scripts/check-fresh-call-dispatch.ts +9 -3
- package/scripts/check-gate-qualification.ts +6 -6
- package/scripts/check-herdr-fresh-call.ts +1 -0
- package/scripts/check-install-surface.ts +6 -0
- package/scripts/check-pi-durable-send.ts +4 -1
- package/scripts/check-probe-ordering.ts +34 -24
- package/scripts/check-release-gate-outcomes.ts +9 -23
- package/scripts/lib/codex-fresh-source-receipts.ts +12 -6
- package/scripts/lib/probe-cli-target.ts +3 -2
- package/scripts/mutants/acp-augment.json +0 -26
- package/scripts/mutants/acp-cortex.json +10 -183
- package/scripts/mutants/acp-foreground-only.json +11 -0
- package/scripts/mutants/acp-launch-namespace.json +3 -1
- package/scripts/mutants/acp-prompt-lifecycle.json +2 -2
- package/scripts/mutants/acp-set-model-readback.json +488 -0
- package/scripts/mutants/agent-guidance.json +2 -2
- package/scripts/mutants/codex-native.json +2 -2
- package/scripts/mutants/control-send-receipt.json +47 -7
- package/scripts/mutants/mux-fresh-call.json +12 -1
- package/scripts/mutants/pack-install.json +4 -4
- package/scripts/mutants/pi-floor.json +8 -10
- package/scripts/mutants/release-gate.json +3 -3
- package/scripts/mutants/v2-surface.json +4 -6
- package/scripts/raw-acp-compaction-measure/probe.ts +8 -2
- package/scripts/smoke-acp-cortex-live.ts +4 -408
- package/scripts/smoke-acp-ordering-probe-live.ts +7 -1
- package/scripts/smoke-acp-session-reuse-live.ts +11 -2
- package/scripts/smoke-acp-v2-send-live.ts +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +4 -4
- 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
|
|
178
|
-
*
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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.
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
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
|
-
|
|
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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
-
//
|
|
37
|
-
//
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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`).
|
|
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`
|
|
21
|
-
// resolved `config.tools`.
|
|
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
|
-
|
|
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,
|