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