gentle-pi 2.6.4 → 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 (128) hide show
  1. package/README.md +25 -7
  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 +41 -19
  18. package/docs/readme-reference.md +175 -36
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +289 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +123 -97
  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 +74 -113
  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 +19 -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 +88 -0
  47. package/lib/session-changes.ts +147 -0
  48. package/lib/shell-bar.ts +24 -10
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes-view.ts +2 -1
  51. package/lib/shell-changes.ts +5 -2
  52. package/lib/shell-prompt.ts +25 -8
  53. package/lib/shell-sidebar-banner.ts +2 -2
  54. package/lib/shell-sidebar-layout.ts +5 -2
  55. package/lib/windows-session-transport.ts +877 -0
  56. package/package.json +3 -3
  57. package/runtime/native-review-cli.mjs +18 -96
  58. package/runtime/windows-session-transport.ps1 +791 -0
  59. package/scripts/gentle-ai-installer.mjs +10 -10
  60. package/scripts/test-packed-runner.mjs +1668 -20
  61. package/scripts/verify-package-files.mjs +2 -3
  62. package/tests/agent-home.test.ts +52 -0
  63. package/tests/agent-profiles.test.ts +30 -1
  64. package/tests/agents-config.test.ts +44 -0
  65. package/tests/agents-history.test.ts +12 -24
  66. package/tests/agents-runner.test.ts +321 -58
  67. package/tests/agents-session-transport-process.test.ts +249 -0
  68. package/tests/agents-session-transport.test.ts +823 -0
  69. package/tests/artifact-language.test.ts +10 -7
  70. package/tests/command-palette.test.ts +378 -0
  71. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  72. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  73. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  74. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  75. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  76. package/tests/gentle-agents.test.ts +883 -356
  77. package/tests/gentle-ai-binary.test.ts +1 -1
  78. package/tests/gentle-ai-installer.test.ts +47 -47
  79. package/tests/gentle-ai.test.ts +472 -4
  80. package/tests/gentle-shell.test.ts +338 -205
  81. package/tests/native-choice-list.test.ts +13 -0
  82. package/tests/native-review-capability-contract.test.ts +15 -1
  83. package/tests/native-review-cli.test.ts +0 -33
  84. package/tests/odd-routing-contract.test.ts +208 -0
  85. package/tests/orchestrator-budget.test.ts +17 -2
  86. package/tests/package-manifest.test.ts +119 -31
  87. package/tests/persona-single-channel.test.ts +3 -3
  88. package/tests/pi-pretty.test.ts +45 -0
  89. package/tests/profile-pin.test.ts +370 -0
  90. package/tests/quiet-tool-rendering.test.ts +32 -5
  91. package/tests/review-contract-prompt.test.ts +9 -0
  92. package/tests/review-controller.test.ts +0 -44
  93. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  94. package/tests/runtime-harness.mjs +4 -4
  95. package/tests/sdd-agent-tools.test.ts +15 -36
  96. package/tests/sdd-archive-replay.test.ts +82 -0
  97. package/tests/sdd-classical-continuation.test.ts +74 -0
  98. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  99. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  100. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  101. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  102. package/tests/sdd-odd-integration.test.ts +33 -0
  103. package/tests/sdd-optional-research.test.ts +124 -0
  104. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  105. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  106. package/tests/sdd-preflight.test.ts +1 -1
  107. package/tests/sdd-research-capabilities.test.ts +20 -162
  108. package/tests/sdd-selection-transport.test.ts +180 -88
  109. package/tests/sdd-status.test.ts +5 -778
  110. package/tests/sdd-task-truth.test.ts +43 -0
  111. package/tests/session-change-capture.test.ts +86 -0
  112. package/tests/session-changes-shell.test.ts +38 -0
  113. package/tests/session-changes.test.ts +114 -0
  114. package/tests/shell-bar.test.ts +35 -0
  115. package/tests/shell-card.test.ts +8 -6
  116. package/tests/shell-changes.test.ts +8 -0
  117. package/tests/shell-prompt.test.ts +41 -7
  118. package/tests/shell-sidebar-banner.test.ts +4 -4
  119. package/tests/shell-sidebar-layout.test.ts +97 -13
  120. package/tests/startup-banner.test.ts +55 -2
  121. package/tests/windows-hidden-processes.test.ts +303 -0
  122. package/tests/windows-session-bootstrap.test.ts +1772 -0
  123. package/tests/windows-session-compile.test.ts +170 -0
  124. package/tests/windows-session-transport.test.ts +754 -0
  125. package/assets/agents/sdd-sync.md +0 -146
  126. package/lib/openspec-guardrails.ts +0 -99
  127. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  128. package/tests/openspec-guardrails.test.ts +0 -71
@@ -0,0 +1,691 @@
1
+ import { chmod, constants, link, lstat, mkdir, open, opendir, realpath, unlink, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { createConnection, createServer, type Server, type Socket } from "node:net";
5
+
6
+ const DIR_MODE = 0o700, FILE_MODE = 0o600, MAX_RECORD_BYTES = 8192, MAX_ENTRIES = 64, MAX_SOCKET_PATH_BYTES = 100;
7
+ const RUNTIME_TMP = "/tmp", RUNTIME_PREFIX = "gentle-pi-", PROFILE_HASH_HEX_LENGTH = 32, SOCKET_NAME_BYTES = 27;
8
+ const SESSION = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/, TOKEN = /^[A-Za-z0-9_-]{22}$/;
9
+ const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
10
+ type Code = "invalid_session" | "not_found" | "invalid_presence" | "unsafe_path" | "io_error" | "busy";
11
+
12
+ export type PresenceRecord = Readonly<{ version: 1; sessionId: string; endpoint: string; createdAt: number }>;
13
+ export type SessionPresenceCandidate = Readonly<{ sessionId: string; reachability: "unknown" }>;
14
+ export type TransportPaths = Readonly<{ root: string; presence: string; sockets: string }>;
15
+
16
+ export class SessionPresenceError extends Error {
17
+ readonly code: Code;
18
+ constructor(code: Code, message: string) { super(message); this.code = code; this.name = "SessionPresenceError"; }
19
+ }
20
+
21
+ function fail(code: Code, message: string): never { throw new SessionPresenceError(code, message); }
22
+ const uid = () => typeof process.getuid === "function" ? process.getuid() : undefined;
23
+ const sameUser = (stat: { uid: number }) => uid() === undefined || stat.uid === uid();
24
+ function sessionId(value: unknown): string {
25
+ if (typeof value !== "string" || !SESSION.test(value)) fail("invalid_session", "invalid session ID");
26
+ return value;
27
+ }
28
+ function plainRecord(value: unknown): value is Record<string, unknown> {
29
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
30
+ }
31
+ function boundary(error: unknown): never {
32
+ if (error instanceof SessionPresenceError) throw error;
33
+ const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : undefined;
34
+ if (code === "ENOENT") fail("not_found", "presence not found");
35
+ if (code === "ELOOP") fail("unsafe_path", "unsafe transport path");
36
+ fail("io_error", "transport I/O failed");
37
+ }
38
+
39
+ // The agent profile and its shared Gentle Agents parent belong to the host
40
+ // runtime. Transport may validate them but must not tighten their permissions.
41
+ async function parentDirectory(path: string) {
42
+ try {
43
+ const stat = await lstat(path);
44
+ if (stat.isSymbolicLink() || !stat.isDirectory() || !sameUser(stat)) fail("unsafe_path", "unsafe transport path");
45
+ } catch (error) { boundary(error); }
46
+ }
47
+
48
+ async function privateDirectory(path: string, create = false) {
49
+ try {
50
+ if (create) await mkdir(path, { mode: DIR_MODE }).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; });
51
+ const stat = await lstat(path);
52
+ if (stat.isSymbolicLink() || !stat.isDirectory() || !sameUser(stat) || (stat.mode & 0o777) !== DIR_MODE) fail("unsafe_path", "unsafe transport path");
53
+ } catch (error) { boundary(error); }
54
+ }
55
+
56
+ type RuntimeParent = Readonly<{ mode: number; uid: number; isSymbolicLink: () => boolean; isDirectory: () => boolean }>;
57
+
58
+ export const isSafeRuntimeParent = (stat: RuntimeParent) => {
59
+ if (stat.isSymbolicLink() || !stat.isDirectory() || (!sameUser(stat) && stat.uid !== 0)) return false;
60
+ const mode = stat.mode & 0o7777;
61
+ return (sameUser(stat) && (mode & 0o022) === 0) || (mode & 0o1777) === 0o1777;
62
+ };
63
+
64
+ async function runtimeSocketDirectory(agentHome: string) {
65
+ try {
66
+ // macOS resolves /tmp to /private/tmp. This shared parent is not ours: it
67
+ // must be owner-only writable or a world-writable sticky tmp.
68
+ const shared = await realpath(RUNTIME_TMP), sharedStat = await lstat(shared);
69
+ if (!isSafeRuntimeParent(sharedStat)) fail("unsafe_path", "unsafe transport path");
70
+ const userRoot = join(shared, `${RUNTIME_PREFIX}${uid() ?? "unknown"}`);
71
+ await privateDirectory(userRoot, true);
72
+ const profile = createHash("sha256").update(agentHome).digest("hex").slice(0, PROFILE_HASH_HEX_LENGTH);
73
+ const sockets = join(userRoot, profile);
74
+ if (Buffer.byteLength(join(sockets, `${"x".repeat(SOCKET_NAME_BYTES - 5)}.sock`)) > MAX_SOCKET_PATH_BYTES) fail("unsafe_path", "unsafe transport path");
75
+ await privateDirectory(sockets, true);
76
+ return sockets;
77
+ } catch (error) { boundary(error); }
78
+ }
79
+
80
+ export class SessionPresenceRegistry {
81
+ readonly paths: TransportPaths;
82
+
83
+ private readonly beforeCandidateOpen?: () => Promise<void>;
84
+
85
+ private constructor(agentHome: string, sockets: string, beforeCandidateOpen?: () => Promise<void>) {
86
+ const root = join(agentHome, "gentle-agents", "transport");
87
+ this.paths = Object.freeze({ root, presence: join(root, "presence"), sockets });
88
+ this.beforeCandidateOpen = beforeCandidateOpen;
89
+ }
90
+
91
+ static async create(agentHome: string, beforeCandidateOpen?: () => Promise<void>) {
92
+ if (process.platform === "win32") fail("io_error", "transport I/O failed");
93
+ const home = resolve(agentHome);
94
+ await parentDirectory(home);
95
+ await parentDirectory(join(home, "gentle-agents"));
96
+ const sockets = await runtimeSocketDirectory(home);
97
+ const registry = new SessionPresenceRegistry(home, sockets, beforeCandidateOpen);
98
+ await privateDirectory(registry.paths.root, true);
99
+ await privateDirectory(registry.paths.presence, true);
100
+ return registry;
101
+ }
102
+
103
+ async record(id: string, createdAt = Date.now()): Promise<PresenceRecord> {
104
+ id = sessionId(id);
105
+ if (!Number.isSafeInteger(createdAt) || createdAt < 0) fail("invalid_presence", "invalid presence record");
106
+ const endpoint = join(this.paths.sockets, `${randomBytes(16).toString("base64url")}.sock`);
107
+ if (Buffer.byteLength(endpoint) > MAX_SOCKET_PATH_BYTES) fail("unsafe_path", "unsafe transport path");
108
+ return Object.freeze({ version: 1, sessionId: id, endpoint, createdAt });
109
+ }
110
+
111
+ presencePath(record: PresenceRecord) { return join(this.paths.presence, `${sessionId(record.sessionId)}.${this.token(record.endpoint)}.json`); }
112
+
113
+ async publish(record: PresenceRecord) {
114
+ this.validate(record);
115
+ const target = this.presencePath(record), temporary = join(this.paths.presence, `.${randomBytes(16).toString("hex")}.tmp`);
116
+ try {
117
+ await writeFile(temporary, JSON.stringify(record), { encoding: "utf8", mode: FILE_MODE, flag: "wx" });
118
+ await chmod(temporary, FILE_MODE);
119
+ await link(temporary, target);
120
+ await unlink(temporary);
121
+ } catch (error) {
122
+ await unlink(temporary).catch(() => {});
123
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") fail("busy", "activation already exists");
124
+ boundary(error);
125
+ }
126
+ }
127
+
128
+ // The public list deliberately serializes only an ID and unknown reachability.
129
+ // Callers that must bind a later send can keep this private activation snapshot.
130
+ async list(excludeSessionId?: string): Promise<readonly SessionPresenceCandidate[]> {
131
+ return (await this.listActivations(excludeSessionId)).map(({ sessionId }) => Object.freeze({ sessionId, reachability: "unknown" as const }));
132
+ }
133
+
134
+ async listActivations(excludeSessionId?: string): Promise<readonly PresenceRecord[]> {
135
+ const excluded = excludeSessionId === undefined ? undefined : sessionId(excludeSessionId);
136
+ await privateDirectory(this.paths.root);
137
+ await privateDirectory(this.paths.presence);
138
+ await privateDirectory(this.paths.sockets);
139
+ const newest = new Map<string, PresenceRecord>();
140
+ let count = 0;
141
+ let dir;
142
+ try {
143
+ dir = await opendir(this.paths.presence);
144
+ for await (const entry of dir) {
145
+ if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
146
+ const match = /^([A-Za-z0-9][A-Za-z0-9_-]{0,127})\.([A-Za-z0-9_-]{22})\.json$/.exec(entry.name);
147
+ if (!match) continue;
148
+ try { await this.beforeCandidateOpen?.(); } catch { fail("io_error", "transport I/O failed"); }
149
+ try {
150
+ const loaded = await this.read(join(this.paths.presence, entry.name), match[1], match[2]);
151
+ const candidate = loaded?.record;
152
+ if (!candidate || candidate.sessionId === excluded || !await this.advertises(candidate)) continue;
153
+ const prior = newest.get(candidate.sessionId);
154
+ if (!prior || candidate.createdAt > prior.createdAt || (candidate.createdAt === prior.createdAt && this.token(candidate.endpoint) > this.token(prior.endpoint))) newest.set(candidate.sessionId, candidate);
155
+ } catch (error) {
156
+ if (!(error instanceof SessionPresenceError) || !["invalid_presence", "unsafe_path", "not_found"].includes(error.code)) throw error;
157
+ }
158
+ }
159
+ } catch (error) { boundary(error); }
160
+ return [...newest.values()].sort((left, right) => left.sessionId.localeCompare(right.sessionId));
161
+ }
162
+
163
+ async resolve(id: string): Promise<PresenceRecord> {
164
+ id = sessionId(id);
165
+ await privateDirectory(this.paths.root);
166
+ await privateDirectory(this.paths.presence);
167
+ await privateDirectory(this.paths.sockets);
168
+ let count = 0, newest: PresenceRecord | undefined, retained: SessionPresenceError | undefined;
169
+ let dir;
170
+ try {
171
+ dir = await opendir(this.paths.presence);
172
+ for await (const entry of dir) {
173
+ if (++count > MAX_ENTRIES) fail("busy", "presence registry is busy");
174
+ const match = new RegExp(`^${id}\\.([A-Za-z0-9_-]{22})\\.json$`).exec(entry.name);
175
+ if (!match) continue;
176
+ try { await this.beforeCandidateOpen?.(); } catch { fail("io_error", "transport I/O failed"); }
177
+ try {
178
+ const loaded = await this.read(join(this.paths.presence, entry.name), id, match[1]);
179
+ const candidate = loaded?.record;
180
+ if (candidate && (!newest || candidate.createdAt > newest.createdAt || (candidate.createdAt === newest.createdAt && this.token(candidate.endpoint) > this.token(newest.endpoint)))) newest = candidate;
181
+ } catch (error) {
182
+ if (!(error instanceof SessionPresenceError) || !["invalid_presence", "unsafe_path", "not_found"].includes(error.code)) throw error;
183
+ if (error.code !== "not_found" && (!retained || (error.code === "unsafe_path" && retained.code !== "unsafe_path"))) retained = error;
184
+ }
185
+ }
186
+ } catch (error) { boundary(error); }
187
+ if (!newest && retained) throw retained;
188
+ if (!newest) fail("not_found", "presence not found");
189
+ return newest;
190
+ }
191
+
192
+ async removeOwn(record: PresenceRecord) {
193
+ this.validate(record);
194
+ const file = this.presencePath(record);
195
+ let result;
196
+ try { result = await this.read(file, record.sessionId, this.token(record.endpoint)); } catch (error) {
197
+ if (error instanceof SessionPresenceError && error.code === "not_found") return;
198
+ throw error;
199
+ }
200
+ if (!result || JSON.stringify(result.record) !== JSON.stringify(record)) return;
201
+ try {
202
+ const stat = await lstat(file);
203
+ if (!stat.isSymbolicLink() && stat.dev === result.stat.dev && stat.ino === result.stat.ino) await unlink(file);
204
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") boundary(error); }
205
+ }
206
+
207
+ private async advertises(record: PresenceRecord) {
208
+ try {
209
+ const stat = await lstat(record.endpoint);
210
+ return !stat.isSymbolicLink() && stat.isSocket() && sameUser(stat);
211
+ } catch (error) {
212
+ const code = (error as NodeJS.ErrnoException).code;
213
+ if (code === "ENOENT" || code === "ELOOP") return false;
214
+ throw error;
215
+ }
216
+ }
217
+
218
+ private token(endpoint: unknown) {
219
+ if (typeof endpoint !== "string" || dirname(endpoint) !== this.paths.sockets || resolve(endpoint) !== endpoint || Buffer.byteLength(endpoint) > MAX_SOCKET_PATH_BYTES) fail("unsafe_path", "unsafe transport path");
220
+ const name = basename(endpoint), token = name.endsWith(".sock") ? name.slice(0, -5) : "";
221
+ if (!TOKEN.test(token)) fail("unsafe_path", "unsafe transport path");
222
+ return token;
223
+ }
224
+
225
+ private validate(value: unknown, expectedId?: string, expectedToken?: string): asserts value is PresenceRecord {
226
+ if (!plainRecord(value)) fail("invalid_presence", "invalid presence record");
227
+ const keys = Reflect.ownKeys(value);
228
+ if (keys.length !== 4 || !["version", "sessionId", "endpoint", "createdAt"].every((key) => keys.includes(key))) fail("invalid_presence", "invalid presence record");
229
+ if (value.version !== 1 || typeof value.sessionId !== "string" || typeof value.endpoint !== "string" || typeof value.createdAt !== "number") fail("invalid_presence", "invalid presence record");
230
+ const record: PresenceRecord = { version: 1, sessionId: value.sessionId, endpoint: value.endpoint, createdAt: value.createdAt };
231
+ if (record.version !== 1 || !Number.isSafeInteger(record.createdAt) || record.createdAt < 0) fail("invalid_presence", "invalid presence record");
232
+ const id = sessionId(record.sessionId), token = this.token(record.endpoint);
233
+ if ((expectedId && id !== expectedId) || (expectedToken && token !== expectedToken)) fail("invalid_presence", "invalid presence record");
234
+ }
235
+
236
+ private async read(file: string, id: string, token: string) {
237
+ let handle;
238
+ try {
239
+ handle = await open(file, OPEN_FLAGS);
240
+ const stat = await handle.stat();
241
+ if (!stat.isFile() || !sameUser(stat) || stat.size > MAX_RECORD_BYTES) fail("invalid_presence", "invalid presence record");
242
+ const buffer = Buffer.alloc(MAX_RECORD_BYTES + 1), { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
243
+ if (bytesRead > MAX_RECORD_BYTES) fail("invalid_presence", "invalid presence record");
244
+ let value: unknown;
245
+ try { value = JSON.parse(buffer.subarray(0, bytesRead).toString("utf8")); } catch { fail("invalid_presence", "invalid presence record"); }
246
+ this.validate(value, id, token);
247
+ return { record: Object.freeze({ ...value }), stat };
248
+ } catch (error) { boundary(error); } finally { await handle?.close().catch(() => {}); }
249
+ }
250
+ }
251
+
252
+ // An 8 KiB control-character payload expands to about 48 KiB in JSON escapes.
253
+ export const MAX_FRAME_BYTES = 65536;
254
+ type ProtocolCode = "malformed" | "oversized" | "invalid_utf8" | "invalid_frame" | "trailing_frame";
255
+ export type NotificationFrame = Readonly<{ version: 1; kind: "notification"; id: string; senderSessionId: string; recipientSessionId: string; message: string }>;
256
+ export type AckFrame = Readonly<{ version: 1; kind: "ack"; id: string; accepted: boolean; error?: "rejected" | "duplicate" | "busy" | "timeout" }>;
257
+ export type WireFrame = NotificationFrame | AckFrame;
258
+
259
+ export class TransportProtocolError extends Error {
260
+ readonly code: ProtocolCode;
261
+ constructor(code: ProtocolCode) { super("invalid transport frame"); this.code = code; this.name = "TransportProtocolError"; }
262
+ }
263
+ function protocol(code: ProtocolCode): never { throw new TransportProtocolError(code); }
264
+ const own = (value: unknown, fields: string[]) => {
265
+ if (!value || typeof value !== "object" || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) protocol("invalid_frame");
266
+ const keys = Reflect.ownKeys(value);
267
+ if (keys.length !== fields.length || !fields.every((field) => keys.includes(field))) protocol("invalid_frame");
268
+ return value as Record<string, unknown>;
269
+ };
270
+ const wireId = (value: unknown) => typeof value === "string" && SESSION.test(value) ? value : protocol("invalid_frame");
271
+ const message = (value: unknown) => {
272
+ if (typeof value !== "string" || Buffer.byteLength(value) > 8192) protocol("invalid_frame");
273
+ return value;
274
+ };
275
+ type AckError = NonNullable<AckFrame["error"]>;
276
+ const ackError = (value: unknown): value is AckError => value === "rejected" || value === "duplicate" || value === "busy" || value === "timeout";
277
+ function frame(value: unknown): WireFrame {
278
+ if (!value || typeof value !== "object") protocol("invalid_frame");
279
+ const basic = value as Record<string, unknown>;
280
+ if (basic.kind === "ack") {
281
+ const fields = basic.accepted === true ? ["version", "kind", "id", "accepted"] : ["version", "kind", "id", "accepted", "error"];
282
+ const ack = own(value, fields);
283
+ if (ack.version !== 1 || typeof ack.accepted !== "boolean") protocol("invalid_frame");
284
+ if (ack.accepted) return Object.freeze({ version: 1, kind: "ack", id: wireId(ack.id), accepted: true });
285
+ const error = ack.error;
286
+ if (!ackError(error)) protocol("invalid_frame");
287
+ return Object.freeze({ version: 1, kind: "ack", id: wireId(ack.id), accepted: false, error });
288
+ }
289
+ const notification = own(value, ["version", "kind", "id", "senderSessionId", "recipientSessionId", "message"]);
290
+ if (notification.version !== 1 || notification.kind !== "notification") protocol("invalid_frame");
291
+ return Object.freeze({ version: 1, kind: "notification", id: wireId(notification.id), senderSessionId: wireId(notification.senderSessionId), recipientSessionId: wireId(notification.recipientSessionId), message: message(notification.message) });
292
+ }
293
+ const encode = (value: unknown, kind: WireFrame["kind"]) => {
294
+ const parsed = frame(value);
295
+ if (parsed.kind !== kind) protocol("invalid_frame");
296
+ const bytes = Buffer.from(`${JSON.stringify(parsed)}\n`);
297
+ if (bytes.length > MAX_FRAME_BYTES) protocol("oversized");
298
+ return bytes;
299
+ };
300
+ export const encodeNotificationFrame = (value: NotificationFrame) => encode(value, "notification");
301
+ export const encodeAckFrame = (value: AckFrame) => encode(value, "ack");
302
+
303
+ export class FrameDecoder {
304
+ private readonly buffer = Buffer.alloc(MAX_FRAME_BYTES);
305
+ private bytes = 0;
306
+ private parsed?: WireFrame;
307
+ private finished = false;
308
+
309
+ push(chunk: Buffer) {
310
+ if (!Buffer.isBuffer(chunk)) protocol("invalid_frame");
311
+ if (!chunk.length) return;
312
+ if (this.parsed || this.finished) protocol("trailing_frame");
313
+ if (this.bytes + chunk.length > MAX_FRAME_BYTES) protocol("oversized");
314
+ chunk.copy(this.buffer, this.bytes);
315
+ this.bytes += chunk.length;
316
+ const newline = this.buffer.subarray(0, this.bytes).indexOf(10);
317
+ if (newline < 0) return;
318
+ if (newline !== this.bytes - 1) protocol("trailing_frame");
319
+ let text: string, value: unknown;
320
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(this.buffer.subarray(0, newline)); } catch { protocol("invalid_utf8"); }
321
+ try { value = JSON.parse(text!); } catch { protocol("invalid_frame"); }
322
+ this.parsed = frame(value!);
323
+ }
324
+
325
+ get complete() { return Boolean(this.parsed); }
326
+
327
+ finish(): WireFrame {
328
+ if (this.finished) protocol("trailing_frame");
329
+ this.finished = true;
330
+ if (!this.parsed) protocol("malformed");
331
+ return this.parsed;
332
+ }
333
+ }
334
+
335
+ export type ReceivedNotification = Readonly<{ id: string; senderSessionId: string; message: string }>;
336
+ type ListenerState = "idle" | "starting" | "accepting" | "active" | "failed" | "closed";
337
+ type ListenerFailure = Readonly<{ code: "io_error"; message: "listener failed" }>;
338
+ type ListenerScheduler = Readonly<{ setTimeout: (callback: () => void, delay: number) => unknown; clearTimeout: (handle: unknown) => void }>;
339
+ export type ActiveSessionListenerOptions = Readonly<{ callbackDeadlineMs?: number; scheduler?: ListenerScheduler; beforeEndpointCleanup?: () => Promise<void> }>;
340
+ const CALLBACK_DEADLINE_MS = 2000, MAX_CALLBACKS = 8, MAX_SEEN_NOTIFICATIONS = 64;
341
+
342
+ export class ActiveSessionListener {
343
+ readonly registry: SessionPresenceRegistry;
344
+ readonly sessionID: string;
345
+ readonly onNotification: (notification: ReceivedNotification) => Promise<void>;
346
+ readonly sockets = new Set<Socket>();
347
+ readonly closed: Promise<void>;
348
+ record?: PresenceRecord;
349
+ failure?: ListenerFailure;
350
+ private server?: Server;
351
+ private state: ListenerState = "idle";
352
+ private generation = 0;
353
+ private startPromise?: Promise<void>;
354
+ private closePromise?: Promise<void>;
355
+ private cleanupPromise?: Promise<void>;
356
+ private readonly settleSockets = new Map<Socket, () => void>();
357
+ private readonly seen = new Map<string, true>();
358
+ private readonly callbackDeadlineMs: number;
359
+ private readonly scheduler: ListenerScheduler;
360
+ private readonly beforeEndpointCleanup?: () => Promise<void>;
361
+ private resolveClosed!: () => void;
362
+
363
+ constructor(registry: SessionPresenceRegistry, sessionID: string, onNotification: (notification: ReceivedNotification) => Promise<void>, options: ActiveSessionListenerOptions = {}) {
364
+ const deadline = options.callbackDeadlineMs ?? CALLBACK_DEADLINE_MS;
365
+ if (!Number.isInteger(deadline) || deadline < 1 || deadline > CALLBACK_DEADLINE_MS) throw new RangeError("invalid callback deadline");
366
+ this.registry = registry;
367
+ this.sessionID = sessionId(sessionID);
368
+ this.onNotification = onNotification;
369
+ this.callbackDeadlineMs = deadline;
370
+ this.scheduler = options.scheduler ?? { setTimeout, clearTimeout };
371
+ this.beforeEndpointCleanup = options.beforeEndpointCleanup;
372
+ this.closed = new Promise<void>((resolveClosed) => { this.resolveClosed = resolveClosed; });
373
+ }
374
+
375
+ get activeConnections() { return this.sockets.size; }
376
+ get status() { return this.state; }
377
+
378
+ start(): Promise<void> {
379
+ if (this.state === "active") return Promise.resolve();
380
+ if (this.state === "closed" || this.state === "failed") return Promise.reject(new SessionPresenceError("io_error", "listener is closed"));
381
+ if (this.startPromise) return this.startPromise;
382
+ const generation = ++this.generation;
383
+ this.state = "starting";
384
+ let operation!: Promise<void>;
385
+ operation = this.doStart(generation).finally(() => { if (this.startPromise === operation) this.startPromise = undefined; });
386
+ this.startPromise = operation;
387
+ return operation;
388
+ }
389
+
390
+ close(): Promise<void> {
391
+ if (this.closePromise) return this.closePromise;
392
+ if (this.state === "closed") return Promise.resolve();
393
+ if (this.state === "failed") return this.beginClose(this.record);
394
+ this.generation++;
395
+ this.state = "closed";
396
+ return this.beginClose(this.record);
397
+ }
398
+
399
+ private beginClose(record: PresenceRecord | undefined): Promise<void> {
400
+ if (this.closePromise) return this.closePromise;
401
+ const startup = this.startPromise;
402
+ let resolve!: () => void, reject!: (error: unknown) => void;
403
+ const completion = new Promise<void>((done, failClose) => { resolve = done; reject = failClose; });
404
+ this.closePromise = completion;
405
+ void this.finishClose(startup, record).then(resolve, reject);
406
+ return completion;
407
+ }
408
+
409
+ private async finishClose(startup: Promise<void> | undefined, record: PresenceRecord | undefined) {
410
+ try {
411
+ await this.cleanup(record);
412
+ await startup?.catch(() => {});
413
+ await this.cleanup(this.record ?? record);
414
+ if (record) await this.registry.removeOwn(record).catch(() => {});
415
+ } finally { this.resolveClosed(); }
416
+ }
417
+
418
+ private ownsStartup(generation: number) {
419
+ return this.generation === generation && (this.state === "starting" || this.state === "accepting");
420
+ }
421
+
422
+ private assertStartup(generation: number) {
423
+ if (!this.ownsStartup(generation)) fail("io_error", "listener is closed");
424
+ }
425
+
426
+ private async doStart(generation: number) {
427
+ let record: PresenceRecord | undefined;
428
+ try {
429
+ record = await this.registry.record(this.sessionID);
430
+ this.assertStartup(generation);
431
+ await lstat(record.endpoint).then(() => fail("unsafe_path", "unsafe transport path")).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; });
432
+ this.assertStartup(generation);
433
+ const server = createServer((socket) => this.accept(socket));
434
+ this.server = server;
435
+ server.on("error", () => this.fail(record!));
436
+ await new Promise<void>((resolveListen, rejectListen) => { server.once("error", rejectListen); server.listen(record.endpoint, () => { server.off("error", rejectListen); resolveListen(); }); });
437
+ this.assertStartup(generation);
438
+ const stat = await lstat(record.endpoint);
439
+ this.assertStartup(generation);
440
+ if (stat.isSymbolicLink() || !stat.isSocket() || !sameUser(stat)) fail("unsafe_path", "unsafe transport path");
441
+ await chmod(record.endpoint, FILE_MODE);
442
+ this.assertStartup(generation);
443
+ this.state = "accepting";
444
+ await this.registry.publish(record);
445
+ this.assertStartup(generation);
446
+ this.record = record;
447
+ this.state = "active";
448
+ } catch (error) {
449
+ await this.cleanup(record);
450
+ if (record) await this.registry.removeOwn(record).catch(() => {});
451
+ if (this.generation === generation && this.state !== "failed" && this.state !== "closed") {
452
+ this.state = "idle";
453
+ this.cleanupPromise = undefined;
454
+ }
455
+ boundary(error);
456
+ }
457
+ }
458
+
459
+ private accept(socket: Socket) {
460
+ if ((this.state !== "accepting" && this.state !== "active") || this.sockets.size >= MAX_CALLBACKS) { socket.destroy(); return; }
461
+ this.sockets.add(socket);
462
+ const decoder = new FrameDecoder();
463
+ let settled = false, processing = false, cancelDelivery: (() => void) | undefined;
464
+ const settle = (frame?: AckFrame) => {
465
+ if (settled) return;
466
+ settled = true;
467
+ cancelDelivery?.();
468
+ cancelDelivery = undefined;
469
+ this.settleSockets.delete(socket);
470
+ this.sockets.delete(socket);
471
+ if (socket.destroyed) return;
472
+ if (frame) socket.end(encodeAckFrame(frame)); else socket.end();
473
+ };
474
+ this.settleSockets.set(socket, settle);
475
+ socket.on("data", (chunk: Buffer) => {
476
+ if (settled || processing) return;
477
+ try {
478
+ decoder.push(chunk);
479
+ if (!decoder.complete) return;
480
+ const incoming = decoder.finish();
481
+ if (incoming.kind !== "notification" || incoming.recipientSessionId !== this.sessionID) { settle({ version: 1, kind: "ack", id: incoming.id, accepted: false, error: "rejected" }); return; }
482
+ if (this.remember(incoming)) { settle({ version: 1, kind: "ack", id: incoming.id, accepted: false, error: "duplicate" }); return; }
483
+ processing = true;
484
+ this.deliver(incoming, settle, (cancel) => { cancelDelivery = cancel; });
485
+ } catch { settle(); }
486
+ });
487
+ socket.on("end", () => {
488
+ if (settled || processing) return;
489
+ try { decoder.finish(); } catch { settle(); }
490
+ });
491
+ socket.on("error", () => settle());
492
+ socket.on("close", () => settle());
493
+ }
494
+
495
+ private remember(incoming: NotificationFrame) {
496
+ const key = `${incoming.senderSessionId}\0${incoming.id}`;
497
+ if (this.seen.has(key)) {
498
+ this.seen.delete(key);
499
+ this.seen.set(key, true);
500
+ return true;
501
+ }
502
+ if (this.seen.size === MAX_SEEN_NOTIFICATIONS) this.seen.delete(this.seen.keys().next().value!);
503
+ this.seen.set(key, true);
504
+ return false;
505
+ }
506
+
507
+ private deliver(incoming: NotificationFrame, settle: (frame?: AckFrame) => void, setCancel: (cancel: () => void) => void) {
508
+ const payload = Object.freeze({ id: incoming.id, senderSessionId: incoming.senderSessionId, message: incoming.message });
509
+ let timer: unknown;
510
+ const cancel = () => {
511
+ if (timer === undefined) return;
512
+ const handle = timer;
513
+ timer = undefined;
514
+ try { this.scheduler.clearTimeout(handle); } catch { /* a test seam cannot interrupt settlement */ }
515
+ };
516
+ setCancel(cancel);
517
+ try {
518
+ timer = this.scheduler.setTimeout(() => {
519
+ timer = undefined;
520
+ settle({ version: 1, kind: "ack", id: incoming.id, accepted: false, error: "timeout" });
521
+ }, this.callbackDeadlineMs);
522
+ } catch { settle({ version: 1, kind: "ack", id: incoming.id, accepted: false, error: "timeout" }); return; }
523
+ void Promise.resolve().then(() => this.onNotification(payload)).then(
524
+ () => { cancel(); settle({ version: 1, kind: "ack", id: incoming.id, accepted: true }); },
525
+ () => { cancel(); settle({ version: 1, kind: "ack", id: incoming.id, accepted: false, error: "rejected" }); },
526
+ );
527
+ }
528
+
529
+ private fail(record: PresenceRecord) {
530
+ if (this.failure || this.state === "closed") return;
531
+ this.generation++;
532
+ this.failure = Object.freeze({ code: "io_error", message: "listener failed" });
533
+ this.state = "failed";
534
+ void this.beginClose(this.record ?? record);
535
+ }
536
+
537
+ private cleanup(record?: PresenceRecord) {
538
+ if (this.cleanupPromise) return this.cleanupPromise;
539
+ const server = this.server;
540
+ this.cleanupPromise = (async () => {
541
+ const sockets = [...this.sockets];
542
+ for (const settle of this.settleSockets.values()) settle();
543
+ for (const socket of sockets) socket.destroy();
544
+ if (server) await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
545
+ if (record) {
546
+ await this.registry.removeOwn(record).catch(() => {});
547
+ try { await this.beforeEndpointCleanup?.(); } catch { /* a test seam cannot leak through cleanup */ }
548
+ }
549
+ if (this.server === server) this.server = undefined;
550
+ })();
551
+ return this.cleanupPromise;
552
+ }
553
+ }
554
+
555
+ type ClientCode = "self" | "busy" | "closed" | "aborted" | "stale" | "not_found" | "connect_timeout" | "ack_timeout" | "remote_rejected" | "invalid_ack" | "io_error";
556
+ type ClientScheduler = Readonly<{ setTimeout: (callback: () => void, delay: number) => unknown; clearTimeout: (handle: unknown) => void }>;
557
+ type ClientSocketFactory = (endpoint: string) => Socket;
558
+ export type ActiveSessionClientOptions = Readonly<{ signal?: AbortSignal; id?: string; expectedActivation?: PresenceRecord; beforeConnect?: () => boolean | Promise<boolean> }>;
559
+ export type ActiveSessionClientConfig = Readonly<{ connectDeadlineMs?: number; ackDeadlineMs?: number; scheduler?: ClientScheduler; connect?: ClientSocketFactory }>;
560
+ export type SentNotification = Readonly<{ id: string; accepted: true }>;
561
+
562
+ export class ActiveSessionClientError extends Error {
563
+ readonly code: ClientCode;
564
+ constructor(code: ClientCode) { super("outbound session notification failed"); this.name = "ActiveSessionClientError"; this.code = code; }
565
+ }
566
+ const clientError = (code: ClientCode) => new ActiveSessionClientError(code);
567
+ const sameActivation = (left: PresenceRecord, right: PresenceRecord) => left.version === right.version && left.sessionId === right.sessionId && left.endpoint === right.endpoint && left.createdAt === right.createdAt;
568
+ const CLIENT_CONNECT_DEADLINE_MS = 500, CLIENT_ACK_DEADLINE_MS = 2000, MAX_OUTBOUND = 8;
569
+ const clientDeadline = (value: number | undefined, fallback: number, maximum: number) => {
570
+ const deadline = value ?? fallback;
571
+ if (!Number.isInteger(deadline) || deadline < 1 || deadline > maximum) throw new RangeError("invalid outbound deadline");
572
+ return deadline;
573
+ };
574
+ type PendingOutbound = { settled: boolean; socket?: Socket; connectTimer?: unknown; ackTimer?: unknown; abort?: () => void; settle: (error?: ActiveSessionClientError, result?: SentNotification) => void };
575
+
576
+ export class ActiveSessionClient {
577
+ readonly registry: SessionPresenceRegistry;
578
+ readonly senderSessionId: string;
579
+ private readonly scheduler: ClientScheduler;
580
+ private readonly connectDeadlineMs: number;
581
+ private readonly ackDeadlineMs: number;
582
+ private readonly connect: ClientSocketFactory;
583
+ private readonly pending = new Set<PendingOutbound>();
584
+ private stopped = false;
585
+
586
+ constructor(registry: SessionPresenceRegistry, senderSessionId: string, config: ActiveSessionClientConfig = {}) {
587
+ this.registry = registry;
588
+ this.senderSessionId = sessionId(senderSessionId);
589
+ this.scheduler = config.scheduler ?? { setTimeout, clearTimeout };
590
+ this.connectDeadlineMs = clientDeadline(config.connectDeadlineMs, CLIENT_CONNECT_DEADLINE_MS, CLIENT_CONNECT_DEADLINE_MS);
591
+ this.ackDeadlineMs = clientDeadline(config.ackDeadlineMs, CLIENT_ACK_DEADLINE_MS, CLIENT_ACK_DEADLINE_MS);
592
+ this.connect = config.connect ?? createConnection;
593
+ }
594
+
595
+ get pendingCount() { return this.pending.size; }
596
+ get closed() { return this.stopped; }
597
+
598
+ sendNotification(recipientSessionId: string, text: string, options: ActiveSessionClientOptions = {}): Promise<SentNotification> {
599
+ let recipient: string, id: string;
600
+ try { recipient = sessionId(recipientSessionId); id = options.id === undefined ? randomBytes(16).toString("hex") : sessionId(options.id); }
601
+ catch (error) { return Promise.reject(error); }
602
+ if (recipient === this.senderSessionId) return Promise.reject(clientError("self"));
603
+ if (this.stopped) return Promise.reject(clientError("closed"));
604
+ if (this.pending.size >= MAX_OUTBOUND) return Promise.reject(clientError("busy"));
605
+ if (options.signal?.aborted) return Promise.reject(clientError("aborted"));
606
+ let pending!: PendingOutbound;
607
+ const operation = new Promise<SentNotification>((resolve, reject) => {
608
+ pending = { settled: false, settle: (error, result) => {
609
+ if (pending.settled) return;
610
+ pending.settled = true;
611
+ for (const timer of [pending.connectTimer, pending.ackTimer]) if (timer !== undefined) try { this.scheduler.clearTimeout(timer); } catch { /* test seams cannot disrupt cleanup */ }
612
+ pending.connectTimer = pending.ackTimer = undefined;
613
+ if (pending.abort) options.signal?.removeEventListener("abort", pending.abort);
614
+ this.pending.delete(pending);
615
+ pending.socket?.destroy();
616
+ if (error) reject(error); else resolve(result!);
617
+ } };
618
+ this.pending.add(pending);
619
+ pending.abort = () => pending.settle(clientError(this.stopped ? "closed" : "aborted"));
620
+ options.signal?.addEventListener("abort", pending.abort, { once: true });
621
+ void this.begin(pending, recipient, text, id, options.expectedActivation, options.beforeConnect);
622
+ });
623
+ return operation;
624
+ }
625
+
626
+ close() {
627
+ if (this.stopped) return;
628
+ this.stopped = true;
629
+ for (const pending of [...this.pending]) pending.settle(clientError("closed"));
630
+ }
631
+
632
+ private async begin(pending: PendingOutbound, recipient: string, text: string, id: string, expectedActivation: PresenceRecord | undefined, beforeConnect: (() => boolean | Promise<boolean>) | undefined) {
633
+ let record: PresenceRecord;
634
+ try { record = await this.registry.resolve(recipient); }
635
+ catch (error) {
636
+ pending.settle(clientError(expectedActivation !== undefined ? "stale" : error instanceof SessionPresenceError && error.code === "not_found" ? "not_found" : "io_error"));
637
+ return;
638
+ }
639
+ if (pending.settled || this.stopped) { pending.settle(clientError("closed")); return; }
640
+ if (expectedActivation !== undefined && !sameActivation(record, expectedActivation)) { pending.settle(clientError("stale")); return; }
641
+ try {
642
+ if (beforeConnect !== undefined && !await beforeConnect()) { pending.settle(clientError("stale")); return; }
643
+ } catch { pending.settle(clientError("stale")); return; }
644
+ if (pending.settled || this.stopped) { pending.settle(clientError("closed")); return; }
645
+ let bytes: Buffer;
646
+ try { bytes = encodeNotificationFrame(Object.freeze({ version: 1, kind: "notification", id, senderSessionId: this.senderSessionId, recipientSessionId: recipient, message: text })); }
647
+ catch { pending.settle(clientError("io_error")); return; }
648
+ let socket: Socket;
649
+ try { socket = this.connect(record.endpoint); }
650
+ catch { pending.settle(clientError("io_error")); return; }
651
+ pending.socket = socket;
652
+ const decoder = new FrameDecoder();
653
+ let connected = false, staleCleanup: Promise<void> | undefined;
654
+ const cancelConnect = () => {
655
+ if (pending.connectTimer === undefined) return;
656
+ try { this.scheduler.clearTimeout(pending.connectTimer); } catch {}
657
+ pending.connectTimer = undefined;
658
+ };
659
+ const stale = (error: NodeJS.ErrnoException) => {
660
+ if (pending.settled || staleCleanup) return;
661
+ if (!connected && (error.code === "ENOENT" || error.code === "ECONNREFUSED")) {
662
+ cancelConnect();
663
+ staleCleanup = this.registry.removeOwn(record).catch(() => {}).then(() => { if (!pending.settled) pending.settle(clientError("io_error")); });
664
+ socket.destroy();
665
+ } else pending.settle(clientError("io_error"));
666
+ };
667
+ socket.on("error", stale);
668
+ socket.once("connect", () => {
669
+ if (pending.settled || staleCleanup) return;
670
+ connected = true;
671
+ cancelConnect();
672
+ try { socket.write(bytes); } catch { pending.settle(clientError("io_error")); return; }
673
+ try { pending.ackTimer = this.scheduler.setTimeout(() => pending.settle(clientError("ack_timeout")), this.ackDeadlineMs); }
674
+ catch { pending.settle(clientError("ack_timeout")); }
675
+ });
676
+ const reply = () => {
677
+ if (pending.settled || staleCleanup) return;
678
+ try {
679
+ const ack = decoder.finish();
680
+ if (ack.kind !== "ack" || ack.id !== id) pending.settle(clientError("invalid_ack"));
681
+ else if (!ack.accepted) pending.settle(clientError("remote_rejected"));
682
+ else pending.settle(undefined, Object.freeze({ id, accepted: true }));
683
+ } catch { pending.settle(clientError("invalid_ack")); }
684
+ };
685
+ socket.on("data", (chunk: Buffer) => { if (!pending.settled && !staleCleanup) try { decoder.push(chunk); } catch { pending.settle(clientError("invalid_ack")); } });
686
+ socket.on("end", reply);
687
+ socket.on("close", () => { if (!pending.settled && !staleCleanup) decoder.complete ? reply() : pending.settle(clientError("io_error")); });
688
+ try { pending.connectTimer = this.scheduler.setTimeout(() => pending.settle(clientError("connect_timeout")), this.connectDeadlineMs); }
689
+ catch { pending.settle(clientError("connect_timeout")); }
690
+ }
691
+ }