@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,1692 @@
1
+ /**
2
+ * Session Control Extension — entwurf owned.
3
+ *
4
+ * Ingested from Armin Ronacher's `agent-stuff` (Apache 2.0) —
5
+ * https://github.com/mitsuhiko/agent-stuff (extensions/control.ts)
6
+ * The AI-summarization `get_summary` command was dropped during ingest so
7
+ * this file no longer depends on `@earendil-works/pi-ai.complete`. Model-routed
8
+ * summarization belongs to consumer skills, not to the entwurf-control
9
+ * protocol surface that entwurf publishes.
10
+ *
11
+ * Why this lives here (not in consumer dotfiles): entwurf's public
12
+ * bridge surface (`mcp/entwurf-bridge.entwurf_v2`, `entwurf_peers`)
13
+ * depends at runtime on pi sessions exposing the v2 control surface and
14
+ * control socket. Bundling it here removes a hidden dependency on a private
15
+ * consumer repo and makes entwurf installable as a public package without
16
+ * extra setup.
17
+ *
18
+ * Enables inter-session communication via Unix domain sockets. When enabled
19
+ * with the `--entwurf-control` flag, each pi session creates a control socket
20
+ * at `~/.pi/entwurf-control/<session-id>.sock` that accepts JSON-RPC commands.
21
+ *
22
+ * Features:
23
+ * - Register the canonical `entwurf_v2` dispatch tool for existing garden citizens.
24
+ * - Expose `entwurf_peers` facts and `/entwurf-sessions` for operator inspection.
25
+ * - Maintain the resident control socket used by v2 live-send / spawn-bg paths.
26
+ * - Keep `/gnew` garden-native in-process session birth.
27
+ *
28
+ * Send-is-throw still applies at the control-socket protocol layer: a `send` RPC
29
+ * ack confirms the receiver enqueued the message (`message_processed` semantics)
30
+ * and does not wait for a peer turn result. Public v1 send surfaces were removed;
31
+ * callers use `entwurf_v2`, whose decider chooses send / spawn-bg / mailbox.
32
+ *
33
+ * Usage:
34
+ * pi --session-id <garden-id> --entwurf-control
35
+ *
36
+ * Addressing is sessionId-only. The sessionId (a garden id for
37
+ * garden-native sessions, or a pi-assigned uuidv7 otherwise) is the only stable
38
+ * identity a peer needs; alias / sessionName surfaces are deliberately not
39
+ * exposed. Use entwurf_peers (or /entwurf-sessions) to discover live
40
+ * sessions; pass the sessionId to entwurf_v2. Note that this is independent
41
+ * of agent-config's --session-control extension, which lives under
42
+ * ~/.pi/session-control/ and may keep its own alias surface.
43
+ *
44
+ * Environment:
45
+ * Sets PI_SESSION_ID / PI_AGENT_ID when enabled, allowing child processes to discover
46
+ * the current session.
47
+ *
48
+ * RPC Protocol:
49
+ * Commands are newline-delimited JSON objects with a `type` field:
50
+ * - { type: "send", message: "...", mode?: "steer"|"follow_up" }
51
+ * - { type: "get_message" }
52
+ * - { type: "get_info" }
53
+ * - { type: "clear", summarize?: boolean }
54
+ * - { type: "abort" }
55
+ * Responses are JSON objects with { type: "response", command, success, data?, error? }
56
+ * (No event channel — the turn_end subscribe surface was removed with the
57
+ * Send-is-throw cleanup; see note above.)
58
+ */
59
+
60
+ import { existsSync, promises as fs, readFileSync } from "node:fs";
61
+ import * as net from "node:net";
62
+ import * as os from "node:os";
63
+ import * as path from "node:path";
64
+ // Use pi-ai's re-exports of typebox so the schema universe matches what
65
+ // pi-coding-agent.registerTool consumes. Importing Type from @sinclair/typebox
66
+ // directly mixes typebox 0.34 (Type.*) with typebox 1.x (StringEnum, TSchema
67
+ // inside pi-coding-agent), which silently widens StringEnum-typed parameters
68
+ // to `unknown` and broke renderCall/execute narrowing. Single-source-of-typebox.
69
+ import { StringEnum, type TextContent, Type } from "@earendil-works/pi-ai";
70
+ import type {
71
+ AgentToolResult,
72
+ AgentToolUpdateCallback,
73
+ ExtensionAPI,
74
+ ExtensionContext,
75
+ MessageRenderer,
76
+ } from "@earendil-works/pi-coding-agent";
77
+ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
78
+ import { Box, type Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
79
+ import { ENTWURF_SENT_MESSAGE_TYPE } from "../protocol.js";
80
+ import {
81
+ fetchControlSocketRuntimeInfo,
82
+ formatRuntimeModel,
83
+ type RpcCommand,
84
+ type RpcResponse,
85
+ type SenderEnvelope,
86
+ } from "./lib/entwurf-control-rpc.js";
87
+ import {
88
+ assertGardenNativeSessionId,
89
+ buildGardenSessionName,
90
+ computeResidentStatusLabel,
91
+ createGardenSessionFile,
92
+ isValidSessionId,
93
+ parseSessionName,
94
+ RESIDENT_SESSION_TAG,
95
+ readSessionHeader,
96
+ removeUnadoptedGardenSessionFile,
97
+ } from "./lib/entwurf-core.js";
98
+ import { isV2ResumeResidentAuthorized } from "./lib/entwurf-v2-resume-marker.js";
99
+ import { probeSocketLiveness, shouldListAsLive, shouldUnlinkOnGc } from "./lib/socket-probe.js";
100
+
101
+ // The `--entwurf-control` socket protocol (wire types + the newline-JSON client) now lives
102
+ // in the ctx-free SSOT `lib/entwurf-control-rpc.ts` so the 5d entwurf_v2 production
103
+ // `sendOverSocket` dep can share it without importing this surface file. Re-export
104
+ // `SenderEnvelope` to keep this module's public surface unchanged for external importers.
105
+ export type { SenderEnvelope } from "./lib/entwurf-control-rpc.js";
106
+
107
+ const ENTWURF_FLAG = "entwurf-control";
108
+ const EMACS_AGENT_SOCKET_FLAG = "emacs-agent-socket";
109
+ const ENTWURF_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
110
+ const SOCKET_SUFFIX = ".sock";
111
+ const SESSION_MESSAGE_TYPE = "entwurf-message";
112
+ // Sender-side UI marker. Layer B (ACP path) emits a CustomMessage with this
113
+ // customType so the operator sees a first-class [entwurf sent →] box paired
114
+ // with the receive-side [entwurf received ⟵] box. The provider-level context
115
+ // filter in index.ts drops this customType before the LLM sees it — colocated
116
+ // with the emitter so sessions without --entwurf-control are still protected.
117
+ // Layer A (native path) reuses the same Box builder via renderSentMessage() but
118
+ // does NOT emit a CustomMessage; the native tool result already lives in the
119
+ // toolResult role and never enters LLM context as a user message.
120
+ const SENDER_INFO_PATTERN = /<sender_info>[\s\S]*?<\/sender_info>/g;
121
+
122
+ // ============================================================================
123
+ // RPC Types — moved to lib/entwurf-control-rpc.ts (ctx-free SSOT, imported above)
124
+ // ============================================================================
125
+
126
+ // ============================================================================
127
+ // Server State
128
+ // ============================================================================
129
+
130
+ interface SocketState {
131
+ server: net.Server | null;
132
+ socketPath: string | null;
133
+ context: ExtensionContext | null;
134
+ }
135
+
136
+ // ============================================================================
137
+ // Utilities
138
+ // ============================================================================
139
+
140
+ const STATUS_KEY = "entwurf-control";
141
+
142
+ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
143
+ return typeof error === "object" && error !== null && "code" in error;
144
+ }
145
+
146
+ function getSocketPath(sessionId: string): string {
147
+ return path.join(ENTWURF_DIR, `${sessionId}${SOCKET_SUFFIX}`);
148
+ }
149
+
150
+ function isSafeSessionId(sessionId: string): boolean {
151
+ return !sessionId.includes("/") && !sessionId.includes("\\") && !sessionId.includes("..") && sessionId.length > 0;
152
+ }
153
+
154
+ async function ensureControlDir(): Promise<void> {
155
+ await fs.mkdir(ENTWURF_DIR, { recursive: true });
156
+ }
157
+
158
+ async function removeSocket(socketPath: string | null): Promise<void> {
159
+ if (!socketPath) return;
160
+ try {
161
+ await fs.unlink(socketPath);
162
+ } catch (error) {
163
+ if (isErrnoException(error) && error.code !== "ENOENT") {
164
+ throw error;
165
+ }
166
+ }
167
+ }
168
+
169
+ // Sweep stale `.sock` entries left behind by hard-killed sessions or stale
170
+ // alias-era artifacts. Runs once per startControlServer call. We only touch
171
+ // entries that are demonstrably dead — a live peer's socket survives.
172
+ async function gcStaleSockets(): Promise<void> {
173
+ let entries: import("node:fs").Dirent[];
174
+ try {
175
+ entries = await fs.readdir(ENTWURF_DIR, { withFileTypes: true });
176
+ } catch (error) {
177
+ if (isErrnoException(error) && error.code === "ENOENT") return;
178
+ throw error;
179
+ }
180
+ for (const entry of entries) {
181
+ if (entry.isSymbolicLink()) {
182
+ // Pre-0.5 alias symlinks (`<name>.alias`) are no longer used.
183
+ // Drop them on encounter so the directory stays clean.
184
+ await fs.unlink(path.join(ENTWURF_DIR, entry.name)).catch(() => {});
185
+ continue;
186
+ }
187
+ if (!entry.name.endsWith(SOCKET_SUFFIX)) continue;
188
+ const fullPath = path.join(ENTWURF_DIR, entry.name);
189
+ // F3: reclaim ONLY a demonstrably dead socket. A timeout / unknown-error
190
+ // probe is indeterminate (a live socket may have stalled under load) and
191
+ // MUST survive the sweep — unlinking it permanently splits that live
192
+ // session's identity. shouldUnlinkOnGc(indeterminate|alive) === false.
193
+ const liveness = await probeSocketLiveness(fullPath);
194
+ if (!shouldUnlinkOnGc(liveness)) continue;
195
+ await fs.unlink(fullPath).catch(() => {});
196
+ }
197
+ }
198
+
199
+ // Listing wrapper over the shared three-valued probe: a session is "alive" for
200
+ // listing/reachability only on a positive connect (shouldListAsLive). An
201
+ // indeterminate probe is hidden from listings but — unlike GC above — never
202
+ // unlinked, so it can reappear once the stall clears.
203
+ async function isSocketAlive(socketPath: string): Promise<boolean> {
204
+ return shouldListAsLive(await probeSocketLiveness(socketPath));
205
+ }
206
+
207
+ type LiveSessionInfo = {
208
+ sessionId: string;
209
+ socketPath: string;
210
+ };
211
+
212
+ type EnrichedSession = LiveSessionInfo & {
213
+ cwd?: string;
214
+ modelId?: string;
215
+ modelProvider?: string;
216
+ idle?: boolean;
217
+ infoError?: string;
218
+ };
219
+
220
+ function abbreviateHome(cwd: string | undefined): string {
221
+ if (!cwd) return "(unknown)";
222
+ const home = os.homedir();
223
+ if (cwd === home) return "~";
224
+ if (cwd.startsWith(`${home}${path.sep}`)) return `~${cwd.slice(home.length)}`;
225
+ return cwd;
226
+ }
227
+
228
+ async function getLiveSessions(): Promise<LiveSessionInfo[]> {
229
+ await ensureControlDir();
230
+ const entries = await fs.readdir(ENTWURF_DIR, { withFileTypes: true });
231
+ const sessions: LiveSessionInfo[] = [];
232
+
233
+ for (const entry of entries) {
234
+ if (!entry.name.endsWith(SOCKET_SUFFIX)) continue;
235
+ const socketPath = path.join(ENTWURF_DIR, entry.name);
236
+ const alive = await isSocketAlive(socketPath);
237
+ if (!alive) continue;
238
+ const sessionId = entry.name.slice(0, -SOCKET_SUFFIX.length);
239
+ if (!isSafeSessionId(sessionId)) continue;
240
+ sessions.push({ sessionId, socketPath });
241
+ }
242
+
243
+ sessions.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
244
+ return sessions;
245
+ }
246
+
247
+ // Enrich each live session with cwd/model/idle by RPC-querying its socket.
248
+ // Per-session failures are surfaced as `infoError` so the operator sees
249
+ // exactly which session is unreachable instead of silently dropping it.
250
+ async function getLiveSessionsWithInfo(): Promise<EnrichedSession[]> {
251
+ const sessions = await getLiveSessions();
252
+ const enriched: EnrichedSession[] = [];
253
+ for (const session of sessions) {
254
+ try {
255
+ // Shared parse/RPC SSOT (lib/entwurf-control-rpc.ts) — a `!success` reply
256
+ // throws there, so it lands in the same catch and surfaces as `infoError`
257
+ // (behavior-preserving: the message is still `response.error ?? "get_info failed"`).
258
+ const info = await fetchControlSocketRuntimeInfo(session.socketPath, { timeout: 1500 });
259
+ enriched.push({
260
+ ...session,
261
+ cwd: info.cwd,
262
+ modelId: info.modelId,
263
+ modelProvider: info.modelProvider,
264
+ idle: info.idle,
265
+ });
266
+ } catch (e) {
267
+ enriched.push({
268
+ ...session,
269
+ infoError: e instanceof Error ? e.message : String(e),
270
+ });
271
+ }
272
+ }
273
+ return enriched;
274
+ }
275
+
276
+ function writeResponse(socket: net.Socket, response: RpcResponse): void {
277
+ try {
278
+ socket.write(`${JSON.stringify(response)}\n`);
279
+ } catch {
280
+ // Socket may be closed
281
+ }
282
+ }
283
+
284
+ function parseCommand(line: string): { command?: RpcCommand; error?: string } {
285
+ try {
286
+ const parsed = JSON.parse(line) as RpcCommand;
287
+ if (!parsed || typeof parsed !== "object") {
288
+ return { error: "Invalid command" };
289
+ }
290
+ if (typeof parsed.type !== "string") {
291
+ return { error: "Missing command type" };
292
+ }
293
+ return { command: parsed };
294
+ } catch (error) {
295
+ return { error: error instanceof Error ? error.message : "Failed to parse command" };
296
+ }
297
+ }
298
+
299
+ // ============================================================================
300
+ // Message Extraction
301
+ // ============================================================================
302
+
303
+ interface ExtractedMessage {
304
+ role: "user" | "assistant";
305
+ content: string;
306
+ timestamp: number;
307
+ }
308
+
309
+ function getLastAssistantMessage(ctx: ExtensionContext): ExtractedMessage | undefined {
310
+ const branch = ctx.sessionManager.getBranch();
311
+
312
+ for (let i = branch.length - 1; i >= 0; i--) {
313
+ const entry = branch[i];
314
+ if (entry.type === "message") {
315
+ const msg = entry.message;
316
+ if ("role" in msg && msg.role === "assistant") {
317
+ const textParts = msg.content
318
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
319
+ .map((c) => c.text);
320
+ if (textParts.length > 0) {
321
+ return {
322
+ role: "assistant",
323
+ content: textParts.join("\n"),
324
+ timestamp: msg.timestamp,
325
+ };
326
+ }
327
+ }
328
+ }
329
+ }
330
+ return undefined;
331
+ }
332
+
333
+ function getFirstEntryId(ctx: ExtensionContext): string | undefined {
334
+ const entries = ctx.sessionManager.getEntries();
335
+ if (entries.length === 0) return undefined;
336
+ const root = entries.find((e) => e.parentId === null);
337
+ return root?.id ?? entries[0]?.id;
338
+ }
339
+
340
+ function extractTextContent(content: string | Array<TextContent | { type: string }>): string {
341
+ if (typeof content === "string") return content;
342
+ return content
343
+ .filter((c): c is TextContent => c.type === "text")
344
+ .map((c) => c.text)
345
+ .join("\n");
346
+ }
347
+
348
+ function stripSenderInfo(text: string): string {
349
+ return text.replace(SENDER_INFO_PATTERN, "").trim();
350
+ }
351
+
352
+ // Sender envelope — what the <sender_info> JSON inside an entwurf-message carries.
353
+ //
354
+ // Addressing is sessionId-only (see header). Envelope fields are display-only —
355
+ // they never feed address resolution. The fully attributed shape lets the
356
+ // operator immediately see WHO sent (agentId, sessionId), FROM WHERE (cwd),
357
+ // and WHEN (timestamp). cwd anchors "which 담당자 is this" to the physical
358
+ // workspace rather than a free-form alias. agentId is the single identity
359
+ // field ("entwurf/<model>"); different school × model = different agent,
360
+ // never split into two fields.
361
+ //
362
+ // wants_reply is an etiquette marker (default false). It is NOT a transport
363
+ // contract — no wait, no polling, no delivery tracking. The sender is saying
364
+ // "this is a conversational message, please respond when you can"; the
365
+ // receiver renders a small "(wants reply)" badge so the human at either end
366
+ // can see it at a glance. Default false because most peer messages are
367
+ // notifications/handoffs/forwards — an always-true default would degrade
368
+ // into ack spam. The receiving model decides whether to reply based on the
369
+ // message itself, not on this flag; the flag only surfaces intent.
370
+ //
371
+ // Envelope fields (sessionId / agentId / cwd / timestamp) are mandatory at
372
+ // send time. A missing envelope field means wiring is broken (no
373
+ // PI_AGENT_ID inject from acp-bridge.ts, no PI_SESSION_ID from
374
+ // entwurf-control, MCP child detached from pi process.env, …). Crash-loud:
375
+ // throw at entwurf_v2, reject at handleCommand("send"). wants_reply is
376
+ // not part of the wiring check — its absence just means "no etiquette
377
+ // marker", which is the default and not an error. Silent fallback for
378
+ // envelope fields is banned — see AGENTS.md Code Principle "Never warn. Throw."
379
+ interface SenderInfo {
380
+ sessionId?: string;
381
+ agentId?: string;
382
+ cwd?: string;
383
+ timestamp?: string; // ISO 8601 UTC; rendered in KST
384
+ wants_reply?: boolean;
385
+ origin?: "pi-session" | "external-mcp" | "meta-session";
386
+ replyable?: boolean;
387
+ }
388
+
389
+ function parseSenderInfo(text: string): SenderInfo | null {
390
+ const match = text.match(/<sender_info>([\s\S]*?)<\/sender_info>/);
391
+ if (!match) return null;
392
+ const raw = match[1].trim();
393
+ if (!raw) return null;
394
+
395
+ if (raw.startsWith("{")) {
396
+ try {
397
+ const parsed = JSON.parse(raw) as {
398
+ sessionId?: unknown;
399
+ agentId?: unknown;
400
+ cwd?: unknown;
401
+ timestamp?: unknown;
402
+ wants_reply?: unknown;
403
+ origin?: unknown;
404
+ replyable?: unknown;
405
+ // Legacy field — pre-rename transcripts may carry reply_requested
406
+ // in the JSON. Accept as fallback so old payloads still render
407
+ // the badge correctly. Removed from the send-side schema.
408
+ reply_requested?: unknown;
409
+ };
410
+ const pickString = (v: unknown): string | undefined => {
411
+ if (typeof v !== "string") return undefined;
412
+ const t = v.trim();
413
+ return t.length > 0 ? t : undefined;
414
+ };
415
+ const wantsReplyRaw =
416
+ typeof parsed.wants_reply === "boolean"
417
+ ? parsed.wants_reply
418
+ : typeof parsed.reply_requested === "boolean"
419
+ ? parsed.reply_requested
420
+ : undefined;
421
+ const originRaw = pickString(parsed.origin);
422
+ const info: SenderInfo = {
423
+ sessionId: pickString(parsed.sessionId),
424
+ agentId: pickString(parsed.agentId),
425
+ cwd: pickString(parsed.cwd),
426
+ timestamp: pickString(parsed.timestamp),
427
+ wants_reply: wantsReplyRaw,
428
+ origin:
429
+ originRaw === "pi-session" || originRaw === "external-mcp" || originRaw === "meta-session"
430
+ ? originRaw
431
+ : undefined,
432
+ replyable: typeof parsed.replyable === "boolean" ? parsed.replyable : undefined,
433
+ };
434
+ // Return only when at least one field carries a value; otherwise let
435
+ // the caller render the unadorned label rather than a phantom header.
436
+ if (
437
+ info.sessionId ||
438
+ info.agentId ||
439
+ info.cwd ||
440
+ info.timestamp ||
441
+ info.wants_reply !== undefined ||
442
+ info.origin ||
443
+ info.replyable !== undefined
444
+ ) {
445
+ return info;
446
+ }
447
+ } catch {
448
+ // Ignore JSON parse errors, fall back to legacy parsing.
449
+ }
450
+ }
451
+
452
+ // Legacy: pre-envelope notes left bare "session <uuid>" in the payload.
453
+ // Keep parsing so old transcripts still render the sender id.
454
+ const legacyIdMatch = raw.match(/session\s+([a-f0-9-]{6,})/i);
455
+ if (legacyIdMatch) {
456
+ return { sessionId: legacyIdMatch[1] };
457
+ }
458
+
459
+ return null;
460
+ }
461
+
462
+ // Build a sender envelope for messages originating from the local pi session.
463
+ // Used by every caller-side send path (the mcp + pi-native entwurf_v2
464
+ // surfaces). Returns undefined when any field
465
+ // cannot be resolved — pi-native callers should fall back to body-less sends
466
+ // rather than synthesize partial envelopes that would render as "(unknown ...)"
467
+ // at the receiver. The MCP-side bridge (mcp/entwurf-bridge entwurf_v2) is
468
+ // strict — it throws when its own env wiring is incomplete — because it
469
+ // represents the public transparency contract.
470
+ //
471
+ // agentId preference order:
472
+ // 1. PI_AGENT_ID env (set by updateSessionEnv as `<ctx.model.provider>/<ctx.model.id>`)
473
+ // 2. `<ctx.model.provider>/<ctx.model.id>` reconstructed from the live pi context
474
+ // 3. undefined → envelope omitted
475
+ function buildLocalSenderEnvelope(ctx: ExtensionContext): SenderEnvelope | undefined {
476
+ const sessionId = ctx.sessionManager.getSessionId();
477
+ if (!sessionId) return undefined;
478
+ const cwd = ctx.cwd;
479
+ if (!cwd) return undefined;
480
+ const envAgent = process.env.PI_AGENT_ID?.trim();
481
+ const ctxAgent = ctx.model?.provider && ctx.model?.id ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
482
+ const agentId = envAgent && envAgent.length > 0 ? envAgent : ctxAgent;
483
+ if (!agentId) return undefined;
484
+ return {
485
+ sessionId,
486
+ agentId,
487
+ cwd,
488
+ timestamp: new Date().toISOString(),
489
+ };
490
+ }
491
+
492
+ // Format a UTC ISO timestamp as `YYYY-MM-DD HH:MM:SS KST`. We avoid pulling
493
+ // Intl into the hot render path — it's heavy and locale-fragile — and instead
494
+ // compute KST manually (UTC+9, no DST). Returns the raw input unchanged when
495
+ // the parse fails so the operator at least sees the original string.
496
+ function formatTimestampKst(iso: string | undefined): string | undefined {
497
+ if (!iso) return undefined;
498
+ const ms = Date.parse(iso);
499
+ if (Number.isNaN(ms)) return iso;
500
+ const kst = new Date(ms + 9 * 60 * 60 * 1000);
501
+ const pad = (n: number) => n.toString().padStart(2, "0");
502
+ const y = kst.getUTCFullYear();
503
+ const mo = pad(kst.getUTCMonth() + 1);
504
+ const d = pad(kst.getUTCDate());
505
+ const h = pad(kst.getUTCHours());
506
+ const mi = pad(kst.getUTCMinutes());
507
+ const s = pad(kst.getUTCSeconds());
508
+ return `${y}-${mo}-${d} ${h}:${mi}:${s} KST`;
509
+ }
510
+
511
+ const renderSessionMessage: MessageRenderer = (message, { expanded }, theme) => {
512
+ const rawContent = extractTextContent(message.content);
513
+ const senderInfo = parseSenderInfo(rawContent);
514
+ let text = stripSenderInfo(rawContent);
515
+ if (!text) text = "(no content)";
516
+
517
+ if (!expanded) {
518
+ const lines = text.split("\n");
519
+ if (lines.length > 5) {
520
+ text = `${lines.slice(0, 5).join("\n")}\n...`;
521
+ }
522
+ }
523
+
524
+ // Build the header lines. Missing envelope fields are rendered as
525
+ // "(unknown ...)" so wiring breaks are visible rather than hidden —
526
+ // transparency over silence.
527
+ //
528
+ // The label is "[entwurf received ⟵]" with a left-pointing arrow so the
529
+ // receiving operator immediately sees the directionality (this is an
530
+ // incoming message). The corresponding sender-side surface in
531
+ // mcp/entwurf-bridge/entwurf_v2 renders "[entwurf sent →]" — same
532
+ // transport, opposite arrows, no confusion about who-said-what when the
533
+ // transcript is read end-to-end.
534
+ //
535
+ // wants_reply defaults to false (etiquette marker, not protocol contract).
536
+ // We show the badge only when the sender explicitly set it true; an
537
+ // undefined or false value omits the badge entirely. This keeps routine
538
+ // peer messages quiet and reserves the badge for messages where the sender
539
+ // genuinely wants a conversational response back.
540
+ const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
541
+ const labelBase = theme.fg("customMessageLabel", `\x1b[1m[entwurf received ⟵]\x1b[22m`);
542
+
543
+ if (senderInfo) {
544
+ const kst = formatTimestampKst(senderInfo.timestamp) ?? "(unknown time)";
545
+ const replyBadge = senderInfo.wants_reply === true ? " (wants reply)" : "";
546
+ const headerLine = `${labelBase} ${theme.fg("dim", `${kst}${replyBadge}`)}`;
547
+ box.addChild(new Text(headerLine, 0, 0));
548
+
549
+ const agentId = senderInfo.agentId ?? "(unknown agent)";
550
+ const cwd = senderInfo.cwd ? abbreviateHome(senderInfo.cwd) : "(unknown cwd)";
551
+ const originBadge =
552
+ senderInfo.origin === "external-mcp"
553
+ ? " [external MCP]"
554
+ : senderInfo.origin === "meta-session"
555
+ ? " [meta-session]"
556
+ : "";
557
+ box.addChild(new Text(theme.fg("dim", `from: ${agentId} @ ${cwd}${originBadge}`), 0, 0));
558
+
559
+ const sessionId = senderInfo.sessionId ?? "(unknown sessionId)";
560
+ const replyable = senderInfo.replyable === false ? " (non-replyable)" : "";
561
+ box.addChild(new Text(theme.fg("dim", `sessionId: ${sessionId}${replyable}`), 0, 0));
562
+ } else {
563
+ box.addChild(new Text(labelBase, 0, 0));
564
+ }
565
+
566
+ box.addChild(new Spacer(1));
567
+ box.addChild(
568
+ new Markdown(text, 0, 0, getMarkdownTheme(), {
569
+ color: (value: string) => theme.fg("customMessageText", value),
570
+ }),
571
+ );
572
+ return box;
573
+ };
574
+
575
+ // Sender-side payload — what renderSentMessage needs to draw the [entwurf sent →]
576
+ // box. Carried verbatim by both Layer A (native renderResult) and Layer B
577
+ // (CustomMessage details for ACP path). All four envelope fields are intentionally
578
+ // echoed in the box even though the sender is "this same session" — operators
579
+ // reading a busy multi-session transcript should be able to verify at a glance
580
+ // which 담당자 is on the wire (cwd) and which model identity (agentId) actually
581
+ // signed the message, without scrolling up to find the session header.
582
+ //
583
+ // timestamp is captured at execute() / send-emit time, not at render time, so
584
+ // re-renders (resize, expand toggle) keep showing the moment the message was
585
+ // actually delivered rather than drifting forward to "now".
586
+ //
587
+ // wants_reply mirrors the receive-side etiquette badge. Native schema does not
588
+ // yet expose it (see registerSessionTool's entwurfSendParameters); leave undefined
589
+ // from the native call site until the schema grows the field.
590
+ interface SentBoxData {
591
+ to: string; // target sessionId
592
+ from?: string; // sender agentId, e.g. "entwurf/claude-opus-4-8"
593
+ cwd?: string; // sender cwd (raw, abbreviateHome applied at render)
594
+ timestamp?: string; // ISO 8601 UTC; rendered in KST
595
+ mode?: string; // "steer" | "follow_up" | string passed through
596
+ wants_reply?: boolean;
597
+ deliveredAs?: string; // RPC echo — surfaces when receiver remapped (e.g. queued as followUp)
598
+ body: string; // message text the operator sent
599
+ }
600
+
601
+ // Visual mirror of renderSessionMessage. Same Box / Markdown / theme tokens —
602
+ // the two boxes must share the customMessageBg / customMessageLabel /
603
+ // customMessageText surface so they are pixel-equivalent in any theme. The
604
+ // only deliberate visual differences are:
605
+ // - label: [entwurf sent →] vs [entwurf received ⟵]
606
+ // - "to:" leads, "from:" follows vs "from:" only
607
+ // - mode: line (no equivalent on receive side — receiver
608
+ // doesn't see how the sender queued it)
609
+ //
610
+ // `expanded` truncates the body the same way as renderSessionMessage so a
611
+ // large send shows the same preview shape as a large receive. operators
612
+ // reading the transcript should not need different mental models.
613
+ const buildSentMessageBox = (data: SentBoxData, expanded: boolean, theme: Theme): Container => {
614
+ let body = data.body || "(no content)";
615
+ if (!expanded) {
616
+ const lines = body.split("\n");
617
+ if (lines.length > 5) {
618
+ body = `${lines.slice(0, 5).join("\n")}\n...`;
619
+ }
620
+ }
621
+
622
+ const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
623
+ const labelBase = theme.fg("customMessageLabel", `\x1b[1m[entwurf sent →]\x1b[22m`);
624
+
625
+ // Header line: label + KST + optional (wants reply) badge — matches the
626
+ // receive-side header layout 1:1.
627
+ const kst = formatTimestampKst(data.timestamp) ?? "(unknown time)";
628
+ const replyBadge = data.wants_reply === true ? " (wants reply)" : "";
629
+ const headerLine = `${labelBase} ${theme.fg("dim", `${kst}${replyBadge}`)}`;
630
+ box.addChild(new Text(headerLine, 0, 0));
631
+
632
+ // to: <sessionId> — target peer
633
+ box.addChild(new Text(theme.fg("dim", `to: ${data.to || "(unknown sessionId)"}`), 0, 0));
634
+
635
+ // from: <agentId> @ <cwd> — self identity. Shown even though it's "us"
636
+ // because in a multi-session human-greeted topology the operator is
637
+ // switching between several pi sessions and needs to confirm which one
638
+ // signed this send.
639
+ const fromAgent = data.from ?? "(unknown agent)";
640
+ const fromCwd = data.cwd ? abbreviateHome(data.cwd) : "(unknown cwd)";
641
+ box.addChild(new Text(theme.fg("dim", `from: ${fromAgent} @ ${fromCwd}`), 0, 0));
642
+
643
+ // mode: <mode>[ → deliveredAs] — show RPC remap when it differs from
644
+ // what the caller asked for (e.g. caller said "steer" but receiver was
645
+ // idle so it became a direct prompt). Silent when they agree.
646
+ if (data.mode) {
647
+ const remap = data.deliveredAs && data.deliveredAs !== data.mode ? theme.fg("muted", ` → ${data.deliveredAs}`) : "";
648
+ box.addChild(new Text(theme.fg("dim", `mode: ${data.mode}${remap}`), 0, 0));
649
+ }
650
+
651
+ box.addChild(new Spacer(1));
652
+ box.addChild(
653
+ new Markdown(body, 0, 0, getMarkdownTheme(), {
654
+ color: (value: string) => theme.fg("customMessageText", value),
655
+ }),
656
+ );
657
+ return box;
658
+ };
659
+
660
+ // CustomMessageRenderer adapter for Layer B (ACP path). The CustomMessage
661
+ // carries the SentBoxData under `details` (set by index.ts streamShellAcp
662
+ // when a completed mcp__entwurf-bridge__entwurf_v2 is observed). `content`
663
+ // holds the raw message body too, but we prefer details.body because the
664
+ // content channel may have been routed through string-only persistence and
665
+ // trimmed.
666
+ const renderSentMessage: MessageRenderer = (message, { expanded }, theme) => {
667
+ const details = (message.details ?? {}) as Partial<SentBoxData>;
668
+ const fallbackBody = extractTextContent(message.content);
669
+ const data: SentBoxData = {
670
+ to: details.to ?? "(unknown sessionId)",
671
+ from: details.from,
672
+ cwd: details.cwd,
673
+ timestamp: details.timestamp,
674
+ mode: details.mode,
675
+ wants_reply: details.wants_reply,
676
+ deliveredAs: details.deliveredAs,
677
+ body: details.body ?? fallbackBody,
678
+ };
679
+ return buildSentMessageBox(data, expanded, theme);
680
+ };
681
+
682
+ // ============================================================================
683
+ // Command Handlers
684
+ // ============================================================================
685
+
686
+ async function handleCommand(
687
+ pi: ExtensionAPI,
688
+ state: SocketState,
689
+ command: RpcCommand,
690
+ socket: net.Socket,
691
+ ): Promise<void> {
692
+ const id = "id" in command && typeof command.id === "string" ? command.id : undefined;
693
+ const respond = (success: boolean, commandName: string, data?: unknown, error?: string) => {
694
+ writeResponse(socket, { type: "response", command: commandName, success, data, error, id });
695
+ };
696
+
697
+ const ctx = state.context;
698
+ if (!ctx) {
699
+ respond(false, command.type, undefined, "Session not ready");
700
+ return;
701
+ }
702
+
703
+ // Abort
704
+ if (command.type === "abort") {
705
+ ctx.abort();
706
+ respond(true, "abort");
707
+ return;
708
+ }
709
+
710
+ // Get last message
711
+ if (command.type === "get_message") {
712
+ const message = getLastAssistantMessage(ctx);
713
+ if (!message) {
714
+ respond(true, "get_message", { message: null });
715
+ return;
716
+ }
717
+ respond(true, "get_message", { message });
718
+ return;
719
+ }
720
+
721
+ // Get session metadata (cwd, model, idle) — used by /entwurf-sessions enrichment.
722
+ if (command.type === "get_info") {
723
+ const sessionId = ctx.sessionManager.getSessionId();
724
+ const modelInfo = ctx.model ? { id: ctx.model.id, provider: ctx.model.provider } : null;
725
+ respond(true, "get_info", {
726
+ sessionId,
727
+ cwd: ctx.cwd,
728
+ model: modelInfo,
729
+ idle: ctx.isIdle(),
730
+ });
731
+ return;
732
+ }
733
+
734
+ // Clear session
735
+ if (command.type === "clear") {
736
+ if (!ctx.isIdle()) {
737
+ respond(false, "clear", undefined, "Session is busy - wait for turn to complete");
738
+ return;
739
+ }
740
+
741
+ const firstEntryId = getFirstEntryId(ctx);
742
+ if (!firstEntryId) {
743
+ respond(false, "clear", undefined, "No entries in session");
744
+ return;
745
+ }
746
+
747
+ const currentLeafId = ctx.sessionManager.getLeafId();
748
+ if (currentLeafId === firstEntryId) {
749
+ respond(true, "clear", { cleared: true, alreadyAtRoot: true });
750
+ return;
751
+ }
752
+
753
+ if (command.summarize) {
754
+ // Summarization requires navigateTree which we don't have direct access to
755
+ // Return an error for now - the caller should clear without summarize
756
+ // or use a different approach
757
+ respond(false, "clear", undefined, "Clear with summarization not supported via RPC - use summarize=false");
758
+ return;
759
+ }
760
+
761
+ // Access internal session manager to rewind (type assertion to access non-readonly methods)
762
+ try {
763
+ const sessionManager = ctx.sessionManager as unknown as { rewindTo(id: string): void };
764
+ sessionManager.rewindTo(firstEntryId);
765
+ respond(true, "clear", { cleared: true, targetId: firstEntryId });
766
+ } catch (error) {
767
+ respond(false, "clear", undefined, error instanceof Error ? error.message : "Clear failed");
768
+ }
769
+ return;
770
+ }
771
+
772
+ // Send message
773
+ if (command.type === "send") {
774
+ const message = command.message;
775
+ if (typeof message !== "string" || message.trim().length === 0) {
776
+ respond(false, "send", undefined, "Missing message");
777
+ return;
778
+ }
779
+
780
+ // Validate sender envelope when present. All four fields are mandatory —
781
+ // any single absence is a wiring break (no PI_AGENT_ID, no PI_SESSION_ID,
782
+ // detached MCP child, …) and must surface immediately rather than render
783
+ // as "(unknown ...)" on the receiver side. Transparency over silence.
784
+ //
785
+ // When sender is omitted entirely we accept the send (a fallback for
786
+ // non-bridge paths or future surfaces that haven't been migrated yet) but
787
+ // the renderer will just show the bare label — the operator will notice
788
+ // the missing header. The entwurf-bridge entwurf_v2 already throws
789
+ // when its env is incomplete, so the common bridge path never reaches
790
+ // this `sender === undefined` branch.
791
+ const sender = command.sender;
792
+ if (sender !== undefined) {
793
+ const missing: string[] = [];
794
+ if (!sender || typeof sender !== "object") {
795
+ respond(false, "send", undefined, "sender must be an object");
796
+ return;
797
+ }
798
+ if (typeof sender.sessionId !== "string" || sender.sessionId.trim().length === 0) missing.push("sessionId");
799
+ if (typeof sender.agentId !== "string" || sender.agentId.trim().length === 0) missing.push("agentId");
800
+ if (typeof sender.cwd !== "string" || sender.cwd.trim().length === 0) missing.push("cwd");
801
+ if (typeof sender.timestamp !== "string" || sender.timestamp.trim().length === 0) missing.push("timestamp");
802
+ if (missing.length > 0) {
803
+ respond(false, "send", undefined, `sender envelope missing required field(s): ${missing.join(", ")}`);
804
+ return;
805
+ }
806
+ }
807
+
808
+ // wants_reply defaults to false (etiquette marker, not transport contract).
809
+ // It surfaces a "(wants reply)" badge on the receiver render so the
810
+ // human/agent at either end sees that the sender wants a conversational
811
+ // response; there is no wait, no poll, no delivery tracking. Most peer
812
+ // messages (notifications, handoff packets, status pings) leave this
813
+ // unset — an always-true default would degrade into ack spam. Whether
814
+ // the receiver actually replies is decided by the message body, not by
815
+ // this flag.
816
+ const wantsReply = typeof command.wants_reply === "boolean" ? command.wants_reply : false;
817
+
818
+ // Synthesize <sender_info> JSON at the receiver side. Caller code paths
819
+ // (entwurf-bridge entwurf_v2, the pi-native entwurf_v2 senderProvider via buildLocalSenderEnvelope)
820
+ // pass the envelope structurally and never touch the message body — the
821
+ // canonical XML-style payload is constructed here once. We emit
822
+ // wants_reply only when the sender explicitly set it true; an undefined
823
+ // or false value omits the field entirely so the receiver renders nothing.
824
+ const senderInfoBlock = sender
825
+ ? `\n\n<sender_info>${JSON.stringify({
826
+ sessionId: sender.sessionId,
827
+ agentId: sender.agentId,
828
+ cwd: sender.cwd,
829
+ timestamp: sender.timestamp,
830
+ ...(sender.origin ? { origin: sender.origin } : {}),
831
+ ...(typeof sender.replyable === "boolean" ? { replyable: sender.replyable } : {}),
832
+ ...(wantsReply ? { wants_reply: true } : {}),
833
+ })}</sender_info>`
834
+ : "";
835
+
836
+ const mode = command.mode ?? "steer";
837
+ const isIdle = ctx.isIdle();
838
+ const customMessage = {
839
+ customType: SESSION_MESSAGE_TYPE,
840
+ content: message + senderInfoBlock,
841
+ display: true,
842
+ };
843
+
844
+ // Crash-loud: a pi.sendMessage throw (queue refusal, internal invariant
845
+ // violation, …) used to fall through unhandled and silently drop the
846
+ // connection — the caller would see only a vague timeout. We catch and
847
+ // surface the failure on the RPC channel so the sender knows the message
848
+ // did NOT enter the receiver's queue.
849
+ try {
850
+ if (isIdle) {
851
+ pi.sendMessage(customMessage, { triggerTurn: true });
852
+ } else {
853
+ pi.sendMessage(customMessage, {
854
+ triggerTurn: true,
855
+ deliverAs: mode === "follow_up" ? "followUp" : "steer",
856
+ });
857
+ }
858
+ } catch (error) {
859
+ const msg = error instanceof Error ? error.message : String(error);
860
+ respond(false, "send", undefined, `pi.sendMessage failed: ${msg}`);
861
+ return;
862
+ }
863
+
864
+ respond(true, "send", {
865
+ delivered: true,
866
+ deliveredAs: isIdle ? "direct" : mode === "follow_up" ? "followUp" : "steer",
867
+ wants_reply: wantsReply,
868
+ });
869
+ return;
870
+ }
871
+
872
+ // Defensive fallback. After the exhaustive RpcCommand chain above the
873
+ // `command` local is narrowed to `never` at the type level, but at runtime
874
+ // we may still receive a JSON object whose `type` is a string we don't
875
+ // recognise (peer-protocol drift, malformed client). Cast through a
876
+ // runtime-only shape so we still surface the unknown command name.
877
+ const unknownType = (command as unknown as { type?: string }).type ?? "unknown";
878
+ respond(false, unknownType, undefined, `Unsupported command: ${unknownType}`);
879
+ }
880
+
881
+ // ============================================================================
882
+ // Server Management
883
+ // ============================================================================
884
+
885
+ async function createServer(pi: ExtensionAPI, state: SocketState, socketPath: string): Promise<net.Server> {
886
+ const server = net.createServer((socket) => {
887
+ socket.setEncoding("utf8");
888
+ let buffer = "";
889
+ socket.on("data", (chunk) => {
890
+ buffer += chunk;
891
+ let newlineIndex = buffer.indexOf("\n");
892
+ while (newlineIndex !== -1) {
893
+ const line = buffer.slice(0, newlineIndex).trim();
894
+ buffer = buffer.slice(newlineIndex + 1);
895
+ newlineIndex = buffer.indexOf("\n");
896
+ if (!line) continue;
897
+
898
+ const parsed = parseCommand(line);
899
+ if (parsed.error) {
900
+ writeResponse(socket, {
901
+ type: "response",
902
+ command: "parse",
903
+ success: false,
904
+ error: `Failed to parse command: ${parsed.error}`,
905
+ });
906
+ continue;
907
+ }
908
+
909
+ // handleCommand is async; without explicit catch the rejection floats
910
+ // silently and the client only sees a 5-minute timeout (no response,
911
+ // no jsonl persist trace on the receiver). Surface any handler
912
+ // failure as an explicit error response so callers can distinguish
913
+ // "handler exploded" from "wait timed out". Parallel execution
914
+ // across the same connection is preserved — we do not await —
915
+ // because current RPC commands are single-response operations and
916
+ // do not require strict per-socket serialization. If a future
917
+ // multi-command dependency appears, add an explicit per-socket
918
+ // command queue rather than awaiting in this data handler — awaiting
919
+ // here would serialize subsequent commands on the same socket and
920
+ // tangle teardown ordering during socket close.
921
+ const commandName = parsed.command?.type ?? "unknown";
922
+ void handleCommand(pi, state, parsed.command!, socket).catch((error) => {
923
+ const message = error instanceof Error ? error.message : String(error);
924
+ writeResponse(socket, {
925
+ type: "response",
926
+ command: commandName,
927
+ success: false,
928
+ error: `handler failed: ${message}`,
929
+ });
930
+ });
931
+ }
932
+ });
933
+ });
934
+
935
+ // Wait for server to start listening, with error handling
936
+ await new Promise<void>((resolve, reject) => {
937
+ server.once("error", reject);
938
+ server.listen(socketPath, () => {
939
+ server.removeListener("error", reject);
940
+ resolve();
941
+ });
942
+ });
943
+
944
+ return server;
945
+ }
946
+
947
+ // The RPC client `sendRpcCommand` + `RpcClientOptions` now live in
948
+ // lib/entwurf-control-rpc.ts (imported above) — see the RPC Types note for why.
949
+
950
+ async function startControlServer(pi: ExtensionAPI, state: SocketState, ctx: ExtensionContext): Promise<void> {
951
+ await ensureControlDir();
952
+ await gcStaleSockets();
953
+ const sessionId = ctx.sessionManager.getSessionId();
954
+ const socketPath = getSocketPath(sessionId);
955
+
956
+ if (state.socketPath === socketPath && state.server) {
957
+ state.context = ctx;
958
+ return;
959
+ }
960
+
961
+ await stopControlServer(state);
962
+ await removeSocket(socketPath);
963
+
964
+ state.context = ctx;
965
+ state.socketPath = socketPath;
966
+ state.server = await createServer(pi, state, socketPath);
967
+ }
968
+
969
+ async function stopControlServer(state: SocketState): Promise<void> {
970
+ if (!state.server) {
971
+ await removeSocket(state.socketPath);
972
+ state.socketPath = null;
973
+ return;
974
+ }
975
+
976
+ const socketPath = state.socketPath;
977
+ state.socketPath = null;
978
+ await new Promise<void>((resolve) => state.server?.close(() => resolve()));
979
+ state.server = null;
980
+ await removeSocket(socketPath);
981
+ }
982
+
983
+ function updateStatus(ctx: ExtensionContext | null, enabled: boolean): void {
984
+ if (!ctx?.hasUI) return;
985
+ if (!enabled) {
986
+ ctx.ui.setStatus(STATUS_KEY, undefined);
987
+ return;
988
+ }
989
+ // Screwdriver (🪛) label, NOT the word "entwurf" — the status label is a UI
990
+ // affordance for the resident session and must not be confused with the
991
+ // `entwurf` session-name tag (the Entwurf resume marker). The garden id shows
992
+ // only once the session file exists (= first assistant turn = model locked);
993
+ // before that it reads `🪛 ready` (model still changeable). See
994
+ // computeResidentStatusLabel.
995
+ const sessionId = ctx.sessionManager.getSessionId();
996
+ const sessionFile = ctx.sessionManager.getSessionFile();
997
+ const sessionFileExists = !!sessionFile && existsSync(sessionFile);
998
+ ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("dim", computeResidentStatusLabel({ sessionId, sessionFileExists })));
999
+ }
1000
+
1001
+ /**
1002
+ * Set the resident session's garden name ONCE, on the first turn that has
1003
+ * written the session file. Spawn-only-name rule: a session already carrying a
1004
+ * canonical garden name (resume) is left untouched. New operator residents are
1005
+ * named with the `control` tag, never `entwurf`. Existing `entwurf`-tagged names
1006
+ * are accepted ONLY for a sessionId-bound v2 spawn-bg resume child (an authorized
1007
+ * Entwurf child resident, left unchanged so it stays re-resumable) — see the tag
1008
+ * branch below. Title slug is the cwd basename (home → `home`); a Korean first
1009
+ * message would ASCII-slugify to `untitled`, so cwd is the stable choice.
1010
+ */
1011
+ function maybeSetResidentName(pi: ExtensionAPI, ctx: ExtensionContext): void {
1012
+ const sessionId = ctx.sessionManager.getSessionId();
1013
+ if (!isValidSessionId(sessionId)) return; // non-garden id is handled (shutdown) by the guard
1014
+ const sessionFile = ctx.sessionManager.getSessionFile();
1015
+ if (!sessionFile || !existsSync(sessionFile)) return; // file not written yet (pre first assistant turn)
1016
+ const existing = ctx.sessionManager.getSessionName();
1017
+ const parsedExisting = existing ? parseSessionName(existing) : null;
1018
+ if (parsedExisting) {
1019
+ // A canonical name whose id mirror disagrees with the header id is an invariant
1020
+ // breach — always a crash (never a cosmetic naming choice).
1021
+ if (parsedExisting.sessionId !== sessionId) {
1022
+ process.stderr.write(`[entwurf-control] corrupt resident session name: ${existing}\n`);
1023
+ process.exit(1);
1024
+ }
1025
+ // The `entwurf` tag is the Entwurf resume marker. An OPERATOR resident must never carry
1026
+ // it (it would advertise a live citizen as also dormant-resumable). The ONE exception is
1027
+ // a v2 spawn-bg resume promoting a dormant Entwurf session to a live resident: that child
1028
+ // SHOULD keep its `entwurf` tag (so it is re-resumable once it dies), and is authorized by
1029
+ // the sessionId-bound env marker its production launcher planted. Keep the name unchanged.
1030
+ if (parsedExisting.tags.includes("entwurf")) {
1031
+ if (isV2ResumeResidentAuthorized(sessionId)) return; // authorized Entwurf child resident
1032
+ process.stderr.write(`[entwurf-control] corrupt resident session name: ${existing}\n`);
1033
+ process.exit(1);
1034
+ }
1035
+ return; // already garden-named (resume) — do not re-set
1036
+ }
1037
+ const provider = ctx.model?.provider;
1038
+ const model = ctx.model?.id;
1039
+ if (!provider || !model) return; // model not resolved yet — a later turn will catch it
1040
+ const cwd = ctx.cwd || process.cwd();
1041
+ const cwdSlug = cwd === os.homedir() ? "home" : path.basename(cwd) || "home";
1042
+ try {
1043
+ pi.setSessionName(
1044
+ buildGardenSessionName({ sessionId, provider, model, rawTitle: cwdSlug, tags: [RESIDENT_SESSION_TAG] }),
1045
+ );
1046
+ } catch (err) {
1047
+ // Odd ctx.model chars (slash/`--`) would throw — log, never crash the
1048
+ // resident session over a display name.
1049
+ process.stderr.write(
1050
+ `[entwurf-control] resident garden name not set: ${err instanceof Error ? err.message : String(err)}\n`,
1051
+ );
1052
+ }
1053
+ }
1054
+
1055
+ function updateSessionEnv(ctx: ExtensionContext | null, enabled: boolean): void {
1056
+ if (!enabled) {
1057
+ delete process.env.PI_SESSION_ID;
1058
+ delete process.env.PI_AGENT_ID;
1059
+ return;
1060
+ }
1061
+ if (!ctx) return;
1062
+ process.env.PI_SESSION_ID = ctx.sessionManager.getSessionId();
1063
+ if (ctx.model?.provider && ctx.model?.id) {
1064
+ process.env.PI_AGENT_ID = `${ctx.model.provider}/${ctx.model.id}`;
1065
+ } else {
1066
+ delete process.env.PI_AGENT_ID;
1067
+ }
1068
+ }
1069
+
1070
+ // Extension factories run before extension flag values are hydrated into runtime.flagValues,
1071
+ // so we inspect argv directly when deciding whether to register tools at load time.
1072
+ function wasBooleanFlagPassed(flagName: string): boolean {
1073
+ const flag = `--${flagName}`;
1074
+ return process.argv.slice(2).includes(flag);
1075
+ }
1076
+
1077
+ // Read a string-valued CLI flag straight from argv. Handles `--flag value` and
1078
+ // `--flag=value`. Used as the argv fallback for the emacs socket flag because the
1079
+ // env application runs without a flag-hydration guarantee.
1080
+ function getStringFlagFromArgv(flagName: string): string | undefined {
1081
+ const flag = `--${flagName}`;
1082
+ const argv = process.argv.slice(2);
1083
+ for (let i = 0; i < argv.length; i++) {
1084
+ const arg = argv[i];
1085
+ if (arg === flag) {
1086
+ const next = argv[i + 1];
1087
+ return next && !next.startsWith("--") ? next.trim() || undefined : undefined;
1088
+ }
1089
+ if (arg.startsWith(`${flag}=`)) {
1090
+ return arg.slice(flag.length + 1).trim() || undefined;
1091
+ }
1092
+ }
1093
+ return undefined;
1094
+ }
1095
+
1096
+ // `--emacs-agent-socket <name>` exports PI_EMACS_AGENT_SOCKET so this session's
1097
+ // own Bash/emacsclient calls (and any spawn-bg child that inherits this env)
1098
+ // target the right Emacs server socket — e.g. `emacsclient -s "$PI_EMACS_AGENT_SOCKET"`.
1099
+ // v2-only revival: the original ACP path injected this into the ACP child's spawn
1100
+ // env (acp-bridge.ts); with no ACP child on this branch, the consumer IS this pi
1101
+ // resident, so we set process.env directly — symmetric with updateSessionEnv's
1102
+ // PI_SESSION_ID/PI_AGENT_ID. Resolved pi.getFlag-first, argv fallback, and applied
1103
+ // independently of --entwurf-control (the original flag was entwurf-control-agnostic).
1104
+ function applyEmacsAgentSocketEnv(pi: ExtensionAPI): void {
1105
+ const fromFlag = pi.getFlag(EMACS_AGENT_SOCKET_FLAG);
1106
+ const socket =
1107
+ typeof fromFlag === "string" && fromFlag.trim() ? fromFlag.trim() : getStringFlagFromArgv(EMACS_AGENT_SOCKET_FLAG);
1108
+ if (socket) {
1109
+ process.env.PI_EMACS_AGENT_SOCKET = socket;
1110
+ } else {
1111
+ delete process.env.PI_EMACS_AGENT_SOCKET;
1112
+ }
1113
+ }
1114
+
1115
+ function shouldRegisterControlTools(pi: ExtensionAPI): boolean {
1116
+ return pi.getFlag(ENTWURF_FLAG) === true || wasBooleanFlagPassed(ENTWURF_FLAG);
1117
+ }
1118
+
1119
+ // ============================================================================
1120
+ // Extension Export
1121
+ // ============================================================================
1122
+
1123
+ export default function (pi: ExtensionAPI) {
1124
+ pi.registerFlag(ENTWURF_FLAG, {
1125
+ description: "Enable per-session control socket under ~/.pi/entwurf-control",
1126
+ type: "boolean",
1127
+ });
1128
+ pi.registerFlag(EMACS_AGENT_SOCKET_FLAG, {
1129
+ description: "Optional Emacs server socket name for agent Emacs operations (exported as PI_EMACS_AGENT_SOCKET)",
1130
+ type: "string",
1131
+ });
1132
+ const state: SocketState = {
1133
+ server: null,
1134
+ socketPath: null,
1135
+ context: null,
1136
+ };
1137
+
1138
+ pi.registerMessageRenderer(SESSION_MESSAGE_TYPE, renderSessionMessage);
1139
+ // Layer B (ACP path) sender-side UI box. Registered unconditionally — even
1140
+ // in a session that is not exposing a control socket (no `--entwurf-control`),
1141
+ // an ACP backend may still use the MCP `entwurf_v2` to message OTHER
1142
+ // sessions. The renderer is needed here for the [entwurf sent →] box to
1143
+ // appear in this session's transcript when it sends.
1144
+ pi.registerMessageRenderer(ENTWURF_SENT_MESSAGE_TYPE, renderSentMessage);
1145
+
1146
+ if (shouldRegisterControlTools(pi)) {
1147
+ registerListSessionsTool(pi);
1148
+ registerEntwurfV2Tool(pi);
1149
+ }
1150
+ registerControlSessionsCommand(pi, () => {});
1151
+ registerGardenNewCommand(pi);
1152
+
1153
+ // Session-replacement identity invariant (0.9.0): under --entwurf-control you
1154
+ // cannot birth or enter a non-garden resident session IN-PROCESS. /new, /fork,
1155
+ // /clone, RPC new_session, ctx.newSession and keybindings all mint a pi
1156
+ // uuidv7 (no --session-id reaches an in-process switch, and the pre-switch
1157
+ // hook result carries only { cancel } — it cannot inject an id, which is
1158
+ // launch-fixed). Without this, such a mint reaches the session_start garden
1159
+ // guard and hard-exits the WHOLE pi process — a terrible UX for a routine
1160
+ // /new. So cancel the mint at the pre-event and point at the garden launcher.
1161
+ const refuseInProcessMint = (ctx: ExtensionContext, what: string, why: string) => {
1162
+ // Lead with the remedy: a TUI notify can truncate a long line, so the
1163
+ // actionable alternative (/gnew) must come BEFORE the technical why and the
1164
+ // shell-launcher fallback — otherwise a blocked /new looks like a dead end.
1165
+ const msg =
1166
+ `[entwurf-control] ${what} is blocked under --entwurf-control. ` +
1167
+ `Use /gnew (or /garden-new) for a same-terminal fresh garden session. ` +
1168
+ `(${why}) ` +
1169
+ `To launch/resume from a shell, use the garden launcher (pia / pit / pihome — they pass --session-id), ` +
1170
+ `or run pi --session-id "$(run.sh new-session-id)" --entwurf-control ...`;
1171
+ // stderr ALWAYS — the durable record even if the TUI swallows the notify.
1172
+ process.stderr.write(`${msg}\n`);
1173
+ if (ctx.hasUI) ctx.ui.notify(`🪛 ${msg}`, "error");
1174
+ };
1175
+
1176
+ const refreshServer = async (ctx: ExtensionContext) => {
1177
+ // --emacs-agent-socket is independent of --entwurf-control: export it
1178
+ // before the control-server branch so an Emacs frontend works even in a
1179
+ // non-control session.
1180
+ applyEmacsAgentSocketEnv(pi);
1181
+ const enabled = pi.getFlag(ENTWURF_FLAG) === true;
1182
+ if (!enabled) {
1183
+ await stopControlServer(state);
1184
+ updateStatus(ctx, false);
1185
+ updateSessionEnv(ctx, false);
1186
+ return;
1187
+ }
1188
+ // Garden-native enforcement (0.9.0): a resident --entwurf-control session
1189
+ // MUST have a garden header id. pi assigns a uuidv7 when the launcher did
1190
+ // not pass --session-id, which means this session was not born through the
1191
+ // garden launcher. No back-compat path. A bare throw in this session_start
1192
+ // handler is swallowed by the extension runner (runner.ts try/catch →
1193
+ // emitError), so escalate explicitly: refuse the control server, do not
1194
+ // leak a uuid into PI_SESSION_ID, loud-notify, and shut pi down.
1195
+ const sessionId = ctx.sessionManager.getSessionId();
1196
+ try {
1197
+ assertGardenNativeSessionId(sessionId);
1198
+ } catch (err) {
1199
+ const reason = err instanceof Error ? err.message : String(err);
1200
+ // stderr ALWAYS: process.exit truncates TUI rendering, so this is the
1201
+ // durable record of why the session refused to start.
1202
+ process.stderr.write(`[entwurf-control] ${reason}\n`);
1203
+ if (ctx.hasUI) ctx.ui.notify(`🪛 ${reason}`, "error");
1204
+ // ctx.shutdown() alone does NOT stop the in-flight startup — verified
1205
+ // live: the model turn still ran (26k tokens) after a session_start
1206
+ // guard that only called shutdown. Hard-exit so a non-garden
1207
+ // --entwurf-control session cannot proceed at all: no turn, no socket
1208
+ // (the guard returns before startControlServer), no PI_SESSION_ID leak.
1209
+ // "보이면 바로 터진다." The guard runs before agent_start, so exiting
1210
+ // here means the model is never invoked.
1211
+ process.exit(1);
1212
+ }
1213
+ await startControlServer(pi, state, ctx);
1214
+ updateStatus(ctx, true);
1215
+ updateSessionEnv(ctx, true);
1216
+ // On a warm start (reload/resume) the file may already exist — set the
1217
+ // garden name now; on a fresh start it's a no-op until the first turn_end.
1218
+ maybeSetResidentName(pi, ctx);
1219
+ };
1220
+
1221
+ // session_start is the unified post-event for the whole session lifecycle
1222
+ // in pi-coding-agent 0.70.x: it fires with reason "startup" | "reload" |
1223
+ // "new" | "resume" | "fork", which covers the original session_switch and
1224
+ // session_fork cases that earlier pi versions exposed as separate events.
1225
+ // Previous code subscribed to "session_switch" and "session_fork" — those
1226
+ // names do not exist in the current ExtensionAPI typing, so the handlers
1227
+ // were dead. The typecheck-exclude on this file kept that decay invisible.
1228
+ // Don't reintroduce them without first confirming the events exist.
1229
+ pi.on("session_start", async (_event, ctx) => {
1230
+ await refreshServer(ctx);
1231
+ });
1232
+
1233
+ // Pre-switch guard — cancel an in-process resident mint BEFORE session_start
1234
+ // fires, so the hard guard never has to hard-exit the process. Covers every
1235
+ // entry point (slash, RPC, keybinding, ctx.newSession) because pi routes them
1236
+ // all through AgentSessionRuntime.{newSession,switchSession} → emitBeforeSwitch.
1237
+ // Only active under --entwurf-control; plain sessions keep /new and /resume
1238
+ // unrestricted.
1239
+ pi.on("session_before_switch", async (event, ctx) => {
1240
+ if (pi.getFlag(ENTWURF_FLAG) !== true) return {};
1241
+ if (event.reason === "new") {
1242
+ refuseInProcessMint(
1243
+ ctx,
1244
+ "/new (in-process new session)",
1245
+ "an in-process new session gets a non-garden uuid the garden guard rejects.",
1246
+ );
1247
+ return { cancel: true };
1248
+ }
1249
+ if (event.reason === "resume" && event.targetSessionFile) {
1250
+ // Pre-cancel a resume INTO a non-garden (legacy uuid) session so it fails
1251
+ // friendly here rather than hard-exiting at the session_start guard. A
1252
+ // garden target passes through; an unreadable header is left to the
1253
+ // session_start backstop.
1254
+ let targetId: string | null = null;
1255
+ try {
1256
+ targetId = readSessionHeader(event.targetSessionFile)?.id ?? null;
1257
+ } catch {
1258
+ targetId = null;
1259
+ }
1260
+ if (targetId) {
1261
+ try {
1262
+ assertGardenNativeSessionId(targetId);
1263
+ } catch {
1264
+ refuseInProcessMint(ctx, "resume", `the target session id "${targetId}" is not garden-native.`);
1265
+ return { cancel: true };
1266
+ }
1267
+ }
1268
+ }
1269
+ return {};
1270
+ });
1271
+
1272
+ // Fork/clone always mints a fresh uuid child — never garden-native in-process.
1273
+ pi.on("session_before_fork", async (_event, ctx) => {
1274
+ if (pi.getFlag(ENTWURF_FLAG) !== true) return {};
1275
+ refuseInProcessMint(
1276
+ ctx,
1277
+ "/fork (session fork/clone)",
1278
+ "a forked session gets a non-garden uuid the garden guard rejects.",
1279
+ );
1280
+ return { cancel: true };
1281
+ });
1282
+
1283
+ pi.on("session_shutdown", async () => {
1284
+ updateStatus(state.context, false);
1285
+ updateSessionEnv(state.context, false);
1286
+ await stopControlServer(state);
1287
+ });
1288
+
1289
+ // turn_end is subscribed ONLY for the resident-session garden lifecycle (0.9.0):
1290
+ // the first assistant turn writes the session file, which (a) flips the status
1291
+ // label from `🪛 ready` to `🪛 <gardenId>` (file-exists = model-locked signal)
1292
+ // and (b) is when the now-locked model lets us set the resident garden name.
1293
+ // This is NOT a send/delivery channel — send-is-throw still holds (the send RPC
1294
+ // ack remains the entire delivery contract); this handler never sends.
1295
+ pi.on("turn_end", async (_event, ctx) => {
1296
+ if (pi.getFlag(ENTWURF_FLAG) !== true) return;
1297
+ maybeSetResidentName(pi, ctx);
1298
+ updateStatus(ctx, true);
1299
+ });
1300
+ }
1301
+
1302
+ // ============================================================================
1303
+ // Tool: entwurf_v2 (5d-3a) — the unified v2 dispatch verb
1304
+ // ============================================================================
1305
+ //
1306
+ // The v2 runner + production deps live in the `.ts`-extension fence (excluded from
1307
+ // this emit-capable root program). A STATIC import would pull the fence's literal
1308
+ // `.ts` imports into root tsc → TS5097. So we reach the surface adapter via a
1309
+ // NON-LITERAL dynamic import: root tsc cannot statically resolve a string-const
1310
+ // specifier, and the strip-types runtime loads the `.ts` fence entry fine. The
1311
+ // local interface below is the only contract this file knows about the fence — the
1312
+ // `EntwurfV2RunResult` union stays behind `runAndRenderEntwurfV2FromSurface`, which
1313
+ // hands back just `{ text, isError }`.
1314
+ const ENTWURF_V2_SURFACE_MODULE = "./lib/entwurf-v2-surface.ts";
1315
+
1316
+ // SE-1 (slice 2e-a): a pi-session sender's `replyable` is a FACT (does its canonical
1317
+ // control socket actually exist?), not env presence. entwurf-self-address.ts is a
1318
+ // `.ts`-extension fence lib, so this root-tsc emit surface reaches it the SAME way as the
1319
+ // v2 surface / mailbox guard — a NON-LITERAL dynamic import behind a local interface.
1320
+ const ENTWURF_SELF_ADDRESS_MODULE = "./lib/entwurf-self-address.ts";
1321
+ const ENTWURF_FACT_PROVIDER_MODULE = "./lib/entwurf-fact-provider.ts";
1322
+ const ENTWURF_PEERS_RENDER_MODULE = "./lib/entwurf-peers-render.ts";
1323
+ const META_SESSION_MODULE = "./lib/meta-session.ts";
1324
+
1325
+ type SelfAddressabilityFn = (facts: {
1326
+ origin: "pi-session" | "meta-session" | "external-mcp";
1327
+ socketAlive?: boolean;
1328
+ socketPathComputable?: boolean;
1329
+ recordBacked?: boolean;
1330
+ ownerAlive?: boolean;
1331
+ watchArmed?: boolean;
1332
+ }) => { replyable: boolean; socketState: "alive" | "expected" | "none"; reason: string };
1333
+
1334
+ interface EntwurfSelfAddressModule {
1335
+ computeSelfAddressability: SelfAddressabilityFn;
1336
+ }
1337
+
1338
+ /**
1339
+ * Decorate a local pi sender envelope with its HONEST replyability (SE-1 slice 2e-a).
1340
+ * The old code hardcoded replyability to true from env presence; a pi session running
1341
+ * without --entwurf-control has a session id but no control socket, so a reply silently fails.
1342
+ * Route the v2 senderProvider through the shared computeSelfAddressability truth table:
1343
+ * replyable ⟺ the canonical socket exists (existsSync
1344
+ * — slice-1 level, NOT a listener probe; deeper liveness is a separate hardening slice).
1345
+ */
1346
+ function decoratePiSenderAddressability(sender: SenderEnvelope, compute: SelfAddressabilityFn): SenderEnvelope {
1347
+ const self = compute({
1348
+ origin: "pi-session",
1349
+ socketAlive: existsSync(getSocketPath(sender.sessionId)),
1350
+ socketPathComputable: true,
1351
+ });
1352
+ return { ...sender, origin: "pi-session", replyable: self.replyable };
1353
+ }
1354
+
1355
+ interface EntwurfV2SurfaceModule {
1356
+ runAndRenderEntwurfV2FromSurface(
1357
+ params: {
1358
+ target: string;
1359
+ intent: "fire-and-forget" | "owned-outcome";
1360
+ mode?: "steer" | "follow_up";
1361
+ wants_reply?: boolean;
1362
+ message: string;
1363
+ },
1364
+ opts: {
1365
+ senderProvider: () => SenderEnvelope | undefined;
1366
+ agentDir?: string;
1367
+ prefixRoots?: readonly string[];
1368
+ },
1369
+ ): Promise<{ text: string; isError: boolean }>;
1370
+ }
1371
+
1372
+ function registerEntwurfV2Tool(pi: ExtensionAPI): void {
1373
+ const entwurfV2Parameters = Type.Object({
1374
+ target: Type.String({ description: "Target garden id (use entwurf_peers to discover)" }),
1375
+ intent: StringEnum(["fire-and-forget", "owned-outcome"] as const, {
1376
+ description:
1377
+ "fire-and-forget = send/reply/hand-off to a LIVE or meta-session target (set wants_reply for an answer); " +
1378
+ "owned-outcome = wake a DORMANT pi via spawn-bg resume ONLY — on a live target it is rejected " +
1379
+ "(owned-live-no-autosend) and never auto-converted",
1380
+ }),
1381
+ message: Type.String({
1382
+ description:
1383
+ "Message / prompt to dispatch. Hard cap 16000 chars; for larger payloads send a file/artifact path plus digest.",
1384
+ maxLength: 16000,
1385
+ }),
1386
+ mode: Type.Optional(
1387
+ StringEnum(["steer", "follow_up"] as const, {
1388
+ description: "Delivery mode for a live send: steer (immediate) or follow_up (after task)",
1389
+ }),
1390
+ ),
1391
+ wants_reply: Type.Optional(Type.Boolean({ description: "Human-conversation reply hint (default false)" })),
1392
+ });
1393
+
1394
+ type EntwurfV2Params = {
1395
+ target: string;
1396
+ intent: "fire-and-forget" | "owned-outcome";
1397
+ message: string;
1398
+ mode?: "steer" | "follow_up";
1399
+ wants_reply?: boolean;
1400
+ };
1401
+
1402
+ const registerTool = pi.registerTool as (def: any) => void;
1403
+
1404
+ registerTool({
1405
+ name: "entwurf_v2",
1406
+ label: "Dispatch (v2)",
1407
+ description: `CANONICAL delivery surface for a garden id. When you have a garden id and want to
1408
+ reach whoever it names — message / reply / hand-off — use THIS verb. A garden id alone does not
1409
+ reveal whether the target is a live pi session, a dormant pi session, or a Claude Code meta-session,
1410
+ and entwurf_v2 is the one surface that reads that and routes correctly (so "when unsure which
1411
+ transport, use entwurf_v2"). It dispatches to EXISTING targets; brand-new sibling creation is deferred
1412
+ to a later v2 lane. Dispatch to a garden citizen through the unified entwurf_v2 verb: the 5b decider
1413
+ picks the transport (live control-socket send / spawn-bg resume / meta-mailbox enqueue) from the
1414
+ target's liveness + your intent, runs it under the v2 lock policy (pi paths take a per-target lock;
1415
+ the mailbox path is lock-free, guarded by active-receiver deliverability), and reports one outcome
1416
+ (delivered / rejected / lock-retained / delivered-but-lock-dirty).
1417
+
1418
+ - target: the garden id of the citizen to reach (required).
1419
+ - intent: fire-and-forget (a send with no owned result) or owned-outcome (you own the result).
1420
+ - message: the message/prompt to dispatch (required).
1421
+ - mode: steer or follow_up for a live send (optional).
1422
+ - wants_reply: reply hint for a live send (optional, default false).
1423
+
1424
+ CHOOSING INTENT (picking wrong is rejected, never auto-fixed): to message / reply / hand off a peer
1425
+ that entwurf_peers shows as liveness=alive (a live pi OR a socket-citizen), use intent: fire-and-forget
1426
+ — it routes to the live control-socket; set wants_reply:true if you need an answer (wants_reply is NOT
1427
+ owned-outcome). For a meta-session (liveness=unsupported, e.g. Claude Code), replies are ALSO
1428
+ fire-and-forget (→ mailbox). owned-outcome is ONLY for waking a DORMANT pi citizen (spawn-bg resume);
1429
+ on a live target it is rejected as owned-live-no-autosend, on an unsupported backend as
1430
+ backend-liveness-unsupported, and is NEVER auto-converted — so pick the right intent up front.
1431
+
1432
+ The decider — not this surface — chooses the transport.`,
1433
+ parameters: entwurfV2Parameters,
1434
+ async execute(
1435
+ _toolCallId: string,
1436
+ params: EntwurfV2Params,
1437
+ _signal: AbortSignal | undefined,
1438
+ _onUpdate: unknown,
1439
+ ctx: ExtensionContext,
1440
+ ) {
1441
+ const target = params.target?.trim();
1442
+ if (!target) {
1443
+ return { content: [{ type: "text", text: "entwurf_v2: missing target" }], isError: true };
1444
+ }
1445
+ if (!isSafeSessionId(target)) {
1446
+ return { content: [{ type: "text", text: "entwurf_v2: invalid target garden id" }], isError: true };
1447
+ }
1448
+ if (!params.message?.trim()) {
1449
+ return { content: [{ type: "text", text: "entwurf_v2: missing message" }], isError: true };
1450
+ }
1451
+
1452
+ try {
1453
+ // The reply-address envelope for this pi session, decorated with its HONEST
1454
+ // replyability (SE-1 2e-a: socket existsSync, not a hardcoded true). ONE provider
1455
+ // feeds the control-socket RPC sender AND the meta-mailbox body sender (see
1456
+ // entwurf-v2-production). Built per-call so the timestamp is the dispatch moment;
1457
+ // computeSelfAddressability is loaded once here (sync senderProvider can't await).
1458
+ const selfMod = (await import(ENTWURF_SELF_ADDRESS_MODULE)) as unknown as EntwurfSelfAddressModule;
1459
+ const senderProvider = (): SenderEnvelope | undefined => {
1460
+ const s = buildLocalSenderEnvelope(ctx);
1461
+ return s ? decoratePiSenderAddressability(s, selfMod.computeSelfAddressability) : undefined;
1462
+ };
1463
+ const mod = (await import(ENTWURF_V2_SURFACE_MODULE)) as unknown as EntwurfV2SurfaceModule;
1464
+ const rendered = await mod.runAndRenderEntwurfV2FromSurface(
1465
+ {
1466
+ target,
1467
+ intent: params.intent,
1468
+ message: params.message,
1469
+ mode: params.mode,
1470
+ wants_reply: params.wants_reply,
1471
+ },
1472
+ // agentDir / prefixRoots intentionally omitted here: the surface adapter falls back
1473
+ // to the ENTWURF_PREFIX_ROOTS env SSOT for prefixRoots (5d-4); agentDir stays undefined.
1474
+ { senderProvider },
1475
+ );
1476
+ return {
1477
+ content: [{ type: "text", text: rendered.text }],
1478
+ isError: rendered.isError,
1479
+ details: { isError: rendered.isError },
1480
+ };
1481
+ } catch (err) {
1482
+ const msg = err instanceof Error ? err.message : String(err);
1483
+ return {
1484
+ content: [{ type: "text", text: `entwurf_v2 error: ${msg}` }],
1485
+ isError: true,
1486
+ details: { error: msg },
1487
+ };
1488
+ }
1489
+ },
1490
+ });
1491
+ }
1492
+
1493
+ // ============================================================================
1494
+ // Tool: entwurf_peers
1495
+ // ============================================================================
1496
+
1497
+ interface EntwurfFactProviderModule {
1498
+ listEntwurfFacts(params: {
1499
+ metaEntries: readonly string[];
1500
+ readRecord: (filename: string) => string;
1501
+ socket: { dir: string };
1502
+ }): Promise<unknown>;
1503
+ }
1504
+
1505
+ interface EntwurfPeersRenderModule {
1506
+ renderEntwurfPeers(result: unknown, controlDir: string): { text: string; payload: unknown };
1507
+ }
1508
+
1509
+ interface MetaSessionModule {
1510
+ defaultMetaSessionsDir(): string;
1511
+ }
1512
+
1513
+ async function renderEntwurfPeersForSurface(): Promise<{ text: string; payload: unknown }> {
1514
+ const meta = (await import(META_SESSION_MODULE)) as unknown as MetaSessionModule;
1515
+ const sessionsDir = meta.defaultMetaSessionsDir();
1516
+ let metaEntries: string[] = [];
1517
+ try {
1518
+ metaEntries = (await fs.readdir(sessionsDir)).filter((name) => name.endsWith(".meta.json"));
1519
+ } catch (err) {
1520
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
1521
+ }
1522
+
1523
+ const provider = (await import(ENTWURF_FACT_PROVIDER_MODULE)) as unknown as EntwurfFactProviderModule;
1524
+ const result = await provider.listEntwurfFacts({
1525
+ metaEntries,
1526
+ readRecord: (filename) => readFileSync(path.join(sessionsDir, filename), "utf8"),
1527
+ // Same socket axis as the legacy live-session scan, but merged with the
1528
+ // meta-record rail by listEntwurfFacts so meta-mailbox citizens are discoverable too.
1529
+ socket: { dir: ENTWURF_DIR },
1530
+ });
1531
+ const render = (await import(ENTWURF_PEERS_RENDER_MODULE)) as unknown as EntwurfPeersRenderModule;
1532
+ return render.renderEntwurfPeers(result, ENTWURF_DIR);
1533
+ }
1534
+
1535
+ function registerListSessionsTool(pi: ExtensionAPI): void {
1536
+ // Same TS2589 workaround as registerSessionTool — see the comment block
1537
+ // in that function for the revisit conditions.
1538
+ const registerTool = pi.registerTool as (def: any) => void;
1539
+ registerTool({
1540
+ name: "entwurf_peers",
1541
+ label: "List Garden Citizens",
1542
+ description:
1543
+ "List the entwurf fact surface across BOTH rails: garden citizens from meta-records (including active self-fetch meta receivers such as claude-code) and record-less control sockets, each with liveness. A legacy `sessions` projection is retained for alive pi control-socket sessions only. Pair with entwurf_v2 to address a peer by garden id; this surface reports facts, never per-row routing verbs.",
1544
+ parameters: Type.Object({}),
1545
+ async execute(
1546
+ _toolCallId: string,
1547
+ _params: Record<string, never>,
1548
+ _signal: AbortSignal | undefined,
1549
+ _onUpdate: AgentToolUpdateCallback<unknown> | undefined,
1550
+ _ctx: ExtensionContext,
1551
+ ): Promise<AgentToolResult<unknown>> {
1552
+ try {
1553
+ const { text, payload } = await renderEntwurfPeersForSurface();
1554
+ return {
1555
+ content: [{ type: "text", text }],
1556
+ details: payload,
1557
+ };
1558
+ } catch (err) {
1559
+ const msg = err instanceof Error ? err.message : String(err);
1560
+ return {
1561
+ content: [{ type: "text", text: `entwurf_peers error: ${msg}` }],
1562
+ details: { error: msg },
1563
+ };
1564
+ }
1565
+ },
1566
+ });
1567
+ }
1568
+
1569
+ function registerControlSessionsCommand(pi: ExtensionAPI, setSessions: (sessions: EnrichedSession[]) => void): void {
1570
+ pi.registerCommand("entwurf-sessions", {
1571
+ description: "List controllable sessions (from entwurf-control sockets)",
1572
+ handler: async (_args, ctx) => {
1573
+ if (pi.getFlag(ENTWURF_FLAG) !== true) {
1574
+ if (ctx.hasUI) {
1575
+ ctx.ui.notify("Entwurf control not enabled — relaunch pi with --entwurf-control", "warning");
1576
+ }
1577
+ return;
1578
+ }
1579
+
1580
+ const sessions = await getLiveSessionsWithInfo();
1581
+ setSessions(sessions);
1582
+
1583
+ const currentSessionId = ctx.sessionManager.getSessionId();
1584
+
1585
+ if (sessions.length === 0) {
1586
+ pi.sendMessage(
1587
+ {
1588
+ customType: "entwurf-sessions",
1589
+ content: "No live sessions found.",
1590
+ display: true,
1591
+ },
1592
+ { triggerTurn: false },
1593
+ );
1594
+ return;
1595
+ }
1596
+
1597
+ const lines: string[] = ["Controllable sessions:", ""];
1598
+ sessions.forEach((s, idx) => {
1599
+ const current = s.sessionId === currentSessionId ? " (current)" : "";
1600
+ const idShort = `${s.sessionId.slice(0, 8)}…${s.sessionId.slice(-4)}`;
1601
+ lines.push(`[${idx + 1}] ${idShort}${current}`);
1602
+ if (s.infoError) {
1603
+ lines.push(` error: ${s.infoError}`);
1604
+ } else {
1605
+ lines.push(` cwd: ${abbreviateHome(s.cwd)}`);
1606
+ const modelLabel = formatRuntimeModel({ modelId: s.modelId, modelProvider: s.modelProvider }) ?? "(unknown)";
1607
+ lines.push(` model: ${modelLabel}`);
1608
+ const idleLabel = s.idle === undefined ? "?" : s.idle ? "yes" : "no (turn in progress)";
1609
+ lines.push(` idle: ${idleLabel}`);
1610
+ }
1611
+ lines.push("");
1612
+ });
1613
+
1614
+ pi.sendMessage(
1615
+ {
1616
+ customType: "entwurf-sessions",
1617
+ content: lines.join("\n").trimEnd(),
1618
+ display: true,
1619
+ },
1620
+ { triggerTurn: false },
1621
+ );
1622
+ },
1623
+ });
1624
+ }
1625
+
1626
+ // /gnew (+ /garden-new) — birth a NEW garden-native session IN-PROCESS, same
1627
+ // terminal, without the uuid that /new would mint. Builtin /new stays blocked
1628
+ // under --entwurf-control (it cannot be made garden-native: the pre-switch hook
1629
+ // result carries only { cancel }, no id injection). /gnew instead pre-creates an
1630
+ // empty garden-native session file and ctx.switchSession()es into it: switchSession
1631
+ // runs SessionManager.open(file), which reads the garden header id BEFORE
1632
+ // session_start, so the backend/bridge identity (PI_SESSION_ID, control socket,
1633
+ // backend sessionId) binds to the garden id from the first moment — no torn
1634
+ // identity. The whole path runs at 0 tokens (it's a command, not a model turn) and
1635
+ // is headless-testable via RPC `prompt "/gnew"` (session.prompt intercepts the
1636
+ // leading slash → the registered command handler, whose ctx has switchSession).
1637
+ function registerGardenNewCommand(pi: ExtensionAPI): void {
1638
+ const register = (name: string) => {
1639
+ pi.registerCommand(name, {
1640
+ description: "Birth a NEW garden-native session in-process (garden id; --entwurf-control safe)",
1641
+ handler: async (_args, ctx) => {
1642
+ if (pi.getFlag(ENTWURF_FLAG) !== true) {
1643
+ if (ctx.hasUI) {
1644
+ ctx.ui.notify(`/${name} only applies under --entwurf-control — relaunch with the flag`, "warning");
1645
+ }
1646
+ return;
1647
+ }
1648
+ // Never replace the session mid-turn.
1649
+ await ctx.waitForIdle();
1650
+
1651
+ let created: { sessionId: string; sessionFile: string } | undefined;
1652
+ try {
1653
+ created = createGardenSessionFile({
1654
+ cwd: ctx.cwd,
1655
+ sessionDir: ctx.sessionManager.getSessionDir(),
1656
+ });
1657
+ } catch (err) {
1658
+ const msg = err instanceof Error ? err.message : String(err);
1659
+ process.stderr.write(`[entwurf-control] /${name} could not create garden session file: ${msg}\n`);
1660
+ if (ctx.hasUI) ctx.ui.notify(`🪛 garden-new failed: ${msg}`, "error");
1661
+ return;
1662
+ }
1663
+
1664
+ try {
1665
+ const result = await ctx.switchSession(created.sessionFile);
1666
+ if (result.cancelled) {
1667
+ // A garden header should pass the pre-switch guard, so a cancel is
1668
+ // unexpected — remove the orphan rather than leave litter, and report.
1669
+ removeUnadoptedGardenSessionFile(created.sessionFile, created.sessionId);
1670
+ process.stderr.write(`[entwurf-control] /${name} switch cancelled for ${created.sessionId}\n`);
1671
+ if (ctx.hasUI) ctx.ui.notify("🪛 garden-new switch cancelled", "warning");
1672
+ return;
1673
+ }
1674
+ } catch (err) {
1675
+ // Switch threw (e.g. cwd vanished): the file we wrote is an unadopted
1676
+ // orphan — clean it up. try/catch keeps the failure path leak-free.
1677
+ removeUnadoptedGardenSessionFile(created.sessionFile, created.sessionId);
1678
+ const msg = err instanceof Error ? err.message : String(err);
1679
+ process.stderr.write(`[entwurf-control] /${name} switch failed for ${created.sessionId}: ${msg}\n`);
1680
+ if (ctx.hasUI) ctx.ui.notify(`🪛 garden-new switch failed: ${msg}`, "error");
1681
+ return;
1682
+ }
1683
+ // Switch succeeded → the session is REPLACED. `ctx` now refers to the old
1684
+ // session and must not be touched; the new session's name, control socket
1685
+ // and PI_SESSION_ID are bound by the session_start handler on the garden id.
1686
+ // The 🪛 status bar flipping to the new id is the confirmation. Return now.
1687
+ },
1688
+ });
1689
+ };
1690
+ register("gnew");
1691
+ register("garden-new");
1692
+ }