@junghanacs/entwurf 0.13.0 → 0.14.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/AGENTS.md +52 -17
- package/BASELINE.md +60 -206
- package/CHANGELOG.md +64 -0
- package/CONTRIBUTING.md +14 -10
- package/DELIVERY.md +118 -261
- package/README.md +91 -431
- package/VERIFY.md +74 -95
- package/demo/README.md +1 -1
- package/demo/demo-baseline.sh +1 -3
- package/demo/demo.sh +2 -5
- package/docs/acp-backend-rail.md +236 -0
- package/docs/external-mcp-host.md +131 -0
- package/docs/fresh-cut-policy.md +99 -0
- package/docs/setup-clean-host.md +124 -328
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
- package/mcp/entwurf-bridge/src/index.ts +176 -67
- package/mcp/entwurf-bridge/start.sh +2 -2
- package/mcp/entwurf-bridge/test.sh +23 -9
- package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
- package/package.json +25 -11
- package/pi-extensions/entwurf-control.ts +220 -71
- package/pi-extensions/lib/acp/acp-client.ts +22 -0
- package/pi-extensions/lib/acp/augment.ts +42 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
- package/pi-extensions/lib/acp/backend.ts +366 -38
- package/pi-extensions/lib/acp/config.ts +1 -1
- package/pi-extensions/lib/acp/engraving.ts +43 -1
- package/pi-extensions/lib/acp/event-mapper.ts +16 -13
- package/pi-extensions/lib/acp/models.ts +4 -4
- package/pi-extensions/lib/acp/overlay.ts +11 -9
- package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
- package/pi-extensions/lib/entwurf-core.ts +15 -15
- package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
- package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
- package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
- package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
- package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
- package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
- package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
- package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
- package/pi-extensions/lib/meta-session.ts +93 -5
- package/pi-extensions/lib/mux-fresh-call.ts +328 -0
- package/pi-extensions/lib/mux-launch.ts +267 -0
- package/pi-extensions/lib/mux-placement.ts +387 -0
- package/pi-extensions/lib/mux-resume-call.ts +221 -0
- package/pi-extensions/lib/resume-launch-identity.ts +162 -0
- package/pi-extensions/lib/session-id.js +8 -5
- package/pi-extensions/lib/socket-discovery.ts +3 -3
- package/prompts/engraving.md +13 -5
- package/run.sh +688 -282
- package/scripts/agy-bridge-config.py +5 -1
- package/scripts/check-acp-backend-preflight.ts +1 -1
- package/scripts/check-acp-carrier-augment.ts +246 -10
- package/scripts/check-acp-cortex.ts +5 -5
- package/scripts/check-acp-overlay.ts +13 -3
- package/scripts/check-acp-prompt-lifecycle.ts +565 -0
- package/scripts/check-acp-stop-reason.ts +342 -0
- package/scripts/check-acp-stream-hooks.ts +504 -0
- package/scripts/check-elapsed.sh +25 -0
- package/scripts/check-entwurf-bridge-boot.ts +51 -4
- package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
- package/scripts/check-entwurf-control-rpc.ts +4 -3
- package/scripts/check-entwurf-resume-args.ts +72 -70
- package/scripts/check-entwurf-session-identity.ts +14 -10
- package/scripts/check-entwurf-v2-contract.ts +34 -59
- package/scripts/check-entwurf-v2-decider.ts +17 -177
- package/scripts/check-entwurf-v2-lock.ts +5 -2
- package/scripts/check-entwurf-v2-matrix.ts +3 -53
- package/scripts/check-entwurf-v2-production.ts +2 -91
- package/scripts/check-entwurf-v2-release.ts +10 -105
- package/scripts/check-entwurf-v2-runner.ts +4 -85
- package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
- package/scripts/check-entwurf-v2-send.ts +0 -28
- package/scripts/check-entwurf-v2-surface.ts +198 -128
- package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
- package/scripts/check-fresh-cut-gate.sh +1 -1
- package/scripts/check-gate-qualification.ts +101 -7
- package/scripts/check-install-container.sh +10 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-keyset-overlap.py +1 -1
- package/scripts/check-meta-facts.ts +249 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-meta-session.ts +169 -0
- package/scripts/check-mux-launch-tmux.ts +316 -0
- package/scripts/check-mux-launch.ts +288 -0
- package/scripts/check-mux-launcher-fence.ts +264 -0
- package/scripts/check-mux-parent-artifact.ts +195 -0
- package/scripts/check-mux-placement-tmux.ts +322 -0
- package/scripts/check-mux-placement.ts +323 -0
- package/scripts/check-mux-resume-call.ts +282 -0
- package/scripts/check-probe-cli-shim.ts +25 -22
- package/scripts/check-probe-ordering.ts +121 -81
- package/scripts/check-release-gate-outcomes.ts +452 -0
- package/scripts/check-resume-launch-identity.ts +244 -0
- package/scripts/check-socket-discovery.ts +1 -1
- package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
- package/scripts/inventory-verification-surface.ts +349 -0
- package/scripts/lib/claude-launcher-fence.ts +322 -0
- package/scripts/lib/live-skip.ts +33 -0
- package/scripts/lib/mutation-qualify.ts +109 -3
- package/scripts/lib/probe-acp-turn.ts +33 -12
- package/scripts/lib/step-outcome.sh +88 -0
- package/scripts/meta-bridge-doctor.sh +6 -8
- package/scripts/meta-facts.ts +60 -0
- package/scripts/mutants/acp-augment.json +106 -0
- package/scripts/mutants/acp-cortex.json +2 -2
- package/scripts/mutants/acp-overlay.json +17 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
- package/scripts/mutants/acp-stop-reason.json +80 -0
- package/scripts/mutants/acp-stream-hooks.json +158 -0
- package/scripts/mutants/bridge-boot-resume.json +45 -0
- package/scripts/mutants/meta-facts.json +50 -0
- package/scripts/mutants/meta-identity.json +36 -0
- package/scripts/mutants/mux-boundary.json +196 -0
- package/scripts/mutants/mux-fresh-call.json +185 -0
- package/scripts/mutants/mux-launcher-fence.json +123 -0
- package/scripts/mutants/mux-parent-artifact.json +39 -0
- package/scripts/mutants/mux-resume-call.json +148 -0
- package/scripts/mutants/probe-ordering.json +6 -1021
- package/scripts/mutants/release-gate.json +140 -0
- package/scripts/mutants/resume-args.json +76 -0
- package/scripts/mutants/resume-launch-identity.json +96 -0
- package/scripts/mutants/v2-surface.json +74 -23
- package/scripts/mutants/v2-visible-resume.json +215 -0
- package/scripts/new-session-id.ts +9 -4
- package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
- package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +8 -14
- package/scripts/smoke-acp-long-turn-live.ts +185 -0
- package/scripts/smoke-acp-mcp-live.ts +2 -2
- package/scripts/smoke-acp-memory-containment-live.ts +2 -2
- package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
- package/scripts/smoke-acp-overlay-live.ts +2 -2
- package/scripts/smoke-acp-provider-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +3 -3
- package/scripts/smoke-acp-session-reuse-live.ts +2 -2
- package/scripts/smoke-acp-skill-live.ts +2 -2
- package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
- package/scripts/smoke-acp-v2-send-live.ts +4 -3
- package/scripts/smoke-agy-native-push-live.ts +10 -20
- package/scripts/smoke-claude-native-resume-live.sh +13 -3
- package/scripts/smoke-entwurf-chain-live.ts +352 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
- package/scripts/smoke-meta-honesty.sh +1 -1
- package/scripts/smoke-meta-install-state.sh +4 -0
- package/scripts/smoke-mux-fresh-call-live.ts +365 -0
- package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
- package/scripts/smoke-pi-attach.ts +1 -1
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
- package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
- package/scripts/check-acp-sdk-surface.ts +0 -236
- package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
- package/scripts/check-entwurf-v2-spawn.ts +0 -399
- package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
|
@@ -55,7 +55,7 @@ import {
|
|
|
55
55
|
type ResolvedAcpConfig,
|
|
56
56
|
resolveProviderConfig,
|
|
57
57
|
} from "./config.js";
|
|
58
|
-
import { buildAcpPrompt } from "./context.js";
|
|
58
|
+
import { type AcpTextBlock, buildAcpPrompt } from "./context.js";
|
|
59
59
|
import {
|
|
60
60
|
type AcpPiStreamState,
|
|
61
61
|
applyAcpSessionUpdate,
|
|
@@ -78,10 +78,31 @@ import {
|
|
|
78
78
|
} from "./session-store.js";
|
|
79
79
|
import { assertExcludeToolsHonored, PI_BUILTIN_BACKED_TOOLS } from "./tool-surface.js";
|
|
80
80
|
|
|
81
|
+
// Bootstrap boundaries ONLY. initialize / newSession / set-model are handshake
|
|
82
|
+
// steps that make no model progress, so a stuck one is a dead session and a cold
|
|
83
|
+
// retry costs nothing — a wall-clock bound is honest there.
|
|
84
|
+
//
|
|
85
|
+
// There is deliberately NO prompt boundary. A running turn is not a failure for
|
|
86
|
+
// having taken long: tool use, reasoning, and provider queueing all legitimately
|
|
87
|
+
// outlive any number we could pick, and the previous 600s absolute cutoff killed
|
|
88
|
+
// turns that were still actively producing tool calls. Worse, the cutoff's own
|
|
89
|
+
// message ("prompt timed out after 600000ms") lands inside pi's transient-error
|
|
90
|
+
// dictionary (`RETRYABLE_PROVIDER_ERROR_PATTERN` in @earendil-works/pi-ai
|
|
91
|
+
// `utils/retry.ts` matches `timed? out` / `timeout`), so pi replayed the SAME
|
|
92
|
+
// full prompt from a cold ACP session up to `retry.maxRetries` times — paying the
|
|
93
|
+
// whole turn again to arrive at the same wall. Elapsed time is not evidence.
|
|
94
|
+
// A prompt now ends only on lifecycle events: it resolves, the operator aborts,
|
|
95
|
+
// or the child dies / its stdio ends (see awaitAcpPromptTurn).
|
|
81
96
|
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
82
97
|
const NEW_SESSION_TIMEOUT_MS = 30_000;
|
|
83
98
|
const SET_MODEL_TIMEOUT_MS = 30_000;
|
|
84
|
-
|
|
99
|
+
|
|
100
|
+
// Bounded CLEANUP window after a user abort — not a turn deadline. On abort we
|
|
101
|
+
// send the ACP `session/cancel` notification and give the agent this long to
|
|
102
|
+
// answer the pending prompt with `cancelled` (the protocol's own ending, which
|
|
103
|
+
// maps to aborted). Only if it does not do so within the window do we escalate
|
|
104
|
+
// to process-group teardown, so an abort always returns promptly.
|
|
105
|
+
const ABORT_CANCEL_GRACE_MS = 5_000;
|
|
85
106
|
|
|
86
107
|
type StdioChild = ChildProcessByStdio<Writable, Readable, Readable>;
|
|
87
108
|
|
|
@@ -121,6 +142,12 @@ export interface AcpTurnDeps {
|
|
|
121
142
|
now(): string;
|
|
122
143
|
/** Record dir override (tests). Defaults to the real session cache dir. */
|
|
123
144
|
sessionDir?: string;
|
|
145
|
+
/**
|
|
146
|
+
* Post-abort cleanup grace (gates). Defaults to ABORT_CANCEL_GRACE_MS. This is
|
|
147
|
+
* the ONLY injectable clock left on the turn path and it bounds cleanup after
|
|
148
|
+
* an abort — never a running prompt.
|
|
149
|
+
*/
|
|
150
|
+
abortGraceMs?: number;
|
|
124
151
|
}
|
|
125
152
|
|
|
126
153
|
// ---------------------------------------------------------------------------
|
|
@@ -147,6 +174,24 @@ interface BridgeSession {
|
|
|
147
174
|
busy: boolean;
|
|
148
175
|
/** Mutable per-turn router — see the CRITICAL note in the file header. */
|
|
149
176
|
activePromptHandler?: (event: AcpBridgeEvent) => void;
|
|
177
|
+
/**
|
|
178
|
+
* The child's own dying words, SESSION-scoped on purpose. The stderr drain is
|
|
179
|
+
* installed once at spawn; keeping the buffer on the turn that spawned would
|
|
180
|
+
* leave every later reuse turn reporting a bare "ACP connection closed" with
|
|
181
|
+
* nothing to diagnose it by (observed 2026-07-30 on a live sonnet reuse turn).
|
|
182
|
+
*/
|
|
183
|
+
stderrTail: string[];
|
|
184
|
+
/** How the child ended, once it has — folded into the prompt-phase error. */
|
|
185
|
+
exit?: { code: number | null; signal: NodeJS.Signals | null };
|
|
186
|
+
/** Set while a prompt is in flight so a child death can close it (awaitAcpPromptTurn). */
|
|
187
|
+
notifyChildGone?: (err: Error) => void;
|
|
188
|
+
/**
|
|
189
|
+
* We are tearing this child down ON PURPOSE (turn-scoped teardown, config
|
|
190
|
+
* drift, error/abort cleanup). Its exit is then expected, not news: without
|
|
191
|
+
* this flag every ordinary turn-scoped turn would announce its own routine
|
|
192
|
+
* teardown to the NEXT turn as if the session had died.
|
|
193
|
+
*/
|
|
194
|
+
retiring?: boolean;
|
|
150
195
|
}
|
|
151
196
|
|
|
152
197
|
const bridgeSessions = new Map<string, BridgeSession>();
|
|
@@ -173,21 +218,78 @@ function registerGlobalCleanup(): void {
|
|
|
173
218
|
});
|
|
174
219
|
}
|
|
175
220
|
|
|
176
|
-
/**
|
|
177
|
-
|
|
221
|
+
/**
|
|
222
|
+
* The child died: mark dead + drop from map + retained set, RECORD how it ended,
|
|
223
|
+
* and close any prompt that was waiting on it. Without that last step a
|
|
224
|
+
* mid-prompt death is only observable through the SDK's generic "ACP connection
|
|
225
|
+
* closed" rejection, which names neither the exit status nor the stderr.
|
|
226
|
+
*/
|
|
227
|
+
function onChildGone(session: BridgeSession, exit?: { code: number | null; signal: NodeJS.Signals | null }): void {
|
|
178
228
|
session.alive = false;
|
|
229
|
+
if (exit) session.exit = exit;
|
|
179
230
|
if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
|
|
180
231
|
retainedChildren.delete(session.child);
|
|
232
|
+
if (session.notifyChildGone) {
|
|
233
|
+
// A turn was waiting on this child — it reports the death itself.
|
|
234
|
+
session.notifyChildGone(childEndedError(session));
|
|
235
|
+
} else if (!session.retiring) {
|
|
236
|
+
// Died BETWEEN turns with no turn to fail and nobody tearing it down, so
|
|
237
|
+
// nobody has seen it. Without this the next turn would silently open a
|
|
238
|
+
// fresh child and read as an ordinary cold start, hiding that the backend
|
|
239
|
+
// session the operator was talking to is gone. A DELIBERATE teardown is
|
|
240
|
+
// excluded — announcing our own routine cleanup would be noise, not news.
|
|
241
|
+
unreportedChildEnds.set(session.key, session.exit ?? { code: null, signal: null });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Deaths no turn observed, keyed by sessionKey and announced once by the next turn. */
|
|
246
|
+
const unreportedChildEnds = new Map<string, { code: number | null; signal: NodeJS.Signals | null }>();
|
|
247
|
+
|
|
248
|
+
/** Read-and-clear: an unreported death is announced exactly once. */
|
|
249
|
+
function takeUnreportedChildEnd(
|
|
250
|
+
sessionKey: string,
|
|
251
|
+
): { code: number | null; signal: NodeJS.Signals | null } | undefined {
|
|
252
|
+
const end = unreportedChildEnds.get(sessionKey);
|
|
253
|
+
if (end) unreportedChildEnds.delete(sessionKey);
|
|
254
|
+
return end;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** "exit code 1" / "signal SIGKILL" / "exit code 0, signal SIGTERM" / "no exit status". */
|
|
258
|
+
function describeChildEnd(exit?: { code: number | null; signal: NodeJS.Signals | null }): string {
|
|
259
|
+
if (!exit) return "no exit status";
|
|
260
|
+
const parts = [
|
|
261
|
+
exit.code !== null ? `exit code ${exit.code}` : undefined,
|
|
262
|
+
exit.signal ? `signal ${exit.signal}` : undefined,
|
|
263
|
+
].filter(Boolean);
|
|
264
|
+
return parts.length > 0 ? parts.join(", ") : "no exit status";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The prompt-phase error for a child that died under a live turn.
|
|
269
|
+
*
|
|
270
|
+
* Wording is load-bearing: pi classifies a failed assistant message by matching
|
|
271
|
+
* its errorMessage against `RETRYABLE_PROVIDER_ERROR_PATTERN`, so anything we
|
|
272
|
+
* author here that reads like "timed out" / "timeout" / "terminated" /
|
|
273
|
+
* "connection lost" would put a full cold prompt replay back on the table. This
|
|
274
|
+
* text names the lifecycle fact and nothing that looks transient.
|
|
275
|
+
* (The appended backend stderr tail is the child's text, not ours.)
|
|
276
|
+
*/
|
|
277
|
+
function childEndedError(session: BridgeSession): Error {
|
|
278
|
+
return new Error(
|
|
279
|
+
`entwurf: the ACP backend process ended while the prompt was still in flight (${describeChildEnd(session.exit)}) — ` +
|
|
280
|
+
"this turn has no answer",
|
|
281
|
+
);
|
|
181
282
|
}
|
|
182
283
|
|
|
183
284
|
// ---------------------------------------------------------------------------
|
|
184
285
|
// timeout / launch / permission / stopReason / teardown helpers
|
|
185
286
|
// ---------------------------------------------------------------------------
|
|
186
287
|
|
|
187
|
-
// Race a
|
|
188
|
-
// naive `Promise.race([p, sleep(ms)])` leaves the timer pending
|
|
189
|
-
// a dangling
|
|
288
|
+
// Race a BOOTSTRAP phase against its timeout, ALWAYS clearing the timer
|
|
289
|
+
// afterwards. A naive `Promise.race([p, sleep(ms)])` leaves the timer pending
|
|
290
|
+
// when `p` wins — a dangling timer that keeps pi's event loop alive long after
|
|
190
291
|
// the turn, so pi would never exit a `-p` run. clearTimeout in finally fixes it.
|
|
292
|
+
// Only initialize / newSession / set-model use this; the prompt has no deadline.
|
|
191
293
|
function withTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
|
|
192
294
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
193
295
|
const timeout = new Promise<never>((_, reject) => {
|
|
@@ -199,6 +301,80 @@ function withTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
|
|
|
199
301
|
});
|
|
200
302
|
}
|
|
201
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Await ONE ACP prompt with NO wall-clock deadline — the turn ends on lifecycle
|
|
306
|
+
* events only. Shared by the new and the reuse turn so both close the same way.
|
|
307
|
+
*
|
|
308
|
+
* The three endings:
|
|
309
|
+
*
|
|
310
|
+
* resolve the agent answered `session/prompt` (any stopReason — the
|
|
311
|
+
* verdict mapping is the caller's job).
|
|
312
|
+
* child gone the child exited or its stdio ended. Both are already fatal to
|
|
313
|
+
* the request — the SDK rejects every pending response when the
|
|
314
|
+
* read loop hits EOF — but the SDK's own error says only "ACP
|
|
315
|
+
* connection closed". `notifyChildGone` gets there first with the
|
|
316
|
+
* exit status, and the caller appends the session stderr tail.
|
|
317
|
+
* abort the operator cancelled. We send ACP `session/cancel` and let
|
|
318
|
+
* the agent end its own turn (`cancelled` → aborted). Escalation
|
|
319
|
+
* to process-group teardown happens only after a bounded grace,
|
|
320
|
+
* and closing the connection rejects the pending request, so an
|
|
321
|
+
* abort is always answered even against a wedged child.
|
|
322
|
+
*
|
|
323
|
+
* A stalled-but-alive child is deliberately NOT an ending: a silent turn is not
|
|
324
|
+
* a failed turn, and nothing here may kill one for being quiet.
|
|
325
|
+
*/
|
|
326
|
+
async function awaitAcpPromptTurn(
|
|
327
|
+
session: BridgeSession,
|
|
328
|
+
promptArgs: { sessionId: string; prompt: AcpTextBlock[] },
|
|
329
|
+
opts: { signal?: AbortSignal; graceMs: number },
|
|
330
|
+
): Promise<{ stopReason?: string }> {
|
|
331
|
+
let rejectLifecycle: ((err: Error) => void) | undefined;
|
|
332
|
+
const lifecycle = new Promise<never>((_, reject) => {
|
|
333
|
+
rejectLifecycle = reject;
|
|
334
|
+
});
|
|
335
|
+
// The race's loser stays pending forever when the prompt wins; a rejection
|
|
336
|
+
// nobody observed would surface as an unhandled rejection at that point.
|
|
337
|
+
lifecycle.catch(() => {});
|
|
338
|
+
|
|
339
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
340
|
+
const escalateAbort = (): void => {
|
|
341
|
+
killChildGroup(session.child, "SIGTERM");
|
|
342
|
+
// Closing rejects the still-pending ACP request, so the await below settles
|
|
343
|
+
// even when the child ignores both the cancel notification and the signal.
|
|
344
|
+
session.connection.close?.(new Error("entwurf: ACP prompt cancelled by the operator"));
|
|
345
|
+
rejectLifecycle?.(new Error("entwurf: the ACP prompt was cancelled by the operator"));
|
|
346
|
+
};
|
|
347
|
+
const onAbort = (): void => {
|
|
348
|
+
try {
|
|
349
|
+
session.connection.cancel?.({ sessionId: promptArgs.sessionId });
|
|
350
|
+
} catch {
|
|
351
|
+
// best-effort: escalation below is what guarantees the abort returns.
|
|
352
|
+
}
|
|
353
|
+
// NOT unref'd, deliberately. This timer is the only thing that finishes an
|
|
354
|
+
// abort against an agent that ignores session/cancel, so letting the event
|
|
355
|
+
// loop drain past it would leave the turn unsettled and the child alive.
|
|
356
|
+
// It is bounded (graceMs) and cleared in the finally below, so the worst it
|
|
357
|
+
// can do is hold an exiting process for that grace — which is the cleanup
|
|
358
|
+
// we asked for.
|
|
359
|
+
graceTimer = setTimeout(escalateAbort, opts.graceMs);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
session.notifyChildGone = (err) => rejectLifecycle?.(err);
|
|
363
|
+
const signal = opts.signal;
|
|
364
|
+
if (signal) {
|
|
365
|
+
if (signal.aborted) onAbort();
|
|
366
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
return await Promise.race([session.connection.prompt(promptArgs), lifecycle]);
|
|
371
|
+
} finally {
|
|
372
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
373
|
+
signal?.removeEventListener("abort", onAbort);
|
|
374
|
+
session.notifyChildGone = undefined;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
202
378
|
/** Approve-all permission policy (YOLO — oracle F). options empty → cancelled. */
|
|
203
379
|
function resolvePermissionResponse(params: { options?: Array<{ optionId: string; kind?: string }> }): {
|
|
204
380
|
outcome: { outcome: "selected"; optionId: string } | { outcome: "cancelled" };
|
|
@@ -209,15 +385,61 @@ function resolvePermissionResponse(params: { options?: Array<{ optionId: string;
|
|
|
209
385
|
return { outcome: { outcome: "selected", optionId: (allow ?? options[0]).optionId } };
|
|
210
386
|
}
|
|
211
387
|
|
|
212
|
-
/** ACP prompt
|
|
213
|
-
|
|
388
|
+
/** Verdict for one ACP prompt result — what pi should report, and why. */
|
|
389
|
+
export type AcpStopVerdict = {
|
|
390
|
+
stopReason: AssistantMessage["stopReason"];
|
|
391
|
+
/** The raw ACP reason, preserved whenever the wire carried one. */
|
|
392
|
+
rawStopReason?: string;
|
|
393
|
+
/** Set exactly when `stopReason` is "error" — carries the reason into the UI. */
|
|
394
|
+
errorMessage?: string;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* ACP prompt stopReason → pi verdict.
|
|
399
|
+
*
|
|
400
|
+
* The ACP terminal set is closed (`@agentclientprotocol/sdk` 1.3.0
|
|
401
|
+
* `schema/types.gen`): end_turn | max_tokens | max_turn_requests | refusal |
|
|
402
|
+
* cancelled. Only three of those are successful or benign ends. The previous
|
|
403
|
+
* implementation returned a bare StopReason with `default: "stop"`, which turned
|
|
404
|
+
* `refusal`, `max_turn_requests`, any future member, AND a missing reason into a
|
|
405
|
+
* clean successful turn — pi then rendered a silently truncated answer as if the
|
|
406
|
+
* model had finished. pi 0.83 closed the same hole in its own providers (#7272:
|
|
407
|
+
* unmapped terminal reasons surface as provider errors, never successful stops)
|
|
408
|
+
* and added `rawStopReason` so the wire value survives the mapping. This mirrors
|
|
409
|
+
* that contract rather than inventing a local one.
|
|
410
|
+
*/
|
|
411
|
+
export function mapPromptStopReason(stopReason: string | undefined): AcpStopVerdict {
|
|
214
412
|
switch (stopReason) {
|
|
413
|
+
case "end_turn":
|
|
414
|
+
return { stopReason: "stop", rawStopReason: stopReason };
|
|
215
415
|
case "max_tokens":
|
|
216
|
-
return "length";
|
|
416
|
+
return { stopReason: "length", rawStopReason: stopReason };
|
|
217
417
|
case "cancelled":
|
|
218
|
-
return "aborted";
|
|
418
|
+
return { stopReason: "aborted", rawStopReason: stopReason };
|
|
419
|
+
case "refusal":
|
|
420
|
+
return {
|
|
421
|
+
stopReason: "error",
|
|
422
|
+
rawStopReason: stopReason,
|
|
423
|
+
errorMessage: "ACP backend stopped with: refusal (the model declined to answer; the turn is incomplete)",
|
|
424
|
+
};
|
|
425
|
+
case "max_turn_requests":
|
|
426
|
+
return {
|
|
427
|
+
stopReason: "error",
|
|
428
|
+
rawStopReason: stopReason,
|
|
429
|
+
errorMessage:
|
|
430
|
+
"ACP backend stopped with: max_turn_requests (the backend's per-turn request budget was exhausted; the turn is incomplete)",
|
|
431
|
+
};
|
|
432
|
+
case undefined:
|
|
433
|
+
return {
|
|
434
|
+
stopReason: "error",
|
|
435
|
+
errorMessage: "ACP backend ended the turn without a stop reason",
|
|
436
|
+
};
|
|
219
437
|
default:
|
|
220
|
-
return
|
|
438
|
+
return {
|
|
439
|
+
stopReason: "error",
|
|
440
|
+
rawStopReason: stopReason,
|
|
441
|
+
errorMessage: `ACP backend stopped with an unrecognized reason: ${stopReason}`,
|
|
442
|
+
};
|
|
221
443
|
}
|
|
222
444
|
}
|
|
223
445
|
|
|
@@ -380,6 +602,58 @@ export function actionableAcpBackendHint(message: string): string | undefined {
|
|
|
380
602
|
].join("\n");
|
|
381
603
|
}
|
|
382
604
|
|
|
605
|
+
/**
|
|
606
|
+
* pi 0.84 streamSimple hook contract (#63; upstream pi-mono #7372 → doc-only PR
|
|
607
|
+
* #7576): implementations must invoke `options.onPayload` before sending the
|
|
608
|
+
* provider request and use any returned replacement.
|
|
609
|
+
*
|
|
610
|
+
* On this rail the provider request is the ACP `session/prompt` params — so the
|
|
611
|
+
* hook receives the EXACT `{sessionId, prompt}` object after the wire content is
|
|
612
|
+
* fully built (augment / reuse delta included) and immediately before
|
|
613
|
+
* `connection.prompt`. That is the truthful analogue of the built-in providers'
|
|
614
|
+
* post-build/pre-send boundary. Replacement is honored fail-closed, never
|
|
615
|
+
* silently: it must be a non-null, non-array object, keep the bootstrapped
|
|
616
|
+
* `sessionId`, and carry a non-empty prompt array — prompt rewriting is
|
|
617
|
+
* upstream-granted power, but entwurf cannot truthfully deliver to an ACP
|
|
618
|
+
* session it did not open, and an emptied prompt would undo this rail's own
|
|
619
|
+
* non-empty-prompt invariant; either refuses the turn before the wire.
|
|
620
|
+
*
|
|
621
|
+
* `options.onResponse` is an EXPLICIT LOCAL NON-HTTP EXEMPTION and is never
|
|
622
|
+
* invoked anywhere on this rail: pi hard-types it as HTTP `{status, headers}`
|
|
623
|
+
* (and the `after_provider_response` extension event re-emits exactly that), and
|
|
624
|
+
* ACP's terminal result arrives only AFTER the session-update body was already
|
|
625
|
+
* consumed — both the shape and the "before consuming its body" ordering are
|
|
626
|
+
* unmappable, so any call would fabricate HTTP evidence that does not exist.
|
|
627
|
+
* The absence is pinned behaviorally by check-acp-stream-hooks.
|
|
628
|
+
*/
|
|
629
|
+
async function applyProviderPayloadHook<T extends { sessionId: string }>(
|
|
630
|
+
options: SimpleStreamOptions | undefined,
|
|
631
|
+
params: T,
|
|
632
|
+
model: Model<Api>,
|
|
633
|
+
): Promise<T> {
|
|
634
|
+
const onPayload = options?.onPayload;
|
|
635
|
+
if (!onPayload) return params;
|
|
636
|
+
const replacement = await onPayload(params, model);
|
|
637
|
+
if (replacement === undefined) return params;
|
|
638
|
+
if (typeof replacement !== "object" || replacement === null || Array.isArray(replacement)) {
|
|
639
|
+
throw new Error(
|
|
640
|
+
"entwurf: before_provider_request returned a non-object replacement — the ACP prompt payload must stay a non-null, non-array object",
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
if ((replacement as { sessionId?: unknown }).sessionId !== params.sessionId) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
"entwurf: before_provider_request changed the ACP sessionId — entwurf cannot truthfully deliver to a session it did not bootstrap; prompt rewriting is allowed, session identity is not",
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const replacementPrompt = (replacement as { prompt?: unknown }).prompt;
|
|
649
|
+
if (!Array.isArray(replacementPrompt) || replacementPrompt.length === 0) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
"entwurf: before_provider_request returned a replacement without a non-empty prompt array — an empty ACP prompt cannot be sent",
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
return replacement as T;
|
|
655
|
+
}
|
|
656
|
+
|
|
383
657
|
/**
|
|
384
658
|
* streamSimple for the entwurf provider. Returns the event stream
|
|
385
659
|
* synchronously and drives the ACP turn on a microtask.
|
|
@@ -426,15 +700,30 @@ export function streamAcpTurn(
|
|
|
426
700
|
};
|
|
427
701
|
}
|
|
428
702
|
|
|
703
|
+
/**
|
|
704
|
+
* Seal the turn from the ACP prompt result. "Success" here means the RPC
|
|
705
|
+
* returned, not that the turn ended well — a returned `refusal` /
|
|
706
|
+
* `max_turn_requests` / unknown / absent reason is sealed as an error event,
|
|
707
|
+
* never a `done`. `rawStopReason` carries the wire value out either way.
|
|
708
|
+
*/
|
|
429
709
|
function finishSuccess(promptResult: { stopReason?: string }): void {
|
|
430
710
|
finalizeAcpStreamState(state);
|
|
431
|
-
const
|
|
432
|
-
if (
|
|
711
|
+
const verdict = mapPromptStopReason(promptResult?.stopReason);
|
|
712
|
+
if (verdict.rawStopReason !== undefined) state.output.rawStopReason = verdict.rawStopReason;
|
|
713
|
+
if (signal?.aborted || verdict.stopReason === "aborted") {
|
|
433
714
|
state.output.stopReason = "aborted";
|
|
434
715
|
stream.push({ type: "error", reason: "aborted", error: state.output });
|
|
716
|
+
} else if (verdict.stopReason === "error") {
|
|
717
|
+
state.output.stopReason = "error";
|
|
718
|
+
state.output.errorMessage = verdict.errorMessage;
|
|
719
|
+
stream.push({ type: "error", reason: "error", error: state.output });
|
|
435
720
|
} else {
|
|
436
|
-
state.output.stopReason =
|
|
437
|
-
stream.push({
|
|
721
|
+
state.output.stopReason = verdict.stopReason;
|
|
722
|
+
stream.push({
|
|
723
|
+
type: "done",
|
|
724
|
+
reason: verdict.stopReason === "length" ? "length" : "stop",
|
|
725
|
+
message: state.output,
|
|
726
|
+
});
|
|
438
727
|
}
|
|
439
728
|
stream.end();
|
|
440
729
|
}
|
|
@@ -601,6 +890,7 @@ export function streamAcpTurn(
|
|
|
601
890
|
// connection + child so it is not orphaned in retainedChildren (GPT blocker 2).
|
|
602
891
|
if (decision.path === "new" && existing) {
|
|
603
892
|
existing.alive = false;
|
|
893
|
+
existing.retiring = true;
|
|
604
894
|
if (bridgeSessions.get(sessionKey) === existing) bridgeSessions.delete(sessionKey);
|
|
605
895
|
retainedChildren.delete(existing.child);
|
|
606
896
|
existing.connection.close?.();
|
|
@@ -639,10 +929,23 @@ export function streamAcpTurn(
|
|
|
639
929
|
try {
|
|
640
930
|
if (signal?.aborted) throw new Error("aborted before launch");
|
|
641
931
|
|
|
932
|
+
// A backend session that died BETWEEN turns is announced here, before
|
|
933
|
+
// the bootstrap notice — otherwise this turn looks like an ordinary
|
|
934
|
+
// cold start and the operator never learns that the session they were
|
|
935
|
+
// talking to ended. Announced once (read-and-clear); the turn continues
|
|
936
|
+
// normally, since opening a fresh child for a NEW user turn is not a
|
|
937
|
+
// replay of anything.
|
|
938
|
+
const priorEnd = takeUnreportedChildEnd(sessionKey);
|
|
939
|
+
if (priorEnd) {
|
|
940
|
+
pushAcpLifecycleNotice(
|
|
941
|
+
state,
|
|
942
|
+
`previous ${adapter.backend} session ended between turns (${describeChildEnd(priorEnd)}) — opening a new one`,
|
|
943
|
+
);
|
|
944
|
+
}
|
|
642
945
|
// S2f visibility: surface the otherwise-silent bootstrap so a slow
|
|
643
946
|
// overlay/spawn/init does not read as a hang. Display-only (marked).
|
|
644
947
|
pushAcpLifecycleNotice(state, `preparing ${adapter.backend} session`);
|
|
645
|
-
//
|
|
948
|
+
// Overlay ordering (rail “Adapter contract”): materialize the overlay first, then spawn with launchEnvDefaults
|
|
646
949
|
// + overlay.envOverrides merged over process.env (defaultDeps spawnChild).
|
|
647
950
|
// sessionKey is the AUTHORITATIVE per-session identity (resolveSessionKey:
|
|
648
951
|
// opts.sessionId → PI_SESSION_ID → cwd) — a session-scoped overlay must
|
|
@@ -659,6 +962,11 @@ export function streamAcpTurn(
|
|
|
659
962
|
if (stderrTail.length > 50) stderrTail.shift();
|
|
660
963
|
});
|
|
661
964
|
|
|
965
|
+
// Abort during BOOTSTRAP (spawn → initialize → newSession → set-model):
|
|
966
|
+
// there is no prompt turn for the agent to cancel yet, so the child is
|
|
967
|
+
// simply torn down. This listener is handed off before the prompt —
|
|
968
|
+
// awaitAcpPromptTurn installs the protocol-cancel-first one for that
|
|
969
|
+
// window, and two live listeners would race SIGTERM against the cancel.
|
|
662
970
|
if (signal) {
|
|
663
971
|
onAbort = () => killChildGroup(spawned, "SIGTERM");
|
|
664
972
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
@@ -700,9 +1008,15 @@ export function streamAcpTurn(
|
|
|
700
1008
|
alive: true,
|
|
701
1009
|
busy: true,
|
|
702
1010
|
activePromptHandler: undefined,
|
|
1011
|
+
// SAME array the stderr drain above pushes into: the buffer outlives
|
|
1012
|
+
// this turn with the session, so a later reuse turn can still report
|
|
1013
|
+
// the child's dying words.
|
|
1014
|
+
stderrTail,
|
|
703
1015
|
};
|
|
704
1016
|
const sess = session;
|
|
705
|
-
spawned.once("exit", () =>
|
|
1017
|
+
spawned.once("exit", (...args: unknown[]) =>
|
|
1018
|
+
onChildGone(sess, { code: (args[0] as number | null) ?? null, signal: (args[1] as NodeJS.Signals) ?? null }),
|
|
1019
|
+
);
|
|
706
1020
|
spawned.once("error", () => onChildGone(sess));
|
|
707
1021
|
|
|
708
1022
|
await withTimeout(
|
|
@@ -730,7 +1044,7 @@ export function streamAcpTurn(
|
|
|
730
1044
|
modelId: model.id,
|
|
731
1045
|
piSessionId: process.env.PI_SESSION_ID?.trim() || undefined,
|
|
732
1046
|
});
|
|
733
|
-
//
|
|
1047
|
+
// Carrier-less shape (rail “Adapter contract”): omit the `_meta` KEY entirely for a carrier-less backend
|
|
734
1048
|
// (sessionMeta === undefined), not `_meta: undefined`.
|
|
735
1049
|
const newSessionArgs =
|
|
736
1050
|
sessionMeta === undefined
|
|
@@ -776,11 +1090,22 @@ export function streamAcpTurn(
|
|
|
776
1090
|
// prompt could still sync-reject before the wire write; the next visible
|
|
777
1091
|
// event after this is the backend's own first token / tool notice.
|
|
778
1092
|
pushAcpLifecycleNotice(state, "sending prompt");
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
);
|
|
1093
|
+
// #63: the pi streamSimple payload hook sees the EXACT wire params and may
|
|
1094
|
+
// replace them (fail-closed integrity inside the helper). It runs while the
|
|
1095
|
+
// bootstrap abort listener is still armed; the recheck below keeps an abort
|
|
1096
|
+
// raised during a slow handler ahead of the wire write.
|
|
1097
|
+
const wireParams = await applyProviderPayloadHook(options, { sessionId: acpSessionId, prompt }, model);
|
|
1098
|
+
if (signal?.aborted) throw new Error("aborted during payload hook");
|
|
1099
|
+
// Hand the abort window over to the prompt driver: from here on an abort
|
|
1100
|
+
// is a protocol `session/cancel` first, teardown only after the grace.
|
|
1101
|
+
if (signal && onAbort) {
|
|
1102
|
+
signal.removeEventListener("abort", onAbort);
|
|
1103
|
+
onAbort = undefined;
|
|
1104
|
+
}
|
|
1105
|
+
const promptResult = await awaitAcpPromptTurn(session, wireParams, {
|
|
1106
|
+
signal,
|
|
1107
|
+
graceMs: deps.abortGraceMs ?? ABORT_CANCEL_GRACE_MS,
|
|
1108
|
+
});
|
|
784
1109
|
|
|
785
1110
|
session.activePromptHandler = undefined;
|
|
786
1111
|
session.busy = false;
|
|
@@ -798,6 +1123,7 @@ export function streamAcpTurn(
|
|
|
798
1123
|
unrefRetainedChild(spawned);
|
|
799
1124
|
persistRecord(session, deps);
|
|
800
1125
|
} else {
|
|
1126
|
+
session.retiring = true;
|
|
801
1127
|
connection.close?.();
|
|
802
1128
|
teardownChild(spawned);
|
|
803
1129
|
}
|
|
@@ -811,11 +1137,12 @@ export function streamAcpTurn(
|
|
|
811
1137
|
// error/abort → drop the (uncertain) session and close its child; an
|
|
812
1138
|
// uncertain connection must never be reused (GPT ④).
|
|
813
1139
|
if (child) {
|
|
1140
|
+
if (session) session.retiring = true;
|
|
814
1141
|
retainedChildren.delete(child);
|
|
815
1142
|
session?.connection.close?.(err);
|
|
816
1143
|
teardownChild(child);
|
|
817
1144
|
}
|
|
818
|
-
finishError(err, aborted, stderrTail);
|
|
1145
|
+
finishError(err, aborted, session?.stderrTail ?? stderrTail);
|
|
819
1146
|
} finally {
|
|
820
1147
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
821
1148
|
}
|
|
@@ -823,7 +1150,6 @@ export function streamAcpTurn(
|
|
|
823
1150
|
|
|
824
1151
|
// --- reuse: send only the latest user delta to the live ACP session
|
|
825
1152
|
async function runReuseTurn(session: BridgeSession, ctxSigs: string[]): Promise<void> {
|
|
826
|
-
let onAbort: (() => void) | undefined;
|
|
827
1153
|
try {
|
|
828
1154
|
if (signal?.aborted) throw new Error("aborted before prompt");
|
|
829
1155
|
|
|
@@ -832,10 +1158,6 @@ export function streamAcpTurn(
|
|
|
832
1158
|
pushAcpLifecycleNotice(state, "reusing live session");
|
|
833
1159
|
session.busy = true;
|
|
834
1160
|
session.activePromptHandler = makePromptHandler(session);
|
|
835
|
-
if (signal) {
|
|
836
|
-
onAbort = () => killChildGroup(session.child, "SIGTERM");
|
|
837
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
838
|
-
}
|
|
839
1161
|
|
|
840
1162
|
// The live ACP session already remembers the prior turns → send only the
|
|
841
1163
|
// latest user delta (re-sending the transcript would duplicate history).
|
|
@@ -844,11 +1166,15 @@ export function streamAcpTurn(
|
|
|
844
1166
|
|
|
845
1167
|
// S2f visibility: about to send the delta to the resident child.
|
|
846
1168
|
pushAcpLifecycleNotice(state, "sending prompt");
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
);
|
|
1169
|
+
// #63: same hook boundary as a new turn — the reuse delta is the wire
|
|
1170
|
+
// params here. The recheck keeps an abort raised during a slow handler
|
|
1171
|
+
// ahead of the wire write; the prompt driver then owns the abort surface.
|
|
1172
|
+
const wireParams = await applyProviderPayloadHook(options, { sessionId: session.acpSessionId, prompt }, model);
|
|
1173
|
+
if (signal?.aborted) throw new Error("aborted during payload hook");
|
|
1174
|
+
const promptResult = await awaitAcpPromptTurn(session, wireParams, {
|
|
1175
|
+
signal,
|
|
1176
|
+
graceMs: deps.abortGraceMs ?? ABORT_CANCEL_GRACE_MS,
|
|
1177
|
+
});
|
|
852
1178
|
|
|
853
1179
|
session.activePromptHandler = undefined;
|
|
854
1180
|
session.busy = false;
|
|
@@ -864,12 +1190,14 @@ export function streamAcpTurn(
|
|
|
864
1190
|
session.busy = false;
|
|
865
1191
|
// error/abort on a reused session → drop it and close the child (GPT ④).
|
|
866
1192
|
if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
|
|
1193
|
+
session.retiring = true;
|
|
867
1194
|
retainedChildren.delete(session.child);
|
|
868
1195
|
session.connection.close?.(err);
|
|
869
1196
|
teardownChild(session.child);
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1197
|
+
// The SAME diagnostics a new turn reports. Without the session-scoped
|
|
1198
|
+
// tail a mid-turn child death on a resident session surfaced as a bare
|
|
1199
|
+
// "ACP connection closed" with nothing to read it by.
|
|
1200
|
+
finishError(err, aborted, session.stderrTail);
|
|
873
1201
|
}
|
|
874
1202
|
}
|
|
875
1203
|
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// the backend-invariant exclude-tools preflight and folds every one of them into
|
|
24
24
|
// `bridgeConfigSignature` (so changing one still invalidates a reused cortex
|
|
25
25
|
// session). Backend-OWNED settings never land here: they ride the opaque
|
|
26
|
-
// `adapterSettings` seam (
|
|
26
|
+
// `adapterSettings` seam (rail “Adapter contract”), which is what keeps a backend-named key like
|
|
27
27
|
// `cortexConnection` off this common shape.
|
|
28
28
|
//
|
|
29
29
|
// PURITY / SIGNATURE contract (NEXT oracle C / 핀1 / GPT `…2f9325` boost):
|
|
@@ -35,6 +35,16 @@
|
|
|
35
35
|
// incompatible and the next turn opens a fresh ACP session with the new carrier.
|
|
36
36
|
// That per-turn rebuild is the accepted cost of the A/B opt-in surface, never the
|
|
37
37
|
// shipped default (which stays cached precisely so a resident never rebuilds).
|
|
38
|
+
//
|
|
39
|
+
// A-JOIN (measured LIVE 2026-07-31, 0.64.0 adapter, fresh Claude ACP): the model's
|
|
40
|
+
// system prompt arrived as
|
|
41
|
+
// `You are a Claude agent, built on Anthropic's Claude Agent SDK.# Engraving Here`
|
|
42
|
+
// A string-form `_meta.systemPrompt` replaces the `claude_code` preset, but the
|
|
43
|
+
// SDK still PREFIXES its own fixed identity sentence and joins the two with
|
|
44
|
+
// NOTHING — so the operator's heading was swallowed into the tail of the SDK's
|
|
45
|
+
// sentence. The boundary therefore belongs to the CARRIER, and it cannot be
|
|
46
|
+
// delegated to engraving.md: the render is trimmed (below), so a leading blank
|
|
47
|
+
// line in the markdown is eaten before it ever reaches the wire.
|
|
38
48
|
|
|
39
49
|
import { readFileSync } from "node:fs";
|
|
40
50
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -43,6 +53,16 @@ import { fileURLToPath } from "node:url";
|
|
|
43
53
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
44
54
|
const DEFAULT_ENGRAVING_PATH = join(HERE, "prompts", "engraving.md");
|
|
45
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The carrier's LEADING boundary — the one the Claude Agent SDK does not supply
|
|
58
|
+
* (see A-JOIN above). A constant, never derived from the template, so the render
|
|
59
|
+
* stays a pure function of (template, backend, mcpServerNames) and the operator's
|
|
60
|
+
* file whitespace can never drift `bridgeConfigSignature`. One blank line is the
|
|
61
|
+
* whole lever: it puts the carrier's first line at the start of its own block
|
|
62
|
+
* instead of at the end of the SDK's fixed sentence.
|
|
63
|
+
*/
|
|
64
|
+
export const CARRIER_LEAD_SEPARATOR = "\n\n";
|
|
65
|
+
|
|
46
66
|
export interface EngravingParams {
|
|
47
67
|
/** Always "claude" in practice — a system-prompt-carrier-less backend (cortex)
|
|
48
68
|
* returns null from `loadCarrier` WITHOUT calling this loader, so claudeAdapter is
|
|
@@ -82,6 +102,22 @@ function interpolate(template: string, params: EngravingParams): string {
|
|
|
82
102
|
return template.replace(/\{\{backend\}\}/g, params.backend).replace(/\{\{mcp_servers\}\}/g, mcpList);
|
|
83
103
|
}
|
|
84
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Render one template into a wire-ready carrier, or `""` when the template has no
|
|
107
|
+
* body (the caller turns that into the opt-out / fail-loud branches).
|
|
108
|
+
*
|
|
109
|
+
* The ORDER here is the A-join fix. Trim first: the operator template's own
|
|
110
|
+
* leading/trailing whitespace must not reach the wire (it would drift
|
|
111
|
+
* bridgeConfigSignature) and the emptiness test must see the BODY — a
|
|
112
|
+
* separator-only string is an opt-out, not a carrier. Then attach OUR boundary,
|
|
113
|
+
* which is what the SDK's fixed sentence has nothing of.
|
|
114
|
+
*/
|
|
115
|
+
function renderCarrier(source: string, params: EngravingParams): string {
|
|
116
|
+
const body = interpolate(source, params).trim();
|
|
117
|
+
if (body.length === 0) return "";
|
|
118
|
+
return `${CARRIER_LEAD_SEPARATOR}${body}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
85
121
|
/**
|
|
86
122
|
* The rendered engraving carrier, or null when an ENV-OVERRIDE engraving file
|
|
87
123
|
* (`ENTWURF_ACP_ENGRAVING_PATH`) is empty, whitespace-only, missing, or
|
|
@@ -94,6 +130,12 @@ function interpolate(template: string, params: EngravingParams): string {
|
|
|
94
130
|
* Callers MUST treat null as "no carrier configured" and omit `_meta.systemPrompt`
|
|
95
131
|
* entirely (passing "" as the `appendSystemPrompt` signature input) so
|
|
96
132
|
* subscription billing is never reclassified.
|
|
133
|
+
*
|
|
134
|
+
* A non-null carrier always LEADS with `CARRIER_LEAD_SEPARATOR`, and callers must
|
|
135
|
+
* pass it on BYTE-FOR-BYTE: the same string feeds `bridgeConfigSignature`
|
|
136
|
+
* (`appendSystemPrompt`) and the wire (`_meta.systemPrompt`), so normalizing it at
|
|
137
|
+
* either hop both re-opens the A-join and makes reuse key on a string that was
|
|
138
|
+
* never sent.
|
|
97
139
|
*/
|
|
98
140
|
export function loadEngraving(params: EngravingParams): string | null {
|
|
99
141
|
const filePath = resolveEngravingPath();
|
|
@@ -110,7 +152,7 @@ export function loadEngraving(params: EngravingParams): string | null {
|
|
|
110
152
|
}
|
|
111
153
|
return null;
|
|
112
154
|
}
|
|
113
|
-
const rendered =
|
|
155
|
+
const rendered = renderCarrier(source, params);
|
|
114
156
|
if (rendered.length === 0) {
|
|
115
157
|
if (isShippedDefault) {
|
|
116
158
|
throw new Error(
|