@junghanacs/entwurf 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- package/scripts/tsconfig.json +34 -0
|
@@ -0,0 +1,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
|
+
}
|