@junghanacs/entwurf 0.23.1 → 0.24.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.
- package/BASELINE.md +1 -0
- package/CHANGELOG.md +173 -0
- package/FAQ.md +47 -0
- package/README.md +5 -3
- package/VERIFY.md +2 -2
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +2 -2
- package/docs/setup-clean-host.md +8 -7
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +33 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +7 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +13 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -1
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/package.json +8 -7
- package/pi-extensions/entwurf-control.ts +56 -0
- package/pi-extensions/lib/acp/backend.ts +16 -6
- package/pi-extensions/lib/acp/context.ts +30 -10
- package/pi-extensions/lib/acp/session-store.ts +2 -2
- package/pi-extensions/lib/callback-env.ts +78 -0
- package/pi-extensions/lib/compaction-send-guard.ts +4 -1
- package/pi-extensions/lib/fresh-call-composition.ts +56 -22
- package/pi-extensions/lib/herdr-fresh-call.ts +12 -4
- package/pi-extensions/lib/herdr-placement.ts +13 -4
- package/pi-extensions/lib/mux-fresh-call.ts +23 -8
- package/pi-extensions/meta-bridge-omp.ts +35 -48
- package/run.sh +135 -50
- package/scripts/check-acp-backend-preflight.ts +117 -9
- package/scripts/check-acp-carrier-augment.ts +6 -4
- package/scripts/check-acp-event-mapper.ts +17 -6
- package/scripts/check-acp-prompt-builder.ts +17 -9
- package/scripts/check-acp-prompt-lifecycle.ts +23 -6
- package/scripts/check-acp-session-reuse.ts +64 -51
- package/scripts/check-acp-session-store.ts +31 -8
- package/scripts/check-acp-stop-reason.ts +20 -2
- package/scripts/check-acp-stream-hooks.ts +22 -5
- package/scripts/check-acp-usage-accounting.ts +33 -9
- package/scripts/check-entwurf-bridge-boot.ts +16 -1
- package/scripts/check-fresh-call-dispatch.ts +4 -5
- package/scripts/check-gate-qualification.ts +44 -5
- package/scripts/check-herdr-activation.ts +73 -0
- package/scripts/check-herdr-fresh-call.ts +22 -6
- package/scripts/check-herdr-sandbox.ts +14 -2
- package/scripts/check-install-container.sh +1 -1
- package/scripts/check-pi-launch.ts +5 -2
- package/scripts/lib/mutation-qualify.ts +1 -1
- package/scripts/mutants/acp-backend-preflight.json +20 -0
- package/scripts/mutants/bridge-boot-resume.json +26 -0
- package/scripts/mutants/herdr-activation.json +16 -0
- package/scripts/mutants/herdr-fresh-call.json +11 -0
- package/scripts/mutants/mux-fresh-call.json +21 -8
- package/scripts/mutants/omp-fresh.json +5 -9
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/smoke-acp-session-reuse-live.ts +23 -5
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +56 -28
- package/scripts/smoke-herdr-raw-install-live.sh +31 -3
- package/scripts/smoke-omp-fresh-live.ts +4 -2
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-provider-state.sh +2 -2
|
@@ -81,6 +81,7 @@ import type {
|
|
|
81
81
|
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
82
82
|
import { Box, type Component, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
83
83
|
import { ENTWURF_SENT_MESSAGE_TYPE } from "../protocol.js";
|
|
84
|
+
import { readCallbackEnv } from "./lib/callback-env.js";
|
|
84
85
|
import {
|
|
85
86
|
type CompactionGuard,
|
|
86
87
|
compactionSendReject,
|
|
@@ -1118,6 +1119,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1118
1119
|
if (shouldRegisterControlTools(pi)) {
|
|
1119
1120
|
registerListSessionsTool(pi);
|
|
1120
1121
|
registerEntwurfV2Tool(pi);
|
|
1122
|
+
registerCallbackTool(pi);
|
|
1121
1123
|
registerFreshCallTool(pi);
|
|
1122
1124
|
registerResumeCallTool(pi);
|
|
1123
1125
|
}
|
|
@@ -1740,6 +1742,60 @@ interface VisibleResumeModule {
|
|
|
1740
1742
|
renderVisibleResume(result: { ok: boolean }): { text: string; isError: boolean };
|
|
1741
1743
|
}
|
|
1742
1744
|
|
|
1745
|
+
function registerCallbackTool(pi: ExtensionAPI): void {
|
|
1746
|
+
const registerTool = pi.registerTool as (def: any) => void;
|
|
1747
|
+
registerTool({
|
|
1748
|
+
name: "entwurf_callback",
|
|
1749
|
+
label: "Fresh Callback",
|
|
1750
|
+
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.`,
|
|
1751
|
+
parameters: Type.Object({}),
|
|
1752
|
+
async execute(
|
|
1753
|
+
_toolCallId: string,
|
|
1754
|
+
_params: Record<string, never>,
|
|
1755
|
+
_signal: AbortSignal | undefined,
|
|
1756
|
+
_onUpdate: unknown,
|
|
1757
|
+
ctx: ExtensionContext,
|
|
1758
|
+
) {
|
|
1759
|
+
const read = readCallbackEnv(process.env);
|
|
1760
|
+
if (!read.ok) {
|
|
1761
|
+
return {
|
|
1762
|
+
content: [{ type: "text", text: `entwurf_callback: ${read.reason}` }],
|
|
1763
|
+
isError: true,
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
try {
|
|
1767
|
+
const selfMod = (await import(ENTWURF_SELF_ADDRESS_MODULE)) as unknown as EntwurfSelfAddressModule;
|
|
1768
|
+
const senderProvider = (): SenderEnvelope | undefined => {
|
|
1769
|
+
const s = buildLocalSenderEnvelope(ctx);
|
|
1770
|
+
return s ? decoratePiSenderAddressability(s, selfMod.computeSelfAddressability) : undefined;
|
|
1771
|
+
};
|
|
1772
|
+
const mod = (await import(ENTWURF_V2_SURFACE_MODULE)) as unknown as EntwurfV2SurfaceModule;
|
|
1773
|
+
const rendered = await mod.runAndRenderEntwurfV2FromSurface(
|
|
1774
|
+
{
|
|
1775
|
+
target: read.target,
|
|
1776
|
+
intent: "fire-and-forget",
|
|
1777
|
+
message: read.nonce,
|
|
1778
|
+
wants_reply: false,
|
|
1779
|
+
},
|
|
1780
|
+
{ senderProvider },
|
|
1781
|
+
);
|
|
1782
|
+
return {
|
|
1783
|
+
content: [{ type: "text", text: rendered.text }],
|
|
1784
|
+
isError: rendered.isError,
|
|
1785
|
+
details: { isError: rendered.isError },
|
|
1786
|
+
};
|
|
1787
|
+
} catch (err) {
|
|
1788
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1789
|
+
return {
|
|
1790
|
+
content: [{ type: "text", text: `entwurf_callback error: ${msg}` }],
|
|
1791
|
+
isError: true,
|
|
1792
|
+
details: { error: msg },
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
},
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1743
1799
|
/**
|
|
1744
1800
|
* Unlike `entwurf_fresh_call`, this tool needs NO caller identity: a resume names an existing
|
|
1745
1801
|
* 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,
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
// -
|
|
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
|
-
// -
|
|
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 {
|
|
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
|
|
90
|
-
*
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
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:
|
|
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,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* compaction-send-guard — whether a control-socket `send` may call `pi.sendMessage`.
|
|
3
3
|
*
|
|
4
|
-
* Pi 0.
|
|
4
|
+
* Pi 0.86.0 has no public `ExtensionContext.isCompacting()` (`[측정 2026-09-20]` the
|
|
5
|
+
* installed `dist/core/extensions/types.d.ts` declares `isIdle(): boolean` at :233 and
|
|
6
|
+
* `signal` at :237, and carries no `isCompacting` member at all; the identifier exists
|
|
7
|
+
* only inside the interactive/rpc modes' own bundles). Compaction is non-idle
|
|
5
8
|
* (`AgentSession.isIdle` = no agent run AND not compacting) but `sendCustomMessage`
|
|
6
9
|
* does not refuse it. A non-streaming `triggerTurn:true` send starts `_runAgentPrompt`
|
|
7
10
|
* while `compact()` is rewriting the session tree — the field path that can make
|
|
@@ -54,6 +54,17 @@ export type FreshCallBackend = (typeof FRESH_CALL_BACKENDS)[number];
|
|
|
54
54
|
* permission dialect: omp's approval layer consults the same minted string (`source-audit.md`).
|
|
55
55
|
*/
|
|
56
56
|
export const FRESH_CALL_CALLBACK_TOOL: Record<FreshCallBackend, string> = {
|
|
57
|
+
pi: "entwurf_callback",
|
|
58
|
+
"claude-code": "mcp__entwurf-bridge__entwurf_callback",
|
|
59
|
+
copilot: "entwurf-bridge-entwurf_callback",
|
|
60
|
+
omp: "mcp__entwurf_bridge_entwurf_callback",
|
|
61
|
+
// Codex's tool process is the operator app-server, not the pane: the no-arg
|
|
62
|
+
// verb refuses there by name. First action stays the delivery verb with args.
|
|
63
|
+
codex: "mcp__entwurf_bridge__entwurf_v2",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Where a sibling SENDS the task result. Always the delivery verb, never the birth callback. */
|
|
67
|
+
export const FRESH_CALL_DELIVERY_TOOL: Record<FreshCallBackend, string> = {
|
|
57
68
|
pi: "entwurf_v2",
|
|
58
69
|
"claude-code": "mcp__entwurf-bridge__entwurf_v2",
|
|
59
70
|
copilot: "entwurf-bridge-entwurf_v2",
|
|
@@ -101,8 +112,11 @@ export const FRESH_CALL_PEERS_TOOL: Record<FreshCallBackend, string> = {
|
|
|
101
112
|
* `[측정, n=3, same model]` adding the sentence below produced 3/3 callbacks. It is a FACT about the
|
|
102
113
|
* runtime, in the register the rest of this framing uses since the prohibitions came out: it grants
|
|
103
114
|
* nothing, forbids nothing, and names the one call that turns a listed name into a callable tool.
|
|
104
|
-
*
|
|
105
|
-
*
|
|
115
|
+
* ALL THREE tools ride ONE `ToolSearch` select — the birth callback, the DELIVERY verb the last
|
|
116
|
+
* line of the framing names, and the peers listing the corroboration sentence offers. The delivery
|
|
117
|
+
* verb has to be in the same select or the deferral simply moves from the first action to the last:
|
|
118
|
+
* a sibling that finishes its task and cannot call `entwurf_v2` is the same production silence,
|
|
119
|
+
* spent instead of saved.
|
|
106
120
|
*
|
|
107
121
|
* Every other backend gets an EMPTY array, deliberately: pi has no deferred-tool surface, and the
|
|
108
122
|
* other three were never measured to need it. A hint invented for a runtime nobody probed would be
|
|
@@ -112,7 +126,7 @@ export const FRESH_CALL_TOOL_LOAD_HINT: Record<FreshCallBackend, readonly string
|
|
|
112
126
|
pi: [],
|
|
113
127
|
"claude-code": [
|
|
114
128
|
"If ${callbackTool} is not callable yet, the entwurf-bridge server is still connecting or its",
|
|
115
|
-
'tools are deferred: load them first with ToolSearch("select:${callbackTool},${peersTool}") —',
|
|
129
|
+
'tools are deferred: load them first with ToolSearch("select:${callbackTool},${deliveryTool},${peersTool}") —',
|
|
116
130
|
"it waits for the server — then call ${callbackTool}.",
|
|
117
131
|
],
|
|
118
132
|
copilot: [],
|
|
@@ -144,22 +158,22 @@ export const OMP_BOOTSTRAP_FLAG = "entwurf-bootstrap";
|
|
|
144
158
|
|
|
145
159
|
/** Payload grammar version, matched exactly by the decoder. A bump means a stale installed
|
|
146
160
|
* unit, which is the one thing `doctor-omp-bridge` exists to say out loud. */
|
|
147
|
-
export const OMP_BOOTSTRAP_VERSION =
|
|
161
|
+
export const OMP_BOOTSTRAP_VERSION = 2;
|
|
148
162
|
|
|
149
163
|
/**
|
|
150
164
|
* The whole of what a fresh omp sibling is launched with.
|
|
151
165
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
166
|
+
* TWO FIELDS, CLOSED. Address (target + nonce) rides process env, not this payload — a second
|
|
167
|
+
* copy here would be a second address axis. The decoder refuses an unknown key, so this object
|
|
168
|
+
* is the entire remaining contract: the task released after the no-arg callback succeeds.
|
|
169
|
+
* `callerGardenId` / `nonce` stay on the function so launcher call sites do not grow a second
|
|
170
|
+
* shape; they are not serialised.
|
|
157
171
|
*/
|
|
158
172
|
export function buildOmpBootstrapPayload(params: { callerGardenId: string; nonce: string; task: string }): string {
|
|
173
|
+
void params.callerGardenId;
|
|
174
|
+
void params.nonce;
|
|
159
175
|
return JSON.stringify({
|
|
160
176
|
v: OMP_BOOTSTRAP_VERSION,
|
|
161
|
-
target: params.callerGardenId,
|
|
162
|
-
nonce: params.nonce,
|
|
163
177
|
task: params.task,
|
|
164
178
|
});
|
|
165
179
|
}
|
|
@@ -298,7 +312,11 @@ export function composeBackendArgs(
|
|
|
298
312
|
case "pi":
|
|
299
313
|
return [composition.prompt, "--entwurf-control", "--model", model];
|
|
300
314
|
case "claude-code":
|
|
301
|
-
return [
|
|
315
|
+
return [
|
|
316
|
+
composition.prompt,
|
|
317
|
+
`--allowedTools=${FRESH_CALL_CALLBACK_TOOL["claude-code"]},${FRESH_CALL_DELIVERY_TOOL["claude-code"]}`,
|
|
318
|
+
`--model=${model}`,
|
|
319
|
+
];
|
|
302
320
|
case "copilot":
|
|
303
321
|
return ["copilot", "--interactive", composition.prompt, "--model", model, "--yolo"];
|
|
304
322
|
case "omp":
|
|
@@ -354,28 +372,44 @@ export function composeFreshCallFraming(params: {
|
|
|
354
372
|
* default: a sibling told nothing about where it is would be told something false by silence. */
|
|
355
373
|
openingLine: string;
|
|
356
374
|
}): string[] {
|
|
357
|
-
const
|
|
375
|
+
const birthTool = FRESH_CALL_CALLBACK_TOOL[params.backend];
|
|
376
|
+
const deliveryTool = FRESH_CALL_DELIVERY_TOOL[params.backend];
|
|
358
377
|
const peersTool = FRESH_CALL_PEERS_TOOL[params.backend];
|
|
359
378
|
// The hint is a per-backend TEMPLATE so the two tool dialects stay the single source of their
|
|
360
379
|
// own spelling here too — a hint that hard-coded Claude's name would go stale the day a dialect
|
|
361
380
|
// moves, and it would go stale silently, in the one sentence a stuck child depends on.
|
|
362
381
|
const loadHint = FRESH_CALL_TOOL_LOAD_HINT[params.backend].map((line) =>
|
|
363
|
-
line
|
|
382
|
+
line
|
|
383
|
+
.replaceAll("${callbackTool}", birthTool)
|
|
384
|
+
.replaceAll("${deliveryTool}", deliveryTool)
|
|
385
|
+
.replaceAll("${peersTool}", peersTool),
|
|
364
386
|
);
|
|
365
387
|
const hintBlock = loadHint.length === 0 ? [] : ["", ...loadHint];
|
|
366
388
|
if (params.openingLine.length === 0) {
|
|
367
389
|
throw new Error("fresh-call composition: openingLine is empty — the rail must state where it placed the sibling");
|
|
368
390
|
}
|
|
391
|
+
const firstAction =
|
|
392
|
+
params.backend === "codex"
|
|
393
|
+
? [
|
|
394
|
+
`FIRST ACTION, before reading files or anything else: call ${birthTool} with ` +
|
|
395
|
+
`target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
|
|
396
|
+
`message set to exactly ${params.nonce} — that string alone, nothing added.`,
|
|
397
|
+
`${params.nonce} is a correlation tag this caller minted for this one launch: it names no`,
|
|
398
|
+
"secret and grants no access. Sending it is how the caller learns the garden id you were born",
|
|
399
|
+
"with, which it has no other way to know. Your own record does not carry the caller's address,",
|
|
400
|
+
"so reporting your identity here does not reach it — that tool call does.",
|
|
401
|
+
"entwurf_callback is refused on this rail: the tool process is the operator app-server, not this pane.",
|
|
402
|
+
]
|
|
403
|
+
: [
|
|
404
|
+
`FIRST ACTION, before reading files or anything else: call ${birthTool} with no arguments.`,
|
|
405
|
+
"That call is how the caller learns the garden id you were born with. There are no",
|
|
406
|
+
"parameters: a target or message you type would be a second address axis.",
|
|
407
|
+
"Your own record does not carry the caller's address, so reporting your identity here does not reach it — that tool call does.",
|
|
408
|
+
];
|
|
369
409
|
return [
|
|
370
410
|
params.openingLine,
|
|
371
411
|
"",
|
|
372
|
-
|
|
373
|
-
`target=${params.callerGardenId}, intent=fire-and-forget, wants_reply=false, and ` +
|
|
374
|
-
`message set to exactly ${params.nonce} — that string alone, nothing added.`,
|
|
375
|
-
`${params.nonce} is a correlation tag this caller minted for this one launch: it names no`,
|
|
376
|
-
"secret and grants no access. Sending it is how the caller learns the garden id you were born",
|
|
377
|
-
"with, which it has no other way to know. Your own record does not carry the caller's address,",
|
|
378
|
-
"so reporting your identity here does not reach it — that tool call does.",
|
|
412
|
+
...firstAction,
|
|
379
413
|
"",
|
|
380
414
|
`You can corroborate the caller first if you want to: ${peersTool} is a read-only listing of`,
|
|
381
415
|
`this garden's citizens and ${params.callerGardenId} is one of them. That listing is capped, so`,
|
|
@@ -393,7 +427,7 @@ export function composeFreshCallFraming(params: {
|
|
|
393
427
|
// its window is not the delivery. Nothing here watches for completion or sends on the
|
|
394
428
|
// sibling's behalf — this rail launches and lets go (Hard Rule 16, and the reason there is no
|
|
395
429
|
// supervisor anywhere in it).
|
|
396
|
-
`When the task reaches its requested final result, send that result to the same target with ${
|
|
430
|
+
`When the task reaches its requested final result, send that result to the same target with ${deliveryTool}.`,
|
|
397
431
|
"Output in this sibling window is not delivered to the caller.",
|
|
398
432
|
];
|
|
399
433
|
}
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
import { createHash } from "node:crypto";
|
|
45
45
|
import { statSync } from "node:fs";
|
|
46
46
|
import path from "node:path";
|
|
47
|
+
import { callbackEnvAssignments } from "./callback-env.ts";
|
|
47
48
|
import {
|
|
48
49
|
buildOmpBootstrapPayload,
|
|
49
50
|
composeBackendArgs,
|
|
@@ -443,7 +444,11 @@ export function herdrAgentNameFromNonce(nonce: string): string {
|
|
|
443
444
|
* not from a screen. Repeating the flag is the grammar; `--env KEY=` injects the EMPTY value
|
|
444
445
|
* rather than dropping the key.
|
|
445
446
|
*/
|
|
446
|
-
export function buildHerdrTabCreateArgs(params: {
|
|
447
|
+
export function buildHerdrTabCreateArgs(params: {
|
|
448
|
+
workspaceId: string;
|
|
449
|
+
cwd?: string;
|
|
450
|
+
callback: { target: string; nonce: string };
|
|
451
|
+
}): string[] {
|
|
447
452
|
return [
|
|
448
453
|
"tab",
|
|
449
454
|
"create",
|
|
@@ -455,6 +460,7 @@ export function buildHerdrTabCreateArgs(params: { workspaceId: string; cwd?: str
|
|
|
455
460
|
"PI_SESSION_ID=",
|
|
456
461
|
"--env",
|
|
457
462
|
"PI_AGENT_ID=",
|
|
463
|
+
...callbackEnvAssignments(params.callback).flatMap((assignment) => ["--env", assignment]),
|
|
458
464
|
];
|
|
459
465
|
}
|
|
460
466
|
|
|
@@ -849,6 +855,7 @@ export async function herdrFreshCall(
|
|
|
849
855
|
buildHerdrTabCreateArgs({
|
|
850
856
|
workspaceId: callerPane.workspaceId,
|
|
851
857
|
...(cwd === undefined ? {} : { cwd }),
|
|
858
|
+
callback: { target: callerGardenId, nonce },
|
|
852
859
|
}),
|
|
853
860
|
);
|
|
854
861
|
if (tabRun.status !== 0) {
|
|
@@ -1205,9 +1212,10 @@ export function renderHerdrFreshCall(result: HerdrFreshCallResult): { text: stri
|
|
|
1205
1212
|
`mean the sibling is running, that its first turn ran, or that the task was delivered. The tab and pane ` +
|
|
1206
1213
|
`coordinates are a view and can change under the sibling — they are not an address and nothing may be ` +
|
|
1207
1214
|
`dispatched to them.\n` +
|
|
1208
|
-
`The sibling's garden id arrives separately —
|
|
1209
|
-
`
|
|
1210
|
-
`
|
|
1215
|
+
`The sibling's garden id arrives separately — its first action is the zero-argument callback verb, and ` +
|
|
1216
|
+
`the sender envelope of THAT message is the address. The nonce above is the correlation tag it carries; ` +
|
|
1217
|
+
`the sibling reads it from its own launch env, never from this receipt. Nothing is polling for it; if it ` +
|
|
1218
|
+
`never comes, the pane is visible and can be read directly.`,
|
|
1211
1219
|
isError: false,
|
|
1212
1220
|
};
|
|
1213
1221
|
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* THE TWO AXES ARE NOT EQUALLY EXACT, AND THE DIFFERENCE IS NAMED. On the claude axis
|
|
22
22
|
* the reported value IS the key, byte for byte. On the pi axis it is a session FILE
|
|
23
23
|
* PATH, and the key is recovered from that filename by a strict conversion measured on
|
|
24
|
-
* pi 0.
|
|
24
|
+
* pi 0.86.0 — a VENDOR FLOOR, not a key equality. Calling both "a unique-key join"
|
|
25
25
|
* would hide which one can drift when a vendor renames a file. The conversion is
|
|
26
26
|
* deliberately strict at both ends so that drift lands as a missed join, never a wrong
|
|
27
27
|
* one, and a listing that declined to read anything says so (see `declinedReports`).
|
|
@@ -112,14 +112,23 @@ const OFFICIAL_REPORTS: Readonly<
|
|
|
112
112
|
};
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
|
-
* A pi session filename, measured 2026-09-
|
|
115
|
+
* A pi session filename, measured 2026-09-20 on pi 0.86.0:
|
|
116
116
|
*
|
|
117
|
-
* 2026-09-
|
|
117
|
+
* 2026-09-20T00-35-44-197Z_01a0bc3d-87c4-738e-b391-265c8ba5a1b0.jsonl
|
|
118
118
|
* └────────── start stamp ─────────┘ └──────── nativeSessionId ────────┘
|
|
119
119
|
*
|
|
120
|
+
* `[측정 2026-09-20]` that is a REAL file a sandboxed pi 0.86.0 wrote, and its own
|
|
121
|
+
* `{"type":"session"}` header carries `id: 01a0bc3d-87c4-738e-b391-265c8ba5a1b0` —
|
|
122
|
+
* the exact string `piNativeSessionIdFromPath` recovers from the name. The layout was
|
|
123
|
+
* re-measured rather than carried: pi 0.86.0 rewrote session-manager.ts (+269 −141
|
|
124
|
+
* over v0.85.1), but every line that BUILDS a name is byte-unchanged
|
|
125
|
+
* (`${fileTimestamp}_${this.sessionId}.jsonl`, session-manager.ts:991/1527/1703); the
|
|
126
|
+
* only diff in that file's `.jsonl` lines is a lambda parameter rename on the READER
|
|
127
|
+
* side. A source read alone would not have settled it, so the real file is the receipt.
|
|
128
|
+
*
|
|
120
129
|
* WHY THIS IS PINNED HERE AND NOT INFERRED. The uuid is the join key and it lives in
|
|
121
130
|
* a VENDOR filename, so this rule depends on pi's naming and would break silently if
|
|
122
|
-
* pi changed it. **Vendor floor: pi 0.
|
|
131
|
+
* pi changed it. **Vendor floor: pi 0.86.0, measured 2026-09-20** — whether that layout
|
|
123
132
|
* is a vendor contract or a convention is NOT measured, so this is the one place in the
|
|
124
133
|
* axis that can drift under us. It is therefore strict on both ends — the name must end
|
|
125
134
|
* in `.jsonl` and the tail after the last `_` must be a well-formed uuid — and a name
|