@junghanacs/entwurf 0.38.1 → 0.39.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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * win32-native — the Windows preview's native surface (#78), and the only way to reach it.
3
+ *
4
+ * Node cannot open a file without following a final-component reparse point on Windows
5
+ * (libuv `UV_FS_O_NOFOLLOW` is 0), and it cannot check who owns a named pipe before
6
+ * talking to it. Those two capabilities come from one Entwurf-owned N-API addon,
7
+ * `native/win32-x64/entwurf_win.node`, built in CI from `native/win32/entwurf_win.c` and
8
+ * shipped beside `native/win32-x64/manifest.json`. This module verifies the manifest and
9
+ * the addon bytes BEFORE `process.dlopen`, and refuses by name otherwise:
10
+ *
11
+ * ENTWURF_WIN32_UNSUPPORTED not win32-x64, no addon in this install (an ordinary
12
+ * Linux pack), a Node without the N-API floor, or a
13
+ * verified addon this host cannot load
14
+ * ENTWURF_WIN32_NATIVE_DRIFT the manifest is malformed, or the addon bytes are not the
15
+ * bytes the manifest names
16
+ *
17
+ * There is no repair, no download and no fallback: a refusal is the whole answer.
18
+ *
19
+ * Authored as `.js` for the reason `control-socket-path.js` is: it is imported from the
20
+ * tsc-emit root program and from the strip-types lanes alike. `meta-session.ts` reaches it
21
+ * only through a win32-only `createRequire`, so the installers that copy `meta-session.ts`
22
+ * on their own are unchanged on Linux and macOS. Keep dependency-free except node builtins.
23
+ */
24
+ import { createHash } from "node:crypto";
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ export const WIN32_UNSUPPORTED_CODE = "ENTWURF_WIN32_UNSUPPORTED";
29
+ export const WIN32_DRIFT_CODE = "ENTWURF_WIN32_NATIVE_DRIFT";
30
+ const PACKAGE_NAME = "@junghanacs/entwurf";
31
+ const NATIVE_DIR = path.join("native", "win32-x64");
32
+ const ADDON_FILE = "entwurf_win.node";
33
+ const SHA256 = /^[0-9a-f]{64}$/;
34
+ // The addon is built against exactly this N-API version (native/win32/binding.gyp, build.mjs). A
35
+ // manifest that declares any other value describes some other build: drift, not a floor to honour.
36
+ const ADDON_NAPI_VERSION = 8;
37
+ // One addon object per process, even if two module instances of this file exist (a
38
+ // transpiling loader and Node's own loader can each evaluate it once).
39
+ const SLOT = Symbol.for("entwurf.win32-native.addon");
40
+ // DOS device names open a device, not a file, at any directory. The addon's GetFileType
41
+ // check refuses a non-disk handle anyway; this keeps the open from ever being attempted.
42
+ const DOS_DEVICE = /^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³]|conin\$|conout\$)$/i;
43
+ /**
44
+ * @param {string} code
45
+ * @param {string} message
46
+ * @param {unknown} [cause]
47
+ * @returns {Error & { code: string }}
48
+ */
49
+ function refusal(code, message, cause) {
50
+ const err = /** @type {Error & { code: string }} */ (new Error(message, cause === undefined ? undefined : { cause }));
51
+ err.code = code;
52
+ return err;
53
+ }
54
+ /**
55
+ * The package root, found from where THIS module lives: the source depth
56
+ * (`<root>/pi-extensions/lib/`) first, then the compiled depth
57
+ * (`<root>/mcp/entwurf-bridge/dist/pi-extensions/lib/`). The root is the directory whose
58
+ * `package.json` names this package; `null` when neither depth does.
59
+ *
60
+ * @param {string} [from]
61
+ * @returns {string | null}
62
+ */
63
+ export function win32NativePackageRoot(from = path.dirname(fileURLToPath(import.meta.url))) {
64
+ for (const up of [
65
+ ["..", ".."],
66
+ ["..", "..", "..", "..", ".."],
67
+ ]) {
68
+ const root = path.resolve(from, ...up);
69
+ let name;
70
+ try {
71
+ name = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"))?.name;
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ if (name === PACKAGE_NAME)
77
+ return root;
78
+ }
79
+ return null;
80
+ }
81
+ /**
82
+ * Verify `<root>/native/win32-x64/` and return the addon path to load. Pure apart from
83
+ * reading the two files, so the refusals are provable on any host.
84
+ *
85
+ * @param {string} root
86
+ * @param {{ napi?: string | undefined }} [versions]
87
+ * @returns {{ addonPath: string, sha256: string, napiVersion: number }}
88
+ */
89
+ export function verifyWin32NativeAt(root, versions = process.versions) {
90
+ const dir = path.join(root, NATIVE_DIR);
91
+ const manifestPath = path.join(dir, "manifest.json");
92
+ let raw;
93
+ try {
94
+ raw = fs.readFileSync(manifestPath, "utf8");
95
+ }
96
+ catch (err) {
97
+ if ( /** @type {{ code?: unknown }} */(err).code === "ENOENT") {
98
+ throw refusal(WIN32_UNSUPPORTED_CODE, `this entwurf install carries no Windows addon (${manifestPath} is absent): only the Windows preview ` +
99
+ "artifact runs on Windows; an ordinary pack does not.");
100
+ }
101
+ throw refusal(WIN32_DRIFT_CODE, `cannot read ${manifestPath}`, err);
102
+ }
103
+ let manifest;
104
+ try {
105
+ manifest = JSON.parse(raw);
106
+ }
107
+ catch (err) {
108
+ throw refusal(WIN32_DRIFT_CODE, `${manifestPath} is not JSON`, err);
109
+ }
110
+ if (manifest?.schema !== 1 ||
111
+ manifest.platform !== "win32" ||
112
+ manifest.arch !== "x64" ||
113
+ manifest.file !== ADDON_FILE ||
114
+ typeof manifest.sha256 !== "string" ||
115
+ !SHA256.test(manifest.sha256) ||
116
+ manifest.napiVersion !== ADDON_NAPI_VERSION) {
117
+ throw refusal(WIN32_DRIFT_CODE, `${manifestPath} does not describe one win32-x64 ${ADDON_FILE}`);
118
+ }
119
+ const napi = Number(versions.napi);
120
+ if (!(napi >= ADDON_NAPI_VERSION)) {
121
+ throw refusal(WIN32_UNSUPPORTED_CODE, `the Windows addon needs N-API ${ADDON_NAPI_VERSION}; this Node offers ${versions.napi ?? "none"}`);
122
+ }
123
+ const addonPath = path.join(dir, ADDON_FILE);
124
+ let bytes;
125
+ try {
126
+ bytes = fs.readFileSync(addonPath);
127
+ }
128
+ catch (err) {
129
+ throw refusal(WIN32_DRIFT_CODE, `the manifest names ${addonPath}, which cannot be read`, err);
130
+ }
131
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
132
+ if (sha256 !== manifest.sha256) {
133
+ throw refusal(WIN32_DRIFT_CODE, `${addonPath} is not the addon its manifest names (sha256 ${sha256} != ${manifest.sha256})`);
134
+ }
135
+ return { addonPath, sha256, napiVersion: ADDON_NAPI_VERSION };
136
+ }
137
+ /**
138
+ * @typedef {object} Win32NativeAddon
139
+ * @property {(file: string) => Buffer} readNoFollow
140
+ * @property {(name: string, line: string, timeoutMs: number) => Promise<{ responseLine: string, serverPid: number }>} pipeRpc
141
+ * @property {(name: string, timeoutMs: number) => Promise<{ serverPid: number }>} pipeProbe
142
+ * @property {() => { live: number, cancelledIncomplete: number }} ioStats
143
+ */
144
+ /** @returns {Win32NativeAddon} */
145
+ export function win32Native() {
146
+ const held = /** @type {Record<symbol, Win32NativeAddon | undefined>} */ ( /** @type {unknown} */(process))[SLOT];
147
+ if (held)
148
+ return held;
149
+ if (process.platform !== "win32" || process.arch !== "x64") {
150
+ throw refusal(WIN32_UNSUPPORTED_CODE, `the Windows native surface exists only on win32-x64, not ${process.platform}-${process.arch}`);
151
+ }
152
+ const root = win32NativePackageRoot();
153
+ if (root === null) {
154
+ throw refusal(WIN32_UNSUPPORTED_CODE, `cannot find the ${PACKAGE_NAME} package root beside this module`);
155
+ }
156
+ const { addonPath } = verifyWin32NativeAt(root);
157
+ const module = { exports: {} };
158
+ try {
159
+ process.dlopen(module, addonPath);
160
+ }
161
+ catch (err) {
162
+ throw refusal(WIN32_UNSUPPORTED_CODE, `this host cannot load the verified Windows addon ${addonPath}`, err);
163
+ }
164
+ const addon = /** @type {Win32NativeAddon} */ (module.exports);
165
+ /** @type {Record<symbol, Win32NativeAddon | undefined>} */ ( /** @type {unknown} */(process))[SLOT] = addon;
166
+ return addon;
167
+ }
168
+ /**
169
+ * Read a record's bytes on Windows, refusing a final-component reparse point (`ELOOP`) and
170
+ * a non-regular entry (`ENTWURF_ENOTREG`) on the very handle the read uses. Over 16 MiB is
171
+ * `EFBIG`; other Win32 failures are `EWIN32_<n>`.
172
+ *
173
+ * @param {string} file
174
+ * @returns {Buffer}
175
+ */
176
+ export function win32ReadNoFollow(file) {
177
+ const stem = path.win32.basename(file).split(".")[0].replace(/[ ]+$/, "");
178
+ if (DOS_DEVICE.test(stem)) {
179
+ throw refusal("ENTWURF_ENOTREG", `${path.win32.basename(file)} names a DOS device, never a record`);
180
+ }
181
+ return win32Native().readNoFollow(file);
182
+ }
@@ -108,6 +108,7 @@ import {
108
108
  } from "../../../pi-extensions/lib/meta-session.ts";
109
109
  import { RESUME_CALL_REJECT_HINT, resumeCall } from "../../../pi-extensions/lib/mux-resume-call.ts";
110
110
  import { registerNativeConversation } from "../../../pi-extensions/lib/native-push/register.ts";
111
+ import { probeSocketLiveness } from "../../../pi-extensions/lib/socket-probe.ts";
111
112
 
112
113
  const HOME = os.homedir();
113
114
 
@@ -137,6 +138,14 @@ try {
137
138
  // ENTWURF_DIR override the pi side does not. The path GRAMMAR is the shared leaf.
138
139
  const ENTWURF_DIR = process.env.ENTWURF_DIR ?? defaultControlSocketDir(HOME);
139
140
 
141
+ /** Is this session's own control address up? A socket file's presence on Linux/macOS. On
142
+ * Windows (#78) the address is a named pipe, which has no file to look at and must not be
143
+ * opened by a stat (that connects): the owner-validated probe answers instead. */
144
+ async function controlAddressPresent(socketPath: string): Promise<boolean> {
145
+ if (process.platform === "win32") return (await probeSocketLiveness(socketPath)) === "alive";
146
+ return existsSync(socketPath);
147
+ }
148
+
140
149
  // ============================================================================
141
150
  // Live control-socket discovery for entwurf_peers lives in the TS fact-provider
142
151
  // (pi-extensions/lib/entwurf-fact-provider.ts → listEntwurfFacts), which the
@@ -219,7 +228,7 @@ class EntwurfSenderIdentityError extends Error {
219
228
  }
220
229
  }
221
230
 
222
- function buildStrictPiSenderEnvelope(): SenderEnvelope {
231
+ async function buildStrictPiSenderEnvelope(): Promise<SenderEnvelope> {
223
232
  const sessionId = process.env.PI_SESSION_ID?.trim();
224
233
  const agentId = process.env.PI_AGENT_ID?.trim();
225
234
  const cwd = process.cwd();
@@ -239,7 +248,7 @@ function buildStrictPiSenderEnvelope(): SenderEnvelope {
239
248
  const socketPath = controlSocketPathIn(ENTWURF_DIR, sessionId as string);
240
249
  const self = computeSelfAddressability({
241
250
  origin: "pi-session",
242
- socketAlive: existsSync(socketPath),
251
+ socketAlive: await controlAddressPresent(socketPath),
243
252
  socketPathComputable: true,
244
253
  });
245
254
  return {
@@ -338,7 +347,7 @@ async function resolveAuthoritativeSender(
338
347
  ): Promise<AuthoritativeSelf | null> {
339
348
  const sessionId = process.env.PI_SESSION_ID?.trim();
340
349
  const agentId = process.env.PI_AGENT_ID?.trim();
341
- const pi = sessionId && agentId && cwd ? buildStrictPiSenderEnvelope() : null;
350
+ const pi = sessionId && agentId && cwd ? await buildStrictPiSenderEnvelope() : null;
342
351
  const marker = resolveTrustedMetaSenderIdentity({
343
352
  markerPath: process.env.ENTWURF_META_SENDER_MARKER?.trim() || undefined,
344
353
  });
@@ -559,7 +568,7 @@ server.tool(
559
568
  // live socket). The old code synthesized the path and printed it as if
560
569
  // it existed — a lie when the session has no --entwurf-control (SE-1).
561
570
  const socketPath = controlSocketPathIn(ENTWURF_DIR, sender.sessionId);
562
- const socketState = existsSync(socketPath) ? "alive" : "expected";
571
+ const socketState = (await controlAddressPresent(socketPath)) ? "alive" : "expected";
563
572
  extra.socketPath = socketPath;
564
573
  extra.socketState = socketState;
565
574
  lines.push(
@@ -0,0 +1,45 @@
1
+ {
2
+ "schema": 1,
3
+ "platform": "win32",
4
+ "arch": "x64",
5
+ "file": "entwurf_win.node",
6
+ "sha256": "9837893521ec1d033913ca6cd1b705cb6e942e8d5c288948a1ed0ca0e28655e5",
7
+ "napiVersion": 8,
8
+ "builtWith": {
9
+ "node": "24.18.1",
10
+ "nodeGyp": "13.1.0",
11
+ "platformToolset": "v145",
12
+ "windowsTargetPlatformVersion": "10.0.26100.0",
13
+ "visualStudio": {
14
+ "displayName": "Visual Studio Enterprise 2026",
15
+ "installationVersion": "18.10.12224.181"
16
+ }
17
+ },
18
+ "source": {
19
+ "entwurf_win.c": "aa8a2994833d6dbbbf56c78687d42df52f645f3f35073943d897f58169219c31",
20
+ "binding.gyp": "be95dcfb152a91377a011aedbadd7943965ede251612394eacb405b20689f18e",
21
+ "package.json": "6ba65c909857f18a5d8e16b63fa55201b1d17261cc4afb1f2eea10514b2cac6a",
22
+ "package-lock.json": "d7dc3c48d003f2356d961972ad5515f192f2c846af8d0b551fa08d20ce155ede",
23
+ "build.mjs": "68120f85289e9598f440b1e0be506feb50a37897414d234ffc6698b754b2988e"
24
+ },
25
+ "provenance": {
26
+ "statement": "tracked C + declared upstream build glue",
27
+ "compiled": {
28
+ "entwurf_win.c": "tracked source",
29
+ "node-gyp/src/win_delay_load_hook.cc": {
30
+ "version": "13.1.0",
31
+ "sha256": "ec2357ffdf512151c21a52326ad3396aaa650b83e5c4a31153d216a155f68ecc",
32
+ "license": "MIT",
33
+ "notice": "LICENSES/node-gyp.txt",
34
+ "licenseSha256": "662a1b0115251cfb29c6aed0f221f8847bc49c6365d1c53a62c9f4bccc2489c3"
35
+ }
36
+ },
37
+ "declaredLinkInputs": [
38
+ "advapi32.lib",
39
+ "kernel32.lib",
40
+ "node.lib (import library, Node v24.18.1 win-x64)",
41
+ "MSVC static CRT (/MT)"
42
+ ],
43
+ "headers": "Node v24.18.1 headers tarball (MIT), declarations only"
44
+ }
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junghanacs/entwurf",
3
- "version": "0.38.1",
3
+ "version": "0.39.0",
4
4
  "description": "Visible sibling sessions across independent agent harnesses: launch in Herdr or tmux, reopen pi in tmux, and deliver messages by garden id; native bridges and Claude/Cortex ACP adapters.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -41,6 +41,8 @@
41
41
  "files": [
42
42
  "protocol.js",
43
43
  "pi-extensions/",
44
+ "native/win32-x64/",
45
+ "LICENSES/node-gyp.txt",
44
46
  "mcp/",
45
47
  "scripts/",
46
48
  "prompts/",
@@ -194,7 +196,7 @@
194
196
  "check:core": "pnpm run check:toolchain && pnpm run check:vitest && pnpm run check:contracts",
195
197
  "check:toolchain": "pnpm lint && pnpm typecheck",
196
198
  "check:vitest": "./run.sh check-mux-fresh-call && ./run.sh check-acp-sdk-surface && ./run.sh check-pi-queue-oracle && ./run.sh check-tests-beside-behavior",
197
- "check:contracts": "./run.sh check-demo-reproducibility && ./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-harness-admission-parity && ./run.sh check-capability-bundle-reach && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-capability-source && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-copilot-statusline && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-runner && ./run.sh check-control-send-receipt && ./run.sh check-compaction-send-guard && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-resume-launch-identity && ./run.sh check-mux-placement && ./run.sh check-mux-launch && ./run.sh check-mux-resume-call && ./run.sh check-mux-parent-artifact && ./run.sh check-mux-launcher-fence && ./run.sh check-entwurf-v2-visible-resume && ./run.sh check-control-socket-path && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-herdr-placement && ./run.sh check-herdr-fresh-call && ./run.sh check-herdr-supply && ./run.sh check-fresh-call-dispatch && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-auth-boundary && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-carrier-augment && ./run.sh check-acp-foreground-only && ./run.sh check-acp-set-model-readback && ./run.sh check-acp-vendor-rejection && ./run.sh check-codex-sender-identity && ./run.sh check-codex-native-push && ./run.sh check-pi-durable-beside && ./run.sh check-pi-durable-carrier && ./run.sh check-pi-durable-native-module",
199
+ "check:contracts": "./run.sh check-demo-reproducibility && ./run.sh check-shell-quote && ./run.sh check-entwurf-session-identity && ./run.sh check-meta-session && ./run.sh check-meta-v3-record && ./run.sh check-mailbox-receipt-state && ./run.sh check-entwurf-capabilities && ./run.sh check-harness-admission-parity && ./run.sh check-capability-bundle-reach && ./run.sh check-meta-mailbox-state-write && ./run.sh check-meta-receiver-marker && ./run.sh check-meta-capability-source && ./run.sh check-model-lock && ./run.sh check-dep-versions && ./run.sh check-node-floor-coherence && ./run.sh check-claude-floor-coherence && ./run.sh check-copilot-statusline && ./run.sh check-pi-import-surface && ./run.sh check-env-namespace && ./run.sh check-pi-runtime-version && ./run.sh check-pi-preflight && ./run.sh check-project-trust-handler && ./run.sh check-entwurf-v2-contract && ./run.sh check-entwurf-v2-send && ./run.sh check-entwurf-v2-runner && ./run.sh check-control-send-receipt && ./run.sh check-compaction-send-guard && ./run.sh check-entwurf-resume-args && ./run.sh check-entwurf-v2-native-push && ./run.sh check-entwurf-v2-production && ./run.sh check-entwurf-v2-surface && ./run.sh check-entwurf-bridge-pi-free && ./run.sh check-resume-launch-identity && ./run.sh check-mux-placement && ./run.sh check-mux-launch && ./run.sh check-mux-resume-call && ./run.sh check-mux-parent-artifact && ./run.sh check-mux-launcher-fence && ./run.sh check-entwurf-v2-visible-resume && ./run.sh check-control-socket-path && ./run.sh check-win32-preview && ./run.sh check-pack-win32-carrier && ./run.sh check-meta-listing && ./run.sh check-entwurf-fact-provider && ./run.sh check-herdr-placement && ./run.sh check-herdr-fresh-call && ./run.sh check-herdr-supply && ./run.sh check-fresh-call-dispatch && ./run.sh check-entwurf-peers-surface && ./run.sh check-entwurf-self-address && ./run.sh check-entwurf-deliverability && ./run.sh check-native-push-adapter && ./run.sh check-native-push-register && ./run.sh check-auth-boundary && ./run.sh check-acp-overlay && ./run.sh check-acp-tool-surface && ./run.sh check-acp-event-mapper && ./run.sh check-acp-prompt-builder && ./run.sh check-acp-config && ./run.sh check-acp-session-store && ./run.sh check-acp-carrier-augment && ./run.sh check-acp-foreground-only && ./run.sh check-acp-set-model-readback && ./run.sh check-acp-vendor-rejection && ./run.sh check-codex-sender-identity && ./run.sh check-codex-native-push && ./run.sh check-pi-durable-beside && ./run.sh check-pi-durable-carrier && ./run.sh check-pi-durable-native-module",
198
200
  "check:hermetic": "./run.sh check-bridge-delivery && ./run.sh smoke-pi-attach && ./run.sh check-fresh-cut-gate && ./run.sh check-hook-launch-topology && ./run.sh check-meta-hook-session-switch && ./run.sh check-copilot-birth-hook && ./run.sh check-copilot-receive-arm && ./run.sh check-copilot-launch && ./run.sh check-pi-launch && ./run.sh check-codex-app-server-launch && ./run.sh check-meta-identity-consumers && ./run.sh check-socket-probe && ./run.sh smoke-meta-honesty && ./run.sh check-meta-doctor-oracle && ./run.sh check-agy-permission-matrix && ./run.sh smoke-agy-statusline-state && ./run.sh smoke-copilot-statusline-state && ./run.sh smoke-copilot-mcp-state && ./run.sh smoke-omp-bridge-state && ./run.sh smoke-omp-mcp-state && ./run.sh smoke-omp-receive-state && ./run.sh check-omp-birth-hook && ./run.sh check-omp-receive-arm && ./run.sh check-omp-fresh-preflight && ./run.sh smoke-agy-hooks-state && ./run.sh smoke-pi-provider-state && ./run.sh smoke-user-scope-citizen && ./run.sh smoke-meta-prune && ./run.sh smoke-meta-keyset-guard && ./run.sh check-meta-manifest-schema && ./run.sh check-entwurf-control-rpc && ./run.sh check-entwurf-bridge-boot && ./run.sh check-probe-bridge-command && ./run.sh check-meta-facts && ./run.sh check-peer-facts && ./run.sh check-herdr-plugin && ./run.sh check-herdr-plugin-profile && ./run.sh check-herdr-plugin-build && ./run.sh check-herdr-runtime-bootstrap && ./run.sh check-herdr-activation && ./run.sh check-agy-sender-identity && ./run.sh check-acp-provider-surface && ./run.sh check-acp-stop-reason && ./run.sh check-acp-prompt-lifecycle && ./run.sh check-acp-usage-accounting && ./run.sh check-acp-launch-namespace && ./run.sh check-acp-stream-hooks && ./run.sh check-acp-backend-preflight && ./run.sh check-acp-session-reuse && ./run.sh check-typing-call-fence && ./run.sh check-gate-manifests && ./run.sh check-release-gate-outcomes && ./run.sh check-probe-ordering && ./run.sh check-probe-cli-shim && ./run.sh check-acp-cortex && ./run.sh check-codex-bridge-identity && ./run.sh check-pi-bridge-sender && ./run.sh check-pi-mcp-register && ./run.sh check-pi-mcp-bridge && ./run.sh check-codex-birth-hook && ./run.sh smoke-codex-config-state && ./run.sh smoke-codex-birth && ./run.sh check-herdr-sandbox",
199
201
  "check:package": "./run.sh check-install-surface && ./run.sh smoke-meta-install-state && ./run.sh smoke-agy-install-state && ./run.sh smoke-setup-verdict && ./run.sh check-pack-pin-matcher && ./run.sh check-pack-consumer-roots && ./run.sh check-package-source-routing && ./run.sh check-install-preflight && ./run.sh check-pack"
200
202
  },
@@ -147,8 +147,12 @@ async function ensureControlDir(): Promise<void> {
147
147
  await fs.mkdir(ENTWURF_DIR, { recursive: true });
148
148
  }
149
149
 
150
+ // On Windows the control address is a named pipe (`\\.\pipe\…`, #78): the kernel owns the
151
+ // name and drops it with the last handle, so there is no file to unlink and none to sweep.
152
+ const PIPE_ADDRESSED = process.platform === "win32";
153
+
150
154
  async function removeSocket(socketPath: string | null): Promise<void> {
151
- if (!socketPath) return;
155
+ if (!socketPath || PIPE_ADDRESSED) return;
152
156
  try {
153
157
  await fs.unlink(socketPath);
154
158
  } catch (error) {
@@ -162,6 +166,7 @@ async function removeSocket(socketPath: string | null): Promise<void> {
162
166
  // alias-era artifacts. Runs once per startControlServer call. We only touch
163
167
  // entries that are demonstrably dead — a live peer's socket survives.
164
168
  async function gcStaleSockets(): Promise<void> {
169
+ if (PIPE_ADDRESSED) return;
165
170
  let entries: import("node:fs").Dirent[];
166
171
  try {
167
172
  entries = await fs.readdir(ENTWURF_DIR, { withFileTypes: true });
@@ -24,9 +24,18 @@
24
24
  * adapter keeps its own root policy: the pi side derives from HOME, the MCP
25
25
  * bridge honours `ENTWURF_DIR`. What is unified is the grammar, not the policy.
26
26
  *
27
- * Keep dependency-free except `node:path`.
27
+ * Windows (#78) has no socket files: the address is a named pipe,
28
+ * `\\.\pipe\entwurf-<sha256(canonRoot(dir))[:16]>-<gardenId>`. The digest keeps two
29
+ * store roots on one host apart without putting a path in the pipe namespace; it is not
30
+ * a new address axis (still the garden id plus the store root). The inverse grammar has
31
+ * no Windows half because nothing scans the pipe namespace: candidates come from records.
32
+ *
33
+ * Keep dependency-free except node builtins (`node:path`, and `node:crypto`/`node:fs`
34
+ * for the Windows root canonicalisation only).
28
35
  */
29
36
 
37
+ import { createHash } from "node:crypto";
38
+ import * as fs from "node:fs";
30
39
  import * as path from "node:path";
31
40
 
32
41
  /** Control-socket filename suffix. The stem is a bare garden id. */
@@ -51,9 +60,55 @@ export function defaultControlSocketDir(home) {
51
60
  * @returns {string}
52
61
  */
53
62
  export function controlSocketPathIn(dir, gardenId) {
63
+ if (process.platform === "win32") return win32PipeNameFor(dir, gardenId);
54
64
  return path.join(dir, `${gardenId}${CONTROL_SOCKET_SUFFIX}`);
55
65
  }
56
66
 
67
+ /** Every Windows control pipe this package names starts here. */
68
+ export const WIN32_PIPE_PREFIX = "\\\\.\\pipe\\entwurf-";
69
+
70
+ /**
71
+ * The one spelling of a Windows store root, so Pi A, Pi B, the bridge and peer-facts
72
+ * derive the same pipe name from differently written paths. The nearest EXISTING ancestor
73
+ * of `path.win32.resolve(dir)` goes through `realpath` (8.3 names expanded, case from the
74
+ * filesystem); the components below it are appended as written. Separators become `\`,
75
+ * there is no trailing separator, and the whole string is lower-cased. Nothing is created
76
+ * or touched, so the control dir need not exist yet. A failure other than ENOENT throws.
77
+ *
78
+ * @param {string} dir
79
+ * @param {(p: string) => string} [realpath]
80
+ * @returns {string}
81
+ */
82
+ export function canonRootWin32(dir, realpath = fs.realpathSync.native) {
83
+ let head = path.win32.resolve(dir);
84
+ /** @type {string[]} */
85
+ const tail = [];
86
+ for (;;) {
87
+ try {
88
+ const joined = path.win32.join(realpath(head), ...tail);
89
+ return joined.replace(/[\\/]+$/, "").toLowerCase();
90
+ } catch (err) {
91
+ const parent = path.win32.dirname(head);
92
+ if (/** @type {{ code?: unknown }} */ (err).code !== "ENOENT" || parent === head) throw err;
93
+ tail.unshift(path.win32.basename(head));
94
+ head = parent;
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Forward grammar on Windows: `\\.\pipe\entwurf-<16 hex>-<gardenId>`.
101
+ *
102
+ * @param {string} dir
103
+ * @param {string} gardenId
104
+ * @param {(p: string) => string} [realpath]
105
+ * @returns {string}
106
+ */
107
+ export function win32PipeNameFor(dir, gardenId, realpath) {
108
+ const digest = createHash("sha256").update(canonRootWin32(dir, realpath), "utf8").digest("hex").slice(0, 16);
109
+ return `${WIN32_PIPE_PREFIX}${digest}-${gardenId}`;
110
+ }
111
+
57
112
  /**
58
113
  * Inverse grammar: a socket filename back to its garden id, or `null` when the
59
114
  * name does not carry the suffix.
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import * as net from "node:net";
18
+ import { win32Native } from "./win32-native.js";
18
19
 
19
20
  // `sender` carries the transparency envelope (agentId / sessionId / cwd /
20
21
  // timestamp). All four fields are mandatory whenever `sender` is present —
@@ -199,6 +200,7 @@ export async function sendRpcCommand(
199
200
  options: RpcClientOptions = {},
200
201
  ): Promise<{ response: RpcResponse }> {
201
202
  const { timeout = 5000 } = options;
203
+ if (process.platform === "win32") return await sendPipeRpcCommand(socketPath, command, timeout);
202
204
 
203
205
  return new Promise((resolve, reject) => {
204
206
  const socket = net.createConnection(socketPath);
@@ -277,3 +279,33 @@ export async function sendRpcCommand(
277
279
  });
278
280
  });
279
281
  }
282
+
283
+ /**
284
+ * The Windows client (#78). The addon makes one owner-validated connect, writes the request
285
+ * line and returns the first response line, all inside one deadline; a timeout is
286
+ * `ETIMEDOUT`, a pipe owned by anyone but this token's expected owner is `EPERM` before any
287
+ * byte. The protocol is request-bound on its own connection, so that first line IS the
288
+ * answer: anything other than this command's response is refused, never waited past.
289
+ */
290
+ async function sendPipeRpcCommand(
291
+ pipeName: string,
292
+ command: RpcCommand,
293
+ timeout: number,
294
+ ): Promise<{ response: RpcResponse }> {
295
+ const { responseLine } = await win32Native().pipeRpc(
296
+ pipeName,
297
+ JSON.stringify(command),
298
+ Math.max(0, Math.floor(timeout)),
299
+ );
300
+ let msg: unknown;
301
+ try {
302
+ msg = JSON.parse(responseLine.trim());
303
+ } catch {
304
+ throw new Error(`control pipe answered ${command.type} with a line that is not JSON`);
305
+ }
306
+ const response = msg as RpcResponse & { type?: unknown };
307
+ if (response?.type !== "response" || response.command !== command.type) {
308
+ throw new Error(`control pipe answered something other than the ${command.type} response`);
309
+ }
310
+ return { response };
311
+ }
@@ -53,6 +53,7 @@
53
53
  import { execFileSync } from "node:child_process";
54
54
  import * as crypto from "node:crypto";
55
55
  import * as fs from "node:fs";
56
+ import { createRequire } from "node:module";
56
57
  import * as os from "node:os";
57
58
  import * as path from "node:path";
58
59
  import { generateSessionId, SESSION_ID_RE } from "./session-id.js";
@@ -1017,6 +1018,7 @@ const O_NONBLOCK_FLAG = typeof fs.constants.O_NONBLOCK === "number" ? fs.constan
1017
1018
  * Shaping a message is the job of the caller that knows what the answer is FOR.
1018
1019
  */
1019
1020
  export function readStoreRecordFile(file: string): string {
1021
+ if (process.platform === "win32") return win32RecordReader()(file).toString("utf8");
1020
1022
  if (O_NOFOLLOW_FLAG === 0) {
1021
1023
  throw new MetaRecordError(
1022
1024
  `cannot read meta-record ${path.basename(file)} safely: this platform exposes no O_NOFOLLOW, so a record ` +
@@ -1039,6 +1041,18 @@ export function readStoreRecordFile(file: string): string {
1039
1041
  }
1040
1042
  }
1041
1043
 
1044
+ /**
1045
+ * Windows has no `O_NOFOLLOW` (libuv defines it as 0), so the same three decisions — a
1046
+ * final-component reparse point refused as `ELOOP`, a non-regular entry as
1047
+ * `ENTWURF_ENOTREG`, both on the handle the read uses — are made by the preview addon
1048
+ * (#78). It is reached only here and only on win32, through `createRequire`, so the
1049
+ * installers that copy this file without its siblings never load it on Linux or macOS.
1050
+ */
1051
+ function win32RecordReader(): (file: string) => Buffer {
1052
+ const native = createRequire(import.meta.url)("./win32-native.js") as typeof import("./win32-native.js");
1053
+ return native.win32ReadNoFollow;
1054
+ }
1055
+
1042
1056
  /** The reader every store-WIDE scan pairs with {@link readActiveStoreEntries}. The
1043
1057
  * caller's entry kind decides the POLICY — whether a non-regular entry is a defect
1044
1058
  * (certification), a diagnostic (listing) or a non-candidate (the rival scan) — because
@@ -57,6 +57,7 @@ import {
57
57
  controlSocketPathIn,
58
58
  defaultControlSocketDir,
59
59
  gardenIdFromSocketFilename,
60
+ WIN32_PIPE_PREFIX,
60
61
  } from "./control-socket-path.js";
61
62
  import type { SocketProbe } from "./entwurf-facts.ts";
62
63
  import { SESSION_ID_RE } from "./session-id.js";
@@ -117,7 +118,9 @@ export type TargetSocketInspection =
117
118
  * `record-less-socket` reject (a diagnostic state) — no probe, no acceptance.
118
119
  */
119
120
  export function isRecordLessSocketCandidate(inspection: TargetSocketInspection): boolean {
120
- return inspection.kind === "socket-file";
121
+ // A Windows pipe name (#78) is never lstat-ed (see inspectControlSocketPath), so its
122
+ // `socket-file` carries no presence evidence and cannot confirm a record-less socket.
123
+ return inspection.kind === "socket-file" && !inspection.socketPath.startsWith(WIN32_PIPE_PREFIX);
121
124
  }
122
125
 
123
126
  export interface LstatLike {
@@ -139,6 +142,11 @@ export async function inspectControlSocketPath(
139
142
  socketPath: string,
140
143
  lstatFn: (p: string) => Promise<LstatLike> = (p) => fs.lstat(p),
141
144
  ): Promise<TargetSocketInspection> {
145
+ // Windows (#78): the address is a named pipe. The pipe filesystem has no symlinks to
146
+ // catch, and opening the name to stat it would CONNECT to it and consume an instance.
147
+ // So the name is never stat-ed: it is safe to probe because the probe itself refuses a
148
+ // pipe whose owner is not this token's expected owner before any byte.
149
+ if (socketPath.startsWith(WIN32_PIPE_PREFIX)) return { kind: "socket-file", socketPath };
142
150
  let st: LstatLike;
143
151
  try {
144
152
  st = await lstatFn(socketPath);
@@ -243,9 +251,12 @@ export async function scanSocketProbes(
243
251
  deps: Partial<SocketScanDeps> = {},
244
252
  ): Promise<SocketScanResult> {
245
253
  const dir = deps.dir ?? CONTROL_SOCKET_DIR;
254
+ // Windows (#78) has no socket files to list and nothing scans the pipe namespace: the
255
+ // candidates are the record-backed citizens alone.
246
256
  const readdir =
247
257
  deps.readdir ??
248
258
  (async (d: string): Promise<SocketDirEntry[]> => {
259
+ if (process.platform === "win32") return [];
249
260
  const dirents = await fs.readdir(d, { withFileTypes: true });
250
261
  return dirents.map((e) => ({ name: e.name, isSymbolicLink: e.isSymbolicLink() }));
251
262
  });
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  import * as net from "node:net";
32
+ import { win32Native } from "./win32-native.js";
32
33
 
33
34
  export type SocketLiveness = "alive" | "dead" | "indeterminate";
34
35
 
@@ -66,6 +67,7 @@ export async function probeSocketLiveness(
66
67
  opts: { timeoutMs?: number } = {},
67
68
  ): Promise<SocketLiveness> {
68
69
  const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
70
+ if (process.platform === "win32") return await probePipeLiveness(socketPath, timeoutMs);
69
71
  return await new Promise<SocketLiveness>((resolve) => {
70
72
  const socket = net.createConnection(socketPath);
71
73
  const timer = setTimeout(() => {
@@ -86,3 +88,21 @@ export async function probeSocketLiveness(
86
88
  });
87
89
  });
88
90
  }
91
+
92
+ /**
93
+ * The Windows probe (#78): the control address is a named pipe, and a connect alone would
94
+ * talk to whoever holds the name. The addon connects within one deadline and refuses a pipe
95
+ * whose owner is not this token's expected owner (`EPERM`) before any byte. Only `ENOENT`
96
+ * (no instance) is dead; a refused owner, a busy pipe past the deadline and every Win32
97
+ * failure stay indeterminate. A missing or drifted addon is not a liveness fact: that
98
+ * refusal throws.
99
+ */
100
+ async function probePipeLiveness(pipeName: string, timeoutMs: number): Promise<SocketLiveness> {
101
+ const native = win32Native();
102
+ try {
103
+ await native.pipeProbe(pipeName, Math.max(0, Math.floor(timeoutMs)));
104
+ return "alive";
105
+ } catch (err) {
106
+ return classifyConnectError((err as { code?: string }).code);
107
+ }
108
+ }