@junghanacs/entwurf 0.12.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 +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- package/scripts/tsconfig.json +34 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
// ACP plugin — real streamSimple backend: in-memory session reuse (S2d-1b-2b)
|
|
2
|
+
// + billing carrier + first-user augment (S2d-1c).
|
|
3
|
+
//
|
|
4
|
+
// S2c opened the provider path as spawn-per-turn: every streamSimple call spawned
|
|
5
|
+
// a FRESH ACP session and tore it down. S2d-1b-2b adds in-memory session REUSE
|
|
6
|
+
// for long-lived (process-scoped) pi processes so a resident does not pay a full
|
|
7
|
+
// spawn+initialize+newSession on every turn and so the model keeps its own ACP
|
|
8
|
+
// history across turns (the delta-only prompt scope — 핀4).
|
|
9
|
+
//
|
|
10
|
+
// Two orthogonal axes (GPT 73b44d):
|
|
11
|
+
// - bootstrapPath (history source): `new` sends the FULL transcript (a fresh ACP
|
|
12
|
+
// session holds no history); `reuse` sends only the latest user delta (the
|
|
13
|
+
// live ACP session already remembers the prior turns — re-sending the whole
|
|
14
|
+
// transcript would duplicate history). buildAcpPrompt() owns that split.
|
|
15
|
+
// - lifecyclePolicy (does the child outlive the turn): `process-scoped`
|
|
16
|
+
// (`--entwurf-control` resident) MAY keep the child + connection in an
|
|
17
|
+
// in-memory map and reuse it; `turn-scoped` (`pi -p` one-shot AND plain
|
|
18
|
+
// interactive) is ALWAYS `new` + teardown — a surviving child's stdio handle
|
|
19
|
+
// would pin a one-shot pi's exit (the S2c hang).
|
|
20
|
+
//
|
|
21
|
+
// Scope of THIS cut (GPT 73b44d / c617cb):
|
|
22
|
+
// - in-memory reuse + new ONLY. Persisted resume/load is the next lane (1b-2c):
|
|
23
|
+
// the record is WRITTEN (so 1b-2c can use it) but never READ/used here, and no
|
|
24
|
+
// resume/load capability is passed to decideBootstrap.
|
|
25
|
+
// - S2d-1c carrier + augment: the engraving carrier (`_meta.systemPrompt`,
|
|
26
|
+
// SHORT, NON-EMPTY by default → v1 preset-replacement memory-containment
|
|
27
|
+
// lever) feeds BOTH the config signature and the session meta from one
|
|
28
|
+
// rendered string; the rich first-user augment (bridge identity + AGENTS + pi
|
|
29
|
+
// base) is prepended to the `new` prompt ONLY, on the wire, so it never enters
|
|
30
|
+
// the reuse-compat signature.
|
|
31
|
+
//
|
|
32
|
+
// CRITICAL — mutable activePromptHandler routing: a retained ACP connection
|
|
33
|
+
// outlives the turn, so its sessionUpdate/requestPermission callbacks must NOT
|
|
34
|
+
// close over the first turn's stream state (turn 2's notifications would leak into
|
|
35
|
+
// turn 1's finished stream). The callbacks delegate to a MUTABLE
|
|
36
|
+
// `session.activePromptHandler` that each turn sets to its own stream state and
|
|
37
|
+
// clears in finally.
|
|
38
|
+
//
|
|
39
|
+
// Errors are encoded into the RETURNED event stream as an `error` event with a
|
|
40
|
+
// final assistant message — never thrown after the stream is returned (the
|
|
41
|
+
// AssistantMessageEventStream contract).
|
|
42
|
+
|
|
43
|
+
import { type ChildProcessByStdio, spawn } from "node:child_process";
|
|
44
|
+
import { readFileSync } from "node:fs";
|
|
45
|
+
import { Readable, Writable } from "node:stream";
|
|
46
|
+
import { ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
|
47
|
+
import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
|
|
48
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
49
|
+
import { type AcpClientHandlers, type AcpConnectionLike, connectAcpClient } from "./acp-client.js";
|
|
50
|
+
import { prependNewPromptAugment } from "./augment.js";
|
|
51
|
+
import { type AcpBackendAdapter, resolveAcpBackendAdapter } from "./backend-adapter.js";
|
|
52
|
+
import {
|
|
53
|
+
enrichMcpServersWithEnvelope,
|
|
54
|
+
mcpServerNames,
|
|
55
|
+
type ResolvedAcpConfig,
|
|
56
|
+
resolveProviderConfig,
|
|
57
|
+
} from "./config.js";
|
|
58
|
+
import { buildAcpPrompt } from "./context.js";
|
|
59
|
+
import {
|
|
60
|
+
type AcpPiStreamState,
|
|
61
|
+
applyAcpSessionUpdate,
|
|
62
|
+
createAcpStreamState,
|
|
63
|
+
finalizeAcpStreamState,
|
|
64
|
+
pushAcpLifecycleNotice,
|
|
65
|
+
pushPermissionNotice,
|
|
66
|
+
} from "./event-mapper.js";
|
|
67
|
+
import {
|
|
68
|
+
type BootstrapDecision,
|
|
69
|
+
type BootstrapParams,
|
|
70
|
+
bridgeConfigSignature,
|
|
71
|
+
buildSessionRecord,
|
|
72
|
+
contextMessageSignatures,
|
|
73
|
+
decideBootstrap,
|
|
74
|
+
type ExistingSession,
|
|
75
|
+
type LifecyclePolicy,
|
|
76
|
+
resolveLifecyclePolicy,
|
|
77
|
+
writeSessionRecord,
|
|
78
|
+
} from "./session-store.js";
|
|
79
|
+
import { assertExcludeToolsHonored, PI_BUILTIN_BACKED_TOOLS } from "./tool-surface.js";
|
|
80
|
+
|
|
81
|
+
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
82
|
+
const NEW_SESSION_TIMEOUT_MS = 30_000;
|
|
83
|
+
const SET_MODEL_TIMEOUT_MS = 30_000;
|
|
84
|
+
const PROMPT_TIMEOUT_MS = 600_000;
|
|
85
|
+
|
|
86
|
+
type StdioChild = ChildProcessByStdio<Writable, Readable, Readable>;
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Injectable seam (deterministic gates) — production wires the real spawn /
|
|
90
|
+
// connection; check-acp-session-reuse injects fakes so it can drive two turns
|
|
91
|
+
// and CAPTURE the prompt payloads without launching a real ACP child.
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/** The subset of the spawned child the backend touches (real or fake). */
|
|
95
|
+
export interface AcpChildLike {
|
|
96
|
+
pid?: number | null;
|
|
97
|
+
exitCode: number | null;
|
|
98
|
+
signalCode: NodeJS.Signals | null;
|
|
99
|
+
stdin: { destroy(): void; unref?(): void };
|
|
100
|
+
stdout: { destroy(): void; unref?(): void };
|
|
101
|
+
stderr: { on(event: "data", listener: (chunk: Buffer) => void): void; destroy(): void; unref?(): void };
|
|
102
|
+
kill(signal?: NodeJS.Signals | number): boolean;
|
|
103
|
+
unref(): void;
|
|
104
|
+
once(event: "exit" | "error", listener: (...args: unknown[]) => void): void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// AcpConnectionLike / AcpClientHandlers (the connection seam the backend drives
|
|
108
|
+
// and the gate fakes) now live in ./acp-client.ts alongside the connectAcpClient
|
|
109
|
+
// factory that builds the real one — imported above.
|
|
110
|
+
|
|
111
|
+
/** Backend dependencies — defaulted to the real implementations, faked in gates. */
|
|
112
|
+
export interface AcpTurnDeps {
|
|
113
|
+
spawnChild(launch: { command: string; args: string[] }, cwd: string, extraEnv: Record<string, string>): AcpChildLike;
|
|
114
|
+
createConnection(child: AcpChildLike, handlers: AcpClientHandlers): AcpConnectionLike;
|
|
115
|
+
lifecyclePolicy(): LifecyclePolicy;
|
|
116
|
+
/** Resolve operator provider config (S2g). Real impl reads global+project settings.
|
|
117
|
+
* Takes the already-routed `adapter` (resolved once at turn entry) so the backend
|
|
118
|
+
* can parse its OWN settings without config.ts re-routing — the model id stays the
|
|
119
|
+
* single routing authority. */
|
|
120
|
+
loadConfig(cwd: string, modelId: string, adapter: AcpBackendAdapter): ResolvedAcpConfig;
|
|
121
|
+
now(): string;
|
|
122
|
+
/** Record dir override (tests). Defaults to the real session cache dir. */
|
|
123
|
+
sessionDir?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// In-memory session registry (process-scoped reuse) + global cleanup
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
interface AcpBridgeEvent {
|
|
131
|
+
type: "session_notification" | "permission_request";
|
|
132
|
+
update?: Record<string, unknown>;
|
|
133
|
+
sessionId?: string;
|
|
134
|
+
decision?: "approved" | "cancelled";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface BridgeSession {
|
|
138
|
+
key: string;
|
|
139
|
+
cwd: string;
|
|
140
|
+
modelId: string;
|
|
141
|
+
child: AcpChildLike;
|
|
142
|
+
connection: AcpConnectionLike;
|
|
143
|
+
acpSessionId: string;
|
|
144
|
+
bridgeConfigSignature: string;
|
|
145
|
+
contextMessageSignatures: string[];
|
|
146
|
+
alive: boolean;
|
|
147
|
+
busy: boolean;
|
|
148
|
+
/** Mutable per-turn router — see the CRITICAL note in the file header. */
|
|
149
|
+
activePromptHandler?: (event: AcpBridgeEvent) => void;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const bridgeSessions = new Map<string, BridgeSession>();
|
|
153
|
+
const retainedChildren = new Set<AcpChildLike>();
|
|
154
|
+
// sessionKeys with a prompt currently in flight. A NEW turn does not enter
|
|
155
|
+
// bridgeSessions until it succeeds, so without this a second concurrent FIRST
|
|
156
|
+
// turn for the same key would also see `existing === undefined` and spawn a
|
|
157
|
+
// second child. Claimed before spawn, released in the orchestrator finally
|
|
158
|
+
// (GPT blocker 1).
|
|
159
|
+
const inFlightKeys = new Set<string>();
|
|
160
|
+
let cleanupRegistered = false;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Register ONE global `exit` hook that SIGKILLs every retained child's group, so
|
|
164
|
+
* a resident pi never orphans the `claude` grandchildren. A per-session
|
|
165
|
+
* `process.once` would leak an EventEmitter listener per turn — GPT c617cb. The
|
|
166
|
+
* handler is sync (the `exit` event forbids async work).
|
|
167
|
+
*/
|
|
168
|
+
function registerGlobalCleanup(): void {
|
|
169
|
+
if (cleanupRegistered) return;
|
|
170
|
+
cleanupRegistered = true;
|
|
171
|
+
process.once("exit", () => {
|
|
172
|
+
for (const child of retainedChildren) killChildGroup(child, "SIGKILL");
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A retained child died between turns: mark dead + drop from map + retained set. */
|
|
177
|
+
function onChildGone(session: BridgeSession): void {
|
|
178
|
+
session.alive = false;
|
|
179
|
+
if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
|
|
180
|
+
retainedChildren.delete(session.child);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// timeout / launch / permission / stopReason / teardown helpers
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
// Race a promise against a timeout, ALWAYS clearing the timer afterwards. A
|
|
188
|
+
// naive `Promise.race([p, sleep(ms)])` leaves the timer pending when `p` wins —
|
|
189
|
+
// a dangling (here 10-minute) timer that keeps pi's event loop alive long after
|
|
190
|
+
// the turn, so pi would never exit a `-p` run. clearTimeout in finally fixes it.
|
|
191
|
+
function withTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
|
|
192
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
193
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
194
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
195
|
+
timer.unref?.();
|
|
196
|
+
});
|
|
197
|
+
return Promise.race([p, timeout]).finally(() => {
|
|
198
|
+
if (timer) clearTimeout(timer);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Approve-all permission policy (YOLO — oracle F). options empty → cancelled. */
|
|
203
|
+
function resolvePermissionResponse(params: { options?: Array<{ optionId: string; kind?: string }> }): {
|
|
204
|
+
outcome: { outcome: "selected"; optionId: string } | { outcome: "cancelled" };
|
|
205
|
+
} {
|
|
206
|
+
const options = Array.isArray(params?.options) ? params.options : [];
|
|
207
|
+
if (options.length === 0) return { outcome: { outcome: "cancelled" } };
|
|
208
|
+
const allow = options.find((o) => o.kind === "allow_once" || o.kind === "allow_always");
|
|
209
|
+
return { outcome: { outcome: "selected", optionId: (allow ?? options[0]).optionId } };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** ACP prompt stopReason → pi stopReason. */
|
|
213
|
+
function mapPromptStopReason(stopReason: string | undefined): AssistantMessage["stopReason"] {
|
|
214
|
+
switch (stopReason) {
|
|
215
|
+
case "max_tokens":
|
|
216
|
+
return "length";
|
|
217
|
+
case "cancelled":
|
|
218
|
+
return "aborted";
|
|
219
|
+
default:
|
|
220
|
+
return "stop";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Signal the child's whole PROCESS GROUP. claude-agent-acp spawns a `claude`
|
|
225
|
+
// grandchild that inherits the stdio pipe fds; killing only the direct child
|
|
226
|
+
// leaves the grandchild holding the write end of pi's stdout pipe, so pi's event
|
|
227
|
+
// loop never drains and the process hangs. The child is spawned `detached` (its
|
|
228
|
+
// own group), so a negative-pid kill reaches the grandchild too.
|
|
229
|
+
function killChildGroup(child: AcpChildLike, signal: NodeJS.Signals): void {
|
|
230
|
+
try {
|
|
231
|
+
if (child.pid != null) process.kill(-child.pid, signal);
|
|
232
|
+
else child.kill(signal);
|
|
233
|
+
} catch {
|
|
234
|
+
try {
|
|
235
|
+
child.kill(signal);
|
|
236
|
+
} catch {
|
|
237
|
+
// already gone
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Tear the child down WITHOUT blocking pi's exit. The pi process exits only when
|
|
243
|
+
// its event loop has no ref'd handles; the backend child's stdio pipes are such
|
|
244
|
+
// handles. Awaiting the child's death (it may be slow to honor SIGTERM, and its
|
|
245
|
+
// `claude` grandchild can linger) would pin pi open. Instead we (1) destroy pi's
|
|
246
|
+
// own pipe handles immediately so the loop frees, (2) unref the child so it never
|
|
247
|
+
// keeps the loop alive, (3) SIGTERM the group now and SIGKILL it after a grace on
|
|
248
|
+
// an UNREF'd timer (best-effort reaping that does not itself hold pi open).
|
|
249
|
+
//
|
|
250
|
+
// REUSE INVARIANT (GPT c617cb): teardownChild is ONLY for turn-scoped one-shots
|
|
251
|
+
// and for incompatible/error/abort closes — NEVER between turns of a retained
|
|
252
|
+
// process-scoped session (that would destroy the reusable connection's stdio).
|
|
253
|
+
function teardownChild(child: AcpChildLike, graceMs = 2_000): void {
|
|
254
|
+
const alreadyDead = child.exitCode !== null || child.signalCode !== null;
|
|
255
|
+
if (!alreadyDead) killChildGroup(child, "SIGTERM");
|
|
256
|
+
for (const s of [child.stdin, child.stdout, child.stderr]) {
|
|
257
|
+
try {
|
|
258
|
+
s?.destroy();
|
|
259
|
+
} catch {
|
|
260
|
+
// best-effort
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
child.unref();
|
|
265
|
+
} catch {
|
|
266
|
+
// best-effort
|
|
267
|
+
}
|
|
268
|
+
if (!alreadyDead) {
|
|
269
|
+
const t = setTimeout(() => killChildGroup(child, "SIGKILL"), graceMs);
|
|
270
|
+
t.unref?.();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// unref (NOT destroy) a retained process-scoped child so its live stdio handles
|
|
275
|
+
// do not pin pi's event loop. While the resident runs, the control socket (and
|
|
276
|
+
// the next turn) keeps the loop alive, so reads still flow and reuse works; on
|
|
277
|
+
// resident shutdown the loop can drain to empty so the `exit` cleanup hook fires
|
|
278
|
+
// (an un-unref'd stdio handle would re-create the S2c hang at quit). GPT amber.
|
|
279
|
+
function unrefRetainedChild(child: AcpChildLike): void {
|
|
280
|
+
try {
|
|
281
|
+
child.unref();
|
|
282
|
+
} catch {
|
|
283
|
+
// best-effort
|
|
284
|
+
}
|
|
285
|
+
for (const s of [child.stdin, child.stdout, child.stderr]) {
|
|
286
|
+
try {
|
|
287
|
+
s?.unref?.();
|
|
288
|
+
} catch {
|
|
289
|
+
// best-effort
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Default (production) dependencies — real spawn + real ACP client connection. */
|
|
295
|
+
function defaultDeps(): AcpTurnDeps {
|
|
296
|
+
return {
|
|
297
|
+
spawnChild: (launch, cwd, extraEnv) =>
|
|
298
|
+
spawn(launch.command, launch.args, {
|
|
299
|
+
cwd,
|
|
300
|
+
env: { ...process.env, ...extraEnv },
|
|
301
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
302
|
+
// Own process group so teardown can signal the claude grandchild too.
|
|
303
|
+
detached: true,
|
|
304
|
+
}) as unknown as AcpChildLike,
|
|
305
|
+
createConnection: (child, handlers) => {
|
|
306
|
+
const real = child as unknown as StdioChild;
|
|
307
|
+
const stdoutWeb = Readable.toWeb(real.stdout) as unknown as ReadableStream<Uint8Array>;
|
|
308
|
+
const stdinWeb = Writable.toWeb(real.stdin) as unknown as WritableStream<Uint8Array>;
|
|
309
|
+
const transport = ndJsonStream(stdinWeb, stdoutWeb);
|
|
310
|
+
return connectAcpClient(transport as unknown as Parameters<typeof connectAcpClient>[0], handlers);
|
|
311
|
+
},
|
|
312
|
+
lifecyclePolicy: () => resolveLifecyclePolicy(),
|
|
313
|
+
loadConfig: (cwd, modelId, adapter) => resolveProviderConfig({ cwd, modelId, adapter }),
|
|
314
|
+
now: () => new Date().toISOString(),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** sessionKey: options.sessionId, else PI_SESSION_ID, else a cwd fallback (GPT ②). */
|
|
319
|
+
function resolveSessionKey(opts: { sessionId?: string } | undefined, cwd: string): string {
|
|
320
|
+
const sid = opts?.sessionId?.trim() || process.env.PI_SESSION_ID?.trim();
|
|
321
|
+
return sid ? `pi:${sid}` : `cwd:${cwd}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Best-effort persist of the session record (1b-2c reads it; this cut only writes). */
|
|
325
|
+
function persistRecord(session: BridgeSession, deps: AcpTurnDeps): void {
|
|
326
|
+
try {
|
|
327
|
+
const record = buildSessionRecord(
|
|
328
|
+
{
|
|
329
|
+
sessionKey: session.key,
|
|
330
|
+
acpSessionId: session.acpSessionId,
|
|
331
|
+
cwd: session.cwd,
|
|
332
|
+
modelId: session.modelId,
|
|
333
|
+
bridgeConfigSignature: session.bridgeConfigSignature,
|
|
334
|
+
contextMessageSignatures: session.contextMessageSignatures,
|
|
335
|
+
},
|
|
336
|
+
deps.now(),
|
|
337
|
+
);
|
|
338
|
+
writeSessionRecord(record, deps.sessionDir);
|
|
339
|
+
} catch {
|
|
340
|
+
// record is a 1b-2c convenience — a write failure must not fail the turn.
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Detour A (A-c) — actionable rendering of a context-window overflow.
|
|
345
|
+
//
|
|
346
|
+
// An interactive / one-shot entwurf turn is `turn-scoped`, so it is ALWAYS
|
|
347
|
+
// `new`: every turn spawns a fresh ACP child and resends the FULL transcript +
|
|
348
|
+
// first-user augment as one prompt (there is no persisted resume yet — that is
|
|
349
|
+
// the deferred 1b-2c lane). A long, or resumed, conversation can therefore
|
|
350
|
+
// exceed the backend model's input window, which the backend returns as a
|
|
351
|
+
// terse 400 the operator sees only as "API Error". This pure classifier turns
|
|
352
|
+
// that into an honest, actionable hint. It does NOT change routing or suppress
|
|
353
|
+
// the error — it only makes the broken state legible (Code Principle: surface
|
|
354
|
+
// broken tool state AS broken).
|
|
355
|
+
//
|
|
356
|
+
// Resume itself is legitimate — entwurf locks the MODEL, not resume — so
|
|
357
|
+
// the hint never tells the operator to stop resuming; it names the real cause
|
|
358
|
+
// (turn-scoped full-transcript replay) and the real follow-up fix.
|
|
359
|
+
const ACP_CONTEXT_OVERFLOW_SIGNATURES: readonly RegExp[] = [
|
|
360
|
+
/prompt is too long/i,
|
|
361
|
+
/input (?:is )?too long/i,
|
|
362
|
+
/input length and `?max_tokens`? exceed/i,
|
|
363
|
+
/(?:maximum|max) context/i,
|
|
364
|
+
/context (?:window|length)/i,
|
|
365
|
+
/too many (?:input )?tokens/i,
|
|
366
|
+
/exceeds? the (?:maximum|context)/i,
|
|
367
|
+
/reduce the length of/i,
|
|
368
|
+
];
|
|
369
|
+
|
|
370
|
+
export function actionableAcpBackendHint(message: string): string | undefined {
|
|
371
|
+
if (!ACP_CONTEXT_OVERFLOW_SIGNATURES.some((re) => re.test(message))) return undefined;
|
|
372
|
+
return [
|
|
373
|
+
"[acp] likely context-window overflow — the backend model rejected the input as too long.",
|
|
374
|
+
" Why: this turn used a FRESH ACP backend session (common in a turn-scoped / no --entwurf-control",
|
|
375
|
+
" session, but also a resident's first or incompatible turn), so it resent the FULL transcript",
|
|
376
|
+
" + augment as one prompt; a long or resumed conversation can exceed the backend model's input",
|
|
377
|
+
" window. (Resume is legitimate — entwurf locks the model, not resume.)",
|
|
378
|
+
" Now: start a fresh or shorter session to get unblocked.",
|
|
379
|
+
" Root fix (follow-up): persisted resume (delta-only) or a window/summary policy.",
|
|
380
|
+
].join("\n");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* streamSimple for the entwurf provider. Returns the event stream
|
|
385
|
+
* synchronously and drives the ACP turn on a microtask.
|
|
386
|
+
*/
|
|
387
|
+
export function streamShellAcp(
|
|
388
|
+
model: Model<Api>,
|
|
389
|
+
context: Context,
|
|
390
|
+
options?: SimpleStreamOptions,
|
|
391
|
+
): ReturnType<typeof createAssistantMessageEventStream> {
|
|
392
|
+
return streamAcpTurn(model, context, options, defaultDeps());
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The seam-aware turn driver. `streamShellAcp` calls this with the real deps. */
|
|
396
|
+
export function streamAcpTurn(
|
|
397
|
+
model: Model<Api>,
|
|
398
|
+
context: Context,
|
|
399
|
+
options: SimpleStreamOptions | undefined,
|
|
400
|
+
deps: AcpTurnDeps,
|
|
401
|
+
): ReturnType<typeof createAssistantMessageEventStream> {
|
|
402
|
+
const stream = createAssistantMessageEventStream();
|
|
403
|
+
const state: AcpPiStreamState = createAcpStreamState(stream, {
|
|
404
|
+
api: "entwurf",
|
|
405
|
+
provider: "entwurf",
|
|
406
|
+
model: model.id,
|
|
407
|
+
});
|
|
408
|
+
const opts = options as
|
|
409
|
+
| ({ cwd?: string; signal?: AbortSignal; sessionId?: string } & SimpleStreamOptions)
|
|
410
|
+
| undefined;
|
|
411
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
412
|
+
const signal = opts?.signal;
|
|
413
|
+
|
|
414
|
+
stream.push({ type: "start", partial: state.output });
|
|
415
|
+
|
|
416
|
+
// Per-turn event router → the CURRENT stream state. The retained connection's
|
|
417
|
+
// callbacks delegate here; we install it per turn and clear it in finally.
|
|
418
|
+
function makePromptHandler(session: BridgeSession): (event: AcpBridgeEvent) => void {
|
|
419
|
+
return (event) => {
|
|
420
|
+
if (event.type === "session_notification") {
|
|
421
|
+
if (event.sessionId && session.acpSessionId && event.sessionId !== session.acpSessionId) return;
|
|
422
|
+
applyAcpSessionUpdate(state, event.update);
|
|
423
|
+
} else if (event.decision) {
|
|
424
|
+
pushPermissionNotice(state, "permission request", event.decision);
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function finishSuccess(promptResult: { stopReason?: string }): void {
|
|
430
|
+
finalizeAcpStreamState(state);
|
|
431
|
+
const mapped = mapPromptStopReason(promptResult?.stopReason);
|
|
432
|
+
if (signal?.aborted || mapped === "aborted") {
|
|
433
|
+
state.output.stopReason = "aborted";
|
|
434
|
+
stream.push({ type: "error", reason: "aborted", error: state.output });
|
|
435
|
+
} else {
|
|
436
|
+
state.output.stopReason = mapped;
|
|
437
|
+
stream.push({ type: "done", reason: mapped === "length" ? "length" : "stop", message: state.output });
|
|
438
|
+
}
|
|
439
|
+
stream.end();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function finishError(err: unknown, aborted: boolean, stderrTail?: string[]): void {
|
|
443
|
+
finalizeAcpStreamState(state);
|
|
444
|
+
state.output.stopReason = aborted ? "aborted" : "error";
|
|
445
|
+
const base = err instanceof Error ? err.message : String(err);
|
|
446
|
+
const tail = (stderrTail ?? []).join("").trim().slice(-1_000);
|
|
447
|
+
const full = tail ? `${base}\n--- backend stderr (tail) ---\n${tail}` : base;
|
|
448
|
+
// A-c: a real failure (not an abort) that looks like a context-window
|
|
449
|
+
// overflow gets an actionable hint appended, so "API Error" stops hiding
|
|
450
|
+
// the turn-scoped full-transcript-replay cause.
|
|
451
|
+
const hint = aborted ? undefined : actionableAcpBackendHint(full);
|
|
452
|
+
state.output.errorMessage = hint ? `${full}\n\n${hint}` : full;
|
|
453
|
+
stream.push({ type: "error", reason: aborted ? "aborted" : "error", error: state.output });
|
|
454
|
+
stream.end();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
queueMicrotask(async () => {
|
|
458
|
+
// Operator provider config (S2g) — resolve FIRST. A config the bridge
|
|
459
|
+
// cannot honor (bad mcpServers / skillPlugins / appendSystemPrompt:true /
|
|
460
|
+
// strictMcpConfig:false) fails loud into the stream before any spawn. This
|
|
461
|
+
// is the baseline fix: the operator's entwurfProvider.{mcpServers,
|
|
462
|
+
// skillPlugins,tools,…} now actually reach the session.
|
|
463
|
+
// Backend adapter — resolve ONCE at turn entry (GPT §9 / Step B). The modelId
|
|
464
|
+
// prefix routes to the owning adapter; an unknown or colliding id fails loud.
|
|
465
|
+
let adapter: AcpBackendAdapter;
|
|
466
|
+
let nativeModelId: string;
|
|
467
|
+
try {
|
|
468
|
+
({ adapter, nativeModelId } = resolveAcpBackendAdapter(model.id));
|
|
469
|
+
} catch (err) {
|
|
470
|
+
finishError(err, false);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
let config: ResolvedAcpConfig;
|
|
474
|
+
try {
|
|
475
|
+
config = deps.loadConfig(cwd, model.id, adapter);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
finishError(err, false);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
// settings.backend is a DIAGNOSTIC guard, never a router. The model id already
|
|
481
|
+
// chose the adapter (above); if the operator ALSO declared a backend it must
|
|
482
|
+
// agree, else fail loud — a mismatch means the settings and the requested model
|
|
483
|
+
// disagree about which backend runs, and silently trusting the model id would
|
|
484
|
+
// hide an operator typo. Routing authority stays single (the model id).
|
|
485
|
+
if (config.backend !== undefined && config.backend !== adapter.backend) {
|
|
486
|
+
finishError(
|
|
487
|
+
new Error(
|
|
488
|
+
`entwurf: entwurfProvider.backend "${config.backend}" does not match the backend that owns model ` +
|
|
489
|
+
`"${model.id}" (${adapter.backend}) — the model id is the routing authority; fix or remove the backend field`,
|
|
490
|
+
),
|
|
491
|
+
false,
|
|
492
|
+
);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const serverNames = mcpServerNames(config);
|
|
496
|
+
// S2g: apply the operator's tool/permission notice preference to THIS turn's
|
|
497
|
+
// stream. Display-only rendering config (not session-compat), so it is set on
|
|
498
|
+
// the stream state and deliberately kept OUT of bridgeConfigSignature. The S2f
|
|
499
|
+
// lifecycle notices ignore this flag (always visible) — only the verbose
|
|
500
|
+
// [tool:*] / [permission:*] stream is suppressed when false.
|
|
501
|
+
state.showToolNotifications = config.showToolNotifications;
|
|
502
|
+
|
|
503
|
+
// Tool-surface truthfulness preflight (S2b assertExcludeToolsHonored) —
|
|
504
|
+
// BEFORE any spawn or session lookup. If pi excluded a built-in the Claude
|
|
505
|
+
// child will still expose (declared != actual), fail fast into the stream
|
|
506
|
+
// rather than lie to the model. Uses the RESOLVED tool surface (S2g) so an
|
|
507
|
+
// operator-narrowed `tools` is what the truthfulness check honors.
|
|
508
|
+
try {
|
|
509
|
+
const activeToolNames = context.tools?.map((t) => t.name) ?? [...PI_BUILTIN_BACKED_TOOLS];
|
|
510
|
+
assertExcludeToolsHonored(activeToolNames, { backend: adapter.backend, tools: config.tools });
|
|
511
|
+
} catch (err) {
|
|
512
|
+
finishError(err, false);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const policy = deps.lifecyclePolicy();
|
|
517
|
+
const sessionKey = resolveSessionKey(opts, cwd);
|
|
518
|
+
// Billing/memory carrier (S2d-1c): SHORT operator-authored system-prompt
|
|
519
|
+
// additions. The shipped default is NON-empty → tiny string carrier →
|
|
520
|
+
// claude_code preset replacement, which strips auto-memory. The SAME rendered
|
|
521
|
+
// string feeds BOTH the config signature (appendSystemPrompt) and
|
|
522
|
+
// _meta.systemPrompt (in runNewTurn), so a carrier change invalidates reuse;
|
|
523
|
+
// loadEngraving is pure (no clock/random/env) so the signature stays a
|
|
524
|
+
// per-(model,template) constant and does NOT rebuild every turn (NEXT
|
|
525
|
+
// §S2-scout 핀1 / oracle C, GPT c32a6c8 ②). null → "" is the explicit
|
|
526
|
+
// operator opt-out branch. mcpServerNames feed the carrier so
|
|
527
|
+
// `{{mcp_servers}}` lists the real set. If the shipped default carrier is
|
|
528
|
+
// missing/empty, loadEngraving throws (trust lever off); surface that as a
|
|
529
|
+
// stream error instead of an unhandled microtask failure.
|
|
530
|
+
let engraving: string | null;
|
|
531
|
+
try {
|
|
532
|
+
engraving = adapter.loadCarrier({ mcpServerNames: serverNames, config });
|
|
533
|
+
} catch (err) {
|
|
534
|
+
finishError(err, false);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
// S2g: the signature folds the FULL resolved config (mcpServersHash + tool
|
|
538
|
+
// surface + skillPlugins + flags) so any operator config change invalidates
|
|
539
|
+
// a reused session; the per-session envelope is excluded (runtime, not config).
|
|
540
|
+
const configSig = bridgeConfigSignature({
|
|
541
|
+
backend: adapter.backend,
|
|
542
|
+
modelId: model.id,
|
|
543
|
+
nativeModelId,
|
|
544
|
+
appendSystemPrompt: engraving ?? "",
|
|
545
|
+
mcpServersHash: config.mcpServersHash,
|
|
546
|
+
settingSources: [...config.settingSources],
|
|
547
|
+
strictMcpConfig: config.strictMcpConfig,
|
|
548
|
+
tools: [...config.tools],
|
|
549
|
+
skillPlugins: [...config.skillPlugins],
|
|
550
|
+
permissionAllow: [...config.permissionAllow],
|
|
551
|
+
disallowedTools: [...config.disallowedTools],
|
|
552
|
+
extra: adapter.configSignatureFields(config.adapterSettings),
|
|
553
|
+
});
|
|
554
|
+
const ctxSigs = contextMessageSignatures(context);
|
|
555
|
+
const params: BootstrapParams = {
|
|
556
|
+
cwd,
|
|
557
|
+
modelId: model.id,
|
|
558
|
+
bridgeConfigSignature: configSig,
|
|
559
|
+
contextMessageSignatures: ctxSigs,
|
|
560
|
+
lifecyclePolicy: policy,
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const existing = bridgeSessions.get(sessionKey);
|
|
564
|
+
|
|
565
|
+
// Concurrent prompt on the same sessionKey → fail-loud (first cut: no
|
|
566
|
+
// queue). Covers BOTH a retained busy session AND an in-flight FIRST turn
|
|
567
|
+
// (a NEW turn is not in the map yet — inFlightKeys). Checked BEFORE we
|
|
568
|
+
// claim/spawn/set any handler, so nothing to unwind (GPT blocker 1).
|
|
569
|
+
if (existing?.busy || inFlightKeys.has(sessionKey)) {
|
|
570
|
+
finishError(new Error(`entwurf session ${sessionKey} is busy with another prompt`), false);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const existingFacts: ExistingSession | undefined = existing
|
|
575
|
+
? {
|
|
576
|
+
cwd: existing.cwd,
|
|
577
|
+
modelId: existing.modelId,
|
|
578
|
+
bridgeConfigSignature: existing.bridgeConfigSignature,
|
|
579
|
+
contextMessageSignatures: existing.contextMessageSignatures,
|
|
580
|
+
alive: existing.alive,
|
|
581
|
+
}
|
|
582
|
+
: undefined;
|
|
583
|
+
|
|
584
|
+
let decision: BootstrapDecision;
|
|
585
|
+
try {
|
|
586
|
+
// 1b-2b: persisted resume/load is OFF — no persisted record and no
|
|
587
|
+
// resume/load capability passed, so decideBootstrap returns only
|
|
588
|
+
// "new" or "reuse". (Persisted resume/load is the 1b-2c lane.)
|
|
589
|
+
decision = decideBootstrap(params, { existing: existingFacts });
|
|
590
|
+
} catch (err) {
|
|
591
|
+
// Model lock (live alive child, different model): surface as a stream
|
|
592
|
+
// error. Do NOT close the live child or drop it from the map — a
|
|
593
|
+
// mismatch means "not reusable for THIS turn", not "dead" (GPT ③).
|
|
594
|
+
finishError(err, false);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// A "new" decision WITH an existing session means we are ABANDONING that
|
|
599
|
+
// session (incompatible drift / stale-dead) — the model-lock throw already
|
|
600
|
+
// returned above, so this is never a "leave it alone" case. Close the old
|
|
601
|
+
// connection + child so it is not orphaned in retainedChildren (GPT blocker 2).
|
|
602
|
+
if (decision.path === "new" && existing) {
|
|
603
|
+
existing.alive = false;
|
|
604
|
+
if (bridgeSessions.get(sessionKey) === existing) bridgeSessions.delete(sessionKey);
|
|
605
|
+
retainedChildren.delete(existing.child);
|
|
606
|
+
existing.connection.close?.();
|
|
607
|
+
teardownChild(existing.child);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Claim the key for the whole turn — atomic with the checks above (no await
|
|
611
|
+
// in between), so a concurrent first turn for the same key sees it in flight
|
|
612
|
+
// and fails loud (GPT blocker 1).
|
|
613
|
+
inFlightKeys.add(sessionKey);
|
|
614
|
+
try {
|
|
615
|
+
if (decision.path === "reuse" && existing) {
|
|
616
|
+
await runReuseTurn(existing, ctxSigs);
|
|
617
|
+
} else {
|
|
618
|
+
await runNewTurn(params, ctxSigs, engraving, config, adapter, nativeModelId);
|
|
619
|
+
}
|
|
620
|
+
} finally {
|
|
621
|
+
inFlightKeys.delete(sessionKey);
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// --- new session: spawn → initialize → newSession → setSessionConfigOption(model) → full transcript
|
|
626
|
+
async function runNewTurn(
|
|
627
|
+
params: BootstrapParams,
|
|
628
|
+
ctxSigs: string[],
|
|
629
|
+
engraving: string | null,
|
|
630
|
+
config: ResolvedAcpConfig,
|
|
631
|
+
adapter: AcpBackendAdapter,
|
|
632
|
+
nativeModelId: string,
|
|
633
|
+
): Promise<void> {
|
|
634
|
+
let child: AcpChildLike | undefined;
|
|
635
|
+
let session: BridgeSession | undefined;
|
|
636
|
+
let onAbort: (() => void) | undefined;
|
|
637
|
+
const stderrTail: string[] = [];
|
|
638
|
+
const sessionKey = resolveSessionKey(opts, cwd);
|
|
639
|
+
try {
|
|
640
|
+
if (signal?.aborted) throw new Error("aborted before launch");
|
|
641
|
+
|
|
642
|
+
// S2f visibility: surface the otherwise-silent bootstrap so a slow
|
|
643
|
+
// overlay/spawn/init does not read as a hang. Display-only (marked).
|
|
644
|
+
pushAcpLifecycleNotice(state, `preparing ${adapter.backend} session`);
|
|
645
|
+
// GPT §9-5: materialize the overlay first, then spawn with launchEnvDefaults
|
|
646
|
+
// + overlay.envOverrides merged over process.env (defaultDeps spawnChild).
|
|
647
|
+
const overlay = adapter.ensureOverlay({ cwd, modelId: model.id, nativeModelId, config });
|
|
648
|
+
const launch = adapter.resolveLaunch({ cwd, modelId: model.id, nativeModelId, config });
|
|
649
|
+
child = deps.spawnChild(launch, cwd, { ...adapter.launchEnvDefaults(), ...overlay.envOverrides });
|
|
650
|
+
const spawned = child;
|
|
651
|
+
|
|
652
|
+
// Drain stderr (an unconsumed pipe can backpressure-deadlock a long turn).
|
|
653
|
+
spawned.stderr.on("data", (c: Buffer) => {
|
|
654
|
+
stderrTail.push(c.toString());
|
|
655
|
+
if (stderrTail.length > 50) stderrTail.shift();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
if (signal) {
|
|
659
|
+
onAbort = () => killChildGroup(spawned, "SIGTERM");
|
|
660
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Mutable-routing callbacks — they read `session` (assigned just below)
|
|
664
|
+
// and delegate to its per-turn activePromptHandler. NEVER close over a
|
|
665
|
+
// turn's stream state directly (CRITICAL — see file header).
|
|
666
|
+
const handlers: AcpClientHandlers = {
|
|
667
|
+
sessionUpdate: async (n) => {
|
|
668
|
+
session?.activePromptHandler?.({
|
|
669
|
+
type: "session_notification",
|
|
670
|
+
update: n?.update,
|
|
671
|
+
sessionId: n?.sessionId,
|
|
672
|
+
});
|
|
673
|
+
},
|
|
674
|
+
requestPermission: async (req) => {
|
|
675
|
+
const response = resolvePermissionResponse(req);
|
|
676
|
+
const decision = response.outcome.outcome === "selected" ? "approved" : "cancelled";
|
|
677
|
+
session?.activePromptHandler?.({ type: "permission_request", decision });
|
|
678
|
+
return response;
|
|
679
|
+
},
|
|
680
|
+
readTextFile: async (req) => ({ content: readFileSync(req.path, "utf8") }),
|
|
681
|
+
writeTextFile: async (): Promise<never> => {
|
|
682
|
+
throw new Error("Client-side writeTextFile is not supported in entwurf ACP mode.");
|
|
683
|
+
},
|
|
684
|
+
};
|
|
685
|
+
const connection = deps.createConnection(spawned, handlers);
|
|
686
|
+
|
|
687
|
+
session = {
|
|
688
|
+
key: sessionKey,
|
|
689
|
+
cwd,
|
|
690
|
+
modelId: model.id,
|
|
691
|
+
child: spawned,
|
|
692
|
+
connection,
|
|
693
|
+
acpSessionId: "",
|
|
694
|
+
bridgeConfigSignature: params.bridgeConfigSignature,
|
|
695
|
+
contextMessageSignatures: ctxSigs,
|
|
696
|
+
alive: true,
|
|
697
|
+
busy: true,
|
|
698
|
+
activePromptHandler: undefined,
|
|
699
|
+
};
|
|
700
|
+
const sess = session;
|
|
701
|
+
spawned.once("exit", () => onChildGone(sess));
|
|
702
|
+
spawned.once("error", () => onChildGone(sess));
|
|
703
|
+
|
|
704
|
+
await withTimeout(
|
|
705
|
+
"initialize",
|
|
706
|
+
connection.initialize({
|
|
707
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
708
|
+
clientCapabilities: {},
|
|
709
|
+
clientInfo: { name: "entwurf", version: "s2d" },
|
|
710
|
+
}),
|
|
711
|
+
INITIALIZE_TIMEOUT_MS,
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
// Tool-narrowed session meta (S2b) + the billing carrier (S2d-1c). The
|
|
715
|
+
// carrier is the SAME rendered engraving folded into configSig above;
|
|
716
|
+
// when null, buildClaudeSessionMeta omits the _meta.systemPrompt key
|
|
717
|
+
// entirely so a carrier-absent session is byte-identical to 1b-2b.
|
|
718
|
+
// S2g: the RESOLVED operator config drives the session meta (tools /
|
|
719
|
+
// permission / disallowed / settingSources / strictMcpConfig / skillPlugins)
|
|
720
|
+
// instead of the old hardcoded minimal surface.
|
|
721
|
+
const sessionMeta = adapter.buildSessionMeta({ modelId: model.id, nativeModelId, config }, engraving);
|
|
722
|
+
// Envelope-enrich the normalized servers at spawn time (PI_SESSION_ID/
|
|
723
|
+
// PI_AGENT_ID into entwurf-bridge) — runtime wiring, applied AFTER the
|
|
724
|
+
// config signature was taken so a new session id never forces a rebuild.
|
|
725
|
+
const wireMcpServers = enrichMcpServersWithEnvelope(config.mcpServers, {
|
|
726
|
+
modelId: model.id,
|
|
727
|
+
piSessionId: process.env.PI_SESSION_ID?.trim() || undefined,
|
|
728
|
+
});
|
|
729
|
+
// GPT §9-4: omit the `_meta` KEY entirely for a carrier-less backend
|
|
730
|
+
// (sessionMeta === undefined), not `_meta: undefined`.
|
|
731
|
+
const newSessionArgs =
|
|
732
|
+
sessionMeta === undefined
|
|
733
|
+
? { cwd, mcpServers: wireMcpServers }
|
|
734
|
+
: { cwd, mcpServers: wireMcpServers, _meta: sessionMeta };
|
|
735
|
+
const created = await withTimeout("newSession", connection.newSession(newSessionArgs), NEW_SESSION_TIMEOUT_MS);
|
|
736
|
+
const acpSessionId = created?.sessionId;
|
|
737
|
+
if (!acpSessionId) throw new Error("newSession returned no sessionId");
|
|
738
|
+
session.acpSessionId = acpSessionId;
|
|
739
|
+
|
|
740
|
+
// Enforce the requested model — a silent default would lie about which
|
|
741
|
+
// model answered. pi 0.50/sdk 0.29: model selection moved from the removed
|
|
742
|
+
// unstable_setSessionModel to setSessionConfigOption({configId:"model"}).
|
|
743
|
+
// The agent resolves the value (full id or alias) to a canonical model id
|
|
744
|
+
// and routes it through query.setModel.
|
|
745
|
+
await withTimeout(
|
|
746
|
+
"enforceModel",
|
|
747
|
+
adapter.enforceModel({ connection, acpSessionId, modelId: model.id, nativeModelId }),
|
|
748
|
+
SET_MODEL_TIMEOUT_MS,
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
// S2f visibility: the session is live and model-locked — the next gap is
|
|
752
|
+
// the prompt round-trip to the first token.
|
|
753
|
+
pushAcpLifecycleNotice(state, `session ready model=${model.id}`);
|
|
754
|
+
|
|
755
|
+
session.activePromptHandler = makePromptHandler(session);
|
|
756
|
+
// new session holds NO history → the full transcript is the only carrier.
|
|
757
|
+
const basePrompt = buildAcpPrompt(context, "new");
|
|
758
|
+
if (basePrompt.length === 0) throw new Error("empty pi context — nothing to prompt");
|
|
759
|
+
// S2d-1c: prepend the rich first-user augment (bridge identity + ~/AGENTS.md
|
|
760
|
+
// + cwd/AGENTS.md + pi base + tool surface) on the WIRE only — never into
|
|
761
|
+
// the pi Context, so it stays out of contextMessageSignatures (NEXT §S2d
|
|
762
|
+
// gate ②). `new`-only → reuse turns stay clean (once-only). Entwurf-spawned
|
|
763
|
+
// prompts that already carry cwd/AGENTS.md get that one section de-duped.
|
|
764
|
+
const prompt = prependNewPromptAugment(basePrompt, {
|
|
765
|
+
backend: adapter.backend,
|
|
766
|
+
cwd,
|
|
767
|
+
mcpServerNames: mcpServerNames(config),
|
|
768
|
+
emacsAgentSocket: process.env.PI_EMACS_AGENT_SOCKET?.trim() || undefined,
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
// S2f visibility: about to send — say "sending" (not "sent") because the
|
|
772
|
+
// prompt could still sync-reject before the wire write; the next visible
|
|
773
|
+
// event after this is the backend's own first token / tool notice.
|
|
774
|
+
pushAcpLifecycleNotice(state, "sending prompt");
|
|
775
|
+
const promptResult = await withTimeout(
|
|
776
|
+
"prompt",
|
|
777
|
+
connection.prompt({ sessionId: acpSessionId, prompt }),
|
|
778
|
+
PROMPT_TIMEOUT_MS,
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
session.activePromptHandler = undefined;
|
|
782
|
+
session.busy = false;
|
|
783
|
+
finishSuccess(promptResult);
|
|
784
|
+
|
|
785
|
+
// Retain ONLY a long-lived process-scoped session that survived the turn
|
|
786
|
+
// alive and un-aborted. A turn-scoped one-shot (and any aborted/dead
|
|
787
|
+
// turn) tears down so its stdio handle cannot pin pi's exit (S2c hang).
|
|
788
|
+
if (params.lifecyclePolicy === "process-scoped" && !signal?.aborted && session.alive) {
|
|
789
|
+
bridgeSessions.set(sessionKey, session);
|
|
790
|
+
retainedChildren.add(spawned);
|
|
791
|
+
registerGlobalCleanup();
|
|
792
|
+
// unref so the retained stdio cannot pin pi's exit at resident
|
|
793
|
+
// shutdown — reuse is unaffected (unref ≠ destroy). GPT amber.
|
|
794
|
+
unrefRetainedChild(spawned);
|
|
795
|
+
persistRecord(session, deps);
|
|
796
|
+
} else {
|
|
797
|
+
connection.close?.();
|
|
798
|
+
teardownChild(spawned);
|
|
799
|
+
}
|
|
800
|
+
} catch (err) {
|
|
801
|
+
const aborted = Boolean(signal?.aborted);
|
|
802
|
+
if (session) {
|
|
803
|
+
session.activePromptHandler = undefined;
|
|
804
|
+
session.busy = false;
|
|
805
|
+
if (bridgeSessions.get(sessionKey) === session) bridgeSessions.delete(sessionKey);
|
|
806
|
+
}
|
|
807
|
+
// error/abort → drop the (uncertain) session and close its child; an
|
|
808
|
+
// uncertain connection must never be reused (GPT ④).
|
|
809
|
+
if (child) {
|
|
810
|
+
retainedChildren.delete(child);
|
|
811
|
+
session?.connection.close?.(err);
|
|
812
|
+
teardownChild(child);
|
|
813
|
+
}
|
|
814
|
+
finishError(err, aborted, stderrTail);
|
|
815
|
+
} finally {
|
|
816
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// --- reuse: send only the latest user delta to the live ACP session
|
|
821
|
+
async function runReuseTurn(session: BridgeSession, ctxSigs: string[]): Promise<void> {
|
|
822
|
+
let onAbort: (() => void) | undefined;
|
|
823
|
+
try {
|
|
824
|
+
if (signal?.aborted) throw new Error("aborted before prompt");
|
|
825
|
+
|
|
826
|
+
// S2f visibility: reuse skips spawn/init entirely — say so, otherwise a
|
|
827
|
+
// resident turn looks identical to a cold start that stalled.
|
|
828
|
+
pushAcpLifecycleNotice(state, "reusing live session");
|
|
829
|
+
session.busy = true;
|
|
830
|
+
session.activePromptHandler = makePromptHandler(session);
|
|
831
|
+
if (signal) {
|
|
832
|
+
onAbort = () => killChildGroup(session.child, "SIGTERM");
|
|
833
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// The live ACP session already remembers the prior turns → send only the
|
|
837
|
+
// latest user delta (re-sending the transcript would duplicate history).
|
|
838
|
+
const prompt = buildAcpPrompt(context, "reuse");
|
|
839
|
+
if (prompt.length === 0) throw new Error("empty delta — a reuse turn has no new user message");
|
|
840
|
+
|
|
841
|
+
// S2f visibility: about to send the delta to the resident child.
|
|
842
|
+
pushAcpLifecycleNotice(state, "sending prompt");
|
|
843
|
+
const promptResult = await withTimeout(
|
|
844
|
+
"prompt",
|
|
845
|
+
session.connection.prompt({ sessionId: session.acpSessionId, prompt }),
|
|
846
|
+
PROMPT_TIMEOUT_MS,
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
session.activePromptHandler = undefined;
|
|
850
|
+
session.busy = false;
|
|
851
|
+
// Advance the stored history to THIS call's context so the NEXT turn's
|
|
852
|
+
// prefix-compat check sees the full prior history (GPT ④: store the
|
|
853
|
+
// ctxSigs from the START of this call, only after the turn succeeds).
|
|
854
|
+
session.contextMessageSignatures = ctxSigs;
|
|
855
|
+
finishSuccess(promptResult);
|
|
856
|
+
persistRecord(session, deps);
|
|
857
|
+
} catch (err) {
|
|
858
|
+
const aborted = Boolean(signal?.aborted);
|
|
859
|
+
session.activePromptHandler = undefined;
|
|
860
|
+
session.busy = false;
|
|
861
|
+
// error/abort on a reused session → drop it and close the child (GPT ④).
|
|
862
|
+
if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
|
|
863
|
+
retainedChildren.delete(session.child);
|
|
864
|
+
session.connection.close?.(err);
|
|
865
|
+
teardownChild(session.child);
|
|
866
|
+
finishError(err, aborted);
|
|
867
|
+
} finally {
|
|
868
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return stream;
|
|
873
|
+
}
|