@junghanacs/entwurf 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/BASELINE.md +1 -0
  2. package/CHANGELOG.md +628 -0
  3. package/DELIVERY.md +24 -1
  4. package/FAQ.md +47 -0
  5. package/README.md +5 -3
  6. package/VERIFY.md +20 -2
  7. package/demo/README.md +5 -2
  8. package/docs/acp-backend-rail.md +49 -2
  9. package/docs/setup-clean-host.md +8 -7
  10. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +38 -5
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-send-receipt.js +101 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +6 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +17 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +4 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +53 -6
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +38 -3
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +20 -6
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +24 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
  23. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
  24. package/mcp/entwurf-bridge/dist/protocol.js +0 -7
  25. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +40 -3
  26. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +72 -0
  27. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  28. package/mcp/entwurf-bridge/src/index.ts +46 -5
  29. package/mcp/entwurf-bridge/test.sh +2 -2
  30. package/package.json +11 -9
  31. package/pi-extensions/entwurf-control.ts +83 -154
  32. package/pi-extensions/lib/acp/backend.ts +16 -6
  33. package/pi-extensions/lib/acp/context.ts +30 -10
  34. package/pi-extensions/lib/acp/session-store.ts +2 -2
  35. package/pi-extensions/lib/callback-env.ts +78 -0
  36. package/pi-extensions/lib/compaction-send-guard.ts +6 -1
  37. package/pi-extensions/lib/control-send-receipt.ts +108 -0
  38. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  39. package/pi-extensions/lib/entwurf-v2-contract.ts +6 -2
  40. package/pi-extensions/lib/entwurf-v2-production.ts +19 -1
  41. package/pi-extensions/lib/entwurf-v2-runner.ts +23 -1
  42. package/pi-extensions/lib/entwurf-v2-send.ts +82 -6
  43. package/pi-extensions/lib/entwurf-v2-surface.ts +42 -3
  44. package/pi-extensions/lib/fresh-call-composition.ts +56 -22
  45. package/pi-extensions/lib/herdr-fresh-call.ts +25 -7
  46. package/pi-extensions/lib/herdr-placement.ts +24 -4
  47. package/pi-extensions/lib/mux-fresh-call.ts +23 -8
  48. package/pi-extensions/meta-bridge-omp.ts +35 -48
  49. package/protocol.js +0 -8
  50. package/run.sh +306 -53
  51. package/scripts/check-acp-backend-preflight.ts +117 -9
  52. package/scripts/check-acp-carrier-augment.ts +6 -4
  53. package/scripts/check-acp-event-mapper.ts +17 -6
  54. package/scripts/check-acp-prompt-builder.ts +17 -9
  55. package/scripts/check-acp-prompt-lifecycle.ts +23 -6
  56. package/scripts/check-acp-session-reuse.ts +64 -51
  57. package/scripts/check-acp-session-store.ts +31 -8
  58. package/scripts/check-acp-stop-reason.ts +20 -2
  59. package/scripts/check-acp-stream-hooks.ts +22 -5
  60. package/scripts/check-acp-usage-accounting.ts +33 -9
  61. package/scripts/check-entwurf-bridge-boot.ts +16 -1
  62. package/scripts/check-entwurf-v2-surface.ts +289 -10
  63. package/scripts/check-fresh-call-dispatch.ts +4 -5
  64. package/scripts/check-gate-qualification.ts +51 -9
  65. package/scripts/check-herdr-activation.ts +73 -0
  66. package/scripts/check-herdr-fresh-call.ts +30 -6
  67. package/scripts/check-herdr-placement.ts +51 -0
  68. package/scripts/check-herdr-plugin-build.ts +11 -4
  69. package/scripts/check-herdr-plugin.ts +745 -38
  70. package/scripts/check-herdr-sandbox.ts +14 -2
  71. package/scripts/check-install-container.sh +1 -1
  72. package/scripts/check-mux-launcher-fence.ts +37 -0
  73. package/scripts/check-pi-launch.ts +8 -2
  74. package/scripts/check-release-gate-outcomes.ts +165 -0
  75. package/scripts/doctor-pi-provider.ts +44 -4
  76. package/scripts/herdr-rails.mjs +75 -0
  77. package/scripts/lib/mutation-qualify.ts +1 -1
  78. package/scripts/meta-bridge-state.py +18 -1
  79. package/scripts/mutants/acp-backend-preflight.json +20 -0
  80. package/scripts/mutants/bridge-boot-resume.json +26 -0
  81. package/scripts/mutants/bridge-command-boot.json +38 -0
  82. package/scripts/mutants/control-send-receipt.json +235 -0
  83. package/scripts/mutants/herdr-activation.json +16 -0
  84. package/scripts/mutants/herdr-fresh-call.json +15 -4
  85. package/scripts/mutants/herdr-plugin-build.json +7 -11
  86. package/scripts/mutants/herdr-plugin.json +177 -9
  87. package/scripts/mutants/meta-retire.json +22 -0
  88. package/scripts/mutants/mux-fresh-call.json +21 -8
  89. package/scripts/mutants/omp-fresh.json +5 -9
  90. package/scripts/mutants/pack-install.json +4 -4
  91. package/scripts/mutants/pi-floor.json +17 -0
  92. package/scripts/mutants/release-gate.json +48 -0
  93. package/scripts/mutants/retired-sent-surface.json +32 -0
  94. package/scripts/mutants/v2-surface.json +7 -7
  95. package/scripts/probe-bridge-command.ts +1 -0
  96. package/scripts/smoke-acp-session-reuse-live.ts +23 -5
  97. package/scripts/smoke-agy-install-state.sh +2 -2
  98. package/scripts/smoke-codex-fresh-live.ts +94 -13
  99. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  100. package/scripts/smoke-herdr-fresh-call-live.ts +71 -36
  101. package/scripts/smoke-herdr-raw-install-live.sh +90 -5
  102. package/scripts/smoke-meta-install-state.sh +107 -1
  103. package/scripts/smoke-mux-lifecycle-live.ts +9 -2
  104. package/scripts/smoke-omp-fresh-live.ts +4 -2
  105. package/scripts/smoke-omp-mcp-state.sh +1 -1
  106. package/scripts/smoke-pi-provider-state.sh +108 -2
  107. package/scripts/smoke-setup-verdict.sh +18 -2
@@ -78,9 +78,9 @@ import type {
78
78
  ExtensionContext,
79
79
  MessageRenderer,
80
80
  } from "@earendil-works/pi-coding-agent";
81
- import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
82
- import { Box, type Component, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
83
- import { ENTWURF_SENT_MESSAGE_TYPE } from "../protocol.js";
81
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
82
+ import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
83
+ import { readCallbackEnv } from "./lib/callback-env.js";
84
84
  import {
85
85
  type CompactionGuard,
86
86
  compactionSendReject,
@@ -88,6 +88,7 @@ import {
88
88
  noteCompactionBefore,
89
89
  noteCompactionTerminal,
90
90
  } from "./lib/compaction-send-guard.js";
91
+ import { sendReceiptBoundary } from "./lib/control-send-receipt.js";
91
92
  import { CONTROL_SOCKET_SUFFIX, controlSocketPathIn, defaultControlSocketDir } from "./lib/control-socket-path.js";
92
93
  import {
93
94
  attachAcceptedSocketDisconnectPolicy,
@@ -111,15 +112,6 @@ const EMACS_AGENT_SOCKET_FLAG = "emacs-agent-socket";
111
112
  // comes from the `.js` leaf both runtime lanes can import.
112
113
  const ENTWURF_DIR = defaultControlSocketDir(os.homedir());
113
114
  const SESSION_MESSAGE_TYPE = "entwurf-message";
114
- // Sender-side UI marker. Layer B (ACP path) emits a CustomMessage with this
115
- // customType so the operator sees a first-class [entwurf sent →] box paired
116
- // with the receive-side [entwurf received ⟵] box. The provider-level context
117
- // filter in index.ts drops this customType before the LLM sees it — colocated
118
- // with the emitter so sessions without --entwurf-control are still protected.
119
- // There is no second emitter: the v1 native send path ("Layer A") that also drew
120
- // this box through renderSentMessage() was removed in the 0.12 cutover, and
121
- // renderSentMessage is now registered for exactly one customType (see
122
- // registerMessageRenderer below). Do not describe a native sender-side box.
123
115
  const SENDER_INFO_PATTERN = /<sender_info>[\s\S]*?<\/sender_info>/g;
124
116
 
125
117
  // ============================================================================
@@ -477,10 +469,12 @@ const renderSessionMessage: MessageRenderer = (message, { expanded }, theme) =>
477
469
  //
478
470
  // The label is "[entwurf received ⟵]" with a left-pointing arrow so the
479
471
  // receiving operator immediately sees the directionality (this is an
480
- // incoming message). The corresponding sender-side surface in
481
- // mcp/entwurf-bridge/entwurf_v2 renders "[entwurf sent →]" — same
482
- // transport, opposite arrows, no confusion about who-said-what when the
483
- // transcript is read end-to-end.
472
+ // incoming message). There is no matching box on the sending side: the one
473
+ // that used to be described here had zero producers and was removed (#120 P3).
474
+ // A sender sees a TEXT receipt naming the acceptance boundary
475
+ // (`entwurf-v2-surface.ts`), which is the honest thing to show — a box would
476
+ // claim the message was rendered into someone's transcript, and only the
477
+ // receiver can say that.
484
478
  //
485
479
  // wants_reply defaults to false (etiquette marker, not protocol contract).
486
480
  // We show the badge only when the sender explicitly set it true; an
@@ -522,124 +516,6 @@ const renderSessionMessage: MessageRenderer = (message, { expanded }, theme) =>
522
516
  return box;
523
517
  };
524
518
 
525
- // Sender-side payload — what renderSentMessage needs to draw the [entwurf sent →]
526
- // box. Carried by the ACP path's CustomMessage `details` — the ONLY carrier since
527
- // the v1 native renderResult path was removed (0.12 cutover). All four envelope fields are intentionally
528
- // echoed in the box even though the sender is "this same session" — operators
529
- // reading a busy multi-session transcript should be able to verify at a glance
530
- // which 담당자 is on the wire (cwd) and which model identity (agentId) actually
531
- // signed the message, without scrolling up to find the session header.
532
- //
533
- // timestamp is captured at execute() / send-emit time, not at render time, so
534
- // re-renders (resize, expand toggle) keep showing the moment the message was
535
- // actually delivered rather than drifting forward to "now".
536
- //
537
- // wants_reply mirrors the receive-side etiquette badge. It stays optional because
538
- // the box is drawn from a CustomMessage whose `details` may predate the field; the
539
- // v1 `registerSessionTool` / `entwurfSendParameters` schema this note used to point
540
- // at is gone (0.12 cutover), so there is no native call site left to grow.
541
- interface SentBoxData {
542
- to: string; // target sessionId
543
- from?: string; // sender agentId, e.g. "entwurf/claude-opus-5"
544
- cwd?: string; // sender cwd (raw, abbreviateHome applied at render)
545
- timestamp?: string; // ISO 8601 UTC; rendered in KST
546
- mode?: string; // "steer" | "follow_up" | string passed through
547
- wants_reply?: boolean;
548
- deliveredAs?: string; // RPC echo — surfaces when receiver remapped (e.g. queued as followUp)
549
- body: string; // message text the operator sent
550
- }
551
-
552
- // Visual mirror of renderSessionMessage. Same Box / Markdown / theme tokens —
553
- // the two boxes must share the customMessageBg / customMessageLabel /
554
- // customMessageText surface so they are pixel-equivalent in any theme. The
555
- // only deliberate visual differences are:
556
- // - label: [entwurf sent →] vs [entwurf received ⟵]
557
- // - "to:" leads, "from:" follows vs "from:" only
558
- // - mode: line (no equivalent on receive side — receiver
559
- // doesn't see how the sender queued it)
560
- //
561
- // `expanded` truncates the body the same way as renderSessionMessage so a
562
- // large send shows the same preview shape as a large receive. operators
563
- // reading the transcript should not need different mental models.
564
- // Return type is `Component`, the interface `MessageRenderer` actually asks for
565
- // (`Component | undefined`, read at pi-coding-agent
566
- // `dist/core/extensions/types.d.ts:889`) — NOT the concrete `Container`.
567
- // `[측정 2026-09-06]` pi 0.85.0's mouse work gave `Container` a `private mouseLayout?`
568
- // (`pi-tui dist/tui.d.ts:198`; 0.84.4's `Container` had no private member at all), and
569
- // `Box` declares a SEPARATE private `mouseLayout` of its own. TypeScript only accepts a
570
- // private member from the same declaration, so the structural assignment `Box -> Container`
571
- // that held through 0.84.4 became TS2322 at 0.85.x. This was an UNDECLARED break — the
572
- // upstream Breaking section names only `createGatewayBindingFetch`. Annotating the shared
573
- // interface is the honest fix: nothing here ever needed Container's own surface.
574
- const buildSentMessageBox = (data: SentBoxData, expanded: boolean, theme: Theme): Component => {
575
- let body = data.body || "(no content)";
576
- if (!expanded) {
577
- const lines = body.split("\n");
578
- if (lines.length > 5) {
579
- body = `${lines.slice(0, 5).join("\n")}\n...`;
580
- }
581
- }
582
-
583
- const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
584
- const labelBase = theme.fg("customMessageLabel", `\x1b[1m[entwurf sent →]\x1b[22m`);
585
-
586
- // Header line: label + KST + optional (wants reply) badge — matches the
587
- // receive-side header layout 1:1.
588
- const kst = formatTimestampKst(data.timestamp) ?? "(unknown time)";
589
- const replyBadge = data.wants_reply === true ? " (wants reply)" : "";
590
- const headerLine = `${labelBase} ${theme.fg("dim", `${kst}${replyBadge}`)}`;
591
- box.addChild(new Text(headerLine, 0, 0));
592
-
593
- // to: <sessionId> — target peer
594
- box.addChild(new Text(theme.fg("dim", `to: ${data.to || "(unknown sessionId)"}`), 0, 0));
595
-
596
- // from: <agentId> @ <cwd> — self identity. Shown even though it's "us"
597
- // because in a multi-session human-greeted topology the operator is
598
- // switching between several pi sessions and needs to confirm which one
599
- // signed this send.
600
- const fromAgent = data.from ?? "(unknown agent)";
601
- const fromCwd = data.cwd ? abbreviateHome(data.cwd) : "(unknown cwd)";
602
- box.addChild(new Text(theme.fg("dim", `from: ${fromAgent} @ ${fromCwd}`), 0, 0));
603
-
604
- // mode: <mode>[ → deliveredAs] — show RPC remap when it differs from
605
- // what the caller asked for (e.g. caller said "steer" but receiver was
606
- // idle so it became a direct prompt). Silent when they agree.
607
- if (data.mode) {
608
- const remap = data.deliveredAs && data.deliveredAs !== data.mode ? theme.fg("muted", ` → ${data.deliveredAs}`) : "";
609
- box.addChild(new Text(theme.fg("dim", `mode: ${data.mode}${remap}`), 0, 0));
610
- }
611
-
612
- box.addChild(new Spacer(1));
613
- box.addChild(
614
- new Markdown(body, 0, 0, getMarkdownTheme(), {
615
- color: (value: string) => theme.fg("customMessageText", value),
616
- }),
617
- );
618
- return box;
619
- };
620
-
621
- // CustomMessageRenderer adapter for Layer B (ACP path). The CustomMessage
622
- // carries the SentBoxData under `details` (set by index.ts streamShellAcp
623
- // when a completed mcp__entwurf-bridge__entwurf_v2 is observed). `content`
624
- // holds the raw message body too, but we prefer details.body because the
625
- // content channel may have been routed through string-only persistence and
626
- // trimmed.
627
- const renderSentMessage: MessageRenderer = (message, { expanded }, theme) => {
628
- const details = (message.details ?? {}) as Partial<SentBoxData>;
629
- const fallbackBody = extractTextContent(message.content);
630
- const data: SentBoxData = {
631
- to: details.to ?? "(unknown sessionId)",
632
- from: details.from,
633
- cwd: details.cwd,
634
- timestamp: details.timestamp,
635
- mode: details.mode,
636
- wants_reply: details.wants_reply,
637
- deliveredAs: details.deliveredAs,
638
- body: details.body ?? fallbackBody,
639
- };
640
- return buildSentMessageBox(data, expanded, theme);
641
- };
642
-
643
519
  // ============================================================================
644
520
  // Command Handlers
645
521
  // ============================================================================
@@ -823,9 +699,17 @@ async function handleCommand(
823
699
  return;
824
700
  }
825
701
 
702
+ // #120 P2: the receiver reports the BOUNDARY it actually observed — `sent` for an idle
703
+ // direct trigger, `queued-steer` / `queued-follow-up` for the two in-process queues — and
704
+ // nothing else. The bare `delivered:true` that used to lead this payload is GONE rather
705
+ // than kept for compatibility: it was equally true of all three states and therefore said
706
+ // nothing, which is the bare `delivered` DELIVERY.md refuses by name. The `deliveredAs`
707
+ // beside it had no consumer at all. Skew is safe in both directions without a translator —
708
+ // an old sender keys on `response.success` and never reads `data`, and a new sender reads
709
+ // an old receiver's payload as `accepted-unknown-boundary`, the honest name for an
710
+ // acceptance it cannot classify.
826
711
  respond(true, "send", {
827
- delivered: true,
828
- deliveredAs: isIdle ? "direct" : mode === "follow_up" ? "followUp" : "steer",
712
+ boundary: sendReceiptBoundary({ idle: isIdle, mode }),
829
713
  wants_reply: wantsReply,
830
714
  });
831
715
  return;
@@ -1103,21 +987,11 @@ export default function (pi: ExtensionAPI) {
1103
987
  };
1104
988
 
1105
989
  pi.registerMessageRenderer(SESSION_MESSAGE_TYPE, renderSessionMessage);
1106
- // Layer B (ACP path) sender-side UI box. Registered unconditionally because
1107
- // renderer registration happens at extension load, BEFORE we know whether
1108
- // this session ends up with a routable identity: `updateSessionEnv` only
1109
- // publishes PI_SESSION_ID once a garden id exists, and clears it otherwise.
1110
- // A session without `--entwurf-control` therefore does NOT send — a bundled
1111
- // `entwurf_v2` sender call there fails loud on the missing envelope (the
1112
- // documented provider-vs-citizen boundary), and this renderer simply never
1113
- // fires. Registering it up front keeps one registration path instead of a
1114
- // conditional seam, and guarantees the [entwurf sent →] box exists for every
1115
- // session that DOES send.
1116
- pi.registerMessageRenderer(ENTWURF_SENT_MESSAGE_TYPE, renderSentMessage);
1117
990
 
1118
991
  if (shouldRegisterControlTools(pi)) {
1119
992
  registerListSessionsTool(pi);
1120
993
  registerEntwurfV2Tool(pi);
994
+ registerCallbackTool(pi);
1121
995
  registerFreshCallTool(pi);
1122
996
  registerResumeCallTool(pi);
1123
997
  }
@@ -1385,7 +1259,7 @@ function registerEntwurfV2Tool(pi: ExtensionAPI): void {
1385
1259
  mode: Type.Optional(
1386
1260
  StringEnum(["steer", "follow_up"] as const, {
1387
1261
  description:
1388
- "Injection style for a CONTROL-SOCKET send only: steer (immediate) or follow_up (after task). " +
1262
+ "Injection style for a CONTROL-SOCKET send only: steer (ask the receiver's steering queue) or follow_up (ask its follow-up queue). NEITHER is an interrupt: a busy receiver drains steering after each turn and follow-ups only when its inner loop ends, so a later steer overtakes every earlier follow_up and there is no order between the two. Both queues are volatile process memory — an abort drops what is in them. The receipt names which queue accepted the message (`queued-steer` / `queued-follow-up`); an idle receiver answers `sent`, meaning the turn was TRIGGERED, not that the model has seen the text. " +
1389
1263
  "The mailbox and native-push plans carry no mode, so it has no effect on those rails.",
1390
1264
  }),
1391
1265
  ),
@@ -1413,9 +1287,11 @@ function registerEntwurfV2Tool(pi: ExtensionAPI): void {
1413
1287
  description: `CANONICAL DELIVERY SURFACE for garden ids: message, reply, or hand off to whoever an id names. The id alone
1414
1288
  does not say which rail that citizen answers on. Give target + intent; the decider picks transport from
1415
1289
  liveness (live socket citizen → control-socket send; deliverable self-fetch citizen → meta-bridge mailbox;
1416
- probe-alive native-push citizen → direct injection into its conversation) and reports ONE outcome
1417
- (delivered / rejected / delivered-but-lock-dirty). EXISTING targets only; discover with entwurf_peers.
1418
- A peer entwurf_peers shows as liveness=alive → fire-and-forget. A citizen with NO socket liveness
1290
+ probe-alive native-push citizen → direct injection into its conversation) and reports ONE outcome, per rail: a control
1291
+ receiver's acceptance boundary ("sent" / "queued-steer" / "queued-follow-up" /
1292
+ "accepted-unknown-boundary" — those four only), a mailbox ENQUEUE receipt, a native-push INJECTION,
1293
+ a rejection, or lock-dirty. EXISTING targets only (see entwurf_peers).
1294
+ A peer shown as liveness=alive → fire-and-forget. A citizen with NO socket liveness
1419
1295
  (liveness=unsupported) is ALSO fire-and-forget — unsupported means only "no control-socket probe" — and the
1420
1296
  decider picks its own rail: a self-fetch backend (e.g. Claude Code) gets the mailbox, a native-push backend
1421
1297
  (e.g. Antigravity) gets direct injection and has NO mailbox at all. THERE IS A THIRD RESULT: the mailbox
@@ -1424,9 +1300,8 @@ rather than queued for an inbox nobody drains. The native-push probe is 3-valued
1424
1300
  alive → injected; dead → native-push-target-dead; indeterminate → native-push-probe-indeterminate
1425
1301
  (unestablished ≠ gone). DORMANT IS UNREACHABLE: a socket-domain citizen that is not running gets
1426
1302
  dormant-fire-forget-unsupported — same receiver rule as the mailbox, no active drainer means no
1427
- delivery. The intent that used to answer there, owned-outcome, resumed it by
1428
- launching a hidden background child; it was withdrawn under the visible-first rule, so re-open the session
1429
- yourself and dispatch again. LOCK: taken for a control-socket-DOMAIN dispatch. The mailbox and native-push
1303
+ delivery. The intent that used to answer there, owned-outcome, was withdrawn
1304
+ under the visible-first rule — re-open the session yourself and dispatch again. LOCK: taken for a control-socket-DOMAIN dispatch. The mailbox and native-push
1430
1305
  rails are lock-free — deliverability and the adapter probe guard them. mode applies to a CONTROL-SOCKET
1431
1306
  send only; other plans carry no mode. wants_reply rides every rail. message caps at 16000 chars; send an
1432
1307
  artifact path + digest for more.`,
@@ -1740,6 +1615,60 @@ interface VisibleResumeModule {
1740
1615
  renderVisibleResume(result: { ok: boolean }): { text: string; isError: boolean };
1741
1616
  }
1742
1617
 
1618
+ function registerCallbackTool(pi: ExtensionAPI): void {
1619
+ const registerTool = pi.registerTool as (def: any) => void;
1620
+ registerTool({
1621
+ name: "entwurf_callback",
1622
+ label: "Fresh Callback",
1623
+ description: `ZERO-ARGUMENT callback for a fresh sibling this process was launched as. Reads ENTWURF_CALLBACK_TARGET and ENTWURF_CALLBACK_NONCE from this process environment (injected by the launcher next to the identity scrub), validates garden-id and nonce grammar, and dispatches through the existing v2 runner with intent fire-and-forget, message=nonce, wants_reply=false. The target is re-resolved by record/decider. REFUSES BY NAME when the pair is absent, malformed, or this process is a Codex-provenance bridge. No arguments, no fallback to a model-supplied target.`,
1624
+ parameters: Type.Object({}),
1625
+ async execute(
1626
+ _toolCallId: string,
1627
+ _params: Record<string, never>,
1628
+ _signal: AbortSignal | undefined,
1629
+ _onUpdate: unknown,
1630
+ ctx: ExtensionContext,
1631
+ ) {
1632
+ const read = readCallbackEnv(process.env);
1633
+ if (!read.ok) {
1634
+ return {
1635
+ content: [{ type: "text", text: `entwurf_callback: ${read.reason}` }],
1636
+ isError: true,
1637
+ };
1638
+ }
1639
+ try {
1640
+ const selfMod = (await import(ENTWURF_SELF_ADDRESS_MODULE)) as unknown as EntwurfSelfAddressModule;
1641
+ const senderProvider = (): SenderEnvelope | undefined => {
1642
+ const s = buildLocalSenderEnvelope(ctx);
1643
+ return s ? decoratePiSenderAddressability(s, selfMod.computeSelfAddressability) : undefined;
1644
+ };
1645
+ const mod = (await import(ENTWURF_V2_SURFACE_MODULE)) as unknown as EntwurfV2SurfaceModule;
1646
+ const rendered = await mod.runAndRenderEntwurfV2FromSurface(
1647
+ {
1648
+ target: read.target,
1649
+ intent: "fire-and-forget",
1650
+ message: read.nonce,
1651
+ wants_reply: false,
1652
+ },
1653
+ { senderProvider },
1654
+ );
1655
+ return {
1656
+ content: [{ type: "text", text: rendered.text }],
1657
+ isError: rendered.isError,
1658
+ details: { isError: rendered.isError },
1659
+ };
1660
+ } catch (err) {
1661
+ const msg = err instanceof Error ? err.message : String(err);
1662
+ return {
1663
+ content: [{ type: "text", text: `entwurf_callback error: ${msg}` }],
1664
+ isError: true,
1665
+ details: { error: msg },
1666
+ };
1667
+ }
1668
+ },
1669
+ });
1670
+ }
1671
+
1743
1672
  /**
1744
1673
  * Unlike `entwurf_fresh_call`, this tool needs NO caller identity: a resume names an existing
1745
1674
  * citizen, so the address is the parameter rather than something the sibling has to report back.
@@ -44,8 +44,8 @@ import { type ChildProcessByStdio, spawn } from "node:child_process";
44
44
  import { readFileSync } from "node:fs";
45
45
  import { Readable, Writable } from "node:stream";
46
46
  import { ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
47
- import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
48
- import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
47
+ import type { Api, AssistantMessage, Model, SimpleStreamOptions, TranscriptContext } from "@earendil-works/pi-ai";
48
+ import { createAssistantMessageEventStream, getCurrentTools } from "@earendil-works/pi-ai";
49
49
  import {
50
50
  type AcpClientHandlers,
51
51
  type AcpConnectionLike,
@@ -81,7 +81,7 @@ import {
81
81
  resolveLifecyclePolicy,
82
82
  writeSessionRecord,
83
83
  } from "./session-store.js";
84
- import { assertExcludeToolsHonored, PI_BUILTIN_BACKED_TOOLS } from "./tool-surface.js";
84
+ import { assertExcludeToolsHonored } from "./tool-surface.js";
85
85
 
86
86
  // Bootstrap boundaries ONLY. initialize / newSession / set-model are handshake
87
87
  // steps that make no model progress, so a stuck one is a dead session and a cold
@@ -1108,7 +1108,7 @@ async function applyProviderPayloadHook<T extends { sessionId: string }>(
1108
1108
  */
1109
1109
  export function streamShellAcp(
1110
1110
  model: Model<Api>,
1111
- context: Context,
1111
+ context: TranscriptContext,
1112
1112
  options?: SimpleStreamOptions,
1113
1113
  ): ReturnType<typeof createAssistantMessageEventStream> {
1114
1114
  return streamAcpTurn(model, context, options, defaultDeps());
@@ -1117,7 +1117,7 @@ export function streamShellAcp(
1117
1117
  /** The seam-aware turn driver. `streamShellAcp` calls this with the real deps. */
1118
1118
  export function streamAcpTurn(
1119
1119
  model: Model<Api>,
1120
- context: Context,
1120
+ context: TranscriptContext,
1121
1121
  options: SimpleStreamOptions | undefined,
1122
1122
  deps: AcpTurnDeps,
1123
1123
  ): ReturnType<typeof createAssistantMessageEventStream> {
@@ -1510,7 +1510,17 @@ export function streamAcpTurn(
1510
1510
  // rather than lie to the model. Uses the RESOLVED tool surface (S2g) so an
1511
1511
  // operator-narrowed `tools` is what the truthfulness check honors.
1512
1512
  try {
1513
- const activeToolNames = context.tools?.map((t) => t.name) ?? [...PI_BUILTIN_BACKED_TOOLS];
1513
+ // pi 0.86 folded `Context.systemPrompt`/`Context.tools` into a leading
1514
+ // `role:"system"` message (`normalizeContext`, pi-ai
1515
+ // `utils/transcript.ts`), so a provider only ever sees a
1516
+ // `TranscriptContext`. The active surface is therefore REPLAYED from the
1517
+ // system messages — `getCurrentTools` applies every toolsAdded/toolsRemoved
1518
+ // delta in order. No `?? [...PI_BUILTIN_BACKED_TOOLS]` fallback: under 0.86
1519
+ // the old `context.tools` read was ALWAYS undefined, so that fallback
1520
+ // silently declared the full builtin set and the truthfulness preflight
1521
+ // could never fire (false green). An empty replayed list means the operator
1522
+ // excluded everything — keep that honest and let the preflight reject.
1523
+ const activeToolNames = getCurrentTools(context.messages).map((t) => t.name);
1514
1524
  assertExcludeToolsHonored(activeToolNames, { backend: adapter.backend, tools: config.tools });
1515
1525
  } catch (err) {
1516
1526
  finishError(err, false);
@@ -8,16 +8,26 @@
8
8
  //
9
9
  // S2c/S2d boundary (GPT S2c Q2): this is CONVERSATION TRANSCRIPT PASSTHROUGH, not
10
10
  // rich-carrier identity injection. Deliberately EXCLUDED here (all S2d):
11
- // - `context.systemPrompt` — never read into the prompt or `_meta.systemPrompt`
11
+ // - the system prompt — never read into the prompt or `_meta.systemPrompt`
12
12
  // (the billing carrier stays absent — NEXT §S2-scout 핀1);
13
13
  // - `~/AGENTS.md` / cwd AGENTS / bridge identity narrative;
14
14
  // - first-user-message augment + project-context de-dup;
15
- // - `context.tools` — the ACP child tool surface is the S2b
15
+ // - the declared tool surface — the ACP child tool surface is the S2b
16
16
  // `_meta.claudeCode.options` SSOT, never re-sent here.
17
+ // Since pi 0.86 both of those ride a leading `role:"system"` message inside the
18
+ // `TranscriptContext` (pi-ai `normalizeContext`) rather than their own `Context`
19
+ // fields, so the exclusion is enforced by `renderMessage`'s explicit
20
+ // `case "system"` below, not by simply not reading two fields.
17
21
  // Structured tool replay is also excluded: tool calls/results render as plain
18
22
  // transcript text, never as ACP tool invocations (the child runs its own tools).
19
23
 
20
- import type { AssistantMessage, Context, Message, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai";
24
+ import type {
25
+ AssistantMessage,
26
+ Message,
27
+ ToolResultMessage,
28
+ TranscriptContext,
29
+ UserMessage,
30
+ } from "@earendil-works/pi-ai";
21
31
 
22
32
  // MUST equal event-mapper.ts `LIFECYCLE_NOTICE_SIGNATURE` (the SSOT/producer).
23
33
  // It is mirrored, not imported: the strip-types deterministic gates load these
@@ -80,16 +90,26 @@ function renderMessage(message: Message): string | undefined {
80
90
  const tag = message.isError ? "Tool error" : "Tool result";
81
91
  return text ? `${tag} (${message.toolName}): ${text}` : undefined;
82
92
  }
93
+ case "system":
94
+ // The never-forward-systemPrompt invariant, stated rather than inherited.
95
+ // Since pi 0.86 the system prompt and the declared tool surface arrive AS
96
+ // transcript messages (`normalizeContext`), so what used to be "we simply
97
+ // do not read `context.systemPrompt`" is now an active decision on every
98
+ // turn. `default` would already skip it; the explicit case exists so
99
+ // deleting it is a visible change and a new pi role cannot quietly start
100
+ // leaking the prompt into the ACP child's transcript.
101
+ return undefined;
83
102
  default:
84
103
  return undefined;
85
104
  }
86
105
  }
87
106
 
88
107
  /**
89
- * Flatten a pi Context into a single transcript string. Excludes
90
- * `context.systemPrompt` and `context.tools` by construction.
108
+ * Flatten a pi transcript into a single transcript string. Excludes the system
109
+ * prompt and the declared tool surface by construction — since 0.86 both ride
110
+ * the leading `role:"system"` message, which `renderMessage` drops explicitly.
91
111
  */
92
- export function contextTranscript(context: Context): string {
112
+ export function contextTranscript(context: TranscriptContext): string {
93
113
  const lines: string[] = [];
94
114
  for (const message of context.messages) {
95
115
  const line = renderMessage(message);
@@ -99,11 +119,11 @@ export function contextTranscript(context: Context): string {
99
119
  }
100
120
 
101
121
  /**
102
- * Convert a pi Context into the ACP `prompt` array (a single text block holding
122
+ * Convert a pi transcript into the ACP `prompt` array (a single text block holding
103
123
  * the flattened transcript). Empty history yields an empty array — the caller
104
124
  * decides whether that is a hard error.
105
125
  */
106
- export function contextToAcpPrompt(context: Context): AcpTextBlock[] {
126
+ export function contextToAcpPrompt(context: TranscriptContext): AcpTextBlock[] {
107
127
  const transcript = contextTranscript(context);
108
128
  if (!transcript) return [];
109
129
  return [{ type: "text", text: transcript }];
@@ -129,7 +149,7 @@ export type AcpBootstrapPath = "new" | "reuse" | "resume" | "load";
129
149
  * (reuse/resume/load): re-sending the whole transcript there would duplicate
130
150
  * history the backend already remembers.
131
151
  */
132
- export function latestUserDelta(context: Context): AcpTextBlock[] {
152
+ export function latestUserDelta(context: TranscriptContext): AcpTextBlock[] {
133
153
  let lastAssistantIdx = -1;
134
154
  for (let i = context.messages.length - 1; i >= 0; i--) {
135
155
  if (context.messages[i].role === "assistant") {
@@ -160,7 +180,7 @@ export function latestUserDelta(context: Context): AcpTextBlock[] {
160
180
  * `contextMessageSignatures` prefix-compat gate (mismatch → fall back to
161
181
  * `"new"` + full transcript); this pure function only splits the scope.
162
182
  */
163
- export function buildAcpPrompt(context: Context, bootstrapPath: AcpBootstrapPath): AcpTextBlock[] {
183
+ export function buildAcpPrompt(context: TranscriptContext, bootstrapPath: AcpBootstrapPath): AcpTextBlock[] {
164
184
  switch (bootstrapPath) {
165
185
  case "new":
166
186
  return contextToAcpPrompt(context);
@@ -27,7 +27,7 @@ import { createHash } from "node:crypto";
27
27
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
28
28
  import { homedir } from "node:os";
29
29
  import { join } from "node:path";
30
- import type { Context, Message, ToolResultMessage } from "@earendil-works/pi-ai";
30
+ import type { Message, ToolResultMessage, TranscriptContext } from "@earendil-works/pi-ai";
31
31
  import type { AcpBootstrapPath } from "./context.js";
32
32
 
33
33
  // MUST equal event-mapper.ts `LIFECYCLE_NOTICE_SIGNATURE` (the SSOT/producer).
@@ -236,7 +236,7 @@ function rawMessageSignature(message: Message): string {
236
236
  * record never stores raw prompt/tool text — the prefix check works the same on
237
237
  * the digest array (GPT `c617cb` hardening).
238
238
  */
239
- export function contextMessageSignatures(context: Context): string[] {
239
+ export function contextMessageSignatures(context: TranscriptContext): string[] {
240
240
  return context.messages.map((m: Message) => sha256(rawMessageSignature(m)));
241
241
  }
242
242
 
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Callback-env — the launch-computed pair that lets a fresh sibling call home
3
+ * without copying a garden id or nonce out of prose.
4
+ *
5
+ * WHY THIS FILE EXISTS. Until this seam, `composeFreshCallFraming` put
6
+ * `target=<caller gid>` and `message=<nonce>` into the first-turn prompt, and
7
+ * the sibling had to retype them into `entwurf_v2`. That is the one place in
8
+ * the garden Hard Rule 2 (record is the sole address axis) was violated: the
9
+ * transcript became the address carrier, and a model that dropped a `$` or a
10
+ * hex nibble produced a refused dispatch that looked like a flaky backend.
11
+ *
12
+ * WHAT TRAVELS. Two process-env names, injected by the launcher next to the
13
+ * identity scrub (`PI_SESSION_ID=` / `PI_AGENT_ID=`), never as a general env
14
+ * carrier. The no-arg `entwurf_callback` verb reads BOTH from its own process
15
+ * and refuses by name when they are absent, malformed, or this process is a
16
+ * Codex-provenance bridge (window env never reaches that MCP child — measured).
17
+ *
18
+ * This module does not dispatch. It parses, validates, and formats. Launchers
19
+ * and the verb share it so a drifted regex cannot inject what the verb rejects.
20
+ */
21
+
22
+ import { SESSION_ID_RE } from "./session-id.js";
23
+
24
+ export const CALLBACK_TARGET_ENV = "ENTWURF_CALLBACK_TARGET";
25
+ export const CALLBACK_NONCE_ENV = "ENTWURF_CALLBACK_NONCE";
26
+
27
+ /** Exactly what `mintNonce` emits (`fresh-call-composition.ts`): `mux-fresh-call-` + 12 random
28
+ * bytes as hex. ONE minter, ONE grammar — both rails call the same `mintNonce`, so a second
29
+ * accepted prefix would be a spelling no launcher produces and this leaf exists to refuse
30
+ * exactly that. A gate fixture is an oracle and never a reason to widen the subject. */
31
+ export const CALLBACK_NONCE_RE = /^mux-fresh-call-[0-9a-f]{24}$/;
32
+
33
+ export type CallbackEnvReject = "callback-env-absent" | "callback-env-malformed" | "codex-callback-env-unsupported";
34
+
35
+ export type CallbackEnvRead = { ok: true; target: string; nonce: string } | { ok: false; reason: CallbackEnvReject };
36
+
37
+ function parseCallbackPair(target: unknown, nonce: unknown): CallbackEnvRead {
38
+ if (typeof target !== "string" || typeof nonce !== "string") {
39
+ return { ok: false, reason: "callback-env-malformed" };
40
+ }
41
+ if (!SESSION_ID_RE.test(target) || !CALLBACK_NONCE_RE.test(nonce)) {
42
+ return { ok: false, reason: "callback-env-malformed" };
43
+ }
44
+ return { ok: true, target, nonce };
45
+ }
46
+
47
+ /**
48
+ * Format the two `KEY=value` assignments a launcher injects. Throws rather than
49
+ * emitting a pair the verb would refuse — a launch that cannot name its caller
50
+ * must not open a window whose first action is a guaranteed reject.
51
+ */
52
+ export function callbackEnvAssignments(params: { target: string; nonce: string }): readonly [string, string] {
53
+ const parsed = parseCallbackPair(params.target, params.nonce);
54
+ if (!parsed.ok) {
55
+ throw new Error(`callback-env: refusing to inject ${parsed.reason}`);
56
+ }
57
+ return [`${CALLBACK_TARGET_ENV}=${parsed.target}`, `${CALLBACK_NONCE_ENV}=${parsed.nonce}`];
58
+ }
59
+
60
+ /**
61
+ * Read the pair from a process environment. Codex provenance is a named refuse
62
+ * even when the pair is well-formed: that bridge is an app-server child, and a
63
+ * pane-injected value would be the wrong citizen's (or last-launch-wins).
64
+ *
65
+ * Both names absent → `callback-env-absent`. One present, or either failing
66
+ * grammar → `callback-env-malformed`. No fallback to a model-supplied target.
67
+ */
68
+ export function readCallbackEnv(env: NodeJS.ProcessEnv = process.env): CallbackEnvRead {
69
+ if (env.ENTWURF_BRIDGE_NATIVE_HOST?.trim() === "codex") {
70
+ return { ok: false, reason: "codex-callback-env-unsupported" };
71
+ }
72
+ const target = env[CALLBACK_TARGET_ENV];
73
+ const nonce = env[CALLBACK_NONCE_ENV];
74
+ if (target === undefined && nonce === undefined) {
75
+ return { ok: false, reason: "callback-env-absent" };
76
+ }
77
+ return parseCallbackPair(target, nonce);
78
+ }
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * compaction-send-guard — whether a control-socket `send` may call `pi.sendMessage`.
3
3
  *
4
- * Pi 0.85.1 has no public `ExtensionContext.isCompacting()`. Compaction is non-idle
4
+ * Pi has no public `ExtensionContext.isCompacting()`. `[측정 2026-09-20, pi 0.86.0]` and
5
+ * re-measured `[측정 2026-09-22, pi 0.87.0]` and `[측정 2026-09-23, pi 0.87.1]`: the installed
6
+ * `dist/core/extensions/types.d.ts` declares `isIdle(): boolean` at :233 and `signal` at :237 —
7
+ * the SAME coordinates at all three floors — and carries no `isCompacting` member at all (grep
8
+ * count 0, re-counted on 0.87.1); the identifier exists
9
+ * only inside the interactive/rpc modes' own bundles). Compaction is non-idle
5
10
  * (`AgentSession.isIdle` = no agent run AND not compacting) but `sendCustomMessage`
6
11
  * does not refuse it. A non-streaming `triggerTurn:true` send starts `_runAgentPrompt`
7
12
  * while `compact()` is rewriting the session tree — the field path that can make