@junghanacs/entwurf 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +88 -28
  3. package/docs/setup-clean-host.md +117 -219
  4. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +454 -0
  5. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +111 -0
  6. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +1683 -0
  7. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-deliverability.js +76 -0
  8. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +121 -0
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +155 -0
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +119 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +160 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +63 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +81 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +290 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +254 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +365 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +64 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +218 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +108 -0
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-resume-marker.js +33 -0
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +116 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +125 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +184 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +237 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +216 -0
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +164 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +66 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +1502 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +50 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +259 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +81 -0
  32. package/mcp/entwurf-bridge/dist/protocol.js +29 -0
  33. package/mcp/entwurf-bridge/start.sh +49 -7
  34. package/mcp/entwurf-bridge/test.sh +12 -3
  35. package/mcp/entwurf-bridge/tsconfig.build.json +42 -0
  36. package/package.json +30 -9
  37. package/pi/meta-bridge/.claude-plugin/marketplace.json +0 -1
  38. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +101 -0
  39. package/pi-extensions/lib/entwurf-v2-contract.ts +10 -78
  40. package/pi-extensions/lib/entwurf-v2-decider.ts +6 -2
  41. package/pi-extensions/lib/entwurf-v2-production.ts +26 -4
  42. package/run.sh +150 -15
  43. package/scripts/check-entwurf-bridge-pi-free.ts +146 -0
  44. package/scripts/check-entwurf-v2-contract.ts +6 -4
  45. package/scripts/check-meta-manifest-schema.py +145 -0
  46. package/scripts/meta-bridge-install.sh +17 -3
  47. package/scripts/meta-bridge-state.py +37 -10
  48. package/scripts/smoke-acp-bundled-mcp-live.ts +13 -2
  49. package/scripts/smoke-acp-carrier-augment-live.ts +35 -19
@@ -0,0 +1,1683 @@
1
+ /**
2
+ * entwurf-core — sync entwurf execution, host-agnostic.
3
+ *
4
+ * Single implementation shared by:
5
+ * - pi-extensions/entwurf.ts (pi native tool surface)
6
+ * - mcp/entwurf-bridge/src/index.ts (MCP tool surface for ACP hosts)
7
+ *
8
+ * This module MUST NOT import anything from @earendil-works/pi-coding-agent or any
9
+ * other pi runtime API. It is pure Node + @sinclair/typebox-free. Anything that
10
+ * requires pi's ExtensionAPI (sendMessage, appendEntry, sessionManager) belongs
11
+ * in the async entwurf path, which stays in pi-extensions/entwurf.ts for now.
12
+ *
13
+ * Scope:
14
+ * - sync execution (spawn pi, collect message_end events, return summary)
15
+ * - local hosts only in 0.9.0. SSH-remote spawn/resume is fail-fast
16
+ * (garden-native session identity is local-FS — header scan / collision
17
+ * precheck cannot see a remote filesystem). The remote roots/isRemote
18
+ * plumbing is retained, parity-gated, for #11 revival (see RemoteSpec note
19
+ * below) — it is NOT a live path in this release.
20
+ * - project-context injection (cwd/AGENTS.md)
21
+ * - explicit compat extension resolution for Claude models + opt-in Codex ACP routing
22
+ *
23
+ * Provider bridge routing contract:
24
+ * - Claude models (claude-*) — always routed through entwurf.
25
+ * If entwurf can't be resolved, falls back to pi-claude-code-use, then warns.
26
+ * - Codex models (openai-codex/*, gpt-5*) — default is the direct openai-codex provider.
27
+ * Opt-in via env var `ENTWURF_ACP_FOR_CODEX=1` routes Codex through entwurf,
28
+ * in which case `normalizeCodexEntwurfModelForAcp()` strips the `openai-codex/`
29
+ * prefix because the bridge forwards the model id verbatim to codex-acp, which
30
+ * only accepts the bare backend id (e.g. `gpt-5.4`) on ChatGPT accounts.
31
+ *
32
+ * The `modelOverride` return field communicates this normalization to the caller so
33
+ * the spawned pi --model matches what the downstream ACP backend expects.
34
+ */
35
+ import { spawn } from "node:child_process";
36
+ import * as fs from "node:fs";
37
+ import * as os from "node:os";
38
+ import * as path from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+ import { ENTWURF_PROJECT_CONTEXT_OPEN_TAG } from "../../protocol.js";
41
+ import { formatSessionTimestamp, generateSessionId, isValidSessionId, SESSION_ID_RE } from "./session-id.js";
42
+ // ============================================================================
43
+ // Constants
44
+ // ============================================================================
45
+ // Expand a leading ~ like pi's expandTildePath, so PI_CODING_AGENT_DIR=~/foo
46
+ // resolves the same way pi's getAgentDir() would.
47
+ function expandTilde(p) {
48
+ if (p === "~")
49
+ return os.homedir();
50
+ if (p.startsWith("~/"))
51
+ return path.join(os.homedir(), p.slice(2));
52
+ return p;
53
+ }
54
+ // Local agent dir honors PI_CODING_AGENT_DIR — the same env pi's getAgentDir()
55
+ // reads (config.ts: ENV_AGENT_DIR). Without this, an isolated install-topology
56
+ // smoke that points pi at a temp agent dir could not steer the entwurf resolver
57
+ // at the same synthetic install tree (#29 correction). Remote (SSH) roots are
58
+ // deliberately NOT env-derived — see packageSourceToRoots: a local override must
59
+ // not leak into the remote host's path.
60
+ const AGENT_DIR = process.env.PI_CODING_AGENT_DIR
61
+ ? expandTilde(process.env.PI_CODING_AGENT_DIR)
62
+ : path.join(os.homedir(), ".pi", "agent");
63
+ const PI_SETTINGS_PATH = process.env.PI_SETTINGS_PATH
64
+ ? expandTilde(process.env.PI_SETTINGS_PATH)
65
+ : path.join(AGENT_DIR, "settings.json");
66
+ export const SESSIONS_BASE = path.join(AGENT_DIR, "sessions");
67
+ const ENTWURF_TARGETS_PATH = process.env.ENTWURF_TARGETS_PATH ?? path.join(AGENT_DIR, "entwurf-targets.json");
68
+ export const DEFAULT_ENTWURF_MODEL = "openai-codex/gpt-5.4";
69
+ export const ENTWURF_CODEX_ACP_ENV = "ENTWURF_ACP_FOR_CODEX";
70
+ // Currently unused: remote/SSH entwurf is fail-fast in 0.9.0 (garden-native
71
+ // identity is local-FS only). Retained for #11 remote revival; parity-gated by
72
+ // scripts/check-shell-quote.ts across entwurf.ts / entwurf-core.ts / entwurf-async.ts.
73
+ // biome-ignore lint/correctness/noUnusedVariables: retained for #11 remote revival; parity-gated.
74
+ function shellQuote(value) {
75
+ return `'${value.replace(/'/g, `'\\''`)}'`;
76
+ }
77
+ // ============================================================================
78
+ // Path / model helpers
79
+ // ============================================================================
80
+ export function cwdToSessionDir(cwd) {
81
+ const normalized = cwd.replace(/\/$/, "");
82
+ const dirName = "--" + normalized.replace(/^\//, "").replace(/\//g, "-") + "--";
83
+ return path.join(SESSIONS_BASE, dirName);
84
+ }
85
+ export function resolveEntwurfModel(model) {
86
+ const trimmed = model?.trim();
87
+ return trimmed ? trimmed : DEFAULT_ENTWURF_MODEL;
88
+ }
89
+ export function isClaudeModel(model) {
90
+ return typeof model === "string" && /(^|\/)claude-/.test(model);
91
+ }
92
+ export function isCodexModel(model) {
93
+ if (typeof model !== "string")
94
+ return false;
95
+ const trimmed = model.trim();
96
+ if (!trimmed)
97
+ return false;
98
+ const [provider, basename = trimmed] = trimmed.includes("/") ? trimmed.split("/", 2) : ["", trimmed];
99
+ return provider === "openai-codex" || /^gpt-5([.-]|$)/.test(basename) || basename.includes("codex");
100
+ }
101
+ export function shouldRouteCodexViaAcp(model) {
102
+ return isCodexModel(model) && process.env[ENTWURF_CODEX_ACP_ENV] === "1";
103
+ }
104
+ export function normalizeCodexEntwurfModelForAcp(model) {
105
+ if (!isCodexModel(model) || typeof model !== "string")
106
+ return model;
107
+ return model.startsWith("openai-codex/") ? model.slice("openai-codex/".length) : model;
108
+ }
109
+ export class EntwurfRegistryError extends Error {
110
+ constructor(message) {
111
+ super(message);
112
+ this.name = "EntwurfRegistryError";
113
+ }
114
+ }
115
+ // Raised when a spawn is routed to provider=entwurf but the bridge extension
116
+ // cannot be resolved from settings package sources or the loaded module self-root.
117
+ // Fail-fast before spawning a child with `--no-extensions --provider entwurf`,
118
+ // which would otherwise die with `Unknown provider "entwurf"` (#29).
119
+ export class EntwurfRoutingError extends Error {
120
+ constructor(message) {
121
+ super(message);
122
+ this.name = "EntwurfRoutingError";
123
+ }
124
+ }
125
+ let cachedRegistry = null;
126
+ export function loadEntwurfTargets() {
127
+ let stat;
128
+ try {
129
+ stat = fs.statSync(ENTWURF_TARGETS_PATH);
130
+ }
131
+ catch {
132
+ // Missing — never cache. Operator may relink at any time and the next
133
+ // call must see the new file.
134
+ throw new EntwurfRegistryError(`Entwurf target registry not found at ${ENTWURF_TARGETS_PATH}. ` +
135
+ `Without it, every entwurf spawn is refused. Run \`./run.sh setup:links\` ` +
136
+ `or create the file manually (see entwurf/pi/entwurf-targets.json for the canonical shape).`);
137
+ }
138
+ if (cachedRegistry && cachedRegistry.mtimeMs === stat.mtimeMs) {
139
+ return cachedRegistry.registry;
140
+ }
141
+ let raw;
142
+ try {
143
+ raw = JSON.parse(fs.readFileSync(ENTWURF_TARGETS_PATH, "utf-8"));
144
+ }
145
+ catch (e) {
146
+ throw new EntwurfRegistryError(`Failed to parse ${ENTWURF_TARGETS_PATH}: ${e instanceof Error ? e.message : String(e)}`);
147
+ }
148
+ if (typeof raw !== "object" || raw === null || !("entwurfTargets" in raw)) {
149
+ throw new EntwurfRegistryError(`Invalid registry shape in ${ENTWURF_TARGETS_PATH}: expected { entwurfTargets: [...] }`);
150
+ }
151
+ const targetsRaw = raw.entwurfTargets;
152
+ if (!Array.isArray(targetsRaw)) {
153
+ throw new EntwurfRegistryError(`Invalid entwurfTargets in ${ENTWURF_TARGETS_PATH}: must be an array`);
154
+ }
155
+ const targets = [];
156
+ for (let i = 0; i < targetsRaw.length; i++) {
157
+ const t = targetsRaw[i];
158
+ if (typeof t !== "object" || t === null) {
159
+ throw new EntwurfRegistryError(`Entry #${i} is not an object`);
160
+ }
161
+ const obj = t;
162
+ if (typeof obj.provider !== "string" || !obj.provider.trim()) {
163
+ throw new EntwurfRegistryError(`Entry #${i}: provider must be a non-empty string`);
164
+ }
165
+ if (typeof obj.model !== "string" || !obj.model.trim()) {
166
+ throw new EntwurfRegistryError(`Entry #${i}: model must be a non-empty string`);
167
+ }
168
+ if (typeof obj.enabled !== "boolean") {
169
+ throw new EntwurfRegistryError(`Entry #${i}: enabled must be a boolean`);
170
+ }
171
+ if (obj.explicitOnly !== undefined && typeof obj.explicitOnly !== "boolean") {
172
+ throw new EntwurfRegistryError(`Entry #${i}: explicitOnly must be boolean if present`);
173
+ }
174
+ targets.push({
175
+ provider: obj.provider.trim(),
176
+ model: obj.model.trim(),
177
+ enabled: obj.enabled,
178
+ explicitOnly: obj.explicitOnly === true ? true : undefined,
179
+ });
180
+ }
181
+ const registry = { entwurfTargets: targets };
182
+ cachedRegistry = { registry, mtimeMs: stat.mtimeMs };
183
+ return registry;
184
+ }
185
+ /** Test-only hook to reset the in-memory cache (e.g. between test runs). */
186
+ export function _resetEntwurfRegistryCache() {
187
+ cachedRegistry = null;
188
+ }
189
+ // ============================================================================
190
+ // Child stderr mirror (opt-in, sentinel observability)
191
+ //
192
+ // Gated by env ENTWURF_CHILD_STDERR_LOG. When set, any entwurf child pi
193
+ // process spawned here also has its stderr appended to the given path. The
194
+ // sentinel uses this to grep for child-side `[entwurf:bootstrap]` bridge
195
+ // markers when asserting continuity — parent stderr can't see that signal
196
+ // because the bridge lives in the child when target provider is entwurf.
197
+ //
198
+ // Opt-in (env unset → no-op) so production runs pay nothing. A write failure
199
+ // surfaces on console.error instead of being silently swallowed (see the "No
200
+ // 면피" invariant in AGENTS.md): a misconfigured diagnostic should be visible.
201
+ // ============================================================================
202
+ export function mirrorChildStderr(proc) {
203
+ const logPath = process.env.ENTWURF_CHILD_STDERR_LOG;
204
+ if (!logPath || !proc.stderr)
205
+ return;
206
+ const writer = fs.createWriteStream(logPath, { flags: "a" });
207
+ writer.on("error", (err) => {
208
+ console.error(`[entwurf] child stderr mirror failed (${logPath}): ${err.message}`);
209
+ });
210
+ proc.stderr.on("data", (data) => writer.write(data));
211
+ proc.on("close", () => writer.end());
212
+ }
213
+ // ============================================================================
214
+ // Spawn guard — one entwurf spawn per (session, target) per process.
215
+ //
216
+ // Shared by pi native tool (pi-extensions/entwurf.ts) and the MCP bridge
217
+ // (mcp/entwurf-bridge). Both paths must go through this gate before calling
218
+ // runEntwurfSync / runEntwurfAsync. entwurf_v2 resume deliberately bypasses it.
219
+ //
220
+ // Map key is the caller-provided sessionId:
221
+ // - pi native: pi.sessionManager.getSessionId()
222
+ // - MCP bridge: process.pid (the MCP subprocess is one Claude session)
223
+ // Resets on process restart, which is the intended lifetime.
224
+ // ============================================================================
225
+ const usedEntwurfTargets = new Map();
226
+ export function ensureEntwurfOncePerTarget(sessionId, targetKey) {
227
+ const seen = usedEntwurfTargets.get(sessionId);
228
+ if (seen && seen.has(targetKey)) {
229
+ throw new Error(`entwurf to ${targetKey} already exists in this session. Use entwurf_v2 to continue.`);
230
+ }
231
+ }
232
+ export function markEntwurfTargetUsed(sessionId, targetKey) {
233
+ let seen = usedEntwurfTargets.get(sessionId);
234
+ if (!seen) {
235
+ seen = new Set();
236
+ usedEntwurfTargets.set(sessionId, seen);
237
+ }
238
+ seen.add(targetKey);
239
+ }
240
+ export function resolveGuardTargetKey(provider, model) {
241
+ const fallbackModel = model && model.trim() ? model : DEFAULT_ENTWURF_MODEL;
242
+ const target = resolveEntwurfTarget({ provider, model: fallbackModel });
243
+ return `${target.provider}/${target.model}`;
244
+ }
245
+ /** Test-only: reset the guard state so unit tests can reuse a single process. */
246
+ export function _resetUsedEntwurfTargets() {
247
+ usedEntwurfTargets.clear();
248
+ }
249
+ /**
250
+ * Resolve caller input to an exact (provider, model) tuple from the registry.
251
+ *
252
+ * Resolution rules (narrow door):
253
+ * 1. Qualified `provider/model` in `model` → split, exact lookup.
254
+ * 2. `provider` + `model` both given → exact lookup.
255
+ * 3. Bare `model` only → registry entries matching that model name where
256
+ * `explicitOnly !== true`:
257
+ * - 0 candidates → reject.
258
+ * - 1 candidate → use it.
259
+ * - 2+ candidates → reject as ambiguous.
260
+ *
261
+ * In all paths the resolved tuple must be present in the registry with
262
+ * `enabled: true`. Otherwise `EntwurfRegistryError` is thrown.
263
+ */
264
+ export function resolveEntwurfTarget(input) {
265
+ const registry = loadEntwurfTargets();
266
+ const enabled = registry.entwurfTargets.filter((t) => t.enabled);
267
+ let provider = input.provider?.trim() || undefined;
268
+ let model = input.model?.trim() || undefined;
269
+ if (!model) {
270
+ throw new EntwurfRegistryError("entwurf: model is required");
271
+ }
272
+ // Path 1: qualified `provider/model` in model field
273
+ if (!provider && model.includes("/")) {
274
+ const slash = model.indexOf("/");
275
+ provider = model.slice(0, slash).trim();
276
+ model = model.slice(slash + 1).trim();
277
+ if (!provider || !model) {
278
+ throw new EntwurfRegistryError(`entwurf: malformed qualified model id "${input.model}"`);
279
+ }
280
+ }
281
+ // Paths 1 & 2: exact tuple lookup
282
+ if (provider) {
283
+ const found = enabled.find((t) => t.provider === provider && t.model === model);
284
+ if (!found) {
285
+ throw new EntwurfRegistryError(`entwurf: (provider="${provider}", model="${model}") is not in the entwurf target ` +
286
+ `registry, or is disabled. Allowed: ${describeRegistryEntries(enabled)}`);
287
+ }
288
+ return { provider: found.provider, model: found.model, explicitOnly: found.explicitOnly === true };
289
+ }
290
+ // Path 3: bare model — auto-resolve excluding explicitOnly
291
+ const candidates = enabled.filter((t) => t.model === model && t.explicitOnly !== true);
292
+ if (candidates.length === 0) {
293
+ const sameModel = enabled.filter((t) => t.model === model);
294
+ if (sameModel.length > 0) {
295
+ throw new EntwurfRegistryError(`entwurf: model "${model}" exists in registry only as explicitOnly target(s). ` +
296
+ `Specify provider explicitly. Available: ${describeRegistryEntries(sameModel)}`);
297
+ }
298
+ throw new EntwurfRegistryError(`entwurf: model "${model}" is not in the entwurf target registry. ` +
299
+ `Allowed: ${describeRegistryEntries(enabled)}`);
300
+ }
301
+ if (candidates.length > 1) {
302
+ throw new EntwurfRegistryError(`entwurf: bare model "${model}" is ambiguous (${candidates.length} candidates). ` +
303
+ `Specify provider explicitly. Candidates: ${describeRegistryEntries(candidates)}`);
304
+ }
305
+ const only = candidates[0];
306
+ return { provider: only.provider, model: only.model, explicitOnly: false };
307
+ }
308
+ function describeRegistryEntries(entries) {
309
+ if (entries.length === 0)
310
+ return "(none)";
311
+ return entries.map((t) => `${t.provider}/${t.model}${t.explicitOnly ? " [explicitOnly]" : ""}`).join(", ");
312
+ }
313
+ // ============================================================================
314
+ // Content extraction
315
+ // ============================================================================
316
+ export function extractTextContent(content) {
317
+ if (typeof content === "string")
318
+ return content;
319
+ if (!Array.isArray(content))
320
+ return "";
321
+ const texts = [];
322
+ for (const block of content) {
323
+ if (typeof block === "object" &&
324
+ block !== null &&
325
+ "type" in block &&
326
+ block.type === "text" &&
327
+ "text" in block &&
328
+ typeof block.text === "string") {
329
+ texts.push(block.text);
330
+ }
331
+ }
332
+ return texts.join("\n\n");
333
+ }
334
+ export function parseMessages(messages) {
335
+ return messages
336
+ .filter((msg) => msg.role === "assistant")
337
+ .map((msg) => extractTextContent(msg.content).trim())
338
+ .filter(Boolean)
339
+ .join("\n\n");
340
+ }
341
+ /**
342
+ * Read the pi session JSONL header (first non-empty line, `type:"session"`).
343
+ *
344
+ * Returns the structural identity carried in the header: pi `id` (= the durable
345
+ * sessionId) and original `cwd`. The header is the sole resume-time authority;
346
+ * the Pi filename `<created-at>_<sessionId>.jsonl` is only a discovery aid.
347
+ *
348
+ * Why this exists (issue #9):
349
+ * `runEntwurfResumeSync` originally fell back to `process.cwd()` when no
350
+ * explicit `cwd` was passed. Through the MCP `entwurf_v2` resume surface, the
351
+ * resumer is a different process from the original spawner, so its cwd is
352
+ * unrelated to the saved session's cwd. The child pi then started in the
353
+ * resumer's cwd, the entwurf bridge persisted that cwd in its session
354
+ * cache, and on lookup `isPersistedSessionCompatible` saw a cwd mismatch
355
+ * against the Scene 1 record. The bridge discarded the record, started a
356
+ * `newSession`, and the backend lost all prior-turn memory — even though the
357
+ * pi JSONL itself was hydrated correctly.
358
+ *
359
+ * Reading the header cwd here lets `runEntwurfResumeSync` align the child's
360
+ * spawn cwd with the original spawn, which keeps the bridge's
361
+ * `pi:<sessionId>` -> `acpSessionId` continuity intact.
362
+ *
363
+ * Invariant (see issue #10): the single identity carrier is `sessionId`. This
364
+ * helper returns `id` alongside `cwd` so future peer-handle work can reuse it
365
+ * without re-reading the file.
366
+ */
367
+ const SESSION_HEADER_READ_BYTES = 8192;
368
+ const SESSION_ANALYSIS_CHUNK_BYTES = 64 * 1024;
369
+ export function readSessionHeader(sessionFile) {
370
+ let fd;
371
+ try {
372
+ fd = fs.openSync(sessionFile, "r");
373
+ const buffer = Buffer.alloc(SESSION_HEADER_READ_BYTES);
374
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
375
+ if (bytesRead <= 0)
376
+ return null;
377
+ // Session header is the first JSONL line. Read only a bounded prefix so
378
+ // header scans over many large transcripts cannot load/split whole files.
379
+ const prefix = buffer.subarray(0, bytesRead).toString("utf8");
380
+ const newlineIdx = prefix.indexOf("\n");
381
+ const trimmed = (newlineIdx >= 0 ? prefix.slice(0, newlineIdx) : prefix).trim();
382
+ if (!trimmed)
383
+ return null;
384
+ const entry = JSON.parse(trimmed);
385
+ if (entry.type !== "session")
386
+ return null;
387
+ const id = typeof entry.id === "string" && entry.id.length > 0 ? entry.id : undefined;
388
+ const cwd = typeof entry.cwd === "string" && entry.cwd.length > 0 ? entry.cwd : undefined;
389
+ return { id, cwd };
390
+ }
391
+ catch {
392
+ return null;
393
+ }
394
+ finally {
395
+ if (fd !== undefined) {
396
+ try {
397
+ fs.closeSync(fd);
398
+ }
399
+ catch {
400
+ /* best-effort close */
401
+ }
402
+ }
403
+ }
404
+ }
405
+ /**
406
+ * Parse a pi session JSONL file and extract the latest assistant state.
407
+ * Pure file I/O — safe to use from MCP bridge or pi runtime.
408
+ */
409
+ export function analyzeSessionFileLike(sessionFile) {
410
+ const analysis = {
411
+ lastAssistantText: null,
412
+ lastError: null,
413
+ lastStopReason: null,
414
+ lastModel: null,
415
+ lastProvider: null,
416
+ turns: 0,
417
+ cost: 0,
418
+ };
419
+ // Per-line accumulation. Identical semantics to the old
420
+ // `readFileSync().trim().split("\n")` pass (last-wins fields, turn/cost
421
+ // accumulation, malformed lines skipped) but streamed so a multi-MB
422
+ // transcript is never held whole in memory at once.
423
+ const processLine = (line) => {
424
+ const trimmed = line.trim();
425
+ if (!trimmed)
426
+ return;
427
+ try {
428
+ const entry = JSON.parse(trimmed);
429
+ if (entry.type !== "message" || entry.message?.role !== "assistant")
430
+ return;
431
+ const msg = entry.message;
432
+ analysis.turns++;
433
+ const text = extractTextContent(msg.content).trim();
434
+ if (text)
435
+ analysis.lastAssistantText = text;
436
+ if (typeof msg.errorMessage === "string" && msg.errorMessage.trim()) {
437
+ analysis.lastError = msg.errorMessage.trim();
438
+ }
439
+ if (typeof msg.stopReason === "string")
440
+ analysis.lastStopReason = msg.stopReason;
441
+ if (typeof msg.model === "string")
442
+ analysis.lastModel = msg.model;
443
+ if (typeof msg.provider === "string")
444
+ analysis.lastProvider = msg.provider;
445
+ const c = msg.usage?.cost?.total;
446
+ if (typeof c === "number")
447
+ analysis.cost += c;
448
+ }
449
+ catch {
450
+ /* skip malformed lines */
451
+ }
452
+ };
453
+ let fd;
454
+ try {
455
+ fd = fs.openSync(sessionFile, "r");
456
+ const chunk = Buffer.alloc(SESSION_ANALYSIS_CHUNK_BYTES);
457
+ // `leftover` holds a partial trailing line carried across chunk reads.
458
+ // Splitting on the newline BYTE (0x0a) and decoding each complete line
459
+ // independently keeps multibyte UTF-8 from being corrupted at a chunk
460
+ // boundary (a newline never falls inside a multibyte sequence).
461
+ let leftover = Buffer.alloc(0);
462
+ let bytesRead = 0;
463
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard read loop
464
+ while ((bytesRead = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0) {
465
+ const buf = leftover.length > 0 ? Buffer.concat([leftover, chunk.subarray(0, bytesRead)]) : chunk.subarray(0, bytesRead);
466
+ let start = 0;
467
+ let nl = buf.indexOf(0x0a, start);
468
+ while (nl !== -1) {
469
+ processLine(buf.toString("utf8", start, nl));
470
+ start = nl + 1;
471
+ nl = buf.indexOf(0x0a, start);
472
+ }
473
+ // Copy the remainder before the next read overwrites `chunk`.
474
+ leftover = Buffer.from(buf.subarray(start));
475
+ }
476
+ if (leftover.length > 0)
477
+ processLine(leftover.toString("utf8"));
478
+ }
479
+ catch {
480
+ /* file not readable */
481
+ }
482
+ finally {
483
+ if (fd !== undefined) {
484
+ try {
485
+ fs.closeSync(fd);
486
+ }
487
+ catch {
488
+ /* best-effort close */
489
+ }
490
+ }
491
+ }
492
+ return analysis;
493
+ }
494
+ /**
495
+ * Single streamed pass over a session JSONL extracting the resume identity.
496
+ * Returns `null` when the session has no `model_change` (never reached an
497
+ * identity) so callers can refuse with their own "no recorded model" result.
498
+ * **Throws** `SessionIdentityError` on model-identity drift (a later
499
+ * `model_change` differs from the first) or on a corrupt session-name mirror
500
+ * (the name's sessionId / provider / model disagree with the header / first
501
+ * model_change). This is the fail-fast that replaces the old "follow the last
502
+ * assistant message's model" behavior.
503
+ *
504
+ * `requireEntwurf` (resume paths): tightens the contract to the locked 0.9.0
505
+ * rule "entwurf 여부 = session name tag 중 'entwurf' 존재; 없으면 Entwurf 세션
506
+ * 아님; compatibility 없음". A general pi session (no name, non-canonical name,
507
+ * or canonical name without the `entwurf` tag) must NOT be resumable as an
508
+ * Entwurf session — it throws instead. lookup/resume authority is still the
509
+ * header id/cwd; the name is only the integrity/discovery mirror being asserted.
510
+ */
511
+ export function readSessionIdentity(sessionFile, opts) {
512
+ const requireEntwurf = opts?.requireEntwurf === true;
513
+ let headerId;
514
+ let headerCwd;
515
+ let first;
516
+ let drift;
517
+ let latestName;
518
+ const onLine = (line) => {
519
+ const t = line.trim();
520
+ if (!t)
521
+ return;
522
+ let e;
523
+ try {
524
+ e = JSON.parse(t);
525
+ }
526
+ catch {
527
+ return;
528
+ }
529
+ if (e.type === "session") {
530
+ if (typeof e.id === "string" && e.id)
531
+ headerId = e.id;
532
+ if (typeof e.cwd === "string" && e.cwd)
533
+ headerCwd = e.cwd;
534
+ }
535
+ else if (e.type === "model_change") {
536
+ const provider = typeof e.provider === "string" ? e.provider : "";
537
+ const modelId = typeof e.modelId === "string" ? e.modelId : "";
538
+ if (!provider || !modelId)
539
+ return;
540
+ if (!first)
541
+ first = { provider, modelId };
542
+ else if ((provider !== first.provider || modelId !== first.modelId) && !drift)
543
+ drift = { provider, modelId };
544
+ }
545
+ else if (e.type === "session_info") {
546
+ if (typeof e.name === "string" && e.name)
547
+ latestName = e.name;
548
+ }
549
+ };
550
+ let fd;
551
+ try {
552
+ fd = fs.openSync(sessionFile, "r");
553
+ const chunk = Buffer.alloc(SESSION_ANALYSIS_CHUNK_BYTES);
554
+ let leftover = Buffer.alloc(0);
555
+ let bytesRead = 0;
556
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard read loop
557
+ while ((bytesRead = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0) {
558
+ const buf = leftover.length > 0 ? Buffer.concat([leftover, chunk.subarray(0, bytesRead)]) : chunk.subarray(0, bytesRead);
559
+ let start = 0;
560
+ let nl = buf.indexOf(0x0a, start);
561
+ while (nl !== -1) {
562
+ onLine(buf.toString("utf8", start, nl));
563
+ start = nl + 1;
564
+ nl = buf.indexOf(0x0a, start);
565
+ }
566
+ leftover = Buffer.from(buf.subarray(start));
567
+ }
568
+ if (leftover.length > 0)
569
+ onLine(leftover.toString("utf8"));
570
+ }
571
+ catch {
572
+ /* file not readable */
573
+ }
574
+ finally {
575
+ if (fd !== undefined) {
576
+ try {
577
+ fs.closeSync(fd);
578
+ }
579
+ catch {
580
+ /* best-effort close */
581
+ }
582
+ }
583
+ }
584
+ if (!first)
585
+ return null;
586
+ if (drift) {
587
+ throw new SessionIdentityError(`Session "${sessionFile}" model-identity drift: first model_change=${first.provider}/${first.modelId} ` +
588
+ `but a later model_change=${drift.provider}/${drift.modelId}. Resume identity is locked to the first ` +
589
+ `model_change; a differing later change is treated as corrupt/drift — refusing to resume.`);
590
+ }
591
+ // Name integrity mirror. In the general path a missing/non-canonical name is
592
+ // not itself a failure; only a canonical name that disagrees is corrupt.
593
+ const parsed = latestName ? parseSessionName(latestName) : null;
594
+ if (parsed) {
595
+ if (headerId && parsed.sessionId !== headerId) {
596
+ throw new SessionIdentityError(`Session name sessionId mirror mismatch: name carries "${parsed.sessionId}" but header id is ` +
597
+ `"${headerId}" (corrupt metadata).`);
598
+ }
599
+ if (parsed.provider !== first.provider || parsed.model !== first.modelId) {
600
+ throw new SessionIdentityError(`Session name provider/model mirror mismatch: name carries "${parsed.provider}/${parsed.model}" but ` +
601
+ `first model_change is "${first.provider}/${first.modelId}" (corrupt metadata).`);
602
+ }
603
+ }
604
+ // Entwurf-resume strictness (locked 0.9.0 rule). A session is an Entwurf
605
+ // session ONLY if its canonical name carries the `entwurf` tag — there is no
606
+ // compatibility path for the old `*_entwurf-<taskId>.jsonl` filename species.
607
+ if (requireEntwurf) {
608
+ if (!headerId) {
609
+ throw new SessionIdentityError(`Refusing Entwurf resume of "${sessionFile}": session header has no id. Not an Entwurf session.`);
610
+ }
611
+ if (!latestName) {
612
+ throw new SessionIdentityError(`Refusing Entwurf resume of sessionId "${headerId}": no session_info name. The Entwurf marker is the ` +
613
+ `name's \`entwurf\` tag; a session with no name is not an Entwurf session (no compatibility path).`);
614
+ }
615
+ if (!parsed) {
616
+ throw new SessionIdentityError(`Refusing Entwurf resume of sessionId "${headerId}": session name "${latestName}" is not canonical ` +
617
+ `(cannot parse the locked grammar). Not an Entwurf session.`);
618
+ }
619
+ if (!parsed.tags.includes("entwurf")) {
620
+ throw new SessionIdentityError(`Refusing Entwurf resume of sessionId "${headerId}": session name tags [${parsed.tags.join(", ")}] do not ` +
621
+ `include "entwurf". The Entwurf marker is the name's \`entwurf\` tag — this is a general pi session.`);
622
+ }
623
+ }
624
+ return { sessionId: headerId, cwd: headerCwd, provider: first.provider, modelId: first.modelId };
625
+ }
626
+ // ============================================================================
627
+ // Explicit compat extensions (Claude + opt-in Codex ACP bridge routing)
628
+ // ============================================================================
629
+ function resolveConfiguredPackageSource(packageNeedle) {
630
+ try {
631
+ if (!fs.existsSync(PI_SETTINGS_PATH))
632
+ return null;
633
+ const settings = JSON.parse(fs.readFileSync(PI_SETTINGS_PATH, "utf-8"));
634
+ const packages = Array.isArray(settings.packages) ? settings.packages : [];
635
+ for (const pkg of packages) {
636
+ if (typeof pkg === "string" && pkg.includes(packageNeedle))
637
+ return pkg;
638
+ }
639
+ }
640
+ catch {
641
+ /* invalid settings */
642
+ }
643
+ return null;
644
+ }
645
+ // Strip an optional trailing @version from an npm spec while preserving a leading
646
+ // @scope. "@junghanacs/entwurf@0.8.0" → "@junghanacs/entwurf";
647
+ // "entwurf@1.2.3" → "entwurf". The install root keys on the bare name,
648
+ // not the raw source string (#29 correction: never slice the version into the path).
649
+ function parseNpmPackageName(spec) {
650
+ const trimmed = spec.trim();
651
+ if (!trimmed)
652
+ return null;
653
+ if (trimmed.startsWith("@")) {
654
+ const slash = trimmed.indexOf("/");
655
+ if (slash < 0)
656
+ return null; // malformed scoped spec — no "/name"
657
+ const versionAt = trimmed.indexOf("@", slash); // version separator sits after scope/name
658
+ return versionAt < 0 ? trimmed : trimmed.slice(0, versionAt);
659
+ }
660
+ const versionAt = trimmed.indexOf("@");
661
+ return versionAt < 0 ? trimmed : trimmed.slice(0, versionAt);
662
+ }
663
+ // Map a Pi settings package source to its installed root, replicating pi
664
+ // PackageManager's USER-scope layout WITHOUT importing pi internals (entwurf-core
665
+ // is pi-runtime-free by contract). Verified against pi-mono package-manager.ts
666
+ // getGitInstallPath / getNpmInstallPath (#29):
667
+ // git:<host>/<path> → <agentDir>/git/<host>/<path>
668
+ // npm:@scope/name[@ver] → <agentDir>/npm/node_modules/@scope/name
669
+ // <relative-or-abs path> → resolved against the agent dir (legacy local source)
670
+ // Project (-l) scope (cwd/.pi/git|npm/...) is intentionally NOT resolved here —
671
+ // resolveConfiguredPackageSource only reads the user settings.json, so project
672
+ // sources are never even seen. Callers fail-fast rather than silently misroute.
673
+ function packageSourceToRoots(source) {
674
+ // Remote roots use the plain ~/.pi/agent layout (NOT the PI_CODING_AGENT_DIR
675
+ // override) — a local agent-dir override must not leak into the SSH host path.
676
+ const remoteAgent = path.posix.join(os.homedir(), ".pi", "agent");
677
+ if (source.startsWith("git:")) {
678
+ const rest = source.slice("git:".length).replace(/^\/+/, "");
679
+ if (!rest)
680
+ return null;
681
+ const segs = rest.split("/");
682
+ return {
683
+ localRoot: path.join(AGENT_DIR, "git", ...segs),
684
+ remoteRoot: path.posix.join(remoteAgent, "git", ...segs),
685
+ };
686
+ }
687
+ if (source.startsWith("npm:")) {
688
+ const name = parseNpmPackageName(source.slice("npm:".length));
689
+ if (!name)
690
+ return null;
691
+ const segs = name.split("/");
692
+ return {
693
+ localRoot: path.join(AGENT_DIR, "npm", "node_modules", ...segs),
694
+ remoteRoot: path.posix.join(remoteAgent, "npm", "node_modules", ...segs),
695
+ };
696
+ }
697
+ // Local path package source, relative to the agent dir. Remote commands now
698
+ // single-quote every argument, so `$HOME` can no longer be left for the remote
699
+ // shell to expand — resolve relative sources against the canonical agent path.
700
+ return {
701
+ localRoot: path.resolve(AGENT_DIR, source),
702
+ remoteRoot: source.startsWith("/") ? source : path.posix.resolve(remoteAgent, source),
703
+ };
704
+ }
705
+ // Probe a candidate package root for a loadable extension entry. Shared by the
706
+ // settings-source path and the self-root fallback so both honor the same layout
707
+ // (root itself, index.ts, extensions/index.ts, dist/* for built packages).
708
+ function probeExtensionRoot(name, localRoot, remoteRoot) {
709
+ const candidates = [
710
+ { localPath: localRoot, remotePath: remoteRoot },
711
+ { localPath: path.join(localRoot, "index.ts"), remotePath: `${remoteRoot}/index.ts` },
712
+ { localPath: path.join(localRoot, "extensions", "index.ts"), remotePath: `${remoteRoot}/extensions/index.ts` },
713
+ {
714
+ localPath: path.join(localRoot, "dist", "extensions", "index.js"),
715
+ remotePath: `${remoteRoot}/dist/extensions/index.js`,
716
+ },
717
+ { localPath: path.join(localRoot, "dist", "index.js"), remotePath: `${remoteRoot}/dist/index.js` },
718
+ ];
719
+ for (const candidate of candidates) {
720
+ if (fs.existsSync(candidate.localPath)) {
721
+ return { name, localPath: candidate.localPath, remotePath: candidate.remotePath };
722
+ }
723
+ }
724
+ return null;
725
+ }
726
+ // <pkgroot>/pi-extensions/lib/entwurf-core.ts → <pkgroot>. entwurf-core runs from
727
+ // source in every surface (pi native + MCP, both via --experimental-strip-types),
728
+ // so import.meta.url always points at this source file, never a bundled copy.
729
+ function resolveSelfRoot() {
730
+ try {
731
+ const here = path.dirname(fileURLToPath(import.meta.url));
732
+ return path.resolve(here, "..", "..");
733
+ }
734
+ catch {
735
+ return null;
736
+ }
737
+ }
738
+ function resolveExplicitExtensionSpec(packageNeedle, isRemote) {
739
+ const source = resolveConfiguredPackageSource(packageNeedle);
740
+ if (source) {
741
+ const roots = packageSourceToRoots(source);
742
+ if (roots) {
743
+ const spec = probeExtensionRoot(packageNeedle, roots.localRoot, roots.remoteRoot);
744
+ if (spec)
745
+ return spec;
746
+ }
747
+ }
748
+ // Self-root fallback — LOCAL spawn only. When settings package-source
749
+ // resolution misses (e.g. local-dev `pi -e /abs/path/entwurf` with no
750
+ // matching settings source), the parent entwurf extension is still loaded
751
+ // from disk and our own module path is a more accurate bridge root than
752
+ // settings (#29 correction #5). Remote spawn cannot reach a local path across
753
+ // SSH, so it is excluded — remote must rely on settings/source mapping.
754
+ if (!isRemote && packageNeedle === "entwurf") {
755
+ const selfRoot = resolveSelfRoot();
756
+ if (selfRoot) {
757
+ const spec = probeExtensionRoot(packageNeedle, selfRoot, selfRoot);
758
+ if (spec)
759
+ return spec;
760
+ }
761
+ }
762
+ return null;
763
+ }
764
+ export function getEntwurfExplicitExtensions(model, isRemote, recordedProvider) {
765
+ const args = [];
766
+ const names = [];
767
+ const warnings = [];
768
+ const wantsClaudeBridge = isClaudeModel(model);
769
+ const wantsCodexBridge = shouldRouteCodexViaAcp(model);
770
+ // Resume-path signal: a session whose first spawn went through entwurf
771
+ // MUST be resumed with the bridge extension loaded — otherwise pi cannot
772
+ // resolve the "entwurf" provider and the resume dies silently (no
773
+ // assistant turn gets appended). This guard is needed because resume
774
+ // deliberately bypasses the Entwurf Target Registry (Identity Preservation
775
+ // Rule) — so routing info has to come from the session's own recordedProvider.
776
+ const wantsAcpByRecordedProvider = recordedProvider === "entwurf";
777
+ if (!wantsClaudeBridge && !wantsCodexBridge && !wantsAcpByRecordedProvider) {
778
+ return { args, names, warnings };
779
+ }
780
+ const acpBridge = resolveExplicitExtensionSpec("entwurf", isRemote);
781
+ if (acpBridge) {
782
+ args.push("-e", isRemote ? acpBridge.remotePath : acpBridge.localPath);
783
+ names.push(acpBridge.name);
784
+ return {
785
+ args,
786
+ names,
787
+ warnings,
788
+ provider: "entwurf",
789
+ // Strip `openai-codex/` prefix when routing via ACP, for both opt-in Codex
790
+ // routing and recorded-provider resume. For bare model ids the helper is
791
+ // a no-op, so this is safe regardless of whether the prefix is present.
792
+ modelOverride: wantsCodexBridge || wantsAcpByRecordedProvider ? normalizeCodexEntwurfModelForAcp(model) : undefined,
793
+ };
794
+ }
795
+ // Bridge unresolved. Explicit ACP intent — recorded provider=entwurf on
796
+ // resume, or opt-in Codex-via-ACP — cannot degrade: the child would be spawned
797
+ // with `--provider entwurf` and die with `Unknown provider`. Signal
798
+ // fail-fast to the caller (#29 correction #4: fail-fast scope = explicit ACP
799
+ // intent). Checked BEFORE the Claude heuristic so a Claude model that also
800
+ // recorded provider=entwurf fails fast instead of silently falling back
801
+ // to the unrelated pi-claude-code-use bridge.
802
+ if (wantsAcpByRecordedProvider) {
803
+ warnings.push("Resume recorded provider=entwurf but the bridge extension could not be resolved " +
804
+ "(checked settings package source: local path / git install / npm install, plus module self-root). " +
805
+ "Refusing to resume with an unknown provider.");
806
+ return { args, names, warnings, unresolvedAcpIntent: true };
807
+ }
808
+ if (wantsCodexBridge) {
809
+ warnings.push(`Codex entwurf requested with ${ENTWURF_CODEX_ACP_ENV}=1 but entwurf could not be resolved. ` +
810
+ "Refusing to spawn with --provider entwurf.");
811
+ return { args, names, warnings, unresolvedAcpIntent: true };
812
+ }
813
+ // Claude model heuristic with no recorded ACP signal: the legacy secondary
814
+ // bridge pi-claude-code-use may be installed independently. Keep this as
815
+ // warning-only graceful degradation (#29 correction #4 decision: do NOT
816
+ // promote to fail-fast — a different provider package owns this path).
817
+ const compat = resolveExplicitExtensionSpec("pi-claude-code-use", isRemote);
818
+ if (compat) {
819
+ args.push("-e", isRemote ? compat.remotePath : compat.localPath);
820
+ names.push(compat.name);
821
+ return { args, names, warnings };
822
+ }
823
+ warnings.push("Claude entwurf requested but entwurf could not be resolved. Claude entwurfs may fail without an explicit provider bridge.");
824
+ return { args, names, warnings };
825
+ }
826
+ /**
827
+ * Registry-driven routing — used by spawn (runEntwurfSync). Replaces the
828
+ * heuristic getEntwurfExplicitExtensions for paths that have already gone
829
+ * through resolveEntwurfTarget (i.e., the (provider, model) tuple is known
830
+ * to be in the registry and is the explicit caller intent).
831
+ *
832
+ * Resume path (runEntwurfResumeSync) intentionally still uses the heuristic
833
+ * helper — Identity Preservation Rule, no registry consultation.
834
+ */
835
+ export function getRegistryRouting(target, isRemote) {
836
+ const args = [];
837
+ const names = [];
838
+ const warnings = [];
839
+ // Native providers (openai-codex, anthropic, etc.) — pi handles them directly.
840
+ // No extension injection; just pass through provider + model.
841
+ if (target.provider !== "entwurf") {
842
+ return { args, names, warnings, provider: target.provider };
843
+ }
844
+ // entwurf targets need the bridge extension injected. If it can't be
845
+ // resolved, fail-fast — NOT warning-only. A warning-then-spawn path puts a
846
+ // child on `pi --no-extensions --provider entwurf`, which dies with
847
+ // `Unknown provider "entwurf"` before any session file exists (#29). The
848
+ // throw is caught by the same tool-surface try/catch that handles
849
+ // EntwurfRegistryError, and surfaces as a failed entwurf.
850
+ const acpBridge = resolveExplicitExtensionSpec("entwurf", isRemote);
851
+ if (!acpBridge) {
852
+ throw new EntwurfRoutingError(`entwurf target requested (provider=${target.provider}, model=${target.model}) but the ` +
853
+ "bridge extension could not be resolved. Checked settings package source: local path / " +
854
+ "git install (~/.pi/agent/git/...) / npm install (~/.pi/agent/npm/node_modules/...)" +
855
+ (isRemote ? "" : " / loaded module self-root") +
856
+ ". Refusing to spawn a child with `--no-extensions --provider entwurf` (it would die " +
857
+ 'with `Unknown provider "entwurf"`). Install entwurf in pi settings packages, or ' +
858
+ "check that the configured source's install directory exists.");
859
+ }
860
+ args.push("-e", isRemote ? acpBridge.remotePath : acpBridge.localPath);
861
+ names.push(acpBridge.name);
862
+ return {
863
+ args,
864
+ names,
865
+ warnings,
866
+ provider: "entwurf",
867
+ // Defensive: registry should already store bare basenames, but if a future
868
+ // entry slips an `openai-codex/` prefix into a entwurf model field,
869
+ // strip it before forwarding to codex-acp.
870
+ modelOverride: target.model.startsWith("openai-codex/") ? target.model.slice("openai-codex/".length) : undefined,
871
+ };
872
+ }
873
+ // ============================================================================
874
+ // Project-context injection (담당자 패턴)
875
+ // ============================================================================
876
+ export function enrichTaskWithProjectContext(task, cwd) {
877
+ const agentsPath = path.join(cwd, "AGENTS.md");
878
+ try {
879
+ if (!fs.existsSync(agentsPath))
880
+ return task;
881
+ const content = fs.readFileSync(agentsPath, "utf-8");
882
+ if (!content.trim())
883
+ return task;
884
+ return [
885
+ `${ENTWURF_PROJECT_CONTEXT_OPEN_TAG} path="${agentsPath}">`,
886
+ content.trim(),
887
+ `</project-context>`,
888
+ "",
889
+ task,
890
+ ].join("\n");
891
+ }
892
+ catch {
893
+ return task;
894
+ }
895
+ }
896
+ // Saved entwurf session lookup is by JSONL header `id` (= sessionId), not by
897
+ // filename species. See findSessionFileById / findSessionFilesById below in the
898
+ // "Garden session identity & name grammar" block — header scan is the sole
899
+ // authority; filenames are a Pi artifact and are never parsed for logic.
900
+ // ============================================================================
901
+ // Garden session identity & name grammar (0.9.0 / 1.0.0) — locked SSOT
902
+ //
903
+ // See NEXT.md "Locked — session identity & name grammar". This block is the
904
+ // ONLY place that assembles or parses a session name; nothing builds it by hand.
905
+ //
906
+ // Authority separation (do not blur):
907
+ // - lookup / resume authority = JSONL header `id` + header `cwd`. Filenames
908
+ // are a Pi artifact and are NEVER parsed for logic.
909
+ // - model authority = JSONL first `model_change` + the
910
+ // provider/model re-supplied on resume.
911
+ // - session name = display / search / integrity-mirror only.
912
+ // title and tags carry zero logic. A name's provider/model mismatch is NOT
913
+ // a routing signal — it is corrupt-metadata, surfaced via fail-fast.
914
+ //
915
+ // Grammar:
916
+ // sessionId = YYYYMMDDTHHMMSS-[0-9a-f]{6} (= JSONL header id)
917
+ // name = {sessionId}=={provider}/{model}--{titleSlug}__{tag}_{tag}
918
+ // == signature delimiter | -- title delimiter
919
+ // __ tag-section start | _ tag separator
920
+ // provider/model = entwurf-targets.json EXACT tuple (no regex model
921
+ // invention; `.`-bearing models gpt-5.5 / gemini-3.1-pro-preview
922
+ // are real).
923
+ // titleSlug = ascii slug, lowercase, hyphen ok, NO underscore. Raw title
924
+ // is free input; the builder canonicalizes it.
925
+ // tags = lowercase alnum, `_`-separated. `entwurf` tag ⇒ Entwurf.
926
+ // ============================================================================
927
+ export class SessionIdentityError extends Error {
928
+ constructor(message) {
929
+ super(message);
930
+ this.name = "SessionIdentityError";
931
+ }
932
+ }
933
+ // Garden session-id grammar SSOT now lives in ./session-id.js (a real `.js`
934
+ // leaf, resolvable from both the tsc-emit and `node --experimental-strip-types`
935
+ // runtimes — same rationale as protocol.js). Imported above for internal use and
936
+ // re-exported here so every existing `entwurf-core` importer keeps working.
937
+ export { formatSessionTimestamp, generateSessionId, isValidSessionId, SESSION_ID_RE };
938
+ const SESSION_TAG_RE = /^[a-z0-9]+$/;
939
+ /** Canonical titleSlug: lowercase-alnum words joined by single hyphens, no edges. */
940
+ const TITLE_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
941
+ /**
942
+ * Canonicalize a human/agent raw title into an ascii slug. lowercase; every
943
+ * non-`[a-z0-9]` run (spaces, unicode, punctuation, `_`, `__`) collapses to a
944
+ * single `-`; trimmed. Empty → fallback (`untitled`). underscore is destroyed
945
+ * here so a raw title can never smuggle a tag delimiter into the slug.
946
+ */
947
+ export function slugifyTitle(rawTitle, fallback = "untitled") {
948
+ const norm = (s) => s
949
+ .toLowerCase()
950
+ .replace(/[^a-z0-9]+/g, "-")
951
+ .replace(/^-+|-+$/g, "");
952
+ return norm(rawTitle ?? "") || norm(fallback) || "untitled";
953
+ }
954
+ /**
955
+ * Exact-tuple membership against the entwurf target registry. Existence, not
956
+ * `enabled` — a session may have been spawned while the target was enabled and
957
+ * later disabled; its name must still validate. Integrity mirror, not a routing gate.
958
+ */
959
+ export function isKnownProviderModel(provider, model) {
960
+ let targets;
961
+ try {
962
+ targets = loadEntwurfTargets().entwurfTargets;
963
+ }
964
+ catch {
965
+ return false;
966
+ }
967
+ return targets.some((t) => t.provider === provider && t.model === model);
968
+ }
969
+ /**
970
+ * Assemble a canonical session name — the ONLY way to produce a `--name` value.
971
+ * Validates sessionId grammar, registry tuple, tag charset; canonicalizes title.
972
+ * Throws SessionIdentityError on any violation; corrupt metadata must never reach `--name`.
973
+ */
974
+ export function buildSessionName(input) {
975
+ const { sessionId, provider, model, rawTitle, tags = [] } = input;
976
+ if (!isValidSessionId(sessionId)) {
977
+ throw new SessionIdentityError(`Invalid sessionId "${sessionId}": expected YYYYMMDDTHHMMSS-[0-9a-f]{6}.`);
978
+ }
979
+ if (!provider || provider.includes("/") || provider.includes("=") || provider.includes("--")) {
980
+ throw new SessionIdentityError(`Invalid provider "${provider}" for session name.`);
981
+ }
982
+ if (!model || model.includes("/") || model.includes("=") || model.includes("--")) {
983
+ throw new SessionIdentityError(`Invalid model "${model}" for session name.`);
984
+ }
985
+ if (!isKnownProviderModel(provider, model)) {
986
+ throw new SessionIdentityError(`provider/model "${provider}/${model}" is not an exact tuple in the entwurf target registry. ` +
987
+ `Session names mirror a real (provider, model); do not invent one.`);
988
+ }
989
+ for (const tag of tags) {
990
+ if (!SESSION_TAG_RE.test(tag)) {
991
+ throw new SessionIdentityError(`Invalid tag "${tag}": tags must match /^[a-z0-9]+$/.`);
992
+ }
993
+ }
994
+ const titleSlug = slugifyTitle(rawTitle);
995
+ const base = `${sessionId}==${provider}/${model}--${titleSlug}`;
996
+ return tags.length > 0 ? `${base}__${tags.join("_")}` : base;
997
+ }
998
+ /**
999
+ * Parse a canonical session name into its fields. Returns `null` on any
1000
+ * structural violation. Pure string work — does NOT consult the registry, so it
1001
+ * stays usable for diagnostics on a name whose target was later removed.
1002
+ */
1003
+ export function parseSessionName(name) {
1004
+ if (typeof name !== "string")
1005
+ return null;
1006
+ const sigIdx = name.indexOf("==");
1007
+ if (sigIdx < 0)
1008
+ return null;
1009
+ const sessionId = name.slice(0, sigIdx);
1010
+ if (!isValidSessionId(sessionId))
1011
+ return null;
1012
+ const rest = name.slice(sigIdx + 2);
1013
+ // First `--` is the title delimiter. provider/model and titleSlug each carry
1014
+ // only single hyphens (registry models have no `--`; slugify collapses runs),
1015
+ // so the first `--` is unambiguous.
1016
+ const titleIdx = rest.indexOf("--");
1017
+ if (titleIdx < 0)
1018
+ return null;
1019
+ const providerModel = rest.slice(0, titleIdx);
1020
+ const titleAndTags = rest.slice(titleIdx + 2);
1021
+ const slashIdx = providerModel.indexOf("/");
1022
+ if (slashIdx < 0)
1023
+ return null;
1024
+ const provider = providerModel.slice(0, slashIdx);
1025
+ const model = providerModel.slice(slashIdx + 1);
1026
+ if (!provider || !model || model.includes("/"))
1027
+ return null;
1028
+ let titleSlug = titleAndTags;
1029
+ let tags = [];
1030
+ const tagIdx = titleAndTags.indexOf("__");
1031
+ if (tagIdx >= 0) {
1032
+ titleSlug = titleAndTags.slice(0, tagIdx);
1033
+ tags = titleAndTags.slice(tagIdx + 2).split("_");
1034
+ if (tags.some((t) => !SESSION_TAG_RE.test(t)))
1035
+ return null;
1036
+ }
1037
+ // canonical-only: a parseable name must carry a slug the builder could emit
1038
+ // (lowercase-alnum + single hyphens). Rejects spaces/uppercase/unicode and
1039
+ // any raw delimiter that slipped through.
1040
+ if (!TITLE_SLUG_RE.test(titleSlug))
1041
+ return null;
1042
+ return { sessionId, provider, model, titleSlug, tags };
1043
+ }
1044
+ /** `entwurf` tag present ⇒ Entwurf session. Reads name as a discovery hint only. */
1045
+ export function isEntwurfSessionName(name) {
1046
+ const parsed = parseSessionName(name);
1047
+ return parsed ? parsed.tags.includes("entwurf") : false;
1048
+ }
1049
+ /** Resident-session tag. The top-level `--entwurf-control` operator session. */
1050
+ export const RESIDENT_SESSION_TAG = "control";
1051
+ /**
1052
+ * Garden-native session name for a TOP-LEVEL operator session (the resident
1053
+ * `--entwurf-control` session), NOT an Entwurf child.
1054
+ *
1055
+ * Same locked grammar as buildSessionName, with two deliberate differences:
1056
+ * - provider/model are validated by charset + presence only, NOT against the
1057
+ * Entwurf Target Registry. The operator's own session may run any native
1058
+ * model (e.g. deepseek/deepseek-v4-pro) that is not an Entwurf spawn target;
1059
+ * mirroring the live ctx.model must not be gated by the spawn registry
1060
+ * (readSessionIdentity's name mirror is registry-free, so this parses fine).
1061
+ * - the `entwurf` tag is FORBIDDEN. `entwurf` is the resume marker
1062
+ * (readSessionIdentity `requireEntwurf`) — a resident session must never be
1063
+ * resumable as an Entwurf child. The resident tag is `control`.
1064
+ *
1065
+ * Symmetric safety: buildSessionName (child) carries `entwurf`; this builder
1066
+ * refuses it. The two name species cannot be confused.
1067
+ */
1068
+ export function buildGardenSessionName(input) {
1069
+ const { sessionId, provider, model, rawTitle, tags = [] } = input;
1070
+ if (!isValidSessionId(sessionId)) {
1071
+ throw new SessionIdentityError(`Invalid sessionId "${sessionId}": expected YYYYMMDDTHHMMSS-[0-9a-f]{6}.`);
1072
+ }
1073
+ if (!provider || provider.includes("/") || provider.includes("=") || provider.includes("--")) {
1074
+ throw new SessionIdentityError(`Invalid provider "${provider}" for garden session name.`);
1075
+ }
1076
+ if (!model || model.includes("/") || model.includes("=") || model.includes("--")) {
1077
+ throw new SessionIdentityError(`Invalid model "${model}" for garden session name.`);
1078
+ }
1079
+ for (const tag of tags) {
1080
+ if (!SESSION_TAG_RE.test(tag)) {
1081
+ throw new SessionIdentityError(`Invalid tag "${tag}": tags must match /^[a-z0-9]+$/.`);
1082
+ }
1083
+ if (tag === "entwurf") {
1084
+ throw new SessionIdentityError(`A resident garden session name must not carry the "entwurf" tag — that tag is the Entwurf resume ` +
1085
+ `marker and would make this operator session resumable as an Entwurf child. Use "${RESIDENT_SESSION_TAG}".`);
1086
+ }
1087
+ }
1088
+ const titleSlug = slugifyTitle(rawTitle);
1089
+ const base = `${sessionId}==${provider}/${model}--${titleSlug}`;
1090
+ return tags.length > 0 ? `${base}__${tags.join("_")}` : base;
1091
+ }
1092
+ /**
1093
+ * Garden-native enforcement for the resident `--entwurf-control` session: the
1094
+ * session header id MUST be a garden sessionId. pi assigns a uuidv7 when the
1095
+ * launcher did not pass `--session-id` (session-manager `newSession`), so a
1096
+ * non-garden id here means the session was not born through the garden launcher.
1097
+ * Throws — there is no backward-compatibility path for uuid sessions under
1098
+ * `--entwurf-control`. The caller escalates (notify + refuse server + shutdown);
1099
+ * a bare throw from a session_start handler is swallowed by the extension runner.
1100
+ */
1101
+ export function assertGardenNativeSessionId(sessionId) {
1102
+ if (!isValidSessionId(sessionId)) {
1103
+ throw new SessionIdentityError(`Non-garden session id "${sessionId ?? "(none)"}" under --entwurf-control. Expected ` +
1104
+ `YYYYMMDDTHHMMSS-[0-9a-f]{6}. Launch through the garden launcher that passes ` +
1105
+ `--session-id "<generated>" (see entwurf README §Garden launcher / run.sh new-session-id) ` +
1106
+ `so every --entwurf-control session is a garden citizen. No uuid / back-compat path.`);
1107
+ }
1108
+ }
1109
+ /** Screwdriver icon for the resident-session status label (GLGMAN's tool). */
1110
+ export const RESIDENT_STATUS_ICON = "🪛";
1111
+ /**
1112
+ * Screwdriver (🪛) status-bar label for the resident session. The garden id
1113
+ * appears ONLY once the session file exists on disk (= first assistant turn
1114
+ * done = model locked; pi's `_persist` defers the file until the first assistant
1115
+ * message). Before that it reads `ready`: the session is live and the model is
1116
+ * still changeable. The id's presence is the model-lock lifecycle signal, not
1117
+ * just an identifier. Pure — UI theming is the caller's concern.
1118
+ */
1119
+ export function computeResidentStatusLabel(input) {
1120
+ return input.sessionFileExists ? `${RESIDENT_STATUS_ICON} ${input.sessionId}` : `${RESIDENT_STATUS_ICON} ready`;
1121
+ }
1122
+ /**
1123
+ * All session files whose JSONL header `id` equals `sessionId`, across every
1124
+ * cwd-encoded session dir. Header is the sole authority — every `.jsonl` header
1125
+ * is read; the filename is NOT used to pre-filter (a renamed/relocated file with
1126
+ * the right header still matches, a filename-only match with a different header
1127
+ * does not). Returns `[]` on invalid id or missing base.
1128
+ */
1129
+ export function findSessionFilesById(sessionId) {
1130
+ if (!isValidSessionId(sessionId))
1131
+ return [];
1132
+ let dirs;
1133
+ try {
1134
+ dirs = fs.readdirSync(SESSIONS_BASE);
1135
+ }
1136
+ catch {
1137
+ return [];
1138
+ }
1139
+ const matches = [];
1140
+ for (const dir of dirs) {
1141
+ const dirPath = path.join(SESSIONS_BASE, dir);
1142
+ let files;
1143
+ try {
1144
+ if (!fs.statSync(dirPath).isDirectory())
1145
+ continue;
1146
+ files = fs.readdirSync(dirPath);
1147
+ }
1148
+ catch {
1149
+ continue;
1150
+ }
1151
+ for (const file of files) {
1152
+ if (!file.endsWith(".jsonl"))
1153
+ continue;
1154
+ const full = path.join(dirPath, file);
1155
+ if (readSessionHeader(full)?.id === sessionId)
1156
+ matches.push(full);
1157
+ }
1158
+ }
1159
+ return matches;
1160
+ }
1161
+ /**
1162
+ * Resolve a sessionId to its single session file by header scan. `null` if none,
1163
+ * the path if exactly one, and **throws** `SessionIdentityError` if the same
1164
+ * header id exists in more than one session (the wrong-cwd duplicate footgun) —
1165
+ * resume must never silently pick one of several ambiguous sessions.
1166
+ */
1167
+ export function findSessionFileById(sessionId) {
1168
+ const matches = findSessionFilesById(sessionId);
1169
+ if (matches.length === 0)
1170
+ return null;
1171
+ if (matches.length > 1) {
1172
+ throw new SessionIdentityError(`sessionId "${sessionId}" is ambiguous: ${matches.length} sessions carry this header id ` +
1173
+ `(${matches.join(", ")}). This is the wrong-cwd duplicate footgun; refuse rather than guess.`);
1174
+ }
1175
+ return matches[0] ?? null;
1176
+ }
1177
+ /**
1178
+ * Parent-side collision pre-check before spawning with `--session-id`. Throws if
1179
+ * any existing session (in ANY cwd dir) already carries this header id —
1180
+ * `--session-id` would otherwise silently open/append to it. Duplicate-across-cwd
1181
+ * is included on purpose (the wrong-cwd footgun).
1182
+ */
1183
+ export function assertSessionIdAvailableForSpawn(sessionId) {
1184
+ if (!isValidSessionId(sessionId)) {
1185
+ throw new SessionIdentityError(`Refusing to spawn with invalid sessionId "${sessionId}".`);
1186
+ }
1187
+ const existing = findSessionFilesById(sessionId);
1188
+ if (existing.length > 0) {
1189
+ throw new SessionIdentityError(`sessionId "${sessionId}" already exists (${existing.length}): ${existing.join(", ")}. ` +
1190
+ `Spawning with this id would append to an existing session, not create a new one.`);
1191
+ }
1192
+ }
1193
+ // ============================================================================
1194
+ // In-process garden-native session birth (/gnew)
1195
+ // ============================================================================
1196
+ /**
1197
+ * pi's `CURRENT_SESSION_VERSION` at our pinned dep (0.78). This module MUST NOT
1198
+ * import pi (see file header), so the version is mirrored here. A header written
1199
+ * at the current version avoids a migrate-on-open rewrite; if pi later bumps the
1200
+ * version, the dep-bump track owns this constant. The garden id survives a
1201
+ * migration rewrite either way (migration preserves the header id), so a stale
1202
+ * version is a cosmetic rewrite, never a torn identity.
1203
+ */
1204
+ export const GARDEN_SESSION_FILE_VERSION = 3;
1205
+ /**
1206
+ * Pre-create an EMPTY garden-native session JSONL (header only) that
1207
+ * `ctx.switchSession(file)` can adopt in-process WITHOUT a torn identity.
1208
+ *
1209
+ * Why a precreated file + switchSession, and not `ctx.newSession({setup})`:
1210
+ * pi's `newSession()` runs `SessionManager.create()` (which mints a fresh uuid)
1211
+ * and fires `session_start` BEFORE the `setup` callback could re-stamp the id —
1212
+ * so the backend/bridge identity (PI_SESSION_ID, control socket, ACP stream
1213
+ * sessionId) binds to the uuid first and a later header rewrite only tears it.
1214
+ * `switchSession()` instead runs `SessionManager.open(file)`, which reads the
1215
+ * header id BEFORE `session_start`, so the garden id is the identity from the
1216
+ * very first bind. No uuid moment ever exists.
1217
+ *
1218
+ * THE TRAP this guards: `SessionManager.setSessionFile()` silently calls
1219
+ * `newSession()` (→ a fresh uuid, and rewrites the file) if it opens a file whose
1220
+ * header is empty/invalid. So the ONLY thing standing between us and a torn
1221
+ * identity is this header being perfectly valid. We therefore write with `wx`
1222
+ * (never overwrite), then read the bytes back and assert they parse to the exact
1223
+ * header — unlinking and throwing on ANY mismatch so a corrupt header can never
1224
+ * reach `switchSession`. Fail-closed: a broken write yields no session, not a uuid.
1225
+ *
1226
+ * Filename mirrors pi's own convention (`<iso-with-:.replaced>_<id>.jsonl`) so the
1227
+ * file is indistinguishable from a launcher-born garden session on disk.
1228
+ */
1229
+ export function createGardenSessionFile(input) {
1230
+ const { cwd, sessionDir, now = new Date() } = input;
1231
+ const sessionId = input.sessionId ?? generateSessionId(now);
1232
+ if (!isValidSessionId(sessionId)) {
1233
+ throw new SessionIdentityError(`Refusing to create garden session file with invalid id "${sessionId}".`);
1234
+ }
1235
+ if (!cwd || !path.isAbsolute(cwd)) {
1236
+ throw new SessionIdentityError(`createGardenSessionFile requires an absolute cwd, got "${cwd}".`);
1237
+ }
1238
+ if (!sessionDir || !path.isAbsolute(sessionDir)) {
1239
+ throw new SessionIdentityError(`createGardenSessionFile requires an absolute sessionDir (ctx.sessionManager.getSessionDir()), got "${sessionDir}".`);
1240
+ }
1241
+ // Collision pre-check (header scan across ALL cwd dirs): switching into an id
1242
+ // that already exists would APPEND to that session, not create a new one.
1243
+ assertSessionIdAvailableForSpawn(sessionId);
1244
+ const timestamp = now.toISOString();
1245
+ const fileTimestamp = timestamp.replace(/[:.]/g, "-"); // pi's filename convention
1246
+ const sessionFile = path.join(sessionDir, `${fileTimestamp}_${sessionId}.jsonl`);
1247
+ const header = { type: "session", version: GARDEN_SESSION_FILE_VERSION, id: sessionId, timestamp, cwd };
1248
+ const line = `${JSON.stringify(header)}\n`;
1249
+ fs.mkdirSync(sessionDir, { recursive: true });
1250
+ // wx — never overwrite. A file already at this exact path is a hard refuse (an
1251
+ // in-flight same-ms collision the header scan could miss). Fail-closed.
1252
+ try {
1253
+ fs.writeFileSync(sessionFile, line, { flag: "wx" });
1254
+ }
1255
+ catch (err) {
1256
+ if (err?.code === "EEXIST") {
1257
+ throw new SessionIdentityError(`Garden session file already exists at ${sessionFile}; refusing to overwrite (wx).`);
1258
+ }
1259
+ throw err;
1260
+ }
1261
+ // Fail-closed read-back: parse the bytes we just wrote and assert the full
1262
+ // header shape. ANY mismatch → unlink + throw, so switchSession never opens a
1263
+ // header that would re-mint a uuid (the setSessionFile trap above).
1264
+ let readBack;
1265
+ try {
1266
+ const raw = fs.readFileSync(sessionFile, "utf8");
1267
+ const firstLine = raw.split("\n", 1)[0] ?? "";
1268
+ readBack = JSON.parse(firstLine);
1269
+ }
1270
+ catch (err) {
1271
+ try {
1272
+ fs.unlinkSync(sessionFile);
1273
+ }
1274
+ catch {
1275
+ /* best-effort */
1276
+ }
1277
+ throw new SessionIdentityError(`Garden session file read-back failed for ${sessionFile}: ${err instanceof Error ? err.message : String(err)}.`);
1278
+ }
1279
+ if (readBack.type !== "session" ||
1280
+ readBack.version !== GARDEN_SESSION_FILE_VERSION ||
1281
+ readBack.id !== sessionId ||
1282
+ readBack.cwd !== cwd ||
1283
+ readBack.timestamp !== timestamp) {
1284
+ try {
1285
+ fs.unlinkSync(sessionFile);
1286
+ }
1287
+ catch {
1288
+ /* best-effort */
1289
+ }
1290
+ throw new SessionIdentityError(`Garden session file read-back mismatch for ${sessionFile}: wrote ` +
1291
+ `{type:session,version:${GARDEN_SESSION_FILE_VERSION},id:${sessionId},timestamp:${timestamp},cwd:${cwd}} but read ` +
1292
+ `${JSON.stringify(readBack)}. Refusing to switch into a header that would re-mint a uuid.`);
1293
+ }
1294
+ return { sessionId, sessionFile };
1295
+ }
1296
+ /**
1297
+ * Best-effort removal of a garden session file we created but never adopted —
1298
+ * the `switchSession` was cancelled or threw, so the file is an orphan. Guarded:
1299
+ * only unlinks if the file STILL carries our header id AND has no entries beyond
1300
+ * the header, so we never delete a session that meanwhile gained content or a
1301
+ * different identity (a successful switch leaves a legitimate empty session that
1302
+ * we keep, exactly like a launcher-born session quit before its first turn).
1303
+ */
1304
+ export function removeUnadoptedGardenSessionFile(sessionFile, sessionId) {
1305
+ try {
1306
+ if (readSessionHeader(sessionFile)?.id !== sessionId)
1307
+ return; // not ours / re-minted — leave it
1308
+ const raw = fs.readFileSync(sessionFile, "utf8");
1309
+ const nonEmptyLines = raw.split("\n").filter((l) => l.trim().length > 0);
1310
+ if (nonEmptyLines.length > 1)
1311
+ return; // gained entries — it's a real session now
1312
+ fs.unlinkSync(sessionFile);
1313
+ }
1314
+ catch {
1315
+ /* best-effort; an orphan header-only file is harmless litter, not a leak */
1316
+ }
1317
+ }
1318
+ /**
1319
+ * Scope lock for 0.9.0 garden-native session identity: spawn/resume/status are
1320
+ * local-FS only. The sessionId collision pre-check (`assertSessionIdAvailableForSpawn`)
1321
+ * and the resume header scan (`findSessionFileById`) walk `~/.pi/agent/sessions`
1322
+ * on the local machine; they cannot see a remote host's filesystem. Remote (SSH)
1323
+ * entwurf identity is parked under #11. Fail-fast here rather than silently spawn
1324
+ * a remote session whose id we can neither pre-check nor later resume.
1325
+ */
1326
+ export function assertLocalOnlyEntwurf(host) {
1327
+ if (host && host !== "local") {
1328
+ throw new SessionIdentityError(`Remote entwurf host "${host}" is out of scope in 0.9.0 garden-native session identity (#11). ` +
1329
+ `sessionId collision pre-check and header-scan resume are local-filesystem only. ` +
1330
+ `Run the entwurf locally; remote/SSH identity is a later phase.`);
1331
+ }
1332
+ }
1333
+ function collectPiRun({ command, args, cwd, signal, onUpdate, result }) {
1334
+ const messages = [];
1335
+ return new Promise((resolve) => {
1336
+ const proc = spawn(command, args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
1337
+ mirrorChildStderr(proc);
1338
+ let buffer = "";
1339
+ let stderr = "";
1340
+ const processLine = (line) => {
1341
+ if (!line.trim())
1342
+ return;
1343
+ let event;
1344
+ try {
1345
+ event = JSON.parse(line);
1346
+ }
1347
+ catch {
1348
+ return;
1349
+ }
1350
+ if (event.type === "message_end" && event.message) {
1351
+ messages.push(event.message);
1352
+ if (event.message.role === "assistant") {
1353
+ result.turns++;
1354
+ const usage = event.message.usage;
1355
+ if (typeof usage?.cost?.total === "number")
1356
+ result.cost += usage.cost.total;
1357
+ if (event.message.model)
1358
+ result.model = event.message.model;
1359
+ if (typeof event.message.stopReason === "string")
1360
+ result.stopReason = event.message.stopReason;
1361
+ if (typeof event.message.errorMessage === "string" && event.message.errorMessage.trim()) {
1362
+ result.error = event.message.errorMessage.trim();
1363
+ }
1364
+ const latest = extractTextContent(event.message.content).trim();
1365
+ if (latest && onUpdate)
1366
+ onUpdate(latest);
1367
+ }
1368
+ }
1369
+ };
1370
+ proc.stdout.on("data", (data) => {
1371
+ buffer += data.toString();
1372
+ const lines = buffer.split("\n");
1373
+ buffer = lines.pop() || "";
1374
+ for (const line of lines)
1375
+ processLine(line);
1376
+ });
1377
+ proc.stderr.on("data", (data) => {
1378
+ stderr += data.toString();
1379
+ });
1380
+ proc.on("close", (code) => {
1381
+ if (buffer.trim())
1382
+ processLine(buffer);
1383
+ result.exitCode = code ?? 0;
1384
+ if (!result.error && result.stopReason === "error") {
1385
+ result.error = "Entwurf model returned stopReason=error";
1386
+ }
1387
+ const assistantText = parseMessages(messages).trim();
1388
+ result.output = assistantText || result.error || stderr || "(no output)";
1389
+ if (code !== 0 && stderr && !result.error)
1390
+ result.error = stderr.slice(0, 500);
1391
+ if ((result.error || result.stopReason === "error") && result.exitCode === 0)
1392
+ result.exitCode = 1;
1393
+ resolve(result);
1394
+ });
1395
+ proc.on("error", (err) => {
1396
+ result.exitCode = 1;
1397
+ result.error = err.message;
1398
+ result.output = "(spawn failed)";
1399
+ resolve(result);
1400
+ });
1401
+ if (signal) {
1402
+ const kill = () => {
1403
+ proc.kill("SIGTERM");
1404
+ setTimeout(() => {
1405
+ if (!proc.killed)
1406
+ proc.kill("SIGKILL");
1407
+ }, 5000);
1408
+ };
1409
+ if (signal.aborted)
1410
+ kill();
1411
+ else
1412
+ signal.addEventListener("abort", kill, { once: true });
1413
+ }
1414
+ });
1415
+ }
1416
+ // ============================================================================
1417
+ // runEntwurfResumeSync — revive a saved entwurf session by sessionId
1418
+ //
1419
+ // Contract:
1420
+ // - Input: sessionId (YYYYMMDDTHHMMSS-[0-9a-f]{6} = JSONL header id) + prompt
1421
+ // - Resolves the saved session file via findSessionFileById (header scan;
1422
+ // throws on the wrong-cwd duplicate footgun, never guesses)
1423
+ // - Reads model + provider from the session's FIRST model_change
1424
+ // (readSessionIdentity) — NOT the last assistant turn — and reuses BOTH
1425
+ // verbatim; a later differing model_change is treated as corrupt drift
1426
+ // - Forces the child cwd to the saved header cwd, then spawns sync
1427
+ // `pi --session-id <sessionId> ... <prompt>` so Pi appends to the SAME
1428
+ // session file (the wrong-cwd footgun would otherwise create a new one)
1429
+ // - Does NOT touch ~/.pi/entwurf-control; works regardless of whether the
1430
+ // original entwurf process is still alive
1431
+ //
1432
+ // Identity Preservation Rule (AGENTS.md, intentionally hard-coded here):
1433
+ // - This API does NOT accept a `model` override. The model identity is
1434
+ // locked to whatever the session recorded at first spawn.
1435
+ // - cwd is bound to the saved header (the resume authority); model MAY NOT
1436
+ // change. An explicit options.cwd is a debug/migration escape hatch only.
1437
+ // - If the session has no recorded model (empty / corrupted / never reached a
1438
+ // model_change) we refuse the resume rather than fall back to a default.
1439
+ //
1440
+ // Scope lock (0.9.0 / NEXT.md Phase 3b): local only. Remote/SSH resume is
1441
+ // parked under #11 and fails fast at the top (header scan is local-FS).
1442
+ // ============================================================================
1443
+ export async function runEntwurfResumeSync(sessionId, prompt, options) {
1444
+ const host = options.host ?? "local";
1445
+ assertLocalOnlyEntwurf(host);
1446
+ // Header scan is the sole lookup authority; readSessionIdentity then extracts
1447
+ // the resume identity from the FIRST model_change (not the last assistant
1448
+ // message) and integrity-checks the name mirror. Both can throw
1449
+ // SessionIdentityError (wrong-cwd duplicate footgun, model drift, corrupt name
1450
+ // mirror) — convert to a clean failed-resume result rather than an exception so
1451
+ // the tool surface renders it like the other pre-spawn guards.
1452
+ let sessionFile;
1453
+ let identity;
1454
+ try {
1455
+ sessionFile = findSessionFileById(sessionId);
1456
+ identity = sessionFile ? readSessionIdentity(sessionFile, { requireEntwurf: true }) : null;
1457
+ }
1458
+ catch (err) {
1459
+ if (err instanceof SessionIdentityError) {
1460
+ return {
1461
+ task: prompt,
1462
+ host,
1463
+ exitCode: 1,
1464
+ output: err.message,
1465
+ turns: 0,
1466
+ cost: 0,
1467
+ sessionId,
1468
+ sessionFile: undefined,
1469
+ explicitExtensions: [],
1470
+ warnings: [],
1471
+ error: "session_identity_corrupt",
1472
+ };
1473
+ }
1474
+ throw err;
1475
+ }
1476
+ if (!sessionFile) {
1477
+ return {
1478
+ task: prompt,
1479
+ host,
1480
+ exitCode: 1,
1481
+ output: `No saved entwurf session found for sessionId "${sessionId}" under ${SESSIONS_BASE}`,
1482
+ turns: 0,
1483
+ cost: 0,
1484
+ sessionId,
1485
+ sessionFile: undefined,
1486
+ explicitExtensions: [],
1487
+ warnings: [],
1488
+ error: "session_not_found",
1489
+ };
1490
+ }
1491
+ // Identity Preservation Rule (AGENTS.md): the session's recorded model is the
1492
+ // only legitimate source of identity for a resume, and the authority is the
1493
+ // FIRST model_change (readSessionIdentity), never invented and never overridden.
1494
+ // If the session never reached a model_change we refuse.
1495
+ const recordedModel = identity?.modelId;
1496
+ const recordedProvider = identity?.provider;
1497
+ if (!identity || !recordedModel) {
1498
+ return {
1499
+ task: prompt,
1500
+ host,
1501
+ exitCode: 1,
1502
+ output: `Cannot resume sessionId "${sessionId}": session has no recorded model ` +
1503
+ `(file empty, corrupted, or never reached a model_change). ` +
1504
+ `Start a fresh entwurf instead — identity must come from the session.`,
1505
+ turns: 0,
1506
+ cost: 0,
1507
+ sessionId,
1508
+ sessionFile,
1509
+ explicitExtensions: [],
1510
+ warnings: [],
1511
+ error: "session_identity_missing",
1512
+ };
1513
+ }
1514
+ const effectiveModel = resolveEntwurfModel(recordedModel);
1515
+ // Pass recordedProvider so the resume path re-injects entwurf when the
1516
+ // original spawn went through it (registry is bypassed on resume per Identity
1517
+ // Preservation Rule — so the bridge signal must come from the session itself).
1518
+ const explicitExtensions = getEntwurfExplicitExtensions(effectiveModel, false, recordedProvider);
1519
+ // Explicit ACP intent that can't resolve the bridge — fail-fast rather than
1520
+ // spawn a guaranteed-broken `--provider entwurf` child (#29). Returned as
1521
+ // an error result to match this function's other pre-spawn guards (session
1522
+ // identity / cwd), which the tool surface renders as a failed resume.
1523
+ if (explicitExtensions.unresolvedAcpIntent) {
1524
+ return {
1525
+ task: prompt,
1526
+ host,
1527
+ exitCode: 1,
1528
+ output: explicitExtensions.warnings.join(" "),
1529
+ turns: 0,
1530
+ cost: 0,
1531
+ sessionId,
1532
+ sessionFile,
1533
+ explicitExtensions: [],
1534
+ warnings: explicitExtensions.warnings,
1535
+ error: "acp_bridge_unresolved",
1536
+ };
1537
+ }
1538
+ const resumeProvider = explicitExtensions.provider ?? recordedProvider;
1539
+ // INVARIANT (#9 / #10): saved session header cwd is the authority for cold
1540
+ // resume, and now doubly so — `--session-id` resolves the session file
1541
+ // relative to the child's cwd (cwdToSessionDir). If we spawned in the wrong
1542
+ // cwd, Pi would NOT find the saved file and would silently create a NEW
1543
+ // session under that id (the wrong-cwd footgun proven live in
1544
+ // smoke-session-id-name T3). Forcing child cwd = header cwd makes Pi resolve
1545
+ // `--session-id` to the existing file and append. `options.cwd` is a
1546
+ // debug/migration escape hatch only; the resumer's `process.cwd()` is NEVER a
1547
+ // fallback. We fail-fast when neither carrier is available.
1548
+ const headerCwd = identity.cwd ?? undefined;
1549
+ if (!options.cwd && !headerCwd) {
1550
+ return {
1551
+ task: prompt,
1552
+ host,
1553
+ exitCode: 1,
1554
+ output: `Cannot resume sessionId "${sessionId}": saved session header has no cwd ` +
1555
+ `and no explicit cwd override was provided. The header cwd is the ` +
1556
+ `authority for cold resume (see #9). Re-spawn from the original cwd, ` +
1557
+ `or pass an explicit options.cwd if you are intentionally migrating.`,
1558
+ turns: 0,
1559
+ cost: 0,
1560
+ sessionId,
1561
+ sessionFile,
1562
+ explicitExtensions: [],
1563
+ warnings: [],
1564
+ error: "session_cwd_missing",
1565
+ };
1566
+ }
1567
+ const effectiveCwd = options.cwd ?? headerCwd;
1568
+ const piArgs = ["--mode", "json", "-p", "--no-extensions", ...explicitExtensions.args, "--session-id", sessionId];
1569
+ if (resumeProvider)
1570
+ piArgs.push("--provider", resumeProvider);
1571
+ piArgs.push("--model", explicitExtensions.modelOverride ?? effectiveModel);
1572
+ piArgs.push(prompt);
1573
+ const result = {
1574
+ task: prompt,
1575
+ host,
1576
+ exitCode: 0,
1577
+ output: "",
1578
+ turns: 0,
1579
+ cost: 0,
1580
+ sessionId,
1581
+ sessionFile,
1582
+ explicitExtensions: [...explicitExtensions.names],
1583
+ warnings: [...explicitExtensions.warnings],
1584
+ };
1585
+ return collectPiRun({
1586
+ command: "pi",
1587
+ args: piArgs,
1588
+ cwd: effectiveCwd,
1589
+ signal: options.signal,
1590
+ onUpdate: options.onUpdate,
1591
+ result,
1592
+ });
1593
+ }
1594
+ // ============================================================================
1595
+ // runEntwurfSync — spawn pi and collect result
1596
+ // ============================================================================
1597
+ export async function runEntwurfSync(task, options) {
1598
+ const host = options.host ?? "local";
1599
+ // Scope lock (0.9.0 / NEXT.md Phase 3b): garden-native session identity is
1600
+ // local-FS only — the sessionId collision pre-check and the header-scan resume
1601
+ // path cannot see a remote filesystem. Remote (SSH) entwurf is parked under #11.
1602
+ assertLocalOnlyEntwurf(host);
1603
+ const effectiveCwd = options.cwd ?? process.cwd();
1604
+ const enrichedTask = enrichTaskWithProjectContext(task, effectiveCwd);
1605
+ // Resolve through the Entwurf Target Registry. This is the spawn gate:
1606
+ // unregistered (provider, model) pairs are rejected here. Resume path does
1607
+ // NOT pass through this — Identity Preservation Rule.
1608
+ const fallbackModel = options.model && options.model.trim() ? options.model : DEFAULT_ENTWURF_MODEL;
1609
+ const target = resolveEntwurfTarget({ provider: options.provider, model: fallbackModel });
1610
+ // Parent generates the durable sessionId and pre-checks for collision before
1611
+ // spawn (async spawn can't self-report; sync is uniform with it). The name is
1612
+ // a display/search/integrity mirror — built only via buildSessionName.
1613
+ const sessionId = generateSessionId();
1614
+ assertSessionIdAvailableForSpawn(sessionId);
1615
+ const sessionName = buildSessionName({
1616
+ sessionId,
1617
+ provider: target.provider,
1618
+ model: target.model,
1619
+ rawTitle: task.slice(0, 80),
1620
+ tags: ["entwurf", "sync"],
1621
+ });
1622
+ const routing = getRegistryRouting(target, false);
1623
+ const command = "pi";
1624
+ const args = [
1625
+ "--mode",
1626
+ "json",
1627
+ "-p",
1628
+ "--no-extensions",
1629
+ ...routing.args,
1630
+ "--session-id",
1631
+ sessionId,
1632
+ "--name",
1633
+ sessionName,
1634
+ "--provider",
1635
+ routing.provider,
1636
+ "--model",
1637
+ routing.modelOverride ?? target.model,
1638
+ enrichedTask,
1639
+ ];
1640
+ const result = {
1641
+ task,
1642
+ host,
1643
+ exitCode: 0,
1644
+ output: "",
1645
+ turns: 0,
1646
+ cost: 0,
1647
+ sessionId,
1648
+ explicitExtensions: [...routing.names],
1649
+ warnings: [...routing.warnings],
1650
+ };
1651
+ const finished = await collectPiRun({
1652
+ command,
1653
+ args,
1654
+ cwd: effectiveCwd,
1655
+ signal: options.signal,
1656
+ onUpdate: options.onUpdate,
1657
+ result,
1658
+ });
1659
+ // Diagnostic only: resolve the Pi-named session file after the run. Header
1660
+ // scan is the authority; the filename is never parsed for logic.
1661
+ finished.sessionFile = findSessionFilesById(sessionId)[0];
1662
+ return finished;
1663
+ }
1664
+ // ============================================================================
1665
+ // Shared summary formatter (used by both pi native and MCP surfaces)
1666
+ // ============================================================================
1667
+ export function formatSyncSummary(result) {
1668
+ return [
1669
+ `Session ID: ${result.sessionId}`,
1670
+ `Host: ${result.host}`,
1671
+ `Turns: ${result.turns}`,
1672
+ `Cost: $${result.cost.toFixed(4)}`,
1673
+ result.model ? `Model: ${result.model}` : null,
1674
+ result.stopReason ? `Stop reason: ${result.stopReason}` : null,
1675
+ result.explicitExtensions.length ? `Compat: ${result.explicitExtensions.join(", ")}` : null,
1676
+ result.warnings.length ? `Warnings: ${result.warnings.join(" | ")}` : null,
1677
+ result.error ? `Error: ${result.error}` : null,
1678
+ "",
1679
+ result.output,
1680
+ ]
1681
+ .filter(Boolean)
1682
+ .join("\n");
1683
+ }