@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,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* meta-bridge-hook — the Claude Code native-session entry shell (1.0.0 step 4).
|
|
3
|
+
*
|
|
4
|
+
* THE load-bearing hook. Shipped inside a plugin bundle whose `hooks/hooks.json`
|
|
5
|
+
* wires it to `SessionStart` (and `CwdChanged` / `UserPromptSubmit`). Because a
|
|
6
|
+
* plugin's hooks load at STARTUP (a bare skill's load only on invocation, after
|
|
7
|
+
* SessionStart has passed), this fires on every native Claude Code open and makes
|
|
8
|
+
* that session a garden citizen WITHOUT any pi JSONL of its own:
|
|
9
|
+
*
|
|
10
|
+
* stdin {session_id, transcript_path, cwd} (Claude hook envelope)
|
|
11
|
+
* -> upsertMetaSession(claude-code) idempotent create/attach the record
|
|
12
|
+
* -> gardenId the session's garden address
|
|
13
|
+
* -> arm watchPath <mailbox>/<gardenId>/inbox.signal idle-wake doorbell
|
|
14
|
+
* -> stdout hookSpecificOutput.watchPaths (SessionStart / CwdChanged only)
|
|
15
|
+
*
|
|
16
|
+
* The record is keyed/looked-up by `native_session_id` (THE authority,
|
|
17
|
+
* scanByNativeId); the idle-wake mailbox is keyed by GARDEN id — that is the
|
|
18
|
+
* whole point of the meta-bridge over the raw per-session prototype: one garden
|
|
19
|
+
* address a sender can target, decoupled from the backend's native id grammar.
|
|
20
|
+
*
|
|
21
|
+
* FAILURE POLICY (decided, see NEXT.md step 4): the runtime hook is
|
|
22
|
+
* BEST-EFFORT + LOG. It never screams into the user's terminal and never blocks
|
|
23
|
+
* startup — on any error it appends a line to `<pi-agent-dir>/meta-bridge-hook.log`
|
|
24
|
+
* and emits an empty `{}` (no arm). The fail-LOUD surface is the doctor
|
|
25
|
+
* (step 5), which reads that log + the meta-record dir to catch a silent miss.
|
|
26
|
+
*
|
|
27
|
+
* watchPaths can be emitted only from SessionStart / CwdChanged / FileChanged, so
|
|
28
|
+
* a UserPromptSubmit fire does a degraded RECORD backfill (upsert) but cannot
|
|
29
|
+
* re-arm the idle watch — the record's address is restored, the wake is not.
|
|
30
|
+
*
|
|
31
|
+
* Run: `<node> --experimental-strip-types <plugin-root>/meta-bridge-hook.ts`.
|
|
32
|
+
* Imports `./lib/meta-session.ts` (+ `./lib/session-id.js`); the installer copies
|
|
33
|
+
* that lib dir alongside this file so `${CLAUDE_PLUGIN_ROOT}` self-locates it.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import * as fs from "node:fs";
|
|
37
|
+
import * as path from "node:path";
|
|
38
|
+
import {
|
|
39
|
+
defaultMetaMailboxDir,
|
|
40
|
+
defaultMetaSessionsDir,
|
|
41
|
+
type MetaReceiverArmProvenance,
|
|
42
|
+
upsertMetaSession,
|
|
43
|
+
writeMetaReceiverMarker,
|
|
44
|
+
writeMetaSenderMarker,
|
|
45
|
+
} from "./lib/meta-session.ts";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Append a best-effort diagnostic line; swallow even its own failure (never throw
|
|
49
|
+
* from the hook). Every line carries a LEVEL token so the doctor — the fail-loud
|
|
50
|
+
* surface — can mechanically tell a silent miss from routine noise:
|
|
51
|
+
* - ERROR: this session did NOT become a garden citizen (or lost its wake).
|
|
52
|
+
* The doctor must treat a recent ERROR as a failure (blocker #2).
|
|
53
|
+
* - WARN : the session registered, but something nearby is off (a corrupt
|
|
54
|
+
* neighbour record skipped during scan, or a degraded UserPromptSubmit
|
|
55
|
+
* backfill — note a degraded SessionStart/CwdChanged is ERROR, since
|
|
56
|
+
* those are the events that actually establish/refresh citizenship).
|
|
57
|
+
* - INFO : normal create/attach/arm.
|
|
58
|
+
* The token sits right after the ISO timestamp, so ` ERROR ` is a clean grep.
|
|
59
|
+
*/
|
|
60
|
+
type LogLevel = "INFO" | "WARN" | "ERROR";
|
|
61
|
+
function logLine(level: LogLevel, message: string): void {
|
|
62
|
+
try {
|
|
63
|
+
// dirname(meta-sessions) == the pi agent dir — no extra resolver export needed.
|
|
64
|
+
const file = path.join(path.dirname(defaultMetaSessionsDir()), "meta-bridge-hook.log");
|
|
65
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
66
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${level} ${message}\n`);
|
|
67
|
+
} catch {
|
|
68
|
+
/* logging is best-effort; a broken log must not break the session */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Emit a hook response on stdout and exit 0. `{}` means "did nothing, do not block startup". */
|
|
73
|
+
function emit(payload: Record<string, unknown>): never {
|
|
74
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Map the hook event to a receiver-marker arm provenance. ONLY the genuinely
|
|
80
|
+
* arm-capable events map; any other (a future/unknown hook event) returns null so we
|
|
81
|
+
* never mint an "active receiver" presence we cannot back — fail-closed, not an
|
|
82
|
+
* optimistic session-start. UserPromptSubmit never reaches here (it early-returns
|
|
83
|
+
* before the arm block).
|
|
84
|
+
*/
|
|
85
|
+
function armProvenanceFor(eventName: string): MetaReceiverArmProvenance | null {
|
|
86
|
+
if (eventName === "SessionStart") return "session-start";
|
|
87
|
+
if (eventName === "CwdChanged") return "cwd-changed";
|
|
88
|
+
if (eventName === "FileChanged") return "file-changed";
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function main(): void {
|
|
93
|
+
let raw: string;
|
|
94
|
+
try {
|
|
95
|
+
raw = fs.readFileSync(0, "utf8"); // fd 0 = stdin (the Claude hook envelope)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
logLine("ERROR", `stdin read failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
98
|
+
emit({});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let env: Record<string, unknown>;
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(raw);
|
|
104
|
+
if (typeof parsed !== "object" || parsed === null) throw new Error("envelope is not an object");
|
|
105
|
+
env = parsed as Record<string, unknown>;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
logLine("ERROR", `envelope parse failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
108
|
+
emit({});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const sessionId = typeof env.session_id === "string" ? env.session_id : "";
|
|
112
|
+
const transcriptPath = typeof env.transcript_path === "string" ? env.transcript_path : "";
|
|
113
|
+
// cwd: prefer the envelope's, fall back to the process cwd (the hook runs in the session's cwd).
|
|
114
|
+
const cwd = typeof env.cwd === "string" && env.cwd.length > 0 ? env.cwd : process.cwd();
|
|
115
|
+
const modelEnvelope = env.model;
|
|
116
|
+
const model =
|
|
117
|
+
typeof modelEnvelope === "object" &&
|
|
118
|
+
modelEnvelope !== null &&
|
|
119
|
+
typeof (modelEnvelope as { id?: unknown }).id === "string"
|
|
120
|
+
? (modelEnvelope as { id: string }).id
|
|
121
|
+
: typeof env.model_id === "string"
|
|
122
|
+
? env.model_id
|
|
123
|
+
: undefined;
|
|
124
|
+
const eventName = typeof env.hook_event_name === "string" ? env.hook_event_name : "SessionStart";
|
|
125
|
+
|
|
126
|
+
if (!sessionId || !transcriptPath) {
|
|
127
|
+
// A degraded envelope: cannot mint an honest reference record. Log + no-op
|
|
128
|
+
// rather than write a half-record or guess a transcript path. LEVEL depends
|
|
129
|
+
// on the event: a degraded SessionStart / CwdChanged means the session FAILED
|
|
130
|
+
// to become (or refresh) a garden citizen — that is the silent registration
|
|
131
|
+
// miss the doctor must catch (blocker #2), so ERROR. UserPromptSubmit only
|
|
132
|
+
// ever does a best-effort record backfill, so a degraded one is just WARN.
|
|
133
|
+
const degradedLevel = eventName === "UserPromptSubmit" ? "WARN" : "ERROR";
|
|
134
|
+
logLine(
|
|
135
|
+
degradedLevel,
|
|
136
|
+
`degraded envelope (event=${eventName}, session_id=${sessionId ? "set" : "MISSING"}, transcript_path=${transcriptPath ? "set" : "MISSING"})`,
|
|
137
|
+
);
|
|
138
|
+
emit({});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let gardenId: string;
|
|
142
|
+
try {
|
|
143
|
+
const result = upsertMetaSession({
|
|
144
|
+
input: { backend: "claude-code", nativeSessionId: sessionId, transcriptPath, cwd, model },
|
|
145
|
+
onSkip: (filename, e) => logLine("WARN", `scan skipped ${filename}: ${e.message}`),
|
|
146
|
+
});
|
|
147
|
+
gardenId = result.record.gardenId;
|
|
148
|
+
logLine("INFO", `${result.action} record ${path.basename(result.path)} (event=${eventName}, native=${sessionId})`);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// Best-effort: a broken record store must surface via the doctor, not by
|
|
151
|
+
// breaking the user's session open. Log and continue with no arm. This is
|
|
152
|
+
// the silent-registration-miss (blocker #2): the session opened fine but is
|
|
153
|
+
// NOT a garden citizen — the doctor catches it via this ERROR line.
|
|
154
|
+
logLine(
|
|
155
|
+
"ERROR",
|
|
156
|
+
`upsert failed (event=${eventName}, native=${sessionId}): ${err instanceof Error ? err.message : String(err)}`,
|
|
157
|
+
);
|
|
158
|
+
emit({});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Sender marker, keyed by the shared Claude Code parent pid: the user-scope
|
|
162
|
+
// MCP child (same parent) reads it at entwurf_v2 send time to promote this
|
|
163
|
+
// session from anonymous external-mcp to a REPLYABLE meta-session sender —
|
|
164
|
+
// process ancestry, not cwd inference (same repo + multiple sessions would be
|
|
165
|
+
// ambiguous). Best-effort: a failed marker only costs reply-addressability
|
|
166
|
+
// (WARN), it does not break the session or the receiver path.
|
|
167
|
+
//
|
|
168
|
+
// SE-1/SE-2 (dual-owner fix): write ONLY for the direct parent (process.ppid =
|
|
169
|
+
// the Claude CLI that ran this hook, verified the native tree is direct — the
|
|
170
|
+
// plugin host is not in between). The old code ALSO wrote a marker for the
|
|
171
|
+
// grandparent. That grandparent is the login shell (e.g. bash under ghostty/i3),
|
|
172
|
+
// which OUTLIVES the Claude session: when Claude exits, the grandparent marker's
|
|
173
|
+
// ownerStartKey still matches a live pid, so it passes readMetaSenderMarker's
|
|
174
|
+
// reuse guard and the dead session keeps looking like a live, replyable receiver
|
|
175
|
+
// — a false-positive "active receiver" leak. The owner must be the watchPaths
|
|
176
|
+
// subscriber (the Claude CLI), nothing higher. If a stray topology means the MCP
|
|
177
|
+
// child's shared ancestor is not process.ppid, that resolves to "no marker"
|
|
178
|
+
// (fail-closed, honest) rather than a wrong-but-live grandparent identity.
|
|
179
|
+
const ownerPid = process.ppid;
|
|
180
|
+
if (typeof ownerPid === "number" && ownerPid > 0) {
|
|
181
|
+
try {
|
|
182
|
+
writeMetaSenderMarker({ backend: "claude-code", gardenId, nativeSessionId: sessionId, cwd, ownerPid });
|
|
183
|
+
logLine("INFO", `sender marker ${ownerPid} -> ${gardenId} (event=${eventName})`);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
logLine(
|
|
186
|
+
"WARN",
|
|
187
|
+
`sender marker write failed (event=${eventName}, pid=${ownerPid}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// watchPaths is emittable only from SessionStart / CwdChanged / FileChanged.
|
|
193
|
+
// UserPromptSubmit reaches here only to backfill the record above; it must NOT
|
|
194
|
+
// claim to arm a watch it cannot (decided).
|
|
195
|
+
if (eventName === "UserPromptSubmit") {
|
|
196
|
+
emit({});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const mailbox = path.join(defaultMetaMailboxDir(), gardenId);
|
|
201
|
+
fs.mkdirSync(mailbox, { recursive: true });
|
|
202
|
+
const signal = path.join(mailbox, "inbox.signal");
|
|
203
|
+
if (!fs.existsSync(signal)) fs.writeFileSync(signal, "", { mode: 0o600 });
|
|
204
|
+
logLine("INFO", `armed watch ${signal}`);
|
|
205
|
+
// Receiver presence marker (SE-2): written on the arm-capable hook path that
|
|
206
|
+
// emits watchPaths, keyed by garden id with the watch owner pid (= the Claude
|
|
207
|
+
// CLI, process.ppid — same single owner as the sender marker, never the
|
|
208
|
+
// grandparent). It records that a LIVE owner reached the watch-arm emit; it is
|
|
209
|
+
// not proof the host ack'd the watch registration. This is what lets a sender
|
|
210
|
+
// tell a live receiver from a terminated one whose record still lingers.
|
|
211
|
+
// Best-effort: a failed/skipped marker only costs deliverability detection
|
|
212
|
+
// (WARN), it does not break the arm. An unknown event maps to null provenance →
|
|
213
|
+
// no marker (fail-closed: never claim an active receiver we cannot back).
|
|
214
|
+
const ownerPid = process.ppid;
|
|
215
|
+
const armProvenance = armProvenanceFor(eventName);
|
|
216
|
+
if (armProvenance === null) {
|
|
217
|
+
logLine("WARN", `receiver marker skipped — non-arm event ${eventName} (garden=${gardenId})`);
|
|
218
|
+
} else if (typeof ownerPid === "number" && ownerPid > 0) {
|
|
219
|
+
try {
|
|
220
|
+
writeMetaReceiverMarker({
|
|
221
|
+
gardenId,
|
|
222
|
+
backend: "claude-code",
|
|
223
|
+
nativeSessionId: sessionId,
|
|
224
|
+
ownerPid,
|
|
225
|
+
armProvenance,
|
|
226
|
+
});
|
|
227
|
+
logLine("INFO", `receiver marker ${gardenId} owner=${ownerPid} arm=${eventName}`);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
logLine(
|
|
230
|
+
"WARN",
|
|
231
|
+
`receiver marker write failed (event=${eventName}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
emit({
|
|
236
|
+
hookSpecificOutput: {
|
|
237
|
+
hookEventName: eventName,
|
|
238
|
+
watchPaths: [signal],
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
} catch (err) {
|
|
242
|
+
logLine(
|
|
243
|
+
"ERROR",
|
|
244
|
+
`arm failed (event=${eventName}, garden=${gardenId}): ${err instanceof Error ? err.message : String(err)}`,
|
|
245
|
+
);
|
|
246
|
+
emit({}); // record landed; only the arm failed — the doctor will flag the missing watch.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
main();
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-lock — entwurf session model lock (extension-side revert).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ v2-only status (doc-truth): the "A (bridge-side)" companion described below
|
|
5
|
+
* — `acp-bridge.ts` / `ensureBridgeSession` — was removed in the ACP purge, so
|
|
6
|
+
* only this B-side hook remains. The `entwurf` provider it guards against is
|
|
7
|
+
* itself routing residue (Phase B: decide remove-vs-redefine model-lock; see
|
|
8
|
+
* NEXT--v2-only.md § Phase B 잔여). The A/B narrative below is retained as
|
|
9
|
+
* historical context pending that decision — read "A" as removed.
|
|
10
|
+
*
|
|
11
|
+
* Companion to the bridge-side guard in `acp-bridge.ts` (ensureBridgeSession
|
|
12
|
+
* reuse-path `ModelSwitchLockedError`). The two work as a pair:
|
|
13
|
+
*
|
|
14
|
+
* - A (bridge-side): live reuse-path mismatch inside a entwurf
|
|
15
|
+
* bridge session is refused at `ensureBridgeSession`. This is the
|
|
16
|
+
* fallback/direct-call boundary: it prevents silent backend handoff
|
|
17
|
+
* and MCP identity drift if the extension hook is absent, disabled,
|
|
18
|
+
* or fails before the revert lands.
|
|
19
|
+
*
|
|
20
|
+
* - B (this hook): cross-provider departure (entwurf/X → native) AND
|
|
21
|
+
* cross-provider entry (native → entwurf/X) — both touch the
|
|
22
|
+
* entwurf boundary and are refused by reverting to the previous
|
|
23
|
+
* model via `pi.setModel(previousModel)`. This is NOT a clean refusal:
|
|
24
|
+
* pi-core has already mutated `agent.state.model` and appended
|
|
25
|
+
* `model_change` to the JSONL before emitting `model_select`. We
|
|
26
|
+
* observe the after-event and revert, which adds a second
|
|
27
|
+
* `model_change` entry (X → Y → X).
|
|
28
|
+
*
|
|
29
|
+
* Why both surfaces (B fires first, A is the fallback):
|
|
30
|
+
* - B observes `model_select` immediately when pi-core emits it,
|
|
31
|
+
* which happens during `AgentSession.setModel()` BEFORE the next
|
|
32
|
+
* prompt reaches any provider. So for the entwurf →
|
|
33
|
+
* entwurf case (and every other touches-entwurf case), B
|
|
34
|
+
* reverts the model first and the next prompt then enters the
|
|
35
|
+
* bridge under the ORIGINAL model. A's reuse-path mismatch check
|
|
36
|
+
* therefore does not fire on the happy path — there is no mismatch
|
|
37
|
+
* left to catch. A only matters if B fails to register, throws
|
|
38
|
+
* before `pi.setModel(from)` completes, or is disabled by an
|
|
39
|
+
* operator overriding the extensions list.
|
|
40
|
+
* - cross-provider departure (entwurf → native) and cross-provider
|
|
41
|
+
* entry (native → entwurf) NEVER reach A at all — the next
|
|
42
|
+
* prompt routes to a different provider, so `ensureBridgeSession`
|
|
43
|
+
* is not called. B is the only surface for those cases.
|
|
44
|
+
* - Wire-evidence captured during the issue #14 investigation: in a
|
|
45
|
+
* native → entwurf entry, pi JSONL continued (hi1, hi2, ...)
|
|
46
|
+
* but a fresh ACP backend session was bootstrapped and the model
|
|
47
|
+
* could not see the pre-switch turn (model replied "현재 세션에서
|
|
48
|
+
* hi2만 보입니다"). Same failure mode as entwurf → native
|
|
49
|
+
* departure, mirrored.
|
|
50
|
+
*
|
|
51
|
+
* Policy:
|
|
52
|
+
* - A entwurf session is locked to its starting model.
|
|
53
|
+
* - Native-to-native switching is free. Once a native session is
|
|
54
|
+
* anchored, switching INTO entwurf is refused because it would
|
|
55
|
+
* create a fresh ACP backend behind a continuous pi transcript.
|
|
56
|
+
* - The lock fires for any in-session `model_select` event whose
|
|
57
|
+
* transition touches the entwurf boundary
|
|
58
|
+
* (`from.provider === "entwurf" || to.provider === "entwurf"`)
|
|
59
|
+
* and whose source is "set" or "cycle" (not "restore").
|
|
60
|
+
*
|
|
61
|
+
* Honest limits:
|
|
62
|
+
* - This is NOT a transcript-clean refusal. The first `model_change`
|
|
63
|
+
* entry (X → Y) is already in the JSONL by the time we observe the
|
|
64
|
+
* event. Our `pi.setModel(from)` adds a second entry (Y → X). For a
|
|
65
|
+
* fully clean refusal, pi-core would need a cancellable
|
|
66
|
+
* `before_model_select` hook that this repo intentionally does not
|
|
67
|
+
* patch (entwurf does not send PRs to pi-core).
|
|
68
|
+
* - On the happy path B is a strict superset of A's coverage. A is the
|
|
69
|
+
* fallback for cases where B did not run (handler registration
|
|
70
|
+
* missing, thrown before the revert lands, extension disabled by an
|
|
71
|
+
* operator override of the extensions list). Smoke `check-model-lock`
|
|
72
|
+
* verifies B's policy logic; `smoke-model-switch` continues to
|
|
73
|
+
* verify A's bridge-side throw as the fallback contract.
|
|
74
|
+
*
|
|
75
|
+
* References:
|
|
76
|
+
* - pi-core setModel: pi-mono/packages/coding-agent/src/core/agent-session.ts:1416
|
|
77
|
+
* - ModelSelectEvent: pi-mono/packages/coding-agent/src/core/extensions/types.ts:711-719
|
|
78
|
+
* - ExtensionAPI.setModel: types.ts:1228 (returns Promise<boolean>; false = auth missing)
|
|
79
|
+
* - ExtensionUIContext.notify: types.ts:135 (sync void)
|
|
80
|
+
* - Wire evidence native → entwurf entry failure mode: GLG live test
|
|
81
|
+
* 2026-05-14 ~13:30 KST, session pi:019e24c0-1251-...
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
85
|
+
|
|
86
|
+
const ENTWURF_PROVIDER = "entwurf";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Module-level reentry guard.
|
|
90
|
+
*
|
|
91
|
+
* When our handler calls `pi.setModel(from)` to revert, pi-core's
|
|
92
|
+
* `AgentSession.setModel()` mutates state and emits another `model_select`
|
|
93
|
+
* event (with `source: "set"`). Without this flag, our hook would observe
|
|
94
|
+
* the synthetic emit and try to revert again — infinite recursion. The
|
|
95
|
+
* flag is set BEFORE `pi.setModel` and cleared in a `finally` so any
|
|
96
|
+
* exception path still releases it.
|
|
97
|
+
*
|
|
98
|
+
* Why module-level (not closure-local in the handler): the handler is
|
|
99
|
+
* registered once and shared. A handler-local `let reverting = false` is
|
|
100
|
+
* also module-scoped in practice but module-level makes the lifetime
|
|
101
|
+
* explicit. ctx-local is unavailable — ExtensionContext has no mutable
|
|
102
|
+
* scratch space we are supposed to use.
|
|
103
|
+
*/
|
|
104
|
+
let reverting = false;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Module-level "session has started" gate.
|
|
108
|
+
*
|
|
109
|
+
* Before the operator commits to a turn, model changes are configuration
|
|
110
|
+
* (CLI `--model`, settings.json default, pre-turn model selector) and the
|
|
111
|
+
* lock must NOT fire — locking pre-turn would defeat legitimate setup.
|
|
112
|
+
* Once the session has anchored a model identity (an existing conversation
|
|
113
|
+
* is loaded, or the first agent_start fires), the lock becomes active.
|
|
114
|
+
*
|
|
115
|
+
* The flag is consulted by `model_select` and set by:
|
|
116
|
+
* - `session_start`: per-reason policy (see below)
|
|
117
|
+
* - `agent_start`: always true (first turn = identity anchored)
|
|
118
|
+
*
|
|
119
|
+
* session_start reason behavior:
|
|
120
|
+
* - resume / fork: immediately true (inherited identity)
|
|
121
|
+
* - reload: preserved OR refreshed via getEntries() —
|
|
122
|
+
* guards against module re-import during
|
|
123
|
+
* extension reload that would reset the flag
|
|
124
|
+
* - startup / new (default): true IFF entries already contain a message
|
|
125
|
+
* (saved-session-on-startup path is rare
|
|
126
|
+
* today since pi-mono distinguishes
|
|
127
|
+
* resume/new explicitly, but this is a
|
|
128
|
+
* future-proof guard)
|
|
129
|
+
*/
|
|
130
|
+
let sessionLocked = false;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Probe whether the session has at least one conversational message.
|
|
134
|
+
*
|
|
135
|
+
* `entry.type === "message"` matches `SessionMessageEntry` (real user /
|
|
136
|
+
* assistant / tool-result messages). Other entry types — `model_change`,
|
|
137
|
+
* `thinking_level_change`, `label`, `custom`, `session_info`,
|
|
138
|
+
* `compaction`, `branch_summary` — are not conversational, so our own
|
|
139
|
+
* revert-induced `model_change` entries do NOT register as "started".
|
|
140
|
+
*
|
|
141
|
+
* Defensive false-positive: if `getEntries()` throws at a reload /
|
|
142
|
+
* teardown boundary, we treat the session as started and engage the
|
|
143
|
+
* lock. This is the conservative side because failing-open would let a
|
|
144
|
+
* race window break the lock. The repo's general "throw, don't warn"
|
|
145
|
+
* rule yields here because this is a lock-state probe, not config
|
|
146
|
+
* validation — silent fall-through to "locked" preserves safety.
|
|
147
|
+
*/
|
|
148
|
+
function hasStartedConversation(ctx: ExtensionContext): boolean {
|
|
149
|
+
try {
|
|
150
|
+
return ctx.sessionManager.getEntries().some((entry) => entry.type === "message");
|
|
151
|
+
} catch {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export default function (pi: ExtensionAPI) {
|
|
157
|
+
pi.on("session_start", (event, ctx) => {
|
|
158
|
+
if (event.reason === "resume" || event.reason === "fork") {
|
|
159
|
+
// Resumed/forked sessions inherit a model identity from the
|
|
160
|
+
// session being resumed/forked. Lock immediately.
|
|
161
|
+
sessionLocked = true;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (event.reason === "reload") {
|
|
165
|
+
// Reload happens in-process (extension/settings reattach).
|
|
166
|
+
// If the module was re-imported during reload, our `sessionLocked`
|
|
167
|
+
// resets to false — recover via getEntries(). If the module
|
|
168
|
+
// survived, preserve the existing flag.
|
|
169
|
+
sessionLocked = sessionLocked || hasStartedConversation(ctx);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// startup / new — empty session: pre-turn model changes are free.
|
|
173
|
+
// Non-empty session (rare today, but possible if pi-mono ever
|
|
174
|
+
// loads a saved session under `reason: "startup"`): lock.
|
|
175
|
+
sessionLocked = hasStartedConversation(ctx);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
pi.on("agent_start", () => {
|
|
179
|
+
// First prompt has triggered the agent loop. Model identity is
|
|
180
|
+
// now anchored for this session — lock from here on.
|
|
181
|
+
sessionLocked = true;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
pi.on("model_select", async (event, ctx) => {
|
|
185
|
+
// Reentry: our own revert call. Skip to avoid infinite loop.
|
|
186
|
+
if (reverting) return;
|
|
187
|
+
|
|
188
|
+
// "restore" fires when pi reloads a saved session's model on
|
|
189
|
+
// startup. Refusing that would either loop or block a legitimate
|
|
190
|
+
// resume. "set" (explicit /model, model selector, Ctrl+P) and
|
|
191
|
+
// "cycle" (next/prev) both reach the user-initiated switch path
|
|
192
|
+
// and are the cases we want to lock.
|
|
193
|
+
if (event.source === "restore") return;
|
|
194
|
+
|
|
195
|
+
// Session not yet started — pre-turn model changes are operator
|
|
196
|
+
// configuration, not a lock violation. CLI --model override,
|
|
197
|
+
// settings.json default load, and pre-turn model selector all
|
|
198
|
+
// reach here under the unlocked state.
|
|
199
|
+
if (!sessionLocked) return;
|
|
200
|
+
|
|
201
|
+
const from = event.previousModel;
|
|
202
|
+
const to = event.model;
|
|
203
|
+
|
|
204
|
+
// First model selection has no previous model to revert to.
|
|
205
|
+
// Lock only applies to in-session switches.
|
|
206
|
+
if (!from) return;
|
|
207
|
+
|
|
208
|
+
// Same model picked again (id + provider identical). Not a switch,
|
|
209
|
+
// just a no-op state set. Skip.
|
|
210
|
+
if (from.provider === to.provider && from.id === to.id) return;
|
|
211
|
+
|
|
212
|
+
// Only act when the transition touches the entwurf boundary.
|
|
213
|
+
// Covers:
|
|
214
|
+
// entwurf → entwurf (id different) — B primary, A fallback
|
|
215
|
+
// entwurf → native — B only (A out of flow)
|
|
216
|
+
// native → entwurf — B only (A not yet engaged)
|
|
217
|
+
// Skips:
|
|
218
|
+
// native → native — out of scope, free
|
|
219
|
+
const touchesEntwurf = from.provider === ENTWURF_PROVIDER || to.provider === ENTWURF_PROVIDER;
|
|
220
|
+
if (!touchesEntwurf) return;
|
|
221
|
+
|
|
222
|
+
reverting = true;
|
|
223
|
+
try {
|
|
224
|
+
const ok = await pi.setModel(from);
|
|
225
|
+
ctx.ui.notify(
|
|
226
|
+
ok
|
|
227
|
+
? `Session is locked to ${from.provider}/${from.id}; reverted attempted switch to ${to.provider}/${to.id}.`
|
|
228
|
+
: `Session is locked to ${from.provider}/${from.id}; failed to revert from ${to.provider}/${to.id} (auth missing for original model?).`,
|
|
229
|
+
ok ? "warning" : "error",
|
|
230
|
+
);
|
|
231
|
+
} finally {
|
|
232
|
+
reverting = false;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!-- ═══════════════════════════════════════════════════════════════════════
|
|
2
|
+
SAMPLE — operator engraving carrier (entwurf)
|
|
3
|
+
|
|
4
|
+
What this is: the markdown body below this comment is loaded VERBATIM
|
|
5
|
+
into the backend's identity slot (Claude `_meta.systemPrompt`). It is
|
|
6
|
+
the one place an operator stamps a short identity onto every entwurf
|
|
7
|
+
ACP turn — replacing Claude's `claude_code` preset (which also strips
|
|
8
|
+
the preset's auto-memory advertisement, the memory-containment lever).
|
|
9
|
+
|
|
10
|
+
Where it loads from:
|
|
11
|
+
• Runtime default = pi-extensions/lib/acp/prompts/engraving.md
|
|
12
|
+
(ships as the minimal `# Engraving Here` placeholder; a gate pins
|
|
13
|
+
it non-empty so the containment lever stays ON).
|
|
14
|
+
• THIS root copy is a documented sample / starting point. Point the
|
|
15
|
+
runtime at your own file with
|
|
16
|
+
ENTWURF_ACP_ENGRAVING_PATH=/path/to/your.md — it is NOT loaded by
|
|
17
|
+
default (the lib copy above is).
|
|
18
|
+
|
|
19
|
+
Rules:
|
|
20
|
+
• Template variables: {{backend}}, {{mcp_servers}}.
|
|
21
|
+
• Keep it TINY. Do NOT paste AGENTS.md, the bridge narrative, or
|
|
22
|
+
tool catalogs here — a large Claude carrier can route OAuth
|
|
23
|
+
sessions to metered "extra usage" billing.
|
|
24
|
+
• An empty or missing file = opt-out (no engraving). That is fine.
|
|
25
|
+
═══════════════════════════════════════════════════════════════════════ -->
|
|
26
|
+
|
|
27
|
+
# Engraving Here
|
package/protocol.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared wire-format constants for entwurf surfaces that must agree
|
|
3
|
+
* across the root bridge and pi-extension / MCP helper code.
|
|
4
|
+
*
|
|
5
|
+
* Keep this file dependency-free. Single source for both runtime paths:
|
|
6
|
+
* - tsc-emit path: typechecked under root tsconfig.json (allowJs: true)
|
|
7
|
+
* and copied through to the .tmp-verify directories by tsc emit.
|
|
8
|
+
* - Node --experimental-strip-types path (mcp bridges): resolves explicit
|
|
9
|
+
* .js imports literally to this file on disk.
|
|
10
|
+
*
|
|
11
|
+
* Why .js and not .ts: strip-types does not substitute .ts source for a
|
|
12
|
+
* literal .js import specifier, and the root config cannot enable
|
|
13
|
+
* allowImportingTsExtensions without losing tsc emit (which check-models
|
|
14
|
+
* relies on). Authoring as .js side-steps the whole drift surface.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Opening marker for the project-context block inserted by entwurf's
|
|
19
|
+
* `enrichTaskWithProjectContext`. The ACP bridge uses the same marker to
|
|
20
|
+
* detect entwurf-spawned first prompts and remove only the duplicate cwd
|
|
21
|
+
* AGENTS.md section from its own pi-context augment.
|
|
22
|
+
*/
|
|
23
|
+
export const ENTWURF_PROJECT_CONTEXT_OPEN_TAG = "<project-context";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Sender-side UI echo custom message type for successful entwurf_v2 send calls.
|
|
27
|
+
* The bridge emits it, the control renderer draws it, and the provider-level
|
|
28
|
+
* context filter removes it before LLM conversion. Keep as one SSOT: a typo
|
|
29
|
+
* here otherwise becomes a silent UI/context split.
|
|
30
|
+
*/
|
|
31
|
+
export const ENTWURF_SENT_MESSAGE_TYPE = "entwurf-sent";
|