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