@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
@@ -49,6 +49,7 @@ import {
49
49
  appendAssistantMessageDiagnostic,
50
50
  createAssistantMessageDiagnostic,
51
51
  createAssistantMessageEventStream,
52
+ getCurrentSystemPrompt,
52
53
  getCurrentTools,
53
54
  } from "@earendil-works/pi-ai";
54
55
  import {
@@ -61,6 +62,7 @@ import { prependNewPromptAugment } from "./augment.js";
61
62
  import {
62
63
  ACP_PROMPT_REJECTION_DIAGNOSTIC,
63
64
  type AcpBackendAdapter,
65
+ assertSupportedDeclaredBackend,
64
66
  resolveAcpBackendAdapter,
65
67
  } from "./backend-adapter.js";
66
68
  import {
@@ -90,7 +92,7 @@ import {
90
92
  resolveLifecyclePolicy,
91
93
  writeSessionRecord,
92
94
  } from "./session-store.js";
93
- import { assertExcludeToolsHonored } from "./tool-surface.js";
95
+ import { assertExcludeToolsHonored, isPiSummaryRequest } from "./tool-surface.js";
94
96
 
95
97
  // Bootstrap boundaries ONLY. initialize / newSession / set-model are handshake
96
98
  // steps that make no model progress, so a stuck one is a dead session and a cold
@@ -310,6 +312,9 @@ interface BridgeSession {
310
312
  acpSessionId: string;
311
313
  bridgeConfigSignature: string;
312
314
  contextMessageSignatures: string[];
315
+ /** The backend thinking value the driver last read back on this session (#142); a fact about the
316
+ * live session, never part of the reuse signature. Undefined until one is set. */
317
+ thinkingValue?: string;
313
318
  alive: boolean;
314
319
  busy: boolean;
315
320
  /** Mutable per-turn router — see the CRITICAL note in the file header. */
@@ -449,6 +454,40 @@ let cleanupRegistered = false;
449
454
  * `process.once` would leak an EventEmitter listener per turn — GPT c617cb. The
450
455
  * handler is sync (the `exit` event forbids async work).
451
456
  */
457
+ /**
458
+ * Close a retained session we are leaving on purpose: mark it retiring (so its exit is not
459
+ * announced as an unexpected end), drop it from the map, and tear its child down.
460
+ */
461
+ function abandonRetainedSession(session: BridgeSession): void {
462
+ session.alive = false;
463
+ session.retiring = true;
464
+ if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
465
+ retainedChildren.delete(session.child);
466
+ session.connection.close?.();
467
+ teardownChild(session.child);
468
+ }
469
+
470
+ /**
471
+ * #163, GLG 2026-11-14: after a successful Pi compaction the retained Claude child of that Pi
472
+ * session is closed at once, not kept until the next turn's prefix break discards it. The next
473
+ * turn then opens a new child from Pi's compacted transcript (summary + kept tail) — the path
474
+ * `decideBootstrap` already takes when the stored prefix no longer matches.
475
+ *
476
+ * Keyed exactly as an ordinary turn keys its session: Pi passes its session manager's id as the
477
+ * stream `sessionId` (pi-coding-agent 1.5.2 `dist/core/sdk.js:276`), and `resolveSessionKey`
478
+ * turns that into `pi:<id>`. A busy or in-flight session is left alone — Pi aborts the running
479
+ * turn before compacting (`dist/core/agent-session.js:2327`), so a busy one is not the session
480
+ * that compaction left behind, and the prefix break still covers it. Returns whether a child
481
+ * was closed.
482
+ */
483
+ export function closeRetainedAcpSession(piSessionId: string): boolean {
484
+ const key = `pi:${piSessionId}`;
485
+ const session = bridgeSessions.get(key);
486
+ if (!session || session.busy || inFlightKeys.has(key)) return false;
487
+ abandonRetainedSession(session);
488
+ return true;
489
+ }
490
+
452
491
  function registerGlobalCleanup(): void {
453
492
  if (cleanupRegistered) return;
454
493
  cleanupRegistered = true;
@@ -757,6 +796,34 @@ function withTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
757
796
  });
758
797
  }
759
798
 
799
+ // Reject as soon as the operator aborts, so a pre-prompt step on a LIVE session
800
+ // (reuse has no bootstrap child to tear down) settles on the abort instead of on
801
+ // the driver's answer or the bootstrap timeout. The step itself is abandoned, not
802
+ // cancelled: the caller drops the session, since what the driver now holds is
803
+ // unknown (#142, Dot review of the reuse effort set).
804
+ function untilAborted<T>(p: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
805
+ if (!signal) return p;
806
+ let onAbort: (() => void) | undefined;
807
+ const aborted = new Promise<never>((_, reject) => {
808
+ onAbort = () => reject(new Error("entwurf: aborted by the operator before the prompt was sent"));
809
+ if (signal.aborted) onAbort();
810
+ else signal.addEventListener("abort", onAbort, { once: true });
811
+ });
812
+ return Promise.race([p, aborted]).finally(() => {
813
+ if (onAbort) signal.removeEventListener("abort", onAbort);
814
+ });
815
+ }
816
+
817
+ // What an operator who pressed ESC needs before speaking again (#142): the turn
818
+ // has stopped, nothing already done was undone, and whether the next message
819
+ // continues this live ACP session or opens a new one. Said only after the turn
820
+ // settled, and only about the live session — not about any private transcript.
821
+ export const ACP_CANCEL_KEPT_NOTICE = "stopped — changes already made stay; next message continues this session";
822
+ export const ACP_CANCEL_CLOSED_NOTICE = "stopped — changes already made stay; next message opens a new session";
823
+ /** #163: Pi's summary turn ended as `cancelled` with no abort from Pi. Kept non-transient for pi's retry classifier. */
824
+ export const ACP_SUMMARY_CANCELLED_MESSAGE =
825
+ "ACP backend ended the summary turn as cancelled although Pi did not abort it; the summary is incomplete and is not kept";
826
+
760
827
  /**
761
828
  * Await ONE ACP prompt with NO wall-clock deadline — the turn ends on lifecycle
762
829
  * events only. Shared by the new and the reuse turn so both close the same way.
@@ -1151,6 +1218,8 @@ export function streamAcpTurn(
1151
1218
  | undefined;
1152
1219
  const cwd = opts?.cwd ?? process.cwd();
1153
1220
  const signal = opts?.signal;
1221
+ /** #163: this turn is Pi's one-shot summary request (set by runSummaryTurn). */
1222
+ let summaryTurnActive = false;
1154
1223
 
1155
1224
  stream.push({ type: "start", partial: state.output });
1156
1225
 
@@ -1420,7 +1489,17 @@ export function streamAcpTurn(
1420
1489
  function finishSuccess(adapter: AcpBackendAdapter, session: BridgeSession, promptResult: AcpPromptResponse): void {
1421
1490
  sealTurnUsage(adapter, session, promptResult);
1422
1491
  finalizeAcpStreamState(state);
1423
- const verdict = mapPromptStopReason(promptResult?.stopReason);
1492
+ let verdict = mapPromptStopReason(promptResult?.stopReason);
1493
+ // #163: Pi keeps an `aborted` summary as a checkpoint — only `error` and `length` are refused
1494
+ // (pi-coding-agent 1.5.2 `compaction.js:454-462`). A summary the backend cancelled while
1495
+ // nobody aborted it is incomplete text, so it is sealed as an error, never stored.
1496
+ if (summaryTurnActive && verdict.stopReason === "aborted" && !signal?.aborted) {
1497
+ verdict = {
1498
+ stopReason: "error",
1499
+ rawStopReason: verdict.rawStopReason,
1500
+ errorMessage: ACP_SUMMARY_CANCELLED_MESSAGE,
1501
+ };
1502
+ }
1424
1503
  if (verdict.rawStopReason !== undefined) state.output.rawStopReason = verdict.rawStopReason;
1425
1504
  if (signal?.aborted || verdict.stopReason === "aborted") {
1426
1505
  state.output.stopReason = "aborted";
@@ -1524,6 +1603,12 @@ export function streamAcpTurn(
1524
1603
  // agree, else fail loud — a mismatch means the settings and the requested model
1525
1604
  // disagree about which backend runs, and silently trusting the model id would
1526
1605
  // hide an operator typo. Routing authority stays single (the model id).
1606
+ try {
1607
+ assertSupportedDeclaredBackend(config.backend);
1608
+ } catch (err) {
1609
+ finishError(err, false);
1610
+ return;
1611
+ }
1527
1612
  if (config.backend !== undefined && config.backend !== adapter.backend) {
1528
1613
  finishError(
1529
1614
  new Error(
@@ -1547,23 +1632,35 @@ export function streamAcpTurn(
1547
1632
  // child will still expose (declared != actual), fail fast into the stream
1548
1633
  // rather than lie to the model. Uses the RESOLVED tool surface (S2g) so an
1549
1634
  // operator-narrowed `tools` is what the truthfulness check honors.
1635
+ //
1636
+ // pi 0.86 folded `Context.systemPrompt`/`Context.tools` into a leading
1637
+ // `role:"system"` message (`normalizeContext`, pi-ai
1638
+ // `utils/transcript.ts`), so a provider only ever sees a
1639
+ // `TranscriptContext`. The active surface is therefore REPLAYED from the
1640
+ // system messages — `getCurrentTools` applies every toolsAdded/toolsRemoved
1641
+ // delta in order. No `?? [...PI_BUILTIN_BACKED_TOOLS]` fallback: under 0.86
1642
+ // the old `context.tools` read was ALWAYS undefined, so that fallback
1643
+ // silently declared the full builtin set and the truthfulness preflight
1644
+ // could never fire (false green). An empty replayed list on an ordinary turn
1645
+ // means the operator excluded everything — keep that honest and reject.
1646
+ //
1647
+ // #163: the one empty surface that is NOT refused is Pi's own summarization
1648
+ // request (`isPiSummaryRequest`). It is honoured rather than refused: the
1649
+ // summary turn below runs on a child that is given no tools at all, so the
1650
+ // declared surface (none) is the actual one.
1651
+ let summaryTurn: boolean;
1550
1652
  try {
1551
- // pi 0.86 folded `Context.systemPrompt`/`Context.tools` into a leading
1552
- // `role:"system"` message (`normalizeContext`, pi-ai
1553
- // `utils/transcript.ts`), so a provider only ever sees a
1554
- // `TranscriptContext`. The active surface is therefore REPLAYED from the
1555
- // system messages — `getCurrentTools` applies every toolsAdded/toolsRemoved
1556
- // delta in order. No `?? [...PI_BUILTIN_BACKED_TOOLS]` fallback: under 0.86
1557
- // the old `context.tools` read was ALWAYS undefined, so that fallback
1558
- // silently declared the full builtin set and the truthfulness preflight
1559
- // could never fire (false green). An empty replayed list means the operator
1560
- // excluded everything — keep that honest and let the preflight reject.
1561
1653
  const activeToolNames = getCurrentTools(context.messages).map((t) => t.name);
1562
- assertExcludeToolsHonored(activeToolNames, { backend: adapter.backend, tools: config.tools });
1654
+ summaryTurn = isPiSummaryRequest(activeToolNames, opts?.cacheRetention);
1655
+ if (!summaryTurn) assertExcludeToolsHonored(activeToolNames, { backend: adapter.backend, tools: config.tools });
1563
1656
  } catch (err) {
1564
1657
  finishError(err, false);
1565
1658
  return;
1566
1659
  }
1660
+ if (summaryTurn) {
1661
+ await runSummaryTurn(config, adapter, nativeModelId);
1662
+ return;
1663
+ }
1567
1664
 
1568
1665
  const policy = deps.lifecyclePolicy();
1569
1666
  const sessionKey = resolveSessionKey(opts, cwd);
@@ -1586,6 +1683,24 @@ export function streamAcpTurn(
1586
1683
  finishError(err, false);
1587
1684
  return;
1588
1685
  }
1686
+ // #142: pi's thinking level → the backend's own value, resolved BEFORE any process or session is
1687
+ // touched, so an unsupported level refuses with nothing to unwind. A backend without the
1688
+ // capability (cortex) resolves nothing and keeps its old behaviour. The value is deliberately NOT
1689
+ // in the reuse signature: changing it must not trade the live Claude conversation for a new one,
1690
+ // so it is set on the live session before the prompt instead (runNewTurn / runReuseTurn).
1691
+ let thinkingValue: string | undefined;
1692
+ if (adapter.resolveThinking) {
1693
+ try {
1694
+ thinkingValue = adapter.resolveThinking({
1695
+ modelId: model.id,
1696
+ reasoning: opts?.reasoning,
1697
+ thinkingLevelMap: model.thinkingLevelMap,
1698
+ });
1699
+ } catch (err) {
1700
+ finishError(err, false);
1701
+ return;
1702
+ }
1703
+ }
1589
1704
  // S2g: the signature folds the FULL resolved config (mcpServersHash + tool
1590
1705
  // surface + skillPlugins + flags) so any operator config change invalidates
1591
1706
  // a reused session; the per-session envelope is excluded (runtime, not config).
@@ -1651,14 +1766,7 @@ export function streamAcpTurn(
1651
1766
  // session (incompatible drift / stale-dead) — the model-lock throw already
1652
1767
  // returned above, so this is never a "leave it alone" case. Close the old
1653
1768
  // connection + child so it is not orphaned in retainedChildren (GPT blocker 2).
1654
- if (decision.path === "new" && existing) {
1655
- existing.alive = false;
1656
- existing.retiring = true;
1657
- if (bridgeSessions.get(sessionKey) === existing) bridgeSessions.delete(sessionKey);
1658
- retainedChildren.delete(existing.child);
1659
- existing.connection.close?.();
1660
- teardownChild(existing.child);
1661
- }
1769
+ if (decision.path === "new" && existing) abandonRetainedSession(existing);
1662
1770
 
1663
1771
  // Claim the key for the whole turn — atomic with the checks above (no await
1664
1772
  // in between), so a concurrent first turn for the same key sees it in flight
@@ -1666,15 +1774,77 @@ export function streamAcpTurn(
1666
1774
  inFlightKeys.add(sessionKey);
1667
1775
  try {
1668
1776
  if (decision.path === "reuse" && existing) {
1669
- await runReuseTurn(existing, ctxSigs, adapter);
1777
+ await runReuseTurn(existing, ctxSigs, adapter, thinkingValue);
1670
1778
  } else {
1671
- await runNewTurn(params, ctxSigs, engraving, config, adapter, nativeModelId);
1779
+ await runNewTurn(params, ctxSigs, engraving, config, adapter, nativeModelId, thinkingValue);
1672
1780
  }
1673
1781
  } finally {
1674
1782
  inFlightKeys.delete(sessionKey);
1675
1783
  }
1676
1784
  });
1677
1785
 
1786
+ // --- #163: Pi's summary request → a one-shot, tool-less child that is never retained
1787
+ //
1788
+ // What makes it a SUMMARY turn rather than an ordinary new one, each for a reason:
1789
+ // - No tools, no MCP, nothing pre-approved, no skill plugins: the request declares no tools,
1790
+ // and a summarizer that could still run Bash would act where Pi cannot see it (Pi's own
1791
+ // "Summarization attempted to call a tool" guard reads `toolCall` blocks, which ACP tool
1792
+ // calls never become).
1793
+ // - No notice of any kind (`state.quiet`): Pi keeps `contentText(response.content)` as the
1794
+ // summary, joining every text block, and that summary lands in the session file.
1795
+ // - No first-user augment: it is operator context for a working session, not for a summary.
1796
+ // - The operator's engraving carrier stays (identity/billing/memory containment are not a
1797
+ // working-session feature), rendered with the empty MCP list this child really has.
1798
+ // - Pi's summarization instructions arrive as a system message, which the transcript
1799
+ // flattening never forwards (context.ts `case "system"`). They are relayed here, verbatim,
1800
+ // through Pi's own renderer, as the first prompt block — for this one turn only.
1801
+ // - Never retained and never registered: it shares no state with the conversation's session.
1802
+ // It neither reads nor tears down `bridgeSessions`, whatever key it arrived with.
1803
+ async function runSummaryTurn(
1804
+ config: ResolvedAcpConfig,
1805
+ adapter: AcpBackendAdapter,
1806
+ nativeModelId: string,
1807
+ ): Promise<void> {
1808
+ state.quiet = true;
1809
+ summaryTurnActive = true;
1810
+ const summaryConfig: ResolvedAcpConfig = {
1811
+ ...config,
1812
+ tools: [],
1813
+ mcpServers: [],
1814
+ skillPlugins: [],
1815
+ permissionAllow: [],
1816
+ // Filesystem isolation: no CLAUDE.md, hooks or plugins from the operator's Claude settings —
1817
+ // the summarizer gets the carrier and Pi's instructions, nothing else (config.ts:413-420).
1818
+ settingSources: [],
1819
+ };
1820
+ let engraving: string | null;
1821
+ let thinkingValue: string | undefined;
1822
+ try {
1823
+ engraving = adapter.loadCarrier({ mcpServerNames: [], config: summaryConfig });
1824
+ if (adapter.resolveThinking) {
1825
+ thinkingValue = adapter.resolveThinking({
1826
+ modelId: model.id,
1827
+ reasoning: opts?.reasoning,
1828
+ thinkingLevelMap: model.thinkingLevelMap,
1829
+ });
1830
+ }
1831
+ } catch (err) {
1832
+ finishError(err, false);
1833
+ return;
1834
+ }
1835
+ const instructions = getCurrentSystemPrompt(context.messages).trim();
1836
+ const params: BootstrapParams = {
1837
+ cwd,
1838
+ modelId: model.id,
1839
+ bridgeConfigSignature: "",
1840
+ contextMessageSignatures: [],
1841
+ lifecyclePolicy: "turn-scoped",
1842
+ };
1843
+ await runNewTurn(params, [], engraving, summaryConfig, adapter, nativeModelId, thinkingValue, {
1844
+ instructions,
1845
+ });
1846
+ }
1847
+
1678
1848
  // --- new session: spawn → initialize → newSession → setSessionConfigOption(model) → full transcript
1679
1849
  async function runNewTurn(
1680
1850
  params: BootstrapParams,
@@ -1683,6 +1853,9 @@ export function streamAcpTurn(
1683
1853
  config: ResolvedAcpConfig,
1684
1854
  adapter: AcpBackendAdapter,
1685
1855
  nativeModelId: string,
1856
+ thinkingValue: string | undefined,
1857
+ /** #163: set only by runSummaryTurn — relay these instructions, add no augment. */
1858
+ summary?: { instructions: string },
1686
1859
  ): Promise<void> {
1687
1860
  let child: AcpChildLike | undefined;
1688
1861
  let session: BridgeSession | undefined;
@@ -1702,7 +1875,8 @@ export function streamAcpTurn(
1702
1875
  // talking to ended. Announced once (read-and-clear); the turn continues
1703
1876
  // normally, since opening a fresh child for a NEW user turn is not a
1704
1877
  // replay of anything.
1705
- const priorEnd = takeUnreportedChildEnd(sessionKey);
1878
+ // A summary turn must not consume the conversation's end notice, whatever key it carries.
1879
+ const priorEnd = summary ? undefined : takeUnreportedChildEnd(sessionKey);
1706
1880
  if (priorEnd) {
1707
1881
  pushAcpLifecycleNotice(
1708
1882
  state,
@@ -1845,6 +2019,18 @@ export function streamAcpTurn(
1845
2019
  adapter.enforceModel({ connection, acpSessionId, modelId: model.id, nativeModelId }),
1846
2020
  SET_MODEL_TIMEOUT_MS,
1847
2021
  );
2022
+ // #142: the requested thinking level, set and read back before the prompt. The session keeps
2023
+ // the value the driver confirmed, so a reuse turn sets it again only when it changes.
2024
+ if (thinkingValue !== undefined && adapter.enforceThinking) {
2025
+ await withTimeout(
2026
+ "enforceThinking",
2027
+ adapter.enforceThinking({ connection, acpSessionId, modelId: model.id, value: thinkingValue }),
2028
+ SET_MODEL_TIMEOUT_MS,
2029
+ );
2030
+ session.thinkingValue = thinkingValue;
2031
+ // Visible like the model lock: the operator sees the effort the driver read back.
2032
+ pushAcpLifecycleNotice(state, `effort ${thinkingValue} read back`);
2033
+ }
1848
2034
 
1849
2035
  // S2f visibility: the session is live and model-locked — the next gap is
1850
2036
  // the prompt round-trip to the first token.
@@ -1859,12 +2045,15 @@ export function streamAcpTurn(
1859
2045
  // the pi Context, so it stays out of contextMessageSignatures (NEXT §S2d
1860
2046
  // gate ②). `new`-only → reuse turns stay clean (once-only). Entwurf-spawned
1861
2047
  // prompts that already carry cwd/AGENTS.md get that one section de-duped.
1862
- const prompt = prependNewPromptAugment(basePrompt, {
1863
- backend: adapter.backend,
1864
- cwd,
1865
- mcpServerNames: mcpServerNames(config),
1866
- emacsAgentSocket: process.env.PI_EMACS_AGENT_SOCKET?.trim() || undefined,
1867
- });
2048
+ // #163: a summary turn carries Pi's summarization instructions instead.
2049
+ const prompt: AcpTextBlock[] = summary
2050
+ ? [...(summary.instructions ? [{ type: "text" as const, text: summary.instructions }] : []), ...basePrompt]
2051
+ : prependNewPromptAugment(basePrompt, {
2052
+ backend: adapter.backend,
2053
+ cwd,
2054
+ mcpServerNames: mcpServerNames(config),
2055
+ emacsAgentSocket: process.env.PI_EMACS_AGENT_SOCKET?.trim() || undefined,
2056
+ });
1868
2057
 
1869
2058
  // S2f visibility: about to send — say "sending" (not "sent") because the
1870
2059
  // prompt could still sync-reject before the wire write; the next visible
@@ -1920,6 +2109,9 @@ export function streamAcpTurn(
1920
2109
  unrefRetainedChild(spawned);
1921
2110
  }
1922
2111
 
2112
+ if (signal?.aborted || mapPromptStopReason(promptResult?.stopReason).stopReason === "aborted") {
2113
+ pushAcpLifecycleNotice(state, retain ? ACP_CANCEL_KEPT_NOTICE : ACP_CANCEL_CLOSED_NOTICE);
2114
+ }
1923
2115
  finishSuccess(adapter, session, promptResult);
1924
2116
 
1925
2117
  if (retain) {
@@ -1947,6 +2139,7 @@ export function streamAcpTurn(
1947
2139
  // own SIGTERM; the tail collected by then is sealed before our cleanup
1948
2140
  // touches the stderr pipe.
1949
2141
  const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
2142
+ if (aborted) pushAcpLifecycleNotice(state, ACP_CANCEL_CLOSED_NOTICE);
1950
2143
  finishError(
1951
2144
  err,
1952
2145
  aborted,
@@ -1969,7 +2162,12 @@ export function streamAcpTurn(
1969
2162
  }
1970
2163
 
1971
2164
  // --- reuse: send only the latest user delta to the live ACP session
1972
- async function runReuseTurn(session: BridgeSession, ctxSigs: string[], adapter: AcpBackendAdapter): Promise<void> {
2165
+ async function runReuseTurn(
2166
+ session: BridgeSession,
2167
+ ctxSigs: string[],
2168
+ adapter: AcpBackendAdapter,
2169
+ thinkingValue: string | undefined,
2170
+ ): Promise<void> {
1973
2171
  /** Same phase discipline as a new turn — reuse just has no bootstrap to lose. */
1974
2172
  let phase: "pre-prompt" | "prompt" = "pre-prompt";
1975
2173
  try {
@@ -1979,7 +2177,27 @@ export function streamAcpTurn(
1979
2177
  // resident turn looks identical to a cold start that stalled.
1980
2178
  pushAcpLifecycleNotice(state, "reusing live session");
1981
2179
  session.busy = true;
1982
- session.activePromptHandler = makePromptHandler(session);
2180
+
2181
+ // #142: a changed thinking level is set on THIS live session, before the delta is sent; the
2182
+ // conversation is kept. A refused or mismatched answer fails the turn before the prompt and
2183
+ // the session is dropped by the catch below, like any other pre-prompt failure on reuse.
2184
+ if (thinkingValue !== undefined && adapter.enforceThinking && session.thinkingValue !== thinkingValue) {
2185
+ await withTimeout(
2186
+ "enforceThinking",
2187
+ untilAborted(
2188
+ adapter.enforceThinking({
2189
+ connection: session.connection,
2190
+ acpSessionId: session.acpSessionId,
2191
+ modelId: model.id,
2192
+ value: thinkingValue,
2193
+ }),
2194
+ signal,
2195
+ ),
2196
+ SET_MODEL_TIMEOUT_MS,
2197
+ );
2198
+ session.thinkingValue = thinkingValue;
2199
+ pushAcpLifecycleNotice(state, `effort ${thinkingValue} read back`);
2200
+ }
1983
2201
 
1984
2202
  // The live ACP session already remembers the prior turns → send only the
1985
2203
  // latest user delta (re-sending the transcript would duplicate history).
@@ -1993,6 +2211,12 @@ export function streamAcpTurn(
1993
2211
  // ahead of the wire write; the prompt driver then owns the abort surface.
1994
2212
  const wireParams = await applyProviderPayloadHook(options, { sessionId: session.acpSessionId, prompt }, model);
1995
2213
  if (signal?.aborted) throw new Error("aborted during payload hook");
2214
+ // #142: this turn's stream receives updates only from the moment its own prompt goes out.
2215
+ // Before that, nothing on this live session can be this turn's output — a late update of
2216
+ // the previous (cancelled) turn arriving during the effort set would otherwise be shown as
2217
+ // this turn's answer. Inside the prompt window the wire carries no turn id; that part
2218
+ // rests on the agent not speaking for a turn it already answered.
2219
+ session.activePromptHandler = makePromptHandler(session);
1996
2220
  phase = "prompt";
1997
2221
  const promptResult = await awaitAcpPromptTurn(session, wireParams, {
1998
2222
  signal,
@@ -2005,6 +2229,10 @@ export function streamAcpTurn(
2005
2229
  // prefix-compat check sees the full prior history (GPT ④: store the
2006
2230
  // ctxSigs from the START of this call, only after the turn succeeds).
2007
2231
  session.contextMessageSignatures = ctxSigs;
2232
+ // A cancel the agent answered itself leaves this live session in place (no drop below).
2233
+ if (signal?.aborted || mapPromptStopReason(promptResult?.stopReason).stopReason === "aborted") {
2234
+ pushAcpLifecycleNotice(state, ACP_CANCEL_KEPT_NOTICE);
2235
+ }
2008
2236
  finishSuccess(adapter, session, promptResult);
2009
2237
  persistRecord(session, deps);
2010
2238
  } catch (err) {
@@ -2018,6 +2246,7 @@ export function streamAcpTurn(
2018
2246
  // ordering matters most here: teardown first would have overwritten the
2019
2247
  // child's own exit status with our SIGTERM.
2020
2248
  const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
2249
+ if (aborted) pushAcpLifecycleNotice(state, ACP_CANCEL_CLOSED_NOTICE);
2021
2250
  // Without the session-scoped tail a mid-turn child death on a resident
2022
2251
  // session surfaced as a bare "ACP connection closed" with nothing to read
2023
2252
  // it by.
@@ -16,15 +16,10 @@
16
16
  //
17
17
  // The COMMON keys parsed here are Claude-SHAPED by origin (tools / permissionAllow /
18
18
  // disallowedTools / settingSources / skillPlugins are claude-agent-acp's surface).
19
- // What varies per backend is whether they are PROJECTED ONTO THE BACKEND SESSION —
20
- // not whether they are read at all. Cortex runs native tools and reaches MCP through
21
- // its own overlay mcp.json projection, so none of those keys shape a cortex session;
22
- // they nonetheless stay live on the common path, where backend.ts reads `tools` for
23
- // the backend-invariant exclude-tools preflight and folds every one of them into
24
- // `bridgeConfigSignature` (so changing one still invalidates a reused cortex
25
- // session). Backend-OWNED settings never land here: they ride the opaque
26
- // `adapterSettings` seam (rail “Adapter contract”), which is what keeps a backend-named key like
27
- // `cortexConnection` off this common shape.
19
+ // The common path reads `tools` for the backend-invariant exclude-tools preflight
20
+ // and folds these keys into `bridgeConfigSignature`. Backend-owned settings ride
21
+ // the opaque `adapterSettings` seam. A dormant `cortexConnection` does not select
22
+ // or enable Cortex ACP, which is disabled.
28
23
  //
29
24
  // PURITY / SIGNATURE contract (NEXT oracle C / 핀1 / GPT `…2f9325` boost):
30
25
  // - `normalizeMcpServers` is pure: a SORTED, validated server list + a sha256
@@ -74,6 +74,19 @@ function textFromAssistantContent(content: AssistantMessage["content"]): string
74
74
  );
75
75
  }
76
76
 
77
+ /**
78
+ * The harness fact pi already holds about an assistant turn that was cut
79
+ * (`stopReason: "aborted"` — an operator abort, or a vendor `cancelled` mapped to it; the line
80
+ * names no cause), stated so a NEW session's transcript does not read a
81
+ * cancelled turn as a clean refusal (#142: a fresh session concluded a command had
82
+ * not run when its first effect was on disk). It adds no claim about what did or
83
+ * did not happen — only that the turn was cut and that a cut is not a rollback.
84
+ * Only the full transcript carries it: a reused session already holds its own
85
+ * record of the interruption and receives just the latest user delta.
86
+ */
87
+ export const ABORTED_TURN_STATUS =
88
+ "[turn status: aborted — actions this turn had started may already have taken effect; cancellation is not rollback]";
89
+
77
90
  /** Render one pi message as a transcript line, or undefined to skip it. */
78
91
  function renderMessage(message: Message): string | undefined {
79
92
  switch (message.role) {
@@ -83,6 +96,10 @@ function renderMessage(message: Message): string | undefined {
83
96
  }
84
97
  case "assistant": {
85
98
  const text = textFromAssistantContent(message.content).trim();
99
+ if (message.stopReason === "aborted") {
100
+ // Kept even when the cut turn left no text: the cut itself is the fact.
101
+ return text ? `Assistant: ${text}\n${ABORTED_TURN_STATUS}` : `Assistant: ${ABORTED_TURN_STATUS}`;
102
+ }
86
103
  return text ? `Assistant: ${text}` : undefined;
87
104
  }
88
105
  case "toolResult": {