@junghanacs/entwurf 0.12.10 → 0.13.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 (43) hide show
  1. package/AGENTS.md +2 -1
  2. package/BASELINE.md +45 -6
  3. package/CHANGELOG.md +16 -0
  4. package/CONTRIBUTING.md +4 -2
  5. package/DELIVERY.md +1 -1
  6. package/README.md +20 -5
  7. package/VERIFY.md +7 -4
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +148 -5
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -5
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
  13. package/package.json +4 -2
  14. package/pi/settings.reference.json +1 -1
  15. package/pi-extensions/acp-provider.ts +20 -10
  16. package/pi-extensions/lib/acp/augment.ts +60 -2
  17. package/pi-extensions/lib/acp/backend-adapter.ts +183 -8
  18. package/pi-extensions/lib/acp/backend.ts +5 -1
  19. package/pi-extensions/lib/acp/config.ts +19 -5
  20. package/pi-extensions/lib/acp/engraving.ts +3 -1
  21. package/pi-extensions/lib/acp/event-mapper.ts +10 -3
  22. package/pi-extensions/lib/acp/models.ts +69 -7
  23. package/pi-extensions/lib/acp/overlay.ts +234 -5
  24. package/pi-extensions/lib/acp/tool-surface.ts +12 -4
  25. package/run.sh +152 -21
  26. package/scripts/check-acp-cortex.ts +668 -0
  27. package/scripts/check-acp-provider-surface.ts +50 -6
  28. package/scripts/check-acp-session-reuse.ts +64 -1
  29. package/scripts/check-gate-qualification.ts +2 -0
  30. package/scripts/check-probe-cli-shim.ts +879 -0
  31. package/scripts/check-probe-ordering.ts +2450 -0
  32. package/scripts/check-shell-quote.ts +4 -4
  33. package/scripts/fixtures/probe-cli-shim +20 -0
  34. package/scripts/fixtures/probe-mcp-server.ts +168 -12
  35. package/scripts/lib/probe-acp-turn.ts +207 -0
  36. package/scripts/lib/probe-cli-shim.ts +464 -0
  37. package/scripts/lib/probe-cli-target.ts +165 -0
  38. package/scripts/lib/probe-event-log.ts +383 -0
  39. package/scripts/lib/probe-verdict.ts +1213 -0
  40. package/scripts/mutants/acp-cortex.json +196 -0
  41. package/scripts/mutants/probe-ordering.json +1032 -0
  42. package/scripts/smoke-acp-cortex-live.ts +392 -0
  43. package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
@@ -5,9 +5,10 @@
5
5
  * SSH command strings with `JSON.stringify`, which is NOT a shell-safe escape.
6
6
  * Backticks and `$(...)` inside a user prompt got executed by the remote shell
7
7
  * before pi ever saw them. The fix introduced `shellQuote()` (POSIX `'...'`
8
- * with `'\''` escape) in two places:
9
- * - pi-extensions/lib/entwurf-core.ts (the one remaining site; the entwurf.ts /
8
+ * with `'\''` escape) in:
9
+ * - pi-extensions/lib/entwurf-core.ts (the one remaining core site; the entwurf.ts /
10
10
  * entwurf-async.ts siblings this once cross-checked were removed in 0.12)
11
+ * - pi-extensions/lib/acp/backend-adapter.ts (cortex CORTEX_ACP_COMMAND override arg quoting)
11
12
  *
12
13
  * This script enforces two invariants:
13
14
  * 1. `shellQuote()` source matches the reference implementation byte-for-byte.
@@ -15,7 +16,6 @@
15
16
  * payload classes that caused the original incident — backtick command
16
17
  * substitution, `$(...)` command substitution, `$VAR` expansion, embedded
17
18
  * single quotes, whitespace, empty string, non-ASCII text.
18
- *
19
19
  * No process spawn, no SSH, no API. Pure-string verification — safe in `pnpm
20
20
  * check` chain.
21
21
  */
@@ -38,7 +38,7 @@ const REFERENCE_BODY = `function shellQuote(value: string): string {
38
38
  \treturn \`'\${value.replace(/'/g, \`'\\\\''\`)}\`;
39
39
  }`;
40
40
 
41
- const SOURCE_SITES = ["pi-extensions/lib/entwurf-core.ts"] as const;
41
+ const SOURCE_SITES = ["pi-extensions/lib/entwurf-core.ts", "pi-extensions/lib/acp/backend-adapter.ts"] as const;
42
42
 
43
43
  // Match the function block from `function shellQuote` up to the closing brace.
44
44
  // Tab indentation is required (matches the rest of the repo).
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // §11-7-c B-name-snapshot producer — the launcher the SDK actually execs.
3
+ //
4
+ // EXTENSIONLESS ON PURPOSE, and that is a contract, not a style choice. The SDK
5
+ // picks its launch branch off a pure suffix test: a path ending in one of
6
+ // SDK_SCRIPT_SUFFIXES (.js/.mjs/.tsx/.ts/.jsx) is run as `node|bun <path>`, and
7
+ // anything else is spawned DIRECTLY. The probe asserts the native branch instead
8
+ // of reproducing the interpreter choice (probe-cli-target.ts), so the instrument
9
+ // itself has to sit on that branch too — hence no extension, a shebang, and the
10
+ // executable bit tracked in git. Renaming this file to *.js would silently move
11
+ // the shim onto the other launch branch.
12
+ //
13
+ // It stays two lines because an extensionless file is outside `tsc --noEmit` and
14
+ // outside biome: every behaviour worth typechecking, linting and mutating lives
15
+ // in ../lib/probe-cli-shim.ts, which check-probe-cli-shim drives as a real
16
+ // process. Node >=24 (package.json engines) with "type": "module" resolves this
17
+ // file as ESM and strips types from the imported .ts SSOT.
18
+ import { runProbeCliShim } from "../lib/probe-cli-shim.ts";
19
+
20
+ runProbeCliShim();
@@ -1,4 +1,5 @@
1
- // Tiny stdio MCP server used ONLY by smoke-acp-mcp-live (S2g LIVE 1).
1
+ // Tiny stdio MCP server used by smoke-acp-mcp-live (S2g LIVE 1) and by the
2
+ // §11-7 ordering probe (smoke-acp-ordering-probe-live).
2
3
  //
3
4
  // It exposes ONE tool — `probe_nonce` — that returns a per-run secret nonce
4
5
  // supplied via the PROBE_NONCE env var. The smoke registers this server in a
@@ -9,25 +10,180 @@
9
10
  // is broken (the pre-S2g hardcoded `mcpServers:[]`), the tool never exists and
10
11
  // the nonce cannot appear.
11
12
  //
13
+ // §11-7 ordering-probe mode — active ONLY when PROBE_MCP_EVENT_LOG is set
14
+ // (smoke-acp-mcp-live never sets it, so its behavior is unchanged):
15
+ // - PROBE_MCP_STARTUP_DELAY_MS delays the transport connect — the controlled
16
+ // input the probe injects instead of trying to observe a window that has no
17
+ // observable signal on this server's ACP surface;
18
+ // - the tool takes a REQUIRED `probeRunId` argument, the cross-layer join key
19
+ // (§11-7: the ACP toolCallId and the MCP JSON-RPC id share no namespace, so
20
+ // correlation must ride an argument we control);
21
+ // - every wire step appends to the shared NDJSON event log, including THE
22
+ // wire-availability marker `tools_list_response_forwarded`, emitted only
23
+ // after the ENTIRE tools/list response frame was write()-n to downstream
24
+ // stdio AND the write callback fired. The SDK's own send() resolves on
25
+ // buffered-write/drain — NOT the write callback — which is exactly the
26
+ // false-green §11-7 forbids, so probe mode swaps in a callback-forwarding
27
+ // transport instead of trusting send().
28
+ //
12
29
  // Lives under the repo's scripts/fixtures/ so its `@modelcontextprotocol/sdk`
13
30
  // import resolves against the repo node_modules even though the server is spawned
14
31
  // (by the claude ACP child) with an arbitrary scratch cwd. Deliberately minimal:
15
- // no identity / env coupling beyond PROBE_NONCE, so a failure isolates to "did
16
- // the operator mcpServers reach newSession" — not to entwurf-bridge wiring.
32
+ // no identity / env coupling beyond the PROBE_* vars, so a failure isolates to
33
+ // "did the operator mcpServers reach newSession" — not to entwurf-bridge wiring.
17
34
 
18
35
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
36
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
37
+ import { serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
38
+ import { z } from "zod";
39
+ import {
40
+ appendProbeEvent,
41
+ PROBE_EXPECTED_TOOL as EXPECTED_TOOL,
42
+ PROBE_ENV,
43
+ PROBE_EVENTS,
44
+ type ProbeEventName,
45
+ } from "../lib/probe-event-log.ts";
46
+
47
+ const eventLog = process.env[PROBE_ENV.eventLog];
48
+ const probeMode = typeof eventLog === "string" && eventLog.length > 0;
49
+ const runId = process.env[PROBE_ENV.runId] ?? "unknown-run";
50
+ const startupDelayMs = Math.max(0, Number(process.env[PROBE_ENV.startupDelayMs] ?? "0") || 0);
51
+
52
+ function emit(event: ProbeEventName, payload: Record<string, unknown> = {}): void {
53
+ if (probeMode && eventLog) appendProbeEvent(eventLog, runId, event, payload);
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // §11-7 instrumented transport — write-callback semantics + wire markers.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ type JsonRpcMessage = {
61
+ id?: string | number;
62
+ method?: string;
63
+ params?: { name?: string; arguments?: Record<string, unknown> };
64
+ result?: { tools?: Array<{ name?: string }> };
65
+ };
66
+
67
+ class InstrumentedStdioServerTransport extends StdioServerTransport {
68
+ // JSON-RPC request id → method, so a response frame can be attributed to the
69
+ // request that caused it (the response itself carries no method).
70
+ private readonly pendingMethods = new Map<string | number, string>();
71
+
72
+ constructor() {
73
+ super();
74
+ // The SDK protocol layer assigns `transport.onmessage = handler` after
75
+ // connect; the base class reads `this.onmessage`. Intercept the assignment
76
+ // so every inbound frame is stamped BEFORE the handler runs — receipt
77
+ // markers, not handler-return markers. Installed via defineProperty because
78
+ // the base declares `onmessage` as a plain property (TS2611 forbids a
79
+ // static accessor override) yet never assigns it in its constructor, so an
80
+ // instance accessor here wins cleanly.
81
+ let raw: ((message: unknown, extra?: unknown) => void) | undefined;
82
+ const stampInbound = (msg: JsonRpcMessage) => this.stampInbound(msg);
83
+ Object.defineProperty(this, "onmessage", {
84
+ get(): ((message: unknown, extra?: unknown) => void) | undefined {
85
+ if (!raw) return undefined;
86
+ return (message: unknown, extra?: unknown) => {
87
+ stampInbound(message as JsonRpcMessage);
88
+ raw?.(message, extra);
89
+ };
90
+ },
91
+ set(handler: ((message: unknown, extra?: unknown) => void) | undefined) {
92
+ raw = handler;
93
+ },
94
+ });
95
+ }
96
+
97
+ private stampInbound(msg: JsonRpcMessage): void {
98
+ if (typeof msg?.method !== "string") return;
99
+ if (msg.id !== undefined) this.pendingMethods.set(msg.id, msg.method);
100
+ if (msg.method === "initialize") {
101
+ emit(PROBE_EVENTS.fixtureInitializeReceived, { jsonrpcId: msg.id });
102
+ } else if (msg.method === "tools/list") {
103
+ emit(PROBE_EVENTS.fixtureToolsListReceived, { jsonrpcId: msg.id });
104
+ } else if (msg.method === "tools/call") {
105
+ emit(PROBE_EVENTS.fixtureToolsCallReceived, {
106
+ jsonrpcId: msg.id,
107
+ tool: msg.params?.name,
108
+ probeRunId: msg.params?.arguments?.probeRunId,
109
+ });
110
+ }
111
+ }
112
+
113
+ // Write with the CALLBACK, not the buffered-return the SDK send() settles
114
+ // for: the callback fires when the frame reached the OS pipe, which is the
115
+ // §11-7 definition of "forwarded". Markers are emitted inside the callback
116
+ // so they can never precede the hand-off they claim — and ONLY on a
117
+ // SUCCESSFUL callback: an errored write (EPIPE, closed stream) must reject,
118
+ // never stamp, or the marker would claim a hand-off that did not happen.
119
+ override send(message: unknown): Promise<void> {
120
+ const json = serializeMessage(message as never);
121
+ return new Promise((resolve, reject) => {
122
+ (this as unknown as { _stdout: NodeJS.WriteStream })._stdout.write(json, (err?: Error | null) => {
123
+ if (err) {
124
+ reject(err);
125
+ return;
126
+ }
127
+ this.stampOutbound(message as JsonRpcMessage);
128
+ resolve();
129
+ });
130
+ });
131
+ }
132
+
133
+ private stampOutbound(msg: JsonRpcMessage): void {
134
+ if (msg?.id === undefined || typeof msg.method === "string") return;
135
+ const requestMethod = this.pendingMethods.get(msg.id);
136
+ if (requestMethod === undefined) return;
137
+ this.pendingMethods.delete(msg.id);
138
+ if (requestMethod === "tools/list") {
139
+ const toolNames = (msg.result?.tools ?? []).map((t) => t?.name).filter((n): n is string => !!n);
140
+ if (toolNames.includes(EXPECTED_TOOL)) {
141
+ emit(PROBE_EVENTS.toolsListResponseForwarded, { jsonrpcId: msg.id, tools: toolNames });
142
+ }
143
+ } else if (requestMethod === "tools/call") {
144
+ emit(PROBE_EVENTS.fixtureToolsCallReplied, { jsonrpcId: msg.id });
145
+ }
146
+ }
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Server — one tool in both modes; probe mode adds the REQUIRED join key.
151
+ // ---------------------------------------------------------------------------
152
+
153
+ emit(PROBE_EVENTS.fixtureProcessStart, { startupDelayMs });
20
154
 
21
155
  const server = new McpServer({ name: "probe", version: "0.0.1" });
22
156
 
23
- server.tool(
24
- "probe_nonce",
25
- "Return this session's secret probe nonce. Call this when asked for the probe nonce.",
26
- {},
27
- async () => ({
28
- content: [{ type: "text", text: `PROBE_NONCE=${process.env.PROBE_NONCE ?? "MISSING"}` }],
29
- }),
30
- );
157
+ if (probeMode) {
158
+ server.tool(
159
+ EXPECTED_TOOL,
160
+ "Return this session's secret probe nonce. Call this when asked for the probe nonce, " +
161
+ "passing the exact probeRunId given in the prompt.",
162
+ { probeRunId: z.string().describe("The exact probeRunId string given in the prompt.") },
163
+ async () => ({
164
+ content: [{ type: "text", text: `PROBE_NONCE=${process.env[PROBE_ENV.nonce] ?? "MISSING"}` }],
165
+ }),
166
+ );
167
+ } else {
168
+ server.tool(
169
+ EXPECTED_TOOL,
170
+ "Return this session's secret probe nonce. Call this when asked for the probe nonce.",
171
+ {},
172
+ async () => ({
173
+ content: [{ type: "text", text: `PROBE_NONCE=${process.env[PROBE_ENV.nonce] ?? "MISSING"}` }],
174
+ }),
175
+ );
176
+ }
177
+
178
+ // The injected startup delay sits BEFORE the transport exists at all — the
179
+ // probe's controlled input models a slow-to-serve MCP server, so nothing may be
180
+ // readable on the wire until the delay elapses.
181
+ if (startupDelayMs > 0) {
182
+ emit(PROBE_EVENTS.fixtureDelayStart, { delayMs: startupDelayMs });
183
+ await new Promise((resolve) => setTimeout(resolve, startupDelayMs));
184
+ emit(PROBE_EVENTS.fixtureDelayEnd, { delayMs: startupDelayMs });
185
+ }
31
186
 
32
- const transport = new StdioServerTransport();
187
+ const transport = probeMode ? new InstrumentedStdioServerTransport() : new StdioServerTransport();
33
188
  await server.connect(transport);
189
+ emit(PROBE_EVENTS.fixtureTransportConnected, {});
@@ -0,0 +1,207 @@
1
+ // §11-7 ordering probe — the ACP-side turn driver (probe-dedicated raw client).
2
+ //
3
+ // §11-7 names two client seams and requires picking one explicitly. This lane
4
+ // picks the RAW CLIENT (not the stdio wire proxy), so it carries §11-7's bound:
5
+ // it is NOT the production path and must be gated as issuing the SAME calls,
6
+ // arguments, and order as the backend's real sequence (backend.ts runNewTurn) —
7
+ // otherwise the probe measures a lookalike. check-probe-ordering is that gate;
8
+ // it drives this exact function over a recording fake connection.
9
+ //
10
+ // Production values arrive through the `adapter` / `enrichMcpServers` seams —
11
+ // the LIVE runner tsc-emits pi-extensions and injects the REAL claudeAdapter +
12
+ // enrichMcpServersWithEnvelope (backend-adapter.ts carries `.js` value imports,
13
+ // so it is not strip-types-loadable from here; the emit-then-import pattern is
14
+ // the house one, see check-acp-session-reuse). This module therefore holds ONLY
15
+ // the orchestration backend.ts owns: phase order, arg assembly, timeouts, and
16
+ // phase-attributed failure — each pinned against backend.ts source by the gate
17
+ // so production drift turns the gate red instead of silently unbinding the probe.
18
+ //
19
+ // Every phase start/end is stamped into the shared NDJSON log — including
20
+ // set-model, because §11-7 measured that dropping it misreads an enforceModel
21
+ // stall as C or D.
22
+
23
+ import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
24
+ import type { AcpConnectionLike } from "../../pi-extensions/lib/acp/acp-client.ts";
25
+ import type { ResolvedAcpConfig } from "../../pi-extensions/lib/acp/config.ts";
26
+ import { PROBE_EVENTS, type ProbeEventName } from "./probe-event-log.ts";
27
+
28
+ export interface ProbePhaseTimeouts {
29
+ initializeMs: number;
30
+ newSessionMs: number;
31
+ setModelMs: number;
32
+ promptMs: number;
33
+ }
34
+
35
+ // Phase timeouts — MUST equal backend.ts's INITIALIZE/NEW_SESSION/SET_MODEL/
36
+ // PROMPT_TIMEOUT_MS (check-probe-ordering pins them against the source). §11-7:
37
+ // a D verdict is only readable against these boundaries, so the probe may not
38
+ // invent its own.
39
+ export const PROBE_PHASE_TIMEOUTS: ProbePhaseTimeouts = {
40
+ initializeMs: 30_000,
41
+ newSessionMs: 30_000,
42
+ setModelMs: 30_000,
43
+ promptMs: 600_000,
44
+ };
45
+
46
+ /** The two adapter methods the turn drives — satisfied by the REAL claudeAdapter
47
+ * (emitted) in the LIVE runner and by recording stubs in the gate. */
48
+ export interface ProbeAdapterSeam {
49
+ buildSessionMeta(
50
+ params: { modelId: string; nativeModelId: string; config: ResolvedAcpConfig },
51
+ carrier: string | null,
52
+ ): Record<string, unknown> | undefined;
53
+ enforceModel(params: {
54
+ connection: AcpConnectionLike;
55
+ acpSessionId: string;
56
+ modelId: string;
57
+ nativeModelId: string;
58
+ }): Promise<void>;
59
+ }
60
+
61
+ /** Production's enrichMcpServersWithEnvelope shape (config.ts). */
62
+ export type ProbeMcpEnricher = (
63
+ servers: ResolvedAcpConfig["mcpServers"],
64
+ envelope: { modelId?: string; piSessionId?: string },
65
+ ) => ResolvedAcpConfig["mcpServers"];
66
+
67
+ /** The turn phases in production order. `initialize` failures are P0/I0 input
68
+ * (run-invalidating), the rest map to phase-qualified D readings. */
69
+ export type ProbeTurnPhase = "initialize" | "newSession" | "enforceModel" | "prompt";
70
+
71
+ /** A phase-attributed turn failure — the classifier needs WHICH wire step died,
72
+ * never just that the turn died. */
73
+ export class ProbePhaseError extends Error {
74
+ readonly phase: ProbeTurnPhase;
75
+ readonly timedOut: boolean;
76
+
77
+ constructor(phase: ProbeTurnPhase, cause: Error, timedOut: boolean) {
78
+ super(`${phase}: ${cause.message}`);
79
+ this.phase = phase;
80
+ this.timedOut = timedOut;
81
+ this.cause = cause;
82
+ }
83
+ }
84
+
85
+ class PhaseTimeoutError extends Error {}
86
+
87
+ // Same guard shape as backend.ts withTimeout / smoke-acp-raw-turn-live: always
88
+ // clear the timer so a passing turn cannot pin the event loop on a stale timer.
89
+ function withPhaseTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
90
+ let timer: ReturnType<typeof setTimeout> | undefined;
91
+ const timeout = new Promise<never>((_, reject) => {
92
+ timer = setTimeout(() => reject(new PhaseTimeoutError(`${label} timed out after ${ms}ms`)), ms);
93
+ });
94
+ return Promise.race([p, timeout]).finally(() => {
95
+ if (timer) clearTimeout(timer);
96
+ });
97
+ }
98
+
99
+ export interface ProbeTurnParams {
100
+ cwd: string;
101
+ modelId: string;
102
+ nativeModelId: string;
103
+ config: ResolvedAcpConfig;
104
+ /** Production carrier (adapter.loadCarrier result) — folded into `_meta` by
105
+ * buildSessionMeta so the probe's newSession matches production shape. */
106
+ carrier: string | null;
107
+ promptText: string;
108
+ adapter: ProbeAdapterSeam;
109
+ enrichMcpServers: ProbeMcpEnricher;
110
+ log: (event: ProbeEventName, payload?: Record<string, unknown>) => void;
111
+ timeouts?: ProbePhaseTimeouts;
112
+ }
113
+
114
+ export interface ProbeTurnResult {
115
+ acpSessionId: string;
116
+ stopReason: string | undefined;
117
+ }
118
+
119
+ async function runPhase<T>(
120
+ phase: ProbeTurnPhase,
121
+ startEvent: ProbeEventName,
122
+ endEvent: ProbeEventName,
123
+ log: ProbeTurnParams["log"],
124
+ ms: number,
125
+ body: () => Promise<T>,
126
+ ): Promise<T> {
127
+ log(startEvent, { timeoutMs: ms });
128
+ try {
129
+ const result = await withPhaseTimeout(phase, body(), ms);
130
+ log(endEvent, { ok: true });
131
+ return result;
132
+ } catch (err) {
133
+ const error = err instanceof Error ? err : new Error(String(err));
134
+ const timedOut = error instanceof PhaseTimeoutError;
135
+ log(endEvent, { ok: false, timedOut, error: error.message });
136
+ throw new ProbePhaseError(phase, error, timedOut);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Drive ONE production-shaped turn over an already-connected ACP connection:
142
+ *
143
+ * initialize → newSession → enforceModel(setSessionConfigOption) → prompt
144
+ *
145
+ * The connection seam is `AcpConnectionLike` — the same seam backend.ts drives —
146
+ * so the sameness gate runs this function over a recording fake with zero
147
+ * launch/IO coupling.
148
+ */
149
+ export async function driveProbeTurn(connection: AcpConnectionLike, params: ProbeTurnParams): Promise<ProbeTurnResult> {
150
+ const t = params.timeouts ?? PROBE_PHASE_TIMEOUTS;
151
+ const log = params.log;
152
+
153
+ await runPhase("initialize", PROBE_EVENTS.initializeStart, PROBE_EVENTS.initializeEnd, log, t.initializeMs, () =>
154
+ connection.initialize({
155
+ protocolVersion: PROTOCOL_VERSION,
156
+ clientCapabilities: {},
157
+ clientInfo: { name: "entwurf", version: "s2d" },
158
+ }),
159
+ );
160
+
161
+ // _meta + wire servers exactly as backend.ts assembles them (S2g/§9-4): the
162
+ // injected production buildSessionMeta (omitted key when undefined) + the
163
+ // injected production envelope enrichment over the resolved config.
164
+ const sessionMeta = params.adapter.buildSessionMeta(
165
+ { modelId: params.modelId, nativeModelId: params.nativeModelId, config: params.config },
166
+ params.carrier,
167
+ );
168
+ const wireMcpServers = params.enrichMcpServers(params.config.mcpServers, {
169
+ modelId: params.modelId,
170
+ piSessionId: process.env.PI_SESSION_ID?.trim() || undefined,
171
+ });
172
+ const newSessionArgs =
173
+ sessionMeta === undefined
174
+ ? { cwd: params.cwd, mcpServers: wireMcpServers }
175
+ : { cwd: params.cwd, mcpServers: wireMcpServers, _meta: sessionMeta };
176
+
177
+ const created = await runPhase(
178
+ "newSession",
179
+ PROBE_EVENTS.newSessionStart,
180
+ PROBE_EVENTS.newSessionEnd,
181
+ log,
182
+ t.newSessionMs,
183
+ () => connection.newSession(newSessionArgs),
184
+ );
185
+ const acpSessionId = created?.sessionId;
186
+ if (!acpSessionId) {
187
+ throw new ProbePhaseError("newSession", new Error("newSession returned no sessionId"), false);
188
+ }
189
+
190
+ await runPhase("enforceModel", PROBE_EVENTS.setModelStart, PROBE_EVENTS.setModelEnd, log, t.setModelMs, () =>
191
+ params.adapter.enforceModel({
192
+ connection,
193
+ acpSessionId,
194
+ modelId: params.modelId,
195
+ nativeModelId: params.nativeModelId,
196
+ }),
197
+ );
198
+
199
+ const promptResult = await runPhase("prompt", PROBE_EVENTS.promptStart, PROBE_EVENTS.promptEnd, log, t.promptMs, () =>
200
+ connection.prompt({
201
+ sessionId: acpSessionId,
202
+ prompt: [{ type: "text", text: params.promptText }],
203
+ }),
204
+ );
205
+
206
+ return { acpSessionId, stopReason: promptResult?.stopReason };
207
+ }