gentle-pi 2.7.0 → 3.0.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 (122) hide show
  1. package/README.md +24 -6
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +14 -4
  18. package/docs/readme-reference.md +171 -32
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +281 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +74 -30
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +67 -111
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +10 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +2 -1
  47. package/lib/session-changes.ts +8 -1
  48. package/lib/shell-bar.ts +21 -12
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes.ts +3 -2
  51. package/lib/shell-prompt.ts +25 -8
  52. package/lib/shell-sidebar-banner.ts +2 -2
  53. package/lib/shell-sidebar-layout.ts +5 -2
  54. package/lib/windows-session-transport.ts +877 -0
  55. package/package.json +3 -3
  56. package/runtime/native-review-cli.mjs +9 -96
  57. package/runtime/windows-session-transport.ps1 +791 -0
  58. package/scripts/test-packed-runner.mjs +1668 -20
  59. package/scripts/verify-package-files.mjs +0 -1
  60. package/tests/agent-home.test.ts +52 -0
  61. package/tests/agent-profiles.test.ts +30 -1
  62. package/tests/agents-config.test.ts +44 -0
  63. package/tests/agents-history.test.ts +12 -24
  64. package/tests/agents-runner.test.ts +307 -58
  65. package/tests/agents-session-transport-process.test.ts +249 -0
  66. package/tests/agents-session-transport.test.ts +823 -0
  67. package/tests/artifact-language.test.ts +10 -7
  68. package/tests/command-palette.test.ts +378 -0
  69. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  70. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  71. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  72. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  73. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  74. package/tests/gentle-agents.test.ts +849 -356
  75. package/tests/gentle-ai.test.ts +472 -4
  76. package/tests/gentle-shell.test.ts +201 -8
  77. package/tests/native-choice-list.test.ts +13 -0
  78. package/tests/native-review-cli.test.ts +0 -33
  79. package/tests/odd-routing-contract.test.ts +208 -0
  80. package/tests/orchestrator-budget.test.ts +17 -2
  81. package/tests/package-manifest.test.ts +115 -27
  82. package/tests/persona-single-channel.test.ts +3 -3
  83. package/tests/pi-pretty.test.ts +45 -0
  84. package/tests/profile-pin.test.ts +370 -0
  85. package/tests/quiet-tool-rendering.test.ts +32 -5
  86. package/tests/review-contract-prompt.test.ts +9 -0
  87. package/tests/review-controller.test.ts +0 -44
  88. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  89. package/tests/runtime-harness.mjs +4 -4
  90. package/tests/sdd-agent-tools.test.ts +15 -36
  91. package/tests/sdd-archive-replay.test.ts +82 -0
  92. package/tests/sdd-classical-continuation.test.ts +74 -0
  93. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  94. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  95. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  96. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  97. package/tests/sdd-odd-integration.test.ts +33 -0
  98. package/tests/sdd-optional-research.test.ts +124 -0
  99. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  100. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  101. package/tests/sdd-preflight.test.ts +1 -1
  102. package/tests/sdd-research-capabilities.test.ts +20 -162
  103. package/tests/sdd-selection-transport.test.ts +180 -88
  104. package/tests/sdd-status.test.ts +5 -778
  105. package/tests/sdd-task-truth.test.ts +43 -0
  106. package/tests/session-change-capture.test.ts +20 -2
  107. package/tests/session-changes.test.ts +11 -0
  108. package/tests/shell-bar.test.ts +21 -0
  109. package/tests/shell-card.test.ts +8 -6
  110. package/tests/shell-changes.test.ts +8 -0
  111. package/tests/shell-prompt.test.ts +41 -7
  112. package/tests/shell-sidebar-banner.test.ts +4 -4
  113. package/tests/shell-sidebar-layout.test.ts +97 -13
  114. package/tests/startup-banner.test.ts +55 -2
  115. package/tests/windows-hidden-processes.test.ts +303 -0
  116. package/tests/windows-session-bootstrap.test.ts +1772 -0
  117. package/tests/windows-session-compile.test.ts +170 -0
  118. package/tests/windows-session-transport.test.ts +754 -0
  119. package/assets/agents/sdd-sync.md +0 -146
  120. package/lib/openspec-guardrails.ts +0 -99
  121. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  122. package/tests/openspec-guardrails.test.ts +0 -71
@@ -0,0 +1,877 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { createConnection, type Socket } from "node:net";
3
+ import { fileURLToPath } from "node:url";
4
+ import {
5
+ ActiveSessionClientError,
6
+ FrameDecoder,
7
+ SessionPresenceError,
8
+ TransportProtocolError,
9
+ encodeAckFrame,
10
+ encodeNotificationFrame,
11
+ type AckFrame,
12
+ type PresenceRecord,
13
+ type ReceivedNotification,
14
+ type SessionPresenceCandidate,
15
+ type SentNotification,
16
+ type NotificationFrame,
17
+ } from "./agents-session-transport.ts";
18
+
19
+ export const FIXED_WINDOWS_POWERSHELL = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
20
+ const MAX_CONTROL_BYTES = 16_384;
21
+ // The native helper caps one raw pipe request at MaxPipeBytes = 65,536. Its event
22
+ // carries that request as canonical base64, so this private envelope is bounded by
23
+ // ceil(raw / 3) * 4 plus the largest fixed, ASCII-only control fields below.
24
+ const NATIVE_PIPE_MAX_BYTES = 65_536;
25
+ const MAX_PRIVATE_WIRE_BASE64_BYTES = Math.ceil(NATIVE_PIPE_MAX_BYTES / 3) * 4;
26
+ const MAX_PRIVATE_EVENT_FIXED_BYTES = Buffer.byteLength('{"event":"notification","connectionId":"","generation":2147483647,"wire":""}', "utf8") + 128;
27
+ const MAX_PRIVATE_EVENT_BYTES = MAX_PRIVATE_WIRE_BASE64_BYTES + MAX_PRIVATE_EVENT_FIXED_BYTES;
28
+ const MAX_PENDING = 8;
29
+ const MAX_PENDING_ACK = 8;
30
+ const RPC_DEADLINE_MS = 2_000;
31
+ const STARTUP_DEADLINE_MS = 30_000;
32
+ const CALLBACK_DEADLINE_MS = 2_000;
33
+ const SHUTDOWN_GRACE_MS = 500;
34
+ const PIPE = /^\\\\\.\\pipe\\gentle-pi-[A-Za-z0-9-]{1,96}$/;
35
+ const SESSION = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
36
+
37
+ type HostState = Readonly<{ state: "partial" }> | Readonly<{ state: "initialized"; bootstrap: "complete" }> | Readonly<{ state: "initialized"; bootstrap: "complete"; entries: number }>;
38
+ type HostReply = Readonly<{ requestId: string; ok: boolean; result?: HostState | WindowsRecord | Readonly<{ records: readonly WindowsRecord[] }>; error?: "unavailable" | "unsafe" | "busy" | "not_found" | "invalid" }>;
39
+ type HostEvent = Readonly<{ event: "startup-marker"; marker: WindowsSessionStartupMarker }> | Readonly<{ event: "notification"; connectionId: string; generation: number; frame: NotificationFrame }> | Readonly<{ event: "listener-failed"; generation: number; error: "unavailable" }>;
40
+ type Pending = { kind: "rpc" | "ack"; resolve: (value: Record<string, unknown>) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> };
41
+ type WindowsHostFailureCallback = (generation: number) => void;
42
+ type SpawnedHost = ChildProcessWithoutNullStreams;
43
+ export type WindowsHostCallback = (notification: Readonly<{ connectionId: string; id: string; senderSessionId: string; recipientSessionId: string; message: string }>) => Promise<boolean>;
44
+ export type WindowsSessionTransportHostOptions = Readonly<{
45
+ runtimeScript?: string;
46
+ spawnProcess?: typeof spawn;
47
+ callback?: WindowsHostCallback;
48
+ rpcDeadlineMs?: number;
49
+ startupDeadlineMs?: number;
50
+ }>;
51
+ export type WindowsSessionRegistryPhase = "start" | "initialize" | "cleanup";
52
+ export type WindowsSessionStartupMarker = "script-entered" | "native-ready";
53
+ export type WindowsSessionRegistryPhaseErrorCode = "spawn" | "stream" | "process" | "exit" | "write" | "deadline" | "protocol" | "start-reply" | "stopped" | "unwritable" | "unknown";
54
+ export type WindowsSessionRegistryPhaseError = Readonly<{ class: "timed-out" | "rejected" | "unknown"; code: WindowsSessionRegistryPhaseErrorCode }>;
55
+ /** Receives one fixed, synchronous result event for an operation on this registry's own host. */
56
+ export type WindowsSessionRegistryPhaseEvent = Readonly<{ phase: WindowsSessionRegistryPhase; status: "succeeded" | "failed"; error: WindowsSessionRegistryPhaseError | null; lastStartupMarker: WindowsSessionStartupMarker | null }>;
57
+ export type WindowsSessionRegistryPhaseObserver = (event: WindowsSessionRegistryPhaseEvent) => undefined;
58
+ export type WindowsSessionRegistryObservation = Readonly<{
59
+ availability: "unavailable" | "observed";
60
+ provenance: "unavailable" | "ambiguous" | "owned-instance";
61
+ restoration: "not-required";
62
+ startCalls: number | null;
63
+ initializeCalls: number | null;
64
+ cleanupCalls: number | null;
65
+ firstFailurePhase: WindowsSessionRegistryPhase | null;
66
+ firstFailureClass: WindowsSessionRegistryPhaseError["class"] | null;
67
+ firstFailureCode: WindowsSessionRegistryPhaseError["code"] | null;
68
+ lastStartupMarker: WindowsSessionStartupMarker | null;
69
+ }>;
70
+
71
+ /** Pure probe-side reducer for fixed events emitted by one registry's lexical owner. */
72
+ export class WindowsSessionRegistryPhaseSequence {
73
+ private expected: WindowsSessionRegistryPhase | "complete" = "start";
74
+ private invalid = false;
75
+ private startStatus?: WindowsSessionRegistryPhaseEvent["status"];
76
+ private initializeStatus?: WindowsSessionRegistryPhaseEvent["status"];
77
+ private cleanupStatus?: WindowsSessionRegistryPhaseEvent["status"];
78
+ private lastStartupMarker: WindowsSessionStartupMarker | null = null;
79
+ private firstFailure?: Readonly<{ phase: WindowsSessionRegistryPhase; class: WindowsSessionRegistryPhaseError["class"]; code: WindowsSessionRegistryPhaseError["code"] }>;
80
+ observe(event: unknown): undefined {
81
+ if (this.invalid || !this.validEvent(event) || event.phase !== this.expected || !this.monotonicStartupMarker(event.lastStartupMarker)) { this.invalid = true; return undefined; }
82
+ this.lastStartupMarker = event.lastStartupMarker;
83
+ if (event.phase === "start") this.startStatus = event.status;
84
+ else if (event.phase === "initialize") this.initializeStatus = event.status;
85
+ else this.cleanupStatus = event.status;
86
+ if (event.status === "failed" && this.firstFailure === undefined) this.firstFailure = Object.freeze({ phase: event.phase, class: event.error!.class, code: event.error!.code });
87
+ if (event.phase === "start") this.expected = event.status === "succeeded" ? "initialize" : "cleanup";
88
+ else if (event.phase === "initialize") this.expected = "cleanup";
89
+ else this.expected = "complete";
90
+ return undefined;
91
+ }
92
+ get operationSucceeded() { return this.startStatus === "succeeded" && this.initializeStatus === "succeeded"; }
93
+ get terminalSequenceValid() { return !this.invalid && this.expected === "complete"; }
94
+ get cleanupComplete() { return this.terminalSequenceValid && this.cleanupStatus === "succeeded"; }
95
+ get admitsFullSuccess() { return this.operationSucceeded && this.cleanupComplete; }
96
+ get startupSucceeded() { return !this.invalid && this.expected === "cleanup" && this.operationSucceeded; }
97
+ snapshot(): WindowsSessionRegistryObservation {
98
+ if (!this.terminalSequenceValid) return Object.freeze({ availability: "unavailable", provenance: this.invalid ? "ambiguous" : "unavailable", restoration: "not-required", startCalls: null, initializeCalls: null, cleanupCalls: null, firstFailurePhase: null, firstFailureClass: null, firstFailureCode: null, lastStartupMarker: null });
99
+ return Object.freeze({ availability: "observed", provenance: "owned-instance", restoration: "not-required", startCalls: this.startStatus === undefined ? 0 : 1, initializeCalls: this.initializeStatus === undefined ? 0 : 1, cleanupCalls: this.cleanupStatus === undefined ? 0 : 1, firstFailurePhase: this.firstFailure?.phase ?? null, firstFailureClass: this.firstFailure?.class ?? null, firstFailureCode: this.firstFailure?.code ?? null, lastStartupMarker: this.lastStartupMarker });
100
+ }
101
+ private monotonicStartupMarker(marker: WindowsSessionStartupMarker | null) {
102
+ const ordinal = marker === null ? 0 : marker === "script-entered" ? 1 : 2;
103
+ const prior = this.lastStartupMarker === null ? 0 : this.lastStartupMarker === "script-entered" ? 1 : 2;
104
+ return ordinal >= prior;
105
+ }
106
+ private validEvent(event: unknown): event is WindowsSessionRegistryPhaseEvent {
107
+ if (!event || typeof event !== "object" || Array.isArray(event) || Object.getPrototypeOf(event) !== Object.prototype || Object.keys(event).length !== 4) return false;
108
+ const value = event as Record<string, unknown>;
109
+ if (!(["start", "initialize", "cleanup"] as const).includes(value.phase as WindowsSessionRegistryPhase) || !(["succeeded", "failed"] as const).includes(value.status as WindowsSessionRegistryPhaseEvent["status"]) || (value.lastStartupMarker !== null && value.lastStartupMarker !== "script-entered" && value.lastStartupMarker !== "native-ready")) return false;
110
+ if (value.status === "succeeded") return value.error === null;
111
+ if (!value.error || typeof value.error !== "object" || Array.isArray(value.error) || Object.getPrototypeOf(value.error) !== Object.prototype || Object.keys(value.error).length !== 2) return false;
112
+ const error = value.error as Record<string, unknown>;
113
+ return (["timed-out", "rejected", "unknown"] as const).includes(error.class as WindowsSessionRegistryPhaseError["class"]) && (["spawn", "stream", "process", "exit", "write", "deadline", "protocol", "start-reply", "stopped", "unwritable", "unknown"] as const).includes(error.code as WindowsSessionRegistryPhaseErrorCode) && ((error.class === "timed-out") === (error.code === "deadline"));
114
+ }
115
+ }
116
+
117
+ const defaultRuntimeScript = fileURLToPath(new URL("../runtime/windows-session-transport.ps1", import.meta.url));
118
+ const safeError = (message: string) => new Error(message);
119
+ // Diagnostics are source-defined and remain private to the phase observer: no native
120
+ // error object, message, property, or stderr is copied into the event.
121
+ const transportFailureCodes = new WeakMap<Error, WindowsSessionRegistryPhaseErrorCode>();
122
+ const transportError = (message: string, code: WindowsSessionRegistryPhaseErrorCode) => {
123
+ const error = safeError(message);
124
+ transportFailureCodes.set(error, code);
125
+ return error;
126
+ };
127
+ // This sink intentionally captures no host state. It prevents a late stream error from
128
+ // becoming unhandled after bounded cleanup times out but before the child confirms close.
129
+ const lateChildErrorSink = () => {};
130
+ type DetachedChildCleanup = Readonly<{ closed: () => boolean; install: () => void; close: () => void }>;
131
+ /**
132
+ * This state is handed to the child only after host cleanup times out. Its callbacks
133
+ * retain the child streams and their own exact callback references, never the host.
134
+ */
135
+ const createDetachedChildCleanup = (child: SpawnedHost): DetachedChildCleanup => {
136
+ let closed = false;
137
+ const removeGuards = () => {
138
+ child.removeListener("error", lateChildErrorSink);
139
+ child.stdin.removeListener("error", lateChildErrorSink);
140
+ child.stdout.removeListener("error", lateChildErrorSink);
141
+ child.stderr.removeListener("error", lateChildErrorSink);
142
+ };
143
+ const close = () => {
144
+ if (closed) return;
145
+ closed = true;
146
+ child.removeListener("close", close);
147
+ removeGuards();
148
+ };
149
+ const addGuard = (emitter: NodeJS.EventEmitter) => {
150
+ if (closed) return;
151
+ emitter.on("error", lateChildErrorSink);
152
+ // An external newListener hook can synchronously close the child before on()
153
+ // returns; remove this just-added guard rather than leaving it after close.
154
+ if (closed) emitter.removeListener("error", lateChildErrorSink);
155
+ };
156
+ const install = () => {
157
+ if (closed) return;
158
+ child.once("close", close);
159
+ if (closed) { child.removeListener("close", close); return; }
160
+ addGuard(child); addGuard(child.stdin); addGuard(child.stdout); addGuard(child.stderr);
161
+ };
162
+ return Object.freeze({ closed: () => closed, install, close });
163
+ };
164
+ /** A syntactically valid private envelope can contain one untrusted pipe frame. */
165
+ class InvalidClientWireError extends Error {}
166
+ function controlId(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z0-9-]{1,128}$/.test(value); }
167
+ function plainRecord(value: unknown): value is Record<string, unknown> { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
168
+ const hostErrors = (value: unknown): value is HostReply["error"] => typeof value === "string" && ["unavailable", "unsafe", "busy", "not_found", "invalid"].includes(value);
169
+ const hasPrivateData = (value: unknown): boolean => {
170
+ if (!value || typeof value !== "object") return false;
171
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
172
+ if (/(path|sid|exception|stack|detail)/i.test(key) || hasPrivateData(child)) return true;
173
+ }
174
+ return false;
175
+ };
176
+ const validNotification = (value: unknown): value is NotificationFrame => {
177
+ try { encodeNotificationFrame(value as NotificationFrame); return true; } catch { return false; }
178
+ };
179
+
180
+ /** Parse one public JSONL frame. The helper never exposes a host path, SID, or exception. */
181
+ export function parseWindowsHostFrame(line: string): HostReply {
182
+ if (typeof line !== "string" || Buffer.byteLength(line, "utf8") > MAX_CONTROL_BYTES) throw safeError("invalid Windows transport frame");
183
+ let value: unknown;
184
+ try { value = JSON.parse(line); } catch { throw safeError("invalid Windows transport frame"); }
185
+ if (!value || typeof value !== "object" || Array.isArray(value) || hasPrivateData(value)) throw safeError("invalid Windows transport frame");
186
+ const frame = value as Record<string, unknown>;
187
+ if (!controlId(frame.requestId) || typeof frame.ok !== "boolean") throw safeError("invalid Windows transport frame");
188
+ const keys = Object.keys(frame);
189
+ if (frame.ok) {
190
+ if (keys.length !== 3 || !keys.includes("result") || !frame.result || typeof frame.result !== "object" || Array.isArray(frame.result)) throw safeError("invalid Windows transport frame");
191
+ const result = frame.result as Record<string, unknown>;
192
+ const partial = Object.keys(result).length === 1 && result.state === "partial";
193
+ const initialized = Object.keys(result).length === 2 && result.state === "initialized" && result.bootstrap === "complete";
194
+ const enumerated = Object.keys(result).length === 3 && result.state === "initialized" && result.bootstrap === "complete" && typeof result.entries === "number" && Number.isSafeInteger(result.entries) && result.entries >= 0 && result.entries <= 64;
195
+ let publicResult: HostReply["result"];
196
+ if (partial || initialized || enumerated) publicResult = Object.freeze({ ...result }) as HostState;
197
+ else {
198
+ try {
199
+ if (Object.keys(result).length === 4) publicResult = validRecord(result);
200
+ else if (Object.keys(result).length === 1 && Array.isArray(result.records) && result.records.length <= 64) publicResult = Object.freeze({ records: Object.freeze(result.records.map((record) => validRecord(record))) });
201
+ else throw new Error();
202
+ } catch { throw safeError("invalid Windows transport frame"); }
203
+ }
204
+ return Object.freeze({ requestId: frame.requestId, ok: true, result: publicResult! });
205
+ }
206
+ if (keys.length !== 3 || !hostErrors(frame.error)) throw safeError("invalid Windows transport frame");
207
+ return Object.freeze({ requestId: frame.requestId, ok: false, error: frame.error });
208
+ }
209
+
210
+ /** Decode the helper's bounded private events before allowing application acknowledgement. */
211
+ function parseWindowsHostEvent(line: string): HostEvent | undefined {
212
+ if (typeof line !== "string" || Buffer.byteLength(line, "utf8") > MAX_PRIVATE_EVENT_BYTES) throw safeError("invalid Windows transport frame");
213
+ let value: unknown;
214
+ try { value = JSON.parse(line); } catch { throw safeError("invalid Windows transport frame"); }
215
+ if (!value || typeof value !== "object" || Array.isArray(value) || hasPrivateData(value)) throw safeError("invalid Windows transport frame");
216
+ const event = value as Record<string, unknown>;
217
+ if (event.event === "startup-marker") {
218
+ if (Object.keys(event).length !== 2 || (event.marker !== "script-entered" && event.marker !== "native-ready")) throw safeError("invalid Windows transport frame");
219
+ return Object.freeze({ event: "startup-marker", marker: event.marker as WindowsSessionStartupMarker });
220
+ }
221
+ if (event.event === "listener-failed") {
222
+ if (Object.keys(event).length !== 3 || !Number.isSafeInteger(event.generation) || (event.generation as number) < 1 || (event.generation as number) > 0x7fffffff || event.error !== "unavailable") throw safeError("invalid Windows transport frame");
223
+ return Object.freeze({ event: "listener-failed", generation: event.generation as number, error: "unavailable" });
224
+ }
225
+ if (event.event !== "notification") return undefined;
226
+ if (Object.keys(event).length !== 4 || !controlId(event.connectionId) || !Number.isSafeInteger(event.generation) || (event.generation as number) < 1 || (event.generation as number) > 0x7fffffff || typeof event.wire !== "string" || event.wire.length > MAX_PRIVATE_WIRE_BASE64_BYTES || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(event.wire)) throw safeError("invalid Windows transport frame");
227
+ let bytes: Buffer;
228
+ try {
229
+ bytes = Buffer.from(event.wire, "base64");
230
+ if (bytes.length > NATIVE_PIPE_MAX_BYTES || bytes.toString("base64") !== event.wire) throw new Error();
231
+ } catch { throw safeError("invalid Windows transport frame"); }
232
+ try {
233
+ const decoder = new FrameDecoder(); decoder.push(bytes);
234
+ const frame = decoder.finish();
235
+ if (frame.kind !== "notification") throw new Error();
236
+ return Object.freeze({ event: "notification", connectionId: event.connectionId as string, generation: event.generation as number, frame });
237
+ } catch { throw new InvalidClientWireError("invalid client wire frame"); }
238
+ }
239
+
240
+ /** Decode a notification event while preserving the existing public notification parser. */
241
+ export function parseWindowsHostNotification(line: string): Extract<HostEvent, { event: "notification" }> | undefined {
242
+ const event = parseWindowsHostEvent(line);
243
+ return event?.event === "notification" ? event : undefined;
244
+ }
245
+
246
+ export class WindowsSessionTransportHost {
247
+ private readonly runtimeScript: string;
248
+ private readonly spawnProcess: typeof spawn;
249
+ private readonly callback?: WindowsHostCallback;
250
+ private readonly deadline: number;
251
+ private readonly startupDeadline: number;
252
+ private child?: SpawnedHost;
253
+ private started?: Promise<void>;
254
+ private startupMarker: WindowsSessionStartupMarker | null = null;
255
+ private startupReplyObserved = false;
256
+ private sequence = 0;
257
+ private output = Buffer.alloc(0);
258
+ private readonly pending = new Map<string, Pending>();
259
+ private pendingRpcs = 0;
260
+ private pendingAcks = 0;
261
+ // Every submitted Node listen reserves a strictly increasing epoch. The helper
262
+ // accepts a newer epoch, rather than requiring contiguity, so a locally rejected
263
+ // request or a reply/failure reordering cannot reuse an allocated epoch.
264
+ private nextListenerGeneration = 0;
265
+ private listenerActiveGeneration?: number;
266
+ private listenerPendingGeneration?: number;
267
+ private listenerActiveIdentity?: WindowsRecord;
268
+ private listenerPendingIdentity?: Readonly<{ sessionId: string; createdAt: number }>;
269
+ private listenerFailure?: WindowsHostFailureCallback;
270
+ private stopped = false;
271
+ private childClosed = false;
272
+ private inputClosed = false;
273
+ private childKillRequested = false;
274
+ private cleanup?: Promise<void>;
275
+ private resolveCleanup?: () => void;
276
+ private rejectCleanup?: (error: Error) => void;
277
+ private cleanupTimer?: ReturnType<typeof setTimeout>;
278
+ private readonly onStdoutData = (chunk: Buffer) => this.onOutput(chunk);
279
+ private readonly onStdoutError = () => this.abort("Windows transport host unavailable", true, "stream");
280
+ private readonly onStdinError = () => this.abort("Windows transport host unavailable", true, "stream");
281
+ private readonly onStderrError = () => this.abort("Windows transport host unavailable", true, "stream");
282
+ private readonly onChildError = () => this.abort("Windows transport host unavailable", true, "process");
283
+ private readonly onChildExit = () => this.abort("Windows transport host exited", false, "exit");
284
+ private readonly onChildClose = () => this.handleChildClose();
285
+ // Set only while handing close ownership from the host observer to detached state.
286
+ private handoffCloseState?: DetachedChildCleanup;
287
+
288
+ constructor(options: WindowsSessionTransportHostOptions = {}) {
289
+ this.runtimeScript = options.runtimeScript ?? defaultRuntimeScript;
290
+ this.spawnProcess = options.spawnProcess ?? spawn;
291
+ this.callback = options.callback;
292
+ this.deadline = options.rpcDeadlineMs ?? RPC_DEADLINE_MS;
293
+ this.startupDeadline = options.startupDeadlineMs ?? STARTUP_DEADLINE_MS;
294
+ if (!Number.isInteger(this.deadline) || this.deadline < 1 || this.deadline > RPC_DEADLINE_MS) throw new RangeError("invalid Windows transport deadline");
295
+ if (!Number.isInteger(this.startupDeadline) || this.startupDeadline < 1 || this.startupDeadline > STARTUP_DEADLINE_MS) throw new RangeError("invalid Windows transport startup deadline");
296
+ }
297
+
298
+ get lastStartupMarker() { return this.startupMarker; }
299
+
300
+ start(): Promise<void> {
301
+ if (this.started) return this.started;
302
+ if (this.stopped) return Promise.reject(transportError("Windows transport host exited", "stopped"));
303
+ try {
304
+ this.child = this.spawnProcess(FIXED_WINDOWS_POWERSHELL, ["-NoLogo", "-NoProfile", "-NonInteractive", "-File", this.runtimeScript], { shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }) as SpawnedHost;
305
+ } catch { return Promise.reject(transportError("Windows transport host unavailable", "spawn")); }
306
+ this.child.stdout.on("data", this.onStdoutData);
307
+ this.child.stdout.on("error", this.onStdoutError);
308
+ this.child.stdin.on("error", this.onStdinError);
309
+ this.child.stderr.on("error", this.onStderrError);
310
+ this.child.stderr.resume?.();
311
+ this.child.once("error", this.onChildError);
312
+ this.child.once("exit", this.onChildExit);
313
+ this.child.once("close", this.onChildClose);
314
+ this.started = this.requestWithDeadline("start", {}, this.startupDeadline).then((result) => {
315
+ if (result.state !== "partial") throw transportError("Windows transport host unavailable", "start-reply");
316
+ });
317
+ return this.started;
318
+ }
319
+
320
+ request(operation: string, values: Record<string, unknown>): Promise<Record<string, unknown>> {
321
+ return this.requestWithDeadline(operation, values, operation === "ack" ? CALLBACK_DEADLINE_MS : this.deadline);
322
+ }
323
+
324
+ private requestWithDeadline(operation: string, values: Record<string, unknown>, deadline: number): Promise<Record<string, unknown>> {
325
+ const kind = operation === "ack" ? "ack" : "rpc";
326
+ if (!/^[a-z-]{1,32}$/.test(operation) || hasPrivateData(values) || (kind === "rpc" ? this.pendingRpcs >= MAX_PENDING : this.pendingAcks >= MAX_PENDING_ACK)) return Promise.reject(safeError("Windows transport request unavailable"));
327
+ const child = this.child;
328
+ if (!child || this.stopped) return Promise.reject(transportError("Windows transport host exited", "stopped"));
329
+ if (!child.stdin.writable) return Promise.reject(transportError("Windows transport host exited", "unwritable"));
330
+ const requestId = `${operation}-${++this.sequence}`;
331
+ const line = JSON.stringify({ requestId, operation, ...values });
332
+ if (Buffer.byteLength(line, "utf8") > MAX_CONTROL_BYTES) return Promise.reject(safeError("Windows transport request unavailable"));
333
+ return new Promise((resolve, reject) => {
334
+ const timer = setTimeout(() => this.settle(requestId, transportError("Windows transport request timed out", "deadline")), deadline);
335
+ this.pending.set(requestId, { kind, resolve, reject, timer });
336
+ if (kind === "ack") this.pendingAcks++; else this.pendingRpcs++;
337
+ try { child.stdin.write(`${line}\n`, (error) => { if (error) this.settle(requestId, transportError("Windows transport host exited", "write")); }); }
338
+ catch { this.settle(requestId, transportError("Windows transport host exited", "write")); }
339
+ });
340
+ }
341
+
342
+ setListenerFailure(callback?: WindowsHostFailureCallback) { this.listenerFailure = callback; }
343
+
344
+ async listen(sessionId: string, createdAt = Date.now()): Promise<WindowsRecord> {
345
+ if (this.listenerActiveGeneration !== undefined || this.listenerPendingGeneration !== undefined || this.nextListenerGeneration >= 0x7fffffff) throw safeError("Windows transport listener failed");
346
+ const generation = ++this.nextListenerGeneration;
347
+ this.listenerPendingGeneration = generation;
348
+ this.listenerPendingIdentity = Object.freeze({ sessionId, createdAt });
349
+ try {
350
+ const result = validRecord(await this.request("listen", { sessionId, createdAt, generation }));
351
+ if (this.listenerPendingGeneration !== generation) throw safeError("Windows transport listener failed");
352
+ this.listenerPendingGeneration = undefined;
353
+ this.listenerPendingIdentity = undefined;
354
+ this.listenerActiveGeneration = generation;
355
+ this.listenerActiveIdentity = result;
356
+ return result;
357
+ } catch (error) {
358
+ if (this.listenerPendingGeneration === generation) {
359
+ this.listenerPendingGeneration = undefined;
360
+ this.listenerPendingIdentity = undefined;
361
+ }
362
+ throw error;
363
+ }
364
+ }
365
+
366
+ async stopListener(record: PresenceRecord) {
367
+ const valid = validRecord(record as unknown as Record<string, unknown>);
368
+ const matchesPending = this.listenerPendingIdentity?.sessionId === valid.sessionId && this.listenerPendingIdentity.createdAt === valid.createdAt;
369
+ const matchesActive = this.listenerActiveIdentity?.sessionId === valid.sessionId && this.listenerActiveIdentity.endpoint === valid.endpoint && this.listenerActiveIdentity.createdAt === valid.createdAt;
370
+ // Revoke a matching pending epoch before awaiting the stop RPC. Its delayed
371
+ // listen reply, notification, or failure can no longer make it active.
372
+ if (matchesPending) {
373
+ this.listenerPendingGeneration = undefined;
374
+ this.listenerPendingIdentity = undefined;
375
+ }
376
+ if (matchesActive) {
377
+ this.listenerActiveGeneration = undefined;
378
+ this.listenerActiveIdentity = undefined;
379
+ }
380
+ await this.request("stop-listener", { record: valid });
381
+ }
382
+
383
+ async close() {
384
+ this.listenerActiveGeneration = undefined;
385
+ this.listenerPendingGeneration = undefined;
386
+ this.listenerActiveIdentity = undefined;
387
+ this.listenerPendingIdentity = undefined;
388
+ if (this.cleanup) return this.cleanup;
389
+ const child = this.child;
390
+ if (!child) { this.stopped = true; return; }
391
+ if (!this.stopped) {
392
+ await this.request("shutdown", {}).catch(() => {});
393
+ this.stopped = true;
394
+ }
395
+ await this.releaseOwnedChild(false);
396
+ }
397
+
398
+ private observeStartupMarker(marker: WindowsSessionStartupMarker) {
399
+ const expected = this.startupMarker === null ? "script-entered" : this.startupMarker === "script-entered" ? "native-ready" : undefined;
400
+ if (this.startupReplyObserved || marker !== expected) return false;
401
+ this.startupMarker = marker;
402
+ return true;
403
+ }
404
+ private onOutput(chunk: Buffer) {
405
+ if (!Buffer.isBuffer(chunk)) { this.abort("Windows transport host unavailable", true, "protocol"); return; }
406
+ const output = this.output.length === 0 ? chunk : Buffer.concat([this.output, chunk]);
407
+ let offset = 0;
408
+ for (;;) {
409
+ const newline = output.indexOf(10, offset);
410
+ if (newline < 0) {
411
+ const partial = output.subarray(offset);
412
+ if (partial.length > MAX_PRIVATE_EVENT_BYTES) this.abort("Windows transport host unavailable", true, "protocol");
413
+ else this.output = Buffer.from(partial);
414
+ return;
415
+ }
416
+ const bytes = output.subarray(offset, newline);
417
+ offset = newline + 1;
418
+ if (bytes.length > MAX_PRIVATE_EVENT_BYTES) { this.abort("Windows transport host unavailable", true, "protocol"); return; }
419
+ let line: string;
420
+ try {
421
+ // Newlines are single UTF-8 bytes, so retaining only an unterminated byte
422
+ // suffix preserves split code points without decoding partial chunks.
423
+ line = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
424
+ } catch { this.abort("Windows transport host unavailable", true, "protocol"); return; }
425
+ try {
426
+ const event = parseWindowsHostEvent(line);
427
+ if (event) {
428
+ if (event.event === "startup-marker") {
429
+ if (!this.observeStartupMarker(event.marker)) { this.abort("Windows transport host unavailable", true, "protocol"); return; }
430
+ } else if (event.event === "notification") void this.acknowledge(event);
431
+ else this.failListener(event.generation);
432
+ continue;
433
+ }
434
+ const reply = parseWindowsHostFrame(line);
435
+ if (reply.requestId === "start-1") this.startupReplyObserved = true;
436
+ this.settle(reply.requestId, reply.ok ? undefined : transportError("Windows transport request unavailable", "protocol"), reply.result);
437
+ } catch (error) {
438
+ // Only a schema-validated private event can isolate its embedded client wire.
439
+ if (error instanceof InvalidClientWireError) continue;
440
+ this.abort("Windows transport host unavailable", true, "protocol");
441
+ return;
442
+ }
443
+ }
444
+ }
445
+
446
+ private isCurrentListenerGeneration(generation: number) {
447
+ // Pending replacement ownership is exclusive: old active events are stale.
448
+ return generation === (this.listenerPendingGeneration ?? this.listenerActiveGeneration);
449
+ }
450
+ private async acknowledge(event: Extract<HostEvent, { event: "notification" }>) {
451
+ if (this.stopped || !this.isCurrentListenerGeneration(event.generation)) return;
452
+ const callback = this.callback;
453
+ let timer: ReturnType<typeof setTimeout> | undefined;
454
+ const deadline = new Promise<false>((resolve) => { timer = setTimeout(() => resolve(false), CALLBACK_DEADLINE_MS); });
455
+ const accepted = await Promise.race([
456
+ Promise.resolve(callback?.(Object.freeze({ connectionId: event.connectionId, id: event.frame.id, senderSessionId: event.frame.senderSessionId, recipientSessionId: event.frame.recipientSessionId, message: event.frame.message }))).then((value) => value === true, () => false),
457
+ deadline,
458
+ ]);
459
+ if (timer) clearTimeout(timer);
460
+ if (this.stopped || !this.isCurrentListenerGeneration(event.generation)) return;
461
+ // This is the sole path that lets PowerShell reply on its retained server handle.
462
+ void this.request("ack", { connectionId: event.connectionId, generation: event.generation, id: event.frame.id, accepted }).catch(() => {});
463
+ }
464
+ private settle(requestId: string, error?: Error, result?: Record<string, unknown>) {
465
+ const pending = this.pending.get(requestId);
466
+ if (!pending) return;
467
+ this.pending.delete(requestId); clearTimeout(pending.timer);
468
+ if (pending.kind === "ack") this.pendingAcks--; else this.pendingRpcs--;
469
+ if (error) pending.reject(error); else pending.resolve(result ?? {});
470
+ }
471
+ private failListener(generation: number) {
472
+ if (this.stopped || !this.isCurrentListenerGeneration(generation)) return;
473
+ if (this.listenerPendingGeneration === generation) {
474
+ this.listenerPendingGeneration = undefined;
475
+ this.listenerPendingIdentity = undefined;
476
+ } else {
477
+ this.listenerActiveGeneration = undefined;
478
+ this.listenerActiveIdentity = undefined;
479
+ }
480
+ for (const [id, pending] of [...this.pending]) if (pending.kind === "ack") this.settle(id, safeError("Windows transport listener failed"));
481
+ try { this.listenerFailure?.(generation); } catch {}
482
+ }
483
+ private detachOwnedListeners(keepClose: boolean) {
484
+ const child = this.child;
485
+ if (!child) return;
486
+ child.stdout.removeListener("data", this.onStdoutData);
487
+ child.stdout.removeListener("error", this.onStdoutError);
488
+ child.stdin.removeListener("error", this.onStdinError);
489
+ child.stderr.removeListener("error", this.onStderrError);
490
+ child.removeListener("error", this.onChildError);
491
+ child.removeListener("exit", this.onChildExit);
492
+ if (!keepClose) child.removeListener("close", this.onChildClose);
493
+ }
494
+ private handOffTimedOutClose(child: SpawnedHost) {
495
+ const detached = createDetachedChildCleanup(child);
496
+ this.handoffCloseState = detached;
497
+ detached.install();
498
+ if (!detached.closed()) child.removeListener("close", this.onChildClose);
499
+ this.handoffCloseState = undefined;
500
+ return detached.closed();
501
+ }
502
+ private handleChildClose() {
503
+ this.childClosed = true;
504
+ if (this.cleanupTimer) clearTimeout(this.cleanupTimer);
505
+ this.cleanupTimer = undefined;
506
+ this.handoffCloseState?.close();
507
+ if (!this.stopped) this.abort("Windows transport host exited", false, "exit");
508
+ this.detachOwnedListeners(false);
509
+ const resolve = this.resolveCleanup;
510
+ this.resolveCleanup = undefined;
511
+ this.rejectCleanup = undefined;
512
+ this.cleanup = undefined;
513
+ this.output = Buffer.alloc(0);
514
+ this.child = undefined;
515
+ resolve?.();
516
+ }
517
+ private closeInput() {
518
+ if (this.inputClosed) return;
519
+ this.inputClosed = true;
520
+ try { this.child?.stdin.end(); } catch {}
521
+ }
522
+ private failCleanup() {
523
+ if (this.childClosed) return;
524
+ this.cleanupTimer = undefined;
525
+ const child = this.child;
526
+ if (!child) return;
527
+ // Timeout is not physical closure. Hand close/error safety to callbacks that
528
+ // capture only this child and its streams, then release host protocol state.
529
+ this.detachOwnedListeners(true);
530
+ const closedDuringHandoff = this.handOffTimedOutClose(child);
531
+ this.output = Buffer.alloc(0);
532
+ this.child = undefined;
533
+ if (closedDuringHandoff) return;
534
+ const reject = this.rejectCleanup;
535
+ this.resolveCleanup = undefined;
536
+ this.rejectCleanup = undefined;
537
+ reject?.(safeError("Windows transport host did not close"));
538
+ }
539
+ private requestChildKill() {
540
+ if (this.childClosed || this.childKillRequested) return;
541
+ this.childKillRequested = true;
542
+ try { this.child?.kill(); } catch {}
543
+ }
544
+ private scheduleCleanupKill() {
545
+ if (this.childClosed || this.childKillRequested) return;
546
+ if (this.cleanupTimer) clearTimeout(this.cleanupTimer);
547
+ this.cleanupTimer = setTimeout(() => {
548
+ this.cleanupTimer = undefined;
549
+ this.requestChildKill();
550
+ if (!this.childClosed) this.cleanupTimer = setTimeout(() => this.failCleanup(), SHUTDOWN_GRACE_MS);
551
+ }, SHUTDOWN_GRACE_MS);
552
+ }
553
+ private releaseOwnedChild(killNow: boolean): Promise<void> {
554
+ if (this.cleanup) return this.cleanup;
555
+ const child = this.child;
556
+ if (!child) return Promise.resolve();
557
+ if (!this.cleanup) {
558
+ if (this.childClosed) return Promise.resolve();
559
+ this.cleanup = new Promise<void>((resolve, reject) => { this.resolveCleanup = resolve; this.rejectCleanup = reject; });
560
+ }
561
+ const cleanup = this.cleanup;
562
+ this.closeInput();
563
+ if (killNow) {
564
+ if (this.cleanupTimer) clearTimeout(this.cleanupTimer);
565
+ this.cleanupTimer = undefined;
566
+ this.requestChildKill();
567
+ if (!this.childClosed) this.cleanupTimer = setTimeout(() => this.failCleanup(), SHUTDOWN_GRACE_MS);
568
+ } else this.scheduleCleanupKill();
569
+ return cleanup;
570
+ }
571
+ private abort(message: string, closeChild = false, code: WindowsSessionRegistryPhaseErrorCode = "unknown") {
572
+ if (this.stopped) return;
573
+ // Snapshot, then revoke, before either request settlement or application code.
574
+ // This prevents a synchronous callback from observing an owned stale epoch.
575
+ const generation = this.listenerPendingGeneration ?? this.listenerActiveGeneration;
576
+ this.listenerPendingGeneration = undefined;
577
+ this.listenerActiveGeneration = undefined;
578
+ this.listenerPendingIdentity = undefined;
579
+ this.listenerActiveIdentity = undefined;
580
+ this.stopped = true;
581
+ for (const id of [...this.pending.keys()]) this.settle(id, transportError(message, code));
582
+ if (generation !== undefined) { try { this.listenerFailure?.(generation); } catch {} }
583
+ void this.releaseOwnedChild(closeChild).catch(() => {});
584
+ }
585
+ }
586
+
587
+ type WindowsRecord = PresenceRecord;
588
+ const validRecord = (value: unknown): WindowsRecord => {
589
+ if (!plainRecord(value)) throw new SessionPresenceError("invalid_presence", "invalid presence record");
590
+ const record = value;
591
+ const keys = Object.keys(record);
592
+ if (keys.length !== 4 || !["version", "sessionId", "endpoint", "createdAt"].every((key) => keys.includes(key)) || record.version !== 1 || typeof record.sessionId !== "string" || !SESSION.test(record.sessionId) || typeof record.endpoint !== "string" || !PIPE.test(record.endpoint) || typeof record.createdAt !== "number" || !Number.isSafeInteger(record.createdAt) || record.createdAt < 0) throw new SessionPresenceError("invalid_presence", "invalid presence record");
593
+ return Object.freeze({ version: 1, sessionId: record.sessionId, endpoint: record.endpoint, createdAt: record.createdAt });
594
+ };
595
+ const registryError = (error: unknown): never => {
596
+ if (error instanceof SessionPresenceError) throw error;
597
+ throw new SessionPresenceError("io_error", "transport I/O failed");
598
+ };
599
+
600
+ const phaseError = (error: unknown): WindowsSessionRegistryPhaseError => {
601
+ const code = error instanceof Error ? transportFailureCodes.get(error) ?? "unknown" : "unknown";
602
+ return Object.freeze({ class: code === "deadline" ? "timed-out" : error instanceof Error ? "rejected" : "unknown", code });
603
+ };
604
+ class WindowsSessionRegistryObserver {
605
+ private active = true;
606
+ private readonly callback: WindowsSessionRegistryPhaseObserver;
607
+ private readonly readLastStartupMarker: () => WindowsSessionStartupMarker | null;
608
+ constructor(callback: WindowsSessionRegistryPhaseObserver, readLastStartupMarker: () => WindowsSessionStartupMarker | null) {
609
+ this.callback = callback;
610
+ this.readLastStartupMarker = readLastStartupMarker;
611
+ }
612
+ async run<T>(phase: WindowsSessionRegistryPhase, operation: () => Promise<T>): Promise<T> {
613
+ try {
614
+ const value = await operation();
615
+ this.observe(Object.freeze({ phase, status: "succeeded", error: null, lastStartupMarker: this.readLastStartupMarker() }));
616
+ return value;
617
+ } catch (error) {
618
+ try { this.observe(Object.freeze({ phase, status: "failed", error: phaseError(error), lastStartupMarker: this.readLastStartupMarker() })); } catch { /* the operation rejection remains primary */ }
619
+ throw error;
620
+ }
621
+ }
622
+ private observe(event: WindowsSessionRegistryPhaseEvent) {
623
+ if (!this.active) return;
624
+ let result: unknown;
625
+ try { result = this.callback(event); }
626
+ catch { this.active = false; throw safeError("Windows registry observation failed"); }
627
+ if (result === undefined) return;
628
+ this.active = false;
629
+ try { Promise.resolve(result).catch(() => {}); } catch { /* invalid thenables remain observer failures */ }
630
+ throw safeError("Windows registry observation failed");
631
+ }
632
+ }
633
+
634
+ /** Windows metadata stays in the PowerShell owner process; Node sees only public activation records. */
635
+ export class WindowsSessionPresenceRegistry {
636
+ readonly paths = Object.freeze({ root: "", presence: "", sockets: "" });
637
+ private readonly host: WindowsSessionTransportHost;
638
+ private readonly observer?: WindowsSessionRegistryObserver;
639
+ private notification?: WindowsHostCallback;
640
+ private listenerFailure?: WindowsHostFailureCallback;
641
+ private constructor(host: WindowsSessionTransportHost, observer?: WindowsSessionRegistryObserver) {
642
+ this.host = host;
643
+ this.observer = observer;
644
+ this.host.setListenerFailure((generation) => this.listenerFailure?.(generation));
645
+ }
646
+ static async create(agentHome: string, observePhase?: WindowsSessionRegistryPhaseObserver) {
647
+ if (process.platform !== "win32") throw new SessionPresenceError("io_error", "transport I/O failed");
648
+ if (typeof agentHome !== "string" || !/^[A-Za-z]:\\/.test(agentHome)) throw new SessionPresenceError("unsafe_path", "unsafe transport path");
649
+ let registry: WindowsSessionPresenceRegistry | undefined;
650
+ const host = new WindowsSessionTransportHost({ callback: async (notification) => registry?.notification?.(notification) ?? false });
651
+ const observer = observePhase === undefined ? undefined : new WindowsSessionRegistryObserver(observePhase, () => host.lastStartupMarker);
652
+ const run = <T>(phase: WindowsSessionRegistryPhase, operation: () => Promise<T>) => observer ? observer.run(phase, operation) : operation();
653
+ try {
654
+ await run("start", () => host.start());
655
+ await run("initialize", async () => {
656
+ const initialized = await host.request("initialize", { agentHome });
657
+ if (initialized.state !== "initialized" || initialized.bootstrap !== "complete") throw safeError("Windows transport host unavailable");
658
+ });
659
+ registry = new WindowsSessionPresenceRegistry(host, observer);
660
+ return registry;
661
+ } catch (error) {
662
+ try { await run("cleanup", () => host.close()); } catch { /* registry creation preserves its primary rejection */ }
663
+ registryError(error);
664
+ }
665
+ }
666
+ setNotification(callback?: WindowsHostCallback) { this.notification = callback; }
667
+ clearNotification(callback: WindowsHostCallback) { if (this.notification === callback) this.notification = undefined; }
668
+ setListenerFailure(callback?: WindowsHostFailureCallback) { this.listenerFailure = callback; }
669
+ clearListenerFailure(callback: WindowsHostFailureCallback) { if (this.listenerFailure === callback) this.listenerFailure = undefined; }
670
+ async record(sessionId: string, createdAt = Date.now()) { try { return validRecord(await this.host.request("record", { sessionId, createdAt })); } catch { registryError(undefined); } }
671
+ presencePath(_record: PresenceRecord) { throw new SessionPresenceError("unsafe_path", "unsafe transport path"); }
672
+ async publish(record: PresenceRecord) { try { await this.host.request("publish", { record: validRecord(record as unknown as Record<string, unknown>) }); } catch { registryError(undefined); } }
673
+ async list(excludeSessionId?: string): Promise<readonly SessionPresenceCandidate[]> { return (await this.listActivations(excludeSessionId)).map((record) => Object.freeze({ sessionId: record.sessionId, reachability: "unknown" as const })); }
674
+ async listActivations(excludeSessionId?: string): Promise<readonly WindowsRecord[]> { try { const value = await this.host.request("list", { ...(excludeSessionId === undefined ? {} : { excludeSessionId }) }); if (!Array.isArray(value.records) || value.records.length > 64) throw new Error(); return Object.freeze(value.records.map((record) => validRecord(record))); } catch { registryError(undefined); } }
675
+ async resolve(sessionId: string): Promise<WindowsRecord> { try { return validRecord(await this.host.request("resolve", { sessionId })); } catch { registryError(undefined); } }
676
+ async removeOwn(record: PresenceRecord) { try { await this.host.request("remove", { record: validRecord(record as unknown as Record<string, unknown>) }); } catch { /* identity-bound owned cleanup is intentionally best effort */ } }
677
+ async startListener(sessionId: string): Promise<WindowsRecord> {
678
+ // listen resolves only after the helper has atomically armed and published it.
679
+ try { return await this.host.listen(sessionId); } catch (error) { registryError(error); }
680
+ }
681
+ async stopListener(record: PresenceRecord) { try { await this.host.stopListener(record); } catch { await this.removeOwn(record); } }
682
+ async close() { await (this.observer ? this.observer.run("cleanup", () => this.host.close()) : this.host.close()); }
683
+ }
684
+
685
+ export class WindowsActiveSessionListener {
686
+ readonly registry: WindowsSessionPresenceRegistry;
687
+ readonly sessionID: string;
688
+ record?: PresenceRecord;
689
+ readonly closed: Promise<void>;
690
+ failure?: Readonly<{ code: "io_error"; message: "listener failed" }>;
691
+ private readonly onNotification: (notification: ReceivedNotification) => Promise<void>;
692
+ private state: "idle" | "starting" | "active" | "closed" = "idle";
693
+ private generation = 0;
694
+ private resolveClosed!: () => void;
695
+ private readonly notification = (notification: Readonly<{ connectionId: string; id: string; senderSessionId: string; recipientSessionId: string; message: string }>) => this.receive(notification);
696
+ private listenerFailure?: WindowsHostFailureCallback;
697
+ constructor(registry: WindowsSessionPresenceRegistry, sessionID: string, onNotification: (notification: ReceivedNotification) => Promise<void>) { this.registry = registry; this.sessionID = sessionID; this.onNotification = onNotification; this.closed = new Promise((resolve) => { this.resolveClosed = resolve; }); }
698
+ get status() { return this.state; }
699
+ get activeConnections() { return 0; }
700
+ async start() {
701
+ if (this.state !== "idle") return;
702
+ const generation = ++this.generation;
703
+ this.state = "starting";
704
+ this.registry.setNotification(this.notification);
705
+ // Host generations are shared by the helper. This closure binds its validated
706
+ // current host generation to this object's independent local start token.
707
+ const listenerFailure: WindowsHostFailureCallback = () => this.fail(generation);
708
+ this.listenerFailure = listenerFailure;
709
+ this.registry.setListenerFailure(listenerFailure);
710
+ try {
711
+ const record = await this.registry.startListener(this.sessionID);
712
+ if (this.status !== "starting" || this.generation !== generation) {
713
+ await this.registry.stopListener(record);
714
+ const state = this.status;
715
+ throw new SessionPresenceError("io_error", state === "closed" ? "listener is closed" : "listener failed");
716
+ }
717
+ this.record = record;
718
+ this.state = "active";
719
+ } catch (error) {
720
+ const state = this.status;
721
+ const failed = state === "idle" && this.generation === generation && this.failure !== undefined;
722
+ if (state !== "closed" && this.generation === generation) this.state = "idle";
723
+ if (failed) throw new SessionPresenceError("io_error", "listener failed");
724
+ throw error;
725
+ }
726
+ }
727
+ async receive(notification: Readonly<{ id: string; senderSessionId: string; recipientSessionId: string; message: string }>) { if ((this.state !== "starting" && this.state !== "active") || notification.recipientSessionId !== this.sessionID) return false; try { await this.onNotification(Object.freeze({ id: notification.id, senderSessionId: notification.senderSessionId, message: notification.message })); return true; } catch { return false; } }
728
+ private fail(generation: number) {
729
+ if ((this.state !== "starting" && this.state !== "active") || this.generation !== generation) return;
730
+ const listenerFailure = this.listenerFailure;
731
+ this.record = undefined;
732
+ this.failure = Object.freeze({ code: "io_error", message: "listener failed" });
733
+ this.state = "idle";
734
+ this.registry.clearNotification(this.notification);
735
+ if (listenerFailure) this.registry.clearListenerFailure(listenerFailure);
736
+ if (this.listenerFailure === listenerFailure) this.listenerFailure = undefined;
737
+ }
738
+ async close() {
739
+ if (this.state === "closed") return;
740
+ this.generation++;
741
+ this.state = "closed";
742
+ const record = this.record;
743
+ const listenerFailure = this.listenerFailure;
744
+ this.record = undefined;
745
+ this.listenerFailure = undefined;
746
+ this.registry.clearNotification(this.notification);
747
+ if (listenerFailure) this.registry.clearListenerFailure(listenerFailure);
748
+ try { if (record) await this.registry.stopListener(record); }
749
+ finally { try { await this.registry.close(); } finally { this.resolveClosed(); } }
750
+ }
751
+ }
752
+
753
+ type WindowsClientScheduler = Readonly<{ setTimeout: (callback: () => void, delay: number) => unknown; clearTimeout: (handle: unknown) => void }>;
754
+ export type WindowsActiveSessionClientConfig = Readonly<{ connect?: (endpoint: string) => Socket; scheduler?: WindowsClientScheduler; connectDeadlineMs?: number; ackDeadlineMs?: number }>;
755
+ type WindowsPendingOutbound = { settled: boolean; socket?: Socket; connectTimer?: unknown; ackTimer?: unknown; abort?: () => void; cleanup?: () => void; settle: (error?: ActiveSessionClientError, result?: SentNotification) => void };
756
+
757
+ export class WindowsActiveSessionClient {
758
+ readonly registry: WindowsSessionPresenceRegistry;
759
+ readonly senderSessionId: string;
760
+ private readonly connect: (endpoint: string) => Socket;
761
+ private readonly scheduler: WindowsClientScheduler;
762
+ private readonly connectDeadlineMs: number;
763
+ private readonly ackDeadlineMs: number;
764
+ private stopped = false;
765
+ private readonly pending = new Set<WindowsPendingOutbound>();
766
+ constructor(registry: WindowsSessionPresenceRegistry, senderSessionId: string, config: WindowsActiveSessionClientConfig = {}) {
767
+ this.registry = registry;
768
+ this.senderSessionId = senderSessionId;
769
+ this.connect = config.connect ?? createConnection;
770
+ this.scheduler = config.scheduler ?? { setTimeout, clearTimeout };
771
+ this.connectDeadlineMs = config.connectDeadlineMs ?? RPC_DEADLINE_MS;
772
+ this.ackDeadlineMs = config.ackDeadlineMs ?? RPC_DEADLINE_MS;
773
+ if (!Number.isInteger(this.connectDeadlineMs) || this.connectDeadlineMs < 1 || this.connectDeadlineMs > RPC_DEADLINE_MS || !Number.isInteger(this.ackDeadlineMs) || this.ackDeadlineMs < 1 || this.ackDeadlineMs > RPC_DEADLINE_MS) throw new RangeError("invalid Windows client deadline");
774
+ }
775
+ get pendingCount() { return this.pending.size; }
776
+ get closed() { return this.stopped; }
777
+ close() {
778
+ if (this.stopped) return;
779
+ this.stopped = true;
780
+ for (const pending of [...this.pending]) pending.settle(new ActiveSessionClientError("closed"));
781
+ }
782
+ sendNotification(recipientSessionId: string, message: string, options: { id?: string; expectedActivation?: PresenceRecord; beforeConnect?: () => boolean | Promise<boolean>; signal?: AbortSignal } = {}): Promise<SentNotification> {
783
+ if (this.stopped) return Promise.reject(new ActiveSessionClientError("closed"));
784
+ if (recipientSessionId === this.senderSessionId) return Promise.reject(new ActiveSessionClientError("self"));
785
+ if (this.pending.size >= MAX_PENDING) return Promise.reject(new ActiveSessionClientError("busy"));
786
+ if (options.signal?.aborted) return Promise.reject(new ActiveSessionClientError("aborted"));
787
+ const id = options.id ?? crypto.randomUUID().replaceAll("-", "");
788
+ let pending!: WindowsPendingOutbound;
789
+ return new Promise<SentNotification>((resolve, reject) => {
790
+ pending = { settled: false, settle: (error, result) => {
791
+ if (pending.settled) return;
792
+ pending.settled = true;
793
+ for (const timer of [pending.connectTimer, pending.ackTimer]) if (timer !== undefined) try { this.scheduler.clearTimeout(timer); } catch {}
794
+ pending.connectTimer = pending.ackTimer = undefined;
795
+ if (pending.abort) options.signal?.removeEventListener("abort", pending.abort);
796
+ pending.cleanup?.();
797
+ pending.cleanup = undefined;
798
+ this.pending.delete(pending);
799
+ pending.socket?.destroy();
800
+ if (error) reject(error); else resolve(result!);
801
+ } };
802
+ this.pending.add(pending);
803
+ pending.abort = () => pending.settle(new ActiveSessionClientError(this.stopped ? "closed" : "aborted"));
804
+ options.signal?.addEventListener("abort", pending.abort, { once: true });
805
+ void this.begin(pending, recipientSessionId, message, id, options);
806
+ });
807
+ }
808
+ private async begin(pending: WindowsPendingOutbound, recipientSessionId: string, message: string, id: string, options: { expectedActivation?: PresenceRecord; beforeConnect?: () => boolean | Promise<boolean> }) {
809
+ let record: PresenceRecord;
810
+ try { record = await this.registry.resolve(recipientSessionId); }
811
+ catch { pending.settle(new ActiveSessionClientError(options.expectedActivation ? "stale" : "not_found")); return; }
812
+ if (pending.settled) return;
813
+ if (this.stopped) { pending.settle(new ActiveSessionClientError("closed")); return; }
814
+ if (options.expectedActivation && JSON.stringify(record) !== JSON.stringify(options.expectedActivation)) { pending.settle(new ActiveSessionClientError("stale")); return; }
815
+ try { if (options.beforeConnect && !await options.beforeConnect()) { pending.settle(new ActiveSessionClientError("stale")); return; } }
816
+ catch { pending.settle(new ActiveSessionClientError("stale")); return; }
817
+ if (pending.settled) return;
818
+ if (this.stopped) { pending.settle(new ActiveSessionClientError("closed")); return; }
819
+ let request: Buffer;
820
+ try { request = encodeNotificationFrame(Object.freeze({ version: 1, kind: "notification", id, senderSessionId: this.senderSessionId, recipientSessionId, message })); }
821
+ catch { pending.settle(new ActiveSessionClientError("io_error")); return; }
822
+ let socket: Socket;
823
+ try { socket = this.connect(record.endpoint); }
824
+ catch { pending.settle(new ActiveSessionClientError("io_error")); return; }
825
+ if (pending.settled) { socket.destroy(); return; }
826
+ pending.socket = socket;
827
+ const decoder = new FrameDecoder();
828
+ const finishAck = () => {
829
+ if (pending.settled) return;
830
+ try {
831
+ const ack = decoder.finish() as AckFrame;
832
+ if (ack.kind !== "ack" || ack.id !== id) pending.settle(new ActiveSessionClientError("invalid_ack"));
833
+ else if (!ack.accepted) pending.settle(new ActiveSessionClientError("remote_rejected"));
834
+ else pending.settle(undefined, Object.freeze({ id, accepted: true }));
835
+ } catch { pending.settle(new ActiveSessionClientError("invalid_ack")); }
836
+ };
837
+ const onConnect = () => {
838
+ if (pending.settled) return;
839
+ if (pending.connectTimer !== undefined) { try { this.scheduler.clearTimeout(pending.connectTimer); } catch {} pending.connectTimer = undefined; }
840
+ try { socket.write(request); } catch { pending.settle(new ActiveSessionClientError("io_error")); return; }
841
+ if (pending.settled) return;
842
+ try {
843
+ const timer = this.scheduler.setTimeout(() => pending.settle(new ActiveSessionClientError("ack_timeout")), this.ackDeadlineMs);
844
+ if (pending.settled) { try { this.scheduler.clearTimeout(timer); } catch {} return; }
845
+ pending.ackTimer = timer;
846
+ } catch { pending.settle(new ActiveSessionClientError("ack_timeout")); }
847
+ };
848
+ const onData = (chunk: Buffer) => { if (!pending.settled) try { decoder.push(chunk); } catch { pending.settle(new ActiveSessionClientError("invalid_ack")); } };
849
+ const onClose = () => { if (!pending.settled) pending.settle(new ActiveSessionClientError("io_error")); };
850
+ const onError = () => pending.settle(new ActiveSessionClientError("io_error"));
851
+ const cleanup = () => {
852
+ socket.removeListener("connect", onConnect);
853
+ socket.removeListener("data", onData);
854
+ socket.removeListener("end", finishAck);
855
+ socket.removeListener("close", onClose);
856
+ socket.removeListener("error", onError);
857
+ };
858
+ // Install cleanup before the first registration: EventEmitter-compatible fakes
859
+ // may synchronously abort or throw after attaching an individual handler.
860
+ pending.cleanup = cleanup;
861
+ try {
862
+ socket.once("connect", onConnect);
863
+ if (pending.settled) return;
864
+ socket.on("data", onData);
865
+ if (pending.settled) return;
866
+ socket.once("end", finishAck);
867
+ if (pending.settled) return;
868
+ socket.once("close", onClose);
869
+ if (pending.settled) return;
870
+ socket.once("error", onError);
871
+ if (pending.settled) return;
872
+ const timer = this.scheduler.setTimeout(() => pending.settle(new ActiveSessionClientError("connect_timeout")), this.connectDeadlineMs);
873
+ if (pending.settled) { try { this.scheduler.clearTimeout(timer); } catch {} return; }
874
+ pending.connectTimer = timer;
875
+ } catch { pending.settle(new ActiveSessionClientError("io_error")); }
876
+ }
877
+ }