@junghanacs/entwurf 0.38.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/BASELINE.md +11 -4
  2. package/CHANGELOG.md +323 -0
  3. package/DELIVERY.md +34 -0
  4. package/LICENSES/node-gyp.txt +24 -0
  5. package/README.md +20 -6
  6. package/VERIFY.md +23 -4
  7. package/docs/setup-clean-host.md +43 -2
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +32 -9
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-socket-path.js +56 -1
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +25 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +10 -4
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +14 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +123 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +259 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js +80 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +14 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-probe.js +21 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/win32-native.js +182 -0
  19. package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +65 -0
  20. package/mcp/entwurf-bridge/src/index.ts +38 -9
  21. package/mcp/entwurf-bridge/tsconfig.build.json +7 -0
  22. package/native/win32-x64/entwurf_win.node +0 -0
  23. package/native/win32-x64/manifest.json +45 -0
  24. package/package.json +5 -3
  25. package/pi-extensions/entwurf-control.ts +6 -1
  26. package/pi-extensions/lib/control-socket-path.js +56 -1
  27. package/pi-extensions/lib/entwurf-control-rpc.ts +32 -0
  28. package/pi-extensions/lib/meta-mailbox-body.ts +13 -4
  29. package/pi-extensions/lib/meta-session.ts +14 -0
  30. package/pi-extensions/lib/remote-pair-receive.ts +184 -0
  31. package/pi-extensions/lib/remote-pair-send.ts +325 -0
  32. package/pi-extensions/lib/remote-pair-transport.ts +81 -0
  33. package/pi-extensions/lib/socket-discovery.ts +12 -1
  34. package/pi-extensions/lib/socket-probe.ts +20 -0
  35. package/pi-extensions/lib/win32-native.js +198 -0
  36. package/run.sh +149 -1
  37. package/scripts/check-entwurf-self-address.ts +15 -4
  38. package/scripts/check-gate-qualification.ts +8 -1
  39. package/scripts/check-remote-pair-installed.ts +242 -0
  40. package/scripts/lib/remote-pair-installed-audit.ts +25 -0
  41. package/scripts/mutants/control-send-receipt.json +2 -2
  42. package/scripts/mutants/pack-install.json +28 -2
  43. package/scripts/mutants/remote-pair-display.json +53 -0
  44. package/scripts/mutants/remote-pair-installed-audit.json +28 -0
  45. package/scripts/mutants/remote-pair-receive.json +126 -0
  46. package/scripts/mutants/remote-pair-roundtrip.json +83 -0
  47. package/scripts/mutants/remote-pair-send.json +194 -0
  48. package/scripts/mutants/remote-pair-transport.json +66 -0
  49. package/scripts/mutants/win32-preview.json +215 -0
  50. package/scripts/remote-pair-receive.ts +73 -0
@@ -108,6 +108,15 @@ 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 {
112
+ makeLocalTraceFact,
113
+ readRemotePairOptIn,
114
+ remotePairOptInPath,
115
+ runEntwurfV2WithRemoteRoute,
116
+ } from "../../../pi-extensions/lib/remote-pair-send.ts";
117
+ import { processTransport } from "../../../pi-extensions/lib/remote-pair-transport.ts";
118
+ import { CONTROL_SOCKET_DIR } from "../../../pi-extensions/lib/socket-discovery.ts";
119
+ import { probeSocketLiveness } from "../../../pi-extensions/lib/socket-probe.ts";
111
120
 
112
121
  const HOME = os.homedir();
113
122
 
@@ -137,6 +146,14 @@ try {
137
146
  // ENTWURF_DIR override the pi side does not. The path GRAMMAR is the shared leaf.
138
147
  const ENTWURF_DIR = process.env.ENTWURF_DIR ?? defaultControlSocketDir(HOME);
139
148
 
149
+ /** Is this session's own control address up? A socket file's presence on Linux/macOS. On
150
+ * Windows (#78) the address is a named pipe, which has no file to look at and must not be
151
+ * opened by a stat (that connects): the owner-validated probe answers instead. */
152
+ async function controlAddressPresent(socketPath: string): Promise<boolean> {
153
+ if (process.platform === "win32") return (await probeSocketLiveness(socketPath)) === "alive";
154
+ return existsSync(socketPath);
155
+ }
156
+
140
157
  // ============================================================================
141
158
  // Live control-socket discovery for entwurf_peers lives in the TS fact-provider
142
159
  // (pi-extensions/lib/entwurf-fact-provider.ts → listEntwurfFacts), which the
@@ -219,7 +236,7 @@ class EntwurfSenderIdentityError extends Error {
219
236
  }
220
237
  }
221
238
 
222
- function buildStrictPiSenderEnvelope(): SenderEnvelope {
239
+ async function buildStrictPiSenderEnvelope(): Promise<SenderEnvelope> {
223
240
  const sessionId = process.env.PI_SESSION_ID?.trim();
224
241
  const agentId = process.env.PI_AGENT_ID?.trim();
225
242
  const cwd = process.cwd();
@@ -239,7 +256,7 @@ function buildStrictPiSenderEnvelope(): SenderEnvelope {
239
256
  const socketPath = controlSocketPathIn(ENTWURF_DIR, sessionId as string);
240
257
  const self = computeSelfAddressability({
241
258
  origin: "pi-session",
242
- socketAlive: existsSync(socketPath),
259
+ socketAlive: await controlAddressPresent(socketPath),
243
260
  socketPathComputable: true,
244
261
  });
245
262
  return {
@@ -338,7 +355,7 @@ async function resolveAuthoritativeSender(
338
355
  ): Promise<AuthoritativeSelf | null> {
339
356
  const sessionId = process.env.PI_SESSION_ID?.trim();
340
357
  const agentId = process.env.PI_AGENT_ID?.trim();
341
- const pi = sessionId && agentId && cwd ? buildStrictPiSenderEnvelope() : null;
358
+ const pi = sessionId && agentId && cwd ? await buildStrictPiSenderEnvelope() : null;
342
359
  const marker = resolveTrustedMetaSenderIdentity({
343
360
  markerPath: process.env.ENTWURF_META_SENDER_MARKER?.trim() || undefined,
344
361
  });
@@ -511,12 +528,24 @@ server.tool(
511
528
  // Resolved ONCE so the dispatch-moment timestamp is fixed and the control RPC sender
512
529
  // + the mailbox body sender share one envelope. No replyability gate (see above).
513
530
  const sender = await buildSendSenderEnvelope({ requestMeta: extra._meta });
514
- const rendered = await runAndRenderEntwurfV2FromSurface(
531
+ // #107: the operator's remote-pair opt-in is read per send. Absent = off and the local
532
+ // surface below runs exactly as before; a declared target goes over that one route.
533
+ const rendered = await runEntwurfV2WithRemoteRoute(
515
534
  { target, intent, message, mode, wants_reply },
516
- // No trust-preflight inputs are passed, and none exist to pass: the preflight on this
517
- // path guarded the resume verdict and left with `owned-outcome`. `senderProvider` is
518
- // the whole options surface now.
519
- { senderProvider: () => sender },
535
+ {
536
+ senderProvider: () => sender,
537
+ route: readRemotePairOptIn(remotePairOptInPath()),
538
+ // The decider's canonical socket dir AND this bridge's ENTWURF_DIR: a trace in either is local.
539
+ localTrace: makeLocalTraceFact({
540
+ sessionsDir: defaultMetaSessionsDir(),
541
+ controlSocketDirs: [CONTROL_SOCKET_DIR, ENTWURF_DIR],
542
+ }),
543
+ transport: processTransport,
544
+ // No trust-preflight inputs are passed, and none exist to pass: the preflight on this
545
+ // path guarded the resume verdict and left with `owned-outcome`. `senderProvider` is
546
+ // the whole options surface now.
547
+ runLocal: (params, opts) => runAndRenderEntwurfV2FromSurface(params, opts),
548
+ },
520
549
  );
521
550
  return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
522
551
  } catch (err) {
@@ -559,7 +588,7 @@ server.tool(
559
588
  // live socket). The old code synthesized the path and printed it as if
560
589
  // it existed — a lie when the session has no --entwurf-control (SE-1).
561
590
  const socketPath = controlSocketPathIn(ENTWURF_DIR, sender.sessionId);
562
- const socketState = existsSync(socketPath) ? "alive" : "expected";
591
+ const socketState = (await controlAddressPresent(socketPath)) ? "alive" : "expected";
563
592
  extra.socketPath = socketPath;
564
593
  extra.socketState = socketState;
565
594
  lines.push(
@@ -141,6 +141,12 @@
141
141
  // only repo import is meta-session.ts, ALREADY emitted for the store-doctor, so this adds
142
142
  // ONE leaf JS and ZERO new deps. It is an omp-loaded extension module, never in
143
143
  // index.ts's boot closure, so check-entwurf-bridge-pi-free is unaffected.
144
+ // WHY remote-pair-receive rides THIS build (#107): the sending host's ssh runs the receiving
145
+ // host's `entwurf remote-pair-receive`, and on an installed host run.sh lives under
146
+ // node_modules, where run_ts needs a compiled twin. Its repo imports (remote-pair-receive,
147
+ // remote-pair-send, entwurf-v2-surface, meta-session) are ALREADY emitted for the bridge's own
148
+ // entwurf_v2 handler, so this adds ONE leaf JS and ZERO new deps. It is never in index.ts's boot
149
+ // closure.
144
150
  "include": [
145
151
  "./src/index.ts",
146
152
  "../../protocol.js",
@@ -156,6 +162,7 @@
156
162
  "../../scripts/omp-receive-facts.ts",
157
163
  "../../scripts/codex-socket-path.ts",
158
164
  "../../scripts/codex-socket-classify.ts",
165
+ "../../scripts/remote-pair-receive.ts",
159
166
  "../../pi-extensions/meta-bridge-hook.ts",
160
167
  "../../pi-extensions/meta-bridge-hook-copilot.ts",
161
168
  "../../pi-extensions/meta-bridge-hook-codex.ts",
@@ -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.40.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/",
@@ -193,8 +195,8 @@
193
195
  "check:full": "bash scripts/check-elapsed.sh check:full check:core check:hermetic check:package",
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
- "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",
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 && ./run.sh check-remote-pair-installed-audit",
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
+ }
@@ -35,6 +35,9 @@ export interface MailboxSenderEnvelope {
35
35
  timestamp: string; // ISO 8601 UTC
36
36
  origin?: "pi-session" | "external-mcp" | "meta-session";
37
37
  replyable?: boolean;
38
+ /** #107: set only by the receiving half of a declared remote pair. `via` is the route label;
39
+ * `replyRoute` says whether this host declares a route back. Neither is a probe. */
40
+ remote?: { via: string; replyRoute: "declared" | "none" };
38
41
  }
39
42
 
40
43
  /** `~`-abbreviate a home-relative cwd for display. Reads process.env.HOME. */
@@ -72,14 +75,20 @@ export function formatMetaMailboxBody(sender: MailboxSenderEnvelope, message: st
72
75
  const replyable = sender.replyable === true;
73
76
  const isMeta = sender.origin === "meta-session";
74
77
  const kind = isMeta ? "meta-session, " : "";
78
+ // A remote sender is never replyable here: no reply route was probed. Its cwd is a path on the
79
+ // SENDING host, so it is neither abbreviated against this host's HOME nor shown as a local path.
80
+ const remote = !replyable ? sender.remote : undefined;
75
81
  const sessionLine = replyable
76
82
  ? `${sender.sessionId} (${kind}replyable — reply via entwurf_v2 to this sessionId, intent=fire-and-forget)`
77
- : isMeta
78
- ? `${sender.sessionId} (meta-session, non-replyable)`
79
- : `${sender.sessionId} (external, non-replyable)`;
83
+ : remote
84
+ ? `${sender.sessionId} (remote via ${remote.via}; ${remote.replyRoute === "declared" ? "reply route declared, not probed" : "no reply route declared"})`
85
+ : isMeta
86
+ ? `${sender.sessionId} (meta-session, non-replyable)`
87
+ : `${sender.sessionId} (external, non-replyable)`;
88
+ const where = remote ? `${sender.cwd} (sender-host path)` : abbreviateHome(sender.cwd);
80
89
  return (
81
90
  `[entwurf received ⟵]\n` +
82
- ` from: ${sender.agentId} @ ${abbreviateHome(sender.cwd)}\n` +
91
+ ` from: ${sender.agentId} @ ${where}\n` +
83
92
  ` session: ${sessionLine}\n` +
84
93
  ` at: ${formatKstTimestamp(sender.timestamp)}\n` +
85
94
  ` wants reply: ${wantsReply ? "yes" : "no"}\n` +
@@ -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
@@ -0,0 +1,184 @@
1
+ /**
2
+ * remote-pair-receive — the RECEIVING half of #107's one declared remote pair (rail 1).
3
+ *
4
+ * The host that owns the target record runs this once per SSH call. It reads one JSON claim
5
+ * from stdin, checks it against the operator-written pair declaration, and hands a matching
6
+ * claim to the existing v2 dispatch. The record, owner and deliverability checks stay the
7
+ * dispatch's own: nothing here writes a mailbox, resolves a rail, or retries.
8
+ *
9
+ * Trust model (GLG, #107 D3 (a)): the boundary is the same operator's SSH account. The pair
10
+ * check is a SCOPE limit, not authentication: a claim whose `from`/`target` is not the declared
11
+ * pair is refused before any mutation. It does not detect that same account sending the
12
+ * declared gid falsely; nothing here claims to.
13
+ *
14
+ * Sender: the stdin claim is the only sender input. Ambient carriers (`PI_SESSION_ID`,
15
+ * `PI_AGENT_ID`, sender markers) in the SSH environment are never consulted, so this module
16
+ * does not import the authoritative-sender resolver at all.
17
+ *
18
+ * Display (rail 2b): the envelope keeps the existing `external-mcp` / non-replyable origin, so no
19
+ * receiver, control renderer or self-address table learns a new origin value. A `remote` field
20
+ * carries the route label and whether a route back is declared; the mailbox body renders it as
21
+ * `remote via <route>; reply route declared, not probed` (or `no reply route declared`), with the
22
+ * sender's cwd marked as a sender-host path. The pilot's display proof is the self-fetch mailbox
23
+ * body only: whether another rail (a live control socket, native-push injection) shows the same
24
+ * label is NOT PROVEN here, and nothing in this lane unifies the display across rails.
25
+ */
26
+
27
+ import type { SenderEnvelope } from "./entwurf-control-rpc.ts";
28
+ import type { EntwurfV2SurfaceRendered, SurfaceEntwurfV2Params } from "./entwurf-v2-surface.ts";
29
+ import type { MailboxSenderEnvelope } from "./meta-mailbox-body.ts";
30
+ import { requireGardenId } from "./meta-session.ts";
31
+
32
+ /** One operator-declared pair, as written on the RECEIVING host. */
33
+ export interface RemotePairDeclaration {
34
+ /** The sender's garden id on the other host. */
35
+ remoteGardenId: string;
36
+ /** The SSH alias this host uses to reach it (route label; never an identity). */
37
+ ssh: string;
38
+ /** The garden id on THIS host the remote sender may address. */
39
+ localGardenId: string;
40
+ }
41
+
42
+ /** What the sending host writes to stdin. */
43
+ export interface RemoteClaim {
44
+ from: string;
45
+ fromAgentId: string;
46
+ /** A path on the SENDING host. Shown, never checked here. */
47
+ fromCwd: string;
48
+ target: string;
49
+ message: string;
50
+ wantsReply: boolean;
51
+ }
52
+
53
+ export type RemotePairRejectReason = "remote-pair-from-mismatch" | "remote-pair-target-mismatch";
54
+
55
+ export type RemoteReceiveReceipt =
56
+ | { kind: "rejected"; reason: RemotePairRejectReason; detail: string }
57
+ | { kind: "dispatched"; isError: boolean; text: string };
58
+
59
+ export interface RemoteReceiveDeps {
60
+ dispatch: (params: SurfaceEntwurfV2Params, sender: SenderEnvelope) => Promise<EntwurfV2SurfaceRendered>;
61
+ now?: () => Date;
62
+ /** Whether THIS host declares a route back to the sender. Shown, never probed. Default: none. */
63
+ replyRoute?: "declared" | "none";
64
+ }
65
+
66
+ const MESSAGE_CAP = 16000;
67
+
68
+ /** Every Unicode control character (the class herdr-fresh-call.ts refuses). A header field is
69
+ * rendered unescaped into the body's `[entwurf received]` block, so a newline in one could forge a
70
+ * `session:` or `wants reply:` line. The message body keeps its newlines. */
71
+ const CONTROL_CHAR = /\p{Cc}/u;
72
+
73
+ export class RemotePairInputError extends Error {}
74
+
75
+ function parseObject(text: string, what: string): Record<string, unknown> {
76
+ let raw: unknown;
77
+ try {
78
+ raw = JSON.parse(text);
79
+ } catch (err) {
80
+ throw new RemotePairInputError(`${what}: not JSON (${err instanceof Error ? err.message : String(err)})`);
81
+ }
82
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
83
+ throw new RemotePairInputError(`${what}: must be a JSON object`);
84
+ }
85
+ return raw as Record<string, unknown>;
86
+ }
87
+
88
+ function requireExactKeys(obj: Record<string, unknown>, keys: readonly string[], what: string): void {
89
+ const got = Object.keys(obj).sort();
90
+ const want = [...keys].sort();
91
+ if (got.length !== want.length || got.some((k, i) => k !== want[i])) {
92
+ throw new RemotePairInputError(`${what}: keys must be exactly ${want.join(", ")} (got ${got.join(", ")})`);
93
+ }
94
+ }
95
+
96
+ function requireString(obj: Record<string, unknown>, key: string, what: string): string {
97
+ const v = obj[key];
98
+ if (typeof v !== "string" || v.length === 0) {
99
+ throw new RemotePairInputError(`${what}: "${key}" must be a non-empty string`);
100
+ }
101
+ return v;
102
+ }
103
+
104
+ /** A field that lands in the rendered sender header: non-empty and free of control characters. */
105
+ function requireHeaderString(obj: Record<string, unknown>, key: string, what: string): string {
106
+ const v = requireString(obj, key, what);
107
+ if (CONTROL_CHAR.test(v)) {
108
+ throw new RemotePairInputError(`${what}: "${key}" must not contain control characters`);
109
+ }
110
+ return v;
111
+ }
112
+
113
+ export function parseRemoteClaim(text: string): RemoteClaim {
114
+ const what = "remote claim";
115
+ const obj = parseObject(text, what);
116
+ requireExactKeys(obj, ["from", "fromAgentId", "fromCwd", "target", "message", "wantsReply"], what);
117
+ const message = requireString(obj, "message", what);
118
+ if (message.length > MESSAGE_CAP) {
119
+ throw new RemotePairInputError(`${what}: "message" exceeds ${MESSAGE_CAP} chars`);
120
+ }
121
+ if (typeof obj.wantsReply !== "boolean") {
122
+ throw new RemotePairInputError(`${what}: "wantsReply" must be a boolean`);
123
+ }
124
+ return {
125
+ from: requireGardenId(obj.from),
126
+ fromAgentId: requireHeaderString(obj, "fromAgentId", what),
127
+ fromCwd: requireHeaderString(obj, "fromCwd", what),
128
+ target: requireGardenId(obj.target),
129
+ message,
130
+ wantsReply: obj.wantsReply,
131
+ };
132
+ }
133
+
134
+ /** The scope check. Null = inside the declared pair. */
135
+ export function checkRemotePair(
136
+ claim: RemoteClaim,
137
+ decl: RemotePairDeclaration,
138
+ ): { reason: RemotePairRejectReason; detail: string } | null {
139
+ if (claim.from !== decl.remoteGardenId) {
140
+ return {
141
+ reason: "remote-pair-from-mismatch",
142
+ detail: `from ${claim.from} is not the declared remote ${decl.remoteGardenId}`,
143
+ };
144
+ }
145
+ if (claim.target !== decl.localGardenId) {
146
+ return {
147
+ reason: "remote-pair-target-mismatch",
148
+ detail: `target ${claim.target} is not the declared local ${decl.localGardenId}`,
149
+ };
150
+ }
151
+ return null;
152
+ }
153
+
154
+ export function buildRemoteSenderEnvelope(
155
+ claim: RemoteClaim,
156
+ decl: RemotePairDeclaration,
157
+ now: Date,
158
+ replyRoute: "declared" | "none" = "none",
159
+ ): SenderEnvelope & MailboxSenderEnvelope {
160
+ return {
161
+ sessionId: claim.from,
162
+ agentId: claim.fromAgentId,
163
+ cwd: claim.fromCwd,
164
+ timestamp: now.toISOString(),
165
+ origin: "external-mcp",
166
+ replyable: false,
167
+ remote: { via: decl.ssh, replyRoute },
168
+ };
169
+ }
170
+
171
+ export async function receiveRemoteClaim(
172
+ claim: RemoteClaim,
173
+ decl: RemotePairDeclaration,
174
+ deps: RemoteReceiveDeps,
175
+ ): Promise<RemoteReceiveReceipt> {
176
+ const mismatch = checkRemotePair(claim, decl);
177
+ if (mismatch) return { kind: "rejected", ...mismatch };
178
+ const sender = buildRemoteSenderEnvelope(claim, decl, (deps.now ?? (() => new Date()))(), deps.replyRoute);
179
+ const rendered = await deps.dispatch(
180
+ { target: claim.target, intent: "fire-and-forget", message: claim.message, wants_reply: claim.wantsReply },
181
+ sender,
182
+ );
183
+ return { kind: "dispatched", isError: rendered.isError, text: rendered.text };
184
+ }